@pie-players/pie-assessment-toolkit 0.3.63 → 0.3.65

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 (67) hide show
  1. package/README.md +92 -1
  2. package/dist/components/ItemToolBar.custom-element.js +1 -1
  3. package/dist/components/PieAssessmentToolkit.custom-element.js +11 -11
  4. package/dist/components/SectionToolBar.custom-element.js +1 -1
  5. package/dist/components/chunks/ItemToolBar-cckwpz6c.js +51 -0
  6. package/dist/components/chunks/ItemToolBar-pryf0rtz.js +22 -0
  7. package/dist/index.d.ts +10 -6
  8. package/dist/index.js +12 -4
  9. package/dist/policy/core/ToolPolicyEngine.d.ts +21 -0
  10. package/dist/policy/core/ToolPolicyEngine.js +27 -0
  11. package/dist/policy/core/feature-decision.d.ts +57 -0
  12. package/dist/policy/core/feature-decision.js +40 -0
  13. package/dist/policy/engine.d.ts +1 -0
  14. package/dist/policy/sources/PnpPolicySource.d.ts +22 -0
  15. package/dist/policy/sources/PnpPolicySource.js +41 -11
  16. package/dist/runtime/catalog-registration.d.ts +56 -1
  17. package/dist/runtime/catalog-registration.js +64 -31
  18. package/dist/runtime/composition-emit-scheduler.d.ts +78 -0
  19. package/dist/runtime/composition-emit-scheduler.js +154 -0
  20. package/dist/runtime/core/engine-resolver.d.ts +1 -1
  21. package/dist/services/AccessibilityCatalogResolver.d.ts +100 -4
  22. package/dist/services/AccessibilityCatalogResolver.js +183 -58
  23. package/dist/services/SSMLExtractor.js +28 -18
  24. package/dist/services/TTSService.d.ts +25 -0
  25. package/dist/services/TTSService.js +241 -45
  26. package/dist/services/ToolRegistry.d.ts +218 -8
  27. package/dist/services/ToolRegistry.js +124 -8
  28. package/dist/services/ToolkitCoordinator.d.ts +25 -3
  29. package/dist/services/ToolkitCoordinator.js +47 -6
  30. package/dist/services/catalog-media.d.ts +25 -0
  31. package/dist/services/catalog-media.js +101 -0
  32. package/dist/services/createDefaultToolRegistry.d.ts +25 -58
  33. package/dist/services/createDefaultToolRegistry.js +24 -104
  34. package/dist/services/defaultPersonalNeedsProfile.d.ts +20 -2
  35. package/dist/services/defaultPersonalNeedsProfile.js +21 -19
  36. package/dist/services/interfaces.d.ts +29 -2
  37. package/dist/services/pnp-standard-features.d.ts +1 -1
  38. package/dist/services/spoken-audio-cards.d.ts +54 -0
  39. package/dist/services/spoken-audio-cards.js +66 -0
  40. package/dist/services/tool-config-defaults.d.ts +7 -23
  41. package/dist/services/tool-config-defaults.js +7 -46
  42. package/dist/services/tool-config-validation.d.ts +1 -1
  43. package/dist/services/tool-config-validation.js +44 -4
  44. package/dist/services/tts/browser-provider.js +2 -1
  45. package/dist/services/tts/math-aware-text-processing.js +3 -3
  46. package/dist/services/tts/text-processing.d.ts +51 -0
  47. package/dist/services/tts/text-processing.js +117 -1
  48. package/dist/services/tts-runtime-config.js +7 -2
  49. package/dist/tools/internal.d.ts +34 -0
  50. package/dist/tools/internal.js +33 -0
  51. package/dist/tools/tool-tag-map.d.ts +15 -3
  52. package/dist/tools/tool-tag-map.js +21 -18
  53. package/package.json +14 -10
  54. package/dist/components/chunks/ItemToolBar-843902tp.js +0 -51
  55. package/dist/components/chunks/ItemToolBar-84nv78dy.js +0 -22
  56. package/dist/tools/registrations/accessibility-tools.d.ts +0 -34
  57. package/dist/tools/registrations/accessibility-tools.js +0 -217
  58. package/dist/tools/registrations/calculator.d.ts +0 -20
  59. package/dist/tools/registrations/calculator.js +0 -228
  60. package/dist/tools/registrations/interaction-tools.d.ts +0 -27
  61. package/dist/tools/registrations/interaction-tools.js +0 -143
  62. package/dist/tools/registrations/measurement-tools.d.ts +0 -24
  63. package/dist/tools/registrations/measurement-tools.js +0 -130
  64. package/dist/tools/registrations/subject-specific-tools.d.ts +0 -27
  65. package/dist/tools/registrations/subject-specific-tools.js +0 -158
  66. package/dist/tools/registrations/tts.d.ts +0 -21
  67. package/dist/tools/registrations/tts.js +0 -184
