@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.
Files changed (89) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +148 -3
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  7. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +14 -0
  8. package/package.json +3 -3
  9. package/src/components/ui/banner.tsx +2 -0
  10. package/src/components/ui/chart.tsx +57 -2
  11. package/src/components/ui/sidebar.tsx +1 -0
  12. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  13. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  14. package/template/AGENTS.md +18 -15
  15. package/template/app/(app)/data-list/page.tsx +2 -2
  16. package/template/app/(app)/question-bank/layout.tsx +18 -5
  17. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  18. package/template/app/globals.css +108 -1
  19. package/template/app/layout.tsx +41 -5
  20. package/template/components/app-sidebar.tsx +68 -34
  21. package/template/components/ask-leo-sidebar.tsx +0 -2
  22. package/template/components/brand-color-picker.tsx +344 -0
  23. package/template/components/compliance-list-view.tsx +33 -51
  24. package/template/components/compliance-table.tsx +24 -0
  25. package/template/components/data-table/index.tsx +68 -24
  26. package/template/components/data-table/pagination.tsx +0 -1
  27. package/template/components/data-table/types.ts +4 -1
  28. package/template/components/data-table/use-table-state.ts +243 -94
  29. package/template/components/data-views/data-row-list.tsx +183 -0
  30. package/template/components/data-views/index.ts +7 -3
  31. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  32. package/template/components/export-drawer.tsx +1 -1
  33. package/template/components/exxat-product-logo.tsx +172 -317
  34. package/template/components/invite-collaborators-drawer.tsx +5 -3
  35. package/template/components/key-metrics.tsx +74 -46
  36. package/template/components/new-placement-form.tsx +4 -2
  37. package/template/components/new-question-composer.tsx +2208 -0
  38. package/template/components/page-breadcrumb-trail.tsx +131 -0
  39. package/template/components/page-header.tsx +2 -1
  40. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  41. package/template/components/placement-detail.tsx +1 -1
  42. package/template/components/placements-board-view.tsx +1 -1
  43. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  44. package/template/components/placements-list-view.tsx +18 -132
  45. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  46. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  47. package/template/components/placements-table-columns.tsx +2 -2
  48. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  49. package/template/components/product-switcher.tsx +26 -8
  50. package/template/components/product-wordmark.tsx +285 -0
  51. package/template/components/question-bank-client.tsx +20 -2
  52. package/template/components/question-bank-hub-client.tsx +108 -115
  53. package/template/components/question-bank-list-view.tsx +30 -54
  54. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  55. package/template/components/question-bank-secondary-nav.tsx +0 -3
  56. package/template/components/question-bank-table.tsx +30 -5
  57. package/template/components/rotations-empty-state.tsx +3 -0
  58. package/template/components/secondary-panel.tsx +23 -3
  59. package/template/components/settings-appearance-card.tsx +584 -141
  60. package/template/components/site-header.tsx +36 -31
  61. package/template/components/sites-list-view.tsx +31 -36
  62. package/template/components/sites-table.tsx +24 -0
  63. package/template/components/table-properties/drawer.tsx +1 -1
  64. package/template/components/team-client.tsx +1 -1
  65. package/template/components/team-list-view.tsx +34 -50
  66. package/template/components/team-table.tsx +29 -3
  67. package/template/components/templates/nested-secondary-panel-shell.tsx +8 -2
  68. package/template/components/ui/dot-pattern.tsx +50 -26
  69. package/template/components/ui/leo-icon.tsx +23 -3
  70. package/template/contexts/product-context.tsx +51 -7
  71. package/template/contexts/system-banner-context.tsx +112 -4
  72. package/template/eslint.config.mjs +18 -0
  73. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  74. package/template/lib/data-list-persistence.ts +57 -257
  75. package/template/lib/dev-log.test.ts +6 -5
  76. package/template/lib/exxat-palette.json +1462 -0
  77. package/template/lib/exxat-palette.ts +136 -0
  78. package/template/lib/list-page-table-properties.ts +1 -1
  79. package/template/lib/list-status-badges.ts +1 -1
  80. package/template/lib/mailto.ts +29 -0
  81. package/template/lib/placement-board-card-layout.ts +1 -1
  82. package/template/lib/product-brand.ts +268 -0
  83. package/template/lib/question-bank-authoring.ts +308 -0
  84. package/template/lib/question-bank-nav.ts +44 -0
  85. package/template/lib/raf-throttle.ts +45 -0
  86. package/template/lib/table-state-lifecycle.ts +474 -0
  87. package/template/next.config.mjs +156 -0
  88. package/template/package.json +3 -3
  89. package/template/stores/app-store.ts +46 -1
