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.
- package/README.md +8 -1
- package/bin/cli.js +16 -0
- package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
- package/framework/docs/USER_GUIDE.md +14 -0
- package/framework/js/app/AppActions.js +17 -23
- package/framework/js/app/AppUI.js +7 -4
- package/framework/js/assessment/AssessmentActions.js +16 -15
- package/framework/js/assessment/AssessmentFactory.js +44 -15
- package/framework/js/automation/api-engagement.js +6 -3
- package/framework/js/components/ui-components/audio-player.js +30 -8
- package/framework/js/components/ui-components/collapse.js +4 -2
- package/framework/js/components/ui-components/dropdown.js +7 -3
- package/framework/js/components/ui-components/embed-frame.js +5 -1
- package/framework/js/components/ui-components/flip-card.js +12 -6
- package/framework/js/components/ui-components/interactive-image.js +28 -52
- package/framework/js/components/ui-components/lightbox.js +19 -5
- package/framework/js/components/ui-components/modal.js +20 -7
- package/framework/js/components/ui-components/notifications.js +8 -3
- package/framework/js/components/ui-components/video-player.js +46 -20
- package/framework/js/core/event-bus.js +31 -28
- package/framework/js/drivers/cmi5-driver.js +186 -83
- package/framework/js/drivers/driver-factory.js +7 -1
- package/framework/js/drivers/driver-interface.js +1 -1
- package/framework/js/drivers/http-driver-base.js +6 -0
- package/framework/js/drivers/lti-driver.js +112 -47
- package/framework/js/drivers/proxy-driver.js +347 -49
- package/framework/js/drivers/scorm-12-driver.js +213 -130
- package/framework/js/drivers/scorm-2004-driver.js +32 -19
- package/framework/js/drivers/scorm-driver-base.js +12 -0
- package/framework/js/drivers/standalone-driver.js +139 -0
- package/framework/js/engagement/engagement-trackers.js +38 -13
- package/framework/js/engagement/requirement-strategies.js +5 -1
- package/framework/js/main.js +7 -0
- package/framework/js/managers/assessment-manager.js +16 -1
- package/framework/js/managers/audio-manager.js +50 -16
- package/framework/js/managers/flag-manager.js +5 -1
- package/framework/js/managers/objective-manager.js +15 -7
- package/framework/js/managers/score-manager.js +27 -6
- package/framework/js/managers/video-manager.js +149 -70
- package/framework/js/navigation/NavigationActions.js +18 -4
- package/framework/js/navigation/document-gallery.js +7 -2
- package/framework/js/navigation/navigation-validators.js +3 -2
- package/framework/js/state/lms-connection.js +41 -16
- package/framework/js/state/state-commits.js +10 -5
- package/framework/js/state/state-manager.js +95 -9
- package/framework/js/state/state-validation.js +44 -11
- package/framework/js/utilities/course-helpers.js +3 -2
- package/framework/js/utilities/media-utils.js +28 -0
- package/framework/js/utilities/portable-assets.js +131 -0
- package/framework/js/utilities/ui-initializer.js +79 -5
- package/framework/js/utilities/utilities.js +32 -20
- package/framework/js/utilities/view-manager.js +16 -1
- package/framework/js/validation/scorm-validators.js +69 -0
- package/framework/version.json +2 -2
- package/lib/authoring-api.js +37 -18
- package/lib/build-packaging.js +47 -3
- package/lib/build.js +1 -1
- package/lib/cloud.js +40 -11
- package/lib/convert.js +30 -2
- package/lib/create.js +5 -3
- package/lib/dev.js +1 -1
- package/lib/headless-browser.js +66 -14
- package/lib/manifest/cmi5-manifest.js +26 -4
- package/lib/manifest/lti-tool-config.js +7 -2
- package/lib/manifest/manifest-factory.d.ts +1 -1
- package/lib/manifest/manifest-factory.js +3 -2
- package/lib/manifest/scorm-12-manifest.js +2 -2
- package/lib/manifest/scorm-2004-manifest.js +3 -28
- package/lib/manifest/scorm-proxy-manifest.js +37 -32
- package/lib/manifest/xml-utils.js +10 -0
- package/lib/narration.js +2 -2
- package/lib/portable-html.js +196 -0
- package/lib/preview-export.js +1 -1
- package/lib/preview-server.js +27 -6
- package/lib/project-utils.js +9 -1
- package/lib/proxy-templates/proxy.html +4 -1
- package/lib/proxy-templates/scorm-bridge.js +82 -10
- package/lib/scaffold.js +9 -4
- package/lib/stub-player/lms-api.js +63 -8
- package/package.json +5 -1
- package/schemas/adlcp_rootv1p2.xsd +110 -0
- package/schemas/coursecode_scorm12_package.xsd +5 -0
- package/schemas/coursecode_scorm2004_package.xsd +8 -0
- package/schemas/imscp_rootv1p1p2.xsd +304 -0
- 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
|
|
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:**
|
|
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
|
-
> **
|
|
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
|
-
**
|
|
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** |
|
|
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
|
|
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)** |
|
|
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
|
|
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
|
-
//
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
119
|
+
assessmentConfigs.get(AssessmentManager.resolveAssessmentId(s))?.completionRequirements?.requireSubmission
|
|
127
120
|
);
|
|
128
121
|
|
|
129
122
|
const allSubmitted = assessmentsRequiringSubmission.every(s =>
|
|
130
|
-
AssessmentManager.meetsCompletionRequirements(
|
|
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
|
|
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(
|
|
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
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
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: '
|
|
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
|
|
324
|
-
|
|
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}`, {
|
|
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 (
|
|
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 *
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
878
|
+
this.container.addEventListener('click', this.domEventHandlers.click);
|
|
871
879
|
|
|
872
880
|
if (this.elements.progressBar) {
|
|
873
|
-
this.elements.progressBar.addEventListener('keydown', this.
|
|
874
|
-
this.elements.progressBar.addEventListener('mousedown', 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
|
-
|
|
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
|
|
966
|
+
const cleanupDrag = () => {
|
|
954
967
|
document.removeEventListener('mousemove', onMouseMove);
|
|
955
|
-
document.removeEventListener('mouseup',
|
|
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',
|
|
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);
|