Intended Audience: AI Agents — This document is a machine-readable reference for AI agents developing the framework itself. For human-readable documentation, see
USER_GUIDE.md.
Related Docs:
COURSE_AUTHORING_GUIDE.md- For course authors (not framework devs)DATA_MODEL.md- Complete learner data schemas and storage architecture
The framework source repo has a different structure than course projects created with coursecode create. Use these commands from the repo root:
| Command | Purpose | Output |
|---|---|---|
npm run dev |
Build watch only (no server) | dist/ |
npm run preview |
Stub LMS player + build watch + live reload | dist/ + server on :4173 |
npm run preview:scorm12 |
Preview with SCORM 1.2 format | dist/ + server on :4173 |
npm run preview:cmi5 |
Preview with cmi5 format | dist/ + server on :4173 |
npm run build |
One-time development build | dist/ |
npm run build:scorm2004 |
Build SCORM 2004 package | dist/ |
npm run build:scorm12 |
Build SCORM 1.2 package | dist/ |
npm run build:cmi5 |
Build cmi5 package | dist/ |
npm run build:lti |
Build LTI 1.3 package | dist/ |
# Start the preview server with stub LMS
npm run preview
# Opens http://localhost:4173 with:
# - Stub SCORM API (localStorage persistence)
# - Live reload on file changes
# - Debug panel for API inspection with LMS Compatibility Warnings
# - Content viewer for course reviewCourseCode uses Vite 8 and emits one standards-based ESM build. The supported learner-browser floor is intentionally fixed in both Vite configurations:
- Chrome 111+
- Edge 111+
- Firefox 114+
- Safari 16.4+
SCORM 1.2 and SCORM 2004 define the LMS runtime/API contract; they do not require ES3, ES5, Internet Explorer, or a SystemJS bundle. CourseCode no longer generates an IE11 fallback with @vitejs/plugin-legacy. The framework's CSS design system depends heavily on custom properties, Grid, color-mix(), and other modern platform features that JavaScript transpilation cannot make reliable in IE11.
Treat an older embedded LMS browser as a separate, explicitly tested customer requirement. Do not re-enable Babel/SystemJS as a general compatibility precaution: a legacy JavaScript bundle alone is not evidence that the complete course UI works in that engine.
The framework and generated projects require Node 20.19+. Vite, vite-plugin-static-copy, and their lockfiles must be upgraded together and verified with a freshly generated project plus every LMS-format build.
The preview server (stub player) includes an advanced diagnostic system that monitors API usage and data limits to detect potential issues before deployment to a real LMS.
It flags issues with color-coded badges in the debug panel:
- Red Error Badge: API misuse (e.g., calling
GetValuebeforeInitialize,Terminateerrors) - Yellow Warning Badge: Potential compatibility issues (e.g., suspend data exceeding 4KB/64KB limits)
Check the Errors tab in the debug panel to see detailed logs and remediation steps.
The framework supports multiple LMS formats:
| Format | Description | Limit |
|---|---|---|
cmi5 |
cmi5/xAPI (default) | Unlimited |
scorm2004 |
SCORM 2004 4th Edition | 64KB suspend_data |
scorm1.2 |
SCORM 1.2 (legacy LMS) | 4KB suspend_data (Strict Diet Mode found in scorm-12-driver.js) |
lti |
LTI 1.3 (remote-hosted, JWT launch) | Unlimited (host-dependent) |
Note on SCORM 1.2 Strict Diet Mode: The framework preserves the complete CourseCode state and compresses it into an ASCII-safe representation. If the compressed value still exceeds SCORM 1.2's 4,096-character limit, the write fails loudly instead of silently discarding learner state. Courses that exceed the limit must reduce persisted data or use SCORM 2004, cmi5, or LTI.
Set format in course-config.js (for local CLI builds):
export const courseConfig = {
// format: 'cmi5', // Default is 'cmi5'. Options: 'scorm2004' | 'scorm1.2' | 'lti'
// ... rest of config
};Or override via CLI:
# Environment variable
LMS_FORMAT=scorm1.2 npm run preview
# Build script
npm run build:scorm12
npm run build:ltiCloud courses ignore this setting. When a course is deployed to CourseCode Cloud, the format in
course-config.jsis irrelevant — the cloud uses the universal build to generate ZIPs for any format on demand by re-stamping the meta tag and generating the appropriate manifest. Authors never need to choose a format for cloud-deployed courses.
The framework produces a universal build — a single dist/ output that contains all LMS drivers as lazy-loaded chunks. The active format is determined at runtime, not build time. This enables:
- One build, any format:
dist/works with cmi5, SCORM 2004, SCORM 1.2, or LTI without rebuilding - Cloud-ready: Platforms can serve any format from a single upload by re-stamping a meta tag
- No user code execution: Format-specific ZIPs can be assembled using only framework utilities (no Vite, no course JS)
lms-connection.js resolves the active format via a priority chain:
| Priority | Source | Purpose |
|---|---|---|
| 1 | <meta name="lms-format"> tag in HTML |
Primary — stamped at build time, re-stampable by cloud/CI |
| 2 | import.meta.env.LMS_FORMAT (Vite define) |
Fallback for preview server / dev builds |
| 3 | 'cmi5' |
Default |
The meta tag is injected into dist/index.html during the post-build step. Its value comes from course-config.js (or the LMS_FORMAT env var override). Any platform can re-stamp it with a simple HTML string replacement — no build tools needed.
All drivers use await import() in driver-factory.js. Vite emits each as a separate lazy chunk:
dist/assets/
main.js ← Framework entry (shared across all formats)
scorm-2004-driver.js ← Lazy chunk, only fetched if format = 'scorm2004'
scorm-12-driver.js ← Lazy chunk, only fetched if format = 'scorm1.2'
cmi5-driver.js ← Lazy chunk, only fetched if format = 'cmi5'
lti-driver.js ← Lazy chunk, only fetched if format = 'lti'
proxy-driver.js ← Lazy chunk, only fetched if format = '*-proxy'
CourseCode delivery serves static assets by file extension, not stored upload metadata. Supported course asset types include HTML, JS, CSS, JSON/XML, common images/fonts/audio/video, PDF, csv, vtt, wasm, gltf/glb, and source maps; unknown extensions are served as application/octet-stream.
The browser only downloads the one chunk matching the meta tag. Unused driver chunks sit on disk (~20-30 KB each) and are never requested.
| Command | Output | Contains |
|---|---|---|
coursecode build |
dist/ |
Universal build + format manifest + meta tag stamped |
coursecode build (with PACKAGE=true) |
dist/ + ZIP |
Same + format-specific ZIP for LMS upload |
coursecode export html |
<course-title>.html |
One portable learner file with an embedded standalone driver and course assets; no LMS manifest or server |
coursecode preview --export |
course-preview/ |
Copy of dist/ wrapped in stub player (for Netlify/GitHub Pages) |
coursecode deploy |
Uploads dist/ |
Cloud hosts universal build, assembles format ZIPs on demand. Flags: --promote (force live), --stage (force staged), --preview (preview-only: production untouched, preview always moved), --repair-binding (clear stale local cloud binding first if the remote course was deleted). --promote/--stage are mutually exclusive; --preview stacks with either. GitHub-linked courses: production deploy blocked; only --preview allowed (see GitHub Source Guard below). |
The ZIP never includes preview/stub player assets. Preview is a separate concern (see below).
coursecode export html is intentionally smaller than preview export. It builds the normal learner runtime with LMS_FORMAT=standalone, uses vite-plugin-singlefile to inline generated JavaScript and CSS, embeds copied files from course/assets/ plus the document-gallery manifest as data URLs, and omits LMS schemas, manifests, packaging, and the preview/stub-player shell.
The standalone driver keeps the same semantic state contract as the LMS drivers. It saves bookmark, completion, success, score, and CourseCode suspend state to browser localStorage, keyed by course title and version. Restore is best effort because browsers define storage behavior for file:// documents; when storage is unavailable, the course remains usable and keeps state only for the open session.
Portable HTML is for direct file sharing, offline reference/training, USB delivery, and unmanaged kiosks. It does not provide learner identity, centralized reporting, cross-device sync, tamper-resistant records, or an administrator view. Remote embeds such as YouTube still require network access, and media-heavy courses can produce large HTML files.
lib/build-packaging.js exports stampFormat(html, format) for re-stamping the meta tag:
import { stampFormat } from 'coursecode/build-packaging';
import { generateManifest } from 'coursecode/manifest';
// Re-stamp HTML string for a different format (no filesystem needed)
const stampedHtml = stampFormat(indexHtml, 'scorm2004');
// Generate the format-specific manifest
const { filename, content } = generateManifest('scorm2004', config, files, options);Both are pure Node utilities.
| File | Purpose |
|---|---|
framework/js/state/lms-connection.js |
getLMSFormat() — runtime priority chain (meta → env → default) |
framework/js/drivers/driver-factory.js |
Dynamic import() switch — loads one driver at runtime |
framework/js/drivers/standalone-driver.js |
Best-effort local persistence for portable HTML |
lib/build-packaging.js |
stampFormat() — pure string meta tag re-stamping; stampFormatInHtml() — file-based wrapper |
lib/portable-html.js |
Single-file Vite plugin wrapper, copied-asset embedding, and export validation |
lib/manifest/manifest-factory.js |
generateManifest() — generates format-specific manifests |
Both vite.config.js files |
Post-build closeBundle hook stamps meta tag into dist/index.html |
When using cmi5 format, the framework automatically sends rich xAPI statements:
| Event | xAPI Verb | Data |
|---|---|---|
| Slide navigation | experienced |
Slide ID, time spent |
| Interaction answered | answered |
Response, result, duration |
| Objective updated | completed/passed/failed |
Objective ID, score |
| Assessment submitted | completed |
Score, pass/fail, duration, attempt# |
LMS Launch Data: cmi5 exposes masteryScore and moveOn from LMS launch parameters. If the LMS sets masteryScore, it overrides the course's configured passingScore for assessments.
// Access launch data programmatically (cmi5 only)
const launchData = stateManager.getLaunchData();
// Returns: { moveOn, masteryScore, launchMode, activityId, registration }For hot-fixable courses, the framework supports external hosting: course content lives on a CDN while a minimal proxy package is uploaded to the LMS.
| Format | Description | Use Case |
|---|---|---|
scorm1.2-proxy |
SCORM 1.2 proxy | Legacy LMS, CDN hosting |
scorm2004-proxy |
SCORM 2004 proxy | Modern SCORM LMS, CDN hosting |
cmi5-remote |
cmi5 with absolute AU URL | Modern LMS/LRS, CDN hosting |
Configuration:
// course-config.js
export const courseConfig = {
format: 'scorm1.2-proxy',
externalUrl: 'https://cdn.example.com/my-course', // Required
accessControl: { enforcement: 'server' }
};Generate tokens:
coursecode token # Generate random token
coursecode token --add acme-corp # Store client in .coursecode/access-control.jsonBuild outputs:
dist/— Deploy to CDN*_acme-corp_proxy.zip,*_globex_proxy.zip— One package per client
Access Control:
- Tokens are stored in the gitignored
.coursecode/access-control.json, never in learner-facing course source. - Tokens are injected into client package URLs:
https://cdn.example.com/my-course?clientId=acme-corp&token=.... - The CDN/backend must validate credentials before serving
index.htmlor assets. Browser-side checks are not access control. - To disable a client, remove it from the access file and revoke it at the delivery layer.
Architecture:
- Proxy formats: LMS loads a lightweight proxy (proxy.html + bridge) that iframes the CDN-hosted course. The bridge relays
postMessagecalls to the LMS SCORM API via pipwerks. - cmi5-remote: The cmi5 manifest's AU URL points directly to the CDN. Course communicates with LRS via xAPI (no iframe/bridge).
Key files:
| File | Purpose |
|---|---|
framework/js/drivers/proxy-driver.js |
Course-side postMessage LMSDriver |
.coursecode/access-control.json |
Gitignored build-time client credentials |
lib/proxy-templates/proxy.html |
Proxy package entry point |
lib/proxy-templates/scorm-bridge.js |
postMessage ↔ pipwerks bridge |
lib/token.js |
CLI token generator |
LTI 1.3 requires a trusted server backend. The backend performs OIDC state/nonce, issuer, audience, signature, and deployment validation; holds the tool private key; exchanges OAuth client credentials; persists learner state; and proxies AGS score writes. The browser driver only consumes server-validated launch claims and calls same-origin state/AGS proxy endpoints. Direct browser id_token processing is intentionally rejected.
Preview mode: Proxy/remote suffixes are stripped (e.g., scorm1.2-proxy → scorm1.2) so the stub LMS works normally.
| Role | Testing Tool | Why |
|---|---|---|
| Framework Developers | npm run preview (stub server + example course) |
Test framework changes against template/course/ with full LMS simulation |
| Course Authors | coursecode preview (stub server) |
Fast iteration on content, CSS, gating, interactions |
Framework developers modify code in framework/ that directly interfaces with the LMS via drivers. The preview server with the example course in template/course/ provides a complete testing environment for validating driver behavior, state management, and component functionality.
Course authors only edit course/ files and never touch LMS APIs directly. The framework abstracts all persistence, so the stub server's simple key-value storage is sufficient for testing content, styling, engagement tracking, and assessment logic.
Development & Testing Environment:
- Use
npm run previewfor framework development with stub LMS - CRITICAL: Watch Vite Build Warnings! Errors like "X is not exported by Y" mean code WILL fail at runtime with "(void 0) is not a function". Always check build output for export/import mismatches.
- NEVER call
window.doSetValue/doGetValuedirectly - usestateManagermethods only - ALWAYS use
stateManager- single source of truth for persistence - TIERED ERROR HANDLING - Tier 1 (contract violations) always throw; Tier 2 (runtime/init) use
logger.fatal()→ throws in DEV, degrades in PROD - NEVER use
console.*directly - uselogger.*instead (enforced by build linter) - State-UI-Actions pattern - for complex components
- ES Modules -
import/export,const/let(novar) - Standardized errors - ALWAYS emit
{ domain, operation, message, stack, context }on events ending in:error
| Path | Purpose |
|---|---|
framework/js/app/ |
Global lifecycle, UI (modals, notifications), state |
framework/js/core/ |
Core services: EventBus, runtime |
framework/js/drivers/ |
LMS format drivers: SCORM 2004, SCORM 1.2, cmi5, LTI |
framework/js/vendor/ |
Third-party libs (pipwerks.js) |
framework/js/state/ |
State management facade, LMS connection, xAPI service (see DATA_MODEL.md) |
framework/js/managers/ |
Feature managers: objectives, interactions, engagement, accessibility, etc. |
framework/js/components/ |
Reusable UI (tabs, dropdowns) & interactions (MCQ, drag-drop) |
framework/js/navigation/ |
Navigation: menu, buttons, document gallery |
framework/js/utilities/ |
Helpers (incl. validation-helpers.js for SCORM 2004 4E validation) |
framework/js/dev/ |
Dev-only code (linter, automation API) - tree-shaken in prod |
Separates data, presentation, logic (used in app/, navigation/):
- State (
*-State.js): In-memory data container. No DOM, nostateManageraccess - UI (
*-UI.js): DOM manipulation only. No internal state - Actions (
*-Actions.js): Orchestrates: handles input → updates State → directs UI → calls managers
- Single ViewManager in
main.jscontrols slide navigation - No caching - views render fresh each
showView()to prevent stale data - Slide signature:
render(_root, context). The first argument is reserved and currentlynull; slides create and return their own rootHTMLElement. The second argument carries navigation/render context. - Components with sub-views (assessments) create own ViewManager
- Use
data-action="action-name"attributes - Single listener on container delegates based on attribute
- Works with ViewManager's dynamic rendering
Unified write operations for course data. All edits go through one endpoint:
POST /__write
{ "target": "config", "id": "navigation.sidebar.enabled", "value": true }| Target | What it edits | id format |
|---|---|---|
config |
course-config.js properties |
Dot-notation path |
slide |
Slide config in structure array | slideId |
objective |
Objective in objectives array | objectiveId |
gating |
Gating conditions for slide | slideId |
Process: Import config as object → modify property at path → serialize → write back. No regex.
Converts .pptx to a CourseCode project. Two paths for slide image acquisition:
- Auto-export (macOS): AppleScript drives Microsoft PowerPoint to export each slide as PNG → temp directory → copied to
course/assets/slides/ - Manual (
--slides-dir): User points at pre-exported images (any platform, no PowerPoint needed)
After images are acquired, the module:
- Extracts text via
node-pptx-parser→course/references/converted/ - Scaffolds project via
create.js - Removes template slides, generates
slide-XX.htmlfiles (each an<img class="img-contain">) - Writes
course-config.jswithlayout: 'presentation', no engagement tracking
For complete domain schemas, storage architecture, and data flow, see
DATA_MODEL.md.
| Manager | Location | Purpose |
|---|---|---|
stateManager |
state/ |
Sole public API for all LMS and state operations |
assessmentManager |
managers/ |
Graded assessments: question banks, randomization, scoring |
engagementManager |
managers/ |
Tracks content interaction (tabs, accordions, scroll, time) |
objectiveManager |
managers/ |
Learning objectives (CMI-backed domain) |
interactionManager |
managers/ |
LMS interaction reporting (CMI-backed, append-only) |
accessibilityManager |
managers/ |
A11y preferences persisted via stateManager |
flagManager |
managers/ |
Arbitrary key-value flags in suspend data |
audioManager |
managers/ |
Audio playback: position persistence, completion tracking |
videoManager |
managers/ |
Video playback: position persistence, completion tracking |
scoreManager |
managers/ |
Course-level scoring (dynamically loaded) |
commentManager |
managers/ |
End-of-course comments/ratings |
breakpointManager |
app/ |
Responsive breakpoints, .bp-* classes |
navigationState |
navigation/ |
Current slide, visited slides |
stateManager (from state/index.js) is the single entry point for all LMS communication. It composes:
| Internal Module | Responsibility |
|---|---|
lms-connection.js |
Driver lifecycle, connection init/terminate, keep-alive |
xapi-statement-service.js |
Bridges events to xAPI statements (cmi5 only) |
state-domains.js |
Domain CRUD with append-only semantics |
state-commits.js |
Auto-batched commit scheduling (500ms debounce) |
state-validation.js |
State hydration, migration, validation |
transaction-log.js |
Ring buffer for debugging |
Persistence encoding: SCORM 2004 uses
lz-stringUTF-16 compression. SCORM 1.2 uses an ASCII-safeCC12:encoded representation because legacy LMS implementations may reject UTF-16 suspend data. cmi5 and LTI persist structured JSON through their HTTP state endpoints. These format differences are transparent to framework consumers.
All state access uses the domain pattern:
// Read/write domain state — stateManager routes to appropriate storage
stateManager.getDomainState('objectives'); // → cmi.objectives.* (SCORM) or suspend_data (cmi5/LTI)
stateManager.getDomainState('navigation'); // → suspend_data
stateManager.setDomainState('objectives', data);
stateManager.setDomainState('flags', data);
// Semantic LMS methods — no raw CMI access
stateManager.reportScore({ raw: 85, scaled: 0.85, min: 0, max: 100 });
stateManager.reportCompletion('completed');
stateManager.setBookmark('slide-03');
stateManager.flush(); // Commit now, don't wait for debounceRule: Never bypass
stateManagerfor LMS access. Never importlms-connection.jsdirectly.
LMS writes are auto-batched with a 500ms debounce. Rapid setDomainState calls combine into a single commit. Critical actions (exit, terminate) automatically flush pending writes before proceeding.
eventBus.on('flag:updated', ({ key, value }) => { /* handle */ });
eventBus.on('flag:removed', ({ key }) => { /* handle */ });Handles LMS data mismatches when course structure changes after learners have started:
| Behavior | Dev Mode | Prod Mode |
|---|---|---|
Invalid slide in cmi.location |
Throws error | Reverts to slide 0 |
| Missing assessment questions | Throws error | Filters out missing |
| Orphaned engagement/navigation data | Warns | Silently removes |
| Schema version newer (downgrade) | Throws error | Resets to fresh state |
| Schema version older (upgrade) | Runs migrations | Runs migrations |
Setup: stateManager.setCourseValidationConfig(config) called in main.js before initialize().
Event: state:recovered emitted when prod mode gracefully recovers.
Schema versioning: STATE_SCHEMA_VERSION constant in state-validation.js. Increment when state structure changes incompatibly.
When incrementing STATE_SCHEMA_VERSION, add a migration function in STATE_MIGRATIONS:
const STATE_MIGRATIONS = {
2: (state) => {
// Migrate from v1 → v2: rename 'oldDomain' to 'newDomain'
if (state.oldDomain) {
state.newDomain = state.oldDomain;
delete state.oldDomain;
}
return state;
},
3: (state) => {
// Migrate from v2 → v3: restructure nested data
// ...
return state;
}
};When to add migrations:
- Renaming domains (
navigation→nav) - Restructuring nested data paths
- Changing data types (array → object)
- Adding required fields that code expects
When NOT needed (validation handles):
- Adding optional fields or new domains
- Adding new slide/interaction types
Migrations run sequentially (v1→v2→v3), so each only handles one version jump.
Logger and event communication are integrated: use logger.* for observability. logger.warn(), logger.error(), and logger.fatal() automatically emit structured log:warn or log:error events. Emit an additional domain event only when another framework component needs to react to that specific failure; do not duplicate a log:error event manually.
logger.debug('Initializing slide', { slideId: 'intro' }); // Dev only
logger.info('User action recorded'); // Dev only
logger.warn('Deprecated feature used'); // Dev + Prod
logger.error('Operation failed', error); // Dev + ProdGlobal as logger or window.logger (no import needed). Auto-filtered by environment.
Tiered Error Strategy:
| Tier | When | Behavior | Example |
|---|---|---|---|
| Tier 1 | Contract violations, programming errors | Always throw |
Wrong parameter types, API misuse, double-init |
| Tier 2 | Runtime/init failures (missing DOM, bad config) | logger.fatal() → throws in DEV, logs+degrades in PROD |
Missing container element, invalid data attributes |
| Tier 3 | Recoverable background or unload failures | Structured logger event; retain/retry or emit a domain event when actionable | Debounced commit retry, best-effort unload save |
logger.fatal(message, context) — Tier 2 handler in framework/js/utilities/logger.js:
if (!container) {
logger.fatal('initTabs: container not found', { domain: 'ui', operation: 'initTabs' });
return; // Required — exit the function after calling logger.fatal
}- DEV: throws
Errorwith formatted message for immediate visibility - PROD: calls
logger.warn()for graceful degradation
All error events follow the standardized shape:
{ domain, operation, message, stack?, context? }When to catch depends on who can still act on the failure:
- Critical awaited work (assessment submission, completion, explicit flush/terminate): log or clean up, then re-throw so the caller cannot treat the operation as successful.
- Detached background work (debounced commits): do not create an unhandled rejection. Log with structured context, preserve dirty state, and retry or emit an actionable domain event.
- Unload/pagehide work: the browser is leaving and cannot await a recovery path. Use the driver's best-effort unload transport and log any synchronous or asynchronous failure with structured context; do not re-throw into the abandoned event handler.
- Expected capability probes: a documented unsupported optional LMS field may degrade to a conservative default. Do not suppress failures for required resume, completion, score, or assessment fields.
Critical-path example:
try {
objectiveManager.setCompletionStatus('obj', 'completed');
} catch (error) {
showNotification('Failed to save', 'error');
throw error; // Required - no silent failures
}Three optional utilities for outbound communication. All in framework/js/utilities/, initialized in main.js. Each uses a priority chain for configuration:
1. <meta name="cc-*"> tag in HTML → Injected by CourseCode Cloud (highest priority)
2. environment.* in course-config.js → Author-defined (self-hosted fallback)
3. Skip → Feature disabled (silent)
When cloud meta tags are present, they always win — even if course-config.js also has values. This ensures cloud-served courses always report to the correct endpoints.
| Utility | Config Key | Meta Tag | Transport | Events |
|---|---|---|---|---|
error-reporter.js |
environment.errorReporting |
cc-error-endpoint |
POST per error (60s dedup) | *:error (14 event types) |
data-reporter.js |
environment.dataReporting |
cc-data-endpoint |
Batched POST + sendBeacon on unload |
assessment:submitted, objective:updated, interaction:recorded, course:statusChanged, channel:message |
course-channel.js |
environment.channel |
cc-channel-endpoint + cc-channel-id |
POST to send, SSE to receive | channel:message, channel:connected, channel:disconnected |
Error Reporter — Subscribes to all *:error events, deduplicates by domain+operation+message (60s window), POSTs to endpoint. Optional enableUserReports: true adds "Report Issue" to settings menu. submitUserReport() for programmatic user reports.
Data Reporter — Queues assessment/objective/interaction/session/channel records, flushes on batch size (default 10) or timer (default 30s). sendBeacon fallback on page unload. Also listens to course:statusChanged (queues a session record on completion) and channel:message (queues a channel record). Exposes CourseCode.reportData(type, data) for course authors to send custom event types.
Course Channel — Generic pub/sub pipe. sendChannelMessage(data) POSTs any JSON to endpoint/channelId. SSE listener on same URL bridges incoming messages to EventBus. Exponential backoff reconnect (1s → 30s cap). Content-agnostic — the relay is a dumb fan-out router.
// Self-hosted config (all optional)
environment: {
errorReporting: { endpoint: '...', apiKey: '...', includeContext: true, enableUserReports: true },
dataReporting: { endpoint: '...', apiKey: '...', batchSize: 10, flushInterval: 30000 },
channel: { endpoint: '...', apiKey: '...', channelId: 'session-123' }
}Cloud meta tags (injected into <head> by CourseCode Cloud):
<meta name="cc-error-endpoint" content="https://engine.example.com/errors">
<meta name="cc-data-endpoint" content="https://engine.example.com/data">
<meta name="cc-channel-endpoint" content="https://engine.example.com/channel">
<meta name="cc-channel-id" content="session-abc123">
<meta name="cc-api-key" content="sk_live_abc123">
<meta name="cc-license-id" content="lic_xyz">
<meta name="cc-course-id" content="course_456">Authentication — All reporters support an optional apiKey field (from config or cc-api-key meta tag). When set, it's sent as Authorization: Bearer <apiKey> on fetch() calls. For sendBeacon (page unload), fetch() with keepalive: true is used instead since sendBeacon doesn't support custom headers. For SSE (EventSource), the token is passed as a ?token= URL parameter.
Cloud attribution — When cc-license-id and cc-course-id meta tags are present (LTI/cmi5 launches), error and data reporters include licenseId and courseId in all payloads for engine routing.
Example backends: framework/docs/examples/cloudflare-{error-worker,data-worker,channel-relay}.js
Local dev: Error and data reporters are automatically disabled during watch builds (
coursecode preview,coursecode dev,npm run dev). The CLI setsVITE_COURSECODE_LOCAL=truein the Vite build env, which the reporters check at init. Production builds (coursecode build) do not set this flag.
This codebase serves two roles:
- Framework source repo — where the framework itself is developed (
vite.framework-dev.config.js,template/course/as test content) - Course project template —
template/is what course authors get when they create a new project (template/vite.config.js, their owncourse/)
| File | Used by | Purpose |
|---|---|---|
vite.framework-dev.config.js |
Framework developers (this repo) | Builds from template/course/, references lib/ directly |
template/vite.config.js |
Course authors (their project) | Builds from course/, imports from coursecode package |
Preview is not part of the build output. It is platform infrastructure, served separately from dist/:
| Scenario | Who provides preview? | Where preview lives |
|---|---|---|
coursecode preview (local) |
preview-server.js |
In-memory, never written to disk |
coursecode preview --export |
preview-export.js |
Separate course-preview/ directory (copies dist/ + wraps in stub player) |
| CourseCode Cloud | Cloud platform | Cloud hosts its own stub player, wraps any uploaded dist/ |
| Self-hosted CDN (proxy) | N/A | User tests locally with coursecode preview, CDN serves dist/ only |
Key principle: dist/ never contains preview/stub player assets. The --export flag produces a separate course-preview/ folder for static hosting (Netlify, GitHub Pages). The stub player is generic — it wraps any course's index.html in an iframe with fake LMS APIs.
The universal build enables cloud platforms to assemble format-specific outputs without running any framework build tools or user-uploaded code:
User: coursecode build → uploads dist/
│
┌────────────────────┼─────────────────────┐
▼ ▼ ▼
Cloud Preview Cloud ZIP (SCORM 2004) Cloud ZIP (SCORM 1.2)
┌────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Cloud's own │ │ Copy dist/ │ │ Copy dist/ │
│ stub player │ │ stampFormat │ │ stampFormat │
│ iframes the │ │ generateManifest │ │ generateManifest │
│ uploaded │ │ → ZIP │ │ → ZIP │
│ dist/ │ └──────────────────┘ └──────────────────┘
└────────────┘
Cloud dependencies: The cloud app imports stampFormat and generateManifest directly from the coursecode npm package. These are pure functions — no filesystem, no Vite, no dynamic imports of user code, no eval. All inputs (title, version, file list) come from scanning the uploaded dist/ or the cloud's own database.
Security boundary: The cloud never executes course-config.js or any user-authored JavaScript. The meta tag and manifest are the only format-specific artifacts, and both are generated from trusted framework source code.
Courses linked to GitHub for production deploys are protected from accidental CLI overwrites via two layers:
| Layer | Mechanism | Key |
|---|---|---|
| CLI (local) | deploy() reads sourceType from .coursecoderc.json |
Fast fail before build — no wasted time |
| Server (safety net) | Finalize endpoint rejects non-preview CLI uploads when github_repo is set |
Can't bypass via old CLI or curl |
--previewalways allowed — useful for testing without touching production pointer.coursecoderc.jsoncarriessourceTypeandgithubRepo(committed to repo by cloud's GitHub integration), so anyone cloning gets the guard automatically- Self-healing link state: Both
status()anddeploy()treat the server'sgithub_repoas authoritative. They add or refresh localsourceType/githubRepometadata when linked and clear it when unlinked. No manual cleanup or repo commit is needed. - CLI error code:
github_source_blocked(structured JSON for Desktop/CI consumers)
Two linters validate courses at different stages, each in a different environment:
Runs in the browser during preview when import.meta.env.DEV === true. Called from main.js before course initialization.
What it checks (needs a real DOM):
- Renders each slide to detached DOM, validates engagement requirements match content
- Visual layout: contrast ratios, touch target sizes, nested cards, heading hierarchy
- Component structure validation against schemas
- Assessment config validation
- Audio configuration conflicts (slide audio vs modal audio)
On failure: Halts initialization with formatted error showing slide ID and fix needed.
Production: Tree-shaken out — never included in production builds.
Runs in Node.js during build (via vite.framework-dev.config.js closeBundle hook) and via MCP/CLI.
What it checks (no DOM needed):
- Course config validation (structure, objectives, gating conditions)
- CSS class validation via PostCSS — flags hallucinated class names
- Schema-driven requirement validation from source templates
- Duplicate interaction ID detection
Invoked by:
npm run build— automatically incloseBundlehookcoursecode lintCLI command- MCP
coursecode_linttool
Errors fail the build; warnings print but don't block.
The MCP coursecode_lint tool runs the static/build-time linter (config validation, CSS class verification, structure checks). It does not inspect the running preview and does not include live runtime, browser console, or Vite build-watch diagnostics.
Use:
coursecode_lintfor static preflight validation after source/config editscoursecode_errorsfor the live "what is broken right now?" preview rollupcoursecode_statewhen you also need current slide, TOC, engagement, LMS state, and diagnostics
Pure validation functions used by both linters. No environment-specific code (no DOM, no fs). Includes assessment validation, engagement validation, and result formatting.
All slides MUST have engagement config in course-config.js.
| Type | Required Props | Validates Against |
|---|---|---|
viewAllTabs |
- | [data-component="tabs"] |
viewAllPanels |
- | [data-component="accordion"] |
viewAllFlipCards |
- | [data-flip-card-id] |
viewAllTimelineEvents |
- | [data-component="interactive-timeline"] |
viewAllHotspots |
- | [data-component="interactive-image"] |
viewAllModals |
- | [data-component="modal-trigger"] with data-modal-id |
interactionComplete |
interactionId, optional label |
[data-interaction-id] |
allInteractionsComplete |
- | Any [data-interaction-id] |
scrollDepth |
percentage |
Scroll event tracking |
timeOnSlide |
minSeconds |
Session timer |
slideAudioComplete |
- | Slide audio config |
audioComplete |
audioId |
[data-audio-id] |
modalAudioComplete |
modalId |
Modal audio config |
flag |
key, optional equals |
flagManager |
allFlags |
flags array |
flagManager |
message(engagement-level): Full override of tooltip textlabel(requirement-level): Custom name in "Complete: X" format (auto-formats from ID if omitted)
When engagement.required is true, the circular progress indicator displays in the nav footer by default. Set showIndicator: false to hide it.
Custom indicators use EngagementManager API:
const progress = engagementManager.getProgress(slideId);
// Returns: { percentage: 75, items: [{type, label, complete}, ...] }Components auto-initialize via data-component attributes. Registration in framework/js/components/.
| Component | Attribute | Events Emitted |
|---|---|---|
| Tabs | data-component="tabs" |
tab:selected |
| Accordion | data-component="accordion" |
accordion:panel-opened |
| Carousel | data-component="carousel" |
carousel:slide-changed |
| Collapse | data-component="collapse" |
collapse:toggle |
| Dropdown | data-component="dropdown" |
dropdown:change |
| Modal | data-component="modal-trigger" |
modal:opened, modal:closed |
| Flip Card | data-component="flip-card" |
flipcard:flipped |
| Interactive Timeline | data-component="interactive-timeline" |
timeline:event-viewed |
| Toggle Group | data-component="toggle-group" |
toggle:change |
| Checkbox Group | data-component="checkbox-group" |
checkbox-group:change |
| Audio Player | data-component="audio-player" |
audio:complete |
| Video Player | data-component="video-player" |
video:complete |
Components and layout patterns are managed via component-catalog.js, which mirrors the interaction catalog pattern:
// Auto-discovery via import.meta.glob
// framework/js/components/ui-components/*.js → All built-in components (tabs, accordion, carousel, modal, hero, steps, timeline, etc.)
// course/components/*.js → Custom course componentsSchema Pattern - Every component exports a schema for validation:
// Example: framework/js/components/ui-components/tabs.js
export const schema = {
type: 'tabs',
description: 'Accessible tab interface with keyboard navigation',
properties: {
activeClass: { type: 'string', default: 'active' }
},
structure: {
container: '[data-component="tabs"]',
children: {
button: { selector: '[data-action="select-tab"]', required: true, minItems: 1 },
panel: { selector: '.tab-content', required: true }
}
}
};
export const metadata = {
category: 'ui-component',
engagementTracking: 'viewAllTabs',
emitsEvents: ['tab:selected']
};
export function initTabs(container) { /* ... */ }Linter Validation - The runtime linter validates component structure against schemas during preview.
Access: window.CourseCodeAutomation (dev mode only, tree-shaken in prod)
Enable in course/course-config.js:
environment: {
automation: { enabled: true, disableBeforeUnloadGuard: true, exposeCorrectAnswers: true }
}Requirements: import.meta.env.MODE !== 'production' AND automation.enabled === true
| Category | Method | Returns/Purpose |
|---|---|---|
| Discovery | listInteractions() |
[{id, type, registeredAt}, ...] |
getInteractionMetadata(id) |
{id, type, registeredAt} |
|
| State Access | getResponse(id) |
Current response (format varies by type) |
getCorrectResponse(id) |
Correct answer (needs exposeCorrectAnswers) |
|
| State Mutation | setResponse(id, response) |
Sets response |
| Evaluation | checkAnswer(id) |
{correct, score, feedback, ...} |
checkSlideAnswers(slideId?) |
[{interactionId, type, evaluation}, ...] |
|
| Navigation | getCourseStructure() |
Structure array from config |
getCurrentSlide() |
Current slide ID or null | |
goToSlide(slideId, context?) |
Navigate with optional context | |
| Engagement | getEngagementState() |
{complete, tracked, requirements} |
getEngagementProgress() |
{percentage, items: [{label, complete, type}]} |
|
markTabViewed(tabId) |
Manually track tab (testing) | |
setScrollDepth(percentage) |
Simulate scroll (testing) | |
resetEngagement() |
Reset tracking for current slide | |
| Flags | getFlag(key) |
Get flag value |
setFlag(key, value) |
Set flag (triggers engagement re-eval) | |
getAllFlags() |
All flags as object | |
removeFlag(key) |
Remove flag | |
| Audio | getAudioState() |
{currentSrc, position, isPlaying, isMuted, duration} |
hasAudio() |
Check if audio loaded | |
playAudio() / pauseAudio() / toggleAudio() |
Playback control | |
restartAudio() |
Restart from beginning | |
seekAudio(seconds) / seekAudioToPercentage(pct) |
Seek | |
toggleAudioMute() / setAudioMuted(bool) |
Mute control | |
getAudioProgress() |
Playback percentage | |
| Observability | getAutomationTrace() |
[{timestamp, action, ...}, ...] |
clearAutomationTrace() |
Clears trace log | |
getVersion() |
{api, phase, features} |
| Type | Format |
|---|---|
| Multiple Choice | 'a', 'b', 'c' |
| True/False | true / false |
| Fill-in-Blank | {blankId: 'answer'} |
| Drag-Drop | {itemId: zoneId} |
| Numeric | 1.5 |
| Sequencing | ['id1', 'id2', 'id3'] |
| Likert | {questionId: 'value'} |
Nav: nav-prev, nav-next, nav-exit, nav-menu-toggle, nav-menu-item-{slideId}, nav-section-{sectionId}
Interactions: {id}-check-answer, {id}-reset, {id}-controls, {id}-feedback, {id}-choice-{index}, {id}-blank-{index}, {id}-input, {id}-drag-item-{itemId}, {id}-drop-zone-{zoneId}
Assessments: assessment-start, assessment-nav-{prev|next}, assessment-submit, assessment-retake, assessment-review-question-{index}
The MCP server runs a persistent headless Chrome internally via puppeteer-core. All runtime tools execute directly in this headless browser — agents never need to open a browser.
The MCP does not start or manage the preview server. Runtime tools connect to an already-running preview server.
- If preview is already running for the current project, use it. Do not start a second preview server.
- If preview is not running, start it in a terminal with
coursecode preview. - For framework development from this repo, use
npm run preview. - AI agents may start preview only via their terminal/command execution tool, and only after confirming preview is not already running or after a runtime MCP tool reports that preview is not running.
If the preview is not running, runtime tools fail fast with a clear error message.
- Make sure the preview server is running externally (see above)
- Add to IDE MCP config:
{ "mcpServers": { "coursecode": { "command": "coursecode", "args": ["mcp"] } } }- On the first runtime tool call, the MCP launches headless Chrome and connects to the already-running preview server
- The browser auto-reconnects when Vite rebuilds (file changes trigger SSE reload)
- All tool calls execute instantly via
page.evaluate()— no manual waits needed
Tool results include machine-readable structuredContent plus text content for compatibility. Tool failures use structured error payloads with stable code, message, hint, and optional details fields so AI clients can recover without parsing prose.
| Tool | Purpose | Returns |
|---|---|---|
coursecode_state |
Full course snapshot + live diagnostics | {slide, toc, interactions, engagement, lmsState, apiLog, diagnostics, issues, errors, frameworkLogs, consoleLogs} |
coursecode_errors |
Live diagnostic rollup only | {build, runtime, framework, console, issues, errors, count, clean} — same diagnostic sources as coursecode_state, without the state payload |
coursecode_navigate |
Go to slide by ID | {slide, interactions, engagement, accessibility} |
coursecode_interact |
Set response + evaluate | {interactionId, response} → {correct, score, feedback} |
coursecode_screenshot |
Visual capture (JPEG) | Optional slideId to navigate first, fullPage for scroll capture |
coursecode_viewport |
Set viewport size | Breakpoint name or {width, height} → persists until changed |
coursecode_reset |
Clear learner state | No input; clears local state and reloads |
Two quality modes optimize for token efficiency — neither changes the viewport:
| Mode | Quality | Typical Size | Use For |
|---|---|---|---|
| normal (default) | JPEG@50 | ~20-40KB | Layout checks |
| detailed | JPEG@90 | ~100-200KB | Close text/element inspection |
Use coursecode_viewport for responsive design testing. Two input modes:
- Breakpoint name:
"mobile-portrait","tablet-landscape", etc. — resolved dynamically from the running course'sbreakpointManager.getBreakpoints(), so always in sync with CSS. - Explicit dimensions:
{width: 375, height: 812}for specific device sizes.
The viewport persists until explicitly changed again. Default is 1280×720.
AI tip: For realistic mobile QA, use explicit phone dimensions (for example
{width: 375, height: 812}) in addition to named breakpoints.
Use MCP tools for all course interaction — never use external browser tools:
coursecode_state→ get all slide IDs, current position, interactionscoursecode_navigate(slideId)→ instant slide navigationcoursecode_viewport(breakpoint)→ set viewport for responsive testingcoursecode_screenshot(slideId)→ navigate + capture in one callcoursecode_interact(id, response)→ answer + evaluate in one call
MCP Server (IDE) ──puppeteer──▶ Headless Chrome ──HTTP──▶ Preview Server
│
└── Course iframe (CourseCodeAutomation API)
- Preview not running? → Tools return a clear error. Start preview externally in a terminal, then retry.
- Chrome not found? → Install Google Chrome or set
CHROME_PATHenv var
Before merging responsive/layout changes:
npm run prerelease:check
npm run smoke:responsive -- --profile=expandedlint:responsiveguardsresponsive.cssownership (no shell/chrome selectors)lint:responsive:structureenforces layout exclusions/scoping for high-risk shell selectors
Singleton at framework/js/managers/audio-manager.js. Manages single audio element.
- Position Persistence: Saves position when leaving slide, restores on return
- Completion Tracking: Tracks max position reached (handles seeks/replays)
- Single Instance: Only one audio plays at a time (slide vs modal vs standalone)
- Mute Preference: Mute state persists across session
- Auto-Pause: Audio pauses on navigation away or modal close
const { audioManager } = CourseCode;
audioManager.play();
audioManager.pause();
audioManager.togglePlayPause();
audioManager.restart();
audioManager.seek(30); // seconds
audioManager.seekToPercentage(50);
audioManager.toggleMute();
audioManager.getState(); // Full state object
audioManager.hasAudio();- Slide audio: Full controls (play/pause, restart, progress bar, mute, time)
- Modal audio: Compact controls (play/pause, restart, mute only)
- Standalone: Full controls inline
framework/js/managers/assessment-manager.js
- Question Banks: Select N questions from categorized banks
- Randomization: Shuffle questions and/or re-randomize on retake
- Progressive Intervention: Show remedial content after N failures, restart course after M
- Auto-Linked Objectives: Updates objective on submission based on score
- Unanswered Handling: Confirmation modal or immediate submit (configurable)
const { AssessmentManager } = CourseCode;
const assessment = AssessmentManager.createAssessment(
{ ...config, questions }, // OR questionBanks for random selection
overrides
);
assessment.render(container);eventBus.on('assessment:submitted', ({ id, score, passed }) => { });
eventBus.on('assessment:retake', ({ id, attemptNumber }) => { });| File | Purpose |
|---|---|
design-tokens.css |
CSS variables (colors, spacing, typography) |
01-base.css |
HTML resets, base typography |
02-layout.css |
Content width, stacks, columns, splits |
components/*.css |
Individual UI component styles (cards, hero, tabs, steps, timeline, etc.) |
interactions/*.css |
Interaction-specific styles |
utilities/*.css |
Utility classes (spacing, display, flex) |
responsive.css |
Shared content/component responsive rules (non-shell) |
responsive-structure.css |
App shell/header/footer/nav/audio responsive rules |
framework.css |
Main import file, orchestrates all modules |
CSS-only layouts controlled via data-layout attribute on <html>. Files in framework/css/layouts/:
| File | Purpose |
|---|---|
base.css |
Layout tokens, default structure |
traditional.css |
Full header, sidebar toggle |
article.css |
Default: Minimal header, centered content, floating pill nav |
focused.css |
Hidden header, centered content, floating pill nav |
presentation.css |
Full viewport, edge arrow navigation |
canvas.css |
Zero framework CSS — all styles reverted to browser defaults, author BYOs via theme.css |
Note: Canvas only strips CSS (
all: reverton#slide-container). All JS infrastructure — navigation, gating, interactions, engagement tracking, and LMS drivers — remains fully functional.
Set via layout in course-config.js. The main.js automatically applies the attribute from config.
- Put shared content/component responsive rules in
framework/css/responsive.css. - Put shell/chrome responsive rules (
#app, header/brand, footer/nav/audio) inframework/css/responsive-structure.css. - For generic shell selectors, exclude layout-owned behavior (especially
articleandfocused) unless explicitly layout-scoped.
Slides are automatically wrapped with content width class (default: .content-medium).
Override per-slide with data-content-width attribute or globally via slideDefaults.contentWidth in course-config.js.
framework/js/navigation/document-gallery.js — Collapsible sidebar gallery for reference documents.
Build pipeline: vite-plugin-content-discovery.js scans course/assets/docs/ and generates _gallery-manifest.json at build time. In dev mode, preview-server.js generates the manifest dynamically.
Key files:
| File | Purpose |
|---|---|
framework/js/navigation/document-gallery.js |
Fetches manifest, renders thumbnails, expand/collapse logic |
framework/css/components/document-gallery.css |
2-column grid, thumbnail variants |
lib/vite-plugin-content-discovery.js |
generateGalleryManifest() — build-time discovery |
Behavior: Expanding gallery collapses nav menu (inverse toggle). Gallery resets to collapsed on sidebar transitionend close. Configured via navigation.documentGallery in course-config.js.
framework/js/utilities/icons.js provides a centralized icon registry. Icons are rendered as SVG strings with no inline width/height - sizing is controlled via CSS classes.
const { iconManager } = CourseCode;
// Basic usage
iconManager.getIcon('menu'); // Returns SVG string
// With options
iconManager.getIcon('check', {
size: 'lg', // 'xs'|'sm'|'md'|'lg'|'xl'|'2xl'|'3xl' or px: 12|16|20|24|32|48|64
class: 'icon-success', // Additional CSS classes
strokeWidth: 2, // SVG stroke width (default: 2)
color: 'currentColor' // Stroke color (default: currentColor)
});
// Register custom icons
iconManager.register('custom-icon', '<path d="..." />');
iconManager.registerAll({ icon1: '...', icon2: '...' });Course authors add custom icons in course/icons.js:
export const customIcons = {
'rocket': '<path d="..." />'
};These are automatically registered and available via iconManager.getIcon('rocket').
framework/js/utilities/breakpoint-manager.js - Dynamically applies responsive CSS classes to <html>.
| Class | Condition | Width |
|---|---|---|
.bp-min-large-desktop |
≥1440px | Large screens |
.bp-max-desktop |
≤1439px | Desktop and below |
.bp-max-tablet-landscape |
≤1199px | Tablet landscape |
.bp-max-tablet-portrait |
≤1023px | Tablet portrait |
.bp-max-mobile-landscape |
≤767px | Mobile landscape |
.bp-max-mobile-portrait |
≤479px | Mobile portrait |
Multiple classes cascade (e.g., at 600px: .bp-max-desktop, .bp-max-tablet-landscape, .bp-max-tablet-portrait, .bp-max-mobile-landscape all applied).
import { breakpointManager } from './utilities/breakpoint-manager.js';
breakpointManager.getCurrentBreakpoint(); // 'tablet-portrait'
breakpointManager.isMobile(); // true if ≤767px
breakpointManager.isTablet(); // true if 768-1199px
breakpointManager.isDesktop(); // true if ≥1200px
breakpointManager.isAtMost('tablet-portrait'); // true if ≤1023px
breakpointManager.onChange((newBp, oldBp) => { /* handle */ });
breakpointManager.refresh(); // Force re-evaluationExposed globally via CourseCode.breakpointManager.
Course Authors: See "Extending with Plugins" in
framework/docs/USER_GUIDE.md. Steps below are for framework developers.
- Create file in
framework/js/components/interactions/ - Export
create(container, config),metadata, andschema - Register with
catalogInteraction()incore/interaction-catalog.js(auto-discovered viaimport.meta.glob) - Add CSS to
framework/css/interactions/ - Update automation API if needed for testing support
- Add to
COURSE_AUTHORING_GUIDE.mdif has author-facing classes
export function create(container, config) {
// config must have: id, (type-specific props)
// Must set data-interaction-id on root element
// Must emit events for state changes
// Must support getResponse() / setResponse() for automation
return {
getResponse: () => currentResponse,
setResponse: (val) => { /* update UI and state */ },
checkAnswer: () => ({ correct, score, feedback }),
reset: () => { /* clear to initial state */ }
};
}