@@ -4,9 +4,11 @@
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 { ItemEntity } from "@pie-players/pie-players-shared/types";
8
+ import type { CatalogOwnerContext } from "./AccessibilityCatalogResolver.js";
7
9
  import type { ToolContext, ToolLevel } from "./tool-context.js";
8
10
  import type { ToolComponentOverrides } from "../tools/tool-tag-map.js";
9
- import type { ElementToolStateStoreApi, ToolCoordinatorApi, ToolkitCoordinatorApi, TtsServiceApi } from "./interfaces.js";
11
+ import type { AccessibilityCatalogResolverApi, ElementToolStateStoreApi, ToolCoordinatorApi, ToolkitCoordinatorApi, TtsServiceApi } from "./interfaces.js";
10
12
  import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
11
13
  import type { ToolProviderConfig as ToolRuntimeConfig } from "./tools-config-normalizer.js";
12
14
  import type { ToolConfigDiagnostic } from "./tool-config-validation.js";
@@ -14,7 +16,14 @@ export type ToolModuleLoader = () => Promise<unknown>;
14
16
  export interface ToolToolbarButtonDefinition {
15
17
  toolId: string;
16
18
  label: string;
17
- icon: string;
19
+ /**
20
+ * Optional to match what the renderers already do: `ToolButton.svelte` and
21
+ * `ItemToolBar.svelte` both guard on `button.icon`, and `ToolbarItem.icon` is
22
+ * already optional, so requiring it here claimed a guarantee nothing relied
23
+ * on. A registration that renders a button still has to declare an icon —
24
+ * `assertToolRegistrationShape` enforces that.
25
+ */
26
+ icon?: string;
18
27
  ariaLabel: string;
19
28
  tooltip?: string;
20
29
  onClick: () => void;
@@ -148,8 +157,135 @@ export interface ToolToolbarRenderResult {
148
157
  sync?: () => void;
149
158
  subscribeActive?: (callback: (active: boolean) => void) => () => void;
150
159
  }
151
- export type ToolActivation = "toolbar-toggle" | "selection-gateway";
160
+ export type ToolActivation = "toolbar-toggle" | "selection-gateway" | "region";
152
161
  export type ToolSingletonScope = "section";
162
+ /**
163
+ * Services a host hands a capability rendering into one of its surfaces.
164
+ *
165
+ * Deliberately the same three references a toolbar tool reaches through
166
+ * `ToolbarContext`, and no more: a capability that needs the coordinator can ask
167
+ * it for anything else. Passing the host's own component or state would make the
168
+ * registration depend on which renderer mounted it.
169
+ */
170
+ export interface ToolSurfaceServices {
171
+ toolkitCoordinator: ToolkitCoordinatorApi | null;
172
+ ttsService: TtsServiceApi | null;
173
+ catalogResolver: AccessibilityCatalogResolverApi | null;
174
+ }
175
+ /**
176
+ * What a host tells a capability when asking it to fill a surface.
177
+ *
178
+ * `surface` is a host-defined slot name. Core defines none and validates only
179
+ * that a region capability claims at least one, so a host can open a new surface
180
+ * without a change here and a capability can declare which of a host's surfaces
181
+ * it fits. Section-player ships `"content-media"` (the media region on an item
182
+ * or passage card) and `"section-overlay"` (the section-scoped singleton).
183
+ *
184
+ * `content` carries whatever the capability's own `requiresAuthoredContent`
185
+ * resolved, so the host neither inspects nor names it — it hands back what the
186
+ * capability asked for.
187
+ */
188
+ export interface ToolSurfaceRenderContext {
189
+ toolId: string;
190
+ /** The PNP/AfA support id policy granted for this render. */
191
+ featureId: string;
192
+ /** Host slot being filled. */
193
+ surface: string;
194
+ /** Feature parameters from the policy decision, if any. */
195
+ parameters?: unknown;
196
+ /** Resolved content dependency, when the capability declares one. */
197
+ content?: unknown;
198
+ services: ToolSurfaceServices;
199
+ componentOverrides?: ToolComponentOverrides;
200
+ }
201
+ /**
202
+ * What a host tells a capability when asking whether the content it needs is
203
+ * present.
204
+ */
205
+ export interface ToolContentDependencyContext {
206
+ /** The PNP/AfA support id being resolved. */
207
+ featureId: string;
208
+ /** Feature parameters from the policy decision, if any. */
209
+ parameters?: unknown;
210
+ catalogResolver: AccessibilityCatalogResolverApi | null;
211
+ /** Owner scope for catalog lookups, without `modelId`. */
212
+ ownerContext: CatalogOwnerContext;
213
+ /** The item in scope, when the host renders per item. */
214
+ item?: ItemEntity | null;
215
+ }
216
+ /**
217
+ * A capability's declaration that it needs authored content to have anything to
218
+ * show, and the check that decides whether that content is present.
219
+ *
220
+ * This is the resource half of AfA's PNP/DRD pair. Signing needs an authored
221
+ * catalog card, braille a transcription, authored SSML a `<speak>` in that item.
222
+ * It is intrinsic to the capability, unlike eligibility tier, which is a property
223
+ * of the program.
224
+ *
225
+ * Two independent things follow from declaring it, and both used to be done by
226
+ * naming ids in core:
227
+ *
228
+ * 1. **Availability is grant AND content.** The host renders only when policy
229
+ * granted the feature *and* `resolve` returned something. Neither half
230
+ * implies the other and neither is a default, so a learner with the
231
+ * accommodation still sees nothing on an item that carries no resource — no
232
+ * dead affordance.
233
+ * 2. **It is not granted wholesale.** A host building a default grant list
234
+ * filters on this declaration instead of on a compile-time array of ids it
235
+ * cannot extend. `@pie-players/pie-default-tool-loaders` asserts its
236
+ * universal preset holds no id belonging to a capability that declares one.
237
+ *
238
+ * `resolve` returns the resolved content, which the host hands straight back
239
+ * through `ToolSurfaceRenderContext.content` without inspecting it. That is what
240
+ * keeps the resolver and the host from knowing which accommodation they are
241
+ * resolving.
242
+ *
243
+ * Resolvable only on a surface the host renders per item or per passage:
244
+ * `ownerContext` names an item model or a passage, never a section, because a DRD
245
+ * resource pairs with a piece of content and not with a container. A capability
246
+ * declaring one and claiming a section-scoped surface is declined there rather
247
+ * than mounted with no content. `resolve` is also synchronous — a capability whose
248
+ * resource has to be fetched resolves the reference here and fetches inside its
249
+ * own element, where it can show its own pending state.
250
+ */
251
+ export interface ToolContentDependency {
252
+ /**
253
+ * The resolved content, or `null` when the item carries none.
254
+ *
255
+ * Must be JSON-serializable. A host re-resolves on every policy and catalog
256
+ * signal and compares the answer structurally to decide whether anything moved,
257
+ * because every resolution builds fresh objects and identity would report a
258
+ * change each time. A `Map`, a function or a DOM node therefore compares equal
259
+ * to itself across a real change and the capability never hears about it; a
260
+ * cyclic value throws inside the host's own reconciliation.
261
+ */
262
+ resolve(context: ToolContentDependencyContext): unknown | null;
263
+ /**
264
+ * Optional human-readable description of what has to be authored, for a
265
+ * policy debugger explaining why an otherwise-granted capability is absent.
266
+ */
267
+ description?: string;
268
+ }
269
+ export interface ToolSurfaceRenderResult {
270
+ /** Element for the host to mount into its surface. */
271
+ element: HTMLElement;
272
+ /** Accessible name for the surface, when the capability owns that wording. */
273
+ ariaLabel?: string;
274
+ /**
275
+ * Reapply props after policy, parameters or content change.
276
+ *
277
+ * Takes the current context rather than closing over the one captured at
278
+ * render: the whole point of reconciling by `toolId` instead of remounting is
279
+ * that a re-resolve reaches the mounted element, and a closure over the
280
+ * render-time context re-applies the values the host already had. A signed
281
+ * alternate re-resolved to a different recording — a live `signLang` change, or
282
+ * a catalog registering after first paint — would otherwise leave the learner
283
+ * watching the previous one with no error anywhere.
284
+ */
285
+ sync?: (context: ToolSurfaceRenderContext) => void;
286
+ /** Release listeners and media before the host unmounts the element. */
287
+ destroy?: () => void;
288
+ }
153
289
  /**
154
290
  * Tool registration interface
155
291
  */
@@ -160,16 +296,31 @@ export interface ToolRegistration {
160
296
  name: string;
161
297
  /** Description of what the tool does */
162
298
  description: string;
163
- /** Icon identifier or SVG string */
164
- icon: string | ((context: ToolContext) => string);
299
+ /**
300
+ * Icon identifier or SVG string. Required for the activations that render a
301
+ * toolbar button; a region capability has no button, so it has no icon.
302
+ */
303
+ icon?: string | ((context: ToolContext) => string);
165
304
  /** Which levels this tool supports */
166
305
  supportedLevels: ToolLevel[];
167
306
  /**
168
307
  * Activation model for this tool.
169
308
  * - toolbar-toggle: rendered as a toolbar button (default)
170
309
  * - selection-gateway: rendered as a singleton selection-driven gateway
310
+ * - region: rendered into a host surface, with no toolbar button
171
311
  */
172
312
  activation?: ToolActivation;
313
+ /**
314
+ * Host surfaces this capability can fill. Required for `activation: "region"`
315
+ * and meaningful for any activation whose capability also has a non-toolbar
316
+ * surface — the annotation toolbar is both a toolbar button and a
317
+ * section-scoped singleton.
318
+ *
319
+ * Names are the host's, not core's. A host discovers what it can mount by
320
+ * asking {@link ToolRegistry.getToolsBySurface}, which is what keeps a
321
+ * renderer from naming a capability.
322
+ */
323
+ surfaces?: string[];
173
324
  /**
174
325
  * Optional singleton scope for activation models that mount exactly one instance.
175
326
  */
@@ -180,6 +331,14 @@ export interface ToolRegistration {
180
331
  * Example: ['calculator', 'basic-calculator', 'scientific-calculator']
181
332
  */
182
333
  pnpSupportIds?: string[];
334
+ /**
335
+ * Authored content this capability needs before it has anything to show.
336
+ *
337
+ * Declaring it makes availability "grant AND content", and excludes the
338
+ * capability from any wholesale default grant. See
339
+ * {@link ToolContentDependency}.
340
+ */
341
+ requiresAuthoredContent?: ToolContentDependency;
183
342
  /**
184
343
  * Optional provider registration metadata.
185
344
  * When present, ToolkitCoordinator can register provider(s) generically
@@ -196,12 +355,31 @@ export interface ToolRegistration {
196
355
  * Pass 2: Tool decides if it's relevant in this context
197
356
  * Called ONLY if orchestrator has already allowed the tool (Pass 1)
198
357
  *
358
+ * Required for the toolbar activations, and meaningless for `activation:
359
+ * "region"`: a region capability has no toolbar presence to be relevant to, and
360
+ * the question it *would* answer — is there anything to show here — is
361
+ * `requiresAuthoredContent`. A registration that omits this is never returned
362
+ * by `getVisibleTools`.
363
+ *
199
364
  * @param context - Rich context about where tool is being evaluated
200
365
  * @returns true if tool should be visible, false to hide
201
366
  */
202
- isVisibleInContext(context: ToolContext): boolean;
203
- /** Required toolbar-first render contract. */
204
- renderToolbar(context: ToolContext, toolbarContext: ToolbarContext): ToolToolbarRenderResult | null;
367
+ isVisibleInContext?(context: ToolContext): boolean;
368
+ /**
369
+ * Toolbar render contract. Required for `toolbar-toggle` and
370
+ * `selection-gateway`; a region capability renders through
371
+ * {@link ToolRegistration.renderSurface} instead.
372
+ */
373
+ renderToolbar?(context: ToolContext, toolbarContext: ToolbarContext): ToolToolbarRenderResult | null;
374
+ /**
375
+ * Render into one of the host surfaces this capability declares.
376
+ *
377
+ * Returning `null` means "nothing to show for this render" and is not an
378
+ * error — a capability may decline once the host has already granted and
379
+ * resolved content. The host mounts the returned element and calls `sync()`
380
+ * when policy, parameters or content move.
381
+ */
382
+ renderSurface?(context: ToolSurfaceRenderContext): ToolSurfaceRenderResult | null;
205
383
  }
206
384
  /**
207
385
  * Tool Registry
@@ -290,6 +468,23 @@ export declare class ToolRegistry {
290
468
  * Resolve singleton scope for a tool when present.
291
469
  */
292
470
  getToolSingletonScope(toolId: string): ToolSingletonScope | null;
471
+ /**
472
+ * Registrations that can fill a named host surface.
473
+ *
474
+ * The discovery call a renderer makes instead of naming a capability. Order
475
+ * follows registration order, so a host mounting several capabilities into one
476
+ * surface gets a stable sequence without core deciding a precedence it has no
477
+ * basis for.
478
+ */
479
+ getToolsBySurface(surface: string): ToolRegistration[];
480
+ /**
481
+ * Support ids belonging to capabilities that need authored content.
482
+ *
483
+ * What a host filters a default grant list on, in place of the compile-time
484
+ * exclusion array this replaced: granting one of these wholesale grants an
485
+ * accommodation to learners with no documented need for it.
486
+ */
487
+ getContentDependentSupportIds(): string[];
293
488
  /**
294
489
  * Filter tool IDs by activation type.
295
490
  */
@@ -319,6 +514,9 @@ export declare class ToolRegistry {
319
514
  supportedLevels: ToolLevel[];
320
515
  activation: ToolActivation;
321
516
  singletonScope: ToolSingletonScope | null;
517
+ surfaces: string[];
518
+ requiresAuthoredContent: boolean;
519
+ contentDependencyDescription: string | null;
322
520
  }>;
323
521
  /**
324
522
  * Generate PNP support IDs from enabled tools
@@ -358,4 +556,16 @@ export declare class ToolRegistry {
358
556
  * Render a tool for toolbar use with component overrides attached.
359
557
  */
360
558
  renderForToolbar(toolId: string, context: ToolContext, toolbarContext: ToolbarContext): ToolToolbarRenderResult | null;
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: string, context: Omit<ToolSurfaceRenderContext, "componentOverrides"> & {
569
+ componentOverrides?: ToolComponentOverrides;
570
+ }): ToolSurfaceRenderResult | null;
361
571
  }
