@conform-ed/qti-react 0.0.14 → 0.0.16

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 (42) hide show
  1. package/dist/index.d.ts +6 -4
  2. package/dist/index.js +2492 -408
  3. package/dist/normalized-item.d.ts +7 -5
  4. package/dist/pnp.d.ts +115 -0
  5. package/dist/response-validity.d.ts +28 -0
  6. package/dist/rp/evaluate.d.ts +6 -1
  7. package/dist/rp/index.d.ts +2 -2
  8. package/dist/rp/interpreter.d.ts +7 -1
  9. package/dist/rp/lookup-table.d.ts +17 -0
  10. package/dist/rp/template-processing.d.ts +5 -0
  11. package/dist/rp/types.d.ts +71 -7
  12. package/dist/runtime.d.ts +95 -0
  13. package/dist/store.d.ts +47 -0
  14. package/dist/test/controller.d.ts +22 -0
  15. package/dist/test/index.d.ts +2 -1
  16. package/dist/test/results.d.ts +102 -0
  17. package/dist/test/session-store.d.ts +32 -0
  18. package/dist/test/types.d.ts +173 -5
  19. package/dist/types.d.ts +5 -0
  20. package/package.json +5 -1
  21. package/src/content-model.ts +44 -4
  22. package/src/index.ts +43 -1
  23. package/src/normalized-item.ts +106 -4
  24. package/src/pci/mount.ts +11 -3
  25. package/src/pnp.ts +333 -0
  26. package/src/reference-skin/choice.ts +3 -0
  27. package/src/response-validity.ts +163 -0
  28. package/src/rp/evaluate.ts +280 -32
  29. package/src/rp/index.ts +5 -0
  30. package/src/rp/interpreter.ts +81 -1
  31. package/src/rp/lookup-table.ts +46 -0
  32. package/src/rp/template-processing.ts +41 -0
  33. package/src/rp/types.ts +75 -7
  34. package/src/runtime.ts +397 -20
  35. package/src/store.ts +146 -8
  36. package/src/test/controller.ts +856 -82
  37. package/src/test/index.ts +23 -0
  38. package/src/test/results.ts +378 -0
  39. package/src/test/session-store.ts +109 -1
  40. package/src/test/types.ts +172 -5
  41. package/src/types.ts +1 -0
  42. package/src/xspattern.d.ts +11 -0
package/src/runtime.ts CHANGED
@@ -28,6 +28,8 @@ import {
28
28
  v0ContentModel,
29
29
  type ContentModel,
30
30
  } from "./content-model";
31
+ import { resolveCatalogSupports, type CatalogView, type PnpView, type ResolvedCatalogSupport } from "./pnp";
32
+ import { collectInteractionConstraints } from "./response-validity";
31
33
  import { collectRpIssues, collectTemplateIssues } from "./rp";
