coursecode 0.1.26 → 0.1.28

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.
@@ -662,9 +662,9 @@ Runs **in Node.js** during build (via `vite.framework-dev.config.js` `closeBundl
662
662
 
663
663
  **Errors fail the build; warnings print but don't block.**
664
664
 
665
- ### MCP `coursecode_lint` — Unified Results
665
+ ### MCP `coursecode_lint` — Build-Time Only
666
666
 
667
- The MCP `coursecode_lint` tool always runs the build linter. When the preview server is running and the headless browser is connected, it also merges runtime errors from the preview server's error log into the same response. These are the same errors shown in the debug panel's Errors tab and returned by `coursecode_state` LMS API misuse, console errors, uncaught exceptions, and data limit warnings. Runtime-sourced items are tagged with `source: 'runtime'` and `rule: 'runtime-error'`. The `runtimeLintIncluded` flag in the response indicates whether runtime errors were included. This gives AI agents a single tool for both static lint and runtime errors without needing a separate `coursecode_state` call.
667
+ The MCP `coursecode_lint` tool runs the build linter (config validation, CSS class verification, structure checks). It does NOT include runtime errors. For runtime errors, contrast warnings, and other dynamic issues, use `coursecode_state` which returns `frameworkLogs` and `errors` from the preview server.
668
668
 
669
669
  ### Shared Rules (`lib/validation-rules.js`)
670
670
 
@@ -167,9 +167,8 @@ export function getAuthoringContext() {
167
167
 
168
168
  /**
169
169
  * Dynamic CSS catalog — extracts structured class data from real CSS files via PostCSS.
170
- *
171
- * Without filterCategory: returns compact categorized index (class name short description).
172
- * With filterCategory: returns full detail for that category (all declarations).
170
+ * Token values (spacing, font sizes, etc.) are resolved from design-tokens.css so
171
+ * AI sees actual values like "1rem" instead of opaque "var(--space-4)".
173
172
  *
174
173
  * Category names are derived from file paths relative to framework/css/:
175
174
  * utilities/borders.css → "utilities/borders"
@@ -177,8 +176,77 @@ export function getAuthoringContext() {
177
176
  * components/hero.css → "components/hero"
178
177
  */
179
178
 
180
- // Module-level cache — parsed once per process
179
+ // Module-level caches — parsed once per process
181
180
  let _cssCatalogCache = null;
181
+ let _tokenMapCache = null;
182
+
183
+ /**
184
+ * Parse design-tokens.css and resolve var() chains to final values.
185
+ * Returns a map of --variable-name → resolved-value.
186
+ */
187
+ function buildTokenMap() {
188
+ if (_tokenMapCache) return _tokenMapCache;
189
+
190
+ const frameworkRoot = getFrameworkRoot();
191
+ const tokensFile = path.join(frameworkRoot, 'framework', 'css', 'design-tokens.css');
192
+
193
+ if (!fs.existsSync(tokensFile)) {
194
+ _tokenMapCache = {};
195
+ return _tokenMapCache;
196
+ }
197
+
198
+ const source = fs.readFileSync(tokensFile, 'utf-8');
199
+ const root = postcss.parse(source);
200
+ const tokens = {};
201
+
202
+ // Extract all custom properties from :root blocks
203
+ root.walk(node => {
204
+ if (node.type === 'decl' && node.prop.startsWith('--')) {
205
+ tokens[node.prop] = node.value.trim();
206
+ }
207
+ });
208
+
209
+ // Resolve var() chains iteratively (handles --space-4 → --space-4-base → 0.25rem)
210
+ for (let i = 0; i < 10; i++) {
211
+ let changed = false;
212
+ for (const [prop, value] of Object.entries(tokens)) {
213
+ const resolved = value.replace(/var\(\s*([^,)]+)\s*\)/g, (match, varName) => {
214
+ const trimmed = varName.trim();
215
+ if (tokens[trimmed] && !tokens[trimmed].includes('var(')) {
216
+ changed = true;
217
+ return tokens[trimmed];
218
+ }
219
+ return match;
220
+ });
221
+ tokens[prop] = resolved;
222
+ }
223
+ if (!changed) break;
224
+ }
225
+
226
+ _tokenMapCache = tokens;
227
+ return _tokenMapCache;
228
+ }
229
+
230
+ /**
231
+ * Replace var() references in a CSS value with resolved token values.
232
+ * Only resolves structural tokens (spacing, sizes, radii). Color tokens are
233
+ * left as var() references since semantic names like var(--bg-elevated) are
234
+ * more useful to AI than theme-dependent hex values like #1e293b.
235
+ */
236
+ function resolveVarRefs(value, tokenMap) {
237
+ return value.replace(/var\(\s*([^,)]+?)(?:\s*,\s*([^)]+))?\s*\)/g, (_match, varName, fallback) => {
238
+ const resolved = tokenMap[varName.trim()];
239
+ if (resolved && !resolved.includes('var(')) {
240
+ // Skip color values — semantic variable names are more useful
241
+ if (/^#|^rgba?\(|^hsla?\(|^color-mix\(|^oklch\(/.test(resolved)) {
242
+ return _match;
243
+ }
244
+ return resolved;
245
+ }
246
+ if (fallback) return fallback.trim();
247
+ return _match;
248
+ });
249
+ }
182
250
 
183
251
  function buildCssCatalog() {
184
252
  if (_cssCatalogCache) return _cssCatalogCache;
@@ -203,6 +271,9 @@ function buildCssCatalog() {
203
271
  // No course directory — framework-only mode
204
272
  }
205
273
 
274
+ // Resolve design tokens so declarations show real values
275
+ const tokenMap = buildTokenMap();
276
+
206
277
  const categories = {};
207
278
  let totalClasses = 0;
208
279
 
@@ -218,7 +289,7 @@ function buildCssCatalog() {
218
289
  const root = postcss.parse(source, { from: file });
219
290
  const classes = {};
220
291
 
221
- extractClassCatalog(root, classes);
292
+ extractClassCatalog(root, classes, tokenMap);
222
293
 
223
294
  if (Object.keys(classes).length > 0) {
224
295
  categories[category] = {
@@ -239,8 +310,9 @@ function buildCssCatalog() {
239
310
  /**
240
311
  * Extract class names with abbreviated declarations from PostCSS nodes.
241
312
  * Walks the AST and builds { className: "shortDescription" } entries.
313
+ * Token values are resolved so AI sees "1rem" instead of "var(--space-4)".
242
314
  */
243
- function extractClassCatalog(node, classes) {
315
+ function extractClassCatalog(node, classes, tokenMap) {
244
316
  if (node.type === 'rule' && node.selector) {
245
317
  // Only process simple class selectors (e.g., .foo, .foo-bar)
246
318
  // Skip compound selectors, pseudo-classes, nested selectors
@@ -255,11 +327,12 @@ function extractClassCatalog(node, classes) {
255
327
  const className = simpleClassMatch[1];
256
328
  if (classes[className]) continue; // Already captured
257
329
 
258
- // Build short description from declarations
330
+ // Build short description from declarations, resolving token values
259
331
  const decls = [];
260
332
  node.walk(child => {
261
333
  if (child.type === 'decl') {
262
- decls.push(`${child.prop}: ${child.value}`);
334
+ const resolved = resolveVarRefs(`${child.prop}: ${child.value}`, tokenMap);
335
+ decls.push(resolved);
263
336
  }
264
337
  });
265
338
 
@@ -276,34 +349,154 @@ function extractClassCatalog(node, classes) {
276
349
  // Recurse into @media, @supports, etc.
277
350
  if (node.nodes) {
278
351
  for (const child of node.nodes) {
279
- extractClassCatalog(child, classes);
352
+ extractClassCatalog(child, classes, tokenMap);
280
353
  }
281
354
  }
282
355
  }
283
356
 
357
+ // Internal categories — framework-managed CSS that authors don't write manually.
358
+ // Filtered from TOC and search by default; always accessible via direct category drill-in.
359
+ const INTERNAL_CATEGORY_PREFIXES = ['interactions/'];
360
+ const INTERNAL_CATEGORIES = new Set([
361
+ 'accessibility',
362
+ 'components/assessments',
363
+ 'components/audio-player',
364
+ 'components/document-gallery',
365
+ 'components/embed-frame',
366
+ 'components/engagement',
367
+ 'components/footer',
368
+ 'components/notifications',
369
+ 'components/sidebar',
370
+ 'components/spinner',
371
+ ]);
372
+
373
+ function isInternalCategory(category) {
374
+ if (INTERNAL_CATEGORY_PREFIXES.some(p => category.startsWith(p))) return true;
375
+ return INTERNAL_CATEGORIES.has(category);
376
+ }
377
+
378
+ // Brief descriptions for each author-facing category.
379
+ // Shown in TOC mode so AI can navigate without drilling into every category.
380
+ const CATEGORY_HINTS = {
381
+ 'layout': 'Columns, splits, stacks, content widths (columns-*, split-*, content-*)',
382
+ 'utilities/spacing': 'Margin (m-*), padding (p-*), gap (gap-*)',
383
+ 'utilities/colors': 'Text (text-*) and background (bg-*) colors',
384
+ 'utilities/typography': 'Font size, weight, line-height, alignment (text-sm, font-bold)',
385
+ 'utilities/display': 'Display mode, overflow, position (d-flex, d-grid, d-none)',
386
+ 'utilities/flexbox': 'Flex direction, alignment, wrapping, gap (flex-row, justify-center)',
387
+ 'utilities/grid': 'CSS Grid columns, rows, spans',
388
+ 'utilities/borders': 'Border width, radius, style (rounded-*, border-*)',
389
+ 'utilities/animations': 'Transitions, entrance animations (fade-in, slide-up)',
390
+ 'utilities/lists': 'List type, spacing, marker styles',
391
+ 'utilities/visibility': 'Show/hide, opacity (visible, hidden, sr-only)',
392
+ 'utilities/icons': 'Icon sizing, colors, alignment in text (icon-sm, icon-primary)',
393
+ 'utilities/decorative': 'Dividers, gradients, shadows, overlays',
394
+ 'utilities/tables': 'Table layout and cell utilities',
395
+ 'utilities/container': 'Container width constraints',
396
+ 'utilities/accessibility-utils': 'Screen reader, focus, skip-link helpers',
397
+ 'components/cards': 'Card containers with headers, bodies, footers (card, card-*)',
398
+ 'components/callouts': 'Info/warning/tip/danger callout boxes (callout, callout-*)',
399
+ 'components/hero': 'Full-width hero banner sections',
400
+ 'components/images': 'Image sizing, grids, rounded, shadow, captions (image-*)',
401
+ 'components/tables': 'Table striping, borders, hover, compact variants',
402
+ 'components/badges': 'Inline badge/label indicators (badge, badge-*)',
403
+ 'components/buttons': 'Button variants and sizes (btn, btn-*)',
404
+ 'components/tabs': 'Tab list and panel styling',
405
+ 'components/accordions': 'Accordion panel structure and states',
406
+ 'components/carousel': 'Carousel slide navigation',
407
+ 'components/breadcrumbs': 'Breadcrumb path navigation',
408
+ 'components/modals': 'Modal dialog overlays',
409
+ 'components/tooltip': 'Hover tooltip styling',
410
+ 'components/flip-cards': 'Front/back flip card containers',
411
+ 'components/slide-header': 'Slide title and subtitle styling',
412
+ 'components/forms': 'Form inputs, labels, validation',
413
+ 'components/toggle': 'Toggle switch controls',
414
+ 'components/checkbox-group': 'Grouped checkbox layouts',
415
+ 'components/dropdown': 'Dropdown select menus',
416
+ 'components/collapse': 'Collapsible content panels',
417
+ 'components/lightbox': 'Fullscreen image viewer',
418
+ 'components/video-player': 'Embedded video player',
419
+ };
420
+
284
421
  /**
285
- * Get CSS catalog — compact list or full detail for one category.
286
- * @param {string} [filterCategory] - If provided, return detail for this category only
422
+ * Get CSS catalog — three access modes:
423
+ * 1. No args: compact TOC (category names + class counts)
424
+ * 2. category: full class list with declarations for one category
425
+ * 3. search: find classes by name across all categories
426
+ *
427
+ * Internal categories (interactions, accessibility, app shell) are hidden by default.
428
+ * Set includeInternal: true to show them in TOC and search.
429
+ * Direct category access always works regardless of includeInternal.
430
+ *
431
+ * @param {Object} [options]
432
+ * @param {string} [options.category] - Return full detail for this category
433
+ * @param {string} [options.search] - Search class names (substring match)
434
+ * @param {boolean} [options.includeInternal] - Include framework-internal categories (default: false)
287
435
  */
288
- export function getCssCatalog(filterCategory) {
436
+ export function getCssCatalog({ category, search, includeInternal = false } = {}) {
289
437
  const catalog = buildCssCatalog();
290
438
 
291
- if (filterCategory) {
292
- const cat = catalog.categories[filterCategory];
439
+ // Mode: Category detail — always works, even for internal categories
440
+ if (category) {
441
+ const cat = catalog.categories[category];
293
442
  if (!cat) {
443
+ const available = Object.keys(catalog.categories)
444
+ .filter(c => includeInternal || !isInternalCategory(c))
445
+ .sort();
294
446
  return {
295
- error: `Unknown category: '${filterCategory}'`,
296
- available: Object.keys(catalog.categories).sort()
447
+ error: `Unknown category: '${category}'`,
448
+ available
297
449
  };
298
450
  }
299
- return { category: filterCategory, ...cat };
451
+ return { category, ...cat };
452
+ }
453
+
454
+ // Mode: Search — case-insensitive substring match on class names
455
+ if (search) {
456
+ const query = search.toLowerCase();
457
+ const results = [];
458
+ for (const [cat, data] of Object.entries(catalog.categories)) {
459
+ if (!includeInternal && isInternalCategory(cat)) continue;
460
+ for (const [cls, declarations] of Object.entries(data.classes)) {
461
+ if (cls.toLowerCase().includes(query)) {
462
+ results.push({ class: cls, category: cat, declarations });
463
+ }
464
+ }
465
+ }
466
+ const capped = results.length > 50;
467
+ return {
468
+ query: search,
469
+ results: results.slice(0, 50),
470
+ count: results.length,
471
+ capped,
472
+ message: capped
473
+ ? `Showing 50 of ${results.length} matches. Narrow your search or use 'category' to browse.`
474
+ : `${results.length} classes matching '${search}'.`
475
+ };
476
+ }
477
+
478
+ // Mode: TOC — category names, class counts, and hints
479
+ const categories = {};
480
+ let totalClasses = 0;
481
+ let filteredClasses = 0;
482
+ for (const [cat, data] of Object.entries(catalog.categories)) {
483
+ const count = Object.keys(data.classes).length;
484
+ if (!includeInternal && isInternalCategory(cat)) {
485
+ filteredClasses += count;
486
+ continue;
487
+ }
488
+ const entry = { count };
489
+ if (CATEGORY_HINTS[cat]) entry.hint = CATEGORY_HINTS[cat];
490
+ categories[cat] = entry;
491
+ totalClasses += count;
300
492
  }
301
493
 
302
494
  return {
303
- categories: catalog.categories,
304
- totalClasses: catalog.totalClasses,
305
- categoryCount: Object.keys(catalog.categories).length,
306
- message: `${catalog.totalClasses} CSS classes across ${Object.keys(catalog.categories).length} categories. Pass 'category' for full detail.`
495
+ categories,
496
+ totalClasses,
497
+ categoryCount: Object.keys(categories).length,
498
+ message: `${totalClasses} CSS classes across ${Object.keys(categories).length} categories. Use 'category' for full class list or 'search' to find classes by name.`
499
+ + (filteredClasses > 0 ? ` (${filteredClasses} internal classes hidden — use includeInternal: true to show)` : '')
307
500
  };
308
501
  }
309
502
 
@@ -1139,6 +1139,10 @@ export async function getContentExport(options = {}) {
1139
1139
  const fullCoursePath = validateProject(coursePath, true);
1140
1140
  if (!fullCoursePath) return null;
1141
1141
 
1142
+ // Size guard threshold — prevent unbounded context consumption.
1143
+ // Only applies to full-course exports (no slides filter).
1144
+ const MAX_UNFILTERED_SIZE = 40 * 1024; // 40KB
1145
+
1142
1146
  try {
1143
1147
  const config = await loadCourseConfig(fullCoursePath);
1144
1148
 
@@ -1186,6 +1190,11 @@ export async function getContentExport(options = {}) {
1186
1190
  md += formatBranding(config);
1187
1191
  md += formatFeatures(config);
1188
1192
 
1193
+ // Size guard: if full-course export exceeds threshold, return summary instead
1194
+ if (!slides && md.length > MAX_UNFILTERED_SIZE) {
1195
+ return buildExportSummary(config, structure, fullCoursePath, md.length, exportOptions);
1196
+ }
1197
+
1189
1198
  return md;
1190
1199
  } catch (error) {
1191
1200
  console.error('Failed to generate content export:', error.message);
@@ -1193,6 +1202,49 @@ export async function getContentExport(options = {}) {
1193
1202
  }
1194
1203
  }
1195
1204
 
1205
+ /**
1206
+ * Build a compact summary when full export exceeds size threshold.
1207
+ * Returns structure overview + per-slide sizes so AI can scope targeted exports.
1208
+ */
1209
+ function buildExportSummary(config, structure, coursePath, totalSize, exportOptions) {
1210
+ const meta = config.metadata || {};
1211
+ let summary = `# ${meta.title || 'Untitled Course'} — Export Summary\n\n`;
1212
+ summary += `> Full export is ${(totalSize / 1024).toFixed(0)}KB — too large for context. Use the \`slides\` parameter to export specific slides.\n\n`;
1213
+
1214
+ summary += '## Course Structure\n\n';
1215
+ summary += generateStructureOverview(structure);
1216
+ summary += '\n\n';
1217
+
1218
+ // Per-slide size estimates
1219
+ summary += '## Slide Sizes\n\n';
1220
+ summary += '| Slide ID | Title | Size |\n';
1221
+ summary += '|----------|-------|------|\n';
1222
+
1223
+ function measureItems(items) {
1224
+ for (const item of items) {
1225
+ if (item.type === 'section') {
1226
+ measureItems(item.children || []);
1227
+ } else {
1228
+ let content = '';
1229
+ if (item.type === 'slide') {
1230
+ content = formatSlide(item, coursePath, exportOptions);
1231
+ } else if (item.type === 'assessment') {
1232
+ content = formatAssessment(item, coursePath, exportOptions);
1233
+ }
1234
+ const sizeKB = (content.length / 1024).toFixed(1);
1235
+ const title = item.title || item.menu?.label || item.id;
1236
+ summary += `| \`${item.id}\` | ${title} | ${sizeKB}KB |\n`;
1237
+ }
1238
+ }
1239
+ }
1240
+
1241
+ measureItems(structure);
1242
+ summary += `\n**Total:** ${(totalSize / 1024).toFixed(0)}KB across all slides\n`;
1243
+ summary += '\nUse `slides: "slide-id-1,slide-id-2"` to export specific slides, or `interactionsOnly: true` for just Q&A.\n';
1244
+
1245
+ return summary;
1246
+ }
1247
+
1196
1248
  /**
1197
1249
  * Generate JSON output
1198
1250
  */
