coursecast 0.1.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/LICENSE +21 -0
- package/README.md +87 -0
- package/bin/coursecast.mjs +93 -0
- package/package.json +42 -0
- package/src/engines/claude-code.mjs +49 -0
- package/src/engines/codex.mjs +71 -0
- package/src/engines/gemini.mjs +92 -0
- package/src/engines/shared-prompts.mjs +88 -0
- package/src/engines/stub.mjs +98 -0
- package/src/index.mjs +168 -0
- package/src/progress-client.mjs +56 -0
- package/src/providers/netlify.mjs +27 -0
- package/src/providers/vercel.mjs +28 -0
- package/src/render-dashboard.mjs +218 -0
- package/src/render.mjs +147 -0
- package/src/storage/cloud.mjs +48 -0
- package/src/storage/index.mjs +14 -0
- package/src/storage/local.mjs +10 -0
- package/src/widgets/flashcards.mjs +51 -0
- package/src/widgets/index.mjs +42 -0
- package/src/widgets/reveal-diagram.mjs +61 -0
- package/src/widgets/what-if.mjs +99 -0
package/src/index.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Orchestration: topic → (engine) content → (render) HTML → optional (provider) deploy.
|
|
2
|
+
|
|
3
|
+
import { mkdir, writeFile, readdir, rm } from 'node:fs/promises';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { renderLesson } from './render.mjs';
|
|
6
|
+
import { loadStorage } from './storage/index.mjs';
|
|
7
|
+
|
|
8
|
+
// Reserved because they collide with shared localStorage keys used by the dashboard.
|
|
9
|
+
const RESERVED_SLUGS = new Set(['progress', 'theme', 'index']);
|
|
10
|
+
|
|
11
|
+
export function slugify(s) {
|
|
12
|
+
let slug = s.toLowerCase().trim()
|
|
13
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) || 'lesson';
|
|
14
|
+
if (RESERVED_SLUGS.has(slug)) slug = `${slug}-lesson`;
|
|
15
|
+
return slug;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
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.`); }
|
|
21
|
+
}
|
|
22
|
+
async function loadProvider(id) {
|
|
23
|
+
try { return await import(`./providers/${id}.mjs`); }
|
|
24
|
+
catch { throw new Error(`Unknown provider "${id}". Available: vercel, netlify.`); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function createLesson({ topic, engine = 'claude-code', model, storage = 'local', outDir, log = () => {} }) {
|
|
28
|
+
const slug = slugify(topic);
|
|
29
|
+
const eng = await loadEngine(engine);
|
|
30
|
+
log(`🧠 Generating lesson with the "${eng.name}" engine…`);
|
|
31
|
+
const content = await eng.generateLesson({ topic, slug, model });
|
|
32
|
+
content.slug = content.slug || slug;
|
|
33
|
+
|
|
34
|
+
const store = await loadStorage(storage);
|
|
35
|
+
content.storageSnippet = store.clientSnippet(content.slug);
|
|
36
|
+
if (store.needsBackend) {
|
|
37
|
+
log(`🔗 Storage: cloud (sync-code). Note: the cloud API isn't wired yet — the page runs local-only until deployed in cloud mode.`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const html = renderLesson(content);
|
|
41
|
+
const dir = resolve(outDir || join(process.cwd(), 'out', slug));
|
|
42
|
+
await mkdir(dir, { recursive: true });
|
|
43
|
+
const file = join(dir, 'index.html');
|
|
44
|
+
await writeFile(file, html, 'utf8');
|
|
45
|
+
log(`📄 Wrote ${file}`);
|
|
46
|
+
return { slug, dir, file, content };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Generate a whole multi-lesson course from one topic: syllabus -> lessons (parallel,
|
|
50
|
+
// with retry) -> mastery dashboard. Returns { dir, slug, manifest, lessons, failed }.
|
|
51
|
+
export async function createCourse({ topic, engine = 'claude-code', model, storage = 'local', outDir, lessons, concurrency = 4, log = () => {} }) {
|
|
52
|
+
concurrency = Math.max(1, concurrency || 4);
|
|
53
|
+
lessons = lessons ? Math.max(1, lessons) : undefined;
|
|
54
|
+
const slug = slugify(topic);
|
|
55
|
+
const eng = await loadEngine(engine);
|
|
56
|
+
if (typeof eng.generateSyllabus !== 'function') {
|
|
57
|
+
throw new Error(`Engine "${eng.name}" does not support course generation (no generateSyllabus).`);
|
|
58
|
+
}
|
|
59
|
+
const store = await loadStorage(storage);
|
|
60
|
+
const { renderLesson } = await import('./render.mjs');
|
|
61
|
+
|
|
62
|
+
log(`🧭 Designing syllabus with the "${eng.name}" engine…`);
|
|
63
|
+
const syllabus = await eng.generateSyllabus({ topic, lessons, model });
|
|
64
|
+
const phases = (syllabus.phases || []).filter(p => p && Array.isArray(p.lessons) && p.lessons.length);
|
|
65
|
+
if (!phases.length) throw new Error('Syllabus had no lessons.');
|
|
66
|
+
|
|
67
|
+
// Flatten in learning order, dedupe stems, and wire labels + next-cards.
|
|
68
|
+
const flat = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const ph of phases) {
|
|
71
|
+
for (const l of ph.lessons) {
|
|
72
|
+
let stem = slugify(l.stem || l.title || 'lesson');
|
|
73
|
+
while (seen.has(stem)) stem = `${stem}-2`;
|
|
74
|
+
seen.add(stem);
|
|
75
|
+
flat.push({ stem, title: l.title || stem, desc: l.desc || '', phase: ph.name });
|
|
76
|
+
l.stem = stem; // keep manifest in sync for the dashboard
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const total = flat.length;
|
|
80
|
+
log(`📚 ${total} lessons across ${phases.length} phases. Generating…`);
|
|
81
|
+
|
|
82
|
+
const dir = resolve(outDir || join(process.cwd(), 'out', slug));
|
|
83
|
+
await mkdir(dir, { recursive: true });
|
|
84
|
+
|
|
85
|
+
// Pass 1: generate each lesson's CONTENT (with retry). Rendering/next-cards happen in
|
|
86
|
+
// pass 2, once we know which lessons survived — so next-cards never point to a skipped page.
|
|
87
|
+
async function genContent(item, i) {
|
|
88
|
+
const context = {
|
|
89
|
+
courseTitle: syllabus.courseTitle,
|
|
90
|
+
title: item.title,
|
|
91
|
+
lessonLabel: `Lesson ${i + 1} of ${total}`,
|
|
92
|
+
kicker: item.phase,
|
|
93
|
+
next: null, // real next-card is wired in pass 2 against survivors
|
|
94
|
+
};
|
|
95
|
+
const attempt = async () => {
|
|
96
|
+
const content = await eng.generateLesson({ topic: `${item.title} — ${topic}`, slug: item.stem, model, context });
|
|
97
|
+
content.slug = item.stem;
|
|
98
|
+
content.courseTitle = syllabus.courseTitle;
|
|
99
|
+
if (!content.title) throw new Error('empty lesson');
|
|
100
|
+
return content;
|
|
101
|
+
};
|
|
102
|
+
try { return { stem: item.stem, ok: true, content: await attempt() }; }
|
|
103
|
+
catch (e1) {
|
|
104
|
+
log(` ↻ retry: ${item.stem} (${e1.message})`);
|
|
105
|
+
try { return { stem: item.stem, ok: true, content: await attempt() }; }
|
|
106
|
+
catch (e2) { log(` ✗ failed: ${item.stem} (${e2.message})`); return { stem: item.stem, ok: false, error: e2.message }; }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Bounded-concurrency pool.
|
|
111
|
+
const results = new Array(total);
|
|
112
|
+
let cursor = 0;
|
|
113
|
+
async function worker() {
|
|
114
|
+
while (cursor < total) {
|
|
115
|
+
const i = cursor++;
|
|
116
|
+
results[i] = await genContent(flat[i], i);
|
|
117
|
+
const okSoFar = results.filter(r => r && r.ok).length;
|
|
118
|
+
log(` ${results[i].ok ? '✓' : '✗'} ${okSoFar}/${total} ok ${flat[i].stem}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
await Promise.all(Array.from({ length: Math.min(concurrency, total) }, worker));
|
|
122
|
+
|
|
123
|
+
const failed = results.filter(r => r && !r.ok).map(r => r.stem);
|
|
124
|
+
const okStems = new Set(results.filter(r => r && r.ok).map(r => r.stem));
|
|
125
|
+
|
|
126
|
+
// Pass 2: wire each survivor's next-card to the NEXT SURVIVING lesson, then render + write.
|
|
127
|
+
const survivors = flat.filter(it => okStems.has(it.stem));
|
|
128
|
+
const byStem = new Map(results.filter(r => r && r.ok).map(r => [r.stem, r.content]));
|
|
129
|
+
for (let k = 0; k < survivors.length; k++) {
|
|
130
|
+
const content = byStem.get(survivors[k].stem);
|
|
131
|
+
const nx = survivors[k + 1];
|
|
132
|
+
content.next = nx ? { stem: nx.stem, title: nx.title, desc: nx.desc } : null;
|
|
133
|
+
content.storageSnippet = store.clientSnippet(content.slug);
|
|
134
|
+
await writeFile(join(dir, `${content.slug}.html`), renderLesson(content), 'utf8');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Prune stale lesson files from earlier runs (a non-deterministic engine may rename stems).
|
|
138
|
+
try {
|
|
139
|
+
const keep = new Set([...okStems].map(s => `${s}.html`).concat('index.html'));
|
|
140
|
+
for (const f of await readdir(dir)) {
|
|
141
|
+
if (f.endsWith('.html') && !keep.has(f)) await rm(join(dir, f));
|
|
142
|
+
}
|
|
143
|
+
} catch { /* best effort */ }
|
|
144
|
+
|
|
145
|
+
// Build the dashboard over the lessons that actually generated.
|
|
146
|
+
const manifest = {
|
|
147
|
+
courseTitle: syllabus.courseTitle || slug,
|
|
148
|
+
courseSubtitle: syllabus.courseSubtitle || '',
|
|
149
|
+
lessonPath: stem => `${stem}.html`,
|
|
150
|
+
phases: phases.map(ph => ({
|
|
151
|
+
name: ph.name,
|
|
152
|
+
lessons: ph.lessons.filter(l => okStems.has(l.stem)).map(l => ({ stem: l.stem, title: l.title, desc: l.desc || '' })),
|
|
153
|
+
})).filter(ph => ph.lessons.length),
|
|
154
|
+
};
|
|
155
|
+
const { renderDashboard } = await import('./render-dashboard.mjs');
|
|
156
|
+
await writeFile(join(dir, 'index.html'), renderDashboard(manifest), 'utf8');
|
|
157
|
+
|
|
158
|
+
if (store.needsBackend) log(`🔗 Storage: cloud (sync-code) — client adapter embedded; wire the cloud API at deploy to activate.`);
|
|
159
|
+
log(`📄 Wrote ${okStems.size} lessons + dashboard to ${dir}`);
|
|
160
|
+
return { dir, slug, manifest, lessons: [...okStems], failed };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function deployDir({ dir, provider = 'vercel', prod = true, dryRun = false, log = () => {} }) {
|
|
164
|
+
const prov = await loadProvider(provider);
|
|
165
|
+
log(`🚀 Deploying to ${prov.name}${dryRun ? ' (dry run)' : ''}…`);
|
|
166
|
+
const res = await prov.deploy({ dir, prod, dryRun });
|
|
167
|
+
return res;
|
|
168
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Single source of truth for the per-lesson progress reporter that every lesson embeds.
|
|
2
|
+
// It writes a compact summary of THIS lesson into the shared `cc-progress` localStorage
|
|
3
|
+
// object, so the course dashboard can read mastery across all lessons with no backend.
|
|
4
|
+
//
|
|
5
|
+
// Status model:
|
|
6
|
+
// mastered — all checklist items done AND a quiz was taken with confidence >= 0.8
|
|
7
|
+
// needs_practice — a quiz was taken but confidence < 0.6
|
|
8
|
+
// in_progress — some progress (a check ticked or quiz attempted) but not mastered
|
|
9
|
+
// visited — opened, nothing done yet
|
|
10
|
+
// A lesson with no entry at all is treated as "untouched" by the dashboard.
|
|
11
|
+
//
|
|
12
|
+
// Robustness notes (from review):
|
|
13
|
+
// - checksDone is read from the live DOM, not storage, so it never depends on the
|
|
14
|
+
// lesson's own save() timing.
|
|
15
|
+
// - mastered REQUIRES an actual quiz (a checklist-only lesson can't auto-master).
|
|
16
|
+
// - completion % weights the quiz slot by score, so a failed quiz isn't "100%".
|
|
17
|
+
// - `ts` is only bumped when real work changed, so re-opening doesn't reset staleness.
|
|
18
|
+
|
|
19
|
+
export function progressSnippet(slug) {
|
|
20
|
+
return `<!-- coursecast-progress -->
|
|
21
|
+
<script>(function(){
|
|
22
|
+
var SLUG=${JSON.stringify(slug)}, PKEY='cc-progress', KEY='cc-'+SLUG;
|
|
23
|
+
function read(k){var v;try{v=JSON.parse(localStorage.getItem(k)||'{}')}catch(e){}return (v&&typeof v==='object'&&!Array.isArray(v))?v:{};}
|
|
24
|
+
function write(k,v){try{localStorage.setItem(k,JSON.stringify(v))}catch(e){}}
|
|
25
|
+
function title(){var t=(document.title||SLUG).replace(/\\s+—\\s+coursecast.*/,'');return t.replace(/^Lesson\\s+\\S+\\s+·\\s+/,'').trim()||SLUG;}
|
|
26
|
+
function snapshot(){
|
|
27
|
+
var st=read(KEY);
|
|
28
|
+
var CHK='.check-item input[type=checkbox], .wcheck';
|
|
29
|
+
var checksTotal=document.querySelectorAll(CHK).length;
|
|
30
|
+
var checksDone=document.querySelectorAll(CHK.replace(/,/g,':checked,')+':checked').length;
|
|
31
|
+
var quizTotal=document.querySelectorAll('.quiz-q').length;
|
|
32
|
+
var quizScore=(st.quizScore!=null)?st.quizScore:null;
|
|
33
|
+
if(quizScore!=null&&quizTotal>0&&quizScore>quizTotal)quizScore=quizTotal;
|
|
34
|
+
var conf=quizTotal?Math.min(1,(quizScore||0)/quizTotal):0;
|
|
35
|
+
var denom=checksTotal+(quizTotal?1:0);
|
|
36
|
+
var quizCredit=(quizScore!=null)?(quizTotal?conf:1):0;
|
|
37
|
+
var pct=denom?((checksDone+quizCredit)/denom):0;
|
|
38
|
+
if(pct>1)pct=1;
|
|
39
|
+
var status;
|
|
40
|
+
if(checksDone>=checksTotal&&quizTotal>0&&quizScore!=null&&conf>=0.8)status='mastered';
|
|
41
|
+
else if(quizScore!=null&&conf<0.6)status='needs_practice';
|
|
42
|
+
else if(checksDone>0||quizScore!=null)status='in_progress';
|
|
43
|
+
else status='visited';
|
|
44
|
+
var prog=read(PKEY),prev=prog[SLUG];
|
|
45
|
+
var changed=!prev||prev.checksDone!==checksDone||prev.quizScore!==quizScore;
|
|
46
|
+
var ts=(prev&&!changed&&prev.ts)?prev.ts:Date.now();
|
|
47
|
+
prog[SLUG]={title:title(),status:status,pct:Math.round(pct*100)/100,conf:Math.round(conf*100)/100,
|
|
48
|
+
checksDone:checksDone,checksTotal:checksTotal,quizScore:quizScore,quizTotal:quizTotal,ts:ts};
|
|
49
|
+
write(PKEY,prog);
|
|
50
|
+
}
|
|
51
|
+
function ready(fn){if(document.readyState!=='loading')fn();else document.addEventListener('DOMContentLoaded',fn);}
|
|
52
|
+
ready(snapshot);
|
|
53
|
+
document.addEventListener('change',function(e){var t=e.target;if(t&&t.matches&&t.matches('.check-item input, .wcheck'))setTimeout(snapshot,40);});
|
|
54
|
+
document.addEventListener('click',function(e){var t=e.target;if(t&&t.closest&&t.closest('.quiz-opt'))setTimeout(snapshot,40);});
|
|
55
|
+
})();</script>`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// netlify provider — deploys a static directory using the user's existing `netlify login`.
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
export const name = 'netlify';
|
|
6
|
+
|
|
7
|
+
export function isAvailable() {
|
|
8
|
+
const r = spawnSync('netlify', ['--version'], { encoding: 'utf8' });
|
|
9
|
+
return r.status === 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function deploy({ dir, prod = true, dryRun = false }) {
|
|
13
|
+
if (!isAvailable()) {
|
|
14
|
+
throw new Error('Netlify CLI not found. Install with `npm i -g netlify-cli`, then `netlify login`.');
|
|
15
|
+
}
|
|
16
|
+
const args = ['deploy', '--dir', dir];
|
|
17
|
+
if (prod) args.push('--prod');
|
|
18
|
+
if (dryRun) {
|
|
19
|
+
return { url: null, command: `netlify ${args.join(' ')}`, dryRun: true };
|
|
20
|
+
}
|
|
21
|
+
const r = spawnSync('netlify', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
22
|
+
if (r.status !== 0) {
|
|
23
|
+
throw new Error(`netlify deploy failed: ${r.stderr || r.stdout}`);
|
|
24
|
+
}
|
|
25
|
+
const url = (r.stdout.match(/https:\/\/\S+\.netlify\.app\S*/) || [])[0] || r.stdout.trim().split('\n').pop();
|
|
26
|
+
return { url, command: `netlify ${args.join(' ')}` };
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// vercel provider — deploys a static directory using the user's existing `vercel login`.
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
|
|
5
|
+
export const name = 'vercel';
|
|
6
|
+
|
|
7
|
+
export function isAvailable() {
|
|
8
|
+
const r = spawnSync('vercel', ['--version'], { encoding: 'utf8' });
|
|
9
|
+
return r.status === 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function deploy({ dir, prod = true, dryRun = false }) {
|
|
13
|
+
if (!isAvailable()) {
|
|
14
|
+
throw new Error('Vercel CLI not found. Install with `npm i -g vercel`, then `vercel login`.');
|
|
15
|
+
}
|
|
16
|
+
const args = ['deploy', dir, '--yes'];
|
|
17
|
+
if (prod) args.push('--prod');
|
|
18
|
+
if (dryRun) {
|
|
19
|
+
return { url: null, command: `vercel ${args.join(' ')}`, dryRun: true };
|
|
20
|
+
}
|
|
21
|
+
const r = spawnSync('vercel', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
22
|
+
if (r.status !== 0) {
|
|
23
|
+
throw new Error(`vercel deploy failed: ${r.stderr || r.stdout}`);
|
|
24
|
+
}
|
|
25
|
+
// Vercel prints the deployment URL on stdout.
|
|
26
|
+
const url = (r.stdout.match(/https:\/\/\S+\.vercel\.app\S*/) || [])[0] || r.stdout.trim().split('\n').pop();
|
|
27
|
+
return { url, command: `vercel ${args.join(' ')}` };
|
|
28
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// render-dashboard.mjs — builds a course dashboard/index page that reads the shared
|
|
2
|
+
// `cc-progress` localStorage object and shows a mastery heatmap, overall progress, and
|
|
3
|
+
// a "Review next" panel. Pure static; no backend. Reused by the CLI and the GCP retrofit.
|
|
4
|
+
//
|
|
5
|
+
// manifest = {
|
|
6
|
+
// courseTitle, courseSubtitle, lessonPath(stem) -> href,
|
|
7
|
+
// phases: [{ name, lessons: [{ stem, title, desc }] }]
|
|
8
|
+
// }
|
|
9
|
+
|
|
10
|
+
const esc = (s = '') => String(s)
|
|
11
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
12
|
+
// Safe embed of data into an inline <script>: prevents a "</script>" or "<!--" in any
|
|
13
|
+
// string from breaking out of the script element.
|
|
14
|
+
const jsonEmbed = v => JSON.stringify(v).replace(/</g, '\\u003c');
|
|
15
|
+
|
|
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;}
|
|
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
|
+
--mastered:#0ca30c;--progress:#3987e5;--needs:#c98500;--visited:#898781;--untouched:#3a3a38;}
|
|
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;}}
|
|
22
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
23
|
+
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--page);color:var(--ink);line-height:1.6}
|
|
24
|
+
.wrap{max-width:860px;margin:0 auto;padding:44px 20px 80px}
|
|
25
|
+
.theme-btn{float:right;border:1px solid var(--border);background:var(--surface);color:var(--ink);border-radius:8px;padding:5px 12px;cursor:pointer;font-size:13px}
|
|
26
|
+
.kicker{font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);font-weight:700}
|
|
27
|
+
h1{font-size:38px;line-height:1.15;margin:8px 0 10px}.lede{font-size:17px;color:var(--ink-2);max-width:620px}
|
|
28
|
+
h2{font-size:14px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:40px 0 12px;border-bottom:1px solid var(--grid);padding-bottom:8px}
|
|
29
|
+
.overview{display:flex;gap:24px;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:22px;margin:26px 0}
|
|
30
|
+
.ring{position:relative;width:96px;height:96px;flex:0 0 96px}
|
|
31
|
+
.ring svg{transform:rotate(-90deg)}
|
|
32
|
+
.ring .pct{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700}
|
|
33
|
+
.ostats{display:flex;flex-wrap:wrap;gap:8px 20px;flex:1}
|
|
34
|
+
.ostat{font-size:13px;color:var(--ink-2)}.ostat b{color:var(--ink);font-size:20px;font-weight:700;display:block;line-height:1.1}
|
|
35
|
+
.legend{display:flex;gap:14px;flex-wrap:wrap;margin:6px 0 0;font-size:12px;color:var(--ink-2)}
|
|
36
|
+
.legend span{display:inline-flex;align-items:center;gap:5px}.dot{width:10px;height:10px;border-radius:50%;display:inline-block}
|
|
37
|
+
.d-mastered{background:var(--mastered)}.d-in_progress{background:var(--progress)}.d-needs_practice{background:var(--needs)}.d-visited{background:var(--visited)}.d-untouched{background:var(--untouched)}
|
|
38
|
+
.heat{display:grid;grid-template-columns:repeat(auto-fill,minmax(26px,1fr));gap:6px;margin:8px 0 4px}
|
|
39
|
+
.cell{aspect-ratio:1;border-radius:6px;background:var(--untouched);border:1px solid var(--border);cursor:pointer;position:relative;text-decoration:none;display:block}
|
|
40
|
+
.cell:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
|
|
41
|
+
.cell.s-mastered{background:var(--mastered)}.cell.s-in_progress{background:var(--progress)}.cell.s-needs_practice{background:var(--needs)}.cell.s-visited{background:var(--visited)}
|
|
42
|
+
.review{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:6px 4px;margin:10px 0}
|
|
43
|
+
a.rrow,a.lrow{display:flex;align-items:center;gap:12px;text-decoration:none;color:inherit;padding:12px 16px;border-radius:11px}
|
|
44
|
+
a.rrow:hover,a.lrow:hover{background:color-mix(in srgb,var(--accent) 7%,var(--surface))}
|
|
45
|
+
.rrow .why{font-size:12px;color:var(--muted)}
|
|
46
|
+
.num{flex:0 0 30px;height:30px;border-radius:8px;background:color-mix(in srgb,var(--accent) 12%,var(--surface));color:var(--accent);font-weight:700;display:flex;align-items:center;justify-content:center;font-size:13px}
|
|
47
|
+
.lt{font-weight:600}.ld{font-size:12.5px;color:var(--ink-2)}
|
|
48
|
+
.bar{flex:0 0 60px;height:6px;background:var(--grid);border-radius:3px;overflow:hidden;margin-left:auto}.bar>div{height:100%;background:var(--accent)}
|
|
49
|
+
.badge{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;border:1px solid var(--border);white-space:nowrap}
|
|
50
|
+
.phase-lessons a{border:1px solid var(--border);background:var(--surface);border-radius:11px;margin:8px 0}
|
|
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
|
+
.reset{border:1px solid var(--border);background:none;color:var(--muted);border-radius:8px;padding:4px 10px;cursor:pointer;font-size:12px}
|
|
53
|
+
.empty{color:var(--muted);font-size:14px;padding:10px 16px}
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
export function renderDashboard(manifest) {
|
|
57
|
+
const m = manifest;
|
|
58
|
+
const lessonPath = m.lessonPath || (stem => `${stem}.html`);
|
|
59
|
+
const allLessons = m.phases.flatMap(p => p.lessons);
|
|
60
|
+
const LDATA = jsonEmbed(allLessons.map((l, i) => ({
|
|
61
|
+
stem: l.stem, title: l.title, desc: l.desc || '', href: lessonPath(l.stem), n: i + 1,
|
|
62
|
+
})));
|
|
63
|
+
const PHASES = jsonEmbed(m.phases.map(p => ({ name: p.name, stems: p.lessons.map(l => l.stem) })));
|
|
64
|
+
|
|
65
|
+
return `<!DOCTYPE html>
|
|
66
|
+
<html lang="en">
|
|
67
|
+
<head>
|
|
68
|
+
<meta charset="UTF-8">
|
|
69
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
70
|
+
<title>${esc(m.courseTitle)} — coursecast</title>
|
|
71
|
+
<style>${CSS}</style>
|
|
72
|
+
<script>try{var t=localStorage.getItem("cc-theme");if(t)document.documentElement.dataset.theme=t;}catch(e){}</script>
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<div class="wrap">
|
|
76
|
+
<button class="theme-btn" id="themeBtn">◐ Theme</button>
|
|
77
|
+
<div class="kicker">coursecast · Interactive Course</div>
|
|
78
|
+
<h1>${esc(m.courseTitle)}</h1>
|
|
79
|
+
<p class="lede">${esc(m.courseSubtitle || '')}</p>
|
|
80
|
+
|
|
81
|
+
<div class="overview" id="overview"></div>
|
|
82
|
+
|
|
83
|
+
<h2>Mastery map</h2>
|
|
84
|
+
<div class="heat" id="heat"></div>
|
|
85
|
+
<div class="legend">
|
|
86
|
+
<span><i class="dot d-mastered"></i>Mastered</span>
|
|
87
|
+
<span><i class="dot d-in_progress"></i>In progress</span>
|
|
88
|
+
<span><i class="dot d-needs_practice"></i>Needs practice</span>
|
|
89
|
+
<span><i class="dot d-visited"></i>Opened</span>
|
|
90
|
+
<span><i class="dot d-untouched"></i>Not started</span>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<h2>🎯 Review next</h2>
|
|
94
|
+
<div class="review" id="review"></div>
|
|
95
|
+
|
|
96
|
+
<h2>All lessons</h2>
|
|
97
|
+
<div id="toc"></div>
|
|
98
|
+
|
|
99
|
+
<div class="foot">
|
|
100
|
+
<span>Progress saves on this device. Generated by <b>coursecast</b>.</span>
|
|
101
|
+
<button class="reset" id="reset">Reset progress</button>
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<script>
|
|
105
|
+
(function(){
|
|
106
|
+
var LESSONS=${LDATA}, PHASES=${PHASES};
|
|
107
|
+
var BY={};LESSONS.forEach(function(l){BY[l.stem]=l});
|
|
108
|
+
function read(k){var v;try{v=JSON.parse(localStorage.getItem(k)||'{}')}catch(e){}return (v&&typeof v==='object'&&!Array.isArray(v))?v:{};}
|
|
109
|
+
function esc(s){return String(s==null?'':s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
110
|
+
var VALID={mastered:1,in_progress:1,needs_practice:1,visited:1,untouched:1};
|
|
111
|
+
var LABEL={mastered:'Mastered',needs_practice:'Needs practice',in_progress:'In progress',visited:'Opened',untouched:'Not started'};
|
|
112
|
+
var SW={needs_practice:1.0,in_progress:0.6,visited:0.5};
|
|
113
|
+
|
|
114
|
+
var tb=document.getElementById('themeBtn');
|
|
115
|
+
tb.addEventListener('click',function(){
|
|
116
|
+
var d=matchMedia('(prefers-color-scheme: dark)').matches;
|
|
117
|
+
var cur=document.documentElement.dataset.theme||(d?'dark':'light');var nx=cur==='dark'?'light':'dark';
|
|
118
|
+
document.documentElement.dataset.theme=nx;try{localStorage.setItem('cc-theme',nx)}catch(e){}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
function statusOf(p){var s=p&&p.status;return VALID[s]?s:'untouched';}
|
|
122
|
+
function quizStr(p){return (p&&p.quizScore!=null&&p.quizTotal)?(' · quiz '+p.quizScore+'/'+p.quizTotal):'';}
|
|
123
|
+
|
|
124
|
+
function render(){
|
|
125
|
+
var prog=read('cc-progress');
|
|
126
|
+
var counts={mastered:0,needs_practice:0,in_progress:0,visited:0,untouched:0};
|
|
127
|
+
var sumPct=0;
|
|
128
|
+
LESSONS.forEach(function(l){
|
|
129
|
+
var p=prog[l.stem];var status=statusOf(p);
|
|
130
|
+
counts[status]++;
|
|
131
|
+
sumPct+=(p&&typeof p.pct==='number')?p.pct:0;
|
|
132
|
+
});
|
|
133
|
+
var total=LESSONS.length;
|
|
134
|
+
var overallPct=total?Math.round((sumPct/total)*100):0;
|
|
135
|
+
var remaining=total-counts.mastered-counts.needs_practice-counts.in_progress;
|
|
136
|
+
|
|
137
|
+
// Overview ring (neutral accent — green is reserved for mastery in the heatmap)
|
|
138
|
+
var r=42,circ=2*Math.PI*r,off=circ*(1-overallPct/100);
|
|
139
|
+
document.getElementById('overview').innerHTML=
|
|
140
|
+
'<div class="ring"><svg width="96" height="96" viewBox="0 0 96 96" aria-hidden="true">'+
|
|
141
|
+
'<circle cx="48" cy="48" r="'+r+'" fill="none" stroke="var(--grid)" stroke-width="8"/>'+
|
|
142
|
+
'<circle cx="48" cy="48" r="'+r+'" fill="none" stroke="var(--accent)" stroke-width="8" stroke-linecap="round" stroke-dasharray="'+circ.toFixed(1)+'" stroke-dashoffset="'+off.toFixed(1)+'"/>'+
|
|
143
|
+
'</svg><div class="pct" role="img" aria-label="Overall progress '+overallPct+' percent">'+overallPct+'%</div></div>'+
|
|
144
|
+
'<div class="ostats">'+
|
|
145
|
+
'<div class="ostat"><b>'+counts.mastered+'</b>mastered</div>'+
|
|
146
|
+
'<div class="ostat"><b>'+counts.needs_practice+'</b>need practice</div>'+
|
|
147
|
+
'<div class="ostat"><b>'+counts.in_progress+'</b>in progress</div>'+
|
|
148
|
+
'<div class="ostat"><b>'+remaining+'</b>not started</div>'+
|
|
149
|
+
'<div class="ostat"><b>'+total+'</b>lessons total</div>'+
|
|
150
|
+
'</div>';
|
|
151
|
+
|
|
152
|
+
// Heatmap (accessible label per cell, not color-only)
|
|
153
|
+
document.getElementById('heat').innerHTML=LESSONS.map(function(l){
|
|
154
|
+
var p=prog[l.stem];var status=statusOf(p);
|
|
155
|
+
var lbl=l.n+'. '+l.title+' — '+LABEL[status]+quizStr(p);
|
|
156
|
+
return '<a class="cell s-'+status+'" href="'+esc(l.href)+'" title="'+esc(lbl)+'" aria-label="'+esc(lbl)+'"></a>';
|
|
157
|
+
}).join('');
|
|
158
|
+
|
|
159
|
+
// Review ranking
|
|
160
|
+
var now=Date.now();
|
|
161
|
+
var ranked=LESSONS.map(function(l){
|
|
162
|
+
var p=prog[l.stem];if(!p)return null;
|
|
163
|
+
var status=statusOf(p);
|
|
164
|
+
if(status==='mastered')return null;
|
|
165
|
+
if((p.checksDone|0)===0&&p.quizScore==null)return null; // opened, no work -> not review-worthy
|
|
166
|
+
var days=Math.max(0,(now-(p.ts||now))/86400000);
|
|
167
|
+
var timeScore=Math.log(days+2)/Math.LN2;
|
|
168
|
+
var conf=(typeof p.conf==='number')?p.conf:0;
|
|
169
|
+
var pr=(1-conf)*timeScore*(SW[status]||0.5);
|
|
170
|
+
var neverQuizzed=(p.quizTotal>0&&p.quizScore==null&&(p.checksDone|0)>0);
|
|
171
|
+
if(neverQuizzed)pr+=0.6;
|
|
172
|
+
var why=status==='needs_practice'?'low quiz score':(neverQuizzed?'take the quiz':(days>7?'getting stale':'unfinished'));
|
|
173
|
+
return {l:l,p:p,pr:pr,why:why};
|
|
174
|
+
}).filter(Boolean).sort(function(a,b){return b.pr-a.pr}).slice(0,5);
|
|
175
|
+
|
|
176
|
+
var rv=document.getElementById('review');
|
|
177
|
+
if(!ranked.length){
|
|
178
|
+
var started=LESSONS.some(function(l){return prog[l.stem]});
|
|
179
|
+
rv.innerHTML='<div class="empty">'+(started?'✅ Nothing to review right now — nicely done.':'Start any lesson below and your weak spots will show up here.')+'</div>';
|
|
180
|
+
} else {
|
|
181
|
+
rv.innerHTML=ranked.map(function(x){
|
|
182
|
+
return '<a class="rrow" href="'+esc(x.l.href)+'"><span class="num">'+x.l.n+'</span>'+
|
|
183
|
+
'<span><span class="lt">'+esc(x.l.title)+'</span><br><span class="why">'+esc(x.why)+esc(quizStr(x.p))+'</span></span>'+
|
|
184
|
+
'<span class="badge" style="color:var(--needs)">Review</span></a>';
|
|
185
|
+
}).join('');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// TOC by phase with status
|
|
189
|
+
document.getElementById('toc').innerHTML=PHASES.map(function(ph){
|
|
190
|
+
var rows=ph.stems.map(function(stem){
|
|
191
|
+
var l=BY[stem];if(!l)return '';
|
|
192
|
+
var p=prog[stem];var status=statusOf(p);var pct=Math.round(((p&&p.pct)||0)*100);
|
|
193
|
+
return '<div class="phase-lessons"><a class="lrow" href="'+esc(l.href)+'" aria-label="'+esc(l.n+'. '+l.title+' — '+LABEL[status])+'">'+
|
|
194
|
+
'<span class="dot d-'+status+'" style="flex:0 0 10px"></span>'+
|
|
195
|
+
'<span class="num">'+l.n+'</span>'+
|
|
196
|
+
'<span><span class="lt">'+esc(l.title)+'</span><br><span class="ld">'+esc(l.desc)+'</span></span>'+
|
|
197
|
+
'<span class="bar"><div style="width:'+pct+'%"></div></span></a></div>';
|
|
198
|
+
}).join('');
|
|
199
|
+
return '<h3 style="font-size:13px;color:var(--muted);margin:18px 0 4px;text-transform:uppercase;letter-spacing:.06em">'+esc(ph.name)+'</h3>'+rows;
|
|
200
|
+
}).join('');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
document.getElementById('reset').addEventListener('click',function(){
|
|
204
|
+
if(!confirm('Reset all progress on this device?'))return;
|
|
205
|
+
try{
|
|
206
|
+
var kill=[];
|
|
207
|
+
for(var i=0;i<localStorage.length;i++){var k=localStorage.key(i);if(k&&k.indexOf('cc-')===0&&k!=='cc-theme')kill.push(k);}
|
|
208
|
+
kill.forEach(function(k){localStorage.removeItem(k)});
|
|
209
|
+
}catch(e){}
|
|
210
|
+
render();
|
|
211
|
+
});
|
|
212
|
+
render();
|
|
213
|
+
window.addEventListener('focus',render);
|
|
214
|
+
})();
|
|
215
|
+
</script>
|
|
216
|
+
</body>
|
|
217
|
+
</html>`;
|
|
218
|
+
}
|