coursecode 0.1.27 → 0.1.29

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.
@@ -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,9 +347,12 @@ 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.
@@ -329,6 +378,10 @@ Use AFTER making changes to validate the course.`,
329
378
  type: 'object',
330
379
  properties: {},
331
380
  required: []
381
+ },
382
+ annotations: {
383
+ readOnlyHint: true,
384
+ idempotentHint: true
332
385
  }
333
386
  },
334
387
  {
@@ -348,6 +401,10 @@ Use to discover available icons before authoring slides or configuring menus.`,
348
401
  }
349
402
  },
350
403
  required: []
404
+ },
405
+ annotations: {
406
+ readOnlyHint: true,
407
+ idempotentHint: true
351
408
  }
352
409
  },
353
410
  {
@@ -368,6 +425,8 @@ Filtering options keep output manageable:
368
425
  - excludeInteractions: content only, no Q&A
369
426
  - format: 'md' (default) or 'json' for structured data
370
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
+
371
430
  Does not require preview server.`,
372
431
  inputSchema: {
373
432
  type: 'object',
@@ -403,6 +462,10 @@ Does not require preview server.`,
403
462
  }
404
463
  },
405
464
  required: []
465
+ },
466
+ annotations: {
467
+ readOnlyHint: true,
468
+ idempotentHint: true
406
469
  }
407
470
  },
408
471
  ];