@@ -39,6 +39,10 @@ Requires preview server to be running.`,
39
39
  type: 'object',
40
40
  properties: {},
41
41
  required: []
42
+ },
43
+ annotations: {
44
+ readOnlyHint: true,
45
+ idempotentHint: true
42
46
  }
43
47
  },
44
48
  {
@@ -71,6 +75,9 @@ Requires preview server to be running.`,
71
75
  }
72
76
  },
73
77
  required: ['slideId']
78
+ },
79
+ annotations: {
80
+ idempotentHint: true
74
81
  }
75
82
  },
76
83
  {
@@ -102,6 +109,9 @@ Requires preview server to be running.`,
102
109
  }
103
110
  },
104
111
  required: ['interactionId', 'response']
112
+ },
113
+ annotations: {
114
+ destructiveHint: false
105
115
  }
106
116
  },
107
117
  {
@@ -115,6 +125,10 @@ Requires preview server to be running.`,
115
125
  type: 'object',
116
126
  properties: {},
117
127
  required: []
128
+ },
129
+ annotations: {
130
+ destructiveHint: true,
131
+ idempotentHint: true
118
132
  }
119
133
  },
120
134
  {
@@ -153,6 +167,10 @@ Requires preview server to be running.`,
153
167
  }
154
168
  },
155
169
  required: []
170
+ },
171
+ annotations: {
172
+ readOnlyHint: true,
173
+ idempotentHint: true
156
174
  }
157
175
  },
158
176
  {
@@ -188,9 +206,11 @@ Requires preview server to be running.`,
188
206
  }
