@exxatdesignux/ui 0.2.15 → 0.2.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/consumer-extras/cursor-skills/exxat-collaboration-access/SKILL.md +3 -1
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +151 -3
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  6. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  7. package/consumer-extras/cursor-skills/exxat-mono-ids/SKILL.md +56 -0
  8. package/consumer-extras/cursor-skills/exxat-primary-nav-secondary-panel/SKILL.md +17 -1
  9. package/consumer-extras/patterns/collaboration-access-pattern.md +2 -0
  10. package/package.json +3 -3
  11. package/src/components/ui/banner.tsx +2 -0
  12. package/src/components/ui/chart.tsx +57 -2
  13. package/src/components/ui/sidebar.tsx +1 -0
  14. package/src/globals.css +21 -2
  15. package/src/theme.css +4 -2
  16. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  17. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  18. package/template/AGENTS.md +23 -18
  19. package/template/app/(app)/data-list/page.tsx +2 -2
  20. package/template/app/(app)/question-bank/layout.tsx +27 -7
  21. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  22. package/template/app/globals.css +136 -2
  23. package/template/app/layout.tsx +41 -5
  24. package/template/components/app-sidebar.tsx +141 -59
  25. package/template/components/ask-leo-sidebar.tsx +1 -4
  26. package/template/components/brand-color-picker.tsx +344 -0
  27. package/template/components/compliance-list-view.tsx +33 -51
  28. package/template/components/compliance-table.tsx +24 -0
  29. package/template/components/data-table/index.tsx +68 -24
  30. package/template/components/data-table/pagination.tsx +0 -1
  31. package/template/components/data-table/types.ts +4 -1
  32. package/template/components/data-table/use-table-state.ts +243 -94
  33. package/template/components/data-views/data-row-list.tsx +183 -0
  34. package/template/components/data-views/finder-panel-view.tsx +2 -2
  35. package/template/components/data-views/index.ts +26 -3
  36. package/template/components/data-views/list-page-split-details-placeholder.tsx +3 -3
  37. package/template/components/data-views/list-page-split-hub-tokens.ts +1 -1
  38. package/template/components/data-views/list-page-tree-column-header.tsx +1 -1
  39. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  40. package/template/components/data-views/outline-tree-menu.tsx +157 -0
  41. package/template/components/data-views/question-bank-folder-tree-branch.tsx +210 -0
  42. package/template/components/export-drawer.tsx +1 -1
  43. package/template/components/exxat-product-logo.tsx +173 -379
  44. package/template/components/folder-details-shell.tsx +1 -1
  45. package/template/components/hub-tree-panel-view.tsx +88 -80
  46. package/template/components/invite-collaborators-drawer.tsx +5 -3
  47. package/template/components/key-metrics.tsx +116 -51
  48. package/template/components/new-placement-form.tsx +4 -2
  49. package/template/components/new-question-composer.tsx +2208 -0
  50. package/template/components/page-breadcrumb-trail.tsx +131 -0
  51. package/template/components/page-header.tsx +21 -11
  52. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +1 -1
  53. package/template/components/placement-detail.tsx +1 -1
  54. package/template/components/placements-board-view.tsx +1 -1
  55. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  56. package/template/components/placements-list-view.tsx +18 -132
  57. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  58. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  59. package/template/components/placements-table-columns.tsx +2 -2
  60. package/template/components/{data-list-table.tsx → placements-table.tsx} +67 -58
  61. package/template/components/product-switcher.tsx +26 -11
  62. package/template/components/product-wordmark.tsx +285 -0
  63. package/template/components/question-bank-client.tsx +130 -70
  64. package/template/components/question-bank-hub-client.tsx +108 -115
  65. package/template/components/question-bank-list-view.tsx +30 -54
  66. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  67. package/template/components/question-bank-page-header.tsx +18 -2
  68. package/template/components/question-bank-secondary-nav.tsx +12 -228
  69. package/template/components/question-bank-table.tsx +30 -5
  70. package/template/components/rotations-empty-state.tsx +3 -0
  71. package/template/components/secondary-panel.tsx +24 -4
  72. package/template/components/settings-appearance-card.tsx +584 -141
  73. package/template/components/site-header.tsx +56 -32
  74. package/template/components/sites-list-view.tsx +31 -36
  75. package/template/components/sites-table.tsx +24 -0
  76. package/template/components/table-properties/drawer.tsx +1 -1
  77. package/template/components/team-client.tsx +1 -1
  78. package/template/components/team-list-view.tsx +34 -50
  79. package/template/components/team-table.tsx +29 -3
  80. package/template/components/templates/dedicated-search-landing-template.tsx +86 -20
  81. package/template/components/templates/list-page.tsx +1 -3
  82. package/template/components/templates/nested-secondary-panel-shell.tsx +11 -6
  83. package/template/components/ui/dot-pattern.tsx +50 -26
  84. package/template/components/ui/leo-icon.tsx +23 -3
  85. package/template/contexts/product-context.tsx +51 -7
  86. package/template/contexts/system-banner-context.tsx +112 -4
  87. package/template/docs/collaboration-access-pattern.md +2 -0
  88. package/template/docs/question-bank-hub-header-pattern.md +25 -0
  89. package/template/eslint.config.mjs +18 -0
  90. package/template/hooks/use-secondary-panel-hub-nav.ts +17 -1
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/data-list-persistence.ts +57 -257
  93. package/template/lib/dev-log.test.ts +6 -5
  94. package/template/lib/exxat-palette.json +1462 -0
  95. package/template/lib/exxat-palette.ts +136 -0
  96. package/template/lib/list-page-table-properties.ts +1 -1
  97. package/template/lib/list-status-badges.ts +1 -1
  98. package/template/lib/mailto.ts +29 -0
  99. package/template/lib/mock/navigation.tsx +30 -1
  100. package/template/lib/placement-board-card-layout.ts +1 -1
  101. package/template/lib/product-brand.ts +268 -0
  102. package/template/lib/question-bank-authoring.ts +308 -0
  103. package/template/lib/question-bank-nav.ts +70 -0
  104. package/template/lib/raf-throttle.ts +45 -0
  105. package/template/lib/table-state-lifecycle.ts +474 -0
  106. package/template/next.config.mjs +156 -0
  107. package/template/package.json +6 -6
  108. package/template/stores/app-store.ts +46 -1
  109. package/template/components/command-menu-01.tsx +0 -133
  110. package/template/components/command-menu-02.tsx +0 -386
