coursecode 0.1.57 → 0.1.59

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 (95) hide show
  1. package/README.md +8 -1
  2. package/bin/cli.js +16 -0
  3. package/framework/css/components/accordions.css +117 -31
  4. package/framework/css/layouts/article.css +7 -0
  5. package/framework/docs/COURSE_AUTHORING_GUIDE.md +4 -2
  6. package/framework/docs/FRAMEWORK_GUIDE.md +25 -7
  7. package/framework/docs/USER_GUIDE.md +14 -0
  8. package/framework/js/app/AppActions.js +17 -23
  9. package/framework/js/app/AppUI.js +7 -4
  10. package/framework/js/assessment/AssessmentActions.js +16 -15
  11. package/framework/js/assessment/AssessmentFactory.js +44 -15
  12. package/framework/js/automation/api-engagement.js +6 -3
  13. package/framework/js/components/interactions/drag-drop.js +4 -4
  14. package/framework/js/components/interactions/fill-in.js +7 -4
  15. package/framework/js/components/interactions/hotspot.js +2 -2
  16. package/framework/js/components/interactions/matching.js +4 -3
  17. package/framework/js/components/ui-components/audio-player.js +30 -8
  18. package/framework/js/components/ui-components/collapse.js +4 -2
  19. package/framework/js/components/ui-components/dropdown.js +7 -3
  20. package/framework/js/components/ui-components/embed-frame.js +5 -1
  21. package/framework/js/components/ui-components/flip-card.js +12 -6
  22. package/framework/js/components/ui-components/interactive-image.js +28 -52
  23. package/framework/js/components/ui-components/lightbox.js +19 -5
  24. package/framework/js/components/ui-components/modal.js +20 -7
  25. package/framework/js/components/ui-components/notifications.js +8 -3
  26. package/framework/js/components/ui-components/video-player.js +46 -20
  27. package/framework/js/core/event-bus.js +31 -28
  28. package/framework/js/dev/runtime-linter.js +6 -2
  29. package/framework/js/drivers/cmi5-driver.js +186 -83
  30. package/framework/js/drivers/driver-factory.js +7 -1
  31. package/framework/js/drivers/driver-interface.js +1 -1
  32. package/framework/js/drivers/http-driver-base.js +6 -0
  33. package/framework/js/drivers/lti-driver.js +112 -47
  34. package/framework/js/drivers/proxy-driver.js +347 -49
  35. package/framework/js/drivers/scorm-12-driver.js +213 -130
  36. package/framework/js/drivers/scorm-2004-driver.js +32 -19
  37. package/framework/js/drivers/scorm-driver-base.js +12 -0
  38. package/framework/js/drivers/standalone-driver.js +139 -0
  39. package/framework/js/engagement/engagement-trackers.js +38 -13
  40. package/framework/js/engagement/requirement-strategies.js +5 -1
  41. package/framework/js/main.js +7 -0
  42. package/framework/js/managers/assessment-manager.js +16 -1
  43. package/framework/js/managers/audio-manager.js +50 -16
  44. package/framework/js/managers/flag-manager.js +5 -1
  45. package/framework/js/managers/objective-manager.js +15 -7
  46. package/framework/js/managers/score-manager.js +27 -6
  47. package/framework/js/managers/video-manager.js +149 -70
  48. package/framework/js/navigation/NavigationActions.js +18 -4
  49. package/framework/js/navigation/document-gallery.js +7 -2
  50. package/framework/js/navigation/navigation-validators.js +3 -2
  51. package/framework/js/state/lms-connection.js +41 -16
  52. package/framework/js/state/state-commits.js +10 -5
  53. package/framework/js/state/state-manager.js +95 -9
  54. package/framework/js/state/state-validation.js +44 -11
  55. package/framework/js/utilities/course-helpers.js +3 -2
  56. package/framework/js/utilities/media-utils.js +28 -0
  57. package/framework/js/utilities/portable-assets.js +151 -0
  58. package/framework/js/utilities/ui-initializer.js +79 -5
  59. package/framework/js/utilities/utilities.js +32 -20
  60. package/framework/js/utilities/view-manager.js +16 -1
  61. package/framework/js/validation/scorm-validators.js +69 -0
  62. package/framework/version.json +2 -2
  63. package/lib/authoring-api.js +37 -18
  64. package/lib/build-linter.js +192 -2
  65. package/lib/build-packaging.js +47 -3
  66. package/lib/build.js +69 -8
  67. package/lib/cloud.js +40 -11
  68. package/lib/convert.js +30 -2
  69. package/lib/create.js +5 -3
  70. package/lib/dev.js +1 -1
  71. package/lib/headless-browser.js +66 -14
  72. package/lib/manifest/cmi5-manifest.js +26 -4
  73. package/lib/manifest/lti-tool-config.js +7 -2
  74. package/lib/manifest/manifest-factory.d.ts +1 -1
  75. package/lib/manifest/manifest-factory.js +3 -2
  76. package/lib/manifest/scorm-12-manifest.js +2 -2
  77. package/lib/manifest/scorm-2004-manifest.js +3 -28
  78. package/lib/manifest/scorm-proxy-manifest.js +37 -32
  79. package/lib/manifest/xml-utils.js +10 -0
  80. package/lib/narration.js +2 -2
  81. package/lib/portable-html.js +196 -0
  82. package/lib/preview-export.js +1 -1
  83. package/lib/preview-server.js +27 -6
  84. package/lib/project-utils.js +9 -1
  85. package/lib/proxy-templates/proxy.html +4 -1
  86. package/lib/proxy-templates/scorm-bridge.js +82 -10
  87. package/lib/scaffold.js +9 -4
  88. package/lib/stub-player/lms-api.js +63 -8
  89. package/package.json +5 -1
  90. package/schemas/adlcp_rootv1p2.xsd +110 -0
  91. package/schemas/coursecode_scorm12_package.xsd +5 -0
  92. package/schemas/coursecode_scorm2004_package.xsd +8 -0
  93. package/schemas/imscp_rootv1p1p2.xsd +304 -0
  94. package/template/course/slides/example-ui-showcase.js +1 -1
  95. 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')
