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

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 (38) hide show
  1. package/README.md +63 -0
  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-843902tp.js → ItemToolBar-3cppre9r.js} +26 -26
  6. package/dist/components/chunks/{ItemToolBar-84nv78dy.js → ItemToolBar-7rq2gj8b.js} +1 -1
  7. package/dist/index.d.ts +8 -3
  8. package/dist/index.js +5 -2
  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/services/AccessibilityCatalogResolver.d.ts +100 -4
  19. package/dist/services/AccessibilityCatalogResolver.js +183 -58
  20. package/dist/services/SSMLExtractor.js +28 -18
  21. package/dist/services/TTSService.d.ts +25 -0
  22. package/dist/services/TTSService.js +241 -45
  23. package/dist/services/ToolkitCoordinator.d.ts +23 -2
  24. package/dist/services/ToolkitCoordinator.js +24 -0
  25. package/dist/services/catalog-media.d.ts +25 -0
  26. package/dist/services/catalog-media.js +101 -0
  27. package/dist/services/defaultPersonalNeedsProfile.d.ts +16 -0
  28. package/dist/services/defaultPersonalNeedsProfile.js +23 -0
  29. package/dist/services/interfaces.d.ts +29 -2
  30. package/dist/services/pnp-standard-features.d.ts +1 -1
  31. package/dist/services/sign-language-cards.d.ts +82 -0
  32. package/dist/services/sign-language-cards.js +133 -0
  33. package/dist/services/spoken-audio-cards.d.ts +54 -0
  34. package/dist/services/spoken-audio-cards.js +66 -0
  35. package/dist/services/tts/math-aware-text-processing.js +3 -3
  36. package/dist/services/tts/text-processing.d.ts +51 -0
  37. package/dist/services/tts/text-processing.js +117 -1
  38. package/package.json +9 -9
@@ -15,9 +15,11 @@
15
15
  *
16
16
  * Part of PIE Assessment Toolkit.
17
17
  */
