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
@@ -51,21 +51,14 @@ export function init(root, _options = {}) {
51
51
  const currentSlideId = NavigationState.getCurrentSlideId();
52
52
  const imageId = container.id || `interactive-image-${Math.random().toString(36).substr(2, 9)}`;
53
53
  const accordionId = container.dataset.accordionId;
54
-
55
- // Register with EngagementManager if on a slide
56
- if (currentSlideId) {
57
- const hotspotIds = hotspots.map(h => h.dataset.hotspotId);
58
- if (typeof engagementManager.registerInteractiveImage === 'function') {
59
- engagementManager.registerInteractiveImage(currentSlideId, hotspotIds);
60
- }
61
- }
54
+ const cleanups = [];
62
55
 
63
56
  // Setup Accordion Integration if ID is provided
64
57
  let accordionElement = null;
65
58
  if (accordionId) {
66
59
  accordionElement = document.getElementById(accordionId);
67
60
  if (accordionElement) {
68
- setupAccordionIntegration(container, hotspots, accordionElement, accordionId);
61
+ cleanups.push(setupAccordionIntegration(hotspots, accordionElement, accordionId));
69
62
  } else {
70
63
  logger.warn(`[InteractiveImage] Accordion #${accordionId} not found for image ${imageId}`);
71
64
  }
@@ -113,21 +106,30 @@ export function init(root, _options = {}) {
113
106
  }
114
107
  };
115
108
 
116
- hotspot.addEventListener('click', handleClick);
117
- hotspot.addEventListener('keydown', (e) => {
109
+ const handleKeydown = (e) => {
118
110
  if (e.key === 'Enter' || e.key === ' ') {
119
111
  handleClick(e);
120
112
  }
113
+ };
114
+
115
+ hotspot.addEventListener('click', handleClick);
116
+ hotspot.addEventListener('keydown', handleKeydown);
117
+ cleanups.push(() => {
118
+ hotspot.removeEventListener('click', handleClick);
119
+ hotspot.removeEventListener('keydown', handleKeydown);
121
120
  });
122
121
  });
123
122
 
124
123
  logger.debug(`[InteractiveImage] Initialized ${hotspots.length} hotspots for ${imageId}`);
124
+ return {
125
+ destroy: () => cleanups.forEach(cleanup => cleanup())
126
+ };
125
127
  }
126
128
 
127
129
  /**
128
130
  * Sets up bi-directional sync between image hotspots and accordion panels.
129
131
  */
