@conform-ed/qti-react 0.0.12 → 0.0.14

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 (134) hide show
  1. package/dist/capability.d.ts +17 -0
  2. package/dist/content-model.d.ts +42 -0
  3. package/dist/graphic.d.ts +23 -0
  4. package/dist/index.d.ts +14 -0
  5. package/dist/index.js +4556 -212
  6. package/dist/interactions/associate.d.ts +2 -0
  7. package/dist/interactions/choice.d.ts +2 -0
  8. package/dist/interactions/drawing.d.ts +2 -0
  9. package/dist/interactions/end-attempt.d.ts +2 -0
  10. package/dist/interactions/extended-text.d.ts +2 -0
  11. package/dist/interactions/gap-match.d.ts +2 -0
  12. package/dist/interactions/graphic.d.ts +13 -0
  13. package/dist/interactions/hottext.d.ts +2 -0
  14. package/dist/interactions/index.d.ts +18 -0
  15. package/dist/interactions/inline-choice.d.ts +2 -0
  16. package/dist/interactions/match.d.ts +2 -0
  17. package/dist/interactions/media.d.ts +2 -0
  18. package/dist/interactions/order.d.ts +2 -0
  19. package/dist/interactions/slider.d.ts +2 -0
  20. package/dist/interactions/text-entry.d.ts +2 -0
  21. package/dist/interactions/upload.d.ts +2 -0
  22. package/dist/normalized-item.d.ts +30 -0
  23. package/dist/pci/index.d.ts +6 -0
  24. package/dist/pci/interaction.d.ts +8 -0
  25. package/dist/pci/markup.d.ts +10 -0
  26. package/dist/pci/mount.d.ts +50 -0
  27. package/dist/pci/registry.d.ts +53 -0
  28. package/dist/pci/response.d.ts +11 -0
  29. package/dist/pci/skin.d.ts +12 -0
  30. package/dist/reference-skin/associate.d.ts +8 -0
  31. package/dist/reference-skin/choice.d.ts +8 -0
  32. package/dist/reference-skin/content.d.ts +6 -0
  33. package/dist/reference-skin/drawing.d.ts +9 -0
  34. package/dist/reference-skin/end-attempt.d.ts +7 -0
  35. package/dist/reference-skin/extended-text.d.ts +6 -0
  36. package/dist/reference-skin/gap-match.d.ts +8 -0
  37. package/dist/reference-skin/graphic-associate.d.ts +8 -0
  38. package/dist/reference-skin/graphic-base.d.ts +39 -0
  39. package/dist/reference-skin/graphic-gap-match.d.ts +8 -0
  40. package/dist/reference-skin/graphic-order.d.ts +8 -0
  41. package/dist/reference-skin/hotspot.d.ts +8 -0
  42. package/dist/reference-skin/hottext.d.ts +8 -0
  43. package/dist/reference-skin/index.d.ts +30 -0
  44. package/dist/reference-skin/inline-choice.d.ts +7 -0
  45. package/dist/reference-skin/match.d.ts +8 -0
  46. package/dist/reference-skin/media.d.ts +9 -0
  47. package/dist/reference-skin/order.d.ts +8 -0
  48. package/dist/reference-skin/position-object.d.ts +9 -0
  49. package/dist/reference-skin/select-point.d.ts +8 -0
  50. package/dist/reference-skin/slider.d.ts +8 -0
  51. package/dist/reference-skin/text-entry.d.ts +6 -0
  52. package/dist/reference-skin/upload.d.ts +8 -0
  53. package/dist/response-processing.d.ts +48 -0
  54. package/dist/rp/evaluate.d.ts +35 -0
  55. package/dist/rp/index.d.ts +4 -0
  56. package/dist/rp/interpreter.d.ts +15 -0
  57. package/dist/rp/template-processing.d.ts +49 -0
  58. package/dist/rp/templates.d.ts +8 -0
  59. package/dist/rp/types.d.ts +158 -0
  60. package/dist/rp/values.d.ts +27 -0
  61. package/dist/runtime.d.ts +164 -0
  62. package/dist/store.d.ts +61 -0
  63. package/dist/test/controller.d.ts +11 -0
  64. package/dist/test/index.d.ts +3 -0
  65. package/dist/test/session-store.d.ts +46 -0
  66. package/dist/test/types.d.ts +194 -0
  67. package/dist/types.d.ts +58 -0
  68. package/package.json +8 -6
  69. package/src/capability.ts +24 -0
  70. package/src/content-model.ts +104 -5
  71. package/src/graphic.ts +103 -0
  72. package/src/index.ts +139 -3
  73. package/src/interactions/associate.ts +22 -0
  74. package/src/interactions/choice.ts +2 -2
  75. package/src/interactions/drawing.ts +24 -0
  76. package/src/interactions/end-attempt.ts +19 -0
  77. package/src/interactions/extended-text.ts +21 -0
  78. package/src/interactions/gap-match.ts +22 -0
  79. package/src/interactions/graphic.ts +104 -0
  80. package/src/interactions/hottext.ts +21 -0
  81. package/src/interactions/index.ts +57 -3
  82. package/src/interactions/inline-choice.ts +2 -2
  83. package/src/interactions/match.ts +27 -0
  84. package/src/interactions/media.ts +24 -0
  85. package/src/interactions/order.ts +21 -0
  86. package/src/interactions/slider.ts +24 -0
  87. package/src/interactions/text-entry.ts +2 -2
  88. package/src/interactions/upload.ts +19 -0
  89. package/src/normalized-item.ts +563 -0
  90. package/src/pci/index.ts +22 -0
  91. package/src/pci/interaction.ts +42 -0
  92. package/src/pci/markup.ts +102 -0
  93. package/src/pci/mount.ts +134 -0
  94. package/src/pci/registry.ts +240 -0
  95. package/src/pci/response.ts +138 -0
  96. package/src/pci/skin.ts +86 -0
  97. package/src/reference-skin/associate.ts +98 -0
  98. package/src/reference-skin/choice.ts +44 -0
  99. package/src/reference-skin/content.ts +30 -0
  100. package/src/reference-skin/drawing.ts +160 -0
  101. package/src/reference-skin/end-attempt.ts +27 -0
  102. package/src/reference-skin/extended-text.ts +35 -0
  103. package/src/reference-skin/gap-match.ts +69 -0
  104. package/src/reference-skin/graphic-associate.ts +123 -0
  105. package/src/reference-skin/graphic-base.ts +142 -0
  106. package/src/reference-skin/graphic-gap-match.ts +143 -0
  107. package/src/reference-skin/graphic-order.ts +76 -0
  108. package/src/reference-skin/hotspot.ts +43 -0
  109. package/src/reference-skin/hottext.ts +42 -0
  110. package/src/reference-skin/index.ts +74 -0
  111. package/src/reference-skin/inline-choice.ts +42 -0
  112. package/src/reference-skin/match.ts +80 -0
  113. package/src/reference-skin/media.ts +74 -0
  114. package/src/reference-skin/order.ts +79 -0
  115. package/src/reference-skin/position-object.ts +84 -0
  116. package/src/reference-skin/select-point.ts +87 -0
  117. package/src/reference-skin/slider.ts +41 -0
  118. package/src/reference-skin/text-entry.ts +31 -0
  119. package/src/reference-skin/upload.ts +46 -0
  120. package/src/response-processing.ts +178 -29
  121. package/src/rp/evaluate.ts +827 -0
  122. package/src/rp/index.ts +30 -0
  123. package/src/rp/interpreter.ts +254 -0
  124. package/src/rp/template-processing.ts +290 -0
  125. package/src/rp/templates.ts +190 -0
  126. package/src/rp/types.ts +167 -0
  127. package/src/rp/values.ts +211 -0
  128. package/src/runtime.ts +476 -28
  129. package/src/store.ts +161 -5
  130. package/src/test/controller.ts +809 -0
  131. package/src/test/index.ts +25 -0
  132. package/src/test/session-store.ts +243 -0
  133. package/src/test/types.ts +203 -0
  134. package/src/types.ts +27 -1
