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
@@ -70,7 +70,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
70
70
  await stateManager.setAttemptNumber(attemptNumber);
71
71
 
72
72
  _manageFooterVisibility('question');
73
- uiManager.showView('question');
73
+ await uiManager.showView('question');
74
74
  }
75
75
 
76
76
  async function handlePrev() {
@@ -79,7 +79,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
79
79
  const currentIndex = stateManager.getCurrentQuestionIndex();
80
80
  if (currentIndex > 0) {
81
81
  await stateManager.setCurrentQuestionIndex(currentIndex - 1);
82
- uiManager.showView('question');
82
+ await uiManager.showView('question');
83
83
  }
84
84
  }
85
85
 
@@ -94,7 +94,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
94
94
  await stateManager.updateSession({ reviewReached: true });
95
95
  await stateManager.setCurrentView('review');
96
96
  _manageFooterVisibility('review');
97
- uiManager.showView('review');
97
+ await uiManager.showView('review');
98
98
  } else {
99
99
  // FIX: Route through handleSubmit to ensure unanswered checks are performed
100
100
  // previously called submitAssessment() directly, bypassing validation
@@ -102,7 +102,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
102
102
  }
103
103
  } else {
104
104
  await stateManager.setCurrentQuestionIndex(currentIndex + 1);
105
- uiManager.showView('question');
105
+ await uiManager.showView('question');
106
106
  }
107
107
  }
108
108
 
@@ -119,21 +119,21 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
119
119
  await stateManager.setCurrentView('question');
120
120
  await stateManager.setCurrentQuestionIndex(index);
121
121
  _manageFooterVisibility('question');
122
- uiManager.showView('question');
122
+ await uiManager.showView('question');
123
123
  }
124
124
 
125
125
  async function handleBackToQuestions() {
126
126
  await _forceSaveCurrentResponse();
127
127
  await stateManager.setCurrentView('question');
128
128
  _manageFooterVisibility('question');
129
- uiManager.showView('question');
129
+ await uiManager.showView('question');
130
130
  }
131
131
 
132
132
  async function handleJumpToReview() {
133
133
  await _forceSaveCurrentResponse();
134
134
  await stateManager.setCurrentView('review');
135
135
  _manageFooterVisibility('review');
136
- uiManager.showView('review');
136
+ await uiManager.showView('review');
137
137
  }
138
138
 
139
139
  async function handleSubmit() {
@@ -242,14 +242,14 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
242
242
  // This will cause slide to create NEW assessment instance
243
243
  const currentSlideId = getCurrentSlideId();
244
244
  if (currentSlideId) {
245
- goToSlide(currentSlideId, { refreshAssessment: true });
245
+ await goToSlide(currentSlideId, { refreshAssessment: true });
246
246
  return; // Exit - new instance will be created
247
247
  }
248
248
  }
249
249
 
250
250
  // Simple reset - same questions
251
251
  _manageFooterVisibility('intro');
252
- uiManager.showView('intro');
252
+ await uiManager.showView('intro');
253
253
  }
254
254
 
255
255
  function handleRestartCourse() {
@@ -353,7 +353,7 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
353
353
  throw new Error(errorMessage);
354
354
  }
355
355
 