189
207
  },
190
208
  required: []
209
+ },
210
+ annotations: {
211
+ idempotentHint: true
191
212
  }
192
213
  },
193
- // --- Workflow & Build Tools ---
194
214
  {
195
215
  name: 'coursecode_workflow_status',
196
216
  description: `Detect the current authoring stage and get stage-specific instructions.
@@ -215,6 +235,10 @@ Call this after completing a major milestone to get updated guidance for the nex
215
235
  type: 'object',
216
236
  properties: {},
217
237
  required: []
238
+ },
239
+ annotations: {
240
+ readOnlyHint: true,
241
+ idempotentHint: true
218
242
  }
219
243
  },
220
244
  {
@@ -240,27 +264,45 @@ Use in Stage 5 when the course is ready for export.`,
240
264
  }
241
265
  },
242
266
  required: []
267
+ },
268
+ annotations: {
269
+ idempotentHint: true
243
270
  }
244
271
  },
245
- // --- Catalog & Validation Tools (filesystem, no preview needed) ---
246
272
  {
247
273
  name: 'coursecode_css_catalog',
248
- description: `Get CSS class information extracted from real CSS source files.
274
+ description: `Browse or search CSS classes available for slide authoring.
249
275
 
250
- Without 'category': returns all classes grouped by category with abbreviated declarations.
251
- With 'category': returns full detail for that category only.
276
+ Three modes:
277
+ - No args: compact table of contents (category names + class counts)
278
+ - category: full class list with abbreviated declarations for one category
279
+ - search: find classes by name across all categories
252
280
 
253
- Categories are derived from CSS file paths (e.g., "utilities/borders", "layout", "patterns").
254
- Use to discover available CSS classes before authoring slides. Lint catches invalid classes.`,
281
+ Author-facing categories (layout, utilities, common components) shown by default.
282
+ Framework-internal categories (interactions, accessibility, app shell) hidden set includeInternal: true to include them.
283
+
284
+ Use to discover available CSS classes before authoring slides. Lint catches invalid classes with fix suggestions.`,
255
285
  inputSchema: {
256
286
  type: 'object',
257
287
  properties: {
258
288
  category: {
259
289
  type: 'string',
260
- description: 'Optional category to get full details for (e.g., "utilities/colors", "layout", "patterns")'
290
+ description: 'Get full class details for this category (e.g., "utilities/spacing", "layout", "components/cards")'
291
+ },
292
+ search: {
293
+ type: 'string',
294
+ description: 'Search class names (e.g., "gap", "card", "text-"). Returns matching classes with declarations.'
295
+ },
296
+ includeInternal: {
297
+ type: 'boolean',
298
+ description: 'Include framework-internal categories (interactions, accessibility, app shell). Default: false.'
261
299
  }
262
300
  },
263
301
  required: []
302
+ },
303
+ annotations: {
304
+ readOnlyHint: true,
305
+ idempotentHint: true
264
306
  }
265
307
  },
