@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.
Files changed (111) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/consumer-extras/cursor-skills/exxat-ds-skill/SKILL.md +149 -4
  3. package/consumer-extras/cursor-skills/exxat-ds-skill/references/accessibility.md +142 -0
  4. package/consumer-extras/cursor-skills/exxat-ds-skill/references/coach-marks.md +169 -0
  5. package/consumer-extras/cursor-skills/exxat-ds-skill/references/data-table-pattern.md +382 -0
  6. package/consumer-extras/cursor-skills/exxat-kpi-flat-band/SKILL.md +38 -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 +19 -0
  9. package/consumer-extras/patterns/data-views-pattern.md +2 -0
  10. package/consumer-extras/patterns/kpi-flat-band-pattern.md +57 -0
  11. package/consumer-extras/patterns/shell-surface-elevation-pattern.md +52 -0
  12. package/package.json +3 -3
  13. package/src/components/ui/banner.tsx +2 -0
  14. package/src/components/ui/chart.tsx +57 -2
  15. package/src/components/ui/sidebar.tsx +3 -2
  16. package/src/globals.css +65 -14
  17. package/src/theme.css +3 -3
  18. package/template/.claude/skills/exxat-ds-skill/SKILL.md +1 -1
  19. package/template/.cursor/rules/exxat-kbd-shortcuts.mdc +1 -1
  20. package/template/.cursor/rules/exxat-mono-ids.mdc +30 -0
  21. package/template/AGENTS.md +27 -17
  22. package/template/app/(app)/data-list/page.tsx +2 -2
  23. package/template/app/(app)/error.tsx +22 -6
  24. package/template/app/(app)/layout.tsx +13 -6
  25. package/template/app/(app)/question-bank/layout.tsx +18 -5
  26. package/template/app/(app)/question-bank/new/page.tsx +58 -0
  27. package/template/app/global-error.tsx +63 -0
  28. package/template/app/globals.css +151 -14
  29. package/template/app/layout.tsx +43 -5
  30. package/template/components/app-sidebar.tsx +68 -33
  31. package/template/components/ask-leo-sidebar.tsx +0 -2
  32. package/template/components/brand-color-picker.tsx +344 -0
  33. package/template/components/compliance-list-view.tsx +33 -51
  34. package/template/components/compliance-table.tsx +4 -0
  35. package/template/components/data-table/index.tsx +99 -91
  36. package/template/components/data-table/pagination.tsx +0 -1
  37. package/template/components/data-table/types.ts +4 -1
  38. package/template/components/data-table/use-table-state.ts +276 -100
  39. package/template/components/data-views/data-row-list.tsx +183 -0
  40. package/template/components/data-views/index.ts +7 -3
  41. package/template/components/data-views/os-folder-glyph.tsx +8 -0
  42. package/template/components/dev-chunk-load-recovery.tsx +41 -0
  43. package/template/components/export-drawer.tsx +1 -1
  44. package/template/components/exxat-product-logo.tsx +168 -317
  45. package/template/components/invite-collaborators-drawer.tsx +5 -3
  46. package/template/components/key-metrics.tsx +122 -62
  47. package/template/components/new-placement-form.tsx +4 -2
  48. package/template/components/new-question-composer.tsx +2208 -0
  49. package/template/components/page-breadcrumb-trail.tsx +131 -0
  50. package/template/components/page-header.tsx +2 -1
  51. package/template/components/{data-views/placement-board-card.tsx → placement-board-card.tsx} +2 -2
  52. package/template/components/placement-detail.tsx +1 -1
  53. package/template/components/placements-board-view.tsx +1 -1
  54. package/template/components/{data-list-client.tsx → placements-client.tsx} +9 -7
  55. package/template/components/placements-list-view.tsx +19 -133
  56. package/template/components/{data-list-table-cells.test.tsx → placements-table-cells.test.tsx} +2 -2
  57. package/template/components/{data-list-table-cells.tsx → placements-table-cells.tsx} +1 -1
  58. package/template/components/placements-table-columns.tsx +2 -2
  59. package/template/components/{data-list-table.tsx → placements-table.tsx} +42 -66
  60. package/template/components/product-switcher.tsx +24 -7
  61. package/template/components/product-wordmark.tsx +282 -0
  62. package/template/components/question-bank-client.tsx +20 -2
  63. package/template/components/question-bank-hub-client.tsx +105 -115
  64. package/template/components/question-bank-list-view.tsx +30 -54
  65. package/template/components/question-bank-new-folder-sheet.tsx +1 -1
  66. package/template/components/question-bank-secondary-nav.tsx +0 -3
  67. package/template/components/question-bank-table.tsx +19 -6
  68. package/template/components/rotations-empty-state.tsx +3 -0
  69. package/template/components/secondary-panel.tsx +23 -3
  70. package/template/components/settings-appearance-card.tsx +584 -141
  71. package/template/components/sidebar-shell.tsx +2 -1
  72. package/template/components/site-header.tsx +36 -31
  73. package/template/components/sites-list-view.tsx +31 -36
  74. package/template/components/sites-table.tsx +4 -0
  75. package/template/components/table-properties/drawer-button.tsx +38 -20
  76. package/template/components/table-properties/drawer.tsx +17 -14
  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 +8 -3
  80. package/template/components/templates/list-page.tsx +12 -9
  81. package/template/components/templates/nested-secondary-panel-shell.tsx +10 -4
  82. package/template/components/ui/dot-pattern.tsx +50 -26
  83. package/template/components/ui/leo-icon.tsx +23 -3
  84. package/template/contexts/product-context.tsx +70 -7
  85. package/template/contexts/system-banner-context.tsx +112 -4
  86. package/template/docs/data-views-pattern.md +2 -0
  87. package/template/docs/kpi-flat-band-pattern.md +57 -0
  88. package/template/docs/kpi-strip-max-four-pattern.md +1 -0
  89. package/template/docs/shell-surface-elevation-pattern.md +52 -0
  90. package/template/eslint.config.mjs +18 -0
  91. package/template/hooks/use-sidebar-reflow-zoom.ts +21 -11
  92. package/template/lib/chunk-load-error.ts +13 -0
  93. package/template/lib/conditional-rule-match.ts +87 -22
  94. package/template/lib/data-list-persistence.ts +57 -257
  95. package/template/lib/data-list-view.ts +6 -0
  96. package/template/lib/dev-log.test.ts +6 -5
  97. package/template/lib/exxat-palette.json +1462 -0
  98. package/template/lib/exxat-palette.ts +136 -0
  99. package/template/lib/list-page-table-properties.ts +1 -1
  100. package/template/lib/list-status-badges.ts +1 -1
  101. package/template/lib/mailto.ts +29 -0
  102. package/template/lib/placement-board-card-layout.ts +1 -1
  103. package/template/lib/product-brand.ts +268 -0
  104. package/template/lib/question-bank-authoring.ts +308 -0
  105. package/template/lib/question-bank-nav.ts +44 -0
  106. package/template/lib/raf-throttle.ts +45 -0
  107. package/template/lib/sidebar-state-cookie.ts +9 -0
  108. package/template/lib/table-state-lifecycle.ts +521 -0
  109. package/template/next.config.mjs +156 -0
  110. package/template/package.json +3 -3
  111. package/template/stores/app-store.ts +46 -1
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Question bank authoring — health-science domain constants for `NewQuestionAuthoring`.
3
+ *
4
+ * Storage in `lib/mock/question-bank.ts` keeps the simple `QuestionBankType` enum
5
+ * (`multiple_choice` | `true_false` | `short_answer`). The authoring surface
6
+ * exposes the richer item-writer model that health-science assessments rely on
7
+ * (NBME / NCLEX / OSCE style) and maps back to that base enum on save.
8
+ */
9
+
10
+ import type { QuestionBankType, QuestionBankBloomLevel } from "@/lib/mock/question-bank"
11
+
12
+ // ─────────────────────────────────────────────────────────────────────────────
13
+ // Question type
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+
16
+ /** Authoring-time question type — richer than the storage enum. */
17
+ export type AuthoringQuestionType =
18
+ | "mcq_single"
19
+ | "mcq_multiple"
20
+ | "true_false"
21
+ | "short_answer"
22
+ | "numeric"
23
+ | "essay"
24
+ | "fill_blank"
25
+ | "matching"
26
+ | "ordering"
27
+ | "hotspot"
28
+
29
+ export interface AuthoringQuestionTypeOption {
30
+ value: AuthoringQuestionType
31
+ label: string
32
+ shortLabel: string
33
+ icon: string
34
+ /** Long, sentence-form description for the inspector caption. */
35
+ description: string
36
+ /** Compact one-liner shown under the title in `SelectionTileGrid` tiles. */
37
+ tileSummary: string
38
+ /** "Single best answer", "Multiple response", etc. — appears under the title. */
39
+ badge?: string
40
+ /** Maps to the storage type when persisting. */
41
+ storageType: QuestionBankType
42
+ }
43
+
44
+ export const AUTHORING_QUESTION_TYPES: readonly AuthoringQuestionTypeOption[] = [
45
+ {
46
+ value: "mcq_single",
47
+ label: "Multiple choice",
48
+ shortLabel: "Multiple choice",
49
+ icon: "fa-list-radio",
50
+ description:
51
+ "NBME-style. Stem describes a clinical scenario; pick the one best option. Default 5 distractors.",
52
+ tileSummary: "Single correct answer from a set of options",
53
+ badge: "Single best answer",
54
+ storageType: "multiple_choice",
55
+ },
56
+ {
57
+ value: "mcq_multiple",
58
+ label: "Multi-select",
59
+ shortLabel: "Multi-select",
60
+ icon: "fa-list-check",
61
+ description:
62
+ "NCLEX-style. Learner selects every correct option from the list. Common for safety and prioritisation items.",
63
+ tileSummary: "One or more correct answers — partial credit",
64
+ badge: "Multiple response",
65
+ storageType: "multiple_choice",
66
+ },
67
+ {
68
+ value: "true_false",
69
+ label: "True / False",
70
+ shortLabel: "True / False",
71
+ icon: "fa-circle-half-stroke",
72
+ description:
73
+ "Use sparingly — best for unambiguous facts. Avoid for nuanced clinical judgment.",
74
+ tileSummary: "Binary statement — quick recall",
75
+ storageType: "true_false",
76
+ },
77
+ {
78
+ value: "short_answer",
79
+ label: "Short answer",
80
+ shortLabel: "Short answer",
81
+ icon: "fa-input-text",
82
+ description:
83
+ "Free-text response with a model answer + acceptable variants. Good for definitions and one-line answers.",
84
+ tileSummary: "Single word or short phrase, exact-match",
85
+ storageType: "short_answer",
86
+ },
87
+ {
88
+ value: "numeric",
89
+ label: "Numeric",
90
+ shortLabel: "Numeric",
91
+ icon: "fa-input-numeric",
92
+ description:
93
+ "Number-only response with optional units and a ± tolerance band. Auto-graded on submission.",
94
+ tileSummary: "Number with units and tolerance band",
95
+ badge: "Auto-graded",
96
+ storageType: "short_answer",
97
+ },
98
+ {
99
+ value: "essay",
100
+ label: "Essay",
101
+ shortLabel: "Essay",
102
+ icon: "fa-paragraph",
103
+ description:
104
+ "Long-form response graded against a rubric. Use for clinical reasoning and care-plan items.",
105
+ tileSummary: "Long response — rubric-graded",
106
+ badge: "Rubric",
107
+ storageType: "short_answer",
108
+ },
109
+ {
110
+ value: "fill_blank",
111
+ label: "Fill in the blank",
112
+ shortLabel: "Fill in the blank",
113
+ icon: "fa-text-size",
114
+ description:
115
+ "Sentence with one or more {{blanks}}; learners type the missing word(s). List accepted answers per blank.",
116
+ tileSummary: "Sentence with one or more blanks to fill",
117
+ storageType: "short_answer",
118
+ },
119
+ {
120
+ value: "matching",
121
+ label: "Matching",
122
+ shortLabel: "Matching",
123
+ icon: "fa-arrow-right-arrow-left",
124
+ description:
125
+ "Two columns — pair each prompt on the left to its correct match on the right.",
126
+ tileSummary: "Pair terms on the left to definitions on the right",
127
+ storageType: "multiple_choice",
128
+ },
129
+ {
130
+ value: "ordering",
131
+ label: "Ordering",
132
+ shortLabel: "Ordering",
133
+ icon: "fa-list-ol",
134
+ description:
135
+ "Show the steps; the learner re-orders them into the correct sequence (e.g. ACLS algorithm steps).",
136
+ tileSummary: "Place items in the correct sequence",
137
+ storageType: "multiple_choice",
138
+ },
139
+ {
140
+ value: "hotspot",
141
+ label: "Hotspot",
142
+ shortLabel: "Hotspot",
143
+ icon: "fa-bullseye-pointer",
144
+ description:
145
+ "Image-based item — learner clicks the correct region (e.g. anatomy, x-ray, ECG).",
146
+ tileSummary: "Click the correct region of an image",
147
+ badge: "Image-based",
148
+ storageType: "multiple_choice",
149
+ },
150
+ ] as const
151
+
152
+ export function authoringQuestionType(value: AuthoringQuestionType): AuthoringQuestionTypeOption {
153
+ return AUTHORING_QUESTION_TYPES.find(t => t.value === value) ?? AUTHORING_QUESTION_TYPES[0]
154
+ }
155
+
156
+ // ─────────────────────────────────────────────────────────────────────────────
157
+ // Difficulty + Bloom + cognitive level (NBME)
158
+ // ─────────────────────────────────────────────────────────────────────────────
159
+
160
+ export const AUTHORING_DIFFICULTY_OPTIONS = [
161
+ { value: "easy", label: "Easy", description: "Recall · expected ≥ 80% correct" },
162
+ { value: "medium", label: "Medium", description: "Application · 50–80% correct" },
163
+ { value: "hard", label: "Hard", description: "Analysis · ≤ 50% correct" },
164
+ ] as const
165
+
166
+ export const AUTHORING_BLOOM_OPTIONS: readonly { value: QuestionBankBloomLevel; label: string; hint: string }[] = [
167
+ { value: "Remember", label: "Remember", hint: "Recall facts, terms, basic concepts" },
168
+ { value: "Understand", label: "Understand", hint: "Explain, summarise, classify" },
169
+ { value: "Apply", label: "Apply", hint: "Use knowledge in a new situation" },
170
+ { value: "Analyze", label: "Analyze", hint: "Break information apart, examine relationships" },
171
+ { value: "Evaluate", label: "Evaluate", hint: "Justify a stand, defend a decision" },
172
+ { value: "Create", label: "Create", hint: "Produce new or original work" },
173
+ ] as const
174
+
175
+ /** NBME-aligned cognitive level — separate from Bloom (broader). */
176
+ export const AUTHORING_COG_LEVEL_OPTIONS = [
177
+ { value: "recall", label: "Recall", hint: "Definitions, mechanisms, anatomy" },
178
+ { value: "application", label: "Application", hint: "Apply concept to a new patient scenario" },
179
+ { value: "analysis", label: "Analysis", hint: "Compare, evaluate, synthesise findings" },
180
+ ] as const
181
+ export type AuthoringCogLevel = typeof AUTHORING_COG_LEVEL_OPTIONS[number]["value"]
182
+
183
+ // ─────────────────────────────────────────────────────────────────────────────
184
+ // Subject area / body system / discipline / phase
185
+ // ─────────────────────────────────────────────────────────────────────────────
186
+
187
+ export const AUTHORING_SUBJECT_AREAS = [
188
+ "Anatomy",
189
+ "Physiology",
190
+ "Pathology",
191
+ "Pharmacology",
192
+ "Microbiology & immunology",
193
+ "Behavioral science",
194
+ "Biochemistry & genetics",
195
+ "Public health & epidemiology",
196
+ "Clinical skills",
197
+ "Communication & ethics",
198
+ ] as const
199
+ export type AuthoringSubjectArea = typeof AUTHORING_SUBJECT_AREAS[number]
200
+
201
+ export const AUTHORING_BODY_SYSTEMS = [
202
+ { value: "cardiovascular", label: "Cardiovascular", icon: "fa-heart-pulse" },
203
+ { value: "respiratory", label: "Respiratory", icon: "fa-lungs" },
204
+ { value: "renal", label: "Renal & urinary", icon: "fa-droplet" },
205
+ { value: "gastrointestinal", label: "Gastrointestinal", icon: "fa-stomach" },
206
+ { value: "endocrine", label: "Endocrine", icon: "fa-flask" },
207
+ { value: "musculoskeletal", label: "Musculoskeletal", icon: "fa-bone" },
208
+ { value: "nervous", label: "Nervous", icon: "fa-brain" },
209
+ { value: "reproductive", label: "Reproductive", icon: "fa-venus-mars" },
210
+ { value: "hematologic", label: "Hematologic & oncologic", icon: "fa-vial" },
211
+ { value: "immune", label: "Immune", icon: "fa-shield-virus" },
212
+ { value: "skin", label: "Skin & subcutaneous", icon: "fa-hand-dots" },
213
+ { value: "behavioral_psych", label: "Behavioral / psych", icon: "fa-comments" },
214
+ { value: "multisystem", label: "Multisystem", icon: "fa-circle-nodes" },
215
+ ] as const
216
+ export type AuthoringBodySystem = typeof AUTHORING_BODY_SYSTEMS[number]["value"]
217
+
218
+ export const AUTHORING_DISCIPLINES = [
219
+ "Medicine (MD / DO)",
220
+ "Nursing (BSN / MSN)",
221
+ "Pharmacy (PharmD)",
222
+ "Dental (DMD / DDS)",
223
+ "Physical therapy (DPT)",
224
+ "Occupational therapy",
225
+ "Physician assistant",
226
+ "Respiratory therapy",
227
+ "Medical lab science",
228
+ "Radiologic sciences",
229
+ "Public health (MPH)",
230
+ ] as const
231
+ export type AuthoringDiscipline = typeof AUTHORING_DISCIPLINES[number]
232
+
233
+ export const AUTHORING_PHASES = [
234
+ { value: "preclinical", label: "Pre-clinical", hint: "Foundational sciences" },
235
+ { value: "clinical", label: "Clinical", hint: "Clerkships / practicum" },
236
+ { value: "residency", label: "Residency / fellowship", hint: "Post-graduate training" },
237
+ { value: "ce", label: "Continuing education", hint: "Practising clinicians" },
238
+ ] as const
239
+ export type AuthoringPhase = typeof AUTHORING_PHASES[number]["value"]
240
+
241
+ // ─────────────────────────────────────────────────────────────────────────────
242
+ // Workflow status
243
+ // ─────────────────────────────────────────────────────────────────────────────
244
+
245
+ export type AuthoringStatus = "draft" | "in_review" | "approved" | "published" | "retired"
246
+
247
+ export const AUTHORING_STATUS_OPTIONS: readonly { value: AuthoringStatus; label: string; description: string; icon: string }[] = [
248
+ {
249
+ value: "draft",
250
+ label: "Draft",
251
+ description: "Visible only to authors. Not eligible for assessments.",
252
+ icon: "fa-file-pen",
253
+ },
254
+ {
255
+ value: "in_review",
256
+ label: "In review",
257
+ description: "Awaiting subject-matter expert peer review.",
258
+ icon: "fa-magnifying-glass",
259
+ },
260
+ {
261
+ value: "approved",
262
+ label: "Approved",
263
+ description: "SME signed off. Ready to publish to the live bank.",
264
+ icon: "fa-circle-check",
265
+ },
266
+ {
267
+ value: "published",
268
+ label: "Published",
269
+ description: "Live in the bank. Available for exam / quiz selection.",
270
+ icon: "fa-broadcast-tower",
271
+ },
272
+ {
273
+ value: "retired",
274
+ label: "Retired",
275
+ description: "Removed from active use. Kept for audit and reuse history.",
276
+ icon: "fa-box-archive",
277
+ },
278
+ ] as const
279
+
280
+ // ─────────────────────────────────────────────────────────────────────────────
281
+ // Defaults
282
+ // ─────────────────────────────────────────────────────────────────────────────
283
+
284
+ export const AUTHORING_DEFAULT_OPTION_COUNT = 5
285
+ export const AUTHORING_MIN_OPTION_COUNT = 2
286
+ export const AUTHORING_MAX_OPTION_COUNT = 8
287
+
288
+ /** Stem placeholder — modelled on a typical NBME / clerkship vignette. */
289
+ export const AUTHORING_STEM_PLACEHOLDER = `A 58-year-old man presents to the emergency department with substernal chest pressure that began two hours ago while mowing the lawn. The pain radiates to the left jaw and is associated with diaphoresis and nausea. Past medical history is significant for hypertension and type 2 diabetes mellitus. Vital signs show blood pressure 158/92 mm Hg, heart rate 102/min, respirations 20/min. Physical examination is notable for an S4 gallop. ECG shows 2 mm ST-segment elevation in leads II, III, and aVF.`
290
+
291
+ export const AUTHORING_LEAD_IN_PLACEHOLDER =
292
+ "Which of the following is the most likely diagnosis?"
293
+
294
+ /** Best-practice rationale prompt — author-side, not student-facing. */
295
+ export const AUTHORING_RATIONALE_PLACEHOLDER =
296
+ "Explain why this option is correct (or why this distractor is plausible). Cite mechanism, guideline, or reference where possible."
297
+
298
+ /**
299
+ * Draft question handle assigned when the composer route loads.
300
+ * Format: `Q-YYMM-XXXX` (e.g. `Q-2605-A3F2`). Generate on the server and pass
301
+ * into `NewQuestionComposer` so SSR and hydration share one value.
302
+ */
303
+ export function generateDraftQuestionId(now = new Date()): string {
304
+ const yy = String(now.getFullYear()).slice(-2)
305
+ const mm = String(now.getMonth() + 1).padStart(2, "0")
306
+ const rand = Math.random().toString(16).slice(2, 6).toUpperCase().padEnd(4, "0")
307
+ return `Q-${yy}${mm}-${rand}`
308
+ }
@@ -130,6 +130,50 @@ export interface QuestionBankHubHeaderModel {
130
130
  breadcrumbs?: { label: string; href?: string }[]
131
131
  }
