coursecode 0.1.58 → 0.1.60

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.
@@ -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
+ }
@@ -166,14 +166,14 @@
166
166
  .btn-success,
167
167
  .btn-check {
168
168
  background: var(--color-success);
169
- color: var(--color-white);
169
+ color: var(--color-on-success);
170
170
  border-color: var(--color-success);
171
171
  }
172
172
 
173
173
  .btn-success:hover:not(:disabled),
174
174
  .btn-check:hover:not(:disabled) {
175
175
  background: var(--color-success-hover);
176
- color: var(--color-white);
176
+ color: var(--color-on-success);
177
177
  border-color: var(--color-success-hover);
178
178
  box-shadow: var(--shadow-md);
179
179
  transform: translateY(var(--btn-hover-translate-y));
@@ -153,6 +153,7 @@
153
153
  --color-success-light: color-mix(in srgb, var(--palette-green) 4%, var(--palette-white));
154
154
  --color-success-hover: color-mix(in srgb, var(--palette-green) 80%, var(--palette-black));
155
155
  --color-success-text: color-mix(in srgb, var(--palette-green) 85%, var(--palette-black));
156
+ --color-on-success: var(--color-white); /* Text/icon color on success-filled backgrounds */
156
157
 
157
158
  --color-partial: var(--palette-amber); /* Partial credit feedback */
158
159
 
@@ -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 */
@@ -102,6 +102,8 @@ Test your course with a stub LMS wrapper.
102
102
 
103
103
  Runs Vite build in watch mode + stub LMS server. Changes to source files trigger automatic rebuilds:
104
104
 
105
+ > **MCP-first workflow for AI agents:** Start by calling `coursecode_state`. If it connects, reuse that running preview for the entire authoring session. After editing course files, rely on preview hot reload and use `coursecode_errors`, `coursecode_screenshot`, `coursecode_viewport`, and the other CourseCode MCP runtime tools to inspect the result. Do not restart preview or run a production build after routine edits. If `coursecode_state` reports that preview is unavailable, start `coursecode preview` once in a terminal, then return to the MCP tools. Use `coursecode_lint` for static preflight checks and reserve `coursecode_build` for final package verification or export.
106
+
105
107
  ```bash
106
108
  coursecode preview # Open http://localhost:4173
107
109
  ```
@@ -618,7 +620,7 @@ createNumericQuestion({
618
620
  ```javascript
619
621
  createHotspotQuestion({
620
622
  id: 'hot1', prompt: 'Click the correct region:',
621
- image: { src: 'assets/images/diagram.png', alt: 'Diagram' },
623
+ image: { src: 'assets/images/diagram.png', alt: 'Diagram' }, // Relative to course/assets/
622
624
  hotspots: [
623
625
  // pos = [x%, y%, width%, height%]
624
626
  { id: 'zone1', pos: [10, 20, 15, 10], correct: true, label: 'Correct Zone', feedback: 'Good choice!' },
@@ -627,6 +629,8 @@ createHotspotQuestion({
627
629
  });
628
630
  ```
629
631
 
632
+ 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/`.
633
+
630
634
  **Appearance themes**: `'correct'`, `'incorrect'`, `'primary'`, `'accent'` — or provide custom `appearance` object.
631
635
 
632
636
  ### Sequencing
@@ -1477,7 +1481,7 @@ parent.postMessage({ type: 'coursecode:resize', height: 400 }, '*');
1477
1481
 
1478
1482
  ```html
1479
1483
  <div data-component="interactive-image" id="diagram">
1480
- <img src="assets/images/diagram.png" alt="Diagram" />
1484
+ <img src="course/assets/images/diagram.png" alt="Diagram" />
1481
1485
  <button data-hotspot-id="zone1" data-title="Component A" data-body="Description here..."
1482
1486
  class="interactive-image-hotspot" style="top: 20%; left: 30%;">A</button>
1483
1487
  <button data-hotspot-id="zone2" data-title="Component B" data-body="More details..."
@@ -6,7 +6,7 @@
6
6
 
7
7
  import interactionRegistry from '../managers/interaction-registry.js';
8
8
  import { courseConfig } from '../../../course/course-config.js';
9
- import { getInteractionState, recordInteractionResult } from '../components/interactions/interaction-base.js';
9
+ import { dispatchInteractionChecked, getInteractionState, recordInteractionResult } from '../components/interactions/interaction-base.js';
10
10
  import { logger } from '../utilities/logger.js';
11
11
 
12
12
  /**
@@ -144,6 +144,9 @@ export function createInteractionMethods(logTrace) {
144
144
  }
145
145
  }
146
146
 
147
+ const interactionElement = document.querySelector(`[data-interaction-id="${CSS.escape(interactionId)}"]`);
148
+ dispatchInteractionChecked(interactionElement, evaluation);
149
+
147
150
  return evaluation;
148
151
  } catch (error) {
149
152
  logTrace('checkAnswer:error', { interactionId, error: error.message });
@@ -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: {
@@ -6,10 +6,10 @@ import {
6
6
  clearFeedback,
7
7
  validateContainer,
8
8
  parseResponse,
9
- recordInteractionResult,
10
9
  registerCoreInteraction
11
10
  } from './interaction-base.js';
12
11
  import { logger } from '../../utilities/logger.js';
12
+ import { resolveCourseAssetUrl } from '../../utilities/portable-assets.js';
13
13
 
14
14
  // Default appearance themes for hotspots
15
15
  const HOTSPOT_DEFAULTS = {
@@ -282,7 +282,7 @@ export function createHotspotQuestion(config) {
282
282
  <div class="hotspot-container">
283
283
  <div class="image-container relative">
284
284
  <img
285
- src="${image.src}"
285
+ src="${resolveCourseAssetUrl(image.src)}"
286
286
  alt="${image.alt || ''}"
287
287
  class="w-full h-auto"
288
288
  id="${id}_image"
@@ -329,6 +329,7 @@ export function createHotspotQuestion(config) {
329
329
  if (!selections.length) {
330
330
  displayFeedback(
331
331
  targetContainer,
332
+ id,
332
333
  'Select at least one hotspot before checking your answer.',
333
334
  'error'
334
335
  );
@@ -340,7 +341,8 @@ export function createHotspotQuestion(config) {
340
341
  if (evaluation.correct) {
341
342
  displayFeedback(
342
343
  targetContainer,
343
- '✓ Excellent! You found all the correct areas.',
344
+ id,
345
+ feedback?.correct || '✓ Excellent! You found all the correct areas.',
344
346
  'correct'
345
347
  );
346
348
  } else {
@@ -348,21 +350,12 @@ export function createHotspotQuestion(config) {
348
350
  const selectedCount = selections.length;
349
351
  displayFeedback(
350
352
  targetContainer,
351
- `✗ Keep trying. ${selectedCount} selected / ${correctCount} required.`,
353
+ id,
354
+ feedback?.incorrect || `✗ Keep trying. ${selectedCount} selected / ${correctCount} required.`,
352
355
  'incorrect'
353
356
  );
354
357
  }
355
358
 
356
- recordInteractionResult(
357
- id,
358
- 'other',
359
- evaluation.response,
360
- evaluation.correct,
361
- JSON.stringify(normalizedHotspots.filter(h => h.correct).map(h => h.id)),
362
- prompt,
363
- controlled
364
- );
365
-
366
359
  return evaluation;
367
360
  },
368
361
 
@@ -699,4 +692,3 @@ function toNumber(value, fallback = 0) {
699
692
  }
700
693
  return 0;
701
694
  }
702
-
@@ -195,6 +195,8 @@ export function createInteractionEventHandler(questionObj, config, customHandler
195
195
  if (!config.controlled) {
196
196
  recordInteractionResult(config, evaluation);
197
197
  }
198
+
199
+ dispatchInteractionChecked(event.currentTarget, evaluation);
198
200
  break;
199
201
 
200
202
  case 'reset':
@@ -231,7 +233,11 @@ export function createInteractionEventHandler(questionObj, config, customHandler
231
233
  */
232
234
  export function recordInteractionResult(config, evaluation) {
233
235
  // Mark as submitted in state (for restoration purposes)
234
- saveInteractionState(config.id, evaluation.response, true);
236
+ saveInteractionState(
237
+ config.id,
238
+ normalizeInteractionResponseForPersistence(config.scormType, evaluation.response),
239
+ true
240
+ );
235
241
 
236
242
  // Format the response according to SCORM 2004 requirements
237
243
  const scormType = config.scormType || 'other';
@@ -266,6 +272,43 @@ export function recordInteractionResult(config, evaluation) {
266
272
  }
267
273
  }
268
274
 
275
+ /**
276
+ * Converts structured interaction responses back from their evaluation/SCORM
277
+ * JSON representation before storing them for UI restoration.
278
+ */
279
+ export function normalizeInteractionResponseForPersistence(scormType, response) {
280
+ if (typeof response !== 'string' || !['choice', 'matching', 'sequencing', 'other'].includes(scormType)) {
281
+ return response;
282
+ }
283
+
284
+ const trimmed = response.trim();
285
+ if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) {
286
+ return response;
287
+ }
288
+
289
+ try {
290
+ return JSON.parse(trimmed);
291
+ } catch {
292
+ return response;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * Emits the documented interaction completion event from a rendered
298
+ * interaction or its author-provided container.
299
+ */
300
+ export function dispatchInteractionChecked(target, evaluation) {
301
+ if (!target || !evaluation) return;
302
+
303
+ target.dispatchEvent(new CustomEvent('interaction-checked', {
304
+ bubbles: true,
305
+ detail: {
306
+ isCorrect: evaluation.correct,
307
+ evaluation
308
+ }
309
+ }));
310
+ }
311
+
269
312
  /**
270
313
  * Creates standard interaction controls HTML.
271
314
  * Uses utility classes for layout: .flex .flex-wrap .justify-center .gap-3
@@ -430,14 +473,34 @@ const _responseDebounceTimers = new Map();
430
473
  * @param {object} questionObj - The live interaction instance.
431
474
  */
432
475
  export function registerCoreInteraction(config, questionObj) {
476
+ const scormTypeByInteractionType = {
477
+ 'multiple-choice-single': 'choice',
478
+ 'multiple-choice-multiple': 'choice',
479
+ 'drag-drop': 'other',
480
+ 'fill-in': 'fill-in',
481
+ 'hotspot': 'other',
482
+ 'likert': 'likert',
483
+ 'matching': 'matching',
484
+ 'numeric': 'numeric',
485
+ 'sequencing': 'sequencing',
486
+ 'true-false': 'true-false'
487
+ };
488
+ const registryConfig = config.scormType
489
+ ? config
490
+ : { ...config, scormType: scormTypeByInteractionType[questionObj.type] || 'other' };
491
+
433
492
  // Delegate to the InteractionRegistry (separate from persistence manager)
434
- interactionRegistry.register(config, questionObj);
493
+ interactionRegistry.register(registryConfig, questionObj);
494
+
495
+ // Snapshot restoration state before deferring DOM work. An automated check can
496
+ // run before the next animation frame; reading state inside the callback would
497
+ // then mistake that new answer for pre-existing state and restore it twice.
498
+ const savedState = getInteractionState(config.id);
435
499
 
436
500
  // Defer state restoration and auto-save setup to next frame
437
501
  // This ensures the DOM container exists after render() completes
438
502
  requestAnimationFrame(() => {
439
503
  // Restore previously saved response state if it exists
440
- const savedState = getInteractionState(config.id);
441
504
  if (savedState && savedState.response !== null && savedState.response !== undefined) {
442
505
  try {
443
506
  if (typeof questionObj.setResponse === 'function') {
@@ -446,7 +509,9 @@ export function registerCoreInteraction(config, questionObj) {
446
509
 
447
510
  // If it was previously submitted, also restore the feedback state
448
511
  if (savedState.submitted && typeof questionObj.checkAnswer === 'function') {
449
- questionObj.checkAnswer();
512
+ const evaluation = questionObj.checkAnswer();
513
+ const interactionElement = document.querySelector(`[data-interaction-id="${CSS.escape(config.id)}"]`);
514
+ dispatchInteractionChecked(interactionElement, evaluation);
450
515
  }
451
516
  }
452
517
  } catch (error) {
@@ -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: {
@@ -7,8 +7,8 @@
7
7
  export const schema = {
8
8
  type: 'interactive-image',
9
9
  description: 'Image with clickable hotspots for modals or accordion integration',
10
- example: `<div data-component="interactive-image" class="interactive-image" style="position: relative; display: inline-block;">
11
- <img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='250' fill='%23f1f5f9'%3E%3Crect width='400' height='250' rx='8'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%2394a3b8' font-family='system-ui' font-size='14'%3EInteractive Diagram%3C/text%3E%3C/svg%3E" alt="Interactive diagram">
10
+ example: `<div data-component="interactive-image" class="interactive-image-container">
11
+ <img src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='400' height='250' fill='%23f1f5f9'%3E%3Crect width='400' height='250' rx='8'/%3E%3Ctext x='50%25' y='50%25' dominant-baseline='middle' text-anchor='middle' fill='%2394a3b8' font-family='system-ui' font-size='14'%3EInteractive Diagram%3C/text%3E%3C/svg%3E" alt="Interactive diagram" class="interactive-image-img">
12
12
  <button data-hotspot-id="feature-a" data-title="Feature A" data-body="Details about Feature A" class="hotspot" style="position: absolute; top: 30%; left: 25%; width: 24px; height: 24px; border-radius: 50%; background: #3b82f6; border: 2px solid white; cursor: pointer;" aria-label="Feature A"></button>
13
13
  <button data-hotspot-id="feature-b" data-title="Feature B" data-body="Details about Feature B" class="hotspot" style="position: absolute; top: 60%; left: 65%; width: 24px; height: 24px; border-radius: 50%; background: #f59e0b; border: 2px solid white; cursor: pointer;" aria-label="Feature B"></button>
14
14
  </div>`,
@@ -45,6 +45,12 @@ export function init(root, _options = {}) {
45
45
  const container = typeof root === 'string' ? document.querySelector(root) : root;
46
46
  if (!container) return;
47
47
 
48
+ // The data-component attribute is the author-facing contract. Normalize
49
+ // the internal CSS hooks so documented minimal markup positions hotspots
50
+ // against the image instead of an unrelated ancestor.
51
+ container.classList.add('interactive-image-container');
52
+ container.querySelector(':scope > img')?.classList.add('interactive-image-img');
53
+
48
54
  const hotspots = Array.from(container.querySelectorAll('[data-hotspot-id]'));
49
55
  if (!hotspots.length) return;
50
56
 
@@ -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
  ]);
@@ -781,6 +781,28 @@ function getEffectiveBackgroundColor(element) {
781
781
  return [result[0], result[1], result[2]];
782
782
  }
783
783
 
784
+ /**
785
+ * Detects computed gradient backgrounds on an element or any ancestor.
786
+ * Course themes commonly use custom classes, so class-name allowlists cannot
787
+ * reliably identify every gradient that makes flat-color contrast calculation
788
+ * invalid.
789
+ * @param {HTMLElement} element
790
+ * @returns {boolean}
791
+ */
792
+ function hasComputedGradientBackground(element) {
793
+ let current = element;
794
+
795
+ while (current && current.tagName !== 'HTML') {
796
+ const backgroundImage = window.getComputedStyle(current).backgroundImage;
797
+ if (backgroundImage && backgroundImage !== 'none' && backgroundImage.includes('gradient')) {
798
+ return true;
799
+ }
800
+ current = current.parentElement;
801
+ }
802
+
803
+ return false;
804
+ }
805
+
784
806
 
785
807
  /**
786
808
  * Builds a compact description of an element and its ancestry for lint messages.
@@ -833,6 +855,7 @@ function validateVisualLayout(slideId, renderedContent, errors, warnings) {
833
855
  const textElements = renderedContent.querySelectorAll('p, span:not(.accordion-icon), li, a, h1, h2, h3, h4, h5, h6, button');
834
856
  for (const el of textElements) {
835
857
  if (isLintIgnored(el, 'contrast')) continue;
858
+ if (el.classList.contains('sr-only') || el.closest('.sr-only')) continue;
836
859
  // Skip elements that are not visible or have no text
837
860
  if (el.offsetParent === null || el.textContent.trim() === '') continue;
838
861
 
@@ -872,18 +895,7 @@ function validateVisualLayout(slideId, renderedContent, errors, warnings) {
872
895
  if (el.classList.contains('badge') || el.closest('.badge') !== null) continue;
873
896
 
874
897
  // Skip contrast check for elements on gradient backgrounds (can't reliably compute)
875
- const hasGradientBackground = el.closest('.gradient') !== null ||
876
- el.closest('.gradient-light') !== null ||
877
- el.closest('.hero-gradient') !== null ||
878
- el.closest('.btn-gradient') !== null ||
879
- el.closest('[class*="bg-gradient-dark"]') !== null ||
880
- el.closest('[class*="gradient-header"]') !== null ||
881
- el.closest('[class*="gradient-success"]') !== null ||
882
- el.closest('[class*="gradient-progress"]') !== null ||
883
- el.closest('[style*="linear-gradient"]') !== null ||
884
- el.closest('[style*="radial-gradient"]') !== null ||
885
- style.backgroundImage.includes('gradient');
886
- if (hasGradientBackground) continue;
898
+ if (hasComputedGradientBackground(el)) continue;
887
899
 
888
900
  const textColorStr = style.color;
889
901
  const bgColorRgb = getEffectiveBackgroundColor(el);
@@ -1042,7 +1054,8 @@ function validateTextProximityToVisualElements(slideId, renderedContent, warning
1042
1054
  'dropdown', 'dropdown-menu', 'dropdown-item',
1043
1055
  'table', // Tables manage their own cell spacing
1044
1056
  'step-number', 'step-content', 'step', // Pattern-steps elements have intentional circular styling
1045
- 'btn-link' // Link-styled button intentionally has minimal padding
1057
+ 'btn-link', // Link-styled button intentionally has minimal padding
1058
+ 'hotspot-area', 'interactive-image-hotspot'
1046
1059
  ]);
1047
1060
 
1048
1061
  // HTML elements that manage their own spacing (exclude from checks)
@@ -1171,6 +1184,8 @@ function validateElementOverlap(slideId, renderedContent, warnings) {
1171
1184
  const absoluteElements = renderedContent.querySelectorAll('[style*="position:absolute"], [style*="position: absolute"]');
1172
1185
  for (const el of absoluteElements) {
1173
1186
  if (el.offsetParent === null) continue;
1187
+ // Hotspots intentionally overlap their background image and sometimes each other.
1188
+ if (el.matches('[data-hotspot-id], .hotspot-area, .interactive-image-hotspot')) continue;
1174
1189
 
1175
1190
  const rect = el.getBoundingClientRect();
1176
1191
  const elText = el.textContent.trim().substring(0, 30);
@@ -72,6 +72,26 @@ export function resolvePortableAssetUrl(value) {
72
72
  return value;
73
73
  }
74
74
 
75
+ /**
76
+ * Resolve an author-facing course asset path for both normal and portable builds.
77
+ * Component APIs may use paths relative to course/assets (for example,
78
+ * "images/diagram.svg") or the legacy "assets/..." form documented by older
79
+ * CourseCode releases. Direct HTML should use "course/assets/...".
80
+ * @param {string} value
81
+ * @returns {string}
82
+ */
83
+ export function resolveCourseAssetUrl(value) {
84
+ if (typeof value !== 'string' || value.length === 0) return value;
85
+
86
+ const portableValue = resolvePortableAssetUrl(value);
87
+ if (portableValue !== value) return portableValue;
88
+ if (/^(?:data:|blob:|https?:|mailto:|tel:|#|\/\/)/i.test(value)) return value;
89
+ if (value.startsWith('/') || value.startsWith('./') || value.startsWith('../')) return value;
90
+ if (value.startsWith('course/assets/')) return `./${value}`;
91
+ if (value.startsWith('assets/')) return `./course/${value}`;
92
+ return `./course/assets/${value}`;
93
+ }
94
+
75
95
  /**
76
96
  * Rewrite URL-bearing attributes in freshly rendered slide DOM before
77
97
  * declarative components initialize and begin loading media.
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.1.58",
2
+ "version": "0.1.60",
3
3
  "name": "CourseCode Framework",
4
4
  "description": "Multi-format course authoring and learner runtime framework",
5
- "released": "2026-07-13",
5
+ "released": "2026-07-14",
6
6
  "formats": [
7
7
  "SCORM 2004 4th Edition",
8
8
  "SCORM 1.2",
@@ -12,7 +12,7 @@
12
12
  import fs from 'fs';
13
13
  import path from 'path';
14
14
  import { parseSlideSource, extractAssessment } from './course-parser.js';
15
- import { getEngagementTrackingMap, getRegisteredComponentTypes } from './schema-extractor.js';
15
+ import { getAllIcons, getEngagementTrackingMap, getRegisteredComponentTypes } from './schema-extractor.js';
16
16
  import { getValidCssClasses, lintCssSelectors } from './css-index.js';
17
17
  import {
18
18
  parseSlideNarration,
@@ -51,7 +51,7 @@ const DYNAMIC_CLASSES = new Set([
51
51
  // Component-internal classes — styled via [data-component] selectors in individual component CSS files
52
52
  'intro-card', 'card-icon',
53
53
  // Interaction-internal classes — used by interaction JS for DOM structure
54
- 'drag-drop', 'matching-items', 'matching-targets',
54
+ 'drag-drop', 'matching-items', 'matching-targets', 'image-container', 'hotspot-feedback',
55
55
  // Slide-specific JS selectors — queried by slide scripts for event binding
56
56
  'resources', 'complete-remedial-btn',
57
57
  ]);
@@ -93,6 +93,7 @@ export async function lintCourse(courseConfig, coursePath) {
93
93
  slideFilesOnDisk
94
94
  );
95
95
  warnings.push(...globalWarnings);
96
+ validateMenuIcons(courseConfig, coursePath, warnings);
96
97
 
97
98
  // Build valid CSS class index once for all slides
98
99
  const validCssIndex = getValidCssClasses();
@@ -219,8 +220,12 @@ async function validateSlide(slide, coursePath, objectiveIds, errors, warnings,
219
220
  if (interaction.id) {
220
221
  registerInteractionId(interaction.id, slide.id, 'DOM Interaction', interactionIdRegistry, errors);
221
222
  }
223
+ validateInteractionSchema(slide.id, interaction, errors);
224
+ validateInteractionAssetReferences(slide.id, interaction, coursePath, errors);
222
225
  }
223
226
 
227
+ validateDirectAssetReferences(slide.id, slideData.elements || [], coursePath, errors);
228
+
224
229
  // Static CSS class validation — checks class attributes in source template
225
230
  validateCssClassesStatic(slide.id, source, validCssIndex, warnings);
226
231
 
@@ -244,6 +249,191 @@ function validateRequirementContent(slideId, requirement, slideData, engagementT
244
249
  }
245
250
  }
246
251
 
252
+ function schemaTypeMatches(value, expectedType) {
253
+ if (Array.isArray(expectedType)) {
254
+ return expectedType.some(type => schemaTypeMatches(value, type));
255
+ }
256
+ if (expectedType === 'array') return Array.isArray(value);
257
+ if (expectedType === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
258
+ if (expectedType === 'number') return typeof value === 'number' && Number.isFinite(value);
259
+ return typeof value === expectedType;
260
+ }
261
+
262
+ function validateSchemaValue(reference, value, definition, errors, enforceRequiredChildren = true) {
263
+ if (!definition || value === undefined) return;
264
+
265
+ if (definition.type && !schemaTypeMatches(value, definition.type)) {
266
+ const actualType = Array.isArray(value) ? 'array' : value === null ? 'null' : typeof value;
267
+ const expectedType = Array.isArray(definition.type) ? definition.type.join(' or ') : definition.type;
268
+ errors.push(`${reference} must be ${expectedType}, got ${actualType}.`);
269
+ return;
270
+ }
271
+
272
+ if (definition.enum && !definition.enum.includes(value)) {
273
+ errors.push(`${reference} must be one of: ${definition.enum.join(', ')}.`);
274
+ }
275
+
276
+ if (Array.isArray(value)) {
277
+ if (definition.minItems != null && value.length < definition.minItems) {
278
+ errors.push(`${reference} must contain at least ${definition.minItems} item(s).`);
279
+ }
280
+ if (definition.itemSchema) {
281
+ value.forEach((item, index) => {
282
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return;
283
+ for (const [key, childDefinition] of Object.entries(definition.itemSchema)) {
284
+ if (childDefinition.required && !Object.hasOwn(item, key)) {
285
+ errors.push(`${reference}[${index}] is missing required property "${key}".`);
286
+ continue;
287
+ }
288
+ validateSchemaValue(`${reference}[${index}].${key}`, item[key], childDefinition, errors);
289
+ }
290
+ });
291
+ }
292
+ }
293
+
294
+ if (definition.type === 'object' && definition.valueSchema) {
295
+ for (const [key, childDefinition] of Object.entries(definition.valueSchema)) {
296
+ if (enforceRequiredChildren && childDefinition.required && !Object.hasOwn(value, key)) {
297
+ errors.push(`${reference} is missing required property "${key}".`);
298
+ continue;
299
+ }
300
+ validateSchemaValue(`${reference}.${key}`, value[key], childDefinition, errors);
301
+ }
302
+ }
303
+
304
+ if (definition.type === 'object' && definition.mapValueSchema) {
305
+ for (const [key, childValue] of Object.entries(value)) {
306
+ validateSchemaValue(`${reference}.${key}`, childValue, definition.mapValueSchema, errors);
307
+ }
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Validate statically parsed interaction values against the registered schema.
313
+ * Top-level missing values are skipped because they may be supplied by a runtime
314
+ * variable that the lightweight source parser cannot resolve. Known literal
315
+ * values, nested objects, and array items are validated fully.
316
+ */
317
+ export function validateInteractionSchema(slideId, interaction, errors) {
318
+ const properties = interaction?.schema?.properties;
319
+ if (!properties) return;
320
+
321
+ for (const [key, definition] of Object.entries(properties)) {
322
+ if (!Object.hasOwn(interaction, key)) continue;
323
+ validateSchemaValue(`Slide "${slideId}" interaction "${interaction.id}" property "${key}"`, interaction[key], definition, errors);
324
+ }
325
+ }
326
+
327
+ const DIRECT_ASSET_ATTRIBUTES = new Set([
328
+ 'src', 'href', 'poster', 'data-src', 'data-audio-src', 'data-video-src',
329
+ 'data-video-poster', 'data-video-captions', 'data-lightbox-src',
330
+ 'data-lightbox-thumbnail', 'data-md-src'
331
+ ]);
332
+ const NATIVE_ASSET_ATTRIBUTES = new Set(['src', 'href', 'poster']);
333
+
334
+ function isExternalAssetReference(reference) {
335
+ return /^(?:data:|blob:|https?:|mailto:|tel:|#|\/\/|@)/i.test(reference);
336
+ }
337
+
338
+ function assetPathForReference(reference, coursePath, directHtml) {
339
+ if (typeof reference !== 'string' || !reference || reference.includes('${')) return null;
340
+ if (isExternalAssetReference(reference)) return null;
341
+
342
+ const clean = reference.split('#', 1)[0].split('?', 1)[0].replace(/\\/g, '/');
343
+ const withoutDot = clean.replace(/^\.\//, '');
344
+
345
+ if (directHtml && withoutDot.startsWith('assets/')) {
346
+ return {
347
+ error: `Direct HTML asset path "${reference}" will not exist in LMS packages. Use "course/${withoutDot}".`
348
+ };
349
+ }
350
+
351
+ let relativePath = null;
352
+ if (withoutDot.startsWith('course/assets/')) {
353
+ relativePath = withoutDot.slice('course/assets/'.length);
354
+ } else if (!directHtml && withoutDot.startsWith('assets/')) {
355
+ relativePath = withoutDot.slice('assets/'.length);
356
+ } else if (!directHtml && !withoutDot.startsWith('/') && !withoutDot.startsWith('../')) {
357
+ relativePath = withoutDot;
358
+ }
359
+
360
+ if (!relativePath) return null;
361
+ return { filePath: path.join(coursePath, 'assets', relativePath), relativePath };
362
+ }
363
+
364
+ function validateAssetReference(slideId, reference, coursePath, directHtml, errors) {
365
+ const resolved = assetPathForReference(reference, coursePath, directHtml);
366
+ if (!resolved) return;
367
+ if (resolved.error) {
368
+ errors.push(`Slide "${slideId}": ${resolved.error}`);
369
+ return;
370
+ }
371
+ if (!fs.existsSync(resolved.filePath)) {
372
+ errors.push(`Slide "${slideId}": local course asset not found: course/assets/${resolved.relativePath}`);
373
+ }
374
+ }
375
+
376
+ export function validateDirectAssetReferences(slideId, elements, coursePath, errors) {
377
+ for (const element of elements || []) {
378
+ for (const [attribute, value] of Object.entries(element.attributes || {})) {
379
+ if (!DIRECT_ASSET_ATTRIBUTES.has(attribute)) continue;
380
+ const componentOwnsHref = attribute === 'href' && element.attributes?.['data-component'] === 'lightbox';
381
+ validateAssetReference(
382
+ slideId,
383
+ value,
384
+ coursePath,
385
+ NATIVE_ASSET_ATTRIBUTES.has(attribute) && !componentOwnsHref,
386
+ errors
387
+ );
388
+ }
389
+ }
390
+ }
391
+
392
+ export function validateInteractionAssetReferences(slideId, interaction, coursePath, errors) {
393
+ const visit = (value, key = '') => {
394
+ if (!value || typeof value !== 'object') return;
395
+ for (const [childKey, childValue] of Object.entries(value)) {
396
+ if (childKey === 'schema') continue;
397
+ if (childKey === 'src' && typeof childValue === 'string') {
398
+ validateAssetReference(slideId, childValue, coursePath, false, errors);
399
+ } else if (childValue && typeof childValue === 'object') {
400
+ visit(childValue, childKey || key);
401
+ }
402
+ }
403
+ };
404
+ visit(interaction);
405
+ }
406
+
407
+ function customIconNames(coursePath) {
408
+ const iconFile = path.join(coursePath, 'icons.js');
409
+ if (!fs.existsSync(iconFile)) return [];
410
+ const source = fs.readFileSync(iconFile, 'utf-8');
411
+ const declaration = source.match(/export\s+const\s+customIcons\s*=\s*\{([\s\S]*?)\n\};/);
412
+ if (!declaration) return [];
413
+ return [...declaration[1].matchAll(/^\s*['"]([^'"]+)['"]\s*:/gm)].map(match => match[1]);
414
+ }
415
+
416
+ function isEmojiIcon(value) {
417
+ return /^[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1FA00}-\u{1FAFF}]/u.test(value);
418
+ }
419
+
420
+ export function validateMenuIcons(courseConfig, coursePath, warnings) {
421
+ const validIcons = new Set([...Object.keys(getAllIcons()), ...customIconNames(coursePath)]);
422
+ const inspectIcon = (icon, owner) => {
423
+ if (typeof icon !== 'string' || !icon || validIcons.has(icon) || isEmojiIcon(icon)) return;
424
+ warnings.push(`Unknown menu icon "${icon}" on ${owner}. Add it to course/icons.js or use a registered icon.`);
425
+ };
426
+
427
+ const walk = items => {
428
+ for (const item of items || []) {
429
+ inspectIcon(item.menu?.icon, `${item.type || 'item'} "${item.id || item.title || 'unknown'}"`);
430
+ if (item.children) walk(item.children);
431
+ }
432
+ };
433
+ walk(courseConfig.structure);
434
+ inspectIcon(courseConfig.navigation?.documentGallery?.icon, 'navigation.documentGallery');
435
+ }
436
+
247
437
  /**
248
438
  * Static CSS class validation — extracts class="..." values from slide source
249
439
  * and checks them against the valid CSS class index built from PostCSS.
package/lib/build.js CHANGED
@@ -9,6 +9,69 @@ import { fileURLToPath } from 'url';
9
9
  import { validateProject } from './project-utils.js';
10
10
 
11
11
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ export function snapshotZipFiles(directory) {
14
+ const snapshot = new Map();
15
+ if (!fs.existsSync(directory)) return snapshot;
16
+
17
+ for (const filename of fs.readdirSync(directory)) {
18
+ if (!filename.endsWith('.zip')) continue;
19
+ const stats = fs.statSync(path.join(directory, filename));
20
+ snapshot.set(filename, { mtimeMs: stats.mtimeMs, size: stats.size });
21
+ }
22
+
23
+ return snapshot;
24
+ }
25
+
26
+ export function findProducedZipFiles(directory, before = new Map()) {
27
+ if (!fs.existsSync(directory)) return [];
28
+
29
+ return fs.readdirSync(directory)
30
+ .filter(filename => filename.endsWith('.zip'))
31
+ .map(filename => {
32
+ const stats = fs.statSync(path.join(directory, filename));
33
+ return { filename, mtimeMs: stats.mtimeMs, size: stats.size };
34
+ })
35
+ .filter(file => {
36
+ const previous = before.get(file.filename);
37
+ return !previous || previous.mtimeMs !== file.mtimeMs || previous.size !== file.size;
38
+ })
39
+ .sort((a, b) => a.mtimeMs - b.mtimeMs || a.filename.localeCompare(b.filename))
40
+ .map(file => file.filename);
41
+ }
42
+
43
+ export function detectBuiltFormat(distDirectory, requestedFormat) {
44
+ const indexPath = path.join(distDirectory, 'index.html');
45
+
46
+ if (fs.existsSync(indexPath)) {
47
+ const html = fs.readFileSync(indexPath, 'utf8');
48
+ const match = html.match(/<meta\s+name=["']lms-format["']\s+content=["']([^"']+)["']/i);
49
+ if (match?.[1]) return match[1];
50
+ }
51
+
52
+ if (fs.existsSync(path.join(distDirectory, 'cmi5.xml'))) return 'cmi5';
53
+ if (fs.existsSync(path.join(distDirectory, 'lti-tool-config.json'))) return 'lti';
54
+
55
+ return requestedFormat || 'scorm2004';
56
+ }
57
+
58
+ export function formatBuildOutput(format, zipFiles = []) {
59
+ const normalizedFormat = format || 'scorm2004';
60
+ const displayNames = {
61
+ scorm2004: 'SCORM 2004',
62
+ 'scorm1.2': 'SCORM 1.2',
63
+ cmi5: 'cmi5',
64
+ lti: 'LTI 1.3',
65
+ 'scorm2004-proxy': 'SCORM 2004 proxy',
66
+ 'scorm1.2-proxy': 'SCORM 1.2 proxy',
67
+ 'cmi5-remote': 'cmi5 remote'
68
+ };
69
+ const displayName = displayNames[normalizedFormat] || normalizedFormat;
70
+ const archives = zipFiles.map(filename => ` - ${filename} Ready for LMS upload`).join('\n');
71
+
72
+ return ` Output:\n - dist/ ${displayName} package files${archives ? `\n${archives}` : ''}\n\n To test locally, launch dist/ or the archive in a ${displayName}-compatible LMS or conformance tool.`;
73
+ }
74
+
12
75
  /**
13
76
  * Run a command and return a promise
14
77
  */
@@ -42,6 +105,7 @@ export async function build(options = {}) {
42
105
  `);
43
106
 
44
107
  const startTime = Date.now();
108
+ const zipSnapshot = snapshotZipFiles(process.cwd());
45
109
 
46
110
  try {
47
111
  // Build environment — pass lib dir so vite.config.js can resolve coursecode utilities
@@ -64,17 +128,14 @@ export async function build(options = {}) {
64
128
 
65
129
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
66
130
 
67
- // Check for output
68
- const zipFiles = fs.readdirSync(process.cwd()).filter(f => f.endsWith('.zip'));
131
+ const zipFiles = findProducedZipFiles(process.cwd(), zipSnapshot);
132
+ const builtFormat = detectBuiltFormat(path.join(process.cwd(), 'dist'), options.format);
133
+ const output = formatBuildOutput(builtFormat, zipFiles);
69
134
 
70
135
  console.log(`
71
136
  ✅ Build completed in ${elapsed}s
72
137
 
73
- Output:
74
- - dist/ SCORM package files
75
- ${zipFiles.length > 0 ? `- ${zipFiles[zipFiles.length - 1]} Ready for LMS upload` : ''}
76
-
77
- To test locally, load dist/ directory in a SCORM testing tool.
138
+ ${output}
78
139
  `);
79
140
 
80
141
  } catch (error) {
@@ -410,7 +410,8 @@ class HeadlessBrowser {
410
410
  if (scrollY !== undefined && scrollY > 0) {
411
411
  await this._ensureCourseFrame();
412
412
  await this.courseFrame.evaluate((y) => {
413
- const container = document.querySelector('.slide-content')
413
+ const container = document.querySelector('#content')
414
+ || document.querySelector('.slide-content')
414
415
  || document.querySelector('[class*="slide"]')
415
416
  || document.documentElement;
416
417
  container.scrollTop = y;
@@ -425,17 +426,21 @@ class HeadlessBrowser {
425
426
  // temporarily expand the viewport so nothing is clipped, then screenshot.
426
427
  await this._ensureCourseFrame();
427
428
  const contentHeight = await this.courseFrame.evaluate(() => {
429
+ const content = document.querySelector('#content');
428
430
  return Math.max(
431
+ content?.scrollHeight || 0,
429
432
  document.body.scrollHeight,
430
433
  document.documentElement.scrollHeight
431
434
  );
432
435
  });
433
436
 
434
- const fullHeight = Math.max(contentHeight, this._viewport.height);
437
+ const iframeElement = await this.page.$('iframe');
438
+ const iframeBox = await iframeElement?.boundingBox();
439
+ const playerChromeHeight = Math.max(0, this._viewport.height - (iframeBox?.height || this._viewport.height));
440
+ const fullHeight = Math.max(contentHeight + playerChromeHeight, this._viewport.height);
435
441
  await this.page.setViewport({ width: this._viewport.width, height: fullHeight });
436
442
  await new Promise(resolve => setTimeout(resolve, 200));
437
443
 
438
- const iframeElement = await this.page.$('iframe');
439
444
  if (iframeElement) {
440
445
  screenshotBuffer = await iframeElement.screenshot({
441
446
  type: 'jpeg',
package/lib/info.js CHANGED
@@ -9,6 +9,18 @@ import { fileURLToPath } from 'url';
9
9
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
10
  const PACKAGE_ROOT = path.join(__dirname, '..');
11
11
 
12
+ export function findLatestCoursePackage(directory) {
13
+ const packages = fs.readdirSync(directory, { withFileTypes: true })
14
+ .filter(entry => entry.isFile() && entry.name.endsWith('.zip'))
15
+ .map(entry => ({
16
+ name: entry.name,
17
+ modifiedAt: fs.statSync(path.join(directory, entry.name)).mtimeMs
18
+ }))
19
+ .sort((a, b) => b.modifiedAt - a.modifiedAt || a.name.localeCompare(b.name));
20
+
21
+ return packages[0]?.name || null;
22
+ }
23
+
12
24
  export async function info() {
13
25
  const cwd = process.cwd();
14
26
 
@@ -70,9 +82,9 @@ export async function info() {
70
82
  }
71
83
 
72
84
  // Check for ZIP
73
- const zipFiles = fs.readdirSync(cwd).filter(f => f.endsWith('.zip'));
74
- if (zipFiles.length > 0) {
75
- console.log(` Course package: ${zipFiles[zipFiles.length - 1]}`);
85
+ const latestPackage = findLatestCoursePackage(cwd);
86
+ if (latestPackage) {
87
+ console.log(` Course package: ${latestPackage}`);
76
88
  }
77
89
 
78
90
  console.log('');
package/lib/upgrade.js CHANGED
@@ -113,6 +113,39 @@ export function addMissingRuntimeDependencies(projectPkg, templatePkg) {
113
113
  return added;
114
114
  }
115
115
 
116
+ /**
117
+ * Keep the project-local authoring/build CLI aligned with the framework that
118
+ * was just installed. Generated projects rely on this dependency so an
119
+ * isolated checkout can run CourseCode commands without a global install.
120
+ */
121
+ export function syncCoursecodeCliDependency(projectPkg, targetVersion) {
122
+ const targetRange = `^${targetVersion}`;
123
+ const currentDevRange = projectPkg.devDependencies?.coursecode;
124
+ const currentRuntimeRange = projectPkg.dependencies?.coursecode;
125
+ const updated = currentDevRange !== targetRange || Boolean(currentRuntimeRange);
126
+
127
+ if (!updated) {
128
+ return { updated: false, from: currentDevRange, to: targetRange };
129
+ }
130
+
131
+ projectPkg.devDependencies = Object.fromEntries(
132
+ Object.entries({
133
+ ...(projectPkg.devDependencies || {}),
134
+ coursecode: targetRange
135
+ }).sort(([a], [b]) => a.localeCompare(b))
136
+ );
137
+
138
+ if (currentRuntimeRange) {
139
+ delete projectPkg.dependencies.coursecode;
140
+ }
141
+
142
+ return {
143
+ updated: true,
144
+ from: currentDevRange || currentRuntimeRange || null,
145
+ to: targetRange
146
+ };
147
+ }
148
+
116
149
  const OBSOLETE_MANAGED_DEV_DEPENDENCIES = ['@vitejs/plugin-legacy'];
117
150
 
118
151
  /**
@@ -209,7 +242,7 @@ export async function upgrade(options = {}) {
209
242
  - framework/ (replace entirely)
210
243
  - schemas/ (replace entirely)
211
244
  - lib/manifest/ (replace entirely)
212
- - package.json (add missing runtime dependencies)
245
+ - package.json (sync CourseCode CLI and add missing runtime dependencies)
213
246
  - .coursecoderc.json (update version)`;
214
247
 
215
248
  if (options.configs) {
@@ -307,14 +340,17 @@ export async function upgrade(options = {}) {
307
340
  // the project's existing version ranges.
308
341
  const pkgPath = path.join(cwd, 'package.json');
309
342
  let addedRuntimeDeps = [];
343
+ let cliDependencyChange = null;
310
344
  let toolingChanges = null;
311
345
  if (fs.existsSync(pkgPath)) {
312
346
  const projectPkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
313
347
  addedRuntimeDeps = addMissingRuntimeDependencies(projectPkg, templatePkg);
348
+ cliDependencyChange = syncCoursecodeCliDependency(projectPkg, targetVersion);
314
349
  if (options.configs) {
315
350
  toolingChanges = syncManagedBuildTooling(projectPkg, templatePkg);
316
351
  }
317
- const packageChanged = addedRuntimeDeps.length > 0 ||
352
+ const packageChanged = cliDependencyChange.updated ||
353
+ addedRuntimeDeps.length > 0 ||
318
354
  toolingChanges?.updated.length > 0 ||
319
355
  toolingChanges?.removed.length > 0 ||
320
356
  toolingChanges?.engineUpdated;
@@ -330,13 +366,19 @@ export async function upgrade(options = {}) {
330
366
 
331
367
  Your course/ directory was not modified.`;
332
368
 
369
+ if (cliDependencyChange?.updated) {
370
+ const previousRange = cliDependencyChange.from || 'missing';
371
+ successMsg += `
372
+
373
+ CourseCode CLI dependency updated:
374
+ - coursecode: ${previousRange} → ${cliDependencyChange.to}`;
375
+ }
376
+
333
377
  if (addedRuntimeDeps.length > 0) {
334
378
  successMsg += `
335
379
 
336
380
  Runtime dependencies added to package.json:
337
- - ${addedRuntimeDeps.join('\n - ')}
338
-
339
- Run npm install to update node_modules and your lockfile.`;
381
+ - ${addedRuntimeDeps.join('\n - ')}`;
340
382
  }
341
383
 
342
384
  if (toolingChanges && (
@@ -359,9 +401,6 @@ export async function upgrade(options = {}) {
359
401
  successMsg += `
360
402
  - Updated Node engine requirement to ${templatePkg.engines.node}`;
361
403
  }
362
- successMsg += `
363
-
364
- Run npm install to update node_modules and your lockfile.`;
365
404
  }
366
405
 
367
406
  if (configUpdates.length > 0) {
@@ -373,6 +412,18 @@ ${configUpdates.join('\n')}
373
412
  Review the changes and delete .backup files when satisfied.`;
374
413
  }
375
414
 
415
+ if (
416
+ cliDependencyChange?.updated ||
417
+ addedRuntimeDeps.length > 0 ||
418
+ toolingChanges?.updated.length > 0 ||
419
+ toolingChanges?.removed.length > 0 ||
420
+ toolingChanges?.engineUpdated
421
+ ) {
422
+ successMsg += `
423
+
424
+ Run npm install to update node_modules and your lockfile.`;
425
+ }
426
+
376
427
  successMsg += `
377
428
 
378
429
  Review the changelog for breaking changes:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.58",
3
+ "version": "0.1.60",
4
4
  "description": "Multi-format course authoring framework with CLI tools (SCORM 2004, SCORM 1.2, cmi5, LTI 1.3)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1386,7 +1386,7 @@ export const slide = {
1386
1386
  <div class="flex gap-4 flex-wrap items-start justify-between">
1387
1387
  <a href="assets/docs/example_md_1.md" data-component="lightbox" data-lightbox-caption="Sample Document" data-lightbox-subtitle="Sample Document" style="width: 200px; height: 150px;"></a>
1388
1388
  <a href="assets/docs/example_md_2.md" data-component="lightbox" data-lightbox-caption="Reference Table" data-lightbox-subtitle="Reference Table" style="width: 200px; height: 150px;"></a>
1389
- <a href="assets/docs/example_pdf_1.pdf" data-component="lightbox" data-lightbox-caption="Quick Reference Guide" data-lightbox-thumbnail="assets/docs/example_pdf_1_thumbnail.png" data-lightbox-subtitle="Quick Reference Guide" style="width: 200px; height: 150px;"></a>
1389
+ <a href="assets/docs/example_pdf_2.pdf" data-component="lightbox" data-lightbox-caption="Quick Reference Guide" data-lightbox-thumbnail="assets/docs/example_pdf_1_thumbnail.png" data-lightbox-subtitle="Quick Reference Guide" style="width: 200px; height: 150px;"></a>
1390
1390
  </div>
1391
1391
  </section>
1392
1392