The Ray's Arithmetic app at https://abcbooks.fyi/math/ now lets the kids show their work, the way they would on a slate. Every number question in all five books has a Show work button that opens a sheet of squared scratch paper beside the question. The kid writes on it with a finger or a stylus while solving, and each page is saved with its question, for each reader and each book. Later a grown-up taps See the work to look over every page. This post covers what is new and how to rebuild it. The app, the server and the voice are explained in the Book One post, and Book 5 in the Geometry post. Every block has a Copy button.
What the kids see
- In Story problems and Practice (the timed table drill), the bottom row has a new button, âœï¸ Show work. Tapping it opens the scratch paper. Tapping it again (it now says Put paper away) closes it.
- With the tablet held sideways, the paper sits to the right of the question and number pad. Held upright, it sits under the number pad. It never covers the question or the pad, so the kid can work and answer without closing it. While it is open the question text and number pad shrink a little to make room.
- Four big buttons under the paper: Pencil to write, Eraser (drag over a line to rub the whole line out), Undo (takes back the last line; right after Clear it brings the whole page back), and Clear.
- There is one page per story problem (all its parts share the page) and one page per Practice fact. When a question comes back (a missed fact, or "Finish later" and coming back the next day), its old work comes back with it.
- If the kid leaves the paper open, it opens again by itself on the next question and the next lesson.
- With a stylus, a hand resting on the screen is ignored while the pen is writing.
What the grown-ups see
The lesson map and the sticker book have a âœï¸ See the work button. It shows the work of the reader who is picked, in the book that is open:
- Pages are grouped by lesson, the most recent lesson first, and in question order inside a lesson.
- Each page shows the drawing, the question, the last answer the kid gave, a check mark if it was right the first time or "(missed a try)" if there was a wrong answer first, and the date and time.
- Tap a page to see it large. From there, Delete this page removes it. Delete all work in this book at the bottom clears the whole book for that reader.
How it is stored
- The drawings are kept on the tablet in the browser's storage under the name
rays-work, apart from the progress (rays). The progress is copied to the family account as one big blob, and drawings are too big for that. - With a family account and the math plan (added later on 18OCT2026), each page is also sent to the account by itself, a few seconds after the kid stops writing, at
/account/api/workon CT 308 (abc-account). Deleting a page sends the delete too. Pages waiting to go up are kept in a small list (rays-work-up), so work done offline goes up later. The first time a family signs in with the plan, the pages already on that tablet go up once. See the work in the app then also shows pages from the family's other devices, and parents see every page on the account page (https://abcbooks.fyi/account/ , Math work). The server side is in the family accounts post. Without the plan, nothing leaves the tablet. - A page is stored as its pen lines, not as a picture: each line is a list of points measured from 0 to 1000 across the page, and points closer than a few units are skipped. A typical page is 1 to 3 KB.
- All pages together are capped at about 1.2 million characters (roughly 400 to 1000 pages). Past that, the oldest pages of any reader are dropped first, so it can never fill the browser's storage. A single page stops taking ink after about 6000 points and says "This page is full. Tap Clear."
Things found on the way:
- A hidden canvas has no size. The paper is sized when it opens. If the screen isn't drawn yet, the browser skips the usual "next frame" call, and the first line drawn crashed. It now sizes itself again on the first touch.
- Portrait is tight. On an upright 10-inch tablet the question, the answer box, the 12-key pad, the "Say it" button and the paper didn't all fit; the "Say it" button was cut off. While the paper is open the answer box, the big sum in Practice and the "Say it" button get smaller, the question text scrolls inside a shorter box, and the paper takes 35% of the height.
- Global canvas styles. The app sets every canvas to fill its box (for the tracing step), which squashed the small page pictures on the See the work screen; those get their own style.
- Script order matters.
scratch.jswaits for the account check from/account/sync.js, sosync.jshas to load first inindex.html; the other way round, nothing was ever sent. - "Upload once" has to notice a new family. The flag that says "this tablet's pages already went up" is the family's reader names, and it is cleared while there is no plan, so a plan that lapses and comes back, or another family on the same tablet, uploads the old pages again.
- The offline cache serves the old app. After changing
app.js, bumprays-shell-v18insw.js, or the old version keeps showing.
Files
Set up the Book One post (and the book posts) first. This adds scratch.js and replaces three files. Each block writes one file into /root/rays-rebuild/ when pasted into a Proxmox host shell (Proxmox web UI, pve, Shell). Then copy that folder to the PC and put all four in C:\RaysArithmetic\public\.
Proxmox host shell:
mkdir -p /root/rays-rebuild/public
scratch.js (new): the scratch paper, its storage, and the page pictures for the grown-ups.
cat > /root/rays-rebuild/scratch.js <<'EOF'
// "Show your work" scratch paper for number questions. The kid writes with a finger or stylus; each page is kept with
// its question, per kid and per book, so a grown-up can look it over later (See the work on the lesson map).
// Pages live in their own localStorage key 'rays-work' (device only: 'rays' is synced to the family account, and
// drawings are too big for that). Strokes are stored as points, 0-1000 across the page width, and the oldest pages
// are dropped once the key passes LIMIT characters.
const RaysWork = (() => {
const KEY = 'rays-work', LIMIT = 1200000, MAXPTS = 12000;
let W = null;
function data() {
if (W) return W;
try { W = JSON.parse(localStorage.getItem(KEY)); } catch (e) {}
W = W && typeof W === 'object' ? W : {};
W.pages = W.pages || {}; W.open = W.open || {};
return W;
}
// pages[kid][book][key] = { q, l: lesson, t: when, h: page height / width, s: [[x,y,x,y...],...], a: answer, r: 'ok'|'miss' }
const kidPages = (kid, book) => { const p = data().pages; p[kid] = p[kid] || {}; return (p[kid][book] = p[kid][book] || {}); };
function store() {
const w = data(); let txt = JSON.stringify(w);
// drop the oldest pages (any kid, any book) until it fits
if (txt.length > LIMIT) {
const all = [];
for (const k in w.pages) for (const b in w.pages[k]) for (const q in w.pages[k][b]) all.push([w.pages[k][b][q].t || 0, k, b, q]);
all.sort((a, b) => a[0] - b[0]);
while (txt.length > LIMIT * 0.85 && all.length > 1) {
for (let i = 0; i < 20 && all.length > 1; i++) { const [, k, b, q] = all.shift(); delete w.pages[k][b][q]; }
txt = JSON.stringify(w);
}
}
try { localStorage.setItem(KEY, txt); } catch (e) { // the browser is full: keep only the newer half and try again
const all = [];
for (const k in w.pages) for (const b in w.pages[k]) for (const q in w.pages[k][b]) all.push([w.pages[k][b][q].t || 0, k, b, q]);
all.sort((a, b) => a[0] - b[0]).slice(0, Math.ceil(all.length / 2)).forEach(([, k, b, q]) => delete w.pages[k][b][q]);
try { localStorage.setItem(KEY, JSON.stringify(w)); } catch (e2) {}
}
}
// ---------- the family account: with the math plan, each saved page also goes up to the account (one page per
// row, /account/api/work), so a grown-up can see the work from any device on the account page. Deletes go up too.
// A queue of page ids ('rays-work-up') survives going offline; pages pruned here for space are NOT deleted there.
const UPQ = 'rays-work-up', ACC = () => window.ABCAccount && ABCAccount.signedIn ? ABCAccount.info : null;
let upQ = null, busy = null, upT = 0;
const queue = () => { if (!upQ) { try { upQ = JSON.parse(localStorage.getItem(UPQ)); } catch (e) {} if (!Array.isArray(upQ)) upQ = []; } return upQ; };
const keepQ = () => { try { localStorage.setItem(UPQ, JSON.stringify(upQ)); } catch (e) {} };
const local = (kid, book, key) => ((data().pages[kid] || {})[book] || {})[key] || null;
function enqueue(kid, book, key) {
const a = ACC(); if (!a || !(a.access && a.access.math) && local(kid, book, key)) return; // saving needs the plan; deleting doesn't
const q = queue(), id = kid + '\n' + book + '\n' + key;
if (!q.includes(id) || (busy && busy.has(id))) { q.push(id); if (q.length > 5000) q.splice(0, q.length - 5000); keepQ(); }
clearTimeout(upT); upT = setTimeout(upload, 2500);
}
async function upload() {
if (busy || !ACC()) return; const q = queue();
try {
while (q.length) {
const ids = q.slice(0, 25); busy = new Set(ids);
const pages = ids.map(id => { const [kid, book, key] = id.split('\n'); return { reader: kid, book, key, page: local(kid, book, key) }; });
const r = await fetch('/account/api/work', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pages }) });
if (r.status === 401) { q.length = 0; break; } // signed out
if (r.status === 402) { // no math plan: keep only the deletes, which are always allowed
const dels = q.filter(id => !local(...id.split('\n'))); q.length = 0; q.push(...dels);
continue;
}
if (!r.ok) break; // try again later
q.splice(0, ids.length); // a page changed meanwhile was queued again behind these
}
} catch (e) {} // offline: try again later
busy = null; keepQ();
}
// after sign-in with the plan, the pages already on this device go up once (again after a plan comes back, or for
// another family, told apart by its reader names); then retry whatever is waiting
if (window.ABCAccount) ABCAccount.ready.then(info => {
const w = data(), names = (info.profiles || []).map(p => p.name), fam = names.join('|');
const paid = info.signedIn && info.access && info.access.math;
if (!paid) { if (w.upAll) { w.upAll = false; store(); } if (!info.signedIn) return; }
else if (w.upAll !== fam) {
for (const k of names) for (const b in w.pages[k] || {}) for (const key in w.pages[k][b]) enqueue(k, b, key);
w.upAll = fam; store();
}
upload(); setInterval(() => { if (queue().length) upload(); }, 60000);
window.addEventListener('online', upload);
});
// the pages of one reader in one book on the account (every device), for See the work; [] when not signed in
async function remote(kid, book) {
if (!ACC()) return [];
try { const r = await fetch(`/account/api/work?reader=${encodeURIComponent(kid)}&book=${encodeURIComponent(book)}`, { credentials: 'same-origin' }); return r.ok ? (await r.json()).pages || [] : []; }
catch (e) { return []; }
}
// draw stored strokes into a 2d context, fitted into w x h
function paint(ctx, pg, w, h, lw) {
const sc = Math.min(w / 1000, h / (1000 * (pg.h || 0.75)));
ctx.strokeStyle = '#1d2a44'; ctx.lineCap = ctx.lineJoin = 'round'; ctx.lineWidth = lw || Math.max(1.5, 4 * sc);
for (const s of pg.s) {
ctx.beginPath(); ctx.moveTo(s[0] * sc, s[1] * sc);
if (s.length === 2) ctx.lineTo(s[0] * sc + 0.1, s[1] * sc);
for (let i = 2; i < s.length; i += 2) ctx.lineTo(s[i] * sc, s[i + 1] * sc);
ctx.stroke();
}
}
function grid(ctx, w, h, step) { // faint squared paper, so columns of figures line up
ctx.fillStyle = '#fffdf6'; ctx.fillRect(0, 0, w, h);
ctx.strokeStyle = 'rgba(90,140,200,.22)'; ctx.lineWidth = 1; ctx.beginPath();
for (let x = step; x < w; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, h); }
for (let y = step; y < h; y += step) { ctx.moveTo(0, y); ctx.lineTo(w, y); }
ctx.stroke();
}
// Pad(stage, kid, book): the paper beside the question. page(key, info) switches to a question's page (its old work
// comes back); result(answer, right) notes how it went; open/close/toggle; stop() saves and removes it.
function Pad(stage, kid, book, toast) {
const box = document.createElement('div'); box.className = 'scratch';
const cv = document.createElement('canvas'); const area = document.createElement('div'); area.className = 'sarea'; area.appendChild(cv);
const tools = document.createElement('div'); tools.className = 'stools';
const mk = (label, fn, title) => { const b = document.createElement('button'); b.innerHTML = label; b.title = title || ''; b.onclick = fn; tools.appendChild(b); return b; };
let mode = 'pen';
const penB = mk('✏️ Pencil', () => setMode('pen'), 'Write'), eraB = mk('<span class="erz"></span> Eraser', () => setMode('erase'), 'Rub out a line');
mk('↶ Undo', () => { // after Clear, Undo brings the whole page back
if (!pg) return; if (!pg.s.length && undoAll) { pg.s = undoAll; undoAll = null; } else if (pg.s.length) pg.s.pop(); else return;
dirty(); redraw();
}, 'Take back the last line');
mk('🗑️ Clear', () => { if (pg && pg.s.length) { undoAll = pg.s; pg.s = []; dirty(); redraw(); } }, 'Clear the page');
box.append(area, tools);
let C = null, pg = null, key = null, cur = null, pid = null, penAt = 0, undoAll = null, tSave = 0, isOpen = false;
function setMode(m) { mode = m; penB.classList.toggle('on', m === 'pen'); eraB.classList.toggle('on', m === 'erase'); }
setMode('pen');
function size() {
const r = cv.getBoundingClientRect(), dpr = Math.min(2, window.devicePixelRatio || 1);
if (!r.width) return false;
cv.width = Math.round(r.width * dpr); cv.height = Math.round(r.height * dpr);
const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
C = { ctx, w: r.width, h: r.height }; return true;
}
function redraw() {
if (!C) return; const { ctx, w, h } = C;
grid(ctx, w, h, Math.max(22, w / 18));
if (pg) paint(ctx, { ...pg, h: h / w }, w, h);
}
const sc = () => C.w / 1000; // page units -> px
function pos(e) { const r = cv.getBoundingClientRect(); return [Math.round((e.clientX - r.left) / sc()), Math.round((e.clientY - r.top) / sc())]; }
const pts = () => pg.s.reduce((n, s) => n + s.length, 0);
function rub(p) { // the eraser takes away every line it touches
const R = 14, n = pg.s.length;
pg.s = pg.s.filter(s => { for (let i = 0; i < s.length; i += 2) if (Math.abs(s[i] - p[0]) < R && Math.abs(s[i + 1] - p[1]) < R) return false; return true; });
if (pg.s.length !== n) { dirty(); redraw(); }
}
cv.onpointerdown = e => {
if (!pg || pid != null || (!C && !size())) return;
if (e.pointerType === 'pen') penAt = Date.now();
else if (e.pointerType === 'touch' && Date.now() - penAt < 1500) return; // a hand resting while the stylus writes
e.preventDefault(); try { cv.setPointerCapture(e.pointerId); } catch (_) {}
pid = e.pointerId; const p = pos(e);
if (mode === 'erase') { cur = null; rub(p); return; }
if (pts() > MAXPTS) { pid = null; return toast && toast('This page is full. Tap Clear.', 2000); }
cur = p.slice(); pg.s.push(cur); undoAll = null;
const { ctx } = C; ctx.fillStyle = '#1d2a44'; ctx.beginPath(); ctx.arc(p[0] * sc(), p[1] * sc(), Math.max(1.5, 4 * sc()) / 2, 0, 7); ctx.fill();
};
cv.onpointermove = e => {
if (e.pointerId !== pid) return; e.preventDefault();
if (e.pointerType === 'pen') penAt = Date.now();
const evs = e.getCoalescedEvents ? e.getCoalescedEvents() : null;
for (const ev of (evs && evs.length ? evs : [e])) {
const p = pos(ev);
if (mode === 'erase') { rub(p); continue; }
const lx = cur[cur.length - 2], ly = cur[cur.length - 1];
if (Math.abs(p[0] - lx) + Math.abs(p[1] - ly) < 4) continue; // keep the points few
cur.push(p[0], p[1]);
const { ctx } = C, k = sc(); ctx.strokeStyle = '#1d2a44'; ctx.lineCap = ctx.lineJoin = 'round'; ctx.lineWidth = Math.max(1.5, 4 * k);
ctx.beginPath(); ctx.moveTo(lx * k, ly * k); ctx.lineTo(p[0] * k, p[1] * k); ctx.stroke();
}
};
cv.onpointerup = cv.onpointercancel = e => { if (e.pointerId !== pid) return; pid = null; if (cur) dirty(); cur = null; };
let changed = false;
function dirty() { changed = true; clearTimeout(tSave); tSave = setTimeout(flush, 1500); }
function flush() { // the page goes into storage (and up to the account); an empty page is not kept
clearTimeout(tSave); if (!pg || !key || !changed) return; changed = false;
const pages = kidPages(kid, book), had = !!pages[key];
if (pg.s.length) { pg.t = Date.now(); if (C) pg.h = +(C.h / C.w).toFixed(3); pages[key] = { ...pg, s: pg.s.map(s => s.slice()) }; }
else delete pages[key];
store();
if (pg.s.length || had) enqueue(kid, book, key);
}
const onRes = () => { if (isOpen && size()) redraw(); };
const onHide = () => { if (document.hidden) flush(); };
window.addEventListener('resize', onRes); document.addEventListener('visibilitychange', onHide); window.addEventListener('pagehide', flush);
function show(on) {
isOpen = on; data().open[kid] = on; store();
stage.classList.toggle('working', on);
if (on) { if (!box.parentNode) stage.appendChild(box); if (size()) redraw(); else requestAnimationFrame(() => { size(); redraw(); }); }
else { flush(); box.remove(); }
}
return {
// info: { q: question text, l: lesson number }
page(k, info) {
if (key === k) return; flush();
key = k; const old = kidPages(kid, book)[k];
pg = old ? { ...old, s: old.s.map(s => s.slice()) } : { q: '', l: 0, s: [] };
pg.q = String(info.q || '').slice(0, 400); pg.l = info.l;
if (Date.now() - (pg.t || 0) > 30 * 60000) { delete pg.a; delete pg.r; } // a fresh try (a missed fact coming back keeps its miss)
undoAll = null; redraw();
},
result(ans, right) { if (!pg) return; pg.a = String(ans).slice(0, 44); if (!right) pg.r = 'miss'; else if (!pg.r) pg.r = 'ok'; if (pg.s.length) dirty(); },
toggle() { show(!isOpen); return isOpen; },
open: () => isOpen,
start() { if (data().open[kid]) show(true); return isOpen; }, // opens again if the kid left it open
stop() { flush(); window.removeEventListener('resize', onRes); document.removeEventListener('visibilitychange', onHide); window.removeEventListener('pagehide', flush); stage.classList.remove('working'); box.remove(); }
};
}
// for the grown-ups' view: the pages one kid has in one book, newest first
function list(kid, book) {
const p = (data().pages[kid] || {})[book] || {};
return Object.keys(p).map(k => ({ key: k, ...p[k] })).sort((a, b) => (b.t || 0) - (a.t || 0));
}
// deletes here and on the account (a whole book there in one call, so pages from other devices go too)
function remove(kid, book, key) {
const p = (data().pages[kid] || {})[book];
if (key) { if (p) { delete p[key]; store(); } enqueue(kid, book, key); return; }
if (p) { data().pages[kid][book] = {}; store(); }
if (ACC()) fetch('/account/api/work/delete', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reader: kid, book }) }).catch(() => {});
}
// a picture of one page, w px wide
function picture(pg, w) {
const h = Math.round(w * (pg.h || 0.75)), cv = document.createElement('canvas'), dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = w * dpr; cv.height = h * dpr; cv.style.width = w + 'px'; cv.style.height = h + 'px';
const ctx = cv.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
grid(ctx, w, h, Math.max(10, w / 18)); paint(ctx, pg, w, h);
return cv;
}
return { Pad, list, remote, remove, picture };
})();
EOF
app.js: the game for all five books (new: the Show work button in Story problems and Practice, and the See the work screen).
cat > /root/rays-rebuild/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' },
{ id: 'algebra1', title: "Ray's New Elementary Algebra", sub: 'Book 6 · Grades 9-10', color: '#8f2f4f' },
{ 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: '?' }, study: { name: 'Study', icon: '📐' }, quiz: { name: 'Quiz', 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 = (algMode ? RaysAlg.pretty(val) : /^\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
let algMode = false; // Algebra: + − ( ) / power and the question's letters; OK hands back the text (alg.js checks it)
const press = k => {
if (k === '⌫') val = val.slice(0, -1);
else if (k === 'OK') { if (val.trim()) onOK(fracMode || algMode ? val.trim() : parseInt(val, 10)); return; }
else if (algMode) { if (val.length < 200 && !(k === '^' && !/[\da-z)]$/.test(val))) val += k === '−' ? '-' : k; }
// "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 algRow = el('div', 'pad alg'), letRow = el('div', 'pad alg'); algRow.style.display = letRow.style.display = 'none';
['+', '−', '(', ')', '/', '^'].forEach(k => algRow.appendChild(btn(k === '^' ? 'xⁿ' : k, () => press(k))));
const onKey = e => { if (algMode && /^[a-z+\-()/^*]$/.test(e.key)) press(e.key); else 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, algRow, letRow, mic);
return { el: wrap, entry, frac(on) { fracMode = !!on; fracRow.style.display = on ? '' : 'none'; mic.style.display = on || !micOK ? 'none' : ''; },
alg(keys) { // keys = the question's letters ("abx"); null turns the algebra keys off
algMode = keys != null; wrap.classList.toggle('algmode', algMode); algRow.style.display = letRow.style.display = algMode ? '' : 'none'; letRow.innerHTML = '';
[...(keys || '')].forEach(c => letRow.appendChild(btn(c, () => press(c), 'let')));
letRow.style.display = algMode && keys ? '' : 'none';
if (algMode) { fracMode = false; fracRow.style.display = 'none'; mic.style.display = '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');
}
// "Show your work": a pencil button that opens scratch paper beside the question (scratch.js). The paper comes
// back open on the next question and the next lesson if the kid left it open.
function workPad() {
const w = RaysWork.Pad($('stage'), S.kid, bookId, toast);
const b = btn('', () => { label(w.toggle()); });
const label = on => { b.textContent = on ? '✏️ Put paper away' : '✏️ Show work'; b.classList.toggle('on', on); };
label(w.start());
return { btn: b, page: w.page, result: w.result, stop: w.stop };
}
// grown-ups: every page of work one kid has in one book, newest first, grouped by lesson
// With a family account, the pages saved there from other devices are added (fetched after the first draw).
let workBack = 'map';
const gone = new Set(); // deleted in this visit: the account may not have heard yet
function showWork(from, fromAcct) {
workBack = from; const id = bookId || 'primary1877', B = BOOKS[id], kid = S.kid;
const pages = RaysWork.list(kid, id), box = $('workList'); box.innerHTML = '';
if (fromAcct) {
const at = new Map(pages.map((pg, i) => [pg.key, i]));
fromAcct.filter(pg => !gone.has(id + '|' + pg.key) && !gone.has(id + '|*')).forEach(pg => {
if (!at.has(pg.key)) pages.push(pg); else if ((pg.t || 0) > (pages[at.get(pg.key)].t || 0)) pages[at.get(pg.key)] = pg;
});
} else RaysWork.remote(kid, id).then(r => { if (r.length && $('work').classList.contains('on') && S.kid === kid && (bookId || 'primary1877') === id) showWork(from, r); });
// lessons with the newest work first; inside a lesson, the pages in question order
const newest = {}; pages.forEach(pg => { newest[pg.l] = Math.max(newest[pg.l] || 0, pg.t || 0); });
pages.sort((a, b) => newest[b.l] - newest[a.l] || a.key.localeCompare(b.key, undefined, { numeric: true }));
$('workTitle').textContent = `${S.kid}: work in ${B ? B.title : ''} (${pages.length} page${pages.length === 1 ? '' : 's'})`;
$('workDel').style.display = pages.length ? '' : 'none';
$('workDel').onclick = () => { if (confirm(`Delete all of ${S.kid}'s work in this book?`)) { RaysWork.remove(S.kid, id); gone.add(id + '|*'); showWork(from); } };
if (!pages.length) box.appendChild(el('p', 'note', 'No work saved yet. In Story problems and Practice, the ✏️ Show work button opens scratch paper; each page is kept here with its question.'));
const tw = Math.min(300, Math.floor((window.innerWidth - 60) / 2));
let shown = 0, lastL = null;
function more() {
pages.slice(shown, shown + 40).forEach(pg => {
if (pg.l !== lastL) { lastL = pg.l; const Ls = B && B.lessons.find(x => x.n === pg.l); box.appendChild(el('h3', 'wles', '')).textContent = `Lesson ${pg.l}${Ls ? ': ' + Ls.title : ''}`; }
const card = el('button', 'wpage'); card.appendChild(RaysWork.picture(pg, tw));
const cap = el('div', 'wcap'); cap.textContent = pg.q;
const res = el('div', 'wres' + (pg.r === 'miss' ? ' miss' : pg.r === 'ok' ? ' ok' : ''));
res.textContent = (pg.a != null ? `Answer: ${pg.a} ` + (pg.r === 'ok' ? '✓' : pg.r === 'miss' ? '(missed a try)' : '') : 'Not answered') + ' · ' + new Date(pg.t).toLocaleString([], { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
card.append(cap, res); card.onclick = () => bigPage(pg, id, from); box.appendChild(card);
});
shown += 40; const m = box.querySelector('.wmore'); if (m) m.remove();
if (shown < pages.length) box.appendChild(btn('Show more', more, 'wmore'));
}
more(); go('work');
}
function bigPage(pg, id, from) {
const v = $('workBig'); v.innerHTML = ''; v.classList.add('on');
const q = el('div', 'wcap'); q.textContent = `Lesson ${pg.l}: ${pg.q}`;
const w = Math.min(window.innerWidth - 40, 1000), h = window.innerHeight - 190;
v.append(q, RaysWork.picture(pg, Math.round(Math.min(w, h / (pg.h || 0.75)))),
el('div', 'row'));
v.lastChild.append(btn('Close', () => v.classList.remove('on'), 'big'),
btn('Delete this page', () => { if (confirm('Delete this page?')) { RaysWork.remove(S.kid, id, pg.key); gone.add(id + '|' + pg.key); v.classList.remove('on'); showWork(from); } }, 'ghost parent'));
}
$('mapWork').onclick = () => showWork('map');
$('stkWork').onclick = () => { if (S.kid) showWork('stickers'); };
$('workBack').onclick =() => { $('workBig').classList.remove('on'); go(workBack); };
// ---------- 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.arts) s.push('study'); if (L.quiz) s.push('quiz');
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, study: stepStudy, quiz: stepQuiz })[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'); 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');
const work = workPad();
$('controls').append(showB, work.btn);
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;
work.page(`${L.n}:d:${cur.ex}`, { q: `${cur.ex} ${cur.q}`, l: L.n });
$('hint').textContent = `${done + 1} of ${items.length}`;
sayAgain = () => speak(cur.q); sayAgain();
if (!t0) t0 = Date.now();
}
function check(v) {
work.result(v, v === cur.a);
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(); work.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();
const work = workPad();
if (!/^algebra/.test(bookId)) $('controls').append(btn('🧮 Counters', () => $('tray').classList.toggle('on'))); // no counting beads in Algebra
$('controls').append(work.btn, 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.e != null ? RaysAlg.pretty(p.e) : (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.alg(p.e != null ? p.v || '' : null); pad.show(!p.open); openB.style.display = p.open ? '' : 'none';
render();
work.page(`${L.n}:${qi}`, { q: Q().parts.map(x => x.t).join(' '), l: L.n }); // one page for all the parts of a question
$('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 norm = s => String(s).trim().replace(/\s+/g, ' ');
const isRight = (p, v) => { const want = p.seq ? p.seq[si] : p.e != null ? p.e : p.f || p.a;
return p.e != null ? RaysAlg.ok(v, p.e) || (p.ealt || []).some(x => RaysAlg.ok(v, x)) : 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)); };
let right = isRight(p, v);
// "x = {A} or {B}" (the two roots of a quadratic): B typed first counts too; the next box then wants A
const nx = Q().parts[pi + 1];
if (!right && p.pr && nx && isRight(nx, v)) {
for (const k of ['e', 'es', 'ealt', 'a', 'alt', 'f', 'falt', 'v']) { const t = p[k]; if (nx[k] === undefined) delete p[k]; else p[k] = nx[k]; if (t === undefined) delete nx[k]; else nx[k] = t; }
right = true;
}
const want = p.seq ? p.seq[si] : p.e != null ? p.e : p.f || p.a;
if (!p.seq || !right) work.result(v, right);
if (right) {
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(); work.stop(); };
afterIntro(intro, () => ask());
}
// Book Five: the book's articles, each beside its figure cut from the scan (same key as make-audio.ps1 ArtKey)
const artKey = a => `${a.n}. ${a.h ? a.h + '. ' : ''}${a.t}`;
function stepStudy(intro) {
const arts = L.arts; let i = 0, run = 0; const seen = new Set();
const stop = () => { run++; playB.disabled = false; }; // a tap cancels reading-to-me
const col = el('div', 'col study'); $('stage').appendChild(col);
const face = el('div', 'art'); const chips = el('div', 'chips'); col.append(face, chips);
const chipEls = arts.map((a, k) => { const c = btn('§' + a.n, () => { stop(); i = k; show(); }, 'chip'); chips.appendChild(c); return c; });
function draw() {
const a = arts[i]; face.innerHTML = '';
if (a.figs) { const fb = el('div', 'figs'); a.figs.forEach(f => { const im = el('img', 'fig'); im.src = f; im.alt = 'Figure for article ' + a.n; fb.appendChild(im); }); face.appendChild(fb); }
face.appendChild(el('p', '', `<b>${a.n}.</b> ${a.h ? `<b>${a.h}.</b> ` : ''}${a.t}`));
chipEls.forEach((c, k) => c.classList.toggle('cur', k === i));
}
function show() {
const a = arts[i]; draw(); chipEls[i].classList.add('seen'); seen.add(i);
chipEls[i].scrollIntoView({ block: 'nearest' }); face.scrollTop = 0;
$('hint').textContent = `Part ${i + 1} of ${arts.length}`;
if (seen.size === arts.length) nextB.disabled = false;
return speak(artKey(a));
}
async function playAll() { // from the part on screen to the end
const my = ++run; playB.disabled = true;
for (; i < arts.length; i++) { await show(); if (my !== run) return; await wait(700); if (my !== run) return; }
i = arts.length - 1; playB.disabled = false; nextB.disabled = false;
}
const prevB = btn('◀', () => { stop(); if (i > 0) { i--; show(); } });
const fwdB = btn('▶', () => { stop(); if (i < arts.length - 1) { i++; show(); } else nextB.disabled = false; });
const playB = btn('▶ Read to me', playAll, 'big'), nextB = btn('Next step ▶', () => next(3));
nextB.disabled = true; $('controls').append(prevB, playB, fwdB, nextB);
sayAgain = show; cleanup = () => { run++; };
draw(); afterIntro(intro, () => { if (!seen.size) playAll(); });
}
// pick the answer (or True / False); a miss comes back at the end, two misses show the answer
function stepQuiz(intro) {
let queue = shuffle(L.quiz.map(x => ({ ...x }))), done = 0, miss = 0, tries = 0, busy = false, cur;
const total = queue.length;
const col = el('div', 'qa'); $('stage').appendChild(col);
const qd = el('div', 'q'); const grid = el('div', 'mc'); col.append(qd, grid);
function ask() {
if (!queue.length) { toast('Great job! ⭐'); return setTimeout(() => next(starsFor(miss, total)), 900); }
cur = queue[0]; tries = 0; busy = false; grid.innerHTML = '';
qd.textContent = (cur.tf ? 'True or false? ' : '') + cur.q;
const order = cur.tf ? [0, 1] : shuffle(cur.c.map((_, k) => k));
order.forEach(k => grid.appendChild(btn(cur.c[k], e => pick(k, e.currentTarget))));
$('hint').textContent = `${done + 1} of ${total}`;
sayAgain = () => speak(cur.q); sayAgain();
}
function pick(k, b) {
if (busy) return;
if (k === cur.a) { busy = true; beep(true); b.classList.add('ok'); queue.shift(); done++; speak(P.yes); return setTimeout(ask, 900); }
miss++; beep(false); b.classList.remove('no'); void b.offsetWidth; b.classList.add('no'); b.disabled = true;
if (++tries < 2 && !cur.tf) return speak(P.tryAgain);
busy = true; [...grid.children].forEach(x => { if (x.textContent === cur.c[cur.a]) x.classList.add('ok'); });
const it = queue.shift(); it.again = true; queue.push(it); // asked again at the end
speak('ansc:' + cur.c[cur.a]).then(() => wait(700)).then(ask);
}
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);
}
// ?preview opens the "Coming soon" books that already have a book file (testing a book before it goes live)
const ready = b => !b.soon || /[?&]preview\b/.test(location.search);
Promise.all([
fetch('prompts.json').then(r => r.json()).then(j => { P = j; }),
...SHELF.filter(ready).map(b => fetch(`books/${b.id}.json`).then(r => r.ok ? r.json() : null).then(j => { if (j) BOOKS[b.id] = j; })),
...SHELF.filter(ready).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 }), toStep: id => { const i = steps.indexOf(id); if (i >= 0) { stepIdx = i; runStep(); } }, wordsToNumber };
})();
EOF
index.html: the page and its styles (new: the paper layout, the See the work screen, and loading scratch.js after /account/sync.js). It also loads alg.js, the answer checker for the Algebra book that is being built; until that book has its own post, delete the alg.js line if you rebuild from this post (nothing else needs it while Algebra shows "Coming soon").
cat > /root/rays-rebuild/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 */
.study{gap:1vmin}.study .chips{max-height:15vh;flex-shrink:0}.art{flex:1;min-height:0;overflow-y:auto;width:min(94vw,760px);background:#fffdf6;border-radius:12px;padding:10px 14px;color:#222}
.art{display:flex;flex-direction:column;gap:10px}.art .figs{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;align-items:center}
.art img.fig{display:block;max-width:100%;max-height:26vh;mix-blend-mode:multiply}
@media (min-aspect-ratio:5/4){.art{flex-direction:row;align-items:flex-start}.art .figs{flex:0 0 38%;flex-direction:column;position:sticky;top:0}.art img.fig{max-height:30vh}.art p{flex:1}}
.art p{margin:0;font-size:clamp(18px,3.8vmin,28px);line-height:1.45}
.mc{display:flex;flex-direction:column;gap:1.4vmin;width:min(92vw,640px)}
.mc button{font-size:clamp(18px,4vmin,28px);min-height:56px;padding:8px 14px;text-align:left;line-height:1.3}
.mc button.ok{background:var(--good);border-color:var(--good);color:#fff}.mc button.no{animation:shake .4s;opacity:.55}
.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}
.algmode .entry{max-width:92vw;overflow-wrap:anywhere;letter-spacing:0}.algmode .entry.long{font-size:clamp(18px,4.2vmin,32px)}
.pad.alg,.algmode .pad{grid-template-columns:repeat(6,1fr);width:min(92vw,480px)}.pad.alg button,.algmode .pad button{font-size:clamp(20px,5vmin,32px);min-height:clamp(40px,8vmin,60px)}.pad.alg button.let{font-style:italic;font-family:Georgia,serif}
.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}
/* show your work: scratch paper beside the question (landscape) or under the number pad (portrait), never over them */
#controls button.on{background:var(--hi);border-color:var(--accent)}
#stage.working{display:flex;flex-direction:column;gap:8px}
#stage.working .qa{flex:1 1 auto;height:auto;min-height:0;overflow-y:auto}
#stage.working .qa .pic{display:none}
#stage.working .pad button{min-height:clamp(42px,7vmin,60px)}#stage.working .micb{min-height:44px;padding:4px 16px;font-size:clamp(17px,3.6vmin,24px)}#stage.working .entry{font-size:clamp(28px,6.5vmin,50px)}#stage.working .expr{font-size:clamp(32px,7.5vmin,64px)}#stage.working .expr.long{font-size:clamp(24px,5.5vmin,44px)}#stage.working .qa{gap:.6vmin;overflow:hidden}#stage.working .qa>*{flex-shrink:0}#stage.working .qa>.q{flex:0 1 auto;min-height:2.8em;max-height:24vh}#stage.working .qa>.picbox{flex:0 1 auto;overflow-y:auto}
.scratch{flex:0 0 35%;display:flex;flex-direction:column;gap:6px;min-height:0;min-width:0}
.sarea{flex:1;position:relative;min-height:0;border:3px solid var(--line);border-radius:14px;overflow:hidden;background:#fffdf6}
.sarea canvas{border-radius:0}
.stools{display:flex;gap:6px;justify-content:center;flex-wrap:wrap}
.stools button{font-size:clamp(16px,3.2vmin,22px);min-height:clamp(46px,8vmin,60px);padding:4px 12px;flex:1 1 0;max-width:180px;white-space:nowrap}
.stools button.on{background:var(--accent);border-color:#164a63;color:#fff}
.erz{display:inline-block;width:1.1em;height:.65em;background:linear-gradient(90deg,#f4a3b4 60%,#5a8fd0 60%);border-radius:3px;transform:rotate(-20deg);vertical-align:middle}
@media (min-aspect-ratio:5/4){#stage.working{flex-direction:row}#stage.working .qa{flex:0 0 44%;height:100%}#stage.working .qa .pic{display:block}.scratch{flex:1 1 0}}
.worklist{display:flex;flex-wrap:wrap;gap:12px;justify-content:center;align-content:flex-start;overflow-y:auto;padding:6px}
.wles{flex-basis:100%;margin:10px 0 0;font-size:clamp(17px,3.6vmin,24px)}
.wpage{display:flex;flex-direction:column;gap:4px;padding:6px;text-align:left;font-size:15px;line-height:1.3;max-width:min(316px,46vw);border-width:2px}
.wpage canvas,.workbig canvas{position:static;border-radius:6px}
.wcap{font-size:clamp(14px,2.8vmin,18px);max-height:4.2em;overflow:hidden}
.workbig .wcap{max-height:none;text-align:center;max-width:94vw}
.wres{color:var(--soft);font-size:14px}.wres.ok{color:var(--good)}.wres.miss{color:var(--a)}
.workbig{position:fixed;inset:0;background:var(--paper);z-index:30;display:none;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:12px}
.workbig.on{display:flex}
</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><button id="mapWork" class="parent" title="Grown-ups: the scratch paper saved with each question">✏️ See the work</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>
<div class="row"><button id="stkWork" class="parent">✏️ See the work</button></div>
</section>
<section id="work" class="screen">
<div class="top"><button id="workBack">◀</button><div class="t" id="workTitle">Work</div></div>
<div class="worklist grow" id="workList"></div>
<div class="row"><button id="workDel" class="ghost parent">Delete all work in this book</button></div>
<div class="workbig" id="workBig"></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="scratch.js"></script>
<script src="alg.js"></script>
<script src="app.js"></script>
<script>if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});</script>
</body></html>
EOF
sw.js: offline support (now keeps scratch.js too; cache rays-shell-v18).
cat > /root/rays-rebuild/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-v19', CLIPS = 'rays-clips-v1';
const FILES = ['./', 'index.html', 'app.js', 'prompts.json', 'trace.js', 'scratch.js', 'alg.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', 'books/geometry.json', 'audio/geometry.json', 'books/algebra1.json', 'audio/algebra1.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
Rebuild, step by step
1. Try it on the PC
PC (PowerShell). It serves the app at http://localhost:8129/ :
powershell -ExecutionPolicy Bypass -File C:\RaysArithmetic\serve-local.ps1
Pick a name, open Book 1, Lesson 7, tap âœï¸ Show work and draw with the mouse.
2. Put it on the server
PC (PowerShell). No -Audio is needed, since no new spoken lines were added. It ends with "RAYS-DONE":
powershell -ExecutionPolicy Bypass -File C:\RaysArithmetic\deploy.ps1
3. Check it
Open https://abcbooks.fyi/math/ , pick a name, open any book and a lesson with story problems. Tap âœï¸ Show work, write something, answer the question, and go on to the next one. Go back to the lesson map and tap âœï¸ See the work: the page is there with its question and answer. Reload the page and check again; it is still there.
Troubleshooting
- No Show work button: the old app is cached. Reload the page twice. To check that the server has the new version, open a PowerShell window on the PC and run this; it should print
rays-shell-v18or later:
(Invoke-WebRequest -UseBasicParsing https://abcbooks.fyi/math/sw.js).Content
- The file is missing on the server: Proxmox web UI, pve, Shell. It should list
scratch.jswith a size of about 10 KB:
pct exec 306 -- ls -l /var/www/abcbooks/rays/scratch.js
- The web server log for CT 306 (abc-web, Caddy): Proxmox web UI, pve, Shell. Healthy is a quiet log or start-up lines; lines with
"level":"error"are real problems. Lines about the other apps on the same site are normal:
pct exec 306 -- journalctl -u caddy -n 50 --no-pager
- Errors in the app itself: on the PC, open the page in Chrome or Edge, press F12, and pick Console. Red lines that name
scratch.jsorapp.jsare real problems. "Failed to load resource" lines for.mp3files while testing on the PC are harmless (the voice clips are only on the server). - The work is gone: drawings live only on the tablet they were made on. Clearing the browser's site data deletes them, and the oldest pages are dropped once there are very many.
- Drawing on the paper scrolls the page instead: the paper canvas must keep
touch-action: none(it comes from thecanvasstyle inindex.html).
Notes
- The page key is the lesson and question number (
7:0) for story problems and the sum itself (11:d:1 + 4 = ?) for Practice. If a book's questions are renumbered later, old pages simply stay under their old keys. - Next on the shelf: Ray's New Elementary Algebra and Ray's New Higher Algebra.