@pie-players/pie-players-shared 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 (87) hide show
  1. package/README.md +59 -0
  2. package/dist/formative/index.d.ts +16 -0
  3. package/dist/formative/index.js +5 -0
  4. package/dist/formative/mastery.d.ts +16 -0
  5. package/dist/formative/mastery.js +43 -0
  6. package/dist/formative/outcome.d.ts +19 -0
  7. package/dist/formative/outcome.js +90 -0
  8. package/dist/formative/policy.d.ts +37 -0
  9. package/dist/formative/policy.js +86 -0
  10. package/dist/formative/session.d.ts +19 -0
  11. package/dist/formative/session.js +113 -0
  12. package/dist/formative/state.d.ts +60 -0
  13. package/dist/formative/state.js +140 -0
  14. package/dist/formative/types.d.ts +164 -0
  15. package/dist/formative/types.js +13 -0
  16. package/dist/i18n/catalogs.d.ts +38 -0
  17. package/dist/i18n/catalogs.js +47 -0
  18. package/dist/i18n/index.d.ts +28 -4
  19. package/dist/i18n/index.js +34 -3
  20. package/dist/i18n/language-tags.d.ts +51 -0
  21. package/dist/i18n/language-tags.js +84 -0
  22. package/dist/i18n/messages/en-US.d.ts +601 -0
  23. package/dist/i18n/messages/en-US.js +617 -0
  24. package/dist/i18n/messages/nl-NL.d.ts +516 -0
  25. package/dist/i18n/messages/nl-NL.js +516 -0
  26. package/dist/i18n/provider.d.ts +168 -0
  27. package/dist/i18n/provider.js +472 -0
  28. package/dist/i18n/types.d.ts +177 -28
  29. package/dist/i18n/types.js +6 -2
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.js +2 -0
  32. package/dist/pie/custom-element-define.d.ts +24 -0
  33. package/dist/pie/custom-element-define.js +36 -6
  34. package/dist/pie/index.d.ts +1 -1
  35. package/dist/pie/index.js +1 -1
  36. package/dist/pie/versioned-tag.js +3 -2
  37. package/dist/security/sanitize-forbidden-lists.d.ts +8 -0
  38. package/dist/security/sanitize-forbidden-lists.js +41 -0
  39. package/dist/security/sanitize-item-markup.js +3 -36
  40. package/dist/security/sanitize-svg-icon.js +3 -25
  41. package/dist/security/wrap-overwide-images.d.ts +5 -10
  42. package/dist/security/wrap-overwide-images.js +13 -62
  43. package/dist/security/wrap-overwide-tables.d.ts +5 -10
  44. package/dist/security/wrap-overwide-tables.js +13 -62
  45. package/dist/security/wrap-overwide.d.ts +51 -0
  46. package/dist/security/wrap-overwide.js +91 -0
  47. package/dist/server/npm-auth-env.d.ts +13 -0
  48. package/dist/server/npm-auth-env.js +64 -0
  49. package/dist/timed-media/index.d.ts +17 -0
  50. package/dist/timed-media/index.js +16 -0
  51. package/dist/timed-media/media-element-source.d.ts +36 -0
  52. package/dist/timed-media/media-element-source.js +121 -0
  53. package/dist/timed-media/policy.d.ts +36 -0
  54. package/dist/timed-media/policy.js +297 -0
  55. package/dist/timed-media/session.d.ts +21 -0
  56. package/dist/timed-media/session.js +85 -0
  57. package/dist/timed-media/state.d.ts +66 -0
  58. package/dist/timed-media/state.js +357 -0
  59. package/dist/timed-media/types.d.ts +343 -0
  60. package/dist/timed-media/types.js +12 -0
  61. package/dist/tools/term-lookup.d.ts +189 -0
  62. package/dist/tools/term-lookup.js +284 -0
  63. package/dist/types/index.d.ts +105 -1
  64. package/dist/ui/attribute-coercion.d.ts +1 -0
  65. package/dist/ui/attribute-coercion.js +19 -0
  66. package/dist/ui/first-focusable.d.ts +22 -0
  67. package/dist/ui/first-focusable.js +47 -0
  68. package/dist/ui/focus-trap.js +7 -2
  69. package/dist/ui/pointer-drag.d.ts +30 -0
  70. package/dist/ui/pointer-drag.js +34 -0
  71. package/package.json +38 -6
  72. package/dist/i18n/loader.d.ts +0 -35
  73. package/dist/i18n/loader.js +0 -132
  74. package/dist/i18n/simple-i18n.d.ts +0 -68
  75. package/dist/i18n/simple-i18n.js +0 -198
  76. package/dist/i18n/translations/ar/common.json +0 -36
  77. package/dist/i18n/translations/ar/toolkit.json +0 -48
  78. package/dist/i18n/translations/ar/tools.json +0 -103
  79. package/dist/i18n/translations/en/common.json +0 -36
  80. package/dist/i18n/translations/en/toolkit.json +0 -48
  81. package/dist/i18n/translations/en/tools.json +0 -103
  82. package/dist/i18n/translations/es/common.json +0 -36
  83. package/dist/i18n/translations/es/toolkit.json +0 -48
  84. package/dist/i18n/translations/es/tools.json +0 -103
  85. package/dist/i18n/translations/zh/common.json +0 -36
  86. package/dist/i18n/translations/zh/toolkit.json +0 -48
  87. package/dist/i18n/translations/zh/tools.json +0 -103
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Timed-media section vocabulary.
3
+ *
4
+ * A **Cue** is a window on a media timeline that activates one or more of the
5
+ * section's item refs. A **Media Time Source** is the port the section reaches
6
+ * media through — never a library API, so a host can supply its own media
7
+ * element without shipping a PIE element.
8
+ *
9
+ * See `docs/prds/timed-media-section-contract.md` for the ratified contract and
10
+ * `docs/architecture/timed-media-section.md` for the layer-ownership record.
11
+ */
12
+ export {};
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Term lookup, shared by the dictionary tools.
3
+ *
4
+ * A dictionary and a picture dictionary differ only in what a result carries: one
5
+ * returns definitions, the other pictures. Everything up to that point is the same
6
+ * concern — normalising what a text selection dragged in, deciding whether a term is
7
+ * worth a request, telling "no entry for this word" apart from "the service did not
8
+ * answer", and not letting a superseded lookup overwrite a newer one's state. That is
9
+ * the part worth getting right once and testing without a browser, so it lives here
10
+ * and each tool supplies only its item reader.
11
+ *
12
+ * Nothing here names a capability or a service. PIE ships no dictionary endpoint: the
13
+ * corpus behind one is licensed per programme, so a host supplies either an endpoint
14
+ * or a resolver of its own.
15
+ */
16
+ /** What a host's term service is asked for. */
17
+ export interface TermLookupRequest {
18
+ /** The term, already normalised by {@link normalizeTerm}. */
19
+ keyword: string;
20
+ /** BCP-47 tag the learner is reading in, when the host declares one. */
21
+ language?: string;
22
+ /** Upper bound on items, so a host cannot be made to return an unbounded page. */
23
+ max?: number;
24
+ }
25
+ /**
26
+ * A lookup outcome, as a discriminated union.
27
+ *
28
+ * `empty` and `error` are separate states because they need different words in front
29
+ * of a learner: one means the service has no entry for what they selected, the other
30
+ * means it did not answer. Collapsing them tells a learner their word is not real
31
+ * when the network is down.
32
+ */
33
+ export type TermLookupResult<TItem> = {
34
+ status: "ok";
35
+ items: TItem[];
36
+ } | {
37
+ status: "empty";
38
+ } | {
39
+ status: "error";
40
+ reason: string;
41
+ };
42
+ export type TermLookup<TItem> = (request: TermLookupRequest, signal?: AbortSignal) => Promise<TermLookupResult<TItem>>;
43
+ /** Longest term worth sending. Past this it is prose, not a headword. */
44
+ export declare const MAX_LOOKUP_TERM_LENGTH = 80;
45
+ /** Words a term may span. Generous enough for `carbon dioxide` and `mother in law`. */
46
+ export declare const MAX_LOOKUP_TERM_WORDS = 4;
47
+ /**
48
+ * Collapse whitespace and strip the punctuation a text selection drags along.
49
+ *
50
+ * A learner double-clicking a word at the end of a sentence selects `"reason."`, and
51
+ * a drag across a line break selects `"photo-\nsynthesis"`. Leading and trailing
52
+ * punctuation goes; internal hyphens and apostrophes stay, because `mother-in-law`
53
+ * and `don't` are the entries.
54
+ */
55
+ export declare function normalizeTerm(raw: string): string;
56
+ /**
57
+ * Whether a normalised term is worth sending.
58
+ *
59
+ * A whole sentence is not a lookup, and sending it wastes a request that will miss.
60
+ */
61
+ export declare function isLookupableTerm(term: string): boolean;
62
+ /**
63
+ * Read a host payload into a result.
64
+ *
65
+ * `keys` are the payload fields to accept the item array under, in order, so a tool
66
+ * can take more than one spelling of the same field. Unknown extra fields are ignored
67
+ * rather than rejected, so a host can extend its payload without this package having
68
+ * to agree first. An item its reader rejects is dropped rather than failing the whole
69
+ * response: one malformed entry should not cost the learner the others.
70
+ */
71
+ export declare function readTermLookupPayload<TItem>(payload: unknown, args: {
72
+ /** Names the service in messages a learner reads, e.g. `"dictionary"`. */
73
+ serviceLabel: string;
74
+ keys: readonly string[];
75
+ toItem: (value: unknown) => TItem | null;
76
+ }): TermLookupResult<TItem>;
77
+ /**
78
+ * A lookup that POSTs to a host endpoint.
79
+ *
80
+ * `credentials: "same-origin"` by default. The endpoint a host names is expected to
81
+ * sit behind the same session boundary as the assessment itself, so the session
82
+ * cookie is exactly what authorises it and requiring a host to wire a token to get
83
+ * its own already-authenticated route to answer is a configuration step it would
84
+ * reasonably forget. Cross-origin requests still send nothing. A host that authorises
85
+ * some other way passes `headers`, and one that wants no ambient credentials at all
86
+ * passes `credentials: "omit"`.
87
+ */
88
+ export declare function createEndpointTermLookup<TItem>(args: {
89
+ endpoint: string;
90
+ /** Names the service in messages a learner reads, e.g. `"dictionary"`. */
91
+ serviceLabel: string;
92
+ /** Reads a host payload into a result; usually {@link readTermLookupPayload}. */
93
+ readResponse: (payload: unknown) => TermLookupResult<TItem>;
94
+ headers?: () => Promise<Record<string, string>> | Record<string, string>;
95
+ credentials?: RequestCredentials;
96
+ fetchImpl?: typeof fetch;
97
+ }): TermLookup<TItem>;
98
+ /** What a term panel is showing. */
99
+ export type TermPanelState<TItem> = {
100
+ kind: "unconfigured";
101
+ } | {
102
+ kind: "idle";
103
+ } | {
104
+ kind: "searching";
105
+ term: string;
106
+ } | {
107
+ kind: "results";
108
+ term: string;
109
+ items: TItem[];
110
+ } | {
111
+ kind: "empty";
112
+ term: string;
113
+ } | {
114
+ kind: "error";
115
+ term: string;
116
+ reason: string;
117
+ };
118
+ /** Shown when the learner sends something that is not a headword. */
119
+ export declare const NOT_A_TERM_REASON = "Look up a single word or short phrase.";
120
+ export interface TermLookupSessionOptions<TItem> {
121
+ /**
122
+ * Read at call time rather than captured, so a resolver that arrives after mount —
123
+ * a host setting `endpoint` on a live element — is picked up without a remount.
124
+ */
125
+ resolver: () => TermLookup<TItem> | null;
126
+ max: number;
127
+ language?: () => string | undefined;
128
+ /** Applied on every transition. The component holds the state; this drives it. */
129
+ onState: (state: TermPanelState<TItem>) => void;
130
+ }
131
+ /**
132
+ * The lookup half of a term panel: request sequencing and state transitions, with no
133
+ * framework reactivity of its own.
134
+ *
135
+ * Deliberately not a rune module. Rune modules in this package are source-only and
136
+ * each consumer has to alias them in its own Vite and TypeScript config; a plain
137
+ * class costs a caller one `$state` field instead, and is unit-testable directly.
138
+ */
139
+ export declare class TermLookupSession<TItem> {
140
+ #private;
141
+ private readonly options;
142
+ constructor(options: TermLookupSessionOptions<TItem>);
143
+ get searchedFor(): string;
144
+ /**
145
+ * Reconcile the unconfigured notice with whether a resolver exists.
146
+ *
147
+ * A tool with nowhere to look words up says so, rather than offering a field that
148
+ * silently fails: the host has misconfigured it and the learner needs to know it is
149
+ * not their typing. Called with the current state because it only ever moves
150
+ * between `idle` and `unconfigured` — a resolver arriving mid-lookup must not
151
+ * discard results the learner is reading.
152
+ */
153
+ syncConfigured(current: TermPanelState<TItem>): void;
154
+ /**
155
+ * Run a term handed in from outside, at most once per request.
156
+ *
157
+ * Keyed on the request's identity, not on the term or on what was last searched.
158
+ * Both of the alternatives are wrong in a way a learner meets: keying on the term
159
+ * means selecting a word, typing another into the field, then selecting the first
160
+ * word again does nothing, because the incoming term never changed; keying on
161
+ * {@link searchedFor} means every reopen re-issues the stale selection and discards
162
+ * the lookup the learner typed. An id that changes per request says what both are
163
+ * guessing at.
164
+ *
165
+ * A request with no id falls back to the term, which is the best available identity
166
+ * when the caller cannot mint one — a host assigning `term` directly, for instance.
167
+ *
168
+ * Returns whether it issued a lookup, so a caller can mirror the term into its own
169
+ * field only when one ran.
170
+ */
171
+ syncRequestedTerm(request: {
172
+ term: string;
173
+ requestId?: string | number;
174
+ visible: boolean;
175
+ }): boolean;
176
+ run(raw: string): Promise<void>;
177
+ }
178
+ /**
179
+ * The panel's live-region text.
180
+ *
181
+ * Only the states a learner is waiting on announce. `idle`, `unconfigured` and
182
+ * `error` are already rendered as visible text in the panel body, and repeating them
183
+ * here would announce the same sentence twice.
184
+ */
185
+ export declare function termPanelStatusMessage<TItem>(state: TermPanelState<TItem>, labels: {
186
+ /** e.g. `2 entries`, `1 picture`. */
187
+ countLabel: (count: number) => string;
188
+ emptyLabel: (term: string) => string;
189
+ }): string;
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Term lookup, shared by the dictionary tools.
3
+ *
4
+ * A dictionary and a picture dictionary differ only in what a result carries: one
5
+ * returns definitions, the other pictures. Everything up to that point is the same
6
+ * concern — normalising what a text selection dragged in, deciding whether a term is
7
+ * worth a request, telling "no entry for this word" apart from "the service did not
8
+ * answer", and not letting a superseded lookup overwrite a newer one's state. That is
9
+ * the part worth getting right once and testing without a browser, so it lives here
10
+ * and each tool supplies only its item reader.
11
+ *
12
+ * Nothing here names a capability or a service. PIE ships no dictionary endpoint: the
13
+ * corpus behind one is licensed per programme, so a host supplies either an endpoint
14
+ * or a resolver of its own.
15
+ */
16
+ /** Longest term worth sending. Past this it is prose, not a headword. */
17
+ export const MAX_LOOKUP_TERM_LENGTH = 80;
18
+ /** Words a term may span. Generous enough for `carbon dioxide` and `mother in law`. */
19
+ export const MAX_LOOKUP_TERM_WORDS = 4;
20
+ /**
21
+ * Collapse whitespace and strip the punctuation a text selection drags along.
22
+ *
23
+ * A learner double-clicking a word at the end of a sentence selects `"reason."`, and
24
+ * a drag across a line break selects `"photo-\nsynthesis"`. Leading and trailing
25
+ * punctuation goes; internal hyphens and apostrophes stay, because `mother-in-law`
26
+ * and `don't` are the entries.
27
+ */
28
+ export function normalizeTerm(raw) {
29
+ return raw
30
+ .replace(/\s+/gu, " ")
31
+ .trim()
32
+ .replace(/^[^\p{L}\p{N}]+/u, "")
33
+ .replace(/[^\p{L}\p{N}]+$/u, "");
34
+ }
35
+ /**
36
+ * Whether a normalised term is worth sending.
37
+ *
38
+ * A whole sentence is not a lookup, and sending it wastes a request that will miss.
39
+ */
40
+ export function isLookupableTerm(term) {
41
+ if (!term)
42
+ return false;
43
+ if (term.length > MAX_LOOKUP_TERM_LENGTH)
44
+ return false;
45
+ return term.split(" ").length <= MAX_LOOKUP_TERM_WORDS;
46
+ }
47
+ /**
48
+ * Read a host payload into a result.
49
+ *
50
+ * `keys` are the payload fields to accept the item array under, in order, so a tool
51
+ * can take more than one spelling of the same field. Unknown extra fields are ignored
52
+ * rather than rejected, so a host can extend its payload without this package having
53
+ * to agree first. An item its reader rejects is dropped rather than failing the whole
54
+ * response: one malformed entry should not cost the learner the others.
55
+ */
56
+ export function readTermLookupPayload(payload, args) {
57
+ if (!payload || typeof payload !== "object") {
58
+ return {
59
+ status: "error",
60
+ reason: `The ${args.serviceLabel} returned no data.`,
61
+ };
62
+ }
63
+ const record = payload;
64
+ let raw = null;
65
+ for (const key of args.keys) {
66
+ if (Array.isArray(record[key])) {
67
+ raw = record[key];
68
+ break;
69
+ }
70
+ }
71
+ if (!raw) {
72
+ return {
73
+ status: "error",
74
+ reason: `The ${args.serviceLabel} response was unreadable.`,
75
+ };
76
+ }
77
+ const items = raw
78
+ .map(args.toItem)
79
+ .filter((item) => item !== null);
80
+ return items.length > 0 ? { status: "ok", items } : { status: "empty" };
81
+ }
82
+ /**
83
+ * A lookup that POSTs to a host endpoint.
84
+ *
85
+ * `credentials: "same-origin"` by default. The endpoint a host names is expected to
86
+ * sit behind the same session boundary as the assessment itself, so the session
87
+ * cookie is exactly what authorises it and requiring a host to wire a token to get
88
+ * its own already-authenticated route to answer is a configuration step it would
89
+ * reasonably forget. Cross-origin requests still send nothing. A host that authorises
90
+ * some other way passes `headers`, and one that wants no ambient credentials at all
91
+ * passes `credentials: "omit"`.
92
+ */
93
+ export function createEndpointTermLookup(args) {
94
+ const doFetch = args.fetchImpl ?? globalThis.fetch;
95
+ return async (request, signal) => {
96
+ if (typeof doFetch !== "function") {
97
+ return {
98
+ status: "error",
99
+ reason: "No fetch implementation is available.",
100
+ };
101
+ }
102
+ let headers = {
103
+ "content-type": "application/json",
104
+ };
105
+ try {
106
+ const extra = await args.headers?.();
107
+ if (extra)
108
+ headers = { ...headers, ...extra };
109
+ }
110
+ catch {
111
+ return {
112
+ status: "error",
113
+ reason: `The ${args.serviceLabel} could not be authorized.`,
114
+ };
115
+ }
116
+ try {
117
+ const response = await doFetch(args.endpoint, {
118
+ method: "POST",
119
+ headers,
120
+ credentials: args.credentials ?? "same-origin",
121
+ body: JSON.stringify(request),
122
+ signal,
123
+ });
124
+ if (!response.ok) {
125
+ return {
126
+ status: "error",
127
+ reason: `The ${args.serviceLabel} is unavailable (${response.status}).`,
128
+ };
129
+ }
130
+ return args.readResponse(await response.json());
131
+ }
132
+ catch (cause) {
133
+ // An aborted request is a newer lookup superseding this one, not a failure
134
+ // the learner should be told about.
135
+ if (cause instanceof DOMException && cause.name === "AbortError") {
136
+ return { status: "empty" };
137
+ }
138
+ return {
139
+ status: "error",
140
+ reason: `The ${args.serviceLabel} could not be reached.`,
141
+ };
142
+ }
143
+ };
144
+ }
145
+ /** Shown when the learner sends something that is not a headword. */
146
+ export const NOT_A_TERM_REASON = "Look up a single word or short phrase.";
147
+ /**
148
+ * The lookup half of a term panel: request sequencing and state transitions, with no
149
+ * framework reactivity of its own.
150
+ *
151
+ * Deliberately not a rune module. Rune modules in this package are source-only and
152
+ * each consumer has to alias them in its own Vite and TypeScript config; a plain
153
+ * class costs a caller one `$state` field instead, and is unit-testable directly.
154
+ */
155
+ export class TermLookupSession {
156
+ options;
157
+ /** Cancels a lookup the learner has already superseded. */
158
+ #inFlight = null;
159
+ /** The term the last lookup ran for, so a repeat of it is not re-issued. */
160
+ #searchedFor = "";
161
+ /** Identity of the last handed-in term this session acted on. */
162
+ #handledRequest = null;
163
+ constructor(options) {
164
+ this.options = options;
165
+ }
166
+ get searchedFor() {
167
+ return this.#searchedFor;
168
+ }
169
+ /**
170
+ * Reconcile the unconfigured notice with whether a resolver exists.
171
+ *
172
+ * A tool with nowhere to look words up says so, rather than offering a field that
173
+ * silently fails: the host has misconfigured it and the learner needs to know it is
174
+ * not their typing. Called with the current state because it only ever moves
175
+ * between `idle` and `unconfigured` — a resolver arriving mid-lookup must not
176
+ * discard results the learner is reading.
177
+ */
178
+ syncConfigured(current) {
179
+ const resolver = this.options.resolver();
180
+ if (!resolver && current.kind === "idle") {
181
+ this.options.onState({ kind: "unconfigured" });
182
+ return;
183
+ }
184
+ if (resolver && current.kind === "unconfigured") {
185
+ this.options.onState({ kind: "idle" });
186
+ }
187
+ }
188
+ /**
189
+ * Run a term handed in from outside, at most once per request.
190
+ *
191
+ * Keyed on the request's identity, not on the term or on what was last searched.
192
+ * Both of the alternatives are wrong in a way a learner meets: keying on the term
193
+ * means selecting a word, typing another into the field, then selecting the first
194
+ * word again does nothing, because the incoming term never changed; keying on
195
+ * {@link searchedFor} means every reopen re-issues the stale selection and discards
196
+ * the lookup the learner typed. An id that changes per request says what both are
197
+ * guessing at.
198
+ *
199
+ * A request with no id falls back to the term, which is the best available identity
200
+ * when the caller cannot mint one — a host assigning `term` directly, for instance.
201
+ *
202
+ * Returns whether it issued a lookup, so a caller can mirror the term into its own
203
+ * field only when one ran.
204
+ */
205
+ syncRequestedTerm(request) {
206
+ const incoming = normalizeTerm(request.term);
207
+ // Closed panels spend no request: a selection made while the panel is shut is not
208
+ // a question, and answering it would burn a lookup the learner never sees.
209
+ if (!request.visible || !incoming)
210
+ return false;
211
+ const identity = request.requestId === undefined
212
+ ? `term:${incoming}`
213
+ : `id:${request.requestId}`;
214
+ if (identity === this.#handledRequest)
215
+ return false;
216
+ this.#handledRequest = identity;
217
+ void this.run(incoming);
218
+ return true;
219
+ }
220
+ async run(raw) {
221
+ const resolver = this.options.resolver();
222
+ if (!resolver) {
223
+ this.options.onState({ kind: "unconfigured" });
224
+ return;
225
+ }
226
+ const keyword = normalizeTerm(raw);
227
+ if (!isLookupableTerm(keyword)) {
228
+ this.options.onState({
229
+ kind: "error",
230
+ term: keyword,
231
+ reason: NOT_A_TERM_REASON,
232
+ });
233
+ return;
234
+ }
235
+ this.#inFlight?.abort();
236
+ const controller = new AbortController();
237
+ this.#inFlight = controller;
238
+ this.#searchedFor = keyword;
239
+ this.options.onState({ kind: "searching", term: keyword });
240
+ const result = await resolver({
241
+ keyword,
242
+ language: this.options.language?.() || undefined,
243
+ max: this.options.max,
244
+ }, controller.signal);
245
+ // A superseded lookup must not overwrite the newer one's state.
246
+ if (controller !== this.#inFlight)
247
+ return;
248
+ this.#inFlight = null;
249
+ if (result.status === "ok") {
250
+ this.options.onState({
251
+ kind: "results",
252
+ term: keyword,
253
+ items: result.items,
254
+ });
255
+ return;
256
+ }
257
+ if (result.status === "empty") {
258
+ this.options.onState({ kind: "empty", term: keyword });
259
+ return;
260
+ }
261
+ this.options.onState({
262
+ kind: "error",
263
+ term: keyword,
264
+ reason: result.reason,
265
+ });
266
+ }
267
+ }
268
+ /**
269
+ * The panel's live-region text.
270
+ *
271
+ * Only the states a learner is waiting on announce. `idle`, `unconfigured` and
272
+ * `error` are already rendered as visible text in the panel body, and repeating them
273
+ * here would announce the same sentence twice.
274
+ */
275
+ export function termPanelStatusMessage(state, labels) {
276
+ if (state.kind === "searching")
277
+ return `Looking up ${state.term}`;
278
+ if (state.kind === "results") {
279
+ return `${labels.countLabel(state.items.length)} for ${state.term}`;
280
+ }
281
+ if (state.kind === "empty")
282
+ return labels.emptyLabel(state.term);
283
+ return "";
284
+ }
@@ -7,6 +7,10 @@
7
7
  * INCLUDING REFERENCES TO THE DATASTORE PACKAGE INSTEAD WILL MAKE PIEONEER THROW 500
8
8
  * EVERYWHERE WITHOUT ANY FURTHER EXPLANATION
9
9
  */
10
+ import type { FormativeDeliveryPolicy, FormativeItemPolicy, FormativeSectionSlice } from "../formative/types.js";
11
+ import type { TimedMediaSectionData, TimedMediaSectionSessionSlice } from "../timed-media/types.js";
12
+ export type { FormativeDeliveryPolicy, FormativeItemPolicy };
13
+ export type { TimedMediaSectionData };
10
14
  export interface BaseEntity {
11
15
  id?: string;
12
16
  createdAt?: Date;
@@ -233,6 +237,14 @@ export interface AssessmentItemRef extends SearchMetaDataEntity {
233
237
  item?: ItemEntity;
234
238
  /** Item-level settings for tool requirements and customization */
235
239
  settings?: ItemSettings;
240
+ /**
241
+ * Formative delivery override for this item. Every field overrides the
242
+ * section's `formative` policy independently, which is the order QTI 3 uses
243
+ * for `qti-item-session-control` on an item ref versus its section.
244
+ *
245
+ * See `docs/prds/formative-delivery-contract.md`.
246
+ */
247
+ formative?: FormativeItemPolicy;
236
248
  }
237
249
  export interface AssessmentSection extends SearchMetaDataEntity, SettingsMetaDataEntity {
238
250
  id?: string;
@@ -266,6 +278,35 @@ export interface AssessmentSection extends SearchMetaDataEntity, SettingsMetaDat
266
278
  * delivered without an enclosing assessment.
267
279
  */
268
280
  personalNeedsProfile?: PersonalNeedsProfile;
281
+ /**
282
+ * Formative delivery policy for this section: how many Tries a learner gets
283
+ * on each item, when feedback is revealed, and whether the behavior is on at
284
+ * all. Absent or `enabled: false` delivers exactly as a section without this
285
+ * field — no control, no state, no env override.
286
+ *
287
+ * Individual items override it through `AssessmentItemRef.formative`. See
288
+ * `docs/prds/formative-delivery-contract.md`.
289
+ */
290
+ formative?: FormativeDeliveryPolicy;
291
+ /**
292
+ * The section flavor. Absent for every section authored so far, and the only
293
+ * value is `"timed-media"`, which pairs with `timedMedia` below.
294
+ *
295
+ * A discriminator on data, not a renderer selector: the host still picks the
296
+ * layout tag, as every integration that renders a section already does. See
297
+ * `docs/prds/timed-media-section-contract.md#delivery-attachment`.
298
+ */
299
+ sectionType?: "timed-media";
300
+ /**
301
+ * Cue timeline and playback policy for a timed-media section. Carries no media
302
+ * payload — `stimulusRef` names the `class: "stimulus"` rubric block whose
303
+ * passage supplies the time source, so the media keeps a Catalog Owner and its
304
+ * captions, transcript and signed alternates resolve through the
305
+ * accessibility-catalog rail.
306
+ *
307
+ * Ignored unless `sectionType` is `"timed-media"`.
308
+ */
309
+ timedMedia?: TimedMediaSectionData;
269
310
  sort?: string;
270
311
  }
271
312
  export interface TestPart {
@@ -275,6 +316,70 @@ export interface TestPart {
275
316
  submissionMode: "individual" | "simultaneous";
276
317
  sections: AssessmentSection[];
277
318
  }
319
+ /**
320
+ * The host-facing persistence snapshot for one section, returned by
321
+ * `SectionControllerHandle.getSession()` and accepted by `applySession()`.
322
+ *
323
+ * Canonical here rather than in a player or the toolkit: the toolkit is
324
+ * complementary to a player rather than an alternative to one and sits beneath
325
+ * both entry paths, so a shape both need belongs alongside `AssessmentSection`.
326
+ * The delivery slices it carries are defined in this package too, which is what
327
+ * previously forced them to reference this shape in prose instead of types.
328
+ */
329
+ export interface SectionControllerSessionState {
330
+ currentItemIndex?: number;
331
+ visitedItemIdentifiers?: string[];
332
+ itemSessions: Record<string, unknown>;
333
+ /**
334
+ * Formative delivery state — Try counts and reveal state per item.
335
+ *
336
+ * Optional, and its absence is indistinguishable from a snapshot saved
337
+ * before formative delivery existed, which is what keeps existing persisted
338
+ * sessions valid. A slice with an unrecognized `version` is rejected whole
339
+ * and formative state starts clean; `itemSessions` in the same snapshot is
340
+ * unaffected, so a formative version bump never costs a learner responses.
341
+ */
342
+ formative?: FormativeSectionSlice;
343
+ /**
344
+ * Timed-media delivery state — media progress, cue visits and completions.
345
+ *
346
+ * Same posture as `formative`: optional, absence indistinguishable from a
347
+ * pre-timed-media snapshot, and an unrecognized `version` rejected whole so
348
+ * cue progress restarts while item sessions in the same snapshot are applied
349
+ * untouched.
350
+ */
351
+ timedMedia?: TimedMediaSectionSessionSlice;
352
+ }
353
+ export interface AssessmentSessionNavigationState {
354
+ currentSectionIndex: number;
355
+ visitedSectionIdentifiers: string[];
356
+ currentSectionIdentifier?: string;
357
+ }
358
+ export interface AssessmentSessionRealization {
359
+ seed: string;
360
+ sectionIdentifiers: string[];
361
+ }
362
+ export interface AssessmentSectionSessionState {
363
+ sectionIdentifier: string;
364
+ updatedAt: string;
365
+ /**
366
+ * The full section snapshot. Typing this as anything narrower drops the
367
+ * delivery slices without a compiler error at the point of loss.
368
+ */
369
+ session: SectionControllerSessionState | null;
370
+ }
371
+ export interface AssessmentSession {
372
+ version: 1;
373
+ assessmentAttemptSessionIdentifier: string;
374
+ assessmentId: string;
375
+ startedAt: string;
376
+ updatedAt: string;
377
+ completedAt?: string;
378
+ navigationState: AssessmentSessionNavigationState;
379
+ realization: AssessmentSessionRealization;
380
+ sectionSessions: Record<string, AssessmentSectionSessionState>;
381
+ contextVariables?: Record<string, unknown>;
382
+ }
278
383
  export interface ContextDeclaration {
279
384
  identifier: string;
280
385
  baseType: "boolean" | "integer" | "float" | "string" | "identifier" | "point" | "pair" | "directedPair" | "duration" | "file" | "uri";
@@ -950,4 +1055,3 @@ export interface CmsBlueprintItemEntity extends BaseEntity {
950
1055
  details?: string;
951
1056
  assessmentId?: string;
952
1057
  }
953
- export {};
@@ -0,0 +1 @@
1
+ export declare function coerceBooleanLike(value: boolean | string | null | undefined, defaultValue?: boolean): boolean;
@@ -0,0 +1,19 @@
1
+ export function coerceBooleanLike(value, defaultValue = false) {
2
+ if (typeof value === "boolean") {
3
+ return value;
4
+ }
5
+ if (value === null || value === undefined) {
6
+ return defaultValue;
7
+ }
8
+ const normalizedValue = String(value).trim().toLowerCase();
9
+ if (normalizedValue === "") {
10
+ return defaultValue;
11
+ }
12
+ if (["false", "0", "off", "no"].includes(normalizedValue)) {
13
+ return false;
14
+ }
15
+ if (["true", "1", "on", "yes"].includes(normalizedValue)) {
16
+ return true;
17
+ }
18
+ return Boolean(normalizedValue);
19
+ }
@@ -8,3 +8,25 @@ export declare const FOCUSABLE_SELECTOR: string;
8
8
  * not disabled, not inside `inert`, and has a layout box).
9
9
  */
10
10
  export declare function isProgrammaticFocusTarget(el: HTMLElement): boolean;
11
+ /**
12
+ * Whether `el` is in the sequential tab order.
13
+ *
14
+ * Stricter than {@link isProgrammaticFocusTarget}, which deliberately accepts
15
+ * `tabindex="-1"`: such an element is a valid `focus()` target — a roving-tabindex
16
+ * control or a landmark focused after a view change — but is explicitly not tabbable.
17
+ * A tab order that included it would stop on elements the browser skips.
18
+ */
19
+ export declare function isTabbable(el: HTMLElement): boolean;
20
+ /**
21
+ * Focusable descendants of `root` in tab order, descending into open shadow roots.
22
+ *
23
+ * `querySelectorAll` stops at a shadow boundary, which makes it the wrong tool for
24
+ * collecting a tab order in this codebase: every tool renders into `shadow: "open"`,
25
+ * so a container holding one has focusable content the selector cannot see. A focus
26
+ * trap built on the flat query traps Tab in the host's own chrome and never reaches
27
+ * the tool — the tool's controls are then unreachable by keyboard entirely.
28
+ *
29
+ * Closed shadow roots are invisible to script and are skipped; nothing can be done
30
+ * for them from outside.
31
+ */
32
+ export declare function collectFocusable(root: Element | ShadowRoot): HTMLElement[];