@pie-players/pie-section-player 0.3.74 → 1.0.0-beta.0

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 (134) hide show
  1. package/README.md +862 -866
  2. package/custom-elements.json +3922 -0
  3. package/dist/browser/assets/{evaluation-worker-DkU_wGh3.js → evaluation-worker-0xRDVTJx.js} +98934 -88735
  4. package/dist/browser/chunks/PUA-tLuTiQ7--f2b27953.js +1 -1
  5. package/dist/browser/chunks/ToolCoordinator-0a6c4088.js +1 -0
  6. package/dist/browser/chunks/accents-BTUYmhn9-cafeb96e.js +1 -1
  7. package/dist/browser/chunks/accents-b-i-CngHZsLL-e0eb98fd.js +1 -1
  8. package/dist/browser/chunks/arabic-BzEHMVZD-30ac1ade.js +1 -1
  9. package/dist/browser/chunks/arrows-VjxlU-jv-2e17c192.js +1 -1
  10. package/dist/browser/chunks/attribute-coercion-b93b482c.js +1 -0
  11. package/dist/browser/chunks/braille-BeWC_Ruq-a3bb8ef9.js +1 -1
  12. package/dist/browser/chunks/braille-d-Dk6J96GO-d832bfbf.js +1 -1
  13. package/dist/browser/chunks/browser-a3c32178.js +17 -16
  14. package/dist/browser/chunks/calculator-element-271c142f.js +11 -8
  15. package/dist/browser/chunks/calligraphic-Cew9cU3x-04cf0735.js +1 -1
  16. package/dist/browser/chunks/catalog-context-46d3e50e.js +1 -0
  17. package/dist/browser/chunks/catalog-owner-d602f784.js +1 -0
  18. package/dist/browser/chunks/cherokee-Cny3FYho-4da7d2f3.js +1 -1
  19. package/dist/browser/chunks/classPrivateFieldGet2-338e0179.js +1 -1
  20. package/dist/browser/chunks/classPrivateMethodInitSpec-563cd0a5.js +1 -0
  21. package/dist/browser/chunks/cyrillic-CfCDulXU-5550df0b.js +1 -1
  22. package/dist/browser/chunks/cyrillic-ss-lU7bNGG9-12eb0188.js +1 -1
  23. package/dist/browser/chunks/devanagari-CDIz2h-r-18c38725.js +1 -1
  24. package/dist/browser/chunks/dist-41b39e7f.js +1 -1
  25. package/dist/browser/chunks/dist-59132a53.js +1 -1
  26. package/dist/browser/chunks/dist-60d7afef.js +1 -1
  27. package/dist/browser/chunks/dist-ca9b9622.js +1 -1
  28. package/dist/browser/chunks/double-struck-BpJEQk8y-500ce8dd.js +1 -1
  29. package/dist/browser/chunks/focus-trap-44913bf3.js +1 -1
  30. package/dist/browser/chunks/fraktur-rLEq9DEH-44cc14d8.js +1 -1
  31. package/dist/browser/chunks/greek-BlraJTyL-90ce8924.js +1 -1
  32. package/dist/browser/chunks/greek-ss-D-EN2PK0-91610739.js +1 -1
  33. package/dist/browser/chunks/hebrew-GiQZeybp-b583f555.js +1 -1
  34. package/dist/browser/chunks/latin-b-BhEnC7R2-d13e06ff.js +1 -1
  35. package/dist/browser/chunks/latin-bi-DqiIKv3U-1dec8705.js +1 -1
  36. package/dist/browser/chunks/latin-i-B1xtwaE1-21637466.js +1 -1
  37. package/dist/browser/chunks/latin-xfWFlLkD-5bcea5cf.js +1 -1
  38. package/dist/browser/chunks/logger-8b07fb72.js +1 -1
  39. package/dist/browser/chunks/marrows-DD1NgMQi-15b63755.js +1 -1
  40. package/dist/browser/chunks/math-DpWn1mbG-38eeb914.js +1 -1
  41. package/dist/browser/chunks/math-rendering-ready-9a0fd90a.js +1 -1
  42. package/dist/browser/chunks/{mathjax-D2JHb_R_-64780dc5.js → mathjax-BKrHE4xs-3ca05bf6.js} +41 -41
  43. package/dist/browser/chunks/monospace-D116QD2a-98d484fd.js +1 -1
  44. package/dist/browser/chunks/monospace-ex-DcXpouKS-456b403c.js +1 -1
  45. package/dist/browser/chunks/monospace-l-Ct5wkMDs-f34a4a9e.js +1 -1
  46. package/dist/browser/chunks/mshapes-DCp86WSX-c0055952.js +1 -1
  47. package/dist/browser/chunks/nl-NL-364d61b9.js +1 -1
  48. package/dist/browser/chunks/nl-NL-8b7e6d4b.js +1 -1
  49. package/dist/browser/chunks/phonetics-BwOCw2Y9-d2e3af59.js +1 -1
  50. package/dist/browser/chunks/phonetics-ss-CRtMn02f-579b6135.js +1 -1
  51. package/dist/browser/chunks/pie-api-client-687c319a.js +1 -1
  52. package/dist/browser/chunks/provider-4c704118.js +1 -1
  53. package/dist/browser/chunks/runtime-YFqnU6ic-d5c0cb49.js +8605 -0
  54. package/dist/browser/chunks/sans-serif-BYPZKlE9-2efdbcd9.js +1 -1
  55. package/dist/browser/chunks/sans-serif-b-B0kRKiC--b5530dce.js +1 -1
  56. package/dist/browser/chunks/sans-serif-bi-DXWul6mu-5370dba4.js +1 -1
  57. package/dist/browser/chunks/sans-serif-ex-Boux1kMP-aa178f31.js +1 -1
  58. package/dist/browser/chunks/sans-serif-i-676RFaSO-2db54474.js +1 -1
  59. package/dist/browser/chunks/sans-serif-r-DrN9xdZt-0fc72eea.js +1 -1
  60. package/dist/browser/chunks/script-0ohehnXc-526904f4.js +1 -1
  61. package/dist/browser/chunks/shapes-BG47NodJ-d59b0eca.js +1 -1
  62. package/dist/browser/chunks/{src-BcptuNS5-ed25d7fd.js → src-D_1goyPT-8cca0246.js} +81 -82
  63. package/dist/browser/chunks/{sre-engine-wf9s43gg-f09289c5.js → sre-0162a7a9.js} +18 -18
  64. package/dist/browser/chunks/sre-engine-ace23548.js +1 -0
  65. package/dist/browser/chunks/sre-engine-bj997w4b-891a4cbe.js +1 -0
  66. package/dist/browser/chunks/sre-locales-112320af.js +1 -0
  67. package/dist/browser/chunks/symbols-BqnUxf_t-83087e87.js +1 -1
  68. package/dist/browser/chunks/symbols-b-i-eg9k5hmF-9c461ac2.js +1 -1
  69. package/dist/browser/chunks/term-lookup-632cf6b1.js +1 -0
  70. package/dist/browser/chunks/tool-annotation-toolbar-997438b4.js +7 -6
  71. package/dist/browser/chunks/tool-answer-eliminator-35483abd.js +6 -6
  72. package/dist/browser/chunks/tool-color-scheme-b7de5cdf.js +5 -5
  73. package/dist/browser/chunks/tool-dictionary-cecff3b4.js +3 -3
  74. package/dist/browser/chunks/tool-graph-cb0338e8.js +6 -6
  75. package/dist/browser/chunks/tool-host-contract-fae1c415.js +1 -0
  76. package/dist/browser/chunks/tool-instance-id-76ac45e2.js +1 -0
  77. package/dist/browser/chunks/tool-line-reader-f8ef3c6a.js +3 -3
  78. package/dist/browser/chunks/tool-periodic-table-43b133b8.js +13 -16
  79. package/dist/browser/chunks/tool-picture-dictionary-33721e91.js +3 -3
  80. package/dist/browser/chunks/tool-protractor-23f77028.js +6 -6
  81. package/dist/browser/chunks/tool-ruler-1d9a03a6.js +12 -9
  82. package/dist/browser/chunks/tool-tts-inline-98c2ce7a.js +27 -33
  83. package/dist/browser/chunks/toolbar-registration-helpers-a7ab1ad4.js +194 -0
  84. package/dist/browser/chunks/variants-2LRNhOUP-966fc49d.js +1 -1
  85. package/dist/browser/pie-section-player.js +210 -100
  86. package/dist/chunks/nl-NL-364d61b9.js +35 -30
  87. package/dist/chunks/{sre-engine-wf9s43gg-f09289c5.js → sre-engine-bj997w4b-891a4cbe.js} +1 -1
  88. package/dist/component-definitions.d.ts +0 -2
  89. package/dist/components/shared/card-media-region.d.ts +3 -4
  90. package/dist/components/shared/composition.d.ts +3 -1
  91. package/dist/components/shared/framework-error-latch.d.ts +34 -0
  92. package/dist/components/shared/host-element.d.ts +10 -0
  93. package/dist/components/shared/kernel-host-default-body.d.ts +14 -0
  94. package/dist/components/shared/layout-host-methods.d.ts +12 -27
  95. package/dist/components/shared/missing-tool-providers.d.ts +6 -12
  96. package/dist/components/shared/player-preload.d.ts +4 -9
  97. package/dist/components/shared/section-player-backend-delivery.d.ts +1 -1
  98. package/dist/components/shared/section-player-card-context.d.ts +5 -4
  99. package/dist/components/shared/section-player-host-runtime.d.ts +32 -21
  100. package/dist/components/shared/section-player-layout-context.d.ts +77 -0
  101. package/dist/components/shared/section-player-own-event.d.ts +10 -0
  102. package/dist/components/shared/section-player-runtime-callbacks.d.ts +1 -1
  103. package/dist/components/shared/section-player-shell-layout.svelte.d.ts +3 -11
  104. package/dist/components/shared/section-player-view-state.d.ts +6 -2
  105. package/dist/components/shared/toolkit-ready-wait.d.ts +16 -0
  106. package/dist/contracts/host-hooks.d.ts +0 -7
  107. package/dist/contracts/runtime-host-contract.d.ts +0 -5
  108. package/dist/controllers/SectionController.d.ts +21 -6
  109. package/dist/controllers/types.d.ts +8 -1
  110. package/dist/item-section/index.d.ts +22 -0
  111. package/dist/item-section/index.js +37 -0
  112. package/dist/pie-section-player.d.ts +4 -6
  113. package/dist/pie-section-player.js +24965 -25463
  114. package/dist/policies/index.d.ts +1 -19
  115. package/dist/policies/index.js +6 -14
  116. package/dist/policies/types.d.ts +0 -4
  117. package/package.json +17 -55
  118. package/dist/browser/chunks/ToolCoordinator-cc2dc319.js +0 -1
  119. package/dist/browser/chunks/nds-icon-button-d602f784.js +0 -194
  120. package/dist/browser/chunks/pie-tool-calculator-cortex-0626bc24.js +0 -9
  121. package/dist/browser/chunks/pie-tool-calculator-geogebra-561236cb.js +0 -9
  122. package/dist/browser/chunks/runtime-C7H1jS4E-64772f5e.js +0 -8413
  123. package/dist/browser/chunks/tool-host-contract-b93b482c.js +0 -1
  124. package/dist/components/item-shell-element.d.ts +0 -1
  125. package/dist/components/shared/shell-registration.d.ts +0 -55
  126. package/dist/contracts/layout-contract.d.ts +0 -17
  127. package/dist/contracts/layout-contract.js +0 -0
  128. package/dist/contracts/layout-parity-metadata.d.ts +0 -4
  129. package/dist/contracts/layout-parity-metadata.js +0 -87
  130. package/dist/contracts/public-events.d.ts +0 -92
  131. package/dist/contracts/public-events.js +0 -12
  132. package/dist/policies/guards.d.ts +0 -2
  133. /package/dist/browser/chunks/{ItemToolBar-wr2myaah-2ce4b643.js → ItemToolBar-66hcwmfw-3bcb82a3.js} +0 -0
  134. /package/dist/chunks/{ItemToolBar-wr2myaah-1287ba31.js → ItemToolBar-66hcwmfw-1287ba31.js} +0 -0