@@ -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,30 @@ 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.renderSurface !== undefined &&
97
+ typeof registration.renderSurface !== "function") {
98
+ throw new Error(`Invalid tool registration "${registration.toolId}": "renderSurface" must be a function.`);
99
+ }
100
+ if (registration.renderSurface && !registration.surfaces?.length) {
101
+ // A surface renderer nothing can find is a registration that silently does
102
+ // not render, which is the failure mode this mechanism exists to remove.
103
+ throw new Error(`Invalid tool registration "${registration.toolId}": "renderSurface" requires at least one entry in "surfaces".`);
104
+ }
78
105
  if (registration.singletonScope !== undefined &&
79
106
  registration.singletonScope !== "section") {
80
107
  throw new Error(`Invalid tool registration "${registration.toolId}": unsupported singletonScope "${String(registration.singletonScope)}".`);
@@ -88,10 +115,33 @@ function assertToolRegistrationShape(registration) {
88
115
  registration.pnpSupportIds.some((pnpId) => typeof pnpId !== "string" || pnpId.trim().length === 0))) {
89
116
  throw new Error(`Invalid tool registration "${registration.toolId}": "pnpSupportIds" must be an array of non-empty strings.`);
90
117
  }
91
- if (typeof registration.isVisibleInContext !== "function") {
118
+ if (registration.activation !== "region" &&
119
+ typeof registration.isVisibleInContext !== "function") {
92
120
  throw new Error(`Invalid tool registration "${registration.toolId}": "isVisibleInContext" must be a function.`);
93
121
  }
94
- if (typeof registration.renderToolbar !== "function") {
122
+ if (registration.isVisibleInContext !== undefined &&
123
+ typeof registration.isVisibleInContext !== "function") {
124
+ throw new Error(`Invalid tool registration "${registration.toolId}": "isVisibleInContext" must be a function when present.`);
125
+ }
126
+ if (registration.requiresAuthoredContent !== undefined) {
127
+ if (typeof registration.requiresAuthoredContent !== "object" ||
128
+ registration.requiresAuthoredContent === null ||
129
+ typeof registration.requiresAuthoredContent.resolve !== "function") {
130
+ throw new Error(`Invalid tool registration "${registration.toolId}": "requiresAuthoredContent" must be an object with a "resolve" function.`);
131
+ }
132
+ if (!registration.pnpSupportIds?.length) {
133
+ // A content dependency's second job is keeping the capability out of a
134
+ // wholesale grant, and a host filters that by support id. Declaring one
135
+ // with no id to filter on would silently drop that guarantee.
136
+ 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.`);
137
+ }
138
+ }
139
+ if (registration.renderToolbar !== undefined) {
140
+ if (typeof registration.renderToolbar !== "function") {
141
+ throw new Error(`Invalid tool registration "${registration.toolId}": "renderToolbar" must be a function.`);
142
+ }
143
+ }
144
+ else if (!isRegion) {
95
145
  throw new Error(`Invalid tool registration "${registration.toolId}": "renderToolbar" must be a function.`);
96
146
  }
97
147
  }
@@ -251,6 +301,37 @@ export class ToolRegistry {
251
301
  getToolSingletonScope(toolId) {
252
302
  return this.get(toolId)?.singletonScope || null;
253
303
  }
304
+ /**
305
+ * Registrations that can fill a named host surface.
306
+ *
307
+ * The discovery call a renderer makes instead of naming a capability. Order
308
+ * follows registration order, so a host mounting several capabilities into one
309
+ * surface gets a stable sequence without core deciding a precedence it has no
310
+ * basis for.
311
+ */
312
+ getToolsBySurface(surface) {
313
+ if (!surface)
314
+ return [];
315
+ return this.getAllTools().filter((tool) => typeof tool.renderSurface === "function" &&
316
+ tool.surfaces?.includes(surface));
317
+ }
318
+ /**
319
+ * Support ids belonging to capabilities that need authored content.
320
+ *
321
+ * What a host filters a default grant list on, in place of the compile-time
322
+ * exclusion array this replaced: granting one of these wholesale grants an
323
+ * accommodation to learners with no documented need for it.
324
+ */
325
+ getContentDependentSupportIds() {
326
+ const ids = new Set();
327
+ for (const tool of this.getAllTools()) {
328
+ if (!tool.requiresAuthoredContent)
329
+ continue;
330
+ for (const supportId of tool.pnpSupportIds || [])
331
+ ids.add(supportId);
332
+ }
333
+ return [...ids].sort();
334
+ }
254
335
  /**
255
336
  * Filter tool IDs by activation type.
256
337
  */
@@ -279,9 +360,10 @@ export class ToolRegistry {
279
360
  if (!tool.supportedLevels.includes(context.level)) {
280
361
  continue;
281
362
  }
282
- // Pass 2: Ask tool if it's relevant
363
+ // Pass 2: Ask tool if it's relevant. A region capability declares no
364
+ // answer and has no toolbar presence, so it is never visible here.
283
365
  try {
284
- if (tool.isVisibleInContext(context)) {
366
+ if (tool.isVisibleInContext?.(context)) {
285
367
  visible.push(tool);
286
368
  }
287
369
  }
@@ -306,6 +388,9 @@ export class ToolRegistry {
306
388
  supportedLevels: tool.supportedLevels,
307
389
  activation: tool.activation || "toolbar-toggle",
308
390
  singletonScope: tool.singletonScope || null,
391
+ surfaces: tool.surfaces || [],
392
+ requiresAuthoredContent: Boolean(tool.requiresAuthoredContent),
393
+ contentDependencyDescription: tool.requiresAuthoredContent?.description ?? null,
309
394
  }));
310
395
  }
311
396
  /**
@@ -402,6 +487,12 @@ export class ToolRegistry {
402
487
  if (!tool) {
403
488
  throw new Error(`Tool '${toolId}' is not registered`);
404
489
  }
490
+ if (typeof tool.renderToolbar !== "function") {
491
+ // Naming the activation rather than "renderToolbar is not a function":
492
+ // the caller's mistake is asking a surface capability for a toolbar
493
+ // button, and it is fixed by placement config, not by the registration.
494
+ 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.`);
495
+ }
405
496
  const mergedContext = {
406
497
  ...toolbarContext,
407
498
  componentOverrides: {
@@ -411,4 +502,29 @@ export class ToolRegistry {
411
502
  };
412
503
  return tool.renderToolbar(context, mergedContext);
413
504
  }
505
+ /**
506
+ * Render a capability into a host surface, with component overrides attached.
507
+ *
508
+ * The surface counterpart of {@link renderForToolbar}, and it exists for the
509
+ * same reason: the registry owns the component-override map, so a host calling
510
+ * `registration.renderSurface(...)` directly would resolve element tags against
511
+ * nothing and fail on every packaged capability. Overrides passed in the
512
+ * context still win, matching the toolbar path's precedence.
513
+ */
514
+ renderForSurface(toolId, context) {
515
+ const tool = this.get(toolId);
516
+ if (!tool) {
517
+ throw new Error(`Tool '${toolId}' is not registered`);
518
+ }
519
+ if (typeof tool.renderSurface !== "function") {
520
+ throw new Error(`Tool '${toolId}' does not render into a host surface. Surface capabilities declare "surfaces" and implement "renderSurface".`);
521
+ }
522
+ return tool.renderSurface({
523
+ ...context,
524
+ componentOverrides: {
525
+ ...(this.componentOverrides || {}),
526
+ ...(context.componentOverrides || {}),
527
+ },
528
+ });
529
+ }
414
530
  }
@@ -17,6 +17,7 @@ import type { AccessibilityCatalog, AssessmentEntity, AssessmentItemRef } from "
17
17
  import { type CanonicalToolsConfig, type ToolPlacementConfig, type ToolPlacementLevel, type ToolPolicyConfig, type ToolProviderConfig, type ToolProvidersConfig } from "./tools-config-normalizer.js";
18
18
  import { type ToolConfigStrictness } from "./tool-config-validation.js";
19
19
  import { AccessibilityCatalogResolver } from "./AccessibilityCatalogResolver.js";
20
+ import type { CatalogChangeListener } from "./AccessibilityCatalogResolver.js";
20
21
  import { ElementToolStateStore } from "./ElementToolStateStore.js";
21
22
  import { type FrameworkErrorModel } from "./framework-error.js";
22
23
  import { FrameworkErrorBus, type FrameworkErrorListener } from "./framework-error-bus.js";
@@ -28,11 +29,12 @@ import type { SREMathSpeechOptions } from "./tts/math-speech.js";
28
29
  import { ToolProviderRegistry } from "./tool-providers/index.js";
29
30
  import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
30
31
  import type { TTSToolProviderConfig } from "./tool-providers/index.js";
31
- import type { ResolvedToolContext, ToolContextResolver, ToolContextResolverContext, ToolContextResolverMap, ToolRegistry } from "./ToolRegistry.js";
32
- import { type PnpEnforcementMode, type PolicySource, type ResolvedEngineInputs, type ToolPolicyChangeListener, type ToolPolicyDecision, type ToolPolicyDecisionRequest } from "../policy/engine.js";
32
+ import { ToolRegistry } from "./ToolRegistry.js";
33
+ import type { ResolvedToolContext, ToolContextResolver, ToolContextResolverContext, ToolContextResolverMap } from "./ToolRegistry.js";
34
+ import { type FeaturePolicyDecision, type PnpEnforcementMode, type PolicySource, type ResolvedEngineInputs, type ToolPolicyChangeListener, type ToolPolicyDecision, type ToolPolicyDecisionRequest } from "../policy/engine.js";
33
35
  import type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionSessionPersistenceStrategy, SectionPersistenceFactoryDefaults } from "./section-controller-types.js";
34
36
  export type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionControllerLoadedRenderable, SectionSessionPersistenceConfig, SectionSessionPersistenceStrategy, SectionControllerRuntimeState, SectionControllerSessionState, SectionPersistenceFactoryDefaults, } from "./section-controller-types.js";
