@pie-players/pie-assessment-toolkit 0.3.67 → 0.3.69

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 (73) hide show
  1. package/README.md +81 -9
  2. package/dist/attempt/AssessmentSession.d.ts +7 -27
  3. package/dist/components/ItemToolBar.custom-element.js +1 -1
  4. package/dist/components/PieAssessmentToolkit.custom-element.js +14 -14
  5. package/dist/components/SectionToolBar.custom-element.js +1 -1
  6. package/dist/components/chunks/ItemToolBar-pe5szfyx.js +46 -0
  7. package/dist/components/chunks/ItemToolBar-rd7te9r0.js +51 -0
  8. package/dist/components/item-toolbar-element.js +1 -0
  9. package/dist/components/pie-assessment-toolkit-element.js +1 -0
  10. package/dist/components/section-toolbar-element.js +1 -0
  11. package/dist/context/assessment-toolkit-context.d.ts +28 -0
  12. package/dist/index.d.ts +7 -3
  13. package/dist/index.js +4 -2
  14. package/dist/policy/core/compose-decision.js +14 -0
  15. package/dist/policy/core/decision-types.d.ts +1 -1
  16. package/dist/policy/sources/PnpPolicySource.d.ts +8 -0
  17. package/dist/policy/sources/PnpPolicySource.js +19 -10
  18. package/dist/runtime/SectionRuntimeEngine.d.ts +56 -0
  19. package/dist/runtime/SectionRuntimeEngine.js +66 -1
  20. package/dist/runtime/core/engine-resolver.d.ts +25 -1
  21. package/dist/runtime/registration-events.d.ts +52 -0
  22. package/dist/runtime/registration-events.js +2 -0
  23. package/dist/services/AccessibilityCatalogResolver.js +21 -5
  24. package/dist/services/I18nService.d.ts +28 -100
  25. package/dist/services/I18nService.js +43 -233
  26. package/dist/services/TTSService.d.ts +12 -0
  27. package/dist/services/TTSService.js +18 -17
  28. package/dist/services/ToolRegistry.d.ts +120 -2
  29. package/dist/services/ToolRegistry.js +64 -0
  30. package/dist/services/ToolkitCoordinator.d.ts +39 -37
  31. package/dist/services/ToolkitCoordinator.js +40 -0
  32. package/dist/services/audio-handoff.d.ts +39 -0
  33. package/dist/services/audio-handoff.js +58 -0
  34. package/dist/services/catalog-media.d.ts +35 -4
  35. package/dist/services/catalog-media.js +92 -3
  36. package/dist/services/framework-error.d.ts +15 -1
  37. package/dist/services/interfaces.d.ts +28 -0
  38. package/dist/services/pnp-standard-features.d.ts +1 -1
  39. package/dist/services/section-controller-types.d.ts +218 -7
  40. package/dist/services/selection-action.d.ts +49 -0
  41. package/dist/services/selection-action.js +10 -0
  42. package/dist/services/spoken-audio-cards.js +5 -1
  43. package/dist/services/tool-context.d.ts +6 -5
  44. package/dist/services/tool-context.js +205 -155
  45. package/dist/services/tool-icons.d.ts +18 -0
  46. package/dist/services/tool-icons.js +31 -0
  47. package/dist/services/tool-providers/CortexToolProvider.d.ts +18 -0
  48. package/dist/services/tool-providers/CortexToolProvider.js +32 -0
  49. package/dist/services/tool-providers/DesmosToolProvider.d.ts +13 -101
  50. package/dist/services/tool-providers/DesmosToolProvider.js +14 -145
  51. package/dist/services/tool-providers/GeoGebraToolProvider.d.ts +21 -0
  52. package/dist/services/tool-providers/GeoGebraToolProvider.js +32 -0
  53. package/dist/services/tool-providers/LazyCalculatorToolProvider.d.ts +35 -0
  54. package/dist/services/tool-providers/LazyCalculatorToolProvider.js +95 -0
  55. package/dist/services/tool-providers/index.d.ts +4 -0
  56. package/dist/services/tool-providers/index.js +2 -0
  57. package/dist/services/tool-request.d.ts +106 -0
  58. package/dist/services/tool-request.js +127 -0
  59. package/dist/services/toolbar-items.d.ts +6 -0
  60. package/dist/tools/client.d.ts +0 -2
  61. package/dist/tools/client.js +0 -4
  62. package/dist/tools/internal.d.ts +7 -1
  63. package/dist/tools/internal.js +8 -1
  64. package/dist/tools/tool-surface-host.d.ts +57 -0
  65. package/dist/tools/tool-surface-host.js +610 -0
  66. package/dist/tools/types.d.ts +1 -66
  67. package/package.json +22 -12
  68. package/dist/components/chunks/ItemToolBar-8jgdz50p.js +0 -51
  69. package/dist/components/chunks/ItemToolBar-cvs646j3.js +0 -36
  70. package/dist/tools/calculators/desmos-provider.d.ts +0 -46
  71. package/dist/tools/calculators/desmos-provider.js +0 -393
  72. package/dist/tools/library-loader.d.ts +0 -62
  73. package/dist/tools/library-loader.js +0 -261
