@pie-players/pie-section-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.
Files changed (59) hide show
  1. package/README.md +845 -964
  2. package/custom-elements.json +3922 -0
  3. package/dist/browser/assets/{evaluation-worker-CORp9m0K.js → evaluation-worker-0xRDVTJx.js} +82215 -73234
  4. package/dist/browser/chunks/ToolCoordinator-0a6c4088.js +1 -0
  5. package/dist/browser/chunks/attribute-coercion-b93b482c.js +1 -0
  6. package/dist/browser/chunks/calculator-element-271c142f.js +8 -9
  7. package/dist/browser/chunks/catalog-context-46d3e50e.js +1 -1
  8. package/dist/browser/chunks/classPrivateFieldGet2-338e0179.js +1 -1
  9. package/dist/browser/chunks/classPrivateMethodInitSpec-563cd0a5.js +1 -0
  10. package/dist/browser/chunks/custom-element-define-2a92deb0.js +1 -0
  11. package/dist/browser/chunks/dist-41b39e7f.js +1 -1
  12. package/dist/browser/chunks/dist-ca9b9622.js +1 -1
  13. package/dist/browser/chunks/focus-trap-44913bf3.js +1 -1
  14. package/dist/browser/chunks/math-rendering-ready-9a0fd90a.js +1 -1
  15. package/dist/browser/chunks/mathjax-BKrHE4xs-3ca05bf6.js +1 -1
  16. package/dist/browser/chunks/nl-NL-364d61b9.js +1 -1
  17. package/dist/browser/chunks/nl-NL-8b7e6d4b.js +1 -1
  18. package/dist/browser/chunks/pie-api-client-687c319a.js +1 -1
  19. package/dist/browser/chunks/provider-4c704118.js +1 -1
  20. package/dist/browser/chunks/runtime-YFqnU6ic-d5c0cb49.js +8605 -0
  21. package/dist/browser/chunks/{src-BcptuNS5-ed25d7fd.js → src-D_1goyPT-8cca0246.js} +81 -82
  22. package/dist/browser/chunks/{sre-engine-jd8cc08x-42d270e5.js → sre-engine-bj997w4b-891a4cbe.js} +1 -1
  23. package/dist/browser/chunks/term-lookup-632cf6b1.js +1 -0
  24. package/dist/browser/chunks/tool-annotation-toolbar-997438b4.js +5 -5
  25. package/dist/browser/chunks/tool-answer-eliminator-35483abd.js +6 -6
  26. package/dist/browser/chunks/tool-color-scheme-b7de5cdf.js +5 -5
  27. package/dist/browser/chunks/tool-dictionary-cecff3b4.js +3 -3
  28. package/dist/browser/chunks/tool-graph-cb0338e8.js +6 -6
  29. package/dist/browser/chunks/tool-host-contract-fae1c415.js +1 -0
  30. package/dist/browser/chunks/tool-instance-id-76ac45e2.js +1 -0
  31. package/dist/browser/chunks/tool-line-reader-f8ef3c6a.js +3 -3
  32. package/dist/browser/chunks/tool-periodic-table-43b133b8.js +13 -16
  33. package/dist/browser/chunks/tool-picture-dictionary-33721e91.js +3 -3
  34. package/dist/browser/chunks/tool-protractor-23f77028.js +6 -6
  35. package/dist/browser/chunks/tool-ruler-1d9a03a6.js +12 -9
  36. package/dist/browser/chunks/tool-tts-inline-98c2ce7a.js +24 -30
  37. package/dist/browser/chunks/toolbar-registration-helpers-a7ab1ad4.js +194 -0
  38. package/dist/browser/chunks/versioned-tag-54a76be9.js +1 -1
  39. package/dist/browser/pie-section-player.js +66 -65
  40. package/dist/chunks/nl-NL-364d61b9.js +34 -6
  41. package/dist/chunks/{sre-engine-jd8cc08x-42d270e5.js → sre-engine-bj997w4b-891a4cbe.js} +1 -1
  42. package/dist/component-definitions.d.ts +0 -2
  43. package/dist/components/shared/card-media-region.d.ts +3 -4
  44. package/dist/components/shared/framework-error-latch.d.ts +4 -2
  45. package/dist/components/shared/missing-tool-providers.d.ts +0 -6
  46. package/dist/components/shared/player-preload.d.ts +4 -9
  47. package/dist/components/shared/section-player-card-context.d.ts +0 -3
  48. package/dist/components/shared/section-player-host-runtime.d.ts +0 -2
  49. package/dist/components/shared/section-player-view-state.d.ts +0 -1
  50. package/dist/controllers/SectionController.d.ts +20 -6
  51. package/dist/pie-section-player.js +11279 -11191
  52. package/package.json +11 -9
  53. package/dist/browser/chunks/ToolCoordinator-cc2dc319.js +0 -1
  54. package/dist/browser/chunks/runtime-wcU7KOFU-ac86d7b8.js +0 -8536
  55. package/dist/browser/chunks/tool-host-contract-b93b482c.js +0 -1
  56. package/dist/browser/chunks/tool-instance-id-2a92deb0.js +0 -1
  57. package/dist/browser/chunks/tts-runtime-config-a7ab1ad4.js +0 -194
  58. /package/dist/browser/chunks/{ItemToolBar-jx38w99s-58defccc.js → ItemToolBar-66hcwmfw-3bcb82a3.js} +0 -0
  59. /package/dist/chunks/{ItemToolBar-jx38w99s-1287ba31.js → ItemToolBar-66hcwmfw-1287ba31.js} +0 -0
package/README.md CHANGED
@@ -1,14 +1,14 @@
1
1
  # @pie-players/pie-section-player
2
2
 