@@ -37,8 +37,32 @@ type Cloud = {
37
37
  delay: number
38
38
  }
39
39
 
40
- function rand(min: number, max: number) {
41
- return min + Math.random() * (max - min)
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
- Array.from({ length: glowCount }).map((_, i) => {
65
- // Drift diagonally: bottom-right → top-left. Start/end partly off-canvas
66
- // so the cloud enters and exits softly without a visible edge.
67
- const startX = rand(85, 120)
68
- const endX = rand(-20, 15)
69
- const midX = (startX + endX) / 2 + rand(-6, 6)
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
- const startY = rand(85, 115)
72
- const endY = rand(-15, 10)
73
- const midY = (startY + endY) / 2 + rand(-4, 4)
96
+ const startY = rand(85, 115)
97
+ const endY = rand(-15, 10)
98
+ const midY = (startY + endY) / 2 + rand(-4, 4)
74
99
 
75
- const duration = rand(8, 12)
76
- // Offset clouds by half a cycle so one is arriving as the other leaves.
77
- const delay = -(i / glowCount) * duration
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
- return {
80
- key: i,
81
- xs: [`${startX}%`, `${midX}%`, `${endX}%`],
82
- ys: [`${startY}%`, `${midY}%`, `${endY}%`],
83
- duration,
84
- delay,
85
- }
86
- }),
87
- [glowCount],
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(() => setCast(false), 720)
636
+ const tCast = setTimeout(() => {
637
+ clickTimersRef.current.delete(tCast)
638
+ setCast(false)
639
+ }, 720)
640
+ clickTimersRef.current.add(tCast)
625
641
 
626
- const id = Date.now() + Math.random()
642
+ const id = ++ringIdRef.current
627
643
  setRings(prev => [...prev, id])
628
- setTimeout(() => setRings(prev => prev.filter(r => r !== id)), 800)
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,73 @@
10
10
 
11
11
  import * as React from "react"
12
12
  import { useAppStore, type Product } from "@/stores/app-store"
13
+ import { brandForProduct } from "@/lib/product-brand"
13
14
 
14
15
  export type { Product }
15
16
 
16
17
  export function useProduct() {
17
- const product = useAppStore(s => s.product)
18
- const setProduct = useAppStore(s => s.setProduct)
19
- return { product, setProduct }
18
+ const product = useAppStore(s => s.product)
19
+ const setProduct = useAppStore(s => s.setProduct)
20
+ const customProductBrand = useAppStore(s => s.customProductBrand)
21
+ const setCustomProductBrand = useAppStore(s => s.setCustomProductBrand)
22
+ const productBrandColors = useAppStore(s => s.productBrandColors)
23
+ const setProductBrandColor = useAppStore(s => s.setProductBrandColor)
24
+ const hiddenProductIds = useAppStore(s => s.hiddenProductIds)
25
+ const hideProduct = useAppStore(s => s.hideProduct)
26
+ const showProduct = useAppStore(s => s.showProduct)
27
+ return {
28
+ product,
29
+ setProduct,
30
+ customProductBrand,
31
+ setCustomProductBrand,
32
+ productBrandColors,
33
+ setProductBrandColor,
34
+ hiddenProductIds,
35
+ hideProduct,
36
+ showProduct,
37
+ }
20
38
  }
21
39
 
22
40
  export function ProductProvider({ children }: { children: React.ReactNode }) {
23
41
  const product = useAppStore(s => s.product)
42
+ const customProductBrand = useAppStore(s => s.customProductBrand)
43
+ const productBrandColors = useAppStore(s => s.productBrandColors)
24
44
 
25
45
  // Rehydrate from localStorage once — keeps SSR render matching server output.
26
46
  React.useEffect(() => {
27
47
  void useAppStore.persist.rehydrate()
28
48
  }, [])
29
49
 
30
- // Sync theme class to <html> whenever product changes.
50
+ // Sync theme class to <html> whenever product (or its accent override) changes.
31
51
  React.useEffect(() => {
32
52
  const html = document.documentElement
33
- html.classList.remove("theme-one", "theme-prism")
34
- html.classList.add(product === "exxat-one" ? "theme-one" : "theme-prism")
35
- }, [product])
53
+ html.classList.remove("theme-one", "theme-prism", "theme-assessment", "theme-custom")
54
+ // Effective brand colour for the active product — picks up any
55
+ // per-product override the user set in Settings → Appearance. Drives
56
+ // `--custom-product-brand-color` so `theme-custom` chrome retints.
57
+ const effectiveBrandColor = brandForProduct(product, customProductBrand, productBrandColors).brandColor
58
+ html.style.setProperty("--custom-product-brand-color", effectiveBrandColor)
59
+ // If the user has set a brand-colour override for the active product,
60
+ // flip to `theme-custom` so the chrome retints from
61
+ // `--custom-product-brand-color`. The hardcoded `theme-one / theme-prism
62
+ // / theme-assessment` classes (with bespoke hue formulas in
63
+ // `globals.css`) are still used for the **default** look of each
64
+ // built-in.
65
+ const hasAccentOverride = Boolean(productBrandColors[product])
66
+ let themeClass: "theme-one" | "theme-prism" | "theme-assessment" | "theme-custom"
67
+ if (hasAccentOverride) {
68
+ themeClass = "theme-custom"
69
+ } else if (product === "exxat-one") {
70
+ themeClass = "theme-one"
71
+ } else if (product === "exxat-prism") {
72
+ themeClass = "theme-prism"
73
+ } else if (product === "exxat-assessment" || (product === "exxat-custom" && !customProductBrand)) {
74
+ themeClass = "theme-assessment"
75
+ } else {
76
+ themeClass = "theme-custom"
77
+ }
78
+ html.classList.add(themeClass)
79
+ }, [customProductBrand, product, productBrandColors])
36
80
 
