@exxatdesignux/ui 0.2.16 → 0.2.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
- 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-kpi-flat-band/SKILL.md +38 -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 +19 -0
- package/consumer-extras/patterns/data-views-pattern.md +2 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -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 +3 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +27 -17
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +151 -14
- package/template/app/layout.tsx +43 -5
- package/template/components/app-sidebar.tsx +68 -33
- 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 +4 -0
- package/template/components/data-table/index.tsx +99 -91
- 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 +276 -100
- 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/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +168 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +122 -62
- 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} +2 -2
- 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 +19 -133
- 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} +42 -66
- package/template/components/product-switcher.tsx +24 -7
- package/template/components/product-wordmark.tsx +282 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +105 -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 +19 -6
- 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/sidebar-shell.tsx +2 -1
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +4 -0
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +17 -14
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +8 -3
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +70 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- package/template/docs/data-views-pattern.md +2 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +52 -0
- package/template/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/data-list-view.ts +6 -0
- 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/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +521 -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
|
+
* Properties drawer + multi-view composition (table / list / board / …).
|
|
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,8 @@ import {
|
|
|
48
57
|
DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
|
|
49
58
|
type DataListDisplayOptions,
|
|
50
59
|
} from "@/lib/data-list-display-options"
|
|
51
|
-
import {
|
|
52
|
-
applyLifecyclePersisted,
|
|
53
|
-
loadLifecycleFromStorage,
|
|
54
|
-
scheduleLifecycleSave,
|
|
55
|
-
serializeLifecycle,
|
|
56
|
-
type TableStatePersistSlice,
|
|
57
|
-
} from "@/lib/data-list-persistence"
|
|
58
60
|
import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
|
|
59
|
-
import { StatusBadge } from "@/components/
|
|
61
|
+
import { StatusBadge } from "@/components/placements-table-cells"
|
|
60
62
|
import { columnsToFilterFields } from "@/components/placements-table-columns"
|
|
61
63
|
import { DataTable, DataTableToolbar } from "@/components/data-table"
|
|
62
64
|
import { CountSyncer, PaginationBar } from "@/components/data-table/pagination"
|
|
@@ -127,9 +129,14 @@ function DataListBoardShell({
|
|
|
127
129
|
displayOptions: DataListDisplayOptions
|
|
128
130
|
onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
|
|
129
131
|
}) {
|
|
132
|
+
// Store the "open properties drawer" callback on a stable ref so the parent
|
|
133
|
+
// imperative handle can invoke it without re-rendering the whole table.
|
|
134
|
+
// `state` is freshly returned each render by useTableState; only the React
|
|
135
|
+
// setter is stable and needed here.
|
|
130
136
|
React.useEffect(() => {
|
|
131
137
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
132
|
-
|
|
138
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
139
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
133
140
|
|
|
134
141
|
const boardColumnMenu: PlacementsBoardColumnMenu = React.useMemo(
|
|
135
142
|
() => ({
|
|
@@ -248,9 +255,11 @@ function DataListListShell({
|
|
|
248
255
|
listRows: Placement[]
|
|
249
256
|
emptyTableCopy: string
|
|
250
257
|
}) {
|
|
258
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
251
259
|
React.useEffect(() => {
|
|
252
260
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
253
|
-
|
|
261
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
262
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
254
263
|
|
|
255
264
|
return (
|
|
256
265
|
<>
|
|
@@ -336,9 +345,11 @@ function DataListDashboardShell({
|
|
|
336
345
|
displayOptions: DataListDisplayOptions
|
|
337
346
|
onDisplayOptionsChange: (patch: Partial<DataListDisplayOptions>) => void
|
|
338
347
|
}) {
|
|
348
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
339
349
|
React.useEffect(() => {
|
|
340
350
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
341
|
-
|
|
351
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
352
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
342
353
|
|
|
343
354
|
const dashboardKpi = React.useMemo(
|
|
344
355
|
() => ({
|
|
@@ -526,7 +537,7 @@ function PlacementFolderTile({
|
|
|
526
537
|
conditionalRules?: ConditionalRule[]
|
|
527
538
|
onClick: () => void
|
|
528
539
|
}) {
|
|
529
|
-
const ruleBg = getConditionalRowBackground(row, conditionalRules)
|
|
540
|
+
const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
|
|
530
541
|
const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
|
|
531
542
|
const showStatus = isBoardFieldActive("status", tab, hiddenColKeys, boardColumns)
|
|
532
543
|
const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
|
|
@@ -627,9 +638,11 @@ function DataListFolderShell({
|
|
|
627
638
|
}) {
|
|
628
639
|
const router = useRouter()
|
|
629
640
|
|
|
641
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
630
642
|
React.useEffect(() => {
|
|
631
643
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
632
|
-
|
|
644
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
645
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
633
646
|
|
|
634
647
|
const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
|
|
635
648
|
|
|
@@ -735,9 +748,11 @@ function DataListTreeShell({
|
|
|
735
748
|
const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
|
|
736
749
|
const [selectedId, setSelectedId] = React.useState<number | null>(() => listRows[0]?.id ?? null)
|
|
737
750
|
|
|
751
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
738
752
|
React.useEffect(() => {
|
|
739
753
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
740
|
-
|
|
754
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
755
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
741
756
|
|
|
742
757
|
React.useEffect(() => {
|
|
743
758
|
if (selectedId == null) {
|
|
@@ -861,7 +876,7 @@ function PlacementFinderListRow({
|
|
|
861
876
|
boardColumns: ColumnDef<Placement>[]
|
|
862
877
|
conditionalRules?: ConditionalRule[]
|
|
863
878
|
}) {
|
|
864
|
-
const ruleBg = getConditionalRowBackground(row, conditionalRules)
|
|
879
|
+
const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
|
|
865
880
|
const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
|
|
866
881
|
const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
|
|
867
882
|
const name = showStudent ? row.student : `Placement ${row.id}`
|
|
@@ -941,7 +956,7 @@ function PlacementFinderDetail({
|
|
|
941
956
|
<i className="fa-light fa-envelope text-[10px]" aria-hidden="true" /> Email
|
|
942
957
|
</dt>
|
|
943
958
|
<dd className="text-[13px]">
|
|
944
|
-
<a href={
|
|
959
|
+
<a href={mailtoHref(row.email)} className="text-interactive-foreground hover:underline">{row.email}</a>
|
|
945
960
|
</dd>
|
|
946
961
|
</div>
|
|
947
962
|
)}
|
|
@@ -1099,9 +1114,11 @@ function DataListPanelShell({
|
|
|
1099
1114
|
panelRenderListRow?: (row: Placement, isSelected: boolean) => React.ReactNode
|
|
1100
1115
|
panelRenderDetail?: (row: Placement) => React.ReactNode
|
|
1101
1116
|
}) {
|
|
1117
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
1102
1118
|
React.useEffect(() => {
|
|
1103
1119
|
openDrawerRef.current = () => state.setSheetOpen(true)
|
|
1104
|
-
|
|
1120
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1121
|
+
}, [openDrawerRef, state.setSheetOpen])
|
|
1105
1122
|
|
|
1106
1123
|
const boardColumns = state.displayCols.filter(c => c.key !== "select" && c.key !== "actions")
|
|
1107
1124
|
const groups = React.useMemo(
|
|
@@ -1187,7 +1204,7 @@ function DataListPanelShell({
|
|
|
1187
1204
|
// Props
|
|
1188
1205
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1189
1206
|
|
|
1190
|
-
export interface
|
|
1207
|
+
export interface PlacementsTableProps {
|
|
1191
1208
|
view?: DataListViewType
|
|
1192
1209
|
onViewChange?: (view: DataListViewType) => void
|
|
1193
1210
|
/** Demo row segment: drives filtered rows + column set (`all` | `upcoming` | `ongoing` | `completed`). */
|
|
@@ -1210,13 +1227,13 @@ export interface DataListTableProps {
|
|
|
1210
1227
|
}
|
|
1211
1228
|
|
|
1212
1229
|
/** Imperative handle — open Table Properties (table view only). */
|
|
1213
|
-
export type
|
|
1230
|
+
export type PlacementsTableHandle = OpenTablePropertiesHandle
|
|
1214
1231
|
|
|
1215
1232
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1216
1233
|
// Main component
|
|
1217
1234
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1218
1235
|
|
|
1219
|
-
export const
|
|
1236
|
+
export const PlacementsTable = React.forwardRef<PlacementsTableHandle, PlacementsTableProps>(function PlacementsTable({
|
|
1220
1237
|
view = "table",
|
|
1221
1238
|
onViewChange,
|
|
1222
1239
|
lifecycleTabId = "all",
|
|
@@ -1318,52 +1335,11 @@ export const DataListTable = React.forwardRef<DataListTableHandle, DataListTable
|
|
|
1318
1335
|
|
|
1319
1336
|
const tableState = useTableState(tableData, columns, { key: "student", dir: "asc" }, paginationOverride)
|
|
1320
1337
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
React.useLayoutEffect(() => {
|
|
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
|
-
|
|
1338
|
+
// Stable "open properties drawer" callback ref — see top of this file.
|
|
1333
1339
|
React.useEffect(() => {
|
|
1334
1340
|
openDrawerRef.current = () => tableState.setSheetOpen(true)
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
React.useEffect(() => {
|
|
1338
|
-
const payload = serializeLifecycle(tableState as unknown as TableStatePersistSlice, {
|
|
1339
|
-
conditionalRules,
|
|
1340
|
-
pagination,
|
|
1341
|
-
paginationPage: safePage,
|
|
1342
|
-
paginationPageSize,
|
|
1343
|
-
})
|
|
1344
|
-
scheduleLifecycleSave(lifecycleTabId, payload)
|
|
1345
|
-
}, [
|
|
1346
|
-
lifecycleTabId,
|
|
1347
|
-
tableState.sortRules,
|
|
1348
|
-
tableState.search,
|
|
1349
|
-
tableState.activeFilters,
|
|
1350
|
-
tableState.filterConnectors,
|
|
1351
|
-
tableState.groupBy,
|
|
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
|
-
])
|
|
1341
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1342
|
+
}, [openDrawerRef, tableState.setSheetOpen])
|
|
1367
1343
|
|
|
1368
1344
|
function buildToolbarSlot(
|
|
1369
1345
|
s: ReturnType<typeof useTableState<Placement>>,
|
|
@@ -1658,7 +1634,7 @@ export const DataListTable = React.forwardRef<DataListTableHandle, DataListTable
|
|
|
1658
1634
|
return <DataTable<Placement> key={lifecycleTabId} {...tableProps} />
|
|
1659
1635
|
})
|
|
1660
1636
|
|
|
1661
|
-
|
|
1637
|
+
PlacementsTable.displayName = "PlacementsTable"
|
|
1662
1638
|
|
|
1663
1639
|
|
|
1664
1640
|
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,6 +64,7 @@ export function ProductSwitcher() {
|
|
|
51
64
|
suppressHydrationWarning
|
|
52
65
|
>
|
|
53
66
|
{iconRail ? (
|
|
67
|
+
// Collapsed icon-rail product mark — same frame as school avatar.
|
|
54
68
|
<span className="flex size-8 shrink-0 items-center justify-center">
|
|
55
69
|
<ExxatProductMark product={current.id} className="size-7" />
|
|
56
70
|
</span>
|
|
@@ -63,7 +77,7 @@ export function ProductSwitcher() {
|
|
|
63
77
|
<ExxatProductLogo
|
|
64
78
|
product={current.id}
|
|
65
79
|
variant="mutedSuffix"
|
|
66
|
-
className="
|
|
80
|
+
className="w-auto max-w-[min(100%,260px)]"
|
|
67
81
|
/>
|
|
68
82
|
</span>
|
|
69
83
|
<i
|
|
@@ -84,7 +98,7 @@ export function ProductSwitcher() {
|
|
|
84
98
|
Switch product
|
|
85
99
|
</DropdownMenuLabel>
|
|
86
100
|
<DropdownMenuSeparator />
|
|
87
|
-
{
|
|
101
|
+
{products.map(p => (
|
|
88
102
|
<DropdownMenuItem
|
|
89
103
|
key={p.id}
|
|
90
104
|
onClick={() => setProduct(p.id)}
|
|
@@ -94,7 +108,10 @@ export function ProductSwitcher() {
|
|
|
94
108
|
<ExxatProductLogo
|
|
95
109
|
product={p.id}
|
|
96
110
|
variant="mutedSuffix"
|
|
97
|
-
|
|
111
|
+
// h-9 matches the sidebar trigger so the mark renders at the
|
|
112
|
+
// same 32 px footprint in both contexts. Dropdown rows
|
|
113
|
+
// accommodate the bump via `py-2` on `DropdownMenuItem`.
|
|
114
|
+
className="h-9 w-auto shrink-0 max-w-[min(100%,240px)]"
|
|
98
115
|
/>
|
|
99
116
|
{p.id === product && (
|
|
100
117
|
<i
|
|
@@ -0,0 +1,282 @@
|
|
|
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"` (sidebar / switcher): prefix recedes in dark mode;
|
|
20
|
+
* suffix always uses `wordmarkColor` (Exxat pink) for brand recognition.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import * as React from "react"
|
|
24
|
+
import { cn } from "@/lib/utils"
|
|
25
|
+
import type { ProductBrandConfig } from "@/lib/product-brand"
|
|
26
|
+
|
|
27
|
+
export type ProductWordmarkVariant = "default" | "mutedSuffix"
|
|
28
|
+
|
|
29
|
+
export interface ProductWordmarkProps {
|
|
30
|
+
config: ProductBrandConfig
|
|
31
|
+
variant?: ProductWordmarkVariant
|
|
32
|
+
className?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ── Wordmark ──────────────────────────────────────────────────────────────── */
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Inline product wordmark. Sizing is height-driven — the parent sets the
|
|
39
|
+
* height (e.g. `className="h-7"`) and the text scales via `text-[...]`
|
|
40
|
+
* derived from `--wordmark-size` (set inline from the rendered font-size).
|
|
41
|
+
*
|
|
42
|
+
* Use `aria-hidden` because the wordmark is decorative — pair it with an
|
|
43
|
+
* `aria-label` on the trigger/link (see {@link productBrandLabel}).
|
|
44
|
+
*/
|
|
45
|
+
export function ProductWordmark({
|
|
46
|
+
config,
|
|
47
|
+
variant = "default",
|
|
48
|
+
className,
|
|
49
|
+
}: ProductWordmarkProps) {
|
|
50
|
+
const prefix = config.prefix ?? "Exxat"
|
|
51
|
+
const { suffix, brandColor, wordmarkColor } = config
|
|
52
|
+
const suffixColor = wordmarkColor ?? brandColor
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<span
|
|
56
|
+
aria-hidden="true"
|
|
57
|
+
data-product-wordmark
|
|
58
|
+
data-product-id={config.id}
|
|
59
|
+
className={cn(
|
|
60
|
+
// Inline-flex so it sits on a text baseline; whitespace-nowrap so the
|
|
61
|
+
// suffix never wraps under the prefix at narrow widths.
|
|
62
|
+
"inline-flex items-baseline whitespace-nowrap leading-none select-none",
|
|
63
|
+
// Sized **relative to the inherited font-size** so the wordmark always
|
|
64
|
+
// dominates whatever surface hosts it. The parent (`ProductLogo` /
|
|
65
|
+
// `ExxatProductLogo`) pins `text-base` (16 px) → this resolves to
|
|
66
|
+
// ~28 px wordmark text (~20 px cap), matching the cap-to-render-height
|
|
67
|
+
// ratio in the standalone Exxat brand assets (~0.72; image dims 446×124
|
|
68
|
+
// with ~89 px caps). Slight (~1 px) overflow against a 28 px parent
|
|
69
|
+
// height is acceptable — sidebar / switcher slots use `overflow-visible`.
|
|
70
|
+
"text-[1.78em] tracking-tight",
|
|
71
|
+
// Vertically centre the **cap mid-line** on the parent's mid-line.
|
|
72
|
+
// Without this nudge the cap sits ~9 % of font-size above span centre
|
|
73
|
+
// because Inter / Ivy Presto baseline metrics put glyphs in the upper
|
|
74
|
+
// portion of the line box. 0.09 em moves the cap centre down by that
|
|
75
|
+
// exact offset so it shares an axis with the mark centre.
|
|
76
|
+
"translate-y-[0.09em]",
|
|
77
|
+
className,
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
<span
|
|
81
|
+
className={cn(
|
|
82
|
+
"font-sans font-extrabold",
|
|
83
|
+
// Neutral wordmark prefix: deep slate on light, soft cool grey on dark.
|
|
84
|
+
"text-[#273441] dark:text-[#A8B2BA]",
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{prefix}
|
|
88
|
+
</span>
|
|
89
|
+
<span
|
|
90
|
+
data-product-wordmark-suffix
|
|
91
|
+
className={cn(
|
|
92
|
+
// Per the official Exxat brand spec (Figma):
|
|
93
|
+
// font-family: IvyPresto Text
|
|
94
|
+
// weight: SemiBold (600) — NOT Bold / ExtraBold
|
|
95
|
+
// tracking: -3% — overrides parent `tracking-tight`
|
|
96
|
+
// line-height: auto — inherited (parent sets `leading-none`)
|
|
97
|
+
// IvyPresto's Bodoni-lineage SemiBold already has the thick verticals
|
|
98
|
+
// that read as a logo; pushing to 700/800 makes the letterforms
|
|
99
|
+
// visually heavier than the brand asset.
|
|
100
|
+
"ms-[0.18em] font-semibold tracking-[-0.03em]",
|
|
101
|
+
)}
|
|
102
|
+
style={{
|
|
103
|
+
// Ivy Presto Text from Adobe Fonts. Upright (NOT italic) — matches
|
|
104
|
+
// the official Exxat wordmark. Fallback chain ends in `serif` so
|
|
105
|
+
// FOUT still renders a serif that reads as a logo rather than Inter.
|
|
106
|
+
fontFamily: "var(--font-heading), 'ivypresto-text', Georgia, serif",
|
|
107
|
+
// `wordmarkColor` (Exxat pink) in light and dark — never muted to grey.
|
|
108
|
+
color: suffixColor,
|
|
109
|
+
}}
|
|
110
|
+
>
|
|
111
|
+
{suffix}
|
|
112
|
+
</span>
|
|
113
|
+
</span>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* ── Circular mark ─────────────────────────────────────────────────────────── */
|
|
118
|
+
|
|
119
|
+
export interface ProductMarkProps {
|
|
120
|
+
config: ProductBrandConfig
|
|
121
|
+
className?: string
|
|
122
|
+
cutoutColor?: string
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Generate a stable id suffix for SVG gradient defs so multiple marks on the
|
|
127
|
+
* same page never collide. Strip colons because IDs in HTML/SVG can't legally
|
|
128
|
+
* include them (Radix uses `:`-style IDs by default).
|
|
129
|
+
*/
|
|
130
|
+
function useMarkGradientId(brandId: string) {
|
|
131
|
+
const raw = React.useId().replace(/:/g, "")
|
|
132
|
+
return `pmk-${brandId.replace(/[^a-z0-9-]/gi, "")}-${raw}`
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Defer SVG `<defs>` (gradient refs) until after mount so server HTML matches
|
|
137
|
+
* the first client paint. `useId()` returns different suffixes in SSR vs CSR
|
|
138
|
+
* trees that conditionally mount the sidebar.
|
|
139
|
+
*/
|
|
140
|
+
function useBrowserPaintReady() {
|
|
141
|
+
const [ready, setReady] = React.useState(false)
|
|
142
|
+
React.useLayoutEffect(() => {
|
|
143
|
+
setReady(true)
|
|
144
|
+
}, [])
|
|
145
|
+
return ready
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Recoloured Exxat "E" mark. Same geometry as the canonical brand mark, so
|
|
150
|
+
* existing pixel-aligned layouts (sidebar header, dropdown rows) don't shift.
|
|
151
|
+
*
|
|
152
|
+
* Fills:
|
|
153
|
+
* - Outer circle: `markGradient` if provided, else flat `brandColor`.
|
|
154
|
+
* - Inner shadow plate: `markShadow` (defaults to `brandColor`).
|
|
155
|
+
* - Cut-out "E" strokes: always white in product chrome (sidebar / switcher).
|
|
156
|
+
*/
|
|
157
|
+
export function ProductMark({ config, className, cutoutColor = "white" }: ProductMarkProps) {
|
|
158
|
+
const ready = useBrowserPaintReady()
|
|
159
|
+
const gradId = useMarkGradientId(config.id)
|
|
160
|
+
const [from, to] = config.markGradient ?? [config.brandColor, config.brandColor]
|
|
161
|
+
const shadow = config.markShadow ?? config.brandColor
|
|
162
|
+
|
|
163
|
+
// No size default. Callers MUST set explicit dimensions (`size-7`, `h-full
|
|
164
|
+
// w-auto`, etc.). A `size-*` default here loses to a downstream `h-full /
|
|
165
|
+
// w-auto` only when `tailwind-merge` correctly identifies `size-7` as a
|
|
166
|
+
// `w-7 + h-7` shorthand — fragile across versions and causes the mark to
|
|
167
|
+
// render at the default size instead of the parent's height (see
|
|
168
|
+
// `ExxatProductLogo` h-full mark → 32 px in h-8 parent). Aspect-square stays
|
|
169
|
+
// so the mark renders as a circle when only one of width/height is set.
|
|
170
|
+
const sharedClass = cn(
|
|
171
|
+
"box-border block aspect-square shrink-0 flex-none object-contain",
|
|
172
|
+
className,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
if (!ready) {
|
|
176
|
+
return (
|
|
177
|
+
<svg
|
|
178
|
+
viewBox="0 8.25 147 147"
|
|
179
|
+
preserveAspectRatio="xMidYMid meet"
|
|
180
|
+
fill="none"
|
|
181
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
182
|
+
data-product-mark
|
|
183
|
+
data-product-logo-mark
|
|
184
|
+
data-product-id={config.id}
|
|
185
|
+
className={sharedClass}
|
|
186
|
+
aria-hidden="true"
|
|
187
|
+
suppressHydrationWarning
|
|
188
|
+
/>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<svg
|
|
194
|
+
viewBox="0 8.25 147 147"
|
|
195
|
+
preserveAspectRatio="xMidYMid meet"
|
|
196
|
+
fill="none"
|
|
197
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
198
|
+
data-product-mark
|
|
199
|
+
data-product-logo-mark
|
|
200
|
+
data-product-id={config.id}
|
|
201
|
+
className={sharedClass}
|
|
202
|
+
aria-hidden="true"
|
|
203
|
+
suppressHydrationWarning
|
|
204
|
+
>
|
|
205
|
+
<path
|
|
206
|
+
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"
|
|
207
|
+
fill={`url(#${gradId})`}
|
|
208
|
+
/>
|
|
209
|
+
<path
|
|
210
|
+
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"
|
|
211
|
+
fill={shadow}
|
|
212
|
+
/>
|
|
213
|
+
<path d="M102.474 116.355H50.5576L58.6764 102.302H102.474V116.355Z" fill={cutoutColor} />
|
|
214
|
+
<path d="M102.474 60.1303H58.6764L50.5576 46.0771H102.474V60.1303Z" fill={cutoutColor} />
|
|
215
|
+
<path d="M102.474 88.2368H66.7949L70.8483 81.2102L66.7949 74.1836H102.474V88.2368Z" fill={cutoutColor} />
|
|
216
|
+
<path d="M39.2227 74.1835H66.795L58.6762 60.1304H39.2227V74.1835Z" fill={cutoutColor} />
|
|
217
|
+
<path d="M39.2227 102.302H58.6762L66.795 88.2368H39.2227V102.302Z" fill={cutoutColor} />
|
|
218
|
+
<defs>
|
|
219
|
+
<linearGradient
|
|
220
|
+
id={gradId}
|
|
221
|
+
x1="28.3733"
|
|
222
|
+
y1="134.255"
|
|
223
|
+
x2="117.195"
|
|
224
|
+
y2="30.9074"
|
|
225
|
+
gradientUnits="userSpaceOnUse"
|
|
226
|
+
>
|
|
227
|
+
<stop offset="0" stopColor={from} />
|
|
228
|
+
<stop offset="1" stopColor={to} />
|
|
229
|
+
</linearGradient>
|
|
230
|
+
</defs>
|
|
231
|
+
</svg>
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/* ── Mark + wordmark combo ─────────────────────────────────────────────────── */
|
|
236
|
+
|
|
237
|
+
export interface ProductLogoProps {
|
|
238
|
+
config: ProductBrandConfig
|
|
239
|
+
variant?: ProductWordmarkVariant
|
|
240
|
+
/** Render only the mark (omit the wordmark). */
|
|
241
|
+
markOnly?: boolean
|
|
242
|
+
/** Render only the wordmark (omit the mark). */
|
|
243
|
+
wordmarkOnly?: boolean
|
|
244
|
+
className?: string
|
|
245
|
+
/** Class applied to the inner mark — useful for sizing it independently. */
|
|
246
|
+
markClassName?: string
|
|
247
|
+
/** Class applied to the inner wordmark. */
|
|
248
|
+
wordmarkClassName?: string
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Mark + wordmark composed inline. Pass `markOnly` for collapsed sidebar /
|
|
253
|
+
* favicon-like contexts, or `wordmarkOnly` if you've already rendered the
|
|
254
|
+
* mark separately (e.g. switcher dropdown rows).
|
|
255
|
+
*/
|
|
256
|
+
export function ProductLogo({
|
|
257
|
+
config,
|
|
258
|
+
variant = "default",
|
|
259
|
+
markOnly = false,
|
|
260
|
+
wordmarkOnly = false,
|
|
261
|
+
className,
|
|
262
|
+
markClassName,
|
|
263
|
+
wordmarkClassName,
|
|
264
|
+
}: ProductLogoProps) {
|
|
265
|
+
if (markOnly) {
|
|
266
|
+
return <ProductMark config={config} className={cn(className, markClassName)} />
|
|
267
|
+
}
|
|
268
|
+
if (wordmarkOnly) {
|
|
269
|
+
return <ProductWordmark config={config} variant={variant} className={cn(className, wordmarkClassName)} />
|
|
270
|
+
}
|
|
271
|
+
return (
|
|
272
|
+
<span
|
|
273
|
+
aria-hidden="true"
|
|
274
|
+
data-product-logo
|
|
275
|
+
data-product-id={config.id}
|
|
276
|
+
className={cn("inline-flex items-center gap-2", className)}
|
|
277
|
+
>
|
|
278
|
+
<ProductMark config={config} className={cn("size-7", markClassName)} />
|
|
279
|
+
<ProductWordmark config={config} variant={variant} className={wordmarkClassName} />
|
|
280
|
+
</span>
|
|
281
|
+
)
|
|
282
|
+
}
|
|
@@ -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}
|