question-bank-framework 0.4.0 → 0.5.0

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/README.md CHANGED
@@ -99,6 +99,33 @@ defineBank({
99
99
  });
100
100
  ```
101
101
 
102
+ ## Question kinds
103
+
104
+ Every question has `id/title/prompt/tags`; `kind` (default `written`) picks the
105
+ response UI and grading. All kinds except `written` are auto-graded in quizzes
106
+ (Check answer → per-question feedback, the bank's answer shown as the explanation).
107
+
108
+ | kind | extra fields | answer UI |
109
+ |---|---|---|
110
+ | `written` | — | free-text box; "Show answer" reveals `answers[id]` |
111
+ | `single` | `options`, `correctIds` (exactly one) | radio list |
112
+ | `multiple` | `options`, `correctIds` (≥ 1, exact-set match) | checkbox list |
113
+ | `numerical` | `numeric: { value, tolerance?, unit? }` | number input; correct within ±`tolerance` (default exact). Accepts `1,756`, `1.2e3`. |
114
+ | `matching` | shared `options`, `items: [{ id, text, correctId }]` | one dropdown per item, options lettered A, B, … |
115
+ | `dropdown` | `blanks: [{ id, label?, options, correctId }]` | one dropdown per blank, each with its own options |
116
+
117
+ ```json
118
+ { "id": "q7", "kind": "matching", "title": "…", "prompt": "Match each join to its cost.", "tags": [],
119
+ "options": [{ "id": "a", "text": "Hash join" }, { "id": "b", "text": "Sort-merge join" }],
120
+ "items": [{ "id": "1", "text": "`3(M + N)`", "correctId": "a" }] }
121
+ ```
122
+
123
+ Matching and dropdown questions earn partial credit per item/blank in the
124
+ review (`gradeAnswer`), but only count as correct in the quiz score when every
125
+ item is right. `defineBank` validates each question's fields against its kind
126
+ and throws on mismatches (e.g. a `correctId` that isn't one of the options).
127
+ Don't repeat the option list or answer table in `prompt`; the UI renders them.
128
+
102
129
  ## Dev
103
130
 
104
131
  ```sh
