Both apps at abcbooks.fyi used to open lessons strictly in order: a lesson stayed locked until the one before it was finished. Now a grown-up or an older child can jump straight to any lesson, in the readers (ABC Books) and in the math books (Ray's Arithmetic). Every block has a Copy button.
What changed
- "Go to lesson" sits at the top of every book's lesson map. It's a list of every lesson by number and name, then a Go button. It starts on the lesson that's next, and finished lessons have a โ .
- Names come from the books:
- Second to Sixth Readers: the lesson titles, for example "Lesson 2: Bubbles".
- Primer, First Reader and Spell & Read: these have no titles, so each lesson is named by its first line, for example "Lesson 21: What bird is this?".
- Ray's books: "Lesson 31: Taking away fractions", with the list grouped by the book's parts (Addition, Subtraction, Fractions...).
- Skipping ahead on the map: a lesson ahead of the child still looks faded. Tapping it once says "Lesson 7 is ahead. Tap it again to skip to it."; a second tap within 4 seconds opens it. One stray tap won't skip a lesson by accident.
- Carrying on after a skip: lessons are open up to one past the furthest lesson finished. A child who jumps to lesson 40 and finishes it finds lesson 41 glowing as "next", and every earlier lesson stays open to go back to.
- Screen readers hear each tile's full name, for example "Lesson 5: Two Dogs, ahead" or "Lesson 5: Two Dogs, done".
- Locked lessons: the subscription lock (when it's switched on) still applies to Go and to the tiles.
How it works
openBook()in eachapp.js:- finds
far, the furthest lesson with stars. A tile is open when its index is<= far + 1, andfar + 1is the glowing one. - remembers a tap on a faded tile in
skipArmfor 4 seconds; a second tap starts the lesson. - fills the
jumpSellist.lessonName()in ABC Books uses the title, else the first line, cut at 48 letters. Ray's uses<optgroup>for the parts.
- finds
index.htmlin each app adds the.jumpbar (label, list, Go) above the tiles, with matching dark styles.sw.js: the cache versions went up (abc-shell-v25,rays-shell-v11), so phones and tablets that installed the app pick up the change on their next visit.
Progress isn't changed by any of this; skipping doesn't give stars for the lessons in between.
Where the files go
These steps assume both apps were built by the earlier posts: the Primer post for ABC Books, and the Ray's New Primary Arithmetic post for Ray's. Those posts are republished with these same files, so following them from scratch gives the same result.
Run everything on your Windows PC, in PowerShell, in the folder that holds ABCBooks and RaysArithmetic. The blocks below are written as Linux cat commands so they match the other posts; the simplest way on the PC is to open each file in Notepad and paste the block's contents (everything between the EOF lines).
Proxmox host shell (Proxmox web UI โ pve โ Shell), if you'd rather write them on the host first, one command:
mkdir -p /root/lesson-jump/ABCBooks/public /root/lesson-jump/RaysArithmetic/public
ABC Books (the readers)
ABCBooks/public/app.js: lessonName(), skipArm and the new openBook().
cat > /root/lesson-jump/app.js <<'EOF'
// ABC Books: gamified McGuffey lessons. All progress is saved per kid in localStorage ('abcbooks').
const BOOKS = {};
const App = (() => {
const $ = id => document.getElementById(id);
// Readers are added by a grown-up on the home screen. Devices that used ABC Books before that existed
// keep the first family's three readers.
const LEGACY_KIDS = ['Layla', 'Lavon', 'Lilly'];
let P = {}; // spoken instructions (prompts.json, also recorded by tools/make-audio.ps1)
const SHELF = [
{ id: 'primer1881' },
{ id: 'first1879' },
{ id: 'second1879' },
{ id: 'third1879' },
{ id: 'fourth1879' },
{ id: 'fifth1879' },
{ id: 'sixth1879' },
{ id: 'spell1867' },
{ id: 'silabario1861' } // Spanish (Puerto Rico, 1861): book.lang 'es', words shown with syllable hyphens
];
const STEPS = [
{ id: 'words', name: 'New words', icon: 'A' },
{ id: 'spell', name: 'Spell it', icon: '๐ค' },
{ id: 'listen', name: 'Listen', icon: '๐' },
{ id: 'read', name: 'Your turn to read', icon: '๐ค' },
{ id: 'talk', name: 'Talk about it', icon: '๐ฌ' },
{ id: 'print', name: 'Trace: print', icon: 'a' },
{ id: 'script', name: 'Trace: cursive', icon: '๐ถ' },
{ id: 'board', name: 'Blackboard', icon: 'โ' }
];
// ---------- storage ----------
let S = load();
function load() {
let s = null; try { s = JSON.parse(localStorage.getItem('abcbooks')); } catch (e) {}
s = s || { kid: null, progress: {}, boards: {} };
if (!s.speed) s.speed = 60;
if (!Array.isArray(s.readers)) {
s.readers = (s.kid || Object.keys(s.progress).length) ? LEGACY_KIDS.slice() : [];
for (const n of Object.keys(s.progress)) if (!s.readers.includes(n)) s.readers.push(n);
}
if (!s.kid) { try { const c = JSON.parse(localStorage.getItem('blockcraft_cfg')); if (c && s.readers.includes(c.name)) s.kid = c.name; } catch (e) {} }
return s;
}
function save() { try { localStorage.setItem('abcbooks', JSON.stringify(S)); } catch (e) { trimBoards(); try { localStorage.setItem('abcbooks', JSON.stringify(S)); } catch (e2) {} } }
function trimBoards() { for (const k in S.boards) S.boards[k] = S.boards[k].slice(-12); }
const prog = (book) => ((S.progress[S.kid] = S.progress[S.kid] || {})[book] = (S.progress[S.kid] || {})[book] || {});
// ---------- sound + voice ----------
let AC;
function beep(good) {
try {
AC = AC || new (window.AudioContext || window.webkitAudioContext)();
const o = AC.createOscillator(), g = AC.createGain(), t = AC.currentTime;
o.type = 'triangle'; o.connect(g); g.connect(AC.destination);
const notes = good ? [523, 659, 784] : [220, 180];
notes.forEach((f, i) => o.frequency.setValueAtTime(f, t + i * 0.1));
g.gain.setValueAtTime(0.25, t); g.gain.exponentialRampToValueAtTime(0.001, t + notes.length * 0.1 + 0.2);
o.start(t); o.stop(t + notes.length * 0.1 + 0.25);
} catch (e) {}
}
const hasTTS = 'speechSynthesis' in window;
let voice = null;
function pickVoice() {
if (!hasTTS) return;
const v = speechSynthesis.getVoices().filter(v => /^en/i.test(v.lang));
voice = v.find(v => /female|samantha|zira|aria|jenny|google us/i.test(v.name)) || v[0] || null;
}
if (hasTTS) { pickVoice(); speechSynthesis.onvoiceschanged = pickVoice; }
// Recorded Eloquence clips (tools/make-audio.ps1): audio/<book>.json maps text -> clip id + word start times per speed.
const AUDIO = {};
const clip = new Audio(); clip.preload = 'auto';
let clipDone = null, clipRaf = 0, unlocked = false, seqTok = 0, playTok = 0;
function stopAll() { seqTok++; stopSpeech(); } // stop anything playing, including a word-by-word sequence
// ---------- sound it out (phonics chunks, recorded by tools/make-soundouts.ps1) ----------
// The word big on screen, letter teams (st, sh, igh, ing...) in red, a silent e grey; Eloquence says the word, then each
// chunk's phonics sound while the highlight moves slowly from chunk to chunk.
const SO = {};
const loadSO = b => (b in SO) ? SO[b] : (SO[b] = fetch(`audio/${b}.soundout.json`).then(r => r.ok ? r.json() : null).catch(() => null));
// a word as the recordings know it: letters (any language) and apostrophes; hyphens kept for the Spanish syllable books
const isEs = () => !!(book && book.lang === 'es');
const clean = w => w.replace(isEs() ? /[^\p{L}'-]/gu : /[^A-Za-z']/g, '').replace(/^-+|-+$/g, '');
// Spanish (the 1861 Silabario): the word, then one syllable at a time, like the book teaches (ca... sa... casa)
async function soundOutEs(word) {
const w = clean(word), key = w.replace(/-/g, '').toLowerCase(); if (!key) return;
const parts = (book.syl && book.syl[key]) || w.toLowerCase().split('-');
stopAll();
const ov = el('div', 'so'), row = el('div', 'sow'); ov.appendChild(row);
const spans = parts.map(t => { const s = el('span', '', esc(t)); row.appendChild(s); return s; });
ov.appendChild(el('div', 'hint', 'Listen: one syllable at a time, then the word. Tap to close.'));
document.body.appendChild(ov);
const my = seqTok; let stop = false; ov.onclick = () => { stop = true; stopAll(); };
try {
for (let i = 0; i < parts.length && !stop && my === seqTok; i++) {
spans.forEach((s, k) => s.classList.toggle('on', k === i));
const c = clipFor('syl:' + parts[i]); if (c) await playClip(c); else await ttsSpeak(parts[i]);
await new Promise(r => setTimeout(r, 350));
}
spans.forEach(s => s.classList.add('on'));
if (!stop && my === seqTok) { const c = clipFor(w) || clipFor(key); if (c) await playClip(c); }
} finally { setTimeout(() => ov.remove(), 400); }
}
// ---------- instructions in the Spanish book: English, then Spanish (or one of them; a grown-up's switch, S.ins) ----------
// instructions-es.json maps the app's English text to Spanish; the Spanish clips are recorded as 'es:<English text>'.
// Only for books with lang 'es': the English books never change.
let ES = {};
const insMode = () => isEs() ? (S.ins || 'both') : 'en';
const fill = (t, v) => v ? t.replace(/\{(\w+)\}/g, (m, k) => (k in v ? v[k] : m)) : t;
function hint(key, v) {
const en = fill(key, v), es = ES[key] && fill(ES[key], v), m = insMode(), h = $('hint');
if (m === 'en' || !es) h.textContent = en;
else if (m === 'es') h.textContent = es;
else h.innerHTML = esc(en) + '<br><span class="es">' + esc(es) + '</span>';
}
function toastI(key, ms) {
const es = ES[key], m = insMode();
if (m === 'en' || !es) return toast(key, ms);
toast(m === 'es' ? es : key, ms);
if (m === 'both') $('toast').innerHTML = esc(key) + '<br>' + esc(es);
}
const titleI = name => { const es = ES[name], m = insMode(); return m === 'en' || !es ? name : m === 'es' ? es : `${name} ยท ${es}`; };
// say an instruction: English, a short pause, then Spanish; stops if anything else starts speaking meanwhile
async function sayIns(key) {
const m = insMode(), esKey = 'es:' + key;
if (m === 'en' || !clipFor(esKey)) return speak(key);
if (m === 'es') return speak(esKey);
await speak(key);
const t = seqTok; await new Promise(r => setTimeout(r, 250));
if (t === seqTok) return speak(esKey);
}
async function soundOut(word) {
if (isEs()) return soundOutEs(word);
const w = word.replace(/[^A-Za-z']/g, '').toLowerCase(); if (!w) return;
const m = await loadSO(bookId), e = m && m.words[w];
if (!e) return speak(word.replace(/[^A-Za-z']/g, ''), null, 0.7);
stopAll();
const ov = el('div', 'so'), row = el('div', 'sow'); ov.appendChild(row);
const spans = e.ch.map(([t, k]) => { const s = el('span', (t.length > 1 ? 'multi' : '') + (k === 'S' ? ' silent' : ''), t); row.appendChild(s); return s; });
ov.appendChild(el('div', 'hint', 'Listen: the word, then each sound. Tap to close.'));
ov.onclick = () => stopAll();
document.body.appendChild(ov);
const my = ++playTok;
try {
const u = await clipURL(`audio/so/s${m.speed}/${e.id}.mp3`);
if (my !== playTok) return;
await new Promise(res => {
clipDone = res; clip.muted = false; clip.src = u;
const tick = () => {
const ms = clip.currentTime * 1000; let cur = -1;
e.ch.forEach(([, , t], i) => { if (t >= 0 && ms >= t - 80) cur = i; });
spans.forEach((s, i) => s.classList.toggle('on', i === cur));
clipRaf = setTimeout(tick, 60); // a timer, not animation frames: keeps stepping even on a busy tablet
};
clip.onended = clip.onerror = () => { clearTimeout(clipRaf); if (clipDone) { const d = clipDone; clipDone = null; d(); } };
clip.play().then(tick).catch(() => clip.onended());
});
} catch (err) { } finally { setTimeout(() => ov.remove(), 500); }
}
// press and hold a word (about half a second) to sound it out; a quick tap still does what it did before
function holdToSound(node, word) {
let t = 0, held = false;
node.addEventListener('pointerdown', () => { held = false; clearTimeout(t); t = setTimeout(() => { held = true; soundOut(word); }, 550); });
['pointerup', 'pointerleave', 'pointercancel'].forEach(ev => node.addEventListener(ev, () => clearTimeout(t)));
node.addEventListener('click', e => { if (held) { e.stopImmediatePropagation(); e.preventDefault(); held = false; } }, true);
node.addEventListener('contextmenu', e => e.preventDefault());
}
function stopSpeech() {
cancelAnimationFrame(clipRaf); clearTimeout(clipRaf); clip.pause();
if (clipDone) { const d = clipDone; clipDone = null; d(); }
if (hasTTS) speechSynthesis.cancel();
}
// look in the open book's recordings first, then any other book's (the home screen has no book open yet)
function clipFor(key) {
for (const b of [bookId, ...Object.keys(AUDIO)]) {
const m = AUDIO[b]; if (!m) continue;
const c = m.items[key] || (!/\s/.test(key) && m.items[key.toLowerCase()]);
if (c) return { ...c, book: b };
}
}
const speedOf = b => AUDIO[b].speeds.includes(S.speed) ? S.speed : AUDIO[b].speeds[0];
function playClip(c, onWord) {
stopSpeech();
const sp = speedOf(c.book), t = c['t' + sp] || [];
const my = ++playTok;
return new Promise(res => {
clipDone = () => { onWord && onWord(-1); res(); };
clipURL(`audio/${c.book}/s${sp}/${c.id}.mp3`).then(u => {
if (my !== playTok) return;
clip.muted = false; clip.src = u;
const tick = () => {
const ms = clip.currentTime * 1000 + 40; let i = 0;
for (let k = 0; k < t.length - 1; k++) if (t[k] >= 0 && ms >= t[k]) i = k;
onWord(i); clipRaf = requestAnimationFrame(tick);
};
clip.onended = clip.onerror = () => { cancelAnimationFrame(clipRaf); if (clipDone) { const d = clipDone; clipDone = null; d(); } };
clip.play().then(() => { if (onWord) tick(); }).catch(() => clip.onended());
}).catch(() => { if (my === playTok && clipDone) { const d = clipDone; clipDone = null; d(); } });
});
}
// Clips are fetched whole and played from memory: works with the offline cache on every browser (Safari needs this).
const blobs = new Map();
function clipURL(url) {
if (!blobs.has(url)) blobs.set(url, fetch(url).then(r => { if (!r.ok) throw new Error(r.status); return r.blob(); }).then(b => URL.createObjectURL(b)));
const p = blobs.get(url); p.catch(() => blobs.delete(url)); return p;
}
// warm up a lesson's clips so the first tap answers right away
function preload(L) {
if (!AUDIO[bookId]) return;
[...L.lines, ...L.words, ...L.letters.map(t => 'letter:' + t)].forEach(k => { const c = clipFor(k); if (c) clipURL(`audio/${c.book}/s${speedOf(c.book)}/${c.id}.mp3`).catch(() => {}); });
}
// Safari/iOS only allows audio that starts inside a tap: play a muted clip on the first tap to unlock it.
window.addEventListener('pointerdown', () => {
const c = clipFor('Yes!'); if (unlocked || !c) return; unlocked = true;
const src = `audio/${c.book}/s${AUDIO[c.book].speeds[0]}/${c.id}.mp3`;
if (!clip.src) { clip.muted = true; clip.src = src; clip.play().then(() => { if (clip.src.endsWith(src)) { clip.pause(); } clip.muted = false; }).catch(() => { clip.muted = false; }); }
}, { passive: true });
// speak(text, onWord(i)) -> Promise: recorded clip if there is one, else one clip per word, else the device voice.
async function speak(text, onWord, rate) {
seqTok++;
const c = clipFor(text);
if (c) return playClip(c, onWord);
const parts = text.split(/[\s,]+/).map(clean).filter(Boolean);
if (parts.length && parts.every(w => clipFor(w))) {
const my = seqTok;
for (let i = 0; i < parts.length && my === seqTok; i++) { await playClip(clipFor(parts[i]), onWord ? j => onWord(j < 0 ? -1 : i) : null); }
return;
}
return ttsSpeak(text, onWord, rate);
}
// device voice fallback. Word highlight uses boundary events, or a timing guess when the device has none.
function ttsSpeak(text, onWord, rate) {
return new Promise(res => {
const words = text.split(/\s+/);
if (!hasTTS) { // no voice: still step through the words so the highlight works
let i = 0; const t = setInterval(() => { onWord && onWord(i); if (++i > words.length) { clearInterval(t); res(); } }, 550); return;
}
speechSynthesis.cancel();
const u = new SpeechSynthesisUtterance(text.replace(/;/g, ','));
if (voice) u.voice = voice; u.rate = rate || 0.8; u.pitch = 1.05;
let gotBoundary = false, guess;
const starts = []; let p = 0; for (const w of words) { starts.push(p); p += w.length + 1; }
u.onboundary = e => {
if (e.name && e.name !== 'word') return; gotBoundary = true; clearInterval(guess);
let i = starts.findIndex((s, k) => e.charIndex >= s && (k === starts.length - 1 || e.charIndex < starts[k + 1]));
onWord && i >= 0 && onWord(i);
};
u.onstart = () => {
if (!onWord) return; let i = 0; onWord(0);
guess = setInterval(() => { if (gotBoundary) return clearInterval(guess); if (++i < words.length) onWord(i); }, 620 / (rate || 0.8) * 0.8);
};
u.onend = u.onerror = () => { clearInterval(guess); onWord && onWord(-1); res(); };
speechSynthesis.speak(u);
});
}
// ---------- speech recognition ----------
// First choice: record the line and let our Vosk server (CT 307, api/recognize) check it. It only
// listens for the line's words, so misreads are caught instead of auto-corrected. Fallback: the browser's own.
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const GUM = !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
let useMic = GUM || !!SR; // turned off for good if the browser blocks the mic (Amazon Kids browser says not-allowed)
let useServer = GUM, lastUnknown = [], stopListening = () => {};
const SOUNDS_LIKE = { a: ['uh', 'ah', 'the'], ann: ['an', 'anne', 'and'], nat: ['gnat', 'matt', 'nate'], rat: ['ratt'], cat: ['kat'], rab: ['rob', 'rabb'] };
// the speech checker answers without syllable hyphens and (Spanish) treats m, b and v alike
const norm = w => { const n = w.toLowerCase().replace(/[^\p{L}']/gu, ''); return isEs() ? n.replace(/[mv]/g, 'b') : n; };
function same(a, b) { return a === b || (SOUNDS_LIKE[a] || []).includes(b); }
// which expected words were heard, in order (LCS alignment)
function matchWords(expected, heard) {
const E = expected.map(norm), H = heard.map(norm).filter(Boolean);
const m = E.length, n = H.length, d = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
for (let i = m - 1; i >= 0; i--) for (let j = n - 1; j >= 0; j--) d[i][j] = same(E[i], H[j]) ? d[i + 1][j + 1] + 1 : Math.max(d[i + 1][j], d[i][j + 1]);
const ok = new Array(m).fill(false); let i = 0, j = 0;
while (i < m && j < n) { if (same(E[i], H[j])) { ok[i] = true; i++; j++; } else if (d[i + 1][j] >= d[i][j + 1]) i++; else j++; }
return ok;
}
async function listen(timeoutMs, line) {
lastUnknown = [];
if (useServer) {
try { return await listenServer(timeoutMs, line); }
catch (e) { useServer = false; if (!SR) { useMic = false; return []; } } // server down: browser recognizer if any
}
return listenBrowser(timeoutMs);
}
// Records until the kid stops talking (1.3 s quiet), the time runs out, or the mic is tapped again.
async function listenServer(timeoutMs, line) {
let stream;
try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); }
catch (e) { useMic = false; return []; }
const ctx = new (window.AudioContext || window.webkitAudioContext)();
try { await ctx.resume(); } catch (e) {}
const src = ctx.createMediaStreamSource(stream), proc = ctx.createScriptProcessor(4096, 1, 1);
const chunks = []; let spoke = false, quiet = 0; const t0 = performance.now();
await new Promise(res => {
let done = false; const fin = () => { if (!done) { done = true; res(); } };
stopListening = fin;
proc.onaudioprocess = e => {
const d = e.inputBuffer.getChannelData(0); chunks.push(new Float32Array(d));
let s = 0; for (let i = 0; i < d.length; i++) s += d[i] * d[i];
if (Math.sqrt(s / d.length) > 0.015) { spoke = true; quiet = 0; } else if (spoke) quiet += d.length / ctx.sampleRate * 1000;
if ((spoke && quiet > 1300) || performance.now() - t0 > timeoutMs) fin();
};
src.connect(proc); proc.connect(ctx.destination);
});
stopListening = () => {};
proc.disconnect(); src.disconnect(); stream.getTracks().forEach(t => t.stop());
const rate = ctx.sampleRate; ctx.close();
if (!spoke) return [];
const r = await fetch('api/recognize?words=' + encodeURIComponent(line) + (isEs() ? '&lang=es' : ''), { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: to16k(chunks, rate) });
if (!r.ok) throw new Error('speech ' + r.status);
const j = await r.json(); lastUnknown = j.unknown || [];
return [j.words || []];
}
// Float32 at the device rate -> 16 kHz 16-bit PCM (what Vosk wants)
function to16k(chunks, rate) {
const n = chunks.reduce((a, c) => a + c.length, 0), all = new Float32Array(n); let o = 0;
for (const c of chunks) { all.set(c, o); o += c.length; }
const ratio = rate / 16000, m = Math.floor(n / ratio), out = new Int16Array(m);
for (let i = 0; i < m; i++) {
const a = Math.floor(i * ratio), b = Math.min(n, Math.max(a + 1, Math.floor((i + 1) * ratio))); let s = 0;
for (let k = a; k < b; k++) s += all[k];
out[i] = Math.max(-1, Math.min(1, s / (b - a))) * 32767;
}
return out.buffer;
}
function listenBrowser(timeoutMs) {
return new Promise(res => {
const r = new SR(); r.lang = isEs() ? 'es-US' : 'en-US'; r.interimResults = false; r.maxAlternatives = 3;
let best = [], done = false;
const fin = () => { if (done) return; done = true; try { r.stop(); } catch (e) {} res(best); };
r.onresult = e => { const alts = [...e.results[0]].map(a => a.transcript.split(/\s+/)); best = alts; fin(); };
r.onerror = e => { if (/not-allowed/.test(e.error || '')) useMic = false; fin(); };
r.onend = fin;
try { r.start(); } catch (e) { useMic = false; return fin(); }
setTimeout(fin, timeoutMs || 9000);
});
}
// ---------- helpers ----------
function toast(t, ms) { const el = $('toast'); el.textContent = t; el.style.display = 'block'; clearTimeout(toast.t); toast.t = setTimeout(() => el.style.display = 'none', ms || 1200); }
function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
function btn(label, fn, cls) { const b = el('button', cls, label); b.onclick = fn; return b; }
const shuffle = a => { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const starStr = n => 'โ
'.repeat(n) + 'โ'.repeat(3 - n);
function go(id) { document.querySelectorAll('.screen').forEach(s => s.classList.toggle('on', s.id === id)); if (id === 'home') home(); }
// ---------- readers (who is reading) ----------
// Names stay on this device only. "Edit" lets a grown-up remove a reader (two taps; their stars go too).
let addingReader = false, editingReaders = false;
const esc = t => t.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
// say a line with the kid's name if the voice can say the name, else the line without it
const canSay = t => hasTTS || !!clipFor(t) || t.split(/[\s,]+/).map(clean).filter(Boolean).every(w => clipFor(w));
const sayWithName = (withName, without) => speak(canSay(withName) ? withName : without);
// Signed in to a family account (/account/sync.js): the names come from the account, so they are added
// and removed on the account page instead.
const acct = () => window.ABCAccount && window.ABCAccount.signedIn;
function readerButtons() {
const k = $('kids'); k.innerHTML = '';
$('whoHint').textContent = S.readers.length ? '' : 'Grown-ups: add each learner (a first name or nickname).';
if (acct()) {
$('whoHint').innerHTML = S.readers.length ? '' : 'Grown-ups: add your learners on the <a href="/account/">account page</a>.';
S.readers.forEach(n => k.appendChild(btn(esc(n), () => { S.kid = n; save(); home(); sayWithName(P.greet.replace('{kid}', n), P.greetAny); }, S.kid === n ? 'sel' : '')));
if (S.readers.length) { const a = el('a', 'parent', 'Add or remove learners'); a.href = '/account/'; k.appendChild(a); }
return;
}
S.readers.forEach(n => {
const b = btn(esc(n), () => {
if (!editingReaders) { S.kid = n; save(); home(); return sayWithName(P.greet.replace('{kid}', n), P.greetAny); }
if (!b.dataset.sure) { b.dataset.sure = 1; b.textContent = `Remove ${n}? Tap again`; return; }
S.readers = S.readers.filter(x => x !== n); delete S.progress[n]; delete S.boards[n];
if (S.kid === n) S.kid = null;
save(); home();
}, (S.kid === n ? 'sel' : '') + (editingReaders ? ' edit' : ''));
k.appendChild(b);
});
if (addingReader || !S.readers.length) {
const inp = el('input'); inp.maxLength = 16; inp.placeholder = 'Name'; inp.autocomplete = 'off';
const add = () => {
const n = inp.value.trim().replace(/\s+/g, ' ');
if (!/^\p{L}[\p{L}' .-]{0,15}$/u.test(n)) return toast('Letters only, please');
if (S.readers.some(x => x.toLowerCase() === n.toLowerCase())) return toast(`${n} is already here`);
S.readers.push(n); S.kid = n; addingReader = false; save(); home();
};
inp.onkeydown = e => { if (e.key === 'Enter') add(); };
k.append(inp, btn('Add', add, 'big'));
if (S.readers.length) k.appendChild(btn('Cancel', () => { addingReader = false; home(); }, 'ghost parent'));
setTimeout(() => inp.focus(), 50);
} else if (!editingReaders && S.readers.length < 8) k.appendChild(btn('๏ผ', () => { addingReader = true; home(); }, 'add'));
if (S.readers.length && !addingReader) k.appendChild(btn(editingReaders ? 'Done' : 'Edit', () => { editingReaders = !editingReaders; home(); }, 'ghost parent'));
}
// ---------- home + map ----------
function home() {
readerButtons();
const sb = $('speedBtn'); sb.textContent = 'Voice: ' + (S.speed === 75 ? 'faster' : 'normal');
sb.onclick = () => { S.speed = S.speed === 75 ? 60 : 75; save(); home(); bookId = bookId || 'primer1881'; speak('Great job!'); };
// Grown-ups: the Spanish 1861 stories come with gentler words by default (a few unkind words and views of the
// time were changed); this switches to the book's original wording.
const ob = $('origBtn'), hasOrig = Object.values(BOOKS).some(b => b.lessons.some(L => L.linesOrig));
ob.hidden = !hasOrig; ob.textContent = 'Spanish stories: ' + (S.orig ? 'original 1861 words' : 'gentle words');
ob.onclick = () => { S.orig = !S.orig; save(); home(); toast(S.orig ? 'Original 1861 wording' : 'Gentle wording', 1600); };
// Grown-ups: instructions in the Spanish book in English then Spanish, English only, or Spanish only (once kids know the routine)
const ib = $('insBtn'), INS = { both: 'English + Spanish', en: 'English only', es: 'Spanish only' }, order = ['both', 'en', 'es'];
ib.hidden = !Object.values(BOOKS).some(b => b.lang === 'es');
ib.textContent = 'Spanish book instructions: ' + INS[S.ins || 'both'];
ib.onclick = () => { S.ins = order[(order.indexOf(S.ins || 'both') + 1) % 3]; save(); home(); toast(INS[S.ins], 1600); };
// The books follow the kids as they grow: each shows this kid's progress, and the one they're on glows.
// Every book stays open, so an older kid can start at their own level.
const sh = $('shelf'); sh.innerHTML = '';
const doneIn = id => { const p = (S.progress[S.kid] || {})[id] || {}; return Object.values(p).filter(x => x && x.stars).length; };
const open = SHELF.filter(b => !b.soon && BOOKS[b.id]);
const cur = S.kid && (open.slice().reverse().find(b => doneIn(b.id) > 0 && doneIn(b.id) < BOOKS[b.id].lessons.length)
|| open.find(b => doneIn(b.id) < BOOKS[b.id].lessons.length));
SHELF.forEach(b => {
const B = BOOKS[b.id] || b;
const n = BOOKS[b.id] ? BOOKS[b.id].lessons.length : 0, d = S.kid && n ? doneIn(b.id) : 0;
const prog = n && S.kid ? (d >= n ? 'โ
Finished!' : d ? `Lesson ${d + 1} of ${n}` : `Lessons: ${n}`) : '';
const e = btn(`<b>${B.title}</b><span class="bp">${prog}</span><small>${B.sub}</small>`, () => {
if (!S.kid) return toast('Pick your name first!');
if (b.soon || !BOOKS[b.id]) return toast('Coming soon!');
openBook(b.id);
}, 'book' + (b.soon ? ' soon' : '') + (cur && cur.id === b.id ? ' cur' : ''));
e.style.background = B.color; sh.appendChild(e);
});
}
let book, bookId;
// A lesson's name for the "Go to lesson" list and the tiles: its title, else its first line.
function lessonName(L) {
const s = L.title || (L.lines && L.lines[0]) || (L.words || []).slice(0, 4).join(', ');
return `${L.review ? 'Review' : 'Lesson'} ${L.n}` + (s ? ': ' + (s.length > 48 ? s.slice(0, 46) + 'โฆ' : s) : '');
}
let skipArm = null; // a locked tile tapped once: a 2nd tap within 4 s skips ahead to it
function openBook(id) {
loadSO(id);
bookId = id; book = BOOKS[id]; $('mapTitle').textContent = `${S.kid}: ${book.title}`;
const t = $('tiles'); t.innerHTML = ''; const p = prog(id);
// Lessons are open up to one past the furthest one finished, so after a skip the kid carries on from there.
let far = -1; book.lessons.forEach((L, i) => { if ((p[L.n] || {}).stars) far = i; });
const paidAt = i => window.ABCAccount && window.ABCAccount.locked('readers', i);
const begin = (L, i) => paidAt(i) ? window.ABCAccount.unlockScreen('readers') : startLesson(L);
book.lessons.forEach((L, i) => {
const st = (p[L.n] || {}).stars || 0, open = i <= far + 1, isNext = i === far + 1;
// past the free lessons without a subscription (only when the paywall is switched on)
const paid = paidAt(i);
const tile = btn(`${L.review ? 'Review<br>' + L.n : L.n}<span class="st">${st ? starStr(st) : paid ? '๐' : ''}</span>`, () => {
if (paid) return window.ABCAccount.unlockScreen('readers');
if (!open && !st && !(skipArm && skipArm.i === i && Date.now() - skipArm.at < 4000)) {
skipArm = { i, at: Date.now() };
return toast(`${L.review ? 'Review' : 'Lesson'} ${L.n} is ahead. Tap it again to skip to it.`, 3500);
}
skipArm = null; startLesson(L);
}, 'tile' + (isNext ? ' next' : '') + (!open && !st ? ' locked' : '') + (L.review ? ' rev' : ''));
tile.title = lessonName(L) + (st ? ', done' : !open ? ', ahead' : '');
tile.setAttribute('aria-label', tile.title);
t.appendChild(tile);
});
// "Go to lesson": every lesson by name, starting on the next one; Go starts it (skipping ahead is allowed).
const sel = $('jumpSel'); sel.innerHTML = '';
book.lessons.forEach((L, i) => {
const o = el('option'); o.textContent = lessonName(L) + ((p[L.n] || {}).stars ? ' โ
' : '');
o.value = i; if (i === Math.min(far + 1, book.lessons.length - 1)) o.selected = true; sel.appendChild(o);
});
$('jumpGo').onclick = () => { const i = +sel.value; begin(book.lessons[i], i); };
go('map'); sayIns(P.map);
const nx = t.querySelector('.next'); if (nx) nx.scrollIntoView({ block: 'center' });
}
// ---------- lesson flow ----------
let L, stepIdx, scores, cleanup = null, sayAgain = null;
function stepsFor(L) { return STEPS.filter(s => (s.id !== 'words' || (L.words.length + L.letters.length)) && (s.id !== 'talk' || (L.questions && L.questions.length)) && (s.id !== 'spell' || book.spell)); }
function startLesson(lesson) {
L = S.orig && lesson.linesOrig ? { ...lesson, lines: lesson.linesOrig } : lesson; // original 1861 wording if a grown-up chose it
scores = {}; stepIdx = 0; preload(L);
$('lesTitle').textContent = (L.review ? 'Review ' : 'Lesson ') + L.n;
go('lesson'); runStep();
}
function leave() { stopStep(); openBook(bookId); }
function stopStep() { stopAll(); if (cleanup) { cleanup(); cleanup = null; } }
function runStep() {
stopStep();
const steps = stepsFor(L), s = steps[stepIdx];
const dots = $('steps'); dots.innerHTML = '';
steps.forEach((x, i) => { const d = el('i', i < stepIdx ? 'done' : i === stepIdx ? 'cur' : '', x.icon); dots.appendChild(d); });
if (!s) return finish();
$('stepTitle').textContent = titleI(s.name); $('stage').innerHTML = ''; $('controls').innerHTML = ''; $('hint').textContent = '';
sayAgain = null; stepTok++;
const instr = s.id === 'read' ? (useMic ? P.readMic : P.readHelper) : P[s.id];
hint(instr);
introP = (stepIdx === 0 ? sayIns('lesson:' + L.n) : Promise.resolve()).then(() => stepTok === myTok && sayIns(instr));
const myTok = stepTok;
$('sayBtn').onclick = () => sayIns(instr).then(() => sayAgain && sayAgain());
({ words: stepWords, listen: stepListen, read: stepRead, talk: stepTalk, spell: stepSpell, print: () => stepTrace('print'), script: () => stepTrace('script'), board: stepBoard })[s.id]();
}
let introP = Promise.resolve(), stepTok = 0;
const afterIntro = fn => { const t = stepTok; introP.then(() => { if (t === stepTok) fn(); }); };
function next(stars) { scores[stepsFor(L)[stepIdx].id] = stars; stepIdx++; runStep(); }
// 1. New words: hear each card, then "find it" game
function stepWords() {
const items = [...L.letters.map(t => ({ t, letter: true })), ...L.words.map(t => ({ t }))];
const box = el('div', 'cards'); $('stage').appendChild(box);
const cards = items.map(it => { const c = btn(it.t, () => onTap(it, c), 'card' + (it.letter ? ' letter' : '')); if (!it.letter) holdToSound(c, it.t); box.appendChild(c); return c; });
let mode = 'learn', heardAll = new Set(), target = null, round = 0, misses = 0, busy = false;
const rounds = shuffle(L.words.length ? L.words : L.letters).concat(shuffle(L.words.length ? L.words : L.letters)).slice(0, 6);
hint('Tap each card to hear it.');
// a word with a meaning from the book (Fourth Reader DEFINITIONS) says the word and what it means
const def = it => !it.letter && L.defs && L.defs[it.t];
function say(it) { if (def(it)) $('hint').textContent = `${it.t}: ${def(it)}`; return speak(it.letter ? 'letter:' + it.t : def(it) ? 'def:' + it.t : it.t, null, 0.7); }
function onTap(it, c) {
if (mode === 'learn') { say(it); heardAll.add(it); c.classList.add('ok'); setTimeout(() => c.classList.remove('ok'), 500); if (heardAll.size === items.length) startGame(); return; }
if (busy) return;
if (it.t === target) { busy = true; beep(true); c.classList.add('ok'); setTimeout(() => { busy = false; c.classList.remove('ok'); nextRound(); }, 700); }
else { beep(false); misses++; c.classList.remove('no'); void c.offsetWidth; c.classList.add('no'); }
}
function startGame() {
$('controls').innerHTML = '';
$('controls').appendChild(btn('Play: Find it!', () => { mode = 'game'; $('controls').innerHTML = ''; sayIns(P.game).then(nextRound); }, 'big'));
hint('Good! Now play the game.'); sayIns('Good! Now play the game.');
}
function nextRound() {
if (round >= rounds.length) { busy = true; toastI('Great job! โญ'); return setTimeout(() => next(misses === 0 ? 3 : misses < 3 ? 2 : 1), 900); }
target = rounds[round++]; hint('Find the word ({a} of {b})', { a: round, b: rounds.length });
sayAgain = () => sayIns('Find ' + target); sayAgain();
shuffle(cards).forEach(c => box.appendChild(c));
}
}
// 2. Listen: the book is read to you with each word lit up
function stepListen() {
const st = $('stage'); const wrap = el('div', 'text');
wrap.appendChild(el('div', 'pic', `${L.pic}<small>${L.picText}</small>`));
if (L.title) wrap.appendChild(el('h2', '', L.title));
if (L.author) { const by = el('div', 'parent', 'by ' + L.author); if (L.about) by.title = L.about; wrap.appendChild(by); }
if (L.en) wrap.appendChild(el('div', 'parent', 'In English: ' + esc(L.en))); // Spanish book: what it means, for grown-ups
if (L.note) wrap.appendChild(el('div', 'parent', 'Note for the teacher (1861): ' + esc(L.note)));
const lines = L.lines.map(t => { const d = el('div', 'ln'); t.split(' ').forEach(w => { const s = el('span', 'w', w); s.onclick = () => speak(clean(w), null, 0.7); holdToSound(s, w); d.appendChild(s); d.append(' '); }); wrap.appendChild(d); return d; });
st.appendChild(wrap);
let stop = false;
async function playAll() {
playB.disabled = true; stop = false;
for (const d of lines) {
if (stop) break;
d.classList.add('cur'); d.scrollIntoView({ block: 'nearest' });
const ws = [...d.querySelectorAll('.w')];
await speak(d.textContent.trim(), i => ws.forEach((w, k) => w.classList.toggle('hi', k === i)));
d.classList.remove('cur'); await new Promise(r => setTimeout(r, 350));
}
if (stop) return;
playB.disabled = false; nextB.disabled = false; hint('Tap a word to hear it. Press and hold a word to sound it out.');
}
const playB = btn('โถ Read to me', playAll, 'big'), nextB = btn('Next โถ', () => next(3));
nextB.disabled = true; $('controls').append(playB, nextB);
sayAgain = playAll; cleanup = () => { stop = true; stopAll(); };
if (!AUDIO[bookId] && !hasTTS) hint('This device has no reading voice. A grown-up can read it.');
afterIntro(playAll);
}
// 3. Your turn: kid reads each line; the app listens (or a grown-up marks it)
function stepRead() {
const st = $('stage'); const wrap = el('div', 'text'); st.appendChild(wrap);
let li = 0, total = 0, right = 0, tries = 0;
const lineEl = el('div', 'ln cur'); wrap.appendChild(lineEl);
const mic = btn('๐ค', go, 'mic');
function show() {
lineEl.innerHTML = '';
L.lines[li].split(' ').forEach(w => { const s = el('span', 'w', w); holdToSound(s, w); lineEl.append(s, ' '); });
hint(useMic ? 'Line {a} of {b}: tap the microphone, then read it out loud.' : 'Line {a} of {b}: read it out loud. Grown-up: tap words that were missed, then โ.', { a: li + 1, b: L.lines.length });
$('controls').innerHTML = '';
if (useMic) $('controls').appendChild(mic);
else {
[...lineEl.querySelectorAll('.w')].forEach(w => { w.classList.add('ok'); w.onclick = () => { w.classList.toggle('ok'); w.classList.toggle('miss'); }; });
$('controls').appendChild(btn('โ Done', () => grade([...lineEl.querySelectorAll('.w')].map(w => w.classList.contains('ok'))), 'big'));
}
$('controls').appendChild(btn('๐ Help', () => speak(L.lines[li])));
$('controls').appendChild(btn('Skip', () => next(1), 'ghost parent'));
}
async function go() {
if (mic.classList.contains('live')) return stopListening(); // second tap = "I'm done"
mic.classList.add('live');
const words = L.lines[li].split(' ');
const alts = await listen(Math.min(25000, 4000 + 700 * words.length), L.lines[li]);
mic.classList.remove('live');
if (!useMic) { hint(P.micBlocked); sayIns(P.micBlocked); return show(); }
let ok = new Array(words.length).fill(false);
for (const a of alts) { const m = matchWords(words, a); if (m.filter(Boolean).length > ok.filter(Boolean).length) ok = m; }
const unknown = lastUnknown.map(norm);
if (alts.length) ok = ok.map((v, i) => v || unknown.includes(norm(words[i]))); // words the checker can't hear count as read
// Spanish: the small Spanish checker misses some words a child reads right (tested with the book's own
// recordings: about 84% heard), so a line where 3 of 4 words were heard counts as read
if (isEs() && alts.length && ok.filter(Boolean).length >= 0.75 * ok.length) ok = ok.map(() => true);
[...lineEl.querySelectorAll('.w')].forEach((w, i) => { w.classList.toggle('ok', ok[i]); w.classList.toggle('miss', !ok[i]); });
if (!alts.length) { toastI('I did not hear you. Try again!', 1500); return; }
grade(ok);
}
function grade(ok) {
const n = ok.filter(Boolean).length; tries++;
if (n === ok.length || tries >= 2) {
total += ok.length; right += n; tries = 0;
if (n === ok.length) { beep(true); toastI('Yes! โญ', 900); } else toastI('Good try!', 900);
setTimeout(() => { if (++li >= L.lines.length) next(right / total > 0.9 ? 3 : right / total > 0.7 ? 2 : 1); else show(); }, 1000);
} else {
beep(false); hint('Listen to the red words sounded out, then try again.');
const words = L.lines[li].split(' ').filter((w, i) => !ok[i]);
(async () => { // each missed word sounded out, the phonics way
for (const w of words) { await soundOut(w); await new Promise(r => setTimeout(r, 400)); }
if (!useMic) show();
})();
}
}
sayAgain = () => speak(L.lines[li]);
show();
}
// 1b. Spell it (Spell & Read, 1867): "Let the child spell each word in the line, then read the line."
// Tapping a word plays its spelling ("I, S. is.") so a grown-up can check.
function stepSpell() {
const wrap = el('div', 'text'); $('stage').appendChild(wrap);
const box = el('div', 'cards'); box.style.height = 'auto'; wrap.appendChild(box);
let i = 0;
function show() {
box.innerHTML = '';
L.lines[i].split(' ').forEach(w => {
// a joined word ("new-cut", "lap-dog") is spelled one part after the other
const parts = w.split('-').map(p => p.replace(/[^A-Za-z']/g, '')).filter(Boolean);
if (!parts.length) return;
const sb = btn(w, async () => { for (const p of parts) await speak('spell:' + (p === 'I' ? p : p.toLowerCase()), null, 0.7); }, 'card');
holdToSound(sb, w); box.appendChild(sb);
});
$('hint').textContent = `Line ${i + 1} of ${L.lines.length}: spell each word out loud, then read the line.`;
}
$('controls').append(btn('๐ Line', () => speak(L.lines[i])), btn('Next line โถ', () => { if (++i < L.lines.length) show(); else next(3); }, 'big'));
sayAgain = () => speak(L.lines[i]);
show();
}
// 3b. Talk about it: the book's own questions (Fourth Reader EXERCISES), read one at a time; the kid answers out loud
function stepTalk() {
const wrap = el('div', 'text'); $('stage').appendChild(wrap);
const q = el('div', 'ln cur'); wrap.appendChild(q);
let i = 0;
function show() {
q.textContent = L.questions[i];
$('hint').textContent = `Question ${i + 1} of ${L.questions.length}: answer it out loud.`;
sayAgain = () => speak(L.questions[i]); sayAgain();
}
$('controls').append(btn('Next question โถ', () => { if (++i < L.questions.length) show(); else next(3); }, 'big'));
afterIntro(show);
}
// 4/5. Tracing pages: letters, words, then each sentence
// Copy work stays a sensible length in the long Readers: new words 6 to a page, up to 6 short sentences
// (tools/make-audio.ps1 records the same word pages).
const WORDS_PER_PAGE = 6;
function copySentences(max) {
if (L.board) return L.board.slice(0, max); // the Spanish book lists its own pages (no syllable hyphens)
const seen = new Set(), all = [];
L.lines.filter(t => t.split(' ').length > 2).forEach(t => { const k = t.toLowerCase(); if (!seen.has(k)) { seen.add(k); all.push(t); } });
const short = all.filter(t => t.length <= 70);
return (short.length ? short : all.slice(0, 1)).slice(0, max);
}
function copyItems(style) {
if (L.copy) return L.copy.slice();
if (style === 'script' && L.script) return L.script.slice(0, 6);
const out = [];
if (L.letters.length) out.push(L.letters.join(' '));
for (let i = 0; i < L.words.length; i += WORDS_PER_PAGE) out.push(L.words.slice(i, i + WORDS_PER_PAGE).join(' '));
return out.concat(copySentences(6));
}
function stepTrace(style) {
const items = copyItems(style); let i = 0, misses = 0, tracer;
const box = el('div', 'grow paper'); box.style.height = '100%'; $('stage').appendChild(box);
const cv = el('canvas'); box.appendChild(cv);
ABCTrace.Tracer.onMiss = why => { misses++; beep(false); hint(why === 'start' ? 'Start at the green dot!' : 'Follow the arrow all the way.'); };
function page() {
if (tracer) tracer.stop();
hint('Page {a} of {b}: start at the green dot and follow the arrow.', { a: i + 1, b: items.length });
sayAgain = () => speak(items[i]); cv.dataset.text = items[i];
if (i === 0) afterIntro(sayAgain); else sayAgain();
tracer =ABCTrace.Tracer(cv, items[i], style, () => {
beep(true); toast('โญ', 700);
setTimeout(() => { if (++i < items.length) page(); else next(misses < 3 ? 3 : misses < 8 ? 2 : 1); }, 800);
});
}
$('controls').appendChild(btn('Skip page', () => { if (++i < items.length) page(); else next(1); }, 'ghost parent'));
setTimeout(page, 50);
const onRes = () => page(); window.addEventListener('resize', onRes);
cleanup = () => { tracer && tracer.stop(); window.removeEventListener('resize', onRes); };
}
// 6. Blackboard: write it yourself in chalk
function stepBoard() {
const items = copySentences(3);
if (!items.length) items.push(L.lines[0]);
let i = 0, style = 'print', bb, total = 0;
const box = el('div', 'grow'); box.style.height = '100%'; $('stage').appendChild(box);
const cv = el('canvas'); cv.style.background = 'var(--board)'; box.appendChild(cv);
const styleB = btn('Print', () => { style = style === 'print' ? 'script' : 'print'; styleB.textContent = style === 'print' ? 'Print' : 'Cursive'; page(); });
$('controls').append(styleB, btn('๐ Peek', () => bb.peek(2500)), btn('โถ', () => bb.undo()), btn('Erase', () => bb.erase()), btn('Done โ', done, 'big'));
function page() {
if (bb) bb.stop();
bb = ABCTrace.Blackboard(cv, items[i], style);
hint('Write: "{t}" ({a} of {b})', { t: items[i], a: i + 1, b: items.length });
sayAgain = () => sayIns('Write: ' + items[i]);
if (i === 0) afterIntro(sayAgain); else sayAgain();
}
function done() {
if (bb.empty()) return toastI('Write it on the board first!');
const s = bb.score(); total += s;
const list = (S.boards[S.kid] = S.boards[S.kid] || []);
list.push({ book: bookId, n: L.n, text: items[i], style, img: bb.snapshot(), at: Date.now() });
if (list.length > 40) list.splice(0, list.length - 40);
save(); beep(true); toast(starStr(s), 1100);
setTimeout(() => { if (++i < items.length) page(); else next(Math.round(total / items.length)); }, 1200);
}
setTimeout(page, 50);
cleanup = () => bb && bb.stop();
}
function finish() {
const vals = Object.values(scores), stars = Math.max(1, Math.round(vals.reduce((a, b) => a + b, 0) / vals.length));
const p = prog(bookId), old = (p[L.n] || {}).stars || 0;
p[L.n] = { stars: Math.max(old, stars), steps: scores, at: Date.now() }; save();
$('stepTitle').textContent = `${S.kid}, you finished ${L.review ? 'Review' : 'Lesson'} ${L.n}!`;
$('stage').innerHTML = `<div class="text"><div class="stars">${starStr(stars)}</div></div>`;
$('hint').textContent = ''; $('controls').innerHTML = '';
$('controls').append(btn('Lessons', () => openBook(bookId), 'big'));
beep(true); (insMode() === 'en' ? sayWithName(`Great job, ${S.kid}!`, 'Great job!') : sayIns(clipFor('es:Great job, ' + S.kid + '!') ? `Great job, ${S.kid}!` : 'Great job!')).then(() => sayIns(P.done));
}
// ---------- gallery ----------
$('toGallery').onclick = () => {
const g = $('gal'); g.innerHTML = '';
const list = (S.boards[S.kid] || []).slice().reverse();
if (!list.length) g.innerHTML = '<p>No blackboard pictures yet.</p>';
list.forEach(b => { const f = el('figure'); f.style.margin = 0; const im = el('img'); im.src = b.img; f.append(im, el('figcaption', 'parent', `Lesson ${b.n} ยท ${b.style} ยท ${new Date(b.at).toLocaleDateString()}`)); g.appendChild(f); });
go('gallery');
};
// books + their recorded voice are JSON files, so the same lesson data feeds the app and the audio recorder
Promise.all([fetch('prompts.json').then(r => r.json()).then(j => { P = j; }),
fetch('instructions-es.json').then(r => r.ok ? r.json() : null).then(j => { if (j) ES = j.text; }).catch(() => {})].concat(SHELF.filter(b => !b.soon).map(b => Promise.all([
fetch(`books/${b.id}.json`).then(r => r.json()).then(j => { BOOKS[b.id] = j; }),
fetch(`audio/${b.id}.json`).then(r => r.ok ? r.json() : null).then(j => { if (j) AUDIO[b.id] = j; }).catch(() => {})
])))).then(home, () => toast('Could not load the books. Check the internet.', 4000));
// the account answer can arrive after the first draw (signed in or out on another page): redraw the home screen
if (window.ABCAccount) window.ABCAccount.ready.then(() => { if ($('home').classList.contains('on') && Object.keys(BOOKS).length) home(); });
window.addEventListener('pointerdown', () => { if (AC && AC.state === 'suspended') AC.resume(); }, { passive: true });
return { go, leave, debug: () => ({ S, L, stepIdx, scores }) };
})();
EOF
ABCBooks/public/index.html: the .jump styles and the "Go to lesson" bar in the map screen.
cat > /root/lesson-jump/index.html <<'EOF'
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<script>
// The readers live at abcbooks.fyi/readers/ (the welcome page is at /). This page opened at the old address
// (an old offline copy or bookmark of abcbooks.fyi/index.html): go to the new one.
if (location.hostname === 'abcbooks.fyi' && !location.pathname.startsWith('/readers/')) location.replace('/readers/');
</script>
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#121212">
<title>ABC Books</title>
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icon-192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="ABC Books">
<style>
:root{color-scheme:dark;--paper:#121212;--ink:#ececec;--soft:#a8a8a8;--accent:#d9733f;--good:#3fae6e;--bad:#ff6b5e;--board:#1d3527;--card:#1e1e1e;--line:#6a6a6a;--hi:#3a3220}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;background:var(--paper);color:var(--ink);font-family:Georgia,"Times New Roman",serif;overflow:hidden;user-select:none;-webkit-user-select:none}
.screen{position:absolute;inset:0;display:none;flex-direction:column;padding:12px 16px}
.screen.on{display:flex}
h1{margin:4px 0 10px;font-size:clamp(26px,6vmin,44px);text-align:center;letter-spacing:.04em}
h2{margin:6px 0;font-size:clamp(20px,4.5vmin,32px);text-align:center}
button{font:inherit;font-size:clamp(18px,4vmin,26px);border:3px solid var(--line);background:var(--card);color:var(--ink);border-radius:14px;padding:10px 18px;min-height:52px;cursor:pointer}
button:active{transform:scale(.96)}
button.big{background:var(--accent);color:#fff;border-color:#6b2e14;font-size:clamp(22px,5vmin,32px);padding:14px 28px}
button.ghost{border-color:transparent;background:transparent}
button:disabled{opacity:.35}
.row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;align-items:center}
.grow{flex:1;min-height:0;position:relative}
.kids button{min-width:30vmin;font-size:clamp(24px,6vmin,40px)}
.kids button.sel{background:var(--accent);border-color:var(--accent);color:#fff}
.kids button.add,.kids button.ghost,.kids button.big{min-width:0}
.kids button.edit{border-style:dashed}
.kids input{font:inherit;font-size:clamp(22px,5vmin,34px);width:min(60vw,320px);padding:8px 14px;border-radius:14px;border:3px solid var(--line);background:var(--card);color:var(--ink)}
.shelf{display:flex;gap:3vmin;justify-content:center;flex-wrap:wrap;margin-top:3vmin;padding:8px}
#home{overflow-y:auto}
.book{width:min(30vmin,190px);aspect-ratio:3/4;border-radius:6px 14px 14px 6px;color:#fff;padding:14px;display:flex;flex-direction:column;justify-content:space-between;box-shadow:inset 10px 0 0 rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.25);border:none;text-align:left}
.book b{font-size:clamp(18px,4vmin,26px)}.book small{font-size:clamp(13px,2.6vmin,17px);opacity:.9}
.book.soon{filter:grayscale(.7);opacity:.6}
.book .bp{font-size:clamp(14px,3vmin,19px);font-weight:bold;background:rgba(0,0,0,.35);border-radius:8px;padding:4px 8px;align-self:flex-start}
.book.cur{outline:5px solid #ffd166;outline-offset:3px;animation:pulse 1.6s infinite}
.map{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(22vmin,120px),1fr));grid-auto-rows:min(24vmin,120px);gap:12px;overflow-y:auto;padding:6px;align-content:start}
.tile{height:100%;min-height:0;border-radius:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:clamp(22px,6vmin,38px);padding:4px}
.tile .st{font-size:clamp(14px,3vmin,20px);color:#e0a000;letter-spacing:2px}
.tile.next{background:var(--hi);border-color:var(--accent);animation:pulse 1.4s infinite}
.tile.locked{opacity:.35}
.jump{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:6px 0;font-size:clamp(16px,3.4vmin,22px)}
.jump select{flex:1;min-width:0;max-width:100%;font:inherit;padding:8px;background:var(--card);color:var(--ink);border:2px solid var(--line);border-radius:12px}
.tile.rev{font-size:clamp(16px,3.6vmin,24px)}
@keyframes pulse{50%{transform:scale(1.05)}}
.top{display:flex;align-items:center;gap:8px}
.top .t{flex:1;text-align:center;font-size:clamp(18px,4vmin,28px)}
.steps{display:flex;gap:6px;justify-content:center;margin:4px 0}
.steps i{width:clamp(18px,4vmin,28px);height:clamp(18px,4vmin,28px);border-radius:50%;border:3px solid var(--soft);font-style:normal;font-size:12px;display:flex;align-items:center;justify-content:center}
.steps i.done{background:var(--good);border-color:var(--good)}.steps i.cur{border-color:var(--accent);background:var(--hi)}
.cards{display:flex;flex-wrap:wrap;gap:12px;justify-content:center;align-content:center;height:100%}
.card{min-width:20vmin;font-size:clamp(34px,10vmin,72px);padding:8px 18px;border-radius:16px;background:var(--card)}
.card.letter{color:var(--accent)}
.card.ok{background:var(--good);color:#fff;border-color:var(--good)}.card.no{animation:shake .4s}
@keyframes shake{25%{transform:translateX(-8px)}75%{transform:translateX(8px)}}
.pic{font-size:clamp(40px,12vmin,90px);text-align:center;line-height:1.1}
.pic small{display:block;font-size:14px;color:var(--soft)}
.text{display:flex;flex-direction:column;align-items:center;gap:1.4vmin;overflow-y:auto;height:100%;justify-content:center}
.ln{font-size:clamp(26px,7vmin,52px);line-height:1.2;padding:2px 10px;border-radius:10px}
.ln.cur{background:var(--hi)}
.w{display:inline-block;padding:0 4px;border-radius:8px}
.w.hi{background:var(--accent);color:#fff}.w.ok{color:var(--good);text-decoration:underline 4px}.w.miss{color:var(--bad);text-decoration:underline wavy 3px}
canvas{position:absolute;inset:0;width:100%;height:100%;touch-action:none;border-radius:14px}
.paper{background:#1a1a1a;border:3px solid var(--line);border-radius:14px}
.hint{font-size:clamp(15px,3.2vmin,20px);color:var(--soft);text-align:center;min-height:1.4em}
.hint .es{color:#e9b872}
.toast{position:fixed;left:50%;top:40%;transform:translate(-50%,-50%);background:#2a2a2a;border:3px solid var(--accent);color:#fff;padding:18px 28px;border-radius:18px;font-size:clamp(24px,6vmin,40px);display:none;z-index:9;text-align:center}
.stars{font-size:clamp(40px,12vmin,90px);color:#e0a000;text-align:center;letter-spacing:6px}
.gallery{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;overflow-y:auto}
.gallery img{width:min(44vw,260px);border-radius:8px;border:3px solid var(--board)}
.mic{width:clamp(80px,20vmin,130px);height:clamp(80px,20vmin,130px);border-radius:50%;font-size:clamp(36px,9vmin,60px);background:var(--card)}
.mic.live{background:var(--bad);color:#fff;animation:pulse 1s infinite}
.parent{font-size:15px;color:var(--soft)}
.so{position:fixed;inset:0;background:rgba(10,10,10,.92);display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:30;gap:4vmin}
.sow{display:flex;gap:1.5vmin;flex-wrap:wrap;justify-content:center}
.sow span{font-size:clamp(56px,16vmin,140px);font-family:Georgia,serif;color:var(--ink);padding:0 .12em;border-radius:14px;border:4px solid transparent;transition:background .45s,border-color .45s,transform .45s}
.sow span.multi{color:#ff5a5a}
.sow span.silent{color:#777}
.sow span.on{background:var(--hi);border-color:#ffd166;transform:scale(1.12)}
.w{-webkit-touch-callout:none}
</style>
</head><body>
<section id="home" class="screen on">
<h1>ABC Books</h1>
<h2>Who is reading?</h2>
<div class="row kids" id="kids"></div>
<div class="hint" id="whoHint"></div>
<div class="shelf" id="shelf"></div>
<div class="row" style="margin-top:auto"><button class="ghost parent" id="speedBtn">Voice: normal</button><button class="ghost parent" id="origBtn" hidden>Spanish stories: gentle words</button><button class="ghost parent" id="insBtn" hidden>Spanish book instructions: English + Spanish</button><button class="ghost parent" id="toGallery">Blackboard pictures</button><a class="parent" href="check.html">Tablet check</a><a class="parent" href="/account/">Grown-ups: account</a><a class="parent" href="/math/">Math</a><a class="parent" href="/">About</a></div>
</section>
<section id="map" class="screen">
<div class="top"><button onclick="App.go('home')">โ</button><div class="t" id="mapTitle"></div></div>
<div class="jump"><label for="jumpSel">Go to lesson</label><select id="jumpSel"></select><button id="jumpGo">Go</button></div>
<div class="map grow" id="tiles"></div>
</section>
<section id="lesson" class="screen">
<div class="top"><button onclick="App.leave()">โ</button><div class="t" id="lesTitle"></div><button id="sayBtn" title="Say it again">๐</button></div>
<div class="steps" id="steps"></div>
<h2 id="stepTitle"></h2>
<div class="grow" id="stage"></div>
<div class="hint" id="hint"></div>
<div class="row" id="controls"></div>
</section>
<section id="gallery" class="screen">
<div class="top"><button onclick="App.go('home')">โ</button><div class="t">Blackboard pictures</div></div>
<div class="gallery grow" id="gal"></div>
</section>
<div class="toast" id="toast"></div>
<script src="hershey.js"></script>
<script src="trace.js"></script>
<script src="/account/sync.js"></script>
<script src="app.js"></script>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});</script>
</body></html>
EOF
ABCBooks/public/sw.js
cat > /root/lesson-jump/sw.js <<'EOF'
// Offline support. App files: network first (so updates show), cached copy when offline.
// Voice clips (audio/*.mp3) never change for a given name: cache first, saved the first time they play.
// Other apps share this origin (Ray's Arithmetic at /math/ with its own 'rays-*' caches and its own SW),
// so only touch 'abc-' caches and leave /rays/ requests alone.
// The readers moved from abcbooks.fyi/ to abcbooks.fyi/readers/ (the welcome page is at / now). This same file still
// answers at /sw.js, so a device with the old whole-site worker gets this one there, and it removes itself.
const SHELL = 'abc-shell-v27', CLIPS = 'abc-clips-v1';
const RETIRED = location.hostname === 'abcbooks.fyi' && new URL(self.registration.scope).pathname === '/';
if (RETIRED) {
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', e => e.waitUntil(self.registration.unregister()));
} else {
const FILES = ['./', 'index.html', 'app.js', 'prompts.json', 'trace.js', 'hershey.js', 'books/primer1881.json', 'audio/primer1881.json', 'books/first1879.json', 'audio/first1879.json', 'books/second1879.json', 'audio/second1879.json', 'books/third1879.json', 'audio/third1879.json', 'books/fourth1879.json', 'audio/fourth1879.json', 'books/fifth1879.json', 'audio/fifth1879.json', 'books/sixth1879.json', 'audio/sixth1879.json', 'books/spell1867.json', 'audio/spell1867.json', 'books/silabario1861.json', 'audio/silabario1861.json', 'instructions-es.json', 'phonics.json', 'icon-192.png', 'manifest.webmanifest'];
// one file at a time: a book that isn't on this server (a rebuild without the Spanish book) mustn't stop the rest
self.addEventListener('install', e => { e.waitUntil(caches.open(SHELL).then(c => Promise.all(FILES.map(f => c.add(f).catch(() => {})))).then(() => self.skipWaiting())); });
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k.startsWith('abc-') && k !== SHELL && k !== CLIPS).map(k => caches.delete(k)))).then(() => self.clients.claim()));
});
self.addEventListener('fetch', e => {
const r = e.request, u = new URL(r.url);
// the account service (/account/, CT 308) and the reading check (/api/) always go to the network; only sync.js is kept for offline
if (r.method !== 'GET' || u.origin !== location.origin || /\/(rays|math)(\/|$)/.test(u.pathname)) return;
if (/^\/api\//.test(u.pathname) || (/^\/account(\/|$)/.test(u.pathname) && u.pathname !== '/account/sync.js')) return;
if (r.url.endsWith('.mp3')) {
e.respondWith(caches.open(CLIPS).then(c => c.match(r).then(hit => hit || fetch(r).then(res => { if (res.ok && res.status === 200) c.put(r, res.clone()); return res; }))));
return;
}
e.respondWith(fetch(r).then(res => { if (res.ok) caches.open(SHELL).then(c => c.put(r, res.clone())); return res; }).catch(() => caches.match(r)));
});
}
EOF
Ray's Arithmetic (the math books)
RaysArithmetic/public/app.js: skipArm and the new openBook().
cat > /root/lesson-jump/app.js <<'EOF'
// Ray's Arithmetic: gamified Ray's New Primary Arithmetic (1877). Progress is saved per kid in localStorage ('rays').
// Same look and voice as ABC Books; the kid picked in ABC Books (same site) is picked here too.
const Rays = (() => {
const $ = id => document.getElementById(id);
// Readers come from ABC Books on this site ('abcbooks'.readers: names a grown-up added there). Same rule as ABC
// Books: no 'abcbooks' (or no kid and no progress) = no readers yet; an old device without .readers = the three
// original kids plus any names in its progress.
const NAME_OK = n => typeof n === 'string' && /^\p{L}[\p{L}' .-]{0,15}$/u.test(n.trim());
function readers() {
let c = null; try { c = JSON.parse(localStorage.getItem('abcbooks')); } catch (e) {}
let r = [];
if (c && Array.isArray(c.readers)) r = c.readers.filter(NAME_OK);
else if (c && (c.kid || (c.progress && Object.keys(c.progress).length))) r = ['Layla', 'Lavon', 'Lilly'].concat(Object.keys(c.progress || {}).filter(n => NAME_OK(n) && !['Layla', 'Lavon', 'Lilly'].includes(n)));
if (S && S.kid && r.length && !r.includes(S.kid)) r = r.concat(S.kid);
return r;
}
// Ray's series in order (raysarithmetic.wordpress.com/rays-free-arithmetic). A book is playable once books/<id>.json exists.
const SHELF = [
{ id: 'primary1877', title: "Ray's New Primary Arithmetic", sub: 'Book 1 ยท Grades 1-2', color: '#2f6f8f' },
{ id: 'intellectual', title: "Ray's New Intellectual Arithmetic", sub: 'Book 2 ยท Grades 3-4', color: '#6a4c93' },
{ id: 'practical', title: "Ray's New Practical Arithmetic", sub: 'Book 3 ยท Grades 5-6', color: '#2e7d4f' },
{ id: 'higher', title: "Ray's New Higher Arithmetic", sub: 'Book 4 ยท Grades 7-8', color: '#a0522d' },
{ id: 'geometry', title: "Ray's Geometry and Trigonometry", sub: 'Book 5 ยท Grades 9-10', color: '#7a6a1f', soon: true },
{ id: 'algebra1', title: "Ray's New Elementary Algebra", sub: 'Book 6 ยท Grades 9-10', color: '#8f2f4f', soon: true },
{ id: 'algebra2', title: "Ray's New Higher Algebra", sub: 'Book 7 ยท Grade 11', color: '#3b4a8f', soon: true }
];
const BOOKS = {};
let P = {};
const STICKERS = ['๐ฆ', '๐ข', '๐ฆ', '๐', '๐ฆ', '๐', '๐ธ', '๐ง', '๐ฆ', '๐ฌ', '๐ฆ', '๐ผ', '๐จ', '๐', '๐ฆ', '๐ณ', '๐ฆ', '๐ฆ', '๐', '๐ฆ', '๐ฟ๏ธ', '๐ฆ', '๐ ', '๐ค', '๐ฐ', '๐ป', '๐ฏ', '๐ฎ', '๐ท', '๐ต', '๐ป', '๐', 'โญ', '๐', '๐', 'โต', '๐', '๐', '๐', '๐ฉ'];
const STEPS = {
count: { name: 'Count', icon: 'โ' }, numbers: { name: 'Numbers', icon: '#' }, find: { name: 'Find the number', icon: '๐' },
say: { name: 'Read the numbers', icon: '๐ฌ' }, write: { name: 'Write the numbers', icon: 'โ' }, trace: { name: 'Trace the numbers', icon: '1' },
learn: { name: 'Learn the table', icon: '๐' }, rules: { name: 'Learn the table', icon: '๐' }, drill: { name: 'Practice', icon: 'โก' },
problems: { name: 'Story problems', icon: '?' }
};
// ---------- storage ----------
let S = load();
function load() {
let s = null; try { s = JSON.parse(localStorage.getItem('rays')); } catch (e) {}
s = s || {};
s.progress = s.progress || {}; s.pos = s.pos || {}; s.best = s.best || {}; s.stickers = s.stickers || {};
if (!s.speed) s.speed = 60;
if (!s.kid) { // the kid picked in ABC Books or BlockCraft on this site
for (const k of ['abcbooks', 'blockcraft_cfg']) { try { const c = JSON.parse(localStorage.getItem(k)); const n = c && (k === 'abcbooks' ? c.kid : c.name); if (NAME_OK(n)) { s.kid = n.trim(); break; } } catch (e) {} }
}
return s;
}
function save() { try { localStorage.setItem('rays', JSON.stringify(S)); } catch (e) {} }
const kidMap = obj => (obj[S.kid] = obj[S.kid] || {});
const prog = b => (kidMap(S.progress)[b] = kidMap(S.progress)[b] || {});
// ---------- sound + voice (recorded Eloquence clips, else the device voice) ----------
let AC;
function beep(good) {
try {
AC = AC || new (window.AudioContext || window.webkitAudioContext)();
const o = AC.createOscillator(), g = AC.createGain(), t = AC.currentTime;
o.type = 'triangle'; o.connect(g); g.connect(AC.destination);
const notes = good ? [523, 659, 784] : [220, 180];
notes.forEach((f, i) => o.frequency.setValueAtTime(f, t + i * 0.1));
g.gain.setValueAtTime(0.25, t); g.gain.exponentialRampToValueAtTime(0.001, t + notes.length * 0.1 + 0.2);
o.start(t); o.stop(t + notes.length * 0.1 + 0.25);
} catch (e) {}
}
const hasTTS = 'speechSynthesis' in window;
let voice = null;
function pickVoice() { if (!hasTTS) return; const v = speechSynthesis.getVoices().filter(v => /^en/i.test(v.lang)); voice = v.find(v => /female|samantha|zira|aria|jenny|google us/i.test(v.name)) || v[0] || null; }
if (hasTTS) { pickVoice(); speechSynthesis.onvoiceschanged = pickVoice; }
let AUDIO = null; // audio/primary1877.json: spoken text -> clip id (tools/make-audio.ps1)
const clip = new Audio(); clip.preload = 'auto';
let clipDone = null, tok = 0, unlocked = false;
const blobs = new Map();
function clipURL(url) { // fetched whole and played from memory: works offline and on Safari
if (!blobs.has(url)) blobs.set(url, fetch(url).then(r => { if (!r.ok) throw new Error(r.status); return r.blob(); }).then(b => URL.createObjectURL(b)));
const p = blobs.get(url); p.catch(() => blobs.delete(url)); return p;
}
const speedOf = () => AUDIO.speeds.includes(S.speed) ? S.speed : AUDIO.speeds[0];
const urlFor = key => { const c = AUDIO && AUDIO.items[key]; return c ? `audio/s${speedOf()}/${c}.mp3` : null; };
function stopAll() { tok++; clip.pause(); if (clipDone) { const d = clipDone; clipDone = null; d(); } if (hasTTS) speechSynthesis.cancel(); }
function playURL(u, my) {
return new Promise(res => {
clipURL(u).then(b => {
if (my !== tok) return res();
clipDone = res; clip.muted = false; clip.src = b;
clip.onended = clip.onerror = () => { if (clipDone) { const d = clipDone; clipDone = null; d(); } };
clip.play().catch(() => clip.onended());
}).catch(() => res());
});
}
// speak(...keys): one clip per key, played one after the other; a key with no clip uses the device voice
async function speak(...keys) {
stopAll(); const my = tok;
for (const k of keys) {
if (my !== tok || k == null || k === '') continue;
const u = urlFor(String(k));
if (u) await playURL(u, my); else await tts(String(k).replace(/^(n|find|ans|lesson):/, ''));
}
}
function tts(text) {
return new Promise(res => {
if (!hasTTS) return setTimeout(res, 300);
const u = new SpeechSynthesisUtterance(text); if (voice) u.voice = voice; u.rate = 0.85;
u.onend = u.onerror = res; speechSynthesis.speak(u);
});
}
const sayNum = n => speak('n:' + n);
window.addEventListener('pointerdown', () => { // iOS: unlock audio inside the first tap
if (AC && AC.state === 'suspended') AC.resume();
const u = urlFor(P.yes); if (unlocked || !u) return; unlocked = true;
clipURL(u).then(b => { if (!clip.src) { clip.muted = true; clip.src = b; clip.play().then(() => { clip.pause(); clip.muted = false; }).catch(() => { clip.muted = false; }); } }).catch(() => {});
}, { passive: true });
// ---------- helpers ----------
function toast(t, ms) { const e = $('toast'); e.textContent = t; e.style.display = 'block'; clearTimeout(toast.t); toast.t = setTimeout(() => e.style.display = 'none', ms || 1100); }
function el(tag, cls, html) { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; return e; }
function btn(label, fn, cls) { const b = el('button', cls, label); b.onclick = fn; return b; }
const shuffle = a => { a = a.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
const range = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => a + i);
const starStr = n => 'โ
'.repeat(n) + 'โ'.repeat(3 - n);
const starsFor = (miss, n) => miss === 0 ? 3 : miss <= Math.max(2, Math.round(n * 0.15)) ? 2 : 1;
const wait = ms => new Promise(r => setTimeout(r, ms));
function go(id) { document.querySelectorAll('.screen').forEach(s => s.classList.toggle('on', s.id === id)); if (id === 'home') home(); if (id === 'stickers') stickers(); }
const ONES = ['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'];
const TENS = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
const words = n => n < 20 ? ONES[n] : n === 100 ? 'one hundred' : TENS[Math.floor(n / 10)] + (n % 10 ? '-' + ONES[n % 10] : '');
// pictures: dots for a fact (a and b in two colors, crossed out when taken away, rows for times, groups for division)
function dotsFor(op, a, b) {
const box = el('div', 'dots');
const grp = (n, cls, boxed) => { const g = el('div', 'grp' + (boxed ? ' box' : '')); for (let i = 0; i < n; i++) g.appendChild(el('i', 'dot ' + (cls || ''))); return g; };
if (op === 'add') { box.append(grp(a), el('span', 'op', '+'), grp(b, 'b')); }
else if (op === 'sub') { const g = el('div', 'grp'); for (let i = 0; i < b; i++) g.appendChild(el('i', 'dot ' + (i >= b - a ? 'x' : ''))); box.appendChild(g); }
else if (op === 'mul') { if (a * b > 100) return box; for (let i = 0; i < a; i++) box.appendChild(grp(b, i % 2 ? 'b' : '', true)); }
else if (op === 'div') { if (b > 100) return box; for (let i = 0; i < b / a; i++) box.appendChild(grp(a, i % 2 ? 'b' : '', true)); }
return box;
}
const SIGN = { add: '+', sub: 'โ', mul: 'ร', div: 'รท' };
const expr = (op, f) => op === 'sub' ? `${f.b} โ ${f.a}` : op === 'div' ? `${f.b} รท ${f.a}` : `${f.a} ${SIGN[op]} ${f.b}`;
// a picture for a story problem, from the first thing it counts
const PICS = [['cent|dime|money', '๐ฐ'], ['dollar', '๐ต'], ['apple', '๐'], ['pear', '๐'], ['peach', '๐'], ['orange', '๐'], ['lemon', '๐'], ['cherr', '๐'], ['plum|raisin|fig', '๐'], ['melon', '๐'], ['pine-apple', '๐'], ['strawberr|berr', '๐'], ['nut|chestnut|pecan|walnut', '๐ฐ'],
['marble', '๐ต'], ['top', '๐ฏ'], ['kite', '๐'], ['ball', 'โฝ'], ['doll', '๐งธ'], ['bird|swallow|crow|pigeon', '๐ฆ'], ['owl', '๐ฆ'], ['bat', '๐ฆ'], ['duck', '๐ฆ'], ['geese|goose', '๐ฆข'], ['chicken|hen', '๐'], ['fish', '๐'], ['horse', '๐ด'], ['cow|calf|calves', '๐'], ['sheep', '๐'], ['pig|hog', '๐'], ['rabbit', '๐'], ['cat|kitten', '๐'], ['mice|mouse', '๐ญ'],
['egg', '๐ฅ'], ['cake|candy', '๐ฌ'], ['milk', '๐ฅ'], ['book|primer|reader|geograph', '๐'], ['pen|pencil|slate', 'โ๏ธ'], ['ribbon|tape|cloth|calico|muslin|silk', '๐'], ['tree|orchard', '๐ณ'], ['flower|rose', '๐น'], ['leaves|leaf', '๐'], ['ship|boat', 'โต'], ['car', '๐'], ['mile|travel', '๐ค๏ธ'], ['hour|minute', 'โฐ'], ['day|week|month|year', '๐
'], ['wood|cord', '๐ฒ'], ['flour|wheat|corn|oats|seed', '๐พ'],
['boy|girl|pupil|scholar|men|man|persons', '๐ง'], ['window|pane', '๐ '], ['chair|bench', '๐บ'], ['foot|feet|inch|yard|rod', '๐'], ['pound|ounce|ton', 'โ๏ธ'], ['quart|pint|gallon|gill|peck|bushel', '๐ถ']];
function picFor(text) { const t = text.toLowerCase(); let best = null, at = 1e9; for (const [rx, e] of PICS) { const m = t.match(new RegExp('\\b(' + rx + ')')); if (m && m.index < at) { at = m.index; best = e; } } return best || '๐งฎ'; }
// ---------- spoken answers ----------
// The kid can say the answer. First choice: our own Vosk server (CT 307, /api/recognize, shared with ABC Books),
// told to listen only for number words. Fallback: the browser's own recognizer. No mic at all: the button hides.
const NUM_WORDS = 'zero oh one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty thirty forty fifty sixty seventy eighty ninety hundred thousand and a';
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
const GUM = !!(window.isSecureContext && navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
let micOK = GUM || !!SR, useServer = GUM;
const W1 = { zero: 0, oh: 0, one: 1, two: 2, three: 3, four: 4, for: 4, five: 5, six: 6, seven: 7, eight: 8, ate: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19 };
const W10 = { twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90 };
// "nine thousand six hundred" -> 9600; "four two" (digits one at a time) -> 42; "7" -> 7
function wordsToNumber(words) {
// "a hundred" = one hundred; a stray "a" (Vosk often hears it for "one"/"and" in fast speech) alone is one
const w = words.join(' ').toLowerCase().replace(/-/g, ' ').split(/\s+/).filter(x => x && x !== 'and').map((x, i, all) => x === 'a' ? (all.length === 1 || /^(hundred|thousand)$/.test(all[i + 1] || '') ? 'one' : '') : x).filter(Boolean);
if (!w.length) return null;
if (w.every(x => /^\d+$/.test(x))) return parseInt(w.join(''), 10);
if (w.length > 1 && w.every(x => x in W1 && W1[x] < 10)) return parseInt(w.map(x => W1[x]).join(''), 10);
let total = 0, cur = 0, any = false;
for (const x of w) {
if (/^\d+$/.test(x)) { cur += parseInt(x, 10); any = true; }
else if (x in W1 || x in W10) { // "nineteen twenty" (said like a year) -> 1920; "twenty four" -> 24
const v = x in W1 ? W1[x] : W10[x];
cur = v >= 10 && cur % 100 ? cur * 100 + v : cur + v; any = true;
}
else if (x === 'hundred') { cur = (cur || 1) * 100; any = true; }
else if (x === 'thousand') { total += (cur || 1) * 1000; cur = 0; any = true; }
else return null; // a word that isn't a number: didn't understand
}
return any ? total + cur : null;
}
let stopListening = () => {};
async function hearNumber() {
if (useServer) {
try { const w = await listenServer(7000); return w ? wordsToNumber(w) : null; }
catch (e) { useServer = false; if (!SR) { micOK = false; return null; } }
}
const alts = await listenBrowser(7000);
for (const a of alts) { const n = wordsToNumber(a); if (n != null) return n; }
return null;
}
// records until the kid stops talking (1.2 s quiet) or the time runs out (same as ABC Books)
async function listenServer(timeoutMs) {
let stream;
try { stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } }); }
catch (e) { micOK = false; return null; }
const ctx = new (window.AudioContext || window.webkitAudioContext)();
try { await ctx.resume(); } catch (e) {}
const src = ctx.createMediaStreamSource(stream), proc = ctx.createScriptProcessor(4096, 1, 1);
const chunks = []; let spoke = false, quiet = 0; const t0 = performance.now();
await new Promise(res => {
let done = false; const fin = () => { if (!done) { done = true; res(); } };
stopListening = fin;
proc.onaudioprocess = e => {
const d = e.inputBuffer.getChannelData(0); chunks.push(new Float32Array(d));
let s = 0; for (let i = 0; i < d.length; i++) s += d[i] * d[i];
if (Math.sqrt(s / d.length) > 0.015) { spoke = true; quiet = 0; } else if (spoke) quiet += d.length / ctx.sampleRate * 1000;
if ((spoke && quiet > 1200) || performance.now() - t0 > timeoutMs) fin();
};
src.connect(proc); proc.connect(ctx.destination);
});
stopListening = () => {};
proc.disconnect(); src.disconnect(); stream.getTracks().forEach(t => t.stop());
const rate = ctx.sampleRate; ctx.close();
if (!spoke) return null;
const r = await fetch('/api/recognize?near=0&words=' + encodeURIComponent(NUM_WORDS), { method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: to16k(chunks, rate) });
if (!r.ok) throw new Error('speech ' + r.status);
const j = await r.json();
return j.words || [];
}
function to16k(chunks, rate) { // Float32 at the device rate -> 16 kHz 16-bit PCM (what Vosk wants)
const n = chunks.reduce((a, c) => a + c.length, 0), all = new Float32Array(n); let o = 0;
for (const c of chunks) { all.set(c, o); o += c.length; }
const ratio = rate / 16000, m = Math.floor(n / ratio), out = new Int16Array(m);
for (let i = 0; i < m; i++) {
const a = Math.floor(i * ratio), b = Math.min(n, Math.max(a + 1, Math.floor((i + 1) * ratio))); let s = 0;
for (let k = a; k < b; k++) s += all[k];
out[i] = Math.max(-1, Math.min(1, s / (b - a))) * 32767;
}
return out.buffer;
}
function listenBrowser(timeoutMs) {
return new Promise(res => {
if (!SR) return res([]);
const r = new SR(); r.lang = 'en-US'; r.interimResults = false; r.maxAlternatives = 5;
let best = [], done = false;
const fin = () => { if (done) return; done = true; try { r.stop(); } catch (e) {} res(best); };
stopListening = fin;
r.onresult = e => { best = [...e.results[0]].map(a => a.transcript.split(/\s+/)); fin(); };
r.onerror = e => { if (/not-allowed|service-not-allowed/.test(e.error || '')) micOK = false; fin(); };
r.onend = fin;
try { r.start(); } catch (e) { micOK = false; return fin(); }
setTimeout(fin, timeoutMs);
});
}
// number pad (big buttons; a computer keyboard works too; ๐ค to say the answer)
function numPad(onOK) {
const wrap = el('div', 'col'); wrap.style.height = 'auto'; wrap.style.gap = '1vmin';
const entry = el('div', 'entry', ' '); let val = '';
// long whole numbers are shown with commas, the book's way (4,576,791)
const show = () => { entry.textContent = (/^\d{5,}$/.test(val) ? val.replace(/\B(?=(\d{3})+$)/g, ',') : val) || 'ย '; entry.classList.toggle('long', val.length > 9); entry.classList.remove('no', 'ok'); };
const pad = el('div', 'pad');
let fracMode = false; // answers like 3/4 or 2 1/2: extra keys, and OK hands back the text
const press = k => {
if (k === 'โซ') val = val.slice(0, -1);
else if (k === 'OK') { if (val.trim()) onOK(fracMode ? val.trim() : parseInt(val, 10)); return; }
// "and" after a decimal makes a complex decimal (.0154 1/4); the fraction after it may then have a /
else if (k === 'and') { if (fracMode && val && !/[ /]/.test(val) && !val.endsWith('.')) val += ' '; }
else if (k === '/') { if (fracMode && val && !val.endsWith(' ') && !val.includes('/') && (!val.includes('.') || /\.\d+ \d+$/.test(val))) val += '/'; }
else if (k === '.') { if (fracMode && !/[ /.]/.test(val)) val += '.'; }
else if (val.length < 40) val += k; // the book's long numbers (up to 36 figures) are checked as written
show();
};
['1', '2', '3', '4', '5', '6', '7', '8', '9', 'โซ', '0', 'OK'].forEach(k => pad.appendChild(btn(k, () => press(k), k === 'OK' ? 'okb' : '')));
const fracRow = el('div', 'pad'); fracRow.style.display = 'none';
fracRow.append(btn('/', () => press('/')), btn('and', () => press('and')), btn('.', () => press('.')));
const onKey = e => { if (/^[0-9]$/.test(e.key)) press(e.key); else if (e.key === '/' || e.key === '.') press(e.key); else if (e.key === ' ' && fracMode) { e.preventDefault(); press('and'); } else if (e.key === 'Backspace') press('โซ'); else if (e.key === 'Enter') press('OK'); };
window.addEventListener('keydown', onKey);
const mic = btn('๐ค Say it', async () => {
if (mic.classList.contains('live')) return stopListening();
stopAll(); mic.classList.add('live'); mic.textContent = '๐ค Listeningโฆ';
const n = await hearNumber();
mic.classList.remove('live'); mic.textContent = '๐ค Say it';
if (!micOK) { mic.style.display = 'none'; return toast('This device cannot listen. Type the answer.', 2000); }
if (n == null) return toast('I did not catch that. Say it again!', 1500);
val = String(n); show(); onOK(n);
}, 'micb');
if (!micOK) mic.style.display = 'none';
wrap.append(entry, pad, fracRow, mic);
return { el: wrap, entry, frac(on) { fracMode = !!on; fracRow.style.display = on ? '' : 'none'; mic.style.display = on || !micOK ? 'none' : ''; }, show(on) { wrap.style.display = on ? '' : 'none'; }, clear() { val = ''; show(); }, set(v, cls) { val = String(v); show(); if (cls) entry.classList.add(cls); }, bad() { entry.classList.remove('no'); void entry.offsetWidth; entry.classList.add('no'); }, stop() { stopListening(); window.removeEventListener('keydown', onKey); } };
}
// fractions compare by value: 5/2 = 2 1/2 = 2.5
function fracVal(s) {
s = String(s).trim(); let m;
if ((m = s.match(/^(\d+) (\d+)\/(\d+)$/))) return +m[1] + m[2] / m[3];
if ((m = s.match(/^(\d+)\/(\d+)$/))) return m[1] / m[2];
if ((m = s.match(/^(\d*)\.(\d+) (\d+)\/(\d+)$/))) return parseFloat((m[1] || '0') + '.' + m[2]) + m[3] / m[4] / Math.pow(10, m[2].length); // complex decimal
return /^\d*\.?\d+$/.test(s) ? parseFloat(s) : NaN;
}
// relative check, so tiny decimals (.00000002) and large ones (2900.000029) both compare exactly
const sameVal = (a, b) => { const x = fracVal(a), y = fracVal(b); return Math.abs(x - y) <= 1e-13 + 1e-10 * Math.abs(y); };
// counters tray: tap beads to count with
function initTray() {
const f = $('frame'); f.innerHTML = '';
for (let i = 0; i < 30; i++) f.appendChild(btn('', e => { e.currentTarget.classList.toggle('on'); count(); }));
const count = () => { $('trayN').textContent = f.querySelectorAll('.on').length; };
$('trayClear').onclick = () => { f.querySelectorAll('.on').forEach(b => b.classList.remove('on')); count(); };
$('trayClose').onclick = () => $('tray').classList.remove('on');
}
// ---------- home, map, stickers ----------
let addingReader = false;
function home() {
const k = $('kids'); k.innerHTML = '';
// names go in as text (never innerHTML); a name with no recorded greeting gets the line without a name
const rs = readers();
const signed = window.ABCAccount && window.ABCAccount.signedIn;
if (!rs.length && S.kid) { S.kid = null; save(); }
$('whoHint').textContent = rs.length ? '' : 'Grown-ups: add each learner (a first name or nickname).';
rs.forEach(n => { const b = btn('', () => { S.kid = n; save(); home(); const g = P.greet.replace('{kid}', n); speak(urlFor(g) ? g : P.hello); }, S.kid === n ? 'sel' : ''); b.textContent = n; k.appendChild(b); });
if (signed) { // a family account: names are added and removed on the account page
if (!rs.length) $('whoHint').innerHTML = 'Grown-ups: add your learners on the <a href="/account/">account page</a>.';
else { const a = el('a', 'parent'); a.href = '/account/'; a.textContent = 'Add or remove learners'; k.appendChild(a); }
} else if (addingReader || !rs.length) {
// the names live in 'abcbooks'.readers, shared with ABC Books, so a learner added here is in both
const inp = el('input'); inp.maxLength = 16; inp.placeholder = 'Name'; inp.autocomplete = 'off';
const add = () => {
const n = inp.value.trim().replace(/\s+/g, ' ');
if (!NAME_OK(n)) return toast('Letters only, please');
if (rs.some(x => x.toLowerCase() === n.toLowerCase())) return toast(`${n} is already here`);
let c = null; try { c = JSON.parse(localStorage.getItem('abcbooks')); } catch (e) {}
c = c || { kid: null, progress: {}, boards: {} };
c.readers = rs.concat(n); if (!c.kid) c.kid = n;
try { localStorage.setItem('abcbooks', JSON.stringify(c)); } catch (e) {}
S.kid = n; addingReader = false; save(); home();
};
inp.onkeydown = e => { if (e.key === 'Enter') add(); };
k.append(inp, btn('Add', add, 'big'));
if (rs.length) k.appendChild(btn('Cancel', () => { addingReader = false; home(); }, 'ghost parent'));
setTimeout(() => inp.focus(), 50);
} else if (rs.length < 8) k.appendChild(btn('๏ผ', () => { addingReader = true; home(); }, 'add'));
const sb = $('speedBtn'); sb.textContent = 'Voice: ' + (S.speed === 75 ? 'faster' : 'normal');
sb.onclick = () => { S.speed = S.speed === 75 ? 60 : 75; save(); home(); speak(P.yes); };
const sh = $('shelf'); sh.innerHTML = '';
// the kid's current book glows: the first open book they haven't finished
const doneIn = id => S.kid && BOOKS[id] ? Object.values(prog(id)).filter(x => x && x.stars).length : 0;
const cur = S.kid && SHELF.find(b => BOOKS[b.id] && doneIn(b.id) < BOOKS[b.id].lessons.length);
SHELF.forEach(b => {
const B = BOOKS[b.id], n = B ? B.lessons.length : 0, d = doneIn(b.id);
const pr = !B ? 'Coming soon' : S.kid ? (d >= n ? 'โ
Finished!' : d ? `Lesson ${d + 1} of ${n}` : `Lessons: ${n}`) : '';
const e = btn(`<b>${b.title}</b><span class="bp">${pr}</span><small>${b.sub}</small>`, () => {
if (!B) return toast('Coming soon!');
if (!S.kid) return toast('Pick your name first!');
openBook(b.id);
}, 'book' + (!B ? ' soon' : '') + (cur && cur.id === b.id ? ' cur' : ''));
e.style.background = b.color; sh.appendChild(e);
});
}
let book, bookId;
let skipArm = null; // a locked tile tapped once: a 2nd tap within 4 s skips ahead to it
function openBook(id) {
bookId = id; book = BOOKS[id]; $('mapTitle').textContent = `${S.kid}: ${book.title}`;
const t = $('tiles'); t.innerHTML = ''; const p = prog(id);
// Lessons are open up to one past the furthest one finished, so after a skip the kid carries on from there.
let far = -1; book.lessons.forEach((L, i) => { if ((p[L.n] || {}).stars) far = i; });
const paidAt = i => window.ABCAccount && window.ABCAccount.locked('math', i);
book.lessons.forEach((L, i) => {
const st = (p[L.n] || {}).stars || 0, open = i <= far + 1, isNext = i === far + 1;
// past the free lessons without a subscription (/account/sync.js; only when the paywall is switched on)
const paid = paidAt(i);
const tile = btn(`${L.n}<small>${L.part}</small><span class="st">${st ? starStr(st) : paid ? '๐' : ''}</span>`, () => {
if (paid) return window.ABCAccount.unlockScreen('math');
if (!open && !st && !(skipArm && skipArm.i === i && Date.now() - skipArm.at < 4000)) {
skipArm = { i, at: Date.now() };
return toast(`Lesson ${L.n} is ahead. Tap it again to skip to it.`, 3500);
}
skipArm = null; startLesson(L);
}, 'tile' + (isNext ? ' next' : '') + (!open && !st ? ' locked' : '') + (L.review ? ' rev' : ''));
tile.title = `Lesson ${L.n}: ${L.title}` + (st ? ', done' : !open ? ', ahead' : '');
tile.setAttribute('aria-label', tile.title); t.appendChild(tile);
});
// "Go to lesson": every lesson by title, grouped by part, starting on the next one; Go starts it.
const sel = $('jumpSel'); sel.innerHTML = ''; let grp = null;
book.lessons.forEach((L, i) => {
if (!grp || grp.label !== L.part) { grp = document.createElement('optgroup'); grp.label = L.part || ''; sel.appendChild(grp); }
const o = el('option'); o.textContent = `Lesson ${L.n}: ${L.title}` + ((p[L.n] || {}).stars ? ' โ
' : '');
o.value = i; if (i === Math.min(far + 1, book.lessons.length - 1)) o.selected = true; grp.appendChild(o);
});
$('jumpGo').onclick = () => { const i = +sel.value; paidAt(i) ? window.ABCAccount.unlockScreen('math') : startLesson(book.lessons[i]); };
go('map'); speak(P.map);
const nx = t.querySelector('.next'); if (nx) nx.scrollIntoView({ block: 'center' });
}
const stkKey = (b, n) => b === 'primary1877' ? n : `${b}:${n}`; // Book One keeps its first keys
function stickers() { // the sticker book for the book the kid is in (Book One when none is open yet)
const g = $('stkList'); g.innerHTML = ''; const got = kidMap(S.stickers), id = bookId || 'primary1877', B = BOOKS[id];
const n = B ? B.lessons.filter(L => got[stkKey(id, L.n)]).length : 0;
$('stkTitle').textContent = `${S.kid || ''} sticker book: ${B ? B.title : ''}, ${n} of ${B ? B.lessons.length : 0}`;
(B ? B.lessons : []).forEach(L => { const v = got[stkKey(id, L.n)]; const s = el('span', v ? 'got' : '', v || ''); s.title = 'Lesson ' + L.n; g.appendChild(s); });
}
$('toStickers').onclick = () => { if (!S.kid) return toast('Pick your name first!'); go('stickers'); };
// ---------- lesson flow ----------
let L, steps, stepIdx, scores, cleanup = null, sayAgain = null, stepTok = 0;
function stepsFor(L) {
switch (L.kind) {
case 'count': return ['count', 'find', 'trace'];
case 'numbers': return ['numbers', 'find', 'trace'];
case 'read': return ['say', 'find'];
case 'write': return ['write', 'trace'];
}
const s = [];
if (L.table) s.push('learn', 'drill');
if (L.rules) s.push('rules'); if (L.rules && L.rules.some(r => r.quiz !== false) && !L.table) s.push('drill');
if (L.qs) s.push('problems');
return s;
}
function startLesson(lesson) {
L = lesson; steps = stepsFor(L); scores = {}; stepIdx = 0;
$('lesTitle').textContent = `Lesson ${L.n}: ${L.title}`;
go('lesson'); runStep(true);
}
function leave() { stopStep(); openBook(bookId); }
function stopStep() { stopAll(); $('tray').classList.remove('on'); if (cleanup) { cleanup(); cleanup = null; } }
function runStep(first) {
stopStep();
const id = steps[stepIdx];
const dots = $('steps'); dots.innerHTML = '';
steps.forEach((x, i) => dots.appendChild(el('i', i < stepIdx ? 'done' : i === stepIdx ? 'cur' : '', STEPS[x].icon)));
if (!id) return finish();
$('stepTitle').textContent = STEPS[id].name; $('stage').innerHTML = ''; $('controls').innerHTML = ''; $('hint').textContent = P[id] || '';
sayAgain = null; const my = ++stepTok;
const intro = (first ? speak('lesson:' + L.n, P[id]) : speak(P[id]));
$('sayBtn').onclick = () => sayAgain ? sayAgain() : speak(P[id]);
({ count: stepCount, numbers: stepNumbers, find: stepFind, say: stepSay, write: stepWrite, trace: stepTrace, learn: stepLearn, rules: stepRules, drill: stepDrill, problems: stepProblems })[id](intro.then(() => my === stepTok));
}
function next(stars) { scores[steps[stepIdx]] = stars; stepIdx++; runStep(); }
const afterIntro = (intro, fn) => intro.then(ok => { if (ok) fn(); });
// Lesson 1: count the balls, one tap per ball
function stepCount(intro) {
let n = L.from;
const st = $('stage'); const col = el('div', 'col'); st.appendChild(col);
function show() {
col.innerHTML = ''; let c = 0;
const balls = el('div', 'balls'); const big = el('div', 'big-n', ' '); const w = el('div', 'word', ' ');
for (let i = 0; i < n; i++) balls.appendChild(btn('', e => {
const b = e.currentTarget; if (b.classList.contains('on')) return;
b.classList.add('on'); b.textContent = ++c; sayNum(c);
if (c === n) { big.textContent = n; w.textContent = words(n); setTimeout(() => { beep(true); speak('n:' + n).then(() => wait(700)).then(() => { if (++n > L.to) next(3); else show(); }); }, 650); }
}, 'ball'));
col.append(balls, big, w);
$('hint').textContent = `Tap each ball and count. (${n - L.from + 1} of ${L.to - L.from + 1})`;
}
sayAgain = () => speak(P.count);
show();
}
// Lessons 2-4: each number with its tens and ones
function stepNumbers(intro) {
let n = L.from; const st = $('stage'); const col = el('div', 'col'); st.appendChild(col);
let auto = false;
function show() {
col.innerHTML = '';
col.append(el('div', 'big-n', n), el('div', 'word', words(n)));
const rods = el('div', 'rods');
for (let t = 0; t < Math.floor(n / 10); t++) { const r = el('div', 'rod'); for (let i = 0; i < 10; i++) r.appendChild(el('i')); rods.appendChild(r); }
const ones = el('div', 'grp'); for (let i = 0; i < n % 10; i++) ones.appendChild(el('i', 'dot')); rods.appendChild(ones);
col.appendChild(rods);
col.appendChild(el('div', 'note', `${Math.floor(n / 10)} tens and ${n % 10} ones`));
$('hint').textContent = `${n - L.from + 1} of ${L.to - L.from + 1}`;
return sayNum(n);
}
async function playAll() { auto = true; playB.disabled = true; while (auto && n <= L.to) { await show(); await wait(700); if (!auto) break; if (n < L.to) n++; else break; } auto = false; playB.disabled = false; nextB.disabled = false; }
const prevB = btn('โ', () => { auto = false; if (n > L.from) { n--; show(); } });
const fwdB = btn('โถ', () => { auto = false; if (n < L.to) { n++; show(); } else nextB.disabled = false; });
const playB = btn('โถ Play all', playAll, 'big'), nextB = btn('Next step โถ', () => next(3));
nextB.disabled = true; $('controls').append(prevB, playB, fwdB, nextB);
sayAgain = show; cleanup = () => { auto = false; };
col.append(el('div', 'big-n', n), el('div', 'word', words(n)));
afterIntro(intro, playAll);
}
// hear a number, tap it
function stepFind(intro) {
const pool = L.list || range(L.from, L.to);
const rounds = shuffle(pool).slice(0, 8); let r = 0, miss = 0, busy = false, target;
const st = $('stage'); const col = el('div', 'col'); st.appendChild(col);
const grid = el('div', 'choices'); col.appendChild(grid);
function round() {
if (r >= rounds.length) { toast('Great job! โญ'); return setTimeout(() => next(starsFor(miss, rounds.length)), 900); }
target = rounds[r++]; grid.innerHTML = '';
const opts = shuffle([target, ...shuffle(pool.filter(x => x !== target)).slice(0, 3)]);
opts.forEach(v => grid.appendChild(btn(v, e => {
if (busy) return; const b = e.currentTarget;
if (v === target) { busy = true; beep(true); b.classList.add('ok'); setTimeout(() => { busy = false; round(); }, 800); }
else { miss++; beep(false); b.classList.remove('no'); void b.offsetWidth; b.classList.add('no'); }
})));
$('hint').textContent = `Find the number you hear (${r} of ${rounds.length})`;
sayAgain = () => speak('find:' + target); sayAgain();
}
afterIntro(intro, round);
}
// Lesson 5: numbers to be read, out loud to a grown-up
function stepSay(intro) {
const g = el('div', 'grid'); $('stage').appendChild(g);
L.list.forEach(v => g.appendChild(btn(v, e => { e.currentTarget.classList.add('seen'); sayNum(v); })));
$('controls').appendChild(btn('Done โ', () => next(3), 'big'));
}
// Lesson 6: numbers to be written: hear it, type it
function stepWrite(intro) {
const items = shuffle(L.list).slice(0, 12); let i = 0, miss = 0, tries = 0;
const col = el('div', 'col'); $('stage').appendChild(col);
const pad = numPad(v => {
if (v === items[i]) { beep(true); pad.set(v, 'ok'); tries = 0; setTimeout(() => { i++; ask(); }, 700); }
else if (++tries < 2) { miss++; beep(false); pad.bad(); speak(P.tryAgain, 'n:' + items[i]); }
else { miss++; pad.set(items[i], 'ok'); speak('ans:' + items[i]); tries = 0; setTimeout(() => { i++; ask(); }, 1800); }
});
col.appendChild(pad.el);
function ask() {
if (i >= items.length) return next(starsFor(miss, items.length));
pad.clear(); $('hint').textContent = `Write the number you hear (${i + 1} of ${items.length})`;
sayAgain = () => sayNum(items[i]); sayAgain();
}
cleanup = () => pad.stop();
afterIntro(intro, ask);
}
// trace the figures (same engine as ABC Books)
function stepTrace(intro) {
const nums = L.kind === 'count' ? ['1 2 3', '4 5 6', '7 8 9', '10'] : L.kind === 'write' ? ['0 1 2 3 4', '5 6 7 8 9', '10 20 30'] : (() => { const r = range(L.from, L.to), out = []; for (let i = 0; i < r.length && out.length < 4; i += 4) out.push(r.slice(i, i + 4).join(' ')); return out; })();
let i = 0, misses = 0, tracer;
const box = el('div', 'grow paper'); box.style.height = '100%'; $('stage').appendChild(box);
const cv = el('canvas'); box.appendChild(cv);
ABCTrace.Tracer.onMiss = why => { misses++; beep(false); $('hint').textContent = why === 'start' ? 'Start at the green dot!' : 'Follow the arrow all the way.'; };
function page() {
if (tracer) tracer.stop();
$('hint').textContent = `Page ${i + 1} of ${nums.length}: start at the green dot and follow the arrow.`;
tracer = ABCTrace.Tracer(cv, nums[i], 'print', () => { beep(true); toast('โญ', 700); setTimeout(() => { if (++i < nums.length) page(); else next(misses < 3 ? 3 : misses < 8 ? 2 : 1); }, 800); });
}
$('controls').appendChild(btn('Skip page', () => { if (++i < nums.length) page(); else next(1); }, 'ghost parent'));
setTimeout(page, 50);
const onRes = () => page(); window.addEventListener('resize', onRes);
cleanup = () => { tracer && tracer.stop(); window.removeEventListener('resize', onRes); };
}
// the book's table, one line at a time with its picture
function stepLearn(intro) { learnList(L.table.map(f => ({ t: f.t, pic: () => dotsFor(L.op, f.a, f.b), ex: `${expr(L.op, f)} = ${f.r}` })), intro); }
function stepRules(intro) { learnList(L.rules.map(r => ({ t: r.t, pic: () => el('div', 'pic', picFor(r.t)) })), intro); }
function learnList(items, intro) {
const col = el('div', 'col'); $('stage').appendChild(col);
const face = el('div', 'col'); face.style.height = 'auto'; const chips = el('div', 'chips'); col.append(face, chips);
let i = 0, auto = false; const seen = new Set();
const chipEls = items.map((it, k) => { const c = btn(it.t, () => { auto = false; i = k; show(); }, 'chip'); chips.appendChild(c); return c; });
function show() {
face.innerHTML = ''; const it = items[i];
const f = el('div', 'fact', it.t); if (it.ex) f.appendChild(el('span', 'sm', it.ex));
face.append(f, it.pic());
chipEls.forEach((c, k) => c.classList.toggle('cur', k === i)); chipEls[i].classList.add('seen'); seen.add(i);
chipEls[i].scrollIntoView({ block: 'nearest' });
if (seen.size === items.length) nextB.disabled = false;
return speak(it.t);
}
async function playAll() { auto = true; playB.disabled = true; for (i = 0; i < items.length && auto; i++) { await show(); await wait(600); } i = Math.min(i, items.length - 1); auto = false; playB.disabled = false; nextB.disabled = false; }
const playB = btn('โถ Read to me', playAll, 'big'), nextB = btn('Next โถ', () => next(3));
nextB.disabled = true; $('controls').append(playB, nextB);
sayAgain = show; cleanup = () => { auto = false; };
face.appendChild(el('div', 'fact', items[0].t));
$('hint').textContent = 'Tap โถ Read to me, or tap each line.';
afterIntro(intro, playAll);
}
// practice: every fact in the table (or every rule), timed; a missed one comes back later
function stepDrill(intro) {
const items = L.table ? L.table.map(f => ({ q: f.q, ex: expr(L.op, f) + ' = ?', a: f.r, say: f.t, pic: () => dotsFor(L.op, f.a, f.b) }))
: L.rules.filter(r => r.quiz !== false).map(r => ({ q: r.q, ex: `? ${r.a} = 1 ${r.b}`, a: r.n, say: r.t, pic: () => el('div', 'pic', picFor(r.t)) }));
let queue = shuffle(items), done = 0, miss = 0, tries = 0, streak = 0, t0 = 0, cur;
const key = `${bookId}:${L.n}`, best = kidMap(S.best);
const col = el('div', 'qa'); $('stage').appendChild(col);
const top = el('div', 'row'); const timer = el('div', 'timer'); const fire = el('div', 'timer'); top.append(timer, fire);
const ex = el('div', 'expr'); const qd = el('div', 'note'); const picBox = el('div'); picBox.style.minHeight = '0';
const pad = numPad(check);
col.append(top, ex, qd, picBox, pad.el);
const showB = btn('๐ Show me', () => { picBox.innerHTML = ''; picBox.appendChild(cur.pic()); }, 'ghost');
$('controls').appendChild(showB);
const tick = setInterval(() => { if (t0) timer.textContent = `โฑ ${Math.round((Date.now() - t0) / 1000)}s` + (best[key] ? ` (best ${best[key]}s)` : ''); }, 500);
function ask() {
if (!queue.length) {
const secs = Math.round((Date.now() - t0) / 1000);
let msg = `${secs} seconds!`;
if (!best[key] || secs < best[key]) { if (best[key]) msg += ' New best time! ๐'; best[key] = secs; save(); }
toast(msg, 1800); return setTimeout(() => next(starsFor(miss, items.length)), 1800);
}
cur = queue[0]; tries = 0; pad.clear(); picBox.innerHTML = '';
ex.textContent = cur.ex; ex.className = 'expr' + (cur.ex.length > 12 ? ' long' : ''); qd.textContent = cur.q;
$('hint').textContent = `${done + 1} of ${items.length}`;
sayAgain = () => speak(cur.q); sayAgain();
if (!t0) t0 = Date.now();
}
function check(v) {
if (v === cur.a) {
beep(true); pad.set(v, 'ok'); streak++; fire.textContent = streak >= 3 ? `๐ฅ ${streak}` : '';
queue.shift(); done++; setTimeout(ask, 600);
} else if (++tries < 2) { beep(false); pad.bad(); miss++; streak = 0; fire.textContent = ''; speak(P.tryAgain); }
else { // show it, say the book's line, and ask it again later
pad.set(cur.a, 'ok'); picBox.innerHTML = ''; picBox.appendChild(cur.pic());
const it = queue.shift(); queue.splice(Math.min(3, queue.length), 0, it);
speak(cur.say).then(() => wait(600)).then(ask);
}
}
cleanup = () => { clearInterval(tick); pad.stop(); };
afterIntro(intro, ask);
}
// story problems: the book's questions, one answer at a time; two misses shows the answer and the book's solution
function stepProblems(intro) {
const qs = L.qs, posKey = `${bookId}:${L.n}`, pos = kidMap(S.pos);
let qi = pos[posKey] || 0, pi = 0, tries = 0, miss = 0, si = 0, busy = false;
if (qi >= qs.length) qi = 0;
const col = el('div', 'qa'); $('stage').appendChild(col);
const pic = el('div', 'pic'); const qd = el('div', 'q'); const seq = el('div', 'seq'); const sol = el('div', 'sol');
const pad = numPad(check);
col.append(pic, qd, seq, sol, pad.el);
initTray();
$('controls').append(btn('๐งฎ Counters', () => $('tray').classList.toggle('on')), btn('Finish later', () => { pos[posKey] = qi; save(); leave(); }, 'ghost parent'));
if (L.note) $('hint').textContent = L.note;
const Q = () => qs[qi], Pt = () => Q().parts[pi];
function render() {
const q = Q(); qd.innerHTML = ''; qd.classList.toggle('has-stack', q.parts.some(p => p.col));
q.parts.forEach((p, k) => {
qd.appendChild(el('span', 'pt' + (k === pi ? ' cur' : k < pi ? '' : ''), p.t + ' '));
if (p.col) { // a sum written in a column, the book's way: numbers right-aligned, the sign on the last line, a rule under
const st = el('div', 'stack'), sign = { '+': '+', '-': 'โ', x: 'ร' }[p.col.op];
p.col.n.forEach((v, j) => st.appendChild(el('div', '', (j === p.col.n.length - 1 ? `<b>${sign}</b>` : '') + v)));
if (k < pi) st.appendChild(el('div', 'sum', String(p.f || p.a))); qd.appendChild(st);
}
if (k < pi) qd.appendChild(el('span', 'got' + (p.missed ? ' miss' : ''), p.seq || p.open ? 'โ' : (p.f || p.a)));
if (k < q.parts.length - 1) qd.append(' ');
});
pic.textContent = picFor(q.parts[0].t);
seq.innerHTML = '';
const p = Pt(); if (p && p.seq) p.seq.forEach((v, k) => seq.appendChild(el('span', k < si ? 'got' : k === si ? 'cur' : '', k < si ? v : '?')));
const cur = seq.querySelector('.cur'); if (cur) cur.scrollIntoView({ block: 'nearest' });
}
function ask(sayIt) {
if (qi >= qs.length) { pos[posKey] = 0; save(); return next(starsFor(miss, qs.reduce((n, q) => n + q.parts.length, 0))); }
tries = 0; pad.clear(); sol.textContent = ''; if (pi === 0) Q().shown = false;
const p = Pt(); if (p.seq && si === 0) si = 1; // the first number of a count is given
pad.frac(p.f); pad.show(!p.open); openB.style.display = p.open ? '' : 'none';
render();
$('hint').textContent = `Question ${qi + 1} of ${qs.length}`;
sayAgain = () => speak(p.s || p.t, p.open ? 'Answer out loud, then tap Next.' : null);
if (sayIt !== false) sayAgain();
}
function advance() {
busy = false; const q = Q();
if (++pi < q.parts.length) { si = 0; return ask(); }
if (q.sol && !q.shown) { q.shown = true; sol.textContent = q.sol; }
qi++; pi = 0; si = 0; pos[posKey] = qi; save();
setTimeout(() => ask(), q.sol && sol.textContent ? 600 : 0);
}
function check(v) {
if (busy) return; const p = Pt();
const want = p.seq ? p.seq[si] : p.f || p.a;
const norm = s => String(s).trim().replace(/\s+/g, ' ');
if (p.ex ? p.ex.includes(norm(v)) : p.f ? sameVal(v, p.f) || (p.falt || []).some(x => sameVal(v, x)) : v === want || (!p.seq && p.alt && p.alt.includes(v))) {
beep(true); pad.set(v, 'ok'); tries = 0;
if (p.seq) { if (++si >= p.seq.length) { busy = true; render(); return setTimeout(() => { si = 0; advance(); }, 700); } setTimeout(() => { pad.clear(); render(); }, 350); return; }
busy = true; speak(P.yes); setTimeout(advance, 900); return;
}
miss++;
if (++tries < 2) { beep(false); pad.bad(); speak(P.tryAgain); return; }
// second miss: show the answer, say it, and the book's solution when there is one
busy = true; pad.set(want, 'ok'); tries = 0;
const q = Q(), last = !p.seq && pi === q.parts.length - 1;
if (last && q.sol) { sol.textContent = q.sol; q.shown = true; }
if (p.seq) { si++; if (si >= p.seq.length) { si = 0; p.missed = true; return speak('ans:' + want).then(() => advance()); } return speak('n:' + want).then(() => { busy = false; pad.clear(); render(); }); }
p.missed = true;
speak('ans:' + want, last && q.sol ? q.sol : null).then(() => wait(800)).then(advance);
}
// a question answered out loud to a grown-up: no typing, just Next. When the book gives the answer (sol:), the first
// Next shows and says it (e.g. "57 is written LVII."), the second moves on.
const openB = btn('Next โถ', () => {
if (busy) return; const q = Q();
if (pi === q.parts.length - 1 && q.sol && !q.shown) { q.shown = true; sol.textContent = q.sol; speak(q.sol); return; }
busy = true; beep(true); setTimeout(advance, 300);
}, 'big'); openB.style.display = 'none';
col.appendChild(openB);
cleanup = () => pad.stop();
afterIntro(intro, () => ask());
}
function finish() {
const vals = Object.values(scores), stars = Math.max(1, Math.round(vals.reduce((a, b) => a + b, 0) / Math.max(1, vals.length)));
const p = prog(bookId), old = (p[L.n] || {}).stars || 0;
p[L.n] = { stars: Math.max(old, stars), at: Date.now() };
const got = kidMap(S.stickers), sk = stkKey(bookId, L.n); const sticker = got[sk] || STICKERS[(L.n * 7 + SHELF.findIndex(b => b.id === bookId) * 3) % STICKERS.length]; got[sk] = sticker;
save();
$('stepTitle').textContent = `${S.kid}, you finished Lesson ${L.n}!`;
$('stage').innerHTML = `<div class="col"><div class="stars">${starStr(stars)}</div><div class="stk">${sticker}</div><div class="note">A new sticker for your sticker book!</div></div>`;
$('hint').textContent = ''; $('controls').innerHTML = '';
$('controls').append(btn('Lessons', () => openBook(bookId), 'big'), btn('Sticker book', () => go('stickers')));
const gj = `Great job, ${S.kid}!`; beep(true); speak(urlFor(gj) ? gj : P.greatJob, P.done);
}
Promise.all([
fetch('prompts.json').then(r => r.json()).then(j => { P = j; }),
...SHELF.filter(b => !b.soon).map(b => fetch(`books/${b.id}.json`).then(r => r.json()).then(j => { BOOKS[b.id] = j; })),
...SHELF.filter(b => !b.soon).map(b => fetch(`audio/${b.id}.json`).then(r => r.ok ? r.json() : null).then(j => {
if (!j) return; if (!AUDIO) AUDIO = { speeds: j.speeds, items: {} }; Object.assign(AUDIO.items, j.items);
}).catch(() => {}))
]).then(home, () => toast('Could not load the book. Check the internet.', 4000));
// the account answer can arrive after the first draw: redraw the home screen
if (window.ABCAccount) window.ABCAccount.ready.then(() => { if ($('home').classList.contains('on') && Object.keys(BOOKS).length) home(); });
return { go, leave, debug: () => ({ S, L, steps, stepIdx, scores }), wordsToNumber };
})();
EOF
RaysArithmetic/public/index.html
cat > /root/lesson-jump/index.html <<'EOF'
<!doctype html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<meta name="theme-color" content="#121212">
<title>Ray's Arithmetic</title>
<link rel="icon" type="image/png" sizes="192x192" href="icon-192.png">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="icon-192.png">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Ray's Math">
<style>
:root{color-scheme:dark;--paper:#121212;--ink:#ececec;--soft:#a8a8a8;--accent:#2f8fbf;--warm:#d9733f;--good:#3fae6e;--bad:#ff6b5e;--board:#1d3527;--card:#1e1e1e;--line:#6a6a6a;--hi:#1f3340;--a:#f0a03c;--b:#5ab4f0}
*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{margin:0;height:100%;background:var(--paper);color:var(--ink);font-family:Georgia,"Times New Roman",serif;overflow:hidden;user-select:none;-webkit-user-select:none;-webkit-text-size-adjust:100%;text-size-adjust:100%}
.screen{position:absolute;inset:0;display:none;flex-direction:column;padding:10px 16px}
.screen.on{display:flex}
h1{margin:4px 0 10px;font-size:clamp(26px,6vmin,44px);text-align:center;letter-spacing:.04em}
h2{margin:4px 0;font-size:clamp(19px,4.2vmin,30px);text-align:center}
button{font:inherit;font-size:clamp(18px,4vmin,26px);border:3px solid var(--line);background:var(--card);color:var(--ink);border-radius:14px;padding:8px 16px;min-height:50px;cursor:pointer}
button:active{transform:scale(.96)}
button.big{background:var(--accent);color:#fff;border-color:#164a63;font-size:clamp(21px,4.6vmin,30px);padding:12px 26px}
button.ghost{border-color:transparent;background:transparent}
button:disabled{opacity:.35}
.row{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;align-items:center}
.grow{flex:1;min-height:0;position:relative}
.kids button{min-width:30vmin;font-size:clamp(24px,6vmin,40px)}
.kids button.sel{background:var(--accent);border-color:var(--accent);color:#fff}
.kids button.add,.kids button.ghost,.kids button.big{min-width:0}
.kids input{font:inherit;font-size:clamp(22px,5vmin,34px);width:min(60vw,320px);padding:8px 14px;border-radius:14px;border:3px solid var(--line);background:var(--card);color:var(--ink)}
#home{overflow-y:auto}
.shelf{display:flex;gap:3vmin;justify-content:center;flex-wrap:wrap;margin-top:3vmin;padding:8px}
.book{width:min(34vmin,210px);aspect-ratio:3/4;border-radius:6px 14px 14px 6px;color:#fff;padding:14px;display:flex;flex-direction:column;justify-content:space-between;box-shadow:inset 10px 0 0 rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.25);border:none;text-align:left}
.book b{font-size:clamp(18px,4vmin,26px)}.book small{font-size:clamp(13px,2.6vmin,17px);opacity:.9}
.book .bp{font-size:clamp(14px,3vmin,19px);font-weight:bold;background:rgba(0,0,0,.35);border-radius:8px;padding:4px 8px;align-self:flex-start}
.book.soon{filter:grayscale(.6);opacity:.6}
.book.cur{outline:5px solid #ffd166;outline-offset:3px;animation:pulse 1.6s infinite}
.map{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(22vmin,120px),1fr));grid-auto-rows:min(24vmin,120px);gap:12px;overflow-y:auto;padding:6px;align-content:start}
.tile{height:100%;min-height:0;border-radius:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;font-size:clamp(22px,6vmin,38px);padding:4px;line-height:1.1}
.tile small{font-size:clamp(11px,2.4vmin,15px);color:var(--soft)}
.tile .st{font-size:clamp(14px,3vmin,20px);color:#e0a000;letter-spacing:2px}
.tile.next{background:var(--hi);border-color:var(--accent);animation:pulse 1.4s infinite}
.tile.locked{opacity:.35}
.jump{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:6px 0;font-size:clamp(16px,3.4vmin,22px)}
.jump select{flex:1;min-width:0;max-width:100%;font:inherit;padding:8px;background:var(--card);color:var(--ink);border:2px solid var(--line);border-radius:12px}
.tile.rev{border-style:dashed}
@keyframes pulse{50%{transform:scale(1.05)}}
.top{display:flex;align-items:center;gap:8px}
.top .t{flex:1;text-align:center;font-size:clamp(17px,3.8vmin,26px)}
.steps{display:flex;gap:6px;justify-content:center;margin:4px 0}
.steps i{width:clamp(18px,4vmin,28px);height:clamp(18px,4vmin,28px);border-radius:50%;border:3px solid var(--soft);font-style:normal;font-size:12px;display:flex;align-items:center;justify-content:center}
.steps i.done{background:var(--good);border-color:var(--good)}.steps i.cur{border-color:var(--accent);background:var(--hi)}
.hint{font-size:clamp(15px,3.2vmin,20px);color:var(--soft);text-align:center;min-height:1.4em}
.toast{position:fixed;left:50%;top:40%;transform:translate(-50%,-50%);background:#2a2a2a;border:3px solid var(--accent);color:#fff;padding:18px 28px;border-radius:18px;font-size:clamp(24px,6vmin,40px);display:none;z-index:9;text-align:center}
.stars{font-size:clamp(40px,12vmin,90px);color:#e0a000;text-align:center;letter-spacing:6px}
.parent{font-size:15px;color:var(--soft)}
.col{display:flex;flex-direction:column;align-items:center;gap:1.2vmin;height:100%;overflow-y:auto;justify-content:center}
.big-n{font-size:clamp(70px,22vmin,170px);line-height:1;font-weight:bold}
.word{font-size:clamp(22px,5vmin,36px);color:var(--soft)}
.fact{font-size:clamp(30px,8vmin,64px);text-align:center;line-height:1.15}
.fact .sm{display:block;font-size:clamp(16px,3.4vmin,22px);color:var(--soft)}
.dots{display:flex;flex-wrap:wrap;gap:1.4vmin;justify-content:center;align-items:center;max-width:94vw}
.grp{display:grid;grid-template-columns:repeat(5,auto);gap:.8vmin;padding:1vmin;border-radius:10px;border:2px dashed transparent}
.grp.box{border-color:var(--line)}
.dot{width:clamp(14px,3.6vmin,26px);height:clamp(14px,3.6vmin,26px);border-radius:50%;background:var(--a)}
.dot.b{background:var(--b)}.dot.x{background:#444;position:relative}.dot.x::after{content:'';position:absolute;left:-2px;right:-2px;top:45%;height:3px;background:var(--bad);transform:rotate(-35deg)}
.op{font-size:clamp(26px,6vmin,44px);color:var(--soft)}
.chips{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;max-height:24vh;overflow-y:auto}
.chip{font-size:clamp(14px,3vmin,19px);min-height:38px;padding:4px 10px;border-radius:10px}
.chip.cur{background:var(--hi);border-color:var(--accent)}.chip.seen{border-color:var(--good)}
.balls{display:flex;flex-wrap:wrap;gap:2vmin;justify-content:center;max-width:90vw}
.ball{width:clamp(44px,11vmin,80px);height:clamp(44px,11vmin,80px);border-radius:50%;border:none;background:radial-gradient(circle at 35% 30%,#ffd9a8,var(--warm) 60%,#7a3412);padding:0;min-height:0;font-size:clamp(18px,4vmin,28px);color:#fff;font-weight:bold}
.ball.on{background:radial-gradient(circle at 35% 30%,#b8f0c8,var(--good) 60%,#1b5a35)}
.rods{display:flex;gap:1vmin;align-items:flex-end;justify-content:center;flex-wrap:wrap}
.rod{display:grid;grid-template-rows:repeat(10,1fr);gap:2px;height:clamp(110px,26vmin,190px);width:clamp(14px,3.4vmin,22px)}
.rod i{background:var(--b);border-radius:3px}
.choices{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2vmin;width:min(90vw,520px)}
.choices button{font-size:clamp(40px,12vmin,86px);min-height:clamp(80px,18vmin,140px)}
.choices button.ok{background:var(--good);border-color:var(--good);color:#fff}.choices button.no{animation:shake .4s}
@keyframes shake{25%{transform:translateX(-8px)}75%{transform:translateX(8px)}}
.grid{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;overflow-y:auto;max-height:100%;align-content:flex-start;padding:4px}
.grid button{min-width:clamp(56px,14vmin,90px);font-size:clamp(24px,6vmin,40px)}
.grid button.seen{border-color:var(--good)}
/* question + number pad */
.qa{display:flex;flex-direction:column;align-items:center;gap:1vmin;height:100%;min-height:0}
.q{font-size:clamp(20px,4.6vmin,34px);line-height:1.35;text-align:center;overflow-y:auto;max-height:42%;padding:4px 10px;border-radius:12px}
.q .pt{opacity:.55}.q .pt.cur{opacity:1;background:var(--hi);border-radius:8px;padding:0 4px}
.q .got{display:inline-block;background:var(--good);color:#fff;border-radius:8px;padding:0 8px;margin:0 4px;font-weight:bold}
.q .miss{background:var(--warm)}
.q.has-stack{max-height:60%}.q.has-stack .pt{font-size:.85em}
.stack{display:table;margin:.2em auto;font-family:'Courier New',monospace;font-weight:bold;text-align:right;line-height:1.05;font-size:1.05em;border-bottom:3px solid currentColor;padding:0 .3em}
.stack b{float:left;margin-right:.6em}.stack .sum{color:var(--good);border-top:3px solid currentColor;margin-bottom:-3px}
.pic{font-size:clamp(30px,8vmin,56px);line-height:1}
.expr{font-size:clamp(46px,13vmin,100px);font-weight:bold;line-height:1.05;text-align:center}
.micb{min-width:min(86vw,360px);font-size:clamp(20px,4.6vmin,30px)}.micb.live{background:var(--bad);border-color:var(--bad);color:#fff;animation:pulse 1s infinite}
.expr.long{font-size:clamp(28px,7vmin,52px)}
.entry{min-width:3.4em;min-height:1.3em;font-size:clamp(36px,9vmin,64px);text-align:center;border-bottom:4px solid var(--accent);font-weight:bold;letter-spacing:.05em}
.entry.long{font-size:clamp(24px,6vmin,44px)}.entry.no{animation:shake .4s;border-color:var(--bad)}.entry.ok{color:var(--good)}
.pad{display:grid;grid-template-columns:repeat(3,1fr);gap:1.2vmin;width:min(86vw,360px)}
.pad button{font-size:clamp(24px,6vmin,38px);min-height:clamp(46px,9.5vmin,70px);padding:0}
.pad button.okb{background:var(--good);border-color:var(--good);color:#fff}
.seq{display:flex;flex-wrap:wrap;gap:6px;justify-content:center;max-height:22vh;overflow-y:auto}
.seq span{min-width:2.2em;text-align:center;border:2px solid var(--line);border-radius:8px;padding:2px 6px;font-size:clamp(18px,4vmin,28px)}
.seq span.got{border-color:var(--good);color:var(--good)}.seq span.cur{border-color:var(--accent);background:var(--hi)}
.sol{font-size:clamp(16px,3.4vmin,22px);color:#ffd166;text-align:center;max-width:90vw}
.tray{position:fixed;left:50%;bottom:2vh;transform:translateX(-50%);background:#1a1a1a;border:3px solid var(--accent);border-radius:16px;padding:10px;z-index:20;display:none;flex-direction:column;align-items:center;gap:8px}
.tray.on{display:flex}
.frame{display:grid;grid-template-columns:repeat(10,1fr);gap:5px}
.frame button{width:clamp(26px,6.5vmin,44px);height:clamp(26px,6.5vmin,44px);min-height:0;border-radius:50%;padding:0;border-width:2px}
.frame button.on{background:var(--a);border-color:var(--a)}
.timer{font-size:clamp(16px,3.4vmin,22px);color:var(--soft)}
.stk{font-size:clamp(40px,10vmin,72px)}
.stickers{display:flex;flex-wrap:wrap;gap:10px;justify-content:center;overflow-y:auto;font-size:clamp(34px,9vmin,60px)}
.stickers span{width:1.5em;height:1.5em;display:flex;align-items:center;justify-content:center;border:2px dashed var(--line);border-radius:12px}
.stickers span.got{border:2px solid #e0a000;background:#2a2410}
canvas{position:absolute;inset:0;width:100%;height:100%;touch-action:none;border-radius:14px}
.paper{background:#1a1a1a;border:3px solid var(--line);border-radius:14px}
.note{font-size:clamp(13px,2.8vmin,17px);color:var(--soft);text-align:center;max-width:90vw}
</style>
</head><body>
<section id="home" class="screen on">
<h1>Ray's Arithmetic</h1>
<h2>Who is doing math today?</h2>
<div class="row kids" id="kids"></div>
<div class="hint" id="whoHint"></div>
<div class="shelf" id="shelf"></div>
<div class="row" style="margin-top:auto"><button class="ghost parent" id="speedBtn">Voice: normal</button><button class="ghost parent" id="toStickers">Sticker book</button><a class="parent" href="/account/">Grown-ups: account</a><a class="parent" href="/readers/">Readers</a><a class="parent" href="/">About</a></div>
</section>
<section id="map" class="screen">
<div class="top"><button onclick="Rays.go('home')">โ</button><div class="t" id="mapTitle"></div></div>
<div class="jump"><label for="jumpSel">Go to lesson</label><select id="jumpSel"></select><button id="jumpGo">Go</button></div>
<div class="map grow" id="tiles"></div>
</section>
<section id="lesson" class="screen">
<div class="top"><button onclick="Rays.leave()">โ</button><div class="t" id="lesTitle"></div><button id="sayBtn" title="Say it again">๐</button></div>
<div class="steps" id="steps"></div>
<h2 id="stepTitle"></h2>
<div class="grow" id="stage"></div>
<div class="hint" id="hint"></div>
<div class="row" id="controls"></div>
</section>
<section id="stickers" class="screen">
<div class="top"><button onclick="Rays.go('home')">โ</button><div class="t" id="stkTitle">Sticker book</div></div>
<div class="stickers grow" id="stkList"></div>
</section>
<div class="tray" id="tray"><div class="frame" id="frame"></div><div class="row"><b id="trayN">0</b><button id="trayClear">Clear</button><button id="trayClose">Close</button></div></div>
<div class="toast" id="toast"></div>
<script src="hershey.js"></script>
<script src="trace.js"></script>
<script src="/account/sync.js"></script>
<script src="app.js"></script>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});</script>
</body></html>
EOF
RaysArithmetic/public/sw.js
cat > /root/lesson-jump/sw.js <<'EOF'
// Offline support. App files: network first (so updates show), cached copy when offline.
// Voice clips (audio/*.mp3) never change for a given name: cache first, saved the first time they play.
const SHELL = 'rays-shell-v11', CLIPS = 'rays-clips-v1';
const FILES = ['./', 'index.html', 'app.js', 'prompts.json', 'trace.js', 'hershey.js', 'books/primary1877.json', 'audio/primary1877.json', 'books/intellectual.json', 'audio/intellectual.json', 'books/practical.json', 'audio/practical.json', 'books/higher.json', 'audio/higher.json', 'icon-192.png', 'manifest.webmanifest'];
self.addEventListener('install', e => { e.waitUntil(caches.open(SHELL).then(c => Promise.all(FILES.map(f => c.add(f).catch(() => {})))).then(() => self.skipWaiting())); });
self.addEventListener('activate', e => {
e.waitUntil(caches.keys().then(ks => Promise.all(ks.filter(k => k.startsWith('rays-') && k !== SHELL && k !== CLIPS).map(k => caches.delete(k)))).then(() => self.clients.claim()));
});
self.addEventListener('fetch', e => {
const r = e.request; if (r.method !== 'GET' || new URL(r.url).origin !== location.origin) return;
if (r.url.endsWith('.mp3')) {
e.respondWith(caches.open(CLIPS).then(c => c.match(r).then(hit => hit || fetch(r).then(res => { if (res.ok && res.status === 200) c.put(r, res.clone()); return res; }))));
return;
}
e.respondWith(fetch(r).then(res => { if (res.ok) caches.open(SHELL).then(c => c.put(r, res.clone())); return res; }).catch(() => caches.match(r)));
});
EOF
Test on the PC first
The local test servers take a free port when the usual one is busy (PORT), so two copies can run at once. PowerShell on the PC, from the folder that holds ABCBooks:
powershell -NoProfile -ExecutionPolicy Bypass -File ABCBooks\serve-local.ps1
Open http://localhost:8128/, add a reader, open the Second Reader and check:
- The list at the top starts on "Lesson 1: Evening At Home".
- Tapping lesson 5 once shows "Lesson 5 is ahead. Tap it again to skip to it."; tapping again opens it.
- Back on the map, pick "Lesson 40" in the list and press Go: lesson 40 opens.
Voice clips and the account script show as 404 errors in a local copy; that's normal, they live on the server. Do the same for Ray's with RaysArithmetic\serve-local.ps1 (http://localhost:8129/).
Deploy
PowerShell on the PC, one at a time:
powershell -ExecutionPolicy Bypass -File ABCBooks\deploy.ps1
powershell -ExecutionPolicy Bypass -File RaysArithmetic\deploy.ps1
The first copies the readers to CT 306 (abc-web) and ends with a file listing; the second ends with RAYS-DONE. Neither one touches the recorded audio or the other app's folder.
Check it's live
PowerShell on the PC. It should print True and the new cache version for each app:
foreach ($u in "https://abcbooks.fyi/readers/sw.js","https://abcbooks.fyi/math/sw.js") { ((Invoke-WebRequest -UseBasicParsing $u).Content -match '(abc|rays)-shell-v\d+'); $Matches[0] }
On a tablet that already has the app open, close it and open it again (twice, if the list doesn't show the first time): the service worker swaps in the new files on the next visit.
Troubleshooting: where the logs are
The web server
- Where: Caddy's journal inside CT 306 (abc-web).
- Open: Proxmox host shell.
pct exec 306 -- journalctl -u caddy -n 50 --no-pager
- Look for: a reload or start line. This feature has no server part; if the list is missing, it's almost always an old cached copy on the device.
The page itself
- Where: the browser's console on the PC (F12 โ Console) with the app open.
- Look for: red errors that mention
app.js. 404s for.mp3files oraccount/sync.jsonly happen on a local copy.
No Uptime Kuma monitors were added for this.