3
- Section rendering package with layout custom elements:
4
-
5
- - `pie-section-player-splitpane`
6
- - `pie-section-player-vertical`
7
- - `pie-section-player-tabbed`
8
-
9
- A host that arranges the section itself builds its layout from
10
- `pie-section-player-kernel-host` and the two panes; see
11
- [Custom layout authoring](#custom-layout-authoring).
3
+ The section player renders one assessment section, its items and passages, as a
4
+ custom element. This README is the API reference for hosts that embed it: the
5
+ elements, their inputs, host methods and events, and the package exports. The
6
+ [section player integration guide](../../docs/section-player/integration-guide.md)
7
+ walks through an integration end to end,
8
+ [Custom Section Layouts](../../docs/section-player/custom-layouts.md) covers
9
+ host-built layouts, [Formative Delivery](../../docs/section-player/formative-delivery.md)
10
+ covers practice sections, and the package's
11
+ [architecture notes](./ARCHITECTURE.md) cover its internals.
12
12
 
13
13
  ## Install
14
14
 
@@ -16,71 +16,331 @@ A host that arranges the section itself builds its layout from
16
16
  npm install @pie-players/pie-section-player
17
17
  ```
18
18
 
19
+ ```ts
20
+ import "@pie-players/pie-section-player";
21
+ ```
22
+
23
+ Importing the package root registers every section player element. The root
24
+ entry is bundler-only: it imports `@pie-players/pie-item-player`,
25
+ `@pie-players/pie-default-tool-loaders` and `speech-rule-engine`, with the
26
+ engine's JSON locale tables, by bare specifier and without import attributes.
27
+ Items render through the host's one `@pie-players/pie-item-player`, which this
28
+ package depends on at its own version. A host without a bundler loads the
29
+ self-contained `./browser` build
30
+ ([Loading from a CDN](../../docs/install/cdn.md#section-player-browser-build)).
31
+
32
+ The package is browser-only. `@pie-players/pie-section-player/item-section` is
33
+ its one Node-safe entry; the other Node-safe packages are listed in the
34
+ [library packaging strategy](../../docs/setup/library-packaging-strategy.md).
35
+
19
36
  No stylesheet import is needed. Authored content depends on shared classes
20
37
  (passage markup, the legacy `kds-*` families, answer-eliminator styles) from
21
- `@pie-players/pie-theme/components.css`, and this player renders items through
22
- `@pie-players/pie-item-player`, which installs that stylesheet itself. See
23
- [content styles](../item-player/README.md#content-styles) for the host-ownership
24
- opt-out.
38
+ `@pie-players/pie-theme/components.css`, and the item player installs that
39
+ stylesheet itself. [Content styles](../item-player/README.md#content-styles)
40
+ describes the host-ownership opt-out.
41
+
42
+ ## Elements
43
+
44
+ | Element | Role |
45
+ | --- | --- |
46
+ | `pie-section-player-splitpane` | Passages and items side by side, with a resizable divider. Below the narrow breakpoint it collapses to tabs or a vertical stack |
47
+ | `pie-section-player-vertical` | The passages above the items |
48
+ | `pie-section-player-tabbed` | A Passage tab and a Questions tab when the section has passages; the items alone otherwise |
49
+ | `pie-section-player-kernel-host` | A section player without a layout of its own; its children are the layout ([Custom layout authoring](#custom-layout-authoring)) |
50
+ | `pie-section-player-items-pane`, `pie-section-player-passages-pane` | The panes a custom layout places inside the kernel host |
51
+ | `pie-section-player-item-card`, `pie-section-player-passage-card` | The cards the panes render, which hosts style ([Styling](#styling)) |
25
52
 
26
- ## Runtime boundary and migration
53
+ The layouts also render `pie-section-player-base`, `pie-section-player-shell`
54
+ and `pie-passage-shell`. Those are internal and not part of the API.
27
55
 
28
- - Browser-only package: `@pie-players/pie-section-player` registers custom elements and
29
- is intended for browser/DOM hosts, not plain Node runtime imports.
30
- - Node-import-safe packages (for server/runtime utilities) are documented in
31
- `docs/setup/library-packaging-strategy.md`.
32
- - Migration direction: prefer the stable default entry for side-effect registration:
56
+ ## Usage
57
+
58
+ ```html
59
+ <pie-section-player-splitpane section-id="practice-set" attempt-id="attempt-1"></pie-section-player-splitpane>
60
+ ```
33
61
 
34
62
  ```ts
35
63
  import "@pie-players/pie-section-player";
64
+ import type {
65
+ SectionPlayerRuntimeConfig,
66
+ SectionPlayerRuntimeHostContract,
67
+ } from "@pie-players/pie-section-player";
68
+ import type { AssessmentSection } from "@pie-players/pie-players-shared/types";
69
+
70
+ declare const section: AssessmentSection;
71
+
72
+ const runtime: SectionPlayerRuntimeConfig = {
73
+ assessmentId: "practice-1",
74
+ playerType: "iife",
75
+ env: { mode: "gather", role: "student" },
76
+ };
77
+
78
+ const player = document.querySelector<HTMLElement & SectionPlayerRuntimeHostContract>(
79
+ "pie-section-player-splitpane",
80
+ );
81
+ if (player) {
82
+ player.runtime = runtime;
83
+ player.section = section;
84
+ }
36
85
  ```
37
86
 
38
- The entrypoints under `@pie-players/pie-section-player/components/*` load one
39
- element each, together with every element it renders: a layout entry also
40
- registers the cards, panes and shell. They choose which layouts a host loads; the
41
- tag names stay fixed.
87
+ Strings and numbers are attributes; objects (`runtime`, `section`, `session` and
88
+ the rest of [Inputs](#inputs)) are JavaScript properties. `env` is a `runtime`
89
+ field: the layouts have no `env` property.
42
90
 
43
- The entries are bundler-only: they import `@pie-players/pie-item-player`,
44
- `@pie-players/pie-default-tool-loaders` and `speech-rule-engine`, with the
45
- engine's JSON locale tables, by bare specifier and without import attributes.
46
- Items render through the host's one `@pie-players/pie-item-player`, which this
47
- package depends on at its own version.
91
+ Set `runtime` no later than `section`. When the player builds its own
92
+ coordinator, the section's arrival rebuilds that coordinator from the current
93
+ `runtime`, so both can be set a tick after the element mounts. Once the section
94
+ has initialized, a change to `runtime.tools`, `runtime.assessmentId`,
95
+ `runtime.accessibility`, `runtime.lazyInit`, `tool-config-strictness` or
96
+ `toolRegistry` is reported once in the console and does not reach that
97
+ coordinator; `runtime.tools.pnpEnforcement` still applies. Change a running
98
+ coordinator through the one `toolkit-ready` carries, with
99
+ `updateToolConfig(...)` or `updateToolsPlacement(...)`, or pass your own as
100
+ `runtime.coordinator`.
48
101
 
49
- ## SectionController
102
+ Attributes cover standard delivery. Host policy runs in host code over the
103
+ JavaScript surface: the [host methods](#host-methods), the
104
+ [section controller](#section-controller) and its [events](#events). The player
105
+ has no gating API of its own; a host composes forward and backward eligibility
106
+ from `getSnapshot().navigation` and controller events. Updates that leave the
107
+ composition unchanged (responses, tool toggles, runtime changes that keep the
108
+ same items) keep every item and passage mounted and keep each pane's scroll
109
+ position ([flow invariants](./ARCHITECTURE.md#unidirectional-flow-invariants)).
110
+
111
+ The `single-question` and `session-hydrate-db` routes of `apps/section-demos`
112
+ ([single question](../../apps/section-demos/src/routes/%28demos%29/single-question/+page.svelte),
113
+ [session hydration](../../apps/section-demos/src/routes/%28demos%29/session-hydrate-db/+page.svelte))
114
+ are complete host integrations.
115
+
116
+ ## Inputs
117
+
118
+ The three layouts take these inputs. `pie-section-player-kernel-host` takes all
119
+ of them except the [layout dimensions](#layout-dimensions). Numeric attributes
120
+ are clamped to their range.
121
+
122
+ | Input | Form | Default | Effect |
123
+ | --- | --- | --- | --- |
124
+ | `runtime` | property | — | Player strategy, tools, accessibility, coordinator, env and callbacks ([Runtime configuration](#runtime-configuration)) |
125
+ | `section` | property | — | The `AssessmentSection` to deliver |
126
+ | `session` | property | — | The section's session, a `SectionControllerSessionState` ([Session lifecycle](#session-lifecycle)) |
127
+ | `assessment` | property | — | The `AssessmentEntity` whose `personalNeedsProfile` and `settings` tool policy reads, forwarded to a coordinator the player builds. A coordinator passed in `runtime` is the host's to bind with `updateAssessment`. A section's own `personalNeedsProfile` is not read, and the player warns once when it finds one |
128
+ | `policies` | property | `DEFAULT_SECTION_PLAYER_POLICIES` | A partial `SectionPlayerPolicies` for readiness, element pre-warm and telemetry ([Policies](#policies)) |
129
+ | `hooks` | property | — | Host callbacks, a `SectionPlayerHostHooks` ([Card titles](#card-titles)) |
130
+ | `toolRegistry` | property | `createPackagedToolRegistry()` | The registry the toolbars load tools from ([Tools](#tools)) |
131
+ | `sectionHostButtons`, `itemHostButtons`, `passageHostButtons` | property | — | Host buttons appended to the section, item and passage toolbars |
132
+ | `section-id` | attribute | `section.identifier`, then `section-<assessmentId>` | The section's id for its controller and persistence |
133
+ | `attempt-id` | attribute | — | The attempt the session belongs to. Without one the default persistence neither reads nor writes |
134
+ | `locale` | attribute | `en-US` | BCP-47 locale of the player's own interface text. It never sets `runtime.contentLanguage` |
135
+ | `base-heading-level` | attribute | `2` (1–6) | The level of the card headings, from which every descendant's outline derives ([Heading structure](#heading-structure)) |
136
+ | `show-toolbar` | attribute | `false` | Renders the section toolbar, which section-level tools need. Accepts `true`/`false`, `1`/`0` and `yes`/`no` |
137
+ | `toolbar-position` | attribute | `right` | `top`, `right`, `bottom`, `left` or `none` |
138
+ | `tool-config-strictness` | attribute | `error` | `off`, `warn` or `error` for tool configuration validation |
139
+ | `nds-icons` | attribute | — | Renders toolbar buttons as NDS icon buttons (below) |
140
+ | `iife-bundle-host` | attribute | — | The bundle host for the IIFE element pre-warm when `runtime.player.loaderOptions.bundleHost` is unset |
141
+ | `debug` | attribute | — | Debug logging (`true` enables, `false` or `0` disables; [Debug logging](#debug-logging)) |
142
+ | `narrow-layout-breakpoint` | attribute | `1100` (400–2000) | The viewport width in px at or below which the layout narrows |
143
+ | `content-max-width-no-passage` | attribute | unset (320–2200) | Maximum content width in px for a section without passages |
144
+ | `content-max-width-with-passage` | attribute | unset (320–2200) | Maximum content width in px for a section with passages |
145
+ | `split-pane-initial-passage-width` | attribute | `50` (20–80) | Splitpane only: the passage pane's width in percent at mount |
146
+ | `split-pane-min-region-width` | attribute | unset (160–1200) | Splitpane only: the minimum pane width in px. Unset, the divider stays between 20% and 80% |
147
+ | `split-pane-collapse-strategy` | attribute | `tabbed` | Splitpane only: `tabbed` or `vertical` below the breakpoint |
148
+
149
+ `nds-icons` opts in to the Renaissance Next Design System (NDS) icon buttons,
150
+ which render in Font Awesome and Roboto. The players add Font Awesome Free from
151
+ jsDelivr unless the page links a Font Awesome stylesheet, and the buttons add
152
+ Roboto from `ui.renaissance.com` unless the page links a stylesheet whose URL
153
+ contains `Roboto`. That CDN serves the font files to Renaissance origins only,
154
+ so a page elsewhere links its own Roboto. A page that links Font Awesome Pro
155
+ keeps the design's Light and Regular weights; without Pro every glyph renders in
156
+ Solid.
157
+
158
+ ### Layout dimensions
159
+
160
+ At or below `narrow-layout-breakpoint` the splitpane renders its
161
+ `split-pane-collapse-strategy` view, and every layout moves the section toolbar
162
+ to `top`, whatever `toolbar-position` says, `none` included. The shell
163
+ separately moves a `left` or `right` toolbar to `top` at a fixed 1100px, so with
164
+ a smaller breakpoint side toolbars still move to the top from 1100px down.
165
+
166
+ The vertical and tabbed layouts take the two max-width attributes too. When
167
+ both are set, the with-passage cap resolves to the greater of the two after
168
+ clamping, so a section with passages is never narrower than one without.
50
169
 
51
- `SectionController` is the domain authority inside a section player. It owns
52
- in-section navigation state, the canonical aggregation of per-item sessions,
53
- and the host-facing persistence snapshot. The layout custom elements
54
- (`pie-section-player-splitpane` / `-vertical` / `-tabbed`) are transport
55
- adapters around it. See
56
- [`docs/section-player/controller-boundaries.md`](../../docs/section-player/controller-boundaries.md)
57
- for the rationale behind that split, and
58
- [`docs/section-player/client-architecture-tutorial.md`](../../docs/section-player/client-architecture-tutorial.md)
59
- for the end-to-end walkthrough.
170
+ ```html
171
+ <pie-section-player-splitpane
172
+ content-max-width-no-passage="800"
173
+ content-max-width-with-passage="1200"
174
+ split-pane-min-region-width="280"
175
+ split-pane-collapse-strategy="vertical"
176
+ ></pie-section-player-splitpane>
177
+ ```
60
178
 
61
- The handle implements `SectionControllerHandle` from
62
- `@pie-players/pie-assessment-toolkit`; see the JSDoc on that interface for
63
- the per-method contract.
179
+ ### Runtime configuration
180
+
181
+ `runtime` is a `SectionPlayerRuntimeConfig`. `locale`, `nds-icons` and
182
+ `tool-config-strictness` are attributes only.
183
+
184
+ | Key | Default | Effect |
185
+ | --- | --- | --- |
186
+ | `assessmentId` | — | The assessment the section belongs to; part of every session key |
187
+ | `playerType` | `"iife"` | The item player strategy: `"iife"`, `"esm"` or `"preloaded"` ([Preloaded elements](#preloaded-elements)) |
188
+ | `player` | — | Item player settings: `hosted`, `loaderOptions`, `loaderConfig`, `backend` and `resolveBackend` ([Backend delivery](#backend-delivery-for-embedded-items)). Every other field is passed to each embedded item player as a property |
189
+ | `env` | `{ mode: "gather", role: "student" }` | The section env every item renders in |
190
+ | `contentLanguage` | `en-US` | BCP-47 language of content whose markup names none; read-aloud speaks in it and catalog cards are picked by it. A `lang` between the content and its card wins |
191
+ | `tools` | — | Tool placement, providers and policy ([Tools](#tools)) |
192
+ | `toolContextResolvers` | — | Per-tool resolvers that run after the policy gates: they can hide a tool that survived them or attach render parameters, and cannot re-enable a removed one |
193
+ | `accessibility` | — | `{ catalogs, language }`: accessibility catalogs and their language |
194
+ | `lazyInit` | `false` | Starts text to speech at first use. `waitUntilReady()` then settles without it unless policy grants the tool |
195
+ | `coordinator` | — | A `ToolkitCoordinator` the host built. Unset, the player builds one |
196
+ | `isolation` | `"inherit"` | Without `coordinator`, `"inherit"` shares an enclosing toolkit's coordinator when there is one; `"force"` keeps the player's own |
197
+ | `createSectionController` | — | A factory for the section controller ([Custom section controllers](#custom-section-controllers)) |
198
+ | `onStageChange`, `onLoadingComplete`, `onFrameworkError` | — | Callbacks with each `pie-stage-change` detail, the `pie-loading-complete` detail and each framework error model ([Instrumentation](#instrumentation)) |
199
+
200
+ ### Tools
201
+
202
+ Tool placement is empty by default. `runtime.tools.placement` places tools by
203
+ scope (`section`, `item`, `passage`), and
204
+ `SECTION_PLAYER_PREFERRED_TOOL_PLACEMENT` from
205
+ `@pie-players/pie-default-tool-loaders` is the packaged opt-in. Section-level
206
+ tools render only when `show-toolbar` is set. The
207
+ [canonical tool ids](../../docs/tools-and-accomodations/tool_provider_system.md#canonical-tool-ids)
208
+ are listed with the tool provider system. Tool configuration is validated when
209
+ the toolkit initializes, toolbar overlays included, at the
210
+ `tool-config-strictness` level. The text-to-speech provider is configured under
211
+ `tools.providers.textToSpeech`; validation rejects `tools.providers.tts`.
64
212
 
65
- ### Obtaining the handle
213
+ ```ts
214
+ import {
215
+ SECTION_PLAYER_PREFERRED_TOOL_PLACEMENT,
216
+ createPackagedToolRegistry,
217
+ } from "@pie-players/pie-default-tool-loaders";
218
+ import type { SectionPlayerRuntimeHostContract } from "@pie-players/pie-section-player";
219
+
220
+ const player = document.querySelector<HTMLElement & SectionPlayerRuntimeHostContract>(
221
+ "pie-section-player-splitpane",
222
+ );
223
+ if (player) {
224
+ player.toolRegistry = createPackagedToolRegistry();
225
+ player.runtime = {
226
+ assessmentId: "practice-1",
227
+ tools: { placement: SECTION_PLAYER_PREFERRED_TOOL_PLACEMENT },
228
+ };
229
+ }
230
+ ```
231
+
232
+ `toolRegistry` replaces the packaged registry. Build it with
233
+ `createPackagedToolRegistry()` and register custom tools on it, since the
234
+ toolbars load each tool's element through the registry's loaders. A coordinator
235
+ the player builds takes this registry. A coordinator passed as
236
+ `runtime.coordinator` keeps the registry it was constructed with, which decides
237
+ policy; constructed without one, it takes the player's.
238
+
239
+ ### Policies
240
+
241
+ `policies` is a partial `SectionPlayerPolicies`. Every unset field takes its
242
+ value from `DEFAULT_SECTION_PLAYER_POLICIES`, exported from the package root and
243
+ from `@pie-players/pie-section-player/policies`.
244
+
245
+ | Field | Default | Effect |
246
+ | --- | --- | --- |
247
+ | `readiness.mode` | `"progressive"` | `"progressive"` or `"strict"`. Both hold the `interactive` stage and `pie-loading-complete` until the element pre-warm resolves, and the two currently emit the same sequence |
248
+ | `preload.enabled` | `true` | `false` skips the section's element pre-warm, the step that loads every element the items name before they mount. Items still mount, and each item player registers its own elements. For a host that owns element registration end to end |
249
+ | `telemetry.enabled` | `true` | `false` stops the `pie-section-*` instrumentation stream ([Instrumentation](#instrumentation)) |
250
+
251
+ `resolveSectionPlayerPolicies(policies)` from
252
+ `@pie-players/pie-section-player/policies` returns the filled-in object, for
253
+ host code that applies the same gates.
254
+
255
+ ### Card titles
256
+
257
+ `hooks.cardTitleFormatter` sets the heading of each item and passage card:
66
258
 
67
259
  ```ts
68
- const host = document.querySelector("pie-section-player-splitpane") as any;
69
- const controller = await host.waitForSectionController(5000);
260
+ import type { SectionPlayerHostHooks } from "@pie-players/pie-section-player";
261
+
262
+ const hooks: SectionPlayerHostHooks = {
263
+ cardTitleFormatter: (context) =>
264
+ context.kind === "item"
265
+ ? `Question ${context.itemIndex + 1}: ${context.item.name || context.defaultTitle}`
266
+ : context.passage.name || context.defaultTitle,
267
+ };
70
268
  ```
71
269
 
72
- `waitForSectionController(timeoutMs)` resolves when the layout CE has wired
73
- its controller (the same anchor `pie-stage-change` reaches with
74
- `detail.stage === "engine-ready"`), or with `null` once `timeoutMs` passes.
75
- Use `getSectionController()` if you've already passed the readiness anchor
76
- synchronously.
270
+ An item context carries `item`, `itemIndex`, `itemCount`, `canonicalItemId` and
271
+ `defaultTitle`, the localized "Question N" ("Question" for a one-item section);
272
+ a passage context carries `passage` and `defaultTitle`. A formatter that returns
273
+ anything but a non-empty string, or throws, gets `defaultTitle`. The formatter
274
+ stays active across the splitpane's narrow and wide transitions.
275
+
276
+ ### Debug logging
277
+
278
+ Debug logging is page-wide. A layout's `debug` attribute writes
279
+ `window.PIE_DEBUG`, the flag every PIE logger on the page reads, so the last
280
+ host to set it decides for all of them. Without a `debug` attribute a layout
281
+ follows `window.PIE_DEBUG`, which a host can set directly.
77
282
 
78
- The layout elements define their host methods from the moment they are
79
- created, so a host can call them before the element mounts. Until it mounts,
283
+ ## Host methods
284
+
285
+ The layouts and the kernel host implement `SectionPlayerRuntimeHostContract`:
286
+
287
+ | Method | Returns | Effect |
288
+ | --- | --- | --- |
289
+ | `getSnapshot()` | `SectionPlayerSnapshot` | `{ composition: { itemsCount, passagesCount }, navigation: { currentIndex, totalItems, canNext, canPrevious, currentItemId } }` |
290
+ | `navigateTo(index)` | `boolean` | Moves to the item at a zero-based index; `false` when it cannot |
291
+ | `navigateNext()`, `navigatePrevious()` | `boolean` | Moves one item; `false` when it cannot |
292
+ | `getSectionController()` | `SectionControllerHandle \| null` | The current section's controller |
293
+ | `waitForSectionController(timeoutMs = 5000)` | `Promise<SectionControllerHandle \| null>` | Resolves at once when the controller is resolvable, else at the next `toolkit-ready`, else at the timeout with the current lookup, which may be `null` |
294
+
295
+ The methods exist from the moment the element is created. Until it mounts,
80
296
  `waitForSectionController` waits, `getSectionController()` returns `null`, the
81
- navigation methods return `false`, and `getSnapshot()` and the `select*` reads
82
- return `null` (`pie-section-player-kernel-host` returns its bootstrapping
83
- snapshot).
297
+ navigation methods return `false`, and `getSnapshot()` returns `null` on the
298
+ stock layouts and the bootstrap snapshot (zero counts, no navigation) on the
299
+ kernel host.
300
+
301
+ ```ts
302
+ import type { SectionPlayerRuntimeHostContract } from "@pie-players/pie-section-player";
303
+
304
+ const player = document.querySelector<HTMLElement & SectionPlayerRuntimeHostContract>(
305
+ "pie-section-player-splitpane",
306
+ );
307
+ const controller = await player?.waitForSectionController(5000);
308
+
309
+ let sectionComplete = false;
310
+ const unsubscribe = controller?.subscribe?.((event) => {
311
+ if (event.type === "section-items-complete-changed") {
312
+ sectionComplete = event.complete;
313
+ }
314
+ });
315
+
316
+ function canAdvance(): boolean {
317
+ return Boolean(player?.getSnapshot()?.navigation.canNext && sectionComplete);
318
+ }
319
+ ```
320
+
321
+ ## Section controller
322
+
323
+ The section controller is the section's domain authority: it owns in-section
324
+ navigation, the canonical aggregation of item sessions and the persistence
325
+ snapshot. The layouts are transport adapters around it
326
+ ([controller ownership](./ARCHITECTURE.md#controller-ownership)). Its handle,
327
+ `SectionControllerHandle` from `@pie-players/pie-assessment-toolkit`, declares
328
+ every member optional, and the interface's JSDoc carries the per-method
329
+ contract.
330
+
331
+ | Member | Effect |
332
+ | --- | --- |
333
+ | `getSession()` | The section session, a `SectionControllerSessionState`: `currentItemIndex`, `visitedItemIdentifiers`, `itemSessions`, and `formative` and `timedMedia` when the section uses them |
334
+ | `applySession(session, { mode })` | Applies a section session. `"replace"` (the default) replaces it; `"merge"` overlays it item by item. Item entries may be canonical entries or raw item sessions |
335
+ | `updateItemSession(itemId, detail)` | Writes one item's session synchronously and emits `item-session-data-changed` or `item-session-meta-changed` |
336
+ | `hydrate()` | Loads the session from the persistence strategy and applies it in replace mode; a no-op without a strategy or a stored session |
337
+ | `persist()` | Saves the session through the strategy, at the host's cadence; a no-op without one |
338
+ | `configureSessionPersistence({ context, strategy })` | Sets the persistence strategy |
339
+ | `subscribe(listener)` | Delivers every controller event to `listener` until the returned function is called or the controller is disposed |
340
+ | `getRuntimeState()` | Introspection only; persist `getSession()` |
341
+ | `updateInput(input)` | Refreshes the composition for the same cohort, the `(sectionId, attemptId)` pair, and keeps the in-memory session |
342
+ | Formative members | `recordFormativeTry`, `retryFormativeItem`, `revealFormativeItem`, `hideFormativeItem`, `getFormativeProjection` ([Formative delivery](#formative-delivery)) |
343
+ | Timed-media members | `attachMediaTimeSource`, `detachMediaTimeSource`, `pauseMediaForCompetingAudio`, `getTimedMediaProjection` ([Timed media](#timed-media)) |
84
344
 
85
345
  ### Session lifecycle
86
346
 
@@ -105,129 +365,232 @@ Driving the controller directly:
105
365
  controller?.configureSessionPersistence?.({ context, strategy });
106
366
  await controller?.hydrate?.();
107
367
  const unsubscribe = controller?.subscribe?.(handleEvent);
108
- // ...later, on save / unload:
368
+ // Later, on save or unload:
109
369
  await controller?.persist?.();
110
370
  unsubscribe?.();
111
371
  ```
112
372
 
113
- `getSession()` / `applySession(session, { mode })` / `updateItemSession(itemId,
114
- detail)` are the direct read/write surfaces and exchange the same
115
- `SectionControllerSessionState` shape the persistence strategy load/save
116
- methods receive. See [Item session management](#item-session-management) for
117
- worked examples.
118
-
119
- ### Event stream
120
-
121
- The controller's typed event stream (`SectionControllerEvent` discriminated
122
- union) is the single source of truth for in-section change. Hosts usually
123
- subscribe through `ToolkitCoordinator.subscribeItemEvents` /
124
- `subscribeSectionLifecycleEvents` (cohort-aware filtering, survives
125
- navigation) — see [JS API example for advanced host
126
- policy](#js-api-example-for-advanced-host-policy). Key event types:
127
-
128
- - `item-selected` — item navigation within the current section.
129
- - `item-session-data-changed` / `item-session-meta-changed` — per-item
130
- session updates the persistence layer should observe. `sectionId` names the
131
- section. `elementId` names the reporting element when the change carries one,
132
- and `complete` is then that element's own. A commit carries
133
- `sessionCommitReason` (see [Commit at a section
134
- boundary](#commit-at-a-section-boundary)).
135
- - `item-complete-changed` — an item's completion flipped. An item is complete
136
- when every element that has reported its completion is complete. A report
137
- without `elementId`, and a restored session's item-level `complete`, set the
138
- item's completion directly. A session restored through `applySession`
139
- without `complete` is complete when it holds a response. A passage's own
140
- `session-changed` stays inside its shell: a passage holds no response.
141
- - `content-loaded` — passage / item / rubric finished loading. Carries
142
- `contentKind`, `itemId`, and `canonicalItemId`.
143
- - `section-loading-complete` — every renderable in the section finished
144
- loading.
145
- - `section-items-complete-changed` — aggregate completion flip.
146
- - `section-error` — an item player failed (`source: "item-player"`), or the
147
- section runtime did (`source: "section-runtime"`), a rejected element warmup
148
- included, which leaves the items unmounted.
149
- - `section-navigation-change` — the controller's section identity changed.
150
- - `formative-try-recorded` — a learner checked an answer.
151
- - `formative-reveal-changed` — the reveal state changed without a Try: a learner
152
- dismissed feedback, or a host forced or withdrew a reveal (`source` says which).
153
- - `section-mastery-changed` — the mastery rollup changed. Emitted on change
154
- only, like `section-items-complete-changed`.
155
- - `timed-media-cue-changed` — a cue activated, a gate released, or aggregate
156
- completion flipped. Not emitted for media position: `timeupdate` fires about four
157
- times a second and moves nothing a layout renders.
158
- - `timed-media-audio-started` — media audio is running, so read-aloud must yield.
159
- Emitted only where playback actually stood; a gate that re-paused on the same
160
- `play` produced no audio.
161
- - `timed-media-policy-degraded` — the attached media time source cannot carry out a
162
- playback policy, so it is advisory from here.
163
- - `timed-media-invalid` — authored `timedMedia` that cannot be delivered; the
164
- section renders without cue behavior.
165
-
166
- The helpers' default event sets leave out `formative-try-recorded`,
167
- `formative-reveal-changed`, `section-mastery-changed` and
168
- `timed-media-audio-started`. Name them in `subscribeItemEvents({ eventTypes })`,
169
- or subscribe through `subscribeSectionEvents`.
373
+ ### Item session management
170
374
 
171
- Item-scoped events carry both id forms, and both are always populated.
172
- `itemId` is the bare `item.id` — the form the map returned by
173
- `getItemSessionsByItemId()` is keyed by, and the form `applySession` expects.
174
- `canonicalItemId` is that item's adapter identifier, and falls back to `itemId`
175
- when the section was not built from an adapter or no adapter ref matches it.
176
- Correlate with formative policy and `runtime.player.resolveBackend` by
177
- `canonicalItemId`; reach the session by `itemId`. `content-loaded` and
178
- `item-player-error` both carry the pair.
375
+ `getSession()`, `applySession()` and `updateItemSession()` exchange the same
376
+ `SectionControllerSessionState` the persistence strategy loads and saves and the
377
+ `session` property takes.
179
378
 
180
- ### Formative delivery
379
+ ```ts
380
+ import type { SectionPlayerRuntimeHostContract } from "@pie-players/pie-section-player";
181
381
 
182
- Set `formative` on the section and the player renders a check-answer control per
183
- item, records Tries, and reveals feedback:
382
+ const player = document.querySelector<HTMLElement & SectionPlayerRuntimeHostContract>(
383
+ "pie-section-player-splitpane",
384
+ );
385
+ const controller = await player?.waitForSectionController(5000);
184
386
 
185
- ```ts
186
- const section: AssessmentSection = {
187
- identifier: "practice-set",
188
- formative: { enabled: true, maxTries: 3, feedback: "correctness" },
189
- assessmentItemRefs: [
190
- { identifier: "q1", item },
191
- // Overrides the section default field by field.
192
- { identifier: "q2", item, formative: { maxTries: 1, feedback: "solution" } },
193
- { identifier: "q3", item, formative: { enabled: false } },
194
- ],
195
- };
387
+ // Read the current section session.
388
+ const currentSession = controller?.getSession?.();
389
+
390
+ // Replace the section session, as when resuming from a stored snapshot.
391
+ await controller?.applySession?.(
392
+ {
393
+ currentItemIndex: 0,
394
+ visitedItemIdentifiers: ["q1"],
395
+ itemSessions: {
396
+ q1: {
397
+ itemIdentifier: "q1",
398
+ pieSessionId: "q1-session",
399
+ session: { id: "q1-session", data: [{ id: "choice", value: "a" }] },
400
+ },
401
+ },
402
+ },
403
+ { mode: "replace" },
404
+ );
405
+
406
+ // Update one item's session.
407
+ controller?.updateItemSession?.("q1", {
408
+ session: { id: "q1-session", data: [{ id: "choice", value: "b" }] },
409
+ complete: true,
410
+ });
196
411
  ```
197
412
 
198
- Absent, or `enabled: false`, and delivery is unchanged: no control, no state, no
199
- `env` override, and `getSession()` does not carry the key.
413
+ ### Commit at a section boundary
414
+
415
+ A delivery element coalesces its `session-changed` dispatch, so a response the
416
+ learner has finished entering can still be pending when the section moves. The
417
+ controller commits pending element sessions before item navigation, before
418
+ `updateInput()` snapshots the session for a section swap, and before `persist()`.
419
+ The player also commits when an item shell tears down and when the page goes
420
+ hidden.
421
+
422
+ A committed response travels like any other. A raw element `session-changed`
423
+ does not leave its `<pie-item-scope>`, which re-dispatches it as the normalized
424
+ `item-session-changed` (`PIE_ITEM_SESSION_CHANGED_EVENT`); the toolkit then
425
+ publishes the section's canonical `session-changed`. Both bubble through the
426
+ layout element to `document`, and a listener on either receives each dispatch
427
+ once. Host code persists from the controller's events or from its session
428
+ snapshot.
429
+
430
+ Each of those events marks a commit with `sessionCommitReason`
431
+ (`"teardown" | "navigate" | "page-hidden"`): `item-session-changed`, the
432
+ toolkit's `session-changed`, and the controller's `item-session-data-changed`
433
+ and `item-session-meta-changed`. A commit at a section swap or item navigation
434
+ reports the item being left, which the host may already have moved past, so a
435
+ handler that sets its current item or navigation state from these events leaves
436
+ that state alone on a commit and persists the commit's session as usual. The
437
+ controller's events also carry `sectionId`, the section the item belongs to.
200
438
 
201
- PIE renders no feedback of its own. A revealed item gets `mode: "evaluate"`
202
- projected over the section env — with `role: "instructor"` under
203
- `feedback: "solution"` — and the element draws the rest. Only that item's env
204
- changes; its neighbours stay editable. A retry withdraws the projection.
439
+ Navigation inside a section keeps every item mounted, so nothing is discarded
440
+ and the element's own debounce would complete on its own. The commit still runs
441
+ there because the response belongs to the item being left: a host that persists
442
+ on the navigation event, or a `persist()` that follows it, would otherwise
443
+ snapshot a session the learner had already changed.
205
444
 
206
- Read the resolved state from `getFormativeProjection()`, or from
207
- `composition.formative` in a `composition-changed` event's detail. Drive it from a
208
- host through the same handle:
445
+ The section controller is DOM-free, so the player supplies the commit through
446
+ the handle's `setPendingSessionCommit()`. The player registers it on the
447
+ controllers it creates and on one the toolkit has already built. A host-built
448
+ controller that leaves the method unimplemented gets no commit, and a pending
449
+ response is lost at those boundaries.
450
+
451
+ ### Custom section controllers
452
+
453
+ `runtime.createSectionController` is a factory the player calls for each
454
+ section's controller. A coordinator's `hooks.createSectionController` takes
455
+ precedence over it; without either, the player constructs a `SectionController`.
456
+ The player builds its own coordinator without hooks, so a host that lets it do
457
+ so supplies a controller through `runtime.createSectionController`.
458
+
459
+ ## Events
460
+
461
+ ### DOM events
462
+
463
+ Every event bubbles and is composed, so it reaches `document`, and a listener on
464
+ the layout element receives each one. The layout element dispatches
465
+ `pie-stage-change`, `pie-loading-complete` and the `element-preload-*` events.
466
+ The toolkit dispatches the runtime, readiness, composition, `session-changed`
467
+ and `framework-error` events on the inner `pie-section-player-base`, and they
468
+ bubble out through the layout element. `item-session-changed` starts at the
469
+ item's `<pie-item-scope>`.
470
+
471
+ | Event | Detail | When |
472
+ | --- | --- | --- |
473
+ | `runtime-owned`, `runtime-inherited` | `runtimeId`, `parentRuntimeId` | The player's toolkit owns its coordinator, or shares an enclosing one |
474
+ | `runtime-ready` | `runtimeId`, `coordinator`, `ownership` | Once per coordinator, before the first section starts: the earliest point a host holds the coordinator |
475
+ | `toolkit-ready` | `runtimeId`, `assessmentId`, `sectionId`, `itemPlayer`, `coordinator` | For each section, a microtask after its controller resolves and its composition goes out |
476
+ | `section-ready` | `sectionId`, `attemptId`, `controller` (or `null`) | After that section's `toolkit-ready` and its `engine-ready` stage |
477
+ | `composition-changed` | `composition`, `version` | The composition changed; `composition.formative` carries the formative projection |
478
+ | `session-changed` | The normalized item session, plus `itemId`, `canonicalItemId` and `sourceRuntimeId` | An item's session changed |
479
+ | `item-session-changed` | The normalized item session | Re-dispatched by `<pie-item-scope>` from an element's `session-changed` |
480
+ | `pie-stage-change` | `stage`, `status`, `runtimeId`, `sectionId`, `attemptId`, `timestamp`, `sourceCe` | A lifecycle stage was entered: `composed`, `engine-ready`, `interactive`, `disposed` |
481
+ | `pie-loading-complete` | `runtimeId`, `sectionId`, `attemptId`, `itemCount`, `timestamp`, `sourceCe` | Once per cohort, after `section-ready` and the element pre-warm, before the items load |
482
+ | `framework-error` | A `FrameworkErrorModel` | Any failure crossing the framework boundary, once per error |
483
+ | `element-preload-retry`, `element-preload-error` | `assessmentId`, `sectionId`, `attemptId` | The element pre-warm retried or failed |
484
+
485
+ A first section starts in this order: `pie-stage-change` `composed`,
486
+ `runtime-ready`, `toolkit-ready`, `section-ready`, `pie-stage-change`
487
+ `engine-ready`, then `pie-stage-change` `interactive` and `pie-loading-complete`
488
+ once the element pre-warm resolves. The items load after that; the controller's
489
+ `section-loading-complete` marks every one loaded, and a session applied before
490
+ then is announced again as `section-session-applied` with `replay: true`.
491
+
492
+ A non-recoverable framework error before `interactive` ends the stage chain:
493
+ the first stage the section did not reach is emitted as `failed`, and any after
494
+ it as `skipped`. A pre-warm failure also arrives as an `element-preload`
495
+ framework error.
496
+
497
+ ### Controller events
498
+
499
+ `subscribe()` on the handle, and the coordinator's subscription helpers,
500
+ deliver `SectionControllerEvent`, a union discriminated by `type`. Every event
501
+ carries `timestamp`, and every one except `section-navigation-change` carries
502
+ `currentItemIndex`.
503
+
504
+ | Event | Payload | Emitted when |
505
+ | --- | --- | --- |
506
+ | `item-selected` | `previousItemId`, `currentItemId`, `itemIndex`, `totalItems` | Item navigation within the section |
507
+ | `item-session-data-changed` | `itemId`, `canonicalItemId`, `session`, `intent`, `complete`, `component`, `elementId`, `sectionId`, `sessionCommitReason` | An item's response changed |
508
+ | `item-session-meta-changed` | As above, without `session` and `intent` | An item's session metadata changed |
509
+ | `item-complete-changed` | `itemId`, `canonicalItemId`, `complete`, `previousComplete` | An item's completion flipped |
510
+ | `content-loaded` | `contentKind` (`item`, `passage`, `rubric`, `unknown`), `itemId`, `canonicalItemId`, `detail` | A passage, item or rubric finished loading |
511
+ | `item-player-error` | `contentKind`, `itemId`, `canonicalItemId`, `error` | An item player failed |
512
+ | `section-loading-complete` | `totalRegistered`, `totalLoaded` | Every renderable in the section finished loading |
513
+ | `section-items-complete-changed` | `complete`, `completedCount`, `totalItems` | The section's aggregate completion flipped |
514
+ | `section-session-applied` | `mode`, `itemSessionCount`, `replay` | A session was applied; `replay: true` after `section-loading-complete` |
515
+ | `section-navigation-change` | `previousSectionId`, `currentSectionId`, `attemptId`, `reason` (`input-change`, `runtime-transition`) | The controller's section identity changed |
516
+ | `section-error` | `source`, `error`, `itemId`, `canonicalItemId`, `contentKind` | An item player failed (`source: "item-player"`), or the section runtime did (`"section-runtime"`), a rejected element pre-warm included, which leaves the items unmounted |
517
+ | `formative-try-recorded` | `itemId`, `canonicalItemId`, `tryCount`, `outcome`, `revealed` | A Try was recorded |
518
+ | `formative-reveal-changed` | `itemId`, `canonicalItemId`, `revealed`, `feedback`, `tryCount`, `source` | The reveal changed without a Try |
519
+ | `section-mastery-changed` | `mastery` | The mastery rollup changed |
520
+ | `timed-media-cue-changed` | Active, visited and completed cues, `revealedItemIds`, `gateCueIdentifier`, `mediaCompleted`, `aggregateComplete` | A cue activated, a gate released, or aggregate completion flipped |
521
+ | `timed-media-audio-started` | — | Media audio is running, so read-aloud yields |
522
+ | `timed-media-policy-degraded` | `degradations` | The media time source cannot carry out a playback policy |
523
+ | `timed-media-invalid` | `errors` | The authored `timedMedia` cannot be delivered |
524
+
525
+ An item is complete when every element that has reported its completion is
526
+ complete. A report without `elementId`, and a restored session's item-level
527
+ `complete`, set the item's completion directly. A session restored through
528
+ `applySession` without `complete` is complete when it holds a response. A
529
+ passage's own `session-changed` stays inside its shell: a passage holds no
530
+ response.
531
+
532
+ `timed-media-cue-changed` is not emitted for media position: `timeupdate` fires
533
+ about four times a second and moves nothing a layout renders.
534
+ `timed-media-audio-started` is emitted only where playback actually started; a
535
+ gate that re-paused on the same `play` produced no audio.
536
+
537
+ ### Subscriptions
538
+
539
+ The coordinator's helpers follow the active cohort, the `(sectionId, attemptId)`
540
+ pair, across section changes, so one subscription survives navigation; the
541
+ handle's `subscribe()` ends with its controller.
209
542
 
210
543
  ```ts
211
- const controller = await host.waitForSectionController(5000);
212
- // The learner's actions, budget-respecting.
213
- controller?.recordFormativeTry?.({ itemId, outcomes }); // outcomes from provideScore()
214
- controller?.retryFormativeItem?.({ itemId });
215
- // Host authority: a teacher-driven "show the answer". Spends no Try, ignores the
216
- // Try budget, works on an item with no Try yet.
217
- controller?.revealFormativeItem?.({ itemId, feedback: "solution" });
218
- controller?.hideFormativeItem?.({ itemId });
544
+ const unsubscribeItems = coordinator.subscribeItemEvents({
545
+ listener: (event) => {
546
+ // item-selected, item-session-*, item-complete-changed, content-loaded, item-player-error
547
+ },
548
+ });
549
+
550
+ const unsubscribeSection = coordinator.subscribeSectionLifecycleEvents({
551
+ listener: (event) => {
552
+ // section-navigation-change, section-session-applied, section-loading-complete,
553
+ // section-items-complete-changed, section-error, timed-media-* (except audio-started)
554
+ },
555
+ });
219
556
  ```
220
557
 
221
- `feedback` is stated rather than taken from the policy, because a reveal under
222
- `feedback: "none"` would project nothing. A learner retry clears it, so a forced
223
- solution does not upgrade every later reveal on that item.
558
+ The defaults leave out `formative-try-recorded`, `formative-reveal-changed`,
559
+ `section-mastery-changed` and `timed-media-audio-started`. Name them in
560
+ `subscribeItemEvents({ eventTypes })`, or subscribe through
561
+ `subscribeSectionEvents({ listener, eventTypes, itemIds })`.
224
562
 
225
- Try state persists inside `SectionControllerSessionState.formative` and hydrates
226
- with the rest of the snapshot. See
227
- [`docs/prds/formative-delivery-contract.md`](../../docs/prds/formative-delivery-contract.md)
228
- for the full contract, its QTI 3 mapping, and the mastery denominator rule.
563
+ A helper called before the coordinator's first section has been requested
564
+ throws ("requires an active section cohort"). `runtime-ready` fires before that
565
+ request, so a host subscribes from `toolkit-ready` or later. A listener added
566
+ while a section is starting binds when it becomes active.
229
567
 
230
- ### Timed media
568
+ Item-scoped events carry both id forms, and both are always populated.
569
+ `itemId` is the bare `item.id`, the form `applySession` expects.
570
+ `canonicalItemId` is that item's adapter identifier, and falls back to `itemId`
571
+ when the section was not built from an adapter or no adapter ref matches it.
572
+ Correlate with formative policy and `runtime.player.resolveBackend` by
573
+ `canonicalItemId`; reach the session by `itemId`.
574
+
575
+ ## Formative delivery
576
+
577
+ A section with `formative` set delivers as practice: each formative item gets a
578
+ check-answer control, records Tries against `maxTries`, and reveals feedback by
579
+ `feedback` and `revealOn` (`"on-try"`, or `"on-final-try"`, which resolves to
580
+ `"on-try"` under `maxTries: "unlimited"`). An item ref changes the section's
581
+ policy field by field. A revealed item gets the env projection, `mode:
582
+ "evaluate"` (with `role: "instructor"` under `feedback: "solution"`) over the
583
+ section env for that item alone, and the element draws the feedback. A section
584
+ in which no item is enabled delivers unchanged, with no control, state or env
585
+ projection. Scoring a Try needs an unhosted item player
586
+ ([player requirements](../../docs/section-player/formative-delivery.md#player-requirements)):
587
+ in a hosted player `provideScore()` returns empty slots and every Try records
588
+ `unknown`. [Formative Delivery](../../docs/section-player/formative-delivery.md)
589
+ covers the policy, the controller methods and events, mastery and persistence;
590
+ the [formative delivery contract](../../docs/prds/formative-delivery-contract.md)
591
+ holds the specification and its QTI 3 mapping.
592
+
593
+ ## Timed media
231
594
 
232
595
  Set `sectionType: "timed-media"` and a `timedMedia` block, and the section's cue
233
596
  timeline decides when its items are delivered:
@@ -244,8 +607,8 @@ const section: AssessmentSection = {
244
607
  class: "stimulus",
245
608
  view: ["candidate"],
246
609
  // An ordinary passage. Its config mounts the media element — a PIE element,
247
- // or authored `<video>` markup — and it owns the accessibility catalogs that
248
- // carry captions, transcript and signed alternates.
610
+ // or authored `<video>` markup, whose `<track>` carries the captions — and
611
+ // its accessibility catalogs carry the transcript and signed alternates.
249
612
  passage: videoPassage,
250
613
  },
251
614
  ],
@@ -266,150 +629,226 @@ const section: AssessmentSection = {
266
629
  };
267
630
  ```
268
631
 
269
- Every item a gate names must satisfy its `releaseOn`. To split must-answer items from
270
- optional ones, author two cues at the same timestamp — a gate over the first set, a
271
- reveal over the second. Both activate in the same pass, the reveal completes at once,
272
- and only the gate holds playback.
632
+ Every item a gate names must satisfy its `releaseOn`. To split must-answer items
633
+ from optional ones, author two cues at the same timestamp: a gate over the first
634
+ set and a reveal over the second. Both activate in the same pass, the reveal
635
+ completes at once, and only the gate holds playback.
273
636
 
274
- Absent `sectionType` and delivery is unchanged: no projection, no session slice, no
275
- cue behavior. An item no `reveal` or `gate` cue names is delivered normally —
637
+ Without `sectionType`, delivery is unchanged: no projection, no session slice,
638
+ no cue behavior. An item no `reveal` or `gate` cue names is delivered normally,
276
639
  including one a `metadata` cue names, since metadata records state and reveals
277
- nothing. A cued item is mounted and hidden until its cue fires, so its session and
278
- shell registration survive a seek backwards.
640
+ nothing. A cued item is mounted and hidden until its cue fires, so its session
641
+ and shell registration survive a seek backwards.
279
642
 
280
- The section reaches media only through a **Media Time Source**. The stimulus card
281
- finds the media element its passage mounted and registers a native adapter; a host
282
- with its own player registers its own port instead, and that port outranks the
283
- card's discovery for as long as it is attached:
643
+ The section reaches media only through a **Media Time Source**. The stimulus
644
+ card finds the media element its passage mounted and registers a native adapter;
645
+ a host with its own player registers its own port, which outranks the card's
646
+ adapter for as long as it is attached:
284
647
 
285
648
  ```ts
286
649
  const controller = await host.waitForSectionController(5000);
287
- // No `renderableId`: a host is asserting its own port, where a renderable's adapter
288
- // has to name itself and is ignored unless it is the resolved stimulus.
650
+ // No `renderableId`: a host asserts its own port. A renderable's adapter names
651
+ // itself, and is ignored unless it is the resolved stimulus.
289
652
  controller?.attachMediaTimeSource?.(myThirdPartyPort);
290
653
  controller?.detachMediaTimeSource?.();
291
654
  controller?.getTimedMediaProjection?.(); // cues, gate, enforcement, revealed items
292
- // One half of the read-aloud handoff; `false` means the port cannot pause, so the
293
- // overlap stands rather than the accommodation being withheld.
655
+ // One half of the read-aloud handoff. `false` means the port cannot pause, and
656
+ // the overlap stands; the accommodation is not withheld.
294
657
  controller?.pauseMediaForCompetingAudio?.();
295
658
  ```
296
659
 
297
- Read-aloud and media audio never run at once, and the action the learner just took
298
- wins: starting read-aloud pauses media, starting media pauses read-aloud. The section
299
- supplies both halves — the method above and `timed-media-audio-started` — and the
300
- toolkit arbitrates between them, because only the toolkit holds the TTS service and
301
- the section. Neither direction resumes what it silenced.
660
+ Read-aloud and media audio never run at once, and the action the learner just
661
+ took wins: starting read-aloud pauses media, starting media pauses read-aloud.
662
+ The section supplies both halves, the method above and
663
+ `timed-media-audio-started`, and the toolkit arbitrates between them, because
664
+ only the toolkit holds both the text-to-speech service and the section. Neither
665
+ direction resumes what it silenced.
302
666
 
303
667
  Where the port reports `canPause: false` or `canRestrictSeeking: false`, the
304
- matching policy degrades to **advisory**: cues still fire, state is still recorded,
305
- the projection says `enforcement: "advisory"`, and a recoverable `timed-media`
306
- framework warning names the policy that lost its teeth. Nothing silently pretends to
307
- hold.
308
-
309
- Three authoring mistakes fail loudly rather than delivering inert cues: a
310
- `stimulusRef` that resolves to no renderable in the section; a gate on correctness
311
- over an item without unlimited Tries, where a learner who spent a finite budget could
312
- never release playback again; and a `stimulusRef` that resolves to a renderable which
313
- mounts no media, reported once the section's content has loaded and no time source
314
- has attached. Each reports a `timed-media` framework error, after which the section
315
- delivers as an ordinary section with every item visible.
316
-
317
- Cue state persists inside `SectionControllerSessionState.timedMedia` and hydrates
318
- with the rest of the snapshot, including the furthest position reached, which is what
319
- `allowSeekAhead: false` clamps against across a reload. See
320
- [`docs/prds/timed-media-section-contract.md`](../../docs/prds/timed-media-section-contract.md)
321
- for the contract and the decision record.
322
-
323
- ## Usage
668
+ matching policy degrades to **advisory**: cues still fire, state is still
669
+ recorded, the projection says `enforcement: "advisory"`, and a recoverable
670
+ `timed-media` framework warning names the policy that can no longer be
671
+ enforced.
672
+
673
+ Three authoring mistakes fail loudly: a `stimulusRef` that resolves to no
674
+ renderable in the section; a gate on correctness over an item without unlimited
675
+ Tries, where a learner who spent a finite budget could never release playback
676
+ again; and a `stimulusRef` that resolves to a renderable which mounts no media,
677
+ reported once the section's content has loaded and no time source has attached.
678
+ Each reports a `timed-media` framework error, after which the section delivers
679
+ as an ordinary section with every item visible.
680
+
681
+ Cue state persists inside `SectionControllerSessionState.timedMedia` and
682
+ hydrates with the rest of the snapshot, including the furthest position reached,
683
+ which `allowSeekAhead: false` clamps against across a reload. The
684
+ [timed-media section contract](../../docs/prds/timed-media-section-contract.md)
685
+ holds the contract and its decision record.
686
+
687
+ ## One item as a section
324
688
 
325
- Import the package root, which registers every section-player element:
689
+ `sectionFromItem` wraps one item config, in the shape `<pie-item-player config>`
690
+ takes, and optionally its session, in the `section` and `session` the layouts
691
+ take. It is exported from the package root and from
692
+ `@pie-players/pie-section-player/item-section`, which defines no custom element
693
+ and imports in Node.js.
326
694
 
327
695
  ```ts
328
- import '@pie-players/pie-section-player';
696
+ import { sectionFromItem } from "@pie-players/pie-section-player/item-section";
697
+
698
+ const { section, session } = sectionFromItem(itemConfig, { session: itemSession });
699
+ player.section = section;
700
+ player.session = session;
329
701
  ```
330
702
 
331
- Render in HTML/Svelte/JSX:
703
+ The item ref's `identifier` and the item's `id` are the config's `id`, so every
704
+ `itemId` the section reports is the id the host already holds. An advanced
705
+ config's `passage` becomes the item's passage, and its `instructorResources` and
706
+ `defaultExtraModels` stay on the item's config. The section carries no `baseId`
707
+ or `version`; `options.sectionId` names the section, which defaults to the
708
+ config's `id`, and the player takes it as the section id when `section-id` is
709
+ unset.
332
710
 
333
- ```html
334
- <pie-section-player-splitpane></pie-section-player-splitpane>
711
+ ## Backend delivery for embedded items
712
+
713
+ A host configures item player backend delivery once, on the section player's
714
+ runtime, for a delivery backend that processes models and scores on the server.
715
+ The section player derives a `backend` property for each embedded item player
716
+ before it renders the item.
717
+
718
+ ```ts
719
+ import type { SectionPlayerRuntimeConfig } from "@pie-players/pie-section-player";
720
+
721
+ const runtime: SectionPlayerRuntimeConfig = {
722
+ playerType: "iife",
723
+ env: { mode: "gather", role: "student" },
724
+ player: {
725
+ backend: {
726
+ delivery: {
727
+ enabled: true,
728
+ baseUrl: apiBaseUrl,
729
+ assignmentId,
730
+ autosave: { enabled: true, debounceMs: 250 },
731
+ },
732
+ },
733
+ },
734
+ };
735
+
736
+ sectionPlayer.runtime = runtime;
335
737
  ```
336
738
 
337
- Set complex values (`runtime`, `section`, `session`) as JS properties. `env` is a
338
- `runtime` field (`runtime.env`); the layout elements have no `env` property.
739
+ With `runtime.player.backend.delivery` enabled, each item player's `itemId` and
740
+ `sessionId` are its per-item delivery identity, derived from
741
+ `canonicalItemId || item.id` and the item session. Static delivery fields
742
+ (`baseUrl`, `auth`, `endpoints`, `assignmentId`, `autosave`) are kept. Set
743
+ `assignmentId` to the backend assignment the items are delivered under.
339
744
 
340
- Set `runtime` no later than `section`. When the player builds its own
341
- coordinator, the section's arrival rebuilds that coordinator from the current
342
- `runtime`, so both can be set a tick after the element mounts. Once the section
343
- has initialized, a change to `runtime.tools`, `runtime.assessmentId`,
344
- `runtime.accessibility`, `runtime.lazyInit`, `tool-config-strictness` or
345
- `toolRegistry` is reported once in the console and does not reach that
346
- coordinator; `runtime.tools.pnpEnforcement` still applies. Change a running
347
- coordinator through the one `toolkit-ready` carries, with
348
- `updateToolConfig(...)` or `updateToolsPlacement(...)`, or pass your own as
349
- `runtime.coordinator`.
745
+ An enabled `backend.delivery` also sets `hosted: true` on each embedded item
746
+ player unless `runtime.player.hosted` is set, so the item players load no
747
+ element controllers and render the models the server returns. Those item players
748
+ cannot score in the browser, so formative Tries record `unknown`: formative
749
+ delivery needs an unhosted player.
750
+
751
+ `runtime.player.resolveBackend(context, baseBackend)` maps per-item identity
752
+ when the derivation does not fit. `context` carries `itemId`, `canonicalItemId`,
753
+ `item`, `itemIndex`, `itemSession`, `sectionId`, `env` and `baseBackend`; the
754
+ returned config merges over the derived backend, and a nullish return keeps it.
755
+ The resolver receives cloned backend objects, so a per-item change neither
756
+ mutates the shared runtime nor leaks across items, and the key is removed before
757
+ the properties reach `<pie-item-player>`.
758
+
759
+ The section player derives `backend` and calls nothing on the item players. An
760
+ embedded `<pie-item-player>` loads on its own when its derived
761
+ `backend.delivery` has a load signature, so every mounted item player issues one
762
+ backend load for its own item. Passage players receive the shared non-delivery
763
+ backend configuration and no item delivery configuration. This configuration is
764
+ separate from the element loader, which hands IIFE and ESM bundle loads to an
765
+ adapter (`preloaded` uses none). The item player's
766
+ [backend support](../../docs/item-player/backend-support.md) guide covers the
767
+ delivery backend contract.
768
+
769
+ ### Section session persistence
770
+
771
+ Section state persists outside `runtime.player.backend`, which covers one
772
+ item's calls. A coordinator hook,
773
+ `ToolkitCoordinatorHooks.createSectionSessionPersistence`, returns the strategy
774
+ for each section controller, created once per `(assessmentId, sectionId,
775
+ attemptId)`; it loads and saves the `SectionControllerSessionState` that carries
776
+ navigation, item sessions, and formative and timed-media state.
350
777
 
351
- ## Runtime Inputs
352
-
353
- The layout elements (`pie-section-player-splitpane`,
354
- `pie-section-player-vertical`, `pie-section-player-tabbed`) support:
355
-
356
- - `runtime` (object): primary coordinator/tools/player runtime bundle
357
- - `section` (object): assessment section payload
358
- - `session` (object, JS property only): the section's session, a `SectionControllerSessionState`; see [Session lifecycle](#session-lifecycle)
359
- - `assessment` (object, JS property only): the `AssessmentEntity` whose `personalNeedsProfile` and `settings` tool policy reads, forwarded to the coordinator the player builds. A coordinator passed in `runtime` is the host's to bind with `updateAssessment`. A section's own `personalNeedsProfile` is not read, and the player warns once when it finds one
360
- - `debug` (boolean-like): verbose debug logging control (`"true"` enables, `"false"`/`"0"` disables)
361
- - `toolbar-position` (string): `top|right|bottom|left|none`
362
- - `narrow-layout-breakpoint` (number, optional): viewport width in px below which the layout collapses (split pane: single column; vertical: toolbar moves to top). Clamped to 400–2000; default 1100.
363
- - `content-max-width-no-passage` (number, optional): max width in px when no passages exist. Clamped to 320–2200. Unset by default (layout uses available width).
364
- - `content-max-width-with-passage` (number, optional): max width in px when passages are present. Clamped to 320–2200. Unset by default (layout uses available width).
365
- - `split-pane-min-region-width` (number, optional): splitpane minimum pane width in px. Clamped to 160–1200. Unset by default (split bounds stay at 20–80). Splitpane only.
366
- - `split-pane-collapse-strategy` (string, optional): splitpane stacked-mode strategy. Supported values: `tabbed` (default) and `vertical`. Splitpane only.
367
- - `base-heading-level` (number, optional): the heading level this player's card headings occupy, and the level every descendant's outline derives from. Clamped to 1–6; default 2. See [Heading structure](#heading-structure).
368
- - `show-toolbar` (boolean-like): accepts `true/false` and common string forms (`"true"`, `"false"`, `"1"`, `"0"`, `"yes"`, `"no"`); default `false`, so tools placed at `section` level render only when it is `true`
369
- - `locale` (string, optional): BCP-47 locale for the player's own interface text. Unset renders `en-US`.
370
- - `runtime.contentLanguage` (string, optional, `runtime` only): BCP-47 language of the content where its markup names none, which read-aloud speaks in and picks catalog cards by. A `lang` between the content and its card wins; unset reads `en-US`. `locale` never sets it.
371
- - `nds-icons` (boolean): opt in to NDS icon buttons.
372
- - `tool-config-strictness` (string, optional): `off|warn|error` for tool-config validation; default `error`.
373
- - `split-pane-initial-passage-width` (number, optional): splitpane passage pane width in percent at mount. Clamped to 20–80; default 50. Splitpane only.
374
- - `iife-bundle-host` (string, optional): bundle host for the IIFE element pre-warm when `runtime.player.loaderOptions.bundleHost` is unset.
375
- - Host extension props (JS properties only): `toolRegistry`, `sectionHostButtons`, `itemHostButtons`, `passageHostButtons`, `hooks`
376
-
377
- When the viewport is no wider than `narrow-layout-breakpoint` (default 1100px),
378
- splitpane and vertical layout hosts normalize section toolbar placement to `top`.
379
- This includes `left`, `right`, `bottom`, and `none` values. Separately, the shell
380
- moves a `left` or `right` toolbar to `top` at a fixed 1100px, so with a smaller
381
- breakpoint side toolbars still move to the top from 1100px down.
382
-
383
- `hooks.cardTitleFormatter` remains active across responsive splitpane transitions (split -> stacked and stacked -> split), because title rendering is provided through shared card context rather than layout-specific state.
384
-
385
- To opt into PIE-117 dimensions from a host, configure:
778
+ ```ts
779
+ import { ToolkitCoordinator } from "@pie-players/pie-assessment-toolkit";
780
+ import type { SectionPlayerRuntimeConfig } from "@pie-players/pie-section-player";
781
+
782
+ const coordinator = new ToolkitCoordinator({
783
+ assessmentId: "practice-1",
784
+ hooks: {
785
+ createSectionSessionPersistence: () => ({
786
+ async loadSession({ key }) {
787
+ return await loadSectionSession(key.sectionId, key.attemptId);
788
+ },
789
+ async saveSession({ key }, session) {
790
+ await saveSectionSession(key.sectionId, key.attemptId, session);
791
+ },
792
+ }),
793
+ },
794
+ });
386
795
 
387
- ```html
388
- <pie-section-player-splitpane
389
- content-max-width-no-passage="800"
390
- content-max-width-with-passage="1200"
391
- split-pane-min-region-width="280"
392
- ></pie-section-player-splitpane>
796
+ const runtime: SectionPlayerRuntimeConfig = { assessmentId: "practice-1", coordinator };
393
797
  ```
394
798
 
395
- Use the same max-width attributes on `pie-section-player-vertical` when you want the same no-passage/with-passage width behavior in vertical mode.
799
+ Without the hook, the strategy stores the session in `localStorage` under
800
+ `pie:section-controller:v1:{assessmentId}:{sectionId}:{attemptId}`, and only
801
+ when there is an attempt id. The coordinator persists a controller before it
802
+ disposes it. A player that builds its own coordinator installs no hooks, so a
803
+ host that lets it do so restores through the `session` property and saves from
804
+ the controller's events or `getSession()`, or sets a strategy on the handle with
805
+ `configureSessionPersistence()`.
396
806
 
397
- To force splitpane stacked mode to use vertical rendering:
807
+ The player does not load a section definition by identity: the host loads the
808
+ `AssessmentSection` and passes it in.
398
809
 
399
- ```html
400
- <pie-section-player-splitpane
401
- narrow-layout-breakpoint="1100"
402
- split-pane-collapse-strategy="vertical"
403
- ></pie-section-player-splitpane>
810
+ ## Preloaded elements
811
+
812
+ With `runtime.playerType: "preloaded"` the section player loads no element
813
+ code. Its pre-warm asserts that every tag the section's items and passages name
814
+ is registered, so the host registers the elements first:
815
+
816
+ ```ts
817
+ import { registerPreloadedElements } from "@pie-players/pie-item-player/preloaded";
818
+ import * as delivery from "@pie-element/multiple-choice/browser/delivery";
819
+ import * as controller from "@pie-element/multiple-choice/browser/controller";
820
+ import manifest from "../package.json"; // pins "@pie-element/multiple-choice" exactly
821
+
822
+ registerPreloadedElements(
823
+ [
824
+ {
825
+ tag: "pie-element-multiple-choice",
826
+ package: "@pie-element/multiple-choice",
827
+ version: manifest.dependencies["@pie-element/multiple-choice"],
828
+ element: delivery,
829
+ controller,
830
+ },
831
+ ],
832
+ { math: { assetRoot: "https://assets.example.com/npm" } },
833
+ );
834
+
835
+ sectionPlayer.runtime = { ...sectionPlayer.runtime, playerType: "preloaded" };
404
836
  ```
405
837
 
406
- By default, splitpane stacked mode uses tabs. The dedicated `pie-section-player-tabbed` layout also always renders passage/items tabs when passages are present.
838
+ Register before the section player mounts: a tag missing at pre-warm leaves
839
+ the items unmounted and raises a non-recoverable `element-preload` framework
840
+ error. Item players count as hosted under `runtime.player.hosted` or an enabled
841
+ `runtime.player.backend.delivery`, and then need no `controller`. Version pins,
842
+ element sets, MathJax assets and TypeScript setup are in
843
+ [Registering elements from npm](../../docs/item-player/loading-strategies.md#registering-elements-from-npm).
844
+
845
+ ## Styling
407
846
 
408
847
  ### Heading structure
409
848
 
410
849
  The player publishes one number and every descendant derives its outline from it.
411
850
  Set `base-heading-level` to the level the cards should occupy in the surrounding
412
- page — 2 when the page has its own `<h1>` above the player, 3 when the player sits
851
+ page: 2 when the page has its own `<h1>` above the player, 3 when the player sits
413
852
  under an `<h2>`, and so on:
414
853
 
415
854
  ```html
@@ -426,34 +865,30 @@ h2 Question 1 <- item card heading
426
865
  h3 Part A <- authored data-heading content in the prompt
427
866
  ```
428
867
 
429
- The two content kinds derive different levels from the same number, and the
430
- difference is deliberate. An item card's heading *is* the item's heading, so the
431
- item player is told not to emit a screen-reader item heading of its own — one at
432
- that level already exists, and a second would read as its sibling. A passage
433
- card's heading is a group label, so the passage player is told to start one level
434
- deeper, putting the passage's own title beneath it.
868
+ The two content kinds derive different levels from the same number. An item
869
+ card's heading is the item's heading, so the item player emits no screen-reader
870
+ item heading of its own: one at that level already exists, and a second would
871
+ read as its sibling. A passage card's heading is a group label, so the passage
872
+ player starts one level deeper, putting the passage's own title beneath it.
435
873
 
436
874
  Authored `data-heading="headingN"` markup in passages and prompts becomes real
437
- heading elements only when a level is published, which the player now always does.
438
- Content authored against
439
- [PIE-151](https://illuminate.atlassian.net/browse/PIE-151) therefore renders as
440
- structure without any host configuration.
875
+ heading elements only when a level is published, which the player always does,
876
+ so authored heading markup renders as structure without host configuration.
441
877
 
442
- A host that needs the element's own screen-reader item heading — because it is not
443
- supplying question headings of its own — overrides per player through the runtime:
878
+ A host that does not supply question headings of its own, and so needs the
879
+ element's screen-reader item heading, turns it on through the runtime:
444
880
 
445
881
  ```js
446
882
  sectionPlayer.runtime = { player: { includeSrHeading: true } };
447
883
  ```
448
884
 
449
- The pattern behind this, and the reason the value is published rather than pushed,
450
- is in
451
- [`docs/architecture/composition-context.md`](../../docs/architecture/composition-context.md).
885
+ [Composition context](../../docs/architecture/composition-context.md) sets out
886
+ the pattern, and why the level is published to descendants.
452
887
 
453
888
  ### Tab styling hooks
454
889
 
455
- `pie-section-player-tabbed` and splitpane `tabbed` collapse mode expose canonical `pie-*`
456
- hooks for theming:
890
+ `pie-section-player-tabbed` and the splitpane's `tabbed` collapse mode expose
891
+ these hooks:
457
892
 
458
893
  - `pie-section-player-tabs`
459
894
  - `pie-section-player-tab`
@@ -462,48 +897,43 @@ hooks for theming:
462
897
 
463
898
  Tabs also carry `data-pie-purpose="passage-label"` and `data-pie-purpose="item-label"`.
464
899
 
465
- Tab colors, spacing, and track geometry can be themed via CSS variables:
900
+ Tab colors, spacing and track geometry are themed through
466
901
  `--pie-section-player-tab-color`, `--pie-section-player-tab-background`,
467
902
  `--pie-section-player-tab-active-color`,
468
903
  `--pie-section-player-tab-active-background`,
469
904
  `--pie-section-player-tab-gap`,
470
905
  `--pie-section-player-tab-track-radius`,
471
- `--pie-section-player-tab-track-padding`, and
906
+ `--pie-section-player-tab-track-padding` and
472
907
  `--pie-section-player-tab-padding-block`.
473
908
 
474
909
  ### Card header styling hooks
475
910
 
476
- Passage and item cards share a common header row
911
+ Passage and item cards share a header row
477
912
  (`.pie-section-player-content-card-header`, with the card-specific aliases
478
- `.pie-section-player-passage-header` / `.pie-section-player-item-header`).
479
-
480
- - Title and toolbar are centered vertically by default. There is no prop or
481
- attribute for this — hosts needing a non-standard alignment should override
482
- the selector in their own stylesheet.
483
- - Card corners default to `8px`. Hosts/themes can override the card radius via
484
- `--pie-section-player-card-radius`.
485
- - The header fill is transparent by default. Hosts/themes opt into a color
486
- via the `--pie-section-player-card-header-background` CSS variable; the
487
- framework does not ship a brand palette.
488
- - When a header fill is provided, header top corners default just inside the
489
- card radius. Hosts/themes can override them independently via
490
- `--pie-section-player-card-header-radius`.
913
+ `.pie-section-player-passage-header` and `.pie-section-player-item-header`).
914
+
915
+ - Title and toolbar are centered vertically. A host that needs another
916
+ alignment overrides the selector in its own stylesheet; there is no attribute
917
+ for it.
918
+ - Card corners default to `8px`; `--pie-section-player-card-radius` overrides
919
+ them.
920
+ - The header fill is transparent by default.
921
+ `--pie-section-player-card-header-background` sets a color; the framework
922
+ ships no brand palette.
923
+ - With a header fill, the header's top corners default to just inside the card
924
+ radius; `--pie-section-player-card-header-radius` overrides them.
491
925
  - Under a dark theme the header takes
492
- `--pie-section-player-card-header-background-dark` instead, falling back to
493
- `--pie-section-player-card-header-background` when it is unset. That is the
494
- hook for a host whose brand tint is legible on a light card but not on a dark
495
- one. "Dark theme" here means the selectors the theme package writes its dark
496
- tokens under: `[data-theme="dark"]` on an ancestor, or
497
- `pie-theme[theme="dark"]`. Note the `-dark` suffix means *under a dark theme*
498
- here, unlike canonical tokens such as `--pie-background-dark`, where it means
499
- a darker shade.
500
- - `pie-section-player-passage-card` also bridges
501
- `--pie-passage-header-background` to `--pie-section-player-card-header-background`,
502
- so a hosted passage-player custom element (defined outside this package)
503
- picks up the same header fill without either side hardcoding the other's
504
- token name.
505
-
506
- Example (host CSS):
926
+ `--pie-section-player-card-header-background-dark`, falling back to
927
+ `--pie-section-player-card-header-background` when it is unset: the hook for
928
+ a brand tint that is legible on a light card and not on a dark one. A dark
929
+ theme is `[data-theme="dark"]` on an ancestor or `pie-theme[theme="dark"]`,
930
+ the selectors the theme package writes its dark tokens under. The `-dark`
931
+ suffix here means under a dark theme; on canonical tokens such as
932
+ `--pie-background-dark` it means a darker shade.
933
+ - `pie-section-player-passage-card` bridges `--pie-passage-header-background`
934
+ to `--pie-section-player-card-header-background`, so a passage player custom
935
+ element defined outside this package takes the same header fill without
936
+ either side hardcoding the other's token name.
507
937
 
508
938
  ```css
509
939
  pie-section-player-passage-card,
@@ -515,19 +945,14 @@ pie-section-player-item-card {
515
945
  }
516
946
  ```
517
947
 
518
- When both max-width attributes are set, the with-passage cap resolves to the greater
519
- of the two configured values (after clamp), so with-passage mode never ends up narrower
520
- than no-passage mode.
521
-
522
948
  ### Split-pane backdrop
523
949
 
524
950
  The split-pane layout paints a backdrop behind each scrollable pane, under the
525
951
  passage and item cards. It reads the canonical `--pie-background-dark`, so it
526
- follows the active theme and color scheme, and there is no pane-specific hook:
527
- the backdrop is meant to stay with the theme rather than be styled per pane.
528
- The rule covers the items pane as well as the passage pane, so it is not driven
529
- from a passage-header hook. Card fills stay independent via
530
- `--pie-section-player-card-header-background`.
952
+ follows the active theme and color scheme, and it has no pane-specific hook: the
953
+ backdrop stays with the theme. The rule covers the items pane as well as the
954
+ passage pane, so no passage-header hook drives it. Card fills stay independent
955
+ through `--pie-section-player-card-header-background`.
531
956
 
532
957
  ### Content-card tool surfaces
533
958
 
@@ -536,215 +961,53 @@ Item and passage cards offer two content-scoped host surfaces:
536
961
  - `content-lead` is a full-width stack before the authored player content.
537
962
  - `content-media` is the resizable region beside that content.
538
963
 
539
- They are host surfaces, not features: whatever capability declares one of those
540
- names in `surfaces` may render there, and this package names no capability. A
541
- surface becomes mountable only when policy grants the capability and its own
542
- `requiresAuthoredContent` resolves, so an item or passage without the authored
543
- resource gets no dead affordance.
964
+ Any capability that declares one of those names in `surfaces` may render there,
965
+ and this package names no capability. A surface becomes mountable only when
966
+ policy grants the capability and its own `requiresAuthoredContent` resolves, so
967
+ an item or passage without the authored resource gets no dead affordance.
544
968
 
545
- Today's occupant is `@pie-players/pie-tool-sign-language` — a signed (ASL)
546
- translation gated on the `signLanguage` PNP support. It is not part of the
547
- packaged capability set: a deployment opts in by registering it on the tool
548
- registry it passes to the player.
969
+ The packaged audio transcript (`audioTranscriptRegistration` in
970
+ `@pie-players/pie-default-tool-loaders`) renders in `content-lead`.
971
+ `content-media` holds `@pie-players/pie-tool-sign-language`, a signed (ASL)
972
+ translation gated on the `signLanguage` PNP support; it is outside the packaged
973
+ set, so a deployment opts in by registering it on the tool registry it passes to
974
+ the player.
549
975
 
550
- The `content-media` adapter sits to the right of the content and is resizable via a
551
- keyboard-accessible divider (`role="separator"`; arrow keys, `Home`/`End`,
552
- `Escape` to cancel a drag). Below a card width of 560px the region stacks under
553
- the content and the divider is withdrawn. Placement is fixed in this iteration:
554
- there is no orientation toggle and no free repositioning.
976
+ The `content-media` region sits to the right of the content and is resizable
977
+ through a keyboard-accessible divider (`role="separator"`; arrow keys,
978
+ `Home`/`End`, `Escape` to cancel a drag). Below a card width of 560px the region
979
+ stacks under the content and the divider is withdrawn. Placement is fixed: there
980
+ is no orientation toggle and no free repositioning.
555
981
 
556
982
  This package owns the region's share of the card width and nothing inside it. A
557
- capability mounted here sizes its own content: signing legibility needs height for
558
- hands and face, so it is sized by an aspect-ratio target with a height floor
559
- rather than by width alone. The `--pie-section-player-item-media-*` tokens hosts
560
- set for that belong to `@pie-players/pie-tool-sign-language` and are documented
561
- with their defaults in [its README](../tool-sign-language/README.md) — they keep
562
- the `pie-section-player` prefix because hosts already set them by those names.
563
-
564
- All three section-player surfaces (`content-lead`, `content-media`, and
565
- `section-overlay`) share one internal Tool Surface Host. It observes live
566
- `ToolRegistry` mutations and policy/catalog changes, preserves registration
567
- order across lazy loads, synchronizes an existing element when its current
568
- context changes, and always calls `destroy()` before removing it. Surface
569
- failures are isolated per capability and emitted as recoverable
570
- `framework-error` warnings; they never block readiness or remove another
571
- working capability. A `renderSurface()` result of `null` is a normal
572
- mountable-but-unoccupied result, not an error.
573
-
574
- ### API direction: CE defaults first, JS customization for advanced cases
575
-
576
- The intended usage model is:
577
-
578
- - **CE props for default/standard flows (roughly 90% use cases)**:
579
- - `section`, `section-id`, `attempt-id`, `debug`, `locale`, `nds-icons`, `tool-config-strictness`
580
- - `show-toolbar`, `toolbar-position`, `narrow-layout-breakpoint`
581
- - `content-max-width-no-passage`, `content-max-width-with-passage`, and on splitpane `split-pane-min-region-width`, `split-pane-collapse-strategy`
582
- - **JS API for advanced customization**:
583
- - Get the controller handle via `getSectionController()` or `waitForSectionController()` (preferred)
584
- - Listen for `pie-stage-change` and filter on `detail.stage === "engine-ready"` for an event-driven entry point
585
- - Apply custom policy/gating in host code (for example, domain-specific `canNext` based on controller events like `section-items-complete-changed`)
586
- - Compose forward/backward eligibility in host code using `getSnapshot().navigation` + host state; there is intentionally no separate parallel CE gating API for this
587
- - Inject custom toolbar tooling with `toolRegistry` and optional host button arrays (`sectionHostButtons`, `itemHostButtons`, `passageHostButtons`)
588
- - Register host callbacks via `hooks` (for example `hooks.cardTitleFormatter`)
589
-
590
- Example:
591
-
592
- ```ts
593
- const host = document.querySelector("pie-section-player-splitpane") as any;
594
- host.hooks = {
595
- cardTitleFormatter: (context: any) => {
596
- if (context.kind === "item") {
597
- return `Question ${context.itemIndex + 1}: ${context.item?.name || context.defaultTitle}`;
598
- }
599
- return context.passage?.name || context.defaultTitle;
600
- },
601
- };
602
- ```
603
-
604
- Advanced runtime configuration is supplied through the `runtime` object. Set player config, tools, accessibility, coordinator, env, and `createSectionController` on `runtime.<key>`.
605
-
606
- ### Backend delivery for embedded items
607
-
608
- Hosts can configure item-player backend delivery once at the section-player
609
- runtime level. Section-player derives a concrete `backend` prop for each
610
- embedded item player before it renders the item. This is intended for hosts
611
- that need server-processed PIE models and server scoring without querying every
612
- nested `<pie-item-player>`.
613
-
614
- ```ts
615
- import type {
616
- SectionPlayerRuntimeConfig,
617
- } from "@pie-players/pie-section-player";
618
-
619
- const runtime: SectionPlayerRuntimeConfig = {
620
- playerType: "iife",
621
- env: {
622
- mode: "gather",
623
- role: "student",
624
- },
625
- player: {
626
- backend: {
627
- delivery: {
628
- enabled: true,
629
- baseUrl: bffUrl,
630
- assignmentId: playerSessionId,
631
- endpoints: {
632
- load: "/api/player/load",
633
- saveSession: "/api/player/save",
634
- model: "/api/player/model",
635
- score: "/api/player/score",
636
- },
637
- autosave: { enabled: true, debounceMs: 250 },
638
- },
639
- },
640
- },
641
- };
642
-
643
- sectionPlayer.runtime = runtime;
644
- ```
645
-
646
- When `runtime.player.backend.delivery` is enabled, section-player treats
647
- `itemId` and `sessionId` as per-item delivery identity. It derives them from
648
- `canonicalItemId || item.id` and the item session before forwarding `backend` to
649
- each embedded item player. Static delivery fields such as `baseUrl`, `auth`,
650
- `endpoints`, `assignmentId`, and `autosave` are preserved. Use `assignmentId`
651
- for shared attempt/player identity.
652
-
653
- An enabled `backend.delivery` also sets `hosted: true` on each embedded item
654
- player unless `runtime.player.hosted` is set, so the item players load no
655
- element controllers and render the models the server returns.
656
-
657
- `runtime.player.resolveBackend` is a section-player-reserved key. It is called
658
- with `{ itemId, canonicalItemId, item, itemIndex, itemSession, sectionId, env,
659
- baseBackend }` and is stripped before props reach `<pie-item-player>`. Use it
660
- only when the backend needs custom per-item identity mapping. The resolver
661
- receives cloned backend objects, so per-item identity changes do not mutate the
662
- shared runtime configuration or leak across items.
663
-
664
- Section-player only derives the concrete `backend` prop. It does not call
665
- `loadFromBackend()` on nested item players. Embedded `<pie-item-player>` loads
666
- automatically when its derived `backend.delivery` config has a load signature,
667
- so every mounted item player issues one backend load for its own item. Passage
668
- players do not receive item delivery backend config, but shared non-delivery
669
- backend config is preserved.
670
-
671
- This backend delivery config is separate from the element-loader backend used
672
- for IIFE/ESM bundle preloading.
673
-
674
- ### Preloaded elements
675
-
676
- With `runtime.playerType: "preloaded"` the section player loads no element
677
- code. Its pre-warm asserts that every tag the section's items and passages name
678
- is registered, so the host registers the elements first:
679
-
680
- ```ts
681
- import { registerPreloadedElements } from "@pie-players/pie-item-player/preloaded";
682
- import * as delivery from "@pie-element/multiple-choice/browser/delivery";
683
- import * as controller from "@pie-element/multiple-choice/browser/controller";
684
- import manifest from "../package.json"; // pins "@pie-element/multiple-choice" exactly
685
-
686
- registerPreloadedElements(
687
- [
688
- {
689
- tag: "pie-element-multiple-choice",
690
- package: "@pie-element/multiple-choice",
691
- version: manifest.dependencies["@pie-element/multiple-choice"],
692
- element: delivery,
693
- controller,
694
- },
695
- ],
696
- { math: { assetRoot: "https://assets.example.com/npm" } },
697
- );
698
-
699
- sectionPlayer.runtime = { ...sectionPlayer.runtime, playerType: "preloaded" };
700
- ```
701
-
702
- - Register before the section player mounts. A tag missing at pre-warm leaves
703
- the items unmounted and raises a non-recoverable `element-preload` framework
704
- error.
705
- - Install element packages with `npm install --save-exact`. npm otherwise saves
706
- a caret range, which registration rejects as a `version`, and which a fresh
707
- install can resolve to another release line: `^13.4.0-next.15` resolves to
708
- the legacy `13.4.4`, which has no `./browser/*` modules.
709
- - Install every pie-elements-ng package from one release, in one install from
710
- the same dist-tag, and upgrade them together. Elements whose `./browser/*`
711
- builds typeset on `window.MathJax` share the MathJax the first of them loads,
712
- in the build and configuration of that element's release, so in a mixed set
713
- an element can typeset with a MathJax it was not built for. Elements that
714
- bundle their own MathJax share none
715
- ([One MathJax version per page](../../docs/item-player/loading-strategies.md#one-mathjax-version-per-page)).
716
- - Pass `math.assetRoot`, an npm root serving the fonts and speech the elements'
717
- bundled MathJax loads, or `math.assetUrls`, each file's URL; without either,
718
- elements on adapter 0.1.3 or later render without web fonts and speech
719
- ([MathJax assets](../../docs/item-player/loading-strategies.md#mathjax-assets)).
720
- - Register one version per package. The players align every authored version
721
- of a package to the registered one, and registering a second version throws.
722
- - Register each package's `controller` unless the item players are hosted
723
- (`runtime.player.hosted`, or an enabled `runtime.player.backend.delivery`).
724
- A player that is not hosted runs `model()` in the browser and warns for each
725
- tag registered without one.
726
- - Only `@pie-element/*` builds from pie-elements-ng publish
727
- `./browser/delivery` and `./browser/controller`.
728
- - Under TypeScript, the `package.json` import needs `resolveJsonModule`, and a
729
- package version that ships no declarations for `./browser/*` needs a
730
- `declare module` shim for those subpaths.
731
-
732
- See [`strategy="preloaded"`](../../docs/item-player/loading-strategies.md#strategypreloaded)
733
- for the registration contract.
734
-
735
- ### Host-owned focus
736
-
737
- Section-player does not move focus on behalf of host-level affordances such
738
- as "Skip to Main", nor does it make passage/question containers tab stops.
739
- Hosts own page chrome, skip links, landmarks, and any special focus placement.
740
- For example, a host shell can focus its own
741
- `main#main-content`; the next Tab then follows the browser's natural order
742
- into the first actionable control rendered inside the section player.
743
-
744
- The passage and item card custom elements are content/layout surfaces, not
745
- public focus targets. Splitpane passage content remains scrollable through the
746
- pane's native scroll behavior, but the passage pane itself is not inserted into
747
- sequential keyboard navigation.
983
+ capability mounted here sizes its own content: signing legibility needs height
984
+ for hands and face, so the sign-language tool sizes by an aspect-ratio target
985
+ with a height floor. The `--pie-section-player-item-media-*` tokens that size it
986
+ belong to `@pie-players/pie-tool-sign-language` and are documented with their
987
+ defaults in [its README](../tool-sign-language/README.md); they keep the
988
+ `pie-section-player` prefix because hosts already set them by those names.
989
+
990
+ All three section player surfaces (`content-lead`, `content-media` and
991
+ `section-overlay`) share one tool surface host. It observes live `ToolRegistry`
992
+ mutations and policy and catalog changes, keeps registration order across lazy
993
+ loads, synchronizes an existing element when its context changes, and always
994
+ calls `destroy()` before removing it. Surface failures are isolated per
995
+ capability and emitted as recoverable `framework-error` warnings; they never
996
+ block readiness or remove another working capability. A `renderSurface()` result
997
+ of `null` means mountable and unoccupied.
998
+
999
+ ## Host-owned focus
1000
+
1001
+ The section player does not move focus on behalf of host affordances such as
1002
+ "Skip to Main", and does not make passage or question containers tab stops.
1003
+ Hosts own page chrome, skip links, landmarks and special focus placement. A host
1004
+ shell can focus its own `main#main-content`, and the next Tab follows the
1005
+ browser's natural order into the first actionable control in the section
1006
+ player.
1007
+
1008
+ The passage and item cards are content surfaces and not focus targets. Splitpane
1009
+ passage content scrolls through the pane's native scrolling, and the passage
1010
+ pane itself is not in the sequential keyboard order.
748
1011
 
749
1012
  ```html
750
1013
  <a href="#main-content" class="skip-link">Skip to Main</a>
@@ -753,251 +1016,13 @@ sequential keyboard navigation.
753
1016
  </main>
754
1017
  ```
755
1018
 
756
- **Policy fields.** The layout accepts a partial `policies` object: every
757
- unset field, a missing section included, takes its value from
758
- `DEFAULT_SECTION_PLAYER_POLICIES`.
759
-
760
- - `readiness.mode` (`"progressive"` | `"strict"`) — the mode the layout
761
- kernel gates readiness by. Both modes
762
- hold the `interactive` stage and `pie-loading-complete` until the section's
763
- element pre-warm resolves and the item cards can mount. The kernel has no
764
- later loading signal, so the two modes currently emit the same sequence.
765
- Default: `"progressive"`.
766
- - `preload.enabled` — when `false`, `SectionItemsPane` short-circuits the
767
- section-level element warmup pipeline (`warmupSectionElements`). Items
768
- still mount and item-players register their own elements on demand. Use
769
- this to disable section pre-warm when the host already owns element
770
- registration end-to-end. Default: `true`.
771
- - `telemetry.enabled` — when `false`, the layout custom elements skip
772
- `attachInstrumentationEventBridge` setup, so no `pie-section-*`
773
- telemetry events flow through the bridge. Hosts that want a different
774
- shape of opt-out can still override `runtime.player.loaderConfig.instrumentationProvider`.
775
- Default: `true`.
776
-
777
- `resolveSectionPlayerPolicies(policies)` returns the filled-in object, for
778
- host code that needs the same gates.
779
-
780
- ### Navigation signals
781
-
782
- - `item-selected`: item-level navigation change within the current section in the `SectionController` broadcast stream (`itemIndex`, `currentItemId`, `totalItems`).
783
- - `section-navigation-change`: section-level navigation/selection change in the `SectionController` broadcast stream (`previousSectionId`, `currentSectionId`, `reason`).
784
-
785
- Runtime configuration is explicit:
786
-
787
- - `runtime` owns runtime fields (`assessmentId`, `playerType`, `player`, `lazyInit`, `tools`, `toolContextResolvers`, `accessibility`, `coordinator`, `createSectionController`, `isolation`, `env`, `contentLanguage` and the `on*` callbacks). `locale`, `nds-icons` and `tool-config-strictness` are attributes only.
788
- - Tool placement is configured through `runtime.tools.placement.section`, `runtime.tools.placement.item`, and `runtime.tools.placement.passage`.
789
- - Tool configuration validation is canonical in toolkit initialization (`pie-assessment-toolkit`), including toolbar overlays. Use the `tool-config-strictness` attribute (`off` | `warn` | `error`) to control warning-only vs fail-fast behavior.
790
- - TTS provider config must use `tools.providers.textToSpeech` (canonical). `tools.providers.tts` is rejected by validation.
791
- - Host tool overrides:
792
- - `toolRegistry` replaces the default toolbar registry when provided. Build it with `createPackagedToolRegistry({ toolModuleLoaders: DEFAULT_TOOL_MODULE_LOADERS })` and register custom tools on it, since toolbars load each tool's element through the registry's loaders. A player that builds its own coordinator gives it this registry; a coordinator passed as `runtime.coordinator` keeps its own, which decides policy, so build that coordinator with the same registry
793
- - host buttons are appended per toolbar scope via `sectionHostButtons`, `itemHostButtons`, `passageHostButtons`
794
-
795
- Debug logging is page-wide. A layout's `debug` attribute writes `window.PIE_DEBUG`, the flag every PIE logger on the page reads, so the last host to set it decides for all of them:
796
-
797
- - Enable: `<pie-section-player-splitpane debug="true">`
798
- - Disable: `<pie-section-player-splitpane debug="false">` (or `debug="0"`)
799
-
800
- Without a `debug` attribute a layout follows `window.PIE_DEBUG`, which a host can set directly.
801
-
802
- See the progressive demo routes in `apps/section-demos/src/routes/(demos)` (for example `single-question/+page.svelte` and `session-hydrate-db/+page.svelte`) for end-to-end host integrations.
803
-
804
- ## Data flow and stability guarantees
805
-
806
- Section-player follows a unidirectional flow model:
807
-
808
- - Inputs flow downward (`runtime`, `section`, `env`, toolbar options) into base/toolkit/layout/card render paths.
809
- - State updates flow upward as events (`runtime-*`, `session-changed`, controller change events) and are reconciled by runtime owners.
810
- - Layout/card components should not create competing sources of truth for composition/session.
811
-
812
- ### Stability guarantees
813
-
814
- For non-structural updates, section-player guarantees behavior stability:
815
-
816
- - Item/passage shell identity remains stable (no remount churn for response-only updates).
817
- - Pane-local scroll position remains stable in splitpane and vertical layouts.
818
-
819
- Non-structural updates include:
1019
+ ## Instrumentation
820
1020
 
821
- - response/session updates
822
- - tool toggles/config updates
823
- - runtime config changes that do not alter composition identity
824
-
825
- Structural composition changes (new/removed/reordered entities) may legitimately re-render/remount affected nodes.
826
-
827
- ## Custom layout authoring
828
-
829
- A host builds its own section layout from `pie-section-player-kernel-host` and the
830
- two panes. The kernel host runs the section: toolkit, section controller,
831
- readiness, element pre-warm and the section toolbar. Its element children are the
832
- layout, and the panes inside them render the section's items and passages. The
833
- stock layouts are built from the same panes.
834
-
835
- ```html
836
- <!doctype html>
837
- <html lang="en">
838
- <head>
839
- <meta charset="utf-8" />
840
- <style>
841
- body { margin: 0; }
842
- pie-section-player-kernel-host { display: block; height: 100vh; }
843
- .columns { display: grid; grid-template-columns: 3fr 2fr; height: 100%; }
844
- .column { min-height: 0; overflow: auto; }
845
- </style>
846
- <script type="module">
847
- import "https://cdn.jsdelivr.net/npm/@pie-players/pie-section-player@x.y.z/dist/browser/pie-section-player.js";
848
-
849
- const player = document.querySelector("pie-section-player-kernel-host");
850
- player.addEventListener("pie-loading-complete", () => {
851
- console.log("ready", player.getSnapshot().navigation);
852
- });
853
- player.runtime = { env: { mode: "gather", role: "student" } };
854
- player.section = {
855
- identifier: "water-cycle",
856
- rubricBlocks: [
857
- {
858
- identifier: "passage-1",
859
- class: "stimulus",
860
- view: ["candidate"],
861
- passage: {
862
- id: "passage-1",
863
- config: {
864
- markup: "<p>Water evaporates, condenses into clouds and falls as rain.</p>",
865
- elements: {},
866
- models: [],
867
- },
868
- },
869
- },
870
- ],
871
- assessmentItemRefs: [
872
- {
873
- identifier: "item-1",
874
- item: {
875
- id: "item-1",
876
- config: {
877
- markup: "<p>Name the stage in which water vapour forms clouds.</p>",
878
- elements: {},
879
- models: [],
880
- },
881
- },
882
- },
883
- ],
884
- };
885
- </script>
886
- </head>
887
- <body>
888
- <pie-section-player-kernel-host section-id="water-cycle" attempt-id="attempt-1">
889
- <div class="columns">
890
- <div class="column"><pie-section-player-items-pane></pie-section-player-items-pane></div>
891
- <div class="column"><pie-section-player-passages-pane></pie-section-player-passages-pane></div>
892
- </div>
893
- </pie-section-player-kernel-host>
894
- </body>
895
- </html>
896
- ```
897
-
898
- The example loads the [browser build](../../docs/setup/cdn_usage.md#section-player-browser-build).
899
- A bundled host imports `@pie-players/pie-section-player`, which defines the
900
- kernel host and the panes too. An item with PIE elements names them in
901
- `config.elements` and carries their `config.models`, as in any section. The
902
- `/custom-layout` route of `apps/section-demos`
903
- ([source](../../apps/section-demos/src/routes/%28demos%29/custom-layout/+page.svelte))
904
- builds the same two columns in Svelte.
905
-
906
- ### Kernel host
907
-
908
- - **Inputs and host methods.** Those of the layout elements in
909
- [Runtime Inputs](#runtime-inputs), without the layout dimensions
910
- (`narrow-layout-breakpoint`, `content-max-width-*`, `split-pane-*`).
911
- - **DOM.** The open shadow root holds the toolkit and the section toolbar around
912
- one default slot; `show-toolbar` and `toolbar-position` place the toolbar around
913
- the layout. The children, the panes and the content they render stay in light
914
- DOM, where page styles and the content stylesheet reach them. The element has
915
- no styles of its own, so the host gives it `display` and a height.
916
- - **Stock body.** With no element children it renders its own layout, the
917
- passages pane above the items pane, and places the passages pane only for a
918
- section with passages. The stock body leaves when the first element child
919
- arrives and returns when the last one leaves. Text and comment children do not
920
- count, so markup whitespace and a framework's placeholder comments leave it in
921
- place.
922
- - **Navigation and state.** The host methods (`navigateNext`, `navigatePrevious`,
923
- `navigateTo`, `getSnapshot`, `getSectionController`, `waitForSectionController`)
924
- and the events (`pie-stage-change`, `pie-loading-complete`,
925
- `composition-changed`, `session-changed`, `framework-error`, `toolkit-ready`)
926
- are those of the stock layouts. `detail.sourceCe` on its `pie-stage-change`
927
- and `pie-loading-complete` events reads `pie-section-player-kernel-host`.
928
-
929
- ### Panes
930
-
931
- `<pie-section-player-items-pane>` renders the item cards of the current
932
- composition, with their toolbars, and runs the element pre-warm;
933
- `<pie-section-player-passages-pane>` renders the passage cards. A pane takes no
934
- attributes or properties, and its pre-warm failures arrive as the section
935
- player's `framework-error` and `element-preload-error` events. It reads the
936
- section player it belongs to from a context the kernel publishes, so it renders
937
- at any depth below the kernel host, and outside a section player it renders
938
- nothing.
939
-
940
- - **One pane of each kind renders**: the first connected. A second pane of a
941
- kind renders nothing and takes over when the first disconnects; the player
942
- reports the duplicate once in the console.
943
- - **Readiness follows the rendering items pane.** `interactive` and
944
- `pie-loading-complete` wait for its element pre-warm, and reports from any
945
- other pane are ignored. A section with items and no items pane never reaches
946
- either; the player reports it once in the console a task after
947
- `section-ready`.
948
- - **The passages pane is optional.** A section without passages needs none, and
949
- readiness does not wait for it.
950
- - **Scrolling belongs to the layout.** The items pane's scroll hint follows the
951
- nearest ancestor with `overflow-y: auto` or `scroll`, so the layout gives each
952
- pane's container that overflow and a bounded height. The stock layouts' pane backdrops and margins
953
- stay with those layouts; the cards keep their tags and
954
- [styling hooks](#card-header-styling-hooks).
955
-
956
- ### JS API example for advanced host policy
957
-
958
- ```ts
959
- const host = document.querySelector("pie-section-player-splitpane") as any;
960
- const controller = await host.waitForSectionController(5000);
961
- let sectionComplete = false;
962
-
963
- const unsubscribe = controller?.subscribe?.((event: any) => {
964
- if (event?.type === "section-items-complete-changed") {
965
- sectionComplete = event.complete === true;
966
- }
967
- });
968
-
969
- function canAdvance() {
970
- const nav = host.getSnapshot?.()?.navigation;
971
- return Boolean(nav?.canNext && sectionComplete);
972
- }
973
- ```
974
-
975
- If you already have a `ToolkitCoordinator`, prefer helper subscriptions for host logic. Subscriptions follow the toolkit's active section cohort automatically — a single subscribe call survives navigation:
976
-
977
- ```ts
978
- const unsubscribeItem = coordinator.subscribeItemEvents({
979
- listener: (event: any) => {
980
- // item-scoped stream
981
- },
982
- });
983
-
984
- const unsubscribeSection = coordinator.subscribeSectionLifecycleEvents({
985
- listener: (event: any) => {
986
- // section-loading-complete / section-items-complete-changed / section-error / section-navigation-change
987
- },
988
- });
989
- ```
990
-
991
- Subscribe **after** the first `getOrCreateSectionController(...)` resolves (or after `toolkit-ready` once the section player has fully wired its controller — typically the safest anchor in host code is `toolkit-ready` followed by the first controller-resolve). Calling subscribe before the first `getOrCreateSectionController(...)` call throws; a listener added while a section is starting binds when that section becomes active.
992
-
993
- Use `subscribeSectionEvents(...)` only for advanced mixed filtering requirements.
994
-
995
- > **Upgrading from `<0.3.35`?** The `sectionId` / `attemptId` arguments on `subscribeItemEvents` / `subscribeSectionLifecycleEvents` / `subscribeSectionEvents` were dropped — subscriptions now follow the toolkit's active section cohort automatically and migrate across navigation. See the **"Migrating from `<0.3.35`"** section in [`@pie-players/pie-assessment-toolkit`](../assessment-toolkit/README.md#migrating-from-0335-breaking--pre-10) for the full upgrade recipe.
996
-
997
- ### Item-level observability configuration
998
-
999
- Item-level resource observability is configured on the embedded `pie-item-player` via
1000
- `loaderConfig`. In section-player integrations, pass this through `runtime.player.loaderConfig`.
1021
+ Instrumentation goes through the shared `InstrumentationProvider` contract, set
1022
+ at `runtime.player.loaderConfig.instrumentationProvider`. The embedded item
1023
+ players use the same `loaderConfig` for resource monitoring; `loaderOptions`
1024
+ controls bundle loading. A provider is a JavaScript object, so it is set on the
1025
+ `runtime` property.
1001
1026
 
1002
1027
  ```ts
1003
1028
  import { ConsoleInstrumentationProvider } from "@pie-players/pie-players-shared";
@@ -1021,212 +1046,68 @@ sectionPlayerEl.runtime = {
1021
1046
  };
1022
1047
  ```
1023
1048
 
1024
- Important:
1025
-
1026
- - `loaderOptions` controls bundle loading. `loaderConfig` controls runtime resource monitoring.
1027
- - Custom providers (functions/instances) must be passed as JS properties (`runtime` object), not serialized string attributes.
1028
-
1029
- ### Instrumentation ownership and semantics
1030
-
1031
- Section-player instrumentation is provider-agnostic and uses the shared
1032
- `InstrumentationProvider` contract.
1033
-
1034
- - Canonical provider path: `runtime.player.loaderConfig.instrumentationProvider`
1035
- - With `trackPageActions: true`, missing/`undefined` providers use the default New Relic provider path.
1036
- - `instrumentationProvider: null` explicitly disables instrumentation.
1037
- - Invalid provider objects are ignored (optional debug warning), also no-op.
1038
- - Existing `item-player` behavior is preserved.
1039
- - For local debug overlays, compose providers (for example `NewRelicInstrumentationProvider` + `DebugPanelInstrumentationProvider`) through `CompositeInstrumentationProvider`.
1040
- - Toolkit telemetry forwarding uses the same provider path, so tool/backend
1041
- operational events are visible alongside section events when toolkit is mounted.
1042
-
1043
- Canonical lifecycle stream (engine-routed, dispatched on the outer layout CE,
1044
- bubbling and composed):
1045
-
1046
- - `pie-stage-change` — single typed transition stream covering
1047
- `composed` → `engine-ready` → `interactive` → `disposed`. Payload is a
1048
- `StageChangeDetail`. A non-recoverable framework error before
1049
- `interactive` emits the current stage `failed` and each stage it never
1050
- reached `skipped`.
1051
- - `pie-loading-complete` — fires once per cohort, when the section's element
1052
- pre-warm resolves for the current composition and the item cards can mount
1053
- (kernel-routed).
1054
- - `framework-error` — canonical error event for any failure crossing the
1055
- framework boundary. Payload is a `FrameworkErrorModel`. The toolkit
1056
- dispatches it once per error, bubbling and composed, so it reaches the
1057
- layout CE and `document`; errors from a coordinator the host passes as
1058
- `runtime.coordinator` arrive the same way.
1059
- `tests/section-player-event-delivery.spec.ts` pins these counts.
1060
-
1061
- Callbacks on `runtime`:
1062
-
1063
- - `runtime.onStageChange(detail)` and `runtime.onLoadingComplete(detail)`.
1064
- - `runtime.onFrameworkError(model)` fires once per error regardless of
1065
- wrapper depth, like the `framework-error` DOM event; consume either.
1066
-
1067
- Section-player owned instrumentation stream:
1068
-
1069
- - `pie-section-stage-change`
1070
- - `pie-section-loading-complete`
1071
- - `pie-section-framework-error`
1072
- - `pie-section-element-preload-retry`
1073
- - `pie-section-element-preload-error`
1074
-
1075
- Build consumers against these canonical lifecycle events:
1076
-
1077
- - `readiness-change` → listen for `pie-stage-change`.
1078
- - `interaction-ready` → `pie-stage-change` filtered on
1079
- `detail.stage === "interactive"`.
1080
- - `ready` → `pie-loading-complete`.
1081
- - `section-controller-ready` → call
1082
- `waitForSectionController(timeoutMs)` or `getSectionController()`
1083
- on the layout CE, or filter `pie-stage-change` for
1084
- `detail.stage === "engine-ready"`.
1085
-
1086
- If toolkit is mounted, toolkit lifecycle events are emitted on a separate
1087
- `pie-toolkit-*` stream. This separation avoids semantic overlap; bridge dedupe
1088
- is a defensive safety net only.
1089
-
1090
- Toolkit tool/backend operational stream:
1091
-
1092
- - `pie-tool-init-start|success|error`
1093
- - `pie-tool-backend-call-start|success|error`
1094
- - `pie-tool-library-load-start|success|error`
1095
-
1096
- ### Item session management
1097
-
1098
- Section session data can be managed either through persistence hooks or directly through the controller API.
1099
-
1100
- ```ts
1101
- const host = document.querySelector("pie-section-player-splitpane") as any;
1102
- const controller = await host.waitForSectionController(5000);
1103
-
1104
- // Read current section session snapshot.
1105
- const currentSession = controller?.getSession?.();
1106
-
1107
- // Replace section session state (resume from backend snapshot).
1108
- await controller?.applySession?.({
1109
- currentItemIndex: 0,
1110
- visitedItemIdentifiers: ["q1"],
1111
- itemSessions: {
1112
- q1: {
1113
- itemIdentifier: "q1",
1114
- pieSessionId: "q1-session",
1115
- session: { id: "q1-session", data: [{ id: "choice", value: "a" }] }
1116
- }
1117
- }
1118
- }, { mode: "replace" });
1119
-
1120
- // Update a single item session directly.
1121
- await controller?.updateItemSession?.("q1", {
1122
- session: { id: "q1-session", data: [{ id: "choice", value: "b" }] },
1123
- complete: true,
1124
- });
1125
- ```
1126
-
1127
- The same controller snapshot is what the persistence strategy saves/loads, and
1128
- what the layout's `session` property takes.
1129
- When a controller is reused for the same `sectionId`/`attemptId`, `updateInput()` refreshes composition input while preserving in-memory section session data.
1130
-
1131
- ### One item as a section
1132
-
1133
- `sectionFromItem` wraps one item config, in the shape `<pie-item-player config>`
1134
- takes, and optionally its session, in the `section` and `session` the layouts
1135
- take. It is exported from the package root and from
1136
- `@pie-players/pie-section-player/item-section`, which defines no custom element
1137
- and imports in Node.
1138
-
1139
- ```ts
1140
- import { sectionFromItem } from "@pie-players/pie-section-player/item-section";
1141
-
1142
- const { section, session } = sectionFromItem(itemConfig, { session: itemSession });
1143
- player.setAttribute("section-id", section.identifier);
1144
- player.section = section;
1145
- player.session = session;
1146
- ```
1147
-
1148
- The item ref's `identifier` and the item's `id` are the config's `id`, so every
1149
- `itemId` the section reports is the id the host already holds. An advanced
1150
- config's `passage` becomes the item's passage, and its `instructorResources` and
1151
- `defaultExtraModels` stay on the item's config. The section carries no `baseId`
1152
- or `version`; `options.sectionId` names the section, which defaults to the
1153
- config's `id`.
1154
-
1155
- ### Commit at a section boundary
1049
+ [Instrumentation providers](../../docs/architecture/instrumentation-providers.md#provider-resolution)
1050
+ sets out how an unset, `null` or invalid provider resolves. Local debug overlays
1051
+ compose providers (for example `NewRelicInstrumentationProvider` and
1052
+ `DebugPanelInstrumentationProvider`) through `CompositeInstrumentationProvider`.
1053
+
1054
+ The provider receives the section player's own stream (`pie-section-stage-change`,
1055
+ `pie-section-loading-complete`, `pie-section-framework-error`,
1056
+ `pie-section-element-preload-retry`, `pie-section-element-preload-error`), which
1057
+ `policies.telemetry.enabled: false` stops, and the toolkit's separate
1058
+ `pie-toolkit-*` stream with its tool and backend
1059
+ [operational events](../../docs/architecture/instrumentation-providers.md#operational-events).
1060
+ The two streams do not overlap.
1061
+
1062
+ `runtime.onStageChange(detail)` and `runtime.onLoadingComplete(detail)` receive
1063
+ the `pie-stage-change` and `pie-loading-complete` details.
1064
+ `runtime.onFrameworkError(model)` fires once per error whatever the wrapper
1065
+ depth, like the `framework-error` DOM event; consume either. Errors from a
1066
+ coordinator passed as `runtime.coordinator` arrive the same way.
1156
1067
 
1157
- A delivery element coalesces its `session-changed` dispatch, so a response the
1158
- learner has finished entering can still be pending when the section moves. The
1159
- controller commits pending element sessions before item navigation, before
1160
- `updateInput()` snapshots the session for a section swap, and before `persist()`.
1161
- The player also commits when an item shell tears down and when the page goes
1162
- hidden.
1163
-
1164
- A committed response travels like any other. A raw element `session-changed`
1165
- does not leave its `<pie-item-scope>`, which re-dispatches it as the normalized
1166
- `item-session-changed` (`PIE_ITEM_SESSION_CHANGED_EVENT`); the toolkit then
1167
- publishes the section's canonical `session-changed`. Both bubble through the
1168
- layout element to `document`, and a listener on either receives each dispatch
1169
- once.
1170
- Host code persists from the controller's events or from its session snapshot.
1171
-
1172
- Each of those events marks a commit with `sessionCommitReason`
1173
- (`"teardown" | "navigate" | "page-hidden"`): `item-session-changed`, the
1174
- toolkit's `session-changed`, and the controller's `item-session-data-changed`
1175
- and `item-session-meta-changed`. A commit at a section swap or item navigation
1176
- reports the item being left, which the host may already have moved past, so a
1177
- handler that sets its current item or navigation state from these events leaves
1178
- that state alone on a commit and persists the commit's session as usual. The
1179
- controller's events also carry `sectionId`, the section the item belongs to.
1180
-
1181
- Navigation inside a section keeps every item mounted, so nothing is discarded
1182
- and the element's own debounce would complete on its own. The commit still runs
1183
- there because the response belongs to the item being left: a host that persists
1184
- on the navigation event, or a `persist()` that follows it, would otherwise
1185
- snapshot a session the learner had already changed.
1068
+ ## Custom layout authoring
1186
1069
 
1187
- `SectionController` stays DOM-free; the player supplies the commit through
1188
- `setPendingSessionCommit()`, declared on `SectionControllerHandle`. The player
1189
- registers it both on the controllers it creates and on one the toolkit has
1190
- already built, since the first section's controller usually exists before the
1191
- player can override the factory. A host-built controller that leaves the method
1192
- unimplemented keeps the behaviour it had before the hook existed, and loses a
1193
- pending response at those boundaries.
1070
+ A host builds its own section layout from `pie-section-player-kernel-host` and
1071
+ the two panes. The kernel host runs the section: the toolkit, the section
1072
+ controller, readiness, the element pre-warm and the section toolbar. Its element
1073
+ children are the layout, and the panes inside them render the section's items
1074
+ and passages. [Custom Section Layouts](../../docs/section-player/custom-layouts.md)
1075
+ covers the kernel host, the panes and the rules a layout follows.
1194
1076
 
1195
1077
  ## Content trust boundary
1196
1078
 
1197
- Section-player layouts embed `<pie-item-player>` elements for each item.
1198
- Item and passage markup is sanitized by default via DOMPurify; see the
1199
- [pie-item-player README](../item-player/README.md#content-trust-boundary)
1200
- for the allow-list, the `trust-markup` opt-out and the `sanitizeMarkup`
1201
- property. Hosts can forward those settings through the section-player
1202
- `runtime.player` overrides — the runtime flattens these onto the embedded
1203
- `<pie-item-player>` instance, so any field not recognized by the kernel is
1204
- passed straight through as a prop/attribute:
1079
+ The layouts embed a `<pie-item-player>` for each item. Item and passage markup
1080
+ is sanitized with DOMPurify by default; the
1081
+ [item player README](../item-player/README.md#content-trust-boundary) describes
1082
+ the allow-list, the `trust-markup` opt-out and the `sanitizeMarkup` property.
1083
+ Every `runtime.player` field the section player does not consume is set on each
1084
+ embedded item player as a property, so a host forwards those settings there:
1205
1085
 
1206
- ```ts
1086
+ ```js
1207
1087
  const runtime = {
1208
1088
  playerType: "iife",
1209
1089
  player: {
1210
- trustMarkup: false, // default, keeps DOMPurify on
1090
+ trustMarkup: false, // the default: DOMPurify stays on
1211
1091
  // sanitizeMarkup: (html) => myCustomSanitize(html),
1212
1092
  },
1213
1093
  };
1214
1094
  ```
1215
1095
 
1216
- Set `trustMarkup: true` only when the section payload is guaranteed to be
1217
- produced by a trusted pipeline.
1096
+ Set `trustMarkup: true` only when a trusted pipeline produces the section
1097
+ payload. The [security overview](../../docs/security/readme.md) covers the wider
1098
+ trust model.
1218
1099
 
1219
1100
  ## Exports
1220
1101
 
1221
- Published exports are intentionally minimal:
1222
-
1223
- - `@pie-players/pie-section-player`
1224
- - `@pie-players/pie-section-player/browser`, the self-contained browser build ([CDN usage](../../docs/setup/cdn_usage.md#section-player-browser-build))
1225
- - `@pie-players/pie-section-player/components/section-player-splitpane-element`, which resolves to the package root
1226
- - `@pie-players/pie-section-player/contracts/runtime-host-contract`
1227
- - `@pie-players/pie-section-player/contracts/host-hooks`
1228
- - `@pie-players/pie-section-player/policies`
1229
- - `@pie-players/pie-section-player/item-section`
1102
+ | Specifier | Contents |
1103
+ | --- | --- |
1104
+ | `@pie-players/pie-section-player` | Registers every element. Exports `sectionFromItem`, `DEFAULT_SECTION_PLAYER_POLICIES` and the types `SectionPlayerRuntimeConfig`, `SectionPlayerRuntimePlayerConfig`, `SectionPlayerRuntimeHostContract`, `SectionPlayerSnapshot`, `SectionPlayerNavigationSnapshot`, `SectionPlayerPolicies`, `SectionPlayerHostHooks`, the card title context types, `SectionPlayerBackendResolver` and `SectionPlayerBackendResolverContext` |
1105
+ | `@pie-players/pie-section-player/browser` | The self-contained browser build, which also exports `createPackagedToolRegistry` and `DEFAULT_TOOL_MODULE_LOADERS` ([Loading from a CDN](../../docs/install/cdn.md#section-player-browser-build)) |
1106
+ | `@pie-players/pie-section-player/components/section-player-splitpane-element` | An alias of the package root |
1107
+ | `@pie-players/pie-section-player/contracts/runtime-host-contract` | The host method types |
1108
+ | `@pie-players/pie-section-player/contracts/host-hooks` | `SectionPlayerHostHooks` |
1109
+ | `@pie-players/pie-section-player/policies` | `DEFAULT_SECTION_PLAYER_POLICIES`, `resolveSectionPlayerPolicies` and the policy types |
1110
+ | `@pie-players/pie-section-player/item-section` | `sectionFromItem`, Node-safe |
1230
1111
 
1231
1112
  ## Development
1232
1113