coursecode 0.1.57 → 0.1.58

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 (85) hide show
  1. package/README.md +8 -1
  2. package/bin/cli.js +16 -0
  3. package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
  4. package/framework/docs/USER_GUIDE.md +14 -0
  5. package/framework/js/app/AppActions.js +17 -23
  6. package/framework/js/app/AppUI.js +7 -4
  7. package/framework/js/assessment/AssessmentActions.js +16 -15
  8. package/framework/js/assessment/AssessmentFactory.js +44 -15
  9. package/framework/js/automation/api-engagement.js +6 -3
  10. package/framework/js/components/ui-components/audio-player.js +30 -8
  11. package/framework/js/components/ui-components/collapse.js +4 -2
  12. package/framework/js/components/ui-components/dropdown.js +7 -3
  13. package/framework/js/components/ui-components/embed-frame.js +5 -1
  14. package/framework/js/components/ui-components/flip-card.js +12 -6
  15. package/framework/js/components/ui-components/interactive-image.js +28 -52
  16. package/framework/js/components/ui-components/lightbox.js +19 -5
  17. package/framework/js/components/ui-components/modal.js +20 -7
  18. package/framework/js/components/ui-components/notifications.js +8 -3
  19. package/framework/js/components/ui-components/video-player.js +46 -20
  20. package/framework/js/core/event-bus.js +31 -28
  21. package/framework/js/drivers/cmi5-driver.js +186 -83
  22. package/framework/js/drivers/driver-factory.js +7 -1
  23. package/framework/js/drivers/driver-interface.js +1 -1
  24. package/framework/js/drivers/http-driver-base.js +6 -0
  25. package/framework/js/drivers/lti-driver.js +112 -47
  26. package/framework/js/drivers/proxy-driver.js +347 -49
  27. package/framework/js/drivers/scorm-12-driver.js +213 -130
  28. package/framework/js/drivers/scorm-2004-driver.js +32 -19
  29. package/framework/js/drivers/scorm-driver-base.js +12 -0
  30. package/framework/js/drivers/standalone-driver.js +139 -0
  31. package/framework/js/engagement/engagement-trackers.js +38 -13
  32. package/framework/js/engagement/requirement-strategies.js +5 -1
  33. package/framework/js/main.js +7 -0
  34. package/framework/js/managers/assessment-manager.js +16 -1
  35. package/framework/js/managers/audio-manager.js +50 -16
  36. package/framework/js/managers/flag-manager.js +5 -1
  37. package/framework/js/managers/objective-manager.js +15 -7
  38. package/framework/js/managers/score-manager.js +27 -6
  39. package/framework/js/managers/video-manager.js +149 -70
  40. package/framework/js/navigation/NavigationActions.js +18 -4
  41. package/framework/js/navigation/document-gallery.js +7 -2
  42. package/framework/js/navigation/navigation-validators.js +3 -2
  43. package/framework/js/state/lms-connection.js +41 -16
  44. package/framework/js/state/state-commits.js +10 -5
  45. package/framework/js/state/state-manager.js +95 -9
  46. package/framework/js/state/state-validation.js +44 -11
  47. package/framework/js/utilities/course-helpers.js +3 -2
  48. package/framework/js/utilities/media-utils.js +28 -0
  49. package/framework/js/utilities/portable-assets.js +131 -0
  50. package/framework/js/utilities/ui-initializer.js +79 -5
  51. package/framework/js/utilities/utilities.js +32 -20
  52. package/framework/js/utilities/view-manager.js +16 -1
  53. package/framework/js/validation/scorm-validators.js +69 -0
  54. package/framework/version.json +2 -2
  55. package/lib/authoring-api.js +37 -18
  56. package/lib/build-packaging.js +47 -3
  57. package/lib/build.js +1 -1
  58. package/lib/cloud.js +40 -11
  59. package/lib/convert.js +30 -2
  60. package/lib/create.js +5 -3
  61. package/lib/dev.js +1 -1
  62. package/lib/headless-browser.js +66 -14
  63. package/lib/manifest/cmi5-manifest.js +26 -4
  64. package/lib/manifest/lti-tool-config.js +7 -2
  65. package/lib/manifest/manifest-factory.d.ts +1 -1
  66. package/lib/manifest/manifest-factory.js +3 -2
  67. package/lib/manifest/scorm-12-manifest.js +2 -2
  68. package/lib/manifest/scorm-2004-manifest.js +3 -28
  69. package/lib/manifest/scorm-proxy-manifest.js +37 -32
  70. package/lib/manifest/xml-utils.js +10 -0
  71. package/lib/narration.js +2 -2
  72. package/lib/portable-html.js +196 -0
  73. package/lib/preview-export.js +1 -1
  74. package/lib/preview-server.js +27 -6
  75. package/lib/project-utils.js +9 -1
  76. package/lib/proxy-templates/proxy.html +4 -1
  77. package/lib/proxy-templates/scorm-bridge.js +82 -10
  78. package/lib/scaffold.js +9 -4
  79. package/lib/stub-player/lms-api.js +63 -8
  80. package/package.json +5 -1
  81. package/schemas/adlcp_rootv1p2.xsd +110 -0
  82. package/schemas/coursecode_scorm12_package.xsd +5 -0
  83. package/schemas/coursecode_scorm2004_package.xsd +8 -0
  84. package/schemas/imscp_rootv1p1p2.xsd +304 -0
  85. package/template/vite.config.js +50 -22
