Project structure
This page maps the web app's source (apps/web/src) and the shared package it leans on (packages/core), then covers two conventions that live in shared components rather than in any one page: auto-growing text fields and the focus ring.
The web app's source tree
src/
App.jsx route table; the editor, library, settings, moderation, login, OAuth and lesson History routes are lazy (see pages-and-routing.md)
main.jsx browser entry: configureCore, then ColorSchemeProvider + TooltipProvider + BrowserRouter + SsrProvider + AuthProvider + DisplayNameGate + Toaster + ServiceWorkerPrompt; hydrates a server-rendered page, mounts a plain one
entry-server.jsx the same tree built for the Worker (see server-rendering.md), kept structurally in step with main.jsx
styles/globals.css Tailwind v4 + shadcn/ui design tokens (light/dark palettes, opaque surfaces, the radius scale, chrome heights), the focus ring, plus the `--safe-*` inset tokens and `*-safe` utilities (see design-system.md and mobile-layout.md)
locales/en/*.json one JSON file per i18next namespace (see internationalization.md)
pages/
HomePage.jsx marketing splash (signed out) or dashboard (signed in)
EditorPage.jsx the lesson builder (toolbar, section list, + button, publish, collaborate), and the owner of which of this device's lessons is open
HubPage.jsx public gallery of published lessons + client-side search
ProfilePage.jsx a user's public profile: bio + their published lessons
LibraryPage.jsx the lessons this device holds (/library): open, copy, rename, delete, start another (see local-lessons.md)
LoginPage.jsx sign-in (magic link or emailed code, or username and password) / account status
ModerationPage.jsx moderator/admin queue for reported content
SettingsPage.jsx appearance, language, read-aloud, This device (install, lessons, downloaded AI models), account
NotFoundPage.jsx the 404 page for a path that isn't a route
OAuthAuthorizePage.jsx the MCP consent screen, the one route outside AppShell
lesson/ one lesson (/hub/:id) and its tabs (see pages-and-routing.md)
LessonLayout.jsx owns the fetch, the identity header and every whole-lesson action; tabs read it via useLesson()
LessonTabs.jsx the tab bar: NavLinks to real routes, not a Tabs widget
LessonOverview.jsx the document itself + the "About" rail (author, ages, fork lineage, Print PDF / Download Word / Fork)
LessonPractice.jsx interactive mode, given a URL
LessonDiscussion.jsx comments + the star rating
LessonProposals.jsx / LessonProposal.jsx proposals from other people's forks; the detail view shows the diff and hands merging (or trying it in a variation) to the editor
LessonHistory.jsx the published commit timeline, read out of the lesson's packfile (lazy: it loads the git engine)
components/
layout/
AppShell.jsx the one layout route every page sits in: AppHeader + the page. Takes no configuration. Publishes @container/page
AppHeader.jsx the app's chrome: nav links (a sheet below md), New lesson, and the install/theme/settings/notification/account cluster
PageBar.jsx the contextual bar pinned under the header on lesson pages and in the editor: breadcrumb + the page's own actions
PageBody.jsx the content column, in two documented widths (wide / reading). Exports PAGE_WIDTHS for the tab bar, which needs the width but can't be the column, and PAGE_GUTTER, the safe-area-aware side padding for every content column
EditorShell.jsx the editor's single splat route behind one lazy import; mounts no chrome of its own
editor/
SectionOutline.jsx the editor's left-hand section list (52rem+ of page column): jump to a section, collapse them all, see where checks found problems; `readOnly` reuses it beside the preview
LessonPreview.jsx what the editor's document column holds while Preview is on: the eyebrow, the narrow-screen exit, and LessonView in a panel frame
LessonTextInput.jsx a text block's tiptap editor: bold/italic/underline and footnotes, committed like LiveField (see formatting-and-footnotes.md)
LessonTextToolbar.jsx its toolbar and the footnote form (cite a source, add a note, or both; add a new source on the spot)
SourcesPanel.jsx the lesson's Sources card at the end of the editor, with how often each source is cited
LessonChecksSheet.jsx the "Check lesson" panel: findings grouped by section, each a link to its block (see lesson-checks.md)
FactCheckSection.jsx the Check panel's fact check (see fact-checking.md)
AiFixDialog.jsx ask AI to fix one check finding and preview the fix (see ai-lesson-fixes.md)
checkGroups.js groups the Check panel's items by section, shared by the checks and the fact check
DocumentImportDialog.jsx Import from text: paste or drop a .txt, .md or hand-written .docx and see what was found before importing (see document-import.md)
InstallAppButton.jsx the "Install app" control, in the header's utility cluster and on the settings page; renders nothing unless the app is installable (see pwa-and-offline.md)
NotificationBell.jsx header bell that polls for and shows the user's notifications
DisplayNameGate.jsx makes a signed-in user pick a display name before using the app
DisplayNameDialog.jsx pick / change your public display name
EmailCodeForm.jsx the "code from the email" sign-in, shared by /login and the MCP consent screen (see pwa-and-offline.md)
BioDialog.jsx edit your public profile bio (rich text)
FollowListDialog.jsx a profile's followers and following, in two tabs
FirstLessonWizard.jsx dismissable step-by-step welcome guide for newcomers
FloatingWords.jsx the homepage hero's drifting spelling words (tsParticles)
Skeletons.jsx shared loading skeletons, used instead of spinners
CommentsSection.jsx lesson comments list + post/reply/edit boxes, incl. the 1-5 star rating input
RichTextInput.jsx the tiptap-based editor used for comments + bios (formatting and links; no media)
RichTextToolbar.jsx its shadcn ToggleGroup toolbar (bold/italic/underline/lists/link/etc.)
RichText.jsx renders a stored comment/bio: sanitized HTML, or plain text for pre-rich-text values
LiveField.jsx debounced LiveInput/LiveTextarea (commit about 200ms after typing pauses, hold off remote updates while focused)
TextRuns.jsx draws a text block's formatted runs and a footnote's or source's citation as React elements (no innerHTML)
LessonView.jsx read-only renderer for the lesson page and the editor's preview mode (blocks straight to React, lazy images, drawn in the app's theme)
LessonTranslation.jsx the published lesson's on-device Translate action (see lesson-translation.md)
LessonSummary.jsx on-device AI summary card on the lesson page (hidden unless the device can run an engine)
InteractiveLesson.jsx full-screen step-by-step walkthrough of a lesson, with a field per question, autosaved progress you can come back to, and optional read-aloud (see interactive-mode.md)
SpeechVoiceSelect.jsx the read-aloud voice picker (natural voices, then the browser's), shared by the walkthrough's popover and the settings page
MyLessonAnswers.jsx the reader's own saved answers on the lesson page, private to them
SectionCard.jsx a named section with its content blocks + add buttons; measures the pointer against its own rows during a block drag, but the drag itself is owned by EditorPage (blocks can move between sections)
ContentBlock.jsx a single text, spelling, image, VAKT or question block; owns BLOCK_LAYOUT, the responsive content/controls split (see mobile-layout.md)
IconActionButton.jsx the icon + tooltip button behind every block/section control; the tooltip doubles as its aria-label
AiTextDialog.jsx Turnstile-verified "Suggest text with AI" dialog
AiQuestionDialog.jsx Turnstile-verified "suggest a question with AI" dialog
AiLessonIdeaDialog.jsx Turnstile-verified "suggest a whole lesson outline with AI" dialog
ImageSearchDialog.jsx the "Search images" dialog: Pixabay (Turnstile-verified, through the Worker) and Wikimedia Commons (see images.md)
CollaborateDialog.jsx live-collaboration control panel (host/join, roster, trusted collaborators)
CollabCursors.jsx floating colored carets showing collaborators' selections
CollabChat.jsx in-session chat: a floating corner panel on desktop, a bottom sheet on mobile
HistoryDialog.jsx the lesson's version timeline: what each commit changed, per block, + restore
ChangeSummary.jsx renders what a set of block operations changed, the same way wherever it's shown
VariationsDialog.jsx a lesson's variations: the branches of its repository, as an author sees them (see variations.md)
MergeDialog.jsx settle a merge: a fork's original, or a pull request being reviewed (mine / theirs / keep both)
ProposeChangesDialog.jsx open a pull request against the lesson this fork came from (see pull-requests.md)
PullRequestsSection.jsx proposed changes on a lesson's page, with review/merge and close for whoever may
ui/ shadcn/ui primitives (Button, Dialog, DropdownMenu, Select, Sheet, Tooltip, Sonner Toaster, Skeleton, etc.): Radix underneath, styled from the tokens in styles/globals.css
ui/textarea.jsx Textarea, which grows to fit its text (see "Auto-growing text fields" below); hence `resize-none`, and never a scrollbar
lib/
i18n.js react-i18next setup: registers every namespace's resources, fallback/supported languages; exports baseConfig for the server render
languages.js LANGUAGES registry backing the settings page's language switcher (English only today)
colorScheme.jsx ColorSchemeProvider + useColorScheme (light/dark/system, persisted, applied as data-theme on <html>)
useLiveField.js shared debounce/commit buffering behind LiveField.jsx
lessonSources.jsx LessonSourcesProvider: hands text block editors the lesson's sources and their footnote numbering
footnoteExtension.js the tiptap footnote node (an inline atom numbered by a CSS counter)
lessonChecks.js the lesson checks as the editor shows them (the checks themselves are in core)
factCheck.js fact checking as the editor shows it
collab.js useCollaboration hook (one WebSocket to the CollabRoom Durable Object; doc sync, cursors, chat)
useSelectionBroadcast.js broadcasts the local editor selection to peers
useDragAutoScroll.js scrolls the page while a block drag hovers near a window edge (the browser only auto-scrolls a native drag while the pointer keeps moving)
useScrollAnchor.js keeps a section/block still on screen while the move buttons reorder it, plus scrollToElement/idSelector (see navigating-large-lessons.md)
git/ what has to stay in the app; the rest is in core (see below)
engine.js, load.js the git engine, behind one dynamic import (keeps isomorphic-git off the main bundle)
useLessonGit.js the editor's controller: setup, periodic commits, history, restore
exports/
engine.js, load.js the docx/PDF/import pipeline, behind one dynamic import (keeps docx, mammoth and html2pdf.js off every page that never exports; preview doesn't need it)
useImageSrc.js resolves an image ref to a displayable src
speechPrefs.js the read-aloud preferences (on/off, voice, pace) and the voice lists (the browser's, and the natural voices this device can run), shared by interactive mode and the settings page
useSpeech.js text-to-speech for interactive mode: the browser's voices over the Web Speech API, or a natural voice (Kokoro) played through Web Audio, with the browser's as the fallback
auth.jsx AuthProvider + useAuth (session, magic link, emailed code, password sign-in, sign out)
seo.jsx <DocumentMeta> / <JsonLd>: React 19 hoists these into <head>, which is what makes them work under SSR
ssr.jsx the client/server handoff: SsrProvider, useServerData, useSiteOrigin
pwa.jsx registers the service worker; toasts when a new build is waiting
useInstallPrompt.js captures beforeinstallprompt (or detects iOS Safari) behind the install button
utils.js cn(): clsx + tailwind-mergeOutside src/
public/icons/ holds the PWA icons and the two SVGs they're rasterized from, and public/home/ the homepage's feature screenshots. The VitePWA block in vite.config.js holds the manifest and service-worker configuration (see Installable app & offline use), and the same file holds SSR_UNREACHABLE, the modules stubbed out of the server build (see Server rendering).
Shared lesson logic
The parts of the lesson model that don't depend on React live in packages/core (@spelling-creator/core), so the Worker and the MCP server can apply the same rules. Each module is its own subpath export (listed in packages/core/package.json); a few, noted below, are internal and reached only through another module.
Runtime-neutral: safe to import from the browser, Node or the Worker:
@spelling-creator/core/
config the seam the host app passes its configuration through
questions question type definitions, colors, block factories
spelling helpers for the explicit "spelling words" block
vakt shared definitions for the VAKT block (an activity, not a question)
ageRanges the age ranges a lesson can be pitched at
lessonText a text block's content: formatted paragraphs with footnotes
sources a lesson's sources (doc.sources) and how text blocks cite them
lessonSearch fully client-side hub search (Fuse.js)
lessonFile the .json lesson-file envelope (shared with the importer + MCP)
jsonImport parse + validate a .json lesson file back into the lesson model
documentImport import from text that was never a lesson file (pasted, .txt, hand-written Word)
documentImportModel the prompt and reply handling for the on-device model behind Import from text
lessonBuild build the editor's doc from the MCP server's LLM-friendly lesson input
lessonPatch apply a small list of edit operations to a doc (the MCP server's patch_lesson)
lessonChecks the lesson checks, shared by the editor and the MCP server
lessonFixes quick fixes for check findings that need no judgment
lessonAiFixes AI fixes for the findings that do
factCheck checking a lesson's facts against Wikidata, the Smithsonian GVP and Wikipedia
wikidata, wikipedia the plumbing those sources share (smithsonian and factText are internal)
wikidataMedia the pictures Wikidata lists for a topic
image image sizing: selectable sizes, scale, fit-within
imageCredit an image's caption and its license credit, kept apart
lessonLayout the presentation constant every lesson render shares (DOCX_MAX_IMAGE_WIDTH), outside browser/ so the viewer and the server render can use it without pulling in docx + mammoth
interactive turning a lesson into the ordered walkthrough interactive mode steps through
lessonResponses saved answers from interactive mode, via the Worker
lessonTranslation what on-device translation covers in a lesson, and its lookup keys
id id generation
username usernames as login identifiers, for an instance with no mail server
wikimedia Commons action-API round trip + attribution metadata
lessons list / fetch / publish hub lessons (+ the feed URL)
comments list / post / edit lesson comments
pulls pull requests (proposed changes), via the Worker
users other users' public profiles, follows, activity
profile your own profile (display name, bio)
notifications the notification feed
moderation the moderation queue
pixabay search + fetch Pixabay images via the Worker
aiSuggest AI text / questions / lesson ideas via the Worker
spellingWords the aggregated published-lesson word list
mcpOAuth the MCP OAuth approval handshake
imagesClient upload a lesson's images to the Worker (R2) on publish
richText rich-text policy: allow-list, link schemes, HTML to text
translationLanguages the languages comment translation covers: BCP-47 <-> FLORES-200
opusMtModels the per-pair Opus-MT models the translation fallback prefers (into English)
collabFrames the live-collaboration wire protocol, shared by the room, the browser and the MCP server
ydoc the Yjs lesson document: Y.Doc <-> doc model, remote apply, reconcile
git/remote the /git/:lessonId endpoints (pack in R2)
git/doc pure doc helpers: canonical JSON, manifest, block map (no git)
git/ops diff two docs into block operations; render commit messages (no git)
git/merge three-way merge by block id, field-level (no git)
git/refs the names a lesson's branches may have (no git)
git/layout document <-> git tree (lesson.json manifest + blocks/<blockId>.json)
git/repo commit, history, diff two commits, restore (bare repo, pure plumbing)
git/pack pack for upload; clone/fetch from a pack; find the merge base
git/memfs an in-memory filesystem for the git engine, for hosts with no otherBrowser tier: framework-agnostic, but needs a DOM (IndexedDB, <canvas>, FileReader, an <a> to download). Behind a separate subpath so the Worker and the MCP server cannot reach it by accident:
@spelling-creator/core/browser/
imageStore the IndexedDB stores themselves: the lesson library, its documents, image blobs, editor flags
imageRef binary image-ref model (a block references its bytes)
imageFile read a File to bytes, measure it, opportunistically re-encode to WEBP
storage the lesson library: every lesson this device holds, which one is open, and the two migrations into it
interactiveProgress the unfinished interactive run-through this device is holding (localStorage): resume, expiry, pruning
modelCache the on-device models transformers.js has downloaded (Cache Storage): their size, and deleting them
docxExport build the .docx (text, images, questions)
docxImport best-effort import of an exported .docx back into the lesson model
documentText the text of a file someone wants to import from (Word via mammoth, else plain text)
documentModel whether the device can run the Import from text model, and the door to its engine
documentModelEngine (internal) the engine itself, a lazy chunk only the import dialog loads
pdfExport docx -> html (mammoth) -> pdf (html2pdf.js), the only non-Word use of the Word pipeline
jsonExport download a lesson as .json (the envelope itself is in lessonFile)
feeds read the hub / user Atom feeds (DOMParser)
supabase the Supabase browser client (auth only), built on first use
turnstile the Cloudflare Turnstile widget loader
googleDrive OAuth2 + upload the docx to Drive as a Google Doc
sanitizeRichText the render-time DOMPurify pass (policy comes from ../richText)
commonsImages search Wikimedia Commons + download an image (no key, no proxy)
presence per-collaborator color + selection presence helpers
summarizer on-device summaries: browser Summarizer API with an in-page fallback (fails closed when neither can run)
fallbackSummarizer (internal) the fallback itself: LFM2.5 via transformers.js on WebGPU, a lazy chunk only a click ever loads
translator on-device translation: browser Translator API with an in-page fallback
fallbackTranslator (internal) the fallback itself: Opus-MT / NLLB-200 via transformers.js, a lazy chunk only a click ever loads
deviceCheck (internal) the checks the in-page models share: the WebGPU adapter (asked for once a page), the large-model limits, a metered connection
readAloud the natural read-aloud voice (Kokoro): the device check, whether it's already downloaded, and the door to its engine
readAloudEngine (internal) the engine itself: text cleanup, espeak-ng phonemes (Spellophone), Kokoro via transformers.js on WebGPU, a lazy chunk only speaking ever loads
readAloudVoices (internal) the Kokoro model (id and pinned revision) and the voices on offer, readable without the engine
downloadProgress (internal) shared by the transformers.js engines: per-file download events summed into the one 0-1 fraction the UI shows
git/fs LightningFS, the IndexedDB filesystem the repos live on
git/sync fork (= clone the repo), merge-with-original and proposal-review flows.oxlintrc.json enforces the split: packages/core is linted against the worker env, and only src/browser/** is opted into browser. A module outside that directory that reaches for document fails the lint rather than breaking inside the Worker.
The config seam
Core modules must not read import.meta.env: it is bundler-specific, substituted at build time, and absent in Node, in the Worker and under any other bundler. A module that reads it at import time can only ever be used by the web app, which is what previously pinned the whole image/export tier inside apps/web.
So the host passes its configuration in once, before anything uses it:
// apps/web/src/main.jsx: the only place in the app that touches import.meta.env
configureCore({
apiUrl: import.meta.env.VITE_API_URL,
supabaseUrl: import.meta.env.VITE_SUPABASE_URL,
supabaseAnonKey: import.meta.env.VITE_SUPABASE_ANON_KEY,
googleClientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
turnstileSiteKey: import.meta.env.VITE_TURNSTILE_SITE_KEY,
authMode: import.meta.env.VITE_AUTH_MODE,
usernameDomain: import.meta.env.VITE_USERNAME_DOMAIN,
});The server render passes the same keys from the server's own bindings (see coreConfig in apps/api/src/routes/ssr.js), so the page it renders and the client it hydrates into agree.
Readers resolve lazily (apiUrl(), not a captured constant), which matters: ES imports are hoisted, so configureCore runs after every module in the graph has already been evaluated. Anything capturing the value at import time would capture "". packages/core/src/config.test.js pins that behavior.
The same reasoning is why browser/supabase builds its client inside getSupabase() rather than at module scope, memoizing it so the SDK's session and refresh timer exist exactly once. It is also why the old supabaseEnabled, googleDriveEnabled, lessonHubEnabled, notificationsEnabled, profilesEnabled and gitRemoteEnabled constants are gone: every one was computed at module scope, so under a lazily resolved config all of them would have read as false. They are now predicates: hasSupabase(), hasGoogleDrive() and hasApi() (the last replacing four names for one predicate), joined since by hasTurnstile(), hasPasswordAuth() and hasMagicLinkAuth().
Why version history splits the way it does
git/repo and friends never open a filesystem themselves: every function takes a { fs, gitdir } context, which is why they port unchanged (LightningFS in the browser, git/memfs where there is nothing else). What stays in the web app is lib/git/engine.js and lib/git/load.js (the dynamic-import boundary and the Buffer polyfill browsers need) plus useLessonGit, the editor's own controller. fs (LightningFS over IndexedDB) and sync sit in core's browser tier, and remote is runtime-neutral now that it reads its base URL through the config seam rather than import.meta.env.
That boundary is load-bearing for bundle size: isomorphic-git stays behind load.js's dynamic import, in its own async chunk (about 200 KB), rather than in the bundle every homepage visitor downloads. The tags: ["$initial"] on the vendor code-splitting group in vite.config.js is what keeps the vendor chunk from swallowing it back.
wikimedia holds only the parts of the Commons integration that are genuinely common to both clients: the endpoint, the query/unwrap call, and the license/author handling. The web app and the MCP server keep their own search and download functions on top of it, because their result shapes, paging and error wording are part of their respective contracts and are not interchangeable.
For how the other apps fit around this package, see the developer overview.
Conventions in shared components
Auto-growing text fields
Every Textarea sizes itself to its content, so a lesson paragraph or a long question prompt is read in full inside its content block rather than scrolled through a two-line slot. ui/textarea.jsx measures it: on each input, on any change to a controlled value (a lesson loading, a collaborator's edit, an AI suggestion landing in a block), and, via a ResizeObserver on the field, on any change to its width, since re-wrapping the text changes how tall it needs to be. That last one also covers a field going from zero width to a real one, which is how a block inside a collapsed section gets measured when the section is opened.
This was field-sizing: content, a single CSS declaration that does the same job. It's deliberately gone: where a browser doesn't honor it there is no symptom to debug, only a field stuck at its min-height showing a scrollbar and a resize grabber, which is the exact state it existed to prevent. Measuring in JS behaves identically everywhere, so it's the only path rather than a fallback behind a feature test. Two class names ride along with it: resize-none (a hand-dragged height would be overwritten by the next keystroke) and overflow-hidden (the field is always exactly as tall as its text, so there is nothing to scroll).
Focus rings
globals.css gives :focus-visible the app's own ring (2px of --ring, offset by 2px) in the base layer. shadcn's primitives are unaffected: they pair outline-none with a focus-visible:ring-* of their own, and utilities beat base. The rule is there for everything else, and the app has a lot of it: the header's links and icon buttons, the editor's toolbar buttons, the star rating. None of those had a focus style, so they fell through to the browser's default ring, which Chrome draws as a dark outline banded with white: stray chrome rather than part of the app, and on the old indigo header bar the white band was the only part of it you could see.
It's a base rule rather than a class each control opts into because the controls that were missing it are exactly the ones nobody thought about; this way a new hand-rolled button gets it without anyone remembering.
One surface overrides the color, because --ring is --primary and would disappear into it: HomePage's hero, a fixed gradient that doesn't follow the theme, so its two call-to-action links carry focus-visible:outline-white themselves.
There used to be a second: header :focus-visible switched the ring to --primary-foreground, because the app bar was a block of --primary and the ordinary ring vanished into it. The header and PageBar now draw on --card, so the ordinary ring is correct again, and the override had become actively wrong, a near-white ring on a light surface. It is gone.