@pie-players/pie-players-shared 0.3.72 → 0.3.74

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 (123) hide show
  1. package/README.md +27 -1
  2. package/dist/components/vendor/nds/README.md +7 -5
  3. package/dist/config/profile.d.ts +0 -2
  4. package/dist/config/profile.js +2 -8
  5. package/dist/i18n/index.d.ts +2 -3
  6. package/dist/i18n/index.js +2 -3
  7. package/dist/i18n/messages/en-US.d.ts +10 -0
  8. package/dist/i18n/messages/en-US.js +12 -2
  9. package/dist/i18n/messages/nl-NL.d.ts +6 -0
  10. package/dist/i18n/messages/nl-NL.js +8 -2
  11. package/dist/i18n/provider.d.ts +4 -5
  12. package/dist/i18n/provider.js +4 -5
  13. package/dist/index.d.ts +4 -2
  14. package/dist/index.js +3 -1
  15. package/dist/instrumentation/index.d.ts +5 -4
  16. package/dist/instrumentation/index.js +5 -4
  17. package/dist/instrumentation/new-relic-agent.d.ts +20 -0
  18. package/dist/instrumentation/new-relic-agent.js +24 -0
  19. package/dist/instrumentation/providers/BaseInstrumentationProvider.d.ts +5 -4
  20. package/dist/instrumentation/providers/NewRelicInstrumentationProvider.d.ts +7 -7
  21. package/dist/instrumentation/providers/NewRelicInstrumentationProvider.js +21 -32
  22. package/dist/instrumentation/types.d.ts +7 -3
  23. package/dist/loader-config.d.ts +16 -6
  24. package/dist/loaders/controller-loading.d.ts +14 -0
  25. package/dist/loaders/controller-loading.js +12 -0
  26. package/dist/loaders/defaults.d.ts +10 -0
  27. package/dist/loaders/defaults.js +10 -0
  28. package/dist/loaders/element-loader.d.ts +20 -8
  29. package/dist/loaders/element-loader.js +102 -36
  30. package/dist/loaders/element-package-policy.d.ts +2 -0
  31. package/dist/loaders/element-package-policy.js +2 -1
  32. package/dist/loaders/esm-adapter.d.ts +18 -0
  33. package/dist/loaders/esm-adapter.js +617 -102
  34. package/dist/loaders/iife-adapter.d.ts +0 -5
  35. package/dist/loaders/iife-adapter.js +3 -9
  36. package/dist/loaders/index.d.ts +10 -3
  37. package/dist/loaders/index.js +8 -3
  38. package/dist/loaders/mathjax-version-conflict.d.ts +17 -0
  39. package/dist/loaders/mathjax-version-conflict.js +51 -0
  40. package/dist/loaders/module-shim.d.ts +12 -0
  41. package/dist/loaders/module-shim.js +12 -0
  42. package/dist/loaders/preloaded-alignment.d.ts +24 -0
  43. package/dist/loaders/preloaded-alignment.js +57 -0
  44. package/dist/loaders/preloaded-authored-tags.d.ts +26 -0
  45. package/dist/loaders/preloaded-authored-tags.js +69 -0
  46. package/dist/loaders/preloaded-registration.d.ts +56 -0
  47. package/dist/loaders/preloaded-registration.js +129 -0
  48. package/dist/media/index.d.ts +69 -0
  49. package/dist/media/index.js +194 -0
  50. package/dist/pie/authoring.d.ts +1 -1
  51. package/dist/pie/authoring.js +25 -18
  52. package/dist/pie/element-announcements.d.ts +39 -0
  53. package/dist/pie/element-announcements.js +67 -0
  54. package/dist/pie/element-observer.d.ts +3 -1
  55. package/dist/pie/element-observer.js +1 -0
  56. package/dist/pie/index.d.ts +6 -4
  57. package/dist/pie/index.js +6 -3
  58. package/dist/pie/initialization.js +13 -22
  59. package/dist/pie/initialize-element.d.ts +3 -0
  60. package/dist/pie/initialize-element.js +3 -4
  61. package/dist/pie/instrumentation-event-map.js +2 -9
  62. package/dist/pie/instrumentation-provider-resolution.d.ts +2 -1
  63. package/dist/pie/instrumentation-provider-resolution.js +0 -1
  64. package/dist/pie/item-session-contract.d.ts +47 -0
  65. package/dist/pie/item-session-contract.js +196 -9
  66. package/dist/pie/markup-math.d.ts +20 -0
  67. package/dist/pie/markup-math.js +118 -0
  68. package/dist/pie/math-rendering.d.ts +10 -21
  69. package/dist/pie/math-rendering.js +50 -35
  70. package/dist/pie/registry.d.ts +13 -1
  71. package/dist/pie/registry.js +39 -1
  72. package/dist/pie/resource-monitor.d.ts +6 -33
  73. package/dist/pie/resource-monitor.js +14 -213
  74. package/dist/pie/scoring.d.ts +11 -2
  75. package/dist/pie/scoring.js +18 -16
  76. package/dist/pie/session-commit.d.ts +121 -0
  77. package/dist/pie/session-commit.js +420 -0
  78. package/dist/pie/session-snapshot.d.ts +80 -0
  79. package/dist/pie/session-snapshot.js +166 -0
  80. package/dist/pie/tag-names.d.ts +1 -0
  81. package/dist/pie/tag-names.js +1 -0
  82. package/dist/pie/types.d.ts +4 -5
  83. package/dist/pie/types.js +1 -0
  84. package/dist/pie/updates.d.ts +2 -2
  85. package/dist/pie/updates.js +33 -15
  86. package/dist/pie/versioned-tag.d.ts +4 -0
  87. package/dist/pie/versioned-tag.js +4 -0
  88. package/dist/security/index.d.ts +2 -3
  89. package/dist/security/index.js +2 -3
  90. package/dist/security/sanitize-item-markup.d.ts +0 -2
  91. package/dist/security/sanitize-item-markup.js +0 -4
  92. package/dist/security/sanitize-svg-icon.d.ts +0 -2
  93. package/dist/security/sanitize-svg-icon.js +0 -5
  94. package/dist/security/wrap-overwide.d.ts +5 -0
  95. package/dist/security/wrap-overwide.js +16 -0
  96. package/dist/timed-media/policy.js +5 -16
  97. package/dist/types/index.d.ts +30 -311
  98. package/dist/types/index.js +0 -68
  99. package/dist/ui/attribute-coercion.d.ts +12 -0
  100. package/dist/ui/attribute-coercion.js +33 -0
  101. package/dist/ui/content-styles.d.ts +18 -16
  102. package/dist/ui/content-styles.js +67 -36
  103. package/dist/ui/overlay-containment.d.ts +31 -0
  104. package/dist/ui/overlay-containment.js +63 -0
  105. package/dist/ui/overlay-placement.d.ts +110 -0
  106. package/dist/ui/overlay-placement.js +222 -0
  107. package/dist/ui/pointer-gesture.d.ts +32 -0
  108. package/dist/ui/pointer-gesture.js +56 -0
  109. package/dist/ui/pointer-rotate.d.ts +29 -0
  110. package/dist/ui/pointer-rotate.js +39 -0
  111. package/package.json +21 -17
  112. package/dist/pie/component-context.d.ts +0 -21
  113. package/dist/pie/component-context.js +0 -29
  114. package/dist/pie/player-initializer.d.ts +0 -54
  115. package/dist/pie/player-initializer.js +0 -122
  116. package/dist/security/wrap-model-rich-content.d.ts +0 -1
  117. package/dist/security/wrap-model-rich-content.js +0 -40
  118. package/dist/server/npm-auth-env.d.ts +0 -13
  119. package/dist/server/npm-auth-env.js +0 -64
  120. package/dist/server/npm-registry.d.ts +0 -7
  121. package/dist/server/npm-registry.js +0 -59
  122. package/dist/ui/debug-panel-persistence.d.ts +0 -17
  123. package/dist/ui/debug-panel-persistence.js +0 -37