@@ -18,6 +18,10 @@
18
18
  import { eventBus } from '../core/event-bus.js';
19
19
  import stateManager from '../state/index.js';
20
20
  import { logger } from '../utilities/logger.js';
21
+ import {
22
+ DEFAULT_MEDIA_COMPLETION_THRESHOLD,
23
+ normalizeCompletionThreshold
24
+ } from '../utilities/media-utils.js';
21
25
 
22
26
  /**
23
27
  * @typedef {Object} VideoState
@@ -44,9 +48,6 @@ import { logger } from '../utilities/logger.js';
44
48
  * @property {number} [completionThreshold=0.95] - Percentage (0-1) required for completion
45
49
  */
46
50
 
47
- /** Default completion threshold (95%) */
48
- const DEFAULT_COMPLETION_THRESHOLD = 0.95;
49
-
50
51
  class VideoManager {
51
52
  constructor() {
52
53
  /** @type {boolean} */
@@ -63,7 +64,7 @@ class VideoManager {
63
64
  duration: 0,
64
65
  volume: 1,
65
66
  required: false,
66
- completionThreshold: DEFAULT_COMPLETION_THRESHOLD,
67
+ completionThreshold: DEFAULT_MEDIA_COMPLETION_THRESHOLD,
67
68
  isCompleted: false
68
69
  };
69
70
 
@@ -78,6 +79,9 @@ class VideoManager {
78
79
 
79
80
  /** @type {number|null} */
80
81
  this.updateInterval = null;
82
+
83
+ /** @type {HTMLVideoElement|null} */
84
+ this.activeVideo = null;
81
85
  }
82
86
 
83
87
  /**
@@ -116,41 +120,78 @@ class VideoManager {
116
120
  throw new Error('VideoManager.attachVideo: contextId is required');
117
121
  }
118
122
 
119
- // Save position of current context before switching
120
- if (this.state.contextId && this.state.contextId !== contextId) {
121
- this._savePosition();
122
- }
123
-
124
- // Update state
125
- this.state.currentSrc = config.src;
126
- this.state.contextId = contextId;
127
- this.state.contextType = config.contextType || 'standalone';
128
- this.state.duration = 0;
129
- this.state.isPlaying = false;
130
- this.state.position = 0;
131
- this.state.required = config.required || false;
132
- this.state.completionThreshold = config.completionThreshold ?? DEFAULT_COMPLETION_THRESHOLD;
133
- this.maxPositionReached = 0;
134
-
135
- // Check if already completed (from previous session)
136
- this.state.isCompleted = this._isContextCompleted(contextId);
137
-
138
- // Check for saved position
139
123
  const savedPosition = this._getSavedPosition(contextId);
140
124
 
125
+ video._videoManagerState = {
126
+ currentSrc: config.src,
127
+ contextId,
128
+ contextType: config.contextType || 'standalone',
129
+ duration: 0,
130
+ position: 0,
131
+ required: config.required || false,
132
+ completionThreshold: normalizeCompletionThreshold(config.completionThreshold),
133
+ isCompleted: this._isContextCompleted(contextId),
134
+ maxPositionReached: savedPosition
135
+ };
136
+
141
137
  // Set up event listeners
142
138
  this._setupVideoListeners(video, contextId, savedPosition);
143
139
 
140
+ if (!this.activeVideo) {
141
+ this._activateVideo(video);
142
+ }
143
+
144
144
  // Emit loadStart event
145
145
  eventBus.emit('video:loadStart', {
146
146
  contextId,
147
- contextType: this.state.contextType,
147
+ contextType: video._videoManagerState.contextType,
148
148
  src: config.src
149
149
  });
150
150
 
151
151
  logger.debug(`[VideoManager] Attached video: ${contextId}`);
152
152
  }
153
153
 
154
+ /**
155
+ * Activates a rendered video, pausing and saving the previous context so
156
+ * playback and completion events cannot be attributed to another player.
157
+ * @private
158
+ * @param {HTMLVideoElement} video
159
+ */
160
+ _activateVideo(video) {
161
+ const localState = video?._videoManagerState;
162
+ if (!localState || this.activeVideo === video) return;
163
+
164
+ const previousVideo = this.activeVideo;
165
+ if (previousVideo) {
166
+ if (Number.isFinite(previousVideo.currentTime)) {
167
+ this.state.position = previousVideo.currentTime;
168
+ }
169
+ this._savePosition();
170
+ if (!previousVideo.paused) {
171
+ previousVideo.pause();
172
+ }
173
+ }
174
+
175
+ this._stopPositionUpdates();
176
+ this.activeVideo = video;
177
+ this.maxPositionReached = localState.maxPositionReached;
178
+ this.state = {
179
+ currentSrc: localState.currentSrc,
180
+ contextId: localState.contextId,
181
+ contextType: localState.contextType,
182
+ position: video.currentTime || localState.position || 0,
183
+ isPlaying: !video.paused,
184
+ isMuted: video.muted,
185
+ duration: Number.isFinite(video.duration) && video.duration > 0
186
+ ? video.duration
187
+ : localState.duration,
188
+ volume: video.volume,
189
+ required: localState.required,
190
+ completionThreshold: localState.completionThreshold,
191
+ isCompleted: localState.isCompleted
192
+ };
193
+ }
194
+
154
195
  /**
155
196
  * Sets up event listeners on a video element.
156
197
  * @private
@@ -164,50 +205,66 @@ class VideoManager {
164
205
  video._videoManagerListeners = {};
165
206
 
166
207
  const listeners = video._videoManagerListeners;
208
+ const localState = video._videoManagerState;
167
209
 
168
210
  listeners.loadedmetadata = () => {
169
- if (isFinite(video.duration) && video.duration > 0) {
170
- this.state.duration = video.duration;
211
+ if (Number.isFinite(video.duration) && video.duration > 0) {
212
+ localState.duration = video.duration;
171
213
  }
172
214
 
173
215
  // Restore position if we have one saved
174
216
  if (savedPosition > 0 && savedPosition < video.duration) {
175
217
  video.currentTime = savedPosition;
176
- this.state.position = savedPosition;
177
- this.maxPositionReached = savedPosition;
218
+ localState.position = savedPosition;
219
+ localState.maxPositionReached = savedPosition;
220
+ }
221
+
222
+ if (this.activeVideo === video) {
223
+ this.state.duration = localState.duration;
224
+ this.state.position = localState.position;
225
+ this.maxPositionReached = localState.maxPositionReached;
178
226
  }
179
227
 
180
228
  eventBus.emit('video:loaded', {
181
- src: this.state.currentSrc,
182
- duration: this.state.duration,
229
+ src: localState.currentSrc,
230
+ duration: localState.duration,
183
231
  contextId,
184
- contextType: this.state.contextType
232
+ contextType: localState.contextType
185
233
  });
186
234
  };
187
235
 
188
236
  listeners.play = () => {
237
+ this._activateVideo(video);
189
238
  this.state.isPlaying = true;
190
239
  this._startPositionUpdates(video);
191
240
  eventBus.emit('video:play', {
192
241
  contextId,
193
- contextType: this.state.contextType
242
+ contextType: localState.contextType
194
243
  });
195
244
  };
196
245
 
197
246
  listeners.pause = () => {
198
- this.state.isPlaying = false;
199
- this._stopPositionUpdates();
200
- this._savePosition();
247
+ localState.position = video.currentTime;
248
+ if (this.activeVideo === video) {
249
+ this.state.position = video.currentTime;
250
+ this.state.isPlaying = false;
251
+ this._stopPositionUpdates();
252
+ this._savePosition();
253
+ }
201
254
  eventBus.emit('video:pause', {
202
255
  contextId,
203
- contextType: this.state.contextType,
204
- position: this.state.position
256
+ contextType: localState.contextType,
257
+ position: localState.position
205
258
  });
206
259
  };
207
260
 
208
261
  listeners.ended = () => {
262
+ this._activateVideo(video);
263
+ localState.position = video.duration;
264
+ localState.maxPositionReached = video.duration;
209
265
  this.state.isPlaying = false;
210
266
  this.state.position = video.duration;
267
+ this.maxPositionReached = video.duration;
211
268
  this._stopPositionUpdates();
212
269
 
213
270
  // Mark as completed when video ends
@@ -217,31 +274,38 @@ class VideoManager {
217
274
  };
218
275
 
219
276
  listeners.timeupdate = () => {
220
- this.state.position = video.currentTime;
277
+ localState.position = video.currentTime;
221
278
 
222
279
  // Track max position for completion calculation
223
- if (video.currentTime > this.maxPositionReached) {
224
- this.maxPositionReached = video.currentTime;
280
+ if (video.currentTime > localState.maxPositionReached) {
281
+ localState.maxPositionReached = video.currentTime;
225
282
  }
226
283
 
227
- // Check for completion threshold during playback
228
- this._checkAndMarkCompleted();
284
+ if (this.activeVideo === video) {
285
+ this.state.position = video.currentTime;
286
+ this.maxPositionReached = localState.maxPositionReached;
287
+ this._checkAndMarkCompleted();
288
+ }
229
289
  };
230
290
 
231
291
  listeners.volumechange = () => {
232
- this.state.volume = video.volume;
233
- this.state.isMuted = video.muted;
234
- this._persistMuteState();
292
+ if (this.activeVideo === video) {
293
+ this.state.volume = video.volume;
294
+ this.state.isMuted = video.muted;
295
+ this._persistMuteState();
296
+ }
235
297
  };
236
298
 
237
299
  listeners.error = () => {
238
300
  const error = video.error;
239
301
  const errorMessage = error ? `${error.code}: ${error.message}` : 'Unknown error';
240
302
 
241
- this.state.isPlaying = false;
242
- this._stopPositionUpdates();
303
+ if (this.activeVideo === video) {
304
+ this.state.isPlaying = false;
305
+ this._stopPositionUpdates();
306
+ }
243
307
 
244
- logger.error(`[VideoManager] Video playback error: ${errorMessage}`, { domain: 'video', operation: 'playback', src: this.state.currentSrc, contextId });
308
+ logger.error(`[VideoManager] Video playback error: ${errorMessage}`, { domain: 'video', operation: 'playback', src: localState.currentSrc, contextId });
245
309
  };
246
310
 
247
311
  // Attach all listeners
@@ -260,8 +324,14 @@ class VideoManager {
260
324
  detachVideo(video) {
261
325
  if (!video || !video._videoManagerListeners) return;
262
326
 
327
+ const localState = video._videoManagerState;
328
+ const wasActive = this.activeVideo === video;
329
+
263
330
  // Save position before detaching
264
- if (video._videoManagerContextId === this.state.contextId) {
331
+ if (wasActive) {
332
+ this.state.position = Number.isFinite(video.currentTime)
333
+ ? video.currentTime
334
+ : this.state.position;
265
335
  this._savePosition();
266
336
  }
267
337
 
@@ -272,32 +342,38 @@ class VideoManager {
272
342
 
273
343
  delete video._videoManagerListeners;
274
344
  delete video._videoManagerContextId;
275
-
276
- this._stopPositionUpdates();
345
+ delete video._videoManagerState;
277
346
 
278
347
  // Clear state if this was the active video
279
- if (video._videoManagerContextId === this.state.contextId) {
280
- const wasMuted = this.state.isMuted;
281
- this.state = {
282
- currentSrc: null,
283
- contextId: null,
284
- contextType: 'standalone',
285
- position: 0,
286
- isPlaying: false,
287
- isMuted: wasMuted,
288
- duration: 0,
289
- volume: this.state.volume,
290
- required: false,
291
- completionThreshold: DEFAULT_COMPLETION_THRESHOLD,
292
- isCompleted: false
293
- };
294
- this.maxPositionReached = 0;
348
+ if (wasActive) {
349
+ this._stopPositionUpdates();
350
+ this.activeVideo = null;
351
+ this._resetActiveState();
295
352
  }
296
353
 
297
- eventBus.emit('video:unloaded', { contextType: this.state.contextType });
354
+ eventBus.emit('video:unloaded', { contextType: localState?.contextType || 'standalone' });
298
355
  logger.debug('[VideoManager] Detached video');
299
356
  }
300
357
 
358
+ /** @private */
359
+ _resetActiveState() {
360
+ const { isMuted, volume } = this.state;
361
+ this.state = {
362
+ currentSrc: null,
363
+ contextId: null,
364
+ contextType: 'standalone',
365
+ position: 0,
366
+ isPlaying: false,
367
+ isMuted,
368
+ duration: 0,
369
+ volume,
370
+ required: false,
371
+ completionThreshold: DEFAULT_MEDIA_COMPLETION_THRESHOLD,
372
+ isCompleted: false
373
+ };
374
+ this.maxPositionReached = 0;
375
+ }
376
+
301
377
  /**
302
378
  * Gets the current video state.
303
379
  * @returns {VideoState}
@@ -404,6 +480,9 @@ class VideoManager {
404
480
 
405
481
  if (completionPercentage >= this.state.completionThreshold) {
406
482
  this.state.isCompleted = true;
483
+ if (this.activeVideo?._videoManagerState) {
484
+ this.activeVideo._videoManagerState.isCompleted = true;
485
+ }
407
486
  this._markContextCompleted(this.state.contextId);
408
487
 
409
488
  logger.debug(`[VideoManager] Video completed: ${this.state.contextId}`);
@@ -421,7 +500,7 @@ class VideoManager {
421
500
  * @private
422
501
  */
423
502
  _savePosition() {
424
- if (!this.state.contextId || !this.state.position) return;
503
+ if (!this.state.contextId || !Number.isFinite(this.state.position)) return;
425
504
 
426
505
  this.positionCache.set(this.state.contextId, this.state.position);
427
506
  this._persistPositions();
@@ -698,8 +698,7 @@ async function _performNavigation(slideId, context = {}, updateBookmark = true)
698
698
 
699
699
  // Update progress measure to reflect slide visit
700
700
  // Count only sequential slides (excludes remedial/conditional slides)
701
- const totalSequentialSlides = slides.filter(s => isSlideInSequence(s, stateManager, assessmentConfigs)).length;
702
- stateManager.updateProgressMeasure(totalSequentialSlides);
701
+ syncProgressMeasure();
703
702
 
704
703
  // Set bookmark as FINAL step after successful navigation
705
704
  // We use slide ID as the bookmark value (unique, stable, human-readable)
@@ -880,13 +879,14 @@ export function sync() {
880
879
  const hasBeenVisited = visitedSlides.includes(slide.id);
881
880
 
882
881
  if (slide.type === 'assessment') {
883
- const config = assessmentConfigs.get(slide.assessmentId);
882
+ const assessmentId = AssessmentManager.resolveAssessmentId(slide);
883
+ const config = assessmentConfigs.get(assessmentId);
884
884
  const requirements = config?.completionRequirements;
885
885
  let requirementsMet = false;
886
886
 
887
887
  // Only check requirements if they are defined in the assessment's config
888
888
  if (requirements) {
889
- requirementsMet = AssessmentManager.meetsCompletionRequirements(slide.assessmentId, requirements);
889
+ requirementsMet = AssessmentManager.meetsCompletionRequirements(assessmentId, requirements);
890
890
  }
891
891
 
892
892
  // Mark as "visited" (i.e., show checkmark) only if visited AND requirements are met.
@@ -1071,6 +1071,20 @@ export function getAllSlides() {
1071
1071
  return slides;
1072
1072
  }
1073
1073
 
1074
+ /**
1075
+ * Recalculates LMS progress using only slides in the learner's active sequence.
1076
+ * Remedial and currently excluded conditional slides must not inflate the
1077
+ * numerator or allow progress_measure to reach 100% early.
1078
+ */
1079
+ export function syncProgressMeasure() {
1080
+ _requireInitialized();
1081
+ const activeSlides = slides.filter(slide => isSlideInSequence(slide, stateManager, assessmentConfigs));
1082
+ return stateManager.updateProgressMeasure(
1083
+ activeSlides.length,
1084
+ activeSlides.map(slide => slide.id)
1085
+ );
1086
+ }
1087
+
1074
1088
  /**
1075
1089
  * Gets the current slide object.
1076
1090
  * @returns {object|null} The current slide, or null if not found.
@@ -16,6 +16,7 @@
16
16
  import { iconManager } from '../utilities/icons.js';
17
17
  import { logger } from '../utilities/logger.js';
18
18
  import { open as openLightbox } from '../components/ui-components/lightbox.js';
19
+ import { resolvePortableAssetUrl } from '../utilities/portable-assets.js';
19
20
 
20
21
  /** @type {HTMLElement|null} */
21
22
  let galleryContainer = null;
@@ -69,14 +70,18 @@ export async function init(courseConfig) {
69
70
  */
70
71
  async function _fetchManifest() {
71
72
  try {
72
- const response = await fetch('./_gallery-manifest.json');
73
+ const response = await fetch(resolvePortableAssetUrl('./_gallery-manifest.json'));
73
74
  if (!response.ok) {
74
75
  // Manifest doesn't exist yet (dev mode or no docs) — not an error
75
76
  logger.debug('[DocumentGallery] No gallery manifest found (this is normal in dev mode)');
76
77
  return null;
77
78
  }
78
79
  const manifest = await response.json();
79
- return manifest.items || [];
80
+ return (manifest.items || []).map(item => ({
81
+ ...item,
82
+ src: resolvePortableAssetUrl(item.src),
83
+ thumbnail: resolvePortableAssetUrl(item.thumbnail)
84
+ }));
80
85
  } catch (_error) {
81
86
  logger.debug('[DocumentGallery] Could not load gallery manifest');
82
87
  return null;
@@ -146,13 +146,14 @@ export function validateNavigationFrom(slide, assessmentConfigs) {
146
146
 
147
147
  // Check if this is an assessment with completion requirements that block navigation
148
148
  if (slide.type === 'assessment') {
149
- const config = assessmentConfigs.get(slide.assessmentId);
149
+ const assessmentId = AssessmentManager.resolveAssessmentId(slide);
150
+ const config = assessmentConfigs.get(assessmentId);
150
151
  const requirements = config?.completionRequirements;
151
152
 
152
153
  // Only block if blockNavigation is explicitly true
153
154
  if (requirements?.blockNavigation === true) {
154
155
  const requirementsMet = AssessmentManager.meetsCompletionRequirements(
155
- slide.assessmentId,
156
+ assessmentId,
156
157
  requirements
157
158
  );
158
159
 
@@ -327,21 +327,33 @@ class LMSConnection {
327
327
  window.addEventListener('pagehide', () => {
328
328
  if (this.isTerminated) return;
329
329
 
330
- // Startup Guard: Ignore pagehide events < 2s after startup
331
- if (Date.now() - this.sessionStartTime < 2000) {
332
- logger.debug('[LMSConnection] Page unload detected immediately after startup (< 2s). Ignoring.');
333
- return;
334
- }
335
-
336
330
  logger.debug('[LMSConnection] Page unload detected. Attempting emergency save...');
337
331
 
338
- // Use emergencySave() for any driver that supports it (sendBeacon guaranteed delivery)
332
+ try {
333
+ stateManager.prepareEmergencySave();
334
+ } catch (error) {
335
+ logger.error('[LMSConnection] Failed to stage emergency state', {
336
+ domain: 'state',
337
+ operation: 'prepareEmergencySave',
338
+ format: this.format,
339
+ error: error.message,
340
+ stack: error.stack
341
+ });
342
+ }
343
+
344
+ // Use the driver's best-effort unload transport when available.
339
345
  if (this.driver?.emergencySave) {
340
346
  try {
341
347
  this.driver.emergencySave();
342
- logger.debug('[LMSConnection] Emergency save completed via sendBeacon');
343
- } catch (e) {
344
- logger.debug('[LMSConnection] Emergency save failed:', e.message);
348
+ logger.debug('[LMSConnection] Emergency save staged via driver transport');
349
+ } catch (error) {
350
+ logger.error('[LMSConnection] Driver emergency save failed', {
351
+ domain: 'lms',
352
+ operation: 'emergencySave',
353
+ format: this.format,
354
+ error: error.message,
355
+ stack: error.stack
356
+ });
345
357
  }
346
358
  return;
347
359
  }
@@ -351,12 +363,24 @@ class LMSConnection {
351
363
  // Delegate to StateManager to save exit status
352
364
  const exitPromise = stateManager.exitCourseWithSuspend();
353
365
  if (exitPromise && typeof exitPromise.catch === 'function') {
354
- exitPromise.catch(e => {
355
- logger.debug('[LMSConnection] Best-effort save during unload did not complete:', e.message);
366
+ exitPromise.catch(error => {
367
+ logger.error('[LMSConnection] Best-effort unload save did not complete', {
368
+ domain: 'lms',
369
+ operation: 'exitCourseWithSuspend',
370
+ format: this.format,
371
+ error: error.message,
372
+ stack: error.stack
373
+ });
356
374
  });
357
375
  }
358
- } catch (e) {
359
- logger.debug('[LMSConnection] Best-effort save during unload did not complete:', e.message);
376
+ } catch (error) {
377
+ logger.error('[LMSConnection] Best-effort unload save did not start', {
378
+ domain: 'lms',
379
+ operation: 'exitCourseWithSuspend',
380
+ format: this.format,
381
+ error: error.message,
382
+ stack: error.stack
383
+ });
360
384
  }
361
385
  });
362
386
  }
@@ -368,8 +392,8 @@ class LMSConnection {
368
392
  _startKeepAlive() {
369
393
  this._stopKeepAlive();
370
394
 
371
- // Only SCORM formats need keep-alive (cmi5/LTI use stateless HTTP)
372
- if (this.format.startsWith('cmi5') || this.format === 'lti') {
395
+ // Only SCORM formats need keep-alive (HTTP and standalone drivers do not)
396
+ if (this.format.startsWith('cmi5') || this.format === 'lti' || this.format === 'standalone') {
373
397
  return;
374
398
  }
375
399
 
@@ -402,6 +426,7 @@ class LMSConnection {
402
426
  if (this.format === 'scorm1.2') return 'strict-scorm12';
403
427
  if (this.format === 'scorm2004') return 'conservative-scorm2004';
404
428
  if (this.format.startsWith('cmi5') || this.format === 'lti') return 'modern-http';
429
+ if (this.format === 'standalone') return 'balanced';
405
430
  return 'balanced';
406
431
  }
407
432
 
@@ -10,6 +10,7 @@ import { classifyLmsError } from './lms-error-utils.js';
10
10
 
11
11
  // Auto-batch debounce delay in milliseconds
12
12
  const AUTO_BATCH_DELAY_MS = 500;
13
+ const MAX_RETRY_DELAY_MS = 60000;
13
14
 
14
15
  export class CommitScheduler {
15
16
  /**
@@ -26,6 +27,7 @@ export class CommitScheduler {
26
27
  this._autoBatchDirty = false;
27
28
  this._autoBatchSuspendDirty = false;
28
29
  this._inFlightCommit = null;
30
+ this._retryDelayMs = AUTO_BATCH_DELAY_MS;
29
31
  }
30
32
 
31
33
  /**
@@ -43,7 +45,7 @@ export class CommitScheduler {
43
45
  }
44
46
 
45
47
  this._autoBatchTimer = setTimeout(() => {
46
- this._executeCommit();
48
+ void this._executeCommit(false);
47
49
  }, AUTO_BATCH_DELAY_MS);
48
50
 
49
51
  logger.debug(`[StateManager] Auto-batch scheduled (${AUTO_BATCH_DELAY_MS}ms debounce)`);
@@ -60,7 +62,7 @@ export class CommitScheduler {
60
62
 
61
63
  if (this._autoBatchDirty) {
62
64
  logger.debug('[StateManager] Force-flushing pending writes (critical action)');
63
- await this._executeCommit();
65
+ await this._executeCommit(true);
64
66
  return;
65
67
  }
66
68
 
@@ -148,7 +150,7 @@ export class CommitScheduler {
148
150
 
149
151
  // --- Private ---
150
152
 
151
- async _executeCommit() {
153
+ async _executeCommit(propagateError = false) {
152
154
  this._autoBatchTimer = null;
153
155
 
154
156
  if (!this._autoBatchDirty) return;
@@ -175,6 +177,7 @@ export class CommitScheduler {
175
177
  await this._inFlightCommit;
176
178
 
177
179
  logger.debug('[StateManager] Auto-batch committed');
180
+ this._retryDelayMs = AUTO_BATCH_DELAY_MS;
178
181
  eventBus.emit('state:commitSuccess');
179
182
  eventBus.emit('state:committed');
180
183
  } catch (error) {
@@ -186,14 +189,16 @@ export class CommitScheduler {
186
189
  }
187
190
  logger.error('[StateManager] Auto-batch commit failed:', { domain: 'state', operation: 'autoBatchCommit', stack: error.stack });
188
191
  eventBus.emit('state:commitFailed', { error: error.message, classification });
192
+ this._retryDelayMs = Math.min(this._retryDelayMs * 2, MAX_RETRY_DELAY_MS);
193
+ if (propagateError) throw error;
189
194
  } finally {
190
195
  this._inFlightCommit = null;
191
196
 
192
197
  // If writes happened during commit, schedule follow-up commit.
193
198
  if (this._autoBatchDirty && !this._autoBatchTimer) {
194
199
  this._autoBatchTimer = setTimeout(() => {
195
- this._executeCommit();
196
- }, AUTO_BATCH_DELAY_MS);
200
+ void this._executeCommit(false);
201
+ }, this._retryDelayMs);
197
202
  }
198
203
  }
199
204
  }