@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
@@ -0,0 +1,57 @@
1
+ # KPI flat band (`KeyMetrics` `variant="flat"`)
2
+
3
+ > **Component:** `components/key-metrics.tsx` — **`flatMetricsHairlineClass`**, **`flatBandStyle`**.
4
+ > **Tokens:** `app/globals.css` — `--key-metrics-flat-*`.
5
+ > **Cursor:** `.cursor/rules/exxat-kpi-flat-band.mdc` · `.cursor/skills/exxat-kpi-flat-band/SKILL.md`
6
+ > **Related:** `docs/kpi-strip-max-four-pattern.md`, `docs/kpi-trend-pattern.md`
7
+
8
+ ## Intent
9
+
10
+ List hubs and the main dashboard mix view use **`KeyMetrics variant="flat"`** as a **metrics strip without a surface**: users see KPI copy and deltas on the **page canvas**, with a **brand-colored glow** under the band only. This is **not** a card, tinted panel, or `gap-px` grid fill.
11
+
12
+ ## MUST
13
+
14
+ 1. **No band surface** — The `<section>` background is **only** `var(--key-metrics-flat-band-radial)`. **Do not** stack `--key-metrics-flat-band-linear`, opaque gradients, or `box-shadow` fills that read as a grey/lavender box.
15
+ 2. **Transparent cells** — `metricsCellSurfaceClassName` is **`bg-transparent`** for `variant="flat"`. **Do not** use `bg-background`, `bg-card`, or `gap-px` + `bg-border` / `bg-foreground/*` on the grid (that paints tile surfaces).
16
+ 3. **Hairlines = borders only** — Use **`flatMetricsHairlineClass(itemCount, metricsHalfWidthLayout)`** in `key-metrics.tsx`:
17
+ - **2 tiles:** `border-r` on the first cell only.
18
+ - **4 tiles, wide strip (default):** `border-r` on cells 1–3 (verticals between all columns); **no** horizontal rule.
19
+ - **4 tiles, narrow `@container` (&lt; 30rem, 2×2 grid):** odd-column `border-r` + `border-b` on the top row only (via `@[max-width:29.99rem]` overrides).
20
+ 4. **Divider color (OKLCH)** — `--key-metrics-flat-divider: color-mix(in oklch, var(--sidebar-border) 55%, transparent)`; apply on children with `[&>*]:border-[color:var(--key-metrics-flat-divider)]`. Dividers follow **active product** hue (`--sidebar-border`), not neutral grey alone.
21
+ 5. **Glow (OKLCH)** — Radial stops use `color-mix(in oklch, var(--brand-color) …%, transparent)` so **Exxat One / Prism / Assessment / `theme-custom`** each tint correctly. **Do not** hardcode rose/indigo literals on theme blocks unless documenting a one-off.
22
+ 6. **List page usage** — Prefer **`showHeader={false}`**, **`metricsSingleRow`** when four KPIs share one row; pass **`insight`** only when the insight rail is product-required (same row uses `lg:grid-cols-[3fr_2fr]`).
23
+ 7. **Cap at four tiles** — See **`docs/kpi-strip-max-four-pattern.md`**.
24
+
25
+ ## MUST NOT
26
+
27
+ - Add **`--key-metrics-flat-band-linear`** back into `flatBandStyle` or hub inline styles (e.g. question-bank hub hero).
28
+ - Use **`variant="card"`** on **`ListPageTemplate`** metrics when the design calls for a **flat strip** on the page background.
29
+ - Duplicate KPI numbers in ad-hoc **`Card`** grids on the same hub.
30
+ - Set **`variant="mutedSuffix"`** on product wordmarks to grey out the **suffix** in dark mode — suffix stays **Exxat pink** (`wordmarkColor`); see **`lib/product-brand.ts`**.
31
+
32
+ ## Tokens (`app/globals.css`)
33
+
34
+ | Token | Role |
35
+ |--------|------|
36
+ | `--key-metrics-flat-band-radial` | Bottom brand glow (only layer on flat `<section>`) |
37
+ | `--key-metrics-flat-band-shadow` | **`none`** for flat band (no faux surface lift) |
38
+ | `--key-metrics-flat-cell-bg` | **`transparent`** |
39
+ | `--key-metrics-flat-divider` | OKLCH hairline between cells |
40
+
41
+ Dark mode (`.dark`): same rules — transparent cells, radial glow only, no linear fill to `--background`.
42
+
43
+ ## Reference implementations
44
+
45
+ - `components/question-bank-client.tsx` — `KeyMetrics variant="flat" metricsSingleRow`
46
+ - `components/dashboard-tabs.tsx` — mix view flat band + insight
47
+ - `components/placements-client.tsx`, `team-client.tsx`, `compliance-client.tsx` — list hub metrics slot
48
+
49
+ ## Insight rail (flat + side-by-side)
50
+
51
+ When **`insight`** is shown beside KPIs, the insight **`Card`** may keep its own surface; the **KPI grid** stays transparent. **Do not** add `lg:border-l` on the insight column for flat band — the insight card ring is the separator (`key-metrics.tsx`).
52
+
53
+ ## See also
54
+
55
+ - **`docs/kpi-strip-max-four-pattern.md`**
56
+ - **`docs/kpi-trend-pattern.md`**
57
+ - **`docs/shell-surface-elevation-pattern.md`** — sidebar / secondary panel / page stack
@@ -26,4 +26,5 @@ On **primary list hubs** (`ListPageTemplate` metrics slot) and on **dashboard
26
26
  ## See also