32
34
  import type {
33
35
  CustomOperatorImplementation,
@@ -47,6 +49,8 @@ export type { CapabilityIssue, CapabilityIssueType, CapabilityReport } from "./c
47
49
 
48
50
  export interface XmlContentNode {
49
51
  kind: "xml";
52
+ /** XML namespace URI; foreign vocabularies (SSML) are recognized by it. */
53
+ namespace?: string;
50
54
  name: string;
51
55
  value?: string;
52
56
  attributes?: Record<string, unknown>;
@@ -69,6 +73,34 @@ export interface FeedbackView {
69
73
  content?: readonly BodyNode[];
70
74
  }
71
75
 
76
+ /** An item's reference to a shared AssessmentStimulus document (§7.6). */
77
+ export interface AssessmentStimulusRefView {
78
+ readonly identifier: string;
79
+ readonly href: string;
80
+ readonly title?: string;
81
+ }
82
+
83
+ /** Companion materials, structurally as normalized (calculators, rules, protractors, materials). */
84
+ export interface CompanionMaterialsView {
85
+ readonly calculators?: readonly Record<string, unknown>[];
86
+ readonly rules?: readonly Record<string, unknown>[];
87
+ readonly protractors?: readonly Record<string, unknown>[];
88
+ readonly digitalMaterials?: readonly {
89
+ readonly fileHref: string;
90
+ readonly label?: string;
91
+ readonly mimeType?: string;
92
+ readonly resourceIcon?: string;
93
+ }[];
94
+ readonly physicalMaterials?: readonly string[];
95
+ }
96
+
97
+ /** The resolved stimulus body, rendered through the same content walk as the item body. */
98
+ export interface StimulusContentView {
99
+ readonly content: readonly BodyNode[];
100
+ /** The stimulus document's catalogs (dormant alternative content, §5.29). */
101
+ readonly catalogs?: readonly CatalogView[];
102
+ }
103
+
72
104
  export interface AssessmentItemView {
73
105
  responseDeclarations: readonly ResponseDeclarationView[];
74
106
  outcomeDeclarations?: readonly OutcomeDeclarationView[];
@@ -78,6 +110,15 @@ export interface AssessmentItemView {
78
110
  /** QTI adaptive item: multiple attempts until completionStatus reaches "completed". */
79
111
  adaptive?: boolean;
80
112
  modalFeedbacks?: readonly FeedbackView[];
113
+ assessmentStimulusRefs?: readonly AssessmentStimulusRefView[];
114
+ /** Every catalog in the item (item-level and nested), pooled for idref resolution. */
115
+ catalogs?: readonly CatalogView[];
116
+ /**
117
+ * Companion materials (§2.13.1): "content props that provide key information to be
118
+ * used when answering an Item, e.g. a calculator, protractor, lookup chart". The
119
+ * runtime exposes them; the delivery platform owns the tools themselves.
120
+ */
121
+ companionMaterials?: CompanionMaterialsView;
81
122
  itemBody: { content?: BodyNode[] };
82
123
  }
83
124
 
@@ -140,6 +181,12 @@ export interface InteractionRenderProps {
140
181
  * interactions like PCI own their response state). Returns the unregister function.
141
182
  */
142
183
  registerResponseCollector: (collector: () => ResponseValue | undefined) => () => void;
184
+ /**
185
+ * Render the active catalog supports for a skin-owned node's data-catalog-idref
186
+ * (e.g. a choice label) — the same resolution and presentation the core walk
187
+ * applies to generic flow nodes. Null when nothing is active.
188
+ */
189
+ renderCatalogSupports: (catalogIdref: string | undefined) => ReactNode;
143
190
  }
144
191
 
145
192
  /** Per-kind render overrides a skin passes to `renderContent` for nodes it owns. */
@@ -166,6 +213,19 @@ export interface QtiRuntimeConfig {
166
213
  * registered classes pass the capability gate; everything else stays unsupported.
167
214
  */
168
215
  readonly customOperators?: Readonly<Record<string, CustomOperatorImplementation>>;
216
+ /**
217
+ * Resolve an item's shared-stimulus reference to its normalized body content
218
+ * (synchronous by design, like the session store's resolveItem: load the package's
219
+ * stimuli before mounting; `stimulusContentFromNormalized` reshapes a normalized
220
+ * document). Unresolved refs are capability issues, never silent drops (ADR-0003).
221
+ */
222
+ readonly resolveStimulus?: (ref: AssessmentStimulusRefView) => StimulusContentView | null;
223
+ /**
224
+ * Replaces the default rendering of an active catalog support (the note-role span
225
+ * appended beside the referenced content). The delivery engine owns presentation —
226
+ * tooltips, players, glossary panels — the runtime owns resolution.
227
+ */
228
+ readonly renderCatalogSupport?: (support: ResolvedCatalogSupport, catalogIdref: string) => ReactNode;
169
229
  }
170
230
 
171
231
  export interface ItemRendererProps {
@@ -178,6 +238,32 @@ export interface ItemRendererProps {
178
238
  store?: AttemptStore | undefined;
179
239
  /** Clone seed for template processing; store it to replay the same clone. */
180
240
  seed?: number | undefined;
241
+ /**
242
+ * The item-session state to render. `review` is read-only: "the candidate can
243
+ * review the qti-item-body along with the responses they gave, but cannot update
244
+ * or resubmit them". `solution` additionally swaps in the clone's resolved correct
245
+ * responses ("a way of entering the solution state"); the show-solution gate is
246
+ * the consumer's (effective ItemSessionControl). Default: "interact".
247
+ */
248
+ mode?: ItemRenderMode | undefined;
249
+ /**
250
+ * Effective ItemSessionControl show-feedback; consulted only outside `interact`.
251
+ * `false` withholds modal and integrated feedback — visibility is then
252
+ * "determined by the default values of the outcome variables" — and is ignored
253
+ * for adaptive items, per spec.
254
+ */
255
+ showFeedback?: boolean | undefined;
256
+ /**
257
+ * The candidate's AfA PNP. Activates the item's dormant catalog supports (§5.29):
258
+ * "A candidate's profile (or assessment program settings) will indicate whether the
259
+ * candidate should be presented any of the possible supports."
260
+ */
261
+ pnp?: PnpView | undefined;
262
+ /**
263
+ * Supports in effect beyond the PNP's initial activation — program settings and
264
+ * candidate-toggled options (activate-as-option-set). Prohibited supports stay out.
265
+ */
266
+ activeSupports?: readonly string[] | undefined;
181
267
  // Rendered inside the same runtime context as the item body, after it. Lets a consumer
182
268
  // drop controls (a Submit bar, a score panel) that call `useAttempt()` for this item —
183
269
  // the attempt store is per-item and scoped to this subtree.
@@ -188,6 +274,10 @@ export interface ContentRendererProps {
188
274
  nodes?: readonly BodyNode[] | undefined;
189
275
  /** Values for printedVariable (and showHide-gated feedback) inside the content. */
190
276
  outcomes?: Readonly<Record<string, OutcomeValue>> | undefined;
277
+ /** Catalogs referenced by this content (e.g. a test rubric block's catalogInfo). */
278
+ catalogs?: readonly CatalogView[] | undefined;
279
+ pnp?: PnpView | undefined;
280
+ activeSupports?: readonly string[] | undefined;
191
281
  }
192
282
 
193
283
  export interface QtiRuntime {
@@ -198,6 +288,13 @@ export interface QtiRuntime {
198
288
  */
199
289
  ContentRenderer: ComponentType<ContentRendererProps>;
200
290
  useAttempt: () => AttemptController;
291
+ /**
292
+ * The active supports resolved for a catalog idref — for skins whose own nodes
293
+ * carry data-catalog-idref (e.g. a choice label) and consumers building support
294
+ * UI (glossary panels, toggles). The core walk already decorates generic flow
295
+ * and block nodes; this is the same resolution by hand.
296
+ */
297
+ useCatalogSupports: (catalogIdref: string | undefined) => readonly ResolvedCatalogSupport[];
201
298
  /**
202
299
  * The Capability Report for an item against this runtime's injected descriptors,
203
300
  * skins, and content model. Consumers gate delivery on it (ADR-0003); the
@@ -214,9 +311,21 @@ export interface AttemptController extends AttemptSnapshot {
214
311
 
215
312
  // ---------- Internal context ----------
216
313
 
314
+ /** The item-session states the renderer knows (ItemSessionControl review/solution). */
315
+ export type ItemRenderMode = "interact" | "review" | "solution";
316
+
217
317
  interface RuntimeContextValue {
218
318
  store: AttemptStore;
219
319
  declarationsById: ReadonlyMap<string, ResponseDeclarationView>;
320
+ mode: ItemRenderMode;
321
+ /** show-feedback=false outside interact: modal + integrated feedback withheld. */
322
+ suppressFeedback: boolean;
323
+ /** The clone's resolved correct responses — what the solution state displays. */
324
+ solutionResponses: Readonly<Record<string, ResponseValue>>;
325
+ /** Declared outcome defaults — feedback visibility "as at the start of each attempt". */
326
+ defaultOutcomes: Readonly<Record<string, OutcomeValue>>;
327
+ /** Resolved active catalog supports by catalog id (§5.28 idref resolution). */
328
+ catalogSupports: ReadonlyMap<string, readonly ResolvedCatalogSupport[]>;
220
329
  }
221
330
 
222
331
  const RuntimeContext = createContext<RuntimeContextValue | null>(null);
@@ -294,6 +403,9 @@ function createStaticStore(outcomes: Readonly<Record<string, OutcomeValue>>): At
294
403
  outcomes,
295
404
  templateValues: {},
296
405
  attemptCount: 1,
406
+ durationSeconds: null,
407
+ responseViolations: [],
408
+ correctResponses: {},
297
409
  };
298
410
 
299
411
  return {
@@ -303,6 +415,8 @@ function createStaticStore(outcomes: Readonly<Record<string, OutcomeValue>>): At
303
415
  registerResponseCollector: () => () => {},
304
416
  submit: () => [],
305
417
  reset: () => {},
418
+ suspend: () => {},
419
+ resume: () => {},
306
420
  };
307
421
  }
308
422
 
@@ -322,7 +436,21 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
322
436
  const descriptorsByKind = new Map(config.interactions.map((descriptor) => [descriptor.kind, descriptor]));
323
437
  const resolveAsset = config.assetResolver ?? ((href: string) => href);
324
438
 
439
+ /** SSML 1.1 (§2.13.2): aural annotations whose text renders transparently. */
440
+ const ssmlNamespace = "http://www.w3.org/2001/10/synthesis";
441
+
325
442
  function renderFlow(node: XmlContentNode, key: number, overrides?: NodeOverrides, inMath = false): ReactNode {
443
+ // SSML wraps visual text for speech synthesis (alias substitutions, prosody);
444
+ // rendering the element as HTML would misread it (ssml:sub is not a subscript).
445
+ // The annotated text passes through; the aural semantics belong to TTS hosts.
446
+ if (node.namespace === ssmlNamespace) {
447
+ return createElement(
448
+ Fragment,
449
+ { key },
450
+ node.value ?? node.children?.map((child, index) => renderNode(child, index, overrides, inMath)),
451
+ );
452
+ }
453
+
326
454
  const isMath = inMath || node.name === model.mathRoot;
327
455
 
328
456
  if (!isMath && !isAllowedFlowElement(model, node.name)) {
@@ -361,7 +489,96 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
361
489
  );
362
490
  }
363
491
 
364
- function renderNode(node: BodyNode, key: number, overrides?: NodeOverrides, inMath = false): ReactNode {
492
+ /** The catalog reference a node carries: data-catalog-idref on generic xml nodes, the structured field on QTI nodes. */
493
+ function catalogIdrefOf(node: BodyNode): string | undefined {
494
+ const value =
495
+ node.kind === "xml"
496
+ ? (node as XmlContentNode).attributes?.["data-catalog-idref"]
497
+ : (node as { dataCatalogIdref?: unknown }).dataCatalogIdref;
498
+
499
+ return typeof value === "string" && value !== "" ? value : undefined;
500
+ }
501
+
502
+ /** The default support presentation: a note beside the referenced content. */
503
+ function renderSupportDefault(
504
+ support: ResolvedCatalogSupport,
505
+ catalogIdref: string,
506
+ key: number,
507
+ overrides?: NodeOverrides,
508
+ ): ReactNode {
509
+ return createElement(
510
+ "span",
511
+ {
512
+ key: `support-${key}`,
513
+ role: "note",
514
+ "data-qti-catalog-idref": catalogIdref,
515
+ "data-qti-support": support.support,
516
+ ...(support.xmlLang !== undefined ? { lang: support.xmlLang } : {}),
517
+ },
518
+ support.content?.map((child, index) => renderNode(child, index, overrides)),
519
+ // File-backed alternatives default to an accessible link through the Asset
520
+ // Resolver; players and panels are the delivery engine's (renderCatalogSupport).
521
+ support.fileHrefs?.map((file, index) =>
522
+ createElement(
523
+ "a",
524
+ { key: `file-${index}`, href: resolveAsset(file.href), type: file.mimeType, "data-qti-support-file": true },
525
+ support.support,
526
+ ),
527
+ ),
528
+ );
529
+ }
530
+
531
+ /**
532
+ * Renders a catalog-referencing node: the authored content as-is, then each active
533
+ * support's resolved alternative content. Dormant content stays dormant — no
534
+ * resolved supports means the original alone.
535
+ */
536
+ function CatalogSupportHost({
537
+ catalogIdref,
538
+ node,
539
+ overrides,
540
+ inMath,
541
+ }: {
542
+ catalogIdref: string;
543
+ node: BodyNode;
544
+ overrides?: NodeOverrides | undefined;
545
+ inMath: boolean;
546
+ }): ReactNode {
547
+ const { catalogSupports } = useRuntimeContext();
548
+ const supports = catalogSupports.get(catalogIdref) ?? [];
549
+ const original = renderNode(node, 0, overrides, inMath, true);
550
+
551
+ if (!supports.length) {
552
+ return original;
553
+ }
554
+
555
+ return createElement(
556
+ Fragment,
557
+ null,
558
+ original,
559
+ supports.map((support, index) =>
560
+ config.renderCatalogSupport
561
+ ? createElement(Fragment, { key: `support-${index}` }, config.renderCatalogSupport(support, catalogIdref))
562
+ : renderSupportDefault(support, catalogIdref, index, overrides),
563
+ ),
564
+ );
565
+ }
566
+
567
+ function renderNode(
568
+ node: BodyNode,
569
+ key: number,
570
+ overrides?: NodeOverrides,
571
+ inMath = false,
572
+ skipCatalog = false,
573
+ ): ReactNode {
574
+ if (!skipCatalog) {
575
+ const catalogIdref = catalogIdrefOf(node);
576
+
577
+ if (catalogIdref !== undefined) {
578
+ return createElement(CatalogSupportHost, { key, catalogIdref, node, overrides, inMath });
579
+ }
580
+ }
581
+
365
582
  const override = overrides?.[node.kind];
366
583
 
367
584
  if (override) {
@@ -439,11 +656,17 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
439
656
  element: "span" | "div";
440
657
  overrides?: NodeOverrides | undefined;
441
658
  }): ReactNode {
442
- const { store } = useRuntimeContext();
659
+ const { store, suppressFeedback, defaultOutcomes } = useRuntimeContext();
443
660
  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)) {
661
+ // Withheld feedback shows "the version of the qti-item-body displayed to the
662
+ // candidate at the start of each attempt … with the visibility of any integrated
663
+ // feedback determined by the default values of the outcome variables and not the
664
+ // values … updated by the invocation of response processing".
665
+ const outcome = suppressFeedback
666
+ ? (defaultOutcomes[feedback.outcomeIdentifier] ?? null)
667
+ : (snapshot.outcomes[feedback.outcomeIdentifier] ?? null);
668
+
669
+ if (!feedbackVisible(outcome, feedback, suppressFeedback || snapshot.submitted)) {
447
670
  return null;
448
671
  }
449
672
 
@@ -488,11 +711,14 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
488
711
  }
489
712
 
490
713
  function ModalFeedbackHost({ feedback }: { feedback: FeedbackView }): ReactNode {
491
- const { store } = useRuntimeContext();
714
+ const { store, suppressFeedback } = useRuntimeContext();
492
715
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
493
716
  const outcome = snapshot.outcomes[feedback.outcomeIdentifier] ?? null;
494
717
 
495
- if (!feedbackVisible(outcome, feedback, snapshot.submitted)) {
718
+ // "If it is 'false' then feedback is not shown. This includes both Modal
719
+ // Feedback and Integrated Feedback even if the candidate has access to the
720
+ // review state."
721
+ if (suppressFeedback || !feedbackVisible(outcome, feedback, snapshot.submitted)) {
496
722
  return null;
497
723
  }
498
724
 
@@ -504,14 +730,24 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
504
730
  }
505
731
 
506
732
  function InteractionHost({ node }: { node: InteractionNode }): ReactNode {
507
- const { store, declarationsById } = useRuntimeContext();
733
+ const { store, declarationsById, mode, suppressFeedback, solutionResponses } = useRuntimeContext();
508
734
  const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
509
735
 
510
736
  const responseIdentifier = node.responseIdentifier;
511
737
  const declaration = declarationsById.get(responseIdentifier);
512
738
  const cardinality: Cardinality = declaration?.cardinality ?? "single";
513
- const value = snapshot.responses[responseIdentifier] ?? null;
514
- const disabled = snapshot.submitted;
739
+ // The solution state displays the clone's correct response in place of the
740
+ // candidate's ("a way of entering the solution state").
741
+ const value =
742
+ mode === "solution"
743
+ ? (solutionResponses[responseIdentifier] ?? null)
744
+ : (snapshot.responses[responseIdentifier] ?? null);
745
+ // Review and solution are read-only: "can review the qti-item-body along with
746
+ // the responses they gave, but cannot update or resubmit them".
747
+ const disabled = snapshot.submitted || mode !== "interact";
748
+ // Correctness chrome is feedback: shown after a submitted attempt unless
749
+ // show-feedback withholds it — and always in the solution state (its point).
750
+ const revealed = mode === "solution" || (snapshot.submitted && !suppressFeedback);
515
751
 
516
752
  const answered =
517
753
  value !== null &&
@@ -520,12 +756,16 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
520
756
 
521
757
  let status: InteractionStatus = answered ? "answered" : "unanswered";
522
758
 
523
- if (disabled) {
759
+ if (revealed) {
524
760
  const scored = snapshot.scores.find((score) => score.identifier === responseIdentifier);
525
- status = scored?.correct ? "correct" : "incorrect";
761
+ status = mode === "solution" || scored?.correct ? "correct" : "incorrect";
526
762
  }
527
763
 
528
764
  const setValue = (next: ResponseValue): void => {
765
+ if (disabled) {
766
+ return;
767
+ }
768
+
529
769
  store.setResponse(responseIdentifier, next);
530
770
  };
531
771
 
@@ -534,7 +774,7 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
534
774
 
535
775
  let status: OptionStatus = selected ? "selected" : "idle";
536
776
 
537
- if (disabled) {
777
+ if (revealed) {
538
778
  if (isCorrectOption(declaration, optionIdentifier)) {
539
779
  status = "correct";
540
780
  } else if (selected) {
@@ -574,6 +814,21 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
574
814
  const renderContent = (nodes: readonly BodyNode[] | undefined, overrides?: NodeOverrides): ReactNode =>
575
815
  nodes ? nodes.map((child, index) => renderNode(child, index, overrides)) : null;
576
816
 
817
+ const { catalogSupports } = useRuntimeContext();
818
+ const renderCatalogSupportsForSkin = (catalogIdref: string | undefined): ReactNode => {
819
+ const supports = catalogIdref !== undefined ? (catalogSupports.get(catalogIdref) ?? []) : [];
820
+
821
+ if (!supports.length) {
822
+ return null;
823
+ }
824
+
825
+ return supports.map((support, index) =>
826
+ config.renderCatalogSupport
827
+ ? createElement(Fragment, { key: `support-${index}` }, config.renderCatalogSupport(support, catalogIdref!))
828
+ : renderSupportDefault(support, catalogIdref!, index),
829
+ );
830
+ };
831
+
577
832
  const Skin = config.skin[node.kind];
578
833
 
579
834
  if (!Skin) {
@@ -585,8 +840,9 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
585
840
  responseIdentifier,
586
841
  value,
587
842
  setValue,
843
+ renderCatalogSupports: renderCatalogSupportsForSkin,
588
844
  disabled,
589
- showFeedback: disabled,
845
+ showFeedback: revealed,
590
846
  status,
591
847
  getOptionProps,
592
848
  renderContent,
@@ -599,18 +855,41 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
599
855
  });
600
856
  }
601
857
 
602
- function ContentRenderer({ nodes, outcomes }: ContentRendererProps): ReactNode {
858
+ function ContentRenderer({ nodes, outcomes, catalogs, pnp, activeSupports }: ContentRendererProps): ReactNode {
603
859
  const store = useMemo(() => createStaticStore(outcomes ?? {}), [outcomes]);
604
860
  const declarationsById = useMemo(() => new Map<string, ResponseDeclarationView>(), []);
861
+ const catalogSupports = useMemo(
862
+ () => resolveCatalogSupports({ catalogs, pnp, activeSupports }).byCatalogId,
863
+ [catalogs, pnp, activeSupports],
864
+ );
605
865
 
606
866
  return createElement(
607
867
  RuntimeContext.Provider,
608
- { value: { store, declarationsById } },
868
+ {
869
+ value: {
870
+ store,
871
+ declarationsById,
872
+ mode: "interact",
873
+ suppressFeedback: false,
874
+ solutionResponses: {},
875
+ defaultOutcomes: {},
876
+ catalogSupports,
877
+ },
878
+ },
609
879
  nodes?.map((node, index) => renderNode(node, index)),
610
880
  );
611
881
  }
612
882
 
613
- function ItemRenderer({ item, store: externalStore, seed, children }: ItemRendererProps): ReactNode {
883
+ function ItemRenderer({
884
+ item,
885
+ store: externalStore,
886
+ seed,
887
+ mode = "interact",
888
+ showFeedback,
889
+ pnp,
890
+ activeSupports,
891
+ children,
892
+ }: ItemRendererProps): ReactNode {
614
893
  const store = useMemo(
615
894
  () =>
616
895
  externalStore ??
@@ -626,6 +905,7 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
626
905
  seed,
627
906
  normalization: config.normalization,
628
907
  customOperators: config.customOperators,
908
+ constraints: collectInteractionConstraints(item.itemBody.content),
629
909
  },
630
910
  ),
631
911
  [item, externalStore, seed],
@@ -636,12 +916,83 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
636
916
  [item],
637
917
  );
638
918
 
919
+ // "the setting of show-feedback should be ignored for adaptive items when
920
+ // allow-review is 'true'. When in the review state, the final values of the
921
+ // outcome variables should be used."
922
+ const suppressFeedback = mode !== "interact" && showFeedback === false && item.adaptive !== true;
923
+
924
+ // Declared outcome defaults, flat-encoded like snapshot outcomes: withheld
925
+ // feedback re-evaluates against these ("as at the start of each attempt").
926
+ const defaultOutcomes = useMemo(() => {
927
+ const defaults: Record<string, OutcomeValue> = {};
928
+
929
+ for (const declaration of item.outcomeDeclarations ?? []) {
930
+ const values = declaration.defaultValue?.values;
931
+
932
+ if (values !== undefined) {
933
+ defaults[declaration.identifier] =
934
+ declaration.cardinality === "single"
935
+ ? ((values[0]?.value ?? null) as OutcomeValue)
936
+ : (values.map((entry) => entry.value) as never);
937
+ }
938
+ }
939
+
940
+ return defaults;
941
+ }, [item]);
942
+
943
+ // Shared stimulus content renders before the body through the same sanitized
944
+ // walk; an unresolved ref gets the explicit placeholder (ADR-0003 backstop —
945
+ // canDeliver already reported it).
946
+ const stimulusViews = (item.assessmentStimulusRefs ?? []).map((ref) => ({
947
+ ref,
948
+ view: config.resolveStimulus?.(ref) ?? null,
949
+ }));
950
+
951
+ // Catalog ids are document-unique; the item's pool and the resolved stimuli's
952
+ // pool resolve together so idrefs reach across both (§5.28).
953
+ const catalogSupports = resolveCatalogSupports({
954
+ catalogs: [...(item.catalogs ?? []), ...stimulusViews.flatMap(({ view }) => view?.catalogs ?? [])],
955
+ pnp,
956
+ activeSupports,
957
+ }).byCatalogId;
958
+
959
+ const stimuli = stimulusViews.map(({ ref, view }, index) =>
960
+ createElement(
961
+ "section",
962
+ { key: `stimulus-${index}`, "data-qti-stimulus": ref.identifier },
963
+ view === null
964
+ ? createElement(
965
+ "div",
966
+ { role: "note", "data-qti-unsupported": "assessmentStimulusRef" },
967
+ `This content requires a shared stimulus (${ref.href}) this runtime cannot resolve.`,
968
+ )
969
+ : view.content.map((node, nodeIndex) => renderNode(node, nodeIndex)),
970
+ ),
971
+ );
972
+
639
973
  const body = (item.itemBody.content ?? []).map((node, index) => renderNode(node, index));
640
974
  const modals = (item.modalFeedbacks ?? []).map((feedback, index) =>
641
975
  createElement(ModalFeedbackHost, { key: index, feedback }),
642
976
  );
643
977
 
644
- return createElement(RuntimeContext.Provider, { value: { store, declarationsById } }, body, modals, children);
978
+ return createElement(
979
+ RuntimeContext.Provider,
980
+ {
981
+ value: {
982
+ store,
983
+ declarationsById,
984
+ mode,
985
+ suppressFeedback,
986
+ solutionResponses: store.getSnapshot().correctResponses,
987
+ defaultOutcomes,
988
+ catalogSupports,
989
+ },
990
+ },
991
+ stimuli,
992
+ body,
993
+ modals,
994
+ children,
995
+ );
645
996
  }
646
997
 
647
998
  function useAttempt(): AttemptController {
@@ -655,6 +1006,14 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
655
1006
  };
656
1007
  }
657
1008
 
1009
+ const noSupports: readonly ResolvedCatalogSupport[] = [];
1010
+
1011
+ function useCatalogSupports(catalogIdref: string | undefined): readonly ResolvedCatalogSupport[] {
1012
+ const { catalogSupports } = useRuntimeContext();
1013
+
1014
+ return catalogIdref !== undefined ? (catalogSupports.get(catalogIdref) ?? noSupports) : noSupports;
1015
+ }
1016
+
658
1017
  function canDeliver(item: AssessmentItemView): CapabilityReport {
659
1018
  const issues: CapabilityIssue[] = [];
660
1019
  const seen = new Set<string>();
@@ -740,6 +1099,21 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
740
1099
  walk(node);
741
1100
  }
742
1101
 
1102
+ // Shared stimulus refs must resolve to be deliverable; resolved content passes
1103
+ // through the same content-model gate as the body.
1104
+ for (const ref of item.assessmentStimulusRefs ?? []) {
1105
+ const stimulus = config.resolveStimulus?.(ref) ?? null;
1106
+
1107
+ if (stimulus === null) {
1108
+ report({ type: "unsupported-element", name: "assessmentStimulusRef", detail: ref.href });
1109
+ continue;
1110
+ }
1111
+
1112
+ for (const node of stimulus.content) {
1113
+ walk(node);
1114
+ }
1115
+ }
1116
+
743
1117
  for (const feedback of item.modalFeedbacks ?? []) {
744
1118
  for (const child of feedback.content ?? []) {
745
1119
  walk(child);
@@ -748,7 +1122,10 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
748
1122
 
749
1123
  const customOperatorClasses = new Set(Object.keys(config.customOperators ?? {}));
750
1124
 
751
- for (const issue of collectRpIssues(item.responseProcessing, { customOperatorClasses })) {
1125
+ for (const issue of collectRpIssues(item.responseProcessing, {
1126
+ customOperatorClasses,
1127
+ outcomeDeclarations: item.outcomeDeclarations ?? [],
1128
+ })) {
752
1129
  report(issue);
753
1130
  }
754
1131
 
@@ -759,5 +1136,5 @@ export function createQtiRuntime(config: QtiRuntimeConfig): QtiRuntime {
759
1136
  return { deliverable: issues.length === 0, issues };
760
1137
  }
761
1138
 
762
- return { ItemRenderer, ContentRenderer, useAttempt, canDeliver };
1139
+ return { ItemRenderer, ContentRenderer, useAttempt, useCatalogSupports, canDeliver };
763
1140
  }