package/lib/mcp-server.js CHANGED
@@ -242,7 +242,7 @@ export async function startMcpServer(options = {}) {
242
242
 
243
243
  // === Catalog & validation tools (filesystem, no preview needed) ===
244
244
  case 'coursecode_css_catalog':
245
- result = getCssCatalog(args?.category);
245
+ result = getCssCatalog(args || {});
246
246
  break;
247
247
 
248
248
  case 'coursecode_component_catalog':
@@ -41,6 +41,7 @@ export function createOutlineModeHandlers(context) {
41
41
  let isVisible = false;
42
42
  let courseLoaded = false;
43
43
  let viewingStage = null; // Which stage the dashboard is showing
44
+ const calloutDismissed = localStorage.getItem('coursecode-walkthroughCalloutDismissed') === 'true';
44
45
 
45
46
  async function checkStage() {
46
47
  const params = new URLSearchParams(window.location.search);
@@ -133,6 +134,9 @@ export function createOutlineModeHandlers(context) {
133
134
  // Stage stepper
134
135
  parts.push(renderStepper(detectedStage, viewingStage));
135
136
 
137
+ // Walkthrough callout (dismissable, shown below stepper)
138
+ parts.push(renderWalkthroughCallout());
139
+
136
140
  // Stage header — contextual button text
137
141
  const hasSlides = stageData.checklist?.hasSlides;
138
142
  let skipLabel;
@@ -511,6 +515,39 @@ export function createOutlineModeHandlers(context) {
511
515
  `;
512
516
  }
513
517
 
518
+ // ── Walkthrough Callout ───────────────────────────────────
519
+
520
+ function renderWalkthroughCallout() {
521
+ if (calloutDismissed) return '';
522
+
523
+ const hasSlides = stageData?.checklist?.hasSlides;
524
+ let skipLabel;
525
+ if (courseLoaded) {
526
+ skipLabel = '← Back to Course';
527
+ } else if (hasSlides) {
528
+ skipLabel = 'View Course ▸';
529
+ } else {
530
+ skipLabel = 'Skip to Course ▸';
531
+ }
532
+
533
+ return `
534
+ <div class="outline-walkthrough-callout" id="outline-walkthrough-callout">
535
+ <div class="outline-callout-content">
536
+ <div class="outline-callout-icon">💡</div>
537
+ <div class="outline-callout-text">
538
+ <strong>This is an optional walkthrough.</strong>
539
+ It guides you through building a course step-by-step, but you can skip it at any time.
540
+ Close with the <span class="outline-callout-kbd">✕</span> in the top-right corner, or return anytime from the course preview toolbar.
541
+ </div>
542
+ </div>
543
+ <div class="outline-callout-actions">
544
+ <button id="outline-callout-skip-btn" class="outline-callout-skip-btn">${skipLabel}</button>
545
+ <button id="outline-callout-dismiss-btn" class="outline-callout-dismiss-btn">Got it</button>
546
+ </div>
547
+ </div>
548
+ `;
549
+ }
550
+
514
551
  // ── Shared Components ────────────────────────────────────
515
552
 
516
553
  function renderChecklist() {
@@ -587,6 +624,17 @@ export function createOutlineModeHandlers(context) {
587
624
  // Skip / Back button
588
625
  document.getElementById('stub-player-skip-outline-btn')?.addEventListener('click', dismissDashboard);
589
626
 
627
+ // Walkthrough callout handlers
628
+ document.getElementById('outline-callout-skip-btn')?.addEventListener('click', dismissDashboard);
629
+ document.getElementById('outline-callout-dismiss-btn')?.addEventListener('click', () => {
630
+ localStorage.setItem('coursecode-walkthroughCalloutDismissed', 'true');
631
+ const callout = document.getElementById('outline-walkthrough-callout');
632
+ if (callout) {
633
+ callout.classList.add('dismissing');
634
+ callout.addEventListener('animationend', () => callout.remove(), { once: true });
635
+ }
636
+ });
637
+
590
638
  // Stepper clicks
591
639
  outlineContent.querySelectorAll('.stepper-step').forEach(btn => {
592
640
  btn.addEventListener('click', () => {
@@ -147,6 +147,116 @@
147
147
  margin-top: 20px;
148
148
  }
149
149
 
150
+ /* ── Walkthrough Callout ────────────────── */
151
+
152
+ .outline-walkthrough-callout {
153
+ margin-bottom: 24px;
154
+ padding: 16px 20px;
155
+ background: rgba(74, 111, 165, 0.1);
156
+ border: 1px solid rgba(74, 111, 165, 0.25);
157
+ border-left: 3px solid var(--color-info);
158
+ border-radius: 8px;
159
+ animation: callout-slide-in 0.3s ease-out;
160
+ }
161
+
162
+ @keyframes callout-slide-in {
163
+ from { opacity: 0; transform: translateY(-8px); }
164
+ to { opacity: 1; transform: translateY(0); }
165
+ }
166
+
167
+ .outline-walkthrough-callout.dismissing {
168
+ animation: callout-slide-out 0.25s ease-in forwards;
169
+ }
170
+
171
+ @keyframes callout-slide-out {
172
+ from { opacity: 1; transform: translateY(0); max-height: 200px; margin-bottom: 24px; }
173
+ to { opacity: 0; transform: translateY(-8px); max-height: 0; margin-bottom: 0; padding: 0 20px; overflow: hidden; }
174
+ }
175
+
176
+ .outline-callout-content {
177
+ display: flex;
178
+ gap: 12px;
179
+ align-items: flex-start;
180
+ }
181
+
182
+ .outline-callout-icon {
183
+ font-size: 20px;
184
+ line-height: 1;
185
+ flex-shrink: 0;
186
+ margin-top: 1px;
187
+ }
188
+
189
+ .outline-callout-text {
190
+ font-size: 13px;
191
+ line-height: 1.6;
192
+ color: var(--color-gray-400);
193
+ }
194
+
195
+ .outline-callout-text strong {
196
+ color: var(--color-white);
197
+ }
198
+
199
+ .outline-callout-kbd {
200
+ display: inline-flex;
201
+ align-items: center;
202
+ justify-content: center;
203
+ width: 18px;
204
+ height: 18px;
205
+ background: rgba(0, 0, 0, 0.3);
206
+ border: 1px solid var(--color-primary-panel);
207
+ border-radius: 4px;
208
+ font-size: 10px;
209
+ color: var(--color-gray-400);
210
+ vertical-align: middle;
211
+ line-height: 1;
212
+ }
213
+
214
+ .outline-callout-actions {
215
+ display: flex;
216
+ gap: 8px;
217
+ margin-top: 14px;
218
+ padding-left: 32px;
219
+ }
220
+
221
+ .outline-callout-skip-btn {
222
+ background: var(--color-info);
223
+ color: var(--color-white);
224
+ border: none;
225
+ padding: 7px 16px;
226
+ border-radius: 6px;
227
+ font-size: 12px;
228
+ font-weight: 600;
229
+ cursor: pointer;
230
+ transition: background 0.15s, transform 0.1s;
231
+ }
232
+
233
+ .outline-callout-skip-btn:hover {
234
+ background: color-mix(in srgb, var(--color-info) 80%, var(--color-white));
235
+ transform: translateY(-1px);
236
+ }
237
+
238
+ .outline-callout-skip-btn:active {
239
+ transform: translateY(0);
240
+ }
241
+
242
+ .outline-callout-dismiss-btn {
243
+ background: none;
244
+ color: var(--color-gray-600);
245
+ border: 1px solid var(--color-primary-panel);
246
+ padding: 7px 14px;
247
+ border-radius: 6px;
248
+ font-size: 12px;
249
+ font-weight: 500;
250
+ cursor: pointer;
251
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
252
+ }
253
+
254
+ .outline-callout-dismiss-btn:hover {
255
+ background: var(--color-primary-panel);
256
+ color: var(--color-gray-200);
257
+ border-color: var(--color-info);
258
+ }
259
+
150
260
  /* ── Stage Header ───────────────────────── */
151
261
 
152
262
  .outline-stage-header {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecode",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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": {