@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
@@ -187,10 +187,21 @@ html[data-text-size="large"] {
187
187
  --leo-surface-tint-b: color-mix(in oklch, var(--brand-color) 8%, var(--background));
188
188
  --leo-surface-gradient: linear-gradient(180deg, var(--leo-surface-tint-a) 0%, var(--leo-surface-tint-b) 100%);
189
189
 
190
- /* KeyMetrics `variant="flat"` — soft KPI band (lavender wash canvas) */
191
- --key-metrics-flat-grad-top: color-mix(in oklch, var(--brand-tint-light) 90%, var(--background));
192
- --key-metrics-flat-grad-mid: color-mix(in oklch, var(--brand-tint) 34%, var(--background));
193
- --key-metrics-flat-cell-bg: color-mix(in oklch, var(--background) 80%, var(--brand-tint-light));
190
+ /* KeyMetrics `variant="flat"` — no band surface; bottom brand glow only (OKLCH). */
191
+ --key-metrics-flat-cell-bg: transparent;
192
+ --key-metrics-flat-divider: color-mix(in oklch, var(--sidebar-border) 55%, transparent);
193
+ --key-metrics-flat-band-radial: radial-gradient(
194
+ ellipse 120% 68% at 50% 100%,
195
+ color-mix(in oklch, var(--brand-color) 20%, transparent) 0%,
196
+ color-mix(in oklch, var(--brand-color) 8%, transparent) 42%,
197
+ transparent 72%
198
+ );
199
+ --key-metrics-flat-band-shadow: none;
200
+ --key-metrics-card-glow-radial: radial-gradient(
201
+ ellipse 110% 90% at 50% 100%,
202
+ color-mix(in oklch, var(--brand-color) 18%, transparent) 0%,
203
+ transparent 65%
204
+ );
194
205
 
195
206
  /* ── Surfaces ────────────────────────────────────────────────── */
196
207
  --background: oklch(1 0 0);
@@ -270,8 +281,8 @@ html[data-text-size="large"] {
270
281
  --sidebar-ring: oklch(0.25 0 0);
271
282
  /* Nav section titles — ≥4.5:1 vs --sidebar (not vs page white) */
272
283
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 58%, var(--sidebar));
273
- /* Nested secondary rail — soft brand wash on canvas (not pure `--background`, not full `--sidebar`). */
274
- --secondary-panel-bg: color-mix(in oklch, var(--brand-tint) 34%, var(--background));
284
+ /* Nested secondary rail — elevation 1: brand wash, lighter than `--sidebar` / `--brand-tint`. */
285
+ --secondary-panel-bg: color-mix(in oklch, var(--background) 40%, var(--brand-tint-light) 60%);
275
286
 
276
287
  /* Browser UI (meta theme-color) — aligned with --brand-tint */
277
288
  --theme-color-chrome: #f3f2f8;
@@ -379,9 +390,21 @@ html[data-text-size="large"] {
379
390
  --destructive: oklch(0.65 0.20 25); /* brighter for dark bg */
380
391
  --destructive-foreground: oklch(0.10 0 0);
381
392
 
382
- --key-metrics-flat-grad-top: color-mix(in oklch, var(--brand-color) 14%, var(--background));
383
- --key-metrics-flat-grad-mid: color-mix(in oklch, var(--muted) 42%, var(--background));
384
- --key-metrics-flat-cell-bg: color-mix(in oklch, var(--background) 88%, var(--brand-color));
393
+ /* KeyMetrics flat band no surface; bottom brand glow only (OKLCH). */
394
+ --key-metrics-flat-cell-bg: transparent;
395
+ --key-metrics-flat-divider: color-mix(in oklch, var(--sidebar-border) 55%, transparent);
396
+ --key-metrics-flat-band-radial: radial-gradient(
397
+ ellipse 120% 68% at 50% 100%,
398
+ color-mix(in oklch, var(--brand-color) 26%, transparent) 0%,
399
+ color-mix(in oklch, var(--brand-color) 10%, transparent) 42%,
400
+ transparent 72%
401
+ );
402
+ --key-metrics-flat-band-shadow: none;
403
+ --key-metrics-card-glow-radial: radial-gradient(
404
+ ellipse 110% 90% at 50% 100%,
405
+ color-mix(in oklch, var(--brand-color) 22%, transparent) 0%,
406
+ transparent 62%
407
+ );
385
408
 
386
409
  /* Borders — visible but not washed out on dark surfaces */
387
410
  --border: oklch(0.38 0.008 270);
@@ -431,8 +454,8 @@ html[data-text-size="large"] {
431
454
  --sidebar-border: oklch(0.38 0.010 270);
432
455
  --sidebar-ring: oklch(0.85 0 0);
433
456
  --sidebar-section-label-foreground: color-mix(in oklch, var(--sidebar-foreground) 48%, var(--sidebar));
434
- /* Nested secondary rail — dark: subtle brand lift on canvas (not flat `--background` only). */
435
- --secondary-panel-bg: color-mix(in oklch, var(--background) 82%, var(--brand-color) 18%);
457
+ /* Nested secondary rail — elevation 1: brand stack, lifted toward `--card` / page. */
458
+ --secondary-panel-bg: color-mix(in oklch, var(--card) 32%, var(--brand-tint) 68%);
436
459
  --theme-color-chrome: #2f2d36;
437
460
 
438
461
  /* Lifted scrim on dark — white-tinted veil, not heavy black */
@@ -493,6 +516,7 @@ html[data-text-size="large"] {
493
516
  --secondary: oklch(0.31 0.04 286.1);
494
517
  --muted: oklch(0.31 0.04 286.1);
495
518
  --accent: oklch(0.33 0.06 286.1);
519
+ --brand-tint-light: oklch(0.30 0.014 286.1);
496
520
  }
497
521
 
498
522
  /* ==========================================================================
@@ -501,9 +525,14 @@ html[data-text-size="large"] {
501
525
  ========================================================================== */
502
526
  .theme-prism,
503
527
  .theme-rose {
504
- --brand-color: oklch(0.57 0.24 342); /* Prism rose */
505
- --brand-color-dark: oklch(0.42 0.24 342);
506
- --ring: var(--brand-color-dark);
528
+ --brand-tint: oklch(0.97 0.02 343);
529
+ --brand-tint-light: oklch(0.992 0.01 343);
530
+ --brand-tint-subtle: oklch(0.93 0.028 343);
531
+ --brand-color: oklch(0.57 0.24 342); /* Prism rose */
532
+ --brand-color-light: oklch(0.78 0.14 342);
533
+ --brand-color-dark: oklch(0.42 0.24 342);
534
+ --brand-color-deep: oklch(0.32 0.20 342);
535
+ --ring: var(--brand-color-dark);
507
536
  }
508
537
 
509
538
  .theme-prism:not(.dark),
@@ -531,6 +560,7 @@ html[data-text-size="large"] {
531
560
  --muted: oklch(0.31 0.04 342);
532
561
  --accent: oklch(0.33 0.06 342);
533
562
  --theme-color-chrome: #2a2428;
563
+ --brand-tint-light: oklch(0.30 0.014 342);
534
564
  }
535
565
 
536
566
  /* ==========================================================================
@@ -6,6 +6,7 @@ import "./globals.css"
6
6
  import { ThemeProvider } from "@/components/theme-provider"
7
7
  import { TooltipProvider } from "@/components/ui/tooltip"
8
8
  import { ProductProvider } from "@/contexts/product-context"
9
+ import { DevChunkLoadRecovery } from "@/components/dev-chunk-load-recovery"
9
10
  import { ThemeColorSync } from "@/components/theme-color-sync"
10
11
  import { cn } from "@/lib/utils"
11
12
 
@@ -90,6 +91,7 @@ export default function RootLayout({
90
91
  />
91
92
  </head>
92
93
  <body className="bg-sidebar text-foreground font-sans">
94
+ <DevChunkLoadRecovery />
93
95
  {/*
94
96
  * Font Awesome Pro Kit — subset via fontawesome-subset.manifest.json +
95
97
  * fontawesome.com/kits (Icon Selection).
@@ -844,10 +844,10 @@ function ProductLogoButton() {
844
844
  suppressHydrationWarning
845
845
  >
846
846
  {iconRail ? (
847
- // Match the school selector footprint in the icon rail; the
848
- // inner mark cutout uses the rail surface instead of a white fill.
847
+ // Match the school selector footprint in the icon rail (32px frame,
848
+ // 28px mark same visual weight as the avatar with inset padding).
849
849
  <span className="flex size-8 shrink-0 items-center justify-center">
850
- <ExxatProductMark product={current.id} className="size-8" cutoutColor="var(--sidebar)" />
850
+ <ExxatProductMark product={current.id} className="size-7" />
851
851
  </span>
852
852
  ) : (
853
853
  <span className="flex min-h-0 min-w-0 flex-1 items-stretch gap-2">
@@ -1004,6 +1004,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
1004
1004
  <SidebarGroupLabel
1005
1005
  id="sidebar-documents-heading"
1006
1006
  className="text-xs font-medium uppercase tracking-wide px-2 text-sidebar-section-label"
1007
+ suppressHydrationWarning
1007
1008
  >
1008
1009
  {NAV_DOCUMENTS_LABEL}
1009
1010
  </SidebarGroupLabel>
@@ -30,7 +30,6 @@ import type { DataListViewType } from "@/lib/data-list-view"
30
30
  import type { OpenTablePropertiesHandle } from "@/lib/list-page-table-properties"
31
31
  import type { ColumnDef } from "@/components/data-table/types"
32
32
  import { useTableState } from "@/components/data-table/use-table-state"
33
- import { useTableStateLifecycle } from "@/lib/table-state-lifecycle"
34
33
  import { TablePropertiesDrawerButton } from "@/components/table-properties"
35
34
  import type { ConditionalRule, FilterFieldDef, FilterOperator } from "@/components/table-properties/types"
36
35
  import { ListHubStatusBadge } from "@/components/list-hub-status-badge"
@@ -264,25 +263,6 @@ export const ComplianceTable = React.forwardRef<
264
263
 
265
264
  const tableState = useTableState(items, columns, { key: "dueDate", dir: "asc" })
266
265
 
267
- // Persist this hub's table lifecycle (sort / search / filters / column
268
- // visibility / etc.) to localStorage. See `lib/table-state-lifecycle`.
269
- const lifecycleColumnKeys = React.useMemo(
270
- () => new Set(columns.map(c => c.key)),
271
- [columns],
272
- )
273
- useTableStateLifecycle({
274
- namespace: "compliance",
275
- tabId: "main",
276
- tableState,
277
- columnKeys: lifecycleColumnKeys,
278
- extras: { conditionalRules },
279
- onLoadExtras: e => {
280
- if (e && Array.isArray(e.conditionalRules)) {
281
- setConditionalRules(e.conditionalRules as ConditionalRule[])
282
- }
283
- },
284
- })
285
-
286
266
  const dashboardKpi = React.useMemo(
287
267
  () => ({
288
268
  metrics: complianceKpiMetrics(tableState.rows as ComplianceItem[]),
@@ -56,7 +56,8 @@ import {
56
56
  TooltipTrigger,
57
57
  } from "@/components/ui/tooltip"
58
58
  import { OPERATOR_LABELS } from "@/components/table-properties/types"
59
- import type { ActiveFilter, FilterTextMask } from "@/components/table-properties/types"
59
+ import type { ActiveFilter } from "@/components/table-properties/types"
60
+ import { getConditionalCellBackground } from "@/lib/conditional-rule-match"
60
61
  import { formatYmdForDisplay } from "@/lib/date-filter"
61
62
  import { FilterDateCalendar } from "@/components/data-table/filter-date-calendar"
62
63
  import { FilterTextValueInput } from "@/components/data-table/filter-text-value-input"
@@ -81,26 +82,6 @@ function resolvedColumnLabel<TData>(col: ColumnDef<TData>): string {
81
82
  return defaultColumnHeaderLabel(col.key) ?? col.key
82
83
  }
83
84
 
84
- function conditionalTextMatches(
85
- cellVal: string,
86
- needle: string,
87
- op: "contains" | "not_contains",
88
- textMask: FilterTextMask | undefined,
89
- ) {
90
- const v = cellVal.trim()
91
- const n = needle.trim()
92
- if (!n) return op === "not_contains"
93
- if (textMask === "phone" || textMask === "zip") {
94
- const nd = n.replace(/\D/g, "")
95
- const hay = v.replace(/\D/g, "")
96
- if (!nd) return op === "not_contains"
97
- const hit = hay.includes(nd)
98
- return op === "contains" ? hit : !hit
99
- }
100
- const hit = v.toLowerCase().includes(n.toLowerCase())
101
- return op === "contains" ? hit : !hit
102
- }
103
-
104
85
  // ─────────────────────────────────────────────────────────────────────────────
105
86
  // Internal sub-components
106
87
  // ─────────────────────────────────────────────────────────────────────────────
@@ -802,12 +783,19 @@ function DataTableInner<TData extends Record<string, unknown>>({
802
783
  setSheetOpen,
803
784
  } = state
804
785
 
805
- // Mount overflow check
786
+ // Mount overflow check + scrollport width for sticky group headers on horizontal scroll.
806
787
  React.useEffect(() => {
807
- checkOverflow()
788
+ const syncScrollport = () => {
789
+ const el = scrollRef.current
790
+ if (el) {
791
+ el.style.setProperty("--dt-scrollport-width", `${el.clientWidth}px`)
792
+ }
793
+ checkOverflow()
794
+ }
795
+ syncScrollport()
808
796
  const el = scrollRef.current
809
797
  if (!el) return
810
- const ro = new ResizeObserver(checkOverflow)
798
+ const ro = new ResizeObserver(syncScrollport)
811
799
  ro.observe(el)
812
800
  return () => ro.disconnect()
813
801
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -1351,18 +1339,19 @@ function DataTableInner<TData extends Record<string, unknown>>({
1351
1339
  <React.Fragment key={groupKey ?? "__all__"}>
1352
1340
  {groupLabel && (
1353
1341
  <tr>
1354
- <td
1355
- colSpan={displayCols.length}
1356
- className={cn(
1357
- "px-4 py-1.5 text-xs font-semibold text-muted-foreground tracking-wide bg-dt-group-bg select-none",
1358
- !isReflowViewport && "sticky left-0",
1359
- "border-b border-border",
1360
- )}
1361
- >
1362
- {groupLabel}
1363
- <span className="ml-2 font-normal normal-case opacity-60 tracking-normal">
1364
- {groupRows.length} record{groupRows.length !== 1 ? "s" : ""}
1365
- </span>
1342
+ <td colSpan={displayCols.length} className="p-0 border-b border-border bg-dt-group-bg">
1343
+ <div
1344
+ className={cn(
1345
+ "sticky left-0 z-[25] px-4 py-1.5 text-xs font-semibold text-muted-foreground tracking-wide bg-dt-group-bg select-none",
1346
+ !isReflowViewport && "shadow-[4px_0_8px_-4px_var(--sticky-edge-fade)]",
1347
+ )}
1348
+ style={{ width: "var(--dt-scrollport-width, 100%)" }}
1349
+ >
1350
+ {groupLabel}
1351
+ <span className="ml-2 font-normal normal-case opacity-60 tracking-normal">
1352
+ {groupRows.length} record{groupRows.length !== 1 ? "s" : ""}
1353
+ </span>
1354
+ </div>
1366
1355
  </td>
1367
1356
  </tr>
1368
1357
  )}
@@ -1419,37 +1408,12 @@ function DataTableInner<TData extends Record<string, unknown>>({
1419
1408
  ]
1420
1409
  )
1421
1410
 
1422
- // Conditional rule background for this cell
1423
- const conditionalBg = conditionalRules?.find(rule => {
1424
- if (rule.fieldKey !== col.key) return false
1425
- const cellVal = String(row[rule.fieldKey as keyof TData] ?? "")
1426
- const v = cellVal.trim()
1427
- const ruleCol = columns.find(c => c.key === rule.fieldKey)
1428
- const textMask =
1429
- ruleCol?.filter?.type === "text" ? ruleCol.filter.textMask : undefined
1430
- switch (rule.operator) {
1431
- case "is":
1432
- return rule.values.length > 0 && rule.values.includes(v)
1433
- case "is_not":
1434
- return rule.values.length > 0 && !rule.values.includes(v)
1435
- case "contains":
1436
- return (
1437
- rule.values.length > 0 &&
1438
- rule.values.some(val =>
1439
- conditionalTextMatches(v, val, "contains", textMask),
1440
- )
1441
- )
1442
- case "not_contains":
1443
- return (
1444
- rule.values.length > 0 &&
1445
- !rule.values.some(val =>
1446
- conditionalTextMatches(v, val, "contains", textMask),
1447
- )
1448
- )
1449
- default:
1450
- return false
1451
- }
1452
- })?.bgColor
1411
+ const conditionalBg = getConditionalCellBackground(
1412
+ row,
1413
+ col.key,
1414
+ conditionalRules,
1415
+ columns,
1416
+ )
1453
1417
 
1454
1418
  const tdStyle = conditionalBg
1455
1419
  ? { ...cs, background: conditionalBg }
@@ -112,7 +112,8 @@ export function useTableState<TData extends Record<string, unknown>>(
112
112
  const addSortRule = React.useCallback((fieldKey: string) => {
113
113
  setSortRules(prev => {
114
114
  if (prev.some(r => r.fieldKey === fieldKey)) return prev
115
- return [...prev, { id: `sort-${Date.now()}`, fieldKey, direction: "asc" }]
115
+ // New drawer sorts are primary (same as column-header sort), not trailing.
116
+ return [{ id: `sort-${Date.now()}`, fieldKey, direction: "asc" }, ...prev]
116
117
  })
117
118
  }, [setSortRules])
118
119
 
@@ -178,9 +179,12 @@ export function useTableState<TData extends Record<string, unknown>>(
178
179
  }
179
180
  return f.operators?.[0] ?? "contains"
180
181
  })()
181
- setActiveFilters(prev => [...prev, { id, fieldKey, operator: firstOperator, values: [] }])
182
+ const newFilter: ActiveFilter = { id, fieldKey, operator: firstOperator, values: [] }
183
+ setActiveFilters(prev => [...prev, newFilter])
182
184
  if (fromDrawer) {
183
- setDrawerExpandedFilters(new Set([id]))
185
+ setDrawerExpandedFilters(() => new Set([id]))
186
+ // Keep toolbar pills hidden until a value is chosen — avoids mounting every
187
+ // FilterPill (heavy) on each drawer "Add filter" click.
184
188
  } else {
185
189
  setOpenFilterId(id)
186
190
  setFilterBarVisible(true)
@@ -188,8 +192,24 @@ export function useTableState<TData extends Record<string, unknown>>(
188
192
  }, [columns, setActiveFilters, setDrawerExpandedFilters, setOpenFilterId, setFilterBarVisible])
189
193
 
190
194
  const updateFilter = React.useCallback((id: string, patch: Partial<ActiveFilter>) => {
191
- setActiveFilters(prev => prev.map(f => f.id === id ? { ...f, ...patch } : f))
192
- }, [setActiveFilters])
195
+ let shouldShowFilterBar = false
196
+ setActiveFilters(prev => {
197
+ const next = prev.map(f => {
198
+ if (f.id !== id) return f
199
+ const merged = { ...f, ...patch }
200
+ const col = columns.find(c => c.key === merged.fieldKey)
201
+ if (merged.values.length > 0) {
202
+ shouldShowFilterBar =
203
+ col?.filter?.type === "text"
204
+ ? (merged.values[0] ?? "").trim().length > 0
205
+ : true
206
+ }
207
+ return merged
208
+ })
209
+ return next
210
+ })
211
+ if (shouldShowFilterBar) setFilterBarVisible(true)
212
+ }, [columns, setActiveFilters, setFilterBarVisible])
193
213
 
194
214
  const removeFilter = React.useCallback((id: string) => {
195
215
  // Use functional updates only — no stale-closure risk on activeFilters.
@@ -342,7 +362,14 @@ export function useTableState<TData extends Record<string, unknown>>(
342
362
  result = result.filter(r => getSearchableText(r).includes(q))
343
363
  }
344
364
 
345
- const activeWithValues = activeFilters.filter(f => f.values.length > 0)
365
+ const activeWithValues = activeFilters.filter(f => {
366
+ if (f.values.length === 0) return false
367
+ const col = columnsByKey.get(f.fieldKey)
368
+ if (col?.filter?.type === "text") {
369
+ return (f.values[0] ?? "").trim().length > 0
370
+ }
371
+ return true
372
+ })
346
373
  if (activeWithValues.length > 0) {
347
374
  // Pre-resolve column, operator, normalised needle, and select-value Set
348
375
  // for each active filter ONCE (instead of per row).
@@ -0,0 +1,41 @@
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+
5
+ import { isChunkLoadError } from "@/lib/chunk-load-error"
6
+
7
+ const RELOAD_FLAG = "exxat-ds:chunk-reload-attempted"
8
+
9
+ /**
10
+ * Dev-only: auto-reload once when Turbopack serves a stale chunk hash so users
11
+ * are not stuck on a blank shell before the route error boundary mounts.
12
+ */
13
+ export function DevChunkLoadRecovery() {
14
+ React.useEffect(() => {
15
+ if (process.env.NODE_ENV !== "development") return
16
+
17
+ function maybeReload(error: unknown) {
18
+ if (!isChunkLoadError(error)) return
19
+ if (typeof window === "undefined") return
20
+ if (window.sessionStorage.getItem(RELOAD_FLAG) === "1") return
21
+ window.sessionStorage.setItem(RELOAD_FLAG, "1")
22
+ window.location.reload()
23
+ }
24
+
25
+ const onError = (event: ErrorEvent) => {
26
+ maybeReload(event.error ?? event.message)
27
+ }
28
+ const onRejection = (event: PromiseRejectionEvent) => {
29
+ maybeReload(event.reason)
30
+ }
31
+
32
+ window.addEventListener("error", onError)
33
+ window.addEventListener("unhandledrejection", onRejection)
34
+ return () => {
35
+ window.removeEventListener("error", onError)
36
+ window.removeEventListener("unhandledrejection", onRejection)
37
+ }
38
+ }, [])
39
+
40
+ return null
41
+ }
@@ -38,7 +38,7 @@ export type ExxatProductLogoVariant = "default" | "mutedSuffix"
38
38
  export interface ExxatProductLogoProps {
39
39
  product: Product
40
40
  className?: string
41
- /** Sidebar / switcher: muted wordmark in dark mode only; light keeps brand color. */
41
+ /** Reserved for switcher chrome; suffix stays Exxat pink in all modes. */
42
42
  variant?: ExxatProductLogoVariant
43
43
  }
44
44
 
@@ -191,7 +191,6 @@ export function ExxatProductLogo({
191
191
  const customProductBrand = useAppStore(s => s.customProductBrand)
192
192
  const productBrandColors = useAppStore(s => s.productBrandColors)
193
193
  const config = brandForProduct(product, customProductBrand, productBrandColors)
194
- const muted = variant === "mutedSuffix"
195
194
  const suffixColor = config.wordmarkColor ?? config.brandColor
196
195
 
197
196
  return (
@@ -213,10 +212,7 @@ export function ExxatProductLogo({
213
212
  {/* HTML suffix — IvyPresto Text SemiBold per Figma brand spec. */}
214
213
  <span
215
214
  data-product-wordmark-suffix
216
- className={cn(
217
- "ms-[0.18em] text-[1.55em] font-semibold tracking-[-0.03em] -translate-y-[3px]",
218
- muted && "dark:!text-[var(--muted-foreground)]",
219
- )}
215
+ className="ms-[0.18em] text-[1.55em] font-semibold tracking-[-0.03em] -translate-y-[3px]"
220
216
  style={{
221
217
  fontFamily: "var(--font-heading), 'ivypresto-text', Georgia, serif",
222
218
  color: suffixColor,
@@ -216,6 +216,41 @@ export interface KeyMetricsProps {
216
216
  * (3fr / 2fr split). Tighter breakpoints because available width is ~60%
217
217
  * of the section.
218
218
  */
219
+ /**
220
+ * Flat KPI hairlines — cell borders only (no grid gap fill / no surface).
221
+ * Four tiles: default 4-across verticals; 2×2 hairlines only when @container is narrow.
222
+ */
223
+ function flatMetricsHairlineClass(
224
+ itemCount: number,
225
+ metricsHalfWidthLayout: boolean,
226
+ ): string {
227
+ if (itemCount <= 1) return "gap-0"
228
+
229
+ const childBorder = "[&>*]:border-[color:var(--key-metrics-flat-divider)]"
230
+
231
+ if (itemCount === 2) {
232
+ return cn("gap-0", childBorder, "[&>*:first-child]:border-r")
233
+ }
234
+
235
+ if (itemCount === 4) {
236
+ const narrow2x2 = metricsHalfWidthLayout
237
+ ? "@[max-width:25.99rem]"
238
+ : "@[max-width:29.99rem]"
239
+ return cn(
240
+ "gap-0",
241
+ childBorder,
242
+ /* Wide strip (matches `@[30rem]:grid-cols-4`) — verticals between all tiles, no horizontal */
243
+ "[&>*:not(:last-child)]:border-r",
244
+ /* Narrow strip (`@[18rem]`–`@[30rem]` 2×2) */
245
+ `${narrow2x2}:[&>*:not(:last-child)]:border-r-0`,
246
+ `${narrow2x2}:[&>*:nth-child(odd)]:border-r`,
247
+ `${narrow2x2}:[&>*:not(:nth-last-child(-n+2))]:border-b`,
248
+ )
249
+ }
250
+
251
+ return cn("gap-0", childBorder, "[&>*:not(:last-child)]:border-r")
252
+ }
253
+
219
254
  function metricsRowColumnsClass(rowLength: number, metricsHalfWidthLayout: boolean): string {
220
255
  const half = metricsHalfWidthLayout
221
256
  switch (rowLength) {
@@ -538,7 +573,7 @@ function KeyMetricsInner({
538
573
  }: InnerProps) {
539
574
  const isFlatBand = surfaceVariant === "flat"
540
575
  const metricsGridClassName = isFlatBand
541
- ? "gap-0 bg-transparent [&>*:not(:last-child)]:border-r [&>*:not(:last-child)]:border-foreground/[0.055]"
576
+ ? flatMetricsHairlineClass(metrics.length, metricsHalfWidthLayout)
542
577
  : "gap-px bg-border"
543
578
  /** Side-by-side KPI + insight rail (md+). Disabled for half-width dashboard cards — insight stacks below. */
544
579
  const insightSideBySide = insight && !insightFullWidth && !metricsHalfWidthLayout
@@ -642,17 +677,16 @@ function KeyMetricsInner({
642
677
  <div className="@container/metrics-strip hidden lg:block">
643
678
  {rows.map((row, rowIdx) => (
644
679
  <React.Fragment key={rowIdx}>
645
- {rowIdx > 0 && (
646
- <Separator
647
- aria-hidden="true"
648
- className={cn("my-1", isFlatBand && "bg-foreground/[0.055]")}
649
- />
680
+ {rowIdx > 0 && !isFlatBand && (
681
+ <Separator aria-hidden="true" className="my-1" />
650
682
  )}
651
683
  <div
652
684
  className={cn(
653
685
  "grid",
654
686
  metricsRowColumnsClass(row.length, metricsHalfWidthLayout),
655
- metricsGridClassName,
687
+ isFlatBand
688
+ ? flatMetricsHairlineClass(row.length, metricsHalfWidthLayout)
689
+ : metricsGridClassName,
656
690
  )}
657
691
  >
658
692
  {row.map((m) => (
@@ -693,8 +727,9 @@ function KeyMetricsInner({
693
727
  insightSideBySide &&
694
728
  !insightFullWidth &&
695
729
  cn(
696
- "lg:h-full lg:border-l lg:pl-6",
697
- isFlatBand ? "lg:border-border/40" : "lg:border-border",
730
+ "lg:h-full lg:pl-6",
731
+ /* Flat band: insight card ring is the divider — skip `border-l` (double line). */
732
+ !isFlatBand && "lg:border-l lg:border-border",
698
733
  )
699
734
  )}
700
735
  >
@@ -856,7 +891,9 @@ export function KeyMetrics({
856
891
  })()
857
892
 
858
893
  const metricsCellSurfaceClassName =
859
- variant === "flat" ? "bg-transparent" : "bg-card"
894
+ variant === "flat"
895
+ ? "bg-transparent"
896
+ : "bg-card dark:bg-transparent"
860
897
 
861
898
  const innerProps: InnerProps = {
862
899
  title,
@@ -900,18 +937,13 @@ export function KeyMetrics({
900
937
  * ─────────────────────────────────────────────────────────────────────────
901
938
  */
902
939
  const glowStyle: React.CSSProperties = {
903
- /* oklch relative color: inherit brand hue/chroma/lightness, set alpha only */
904
- background:
905
- "radial-gradient(ellipse 110% 90% at 50% 100%, oklch(from var(--brand-color) l c h / 0.13) 0%, transparent 65%)",
940
+ background: "var(--key-metrics-card-glow-radial)",
906
941
  }
907
942
 
908
- /** List-page KPI band: soft tint page bg + gentle lift (avoids a hard line into the toolbar). */
943
+ /** List-page KPI band transparent; only `--key-metrics-flat-band-radial` glow. */
909
944
  const flatBandStyle: React.CSSProperties = {
910
- background: [
911
- "radial-gradient(ellipse 118% 70% at 50% 100%, oklch(from var(--brand-color) l c h / 0.11) 0%, transparent 60%)",
912
- "linear-gradient(180deg, var(--key-metrics-flat-grad-top) 0%, var(--key-metrics-flat-grad-mid) 48%, var(--background) 100%)",
913
- ].join(", "),
914
- boxShadow: "0 18px 42px -26px color-mix(in oklch, var(--brand-color) 10%, transparent)",
945
+ background: "var(--key-metrics-flat-band-radial)",
946
+ boxShadow: "var(--key-metrics-flat-band-shadow)",
915
947
  }
916
948
 
917
949
  /* ── Card variant — ChartCard-style chrome ───────────────────────────── */
@@ -982,11 +1014,11 @@ export function KeyMetrics({
982
1014
  )
983
1015
  }
984
1016
 
985
- /* ── Flat variant — soft tint band + bottom glow (no sharp cut to content below) ── */
1017
+ /* ── Flat variant — no surface; bottom brand glow only ── */
986
1018
  return (
987
1019
  <section
988
1020
  aria-label={title}
989
- className={cn("relative w-full overflow-hidden pt-5 pb-6", className)}
1021
+ className={cn("relative w-full overflow-hidden pt-5 pb-8", className)}
990
1022
  style={flatBandStyle}
991
1023
  >
992
1024
  <KeyMetricsInner
@@ -1025,7 +1057,7 @@ export function KeyMetricsContent({
1025
1057
  showHeader={false}
1026
1058
  insightCompact={insightCompact}
1027
1059
  insightFullWidth={insightFullWidth}
1028
- metricsCellSurfaceClassName="bg-card"
1060
+ metricsCellSurfaceClassName="bg-card dark:bg-transparent"
1029
1061
  />
1030
1062
  )
1031
1063
  }
@@ -173,7 +173,7 @@ export function BoardPlacementCard({
173
173
  onOpen: (id: number) => void
174
174
  }) {
175
175
  const lc = lineClampClass(lineCount)
176
- const ruleBg = getConditionalRowBackground(row, conditionalRules)
176
+ const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
177
177
 
178
178
  const visibleCols = boardColumns.filter(c => !hiddenColKeys.has(c.key))
179
179
  const showStudent = visibleCols.some(c => c.key === "student")
@@ -62,7 +62,7 @@ function PlacementListRowContent({
62
62
  conditionalRules: ConditionalRule[] | undefined
63
63
  onOpen: (id: number) => void
64
64
  }) {
65
- const ruleBg = getConditionalRowBackground(row, conditionalRules)
65
+ const ruleBg = getConditionalRowBackground(row, conditionalRules, boardColumns)
66
66
  const showStudent = isBoardFieldActive("student", tab, hiddenColKeys, boardColumns)
67
67
  const showStatus = isBoardFieldActive("status", tab, hiddenColKeys, boardColumns)
68
68
  const showSite = isBoardFieldActive("site", tab, hiddenColKeys, boardColumns)