coursecode 0.1.56 → 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 (86) 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 +49 -5
  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 +28 -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 +6 -2
  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/package.json +1 -1
  86. package/template/vite.config.js +50 -22
package/README.md CHANGED
@@ -223,7 +223,13 @@ coursecode deploy --repair-binding
223
223
  coursecode build
224
224
  ```
225
225
 
226
- **Share for review**: Export a standalone preview for stakeholders. Deploy to GitHub Pages, Netlify, or any static host:
226
+ **Share one file with no server**: Create a portable HTML course that recipients can double-click. Local progress restore is best effort and stays in that browser:
227
+
228
+ ```bash
229
+ coursecode export html
230
+ ```
231
+
232
+ **Share a hosted review preview**: Export the preview shell for GitHub Pages, Netlify, or another static host:
227
233
 
228
234
  ```bash
229
235
  coursecode preview --export
@@ -238,6 +244,7 @@ coursecode preview --export
238
244
  | `coursecode create <name>` | Create a new course project; `create .` derives a title-cased course title and hyphenated npm name from the current folder |
239
245
  | `coursecode init [name]` | Initialize the current directory, optionally with a course title |
240
246
  | `coursecode preview` | Preview your course locally |
247
+ | `coursecode export html` | Create one shareable course file that opens without an LMS or server |
241
248
  | `coursecode convert` | Convert PDFs, Word, PowerPoint to markdown |
242
249
  | `coursecode mcp` | Start the MCP server for AI integration |
243
250
  | `coursecode lint` | Validate course structure and content |
package/bin/cli.js CHANGED
@@ -8,6 +8,7 @@
8
8
  * coursecode init [name] - Initialize the current directory
9
9
  * coursecode dev - Start development server
10
10
  * coursecode build - Build course package
11
+ * coursecode export html - Create a portable single-file course
11
12
  * coursecode upgrade - Upgrade framework in current project
12
13
  * coursecode clean - Remove example files from project
13
14
  * coursecode new <type> - Create new slide, assessment, or config
@@ -123,6 +124,21 @@ program
123
124
  await build(options);
124
125
  });
125
126
 
127
+ // Standalone export commands
128
+ const exportCommand = program
129
+ .command('export')
130
+ .description('Export a course for use outside an LMS');
131
+
132
+ exportCommand
133
+ .command('html')
134
+ .description('Create one shareable HTML file that opens without a server')
135
+ .option('-o, --output <file>', 'Output HTML file path (defaults to the course title)')
136
+ .option('--no-lint', 'Skip the course lint step')
137
+ .action(async (options) => {
138
+ const { exportPortableHtml } = await import('../lib/portable-html.js');
139
+ await exportPortableHtml(options);
140
+ });
141
+
126
142
  // Lint command
127
143
  program
128
144
  .command('lint')
@@ -72,7 +72,7 @@ The framework supports multiple LMS formats:
72
72
  | `scorm1.2` | SCORM 1.2 (legacy LMS) | 4KB suspend_data (Strict Diet Mode found in `scorm-12-driver.js`) |
73
73
  | `lti` | LTI 1.3 (remote-hosted, JWT launch) | Unlimited (host-dependent) |
74
74
 
75
- > **Note on SCORM 1.2 Strict Diet Mode:** To stay within the 4KB limit, this mode only persists interaction responses for the *current* slide. If a user navigates away and returns, their previous answers on other slides may not be restored visually, although their completion status/score for assessments is always preserved.
75
+ > **Note on SCORM 1.2 Strict Diet Mode:** The framework preserves the complete CourseCode state and compresses it into an ASCII-safe representation. If the compressed value still exceeds SCORM 1.2's 4,096-character limit, the write fails loudly instead of silently discarding learner state. Courses that exceed the limit must reduce persisted data or use SCORM 2004, cmi5, or LTI.
76
76
 
77
77
  **Set format in `course-config.js`** (for local CLI builds):
