coursecode 0.1.59 → 0.1.61

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.
@@ -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
 
@@ -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
  ```
@@ -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 });
@@ -6,7 +6,6 @@ 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';
@@ -330,6 +329,7 @@ export function createHotspotQuestion(config) {
330
329
  if (!selections.length) {
331
330
  displayFeedback(
332
331
  targetContainer,
332
+ id,
333
333
  'Select at least one hotspot before checking your answer.',
334
334
  'error'
335
335
  );
@@ -341,7 +341,8 @@ export function createHotspotQuestion(config) {
341
341
  if (evaluation.correct) {
342
342
  displayFeedback(
343
343
  targetContainer,
344
- '✓ Excellent! You found all the correct areas.',
344
+ id,
345
+ feedback?.correct || '✓ Excellent! You found all the correct areas.',
345
346
  'correct'
346
347
  );
347
348
  } else {
@@ -349,21 +350,12 @@ export function createHotspotQuestion(config) {
349
350
  const selectedCount = selections.length;
350
351
  displayFeedback(
351
352
  targetContainer,
352
- `✗ Keep trying. ${selectedCount} selected / ${correctCount} required.`,
353
+ id,
354
+ feedback?.incorrect || `✗ Keep trying. ${selectedCount} selected / ${correctCount} required.`,
353
355
  'incorrect'
354
356
  );
355
357
  }
356
358
 
357
- recordInteractionResult(
358
- id,
359
- 'other',
360
- evaluation.response,
361
- evaluation.correct,
362
- JSON.stringify(normalizedHotspots.filter(h => h.correct).map(h => h.id)),
363
- prompt,
364
- controlled
365
- );
366
-
367
359
  return evaluation;
368
360
  },
369
361
 
@@ -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) {
@@ -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
 
@@ -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.
@@ -873,18 +895,7 @@ function validateVisualLayout(slideId, renderedContent, errors, warnings) {
873
895
  if (el.classList.contains('badge') || el.closest('.badge') !== null) continue;
874
896
 
875
897
  // Skip contrast check for elements on gradient backgrounds (can't reliably compute)
876
- const hasGradientBackground = el.closest('.gradient') !== null ||
877
- el.closest('.gradient-light') !== null ||
878
- el.closest('.hero-gradient') !== null ||
879
- el.closest('.btn-gradient') !== null ||
880
- el.closest('[class*="bg-gradient-dark"]') !== null ||
881
- el.closest('[class*="gradient-header"]') !== null ||
882
- el.closest('[class*="gradient-success"]') !== null ||
883
- el.closest('[class*="gradient-progress"]') !== null ||
884
- el.closest('[style*="linear-gradient"]') !== null ||
885
- el.closest('[style*="radial-gradient"]') !== null ||
886
- style.backgroundImage.includes('gradient');
887
- if (hasGradientBackground) continue;
898
+ if (hasComputedGradientBackground(el)) continue;
888
899
 
889
900
  const textColorStr = style.color;
890
901
  const bgColorRgb = getEffectiveBackgroundColor(el);
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.1.59",
2
+ "version": "0.1.61",
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-20",
6
6
  "formats": [
7
7
  "SCORM 2004 4th Edition",
8
8
  "SCORM 1.2",
@@ -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',
@@ -459,7 +464,9 @@ class HeadlessBrowser {
459
464
  }
460
465
 
461
466
  return {
462
- data: screenshotBuffer.toString('base64'),
467
+ // Puppeteer >=22 returns Uint8Array, whose toString() ignores the
468
+ // 'base64' argument — wrap in Buffer so encoding applies.
469
+ data: Buffer.from(screenshotBuffer).toString('base64'),
463
470
  mimeType: 'image/jpeg'
464
471
  };
465
472
  }
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.59",
3
+ "version": "0.1.61",
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": {