132
132
 
133
+ /** Back link for `/question-bank/new` — icon + parent label in `SiteHeader` (`back` prop). */
134
+ export function newQuestionBackNav(
135
+ folders: QuestionBankFolder[],
136
+ folderId?: string,
137
+ ): { label: string; href: string } {
138
+ if (folderId) {
139
+ const folder = folders.find(f => f.id === folderId)
140
+ if (folder) {
141
+ return {
142
+ label: folder.name,
143
+ href: questionBankNavHref({ scope: "folder", folderId: folder.id }),
144
+ }
145
+ }
146
+ }
147
+ return {
148
+ label: QUESTION_BANK_HUB_BREADCRUMB.label,
149
+ href: questionBankNavHref({ scope: "all" }),
150
+ }
151
+ }
152
+
153
+ /** Ancestor-only breadcrumbs for `/question-bank/new` (current page is the PageHeader title). */
154
+ export function newQuestionBreadcrumbs(
155
+ folders: QuestionBankFolder[],
156
+ folderId?: string,
157
+ ): { label: string; href: string }[] {
158
+ if (folderId) {
159
+ const folder = folders.find(f => f.id === folderId)
160
+ if (folder) {
161
+ return [
162
+ {
163
+ label: folder.name,
164
+ href: questionBankNavHref({ scope: "folder", folderId: folder.id }),
165
+ },
166
+ ]
167
+ }
168
+ }
169
+ return [
170
+ {
171
+ label: QUESTION_BANK_HUB_BREADCRUMB.label,
172
+ href: QUESTION_BANK_HUB_BREADCRUMB.href,
173
+ },
174
+ ]
175
+ }
176
+
133
177
  export function questionBankHubHeaderModel(
134
178
  folders: QuestionBankFolder[],
135
179
  nav: QuestionBankNavState,
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Coalesce many calls to `fn` into one per animation frame.
3
+ *
4
+ * Use for high-frequency layout-reading event handlers (scroll, resize,
5
+ * visualViewport, ResizeObserver) where the work must happen in a frame but
6
+ * doing it on every event call (60+/s for resize, hundreds/s for capture
7
+ * scrolls) wastes layout/paint cycles. The returned function exposes
8
+ * `.cancel()` so effect cleanup can drop a pending frame.
9
+ *
10
+ * Pattern:
11
+ * const apply = () => { ...layout reads + setState... }
12
+ * const scheduled = rafThrottle(apply)
13
+ * window.addEventListener("scroll", scheduled, { passive: true, capture: true })
14
+ * return () => {
15
+ * scheduled.cancel()
16
+ * window.removeEventListener("scroll", scheduled, { capture: true })
17
+ * }
18
+ */
19
+ export function rafThrottle<TArgs extends unknown[]>(
20
+ fn: (...args: TArgs) => void,
21
+ ): ((...args: TArgs) => void) & { cancel: () => void } {
22
+ let rafId = 0
23
+ let lastArgs: TArgs | null = null
24
+
25
+ const scheduled = ((...args: TArgs) => {
26
+ lastArgs = args
27
+ if (rafId !== 0) return
28
+ rafId = requestAnimationFrame(() => {
29
+ rafId = 0
30
+ const a = lastArgs
31
+ lastArgs = null
32
+ if (a) fn(...a)
33
+ })
34
+ }) as ((...args: TArgs) => void) & { cancel: () => void }
35
+
36
+ scheduled.cancel = () => {
37
+ if (rafId !== 0) {
38
+ cancelAnimationFrame(rafId)
39
+ rafId = 0
40
+ }
41
+ lastArgs = null
42
+ }
43
+
44
+ return scheduled
45
+ }
@@ -0,0 +1,9 @@
1
+ /** Cookie name persisted by `@exxatdesignux/ui` `SidebarProvider` (`setOpen`). */
2
+ export const SIDEBAR_STATE_COOKIE_NAME = "sidebar_state"
3
+
4
+ /** Read desktop sidebar expanded state for SSR `defaultOpen` (matches client cookie restore). */
5
+ export function sidebarDefaultOpenFromCookie(
6
+ value: string | undefined,
7
+ ): boolean {
8
+ return value !== "false"
9
+ }