Interactive lesson mode
Any lesson on the hub can be worked through instead of read. Press Start lesson on a lesson page (or open /hub/:id/practice directly) and the lesson takes over the screen: a section's material appears on its own, one step at a time, then that section's questions appear one after another, each with a field to type an answer into.
It has its own URL (it is a tab of the lesson; see Pages & routing), so a teacher can send a class straight into the walkthrough rather than to the lesson with an instruction to press a button. Closing it returns to the lesson. The component itself is still a dialog, deliberately: it is a focus mode with its own bottom bars and its own idea of the viewport, and the route only decides when it is open.
You don't have to finish in one sitting: what you type is kept in your browser as you go, and the lesson reopens where you left it. See Picking up where you left off.
At the end you get a summary of everything you wrote, and, if you're signed in, it is saved privately to your account. Nobody else can read it, including the person who wrote the lesson. See Privacy below, which is the part of this feature worth being precise about.
The lesson can also be read aloud with the browser's built-in speech synthesis, on the reader's own device. See Reading aloud.
Someone presenting a lesson to a class can turn on show answers to see the author's answer to each question alongside it.
Every existing lesson already works
There is no "interactive lesson" document type and nothing to switch on when authoring. The walkthrough is derived from the lesson document you already have (packages/core/src/interactive.js), so every lesson ever published works, including ones made long before this feature existed and ones written by the MCP server. Nothing is added to a lesson to make it playable, and a lesson stays exactly as printable as it was.
The rules that turn a document into steps:
| In the document | Becomes |
|---|---|
| A section's text, image, spelling and VAKT blocks | One content step, holding them together in document order. |
| Each question block | One question step, with a text field, after that section's content. |
| A section with only questions | No content step; it opens straight on its first question. |
| A section with nothing in it | Nothing. |
| A lesson with no questions at all | A read-through: every content step, no answer fields, nothing saved. |
Text blocks keep their bold, italics and underlining, but not their footnote markers: this is the screen the speller reads, and a superscript number with nowhere on the screen to lead to is clutter there. The voice reads the plain words (see Formatting, footnotes & sources).
Questions are numbered from 1 within each section, matching the editor's Q7 numbering (see Navigating large lessons).
What it looks like
Interactive mode is full-screen and drawn in the app's own theme, light or dark, as is the lesson page below it, which follows the theme too rather than reproducing the white sheet the DOCX/PDF export produces. What's different here is the scale: a surface you read and answer on for twenty minutes gets its own treatment, so the blocks are re-rendered: prose at reading size, images framed in the app's border and radius and sized by the reading column rather than by the size and alignment they carry (so a picture fills the width on a phone), spelling words as cards you could read across a room, and a VAKT activity set apart as a red-edged card so whoever is presenting spots it mid-passage and stops. Only the presentation differs; the content is the same blocks.
A progress bar across the top counts the steps and how many questions you've answered so far.
Showing the answers (for whoever is presenting)
The eye button in the top bar turns on show answers, and each question then displays the answer its author wrote, under the field you type into. It's there for the person running the lesson at the front of a room, who would otherwise keep the lesson open in a second window to see what they're walking a class towards.
- It starts off every time interactive mode opens. Unlike the speech settings it isn't remembered, so a learner's own run-through never begins with the answers on screen.
- The button is only rendered when the lesson has an answer to reveal somewhere; a lesson of purely open-ended questions has nothing behind it.
- Each question type shows what it stores: the answer for a single, number or background question, the working steps as well for a number question, and every accepted answer for a multiple-answer one. An open-ended question, which by design has no author's answer, says so rather than leaving a gap.
- A suggested-answers question labels its reveal as suggestions and says that anything fitting the topic counts. Whoever is looking at the reveal is usually the person deciding whether the learner was right, and for that one type the decision is genuinely theirs: reading three boxes as the only right answers would mark a learner wrong for an answer the question was written to accept.
- Every answer gets a box of its own, so a multiple-answer question shows three answers as three things rather than as a bulleted list under one heading. They're stacked as equals rather than numbered: any of them is a right answer, and a list numbered 1, 2, 3 reads as an order to give them in. A number question's working is not one of these boxes: it's how you reach the answer, not an answer, and it stays a numbered list below them.
- Clicking an answer puts it in your field. It replaces what's there (a multiple-answer question wants one of its accepted answers, not all of them run together), then focuses the field with the caret at the end, since the point of putting text there is usually to keep working on it. What lands in the field is from then on your own answer: it counts as answered, it's kept by progress, and it's what gets filed at the end. A shortcut through typing, not a verdict; see below.
- The reveal also applies to the end-of-lesson summary, where the author's answer sits under the one you wrote. Useful for going back over the questions as a class. The boxes are read-only there: there is no field to fill on the summary, so nothing there is clickable.
- Answers are still never spoken; see Reading aloud.
Showing an answer is not marking one; see below.
Picking up where you left off
A lesson is twenty minutes of typing, and a bell goes, or a tab gets closed, or a laptop lid comes down. So the run-through you are in the middle of is written to your browser as you work (every answer and which step you were on), and opening the lesson again drops you back exactly there, with what you'd typed still in the fields. The lesson page's button says Continue lesson rather than Start lesson when there is something to come back to, and the step you resume at says so, with a Start again button beside it for when the thing waiting is somebody else's half-finished attempt.
This is deliberately not the same mechanism as the saved run-throughs above, and the differences are the point:
| Progress (unfinished) | A saved run-through (finished) | |
|---|---|---|
| Lives in | this browser (localStorage) | your account, on the server |
| Needs | nothing; signed out works too | a signed-in session |
| Travels | no: this device only | yes: any device you sign in on |
| Kept until | it is filed, you start again or discard it, or 90 days pass | you delete it |
| Anyone else? | never sent anywhere at all | only you can read it |
Consequences worth knowing:
- Progress does not follow you between devices. Starting on a school desktop and finishing on a phone still starts over. Syncing it would mean putting half-written answers on the server, which is a much bigger promise than "your tab remembers", and this feature isn't worth making it.
- Records are kept per signed-in learner as well as per lesson. A shared classroom machine is the normal case here, and resuming into whatever the previous user typed would be worse than not resuming at all. Two learners who are both signed out do share one record per lesson, because there is nothing to tell them apart: the browser is the only identity on offer. That is the same bargain as a half-filled form left in a shared browser, and it is why a resumed run-through always says so and offers Start again rather than quietly continuing.
- A run-through belongs to whoever started it. The signed-in account can change with the walkthrough open (a sign-in in another tab, a sign-out), and the run then carries on writing to the record it began in, rather than moving one person's half-written answers into the account that just appeared. Pressing Finish in that state doesn't file them either; the summary says why, and the answers stay on the device for the learner they belong to. This is why the copy has always said to sign in before you start.
- A browser keeps the 20 most recently touched lessons and forgets a run-through nobody came back to within 90 days. Pruning is fine here in a way it explicitly isn't for saved run-throughs: this is a resume cache, not the only copy of anything you chose to keep.
- Closing mid-way no longer discards anything, so the confirmation on the way out now says that instead of warning about it, and carries a Discard answers button for deliberately throwing the attempt away. Where the browser refuses us storage (private browsing, a full quota) or has none at all, the old warning comes back, because by then it is true again: every write reports whether it landed, and the confirmation only promises what was actually kept.
- The local copy is dropped as soon as the run-through is filed to your account. A failed save deliberately leaves it, so closing and coming back is a way to try again rather than a way to lose the lot. Signed out, where saving was never possible, it also stays, since it is the only copy there is.
- Answers are keyed by block id, so a lesson edited between two sittings still matches each answer to its question. The step you were on is remembered by key rather than by number for the same reason; if that step has since been deleted the lesson opens at the top, with the answers still restored.
What it deliberately doesn't do
It doesn't mark your answers. Nothing you type is ever compared against the author's answer, and no verdict is ever drawn: not while answering, not on the summary, and not with the reveal above turned on, which only puts the two side by side. Spelling is about the learner producing the response; a right/wrong verdict from a string comparison would be wrong a lot of the time and the wrong shape of feedback even when it wasn't.
Taking an author's answer into your field by clicking it doesn't change that. It's a deliberate act on a panel you had to switch on, and all it does is save you typing: the answer that lands there is treated exactly like one you wrote yourself, and nothing anywhere notices where it came from.
It doesn't store a half-finished run-through as a completed one. Progress is a browser-side scratchpad; nothing reaches lesson_responses until you press Finish, and the Your answers panel only ever lists run-throughs that were finished.
Privacy: who can read your answers
Only you.
- Every endpoint that touches saved answers requires a signed-in session, and the Worker scopes each query to
user_id = <verified caller>; that filter is the only way a row is ever addressed, not a check layered on top of one. - There is no endpoint that returns another user's answers. Not for the lesson's author, not for a moderator, not for an admin. A lesson author can see that their lesson exists and who commented on it; they cannot see who worked through it or what they wrote.
- The
lesson_responsestable has no public read policy, unlikelessons,commentsandratings. - Answers are not run through the profanity filter that comments go through. There's no audience to protect: nobody but their author ever reads them.
- The in-progress copy described in Picking up where you left off is narrower still: it never leaves the device. There is no endpoint behind it, nothing to scope by user id server-side, and no new way for anyone (author, moderator, admin) to learn that a lesson was even opened.
Your saved run-throughs appear in a Your answers panel on the lesson page, below the lesson itself and above the comments. It renders for you and nobody else, and each one can be deleted outright.
Signed out, you can still work through a lesson start to finish and see your summary; there's just no account to save it to, and the summary says so.
Reading aloud (text-to-speech)
The speaker button in the top bar turns on read aloud, using the browser's Web Speech API (speechSynthesis). Like lesson summaries, this runs entirely on the reader's own device: no Worker call, no API key, no cost, and the lesson text never leaves the machine. Unlike summaries, it needs no special hardware and is supported across current browsers, but it's still probed for rather than assumed, and where it's missing the controls aren't rendered at all.
With it on:
- each step is read as it appears: the section name, then the prose, image captions, or the question prompt;
- a replay button re-reads the current step (and turns into a stop button while it's speaking);
- every spelling word gets its own speaker button, because hearing one word again is the commonest thing a learner wants and a different job from hearing the whole step;
- the settings popover picks a voice from the ones the browser offers and a pace from 0.7x to 1.5x.
A question's answer is never spoken, even with show answers on: speech is a learner's setting as often as a presenter's, and saying the answer out loud the moment a question appears would give it away to the one person meant to be working it out. Revealing it on screen is a deliberate act; speaking it would be a side effect of one.
Your choice of on/off, voice and pace is remembered in localStorage, so someone who needs speech doesn't re-enable it on every lesson. The same three settings appear under Reading aloud on the settings page, for anyone who would rather set them up before starting a lesson than from the popover mid-walkthrough; both read and write the same keys through apps/web/src/lib/speechPrefs.js. A change made in one reaches the other the next time it's opened, not while both are on screen.
On a browser with no speech synthesis neither the controls nor the settings section is rendered at all, rather than offering a button that can't work.
Three platform quirks are handled between the two files. speechPrefs.js takes the one that belongs to the voice list: voices load asynchronously, announced by voiceschanged. useSpeech.js takes the two that belong to speaking: Chromium cuts off a single utterance after about 15 seconds (so text is split into sentence-sized chunks and queued), and cancel() isn't synchronous (so a new utterance is deferred a tick after one).
Worker endpoints
| Method & path | Auth | Response |
|---|---|---|
GET /lessons/:id/responses | Bearer <Supabase JWT> | { "responses": [{ id, lessonId, answers, completedAt }] }; the caller's own only, newest first |
POST /lessons/:id/responses | Bearer <Supabase JWT> | { "response": { id, lessonId, answers, completedAt } } |
DELETE /lessons/:id/responses/:rid | Bearer <Supabase JWT> | { "ok": true }, the caller's own only; else 404 |
POSTbody is{ answers }, whereanswersis one entry per question:{ blockId, sectionId, sectionName, questionType, prompt, answer }. The Worker normalises every field to a string of known maximum length and drops anything else, so the storedjsonbcan only hold that shape.- The prompt is snapshotted alongside the answer on purpose: a saved run-through has to stay readable after the lesson is edited, re-ordered, or has that question deleted.
- Skipped questions are stored as blank answers rather than dropped, so the set still says which questions were asked.
- Limits (shared between browser and Worker in
packages/core/src/interactive.js): 5,000 characters per answer and 500 answers per submission. - You may keep 20 saved run-throughs of any one lesson. Past that a
POSTis rejected with409and a message asking you to delete an older one, rejected rather than silently pruning the oldest, for the same reason the draft cap is: they're the user's own answers, and quietly deleting them to make room isn't ours to decide. POSTalso checks the lesson is one the caller could have read in the first place: published and not shadowbanned, or theirs / trusted / moderated.
Supabase schema
create table if not exists public.lesson_responses (
id uuid primary key default gen_random_uuid(),
lesson_id uuid not null references public.lessons (id) on delete cascade,
user_id uuid not null references auth.users (id) on delete cascade,
answers jsonb not null,
completed_at timestamptz not null default now()
);
create index if not exists lesson_responses_user_lesson_idx
on public.lesson_responses (user_id, lesson_id, completed_at desc);
-- No public read policy, unlike lessons/comments/ratings: this data is private.
alter table public.lesson_responses enable row level security;The full schema, with the reasoning in comments, is apps/api/schema.sql.
Where the code lives
| File | What it does |
|---|---|
packages/core/src/interactive.js | Turns a document into steps; the answers the reveal shows; limits and validation. |
packages/core/src/browser/interactiveProgress.js | The unfinished run-through kept on the device: resume, expiry, pruning. |
packages/core/src/lessonResponses.js | Client for the three endpoints above. |
apps/api/src/routes/lessonResponses.js | The endpoints, and the privacy scoping. |
apps/web/src/components/InteractiveLesson.jsx | The full-screen walkthrough. |
apps/web/src/components/MyLessonAnswers.jsx | The private "Your answers" panel on the lesson page. |
apps/web/src/pages/lesson/LessonLayout.jsx | Start vs. Continue lesson on the lesson page's button. |
apps/web/src/lib/useSpeech.js | Web Speech API wrapper: speaking, and the two platform quirks it owns. |
apps/web/src/lib/speechPrefs.js | The read-aloud preferences and voice list, shared with the settings page. |