Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/docs/docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@ export default defineConfig({
items: [
{ text: "Overview & features", link: "/web-app/overview" },
{ text: "Pages & routing", link: "/web-app/pages-and-routing" },
{
text: "Lessons on this device",
link: "/web-app/local-lessons",
},
{ text: "Server rendering", link: "/web-app/server-rendering" },
{ text: "Question blocks", link: "/web-app/question-blocks" },
{ text: "AI text suggestions", link: "/web-app/ai-text-suggestions" },
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/docs/monorepo/lesson-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ curl -X POST https://<worker-host>/admin/migrate-images \
-d '{"cursor": 0, "limit": 25}'
```

Local drafts migrate automatically on first load (old `localStorage` doc → IndexedDB).
Local lessons migrate automatically on first load (old `localStorage` doc →
IndexedDB → the [lesson library](/web-app/local-lessons)).
Readers tolerate legacy base64 throughout, so the backfill can run any time after
deploy. Deploy order: deploy the Worker (so `/images` exists) → ship the web build
→ run the backfill.
Expand Down
42 changes: 34 additions & 8 deletions apps/docs/docs/monorepo/version-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,31 @@ The history view asks two questions about a selected version, because they have
different answers the moment anything has happened since: _what changed here_
(history) and _difference from now_ (the decision you are about to make).

## One repository per lesson

The browser holds **a repository per lesson**, not one for "the editor":

```
/lessons/<repoId>/.git bare — no working tree, no index
```

`repoId` is the lesson's hub id once it has one, and otherwise its id in this
device's [lesson library](/web-app/local-lessons) — which is what lets the editor
hold as many lessons as you make, each with a history of its own, and switch
between them by switching repositories. `repoIdFor(lessonId, localId)` is the one
place that decides.

The id changes exactly once in a lesson's life: the first time it is saved to the
cloud, `adoptDraftRepo` copies `/lessons/<localId>` to `/lessons/<hubId>` and
drops the original. The copy is a legitimate clone — git objects are immutable and
content-addressed, so every commit keeps its oid — and it is what stops an hour of
history built before publishing from being stranded under an id nothing points at
any more. From then on the repository follows the _lesson_: opening it on another
machine clones that history down rather than starting a new one.

A repository is only ever read through `repoCtx(repoId)`, so nothing below this
line knows or cares which of the two kinds of id it was given.

## More than one branch

A lesson's repository holds a branch per **variation** — an alternative version of
Expand All @@ -128,8 +153,9 @@ become.

Which one is being edited is `HEAD`, a symbolic ref, exactly as in git. That is
not just tidiness: `HEAD` lives inside the gitdir, so it survives the two places a
repository is copied wholesale — publishing a draft (`adoptDraftRepo`) and forking
a lesson locally (`copyRepo`) — neither of which knows branches exist.
repository is copied wholesale — publishing a local lesson (`adoptDraftRepo`) and
copying one into another (`copyRepo`, behind both "fork into a new lesson" and
"duplicate") — neither of which knows branches exist.

Everything on this page is per branch as a result. A commit moves whatever `HEAD`
points at; the history view reads the branch being edited unless given a ref; the
Expand Down Expand Up @@ -359,10 +385,10 @@ than the bundler's env, which is what lets it sit on this side of the line.
Browser-bound (`@spelling-creator/core/browser/git/*`) — framework-agnostic, but
needs a real browser:

| Module | Purpose |
| ------ | ------------------------------------------------------------ |
| `fs` | LightningFS — the IndexedDB filesystem the repos live on. |
| `sync` | Fork (clone), merge, push, and both sides of a pull request. |
| Module | Purpose |
| ------ | ----------------------------------------------------------------------------------- |
| `fs` | LightningFS — the IndexedDB filesystem the repos live on, one directory per lesson. |
| `sync` | Fork (clone), merge, push, and both sides of a pull request. |

Server-side (`apps/mcp/src/git.js`) — the fork-and-propose flow for an AI
assistant, which is `browser/git/sync`'s two outbound steps built on `memfs`
Expand Down Expand Up @@ -393,8 +419,8 @@ Worker: `apps/api/src/routes/git.js` and `apps/api/src/routes/pulls.js`, with th
trusted-collaborator check in `apps/api/src/lib/lesson.js`
(`isTrustedCollaborator`).

Repositories are **bare** — no working tree, no index. The editor's document
lives in React state and IndexedDB, so checked-out files would be dead weight;
Repositories are **bare** — no working tree, no index. The editor's documents
live in React state and IndexedDB, so checked-out files would be dead weight;
everything goes straight through plumbing (`writeBlob` → `writeTree` →
`writeCommit` → `writeRef`).

Expand Down
110 changes: 110 additions & 0 deletions apps/docs/docs/web-app/local-lessons.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
title: Lessons on this device
---

# Lessons on this device

The editor holds **as many lessons as you make**. They live in this browser, in
IndexedDB, and you switch between them from the **Lessons** button in the editor's
top bar (or **On this device** in the sidebar, which opens the same panel).

Nothing you are working on is ever replaced. That is the whole point of the
feature, and it is worth saying plainly, because it used to be the opposite: the
editor kept exactly **one** working document, so opening a lesson from the hub,
forking one, or importing a Word file all overwrote whatever was on screen — and
each of those flows needed a "Replace your current work?" dialog to warn you
first. Those dialogs are gone, because there is nothing left to replace.

## The panel

```text
Lessons on this device
─────────────────────────────────────────────────────────
Volcanoes ✓ ⋯
3 sections · 24 blocks · edited just now Published

Volcanoes (copy) ⋯
3 sections · 24 blocks · edited 2 minutes ago

Year 4 spellings ⋯
1 section · 6 blocks · edited yesterday Cloud draft
─────────────────────────────────────────────────────────
+ New lesson Close
```

Clicking a row switches to it. The badge on the right says where else that lesson
exists — **Published** on the hub, or a private **Cloud draft**; a lesson with no
badge is on this device only. The `⋯` menu holds the three things you can do to a
lesson you are not currently in:

| Action | What it does |
| --------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| **Rename** | Retitles it. Same as editing the title at the top of the editor — the title _is_ the lesson's name. |
| **Duplicate** | A full copy, including its [version history](/monorepo/version-history), unattached to the hub, titled "… (copy)". |
| **Delete from this device** | Removes the lesson, its document and its history. Asks twice, and cannot be undone. |

## Where each lesson lives

A lesson is three things, and each one is keyed by the same id:

| What | Where |
| ------------------- | --------------------------------------------------------------------------- |
| Its metadata | The `lessons` store — title, block counts, hub attachment, last-edited time |
| Its document | The `lessonDocs` store, one record per lesson |
| Its version history | A git repository of its own, at `/lessons/<id>/.git` in LightningFS |

The split between the first two is what keeps the list cheap: showing you a
dozen titles reads a dozen small records, not a dozen whole lessons with their
images.

The id is also the name of the lesson's repository — until the lesson is saved to
the cloud, at which point the repository moves under the hub's id for it and
follows the lesson to your other devices. See
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
[Version history](/monorepo/version-history) for what that repository holds.

## What each flow does now

| You do this | What happens |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------- |
| **New lesson** (sidebar) | Adds an empty lesson and opens it. Pressing it while already in an untouched one stays put. |
| **Edit** on one of your hub lessons | Opens the copy this device already has of it, or makes one. Never a second copy of the same lesson. |
| **Fork** a lesson from the hub | A new lesson, cloned with the original's history, titled "… (copy)". |
| **Fork into a new lesson** (in the editor) | The same, from the lesson you're in — which stays in the list, still attached to its hub row. |
| **Import** a Word or JSON file | A new lesson, with a history that starts at the import. |
| **Save to cloud** on a device-only lesson | Attaches it to the hub lesson it creates, and takes its history up with it. |

## What this does not do

These lessons are **local**. Nothing here syncs: another browser, another device
or another profile has its own library, and clearing your browsing data clears
it. Saving a lesson to the cloud — published or as a private draft — is what puts
a copy somewhere else, and is the only thing that does. The panel says so at the
bottom, for the same reason.

Deleting a lesson that has been saved to the cloud removes only the local copy.
The hub keeps the lesson and its published history, and opening it for editing
again clones that history back down.

## Where this lives in the code

| File | What it holds |
| ------------------------------------------- | -------------------------------------------------------------------------- |
| `packages/core/src/browser/storage.js` | The library API — list, get, create, save, delete, and the two migrations |
| `packages/core/src/browser/imageStore.js` | The IndexedDB stores themselves (`lessons`, `lessonDocs`, `images`, `app`) |
| `apps/web/src/components/LessonsDialog.jsx` | The panel above |
| `apps/web/src/pages/EditorPage.jsx` | Opening, creating, duplicating, deleting — and saving before it leaves one |

## Upgrading from the single-document editor

Two migrations run in order the first time the editor loads, and both are
idempotent:

1. `migrateLocalStorage()` — the pre-IndexedDB draft (a `localStorage` document
with base64 images) moves into IndexedDB, images becoming binary blobs.
2. `migrateToLibrary()` — that single document becomes the library's first
lesson, keeping its title, its hub attachment and its fork origin.

The migrated lesson is given the id `draft`, which is not arbitrary: `draft` is
the name the old working lesson's repository already has on disk, and a local
lesson's id _is_ its repo id, so the whole timeline carries across without a
single git object being copied. Lessons made after it get ordinary random ids.
5 changes: 5 additions & 0 deletions apps/docs/docs/web-app/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ PDF printing.
and merges your proposal (see [Pull requests](./pull-requests.md)).
- **Auto-save** - your work is kept in IndexedDB between reloads (images as binary
blobs, so large drafts aren't capped by `localStorage`'s ~5 MB quota).
- **As many lessons as you make** - the editor holds a whole library of them
locally, each with its own document and version history, and switches between
them from the **Lessons** button. Opening a lesson from the hub, forking one or
importing a document adds to that library rather than replacing what you were
working on (see [Lessons on this device](./local-lessons.md)).
- **Installable, and works offline** - the app can be installed to a Home Screen
or dock and opens in its own window; a service worker precaches the shell, so
the editor, version history and DOCX/PDF export all keep working with no
Expand Down
26 changes: 17 additions & 9 deletions apps/docs/docs/web-app/pages-and-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,9 @@ whether 256px of the screen is currently a sidebar or not.
| -------------------------- | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | **Home** | Landing page. Signed out: a marketing splash (animated floating words + feature blurbs). Signed in: a dashboard (latest-lessons feed, your activity, activity from people you follow, notifications). |
| `/editor` | **Editor** | The lesson builder. Three panes — section outline, document, live preview — each appearing once the page column has room for it. |
| `/editor/lessons` | **Editor** | The [lessons this device holds](./local-lessons.md), over the editor — switch between them, copy, rename or delete one. |
| `/editor/history` | **Editor** | The version-history panel, over the editor. |
| `/editor/variations` | **Editor** | The [variations](./lesson-variations.md) panel, over the editor. |
| `/editor/collaborate` | **Editor** | The [live-collaboration](./live-collaboration.md) panel, over the editor. |
| `/hub` | **Lesson hub** | Public gallery of published lessons (plus your own drafts), with search. |
| `/hub/:id` | **Lesson → Lesson** | The lesson itself, with an "About" rail: author, ages, section count, fork lineage, and the print / Word / fork actions. |
Expand Down Expand Up @@ -142,15 +144,21 @@ Two things deliberately did **not** become tabs:

## Query-string deep links

Two query strings deep-link into the editor rather than being routes of their
own: `?join=<code>` opens the [live-collaboration](./live-collaboration.md)
panel on that invite, and `?pull=<id>&lesson=<lessonId>` opens a
[proposed change](./pull-requests.md) for review once the lesson it names has
loaded — the lesson id is part of the link precisely so the review waits for the
right one, rather than acting on whatever the editor already had open. Both are
consumed once and then simply sit in the URL. Opening an editor panel preserves
them, so navigating to `/editor/collaborate` never drops the invite that sent
you there.
Four query strings deep-link into the editor rather than being routes of their
own:

| Link | What it does |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `?join=<code>` | Opens the [live-collaboration](./live-collaboration.md) panel on that invite. |
| `?pull=<id>&lesson=<lessonId>` | Opens a [proposed change](./pull-requests.md) for review once the lesson it names has loaded — the lesson id is part of the link precisely so the review waits for the right one, rather than acting on whatever the editor already had open. |
| `?local=<id>` | Switches to one of the [lessons on this device](./local-lessons.md). |
| `?new=1` | Starts a new lesson — what the sidebar's **New lesson** button links to, since plain `/editor` resumes whichever lesson you last had open. |

The first two are consumed once and then simply sit in the URL; the last two are
stripped from it as they are read, because they are instructions rather than
state and a reload should not carry them out twice. Opening an editor panel
preserves the query string, so navigating to `/editor/collaborate` never drops
the invite that sent you there.

## Offline and the service worker

Expand Down
Loading
Loading