coursecast 0.2.4 → 0.3.0

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
@@ -34,21 +34,22 @@ The engine is what writes the content. Pick with `--engine`:
34
34
 
35
35
  | Engine | Runs on | Needs |
36
36
  |---|---|---|
37
- | `claude-code` *(default)* | your [Claude Code](https://claude.com/claude-code) CLI | `claude` on PATH |
38
- | `codex` | your Codex CLI | `codex` on PATH |
39
- | `gemini` | Google Gemini API, search-grounded | `GEMINI_API_KEY` ([free key](https://aistudio.google.com/apikey)) |
37
+ | `claude` *(default)* | your [Claude Code](https://claude.com/claude-code) CLI | `claude` on PATH |
38
+ | `codex` | your [Codex](https://developers.openai.com/codex/cli/) CLI | `codex` on PATH |
39
+ | `gemini-cli` | your [Gemini CLI](https://github.com/google-gemini/gemini-cli) login | `gemini` on PATH — **no API key** |
40
+ | `gemini` | the [Gemini API](https://ai.google.dev/), search-grounded | `GEMINI_API_KEY` ([free key](https://aistudio.google.com/apikey)) + your `--model` choice |
40
41
 
41
42
  ```bash
42
- coursecast "learn rust" --engine gemini
43
+ coursecast "learn rust" --engine gemini --model gemini-3.5-flash-lite
43
44
  ```
44
45
 
45
- Gemini extras: `GEMINI_MODEL` overrides the model (default `gemini-3.5-flash-lite`);
46
- `COURSECAST_RESEARCH=0` skips the web-search research pass (faster, cheaper, less current).
46
+ The gemini engine never picks a model for you — pass `--model` (or set `GEMINI_MODEL`) with
47
+ whatever your key has access to. `COURSECAST_RESEARCH=0` skips the web-search research pass.
47
48
 
48
49
  ## All flags
49
50
 
50
51
  ```
51
- --engine <id> claude-code (default) | gemini | codex
52
+ --engine <id> claude (default) | gemini | codex
52
53
  --model <id> model hint passed to the engine
53
54
  --storage <id> progress storage: local (default) | cloud
54
55
  --provider <id> deploy target: vercel (default) | netlify
@@ -24,8 +24,8 @@ USAGE
24
24
  coursecast "<topic>" [options]
25
25
 
26
26
  OPTIONS
27
- --engine <id> generation engine: claude-code (default), gemini, codex, stub
28
- --model <id> model hint passed to the engine (e.g. gemini-3.5-flash-lite)
27
+ --engine <id> generation engine: claude (default), gemini, gemini-cli, codex, stub
28
+ --model <id> model to use (required for gemini, e.g. gemini-3.5-flash-lite)
29
29
  --storage <id> progress storage: local (default, no setup), cloud (sync across devices)
30
30
  --provider <id> deploy target: vercel | netlify (auto-detects what you have installed)
31
31
  --lessons <n> target number of lessons (default ~12)
@@ -37,8 +37,9 @@ OPTIONS
37
37
  -h, --help show this help
38
38
 
39
39
  EXAMPLES
40
- coursecast "kubernetes basics" # default engine (claude-code)
41
- coursecast "how DNS works" --engine gemini # cheap, search-grounded
40
+ coursecast "kubernetes basics" # default engine (claude)
41
+ coursecast "how DNS works" --engine gemini --model gemini-3.5-flash-lite
42
+ coursecast "how DNS works" --engine gemini-cli # no API key — uses your Gemini CLI login
42
43
  coursecast "intro to nuclear power" --deploy # generate + ship to your Vercel
43
44
  `);
44
45
  }
@@ -56,7 +57,7 @@ async function main() {
56
57
  process.exit(1);
57
58
  }
58
59
 
59
- const engine = getFlag('engine', 'claude-code');
60
+ const engine = getFlag('engine', 'claude');
60
61
  const providerFlag = getFlag('provider', undefined);
61
62
  const model = getFlag('model', undefined);
62
63
  const storage = getFlag('storage', 'local');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecast",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "description": "One prompt → a full interactive course — live widgets, quizzes, a mastery dashboard — rendered to self-contained HTML and deployed live.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,30 +1,38 @@
1
- // claude-code engine — drives the Claude Code CLI headlessly to generate real lesson content.
1
+ // claude engine — drives the Claude Code CLI headlessly to generate real lesson content.
2
2
  // Requires `claude` on PATH (https://claude.ai/code). Highest quality; reuses the same
3
3
  // research+authoring the /learn skill uses. Output is a lesson content object (see shared-prompts).
4
4
 
5
5
  import { spawn } from 'node:child_process'
6
- import { buildSyllabusPrompt, buildLessonPrompt, finalizeLesson, extractJsonObject } from './shared-prompts.mjs'
6
+ import {
7
+ buildSyllabusPrompt, buildLessonPrompt, extractJsonObject,
8
+ generateSyllabusWithRepair, generateLessonWithRepair,
9
+ } from './shared-prompts.mjs'
7
10
 
8
- export const name = 'claude-code'
11
+ export const name = 'claude'
9
12
 
10
13
  export async function generateSyllabus({ topic, lessons = 12, model }) {
11
- const prompt = buildSyllabusPrompt({ topic, lessons })
12
- const raw = await run('claude', model ? ['-p', prompt, '--output-format', 'json', '--model', model] : ['-p', prompt, '--output-format', 'json'])
13
- const obj = extractJsonObject(extractText(raw))
14
- if (!obj || !Array.isArray(obj.phases)) throw new Error('claude-code engine: could not parse a syllabus object')
15
- return obj
14
+ return generateSyllabusWithRepair({
15
+ call: callModel(model),
16
+ prompt: buildSyllabusPrompt({ topic, lessons }),
17
+ label: 'claude engine',
18
+ })
16
19
  }
17
20
 
18
21
  export async function generateLesson({ topic, slug, model, context }) {
19
- const prompt = buildLessonPrompt({ topic, slug, context })
20
- const args = ['-p', prompt, '--output-format', 'json']
21
- if (model) args.push('--model', model)
22
+ return generateLessonWithRepair({
23
+ call: callModel(model),
24
+ prompt: buildLessonPrompt({ topic, slug, context }),
25
+ slug, context, label: 'claude engine',
26
+ })
27
+ }
22
28
 
23
- const raw = await run('claude', args)
24
- // Claude Code --output-format json wraps the result; find the lesson JSON inside.
25
- const obj = extractJsonObject(extractText(raw))
26
- if (!obj || !obj.title) throw new Error('claude-code engine: could not parse a lesson object from output')
27
- return finalizeLesson(obj, slug, context)
29
+ // One text-in/text-out call through the local CLI, unwrapped from its JSON envelope.
30
+ function callModel(model) {
31
+ return async (prompt) => {
32
+ const args = ['-p', prompt, '--output-format', 'json']
33
+ if (model) args.push('--model', model)
34
+ return extractText(await run('claude', args))
35
+ }
28
36
  }
29
37
 
30
38
  function run(cmd, args) {
@@ -1,49 +1,41 @@
1
1
  // codex engine — drives the OpenAI Codex CLI headlessly to generate real content, so a
2
- // user with a Codex subscription can generate without a raw API key (mirrors claude-code).
3
- // Requires `codex` on PATH. Output is a lesson/syllabus content object (see schemas).
2
+ // user with a Codex subscription can generate without a raw API key (mirrors the claude engine).
3
+ // Requires `codex` on PATH. Output is a lesson/syllabus content object (see shared-prompts).
4
4
  //
5
5
  // Codex CLI non-interactive mode: `codex exec "<prompt>"` prints the model's final answer
6
6
  // to stdout. We ask for a bare JSON object and extract it.
7
+ //
8
+ // This engine used to carry its OWN copy of the schemas and prompts, which meant it silently
9
+ // missed every authoring rule added to the shared contract (valid-HTML rules, labelled form
10
+ // controls, correct quiz answer keys). It now uses shared-prompts like the other engines, so
11
+ // improving the prompt improves every engine at once.
7
12
 
8
13
  import { spawn } from 'node:child_process';
14
+ import {
15
+ buildSyllabusPrompt, buildLessonPrompt,
16
+ generateSyllabusWithRepair, generateLessonWithRepair,
17
+ } from './shared-prompts.mjs';
9
18
 
10
19
  export const name = 'codex';
11
20
 
12
- const LESSON_SCHEMA = `{
13
- "slug": string (kebab-case), "title": string, "courseTitle": string, "lessonLabel": string,
14
- "kicker": string, "lede": string (HTML ok), "tiles": [{ "big": string, "lbl": string, "note"?: string }],
15
- "analogy": { "tag": "Analogy", "text": string }, "sections": [{ "h2": string, "html": string }],
16
- "simulation": { "title": string, "sub": string, "html": string, "js": string },
17
- "widgets": [{ "type": "what-if"|"flashcards"|"reveal-diagram", "config": object }],
18
- "terminal": [{ "cmd": string, "explain": string }], "checklist": [{ "title": string, "desc": string, "hint"?: string }],
19
- "quiz": [{ "q": string, "opts": [string], "a": number, "fb": string }], "next": null, "footer": string
20
- }`;
21
-
22
- const SYLLABUS_SCHEMA = `{
23
- "courseTitle": string, "courseSubtitle": string,
24
- "phases": [ { "name": string, "lessons": [ { "stem": string (kebab-case, unique), "title": string, "desc": string } ] } ]
25
- }`;
21
+ const call = async (prompt) => run('codex', ['exec', prompt]);
26
22
 
27
23
  export async function generateSyllabus({ topic, lessons = 12 }) {
28
- const prompt = `Design a course syllabus for someone learning "${topic}". Output ONLY one JSON object, no prose, no fences, matching:\n${SYLLABUS_SCHEMA}\nAim for about ${lessons} lessons in 3-6 phases (foundations -> core -> advanced -> practice). Unique kebab-case stems.`;
29
- const obj = extractJsonObject(await run('codex', ['exec', prompt]));
30
- if (!obj || !Array.isArray(obj.phases)) throw new Error('codex engine: could not parse a syllabus object');
31
- return obj;
24
+ return generateSyllabusWithRepair({
25
+ call,
26
+ prompt: buildSyllabusPrompt({ topic, lessons }),
27
+ label: 'codex engine',
28
+ });
32
29
  }
33
30
 
34
31
  export async function generateLesson({ topic, slug, context }) {
35
- const ctx = context || {};
36
- const position = ctx.lessonLabel ? ` It is ${ctx.lessonLabel} of "${ctx.courseTitle}" in the "${ctx.kicker}" section.` : '';
37
- const prompt = `Generate ONE interactive lesson for a learner of "${topic}".${position}\nOutput ONLY one JSON object, no prose, no fences, matching:\n${LESSON_SCHEMA}\nRules: slug must be "${slug}"; beginner-friendly with analogies; the simulation and any widgets must be genuinely interactive, vanilla JS, no external libraries; all facts current and correct.`;
38
- const obj = extractJsonObject(await run('codex', ['exec', prompt]));
39
- if (!obj || !obj.title) throw new Error('codex engine: could not parse a lesson object');
40
- obj.slug = slug;
41
- if (ctx.courseTitle) obj.courseTitle = ctx.courseTitle;
42
- if (ctx.title) obj.title = ctx.title;
43
- if (ctx.lessonLabel) obj.lessonLabel = ctx.lessonLabel;
44
- if (ctx.kicker) obj.kicker = ctx.kicker;
45
- if (ctx.next !== undefined) obj.next = ctx.next;
46
- return obj;
32
+ return generateLessonWithRepair({
33
+ call,
34
+ prompt: buildLessonPrompt({ topic, slug, context }),
35
+ slug,
36
+ context,
37
+ label: 'codex engine',
38
+ });
47
39
  }
48
40
 
49
41
  function run(cmd, args) {
@@ -56,16 +48,3 @@ function run(cmd, args) {
56
48
  p.on('close', code => code === 0 ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err || out}`)));
57
49
  });
58
50
  }
59
-
60
- // Tolerant JSON extraction: strip ``` fences, then take the outermost {...}. (json-repair-lite)
61
- function extractJsonObject(text) {
62
- let t = String(text).replace(/```(?:json)?/gi, '');
63
- const s = t.indexOf('{'), e = t.lastIndexOf('}');
64
- if (s === -1 || e === -1) return null;
65
- const slice = t.slice(s, e + 1);
66
- try { return JSON.parse(slice); }
67
- catch {
68
- try { return JSON.parse(slice.replace(/,\s*([}\]])/g, '$1')); } // drop trailing commas
69
- catch { return null; }
70
- }
71
- }
@@ -0,0 +1,61 @@
1
+ // gemini-cli engine — drives Google's Gemini CLI headlessly, so you can generate with your
2
+ // signed-in Google account and NO API key (the CLI's free tier covers a lot of courses).
3
+ // Requires `gemini` on PATH (https://github.com/google-gemini/gemini-cli, then `gemini` once
4
+ // to sign in). This is the key-less sibling of the `gemini` engine, which calls the API.
5
+
6
+ import { spawn } from 'node:child_process'
7
+ import {
8
+ buildSyllabusPrompt, buildLessonPrompt, extractJsonObject,
9
+ generateSyllabusWithRepair, generateLessonWithRepair,
10
+ } from './shared-prompts.mjs'
11
+
12
+ export const name = 'gemini-cli'
13
+
14
+ // `--output-format json` wraps the answer in an envelope; the lesson JSON is inside it.
15
+ function extractText(raw) {
16
+ const env = extractJsonObject(raw)
17
+ if (env && typeof env.response === 'string') return env.response
18
+ if (env && typeof env.output === 'string') return env.output
19
+ return raw
20
+ }
21
+
22
+ function call(model) {
23
+ return async (prompt) => {
24
+ const args = ['-p', prompt, '--output-format', 'json']
25
+ if (model) args.push('-m', model)
26
+ return extractText(await run('gemini', args))
27
+ }
28
+ }
29
+
30
+ export async function generateSyllabus({ topic, lessons = 12, model }) {
31
+ return generateSyllabusWithRepair({
32
+ call: call(model),
33
+ prompt: buildSyllabusPrompt({ topic, lessons }),
34
+ label: 'gemini-cli engine',
35
+ })
36
+ }
37
+
38
+ export async function generateLesson({ topic, slug, model, context }) {
39
+ return generateLessonWithRepair({
40
+ call: call(model),
41
+ prompt: buildLessonPrompt({ topic, slug, context }),
42
+ slug,
43
+ context,
44
+ label: 'gemini-cli engine',
45
+ })
46
+ }
47
+
48
+ function run(cmd, args) {
49
+ return new Promise((resolve, reject) => {
50
+ const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] })
51
+ let out = '', err = ''
52
+ p.stdout.on('data', (d) => (out += d))
53
+ p.stderr.on('data', (d) => (err += d))
54
+ p.on('error', (e) =>
55
+ reject(new Error(
56
+ `Cannot run "gemini" — install Google's Gemini CLI and sign in:\n` +
57
+ ` npm i -g @google/gemini-cli && gemini\n(${e.message})`
58
+ )))
59
+ p.on('close', (code) => (code === 0 ? resolve(out) : reject(new Error(`gemini exited ${code}: ${err || out}`))))
60
+ })
61
+ }
@@ -4,25 +4,38 @@
4
4
  // schema rather than inventing them from stale training data.
5
5
  //
6
6
  // Requires: GEMINI_API_KEY (free tier works — https://aistudio.google.com/apikey)
7
- // Optional: GEMINI_MODEL (default "gemini-3.5-flash-lite"), COURSECAST_RESEARCH=0 to skip search.
7
+ // and a model of YOUR choice via --model or GEMINI_MODEL the CLI never
8
+ // picks one for you (it's your key, your cost, your quality bar).
9
+ // Optional: COURSECAST_RESEARCH=0 to skip the web-search research pass.
8
10
 
9
- import { buildSyllabusPrompt, buildLessonPrompt, finalizeLesson, extractJsonObject } from './shared-prompts.mjs'
11
+ import {
12
+ buildSyllabusPrompt, buildLessonPrompt,
13
+ generateSyllabusWithRepair, generateLessonWithRepair,
14
+ } from './shared-prompts.mjs'
10
15
 
11
16
  export const name = 'gemini'
12
17
 
13
18
  const API = 'https://generativelanguage.googleapis.com/v1beta/models'
14
- const DEFAULT_MODEL = process.env.GEMINI_MODEL || 'gemini-3.5-flash-lite'
19
+ const DEFAULT_MODEL = process.env.GEMINI_MODEL // no baked-in default — the user chooses
15
20
  const RESEARCH_ON = process.env.COURSECAST_RESEARCH !== '0'
16
21
 
17
22
  function apiKey() {
18
23
  const k = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
19
- if (!k) throw new Error('gemini engine: set GEMINI_API_KEY (free key at https://aistudio.google.com/apikey).')
24
+ if (!k) throw new Error(
25
+ 'gemini engine: set GEMINI_API_KEY (free key at https://aistudio.google.com/apikey)\n' +
26
+ ' …or skip the key entirely with --engine gemini-cli, which uses your signed-in Gemini CLI.'
27
+ )
20
28
  return k
21
29
  }
22
30
 
23
31
  // One Gemini call. `json:true` forces a JSON body; `search:true` enables Google Search grounding.
24
32
  // (Gemini disallows forced-JSON + grounding together, so a call is one or the other.)
25
33
  async function callGemini({ model = DEFAULT_MODEL, prompt, json = false, search = false, maxOutputTokens = 8192 }) {
34
+ if (!model) {
35
+ throw new Error(
36
+ 'gemini engine: pick a model with --model (e.g. gemini-3.5-flash, gemini-3.5-flash-lite) or set GEMINI_MODEL.'
37
+ )
38
+ }
26
39
  const body = {
27
40
  contents: [{ parts: [{ text: prompt }] }],
28
41
  generationConfig: { temperature: 0.7, maxOutputTokens },
@@ -73,20 +86,20 @@ function withResearch(prompt, brief) {
73
86
 
74
87
  export async function generateSyllabus({ topic, lessons = 12, model }) {
75
88
  const brief = await research({ topic, focus: 'the full scope a learner must cover, in learning order', model })
76
- const prompt = withResearch(buildSyllabusPrompt({ topic, lessons }), brief)
77
- const raw = await callGemini({ model, prompt, json: true, maxOutputTokens: 8192 })
78
- const obj = extractJsonObject(raw)
79
- if (!obj || !Array.isArray(obj.phases)) throw new Error('gemini engine: could not parse a syllabus object')
80
- return obj
89
+ return generateSyllabusWithRepair({
90
+ call: (prompt) => callGemini({ model, prompt, json: true, maxOutputTokens: 8192 }),
91
+ prompt: withResearch(buildSyllabusPrompt({ topic, lessons }), brief),
92
+ label: 'gemini engine',
93
+ })
81
94
  }
82
95
 
83
96
  export async function generateLesson({ topic, slug, model, context }) {
84
97
  const focus = context?.title ? `the lesson "${context.title}" (${context.kicker || ''})` : slug
85
98
  const brief = await research({ topic, focus, model })
86
- const prompt = withResearch(buildLessonPrompt({ topic, slug, context }), brief)
87
99
  // Lessons are large (sections + a simulation + quiz), so allow plenty of output room.
88
- const raw = await callGemini({ model, prompt, json: true, maxOutputTokens: 65536 })
89
- const obj = extractJsonObject(raw)
90
- if (!obj || !obj.title) throw new Error('gemini engine: could not parse a lesson object from output')
91
- return finalizeLesson(obj, slug, context)
100
+ return generateLessonWithRepair({
101
+ call: (prompt) => callGemini({ model, prompt, json: true, maxOutputTokens: 65536 }),
102
+ prompt: withResearch(buildLessonPrompt({ topic, slug, context }), brief),
103
+ slug, context, label: 'gemini engine',
104
+ })
92
105
  }
@@ -1,5 +1,5 @@
1
1
  // Shared generation contract — the schemas, prompts, and post-processing every engine uses.
2
- // Keeping these in one place means the claude-code, gemini, and any future engine all produce
2
+ // Keeping these in one place means the claude, gemini, and any future engine all produce
3
3
  // the exact same lesson shape and are held to the same authoring bar; only the transport differs.
4
4
 
5
5
  export const LESSON_SCHEMA = `{
@@ -59,6 +59,29 @@ Rules:
59
59
  - Playful but not childish; a total beginner must be able to follow. Analogies for abstract ideas.
60
60
  - The simulation must be genuinely interactive and teach the lesson's deepest idea (e.g. a slider, a toggle, a click-through). Vanilla JS only; no external libraries. Give its elements unique ids.
61
61
  - All facts must be current and correct. Commands must actually work.
62
+ - The quiz answer index "a" must be the genuinely correct option — a learner is graded on it.
63
+ - Only include "terminal" when the topic really involves a command line; for non-technical
64
+ subjects omit it rather than inventing shell commands.
65
+
66
+ HTML rules for every "html" field you write (sections[].html, simulation.html) — these are
67
+ injected into a page as-is, so malformed markup breaks the lesson:
68
+ - Write valid, self-contained HTML fragments only. NEVER include <style>, <script>, <html>,
69
+ <head> or <body> tags — style with inline style="" attributes or the shared CSS classes.
70
+ - Never put a block element (<div>, <p>, <table>, <ul>) inside an inline element (<span>, <b>, <a>).
71
+ - Escape ampersands as &amp; (and < as &lt;) in text; a raw "&" is invalid.
72
+ - Every <button> needs type="button".
73
+ - Every form control (<input>, <select>, <textarea>) needs an accessible name: either a
74
+ <label for="id"> pointing at it, or an aria-label. Sliders and toggles included.
75
+ - Don't use heading tags inside sections[].html — the section's "h2" is already the heading;
76
+ use <b>/<strong> for sub-emphasis so the page's heading order stays valid.
77
+
78
+ Math and symbols — there is NO LaTeX/MathJax renderer on the page:
79
+ - NEVER write $...$, \(...\), \[...\] or backslash macros (\frac, \sum, \Delta, \mathbf,
80
+ \text). They ship to the learner as literal, broken-looking text.
81
+ - Express maths in plain Unicode: superscripts ²³ⁿ, subscripts ₀₁ₙ, Δ α β θ λ π Σ ∫ √ ≈ ≤ ≥ ×
82
+ · → ∞ ∇, and chemistry as CO₂ / H₂O. Write "dA/dt = h/2", not "$\frac{dA}{dt}=\frac{h}{2}$".
83
+ - quiz "opts", flashcard "front"/"back" and tiles "big" are inserted as TEXT, not HTML — put no
84
+ tags in them at all. Inside sections[].html you may also use <sub>, <sup> and <code>.
62
85
  - Output must be valid JSON and nothing else.`
63
86
  }
64
87
 
@@ -86,3 +109,96 @@ export function extractJsonObject(text) {
86
109
  catch { return null }
87
110
  }
88
111
  }
112
+
113
+ // ---------------------------------------------------------------- validate + repair
114
+ //
115
+ // Models drop a required field or mis-index a quiz answer often enough that a silent
116
+ // half-broken lesson is a real quality problem — and a wrong quiz answer key actively
117
+ // mis-teaches. So every engine validates the parsed JSON against the schema and, on a
118
+ // miss, gets ONE repair attempt that shows the model its own output and the exact errors.
119
+ // One extra call on the minority of generations that need it; no cost on the ones that don't.
120
+
121
+ export function validateLesson(obj) {
122
+ const e = []
123
+ if (!obj || typeof obj !== 'object') return ['the output was not a JSON object']
124
+ if (!obj.title) e.push('"title" is missing')
125
+ if (!Array.isArray(obj.sections) || !obj.sections.length) e.push('"sections" must be a non-empty array of {h2, html}')
126
+ else obj.sections.forEach((s, i) => {
127
+ if (!s || !s.h2) e.push(`sections[${i}] is missing "h2"`)
128
+ if (!s || !s.html) e.push(`sections[${i}] is missing "html"`)
129
+ })
130
+ if (obj.quiz !== undefined) {
131
+ if (!Array.isArray(obj.quiz)) e.push('"quiz" must be an array')
132
+ else obj.quiz.forEach((q, i) => {
133
+ if (!q || !q.q) e.push(`quiz[${i}] is missing the question "q"`)
134
+ if (!q || !Array.isArray(q.opts) || q.opts.length < 2) e.push(`quiz[${i}] needs at least 2 "opts"`)
135
+ else if (typeof q.a !== 'number' || !Number.isInteger(q.a) || q.a < 0 || q.a >= q.opts.length)
136
+ e.push(`quiz[${i}].a must be the 0-based index of the CORRECT option (0..${q.opts.length - 1}), got ${JSON.stringify(q && q.a)}`)
137
+ })
138
+ }
139
+ if (obj.checklist !== undefined && !Array.isArray(obj.checklist)) e.push('"checklist" must be an array')
140
+ if (obj.tiles !== undefined && !Array.isArray(obj.tiles)) e.push('"tiles" must be an array')
141
+ if (obj.simulation) {
142
+ if (!obj.simulation.html) e.push('"simulation" is missing "html"')
143
+ if (!obj.simulation.js) e.push('"simulation" is missing "js"')
144
+ }
145
+ return e
146
+ }
147
+
148
+ export function validateSyllabus(obj) {
149
+ const e = []
150
+ if (!obj || typeof obj !== 'object') return ['the output was not a JSON object']
151
+ if (!obj.courseTitle) e.push('"courseTitle" is missing')
152
+ if (!Array.isArray(obj.phases) || !obj.phases.length) e.push('"phases" must be a non-empty array')
153
+ else obj.phases.forEach((p, i) => {
154
+ if (!p || !p.name) e.push(`phases[${i}] is missing "name"`)
155
+ if (!p || !Array.isArray(p.lessons) || !p.lessons.length) e.push(`phases[${i}].lessons must be a non-empty array`)
156
+ else p.lessons.forEach((l, k) => { if (!l || !l.title) e.push(`phases[${i}].lessons[${k}] is missing "title"`) })
157
+ })
158
+ return e
159
+ }
160
+
161
+ function repairPrompt(original, output, errors) {
162
+ return `Your previous response did not match the required schema.
163
+
164
+ ERRORS:
165
+ ${errors.map((x) => `- ${x}`).join('\n')}
166
+
167
+ YOUR PREVIOUS OUTPUT:
168
+ ${String(output).slice(0, 12000)}
169
+
170
+ Fix ONLY those errors and return the COMPLETE corrected JSON object — all the original
171
+ content, with the problems above resolved. Output ONLY the JSON, no prose, no fences.
172
+
173
+ The original request, for reference:
174
+ ${original}`
175
+ }
176
+
177
+ // Run `call(prompt)` -> text, parse, validate, and retry once with the errors if needed.
178
+ // Keeps whichever attempt validates better, so a repair can never make things worse.
179
+ async function withRepair({ call, prompt, validate, label, what }) {
180
+ const raw = await call(prompt)
181
+ let obj = extractJsonObject(raw)
182
+ let errs = validate(obj)
183
+ if (errs.length) {
184
+ try {
185
+ const raw2 = await call(repairPrompt(prompt, raw, errs))
186
+ const obj2 = extractJsonObject(raw2)
187
+ const errs2 = validate(obj2)
188
+ if (errs2.length < errs.length) { obj = obj2; errs = errs2 }
189
+ } catch { /* keep the first attempt if the repair call itself fails */ }
190
+ }
191
+ if (!obj || errs.length) {
192
+ throw new Error(`${label}: could not produce a valid ${what} (${errs.slice(0, 3).join('; ') || 'unparseable output'})`)
193
+ }
194
+ return obj
195
+ }
196
+
197
+ export async function generateSyllabusWithRepair({ call, prompt, label = 'engine' }) {
198
+ return withRepair({ call, prompt, validate: validateSyllabus, label, what: 'syllabus' })
199
+ }
200
+
201
+ export async function generateLessonWithRepair({ call, prompt, slug, context, label = 'engine' }) {
202
+ const obj = await withRepair({ call, prompt, validate: validateLesson, label, what: 'lesson' })
203
+ return finalizeLesson(obj, slug, context)
204
+ }
package/src/index.mjs CHANGED
@@ -15,9 +15,13 @@ export function slugify(s) {
15
15
  return slug;
16
16
  }
17
17
 
18
+ // "claude-code" was the id before 0.2.6 — keep it working so existing scripts don't break.
19
+ const ENGINE_ALIASES = { 'claude-code': 'claude' };
20
+
18
21
  async function loadEngine(id) {
19
- try { return await import(`./engines/${id}.mjs`); }
20
- catch { throw new Error(`Unknown engine "${id}". Available: claude-code, gemini, codex, stub.`); }
22
+ const resolved = ENGINE_ALIASES[id] || id;
23
+ try { return await import(`./engines/${resolved}.mjs`); }
24
+ catch { throw new Error(`Unknown engine "${id}". Available: claude, gemini, gemini-cli, codex, stub.`); }
21
25
  }
22
26
  // Pick a deploy provider by what's actually installed: vercel first, then netlify.
23
27
  // Auto-detection beats a silent default (which pretended netlify didn't exist) and
@@ -40,7 +44,7 @@ async function loadProvider(id) {
40
44
  catch { throw new Error(`Unknown provider "${id}". Available: vercel, netlify.`); }
41
45
  }
42
46
 
43
- export async function createLesson({ topic, engine = 'claude-code', model, storage = 'local', outDir, log = () => {} }) {
47
+ export async function createLesson({ topic, engine = 'claude', model, storage = 'local', outDir, log = () => {} }) {
44
48
  const slug = slugify(topic);
45
49
  const eng = await loadEngine(engine);
46
50
  log(`🧠 Generating lesson with the "${eng.name}" engine…`);
@@ -64,7 +68,7 @@ export async function createLesson({ topic, engine = 'claude-code', model, stora
64
68
 
65
69
  // Generate a whole multi-lesson course from one topic: syllabus -> lessons (parallel,
66
70
  // with retry) -> mastery dashboard. Returns { dir, slug, manifest, lessons, failed }.
67
- export async function createCourse({ topic, engine = 'claude-code', model, storage = 'local', outDir, lessons, concurrency = 4, log = () => {} }) {
71
+ export async function createCourse({ topic, engine = 'claude', model, storage = 'local', outDir, lessons, concurrency = 4, log = () => {} }) {
68
72
  concurrency = Math.max(1, concurrency || 4);
69
73
  lessons = lessons ? Math.max(1, lessons) : undefined;
70
74
  const slug = slugify(topic);
@@ -14,8 +14,8 @@ const esc = (s = '') => String(s)
14
14
  const jsonEmbed = v => JSON.stringify(v).replace(/</g, '\\u003c');
15
15
 
16
16
  const CSS = `
17
- :root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink-2:#52514e;--muted:#898781;--grid:#e1e0d9;--border:rgba(11,11,11,0.10);--accent:#2a78d6;
18
- --mastered:#0ca30c;--progress:#2a78d6;--needs:#eda100;--visited:#898781;--untouched:#c3c2b7;}
17
+ :root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink-2:#52514e;--muted:#67665f;--grid:#e1e0d9;--border:rgba(11,11,11,0.10);--accent:#2a78d6;
18
+ --mastered:#0ca30c;--progress:#2a78d6;--needs:#9a6400;--visited:#898781;--untouched:#c3c2b7;}
19
19
  :root[data-theme="dark"]{color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--border:rgba(255,255,255,0.10);--accent:#3987e5;
20
20
  --mastered:#0ca30c;--progress:#3987e5;--needs:#c98500;--visited:#898781;--untouched:#3a3a38;}
21
21
  @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--border:rgba(255,255,255,0.10);--accent:#3987e5;--needs:#c98500;--visited:#898781;--untouched:#3a3a38;}}
@@ -49,6 +49,7 @@ const CSS = `
49
49
  .badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;border:1px solid var(--border);white-space:nowrap}
50
50
  .phase-lessons a{border:1px solid var(--border);background:var(--surface);border-radius:11px;margin:8px 0}
51
51
  .foot{margin-top:44px;font-size:13px;color:var(--muted);border-top:1px solid var(--grid);padding-top:16px;display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap}
52
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
52
53
  .reset{border:1px solid var(--border);background:none;color:var(--muted);border-radius:8px;padding:4px 10px;cursor:pointer;font-size:12px}
53
54
  .empty{color:var(--muted);font-size:14px;padding:10px 16px}
54
55
  `;
@@ -72,8 +73,8 @@ export function renderDashboard(manifest) {
72
73
  <script>try{var t=localStorage.getItem("cc-theme");if(t)document.documentElement.dataset.theme=t;}catch(e){}</script>
73
74
  </head>
74
75
  <body>
75
- <div class="wrap">
76
- <button class="theme-btn" id="themeBtn">◐ Theme</button>
76
+ <main class="wrap">
77
+ <button type="button" class="theme-btn" id="themeBtn">◐ Theme</button>
77
78
  <div class="kicker">coursecast · Interactive Course</div>
78
79
  <h1>${esc(m.courseTitle)}</h1>
79
80
  <p class="lede">${esc(m.courseSubtitle || '')}</p>
@@ -98,9 +99,9 @@ export function renderDashboard(manifest) {
98
99
 
99
100
  <div class="foot">
100
101
  <span>Progress saves on this device. Generated by <b>coursecast</b>.</span>
101
- <button class="reset" id="reset">Reset progress</button>
102
+ <button type="button" class="reset" id="reset">Reset progress</button>
102
103
  </div>
103
- </div>
104
+ </main>
104
105
  <script>
105
106
  (function(){
106
107
  var LESSONS=${LDATA}, PHASES=${PHASES};
package/src/render.mjs CHANGED
@@ -9,8 +9,87 @@ const esc = (s = '') => String(s)
9
9
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
10
10
  .replace(/"/g, '&quot;');
11
11
 
12
+ // Model-authored HTML (sections[].html, simulation.html) is injected as-is, so the prompt
13
+ // asks for well-formed fragments — but a generator that ignores the rules must not be able
14
+ // to break the page or its accessibility. This is a safety net, not a parser.
15
+ //
16
+ // Styles are HOISTED rather than dropped: a <style> inside a <div> is invalid markup, but
17
+ // deleting it would silently break the look of an AI-authored simulation. Collected CSS is
18
+ // emitted in <head>, which is valid and visually identical (each lesson is its own page, so
19
+ // there is nothing for it to leak into). <script> is left alone — it is valid flow content,
20
+ // and simulations legitimately inline their JS.
21
+ const INPUT_TYPES = new Set(['button', 'checkbox', 'color', 'date', 'datetime-local', 'email', 'file',
22
+ 'hidden', 'image', 'month', 'number', 'password', 'radio', 'range', 'reset', 'search', 'submit',
23
+ 'tel', 'text', 'time', 'url', 'week']);
24
+
25
+ function tidyFragment(html = '', sink) {
26
+ return String(html)
27
+ // Entity-encoded attribute quotes: a model that emits id=&quot;x&quot; produces an
28
+ // attribute whose value is literally &quot;x&quot;, so the simulation's own
29
+ // getElementById('x') finds nothing and throws. Only rewritten inside a tag, so prose
30
+ // that legitimately contains &quot; is untouched.
31
+ .replace(/<[a-z][^>]*>/gi, (tag) =>
32
+ tag.includes('=&quot;') ? tag.replace(/=&quot;(.*?)&quot;/g, '="$1"') : tag)
33
+ .replace(/<style\b[^>]*>([\s\S]*?)<\/style>/gi, (_m, css) => { if (sink) sink.push(css); return ''; })
34
+ // A <button> with no type submits enclosing forms and trips validation.
35
+ .replace(/<button(?![^>]*\btype=)/gi, '<button type="button"')
36
+ // Repair broken <input type>. Two failure modes seen in real output, both of which
37
+ // silently turn a working slider into a text box:
38
+ // - duplicated type= (the browser honours the FIRST, which may be the bogus one)
39
+ // - a single type= whose value isn't an input type at all (e.g. type="id")
40
+ // A tag carrying min/max/step was clearly meant to be a range.
41
+ .replace(/<input\b[^>]*>/gi, (tag) => {
42
+ const types = [...tag.matchAll(/\stype\s*=\s*(["'])(.*?)\1/gi)];
43
+ if (!types.length) return tag;
44
+ const valid = types.find((t) => INPUT_TYPES.has(t[2].toLowerCase()));
45
+ const guess = /\s(?:min|max|step)\s*=/i.test(tag) ? 'range' : 'text';
46
+ const keep = valid ? valid[0] : ` type="${guess}"`;
47
+ let placed = false;
48
+ return tag.replace(/\stype\s*=\s*(["'])(.*?)\1/gi, () => {
49
+ if (placed) return '';
50
+ placed = true;
51
+ return keep;
52
+ });
53
+ })
54
+ // An id with surrounding whitespace is invalid AND breaks the simulation's own
55
+ // getElementById lookups, which then throw on a null element.
56
+ .replace(/\s(id|for)\s*=\s*(["'])\s*(.*?)\s*\2/gi, (_m, attr, q, val) => ` ${attr}=${q}${val}${q}`)
57
+ // Headings inside a fragment break the page's heading order (the section already owns
58
+ // its <h2>). Demote them to bold text, which is what they were being used for visually.
59
+ // Any style= the model already set is preserved rather than duplicated.
60
+ .replace(/<h[1-6]\b([^>]*)>/gi, (_m, attrs) =>
61
+ /\bstyle\s*=/i.test(attrs)
62
+ ? `<strong${attrs.replace(/\bstyle\s*=\s*(["'])/i, 'style=$1display:block;font-size:1.05em;')}>`
63
+ : `<strong${attrs} style="display:block;font-size:1.05em">`)
64
+ .replace(/<\/h[1-6]>/gi, '</strong>')
65
+ // Bare "&" that isn't already an entity is invalid markup.
66
+ .replace(/&(?!#?[a-z0-9]{1,8};)/gi, '&amp;');
67
+ }
68
+
69
+ // Model-authored simulation JS, repaired the same way its HTML is.
70
+ //
71
+ // The failure this catches: a model double-escapes its newlines, so `simulation.js` arrives
72
+ // as one line containing literal backslash-n sequences. That is a hard SyntaxError, and the
73
+ // lesson's whole interactive simulation silently does nothing. Repair is only attempted in
74
+ // the unambiguous case (escapes present, no real newlines at all) so a script that
75
+ // legitimately contains "\n" inside a string is never touched.
76
+ function tidyScript(js = '') {
77
+ let src = String(js);
78
+ if (!src.trim()) return '';
79
+ if (!/\r?\n/.test(src) && /\\n/.test(src)) {
80
+ src = src.replace(/\\r\\n|\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\(["'`])/g, '$1');
81
+ }
82
+ // Generation runs in Node, so we can actually check it parses before shipping it.
83
+ try {
84
+ new Function(src);
85
+ } catch (e) {
86
+ return `/* coursecast: simulation script omitted — it does not parse (${String(e.message).slice(0, 80)}) */`;
87
+ }
88
+ return src;
89
+ }
90
+
12
91
  const CSS = `
13
- :root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink-2:#52514e;--muted:#898781;--grid:#e1e0d9;--baseline:#c3c2b7;--border:rgba(11,11,11,0.10);--accent:#2a78d6;--s1:#2a78d6;--s2:#eb6834;--s3:#1baf7a;--good:#0ca30c;--good-text:#006300;--critical:#d03b3b;--warning:#fab219;}
92
+ :root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink-2:#52514e;--muted:#67665f;--grid:#e1e0d9;--baseline:#c3c2b7;--border:rgba(11,11,11,0.10);--accent:#2a78d6;--s1:#2a78d6;--s2:#eb6834;--s3:#1baf7a;--good:#0ca30c;--good-text:#006300;--critical:#d03b3b;--warning:#fab219;}
14
93
  :root[data-theme="dark"]{color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--baseline:#383835;--border:rgba(255,255,255,0.10);--accent:#3987e5;--s1:#3987e5;--s2:#d95926;--s3:#199e70;--good:#0ca30c;--good-text:#0ca30c;--critical:#d03b3b;--warning:#fab219;}
15
94
  @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--baseline:#383835;--border:rgba(255,255,255,0.10);--accent:#3987e5;--s1:#3987e5;--s2:#d95926;--s3:#199e70;--good:#0ca30c;--good-text:#0ca30c;--critical:#d03b3b;--warning:#fab219;}}
16
95
  *{margin:0;padding:0;box-sizing:border-box}
@@ -34,12 +113,12 @@ const CSS = `
34
113
  .btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
35
114
  .term{background:#16161a;color:#e6e6ec;border-radius:12px;padding:4px 0 10px;margin:20px 0;overflow-x:auto;border:1px solid var(--border)}
36
115
  .term .thead{display:flex;gap:6px;padding:10px 14px 8px;border-bottom:1px solid #2a2a30;margin-bottom:6px}.term .dot{width:11px;height:11px;border-radius:50%;background:#3a3a42}
37
- .tline{font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:13.5px;padding:5px 14px;cursor:pointer;white-space:pre;border-left:3px solid transparent}
116
+ .tline{display:block;width:100%;text-align:left;background:none;border:0;color:inherit;font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:13.5px;padding:5px 14px;cursor:pointer;white-space:pre;border-left:3px solid transparent}
38
117
  .tline:hover,.tline.active{background:#1f1f26;border-left-color:var(--accent)}.tline .prompt{color:#5fb85f}
39
118
  .term-explain{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px 16px;font-size:14px;color:var(--ink-2);min-height:52px;margin:-8px 0 20px}.term-explain b{color:var(--ink)}
40
119
  .check-item{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin:10px 0}
41
120
  .check-item label{display:flex;gap:12px;align-items:flex-start;cursor:pointer;font-weight:600}.check-item input{width:18px;height:18px;margin-top:3px;accent-color:var(--good)}
42
- .check-item .desc{font-size:14px;color:var(--ink-2);font-weight:400;margin-top:2px}.check-item details{margin:8px 0 0 30px;font-size:13.5px;color:var(--ink-2)}
121
+ .check-item .desc{display:block;font-size:14px;color:var(--ink-2);font-weight:400;margin-top:2px}.check-item details{margin:8px 0 0 30px;font-size:13.5px;color:var(--ink-2)}
43
122
  .check-item summary{cursor:pointer;color:var(--accent);font-weight:600;font-size:13px}.check-item.done label{text-decoration:line-through;color:var(--muted)}
44
123
  .quiz-q{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin:14px 0}.quiz-q .qt{font-weight:700;margin-bottom:10px}
45
124
  .quiz-opt{display:block;width:100%;text-align:left;margin:6px 0;padding:10px 14px;border-radius:9px;border:1px solid var(--border);background:var(--page);color:var(--ink);cursor:pointer;font-size:14.5px}
@@ -49,21 +128,34 @@ const CSS = `
49
128
  .nextcard{display:flex;justify-content:space-between;align-items:center;gap:16px;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px;margin-top:48px;text-decoration:none;color:inherit;cursor:pointer}
50
129
  .nextcard:hover{border-color:var(--accent)}.nextcard .nk{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:700}.nextcard .nt{font-size:18px;font-weight:700}
51
130
  .foot{font-size:12.5px;color:var(--muted);margin-top:40px}
131
+ /* Keyboard users must be able to see where they are — every interactive element. */
132
+ :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}
133
+ .skip{position:absolute;left:-9999px;top:0;background:var(--accent);color:#fff;padding:10px 16px;border-radius:0 0 8px 0;z-index:20;text-decoration:none;font-weight:600}
134
+ .skip:focus{left:0}
135
+ @media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}}
52
136
  `;
53
137
 
54
138
  export function renderLesson(c) {
55
139
  const slug = c.slug;
140
+ const hoistedCss = []; // CSS lifted out of model-authored fragments (see tidyFragment)
141
+ const lede = c.lede ? `<p class="lede">${tidyFragment(c.lede, hoistedCss)}</p>` : '';
56
142
  const tiles = (c.tiles || []).map(t =>
57
- `<div class="tile"><div class="big">${esc(t.big)}</div><div class="lbl">${t.lbl || ''}</div>${t.note ? `<div class="note">${t.note}</div>` : ''}</div>`).join('');
143
+ `<div class="tile"><div class="big">${esc(t.big)}</div><div class="lbl">${tidyFragment(t.lbl, hoistedCss)}</div>${t.note ? `<div class="note">${tidyFragment(t.note, hoistedCss)}</div>` : ''}</div>`).join('');
58
144
  const sections = (c.sections || []).map(s =>
59
- `<h2>${esc(s.h2)}</h2>${s.html || ''}`).join('\n');
60
- const analogy = c.analogy ? `<div class="analogy"><span class="tag">${esc(c.analogy.tag || 'Analogy')}</span><p>${c.analogy.text || ''}</p></div>` : '';
61
- const sim = c.simulation ? `<div class="simwrap"><div class="ctitle">${esc(c.simulation.title || '')}</div><div class="csub">${esc(c.simulation.sub || '')}</div>${c.simulation.html || ''}</div>${c.simulation.js ? `<script>${c.simulation.js}</script>` : ''}` : '';
62
- const widgets = renderWidgets(c.widgets);
63
- const term = (c.terminal && c.terminal.length) ? `<div class="term"><div class="thead"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>${c.terminal.map(t => `<div class="tline" data-x="${esc(t.explain)}"><span class="prompt">$</span> ${esc(t.cmd)}</div>`).join('')}</div><div class="term-explain" id="termExplain">👆 <b>Click a command</b> to decode it.</div>` : '';
145
+ `<h2>${esc(s.h2)}</h2>${tidyFragment(s.html, hoistedCss)}`).join('\n');
146
+ const analogy = c.analogy ? `<div class="analogy"><span class="tag">${esc(c.analogy.tag || 'Analogy')}</span><p>${tidyFragment(c.analogy.text, hoistedCss)}</p></div>` : '';
147
+ const sim = c.simulation ? `<div class="simwrap"><div class="ctitle">${esc(c.simulation.title || '')}</div><div class="csub">${esc(c.simulation.sub || '')}</div>${tidyFragment(c.simulation.html, hoistedCss)}</div>${c.simulation.js ? `<script>${tidyScript(c.simulation.js)}</script>` : ''}` : '';
148
+ const widgets = renderWidgets(c.widgets, hoistedCss);
149
+ const term = (c.terminal && c.terminal.length) ? `<div class="term"><div class="thead"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>${c.terminal.map(t => `<button type="button" class="tline" aria-pressed="false" data-x="${esc(t.explain)}"><span class="prompt" aria-hidden="true">$</span> ${esc(t.cmd)}</button>`).join('')}</div><div class="term-explain" id="termExplain" role="status" aria-live="polite">👆 <b>Click a command</b> to decode it.</div>` : '';
64
150
  const checklist = (c.checklist || []).map((it, i) =>
65
- `<div class="check-item"><label><input type="checkbox" data-c="${i}"><span>${esc(it.title)}<div class="desc">${it.desc || ''}</div></span></label>${it.hint ? `<details><summary>Stuck? Hint</summary>${esc(it.hint)}</details>` : ''}</div>`).join('');
66
- const next = c.next ? `<a class="nextcard" href="${esc(c.next.stem)}.html"><div><div class="nk">Up next</div><div class="nt">${esc(c.next.title)}</div><div style="font-size:14px;color:var(--ink-2)">${esc(c.next.desc || '')}</div></div><div style="font-size:28px">→</div></a>` : '';
151
+ `<div class="check-item"><label><input type="checkbox" data-c="${i}"><span>${esc(it.title)}<span class="desc">${tidyFragment(it.desc, hoistedCss)}</span></span></label>${it.hint ? `<details><summary>Stuck? Hint</summary>${esc(it.hint)}</details>` : ''}</div>`).join('');
152
+ // `next: null` means "last lesson of a course" (the course builder sets it explicitly);
153
+ // `undefined` means a standalone lesson, which has no dashboard to return to.
154
+ const next = c.next
155
+ ? `<a class="nextcard" href="${esc(c.next.stem)}.html"><div><div class="nk">Up next</div><div class="nt">${esc(c.next.title)}</div><div style="font-size:14px;color:var(--ink-2)">${esc(c.next.desc || '')}</div></div><div style="font-size:28px">→</div></a>`
156
+ : c.next === null
157
+ ? `<a class="nextcard" href="index.html"><div><div class="nk">Course complete</div><div class="nt">Back to ${esc(c.courseTitle || 'the dashboard')}</div><div style="font-size:14px;color:var(--ink-2)">Review your mastery map and revisit anything shaky.</div></div><div style="font-size:28px">🎓</div></a>`
158
+ : '';
67
159
 
68
160
  const quizData = JSON.stringify(c.quiz || []);
69
161
 
@@ -74,19 +166,21 @@ export function renderLesson(c) {
74
166
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
75
167
  <title>${esc(c.title)} — coursecast</title>
76
168
  <style>${CSS}</style>
169
+ ${hoistedCss.length ? `<style>${hoistedCss.join("\n")}</style>` : ''}
77
170
  <script>try{var t=localStorage.getItem("cc-theme");if(t)document.documentElement.dataset.theme=t;}catch(e){}</script>
78
171
  </head>
79
172
  <body>
80
- <div class="topbar">
81
- <div class="crumb">🎓 ${esc(c.courseTitle || 'coursecast')} · <b>${esc(c.lessonLabel || '')} — ${esc(c.title)}</b></div>
173
+ <a class="skip" href="#lesson-main">Skip to content</a>
174
+ <header class="topbar">
175
+ <div class="crumb">${c.courseTitle ? `<a class="crumb-home" href="index.html">🎓 ${esc(c.courseTitle)}</a>` : '🎓 coursecast'} · <b>${esc(c.lessonLabel || '')} — ${esc(c.title)}</b></div>
82
176
  <div class="progressbar"><div id="lessonProgress"></div></div>
83
- <button class="theme-btn" id="themeBtn">◐ Theme</button>
84
- </div>
85
- <div class="wrap">
177
+ <button type="button" class="theme-btn" id="themeBtn">◐ Theme</button>
178
+ </header>
179
+ <main class="wrap" id="lesson-main">
86
180
  <div class="hero">
87
181
  ${c.kicker ? `<div class="kicker">${esc(c.kicker)}</div>` : ''}
88
182
  <h1>${esc(c.title)}</h1>
89
- ${c.lede ? `<p class="lede">${c.lede}</p>` : ''}
183
+ ${lede}
90
184
  </div>
91
185
  ${tiles ? `<div class="tiles">${tiles}</div>` : ''}
92
186
  ${analogy}
@@ -98,7 +192,7 @@ export function renderLesson(c) {
98
192
  ${(c.quiz && c.quiz.length) ? `<h2>🧠 Prove it</h2><div id="quiz"></div><div class="quiz-score" id="quizScore"><div class="big"></div><div class="msg" style="color:var(--ink-2)"></div></div>` : ''}
99
193
  ${next}
100
194
  <p class="foot">Generated by the <b>coursecast</b> CLI · ${esc(c.footer || '')}</p>
101
- </div>
195
+ </main>
102
196
  <script>
103
197
  (function(){
104
198
  var KEY=${JSON.stringify("cc-" + slug)}, state={};
@@ -112,8 +206,8 @@ export function renderLesson(c) {
112
206
  root.dataset.theme=nx;try{localStorage.setItem('cc-theme',nx)}catch(e){}
113
207
  });
114
208
  document.querySelectorAll('.tline').forEach(function(l){l.addEventListener('click',function(){
115
- document.querySelectorAll('.tline').forEach(function(x){x.classList.remove('active')});
116
- l.classList.add('active');document.getElementById('termExplain').innerHTML='<b>What this does:</b> '+l.dataset.x;
209
+ document.querySelectorAll('.tline').forEach(function(x){x.classList.remove('active');x.setAttribute('aria-pressed','false')});
210
+ l.classList.add('active');l.setAttribute('aria-pressed','true');document.getElementById('termExplain').innerHTML='<b>What this does:</b> '+l.dataset.x;
117
211
  })});
118
212
  var checks=document.querySelectorAll('.check-item input[type=checkbox]');
119
213
  var QUIZ=${quizData};
@@ -127,7 +221,7 @@ export function renderLesson(c) {
127
221
  var quizEl=document.getElementById('quiz'),answered=0,score=0;
128
222
  if(quizEl)QUIZ.forEach(function(item,qi){var card=document.createElement('div');card.className='quiz-q';
129
223
  card.innerHTML='<div class="qt">'+(qi+1)+'. '+item.q+'</div>';
130
- item.opts.forEach(function(opt,oi){var b=document.createElement('button');b.className='quiz-opt';b.textContent=opt;
224
+ item.opts.forEach(function(opt,oi){var b=document.createElement('button');b.type='button';b.className='quiz-opt';b.textContent=opt;
131
225
  b.addEventListener('click',function(){if(card.dataset.done)return;card.dataset.done='1';
132
226
  var right=oi===item.a;if(right){score++;b.classList.add('correct')}else{b.classList.add('wrong');card.querySelectorAll('.quiz-opt')[item.a].classList.add('correct')}
133
227
  var fb=card.querySelector('.quiz-fb');fb.style.display='block';fb.innerHTML=(right?'✅ <b>Nailed it.</b> ':'❌ <b>Not quite.</b> ')+(item.fb||'');
@@ -27,9 +27,9 @@ export function render(config, id) {
27
27
  <div class="fc-meta">${cards.length} cards · click the card to flip</div>
28
28
  <div class="fc-card" data-card><span class="hint" data-side>Front</span><span data-face></span></div>
29
29
  <div class="fc-ctrls">
30
- <button class="fc-btn" data-prev>‹ Prev</button>
31
- <button class="fc-btn" data-next>Next ›</button>
32
- <button class="fc-btn good" data-known>✓ I knew it</button>
30
+ <button type="button" class="fc-btn" data-prev>‹ Prev</button>
31
+ <button type="button" class="fc-btn" data-next>Next ›</button>
32
+ <button type="button" class="fc-btn good" data-known>✓ I knew it</button>
33
33
  <span class="fc-prog" data-prog></span>
34
34
  </div>
35
35
  <script>(function(){
@@ -6,8 +6,11 @@
6
6
  // self-contained HTML. Every widget is vanilla JS, no CDNs, no build step — so a lesson
7
7
  // stays one offline file with no asset inliner needed.
8
8
  //
9
- // A widget module exports: `type` (string) and `render(config, id) -> htmlString`.
10
- // `id` is a unique per-instance prefix so multiple widgets can share a page safely.
9
+ // A widget module exports: `type` (string) and `render(config, id)`, returning either an
10
+ // HTML string or `{ html, css }`. `id` is a unique per-instance prefix so multiple widgets
11
+ // can share a page safely. CSS is returned separately (not inside a <style> in the markup)
12
+ // because a <style> element inside a <div> is invalid HTML — the page renderer collects it
13
+ // and emits it in <head>.
11
14
 
12
15
  import * as whatIf from './what-if.mjs';
13
16
  import * as flashcards from './flashcards.mjs';
@@ -31,12 +34,25 @@ export const jsonInline = (v) => JSON.stringify(v)
31
34
 
32
35
  // Render a lesson's widgets array to a single HTML string. Unknown/invalid widgets are
33
36
  // skipped (never crash a whole lesson over one bad widget).
34
- export function renderWidgets(widgets) {
37
+ //
38
+ // Each widget writes its own id-scoped CSS next to its markup, which keeps the widget
39
+ // modules self-contained and readable — but a <style> element inside a <div> is invalid
40
+ // HTML. So the style blocks are lifted out here and handed to `cssSink` for the page
41
+ // renderer to emit in <head>. Doing it centrally means widget authors keep writing their
42
+ // CSS inline and never have to think about it. The CSS is already `#id`-scoped, so moving
43
+ // it changes nothing visually.
44
+ export function renderWidgets(widgets, cssSink) {
35
45
  if (!Array.isArray(widgets) || !widgets.length) return '';
36
46
  return widgets.map((w, i) => {
37
47
  const mod = w && REGISTRY[w.type];
38
48
  if (!mod) return '';
39
- try { return mod.render(w.config || {}, `w${i}`); }
40
- catch { return ''; }
49
+ try {
50
+ const html = mod.render(w.config || {}, `w${i}`);
51
+ if (!cssSink) return html;
52
+ return String(html).replace(/<style>([\s\S]*?)<\/style>/gi, (_m, css) => {
53
+ cssSink.push(css);
54
+ return '';
55
+ });
56
+ } catch { return ''; }
41
57
  }).filter(Boolean).join('\n');
42
58
  }
@@ -37,9 +37,9 @@ export function render(config, id) {
37
37
  <div class="rd-meta">Reveal the flow step by step</div>
38
38
  <div data-nodes>${items}</div>
39
39
  <div class="rd-ctrls">
40
- <button class="rd-btn primary" data-next>Reveal next ↓</button>
41
- <button class="rd-btn" data-all>Show all</button>
42
- <button class="rd-btn" data-reset>Reset</button>
40
+ <button type="button" class="rd-btn primary" data-next>Reveal next ↓</button>
41
+ <button type="button" class="rd-btn" data-all>Show all</button>
42
+ <button type="button" class="rd-btn" data-reset>Reset</button>
43
43
  <span class="rd-prog" data-prog></span>
44
44
  </div>
45
45
  <script>(function(){