356
- goToSlide(firstRemedialSlide, {
356
+ await goToSlide(firstRemedialSlide, {
357
357
  fromAssessment: config.id,
358
358
  remedialReview: true
359
359
  });
@@ -413,11 +413,6 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
413
413
  const displayData = _prepareResultsDisplayData(finalResults);
414
414
 
415
415
  await stateManager.setCurrentView('results');
416
- _manageFooterVisibility('results');
417
- uiManager.showView('results', displayData);
418
-
419
- // Keep full results in memory for this session only (not persisted)
420
- // This allows detailed review immediately after submission
421
416
 
422
417
  // Automatically set linked objective if configured
423
418
  if (config.assessmentObjective) {
@@ -446,6 +441,12 @@ export function createAssessmentActions(stateManager, uiManager, questionInstanc
446
441
  // Assessment submission is a critical action - don't rely on debounce
447
442
  await globalStateManager.flush();
448
443
 
444
+ // Only transition the UI after the assessment outcome, objective, and
445
+ // course score are durably committed. A rendering failure must never
446
+ // prevent the LMS from receiving the learner's submitted result.
447
+ _manageFooterVisibility('results');
448
+ await uiManager.showView('results', displayData);
449
+
449
450
  if (typeof config.onComplete === 'function') {
450
451
  config.onComplete(finalResults);
451
452
  }
@@ -231,15 +231,37 @@ export function createAssessmentInstance(config) {
231
231
  });
232
232
  throw error;
233
233
  } else {
234
- // Production mode: Log warning and filter out missing questions
235
- // Also emit event so course can potentially track this
236
- logger.warn(`[AssessmentFactory] ${errorMessage} Continuing with available questions.`);
234
+ // Index-keyed responses cannot be safely paired with a
235
+ // filtered question list. Restart only the in-progress
236
+ // session and preserve the learner's submitted summaries.
237
+ logger.warn(`[AssessmentFactory] ${errorMessage} Restarting the in-progress attempt safely.`);
237
238
  eventBus.emit('state:recovered', {
238
239
  domain: 'assessment',
239
240
  message: errorMessage,
240
241
  context: { assessmentId, missingIds },
241
- action: 'filtered_missing_questions'
242
+ action: 'restarted_changed_assessment'
242
243
  });
244
+
245
+ const replacementQuestions = _selectNewQuestions();
246
+ const existingSession = assessmentState.getSession() || {};
247
+ assessmentState.updateSession({
248
+ ...existingSession,
249
+ currentView: 'intro',
250
+ currentQuestionIndex: 0,
251
+ startTime: null,
252
+ submitted: false,
253
+ responses: {},
254
+ selectedQuestions: replacementQuestions.map(question => question.id),
255
+ reviewReached: false
256
+ }, 'assessment-state:course-update').catch(error => {
257
+ logger.error(`Failed to persist safe assessment restart: ${error.message}`, {
258
+ domain: 'assessment',
259
+ operation: 'recover-changed-questions',
260
+ assessmentId,
261
+ stack: error.stack
262
+ });
263
+ });
264
+ return replacementQuestions;
243
265
  }
244
266
  }
245
267
 
@@ -248,22 +270,19 @@ export function createAssessmentInstance(config) {
248
270
  .filter(q => q !== undefined); // Filter out any missing questions
249
271
  }
250
272
 
273
+ return _selectNewQuestions();
274
+ }
275
+
276
+ function _selectNewQuestions() {
251
277
  if (config.questionBanks && Array.isArray(config.questionBanks) && config.questionBanks.length > 0) {
252
- // New session with banks: select and optionally randomize
253
278
  let selected = _selectQuestionsFromBanks(config.questionBanks);
254
-
255
- if (config.settings?.randomizeQuestions) {
256
- selected = shuffleArray(selected);
257
- }
258
-
279
+ if (config.settings?.randomizeQuestions) selected = shuffleArray(selected);
259
280
  return selected;
260
281
  }
261
282
 
262
- // Direct mode: use questions array as-is
263
283
  if (config.settings?.randomizeQuestions && Array.isArray(config.questions)) {
264
284
  return shuffleArray(config.questions);
265
285
  }
266
-
267
286
  return config.questions || [];
268
287
  }
269
288
 
