@pie-players/pie-assessment-toolkit 0.3.74 → 0.3.75

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 (228) hide show
  1. package/README.md +349 -313
  2. package/dist/attempt/TestSession.d.ts +1 -20
  3. package/dist/attempt/TestSession.js +8 -99
  4. package/dist/components/ItemScope.custom-element.d.ts +1 -0
  5. package/dist/components/ItemScope.custom-element.js +1 -0
  6. package/dist/components/ItemToolBar.custom-element.js +1 -1
  7. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  8. package/dist/components/SectionToolBar.custom-element.js +1 -1
  9. package/dist/components/chunks/ItemToolBar-7tt7v2xp.js +4 -0
  10. package/dist/components/chunks/{ItemToolBar-gmrryv2v.js → ItemToolBar-97hx60z4.js} +1 -1
  11. package/dist/components/chunks/ItemToolBar-bxwyhr5p.js +2 -0
  12. package/dist/components/chunks/ItemToolBar-jx38w99s.js +2 -0
  13. package/dist/components/chunks/ItemToolBar-rd2bb4p6.js +40 -0
  14. package/dist/components/chunks/ItemToolBar-vdkyn9f3.js +2 -0
  15. package/dist/components/chunks/sre-engine-jd8cc08x.js +1 -0
  16. package/dist/components/item-scope-element.d.ts +2 -0
  17. package/dist/components/item-scope-element.js +2 -0
  18. package/dist/context/assessment-toolkit-context.d.ts +27 -6
  19. package/dist/index.d.ts +22 -47
  20. package/dist/index.js +12 -36
  21. package/dist/policy/core/PolicySource.d.ts +1 -1
  22. package/dist/policy/core/ToolPolicyEngine.d.ts +77 -32
  23. package/dist/policy/core/ToolPolicyEngine.js +136 -46
  24. package/dist/policy/core/compose-decision.d.ts +28 -7
  25. package/dist/policy/core/compose-decision.js +75 -54
  26. package/dist/policy/core/decision-types.d.ts +65 -34
  27. package/dist/policy/core/feature-decision.d.ts +16 -9
  28. package/dist/policy/core/feature-decision.js +14 -8
  29. package/dist/policy/core/pnp-policy-inputs.d.ts +24 -33
  30. package/dist/policy/core/pnp-policy-inputs.js +28 -56
  31. package/dist/policy/core/provenance.d.ts +9 -4
  32. package/dist/policy/core/provenance.js +1 -1
  33. package/dist/policy/engine.d.ts +7 -23
  34. package/dist/policy/engine.js +5 -20
  35. package/dist/policy/sources/PnpPolicySource.d.ts +44 -61
  36. package/dist/policy/sources/PnpPolicySource.js +117 -118
  37. package/dist/runtime/SectionControllerBinding.d.ts +279 -0
  38. package/dist/runtime/SectionControllerBinding.js +450 -0
  39. package/dist/runtime/SectionRuntimeEngine.d.ts +26 -289
  40. package/dist/runtime/SectionRuntimeEngine.js +30 -372
  41. package/dist/runtime/adapter/SectionEngineAdapter.d.ts +19 -80
  42. package/dist/runtime/adapter/SectionEngineAdapter.js +19 -92
  43. package/dist/runtime/adapter/dom-event-bridge.d.ts +15 -30
  44. package/dist/runtime/adapter/dom-event-bridge.js +17 -44
  45. package/dist/runtime/adapter/subscriber-fanout.d.ts +1 -2
  46. package/dist/runtime/adapter/subscriber-fanout.js +1 -2
  47. package/dist/runtime/catalog-context.d.ts +19 -0
  48. package/dist/runtime/catalog-context.js +28 -0
  49. package/dist/runtime/composition-emit-scheduler.d.ts +9 -15
  50. package/dist/runtime/composition-emit-scheduler.js +9 -15
  51. package/dist/runtime/content-language.d.ts +32 -0
  52. package/dist/runtime/content-language.js +46 -0
  53. package/dist/runtime/content-region.d.ts +17 -0
  54. package/dist/runtime/content-region.js +29 -0
  55. package/dist/runtime/content-registration.d.ts +13 -0
  56. package/dist/runtime/content-registration.js +25 -0
  57. package/dist/runtime/core/SectionEngineCore.d.ts +2 -2
  58. package/dist/runtime/core/SectionEngineCore.js +2 -2
  59. package/dist/runtime/core/cohort.d.ts +1 -1
  60. package/dist/runtime/core/cohort.js +1 -1
  61. package/dist/runtime/core/engine-input.d.ts +18 -30
  62. package/dist/runtime/core/engine-input.js +11 -12
  63. package/dist/runtime/core/engine-output.d.ts +16 -40
  64. package/dist/runtime/core/engine-output.js +9 -21
  65. package/dist/runtime/core/engine-readiness.d.ts +5 -37
  66. package/dist/runtime/core/engine-readiness.js +8 -41
  67. package/dist/runtime/core/engine-resolver.d.ts +46 -74
  68. package/dist/runtime/core/engine-resolver.js +18 -43
  69. package/dist/runtime/core/engine-state.d.ts +28 -36
  70. package/dist/runtime/core/engine-state.js +18 -21
  71. package/dist/runtime/core/engine-transition.d.ts +14 -19
  72. package/dist/runtime/core/engine-transition.js +71 -51
  73. package/dist/runtime/engine.d.ts +16 -19
  74. package/dist/runtime/engine.js +12 -19
  75. package/dist/runtime/page-warnings.d.ts +13 -0
  76. package/dist/runtime/page-warnings.js +22 -0
  77. package/dist/runtime/policy-input-forwarding.d.ts +25 -0
  78. package/dist/runtime/policy-input-forwarding.js +25 -0
  79. package/dist/runtime/registration-events.d.ts +11 -0
  80. package/dist/runtime/runtime-event-claim.d.ts +8 -0
  81. package/dist/runtime/runtime-event-claim.js +15 -0
  82. package/dist/runtime/shell-event-bridge.d.ts +45 -0
  83. package/dist/runtime/shell-event-bridge.js +127 -0
  84. package/dist/runtime/shell-registration.d.ts +73 -0
  85. package/dist/runtime/shell-registration.js +95 -0
  86. package/dist/runtime/shell-scope.d.ts +68 -0
  87. package/dist/runtime/shell-scope.js +188 -0
  88. package/dist/runtime/tool-host-contract.d.ts +8 -10
  89. package/dist/runtime/tool-host-contract.js +11 -11
  90. package/dist/runtime/unclaimed-registration-watch.d.ts +7 -0
  91. package/dist/runtime/unclaimed-registration-watch.js +23 -0
  92. package/dist/services/ElementToolStateStore.d.ts +14 -10
  93. package/dist/services/ElementToolStateStore.js +37 -13
  94. package/dist/services/HighlightCoordinator.d.ts +21 -21
  95. package/dist/services/HighlightCoordinator.js +431 -325
  96. package/dist/services/RangeSerializer.d.ts +10 -1
  97. package/dist/services/RangeSerializer.js +87 -14
  98. package/dist/services/TTSService.d.ts +117 -70
  99. package/dist/services/TTSService.js +668 -578
  100. package/dist/services/ToolCoordinator.d.ts +43 -60
  101. package/dist/services/ToolCoordinator.js +164 -206
  102. package/dist/services/ToolRegistry.d.ts +74 -57
  103. package/dist/services/ToolRegistry.js +104 -114
  104. package/dist/services/ToolkitCoordinator.d.ts +183 -189
  105. package/dist/services/ToolkitCoordinator.js +477 -431
  106. package/dist/services/audio-handoff.d.ts +6 -7
  107. package/dist/services/audio-handoff.js +20 -14
  108. package/dist/services/catalog-owner.d.ts +0 -1
  109. package/dist/services/framework-error-bus.d.ts +3 -6
  110. package/dist/services/framework-error-bus.js +3 -6
  111. package/dist/services/framework-error.d.ts +22 -7
  112. package/dist/services/framework-error.js +9 -7
  113. package/dist/services/interfaces.d.ts +158 -129
  114. package/dist/services/section-session-assignment.d.ts +21 -0
  115. package/dist/services/section-session-assignment.js +82 -0
  116. package/dist/services/spoken-audio-cards.d.ts +0 -2
  117. package/dist/services/spoken-audio-cards.js +0 -2
  118. package/dist/services/tool-config-defaults.d.ts +3 -3
  119. package/dist/services/tool-config-defaults.js +3 -3
  120. package/dist/services/tool-config-validation.d.ts +4 -5
  121. package/dist/services/tool-config-validation.js +36 -61
  122. package/dist/services/tool-context.d.ts +0 -8
  123. package/dist/services/tool-context.js +0 -12
  124. package/dist/services/tool-icons.d.ts +4 -3
  125. package/dist/services/tool-icons.js +4 -3
  126. package/dist/services/tool-instance-id.d.ts +0 -1
  127. package/dist/services/tool-instance-id.js +0 -6
  128. package/dist/services/tool-providers/TTSToolProvider.d.ts +18 -19
  129. package/dist/services/tool-providers/TTSToolProvider.js +37 -34
  130. package/dist/services/tool-providers/ToolProviderApi.d.ts +10 -4
  131. package/dist/services/tool-providers/ToolProviderRegistry.d.ts +30 -26
  132. package/dist/services/tool-providers/ToolProviderRegistry.js +119 -93
  133. package/dist/services/tool-request.d.ts +27 -12
  134. package/dist/services/tool-request.js +24 -14
  135. package/dist/services/toolbar-decided-tools.d.ts +15 -0
  136. package/dist/services/toolbar-decided-tools.js +28 -0
  137. package/dist/services/tools-config-normalizer.d.ts +17 -22
  138. package/dist/services/tools-config-normalizer.js +2 -10
  139. package/dist/services/tts/browser-provider.d.ts +38 -2
  140. package/dist/services/tts/browser-provider.js +276 -140
  141. package/dist/services/tts/flat-tree.d.ts +71 -0
  142. package/dist/services/tts/flat-tree.js +269 -0
  143. package/dist/services/tts/generated-speech/assemble-plan.d.ts +3 -1
  144. package/dist/services/tts/generated-speech/assemble-plan.js +3 -1
  145. package/dist/services/tts/generated-speech/dom/build-dom-plan.d.ts +2 -0
  146. package/dist/services/tts/generated-speech/dom/build-dom-plan.js +1 -1
  147. package/dist/services/tts/generated-speech/math-speech-cache.d.ts +3 -1
  148. package/dist/services/tts/generated-speech/math-speech-cache.js +5 -1
  149. package/dist/services/tts/highlight-pipeline/create-highlight-plan.js +12 -4
  150. package/dist/services/tts/highlight-pipeline/prose-boundary-resolver.js +6 -4
  151. package/dist/services/tts/highlight-pipeline/readable-region.js +3 -2
  152. package/dist/services/tts/highlight-pipeline/types.d.ts +5 -0
  153. package/dist/services/tts/highlight-pipeline/visible-map-range.d.ts +12 -0
  154. package/dist/services/tts/highlight-pipeline/visible-map-range.js +73 -7
  155. package/dist/services/tts/math-aware-text-processing.d.ts +5 -1
  156. package/dist/services/tts/math-aware-text-processing.js +32 -18
  157. package/dist/services/tts/math-control-names.d.ts +21 -0
  158. package/dist/services/tts/math-control-names.js +142 -0
  159. package/dist/services/tts/math-speech.d.ts +11 -0
  160. package/dist/services/tts/math-speech.js +51 -18
  161. package/dist/services/tts/provider-options.d.ts +41 -0
  162. package/dist/services/tts/provider-options.js +1 -0
  163. package/dist/services/tts/ssml/spoken-text.d.ts +2 -26
  164. package/dist/services/tts/ssml/spoken-text.js +29 -0
  165. package/dist/services/tts/start-failure.d.ts +11 -0
  166. package/dist/services/tts/start-failure.js +19 -0
  167. package/dist/services/tts/text-processing.d.ts +9 -0
  168. package/dist/services/tts/text-processing.js +46 -33
  169. package/dist/services/tts/text-segmentation.d.ts +7 -0
  170. package/dist/services/tts/text-segmentation.js +47 -0
  171. package/dist/services/tts-runtime-config.d.ts +25 -17
  172. package/dist/services/tts-runtime-config.js +16 -95
  173. package/dist/tools/content-capability-resolution.d.ts +7 -9
  174. package/dist/tools/content-capability-resolution.js +8 -45
  175. package/dist/tools/registration.d.ts +58 -0
  176. package/dist/tools/registration.js +54 -0
  177. package/dist/tools/registrations/toolbar-registration-helpers.js +2 -2
  178. package/dist/tools/tool-surface-host.d.ts +6 -10
  179. package/dist/tools/tool-surface-host.js +44 -42
  180. package/dist/tools/tool-tag-map.d.ts +5 -15
  181. package/dist/tools/tool-tag-map.js +6 -20
  182. package/dist/utils/structural-equality.d.ts +11 -0
  183. package/dist/utils/structural-equality.js +47 -0
  184. package/package.json +13 -29
  185. package/dist/attempt/adapters/activity-to-test-attempt-session.d.ts +0 -49
  186. package/dist/attempt/adapters/activity-to-test-attempt-session.js +0 -138
  187. package/dist/components/chunks/ItemToolBar-b9dz5jvd.js +0 -40
  188. package/dist/components/chunks/ItemToolBar-h4xap0t0.js +0 -4
  189. package/dist/components/chunks/ItemToolBar-wr2myaah.js +0 -2
  190. package/dist/components/chunks/sre-engine-wf9s43gg.js +0 -1
  191. package/dist/context/runtime-context-consumer.d.ts +0 -13
  192. package/dist/context/runtime-context-consumer.js +0 -18
  193. package/dist/item-loader.d.ts +0 -44
  194. package/dist/item-loader.js +0 -61
  195. package/dist/policy/core/engine-context.d.ts +0 -29
  196. package/dist/policy/core/engine-context.js +0 -27
  197. package/dist/policy/core/policy-source-tag.d.ts +0 -31
  198. package/dist/policy/core/policy-source-tag.js +0 -28
  199. package/dist/policy/internal.d.ts +0 -25
  200. package/dist/policy/internal.js +0 -25
  201. package/dist/runtime/adapter/coordinator-bridge.d.ts +0 -92
  202. package/dist/runtime/adapter/coordinator-bridge.js +0 -126
  203. package/dist/runtime/adapter/framework-error-bridge.d.ts +0 -36
  204. package/dist/runtime/adapter/framework-error-bridge.js +0 -46
  205. package/dist/runtime/adapter/instrumentation-bridge.d.ts +0 -31
  206. package/dist/runtime/adapter/instrumentation-bridge.js +0 -38
  207. package/dist/runtime/core/engine-stage-derivation.d.ts +0 -18
  208. package/dist/runtime/core/engine-stage-derivation.js +0 -29
  209. package/dist/runtime/engine-context.d.ts +0 -39
  210. package/dist/runtime/engine-context.js +0 -31
  211. package/dist/runtime/internal.d.ts +0 -43
  212. package/dist/runtime/internal.js +0 -45
  213. package/dist/runtime/section-runtime-engine-host-context.d.ts +0 -74
  214. package/dist/runtime/section-runtime-engine-host-context.js +0 -57
  215. package/dist/runtime/stage-emit-gate.d.ts +0 -92
  216. package/dist/runtime/stage-emit-gate.js +0 -82
  217. package/dist/services/I18nService.d.ts +0 -47
  218. package/dist/services/I18nService.js +0 -84
  219. package/dist/services/ThemeProvider.d.ts +0 -95
  220. package/dist/services/ThemeProvider.js +0 -334
  221. package/dist/services/defaultPersonalNeedsProfile.d.ts +0 -21
  222. package/dist/services/defaultPersonalNeedsProfile.js +0 -26
  223. package/dist/services/pnp-standard-features.d.ts +0 -238
  224. package/dist/services/pnp-standard-features.js +0 -338
  225. package/dist/tools/client.d.ts +0 -10
  226. package/dist/tools/client.js +0 -10
  227. package/dist/tools/internal.d.ts +0 -44
  228. package/dist/tools/internal.js +0 -39
