diff --git a/.claude/skills/add-feature/SKILL.md b/.claude/skills/add-feature/SKILL.md new file mode 100644 index 0000000..dbb6d2d --- /dev/null +++ b/.claude/skills/add-feature/SKILL.md @@ -0,0 +1,49 @@ +--- +name: add-feature +description: Add a capability to Tutor end to end, following the test-first port, adapter, service, route, api client, feature slice recipe. +--- + +# Add a Feature to Tutor + +The recipe for adding a capability that needs new server behavior, from domain types down to the UI. Test commits precede or accompany implementation commits at every step. That ordering is deliberate and should stay visible in git history. + +## When to use, when not + +Use this when the feature reaches new server behavior or a new external dependency. Skip it for a pure UI tweak with no new I/O. That is just a component edit, no port, service, or route involved. + +## Decide whether a new port is needed + +Only add a port when the feature reaches a genuinely new external dependency, meaning a new service, a new binary, or a new file format. Reusing an existing port is the common case. There are 15 today (`text-generation`, `key-vault`, `image-generation`, `book-repository`, `artifact-store`, `speech-synthesis`, `audio-assembly`, `diagram-renderer`, `epub-import`, `epub-export`, `background-tasks`, `job-journal`, `library-migrator`, `clock`, `os-file-manager`). Check `server/ports/README.md` for the current list before assuming you need a new one. + +## Steps, in TDD order + +1. Define or extend the domain types in `shared/`. Persisted entities are Zod schemas in `shared/domain.ts`. Request bodies go in `shared/contracts.ts`. Response shapes go in `shared/responses.ts`. SSE event unions go in `shared/events.ts`. `shared/` is flat, there is no subfolder per feature. + +2. Declare the port interface in `server/ports/.ts`, named for the capability and never for the vendor. + +3. Write the contract test `server/ports/.contract.ts` and the in-memory fake `server/ports/.fake.ts`, then `server/ports/.fake.test.ts`, which runs the contract against the fake. This is RED first, there is no adapter yet. + +4. Write the adapter `server/adapters/-.ts` and run the same contract against it. That turns the contract GREEN and is what stops the fake drifting from the real thing. `fs-book-repository.ts`, `kokoro-speech-synthesis.ts`, and `system-clock.ts` are worked examples of this naming. When the technology name already implies the capability's own prefix, the capability half gets shortened instead of duplicated, as in `epub2-import.ts` for the `epub-import` port. A couple of adapters, such as `os-file-manager.ts`, carry no technology prefix at all because there is only one plausible implementation. Match the port's name unless one of those two cases applies. + +5. Write the service unit test `server/services/.test.ts` against the fake. RED. + +6. Write the service `server/services/.ts` as a `createX(deps)` factory, where `deps` is an object of ports by name, for example `{ ai, books, clock }`. GREEN. See `server/services/create-book.ts` for the shape. + +7. Add the thin route in the matching `server/routes/*.ts` module (`library.ts`, `reading.ts`, `assessment.ts`, `authoring.ts`, and others by domain area, not one file per entity). Parse the body with the Zod schema from `shared/contracts.ts` through `parseBody()` in `server/http/parse.ts`, then delegate to the service. Register any new port and service through `server/composition-root.ts`, adding a field to the `Ports` interface and wiring it in `createPorts()`. Then run `pnpm docs:routes` and commit the regenerated `docs/api-routes.md`. + +8. Add one function to the matching `client/api/*.ts` module with its mocked-fetch test, then the feature hook and component under `client/features//` (see `audiobook`, `chat`, `creation`, `library`, `markdown`, `profile`, `progress`, `quiz`, `reader`, `settings` for the existing slices, each with its own `components/` and `hooks/`). + +## Conventions checklist + +- No raw `fetch` or `new EventSource` outside `client/api/`. Both are lint errors, enforced in `eslint.config.mjs`, not just conventions. +- No SDK or vendor import outside `server/adapters/`. +- No `fs` in `server/services/` or any domain module. +- No magic strings where a named constant already exists. +- JSDoc on new exported symbols, stating a constraint the signature cannot show on its own. +- Tests colocated beside the file they cover, as `*.test.ts`. +- Test commits before implementation commits. +- Domain names taken from `CONTEXT.md`, never invented fresh. + +## Finish + +Run the `verify` skill before calling the feature done. diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md new file mode 100644 index 0000000..915436d --- /dev/null +++ b/.claude/skills/verify/SKILL.md @@ -0,0 +1,57 @@ +--- +name: verify +description: Verify the Tutor repo is green before a commit, a PR, or a handoff. Runs this project's test, typecheck, lint, generated-docs drift, and server boot checks, then reports a pass or fail table. +--- + +# Verify Tutor + +Runs the checks that gate a commit, a PR, or a handoff to another agent in this repo. This is not a general test runner. It is the specific set of commands this project uses to call a change safe. + +## When to use + +- Before committing. +- Before opening a PR. +- Before handing work to another agent. +- After any change that touches the server routes. + +## Steps + +Run these in order. Each names the exact command and what to expect. + +1. `pnpm test`. Runs the full Vitest suite. Expect every test to pass, with no failures reported. + +2. `pnpm typecheck`. Runs `tsc --noEmit`. Expect no output. + +3. `pnpm lint`. Runs ESLint across the project with `--max-warnings 0`. Expect no output. Zero warnings is the bar, not zero errors. + +4. `pnpm docs:routes && git diff --exit-code docs/api-routes.md`. Regenerates `docs/api-routes.md` from the live route definitions, then fails if the regenerated file differs from what is committed. If it fails, the fix is to commit the regenerated file, not to hand-edit the doc. + +5. Boot check. Starts the real server, hits the health endpoint, then stops the server. Run as separate commands so the server can be killed cleanly: + + ``` + pnpm dev:server & + SERVER_PID=$! + sleep 2 + curl -s http://127.0.0.1:3147/api/health + kill $SERVER_PID + ``` + + Expect the curl output to be `{"status":"ok"}`. This binds port 3147, so if an already-running dev server is holding that port, this step fails with `EADDRINUSE`. That is a real signal to go check for a stray server, not noise to ignore or retry past. + +Optionally, `pnpm e2e` is a heavier Playwright suite that builds the app and drives a real browser. It is not part of the default set above because of that cost, but run it when a change touches end-to-end reader or generation flows. + +## Failure handling + +Fix every failure, then rerun the full set from step 1. Never report the first failure and stop partway. If a failing check is out of scope for the current change, say so explicitly in the report, with the actual output as evidence, rather than silently skipping it. + +## Report format + +Report a markdown table, one row per step, Evidence always a real line copied from the command's actual output, never a paraphrase or a guess. + +| Check | Command | Result | Evidence | +|-------|---------|--------|----------| +| Tests | `pnpm test` | Pass | `Test Files 136 passed (136)` | +| Typecheck | `pnpm typecheck` | Pass | (no output) | +| Lint | `pnpm lint` | Pass | (no output) | +| Docs drift | `pnpm docs:routes && git diff --exit-code docs/api-routes.md` | Pass | (no diff) | +| Boot check | `pnpm dev:server` + `curl .../api/health` | Pass | `{"status":"ok"}` | diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd28061..055c116 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,19 @@ jobs: - run: pnpm test + # docs/api-routes.md is generated from the Fastify route registry, so it + # can only be wrong if someone changed a route and did not regenerate it. + # This step is what makes that impossible to merge. It runs last because + # it boots the server, and a failure here is a stale file rather than + # broken code. + - name: Check the generated API routes doc is current + run: | + pnpm docs:routes + git diff --exit-code docs/api-routes.md || { + echo "::error file=docs/api-routes.md::docs/api-routes.md is stale. Run 'pnpm docs:routes' and commit the result." + exit 1 + } + # The end-to-end journey suite, in its own job so it runs alongside verify # rather than behind it. `pnpm test` stays the fast one and never waits on a # browser. See e2e/README.md for what the journeys cover and why the server diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..1d4eac6 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "tutor": { + "command": "pnpm", + "args": ["mcp:dev"], + "env": { + "TUTOR_API_URL": "http://127.0.0.1:3147" + } + } + } +} diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2cbbb7a --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,146 @@ +# 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. + +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. + +## 1. What talks to what + +```mermaid +flowchart LR + reader([Reader]) + subgraph app["Tutor.app"] + electron["electron/
main and preload"] + client["client/
React renderer"] + server["server/
embedded Fastify"] + end + library[("On-disk library
Markdown and YAML")] + providers["AI providers
Anthropic, OpenAI, Google"] + local["Kokoro TTS and ffmpeg
on this machine"] + + reader --> client + electron --> client + electron --> server + client -->|"HTTP and SSE on 127.0.0.1"| server + server --> library + server -->|"the reader's own API key"| providers + 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). + +## 2. The server hexagon + +```mermaid +flowchart LR + subgraph core["server core"] + direction TB + routes["routes/
parse and delegate"] + services["services/
application logic"] + domain["domain/
pure rules"] + routes --> services + services --> domain + end + + 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"] + 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"] + end + + services --> pStore + services --> pAi + services --> pMedia + services --> pSys + pStore --> aStore + pAi --> aAi + pMedia --> aMedia + 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. + +## 3. How a request travels + +```mermaid +flowchart LR + components["features/
components"] --> hooks["features/
hooks"] + hooks --> store["store/
Redux slices"] + hooks --> api["api/
the one HTTP client"] + api -->|"HTTP and SSE"| routes["routes/"] + routes --> services["services/"] + services --> ports["ports/"] + 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. + +## 4. The adaptive loop + +```mermaid +sequenceDiagram + actor Reader + participant Client as client/ + participant Server as server/ + participant AI as TextGeneration + participant Disk as library + + Reader->>Client: topic and prompt + Client->>Server: POST /api/books + Server->>AI: draft the table of contents + Server->>Disk: meta.yml, toc.yml + Server-->>Client: SSE, status toc_review + Reader->>Client: approve the TOC + Client->>Server: PUT /api/books/:id/toc + Server->>AI: generate chapter 1 + Server-->>Client: SSE chunks as they stream + Server->>Disk: chapters/01.md + Reader->>Client: read, then submit feedback + Client->>Server: POST /api/books/:id/chapters/1/feedback + Server->>Disk: feedback/01.yml + Server->>AI: generate chapter 2 in the background + Reader->>Client: answer the quiz while it generates + 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). + +## 5. The dependency rule + +```mermaid +flowchart TD + client["client/"] --> shared["shared/"] + server["server/"] --> shared + electron["electron/"] --> shared + client -. "ESLint error" .-> server + 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. + +## 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. + +## Where to read next + +| Area | Start at | +|---|---| +| Server, routes, services, ports, adapters | [`server/README.md`](server/README.md) | +| React renderer and feature slices | [`client/README.md`](client/README.md) | +| Types both sides depend on | [`shared/README.md`](shared/README.md) | +| Electron shell and packaging | [`electron/README.md`](electron/README.md) | +| End-to-end journeys | [`e2e/README.md`](e2e/README.md) | +| Every decision and what it cost | [`docs/adr/`](docs/adr/README.md) | +| Domain vocabulary | [`CONTEXT.md`](CONTEXT.md) | +| Generated HTTP surface | [`docs/api-routes.md`](docs/api-routes.md) | diff --git a/CLAUDE.md b/CLAUDE.md index 077b456..60a2486 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,24 +2,46 @@ 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. +## Start here + +| Question | Answer lives in | +|---|---| +| How does this fit together? | [`ARCHITECTURE.md`](ARCHITECTURE.md), the entry point, five diagrams | +| What does this word mean? | [`CONTEXT.md`](CONTEXT.md), the domain glossary | +| Why is it built this way? | [`docs/adr/`](docs/adr/README.md), eight decisions and what each cost | +| What endpoints exist? | [`docs/api-routes.md`](docs/api-routes.md), generated, run `pnpm docs:routes` | +| How do I add a capability? | the `add-feature` Agent Skill | +| Is the repo green? | the `verify` Agent Skill | + ## 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** — Click any sentence to slide out a chat panel for deeper AI explanation, then return to where you left off +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) -## Architecture +## Repo layout + +Each line links to the README that owns that folder. There is no file-by-file tree here on purpose, because it rots. -- **Storage:** Filesystem — Markdown chapters + YAML metadata in `books/` -- **Backend:** Fastify server (`server/`) -- **Frontend:** React 19 + Vite (`src/`) -- **AI:** Vercel AI SDK (`ai` + `@ai-sdk/anthropic`) with structured output via `generateObject()` -- **Learning profile:** Global defaults in `books/learning-profile.yml` with per-book overrides +``` +client/ React 19 renderer → client/README.md + api/ the only code that talks to server → client/README.md + features/ one folder per capability → client/features/README.md +server/ embedded Fastify, hexagonal → server/README.md + ports/ 15 interfaces, fakes, contracts → server/ports/README.md + adapters/ the only place real I/O happens → server/adapters/README.md + services/ application logic over ports → server/services/README.md + migrations/ forward-only schema steps → server/migrations/README.md +shared/ types both sides import → shared/README.md +electron/ main and preload, packaging → electron/README.md +e2e/ Playwright journeys on fakes → e2e/README.md +docs/ ADRs, generated routes, plans → docs/adr/README.md +``` ## Tech Stack @@ -36,63 +58,7 @@ AI-generated books tailored to your learning style. Books are generated chapter- | AI | Vercel AI SDK (`ai` + `@ai-sdk/anthropic`) | | Validation | Zod | | Config | YAML (`yaml` package) | -| Testing | Vitest | - -## File Structure - -``` -tutor/ -├── books/ # Generated content (gitignored except learning-profile) -│ ├── learning-profile.yml # Global learning style config -│ └── {book-id}/ -│ ├── meta.yml # Status, title, prompt, overrides -│ ├── toc.yml # Approved table of contents -│ ├── chapters/ -│ │ └── 01.md ... NN.md # Chapter content (markdown) -│ ├── progress.yml # Per-chapter scroll progress -│ └── feedback/ -│ └── 01.yml ... NN.yml # Feedback + quiz per chapter -├── server/ # Backend (Fastify) -│ ├── index.ts # Server entry point -│ ├── schemas.ts # Zod schemas for all YAML metadata -│ ├── routes/ -│ │ ├── books.ts # CRUD, generation triggers, progress -│ │ ├── chapters.ts # Chapter content, status, quiz -│ │ └── profile.ts # Learning profile management -│ ├── services/ -│ │ ├── book-generator.ts # AI generation (TOC, chapters, quiz) -│ │ ├── book-store.ts # Filesystem read/write -│ │ └── generation-queue.ts # In-memory background generation tracking -│ └── prompts/ -│ ├── generate-toc.md -│ ├── generate-chapter.md -│ └── generate-quiz.md -├── src/ # Frontend (React + Vite) -│ ├── main.tsx -│ ├── App.tsx -│ ├── pages/ -│ │ ├── LibraryPage.tsx # Book grid with progress bars -│ │ └── ReaderPage.tsx # Chapter reader + feedback + quiz -│ ├── components/ -│ │ ├── BookCard.tsx -│ │ ├── BookGrid.tsx -│ │ ├── WizardModal.tsx # 3-step: prompt → TOC → generating -│ │ ├── MarkdownReader.tsx # Renders chapter markdown, clickable sentences -│ │ ├── InlineChatPanel.tsx # Slide-out AI chat for explaining selected text -│ │ ├── FeedbackForm.tsx -│ │ ├── QuizPanel.tsx -│ │ └── ProgressBar.tsx -│ ├── hooks/ -│ │ └── useScrollProgress.ts -│ ├── lib/ -│ │ ├── api.ts # Fetch wrapper for backend -│ │ └── utils.ts # cn() helper (shadcn) -│ └── store.ts # Redux Toolkit store -├── components.json # shadcn/ui config -├── index.html # Vite entry -├── vite.config.ts -└── vitest.config.ts -``` +| Testing | Vitest, Playwright for journeys | ## Key Design Decisions @@ -100,31 +66,10 @@ tutor/ - **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 -- **Background generation** — In-memory `Map`, fire-and-forget on quiz submit -- **If server restarts mid-generation** — Book stays valid, user can retrigger from the reader -- **Inline chat** — Click any sentence to open a slide-out panel for AI-powered deeper explanation; dismissing returns to reading position - -## API Routes - -| Method | Path | Purpose | -|--------|------|---------| -| `GET` | `/api/books` | List all books | -| `POST` | `/api/books` | Start new book (generates TOC) | -| `GET` | `/api/books/:id` | Get book metadata + progress | -| `DELETE` | `/api/books/:id` | Delete a book | -| `POST` | `/api/books/:id/reset` | Reset reader interaction (progress, rating, feedback, quiz answers) | -| `GET` | `/api/books/:id/toc` | Get table of contents | -| `PUT` | `/api/books/:id/toc` | Approve TOC, triggers Ch.1 generation | -| `GET` | `/api/books/:id/chapters/:num` | Get chapter markdown content | -| `GET` | `/api/books/:id/chapters/:num/status` | Check generation status | -| `PUT` | `/api/books/:id/progress/:num` | Update scroll progress | -| `POST` | `/api/books/:id/chapters/:num/feedback` | Submit chapter feedback | -| `GET` | `/api/books/:id/chapters/:num/quiz` | Get quiz questions | -| `POST` | `/api/books/:id/chapters/:num/quiz` | Submit quiz, triggers next chapter | -| `POST` | `/api/books/:id/chapters/:num/chat` | Inline chat about a sentence/passage | -| `GET` | `/api/profile` | Get learning profile | -| `PUT` | `/api/profile` | Update learning profile | +- **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 ## Electron Packaging @@ -141,38 +86,45 @@ This is an Electron app using `vite-plugin-electron`. Three modes exist with dif - **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 `books.ts`). -- **Never modify `index.html` or `package.json` to match build output** — `dist/` is the build target, source files must keep source references (`/src/main.tsx`) +- **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`) ## Development ```bash pnpm test # Run all tests +pnpm typecheck # tsc --noEmit +pnpm lint # ESLint, zero warnings is the bar +pnpm e2e # Playwright journeys against the fake AI adapter +pnpm docs:routes # Regenerate docs/api-routes.md, CI fails on drift pnpm electron:dev # Dev mode (Vite + Electron + HMR) pnpm electron:preview # Build then run (test production rendering) pnpm electron:build # Build + package DMG pnpm dev:server # Fastify standalone on port 3147 +pnpm mcp:dev # MCP server, needs dev:server on 3147 (see .mcp.json) ``` ## Conventions -- Zod schemas live in `server/schemas.ts` — single source of truth for all data shapes +- 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 (`ai` package) for all AI calls — prefer `generateObject()` for structured output +- Vercel AI SDK is reached only through the `TextGeneration` port, never imported outside `server/adapters/` - Tests colocated with source files (`*.test.ts`) -- Path aliases: `@client/*` → `client/*`, `@server/*` → `server/*` +- **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 (Aspirational) +## Domain & Architecture -The codebase is moving toward domain-driven design with ports-and-adapters separation. Apply these when adding or refactoring code; existing code does not all conform yet. +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`. Use these names everywhere — schemas, services, components, prompts, UI copy. Don't invent synonyms. -- **Pure domain core** in `server/schemas.ts` and any future domain modules — Zod types and pure functions only. No `fs`, `fetch`, AI SDK imports, or env vars inside the domain. -- **Ports for every external dependency**: AI providers, filesystem persistence, audio synthesis, EPUB tooling, image generation, API key storage, background queues, Electron IPC, frontend → backend HTTP. Each gets a single named module that the rest of the app depends on by shape, not by SDK. -- **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 port → return result. No business logic, no direct `fs` or SDK calls in `server/routes/*.ts`. -- **Frontend goes through one client**: components import from `src/lib/api.ts`, not raw `fetch`. 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 — 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 ## UI / Frontend Design @@ -192,7 +144,7 @@ The codebase is moving toward domain-driven design with ports-and-adapters separ ### 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 `