27
27
 
28
28
  - **`docs/kpi-trend-pattern.md`** — deltas, arrows, **`trendPolarity`**.
29
+ - **`docs/kpi-flat-band-pattern.md`** — **`variant="flat"`** presentation (orthogonal to tile count).
29
30
  - **`.cursor/rules/exxat-kpi-max-four.mdc`**, **`.cursor/skills/exxat-kpi-max-four/SKILL.md`**
@@ -0,0 +1,52 @@
1
+ # Shell surface elevation (sidebar · secondary panel · page)
2
+
3
+ > **Tokens:** `app/globals.css` — `--sidebar`, `--secondary-panel-bg`, `--background`, `--brand-tint*`.
4
+ > **Shell:** `components/templates/nested-secondary-panel-shell.tsx` — `bg-[var(--secondary-panel-bg)]`.
5
+ > **Cursor:** `.cursor/rules/exxat-primary-nav-secondary-panel.mdc` · `.cursor/skills/exxat-primary-nav-secondary-panel/SKILL.md`
6
+
7
+ ## Stack (back → front)
8
+
9
+ | Level | Surface | Token / class | Notes |
10
+ |-------|---------|---------------|--------|
11
+ | **0** | Primary icon rail + app chrome | `--sidebar` (= `--brand-tint` on light product themes) | Darkest brand wash in the shell |
12
+ | **1** | Nested secondary panel (Library, etc.) | `--secondary-panel-bg` | **Lighter** than level 0; **same product hue** |
13
+ | **2** | Main page / inset content | `--background` | Lightest (white canvas light; dark charcoal dark) |
14
+
15
+ **MUST** derive secondary panel fill from **`--brand-tint` / `--brand-tint-light`**, not a fixed rose or neutral grey. When the user selects **Exxat One**, both levels use **indigo hue ~286**; **Prism** uses **rose ~342**; **`theme-custom`** follows `--custom-product-brand-color` via `ProductProvider`.
16
+
17
+ ## OKLCH formulas (light)
18
+
19
+ ```css
20
+ --sidebar: var(--brand-tint);
21
+ --secondary-panel-bg: color-mix(in oklch, var(--background) 40%, var(--brand-tint-light) 60%);
22
+ ```
23
+
24
+ ## OKLCH formulas (dark)
25
+
26
+ ```css
27
+ --secondary-panel-bg: color-mix(in oklch, var(--card) 32%, var(--brand-tint) 68%);
28
+ ```
29
+
30
+ Per-product **dark** theme blocks (`.theme-one.dark`, `.theme-prism.dark`, …) set **`--brand-tint-light`** where needed so mixes stay on-hue.
31
+
32
+ ## Implementation
33
+
34
+ - **`NestedSecondaryPanelShell`** — `bg-[var(--secondary-panel-bg)]`, `ring-sidebar-border` (not generic `ring-border` alone).
35
+ - **Do not** set secondary panel to `bg-sidebar` (same as level 0 — loses elevation).
36
+ - **Do not** use `color-mix(… var(--sidebar) …)` without brand tokens if it drifts from active product theme.
37
+
38
+ ## Product theme classes
39
+
40
+ - **`theme-one`** / **`theme-prism`** / **`theme-assessment`** — built-in OKLCH brand scales in `globals.css`.
41
+ - **`theme-custom`** — when user picks an accent in Settings; driven by `--custom-product-brand-color`.
42
+ - **`ProductProvider`** — applies `theme-one` vs `theme-prism` vs `theme-custom`; accent override only when it **differs** from the product default (see `accentOverrideActive` in `contexts/product-context.tsx`).
43
+
44
+ ## Logo vs chrome
45
+
46
+ - **Chrome** (sidebar, secondary panel, KPI glow) follows **`--brand-tint` / `--brand-color`** per product.
47
+ - **Logo art** (mark + suffix) stays **Exxat pink** via `wordmarkColor` / `markGradient` in `lib/product-brand.ts` — recolouring a product in Settings changes **theme accent**, not corporate logo pink.
48
+
49
+ ## See also
50
+
51
+ - **`docs/kpi-flat-band-pattern.md`** — flat KPI strip uses brand glow only, no surface
52
+ - **`apps/web/AGENTS.md` §4.6** — secondary panel wiring
@@ -0,0 +1,13 @@
1
+ /** Detect stale Turbopack / webpack chunk failures after dev-server rebuilds. */
2
+ export function isChunkLoadError(error: unknown): boolean {
3
+ if (!error || typeof error !== "object") return false
4
+ const err = error as { name?: string; message?: string }
5
+ const name = err.name ?? ""
6
+ const msg = err.message ?? ""
7
+ return (
8
+ name === "ChunkLoadError" ||
9
+ msg.includes("Failed to load chunk") ||
10
+ msg.includes("Loading chunk") ||
11
+ msg.includes("ChunkLoadError")
12
+ )
13
+ }
@@ -1,32 +1,97 @@
1
- import type { ConditionalRule } from "@/components/table-properties/types"
1
+ import type { ConditionalRule, FilterTextMask } from "@/components/table-properties/types"
2
2
 