78
78
  ```javascript
@@ -138,11 +138,20 @@ The browser only downloads the one chunk matching the meta tag. Unused driver ch
138
138
  |---------|--------|---------|
139
139
  | `coursecode build` | `dist/` | Universal build + format manifest + meta tag stamped |
140
140
  | `coursecode build` (with `PACKAGE=true`) | `dist/` + ZIP | Same + format-specific ZIP for LMS upload |
141
+ | `coursecode export html` | `<course-title>.html` | One portable learner file with an embedded standalone driver and course assets; no LMS manifest or server |
141
142
  | `coursecode preview --export` | `course-preview/` | Copy of `dist/` wrapped in stub player (for Netlify/GitHub Pages) |
142
143
  | `coursecode deploy` | Uploads `dist/` | Cloud hosts universal build, assembles format ZIPs on demand. Flags: `--promote` (force live), `--stage` (force staged), `--preview` (preview-only: production untouched, preview always moved), `--repair-binding` (clear stale local cloud binding first if the remote course was deleted). `--promote`/`--stage` are mutually exclusive; `--preview` stacks with either. **GitHub-linked courses**: production deploy blocked; only `--preview` allowed (see GitHub Source Guard below). |
143
144
 
144
145
  The ZIP never includes preview/stub player assets. Preview is a separate concern (see below).
145
146
 
147
+ #### Portable HTML Architecture
148
+
149
+ `coursecode export html` is intentionally smaller than preview export. It builds the normal learner runtime with `LMS_FORMAT=standalone`, uses `vite-plugin-singlefile` to inline generated JavaScript and CSS, embeds copied files from `course/assets/` plus the document-gallery manifest as data URLs, and omits LMS schemas, manifests, packaging, and the preview/stub-player shell.
150
+
151
+ The standalone driver keeps the same semantic state contract as the LMS drivers. It saves bookmark, completion, success, score, and CourseCode suspend state to browser `localStorage`, keyed by course title and version. Restore is best effort because browsers define storage behavior for `file://` documents; when storage is unavailable, the course remains usable and keeps state only for the open session.
152
+
153
+ Portable HTML is for direct file sharing, offline reference/training, USB delivery, and unmanaged kiosks. It does not provide learner identity, centralized reporting, cross-device sync, tamper-resistant records, or an administrator view. Remote embeds such as YouTube still require network access, and media-heavy courses can produce large HTML files.
154
+
146
155
  #### Re-Stamping for Different Formats
147
156
 
148
157
  `lib/build-packaging.js` exports `stampFormat(html, format)` for re-stamping the meta tag:
@@ -166,7 +175,9 @@ Both are pure Node utilities.
166
175
  |------|---------|
167
176
  | `framework/js/state/lms-connection.js` | `getLMSFormat()` — runtime priority chain (meta → env → default) |
168
177
  | `framework/js/drivers/driver-factory.js` | Dynamic `import()` switch — loads one driver at runtime |
178
+ | `framework/js/drivers/standalone-driver.js` | Best-effort local persistence for portable HTML |
169
179
  | `lib/build-packaging.js` | `stampFormat()` — pure string meta tag re-stamping; `stampFormatInHtml()` — file-based wrapper |
180
+ | `lib/portable-html.js` | Single-file Vite plugin wrapper, copied-asset embedding, and export validation |
170
181
  | `lib/manifest/manifest-factory.js` | `generateManifest()` — generates format-specific manifests |
171
182
  | Both `vite.config.js` files | Post-build `closeBundle` hook stamps meta tag into `dist/index.html` |
172
183
 
@@ -385,7 +396,7 @@ After images are acquired, the module:
385
396
  | `state-validation.js` | State hydration, migration, validation |
386
397
  | `transaction-log.js` | Ring buffer for debugging |
387
398
 
388
- > **Compression:** `cmi.suspend_data` is automatically compressed via `lz-string` (UTF16) in the driver layer. This is transparent to all consumers.
399
+ > **Persistence encoding:** SCORM 2004 uses `lz-string` UTF-16 compression. SCORM 1.2 uses an ASCII-safe `CC12:` encoded representation because legacy LMS implementations may reject UTF-16 suspend data. cmi5 and LTI persist structured JSON through their HTTP state endpoints. These format differences are transparent to framework consumers.
389
400
 
390
401
  #### Domain API
391
402
 
@@ -474,7 +485,7 @@ Migrations run sequentially (v1→v2→v3), so each only handles one version jum
474
485
 
475
486
  ## Logging & Error Handling
476
487
 
477
- **Two complementary systems - use BOTH:**
488
+ **Logger and event communication are integrated:** use `logger.*` for observability. `logger.warn()`, `logger.error()`, and `logger.fatal()` automatically emit structured `log:warn` or `log:error` events. Emit an additional domain event only when another framework component needs to react to that specific failure; do not duplicate a `log:error` event manually.
478
489
 
479
490
  ### Logger (Observability)
480
491
 
@@ -495,7 +506,7 @@ Global as `logger` or `window.logger` (no import needed). Auto-filtered by envir
495
506
  |------|------|----------|---------|