package/src/runtime.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The headless runtime (ADR-0002): a factory that assembles a QTI item renderer from an
2
+ * The headless runtime (ADR-0001): a factory that assembles a QTI item renderer from an
3
3
  * injected set of interaction descriptors plus a skin registry. The kind-union is the
4
4
  * injected set — no global registry, no module augmentation. The core owns response
5
5
  * state and a11y wiring; skins are controlled components.
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import {
12
+ Fragment,
12
13
  createContext,
13
14
  createElement,
14
15
  useContext,
@@ -19,16 +20,29 @@ import {
19
20
  } from "react";
20
21
  import type { ZodType } from "zod";
21
22
 
23
+ import type { CapabilityIssue, CapabilityReport } from "./capability";
22
24
  import {
23
25
  isAllowedFlowElement,
24
- isInteractionKind,
25
26
  sanitizeAttributes,
27
+ sanitizeMathAttributes,
26
28
  v0ContentModel,
27
29
  type ContentModel,
28
30
  } from "./content-model";
31
+ import { collectRpIssues, collectTemplateIssues } from "./rp";
32
+ import type {
33
+ CustomOperatorImplementation,
34
+ OutcomeDeclarationView,
35
+ OutcomeValue,
36
+ ResponseNormalization,
37
+ ResponseProcessingView,
38
+ TemplateDeclarationView,
39
+ TemplateProcessingView,
40
+ } from "./rp";
29
41
  import { createAttemptStore, type AttemptSnapshot, type AttemptStore } from "./store";
30
42
  import type { Cardinality, ResponseDeclarationView, ResponseValue, ScoreResult } from "./types";
31
43
 
44
+ export type { CapabilityIssue, CapabilityIssueType, CapabilityReport } from "./capability";
45
+
32
46
  // ---------- Node views (structural; validated upstream by @conform-ed/contracts) ----------
33
47
 
34
48
  export interface XmlContentNode {
@@ -47,8 +61,23 @@ export interface InteractionNode {
47
61
 
48
62
  export type BodyNode = XmlContentNode | InteractionNode | { kind: string; value?: string; children?: BodyNode[] };
49
63
 
64
+ /** A feedback element's view: feedbackInline/feedbackBlock in the body, or modalFeedback. */
65
+ export interface FeedbackView {
66
+ outcomeIdentifier: string;
67
+ identifier: string;
68
+ showHide?: "show" | "hide";
69
+ content?: readonly BodyNode[];
70
+ }
71
+
50
72
  export interface AssessmentItemView {
51
73
  responseDeclarations: readonly ResponseDeclarationView[];
74
+ outcomeDeclarations?: readonly OutcomeDeclarationView[];
75
+ responseProcessing?: ResponseProcessingView;
76
+ templateDeclarations?: readonly TemplateDeclarationView[];
77
+ templateProcessing?: TemplateProcessingView;
78
+ /** QTI adaptive item: multiple attempts until completionStatus reaches "completed". */
79
+ adaptive?: boolean;
80
+ modalFeedbacks?: readonly FeedbackView[];
52
81
  itemBody: { content?: BodyNode[] };
53
82
  }
54
83
 
@@ -83,7 +112,7 @@ export interface OptionProps {
83
112
  onClick: () => void;
84
113
  }
85
114
 
86
- /** Controlled props every interaction skin receives by default (ADR-0002). */
115
+ /** Controlled props every interaction skin receives by default (ADR-0001). */
87
116
  export interface InteractionRenderProps {
88
117
  node: InteractionNode;
89
118
  responseIdentifier: string;
@@ -96,10 +125,26 @@ export interface InteractionRenderProps {
96
125
  /** Whole-interaction status (drives feedback for option-less interactions). */
97
126
  status: InteractionStatus;
98
127
  getOptionProps: (optionIdentifier: string) => OptionProps;
99
- /** Render body fragments (prompt, choice labels) through the core allowlist walk. */
100
- renderContent: (nodes: readonly BodyNode[] | undefined) => ReactNode;
128
+ /**
129
+ * Render body fragments (prompt, choice labels) through the core allowlist walk.
130
+ * `overrides` lets a skin take over specific node kinds nested anywhere in the
131
+ * fragment (e.g. `hottext`, `gap`) while the core keeps walking everything else.
132
+ */
133
+ renderContent: (nodes: readonly BodyNode[] | undefined, overrides?: NodeOverrides) => ReactNode;
134
+ /** The runtime's Asset Resolver (identity when none configured) for skin-owned media. */
135
+ resolveAsset: (href: string) => string;
136
+ /** Set this interaction's response to true and submit the attempt (endAttemptInteraction). */
137
+ endAttempt: () => void;
138
+ /**
139
+ * Register a submit-time response collector for this interaction (imperative
140
+ * interactions like PCI own their response state). Returns the unregister function.
141
+ */
142
+ registerResponseCollector: (collector: () => ResponseValue | undefined) => () => void;
101
143
  }
102
144
 
145
+ /** Per-kind render overrides a skin passes to `renderContent` for nodes it owns. */
146
+ export type NodeOverrides = Readonly<Record<string, (node: BodyNode, key: number) => ReactNode>>;
147
+
103
148
  export type InteractionSkin = ComponentType<InteractionRenderProps>;
104
149
  export type SkinRegistry = Readonly<Record<string, InteractionSkin>>;
105
150
 
@@ -107,19 +152,59 @@ export interface QtiRuntimeConfig {
107
152
  readonly interactions: readonly InteractionDescriptor[];
108
153
  readonly skin: SkinRegistry;
109
154
  readonly contentModel?: ContentModel;
155
+ /** Replaces the default Unsupported Placeholder for interaction nodes this runtime cannot render. */
156
+ readonly renderUnsupported?: (node: InteractionNode) => ReactNode;
157
+ /** The Response Normalization hook (ADR-0004): opt-in candidate-input leniency. */
158
+ readonly normalization?: ResponseNormalization;
159
+ /**
160
+ * The Asset Resolver: maps package-relative media references (img/audio/video `src`,
161
+ * `poster`) to real URLs at render time. Identity when omitted.
162
+ */
163
+ readonly assetResolver?: (href: string) => string;
164
+ /**
165
+ * Registered vendor `customOperator` implementations by class. Items using only
166
+ * registered classes pass the capability gate; everything else stays unsupported.
167
+ */
168
+ readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
110
169
  }
111
170
 
112
171
  export interface ItemRendererProps {
113
172
  item: AssessmentItemView;
173
+ /**
174
+ * An externally owned attempt store. Without it the renderer creates a fresh
175
+ * per-mount store; passing one enables review/replay modes (rehydrate a stored,
176
+ * already-submitted attempt) and server-side rendering of submitted states.
177
+ */
178
+ store?: AttemptStore | undefined;
179
+ /** Clone seed for template processing; store it to replay the same clone. */
180
+ seed?: number | undefined;
114
181
  // Rendered inside the same runtime context as the item body, after it. Lets a consumer
115
182
  // drop controls (a Submit bar, a score panel) that call `useAttempt()` for this item —
116
183
  // the attempt store is per-item and scoped to this subtree.
117
184
  children?: ReactNode;
118
185
  }
119
186
 
187
+ export interface ContentRendererProps {
188
+ nodes?: readonly BodyNode[] | undefined;
189
+ /** Values for printedVariable (and showHide-gated feedback) inside the content. */
190
+ outcomes?: Readonly<Record<string, OutcomeValue>> | undefined;
191
+ }
192
+
120
193
  export interface QtiRuntime {
121
194
  ItemRenderer: ComponentType<ItemRendererProps>;
195
+ /**
196
+ * Flow content outside an item attempt — test feedback, rubric copy. Same sanitizer
197
+ * and node walk as the item body, over caller-supplied outcome values.
198
+ */
199
+ ContentRenderer: ComponentType<ContentRendererProps>;
122
200
  useAttempt: () => AttemptController;
201
+ /**
202
+ * The Capability Report for an item against this runtime's injected descriptors,
203
+ * skins, and content model. Consumers gate delivery on it (ADR-0003); the
204
+ * ItemRenderer placeholder is only the backstop for content that reaches
205
+ * rendering anyway.
206
+ */
207
+ canDeliver: (item: AssessmentItemView) => CapabilityReport;
123
208
  }
124
209
 
125
210
  export interface AttemptController extends AttemptSnapshot {
@@ -151,37 +236,193 @@ function responseIncludes(value: ResponseValue, optionIdentifier: string): boole
151
236
  return false;
152
237
  }
153
238
 
154
- return typeof value === "string" ? value === optionIdentifier : value.includes(optionIdentifier);
239
+ return typeof value === "string"
240
+ ? value === optionIdentifier
241
+ : Array.isArray(value) && value.includes(optionIdentifier);
155
242
  }
156
243
 
157
244
  function isCorrectOption(declaration: ResponseDeclarationView | undefined, optionIdentifier: string): boolean {
158
245
  return Boolean(declaration?.correctResponse?.values.some((entry) => entry.value === optionIdentifier));
159
246
  }
160
247
 
248
+ /**
249
+ * An interaction node is any non-xml node carrying a `responseIdentifier` — including
250
+ * kinds this runtime has never heard of (lagging consumers, foreign extensions). The
251
+ * discriminator must not depend on the injected descriptor set, or unknown interactions
252
+ * would be indistinguishable from text and silently dropped.
253
+ */
254
+ function isInteractionNode(node: BodyNode): node is InteractionNode {
255
+ return node.kind !== "xml" && typeof (node as { responseIdentifier?: unknown }).responseIdentifier === "string";
256
+ }
257
+
258
+ const feedbackKinds = new Set(["feedbackInline", "feedbackBlock"]);
259
+
260
+ function isFeedbackNode(node: BodyNode): boolean {
261
+ return feedbackKinds.has(node.kind);
262
+ }
263
+
264
+ const templateContentKinds = new Set(["templateInline", "templateBlock"]);
265
+
266
+ /** templateInline/templateBlock: visibility decided by a template variable's value. */
267
+ interface TemplateContentView {
268
+ readonly templateIdentifier: string;
269
+ readonly identifier: string;
270
+ readonly showHide?: "show" | "hide";
271
+ readonly content?: readonly BodyNode[];
272
+ }
273
+
274
+ function isTemplateContentNode(node: BodyNode): boolean {
275
+ return templateContentKinds.has(node.kind);
276
+ }
277
+
278
+ /** Same show/hide semantics as feedback, but against the clone's template values. */
279
+ function templateVisible(value: OutcomeValue, view: TemplateContentView): boolean {
280
+ const matched = Array.isArray(value) ? value.includes(view.identifier) : value === view.identifier;
281
+
282
+ return matched !== (view.showHide === "hide");
283
+ }
284
+
285
+ /** Body node kinds that render without a descriptor, skin, or content-model entry. */
286
+ const intrinsicLeafKinds = new Set(["text", "printedVariable"]);
287
+
288
+ /** A read-only, already-"submitted" store: backs content rendered outside an attempt. */
289
+ function createStaticStore(outcomes: Readonly<Record<string, OutcomeValue>>): AttemptStore {
290
+ const snapshot: AttemptSnapshot = {
291
+ responses: {},
292
+ submitted: true,
293
+ scores: [],
294
+ outcomes,
295
+ templateValues: {},
296
+ attemptCount: 1,
297
+ };
298
+
299
+ return {
300
+ getSnapshot: () => snapshot,
301
+ subscribe: () => () => {},
302
+ setResponse: () => {},
303
+ registerResponseCollector: () => () => {},
304
+ submit: () => [],
305
+ reset: () => {},
306
+ };
307
+ }
308
+
309
+ /** QTI showHide semantics: `show` reveals on a matched outcome, `hide` reveals on a miss. */
310
+ function feedbackVisible(outcome: OutcomeValue, feedback: FeedbackView, submitted: boolean): boolean {
311
+ if (!submitted) {
312
+ return false;
313
+ }
314
+
315
+ const matched = Array.isArray(outcome) ? outcome.includes(feedback.identifier) : outcome === feedback.identifier;
316
+
317
+ return matched !== (feedback.showHide === "hide");
318
+ }
319
+
161
320
  export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
162
321
  const model = config.contentModel ?? v0ContentModel;
163
322
  const descriptorsByKind = new Map(config.interactions.map((descriptor) => [descriptor.kind, descriptor]));
323
+ const resolveAsset = config.assetResolver ?? ((href: string) => href);
164
324
 
165
- function renderFlow(node: XmlContentNode, key: number): ReactNode {
166
- const isMath = node.name === model.mathRoot;
325
+ function renderFlow(node: XmlContentNode, key: number, overrides?: NodeOverrides, inMath = false): ReactNode {
326
+ const isMath = inMath || node.name === model.mathRoot;
167
327
 
168
328
  if (!isMath && !isAllowedFlowElement(model, node.name)) {
169
329
  return null; // not allowlisted → dropped (the sanitizer)
170
330
  }
171
331
 
172
- const attributes = sanitizeAttributes(model, node.attributes);
173
- const children = node.children?.map((child, index) => renderNode(child, index));
332
+ // Inside math the subtree renders structurally: element names pass, attribute
333
+ // hardening still applies (see the content model's mathRoot note).
334
+ const attributes = isMath
335
+ ? sanitizeMathAttributes(node.attributes)
336
+ : sanitizeAttributes(model, node.name, node.attributes);
337
+
338
+ for (const name of model.urlAttributes) {
339
+ const value = attributes[name];
340
+
341
+ if (value !== undefined) {
342
+ attributes[name] = resolveAsset(value);
343
+ }
344
+ }
345
+
346
+ const children = node.children?.map((child, index) => renderNode(child, index, overrides, isMath));
174
347
 
175
348
  return createElement(node.name, { key, ...attributes }, node.value ?? children);
176
349
  }
177
350
 
178
- function renderNode(node: BodyNode, key: number): ReactNode {
179
- if (isInteractionKind(model, node.kind) && descriptorsByKind.has(node.kind) && config.skin[node.kind]) {
180
- return createElement(InteractionHost, { key, node: node as InteractionNode });
351
+ function renderUnsupported(node: InteractionNode, key: number): ReactNode {
352
+ if (config.renderUnsupported) {
353
+ return createElement("span", { key }, config.renderUnsupported(node));
354
+ }
355
+
356
+ // The Unsupported Placeholder (ADR-0003): explicit and accessible, never a silent drop.
357
+ return createElement(
358
+ "div",
359
+ { key, role: "note", "data-qti-unsupported": node.kind },
360
+ `This content requires an interaction type (${node.kind}) this runtime does not support.`,
361
+ );
362
+ }
363
+
364
+ function renderNode(node: BodyNode, key: number, overrides?: NodeOverrides, inMath = false): ReactNode {
365
+ const override = overrides?.[node.kind];
366
+
367
+ if (override) {
368
+ return createElement(Fragment, { key }, override(node, key));
369
+ }
370
+
371
+ if (isInteractionNode(node)) {
372
+ // Dispatch is governed by the injected descriptor + skin sets alone (ADR-0001:
373
+ // the kind-union is the injected set), so consumer extension kinds render
374
+ // without being named in the content model.
375
+ if (descriptorsByKind.has(node.kind) && config.skin[node.kind]) {
376
+ return createElement(InteractionHost, { key, node });
377
+ }
378
+
379
+ return renderUnsupported(node, key);
380
+ }
381
+
382
+ if (isFeedbackNode(node)) {
383
+ return createElement(FeedbackHost, {
384
+ key,
385
+ feedback: node as unknown as FeedbackView,
386
+ element: node.kind === "feedbackInline" ? "span" : "div",
387
+ overrides,
388
+ });
389
+ }
390
+
391
+ if (isTemplateContentNode(node)) {
392
+ return createElement(TemplateContentHost, {
393
+ key,
394
+ view: node as unknown as TemplateContentView,
395
+ element: node.kind === "templateInline" ? "span" : "div",
396
+ overrides,
397
+ });
398
+ }
399
+
400
+ if (node.kind === "rubricBlock") {
401
+ const rubric = node as unknown as { view?: readonly string[]; content?: readonly BodyNode[] };
402
+
403
+ // Rubric blocks are addressed by view; a delivery engine shows candidates theirs.
404
+ if (!rubric.view?.includes("candidate")) {
405
+ return null;
406
+ }
407
+
408
+ return createElement(
409
+ "div",
410
+ { key, "data-qti-rubric-block": true },
411
+ rubric.content?.map((child, index) => renderNode(child, index, overrides)),
412
+ );
413
+ }
414
+
415
+ if (node.kind === "printedVariable") {
416
+ const identifier = (node as { identifier?: unknown }).identifier;
417
+
418
+ return createElement(PrintedVariableHost, {
419
+ key,
420
+ identifier: typeof identifier === "string" ? identifier : "",
421
+ });
181
422
  }
182
423
 
183
424
  if (node.kind === "xml") {
184
- return renderFlow(node as XmlContentNode, key);
425
+ return renderFlow(node as XmlContentNode, key, overrides, inMath);
185
426
  }
186
427
 
187
428
  const value = (node as { value?: string }).value;
@@ -189,6 +430,79 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
189
430
  return typeof value === "string" ? value : null;
190
431
  }
191
432
 
433
+ function FeedbackHost({
434
+ feedback,
435
+ element,
436
+ overrides,
437
+ }: {
438
+ feedback: FeedbackView;
439
+ element: "span" | "div";
440
+ overrides?: NodeOverrides | undefined;
441
+ }): ReactNode {
442
+ const { store } = useRuntimeContext();
443
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
444
+ const outcome = snapshot.outcomes[feedback.outcomeIdentifier] ?? null;
445
+
446
+ if (!feedbackVisible(outcome, feedback, snapshot.submitted)) {
447
+ return null;
448
+ }
449
+
450
+ return createElement(
451
+ element,
452
+ { "data-qti-feedback": feedback.identifier },
453
+ feedback.content?.map((child, index) => renderNode(child, index, overrides)),
454
+ );
455
+ }
456
+
457
+ function TemplateContentHost({
458
+ view,
459
+ element,
460
+ overrides,
461
+ }: {
462
+ view: TemplateContentView;
463
+ element: "span" | "div";
464
+ overrides?: NodeOverrides | undefined;
465
+ }): ReactNode {
466
+ const { store } = useRuntimeContext();
467
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
468
+ const value = snapshot.templateValues[view.templateIdentifier] ?? null;
469
+
470
+ if (!templateVisible(value, view)) {
471
+ return null;
472
+ }
473
+
474
+ return createElement(
475
+ element,
476
+ { "data-qti-template": view.identifier },
477
+ view.content?.map((child, index) => renderNode(child, index, overrides)),
478
+ );
479
+ }
480
+
481
+ function PrintedVariableHost({ identifier }: { identifier: string }): ReactNode {
482
+ const { store } = useRuntimeContext();
483
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
484
+ const value = snapshot.templateValues[identifier] ?? snapshot.outcomes[identifier] ?? null;
485
+ const text = value === null ? "" : Array.isArray(value) ? value.join(" ") : String(value);
486
+
487
+ return createElement("span", { "data-qti-printed-variable": identifier }, text);
488
+ }
489
+
490
+ function ModalFeedbackHost({ feedback }: { feedback: FeedbackView }): ReactNode {
491
+ const { store } = useRuntimeContext();
492
+ const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
493
+ const outcome = snapshot.outcomes[feedback.outcomeIdentifier] ?? null;
494
+
495
+ if (!feedbackVisible(outcome, feedback, snapshot.submitted)) {
496
+ return null;
497
+ }
498
+
499
+ return createElement(
500
+ "div",
501
+ { role: "status", "data-qti-modal-feedback": feedback.identifier },
502
+ feedback.content?.map((child, index) => renderNode(child, index)),
503
+ );
504
+ }
505
+
192
506
  function InteractionHost({ node }: { node: InteractionNode }): ReactNode {
193
507
  const { store, declarationsById } = useRuntimeContext();
194
508
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
@@ -246,7 +560,8 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
246
560
  return;
247
561
  }
248
562
 
249
- const current = value === null ? [] : typeof value === "string" ? [value] : [...value];
563
+ const current =
564
+ value === null ? [] : typeof value === "string" ? [value] : Array.isArray(value) ? [...value] : [];
250
565
  const next = selected
251
566
  ? current.filter((entry) => entry !== optionIdentifier)
252
567
  : [...current, optionIdentifier];
@@ -256,8 +571,8 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
256
571
  };
257
572
  };
258
573
 
259
- const renderContent = (nodes: readonly BodyNode[] | undefined): ReactNode =>
260
- nodes ? nodes.map((child, index) => renderNode(child, index)) : null;
574
+ const renderContent = (nodes: readonly BodyNode[] | undefined, overrides?: NodeOverrides): ReactNode =>
575
+ nodes ? nodes.map((child, index) => renderNode(child, index, overrides)) : null;
261
576
 
262
577
  const Skin = config.skin[node.kind];
263
578
 
@@ -275,20 +590,46 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
275
590
  status,
276
591
  getOptionProps,
277
592
  renderContent,
593
+ resolveAsset,
594
+ endAttempt: () => {
595
+ store.setResponse(responseIdentifier, "true");
596
+ store.submit();
597
+ },
598
+ registerResponseCollector: (collector) => store.registerResponseCollector(responseIdentifier, collector),
278
599
  });
279
600
  }
280
601
 
281
- function ItemRenderer({ item, children }: ItemRendererProps): ReactNode {
282
- const store = useMemo(() => {
283
- const initial: Record<string, ResponseValue> = {};
602
+ function ContentRenderer({ nodes, outcomes }: ContentRendererProps): ReactNode {
603
+ const store = useMemo(() => createStaticStore(outcomes ?? {}), [outcomes]);
604
+ const declarationsById = useMemo(() => new Map<string, ResponseDeclarationView>(), []);
284
605
 
285
- for (const node of item.itemBody.content ?? []) {
286
- // initial responses are seeded lazily by skins; declarations drive scoring.
287
- void node;
288
- }
606
+ return createElement(
607
+ RuntimeContext.Provider,
608
+ { value: { store, declarationsById } },
609
+ nodes?.map((node, index) => renderNode(node, index)),
610
+ );
611
+ }
289
612
 
290
- return createAttemptStore(item.responseDeclarations, initial);
291
- }, [item]);
613
+ function ItemRenderer({ item, store: externalStore, seed, children }: ItemRendererProps): ReactNode {
614
+ const store = useMemo(
615
+ () =>
616
+ externalStore ??
617
+ createAttemptStore(
618
+ item.responseDeclarations,
619
+ {},
620
+ {
621
+ outcomeDeclarations: item.outcomeDeclarations,
622
+ responseProcessing: item.responseProcessing,
623
+ templateDeclarations: item.templateDeclarations,
624
+ templateProcessing: item.templateProcessing,
625
+ adaptive: item.adaptive,
626
+ seed,
627
+ normalization: config.normalization,
628
+ customOperators: config.customOperators,
629
+ },
630
+ ),
631
+ [item, externalStore, seed],
632
+ );
292
633
 
293
634
  const declarationsById = useMemo(
294
635
  () => new Map(item.responseDeclarations.map((declaration) => [declaration.identifier, declaration])),
@@ -296,8 +637,11 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
296
637
  );
297
638
 
298
639
  const body = (item.itemBody.content ?? []).map((node, index) => renderNode(node, index));
640
+ const modals = (item.modalFeedbacks ?? []).map((feedback, index) =>
641
+ createElement(ModalFeedbackHost, { key: index, feedback }),
642
+ );
299
643
 
300
- return createElement(RuntimeContext.Provider, { value: { store, declarationsById } }, body, children);
644
+ return createElement(RuntimeContext.Provider, { value: { store, declarationsById } }, body, modals, children);
301
645
  }
302
646
 
303
647
  function useAttempt(): AttemptController {
@@ -311,5 +655,109 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
311
655
  };
312
656
  }
313
657
 
314
- return { ItemRenderer, useAttempt };
658
+ function canDeliver(item: AssessmentItemView): CapabilityReport {
659
+ const issues: CapabilityIssue[] = [];
660
+ const seen = new Set<string>();
661
+
662
+ function report(issue: CapabilityIssue): void {
663
+ const dedupeKey = `${issue.type}:${issue.name}:${issue.responseIdentifier ?? ""}`;
664
+
665
+ if (!seen.has(dedupeKey)) {
666
+ seen.add(dedupeKey);
667
+ issues.push(issue);
668
+ }
669
+ }
670
+
671
+ function walk(node: BodyNode): void {
672
+ if (isFeedbackNode(node) || isTemplateContentNode(node) || node.kind === "rubricBlock") {
673
+ for (const child of (node as unknown as { content?: readonly BodyNode[] }).content ?? []) {
674
+ walk(child);
675
+ }
676
+
677
+ return;
678
+ }
679
+
680
+ if (isInteractionNode(node)) {
681
+ const descriptor = descriptorsByKind.get(node.kind);
682
+
683
+ if (!descriptor || !config.skin[node.kind]) {
684
+ report({
685
+ type: "unsupported-interaction",
686
+ name: node.kind,
687
+ responseIdentifier: node.responseIdentifier,
688
+ });
689
+
690
+ return;
691
+ }
692
+
693
+ const parsed = descriptor.schema.safeParse(node);
694
+
695
+ if (!parsed.success) {
696
+ const detail = parsed.error.issues[0]?.message;
697
+
698
+ report({
699
+ type: "invalid-interaction",
700
+ name: node.kind,
701
+ responseIdentifier: node.responseIdentifier,
702
+ ...(detail !== undefined ? { detail } : {}),
703
+ });
704
+ }
705
+
706
+ // Interaction-internal content (prompt, choice bodies) is structurally
707
+ // validated by the descriptor schema; its flow elements are walked when the
708
+ // descriptor surfaces them. Generic field-sniffing is deliberately avoided.
709
+ return;
710
+ }
711
+
712
+ if (node.kind === "xml") {
713
+ const xmlNode = node as XmlContentNode;
714
+
715
+ if (xmlNode.name === model.mathRoot) {
716
+ return; // MathML renders structurally; its subtree is not flow content
717
+ }
718
+
719
+ if (!isAllowedFlowElement(model, xmlNode.name)) {
720
+ report({ type: "unsupported-element", name: xmlNode.name });
721
+ }
722
+
723
+ for (const child of xmlNode.children ?? []) {
724
+ walk(child);
725
+ }
726
+
727
+ return;
728
+ }
729
+
730
+ if (intrinsicLeafKinds.has(node.kind)) {
731
+ return;
732
+ }
733
+
734
+ // Any other kind (include, multi-stage groups, future vocabulary) has no
735
+ // rendering path: report it rather than let the renderer drop it (ADR-0003).
736
+ report({ type: "unsupported-element", name: node.kind });
737
+ }
738
+
739
+ for (const node of item.itemBody.content ?? []) {
740
+ walk(node);
741
+ }
742
+
743
+ for (const feedback of item.modalFeedbacks ?? []) {
744
+ for (const child of feedback.content ?? []) {
745
+ walk(child);
746
+ }
747
+ }
748
+
749
+ const customOperatorClasses = new Set(Object.keys(config.customOperators ?? {}));
750
+
751
+ for (const issue of collectRpIssues(item.responseProcessing, { customOperatorClasses })) {
752
+ report(issue);
753
+ }
754
+
755
+ for (const issue of collectTemplateIssues(item.templateProcessing, { customOperatorClasses })) {
756
+ report(issue);
757
+ }
758
+
759
+ return { deliverable: issues.length === 0, issues };
760
+ }
761
+
762
+ return { ItemRenderer, ContentRenderer, useAttempt, canDeliver };
315
763
  }