3
- /** First matching conditional rule background for a row (same logic as DataTable cells). */
3
+ export type ConditionalColumnHint = {
4
+ key: string
5
+ sortKey?: string
6
+ filter?: { type?: string; textMask?: FilterTextMask }
7
+ }
8
+
9
+ function rowValueForRule<T extends Record<string, unknown>>(
10
+ row: T,
11
+ rule: ConditionalRule,
12
+ columns?: ConditionalColumnHint[],
13
+ ): string {
14
+ const col = columns?.find(c => c.key === rule.fieldKey)
15
+ const dataKey = (col?.sortKey ?? rule.fieldKey) as keyof T
16
+ return String(row[dataKey] ?? "")
17
+ }
18
+
19
+ function ruleHasActiveValues(
20
+ rule: ConditionalRule,
21
+ columns?: ConditionalColumnHint[],
22
+ ): boolean {
23
+ if (rule.values.length === 0) return false
24
+ const col = columns?.find(c => c.key === rule.fieldKey)
25
+ if (col?.filter?.type === "text") return (rule.values[0] ?? "").trim().length > 0
26
+ return true
27
+ }
28
+
29
+ function conditionalTextMatches(
30
+ cellVal: string,
31
+ needle: string,
32
+ op: "contains" | "not_contains",
33
+ textMask: FilterTextMask | undefined,
34
+ ) {
35
+ const v = cellVal.trim()
36
+ const n = needle.trim()
37
+ if (!n) return op === "not_contains"
38
+ if (textMask === "phone" || textMask === "zip") {
39
+ const nd = n.replace(/\D/g, "")
40
+ const hay = v.replace(/\D/g, "")
41
+ if (!nd) return op === "not_contains"
42
+ const hit = hay.includes(nd)
43
+ return op === "contains" ? hit : !hit
44
+ }
45
+ const hit = v.toLowerCase().includes(n.toLowerCase())
46
+ return op === "contains" ? hit : !hit
47
+ }
48
+
49
+ /** Whether a conditional rule matches a row (same logic as DataTable cells). */
50
+ export function conditionalRuleMatchesRow<T extends Record<string, unknown>>(
51
+ row: T,
52
+ rule: ConditionalRule,
53
+ columns?: ConditionalColumnHint[],
54
+ ): boolean {
55
+ if (!ruleHasActiveValues(rule, columns)) return false
56
+ const v = rowValueForRule(row, rule, columns).trim()
57
+ const col = columns?.find(c => c.key === rule.fieldKey)
58
+ const textMask = col?.filter?.type === "text" ? col.filter.textMask : undefined
59
+ switch (rule.operator) {
60
+ case "is":
61
+ return rule.values.includes(v)
62
+ case "is_not":
63
+ return !rule.values.includes(v)
64
+ case "contains":
65
+ return rule.values.some(val => conditionalTextMatches(v, val, "contains", textMask))
66
+ case "not_contains":
67
+ return !rule.values.some(val => conditionalTextMatches(v, val, "contains", textMask))
68
+ default:
69
+ return false
70
+ }
71
+ }
72
+
73
+ /** First matching conditional rule background for a row (list/board row tint). */
4
74
  export function getConditionalRowBackground<T extends Record<string, unknown>>(
5
75
  row: T,
6
76
  rules: ConditionalRule[] | undefined,
77
+ columns?: ConditionalColumnHint[],
7
78
  ): string | undefined {
8
79
  if (!rules?.length) return undefined
9
80
  for (const rule of rules) {
10
- const cellVal = String(row[rule.fieldKey as keyof T] ?? "")
11
- const v = cellVal.trim()
12
- switch (rule.operator) {
13
- case "is":
14
- if (rule.values.length > 0 && rule.values.includes(v)) return rule.bgColor
15
- break
16
- case "is_not":
17
- if (rule.values.length > 0 && !rule.values.includes(v)) return rule.bgColor
18
- break
19
- case "contains":
20
- if (rule.values.length > 0 && rule.values.some(val => v.toLowerCase().includes(val.toLowerCase())))
21
- return rule.bgColor
22
- break
23
- case "not_contains":
24
- if (rule.values.length > 0 && !rule.values.some(val => v.toLowerCase().includes(val.toLowerCase())))
25
- return rule.bgColor
26
- break
27
- default:
28
- break
29
- }
81
+ if (conditionalRuleMatchesRow(row, rule, columns)) return rule.bgColor
30
82
  }
31
83
  return undefined
32
84
  }
