@exxatdesignux/ui 0.2.16 → 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 +26 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
- package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
- package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -0
- package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
- package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +19 -0
- 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 +3 -3
- package/src/components/ui/banner.tsx +2 -0
- package/src/components/ui/chart.tsx +57 -2
- package/src/components/ui/sidebar.tsx +3 -2
- package/src/globals.css +65 -14
- package/src/theme.css +3 -3
- package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
- package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
- package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
- package/template/AGENTS.md +27 -17
- package/template/app/(app)/data-list/page.tsx +2 -2
- package/template/app/(app)/error.tsx +22 -6
- package/template/app/(app)/layout.tsx +13 -6
- package/template/app/(app)/question-bank/layout.tsx +18 -5
- package/template/app/(app)/question-bank/new/page.tsx +58 -0
- package/template/app/global-error.tsx +63 -0
- package/template/app/globals.css +151 -14
- package/template/app/layout.tsx +43 -5
- package/template/components/app-sidebar.tsx +68 -33
- package/template/components/ask-leo-sidebar.tsx +0 -2
- package/template/components/brand-color-picker.tsx +344 -0
- package/template/components/compliance-list-view.tsx +33 -51
- package/template/components/compliance-table.tsx +4 -0
- package/template/components/data-table/index.tsx +99 -91
- package/template/components/data-table/pagination.tsx +0 -1
- package/template/components/data-table/types.ts +4 -1
- package/template/components/data-table/use-table-state.ts +276 -100
- package/template/components/data-views/data-row-list.tsx +183 -0
- package/template/components/data-views/index.ts +7 -3
- package/template/components/data-views/os-folder-glyph.tsx +8 -0
- package/template/components/dev-chunk-load-recovery.tsx +41 -0
- package/template/components/export-drawer.tsx +1 -1
- package/template/components/exxat-product-logo.tsx +168 -317
- package/template/components/invite-collaborators-drawer.tsx +5 -3
- package/template/components/key-metrics.tsx +122 -62
- package/template/components/new-placement-form.tsx +4 -2
- package/template/components/new-question-composer.tsx +2208 -0
- package/template/components/page-breadcrumb-trail.tsx +131 -0
- package/template/components/page-header.tsx +2 -1
- package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
- package/template/components/placement-detail.tsx +1 -1
- package/template/components/placements-board-view.tsx +1 -1
- package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
- package/template/components/placements-list-view.tsx +19 -133
- package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
- package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
- package/template/components/placements-table-columns.tsx +2 -2
- package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
- package/template/components/product-switcher.tsx +24 -7
- package/template/components/product-wordmark.tsx +282 -0
- package/template/components/question-bank-client.tsx +20 -2
- package/template/components/question-bank-hub-client.tsx +105 -115
- package/template/components/question-bank-list-view.tsx +30 -54
- package/template/components/question-bank-new-folder-sheet.tsx +1 -1
- package/template/components/question-bank-secondary-nav.tsx +0 -3
- package/template/components/question-bank-table.tsx +19 -6
- package/template/components/rotations-empty-state.tsx +3 -0
- package/template/components/secondary-panel.tsx +23 -3
- package/template/components/settings-appearance-card.tsx +584 -141
- package/template/components/sidebar-shell.tsx +2 -1
- package/template/components/site-header.tsx +36 -31
- package/template/components/sites-list-view.tsx +31 -36
- package/template/components/sites-table.tsx +4 -0
- package/template/components/table-properties/drawer-button.tsx +38 -20
- package/template/components/table-properties/drawer.tsx +17 -14
- package/template/components/team-client.tsx +1 -1
- package/template/components/team-list-view.tsx +34 -50
- package/template/components/team-table.tsx +8 -3
- package/template/components/templates/list-page.tsx +12 -9
- package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
- package/template/components/ui/dot-pattern.tsx +50 -26
- package/template/components/ui/leo-icon.tsx +23 -3
- package/template/contexts/product-context.tsx +70 -7
- package/template/contexts/system-banner-context.tsx +112 -4
- 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/eslint.config.mjs +18 -0
- package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
- package/template/lib/chunk-load-error.ts +13 -0
- package/template/lib/conditional-rule-match.ts +87 -22
- package/template/lib/data-list-persistence.ts +57 -257
- package/template/lib/data-list-view.ts +6 -0
- package/template/lib/dev-log.test.ts +6 -5
- package/template/lib/exxat-palette.json +1462 -0
- package/template/lib/exxat-palette.ts +136 -0
- package/template/lib/list-page-table-properties.ts +1 -1
- package/template/lib/list-status-badges.ts +1 -1
- package/template/lib/mailto.ts +29 -0
- package/template/lib/placement-board-card-layout.ts +1 -1
- package/template/lib/product-brand.ts +268 -0
- package/template/lib/question-bank-authoring.ts +308 -0
- package/template/lib/question-bank-nav.ts +44 -0
- package/template/lib/raf-throttle.ts +45 -0
- package/template/lib/sidebar-state-cookie.ts +9 -0
- package/template/lib/table-state-lifecycle.ts +521 -0
- package/template/next.config.mjs +156 -0
- package/template/package.json +3 -3
- package/template/stores/app-store.ts +46 -1
|
@@ -37,8 +37,32 @@ type Cloud = {
|
|
|
37
37
|
delay: number
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Tiny deterministic PRNG (mulberry32). We use a seeded RNG instead of
|
|
42
|
+
* `Math.random()` so the SVG attributes emitted on the server match the
|
|
43
|
+
* client's first paint — otherwise React reports a hydration mismatch and
|
|
44
|
+
* has to re-paint every drifting `<motion.circle>` on mount, which is both
|
|
45
|
+
* a perf cost and a visible jump.
|
|
46
|
+
*/
|
|
47
|
+
function mulberry32(seed: number): () => number {
|
|
48
|
+
let s = seed >>> 0
|
|
49
|
+
return () => {
|
|
50
|
+
s = (s + 0x6d2b79f5) >>> 0
|
|
51
|
+
let t = s
|
|
52
|
+
t = Math.imul(t ^ (t >>> 15), t | 1)
|
|
53
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
|
|
54
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function hashString(str: string): number {
|
|
59
|
+
// Cheap FNV-1a-style hash. Stable across SSR + CSR for the same input.
|
|
60
|
+
let h = 2166136261
|
|
61
|
+
for (let i = 0; i < str.length; i++) {
|
|
62
|
+
h ^= str.charCodeAt(i)
|
|
63
|
+
h = Math.imul(h, 16777619)
|
|
64
|
+
}
|
|
65
|
+
return h >>> 0
|
|
42
66
|
}
|
|
43
67
|
|
|
44
68
|
export function DotPattern({
|
|
@@ -59,33 +83,33 @@ export function DotPattern({
|
|
|
59
83
|
const maskId = `${id}-mask`
|
|
60
84
|
const gradId = `${id}-grad`
|
|
61
85
|
|
|
62
|
-
const clouds = React.useMemo<Cloud[]>(
|
|
63
|
-
()
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
const clouds = React.useMemo<Cloud[]>(() => {
|
|
87
|
+
const rng = mulberry32(hashString(`${id}|${glowCount}`))
|
|
88
|
+
const rand = (min: number, max: number) => min + rng() * (max - min)
|
|
89
|
+
return Array.from({ length: glowCount }).map((_, i) => {
|
|
90
|
+
// Drift diagonally: bottom-right → top-left. Start/end partly off-canvas
|
|
91
|
+
// so the cloud enters and exits softly without a visible edge.
|
|
92
|
+
const startX = rand(85, 120)
|
|
93
|
+
const endX = rand(-20, 15)
|
|
94
|
+
const midX = (startX + endX) / 2 + rand(-6, 6)
|
|
70
95
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
96
|
+
const startY = rand(85, 115)
|
|
97
|
+
const endY = rand(-15, 10)
|
|
98
|
+
const midY = (startY + endY) / 2 + rand(-4, 4)
|
|
74
99
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
100
|
+
const duration = rand(8, 12)
|
|
101
|
+
// Offset clouds by half a cycle so one is arriving as the other leaves.
|
|
102
|
+
const delay = -(i / glowCount) * duration
|
|
78
103
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
)
|
|
104
|
+
return {
|
|
105
|
+
key: i,
|
|
106
|
+
xs: [`${startX}%`, `${midX}%`, `${endX}%`],
|
|
107
|
+
ys: [`${startY}%`, `${midY}%`, `${endY}%`],
|
|
108
|
+
duration,
|
|
109
|
+
delay,
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
}, [glowCount, id])
|
|
89
113
|
|
|
90
114
|
return (
|
|
91
115
|
<svg
|
|
@@ -618,14 +618,34 @@ function InteractiveIcon({ sz, reduced }: { sz: SZ; reduced: boolean }) {
|
|
|
618
618
|
const onDown = React.useCallback(() => setPressed(true), [])
|
|
619
619
|
const onUp = React.useCallback(() => setPressed(false), [])
|
|
620
620
|
|
|
621
|
+
// Track click-effect timers so unmounting (Ask Leo sidebar close) doesn't
|
|
622
|
+
// leave timers running that then call setState on an unmounted component.
|
|
623
|
+
const clickTimersRef = React.useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
|
|
624
|
+
React.useEffect(() => {
|
|
625
|
+
const set = clickTimersRef.current
|
|
626
|
+
return () => {
|
|
627
|
+
for (const t of set) clearTimeout(t)
|
|
628
|
+
set.clear()
|
|
629
|
+
}
|
|
630
|
+
}, [])
|
|
631
|
+
|
|
632
|
+
const ringIdRef = React.useRef(0)
|
|
621
633
|
const onClick = React.useCallback(() => {
|
|
622
634
|
if (reduced) return
|
|
623
635
|
setCast(true)
|
|
624
|
-
setTimeout(() =>
|
|
636
|
+
const tCast = setTimeout(() => {
|
|
637
|
+
clickTimersRef.current.delete(tCast)
|
|
638
|
+
setCast(false)
|
|
639
|
+
}, 720)
|
|
640
|
+
clickTimersRef.current.add(tCast)
|
|
625
641
|
|
|
626
|
-
const id =
|
|
642
|
+
const id = ++ringIdRef.current
|
|
627
643
|
setRings(prev => [...prev, id])
|
|
628
|
-
setTimeout(() =>
|
|
644
|
+
const tRing = setTimeout(() => {
|
|
645
|
+
clickTimersRef.current.delete(tRing)
|
|
646
|
+
setRings(prev => prev.filter(r => r !== id))
|
|
647
|
+
}, 800)
|
|
648
|
+
clickTimersRef.current.add(tRing)
|
|
629
649
|
|
|
630
650
|
spawnBurst(6)
|
|
631
651
|
}, [reduced, spawnBurst])
|
|
@@ -10,29 +10,92 @@
|
|
|
10
10
|
|
|
11
11
|
import * as React from "react"
|
|
12
12
|
import { useAppStore, type Product } from "@/stores/app-store"
|
|
13
|
+
import {
|
|
14
|
+
brandForProduct,
|
|
15
|
+
EXXAT_ASSESSMENT_BRAND,
|
|
16
|
+
EXXAT_ONE_BRAND,
|
|
17
|
+
EXXAT_PRISM_BRAND,
|
|
18
|
+
} from "@/lib/product-brand"
|
|
19
|
+
|
|
20
|
+
const DEFAULT_PRODUCT_ACCENT: Record<Product, string> = {
|
|
21
|
+
"exxat-one": EXXAT_ONE_BRAND.brandColor,
|
|
22
|
+
"exxat-prism": EXXAT_PRISM_BRAND.brandColor,
|
|
23
|
+
"exxat-assessment": EXXAT_ASSESSMENT_BRAND.brandColor,
|
|
24
|
+
"exxat-custom": "",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function accentOverrideActive(product: Product, override: string | undefined): boolean {
|
|
28
|
+
if (!override?.trim()) return false
|
|
29
|
+
const defaultAccent = DEFAULT_PRODUCT_ACCENT[product]?.trim()
|
|
30
|
+
if (!defaultAccent) return true
|
|
31
|
+
return override.trim().toLowerCase() !== defaultAccent.toLowerCase()
|
|
32
|
+
}
|
|
13
33
|
|
|
14
34
|
export type { Product }
|
|
15
35
|
|
|
16
36
|
export function useProduct() {
|
|
17
|
-
const product
|
|
18
|
-
const setProduct
|
|
19
|
-
|
|
37
|
+
const product = useAppStore(s => s.product)
|
|
38
|
+
const setProduct = useAppStore(s => s.setProduct)
|
|
39
|
+
const customProductBrand = useAppStore(s => s.customProductBrand)
|
|
40
|
+
const setCustomProductBrand = useAppStore(s => s.setCustomProductBrand)
|
|
41
|
+
const productBrandColors = useAppStore(s => s.productBrandColors)
|
|
42
|
+
const setProductBrandColor = useAppStore(s => s.setProductBrandColor)
|
|
43
|
+
const hiddenProductIds = useAppStore(s => s.hiddenProductIds)
|
|
44
|
+
const hideProduct = useAppStore(s => s.hideProduct)
|
|
45
|
+
const showProduct = useAppStore(s => s.showProduct)
|
|
46
|
+
return {
|
|
47
|
+
product,
|
|
48
|
+
setProduct,
|
|
49
|
+
customProductBrand,
|
|
50
|
+
setCustomProductBrand,
|
|
51
|
+
productBrandColors,
|
|
52
|
+
setProductBrandColor,
|
|
53
|
+
hiddenProductIds,
|
|
54
|
+
hideProduct,
|
|
55
|
+
showProduct,
|
|
56
|
+
}
|
|
20
57
|
}
|
|
21
58
|
|
|
22
59
|
export function ProductProvider({ children }: { children: React.ReactNode }) {
|
|
23
60
|
const product = useAppStore(s => s.product)
|
|
61
|
+
const customProductBrand = useAppStore(s => s.customProductBrand)
|
|
62
|
+
const productBrandColors = useAppStore(s => s.productBrandColors)
|
|
24
63
|
|
|
25
64
|
// Rehydrate from localStorage once — keeps SSR render matching server output.
|
|
26
65
|
React.useEffect(() => {
|
|
27
66
|
void useAppStore.persist.rehydrate()
|
|
28
67
|
}, [])
|
|
29
68
|
|
|
30
|
-
// Sync theme class to <html> whenever product changes.
|
|
69
|
+
// Sync theme class to <html> whenever product (or its accent override) changes.
|
|
31
70
|
React.useEffect(() => {
|
|
32
71
|
const html = document.documentElement
|
|
33
|
-
html.classList.remove("theme-one", "theme-prism")
|
|
34
|
-
|
|
35
|
-
|
|
72
|
+
html.classList.remove("theme-one", "theme-prism", "theme-assessment", "theme-custom")
|
|
73
|
+
// Effective brand colour for the active product — picks up any
|
|
74
|
+
// per-product override the user set in Settings → Appearance. Drives
|
|
75
|
+
// `--custom-product-brand-color` so `theme-custom` chrome retints.
|
|
76
|
+
const effectiveBrandColor = brandForProduct(product, customProductBrand, productBrandColors).brandColor
|
|
77
|
+
html.style.setProperty("--custom-product-brand-color", effectiveBrandColor)
|
|
78
|
+
// If the user has set a brand-colour override for the active product,
|
|
79
|
+
// flip to `theme-custom` so the chrome retints from
|
|
80
|
+
// `--custom-product-brand-color`. The hardcoded `theme-one / theme-prism
|
|
81
|
+
// / theme-assessment` classes (with bespoke hue formulas in
|
|
82
|
+
// `globals.css`) are still used for the **default** look of each
|
|
83
|
+
// built-in.
|
|
84
|
+
const hasAccentOverride = accentOverrideActive(product, productBrandColors[product])
|
|
85
|
+
let themeClass: "theme-one" | "theme-prism" | "theme-assessment" | "theme-custom"
|
|
86
|
+
if (hasAccentOverride) {
|
|
87
|
+
themeClass = "theme-custom"
|
|
88
|
+
} else if (product === "exxat-one") {
|
|
89
|
+
themeClass = "theme-one"
|
|
90
|
+
} else if (product === "exxat-prism") {
|
|
91
|
+
themeClass = "theme-prism"
|
|
92
|
+
} else if (product === "exxat-assessment" || (product === "exxat-custom" && !customProductBrand)) {
|
|
93
|
+
themeClass = "theme-assessment"
|
|
94
|
+
} else {
|
|
95
|
+
themeClass = "theme-custom"
|
|
96
|
+
}
|
|
97
|
+
html.classList.add(themeClass)
|
|
98
|
+
}, [customProductBrand, product, productBrandColors])
|
|
36
99
|
|
|
37
100
|
return <>{children}</>
|
|
38
101
|
}
|
|
@@ -43,6 +43,111 @@ export const DEFAULT_SYSTEM_BANNER_CONFIG: SystemBannerConfig = {
|
|
|
43
43
|
|
|
44
44
|
const STORAGE_KEY = "exxat:system-banner-config"
|
|
45
45
|
|
|
46
|
+
const ALLOWED_VARIANTS: ReadonlySet<SystemBannerVariant> = new Set([
|
|
47
|
+
"info",
|
|
48
|
+
"warning",
|
|
49
|
+
"error",
|
|
50
|
+
"success",
|
|
51
|
+
"promo",
|
|
52
|
+
])
|
|
53
|
+
|
|
54
|
+
const ALLOWED_EMPHASIS: ReadonlySet<SystemBannerEmphasis> = new Set([
|
|
55
|
+
"prominent",
|
|
56
|
+
"subtle",
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Strip any `actionHref` whose URL scheme could execute script when the
|
|
61
|
+
* banner CTA is clicked (`javascript:`, `data:`, `vbscript:`, etc.).
|
|
62
|
+
*
|
|
63
|
+
* The banner UI renders `actionHref` as a plain `<a href>`, so a malicious
|
|
64
|
+
* value in `localStorage` — written by an extension, a victim of a
|
|
65
|
+
* same-origin bug elsewhere, or a future feature that accepts user input —
|
|
66
|
+
* would become a one-click XSS or open-redirect vector on every tab that
|
|
67
|
+
* receives the storage event. We accept only:
|
|
68
|
+
*
|
|
69
|
+
* - Absolute http(s) URLs.
|
|
70
|
+
* - Absolute mailto: / tel: URIs (banner CTAs sometimes deep-link these).
|
|
71
|
+
* - Same-origin relative paths (`/foo`, `./bar`, `../baz`).
|
|
72
|
+
* - The single legacy placeholder `"#"` shipped in the default config.
|
|
73
|
+
*
|
|
74
|
+
* Anything else collapses to `undefined`, which the banner treats as
|
|
75
|
+
* "no CTA link".
|
|
76
|
+
*/
|
|
77
|
+
function sanitizeActionHref(href: unknown): string | undefined {
|
|
78
|
+
if (typeof href !== "string") return undefined
|
|
79
|
+
const trimmed = href.trim()
|
|
80
|
+
if (!trimmed) return undefined
|
|
81
|
+
if (trimmed === "#") return trimmed
|
|
82
|
+
|
|
83
|
+
// Same-origin relative paths.
|
|
84
|
+
if (
|
|
85
|
+
trimmed.startsWith("/") ||
|
|
86
|
+
trimmed.startsWith("./") ||
|
|
87
|
+
trimmed.startsWith("../")
|
|
88
|
+
) {
|
|
89
|
+
return trimmed
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Absolute URLs — only allow http(s) / mailto: / tel:.
|
|
93
|
+
try {
|
|
94
|
+
// Use a dummy base so `new URL` accepts both absolute and protocol-relative inputs.
|
|
95
|
+
const url = new URL(trimmed, "https://exxat.invalid")
|
|
96
|
+
if (
|
|
97
|
+
url.protocol === "http:" ||
|
|
98
|
+
url.protocol === "https:" ||
|
|
99
|
+
url.protocol === "mailto:" ||
|
|
100
|
+
url.protocol === "tel:"
|
|
101
|
+
) {
|
|
102
|
+
return trimmed
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
/* fallthrough to reject */
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return undefined
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Coerce an unknown JSON payload (from `localStorage` or a cross-tab
|
|
113
|
+
* `storage` event) into a `SystemBannerConfig`. Unknown fields are dropped,
|
|
114
|
+
* known fields are type-narrowed, and any string field is capped so a
|
|
115
|
+
* malformed/oversized payload cannot stall the renderer.
|
|
116
|
+
*
|
|
117
|
+
* Returns `null` when the payload cannot be coerced — callers fall back to
|
|
118
|
+
* the shipped default rather than render attacker-controlled content.
|
|
119
|
+
*/
|
|
120
|
+
function coerceConfig(raw: unknown): SystemBannerConfig | null {
|
|
121
|
+
if (!raw || typeof raw !== "object") return null
|
|
122
|
+
const r = raw as Record<string, unknown>
|
|
123
|
+
const str = (v: unknown, max = 280): string | undefined =>
|
|
124
|
+
typeof v === "string" ? v.slice(0, max) : undefined
|
|
125
|
+
|
|
126
|
+
const variant = ALLOWED_VARIANTS.has(r.variant as SystemBannerVariant)
|
|
127
|
+
? (r.variant as SystemBannerVariant)
|
|
128
|
+
: DEFAULT_SYSTEM_BANNER_CONFIG.variant
|
|
129
|
+
const emphasis = ALLOWED_EMPHASIS.has(r.emphasis as SystemBannerEmphasis)
|
|
130
|
+
? (r.emphasis as SystemBannerEmphasis)
|
|
131
|
+
: DEFAULT_SYSTEM_BANNER_CONFIG.emphasis
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
enabled:
|
|
135
|
+
typeof r.enabled === "boolean"
|
|
136
|
+
? r.enabled
|
|
137
|
+
: DEFAULT_SYSTEM_BANNER_CONFIG.enabled,
|
|
138
|
+
variant,
|
|
139
|
+
emphasis,
|
|
140
|
+
title: str(r.title, 120) ?? DEFAULT_SYSTEM_BANNER_CONFIG.title,
|
|
141
|
+
message: str(r.message, 280) ?? DEFAULT_SYSTEM_BANNER_CONFIG.message,
|
|
142
|
+
actionLabel: str(r.actionLabel, 60),
|
|
143
|
+
actionHref: sanitizeActionHref(r.actionHref),
|
|
144
|
+
dismissible:
|
|
145
|
+
typeof r.dismissible === "boolean"
|
|
146
|
+
? r.dismissible
|
|
147
|
+
: DEFAULT_SYSTEM_BANNER_CONFIG.dismissible,
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
46
151
|
interface SystemBannerContextValue {
|
|
47
152
|
config: SystemBannerConfig
|
|
48
153
|
updateConfig: (patch: Partial<SystemBannerConfig>) => void
|
|
@@ -66,9 +171,8 @@ function readStored(): SystemBannerConfig {
|
|
|
66
171
|
try {
|
|
67
172
|
const raw = window.localStorage.getItem(STORAGE_KEY)
|
|
68
173
|
if (!raw) return DEFAULT_SYSTEM_BANNER_CONFIG
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
return { ...DEFAULT_SYSTEM_BANNER_CONFIG, ...parsed }
|
|
174
|
+
const coerced = coerceConfig(JSON.parse(raw))
|
|
175
|
+
return coerced ?? DEFAULT_SYSTEM_BANNER_CONFIG
|
|
72
176
|
} catch {
|
|
73
177
|
return DEFAULT_SYSTEM_BANNER_CONFIG
|
|
74
178
|
}
|
|
@@ -95,11 +199,15 @@ export function SystemBannerProvider({ children }: { children: React.ReactNode }
|
|
|
95
199
|
}, [config, hydrated])
|
|
96
200
|
|
|
97
201
|
// Cross-tab sync — if you change the banner in one tab, others follow.
|
|
202
|
+
// The payload is treated as untrusted (an extension or future bug could
|
|
203
|
+
// write into the same key) so we route it through `coerceConfig` to drop
|
|
204
|
+
// unknown fields and `sanitizeActionHref` to refuse `javascript:` URLs.
|
|
98
205
|
React.useEffect(() => {
|
|
99
206
|
function onStorage(e: StorageEvent) {
|
|
100
207
|
if (e.key !== STORAGE_KEY || !e.newValue) return
|
|
101
208
|
try {
|
|
102
|
-
|
|
209
|
+
const coerced = coerceConfig(JSON.parse(e.newValue))
|
|
210
|
+
if (coerced) setConfig(coerced)
|
|
103
211
|
} catch {
|
|
104
212
|
/* ignore malformed payloads */
|
|
105
213
|
}
|
|
@@ -18,6 +18,8 @@ This document describes how list pages combine **views**, **toolbar** behavior,
|
|
|
18
18
|
| **Team page (primary template)** | `TeamClient` = `ListPageTemplate` + `KeyMetrics` + `TeamPageHeader` + `TeamTable` (same composition as `DataListClient`) | `components/team-client.tsx`, `lib/mock/team-kpi.ts` |
|
|
19
19
|
| **Team roster** | `TeamTable` — `DataTable` + `useTableState` + `TablePropertiesDrawer`; list/board/dashboard read **`tableState.rows`** | `components/team-table.tsx` |
|
|
20
20
|
| **Dashboard view (list tab)** | **`KeyMetrics`** (`variant="flat"` or `"card"`) — same KPI system as the template metrics strip; **do not** add ad-hoc `Card` grids for entity summaries | `TeamTable` dashboard branch, `lib/mock/team-kpi.ts` |
|
|
21
|
+
| **List hub metrics strip** | **`KeyMetrics variant="flat"`** — transparent cells, OKLCH brand glow only, border hairlines (**no** grey panel) | **`docs/kpi-flat-band-pattern.md`**, Placements / Team / Question bank clients |
|
|
22
|
+
| **Secondary panel chrome** | **`--secondary-panel-bg`** on **`NestedSecondaryPanelShell`** (lighter than sidebar, follows active product) | **`docs/shell-surface-elevation-pattern.md`**, Question bank |
|
|
21
23
|
| **Export** | `ExportDrawer` | `ListPageTemplate` export props; `DataListClient` |
|
|
22
24
|
| **View body layout** (gutter + centered max-width for folder / icon / panel-style content) | **`ListPageViewFrame`** (`components/data-views/list-page-view-frame.tsx`, re-exported from `components/data-views`) | **`FolderGridView`** (uses the frame); **`QuestionBankOsFolderView`** — see **`AGENTS.md` §4.5** |
|
|
23
25
|
|
|
@@ -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` (< 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
|
|
@@ -13,6 +13,24 @@ const eslintConfig = defineConfig([
|
|
|
13
13
|
"build/**",
|
|
14
14
|
"next-env.d.ts",
|
|
15
15
|
]),
|
|
16
|
+
{
|
|
17
|
+
rules: {
|
|
18
|
+
// Allow intentionally-unused args / vars / destructured props /
|
|
19
|
+
// generics when prefixed with `_`. This is the standard escape hatch
|
|
20
|
+
// for "I'm satisfying a callback signature but don't need this slot"
|
|
21
|
+
// — common in cell renderers (`(value, _row) => …`), destructured
|
|
22
|
+
// tuples (`const [_, setX] = useState()`), and generic constraints.
|
|
23
|
+
"@typescript-eslint/no-unused-vars": [
|
|
24
|
+
"warn",
|
|
25
|
+
{
|
|
26
|
+
argsIgnorePattern: "^_",
|
|
27
|
+
varsIgnorePattern: "^_",
|
|
28
|
+
caughtErrorsIgnorePattern: "^_",
|
|
29
|
+
destructuredArrayIgnorePattern: "^_",
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
},
|
|
16
34
|
]);
|
|
17
35
|
|
|
18
36
|
export default eslintConfig;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
|
+
import { rafThrottle } from "@/lib/raf-throttle"
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* When true, the sidebar should **not** pin utilities + profile to the bottom — the whole
|
|
@@ -14,25 +15,34 @@ export function useSidebarReflowZoom(): boolean {
|
|
|
14
15
|
|
|
15
16
|
React.useEffect(() => {
|
|
16
17
|
const vv = window.visualViewport
|
|
18
|
+
// Cache the MediaQueryList — calling `matchMedia` on every compute() is
|
|
19
|
+
// measurable on pinch/zoom where visualViewport scroll fires per frame.
|
|
20
|
+
const mql = window.matchMedia("(max-height: 640px)")
|
|
17
21
|
|
|
18
22
|
function compute() {
|
|
19
23
|
const scale = vv?.scale ?? 1
|
|
20
|
-
const short =
|
|
24
|
+
const short = mql.matches
|
|
21
25
|
const veryShort = window.innerHeight <= 420
|
|
22
|
-
|
|
26
|
+
const next = scale >= 1.99 || short || veryShort
|
|
27
|
+
// Avoid unnecessary React re-renders when nothing changed.
|
|
28
|
+
setReflow(prev => (prev === next ? prev : next))
|
|
23
29
|
}
|
|
24
30
|
|
|
25
31
|
compute()
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
32
|
+
// rAF-coalesce: visualViewport.scroll can fire hundreds of times per second
|
|
33
|
+
// during pinch-zoom — without throttling we trigger setReflow + matchMedia
|
|
34
|
+
// per event. One sample per frame is enough for a layout breakpoint flag.
|
|
35
|
+
const scheduled = rafThrottle(compute)
|
|
36
|
+
vv?.addEventListener("resize", scheduled, { passive: true })
|
|
37
|
+
vv?.addEventListener("scroll", scheduled, { passive: true })
|
|
38
|
+
window.addEventListener("resize", scheduled, { passive: true })
|
|
39
|
+
mql.addEventListener("change", scheduled)
|
|
31
40
|
return () => {
|
|
32
|
-
|
|
33
|
-
vv?.removeEventListener("
|
|
34
|
-
|
|
35
|
-
|
|
41
|
+
scheduled.cancel()
|
|
42
|
+
vv?.removeEventListener("resize", scheduled)
|
|
43
|
+
vv?.removeEventListener("scroll", scheduled)
|
|
44
|
+
window.removeEventListener("resize", scheduled)
|
|
45
|
+
mql.removeEventListener("change", scheduled)
|
|
36
46
|
}
|
|
37
47
|
}, [])
|
|
38
48
|
|
|
@@ -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
|
+
}
|