@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
@@ -1,46 +1,25 @@
1
- import { createPackagedToolRegistry } from "./createDefaultToolRegistry.js";
2
1
  /**
3
- * PNP support ids that must never enter the computed default profile, however
4
- * they reach the registry.
2
+ * An empty personal-needs profile: nothing granted, nothing prohibited, nothing
3
+ * activated at init.
5
4
  *
6
- * `computeDefaultSupports()` derives the fallback profile from every registered
7
- * tool's `pnpSupportIds`, which is right for universal features — a highlighter
8
- * or a zoom control should be there for every student by default. It is wrong
9
- * for an **accommodation**: signing requires a documented need (IEP / 504), so
10
- * granting it to every host that does not supply its own profile would invert
11
- * the eligibility tier. Registry membership is about being policy-addressable,
12
- * not about who may enable it.
5
+ * The core ships no populated default on purpose. It once derived one from every
6
+ * registered tool's `pnpSupportIds`, which read *registry membership* as
7
+ * *eligibility tier* registration means "policy-addressable", not "universal,
8
+ * on by default" so an accommodation-tier capability was granted to every
9
+ * student of every host that supplied no profile. The remedy at the time was a
10
+ * compile-time list of ids to exclude, which a host could not extend for its own
11
+ * accommodation.
13
12
  *
14
- * Excluded here rather than by declining to register, so the guarantee holds
15
- * even if a signing tool is later registered for some other reason.
13
+ * Which capabilities a deployment grants by default is a property of the
14
+ * program, not of this package: TTS is a universal feature in one program and a
15
+ * documented accommodation in another. That belongs in policy configuration
16
+ * alongside the district and test-administration levels. A named preset of
17
+ * today's universal set ships as data from
18
+ * `@pie-players/pie-default-tool-loaders`, for hosts that want it.
16
19
  */