85
+
86
+ /** Background for one table cell from conditional rules on that column. */
87
+ export function getConditionalCellBackground<T extends Record<string, unknown>>(
88
+ row: T,
89
+ colKey: string,
90
+ rules: ConditionalRule[] | undefined,
91
+ columns?: ConditionalColumnHint[],
92
+ ): string | undefined {
93
+ if (!rules?.length) return undefined
94
+ const rule = rules.find(r => r.fieldKey === colKey)
95
+ if (!rule || !conditionalRuleMatchesRow(row, rule, columns)) return undefined
96
+ return rule.bgColor
97
+ }
@@ -30,3 +30,9 @@ export function dataListViewLabel(view: DataListViewType): string {
30
30
  export function dataListViewIcon(view: DataListViewType): string {
31
31
  return DATA_LIST_VIEW_TILES.find(t => t.value === view)?.icon ?? "fa-table"
32
32
  }
33
+
34
+ /** Add-view menu hint + `<Shortcut>` keys (1–9). Skipped in inputs via `useShortcut`. */
35
+ export function dataListViewAddShortcut(index: number): string | undefined {
36
+ if (index < 0 || index > 8) return undefined
37
+ return String(index + 1)
38
+ }
@@ -0,0 +1,9 @@
1
+ /** Cookie name persisted by `@exxatdesignux/ui` `SidebarProvider` (`setOpen`). */
2
+ export const SIDEBAR_STATE_COOKIE_NAME = "sidebar_state"
3
+
4
+ /** Read desktop sidebar expanded state for SSR `defaultOpen` (matches client cookie restore). */
5
+ export function sidebarDefaultOpenFromCookie(
6
+ value: string | undefined,
7
+ ): boolean {
8
+ return value !== "false"
9
+ }
@@ -229,7 +229,19 @@ function filterRecordKeys<T extends Record<string, unknown>>(obj: T, keys: Set<s
229
229
  return out
230
230
  }
231
231
 
232
- export function applyLifecyclePersisted(
232
+ function sanitizeActiveFilters(
233
+ filters: ActiveFilter[],
234
+ columnKeys: Set<string>,
235
+ ): ActiveFilter[] {
236
+ return filters.filter(f => columnKeys.has(f.fieldKey))
237
+ }
238
+
239
+ function sanitizeSortRules(rules: SortRule[], columnKeys: Set<string>): SortRule[] {
240
+ return rules.filter(r => columnKeys.has(r.fieldKey))
241
+ }
242
+
243
+ /** Column layout only — keeps in-memory search / filters when the column set changes. */
244
+ export function applyLifecycleColumnLayout(
233
245
  ts: TableStatePersistSlice,
234
246
  p: PersistedLifecycleV1,
235
247
  columnKeys: Set<string>,
@@ -241,11 +253,6 @@ export function applyLifecyclePersisted(
241
253
  const colWrap = filterRecordKeys(p.colWrap, columnKeys) as Record<string, boolean>
242
254
  const colMenuSearch = filterRecordKeys(p.colMenuSearch, columnKeys) as Record<string, string>
243
255
 
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
256
  ts.setColOrder(colOrder)
250
257
  ts.setHiddenCols(hidden)
251
258
  ts.setColWidths(colWidths)
@@ -254,6 +261,20 @@ export function applyLifecyclePersisted(
254
261
  ts.setColMenuSearch(colMenuSearch)
255
262
  ts.setRowHeight(p.rowHeight)
256
263
  ts.setShowGridlines(p.showGridlines)
264
+ }
265
+
266
+ export function applyLifecyclePersisted(
267
+ ts: TableStatePersistSlice,
268
+ p: PersistedLifecycleV1,
269
+ columnKeys: Set<string>,
270
+ ): void {
271
+ applyLifecycleColumnLayout(ts, p, columnKeys)
272
+
273
+ ts.setSortRules(sanitizeSortRules(p.sortRules, columnKeys))
274
+ ts.setSearch(p.search)
275
+ ts.setActiveFilters(sanitizeActiveFilters(p.activeFilters, columnKeys))
276
+ ts.setFilterConnectors(p.filterConnectors)
277
+ ts.setGroupBy(p.groupBy != null && columnKeys.has(p.groupBy) ? p.groupBy : null)
257
278
  ts.setFilterBarVisible(p.filterBarVisible)
258
279
  ts.setSearchOpen(p.searchOpen)
259
280
  }
@@ -427,20 +448,46 @@ export function useTableStateLifecycle<TExtras extends Record<string, unknown> |
427
448
  onLoadExtrasRef.current = onLoadExtras
428
449
  })
429
450
 
451
+ const columnKeysFingerprint = React.useMemo(
452
+ () => [...columnKeys].sort().join("\0"),
453
+ [columnKeys],
454
+ )
455
+
456
+ const loadedScopeRef = React.useRef<string | null>(null)
457
+ const appliedColumnFingerprintRef = React.useRef<string | null>(null)
458
+
430
459
  // ── Load ────────────────────────────────────────────────────────────────
431
460
  // useLayoutEffect so the rehydrated state paints in the first frame after
432
461
  // mount instead of flashing the unhydrated defaults first.
433
462
  React.useLayoutEffect(() => {
463
+ // Wait until column defs exist — applying persisted sort/filters against an
464
+ // empty key set would sanitize everything away and look like Properties broke.
465
+ if (columnKeys.size === 0) return
466
+
467
+ const scope = `${namespace}:${tabId}`
434
468
  const raw = loadLifecycleFromStorage(namespace, tabId)
469
+
470
+ if (loadedScopeRef.current !== scope) {
471
+ loadedScopeRef.current = scope
472
+ appliedColumnFingerprintRef.current = columnKeysFingerprint
473
+ if (!raw) return
474
+ applyLifecyclePersisted(tableState, raw, columnKeys)
475
+ const e = readLifecycleExtras<Record<string, unknown>>(raw)
476
+ onLoadExtrasRef.current?.(e as TExtras | Record<string, unknown> | undefined)
477
+ return
478
+ }
479
+
480
+ if (appliedColumnFingerprintRef.current === columnKeysFingerprint) return
481
+ appliedColumnFingerprintRef.current = columnKeysFingerprint
435
482
  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)
483
+ // Column defs changed (e.g. hub scope / dynamic filter options) — re-merge
484
+ // layout only; do not wipe in-memory filters the user set in Properties.
485
+ applyLifecycleColumnLayout(tableState, raw, columnKeys)
439
486
  // `tableState` is freshly returned each render; depending on it would
440
487
  // re-apply persisted state on every keystroke and undo edits. Depend only
441
- // on the load scope (namespace + tabId + column set).
488
+ // on the load scope (namespace + tabId + column fingerprint).
442
489
  // eslint-disable-next-line react-hooks/exhaustive-deps
443
- }, [namespace, tabId, columnKeys])
490
+ }, [namespace, tabId, columnKeysFingerprint])
444
491
 
445
492
  // ── Save ────────────────────────────────────────────────────────────────
446
493
  // Serialise + debounce on every persisted slice change. Don't depend on