@@ -247,8 +247,10 @@
247
247
  ACCORDION STYLE VARIANTS
248
248
  ============================================================================
249
249
 
250
- Apply these data attributes to the .accordion container to change
251
- the visual appearance.
250
+ Global theme tokens are bridged to data attributes on <html>. Authors may
251
+ override that default on an individual .accordion. Keep every variant rule
252
+ scoped through the accordion container so a local attribute wins over an
253
+ inherited global attribute.
252
254
 
253
255
  USAGE:
254
256
  <div class="accordion" data-accordion-style="flush">
@@ -260,33 +262,51 @@
260
262
  /* --------------------------------------
261
263
  Flush Style - No outer border, dividers only
262
264
  -------------------------------------- */
263
- [data-accordion-style="flush"].accordion {
265
+ :is(
266
+ [data-accordion-style="flush"] .accordion:not([data-accordion-style]),
267
+ .accordion[data-accordion-style="flush"]
268
+ ) {
264
269
  border: none;
265
270
  border-radius: 0;
266
271
  box-shadow: none;
267
272
  background: transparent;
268
273
  }
269
274
 
270
- [data-accordion-style="flush"] .accordion-item {
275
+ :is(
276
+ [data-accordion-style="flush"] .accordion:not([data-accordion-style]),
277
+ .accordion[data-accordion-style="flush"]
278
+ ) .accordion-item {
271
279
  background: transparent;
272
280
  border-bottom: 1px solid var(--color-gray-200);
273
281
  }
274
282
 
275
- [data-accordion-style="flush"] .accordion-item:last-child {
283
+ :is(
284
+ [data-accordion-style="flush"] .accordion:not([data-accordion-style]),
285
+ .accordion[data-accordion-style="flush"]
286
+ ) .accordion-item:last-child {
276
287
  border-bottom: 1px solid var(--color-gray-200);
277
288
  }
278
289
 
279
- [data-accordion-style="flush"] .accordion-button {
290
+ :is(
291
+ [data-accordion-style="flush"] .accordion:not([data-accordion-style]),
292
+ .accordion[data-accordion-style="flush"]
293
+ ) .accordion-button {
280
294
  padding-left: 0;
281
295
  padding-right: 0;
282
296
  }
283
297
 
284
- [data-accordion-style="flush"] .accordion-body {
298
+ :is(
299
+ [data-accordion-style="flush"] .accordion:not([data-accordion-style]),
300
+ .accordion[data-accordion-style="flush"]
301
+ ) .accordion-body {
285
302
  padding-left: 0;
286
303
  padding-right: 0;
287
304
  }
288
305
 
289
- [data-accordion-style="flush"] .accordion-content {
306
+ :is(
307
+ [data-accordion-style="flush"] .accordion:not([data-accordion-style]),
308
+ .accordion[data-accordion-style="flush"]
309
+ ) .accordion-content {
290
310
  background: transparent;
291
311
  }
292
312
 
@@ -295,7 +315,10 @@
295
315
  /* --------------------------------------
296
316
  Separated Style - Gap between items
297
317
  -------------------------------------- */
298
- [data-accordion-style="separated"].accordion {
318
+ :is(
319
+ [data-accordion-style="separated"] .accordion:not([data-accordion-style]),
320
+ .accordion[data-accordion-style="separated"]
321
+ ) {
299
322
  border: none;
300
323
  border-radius: 0;
301
324
  box-shadow: none;
@@ -305,18 +328,27 @@
305
328
  gap: var(--space-3);
306
329
  }
307
330
 
308
- [data-accordion-style="separated"] .accordion-item {
331
+ :is(
332
+ [data-accordion-style="separated"] .accordion:not([data-accordion-style]),
333
+ .accordion[data-accordion-style="separated"]
334
+ ) .accordion-item {
309
335
  border: 1px solid var(--color-gray-200);
310
336
  border-radius: var(--radius-lg);
311
337
  overflow: hidden;
312
338
  box-shadow: var(--shadow-sm);
313
339
  }
314
340
 
315
- [data-accordion-style="separated"] .accordion-item:last-child {
341
+ :is(
342
+ [data-accordion-style="separated"] .accordion:not([data-accordion-style]),
343
+ .accordion[data-accordion-style="separated"]
344
+ ) .accordion-item:last-child {
316
345
  border-bottom: 1px solid var(--color-gray-200);
317
346
  }
318
347
 
319
- [data-accordion-style="separated"] .accordion-button:not(.collapsed) {
348
+ :is(
349
+ [data-accordion-style="separated"] .accordion:not([data-accordion-style]),
350
+ .accordion[data-accordion-style="separated"]
351
+ ) .accordion-button:not(.collapsed) {
320
352
  border-bottom: none;
321
353
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
322
354
  }
@@ -326,56 +358,86 @@
326
358
  /* --------------------------------------
327
359
  Minimal Style - No borders, subtle hover
328
360
  -------------------------------------- */
329
- [data-accordion-style="minimal"].accordion {
361
+ :is(
362
+ [data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
363
+ .accordion[data-accordion-style="minimal"]
364
+ ) {
330
365
  border: none;
331
366
  border-radius: 0;
332
367
  box-shadow: none;
333
368
  background: transparent;
334
369
  }
335
370
 
336
- [data-accordion-style="minimal"] .accordion-item {
371
+ :is(
372
+ [data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
373
+ .accordion[data-accordion-style="minimal"]
374
+ ) .accordion-item {
337
375
  background: transparent;
338
376
  border-bottom: none;
339
377
  }
340
378
 
341
- [data-accordion-style="minimal"] .accordion-button {
379
+ :is(
380
+ [data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
381
+ .accordion[data-accordion-style="minimal"]
382
+ ) .accordion-button {
342
383
  padding: var(--space-3) var(--space-4);
343
384
  border-radius: var(--radius-md);
344
385
  }
345
386
 
346
- [data-accordion-style="minimal"] .accordion-button:hover {
387
+ :is(
388
+ [data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
389
+ .accordion[data-accordion-style="minimal"]
390
+ ) .accordion-button:hover {
347
391
  background: var(--bg-muted);
348
392
  }
349
393
 
350
- [data-accordion-style="minimal"] .accordion-button:not(.collapsed) {
394
+ :is(
395
+ [data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
396
+ .accordion[data-accordion-style="minimal"]
397
+ ) .accordion-button:not(.collapsed) {
351
398
  background: var(--color-primary-alpha-05);
352
399
  border-bottom: none;
353
400
  }
354
401
 
355
- [data-accordion-style="minimal"] .accordion-content {
402
+ :is(
403
+ [data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
404
+ .accordion[data-accordion-style="minimal"]
405
+ ) .accordion-content {
356
406
  background: transparent;
357
407
  }
358
408
 
359
- [data-accordion-style="minimal"] .accordion-body {
409
+ :is(
410
+ [data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
411
+ .accordion[data-accordion-style="minimal"]
412
+ ) .accordion-body {
360
413
  padding: var(--space-2) var(--space-4) var(--space-4);
361
414
  }
362
415
 
363
416
  /* Dark mode for minimal */
364
- [data-theme="dark"] [data-accordion-style="minimal"] .accordion-button:not(.collapsed) {
417
+ :is(
418
+ [data-theme="dark"][data-accordion-style="minimal"] .accordion:not([data-accordion-style]),
419
+ [data-theme="dark"] .accordion[data-accordion-style="minimal"]
420
+ ) .accordion-button:not(.collapsed) {
365
421
  background: var(--color-primary-alpha-20);
366
422
  }
367
423
 
368
424
  /* --------------------------------------
369
425
  Boxed Style - Each item is a bordered box
370
426
  -------------------------------------- */
371
- [data-accordion-style="boxed"].accordion {
427
+ :is(
428
+ [data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
429
+ .accordion[data-accordion-style="boxed"]
430
+ ) {
372
431
  border: none;
373
432
  border-radius: 0;
374
433
  box-shadow: none;
375
434
  background: transparent;
376
435
  }
377
436
 
378
- [data-accordion-style="boxed"] .accordion-item {
437
+ :is(
438
+ [data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
439
+ .accordion[data-accordion-style="boxed"]
440
+ ) .accordion-item {
379
441
  border: 2px solid var(--color-gray-200);
380
442
  border-radius: var(--radius-lg);
381
443
  margin-bottom: var(--space-3);
@@ -383,38 +445,62 @@
383
445
  background: var(--bg-surface);
384
446
  }
385
447
 
386
- [data-accordion-style="boxed"] .accordion-item:last-child {
448
+ :is(
449
+ [data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
450
+ .accordion[data-accordion-style="boxed"]
451
+ ) .accordion-item:last-child {
387
452
  margin-bottom: 0;
388
453
  }
389
454
 
390
- [data-accordion-style="boxed"] .accordion-button {
455
+ :is(
456
+ [data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
457
+ .accordion[data-accordion-style="boxed"]
458
+ ) .accordion-button {
391
459
  background: var(--bg-subtle);
392
460
  }
393
461
 
394
- [data-accordion-style="boxed"] .accordion-button:hover {
462
+ :is(
463
+ [data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
464
+ .accordion[data-accordion-style="boxed"]
465
+ ) .accordion-button:hover {
395
466
  background: var(--bg-muted);
396
467
  }
397
468
 
398
- [data-accordion-style="boxed"] .accordion-button:not(.collapsed) {
469
+ :is(
470
+ [data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
471
+ .accordion[data-accordion-style="boxed"]
472
+ ) .accordion-button:not(.collapsed) {
399
473
  background: var(--color-primary);
400
474
  color: var(--color-white);
401
475
  border-bottom: none;
402
476
  }
403
477
 
404
- [data-accordion-style="boxed"] .accordion-button:not(.collapsed) .accordion-icon {
478
+ :is(
479
+ [data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
480
+ .accordion[data-accordion-style="boxed"]
481
+ ) .accordion-button:not(.collapsed) .accordion-icon {
405
482
  color: var(--color-white);
406
483
  }
407
484
 
408
- [data-accordion-style="boxed"] .accordion-content {
485
+ :is(
486
+ [data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
487
+ .accordion[data-accordion-style="boxed"]
488
+ ) .accordion-content {
409
489
  border-top: 2px solid var(--color-gray-200);
410
490
  }
411
491
 
412
492
  /* Dark mode for boxed */
413
- [data-theme="dark"] [data-accordion-style="boxed"] .accordion-item {
493
+ :is(
494
+ [data-theme="dark"][data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
495
+ [data-theme="dark"] .accordion[data-accordion-style="boxed"]
496
+ ) .accordion-item {
414
497
  border-color: var(--border-default);
415
498
  }
416
499
 
417
- [data-theme="dark"] [data-accordion-style="boxed"] .accordion-content {
500
+ :is(
501
+ [data-theme="dark"][data-accordion-style="boxed"] .accordion:not([data-accordion-style]),
502
+ [data-theme="dark"] .accordion[data-accordion-style="boxed"]
503
+ ) .accordion-content {
418
504
  border-top-color: var(--border-default);
419
505
  }
420
506
 
@@ -707,4 +793,4 @@
707
793
  [data-theme="dark"] .accordion-badge-outline {
708
794
  border-color: rgba(var(--color-primary-rgb), 0.4);
709
795
  color: var(--color-primary-light);
710
- }
796
+ }
@@ -101,6 +101,13 @@
101
101
  margin: 0 auto;
102
102
  }
103
103
 
104
+ /* Breadcrumbs are positioned absolutely and do not reserve layout space.
105
+ Add clearance only while they are visible; mobile hides them entirely. */
106
+ html:not(.bp-max-mobile-landscape)[data-layout="article"]
107
+ .breadcrumbs:not(.hidden):not([hidden]) ~ #slide-container {
108
+ padding-top: var(--space-4);
109
+ }
110
+
104
111
  /* Footer styling moved to components/footer.css - DATA-LAYOUT VARIANTS section */
105
112
 
106
113
  /* Audio player styling moved to components/audio-player.css - DATA-LAYOUT VARIANTS section */
@@ -618,7 +618,7 @@ createNumericQuestion({
618
618
  ```javascript
619
619
  createHotspotQuestion({
620
620
  id: 'hot1', prompt: 'Click the correct region:',
621
- image: { src: 'assets/images/diagram.png', alt: 'Diagram' },
621
+ image: { src: 'assets/images/diagram.png', alt: 'Diagram' }, // Relative to course/assets/
622
622
  hotspots: [
623
623
  // pos = [x%, y%, width%, height%]
624
624
  { id: 'zone1', pos: [10, 20, 15, 10], correct: true, label: 'Correct Zone', feedback: 'Good choice!' },
@@ -627,6 +627,8 @@ createHotspotQuestion({
627
627
  });
628
628
  ```
629
629
 
630
+ Interaction APIs such as `createHotspotQuestion()` resolve relative asset paths against `course/assets/`. In direct HTML attributes such as `<img src>`, use the packaged path `course/assets/...` as shown in the interactive-image example below. Declarative components may document their own path base; for example, lightbox `href` values are relative to `course/` and normally begin with `assets/`.
631
+
630
632
  **Appearance themes**: `'correct'`, `'incorrect'`, `'primary'`, `'accent'` — or provide custom `appearance` object.
631
633
 
632
634
  ### Sequencing
@@ -1477,7 +1479,7 @@ parent.postMessage({ type: 'coursecode:resize', height: 400 }, '*');
1477
1479
 
1478
1480
  ```html
1479
1481
  <div data-component="interactive-image" id="diagram">
1480
- <img src="assets/images/diagram.png" alt="Diagram" />
1482
+ <img src="course/assets/images/diagram.png" alt="Diagram" />
1481
1483
  <button data-hotspot-id="zone1" data-title="Component A" data-body="Description here..."
1482
1484
  class="interactive-image-hotspot" style="top: 20%; left: 30%;">A</button>
1483
1485
  <button data-hotspot-id="zone2" data-title="Component B" data-body="More details..."
@@ -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: {