@pie-players/pie-assessment-player 0.3.75 → 1.0.0-beta.1

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.
package/README.md CHANGED
@@ -1,209 +1,427 @@
1
- # PIE Assessment Player
2
-
3
- `@pie-players/pie-assessment-player` provides assessment-level orchestration custom elements.
4
-
5
- It coordinates section flow and assessment session state while delegating section rendering to `@pie-players/pie-section-player` and shared services to `@pie-players/pie-assessment-toolkit`.
6
-
7
- Primary entrypoint:
8
-
9
- - `@pie-players/pie-assessment-player`
1
+ # @pie-players/pie-assessment-player
2
+
3
+ `<pie-assessment-player-default>` is the reference assessment player: a
4
+ multi-section player assembled only from the public exports of the section
5
+ player, the assessment toolkit and `@pie-players/pie-players-shared`. It serves
6
+ prototypes and demos, and it is the worked example for teams that build their
7
+ own. It has no production users. Production assessment players are host-built
8
+ from the same packages
9
+ ([product scope](../../docs/architecture/architecture.md#product-scope)), and
10
+ this package changes without a compatibility period.
11
+
12
+ This README is the API reference for a host that mounts it: inputs, host
13
+ methods, the controller, hooks, events and persistence.
14
+ [Building a Multi-Section Player](../../docs/assessment-player/integration-guide.md)
15
+ builds a player from the toolkit's assessment-session helpers, with this one as
16
+ the worked example, and the [assessment demos](../../apps/assessment-demos/README.md)
17
+ run it.
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install @pie-players/pie-assessment-player
23
+ ```
10
24
 
11
- The entry is bundler-only: it imports the toolkit, players-shared and the
12
- section player by bare specifier.
25
+ ```ts
26
+ import "@pie-players/pie-assessment-player";
27
+ ```
13
28
 
14
- ## AssessmentController
29
+ Importing the package registers `pie-assessment-player-default` and
30
+ `pie-assessment-player-shell`, and its import of `@pie-players/pie-section-player`
31
+ registers the section player layouts. The entry is bundler-only and
32
+ browser-only: it imports `@pie-players/pie-section-player` and
33
+ `@pie-players/pie-assessment-toolkit` by bare specifier. The package has no
34
+ browser build, so a CDN cannot serve it; its `unpkg` and `jsdelivr` fields name
35
+ the bundler entry.
36
+
37
+ ## Elements
38
+
39
+ | Element | Role |
40
+ | --- | --- |
41
+ | `pie-assessment-player-default` | Runs an assessment's sections one at a time in a section player, with a position label and Back and Next buttons |
42
+ | `pie-assessment-player-shell` | A layout frame: a `navigation` slot above a default slot, filled by the host ([Shell element](#shell-element)) |
43
+
44
+ ## Usage
45
+
46
+ ```html
47
+ <pie-assessment-player-default
48
+ assessment-id="algebra-unit-1"
49
+ attempt-id="attempt-42"
50
+ section-player-layout="splitpane"
51
+ ></pie-assessment-player-default>
52
+ ```
15
53
 
16
- `AssessmentController` is the domain authority for an assessment attempt. It
17
- owns cross-section navigation, the assessment session shape (delivery plan +
18
- per-section snapshots), and `submit()`. Per-section concerns (in-section
19
- navigation, item sessions, content loading) belong to the embedded
20
- `SectionController` exposed by `@pie-players/pie-section-player`. See
21
- [`docs/assessment-player/client-architecture-tutorial.md`](../../docs/assessment-player/client-architecture-tutorial.md)
22
- for the end-to-end walkthrough.
54
+ ```ts
55
+ import "@pie-players/pie-assessment-player";
56
+ import type {
57
+ AssessmentDefinition,
58
+ AssessmentPlayerHooks,
59
+ AssessmentPlayerRuntimeConfig,
60
+ AssessmentPlayerRuntimeHostContract,
61
+ } from "@pie-players/pie-assessment-player";
62
+ import type { AssessmentSession, ToolkitCoordinator } from "@pie-players/pie-assessment-toolkit";
63
+
64
+ type AssessmentPlayerElement = HTMLElement &
65
+ AssessmentPlayerRuntimeConfig &
66
+ AssessmentPlayerRuntimeHostContract & {
67
+ coordinator: ToolkitCoordinator | null;
68
+ session: AssessmentSession | null;
69
+ };
70
+
71
+ declare const assessment: AssessmentDefinition;
72
+ declare const hooks: AssessmentPlayerHooks;
73
+
74
+ const player = document.querySelector<AssessmentPlayerElement>("pie-assessment-player-default");
75
+ if (player) {
76
+ player.assessment = assessment;
77
+ player.hooks = hooks;
78
+ player.env = { mode: "gather", role: "student" };
79
+ }
80
+ ```
23
81
 
24
- The handle implements `AssessmentControllerHandle` (defined in
25
- `packages/assessment-player/src/controller/AssessmentController.ts`); see
26
- the JSDoc on that interface for the per-method contract.
82
+ An `AssessmentDefinition` lists its sections in `testParts[].sections` or in
83
+ `sections`. The default delivery plan flattens them in order; a section without
84
+ an `identifier` gets `section-{index}`, or `section-{part}-{index}` inside a test
85
+ part.
86
+
87
+ ## Inputs
88
+
89
+ | Input | Attribute | Default | A change |
90
+ | --- | --- | --- | --- |
91
+ | `assessmentId` | `assessment-id` | — | Rebuilds the controller |
92
+ | `attemptId` | `attempt-id` | — | Rebuilds the controller. Without one the default persistence neither reads nor writes |
93
+ | `assessment` | property | — | Rebuilds the controller |
94
+ | `hooks` | property | — | Rebuilds the controller ([Hooks](#hooks)) |
95
+ | `session` | property | — | Rebuilds the controller from the new value, under the rules in [Lifecycle](#lifecycle) |
96
+ | `env` | property | — | Sets `runtime` on the mounted section: `{ mode, role }` |
97
+ | `coordinator` | property | — | Sets `runtime` on the mounted section: a host-owned `ToolkitCoordinator` every section shares |
98
+ | `playerType` | `player-type` | `iife` | Sets `runtime` on the mounted section: the element loading strategy, `iife`, `esm` or `preloaded` |
99
+ | `sectionPlayerRuntime` | property | — | Re-attaches instrumentation, then sets `runtime` on the mounted section ([Section runtime](#section-runtime)) |
100
+ | `sectionPlayerLayout` | `section-player-layout` | `splitpane` | Remounts the section, keeping its session and the controller. `vertical` selects the vertical layout; any other value the splitpane |
101
+ | `showNavigation` | `show-navigation` | `true` | Shows or hides the position label and the Back and Next buttons |
102
+ | `locale` | `locale` | `en-US` | Relabels the chrome and sets the section's `locale` attribute, removing it when empty |
103
+ | `debug` | `debug` | — | Sets `window.PIE_DEBUG` ([Debug logging](#debug-logging)) |
104
+
105
+ The element starts once it is connected and holds both `assessmentId` and
106
+ `assessment`; until then it shows an empty state. Inputs assigned in the same
107
+ turn are batched, whether before or after connecting. A rebuild retires the
108
+ current controller at once and starts the next in a microtask, which remounts
109
+ the section. An object input changes only when replaced: mutating an assigned
110
+ object in place signals nothing. `env`, `coordinator`, `playerType` and
111
+ `sectionPlayerRuntime` reach the mounted section without a remount.
112
+
113
+ ### Section runtime
114
+
115
+ Each mounted section player receives a `runtime` built from the element's
116
+ inputs:
117
+
118
+ | `runtime` key | Source |
119
+ | --- | --- |
120
+ | `playerType` | `playerType` |
121
+ | `assessmentId` | `assessmentId`, when set |
122
+ | `env` | `env`, when set |
123
+ | `coordinator` | `coordinator`, when set |
124
+ | `player` | `sectionPlayerRuntime.player`, with `backend` deep-cloned |
125
+ | Any other key | `sectionPlayerRuntime` |
126
+
127
+ A key in `sectionPlayerRuntime` overrides the element's own, so
128
+ `sectionPlayerRuntime.assessmentId` wins over `assessment-id`. The section's
129
+ `section-id` is the section identifier; `attempt-id`, `locale` and `debug` travel
130
+ as section attributes, and `hooks.cardTitleFormatter` becomes the section's
131
+ `hooks`. The section player's [inputs](../section-player/README.md#inputs)
132
+ and [runtime configuration](../section-player/README.md#runtime-configuration)
133
+ define the keys.
134
+
135
+ Without a `coordinator`, each section's toolkit builds its own coordinator,
136
+ without hooks, and disposes it with the section, so tool state, text-to-speech
137
+ and section persistence hooks do not carry across sections. Pass one
138
+ `ToolkitCoordinator` to share them. It stays host-owned, and on a section change
139
+ the element stops its text-to-speech playback.
140
+
141
+ ## Host methods
142
+
143
+ The element implements `AssessmentPlayerRuntimeHostContract`:
144
+
145
+ | Method | Returns |
146
+ | --- | --- |
147
+ | `getSnapshot()` | `{ readiness, navigation, progress }`, with zero counts before the controller is ready |
148
+ | `selectReadiness()` | `{ phase }`: `bootstrapping`, `hydrating`, `ready` or `error` |
149
+ | `selectNavigation()` | `currentIndex`, `totalSections`, `canNext`, `canPrevious`, `currentSectionId` |
150
+ | `selectProgress()` | `visitedSections`, `totalSections` |
151
+ | `navigateTo(indexOrIdentifier)` | `true` when the section changed, and for the current section's identifier |
152
+ | `navigateNext()`, `navigatePrevious()` | `true` when the section changed |
153
+ | `getAssessmentController()` | The controller handle while connected and ready, else `null` |
154
+ | `waitForAssessmentController(timeoutMs = 5000)` | A promise of the handle, or `null` |
155
+
156
+ Each navigation method first dispatches the cancelable
157
+ `assessment-navigation-requested`, before any range check: a request at the last
158
+ section carries an out-of-range `toIndex` and no `toSectionId`. A canceled request
159
+ returns `false`. Otherwise the method calls the controller and, when the section
160
+ changed, `persist()`. The Back and Next buttons call these methods.
161
+ `navigateTo(identifier)` reports the current index as its `toIndex`, so read the
162
+ target from `toSectionId`; with the current section's identifier it returns `true`
163
+ and dispatches nothing.
27
164
 
28
165
  ### Obtaining the handle
29
166
 
30
- Two equivalent paths:
31
-
32
167
  ```ts
33
- const host = document.querySelector("pie-assessment-player-default") as any;
34
- const controller =
35
- (await host?.waitForAssessmentController?.(5000)) ??
36
- host?.getAssessmentController?.();
168
+ import type { AssessmentPlayerRuntimeHostContract } from "@pie-players/pie-assessment-player";
169
+
170
+ const player = document.querySelector<HTMLElement & AssessmentPlayerRuntimeHostContract>(
171
+ "pie-assessment-player-default",
172
+ );
173
+ const controller = await player?.waitForAssessmentController(5000);
37
174
  ```
38
175
 
39
- `waitForAssessmentController(timeoutMs)` resolves after initialization and
40
- hydration succeed. At that point the getter, ready hook, and ready event all
41
- observe the same controller. The getter returns `null` while initialization is
42
- pending or failed, and after disconnect. A waiter resolves to `null` when its
43
- active attempt fails or is retired, or its own timeout expires. A waiter's
44
- timeout does not cancel a still-active initialization.
45
-
46
- Connect the element and assign `assessmentId`, `attemptId`, `assessment`,
47
- `hooks` and `session` in the same turn, or assign them before connecting. These
48
- assignments are batched; changing assessment, attempt, hooks or session later
49
- retires the previous controller and starts a new one. Replace object properties to update them;
50
- mutating a previously assigned object in place is not an update signal. Runtime
51
- props, locale, and navigation visibility do not reload the assessment.
52
-
53
- Failed initialization exposes an error and a keyboard-accessible Retry action.
54
- The element invokes the ready hook and emits the ready event once per successful
55
- initialization. Ready hooks are notifications: their rejection reaches `onError`
56
- and `assessment-error` without undoing successful hydration.
176
+ The handle is ready once initialization and hydration succeed. The getter, the
177
+ waiter, `onAssessmentControllerReady` and `assessment-controller-ready` then
178
+ observe the same controller. The getter returns `null` before that, after a
179
+ failure and after disconnect. A waiter resolves `null` at once on a disconnected
180
+ element or one in the error phase, and later when its initialization fails or is
181
+ retired or its own timeout expires; the timeout does not cancel the
182
+ initialization. Only the newest connected initialization publishes a controller,
183
+ UI, event, hook call or error.
57
184
 
58
185
  ### Lifecycle
59
186
 
60
- The element owns its assessment controller and calls its idempotent `dispose()`
61
- when inputs replace the attempt or the element disconnects. Disposal removes
62
- listeners and makes the old handle unusable. The nested toolkit disposes any
63
- coordinator it created; a coordinator supplied by the host remains host-owned.
187
+ The element owns its controller. It disposes the controller when an input
188
+ rebuilds it or the element disconnects. `dispose()` is idempotent: it removes the
189
+ listeners, makes the handle unusable and calls `onAssessmentControllerDispose`. A
190
+ host-supplied coordinator stays host-owned; one a section's toolkit built is
191
+ disposed with that section.
192
+
193
+ A failed start shows an error state with a keyboard-accessible Retry button and
194
+ dispatches `assessment-error`. The ready hook and the ready event run once per
195
+ successful start. The hook is a notification: its rejection reaches `onError`
196
+ (phase `controller-init`) and `assessment-error` and leaves the hydrated
197
+ assessment ready.
64
198
 
65
199
  ```ts
66
- // hydrate runs as part of initialize(); call it again only on a manual reload.
67
- const unsubscribe = controller.subscribe(handleEvent);
68
-
69
- // host-driven navigation:
70
- controller.navigateTo("section-2");
71
- controller.navigateNext();
72
- controller.navigatePrevious();
73
-
74
- // persist on whatever cadence the host wants; saves run in call order.
75
- // submit() saves first and rejects, unsubmitted, if that save fails.
76
- await controller.persist();
77
- await controller.submit();
78
- unsubscribe();
200
+ const unsubscribe = controller?.subscribe((event) => {
201
+ if (event.type === "assessment-route-changed") {
202
+ showPosition(event.currentSectionIndex, event.totalSections);
203
+ }
204
+ });
205
+
206
+ // Saves run in call order. submit() saves first and rejects, unsubmitted, when
207
+ // that save fails.
208
+ await controller?.persist();
209
+ await controller?.submit();
210
+ unsubscribe?.();
79
211
  ```
80
212
 
81
- `getSession()` returns the current `AssessmentSession` snapshot — the same
82
- shape the persistence strategy load/save methods exchange and the same
83
- shape per-section bridges roll up into via `updateSectionSession(sectionId,
84
- snapshot)`.
85
-
86
- The element's `session` property takes that shape too. The controller's first
87
- hydrate applies an assigned session in place of the strategy's `loadSession`;
88
- the strategy still receives every `persist()`. The value is consumed once: a
89
- rebuild for another reason, such as an `assessment` change, hydrates from the
90
- strategy. Assigning a session equal to the controller's, as writing
91
- `assessment-session-changed` back into the property does, is a no-op, and so is
92
- `null` once the controller is ready. Reading `session` returns the assigned value
93
- until the controller is ready and a copy of the controller's session after.
94
-
95
- The default element captures the outgoing section before navigation replaces it,
96
- including navigation through the controller. A returning section receives its
97
- saved section snapshot as the section player's `session` property, which its
98
- controller applies before the canonical `engine-ready` stage; the section accepts
99
- input and session updates from `engine-ready` on. The section region stays `aria-busy` during
100
- this handoff. Failed restoration leaves the saved snapshot intact, reports
101
- `assessment-error` and `onError` with phase `navigation`, and offers Retry.
102
- Navigating away or disconnecting retires that handoff. This in-memory restoration
103
- does not acknowledge durable saves; persistence still uses the host's strategy.
104
-
105
- ### Event stream
106
-
107
- The controller's typed event stream
108
- (`AssessmentControllerEvent` discriminated union) covers assessment-level
109
- change only — section-level events flow through the embedded
110
- `SectionController` and the toolkit coordinator's
111
- `subscribeItemEvents` / `subscribeSectionLifecycleEvents` helpers.
112
-
113
- - `assessment-route-changed` — section-level navigation moved (index / id,
114
- `previousSectionId`, `canNext` / `canPrevious`).
115
- - `assessment-session-applied` — `hydrate()` applied a session, persisted or
116
- assigned as `session`.
117
- - `assessment-session-changed` — assessment session mutated (navigation,
118
- per-section snapshot upsert). The active section's snapshot is upserted once
119
- for each `session-changed` it emits.
120
- - `assessment-progress-changed` — visited-section count flipped.
121
- - `assessment-submission-state-changed` — `submit()` recorded the final
122
- state.
123
-
124
- The same assessment event names are also emitted as DOM events on the player
125
- chrome. The instrumentation bridge forwards those DOM events under the
126
- `pie-assessment-*` provider event names documented in
127
- [Instrumentation and observability](#instrumentation-and-observability).
128
-
129
- ## Debug logging
130
-
131
- Assessment-player now exposes a `debug` option on `pie-assessment-player-default`.
132
-
133
- - Enable verbose debug logs: `<pie-assessment-player-default debug="true">`
134
- - Disable verbose debug logs: `<pie-assessment-player-default debug="false">` (or `debug="0"`)
213
+ The `session` property takes an `AssessmentSession`. The controller's first
214
+ hydrate applies it in place of the strategy's `loadSession`, and the strategy
215
+ still receives every save. The value is consumed once: a rebuild for another
216
+ reason, such as an `assessment` change, hydrates from the strategy. Assigning a
217
+ session whose content equals the controller's, as echoing `getSession()` back on
218
+ `assessment-session-changed` does, is a no-op, and so is `null` once the
219
+ controller is ready; any other value rebuilds the controller from it. Reading
220
+ `session` returns the assigned value until the controller is ready, and a copy of
221
+ the controller's session after.
222
+
223
+ On every section mount the element passes the section's saved snapshot, when the
224
+ assessment session holds one, as the section player's `session` property, which
225
+ the section's controller applies before `engine-ready`. A section without an
226
+ entry hydrates from the section strategy
227
+ ([Session persistence and submission](#session-persistence-and-submission)).
228
+ Until the section reaches `engine-ready`, for up to 5 seconds, the section region
229
+ is `aria-busy` and the section `inert`. The element then unlocks it and copies
230
+ the section's session into the assessment session on each toolkit
231
+ `session-changed`, and once more before navigation replaces the section,
232
+ including navigation through the controller. A section that fails or times out
233
+ shows a status message, "restore failed" when a snapshot was passed and "load
234
+ failed" otherwise, with Retry; it calls `onError` with phase `navigation` and
235
+ `{ sectionIdentifier }` and dispatches `assessment-error`, and the saved snapshot
236
+ stays intact. This handoff is in memory and acknowledges no durable save.
237
+
238
+ ### Assessment controller
239
+
240
+ The handle implements `AssessmentControllerHandle`:
241
+
242
+ | Member | Effect |
243
+ | --- | --- |
244
+ | `initialize()` | Builds the delivery plan, creates a fresh session and runs `hydrate()`. The element calls it |
245
+ | `hydrate()` | Runs `onBeforeAssessmentHydrate`, then applies the assigned or loaded session and emits `assessment-session-applied` when there was one |
246
+ | `persist()` | Saves through the strategy, queued in call order. A failure reaches `onError` (`session-save`) and the promise resolves |
247
+ | `submit()` | Saves, then marks the assessment submitted and emits `assessment-submission-state-changed`. A failed save reaches `onError` and rejects, and the assessment stays unsubmitted |
248
+ | `getSession()` | The controller's live `AssessmentSession`, the shape the strategy exchanges; `null` once disposed |
249
+ | `getRuntimeState()` | `readiness`, `currentSectionIndex`, `totalSections`, `currentSectionId`, `canNext`, `canPrevious`, `visitedSections`, `submitted`, for chrome and diagnostics |
250
+ | `navigateTo(indexOrIdentifier)`, `navigateNext()`, `navigatePrevious()` | Move, emitting `assessment-route-changed`, `assessment-session-changed` and `assessment-progress-changed`; `false` for an index out of range or an unknown identifier |
251
+ | `subscribe(listener)` | Delivers the [event stream](#event-stream) and returns a disposer |
252
+ | `getCurrentSection()`, `getSectionAt(index)` | A delivery-plan entry: `sectionIdentifier`, `section`, `stageIdentifier`, `stageIndex`, `sectionIndex` |
253
+ | `getSectionSession(sectionId)` | That section's `SectionControllerSessionState`, or `null` |
254
+ | `updateSectionSession(sectionId, session)` | Writes a section snapshot into the assessment session and emits `assessment-session-changed` |
255
+ | `dispose()` | Retires the controller ([Lifecycle](#lifecycle)) |
256
+
257
+ Persist `getSession()`. `getRuntimeState()` is derived and carries no stability
258
+ guarantee. Navigation through the controller skips the element's navigation
259
+ request and its `persist()`; the element still captures the outgoing section and
260
+ remounts.
261
+
262
+ ## Hooks
263
+
264
+ | Hook | Called |
265
+ | --- | --- |
266
+ | `cardTitleFormatter(context)` | Forwarded to each section player ([Card titles](#card-titles)) |
267
+ | `createAssessmentDeliveryPlan(context, defaults)` | Once per controller, at start. `context` is `{ assessmentId, attemptId, assessment }`; `defaults.createDefaultDeliveryPlan()` flattens the sections into a linear list. A nullish return gets the default |
268
+ | `createAssessmentSessionPersistence(context, defaults)` | Once per controller, at the first hydrate or save, and cached. `context` is `{ assessmentId, attemptId }`; `defaults.createDefaultPersistence()` returns the `localStorage` strategy. A nullish return gets the default |
269
+ | `onBeforeAssessmentHydrate(context)` | Awaited at the start of each hydrate |
270
+ | `onBeforeAssessmentPersist(context, session)` | Awaited before each save, inside the save queue |
271
+ | `onAssessmentControllerReady(controller)` | After a successful start; not awaited |
272
+ | `onAssessmentControllerDispose(controller)` | On dispose |
273
+ | `onError(error, { phase, details })` | On every reported failure |
274
+
275
+ | `onError` phase | Failure |
276
+ | --- | --- |
277
+ | `delivery-plan-create` | `createAssessmentDeliveryPlan` threw |
278
+ | `session-load` | A hydrate failed: its hook, the persistence factory or `loadSession`. On a published controller the element also retires it, shows the error state and dispatches `assessment-error` |
279
+ | `session-save` | A save from `persist()` or `submit()` failed. No DOM event follows |
280
+ | `controller-init` | `onAssessmentControllerReady` rejected |
281
+ | `controller-dispose` | `onAssessmentControllerDispose` rejected |
282
+ | `navigation` | A section failed to load or restore; `details` is `{ sectionIdentifier }` |
283
+
284
+ ## Events
285
+
286
+ ### DOM events
287
+
288
+ The element dispatches these on itself, bubbling and composed.
289
+ `ASSESSMENT_PLAYER_PUBLIC_EVENTS` holds the names.
290
+
291
+ | Event | Detail | When |
292
+ | --- | --- | --- |
293
+ | `assessment-controller-ready` | `{ controller }` | After a successful start |
294
+ | `assessment-navigation-requested` | `fromIndex`, `toIndex`, `fromSectionId`, `toSectionId`, `reason` (`navigate-to`, `navigate-next`, `navigate-previous`) | Before an element navigation. Cancelable: `preventDefault()` blocks it |
295
+ | `assessment-route-changed` | `currentSectionIndex`, `totalSections`, `currentSectionId`, `previousSectionId`, `canNext`, `canPrevious` | After the section changed |
296
+ | `assessment-session-applied` | `timestamp` | A hydrate applied an assigned or loaded session |
297
+ | `assessment-session-changed` | `timestamp` | The session changed: a move or a section snapshot |
298
+ | `assessment-progress-changed` | `visitedSectionCount`, `totalSections` | After every move |
299
+ | `assessment-submission-state-changed` | `submitted: true` | `submit()` succeeded |
300
+ | `assessment-error` | `{ error }` | A failed start, a failed hydrate of a published controller, a rejected ready hook, or a section that failed to load |
301
+
302
+ The route, session, progress and submission details are the controller's event
303
+ objects, `type` and `timestamp` included. A move arrives in this order, for DOM
304
+ and controller listeners alike: `assessment-session-changed` for the outgoing
305
+ section's snapshot, `assessment-route-changed`, `assessment-session-changed` for
306
+ the position, then `assessment-progress-changed`. The section remounts during
307
+ `assessment-route-changed`.
135
308
 
136
- The setting is forwarded to the rendered section-player instance and also applies
137
- the global flag (`window.PIE_DEBUG`) for shared runtime logging behavior.
309
+ ```ts
310
+ player?.addEventListener("assessment-navigation-requested", (event) => {
311
+ const { fromSectionId, toSectionId } = (event as CustomEvent).detail;
312
+ if (!isNavigationAllowed(fromSectionId, toSectionId)) event.preventDefault();
313
+ });
314
+ ```
138
315
 
139
- ## Card Title Formatter
316
+ ### Event stream
140
317
 
141
- Use the existing `hooks` registration surface on `pie-assessment-player-default`.
142
- The callback is forwarded to each rendered section-player instance, including the
143
- initially mounted section (for example when launching directly into section 2).
318
+ The controller's `subscribe()` delivers `AssessmentControllerEvent`, a union of
319
+ `assessment-route-changed`, `assessment-session-applied`,
320
+ `assessment-session-changed`, `assessment-progress-changed` and
321
+ `assessment-submission-state-changed`, with the payloads in the table above. It
322
+ covers assessment-level change only. Section events come from the section player
323
+ and its controller, and the coordinator's `subscribeItemEvents` and
324
+ `subscribeSectionLifecycleEvents` helpers follow them across sections
325
+ ([Subscriptions](../section-player/README.md#subscriptions)).
326
+
327
+ ## Session persistence and submission
328
+
329
+ `createAssessmentSessionPersistence` returns the strategy: `loadSession(context)`,
330
+ `saveSession(context, session)` and an optional `clearSession(context)`. The
331
+ default strategy stores the session in `localStorage` under
332
+ `pie:assessment-controller:v1:{assessmentId}:{attemptId}`. Without an attempt id
333
+ it neither reads nor writes, since nothing would tell two learners on one device
334
+ apart. Supply a strategy backed by your own store for anything beyond a demo.
335
+
336
+ A loaded session replaces the controller's wholesale, without a check against the
337
+ delivery plan. Saves run one at a time in call order, so an older write never
338
+ lands after a newer one. The element calls `persist()` only after its own moves:
339
+ a section's answers reach the assessment session on each `session-changed` but
340
+ reach the strategy at the next move, `persist()` or `submit()`, so a host that
341
+ saves on answers calls `persist()` itself.
342
+
343
+ `submit()` is local. It saves, then sets `submitted`, which the session does not
344
+ record: a reload starts unsubmitted, and navigation still works after submit.
345
+ [Authoritative assessment submission](../../docs/prds/assessment-authoritative-submission.md)
346
+ proposes a host-confirmed submission.
347
+
348
+ The section layer persists too. A section with no entry in the assessment
349
+ session hydrates from the coordinator's section strategy, by default
350
+ `localStorage` keyed by assessment, section and attempt id, and the coordinator
351
+ saves the section there before it disposes the section's controller
352
+ ([Section session persistence](../section-player/README.md#section-session-persistence)).
353
+ To make the assessment controller the one owner, pass a coordinator whose
354
+ `hooks.createSectionSessionPersistence` returns a strategy that loads and saves
355
+ nothing.
356
+
357
+ ## Backend delivery for embedded items
358
+
359
+ Item delivery against a backend is configured on
360
+ `sectionPlayerRuntime.player.backend`. The element passes a deep clone to each
361
+ section player, which derives every embedded item player's `backend` from it
362
+ ([Backend delivery for embedded items](../section-player/README.md#backend-delivery-for-embedded-items)).
363
+ The `attempt-id` is an assessment attempt and never becomes
364
+ `backend.delivery.assignmentId`; a host that delivers under an assignment sets
365
+ `assignmentId` itself. Assessment session persistence stays on
366
+ `createAssessmentSessionPersistence`. The item player's
367
+ [backend support](../../docs/item-player/backend-support.md) guide covers the
368
+ delivery contract.
369
+
370
+ ## Navigation chrome
371
+
372
+ The chrome shows a localized position label ("Section 2 of 3") and Back and Next
373
+ buttons, disabled at the first and last section. The section sits in a region
374
+ named by the position label. When focus was inside the element, a section change
375
+ returns it to the same button, or the other one when that is disabled, or the
376
+ region, and a polite status region announces the new position. Navigation from
377
+ the host's own chrome leaves focus with the host and announces nothing, so the
378
+ host announces the change.
379
+
380
+ ## Card titles
381
+
382
+ `hooks.cardTitleFormatter` reaches every mounted section player, the first one
383
+ included:
144
384
 
145
385
  ```ts
146
- const host = document.querySelector("pie-assessment-player-default") as any;
147
- host.hooks = {
148
- ...(host.hooks || {}),
149
- cardTitleFormatter: (context: Record<string, unknown>) => {
150
- if (context.kind === "item") {
151
- const itemIndex = Number(context.itemIndex ?? 0) + 1;
152
- return `Question ${itemIndex}`;
153
- }
154
- if (context.kind === "passage") {
155
- return "Reading passage";
156
- }
157
- return typeof context.defaultTitle === "string" ? context.defaultTitle : "";
386
+ import type { AssessmentPlayerHooks } from "@pie-players/pie-assessment-player";
387
+
388
+ const hooks: AssessmentPlayerHooks = {
389
+ cardTitleFormatter: (context) => {
390
+ if (context.kind === "item") return `Question ${Number(context.itemIndex ?? 0) + 1}`;
391
+ if (context.kind === "passage") return "Reading passage";
392
+ return null;
158
393
  },
159
394
  };
160
395
  ```
161
396
 
162
- Component registration entrypoints:
163
-
164
- - `@pie-players/pie-assessment-player/components/assessment-player-default-element`
165
- - `@pie-players/pie-assessment-player/components/assessment-player-shell-element`
166
-
167
- ## Instrumentation and observability
168
-
169
- Assessment-player instrumentation is provider-agnostic and built on the shared
170
- `InstrumentationProvider` contract used across players.
171
-
172
- Canonical provider injection paths:
397
+ The section player's [Card titles](../section-player/README.md#card-titles)
398
+ lists the context fields; a return that is not a non-empty string keeps the
399
+ default title.
173
400
 
174
- - `sectionPlayerRuntime.player.loaderConfig.instrumentationProvider`
175
-
176
- Provider semantics:
177
-
178
- - With `trackPageActions: true`, missing/`undefined` provider values use the default New Relic provider path.
179
- - `instrumentationProvider: null` explicitly disables instrumentation.
180
- - Invalid provider objects are ignored (optional debug warning), also no-op.
181
- - Existing `item-player` behavior remains the compatibility anchor.
182
- - To keep production telemetry while debugging, use `CompositeInstrumentationProvider`
183
- with `NewRelicInstrumentationProvider` and `DebugPanelInstrumentationProvider`.
184
- - Toolkit telemetry forwarding uses the same provider path, so tool/backend
185
- operational events appear in production providers and debug overlays.
186
-
187
- Assessment-player owned canonical stream:
188
-
189
- - `pie-assessment-controller-ready`
190
- - `pie-assessment-navigation-requested`
191
- - `pie-assessment-route-changed`
192
- - `pie-assessment-session-applied`
193
- - `pie-assessment-session-changed`
194
- - `pie-assessment-progress-changed`
195
- - `pie-assessment-submission-state-changed`
196
- - `pie-assessment-error`
197
-
198
- Ownership boundary: assessment-player owns assessment semantics only. Section
199
- and toolkit semantics remain in their own streams to avoid overlap. Bridge
200
- dedupe is a safety net, not the primary correctness mechanism.
201
-
202
- Toolkit tool/backend operational stream (when toolkit is mounted):
401
+ ## Debug logging
203
402
 
204
- - `pie-tool-init-start|success|error`
205
- - `pie-tool-backend-call-start|success|error`
206
- - `pie-tool-library-load-start|success|error`
403
+ `debug` sets `window.PIE_DEBUG`, the page-wide flag every PIE logger reads.
404
+ `debug="true"` enables it; `"false"`, `"0"` and an empty value disable it, so a
405
+ bare `debug` attribute turns logging off. The value is also set as each section
406
+ player's `debug` attribute when the section mounts.
407
+
408
+ ## Instrumentation
409
+
410
+ The element forwards its DOM events to an `InstrumentationProvider` under the
411
+ same names with a `pie-` prefix (`assessment-route-changed` becomes
412
+ `pie-assessment-route-changed`), with `instrumentationLayer: "assessment"`,
413
+ `assessmentId` and `attemptId` attached. The provider is
414
+ `sectionPlayerRuntime.player.loaderConfig.instrumentationProvider`, the one the
415
+ section and item players use, and the bridge re-attaches when
416
+ `sectionPlayerRuntime` changes.
417
+ [Instrumentation providers](../../docs/architecture/instrumentation-providers.md#provider-resolution)
418
+ sets out how an unset, `null` or invalid provider resolves.
419
+ `CompositeInstrumentationProvider` combines providers, such as a production one
420
+ and `DebugPanelInstrumentationProvider`.
421
+
422
+ Section and toolkit events keep their own streams. The toolkit's operational
423
+ events, forwarded when the toolkit is mounted, are listed in
424
+ [Instrumentation providers](../../docs/architecture/instrumentation-providers.md#operational-events).
207
425
 
208
426
  ## Preloaded elements
209
427
 
@@ -233,47 +451,56 @@ registerPreloadedElements(
233
451
  );
234
452
  ```
235
453
 
236
- - A tag missing at pre-warm leaves the section's items unmounted and raises a
237
- non-recoverable `element-preload` framework error.
238
- - Install element packages with `npm install --save-exact`. npm otherwise saves
239
- a caret range, which registration rejects as a `version`, and which a fresh
240
- install can resolve to another release line: `^13.4.0-next.15` resolves to
241
- the legacy `13.4.4`, which has no `./browser/*` modules.
242
- - Install every pie-elements-ng package from one release, in one install from
243
- the same dist-tag, and upgrade them together. Elements whose `./browser/*`
244
- builds typeset on `window.MathJax` share the MathJax the first of them loads,
245
- in the build and configuration of that element's release, so in a mixed set
246
- an element can typeset with a MathJax it was not built for. Elements that
247
- bundle their own MathJax share none
248
- ([One MathJax version per page](../../docs/item-player/loading-strategies.md#one-mathjax-version-per-page)).
249
- - Pass `math.assetRoot`, an npm root serving the fonts and speech the elements'
250
- bundled MathJax loads, or `math.assetUrls`, each file's URL; without either,
251
- elements on adapter 0.1.3 or later render without web fonts and speech
252
- ([MathJax assets](../../docs/item-player/loading-strategies.md#mathjax-assets)).
253
- - Register one version per package; registering a second version throws.
254
- - Register each package's `controller` unless the item players are hosted
255
- (`sectionPlayerRuntime.player.hosted`, or an enabled
256
- `sectionPlayerRuntime.player.backend.delivery`). A player that is not hosted
257
- runs `model()` in the browser and warns for each tag registered without one.
258
- - Only `@pie-element/*` builds from pie-elements-ng publish
259
- `./browser/delivery` and `./browser/controller`.
260
- - Under TypeScript, the `package.json` import needs `resolveJsonModule`, and a
261
- package version that ships no declarations for `./browser/*` needs a
262
- `declare module` shim for those subpaths.
263
-
264
- See [`strategy="preloaded"`](../../docs/item-player/loading-strategies.md#strategypreloaded)
265
- for the registration contract.
454
+ A tag missing at pre-warm leaves the section's items unmounted and raises a
455
+ non-recoverable `element-preload` framework error. Item players count as hosted
456
+ under `sectionPlayerRuntime.player.hosted` or an enabled
457
+ `sectionPlayerRuntime.player.backend.delivery`, and then need no `controller`.
458
+ Version pins, element sets, MathJax assets and TypeScript setup are in
459
+ [Registering elements from npm](../../docs/item-player/loading-strategies.md#registering-elements-from-npm).
460
+
461
+ ## Shell element
462
+
463
+ `pie-assessment-player-shell` is a layout scaffold for a host that supplies its
464
+ own chrome: a `navigation` slot above a default slot that fills the remaining
465
+ height. The scaffold renders in an open shadow root and the host's children stay
466
+ in light DOM, so document styles reach them. `show-navigation="false"` drops the
467
+ navigation slot; its children stay in place but unrendered.
468
+
469
+ ```html
470
+ <pie-assessment-player-shell>
471
+ <div slot="navigation">
472
+ <my-assessment-nav></my-assessment-nav>
473
+ </div>
474
+ <pie-section-player-splitpane></pie-section-player-splitpane>
475
+ <my-timer-widget></my-timer-widget>
476
+ </pie-assessment-player-shell>
477
+ ```
478
+
479
+ The shell has no controller. The host mounts the section player, wires
480
+ navigation and drives persistence, as
481
+ [Building a Multi-Section Player](../../docs/assessment-player/integration-guide.md)
482
+ does.
266
483
 
267
484
  ## Content trust boundary
268
485
 
269
- Assessment markup reaches the DOM via the underlying
270
- `<pie-item-player>` element, which now sanitizes item / passage markup by
271
- default through DOMPurify. See the
272
- [pie-item-player README](../item-player/README.md#content-trust-boundary)
273
- for the allow-list, opt-out mechanics (`trust-markup`), and the
274
- `sanitizeMarkup` property override. Assessment-player hosts forward these
275
- settings by setting `sectionPlayerRuntime.player.trustMarkup` /
276
- `sectionPlayerRuntime.player.sanitizeMarkup` on the assessment player; it passes
277
- that runtime to each section player, whose runtime flattens `runtime.player.*`
278
- fields onto the embedded `<pie-item-player>` (see the section-player README
279
- for the exact forwarding shape).
486
+ Item and passage markup reaches the DOM through `<pie-item-player>`, which
487
+ sanitizes it with DOMPurify by default. The
488
+ [item player README](../item-player/README.md#content-trust-boundary) covers the
489
+ allow-list, the `trust-markup` opt-out and the `sanitizeMarkup` override. Set
490
+ `sectionPlayerRuntime.player.trustMarkup` or
491
+ `sectionPlayerRuntime.player.sanitizeMarkup` here: the assessment player passes
492
+ that runtime to each section player, which forwards `runtime.player` fields to
493
+ its item players
494
+ ([section player trust boundary](../section-player/README.md#content-trust-boundary)).
495
+ The [security model](../../docs/security/readme.md) covers the trust boundary
496
+ across the players.
497
+
498
+ ## Exports
499
+
500
+ | Entry | Contents |
501
+ | --- | --- |
502
+ | `@pie-players/pie-assessment-player` | Registers both elements. Exports `ASSESSMENT_PLAYER_PUBLIC_EVENTS` and the types of the inputs, hooks, host contract, controller handle, controller events, event details, delivery plan and persistence strategy |
503
+ | `@pie-players/pie-assessment-player/components/assessment-player-default-element` | An alias of the root entry |
504
+
505
+ The `AssessmentController` class is internal: hosts reach a controller through
506
+ the element.