266
308
  {
@@ -281,6 +323,10 @@ Use to discover available components before authoring slides.`,
281
323
  }
282
324
  },
283
325
  required: []
326
+ },
327
+ annotations: {
328
+ readOnlyHint: true,
329
+ idempotentHint: true
284
330
  }
285
331
  },
286
332
  {
@@ -301,20 +347,22 @@ Use to discover available interactions before creating assessments.`,
301
347
  }
302
348
  },
303
349
  required: []
350
+ },
351
+ annotations: {
352
+ readOnlyHint: true,
353
+ idempotentHint: true
304
354
  }
305
355
  },
306
-
307
356
  {
308
357
  name: 'coursecode_lint',
309
358
  description: `Run the course linter and get structured results.
310
359
 
311
- Always runs build-time lint (config, CSS classes, structure). When the preview server is running and the headless browser is connected, also includes runtime errors from the preview server (same errors shown in coursecode_state and the debug panel Errors tab).
360
+ Always runs build-time lint (config, CSS classes, structure). Does NOT include runtime errors use coursecode_state for runtime errors and contrast warnings when the preview server is running.
312
361
 
313
362
  Returns:
314
- - errors: [{slideId?, rule, message, severity, source?, hint?}]
315
- - warnings: [{slideId?, rule, message, severity, source?, class?, suggestion?, hint?}]
363
+ - errors: [{slideId?, rule, message, severity, hint?}]
364
+ - warnings: [{slideId?, rule, message, severity, hint?}]
316
365
  - passed: boolean
317
- - runtimeLintIncluded: boolean (true when runtime errors were included)
318
366
 
319
367
  Build-time rules (always checked):
320
368
  - undefined-css-class: hallucinated or stale class names (with fix suggestions)
@@ -325,23 +373,15 @@ Build-time rules (always checked):
325
373
  - assessment-id-mismatch: config ID doesn't match assessment ID
326
374
  - invalid-gating: bad gating condition configuration
327
375
 
328
- Runtime errors (included when preview is running, source='runtime'):
329
- - LMS API misuse (GetValue before Initialize, SetValue after Terminate, etc.)
330
- - Console errors and warnings from the course
331
- - Uncaught exceptions and unhandled promise rejections
332
- - Suspend data size warnings
333
-
334
- Suppression: Add data-lint-ignore to any HTML element to suppress warnings for it and children.
335
- data-lint-ignore — suppress all warnings
336
- data-lint-ignore="spacing" — suppress only spacing warnings
337
- data-lint-ignore="spacing,contrast" — suppress multiple categories
338
- Categories: spacing, contrast, target-size, proximity, overlap, list-style, css-class
339
-
340
376
  Use AFTER making changes to validate the course.`,
341
377
  inputSchema: {
342
378
  type: 'object',
343
379
  properties: {},
344
380
  required: []
381
+ },
382
+ annotations: {
383
+ readOnlyHint: true,
384
+ idempotentHint: true
345
385
  }
346
386
  },