@@ -0,0 +1,450 @@
1
+ /**
2
+ * Section controller binding — the toolkit's side of a section runtime.
3
+ *
4
+ * `PieAssessmentToolkit` holds one per element. It resolves the section's
5
+ * controller from the coordinator (`initialize`), keeps the registered item and
6
+ * passage shells (`RuntimeRegistry`) and which of them have loaded, replays
7
+ * both into each controller it resolves, and forwards shell events, session
8
+ * updates and host commands to that controller.
9
+ *
10
+ * The registry outlives a section switch: the shells of the section being left
11
+ * stay registered until they unmount. One membership rule routes every shell
12
+ * event, in replay or live: an event goes to the controller of the section
13
+ * whose composition renders the shell, which is the current controller or a
14
+ * cached controller of a section this binding bound earlier, and is dropped
15
+ * with a warning when no bound section renders it; see `controllerFor`. A
16
+ * cached controller re-bound on a revisit first forgets the loads of shells
17
+ * that are no longer registered, so its `section-loading-complete` waits for
18
+ * the new shells.
19
+ *
20
+ * The section's stage chain is a separate object: the section player's layout
21
+ * kernel drives a `SectionRuntimeEngine` from the readiness signals it derives.
22
+ * The two share no state.
23
+ */
24
+ import { createPieLogger, isGlobalDebugEnabled, } from "@pie-players/pie-players-shared";
25
+ import { RuntimeRegistry } from "./RuntimeRegistry.js";
26
+ const logger = createPieLogger("section-controller-binding", () => isGlobalDebugEnabled());
27
+ /**
28
+ * The `entity.id` of each of a composition model's `renderables`: the ids its
29
+ * item and passage shells register with. `null` when the model has no
30
+ * `renderables` array.
31
+ */
32
+ function readRenderableIds(model) {
33
+ const renderables = model
34
+ ?.renderables;
35
+ if (!Array.isArray(renderables))
36
+ return null;
37
+ const ids = new Set();
38
+ for (const renderable of renderables) {
39
+ const id = renderable?.entity
40
+ ?.id;
41
+ if (typeof id === "string" && id)
42
+ ids.add(id);
43
+ }
44
+ return ids;
45
+ }
46
+ export class SectionControllerBinding {
47
+ registry = new RuntimeRegistry();
48
+ controller = null;
49
+ coordinator = null;
50
+ sectionId = "";
51
+ attemptId;
52
+ unsubscribeController = null;
53
+ activeInitToken = 0;
54
+ /**
55
+ * Ids of the renderables in the current controller's composition model, read
56
+ * when `initialize` resolves it. `null` while no controller is resolved, and
57
+ * for a controller that publishes no `renderables`: its section cannot be
58
+ * told apart, so every shell reaches it.
59
+ */
60
+ sectionRenderableIds = null;
61
+ /**
62
+ * The renderable ids of every section this binding has bound, keyed by
63
+ * section and attempt, least recently bound first. `controllerFor` reads it
64
+ * to find the section that owns a shell outside the current one.
65
+ */
66
+ boundSections = new Map();
67
+ /**
68
+ * Tracks which `(canonicalItemId, contentKind)` pairs have already
69
+ * fired `handleContentLoaded` on this binding, so a section handoff can
70
+ * replay the persistent shells' loaded state into the new controller; see
71
+ * `replayRegisteredShellsIntoController`.
72
+ *
73
+ * Records loads of every registered shell, in or out of the current
74
+ * section, so a shell registered ahead of its section's controller is
75
+ * replayed as loaded. A key is dropped when its last shell unregisters,
76
+ * and on `dispose`.
77
+ */
78
+ loadedRenderableKeys = new Set();
79
+ async initialize(args) {
80
+ this.activeInitToken += 1;
81
+ const token = this.activeInitToken;
82
+ this.coordinator = args.coordinator;
83
+ this.sectionId = args.sectionId;
84
+ this.attemptId = args.attemptId;
85
+ // Before the coordinator re-activates a cached controller and replays its
86
+ // loaded state to subscribers.
87
+ this.forgetUnregisteredLoads(args.coordinator.getSectionController({
88
+ sectionId: args.sectionId,
89
+ attemptId: args.attemptId,
90
+ }));
91
+ let resolved;
92
+ try {
93
+ resolved = (await args.coordinator.getOrCreateSectionController({
94
+ sectionId: args.sectionId,
95
+ attemptId: args.attemptId,
96
+ input: {
97
+ section: args.section,
98
+ sectionId: args.sectionId,
99
+ assessmentId: args.assessmentId,
100
+ view: args.view,
101
+ },
102
+ updateExisting: true,
103
+ initialSession: args.initialSession,
104
+ createDefaultController: args.createDefaultController,
105
+ }));
106
+ }
107
+ catch (error) {
108
+ // The previous section's controller must not keep serving the
109
+ // section that failed to start.
110
+ if (token === this.activeInitToken) {
111
+ this.unsubscribeController?.();
112
+ this.unsubscribeController = null;
113
+ this.controller = null;
114
+ }
115
+ throw error;
116
+ }
117
+ if (token !== this.activeInitToken)
118
+ return;
119
+ this.unsubscribeController?.();
120
+ this.controller = resolved;
121
+ this.sectionRenderableIds = readRenderableIds(resolved.getCompositionModel?.());
122
+ const sectionKey = this.getSectionKey(args.sectionId, args.attemptId);
123
+ this.boundSections.delete(sectionKey);
124
+ this.boundSections.set(sectionKey, {
125
+ sectionId: args.sectionId,
126
+ attemptId: args.attemptId,
127
+ renderableIds: this.sectionRenderableIds,
128
+ });
129
+ // Every initialize re-feeds the registered shells of the resolved
130
+ // controller's section, a same-section `updateInput` included: the
131
+ // controller's register and load are idempotent, so a re-feed of shells
132
+ // it already tracks changes nothing.
133
+ this.replayRegisteredShellsIntoController(resolved);
134
+ args.onCompositionChanged?.(resolved.getCompositionModel?.());
135
+ this.unsubscribeController =
136
+ resolved.subscribe?.((event) => {
137
+ // Isolated deliberately: the controller's emit loop catches per
138
+ // listener, so a diagnostic handler that throws would take the
139
+ // composition republish down with it and every cue and Try would stop
140
+ // reaching the cards — a failure with no symptom except a warning.
141
+ try {
142
+ args.onControllerEvent?.(event);
143
+ }
144
+ catch (error) {
145
+ logger.warn("onControllerEvent handler threw", error);
146
+ }
147
+ args.onCompositionChanged?.(resolved.getCompositionModel?.());
148
+ }) || null;
149
+ }
150
+ /**
151
+ * Re-feed the binding's `RuntimeRegistry` and loaded-set into the
152
+ * resolved controller, limited to the shells of its section (see
153
+ * `isSectionRenderable`). Runs on every `initialize`.
154
+ *
155
+ * Two-pass order — register every shell in document order, then
156
+ * issue `handleContentLoaded` for each shell whose load already
157
+ * fired on this binding. The two-pass shape prevents
158
+ * `evaluateSectionLoadingState` from flapping
159
+ * `section-loading-complete` `false→true→false→…` while replay is
160
+ * mid-walk: any subscriber wired before the binding's own
161
+ * `controller.subscribe` (no such caller today, but cheap defense
162
+ * for future wiring) sees one clean `false→true` transition.
163
+ *
164
+ * Idempotent on the controller side: `handleContentRegistered` and
165
+ * `handleContentLoaded` return early on duplicates, so re-feeding shells
166
+ * the controller already tracks emits nothing.
167
+ */
168
+ replayRegisteredShellsIntoController(controller) {
169
+ if (!controller.handleContentRegistered)
170
+ return;
171
+ const shells = this.registry
172
+ .getOrderedShells()
173
+ .filter((shell) => this.isSectionRenderable(shell));
174
+ if (shells.length === 0)
175
+ return;
176
+ for (const shell of shells) {
177
+ const canonicalItemId = shell.canonicalItemId || shell.itemId;
178
+ controller.handleContentRegistered({
179
+ itemId: shell.itemId,
180
+ canonicalItemId,
181
+ contentKind: shell.contentKind || shell.kind,
182
+ });
183
+ }
184
+ const now = Date.now();
185
+ for (const shell of shells) {
186
+ const canonicalItemId = shell.canonicalItemId || shell.itemId;
187
+ const key = this.getLoadedKey(canonicalItemId, shell.contentKind);
188
+ if (this.loadedRenderableKeys.has(key)) {
189
+ controller.handleContentLoaded?.({
190
+ itemId: shell.itemId,
191
+ canonicalItemId,
192
+ contentKind: shell.contentKind || shell.kind,
193
+ timestamp: now,
194
+ });
195
+ }
196
+ }
197
+ }
198
+ /**
199
+ * Stable key for the binding's `loadedRenderableKeys` set. Mirrors
200
+ * the `(canonicalItemId, contentKind)` shape `SectionController`
201
+ * uses internally (see `getRenderableKey`); we only need
202
+ * consistent add/check semantics on this side, not byte-identical
203
+ * normalization with the controller.
204
+ */
205
+ getLoadedKey(canonicalItemId, contentKind) {
206
+ return `${contentKind ?? ""}:${canonicalItemId}`;
207
+ }
208
+ /**
209
+ * Whether a shell renders one of the current section's renderables: its
210
+ * runtime or canonical id is among the composition model's renderable ids.
211
+ * An item in two sections belongs to both, so its shell carries over a
212
+ * switch the way a persistent passage shell does.
213
+ */
214
+ isSectionRenderable(args) {
215
+ const ids = this.sectionRenderableIds;
216
+ if (!ids)
217
+ return true;
218
+ return (ids.has(args.itemId) ||
219
+ (!!args.canonicalItemId && ids.has(args.canonicalItemId)));
220
+ }
221
+ getSectionKey(sectionId, attemptId) {
222
+ return `${sectionId}\u0000${attemptId ?? ""}`;
223
+ }
224
+ /**
225
+ * The controller a shell event belongs to: the current controller when the
226
+ * shell renders one of its section's renderables, otherwise the cached
227
+ * controller of the most recently bound section that renders it — the
228
+ * section being left, for an outgoing shell's teardown. `null` when no bound
229
+ * section renders the shell or its controller is gone.
230
+ */
231
+ controllerFor(args) {
232
+ if (this.isSectionRenderable(args))
233
+ return this.controller;
234
+ const current = this.getSectionKey(this.sectionId, this.attemptId);
235
+ const sections = [...this.boundSections.entries()].reverse();
236
+ for (const [key, section] of sections) {
237
+ if (key === current || !section.renderableIds)
238
+ continue;
239
+ if (!section.renderableIds.has(args.itemId) &&
240
+ !(args.canonicalItemId && section.renderableIds.has(args.canonicalItemId))) {
241
+ continue;
242
+ }
243
+ const owner = this.coordinator?.getSectionController({
244
+ sectionId: section.sectionId,
245
+ attemptId: section.attemptId,
246
+ });
247
+ if (owner)
248
+ return owner;
249
+ }
250
+ return null;
251
+ }
252
+ /** `controllerFor`, warning when the event has nowhere to go. */
253
+ requireControllerFor(args, what) {
254
+ const controller = this.controllerFor(args);
255
+ if (!controller) {
256
+ logger.warn(`Dropped ${what} for "${args.itemId}": no section this toolkit bound renders it.`);
257
+ }
258
+ return controller;
259
+ }
260
+ /**
261
+ * Forget, on a cached controller about to be re-bound, every load whose
262
+ * shell is no longer registered. A shell that unmounted while its section
263
+ * was not current told that section's controller through `controllerFor`;
264
+ * this covers one whose unregister never arrived.
265
+ */
266
+ forgetUnregisteredLoads(cached) {
267
+ if (!cached || cached === this.controller)
268
+ return;
269
+ const loaded = cached.getRuntimeState?.()?.loadedRenderables ?? [];
270
+ if (loaded.length === 0)
271
+ return;
272
+ const live = new Set();
273
+ for (const shell of this.registry.getOrderedShells()) {
274
+ live.add(shell.itemId);
275
+ if (shell.canonicalItemId)
276
+ live.add(shell.canonicalItemId);
277
+ }
278
+ for (const renderable of loaded) {
279
+ if (live.has(renderable.canonicalItemId) || live.has(renderable.itemId)) {
280
+ continue;
281
+ }
282
+ cached.handleContentUnregistered?.({
283
+ itemId: renderable.itemId,
284
+ canonicalItemId: renderable.canonicalItemId,
285
+ contentKind: renderable.contentKind,
286
+ });
287
+ }
288
+ }
289
+ register(detail) {
290
+ return this.registry.register(detail);
291
+ }
292
+ unregister(element) {
293
+ return this.registry.unregister(element);
294
+ }
295
+ getCompositionModel() {
296
+ return this.controller?.getCompositionModel?.() ?? null;
297
+ }
298
+ getCanonicalItemId(itemId) {
299
+ const map = this.registry.getCanonicalIdMap();
300
+ return (map[itemId] || this.controller?.getCanonicalItemId?.(itemId) || itemId);
301
+ }
302
+ handleContentRegistered(detail) {
303
+ if (!this.isSectionRenderable(detail))
304
+ return;
305
+ this.controller?.handleContentRegistered?.({
306
+ itemId: detail.itemId,
307
+ canonicalItemId: detail.canonicalItemId || detail.itemId,
308
+ contentKind: detail.contentKind || detail.kind,
309
+ });
310
+ }
311
+ /**
312
+ * Called after `unregister` removed the shell from the registry. While
313
+ * another registered shell renders the same renderable — the next
314
+ * section's shell for an item both sections hold — the renderable stays
315
+ * registered and loaded.
316
+ */
317
+ handleContentUnregistered(detail) {
318
+ const canonicalItemId = detail.canonicalItemId || detail.itemId;
319
+ // Key the load-set delete with the same `contentKind`-only
320
+ // shape that `handleContentLoaded` and
321
+ // `replayRegisteredShellsIntoController` use, so add/check/
322
+ // delete keys round-trip identically. The `|| detail.kind`
323
+ // fallback is preserved for the controller-forwarded payload
324
+ // because the controller normalizes via `toSectionContentKind`
325
+ // and the `kind` field remains meaningful there.
326
+ const key = this.getLoadedKey(canonicalItemId, detail.contentKind);
327
+ const stillRendered = this.registry
328
+ .getOrderedShells()
329
+ .some((shell) => this.getLoadedKey(shell.canonicalItemId || shell.itemId, shell.contentKind) === key);
330
+ if (stillRendered)
331
+ return;
332
+ this.loadedRenderableKeys.delete(key);
333
+ this.controllerFor(detail)?.handleContentUnregistered?.({
334
+ itemId: detail.itemId,
335
+ canonicalItemId,
336
+ contentKind: detail.contentKind || detail.kind,
337
+ });
338
+ }
339
+ handleContentLoaded(args) {
340
+ const canonicalItemId = args.canonicalItemId || args.itemId;
341
+ this.loadedRenderableKeys.add(this.getLoadedKey(canonicalItemId, args.contentKind));
342
+ if (!this.isSectionRenderable(args))
343
+ return;
344
+ this.controller?.handleContentLoaded?.(args);
345
+ }
346
+ handleItemPlayerError(args) {
347
+ this.requireControllerFor(args, "an item player error")?.handleItemPlayerError?.(args);
348
+ }
349
+ reportSectionError(args) {
350
+ const controller = args.itemId
351
+ ? this.requireControllerFor({ itemId: args.itemId, canonicalItemId: args.canonicalItemId }, "a section error")
352
+ : this.controller;
353
+ controller?.reportSectionError?.(args);
354
+ }
355
+ updateItemSession(itemId, session) {
356
+ const canonicalId = this.getCanonicalItemId(itemId);
357
+ const controller = this.requireControllerFor({ itemId, canonicalItemId: canonicalId }, "a session update");
358
+ return controller?.updateItemSession?.(canonicalId, session) ?? null;
359
+ }
360
+ navigateToItem(index) {
361
+ return this.controller?.navigateToItem?.(index) ?? null;
362
+ }
363
+ /**
364
+ * Route a learner's formative action to the controller.
365
+ *
366
+ * Canonicalized here for the same reason `updateItemSession` is: the runtime
367
+ * id a card dispatches with is not necessarily the identifier the controller
368
+ * keys state by. Both ids are kept, since the composition's renderables are
369
+ * keyed by the runtime one.
370
+ */
371
+ handleFormativeAction(action) {
372
+ if (!action?.itemId)
373
+ return;
374
+ const canonicalId = action.canonicalItemId || this.getCanonicalItemId(action.itemId);
375
+ const controller = this.requireControllerFor({ itemId: action.itemId, canonicalItemId: canonicalId }, "a formative action");
376
+ if (action.action === "retry") {
377
+ controller?.retryFormativeItem?.({ itemId: canonicalId });
378
+ return;
379
+ }
380
+ controller?.recordFormativeTry?.({
381
+ itemId: canonicalId,
382
+ outcomes: action.outcomes,
383
+ });
384
+ }
385
+ /**
386
+ * Bind or release the section's Media Time Source.
387
+ *
388
+ * A pass-through, like `handleFormativeAction`: the binding routes, the
389
+ * controller decides. `renderableId` travels with the attach because whether the
390
+ * registering renderable is the one `stimulusRef` names is the controller's call
391
+ * — it validated `stimulusRef` in the first place.
392
+ */
393
+ handleMediaTimeSource(action) {
394
+ if (!action?.renderableId)
395
+ return;
396
+ const controller = this.requireControllerFor({
397
+ itemId: action.renderableId,
398
+ canonicalItemId: this.getCanonicalItemId(action.renderableId),
399
+ }, "a media time source");
400
+ if (action.action === "detach") {
401
+ controller?.detachMediaTimeSource?.({
402
+ origin: action.origin ?? "host",
403
+ });
404
+ return;
405
+ }
406
+ if (!action.source)
407
+ return;
408
+ controller?.attachMediaTimeSource?.(action.source, {
409
+ origin: action.origin ?? "host",
410
+ renderableId: action.renderableId,
411
+ });
412
+ }
413
+ /**
414
+ * Silence media audio because something else is about to speak.
415
+ *
416
+ * A pass-through like `handleMediaTimeSource`, and for the same reason: which
417
+ * source is authoritative and whether it reports `canPause` are the controller's
418
+ * to know. Returns whether media audio is now silent, or `true` where there is
419
+ * no timed-media controller to ask — nothing is playing.
420
+ */
421
+ requestMediaPauseForCompetingAudio() {
422
+ return this.controller?.pauseMediaForCompetingAudio?.() ?? true;
423
+ }
424
+ async persist() {
425
+ await this.controller?.persist?.();
426
+ }
427
+ async hydrate() {
428
+ await this.controller?.hydrate?.();
429
+ }
430
+ getRegistry() {
431
+ return this.registry;
432
+ }
433
+ async dispose() {
434
+ this.activeInitToken += 1;
435
+ this.unsubscribeController?.();
436
+ this.unsubscribeController = null;
437
+ if (this.coordinator && this.sectionId) {
438
+ await this.coordinator.disposeSectionController({
439
+ sectionId: this.sectionId,
440
+ attemptId: this.attemptId,
441
+ });
442
+ }
443
+ this.registry.clear();
444
+ this.loadedRenderableKeys.clear();
445
+ this.boundSections.clear();
446
+ this.controller = null;
447
+ this.sectionRenderableIds = null;
448
+ this.coordinator = null;
449
+ }
450
+ }