@pie-players/pie-assessment-toolkit 0.3.64 → 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 (47) hide show
  1. package/README.md +45 -17
  2. package/dist/components/ItemToolBar.custom-element.js +1 -1
  3. package/dist/components/PieAssessmentToolkit.custom-element.js +6 -6
  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 +5 -6
  8. package/dist/index.js +9 -4
  9. package/dist/runtime/composition-emit-scheduler.d.ts +78 -0
  10. package/dist/runtime/composition-emit-scheduler.js +154 -0
  11. package/dist/runtime/core/engine-resolver.d.ts +1 -1
  12. package/dist/services/ToolRegistry.d.ts +218 -8
  13. package/dist/services/ToolRegistry.js +124 -8
  14. package/dist/services/ToolkitCoordinator.d.ts +2 -1
  15. package/dist/services/ToolkitCoordinator.js +23 -6
  16. package/dist/services/createDefaultToolRegistry.d.ts +25 -58
  17. package/dist/services/createDefaultToolRegistry.js +24 -104
  18. package/dist/services/defaultPersonalNeedsProfile.d.ts +16 -14
  19. package/dist/services/defaultPersonalNeedsProfile.js +17 -38
  20. package/dist/services/pnp-standard-features.d.ts +1 -1
  21. package/dist/services/tool-config-defaults.d.ts +7 -23
  22. package/dist/services/tool-config-defaults.js +7 -46
  23. package/dist/services/tool-config-validation.d.ts +1 -1
  24. package/dist/services/tool-config-validation.js +44 -4
  25. package/dist/services/tts/browser-provider.js +2 -1
  26. package/dist/services/tts-runtime-config.js +7 -2
  27. package/dist/tools/internal.d.ts +34 -0
  28. package/dist/tools/internal.js +33 -0
  29. package/dist/tools/tool-tag-map.d.ts +15 -3
  30. package/dist/tools/tool-tag-map.js +21 -18
  31. package/package.json +14 -10
  32. package/dist/components/chunks/ItemToolBar-3cppre9r.js +0 -51
  33. package/dist/components/chunks/ItemToolBar-7rq2gj8b.js +0 -22
  34. package/dist/services/sign-language-cards.d.ts +0 -82
  35. package/dist/services/sign-language-cards.js +0 -133
  36. package/dist/tools/registrations/accessibility-tools.d.ts +0 -34
  37. package/dist/tools/registrations/accessibility-tools.js +0 -217
  38. package/dist/tools/registrations/calculator.d.ts +0 -20
  39. package/dist/tools/registrations/calculator.js +0 -228
  40. package/dist/tools/registrations/interaction-tools.d.ts +0 -27
  41. package/dist/tools/registrations/interaction-tools.js +0 -143
  42. package/dist/tools/registrations/measurement-tools.d.ts +0 -24
  43. package/dist/tools/registrations/measurement-tools.js +0 -130
  44. package/dist/tools/registrations/subject-specific-tools.d.ts +0 -27
  45. package/dist/tools/registrations/subject-specific-tools.js +0 -158
  46. package/dist/tools/registrations/tts.d.ts +0 -21
  47. package/dist/tools/registrations/tts.js +0 -184
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Composition emit scheduler (PIE-885).
3
+ *
4
+ * `PieAssessmentToolkit.svelte` publishes the section composition to the
5
+ * players through exactly one path — the `composition-changed` event — and
6
+ * coalesces bursts of updates behind a one-shot latch so several changes
7
+ * within one frame produce a single emit.
8
+ *
9
+ * That latch used to be cleared only by a `requestAnimationFrame` callback,
10
+ * and the frame branch was chosen whenever `window.requestAnimationFrame`
11
+ * merely *existed* rather than when it was known to fire. In a document that
12
+ * never paints the callback never ran, the latch never cleared, and no
13
+ * `composition-changed` was ever dispatched: the section controller held a
14
+ * correct view model while the player kept its initial empty composition, so
15
+ * every `pie-section-player` route rendered no content at all. The permanent
16
+ * failure was in contexts with no compositor — headless browsers, hidden or
17
+ * offscreen tabs, agent and CI automation harnesses. A background tab only
18
+ * rendered late, because its pending frame becomes due on refocus.
19
+ *
20
+ * So the frame is raced against a deadline timer instead of trusted. Whichever
21
+ * arrives first releases the latch and flushes, and a non-painting document
22
+ * degrades to a slower render rather than a permanent blank. Svelte's own
23
+ * `tick()` races the same two primitives for the same reason.
24
+ *
25
+ * This scheduler owns the latch and both handles, which is what makes "the
26
+ * latch cannot stay set after a scheduled frame is cancelled or superseded"
27
+ * structural rather than a discipline every call site has to keep: releasing
28
+ * the latch and releasing the handles is one operation in one place.
29
+ *
30
+ * Deliberately stateless across cycles. A cycle the timer wins does not switch
31
+ * the scheduler into a timer-preferring mode: that trades the deadline cost for
32
+ * a mode which never returns to frame alignment once a single frame is missed,
33
+ * and a slower render is the accepted outcome.
34
+ *
35
+ * NOT a public surface: internal to the toolkit and not exported from
36
+ * `runtime/engine.ts`. It exists as its own module so the race can be pinned in
37
+ * a unit test without mounting the toolkit CE — same rationale as
38
+ * `runtime/stage-emit-gate.ts`.
39
+ */
40
+ /**
41
+ * How long to wait for a frame before the timer takes over.
42
+ *
43
+ * Six frames at 60fps, so on any normally painting document the frame still
44
+ * wins and emits stay paint-aligned. Chrome clamps timers in hidden tabs to
45
+ * ≥1s and can clamp harder under intensive throttling; that makes a hidden tab
46
+ * bounded-slow instead of blank, which is the point.
47
+ */
48
+ export const DEFAULT_FRAME_DEADLINE_MS = 100;
49
+ function pickFunction(override, ambient) {
50
+ if (typeof override === "function")
51
+ return override;
52
+ if (override === null)
53
+ return null;
54
+ return typeof ambient === "function" ? ambient : null;
55
+ }
56
+ export function createCompositionEmitScheduler(timing = {}) {
57
+ const frameDeadlineMs = timing.frameDeadlineMs ?? DEFAULT_FRAME_DEADLINE_MS;
58
+ let pending = false;
59
+ let pendingFlush = null;
60
+ let frameHandle = null;
61
+ let timerHandle = null;
62
+ // Kept from the arming call so a cancel is paired with the primitive that
63
+ // actually armed the handle.
64
+ let cancelFrame = null;
65
+ let clearTimer = null;
66
+ function release() {
67
+ pending = false;
68
+ if (frameHandle !== null) {
69
+ cancelFrame?.(frameHandle);
70
+ frameHandle = null;
71
+ }
72
+ if (timerHandle !== null) {
73
+ clearTimer?.(timerHandle);
74
+ timerHandle = null;
75
+ }
76
+ cancelFrame = null;
77
+ clearTimer = null;
78
+ }
79
+ /**
80
+ * Resolve one cycle. `firedFrom` names the side that arrived so its own
81
+ * handle is dropped rather than handed back to a cancel that has nothing
82
+ * left to cancel.
83
+ *
84
+ * The latch is released before the flush callback runs, so a re-entrant
85
+ * `schedule()` from inside the flush arms a fresh cycle instead of being
86
+ * swallowed. The pre-fix code cleared its latch first for the same reason.
87
+ */
88
+ function resolve(firedFrom) {
89
+ if (!pending)
90
+ return;
91
+ if (firedFrom === "frame")
92
+ frameHandle = null;
93
+ if (firedFrom === "timer")
94
+ timerHandle = null;
95
+ const flushNow = pendingFlush;
96
+ pendingFlush = null;
97
+ release();
98
+ flushNow?.();
99
+ }
100
+ function schedule(flush) {
101
+ pendingFlush = flush;
102
+ if (pending)
103
+ return;
104
+ pending = true;
105
+ const ambient = globalThis;
106
+ const requestFrame = pickFunction(timing.requestFrame, ambient.requestAnimationFrame);
107
+ cancelFrame = pickFunction(timing.cancelFrame, ambient.cancelAnimationFrame);
108
+ const setTimer = pickFunction(timing.setTimer, ambient.setTimeout);
109
+ clearTimer = pickFunction(timing.clearTimer, ambient.clearTimeout);
110
+ if (requestFrame) {
111
+ const handle = requestFrame(() => resolve("frame"));
112
+ // A primitive that calls back synchronously has already flushed and
113
+ // released; arming anything else would leave a stray handle behind.
114
+ if (!pending)
115
+ return;
116
+ frameHandle = handle;
117
+ // The frame is not trusted to arrive. Without a timer alongside it, a
118
+ // document that never paints leaves the latch set forever.
119
+ if (setTimer) {
120
+ const deadlineHandle = setTimer(() => resolve("timer"), frameDeadlineMs);
121
+ if (pending)
122
+ timerHandle = deadlineHandle;
123
+ }
124
+ return;
125
+ }
126
+ // No frame primitive at all (SSR, non-DOM test environments). Preserve
127
+ // the microtask timing those hosts already had.
128
+ const queueMicrotaskFn = pickFunction(timing.queueMicrotaskFn, ambient.queueMicrotask);
129
+ if (queueMicrotaskFn) {
130
+ queueMicrotaskFn(() => resolve("microtask"));
131
+ return;
132
+ }
133
+ if (setTimer) {
134
+ const deadlineHandle = setTimer(() => resolve("timer"), 0);
135
+ if (pending)
136
+ timerHandle = deadlineHandle;
137
+ return;
138
+ }
139
+ // Nothing to defer with. A synchronous flush is strictly better than
140
+ // dropping the composition on the floor.
141
+ resolve("microtask");
142
+ }
143
+ function cancel() {
144
+ if (!pending)
145
+ return;
146
+ pendingFlush = null;
147
+ release();
148
+ }
149
+ return {
150
+ schedule,
151
+ cancel,
152
+ isPending: () => pending,
153
+ };
154
+ }
@@ -120,7 +120,7 @@ export declare function resolveRuntime(args: {
120
120
  createSectionController: unknown;
121
121
  isolation: string;
122
122
  env: Record<string, unknown>;
123
- toolConfigStrictness: "off" | "error" | "warn";
123
+ toolConfigStrictness: "off" | "warn" | "error";
124
124
  onFrameworkError: FrameworkErrorHandler | undefined;
125
125
  onStageChange: StageChangeHandler | undefined;
126
126
  onLoadingComplete: LoadingCompleteHandler | undefined;
@@ -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
  }
@@ -29,7 +29,8 @@ import type { SREMathSpeechOptions } from "./tts/math-speech.js";
29
29
  import { ToolProviderRegistry } from "./tool-providers/index.js";
30
30
  import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
31
31
  import type { TTSToolProviderConfig } from "./tool-providers/index.js";
32
- import type { ResolvedToolContext, ToolContextResolver, ToolContextResolverContext, ToolContextResolverMap, ToolRegistry } from "./ToolRegistry.js";
32
+ import { ToolRegistry } from "./ToolRegistry.js";
33
+ import type { ResolvedToolContext, ToolContextResolver, ToolContextResolverContext, ToolContextResolverMap } from "./ToolRegistry.js";
33
34
  import { type FeaturePolicyDecision, type PnpEnforcementMode, type PolicySource, type ResolvedEngineInputs, type ToolPolicyChangeListener, type ToolPolicyDecision, type ToolPolicyDecisionRequest } from "../policy/engine.js";
34
35
  import type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionSessionPersistenceStrategy, SectionPersistenceFactoryDefaults } from "./section-controller-types.js";
35
36
  export type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionControllerLoadedRenderable, SectionSessionPersistenceConfig, SectionSessionPersistenceStrategy, SectionControllerRuntimeState, SectionControllerSessionState, SectionPersistenceFactoryDefaults, } from "./section-controller-types.js";