496
507
  | **Tier 1** | Contract violations, programming errors | Always `throw` | Wrong parameter types, API misuse, double-init |
497
508
  | **Tier 2** | Runtime/init failures (missing DOM, bad config) | `logger.fatal()` → throws in DEV, logs+degrades in PROD | Missing container element, invalid data attributes |
498
- | **Tier 3** | Existing dual-mode functions | Logger + eventBus only | Driver warnings, state persistence failures |
509
+ | **Tier 3** | Recoverable background or unload failures | Structured logger event; retain/retry or emit a domain event when actionable | Debounced commit retry, best-effort unload save |
499
510
 
500
511
  **`logger.fatal(message, context)`** — Tier 2 handler in `framework/js/utilities/logger.js`:
501
512
 
@@ -515,7 +526,14 @@ All error events follow the standardized shape:
515
526
  { domain, operation, message, stack?, context? }
516
527
  ```
517
528
 
518
- **When to catch:** Only to show user notification or cleanup, then MUST re-throw:
529
+ **When to catch depends on who can still act on the failure:**
530
+
531
+ - **Critical awaited work** (assessment submission, completion, explicit flush/terminate): log or clean up, then re-throw so the caller cannot treat the operation as successful.
532
+ - **Detached background work** (debounced commits): do not create an unhandled rejection. Log with structured context, preserve dirty state, and retry or emit an actionable domain event.
533
+ - **Unload/pagehide work**: the browser is leaving and cannot await a recovery path. Use the driver's best-effort unload transport and log any synchronous or asynchronous failure with structured context; do not re-throw into the abandoned event handler.
534
+ - **Expected capability probes**: a documented unsupported optional LMS field may degrade to a conservative default. Do not suppress failures for required resume, completion, score, or assessment fields.
535
+
536
+ Critical-path example:
519
537
 
520
538
  ```javascript