package/core/config.tsx CHANGED
@@ -31,6 +31,10 @@ export type {
31
31
  BankSiteConfig,
32
32
  BaseQuestion,
33
33
  DefineBankInput,
34
+ DropdownBlank,
35
+ MatchingItem,
36
+ NumericAnswer,
37
+ PairingSlot,
34
38
  QuestionKind,
35
39
  QuestionOption,
36
40
  QuestionStatus,
@@ -40,13 +44,19 @@ export type {
40
44
  } from './types.ts';
41
45
  export {
42
46
  getCorrectIds,
47
+ getPairingSlots,
48
+ gradeAnswer,
43
49
  isAnswerCorrect,
44
50
  isAnswerProvided,
45
51
  isChoiceQuestion,
52
+ isGradeableQuestion,
53
+ isPairingQuestion,
54
+ parseNumericAnswer,
46
55
  questionKind,
47
56
  questionKindLabels,
48
57
  questionKinds,
49
58
  scoreAnswers,
59
+ toPairings,
50
60
  toSelectedIds,
51
61
  } from './types.ts';
52
62
  export { questionStatuses } from './types.ts';
package/core/index.ts CHANGED
@@ -7,14 +7,20 @@ export {
7
7
  useBank,
8
8
  defineBank,
9
9
  getCorrectIds,
10
+ getPairingSlots,
11
+ gradeAnswer,
10
12
  isAnswerCorrect,
11
13
  isAnswerProvided,
12
14
  isChoiceQuestion,
15
+ isGradeableQuestion,
16
+ isPairingQuestion,
17
+ parseNumericAnswer,
13
18
  questionKind,
14
19
  questionKindLabels,
15
20
  questionKinds,
16
21
  questionStatuses,
17
22
  scoreAnswers,
23
+ toPairings,
18
24
  toSelectedIds,
19
25
  } from './config.tsx';
20
26
  export type {
@@ -26,6 +32,10 @@ export type {
26
32
  BankSiteConfig,
27
33
  BaseQuestion,
28
34
  DefineBankInput,
35
+ DropdownBlank,
36
+ MatchingItem,
37
+ NumericAnswer,
38
+ PairingSlot,
29
39
  QuestionKind,
30
40
  QuestionOption,
31
41
  QuestionStatus,
package/core/types.ts CHANGED
@@ -9,17 +9,25 @@ export type QuestionStatus = (typeof questionStatuses)[number];
9
9
  export type QuestionStatusMap = Record<string, QuestionStatus>;
10
10
 
11
11
  // ---------------------------------------------------------------------------
12
- // Question kinds: written (free response) vs choice (auto-gradeable).
12
+ // Question kinds. `written` is free response; every other kind is
13
+ // auto-gradeable:
14
+ // single / multiple — pick option(s) from `options`
15
+ // numerical — type a number, checked against `numeric`
16
+ // matching — pair each of `items` with one of the shared `options`
17
+ // dropdown — fill each of `blanks` from its own option list
13
18
  // ---------------------------------------------------------------------------
14
19
 
15
- export type QuestionKind = 'written' | 'single' | 'multiple';
20
+ export type QuestionKind = 'written' | 'single' | 'multiple' | 'numerical' | 'matching' | 'dropdown';
16
21
 
17
- export const questionKinds: readonly QuestionKind[] = ['written', 'single', 'multiple'];
22
+ export const questionKinds: readonly QuestionKind[] = ['written', 'single', 'multiple', 'numerical', 'matching', 'dropdown'];
18
23
 
19
24
  export const questionKindLabels: Record<QuestionKind, string> = {
20
25
  written: 'Written',
21
26
  single: 'Single choice',
22
27
  multiple: 'Multiple choice',
28
+ numerical: 'Numerical',
29
+ matching: 'Matching',
30
+ dropdown: 'Dropdowns',
23
31
  };
24
32
 
25
33
  export interface QuestionOption {
@@ -29,20 +37,61 @@ export interface QuestionOption {
29
37
  text: string;
30
38
  }
31
39
 
32
- /** Written answers are free text; choice answers are selected option ids. */
33
- export type QuizAnswer = string | string[];
40
+ /** A left-hand row of a matching question, answered from the question's shared `options`. */
41
+ export interface MatchingItem {
42
+ /** Stable within the question, e.g. "1", "2". */
43
+ id: string;
44
+ /** Row text. Markdown supported. */
45
+ text: string;
46
+ /** Id of the correct option in the question's `options`. */
47
+ correctId: string;
48
+ }
49
+
50
+ /** One blank of a dropdown question, with its own option list. */
51
+ export interface DropdownBlank {
52
+ /** Stable within the question, e.g. "1", "key". */
53
+ id: string;
54
+ /** Text shown before the dropdown, e.g. "A possible candidate key is:". Markdown supported. */
55
+ label?: string;
56
+ options: QuestionOption[];
57
+ /** Id of the correct option in this blank's `options`. */
58
+ correctId: string;
59
+ }
60
+
61
+ /** Expected answer of a numerical question. */
62
+ export interface NumericAnswer {
63
+ value: number;
64
+ /** Absolute tolerance: answers within ±tolerance of value are correct. Defaults to 0 (exact). */
65
+ tolerance?: number;
66
+ /** Unit shown next to the input, e.g. "I/Os" or "bytes". */
67
+ unit?: string;
68
+ }
69
+
70
+ /**
71
+ * Stored quiz answer:
72
+ * written / numerical — the typed text
73
+ * single / multiple — selected option ids
74
+ * matching / dropdown — item/blank id → chosen option id
75
+ */
76
+ export type QuizAnswer = string | string[] | Record<string, string>;
34
77
 
35
78
  export interface BaseQuestion {
36
79
  id: string;
37
80
  title: string;
38
81
  prompt: string;
39
82
  tags: string[];
40
- /** Defaults to 'written' when omitted. Choice kinds require options + correctIds. */
83
+ /** Defaults to 'written' when omitted. Each other kind requires its own answer fields (see below). */
41
84
  kind?: QuestionKind;
42
- /** Choice options. Required for single/multiple, forbidden for written. */
85
+ /** Choice options (single/multiple), or the shared option list of a matching question. */
43
86
  options?: QuestionOption[];
44
- /** Correct option ids. Single choice requires exactly one. */
87
+ /** Correct option ids (single/multiple). Single choice requires exactly one. */
45
88
  correctIds?: string[];
89
+ /** Rows to match (matching). */
90
+ items?: MatchingItem[];
91
+ /** Blanks to fill (dropdown). */
92
+ blanks?: DropdownBlank[];
93
+ /** Expected number (numerical). */
94
+ numeric?: NumericAnswer;
46
95
  // Subjects extend this interface with their own fields; see template/bank.config.ts.
47
96
  }
48
97
 
@@ -161,48 +210,110 @@ export function defineBank<Q extends BaseQuestion>(input: DefineBankInput<Q>): B
161
210
  };
162
211
  }
163
212
 
164
- function validateQuestion(question: BaseQuestion): void {
165
- const kind: QuestionKind = question.kind ?? 'written';
166
- if (!questionKinds.includes(kind)) {
167
- throw new Error(`Question "${question.id}" has unknown kind "${question.kind}".`);
168
- }
169
- if (kind === 'written') {
170
- if (question.options !== undefined) {
171
- throw new Error(`Question "${question.id}" is written and must not define options.`);
172
- }
173
- if (question.correctIds !== undefined) {
174
- throw new Error(`Question "${question.id}" is written and must not define correctIds.`);
175
- }
176
- return;
177
- }
178
- const options = question.options ?? [];
179
- if (options.length < 2) {
180
- throw new Error(`Question "${question.id}" (${kind}) needs at least 2 options.`);
213
+ type KindField = 'options' | 'correctIds' | 'items' | 'blanks' | 'numeric';
214
+
215
+ const kindFields: Record<QuestionKind, readonly KindField[]> = {
216
+ written: [],
217
+ single: ['options', 'correctIds'],
218
+ multiple: ['options', 'correctIds'],
219
+ numerical: ['numeric'],
220
+ matching: ['options', 'items'],
221
+ dropdown: ['blanks'],
222
+ };
223
+
224
+ function validateOptionList(questionId: string, where: string, options: readonly QuestionOption[] | undefined): Set<string> {
225
+ const list = options ?? [];
226
+ if (list.length < 2) {
227
+ throw new Error(`Question "${questionId}" ${where} needs at least 2 options.`);
181
228
  }
182
229
  const seen = new Set<string>();
183
- for (const option of options) {
230
+ for (const option of list) {
184
231
  if (!option.id || seen.has(option.id)) {
185
- throw new Error(`Question "${question.id}" has a duplicate or empty option id "${option.id}".`);
232
+ throw new Error(`Question "${questionId}" ${where} has a duplicate or empty option id "${option.id}".`);
186
233
  }
187
234
  seen.add(option.id);
188
235
  }
189
- const correctIds = question.correctIds ?? [];
190
- if (kind === 'single' && correctIds.length !== 1) {
191
- throw new Error(`Question "${question.id}" (single) needs exactly one correctId.`);
236
+ return seen;
237
+ }
238
+
239
+ function validateQuestion(question: BaseQuestion): void {
240
+ const kind: QuestionKind = question.kind ?? 'written';
241
+ if (!questionKinds.includes(kind)) {
242
+ throw new Error(`Question "${question.id}" has unknown kind "${question.kind}".`);
192
243
  }
193
- if (kind === 'multiple' && correctIds.length < 1) {
194
- throw new Error(`Question "${question.id}" (multiple) needs at least one correctId.`);
244
+ const allowed = kindFields[kind];
245
+ for (const field of ['options', 'correctIds', 'items', 'blanks', 'numeric'] as const) {
246
+ if (question[field] !== undefined && !allowed.includes(field)) {
247
+ throw new Error(`Question "${question.id}" is ${kind} and must not define ${field}.`);
248
+ }
195
249
  }
196
- for (const id of correctIds) {
197
- if (!seen.has(id)) {
198
- throw new Error(`Question "${question.id}" has unknown correctId "${id}".`);
250
+
251
+ switch (kind) {
252
+ case 'written':
253
+ return;
254
+ case 'single':
255
+ case 'multiple': {
256
+ const seen = validateOptionList(question.id, `(${kind})`, question.options);
257
+ const correctIds = question.correctIds ?? [];
258
+ if (kind === 'single' && correctIds.length !== 1) {
259
+ throw new Error(`Question "${question.id}" (single) needs exactly one correctId.`);
260
+ }
261
+ if (kind === 'multiple' && correctIds.length < 1) {
262
+ throw new Error(`Question "${question.id}" (multiple) needs at least one correctId.`);
263
+ }
264
+ for (const id of correctIds) {
265
+ if (!seen.has(id)) throw new Error(`Question "${question.id}" has unknown correctId "${id}".`);
266
+ }
267
+ return;
268
+ }
269
+ case 'numerical': {
270
+ const numeric = question.numeric;
271
+ if (!numeric || typeof numeric.value !== 'number' || !Number.isFinite(numeric.value)) {
272
+ throw new Error(`Question "${question.id}" (numerical) needs numeric.value as a finite number.`);
273
+ }
274
+ if (numeric.tolerance !== undefined && !(Number.isFinite(numeric.tolerance) && numeric.tolerance >= 0)) {
275
+ throw new Error(`Question "${question.id}" (numerical) has an invalid tolerance.`);
276
+ }
277
+ return;
278
+ }
279
+ case 'matching': {
280
+ const seen = validateOptionList(question.id, '(matching)', question.options);
281
+ const items = question.items ?? [];
282
+ if (items.length < 1) throw new Error(`Question "${question.id}" (matching) needs at least 1 item.`);
283
+ const itemIds = new Set<string>();
284
+ for (const item of items) {
285
+ if (!item.id || itemIds.has(item.id)) {
286
+ throw new Error(`Question "${question.id}" has a duplicate or empty item id "${item.id}".`);
287
+ }
288
+ itemIds.add(item.id);
289
+ if (!seen.has(item.correctId)) {
290
+ throw new Error(`Question "${question.id}" item "${item.id}" has unknown correctId "${item.correctId}".`);
291
+ }
292
+ }
293
+ return;
294
+ }
295
+ case 'dropdown': {
296
+ const blanks = question.blanks ?? [];
297
+ if (blanks.length < 1) throw new Error(`Question "${question.id}" (dropdown) needs at least 1 blank.`);
298
+ const blankIds = new Set<string>();
299
+ for (const blank of blanks) {
300
+ if (!blank.id || blankIds.has(blank.id)) {
301
+ throw new Error(`Question "${question.id}" has a duplicate or empty blank id "${blank.id}".`);
302
+ }
303
+ blankIds.add(blank.id);
304
+ const seen = validateOptionList(question.id, `blank "${blank.id}"`, blank.options);
305
+ if (!seen.has(blank.correctId)) {
306
+ throw new Error(`Question "${question.id}" blank "${blank.id}" has unknown correctId "${blank.correctId}".`);
307
+ }
308
+ }
309
+ return;
199
310
  }
200
311
  }
201
312
  }
202
313
 
203
314
  // ---------------------------------------------------------------------------
204
- // Choice helpers. All UI + scoring goes through these so option storage
205
- // (ids) stays decoupled from presentation (letters, markdown).
315
+ // Answer helpers. All UI + scoring goes through these so answer storage
316
+ // (ids, raw text) stays decoupled from presentation (letters, markdown).
206
317
  // ---------------------------------------------------------------------------
207
318
 
208
319
  /** Effective kind: questions without `kind` are written. */
@@ -210,19 +321,60 @@ export function questionKind(question: BaseQuestion): QuestionKind {
210
321
  return question.kind ?? 'written';
211
322
  }
212
323
 
324
+ /** Single or multiple choice (pick from `options`). */
213
325
  export function isChoiceQuestion(question: BaseQuestion): boolean {
214
326
  const kind = questionKind(question);
215
327
  return kind === 'single' || kind === 'multiple';
216
328
  }
217
329
 
218
- /** Correct option ids. Empty for written questions. */
330
+ /** Matching or dropdown (each row/blank maps to one option id). */
331
+ export function isPairingQuestion(question: BaseQuestion): boolean {
332
+ const kind = questionKind(question);
333
+ return kind === 'matching' || kind === 'dropdown';
334
+ }
335
+
336
+ /** Every kind except written can be auto-graded. */
337
+ export function isGradeableQuestion(question: BaseQuestion): boolean {
338
+ return questionKind(question) !== 'written';
339
+ }
340
+
341
+ /** Correct option ids. Empty unless single/multiple. */
219
342
  export function getCorrectIds(question: BaseQuestion): string[] {
220
343
  return isChoiceQuestion(question) ? [...(question.correctIds ?? [])] : [];
221
344
  }
222
345
 
346
+ export interface PairingSlot {
347
+ id: string;
348
+ /** Row text (matching) or blank label (dropdown). */
349
+ label: string | undefined;
350
+ options: readonly QuestionOption[];
351
+ correctId: string;
352
+ }
353
+
354
+ /** Rows of a matching question or blanks of a dropdown question, uniformly. Empty otherwise. */
355
+ export function getPairingSlots(question: BaseQuestion): PairingSlot[] {
356
+ const kind = questionKind(question);
357
+ if (kind === 'matching') {
358
+ const options = question.options ?? [];
359
+ return (question.items ?? []).map((item) => ({ id: item.id, label: item.text, options, correctId: item.correctId }));
360
+ }
361
+ if (kind === 'dropdown') {
362
+ return (question.blanks ?? []).map((blank) => ({
363
+ id: blank.id,
364
+ label: blank.label,
365
+ options: blank.options,
366
+ correctId: blank.correctId,
367
+ }));
368
+ }
369
+ return [];
370
+ }
371
+
372
+ const isRecordAnswer = (answer: QuizAnswer | undefined): answer is Record<string, string> =>
373
+ typeof answer === 'object' && answer !== null && !Array.isArray(answer);
374
+
223
375
  /** Normalize a stored answer to selected option ids — tolerates legacy strings. */
224
376
  export function toSelectedIds(question: BaseQuestion, answer: QuizAnswer | undefined): string[] {
225
- if (answer === undefined) return [];
377
+ if (answer === undefined || isRecordAnswer(answer)) return [];
226
378
  const ids =
227
379
  typeof answer === 'string' ? (answer === '' ? [] : [answer]) : answer.filter((id) => typeof id === 'string');
228
380
  if (!isChoiceQuestion(question)) return ids;
@@ -230,9 +382,34 @@ export function toSelectedIds(question: BaseQuestion, answer: QuizAnswer | undef
230
382
  return ids.filter((id) => valid.has(id));
231
383
  }
232
384
 
233
- /** Whether the user has provided an answer (non-blank text, or ≥1 selection). */
385
+ /** Normalize a stored answer to slot id → option id, dropping unknown slots/options. */
386
+ export function toPairings(question: BaseQuestion, answer: QuizAnswer | undefined): Record<string, string> {
387
+ if (!isRecordAnswer(answer)) return {};
388
+ const out: Record<string, string> = {};
389
+ for (const slot of getPairingSlots(question)) {
390
+ const chosen = answer[slot.id];
391
+ if (typeof chosen === 'string' && slot.options.some((o) => o.id === chosen)) out[slot.id] = chosen;
392
+ }
393
+ return out;
394
+ }
395
+
396
+ /**
397
+ * Parse a typed number. Accepts thousands separators ("1,756"), spaces,
398
+ * a leading "+", scientific notation and a trailing "%" (kept as the
399
+ * literal number). Returns null when the text is not a number.
400
+ */
401
+ export function parseNumericAnswer(text: string): number | null {
402
+ const cleaned = text.trim().replace(/[\s,_]/g, '').replace(/%$/, '').replace(/^\+/, '');
403
+ if (cleaned === '' || !/^-?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(cleaned)) return null;
404
+ const value = Number(cleaned);
405
+ return Number.isFinite(value) ? value : null;
406
+ }
407
+
408
+ /** Whether the user has provided an answer (non-blank text, ≥1 selection, or ≥1 pairing). */
234
409
  export function isAnswerProvided(question: BaseQuestion, answer: QuizAnswer | undefined): boolean {
235
410
  if (answer === undefined) return false;
411
+ if (isPairingQuestion(question)) return Object.keys(toPairings(question, answer)).length > 0;
412
+ if (isRecordAnswer(answer)) return false;
236
413
  if (typeof answer === 'string') return answer.trim().length > 0;
237
414
  if (!isChoiceQuestion(question)) return answer.some((id) => id.trim().length > 0);
238
415
  const valid = new Set((question.options ?? []).map((o) => o.id));
@@ -240,20 +417,45 @@ export function isAnswerProvided(question: BaseQuestion, answer: QuizAnswer | un
240
417
  }
241
418
 
242
419
  /**
243
- * Grade a choice answer. Returns null for written questions or when
244
- * unanswered. Single: exactly the correct id. Multiple: exact set match
245
- * (order-insensitive, no extras, no omissions).
420
+ * Per-part credit for an answer: { earned, possible }. null for written
421
+ * questions or when unanswered. Matching/dropdown earn one part per correct
422
+ * row/blank; every other gradeable kind is a single all-or-nothing part.
246
423
  */
247
- export function isAnswerCorrect(question: BaseQuestion, answer: QuizAnswer | undefined): boolean | null {
248
- if (!isChoiceQuestion(question)) return null;
249
- if (!isAnswerProvided(question, answer)) return null;
424
+ export function gradeAnswer(
425
+ question: BaseQuestion,
426
+ answer: QuizAnswer | undefined,
427
+ ): { earned: number; possible: number } | null {
428
+ if (!isGradeableQuestion(question) || !isAnswerProvided(question, answer)) return null;
429
+ const kind = questionKind(question);
430
+ if (kind === 'numerical') {
431
+ const value = typeof answer === 'string' ? parseNumericAnswer(answer) : null;
432
+ const expected = question.numeric;
433
+ if (value === null || !expected) return { earned: 0, possible: 1 };
434
+ const tolerance = expected.tolerance ?? 0;
435
+ // Small relative epsilon so 0.1 + 0.2 style float noise never fails an exact answer.
436
+ const epsilon = Math.max(Math.abs(expected.value), 1) * 1e-9;
437
+ return { earned: Math.abs(value - expected.value) <= tolerance + epsilon ? 1 : 0, possible: 1 };
438
+ }
439
+ if (kind === 'matching' || kind === 'dropdown') {
440
+ const pairings = toPairings(question, answer);
441
+ const slots = getPairingSlots(question);
442
+ const earned = slots.filter((slot) => pairings[slot.id] === slot.correctId).length;
443
+ return { earned, possible: slots.length };
444
+ }
250
445
  const selected = new Set(toSelectedIds(question, answer));
251
446
  const correct = new Set(getCorrectIds(question));
252
- if (selected.size !== correct.size) return false;
253
- for (const id of selected) {
254
- if (!correct.has(id)) return false;
255
- }
256
- return true;
447
+ const exact = selected.size === correct.size && [...selected].every((id) => correct.has(id));
448
+ return { earned: exact ? 1 : 0, possible: 1 };
449
+ }
450
+
451
+ /**
452
+ * Grade an answer. Returns null for written questions or when unanswered.
453
+ * Single: exactly the correct id. Multiple: exact set match. Numerical:
454
+ * within tolerance. Matching/dropdown: every row/blank correct.
455
+ */
456
+ export function isAnswerCorrect(question: BaseQuestion, answer: QuizAnswer | undefined): boolean | null {
457
+ const grade = gradeAnswer(question, answer);
458
+ return grade === null ? null : grade.earned === grade.possible;
257
459
  }
258
460
 
259
461
  /** Score a set of questions against answers: { correct, gradeable, answered }. */
@@ -266,7 +468,7 @@ export function scoreAnswers(
266
468
  let answered = 0;
267
469
  for (const question of questions) {
268
470
  if (isAnswerProvided(question, answers[question.id])) answered += 1;
269
- if (!isChoiceQuestion(question)) continue;
471
+ if (!isGradeableQuestion(question)) continue;
270
472
  gradeable += 1;
271
473
  if (isAnswerCorrect(question, answers[question.id]) === true) correct += 1;
272
474
  }
@@ -9,7 +9,7 @@ const components: Components = {
9
9
  blockquote: ({ ...props }) => <blockquote className="my-4 border-l-2 border-gray-300 pl-4 text-gray-700" {...props} />,
10
10
  code: ({ className, children, ...props }) => {
11
11
  const isBlock = className?.startsWith('language-') ?? false
12
- return <code className={cn(isBlock ? 'font-mono text-sm' : 'rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] text-gray-900', className)} {...props}>{children}</code>
12
+ return <code className={cn(isBlock ? 'font-mono text-sm' : 'rounded bg-gray-100 px-1 py-0.5 font-mono text-[0.9em] text-gray-900 break-words', className)} {...props}>{children}</code>
13
13
  },
14
14
  h1: ({ ...props }) => <h1 className="mb-3 mt-6 text-2xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
15
15
  h2: ({ ...props }) => <h2 className="mb-3 mt-6 text-xl font-bold tracking-tight text-gray-950 first:mt-0" {...props} />,
@@ -98,12 +98,23 @@ const normalizeGfmTables = (content: string) => {
98
98
  }).join('')
99
99
  }
100
100
 
101
- export function MarkdownContent({ content, className }: { content: string; className?: string }) {
102
- return (
103
- <div className={cn('min-w-0 text-base leading-7 text-gray-800', className)}>
104
- <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={components}>
105
- {normalizeGfmTables(normalizeMathDelimiters(content))}
106
- </ReactMarkdown>
107
- </div>
101
+ // Inline mode: paragraphs render as spans so short markdown (option text,
102
+ // blank labels) can sit inside buttons, select triggers and flex rows.
103
+ const inlineComponents: Components = {
104
+ ...components,
105
+ p: ({ ...props }) => <span className="whitespace-pre-wrap" {...props} />,
106
+ }
107
+
108
+ // min-w-0 + break-words keep long tokens inside the column; display math
109
+ // and wide tables/code scroll horizontally instead of widening the page.
110
+ const blockClassName = 'min-w-0 max-w-full break-words [&_.katex-display]:overflow-x-auto [&_.katex-display]:overflow-y-hidden [&_.katex-display]:py-1'
111
+
112
+ export function MarkdownContent({ content, className, inline = false }: { content: string; className?: string; inline?: boolean }) {
113
+ const markdown = (
114
+ <ReactMarkdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeKatex]} components={inline ? inlineComponents : components}>
115
+ {normalizeGfmTables(normalizeMathDelimiters(content))}
116
+ </ReactMarkdown>
108
117
  )
118
+ if (inline) return <span className={cn('min-w-0', className)}>{markdown}</span>
119
+ return <div className={cn(blockClassName, 'text-base leading-7 text-gray-800', className)}>{markdown}</div>
109
120
  }
@@ -18,7 +18,7 @@ import {
18
18
  import { Search } from 'lucide-react';
19
19
  import { useBank } from '../../core/config.tsx';
20
20
  import type { BaseQuestion, QuestionKind, QuestionStatus, QuestionStatusMap, QuizSession } from '../../core/types.ts';
21
- import { isChoiceQuestion, questionKind, questionKindLabels } from '../../core/types.ts';
21
+ import { getPairingSlots, isChoiceQuestion, questionKind, questionKindLabels } from '../../core/types.ts';
22
22
  import { Badge } from './ui/badge';
23
23
  import { Button } from './ui/button';
24
24
  import { Checkbox } from './ui/checkbox';
@@ -64,10 +64,30 @@ const questionKindStyles: Record<QuestionKind, string> = {
64
64
  written: 'border-gray-300 bg-gray-50 text-gray-700',
65
65
  single: 'border-sky-300 bg-sky-50 text-sky-900',
66
66
  multiple: 'border-violet-300 bg-violet-50 text-violet-900',
67
+ numerical: 'border-emerald-300 bg-emerald-50 text-emerald-900',
68
+ matching: 'border-orange-300 bg-orange-50 text-orange-900',
69
+ dropdown: 'border-rose-300 bg-rose-50 text-rose-900',
67
70
  };
68
71
 
69
72
  const optionLetter = (index: number): string => String.fromCharCode(65 + index);
70
73
 
74
+ /** Short structural summary for the Type badge tooltip, e.g. "5 items · 11 options". */
75
+ function kindDetail(question: BaseQuestion): string | undefined {
76
+ switch (questionKind(question)) {
77
+ case 'single':
78
+ case 'multiple':
79
+ return `${question.options?.length ?? 0} options`;
80
+ case 'matching':
81
+ return `${question.items?.length ?? 0} items · ${question.options?.length ?? 0} options`;
82
+ case 'dropdown':
83
+ return `${question.blanks?.length ?? 0} blanks`;
84
+ case 'numerical':
85
+ return question.numeric?.unit ? `Numeric answer (${question.numeric.unit})` : 'Numeric answer';
86
+ default:
87
+ return undefined;
88
+ }
89
+ }
90
+
71
91
  function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
72
92
  const [open, setOpen] = useState(false);
73
93
  const openTimeout = useRef<number | null>(null);
@@ -184,6 +204,16 @@ function QuestionPreviewPopover({ question }: { question: BaseQuestion }) {
184
204
  ))}
185
205
  </ol>
186
206
  )}
207
+ {getPairingSlots(question).length > 0 && (
208
+ <ol className="mt-3 list-decimal space-y-1 pl-6 text-sm text-gray-800">
209
+ {getPairingSlots(question).map((slot, index) => (
210
+ <li key={slot.id}>
211
+ <MarkdownContent inline content={slot.label?.trim() ? slot.label : `Blank ${index + 1}`} />
212
+ <span className="text-gray-500"> → ___</span>
213
+ </li>
214
+ ))}
215
+ </ol>
216
+ )}
187
217
  </div>
188
218
  </PopoverContent>
189
219
  </Popover>
@@ -394,9 +424,8 @@ export function QuestionBank() {
394
424
  header: 'Type',
395
425
  cell: ({ row }) => {
396
426
  const kind = questionKind(row.original);
397
- const count = row.original.options?.length ?? 0;
398
427
  return (
399
- <Badge variant="outline" className={questionKindStyles[kind]} title={count > 0 ? `${count} options` : undefined}>
428
+ <Badge variant="outline" className={questionKindStyles[kind]} title={kindDetail(row.original)}>
400
429
  {questionKindLabels[kind]}
401
430
  </Badge>
402
431
  );