@pie-players/pie-section-player
v0.3.50
Published
Web component for rendering QTI 3.0 assessment sections with passages and items
Readme
@pie-players/pie-section-player
Section rendering package with layout custom elements:
pie-section-player-splitpanepie-section-player-verticalpie-section-player-tabbed
Use the layout custom elements listed above for section rendering.
Install
npm install @pie-players/pie-section-playerRuntime boundary and migration
- Browser-only package:
@pie-players/pie-section-playerregisters custom elements and is intended for browser/DOM hosts, not plain Node runtime imports. - Node-import-safe packages (for server/runtime utilities) are documented in
docs/setup/library-packaging-strategy.md. - Migration direction: prefer the stable default entry for side-effect registration:
import "@pie-players/pie-section-player";If hosts need explicit registration control, keep using documented component
entrypoints under @pie-players/pie-section-player/components/*.
Standalone browser variants for this package are intentionally deferred; the
current supported contract is the default bundler entrypoints under dist.
SectionController
SectionController is the domain authority inside a section player. It owns
in-section navigation state, the canonical aggregation of per-item sessions,
and the host-facing persistence snapshot. The layout custom elements
(pie-section-player-splitpane / -vertical / -tabbed) are transport
adapters around it. See
docs/section-player/controller-boundaries.md
for the rationale behind that split, and
docs/section-player/client-architecture-tutorial.md
for the end-to-end walkthrough.
The handle implements SectionControllerHandle from
@pie-players/pie-assessment-toolkit; see the JSDoc on that interface for
the per-method contract.
Obtaining the handle
const host = document.querySelector("pie-section-player-splitpane") as any;
const controller = await host.waitForSectionController?.(5000);waitForSectionController(timeoutMs) resolves when the layout CE has wired
its controller (the same anchor pie-stage-change reaches with
detail.stage === "engine-ready"). Use getSectionController() if you've
already passed the readiness anchor synchronously.
Session lifecycle
A typical host flow:
controller?.configureSessionPersistence?.({ context, strategy });
await controller?.hydrate?.();
const unsubscribe = controller?.subscribe?.(handleEvent);
// ...later, on save / unload:
await controller?.persist?.();
unsubscribe?.();getSession() / applySession(session, { mode }) / updateItemSession(itemId,
detail) are the direct read/write surfaces and exchange the same
SectionControllerSessionState shape the persistence strategy load/save
methods receive. See Item session management for
worked examples.
Event stream
The controller's typed event stream (SectionControllerEvent discriminated
union) is the single source of truth for in-section change. Hosts usually
subscribe through ToolkitCoordinator.subscribeItemEvents /
subscribeSectionLifecycleEvents (cohort-aware filtering, survives
navigation) — see JS API example for advanced host
policy. Key event types:
item-selected— item navigation within the current section.item-session-data-changed/item-session-meta-changed— per-item session updates the persistence layer should observe.content-loaded— passage / item / rubric finished loading.section-loading-complete— every renderable in the section finished loading.section-items-complete-changed— aggregate completion flip.section-navigation-change— the controller's section identity changed.
Usage
Import the custom-element registration entrypoint in consumers:
import '@pie-players/pie-section-player/components/section-player-splitpane-element';
import '@pie-players/pie-section-player/components/section-player-vertical-element';
import '@pie-players/pie-section-player/components/section-player-tabbed-element';
import '@pie-players/pie-section-player/components/section-player-item-card-element';
import '@pie-players/pie-section-player/components/section-player-passage-card-element';Render in HTML/Svelte/JSX:
<pie-section-player-splitpane></pie-section-player-splitpane>Set complex values (runtime, section, env) as JS properties.
Runtime Inputs
Both layout elements support:
runtime(object): primary coordinator/tools/player runtime bundlesection(object): assessment section payloadenv(object): optional top-level override for{ mode, role }debug(boolean-like): verbose debug logging control ("true"enables,"false"/"0"disables)toolbar-position(string):top|right|bottom|left|nonenarrow-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.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).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).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.)split-pane-collapse-strategy(string, optional): splitpane stacked-mode strategy. Supported values:tabbed(default) andvertical. (Ignored by vertical/tabbed layouts; supported for API parity.)show-toolbar(boolean-like): acceptstrue/falseand common string forms ("true","false","1","0","yes","no")- Host extension props (JS properties only):
toolRegistry,sectionHostButtons,itemHostButtons,passageHostButtons,hooks
When viewport width is within the collapsed range (~1100px and below), splitpane and
vertical layout hosts normalize section toolbar placement to top. This includes
left, right, bottom, and none values.
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.
To opt into PIE-117 dimensions from a host, configure:
<pie-section-player-splitpane
content-max-width-no-passage="800"
content-max-width-with-passage="1200"
split-pane-min-region-width="280"
></pie-section-player-splitpane>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.
To force splitpane stacked mode to use vertical rendering:
<pie-section-player-splitpane
narrow-layout-breakpoint="1100"
split-pane-collapse-strategy="vertical"
></pie-section-player-splitpane>By default, splitpane stacked mode uses tabs. The dedicated pie-section-player-tabbed layout also always renders passage/items tabs when passages are present.
Tab styling hooks
pie-section-player-tabbed and splitpane tabbed collapse mode expose canonical pie-*
hooks for theming:
pie-section-player-tabspie-section-player-tabpie-section-player-tab--activepie-section-player-tab-panel
For theme compatibility with existing passage-label patterns, tabs also expose:
data-pie-purpose="passage-label"and alias classpassage-labeldata-pie-purpose="item-label"and alias classitem-label
Tab colors/spacing can be themed via CSS variables such as:
--pie-section-player-tab-color, --pie-section-player-tab-active-color,
--pie-section-player-tab-indicator-color, and --pie-section-player-tab-spacing.
Card header styling hooks
Passage and item cards share a common header row
(.pie-section-player-content-card-header, with the card-specific aliases
.pie-section-player-passage-header / .pie-section-player-item-header).
- Title and toolbar are centered vertically by default. There is no prop or attribute for this — hosts needing a non-standard alignment should override the selector in their own stylesheet.
- The header fill is transparent by default. Hosts/themes opt into a color
via the
--pie-section-player-card-header-backgroundCSS variable; the framework does not ship a brand palette.
Example (host CSS):
pie-section-player-passage-card,
pie-section-player-item-card {
--pie-section-player-card-header-background: #c9e5e6;
}When both max-width attributes are set, the with-passage cap resolves to the greater of the two configured values (after clamp), so with-passage mode never ends up narrower than no-passage mode.
API direction: CE defaults first, JS customization for advanced cases
The intended usage model is:
- CE props for default/standard flows (roughly 90% use cases):
assessment-id,section,section-id,attempt-id,debugshow-toolbar,toolbar-position,narrow-layout-breakpointcontent-max-width-no-passage,content-max-width-with-passage,split-pane-min-region-width,split-pane-collapse-strategy
- JS API for advanced customization:
- Get the controller handle via
getSectionController()orwaitForSectionController()(preferred) - Listen for
pie-stage-changeand filter ondetail.stage === "engine-ready"for an event-driven entry point - Apply custom policy/gating in host code (for example, domain-specific
canNextbased on controller events likesection-items-complete-changed) - Compose forward/backward eligibility in host code using
selectNavigation()+ host state; there is intentionally no separate parallel CE gating API for this - Inject custom toolbar tooling with
toolRegistryand optional host button arrays (sectionHostButtons,itemHostButtons,passageHostButtons) - Register host callbacks via
hooks(for examplehooks.cardTitleFormatter)
- Get the controller handle via
Example:
const host = document.querySelector("pie-section-player-splitpane") as any;
host.hooks = {
cardTitleFormatter: (context: any) => {
if (context.kind === "item") {
return `Question ${context.itemIndex + 1}: ${context.item?.name || context.defaultTitle}`;
}
return context.passage?.name || context.defaultTitle;
},
};Advanced runtime configuration is supplied through the runtime object. Set player config, tools, accessibility, coordinator, env, and createSectionController on runtime.<key>.
Host-owned focus
Section-player does not move focus on behalf of host-level affordances such
as "Skip to Main", nor does it make passage/question containers tab stops.
Hosts own page chrome, skip links, landmarks, and any special focus placement.
For example, Quiz Engine's Fixed Player shell can focus its own
main#main-content; the next Tab then follows the browser's natural order
into the first actionable control rendered inside the section player.
The passage and item card custom elements are content/layout surfaces, not public focus targets. Splitpane passage content remains scrollable through the pane's native scroll behavior, but the passage pane itself is not inserted into sequential keyboard navigation.
<a href="#main-content" class="skip-link">Skip to Main</a>
<main id="main-content" tabindex="-1">
<pie-section-player-splitpane></pie-section-player-splitpane>
</main>Wired policy toggles. Each SectionPlayerPolicies field has a real
runtime effect; nothing in this surface is decorative.
readiness.mode("progressive"|"strict") — drives readiness-event emission viacreateReadinessDetailinSectionPlayerLayoutKernel.preload.enabled— whenfalse,SectionItemsPaneshort-circuits the section-level element warmup pipeline (warmupSectionElements). Items still mount and item-players register their own elements on demand. Use this to disable section pre-warm when the host already owns element registration end-to-end. Default:true.telemetry.enabled— whenfalse, the layout custom elements skipattachInstrumentationEventBridgesetup, so nopie-section-*telemetry events flow through the bridge. Hosts that want a different shape of opt-out can still overrideruntime.player.loaderConfig.instrumentationProvider. Default:true.
The exported isPreloadEnabled(policies) and isTelemetryEnabled(policies)
helpers read these toggles with the documented default-true semantics, so
host code that needs to mirror the same gate (e.g. when composing a custom
layout host) can call them directly.
Navigation signals
item-selected: item-level navigation change within the current section in theSectionControllerbroadcast stream (itemIndex,currentItemId,totalItems).section-navigation-change: section-level navigation/selection change in theSectionControllerbroadcast stream (previousSectionId,currentSectionId,reason).
Runtime configuration is explicit:
runtimeowns runtime fields (assessmentId,playerType,player,lazyInit,tools,accessibility,coordinator,isolation,env,createSectionController).- Tool placement is configured through
runtime.tools.placement.section,runtime.tools.placement.item, andruntime.tools.placement.passage. - Tool configuration validation is canonical in toolkit initialization (
pie-assessment-toolkit), including toolbar overlays. Useruntime.toolConfigStrictness(off|warn|error) to control warning-only vs fail-fast behavior. - TTS provider config must use
tools.providers.textToSpeech(canonical).tools.providers.ttsis rejected by validation. - Host tool overrides are additive:
toolRegistryoverrides the default toolbar registry when provided- host buttons are appended per toolbar scope via
sectionHostButtons,itemHostButtons,passageHostButtons
Debug logging can be controlled per section-player host:
- Enable:
<pie-section-player-splitpane debug="true"> - Disable:
<pie-section-player-splitpane debug="false">(ordebug="0")
You can also disable globally via window.PIE_DEBUG = false.
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.
Data flow and stability guarantees
Section-player follows a unidirectional flow model:
- Inputs flow downward (
runtime,section,env, toolbar options) into base/toolkit/layout/card render paths. - State updates flow upward as events (
runtime-*,session-changed, controller change events) and are reconciled by runtime owners. - Layout/card components should not create competing sources of truth for composition/session.
Stability guarantees
For non-structural updates, section-player guarantees behavior stability:
- Item/passage shell identity remains stable (no remount churn for response-only updates).
- Pane-local scroll position remains stable in splitpane and vertical layouts.
Non-structural updates include:
- response/session updates
- tool toggles/config updates
- runtime config changes that do not alter composition identity
Structural composition changes (new/removed/reordered entities) may legitimately re-render/remount affected nodes.
Custom layout authoring
For section layout authors, pie-section-player-shell is the primary abstraction:
- Use
pie-section-player-shellto place the section toolbar around your layout body. - Keep your custom layout logic focused on passages/items and layout UI.
- Treat
pie-section-player-baseas internal runtime plumbing that wraps the shell. - Use
pie-section-player-item-cardandpie-section-player-passage-cardas reusable card primitives. - Prefer shared context for cross-cutting card render plumbing (resolved player tag/action) over repeated prop drilling.
Minimal pattern for package layout components:
<pie-section-player-base runtime={effectiveRuntime} {section} section-id={sectionId} attempt-id={attemptId}>
<pie-section-player-shell
show-toolbar={showToolbar}
toolbar-position={toolbarPosition}
toolRegistry={toolRegistry}
sectionHostButtons={sectionHostButtons}
>
<!-- layout-specific body -->
<pie-section-player-passage-card
passage={passage}
playerParams={passagePlayerParams}
passageToolbarTools={passageToolbarTools}
toolRegistry={toolRegistry}
hostButtons={passageHostButtons}
></pie-section-player-passage-card>
<pie-section-player-item-card
item={item}
canonicalItemId={canonicalItemId}
playerParams={itemPlayerParams}
itemToolbarTools={itemToolbarTools}
toolRegistry={toolRegistry}
hostButtons={itemHostButtons}
></pie-section-player-item-card>
</pie-section-player-shell>
</pie-section-player-base>JS API example for advanced host policy
const host = document.querySelector("pie-section-player-splitpane") as any;
const controller = await host.waitForSectionController?.(5000);
let sectionComplete = false;
const unsubscribe = controller?.subscribe?.((event: any) => {
if (event?.type === "section-items-complete-changed") {
sectionComplete = event.complete === true;
}
});
function canAdvance() {
const nav = host.selectNavigation?.();
return Boolean(nav?.canNext && sectionComplete);
}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:
const unsubscribeItem = coordinator.subscribeItemEvents({
listener: (event: any) => {
// item-scoped stream
},
});
const unsubscribeSection = coordinator.subscribeSectionLifecycleEvents({
listener: (event: any) => {
// section-loading-complete / section-items-complete-changed / section-error / section-navigation-change
},
});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.
Use subscribeSectionEvents(...) only for advanced mixed filtering requirements.
Upgrading from
<0.3.35? ThesectionId/attemptIdarguments onsubscribeItemEvents/subscribeSectionLifecycleEvents/subscribeSectionEventswere 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-toolkitfor the full upgrade recipe.
Item-level observability configuration
Item-level resource observability is configured on the embedded pie-item-player via
loaderConfig. In section-player integrations, pass this through runtime.player.loaderConfig.
import { ConsoleInstrumentationProvider } from "@pie-players/pie-players-shared";
const provider = new ConsoleInstrumentationProvider({ useColors: true });
await provider.initialize({ debug: true });
sectionPlayerEl.runtime = {
playerType: "esm",
player: {
loaderConfig: {
trackPageActions: true,
instrumentationProvider: provider,
maxResourceRetries: 3,
resourceRetryDelay: 500,
},
loaderOptions: {
esmCdnUrl: "https://cdn.jsdelivr.net/npm",
},
},
};Important:
loaderOptionscontrols bundle loading.loaderConfigcontrols runtime resource monitoring.- Custom providers (functions/instances) must be passed as JS properties (
runtimeobject), not serialized string attributes.
Instrumentation ownership and semantics
Section-player instrumentation is provider-agnostic and uses the shared
InstrumentationProvider contract.
- Canonical provider path:
runtime.player.loaderConfig.instrumentationProvider - With
trackPageActions: true, missing/undefinedproviders use the default New Relic provider path. instrumentationProvider: nullexplicitly disables instrumentation.- Invalid provider objects are ignored (optional debug warning), also no-op.
- Existing
item-playerbehavior is preserved. - For local debug overlays, compose providers (for example
NewRelicInstrumentationProvider+DebugPanelInstrumentationProvider) throughCompositeInstrumentationProvider. - Toolkit telemetry forwarding uses the same provider path, so tool/backend operational events are visible alongside section events when toolkit is mounted.
Canonical lifecycle stream (engine-routed, dispatched on the outer layout CE):
pie-stage-change— single typed transition stream coveringcomposed→engine-ready→interactive→disposed. Payload is aStageChangeDetail.pie-loading-complete— fires once per cohort when every item has loaded (kernel-routed; gated oninteractive).framework-error— canonical error event for any failure crossing the framework boundary. Payload is aFrameworkErrorModel. The toolkit's package-internalFrameworkErrorBusand theonFrameworkErrorcallback prop deliver each error exactly once regardless of wrapper depth. Theframework-errorDOM event on the outer layout CE also delivers each error exactly once: the kernel listener at<pie-section-player-base>intercepts the toolkit's bubbled emit and callsevent.stopPropagation(), leaving only the canonical engine-bridge emit on the layout host. The single-emit contract is pinned bytests/section-player-framework-error-dual-emit.test.ts. Direct listeners attached to<pie-assessment-toolkit>itself still see the toolkit's own emit.
Callback-prop mirrors with two-tier precedence (runtime.<key> wins over
the top-level prop):
onStageChange(detail)— on every layout CE,pie-section-player-base, andpie-assessment-toolkit.onLoadingComplete(detail)— on the kernel-backed layout CEs only (split-pane / vertical / tabbed / kernel-host).onFrameworkError(model)— on every layout CE andpie-section-player-base. Fires exactly once per error regardless of wrapper depth (delivered through the package-internalFrameworkErrorBus). Theframework-errorDOM event on the layout CE host is also single-fire; consume either.
Section-player owned instrumentation stream:
pie-section-stage-changepie-section-loading-completepie-section-session-changedpie-section-composition-changedpie-section-framework-error
Build consumers against these canonical lifecycle events:
readiness-change→ listen forpie-stage-change. The readiness payload is also available viaselectReadiness()/getSnapshot().readinesson the layout CE.interaction-ready→pie-stage-changefiltered ondetail.stage === "interactive".ready→pie-loading-complete.section-controller-ready→ callwaitForSectionController(timeoutMs)orgetSectionController()on the layout CE, or filterpie-stage-changefordetail.stage === "engine-ready".
If toolkit is mounted, toolkit lifecycle events are emitted on a separate
pie-toolkit-* stream. This separation avoids semantic overlap; bridge dedupe
is a defensive safety net only.
Toolkit tool/backend operational stream:
pie-tool-init-start|success|errorpie-tool-backend-call-start|success|errorpie-tool-library-load-start|success|error
Item session management
Section session data can be managed either through persistence hooks or directly through the controller API.
const host = document.querySelector("pie-section-player-splitpane") as any;
const controller = await host.waitForSectionController?.(5000);
// Read current section session snapshot.
const currentSession = controller?.getSession?.();
// Replace section session state (resume from backend snapshot).
await controller?.applySession?.({
currentItemIndex: 0,
visitedItemIdentifiers: ["q1"],
itemSessions: {
q1: {
itemIdentifier: "q1",
pieSessionId: "q1-session",
session: { id: "q1-session", data: [{ id: "choice", value: "a" }] }
}
}
}, { mode: "replace" });
// Update a single item session directly.
await controller?.updateItemSession?.("q1", {
session: { id: "q1-session", data: [{ id: "choice", value: "b" }] },
complete: true,
});The same controller snapshot is what the persistence strategy saves/loads.
When a controller is reused for the same sectionId/attemptId, updateInput() refreshes composition input while preserving in-memory section session data.
Content trust boundary
Section-player layouts embed <pie-item-player> elements for each item.
Item and passage markup is sanitized by default via DOMPurify; see
pie-item-player README
property. Hosts can forward those settings through the section-player
runtime.player overrides — the runtime flattens these onto the embedded
<pie-item-player> instance, so any field not recognized by the kernel is
passed straight through as a prop/attribute:
const runtime = {
playerType: "iife",
player: {
trustMarkup: false, // default, keeps DOMPurify on
// sanitizeMarkup: (html) => myCustomSanitize(html),
},
};Set trustMarkup: true only when the section payload is guaranteed to be
produced by a trusted pipeline.
Exports
Published exports are intentionally minimal:
@pie-players/pie-section-player@pie-players/pie-section-player/components/section-player-splitpane-element@pie-players/pie-section-player/components/section-player-vertical-element@pie-players/pie-section-player/components/section-player-tabbed-element@pie-players/pie-section-player/components/section-player-kernel-host-element@pie-players/pie-section-player/components/section-player-shell-element@pie-players/pie-section-player/components/section-player-item-card-element@pie-players/pie-section-player/components/section-player-passage-card-element@pie-players/pie-section-player/components/section-player-items-pane-element@pie-players/pie-section-player/components/section-player-passages-pane-element@pie-players/pie-section-player/contracts/layout-contract@pie-players/pie-section-player/contracts/public-events@pie-players/pie-section-player/contracts/runtime-host-contract@pie-players/pie-section-player/contracts/layout-parity-metadata@pie-players/pie-section-player/contracts/host-hooks@pie-players/pie-section-player/policies
Development
bun run --cwd packages/section-player dev
bun run --cwd packages/section-player check
bun run --cwd packages/section-player build