package/README.md CHANGED
@@ -1,12 +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
- Use the layout custom elements listed above for section rendering.
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.
10
12
 
11
13
  ## Install
12
14
 
@@ -14,201 +16,581 @@ Use the layout custom elements listed above for section rendering.
14
16
  npm install @pie-players/pie-section-player
15
17
  ```
16
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
+
17
36
  No stylesheet import is needed. Authored content depends on shared classes
18
37
  (passage markup, the legacy `kds-*` families, answer-eliminator styles) from
19
- `@pie-players/pie-theme/components.css`, and this player renders items through
20
- `@pie-players/pie-item-player`, which installs that stylesheet itself. See
21
- [content styles](../item-player/README.md#content-styles) for the host-ownership
22
- 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.
23
41
 
24
- ## Runtime boundary and migration
42
+ ## Elements
25
43
 
26
- - Browser-only package: `@pie-players/pie-section-player` registers custom elements and
27
- is intended for browser/DOM hosts, not plain Node runtime imports.
28
- - Node-import-safe packages (for server/runtime utilities) are documented in
29
- `docs/setup/library-packaging-strategy.md`.
30
- - Migration direction: prefer the stable default entry for side-effect registration:
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)) |
52
+
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.
55
+
56
+ ## Usage
57
+
58
+ ```html
59
+ <pie-section-player-splitpane section-id="practice-set" attempt-id="attempt-1"></pie-section-player-splitpane>
60
+ ```
31
61
 
32
62
  ```ts