347
387
  {
@@ -361,6 +401,10 @@ Use to discover available icons before authoring slides or configuring menus.`,
361
401
  }
362
402
  },
363
403
  required: []
404
+ },
405
+ annotations: {
406
+ readOnlyHint: true,
407
+ idempotentHint: true
364
408
  }
365
409
  },
366
410
  {
@@ -381,6 +425,8 @@ Filtering options keep output manageable:
381
425
  - excludeInteractions: content only, no Q&A
382
426
  - format: 'md' (default) or 'json' for structured data
383
427
 
428
+ For large courses (>40KB), full exports automatically return a summary with per-slide sizes instead. Use the slides parameter to export specific content.
429
+
384
430
  Does not require preview server.`,
385
431
  inputSchema: {
386
432
  type: 'object',
@@ -416,6 +462,10 @@ Does not require preview server.`,
416
462
  }
417
463
  },
418
464
  required: []
465
+ },
466
+ annotations: {
467
+ readOnlyHint: true,
468
+ idempotentHint: true
419
469
  }
420
470
  },
421
471
  ];
package/lib/mcp-server.js CHANGED
@@ -105,14 +105,23 @@ export async function startMcpServer(options = {}) {
105
105
  lmsState: api.getLmsState()
106
106
  };
107
107
  });
108
- // Read last 20 API log entries from parent window (stub player diagnostic)
109
- result.apiLog = await headless.evaluateParent(() => {
110
- return window._stubPlayerState?.apiLog?.slice(0, 20) || [];
111
- });
112
- // Read error log from parent window (stub player diagnostic)
113
- result.errors = await headless.evaluateParent(() => {
114
- return window._stubPlayerState?.errorLog || [];
115
- });
108
+ // Read API log and error log from preview server (same data user sees in debug panel)
109
+ try {
110
+ const [logResp, errResp] = await Promise.all([
111
+ fetch(`http://localhost:${port}/__lms/log`),
112
+ fetch(`http://localhost:${port}/__lms/errors`)
113
+ ]);
114
+ result.apiLog = logResp.ok ? (await logResp.json()).entries?.slice(0, 20) || [] : [];
115
+ if (errResp.ok) {
116
+ const errData = await errResp.json();
117
+ result.errors = [...(errData.errors || []), ...(errData.warnings || [])];
118
+ } else {
119
+ result.errors = [];
120
+ }
121
+ } catch {
122
+ result.apiLog = [];
123
+ result.errors = [];
124
+ }
116
125
  // Append console errors/warnings captured from the page
