@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.
- package/CHANGELOG.md +15 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +1 -1
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +6 -1
- package/consumer-extras/patterns/data-views-pattern.md +2 -0
- package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
- package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
- package/package.json +1 -1
- package/src/components/ui/sidebar.tsx +2 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/AGENTS.md +11 -4
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +44 -14
- package/template/app/layout.tsx +2 -0
- package/template/components/app-sidebar.tsx +4 -3
- package/template/components/compliance-table.tsx +0 -20
- package/template/components/data-table/index.tsx +31 -67
- package/template/components/data-table/use-table-state.ts +33 -6
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/exxat-product-logo.tsx +2 -6
- package/template/components/key-metrics.tsx +54 -22
- package/template/components/placement-board-card.tsx +1 -1
- package/template/components/placements-list-view.tsx +1 -1
- package/template/components/placements-table.tsx +3 -36
- package/template/components/product-switcher.tsx +2 -3
- package/template/components/product-wordmark.tsx +4 -7
- package/template/components/question-bank-hub-client.tsx +2 -5
- package/template/components/question-bank-table.tsx +12 -24
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/sites-table.tsx +0 -20
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +16 -13
- package/template/components/team-table.tsx +0 -21
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +2 -2
- package/template/contexts/product-context.tsx +21 -2
- package/template/docs/data-views-pattern.md +2 -0
- package/template/docs/kpi-flat-band-pattern.md +57 -0
- package/template/docs/kpi-strip-max-four-pattern.md +1 -0
- package/template/docs/shell-surface-elevation-pattern.md +52 -0
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-view.ts +6 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +58 -11
package/template/app/globals.css
CHANGED
|
@@ -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"` —
|
|
191
|
-
--key-metrics-flat-
|
|
192
|
-
--key-metrics-flat-
|
|
193
|
-
--key-metrics-flat-
|
|
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 —
|
|
274
|
-
--secondary-panel-bg: color-mix(in oklch, var(--
|
|
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
|
-
|
|
383
|
-
--key-metrics-flat-
|
|
384
|
-
--key-metrics-flat-
|
|
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 —
|
|
435
|
-
--secondary-panel-bg: color-mix(in oklch, var(--
|
|
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-
|
|
505
|
-
--brand-
|
|
506
|
-
--
|
|
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
|
/* ==========================================================================
|
package/template/app/layout.tsx
CHANGED
|
@@ -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
|
|
848
|
-
//
|
|
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-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
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
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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 =>
|
|
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
|
-
/**
|
|
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=
|
|
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
|
-
?
|
|
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
|
-
|
|
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:
|
|
697
|
-
|
|
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"
|
|
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
|
-
|
|
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
|
|
943
|
+
/** List-page KPI band — transparent; only `--key-metrics-flat-band-radial` glow. */
|
|
909
944
|
const flatBandStyle: React.CSSProperties = {
|
|
910
|
-
background:
|
|
911
|
-
|
|
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 —
|
|
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-
|
|
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)
|