@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
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Recorded audio as a `spoken` catalog card.
3
+ *
4
+ * QTI 3 treats a recording and synthesized speech as the *same* support — both
5
+ * are `spoken`, and a card carries recorded audio through `qti-file-href` plus a
6
+ * MIME type — so this is not a new accommodation but the other form the existing
7
+ * one can take. Some programs prefer a human voice to synthesis; PIE's `spoken`
8
+ * card was string-only, so it had no way to say "play this file for this node".
9
+ *
10
+ * A node commonly carries both forms in the same language: the reading script
11
+ * *and* a recording of it. That is APIP's pattern and what QTI's migration
12
+ * guidance preserves, because the script is both the source the audio was
13
+ * generated from and the fallback for when the audio will not play. Resolution
14
+ * chooses between them with `CatalogLookupOptions.form`; playback treats the
15
+ * script as the recording's fallback.
16
+ *
17
+ * Validation posture matches sign-language cards: "treat as absent, never as
18
+ * partially valid". A malformed payload must not produce a silent player that
19
+ * looks like read-aloud is working.
20
+ *
21
+ * Part of PIE Assessment Toolkit.
22
+ */
23
+ import { normalizeMediaFragment, normalizeMediaSources, trimmedOrUndefined, } from "./catalog-media.js";
24
+ /** Catalog type token for spoken alternates. Matches QTI 3's `support` value. */
25
+ export const SPOKEN_CATALOG_TYPE = "spoken";
26
+ /**
27
+ * Validate a `spoken` card's payload into something playable, or `null`.
28
+ *
29
+ * Silent when the card simply is not a recording — a card carrying `content` is
30
+ * a reading script, which is the overwhelmingly common case and not a fault.
31
+ * Loud when a card looks like it meant to be a recording and cannot be played,
32
+ * because that failure is otherwise invisible to everyone but the learner who
33
+ * needed it.
34
+ */
35
+ export function resolveSpokenAudioMedia(card) {
36
+ if (!card)
37
+ return null;
38
+ const payload = card.payload;
39
+ if (!payload || typeof payload !== "object") {
40
+ // A script card, not a broken audio card. Resolution asks for the payload
41
+ // form as a *preference*, so getting a `content` card back here is normal.
42
+ return null;
43
+ }
44
+ const media = payload.media;
45
+ if (!media || typeof media !== "object") {
46
+ console.warn("[spoken-audio] card carries a `payload` with no `media`; recorded speech needs `media.sources`, so this card is ignored and read-aloud falls back to the script or generated speech");
47
+ return null;
48
+ }
49
+ // A signing video filed under `spoken` is a mis-authored card, not an audio
50
+ // track to guess at. Refusing it keeps the two card types from quietly
51
+ // swapping roles.
52
+ if (media.kind !== undefined && media.kind !== "audio") {
53
+ console.warn(`[spoken-audio] card's media is kind "${media.kind}", not "audio"; a spoken card carries recorded speech, so this card is ignored`);
54
+ return null;
55
+ }
56
+ const sources = normalizeMediaSources(media.sources);
57
+ if (sources.length === 0) {
58
+ console.warn("[spoken-audio] card's `media.sources` yielded no usable URL; read-aloud falls back to the script or generated speech");
59
+ return null;
60
+ }
61
+ return {
62
+ sources,
63
+ fragment: normalizeMediaFragment(payload.fragment),
64
+ label: trimmedOrUndefined(media.label),
65
+ };
66
+ }
@@ -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,
@@ -26,7 +26,8 @@ const findBrowserVoice = (voices, preferredName) => {
26
26
  const languagePrefix = language.split("-")[0] || "en";
27
27
  const matchesLanguage = (voice) => {
28
28
  const voiceLanguage = normalizeLanguageCode(voice.lang);
29
- return voiceLanguage === language || voiceLanguage.startsWith(`${languagePrefix}-`);
29
+ return (voiceLanguage === language ||
30
+ voiceLanguage.startsWith(`${languagePrefix}-`));
30
31
  };
31
32
  const ranked = [
32
33
  (voice) => voice.localService && matchesLanguage(voice),
@@ -1,5 +1,5 @@
1
1
  import { canonicalizeMathML } from "./mathml-sanitization.js";
2
- import { collectVisibleTextAndMap, isNodeHiddenForTTS, normalizeTextForSpeech, shouldInsertWordBoundarySpace, } from "./text-processing.js";
2
+ import { collectVisibleTextAndMap, isNodeExcludedFromSpeech, normalizeTextForSpeech, shouldInsertWordBoundarySpace, } from "./text-processing.js";
3
3
  const createAccumulator = (options) => ({
4
4
  chars: [],
5
5
  map: new Map(),
@@ -226,7 +226,7 @@ const textFallbackFromMathML = (mathml) => {
226
226
  const collectVisibleMathFallback = (element, canonicalMathML, options) => {
227
227
  const mathAcc = createAccumulator(options);
228
228
  const visit = (node) => {
229
- if (isNodeHiddenForTTS(node, element))
229
+ if (isNodeExcludedFromSpeech(node, element))
230
230
  return;
231
231
  if (node.nodeType === 3) {
232
232
  appendTextNode(mathAcc, node);
@@ -284,7 +284,7 @@ const collectMathAware = (root, options) => {
284
284
  textChunkSourceElement = undefined;
285
285
  };
286
286
  const processNode = (node) => {
287
- if (isNodeHiddenForTTS(node, root))
287
+ if (isNodeExcludedFromSpeech(node, root))
288
288
  return;
289
289
  if (node.nodeType === Node.TEXT_NODE) {
290
290
  const sourceElement = resolveTextChunkSourceElement(node, root);
@@ -11,6 +11,57 @@ export declare const normalizeTextForSpeech: (text: string) => string;
11
11
  export declare const createSpeechAlignmentTokenPattern: () => RegExp;
12
12
  export declare const isElementHiddenForTTS: (element: Element) => boolean;
13
13
  export declare const isNodeHiddenForTTS: (node: Node, root?: Element | null) => boolean;
14
+ /**
15
+ * Marks content that must be shown but never spoken — items where reading *is*
16
+ * the construct, such as decoding and spelling, where speaking the node hands
17
+ * over the answer.
18
+ *
19
+ * Not a PNP field: `prohibitedSupports` is the learner declining a support, while
20
+ * this is the item saying "not here, for anyone", so it overrides an entitlement
21
+ * rather than yielding to it.
22
+ *
23
+ * Shape follows QTI 3's `data-qti-suppress-tts` — an attribute on the content
24
+ * element, single-valued, vocabulary below. Element placement is what makes it
25
+ * work on undocked nodes and enforceable in the selection read-aloud path, which
26
+ * consults no catalog. The name follows PIE's `data-tts-*` family, and PIE reads
27
+ * only this spelling; importers map QTI's.
28
+ */
29
+ export declare const TTS_SUPPRESS_ATTRIBUTE = "data-tts-suppress";
30
+ /**
31
+ * Whether this element forbids machine read-aloud of itself and its subtree.
32
+ *
33
+ * Unrecognized and empty values suppress rather than pass through, and say so once
34
+ * per distinct value: a typo that fell through would speak a word the item was
35
+ * measuring, invalidating the score with no visible symptom, whereas
36
+ * over-suppressing only withholds speech an author had already marked as withheld.
37
+ */
38
+ export declare const isElementSuppressedForTTS: (element: Element) => boolean;
39
+ export declare const isNodeSuppressedForTTS: (node: Node, root?: Element | null) => boolean;
40
+ /**
41
+ * The predicate every speech-producing path filters on: hidden *or* suppressed.
42
+ *
43
+ * Kept distinct from `isNodeHiddenForTTS`, which stays a question about
44
+ * visibility — suppressed content is visible on purpose, and the highlight
45
+ * geometry resolvers that ask "can the candidate see this" must keep getting
46
+ * the visibility answer rather than this one.
47
+ */
48
+ export declare const isNodeExcludedFromSpeech: (node: Node, root?: Element | null) => boolean;
49
+ /**
50
+ * Text of a range with the parts that must not be spoken removed.
51
+ *
52
+ * `Range.toString()` is not usable for speech: it is pure character extraction
53
+ * and honours no DOM filter at all, so it happily returns suppressed — and
54
+ * hidden — text. The selection read-aloud path is a text-in path rather than a
55
+ * DOM walk, which makes this the only place its content can be filtered.
56
+ *
57
+ * `filtered` reports whether anything was dropped, so a caller can tell "the
58
+ * candidate selected nothing speakable" apart from "the candidate selected
59
+ * nothing".
60
+ */
61
+ export declare const collectRangeTextForSpeech: (range: Range, root: Element) => {
62
+ text: string;
63
+ filtered: boolean;
64
+ };
14
65
  export declare const shouldInsertWordBoundarySpace: (previousChar: string | null, nextChar: string | null, options?: TextProcessingOptions) => boolean;
15
66
  export declare const collectVisibleTextAndMap: (element: Element, options?: TextProcessingOptions) => {
16
67
  text: string;
@@ -62,6 +62,122 @@ export const isNodeHiddenForTTS = (node, root) => {
62
62
  }
63
63
  return false;
64
64
  };
65
+ /**
66
+ * Marks content that must be shown but never spoken — items where reading *is*
67
+ * the construct, such as decoding and spelling, where speaking the node hands
68
+ * over the answer.
69
+ *
70
+ * Not a PNP field: `prohibitedSupports` is the learner declining a support, while
71
+ * this is the item saying "not here, for anyone", so it overrides an entitlement
72
+ * rather than yielding to it.
73
+ *
74
+ * Shape follows QTI 3's `data-qti-suppress-tts` — an attribute on the content
75
+ * element, single-valued, vocabulary below. Element placement is what makes it
76
+ * work on undocked nodes and enforceable in the selection read-aloud path, which
77
+ * consults no catalog. The name follows PIE's `data-tts-*` family, and PIE reads
78
+ * only this spelling; importers map QTI's.
79
+ */
80
+ export const TTS_SUPPRESS_ATTRIBUTE = "data-tts-suppress";
81
+ const SUPPRESSES_COMPUTER_READ_ALOUD = new Set(["computer-read-aloud", "all"]);
82
+ // `screen-reader` is in the vocabulary but is not ours: it asks the delivery
83
+ // engine to hide the node from assistive technology, which is the host's job
84
+ // (and is what `aria-hidden` above already covers on the way in). A node marked
85
+ // only `screen-reader` is still legitimately machine-read aloud.
86
+ const SUPPRESS_VALUES = new Set([
87
+ ...SUPPRESSES_COMPUTER_READ_ALOUD,
88
+ "screen-reader",
89
+ ]);
90
+ const warnedSuppressValues = new Set();
91
+ /**
92
+ * Whether this element forbids machine read-aloud of itself and its subtree.
93
+ *
94
+ * Unrecognized and empty values suppress rather than pass through, and say so once
95
+ * per distinct value: a typo that fell through would speak a word the item was
96
+ * measuring, invalidating the score with no visible symptom, whereas
97
+ * over-suppressing only withholds speech an author had already marked as withheld.
98
+ */
99
+ export const isElementSuppressedForTTS = (element) => {
100
+ const raw = element.getAttribute?.(TTS_SUPPRESS_ATTRIBUTE);
101
+ if (raw === null || raw === undefined)
102
+ return false;
103
+ const value = raw.trim().toLowerCase();
104
+ if (SUPPRESSES_COMPUTER_READ_ALOUD.has(value))
105
+ return true;
106
+ if (SUPPRESS_VALUES.has(value))
107
+ return false;
108
+ if (!warnedSuppressValues.has(value)) {
109
+ warnedSuppressValues.add(value);
110
+ console.warn(`[tts] ${TTS_SUPPRESS_ATTRIBUTE}="${raw}" is not one of ${Array.from(SUPPRESS_VALUES).join(", ")}; suppressing read-aloud for this content anyway, because a suppression attribute that fails open would leak the answer to items where reading is the construct. Correct the value to silence this.`);
111
+ }
112
+ return true;
113
+ };
114
+ export const isNodeSuppressedForTTS = (node, root) => {
115
+ let current = node.nodeType === 1
116
+ ? node
117
+ : node.parentElement;
118
+ while (current) {
119
+ if (isElementSuppressedForTTS(current))
120
+ return true;
121
+ if (root && current === root)
122
+ break;
123
+ current = current.parentElement;
124
+ }
125
+ return false;
126
+ };
127
+ /**
128
+ * The predicate every speech-producing path filters on: hidden *or* suppressed.
129
+ *
130
+ * Kept distinct from `isNodeHiddenForTTS`, which stays a question about
131
+ * visibility — suppressed content is visible on purpose, and the highlight
132
+ * geometry resolvers that ask "can the candidate see this" must keep getting
133
+ * the visibility answer rather than this one.
134
+ */
135
+ export const isNodeExcludedFromSpeech = (node, root) => isNodeHiddenForTTS(node, root) || isNodeSuppressedForTTS(node);
136
+ /**
137
+ * Text of a range with the parts that must not be spoken removed.
138
+ *
139
+ * `Range.toString()` is not usable for speech: it is pure character extraction
140
+ * and honours no DOM filter at all, so it happily returns suppressed — and
141
+ * hidden — text. The selection read-aloud path is a text-in path rather than a
142
+ * DOM walk, which makes this the only place its content can be filtered.
143
+ *
144
+ * `filtered` reports whether anything was dropped, so a caller can tell "the
145
+ * candidate selected nothing speakable" apart from "the candidate selected
146
+ * nothing".
147
+ */
148
+ export const collectRangeTextForSpeech = (range, root) => {
149
+ if (typeof document === "undefined" ||
150
+ typeof document.createTreeWalker !==
151
+ "function" ||
152
+ typeof NodeFilter === "undefined" ||
153
+ typeof range.intersectsNode !== "function") {
154
+ // Degraded, and deliberately not silent about the difference: callers still
155
+ // enforce whole-selection suppression from the range's common ancestor, so
156
+ // the construct guard holds even here. What is lost is per-node filtering
157
+ // of a selection that only partly overlaps suppressed content.
158
+ return { text: range.toString(), filtered: false };
159
+ }
160
+ const parts = [];
161
+ let filtered = false;
162
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
163
+ let current = walker.nextNode();
164
+ while (current) {
165
+ const textNode = current;
166
+ if (range.intersectsNode(textNode)) {
167
+ if (isNodeExcludedFromSpeech(textNode, root)) {
168
+ filtered = true;
169
+ }
170
+ else {
171
+ const raw = textNode.textContent || "";
172
+ const start = textNode === range.startContainer ? range.startOffset : 0;
173
+ const end = textNode === range.endContainer ? range.endOffset : raw.length;
174
+ parts.push(raw.slice(start, end));
175
+ }
176
+ }
177
+ current = walker.nextNode();
178
+ }
179
+ return { text: parts.join(""), filtered };
180
+ };
65
181
  export const shouldInsertWordBoundarySpace = (previousChar, nextChar, options) => {
66
182
  if (!previousChar || !nextChar)
67
183
  return false;
@@ -111,7 +227,7 @@ export const collectVisibleTextAndMap = (element, options) => {
111
227
  while (current) {
112
228
  const textNode = current;
113
229
  const parent = textNode.parentElement;
114
- if (parent && !isNodeHiddenForTTS(textNode, element)) {
230
+ if (parent && !isNodeExcludedFromSpeech(textNode, element)) {
115
231
  const raw = textNode.textContent || "";
116
232
  const firstVisibleMatch = raw.match(/\S/);
117
233
  const firstVisibleChar = firstVisibleMatch ? firstVisibleMatch[0] : null;
@@ -117,7 +117,11 @@ export const normalizeTTSSpeedControlOptions = (value) => {
117
117
  if (rate === undefined || dedupedRates.has(rate))
118
118
  continue;
119
119
  dedupedRates.add(rate);
120
- const defaultLabel = rate === 1 ? "Normal" : typeof entry === "number" ? formatSpeedLabel(rate) : formatSpeedLabel(rate);
120
+ const defaultLabel = rate === 1
121
+ ? "Normal"
122
+ : typeof entry === "number"
123
+ ? formatSpeedLabel(rate)
124
+ : formatSpeedLabel(rate);
121
125
  const label = typeof entry === "number"
122
126
  ? defaultLabel
123
127
  : trimOptionalText(record.label) || defaultLabel;
@@ -131,7 +135,8 @@ export const normalizeTTSSpeedControlOptions = (value) => {
131
135
  label,
132
136
  ariaLabel,
133
137
  isDefault: false,
134
- requestedDefault: record.default === true || record.isDefault === true,
138
+ requestedDefault: record.default === true ||
139
+ record.isDefault === true,
135
140
  });
136
141
  }
137
142
  if (!normalized.length) {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Registration-authoring surface.
3
+ *
4
+ * What a package outside this one needs to write a `ToolRegistration`: the
5
+ * registration types, the context predicates a tool answers `isVisibleInContext`
6
+ * with, scoped-id and element-creation helpers, the toolbar button/overlay
7
+ * helpers, and the provider descriptors for the two capabilities that ship a
8
+ * provider.
9
+ *
10
+ * A separate entry point rather than additions to `.` for the same reason
11
+ * `runtime/internal` and `policy/internal` exist: this is a surface for sibling
12
+ * packages in this repo, not a contract offered to hosts. Widening `.` with two
13
+ * dozen registration-authoring helpers would make every one of them something a
14
+ * host could reasonably expect us to keep.
15
+ *
16
+ * It exists because the packaged registrations moved out to the composition layer
17
+ * (`@pie-players/pie-default-tool-loaders`) so core stops naming capabilities.
18
+ * A host writing its own capability package imports from here too, which is the
19
+ * point — the mechanism is the same one our own registrations use.
20
+ */
21
+ export type { HostedToolContext, HostedToolSize, ResolvedToolContext, ToolActivation, ToolContentDependency, ToolContentDependencyContext, ToolModuleLoader, ToolProviderDescriptor, ToolRegistration, ToolRenderElement, ToolSingletonScope, ToolSurfaceRenderContext, ToolSurfaceRenderResult, ToolSurfaceServices, ToolToolbarButtonDefinition, ToolToolbarRenderResult, ToolWindowShellConfig, ToolbarContext, } from "../services/ToolRegistry.js";
22
+ export { ToolRegistry } from "../services/ToolRegistry.js";
23
+ export type { ToolContext, ToolLevel } from "../services/tool-context.js";
24
+ export { hasChoiceInteraction, hasMathContent, hasReadableText, hasScienceContent, } from "../services/tool-context.js";
25
+ export { createScopedToolId } from "../services/tool-instance-id.js";
26
+ export type { ToolComponentFactory, ToolComponentFactoryMap, ToolComponentOverrides, ToolTagMap, } from "./tool-tag-map.js";
27
+ export { createToolElement, resolveToolTag, toToolIdFromTag, } from "./tool-tag-map.js";
28
+ export { applyOverlaySurface, createScopedVisibilityBinding, syncButtonAndOverlayVisibility, } from "./registrations/toolbar-registration-helpers.js";
29
+ export type { ElementToolStateStoreApi, ToolCoordinatorApi, ToolkitCoordinatorApi, TtsServiceApi, } from "../services/interfaces.js";
30
+ export type { ToolPlacementConfig, ToolProviderConfig, } from "../services/tools-config-normalizer.js";
31
+ export type { ToolConfigDiagnostic } from "../services/tool-config-validation.js";
32
+ export { DesmosToolProvider, TTSToolProvider, } from "../services/tool-providers/index.js";
33
+ export type { NormalizedTTSSpeedOption } from "../services/tts-runtime-config.js";
34
+ export { buildRuntimeTTSConfig, normalizeTTSLayoutMode, normalizeTTSSpeedControlOptions, resolveRuntimeProvider, resolveTTSBackend, resolveTTSHostToolbarLayout, resolveTTSLayoutMode, resolveTTSRuntimeSettings, resolveTransportMode, } from "../services/tts-runtime-config.js";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Registration-authoring surface.
3
+ *
4
+ * What a package outside this one needs to write a `ToolRegistration`: the
5
+ * registration types, the context predicates a tool answers `isVisibleInContext`
6
+ * with, scoped-id and element-creation helpers, the toolbar button/overlay
7
+ * helpers, and the provider descriptors for the two capabilities that ship a
8
+ * provider.
9
+ *
10
+ * A separate entry point rather than additions to `.` for the same reason
11
+ * `runtime/internal` and `policy/internal` exist: this is a surface for sibling
12
+ * packages in this repo, not a contract offered to hosts. Widening `.` with two
13
+ * dozen registration-authoring helpers would make every one of them something a
14
+ * host could reasonably expect us to keep.
15
+ *
16
+ * It exists because the packaged registrations moved out to the composition layer
17
+ * (`@pie-players/pie-default-tool-loaders`) so core stops naming capabilities.
18
+ * A host writing its own capability package imports from here too, which is the
19
+ * point — the mechanism is the same one our own registrations use.
20
+ */
21
+ export { ToolRegistry } from "../services/ToolRegistry.js";
22
+ export { hasChoiceInteraction, hasMathContent, hasReadableText, hasScienceContent, } from "../services/tool-context.js";
23
+ // Scoped tool instance ids, so two placements of one tool do not share state.
24
+ export { createScopedToolId } from "../services/tool-instance-id.js";
25
+ export { createToolElement, resolveToolTag, toToolIdFromTag, } from "./tool-tag-map.js";
26
+ // Toolbar button/overlay wiring shared by every toolbar-toggle registration.
27
+ export { applyOverlaySurface, createScopedVisibilityBinding, syncButtonAndOverlayVisibility, } from "./registrations/toolbar-registration-helpers.js";
28
+ // Provider descriptors for the two packaged capabilities that ship one. These
29
+ // stay here rather than moving with the registrations because they are written
30
+ // against the `pie-calculator` and `pie-tts` contract packages, which the
31
+ // toolkit's own `TTSService` and provider registry also depend on.
32
+ export { DesmosToolProvider, TTSToolProvider, } from "../services/tool-providers/index.js";
33
+ export { buildRuntimeTTSConfig, normalizeTTSLayoutMode, normalizeTTSSpeedControlOptions, resolveRuntimeProvider, resolveTTSBackend, resolveTTSHostToolbarLayout, resolveTTSLayoutMode, resolveTTSRuntimeSettings, resolveTransportMode, } from "../services/tts-runtime-config.js";
@@ -14,10 +14,22 @@ export interface ToolComponentOverrides {
14
14
  toolComponentFactories?: Partial<ToolComponentFactoryMap>;
15
15
  }
16
16
  /**
17
- * Canonical default web component tag mapping for toolkit tools.
18
- * Integrators can override any entry via ToolComponentOverrides.
17
+ * Resolve the element tag for a tool from the overrides in play.
18
+ *
19
+ * There is no built-in map to fall back to. One lived here, naming eleven
20
+ * capabilities, and a core module holding that catalogue is why a host could not
21
+ * add a twelfth without editing this package — the map was overridable, but the
22
+ * default was core-resident. The packaged map is `PACKAGED_TOOL_TAG_MAP` in
23
+ * `@pie-players/pie-default-tool-loaders`, installed onto a registry through
24
+ * `ToolRegistry.setComponentOverrides`.
25
+ *
26
+ * An unmapped toolId falls through to itself, which is only a valid tag if the
27
+ * host's tool id already looks like one. Every packaged id is a single camelCase
28
+ * word, so in practice an unmapped packaged tool throws here rather than
29
+ * rendering a bogus element — the failure names the missing mapping, because
30
+ * "custom element names must include a hyphen" is a true statement about the
31
+ * wrong thing.
19
32
  */
20
- export declare const DEFAULT_TOOL_TAG_MAP: ToolTagMap;
21
33
  export declare const resolveToolTag: (toolId: string, overrides?: ToolComponentOverrides) => string;
22
34
  export declare const createToolElement: (toolId: string, context: ToolContext, toolbarContext: ToolbarContext, overrides?: ToolComponentOverrides) => HTMLElement;
23
35
  export declare const toToolIdFromTag: (tagName: string, overrides?: ToolComponentOverrides) => string | undefined;
@@ -1,24 +1,27 @@
1
1
  import { validateCustomElementTag } from "@pie-players/pie-players-shared/pie/tag-names";
2
2
  /**
3
- * Canonical default web component tag mapping for toolkit tools.
4
- * Integrators can override any entry via ToolComponentOverrides.
3
+ * Resolve the element tag for a tool from the overrides in play.
4
+ *
5
+ * There is no built-in map to fall back to. One lived here, naming eleven
6
+ * capabilities, and a core module holding that catalogue is why a host could not
7
+ * add a twelfth without editing this package — the map was overridable, but the
8
+ * default was core-resident. The packaged map is `PACKAGED_TOOL_TAG_MAP` in
9
+ * `@pie-players/pie-default-tool-loaders`, installed onto a registry through
10
+ * `ToolRegistry.setComponentOverrides`.
11
+ *
12
+ * An unmapped toolId falls through to itself, which is only a valid tag if the
13
+ * host's tool id already looks like one. Every packaged id is a single camelCase
14
+ * word, so in practice an unmapped packaged tool throws here rather than
15
+ * rendering a bogus element — the failure names the missing mapping, because
16
+ * "custom element names must include a hyphen" is a true statement about the
17
+ * wrong thing.
5
18
  */
6
- export const DEFAULT_TOOL_TAG_MAP = {
7
- calculator: "pie-tool-calculator",
8
- textToSpeech: "pie-tool-text-to-speech",
9
- ruler: "pie-tool-ruler",
10
- protractor: "pie-tool-protractor",
11
- answerEliminator: "pie-tool-answer-eliminator",
12
- highlighter: "pie-tool-annotation-toolbar",
13
- lineReader: "pie-tool-line-reader",
14
- theme: "pie-tool-theme",
15
- annotationToolbar: "pie-tool-annotation-toolbar",
16
- graph: "pie-tool-graph",
17
- periodicTable: "pie-tool-periodic-table",
18
- };
19
19
  export const resolveToolTag = (toolId, overrides) => {
20
- const mapped = overrides?.toolTagMap?.[toolId] ?? DEFAULT_TOOL_TAG_MAP[toolId] ?? toolId;
21
- return validateCustomElementTag(mapped, `tool component tag for "${toolId}"`);
20
+ const mapped = overrides?.toolTagMap?.[toolId];
21
+ if (mapped === undefined && !toolId.includes("-")) {
22
+ throw new Error(`No element tag is mapped for tool "${toolId}". Install a tag map on the registry via setComponentOverrides({ toolTagMap }) — for the packaged capabilities, PACKAGED_TOOL_TAG_MAP from "@pie-players/pie-default-tool-loaders".`);
23
+ }
24
+ return validateCustomElementTag(mapped ?? toolId, `tool component tag for "${toolId}"`);
22
25
  };
23
26
  const createDefaultToolElement = (tagName) => document.createElement(tagName);
24
27
  export const createToolElement = (toolId, context, toolbarContext, overrides) => {
@@ -31,6 +34,6 @@ export const createToolElement = (toolId, context, toolbarContext, overrides) =>
31
34
  };
32
35
  export const toToolIdFromTag = (tagName, overrides) => {
33
36
  const validTag = validateCustomElementTag(tagName, "tool component tag");
34
- const map = { ...DEFAULT_TOOL_TAG_MAP, ...overrides?.toolTagMap };
37
+ const map = overrides?.toolTagMap ?? {};
35
38
  return Object.entries(map).find(([, tag]) => tag === validTag)?.[0];
36
39
  };