521
539
  try {
@@ -633,11 +651,11 @@ Courses linked to GitHub for production deploys are protected from accidental CL
633
651
  | Layer | Mechanism | Key |
634
652
  |-------|-----------|-----|
635
653
  | **CLI (local)** | `deploy()` reads `sourceType` from `.coursecoderc.json` | Fast fail before build — no wasted time |
636
- | **Server (safety net)** | Deploy endpoint rejects non-preview uploads for `source_type = 'github'` | Can't bypass via old CLI or `curl` |
654
+ | **Server (safety net)** | Finalize endpoint rejects non-preview CLI uploads when `github_repo` is set | Can't bypass via old CLI or `curl` |
637
655
 
638
656
  - **`--preview` always allowed** — useful for testing without touching production pointer
639
657
  - **`.coursecoderc.json`** carries `sourceType` and `githubRepo` (committed to repo by cloud's GitHub integration), so anyone cloning gets the guard automatically
640
- - **Self-healing on unlink:** If the cloud course is unlinked from GitHub, both `status()` and `deploy()` reconcile — they detect the server no longer reports `source_type: 'github'` and clear the local `sourceType`/`githubRepo` from `.coursecoderc.json`. No manual cleanup or repo commit needed.
658
+ - **Self-healing link state:** Both `status()` and `deploy()` treat the server's `github_repo` as authoritative. They add or refresh local `sourceType`/`githubRepo` metadata when linked and clear it when unlinked. No manual cleanup or repo commit is needed.
641
659
  - CLI error code: `github_source_blocked` (structured JSON for Desktop/CI consumers)
642
660
 
643
661
  ---
@@ -51,6 +51,7 @@ A complete guide to creating interactive e-learning courses with AI assistance.
51
51
  9. [Sharing and Deploying](#sharing-and-deploying)
52
52
  - [Sharing Previews](#sharing-previews)
53
53
  - [Preview Export Options](#preview-export-options)
54
+ - [Sharing One File Without a Server](#sharing-one-file-without-a-server)
54
55
  - [Understanding LMS Formats](#understanding-lms-formats)
55
56
  - [Standard Deployment](#standard-deployment)
56
57
  - [CDN Deployment (Advanced)](#cdn-deployment-advanced)
@@ -637,6 +638,19 @@ coursecode preview --export --no-content
637
638
  - `--nojekyll`: add `.nojekyll` (important for GitHub Pages)
638
639
  - `--no-content`: hide the content viewer panel in exported preview
639
640
 
641
+ ### Sharing One File Without a Server
642
+
643
+ For training that will be opened directly from email, a shared drive, USB media, or a local computer, export one portable HTML file:
644
+
645
+ ```bash
646
+ coursecode export html
647
+ coursecode export html --output ./Safety_Training.html
648
+ ```
649
+
650
+ The recipient double-clicks the `.html` file and uses it in a modern browser. CourseCode embeds the learner runtime and local course assets, so no LMS, web server, install, or ZIP extraction is required. Progress and resume state are saved locally in the browser when `file://` storage is available. If the browser blocks local storage, the course still works but restore is limited to the current session.
651
+
652
+ This export is a good fit for informal training, reference material, stakeholder review, offline delivery, and unmanaged kiosks. It does not provide centralized learner records, identity, administrator reporting, cross-device sync, or certified completion evidence. Internet-hosted content such as YouTube still needs a connection, and large audio/video files make the exported HTML correspondingly large.
653
+
640
654
  ### Understanding LMS Formats
641
655
 
642
656
  An LMS (Learning Management System) is the platform your organization uses to deliver training — think Cornerstone, Moodle, Canvas, Docebo, etc. CourseCode packages your course in a format your LMS understands.
@@ -107,40 +107,39 @@ export async function checkCompletion() {
107
107
  return false;
108
108
  }
109
109
 
110
- // If already marked as completed, no need to re-evaluate.
111
- if (currentCompletionStatus === 'completed') {
112
- eventBus.emit('course:statusChanged', {
113
- completionStatus: 'completed',
114
- successStatus: stateManager.getSuccess() || 'unknown',
115
- isOnLastSlide: true
116
- });
117
- return true;
118
- }
119
-
120
- // On a completion slide, so evaluate final status.
110
+ // Always re-evaluate on a completion slide. Completion and success are
111
+ // independent: a learner can complete a first attempt with a failed
112
+ // success status, then pass a permitted retake later in the same or a
113
+ // resumed session.
121
114
  const assessmentSlides = await CourseHelpers.getSlidesByType('assessment');
122
115
  const assessmentConfigs = await CourseHelpers.getAssessmentConfigs();
123
116
 
124
117
  // Completion is based on submission requirements.
125
118
  const assessmentsRequiringSubmission = assessmentSlides.filter(s =>
126
- assessmentConfigs.get(s.assessmentId)?.completionRequirements?.requireSubmission
119
+ assessmentConfigs.get(AssessmentManager.resolveAssessmentId(s))?.completionRequirements?.requireSubmission
127
120
  );
128
121
 
129
122
  const allSubmitted = assessmentsRequiringSubmission.every(s =>
130
- AssessmentManager.meetsCompletionRequirements(s.assessmentId, { requireSubmission: true })
123
+ AssessmentManager.meetsCompletionRequirements(
124
+ AssessmentManager.resolveAssessmentId(s),
125
+ { requireSubmission: true }
126
+ )
131
127
  );
132
128
 
133
129
  const completionStatus = allSubmitted ? 'completed' : 'incomplete';
134
130
 
135
131
  // Success is based on passing requirements.
136
132
  const assessmentsRequiringPass = assessmentSlides.filter(s =>
137
- assessmentConfigs.get(s.assessmentId)?.completionRequirements?.requirePass
133
+ assessmentConfigs.get(AssessmentManager.resolveAssessmentId(s))?.completionRequirements?.requirePass
138
134
  );
139
135
 
140
136
  let successStatus = 'unknown';
141
137
  if (assessmentsRequiringPass.length > 0) {
142
138
  const allPassed = assessmentsRequiringPass.every(s =>
143
- AssessmentManager.meetsCompletionRequirements(s.assessmentId, { requirePass: true })
139
+ AssessmentManager.meetsCompletionRequirements(
140
+ AssessmentManager.resolveAssessmentId(s),
141
+ { requirePass: true }
142
+ )
144
143
  );
145
144
  successStatus = allPassed ? 'passed' : 'failed';
146
145
  // Note: Course score reporting is handled by ScoreManager (configure in course-config.js)
@@ -188,14 +187,9 @@ function _finalizeCurrentSlideProgress() {
188
187
  if (currentSlideId) {
189
188
  NavigationState.addVisitedSlide(currentSlideId);
190
189
 
191
- // Update progress measure one final time before exit
192
- const slides = NavigationActions.getAllSlides();
193
- if (slides) {
194
- const totalSequentialSlides = slides.filter(s =>
195
- s.navigation?.sequence?.included !== false
196
- ).length;
197
- stateManager.updateProgressMeasure(totalSequentialSlides);
198
- }
190
+ // Update progress one final time using the same dynamic sequence rules
191
+ // as navigation (conditional/remedial slides must not inflate it).
192
+ NavigationActions.syncProgressMeasure();
199
193
  }
200
194
  }
201
195
 
@@ -24,6 +24,7 @@ const completionFeedbackTemplate = `
24
24
 
25
25
  // Store the active rating interaction instance
26
26
  let activeRatingInteraction = null;
27
+ const isStandaloneRuntime = document.querySelector('meta[name="lms-format"]')?.content === 'standalone';
27
28
 
28
29
  const MODAL_DEFINITIONS = {
29
30
  exit: {
@@ -45,8 +46,9 @@ const MODAL_DEFINITIONS = {
45
46
  title: 'Course Complete!',
46
47
  body: `
47
48
  <p><strong>Congratulations!</strong> You have successfully completed this course.</p>
48
- <p>Your completion status and final score have been recorded in the Learning Management System.</p>
49
- <p>When you click "Complete & Exit" below, this window will close and you will be returned to the LMS.</p>
49
+ ${isStandaloneRuntime
50
+ ? '<p>Your completion status and final score have been saved in this browser.</p><p>When you click "Complete & Exit" below, the session will finish. You can close the file or reopen it later.</p>'
51
+ : '<p>Your completion status and final score have been recorded in the Learning Management System.</p><p>When you click "Complete & Exit" below, this window will close and you will be returned to the LMS.</p>'}
50
52
  ${completionFeedbackTemplate}
51
53
  `,
52
54
  footer: `
@@ -61,8 +63,9 @@ const MODAL_DEFINITIONS = {
61
63
  postExit: {
62
64
  title: 'Session Closed',
63
65
  body: `
64
- <p>Your progress has been saved in the LMS. It is now safe to close this window.</p>
65
- <p>If this window does not close automatically, please close it manually and return to the LMS.</p>
66
+ ${isStandaloneRuntime
67
+ ? '<p>Your progress has been saved in this browser. It is now safe to close this file.</p><p>Open the HTML file again to resume later.</p>'
68
+ : '<p>Your progress has been saved in the LMS. It is now safe to close this window.</p><p>If this window does not close automatically, please close it manually and return to the LMS.</p>'}
66
69
  `,
67
70
  footer: '',
68
71
  config: {
@@ -70,7 +70,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
70
70
  await stateManager.setAttemptNumber(attemptNumber);
71
71
 
72
72
  _manageFooterVisibility('question');
73
- uiManager.showView('question');
73
+ await uiManager.showView('question');
74
74
  }
75
75
 
76
76
  async function handlePrev() {
@@ -79,7 +79,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
79
79
  const currentIndex = stateManager.getCurrentQuestionIndex();
80
80
  if (currentIndex > 0) {
81
81
  await stateManager.setCurrentQuestionIndex(currentIndex - 1);
82
- uiManager.showView('question');
82
+ await uiManager.showView('question');
83
83
  }
84
84
  }
85
85
 
@@ -94,7 +94,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
94
94
  await stateManager.updateSession({ reviewReached: true });
95
95
  await stateManager.setCurrentView('review');
96
96
  _manageFooterVisibility('review');
97
- uiManager.showView('review');
97
+ await uiManager.showView('review');
98
98
  } else {
99
99
  // FIX: Route through handleSubmit to ensure unanswered checks are performed
100
100
  // previously called submitAssessment() directly, bypassing validation
@@ -102,7 +102,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
102
102
  }
103
103
  } else {
104
104
  await stateManager.setCurrentQuestionIndex(currentIndex + 1);
105
- uiManager.showView('question');
105
+ await uiManager.showView('question');
106
106
  }
107
107
  }
108
108
 
@@ -119,21 +119,21 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
119
119
  await stateManager.setCurrentView('question');
120
120
  await stateManager.setCurrentQuestionIndex(index);
121
121
  _manageFooterVisibility('question');
122
- uiManager.showView('question');
122
+ await uiManager.showView('question');
123
123
  }
124
124
 
125
125
  async function handleBackToQuestions() {
126
126
  await _forceSaveCurrentResponse();
127
127
  await stateManager.setCurrentView('question');
128
128
  _manageFooterVisibility('question');
129
- uiManager.showView('question');
129
+ await uiManager.showView('question');
130
130
  }
131
131
 
132
132
  async function handleJumpToReview() {
133
133
  await _forceSaveCurrentResponse();
134
134
  await stateManager.setCurrentView('review');
135
135
  _manageFooterVisibility('review');
136
- uiManager.showView('review');
136
+ await uiManager.showView('review');
137
137
  }
138
138
 
139
139
  async function handleSubmit() {
@@ -242,14 +242,14 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
242
242
  // This will cause slide to create NEW assessment instance
243
243
  const currentSlideId = getCurrentSlideId();
244
244
  if (currentSlideId) {
245
- goToSlide(currentSlideId, { refreshAssessment: true });
245
+ await goToSlide(currentSlideId, { refreshAssessment: true });
246
246
  return; // Exit - new instance will be created
247
247
  }
248
248
  }
249
249
 
250
250
  // Simple reset - same questions
251
251
  _manageFooterVisibility('intro');
252
- uiManager.showView('intro');
252
+ await uiManager.showView('intro');
253
253
  }
254
254
 
255
255
  function handleRestartCourse() {
@@ -353,7 +353,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
353
353
  throw new Error(errorMessage);
354
354
  }
355
355
 
356
- goToSlide(firstRemedialSlide, {
356
+ await goToSlide(firstRemedialSlide, {
357
357
  fromAssessment: config.id,
358
358
  remedialReview: true
359
359
  });
@@ -413,11 +413,6 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
413
413
  const displayData = _prepareResultsDisplayData(finalResults);
414
414
 
415
415
  await stateManager.setCurrentView('results');
416
- _manageFooterVisibility('results');
417
- uiManager.showView('results', displayData);
418
-
419
- // Keep full results in memory for this session only (not persisted)
420
- // This allows detailed review immediately after submission
421
416
 
422
417
  // Automatically set linked objective if configured
423
418
  if (config.assessmentObjective) {
@@ -446,6 +441,12 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
446
441
  // Assessment submission is a critical action - don't rely on debounce
447
442
  await globalStateManager.flush();
448
443
 
444
+ // Only transition the UI after the assessment outcome, objective, and
445
+ // course score are durably committed. A rendering failure must never
446
+ // prevent the LMS from receiving the learner's submitted result.
447
+ _manageFooterVisibility('results');
448
+ await uiManager.showView('results', displayData);
449
+
449
450
  if (typeof config.onComplete === 'function') {
450
451
  config.onComplete(finalResults);
451
452
  }
@@ -231,15 +231,37 @@ export function createAssessmentInstance(config) {
231
231
  });
232
232
  throw error;
233
233
  } else {
234
- // Production mode: Log warning and filter out missing questions
235
- // Also emit event so course can potentially track this
236
- logger.warn(`[AssessmentFactory] ${errorMessage} Continuing with available questions.`);
234
+ // Index-keyed responses cannot be safely paired with a
235
+ // filtered question list. Restart only the in-progress
236
+ // session and preserve the learner's submitted summaries.
237
+ logger.warn(`[AssessmentFactory] ${errorMessage} Restarting the in-progress attempt safely.`);
237
238
  eventBus.emit('state:recovered', {
238
239
  domain: 'assessment',
239
240
  message: errorMessage,
240
241
  context: { assessmentId, missingIds },
241
- action: 'filtered_missing_questions'
242
+ action: 'restarted_changed_assessment'
242
243
  });
244
+
245
+ const replacementQuestions = _selectNewQuestions();
246
+ const existingSession = assessmentState.getSession() || {};
247
+ assessmentState.updateSession({
248
+ ...existingSession,
249
+ currentView: 'intro',
250
+ currentQuestionIndex: 0,
251
+ startTime: null,
252
+ submitted: false,
253
+ responses: {},
254
+ selectedQuestions: replacementQuestions.map(question => question.id),
255
+ reviewReached: false
256
+ }, 'assessment-state:course-update').catch(error => {
257
+ logger.error(`Failed to persist safe assessment restart: ${error.message}`, {
258
+ domain: 'assessment',
259
+ operation: 'recover-changed-questions',
260
+ assessmentId,
261
+ stack: error.stack
262
+ });
263
+ });
264
+ return replacementQuestions;
243
265
  }
244
266
  }
245
267
 
@@ -248,22 +270,19 @@ export function createAssessmentInstance(config) {
248
270
  .filter(q => q !== undefined); // Filter out any missing questions
249
271
  }
250
272
 
273
+ return _selectNewQuestions();
274
+ }
275
+
276
+ function _selectNewQuestions() {
251
277
  if (config.questionBanks && Array.isArray(config.questionBanks) && config.questionBanks.length > 0) {
252
- // New session with banks: select and optionally randomize
253
278
  let selected = _selectQuestionsFromBanks(config.questionBanks);
254
-
255
- if (config.settings?.randomizeQuestions) {
256
- selected = shuffleArray(selected);
257
- }
258
-
279
+ if (config.settings?.randomizeQuestions) selected = shuffleArray(selected);
259
280
  return selected;
260
281
  }
261
282
 
262
- // Direct mode: use questions array as-is
263
283
  if (config.settings?.randomizeQuestions && Array.isArray(config.questions)) {
264
284
  return shuffleArray(config.questions);
265
285
  }
266
-
267
286
  return config.questions || [];
268
287
  }
269
288
 
@@ -320,10 +339,20 @@ export function createAssessmentInstance(config) {
320
339
  // Resolve active questions for this session
321
340
  activeQuestions = _resolveActiveQuestions();
322
341
 
323
- // Persist selection if this is a new bank-based session (fire-and-forget)
324
- if (config.questionBanks && !session?.selectedQuestions) {
342
+ // Persist every randomized order, including direct question arrays.
343
+ // Responses are keyed by index, so resume must reconstruct the exact
344
+ // same question order or answers can be scored against another item.
345
+ const requiresPersistedOrder = Boolean(
346
+ config.questionBanks || config.settings?.randomizeQuestions
347
+ );
348
+ if (requiresPersistedOrder && !session?.selectedQuestions) {
325
349
  assessmentState.setSelectedQuestions(activeQuestions).catch(error => {
326
- logger.error(`Failed to persist selected questions: ${error.message}`, { domain: 'assessment', operation: 'persist-selected-questions', assessmentId });
350
+ logger.error(`Failed to persist selected questions: ${error.message}`, {
351
+ domain: 'assessment',
352
+ operation: 'persist-selected-questions',
353
+ assessmentId,
354
+ stack: error.stack
355
+ });
327
356
  });
328
357
  }
329
358
 
@@ -61,7 +61,7 @@ export function createEngagementMethods(logTrace) {
61
61
  if (!slideId) {
62
62
  throw new Error('CourseCodeAutomation: No active slide');
63
63
  }
64
- if (typeof percentage !== 'number' || percentage < 0 || percentage > 100) {
64
+ if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100) {
65
65
  throw new Error('CourseCodeAutomation: Scroll depth must be a number between 0 and 100');
66
66
  }
67
67
  engagementManager.trackScrollDepth(slideId, percentage);
@@ -143,7 +143,7 @@ export function createEngagementMethods(logTrace) {
143
143
  const currentSlideId = NavigationState.getCurrentSlideId();
144
144
 
145
145
  if (state.duration > 0) {
146
- const targetPosition = state.duration * (state.completionThreshold || 0.95);
146
+ const targetPosition = state.duration * state.completionThreshold;
147
147
  audioManager.seek(targetPosition);
148
148
  }
149
149
 
@@ -163,7 +163,10 @@ export function createEngagementMethods(logTrace) {
163
163
  case 'tab':
164
164
  case 'standalone':
165
165
  if (currentSlideId) {
166
- engagementManager.trackStandaloneAudioComplete(currentSlideId, state.contextId);
166
+ const audioId = state.contextType === 'standalone'
167
+ ? state.contextId.replace(/^standalone-/, '')
168
+ : state.contextId;
169
+ engagementManager.trackStandaloneAudioComplete(currentSlideId, audioId);
167
170
  }
168
171
  break;
169
172
  }
@@ -60,6 +60,7 @@ import engagementManager from '../../engagement/engagement-manager.js';
60
60
  import * as NavigationState from '../../navigation/NavigationState.js';
61
61
  import { logger } from '../../utilities/logger.js';
62
62
  import { iconManager } from '../../utilities/icons.js';
63
+ import { normalizeCompletionThreshold } from '../../utilities/media-utils.js';
63
64
 
64
65
 
65
66
  /** @type {HTMLElement|null} */
@@ -351,6 +352,7 @@ function _handleProgressMousedown(event) {
351
352
 
352
353
  const progressBar = elements.progressBar;
353
354
  const rect = progressBar.getBoundingClientRect();
355
+ if (rect.width <= 0) return;
354
356
 
355
357
  const seek = (clientX) => {
356
358
  const percentage = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
@@ -823,7 +825,7 @@ class StandaloneAudioPlayer {
823
825
  this.audioSrc = container.dataset.audioSrc;
824
826
  this.required = container.dataset.audioRequired === 'true';
825
827
  this.compact = container.dataset.audioCompact === 'true';
826
- this.threshold = parseFloat(container.dataset.audioThreshold) || 0.95;
828
+ this.threshold = normalizeCompletionThreshold(container.dataset.audioThreshold);
827
829
 
828
830
  if (!this.audioId) {
829
831
  throw new Error('[AudioPlayer] Standalone player requires data-audio-id');
@@ -836,6 +838,12 @@ class StandaloneAudioPlayer {
836
838
  this.isActive = false;
837
839
  this.eventHandlers = {};
838
840
  this.elements = {};
841
+ this.domEventHandlers = {
842
+ click: this._handleClick.bind(this),
843
+ progressKeydown: this._handleProgressKeydown.bind(this),
844
+ progressMousedown: this._handleProgressMousedown.bind(this)
845
+ };
846
+ this.dragCleanup = null;
839
847
 
840
848
  this._render();
841
849
  this._cacheElements();
@@ -867,11 +875,11 @@ class StandaloneAudioPlayer {
867
875
  }
868
876
 
869
877
  _setupEventListeners() {
870
- this.container.addEventListener('click', this._handleClick.bind(this));
878
+ this.container.addEventListener('click', this.domEventHandlers.click);
871
879
 
872
880
  if (this.elements.progressBar) {
873
- this.elements.progressBar.addEventListener('keydown', this._handleProgressKeydown.bind(this));
874
- this.elements.progressBar.addEventListener('mousedown', this._handleProgressMousedown.bind(this));
881
+ this.elements.progressBar.addEventListener('keydown', this.domEventHandlers.progressKeydown);
882
+ this.elements.progressBar.addEventListener('mousedown', this.domEventHandlers.progressMousedown);
875
883
  }
876
884
  }
877
885
 
@@ -915,7 +923,9 @@ class StandaloneAudioPlayer {
915
923
  completionThreshold: this.threshold
916
924
  }, this.contextId, 'standalone');
917
925
  } catch (error) {
918
- logger.error(`[AudioPlayer] Failed to load ${this.audioId}:`, error.message);
926
+ if (error.name !== 'AbortError') {
927
+ logger.error(`[AudioPlayer] Failed to load ${this.audioId}:`, error.message);
928
+ }
919
929
  }
920
930
  }
921
931
 
@@ -939,8 +949,11 @@ class StandaloneAudioPlayer {
939
949
  _handleProgressMousedown(event) {
940
950
  if (!this.isActive || !audioManager.hasAudio()) return;
941
951
 
952
+ this.dragCleanup?.();
953
+
942
954
  const progressBar = this.elements.progressBar;
943
955
  const rect = progressBar.getBoundingClientRect();
956
+ if (rect.width <= 0) return;
944
957
 
945
958
  const seek = (clientX) => {
946
959
  const pct = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
@@ -950,15 +963,19 @@ class StandaloneAudioPlayer {
950
963
  seek(event.clientX);
951
964
 
952
965
  const onMouseMove = (e) => seek(e.clientX);
953
- const onMouseUp = () => {
966
+ const cleanupDrag = () => {
954
967
  document.removeEventListener('mousemove', onMouseMove);
955
- document.removeEventListener('mouseup', onMouseUp);
968
+ document.removeEventListener('mouseup', cleanupDrag);
956
969
  progressBar.classList.remove('dragging');
970
+ if (this.dragCleanup === cleanupDrag) {
971
+ this.dragCleanup = null;
972
+ }
957
973
  };
958
974
 
959
975
  progressBar.classList.add('dragging');
960
976
  document.addEventListener('mousemove', onMouseMove);
961
- document.addEventListener('mouseup', onMouseUp);
977
+ document.addEventListener('mouseup', cleanupDrag);
978
+ this.dragCleanup = cleanupDrag;
962
979
  }
963
980
 
964
981
  _subscribeToAudioEvents() {
@@ -1111,6 +1128,11 @@ class StandaloneAudioPlayer {
1111
1128
  }
1112
1129
 
1113
1130
  destroy() {
1131
+ this.dragCleanup?.();
1132
+ this.container.removeEventListener('click', this.domEventHandlers.click);
1133
+ this.elements.progressBar?.removeEventListener('keydown', this.domEventHandlers.progressKeydown);
1134
+ this.elements.progressBar?.removeEventListener('mousedown', this.domEventHandlers.progressMousedown);
1135
+
1114
1136
  eventBus.off('audio:loadStart', this.eventHandlers.loadStart);
1115
1137
  eventBus.off('audio:loaded', this.eventHandlers.loaded);
1116
1138
  eventBus.off('audio:unloaded', this.eventHandlers.unloaded);