@@ -10,9 +10,19 @@ import { findOrAddSession } from "./utils.js";
10
10
  // Create module-level logger (respects global debug flag - pass function for dynamic checking)
11
11
  const logger = createPieLogger("pie-scoring", () => isGlobalDebugEnabled());
12
12
  /**
13
- * Find the controller for a PIE element
13
+ * Find the controller for a PIE element.
14
+ *
15
+ * `deliveryBundleType` is the bundle type of the player asking. A `player.js`
16
+ * delivery, which is what a hosted player is, resolves no controller: its
17
+ * models and scores come from the server, and the registry is shared with
18
+ * every other loader on the page, so a registered controller is no evidence
19
+ * that this player may run one.
14
20
  */
15
- export const findPieController = (elementName) => {
21
+ export const findPieController = (elementName, deliveryBundleType) => {
22
+ if (deliveryBundleType === BundleType.player) {
23
+ logger.debug(`[findPieController] ℹ️ ${elementName} delivered as player.js; using server-processed models`);
24
+ return undefined;
25
+ }
16
26
  const registry = pieRegistry();
17
27
  logger.debug(`[findPieController] Looking for controller for: ${elementName}`);
18
28
  logger.debug(`[findPieController] Registry has entries for:`, Object.keys(registry));
@@ -30,20 +40,12 @@ export const findPieController = (elementName) => {
30
40
  controllerKeys: entry.controller ? Object.keys(entry.controller) : [],
31
41
  bundleType: entry.bundleType,
32
42
  });
33
- const controller = entry.controller;
43
+ // Registration writes `null` for a package without a controller.
44
+ const controller = entry.controller ?? undefined;
34
45
  if (!controller) {
35
- // Check if missing controller is expected based on bundle type
36
- if (entry.bundleType === BundleType.clientPlayer) {
37
- // client-player.js MUST have controllers
38
- logger.error(`[findPieController] ❌ CRITICAL: Registry entry exists for ${elementName} but controller is missing!`);
39
- logger.error(`[findPieController] Bundle type: ${entry.bundleType} (controllers required)`);
40
- logger.error(`[findPieController] Entry:`, entry);
41
- throw new Error(`No controller found for ${elementName}. client-player.js bundles MUST include controllers. Check bundle loading and registration.`);
42
- }
43
- else {
44
- // player.js doesn't have controllers - this is expected
45
- logger.debug(`[findPieController] ℹ️ No controller for ${elementName} - using server-processed models (player.js bundle)`);
46
- }
46
+ // A package without one, such as a legacy `@pie-element/protractor`,
47
+ // renders the model it is given, as `<pie-player>` passed it through.
48
+ logger.debug(`[findPieController] ℹ️ No controller for ${elementName}; its model is used as given`);
47
49
  }
48
50
  else {
49
51
  logger.debug(`[findPieController] ✅ Controller found for ${elementName} with functions:`, Object.keys(controller));
@@ -85,7 +87,7 @@ export async function scorePieItem(config, sessionData, options = {}) {
85
87
  logger.debug("found pieEl %O for model id %s", pieEl, model.id);
86
88
  const session = findOrAddSession(sessionData, model.id, model.element);
87
89
  if (pieEl) {
88
- const controller = findPieController(pieEl.localName);
90
+ const controller = findPieController(pieEl.localName, options.bundleType);
89
91
  if (controller?.outcome) {
90
92
  return {
91
93
  ...session,
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Final session commit at a player seam.
3
+ *
4
+ * A delivery element coalesces its `session-changed` dispatch, so a response the
5
+ * learner finished entering can still be pending when the player is about to
6
+ * discard the element. Losing it produces no event, which leaves a host with
7
+ * nothing to detect.
8
+ *
9
+ * Reach, per seam:
10
+ *
11
+ * - `navigate` and `page-hidden` run while the elements are still attached, so
12
+ * the event reaches a `document`-level listener. Quiz Engine listens on
13
+ * `document`.
14
+ * - `teardown` runs from the player's own destroy, which a custom element only
15
+ * learns about in `disconnectedCallback` - after removal. The event reaches
16
+ * the player's own backend save and any listener bound directly to the player
17
+ * element, and it does not reach `document`. An SPA host that unmounts the
18
+ * player is served by `commitPendingSessions()` on the player element, called
19
+ * before the unmount.
20
+ * - Ahead of all three, `flushPendingSessionNotifications` runs when focus
21
+ * leaves a player, so the event lands before the host acts on the click or
22
+ * key that moved it, and carries no reason.
23
+ *
24
+ * Two paths, per element:
25
+ *
26
+ * - An element that owns its notification exposes `commitPendingSession()`, so
27
+ * it dispatches its own event with its own `complete` semantics. The sweep
28
+ * marks that event with `detail.sessionCommitReason`, which the element
29
+ * cannot do for itself and which every guard in a `session-changed`'s way
30
+ * keys on.
31
+ * - An older element gets a synthesized `session-changed` built from its
32
+ * `session` getter. Those elements write the session synchronously and defer
33
+ * only the dispatch, so the read is never older than what the host was last
34
+ * told.
35
+ *
36
+ * Duplicated deliberately into `pie-player-components` and `pie-api-components`,
37
+ * which cannot depend on this package. Changes belong here first; the copies are
38
+ * `src/utils/session-commit.ts` and `src/session-commit.ts` respectively.
39
+ */
40
+ import type { PieLogger } from "./logger.js";
41
+ /**
42
+ * Commit hook a delivery element installs when it owns a deferred
43
+ * `session-changed`. Owned by `@pie-element/shared-player-events`
44
+ * (`createSessionNotifier`); the name is duplicated rather than imported so the
45
+ * players carry no dependency on an element-repo package.
46
+ */
47
+ export declare const SESSION_COMMIT_METHOD = "commitPendingSession";
48
+ export type SessionCommitReason = "teardown" | "navigate" | "page-hidden";
49
+ export interface CommitPendingSessionsOptions {
50
+ /** Recorded on a synthesized event as `detail.sessionCommitReason`. */
51
+ reason?: SessionCommitReason;
52
+ logger?: PieLogger;
53
+ }
54
+ export interface CommitPendingSessionsResult {
55
+ /** Elements that committed their own pending notification. */
56
+ committed: number;
57
+ /** Elements for which the player synthesized a `session-changed`. */
58
+ synthesized: number;
59
+ /** Candidate elements with nothing new to commit. */
60
+ skipped: number;
61
+ }
62
+ /**
63
+ * Record an element's session as one the host has been told about.
64
+ *
65
+ * A player calls this from its `session-changed` listener, which is what makes
66
+ * the commit's discriminant "has anything happened since the host last heard"
67
+ * rather than a guess about an element's session schema.
68
+ */
69
+ export declare function noteSessionObserved(target: EventTarget | null | undefined): void;
70
+ /**
71
+ * Seed the baseline for every delivery element in a subtree.
72
+ *
73
+ * A player calls this once its elements hold their loaded sessions, so a
74
+ * restored response the learner has not touched is never announced as a change.
75
+ */
76
+ export declare function noteSessionBaseline(root: ParentNode | null | undefined): void;
77
+ export declare function commitPendingSessions(root: ParentNode | null | undefined, options?: CommitPendingSessionsOptions): CommitPendingSessionsResult;
78
+ /**
79
+ * Deliver every pending `session-changed` now, as an ordinary announcement.
80
+ *
81
+ * The flush a player runs when focus leaves it, ahead of whatever the learner
82
+ * is moving to, so the host hears the response while the item it belongs to is
83
+ * still current. Same two paths and the same discriminant as the commit, with
84
+ * no `sessionCommitReason`: an adopted element's event is exactly what its
85
+ * debounce would have delivered, only earlier, and the next seam finds nothing
86
+ * pending.
87
+ */
88
+ export declare function flushPendingSessionNotifications(root: ParentNode | null | undefined, options?: {
89
+ logger?: PieLogger;
90
+ }): CommitPendingSessionsResult;
91
+ export interface BindPageLifecycleCommitOptions {
92
+ /**
93
+ * The subtree to commit, re-evaluated on every transition: a player's
94
+ * container is replaced during its lifetime.
95
+ */
96
+ root: () => ParentNode | null | undefined;
97
+ /**
98
+ * Runs after the synchronous commit, for a player that owns a backend save.
99
+ * A hidden document only performs synchronous work dependably, so a save
100
+ * started here needs `keepalive`.
101
+ */
102
+ onHidden?: (reason: SessionCommitReason) => void;
103
+ logger?: PieLogger;
104
+ }
105
+ /**
106
+ * Commit on the page going away rather than on the player being removed from it.
107
+ *
108
+ * `visibilitychange` to `hidden` is the primary signal: it is the last event a
109
+ * mobile browser reliably delivers before freezing or discarding a page.
110
+ * `pagehide` backs it up for same-document navigation and back/forward cache
111
+ * entry, and for iOS Safari, which can fire it without a preceding
112
+ * `visibilitychange`. Both fire on an ordinary navigation; the per-element
113
+ * baseline makes the second a no-op, and input arriving between them is still
114
+ * committed - which a "commit once per hidden transition" guard would drop.
115
+ * `beforeunload` is not used: it is unreliable on mobile and costs the
116
+ * back/forward cache.
117
+ *
118
+ * Returns an unbind function. Safe to call outside a browser, where it binds
119
+ * nothing.
120
+ */
121
+ export declare function bindPageLifecycleCommit(options: BindPageLifecycleCommitOptions): () => void;
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Final session commit at a player seam.
3
+ *
4
+ * A delivery element coalesces its `session-changed` dispatch, so a response the
5
+ * learner finished entering can still be pending when the player is about to
6
+ * discard the element. Losing it produces no event, which leaves a host with
7
+ * nothing to detect.
8
+ *
9
+ * Reach, per seam:
10
+ *
11
+ * - `navigate` and `page-hidden` run while the elements are still attached, so
12
+ * the event reaches a `document`-level listener. Quiz Engine listens on
13
+ * `document`.
14
+ * - `teardown` runs from the player's own destroy, which a custom element only
15
+ * learns about in `disconnectedCallback` - after removal. The event reaches
16
+ * the player's own backend save and any listener bound directly to the player
17
+ * element, and it does not reach `document`. An SPA host that unmounts the
18
+ * player is served by `commitPendingSessions()` on the player element, called
19
+ * before the unmount.
20
+ * - Ahead of all three, `flushPendingSessionNotifications` runs when focus
21
+ * leaves a player, so the event lands before the host acts on the click or
22
+ * key that moved it, and carries no reason.
23
+ *
24
+ * Two paths, per element:
25
+ *
26
+ * - An element that owns its notification exposes `commitPendingSession()`, so
27
+ * it dispatches its own event with its own `complete` semantics. The sweep
28
+ * marks that event with `detail.sessionCommitReason`, which the element
29
+ * cannot do for itself and which every guard in a `session-changed`'s way
30
+ * keys on.
31
+ * - An older element gets a synthesized `session-changed` built from its
32
+ * `session` getter. Those elements write the session synchronously and defer
33
+ * only the dispatch, so the read is never older than what the host was last
34
+ * told.
35
+ *
36
+ * Duplicated deliberately into `pie-player-components` and `pie-api-components`,
37
+ * which cannot depend on this package. Changes belong here first; the copies are
38
+ * `src/utils/session-commit.ts` and `src/session-commit.ts` respectively.
39
+ */
40
+ import { hasLearnerResponse } from "./item-session-contract.js";
41
+ /**
42
+ * Commit hook a delivery element installs when it owns a deferred
43
+ * `session-changed`. Owned by `@pie-element/shared-player-events`
44
+ * (`createSessionNotifier`); the name is duplicated rather than imported so the
45
+ * players carry no dependency on an element-repo package.
46
+ */
47
+ export const SESSION_COMMIT_METHOD = "commitPendingSession";
48
+ /**
49
+ * Where the last-observed session signature is recorded, on the element itself.
50
+ *
51
+ * On the element rather than in a module-scope map for two reasons: a nested
52
+ * player stack loads two copies of this module from different bundles, and
53
+ * module state would then let each layer announce the same commit; and a map
54
+ * keyed by element records what the *sweep* last emitted, which suppresses a
55
+ * later commit of a value the sweep emitted once before (answer A, hide, answer
56
+ * B, answer A again, navigate mid-debounce - the host keeps B).
57
+ *
58
+ * `Symbol.for` so the two bundles resolve the same key.
59
+ */
60
+ const OBSERVED_SIGNATURE = Symbol.for("pie.sessionCommit.observedSignature");
61
+ function isRecord(value) {
62
+ return !!value && typeof value === "object";
63
+ }
64
+ /**
65
+ * A PIE delivery element carries both halves of the element contract: a
66
+ * `session` accessor and a `model` setter. Requiring both keeps the sweep off
67
+ * player chrome and tool custom elements, and an element that has not been
68
+ * upgraded yet has neither.
69
+ */
70
+ function isPieDeliveryElement(element) {
71
+ if (!element.tagName.includes("-"))
72
+ return false;
73
+ return "session" in element && "model" in element;
74
+ }
75
+ function shadowRootOf(node) {
76
+ const candidate = node
77
+ ?.shadowRoot;
78
+ return candidate ?? null;
79
+ }
80
+ function assignedElementsOf(element) {
81
+ const slot = element;
82
+ if (typeof slot.assignedElements !== "function")
83
+ return [];
84
+ try {
85
+ return slot.assignedElements({ flatten: true });
86
+ }
87
+ catch {
88
+ return [];
89
+ }
90
+ }
91
+ /**
92
+ * Walks the flattened tree: `children`, then open shadow roots, then the
93
+ * elements assigned to each `<slot>`.
94
+ *
95
+ * `querySelectorAll("*")` sees none of the last two, and both are load-bearing
96
+ * here. The section player mounts each item inside `<pie-item-shell>`'s shadow
97
+ * root, and projects its item pane through a slot on the assessment toolkit -
98
+ * a walk that stops at `<slot>` finds every delivery element in a section
99
+ * unreachable.
100
+ */
101
+ function collectCandidates(root, seen, out) {
102
+ const rootShadow = shadowRootOf(root);
103
+ if (rootShadow)
104
+ collectCandidates(rootShadow, seen, out);
105
+ const visit = (element) => {
106
+ if (seen.has(element))
107
+ return;
108
+ seen.add(element);
109
+ if (isPieDeliveryElement(element))
110
+ out.push(element);
111
+ collectCandidates(element, seen, out);
112
+ };
113
+ for (const assigned of assignedElementsOf(root))
114
+ visit(assigned);
115
+ const children = root.children;
116
+ if (!children)
117
+ return;
118
+ for (const element of Array.from(children))
119
+ visit(element);
120
+ }
121
+ /**
122
+ * Duck-typed rather than `instanceof Element`: the legacy players' test
123
+ * environment is Stencil's mock DOM, where a mounted element is not an instance
124
+ * of the global `Element`, and a check that silently fails there fails the same
125
+ * way against any other DOM implementation a host brings.
126
+ */
127
+ function isElementLike(node) {
128
+ return (!!node &&
129
+ typeof node === "object" &&
130
+ typeof node.tagName === "string");
131
+ }
132
+ function candidatesIn(root) {
133
+ const candidates = [];
134
+ const seen = new Set();
135
+ if (isElementLike(root) && isPieDeliveryElement(root)) {
136
+ seen.add(root);
137
+ candidates.push(root);
138
+ }
139
+ collectCandidates(root, seen, candidates);
140
+ return candidates;
141
+ }
142
+ function readSession(element) {
143
+ try {
144
+ return element.session;
145
+ }
146
+ catch {
147
+ // A getter that throws is not a session the player can commit.
148
+ return undefined;
149
+ }
150
+ }
151
+ function signatureOf(session) {
152
+ try {
153
+ const json = JSON.stringify(session);
154
+ // `JSON.stringify(undefined)` is `undefined`, which would be written as a
155
+ // signature and then read back as "no baseline".
156
+ return typeof json === "string" ? json : null;
157
+ }
158
+ catch {
159
+ return null;
160
+ }
161
+ }
162
+ function readObservedSignature(element) {
163
+ const recorded = element[OBSERVED_SIGNATURE];
164
+ return typeof recorded === "string" ? recorded : undefined;
165
+ }
166
+ function writeObservedSignature(element, signature) {
167
+ try {
168
+ Object.defineProperty(element, OBSERVED_SIGNATURE, {
169
+ value: signature,
170
+ configurable: true,
171
+ enumerable: false,
172
+ writable: true,
173
+ });
174
+ }
175
+ catch {
176
+ // A frozen element cannot be marked. It then commits again on the next
177
+ // seam, which a player's own session signature check absorbs.
178
+ }
179
+ }
180
+ /**
181
+ * Record an element's session as one the host has been told about.
182
+ *
183
+ * A player calls this from its `session-changed` listener, which is what makes
184
+ * the commit's discriminant "has anything happened since the host last heard"
185
+ * rather than a guess about an element's session schema.
186
+ */
187
+ export function noteSessionObserved(target) {
188
+ if (!isElementLike(target))
189
+ return;
190
+ const signature = signatureOf(readSession(target));
191
+ if (signature === null)
192
+ return;
193
+ writeObservedSignature(target, signature);
194
+ }
195
+ /**
196
+ * Seed the baseline for every delivery element in a subtree.
197
+ *
198
+ * A player calls this once its elements hold their loaded sessions, so a
199
+ * restored response the learner has not touched is never announced as a change.
200
+ */
201
+ export function noteSessionBaseline(root) {
202
+ if (!root)
203
+ return;
204
+ for (const element of candidatesIn(root)) {
205
+ const signature = signatureOf(readSession(element));
206
+ if (signature !== null)
207
+ writeObservedSignature(element, signature);
208
+ }
209
+ }
210
+ /**
211
+ * Mark an element-owned commit's own event as a commit, for the length of the
212
+ * sweep.
213
+ *
214
+ * `commitPendingSession()` lets the element dispatch its own `session-changed`,
215
+ * with its own `complete` semantics, and that event carries no commit marker.
216
+ * Every guard a player puts in a `session-changed`'s way keys on
217
+ * `detail.sessionCommitReason` to let a commit through: the Stencil player's
218
+ * model-set blocker, this project's renderer dedupe, the section player's
219
+ * cross-shell dedupe, `<pie-api-player>`'s save-now decision. Unmarked, an
220
+ * element-owned commit is exactly the event those guards drop - and the
221
+ * elements that adopted `createSessionNotifier` are the ones taking that path,
222
+ * so the exemption applied to nothing.
223
+ *
224
+ * A capture listener on the sweep root runs before the target's own listeners
225
+ * and before every bubble listener at or above the root, which is where all
226
+ * four guards sit. Only the absent key is added; a synthesized event already
227
+ * carries its own.
228
+ */
229
+ function markCommitsDuring(root, reason, sweep) {
230
+ const target = root;
231
+ if (typeof target.addEventListener !== "function")
232
+ return sweep();
233
+ const stamp = (event) => {
234
+ const detail = event.detail;
235
+ if (!isRecord(detail) || "sessionCommitReason" in detail)
236
+ return;
237
+ try {
238
+ detail.sessionCommitReason = reason;
239
+ }
240
+ catch {
241
+ // A frozen detail keeps its own contract; the commit still dispatches.
242
+ }
243
+ };
244
+ target.addEventListener("session-changed", stamp, true);
245
+ try {
246
+ return sweep();
247
+ }
248
+ finally {
249
+ target.removeEventListener("session-changed", stamp, true);
250
+ }
251
+ }
252
+ export function commitPendingSessions(root, options = {}) {
253
+ const result = {
254
+ committed: 0,
255
+ synthesized: 0,
256
+ skipped: 0,
257
+ };
258
+ if (!root)
259
+ return result;
260
+ const reason = options.reason ?? "teardown";
261
+ return markCommitsDuring(root, reason, () => sweepPendingSessions(root, reason, options.logger, result));
262
+ }
263
+ /**
264
+ * `reason` is null for a flush, whose events are ordinary announcements.
265
+ */
266
+ function sweepPendingSessions(root, reason, logger, result) {
267
+ const caller = reason ? "commitPendingSessions" : "flushPendingSessionNotifications";
268
+ for (const element of candidatesIn(root)) {
269
+ const session = readSession(element);
270
+ if (!isRecord(session)) {
271
+ result.skipped += 1;
272
+ continue;
273
+ }
274
+ const signature = signatureOf(session);
275
+ if (signature === null) {
276
+ result.skipped += 1;
277
+ continue;
278
+ }
279
+ const observed = readObservedSignature(element);
280
+ if (observed === signature) {
281
+ // The host already has this. Nothing is pending, whichever path the
282
+ // element would have taken.
283
+ result.skipped += 1;
284
+ continue;
285
+ }
286
+ if (observed === undefined && !hasLearnerResponse(session)) {
287
+ // No baseline, so the player never saw this element load - the section
288
+ // player's shells and the legacy Stencil stack both have paths where
289
+ // that happens. Announcing an untouched element's identity-only session
290
+ // makes a host that re-renders on `session-changed` cancel work it was
291
+ // in the middle of: a config swap re-pushed the host's own config and
292
+ // discarded the incoming item.
293
+ result.skipped += 1;
294
+ continue;
295
+ }
296
+ const commit = element[SESSION_COMMIT_METHOD];
297
+ if (typeof commit === "function") {
298
+ // An element's own commit is a no-op when nothing is pending, and its
299
+ // session can still differ from what the host heard: a controller
300
+ // writing into the session, or an element path that stores a value
301
+ // without notifying. Counting the call as the announcement recorded a
302
+ // response the host never received and every later seam then skipped
303
+ // it, so what the element dispatches is what counts. `flush()` is
304
+ // synchronous in every implementation of this contract; one that
305
+ // deferred would be announced twice, which a player's signature check
306
+ // absorbs.
307
+ let dispatched = false;
308
+ const witness = () => {
309
+ dispatched = true;
310
+ };
311
+ element.addEventListener("session-changed", witness, true);
312
+ let failed = false;
313
+ try {
314
+ commit.call(element);
315
+ }
316
+ catch (error) {
317
+ failed = true;
318
+ logger?.warn(`[${caller}] ${element.tagName.toLowerCase()} failed to commit its pending session`, error);
319
+ }
320
+ finally {
321
+ element.removeEventListener("session-changed", witness, true);
322
+ }
323
+ if (failed) {
324
+ result.skipped += 1;
325
+ continue;
326
+ }
327
+ if (dispatched) {
328
+ writeObservedSignature(element, signature);
329
+ result.committed += 1;
330
+ continue;
331
+ }
332
+ // Nothing was pending, so fall through and announce the session the
333
+ // element holds.
334
+ }
335
+ try {
336
+ element.dispatchEvent(new CustomEvent("session-changed", {
337
+ bubbles: true,
338
+ composed: true,
339
+ detail: {
340
+ component: element.tagName.toLowerCase(),
341
+ session: JSON.parse(signature),
342
+ ...(reason ? { sessionCommitReason: reason } : {}),
343
+ },
344
+ }));
345
+ writeObservedSignature(element, signature);
346
+ result.synthesized += 1;
347
+ }
348
+ catch (error) {
349
+ logger?.warn(`[${caller}] ${element.tagName.toLowerCase()} session commit dispatch failed`, error);
350
+ result.skipped += 1;
351
+ }
352
+ }
353
+ return result;
354
+ }
355
+ /**
356
+ * Deliver every pending `session-changed` now, as an ordinary announcement.
357
+ *
358
+ * The flush a player runs when focus leaves it, ahead of whatever the learner
359
+ * is moving to, so the host hears the response while the item it belongs to is
360
+ * still current. Same two paths and the same discriminant as the commit, with
361
+ * no `sessionCommitReason`: an adopted element's event is exactly what its
362
+ * debounce would have delivered, only earlier, and the next seam finds nothing
363
+ * pending.
364
+ */
365
+ export function flushPendingSessionNotifications(root, options = {}) {
366
+ const result = {
367
+ committed: 0,
368
+ synthesized: 0,
369
+ skipped: 0,
370
+ };
371
+ if (!root)
372
+ return result;
373
+ return sweepPendingSessions(root, null, options.logger, result);
374
+ }
375
+ /**
376
+ * Commit on the page going away rather than on the player being removed from it.
377
+ *
378
+ * `visibilitychange` to `hidden` is the primary signal: it is the last event a
379
+ * mobile browser reliably delivers before freezing or discarding a page.
380
+ * `pagehide` backs it up for same-document navigation and back/forward cache
381
+ * entry, and for iOS Safari, which can fire it without a preceding
382
+ * `visibilitychange`. Both fire on an ordinary navigation; the per-element
383
+ * baseline makes the second a no-op, and input arriving between them is still
384
+ * committed - which a "commit once per hidden transition" guard would drop.
385
+ * `beforeunload` is not used: it is unreliable on mobile and costs the
386
+ * back/forward cache.
387
+ *
388
+ * Returns an unbind function. Safe to call outside a browser, where it binds
389
+ * nothing.
390
+ */
391
+ export function bindPageLifecycleCommit(options) {
392
+ if (typeof document === "undefined" || typeof window === "undefined") {
393
+ return () => { };
394
+ }
395
+ const commit = (reason) => {
396
+ commitPendingSessions(options.root(), {
397
+ reason,
398
+ logger: options.logger,
399
+ });
400
+ try {
401
+ options.onHidden?.(reason);
402
+ }
403
+ catch (error) {
404
+ options.logger?.warn("[bindPageLifecycleCommit] onHidden failed", error);
405
+ }
406
+ };
407
+ const handleVisibilityChange = () => {
408
+ if (document.visibilityState === "hidden")
409
+ commit("page-hidden");
410
+ };
411
+ const handlePageHide = () => {
412
+ commit("page-hidden");
413
+ };
414
+ document.addEventListener("visibilitychange", handleVisibilityChange);
415
+ window.addEventListener("pagehide", handlePageHide);
416
+ return () => {
417
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
418
+ window.removeEventListener("pagehide", handlePageHide);
419
+ };
420
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Device-local snapshot of a committed session.
3
+ *
4
+ * A crash or an OS kill fires no lifecycle event, so the only state that
5
+ * survives it is state already written. The snapshot writes each committed
6
+ * session to device storage and offers it back on the next load.
7
+ *
8
+ * It is offered, never applied. School devices are shared, and a player cannot
9
+ * tell a legitimate recovery from a previous student's draft, so the host
10
+ * decides.
11
+ *
12
+ * `sessionStorage` is the default, matching the annotation toolbar's existing
13
+ * use of it for learner annotations: it is tab-scoped and cleared when the tab
14
+ * closes, so a response does not outlive the sitting, and browser session
15
+ * restore brings it back after a crash. A host that wants recovery across a
16
+ * full browser restart injects a `localStorage`-backed store and owns the
17
+ * retention consequences.
18
+ */
19
+ export interface SessionSnapshotStore {
20
+ read(key: string): string | null;
21
+ write(key: string, value: string): void;
22
+ clear(key: string): void;
23
+ }
24
+ export type SessionSnapshotConfig = boolean | {
25
+ enabled?: boolean;
26
+ /** Defaults to `sessionStorage`. */
27
+ store?: SessionSnapshotStore;
28
+ /** Defaults to a key derived from the delivery identity. */
29
+ key?: string;
30
+ };
31
+ export interface SessionSnapshotRecord {
32
+ key: string;
33
+ session: unknown;
34
+ /** `Date.now()` at the time of the write. */
35
+ timestamp: number;
36
+ }
37
+ export interface SessionSnapshot {
38
+ readonly key: string;
39
+ write(session: unknown): void;
40
+ read(): SessionSnapshotRecord | null;
41
+ clear(): void;
42
+ }
43
+ export type SessionSnapshotIdentity = {
44
+ itemId?: string;
45
+ sessionId?: string;
46
+ assignmentId?: string;
47
+ };
48
+ /**
49
+ * Whether an identity names one sitting.
50
+ *
51
+ * `sessionId` is the only field that distinguishes one learner's attempt from
52
+ * another's. Keyed on the item alone, a shared device offers the previous
53
+ * student's draft to the next one - and the offer is itself a disclosure, before
54
+ * the host ever decides whether to apply it. So without a `sessionId` there is
55
+ * no snapshot, and a host driving the player by props alone opts in by passing
56
+ * an explicit `key`.
57
+ */
58
+ export declare function isSnapshotIdentitySpecific(identity: SessionSnapshotIdentity): boolean;
59
+ /**
60
+ * Key built from the delivery identity, so a snapshot is only offered back to
61
+ * the sitting that wrote it.
62
+ */
63
+ export declare function sessionSnapshotKey(identity: SessionSnapshotIdentity): string;
64
+ /**
65
+ * `sessionStorage`-backed store. Every access is guarded: private mode and
66
+ * blocked site data make the accessor throw, and a player must keep working
67
+ * without a snapshot rather than fail.
68
+ */
69
+ export declare function sessionStorageSnapshotStore(): SessionSnapshotStore;
70
+ export declare function isSessionSnapshotEnabled(config: SessionSnapshotConfig | null | undefined): boolean;
71
+ /**
72
+ * Returns `null` when the host has not enabled a snapshot, and when the
73
+ * identity does not name one sitting. PIE writes learner responses to the
74
+ * device only on an explicit opt-in, and only under a key no other learner
75
+ * shares.
76
+ */
77
+ export declare function createSessionSnapshot(args: {
78
+ config: SessionSnapshotConfig | null | undefined;
79
+ identity: SessionSnapshotIdentity;
80
+ }): SessionSnapshot | null;