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/render.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// render.mjs β turns a lesson content object into a self-contained interactive HTML page.
|
|
2
|
+
// The content is engine-produced (AI or stub); the presentation lives entirely here,
|
|
3
|
+
// so every lesson looks like one product regardless of who generated the content.
|
|
4
|
+
|
|
5
|
+
import { progressSnippet } from './progress-client.mjs';
|
|
6
|
+
import { renderWidgets } from './widgets/index.mjs';
|
|
7
|
+
|
|
8
|
+
const esc = (s = '') => String(s)
|
|
9
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
10
|
+
.replace(/"/g, '"');
|
|
11
|
+
|
|
12
|
+
const CSS = `
|
|
13
|
+
:root{color-scheme:light;--page:#f9f9f7;--surface:#fcfcfb;--ink:#0b0b0b;--ink-2:#52514e;--muted:#898781;--grid:#e1e0d9;--baseline:#c3c2b7;--border:rgba(11,11,11,0.10);--accent:#2a78d6;--s1:#2a78d6;--s2:#eb6834;--s3:#1baf7a;--good:#0ca30c;--good-text:#006300;--critical:#d03b3b;--warning:#fab219;}
|
|
14
|
+
:root[data-theme="dark"]{color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--baseline:#383835;--border:rgba(255,255,255,0.10);--accent:#3987e5;--s1:#3987e5;--s2:#d95926;--s3:#199e70;--good:#0ca30c;--good-text:#0ca30c;--critical:#d03b3b;--warning:#fab219;}
|
|
15
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;--page:#0d0d0d;--surface:#1a1a19;--ink:#fff;--ink-2:#c3c2b7;--muted:#898781;--grid:#2c2c2a;--baseline:#383835;--border:rgba(255,255,255,0.10);--accent:#3987e5;--s1:#3987e5;--s2:#d95926;--s3:#199e70;--good:#0ca30c;--good-text:#0ca30c;--critical:#d03b3b;--warning:#fab219;}}
|
|
16
|
+
*{margin:0;padding:0;box-sizing:border-box}
|
|
17
|
+
body{font-family:system-ui,-apple-system,"Segoe UI",sans-serif;background:var(--page);color:var(--ink);line-height:1.6}
|
|
18
|
+
.wrap{max-width:880px;margin:0 auto;padding:0 20px 80px}
|
|
19
|
+
.topbar{position:sticky;top:0;z-index:10;background:var(--page);border-bottom:1px solid var(--grid);padding:10px 20px;display:flex;align-items:center;gap:12px}
|
|
20
|
+
.topbar .crumb{font-size:13px;color:var(--ink-2);flex:1}.topbar .crumb b{color:var(--ink)}
|
|
21
|
+
.progressbar{height:6px;width:160px;background:var(--grid);border-radius:3px;overflow:hidden}.progressbar>div{height:100%;width:0;background:var(--good);border-radius:3px;transition:width .4s}
|
|
22
|
+
.theme-btn{border:1px solid var(--border);background:var(--surface);color:var(--ink);border-radius:8px;padding:4px 10px;cursor:pointer;font-size:13px}
|
|
23
|
+
.hero{padding:56px 0 8px}.kicker{font-size:13px;letter-spacing:.12em;text-transform:uppercase;color:var(--accent);font-weight:700}
|
|
24
|
+
h1{font-size:40px;line-height:1.15;margin:8px 0 16px}.lede{font-size:19px;color:var(--ink-2);max-width:640px}
|
|
25
|
+
h2{font-size:26px;margin:64px 0 6px;scroll-margin-top:64px}p{margin:12px 0;max-width:680px}
|
|
26
|
+
.tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin:28px 0}
|
|
27
|
+
.tile{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:18px 20px}
|
|
28
|
+
.tile .big{font-size:34px;font-weight:700;line-height:1.1}.tile .lbl{font-size:13px;color:var(--ink-2);margin-top:4px}.tile .note{font-size:11px;color:var(--muted);margin-top:6px}
|
|
29
|
+
.analogy{background:var(--surface);border:1px solid var(--border);border-left:4px solid var(--accent);border-radius:10px;padding:16px 20px;margin:20px 0;max-width:680px}
|
|
30
|
+
.analogy .tag{font-size:12px;font-weight:700;color:var(--accent);text-transform:uppercase;letter-spacing:.08em}
|
|
31
|
+
.simwrap{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px;margin:24px 0}
|
|
32
|
+
.simwrap .ctitle{font-weight:700}.simwrap .csub{font-size:12.5px;color:var(--muted);margin-bottom:8px}
|
|
33
|
+
.btn{border:1px solid var(--border);background:var(--page);color:var(--ink);border-radius:8px;padding:8px 14px;cursor:pointer;font-size:14px;font-weight:600}
|
|
34
|
+
.btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
35
|
+
.term{background:#16161a;color:#e6e6ec;border-radius:12px;padding:4px 0 10px;margin:20px 0;overflow-x:auto;border:1px solid var(--border)}
|
|
36
|
+
.term .thead{display:flex;gap:6px;padding:10px 14px 8px;border-bottom:1px solid #2a2a30;margin-bottom:6px}.term .dot{width:11px;height:11px;border-radius:50%;background:#3a3a42}
|
|
37
|
+
.tline{font-family:ui-monospace,"SF Mono",Menlo,monospace;font-size:13.5px;padding:5px 14px;cursor:pointer;white-space:pre;border-left:3px solid transparent}
|
|
38
|
+
.tline:hover,.tline.active{background:#1f1f26;border-left-color:var(--accent)}.tline .prompt{color:#5fb85f}
|
|
39
|
+
.term-explain{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px 16px;font-size:14px;color:var(--ink-2);min-height:52px;margin:-8px 0 20px}.term-explain b{color:var(--ink)}
|
|
40
|
+
.check-item{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px 16px;margin:10px 0}
|
|
41
|
+
.check-item label{display:flex;gap:12px;align-items:flex-start;cursor:pointer;font-weight:600}.check-item input{width:18px;height:18px;margin-top:3px;accent-color:var(--good)}
|
|
42
|
+
.check-item .desc{font-size:14px;color:var(--ink-2);font-weight:400;margin-top:2px}.check-item details{margin:8px 0 0 30px;font-size:13.5px;color:var(--ink-2)}
|
|
43
|
+
.check-item summary{cursor:pointer;color:var(--accent);font-weight:600;font-size:13px}.check-item.done label{text-decoration:line-through;color:var(--muted)}
|
|
44
|
+
.quiz-q{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin:14px 0}.quiz-q .qt{font-weight:700;margin-bottom:10px}
|
|
45
|
+
.quiz-opt{display:block;width:100%;text-align:left;margin:6px 0;padding:10px 14px;border-radius:9px;border:1px solid var(--border);background:var(--page);color:var(--ink);cursor:pointer;font-size:14.5px}
|
|
46
|
+
.quiz-opt:hover{border-color:var(--accent)}.quiz-opt.correct{border-color:var(--good);background:color-mix(in srgb,var(--good) 12%,var(--page));font-weight:600}
|
|
47
|
+
.quiz-opt.wrong{border-color:var(--critical);background:color-mix(in srgb,var(--critical) 8%,var(--page))}.quiz-fb{font-size:13.5px;margin-top:8px;display:none;color:var(--ink-2)}
|
|
48
|
+
.quiz-score{text-align:center;padding:26px;background:var(--surface);border:1px solid var(--border);border-radius:14px;margin:20px 0;display:none}.quiz-score .big{font-size:42px;font-weight:700}
|
|
49
|
+
.nextcard{display:flex;justify-content:space-between;align-items:center;gap:16px;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px;margin-top:48px;text-decoration:none;color:inherit;cursor:pointer}
|
|
50
|
+
.nextcard:hover{border-color:var(--accent)}.nextcard .nk{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:700}.nextcard .nt{font-size:18px;font-weight:700}
|
|
51
|
+
.foot{font-size:12.5px;color:var(--muted);margin-top:40px}
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
export function renderLesson(c) {
|
|
55
|
+
const slug = c.slug;
|
|
56
|
+
const tiles = (c.tiles || []).map(t =>
|
|
57
|
+
`<div class="tile"><div class="big">${esc(t.big)}</div><div class="lbl">${t.lbl || ''}</div>${t.note ? `<div class="note">${t.note}</div>` : ''}</div>`).join('');
|
|
58
|
+
const sections = (c.sections || []).map(s =>
|
|
59
|
+
`<h2>${esc(s.h2)}</h2>${s.html || ''}`).join('\n');
|
|
60
|
+
const analogy = c.analogy ? `<div class="analogy"><span class="tag">${esc(c.analogy.tag || 'Analogy')}</span><p>${c.analogy.text || ''}</p></div>` : '';
|
|
61
|
+
const sim = c.simulation ? `<div class="simwrap"><div class="ctitle">${esc(c.simulation.title || '')}</div><div class="csub">${esc(c.simulation.sub || '')}</div>${c.simulation.html || ''}</div>${c.simulation.js ? `<script>${c.simulation.js}</script>` : ''}` : '';
|
|
62
|
+
const widgets = renderWidgets(c.widgets);
|
|
63
|
+
const term = (c.terminal && c.terminal.length) ? `<div class="term"><div class="thead"><div class="dot"></div><div class="dot"></div><div class="dot"></div></div>${c.terminal.map(t => `<div class="tline" data-x="${esc(t.explain)}"><span class="prompt">$</span> ${esc(t.cmd)}</div>`).join('')}</div><div class="term-explain" id="termExplain">π <b>Click a command</b> to decode it.</div>` : '';
|
|
64
|
+
const checklist = (c.checklist || []).map((it, i) =>
|
|
65
|
+
`<div class="check-item"><label><input type="checkbox" data-c="${i}"><span>${esc(it.title)}<div class="desc">${it.desc || ''}</div></span></label>${it.hint ? `<details><summary>Stuck? Hint</summary>${esc(it.hint)}</details>` : ''}</div>`).join('');
|
|
66
|
+
const next = c.next ? `<a class="nextcard" href="${esc(c.next.stem)}.html"><div><div class="nk">Up next</div><div class="nt">${esc(c.next.title)}</div><div style="font-size:14px;color:var(--ink-2)">${esc(c.next.desc || '')}</div></div><div style="font-size:28px">β</div></a>` : '';
|
|
67
|
+
|
|
68
|
+
const quizData = JSON.stringify(c.quiz || []);
|
|
69
|
+
|
|
70
|
+
return `<!DOCTYPE html>
|
|
71
|
+
<html lang="en">
|
|
72
|
+
<head>
|
|
73
|
+
<meta charset="UTF-8">
|
|
74
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
75
|
+
<title>${esc(c.title)} β coursecast</title>
|
|
76
|
+
<style>${CSS}</style>
|
|
77
|
+
<script>try{var t=localStorage.getItem("cc-theme");if(t)document.documentElement.dataset.theme=t;}catch(e){}</script>
|
|
78
|
+
</head>
|
|
79
|
+
<body>
|
|
80
|
+
<div class="topbar">
|
|
81
|
+
<div class="crumb">π ${esc(c.courseTitle || 'coursecast')} Β· <b>${esc(c.lessonLabel || '')} β ${esc(c.title)}</b></div>
|
|
82
|
+
<div class="progressbar"><div id="lessonProgress"></div></div>
|
|
83
|
+
<button class="theme-btn" id="themeBtn">β Theme</button>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="wrap">
|
|
86
|
+
<div class="hero">
|
|
87
|
+
${c.kicker ? `<div class="kicker">${esc(c.kicker)}</div>` : ''}
|
|
88
|
+
<h1>${esc(c.title)}</h1>
|
|
89
|
+
${c.lede ? `<p class="lede">${c.lede}</p>` : ''}
|
|
90
|
+
</div>
|
|
91
|
+
${tiles ? `<div class="tiles">${tiles}</div>` : ''}
|
|
92
|
+
${analogy}
|
|
93
|
+
${sections}
|
|
94
|
+
${sim}
|
|
95
|
+
${widgets}
|
|
96
|
+
${term}
|
|
97
|
+
${checklist ? `<h2>π οΈ Touch it for real</h2>${checklist}` : ''}
|
|
98
|
+
${(c.quiz && c.quiz.length) ? `<h2>π§ Prove it</h2><div id="quiz"></div><div class="quiz-score" id="quizScore"><div class="big"></div><div class="msg" style="color:var(--ink-2)"></div></div>` : ''}
|
|
99
|
+
${next}
|
|
100
|
+
<p class="foot">Generated by the <b>coursecast</b> CLI Β· ${esc(c.footer || '')}</p>
|
|
101
|
+
</div>
|
|
102
|
+
<script>
|
|
103
|
+
(function(){
|
|
104
|
+
var KEY=${JSON.stringify("cc-" + slug)}, state={};
|
|
105
|
+
try{var _s=JSON.parse(localStorage.getItem(KEY)||'{}');if(_s&&typeof _s==='object'&&!Array.isArray(_s))state=_s;}catch(e){}
|
|
106
|
+
function save(){try{localStorage.setItem(KEY,JSON.stringify(state))}catch(e){}}
|
|
107
|
+
var root=document.documentElement;
|
|
108
|
+
var tb=document.getElementById('themeBtn');
|
|
109
|
+
if(tb)tb.addEventListener('click',function(){
|
|
110
|
+
var d=matchMedia('(prefers-color-scheme: dark)').matches;
|
|
111
|
+
var cur=root.dataset.theme||(d?'dark':'light');var nx=cur==='dark'?'light':'dark';
|
|
112
|
+
root.dataset.theme=nx;try{localStorage.setItem('cc-theme',nx)}catch(e){}
|
|
113
|
+
});
|
|
114
|
+
document.querySelectorAll('.tline').forEach(function(l){l.addEventListener('click',function(){
|
|
115
|
+
document.querySelectorAll('.tline').forEach(function(x){x.classList.remove('active')});
|
|
116
|
+
l.classList.add('active');document.getElementById('termExplain').innerHTML='<b>What this does:</b> '+l.dataset.x;
|
|
117
|
+
})});
|
|
118
|
+
var checks=document.querySelectorAll('.check-item input[type=checkbox]');
|
|
119
|
+
var QUIZ=${quizData};
|
|
120
|
+
function progress(){var d=0;checks.forEach(function(c){if(c.checked)d++});
|
|
121
|
+
var q=state.quizScore!=null?1:0;var tot=checks.length+(QUIZ.length?1:0);
|
|
122
|
+
var pct=tot?Math.round(((d+q)/tot)*100):0;var el=document.getElementById('lessonProgress');if(el)el.style.width=pct+'%';}
|
|
123
|
+
checks.forEach(function(c){c.checked=!!(state.checks&&state.checks[c.dataset.c]);
|
|
124
|
+
c.closest('.check-item').classList.toggle('done',c.checked);
|
|
125
|
+
c.addEventListener('change',function(){state.checks=state.checks||{};state.checks[c.dataset.c]=c.checked;
|
|
126
|
+
c.closest('.check-item').classList.toggle('done',c.checked);save();progress()})});
|
|
127
|
+
var quizEl=document.getElementById('quiz'),answered=0,score=0;
|
|
128
|
+
if(quizEl)QUIZ.forEach(function(item,qi){var card=document.createElement('div');card.className='quiz-q';
|
|
129
|
+
card.innerHTML='<div class="qt">'+(qi+1)+'. '+item.q+'</div>';
|
|
130
|
+
item.opts.forEach(function(opt,oi){var b=document.createElement('button');b.className='quiz-opt';b.textContent=opt;
|
|
131
|
+
b.addEventListener('click',function(){if(card.dataset.done)return;card.dataset.done='1';
|
|
132
|
+
var right=oi===item.a;if(right){score++;b.classList.add('correct')}else{b.classList.add('wrong');card.querySelectorAll('.quiz-opt')[item.a].classList.add('correct')}
|
|
133
|
+
var fb=card.querySelector('.quiz-fb');fb.style.display='block';fb.innerHTML=(right?'β
<b>Nailed it.</b> ':'β <b>Not quite.</b> ')+(item.fb||'');
|
|
134
|
+
if(++answered===QUIZ.length){state.quizScore=score;save();progress();
|
|
135
|
+
var el=document.getElementById('quizScore');el.style.display='block';
|
|
136
|
+
el.querySelector('.big').textContent=score+' / '+QUIZ.length;
|
|
137
|
+
el.querySelector('.msg').textContent=score===QUIZ.length?'π Perfect!':score>=Math.ceil(QUIZ.length*0.6)?'πͺ Solid β review what you missed and continue.':'π± Good start β revisit the sections above and try again.';}
|
|
138
|
+
});card.appendChild(b)});
|
|
139
|
+
var fb=document.createElement('div');fb.className='quiz-fb';card.appendChild(fb);quizEl.appendChild(card)});
|
|
140
|
+
progress();
|
|
141
|
+
})();
|
|
142
|
+
</script>
|
|
143
|
+
${progressSnippet(slug)}
|
|
144
|
+
${c.storageSnippet || ''}
|
|
145
|
+
</body>
|
|
146
|
+
</html>`;
|
|
147
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// cloud storage backend (opt-in, --storage cloud) β syncs progress across devices via a
|
|
2
|
+
// sync-code/link, with NO accounts and NO database console: the token IS the credential.
|
|
3
|
+
//
|
|
4
|
+
// SCAFFOLD STATE: the client sync adapter below is real, but stays INERT until the page
|
|
5
|
+
// is served with `window.__COURSECAST_API__` set (injected by the cloud deploy step, which
|
|
6
|
+
// is the "cloud next" work: the /api/progress routes + auto-provisioned KV store). Without
|
|
7
|
+
// it, the page behaves exactly like local mode. This is the clean seam between the two.
|
|
8
|
+
|
|
9
|
+
export const name = 'cloud';
|
|
10
|
+
export const needsBackend = true;
|
|
11
|
+
|
|
12
|
+
export function clientSnippet(/* slug */) {
|
|
13
|
+
return `<!-- coursecast-sync -->
|
|
14
|
+
<script>(function(){
|
|
15
|
+
var API=((window.__COURSECAST_API__)||'').replace(/\\/$/,'');
|
|
16
|
+
var TKEY='cc-sync-token', PKEY='cc-progress';
|
|
17
|
+
function q(k){try{return new URLSearchParams(location.search).get(k)}catch(e){return null}}
|
|
18
|
+
// Adopt a token arriving via ?sync=β¦ (opening the sync link on a new device).
|
|
19
|
+
var incoming=q('sync'); if(incoming){try{localStorage.setItem(TKEY,incoming)}catch(e){}}
|
|
20
|
+
var token=null; try{token=localStorage.getItem(TKEY)}catch(e){}
|
|
21
|
+
function read(){var v;try{v=JSON.parse(localStorage.getItem(PKEY)||'{}')}catch(e){}return (v&&typeof v==='object'&&!Array.isArray(v))?v:{};}
|
|
22
|
+
function writeLocal(o){try{localStorage.setItem(PKEY,JSON.stringify(o))}catch(e){}}
|
|
23
|
+
function merge(remote){ // per-lesson newest-timestamp wins
|
|
24
|
+
var local=read(),out={},k;
|
|
25
|
+
for(k in local)out[k]=local[k];
|
|
26
|
+
for(k in remote){ if(!out[k]||((remote[k]&&remote[k].ts)||0)>((out[k]&&out[k].ts)||0))out[k]=remote[k]; }
|
|
27
|
+
writeLocal(out); return out;
|
|
28
|
+
}
|
|
29
|
+
// Public hooks the dashboard's "Sync across devices" UI will call (cloud-next).
|
|
30
|
+
window.coursecastSync={
|
|
31
|
+
enabled:function(){return !!API;},
|
|
32
|
+
hasToken:function(){return !!token;},
|
|
33
|
+
link:function(){return token?(location.origin+location.pathname.replace(/[^/]*$/,'')+'?sync='+encodeURIComponent(token)):null;},
|
|
34
|
+
create:function(){ if(!API)return Promise.reject(new Error('cloud API not configured'));
|
|
35
|
+
return fetch(API+'/api/progress/new',{method:'POST'}).then(function(r){return r.json()}).then(function(j){token=j.token;try{localStorage.setItem(TKEY,token)}catch(e){}push();return token;}); },
|
|
36
|
+
use:function(t){ token=t; try{localStorage.setItem(TKEY,t)}catch(e){} return pull(); }
|
|
37
|
+
};
|
|
38
|
+
function pull(){ if(!API||!token)return Promise.resolve();
|
|
39
|
+
return fetch(API+'/api/progress/'+encodeURIComponent(token)).then(function(r){return r.ok?r.json():null}).then(function(j){if(j&&j.progress)merge(j.progress);}).catch(function(){}); }
|
|
40
|
+
function push(){ if(!API||!token)return;
|
|
41
|
+
try{fetch(API+'/api/progress/'+encodeURIComponent(token),{method:'PUT',headers:{'content-type':'application/json'},body:JSON.stringify({progress:read()})})}catch(e){} }
|
|
42
|
+
if(!API)return; // not deployed in cloud mode yet -> pure local behavior
|
|
43
|
+
pull();
|
|
44
|
+
var last=null; try{last=localStorage.getItem(PKEY)}catch(e){}
|
|
45
|
+
setInterval(function(){var cur=null;try{cur=localStorage.getItem(PKEY)}catch(e){}; if(cur!==last){last=cur;push();}},1500);
|
|
46
|
+
window.addEventListener('beforeunload',push);
|
|
47
|
+
})();</script>`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Storage backend loader β the third pluggable axis (alongside engines and providers).
|
|
2
|
+
// A backend decides how a generated course persists progress:
|
|
3
|
+
// local (default) β localStorage only, zero setup, no server.
|
|
4
|
+
// cloud β sync across devices via a sync-code/link (client adapter is inert
|
|
5
|
+
// until the cloud API is wired at deploy time).
|
|
6
|
+
|
|
7
|
+
export const BACKENDS = ['local', 'cloud'];
|
|
8
|
+
|
|
9
|
+
export async function loadStorage(id = 'local') {
|
|
10
|
+
if (!BACKENDS.includes(id)) {
|
|
11
|
+
throw new Error(`Unknown storage backend "${id}". Available: ${BACKENDS.join(', ')}.`);
|
|
12
|
+
}
|
|
13
|
+
return import(`./${id}.mjs`);
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// local storage backend (default) β progress lives in the browser's localStorage only.
|
|
2
|
+
// Zero setup, no server, no account. The lesson's embedded progress reporter already
|
|
3
|
+
// handles localStorage, so this backend adds nothing to the page.
|
|
4
|
+
|
|
5
|
+
export const name = 'local';
|
|
6
|
+
export const needsBackend = false;
|
|
7
|
+
|
|
8
|
+
export function clientSnippet(/* slug */) {
|
|
9
|
+
return '';
|
|
10
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// flashcards β spaced-recall practice. Flip a card, mark known/unknown, track progress.
|
|
2
|
+
// config = { title, cards: [{ front, back }] }
|
|
3
|
+
import { esc, jsonInline } from './index.mjs';
|
|
4
|
+
|
|
5
|
+
export const type = 'flashcards';
|
|
6
|
+
|
|
7
|
+
export function render(config, id) {
|
|
8
|
+
const cards = (Array.isArray(config.cards) ? config.cards : [])
|
|
9
|
+
.filter(c => c && (c.front || c.back))
|
|
10
|
+
.map(c => ({ front: String(c.front || ''), back: String(c.back || '') }));
|
|
11
|
+
if (!cards.length) return '';
|
|
12
|
+
|
|
13
|
+
return `<div class="widget fc" id="${id}">
|
|
14
|
+
<style>
|
|
15
|
+
#${id}.fc{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin:24px 0}
|
|
16
|
+
#${id} .fc-t{font-weight:700;margin-bottom:4px}
|
|
17
|
+
#${id} .fc-meta{font-size:12.5px;color:var(--muted);margin-bottom:12px}
|
|
18
|
+
#${id} .fc-card{border:1px solid var(--border);border-radius:12px;background:var(--page);min-height:120px;display:flex;align-items:center;justify-content:center;text-align:center;padding:22px;cursor:pointer;user-select:none;font-size:17px}
|
|
19
|
+
#${id} .fc-card .hint{position:absolute;top:8px;right:12px;font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.06em}
|
|
20
|
+
#${id} .fc-card{position:relative}
|
|
21
|
+
#${id} .fc-ctrls{display:flex;gap:8px;align-items:center;margin-top:12px;flex-wrap:wrap}
|
|
22
|
+
#${id} .fc-btn{border:1px solid var(--border);background:var(--page);color:var(--ink);border-radius:8px;padding:7px 12px;cursor:pointer;font-size:13.5px;font-weight:600}
|
|
23
|
+
#${id} .fc-btn.good{border-color:var(--good);color:var(--good-text)}
|
|
24
|
+
#${id} .fc-prog{margin-left:auto;font-size:12.5px;color:var(--ink-2);font-variant-numeric:tabular-nums}
|
|
25
|
+
</style>
|
|
26
|
+
${config.title ? `<div class="fc-t">${esc(config.title)}</div>` : ''}
|
|
27
|
+
<div class="fc-meta">${cards.length} cards Β· click the card to flip</div>
|
|
28
|
+
<div class="fc-card" data-card><span class="hint" data-side>Front</span><span data-face></span></div>
|
|
29
|
+
<div class="fc-ctrls">
|
|
30
|
+
<button class="fc-btn" data-prev>βΉ Prev</button>
|
|
31
|
+
<button class="fc-btn" data-next>Next βΊ</button>
|
|
32
|
+
<button class="fc-btn good" data-known>β I knew it</button>
|
|
33
|
+
<span class="fc-prog" data-prog></span>
|
|
34
|
+
</div>
|
|
35
|
+
<script>(function(){
|
|
36
|
+
var root=document.getElementById(${jsonInline(id)}), CARDS=${jsonInline(cards)};
|
|
37
|
+
var i=0, flipped=false, known={};
|
|
38
|
+
var face=root.querySelector('[data-face]'), side=root.querySelector('[data-side]'), prog=root.querySelector('[data-prog]');
|
|
39
|
+
function draw(){
|
|
40
|
+
face.textContent=flipped?CARDS[i].back:CARDS[i].front;
|
|
41
|
+
side.textContent=flipped?'Back':'Front';
|
|
42
|
+
prog.textContent='Card '+(i+1)+'/'+CARDS.length+' Β· '+Object.keys(known).length+' known';
|
|
43
|
+
}
|
|
44
|
+
root.querySelector('[data-card]').addEventListener('click',function(){flipped=!flipped;draw();});
|
|
45
|
+
root.querySelector('[data-next]').addEventListener('click',function(){i=(i+1)%CARDS.length;flipped=false;draw();});
|
|
46
|
+
root.querySelector('[data-prev]').addEventListener('click',function(){i=(i-1+CARDS.length)%CARDS.length;flipped=false;draw();});
|
|
47
|
+
root.querySelector('[data-known]').addEventListener('click',function(){known[i]=true;i=(i+1)%CARDS.length;flipped=false;draw();});
|
|
48
|
+
draw();
|
|
49
|
+
})();</script>
|
|
50
|
+
</div>`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Widget typology catalog β the "generative UI, flavor 2" layer.
|
|
2
|
+
//
|
|
3
|
+
// Instead of the AI emitting raw simulation code (flexible but inconsistent), a lesson can
|
|
4
|
+
// carry a `widgets: [{ type, config }]` array. The engine just picks a proven type and
|
|
5
|
+
// fills in its config; these renderers turn that data into interactive, dependency-free,
|
|
6
|
+
// self-contained HTML. Every widget is vanilla JS, no CDNs, no build step β so a lesson
|
|
7
|
+
// stays one offline file with no asset inliner needed.
|
|
8
|
+
//
|
|
9
|
+
// A widget module exports: `type` (string) and `render(config, id) -> htmlString`.
|
|
10
|
+
// `id` is a unique per-instance prefix so multiple widgets can share a page safely.
|
|
11
|
+
|
|
12
|
+
import * as whatIf from './what-if.mjs';
|
|
13
|
+
import * as flashcards from './flashcards.mjs';
|
|
14
|
+
import * as revealDiagram from './reveal-diagram.mjs';
|
|
15
|
+
|
|
16
|
+
const REGISTRY = Object.fromEntries(
|
|
17
|
+
[whatIf, flashcards, revealDiagram].map(w => [w.type, w])
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
export const WIDGET_TYPES = Object.keys(REGISTRY);
|
|
21
|
+
|
|
22
|
+
export const esc = (s = '') => String(s)
|
|
23
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
24
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
25
|
+
|
|
26
|
+
// Serialize a value for embedding inside an inline <script>. JSON.stringify does NOT escape
|
|
27
|
+
// "<", so a "</script>" in any string would break out of the script element β escape the
|
|
28
|
+
// angle brackets so no embedded string can terminate the script tag.
|
|
29
|
+
export const jsonInline = (v) => JSON.stringify(v)
|
|
30
|
+
.replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
|
|
31
|
+
|
|
32
|
+
// Render a lesson's widgets array to a single HTML string. Unknown/invalid widgets are
|
|
33
|
+
// skipped (never crash a whole lesson over one bad widget).
|
|
34
|
+
export function renderWidgets(widgets) {
|
|
35
|
+
if (!Array.isArray(widgets) || !widgets.length) return '';
|
|
36
|
+
return widgets.map((w, i) => {
|
|
37
|
+
const mod = w && REGISTRY[w.type];
|
|
38
|
+
if (!mod) return '';
|
|
39
|
+
try { return mod.render(w.config || {}, `w${i}`); }
|
|
40
|
+
catch { return ''; }
|
|
41
|
+
}).filter(Boolean).join('\n');
|
|
42
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// reveal-diagram β a step-by-step flow/process the learner reveals one node at a time,
|
|
2
|
+
// so a sequence (pipeline, lifecycle, decision path) unfolds instead of dumping all at once.
|
|
3
|
+
// config = { title, nodes: [{ label, detail }] }
|
|
4
|
+
import { esc } from './index.mjs';
|
|
5
|
+
|
|
6
|
+
export const type = 'reveal-diagram';
|
|
7
|
+
|
|
8
|
+
export function render(config, id) {
|
|
9
|
+
const nodes = (Array.isArray(config.nodes) ? config.nodes : [])
|
|
10
|
+
.filter(n => n && n.label)
|
|
11
|
+
.map(n => ({ label: String(n.label), detail: String(n.detail || '') }));
|
|
12
|
+
if (!nodes.length) return '';
|
|
13
|
+
|
|
14
|
+
const items = nodes.map((n, k) => `<div class="rd-node" data-k="${k}" hidden>
|
|
15
|
+
<div class="rd-box"><span class="rd-num">${k + 1}</span><span class="rd-label">${esc(n.label)}</span></div>
|
|
16
|
+
${n.detail ? `<div class="rd-detail">${esc(n.detail)}</div>` : ''}
|
|
17
|
+
${k < nodes.length - 1 ? '<div class="rd-arrow">β</div>' : ''}
|
|
18
|
+
</div>`).join('');
|
|
19
|
+
|
|
20
|
+
return `<div class="widget rd" id="${id}">
|
|
21
|
+
<style>
|
|
22
|
+
#${id}.rd{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin:24px 0}
|
|
23
|
+
#${id} .rd-t{font-weight:700;margin-bottom:4px}
|
|
24
|
+
#${id} .rd-meta{font-size:12.5px;color:var(--muted);margin-bottom:12px}
|
|
25
|
+
#${id} .rd-node[hidden]{display:none}
|
|
26
|
+
#${id} .rd-box{display:flex;align-items:center;gap:10px;border:1px solid var(--border);background:var(--page);border-radius:10px;padding:10px 14px}
|
|
27
|
+
#${id} .rd-num{flex:0 0 24px;height:24px;border-radius:6px;background:color-mix(in srgb,var(--accent) 14%,var(--surface));color:var(--accent);font-weight:700;display:flex;align-items:center;justify-content:center;font-size:12px}
|
|
28
|
+
#${id} .rd-label{font-weight:600}
|
|
29
|
+
#${id} .rd-detail{font-size:13.5px;color:var(--ink-2);margin:6px 0 0 34px}
|
|
30
|
+
#${id} .rd-arrow{text-align:center;color:var(--muted);margin:4px 0}
|
|
31
|
+
#${id} .rd-ctrls{display:flex;gap:8px;align-items:center;margin-top:12px}
|
|
32
|
+
#${id} .rd-btn{border:1px solid var(--border);background:var(--page);color:var(--ink);border-radius:8px;padding:7px 12px;cursor:pointer;font-size:13.5px;font-weight:600}
|
|
33
|
+
#${id} .rd-btn.primary{background:var(--accent);border-color:var(--accent);color:#fff}
|
|
34
|
+
#${id} .rd-prog{margin-left:auto;font-size:12.5px;color:var(--ink-2)}
|
|
35
|
+
</style>
|
|
36
|
+
${config.title ? `<div class="rd-t">${esc(config.title)}</div>` : ''}
|
|
37
|
+
<div class="rd-meta">Reveal the flow step by step</div>
|
|
38
|
+
<div data-nodes>${items}</div>
|
|
39
|
+
<div class="rd-ctrls">
|
|
40
|
+
<button class="rd-btn primary" data-next>Reveal next β</button>
|
|
41
|
+
<button class="rd-btn" data-all>Show all</button>
|
|
42
|
+
<button class="rd-btn" data-reset>Reset</button>
|
|
43
|
+
<span class="rd-prog" data-prog></span>
|
|
44
|
+
</div>
|
|
45
|
+
<script>(function(){
|
|
46
|
+
var root=document.getElementById(${JSON.stringify(id)});
|
|
47
|
+
var nodes=root.querySelectorAll('.rd-node'), shown=0;
|
|
48
|
+
var next=root.querySelector('[data-next]'), prog=root.querySelector('[data-prog]');
|
|
49
|
+
function draw(){
|
|
50
|
+
nodes.forEach(function(n,k){n.hidden=k>=shown;});
|
|
51
|
+
prog.textContent=shown+'/'+nodes.length;
|
|
52
|
+
next.disabled=shown>=nodes.length;
|
|
53
|
+
next.style.opacity=next.disabled?0.5:1;
|
|
54
|
+
}
|
|
55
|
+
next.addEventListener('click',function(){if(shown<nodes.length){shown++;draw();}});
|
|
56
|
+
root.querySelector('[data-all]').addEventListener('click',function(){shown=nodes.length;draw();});
|
|
57
|
+
root.querySelector('[data-reset]').addEventListener('click',function(){shown=0;draw();});
|
|
58
|
+
draw();
|
|
59
|
+
})();</script>
|
|
60
|
+
</div>`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// what-if β a parametric "calculator / mini-simulation": sliders for each variable, a live
|
|
2
|
+
// computed output (author-supplied expression over the variables), and an optional bar.
|
|
3
|
+
// config = {
|
|
4
|
+
// title, sub,
|
|
5
|
+
// variables: [{ name, label, min, max, default, step, unit }], // name must be a JS identifier
|
|
6
|
+
// expression: "a * b / 100" // arithmetic over the variable names + Math.* (validated)
|
|
7
|
+
// output: { label, unit, decimals, max } // max => draw a bar scaled to it
|
|
8
|
+
// }
|
|
9
|
+
import { esc, jsonInline } from './index.mjs';
|
|
10
|
+
|
|
11
|
+
export const type = 'what-if';
|
|
12
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
13
|
+
|
|
14
|
+
export function render(config, id) {
|
|
15
|
+
// Only keep variables with a valid JS identifier name β an invalid name would break both
|
|
16
|
+
// the Function it feeds and the DOM selectors that reference it.
|
|
17
|
+
const vars = (Array.isArray(config.variables) ? config.variables : [])
|
|
18
|
+
.filter(v => v && IDENT.test(String(v.name || '')));
|
|
19
|
+
const out = config.output || {};
|
|
20
|
+
const decimals = clampInt(out.decimals, 0, 100, 2);
|
|
21
|
+
|
|
22
|
+
const rows = vars.map(v => {
|
|
23
|
+
let min = num(v.min, 0), max = num(v.max, 100);
|
|
24
|
+
if (max <= min) max = min + 1;
|
|
25
|
+
const def = clamp(num(v.default, min), min, max);
|
|
26
|
+
const step = Math.abs(num(v.step, (max - min) / 100)) || 1;
|
|
27
|
+
return `<div class="wi-row">
|
|
28
|
+
<label for="${id}-${esc(v.name)}">${esc(v.label || v.name)}</label>
|
|
29
|
+
<input type="range" id="${id}-${esc(v.name)}" data-v="${esc(v.name)}" min="${min}" max="${max}" step="${step}" value="${def}">
|
|
30
|
+
<span class="wi-val" data-for="${esc(v.name)}">${def}${v.unit ? ' ' + esc(v.unit) : ''}</span>
|
|
31
|
+
</div>`;
|
|
32
|
+
}).join('');
|
|
33
|
+
|
|
34
|
+
const meta = {
|
|
35
|
+
vars: vars.map(v => ({ name: v.name, unit: v.unit || '' })),
|
|
36
|
+
expr: String(config.expression || '0'),
|
|
37
|
+
out: { unit: out.unit || '', decimals, max: num(out.max, 0) },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return `<div class="widget wi" id="${id}">
|
|
41
|
+
<style>
|
|
42
|
+
#${id}.wi{background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:18px 20px;margin:24px 0}
|
|
43
|
+
#${id} .wi-t{font-weight:700}#${id} .wi-s{font-size:12.5px;color:var(--muted);margin-bottom:10px}
|
|
44
|
+
#${id} .wi-row{display:grid;grid-template-columns:minmax(90px,1fr) 2fr auto;gap:12px;align-items:center;margin:8px 0}
|
|
45
|
+
#${id} .wi-row label{font-size:13.5px}#${id} input[type=range]{width:100%;accent-color:var(--accent);min-height:24px}
|
|
46
|
+
#${id} .wi-val{font-size:13px;color:var(--ink-2);font-variant-numeric:tabular-nums;min-width:56px;text-align:right}
|
|
47
|
+
#${id} .wi-out{margin-top:14px;padding-top:12px;border-top:1px solid var(--grid);display:flex;align-items:baseline;gap:8px;flex-wrap:wrap}
|
|
48
|
+
#${id} .wi-out .lbl{font-size:13px;color:var(--ink-2)}#${id} .wi-out .big{font-size:26px;font-weight:700;font-variant-numeric:tabular-nums}
|
|
49
|
+
#${id} .wi-bar{height:10px;background:var(--grid);border-radius:5px;overflow:hidden;margin-top:10px}
|
|
50
|
+
#${id} .wi-bar>div{height:100%;background:var(--accent);width:0;transition:width .15s}
|
|
51
|
+
</style>
|
|
52
|
+
${config.title ? `<div class="wi-t">${esc(config.title)}</div>` : ''}
|
|
53
|
+
${config.sub ? `<div class="wi-s">${esc(config.sub)}</div>` : ''}
|
|
54
|
+
${rows}
|
|
55
|
+
<div class="wi-out"><span class="lbl">${esc(out.label || 'Result')}:</span><span class="big" data-out>β</span></div>
|
|
56
|
+
${meta.out.max ? `<div class="wi-bar"><div data-bar></div></div>` : ''}
|
|
57
|
+
<script>(function(){
|
|
58
|
+
var root=document.getElementById(${jsonInline(id)}), M=${jsonInline(meta)};
|
|
59
|
+
var names=M.vars.map(function(v){return v.name});
|
|
60
|
+
// Validate the expression: numbers, operators, parens, the variable names, and Math.* only.
|
|
61
|
+
// Anything else (window, document, fetch, constructor, quotes, bracketsβ¦) is rejected, so
|
|
62
|
+
// an AI-authored expression can never execute arbitrary JS in the reader's browser.
|
|
63
|
+
function safe(expr){
|
|
64
|
+
if(typeof expr!=='string'||!/^[\\s0-9.+\\-*/%(),A-Za-z_$]*$/.test(expr))return null;
|
|
65
|
+
var allow={Math:1,abs:1,pow:1,sqrt:1,cbrt:1,min:1,max:1,round:1,floor:1,ceil:1,trunc:1,sign:1,log:1,log2:1,log10:1,exp:1,sin:1,cos:1,tan:1,atan:1,asin:1,acos:1,hypot:1,PI:1,E:1};
|
|
66
|
+
names.forEach(function(n){allow[n]=1});
|
|
67
|
+
var ids=expr.match(/[A-Za-z_$][\\w$]*/g)||[];
|
|
68
|
+
for(var i=0;i<ids.length;i++){if(!allow[ids[i]])return null;}
|
|
69
|
+
return expr;
|
|
70
|
+
}
|
|
71
|
+
var s=safe(M.expr), fn;
|
|
72
|
+
try{ fn=s?new Function(names.join(','),'"use strict";return ('+s+')'):function(){return NaN}; }
|
|
73
|
+
catch(e){ fn=function(){return NaN}; }
|
|
74
|
+
function sel(n){ return (window.CSS&&CSS.escape)?CSS.escape(n):n; }
|
|
75
|
+
function recompute(){
|
|
76
|
+
var r;
|
|
77
|
+
try{
|
|
78
|
+
var vals=names.map(function(n){var el=root.querySelector('[data-v="'+sel(n)+'"]');return el?parseFloat(el.value):0;});
|
|
79
|
+
r=fn.apply(null,vals);
|
|
80
|
+
}catch(e){ r=NaN; }
|
|
81
|
+
var o=root.querySelector('[data-out]');
|
|
82
|
+
o.textContent=(typeof r==='number'&&isFinite(r))?(r.toFixed(M.out.decimals)+(M.out.unit?' '+M.out.unit:'')):'β';
|
|
83
|
+
if(M.out.max){var b=root.querySelector('[data-bar]');var pct=Math.max(0,Math.min(100,(r/M.out.max)*100));b.style.width=(isFinite(pct)?pct:0)+'%';}
|
|
84
|
+
}
|
|
85
|
+
root.querySelectorAll('input[type=range]').forEach(function(el){
|
|
86
|
+
el.addEventListener('input',function(){
|
|
87
|
+
var s2=root.querySelector('.wi-val[data-for="'+sel(el.dataset.v)+'"]');
|
|
88
|
+
if(s2){var u=(M.vars.find(function(v){return v.name===el.dataset.v})||{}).unit;s2.textContent=el.value+(u?' '+u:'');}
|
|
89
|
+
recompute();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
recompute();
|
|
93
|
+
})();</script>
|
|
94
|
+
</div>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function num(v, d) { const n = parseFloat(v); return Number.isFinite(n) ? n : d; }
|
|
98
|
+
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
|
|
99
|
+
function clampInt(v, lo, hi, d) { const n = parseInt(v, 10); return Number.isFinite(n) ? Math.max(lo, Math.min(hi, n)) : d; }
|