diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2cbbb7a..cf7db6d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,8 +1,14 @@ # Architecture -Tutor is one Electron desktop app for one reader on one machine. It generates a book chapter by chapter, and each chapter is shaped by the feedback and quiz results of the one before it. Everything below follows from that, and from there being exactly one writer and no cloud. +Tutor is one Electron desktop app for one reader on one machine. It generates a book chapter by chapter, and each chapter is shaped by the feedback and quiz results of the one before it. -Start here, then follow the links. Domain words are defined in [CONTEXT.md](CONTEXT.md), decisions and their costs in [docs/adr/](docs/adr/README.md), and the HTTP surface in [docs/api-routes.md](docs/api-routes.md), which is generated from the route registry rather than written by hand. +Everything below follows from those two facts. There is exactly one writer, and there is no cloud. + +Start here, then follow the links. + +- Domain words are defined in [CONTEXT.md](CONTEXT.md). +- Decisions and what each one cost are in [docs/adr/](docs/adr/README.md). +- The HTTP surface is in [docs/api-routes.md](docs/api-routes.md), generated from the route registry, never written by hand. ## 1. What talks to what @@ -27,10 +33,14 @@ flowchart LR server --> local ``` -The Fastify server is embedded in the Electron main process rather than deployed anywhere. It binds `127.0.0.1` on a free port at launch, or 3147 when run standalone with `pnpm dev:server`. The library is plain Markdown and YAML under the OS data directory, which is [ADR 0001](docs/adr/0001-filesystem-as-the-database.md). Narration is synthesized locally instead of by a metered cloud service, which is [ADR 0003](docs/adr/0003-local-kokoro-tts.md). +The Fastify server runs inside the Electron main process. It is not deployed anywhere. It binds `127.0.0.1` on a free port at launch, or port 3147 when run standalone with `pnpm dev:server`. + +The library is plain Markdown and YAML under the OS data directory ([ADR 0001](docs/adr/0001-filesystem-as-the-database.md)). Narration is synthesized locally instead of by a metered cloud service ([ADR 0003](docs/adr/0003-local-kokoro-tts.md)). ## 2. The server hexagon +The core never does I/O. It talks to 15 small interfaces, the ports, and each port has one or more adapters that do the real work. + ```mermaid flowchart LR subgraph core["server core"] @@ -44,18 +54,18 @@ flowchart LR subgraph ports["ports/ (15 interfaces)"] direction TB - pStore["BookRepository
ArtifactStore
LibraryMigrator
JobJournal
KeyVault"] - pAi["TextGeneration
ImageGeneration"] - pMedia["SpeechSynthesis
AudioAssembly
DiagramRenderer
EpubImport
EpubExport"] - pSys["BackgroundTasks
Clock
OsFileManager"] + pStore["books and files
BookRepository, ArtifactStore,
LibraryMigrator, JobJournal, KeyVault"] + pAi["AI
TextGeneration, ImageGeneration"] + pMedia["media
SpeechSynthesis, AudioAssembly,
DiagramRenderer, EpubImport, EpubExport"] + pSys["system
BackgroundTasks, Clock, OsFileManager"] end subgraph adapters["adapters/ (the only I/O)"] direction TB aStore["fs-*.ts
file-key-vault.ts"] aAi["ai-sdk-text-generation.ts
http-image-generation.ts"] - aMedia["kokoro-speech-synthesis.ts
ffmpeg-audio-assembly.ts
kroki- and electron-diagram-renderer.ts
epub2-import.ts, epub-gen-export.ts"] - aSys["in-memory- and journalled-background-tasks.ts
system-clock.ts, os-file-manager.ts"] + aMedia["kokoro, ffmpeg, kroki,
electron, epub2, epub-gen"] + aSys["in-memory and journalled tasks,
system-clock, os-file-manager"] end services --> pStore @@ -68,7 +78,50 @@ flowchart LR pSys --> aSys ``` -Nothing in the core names an adapter. [`server/composition-root.ts`](server/composition-root.ts) is the one place a real adapter is chosen, and `buildServer(overrides)` lets a test or the Electron shell substitute one. Every port ships an in-memory fake and a shared contract test, and every adapter that can be exercised without spending money or downloading a model runs that same contract, which is what stops a fake from drifting into a convenient fiction. See [`server/ports/README.md`](server/ports/README.md) and [`server/adapters/README.md`](server/adapters/README.md) for the full mapping, and [ADR 0005](docs/adr/0005-ai-sdk-behind-a-port.md) for why the AI SDK sits behind one. +The boxes inside `ports/` are just themes to keep the diagram readable. They are not layers, and they do not exist in the code. What each port actually is, and why it exists, in one line each. + +**Books and files** + +| Port | What it does | Why it is a port | +|---|---|---| +| BookRepository | Reads and writes the library itself, book metadata, chapters, quizzes, feedback | Services never touch `fs`, and tests run against an in-memory library | +| ArtifactStore | The binary files that belong to a book, covers, audio, EPUBs | Big blobs with their own lifecycle, kept apart from the YAML the repository owns | +| LibraryMigrator | Upgrades an older on-disk library to the current schema at boot | Works on raw YAML that may not validate yet, so it sits below the repository | +| JobJournal | One file per long-running job, so a restart can resume it | Added as a decorator, the in-memory task adapter never had to change | +| KeyVault | Stores the reader's API keys | Keys live in one place and never end up in logs or the journal | + +**AI** + +| Port | What it does | Why it is a port | +|---|---|---| +| TextGeneration | Every prompt to a language model, streaming and structured | The only doorway to AI in the whole app. One fake makes everything testable without a key | +| ImageGeneration | Generates book cover images | Same idea as TextGeneration with a smaller surface | + +**Media** + +| Port | What it does | Why it is a port | +|---|---|---| +| SpeechSynthesis | Turns chapter text into narration audio | The real model is a 100MB download. The fake keeps tests instant | +| AudioAssembly | Stitches chapter audio into one M4B audiobook | ffmpeg is a separate binary with its own failure modes | +| DiagramRenderer | Renders Mermaid blocks to images for EPUB export | The app renders offscreen in Electron, dev uses kroki. One interface hides which | +| EpubImport | Parses an uploaded EPUB into plain book data | Returns data only. Saving it is the service's job | +| EpubExport | Builds an EPUB file from rendered chapters | Wraps a CJS library with awkward packaging, quarantined here | + +**System** + +| Port | What it does | Why it is a port | +|---|---|---| +| BackgroundTasks | The task tray. Start a job, report progress, cancel | Long jobs outlive a request, and the UI watches them over SSE | +| Clock | The current time and fresh ids | Tests pin time instead of sleeping | +| OsFileManager | Reveals a file in Finder | The one place the app shells out to `open` | + +Three rules hold all of this together. + +- Nothing in the core names an adapter. [`server/composition-root.ts`](server/composition-root.ts) is the one place a real adapter is chosen, and `buildServer(overrides)` lets a test swap any of them out. +- Every port ships an in-memory fake and a shared contract test. +- Every adapter that can run without spending money or downloading a model runs that same contract test. That is what keeps a fake honest about how the real adapter behaves. + +See [`server/ports/README.md`](server/ports/README.md), [`server/adapters/README.md`](server/adapters/README.md), and [ADR 0005](docs/adr/0005-ai-sdk-behind-a-port.md) for why the AI SDK sits behind a port. ## 3. How a request travels @@ -83,7 +136,9 @@ flowchart LR ports --> adapters["adapters/"] ``` -Components render and hooks decide. Every call to the server goes through [`client/api/`](client/README.md), and a raw `fetch` or `new EventSource` anywhere else is an ESLint error rather than a convention, because the client previously held eighty four scattered fetch calls and two competing reconnect policies. +Components render. Hooks decide. + +Every call to the server goes through [`client/api/`](client/README.md). A raw `fetch` or `new EventSource` anywhere else is an ESLint error, not a convention. The client used to hold 84 scattered fetch calls and two competing reconnect policies, and the lint rule is what keeps that from coming back. ## 4. The adaptive loop @@ -113,7 +168,9 @@ sequenceDiagram Note over Server,AI: chapter 2 is shaped by chapter 1's feedback and quiz result ``` -Chapters are generated one at a time rather than up front, and the quiz exists partly to cover the generation latency. That is [ADR 0002](docs/adr/0002-just-in-time-chapter-generation.md). If the app is closed mid-generation the work is not lost, because jobs are journalled to disk and resumed at the next boot, which is [ADR 0008](docs/adr/0008-persisted-job-journal.md). +Chapters are generated one at a time, not up front, and the quiz exists partly to cover the generation wait ([ADR 0002](docs/adr/0002-just-in-time-chapter-generation.md)). + +Closing the app mid-generation loses nothing. Jobs are journalled to disk and resumed at the next boot ([ADR 0008](docs/adr/0008-persisted-job-journal.md)). ## 5. The dependency rule @@ -126,11 +183,13 @@ flowchart TD shared -. "ESLint error" .-> client ``` -`shared/` is the dependency root and imports neither side. It holds the Zod schemas, the status predicates, the HTTP contract types, and the SSE event unions, so the two halves of the app validate against the same definitions. This is one package shaped like a monorepo rather than real workspaces, which is [ADR 0004](docs/adr/0004-single-package-monorepo-shaped.md), and the folders are already package-shaped if that ever needs to change. +`shared/` is the root. It imports neither side. It holds the Zod schemas, the status predicates, the HTTP contract types, and the SSE event unions, so both halves of the app validate against the same definitions. + +This is one package shaped like a monorepo, not real workspaces ([ADR 0004](docs/adr/0004-single-package-monorepo-shaped.md)). The folders are already package-shaped if that ever needs to change. ## Deliberately out of scope -Observability and telemetry, a security-hardening pass, and release engineering were all considered and declined. The app runs locally on one machine, holds one reader's data, has no cloud component, and has no multi-user surface, so each of those would add machinery with nothing to protect or measure. The reasoning is recorded in [ADR 0004](docs/adr/0004-single-package-monorepo-shaped.md) rather than left as an unexplained gap. +Observability, a security-hardening pass, and release engineering were considered and declined. The app runs on one machine, holds one reader's data, and has no cloud component, so each of those would add machinery with nothing to protect or measure. The reasoning is in [ADR 0004](docs/adr/0004-single-package-monorepo-shaped.md) so the gap reads as a decision, not an oversight. ## Where to read next diff --git a/CLAUDE.md b/CLAUDE.md index 60a2486..99547c9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # Tutor — Adaptive Learning Library -AI-generated books tailored to your learning style. Books are generated chapter-by-chapter with a feedback loop: after each chapter, quiz questions and feedback shape how subsequent chapters are generated. The book literally rewrites itself based on how you're learning. +AI-generated books tailored to your learning style. Books are generated chapter by chapter with a feedback loop. After each chapter, quiz results and feedback shape how the next one is generated. The book literally rewrites itself based on how you're learning. ## Start here @@ -15,14 +15,14 @@ AI-generated books tailored to your learning style. Books are generated chapter- ## How It Works -1. **Create a book** — Enter a topic + prompt, AI generates a table of contents -2. **Approve the TOC** — Review, edit, reorder chapters, then approve -3. **Read chapter-by-chapter** — Quick, digestible chapters (~1,500 words, 5-10 min read) teaching specific concepts -4. **Inline chat** — Select any text to slide out a chat panel for deeper AI explanation, then return to where you left off -5. **Feedback** — After finishing a chapter, give feedback on what resonated/didn't -6. **Generation triggered** — Submitting feedback triggers next chapter generation in the background -7. **Quiz while waiting** — Optional 3-question quiz to test retention and aid memory while next chapter generates -8. **Adaptive** — Next chapter incorporates feedback + quiz results (wrong answers trigger brief recap at start) +1. **Create a book.** Enter a topic and a prompt, and the AI generates a table of contents. +2. **Approve the TOC.** Review, edit, and reorder chapters, then approve. +3. **Read chapter by chapter.** Quick, digestible chapters, roughly 1,500 words and a 5 to 10 minute read, each teaching specific concepts. +4. **Inline chat.** Select any text to slide out a chat panel for a deeper AI explanation, then return to where you left off. +5. **Feedback.** After finishing a chapter, say what resonated and what didn't. +6. **Generation triggered.** Submitting feedback starts the next chapter generating in the background. +7. **Quiz while waiting.** An optional 3-question quiz tests retention while the next chapter generates. +8. **Adaptive.** The next chapter incorporates the feedback and quiz results, and a wrong answer triggers a brief recap at its start. ## Repo layout @@ -62,18 +62,18 @@ docs/ ADRs, generated routes, plans → docs/adr/README.md ## Key Design Decisions -- **Single-user app** — No auth, no concurrency concerns -- **Chapter length** — ~1,500 words (5-10 min), flex longer when content demands -- **TOC approval** — Step-by-step wizard before generation begins -- **Progress tracking** — Scroll-based auto-tracking (completed at ≥90%) -- **Generation flow** — Just-in-time: one chapter at a time, quiz masks latency ([ADR 0002](docs/adr/0002-just-in-time-chapter-generation.md)) -- **Background work** — A `BackgroundTask` in memory, journalled to disk by the `JobJournal` port so an interrupted one can resume at the next boot ([ADR 0008](docs/adr/0008-persisted-job-journal.md)) -- **If the app restarts mid-generation** — `generate-all` and audiobook jobs resume from disk without redoing finished work; a single interrupted chapter surfaces in the reader's retry panel -- **Inline chat** — Select any text to open a slide-out panel for AI-powered deeper explanation; dismissing returns to reading position +- **Single-user app.** No auth and no concurrency concerns. +- **Chapter length.** Roughly 1,500 words, a 5 to 10 minute read, flexing longer when the content demands it. +- **TOC approval.** A step-by-step wizard runs before any generation begins. +- **Progress tracking.** Scroll-based auto-tracking, with a chapter completed at 90 percent or more. +- **Generation flow.** Just in time, one chapter at a time, with the quiz masking the latency ([ADR 0002](docs/adr/0002-just-in-time-chapter-generation.md)). +- **Background work.** A `BackgroundTask` lives in memory and is journalled to disk by the `JobJournal` port, so an interrupted one can resume at the next boot ([ADR 0008](docs/adr/0008-persisted-job-journal.md)). +- **Restart mid-generation.** `generate-all` and audiobook jobs resume from disk without redoing finished work. A single interrupted chapter surfaces in the reader's retry panel. +- **Inline chat.** Selecting text opens a slide-out panel for a deeper AI explanation, and dismissing it returns to the reading position. ## Electron Packaging -This is an Electron app using `vite-plugin-electron`. Three modes exist with different behaviors: +This is an Electron app using `vite-plugin-electron`. Three modes exist with different behaviors. | Mode | Command | Renderer loads from | API routing | Origin header | |------|---------|--------------------|--------------|----| @@ -83,11 +83,12 @@ This is an Electron app using `vite-plugin-electron`. Three modes exist with dif ### Critical conventions -- **Address**: Always use `127.0.0.1` (not `localhost`) for server communication — avoids IPv6 mismatch on macOS -- **CORS**: Server must accept `Origin: null` (file:// protocol) and any `localhost`/`127.0.0.1` origin — enforced in `server/index.ts:isAllowedOrigin()` -- **CSP**: Both `index.html` meta tag and `electron/main.ts` header must allow `http://localhost:*` AND `http://127.0.0.1:*` in `connect-src` -- **pnpm + electron-builder**: `.npmrc` requires `node-linker=hoisted`. The electron build bundles the unified/remark/rehype ecosystem into `dist-electron/` (via rollup) since electron-builder can't resolve their deep transitive deps. The bundle list is in `vite.config.ts` `external()`. CJS packages (fastify, etc.) stay external — if a CJS transitive dep is missing, add it to `package.json` `dependencies` (e.g., `json-schema-ref-resolver` for fastify). For CJS packages imported dynamically, handle the double-default: `mod.default?.default ?? mod.default` (see `epub-gen-memory` import in `server/adapters/epub-gen-export.ts`). -- **Never modify `index.html` or `package.json` to match build output** — `dist/` is the build target, source files must keep source references (`/client/app/main.tsx`) +- **Address.** Always use `127.0.0.1`, never `localhost`, for server communication. This avoids an IPv6 mismatch on macOS. +- **CORS.** The server must accept `Origin: null` (the file protocol) and any `localhost` or `127.0.0.1` origin. Enforced in `server/index.ts:isAllowedOrigin()`. +- **CSP.** Both the `index.html` meta tag and the `electron/main.ts` header must allow `http://localhost:*` and `http://127.0.0.1:*` in `connect-src`. +- **pnpm + electron-builder.** `.npmrc` requires `node-linker=hoisted`. The electron build bundles the unified, remark, and rehype ecosystem into `dist-electron/` via rollup, because electron-builder cannot resolve their deep transitive deps. The bundle list is in `vite.config.ts` `external()`. +- **CJS packages stay external.** If a CJS transitive dep is missing, add it to `package.json` `dependencies`, the way `json-schema-ref-resolver` is there for fastify. A CJS package imported dynamically needs the double-default dance, `mod.default?.default ?? mod.default`, as in `server/adapters/epub-gen-export.ts`. +- **Never modify `index.html` or `package.json` to match build output.** `dist/` is the build target, and source files must keep source references (`/client/app/main.tsx`). ## Development @@ -106,47 +107,47 @@ pnpm mcp:dev # MCP server, needs dev:server on 3147 (see .mcp.json) ## Conventions -- Zod domain schemas live in `shared/`, the single source of truth for both sides -- YAML for all metadata, Markdown for chapter content -- Vercel AI SDK is reached only through the `TextGeneration` port, never imported outside `server/adapters/` -- Tests colocated with source files (`*.test.ts`) -- **TDD** — tests land before or with implementation, visible in commit order. Contract test before adapter, service test before service, api-client test before the client function -- Path aliases: `@client/*` → `client/*`, `@server/*` → `server/*`, `@shared/*` → `shared/*` -- Domain names come from `CONTEXT.md`. Do not invent a synonym for a word that already has an owner +- Zod domain schemas live in `shared/`, the single source of truth for both sides. +- YAML for all metadata, Markdown for chapter content. +- The Vercel AI SDK is reached only through the `TextGeneration` port and never imported outside `server/adapters/`. +- Tests are colocated with source files (`*.test.ts`). +- **TDD.** Tests land before or with implementation, visible in commit order. Contract test before adapter, service test before service, api-client test before the client function. +- Path aliases: `@client/*` → `client/*`, `@server/*` → `server/*`, `@shared/*` → `shared/*`. +- Domain names come from `CONTEXT.md`. Do not invent a synonym for a word that already has an owner. ## Domain & Architecture These are the rules new and refactored code follows. The server conforms today. The client conforms on the api boundary and is still converging elsewhere. -- **Ubiquitous domain language**: `Book`, `Chapter`, `TOC`, `Feedback`, `Quiz`, `Progress`, `LearningProfile`, `Audiobook`, `BackgroundTask`. Use these names everywhere — schemas, services, components, prompts, UI copy. `CONTEXT.md` is the register -- **Pure domain core** in `shared/` and `server/domain/` — Zod types and pure functions only. No `fs`, `fetch`, AI SDK imports, or env vars inside the domain -- **Ports for every external dependency**: each gets a single named module the rest of the app depends on by shape, not by SDK. Every port ships an in-memory fake and a contract test -- **Adapters do the I/O**: only the adapter touches the SDK, library, child process, or filesystem. Swappable and testable in isolation -- **Routes are thin**: parse input → call a service → return result. No business logic, no direct `fs` or SDK calls in `server/routes/*.ts` -- **Frontend goes through one client**: components import from `client/api/`, not raw `fetch`. A raw `fetch` or `new EventSource` outside `client/api/` is an ESLint error. New endpoints get a function in the client -- **No new SDK sprinkling**: when adding a third-party SDK, wrap it behind a port first, then consume the port from services +- **Ubiquitous domain language.** `Book`, `Chapter`, `TOC`, `Feedback`, `Quiz`, `Progress`, `LearningProfile`, `Audiobook`, `BackgroundTask`. Use these names everywhere, in schemas, services, components, prompts, and UI copy. `CONTEXT.md` is the register. +- **Pure domain core** in `shared/` and `server/domain/`. Zod types and pure functions only. No `fs`, `fetch`, AI SDK imports, or env vars inside the domain. +- **Ports for every external dependency.** Each gets a single named module the rest of the app depends on by shape, not by SDK. Every port ships an in-memory fake and a contract test. +- **Adapters do the I/O.** Only the adapter touches the SDK, library, child process, or filesystem. Swappable and testable in isolation. +- **Routes are thin.** Parse input, call a service, return the result. No business logic and no direct `fs` or SDK calls in `server/routes/*.ts`. +- **The frontend goes through one client.** Components import from `client/api/`, and a raw `fetch` or `new EventSource` outside it is an ESLint error. A new endpoint gets a function in the client. +- **No new SDK sprinkling.** When adding a third-party SDK, wrap it behind a port first, then consume the port from services. ## UI / Frontend Design ### Desktop-First Design -- Optimize for 1280–2560px desktop resolutions — no mobile-first layouts -- Keyboard-first navigation with shortcuts everywhere (use `lucide-react` icons + ``) -- Horizontal layouts: sidebars, resizable panels (shadcn `ResizablePanelGroup`), command palettes (`cmdk`) -- Fluid typography/spacing for large screens: `text-3xl` → `text-4xl` on `lg`, `container mx-auto max-w-7xl` -- Minimum layout target: `min-width: 1024px` +- Optimize for 1280 to 2560px desktop resolutions, no mobile-first layouts. +- Keyboard-first navigation with shortcuts everywhere (use `lucide-react` icons + ``). +- Horizontal layouts: sidebars, resizable panels (shadcn `ResizablePanelGroup`), command palettes. +- Fluid typography and spacing for large screens: `text-3xl` → `text-4xl` on `lg`, `container mx-auto max-w-7xl`. +- Minimum layout target: `min-width: 1024px`. ### shadcn/ui + Tailwind v4 -- Use CVA for component variants, `cn()` for class merging, CSS variables from theme -- Respect system dark mode via `prefers-color-scheme` + manual toggle with shadcn theme provider -- Build using composition: small shadcn-extended primitives first (e.g., ``, ``), then compose pages -- Use CVA to add custom variants (e.g., button with `glass`, `command`) -- Keep logic out of UI files — prefer hooks + context +- Use CVA for component variants, `cn()` for class merging, CSS variables from theme. +- Respect system dark mode via `prefers-color-scheme` plus a manual toggle with the shadcn theme provider. +- Build by composition, small shadcn-extended primitives first, then compose pages. +- Use CVA to add custom variants, such as a button with `glass` or `command`. +- Keep logic out of UI files. Prefer hooks and context. ### Page Layout Patterns -- **Header**: Centered title only, draggable region (`-webkit-app-region: drag`), no navigation buttons inside header -- **Back button**: Absolute-positioned overlay on the content area below the header — `absolute left-6 top-3 z-20` on a plain `