@@ -1,274 +1,84 @@
1
1
  /**
2
2
  * I18nService
3
3
  *
4
- * Internationalization service with hybrid loading strategy.
5
- * Manages translations, locale switching, and RTL/LTR direction.
4
+ * The toolkit's view of the shared i18n provider: a delegating wrapper over
5
+ * `SimpleI18n` from `players-shared` that adds toolkit-scoped logging and wires
6
+ * the lazy catalog loaders.
6
7
  *
7
- * Features:
8
- * - Subscriber/listener pattern for reactive updates
9
- * - Hybrid loading: Bundle English, lazy-load other locales
10
- * - Interpolation: {variable} syntax
11
- * - Pluralization: ICU-style plural rules
12
- * - RTL detection: Automatic based on locale
13
- * - Fallback to English for missing keys
8
+ * It was once a near-verbatim second copy of that class — same fields, same
9
+ * lookup, same plural selection. Duplicate implementations of one contract drift
10
+ * silently, because nothing fails when only one of them is fixed. Keep this a
11
+ * wrapper.
14
12
  *
15
13
  * Part of PIE Assessment Toolkit.
16
14
  */
15
+ import { createPieI18n } from "@pie-players/pie-players-shared/i18n";
17
16
  import { createLogger } from "../utils/logger.js";
18
17
  const log = createLogger("I18nService");
19
18
  /**
20
- * I18nService
19
+ * Toolkit-scoped i18n service.
21
20
  *
22
- * Manages internationalization with reactive state updates.
21
+ * Serves every locale this repository ships, English resident and the rest
22
+ * lazily loaded.
23
23
  */