35
- export type { PnpEnforcementMode, ResolvedEngineInputs, ToolPolicyChangeListener, ToolPolicyDecision, ToolPolicyDecisionRequest, } from "../policy/engine.js";
37
+ export type { FeaturePolicyDecision, PnpEnforcementMode, ResolvedEngineInputs, ToolPolicyChangeListener, ToolPolicyDecision, ToolPolicyDecisionRequest, } from "../policy/engine.js";
36
38
  /**
37
39
  * Generic tool configuration
38
40
  */
@@ -659,6 +661,15 @@ export declare class ToolkitCoordinator {
659
661
  * Thin shim over the owned tool-policy engine.
660
662
  */
661
663
  decideToolPolicy(request: ToolPolicyDecisionRequest): ToolPolicyDecision;
664
+ /**
665
+ * Resolve eligibility for one PNP/AfA feature id, independent of toolbar
666
+ * placement.
667
+ *
668
+ * Thin shim over the owned tool-policy engine; see
669
+ * {@link ToolPolicyEngine.decideFeature} for the contract, including why
670
+ * `pnpEnforcement` is not consulted.
671
+ */
672
+ decideFeaturePolicy(featureId: string): FeaturePolicyDecision;
662
673
  /**
663
674
  * Subscribe to policy-engine change events. Fires whenever the
664
675
  * coordinator's bound inputs change (`updateToolConfig`,
@@ -678,6 +689,17 @@ export declare class ToolkitCoordinator {
678
689
  * relying on a `disposed` event.
679
690
  */
680
691
  onPolicyChange(listener: ToolPolicyChangeListener): () => void;
692
+ /**
693
+ * Subscribe to accessibility-catalog registrations and removals.
694
+ *
695
+ * Delegates to the owned resolver, the same way {@link onPolicyChange}
696
+ * delegates to the owned policy engine, so a consumer holding only the
697
+ * coordinator can react to both of the mutable inputs a catalog-backed
698
+ * capability depends on without reaching for the services directly.
699
+ *
700
+ * @returns Unsubscribe function
701
+ */
702
+ onCatalogsChange(listener: CatalogChangeListener): () => void;
681
703
  /**
682
704
  * Bind (or clear) the active assessment for PNP/profile policy decisions.
683
705
  *
@@ -25,7 +25,7 @@ import { TTSService } from "./TTSService.js";
25
25
  import { BrowserTTSProvider } from "./tts/browser-provider.js";
26
26
  import { buildRuntimeTTSConfig, resolveTTSBackend, resolveTTSRuntimeSettings, } from "./tts-runtime-config.js";
27
27
  import { ToolProviderRegistry } from "./tool-providers/index.js";
28
- import { createPackagedToolRegistry } from "./createDefaultToolRegistry.js";
28
+ import { ToolRegistry } from "./ToolRegistry.js";
29
29
  import { ToolPolicyEngine, } from "../policy/engine.js";
30
30
  import { resolveDefaultPnpEnforcement } from "../policy/internal.js";
31
31
  const isPlainRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
@@ -190,7 +190,13 @@ export class ToolkitCoordinator {
190
190
  boundCurrentItemRef = null;
191
191
  static resolveConfig(config) {
192
192
  const strictness = normalizeToolConfigStrictness(config.toolConfigStrictness);
193
- const toolRegistry = config.toolRegistry ?? createPackagedToolRegistry();
193
+ // An empty registry when the host supplies none. This package no longer
194
+ // holds the packaged capability set, so there is nothing to fall back to —
195
+ // a host that wants stock tools passes a registry from
196
+ // `@pie-players/pie-default-tool-loaders`. Tool-id validation is skipped
197
+ // against an empty registry (with a diagnostic saying so) rather than
198
+ // rejecting every configured id.
199
+ const toolRegistry = config.toolRegistry ?? new ToolRegistry();
194
200
  const normalized = config.deferToolConfigValidation === true
195
201
  ? normalizeToolsConfig(config.tools)
196
202
  : normalizeAndValidateToolsConfig(config.tools, {
@@ -232,8 +238,7 @@ export class ToolkitCoordinator {
232
238
  const resolvedConfig = ToolkitCoordinator.resolveConfig(config);
233
239
  this.assessmentId = resolvedConfig.assessmentId;
234
240
  this.config = resolvedConfig;
235
- this.toolRegistry =
236
- resolvedConfig.toolRegistry ?? createPackagedToolRegistry();
241
+ this.toolRegistry = resolvedConfig.toolRegistry ?? new ToolRegistry();
237
242
  this.installToolContextResolvers(resolvedConfig.toolContextResolvers);
238
243
  this.hooks = resolvedConfig.hooks ?? {};
239
244
  this.lazyInit = config.lazyInit === true;
@@ -1268,13 +1273,16 @@ export class ToolkitCoordinator {
1268
1273
  if (canUseEventTarget) {
1269
1274
  synth.removeEventListener("voiceschanged", onVoicesChanged);
1270
1275
  }
1271
- else if (assignedHandler && synth.onvoiceschanged === onVoicesChanged) {
1276
+ else if (assignedHandler &&
1277
+ synth.onvoiceschanged === onVoicesChanged) {
1272
1278
  synth.onvoiceschanged = previousHandler;
1273
1279
  }
1274
1280
  resolve();
1275
1281
  };
1276
1282
  const onVoicesChanged = (event) => {
1277
- if (!canUseEventTarget && typeof previousHandler === "function" && event) {
1283
+ if (!canUseEventTarget &&
1284
+ typeof previousHandler === "function" &&
1285
+ event) {
1278
1286
  previousHandler.call(synth, event);
1279
1287
  }
1280
1288
  finish();
@@ -1371,6 +1379,15 @@ export class ToolkitCoordinator {
1371
1379
  if (toolId === "tts") {
1372
1380
  throw new Error(`Tool id "tts" is no longer supported. Use "textToSpeech".`);
1373
1381
  }
1382
+ // An empty registry means the host supplied none, not that every id is
1383
+ // wrong. There is nothing to check an id against, and throwing turns a
1384
+ // host's every tool-config call into an exception — including the calls
1385
+ // this coordinator's own default-provider block provokes, which is how a
1386
+ // host that passes no registry ended up unable to read its own config.
1387
+ // `normalizeAndValidateToolsConfig` already reports the missing registry
1388
+ // once, as `tools.registryUnavailable`; a second report per call is noise.
1389
+ if (this.toolRegistry.getAllToolIds().length === 0)
1390
+ return;
1374
1391
  if (!this.toolRegistry.get(toolId)) {
1375
1392
  throw new Error(`Unknown tool id "${toolId}".`);
1376
1393
  }
@@ -1518,6 +1535,17 @@ export class ToolkitCoordinator {
1518
1535
  decideToolPolicy(request) {
1519
1536
  return this.policyEngine.decide(request);
1520
1537
  }
1538
+ /**
1539
+ * Resolve eligibility for one PNP/AfA feature id, independent of toolbar
1540
+ * placement.
1541
+ *
1542
+ * Thin shim over the owned tool-policy engine; see
1543
+ * {@link ToolPolicyEngine.decideFeature} for the contract, including why
1544
+ * `pnpEnforcement` is not consulted.
1545
+ */
1546
+ decideFeaturePolicy(featureId) {
1547
+ return this.policyEngine.decideFeature(featureId);
1548
+ }
1521
1549
  /**
1522
1550
  * Subscribe to policy-engine change events. Fires whenever the
1523
1551
  * coordinator's bound inputs change (`updateToolConfig`,
@@ -1539,6 +1567,19 @@ export class ToolkitCoordinator {
1539
1567
  onPolicyChange(listener) {
1540
1568
  return this.policyEngine.onPolicyChange(listener);
1541
1569
  }
1570
+ /**
1571
+ * Subscribe to accessibility-catalog registrations and removals.
1572
+ *
1573
+ * Delegates to the owned resolver, the same way {@link onPolicyChange}
1574
+ * delegates to the owned policy engine, so a consumer holding only the
1575
+ * coordinator can react to both of the mutable inputs a catalog-backed
1576
+ * capability depends on without reaching for the services directly.
1577
+ *
1578
+ * @returns Unsubscribe function
1579
+ */
1580
+ onCatalogsChange(listener) {
1581
+ return this.catalogResolver.onCatalogsChange(listener);
1582
+ }
1542
1583
  /**
1543
1584
  * Bind (or clear) the active assessment for PNP/profile policy decisions.
1544
1585
  *