From f2349bcca5eed05025fb5f957009a006f0ab956a Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:33:11 +0700 Subject: [PATCH 001/110] Add solo reader bookshelf catalog --- src/lib/library/catalog.js | 75 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 src/lib/library/catalog.js diff --git a/src/lib/library/catalog.js b/src/lib/library/catalog.js new file mode 100644 index 0000000..b9a2448 --- /dev/null +++ b/src/lib/library/catalog.js @@ -0,0 +1,75 @@ +import magi from '../../books/magi/index.js'; + +/** + * The bookshelf is deliberately small and curated. + * + * A catalog entry is not a bundled book. `local` is used only for the + * title that ships with the app. Remote entries describe a Git-hosted + * pack that is fetched only when the reader opens it. + */ +export const CATALOG = [ + { + id: 'magi', + title: 'The Gift of the Magi', + author: 'O. Henry', + kind: 'Short story', + note: 'Love, sacrifice, and the joke hidden inside a perfect gift.', + local: magi, + featured: true, + }, + { + id: 'raven', + title: 'The Raven', + author: 'Edgar Allan Poe', + kind: 'Poem', + note: 'A midnight visitor, a grieving mind, and one word that will not leave.', + remote: { + book: 'https://raw.githubusercontent.com/dancockrell/the-raven-edgar-allan-poe-magi-reader/main/pack/book.json', + base: 'https://raw.githubusercontent.com/dancockrell/the-raven-edgar-allan-poe-magi-reader/main/pack/', + plate: 'art/plate-{scene}.webp', + beatPlate: 'art/beat-{scene}-{line}.webp', + cast: { + wren: 'art/cast-wren.webp', + prof: 'art/cast-prof.webp', + }, + audio: 'audio/', + cues: 'cues/raven.vtt', + }, + }, + { + id: 'rikki-tikki-tavi', + title: 'Rikki-Tikki-Tavi', + author: 'Rudyard Kipling', + kind: 'Short story', + note: 'A mongoose, a garden, and a very serious fight with cobras.', + comingSoon: true, + }, + { + id: 'three-little-pigs', + title: 'The Three Little Pigs', + author: 'Traditional', + kind: 'Fairy tale', + note: 'Three houses, one wolf, and a story built for visual storytelling.', + comingSoon: true, + }, + { + id: 'tortoise-and-hare', + title: 'The Tortoise and the Hare', + author: 'Aesop', + kind: 'Fable', + note: 'Speed is useful. So is actually finishing what you started.', + comingSoon: true, + }, + { + id: 'lion-and-mouse', + title: 'The Lion and the Mouse', + author: 'Aesop', + kind: 'Fable', + note: 'A small kindness becomes much larger when it comes back around.', + comingSoon: true, + }, +]; + +export function catalogBook(id) { + return CATALOG.find((entry) => entry.id === id) || null; +} From 97c543363aff91762267d7d6153ffcf00b119390 Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:33:25 +0700 Subject: [PATCH 002/110] Add Git book plugin loader --- src/lib/library/plugin.js | 94 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/lib/library/plugin.js diff --git a/src/lib/library/plugin.js b/src/lib/library/plugin.js new file mode 100644 index 0000000..337320e --- /dev/null +++ b/src/lib/library/plugin.js @@ -0,0 +1,94 @@ +import { linesOf } from '../reader/beats.js'; + +const cache = new Map(); + +function asset(base, path) { + if (!path) return ''; + try { + return new URL(path, base).href; + } catch { + return path; + } +} + +function pattern(value, scene, line) { + return value + .replaceAll('{scene}', String(scene)) + .replaceAll('{line}', String(line ?? '')); +} + +/** + * Turn a plain JSON book in a Git repository into a runtime pack. + * + * The repository owns the content and media naming. The app owns only + * this small loading contract. Nothing is installed and no arbitrary + * JavaScript is executed: a plugin is data plus media URLs. + */ +export async function loadRemoteBook(entry) { + const spec = entry?.remote; + if (!spec?.book || !spec?.base) throw new Error('This book has no readable plugin.'); + if (cache.has(entry.id)) return cache.get(entry.id); + + const pending = (async () => { + const response = await fetch(spec.book, { cache: 'no-cache' }); + if (!response.ok) throw new Error(`Could not fetch ${entry.title} (${response.status}).`); + const data = await response.json(); + + const plates = { ...(data.plates || {}) }; + const things = [...(data.units || []), ...Object.entries(data.info || {}).map(([id, x]) => ({ id, ...x }))]; + + if (spec.plate) { + for (const thing of things) { + const scene = thing?.scene || thing?.id; + if (scene && !plates[scene]) plates[scene] = asset(spec.base, pattern(spec.plate, scene)); + } + if (!plates.cover) plates.cover = asset(spec.base, pattern(spec.plate, 'cover')); + } + + if (spec.beatPlate) { + for (const unit of data.units || []) { + const scene = unit.scene || unit.id; + linesOf(unit).forEach((_, i) => { + plates[`${scene}-${i}`] = asset(spec.base, pattern(spec.beatPlate, scene, i)); + }); + } + } + + const members = { ...(data.cast?.members || {}) }; + for (const [id, path] of Object.entries(spec.cast || {})) { + members[id] = { ...(members[id] || { id }), art: asset(spec.base, path) }; + } + + return { + ...data, + meta: { ...data.meta, id: data.meta?.id || entry.id, title: data.meta?.title || entry.title }, + plates, + cast: { ...(data.cast || {}), members }, + media: { + audio: asset(spec.base, spec.audio || ''), + cues: asset(spec.base, spec.cues || ''), + }, + plugin: { + source: spec.book, + fetchedAt: Date.now(), + }, + }; + })(); + + cache.set(entry.id, pending); + try { + const book = await pending; + cache.set(entry.id, book); + return book; + } catch (error) { + cache.delete(entry.id); + throw error; + } +} + +export async function loadCatalogBook(entry) { + if (!entry) throw new Error('Book not found.'); + if (entry.local) return entry.local; + if (entry.remote) return loadRemoteBook(entry); + throw new Error(`${entry.title} is on the shelf, but its book pack is not ready yet.`); +} From b50ed84331b3cb7dd8efaa96521c4ea257e4e740 Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:33:34 +0700 Subject: [PATCH 003/110] Add reader-first bookshelf --- src/ui/Bookshelf.jsx | 52 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/ui/Bookshelf.jsx diff --git a/src/ui/Bookshelf.jsx b/src/ui/Bookshelf.jsx new file mode 100644 index 0000000..ef82f12 --- /dev/null +++ b/src/ui/Bookshelf.jsx @@ -0,0 +1,52 @@ +import { Link } from 'react-router-dom'; +import { CATALOG } from '../lib/library/catalog.js'; + +export default function Bookshelf() { + return ( +
+
+

Magi Reader

+

Good books, read closely.

+

+ Illustrated readings with narration, subtitles, words you can tap, and a vocabulary + trainer that remembers what you looked up. Wren and her grandfather Ambrose introduce + each book, then get out of the story's way. +

+
+ +
+
+
+

The bookshelf

+

Choose a book

+
+

Books from Git are fetched only when you open them.

+
+ + +
+
+ ); +} From a7d02dc35c4ec8333bb0dbc38e5a8087bcdb3b0c Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:33:44 +0700 Subject: [PATCH 004/110] Load bookshelf books into reader shell --- src/ui/BookRoute.jsx | 73 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 src/ui/BookRoute.jsx diff --git a/src/ui/BookRoute.jsx b/src/ui/BookRoute.jsx new file mode 100644 index 0000000..a191900 --- /dev/null +++ b/src/ui/BookRoute.jsx @@ -0,0 +1,73 @@ +import { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { catalogBook } from '../lib/library/catalog.js'; +import { loadCatalogBook } from '../lib/library/plugin.js'; +import { BookProvider } from './useBook.jsx'; +import Shell from './Shell.jsx'; + +export default function BookRoute() { + const { bookId = '' } = useParams(); + const entry = catalogBook(bookId); + const [state, setState] = useState(() => ({ + book: entry?.local || null, + error: null, + })); + + useEffect(() => { + let alive = true; + if (!entry) { + setState({ book: null, error: new Error('That book is not on this shelf.') }); + return () => { + alive = false; + }; + } + if (entry.local) { + setState({ book: entry.local, error: null }); + return () => { + alive = false; + }; + } + + setState({ book: null, error: null }); + loadCatalogBook(entry) + .then((book) => { + if (alive) setState({ book, error: null }); + }) + .catch((error) => { + if (alive) setState({ book: null, error }); + }); + + return () => { + alive = false; + }; + }, [entry]); + + if (state.error) { + return ( +
+

Bookshelf

+

Could not open this book.

+

{state.error.message}

+ + Back to the bookshelf + +
+ ); + } + + if (!state.book) { + return ( +
+

Getting the book

+

{entry?.title || 'Book'}

+

Fetching the book pack and its reading map from Git…

+
+ ); + } + + return ( + + + + ); +} From 694283aad3dd7f760e9ab1ceff8cc81f3ca43bfe Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:33:58 +0700 Subject: [PATCH 005/110] Add separate literary explainer experience --- src/ui/Explore.jsx | 95 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 src/ui/Explore.jsx diff --git a/src/ui/Explore.jsx b/src/ui/Explore.jsx new file mode 100644 index 0000000..16b36ac --- /dev/null +++ b/src/ui/Explore.jsx @@ -0,0 +1,95 @@ +import { Link } from 'react-router-dom'; +import { useBook } from './useBook.jsx'; + +function plain(value) { + return String(value || '') + .replace(//gi, ' ') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export default function Explore() { + const { book, title } = useBook(); + const authored = book.explore || {}; + const background = Object.values(book.info || {}); + const units = book.units || []; + + return ( +
+
+

Ambrose's notebook

+

Explore {title}

+

+ This is separate from the reading on purpose. Here we can stop, look closely, talk about + context and craft, and follow an idea without interrupting the story itself. +

+
+ + Read the book + + + Practise vocabulary + +
+
+ + {authored.intro ? ( +
+

{authored.intro.title || 'Before you dig in'}

+

{authored.intro.text}

+
+ ) : null} + + {background.length ? ( +
+

Context

+

The world around the text

+
+ {background.map((item, i) => ( +
+

{item.title || item.caption || 'Background'}

+ {item.caption ?

{plain(item.caption)}

: null} + {item.para || item.text ?

{plain(item.para || item.text)}

: null} +
+ ))} +
+
+ ) : null} + +
+

Close reading

+

Walk through the story

+

+ These notes are not questions to answer. They are a second set of eyes: what is happening, + what the writer is doing, and what is worth noticing when you return to the passage. +

+
+ {units.map((unit, i) => ( +
+ {i + 1} +
+

{unit.title || unit.act || `Part ${i + 1}`}

+ {unit.caption ?

{plain(unit.caption)}

: null} + {unit.para ?

{plain(unit.para)}

: null} + {authored.units?.[unit.id] ? ( +

{authored.units[unit.id]}

+ ) : null} +
+
+ ))} +
+
+ +
+

+ The best use of this section is after a first reading, when you already know what happens + and can afford to notice how the writer made it happen. +

+ + Back to the book + +
+
+ ); +} From be185e1969468a597f5e68990abab7ef5563bd63 Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:34:16 +0700 Subject: [PATCH 006/110] Rebuild app around solo reading loop --- src/main.jsx | 285 ++++++--------------------------------------------- 1 file changed, 33 insertions(+), 252 deletions(-) diff --git a/src/main.jsx b/src/main.jsx index dbcc6c5..20ebcfc 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -10,252 +10,75 @@ import { useParams, } from 'react-router-dom'; -import { defaultBook } from './books/index.js'; import { lineFor } from './lib/vocab/text.js'; import { wordsOf } from './lib/vocab/words.js'; import { createSession, advance, answer, progressOf } from './lib/vocab/session.js'; import { loadTapped, saveTapped, tap, practiceSet } from './lib/vocab/tapped.js'; -import { trackFor, stepTrack } from './lib/reader/track.js'; +import { storyTrack, stepTrack } from './lib/reader/track.js'; import { translatorFor } from './lib/book/translate.js'; import { rememberWhere, whereLeftOff, forgetWhere } from './lib/reader/resume.js'; -import { answerQuestion, skipQuestion, write } from './lib/reader/assessment.js'; -import { - loadAttempt, - saveAttempt, - restoreQuiz, - restoreWriting, - snapshotQuiz, - snapshotWriting, -} from './lib/reader/attempt.js'; - -import { buildSubmission } from './lib/reader/assessment.js'; -import { loadStudent, saveStudent, forgetStudent } from './lib/class/student.js'; -import { loadApi } from './lib/class/key.js'; -import { loadOutbox, saveOutbox, queue, flush } from './lib/class/outbox.js'; -import { senderFor } from './lib/class/send.js'; -import Shell from './ui/Shell.jsx'; -import HandIn from './ui/HandIn.jsx'; -import Class from './ui/Class.jsx'; +import Bookshelf from './ui/Bookshelf.jsx'; +import BookRoute from './ui/BookRoute.jsx'; import Gate from './ui/Gate.jsx'; import Reader from './ui/Reader.jsx'; -import Guide from './ui/Guide.jsx'; +import Explore from './ui/Explore.jsx'; import VocabCard from './ui/VocabCard.jsx'; -import { BookProvider, useBook } from './ui/useBook.jsx'; +import { useBook } from './ui/useBook.jsx'; import './styles.css'; -/* --------------------------------------------------------------- - Hash routing, deliberately. - - itch serves a game from a static path with no server to rewrite - URLs, so a browser reload on /read/2/14 would 404 under history - routing. A hash keeps every route reloadable and shareable on a - plain file host, which is where this actually lives. - --------------------------------------------------------------- */ - -/* The teacher's rules would come from the class settings; until phase 5 - wires that up, the reader's own default is the kind one. */ -const RULES = { retry: true }; - /** - * One reading. + * The actual reading. * - * Owns the attempt — the answers and the writing — because those have to - * survive moving between stops, and the position moves through the - * router. The position itself is never held here: it is the URL. + * There is one reading now. No quiz pass, no writing pass, no teacher + * submission state. The route owns only position, translation, playback + * settings and the reader's personal vocabulary trail. */ function ReadingRoute() { - /* Which book, its id and its line counts all come from the app rather - than from module scope. The id is what a student's answers are filed - under, and computing it once when this file loaded meant that the - moment a second book existed, one book's work would be written under - the other's name. `lineCounts` has the same problem more quietly: it - is what refuses a translation that does not line up, so a stale one - refuses a good translation and accepts a bad one. */ const { book, id: bookId, lineCounts } = useBook(); - const { pass = '1', beat = '0' } = useParams(); + const { beat = '0' } = useParams(); const navigate = useNavigate(); - /* The settings live in the shell, and until now they stopped there: - language, sound and pace were all saved, all persisted, and none of - them reached the reading. */ const { settings } = /** @type {{settings: ReturnType}} */ ( useOutletContext() ); - const passNo = [1, 2, 3].includes(Number(pass)) ? Number(pass) : 1; - const track = useMemo(() => trackFor(book, passNo), [book, passNo]); + const track = useMemo(() => storyTrack(book), [book]); const translator = useMemo( () => translatorFor(book, settings.language, lineCounts), [book, settings.language, lineCounts] ); - /* Resumed on the way in rather than started empty, so a tablet that - slept through break does not cost a student their work. */ - const [quiz, setQuiz] = useState(() => restoreQuiz(book, RULES, loadAttempt(bookId, 2))); - const [writing, setWriting] = useState(() => restoreWriting(book, loadAttempt(bookId, 3))); - - useEffect(() => { - saveAttempt(bookId, 2, snapshotQuiz(quiz)); - }, [bookId, quiz]); - useEffect(() => { - saveAttempt(bookId, 3, snapshotWriting(writing)); - }, [bookId, writing]); - - /* The URL is the position. A bad one is corrected rather than - allowed to blank the page — a stale saved index used to do - exactly that in the legacy reader. */ const wanted = Number.parseInt(beat, 10); const safe = stepTrack(track, Number.isFinite(wanted) ? wanted : 0, 0); - const go = useCallback((n) => navigate(`/read/${passNo}/${n}`), [navigate, passNo]); - - const stop = track[safe]; - - /* Answering points the quiz at the question the reader is actually on - first. Walking back to an earlier question and answering it must - change that question, not whichever one the quiz object had reached. */ - const onAnswer = useCallback( - (choice) => { - if (stop?.kind !== 'question') return; - const at = quiz.questions.findIndex((q) => q.id === stop.question.id); - if (at < 0) return; - setQuiz(answerQuestion({ ...quiz, at, done: false }, choice)); - /* Deliberately does not move on. A wrong first answer under "one - more try" shows a hint; a recorded answer shows the explanation - the book wrote for that question, which is the part that teaches - — and auto-advancing scrolled straight past it. Next is the - student's to press, which is what every quiz they have used - already does. */ - }, - [quiz, stop] + const go = useCallback( + (n) => navigate(`/book/${bookId}/read/${n}`), + [navigate, bookId] ); - const onSkip = useCallback(() => { - if (stop?.kind !== 'question') return; - const at = quiz.questions.findIndex((q) => q.id === stop.question.id); - setQuiz(skipQuestion({ ...quiz, at, done: false })); - go(stepTrack(track, safe, 1)); - }, [quiz, stop, track, safe, go]); - - const onWrite = useCallback( - (text) => { - if (stop?.kind !== 'prompt') return; - const at = writing.prompts.findIndex((p) => p.id === stop.prompt.id); - if (at < 0) return; - setWriting((w) => write({ ...w, at }, text)); - }, - [writing, stop] - ); - - /* Written down as they read, so the gate can offer to carry on. */ useEffect(() => { - rememberWhere(bookId, { pass: passNo, at: safe, of: track.length }); - }, [bookId, passNo, safe, track.length]); + rememberWhere(bookId, { pass: 1, at: safe, of: track.length }); + }, [bookId, safe, track.length]); - /** - * A word looked up is a word to practise. - * - * Written straight to storage rather than held in state: nothing on - * this screen renders the list, so holding it would re-render the whole - * reading on every tap for nothing anyone can see. The practice screen - * reads it when it opens. - */ const onTap = useCallback( (word) => saveTapped(bookId, tap(loadTapped(bookId), word)), [bookId] ); - /* ---- handing it in ---- */ - const [student, setStudent] = useState(() => loadStudent()); - const [handedIn, setHandedIn] = useState(() => new Set()); - const api = loadApi(); - - /** - * Write it down, then send it. - * - * In that order, always. Everything after the first step is best - * effort: if the send fails the work is already in the outbox and - * goes next time, and the student is not told, because there is - * nothing they could do about it and the likely response is to hand - * in again. - */ - const onHandIn = useCallback( - async (setStep) => { - const payload = buildSubmission({ book, pass: passNo, student, quiz, writing }); - if (!payload) return; - - const pending = queue(loadOutbox(bookId), payload); - saveOutbox(bookId, pending); - setStep?.(2); - - const { items } = await flush(pending, senderFor(api)); - saveOutbox(bookId, items); - setHandedIn((s) => new Set(s).add(passNo)); - }, - [book, bookId, passNo, student, quiz, writing, api] - ); - - /* The offline path: a file the teacher collects by hand. Named so it - can be found in a Downloads folder with thirty others in it. */ - const onSaveFile = useCallback(() => { - const payload = buildSubmission({ book, pass: passNo, student, quiz, writing }); - if (!payload) return; - const who = [student?.cls, student?.no, student?.name].filter(Boolean).join(' ') || 'work'; - const name = `${who} — reading ${passNo}.json`.replace(/[\\/:*?"<>|]/g, '-'); - - const url = URL.createObjectURL( - new Blob([JSON.stringify(payload, null, 1)], { type: 'application/json' }) - ); - const a = document.createElement('a'); - a.href = url; - a.download = name; - document.body.appendChild(a); - a.click(); - a.remove(); - setTimeout(() => URL.revokeObjectURL(url), 30_000); - }, [book, passNo, student, quiz, writing]); - - const handIn = ( - { - saveStudent(s); - setStudent(s); - }} - onSignOut={() => { - forgetStudent(); - setStudent(null); - }} - onHandIn={onHandIn} - /> - ); - - if (safe !== wanted || String(passNo) !== pass) { - return ; + if (safe !== wanted) { + return ; } return ( ); } @@ -263,14 +86,6 @@ function ReadingRoute() { function PractiseRoute() { const { book, id } = useBook(); const ctx = useMemo(() => { - /* The words this student looked up, newest first, rather than ten at - random from the whole book. Three places in the app promised this - and none of them did it: a student who tapped four words in part - three was offered words from parts they had not reached, and told - those were the ones they had chosen. - - A student who has tapped nothing still gets the whole glossary, - because an empty practice screen is worse than an unfocused one. */ const all = practiceSet(wordsOf(book), loadTapped(id)); return { book, swaps: book.swaps, all }; }, [book, id]); @@ -278,15 +93,12 @@ function PractiseRoute() { const onAnswer = useCallback(({ ok }) => setSession((s) => answer(s, ok)), []); const onNext = useCallback(() => setSession((s) => advance(ctx, s)), [ctx]); - /* Somewhere to go from here, in both states. The trainer used to be a - room with no door: the only way out was the browser's Back button, - and on a tablet in a classroom that is not a way out. */ const doors = (

- - ‹ Back to the start + + ‹ Back to the book - + Back to the reading

@@ -295,9 +107,10 @@ function PractiseRoute() { if (session.done) { return (
-

Finished

+

Vocabulary

+

Good session.

- {session.right} right{session.wrong ? `, ${session.wrong} to revisit` : ''}. + {session.right} right{session.wrong ? `, ${session.wrong} worth seeing again` : ''}.

{doors}
@@ -319,12 +132,6 @@ function PractiseRoute() { ); } -/** - * The gate, with somewhere to carry on from. - * - * The panel was written and styled and had never appeared once, because - * nothing recorded a position and nothing passed one in. - */ function GateRoute() { const { id: bookId } = useBook(); const [where, setWhere] = useState(() => whereLeftOff(bookId)); @@ -340,49 +147,23 @@ function GateRoute() { } const router = createHashRouter([ + { path: '/', element: }, { - path: '/', - element: , + path: '/book/:bookId', + element: , children: [ { index: true, element: }, - { path: 'read/:pass/:beat', element: }, - { path: 'read/:pass', element: }, - { path: 'practise', element: }, - { path: 'class', element: }, - { path: 'guide', element: }, - { path: '*', element: }, + { path: 'read/:beat', element: }, + { path: 'read', element: }, + { path: 'words', element: }, + { path: 'explore', element: }, ], }, + { path: '*', element: }, ]); -/** - * The app, and the one place that decides which book it is. - * - * The routes above no longer name a book, and that is the point: none of - * them can, because the router is a module constant built before any book - * exists. So the book is held here, in state, and handed to the tree — - * which is why the provider wraps the router rather than sitting inside - * it. Inside, the routes would render outside the provider and see - * nothing. - * - * `defaultBook` is what the reader opens with, exactly as before. There - * is no way to change it yet and that is deliberate: choosing a book - * needs somewhere to keep the choice, and the honest place is the URL — - * a decision that belongs with the bookshelf, not ahead of it. Held as - * state anyway, because that is the difference between "one book" and - * "the first book", and it is the whole seam. - */ -function App() { - const [book] = useState(defaultBook); - return ( - - - - ); -} - createRoot(document.getElementById('root')).render( - + ); From f15a44a3f98f90f7b20884f65e671de9ba704ac7 Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:34:34 +0700 Subject: [PATCH 007/110] Remove classroom shell and focus navigation on reading --- src/ui/Shell.jsx | 105 ++++++++++++++--------------------------------- 1 file changed, 31 insertions(+), 74 deletions(-) diff --git a/src/ui/Shell.jsx b/src/ui/Shell.jsx index 4090212..8042003 100644 --- a/src/ui/Shell.jsx +++ b/src/ui/Shell.jsx @@ -1,23 +1,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; +import { Link, NavLink, Outlet, useLocation } from 'react-router-dom'; import Overlay from './Overlay.jsx'; import { UiLanguage, T } from './useUi.jsx'; import { useBook } from './useBook.jsx'; -import { readJoin, saveApi } from '../lib/class/key.js'; import { load, save, documentState, PACES } from '../lib/settings.js'; -/** - * The frame every screen sits in. - * - * The doors are the ones the legacy reader has, in the same place and - * the same order — Vocabulary, Learning guide, Class, Language, Settings - * — because that arrangement is already familiar to anyone using it. The - * difference is underneath: each is a route with a URL, so Back works, - * a page can be bookmarked, and a teacher can send a link to exactly the - * screen they mean. - */ - -/** @type {[string, string][]} */ const READING_SETTINGS = [ ['contrast', 'Higher contrast'], ['bigText', 'Larger text'], @@ -36,7 +23,6 @@ function useSettings() { }); }, []); - /* The document reflects the settings; nothing else reads them off it. */ useEffect(() => { const reduced = typeof matchMedia === 'function' && @@ -50,66 +36,41 @@ function useSettings() { return { settings, set, couldNotSave }; } -/** - * A link the teacher handed out points this device at their Sheet. - * - * Applied once and then taken out of the URL, so that a student who - * bookmarks the reading does not carry the join code around with them, - * and so a reload does not keep re-applying it. - * - * It can only ever set where work is sent — a join code carries no - * identity, so no link can make anybody the teacher. - */ -function useJoinLink() { - const location = useLocation(); - const navigate = useNavigate(); - - useEffect(() => { - const code = new URLSearchParams(location.search).get('join'); - if (!code) return; - - const read = readJoin(code); - if (read?.api) saveApi(read.api); - - const rest = new URLSearchParams(location.search); - rest.delete('join'); - navigate({ pathname: location.pathname, search: rest.toString() }, { replace: true }); - }, [location.search, location.pathname, navigate]); -} - export default function Shell() { - const { book, title } = useBook(); + const { book, id, title } = useBook(); const { settings, set, couldNotSave } = useSettings(); const [panel, setPanel] = useState(/** @type {null|'settings'|'language'} */ (null)); const location = useLocation(); - useJoinLink(); - /* A route change closes any panel: leaving a screen with a modal still - open is how the legacy reader ended up with a guide that would not - shut. */ useEffect(() => setPanel(null), [location.pathname]); const languages = useMemo(() => book.languages || [], [book]); - const reading = location.pathname.startsWith('/read'); + const reading = location.pathname.includes('/read'); + const home = `/book/${id}`; return ( -
+
- - {title} - An illustrated reading - - -
From b7af2d65a88fe6453cb40968afd6ebffe8186926 Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 09:34:46 +0700 Subject: [PATCH 008/110] Use per-line storyboard art when available --- src/lib/reader/beats.js | 61 +++++++++++------------------------------ 1 file changed, 16 insertions(+), 45 deletions(-) diff --git a/src/lib/reader/beats.js b/src/lib/reader/beats.js index 11e5920..9455fe7 100644 --- a/src/lib/reader/beats.js +++ b/src/lib/reader/beats.js @@ -6,11 +6,7 @@ import { plainStanza, inlineGlosses } from '../book/validate.js'; * One beat is one line of the story: a picture, the words, and the clip * that speaks them. The clip ids follow the book's own convention — * `n__`, numbered across the whole unit rather than restarting - * per stanza, which is how the 519 recordings were named. - * - * Pure, so the whole progression can be checked without a browser: that - * beats line up with clips, that no line is skipped, that the last beat - * of a unit really is the last line. + * per stanza, which is how the recordings are named. */ /** @param {Partial|null|undefined} unit */ @@ -25,12 +21,8 @@ export function linesOf(unit) { * The words this unit glosses, and what they mean. * * The book writes them two ways — a `gloss` list on the unit, and - * `{word|meaning}` inline in the stanzas — and a reader does not care - * which. Both are the same promise: this word is hard, here is what it - * means. Keyed lowercase because that is how a token will be looked up. - * - * @param {Partial|null|undefined} unit - * @returns {Record} + * `{word|meaning}` inline in the stanzas. Both are the same promise: + * this word is hard, here is what it means. */ export function glossOf(unit) { /** @type {Record} */ @@ -47,44 +39,31 @@ export function glossOf(unit) { return out; } -/* Relative, with no leading slash. - * - * itch serves a game from a nested path, so "/art/x.webp" resolves - * against the domain root and 404s for every picture. Vite's own bundle - * is already relative via base:'./'; these paths have to agree with it - * or the build works locally and is broken the moment it is uploaded. */ export const MEDIA_BASE = ''; /** - * @param {Partial|null|undefined} unit - * @param {object} [opts] - * @param {(id:string)=>boolean} [opts.hasClip] which recordings exist - * @param {Record} [opts.plates] scene id to picture file - * @param {string} [opts.base] where those files are served from - * @returns {import('../types.js').Beat[]} + * Build the playable line beats for one unit. + * + * The important art rule is line-first: `-` wins when the + * pack provides it, and the unit plate is only the fallback. That is the + * seam the new storyboard pipeline uses — one or two strong key images + * can be authored for every spoken line without changing reader code. */ export function beatsOf(unit, { hasClip, plates = {}, base = MEDIA_BASE } = {}) { if (!unit?.id) return []; const lines = linesOf(unit); const sceneId = unit.scene || unit.id; - /* The art is content-addressed, so the filename is a hash and the map - is the only way from a scene to its picture. Falling back to the - scene id would silently 404 rather than fail loudly. */ - const file = plates[sceneId]; - const plate = { - id: sceneId, - src: file ? `${base}${file}` : null, - /* The alt text is the caption the book already wrote for this scene, - which describes the picture — far better than "illustration". */ - alt: unit.caption || unit.title || 'Scene illustration', - }; - /* Carried on the beat rather than looked up later: the reader has the - line and needs to know which of its words can be tapped, and that - question should not require the unit as well. */ + const fallbackFile = plates[sceneId]; const gloss = glossOf(unit); return lines.map((line, i) => { const clip = `n_${unit.id}_${i}`; + const file = plates[`${sceneId}-${i}`] || fallbackFile; + const plate = { + id: plates[`${sceneId}-${i}`] ? `${sceneId}-${i}` : sceneId, + src: file ? `${base}${file}` : null, + alt: unit.caption || unit.title || 'Scene illustration', + }; return { i, unit: unit.id, @@ -96,19 +75,11 @@ export function beatsOf(unit, { hasClip, plates = {}, base = MEDIA_BASE } = {}) }); } -/** - * Every beat in the book, in reading order. - * @param {import('../types.js').Book|null|undefined} book - * @param {Parameters[1]} [opts] - * @returns {import('../types.js').Beat[]} - */ export function beatsOfBook(book, opts = {}) { const merged = { plates: book?.plates || {}, ...opts }; return (book?.units || []).flatMap((u) => beatsOf(u, merged)); } -/** Move within a unit, clamped — a beat index out of range used to throw - * and blank the page, so this refuses to produce one. */ export function step(beats, index, delta) { if (!beats.length) return 0; const want = Number.isFinite(index) ? Math.floor(index) + delta : 0; From 5c4ed8491751a813c3b528b057fbbb76145e8d63 Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:23:25 +0700 Subject: [PATCH 009/110] Add uninterrupted solo story track --- src/lib/reader/track.js | 171 +++++++++++----------------------------- 1 file changed, 45 insertions(+), 126 deletions(-) diff --git a/src/lib/reader/track.js b/src/lib/reader/track.js index 520a0df..00ad470 100644 --- a/src/lib/reader/track.js +++ b/src/lib/reader/track.js @@ -3,59 +3,66 @@ import { questionsOf, promptsOf } from './assessment.js'; import { reactionsFor, talkFor } from '../speech/script.js'; /** - * One reading, as a single ordered list of stops. + * One stop on a reading track. * - * The three readings are the same story with different work attached, so - * they are one sequence rather than three screens: read a segment, then - * answer what it asked, then read the next. That is the order a lesson - * actually runs in, and it means the position in the URL still means one - * thing — stop number — no matter which reading is open. Back, reload - * and a shared link keep working because there is nothing else to keep. - * - * A stop is `{kind}` plus what that kind needs: - * line a picture, a line, a clip - * say Wren or the Professor, saying one thing - * question a multiple-choice question about the segment just read - * prompt a written prompt about the segment just read - * - * Speech is a stop rather than something laid over the reading, and that - * is the whole of how two characters are stopped from talking at once. - * The shipping reader let Wren fire a reaction into the same band the - * Professor was mid-sentence in; here the reader is on exactly one stop, - * so there is one speaker and one recording, always. A guarantee nobody - * has to remember at the call site. - */ - -/** - * One stop on the track. - * - * Written out rather than inferred so that `kind` is the three strings it - * really is: inferred, it widens to `string`, the three shapes stop being - * a union anyone can narrow, and every `stop.line` in the reader becomes - * a type error for a property that is plainly there. + * The legacy classroom track can still contain dialogue, questions and + * prompts because old tests and old packs know that shape. The solo reader + * uses `storyTrack`, which deliberately contains only story lines and an + * ending. Keeping those two contracts separate makes it impossible for an + * old teaching field to accidentally interrupt a recreational reading. * * @typedef {object} Stop * @property {'line'|'say'|'question'|'prompt'|'end'} kind - * @property {number} at position on the track — what the URL holds + * @property {number} at * @property {string} unit - * @property {number} [i] line stops: which line of the unit + * @property {number} [i] * @property {string} [line] * @property {string|null} [clip] * @property {{id:string, src:string|null, alt:string}} [plate] - * @property {Record} [gloss] the words this unit explains + * @property {Record} [gloss] + * @property {object} [visual] * @property {import('../speech/script.js').Turn} [turn] * @property {any} [question] * @property {any} [prompt] */ /** - * @param {import('../types.js').Book} book - * @param {number} pass 1 read, 2 quiz, 3 written + * The product track: the literary work, uninterrupted. + * + * Wren and Ambrose belong before and after the work, and the deeper + * explanation belongs in Explore. Nothing from `teaching`, `dialogue`, + * `questions`, `writing`, or reaction data is consulted here. + * + * @param {import('../types.js').Book|null|undefined} book * @param {Parameters[1]} [opts] * @returns {Stop[]} */ +export function storyTrack(book, opts = {}) { + const merged = { + plates: book?.plates || {}, + storyboard: book?.storyboard || {}, + ...opts, + }; + /** @type {Omit[]} */ + const out = []; + + for (const unit of book?.units || []) { + for (const beat of beatsOf(unit, merged)) { + out.push({ kind: 'line', unit: unit.id, ...beat }); + } + } + + const lastUnit = out.length ? out[out.length - 1].unit : book?.units?.[0]?.id || ''; + if (out.length) out.push({ kind: 'end', unit: lastUnit }); + return out.map((stop, at) => ({ ...stop, at })); +} + +/** + * Legacy three-pass track retained while the classroom code is being + * removed from the repository. New product code should use `storyTrack`. + */ export function trackFor(book, pass = 1, opts = {}) { - const merged = { plates: book?.plates || {}, ...opts }; + const merged = { plates: book?.plates || {}, storyboard: book?.storyboard || {}, ...opts }; const units = book?.units || []; const questions = pass === 2 ? questionsOf(book) : []; @@ -74,11 +81,6 @@ export function trackFor(book, pass = 1, opts = {}) { /** @type {Omit[]} */ const out = []; for (const u of units) { - /* First time through, the two of them are the work: Wren reacts - where the book says she does, and they talk about the part when it - is over. Readings 2 and 3 have their own task and are not - interrupted — a question is hard enough to answer without someone - talking over the passage it is about. */ const reacts = pass === 1 ? reactionsFor(book, u.id) : new Map(); for (const beat of beatsOf(u, merged)) { @@ -88,25 +90,14 @@ export function trackFor(book, pass = 1, opts = {}) { } if (pass === 1) { - for (const turn of talkFor(book, u.id)) { - out.push({ kind: 'say', unit: u.id, turn }); - } + for (const turn of talkFor(book, u.id)) out.push({ kind: 'say', unit: u.id, turn }); } for (const x of q.get(u.id) || []) out.push({ kind: 'question', unit: u.id, question: x }); for (const x of p.get(u.id) || []) out.push({ kind: 'prompt', unit: u.id, prompt: x }); } - /* Anything asked about material that is not a read segment — the - background notes — comes after the story rather than being dropped. - Losing a question silently would show up as a class where the marks - do not add up, which is the worst way to find a bug. */ const placed = new Set(units.map((u) => u.id)); const extras = Object.keys(book?.info || {}).filter((id) => !placed.has(id)); - - /* The background pages have pictures too, and they are the pictures a - student is being asked about. Without this the author page and the - note on the story's afterlife showed a black rectangle where the - scene should be. */ const infoPlate = (id) => { const info = book?.info?.[id]; const file = merged.plates[info?.scene || id]; @@ -119,75 +110,25 @@ export function trackFor(book, pass = 1, opts = {}) { }; if (pass === 1) { - /* The author page and the note on why the story lasted are not read - aloud, but the two of them have a conversation about each. Ten - turns of it, which the first draft dropped on the floor because - they hang off units that are not segments. */ for (const id of extras) { const plate = infoPlate(id); for (const turn of talkFor(book, id)) out.push({ kind: 'say', unit: id, turn, plate }); } } for (const x of questions) - if (!placed.has(x.unit)) - out.push({ kind: 'question', unit: x.unit, question: x, plate: infoPlate(x.unit) }); + if (!placed.has(x.unit)) out.push({ kind: 'question', unit: x.unit, question: x, plate: infoPlate(x.unit) }); for (const x of prompts) - if (!placed.has(x.unit)) - out.push({ kind: 'prompt', unit: x.unit, prompt: x, plate: infoPlate(x.unit) }); + if (!placed.has(x.unit)) out.push({ kind: 'prompt', unit: x.unit, prompt: x, plate: infoPlate(x.unit) }); - /* The reading ends, rather than running out. - * - * It used to stop dead: the last stop was the twenty-eighth question, - * and once it was answered there was a greyed-out Next and nothing - * else — no score, no acknowledgement, nowhere to go. Making the - * ending a stop of its own also stops the finish card being stacked - * underneath a question the student has not answered yet. */ const lastUnit = out.length ? out[out.length - 1].unit : units[0]?.id || ''; if (out.length) out.push({ kind: 'end', unit: lastUnit }); - return out.map((stop, i) => ({ ...stop, at: i })); } -/** - * The unit a stop belongs to, whether or not it is read aloud. - * - * The author page and the note on the story's afterlife are not - * segments, but they have a title, an act and a picture like everything - * else — and without this the storyboard listed them as "ohenry" and - * "impact", which is an internal id showing through to a child. - * - * @param {import('../types.js').Book|null|undefined} book - * @param {string} id - */ -/** - * The one thing to look for, at the moment a part begins. - * - * Every part carries a `watch` line for the first reading and a `focus` - * line for the second, both written as "before this bit, notice X". All - * fourteen parts of the shipping book have both, they are translated - * into every language the picker offers, and the printed guide tells - * students "before each part you are told one thing to look for". - * - * Nothing ever put them on screen. They rendered in the guide and - * nowhere else, so the mechanism the second reading is built around was - * authored, translated, documented, and invisible to every student who - * has used this. - * - * Returned only for the FIRST stop of a part. Repeating it under every - * line would turn a prompt into wallpaper, and the point of aiming - * attention is that it is aimed once. - * - * @param {object} book - * @param {number} pass - * @param {any[]} track - * @param {number} at - * @returns {string|null} - */ export function aimAt(book, pass, track, at) { if (pass !== 1 && pass !== 2) return null; const stop = track?.[at]; if (!stop?.unit) return null; - /* the stop before it belonged to another part, or there is none */ if (at > 0 && track[at - 1]?.unit === stop.unit) return null; const teaching = book?.teaching?.[stop.unit]; const text = pass === 1 ? teaching?.watch : teaching?.focus; @@ -198,29 +139,12 @@ export function unitLike(book, id) { return (book?.units || []).find((u) => u.id === id) || book?.info?.[id] || null; } -/** - * Clamp a position onto the track. Never produces one that is not there. - * @param {Stop[]} track - * @param {number} index - * @param {number} delta - */ export function stepTrack(track, index, delta) { if (!track.length) return 0; const want = Number.isFinite(index) ? Math.floor(index) + delta : 0; return Math.max(0, Math.min(track.length - 1, want)); } -/** - * The segments, for the storyboard. - * - * Twelve dots were readable; a hundred are not, and the book is meant to - * take more than one story. So navigation is by segment — the picture and - * its title — and the position within a segment is a bar, not a dot per - * line. - * - * @param {Stop[]} track - * @param {import('../types.js').Book|null|undefined} book - */ export function segmentsOf(track, book) { const out = []; const index = new Map(); @@ -257,7 +181,6 @@ export function segmentsOf(track, book) { return out; } -/** Which segment a position is in, and how far through it. */ export function whereIn(segments, at) { const i = segments.findIndex((s) => at >= s.from && at <= s.to); const seg = segments[i] ?? null; @@ -270,13 +193,9 @@ export function whereIn(segments, at) { }; } -/** The first stop of the segment before / after this one. */ export function jumpSegment(segments, at, delta) { const { index } = whereIn(segments, at); if (index < 0) return at; - /* Back, from partway through a segment, means the top of this one — - the same thing the back button on a music player does, and for the - same reason: it is the move people reach for far more often. */ if (delta < 0 && at > segments[index].from) return segments[index].from; const next = Math.max(0, Math.min(segments.length - 1, index + delta)); return segments[next].from; From 19698573137fc3962686570c6ec0db8895329d1e Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:23:43 +0700 Subject: [PATCH 010/110] Add storyboard visual contract to reading beats --- src/lib/reader/beats.js | 82 +++++++++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/src/lib/reader/beats.js b/src/lib/reader/beats.js index 9455fe7..09de6bd 100644 --- a/src/lib/reader/beats.js +++ b/src/lib/reader/beats.js @@ -1,15 +1,5 @@ import { plainStanza, inlineGlosses } from '../book/validate.js'; -/** - * A unit, cut into the beats the reader actually plays. - * - * One beat is one line of the story: a picture, the words, and the clip - * that speaks them. The clip ids follow the book's own convention — - * `n__`, numbered across the whole unit rather than restarting - * per stanza, which is how the recordings are named. - */ - -/** @param {Partial|null|undefined} unit */ export function linesOf(unit) { return (unit?.stanzas || []) .flatMap((sz) => plainStanza(String(sz)).split('\n')) @@ -17,13 +7,6 @@ export function linesOf(unit) { .filter(Boolean); } -/** - * The words this unit glosses, and what they mean. - * - * The book writes them two ways — a `gloss` list on the unit, and - * `{word|meaning}` inline in the stanzas. Both are the same promise: - * this word is hard, here is what it means. - */ export function glossOf(unit) { /** @type {Record} */ const out = {}; @@ -41,15 +24,43 @@ export function glossOf(unit) { export const MEDIA_BASE = ''; +function visualFor(storyboard, unit, sceneId, i) { + if (!storyboard) return null; + const keyed = storyboard[`${sceneId}-${i}`] || storyboard[`${unit.id}-${i}`]; + if (keyed) return keyed; + const grouped = storyboard[unit.id] || storyboard[sceneId]; + if (Array.isArray(grouped)) return grouped[i] || null; + if (grouped && typeof grouped === 'object') return grouped[String(i)] || null; + return null; +} + /** - * Build the playable line beats for one unit. + * Build one narrated line. * - * The important art rule is line-first: `-` wins when the - * pack provides it, and the unit plate is only the fallback. That is the - * seam the new storyboard pipeline uses — one or two strong key images - * can be authored for every spoken line without changing reader code. + * A book may supply only a plate, a line-specific plate, or a full visual + * storyboard entry. Storyboard entries are intentionally descriptive as + * well as playable so the same JSON can be handed to an art/video model: + * + * { + * start: 'art/s1-0-a.webp', + * end: 'art/s1-0-b.webp', + * clip: 'video/s1-0.mp4', + * shot: 'medium close-up', + * camera: 'slow push toward Della', + * action: 'she counts the last pennies twice', + * mood: 'private worry, not melodrama', + * duration: 6 + * } + * + * `start` is the canonical key image. `end` is optional but strongly + * preferred when a generated clip needs controlled motion. `clip` is the + * finished visual animation; when it is absent the reader displays the + * key image instead. */ -export function beatsOf(unit, { hasClip, plates = {}, base = MEDIA_BASE } = {}) { +export function beatsOf( + unit, + { hasClip, plates = {}, storyboard = {}, base = MEDIA_BASE } = {} +) { if (!unit?.id) return []; const lines = linesOf(unit); const sceneId = unit.scene || unit.id; @@ -58,12 +69,22 @@ export function beatsOf(unit, { hasClip, plates = {}, base = MEDIA_BASE } = {}) return lines.map((line, i) => { const clip = `n_${unit.id}_${i}`; - const file = plates[`${sceneId}-${i}`] || fallbackFile; + const lineFile = plates[`${sceneId}-${i}`] || plates[`${unit.id}-${i}`]; + const file = lineFile || fallbackFile; + const authoredVisual = visualFor(storyboard, unit, sceneId, i); + const visual = authoredVisual + ? { + ...authoredVisual, + start: authoredVisual.start || (file ? `${base}${file}` : null), + } + : null; + const plateSrc = visual?.start || (file ? `${base}${file}` : null); const plate = { - id: plates[`${sceneId}-${i}`] ? `${sceneId}-${i}` : sceneId, - src: file ? `${base}${file}` : null, - alt: unit.caption || unit.title || 'Scene illustration', + id: lineFile ? `${sceneId}-${i}` : sceneId, + src: plateSrc, + alt: visual?.alt || unit.caption || unit.title || 'Scene illustration', }; + return { i, unit: unit.id, @@ -71,12 +92,17 @@ export function beatsOf(unit, { hasClip, plates = {}, base = MEDIA_BASE } = {}) clip: hasClip && !hasClip(clip) ? null : clip, plate, gloss, + visual, }; }); } export function beatsOfBook(book, opts = {}) { - const merged = { plates: book?.plates || {}, ...opts }; + const merged = { + plates: book?.plates || {}, + storyboard: book?.storyboard || {}, + ...opts, + }; return (book?.units || []).flatMap((u) => beatsOf(u, merged)); } From 6ef2486970ef25dbf85835000b1cc86a15b7a8e5 Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:25:41 +0700 Subject: [PATCH 011/110] Play authored visual clips with narration --- src/ui/Scene.jsx | 104 ++++++++++++++++++++++------------------------- 1 file changed, 48 insertions(+), 56 deletions(-) diff --git a/src/ui/Scene.jsx b/src/ui/Scene.jsx index 7c7773e..1674e07 100644 --- a/src/ui/Scene.jsx +++ b/src/ui/Scene.jsx @@ -4,44 +4,20 @@ import { useSpokenLine } from './useSpokenLine.js'; import SpokenText from './SpokenText.jsx'; /** - * The picture window: one image, one line of narration over it. + * One narrated story line. * - * Two rules the old reader learned the hard way and this keeps: - * - * The picture is never cropped. `object-fit: contain` shows the whole - * frame, because a reader that cuts off faces is worse than one with - * letterboxing. Any Ken Burns move starts from the full frame. - * - * The subtitle sits ON the picture, once. The same sentence used to - * appear three times on one screen — as the big line, as a caption and - * again in the translation panel — which is what made the page long - * enough to scroll and made the frame drift as text advanced. - */ - -/** - * @param {object} props - * @param {{id:string,src:string|null,alt:string}} props.plate - * @param {string} props.line the words, from the book itself - * @param {string|null} [props.clip] audio id, e.g. "n_s1_0" - * @param {string} [props.audioBase] - * @param {string} [props.cuesUrl] one WebVTT file for the whole book - * @param {string|null} [props.translation] the same line, in the reader's language - * @param {string} [props.lang] BCP-47 tag for that translation - * @param {Record} [props.gloss] the words this unit explains - * @param {(w:string)=>string|null} [props.wordIn] those meanings, translated - * @param {(w:string)=>void} [props.onTap] told which word was looked up - * @param {boolean} [props.playing] - * @param {boolean} [props.muted] - * @param {number} [props.rate] - * @param {()=>void} [props.onEnded] + * Narration owns time and progression. Visual media is deliberately + * subordinate to it: a generated clip is muted, plays alongside the + * narration, and never advances the reader on its own. If a clip is not + * ready yet, the same storyboard entry still works with one or two key + * images, which is what lets art production proceed line by line. */ export default function Scene({ plate, + visual = null, + motion = true, line, clip, - /* Where this book's media sits, from the pack. No default: a default - here would be one book's folder name living in the engine, which is - the whole thing the pack format exists to stop. */ audioBase = '', cuesUrl = '', translation = null, @@ -55,12 +31,9 @@ export default function Scene({ onEnded, }) { const audioRef = useRef(null); + const videoRef = useRef(null); const { words, index } = useCueTrack(audioRef, clip, cuesUrl); - /* Set on the element rather than passed as an attribute: React does - not reflect `muted` to the DOM property reliably, and playbackRate - has no attribute at all. Both are reapplied whenever the clip - changes, because a new element starts at the defaults. */ useEffect(() => { const el = audioRef.current; if (!el) return; @@ -68,9 +41,6 @@ export default function Scene({ el.playbackRate = rate; }, [muted, rate, clip]); - /* Play/pause is driven by the prop, and a rejected play() is not an - error worth surfacing: browsers refuse autoplay until the reader has - interacted, which is normal and recoverable. */ useEffect(() => { const el = audioRef.current; if (!el) return; @@ -82,24 +52,50 @@ export default function Scene({ } }, [playing, clip]); - /* ------------------------------------------------------------ - The line is always on screen; the highlighting is an extra. - - The first version rendered only the words parsed from the cue file, - so before that fetch resolved — or if it failed, or if a clip had no - recording — the subtitle was empty and the student had a picture - with no text at all. + /* Visual clips follow the narration but do not control it. A shorter + clip simply rests on its last frame; a longer one is paused when the + narration advances to the next line. */ + useEffect(() => { + const el = videoRef.current; + if (!el) return; + el.playbackRate = rate; + if (playing && motion) { + const p = el.play(); + if (p && typeof p.catch === 'function') p.catch(() => {}); + } else { + el.pause(); + } + }, [playing, motion, rate, visual?.clip]); - The second version rendered them once the fetch DID resolve, which - was worse and harder to see: the cue text is a transcript with no - punctuation, so the moment the audio loaded, O. Henry lost every - comma he wrote. The words come from the book. Always. - ------------------------------------------------------------ */ const { tokens, lit: litIndex } = useSpokenLine(line, words, index); + const hasVideo = motion && !!visual?.clip; + const hasPair = motion && !hasVideo && !!visual?.end && !!(visual?.start || plate.src); + const duration = Number(visual?.duration) > 0 ? Number(visual.duration) : 5; return (
- {plate.src ? ( + {hasVideo ? ( +
-

Books from Git are fetched only when you open them.

+

Git books are fetched only when you open them.

    @@ -34,6 +34,7 @@ export default function Bookshelf() {

    {entry.title}

    {entry.author}

    {entry.note}

    + {entry.mediaNote ?

    {entry.mediaNote}

    : null} {ready ? ( {entry.local ? 'Open book' : 'Get and open'} From 60bdaf50e93b3243df4169290ae9e94eddad78c1 Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:32:04 +0700 Subject: [PATCH 027/110] Protect uninterrupted solo reading contract --- src/lib/reader/solo.test.js | 94 +++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/lib/reader/solo.test.js diff --git a/src/lib/reader/solo.test.js b/src/lib/reader/solo.test.js new file mode 100644 index 0000000..afeebd7 --- /dev/null +++ b/src/lib/reader/solo.test.js @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { beatsOf } from './beats.js'; +import { storyTrack } from './track.js'; + +const book = { + meta: { id: 'solo-fixture', title: 'A Small Story' }, + plates: { + s1: 'art/s1.webp', + 's1-0': 'art/s1-0.webp', + }, + storyboard: { + 's1-0': { + start: 'art/start.webp', + end: 'art/end.webp', + clip: 'video/s1-0.mp4', + camera: 'slow push in', + action: 'the reader looks up', + mood: 'quietly curious', + duration: 6, + }, + }, + units: [ + { + id: 's1', + title: 'First scene', + caption: 'A quiet room.', + stanzas: ['First line.\nSecond line.'], + gloss: [['quiet', 'making little sound']], + }, + ], + teaching: { + s1: { watch: 'This must never interrupt the solo reading.' }, + }, + wrenReactions: { + s1: [{ at: 0, line: 'Nor should this.' }], + }, + dialogue: { + s1: [{ who: 'w', text: 'Old classroom conversation.' }], + }, + questions: [{ id: 'q1', unit: 's1', q: 'A question.' }], + writing: [{ id: 'w1', unit: 's1', q: 'A prompt.' }], +}; + +describe('solo reading track', () => { + it('contains only the literary work and a real ending', () => { + const track = storyTrack(book); + expect(track.map((stop) => stop.kind)).toEqual(['line', 'line', 'end']); + expect(track.filter((stop) => stop.kind === 'line').map((stop) => stop.line)).toEqual([ + 'First line.', + 'Second line.', + ]); + }); + + it('cannot leak teaching, questions, prompts or guide reactions into the story', () => { + const serialized = JSON.stringify(storyTrack(book)); + expect(serialized).not.toContain('interrupt the solo reading'); + expect(serialized).not.toContain('Nor should this'); + expect(serialized).not.toContain('Old classroom conversation'); + expect(serialized).not.toContain('A question'); + expect(serialized).not.toContain('A prompt'); + }); + + it('carries the exact storyboard packet on the line it belongs to', () => { + const first = storyTrack(book)[0]; + expect(first.visual).toMatchObject({ + start: 'art/start.webp', + end: 'art/end.webp', + clip: 'video/s1-0.mp4', + camera: 'slow push in', + action: 'the reader looks up', + mood: 'quietly curious', + duration: 6, + }); + expect(first.plate.src).toBe('art/start.webp'); + }); +}); + +describe('visual fallback order', () => { + it('uses a line keyframe before a generic scene plate', () => { + const [beat] = beatsOf(book.units[0], { + plates: book.plates, + storyboard: {}, + }); + expect(beat.plate.src).toBe('art/s1-0.webp'); + }); + + it('uses the storyboard start frame before either plate', () => { + const [beat] = beatsOf(book.units[0], { + plates: book.plates, + storyboard: book.storyboard, + }); + expect(beat.plate.src).toBe('art/start.webp'); + }); +}); From 22d44a667f17c16883b274e9ec384e10d83206d0 Mon Sep 17 00:00:00 2001 From: Dan Cockrell <173971169+dancockrell@users.noreply.github.com> Date: Tue, 1 Sep 2026 12:32:24 +0700 Subject: [PATCH 028/110] Make Explore an expert literary companion --- src/ui/Explore.jsx | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/ui/Explore.jsx b/src/ui/Explore.jsx index 16b36ac..278c481 100644 --- a/src/ui/Explore.jsx +++ b/src/ui/Explore.jsx @@ -14,6 +14,7 @@ export default function Explore() { const authored = book.explore || {}; const background = Object.values(book.info || {}); const units = book.units || []; + const lenses = authored.lenses || []; return (
    @@ -21,8 +22,9 @@ export default function Explore() {

    Ambrose's notebook

    Explore {title}

    - This is separate from the reading on purpose. Here we can stop, look closely, talk about - context and craft, and follow an idea without interrupting the story itself. + This is the conversation we deliberately kept out of the reading. Here we can stop, + look closely, argue with an interpretation, chase a historical detail, and notice how + the writer made the language work.

    @@ -36,11 +38,33 @@ export default function Explore() { {authored.intro ? (
    +

    From Ambrose

    {authored.intro.title || 'Before you dig in'}

    {authored.intro.text}

    ) : null} + {lenses.length ? ( +
    +

    Big ideas

    +

    Ways into the book

    +

    + These are lenses, not answers. A good interpretation should make more of the text + visible; if a lens makes the text smaller or duller, put it down. +

    +
    + {lenses.map((lens, i) => ( +
    + {lens.kicker || 'Ambrose notices'} +

    {lens.title}

    +

    {lens.text}

    + {lens.lookFor ?

    Look back at: {lens.lookFor}

    : null} +
    + ))} +
    +
    + ) : null} + {background.length ? (

    Context

    @@ -59,10 +83,10 @@ export default function Explore() {

    Close reading

    -

    Walk through the story

    +

    Walk through the text

    - These notes are not questions to answer. They are a second set of eyes: what is happening, - what the writer is doing, and what is worth noticing when you return to the passage. + No quiz is hiding here. These notes are a second set of eyes: what is happening, what + the writer is doing, and what becomes more interesting when you read the passage again.

    {units.map((unit, i) => ( @@ -83,8 +107,8 @@ export default function Explore() {