24
24
  export class I18nService {
25
- locale = "en";
26
- fallbackLocale = "en";
27
- direction = "ltr";
28
- translations = new Map();
29
- listeners = new Set();
30
- loadingPromises = new Map();
31
- config;
25
+ i18n;
32
26
  constructor(config = {}) {
33
- this.config = config;
34
- this.fallbackLocale = config.fallbackLocale || "en";
35
- log("I18nService initialized", { fallbackLocale: this.fallbackLocale });
36
- // Load bundled translations (English by default)
37
- if (config.bundledTranslations) {
38
- for (const [locale, bundle] of Object.entries(config.bundledTranslations)) {
39
- this.translations.set(locale, bundle);
40
- log(`Loaded bundled translations for: ${locale}`);
41
- }
42
- }
27
+ this.i18n = createPieI18n(config);
28
+ log("I18nService created", {
29
+ locale: this.i18n.getLocale(),
30
+ fallbackLocale: config.fallbackLocale ?? "en-US",
31
+ });
43
32
  }
44
33
  /**
45
- * Initialize i18n with locale and loading strategy
34
+ * Set the locale, loading its catalog first.
35
+ *
36
+ * Never falls back to the browser locale: a rendered-string change reaches a
37
+ * host's live delivery on their next install with no build signal on their
38
+ * side, so the locale has to be something the host asked for.
46
39
  */
47
40
  async initialize(config) {
48
- Object.assign(this.config, config);
49
- const locale = config.locale || this.detectBrowserLocale();
50
- log(`Initializing with locale: ${locale}`);
51
- await this.setLocale(locale);
41
+ log(`Initializing with locale: ${config.locale ?? this.i18n.getLocale()}`);
42
+ await this.i18n.initialize(config);
52
43
  }
53
- /**
54
- * Translate a key with optional interpolation
55
- *
56
- * @param key Translation key (e.g., 'common.save')
57
- * @param params Optional parameters for interpolation
58
- * @returns Translated string
59
- */
60
- t(key, params) {
61
- const translation = this.getTranslation(key);
62
- return this.interpolate(translation, params);
44
+ t(key, values) {
45
+ return this.i18n.t(key, values);
63
46
  }
64
- /**
65
- * Translate with pluralization
66
- *
67
- * @param key Translation key
68
- * @param count Count for pluralization
69
- * @param params Optional parameters for interpolation
70
- * @returns Translated string with plural form
71
- */
72
- tn(key, count, params) {
73
- const translation = this.getTranslation(key);
74
- if (typeof translation === "object") {
75
- const pluralForm = this.selectPluralForm(count, this.locale);
76
- const text = translation[pluralForm] || translation.other;
77
- return this.interpolate(text, { ...params, count });
78
- }
79
- return this.interpolate(translation, { ...params, count });
47
+ plural(key, options) {
48
+ return this.i18n.plural(key, options);
80
49
  }
81
- /**
82
- * Get current locale
83
- */
84
50
  getLocale() {
85
- return this.locale;
51
+ return this.i18n.getLocale();
86
52
  }
87
- /**
88
- * Change locale (triggers async loading if needed)
89
- *
90
- * @param locale Locale code (e.g., 'en', 'es', 'zh', 'ar')
91
- */
92
53
  async setLocale(locale) {
93
54
  log(`Setting locale to: ${locale}`);
94
- // Check if already loading
95
- if (this.loadingPromises.has(locale)) {
96
- log(`Locale ${locale} is already loading, waiting...`);
97
- await this.loadingPromises.get(locale);
98
- return;
99
- }
100
- // Check if already loaded
101
- if (this.translations.has(locale)) {
102
- log(`Locale ${locale} already loaded`);
103
- this.applyLocale(locale);
104
- return;
105
- }
106
- // Load translations
107
- const loadingPromise = this.loadTranslationsForLocale(locale);
108
- this.loadingPromises.set(locale, loadingPromise);
109
- try {
110
- await loadingPromise;
111
- this.applyLocale(locale);
112
- }
113
- finally {
114
- this.loadingPromises.delete(locale);
115
- }
55
+ await this.i18n.setLocale(locale);
116
56
  }
117
- /**
118
- * Get current text direction
119
- */
120
57
  getDirection() {
121
- return this.direction;
58
+ return this.i18n.getDirection();
122
59
  }
123
- /**
124
- * Get available locales
125
- */
126
60
  getAvailableLocales() {
127
- return Array.from(this.translations.keys());
61
+ return this.i18n.getAvailableLocales();
128
62
  }
129
- /**
130
- * Check if locale is loaded
131
- */
132
63
  isLocaleLoaded(locale) {
133
- return this.translations.has(locale);
64
+ return this.i18n.isLocaleLoaded(locale);
134
65
  }
135
- /**
136
- * Subscribe to locale/translation changes
137
- * Returns unsubscribe function
138
- */
139
66
  subscribe(listener) {
140
- this.listeners.add(listener);
141
- return () => {
142
- this.listeners.delete(listener);
143
- };
67
+ return this.i18n.subscribe(listener);
144
68
  }
145
- /**
146
- * Check if a translation key exists
147
- */
148
69
  hasKey(key) {
149
- const bundle = this.translations.get(this.locale);
150
- return !!bundle?.translations[key];
151
- }
152
- /**
153
- * Notify all listeners of state change
154
- */
155
- notifyListeners() {
156
- this.listeners.forEach((listener) => listener());
157
- }
158
- /**
159
- * Apply locale and notify listeners
160
- */
161
- applyLocale(locale) {
162
- const bundle = this.translations.get(locale);
163
- if (!bundle) {
164
- log(`No bundle found for locale: ${locale}`, "warn");
165
- return;
166
- }
167
- const previousLocale = this.locale;
168
- this.locale = locale;
169
- this.direction = bundle.direction;
170
- log(`Applied locale: ${locale} (direction: ${this.direction})`);
171
- this.applyDOMDirection();
172
- this.notifyListeners();
173
- }
174
- /**
175
- * Apply direction to DOM
176
- */
177
- applyDOMDirection() {
178
- if (typeof document === "undefined")
179
- return;
180
- document.documentElement.setAttribute("dir", this.direction);
181
- document.documentElement.setAttribute("lang", this.locale);
182
- log(`Applied DOM attributes: dir="${this.direction}" lang="${this.locale}"`);
183
- }
184
- /**
185
- * Load translations for a locale
186
- */
187
- async loadTranslationsForLocale(locale) {
188
- if (!this.config.loadTranslations) {
189
- throw new Error(`No translation loader configured for locale: ${locale}`);
190
- }
191
- log(`Loading translations for: ${locale}`);
192
- try {
193
- const bundle = await this.config.loadTranslations(locale);
194
- this.translations.set(locale, bundle);
195
- log(`Loaded translations for: ${locale} (${Object.keys(bundle.translations).length} keys)`);
196
- }
197
- catch (error) {
198
- log(`Failed to load translations for: ${locale}`, "error");
199
- throw error;
200
- }
70
+ return this.i18n.hasKey(key);
201
71
  }
202
- /**
203
- * Get translation for a key
204
- */
205
- getTranslation(key) {
206
- // Try current locale
207
- const currentBundle = this.translations.get(this.locale);
208
- if (currentBundle?.translations[key]) {
209
- return currentBundle.translations[key];
210
- }
211
- // Try fallback locale
212
- const fallbackBundle = this.translations.get(this.fallbackLocale);
213
- if (fallbackBundle?.translations[key]) {
214
- if (this.config.debug) {
215
- log(`Using fallback translation for key: ${key}`);
216
- }
217
- return fallbackBundle.translations[key];
218
- }
219
- // Missing key handling
220
- if (this.config.onMissingKey) {
221
- this.config.onMissingKey(key, this.locale);
222
- }
223
- if (this.config.debug) {
224
- log(`Missing translation key: ${key} (locale: ${this.locale})`, "warn");
225
- }
226
- return key;
72
+ addCustomMessages(locale, messages) {
73
+ this.i18n.addCustomMessages(locale, messages);
227
74
  }
228
- /**
229
- * Interpolate variables in translation string
230
- * Replaces {variable} with params.variable
231
- */
232
- interpolate(text, params) {
233
- if (!params)
234
- return text;
235
- return text.replace(/\{(\w+)\}/g, (match, key) => {
236
- return params[key]?.toString() || match;
237
- });
75
+ withLocale(locale) {
76
+ return this.i18n.withLocale(locale);
238
77
  }
239
- /**
240
- * Select plural form based on count and locale
241
- * Simplified implementation - real implementation would use Intl.PluralRules
242
- */
243
- selectPluralForm(count, locale) {
244
- // Use Intl.PluralRules if available
245
- if (typeof Intl !== "undefined" && Intl.PluralRules) {
246
- try {
247
- const rules = new Intl.PluralRules(locale);
248
- const category = rules.select(count);
249
- return category;
250
- }
251
- catch (error) {
252
- log(`Failed to use Intl.PluralRules for locale: ${locale}`, "warn");
253
- }
254
- }
255
- // Fallback to simple rules
256
- if (count === 0)
257
- return "zero";
258
- if (count === 1)
259
- return "one";
260
- return "other";
78
+ formatNumber(value, options) {
79
+ return this.i18n.formatNumber?.(value, options) ?? String(value);
261
80
  }
262
- /**
263
- * Detect browser locale
264
- */
265
- detectBrowserLocale() {
266
- if (typeof navigator === "undefined")
267
- return "en";
268
- const browserLang = navigator.language ||
269
- (navigator.languages && navigator.languages[0]) ||
270
- "en";
271
- // Extract language code (e.g., 'en-US' -> 'en')
272
- return browserLang.split("-")[0];
81
+ formatDate(date, options) {
82
+ return this.i18n.formatDate?.(date, options) ?? date.toISOString();
273
83
  }
274
84
  }
@@ -109,6 +109,18 @@ export declare class TTSService {
109
109
  setHighlightCoordinator(coordinator: HighlightCoordinatorApi): void;
110
110
  /**
111
111
  * Set a late-bound provider for optional host TTS highlight target remapping.
112
+ *
113
+ * The returned disposer is the only thing that clears the registration: the
114
+ * caller owns the lifetime, and a provider installed once keeps remapping
115
+ * across stops, seek-restarts and later playbacks. Playback termination used to
116
+ * clear it in `stop()` and on both exits of `restartFromSeekIndex`, which left a
117
+ * host that installs before mount — the case a late-bound provider exists for —
118
+ * remapping exactly one playback and then silently falling back to identity.
119
+ *
120
+ * Installing again replaces the previous provider, so a caller that reinstalls
121
+ * per playback needs no disposal between them. A stale provider cannot paint
122
+ * outside its scope in any case: targets are validated by containment in
123
+ * `context.scopeElement` and a failing one falls back to its native range.
112
124
  */
113
125
  setHighlightTargetResolverProvider(provider: TTSHighlightTargetResolverProvider | null): () => void;
114
126
  /**
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * Part of PIE Assessment Toolkit.
17
17
  */
18
- import { applyMediaFragment } from "./catalog-media.js";
18
+ import { applyMediaFragment, enforceMediaFragment } from "./catalog-media.js";
19
19
  import { HighlightColor, HighlightType } from "./HighlightCoordinator.js";
20
20
  import { resolveSpokenAudioMedia, } from "./spoken-audio-cards.js";
21
21
  import { BrowserTTSProvider } from "./tts/browser-provider.js";
@@ -282,6 +282,18 @@ export class TTSService {
282
282
  }
283
283
  /**
284
284
  * Set a late-bound provider for optional host TTS highlight target remapping.
285
+ *
286
+ * The returned disposer is the only thing that clears the registration: the
287
+ * caller owns the lifetime, and a provider installed once keeps remapping
288
+ * across stops, seek-restarts and later playbacks. Playback termination used to
289
+ * clear it in `stop()` and on both exits of `restartFromSeekIndex`, which left a
290
+ * host that installs before mount — the case a late-bound provider exists for —
291
+ * remapping exactly one playback and then silently falling back to identity.
292
+ *
293
+ * Installing again replaces the previous provider, so a caller that reinstalls
294
+ * per playback needs no disposal between them. A stale provider cannot paint
295
+ * outside its scope in any case: targets are validated by containment in
296
+ * `context.scopeElement` and a failing one falls back to its native range.
285
297
  */
286
298
  setHighlightTargetResolverProvider(provider) {
287
299
  this.highlightTargetResolverProvider = provider;
@@ -1731,7 +1743,7 @@ export class TTSService {
1731
1743
  element.src = applyMediaFragment(source.src, media.fragment);
1732
1744
  element.playbackRate = this.normalizePlaybackRate(Number(this.ttsConfig.rate || 1));
1733
1745
  await new Promise((resolve, reject) => {
1734
- let endGuard;
1746
+ let disposeFragment;
1735
1747
  let didStart = false;
1736
1748
  let settled = false;
1737
1749
  const markStarted = () => {
@@ -1744,8 +1756,7 @@ export class TTSService {
1744
1756
  element.removeEventListener("ended", onEnded);
1745
1757
  element.removeEventListener("error", onError);
1746
1758
  element.removeEventListener("playing", markStarted);
1747
- if (endGuard !== undefined)
1748
- clearInterval(endGuard);
1759
+ disposeFragment?.();
1749
1760
  if (this.activeRecordedAudio?.element === element) {
1750
1761
  this.activeRecordedAudio = null;
1751
1762
  }
@@ -1781,16 +1792,9 @@ export class TTSService {
1781
1792
  element.addEventListener("ended", onEnded);
1782
1793
  element.addEventListener("error", onError);
1783
1794
  element.addEventListener("playing", markStarted);
1784
- // Browsers honour a Media Fragments start offset but are inconsistent
1785
- // about the end bound, so the end is enforced here — the same reason the
1786
- // signing region enforces its own.
1787
- const endSeconds = media.fragment?.endSeconds;
1788
- if (endSeconds !== undefined) {
1789
- endGuard = setInterval(() => {
1790
- if (element.currentTime >= endSeconds)
1791
- onEnded();
1792
- }, 100);
1793
- }
1795
+ // Reaching the slice's end is this clip finishing, so the chunk sequence
1796
+ // advances rather than the element merely pausing.
1797
+ disposeFragment = enforceMediaFragment(element, media.fragment, onEnded);
1794
1798
  Promise.resolve(element.play()).then(markStarted).catch(onError);
1795
1799
  }).finally(() => {
1796
1800
  if (this.activeRecordedAudio?.element === element) {
@@ -2092,7 +2096,6 @@ export class TTSService {
2092
2096
  return;
2093
2097
  this.setState(PlaybackState.IDLE);
2094
2098
  this.clearHighlightsAndTracking();
2095
- this.highlightTargetResolverProvider = null;
2096
2099
  }
2097
2100
  catch (error) {
2098
2101
  if (runId !== this.speakRunId)
@@ -2100,7 +2103,6 @@ export class TTSService {
2100
2103
  this.lastError = error instanceof Error ? error.message : String(error);
2101
2104
  this.setState(PlaybackState.ERROR);
2102
2105
  this.clearHighlightsAndTracking();
2103
- this.highlightTargetResolverProvider = null;
2104
2106
  throw error;
2105
2107
  }
2106
2108
  finally {
@@ -2205,7 +2207,6 @@ export class TTSService {
2205
2207
  this.cancelRecordedAudio();
2206
2208
  this.provider.onWordBoundary = undefined;
2207
2209
  this.provider.stop();
2208
- this.highlightTargetResolverProvider = null;
2209
2210
  this.setState(PlaybackState.IDLE);
2210
2211
  this.currentText = null;
2211
2212
  // Clear highlights
@@ -4,6 +4,7 @@
4
4
  * Central registry for all assessment tools. Manages tool metadata, visibility logic,
5
5
  * and button/instance creation. Supports dynamic registration and override by integrators.
6
6
  */
7
+ import type { I18nProvider } from "@pie-players/pie-players-shared/i18n/types";
7
8
  import type { CatalogOwnerSnapshot } from "./AccessibilityCatalogResolver.js";
8
9
  import type { ToolContext, ToolLevel } from "./tool-context.js";
9
10
  import type { ToolComponentOverrides } from "../tools/tool-tag-map.js";
@@ -30,6 +31,17 @@ export interface ToolToolbarButtonDefinition {
30
31
  * `assertToolRegistrationShape` enforces that.
31
32
  */
32
33
  icon?: string;
34
+ /**
35
+ * FontAwesome icon name, opting this button into `<nds-icon-button>` rendering
36
+ * where the host enables NDS icons (`ndsIcons`). Absent means the toolbar keeps
37
+ * its own button rendering, which is what every capability got before the NDS
38
+ * button existed.
39
+ *
40
+ * A declaration rather than a toolId lookup in the toolbar: which capabilities
41
+ * a deployment renders through the host's design system is a composition-layer
42
+ * decision, and the generic core names no capability.
43
+ */
44
+ faIconName?: string;
33
45
  ariaLabel: string;
34
46
  tooltip?: string;
35
47
  onClick: () => void;
@@ -49,7 +61,24 @@ export interface ToolbarContext {
49
61
  };
50
62
  itemId: string;
51
63
  catalogId: string;
64
+ /**
65
+ * Content-alternate language: which authored alternate the catalog resolver
66
+ * should select. Not the interface locale — see {@link ToolbarContext.i18n}. The
67
+ * two are independent by QTI 3's own statement, and conflating them is how a
68
+ * Spanish passage ends up forcing Spanish widget chrome.
69
+ */
52
70
  language: string;
71
+ /**
72
+ * Interface-locale provider for this capability's own UI strings.
73
+ *
74
+ * Required, and always the facade `resolveInterfaceI18n` returns: the toolbar
75
+ * resolves it once from the toolkit runtime context, so a registration reads
76
+ * `toolbarContext.i18n` and neither repeats the no-publisher fallback nor
77
+ * misses the republish that a locale change produces. With no publisher the
78
+ * facade wraps the English-only default, which is why this can be required
79
+ * rather than optional.
80
+ */
81
+ i18n: I18nProvider;
53
82
  ui?: {
54
83
  size?: string;
55
84
  };
@@ -137,6 +166,23 @@ export interface ToolWindowShellConfig {
137
166
  initialMargin?: number;
138
167
  content?: ToolWindowShellContentConfig;
139
168
  actions?: ToolWindowShellAction[];
169
+ /**
170
+ * Render the shell's own header controls as `<nds-icon-button>`s where the host
171
+ * enables NDS icons. Shells without this keep the plain `<button>` controls, and
172
+ * an opted-out host gets those either way.
173
+ */
174
+ ndsHeaderControls?: boolean;
175
+ /**
176
+ * Let Tab and Shift+Tab cross between the page and this shell rather than
177
+ * cycling inside it.
178
+ *
179
+ * The shell is appended to `<body>`, so it sits outside the document order its
180
+ * opener lives in. A shell a learner works *alongside* — reading the question
181
+ * while using the tool — needs Tab from the opener to enter it and Shift+Tab
182
+ * from the first question control to reach its end. A shell a learner works
183
+ * *inside* wants the default trap, which keeps focus captured until dismissed.
184
+ */
185
+ pageTabOrder?: boolean;
140
186
  }
141
187
  export interface HostedToolContext {
142
188
  toolId: string;
@@ -163,6 +209,19 @@ export interface ToolToolbarRenderResult {
163
209
  sync?: () => void;
164
210
  subscribeActive?: (callback: (active: boolean) => void) => () => void;
165
211
  }
212
+ /**
213
+ * A registration's display name in the interface locale.
214
+ *
215
+ * Precedence: the resolved `nameKey`, then `name`. A key that does not resolve
216
+ * falls back to `name` rather than rendering the key, so a catalog gap degrades
217
+ * to English instead of to `tools.something.name` on a toolbar button.
218
+ *
219
+ * Both display resolvers live here so the toolbars, the settings panels and the
220
+ * PNP debugger cannot each invent their own precedence.
221
+ */
222
+ export declare function resolveToolRegistrationName(registration: Pick<ToolRegistration, "name" | "nameKey">, i18n?: I18nProvider): string;
223
+ /** A registration's description in the interface locale. See the name resolver. */
224
+ export declare function resolveToolRegistrationDescription(registration: Pick<ToolRegistration, "description" | "descriptionKey">, i18n?: I18nProvider): string;
166
225
  export type ToolActivation = "toolbar-toggle" | "selection-gateway" | "region";
167
226
  export type ToolSingletonScope = "section";
168
227
  /**
@@ -177,6 +236,13 @@ export interface ToolSurfaceServices {
177
236
  toolkitCoordinator: ToolkitCoordinatorApi | null;
178
237
  ttsService: TtsServiceApi | null;
179
238
  catalogResolver: AccessibilityCatalogResolverApi | null;
239
+ /**
240
+ * Interface-locale provider for the surface's own labels — a region's
241
+ * `aria-label`, a heading it emits. Required, and always the facade
242
+ * `resolveInterfaceI18n` returns, which wraps the English-only default where
243
+ * the host published nothing.
244
+ */
245
+ i18n: I18nProvider;
180
246
  }
181
247
  /**
182
248
  * What a host tells a capability when asking it to fill a surface.
@@ -310,10 +376,28 @@ export interface ToolSurfaceRenderResult {
310
376
  export interface ToolRegistration {
311
377
  /** Unique tool identifier (e.g., 'calculator', 'textToSpeech') */
312
378
  toolId: string;
313
- /** Human-readable name */
379
+ /**
380
+ * Human-readable name.
381
+ *
382
+ * Required, and stays required: it is part of the contract a host implements
383
+ * when it contributes its own capability, and a host cannot be made to ship a
384
+ * message catalog. Treat it as the English source.
385
+ */
314
386
  name: string;
315
- /** Description of what the tool does */
387
+ /** Description of what the tool does. English source, like {@link name}. */
316
388
  description: string;
389
+ /**
390
+ * Message key resolving to {@link name} in the interface locale.
391
+ *
392
+ * Optional by design. A capability that supplies one is localizable; one that
393
+ * does not renders `name` verbatim, which is what keeps a host-authored
394
+ * registration working with no catalog. Resolve with
395
+ * {@link resolveToolRegistrationName} rather than reading either field
396
+ * directly, so precedence stays in one place.
397
+ */
398
+ nameKey?: string;
399
+ /** Message key resolving to {@link description} in the interface locale. */
400
+ descriptionKey?: string;
317
401
  /**
318
402
  * Icon identifier or SVG string. Required for the activations that render a
319
403
  * toolbar button; a region capability has no button, so it has no icon.
@@ -399,6 +483,26 @@ export interface ToolRegistration {
399
483
  * @returns true if tool should be visible, false to hide
400
484
  */
401
485
  isVisibleInContext?(context: ToolContext): boolean;
486
+ /**
487
+ * Whether this tool can act on this content at all — a capability question,
488
+ * not a relevance heuristic. Answering `false` withdraws the tool even where
489
+ * a PNP grant would otherwise keep it, which {@link
490
+ * ToolRegistration.isVisibleInContext} deliberately cannot do.
491
+ *
492
+ * The two gates answer different questions, and most tools declare only the
493
+ * first. A calculator is *applicable* to every item — a learner granted one
494
+ * keeps it on an item that does not look mathematical — while its relevance
495
+ * is a guess about usefulness. An answer eliminator on an item with no choice
496
+ * interaction has nothing to strike through, so no grant can make it work.
497
+ *
498
+ * Declare this only where the tool's own controls provably do nothing:
499
+ * withdrawing a granted accommodation on a false negative is the more
500
+ * expensive failure. Omitting it means "applicable".
501
+ *
502
+ * @param context - The item or element context the tool would act on
503
+ * @returns false to withdraw the tool from this context
504
+ */
505
+ isApplicableToContent?(context: ToolContext): boolean;
402
506
  /**
403
507
  * Toolbar render contract. Required for `toolbar-toggle` and
404
508
  * `selection-gateway`; a region capability renders through
@@ -541,6 +645,20 @@ export declare class ToolRegistry {
541
645
  * @returns Array of visible tool registrations
542
646
  */
543
647
  filterVisibleInContext(allowedToolIds: string[], context: ToolContext): ToolRegistration[];
648
+ /**
649
+ * Whether a tool can act on any of the contexts it would be placed against.
650
+ * Unlike the relevance pass this is a veto: a `false` here removes the tool
651
+ * from a toolbar even when a grant protects it, so a tool answers `false`
652
+ * only where its controls provably do nothing.
653
+ *
654
+ * A tool that declares no applicability gate is applicable. So is one
655
+ * evaluated against no contexts — content that has not resolved yet cannot
656
+ * establish that a tool is useless.
657
+ *
658
+ * @param toolId - Tool to ask
659
+ * @param contexts - Every context the tool could act on at this placement
660
+ */
661
+ isApplicableToAnyContext(toolId: string, contexts: readonly ToolContext[]): boolean;
544
662
  /**
545
663
  * Get tool metadata for building UIs
546
664
  * Useful for building PNP configuration interfaces