@@ -0,0 +1,474 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Generic, opt-in lifecycle persistence for `useTableState`.
5
+ *
6
+ * - Any hub can persist its `DataTable` lifecycle (sort / search / filters /
7
+ * column order / pin / width / hidden / row height / gridlines / etc.) to
8
+ * `localStorage` by calling `useTableStateLifecycle({ namespace, tabId,
9
+ * tableState, columnKeys })`. Don't call it → no persistence (the table
10
+ * still works fine in memory).
11
+ * - Storage keys are namespaced (`exxat-ds:<namespace>:lifecycle:v1:<tabId>`)
12
+ * so each hub owns its own keyspace and can't clobber another hub.
13
+ * - Hubs that need to persist EXTRA state alongside the table (e.g. the
14
+ * placements table also persists `conditionalRules` + pagination) pass an
15
+ * `extras` object and an `onLoadExtras` callback.
16
+ * - Saves are debounced (~400ms) and SSR-safe (no-op on the server).
17
+ *
18
+ * Replaces the older `lib/data-list-persistence.ts`, which was hard-coded to
19
+ * the placements / "data-list" route. That file now re-exports from here for
20
+ * back-compat so existing imports keep working during the migration window.
21
+ */
22
+
23
+ import * as React from "react"
24
+ import type { Dispatch, SetStateAction } from "react"
25
+ import type { RowHeight } from "@/lib/row-height"
26
+ import type { DataListDisplayOptions } from "@/lib/data-list-display-options"
27
+ import type { ActiveFilter, ConditionalRule, SortRule } from "@/components/table-properties/types"
28
+ import type { ViewTab } from "@/components/templates/list-page"
29
+ import type { DataListViewType } from "@/lib/data-list-view"
30
+
31
+ // ─────────────────────────────────────────────────────────────────────────────
32
+ // Storage key + debounce config
33
+ // ─────────────────────────────────────────────────────────────────────────────
34
+
35
+ const LIFECYCLE_SAVE_DEBOUNCE_MS = 400
36
+ const PAGE_SAVE_DEBOUNCE_MS = 400
37
+
38
+ /** Public so hubs that want to clear or namespace-scan storage can. */
39
+ export function lifecycleStorageKey(namespace: string, tabId: string): string {
40
+ return `exxat-ds:${namespace}:lifecycle:v1:${tabId}`
41
+ }
42
+
43
+ export function pageStorageKey(namespace: string): string {
44
+ return `exxat-ds:${namespace}:page:v1`
45
+ }
46
+
47
+ // Module-level timer maps — one per namespace+tabId combo and one per page key.
48
+ const lifecycleTimers = new Map<string, ReturnType<typeof setTimeout>>()
49
+ const pageTimers = new Map<string, ReturnType<typeof setTimeout>>()
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // Persisted shapes
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Versioned snapshot of a single table's lifecycle state. The fields under
57
+ * `extras` are entity-specific (e.g. placements stuff `conditionalRules` and
58
+ * pagination there) and opaque to this module. Older v1 records (pre-extras
59
+ * rollout) may have those entity-specific fields at the top level — the
60
+ * parser accepts both shapes for back-compat.
61
+ */
62
+ export interface PersistedLifecycleV1 {
63
+ v: 1
64
+ sortRules: SortRule[]
65
+ search: string
66
+ activeFilters: ActiveFilter[]
67
+ filterConnectors: Record<string, "and" | "or">
68
+ groupBy: string | null
69
+ colOrder: string[]
70
+ hiddenCols: string[]
71
+ colWidths: Record<string, number>
72
+ colPins: Record<string, "left" | "right">
73
+ colWrap: Record<string, boolean>
74
+ colMenuSearch: Record<string, string>
75
+ rowHeight: RowHeight
76
+ showGridlines: boolean
77
+ filterBarVisible: boolean
78
+ searchOpen: boolean
79
+ /** Generic hub-defined extras. Persisted as JSON. */
80
+ extras?: Record<string, unknown>
81
+ /**
82
+ * @deprecated Legacy top-level fields (used by placements pre-extras
83
+ * rollout). New code SHOULD live under `extras`. Kept here so existing
84
+ * placements `localStorage` payloads still parse.
85
+ */
86
+ conditionalRules?: ConditionalRule[]
87
+ pagination?: boolean
88
+ paginationPage?: number
89
+ paginationPageSize?: number
90
+ }
91
+
92
+ export interface PersistedPageV1 {
93
+ v: 1
94
+ displayOptions: DataListDisplayOptions
95
+ showMetrics: boolean
96
+ tabs: ViewTab[]
97
+ activeTabId: string
98
+ }
99
+
100
+ /**
101
+ * Narrow surface the lifecycle hook needs from `useTableState` — getters +
102
+ * setters for every persisted slice. Defined as a structural type so
103
+ * callers can pass `tableState` directly (it satisfies this shape).
104
+ */
105
+ export interface TableStatePersistSlice {
106
+ sortRules: SortRule[]
107
+ search: string
108
+ activeFilters: ActiveFilter[]
109
+ filterConnectors: Record<string, "and" | "or">
110
+ groupBy: string | null
111
+ colOrder: string[]
112
+ hiddenCols: Set<string>
113
+ colWidths: Record<string, number>
114
+ colPins: Record<string, "left" | "right">
115
+ colWrap: Record<string, boolean>
116
+ colMenuSearch: Record<string, string>
117
+ rowHeight: RowHeight
118
+ showGridlines: boolean
119
+ filterBarVisible: boolean
120
+ searchOpen: boolean
121
+ setSortRules: Dispatch<SetStateAction<SortRule[]>>
122
+ setSearch: Dispatch<SetStateAction<string>>
123
+ setActiveFilters: Dispatch<SetStateAction<ActiveFilter[]>>
124
+ setFilterConnectors: Dispatch<SetStateAction<Record<string, "and" | "or">>>
125
+ setGroupBy: Dispatch<SetStateAction<string | null>>
126
+ setColOrder: Dispatch<SetStateAction<string[]>>
127
+ setHiddenCols: Dispatch<SetStateAction<Set<string>>>
128
+ setColWidths: Dispatch<SetStateAction<Record<string, number>>>
129
+ setColPins: Dispatch<SetStateAction<Record<string, "left" | "right">>>
130
+ setColWrap: Dispatch<SetStateAction<Record<string, boolean>>>
131
+ setColMenuSearch: Dispatch<SetStateAction<Record<string, string>>>
132
+ setRowHeight: Dispatch<SetStateAction<RowHeight>>
133
+ setShowGridlines: Dispatch<SetStateAction<boolean>>
134
+ setFilterBarVisible: Dispatch<SetStateAction<boolean>>
135
+ setSearchOpen: Dispatch<SetStateAction<boolean>>
136
+ }
137
+
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+ // Parsers + validators
140
+ // ─────────────────────────────────────────────────────────────────────────────
141
+
142
+ const VIEW_TYPES: DataListViewType[] = ["table", "list", "board", "dashboard"]
143
+
144
+ function isViewType(v: unknown): v is DataListViewType {
145
+ return typeof v === "string" && (VIEW_TYPES as string[]).includes(v)
146
+ }
147
+
148
+ function parseViewTab(raw: unknown): ViewTab | null {
149
+ if (!raw || typeof raw !== "object") return null
150
+ const o = raw as Record<string, unknown>
151
+ if (typeof o.id !== "string" || typeof o.label !== "string") return null
152
+ if (!isViewType(o.viewType)) return null
153
+ if (typeof o.icon !== "string" || typeof o.filterId !== "string") return null
154
+ return { id: o.id, label: o.label, viewType: o.viewType, icon: o.icon, filterId: o.filterId }
155
+ }
156
+
157
+ export function parsePersistedPage(raw: string | null): PersistedPageV1 | null {
158
+ if (!raw) return null
159
+ try {
160
+ const j = JSON.parse(raw) as unknown
161
+ if (!j || typeof j !== "object") return null
162
+ const o = j as Record<string, unknown>
163
+ if (o.v !== 1) return null
164
+ if (!o.displayOptions || typeof o.displayOptions !== "object") return null
165
+ if (typeof o.showMetrics !== "boolean") return null
166
+ if (!Array.isArray(o.tabs) || typeof o.activeTabId !== "string") return null
167
+ const tabs = o.tabs.map(parseViewTab).filter((t): t is ViewTab => t !== null)
168
+ if (tabs.length === 0) return null
169
+ return {
170
+ v: 1,
171
+ displayOptions: o.displayOptions as DataListDisplayOptions,
172
+ showMetrics: o.showMetrics,
173
+ tabs,
174
+ activeTabId: o.activeTabId,
175
+ }
176
+ } catch {
177
+ return null
178
+ }
179
+ }
180
+
181
+ export function parsePersistedLifecycle(raw: string | null): PersistedLifecycleV1 | null {
182
+ if (!raw) return null
183
+ try {
184
+ const j = JSON.parse(raw) as unknown
185
+ if (!j || typeof j !== "object") return null
186
+ const o = j as Record<string, unknown>
187
+ if (o.v !== 1) return null
188
+ if (!Array.isArray(o.sortRules)) return null
189
+ if (typeof o.search !== "string") return null
190
+ if (!Array.isArray(o.activeFilters)) return null
191
+ if (!o.filterConnectors || typeof o.filterConnectors !== "object") return null
192
+ if (o.groupBy !== null && typeof o.groupBy !== "string") return null
193
+ if (!Array.isArray(o.colOrder)) return null
194
+ if (!Array.isArray(o.hiddenCols)) return null
195
+ if (!o.colWidths || typeof o.colWidths !== "object") return null
196
+ if (!o.colPins || typeof o.colPins !== "object") return null
197
+ if (!o.colWrap || typeof o.colWrap !== "object") return null
198
+ if (!o.colMenuSearch || typeof o.colMenuSearch !== "object") return null
199
+ if (typeof o.rowHeight !== "string") return null
200
+ if (typeof o.showGridlines !== "boolean") return null
201
+ if (typeof o.filterBarVisible !== "boolean") return null
202
+ if (typeof o.searchOpen !== "boolean") return null
203
+ // `extras` is optional; legacy placements payloads kept these at the top
204
+ // level instead and we accept those for back-compat.
205
+ if (o.extras !== undefined && (typeof o.extras !== "object" || o.extras === null)) return null
206
+ return o as unknown as PersistedLifecycleV1
207
+ } catch {
208
+ return null
209
+ }
210
+ }
211
+
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+ // Apply / serialize
214
+ // ─────────────────────────────────────────────────────────────────────────────
215
+
216
+ function mergeColOrder(saved: string[], columnKeys: Set<string>): string[] {
217
+ const ordered = saved.filter(k => columnKeys.has(k))
218
+ for (const k of columnKeys) {
219
+ if (!ordered.includes(k)) ordered.push(k)
220
+ }
221
+ return ordered
222
+ }
223
+
224
+ function filterRecordKeys<T extends Record<string, unknown>>(obj: T, keys: Set<string>): T {
225
+ const out = { ...obj }
226
+ for (const k of Object.keys(out)) {
227
+ if (!keys.has(k)) delete out[k]
228
+ }
229
+ return out
230
+ }
231
+
232
+ export function applyLifecyclePersisted(
233
+ ts: TableStatePersistSlice,
234
+ p: PersistedLifecycleV1,
235
+ columnKeys: Set<string>,
236
+ ): void {
237
+ const colOrder = mergeColOrder(p.colOrder, columnKeys)
238
+ const hidden = new Set(p.hiddenCols.filter(k => columnKeys.has(k)))
239
+ const colWidths = filterRecordKeys(p.colWidths, columnKeys) as Record<string, number>
240
+ const colPins = filterRecordKeys(p.colPins, columnKeys) as Record<string, "left" | "right">
241
+ const colWrap = filterRecordKeys(p.colWrap, columnKeys) as Record<string, boolean>
242
+ const colMenuSearch = filterRecordKeys(p.colMenuSearch, columnKeys) as Record<string, string>
243
+
244
+ ts.setSortRules(p.sortRules)
245
+ ts.setSearch(p.search)
246
+ ts.setActiveFilters(p.activeFilters)
247
+ ts.setFilterConnectors(p.filterConnectors)
248
+ ts.setGroupBy(p.groupBy)
249
+ ts.setColOrder(colOrder)
250
+ ts.setHiddenCols(hidden)
251
+ ts.setColWidths(colWidths)
252
+ ts.setColPins(colPins)
253
+ ts.setColWrap(colWrap)
254
+ ts.setColMenuSearch(colMenuSearch)
255
+ ts.setRowHeight(p.rowHeight)
256
+ ts.setShowGridlines(p.showGridlines)
257
+ ts.setFilterBarVisible(p.filterBarVisible)
258
+ ts.setSearchOpen(p.searchOpen)
259
+ }
260
+
261
+ export function serializeLifecycle(
262
+ ts: TableStatePersistSlice,
263
+ extras?: Record<string, unknown>,
264
+ ): PersistedLifecycleV1 {
265
+ return {
266
+ v: 1,
267
+ sortRules: ts.sortRules,
268
+ search: ts.search,
269
+ activeFilters: ts.activeFilters,
270
+ filterConnectors: ts.filterConnectors,
271
+ groupBy: ts.groupBy,
272
+ colOrder: ts.colOrder,
273
+ hiddenCols: [...ts.hiddenCols],
274
+ colWidths: { ...ts.colWidths },
275
+ colPins: { ...ts.colPins },
276
+ colWrap: { ...ts.colWrap },
277
+ colMenuSearch: { ...ts.colMenuSearch },
278
+ rowHeight: ts.rowHeight,
279
+ showGridlines: ts.showGridlines,
280
+ filterBarVisible: ts.filterBarVisible,
281
+ searchOpen: ts.searchOpen,
282
+ extras,
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Read merged extras from a payload, falling back to the legacy top-level
288
+ * placements fields when `extras` isn't set yet. Generic so hubs can cast to
289
+ * their own extras shape.
290
+ */
291
+ export function readLifecycleExtras<TExtras extends Record<string, unknown>>(
292
+ p: PersistedLifecycleV1,
293
+ ): TExtras | undefined {
294
+ if (p.extras) return p.extras as TExtras
295
+ // Legacy placements payload — synthesise extras from known top-level keys.
296
+ if (
297
+ p.conditionalRules !== undefined ||
298
+ p.pagination !== undefined ||
299
+ p.paginationPage !== undefined ||
300
+ p.paginationPageSize !== undefined
301
+ ) {
302
+ const legacy: Record<string, unknown> = {}
303
+ if (p.conditionalRules !== undefined) legacy.conditionalRules = p.conditionalRules
304
+ if (p.pagination !== undefined) legacy.pagination = p.pagination
305
+ if (p.paginationPage !== undefined) legacy.paginationPage = p.paginationPage
306
+ if (p.paginationPageSize !== undefined) legacy.paginationPageSize = p.paginationPageSize
307
+ return legacy as TExtras
308
+ }
309
+ return undefined
310
+ }
311
+
312
+ // ─────────────────────────────────────────────────────────────────────────────
313
+ // Direct storage IO (rarely needed — prefer the hook)
314
+ // ─────────────────────────────────────────────────────────────────────────────
315
+
316
+ export function loadLifecycleFromStorage(
317
+ namespace: string,
318
+ tabId: string,
319
+ ): PersistedLifecycleV1 | null {
320
+ if (typeof window === "undefined") return null
321
+ return parsePersistedLifecycle(localStorage.getItem(lifecycleStorageKey(namespace, tabId)))
322
+ }
323
+
324
+ export function scheduleLifecycleSave(
325
+ namespace: string,
326
+ tabId: string,
327
+ payload: PersistedLifecycleV1,
328
+ ): void {
329
+ if (typeof window === "undefined") return
330
+ const key = lifecycleStorageKey(namespace, tabId)
331
+ const prev = lifecycleTimers.get(key)
332
+ if (prev) clearTimeout(prev)
333
+ const t = setTimeout(() => {
334
+ lifecycleTimers.delete(key)
335
+ try {
336
+ localStorage.setItem(key, JSON.stringify(payload))
337
+ } catch {
338
+ /* quota / private mode */
339
+ }
340
+ }, LIFECYCLE_SAVE_DEBOUNCE_MS)
341
+ lifecycleTimers.set(key, t)
342
+ }
343
+
344
+ export function loadPageFromStorage(namespace: string): PersistedPageV1 | null {
345
+ if (typeof window === "undefined") return null
346
+ return parsePersistedPage(localStorage.getItem(pageStorageKey(namespace)))
347
+ }
348
+
349
+ export function schedulePageSave(namespace: string, payload: PersistedPageV1): void {
350
+ if (typeof window === "undefined") return
351
+ const key = pageStorageKey(namespace)
352
+ const prev = pageTimers.get(key)
353
+ if (prev) clearTimeout(prev)
354
+ const t = setTimeout(() => {
355
+ pageTimers.delete(key)
356
+ try {
357
+ localStorage.setItem(key, JSON.stringify(payload))
358
+ } catch {
359
+ /* quota */
360
+ }
361
+ }, PAGE_SAVE_DEBOUNCE_MS)
362
+ pageTimers.set(key, t)
363
+ }
364
+
365
+ // ─────────────────────────────────────────────────────────────────────────────
366
+ // React hook (the recommended entry point)
367
+ // ─────────────────────────────────────────────────────────────────────────────
368
+
369
+ export interface UseTableStateLifecycleOptions<TExtras extends Record<string, unknown> | void = void> {
370
+ /** Storage namespace, e.g. `"placements"`, `"team"`, `"question-bank"`. */
371
+ namespace: string
372
+ /**
373
+ * Sub-key per lifecycle tab. A hub with only one lifecycle should pass a
374
+ * stable constant like `"main"`. A hub with multiple lifecycle scopes
375
+ * (e.g. placements' "all / mine / shared" tabs) passes the active scope id.
376
+ */
377
+ tabId: string
378
+ /** `useTableState(...)` return value. Satisfies `TableStatePersistSlice`. */
379
+ tableState: TableStatePersistSlice
380
+ /**
381
+ * Valid column keys for the active table. Persisted column references that
382
+ * are no longer present (e.g. column was renamed / removed) are dropped on
383
+ * load.
384
+ */
385
+ columnKeys: Set<string>
386
+ /**
387
+ * Current value of extra state to persist alongside the table (optional).
388
+ * Pass `undefined` / omit entirely if the hub only persists the table.
389
+ */
390
+ extras?: TExtras
391
+ /**
392
+ * Called once when the persisted record is loaded, with whatever `extras`
393
+ * it contained (or legacy top-level fields). Use this to rehydrate the
394
+ * matching React state in the consumer.
395
+ */
396
+ onLoadExtras?: (extras: TExtras | Record<string, unknown> | undefined) => void
397
+ }
398
+
399
+ /**
400
+ * Opt-in lifecycle persistence for a `DataTable`. Wires up:
401
+ *
402
+ * 1. **Load** (`useLayoutEffect`, once per `tabId` / `columnKeys` change) —
403
+ * reads from `localStorage` and pushes the persisted state back into
404
+ * `tableState` setters; then calls `onLoadExtras` so the consumer can
405
+ * restore hub-specific state too.
406
+ * 2. **Save** (`useEffect`, debounced ~400ms) — re-serializes whenever any
407
+ * persisted slice changes and writes to `localStorage`.
408
+ *
409
+ * Behaviour:
410
+ *
411
+ * - SSR-safe (`localStorage` reads are guarded; the layout effect only runs
412
+ * on the client).
413
+ * - No render hits: setters are called inside the effect, not during render.
414
+ * - Doesn't depend on the full `tableState` object — it depends on each
415
+ * persisted slice individually so the table object identity (which is fresh
416
+ * every render) doesn't force re-saves.
417
+ */
418
+ export function useTableStateLifecycle<TExtras extends Record<string, unknown> | void = void>(
419
+ opts: UseTableStateLifecycleOptions<TExtras>,
420
+ ): void {
421
+ const { namespace, tabId, tableState, columnKeys, extras, onLoadExtras } = opts
422
+
423
+ // Keep `onLoadExtras` in a ref so the load effect doesn't refire when the
424
+ // consumer passes a new function reference each render.
425
+ const onLoadExtrasRef = React.useRef(onLoadExtras)
426
+ React.useEffect(() => {
427
+ onLoadExtrasRef.current = onLoadExtras
428
+ })
429
+
430
+ // ── Load ────────────────────────────────────────────────────────────────
431
+ // useLayoutEffect so the rehydrated state paints in the first frame after
432
+ // mount instead of flashing the unhydrated defaults first.
433
+ React.useLayoutEffect(() => {
434
+ const raw = loadLifecycleFromStorage(namespace, tabId)
435
+ if (!raw) return
436
+ applyLifecyclePersisted(tableState, raw, columnKeys)
437
+ const e = readLifecycleExtras<Record<string, unknown>>(raw)
438
+ onLoadExtrasRef.current?.(e as TExtras | Record<string, unknown> | undefined)
439
+ // `tableState` is freshly returned each render; depending on it would
440
+ // re-apply persisted state on every keystroke and undo edits. Depend only
441
+ // on the load scope (namespace + tabId + column set).
442
+ // eslint-disable-next-line react-hooks/exhaustive-deps
443
+ }, [namespace, tabId, columnKeys])
444
+
445
+ // ── Save ────────────────────────────────────────────────────────────────
446
+ // Serialise + debounce on every persisted slice change. Don't depend on
447
+ // the full `tableState` (fresh per render); depend on each slice instead so
448
+ // a no-op render doesn't trigger a no-op save.
449
+ const extrasJson = React.useMemo(() => (extras ? JSON.stringify(extras) : ""), [extras])
450
+ React.useEffect(() => {
451
+ const payload = serializeLifecycle(tableState, extras as Record<string, unknown> | undefined)
452
+ scheduleLifecycleSave(namespace, tabId, payload)
453
+ // eslint-disable-next-line react-hooks/exhaustive-deps
454
+ }, [
455
+ namespace,
456
+ tabId,
457
+ extrasJson,
458
+ tableState.sortRules,
459
+ tableState.search,
460
+ tableState.activeFilters,
461
+ tableState.filterConnectors,
462
+ tableState.groupBy,
463
+ tableState.colOrder,
464
+ tableState.hiddenCols,
465
+ tableState.colWidths,
466
+ tableState.colPins,
467
+ tableState.colWrap,
468
+ tableState.colMenuSearch,
469
+ tableState.rowHeight,
470
+ tableState.showGridlines,
471
+ tableState.filterBarVisible,
472
+ tableState.searchOpen,
473
+ ])
474
+ }
@@ -4,12 +4,168 @@ const withBundleAnalyzer = bundleAnalyzer({
4
4
  enabled: process.env.ANALYZE === "true",
5
5
  })
6
6
 
7
+ const isDev = process.env.NODE_ENV !== "production"
8
+
9
+ /**
10
+ * Content Security Policy
11
+ *
12
+ * Allowlist matches the runtime third-party origins this app actually contacts
13
+ * (see `app/layout.tsx` and `lib/{logo-dev,stock-portrait}.ts`):
14
+ *
15
+ * • Font Awesome Pro Kit — *.fontawesome.com (loader at kit.*, Pro CSS/font/telemetry
16
+ * at ka-p.*, free fallback at ka-f.*). Wildcard is used
17
+ * because FA rotates resource subdomains per kit tier and
18
+ * every subdomain is FA-controlled.
19
+ * • Adobe Typekit — use.typekit.net (CSS) + p.typekit.net (font files)
20
+ * • Avatars (mock) — img.logo.dev (school logos), randomuser.me (mock portraits)
21
+ *
22
+ * `'unsafe-inline'` is kept for `style-src` because Tailwind injects inline styles
23
+ * and the chart primitive uses one `<style dangerouslySetInnerHTML>` block for
24
+ * per-instance CSS variables. `'unsafe-inline'` is kept for `script-src` because
25
+ * Next.js inlines small bootstrap scripts (page data, RSC payload) on every
26
+ * route; migrating to nonces is a separate refactor. `'unsafe-eval'` is only
27
+ * permitted in development for HMR.
28
+ *
29
+ * If you add a new third-party origin (fonts, images, telemetry), update the
30
+ * matching directive below before shipping.
31
+ */
32
+ function buildContentSecurityPolicy() {
33
+ // `https://*.fontawesome.com` covers every FA Kit subdomain (loader at
34
+ // `kit.fontawesome.com`, Pro resources at `ka-p.fontawesome.com`, free
35
+ // fallback at `ka-f.fontawesome.com`, telemetry, etc.) without us having to
36
+ // chase per-tier subdomain changes. Every subdomain is FA-controlled.
37
+ const FA_HOSTS = "https://*.fontawesome.com"
38
+
39
+ const scriptSrc = [
40
+ "'self'",
41
+ "'unsafe-inline'",
42
+ isDev ? "'unsafe-eval'" : "",
43
+ FA_HOSTS,
44
+ ]
45
+ .filter(Boolean)
46
+ .join(" ")
47
+
48
+ const styleSrc = [
49
+ "'self'",
50
+ "'unsafe-inline'",
51
+ "https://use.typekit.net",
52
+ "https://p.typekit.net",
53
+ FA_HOSTS,
54
+ ].join(" ")
55
+
56
+ const fontSrc = [
57
+ "'self'",
58
+ "data:",
59
+ "https://use.typekit.net",
60
+ "https://p.typekit.net",
61
+ FA_HOSTS,
62
+ ].join(" ")
63
+
64
+ const imgSrc = [
65
+ "'self'",
66
+ "data:",
67
+ "blob:",
68
+ "https://img.logo.dev",
69
+ "https://randomuser.me",
70
+ FA_HOSTS,
71
+ ].join(" ")
72
+
73
+ const connectSrc = [
74
+ "'self'",
75
+ "https://use.typekit.net",
76
+ FA_HOSTS,
77
+ // Next.js dev server uses websockets for HMR.
78
+ isDev ? "ws:" : "",
79
+ isDev ? "wss:" : "",
80
+ ]
81
+ .filter(Boolean)
82
+ .join(" ")
83
+
84
+ const directives = [
85
+ `default-src 'self'`,
86
+ `script-src ${scriptSrc}`,
87
+ `style-src ${styleSrc}`,
88
+ `font-src ${fontSrc}`,
89
+ `img-src ${imgSrc}`,
90
+ `connect-src ${connectSrc}`,
91
+ `frame-ancestors 'none'`,
92
+ `frame-src 'none'`,
93
+ `object-src 'none'`,
94
+ `base-uri 'self'`,
95
+ `form-action 'self'`,
96
+ `manifest-src 'self'`,
97
+ `worker-src 'self' blob:`,
98
+ // `upgrade-insecure-requests` is only meaningful on HTTPS responses.
99
+ isDev ? "" : "upgrade-insecure-requests",
100
+ ]
101
+ .filter(Boolean)
102
+ .join("; ")
103
+
104
+ return directives
105
+ }
106
+
107
+ const SECURITY_HEADERS = [
108
+ {
109
+ key: "Content-Security-Policy",
110
+ value: buildContentSecurityPolicy(),
111
+ },
112
+ // Defence-in-depth — `frame-ancestors 'none'` already blocks framing in
113
+ // modern browsers, but X-Frame-Options is still honoured by older clients
114
+ // and is required by some scanners.
115
+ { key: "X-Frame-Options", value: "DENY" },
116
+ { key: "X-Content-Type-Options", value: "nosniff" },
117
+ { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
118
+ {
119
+ key: "Permissions-Policy",
120
+ value: [
121
+ "accelerometer=()",
122
+ "autoplay=()",
123
+ "camera=()",
124
+ "display-capture=()",
125
+ "encrypted-media=()",
126
+ "fullscreen=(self)",
127
+ "geolocation=()",
128
+ "gyroscope=()",
129
+ "magnetometer=()",
130
+ "microphone=()",
131
+ "midi=()",
132
+ "payment=()",
133
+ "picture-in-picture=()",
134
+ "publickey-credentials-get=()",
135
+ "screen-wake-lock=()",
136
+ "sync-xhr=()",
137
+ "usb=()",
138
+ "xr-spatial-tracking=()",
139
+ "interest-cohort=()",
140
+ ].join(", "),
141
+ },
142
+ // HSTS only takes effect over HTTPS; harmless on http://localhost during dev.
143
+ // Subdomain + preload opt-in — drop `; preload` if the apex domain is not
144
+ // ready for the HSTS preload list.
145
+ {
146
+ key: "Strict-Transport-Security",
147
+ value: "max-age=63072000; includeSubDomains; preload",
148
+ },
149
+ { key: "X-DNS-Prefetch-Control", value: "on" },
150
+ { key: "Cross-Origin-Opener-Policy", value: "same-origin" },
151
+ ]
152
+
7
153
  /** @type {import('next').NextConfig} */
8
154
  const nextConfig = {
9
155
  transpilePackages: ["@exxatdesignux/ui"],
10
156
  experimental: {
11
157
  optimizePackageImports: ["lucide-react", "recharts", "@exxatdesignux/ui"],
12
158
  },
159
+ async headers() {
160
+ return [
161
+ {
162
+ // Apply to every route. Static assets under `/_next/static/*` get the
163
+ // same headers — that's fine since they are first-party.
164
+ source: "/:path*",
165
+ headers: SECURITY_HEADERS,
166
+ },
167
+ ]
168
+ },
13
169
  async redirects() {
14
170
  return [
15
171
  { source: "/rotations", destination: "/examples", permanent: false },
@@ -50,7 +50,7 @@
50
50
  "react-hook-form": "^7.72.0",
51
51
  "react-resizable-panels": "^4.10.0",
52
52
  "recharts": "^2.15.4",
53
- "shadcn": "^4.1.0",
53
+ "shadcn": "^4.7.0",
54
54
  "sonner": "^2.0.7",
55
55
  "tailwind-merge": "^3.5.0",
56
56
  "tw-animate-css": "^1.4.0",
@@ -71,8 +71,8 @@
71
71
  "eslint": "^9.39.4",
72
72
  "eslint-config-next": "16.2.6",
73
73
  "jsdom": "^26.1.0",
74
- "pm2": "^6.0.14",
75
- "postcss": "^8",
74
+ "pm2": "^7.0.1",
75
+ "postcss": "^8.5.14",
76
76
  "prettier": "^3.8.1",
77
77
  "prettier-plugin-tailwindcss": "^0.7.2",
78
78
  "tailwindcss": "^4.2.1",