[], limit: number): Promise {
- const mp3Path = wavPath.replace(/\.wav$/, '.mp3');
- try {
- await execFileAsync('ffmpeg', ['-y', '-i', wavPath, '-codec:a', 'libmp3lame', '-qscale:a', '2', mp3Path]);
- await unlink(wavPath);
- return mp3Path;
- } catch {
- log(' Warning: ffmpeg not available — keeping WAV (install ffmpeg for MP3 output)');
- try { await unlink(mp3Path); } catch { /* ignore */ }
- return wavPath;
- }
-}
-
// ─── Human-readable formatters ────────────────────────────────────
function formatSyllabusMd(syllabus: Syllabus): string {
@@ -548,23 +538,21 @@ async function generateAudio(
log(` Ch ${prefix} Transcript DOCX error: ${err instanceof Error ? err.message : String(err)}`);
}
- // TTS if Gemini key available
- if (GEMINI_API_KEY) {
- log(` Ch ${prefix} Synthesizing audio with Gemini TTS...`);
+ // TTS if ElevenLabs key available
+ if (ELEVENLABS_API_KEY) {
+ log(` Ch ${prefix} Synthesizing audio with ElevenLabs...`);
try {
- const { generateAudiobook } = await import('../src/services/gemini/tts');
+ const { generateAudiobook } = await import('../src/services/elevenLabs/tts');
const { getVoiceOption } = await import('../src/themes');
const voice = getVoiceOption(setup.voiceId);
- const audioBlob = await generateAudiobook(transcriptText, GEMINI_API_KEY, {
- voiceName: voice.id,
- accent: voice.accent,
+ const audioBlob = await generateAudiobook(transcriptText, ELEVENLABS_API_KEY, {
+ voiceId: voice.id,
onProgress: (current, total) => log(` Ch ${prefix} TTS chunk ${current}/${total}`),
});
const arrayBuffer = await audioBlob.arrayBuffer();
- const wavPath = join(OUTPUT_DIR, 'audio', `${prefix}.wav`);
- await save(wavPath, Buffer.from(arrayBuffer));
- const finalPath = await wavToMp3(wavPath);
- log(` Ch ${prefix} Saved ${finalPath.endsWith('.mp3') ? 'MP3' : 'WAV'} audio`);
+ const mp3Path = join(OUTPUT_DIR, 'audio', `${prefix}.mp3`);
+ await save(mp3Path, Buffer.from(arrayBuffer));
+ log(` Ch ${prefix} Saved MP3 audio (ElevenLabs)`);
} catch (err) {
log(` Ch ${prefix} TTS error: ${err instanceof Error ? err.message : String(err)}`);
}
@@ -582,7 +570,7 @@ async function generateSlides(
const slidesText = await streamWithRetry(
{
apiKey: ANTHROPIC_API_KEY!,
- system: buildSlidesPrompt(),
+ system: buildSlidesPrompt(setup.themeId),
messages: [{
role: 'user',
content: buildSlidesUserPrompt(ch.title, ch.keyConcepts, chapterHtml),
@@ -598,8 +586,24 @@ async function generateSlides(
await save(join(OUTPUT_DIR, 'slides', `${prefix}_slides.md`), formatSlidesMd(slides, ch.title));
await save(join(OUTPUT_DIR, 'slides', `${prefix}_slides.json`), JSON.stringify(slides, null, 2));
+ if (!OPENAI_API_KEY) {
+ log(` Ch ${prefix} Skipped PPTX render (no OPENAI_API_KEY for slide images)`);
+ return;
+ }
try {
- const pptxBlob = await generatePptx(slides, syllabus.courseTitle, ch.title, setup.themeId);
+ const { blob: pptxBlob } = await generatePptx(
+ slides,
+ syllabus.courseTitle,
+ ch.title,
+ setup.themeId,
+ OPENAI_API_KEY,
+ {
+ imageQuality: 'high',
+ imageSize: '3840x2160',
+ onProgress: (i, total, phase) =>
+ log(` Ch ${prefix} slide ${phase} ${i}/${total}`),
+ },
+ );
const pptxBuffer = Buffer.from(await pptxBlob.arrayBuffer());
await save(join(OUTPUT_DIR, 'slides', `${prefix}_slides.pptx`), pptxBuffer);
log(` Ch ${prefix} Saved slides (+ PPTX)`);
@@ -613,7 +617,7 @@ async function generateInfographic(
chapterHtml: string,
prefix: string,
) {
- if (!GEMINI_API_KEY) return;
+ if (!OPENAI_API_KEY) return;
log(` Ch ${prefix} Infographic...`);
// Phase 1: generate the prompt
@@ -634,7 +638,10 @@ async function generateInfographic(
console.log('');
// Phase 2: generate the image
- const { base64, mimeType } = await generateInfographicNode(promptText, GEMINI_API_KEY);
+ const { base64, mimeType } = await generateInfographicNode(promptText, OPENAI_API_KEY, {
+ size: '3840x2160',
+ quality: 'high',
+ });
const ext = mimeType.includes('png') ? 'png' : 'jpg';
await save(
join(OUTPUT_DIR, 'infographic', `${prefix}.${ext}`),
@@ -665,7 +672,7 @@ async function generateChapterMaterials(
{ label: `Ch${prefix}-audio`, fn: () => generateAudio(ch, chapterHtml, syllabus, prefix) },
{ label: `Ch${prefix}-slides`, fn: () => generateSlides(ch, chapterHtml, syllabus, prefix) },
];
- if (GEMINI_API_KEY) {
+ if (OPENAI_API_KEY) {
sonnetTasks.push({ label: `Ch${prefix}-infographic`, fn: () => generateInfographic(ch, chapterHtml, prefix) });
}
@@ -709,19 +716,19 @@ async function researchChapter(ch: ChapterSyllabus, syllabus: Syllabus): Promise
};
}
- // Validate DOIs
- const dois = dossier.sources.map(s => s.doi).filter((d): d is string => !!d);
- if (dois.length > 0) {
+ // Verify + enrich sources against Semantic Scholar, Crossref, Unpaywall.
+ if (dossier.sources.length > 0) {
try {
- const validity = await validateDois(dois);
- dossier.sources = dossier.sources.map(s => {
- if (s.doi && validity.has(s.doi) && !validity.get(s.doi)) {
- return { ...s, doi: undefined };
- }
- return s;
+ const { dossier: enriched, stats } = await enrichDossier(dossier, {
+ concurrency: 4,
});
- } catch {
- // DOI validation failed — keep as-is
+ dossier = enriched;
+ const verified = stats.doiVerified + stats.doiResolved;
+ log(
+ ` ${verified}/${stats.total} verified · ${stats.oaFound} open access · ${stats.unverified} unverified`,
+ );
+ } catch (err) {
+ log(` Verification failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
@@ -765,7 +772,7 @@ async function main() {
log(`Output directory: ${OUTPUT_DIR}`);
log(`Models: Opus=${MODELS.opus}, Sonnet=${MODELS.sonnet}`);
if (setup.learnerNotes) log(`Notes: ${setup.learnerNotes}`);
- if (GEMINI_API_KEY) log('Gemini API key detected — infographics and TTS enabled');
+ if (OPENAI_API_KEY) log('OpenAI key detected — image gen enabled; add ELEVENLABS_API_KEY for TTS');
await ensureDir(OUTPUT_DIR);
@@ -790,7 +797,7 @@ async function main() {
model: MODELS.opus,
system: syllabusSystem,
messages: [{ role: 'user', content: syllabusUser }],
- thinkingBudget: 'max',
+ thinkingBudget: 'high',
maxTokens: 16000,
},
{
@@ -827,6 +834,46 @@ async function main() {
return;
}
+ // ── Stage 1b: Learning outcomes table ──────────────────────────
+ log('');
+ log('═══ Stage 1b: Learning Outcomes ═══');
+ let curriculumMap: import('../src/types/course').CurriculumMap | null = null;
+ try {
+ const loText = await streamWithRetry(
+ {
+ apiKey: ANTHROPIC_API_KEY,
+ model: MODELS.haiku,
+ system: buildLearningObjectivesPrompt(),
+ messages: [
+ { role: 'user', content: buildLearningObjectivesUserPrompt(syllabus) },
+ ],
+ maxTokens: 6000,
+ },
+ {},
+ );
+ curriculumMap = parseCurriculumMapResponse(loText);
+ if (curriculumMap) {
+ curriculumMap.syllabusHash = hashSyllabus(syllabus);
+ await save(
+ join(OUTPUT_DIR, 'curriculum-map.json'),
+ JSON.stringify(curriculumMap, null, 2),
+ );
+ await save(
+ join(OUTPUT_DIR, 'learning-outcomes.csv'),
+ buildOutcomesCsv(curriculumMap, syllabus),
+ );
+ await save(
+ join(OUTPUT_DIR, 'learning-outcomes.html'),
+ buildOutcomesPage(curriculumMap, syllabus, setup.themeId),
+ );
+ log(` ${curriculumMap.objectives.length} objectives mapped to ${syllabus.chapters.length} chapters`);
+ } else {
+ log(' Warning: Could not parse learning-outcomes response.');
+ }
+ } catch (err) {
+ log(` Learning-outcomes generation failed: ${err instanceof Error ? err.message : String(err)}`);
+ }
+
// ── Stage 2: Research (3 chapters concurrently) ────────────────
log('');
log('═══ Stage 2: Research (3 concurrent) ═══');
@@ -858,7 +905,7 @@ async function main() {
log('');
log('═══ Stage 3: Building Course Materials ═══');
- const hasGemini = !!GEMINI_API_KEY;
+ const hasImageGen = !!OPENAI_API_KEY;
for (const ch of syllabus.chapters) {
const prefix = pad(ch.number);
@@ -884,11 +931,13 @@ async function main() {
{
apiKey: ANTHROPIC_API_KEY,
model: MODELS.opus,
- system: buildChapterPrompt(setup.themeId, hasGemini),
+ system: buildChapterPrompt(setup.themeId, hasImageGen),
messages: [{
role: 'user',
content: buildChapterUserPrompt(
- syllabus.courseTitle, ch, setup.chapterLength, researchSources, hasGemini,
+ syllabus.courseTitle, ch, setup.chapterLength,
+ { educationLevel: setup.educationLevel, priorKnowledge: setup.priorKnowledge, learnerNotes: setup.learnerNotes },
+ researchSources, hasImageGen, setup.chapterLengthBrief,
),
}],
thinkingBudget: 'high',
@@ -903,12 +952,15 @@ async function main() {
chapterHtml = extractHtml(chapterText);
// Replace Gemini image placeholders with actual generated images
- if (GEMINI_API_KEY) {
+ if (OPENAI_API_KEY) {
log(` Ch ${prefix} Replacing image placeholders...`);
- chapterHtml = await replaceGeminiImagePlaceholdersNode(chapterHtml, GEMINI_API_KEY);
+ chapterHtml = await replaceAiImagePlaceholdersNode(chapterHtml, OPENAI_API_KEY);
}
- await save(join(OUTPUT_DIR, 'chapters', `${prefix}_${slug}.html`), chapterHtml);
+ await save(
+ join(OUTPUT_DIR, 'chapters', `${prefix}_${slug}.html`),
+ renderChapterHtml(chapterHtml, setup.themeId, ch.title),
+ );
log(` Ch ${prefix} Saved chapter HTML`);
} catch (err) {
log(` Ch ${prefix} ERROR: ${err instanceof Error ? err.message : String(err)}`);
@@ -924,6 +976,7 @@ async function main() {
setup,
syllabus,
researchDossiers: dossiers,
+ curriculumMap,
generatedAt: new Date().toISOString(),
};
await save(join(OUTPUT_DIR, 'course.json'), JSON.stringify(course, null, 2));
diff --git a/scripts/lib/codexLandingTemplate.ts b/scripts/lib/codexLandingTemplate.ts
new file mode 100644
index 0000000..e1cb276
--- /dev/null
+++ b/scripts/lib/codexLandingTemplate.ts
@@ -0,0 +1,138 @@
+/**
+ * Codex landing page for courses.classbuild.ai — the parchment index that
+ * links to the six example courses. Matches the ClassBuild site design system
+ * (Newsreader prose, JetBrains Mono metadata, cream ground, maroon + gilt).
+ */
+
+interface LandingCourse {
+ slug: string;
+ name: string;
+ subtitle: string;
+ desc: string;
+ chapters: number;
+ audience: string;
+ theme: string; // chapter-theme label (Terminal, Almanac, …)
+}
+
+// Curated card copy (preserved from the prior landing), with correct theme tags.
+const COURSES: LandingCourse[] = [
+ { slug: 'raising-a-puppy', name: 'Puppy School', subtitle: 'Socialisation, House Training, and Surviving the First Year', desc: 'From the critical 3–16 week socialisation window to adolescent regression at 18 months — everything a first-time owner needs to know.', chapters: 8, audience: 'General public', theme: 'Almanac' },
+ { slug: 'game-theory', name: 'The Strategy of Everything', subtitle: 'Nash Equilibria, Auctions, and the Mathematics of Trust', desc: 'Penalty kicks, arms races, spectrum auctions, and evolution — twelve chapters from the prisoner’s dilemma to Arrow’s impossibility theorem.', chapters: 12, audience: 'Advanced undergrad', theme: 'Terminal' },
+ { slug: 'understanding-your-sleep', name: 'Understanding Your Sleep', subtitle: 'Circadian Rhythms, Sleep Architecture, and the Caffeine Equation', desc: 'Why morning light matters more than blue-light glasses, how sleep stages actually work, and where the evidence on eight hours gets overstated.', chapters: 7, audience: 'General public', theme: 'Notebook' },
+ { slug: 'leadership-through-crisis', name: 'Leadership through Crisis', subtitle: 'Grenfell Tower, Fukushima, and the Thai Cave Rescue', desc: 'Real case studies in high-stakes decision-making — from initial mobilisation under pressure to building a post-incident learning culture.', chapters: 8, audience: 'Professional', theme: 'Studio' },
+ { slug: 'science-and-art-of-tea', name: 'One Leaf, Ten Thousand Cups', subtitle: 'Chemistry, Culture, and Ceremony in Every Cup', desc: 'One plant becomes six types of tea. The oxidation science, brewing variables, and centuries of ritual behind the world’s most consumed drink.', chapters: 6, audience: 'General public', theme: 'Press' },
+ { slug: 'training-for-your-first-marathon', name: 'From Couch to Finish Line', subtitle: 'Training Science, Nutrition, and the Psychology of 42.2 km', desc: 'Evidence-based periodisation, race-day fuelling, and injury prevention — for people who aren’t sure they’re “a runner” yet.', chapters: 8, audience: 'General public', theme: 'Storybook' },
+];
+
+const FONTS_URL =
+ 'https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500;1,6..72,600&family=JetBrains+Mono:wght@400;500;600&display=swap';
+
+function esc(s: string): string {
+ return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+}
+
+export function buildLandingHtml(): string {
+ const cards = COURSES.map(
+ (c, i) => `
+
+
+
+
${esc(c.name)}
+
${esc(c.subtitle)}
+
${esc(c.desc)}
+
+ ${c.chapters} chapters
+ ${esc(c.audience)}
+ ${esc(c.theme)}
+
+
Explore course →
+
+ `,
+ ).join('\n');
+
+ return `
+
+
+
+
+
+
+ClassBuild — Example Courses
+
+
+
+
+
+
+
+
+ ClassBuild Example Courses
+ See what ClassBuild creates
+ Six real courses, built end-to-end. Each one was generated from a single topic description — chapters, quizzes, slides, audiobooks, and teaching packs.
+ Build your own at ClassBuild →
+
+
+ ${cards}
+
+
+
+
+`;
+}
diff --git a/scripts/lib/codexViewerTemplate.ts b/scripts/lib/codexViewerTemplate.ts
new file mode 100644
index 0000000..b79b78e
--- /dev/null
+++ b/scripts/lib/codexViewerTemplate.ts
@@ -0,0 +1,403 @@
+/**
+ * Codex course viewer — the published per-course index.html.
+ *
+ * Chrome (sidebar, tab bar, cards, welcome) is the ClassBuild "Codex" Parchment
+ * design system: cream ground, ink prose in Newsreader, maroon + gilt accents,
+ * JetBrains Mono metadata. Reading / Practice Quiz / Weekly Challenge load the
+ * already-themed course HTML in sandboxed iframes (so each course keeps its own
+ * Terminal/Studio/Almanac/… look). Slides, Audio, Discussion and Activities are
+ * rendered natively in Codex.
+ *
+ * Showcase tab order: Reading · Slides · Audio · Practice Quiz · Discussion ·
+ * Activities · Weekly Challenge. A tab appears only when its data exists.
+ */
+
+export interface SlideMeta { title?: string; }
+export interface DiscussionItem { prompt: string; hook: string; }
+export interface ActivityItem {
+ title: string; duration?: string; description?: string;
+ materials?: string; learningGoal?: string; scalingNotes?: string;
+}
+export interface DownloadLink { label: string; path: string; }
+
+export interface CodexChapterData {
+ number: number;
+ title: string;
+ narrative?: string;
+ chapterHtmlPath?: string; // chapters/NN.html
+ quizHtmlPath?: string; // quizzes/NN.html
+ challengeHtmlPath?: string; // challenges/NN.html
+ audioPath?: string; // audio/NN.mp3
+ transcript?: string;
+ slides?: SlideMeta[]; // for captions; images by convention slides/chNN/slide-MM.jpg
+ discussion?: DiscussionItem[];
+ activities?: ActivityItem[];
+ downloadLinks?: DownloadLink[];
+}
+
+export interface CodexCourseMeta {
+ courseTitle: string;
+ courseOverview: string;
+ audience?: string;
+ themeLabel?: string; // e.g. "Terminal" — the chapter theme, for the sidebar tag
+}
+
+const FONTS_URL =
+ 'https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;0,6..72,700;1,6..72,400;1,6..72,500;1,6..72,600&family=JetBrains+Mono:wght@400;500;600&display=swap';
+
+function esc(s: string): string {
+ return String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"');
+}
+function pad2(n: number): string { return String(n).padStart(2, '0'); }
+
+/** Clamp to ~n chars at a word boundary, adding an ellipsis when cut. */
+function clamp(s: string, n: number): string {
+ const str = s.trim();
+ if (str.length <= n) return str;
+ const cut = str.slice(0, n);
+ const lastSpace = cut.lastIndexOf(' ');
+ return (lastSpace > n * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd() + '…';
+}
+
+function transcriptToHtml(md: string): string {
+ // Light rendering: split on blank lines into paragraphs; strip basic md marks.
+ return md
+ .split(/\n{2,}/)
+ .map((para) => para.trim())
+ .filter(Boolean)
+ .map((para) => `${esc(para.replace(/^#+\s*/, '').replace(/[*_`]/g, ''))}
`)
+ .join('\n');
+}
+
+export function buildCodexViewerHtml(course: CodexCourseMeta, chapters: CodexChapterData[]): string {
+ const sorted = [...chapters].sort((a, b) => a.number - b.number);
+
+ const navItems = sorted
+ .map(
+ (ch) =>
+ `${pad2(ch.number)} ${esc(ch.title)} `,
+ )
+ .join('\n ');
+
+ const panels = sorted
+ .map((ch) => {
+ const tabs: Array<{ id: string; label: string }> = [];
+ const contents: string[] = [];
+
+ // Tab order mirrors the ClassBuild app: Reading · Practice · Challenge ·
+ // Discussion · Activities · Audio · Slides.
+
+ // Reading
+ if (ch.chapterHtmlPath) {
+ tabs.push({ id: 'reading', label: 'Reading' });
+ contents.push(
+ `
`,
+ );
+ }
+ // Practice
+ if (ch.quizHtmlPath) {
+ tabs.push({ id: 'quiz', label: 'Practice' });
+ contents.push(
+ `
`,
+ );
+ }
+ // Challenge
+ if (ch.challengeHtmlPath) {
+ tabs.push({ id: 'challenge', label: 'Challenge' });
+ contents.push(
+ `
`,
+ );
+ }
+ // Discussion
+ if (ch.discussion && ch.discussion.length > 0) {
+ tabs.push({ id: 'discussion', label: 'Discussion' });
+ const cards = ch.discussion
+ .map(
+ (d, i) =>
+ `${i + 1}
${esc(d.prompt)}
${esc(d.hook)}
`,
+ )
+ .join('\n');
+ contents.push(``);
+ }
+ // Activities
+ if (ch.activities && ch.activities.length > 0) {
+ tabs.push({ id: 'activities', label: 'Activities' });
+ const cards = ch.activities
+ .map((a) => {
+ const meta: string[] = [];
+ if (a.materials) meta.push(`Materials ${esc(a.materials)}
`);
+ if (a.learningGoal) meta.push(`Learning goal ${esc(a.learningGoal)}
`);
+ if (a.scalingNotes) meta.push(`Scaling ${esc(a.scalingNotes)}
`);
+ return `
${esc(a.title)} ${a.duration ? `${esc(a.duration)} ` : ''}${a.description ? `
${esc(a.description)}
` : ''}${meta.length ? `
${meta.join('')}
` : ''}
`;
+ })
+ .join('\n');
+ contents.push(``);
+ }
+ // Audio
+ if (ch.audioPath) {
+ const transcript = ch.transcript
+ ? `Transcript ${transcriptToHtml(ch.transcript)}
`
+ : '';
+ tabs.push({ id: 'audio', label: 'Audio' });
+ contents.push(
+ ``,
+ );
+ }
+ // Slides
+ if (ch.slides && ch.slides.length > 0) {
+ tabs.push({ id: 'slides', label: 'Slides' });
+ const cards = ch.slides
+ .map((s, i) => {
+ const src = `slides/ch${pad2(ch.number)}/slide-${pad2(i + 1)}.jpg`;
+ return `${pad2(i + 1)} ${esc(s.title || '')} `;
+ })
+ .join('\n');
+ contents.push(``);
+ }
+
+ const downloads =
+ ch.downloadLinks && ch.downloadLinks.length > 0
+ ? ``
+ : '';
+
+ const tabBar =
+ tabs.length > 1
+ ? `${tabs.map((t, i) => `${t.label} `).join('')}
`
+ : '';
+
+ // Mark first pane active
+ const firstId = tabs[0]?.id;
+ const renderedContents = contents
+ .map((c) => (firstId && c.includes(`data-pane="${firstId}"`) ? c.replace('class="tab-pane"', 'class="tab-pane active"') : c))
+ .join('\n');
+
+ return `
+
+ ${tabBar}
+ ${renderedContents}
+ `;
+ })
+ .join('\n');
+
+ const tag = course.themeLabel ? `${esc(course.themeLabel)} ` : '';
+ const overview = course.courseOverview || '';
+
+ return `
+
+
+
+
+
+
+${esc(course.courseTitle)} — ClassBuild
+
+
+
+
+
+
+
+
+
+
+
+
${sorted.length} ${sorted.length === 1 ? 'class' : 'classes'}
+
${esc(course.courseTitle)}
+ ${overview ? `
${esc(overview.slice(0, 220))}${overview.length > 220 ? '…' : ''}
` : ''}
+
+ ${sorted.length > 0 ? `
+ Begin Class ${pad2(sorted[0].number)} →
+ Browse all classes
+
` : ''}
+
+ ${panels}
+
+
+
+`;
+}
diff --git a/scripts/lib/coursePackageTemplate.ts b/scripts/lib/coursePackageTemplate.ts
index d984ef9..fac62d0 100644
--- a/scripts/lib/coursePackageTemplate.ts
+++ b/scripts/lib/coursePackageTemplate.ts
@@ -532,6 +532,16 @@ export function buildCoursePackageHtml(
box-shadow: var(--card-hover-shadow);
}
+ .slide-thumb {
+ display: block;
+ width: 100%;
+ height: auto;
+ margin: 0.85rem 0 0.25rem;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: var(--card);
+ }
+
.card-num {
display: inline-flex;
align-items: center;
@@ -948,6 +958,7 @@ export function buildCoursePackageHtml(
var html = '';
ch.slides.forEach(function(s, i) {
html += '
' + (i + 1) + ' ' + esc(s.title) + ' ';
+ html += '
';
if (s.layout) html += '
' + s.layout + ' ';
if (s.bodyText) html += '
' + esc(s.bodyText) + '
';
if (s.bullets && s.bullets.length > 0) {
diff --git a/scripts/lib/node-image-placer.ts b/scripts/lib/node-image-placer.ts
index 08c4f6e..5f3c6d6 100644
--- a/scripts/lib/node-image-placer.ts
+++ b/scripts/lib/node-image-placer.ts
@@ -1,56 +1,127 @@
/**
- * Node-compatible version of src/services/gemini/imagePlacer.ts.
- * Replaces
placeholders with actual Gemini-generated images.
- * Uses generateInfographicNode (raw base64) instead of browser canvas compression.
+ * Node-compatible counterpart of src/services/openai/imagePlacer.ts.
+ * Replaces placeholders in chapter HTML with
+ * gpt-image-2 renders.
*/
-import { generateInfographicNode } from './node-image';
+import { generateImageNode } from './node-image';
+import { withSafetyClause } from '../../src/services/openai/safetyClause';
-async function generateWithRetry(prompt: string, apiKey: string, maxRetries = 3): Promise {
+const ASPECT_TO_SIZE: Record = {
+ square: '1024x1024',
+ portrait: '1024x1536',
+ landscape: '1536x1024',
+ 'wide-landscape': '2048x1152',
+};
+
+interface PlaceholderMatch {
+ full: string;
+ prompt: string;
+ aspect: string;
+ caption: string;
+}
+
+function findPlaceholders(html: string): PlaceholderMatch[] {
+ const figureRegex =
+ /]*\bclass="ai-image"[^>]*>([\s\S]*?)<\/figure>/gi;
+ const matches: PlaceholderMatch[] = [];
+ let m: RegExpExecArray | null;
+ while ((m = figureRegex.exec(html)) !== null) {
+ const full = m[0];
+ const inner = m[1];
+ const promptMatch =
+ /data-prompt="([^"]+)"/i.exec(full) ??
+ /data-prompt='([^']+)'/i.exec(full);
+ const aspectMatch =
+ /data-aspect="([^"]+)"/i.exec(full) ??
+ /data-aspect='([^']+)'/i.exec(full);
+ const captionMatch = /]*>([\s\S]*?)<\/figcaption>/i.exec(inner);
+ if (!promptMatch) continue;
+ matches.push({
+ full,
+ prompt: promptMatch[1],
+ aspect: (aspectMatch?.[1] ?? 'landscape').toLowerCase(),
+ caption: captionMatch ? captionMatch[1].trim() : '',
+ });
+ }
+ return matches;
+}
+
+async function generateWithRetry(
+ prompt: string,
+ apiKey: string,
+ size: string,
+ maxRetries = 2,
+): Promise {
+ const finalPrompt = withSafetyClause(prompt);
+ let lastErr: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
- const { base64, mimeType } = await generateInfographicNode(prompt, apiKey);
+ const { base64, mimeType } = await generateImageNode(finalPrompt, apiKey, {
+ size,
+ quality: 'high',
+ });
return `data:${mimeType};base64,${base64}`;
} catch (err) {
+ lastErr = err;
const msg = err instanceof Error ? err.message : String(err);
- const isQuota = /quota|rate|429|resource.*exhausted/i.test(msg);
- if (!isQuota || attempt === maxRetries) throw err;
- // Exponential backoff: 5s, 10s, 20s
- await new Promise(r => setTimeout(r, 5000 * Math.pow(2, attempt)));
+ const transient = /429|rate|quota|5\d\d/i.test(msg);
+ if (!transient || attempt === maxRetries) throw err;
+ await new Promise((r) => setTimeout(r, 4000 * Math.pow(2, attempt)));
}
}
- throw new Error('Gemini retry exhausted');
+ throw lastErr;
}
-export async function replaceGeminiImagePlaceholdersNode(html: string, apiKey: string): Promise {
- const placeholderRegex = /\s*([^<]*)<\/figcaption>\s*<\/figure>/g;
- const matches: Array<{ full: string; prompt: string; caption: string }> = [];
- let m: RegExpExecArray | null;
- while ((m = placeholderRegex.exec(html)) !== null) {
- matches.push({ full: m[0], prompt: m[1], caption: m[2] });
- }
+export async function replaceAiImagePlaceholdersNode(
+ html: string,
+ apiKey: string,
+): Promise {
+ const matches = findPlaceholders(html);
if (matches.length === 0) return html;
- // Generate images sequentially to avoid hitting Gemini rate limits
- const results: Array<{ status: 'fulfilled'; value: string } | { status: 'rejected' }> = [];
- for (const { prompt } of matches) {
+ const results: Array = [];
+ for (const m of matches) {
+ const size = ASPECT_TO_SIZE[m.aspect] ?? ASPECT_TO_SIZE.landscape;
try {
- const value = await generateWithRetry(prompt, apiKey);
- results.push({ status: 'fulfilled', value });
+ results.push(await generateWithRetry(m.prompt, apiKey, size));
} catch {
- results.push({ status: 'rejected' });
+ results.push(null);
}
}
- let result = html;
+ let out = html;
for (let i = 0; i < matches.length; i++) {
- const r = results[i];
- if (r.status === 'fulfilled') {
- const replacement = `${matches[i].caption} `;
- result = result.replace(matches[i].full, replacement);
+ const m = matches[i];
+ const dataUri = results[i];
+ // Strip a leading "Fig. N" the model sometimes includes despite the prompt
+ // telling it not to — otherwise we end up with "Fig. Fig. 1 …".
+ const rawCaption = (m.caption || '')
+ .replace(/^\s*(?:Fig(?:ure)?\.?\s*\d+[.:]?\s*)/i, '')
+ .trim();
+ const figNum = i + 1;
+ // Keep the original prompt + aspect on the rendered figure so the SPA's
+ // per-image refine flow can pick them up when the user opens the chapter
+ // later. (CLI-generated chapters don't refine in-CLI, but the SPA may
+ // load and refine them.)
+ const figureAttrs = `class="ch-figure" data-image-idx="${i}" data-prompt="${m.prompt.replace(/"/g, '"')}" data-aspect="${m.aspect.replace(/"/g, '"')}"`;
+ if (dataUri) {
+ // Theme CSS sets a fixed height on .ch-figbox for the empty-state
+ // placeholder; with a real image inside we let it grow to fit.
+ const replacement = `${
+ rawCaption
+ ? `Fig. ${figNum} ${rawCaption} `
+ : ''
+ } `;
+ out = out.replace(m.full, replacement);
} else {
- const fallback = `${matches[i].caption} `;
- result = result.replace(matches[i].full, fallback);
+ const fallback = `image unavailable
${
+ rawCaption ? `Fig. ${figNum} ${rawCaption} ` : ''
+ } `;
+ out = out.replace(m.full, fallback);
}
}
- return result;
+ return out;
}
+
+// Backwards-compat alias so the older import name still works.
+export const replaceGeminiImagePlaceholdersNode = replaceAiImagePlaceholdersNode;
diff --git a/scripts/lib/node-image.ts b/scripts/lib/node-image.ts
index 886e438..7a03357 100644
--- a/scripts/lib/node-image.ts
+++ b/scripts/lib/node-image.ts
@@ -1,77 +1,60 @@
/**
- * Node.js Gemini image generation — skips browser canvas compression,
- * saves raw PNG base64 directly.
+ * Node.js wrapper around OpenAI gpt-image-2 — returns raw base64 plus the
+ * mime type, leaving the data-URI assembly to the caller.
*/
-interface GeminiPart {
- text?: string;
- inlineData?: { mimeType: string; data: string };
- thought?: boolean;
+interface ImageResponse {
+ data?: Array<{ b64_json?: string }>;
+ error?: { message?: string };
}
-interface GeminiResponse {
- candidates?: Array<{
- content?: {
- parts?: GeminiPart[];
- };
- }>;
- error?: { message: string };
+export interface NodeImageOptions {
+ size?: string;
+ quality?: 'low' | 'medium' | 'high' | 'auto';
}
-/**
- * Generate an infographic using Gemini's image generation model.
- * Returns raw base64 image data (no data URI prefix).
- */
-export async function generateInfographicNode(
+export async function generateImageNode(
prompt: string,
apiKey: string,
+ options: NodeImageOptions = {},
): Promise<{ base64: string; mimeType: string }> {
- const model = 'gemini-3.1-flash-image-preview';
- const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
-
const body = {
- contents: [{ parts: [{ text: prompt }] }],
- generationConfig: {
- responseModalities: ['TEXT', 'IMAGE'],
- },
+ model: 'gpt-image-2',
+ prompt,
+ n: 1,
+ size: options.size ?? '1024x1024',
+ quality: options.quality ?? 'high',
+ output_format: 'jpeg',
+ output_compression: 90,
};
- const res = await fetch(url, {
+ const res = await fetch('https://api.openai.com/v1/images/generations', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
body: JSON.stringify(body),
});
if (!res.ok) {
- const errBody = await res.text();
- throw new Error(`Gemini API error (${res.status}): ${errBody.slice(0, 200)}`);
- }
-
- const data: GeminiResponse = await res.json();
-
- if (data.error) {
- throw new Error(`Gemini error: ${data.error.message}`);
- }
-
- const parts = data.candidates?.[0]?.content?.parts;
- if (!parts || parts.length === 0) {
- throw new Error('Gemini returned no content');
- }
-
- // Find the last non-thought image part
- let imagePart: GeminiPart | undefined;
- for (const part of parts) {
- if (part.inlineData && !part.thought) {
- imagePart = part;
+ let detail = '';
+ try {
+ const errBody = (await res.json()) as ImageResponse;
+ detail = errBody.error?.message ?? '';
+ } catch {
+ detail = await res.text().catch(() => '');
}
+ throw new Error(`OpenAI image API ${res.status}: ${detail.slice(0, 300)}`);
}
- if (!imagePart?.inlineData) {
- throw new Error('Gemini returned no image');
+ const json = (await res.json()) as ImageResponse;
+ const b64 = json.data?.[0]?.b64_json;
+ if (!b64) {
+ throw new Error('OpenAI returned no image data.');
}
-
- return {
- base64: imagePart.inlineData.data,
- mimeType: imagePart.inlineData.mimeType,
- };
+ return { base64: b64, mimeType: 'image/jpeg' };
}
+
+/** Backwards-compat alias for the previous helper name. */
+export const generateInfographicNode = generateImageNode;
diff --git a/scripts/lib/publish.ts b/scripts/lib/publish.ts
index bd99724..372e03e 100644
--- a/scripts/lib/publish.ts
+++ b/scripts/lib/publish.ts
@@ -5,7 +5,7 @@
* builds index.html via coursePackageTemplate, and copies assets.
*/
-import { readFile, mkdir, copyFile, readdir, stat } from 'node:fs/promises';
+import { readFile, mkdir, copyFile, readdir, stat, cp } from 'node:fs/promises';
import { writeFile } from 'node:fs/promises';
import { join, extname } from 'node:path';
@@ -17,6 +17,8 @@ import type {
} from '../../src/types/course';
import { buildCoursePackageHtml } from './coursePackageTemplate';
import type { ChapterPackageData } from './coursePackageTemplate';
+import { buildCodexViewerHtml } from './codexViewerTemplate';
+import type { CodexChapterData } from './codexViewerTemplate';
function pad(n: number): string {
return String(n).padStart(2, '0');
@@ -83,18 +85,52 @@ async function findFileAnyExt(dir: string, prefix: string, exts: string[]): Prom
}
}
+export interface AssembleOptions {
+ /** Themed chapter HTML dir (default: /chapters). */
+ chaptersDir?: string;
+ /** Themed slides JSON dir (default: /slides). */
+ slidesDir?: string;
+ /** Chapter figure images dir (default: /img). Copied to publish/chapters/img. */
+ chapterImgDir?: string;
+ /** Root of per-chapter slide images ch/slide-.jpg (default: /img). */
+ slideImgRootDir?: string;
+ /** Dir holding regenerated _slides.pptx (default: ). */
+ pptxSrcDir?: string;
+ /** Dir holding weekly-challenge HTML (_challenge.html) (default: /weekly-challenge). */
+ challengeDir?: string;
+ /** Human label for the course's chapter theme, shown in the viewer sidebar (e.g. "Terminal"). */
+ themeLabel?: string;
+ /** Use the Codex showcase viewer instead of the legacy course package viewer. */
+ codex?: boolean;
+}
+
export async function assemblePublishPackage(
outputDir: string,
themeId?: string,
+ opts: AssembleOptions = {},
): Promise {
const publishDir = join(outputDir, 'publish');
+ const chaptersDir = opts.chaptersDir ?? join(outputDir, 'chapters');
+ const slidesDir = opts.slidesDir ?? join(outputDir, 'slides');
+ const chapterImgDir = opts.chapterImgDir ?? join(chaptersDir, 'img');
+ const slideImgRootDir = opts.slideImgRootDir ?? join(slidesDir, 'img');
+ const pptxSrcDir = opts.pptxSrcDir ?? slidesDir;
+ const challengeDir = opts.challengeDir ?? join(outputDir, 'weekly-challenge');
+ const challengePaths: Record = {};
+
// Create directory structure
- const subdirs = ['chapters', 'quizzes', 'audio', 'img', 'downloads'];
+ const subdirs = ['chapters', 'quizzes', 'audio', 'img', 'downloads', 'slides', 'challenges'];
for (const sub of subdirs) {
await mkdir(join(publishDir, sub), { recursive: true });
}
+ // Copy chapter figure images. Chapter HTML references them as img/chNN-figMM.jpg;
+ // loaded in the viewer's iframe (base = chapters/NN.html) they resolve to chapters/img/.
+ if (await exists(chapterImgDir)) {
+ await cp(chapterImgDir, join(publishDir, 'chapters', 'img'), { recursive: true });
+ }
+
// ── Load syllabus ──────────────────────────────────────────────
let syllabus: Syllabus | null = null;
let dossiers: ResearchDossier[] = [];
@@ -129,7 +165,7 @@ export async function assemblePublishPackage(
// -- Chapter HTML --
let chapterHtmlPath: string | undefined;
- const chapterSrc = await findFile(join(outputDir, 'chapters'), prefix, '.html');
+ const chapterSrc = await findFile(chaptersDir, prefix, '.html');
if (chapterSrc) {
const destName = `${prefix}.html`;
await copyHtmlWithResizeShim(chapterSrc, join(publishDir, 'chapters', destName));
@@ -145,6 +181,13 @@ export async function assemblePublishPackage(
quizHtmlPath = `quizzes/${destName}`;
}
+ // -- Weekly Challenge HTML --
+ const challengeSrc = await findFile(challengeDir, `${prefix}_challenge`, '.html');
+ if (challengeSrc) {
+ await copyFile(challengeSrc, join(publishDir, 'challenges', `${prefix}.html`));
+ challengePaths[ch.number] = `challenges/${prefix}.html`;
+ }
+
// -- Audio MP3 --
let audioPath: string | undefined;
const audioSrc = await findFileAnyExt(join(outputDir, 'audio'), prefix, ['.mp3']);
@@ -183,9 +226,15 @@ export async function assemblePublishPackage(
// -- Slides JSON --
const slides = await readJson(
- join(outputDir, 'slides', `${prefix}_slides.json`)
+ join(slidesDir, `${prefix}_slides.json`)
);
+ // -- Slide images for viewer cards (slides/chNN/slide-MM.jpg, relative to index.html) --
+ const slideImgSrc = join(slideImgRootDir, `ch${prefix}`);
+ if (slides && await exists(slideImgSrc)) {
+ await cp(slideImgSrc, join(publishDir, 'slides', `ch${prefix}`), { recursive: true });
+ }
+
// -- In-Class Quiz JSON --
const inClassQuiz = await readJson(
join(outputDir, 'quizzes', `${prefix}_inclass.json`)
@@ -196,7 +245,7 @@ export async function assemblePublishPackage(
// -- Collect downloadable files --
// Slides PPTX
- const pptxSrc = await findFile(join(outputDir, 'slides'), `${prefix}_slides`, '.pptx');
+ const pptxSrc = await findFile(pptxSrcDir, `${prefix}_slides`, '.pptx');
if (pptxSrc) {
const destName = `${prefix}_slides.pptx`;
await copyFile(pptxSrc, join(publishDir, 'downloads', destName));
@@ -282,7 +331,33 @@ export async function assemblePublishPackage(
}
// ── Build index.html ───────────────────────────────────────────
- const html = buildCoursePackageHtml(syllabus, chaptersData, themeId);
+ let html: string;
+ if (opts.codex) {
+ const codexChapters: CodexChapterData[] = chaptersData.map((c) => ({
+ number: c.number,
+ title: c.title,
+ narrative: c.narrative,
+ chapterHtmlPath: c.chapterHtmlPath,
+ quizHtmlPath: c.quizHtmlPath,
+ challengeHtmlPath: challengePaths[c.number],
+ audioPath: c.audioPath,
+ transcript: c.transcript,
+ slides: c.slides,
+ discussion: c.discussion,
+ activities: c.activities,
+ downloadLinks: c.downloadLinks,
+ }));
+ html = buildCodexViewerHtml(
+ {
+ courseTitle: syllabus.courseTitle,
+ courseOverview: syllabus.courseOverview ?? '',
+ themeLabel: opts.themeLabel,
+ },
+ codexChapters,
+ );
+ } else {
+ html = buildCoursePackageHtml(syllabus, chaptersData, themeId);
+ }
await writeFile(join(publishDir, 'index.html'), html);
const sizeKb = Math.round(Buffer.byteLength(html) / 1024);
diff --git a/scripts/render-ai-images.ts b/scripts/render-ai-images.ts
new file mode 100644
index 0000000..e15769c
--- /dev/null
+++ b/scripts/render-ai-images.ts
@@ -0,0 +1,197 @@
+/**
+ * Render the placeholders in a chapter HTML file
+ * using OpenAI gpt-image-2, then write the images out as JPEG files in an
+ * adjacent ./img/ directory and rewrite the placeholders to .
+ *
+ * Why files rather than base64-inline: the HTML stays small enough to edit
+ * by hand, the same images are reusable when we re-assemble the published
+ * course package, and the inline data URI versions blow chapter files past
+ * 1 MB once we have two or three figures.
+ *
+ * OPENAI_API_KEY=sk-… npx tsx scripts/render-ai-images.ts \
+ * output/science-and-art-of-tea/chapters-press/01_one-leaf-a-thousand-mountains.html
+ *
+ * Run with --dry to see what it would do without spending money.
+ */
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+const ENDPOINT = 'https://api.openai.com/v1/images/generations';
+const MODEL = 'gpt-image-2';
+
+const ASPECT_TO_SIZE: Record = {
+ square: '1024x1024',
+ portrait: '1024x1536',
+ landscape: '1536x1024',
+ 'wide-landscape': '2048x1152',
+};
+
+const SAFETY_CLAUSE =
+ ' Render only text, numbers, dates, names, and citations that appear verbatim earlier in this prompt. Do not invent values: no made-up axis numbers, percentages, sample sizes, dates, author names, or journal titles. If a chart or label would require data that is not in this prompt, omit the values entirely and render the visual as an unlabelled abstract trend.';
+
+interface Placeholder {
+ full: string;
+ prompt: string;
+ aspect: string;
+ caption: string;
+ imageIdx?: string; // when set, output filename uses this idx instead of document order
+}
+
+function findPlaceholders(html: string): Placeholder[] {
+ const re = /]*\bclass="ai-image"[^>]*>([\s\S]*?)<\/figure>/gi;
+ const out: Placeholder[] = [];
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(html)) !== null) {
+ const full = m[0];
+ const inner = m[1];
+ const promptM = /data-prompt="([^"]+)"/i.exec(full);
+ const aspectM = /data-aspect="([^"]+)"/i.exec(full);
+ const capM = /]*>([\s\S]*?)<\/figcaption>/i.exec(inner);
+ const idxM = /data-image-idx="([^"]+)"/i.exec(full);
+ if (!promptM) continue;
+ out.push({
+ full,
+ prompt: decodeAttr(promptM[1]),
+ aspect: (aspectM?.[1] ?? 'landscape').toLowerCase(),
+ caption: capM ? capM[1].trim() : '',
+ imageIdx: idxM ? idxM[1] : undefined,
+ });
+ }
+ return out;
+}
+
+function decodeAttr(s: string): string {
+ return s.replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
+}
+
+function escapeAttr(s: string): string {
+ return s.replace(/&/g, '&').replace(/"/g, '"');
+}
+
+async function renderImageOnce(prompt: string, aspect: string, apiKey: string): Promise {
+ const body = {
+ model: MODEL,
+ prompt: prompt.trimEnd() + SAFETY_CLAUSE,
+ n: 1,
+ size: ASPECT_TO_SIZE[aspect] ?? ASPECT_TO_SIZE.landscape,
+ quality: 'high',
+ output_format: 'jpeg',
+ output_compression: 90,
+ };
+ const res = await fetch(ENDPOINT, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const text = await res.text();
+ const err = new Error(`OpenAI ${res.status}: ${text.slice(0, 400)}`) as Error & { status?: number };
+ err.status = res.status;
+ throw err;
+ }
+ const json = await res.json() as { data?: Array<{ b64_json?: string }>; error?: { message?: string } };
+ const b64 = json.data?.[0]?.b64_json;
+ if (!b64) throw new Error(`OpenAI returned no image: ${JSON.stringify(json).slice(0, 200)}`);
+ return Buffer.from(b64, 'base64');
+}
+
+async function renderImage(prompt: string, aspect: string, apiKey: string, maxRetries = 3): Promise {
+ let lastErr: unknown;
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ try {
+ return await renderImageOnce(prompt, aspect, apiKey);
+ } catch (err) {
+ lastErr = err;
+ const status = (err as { status?: number }).status;
+ const transient = status === undefined || status === 429 || (status >= 500 && status < 600);
+ if (!transient || attempt === maxRetries) throw err;
+ const wait = 4000 * Math.pow(2, attempt) + Math.floor(Math.random() * 1500);
+ await new Promise((r) => setTimeout(r, wait));
+ }
+ }
+ throw lastErr;
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const dry = args.includes('--dry');
+ const htmlPath = args.find((a) => !a.startsWith('-'));
+ if (!htmlPath) {
+ console.error('usage: tsx render-ai-images.ts [--dry]');
+ process.exit(2);
+ }
+ const apiKey = process.env.OPENAI_API_KEY;
+ if (!apiKey && !dry) {
+ console.error('OPENAI_API_KEY not set');
+ process.exit(2);
+ }
+
+ const abs = path.resolve(htmlPath);
+ const html = await fs.readFile(abs, 'utf8');
+ const placeholders = findPlaceholders(html);
+ console.log(`${placeholders.length} placeholder(s) in ${path.basename(abs)}`);
+
+ if (placeholders.length === 0) {
+ console.log('Nothing to render.');
+ return;
+ }
+
+ const chapterStem = path.basename(abs, '.html').match(/^(\d+)/)?.[1] ?? '01';
+ const imgDir = path.join(path.dirname(abs), 'img');
+ await fs.mkdir(imgDir, { recursive: true });
+
+ let out = html;
+ for (let i = 0; i < placeholders.length; i++) {
+ const p = placeholders[i];
+ // Prefer data-image-idx for filename numbering (so partial re-renders
+ // preserve their original ch01-fig02.jpg slot). Fall back to document order.
+ const figNum = p.imageIdx !== undefined ? parseInt(p.imageIdx, 10) + 1 : i + 1;
+ const filename = `ch${chapterStem}-fig${String(figNum).padStart(2, '0')}.jpg`;
+ const filePath = path.join(imgDir, filename);
+ const relPath = `img/${filename}`;
+ console.log(`\n[${i + 1}/${placeholders.length}] ${p.aspect} → ${filename}`);
+ console.log(` prompt: ${p.prompt.slice(0, 140)}…`);
+
+ if (dry) {
+ console.log(' (--dry, skipping render)');
+ continue;
+ }
+
+ const t0 = Date.now();
+ try {
+ const buf = await renderImage(p.prompt, p.aspect, apiKey!);
+ await fs.writeFile(filePath, buf);
+ const kb = (buf.length / 1024).toFixed(0);
+ const secs = ((Date.now() - t0) / 1000).toFixed(1);
+ console.log(` ✓ ${kb} KB in ${secs}s`);
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(` ✗ ${msg}`);
+ continue;
+ }
+
+ const writeIdx = p.imageIdx !== undefined ? parseInt(p.imageIdx, 10) : i;
+ const cleanCaption = p.caption.replace(/^\s*(?:Fig(?:ure)?\.?\s*\d+[.:]?\s*)/i, '').trim();
+ const replacement =
+ `` +
+ `` +
+ `
` +
+ `
` +
+ (cleanCaption ? `Fig. ${figNum} ${cleanCaption} ` : '') +
+ ` `;
+ out = out.replace(p.full, replacement);
+ }
+
+ if (!dry) {
+ await fs.writeFile(abs, out, 'utf8');
+ console.log(`\nWrote patched HTML: ${abs}`);
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/scripts/render-slides.ts b/scripts/render-slides.ts
new file mode 100644
index 0000000..fb8fc0e
--- /dev/null
+++ b/scripts/render-slides.ts
@@ -0,0 +1,152 @@
+/**
+ * Render every slide imagePrompt in a slides.json file via OpenAI gpt-image-2
+ * and write the JPEGs out as slide-NN.jpg into an adjacent ./img/ directory.
+ *
+ * OPENAI_API_KEY=sk-… npx tsx scripts/render-slides.ts \
+ * output/science-and-art-of-tea/slides-new/01_slides.json
+ *
+ * --dry prints the prompts without spending money. --concurrency=N runs N
+ * renders in flight at once (default 3 — OpenAI tolerates this politely).
+ */
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+const ENDPOINT = 'https://api.openai.com/v1/images/generations';
+const MODEL = 'gpt-image-2';
+const SLIDE_SIZE = '2048x1152'; // wide-landscape 16:9 — gpt-image-2's largest 16:9
+const SAFETY_CLAUSE =
+ ' Render only text, numbers, dates, names, and citations that appear verbatim earlier in this prompt. Do not invent values: no made-up axis numbers, percentages, sample sizes, dates, author names, or journal titles. If a chart or label would require data that is not in this prompt, omit the values entirely and render the visual as an unlabelled abstract trend.';
+
+interface Slide {
+ title: string;
+ imagePrompt: string;
+ speakerNotes: string;
+}
+
+async function renderImageOnce(prompt: string, apiKey: string): Promise {
+ const body = {
+ model: MODEL,
+ prompt: prompt.trimEnd() + SAFETY_CLAUSE,
+ n: 1,
+ size: SLIDE_SIZE,
+ quality: 'high',
+ output_format: 'jpeg',
+ output_compression: 90,
+ };
+ const res = await fetch(ENDPOINT, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const err = new Error(`OpenAI ${res.status}: ${(await res.text()).slice(0, 400)}`) as Error & { status?: number };
+ err.status = res.status;
+ throw err;
+ }
+ const json = await res.json() as { data?: Array<{ b64_json?: string }> };
+ const b64 = json.data?.[0]?.b64_json;
+ if (!b64) throw new Error('No image in response');
+ return Buffer.from(b64, 'base64');
+}
+
+/** Retry on transient 5xx / 429 / network blips with exponential backoff. */
+async function renderImage(prompt: string, apiKey: string, maxRetries = 3): Promise {
+ let lastErr: unknown;
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
+ try {
+ return await renderImageOnce(prompt, apiKey);
+ } catch (err) {
+ lastErr = err;
+ const status = (err as { status?: number }).status;
+ const transient = status === undefined || status === 429 || (status >= 500 && status < 600);
+ if (!transient || attempt === maxRetries) throw err;
+ const wait = 4000 * Math.pow(2, attempt) + Math.floor(Math.random() * 1500);
+ await new Promise((r) => setTimeout(r, wait));
+ }
+ }
+ throw lastErr;
+}
+
+async function runWithConcurrency(tasks: Array<() => Promise>, limit: number): Promise {
+ const results: T[] = new Array(tasks.length);
+ let cursor = 0;
+ async function worker() {
+ while (cursor < tasks.length) {
+ const i = cursor++;
+ results[i] = await tasks[i]();
+ }
+ }
+ await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
+ return results;
+}
+
+async function main() {
+ const args = process.argv.slice(2);
+ const dry = args.includes('--dry');
+ const force = args.includes('--force');
+ const concArg = args.find((a) => a.startsWith('--concurrency='));
+ const concurrency = concArg ? Math.max(1, Math.min(6, parseInt(concArg.split('=')[1], 10) || 3)) : 3;
+ const jsonPath = args.find((a) => !a.startsWith('-'));
+ if (!jsonPath) {
+ console.error('usage: tsx render-slides.ts [--dry] [--concurrency=N]');
+ process.exit(2);
+ }
+ const apiKey = process.env.OPENAI_API_KEY;
+ if (!apiKey && !dry) { console.error('OPENAI_API_KEY not set'); process.exit(2); }
+
+ const abs = path.resolve(jsonPath);
+ const slides = JSON.parse(await fs.readFile(abs, 'utf8')) as Slide[];
+ const stem = path.basename(abs, '.json').match(/^(\d+)/)?.[1] ?? '01';
+ const imgDir = path.join(path.dirname(abs), 'img', `ch${stem}`);
+ await fs.mkdir(imgDir, { recursive: true });
+
+ console.log(`${slides.length} slide(s), concurrency=${concurrency}, size=${SLIDE_SIZE}`);
+ console.log(`output dir: ${imgDir}`);
+
+ const t0 = Date.now();
+ let done = 0;
+
+ const tasks = slides.map((slide, i) => async () => {
+ const n = String(i + 1).padStart(2, '0');
+ const filename = `slide-${n}.jpg`;
+ const filePath = path.join(imgDir, filename);
+ if (dry) {
+ console.log(`[${i + 1}/${slides.length}] DRY · ${slide.title}`);
+ return null;
+ }
+ // Idempotent: if the file already exists and is non-empty, skip it
+ // (unless --force was passed, e.g. to re-render after a prompt rewrite).
+ if (!force) {
+ try {
+ const st = await fs.stat(filePath);
+ if (st.size > 1000) {
+ done += 1;
+ console.log(`[${done}/${slides.length}] · ${filename} already rendered, skipping`);
+ return filename;
+ }
+ } catch { /* file doesn't exist, fall through to render */ }
+ }
+ const slideStart = Date.now();
+ try {
+ const buf = await renderImage(slide.imagePrompt, apiKey!);
+ await fs.writeFile(filePath, buf);
+ done += 1;
+ const secs = ((Date.now() - slideStart) / 1000).toFixed(1);
+ const kb = (buf.length / 1024).toFixed(0);
+ const elapsedMin = ((Date.now() - t0) / 60000).toFixed(1);
+ console.log(`[${done}/${slides.length}] ✓ ${filename} · ${kb} KB · ${secs}s · ${elapsedMin}min total`);
+ return filename;
+ } catch (err: unknown) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(`[${i + 1}/${slides.length}] ✗ ${slide.title} — ${msg}`);
+ return null;
+ }
+ });
+
+ await runWithConcurrency(tasks, concurrency);
+
+ const totalMin = ((Date.now() - t0) / 60000).toFixed(1);
+ console.log(`\nDone in ${totalMin} min · ${done}/${slides.length} successful`);
+}
+
+main().catch((err) => { console.error(err); process.exit(1); });
diff --git a/scripts/rewrite-image-prompts.ts b/scripts/rewrite-image-prompts.ts
new file mode 100644
index 0000000..e01f7b9
--- /dev/null
+++ b/scripts/rewrite-image-prompts.ts
@@ -0,0 +1,273 @@
+/**
+ * Apply a sidecar JSON of image-prompt rewrites to a chapter HTML or slides
+ * JSON file.
+ *
+ * Used to swap the painterly "field-guide watercolour" / "broadsheet pen-and-
+ * ink" prompts in our example courses for clean editorial photography prompts.
+ * The rewrites themselves are produced in a Claude Code conversation (so they
+ * don't burn API credits); this script just applies them deterministically and
+ * leaves a recoverable backup.
+ *
+ * npx tsx --env-file=.env.local scripts/rewrite-image-prompts.ts \
+ * output/raising-a-puppy/chapters-almanac/01_before-they-arrive.html \
+ * --rewrites tmp/rewrites/puppy-ch01.json
+ *
+ * npx tsx --env-file=.env.local scripts/rewrite-image-prompts.ts \
+ * output/raising-a-puppy/slides-almanac/01_slides.json \
+ * --rewrites tmp/rewrites/puppy-slides-01.json
+ *
+ * SIDECAR FORMAT for chapter HTML — value is either a string (just the new
+ * data-prompt) or an object {prompt, caption} when the figcaption text also
+ * needs updating because the original captioning language ("field-guide
+ * reference") would clash with a photographic image:
+ * {
+ * "0": "new prompt for figure index 0",
+ * "1": { "prompt": "...", "caption": "new caption text without Fig. N prefix" }
+ * }
+ *
+ * SIDECAR FORMAT for slides JSON — value is a string (the new imagePrompt):
+ * { "0": "new prompt for slide 0", "3": "new prompt for slide 3" }
+ *
+ * --dry prints what would change without writing.
+ *
+ * Chapter HTMLs are reverted to placeholder form so
+ * the existing render-ai-images.ts picks them up next run. The
+ * text is preserved; the rendered is dropped.
+ *
+ * Slide JSONs have their imagePrompt fields patched in place.
+ *
+ * A .pre-photo-rewrite backup of the target file is created the first time
+ * this script touches it.
+ */
+import fs from 'node:fs/promises';
+import path from 'node:path';
+
+interface Options {
+ dry: boolean;
+ rewritesPath: string | null;
+}
+
+function parseArgs(argv: string[]): { target: string | null; opts: Options } {
+ let target: string | null = null;
+ let dry = false;
+ let rewritesPath: string | null = null;
+ for (let i = 0; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--dry') dry = true;
+ else if (a === '--rewrites') rewritesPath = argv[++i] ?? null;
+ else if (a.startsWith('--rewrites=')) rewritesPath = a.split('=', 2)[1];
+ else if (!a.startsWith('-')) {
+ if (!target) target = a;
+ else throw new Error(`unexpected positional argument: ${a}`);
+ }
+ }
+ return { target, opts: { dry, rewritesPath } };
+}
+
+async function ensureBackup(filePath: string): Promise {
+ const bak = `${filePath}.pre-photo-rewrite`;
+ try {
+ await fs.access(bak);
+ return; // backup already exists, leave it alone
+ } catch { /* fall through */ }
+ await fs.copyFile(filePath, bak);
+}
+
+function decodeAttr(s: string): string {
+ return s.replace(/"/g, '"').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
+}
+
+function escapeAttr(s: string): string {
+ return s.replace(/&/g, '&').replace(/"/g, '"');
+}
+
+/** Strip the leading "Fig. N" span (with optional bold/serif wrapping) and HTML tags. */
+function cleanCaptionText(raw: string): string {
+ return raw
+ .replace(/]*>[^<]*<\/span>\s*/i, '')
+ .replace(/<[^>]+>/g, '')
+ .replace(/^\s*Fig(?:ure)?\.?\s*\d+[.:]?\s*/i, '')
+ .trim();
+}
+
+interface ChapterFigure {
+ full: string; // the original ... block
+ prompt: string;
+ aspect: string;
+ caption: string; // already cleaned of "Fig. N" prefix and inner tags
+ imageIdx?: string; // data-image-idx from a previously-rendered ch-figure, if present
+}
+
+function findChapterFigures(html: string): ChapterFigure[] {
+ // Match either ai-image (un-rendered placeholder) or ch-figure (already-rendered)
+ // figures that carry a data-prompt attribute. Allow other classes alongside the
+ // identifying one.
+ const re = /]*\bclass="(?:ai-image|ch-figure)(?:[^"]*)?"[^>]*>[\s\S]*?<\/figure>/gi;
+ const out: ChapterFigure[] = [];
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(html)) !== null) {
+ const full = m[0];
+ const promptM = /data-prompt="([^"]+)"/i.exec(full);
+ if (!promptM) continue;
+ const aspectM = /data-aspect="([^"]+)"/i.exec(full);
+ const capM = /]*>([\s\S]*?)<\/figcaption>/i.exec(full);
+ const idxM = /data-image-idx="([^"]+)"/i.exec(full);
+ out.push({
+ full,
+ prompt: decodeAttr(promptM[1]),
+ aspect: aspectM ? decodeAttr(aspectM[1]) : 'landscape',
+ caption: capM ? cleanCaptionText(capM[1]) : '',
+ imageIdx: idxM ? idxM[1] : undefined,
+ });
+ }
+ return out;
+}
+
+function buildPlaceholder(prompt: string, aspect: string, caption: string, imageIdx?: string): string {
+ const inner = caption ? `${caption} ` : '';
+ const idxAttr = imageIdx !== undefined ? ` data-image-idx="${escapeAttr(imageIdx)}"` : '';
+ return `${inner} `;
+}
+
+type ChapterRewriteValue = string | { prompt: string; caption?: string };
+type SlideRewriteValue = string;
+type RewriteValue = ChapterRewriteValue | SlideRewriteValue;
+
+async function loadRewrites(rewritesPath: string): Promise> {
+ const txt = await fs.readFile(rewritesPath, 'utf8');
+ const obj = JSON.parse(txt);
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
+ throw new Error(`rewrites file must be a JSON object of {index: newPrompt | {prompt, caption?}}`);
+ }
+ return obj as Record;
+}
+
+function unpackChapterRewrite(v: RewriteValue): { prompt: string; caption?: string } | null {
+ if (typeof v === 'string') return { prompt: v };
+ if (v && typeof v === 'object' && typeof (v as { prompt?: unknown }).prompt === 'string') {
+ return { prompt: (v as { prompt: string }).prompt, caption: (v as { caption?: string }).caption };
+ }
+ return null;
+}
+
+async function processChapter(filePath: string, rewrites: Record, opts: Options): Promise {
+ const html = await fs.readFile(filePath, 'utf8');
+ const figures = findChapterFigures(html);
+ if (figures.length === 0) {
+ console.log(` no figures found in ${path.basename(filePath)}`);
+ return;
+ }
+ console.log(` ${figures.length} figure(s) found; ${Object.keys(rewrites).length} rewrite(s) supplied`);
+
+ let touched = 0;
+ let out = html;
+ for (let i = 0; i < figures.length; i++) {
+ const rv = rewrites[String(i)];
+ if (rv === undefined) {
+ console.log(` [${i}] (skipped — no rewrite supplied)`);
+ continue;
+ }
+ const unpacked = unpackChapterRewrite(rv);
+ if (!unpacked) {
+ console.warn(` [${i}] (skipped — rewrite value must be string or {prompt, caption?})`);
+ continue;
+ }
+ const fig = figures[i];
+ const nextCaption = unpacked.caption ?? fig.caption;
+ console.log(` [${i}] aspect=${fig.aspect}`);
+ console.log(` BEFORE prompt (${fig.prompt.length} chars): ${fig.prompt.slice(0, 140)}…`);
+ console.log(` AFTER prompt (${unpacked.prompt.length} chars): ${unpacked.prompt.slice(0, 140)}…`);
+ if (unpacked.caption && unpacked.caption !== fig.caption) {
+ console.log(` BEFORE caption: ${fig.caption.slice(0, 140)}${fig.caption.length > 140 ? '…' : ''}`);
+ console.log(` AFTER caption: ${nextCaption.slice(0, 140)}${nextCaption.length > 140 ? '…' : ''}`);
+ }
+ out = out.replace(fig.full, buildPlaceholder(unpacked.prompt, fig.aspect, nextCaption, fig.imageIdx ?? String(i)));
+ touched += 1;
+ }
+
+ if (touched === 0) {
+ console.log(' nothing to write');
+ return;
+ }
+ if (opts.dry) {
+ console.log(` (dry) would rewrite ${touched} figure(s) in ${path.basename(filePath)}`);
+ return;
+ }
+ await ensureBackup(filePath);
+ await fs.writeFile(filePath, out, 'utf8');
+ console.log(` ✓ wrote ${path.basename(filePath)} (${touched} figures reverted to ai-image placeholders)`);
+}
+
+interface SlideRecord {
+ title?: string;
+ imagePrompt?: string;
+ [k: string]: unknown;
+}
+
+async function processSlides(filePath: string, rewrites: Record, opts: Options): Promise {
+ const json = await fs.readFile(filePath, 'utf8');
+ const slides = JSON.parse(json) as SlideRecord[];
+ if (!Array.isArray(slides)) {
+ console.error(` ${path.basename(filePath)}: expected an array of slides`);
+ return;
+ }
+ console.log(` ${slides.length} slide(s) in file; ${Object.keys(rewrites).length} rewrite(s) supplied`);
+
+ let touched = 0;
+ for (const key of Object.keys(rewrites)) {
+ const idx = parseInt(key, 10);
+ if (!Number.isInteger(idx) || idx < 0 || idx >= slides.length) {
+ console.warn(` [${key}] (skipped — out of range)`);
+ continue;
+ }
+ const slide = slides[idx];
+ const before = (slide.imagePrompt as string | undefined) ?? '';
+ const rv = rewrites[key];
+ if (typeof rv !== 'string') {
+ console.warn(` [${idx}] (skipped — slide rewrites must be plain strings)`);
+ continue;
+ }
+ console.log(` [${idx}] ${slide.title?.slice(0, 60) ?? ''}`);
+ console.log(` BEFORE (${before.length} chars): ${before.slice(0, 160)}…`);
+ console.log(` AFTER (${rv.length} chars): ${rv.slice(0, 160)}…`);
+ slides[idx].imagePrompt = rv;
+ touched += 1;
+ }
+
+ if (touched === 0) {
+ console.log(' nothing to write');
+ return;
+ }
+ if (opts.dry) {
+ console.log(` (dry) would patch ${touched} imagePrompt(s) in ${path.basename(filePath)}`);
+ return;
+ }
+ await ensureBackup(filePath);
+ await fs.writeFile(filePath, JSON.stringify(slides, null, 2) + '\n', 'utf8');
+ console.log(` ✓ wrote ${path.basename(filePath)} (${touched} imagePrompts patched)`);
+}
+
+async function main() {
+ const { target, opts } = parseArgs(process.argv.slice(2));
+ if (!target || !opts.rewritesPath) {
+ console.error('usage: tsx rewrite-image-prompts.ts --rewrites [--dry]');
+ process.exit(2);
+ }
+ const abs = path.resolve(target);
+ const ext = path.extname(abs).toLowerCase();
+ const rewrites = await loadRewrites(opts.rewritesPath);
+ console.log(`→ ${path.relative(process.cwd(), abs) || abs}${opts.dry ? ' (DRY — no writes)' : ''}`);
+ if (ext === '.html') {
+ await processChapter(abs, rewrites, opts);
+ } else if (ext === '.json') {
+ await processSlides(abs, rewrites, opts);
+ } else {
+ console.error(`unrecognised extension: ${ext}`);
+ process.exit(2);
+ }
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/src/App.tsx b/src/App.tsx
index 029feee..dedc4aa 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import { MotionConfig } from 'framer-motion';
import { AppShell } from './components/layout/AppShell';
import { LandingPage } from './pages/LandingPage';
import { SetupPage } from './pages/SetupPage';
@@ -20,29 +21,61 @@ function App() {
if (!hydrated) {
return (
-
-
-
- Loading course data...
+
+
+
+ cb · loading
+
+ fetching local course data…
);
}
return (
-
-
- }>
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
-
-
+ // reducedMotion="user" disables framer-motion transforms for users with
+ // the OS "reduce motion" preference; the CSS side is handled by the
+ // prefers-reduced-motion block in index.css.
+
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
);
}
diff --git a/src/components/build/ArtifactShell.tsx b/src/components/build/ArtifactShell.tsx
new file mode 100644
index 0000000..171288e
--- /dev/null
+++ b/src/components/build/ArtifactShell.tsx
@@ -0,0 +1,140 @@
+import type { ReactNode } from 'react';
+
+/**
+ * Canonical container for every per-chapter artefact in BuildPage.
+ *
+ * Each tab — Reading, Quiz, Audio, Slides, Discussion, etc. — should wrap its
+ * preview in this shell so the chrome reads as one design language: small
+ * uppercase kicker label on the left, italic title beside it, optional meta
+ * line in muted italic, and a right-aligned actions cluster. The body is a
+ * free-form children slot; an optional foot slot sits below for metadata
+ * strips or secondary controls.
+ *
+ * The shell is decoration only — generation state, downloads, refines, and
+ * all per-artefact behaviour stay with the calling tab. The shell just
+ * guarantees the *outside* of every artefact looks like every other.
+ */
+export interface ArtifactShellProps {
+ /** Small uppercase SC label, e.g. "Slides", "Reading", "Audio". */
+ kicker: string;
+ /** Plain-language title for what's inside (italic). */
+ title?: ReactNode;
+ /** Optional muted meta line — counts, status, etc. */
+ meta?: ReactNode;
+ /** Right-aligned action cluster (buttons, chips). */
+ actions?: ReactNode;
+ /** Optional foot rule + content (e.g. summary metrics). */
+ foot?: ReactNode;
+ /** Body content. */
+ children: ReactNode;
+ /** When true, render the body without a border-bordered card (useful for
+ * full-bleed iframes or single-image previews). Header still renders. */
+ bodyless?: boolean;
+}
+
+export function ArtifactShell({
+ kicker,
+ title,
+ meta,
+ actions,
+ foot,
+ children,
+ bodyless,
+}: ArtifactShellProps) {
+ return (
+
+
+
+
+ {kicker}
+
+ {title && (
+
+ {title}
+
+ )}
+ {meta && (
+
+ {meta}
+
+ )}
+
+ {actions && (
+
+ {actions}
+
+ )}
+
+ {bodyless ? (
+ children
+ ) : (
+ {children}
+ )}
+ {foot && (
+
+ )}
+
+ );
+}
diff --git a/src/components/build/ChapterImageRefineDrawer.tsx b/src/components/build/ChapterImageRefineDrawer.tsx
new file mode 100644
index 0000000..cac3e58
--- /dev/null
+++ b/src/components/build/ChapterImageRefineDrawer.tsx
@@ -0,0 +1,289 @@
+import { motion, AnimatePresence } from 'framer-motion';
+import { CodexButton as Button } from '../codex';
+
+/**
+ * Drawer that slides in from the right when the user clicks a chapter image.
+ * Shows a thumbnail of the current render, the original prompt (editable when
+ * an OpenAI key is present, read-only otherwise), and a Regenerate button that
+ * re-runs gpt-image-2 against just this one image.
+ */
+export function ChapterImageRefineDrawer({
+ state,
+ draft,
+ onDraftChange,
+ onClose,
+ onRefine,
+ isRefining,
+ hasOpenAiKey,
+ error,
+ onAddKey,
+}: {
+ state: { idx: number; prompt: string; aspect: string; src: string } | null;
+ draft: string;
+ onDraftChange: (text: string) => void;
+ onClose: () => void;
+ onRefine: () => void;
+ isRefining: boolean;
+ hasOpenAiKey: boolean;
+ error: string | null;
+ onAddKey: () => void;
+}) {
+ return (
+
+ {state && (
+ <>
+ {/* Scrim is interactive only when the render is idle. While
+ gpt-image-2 runs, we drop it to pointer-events:none so the user
+ can still scroll the chapter, switch tabs, or navigate — the
+ render keeps going regardless and the image swaps in when done. */}
+
+
+
+
+
+ Refine image
+
+
+ Figure {state.idx + 1}
+
+
+
+ ✕
+
+
+
+
+
+
+
+
+
+ Image prompt · {state.aspect}
+ {hasOpenAiKey && draft !== state.prompt && !isRefining && (
+ onDraftChange(state.prompt)}
+ className="cb-italic"
+ style={{
+ background: 'none',
+ border: 0,
+ cursor: 'pointer',
+ padding: 0,
+ fontSize: 11.5,
+ color: 'var(--cb-text-muted)',
+ textTransform: 'none',
+ letterSpacing: 0,
+ }}
+ >
+ ← reset
+
+ )}
+
+
+
+
+
+ {isRefining ? 'Close — keep rendering' : 'Cancel'}
+
+ {!hasOpenAiKey ? (
+
+ Add OpenAI key →
+
+ ) : (
+
+ {isRefining ? 'Rendering…' : 'Regenerate ↻'}
+
+ )}
+
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/build/ChapterSidebar.tsx b/src/components/build/ChapterSidebar.tsx
index 2089c49..2587377 100644
--- a/src/components/build/ChapterSidebar.tsx
+++ b/src/components/build/ChapterSidebar.tsx
@@ -1,6 +1,5 @@
-import { motion } from 'framer-motion';
-import { useCourseStore } from '../../store/courseStore';
import { useApiStore } from '../../store/apiStore';
+import { useCourseStore } from '../../store/courseStore';
interface ChapterSidebarProps {
selectedChapterNum: number;
@@ -9,105 +8,264 @@ interface ChapterSidebarProps {
batchCurrentChapter: number | null;
}
-function countReady(ch: { htmlContent: string; practiceQuizData?: string; inClassQuizData?: unknown[]; weeklyChallengeData?: unknown; discussionData?: unknown[]; activityData?: unknown[]; audioTranscript?: string; slidesJson?: unknown[]; infographicDataUri?: string }, hasGemini: boolean): number {
+const ROMAN_UPPER = [
+ 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X',
+ 'XI', 'XII', 'XIII', 'XIV', 'XV', 'XVI', 'XVII', 'XVIII', 'XIX', 'XX',
+];
+
+interface MaterialCount {
+ ready: number;
+ total: number;
+}
+
+function countReady(
+ ch: {
+ htmlContent: string;
+ practiceQuizData?: string;
+ inClassQuizData?: unknown[];
+ weeklyChallengeData?: unknown;
+ discussionData?: unknown[];
+ activityData?: unknown[];
+ audioTranscript?: string;
+ slidesJson?: unknown[];
+ },
+ advanced: boolean,
+): MaterialCount {
+ // Canvas-focused mode only surfaces four materials (the other tabs are
+ // hidden), so only those count toward "fully drafted".
let count = 0;
if (ch.htmlContent) count++;
if (ch.practiceQuizData) count++;
if (ch.inClassQuizData && ch.inClassQuizData.length > 0) count++;
- if (ch.weeklyChallengeData) count++;
if (ch.discussionData && ch.discussionData.length > 0) count++;
- if (ch.activityData && ch.activityData.length > 0) count++;
- if (ch.audioTranscript) count++;
- if (ch.slidesJson && ch.slidesJson.length > 0) count++;
- if (hasGemini && ch.infographicDataUri) count++;
- return count;
+ if (advanced) {
+ if (ch.weeklyChallengeData) count++;
+ if (ch.activityData && ch.activityData.length > 0) count++;
+ if (ch.audioTranscript) count++;
+ if (ch.slidesJson && ch.slidesJson.length > 0) count++;
+ }
+ return { ready: count, total: advanced ? 8 : 4 };
}
-export function ChapterSidebar({ selectedChapterNum, onSelectChapter, disabled, batchCurrentChapter }: ChapterSidebarProps) {
+export function ChapterSidebar({
+ selectedChapterNum,
+ onSelectChapter,
+ disabled,
+ batchCurrentChapter,
+}: ChapterSidebarProps) {
const { syllabus, chapters, researchDossiers } = useCourseStore();
- const { geminiApiKey } = useApiStore();
- const hasGemini = !!geminiApiKey;
- const totalOutputs = hasGemini ? 9 : 8;
+ const advancedMode = useApiStore((st) => st.advancedMode);
if (!syllabus) return null;
return (
-