@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
|
@@ -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
|
+
}
|