@@ -320,10 +339,20 @@ export function createAssessmentInstance(config) {
320
339
  // Resolve active questions for this session
321
340
  activeQuestions = _resolveActiveQuestions();
322
341
 
323
- // Persist selection if this is a new bank-based session (fire-and-forget)
324
- if (config.questionBanks && !session?.selectedQuestions) {
342
+ // Persist every randomized order, including direct question arrays.
343
+ // Responses are keyed by index, so resume must reconstruct the exact
344
+ // same question order or answers can be scored against another item.
345
+ const requiresPersistedOrder = Boolean(
346
+ config.questionBanks || config.settings?.randomizeQuestions
347
+ );
348
+ if (requiresPersistedOrder && !session?.selectedQuestions) {
325
349
  assessmentState.setSelectedQuestions(activeQuestions).catch(error => {
326
- logger.error(`Failed to persist selected questions: ${error.message}`, { domain: 'assessment', operation: 'persist-selected-questions', assessmentId });
350
+ logger.error(`Failed to persist selected questions: ${error.message}`, {
351
+ domain: 'assessment',
352
+ operation: 'persist-selected-questions',
353
+ assessmentId,
354
+ stack: error.stack
355
+ });
327
356
  });
328
357
  }
329
358
 
@@ -61,7 +61,7 @@ export function createEngagementMethods(logTrace) {
61
61
  if (!slideId) {
62
62
  throw new Error('CourseCodeAutomation: No active slide');
63
63
  }
64
- if (typeof percentage !== 'number' || percentage < 0 || percentage > 100) {
64
+ if (!Number.isFinite(percentage) || percentage < 0 || percentage > 100) {
65
65
  throw new Error('CourseCodeAutomation: Scroll depth must be a number between 0 and 100');
66
66
  }
67
67
  engagementManager.trackScrollDepth(slideId, percentage);
@@ -143,7 +143,7 @@ export function createEngagementMethods(logTrace) {
143
143
  const currentSlideId = NavigationState.getCurrentSlideId();
144
144
 
145
145
  if (state.duration > 0) {
146
- const targetPosition = state.duration * (state.completionThreshold || 0.95);
146
+ const targetPosition = state.duration * state.completionThreshold;
147
147
  audioManager.seek(targetPosition);
148
148
  }
149
149
 
@@ -163,7 +163,10 @@ export function createEngagementMethods(logTrace) {
163
163
  case 'tab':
164
164
  case 'standalone':
165
165
  if (currentSlideId) {
166
- engagementManager.trackStandaloneAudioComplete(currentSlideId, state.contextId);
166
+ const audioId = state.contextType === 'standalone'
167
+ ? state.contextId.replace(/^standalone-/, '')
168
+ : state.contextId;
169
+ engagementManager.trackStandaloneAudioComplete(currentSlideId, audioId);
167
170
  }
168
171
  break;
169
172
  }
@@ -77,8 +77,7 @@ export const schema = {
77
77
  description: 'Draggable items',
78
78
  itemSchema: {
79
79
  id: { type: 'string', required: true },
80
- text: { type: 'string', required: true },
81
- correctZone: { type: 'string', required: true }
80
+ content: { type: 'string', required: true }
82
81
  }
83
82
  },
84
83
  dropZones: {
@@ -88,7 +87,9 @@ export const schema = {
88
87
  description: 'Drop target zones',
89
88
  itemSchema: {
90
89
  id: { type: 'string', required: true },
91
- label: { type: 'string', required: true }
90
+ label: { type: 'string', required: true },
91
+ accepts: { type: 'array', required: true, minItems: 1 },
92
+ maxItems: { type: 'number' }
92
93
  }
93
94
  }
94
95
  }
@@ -881,4 +882,3 @@ function removeItemFromZone(container, itemId) {
881
882
  // Update state
882
883
  delete state.placements[itemId];
883
884
  }
884
-
@@ -137,10 +137,13 @@ export const schema = {
137
137
  type: 'object',
138
138
  required: true,
139
139
  description: 'Map of blank IDs to their correct answers',
140
- valueSchema: {
141
- correct: { type: ['string', 'array'], required: true },
142
- typoTolerance: { type: 'number', default: 0 },
143
- hint: { type: 'string' }
140
+ mapValueSchema: {
141
+ type: 'object',
142
+ valueSchema: {
143
+ correct: { type: ['string', 'array'], required: true },
144
+ typoTolerance: { type: 'number', default: 0 },
145
+ hint: { type: 'string' }
146
+ }
144
147
  }
145
148
  },
146
149
  template: {
@@ -10,6 +10,7 @@ import {
10
10
  registerCoreInteraction
11
11
  } from './interaction-base.js';
12
12
  import { logger } from '../../utilities/logger.js';
13
+ import { resolveCourseAssetUrl } from '../../utilities/portable-assets.js';
13
14
 
14
15
  // Default appearance themes for hotspots
15
16
  const HOTSPOT_DEFAULTS = {
@@ -282,7 +283,7 @@ export function createHotspotQuestion(config) {
282
283
  <div class="hotspot-container">
283
284
  <div class="image-container relative">
284
285
  <img
285
- src="${image.src}"
286
+ src="${resolveCourseAssetUrl(image.src)}"
286
287
  alt="${image.alt || ''}"
287
288
  class="w-full h-auto"
288
289
  id="${id}_image"
@@ -699,4 +700,3 @@ function toNumber(value, fallback = 0) {
699
700
  }
700
701
  return 0;
701
702
  }
702
-
@@ -94,10 +94,11 @@ export const schema = {
94
94
  type: 'array',
95
95
  required: true,
96
96
  minItems: 2,
97
- description: 'Source-target pairs to match',
97
+ description: 'Items and their matching target text',
98
98
  itemSchema: {
99
- source: { type: 'string', required: true },
100
- target: { type: 'string', required: true }
99
+ id: { type: 'string', required: true },
100
+ text: { type: 'string', required: true },
101
+ match: { type: 'string', required: true }
101
102
  }
102
103
  },
103
104
  feedbackMode: {
@@ -60,6 +60,7 @@ import engagementManager from '../../engagement/engagement-manager.js';
60
60
  import * as NavigationState from '../../navigation/NavigationState.js';
61
61
  import { logger } from '../../utilities/logger.js';
62
62
  import { iconManager } from '../../utilities/icons.js';
63
+ import { normalizeCompletionThreshold } from '../../utilities/media-utils.js';
63
64
 
64
65
 
65
66
  /** @type {HTMLElement|null} */
@@ -351,6 +352,7 @@ function _handleProgressMousedown(event) {
351
352
 
352
353
  const progressBar = elements.progressBar;
353
354
  const rect = progressBar.getBoundingClientRect();
355
+ if (rect.width <= 0) return;
354
356
 
355
357
  const seek = (clientX) => {
356
358
  const percentage = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
@@ -823,7 +825,7 @@ class StandaloneAudioPlayer {
823
825
  this.audioSrc = container.dataset.audioSrc;
824
826
  this.required = container.dataset.audioRequired === 'true';
825
827
  this.compact = container.dataset.audioCompact === 'true';
826
- this.threshold = parseFloat(container.dataset.audioThreshold) || 0.95;
828
+ this.threshold = normalizeCompletionThreshold(container.dataset.audioThreshold);
827
829
 
828
830
  if (!this.audioId) {
829
831
  throw new Error('[AudioPlayer] Standalone player requires data-audio-id');
@@ -836,6 +838,12 @@ class StandaloneAudioPlayer {
836
838
  this.isActive = false;
837
839
  this.eventHandlers = {};
838
840
  this.elements = {};
841
+ this.domEventHandlers = {
842
+ click: this._handleClick.bind(this),
843
+ progressKeydown: this._handleProgressKeydown.bind(this),
844
+ progressMousedown: this._handleProgressMousedown.bind(this)
845
+ };
846
+ this.dragCleanup = null;
839
847
 
840
848
  this._render();
841
849
  this._cacheElements();
@@ -867,11 +875,11 @@ class StandaloneAudioPlayer {
867
875
  }
868
876
 
869
877
  _setupEventListeners() {
870
- this.container.addEventListener('click', this._handleClick.bind(this));
878
+ this.container.addEventListener('click', this.domEventHandlers.click);
871
879
 
872
880
  if (this.elements.progressBar) {
873
- this.elements.progressBar.addEventListener('keydown', this._handleProgressKeydown.bind(this));
874
- this.elements.progressBar.addEventListener('mousedown', this._handleProgressMousedown.bind(this));
881
+ this.elements.progressBar.addEventListener('keydown', this.domEventHandlers.progressKeydown);
882
+ this.elements.progressBar.addEventListener('mousedown', this.domEventHandlers.progressMousedown);
875
883
  }
876
884
  }
877
885
 
@@ -915,7 +923,9 @@ class StandaloneAudioPlayer {
915
923
  completionThreshold: this.threshold
916
924
  }, this.contextId, 'standalone');
917
925
  } catch (error) {
918
- logger.error(`[AudioPlayer] Failed to load ${this.audioId}:`, error.message);
926
+ if (error.name !== 'AbortError') {
927
+ logger.error(`[AudioPlayer] Failed to load ${this.audioId}:`, error.message);
928
+ }
919
929
  }
920
930
  }
921
931
 
@@ -939,8 +949,11 @@ class StandaloneAudioPlayer {
939
949
  _handleProgressMousedown(event) {
940
950
  if (!this.isActive || !audioManager.hasAudio()) return;
941
951
 
952
+ this.dragCleanup?.();
953
+
942
954
  const progressBar = this.elements.progressBar;
943
955
  const rect = progressBar.getBoundingClientRect();
956
+ if (rect.width <= 0) return;
944
957
 
945
958
  const seek = (clientX) => {
946
959
  const pct = Math.max(0, Math.min(100, ((clientX - rect.left) / rect.width) * 100));
@@ -950,15 +963,19 @@ class StandaloneAudioPlayer {
950
963
  seek(event.clientX);
951
964
 
952
965
  const onMouseMove = (e) => seek(e.clientX);
953
- const onMouseUp = () => {
966
+ const cleanupDrag = () => {
954
967
  document.removeEventListener('mousemove', onMouseMove);
955
- document.removeEventListener('mouseup', onMouseUp);
968
+ document.removeEventListener('mouseup', cleanupDrag);
956
969
  progressBar.classList.remove('dragging');
970
+ if (this.dragCleanup === cleanupDrag) {
971
+ this.dragCleanup = null;
972
+ }
957
973
  };
958
974
 
959
975
  progressBar.classList.add('dragging');
960
976
  document.addEventListener('mousemove', onMouseMove);
961
- document.addEventListener('mouseup', onMouseUp);
977
+ document.addEventListener('mouseup', cleanupDrag);
978
+ this.dragCleanup = cleanupDrag;
962
979
  }
963
980
 
964
981
  _subscribeToAudioEvents() {
@@ -1111,6 +1128,11 @@ class StandaloneAudioPlayer {
1111
1128
  }
1112
1129
 
1113
1130
  destroy() {
1131
+ this.dragCleanup?.();
1132
+ this.container.removeEventListener('click', this.domEventHandlers.click);
1133
+ this.elements.progressBar?.removeEventListener('keydown', this.domEventHandlers.progressKeydown);
1134
+ this.elements.progressBar?.removeEventListener('mousedown', this.domEventHandlers.progressMousedown);
1135
+
1114
1136
  eventBus.off('audio:loadStart', this.eventHandlers.loadStart);
1115
1137
  eventBus.off('audio:loaded', this.eventHandlers.loaded);
1116
1138
  eventBus.off('audio:unloaded', this.eventHandlers.unloaded);
@@ -90,11 +90,12 @@ export function init(container) {
90
90
  };
91
91
 
92
92
  // Handle transition end to set display: none for accessibility and performance
93
- panel.addEventListener('transitionend', () => {
93
+ const handleTransitionEnd = () => {
94
94
  if (trigger.getAttribute('aria-expanded') === 'false') {
95
95
  panel.style.display = 'none';
96
96
  }
97
- });
97
+ };
98
+ panel.addEventListener('transitionend', handleTransitionEnd);
98
99
 
99
100
  // Ensure panel is correctly displayed or hidden on init
100
101
  if (trigger.getAttribute('aria-expanded') === 'false') {
@@ -109,6 +110,7 @@ export function init(container) {
109
110
  return {
110
111
  destroy: () => {
111
112
  trigger.removeEventListener('click', toggle);
113
+ panel.removeEventListener('transitionend', handleTransitionEnd);
112
114
  }
113
115
  };
114
116
  }
@@ -127,7 +127,7 @@ export function init(root, options = {}) {
127
127
  });
128
128
 
129
129
  // Delegated event listener for the entire dropdown
130
- dropdown.addEventListener('click', (event) => {
130
+ const handleClick = (event) => {
131
131
  const actionTarget = event.target.closest('[data-action]');
132
132
  if (!actionTarget) return;
133
133
 
@@ -145,13 +145,17 @@ export function init(root, options = {}) {
145
145
  selectDropdownItem(dropdownId, itemIndex);
146
146
  }
147
147
  }
148
- });
148
+ };
149
149
 
150
150
  // Keyboard navigation for the menu
151
- menu.addEventListener('keydown', (event) => handleDropdownKeyboard(dropdownId, event));
151
+ const handleKeydown = (event) => handleDropdownKeyboard(dropdownId, event);
152
+ dropdown.addEventListener('click', handleClick);
153
+ menu.addEventListener('keydown', handleKeydown);
152
154
 
153
155
  return {
154
156
  destroy: () => {
157
+ dropdown.removeEventListener('click', handleClick);
158
+ menu.removeEventListener('keydown', handleKeydown);
155
159
  dropdowns.delete(dropdownId);
156
160
  }
157
161
  };
@@ -51,6 +51,7 @@ export const metadata = {
51
51
  import { logger } from '../../utilities/logger.js';
52
52
  import { eventBus } from '../../core/event-bus.js';
53
53
  import flagManager from '../../managers/flag-manager.js';
54
+ import { resolvePortableAssetUrl } from '../../utilities/portable-assets.js';
54
55
 
55
56
  /**
56
57
  * Initializes an embed-frame component.
@@ -254,6 +255,9 @@ function handleReadyMessage(embedId) {
254
255
  * @returns {string} Resolved path
255
256
  */
256
257
  function _resolvePath(src) {
258
+ const portableSrc = resolvePortableAssetUrl(src);
259
+ if (portableSrc !== src) return portableSrc;
260
+
257
261
  // Already absolute URL or protocol-relative
258
262
  if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//')) {
259
263
  return src;
@@ -270,5 +274,5 @@ function _resolvePath(src) {
270
274
  }
271
275
 
272
276
  // Otherwise, assume relative to course/assets/
273
- return `./course/${src}`;
277
+ return resolvePortableAssetUrl(`./course/${src}`);
274
278
  }
@@ -218,13 +218,19 @@ export function init(element) {
218
218
  announceToScreenReader(action);
219
219
  };
220
220
 
221
- // Click listener
222
- element.addEventListener('click', toggleFlip);
223
-
224
- // Keyboard listener (Enter or Space)
225
- element.addEventListener('keydown', (e) => {
221
+ const handleKeydown = (e) => {
226
222
  if (e.key === 'Enter' || e.key === ' ') {
227
223
  toggleFlip(e);
228
224
  }
229
- });
225
+ };
226
+
227
+ element.addEventListener('click', toggleFlip);
228
+ element.addEventListener('keydown', handleKeydown);
229
+
230
+ return {
231
+ destroy: () => {
232
+ element.removeEventListener('click', toggleFlip);
233
+ element.removeEventListener('keydown', handleKeydown);
234
+ }
235
+ };
230
236
  }