@exxatdesignux/ui 0.2.17 → 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.
Files changed (49) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +1 -1
  3. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
  4. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +6 -1
  5. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  6. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  7. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  8. package/package.json +1 -1
  9. package/src/components/ui/sidebar.tsx +2 -2
  10. package/src/globals.css +65 -14
  11. package/src/theme.css +3 -3
  12. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  13. package/template/AGENTS.md +11 -4
  14. package/template/app/(app)/error.tsx +22 -6
  15. package/template/app/(app)/layout.tsx +13 -6
  16. package/template/app/global-error.tsx +63 -0
  17. package/template/app/globals.css +44 -14
  18. package/template/app/layout.tsx +2 -0
  19. package/template/components/app-sidebar.tsx +4 -3
  20. package/template/components/compliance-table.tsx +0 -20
  21. package/template/components/data-table/index.tsx +31 -67
  22. package/template/components/data-table/use-table-state.ts +33 -6
  23. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  24. package/template/components/exxat-product-logo.tsx +2 -6
  25. package/template/components/key-metrics.tsx +54 -22
  26. package/template/components/placement-board-card.tsx +1 -1
  27. package/template/components/placements-list-view.tsx +1 -1
  28. package/template/components/placements-table.tsx +3 -36
  29. package/template/components/product-switcher.tsx +2 -3
  30. package/template/components/product-wordmark.tsx +4 -7
  31. package/template/components/question-bank-hub-client.tsx +2 -5
  32. package/template/components/question-bank-table.tsx +12 -24
  33. package/template/components/sidebar-shell.tsx +2 -1
  34. package/template/components/sites-table.tsx +0 -20
  35. package/template/components/table-properties/drawer-button.tsx +38 -20
  36. package/template/components/table-properties/drawer.tsx +16 -13
  37. package/template/components/team-table.tsx +0 -21
  38. package/template/components/templates/list-page.tsx +12 -9
  39. package/template/components/templates/nested-secondary-panel-shell.tsx +2 -2
  40. package/template/contexts/product-context.tsx +21 -2
  41. package/template/docs/data-views-pattern.md +2 -0
  42. package/template/docs/kpi-flat-band-pattern.md +57 -0
  43. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  44. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  45. package/template/lib/chunk-load-error.ts +13 -0
  46. package/template/lib/conditional-rule-match.ts +87 -22
  47. package/template/lib/data-list-view.ts +6 -0
  48. package/template/lib/sidebar-state-cookie.ts +9 -0
  49. package/template/lib/table-state-lifecycle.ts +58 -11
@@ -4,7 +4,7 @@
4
4
  * PlacementsTable — placements hub composition on top of the generic
5
5
  * `DataTable`. Owns: placement-specific column defs, board column grouping,
6
6
  * KPI dashboards, the "open table properties" imperative handle, and the
7
- * lifecycle persistence wiring (via `useTableStateLifecycle`).
7
+ * Properties drawer + multi-view composition (table / list / board / …).
8
8
  *
9
9
  * NOTE: this is hub composition, NOT a parallel table primitive. Every hub