130
- function setupAccordionIntegration(imageContainer, hotspots, accordionElement, accordionId) {
132
+ function setupAccordionIntegration(hotspots, accordionElement, accordionId) {
131
133
  // Map hotspots by ID for quick access
132
134
  const hotspotMap = new Map(hotspots.map(h => [h.dataset.hotspotId, h]));
133
135
 
@@ -156,25 +158,23 @@ function setupAccordionIntegration(imageContainer, hotspots, accordionElement, a
156
158
 
157
159
  // 2. Hover Effects (Accordion Button -> Image Hotspot)
158
160
  const buttons = accordionElement.querySelectorAll('[data-panel]');
161
+ const buttonCleanups = [];
159
162
  buttons.forEach(btn => {
160
163
  const panelId = btn.dataset.panel;
161
164
  const hotspot = hotspotMap.get(panelId);
162
165
 
163
166
  if (hotspot) {
164
- btn.addEventListener('mouseenter', () => {
165
- hotspot.classList.add('highlighted');
166
- });
167
-
168
- btn.addEventListener('mouseleave', () => {
169
- hotspot.classList.remove('highlighted');
170
- });
171
-
172
- btn.addEventListener('focus', () => {
173
- hotspot.classList.add('highlighted');
174
- });
175
-
176
- btn.addEventListener('blur', () => {
177
- hotspot.classList.remove('highlighted');
167
+ const highlight = () => hotspot.classList.add('highlighted');
168
+ const unhighlight = () => hotspot.classList.remove('highlighted');
169
+ btn.addEventListener('mouseenter', highlight);
170
+ btn.addEventListener('mouseleave', unhighlight);
171
+ btn.addEventListener('focus', highlight);
172
+ btn.addEventListener('blur', unhighlight);
173
+ buttonCleanups.push(() => {
174
+ btn.removeEventListener('mouseenter', highlight);
175
+ btn.removeEventListener('mouseleave', unhighlight);
176
+ btn.removeEventListener('focus', highlight);
177
+ btn.removeEventListener('blur', unhighlight);
178
178
  });
179
179
  }
180
180
  });
@@ -196,40 +196,16 @@ function setupAccordionIntegration(imageContainer, hotspots, accordionElement, a
196
196
  }
197
197
  };
198
198
 
199
- // Component lifecycle cleanup using MutationObserver
200
- // When the container is removed from DOM, we automatically clean up all listeners
201
199
  const cleanupListeners = () => {
202
200
  document.removeEventListener('click', safeHandleOutsideClick);
203
201
  eventBus.off('accordion:toggled', handleAccordionToggle);
204
- if (observer) {
205
- observer.disconnect();
206
- }
202
+ buttonCleanups.forEach(cleanup => cleanup());
207
203
  };
208
204
 
209
205
  const safeHandleOutsideClick = (e) => {
210
- if (!document.body.contains(imageContainer)) {
211
- cleanupListeners();
212
- return;
213
- }
214
206
  handleOutsideClick(e);
215
207
  };
216
208
 
217
- // Use MutationObserver to detect when container is removed from DOM
218
- const observer = new MutationObserver((mutations) => {
219
- for (const mutation of mutations) {
220
- for (const node of mutation.removedNodes) {
221
- if (node === imageContainer || node.contains?.(imageContainer)) {
222
- cleanupListeners();
223
- return;
224
- }
225
- }
226
- }
227
- });
228
-
229
- // Observe parent for child removals (handles SPA navigation cleanup)
230
- if (imageContainer.parentNode) {
231
- observer.observe(imageContainer.parentNode, { childList: true });
232
- }
233
-
234
209
  document.addEventListener('click', safeHandleOutsideClick);
210
+ return cleanupListeners;
235
211
  }
@@ -65,7 +65,7 @@ export const schema = {
65
65
  export const metadata = {
66
66
  category: 'ui-component',
67
67
  cssFile: 'components/lightbox.css',
68
- engagementTracking: 'viewAllLightbox',
68
+ engagementTracking: 'viewAllLightboxes',
69
69
  emitsEvents: ['lightbox:opened', 'lightbox:closed']
70
70
  };
71
71
 
@@ -75,6 +75,7 @@ import { logger } from '../../utilities/logger.js';
75
75
  import { escapeHTML } from '../../utilities/utilities.js';
76
76
  import { fetchAndRenderMarkdown } from '../../utilities/markdown-renderer.js';
77
77
  import engagementManager from '../../engagement/engagement-manager.js';
78
+ import { resolvePortableAssetUrl } from '../../utilities/portable-assets.js';
78
79
 
79
80
  // Lightbox state
80
81
  let lightboxElement = null;
@@ -91,6 +92,8 @@ let currentMediaType = 'image'; // 'image' | 'video' | 'markdown' | 'pdf'
91
92
  */
92
93
  function _resolvePath(src) {
93
94
  if (!src) return src;
95
+ const portableSrc = resolvePortableAssetUrl(src);
96
+ if (portableSrc !== src) return portableSrc;
94
97
  // Already absolute URL or protocol-relative
95
98
  if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//')) {
96
99
  return src;
@@ -104,7 +107,7 @@ function _resolvePath(src) {
104
107
  return src;
105
108
  }
106
109
  // Otherwise, assume relative to course/ directory
107
- return `./course/${src}`;
110
+ return resolvePortableAssetUrl(`./course/${src}`);
108
111
  }
109
112
 
110
113
  /**
@@ -129,6 +132,7 @@ function getTriggerSrc(trigger) {
129
132
  */
130
133
  function detectPDF(url) {
131
134
  if (!url) return null;
135
+ if (url.startsWith('data:application/pdf')) return { type: 'pdf' };
132
136
  if (/\.pdf($|\?|#)/i.test(url)) return { type: 'pdf' };
133
137
  return null;
134
138
  }
@@ -140,6 +144,7 @@ function detectPDF(url) {
140
144
  */
141
145
  function detectMarkdown(url) {
142
146
  if (!url) return null;
147
+ if (url.startsWith('data:text/markdown')) return { type: 'markdown' };
143
148
  if (/\.md($|\?|#)/i.test(url)) return { type: 'markdown' };
144
149
  return null;
145
150
  }
@@ -182,6 +187,7 @@ function detectVimeo(url) {
182
187
  */
183
188
  function detectNativeVideo(url) {
184
189
  if (!url) return null;
190
+ if (url.startsWith('data:video/')) return { type: 'native' };
185
191
  const videoExtensions = /\.(mp4|webm|ogg|mov|m4v)($|\?)/i;
186
192
  if (videoExtensions.test(url)) return { type: 'native' };
187
193
  return null;
@@ -211,10 +217,11 @@ export function init(element) {
211
217
  createLightboxElement();
212
218
  }
213
219
 
214
- element.addEventListener('click', (e) => {
220
+ const handleClick = (e) => {
215
221
  e.preventDefault();
216
222
  openFromTrigger(element);
217
- });
223
+ };
224
+ element.addEventListener('click', handleClick);
218
225
 
219
226
  const src = getTriggerSrc(element);
220
227
  const mediaType = getMediaType(src);
@@ -239,6 +246,14 @@ export function init(element) {
239
246
  if (subtitle) {
240
247
  wrapTriggerWithSubtitle(element, subtitle);
241
248
  }
249
+
250
+ return {
251
+ destroy: () => {
252
+ element.removeEventListener('click', handleClick);
253
+ delete element.dataset.lightboxInitialized;
254
+ if (isOpen) close();
255
+ }
256
+ };
242
257
  }
243
258
 
244
259
  /**
@@ -649,4 +664,3 @@ function navigate(direction) {
649
664
  export function isVisible() {
650
665
  return isOpen;
651
666
  }
652
-
@@ -40,6 +40,7 @@ import * as NavigationState from '../../navigation/NavigationState.js';
40
40
  import * as AudioPlayer from './audio-player.js';
41
41
  import { eventBus } from '../../core/event-bus.js';
42
42
  import { logger } from '../../utilities/logger.js';
43
+ import { normalizeCompletionThreshold } from '../../utilities/media-utils.js';
43
44
  import { renderCompactPlayer } from './audio-player.js';
44
45
 
45
46
  // Schema for validation, linting, and AI-assisted authoring
@@ -144,7 +145,7 @@ export function setup() {
144
145
  * @param {HTMLElement} trigger
145
146
  */
146
147
  export function init(trigger) {
147
- trigger.addEventListener('click', () => {
148
+ const handleClick = () => {
148
149
  const title = trigger.dataset.title || 'Modal';
149
150
  let body = trigger.dataset.body || '';
150
151
  let footer = trigger.dataset.footer || '<button class="btn btn-secondary" data-action="close-modal">Close</button>';
@@ -183,7 +184,7 @@ export function init(trigger) {
183
184
  src: audioSrc,
184
185
  autoplay: trigger.dataset.audioAutoplay === 'true',
185
186
  required: trigger.dataset.audioRequired === 'true',
186
- completionThreshold: parseFloat(trigger.dataset.audioThreshold) || 0.95
187
+ completionThreshold: normalizeCompletionThreshold(trigger.dataset.audioThreshold)
187
188
  } : null;
188
189
 
189
190
  // Get modal ID for tracking (from trigger id or generate one)
@@ -200,7 +201,15 @@ export function init(trigger) {
200
201
  closeOnEscape: trigger.dataset.closeOnEscape !== 'false'
201
202
  }
202
203
  });
203
- });
204
+ };
205
+
206
+ trigger.addEventListener('click', handleClick);
207
+ return {
208
+ destroy: () => {
209
+ trigger.removeEventListener('click', handleClick);
210
+ if (isVisible()) hide();
211
+ }
212
+ };
204
213
  }
205
214
 
206
215
  /**
@@ -275,13 +284,15 @@ export async function show({ title, body, footer = '', modalId = null, config =
275
284
  // (enforced by runtime-linter - a slide cannot have both)
276
285
  if (audioManager.isReady() && audio && audio.src) {
277
286
  const audioContextId = `modal-${currentModalId}`;
287
+ let modalAudioLoaded = false;
278
288
  try {
279
289
  await audioManager.load({
280
290
  src: audio.src,
281
291
  autoplay: audio.autoplay === true,
282
292
  required: audio.required || false,
283
- completionThreshold: audio.completionThreshold || 0.95
293
+ completionThreshold: normalizeCompletionThreshold(audio.completionThreshold)
284
294
  }, audioContextId, 'modal');
295
+ modalAudioLoaded = true;
285
296
 
286
297
  logger.debug(`[Modal] Loaded modal audio: ${audioContextId}`);
287
298
 
@@ -290,11 +301,13 @@ export async function show({ title, body, footer = '', modalId = null, config =
290
301
  AudioPlayer.initAudioControlsInContainer(modalFooter);
291
302
  }
292
303
  } catch (err) {
293
- logger.warn('[Modal] Failed to load modal audio:', err.message);
304
+ if (err.name !== 'AbortError') {
305
+ logger.warn('[Modal] Failed to load modal audio:', err.message);
306
+ }
294
307
  }
295
308
 
296
309
  // If audio is required, listen for completion
297
- if (audio.required) {
310
+ if (audio.required && modalAudioLoaded) {
298
311
  audioCompletedHandler = ({ contextId }) => {
299
312
  if (contextId === audioContextId && currentSlideId) {
300
313
  engagementManager.trackModalAudioComplete(currentSlideId, currentModalId);
@@ -383,4 +396,4 @@ export function hide() {
383
396
  activeConfig = {};
384
397
 
385
398
  announceToScreenReader('Modal closed');
386
- }
399
+ }
@@ -60,16 +60,21 @@ export function setup() {
60
60
  * Initializes declarative notification triggers in a container using event delegation.
61
61
  * @param {HTMLElement} container
62
62
  */
63
- export function init(container) {
63
+ export function initNotificationTriggers(container) {
64
64
  // Use event delegation to handle dynamically rendered content
65
- container.addEventListener('click', (event) => {
65
+ const handleClick = (event) => {
66
66
  const trigger = event.target.closest('[data-action="show-notification"]');
67
67
  if (trigger) {
68
68
  const type = trigger.dataset.type || 'info';
69
69
  const message = trigger.dataset.message || 'Notification';
70
70
  showNotification(message, type);
71
71
  }
72
- });
72
+ };
73
+ container.addEventListener('click', handleClick);
74
+
75
+ return {
76
+ destroy: () => container.removeEventListener('click', handleClick)
77
+ };
73
78
  }
74
79
 
75
80
  export function showNotification(message, type = 'info', duration = 5000, options = {}) {
@@ -54,6 +54,8 @@ import engagementManager from '../../engagement/engagement-manager.js';
54
54
  import * as NavigationState from '../../navigation/NavigationState.js';
55
55
  import { logger } from '../../utilities/logger.js';
56
56
  import { iconManager } from '../../utilities/icons.js';
57
+ import { normalizeCompletionThreshold } from '../../utilities/media-utils.js';
58
+ import { resolvePortableAssetUrl } from '../../utilities/portable-assets.js';
57
59
 
58
60
  /** @type {Map<string, VideoPlayer>} Active player instances */
59
61
  const playerInstances = new Map();
@@ -131,10 +133,12 @@ function detectExternalVideo(url) {
131
133
  * @returns {string} Resolved path
132
134
  */
133
135
  function resolvePath(src) {
136
+ const portableSrc = resolvePortableAssetUrl(src);
137
+ if (portableSrc !== src) return portableSrc;
134
138
  if (src.startsWith('http') || src.startsWith('/') || src.startsWith('./')) {
135
139
  return src;
136
140
  }
137
- return `./course/assets/${src}`;
141
+ return resolvePortableAssetUrl(`./course/assets/${src}`);
138
142
  }
139
143
 
140
144
  /**
@@ -148,7 +152,7 @@ class VideoPlayer {
148
152
  this.posterSrc = container.dataset.videoPoster;
149
153
  this.captionsSrc = container.dataset.videoCaptions;
150
154
  this.required = container.dataset.videoRequired === 'true';
151
- this.threshold = parseFloat(container.dataset.videoThreshold) || 0.95;
155
+ this.threshold = normalizeCompletionThreshold(container.dataset.videoThreshold);
152
156
  this.autoplay = container.dataset.videoAutoplay === 'true';
153
157
 
154
158
  if (!this.videoId) {
@@ -167,6 +171,18 @@ class VideoPlayer {
167
171
  this.iframe = null; // External video iframe
168
172
  this.elements = {};
169
173
  this.eventHandlers = {};
174
+ this.domEventHandlers = {
175
+ click: this._handleClick.bind(this),
176
+ progressKeydown: this._handleProgressKeydown.bind(this),
177
+ progressMousedown: this._handleProgressMousedown.bind(this),
178
+ fullscreenChange: this._handleFullscreenChange.bind(this),
179
+ videoDoubleClick: () => this._toggleFullscreen(),
180
+ overlayClick: (event) => {
181
+ event.stopPropagation();
182
+ this._togglePlayPause();
183
+ }
184
+ };
185
+ this.dragCleanup = null;
170
186
  this.isFullscreen = false;
171
187
 
172
188
  this._render();
@@ -311,35 +327,32 @@ class VideoPlayer {
311
327
  // External videos use platform's native controls
312
328
  if (this.isExternal) {
313
329
  // Only fullscreen detection for wrapper
314
- document.addEventListener('fullscreenchange', this._handleFullscreenChange.bind(this));
315
- document.addEventListener('webkitfullscreenchange', this._handleFullscreenChange.bind(this));
330
+ document.addEventListener('fullscreenchange', this.domEventHandlers.fullscreenChange);
331
+ document.addEventListener('webkitfullscreenchange', this.domEventHandlers.fullscreenChange);
316
332
  return;
317
333
  }
318
334
 
319
335
  // Control bar click delegation
320
- this.container.addEventListener('click', this._handleClick.bind(this));
336
+ this.container.addEventListener('click', this.domEventHandlers.click);
321
337
 
322
338
  // Progress bar interactions
323
339
  if (this.elements.progressBar) {
324
- this.elements.progressBar.addEventListener('keydown', this._handleProgressKeydown.bind(this));
325
- this.elements.progressBar.addEventListener('mousedown', this._handleProgressMousedown.bind(this));
340
+ this.elements.progressBar.addEventListener('keydown', this.domEventHandlers.progressKeydown);
341
+ this.elements.progressBar.addEventListener('mousedown', this.domEventHandlers.progressMousedown);
326
342
  }
327
343
 
328
344
  // Fullscreen change detection
329
- document.addEventListener('fullscreenchange', this._handleFullscreenChange.bind(this));
330
- document.addEventListener('webkitfullscreenchange', this._handleFullscreenChange.bind(this));
345
+ document.addEventListener('fullscreenchange', this.domEventHandlers.fullscreenChange);
346
+ document.addEventListener('webkitfullscreenchange', this.domEventHandlers.fullscreenChange);
331
347
 
332
348
  // Double-click video to toggle fullscreen
333
349
  if (this.video) {
334
- this.video.addEventListener('dblclick', () => this._toggleFullscreen());
350
+ this.video.addEventListener('dblclick', this.domEventHandlers.videoDoubleClick);
335
351
  }
336
352
 
337
353
  // Click overlay to play
338
354
  if (this.elements.overlay) {
339
- this.elements.overlay.addEventListener('click', (e) => {
340
- e.stopPropagation();
341
- this._togglePlayPause();
342
- });
355
+ this.elements.overlay.addEventListener('click', this.domEventHandlers.overlayClick);
343
356
  }
344
357
  }
345
358
 
@@ -447,8 +460,11 @@ class VideoPlayer {
447
460
  _handleProgressMousedown(event) {
448
461
  if (!this.video || !this.video.duration) return;
449
462
 
463
+ this.dragCleanup?.();
464
+
450
465
  const progressBar = this.elements.progressBar;
451
466
  const rect = progressBar.getBoundingClientRect();
467
+ if (rect.width <= 0) return;
452
468
 
453
469
  const seek = (clientX) => {
454
470
  const pct = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
@@ -458,15 +474,19 @@ class VideoPlayer {
458
474
  seek(event.clientX);
459
475
 
460
476
  const onMouseMove = (e) => seek(e.clientX);
461
- const onMouseUp = () => {
477
+ const cleanupDrag = () => {
462
478
  document.removeEventListener('mousemove', onMouseMove);
463
- document.removeEventListener('mouseup', onMouseUp);
479
+ document.removeEventListener('mouseup', cleanupDrag);
464
480
  progressBar.classList.remove('dragging');
481
+ if (this.dragCleanup === cleanupDrag) {
482
+ this.dragCleanup = null;
483
+ }
465
484
  };
466
485
 
467
486
  progressBar.classList.add('dragging');
468
487
  document.addEventListener('mousemove', onMouseMove);
469
- document.addEventListener('mouseup', onMouseUp);
488
+ document.addEventListener('mouseup', cleanupDrag);
489
+ this.dragCleanup = cleanupDrag;
470
490
  }
471
491
 
472
492
  _attachToManager() {
@@ -605,6 +625,8 @@ class VideoPlayer {
605
625
  }
606
626
 
607
627
  destroy() {
628
+ this.dragCleanup?.();
629
+
608
630
  // Unsubscribe from events
609
631
  eventBus.off('video:loaded', this.eventHandlers.loaded);
610
632
  eventBus.off('video:play', this.eventHandlers.play);
@@ -616,11 +638,15 @@ class VideoPlayer {
616
638
  // Detach from manager
617
639
  if (this.video) {
618
640
  videoManager.detachVideo(this.video);
641
+ this.video.removeEventListener('dblclick', this.domEventHandlers.videoDoubleClick);
619
642
  }
620
643
 
621
- // Remove fullscreen listeners
622
- document.removeEventListener('fullscreenchange', this._handleFullscreenChange);
623
- document.removeEventListener('webkitfullscreenchange', this._handleFullscreenChange);
644
+ this.container.removeEventListener('click', this.domEventHandlers.click);
645
+ this.elements.progressBar?.removeEventListener('keydown', this.domEventHandlers.progressKeydown);
646
+ this.elements.progressBar?.removeEventListener('mousedown', this.domEventHandlers.progressMousedown);
647
+ this.elements.overlay?.removeEventListener('click', this.domEventHandlers.overlayClick);
648
+ document.removeEventListener('fullscreenchange', this.domEventHandlers.fullscreenChange);
649
+ document.removeEventListener('webkitfullscreenchange', this.domEventHandlers.fullscreenChange);
624
650
 
625
651
  playerInstances.delete(this.videoId);
626
652
  logger.debug(`[VideoPlayer] Destroyed: ${this.videoId}`);
@@ -137,16 +137,16 @@ class EventBus {
137
137
 
138
138
  // Create a copy of listeners to avoid issues if listeners modify the array
139
139
  const listeners = [...this.events[event]];
140
- const onceListeners = [];
141
140
 
142
141
  listeners.forEach(listener => {
142
+ // Remove one-shot listeners before invocation so thrown callbacks and
143
+ // re-entrant emits cannot execute them more than once.
144
+ if (listener.once) {
145
+ this.off(event, listener.id);
146
+ }
147
+
143
148
  try {
144
149
  listener.callback(data);
145
-
146
- // Track once listeners for removal
147
- if (listener.once) {
148
- onceListeners.push(listener.id);
149
- }
150
150
  } catch (error) {
151
151
  // Log the error but don't break other listeners — use safeStringify
152
152
  // to prevent a secondary cascade from unserializable error objects
@@ -154,8 +154,6 @@ class EventBus {
154
154
  }
155
155
  });
156
156
 
157
- // Remove once listeners
158
- onceListeners.forEach(id => this.off(event, id));
159
157
  } finally {
160
158
  if (isErrorEvent) {
161
159
  this._emittingError = false;
@@ -177,37 +175,42 @@ class EventBus {
177
175
  const hasListeners = Boolean(this.events[event]?.length);
178
176
  if (!isErrorEvent && !hasListeners) return false;
179
177
 
178
+ if (isErrorEvent && this._emittingError) {
179
+ logger.warn(`[EventBus] Suppressed recursive error event: ${event}`);
180
+ return false;
181
+ }
182
+
180
183
  if (isErrorEvent) {
181
- if (this._emittingError) {
182
- logger.warn(`[EventBus] Suppressed recursive error event: ${event}`);
183
- return false;
184
- }
185
184
  this._emittingError = true;
186
- logger.error(`[EventBus Error] ${event}:`, safeStringify(data));
187
- this._emittingError = false;
188
185
  }
189
186
 
190
- if (!hasListeners) return false;
187
+ try {
188
+ if (isErrorEvent) {
189
+ logger.error(`[EventBus Error] ${event}:`, safeStringify(data));
190
+ }
191
191
 
192
- const listeners = [...this.events[event]];
193
- const onceListeners = [];
192
+ if (!hasListeners) return false;
194
193
 
195
- for (const listener of listeners) {
196
- try {
197
- await listener.callback(data);
194
+ const listeners = [...this.events[event]];
198
195
 
196
+ for (const listener of listeners) {
199
197
  if (listener.once) {
200
- onceListeners.push(listener.id);
198
+ this.off(event, listener.id);
201
199
  }
202
- } catch (error) {
203
- logger.error(`[EventBus] Error in async listener for '${event}':`, error);
204
- }
205
- }
206
200
 
207
- // Remove once listeners
208
- onceListeners.forEach(id => this.off(event, id));
201
+ try {
202
+ await listener.callback(data);
203
+ } catch (error) {
204
+ logger.error(`[EventBus] Error in async listener for '${event}':`, error);
205
+ }
206
+ }
209
207
 
210
- return true;
208
+ return true;
209
+ } finally {
210
+ if (isErrorEvent) {
211
+ this._emittingError = false;
212
+ }
213
+ }
211
214
  }
212
215
 
213
216
  /**
@@ -42,7 +42,7 @@ const DYNAMIC_CLASSES = new Set([
42
42
  // Component-internal classes — styled via [data-component] selectors in individual component CSS files
43
43
  'intro-card', 'card-icon',
44
44
  // Interaction-internal classes — used by interaction JS for DOM structure
45
- 'drag-drop', 'matching-items', 'matching-targets',
45
+ 'drag-drop', 'matching-items', 'matching-targets', 'image-container', 'hotspot-feedback',
46
46
  // Slide-specific JS selectors — queried by slide scripts for event binding
47
47
  'resources', 'complete-remedial-btn',
48
48
  ]);
@@ -833,6 +833,7 @@ function validateVisualLayout(slideId, renderedContent, errors, warnings) {
833
833
  const textElements = renderedContent.querySelectorAll('p, span:not(.accordion-icon), li, a, h1, h2, h3, h4, h5, h6, button');
834
834
  for (const el of textElements) {
835
835
  if (isLintIgnored(el, 'contrast')) continue;
836
+ if (el.classList.contains('sr-only') || el.closest('.sr-only')) continue;
836
837
  // Skip elements that are not visible or have no text
837
838
  if (el.offsetParent === null || el.textContent.trim() === '') continue;
838
839
 
@@ -1042,7 +1043,8 @@ function validateTextProximityToVisualElements(slideId, renderedContent, warning
1042
1043
  'dropdown', 'dropdown-menu', 'dropdown-item',
1043
1044
  'table', // Tables manage their own cell spacing
1044
1045
  'step-number', 'step-content', 'step', // Pattern-steps elements have intentional circular styling
1045
- 'btn-link' // Link-styled button intentionally has minimal padding
1046
+ 'btn-link', // Link-styled button intentionally has minimal padding
1047
+ 'hotspot-area', 'interactive-image-hotspot'
1046
1048
  ]);
1047
1049
 
1048
1050
  // HTML elements that manage their own spacing (exclude from checks)
@@ -1171,6 +1173,8 @@ function validateElementOverlap(slideId, renderedContent, warnings) {
1171
1173
  const absoluteElements = renderedContent.querySelectorAll('[style*="position:absolute"], [style*="position: absolute"]');
1172
1174
  for (const el of absoluteElements) {
1173
1175
  if (el.offsetParent === null) continue;
1176
+ // Hotspots intentionally overlap their background image and sometimes each other.
1177
+ if (el.matches('[data-hotspot-id], .hotspot-area, .interactive-image-hotspot')) continue;
1174
1178
 
1175
1179
  const rect = el.getBoundingClientRect();
1176
1180
  const elText = el.textContent.trim().substring(0, 30);