117
126
  result.consoleLogs = headless.getConsoleLogs();
118
127
  break;
@@ -233,7 +242,7 @@ export async function startMcpServer(options = {}) {
233
242
 
234
243
  // === Catalog & validation tools (filesystem, no preview needed) ===
235
244
  case 'coursecode_css_catalog':
236
- result = getCssCatalog(args?.category);
245
+ result = getCssCatalog(args || {});
237
246
  break;
238
247
 
239
248
  case 'coursecode_component_catalog':
@@ -247,48 +256,6 @@ export async function startMcpServer(options = {}) {
247
256
  break;
248
257
  case 'coursecode_lint':
249
258
  result = await lintCourse();
250
- // Merge runtime errors if headless browser is already connected
251
- if (headless.isRunning()) {
252
- try {
253
- const runtimeErrors = await headless.evaluateParent(() => {
254
- return window._stubPlayerState?.errorLog || [];
255
- });
256
- result.runtimeLintIncluded = true;
257
- if (runtimeErrors.length > 0) {
258
- const runtimeWarnings = runtimeErrors.filter(e => e.isWarning);
259
- const runtimeErrs = runtimeErrors.filter(e => !e.isWarning);
260
- if (runtimeWarnings.length > 0) {
261
- result.warnings = (result.warnings || []).concat(
262
- runtimeWarnings.map(e => ({
263
- severity: 'warning',
264
- type: e.type,
265
- message: e.message,
266
- hint: e.hint,
267
- rule: 'runtime-error',
268
- source: 'runtime'
269
- }))
270
- );
271
- result.warningCount = result.warnings.length;
272
- }
273
- if (runtimeErrs.length > 0) {
274
- result.errors = (result.errors || []).concat(
275
- runtimeErrs.map(e => ({
276
- severity: 'error',
277
- type: e.type,
278
- message: e.message,
279
- hint: e.hint,
280
- rule: 'runtime-error',
281
- source: 'runtime'
282
- }))
283
- );
284
- result.errorCount = result.errors.length;
285
- result.passed = result.errors.length === 0;
286
- }
287
- }
288
- } catch {
289
- // Headless browser may have disconnected — non-fatal, build lint still valid
290
- }
291
- }
292
259
  break;
293
260
 
294
261
  case 'coursecode_export_content':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.26",
3
+ "version": "0.1.28",
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": {