coursecode 0.1.2 → 0.1.6

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.
package/README.md CHANGED
@@ -1,14 +1,23 @@
1
1
  # CourseCode
2
2
 
3
- **A modern framework for building interactive e-learning courses with AI-assisted authoring.**
3
+ **AI-first course authoring: create interactive e-learning without writing code, then deploy to any LMS format.**
4
4
 
5
5
  Drop in your existing PDFs, Word docs, or PowerPoints — AI converts them into complete, interactive courses. No vendor lock-in, no subscriptions. Your content, your code, deployed to any LMS.
6
6
 
7
+ ## Start Here
8
+
9
+ If you're creating courses (non-technical or technical), use the **[User Guide](framework/docs/USER_GUIDE.md)** as your primary documentation.
10
+
11
+ - **Course authors (non-technical):** Start with the User Guide for step-by-step workflows
12
+ - **Course authors (technical):** The User Guide is still the best starting point, then use advanced docs as needed
13
+ - **AI assistants:** Use the AI-focused docs in `framework/docs/` (for example `COURSE_AUTHORING_GUIDE.md` and `FRAMEWORK_GUIDE.md`)
14
+
7
15
  ## Features
8
16
 
9
17
  - **MCP integration**: AI connects directly to your course — previews, screenshots, linting, and testing without manual file sharing
18
+ - **No code writing required**: Describe what you want and let AI build slides, interactions, and structure
10
19
  - **Full LMS integration**: SCORM 1.2, SCORM 2004, cmi5, and LTI with complete tracking records
11
- - **AI-ready authoring**: Structured guides and MCP tools for AI-assisted course development
20
+ - **AI-assisted authoring workflow**: Structured guides and MCP tools for faster course development
12
21
  - **Rich UI components**: Images, video, accordions, tabs, and custom sandboxed HTML/JS embeds
13
22
  - **Rich interactions**: Multiple choice, drag-drop, fill-in-the-blank, matching, sequencing, and more
14
23
  - **Fully accessible**: WCAG 2.1 AA compliant with dark mode, high contrast, and reduced motion
@@ -16,10 +25,9 @@ Drop in your existing PDFs, Word docs, or PowerPoints — AI converts them into
16
25
  - **Smart tracking**: Engagement requirements, learning objectives, and progress persistence
17
26
  - **Themeable design**: CSS custom properties for easy brand customization
18
27
  - **Custom endpoints**: Optional webhooks for error reporting and learning record storage
19
- - **Drop-in content conversion**: Feed existing PDFs, Word docs, or PowerPoint files to AI and get a complete, interactive course
20
28
  - **Live preview**: Visual editing, status dashboard, config panels, catalog browser, and full LMS simulation with debug tools