17
- export const ACCOMMODATION_ONLY_SUPPORT_IDS = [
18
- // QTI 3.0 / AfA `signLanguage`. Signed alternates are rendered by
19
- // section-player's per-item media region, gated on this id.
20
- "signLanguage",
21
- ];
22
- function computeDefaultSupports() {
23
- const registry = createPackagedToolRegistry();
24
- const supports = new Set();
25
- const excluded = new Set(ACCOMMODATION_ONLY_SUPPORT_IDS);
26
- for (const tool of registry.getAllTools()) {
27
- for (const supportId of tool.pnpSupportIds || []) {
28
- if (excluded.has(supportId))
29
- continue;
30
- supports.add(supportId);
31
- }
32
- }
33
- return [...supports].sort();
34
- }
35
- const DEFAULT_SUPPORTS = computeDefaultSupports();
36
- export const DEFAULT_PERSONAL_NEEDS_PROFILE = {
37
- supports: [...DEFAULT_SUPPORTS],
38
- prohibitedSupports: [],
39
- activateAtInit: [],
40
- };
41
- export function createDefaultPersonalNeedsProfile() {
20
+ export function createEmptyPersonalNeedsProfile() {
42
21
  return {
43
- supports: [...DEFAULT_SUPPORTS],
22
+ supports: [],
44
23
  prohibitedSupports: [],
45
24
  activateAtInit: [],
46
25
  };
@@ -1,5 +1,5 @@
1
1
  import type { ToolConfigDiagnostic } from "./tool-config-validation.js";
2
- export type FrameworkErrorKind = "tool-config" | "runtime-init" | "runtime-dispose" | "coordinator-init" | "provider-init" | "provider-register" | "tts-init" | "tool-state-load" | "tool-state-save" | "section-controller-init" | "section-controller-dispose" | "unknown";
2
+ export type FrameworkErrorKind = "tool-config" | "runtime-init" | "runtime-dispose" | "coordinator-init" | "provider-init" | "provider-register" | "tts-init" | "tool-state-load" | "tool-state-save" | "section-controller-init" | "section-controller-dispose" | "tool-surface" | "unknown";
3
3
  export type FrameworkErrorSeverity = "warning" | "error";
4
4
  export interface FrameworkErrorModel {
5
5
  kind: FrameworkErrorKind;
@@ -8,8 +8,11 @@
8
8
  * Part of PIE Assessment Toolkit.
9
9
  */
10
10
  import type { I18nServiceApi } from "@pie-players/pie-players-shared/i18n";
11
- import type { AccessibilityCatalogResolver, CatalogChangeListener, CatalogLookupContext, CatalogLookupOptions, CatalogOwnerContext, CatalogStatistics, CatalogType, ResolvedCatalog } from "./AccessibilityCatalogResolver.js";
11
+ import type { AccessibilityCatalog } from "@pie-players/pie-players-shared/types";
12
+ import type { AccessibilityCatalogResolver, CatalogChangeListener, CatalogLookupContext, CatalogLookupOptions, CatalogOwnerRegistration, CatalogOwnerView, CatalogStatistics, CatalogType, ResolvedCatalog } from "./AccessibilityCatalogResolver.js";
13
+ import type { CatalogOwnerContext } from "./catalog-owner.js";
12
14
  import type { FrameworkErrorListener } from "./framework-error-bus.js";
15
+ import type { FrameworkErrorModel } from "./framework-error.js";
13
16
  import type { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
14
17
  import type { SectionControllerHandle, SectionItemEventSubscriptionArgs, SectionScopedEventSubscriptionArgs, SectionEventSubscriptionArgs, ToolkitCoordinatorHooks, ToolkitInitStatus } from "./ToolkitCoordinator.js";
15
18
  import type { ThemeConfig } from "./ThemeProvider.js";
@@ -291,11 +294,15 @@ export interface AccessibilityCatalogResolverApi {
291
294
  /**
292
295
  * Add item-level catalogs (called when rendering a new item)
293
296
  */
294
- addItemCatalogs(catalogs: any[]): void;
297
+ addItemCatalogs(catalogs: AccessibilityCatalog[]): void;
295
298
  /**
296
299
  * Register catalogs scoped to a mounted content owner.
297
300
  */
298
- registerCatalogs?(context: CatalogOwnerContext, catalogs: any[]): () => void;
301
+ registerCatalogs(context: CatalogOwnerContext, catalogs: AccessibilityCatalog[]): () => void;
302
+ /** Register every catalog carried by one mounted item or passage. */
303
+ registerOwner(registration: CatalogOwnerRegistration): () => void;
304
+ /** Bind read and change observation to one catalog owner. */
305
+ forOwner(context: CatalogOwnerContext): CatalogOwnerView;
299
306
  /**
300
307
  * Clear item-level catalogs (called when leaving an item)
301
308
  */
@@ -586,6 +593,11 @@ export interface ToolkitCoordinatorApi {
586
593
  * `<pie-assessment-toolkit>` consume the same bus.
587
594
  */
588
595
  subscribeFrameworkErrors(listener: FrameworkErrorListener): () => void;
596
+ /**
597
+ * Report a pre-built framework warning or error through the canonical bus.
598
+ * Optional so structural host coordinators remain assignable.
599
+ */
600
+ reportFrameworkError?(model: FrameworkErrorModel): void;
589
601
  /**
590
602
  * Resolve the visible tool set for a given placement level + scope.
591
603
  * Returns the engine's full decision (visible tools, diagnostics,
@@ -167,7 +167,7 @@ export declare const QTI_STANDARD_ACCESS_FEATURES: {
167
167
  /**
168
168
  * Flat list of all standard access features for validation
169
169
  */
170
- export declare const ALL_STANDARD_ACCESS_FEATURES: ("transcript" | "braille" | "textToSpeech" | "graph" | "periodicTable" | "calculator" | "answerEliminator" | "ruler" | "protractor" | "graphingCalculator" | "readAloud" | "strikethrough" | "answerMasking" | "readingMask" | "readingGuide" | "readingRuler" | "highContrastDisplay" | "colorContrast" | "invertColors" | "highlighting" | "annotations" | "signLanguage" | "magnification" | "screenMagnifier" | "zoomable" | "highContrastAudio" | "displayTransformability" | "largePrint" | "fontEnlargement" | "resizeText" | "alternativeText" | "longDescription" | "describedMath" | "tactileGraphic" | "tactileObject" | "audioDescription" | "humanVoice" | "syntheticVoice" | "speechRate" | "speechVolume" | "voicePitch" | "captions" | "closedCaptions" | "openCaptions" | "subtitles" | "audioControl" | "noBackgroundAudio" | "keyboardControl" | "mouseControl" | "touchControl" | "voiceControl" | "switchControl" | "eyeGazeControl" | "singleSwitchAccess" | "stickyKeys" | "keyboardShortcuts" | "timingControl" | "unlimitedTime" | "extendedTime" | "pauseControl" | "simplifiedLanguage" | "reducedComplexity" | "structuralNavigation" | "tableOfContents" | "reducedDistraction" | "noFlashing" | "pauseAnimation" | "bookmarking" | "guidedNavigation" | "thesaurus" | "spellingAssistance" | "grammarAssistance" | "lineSpacing" | "wordSpacing" | "letterSpacing" | "fontFamily" | "wordHighlighting" | "lineHighlighting" | "focusIndicator" | "printableResource" | "nemeth" | "refreshableBraille" | "index" | "pageNavigation" | "skipContent" | "breadcrumbs" | "searchable" | "fullTextSearch" | "multilingualText" | "translatedText" | "glossary" | "signLanguageInterpretation" | "visualLanguage" | "formulaSheet" | "itemGlossary" | "tutorialAvailable")[];
170
+ export declare const ALL_STANDARD_ACCESS_FEATURES: ("transcript" | "braille" | "textToSpeech" | "calculator" | "strikethrough" | "answerEliminator" | "magnification" | "screenMagnifier" | "zoomable" | "highContrastDisplay" | "highContrastAudio" | "colorContrast" | "invertColors" | "displayTransformability" | "largePrint" | "fontEnlargement" | "resizeText" | "alternativeText" | "longDescription" | "describedMath" | "tactileGraphic" | "tactileObject" | "audioDescription" | "readAloud" | "humanVoice" | "syntheticVoice" | "speechRate" | "speechVolume" | "voicePitch" | "captions" | "closedCaptions" | "openCaptions" | "signLanguage" | "subtitles" | "audioControl" | "noBackgroundAudio" | "keyboardControl" | "mouseControl" | "touchControl" | "voiceControl" | "switchControl" | "eyeGazeControl" | "singleSwitchAccess" | "stickyKeys" | "keyboardShortcuts" | "timingControl" | "unlimitedTime" | "extendedTime" | "pauseControl" | "simplifiedLanguage" | "reducedComplexity" | "structuralNavigation" | "tableOfContents" | "reducedDistraction" | "noFlashing" | "pauseAnimation" | "annotations" | "bookmarking" | "highlighting" | "guidedNavigation" | "thesaurus" | "spellingAssistance" | "grammarAssistance" | "lineSpacing" | "wordSpacing" | "letterSpacing" | "fontFamily" | "readingMask" | "readingGuide" | "readingRuler" | "wordHighlighting" | "lineHighlighting" | "focusIndicator" | "printableResource" | "nemeth" | "refreshableBraille" | "index" | "pageNavigation" | "skipContent" | "breadcrumbs" | "searchable" | "fullTextSearch" | "multilingualText" | "translatedText" | "glossary" | "signLanguageInterpretation" | "visualLanguage" | "protractor" | "ruler" | "graph" | "graphingCalculator" | "periodicTable" | "formulaSheet" | "answerMasking" | "itemGlossary" | "tutorialAvailable")[];
171
171
  /**
172
172
  * Example PNP configurations for common accessibility needs
173
173
  * These are NOT official profiles but illustrative examples showing
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Placement defaults this package can hold.
3
+ *
4
+ * Only the empty one. `PACKAGED_TOOL_PLACEMENT` and
5
+ * `SECTION_PLAYER_PREFERRED_TOOL_PLACEMENT` name capabilities, so they moved to
6
+ * the composition layer (`@pie-players/pie-default-tool-loaders`).
7
+ */
1
8
  export declare const DEFAULT_TOOL_PLACEMENT: {
2
9
  readonly assessment: readonly [];
3
10
  readonly section: readonly [];
@@ -6,26 +13,3 @@ export declare const DEFAULT_TOOL_PLACEMENT: {
6
13
  readonly rubric: readonly [];
7
14
  readonly element: readonly [];
8
15
  };
9
- /**
10
- * Complete placement preset for hosts that want to enable all packaged tools.
11
- * Prefer explicit host configuration over implicit defaults.
12
- */
13
- export declare const PACKAGED_TOOL_PLACEMENT: {
14
- readonly assessment: readonly ["theme"];
15
- readonly section: readonly ["theme"];
16
- readonly item: readonly ["textToSpeech", "highlighter", "annotationToolbar", "graph", "periodicTable"];
17
- readonly passage: readonly ["textToSpeech", "highlighter", "annotationToolbar", "lineReader"];
18
- readonly rubric: readonly ["textToSpeech", "highlighter", "annotationToolbar", "lineReader"];
19
- readonly element: readonly ["calculator", "answerEliminator", "textToSpeech", "ruler", "protractor", "highlighter", "annotationToolbar", "graph", "periodicTable"];
20
- };
21
- /**
22
- * Opt-in section-player placement preset for hosts that want to expose every
23
- * packaged tool once at its normal assessment surface. This is not exhaustive:
24
- * `supportedLevels` still defines where tools can run, and hosts can still
25
- * choose different placement for custom UX.
26
- */
27
- export declare const SECTION_PLAYER_PREFERRED_TOOL_PLACEMENT: {
28
- section: string[];
29
- item: string[];
30
- passage: string[];
31
- };
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Placement defaults this package can hold.
3
+ *
4
+ * Only the empty one. `PACKAGED_TOOL_PLACEMENT` and
5
+ * `SECTION_PLAYER_PREFERRED_TOOL_PLACEMENT` name capabilities, so they moved to
6
+ * the composition layer (`@pie-players/pie-default-tool-loaders`).
7
+ */
1
8
  export const DEFAULT_TOOL_PLACEMENT = {
2
9
  assessment: [],
3
10
  section: [],
@@ -6,49 +13,3 @@ export const DEFAULT_TOOL_PLACEMENT = {
6
13
  rubric: [],
7
14
  element: [],
8
15
  };
9
- /**
10
- * Complete placement preset for hosts that want to enable all packaged tools.
11
- * Prefer explicit host configuration over implicit defaults.
12
- */
13
- export const PACKAGED_TOOL_PLACEMENT = {
14
- assessment: ["theme"],
15
- section: ["theme"],
16
- item: [
17
- "textToSpeech",
18
- "highlighter",
19
- "annotationToolbar",
20
- "graph",
21
- "periodicTable",
22
- ],
23
- passage: ["textToSpeech", "highlighter", "annotationToolbar", "lineReader"],
24
- rubric: ["textToSpeech", "highlighter", "annotationToolbar", "lineReader"],
25
- element: [
26
- "calculator",
27
- "answerEliminator",
28
- "textToSpeech",
29
- "ruler",
30
- "protractor",
31
- "highlighter",
32
- "annotationToolbar",
33
- "graph",
34
- "periodicTable",
35
- ],
36
- };
37
- /**
38
- * Opt-in section-player placement preset for hosts that want to expose every
39
- * packaged tool once at its normal assessment surface. This is not exhaustive:
40
- * `supportedLevels` still defines where tools can run, and hosts can still
41
- * choose different placement for custom UX.
42
- */
43
- export const SECTION_PLAYER_PREFERRED_TOOL_PLACEMENT = {
44
- section: [
45
- "theme",
46
- "graph",
47
- "periodicTable",
48
- "lineReader",
49
- "ruler",
50
- "protractor",
51
- ],
52
- item: ["calculator", "textToSpeech", "answerEliminator", "annotationToolbar"],
53
- passage: ["textToSpeech", "annotationToolbar"],
54
- };
@@ -4,7 +4,7 @@ import type { ToolRegistry } from "./ToolRegistry.js";
4
4
  export type ToolConfigStrictness = "off" | "warn" | "error";
5
5
  export type ToolConfigDiagnosticSeverity = "warning" | "error";
6
6
  export interface ToolConfigDiagnostic {
7
- code: "tools.unknownToolId" | "tools.unsupportedLevel" | "tools.unknownProviderKey" | "tools.removedProviderKey" | "tools.providerSanitizeFailed" | "tools.providerValidateFailed" | "tools.invalidProviderValidation";
7
+ code: "tools.unknownToolId" | "tools.unsupportedLevel" | "tools.unplaceableActivation" | "tools.registryUnavailable" | "tools.unknownProviderKey" | "tools.removedProviderKey" | "tools.providerSanitizeFailed" | "tools.providerValidateFailed" | "tools.invalidProviderValidation";
8
8
  severity: ToolConfigDiagnosticSeverity;
9
9
  path: string;
10
10
  message: string;
@@ -1,6 +1,5 @@
1
1
  import { normalizeToolsConfig, } from "./tools-config-normalizer.js";
2
2
  import { frameworkErrorFromToolConfigDiagnostics, frameworkErrorFromUnknown, } from "./framework-error.js";
3
- import { createPackagedToolRegistry } from "./createDefaultToolRegistry.js";
4
3
  export function frameworkErrorFromToolConfigValidation(args) {
5
4
  if (Array.isArray(args.diagnostics) && args.diagnostics.length > 0) {
6
5
  return frameworkErrorFromToolConfigDiagnostics({
@@ -120,6 +119,20 @@ function collectPlacementDiagnostics(config, toolMap, diagnostics) {
120
119
  }));
121
120
  continue;
122
121
  }
122
+ if (tool.activation === "region") {
123
+ // A region capability has no toolbar button, so placing it names a
124
+ // surface that will never render it. Reported at the placement rather
125
+ // than at render time, where the symptom is an absent accommodation and
126
+ // no error.
127
+ diagnostics.push(createDiagnostic({
128
+ code: "tools.unplaceableActivation",
129
+ severity: "error",
130
+ path: `placement.${level}`,
131
+ message: `Tool "${toolId}" renders into a host surface (activation "region") and cannot be placed on the ${level} toolbar. Remove it from placement.${level}; its availability comes from policy plus its content dependency.`,
132
+ toolId,
133
+ }));
134
+ continue;
135
+ }
123
136
  const supportsLevel = tool.supportedLevels.includes(level);
124
137
  // Leniency policy: section-capable tools are tolerated in item placement.
125
138
  // This avoids strict failures for common host config patterns while still
@@ -204,9 +217,27 @@ function throwValidationError(diagnostics, source) {
204
217
  export function normalizeAndValidateToolsConfig(input, options = {}) {
205
218
  const strictness = normalizeToolConfigStrictness(options.strictness);
206
219
  const source = options.source ?? "tools";
207
- const registryTools = getRegistryToolMap(options.toolRegistry ?? createPackagedToolRegistry());
220
+ const registryTools = getRegistryToolMap(options.toolRegistry);
208
221
  const normalized = normalizeToolsConfig(input);
209
222
  const diagnostics = [];
223
+ // Tool-id and provider checks need a registry to check against, and every
224
+ // collector below returns early without one. Say so: this package no longer
225
+ // falls back to a packaged registry, so a caller that used to get id
226
+ // validation for free now gets none, and a silent downgrade from "your ids are
227
+ // valid" to "nobody looked" is the kind of change that surfaces as a typo
228
+ // reaching a learner.
229
+ const hasConfiguredTools = Object.values(normalized.placement).some((ids) => ids.length > 0) ||
230
+ Object.keys(normalized.providers).length > 0 ||
231
+ (normalized.policy.allowed?.length ?? 0) > 0 ||
232
+ (normalized.policy.blocked?.length ?? 0) > 0;
233
+ if (registryTools.size === 0 && hasConfiguredTools) {
234
+ diagnostics.push(createDiagnostic({
235
+ code: "tools.registryUnavailable",
236
+ severity: "warning",
237
+ path: "tools",
238
+ message: 'No tool registry was supplied, so tool ids, placement levels and provider config were not validated. Pass `toolRegistry` — for the packaged capability set, `createPackagedToolRegistry()` from "@pie-players/pie-default-tool-loaders".',
239
+ }));
240
+ }
210
241
  const hasRemovedTtsKey = Object.hasOwn(normalized.providers, "tts");
211
242
  const nextProviders = {
212
243
  ...(normalized.providers || {}),
@@ -233,8 +264,17 @@ export function normalizeAndValidateToolsConfig(input, options = {}) {
233
264
  if (strictness === "warn") {
234
265
  emitWarnings(diagnostics, source);
235
266
  }
236
- if (strictness === "error" && diagnostics.length > 0) {
237
- throwValidationError(diagnostics, source);
267
+ // Severity decides what `strictness: "error"` rejects. Every diagnostic this
268
+ // function raised was `"error"` until `tools.registryUnavailable`, which
269
+ // reports that validation could not run rather than that the config is wrong —
270
+ // throwing on it would turn "no registry supplied" from a host's existing,
271
+ // working setup into a construction failure.
272
+ const blocking = diagnostics.filter((entry) => entry.severity === "error");
273
+ if (strictness === "error" && blocking.length > 0) {
274
+ throwValidationError(blocking, source);
275
+ }
276
+ if (strictness === "error" && blocking.length === 0) {
277
+ emitWarnings(diagnostics, source);
238
278
  }
239
279
  return {
240
280
  config: nextConfig,
@@ -7,6 +7,8 @@
7
7
  * Part of PIE Assessment Toolkit.
8
8
  */
9
9
  import { segmentSentences as segmentTextToSentences } from "./text-segmentation.js";
10
+ const NATIVE_START_TIMEOUT_MS = 5_000;
11
+ const VOICE_INVENTORY_TIMEOUT_MS = 2_000;
10
12
  const normalizeLanguageCode = (value) => String(value || "")
11
13
  .trim()
12
14
  .toLowerCase();
@@ -16,17 +18,18 @@ const browserLanguage = () => {
16
18
  : "";
17
19
  return normalizeLanguageCode(navigatorLanguage || "en-US");
18
20
  };
19
- const findBrowserVoice = (voices, preferredName) => {
20
- if (preferredName) {
21
- const explicit = voices.find((voice) => voice.name === preferredName);
22
- if (explicit)
23
- return explicit;
21
+ const findBrowserVoice = (voices, preferredVoice) => {
22
+ if (preferredVoice) {
23
+ return (voices.find((voice) => voice.voiceURI === preferredVoice) ||
24
+ voices.find((voice) => voice.name === preferredVoice) ||
25
+ null);
24
26
  }
25
27
  const language = browserLanguage();
26
28
  const languagePrefix = language.split("-")[0] || "en";
27
29
  const matchesLanguage = (voice) => {
28
30
  const voiceLanguage = normalizeLanguageCode(voice.lang);
29
- return voiceLanguage === language || voiceLanguage.startsWith(`${languagePrefix}-`);
31
+ return (voiceLanguage === language ||
32
+ voiceLanguage.startsWith(`${languagePrefix}-`));
30
33
  };
31
34
  const ranked = [
32
35
  (voice) => voice.localService && matchesLanguage(voice),
@@ -94,9 +97,98 @@ class BrowserTTSProviderImpl {
94
97
  _isPlaying = false;
95
98
  _isPaused = false;
96
99
  speakRunId = 0;
100
+ settlePendingVoiceWait = null;
101
+ settlePendingChunk = null;
102
+ onPlaybackStart = undefined;
97
103
  constructor(config) {
98
104
  this.config = config;
99
105
  }
106
+ waitForBrowserVoices(runId, configuredVoice) {
107
+ return new Promise((resolve, reject) => {
108
+ const synth = speechSynthesis;
109
+ const canUseEventTarget = typeof synth.addEventListener === "function" &&
110
+ typeof synth.removeEventListener === "function";
111
+ const previousHandler = canUseEventTarget ? null : synth.onvoiceschanged;
112
+ let settled = false;
113
+ let timeout = null;
114
+ let pendingSettlement = null;
115
+ const cleanup = () => {
116
+ if (timeout !== null) {
117
+ clearTimeout(timeout);
118
+ timeout = null;
119
+ }
120
+ if (canUseEventTarget) {
121
+ synth.removeEventListener("voiceschanged", onVoicesChanged);
122
+ }
123
+ else if (synth.onvoiceschanged === propertyHandler) {
124
+ synth.onvoiceschanged = previousHandler;
125
+ }
126
+ if (pendingSettlement &&
127
+ this.settlePendingVoiceWait === pendingSettlement) {
128
+ this.settlePendingVoiceWait = null;
129
+ }
130
+ };
131
+ const finish = (voicesAvailable, error) => {
132
+ if (settled)
133
+ return;
134
+ settled = true;
135
+ cleanup();
136
+ if (error) {
137
+ reject(error);
138
+ }
139
+ else {
140
+ resolve(voicesAvailable);
141
+ }
142
+ };
143
+ const inspectVoiceInventory = () => {
144
+ if (runId !== this.speakRunId) {
145
+ finish(false);
146
+ return;
147
+ }
148
+ if (synth.getVoices().length > 0)
149
+ finish(true);
150
+ };
151
+ const onVoicesChanged = () => inspectVoiceInventory();
152
+ const propertyHandler = (event) => {
153
+ try {
154
+ previousHandler?.call(synth, event);
155
+ }
156
+ finally {
157
+ inspectVoiceInventory();
158
+ }
159
+ };
160
+ pendingSettlement = () => finish(false);
161
+ this.settlePendingVoiceWait = pendingSettlement;
162
+ timeout = setTimeout(() => finish(false, new Error(`Configured browser voice "${configuredVoice}" could not be resolved because the browser did not publish its voice inventory within ${VOICE_INVENTORY_TIMEOUT_MS / 1_000} seconds.`)), VOICE_INVENTORY_TIMEOUT_MS);
163
+ if (canUseEventTarget) {
164
+ synth.addEventListener("voiceschanged", onVoicesChanged);
165
+ }
166
+ else {
167
+ synth.onvoiceschanged = propertyHandler;
168
+ }
169
+ // Close the getVoices()/listener-registration race without polling.
170
+ inspectVoiceInventory();
171
+ });
172
+ }
173
+ async resolveBrowserVoice(runId) {
174
+ const configuredVoice = typeof this.config?.voice === "string" ? this.config.voice.trim() : "";
175
+ let voices = speechSynthesis.getVoices();
176
+ if (configuredVoice && voices.length === 0) {
177
+ const voicesAvailable = await this.waitForBrowserVoices(runId, configuredVoice);
178
+ if (!voicesAvailable || runId !== this.speakRunId) {
179
+ return { shouldContinue: false, voice: null };
180
+ }
181
+ voices = speechSynthesis.getVoices();
182
+ }
183
+ if (runId !== this.speakRunId) {
184
+ return { shouldContinue: false, voice: null };
185
+ }
186
+ const voice = findBrowserVoice(voices, configuredVoice || undefined);
187
+ if (configuredVoice && !voice) {
188
+ throw new Error(`Configured browser voice "${configuredVoice}" is unavailable. Select a voice exposed by this browser using its voiceURI or name.`);
189
+ }
190
+ return { shouldContinue: true, voice };
191
+ }
100
192
  async speak(text) {
101
193
  if (!this.config) {
102
194
  throw new Error("TTS not initialized");
@@ -104,12 +196,15 @@ class BrowserTTSProviderImpl {
104
196
  // Invalidate any in-flight run and cancel current utterance.
105
197
  this.stop();
106
198
  const runId = this.speakRunId;
199
+ const voiceResolution = await this.resolveBrowserVoice(runId);
200
+ if (!voiceResolution.shouldContinue)
201
+ return;
107
202
  const chunks = this.splitIntoChunks(text);
108
203
  for (const chunk of chunks) {
109
204
  if (runId !== this.speakRunId) {
110
205
  break;
111
206
  }
112
- const shouldContinue = await this.speakChunk(chunk.text, chunk.offset, runId);
207
+ const shouldContinue = await this.speakChunk(chunk.text, chunk.offset, runId, voiceResolution.voice);
113
208
  if (!shouldContinue) {
114
209
  break;
115
210
  }
@@ -121,6 +216,9 @@ class BrowserTTSProviderImpl {
121
216
  }
122
217
  this.stop();
123
218
  const runId = this.speakRunId;
219
+ const voiceResolution = await this.resolveBrowserVoice(runId);
220
+ if (!voiceResolution.shouldContinue)
221
+ return;
124
222
  for (const segment of segments) {
125
223
  if (runId !== this.speakRunId)
126
224
  break;
@@ -128,7 +226,7 @@ class BrowserTTSProviderImpl {
128
226
  for (const chunk of chunks) {
129
227
  if (runId !== this.speakRunId)
130
228
  break;
131
- const shouldContinue = await this.speakChunk(chunk.text, segment.startOffset + chunk.offset, runId);
229
+ const shouldContinue = await this.speakChunk(chunk.text, segment.startOffset + chunk.offset, runId, voiceResolution.voice);
132
230
  if (!shouldContinue)
133
231
  break;
134
232
  }
@@ -213,7 +311,7 @@ class BrowserTTSProviderImpl {
213
311
  const match = slice.match(/^\s*([^\s]+)/);
214
312
  return match?.[1]?.length || 1;
215
313
  }
216
- async speakChunk(chunkText, chunkOffset, runId) {
314
+ async speakChunk(chunkText, chunkOffset, runId, voice) {
217
315
  return new Promise((resolve, reject) => {
218
316
  if (runId !== this.speakRunId) {
219
317
  resolve(false);
@@ -222,47 +320,99 @@ class BrowserTTSProviderImpl {
222
320
  const utterance = new SpeechSynthesisUtterance(chunkText);
223
321
  this.utterance = utterance;
224
322
  // Apply config
225
- const voice = findBrowserVoice(speechSynthesis.getVoices(), this.config?.voice);
226
323
  if (voice && shouldAssignBrowserVoice(voice))
227
324
  utterance.voice = voice;
228
325
  if (this.config?.rate)
229
326
  utterance.rate = this.config.rate;
230
327
  if (this.config?.pitch)
231
328
  utterance.pitch = this.config.pitch;
329
+ let didStart = false;
330
+ let settled = false;
331
+ let startTimeout = null;
332
+ let pendingSettlement = null;
232
333
  const clearOwnedUtterance = () => {
233
334
  if (this.utterance === utterance) {
234
335
  this.utterance = null;
235
336
  }
236
337
  };
338
+ const clearStartTimeout = () => {
339
+ if (startTimeout !== null) {
340
+ clearTimeout(startTimeout);
341
+ startTimeout = null;
342
+ }
343
+ };
344
+ const detachHandlers = () => {
345
+ utterance.onstart = null;
346
+ utterance.onend = null;
347
+ utterance.onerror = null;
348
+ utterance.onpause = null;
349
+ utterance.onresume = null;
350
+ utterance.onboundary = null;
351
+ };
352
+ const settle = (shouldContinue, error) => {
353
+ if (settled)
354
+ return;
355
+ settled = true;
356
+ clearStartTimeout();
357
+ detachHandlers();
358
+ clearOwnedUtterance();
359
+ if (pendingSettlement &&
360
+ this.settlePendingChunk === pendingSettlement) {
361
+ this.settlePendingChunk = null;
362
+ }
363
+ if (runId === this.speakRunId) {
364
+ this._isPlaying = false;
365
+ this._isPaused = false;
366
+ }
367
+ if (error) {
368
+ reject(error);
369
+ }
370
+ else {
371
+ resolve(shouldContinue);
372
+ }
373
+ };
374
+ const browserEngineStartError = (reason) => new Error(reason === "ended"
375
+ ? "Browser speech synthesis ended before audio started. The browser speech engine may be unavailable or stuck; try another voice or restart the browser."
376
+ : `Browser speech synthesis did not start within ${NATIVE_START_TIMEOUT_MS / 1_000} seconds. The browser speech engine may be unavailable or stuck; try another voice or restart the browser.`);
377
+ pendingSettlement = () => settle(false);
378
+ this.settlePendingChunk = pendingSettlement;
237
379
  utterance.onstart = () => {
238
- if (runId !== this.speakRunId)
380
+ if (runId !== this.speakRunId) {
381
+ settle(false);
239
382
  return;
383
+ }
384
+ didStart = true;
385
+ clearStartTimeout();
240
386
  this._isPlaying = true;
241
387
  this._isPaused = false;
388
+ try {
389
+ this.onPlaybackStart?.();
390
+ }
391
+ catch (error) {
392
+ console.error("Browser TTS playback-start callback failed", error);
393
+ }
242
394
  };
243
395
  utterance.onend = () => {
244
- clearOwnedUtterance();
245
396
  if (runId !== this.speakRunId) {
246
- resolve(false);
397
+ settle(false);
247
398
  return;
248
399
  }
249
- this._isPlaying = false;
250
- this._isPaused = false;
251
- resolve(true);
400
+ if (!didStart) {
401
+ settle(false, browserEngineStartError("ended"));
402
+ return;
403
+ }
404
+ settle(true);
252
405
  };
253
406
  utterance.onerror = (event) => {
254
- clearOwnedUtterance();
255
407
  if (runId !== this.speakRunId) {
256
- resolve(false);
408
+ settle(false);
257
409
  return;
258
410
  }
259
- this._isPlaying = false;
260
- this._isPaused = false;
261
411
  if (event.error === "interrupted" || event.error === "canceled") {
262
- resolve(false);
412
+ settle(false);
263
413
  return;
264
414
  }
265
- reject(new Error(`Speech synthesis error: ${event.error}`));
415
+ settle(false, new Error(`Speech synthesis error: ${event.error}`));
266
416
  };
267
417
  utterance.onpause = () => {
268
418
  if (runId !== this.speakRunId)
@@ -299,7 +449,20 @@ class BrowserTTSProviderImpl {
299
449
  console.log("[BrowserProvider] Calling onWordBoundary with word:", word, "at position:", absoluteBoundaryStart);
300
450
  this.onWordBoundary(word, absoluteBoundaryStart, wordLength);
301
451
  };
302
- speechSynthesis.speak(utterance);
452
+ startTimeout = setTimeout(() => {
453
+ if (settled || didStart || runId !== this.speakRunId)
454
+ return;
455
+ settle(false, browserEngineStartError("timedOut"));
456
+ speechSynthesis.cancel();
457
+ }, NATIVE_START_TIMEOUT_MS);
458
+ try {
459
+ speechSynthesis.speak(utterance);
460
+ }
461
+ catch (error) {
462
+ settle(false, error instanceof Error
463
+ ? error
464
+ : new Error("Browser speech synthesis failed to queue speech"));
465
+ }
303
466
  });
304
467
  }
305
468
  pause() {
@@ -313,8 +476,12 @@ class BrowserTTSProviderImpl {
313
476
  }
314
477
  }
315
478
  stop() {
479
+ const utterance = this.utterance;
480
+ const shouldCancel = this._isPlaying || utterance !== null;
316
481
  this.speakRunId += 1;
317
- if (this._isPlaying || this.utterance) {
482
+ this.settlePendingVoiceWait?.();
483
+ this.settlePendingChunk?.();
484
+ if (shouldCancel) {
318
485
  speechSynthesis.cancel();
319
486
  }
320
487
  this.utterance = null;