10
10
  * has its own `*-table.tsx` of the same shape (`team-table.tsx`,
@@ -57,12 +57,6 @@ import {
57
57
  DEFAULT_DATA_LIST_DISPLAY_OPTIONS,
58
58
  type DataListDisplayOptions,
59
59
  } from "@/lib/data-list-display-options"
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"
66
60
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
67
61
  import { StatusBadge } from "@/components/placements-table-cells"
68
62
  import { columnsToFilterFields } from "@/components/placements-table-columns"
@@ -543,7 +537,7 @@ function PlacementFolderTile({
543
537
  conditionalRules?: ConditionalRule[]
544
538
  onClick: () => void
545
539
  }) {
546
- const ruleBg = getConditionalRowBackground(row, conditionalRules)
540
+ const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
547
541
  const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
548
542
  const showStatus = isBoardFieldActive("status", tab, hiddenColKeys, boardColumns)
549
543
  const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
@@ -882,7 +876,7 @@ function PlacementFinderListRow({
882
876
  boardColumns: ColumnDef<Placement>[]
883
877
  conditionalRules?: ConditionalRule[]
884
878
  }) {
885
- const ruleBg = getConditionalRowBackground(row, conditionalRules)
879
+ const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
886
880
  const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
887
881
  const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)
888
882
  const name = showStudent ? row.student : `Placement ${row.id}`
@@ -1341,39 +1335,12 @@ export const PlacementsTable = React.forwardRef<PlacementsTableHandle, Placement
1341
1335
 
1342
1336
  const tableState = useTableState(tableData, columns, { key: "student", dir: "asc" }, paginationOverride)
1343
1337
 
1344
- const columnKeys = React.useMemo(() => new Set(columns.map(c => c.key)), [columns])
1345
-
1346
1338
  // Stable "open properties drawer" callback ref — see top of this file.
1347
1339
  React.useEffect(() => {
1348
1340
  openDrawerRef.current = () => tableState.setSheetOpen(true)
1349
1341
  // eslint-disable-next-line react-hooks/exhaustive-deps
1350
1342
  }, [openDrawerRef, tableState.setSheetOpen])
1351
1343
 
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: {
1363
- conditionalRules,
1364
- pagination,
1365
- paginationPage: safePage,
1366
- paginationPageSize,
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
- })
1376
-
1377
1344
  function buildToolbarSlot(
1378
1345
  s: ReturnType<typeof useTableState<Placement>>,
1379
1346
  ): React.ReactNode {
@@ -64,10 +64,9 @@ export function ProductSwitcher() {
64
64
  suppressHydrationWarning
65
65
  >
66
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.
67
+ // Collapsed icon-rail product mark same frame as school avatar.
69
68
  <span className="flex size-8 shrink-0 items-center justify-center">
70
- <ExxatProductMark product={current.id} className="size-8" cutoutColor="var(--sidebar)" />
69
+ <ExxatProductMark product={current.id} className="size-7" />
71
70
  </span>
72
71
  ) : (
73
72
  <>
@@ -16,9 +16,8 @@
16
16
  * recolored with the brand's gradient / fill. The SVG geometry stays
17
17
  * constant so existing layouts keep working.
18
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.
19
+ * `variant="mutedSuffix"` (sidebar / switcher): prefix recedes in dark mode;
20
+ * suffix always uses `wordmarkColor` (Exxat pink) for brand recognition.
22
21
  */
23
22
 
24
23
  import * as React from "react"
@@ -50,7 +49,6 @@ export function ProductWordmark({
50
49
  }: ProductWordmarkProps) {
51
50
  const prefix = config.prefix ?? "Exxat"
52
51
  const { suffix, brandColor, wordmarkColor } = config
53
- const mutedSuffix = variant === "mutedSuffix"
54
52
  const suffixColor = wordmarkColor ?? brandColor
55
53
 
56
54
  return (
@@ -100,14 +98,13 @@ export function ProductWordmark({
100
98
  // that read as a logo; pushing to 700/800 makes the letterforms
101
99
  // visually heavier than the brand asset.
102
100
  "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
101
  )}
106
102
  style={{
107
103
  // Ivy Presto Text from Adobe Fonts. Upright (NOT italic) — matches
108
104
  // the official Exxat wordmark. Fallback chain ends in `serif` so
109
105
  // FOUT still renders a serif that reads as a logo rather than Inter.
110
106
  fontFamily: "var(--font-heading), 'ivypresto-text', Georgia, serif",
107
+ // `wordmarkColor` (Exxat pink) in light and dark — never muted to grey.
111
108
  color: suffixColor,
112
109
  }}
113
110
  >
@@ -155,7 +152,7 @@ function useBrowserPaintReady() {
155
152
  * Fills:
156
153
  * - Outer circle: `markGradient` if provided, else flat `brandColor`.
157
154
  * - Inner shadow plate: `markShadow` (defaults to `brandColor`).
158
- * - Cut-out "E" strokes: white by default; callers can override on tinted rails.
155
+ * - Cut-out "E" strokes: always white in product chrome (sidebar / switcher).
159
156
  */
160
157
  export function ProductMark({ config, className, cutoutColor = "white" }: ProductMarkProps) {
161
158
  const ready = useBrowserPaintReady()
@@ -254,11 +254,8 @@ export function QuestionBankHubClient() {
254
254
  aria-label="Search and create questions"
255
255
  className="-mx-4 overflow-hidden px-4 py-6 md:-mx-6 md:px-6"
256
256
  style={{
257
- background: [
258
- "radial-gradient(ellipse 118% 70% at 50% 100%, oklch(from var(--brand-color) l c h / 0.11) 0%, transparent 60%)",
259
- "linear-gradient(180deg, var(--key-metrics-flat-grad-top) 0%, var(--key-metrics-flat-grad-mid) 48%, var(--background) 100%)",
260
- ].join(", "),
261
- boxShadow: "0 18px 42px -26px color-mix(in oklch, var(--brand-color) 10%, transparent)",
257
+ background: "var(--key-metrics-flat-band-radial)",
258
+ boxShadow: "var(--key-metrics-flat-band-shadow)",
262
259
  }}
263
260
  >
264
261
  <div className="mx-auto flex w-full max-w-5xl flex-col gap-8 px-4 md:px-6">
@@ -12,7 +12,6 @@ import type { DataListViewType } from "@/lib/data-list-view"
12
12
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
13
13
  import type { ColumnDef } from "@/components/data-table/types"
14
14
  import { useTableState } from "@/components/data-table/use-table-state"
15
- import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
16
15
  import { TablePropertiesDrawerButton } from "@/components/table-properties"
17
16
  import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
18
17
  import { Button } from "@/components/ui/button"
@@ -656,7 +655,18 @@ export const QuestionBankTable = React.forwardRef<
656
655
  onItemsChange: React.Dispatch<React.SetStateAction<QuestionBankItem[]>>
657
656
  }
658
657
  >(function QuestionBankTable(
659
- { items, navState, urlListSearch, searchLanding, landingFilters, view = "table", onViewChange, folders, onFoldersChange, onItemsChange },
658
+ {
659
+ items,
660
+ navState,
661
+ urlListSearch,
662
+ searchLanding,
663
+ landingFilters,
664
+ view = "table",
665
+ onViewChange,
666
+ folders,
667
+ onFoldersChange,
668
+ onItemsChange,
669
+ },
660
670
  ref,
661
671
  ) {
662
672
  const tableSourceItems = React.useMemo(() => {
@@ -718,28 +728,6 @@ export const QuestionBankTable = React.forwardRef<
718
728
  searchLanding ? undefined : urlListSearch,
719
729
  )
720
730
 
721
- // Persist this hub's table lifecycle (sort / search / filters / column
722
- // visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
723
- // NOTE: tabId is `"main"` here — the question-bank folder scope is
724
- // already URL-driven (`?scope=`, `?folderId=`), so we only persist
725
- // table chrome, not navigation.
726
- const lifecycleColumnKeys = React.useMemo(
727
- () => new Set(columns.map(c => c.key)),
728
- [columns],
729
- )
730
- useTableStateLifecycle({
731
- namespace: "question-bank",
732
- tabId: "main",
733
- tableState,
734
- columnKeys: lifecycleColumnKeys,
735
- extras: { conditionalRules },
736
- onLoadExtras: e => {
737
- if (e && Array.isArray(e.conditionalRules)) {
738
- setConditionalRules(e.conditionalRules as ConditionalRule[])
739
- }
740
- },
741
- })
742
-
743
731
  const openNewFolderForColumn = React.useCallback((parentId: string | null) => {
744
732
  setNewFolderParentId(parentId)
745
733
  setCustomizingFolder(null)
@@ -3,7 +3,8 @@
3
3
  /**
4
4
  * SidebarShell — SidebarProvider with layout-aware widths.
5
5
  * Desktop expanded/collapsed is persisted in the `sidebar_state` cookie by `@exxatdesignux/ui`
6
- * `SidebarProvider` (read on mount + write on toggle).
6
+ * `SidebarProvider` (read on mount + write on toggle). `(app)/layout` passes
7
+ * `defaultOpen` from the same cookie on the server so SSR matches the first client paint.
7
8
  */
8
9
 
9
10
  import * as React from "react"
@@ -10,7 +10,6 @@ import Link from "next/link"
10
10
  import type { SiteDirectoryRow } from "@/lib/mock/sites-directory"
11
11
  import { DataTable, DataTableToolbar } from "@/components/data-table"
12
12
  import { useTableState } from "@/components/data-table/use-table-state"
13
- import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
14
13
  import type { ColumnDef } from "@/components/data-table/types"
15
14
  import type { DataListViewType } from "@/lib/data-list-view"
16
15
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
@@ -194,25 +193,6 @@ export const SitesTable = React.forwardRef<
194
193
 
195
194
  const tableState = useTableState<SiteDirectoryRow>(sites, columns, { key: "name", dir: "asc" })
196
195
 
197
- // Persist this hub's table lifecycle (sort / search / filters / column
198
- // visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
199
- const lifecycleColumnKeys = React.useMemo(
200
- () => new Set(columns.map(c => c.key)),
201
- [columns],
202
- )
203
- useTableStateLifecycle({
204
- namespace: "sites",
205
- tabId: "main",
206
- tableState,
207
- columnKeys: lifecycleColumnKeys,
208
- extras: { conditionalRules },
209
- onLoadExtras: e => {
210
- if (e && Array.isArray(e.conditionalRules)) {
211
- setConditionalRules(e.conditionalRules as ConditionalRule[])
212
- }
213
- },
214
- })
215
-
216
196
  React.useImperativeHandle(
217
197
  ref,
218
198
  () => ({
@@ -151,6 +151,24 @@ export function TablePropertiesDrawerButton({
151
151
  sortKey,
152
152
  } = state
153
153
 
154
+ // Sheet is portaled; keep latest handlers so sort/filter/conditional edits are not lost.
155
+ const stateRef = React.useRef(state)
156
+ stateRef.current = state
157
+ const ruleHandlersRef = React.useRef({
158
+ onAddConditionalRule,
159
+ onRemoveConditionalRule,
160
+ onUpdateConditionalRule,
161
+ onDisplayOptionsChange,
162
+ onPaginationChange,
163
+ })
164
+ ruleHandlersRef.current = {
165
+ onAddConditionalRule,
166
+ onRemoveConditionalRule,
167
+ onUpdateConditionalRule,
168
+ onDisplayOptionsChange,
169
+ onPaginationChange,
170
+ }
171
+
154
172
  return (
155
173
  <>
156
174
  {extraActions}
@@ -185,42 +203,42 @@ export function TablePropertiesDrawerButton({
185
203
  rowHeight={rowHeight}
186
204
  onRowHeightChange={setRowHeight}
187
205
  pagination={pagination}
188
- onPaginationChange={onPaginationChange ?? (() => {})}
206
+ onPaginationChange={v => ruleHandlersRef.current.onPaginationChange?.(v)}
189
207
  activeFilters={activeFilters}
190
- onAddFilter={fieldKey => addFilter(fieldKey, true)}
191
- onUpdateFilter={updateFilter}
192
- onRemoveFilter={removeFilter}
193
- getFilterConnector={getConnector}
194
- onToggleFilterConnector={toggleConnector}
208
+ onAddFilter={fieldKey => stateRef.current.addFilter(fieldKey, true)}
209
+ onUpdateFilter={(id, patch) => stateRef.current.updateFilter(id, patch)}
210
+ onRemoveFilter={id => stateRef.current.removeFilter(id)}
211
+ getFilterConnector={leftId => stateRef.current.getConnector(leftId)}
212
+ onToggleFilterConnector={leftId => stateRef.current.toggleConnector(leftId)}
195
213
  filterBarVisible={filterBarVisible}
196
- onFilterBarVisibleChange={setFilterBarVisible}
214
+ onFilterBarVisibleChange={v => stateRef.current.setFilterBarVisible(v)}
197
215
  drawerExpandedFilters={drawerExpandedFilters}
198
- onDrawerExpandedFiltersChange={setDrawerExpandedFilters}
216
+ onDrawerExpandedFiltersChange={stateRef.current.setDrawerExpandedFilters}
199
217
  totalRows={totalRows}
200
218
  filteredRows={rows.length}
201
219
  sortRules={sortRules}
202
- onSortRulesChange={setSortRules}
203
- onAddSortRule={addSortRule}
204
- onRemoveSortRule={removeSortRule}
205
- onToggleSortDir={toggleSortDir}
220
+ onSortRulesChange={rules => stateRef.current.setSortRules(rules)}
221
+ onAddSortRule={fieldKey => stateRef.current.addSortRule(fieldKey)}
222
+ onRemoveSortRule={id => stateRef.current.removeSortRule(id)}
223
+ onToggleSortDir={id => stateRef.current.toggleSortDir(id)}
206
224
  colOrder={colOrder}
207
- onColOrderChange={setColOrder}
225
+ onColOrderChange={order => stateRef.current.setColOrder(order)}
208
226
  hiddenCols={hiddenCols}
209
- onToggleColVisibility={toggleColVisibility}
210
- onMoveCol={moveCol}
227
+ onToggleColVisibility={key => stateRef.current.toggleColVisibility(key)}
228
+ onMoveCol={(key, dir) => stateRef.current.moveCol(key, dir)}
211
229
  groupBy={groupBy}
212
- onGroupByChange={setGroupBy}
230
+ onGroupByChange={key => stateRef.current.setGroupBy(key)}
213
231
  primarySortKey={sortKey}
214
232
  conditionalRules={conditionalRules}
215
- onAddConditionalRule={onAddConditionalRule}
216
- onRemoveConditionalRule={onRemoveConditionalRule}
217
- onUpdateConditionalRule={onUpdateConditionalRule}
233
+ onAddConditionalRule={rule => ruleHandlersRef.current.onAddConditionalRule(rule)}
234
+ onRemoveConditionalRule={id => ruleHandlersRef.current.onRemoveConditionalRule(id)}
235
+ onUpdateConditionalRule={(id, patch) => ruleHandlersRef.current.onUpdateConditionalRule(id, patch)}
218
236
  filterFields={filterFields}
219
237
  lifecycleTabLabel={lifecycleTabLabel}
220
238
  fieldDefinitions={fieldDefinitions}
221
239
  resolveColumnLabel={resolveColumnLabel}
222
240
  displayOptions={displayOptions}
223
- onDisplayOptionsChange={onDisplayOptionsChange}
241
+ onDisplayOptionsChange={patch => ruleHandlersRef.current.onDisplayOptionsChange(patch)}
224
242
  currentView={currentView}
225
243
  onViewChange={onViewChange}
226
244
  boardGroupByColumnOptions={boardGroupByColumnOptions}
@@ -117,6 +117,9 @@ export interface TablePropertiesDrawerProps {
117
117
 
118
118
  type SheetPanel = "main" | "table-display" | "filter" | "sort" | "group" | "columns" | "conditional-rules"
119
119
 
120
+ /** Properties sheet uses `z-[80]`; default portaled menus are `z-50` and sit underneath. */
121
+ const PROPERTIES_SHEET_PORTAL_Z = "z-[90]"
122
+
120
123
  export function TablePropertiesDrawer({
121
124
  open,
122
125
  onOpenChange,
@@ -243,7 +246,7 @@ export function TablePropertiesDrawer({
243
246
  : "—"
244
247
 
245
248
  return (
246
- <Sheet open={open} onOpenChange={onOpenChange}>
249
+ <Sheet open={open} onOpenChange={onOpenChange} modal={false}>
247
250
  <SheetContent
248
251
  side="right"
249
252
  showCloseButton={false}
@@ -462,7 +465,7 @@ export function TablePropertiesDrawer({
462
465
  >
463
466
  <SelectValue />
464
467
  </SelectTrigger>
465
- <SelectContent align="end">
468
+ <SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
466
469
  {boardGroupByColumnOptions.map(o => (
467
470
  <SelectItem key={o.key} value={o.key}>
468
471
  {o.label}
@@ -533,7 +536,7 @@ export function TablePropertiesDrawer({
533
536
  <SelectTrigger size="sm" className="w-[6.5rem] shrink-0" id="board-line-count" aria-label="Line count">
534
537
  <SelectValue />
535
538
  </SelectTrigger>
536
- <SelectContent align="end">
539
+ <SelectContent align="end" className={PROPERTIES_SHEET_PORTAL_Z}>
537
540
  <SelectItem value="1">1 line</SelectItem>
538
541
  <SelectItem value="2">2 lines</SelectItem>
539
542
  <SelectItem value="3">3 lines</SelectItem>
@@ -659,7 +662,7 @@ export function TablePropertiesDrawer({
659
662
  {[
660
663
  { icon: "fa-circle-1", text: "Click \"Add filter\" below" },
661
664
  { icon: "fa-circle-2", text: "Choose a field to filter by" },
662
- { icon: "fa-circle-3", text: "Pick a condition and value" },
665
+ { icon: "fa-circle-3", text: "Pick at least one value — the grid updates immediately" },
663
666
  ].map(step => (
664
667
  <div key={step.icon} className="flex items-center gap-2 text-xs text-muted-foreground">
665
668
  <i className={`fa-light ${step.icon} text-muted-foreground text-xs shrink-0`} aria-hidden="true" />
@@ -726,7 +729,7 @@ export function TablePropertiesDrawer({
726
729
 
727
730
  {/* Add filter + Remove all */}
728
731
  <div className="flex items-center gap-2 pt-2">
729
- <DropdownMenu>
732
+ <DropdownMenu modal={false}>
730
733
  <DropdownMenuTrigger asChild>
731
734
  <Button
732
735
  type="button"
@@ -737,11 +740,11 @@ export function TablePropertiesDrawer({
737
740
  Add filter
738
741
  </Button>
739
742
  </DropdownMenuTrigger>
740
- <DropdownMenuContent align="start">
743
+ <DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
741
744
  <DropdownMenuLabel className="text-xs">Filter by field</DropdownMenuLabel>
742
745
  <DropdownMenuSeparator />
743
746
  {filterFields.map(f => (
744
- <DropdownMenuItem key={f.key} onClick={() => onAddFilter(f.key)}>
747
+ <DropdownMenuItem key={f.key} onSelect={() => onAddFilter(f.key)}>
745
748
  <i className={`fa-light ${f.icon}`} aria-hidden="true" />
746
749
  {f.label}
747
750
  </DropdownMenuItem>
@@ -833,7 +836,7 @@ export function TablePropertiesDrawer({
833
836
 
834
837
  {/* Add sort + Remove all */}
835
838
  <div className="flex items-center gap-2 pt-2">
836
- <DropdownMenu>
839
+ <DropdownMenu modal={false}>
837
840
  <DropdownMenuTrigger asChild>
838
841
  <Button
839
842
  type="button"
@@ -844,11 +847,11 @@ export function TablePropertiesDrawer({
844
847
  Add sort
845
848
  </Button>
846
849
  </DropdownMenuTrigger>
847
- <DropdownMenuContent align="start">
850
+ <DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
848
851
  <DropdownMenuLabel className="text-xs">Sort by field</DropdownMenuLabel>
849
852
  <DropdownMenuSeparator />
850
853
  {sortFieldList.filter(f => !sortRules.some(r => r.fieldKey === f.key)).map(col => (
851
- <DropdownMenuItem key={col.key} onClick={() => onAddSortRule(col.key)}>
854
+ <DropdownMenuItem key={col.key} onSelect={() => onAddSortRule(col.key)}>
852
855
  <i className="fa-light fa-arrow-up-arrow-down text-xs" aria-hidden="true" />
853
856
  {col.label}
854
857
  </DropdownMenuItem>
@@ -1052,7 +1055,7 @@ function ConditionalRulesPanel({
1052
1055
  )}
1053
1056
 
1054
1057
  <div className="flex items-center gap-2 pt-2">
1055
- <DropdownMenu>
1058
+ <DropdownMenu modal={false}>
1056
1059
  <DropdownMenuTrigger asChild>
1057
1060
  <Button
1058
1061
  type="button"
@@ -1063,13 +1066,13 @@ function ConditionalRulesPanel({
1063
1066
  Add rule
1064
1067
  </Button>
1065
1068
  </DropdownMenuTrigger>
1066
- <DropdownMenuContent align="start">
1069
+ <DropdownMenuContent align="start" className={PROPERTIES_SHEET_PORTAL_Z}>
1067
1070
  <DropdownMenuLabel className="text-xs">Rule for column</DropdownMenuLabel>
1068
1071
  <DropdownMenuSeparator />
1069
1072
  {filterFields.map(f => (
1070
1073
  <DropdownMenuItem
1071
1074
  key={f.key}
1072
- onClick={() => onAdd({
1075
+ onSelect={() => onAdd({
1073
1076
  fieldKey: f.key,
1074
1077
  operator: f.operators[0],
1075
1078
  values: [],
@@ -38,7 +38,6 @@ import type { DataListViewType } from "@/lib/data-list-view"
38
38
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
39
39
  import type { ColumnDef } from "@/components/data-table/types"
40
40
  import { useTableState } from "@/components/data-table/use-table-state"
41
- import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
42
41
  import { TablePropertiesDrawerButton } from "@/components/table-properties"
43
42
  import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
44
43
  import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
@@ -402,26 +401,6 @@ export const TeamTable = React.forwardRef<
402
401
 
403
402
  const tableState = useTableState(members, columns, { key: "name", dir: "asc" })
404
403
 
405
- // Persist this hub's table lifecycle (sort / search / filters / column
406
- // visibility / etc.) to localStorage. See `lib/table-state-lifecycle` for
407
- // the centralised hook; pass `extras` for any non-table state.
408
- const lifecycleColumnKeys = React.useMemo(
409
- () => new Set(columns.map(c => c.key)),
410
- [columns],
411
- )
412
- useTableStateLifecycle({
413
- namespace: "team",
414
- tabId: "main",
415
- tableState,
416
- columnKeys: lifecycleColumnKeys,
417
- extras: { conditionalRules },
418
- onLoadExtras: e => {
419
- if (e && Array.isArray(e.conditionalRules)) {
420
- setConditionalRules(e.conditionalRules as ConditionalRule[])
421
- }
422
- },
423
- })
424
-
425
404
  const dashboardKpi = React.useMemo(
426
405
  () => ({
427
406
  metrics: teamKpiMetrics(tableState.rows),
@@ -47,7 +47,7 @@ import {
47
47
  Shortcut,
48
48
  } from "@/components/ui/dropdown-menu"
49
49
  import type { DataListViewType } from "@/lib/data-list-view"
50
- import { DATA_LIST_VIEW_TILES } from "@/lib/data-list-view"
50
+ import { DATA_LIST_VIEW_TILES, dataListViewAddShortcut } from "@/lib/data-list-view"
51
51
  import {
52
52
  createListPageEditViewHandler,
53
53
  type OpenTablePropertiesHandle,
@@ -273,13 +273,16 @@ export function ListPageTemplate({
273
273
 
274
274
  return (
275
275
  <>
276
- {!hideViewsToolbar && VIEW_TYPES.slice(0, 9).map((v, i) => (
277
- <Shortcut
278
- key={v.type}
279
- keys={`⌘⇧${i + 1}`}
280
- onInvoke={() => addView(v.type)}
281
- />
282
- ))}
276
+ {!hideViewsToolbar && VIEW_TYPES.slice(0, 9).map((v, i) => {
277
+ const keys = dataListViewAddShortcut(i)
278
+ return keys ? (
279
+ <Shortcut
280
+ key={v.type}
281
+ keys={keys}
282
+ onInvoke={() => addView(v.type)}
283
+ />
284
+ ) : null
285
+ })}
283
286
  {activeTab && !hideViewsToolbar && (
284
287
  <>
285
288
  <Shortcut keys="F2" onInvoke={() => openRename(activeTab)} />
@@ -480,7 +483,7 @@ export function ListPageTemplate({
480
483
  {VIEW_TYPES.map((v, i) => (
481
484
  <DropdownMenuItem
482
485
  key={v.type}
483
- shortcut={i < 9 ? `⌘⇧${i + 1}` : undefined}
486
+ shortcut={dataListViewAddShortcut(i)}
484
487
  onSelect={() => addView(v.type)}
485
488
  >
486
489
  <i className={`fa-light ${v.icon}`} aria-hidden="true" />
@@ -15,7 +15,7 @@ export interface NestedSecondaryPanelShellProps {
15
15
 
16
16
  /**
17
17
  * Shared chrome for a nested hub rail — full width vs icon rail.
18
- * Fill uses `--secondary-panel-bg` (soft brand wash on `--background`).
18
+ * Fill uses `--secondary-panel-bg` one step lighter than `--sidebar` (elevation 1).
19
19
  */
20
20
  export function NestedSecondaryPanelShell({
21
21
  open,
@@ -40,7 +40,7 @@ export function NestedSecondaryPanelShell({
40
40
  // 2rem on mobile where the panel scrolls inline and we leave
41
41
  // a little more breathing room). No upper cap so tall screens
42
42
  // get a fully-extended rail.
43
- "shrink-0 m-2 mx-2 rounded-xl ring-1 ring-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
43
+ "shrink-0 m-2 mx-2 rounded-xl ring-1 ring-sidebar-border shadow-sm relative md:sticky md:top-2 bg-[var(--secondary-panel-bg)]",
44
44
  compact
45
45
  ? "w-12 min-w-12 max-w-12 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]"
46
46
  : "w-64 min-w-64 max-w-64 h-[calc(100svh-2rem)] md:h-[calc(100svh-1rem)]",
@@ -10,7 +10,26 @@
10
10
 
11
11
  import * as React from "react"
12
12
  import { useAppStore, type Product } from "@/stores/app-store"
13
- import { brandForProduct } from "@/lib/product-brand"
13
+ import {
14
+ brandForProduct,
15
+ EXXAT_ASSESSMENT_BRAND,
16
+ EXXAT_ONE_BRAND,
17
+ EXXAT_PRISM_BRAND,
18
+ } from "@/lib/product-brand"
19
+
20
+ const DEFAULT_PRODUCT_ACCENT: Record<Product, string> = {
21
+ "exxat-one": EXXAT_ONE_BRAND.brandColor,
22
+ "exxat-prism": EXXAT_PRISM_BRAND.brandColor,
23
+ "exxat-assessment": EXXAT_ASSESSMENT_BRAND.brandColor,
24
+ "exxat-custom": "",
25
+ }
26
+
27
+ function accentOverrideActive(product: Product, override: string | undefined): boolean {
28
+ if (!override?.trim()) return false
29
+ const defaultAccent = DEFAULT_PRODUCT_ACCENT[product]?.trim()
30
+ if (!defaultAccent) return true
31
+ return override.trim().toLowerCase() !== defaultAccent.toLowerCase()
32
+ }
14
33
 
15
34
  export type { Product }
16
35
 
@@ -62,7 +81,7 @@ export function ProductProvider({ children }: { children: React.ReactNode }) {
62
81
  // / theme-assessment` classes (with bespoke hue formulas in
63
82
  // `globals.css`) are still used for the **default** look of each
64
83
  // built-in.
65
- const hasAccentOverride = Boolean(productBrandColors[product])
84
+ const hasAccentOverride = accentOverrideActive(product, productBrandColors[product])
66
85
  let themeClass: "theme-one" | "theme-prism" | "theme-assessment" | "theme-custom"
67
86
  if (hasAccentOverride) {
68
87
  themeClass = "theme-custom"
@@ -18,6 +18,8 @@ This document describes how list pages combine **views**, **toolbar** behavior,
18
18
  | **Team page (primary template)** | `TeamClient` = `ListPageTemplate` + `KeyMetrics` + `TeamPageHeader` + `TeamTable` (same composition as `DataListClient`) | `components/team-client.tsx`, `lib/mock/team-kpi.ts` |
19
19
  | **Team roster** | `TeamTable` — `DataTable` + `useTableState` + `TablePropertiesDrawer`; list/board/dashboard read **`tableState.rows`** | `components/team-table.tsx` |
20
20
  | **Dashboard view (list tab)** | **`KeyMetrics`** (`variant="flat"` or `"card"`) — same KPI system as the template metrics strip; **do not** add ad-hoc `Card` grids for entity summaries | `TeamTable` dashboard branch, `lib/mock/team-kpi.ts` |
21
+ | **List hub metrics strip** | **`KeyMetrics variant="flat"`** — transparent cells, OKLCH brand glow only, border hairlines (**no** grey panel) | **`docs/kpi-flat-band-pattern.md`**, Placements / Team / Question bank clients |
22
+ | **Secondary panel chrome** | **`--secondary-panel-bg`** on **`NestedSecondaryPanelShell`** (lighter than sidebar, follows active product) | **`docs/shell-surface-elevation-pattern.md`**, Question bank |
21
23
  | **Export** | `ExportDrawer` | `ListPageTemplate` export props; `DataListClient` |
22
24
  | **View body layout** (gutter + centered max-width for folder / icon / panel-style content) | **`ListPageViewFrame`** (`components/data-views/list-page-view-frame.tsx`, re-exported from `components/data-views`) | **`FolderGridView`** (uses the frame); **`QuestionBankOsFolderView`** — see **`AGENTS.md` §4.5** |
23
25