37
81
  return <>{children}</>
38
82
  }
@@ -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 parsed = JSON.parse(raw) as Partial<SystemBannerConfig>
70
- // Merge so newly-added fields in the default keep working for old payloads.
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
- setConfig({ ...DEFAULT_SYSTEM_BANNER_CONFIG, ...JSON.parse(e.newValue) })
209
+ const coerced = coerceConfig(JSON.parse(e.newValue))
210
+ if (coerced) setConfig(coerced)
103
211
  } catch {
104
212
  /* ignore malformed payloads */
105
213
  }
@@ -2,6 +2,8 @@
2
2
 
3
3
  Shared UI for **who can access a hub** (face stack in the header) and **inviting people** (floating sheet). **Reference:** Question bank — `QuestionBankPageHeader`, `QuestionBankClient`, `InviteCollaboratorsDrawer`.
4
4
 
5
+ **Folder-scoped question bank:** When the library URL selects a folder (`?scope=folder&folderId=`), the same header **⋯ More** menu also exposes **Customize folder** (name / color / icon) via **`QuestionBankNewFolderSheet`** mounted on **`QuestionBankClient`** so it works on every view tab. See **`docs/question-bank-hub-header-pattern.md`** and **`.cursor/rules/exxat-question-bank-hub-header.mdc`**.
6
+
5
7
  ## When to use
6
8
 
7
9
  - A list hub or library is **shared** across people (not a private directory).