33
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
+ }
34
85
  ```
35
86
 
36
- If hosts need explicit registration control, keep using documented component
37
- entrypoints under `@pie-players/pie-section-player/components/*`.
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.
38
90
 
39
- The entries are bundler-only: they import `@pie-players/pie-item-player`,
40
- `@pie-players/pie-default-tool-loaders` and `speech-rule-engine`, with the
41
- engine's JSON locale tables, by bare specifier and without import attributes.
42
- Items render through the host's one `@pie-players/pie-item-player`, which this
43
- 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`.
44
101
 
45
- ## 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.
46
169
 
47
- `SectionController` is the domain authority inside a section player. It owns
48
- in-section navigation state, the canonical aggregation of per-item sessions,
49
- and the host-facing persistence snapshot. The layout custom elements
50
- (`pie-section-player-splitpane` / `-vertical` / `-tabbed`) are transport
51
- adapters around it. See
52
- [`docs/section-player/controller-boundaries.md`](../../docs/section-player/controller-boundaries.md)
53
- for the rationale behind that split, and
54
- [`docs/section-player/client-architecture-tutorial.md`](../../docs/section-player/client-architecture-tutorial.md)
55
- 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
+ ```
178
+
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`.
212
+
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)) |
56
250
 
57
- The handle implements `SectionControllerHandle` from
58
- `@pie-players/pie-assessment-toolkit`; see the JSDoc on that interface for
59
- the per-method contract.
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.
60
254
 
61
- ### Obtaining the handle
255
+ ### Card titles
256
+
257
+ `hooks.cardTitleFormatter` sets the heading of each item and passage card:
62
258
 
63
259
  ```ts
64
- const host = document.querySelector("pie-section-player-splitpane") as any;
65
- 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
+ };
66
268
  ```
67
269
 
68
- `waitForSectionController(timeoutMs)` resolves when the layout CE has wired
69
- its controller (the same anchor `pie-stage-change` reaches with
70
- `detail.stage === "engine-ready"`), or with `null` once `timeoutMs` passes.
71
- Use `getSectionController()` if you've already passed the readiness anchor
72
- 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.
73
282
 
74
- The layout elements define their host methods from the moment they are
75
- 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,
76
296
  `waitForSectionController` waits, `getSectionController()` returns `null`, the
77
- navigation methods return `false`, and `getSnapshot()` and the `select*` reads
78
- return `null` (`pie-section-player-kernel-host` returns its bootstrapping
79
- 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)) |
80
344
 
81
345
  ### Session lifecycle
82
346
 
83
- A typical host flow:
347
+ Set `session` with `section` to restore a section. The controller created for
348
+ that section applies it in replace mode in place of hydrating from the
349
+ persistence strategy, before the controller is published, so the first
350
+ composition, `engine-ready` and every reader see it. The strategy still receives
351
+ every `persist()`.
352
+
353
+ A later assignment applies through `applySession(value, { mode: "replace" })`
354
+ under the rules `<pie-item-player>` applies to its `session`: a value equal to
355
+ the current session is a no-op, so echoing `session-changed` back is safe, and an
356
+ item session with neither a response value nor a response field leaves one that
357
+ holds responses in place. `null` after creation is a no-op. A `section` change
358
+ without a `session` assignment hydrates the new controller from the strategy.
359
+ Reading `session` returns the assigned value until the controller is published,
360
+ and a fresh `getSession()` snapshot after.
361
+
362
+ Driving the controller directly:
84
363
 
85
364
  ```ts
86
365
  controller?.configureSessionPersistence?.({ context, strategy });
87
366
  await controller?.hydrate?.();
88
367
  const unsubscribe = controller?.subscribe?.(handleEvent);
89
- // ...later, on save / unload:
368
+ // Later, on save or unload:
90
369
  await controller?.persist?.();
91
370
  unsubscribe?.();
92
371
  ```
93
372
 
94
- `getSession()` / `applySession(session, { mode })` / `updateItemSession(itemId,
95
- detail)` are the direct read/write surfaces and exchange the same
96
- `SectionControllerSessionState` shape the persistence strategy load/save
97
- methods receive. See [Item session management](#item-session-management) for
98
- worked examples.
99
-
100
- ### Event stream
101
-
102
- The controller's typed event stream (`SectionControllerEvent` discriminated
103
- union) is the single source of truth for in-section change. Hosts usually
104
- subscribe through `ToolkitCoordinator.subscribeItemEvents` /
105
- `subscribeSectionLifecycleEvents` (cohort-aware filtering, survives
106
- navigation) — see [JS API example for advanced host
107
- policy](#js-api-example-for-advanced-host-policy). Key event types:
108
-
109
- - `item-selected` — item navigation within the current section.
110
- - `item-session-data-changed` / `item-session-meta-changed` — per-item
111
- session updates the persistence layer should observe. `sectionId` names the
112
- section. `elementId` names the reporting element when the change carries one,
113
- and `complete` is then that element's own. A commit carries
114
- `sessionCommitReason` (see [Commit at a section
115
- boundary](#commit-at-a-section-boundary)).
116
- - `item-complete-changed` — an item's completion flipped. An item is complete
117
- when every element that has reported its completion is complete. A report
118
- without `elementId`, and a restored session's item-level `complete`, set the
119
- item's completion directly. A session restored through `applySession`
120
- without `complete` is complete when it holds a response. A passage's own
121
- `session-changed` stays inside its shell: a passage holds no response.
122
- - `content-loaded` — passage / item / rubric finished loading. Carries
123
- `contentKind`, `itemId`, and `canonicalItemId`.
124
- - `section-loading-complete` — every renderable in the section finished
125
- loading.
126
- - `section-items-complete-changed` — aggregate completion flip.
127
- - `section-error` — an item player failed (`source: "item-player"`), or the
128
- section runtime did (`source: "section-runtime"`), a rejected element warmup
129
- included, which leaves the items unmounted.
130
- - `section-navigation-change` — the controller's section identity changed.
131
- - `formative-try-recorded` — a learner checked an answer.
132
- - `formative-reveal-changed` — the reveal state changed without a Try: a learner
133
- dismissed feedback, or a host forced or withdrew a reveal (`source` says which).
134
- - `section-mastery-changed` — the mastery rollup changed. Emitted on change
135
- only, like `section-items-complete-changed`.
136
- - `timed-media-cue-changed` — a cue activated, a gate released, or aggregate
137
- completion flipped. Not emitted for media position: `timeupdate` fires about four
138
- times a second and moves nothing a layout renders.
139
- - `timed-media-audio-started` — media audio is running, so read-aloud must yield.
140
- Emitted only where playback actually stood; a gate that re-paused on the same
141
- `play` produced no audio.
142
- - `timed-media-policy-degraded` — the attached media time source cannot carry out a
143
- playback policy, so it is advisory from here.
144
- - `timed-media-invalid` — authored `timedMedia` that cannot be delivered; the
145
- section renders without cue behavior.
146
-
147
- The helpers' default event sets leave out `formative-try-recorded`,
148
- `formative-reveal-changed`, `section-mastery-changed` and
149
- `timed-media-audio-started`. Name them in `subscribeItemEvents({ eventTypes })`,
150
- or subscribe through `subscribeSectionEvents`.
373
+ ### Item session management
151
374
 
152
- Item-scoped events carry both id forms, and both are always populated.
153
- `itemId` is the bare `item.id` — the form the map returned by
154
- `getItemSessionsByItemId()` is keyed by, and the form `applySession` expects.
155
- `canonicalItemId` is that item's adapter identifier, and falls back to `itemId`
156
- when the section was not built from an adapter or no adapter ref matches it.
157
- Correlate with formative policy and `runtime.player.resolveBackend` by
158
- `canonicalItemId`; reach the session by `itemId`. `content-loaded` and
159
- `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.
160
378
 
161
- ### Formative delivery
379
+ ```ts
380
+ import type { SectionPlayerRuntimeHostContract } from "@pie-players/pie-section-player";
162
381
 
163
- Set `formative` on the section and the player renders a check-answer control per
164
- 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);
165
386
 
166
- ```ts
167
- const section: AssessmentSection = {
168
- identifier: "practice-set",
169
- formative: { enabled: true, maxTries: 3, feedback: "correctness" },
170
- assessmentItemRefs: [
171
- { identifier: "q1", item },
172
- // Overrides the section default field by field.
173
- { identifier: "q2", item, formative: { maxTries: 1, feedback: "solution" } },
174
- { identifier: "q3", item, formative: { enabled: false } },
175
- ],
176
- };
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
+ });
177
411
  ```
178
412
 
179
- Absent, or `enabled: false`, and delivery is unchanged: no control, no state, no
180
- `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.
181
438
 
182
- PIE renders no feedback of its own. A revealed item gets `mode: "evaluate"`
183
- projected over the section env — with `role: "instructor"` under
184
- `feedback: "solution"` — and the element draws the rest. Only that item's env
185
- 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.
186
444
 
187
- Read the resolved state from `getFormativeProjection()`, or off
188
- `compositionModel.formative` in a custom layout. Drive it from a host through the
189
- 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.
190
542
 
191
543
  ```ts
192
- const controller = await host.waitForSectionController(5000);
193
- // The learner's actions, budget-respecting.
194
- controller?.recordFormativeTry?.({ itemId, outcomes }); // outcomes from provideScore()
195
- controller?.retryFormativeItem?.({ itemId });
196
- // Host authority: a teacher-driven "show the answer". Spends no Try, ignores the
197
- // Try budget, works on an item with no Try yet.
198
- controller?.revealFormativeItem?.({ itemId, feedback: "solution" });
199
- 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
+ });
200
556
  ```
201
557
 
202
- `feedback` is stated rather than taken from the policy, because a reveal under
203
- `feedback: "none"` would project nothing. A learner retry clears it, so a forced
204
- 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 })`.
205
562
 
206
- Try state persists inside `SectionControllerSessionState.formative` and hydrates
207
- with the rest of the snapshot. See
208
- [`docs/prds/formative-delivery-contract.md`](../../docs/prds/formative-delivery-contract.md)
209
- 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.
210
567
 
211
- ### 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
212
594
 
213
595
  Set `sectionType: "timed-media"` and a `timedMedia` block, and the section's cue
214
596
  timeline decides when its items are delivered:
@@ -225,8 +607,8 @@ const section: AssessmentSection = {
225
607
  class: "stimulus",
226
608
  view: ["candidate"],
227
609
  // An ordinary passage. Its config mounts the media element — a PIE element,
228
- // or authored `<video>` markup — and it owns the accessibility catalogs that
229
- // 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.
230
612
  passage: videoPassage,
231
613
  },
232
614
  ],
@@ -247,149 +629,226 @@ const section: AssessmentSection = {
247
629
  };
248
630
  ```
249
631
 
250
- Every item a gate names must satisfy its `releaseOn`. To split must-answer items from
251
- optional ones, author two cues at the same timestamp — a gate over the first set, a
252
- reveal over the second. Both activate in the same pass, the reveal completes at once,
253
- 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.
254
636
 
255
- Absent `sectionType` and delivery is unchanged: no projection, no session slice, no
256
- 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,
257
639
  including one a `metadata` cue names, since metadata records state and reveals
258
- nothing. A cued item is mounted and hidden until its cue fires, so its session and
259
- 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.
260
642
 
261
- The section reaches media only through a **Media Time Source**. The stimulus card
262
- finds the media element its passage mounted and registers a native adapter; a host
263
- with its own player registers its own port instead, and that port outranks the
264
- 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:
265
647
 
266
648
  ```ts
267
649
  const controller = await host.waitForSectionController(5000);
268
- // No `renderableId`: a host is asserting its own port, where a renderable's adapter
269
- // 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.
270
652
  controller?.attachMediaTimeSource?.(myThirdPartyPort);
271
653
  controller?.detachMediaTimeSource?.();
272
654
  controller?.getTimedMediaProjection?.(); // cues, gate, enforcement, revealed items
273
- // One half of the read-aloud handoff; `false` means the port cannot pause, so the
274
- // 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.
275
657
  controller?.pauseMediaForCompetingAudio?.();
276
658
  ```
277
659
 
278
- Read-aloud and media audio never run at once, and the action the learner just took
279
- wins: starting read-aloud pauses media, starting media pauses read-aloud. The section
280
- supplies both halves — the method above and `timed-media-audio-started` — and the
281
- toolkit arbitrates between them, because only the toolkit holds the TTS service and
282
- 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.
283
666
 
284
667
  Where the port reports `canPause: false` or `canRestrictSeeking: false`, the
285
- matching policy degrades to **advisory**: cues still fire, state is still recorded,
286
- the projection says `enforcement: "advisory"`, and a recoverable `timed-media`
287
- framework warning names the policy that lost its teeth. Nothing silently pretends to
288
- hold.
289
-
290
- Three authoring mistakes fail loudly rather than delivering inert cues: a
291
- `stimulusRef` that resolves to no renderable in the section; a gate on correctness
292
- over an item without unlimited Tries, where a learner who spent a finite budget could
293
- never release playback again; and a `stimulusRef` that resolves to a renderable which
294
- mounts no media, reported once the section's content has loaded and no time source
295
- has attached. Each reports a `timed-media` framework error, after which the section
296
- delivers as an ordinary section with every item visible.
297
-
298
- Cue state persists inside `SectionControllerSessionState.timedMedia` and hydrates
299
- with the rest of the snapshot, including the furthest position reached, which is what
300
- `allowSeekAhead: false` clamps against across a reload. See
301
- [`docs/prds/timed-media-section-contract.md`](../../docs/prds/timed-media-section-contract.md)
302
- for the contract and the decision record.
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
688
+
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.
303
694
 
304
- ## Usage
695
+ ```ts
696
+ import { sectionFromItem } from "@pie-players/pie-section-player/item-section";
305
697
 
306
- Import the custom-element registration entrypoint in consumers:
698
+ const { section, session } = sectionFromItem(itemConfig, { session: itemSession });
699
+ player.section = section;
700
+ player.session = session;
701
+ ```
702
+
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.
710
+
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.
307
717
 
308
718
  ```ts
309
- import '@pie-players/pie-section-player/components/section-player-splitpane-element';
310
- import '@pie-players/pie-section-player/components/section-player-vertical-element';
311
- import '@pie-players/pie-section-player/components/section-player-tabbed-element';
312
- import '@pie-players/pie-section-player/components/section-player-item-card-element';
313
- import '@pie-players/pie-section-player/components/section-player-passage-card-element';
314
- ```
719
+ import type { SectionPlayerRuntimeConfig } from "@pie-players/pie-section-player";
315
720
 
316
- Render in HTML/Svelte/JSX:
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
+ };
317
735
 
318
- ```html
319
- <pie-section-player-splitpane></pie-section-player-splitpane>
736
+ sectionPlayer.runtime = runtime;
320
737
  ```
321
738
 
322
- Set complex values (`runtime`, `section`) as JS properties. `env` is a
323
- `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.
324
744
 
325
- Set `runtime` no later than `section`. When the player builds its own
326
- coordinator, the section's arrival rebuilds that coordinator from the current
327
- `runtime`, so both can be set a tick after the element mounts. Once the section
328
- has initialized, a change to `runtime.tools`, `runtime.assessmentId`,
329
- `runtime.accessibility`, `runtime.lazyInit`, `runtime.toolConfigStrictness` or
330
- `toolRegistry` is reported once in the console and does not reach that
331
- coordinator; `runtime.tools.pnpEnforcement` still applies. Change a running
332
- coordinator through the one `toolkit-ready` delivers, with
333
- `updateToolConfig(...)` or `updateToolsPlacement(...)`, or pass your own as
334
- `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.
335
777
 
336
- ## Runtime Inputs
337
-
338
- The layout elements (`pie-section-player-splitpane`,
339
- `pie-section-player-vertical`, `pie-section-player-tabbed`) support:
340
-
341
- - `runtime` (object): primary coordinator/tools/player runtime bundle
342
- - `section` (object): assessment section payload
343
- - `debug` (boolean-like): verbose debug logging control (`"true"` enables, `"false"`/`"0"` disables)
344
- - `toolbar-position` (string): `top|right|bottom|left|none`
345
- - `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.
346
- - `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).
347
- - `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).
348
- - `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). (Ignored by vertical layout; supported for API parity.)
349
- - `split-pane-collapse-strategy` (string, optional): splitpane stacked-mode strategy. Supported values: `tabbed` (default) and `vertical`. (Ignored by vertical/tabbed layouts; supported for API parity.)
350
- - `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).
351
- - `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`
352
- - `locale` (string, optional): BCP-47 locale for the player's own interface text. Mirrored onto `runtime.locale`, which wins when both are set. Unset renders `en-US`.
353
- - `nds-icons` (boolean): opt in to NDS icon buttons. Mirrored onto `runtime.ndsIcons`, which wins when both are set.
354
- - `tool-config-strictness` (string, optional): `off|warn|error` for tool-config validation; default `error`. `runtime.toolConfigStrictness` wins when both are set.
355
- - `split-pane-initial-passage-width` (number, optional): splitpane passage pane width in percent at mount. Clamped to 20–80; default 50.
356
- - `iife-bundle-host` (string, optional): bundle host for the IIFE element pre-warm when `runtime.player.loaderOptions.bundleHost` is unset.
357
- - Host extension props (JS properties only): `toolRegistry`, `sectionHostButtons`, `itemHostButtons`, `passageHostButtons`, `hooks`
358
-
359
- When viewport width is within the collapsed range (~1100px and below), splitpane and
360
- vertical layout hosts normalize section toolbar placement to `top`. This includes
361
- `left`, `right`, `bottom`, and `none` values.
362
-
363
- `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.
364
-
365
- 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
+ });
366
795
 
367
- ```html
368
- <pie-section-player-splitpane
369
- content-max-width-no-passage="800"
370
- content-max-width-with-passage="1200"
371
- split-pane-min-region-width="280"
372
- ></pie-section-player-splitpane>
796
+ const runtime: SectionPlayerRuntimeConfig = { assessmentId: "practice-1", coordinator };
373
797
  ```
374
798
 
375
- 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()`.
376
806
 
377
- 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.
378
809
 
379
- ```html
380
- <pie-section-player-splitpane
381
- narrow-layout-breakpoint="1100"
382
- split-pane-collapse-strategy="vertical"
383
- ></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" };
384
836
  ```
385
837
 
386
- 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
387
846
 
388
847
  ### Heading structure
389
848
 
390
849
  The player publishes one number and every descendant derives its outline from it.
391
850
  Set `base-heading-level` to the level the cards should occupy in the surrounding
392
- 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
393
852
  under an `<h2>`, and so on:
394
853
 
395
854
  ```html
@@ -406,87 +865,75 @@ h2 Question 1 <- item card heading
406
865
  h3 Part A <- authored data-heading content in the prompt
407
866
  ```
408
867
 
409
- The two content kinds derive different levels from the same number, and the
410
- difference is deliberate. An item card's heading *is* the item's heading, so the
411
- item player is told not to emit a screen-reader item heading of its own — one at
412
- that level already exists, and a second would read as its sibling. A passage
413
- card's heading is a group label, so the passage player is told to start one level
414
- 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.
415
873
 
416
874
  Authored `data-heading="headingN"` markup in passages and prompts becomes real
417
- heading elements only when a level is published, which the player now always does.
418
- Content authored against
419
- [PIE-151](https://illuminate.atlassian.net/browse/PIE-151) therefore renders as
420
- 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.
421
877
 
422
- A host that needs the element's own screen-reader item heading — because it is not
423
- 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:
424
880
 
425
881
  ```js
426
882
  sectionPlayer.runtime = { player: { includeSrHeading: true } };
427
883
  ```
428
884
 
429
- The pattern behind this, and the reason the value is published rather than pushed,
430
- is in
431
- [`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.
432
887
 
433
888
  ### Tab styling hooks
434
889
 
435
- `pie-section-player-tabbed` and splitpane `tabbed` collapse mode expose canonical `pie-*`
436
- hooks for theming:
890
+ `pie-section-player-tabbed` and the splitpane's `tabbed` collapse mode expose
891
+ these hooks:
437
892
 
438
893
  - `pie-section-player-tabs`
439
894
  - `pie-section-player-tab`
440
895
  - `pie-section-player-tab--active`
441
896
  - `pie-section-player-tab-panel`
442
897
 
443
- For theme compatibility with existing passage-label patterns, tabs also expose:
444
-
445
- - `data-pie-purpose="passage-label"` and alias class `passage-label`
446
- - `data-pie-purpose="item-label"` and alias class `item-label`
898
+ Tabs also carry `data-pie-purpose="passage-label"` and `data-pie-purpose="item-label"`.
447
899
 
448
- Tab colors, spacing, and track geometry can be themed via CSS variables:
900
+ Tab colors, spacing and track geometry are themed through
449
901
  `--pie-section-player-tab-color`, `--pie-section-player-tab-background`,
450
902
  `--pie-section-player-tab-active-color`,
451
903
  `--pie-section-player-tab-active-background`,
452
904
  `--pie-section-player-tab-gap`,
453
905
  `--pie-section-player-tab-track-radius`,
454
- `--pie-section-player-tab-track-padding`, and
906
+ `--pie-section-player-tab-track-padding` and
455
907
  `--pie-section-player-tab-padding-block`.
456
908
 
457
909
  ### Card header styling hooks
458
910
 
459
- Passage and item cards share a common header row
911
+ Passage and item cards share a header row
460
912
  (`.pie-section-player-content-card-header`, with the card-specific aliases
461
- `.pie-section-player-passage-header` / `.pie-section-player-item-header`).
462
-
463
- - Title and toolbar are centered vertically by default. There is no prop or
464
- attribute for this — hosts needing a non-standard alignment should override
465
- the selector in their own stylesheet.
466
- - Card corners default to `8px`. Hosts/themes can override the card radius via
467
- `--pie-section-player-card-radius`.
468
- - The header fill is transparent by default. Hosts/themes opt into a color
469
- via the `--pie-section-player-card-header-background` CSS variable; the
470
- framework does not ship a brand palette.
471
- - When a header fill is provided, header top corners default just inside the
472
- card radius. Hosts/themes can override them independently via
473
- `--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.
474
925
  - Under a dark theme the header takes
475
- `--pie-section-player-card-header-background-dark` instead, falling back to
476
- `--pie-section-player-card-header-background` when it is unset. That is the
477
- hook for a host whose brand tint is legible on a light card but not on a dark
478
- one. "Dark theme" here means the selectors the theme package writes its dark
479
- tokens under: `[data-theme="dark"]` on an ancestor, or
480
- `pie-theme[theme="dark"]`. Note the `-dark` suffix means *under a dark theme*
481
- here, unlike canonical tokens such as `--pie-background-dark`, where it means
482
- a darker shade.
483
- - `pie-section-player-passage-card` also bridges
484
- `--pie-passage-header-background` to `--pie-section-player-card-header-background`,
485
- so a hosted passage-player custom element (defined outside this package)
486
- picks up the same header fill without either side hardcoding the other's
487
- token name.
488
-
489
- 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.
490
937
 
491
938
  ```css
492
939
  pie-section-player-passage-card,
@@ -498,19 +945,14 @@ pie-section-player-item-card {
498
945
  }
499
946
  ```
500
947
 
501
- When both max-width attributes are set, the with-passage cap resolves to the greater
502
- of the two configured values (after clamp), so with-passage mode never ends up narrower
503
- than no-passage mode.
504
-
505
948
  ### Split-pane backdrop
506
949
 
507
950
  The split-pane layout paints a backdrop behind each scrollable pane, under the
508
951
  passage and item cards. It reads the canonical `--pie-background-dark`, so it
509
- follows the active theme and color scheme, and there is no pane-specific hook:
510
- the backdrop is meant to stay with the theme rather than be styled per pane.
511
- The rule covers the items pane as well as the passage pane, so it is not driven
512
- from a passage-header hook. Card fills stay independent via
513
- `--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`.
514
956
 
515
957
  ### Content-card tool surfaces
516
958
 
@@ -519,208 +961,53 @@ Item and passage cards offer two content-scoped host surfaces:
519
961
  - `content-lead` is a full-width stack before the authored player content.
520
962
  - `content-media` is the resizable region beside that content.
521
963
 
522
- They are host surfaces, not features: whatever capability declares one of those
523
- names in `surfaces` may render there, and this package names no capability. A
524
- surface becomes mountable only when policy grants the capability and its own
525
- `requiresAuthoredContent` resolves, so an item or passage without the authored
526
- 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.
527
968
 
528
- Today's occupant is `@pie-players/pie-tool-sign-language` — a signed (ASL)
529
- translation gated on the `signLanguage` PNP support. It is not part of the
530
- packaged capability set: a deployment opts in by registering it on the tool
531
- 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.
532
975
 
533
- The `content-media` adapter sits to the right of the content and is resizable via a
534
- keyboard-accessible divider (`role="separator"`; arrow keys, `Home`/`End`,
535
- `Escape` to cancel a drag). Below a card width of 560px the region stacks under
536
- the content and the divider is withdrawn. Placement is fixed in this iteration:
537
- 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.
538
981
 
539
982
  This package owns the region's share of the card width and nothing inside it. A
540
- capability mounted here sizes its own content: signing legibility needs height for
541
- hands and face, so it is sized by an aspect-ratio target with a height floor
542
- rather than by width alone. The `--pie-section-player-item-media-*` tokens hosts
543
- set for that belong to `@pie-players/pie-tool-sign-language` and are documented
544
- with their defaults in [its README](../tool-sign-language/README.md) — they keep
545
- the `pie-section-player` prefix because hosts already set them by those names.
546
-
547
- All three section-player surfaces (`content-lead`, `content-media`, and
548
- `section-overlay`) share one internal Tool Surface Host. It observes live
549
- `ToolRegistry` mutations and policy/catalog changes, preserves registration
550
- order across lazy loads, synchronizes an existing element when its current
551
- context changes, and always calls `destroy()` before removing it. Surface
552
- failures are isolated per capability and emitted as recoverable
553
- `framework-error` warnings; they never block readiness or remove another
554
- working capability. A `renderSurface()` result of `null` is a normal
555
- mountable-but-unoccupied result, not an error.
556
-
557
- ### API direction: CE defaults first, JS customization for advanced cases
558
-
559
- The intended usage model is:
560
-
561
- - **CE props for default/standard flows (roughly 90% use cases)**:
562
- - `assessment-id`, `section`, `section-id`, `attempt-id`, `debug`
563
- - `show-toolbar`, `toolbar-position`, `narrow-layout-breakpoint`
564
- - `content-max-width-no-passage`, `content-max-width-with-passage`, `split-pane-min-region-width`, `split-pane-collapse-strategy`
565
- - **JS API for advanced customization**:
566
- - Get the controller handle via `getSectionController()` or `waitForSectionController()` (preferred)
567
- - Listen for `pie-stage-change` and filter on `detail.stage === "engine-ready"` for an event-driven entry point
568
- - Apply custom policy/gating in host code (for example, domain-specific `canNext` based on controller events like `section-items-complete-changed`)
569
- - Compose forward/backward eligibility in host code using `selectNavigation()` + host state; there is intentionally no separate parallel CE gating API for this
570
- - Inject custom toolbar tooling with `toolRegistry` and optional host button arrays (`sectionHostButtons`, `itemHostButtons`, `passageHostButtons`)
571
- - Register host callbacks via `hooks` (for example `hooks.cardTitleFormatter`)
572
-
573
- Example:
574
-
575
- ```ts
576
- const host = document.querySelector("pie-section-player-splitpane") as any;
577
- host.hooks = {
578
- cardTitleFormatter: (context: any) => {
579
- if (context.kind === "item") {
580
- return `Question ${context.itemIndex + 1}: ${context.item?.name || context.defaultTitle}`;
581
- }
582
- return context.passage?.name || context.defaultTitle;
583
- },
584
- };
585
- ```
586
-
587
- Advanced runtime configuration is supplied through the `runtime` object. Set player config, tools, accessibility, coordinator, env, and `createSectionController` on `runtime.<key>`.
588
-
589
- ### Backend delivery for embedded items
590
-
591
- Hosts can configure item-player backend delivery once at the section-player
592
- runtime level. Section-player derives a concrete `backend` prop for each
593
- embedded item player before it renders the item. This is intended for hosts
594
- that need server-processed PIE models and server scoring without querying every
595
- nested `<pie-item-player>`.
596
-
597
- ```ts
598
- import type {
599
- SectionPlayerRuntimeConfig,
600
- } from "@pie-players/pie-section-player";
601
-
602
- const runtime: SectionPlayerRuntimeConfig = {
603
- playerType: "iife",
604
- env: {
605
- mode: "gather",
606
- role: "student",
607
- },
608
- player: {
609
- backend: {
610
- delivery: {
611
- enabled: true,
612
- baseUrl: bffUrl,
613
- assignmentId: playerSessionId,
614
- endpoints: {
615
- load: "/api/player/load",
616
- saveSession: "/api/player/save",
617
- model: "/api/player/model",
618
- score: "/api/player/score",
619
- },
620
- autosave: { enabled: true, debounceMs: 250 },
621
- },
622
- },
623
- },
624
- };
625
-
626
- sectionPlayer.runtime = runtime;
627
- ```
628
-
629
- When `runtime.player.backend.delivery` is enabled, section-player treats
630
- `itemId` and `sessionId` as per-item delivery identity. It derives them from
631
- `canonicalItemId || item.id` and the item session before forwarding `backend` to
632
- each embedded item player. Static delivery fields such as `baseUrl`, `auth`,
633
- `endpoints`, `assignmentId`, and `autosave` are preserved. Use `assignmentId`
634
- for shared attempt/player identity.
635
-
636
- An enabled `backend.delivery` also sets `hosted: true` on each embedded item
637
- player unless `runtime.player.hosted` is set, so the item players load no
638
- element controllers and render the models the server returns.
639
-
640
- `runtime.player.resolveBackend` is a section-player-reserved key. It is called
641
- with `{ itemId, canonicalItemId, item, itemIndex, itemSession, sectionId, env,
642
- baseBackend }` and is stripped before props reach `<pie-item-player>`. Use it
643
- only when the backend needs custom per-item identity mapping. The resolver
644
- receives cloned backend objects, so per-item identity changes do not mutate the
645
- shared runtime configuration or leak across items.
646
-
647
- Section-player only derives the concrete `backend` prop. It does not call
648
- `loadFromBackend()` on nested item players. Embedded `<pie-item-player>` loads
649
- automatically when its derived `backend.delivery` config has a load signature,
650
- so every mounted item player issues one backend load for its own item. Passage
651
- players do not receive item delivery backend config, but shared non-delivery
652
- backend config is preserved.
653
-
654
- This backend delivery config is separate from the element-loader backend used
655
- for IIFE/ESM bundle preloading.
656
-
657
- ### Preloaded elements
658
-
659
- With `runtime.playerType: "preloaded"` the section player loads no element
660
- code. Its pre-warm asserts that every tag the section's items and passages name
661
- is registered, so the host registers the elements first:
662
-
663
- ```ts
664
- import { registerPreloadedElements } from "@pie-players/pie-item-player/preloaded";
665
- import * as delivery from "@pie-element/multiple-choice/browser/delivery";
666
- import * as controller from "@pie-element/multiple-choice/browser/controller";
667
- import manifest from "../package.json"; // pins "@pie-element/multiple-choice" exactly
668
-
669
- registerPreloadedElements([
670
- {
671
- tag: "pie-element-multiple-choice",
672
- package: "@pie-element/multiple-choice",
673
- version: manifest.dependencies["@pie-element/multiple-choice"],
674
- element: delivery,
675
- controller,
676
- },
677
- ]);
678
-
679
- sectionPlayer.runtime = { ...sectionPlayer.runtime, playerType: "preloaded" };
680
- ```
681
-
682
- - Register before the section player mounts. A tag missing at pre-warm leaves
683
- the items unmounted and raises a non-recoverable `element-preload` framework
684
- error.
685
- - Install element packages with `npm install --save-exact`. npm otherwise saves
686
- a caret range, which registration rejects as a `version`, and which a fresh
687
- install can resolve to another release line: `^13.4.0-next.15` resolves to
688
- the legacy `13.4.4`, which has no `./browser/*` modules.
689
- - Install every pie-elements-ng package from one release, in one install from
690
- the same dist-tag, and upgrade them together. Elements whose `./browser/*`
691
- builds typeset on `window.MathJax` share the MathJax the first of them loads,
692
- in the build and configuration of that element's release, so in a mixed set
693
- an element can typeset with a MathJax it was not built for. Elements that
694
- bundle their own MathJax share none
695
- ([One MathJax version per page](../../docs/item-player/loading-strategies.md#one-mathjax-version-per-page)).
696
- - Register one version per package. The players align every authored version
697
- of a package to the registered one, and registering a second version throws.
698
- - Register each package's `controller` unless the item players are hosted
699
- (`runtime.player.hosted`, or an enabled `runtime.player.backend.delivery`).
700
- A player that is not hosted runs `model()` in the browser and warns for each
701
- tag registered without one.
702
- - Only `@pie-element/*` builds from pie-elements-ng publish
703
- `./browser/delivery` and `./browser/controller`.
704
- - Under TypeScript, the `package.json` import needs `resolveJsonModule`, and a
705
- package version that ships no declarations for `./browser/*` needs a
706
- `declare module` shim for those subpaths.
707
-
708
- See [`strategy="preloaded"`](../../docs/item-player/loading-strategies.md#strategypreloaded)
709
- for the registration contract.
710
-
711
- ### Host-owned focus
712
-
713
- Section-player does not move focus on behalf of host-level affordances such
714
- as "Skip to Main", nor does it make passage/question containers tab stops.
715
- Hosts own page chrome, skip links, landmarks, and any special focus placement.
716
- For example, a host shell can focus its own
717
- `main#main-content`; the next Tab then follows the browser's natural order
718
- into the first actionable control rendered inside the section player.
719
-
720
- The passage and item card custom elements are content/layout surfaces, not
721
- public focus targets. Splitpane passage content remains scrollable through the
722
- pane's native scroll behavior, but the passage pane itself is not inserted into
723
- 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.
724
1011
 
725
1012
  ```html
726
1013
  <a href="#main-content" class="skip-link">Skip to Main</a>
@@ -729,164 +1016,13 @@ sequential keyboard navigation.
729
1016
  </main>
730
1017
  ```
731
1018
 
732
- **Policy fields.** The layout accepts a partial `policies` object: every
733
- unset field, a missing section included, takes its value from
734
- `DEFAULT_SECTION_PLAYER_POLICIES`.
735
-
736
- - `readiness.mode` (`"progressive"` | `"strict"`) — the mode
737
- `SectionPlayerLayoutKernel` passes to `createReadinessDetail`. Both modes
738
- hold the `interactive` stage and `pie-loading-complete` until the section's
739
- element pre-warm resolves and the item cards can mount. The kernel has no
740
- later loading signal, so the two modes currently emit the same sequence.
741
- Default: `"progressive"`.
742
- - `preload.enabled` — when `false`, `SectionItemsPane` short-circuits the
743
- section-level element warmup pipeline (`warmupSectionElements`). Items
744
- still mount and item-players register their own elements on demand. Use
745
- this to disable section pre-warm when the host already owns element
746
- registration end-to-end. Default: `true`.
747
- - `telemetry.enabled` — when `false`, the layout custom elements skip
748
- `attachInstrumentationEventBridge` setup, so no `pie-section-*`
749
- telemetry events flow through the bridge. Hosts that want a different
750
- shape of opt-out can still override `runtime.player.loaderConfig.instrumentationProvider`.
751
- Default: `true`.
752
-
753
- The exported `isPreloadEnabled(policies)` and `isTelemetryEnabled(policies)`
754
- helpers read these toggles with the documented default-true semantics, so
755
- host code that needs to mirror the same gate (e.g. when composing a custom
756
- layout host) can call them directly.
757
-
758
- ### Navigation signals
759
-
760
- - `item-selected`: item-level navigation change within the current section in the `SectionController` broadcast stream (`itemIndex`, `currentItemId`, `totalItems`).
761
- - `section-navigation-change`: section-level navigation/selection change in the `SectionController` broadcast stream (`previousSectionId`, `currentSectionId`, `reason`).
762
-
763
- Runtime configuration is explicit:
764
-
765
- - `runtime` owns runtime fields (`assessmentId`, `playerType`, `player`, `lazyInit`, `tools`, `accessibility`, `coordinator`, `isolation`, `env`, `createSectionController`).
766
- - Tool placement is configured through `runtime.tools.placement.section`, `runtime.tools.placement.item`, and `runtime.tools.placement.passage`.
767
- - Tool configuration validation is canonical in toolkit initialization (`pie-assessment-toolkit`), including toolbar overlays. Use `runtime.toolConfigStrictness` (`off` | `warn` | `error`) to control warning-only vs fail-fast behavior.
768
- - TTS provider config must use `tools.providers.textToSpeech` (canonical). `tools.providers.tts` is rejected by validation.
769
- - Host tool overrides are additive:
770
- - `toolRegistry` overrides the default toolbar registry when provided
771
- - host buttons are appended per toolbar scope via `sectionHostButtons`, `itemHostButtons`, `passageHostButtons`
772
-
773
- Debug logging can be controlled per section-player host:
774
-
775
- - Enable: `<pie-section-player-splitpane debug="true">`
776
- - Disable: `<pie-section-player-splitpane debug="false">` (or `debug="0"`)
777
-
778
- You can also disable globally via `window.PIE_DEBUG = false`.
779
-
780
- 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.
781
-
782
- ## Data flow and stability guarantees
783
-
784
- Section-player follows a unidirectional flow model:
785
-
786
- - Inputs flow downward (`runtime`, `section`, `env`, toolbar options) into base/toolkit/layout/card render paths.
787
- - State updates flow upward as events (`runtime-*`, `session-changed`, controller change events) and are reconciled by runtime owners.
788
- - Layout/card components should not create competing sources of truth for composition/session.
789
-
790
- ### Stability guarantees
791
-
792
- For non-structural updates, section-player guarantees behavior stability:
793
-
794
- - Item/passage shell identity remains stable (no remount churn for response-only updates).
795
- - Pane-local scroll position remains stable in splitpane and vertical layouts.
796
-
797
- Non-structural updates include:
798
-
799
- - response/session updates
800
- - tool toggles/config updates
801
- - runtime config changes that do not alter composition identity
802
-
803
- Structural composition changes (new/removed/reordered entities) may legitimately re-render/remount affected nodes.
804
-
805
- ## Custom layout authoring
806
-
807
- For section layout authors, `pie-section-player-shell` is the primary abstraction:
808
-
809
- - Use `pie-section-player-shell` to place the section toolbar around your layout body.
810
- - Keep your custom layout logic focused on passages/items and layout UI.
811
- - Treat `pie-section-player-base` as internal runtime plumbing that wraps the shell.
812
- - Use `pie-section-player-item-card` and `pie-section-player-passage-card` as reusable card primitives.
813
- - Prefer shared context for cross-cutting card render plumbing (resolved player tag/action) over repeated prop drilling.
814
-
815
- Minimal pattern for package layout components:
816
-
817
- ```svelte
818
- <pie-section-player-base runtime={effectiveRuntime} {section} section-id={sectionId} attempt-id={attemptId}>
819
- <pie-section-player-shell
820
- show-toolbar={showToolbar}
821
- toolbar-position={toolbarPosition}
822
- toolRegistry={toolRegistry}
823
- sectionHostButtons={sectionHostButtons}
824
- >
825
- <!-- layout-specific body -->
826
- <pie-section-player-passage-card
827
- passage={passage}
828
- playerParams={passagePlayerParams}
829
- passageToolbarTools={passageToolbarTools}
830
- toolRegistry={toolRegistry}
831
- hostButtons={passageHostButtons}
832
- ></pie-section-player-passage-card>
833
- <pie-section-player-item-card
834
- item={item}
835
- canonicalItemId={canonicalItemId}
836
- playerParams={itemPlayerParams}
837
- itemToolbarTools={itemToolbarTools}
838
- toolRegistry={toolRegistry}
839
- hostButtons={itemHostButtons}
840
- ></pie-section-player-item-card>
841
- </pie-section-player-shell>
842
- </pie-section-player-base>
843
- ```
844
-
845
- ### JS API example for advanced host policy
846
-
847
- ```ts
848
- const host = document.querySelector("pie-section-player-splitpane") as any;
849
- const controller = await host.waitForSectionController(5000);
850
- let sectionComplete = false;
851
-
852
- const unsubscribe = controller?.subscribe?.((event: any) => {
853
- if (event?.type === "section-items-complete-changed") {
854
- sectionComplete = event.complete === true;
855
- }
856
- });
857
-
858
- function canAdvance() {
859
- const nav = host.selectNavigation?.();
860
- return Boolean(nav?.canNext && sectionComplete);
861
- }
862
- ```
863
-
864
- 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:
865
-
866
- ```ts
867
- const unsubscribeItem = coordinator.subscribeItemEvents({
868
- listener: (event: any) => {
869
- // item-scoped stream
870
- },
871
- });
872
-
873
- const unsubscribeSection = coordinator.subscribeSectionLifecycleEvents({
874
- listener: (event: any) => {
875
- // section-loading-complete / section-items-complete-changed / section-error / section-navigation-change
876
- },
877
- });
878
- ```
879
-
880
- 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 any cohort exists throws.
881
-
882
- Use `subscribeSectionEvents(...)` only for advanced mixed filtering requirements.
883
-
884
- > **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.
1019
+ ## Instrumentation
885
1020
 
886
- ### Item-level observability configuration
887
-
888
- Item-level resource observability is configured on the embedded `pie-item-player` via
889
- `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.
890
1026
 
891
1027
  ```ts
892
1028
  import { ConsoleInstrumentationProvider } from "@pie-players/pie-players-shared";
@@ -910,208 +1046,68 @@ sectionPlayerEl.runtime = {
910
1046
  };
911
1047
  ```
912
1048
 
913
- Important:
914
-
915
- - `loaderOptions` controls bundle loading. `loaderConfig` controls runtime resource monitoring.
916
- - Custom providers (functions/instances) must be passed as JS properties (`runtime` object), not serialized string attributes.
917
-
918
- ### Instrumentation ownership and semantics
919
-
920
- Section-player instrumentation is provider-agnostic and uses the shared
921
- `InstrumentationProvider` contract.
922
-
923
- - Canonical provider path: `runtime.player.loaderConfig.instrumentationProvider`
924
- - With `trackPageActions: true`, missing/`undefined` providers use the default New Relic provider path.
925
- - `instrumentationProvider: null` explicitly disables instrumentation.
926
- - Invalid provider objects are ignored (optional debug warning), also no-op.
927
- - Existing `item-player` behavior is preserved.
928
- - For local debug overlays, compose providers (for example `NewRelicInstrumentationProvider` + `DebugPanelInstrumentationProvider`) through `CompositeInstrumentationProvider`.
929
- - Toolkit telemetry forwarding uses the same provider path, so tool/backend
930
- operational events are visible alongside section events when toolkit is mounted.
931
-
932
- Canonical lifecycle stream (engine-routed, dispatched on the outer layout CE):
933
-
934
- - `pie-stage-change` — single typed transition stream covering
935
- `composed` → `engine-ready` → `interactive` → `disposed`. Payload is a
936
- `StageChangeDetail`.
937
- - `pie-loading-complete` — fires once per cohort, when the section's element
938
- pre-warm resolves for the current composition and the item cards can mount
939
- (kernel-routed).
940
- - `framework-error` — canonical error event for any failure crossing the
941
- framework boundary. Payload is a `FrameworkErrorModel`. The toolkit's
942
- package-internal `FrameworkErrorBus` and the `onFrameworkError`
943
- callback prop deliver each error exactly once regardless of wrapper
944
- depth, including errors from a coordinator the host passes as
945
- `runtime.coordinator`. The `framework-error` *DOM event* on the outer
946
- layout CE also delivers each error exactly once: the kernel listener at
947
- `<pie-section-player-base>` stops the toolkit's bubbled emit, and the
948
- engine dispatches the error on the layout host without bubbling, so it
949
- does not reach `document`. `tests/section-player-event-delivery.spec.ts`
950
- pins these counts. Direct listeners attached to `<pie-assessment-toolkit>`
951
- itself still see the toolkit's own emit.
952
-
953
- Callback-prop mirrors with two-tier precedence (`runtime.<key>` wins over
954
- the top-level prop):
955
-
956
- - `onStageChange(detail)` — on every layout CE, `pie-section-player-base`,
957
- and `pie-assessment-toolkit`.
958
- - `onLoadingComplete(detail)` — on the kernel-backed layout CEs only
959
- (split-pane / vertical / tabbed / kernel-host).
960
- - `onFrameworkError(model)` — on every layout CE and
961
- `pie-section-player-base`. Fires exactly once per error regardless of
962
- wrapper depth (delivered through the package-internal
963
- `FrameworkErrorBus`). The `framework-error` DOM event on the layout
964
- CE host is also single-fire; consume either.
965
-
966
- Section-player owned instrumentation stream:
967
-
968
- - `pie-section-stage-change`
969
- - `pie-section-loading-complete`
970
- - `pie-section-framework-error`
971
- - `pie-section-element-preload-retry`
972
- - `pie-section-element-preload-error`
973
-
974
- Build consumers against these canonical lifecycle events:
975
-
976
- - `readiness-change` → listen for `pie-stage-change`. The readiness
977
- payload is also available via `selectReadiness()` /
978
- `getSnapshot().readiness` on the layout CE.
979
- - `interaction-ready` → `pie-stage-change` filtered on
980
- `detail.stage === "interactive"`.
981
- - `ready` → `pie-loading-complete`.
982
- - `section-controller-ready` → call
983
- `waitForSectionController(timeoutMs)` or `getSectionController()`
984
- on the layout CE, or filter `pie-stage-change` for
985
- `detail.stage === "engine-ready"`.
986
-
987
- If toolkit is mounted, toolkit lifecycle events are emitted on a separate
988
- `pie-toolkit-*` stream. This separation avoids semantic overlap; bridge dedupe
989
- is a defensive safety net only.
990
-
991
- Toolkit tool/backend operational stream:
992
-
993
- - `pie-tool-init-start|success|error`
994
- - `pie-tool-backend-call-start|success|error`
995
- - `pie-tool-library-load-start|success|error`
996
-
997
- ### Item session management
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.
998
1067
 
999
- Section session data can be managed either through persistence hooks or directly through the controller API.
1000
-
1001
- ```ts
1002
- const host = document.querySelector("pie-section-player-splitpane") as any;
1003
- const controller = await host.waitForSectionController(5000);
1004
-
1005
- // Read current section session snapshot.
1006
- const currentSession = controller?.getSession?.();
1007
-
1008
- // Replace section session state (resume from backend snapshot).
1009
- await controller?.applySession?.({
1010
- currentItemIndex: 0,
1011
- visitedItemIdentifiers: ["q1"],
1012
- itemSessions: {
1013
- q1: {
1014
- itemIdentifier: "q1",
1015
- pieSessionId: "q1-session",
1016
- session: { id: "q1-session", data: [{ id: "choice", value: "a" }] }
1017
- }
1018
- }
1019
- }, { mode: "replace" });
1020
-
1021
- // Update a single item session directly.
1022
- await controller?.updateItemSession?.("q1", {
1023
- session: { id: "q1-session", data: [{ id: "choice", value: "b" }] },
1024
- complete: true,
1025
- });
1026
- ```
1027
-
1028
- The same controller snapshot is what the persistence strategy saves/loads.
1029
- When a controller is reused for the same `sectionId`/`attemptId`, `updateInput()` refreshes composition input while preserving in-memory section session data.
1030
-
1031
- ### Commit at a section boundary
1032
-
1033
- A delivery element coalesces its `session-changed` dispatch, so a response the
1034
- learner has finished entering can still be pending when the section moves. The
1035
- controller commits pending element sessions before item navigation, before
1036
- `updateInput()` snapshots the session for a section swap, and before `persist()`.
1037
- The player also commits when an item shell tears down and when the page goes
1038
- hidden.
1039
-
1040
- A committed response travels like any other. A raw element `session-changed`
1041
- does not leave its `<pie-item-shell>`, which re-dispatches it as the normalized
1042
- `item-session-changed` (`PIE_ITEM_SESSION_CHANGED_EVENT`); the toolkit then
1043
- publishes the section's canonical `session-changed`. Both bubble through the
1044
- layout element to `document`, and a listener on either receives each dispatch
1045
- once.
1046
- Host code persists from the controller's events or from its session snapshot.
1047
-
1048
- Each of those events marks a commit with `sessionCommitReason`
1049
- (`"teardown" | "navigate" | "page-hidden"`): `item-session-changed`, the
1050
- toolkit's `session-changed`, and the controller's `item-session-data-changed`
1051
- and `item-session-meta-changed`. A commit at a section swap or item navigation
1052
- reports the item being left, which the host may already have moved past, so a
1053
- handler that sets its current item or navigation state from these events leaves
1054
- that state alone on a commit and persists the commit's session as usual. The
1055
- controller's events also carry `sectionId`, the section the item belongs to.
1056
-
1057
- Navigation inside a section keeps every item mounted, so nothing is discarded
1058
- and the element's own debounce would complete on its own. The commit still runs
1059
- there because the response belongs to the item being left: a host that persists
1060
- on the navigation event, or a `persist()` that follows it, would otherwise
1061
- snapshot a session the learner had already changed.
1068
+ ## Custom layout authoring
1062
1069
 
1063
- `SectionController` stays DOM-free; the player supplies the commit through
1064
- `setPendingSessionCommit()`, declared on `SectionControllerHandle`. The player
1065
- registers it both on the controllers it creates and on one the toolkit has
1066
- already built, since the first section's controller usually exists before the
1067
- player can override the factory. A host-built controller that leaves the method
1068
- unimplemented keeps the behaviour it had before the hook existed, and loses a
1069
- 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.
1070
1076
 
1071
1077
  ## Content trust boundary
1072
1078
 
1073
- Section-player layouts embed `<pie-item-player>` elements for each item.
1074
- Item and passage markup is sanitized by default via DOMPurify; see the
1075
- [pie-item-player README](../item-player/README.md#content-trust-boundary)
1076
- for the allow-list, the `trust-markup` opt-out and the `sanitizeMarkup`
1077
- property. Hosts can forward those settings through the section-player
1078
- `runtime.player` overrides — the runtime flattens these onto the embedded
1079
- `<pie-item-player>` instance, so any field not recognized by the kernel is
1080
- 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:
1081
1085
 
1082
- ```ts
1086
+ ```js
1083
1087
  const runtime = {
1084
1088
  playerType: "iife",
1085
1089
  player: {
1086
- trustMarkup: false, // default, keeps DOMPurify on
1090
+ trustMarkup: false, // the default: DOMPurify stays on
1087
1091
  // sanitizeMarkup: (html) => myCustomSanitize(html),
1088
1092
  },
1089
1093
  };
1090
1094
  ```
1091
1095
 
1092
- Set `trustMarkup: true` only when the section payload is guaranteed to be
1093
- 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.
1094
1099
 
1095
1100
  ## Exports
1096
1101
 
1097
- Published exports are intentionally minimal:
1098
-
1099
- - `@pie-players/pie-section-player`
1100
- - `@pie-players/pie-section-player/components/section-player-splitpane-element`
1101
- - `@pie-players/pie-section-player/components/section-player-vertical-element`
1102
- - `@pie-players/pie-section-player/components/section-player-tabbed-element`
1103
- - `@pie-players/pie-section-player/components/section-player-kernel-host-element`
1104
- - `@pie-players/pie-section-player/components/section-player-shell-element`
1105
- - `@pie-players/pie-section-player/components/section-player-item-card-element`
1106
- - `@pie-players/pie-section-player/components/section-player-passage-card-element`
1107
- - `@pie-players/pie-section-player/components/section-player-items-pane-element`
1108
- - `@pie-players/pie-section-player/components/section-player-passages-pane-element`
1109
- - `@pie-players/pie-section-player/contracts/layout-contract`
1110
- - `@pie-players/pie-section-player/contracts/public-events`
1111
- - `@pie-players/pie-section-player/contracts/runtime-host-contract`
1112
- - `@pie-players/pie-section-player/contracts/layout-parity-metadata`
1113
- - `@pie-players/pie-section-player/contracts/host-hooks`
1114
- - `@pie-players/pie-section-player/policies`
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 |
1115
1111
 
1116
1112
  ## Development
1117
1113