18
+ import { applyMediaFragment } from "./catalog-media.js";
18
19
  import { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
20
+ import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
19
21
  import { BrowserTTSProvider } from "./tts/browser-provider.js";
20
- import { collectVisibleTextAndMap, isElementHiddenForTTS, isNodeHiddenForTTS, normalizeTextForSpeech, } from "./tts/text-processing.js";
22
+ import { collectRangeTextForSpeech, collectVisibleTextAndMap, isElementHiddenForTTS, isNodeExcludedFromSpeech, isNodeSuppressedForTTS, normalizeTextForSpeech, } from "./tts/text-processing.js";
21
23
  import { createCatalogSpanAlignment, } from "./tts/catalog-span-alignment.js";
22
24
  import { createMathAwareAlignment, } from "./tts/math-alignment/index.js";
23
25
  import { collectMathAwareTextAndMap } from "./tts/math-aware-text-processing.js";
@@ -88,6 +90,10 @@ export class TTSService {
88
90
  activeWordBoundaryOffset = 0;
89
91
  seekSegments = [];
90
92
  playbackChunks = [];
93
+ // The recording currently playing, if any. `cancel` settles the pending play
94
+ // promise as well as stopping the element, so stop/seek cannot wedge the
95
+ // chunk loop on a run that has already been superseded.
96
+ activeRecordedAudio = null;
91
97
  sentenceHighlightSegments = [];
92
98
  currentSeekSegmentIndex = 0;
93
99
  activeSentenceStartOffset = null;
@@ -234,6 +240,31 @@ export class TTSService {
234
240
  setCatalogResolver(resolver) {
235
241
  this.catalogResolver = resolver;
236
242
  }
243
+ /**
244
+ * Whether a catalog holds spoken content this service could actually speak.
245
+ *
246
+ * Exists because `data-catalog-idref` is one attribute with several readers:
247
+ * a node can be docked to a catalog that carries only a signing card, and the
248
+ * nearest docked ancestor of a selection is therefore not necessarily the one
249
+ * holding its authored SSML. A caller resolving a selection climbs ancestors
250
+ * and asks this before settling on an id — see the TTS tool's
251
+ * `findSpokenCatalogId`. Without it, a signing card docked on an inner node
252
+ * silently shadows authored speech on an outer one.
253
+ */
254
+ hasSpokenAlternate(catalogId, language = "en-US") {
255
+ if (!catalogId || !this.catalogResolver)
256
+ return false;
257
+ const resolved = this.catalogResolver.getAlternative(catalogId, {
258
+ type: "spoken",
259
+ language,
260
+ useFallback: true,
261
+ // The string form specifically. A node may also carry a recording of the
262
+ // same script; that one is not what this reports on, and card order must
263
+ // not decide the answer.
264
+ form: "content",
265
+ });
266
+ return resolved?.content !== undefined;
267
+ }
237
268
  getHighlightResolverRuntime() {
238
269
  let provided = null;
239
270
  try {
@@ -584,7 +615,9 @@ export class TTSService {
584
615
  while (currentNode) {
585
616
  const textNode = currentNode;
586
617
  const parent = textNode.parentElement;
587
- if (parent && !this.isElementHidden(parent)) {
618
+ if (parent &&
619
+ !this.isElementHidden(parent) &&
620
+ !isNodeSuppressedForTTS(textNode)) {
588
621
  const boundary = this.getBoundaryAnchor(textNode, contentElement);
589
622
  const boundaryPoint = nodeStartOffsets.get(textNode);
590
623
  if (boundary &&
@@ -1083,16 +1116,20 @@ export class TTSService {
1083
1116
  language: options.language || "en-US",
1084
1117
  useFallback: true,
1085
1118
  context: options.catalogContext,
1119
+ form: "content",
1086
1120
  });
1087
- if (catalogContent) {
1121
+ // A card with no string form has nothing to speak — a signing card, for
1122
+ // instance. Fall through to generated TTS rather than speaking "".
1123
+ const spokenText = catalogContent?.content;
1124
+ if (catalogContent && spokenText !== undefined) {
1088
1125
  const visibleText = options?.contentElement
1089
1126
  ? collectMathAwareTextAndMap(options.contentElement, this.getTextProcessingOptions(options.language)).visibleText || normalizedInputText
1090
1127
  : normalizedInputText;
1091
- const normalizedCatalogText = normalizeTextForSpeech(catalogContent.content);
1128
+ const normalizedCatalogText = normalizeTextForSpeech(spokenText);
1092
1129
  this.debugLog(`[TTSService] Using catalog content for "${options.catalogId}" (${catalogContent.language})`);
1093
1130
  return {
1094
- contentToSpeak: catalogContent.content,
1095
- speechText: catalogContent.content,
1131
+ contentToSpeak: spokenText,
1132
+ speechText: spokenText,
1096
1133
  visibleText,
1097
1134
  highlightText: visibleText,
1098
1135
  usedCatalogSpoken: true,
@@ -1166,12 +1203,32 @@ export class TTSService {
1166
1203
  const catalogIdRef = element.getAttribute("data-catalog-idref");
1167
1204
  if (!catalogIdRef)
1168
1205
  return null;
1169
- return this.catalogResolver.getAlternative(catalogIdRef, {
1206
+ const lookup = {
1170
1207
  type: "spoken",
1171
1208
  language: options.language || "en-US",
1172
1209
  useFallback: true,
1173
1210
  context: options.catalogContext,
1211
+ };
1212
+ // Two lookups rather than one, because `form` is a preference: asking for
1213
+ // the payload form happily returns a script card when no recording
1214
+ // exists, so the answer has to be checked rather than assumed.
1215
+ const audioCard = this.catalogResolver.getAlternative(catalogIdRef, {
1216
+ ...lookup,
1217
+ form: "payload",
1174
1218
  });
1219
+ const audio = audioCard?.payload
1220
+ ? (resolveSpokenAudioMedia({ payload: audioCard.payload }) ?? undefined)
1221
+ : undefined;
1222
+ const scriptCard = this.catalogResolver.getAlternative(catalogIdRef, {
1223
+ ...lookup,
1224
+ form: "content",
1225
+ });
1226
+ // Only a card with a string form can contribute synthesized speech. The
1227
+ // same docking node may also carry a signing card; that one is not ours.
1228
+ const script = scriptCard?.content;
1229
+ if (audio === undefined && script === undefined)
1230
+ return null;
1231
+ return { script, audio };
1175
1232
  };
1176
1233
  const getSingleMathElementForAlignment = (element, visibleText) => {
1177
1234
  const mathElements = getMathElementsForAlignment(element);
@@ -1192,7 +1249,10 @@ export class TTSService {
1192
1249
  return mathElements;
1193
1250
  };
1194
1251
  const visit = (node) => {
1195
- if (isNodeHiddenForTTS(node, root))
1252
+ // Before `resolveCatalog`, so suppression beats an authored `spoken`
1253
+ // card on the same node: the card says how to speak this content, the
1254
+ // suppression says it must not be spoken at all.
1255
+ if (isNodeExcludedFromSpeech(node, root))
1196
1256
  return;
1197
1257
  if (node.nodeType === Node.TEXT_NODE) {
1198
1258
  textBuffer += ` ${node.textContent || ""}`;
@@ -1207,39 +1267,71 @@ export class TTSService {
1207
1267
  const collectedVisible = collectMathAwareTextAndMap(element, this.getTextProcessingOptions(options.language));
1208
1268
  const visibleText = collectedVisible.visibleText ||
1209
1269
  normalizeTextForSpeech(element.textContent || "");
1210
- const alignment = createCatalogSpanAlignment({
1211
- speechText: catalog.content,
1212
- visibleText,
1213
- });
1214
- const mathElement = getSingleMathElementForAlignment(element, visibleText);
1215
- const mathAlignment = mathElement
1216
- ? createMathAwareAlignment({
1217
- mathElement,
1218
- speechText: catalog.content,
1219
- })
1220
- : undefined;
1221
- const mathAlignments = mathAlignment
1222
- ? undefined
1223
- : getMathElementsForAlignment(element).map((candidate) => ({
1224
- element: candidate,
1225
- alignment: createMathAwareAlignment({
1226
- mathElement: candidate,
1227
- speechText: catalog.content,
1228
- }),
1229
- }));
1230
- const speechText = normalizeTextForSpeech(catalog.content);
1231
- chunks.push({
1232
- speechText: catalog.content,
1233
- visibleText,
1234
- sourceElement: element,
1235
- regionElement: resolveReadableRegion(element, root),
1236
- speechMatchesVisibleText: speechText === visibleText,
1237
- playbackMode: alignment.playbackMode,
1238
- alignment,
1239
- mathAlignment,
1240
- mathAlignments,
1241
- visibleMap: collectedVisible.map,
1242
- });
1270
+ const regionElement = resolveReadableRegion(element, root);
1271
+ // The script chunk, when there is a script. Built exactly as before,
1272
+ // and it doubles as the recording's fallback: word-level alignment is
1273
+ // only meaningful for the synthesized path, which is the one that runs
1274
+ // if the audio cannot play.
1275
+ const scriptChunk = catalog.script !== undefined
1276
+ ? (() => {
1277
+ const script = catalog.script;
1278
+ const alignment = createCatalogSpanAlignment({
1279
+ speechText: script,
1280
+ visibleText,
1281
+ });
1282
+ const mathElement = getSingleMathElementForAlignment(element, visibleText);
1283
+ const mathAlignment = mathElement
1284
+ ? createMathAwareAlignment({
1285
+ mathElement,
1286
+ speechText: script,
1287
+ })
1288
+ : undefined;
1289
+ const mathAlignments = mathAlignment
1290
+ ? undefined
1291
+ : getMathElementsForAlignment(element).map((candidate) => ({
1292
+ element: candidate,
1293
+ alignment: createMathAwareAlignment({
1294
+ mathElement: candidate,
1295
+ speechText: script,
1296
+ }),
1297
+ }));
1298
+ return {
1299
+ speechText: script,
1300
+ visibleText,
1301
+ sourceElement: element,
1302
+ regionElement,
1303
+ speechMatchesVisibleText: normalizeTextForSpeech(script) === visibleText,
1304
+ playbackMode: alignment.playbackMode,
1305
+ alignment,
1306
+ mathAlignment,
1307
+ mathAlignments,
1308
+ visibleMap: collectedVisible.map,
1309
+ };
1310
+ })()
1311
+ : null;
1312
+ if (catalog.audio) {
1313
+ chunks.push(scriptChunk
1314
+ ? {
1315
+ ...scriptChunk,
1316
+ audio: catalog.audio,
1317
+ plainFallback: scriptChunk,
1318
+ }
1319
+ : {
1320
+ // No script to fall back to, and nothing synthesizes the
1321
+ // recording's words, so `speechText` exists only to keep seek
1322
+ // and offset bookkeeping consistent with the visible text.
1323
+ speechText: visibleText,
1324
+ visibleText,
1325
+ sourceElement: element,
1326
+ regionElement,
1327
+ speechMatchesVisibleText: false,
1328
+ visibleMap: collectedVisible.map,
1329
+ audio: catalog.audio,
1330
+ });
1331
+ return;
1332
+ }
1333
+ if (scriptChunk)
1334
+ chunks.push(scriptChunk);
1243
1335
  return;
1244
1336
  }
1245
1337
  for (const child of Array.from(element.childNodes)) {
@@ -1519,10 +1611,92 @@ export class TTSService {
1519
1611
  throw error;
1520
1612
  }
1521
1613
  }
1614
+ /**
1615
+ * Play a recorded spoken alternate, resolving when it finishes.
1616
+ *
1617
+ * Rejects if the clip cannot play, which is what routes playback to the
1618
+ * chunk's `plainFallback` — the reading script — in `speakCatalogChunk`.
1619
+ * Highlighting is the docked node as a block for the clip's duration: a
1620
+ * recording emits no word boundaries, and inventing them from its duration
1621
+ * would highlight the wrong words confidently rather than the right region
1622
+ * vaguely.
1623
+ */
1624
+ async playRecordedAudio(media) {
1625
+ if (typeof document === "undefined") {
1626
+ throw new Error("[tts] no document available to play recorded audio");
1627
+ }
1628
+ const source = media.sources[0];
1629
+ const element = document.createElement("audio");
1630
+ element.preload = "auto";
1631
+ element.src = applyMediaFragment(source.src, media.fragment);
1632
+ element.playbackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate || 1));
1633
+ await new Promise((resolve, reject) => {
1634
+ let endGuard;
1635
+ const cleanup = () => {
1636
+ element.removeEventListener("ended", onEnded);
1637
+ element.removeEventListener("error", onError);
1638
+ if (endGuard !== undefined)
1639
+ clearInterval(endGuard);
1640
+ if (this.activeRecordedAudio?.element === element) {
1641
+ this.activeRecordedAudio = null;
1642
+ }
1643
+ };
1644
+ const onEnded = () => {
1645
+ cleanup();
1646
+ resolve();
1647
+ };
1648
+ const onError = () => {
1649
+ cleanup();
1650
+ reject(new Error(`[tts] recorded audio failed to play: ${source.src}`));
1651
+ };
1652
+ // Cancellation has to settle this promise, not just stop the element:
1653
+ // `stop()` bumps the run id, and a pending play that never resolves would
1654
+ // wedge the chunk loop on a run nobody is listening to any more.
1655
+ this.activeRecordedAudio = {
1656
+ element,
1657
+ cancel: () => {
1658
+ cleanup();
1659
+ element.pause();
1660
+ resolve();
1661
+ },
1662
+ };
1663
+ element.addEventListener("ended", onEnded);
1664
+ element.addEventListener("error", onError);
1665
+ // Browsers honour a Media Fragments start offset but are inconsistent
1666
+ // about the end bound, so the end is enforced here — the same reason the
1667
+ // signing region enforces its own.
1668
+ const endSeconds = media.fragment?.endSeconds;
1669
+ if (endSeconds !== undefined) {
1670
+ endGuard = setInterval(() => {
1671
+ if (element.currentTime >= endSeconds)
1672
+ onEnded();
1673
+ }, 100);
1674
+ }
1675
+ Promise.resolve(element.play()).catch(onError);
1676
+ }).finally(() => {
1677
+ if (this.activeRecordedAudio?.element === element) {
1678
+ this.activeRecordedAudio = null;
1679
+ }
1680
+ element.pause();
1681
+ element.removeAttribute("src");
1682
+ });
1683
+ }
1684
+ cancelRecordedAudio() {
1685
+ const active = this.activeRecordedAudio;
1686
+ if (!active)
1687
+ return;
1688
+ this.activeRecordedAudio = null;
1689
+ active.cancel();
1690
+ }
1522
1691
  async speakCatalogChunkOnce(chunk, runId) {
1523
1692
  if (!this.provider)
1524
1693
  return;
1525
1694
  this.lastRenderedRegionTarget = null;
1695
+ if (chunk.audio) {
1696
+ this.highlightCatalogRegion(chunk);
1697
+ await this.playRecordedAudio(chunk.audio);
1698
+ return;
1699
+ }
1526
1700
  const contentRoot = chunk.regionElement || chunk.sourceElement || this.currentContentElement;
1527
1701
  const pipelineChunk = contentRoot
1528
1702
  ? normalizeSpeechChunks({
@@ -1657,9 +1831,13 @@ export class TTSService {
1657
1831
  if (!this.provider) {
1658
1832
  throw new Error("TTS service not initialized");
1659
1833
  }
1660
- const text = range.toString().trim();
1661
- if (!text)
1834
+ // Enforced from the live ancestors rather than from `root`, because the
1835
+ // element carrying the suppression may sit above whatever root the caller
1836
+ // passed or the range happens to resolve to.
1837
+ if (isNodeSuppressedForTTS(range.commonAncestorContainer)) {
1838
+ console.warn("[tts] selection lies inside content marked not-to-be-spoken; nothing was spoken. Selecting a node and pressing read-aloud must not become a way around suppression.");
1662
1839
  return;
1840
+ }
1663
1841
  // Use explicit content root when provided; otherwise keep highlighting scoped
1664
1842
  // to the selected range's nearest element ancestor.
1665
1843
  const fromOptions = options?.contentRoot || null;
@@ -1673,11 +1851,23 @@ export class TTSService {
1673
1851
  }
1674
1852
  if (!root)
1675
1853
  return;
1676
- // Calculate the offset of the range start within the root element
1854
+ const selected = collectRangeTextForSpeech(range, root);
1855
+ const text = selected.text.trim();
1856
+ if (!text) {
1857
+ if (selected.filtered) {
1858
+ console.warn("[tts] every part of the selection is either hidden or marked not-to-be-spoken; nothing was spoken.");
1859
+ }
1860
+ return;
1861
+ }
1862
+ // Calculate the offset of the range start within the root element. Filtered
1863
+ // the same way as the speech itself: the offset indexes into the highlight
1864
+ // text, which comes from the exclusion-aware collectors, so counting
1865
+ // characters here that never reach that text would shift every highlight
1866
+ // after the excluded node.
1677
1867
  const beforeRange = document.createRange();
1678
1868
  beforeRange.selectNodeContents(root);
1679
1869
  beforeRange.setEnd(range.startContainer, range.startOffset);
1680
- const textBeforeRange = beforeRange.toString();
1870
+ const textBeforeRange = collectRangeTextForSpeech(beforeRange, root).text;
1681
1871
  const normalizedTextBeforeRange = normalizeTextForSpeech(textBeforeRange);
1682
1872
  const offset = normalizedTextBeforeRange.length +
1683
1873
  (/\s$/.test(textBeforeRange) && normalizedTextBeforeRange ? 1 : 0);
@@ -1701,6 +1891,7 @@ export class TTSService {
1701
1891
  if (!this.provider)
1702
1892
  return;
1703
1893
  if (this.state === PlaybackState.PLAYING) {
1894
+ this.activeRecordedAudio?.element.pause();
1704
1895
  this.provider.pause();
1705
1896
  this.setState(PlaybackState.PAUSED);
1706
1897
  }
@@ -1712,6 +1903,9 @@ export class TTSService {
1712
1903
  if (!this.provider)
1713
1904
  return;
1714
1905
  if (this.state === PlaybackState.PAUSED) {
1906
+ const recorded = this.activeRecordedAudio?.element;
1907
+ if (recorded)
1908
+ void Promise.resolve(recorded.play()).catch(() => { });
1715
1909
  this.provider.resume();
1716
1910
  this.setState(PlaybackState.PLAYING);
1717
1911
  }
@@ -1729,6 +1923,7 @@ export class TTSService {
1729
1923
  ? this.playbackChunks.slice(safeTargetIndex)
1730
1924
  : null;
1731
1925
  this.speakRunId += 1;
1926
+ this.cancelRecordedAudio();
1732
1927
  this.provider.onWordBoundary = undefined;
1733
1928
  this.provider.stop();
1734
1929
  this.currentSeekSegmentIndex = safeTargetIndex;
@@ -1820,6 +2015,7 @@ export class TTSService {
1820
2015
  if (!this.provider)
1821
2016
  return;
1822
2017
  this.speakRunId += 1;
2018
+ this.cancelRecordedAudio();
1823
2019
  this.provider.onWordBoundary = undefined;
1824
2020
  this.provider.stop();
1825
2021
  this.highlightTargetResolverProvider = null;
@@ -17,6 +17,7 @@ import type { AccessibilityCatalog, AssessmentEntity, AssessmentItemRef } from "
17
17
  import { type CanonicalToolsConfig, type ToolPlacementConfig, type ToolPlacementLevel, type ToolPolicyConfig, type ToolProviderConfig, type ToolProvidersConfig } from "./tools-config-normalizer.js";
18
18
  import { type ToolConfigStrictness } from "./tool-config-validation.js";
19
19
  import { AccessibilityCatalogResolver } from "./AccessibilityCatalogResolver.js";
20
+ import type { CatalogChangeListener } from "./AccessibilityCatalogResolver.js";
20
21
  import { ElementToolStateStore } from "./ElementToolStateStore.js";
21
22
  import { type FrameworkErrorModel } from "./framework-error.js";
22
23
  import { FrameworkErrorBus, type FrameworkErrorListener } from "./framework-error-bus.js";
@@ -29,10 +30,10 @@ import { ToolProviderRegistry } from "./tool-providers/index.js";
29
30
  import type { ToolProviderApi } from "./tool-providers/ToolProviderApi.js";
30
31
  import type { TTSToolProviderConfig } from "./tool-providers/index.js";
31
32
  import type { ResolvedToolContext, ToolContextResolver, ToolContextResolverContext, ToolContextResolverMap, ToolRegistry } from "./ToolRegistry.js";
32
- import { type PnpEnforcementMode, type PolicySource, type ResolvedEngineInputs, type ToolPolicyChangeListener, type ToolPolicyDecision, type ToolPolicyDecisionRequest } from "../policy/engine.js";
33
+ import { type FeaturePolicyDecision, type PnpEnforcementMode, type PolicySource, type ResolvedEngineInputs, type ToolPolicyChangeListener, type ToolPolicyDecision, type ToolPolicyDecisionRequest } from "../policy/engine.js";
33
34
  import type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionSessionPersistenceStrategy, SectionPersistenceFactoryDefaults } from "./section-controller-types.js";
34
35
  export type { SectionControllerContext, SectionControllerEvent, SectionControllerEventType, SectionControllerFactoryDefaults, SectionControllerHandle, SectionControllerKey, SectionControllerLoadedRenderable, SectionSessionPersistenceConfig, SectionSessionPersistenceStrategy, SectionControllerRuntimeState, SectionControllerSessionState, SectionPersistenceFactoryDefaults, } from "./section-controller-types.js";
35
- export type { PnpEnforcementMode, ResolvedEngineInputs, ToolPolicyChangeListener, ToolPolicyDecision, ToolPolicyDecisionRequest, } from "../policy/engine.js";
36
+ export type { FeaturePolicyDecision, PnpEnforcementMode, ResolvedEngineInputs, ToolPolicyChangeListener, ToolPolicyDecision, ToolPolicyDecisionRequest, } from "../policy/engine.js";
36
37
  /**
37
38
  * Generic tool configuration
38
39
  */
@@ -659,6 +660,15 @@ export declare class ToolkitCoordinator {
659
660
  * Thin shim over the owned tool-policy engine.
660
661
  */
661
662
  decideToolPolicy(request: ToolPolicyDecisionRequest): ToolPolicyDecision;
663
+ /**
664
+ * Resolve eligibility for one PNP/AfA feature id, independent of toolbar
665
+ * placement.
666
+ *
667
+ * Thin shim over the owned tool-policy engine; see
668
+ * {@link ToolPolicyEngine.decideFeature} for the contract, including why
669
+ * `pnpEnforcement` is not consulted.
670
+ */
671
+ decideFeaturePolicy(featureId: string): FeaturePolicyDecision;
662
672
  /**
663
673
  * Subscribe to policy-engine change events. Fires whenever the
664
674
  * coordinator's bound inputs change (`updateToolConfig`,
@@ -678,6 +688,17 @@ export declare class ToolkitCoordinator {
678
688
  * relying on a `disposed` event.
679
689
  */
680
690
  onPolicyChange(listener: ToolPolicyChangeListener): () => void;
691
+ /**
692
+ * Subscribe to accessibility-catalog registrations and removals.
693
+ *
694
+ * Delegates to the owned resolver, the same way {@link onPolicyChange}
695
+ * delegates to the owned policy engine, so a consumer holding only the
696
+ * coordinator can react to both of the mutable inputs a catalog-backed
697
+ * capability depends on without reaching for the services directly.
698
+ *
699
+ * @returns Unsubscribe function
700
+ */
701
+ onCatalogsChange(listener: CatalogChangeListener): () => void;
681
702
  /**
682
703
  * Bind (or clear) the active assessment for PNP/profile policy decisions.
683
704
  *
@@ -1518,6 +1518,17 @@ export class ToolkitCoordinator {
1518
1518
  decideToolPolicy(request) {
1519
1519
  return this.policyEngine.decide(request);
1520
1520
  }
1521
+ /**
1522
+ * Resolve eligibility for one PNP/AfA feature id, independent of toolbar
1523
+ * placement.
1524
+ *
1525
+ * Thin shim over the owned tool-policy engine; see
1526
+ * {@link ToolPolicyEngine.decideFeature} for the contract, including why
1527
+ * `pnpEnforcement` is not consulted.
1528
+ */
1529
+ decideFeaturePolicy(featureId) {
1530
+ return this.policyEngine.decideFeature(featureId);
1531
+ }
1521
1532
  /**
1522
1533
  * Subscribe to policy-engine change events. Fires whenever the
1523
1534
  * coordinator's bound inputs change (`updateToolConfig`,
@@ -1539,6 +1550,19 @@ export class ToolkitCoordinator {
1539
1550
  onPolicyChange(listener) {
1540
1551
  return this.policyEngine.onPolicyChange(listener);
1541
1552
  }
1553
+ /**
1554
+ * Subscribe to accessibility-catalog registrations and removals.
1555
+ *
1556
+ * Delegates to the owned resolver, the same way {@link onPolicyChange}
1557
+ * delegates to the owned policy engine, so a consumer holding only the
1558
+ * coordinator can react to both of the mutable inputs a catalog-backed
1559
+ * capability depends on without reaching for the services directly.
1560
+ *
1561
+ * @returns Unsubscribe function
1562
+ */
1563
+ onCatalogsChange(listener) {
1564
+ return this.catalogResolver.onCatalogsChange(listener);
1565
+ }
1542
1566
  /**
1543
1567
  * Bind (or clear) the active assessment for PNP/profile policy decisions.
1544
1568
  *
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Validation primitives shared by every catalog card whose payload references
3
+ * media.
4
+ *
5
+ * Extracted from `sign-language-cards.ts` when `spoken` cards gained a recorded
6
+ * audio form: both card types take authored, wire-facing URLs and hand them to a
7
+ * media element in the learner's browser, so both need the same scheme
8
+ * allow-list and the same "treat as absent, never as partially valid" posture.
9
+ * Two copies of a URL allow-list is one copy that gets a fix and one that does
10
+ * not.
11
+ *
12
+ * Part of PIE Assessment Toolkit.
13
+ */
14
+ import type { MediaFragmentRange, MediaSource } from "@pie-players/pie-players-shared/types";
15
+ export declare function isSafeMediaSrc(raw: unknown): raw is string;
16
+ export declare function normalizeMediaSources(raw: unknown): MediaSource[];
17
+ export declare function normalizeMediaFragment(raw: unknown): MediaFragmentRange | undefined;
18
+ export declare function trimmedOrUndefined(value: unknown): string | undefined;
19
+ /**
20
+ * Apply a fragment range to a source URL as a Media Fragments URI, so one
21
+ * recording can serve several content nodes. Browsers honour the start offset;
22
+ * the end offset is enforced by the caller, because support for the end bound is
23
+ * inconsistent.
24
+ */
25
+ export declare function applyMediaFragment(src: string, fragment?: MediaFragmentRange): string;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Validation primitives shared by every catalog card whose payload references
3
+ * media.
4
+ *
5
+ * Extracted from `sign-language-cards.ts` when `spoken` cards gained a recorded
6
+ * audio form: both card types take authored, wire-facing URLs and hand them to a
7
+ * media element in the learner's browser, so both need the same scheme
8
+ * allow-list and the same "treat as absent, never as partially valid" posture.
9
+ * Two copies of a URL allow-list is one copy that gets a fix and one that does
10
+ * not.
11
+ *
12
+ * Part of PIE Assessment Toolkit.
13
+ */
14
+ /**
15
+ * Media source URLs are handed to a media element in the learner's browser. Only
16
+ * schemes such an element can actually fetch are allowed; anything else is
17
+ * dropped so an authored `javascript:` / `file:` URL cannot ride into the DOM.
18
+ * Relative and protocol-relative URLs are allowed — host content is commonly
19
+ * served from the same origin as the player.
20
+ */
21
+ const DISALLOWED_SRC_SCHEME = /^[a-z][a-z0-9+.-]*:/i;
22
+ const ALLOWED_SRC_SCHEMES = new Set(["http:", "https:", "data:", "blob:"]);
23
+ export function isSafeMediaSrc(raw) {
24
+ if (typeof raw !== "string")
25
+ return false;
26
+ const src = raw.trim();
27
+ if (!src)
28
+ return false;
29
+ // Relative ("/video.mp4", "video.mp4") and protocol-relative ("//cdn/x.mp4")
30
+ // forms carry no scheme to check and inherit the document's.
31
+ if (src.startsWith("//") || !DISALLOWED_SRC_SCHEME.test(src))
32
+ return true;
33
+ const scheme = src.slice(0, src.indexOf(":") + 1).toLowerCase();
34
+ return ALLOWED_SRC_SCHEMES.has(scheme);
35
+ }
36
+ export function normalizeMediaSources(raw) {
37
+ if (!Array.isArray(raw))
38
+ return [];
39
+ const sources = [];
40
+ for (const entry of raw) {
41
+ if (!entry || typeof entry !== "object")
42
+ continue;
43
+ const candidate = entry;
44
+ if (!isSafeMediaSrc(candidate.src))
45
+ continue;
46
+ const source = { src: candidate.src.trim() };
47
+ if (typeof candidate.type === "string" && candidate.type.trim()) {
48
+ source.type = candidate.type.trim();
49
+ }
50
+ if (Number.isFinite(candidate.width))
51
+ source.width = candidate.width;
52
+ if (Number.isFinite(candidate.height))
53
+ source.height = candidate.height;
54
+ // Deduplicated by `src`, because the signing region renders `<source>`
55
+ // elements in an `{#each}` keyed on exactly that: an authored card listing
56
+ // one URL twice — the same file under two MIME types is the plausible way —
57
+ // would otherwise throw Svelte's duplicate-key error and take the whole
58
+ // region down rather than degrade. The first entry wins, so authored order
59
+ // still decides which encoding the browser is offered first.
60
+ if (sources.some((existing) => existing.src === source.src))
61
+ continue;
62
+ sources.push(source);
63
+ }
64
+ return sources;
65
+ }
66
+ export function normalizeMediaFragment(raw) {
67
+ if (!raw || typeof raw !== "object")
68
+ return undefined;
69
+ const candidate = raw;
70
+ const start = Number(candidate.startSeconds);
71
+ if (!Number.isFinite(start) || start < 0)
72
+ return undefined;
73
+ const end = Number(candidate.endSeconds);
74
+ // An end at or before the start would produce a zero/negative slice; treat
75
+ // it as "no end" rather than a range that can never play.
76
+ if (!Number.isFinite(end) || end <= start)
77
+ return { startSeconds: start };
78
+ return { startSeconds: start, endSeconds: end };
79
+ }
80
+ export function trimmedOrUndefined(value) {
81
+ if (typeof value !== "string")
82
+ return undefined;
83
+ const trimmed = value.trim();
84
+ return trimmed || undefined;
85
+ }
86
+ /**
87
+ * Apply a fragment range to a source URL as a Media Fragments URI, so one
88
+ * recording can serve several content nodes. Browsers honour the start offset;
89
+ * the end offset is enforced by the caller, because support for the end bound is
90
+ * inconsistent.
91
+ */
92
+ export function applyMediaFragment(src, fragment) {
93
+ if (!fragment)
94
+ return src;
95
+ // Never stack a second fragment onto a URL that already carries one — the
96
+ // authored value wins.
97
+ if (src.includes("#"))
98
+ return src;
99
+ const end = fragment.endSeconds !== undefined ? `,${fragment.endSeconds}` : "";
100
+ return `${src}#t=${fragment.startSeconds}${end}`;
101
+ }
@@ -1,3 +1,19 @@
1
1
  import type { PersonalNeedsProfile } from "@pie-players/pie-players-shared/types";
2
+ /**
3
+ * PNP support ids that must never enter the computed default profile, however
4
+ * they reach the registry.
5
+ *
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.
13
+ *
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.
16
+ */
17
+ export declare const ACCOMMODATION_ONLY_SUPPORT_IDS: readonly string[];
2
18
  export declare const DEFAULT_PERSONAL_NEEDS_PROFILE: PersonalNeedsProfile;
3
19
  export declare function createDefaultPersonalNeedsProfile(): PersonalNeedsProfile;