@pie-players/pie-assessment-toolkit 0.3.64 → 0.3.66

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 (74) hide show
  1. package/README.md +81 -33
  2. package/dist/components/ItemToolBar.custom-element.js +1 -1
  3. package/dist/components/PieAssessmentToolkit.custom-element.js +9 -8
  4. package/dist/components/SectionToolBar.custom-element.js +1 -1
  5. package/dist/components/chunks/ItemToolBar-8jgdz50p.js +51 -0
  6. package/dist/components/chunks/ItemToolBar-cvs646j3.js +36 -0
  7. package/dist/index.d.ts +7 -8
  8. package/dist/index.js +10 -5
  9. package/dist/policy/core/PolicySource.d.ts +2 -2
  10. package/dist/policy/core/PolicySource.js +2 -2
  11. package/dist/policy/core/ToolPolicyEngine.d.ts +7 -3
  12. package/dist/policy/core/ToolPolicyEngine.js +11 -3
  13. package/dist/policy/core/compose-decision.d.ts +3 -2
  14. package/dist/policy/core/compose-decision.js +3 -2
  15. package/dist/policy/core/decision-types.d.ts +2 -2
  16. package/dist/policy/core/decision-types.js +2 -2
  17. package/dist/policy/core/feature-decision.d.ts +28 -1
  18. package/dist/policy/core/feature-decision.js +25 -3
  19. package/dist/policy/core/pnp-policy-inputs.d.ts +2 -2
  20. package/dist/policy/core/pnp-policy-inputs.js +2 -2
  21. package/dist/policy/core/provenance.d.ts +4 -1
  22. package/dist/policy/core/provenance.js +4 -1
  23. package/dist/policy/sources/PnpPolicySource.d.ts +2 -1
  24. package/dist/policy/sources/PnpPolicySource.js +2 -1
  25. package/dist/runtime/composition-emit-scheduler.d.ts +78 -0
  26. package/dist/runtime/composition-emit-scheduler.js +154 -0
  27. package/dist/runtime/core/engine-resolver.d.ts +1 -1
  28. package/dist/runtime/core/engine-transition.js +3 -1
  29. package/dist/services/AccessibilityCatalogResolver.d.ts +49 -10
  30. package/dist/services/AccessibilityCatalogResolver.js +180 -11
  31. package/dist/services/TTSService.d.ts +11 -0
  32. package/dist/services/TTSService.js +220 -32
  33. package/dist/services/ToolRegistry.d.ts +259 -8
  34. package/dist/services/ToolRegistry.js +181 -11
  35. package/dist/services/ToolkitCoordinator.d.ts +20 -2
  36. package/dist/services/ToolkitCoordinator.js +47 -8
  37. package/dist/services/catalog-owner.d.ts +71 -0
  38. package/dist/services/catalog-owner.js +64 -0
  39. package/dist/services/createDefaultToolRegistry.d.ts +25 -58
  40. package/dist/services/createDefaultToolRegistry.js +24 -104
  41. package/dist/services/defaultPersonalNeedsProfile.d.ts +16 -14
  42. package/dist/services/defaultPersonalNeedsProfile.js +17 -38
  43. package/dist/services/framework-error.d.ts +1 -1
  44. package/dist/services/interfaces.d.ts +15 -3
  45. package/dist/services/pnp-standard-features.d.ts +1 -1
  46. package/dist/services/tool-config-defaults.d.ts +7 -23
  47. package/dist/services/tool-config-defaults.js +7 -46
  48. package/dist/services/tool-config-validation.d.ts +1 -1
  49. package/dist/services/tool-config-validation.js +44 -4
  50. package/dist/services/tts/browser-provider.js +191 -24
  51. package/dist/services/tts-runtime-config.js +7 -2
  52. package/dist/tools/internal.d.ts +35 -0
  53. package/dist/tools/internal.js +33 -0
  54. package/dist/tools/tool-tag-map.d.ts +15 -3
  55. package/dist/tools/tool-tag-map.js +21 -18
  56. package/package.json +14 -10
  57. package/dist/components/chunks/ItemToolBar-3cppre9r.js +0 -51
  58. package/dist/components/chunks/ItemToolBar-7rq2gj8b.js +0 -22
  59. package/dist/runtime/catalog-registration.d.ts +0 -67
  60. package/dist/runtime/catalog-registration.js +0 -86
  61. package/dist/services/sign-language-cards.d.ts +0 -82
  62. package/dist/services/sign-language-cards.js +0 -133
  63. package/dist/tools/registrations/accessibility-tools.d.ts +0 -34
  64. package/dist/tools/registrations/accessibility-tools.js +0 -217
  65. package/dist/tools/registrations/calculator.d.ts +0 -20
  66. package/dist/tools/registrations/calculator.js +0 -228
  67. package/dist/tools/registrations/interaction-tools.d.ts +0 -27
  68. package/dist/tools/registrations/interaction-tools.js +0 -143
  69. package/dist/tools/registrations/measurement-tools.d.ts +0 -24
  70. package/dist/tools/registrations/measurement-tools.js +0 -130
  71. package/dist/tools/registrations/subject-specific-tools.d.ts +0 -27
  72. package/dist/tools/registrations/subject-specific-tools.js +0 -158
  73. package/dist/tools/registrations/tts.d.ts +0 -21
  74. package/dist/tools/registrations/tts.js +0 -184