21
- - **[CourseCode Cloud](https://www.coursecodecloud.com)** *(coming soon)*: Deploy from CLI, share preview links, download any LMS format on demand — no rebuilds
22
- - **CourseCode Desktop** *(coming soon)*: Native app for Mac and Windows with AI-assisted editing, built-in preview, and no code required
29
+ - **[CourseCode Cloud](https://www.coursecodecloud.com)**: Deploy from CLI, share preview links, download any LMS format on demand — no rebuilds
30
+ - **CourseCode Desktop**: Native app for Mac and Windows with AI-assisted editing and built-in preview
23
31
 
24
32
  ---
25
33
 
@@ -57,7 +65,7 @@ Open `http://localhost:4173` to view and edit your course.
57
65
 
58
66
  The example course included with every new project is a complete guide to using CourseCode.
59
67
 
60
- **New to CourseCode?** Read the [User Guide](framework/docs/USER_GUIDE.md) for step-by-step instructions.
68
+ **New to CourseCode?** Start with the [User Guide](framework/docs/USER_GUIDE.md). It is the primary guide for both non-technical and technical course authors.
61
69
 
62
70
  ---
63
71
 
@@ -76,7 +84,7 @@ With MCP, your AI can:
76
84
  - **Browse the component catalog** to discover available UI elements
77
85
  - **Read course state** including engagement, scoring, and LMS data
78
86
 
79
- See the [User Guide](framework/docs/USER_GUIDE.md#mcp-model-context-protocol) for setup instructions.
87
+ See the [User Guide](framework/docs/USER_GUIDE.md#connecting-ai-with-mcp) for setup instructions.
80
88
 
81
89
  ---
82
90
 
@@ -171,7 +179,7 @@ The preview server provides:
171
179
 
172
180
  When ready, deploy:
173
181
 
174
- **With [CourseCode Cloud](https://www.coursecodecloud.com)** *(coming soon)*: Push your course and get a live link. Cloud handles hosting, generates any LMS format on demand, and gives you sharable preview links with optional password protection. No ZIP files, no manual uploads.
182
+ **With [CourseCode Cloud](https://www.coursecodecloud.com)**: Push your course and get a live link. Cloud handles hosting, generates any LMS format on demand, and gives you sharable preview links with optional password protection. No ZIP files, no manual uploads.
175
183
 
176
184
  ```bash
177
185
  coursecode deploy
@@ -191,39 +199,20 @@ coursecode preview --export
191
199
 
192
200
  ---
193
201
 
194
- ## CLI Commands
202
+ ## Core Commands
195
203
 
196
204
  | Command | Description |
197
205
  |---------|-------------|
198
206
  | `coursecode create <name>` | Create a new course project |
199
207
  | `coursecode preview` | Preview your course locally |
200
- | `coursecode build` | Build course package (ZIP for LMS upload) |
201
- | `coursecode build --format scorm1.2` | Build for specific LMS format |
202
- | `coursecode mcp` | Start the MCP server for AI integration |
203
- | `coursecode lint` | Validate course configuration and structure |
204
- | `coursecode narration` | Generate audio narration from text |
205
208
  | `coursecode convert` | Convert PDFs, Word, PowerPoint to markdown |
206
- | `coursecode upgrade` | Upgrade to latest version |
207
-
208
- ### Cloud Commands
209
-
210
- | Command | Description |
211
- |---------|-------------|
212
- | `coursecode login` | Log in to CourseCode Cloud |
213
- | `coursecode logout` | Log out of CourseCode Cloud |
214
- | `coursecode whoami` | Show current Cloud user and organizations |
215
- | `coursecode courses` | List courses on CourseCode Cloud |
209
+ | `coursecode mcp` | Start the MCP server for AI integration |
210
+ | `coursecode lint` | Validate course structure and content |
211
+ | `coursecode build` | Build a package for LMS upload |
216
212
  | `coursecode deploy` | Build and deploy to CourseCode Cloud |
217
- | `coursecode status` | Show deployment status for current course |
218
-
219
- ### Output Format Options
213
+ | `coursecode narration` | Generate audio narration from text |
220
214
 
221
- ```bash
222
- coursecode build --format cmi5 # cmi5/xAPI (default, recommended)
223
- coursecode build --format scorm2004 # SCORM 2004 4th Edition
224
- coursecode build --format scorm1.2 # SCORM 1.2 (widest LMS support)
225
- coursecode build --format lti # LTI 1.3
226
- ```
215
+ For the full command list and deployment options, see the [User Guide](framework/docs/USER_GUIDE.md#sharing-and-deploying) or run `coursecode --help`.
227
216
 
228
217
  ---
229
218
 
package/bin/cli.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * coursecode new <type> - Create new slide, assessment, or config
13
13
  * coursecode test-errors - Test error reporting configuration
14
14
  * coursecode test-data - Test data reporting configuration
15
- * coursecode --version - Show version
15
+ * coursecode version - Show version
16
16
  */
17
17
 
18
18
  import { Command } from 'commander';
@@ -30,12 +30,21 @@ program
30
30
  .description('Multi-format course authoring framework CLI')
31
31
  .version(packageJson.version);
32
32
 
33
+ // Version command (explicit subcommand)
34
+ program
35
+ .command('version')
36
+ .description('Show CLI version')
37
+ .action(() => {
38
+ console.log(packageJson.version);
39
+ });
40
+
33
41
  // Create command
34
42
  program
35
43
  .command('create <name>')
36
44
  .description('Create a new course project')
37
45
  .option('--blank', 'Create without example slides (clean starter)')
38
46
  .option('--no-install', 'Skip npm install')
47
+ .option('--start', 'Auto-start dev server after creation (skip prompt)')
39
48
  .action(async (name, options) => {
40
49
  const { create } = await import('../lib/create.js');
41
50
  await create(name, options);
@@ -280,8 +289,10 @@ program
280
289
  program
281
290
  .command('logout')
282
291
  .description('Log out of CourseCode Cloud')
283
- .action(async () => {
284
- const { logout } = await import('../lib/cloud.js');
292
+ .option('--local', 'Use local Cloud instance (http://localhost:3000)')
293
+ .action(async (options) => {
294
+ const { logout, setLocalMode } = await import('../lib/cloud.js');
295
+ if (options.local) setLocalMode();
285
296
  await logout();
286
297
  });
287
298
 
@@ -513,13 +513,21 @@ try {
513
513
 
514
514
  ### External Communications
515
515
 
516
- Three optional, config-driven utilities for outbound communication. All in `framework/js/utilities/`, initialized in `main.js`. Each activates when its `endpoint` is configured — otherwise silently skips.
516
+ Three optional utilities for outbound communication. All in `framework/js/utilities/`, initialized in `main.js`. Each uses a **priority chain** for configuration:
517
517
 
518
- | Utility | Config Key | Transport | Events |
519
- |---------|-----------|-----------|--------|
520
- | `error-reporter.js` | `environment.errorReporting` | POST per error (60s dedup) | `*:error` (14 event types) |
521
- | `data-reporter.js` | `environment.dataReporting` | Batched POST + `sendBeacon` on unload | `assessment:submitted`, `objective:updated`, `interaction:recorded` |
522
- | `course-channel.js` | `environment.channel` | POST to send, SSE to receive | `channel:message`, `channel:connected`, `channel:disconnected` |
518
+ ```
519
+ 1. <meta name="cc-*"> tag in HTML → Injected by CourseCode Cloud (highest priority)
520
+ 2. environment.* in course-config.js → Author-defined (self-hosted fallback)
521
+ 3. Skip → Feature disabled (silent)
522
+ ```
523
+
524
+ 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.
525
+
526
+ | Utility | Config Key | Meta Tag | Transport | Events |
527
+ |---------|-----------|----------|-----------|--------|
528
+ | `error-reporter.js` | `environment.errorReporting` | `cc-error-endpoint` | POST per error (60s dedup) | `*:error` (14 event types) |
529
+ | `data-reporter.js` | `environment.dataReporting` | `cc-data-endpoint` | Batched POST + `sendBeacon` on unload | `assessment:submitted`, `objective:updated`, `interaction:recorded` |
530
+ | `course-channel.js` | `environment.channel` | `cc-channel-endpoint` + `cc-channel-id` | POST to send, SSE to receive | `channel:message`, `channel:connected`, `channel:disconnected` |
523
531
 
524
532
  **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.
525
533
 
@@ -528,7 +536,7 @@ Three optional, config-driven utilities for outbound communication. All in `fram
528
536
  **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.
529
537
 
530
538
  ```javascript
531
- // Config (all optional)
539
+ // Self-hosted config (all optional)
532
540
  environment: {
533
541
  errorReporting: { endpoint: '...', apiKey: '...', includeContext: true, enableUserReports: true },
534
542
  dataReporting: { endpoint: '...', apiKey: '...', batchSize: 10, flushInterval: 30000 },
@@ -536,7 +544,20 @@ environment: {
536
544
  }
537
545
  ```
538
546
 
539
- **Authentication** All reporters support an optional `apiKey` field. 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.
547
+ **Cloud meta tags** (injected into `<head>` by CourseCode Cloud):
548
+ ```html
549
+ <meta name="cc-error-endpoint" content="https://engine.example.com/errors">
550
+ <meta name="cc-data-endpoint" content="https://engine.example.com/data">
551
+ <meta name="cc-channel-endpoint" content="https://engine.example.com/channel">
552
+ <meta name="cc-channel-id" content="session-abc123">
553
+ <meta name="cc-api-key" content="sk_live_abc123">
554
+ <meta name="cc-license-id" content="lic_xyz">
555
+ <meta name="cc-course-id" content="course_456">
556
+ ```
557
+
558
+ **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.
559
+
560
+ **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.
540
561
 
541
562
  **Example backends:** `framework/docs/examples/cloudflare-{error-worker,data-worker,channel-relay}.js`
542
563
 
@@ -838,15 +859,35 @@ If the preview is not running, runtime tools fail fast with a clear error messag
838
859
  | `coursecode_state` | Full course snapshot | `{slide, structure, interactions, engagement, lmsState, errors}` |
839
860
  | `coursecode_navigate` | Go to slide by ID | `{slideId}` → new state |
840
861
  | `coursecode_interact` | Set response + evaluate | `{interactionId, response}` → `{correct, score, feedback}` |
841
- | `coursecode_screenshot` | Visual capture (PNG) | Optional `slideId` to navigate first, `fullPage` for scroll capture |
862
+ | `coursecode_screenshot` | Visual capture (JPEG) | Optional `slideId` to navigate first, `fullPage` for scroll capture |
863
+ | `coursecode_viewport` | Set viewport size | Breakpoint name or `{width, height}` → persists until changed |
842
864
  | `coursecode_reset` | Clear learner state | `{hard?: boolean}` → reload |
843
865
 
866
+ ### Screenshot Quality Modes
867
+
868
+ Two quality modes optimize for token efficiency — **neither changes the viewport**:
869
+
870
+ | Mode | Quality | Typical Size | Use For |
871
+ |------|---------|-------------|---------|
872
+ | normal (default) | JPEG@50 | ~20-40KB | Layout checks |
873
+ | detailed | JPEG@90 | ~100-200KB | Close text/element inspection |
874
+
875
+ ### Viewport Control
876
+
877
+ Use `coursecode_viewport` for responsive design testing. Two input modes:
878
+
879
+ - **Breakpoint name**: `"mobile-portrait"`, `"tablet-landscape"`, etc. — resolved dynamically from the running course's `breakpointManager.getBreakpoints()`, so always in sync with CSS.
880
+ - **Explicit dimensions**: `{width: 375, height: 812}` for specific device sizes.
881
+
882
+ The viewport **persists** until explicitly changed again. Default is 1280×720.
883
+
844
884
  ### Navigation API
845
885
 
846
886
  Use MCP tools for all course interaction — never use external browser tools:
847
887
 
848
888
  - `coursecode_state` → get all slide IDs, current position, interactions
849
889
  - `coursecode_navigate(slideId)` → instant slide navigation
890
+ - `coursecode_viewport(breakpoint)` → set viewport for responsive testing
850
891
  - `coursecode_screenshot(slideId)` → navigate + capture in one call
851
892
  - `coursecode_interact(id, response)` → answer + evaluate in one call
852
893
 
@@ -10,6 +10,7 @@ A complete guide to creating interactive e-learning courses with AI assistance.
10
10
  - [What You'll Need](#what-youll-need)
11
11
  - [Installation](#installation)
12
12
  - [Creating Your First Project](#creating-your-first-project)
13
+ - [Importing a PowerPoint (Optional)](#importing-a-powerpoint-optional)
13
14
  2. [Your Course Folder](#your-course-folder)
14
15
  - [Where Everything Lives](#where-everything-lives)
15
16
  - [The Documentation Files](#the-documentation-files)
@@ -40,11 +41,15 @@ A complete guide to creating interactive e-learning courses with AI assistance.
40
41
  - [Navigation and Flow](#navigation-and-flow)
41
42
  - [Engagement Requirements](#engagement-requirements)
42
43
  - [Learning Objectives](#learning-objectives)
44
+ - [Course Completion Feedback](#course-completion-feedback)
45
+ - [Updating Live Courses Safely](#updating-live-courses-safely)
43
46
  8. [Sharing and Deploying](#sharing-and-deploying)
44
47
  - [Sharing Previews](#sharing-previews)
48
+ - [Preview Export Options](#preview-export-options)
45
49
  - [Understanding LMS Formats](#understanding-lms-formats)
46
50
  - [Standard Deployment](#standard-deployment)
47
51
  - [CDN Deployment (Advanced)](#cdn-deployment-advanced)
52
+ - [Cloud Deployment](#cloud-deployment)
48
53
  - [Exporting Content for Review](#exporting-content-for-review)
49
54
  9. [Generating Audio Narration](#generating-audio-narration)
50
55
  10. [Troubleshooting](#troubleshooting)
@@ -107,6 +112,22 @@ coursecode new assessment final-quiz # Create a graded quiz
107
112
  coursecode new config # Create a fresh course-config.js
108
113
  ```
109
114
 
115
+ ### Importing a PowerPoint (Optional)
116
+
117
+ If you already have a PowerPoint deck, you can import it directly as a presentation-style course:
118
+
119
+ ```bash
120
+ coursecode import my-deck.pptx
121
+ ```
122
+
123
+ On macOS, CourseCode can drive Microsoft PowerPoint to export slides automatically. On any platform, you can use pre-exported slide images:
124
+
125
+ ```bash
126
+ coursecode import my-deck.pptx --slides-dir ./exported-slides
127
+ ```
128
+
129
+ This creates a project with slide image files, generated slide pages, and extracted text in `course/references/converted/` for AI-assisted enhancement.
130
+
110
131
  ---
111
132
 
112
133
  ## Your Course Folder
@@ -274,6 +295,7 @@ Once connected, your AI assistant gains these capabilities:
274
295
  | `coursecode_component_catalog` | Browse available UI components (tabs, accordion, cards, etc.) |
275
296
  | `coursecode_interaction_catalog` | Browse available interaction types (multiple choice, drag-drop, etc.) |
276
297
  | `coursecode_css_catalog` | Browse available CSS classes by category |
298
+ | `coursecode_icon_catalog` | Browse available icons by name/category |
277
299
  | `coursecode_workflow_status` | Get guidance on what to do next based on your project's current state |
278
300
  | `coursecode_build` | Build the course for LMS deployment |
279
301
 
@@ -459,6 +481,35 @@ Track what learners have accomplished:
459
481
  - Link objectives to assessment scores
460
482
  - Report completion status to your LMS
461
483
 
484
+ ### Course Completion Feedback
485
+
486
+ CourseCode can show an end-of-course feedback section in the completion modal. You can enable:
487
+
488
+ - A 5-star rating
489
+ - A free-text comments field
490
+
491
+ Configure this in `course/course-config.js`:
492
+
493
+ ```javascript
494
+ completion: {
495
+ promptForRating: true,
496
+ promptForComments: true
497
+ }
498
+ ```
499
+
500
+ Set either option to `false` if you do not want to collect that input.
501
+
502
+ ### Updating Live Courses Safely
503
+
504
+ When you update a course structure after learners have already started (for example, add/remove slides or change assessments), stored LMS state may no longer match the new structure.
505
+
506
+ CourseCode includes validation and recovery behavior:
507
+
508
+ - In development, mismatches are surfaced as errors so you can fix issues early
509
+ - In production, CourseCode attempts graceful recovery to keep learners moving
510
+
511
+ Best practice: set and increment `metadata.version` in `course/course-config.js` whenever you make meaningful structural changes.
512
+
462
513
  ---
463
514
 
464
515
  ## Sharing and Deploying
@@ -473,6 +524,24 @@ coursecode preview --export
473
524
 
474
525
  This creates a self-contained folder you can upload to any web host (Netlify, GitHub Pages, etc.). You can add password protection and other options — ask your AI assistant for help.
475
526
 
527
+ ### Preview Export Options
528
+
529
+ Useful `coursecode preview --export` options:
530
+
531
+ ```bash
532
+ coursecode preview --export -o ./course-preview
533
+ coursecode preview --export --password "secret"
534
+ coursecode preview --export --skip-build
535
+ coursecode preview --export --nojekyll
536
+ coursecode preview --export --no-content
537
+ ```
538
+
539
+ - `-o, --output`: choose output folder
540
+ - `-p, --password`: add password protection to shared preview
541
+ - `--skip-build`: export from existing `dist/` without rebuilding
542
+ - `--nojekyll`: add `.nojekyll` (important for GitHub Pages)
543
+ - `--no-content`: hide the content viewer panel in exported preview
544
+
476
545
  ### Understanding LMS Formats
477
546
 
478
547
  An LMS (Learning Management System) is the platform your organization uses to deliver training — think Cornerstone, Moodle, Canvas, Docebo, etc. CourseCode packages your course in a format your LMS understands.
@@ -486,6 +555,8 @@ An LMS (Learning Management System) is the platform your organization uses to de
486
555
 
487
556
  **Not sure which to pick?** Ask your LMS administrator. If they don't know, try **SCORM 1.2** — it works with almost everything.
488
557
 
558
+ > **SCORM 1.2 caveat:** SCORM 1.2 has a strict ~4KB suspend data limit. CourseCode uses a strict storage mode to fit within that limit, which can reduce how much interaction UI state is restored across slides on resume.
559
+
489
560
  > **Using CourseCode Cloud?** You don't need to choose a format. Cloud-deployed courses use a universal build — the cloud generates the correct format automatically when you download a ZIP for your LMS. The format setting in `course-config.js` only applies to local `coursecode build` commands.
490
561
 
491
562
  ### Standard Deployment
@@ -514,6 +585,19 @@ For teams that update courses frequently or serve multiple LMS clients, CourseCo
514
585
 
515
586
  CDN deployment uses special format variants (`scorm1.2-proxy`, `scorm2004-proxy`, `cmi5-remote`). Ask your AI assistant to set this up — it involves configuring an external URL and access tokens in `course-config.js`.
516
587
 
588
+ Generate and add client tokens with:
589
+
590
+ ```bash
591
+ coursecode token --add client-a
592
+ coursecode token --add client-b
593
+ ```
594
+
595
+ Then build your proxy/remote package and deploy:
596
+
597
+ ```bash
598
+ coursecode build --format scorm1.2-proxy
599
+ ```
600
+
517
601
  ### Cloud Deployment
518
602
 
519
603
  CourseCode Cloud is the simplest deployment option. Upload your course once and the cloud handles everything:
@@ -576,6 +660,18 @@ Audio files are generated to `course/assets/audio/` and automatically linked to
576
660
  - Make sure you're giving it the right documentation files
577
661
  - Share error messages so it can fix issues
578
662
 
663
+ **MCP tools are connected but runtime actions fail**
664
+ - Make sure `coursecode preview` is running in the same project
665
+ - Runtime MCP tools (state, navigate, screenshot, interact, reset) require a live preview connection
666
+
667
+ **Cloud/local format behavior is confusing**
668
+ - Local builds use your selected `--format` (or config default)
669
+ - Cloud deploy uses a universal build and lets you choose format at download time
670
+
671
+ **Returning learners see unexpected progress after major course updates**
672
+ - If you changed slide structure or assessments, old stored LMS state may not fully match new content
673
+ - Increment `metadata.version` and re-test resume behavior in preview and LMS
674
+
579
675
  **Need more help?**
580
676
  - Check the [GitHub issues](https://github.com/course-code-framework/coursecode/issues)
581
677
  - The example course includes troubleshooting tips
@@ -140,6 +140,13 @@ class ObjectiveManager {
140
140
  throw new Error('ObjectiveManager: setObjective requires an id.');
141
141
  }
142
142
 
143
+ if (objectiveData.score !== undefined && objectiveData.score !== null) {
144
+ const score = objectiveData.score;
145
+ if (typeof score !== 'number' || !Number.isFinite(score) || score < 0 || score > 100) {
146
+ throw new Error(`ObjectiveManager: Score must be a number between 0 and 100, got ${score}`);
147
+ }
148
+ }
149
+
143
150
  const existing = this.objectives[id] || { id };
144
151
  const updated = { ...existing, ...objectiveData };
145
152
 
@@ -151,7 +158,7 @@ class ObjectiveManager {
151
158
  eventBus.emit('objective:updated', updated);
152
159
 
153
160
  // Emit specific score event if score was updated (for ScoreManager)
154
- if (typeof updated.score === 'number') {
161
+ if (typeof updated.score === 'number' && Number.isFinite(updated.score)) {
155
162
  eventBus.emit('objective:score:updated', {
156
163
  objectiveId: id,
157
164
  score: updated.score
@@ -272,6 +279,15 @@ class ObjectiveManager {
272
279
  this._checkTimeBasedObjectives(view);
273
280
  });
274
281
 
282
+ // Re-evaluate time-based objectives for the slide just left.
283
+ // Slide durations are finalized on navigation:beforeChange, so fromSlideId
284
+ // is the reliable key to check after navigation completes.
285
+ eventBus.on('navigation:changed', ({ fromSlideId }) => {
286
+ if (fromSlideId) {
287
+ this._checkTimeBasedObjectives(fromSlideId);
288
+ }
289
+ });
290
+
275
291
  // Listen for flag changes to track flag-based objectives
276
292
  eventBus.on('flag:updated', ({ key, value }) => {
277
293
  this._checkFlagBasedObjectives(key, value);
@@ -318,6 +334,10 @@ class ObjectiveManager {
318
334
 
319
335
  case 'allSlidesVisited': {
320
336
  const requiredSlides = criteria.slideIds || [];
337
+ if (!Array.isArray(requiredSlides) || requiredSlides.length === 0) {
338
+ logger.warn(`[ObjectiveManager] Objective "${objective.id}" has invalid allSlidesVisited criteria: slideIds must be a non-empty array.`);
339
+ break;
340
+ }
321
341
  const allVisited = requiredSlides.every(sid => allVisitedSlides.has(sid));
322
342
  if (allVisited) {
323
343
  this.setCompletionStatus(objective.id, 'completed');
@@ -352,8 +372,14 @@ class ObjectiveManager {
352
372
  if (criteria.type === 'timeOnSlide' && criteria.slideId === slideId) {
353
373
  const totalMilliseconds = slideDurations[slideId] || 0;
354
374
  const totalSeconds = totalMilliseconds / 1000;
375
+ const minSeconds = criteria.minSeconds;
355
376
 
356
- if (totalSeconds >= (criteria.minSeconds || 0)) {
377
+ if (typeof minSeconds !== 'number' || !Number.isFinite(minSeconds) || minSeconds <= 0) {
378
+ logger.warn(`[ObjectiveManager] Objective "${objective.id}" has invalid timeOnSlide criteria: minSeconds must be a positive number.`);
379
+ return;
380
+ }
381
+
382
+ if (totalSeconds >= minSeconds) {
357
383
  this.setCompletionStatus(objective.id, 'completed');
358
384
  logger.debug(`[ObjectiveManager] Objective "${objective.id}" completed: timeOnSlide (${totalSeconds.toFixed(1)}s)`);
359
385
  }
@@ -400,6 +426,10 @@ class ObjectiveManager {
400
426
  } else if (criteria.type === 'allFlags') {
401
427
  // Multiple flags check - all must be truthy (or match equals values)
402
428
  const requiredFlags = criteria.flags || [];
429
+ if (!Array.isArray(requiredFlags) || requiredFlags.length === 0) {
430
+ logger.warn(`[ObjectiveManager] Objective "${objective.id}" has invalid allFlags criteria: flags must be a non-empty array.`);
431
+ return;
432
+ }
403
433
  const allMet = requiredFlags.every(flagConfig => {
404
434
  const key = typeof flagConfig === 'string' ? flagConfig : flagConfig.key;
405
435
  const value = flags[key];
@@ -696,9 +696,6 @@ async function _performNavigation(slideId, context = {}, updateBookmark = true)
696
696
  _setupEngagementListeners(newSlide);
697
697
  _updateEngagementIndicator(newSlide);
698
698
 
699
- // Start timer for the new slide
700
- AppActions.startSessionTimer(slideId);
701
-
702
699
  // Update progress measure to reflect slide visit
703
700
  // Count only sequential slides (excludes remedial/conditional slides)
704
701
  const totalSequentialSlides = slides.filter(s => isSlideInSequence(s, stateManager, assessmentConfigs)).length;
@@ -154,11 +154,15 @@ export function evaluateGatingCondition(condition, stateManager, assessmentConfi
154
154
  }
155
155
 
156
156
  case 'timeOnSlide': {
157
+ const minSeconds = condition.minSeconds;
158
+ if (typeof minSeconds !== 'number' || !Number.isFinite(minSeconds) || minSeconds <= 0) {
159
+ return false;
160
+ }
157
161
  const sessionData = stateManager.getDomainState('sessionData');
158
162
  const slideDurations = sessionData?.slideDurations || {};
159
163
  const slideDurationMs = slideDurations[condition.slideId] || 0;
160
164
  const totalSeconds = slideDurationMs / 1000;
161
- return totalSeconds >= (condition.minSeconds || 0);
165
+ return totalSeconds >= minSeconds;
162
166
  }
163
167
 
164
168
  case 'custom': {
@@ -23,6 +23,12 @@
23
23
  import { eventBus } from '../core/event-bus.js';
24
24
  import { logger } from './logger.js';
25
25
 
26
+ // ── Cloud meta tag reader ───────────────────────────────────────────
27
+ function getMetaContent(name) {
28
+ const el = document.querySelector(`meta[name="${name}"]`);
29
+ return el ? el.getAttribute('content') : null;
30
+ }
31
+
26
32
  // Connection state
27
33
  let _config = null;
28
34
  let _eventSource = null;
@@ -186,7 +192,16 @@ function handleUnload() {
186
192
  * @param {Object} courseConfig - The course configuration object
187
193
  */
188
194
  export function initCourseChannel(courseConfig) {
189
- const config = courseConfig.environment?.channel;
195
+ // Priority chain: meta tags (cloud-injected) → config (self-hosted) → disabled
196
+ const metaEndpoint = getMetaContent('cc-channel-endpoint');
197
+ const metaChannelId = getMetaContent('cc-channel-id');
198
+ let config;
199
+ if (metaEndpoint && metaChannelId) {
200
+ // Both required — endpoint alone without a channel ID is useless
201
+ config = { endpoint: metaEndpoint, channelId: metaChannelId, apiKey: getMetaContent('cc-api-key') };
202
+ } else {
203
+ config = courseConfig.environment?.channel;
204
+ }
190
205
 
191
206
  _config = config;
192
207
 
@@ -18,6 +18,12 @@
18
18
  import { eventBus } from '../core/event-bus.js';
19
19
  import { logger } from './logger.js';
20
20
 
21
+ // ── Cloud meta tag reader ───────────────────────────────────────────
22
+ function getMetaContent(name) {
23
+ const el = document.querySelector(`meta[name="${name}"]`);
24
+ return el ? el.getAttribute('content') : null;
25
+ }
26
+
21
27
  // Batching state
22
28
  let batch = [];
23
29
  let flushTimer = null;
@@ -25,6 +31,8 @@ let flushTimer = null;
25
31
  // Configuration
26
32
  let _config = null;
27
33
  let _courseConfig = null;
34
+ let _licenseId = null;
35
+ let _courseId = null;
28
36
 
29
37
  const DEFAULT_BATCH_SIZE = 10;
30
38
  const DEFAULT_FLUSH_INTERVAL = 30000; // 30 seconds
@@ -158,6 +166,10 @@ function buildPayload(records) {
158
166
  };
159
167
  }
160
168
 
169
+ // Cloud attribution context (license and course IDs for engine routing)
170
+ if (_licenseId) payload.licenseId = _licenseId;
171
+ if (_courseId) payload.courseId = _courseId;
172
+
161
173
  return payload;
162
174
  }
163
175
 
@@ -238,11 +250,6 @@ function handleBeforeTerminate() {
238
250
  * @param {Object} courseConfig - The course configuration object
239
251
  */
240
252
  export function initDataReporter(courseConfig) {
241
- const config = courseConfig.environment?.dataReporting;
242
-
243
- _config = config;
244
- _courseConfig = courseConfig;
245
-
246
253
  // Never send data during local dev — the preview server and dev command
247
254
  // inject VITE_COURSECODE_LOCAL into the Vite build env, which is auto-exposed
248
255
  // to client code. Production builds via `coursecode build` don't set this.
@@ -251,6 +258,22 @@ export function initDataReporter(courseConfig) {
251
258
  return;
252
259
  }
253
260
 
261
+ // Priority chain: meta tags (cloud-injected) → config (self-hosted) → disabled
262
+ const metaEndpoint = getMetaContent('cc-data-endpoint');
263
+ let config;
264
+ if (metaEndpoint) {
265
+ config = { endpoint: metaEndpoint, apiKey: getMetaContent('cc-api-key') };
266
+ } else {
267
+ config = courseConfig.environment?.dataReporting;
268
+ }
269
+
270
+ _config = config;
271
+ _courseConfig = courseConfig;
272
+
273
+ // Cloud attribution (present when launched via LTI/cmi5)
274
+ _licenseId = getMetaContent('cc-license-id');
275
+ _courseId = getMetaContent('cc-course-id');
276
+
254
277
  // Disabled if no endpoint configured
255
278
  if (!config?.endpoint) {
256
279
  logger.debug('[DataReporter] Not configured, skipping initialization');
@@ -42,6 +42,12 @@ const sendTimestamps = []; // Timestamps of recent sends
42
42
  // ── Re-entrancy guard ───────────────────────────────────────────────
43
43
  let _isReporting = false;
44
44
 
45
+ // ── Cloud meta tag reader ───────────────────────────────────────────
46
+ function getMetaContent(name) {
47
+ const el = document.querySelector(`meta[name="${name}"]`);
48
+ return el ? el.getAttribute('content') : null;
49
+ }
50
+
45
51
  /**
46
52
  * Generate a unique key for an error to detect duplicates
47
53
  */
@@ -172,6 +178,10 @@ async function flushBatch(config, courseConfig) {
172
178
  };
173
179
  }
174
180
 
181
+ // Cloud attribution context (license and course IDs for engine routing)
182
+ if (_licenseId) request.licenseId = _licenseId;
183
+ if (_courseId) request.courseId = _courseId;
184
+
175
185
  // Guard re-entrancy: our own logger calls must not trigger new reports
176
186
  _isReporting = true;
177
187
  try {
@@ -201,6 +211,8 @@ async function flushBatch(config, courseConfig) {
201
211
  // Store config globally for user reports
202
212
  let _config = null;
203
213
  let _courseConfig = null;
214
+ let _licenseId = null;
215
+ let _courseId = null;
204
216
 
205
217
  /**
206
218
  * Check if error reporting is configured and user reports are enabled
@@ -239,6 +251,10 @@ export async function submitUserReport(description, options = {}) {
239
251
  id: _courseConfig.metadata?.id
240
252
  };
241
253
  }
254
+
255
+ // Cloud attribution
256
+ if (_licenseId) payload.licenseId = _licenseId;
257
+ if (_courseId) payload.courseId = _courseId;
242
258
 
243
259
  // Include current slide info if available
244
260
  if (options.currentSlide) {
@@ -279,12 +295,6 @@ export async function submitUserReport(description, options = {}) {
279
295
  * @param {Object} courseConfig - The course configuration object
280
296
  */
281
297
  export function initErrorReporter(courseConfig) {
282
- const config = courseConfig.environment?.errorReporting;
283
-
284
- // Store for user reports
285
- _config = config;
286
- _courseConfig = courseConfig;
287
-
288
298
  // Never send reports during local dev — the preview server and dev command
289
299
  // inject VITE_COURSECODE_LOCAL into the Vite build env, which is auto-exposed
290
300
  // to client code. Production builds via `coursecode build` don't set this.
@@ -292,15 +302,32 @@ export function initErrorReporter(courseConfig) {
292
302
  logger.debug('[ErrorReporter] Disabled in local dev mode');
293
303
  return;
294
304
  }
295
-
305
+
306
+ // Priority chain: meta tags (cloud-injected) → config (self-hosted) → disabled
307
+ const metaEndpoint = getMetaContent('cc-error-endpoint');
308
+ let config;
309
+ if (metaEndpoint) {
310
+ config = { endpoint: metaEndpoint, apiKey: getMetaContent('cc-api-key') };
311
+ } else {
312
+ config = courseConfig.environment?.errorReporting;
313
+ }
314
+
315
+ // Store for user reports
316
+ _config = config;
317
+ _courseConfig = courseConfig;
318
+
319
+ // Cloud attribution (present when launched via LTI/cmi5)
320
+ _licenseId = getMetaContent('cc-license-id');
321
+ _courseId = getMetaContent('cc-course-id');
322
+
296
323
  // Disabled if not configured or no endpoint
297
324
  if (!config?.endpoint) {
298
325
  logger.debug('[ErrorReporter] Not configured, skipping initialization');
299
326
  return;
300
327
  }
301
-
328
+
302
329
  logger.info('[ErrorReporter] Initialized with endpoint:', config.endpoint);
303
-
330
+
304
331
  // Subscribe to unified logger events
305
332
  eventBus.on('log:error', (errorData) => {
306
333
  if (_isReporting) return; // prevent re-entrancy from our own logger calls
package/lib/cloud.js CHANGED
@@ -319,13 +319,20 @@ async function runLoginFlow() {
319
319
  }
320
320
 
321
321
  /**
322
- * Ensure the user is authenticated. Auto-triggers login if no credentials.
322
+ * Ensure the user is authenticated. Auto-triggers login if interactive.
323
+ * In non-interactive environments, exits with an error message.
323
324
  * @returns {Promise<string>} The API token
324
325
  */
325
326
  export async function ensureAuthenticated() {
326
327
  const creds = readCredentials();
327
328
  if (creds?.token) return creds.token;
328
329
 
330
+ // Non-interactive: can't launch browser login — exit with clear error
331
+ if (!process.stdin.isTTY) {
332
+ console.error('\n❌ No Cloud credentials found. Run `coursecode login` first.\n');
333
+ process.exit(1);
334
+ }
335
+
329
336
  console.log('\n No Cloud credentials found. Launching login...');
330
337
  return runLoginFlow();
331
338
  }
package/lib/create.js CHANGED
@@ -211,10 +211,8 @@ export async function create(name, options = {}) {
211
211
  `);
212
212
  }
213
213
 
214
- // Prompt to start dev server
215
- const shouldStart = await promptYesNo(' Start the development server? (Y/n) ');
216
-
217
- if (shouldStart) {
214
+ // --start: auto-start dev server. Otherwise print instructions and exit.
215
+ if (options.start) {
218
216
  console.log('\n Starting development server...\n');
219
217
  const child = spawn('npx', ['coursecode', 'dev'], {
220
218
  cwd: targetDir,
@@ -227,22 +225,7 @@ export async function create(name, options = {}) {
227
225
  console.log(` cd ${name} && coursecode dev\n`);
228
226
  });
229
227
  } else {
230
- console.log(`
231
- To start developing:
232
-
233
- cd ${name}
234
- coursecode dev
235
- `);
228
+ console.log(`\n To start developing:\n\n cd ${name}\n coursecode dev\n`);
236
229
  }
237
230
  }
238
231
 
239
- function promptYesNo(question) {
240
- return new Promise((resolve) => {
241
- process.stdout.write(question);
242
- process.stdin.setEncoding('utf8');
243
- process.stdin.once('data', (data) => {
244
- const answer = data.trim().toLowerCase();
245
- resolve(answer === '' || answer === 'y' || answer === 'yes');
246
- });
247
- });
248
- }
@@ -71,6 +71,7 @@ class HeadlessBrowser {
71
71
  this._reconnectTimer = null;
72
72
  this._stopped = false;
73
73
  this._consoleLogs = [];
74
+ this._viewport = { width: 1280, height: 720 };
74
75
  }
75
76
 
76
77
  /**
@@ -106,7 +107,7 @@ class HeadlessBrowser {
106
107
  });
107
108
 
108
109
  this.page = await this.browser.newPage();
109
- await this.page.setViewport({ width: 1280, height: 720 });
110
+ await this.page.setViewport(this._viewport);
110
111
 
111
112
  // Capture console warnings and errors
112
113
  this._consoleLogs = [];
@@ -250,20 +251,88 @@ class HeadlessBrowser {
250
251
  return logs;
251
252
  }
252
253
 
254
+ /**
255
+ * Set the headless browser viewport size.
256
+ * Accepts either a dimensions object or a named breakpoint string.
257
+ * The viewport persists until explicitly changed again.
258
+ *
259
+ * @param {object|string} sizeOrBreakpoint - {width, height} or breakpoint name
260
+ * @returns {Promise<{width: number, height: number, breakpoint?: string}>} Applied viewport
261
+ */
262
+ async setViewport(sizeOrBreakpoint) {
263
+ this._ensureRunning();
264
+
265
+ let width, height, breakpointName;
266
+
267
+ if (typeof sizeOrBreakpoint === 'string') {
268
+ // Resolve breakpoint name dynamically from the running course
269
+ breakpointName = sizeOrBreakpoint;
270
+ await this._ensureCourseFrame();
271
+ const bp = await this.courseFrame.evaluate((name) => {
272
+ const bpManager = window.CourseCode?.breakpointManager;
273
+ if (!bpManager) return null;
274
+ const breakpoints = bpManager.getBreakpoints();
275
+ return breakpoints.find(b => b.name === name) || null;
276
+ }, breakpointName);
277
+
278
+ if (!bp) {
279
+ // Get available names for error message
280
+ const available = await this.courseFrame.evaluate(() => {
281
+ const bpManager = window.CourseCode?.breakpointManager;
282
+ if (!bpManager) return [];
283
+ return bpManager.getBreakpoints().map(b => b.name);
284
+ });
285
+ throw new Error(
286
+ `Unknown breakpoint "${breakpointName}". ` +
287
+ `Available: ${available.join(', ')}`
288
+ );
289
+ }
290
+
291
+ // Use the breakpoint's boundary width
292
+ width = bp.maxWidth ?? bp.minWidth;
293
+ // Scale height proportionally from the 16:9 base (1280x720)
294
+ height = Math.round(width * (9 / 16));
295
+ } else {
296
+ width = sizeOrBreakpoint.width;
297
+ height = sizeOrBreakpoint.height;
298
+ if (!width || !height) {
299
+ throw new Error('Viewport requires both width and height, or a breakpoint name string.');
300
+ }
301
+ }
302
+
303
+ this._viewport = { width, height };
304
+ await this.page.setViewport(this._viewport);
305
+ // Brief settle for layout reflow
306
+ await new Promise(resolve => setTimeout(resolve, 100));
307
+
308
+ return { width, height, ...(breakpointName ? { breakpoint: breakpointName } : {}) };
309
+ }
310
+
311
+ /**
312
+ * Get the current viewport dimensions.
313
+ * @returns {{width: number, height: number}}
314
+ */
315
+ getViewport() {
316
+ return { ...this._viewport };
317
+ }
318
+
253
319
  /**
254
320
  * Take a screenshot of the current page.
255
- *
321
+ *
256
322
  * Two quality modes optimize for token efficiency:
257
- * - normal (default): 800×450 JPEG@70 (~30-60KB) — quick layout checks
258
- * - detailed: 1280×720 JPEG@90 (~100-200KB) — close text/element inspection
259
- *
323
+ * - normal (default): JPEG@50 (~20-40KB) — quick layout checks
324
+ * - detailed: JPEG@90 (~100-200KB) — close text/element inspection
325
+ *
326
+ * Neither mode changes the viewport. The screenshot captures at the
327
+ * current viewport size. Use setViewport() to change viewport independently.
328
+ *
260
329
  * fullPage captures the entire scrollable course content by screenshotting
261
330
  * the course iframe element directly (bypasses the stub player chrome).
262
- *
331
+ *
263
332
  * @param {object} options
264
333
  * @param {string} [options.slideId] - Navigate to this slide before screenshotting
265
334
  * @param {boolean} [options.fullPage=false] - Capture full scrollable content
266
- * @param {boolean} [options.detailed=false] - Use high-res detailed mode
335
+ * @param {boolean} [options.detailed=false] - Higher JPEG quality for close inspection
267
336
  * @returns {Promise<{data: string, mimeType: string}>} Base64-encoded JPEG
268
337
  */
269
338
  async screenshot(options = {}) {
@@ -271,10 +340,8 @@ class HeadlessBrowser {
271
340
 
272
341
  const { slideId, fullPage = false, detailed = false, scrollY } = options;
273
342
 
274
- // Screenshot quality presets
275
- const preset = detailed
276
- ? { width: 1280, height: 720, quality: 90 }
277
- : { width: 800, height: 450, quality: 70 };
343
+ // Quality-only presets — viewport is NOT changed
344
+ const quality = detailed ? 90 : 50;
278
345
 
279
346
  // Navigate to specific slide if requested
280
347
  if (slideId) {
@@ -297,18 +364,11 @@ class HeadlessBrowser {
297
364
  await new Promise(resolve => setTimeout(resolve, 100));
298
365
  }
299
366
 
300
- // Resize viewport for the chosen mode
301
- await this.page.setViewport({ width: preset.width, height: preset.height });
302
- // Brief settle after resize
303
- await new Promise(resolve => setTimeout(resolve, 100));
304
-
305
367
  let screenshotBuffer;
306
368
 
307
369
  if (fullPage) {
308
370
  // For fullPage, measure the iframe content's full scrollable height,
309
371
  // temporarily expand the viewport so nothing is clipped, then screenshot.
310
- // ElementHandle.screenshot() only captures the element's bounding box,
311
- // so we need to make the viewport tall enough to show everything.
312
372
  await this._ensureCourseFrame();
313
373
  const contentHeight = await this.courseFrame.evaluate(() => {
314
374
  return Math.max(
@@ -317,33 +377,33 @@ class HeadlessBrowser {
317
377
  );
318
378
  });
319
379
 
320
- const fullHeight = Math.max(contentHeight, preset.height);
321
- await this.page.setViewport({ width: preset.width, height: fullHeight });
380
+ const fullHeight = Math.max(contentHeight, this._viewport.height);
381
+ await this.page.setViewport({ width: this._viewport.width, height: fullHeight });
322
382
  await new Promise(resolve => setTimeout(resolve, 200));
323
383
 
324
384
  const iframeElement = await this.page.$('iframe');
325
385
  if (iframeElement) {
326
386
  screenshotBuffer = await iframeElement.screenshot({
327
387
  type: 'jpeg',
328
- quality: preset.quality
388
+ quality
329
389
  });
330
390
  } else {
331
391
  screenshotBuffer = await this.page.screenshot({
332
392
  type: 'jpeg',
333
- quality: preset.quality,
393
+ quality,
334
394
  fullPage: true
335
395
  });
336
396
  }
397
+
398
+ // Restore viewport height (fullPage temporarily expanded it)
399
+ await this.page.setViewport(this._viewport);
337
400
  } else {
338
401
  screenshotBuffer = await this.page.screenshot({
339
402
  type: 'jpeg',
340
- quality: preset.quality
403
+ quality
341
404
  });
342
405
  }
343
406
 
344
- // Restore default viewport
345
- await this.page.setViewport({ width: 1280, height: 720 });
346
-
347
407
  return {
348
408
  data: screenshotBuffer.toString('base64'),
349
409
  mimeType: 'image/jpeg'
@@ -121,9 +121,11 @@ Requires preview server to be running.`,
121
121
  name: 'coursecode_screenshot',
122
122
  description: `Take a screenshot of the course preview. Returns a JPEG image.
123
123
 
124
- Two modes optimized for token efficiency:
125
- - normal (default): 800×450 (~30-60KB) — layout checks
126
- - detailed: 1280×720 (~100-200KB) — close text/element inspection
124
+ Two quality modes (neither changes viewport):
125
+ - normal (default): JPEG@50 (~20-40KB) — layout checks
126
+ - detailed: JPEG@90 (~100-200KB) — close text/element inspection
127
+
128
+ Captures at the current viewport size. Use coursecode_viewport to change viewport for responsive testing.
127
129
 
128
130
  Use scrollY to scroll course content before capturing (useful for long slides).
129
131
  fullPage captures the course iframe's full content area.
@@ -143,7 +145,7 @@ Requires preview server to be running.`,
143
145
  },
144
146
  detailed: {
145
147
  type: 'boolean',
146
- description: 'Use higher-res mode (1280×720) for close inspection'
148
+ description: 'Higher JPEG quality for close inspection (does not change viewport)'
147
149
  },
148
150
  scrollY: {
149
151
  type: 'number',
@@ -153,6 +155,41 @@ Requires preview server to be running.`,
153
155
  required: []
154
156
  }
155
157
  },
158
+ {
159
+ name: 'coursecode_viewport',
160
+ description: `Set the headless browser viewport size for responsive design testing.
161
+
162
+ Two modes:
163
+ - Breakpoint name: 'mobile-portrait', 'mobile-landscape', 'tablet-portrait', etc.
164
+ Resolves width dynamically from the course's breakpoint manager (always in sync with CSS).
165
+ Height scales proportionally (16:9).
166
+ - Explicit dimensions: {width: 375, height: 812} for specific device sizes.
167
+
168
+ The viewport PERSISTS until changed again. Call with 'desktop' or {width: 1280, height: 720} to reset.
169
+
170
+ Returns the applied viewport dimensions and breakpoint name (if used).
171
+
172
+ Use before coursecode_screenshot to test responsive layouts.
173
+ Requires preview server to be running.`,
174
+ inputSchema: {
175
+ type: 'object',
176
+ properties: {
177
+ breakpoint: {
178
+ type: 'string',
179
+ description: 'Named breakpoint from the course (e.g., "mobile-portrait", "tablet-landscape", "desktop")'
180
+ },
181
+ width: {
182
+ type: 'number',
183
+ description: 'Explicit viewport width in pixels (use with height)'
184
+ },
185
+ height: {
186
+ type: 'number',
187
+ description: 'Explicit viewport height in pixels (use with width)'
188
+ }
189
+ },
190
+ required: []
191
+ }
192
+ },
156
193
  // --- Workflow & Build Tools ---
157
194
  {
158
195
  name: 'coursecode_workflow_status',
@@ -652,7 +689,7 @@ function buildBrowserRules() {
652
689
  return `## Browser Architecture (CRITICAL)
653
690
 
654
691
  The MCP server runs its OWN headless Chrome internally via puppeteer-core.
655
- All runtime tools (state, navigate, interact, screenshot, reset) execute instantly inside this headless browser.
692
+ All runtime tools (state, navigate, interact, screenshot, viewport, reset) execute instantly inside this headless browser.
656
693
 
657
694
  ### Preview Server Ownership
658
695
  - The MCP does NOT start or manage the preview server
@@ -663,7 +700,8 @@ All runtime tools (state, navigate, interact, screenshot, reset) execute instant
663
700
  ### Navigation API
664
701
  - coursecode_state → get slim TOC with slide IDs, current slide, interactions, engagement, lmsState, apiLog, errors, frameworkLogs, consoleLogs
665
702
  - coursecode_navigate(slideId) → go to any slide instantly by ID
666
- - coursecode_screenshot(slideId) → navigate + capture in one call
703
+ - coursecode_viewport(breakpoint or {width,height}) → set viewport for responsive testing (persists until changed)
704
+ - coursecode_screenshot(slideId) → navigate + capture in one call (quality modes only, never changes viewport)
667
705
  - coursecode_interact(interactionId, response) → set response + evaluate in one call
668
706
  - NEVER click nav buttons or menu items via browser tools. Use these MCP tools.
669
707
 
package/lib/mcp-server.js CHANGED
@@ -211,6 +211,17 @@ export async function startMcpServer(options = {}) {
211
211
  }]
212
212
  };
213
213
 
214
+ case 'coursecode_viewport':
215
+ await ensureHeadless(port);
216
+ if (args?.breakpoint) {
217
+ result = await headless.setViewport(args.breakpoint);
218
+ } else if (args?.width && args?.height) {
219
+ result = await headless.setViewport({ width: args.width, height: args.height });
220
+ } else {
221
+ throw new Error('Provide either a breakpoint name or both width and height.');
222
+ }
223
+ break;
224
+
214
225
  // === Workflow & build tools ===
215
226
  case 'coursecode_workflow_status':
216
227
  result = await getWorkflowStatusWithInstructions(port);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.2",
3
+ "version": "0.1.6",
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": {