From 6e354ae30a0f2514f19c4a967de54c7b8798cf8b Mon Sep 17 00:00:00 2001
From: Ross Miller
Date: Wed, 22 Jul 2026 20:16:19 -0500
Subject: [PATCH 01/25] docs(plans): commit the reconciled phase 5 plan
Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
---
docs/plans/refactor/phase-5.md | 99 ++++++++++++++++++++++++++++++++++
1 file changed, 99 insertions(+)
create mode 100644 docs/plans/refactor/phase-5.md
diff --git a/docs/plans/refactor/phase-5.md b/docs/plans/refactor/phase-5.md
new file mode 100644
index 0000000..9a3dad1
--- /dev/null
+++ b/docs/plans/refactor/phase-5.md
@@ -0,0 +1,99 @@
+# Phase 5 — Final Sweep + Demo Readiness
+
+> **ARCHITECT'S POST-FLIGHT CORRECTIONS (written mid-effort after Phases 0-3 landed; these override the assumptions below):**
+> 1. The knip entry for the client is `client/app/main.tsx` (Phase 3 moved it under `client/app/`).
+> 2. CI runs on **macos-14** since Phase 0, so knip and the drift job append to that workflow; the branch-protection item stands.
+> 3. These follow-ups ledger items are Phase 5's (see follow-ups.md): #2 swap `client/api/profile.ts`'s local `ProfileResponse` to the shared one, #8 decide with the owner whether the stale untracked `.worktrees/`/`.claude/worktrees/` full-repo copies get deleted, #9 decide the never-emitted `stage` SSE event (server emit vs remove vs keep documented), #14 evaluate extracting `useLibraryDnd`/`useLibraryFilters` from the 1,190-line LibraryPage.tsx IF cheap and behavior-preserving (its size is a recorded, accepted plan exception — do not treat as a defect), #17 adopt `find-unnamed-buttons.mts` (staged next to this file, root-arg + non-zero-exit ready) as a committed `scripts/` check, optionally wired into CI.
+> 4. Visual verification methodology for any S3/S6 checks: compare against a SAME-BUILD control capture, never raw master-vs-branch byte equality — the `NoiseOverlay` texture regenerates via `Math.random()` on every mount (library surfaces only; reader is exactly comparable). Match viewport geometry between captures or the diff measures the harness, not the app.
+> 5. The main checkout hosted overlapping agents twice this effort; before S3's parallel review fan-out, confirm no prior phase's processes or dev servers survive (check ports 3147/3247 parentage before killing anything).
+> 6. Phase 4 now ships a root `ARCHITECTURE.md` hub with the five-diagram suite. S6's README architecture section therefore shrinks to ONE overview diagram plus a prominent link to ARCHITECTURE.md, do not duplicate the suite. The "Start here" line under the badges points at ARCHITECTURE.md first.
+> 7. Phases 6 (E2E journeys on fake adapters) and 7 (durability: schema versioning, persisted generation jobs, AI error taxonomy) run before Phases 4 and 5. Your knip entries, fresh-clone script, and sweep scope must include their additions (e2e/ suite, migration fixtures, jobs journal). The fresh-clone script gains a step running the E2E suite.
+
+Closing phase. Runs after P0–P4 merge. Strictly subtractive plus docs; no behavior change.
+
+Consolidation deltas that modify this plan: the S4 repo-surface deletions (`.superpowers/`, `.agents/`, `skills-lock.json`, `pnpm-workspace.yaml`, marp scripts) were moved into Phase 0 — S4 here reduces to verification greps plus anything P0 deferred; S5 appends the knip job to the existing CI file rather than creating one; S8's issue/PR audit covers the six phase-tracking issues P0 created.
+
+## Recon deltas that shape this phase
+
+- `TUTOR_DATA_DIR` env var already exists (`shared/node/data-dir.ts` post-P1) — fresh-clone verification can run fully isolated from the real library.
+- Electron starts the server on port `0` (random) and exposes it via IPC, so a boot check must discover the port, not assume 3147.
+- `mac.notarize: true` in `package.json` will make an unattended `electron:build` attempt notarization and fail without credentials — the smoke build needs explicit flags.
+- `docs/screenshots/{progress,reader}.png` are unreferenced by the README — verify and prune or reference.
+- `scripts/ralph.sh` — review with the owner's intent in mind; likely prune.
+
+## Implementer tasks
+
+**S1 — Install the dead-code gate.** Add `knip` as devDependency (mature choice; `ts-prune` is archived and its author points to knip), with `knip.json` declaring entries: `server/index.ts`, `server/mcp-server.ts`, `electron/main.ts`, `electron/preload.ts`, `client/main.tsx` (post-P3: `client/app/main.tsx`), `scripts/*.ts`, `**/*.test.ts`. Add `"knip": "knip"` script.
+*Accept:* `pnpm knip` runs; its raw report is captured verbatim into the phase PR body as the sweep worklist.
+
+**S2 — Execute the knip worklist.** Delete unused files, exports, exported types, dependencies, and devDependencies it reports. Anything kept deliberately (public API of a port, fake used only in tests) goes in `knip.json` `ignore*` with a one-line comment saying why. Never delete on knip's word alone — grep the identifier across the repo first (dynamic imports are exactly what knip misses: `epub-gen-memory`, `sanitize-mermaid`, `mermaid-theme` are all dynamically imported), and run the fresh-clone script after S2, not just unit tests.
+*Accept:* `pnpm knip` exits 0. `pnpm test` green, `tsc --noEmit` clean, `eslint` 0 warnings.
+
+**S3 — Manual simplification sweep (parallel review agents, one per area: `shared/`, `server/ports+adapters`, `server/services+routes`, `client/features`, `client/api+store`, root config).** Each agent runs this checklist against `git diff master...HEAD` (the whole refactor) and reports findings only, no edits:
+1. Duplicate helpers that survived the move — grep for the known offenders (`buildProfileContext`, the six label arrays, `AI_TIMEOUT_MS`, the Zod error block, raw status literals) and for any function body appearing in two files.
+2. Orphaned files — modules with zero importers that knip missed (dynamic imports, string-path loads).
+3. Barrel/`index.ts` files that only re-export one thing.
+4. **Over-abstraction introduced by the refactor**: a port is suspect if it has exactly one adapter *and* no fake *and* no contract test *and* is called from one place — that is indirection, not a seam. Also flag: wrapper functions that only forward arguments, interfaces with a single implementation and no test double, service layers that only call one repository method, config objects with one field.
+5. Comments and JSDoc restating signatures, or describing the pre-refactor structure.
+6. Stale references to old paths (`@src/`, `src/`, `lib/`, `server/schemas.ts`, `prompts/*.md`) in code, configs, scripts, docs.
+*Accept:* findings consolidated into one list; each item either fixed or answered with a reason in the PR body. No finding silently dropped. Over-abstraction cuts are capped: only remove if under ~50 lines and fully covered by existing tests; otherwise file an issue.
+
+**S4 — Repo surface verification.** Verify P0's deletions held: `git ls-files | grep -E '\.DS_Store|^\.superpowers|^\.agents|^talks/|skills-lock|pnpm-workspace'` returns nothing. Prune unreferenced screenshots; review `scripts/ralph.sh`. Confirm `lint` script globs are `client/ server/ shared/ electron/`.
+*Accept:* greps clean; fresh `pnpm install` emits no "ignored build scripts" prompt.
+
+**S5 — CI + badges.** Append a `knip` step to the existing `.github/workflows/ci.yml`. Confirm the full job list: install, typecheck, lint, test, docs:routes drift, knip. Add branch protection on `master` requiring the CI check.
+*Accept:* CI green on the phase PR; deliberately editing the routes doc fails the drift job.
+
+**S6 — README polish.** Insert an Architecture section after Features: one inline mermaid diagram (GitHub renders natively) showing `client feature slices → typed api client → thin routes → services → ports → adapters`, ~10 nodes max, package granularity so it only changes when the top-level shape changes. Link out to `docs/adr/`, `CONTEXT.md`, the generated routes doc, and the talks repo. Add three badges at top: CI status, license, latest release. Refresh the Tech Stack table and the Development block against the new script names and folder layout. Verify every screenshot against the running app.
+*Accept:* mermaid renders on GitHub (verify on the PR preview); every relative link resolves; no path named in README is absent from the tree.
+
+**S7 — Fresh-clone verification script.** Commit `scripts/verify-fresh-clone.sh`, `set -euo pipefail`, steps in order:
+1. `git clone --depth 1 file://$PWD "$TMP/tutor"` (catches "works only because of untracked files").
+2. `pnpm install --frozen-lockfile`.
+3. `pnpm test`, `pnpm exec tsc --noEmit`, `pnpm lint --max-warnings 0`, `pnpm knip`.
+4. Server boot: `TUTOR_DATA_DIR="$TMP/data" pnpm dev:server &`, poll `http://127.0.0.1:3147/api/health` for ok with a 30s timeout, kill.
+5. `pnpm build`, then Electron boot: launch `electron . --remote-debugging-port=9222` with `TUTOR_DATA_DIR="$TMP/data"`, poll `http://127.0.0.1:9222/json` until a target exists, assert its URL is the built `index.html`, then kill. (Do not assume port 3147 here — the embedded server uses a random port.)
+6. Build smoke: `CSC_IDENTITY_AUTO_DISCOVERY=false pnpm electron:build --config.mac.notarize=false --publish never`, assert a DMG appears in `release/`.
+7. `rm -rf "$TMP"` in a trap.
+*Accept:* script exits 0 from a clean checkout on a machine with no `.env`; total runtime recorded in the PR body (expect 8–15 minutes; budget one clean run plus one re-run).
+
+**S8 — Final git/GitHub state.** Confirm PR sequence P0→P1→(P2,P3)→P4→P5 all merged in order with conventional-commit titles and gate evidence in each body. Delete merged local and remote branches (~15 stale ones predate the refactor). Update repo description to lead with the engineering story. Add topics: `hexagonal-architecture`, `ports-and-adapters`, `typescript`, `electron`, `claude-code`. Set the About website. Add a "Start here" line directly under the badges pointing at Architecture, ADRs, CONTEXT.md. Close the six phase-tracking issues with a comment linking each phase PR.
+*Accept:* `gh repo view` shows the new description/topics; `git branch -a` shows only active branches; the GitHub landing view shows badges, Start-here, screenshot, architecture diagram within one scroll.
+
+**S9 — Demo runbook.** Author outside the repo, deliver as a file to the owner, never committed. Outline below.
+
+## Runbook outline (10 minutes, three acts)
+
+**Act 1 — Cold reader tour on GitHub (2:30).** Landing README → badges (CI green) → screenshot → architecture mermaid → click into `docs/adr/0001-filesystem-as-database.md` → `CONTEXT.md` glossary showing the Skill collision resolved → generated routes doc.
+Talking points: "the routes doc cannot go stale, CI regenerates it and fails on drift"; "every non-obvious decision has an ADR, so the next agent inherits the reasoning, not just the result"; "the README diagram is the same shape as the folder tree."
+
+**Act 2 — Narrated architecture tour in the editor (4:00).** `server/ports/` → open `text-generation.ts` (the shape) → `adapters/ai-sdk-text-generation.ts` (the only file that imports the AI SDK) → the fake → the contract test that both must pass → a service unit-tested against the fake → a route that is six lines. Then flip to `client/features/reader/` and the one typed API client, showing shared contract types imported by both sides.
+Talking points: "the fakes exist because the ports do — I did not write mocks, I wrote a second adapter"; "the contract test is the port's definition of done, so swapping providers is a file, not a project"; "routes parse and delegate, that is the whole job"; "import boundaries are lint-enforced, so the architecture cannot rot quietly"; "the commit history shows the tests landing first — TDD you can audit."
+
+**Act 3 — Live AI session (3:30).** Run the `verify` skill cold (test + typecheck + lint + boot, all green on screen). Then the `add-feature` skill: add one tiny capability live, watching it walk the test-first recipe — contract test red, adapter green, service test red, service green, route, client call. Show `CLAUDE.md` and `CONTEXT.md` open beside it and point out where the agent is getting each constraint.
+Talking points: "the structure is the prompt — I am not explaining the codebase to it, it is reading the architecture"; "the same checklist a reviewer runs is the one the agent runs"; "watch the red-green rhythm — the skill enforces TDD, not my discipline in the moment."
+Close with the fresh-clone script running in a spare terminal from the start, finishing green on camera.
+
+**Prep block (do the day before):** throwaway `TUTOR_DATA_DIR` seeded with two finished books and one mid-generation; API keys loaded; caches warm so `pnpm install` is not on camera; a rehearsed fallback for the live AI step (a pre-recorded terminal capture) in case the model stalls.
+
+## Risks
+
+1. **Notarization blocks the build smoke.** Unattended `electron:build` will try to notarize and hang or fail. Mitigated by the explicit flags in S7 step 6; the signed path stays `scripts/release.sh`.
+2. **Knip false positives cause over-deletion.** Dynamic imports are exactly what knip misses. Rule: grep first, fresh-clone script after S2.
+3. **Over-abstraction cuts reopen P2.** Cap at ~50 lines and full test coverage; otherwise file an issue.
+4. **Mermaid drift.** Keep the diagram at package granularity.
+5. **Screenshots predate the refactor.** Behavior-preserving means they should still be accurate; verify each against the running app.
+6. **Timing.** The full fresh-clone script including `electron:build` is likely 8–15 minutes.
+
+## Final gate checklist
+
+- [ ] `pnpm test` green; `tsc --noEmit` clean; `eslint --max-warnings 0`; `pnpm knip` exits 0
+- [ ] `scripts/verify-fresh-clone.sh` exits 0 end to end, including the DMG smoke
+- [ ] CI green on `master`; routes-doc drift job proven to fail when the doc is edited
+- [ ] Every S3 finding either fixed or answered in writing
+- [ ] No tracked `.DS_Store`, `.superpowers/`, `.agents/`, `skills-lock.json`, `talks/`
+- [ ] README: badges render, mermaid renders, all relative links resolve, screenshots match the running app
+- [ ] `gh repo view` shows updated description, topics, and website
+- [ ] Merged branches pruned locally and on the remote; six phase issues closed with PR links
+- [ ] Demo runbook delivered to the owner as a file, not committed
+- [ ] Full 10-minute runbook rehearsed once end to end with the seeded data dir
From ce4aabf063d321d61b7b90837ccc4caaf05addd5 Mon Sep 17 00:00:00 2001
From: Ross Miller
Date: Wed, 22 Jul 2026 20:23:31 -0500
Subject: [PATCH 02/25] refactor(server): name the last two delays, trim
provenance comments, state the forwarder convention
The generate-all poll and the capture settle delay become named constants. Two adapter docblocks stop narrating where their code came from and keep only the constraints. The two pure-forward services gain a comment stating they exist to keep the route-service-port layering uniform, answering the review sweep rather than removing them.
Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
---
server/adapters/ai-sdk-text-generation.ts | 5 ++---
server/adapters/electron-diagram-renderer.ts | 5 ++++-
server/adapters/epub-gen-export.ts | 4 +---
server/constants.ts | 3 +++
server/services/generate-all-chapters.ts | 9 +++++----
server/services/get-book.ts | 6 +++++-
server/services/get-skill-progress.ts | 6 +++++-
7 files changed, 25 insertions(+), 13 deletions(-)
diff --git a/server/adapters/ai-sdk-text-generation.ts b/server/adapters/ai-sdk-text-generation.ts
index 7d9c113..496bb8a 100644
--- a/server/adapters/ai-sdk-text-generation.ts
+++ b/server/adapters/ai-sdk-text-generation.ts
@@ -28,9 +28,8 @@ import { nextDelayMs, RETRY_TOTAL_ELAPSED_CEILING_MS } from './retry-policy.js'
* out, get rate limited, or hit a transient provider outage, which is why
* this adapter, not its callers, owns a hard timeout and a retry policy.
*
- * It owns four things that were duplicated across those call sites before
- * this port existed. The first is resolving a provider + model into a
- * callable client, lifted from the pre-port `services/model-client.ts`.
+ * It owns four things so no caller has to. The first is resolving a
+ * provider + model into a callable client.
* The second is the five-minute generation
* timeout, composed by {@link composeAbortSignal}. The third is the
* `experimental_repairText` logging hook used when a model's JSON output
diff --git a/server/adapters/electron-diagram-renderer.ts b/server/adapters/electron-diagram-renderer.ts
index eba9e07..8ac57e9 100644
--- a/server/adapters/electron-diagram-renderer.ts
+++ b/server/adapters/electron-diagram-renderer.ts
@@ -8,6 +8,9 @@ import { diagramSourceFallback, type DiagramRenderer } from '../ports/diagram-re
/** How long a single chart gets to render before its slot falls back to the escaped source block. */
const DEFAULT_PER_CHART_TIMEOUT_MS = 10_000
+/** One compositor beat between resizing the hidden window and capturing it, so the capture sees the resized page. */
+const RESIZE_SETTLE_MS = 100
+
/**
* The minimal surface this adapter needs from an Electron BrowserWindow
* instance. Electron's real BrowserWindow satisfies this structurally, so
@@ -111,7 +114,7 @@ export function createElectronDiagramRenderer(deps: ElectronDiagramRendererDeps)
// Resize to fit diagram and capture as PNG
win.setContentSize(Math.max(dimensions.width, 200), Math.max(dimensions.height, 100))
- await new Promise(r => setTimeout(r, 100))
+ await new Promise(r => setTimeout(r, RESIZE_SETTLE_MS))
const image = await win.webContents.capturePage()
const pngBuffer = image.toPNG()
// Save to temp file — epub-gen-memory doesn't support data: URLs
diff --git a/server/adapters/epub-gen-export.ts b/server/adapters/epub-gen-export.ts
index 9e0f528..3f015eb 100644
--- a/server/adapters/epub-gen-export.ts
+++ b/server/adapters/epub-gen-export.ts
@@ -3,9 +3,7 @@ import type { EpubBuildRequest, EpubExport } from '../ports/epub-export.js'
/**
* Wraps epub-gen-memory to build EPUB bytes from already-rendered chapter
- * HTML. Lifted from the POST /api/books/:id/export-epub route handler in
- * server/routes/books.ts, which built this same options object plus a
- * dynamic import of epub-gen-memory inline.
+ * HTML.
*
* epub-gen-memory ships as CJS with `__esModule` set. Under Node's ESM
* loader a dynamic import() of it yields a double-wrapped default: this
diff --git a/server/constants.ts b/server/constants.ts
index 29f2d96..dbc891d 100644
--- a/server/constants.ts
+++ b/server/constants.ts
@@ -74,3 +74,6 @@ export const IMPORT_BODY_LIMIT_BYTES = 20 * 1024 * 1024 // 20MB, to accommodate
/** Audio bitrate ffmpeg encodes with when muxing the final M4B audiobook. */
export const M4B_BITRATE = '64k'
+
+/** How often generate-all re-checks whether a conflicting single-chapter generation has cleared. */
+export const GENERATION_BATCH_POLL_MS = 1_000
diff --git a/server/services/generate-all-chapters.ts b/server/services/generate-all-chapters.ts
index 8c69a1c..2fc0c38 100644
--- a/server/services/generate-all-chapters.ts
+++ b/server/services/generate-all-chapters.ts
@@ -1,3 +1,4 @@
+import { GENERATION_BATCH_POLL_MS } from '../constants.js'
import type { GenerationJobParams } from '@shared/domain.js'
import type { ProviderId } from '@shared/provider.js'
import type { BackgroundTasks } from '../ports/background-tasks.js'
@@ -12,9 +13,9 @@ import type { GenerateNextChapter, GenerateNextChapterOptions } from './generate
*
* Waits for any single-chapter generation already active for this book
* (started through the SSE hub) to clear before generating each chapter,
- * so the two flows never write the same chapter concurrently. Checking
- * again after a fixed delay, rather than subscribing to the hub, mirrors
- * how server/services/generation-manager.ts polled before this refactor.
+ * so the two flows never write the same chapter concurrently. A fixed-delay
+ * re-check is deliberate, subscribing to the hub would couple this task to
+ * its lifecycle for no gain at this frequency.
*/
export function createGenerateAllChapters(deps: {
backgroundTasks: BackgroundTasks
@@ -59,7 +60,7 @@ export function createGenerateAllChapters(deps: {
// Wait if single-chapter generation is active
while (deps.chapterStream.isGenerating(bookId)) {
- await new Promise(r => setTimeout(r, 1000))
+ await new Promise(r => setTimeout(r, GENERATION_BATCH_POLL_MS))
if (task.signal.aborted) return
}
diff --git a/server/services/get-book.ts b/server/services/get-book.ts
index db8ec3e..2b970d8 100644
--- a/server/services/get-book.ts
+++ b/server/services/get-book.ts
@@ -23,7 +23,11 @@ export interface GetBookTocDeps {
books: BookRepository
}
-/** GET /api/books/:id/toc — the approved table of contents. */
+/**
+ * GET /api/books/:id/toc — the approved table of contents.
+ * A pure forward to the port, kept so every route goes through a service
+ * and the route-service-port layering stays uniform across the server.
+ */
export function createGetBookToc({ books }: GetBookTocDeps) {
return async function getBookToc(bookId: string): Promise {
return books.getToc(bookId)
diff --git a/server/services/get-skill-progress.ts b/server/services/get-skill-progress.ts
index d173754..1fb4c79 100644
--- a/server/services/get-skill-progress.ts
+++ b/server/services/get-skill-progress.ts
@@ -5,7 +5,11 @@ export interface GetSkillProgressDeps {
books: BookRepository
}
-/** GET /api/progress/skills — skill mastery rolled up across every book. */
+/**
+ * GET /api/progress/skills — skill mastery rolled up across every book.
+ * A pure forward to the port, kept so every route goes through a service
+ * and the route-service-port layering stays uniform across the server.
+ */
export function createGetSkillProgress({ books }: GetSkillProgressDeps) {
return async function getSkillProgress(): Promise {
return books.getSkillProgress()
From 69be9144e38f51438b7dccccae8b28046f6597e8 Mon Sep 17 00:00:00 2001
From: Ross Miller
Date: Wed, 22 Jul 2026 20:27:53 -0500
Subject: [PATCH 03/25] docs(readme): add badges, architecture overview, and a
refreshed dev section
The README had no status signals at the top, so add three badges for CI,
license, and the latest release, using shields.io endpoints backed by the
real workflow, license, and release data.
Move the ARCHITECTURE.md pointer up to sit directly under the badges and
state plainly that it is the entry point for technical readers, ahead of
CONTEXT.md and docs/adr/.
Add an Architecture section after Features with one small mermaid diagram
showing the client to server request path in six nodes, real folder names
only, plus a link to ARCHITECTURE.md for the full five-diagram hub.
Refresh the Tech Stack table and Development commands against the current
package.json and folder layout, since Playwright e2e tests exist but were
not mentioned anywhere in the README.
Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
---
README.md | 26 +++++++++++++++++++++++---
1 file changed, 23 insertions(+), 3 deletions(-)
diff --git a/README.md b/README.md
index edc5330..7a3c916 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,17 @@
# Tutor
+[](https://github.com/rsml/tutor/actions/workflows/ci.yml)
+[](LICENSE)
+[](https://github.com/rsml/tutor/releases/latest)
+
+Start here. [ARCHITECTURE.md](ARCHITECTURE.md) is the entry point for technical readers. The vocabulary is in [CONTEXT.md](CONTEXT.md) and the decisions, with what each one cost, are in [docs/adr/](docs/adr/README.md).
+
**Read smarter — personal tutors disguised as books.**
Books suggested and generated just for you based on your feedback, quiz results, and unique learning style.
Talks about this project live in [rsml/talks](https://github.com/rsml/talks).
-Reading the code? Start at [ARCHITECTURE.md](ARCHITECTURE.md). The vocabulary is in [CONTEXT.md](CONTEXT.md) and the decisions, with what each one cost, are in [docs/adr/](docs/adr/README.md).
-
@@ -98,6 +102,21 @@ Give feedback on each chapter. The next one adapts to your quiz results and lear
+## Architecture
+
+```mermaid
+flowchart LR
+ features["client/features/ feature slices"] --> api["client/api/ typed API client"]
+ api -->|"HTTP and SSE"| routes["server/routes/ thin routes"]
+ routes --> services["server/services/"]
+ services --> ports["server/ports/"]
+ ports --> adapters["server/adapters/"]
+```
+
+Client feature slices call the server through one typed API client. Routes stay thin and hand off to services, which depend on ports rather than concrete adapters.
+
+See [ARCHITECTURE.md](ARCHITECTURE.md) for the full five-diagram hub, including how the pieces talk to each other, the server hexagon, the adaptive loop, and the dependency rule.
+
## Build Standalone DMG
```bash
@@ -112,6 +131,7 @@ pnpm install
pnpm dev:server # Keep this running one tab
pnpm electron:dev # Run this in a different tab
pnpm test # Run tests
+pnpm e2e # Run end-to-end tests
```
Set your Claude, ChatGPT or Gemini API key in Settings (gear icon) on first launch.
@@ -128,7 +148,7 @@ Set your Claude, ChatGPT or Gemini API key in Settings (gear icon) on first laun
| AI | Vercel AI SDK |
| Storage | Filesystem (Markdown + YAML) |
| Desktop | Electron (via vite-plugin-electron) |
-| Testing | Vitest |
+| Testing | Vitest + Playwright (e2e) |
## License
From 8f253b25e740f22ab5949fd06a5330db5497760b Mon Sep 17 00:00:00 2001
From: Ross Miller
Date: Wed, 22 Jul 2026 20:48:52 -0500
Subject: [PATCH 04/25] fix(library): make book cards keyboard operable
BookCard rendered as a plain div with onClick and onContextMenu, no
role, no tab stop, and no accessible name, so a keyboard user could not
open a book at all. This is issue 51 item 1, the most severe of the
seven accessibility gaps the Phase 6 journey suite surfaced.
The card now carries role="button", a tabIndex that follows the same
isGenerating gate the click handler already uses, an aria-label taken
from the title, and a keydown handler that activates on Enter or Space
the same way a native button would. The card stays a div rather than a
real button element on purpose, since its cover art, badges, and the
readonly StarRating it nests are easier to reason about without also
managing a native button's default styling and its interaction with
form semantics.
The rename journey's own locator collided with this fix in a way worth
recording. That fixture seeds a book titled "Rename Me", and once the
card exposes an accessible name, getByRole("button", { name: "Rename" })
matches both the card and the actual "Rename" menu item, because
Playwright's default name matching is a substring match and "Rename Me"
contains "Rename". The test now asks for an exact match, which is the
smallest change that keeps its original intent of clicking the menu
item rather than the card.
Claude-Session: https://claude.ai/code/session_01FSkESkJTRpt3Ye2cM7c5n5
---
client/features/library/components/BookCard.tsx | 15 ++++++++++++++-
e2e/journeys/library-crud.spec.ts | 6 +++++-
2 files changed, 19 insertions(+), 2 deletions(-)
diff --git a/client/features/library/components/BookCard.tsx b/client/features/library/components/BookCard.tsx
index e8e72d3..0150c68 100644
--- a/client/features/library/components/BookCard.tsx
+++ b/client/features/library/components/BookCard.tsx
@@ -38,7 +38,20 @@ function BookCardInner({ title, subtitle, chaptersRead, totalChapters, status, r
const isFailed = isFailedBook(status)
return (
-
{/* Depth halo — fades in on hover to lift the card off the page.
Sits behind the cover (first DOM child → painted underneath). */}
diff --git a/e2e/journeys/library-crud.spec.ts b/e2e/journeys/library-crud.spec.ts
index 45d8537..a23d1d9 100644
--- a/e2e/journeys/library-crud.spec.ts
+++ b/e2e/journeys/library-crud.spec.ts
@@ -25,7 +25,11 @@ test('rename persists across reload', async ({ page, app }) => {
await library(page).waitForReady()
await library(page).card('Rename Me').click({ button: 'right' })
- await page.getByRole('button', { name: 'Rename' }).click()
+ // exact: true, because BookCard itself is now a named role="button" (issue
+ // #51 item 1), and this fixture's title, "Rename Me", contains "Rename" as
+ // a substring, which Playwright's default non-exact name matching would
+ // otherwise also match, alongside the actual "Rename" menu item.
+ await page.getByRole('button', { name: 'Rename', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Rename Book' })
// The Title field's