@@ -4,17 +4,32 @@
4
4
  * Central registry for all assessment tools. Manages tool metadata, visibility logic,
5
5
  * and button/instance creation. Supports dynamic registration and override by integrators.
6
6
  */
7
+ import type { CatalogOwnerSnapshot } from "./AccessibilityCatalogResolver.js";
7
8
  import type { ToolContext, ToolLevel } from "./tool-context.js";
8
9
  import type { ToolComponentOverrides } from "../tools/tool-tag-map.js";
9
- import type { ElementToolStateStoreApi, ToolCoordinatorApi, ToolkitCoordinatorApi, TtsServiceApi } from "./interfaces.js";
10
+ import type { AccessibilityCatalogResolverApi, ElementToolStateStoreApi, ToolCoordinatorApi, ToolkitCoordinatorApi, TtsServiceApi } from "./interfaces.js";
10
11
  import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
11
12
  import type { ToolProviderConfig as ToolRuntimeConfig } from "./tools-config-normalizer.js";
12
13
  import type { ToolConfigDiagnostic } from "./tool-config-validation.js";
13
14
  export type ToolModuleLoader = () => Promise<unknown>;
15
+ export type ToolRegistryChangeKind = "register" | "override" | "unregister" | "clear" | "component-overrides" | "module-loaders";
16
+ /** A successful mutation to discoverable or rendering registry state. */
17
+ export interface ToolRegistryChangeEvent {
18
+ kind: ToolRegistryChangeKind;
19
+ toolIds: readonly string[];
20
+ }
21
+ export type ToolRegistryChangeListener = (event: ToolRegistryChangeEvent) => void;
14
22
  export interface ToolToolbarButtonDefinition {
15
23
  toolId: string;
16
24
  label: string;
17
- icon: string;
25
+ /**
26
+ * Optional to match what the renderers already do: `ToolButton.svelte` and
27
+ * `ItemToolBar.svelte` both guard on `button.icon`, and `ToolbarItem.icon` is
28
+ * already optional, so requiring it here claimed a guarantee nothing relied
29
+ * on. A registration that renders a button still has to declare an icon —
30
+ * `assertToolRegistrationShape` enforces that.
31
+ */
32
+ icon?: string;
18
33
  ariaLabel: string;
19
34
  tooltip?: string;
20
35
  onClick: () => void;
@@ -148,8 +163,147 @@ export interface ToolToolbarRenderResult {
148
163
  sync?: () => void;
149
164
  subscribeActive?: (callback: (active: boolean) => void) => () => void;
150
165
  }
151
- export type ToolActivation = "toolbar-toggle" | "selection-gateway";
166
+ export type ToolActivation = "toolbar-toggle" | "selection-gateway" | "region";
152
167
  export type ToolSingletonScope = "section";
168
+ /**
169
+ * Services a host hands a capability rendering into one of its surfaces.
170
+ *
171
+ * Deliberately the same three references a toolbar tool reaches through
172
+ * `ToolbarContext`, and no more: a capability that needs the coordinator can ask
173
+ * it for anything else. Passing the host's own component or state would make the
174
+ * registration depend on which renderer mounted it.
175
+ */
176
+ export interface ToolSurfaceServices {
177
+ toolkitCoordinator: ToolkitCoordinatorApi | null;
178
+ ttsService: TtsServiceApi | null;
179
+ catalogResolver: AccessibilityCatalogResolverApi | null;
180
+ }
181
+ /**
182
+ * What a host tells a capability when asking it to fill a surface.
183
+ *
184
+ * `surface` is a host-defined slot name. Core defines none and validates only
185
+ * that a region capability claims at least one, so a host can open a new surface
186
+ * without a change here and a capability can declare which of a host's surfaces
187
+ * it fits. Section-player ships `"content-lead"` and `"content-media"` on item
188
+ * and passage cards, plus `"section-overlay"` at section scope.
189
+ *
190
+ * `content` carries whatever the capability's own `requiresAuthoredContent`
191
+ * resolved, so the host neither inspects nor names it — it hands back what the
192
+ * capability asked for.
193
+ */
194
+ export interface ToolSurfaceRenderContext {
195
+ toolId: string;
196
+ /** The PNP/AfA support id policy granted for this render. */
197
+ featureId: string;
198
+ /** Host slot being filled. */
199
+ surface: string;
200
+ /** Feature parameters from the policy decision, if any. */
201
+ parameters?: unknown;
202
+ /** Resolved content dependency, when the capability declares one. */
203
+ content?: unknown;
204
+ services: ToolSurfaceServices;
205
+ componentOverrides?: ToolComponentOverrides;
206
+ }
207
+ /**
208
+ * What a host tells a capability when asking whether the content it needs is
209
+ * present.
210
+ */
211
+ export interface ToolContentDependencyContext {
212
+ /** The PNP/AfA support id being resolved. */
213
+ featureId: string;
214
+ /** Feature parameters from the policy decision, if any. */
215
+ parameters?: unknown;
216
+ /**
217
+ * Owner-scoped catalog cards, or `null` when no resolver is available.
218
+ *
219
+ * The catalog module has already applied item/passage/model traversal and
220
+ * registration precedence. A capability interprets its own card type without
221
+ * reconstructing the owner scope or reading the raw entity.
222
+ */
223
+ catalogs: CatalogOwnerSnapshot | null;
224
+ /**
225
+ * Whether policy granted one of this capability's support ids.
226
+ *
227
+ * `false` reaches `resolve` only for a capability that declares
228
+ * {@link ToolRegistration.resolvesWithoutGrant}, and it is the signal that the
229
+ * capability must answer from the content alone. Everything else sees `true`,
230
+ * because the host asks about eligibility first and stops there.
231
+ */
232
+ granted: boolean;
233
+ }
234
+ /**
235
+ * A capability's declaration that it needs authored content to have anything to
236
+ * show, and the check that decides whether that content is present.
237
+ *
238
+ * This is the resource half of AfA's PNP/DRD pair. Signing needs an authored
239
+ * catalog card, braille a transcription, authored SSML a `<speak>` in that item.
240
+ * It is intrinsic to the capability, unlike eligibility tier, which is a property
241
+ * of the program.
242
+ *
243
+ * Two independent things follow from declaring it, and both used to be done by
244
+ * naming ids in core:
245
+ *
246
+ * 1. **Availability is grant AND content.** The host renders only when policy
247
+ * granted the feature *and* `resolve` returned something. Neither half
248
+ * implies the other and neither is a default, so a learner with the
249
+ * accommodation still sees nothing on an item that carries no resource — no
250
+ * dead affordance.
251
+ * 2. **It is not granted wholesale.** A host building a default grant list
252
+ * filters on this declaration instead of on a compile-time array of ids it
253
+ * cannot extend. `@pie-players/pie-default-tool-loaders` asserts its
254
+ * universal preset holds no id belonging to a capability that declares one.
255
+ *
256
+ * `resolve` returns the resolved content, which the host hands straight back
257
+ * through `ToolSurfaceRenderContext.content` without inspecting it. That is what
258
+ * keeps the resolver and the host from knowing which accommodation they are
259
+ * resolving.
260
+ *
261
+ * Resolvable only on a surface the host renders per item or per passage. The
262
+ * owner snapshot represents an item/model or passage, never a section, because a DRD
263
+ * resource pairs with a piece of content and not with a container. A capability
264
+ * declaring one and claiming a section-scoped surface is declined there rather
265
+ * than mounted with no content. `resolve` is also synchronous — a capability whose
266
+ * resource has to be fetched resolves the reference here and fetches inside its
267
+ * own element, where it can show its own pending state.
268
+ */
269
+ export interface ToolContentDependency {
270
+ /**
271
+ * The resolved content, or `null` when the item carries none.
272
+ *
273
+ * Must be JSON-serializable. A host re-resolves on every policy and catalog
274
+ * signal and compares the answer structurally to decide whether anything moved,
275
+ * because every resolution builds fresh objects and identity would report a
276
+ * change each time. A `Map`, function, DOM node, cyclic value, or other
277
+ * non-serializable result is rejected for that capability and reported as a
278
+ * recoverable surface warning instead of escaping into the player.
279
+ */
280
+ resolve(context: ToolContentDependencyContext): unknown | null;
281
+ /**
282
+ * Optional human-readable description of what has to be authored, for a
283
+ * policy debugger explaining why an otherwise-granted capability is absent.
284
+ */
285
+ description?: string;
286
+ }
287
+ export interface ToolSurfaceRenderResult {
288
+ /** Element for the host to mount into its surface. */
289
+ element: HTMLElement;
290
+ /** Accessible name for the surface, when the capability owns that wording. */
291
+ ariaLabel?: string;
292
+ /**
293
+ * Reapply props after policy, parameters or content change.
294
+ *
295
+ * Takes the current context rather than closing over the one captured at
296
+ * render: the whole point of reconciling by `toolId` instead of remounting is
297
+ * that a re-resolve reaches the mounted element, and a closure over the
298
+ * render-time context re-applies the values the host already had. A signed
299
+ * alternate re-resolved to a different recording — a live `signLang` change, or
300
+ * a catalog registering after first paint — would otherwise leave the learner
301
+ * watching the previous one with no error anywhere.
302
+ */
303
+ sync?: (context: ToolSurfaceRenderContext) => void;
304
+ /** Release listeners and media before the host unmounts the element. */
305
+ destroy?: () => void;
306
+ }
153
307
  /**
154
308
  * Tool registration interface
155
309
  */
@@ -160,16 +314,31 @@ export interface ToolRegistration {
160
314
  name: string;
161
315
  /** Description of what the tool does */
162
316
  description: string;
163
- /** Icon identifier or SVG string */
164
- icon: string | ((context: ToolContext) => string);
317
+ /**
318
+ * Icon identifier or SVG string. Required for the activations that render a
319
+ * toolbar button; a region capability has no button, so it has no icon.
320
+ */
321
+ icon?: string | ((context: ToolContext) => string);
165
322
  /** Which levels this tool supports */
166
323
  supportedLevels: ToolLevel[];
167
324
  /**
168
325
  * Activation model for this tool.
169
326
  * - toolbar-toggle: rendered as a toolbar button (default)
170
327
  * - selection-gateway: rendered as a singleton selection-driven gateway
328
+ * - region: rendered into a host surface, with no toolbar button
171
329
  */
172
330
  activation?: ToolActivation;
331
+ /**
332
+ * Host surfaces this capability can fill. Required for `activation: "region"`
333
+ * and meaningful for any activation whose capability also has a non-toolbar
334
+ * surface — the annotation toolbar is both a toolbar button and a
335
+ * section-scoped singleton.
336
+ *
337
+ * Names are the host's, not core's. A host discovers what it can mount by
338
+ * asking {@link ToolRegistry.getToolsBySurface}, which is what keeps a
339
+ * renderer from naming a capability.
340
+ */
341
+ surfaces?: string[];
173
342
  /**
174
343
  * Optional singleton scope for activation models that mount exactly one instance.
175
344
  */
@@ -180,6 +349,30 @@ export interface ToolRegistration {
180
349
  * Example: ['calculator', 'basic-calculator', 'scientific-calculator']
181
350
  */
182
351
  pnpSupportIds?: string[];
352
+ /**
353
+ * Authored content this capability needs before it has anything to show.
354
+ *
355
+ * Declaring it makes availability "grant AND content", and excludes the
356
+ * capability from any wholesale default grant. See
357
+ * {@link ToolContentDependency}.
358
+ */
359
+ requiresAuthoredContent?: ToolContentDependency;
360
+ /**
361
+ * Ask this capability for its content even when policy granted nothing.
362
+ *
363
+ * Only meaningful together with {@link ToolRegistration.requiresAuthoredContent},
364
+ * and only correct for a capability whose authored content can declare itself
365
+ * *presentation* rather than an accommodation — content authored to be delivered
366
+ * that way to everyone, which no profile grants and none revokes. Such a
367
+ * capability must return `null` from `resolve` when its content is the
368
+ * accommodation kind and the grant is absent, and the `granted` flag on the
369
+ * context is how it tells the two apart.
370
+ *
371
+ * Without this, "no grant" ends the question before content is consulted, which
372
+ * is the right default: it is what keeps an accommodation off the item of a
373
+ * learner with no documented need.
374
+ */
375
+ resolvesWithoutGrant?: boolean;
183
376
  /**
184
377
  * Optional provider registration metadata.
185
378
  * When present, ToolkitCoordinator can register provider(s) generically
@@ -196,12 +389,31 @@ export interface ToolRegistration {
196
389
  * Pass 2: Tool decides if it's relevant in this context
197
390
  * Called ONLY if orchestrator has already allowed the tool (Pass 1)
198
391
  *
392
+ * Required for the toolbar activations, and meaningless for `activation:
393
+ * "region"`: a region capability has no toolbar presence to be relevant to, and
394
+ * the question it *would* answer — is there anything to show here — is
395
+ * `requiresAuthoredContent`. A registration that omits this is never returned
396
+ * by `getVisibleTools`.
397
+ *
199
398
  * @param context - Rich context about where tool is being evaluated
200
399
  * @returns true if tool should be visible, false to hide
201
400
  */
202
- isVisibleInContext(context: ToolContext): boolean;
203
- /** Required toolbar-first render contract. */
204
- renderToolbar(context: ToolContext, toolbarContext: ToolbarContext): ToolToolbarRenderResult | null;
401
+ isVisibleInContext?(context: ToolContext): boolean;
402
+ /**
403
+ * Toolbar render contract. Required for `toolbar-toggle` and
404
+ * `selection-gateway`; a region capability renders through
405
+ * {@link ToolRegistration.renderSurface} instead.
406
+ */
407
+ renderToolbar?(context: ToolContext, toolbarContext: ToolbarContext): ToolToolbarRenderResult | null;
408
+ /**
409
+ * Render into one of the host surfaces this capability declares.
410
+ *
411
+ * Returning `null` means "nothing to show for this render" and is not an
412
+ * error — a capability may decline once the host has already granted and
413
+ * resolved content. The host mounts the returned element and calls `sync()`
414
+ * when policy, parameters or content move.
415
+ */
416
+ renderSurface?(context: ToolSurfaceRenderContext): ToolSurfaceRenderResult | null;
205
417
  }
206
418
  /**
207
419
  * Tool Registry
@@ -215,6 +427,13 @@ export declare class ToolRegistry {
215
427
  private moduleLoaders;
216
428
  private loadedToolModules;
217
429
  private moduleLoadPromises;
430
+ private changeListeners;
431
+ private emitChange;
432
+ /**
433
+ * Observe successful registry mutations. Delivery is synchronous, listener
434
+ * failures are isolated, and the returned unsubscribe is idempotent.
435
+ */
436
+ onRegistryChange(listener: ToolRegistryChangeListener): () => void;
218
437
  /**
219
438
  * Normalize a single tool alias to canonical toolId.
220
439
  */
@@ -290,6 +509,23 @@ export declare class ToolRegistry {
290
509
  * Resolve singleton scope for a tool when present.
291
510
  */
292
511
  getToolSingletonScope(toolId: string): ToolSingletonScope | null;
512
+ /**
513
+ * Registrations that can fill a named host surface.
514
+ *
515
+ * The discovery call a renderer makes instead of naming a capability. Order
516
+ * follows registration order, so a host mounting several capabilities into one
517
+ * surface gets a stable sequence without core deciding a precedence it has no
518
+ * basis for.
519
+ */
520
+ getToolsBySurface(surface: string): ToolRegistration[];
521
+ /**
522
+ * Support ids belonging to capabilities that need authored content.
523
+ *
524
+ * What a host filters a default grant list on, in place of the compile-time
525
+ * exclusion array this replaced: granting one of these wholesale grants an
526
+ * accommodation to learners with no documented need for it.
527
+ */
528
+ getContentDependentSupportIds(): string[];
293
529
  /**
294
530
  * Filter tool IDs by activation type.
295
531
  */
@@ -319,6 +555,9 @@ export declare class ToolRegistry {
319
555
  supportedLevels: ToolLevel[];
320
556
  activation: ToolActivation;
321
557
  singletonScope: ToolSingletonScope | null;
558
+ surfaces: string[];
559
+ requiresAuthoredContent: boolean;
560
+ contentDependencyDescription: string | null;
322
561
  }>;
323
562
  /**
324
563
  * Generate PNP support IDs from enabled tools
@@ -358,4 +597,16 @@ export declare class ToolRegistry {
358
597
  * Render a tool for toolbar use with component overrides attached.
359
598
  */
360
599
  renderForToolbar(toolId: string, context: ToolContext, toolbarContext: ToolbarContext): ToolToolbarRenderResult | null;
600
+ /**
601
+ * Render a capability into a host surface, with component overrides attached.
602
+ *
603
+ * The surface counterpart of {@link renderForToolbar}, and it exists for the
604
+ * same reason: the registry owns the component-override map, so a host calling
605
+ * `registration.renderSurface(...)` directly would resolve element tags against
606
+ * nothing and fail on every packaged capability. Overrides passed in the
607
+ * context still win, matching the toolbar path's precedence.
608
+ */
609
+ renderForSurface(toolId: string, context: Omit<ToolSurfaceRenderContext, "componentOverrides"> & {
610
+ componentOverrides?: ToolComponentOverrides;
611
+ }): ToolSurfaceRenderResult | null;
361
612
  }
@@ -55,9 +55,15 @@ function assertToolRegistrationShape(registration) {
55
55
  assertNonEmptyString(registration.toolId, "toolId");
56
56
  assertNonEmptyString(registration.name, "name");
57
57
  assertNonEmptyString(registration.description, "description");
58
- if (typeof registration.icon !== "string" &&
59
- typeof registration.icon !== "function") {
60
- throw new Error(`Invalid tool registration "${registration.toolId}": "icon" must be a string or function.`);
58
+ // A region capability renders into a host surface and has no toolbar button,
59
+ // so it needs neither an icon nor `renderToolbar`. Both stay required for the
60
+ // activations that do render a button, so no existing registration is relaxed.
61
+ const isRegion = registration.activation === "region";
62
+ if (!isRegion || registration.icon !== undefined) {
63
+ if (typeof registration.icon !== "string" &&
64
+ typeof registration.icon !== "function") {
65
+ throw new Error(`Invalid tool registration "${registration.toolId}": "icon" must be a string or function.`);
66
+ }
61
67
  }
62
68
  if (typeof registration.icon === "string") {
63
69
  assertIconStringIsSafe(registration.toolId, registration.icon, "icon");
@@ -72,9 +78,41 @@ function assertToolRegistrationShape(registration) {
72
78
  }
73
79
  if (registration.activation !== undefined &&
74
80
  registration.activation !== "toolbar-toggle" &&
75
- registration.activation !== "selection-gateway") {
81
+ registration.activation !== "selection-gateway" &&
82
+ registration.activation !== "region") {
76
83
  throw new Error(`Invalid tool registration "${registration.toolId}": unsupported activation "${String(registration.activation)}".`);
77
84
  }
85
+ if (registration.surfaces !== undefined &&
86
+ (!Array.isArray(registration.surfaces) ||
87
+ registration.surfaces.some((surface) => typeof surface !== "string" || surface.trim().length === 0))) {
88
+ throw new Error(`Invalid tool registration "${registration.toolId}": "surfaces" must be an array of non-empty strings.`);
89
+ }
90
+ if (isRegion && !registration.surfaces?.length) {
91
+ throw new Error(`Invalid tool registration "${registration.toolId}": region tools must declare at least one host surface in "surfaces".`);
92
+ }
93
+ if (isRegion && typeof registration.renderSurface !== "function") {
94
+ throw new Error(`Invalid tool registration "${registration.toolId}": region tools must implement "renderSurface".`);
95
+ }
96
+ if (registration.resolvesWithoutGrant !== undefined &&
97
+ typeof registration.resolvesWithoutGrant !== "boolean") {
98
+ throw new Error(`Invalid tool registration "${registration.toolId}": "resolvesWithoutGrant" must be a boolean.`);
99
+ }
100
+ if (registration.resolvesWithoutGrant &&
101
+ !registration.requiresAuthoredContent) {
102
+ // The flag only decides whether content is consulted without a grant, so on a
103
+ // capability with no content dependency it reads as "granted to everyone" and
104
+ // does nothing at all.
105
+ throw new Error(`Invalid tool registration "${registration.toolId}": "resolvesWithoutGrant" requires "requiresAuthoredContent".`);
106
+ }
107
+ if (registration.renderSurface !== undefined &&
108
+ typeof registration.renderSurface !== "function") {
109
+ throw new Error(`Invalid tool registration "${registration.toolId}": "renderSurface" must be a function.`);
110
+ }
111
+ if (registration.renderSurface && !registration.surfaces?.length) {
112
+ // A surface renderer nothing can find is a registration that silently does
113
+ // not render, which is the failure mode this mechanism exists to remove.
114
+ throw new Error(`Invalid tool registration "${registration.toolId}": "renderSurface" requires at least one entry in "surfaces".`);
115
+ }
78
116
  if (registration.singletonScope !== undefined &&
79
117
  registration.singletonScope !== "section") {
80
118
  throw new Error(`Invalid tool registration "${registration.toolId}": unsupported singletonScope "${String(registration.singletonScope)}".`);
@@ -88,10 +126,33 @@ function assertToolRegistrationShape(registration) {
88
126
  registration.pnpSupportIds.some((pnpId) => typeof pnpId !== "string" || pnpId.trim().length === 0))) {
89
127
  throw new Error(`Invalid tool registration "${registration.toolId}": "pnpSupportIds" must be an array of non-empty strings.`);
90
128
  }
91
- if (typeof registration.isVisibleInContext !== "function") {
129
+ if (registration.activation !== "region" &&
130
+ typeof registration.isVisibleInContext !== "function") {
92
131
  throw new Error(`Invalid tool registration "${registration.toolId}": "isVisibleInContext" must be a function.`);
93
132
  }
94
- if (typeof registration.renderToolbar !== "function") {
133
+ if (registration.isVisibleInContext !== undefined &&
134
+ typeof registration.isVisibleInContext !== "function") {
135
+ throw new Error(`Invalid tool registration "${registration.toolId}": "isVisibleInContext" must be a function when present.`);
136
+ }
137
+ if (registration.requiresAuthoredContent !== undefined) {
138
+ if (typeof registration.requiresAuthoredContent !== "object" ||
139
+ registration.requiresAuthoredContent === null ||
140
+ typeof registration.requiresAuthoredContent.resolve !== "function") {
141
+ throw new Error(`Invalid tool registration "${registration.toolId}": "requiresAuthoredContent" must be an object with a "resolve" function.`);
142
+ }
143
+ if (!registration.pnpSupportIds?.length) {
144
+ // A content dependency's second job is keeping the capability out of a
145
+ // wholesale grant, and a host filters that by support id. Declaring one
146
+ // with no id to filter on would silently drop that guarantee.
147
+ throw new Error(`Invalid tool registration "${registration.toolId}": "requiresAuthoredContent" requires at least one entry in "pnpSupportIds", which is what a host filters a default grant list on.`);
148
+ }
149
+ }
150
+ if (registration.renderToolbar !== undefined) {
151
+ if (typeof registration.renderToolbar !== "function") {
152
+ throw new Error(`Invalid tool registration "${registration.toolId}": "renderToolbar" must be a function.`);
153
+ }
154
+ }
155
+ else if (!isRegion) {
95
156
  throw new Error(`Invalid tool registration "${registration.toolId}": "renderToolbar" must be a function.`);
96
157
  }
97
158
  }
@@ -107,6 +168,31 @@ export class ToolRegistry {
107
168
  moduleLoaders = new Map();
108
169
  loadedToolModules = new Set();
109
170
  moduleLoadPromises = new Map();
171
+ changeListeners = new Set();
172
+ emitChange(event) {
173
+ for (const listener of this.changeListeners) {
174
+ try {
175
+ listener(event);
176
+ }
177
+ catch (error) {
178
+ console.warn("[ToolRegistry] change listener failed:", error);
179
+ }
180
+ }
181
+ }
182
+ /**
183
+ * Observe successful registry mutations. Delivery is synchronous, listener
184
+ * failures are isolated, and the returned unsubscribe is idempotent.
185
+ */
186
+ onRegistryChange(listener) {
187
+ this.changeListeners.add(listener);
188
+ let subscribed = true;
189
+ return () => {
190
+ if (!subscribed)
191
+ return;
192
+ subscribed = false;
193
+ this.changeListeners.delete(listener);
194
+ };
195
+ }
110
196
  /**
111
197
  * Normalize a single tool alias to canonical toolId.
112
198
  */
@@ -140,6 +226,7 @@ export class ToolRegistry {
140
226
  this.pnpIndex.get(pnpId).add(registration.toolId);
141
227
  }
142
228
  }
229
+ this.emitChange({ kind: "register", toolIds: [registration.toolId] });
143
230
  }
144
231
  /**
145
232
  * Override an existing tool registration
@@ -169,6 +256,7 @@ export class ToolRegistry {
169
256
  this.pnpIndex.get(pnpId).add(registration.toolId);
170
257
  }
171
258
  }
259
+ this.emitChange({ kind: "override", toolIds: [registration.toolId] });
172
260
  }
173
261
  /**
174
262
  * Unregister a tool
@@ -186,6 +274,7 @@ export class ToolRegistry {
186
274
  }
187
275
  }
188
276
  this.tools.delete(toolId);
277
+ this.emitChange({ kind: "unregister", toolIds: [toolId] });
189
278
  }
190
279
  /**
191
280
  * Get a tool registration by ID
@@ -251,6 +340,37 @@ export class ToolRegistry {
251
340
  getToolSingletonScope(toolId) {
252
341
  return this.get(toolId)?.singletonScope || null;
253
342
  }
343
+ /**
344
+ * Registrations that can fill a named host surface.
345
+ *
346
+ * The discovery call a renderer makes instead of naming a capability. Order
347
+ * follows registration order, so a host mounting several capabilities into one
348
+ * surface gets a stable sequence without core deciding a precedence it has no
349
+ * basis for.
350
+ */
351
+ getToolsBySurface(surface) {
352
+ if (!surface)
353
+ return [];
354
+ return this.getAllTools().filter((tool) => typeof tool.renderSurface === "function" &&
355
+ tool.surfaces?.includes(surface));
356
+ }
357
+ /**
358
+ * Support ids belonging to capabilities that need authored content.
359
+ *
360
+ * What a host filters a default grant list on, in place of the compile-time
361
+ * exclusion array this replaced: granting one of these wholesale grants an
362
+ * accommodation to learners with no documented need for it.
363
+ */
364
+ getContentDependentSupportIds() {
365
+ const ids = new Set();
366
+ for (const tool of this.getAllTools()) {
367
+ if (!tool.requiresAuthoredContent)
368
+ continue;
369
+ for (const supportId of tool.pnpSupportIds || [])
370
+ ids.add(supportId);
371
+ }
372
+ return [...ids].sort();
373
+ }
254
374
  /**
255
375
  * Filter tool IDs by activation type.
256
376
  */
@@ -279,9 +399,10 @@ export class ToolRegistry {
279
399
  if (!tool.supportedLevels.includes(context.level)) {
280
400
  continue;
281
401
  }
282
- // Pass 2: Ask tool if it's relevant
402
+ // Pass 2: Ask tool if it's relevant. A region capability declares no
403
+ // answer and has no toolbar presence, so it is never visible here.
283
404
  try {
284
- if (tool.isVisibleInContext(context)) {
405
+ if (tool.isVisibleInContext?.(context)) {
285
406
  visible.push(tool);
286
407
  }
287
408
  }
@@ -306,6 +427,9 @@ export class ToolRegistry {
306
427
  supportedLevels: tool.supportedLevels,
307
428
  activation: tool.activation || "toolbar-toggle",
308
429
  singletonScope: tool.singletonScope || null,
430
+ surfaces: tool.surfaces || [],
431
+ requiresAuthoredContent: Boolean(tool.requiresAuthoredContent),
432
+ contentDependencyDescription: tool.requiresAuthoredContent?.description ?? null,
309
433
  }));
310
434
  }
311
435
  /**
@@ -331,28 +455,43 @@ export class ToolRegistry {
331
455
  * Clear all registrations (useful for testing)
332
456
  */
333
457
  clear() {
458
+ const toolIds = this.getAllToolIds();
459
+ if (toolIds.length === 0)
460
+ return;
334
461
  this.tools.clear();
335
462
  this.pnpIndex.clear();
463
+ this.emitChange({ kind: "clear", toolIds });
336
464
  }
337
465
  /**
338
466
  * Configure global component overrides used by tool instance creation.
339
467
  */
340
468
  setComponentOverrides(overrides) {
469
+ if (this.componentOverrides === overrides)
470
+ return;
341
471
  this.componentOverrides = overrides;
472
+ this.emitChange({ kind: "component-overrides", toolIds: [] });
342
473
  }
343
474
  /**
344
475
  * Register lazy module loaders by toolId.
345
476
  * Toolbars call ensureToolModuleLoaded(toolId) before instance creation.
346
477
  */
347
478
  setToolModuleLoaders(loaders) {
348
- for (const [toolId, loader] of Object.entries(loaders)) {
349
- if (!loader)
350
- continue;
479
+ const entries = Object.entries(loaders).filter((entry) => entry[1] !== undefined);
480
+ for (const [toolId, loader] of entries) {
351
481
  assertNonEmptyString(toolId, "tool module loader id");
352
482
  if (typeof loader !== "function") {
353
483
  throw new Error(`Invalid tool module loader for "${toolId}": expected a function.`);
354
484
  }
485
+ }
486
+ const changedToolIds = [];
487
+ for (const [toolId, loader] of entries) {
488
+ if (this.moduleLoaders.get(toolId) === loader)
489
+ continue;
355
490
  this.moduleLoaders.set(toolId, loader);
491
+ changedToolIds.push(toolId);
492
+ }
493
+ if (changedToolIds.length > 0) {
494
+ this.emitChange({ kind: "module-loaders", toolIds: changedToolIds });
356
495
  }
357
496
  }
358
497
  /**
@@ -402,6 +541,12 @@ export class ToolRegistry {
402
541
  if (!tool) {
403
542
  throw new Error(`Tool '${toolId}' is not registered`);
404
543
  }
544
+ if (typeof tool.renderToolbar !== "function") {
545
+ // Naming the activation rather than "renderToolbar is not a function":
546
+ // the caller's mistake is asking a surface capability for a toolbar
547
+ // button, and it is fixed by placement config, not by the registration.
548
+ throw new Error(`Tool '${toolId}' has activation "${tool.activation || "toolbar-toggle"}" and renders into a host surface, not a toolbar. Remove it from toolbar placement.`);
549
+ }
405
550
  const mergedContext = {
406
551
  ...toolbarContext,
407
552
  componentOverrides: {
@@ -411,4 +556,29 @@ export class ToolRegistry {
411
556
  };
412
557
  return tool.renderToolbar(context, mergedContext);
413
558
  }
559
+ /**
560
+ * Render a capability into a host surface, with component overrides attached.
561
+ *
562
+ * The surface counterpart of {@link renderForToolbar}, and it exists for the
563
+ * same reason: the registry owns the component-override map, so a host calling
564
+ * `registration.renderSurface(...)` directly would resolve element tags against
565
+ * nothing and fail on every packaged capability. Overrides passed in the
566
+ * context still win, matching the toolbar path's precedence.
567
+ */
568
+ renderForSurface(toolId, context) {
569
+ const tool = this.get(toolId);
570
+ if (!tool) {
571
+ throw new Error(`Tool '${toolId}' is not registered`);
572
+ }
573
+ if (typeof tool.renderSurface !== "function") {
574
+ throw new Error(`Tool '${toolId}' does not render into a host surface. Surface capabilities declare "surfaces" and implement "renderSurface".`);
575
+ }
576
+ return tool.renderSurface({
577
+ ...context,
578
+ componentOverrides: {
579
+ ...(this.componentOverrides || {}),
580
+ ...(context.componentOverrides || {}),
581
+ },
582
+ });
583
+ }
414
584
  }