@@ -0,0 +1,25 @@
1
+ # Question bank hub header — folder scope + Customize folder
2
+
3
+ **Audience:** Engineers extending the question bank library hub (`QuestionBankClient`, `QuestionBankPageHeader`, URL scope).
4
+
5
+ ## Problem
6
+
7
+ The library uses **`ListPageTemplate`** with multiple **view tabs** (table, panel, tree, …). **`QuestionBankNewFolderSheet`** (customize mode) is also used inside **`QuestionBankTable`** for some views (e.g. panel columns). If **Customize folder** exists only there, users on **table** or other tabs **cannot** open the sheet from a consistent chrome entry point when the URL is scoped to a folder (`?scope=folder&folderId=…`).
8
+
9
+ ## Pattern
10
+
11
+ 1. **`QuestionBankPageHeader`** exposes optional **`onCustomizeFolder?: () => void`**. When **`navState.scope === "folder"`** and **`navState.folderId`** is set, the hub client passes a callback that opens customize mode for the matching **`QuestionBankFolder`**.
12
+ 2. **`QuestionBankClient`** (or equivalent hub client) mounts **`QuestionBankNewFolderSheet`** **once** beside **`SecondaryPanelHubTemplate` / `ListPageTemplate`**, with local state for **`open`** and **`customizingFolder`**. Saving updates **`folders`** the same way as table-embedded customize flows.
13
+ 3. The header **⋯ More** menu order stays aligned with **§4.7**: **Invite people** (when collaboration variant) → **Customize folder** (when folder-scoped) → **Export** → **Show / hide metric section** (when applicable).
14
+
15
+ ## References
16
+
17
+ | Piece | Location |
18
+ |-------|-----------|
19
+ | Header prop + menu item | `components/question-bank-page-header.tsx` |
20
+ | Client wiring + sheet | `components/question-bank-client.tsx` |
21
+ | URL scope | `lib/question-bank-nav.ts` (`parseQuestionBankNav`, `QuestionBankNavState`) |
22
+ | Sheet UI | `components/question-bank-new-folder-sheet.tsx` |
23
+
24
+ **Cursor rule:** `.cursor/rules/exxat-question-bank-hub-header.mdc`
25
+ **Handbook:** `AGENTS.md` §4.6 (folder-scoped hub chrome).
@@ -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;
@@ -36,6 +36,11 @@ export interface UseSecondaryPanelHubNavOptions<TNav> {
36
36
  canonicalHref?: (searchParams: URLSearchParams) => string | null
37
37
  /** Re-open the secondary panel when the user returns to the default scope (e.g. All questions). */
38
38
  shouldReopenPanel?: (nav: TNav) => boolean
39
+ /**
40
+ * When set, auto-reopen only runs on these pathnames (e.g. library hub, not dedicated search landings).
41
+ * Omit to keep legacy behavior: any {@link hubPathnames} match may reopen the panel.
42
+ */
43
+ reopenPanelOnPathnames?: readonly string[]
39
44
  }
40
45
 
41
46
  /**
@@ -48,6 +53,7 @@ export function useSecondaryPanelHubNav<TNav>({
48
53
  parseNav,
49
54
  canonicalHref,
50
55
  shouldReopenPanel,
56
+ reopenPanelOnPathnames,
51
57
  }: UseSecondaryPanelHubNavOptions<TNav>) {
52
58
  const pathname = usePathname()
53
59
  const router = useRouter()
@@ -90,9 +96,19 @@ export function useSecondaryPanelHubNav<TNav>({
90
96
 
91
97
  React.useEffect(() => {
92
98
  if (!isHubPath || !shouldReopenPanel?.(navState)) return
99
+ if (reopenPanelOnPathnames?.length && !reopenPanelOnPathnames.includes(pathname)) return
93
100
  if (activePanel === panelId) return
94
101
  openPanel(panelId)
95
- }, [activePanel, isHubPath, navState, openPanel, panelId, shouldReopenPanel])
102
+ }, [
103
+ activePanel,
104
+ isHubPath,
105
+ navState,
106
+ openPanel,
107
+ panelId,
108
+ pathname,
109
+ reopenPanelOnPathnames,
110
+ shouldReopenPanel,
111
+ ])
96
112
 
97
113
  return { navState, searchParamsKey, hubPathname, hubBasePath, pathname, isHubPath }
98
114
  }
@@ -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 = window.matchMedia("(max-height: 640px)").matches
24
+ const short = mql.matches
21
25
  const veryShort = window.innerHeight <= 420
22
- setReflow(scale >= 1.99 || short || veryShort)
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
- vv?.addEventListener("resize", compute)
27
- vv?.addEventListener("scroll", compute)
28
- window.addEventListener("resize", compute)
29
- const mql = window.matchMedia("(max-height: 640px)")
30
- mql.addEventListener("change", compute)
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
- vv?.removeEventListener("resize", compute)
33
- vv?.removeEventListener("scroll", compute)
34
- window.removeEventListener("resize", compute)
35
- mql.removeEventListener("change", compute)
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