Files
Curio/docs/implementation-plan.md
arnaudne 5bf6013460 feat: full feature buildout — streaming, i18n, mastery map, admin, jobs
Progressive lesson streaming via onSegment callback (fixes SSE for non-English
users — locale was shadowed in lesson-reader useEffect). Adds: BullMQ workers,
Redis stream buffer, token budget enforcement, Langfuse tracing, golden-eval
runner, Playwright e2e scaffolding, lesson depth/locale/preferences schema,
mastery map UI, admin panel (blueprints/users/reports/quality/misconceptions),
image queries, source citations, view transitions, reading animations, i18n
(next-intl), PDF export, surprise endpoint, and 402 passing unit tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 22:08:14 +02:00

36 KiB

Curio — Implementation Plan (12 features)

Audience: Claude Sonnet, executing one feature at a time. Each section is self-contained: goal, verified current state (with file refs), changes, tests, and acceptance criteria.

This plan was written after mapping the codebase on 2026-06-23. Verify file:line refs before editing — code may have moved.

Global conventions (apply to every feature — non-negotiable)

These come from CLAUDE.md and the spec. Violating them is a bug:

  1. All LLM calls go through src/lib/llm/client.ts. Never call a provider/AI-SDK model directly. Generator and grader must differ.
  2. Prompts live in the versioned registry src/lib/llm/prompts/index.ts ({ id, version, template }). Never inline a prompt. Bump version on any semantic change.
  3. Contracts are Zod schemas in src/schemas/, shared by LLM ↔ API ↔ UI. Validate LLM structured output against them.
  4. Serve path reads the buffer; jobs generate. No synchronous generation in request handlers except the documented cold-start gate. Postgres is source of truth.
  5. Paid background jobs are idempotent (idempotency key + durable jobs ledger row). A retry must never regenerate-and-rebill.
  6. Per-session token budget is enforced in code (src/lib/budget/index.ts) — call checkBudget before any new paid LLM call, recordUsage after.
  7. Migrations are append-only. Never edit an applied migration. Add a new one via pnpm db:generate then pnpm db:migrate. (Note: the migrations dir already contains a few accidental duplicate migrations — do not "fix" them, just add new ones.)
  8. i18n: every user-facing string goes in BOTH messages/en.json and messages/fr.json under the right namespace, read via useTranslations (client) or getTranslations (server). No hardcoded copy.
  9. Tests with every change. Unit tests mock the LLM (Vitest, deterministic fixtures). Real-model calls only in tests/golden/. Run pnpm test before declaring done.
  10. Design quality floor: responsive, visible keyboard focus, reduced-motion respected, WCAG-AA contrast, dark-mode tokens. Reading surface stays calm/editorial; no gamification.

Stack reference: Next.js 15 App Router + React 19, Drizzle + Postgres/pgvector, Redis (ioredis + Upstash ratelimit), BullMQ workers (jobs/, run via pnpm workers), Vercel AI SDK, next-auth v5 (JWT), next-intl, nodemailer (SMTP), Langfuse. Vitest + Playwright.


Suggested execution order

Dependency-aware. Do them in this order unless told otherwise:

  1. #7 (audit migration) — small, de-risks the account story others build on.
  2. #10 (dark-mode polish) — small, isolated; extracts the setTheme helper #13 reuses.
  3. #13 (settings upgrade) — needs #10's setTheme helper; otherwise self-contained UI.
  4. #12 (review upgrade) — extracts the shared grading hook from the lesson reader.
  5. #2 (content versioning) — foundational; #1 and #3 lean on version/provenance.
  6. #1 (novel-misconception harvest loop) — moat; table already exists.
  7. #3 (false-fail dashboard) — consumes #1's labeled data + grades.
  8. #6 (multi-lesson journey) — product; #5 and #11's due-zone build on journey/mastery state.
  9. #11 (mastery upgrade) — surfaces due reviews; pairs with #6/#5.
  10. #5 (returning-learner home) — product; needs #6's journey state.
  11. #9 (streaming generation) — infra polish.
  12. #8 (due-review email digest) — retention; independent, do last.

UI features (#11/#12/#13) are loosely ordered — they can move earlier if a UI pass is wanted sooner; only #13→#10 (theme helper) and #12→lesson-reader (shared hook) are hard dependencies.

Each feature is one focused PR/commit. Keep Route Handlers thin; logic in src/lib/.


#7 — Anonymous → account progress migration (AUDIT + HARDEN)

Status: already implemented. This is an audit/harden task, NOT a build.

Current state (verified):

  • src/app/api/auth/migrate-session/route.ts exists. In a transaction it reassigns responses, mastery (with on-conflict skip when the auth user already has that concept), and journeyInstances from anonUserIdauthUserId, then renames the Redis budget:{id} key. Has __tests__/.
  • Called from src/app/auth/login/login-client.tsx and src/app/auth/register/page.tsx after auth success; clears sessionStorage curio_uid.

Goal: make it correct and complete, not bigger.

Changes / checks:

  1. Mastery conflict policy. Today conflicting concepts are skipped (anon progress dropped). Decide intended behavior: keep the higher score / more-recent lastSeen instead of silently dropping. If changing, do it in the onConflictDoUpdate and document why.
  2. Auth coverage. Confirm migration fires for ALL sign-in paths: credentials login, register, AND OAuth (Google/GitHub/OIDC) first sign-in. OAuth users never hit login-client.tsx. Add a migration trigger on the OAuth callback path (e.g. a client effect on first authenticated landing, or a signIn event in src/auth.ts events) — without it, OAuth users lose anon progress.
  3. Idempotency / double-call. Calling migrate twice (or with no anon data) must be a no-op, not an error. Verify.
  4. Validation & authz. The route must take anonUserId from the request but authUserId strictly from the server session (never trust a client-supplied authUserId). Confirm a malicious anonUserId can only move data INTO the caller's own account.
  5. accounts/sessions tables must not be touched by migration (those are auth-adapter owned).

Tests: extend migrate-session/__tests__: (a) conflict resolution keeps best mastery; (b) OAuth-path migration; (c) double-call no-op; (d) authz — session id always wins over body.

Acceptance: A user who learns anonymously, then signs up OR logs in via any provider, keeps all mastery/responses/journey. No data loss on concept conflicts. pnpm test green.


#10 — Dark mode (AUDIT + POLISH)

Status: already implemented. Audit/polish task.

Current state (verified):

  • src/styles/tokens.css has a full dark token block under :root[data-theme='dark'] plus a @media (prefers-color-scheme: dark) fallback for :root:not([data-theme='light']).
  • src/app/layout.tsx runs a no-flash theme bootstrap (reads localStorage('curio-theme'), sets data-theme on <html>).
  • src/components/site-header.tsx has a ThemeToggle cycling system → dark → light.

Goal: verified-correct dark mode on every surface + a settings entry.

Changes:

  1. Settings selector. Add a theme control to src/app/settings/settings-client.tsx (System / Light / Dark) that writes the same localStorage('curio-theme') key + data-theme the header toggle uses (share one helper — extract setTheme() to e.g. src/lib/theme.ts). Add i18n keys under settings.appearance.* in both locales.
  2. Contrast audit (WCAG-AA, spec §7.5 non-negotiable). Verify every diagnosis-state color in dark mode meets AA against its background: --color-mastered/partial/misconception/uncertain and their -subtle pairs, plus --color-tutor marginalia and accent on --color-surface. Fix any token that fails. This is the highest-risk part — the diagnosis colors carry meaning.
  3. Surface sweep. Manually verify dark rendering on: home, learn/reading surface + marginalia, checkpoint input, review, mastery map, auth pages, admin, settings. Look for hardcoded colors (e.g. #fff) that ignore tokens — grep -rn "#fff\|#000\|rgb(" src/app src/components src/styles and replace with tokens.
  4. Persisted preference (optional). If a user is logged in, persist theme in users.preferences (jsonb already exists) so it follows them across devices; localStorage remains the anon/no-flash source.

Tests: unit test the setTheme helper. Playwright: toggle to dark, assert data-theme="dark" on <html> and a known token's computed value.

Acceptance: Theme switchable from header AND settings; persists; no flash on reload; all diagnosis colors pass AA in both themes; no hardcoded non-token colors remain on core surfaces.


#2 — Content versioning + lazy invalidation

Goal: when a blueprint's generator model or prompt version changes, mark its content stale and regenerate lazily (on next serve or via a job), without regenerate-and-rebill races. Spec §10 / invariant #10.

Current state (verified):

  • blueprints.modelVersion (text) + blueprints.contentVersion (int, default 1). segments.contentVersion + checkpoints.contentVersion exist but are never incremented and never read (generate-blueprint.ts:~85 writes modelVersion = process.env.LLM_GENERATOR_MODEL, contentVersion = 1).
  • Prompt registry has versions (e.g. GENERATE_LESSON is v5) but nothing ties a blueprint to the prompt version it was built with.
  • jobs/promote-blueprint-job.ts already invalidates the Redis cache after promotion — reuse that invalidation primitive.

Define "current content signature": a tuple (generatorModel, generateLessonPromptVersion). Persist what each blueprint was built with; compare against current at serve/audit time.

Changes:

  1. Schema migration: add blueprints.promptVersion (int) and blueprints.staleAt (timestamp, nullable). Optionally blueprints.lastBuiltModel if modelVersion semantics are ambiguous. Generate via pnpm db:generate.
  2. Stamp on generation: in src/lib/generation/generate-lesson.ts / generate-blueprint.ts, write the current prompts.GENERATE_LESSON.version and generator model id at build time.
  3. Staleness check: add src/lib/generation/staleness.ts exporting isBlueprintStale(blueprint): boolean (compares stored model+promptVersion to current config) and markStale(blueprintId).
  4. Invalidation trigger: add an admin action (button on src/app/admin/blueprints/[id]) + an internal function to mark a blueprint stale and bump contentVersion. On the serve path (getLessonResponse in src/lib/db/queries.ts / src/app/learn/[key]/page.tsx), if stale, enqueue a regeneration job (do NOT regenerate synchronously) and keep serving the old version until the new one is ready (lazy invalidation — spec invariant #10).
  5. Regeneration job: add jobs/regenerate-blueprint-job.ts (or extend generate-lesson-job) that regenerates segments/checkpoints at a new contentVersion, re-runs T0/T1 (and T2 at promotion), and swaps the cache. Idempotency key must include the target contentVersion so a retry never double-bills. Reuse the jobs ledger pattern from generate-lesson-job.ts.
  6. Flagged-item regeneration: autoFlagCheckpointIfNeeded (already in queries.ts) sets a checkpoint to verifyStatus='fail'. Add an endpoint/admin action to regenerate just the flagged checkpoint at a bumped contentVersion (cheaper than whole-blueprint).

Tests: unit (LLM mocked): isBlueprintStale truth table; regeneration job bumps contentVersion and is idempotent under retry (same key → no second generation); serve path enqueues but does not block on stale.

Acceptance: Changing LLM_GENERATOR_MODEL or bumping the lesson prompt version causes new serves to enqueue regeneration; learners keep seeing valid old content until the new version verifies; retried jobs never double-generate; admin can force-regenerate a blueprint or a single flagged checkpoint.


#1 — Novel-misconception harvest loop

Goal: close the moat flywheel — turn novel-tagged grades into labeled misconceptions that grow the misconception library AND the golden set. Spec §10.2 / §10.4 / §15.

Current state (verified):

  • grades.misconceptionTag can be 'novel'. src/lib/verification/grader.ts:~183 already enqueues novel cases into the novelMisconductQueue table ({ gradeId, checkpointId, responseText, evidenceSpan, processed:boolean default false, createdAt }, schema ~line 268).
  • getPersonalizationContext (queries.ts:~394) already excludes 'novel' from personalization (only labeled misconceptions are used). So once a novel case is labeled into misconceptions, it automatically starts being used.
  • There is no UI to review the queue, no labeling action, and no path from a labeled item into tests/golden/misconception-cases.ts. That's the gap.

Changes:

  1. Queries (src/lib/db/queries.ts): getNovelQueue({ processed:false, limit }) returning queue rows joined with checkpoint + concept context; labelNovelMisconception({ queueId, conceptId, tag, signature, description, correction }) which (a) inserts a row into misconceptions with verifyStatus='pending', (b) marks the queue row processed=true, in one transaction; dismissNovelQueueItem(queueId) for false positives.
  2. Admin page src/app/admin/misconceptions/ (new): list unprocessed queue items showing the learner response with the evidenceSpan highlighted (reuse the marginalia highlight idiom from grade-display.tsx), the checkpoint prompt, reference answer, and existing misconceptions for that concept. Provide a label form (tag, signature, description, correction) and a dismiss button. Add to admin nav (src/app/admin/layout.tsx).
  3. Verification hook: after labeling, the new misconception is verifyStatus='pending'. Run it through the existing verifyMisconceptions (src/lib/verification/verify-misconceptions.ts) — either inline on label or via a small job — so only verified misconceptions go live.
  4. Golden-set growth: add appendMisconceptionGoldenCase() that writes a new case to tests/golden/misconception-cases.ts (or a sibling JSON the runner reads). At minimum, the admin label action should offer "also add as golden case" producing { response, expectedTag, conceptId }. Keep the golden file the source of truth for the eval.
  5. Optional clustering: if the queue is large, add a cheap-model job to cluster similar novel responses (group by embedding similarity of responseText) so a human labels a cluster once. Use client.cheapGrader / embedText. Defer if time-boxed.

Tests: unit (LLM mocked): labeling inserts misconception + flips processed atomically; dismiss leaves no misconception; labeled+verified misconception appears in getPersonalizationContext for that concept. Add ≥2 new cases to misconception-cases.ts and confirm pnpm test:golden:misconceptions still passes thresholds.

Acceptance: An admin can see novel responses, label or dismiss them; labeled items become verified misconceptions used in future grading/personalization; the golden set grows; the queue drains (processed=true). No new uncontrolled LLM cost on the serve path.


#3 — False-fail monitoring dashboard

Goal: make the spec's core quality metric — false-fail rate (telling a correct learner they're wrong) — continuously visible, not just a CI gate. Spec §15.

Current state (verified):

  • tests/golden/run-grading-eval.ts computes false-fail rate and verdict accuracy against frozen cases; CI-gated (pnpm test:golden, fails if false-fail > 10% or accuracy < 70%). Writes no persistent results (console only).
  • Live signal exists but isn't aggregated: grades (verdict, confidence, payloadJson), contentReports with reason='grade_wrong' (learner-disputed grades), getCheckpointFailureRate + autoFlagCheckpointIfNeeded in queries.ts.
  • src/app/admin/reports/ lists raw content reports; no metrics/aggregation.

Two data sources, show both:

  • Offline (golden): trend of false-fail rate / accuracy per eval run.
  • Online (production): grade_wrong report rate vs total grades; misconception/uncertain verdict distribution; checkpoints auto-flagged.

Changes:

  1. Persist golden runs: new table eval_runs (id, suite, falseFailRate, accuracy, total, model, promptVersion, gitSha nullable, createdAt). Make run-grading-eval.ts (and the content/misconception runners) insert a row at the end (guard behind an env flag so local runs can skip DB writes). Keep console output.
  2. Aggregation queries (src/lib/db/queries.ts or src/lib/metrics.ts): getEvalTrend(suite, limit); getOnlineGradeMetrics(window) → counts by verdict, grade_wrong report rate, # auto-flagged checkpoints, optionally per-blueprint.
  3. Admin dashboard src/app/admin/quality/ (new; link in admin nav): show latest false-fail rate with the 10% threshold marked (red if breached), a small trend (sparkline/table) from eval_runs, the online verdict distribution, and a list of checkpoints with the highest dispute/failure rate (drill into /admin/blueprints/[id]). Keep it calm and tabular — admin tool, not the learner surface.
  4. Alerting (optional): if the latest persisted false-fail rate breaches threshold, surface a banner on /admin.

Tests: unit: aggregation queries over seeded grades/reports return correct counts and rates; eval_runs insert is gated by env flag. No need to run real models in unit tests.

Acceptance: After a golden run, the admin quality page shows the latest + historical false-fail rate against the threshold; production grade/dispute metrics are visible; high-dispute checkpoints are findable. False-fail is now observable continuously.


#6 — Multi-lesson journey continuity

Goal: a Journey is a sequence of bounded lessons through a blueprint, not a single lesson. Let a learner finish lesson N and continue to N+1, with visible progress. Spec §1, §3.

Current state (verified):

  • blueprintsconcepts (ord) → lessons (ord, depth, locale) → segmentscheckpoints. journeyInstances.position is incremented by advanceJourneyPosition (queries.ts:~675) on lesson complete, but nothing reads position and there is no next-lesson chaining. /learn/[key] serves one lesson per intent (getLessonResponse).
  • LessonComplete in lesson-reader.tsx calls POST /api/advance then shows "Progress saved" — a dead end.

Decision to confirm with product: does one blueprint contain multiple lessons (multi-lesson per intent), or is each intent one lesson and a "journey" spans related blueprints? The schema supports multiple lessons per blueprint (lessons.ord), so default assumption: multiple lessons per blueprint, ordered by lessons.ord. Generation currently makes one lesson — extending generation to produce a multi-lesson arc is a larger content change; for this feature, support serving and navigating multiple lessons and generate subsequent lessons lazily on advance.

Changes:

  1. Serve by position: extend getLessonResponse (or add getLessonAtPosition(intentKey, userId, position, ...)) to return the lesson at the learner's current journeyInstances.position (default 0 → first lesson by lessons.ord). /learn/[key]/page.tsx reads the user's position to pick the lesson.
  2. Advance → next: on lesson complete, after advanceJourneyPosition, if a next lesson exists (or can be generated), navigate to it (e.g. /learn/[key]?pos=N or just re-fetch since position advanced). If the next lesson isn't generated yet, enqueue generate-lesson-job for (blueprintId, ord=position) and show the outline/poll state (reuse existing polling). If no next lesson and the blueprint is complete, show a real "Journey complete" terminal state (not just "saved").
  3. Lesson generation for position N: generate-lesson.ts / generate-lesson-job.ts currently assume ord 0. Parameterize by ord/position and pass the concept(s) for that position. Idempotency key must include ord.
  4. Progress UI: add a journey progress indicator to the reading surface header (e.g. "Lesson 2 of 5") sourced from lessons count + position. Calm, non-gamified (spec §7). i18n both locales.
  5. Cross-check #5: the returning-home "continue" surface will read the same journeyInstances + position.

Tests: unit (LLM mocked): position increments serve the next lesson; terminal state when position ≥ lesson count; advance enqueues generation for an ungenerated next lesson with an ord-scoped idempotency key. Playwright: complete a lesson → land on the next or a real completion screen.

Acceptance: Completing a lesson advances to the next lesson in the blueprint (generating it lazily if needed) with a visible "Lesson N of M"; finishing the last lesson shows a Journey-complete state; position persists per user. No synchronous generation on the serve path.


#5 — Returning-learner home

Goal: give the second visit a story — resume in-progress journeys, see due reviews, glance at mastery — instead of only an intent box. Spec §16 (mastery map, Daily Review).

Current state (verified):

  • src/app/page.tsx is intent entry only (title, lede, input, depth/mode, example chips, "spark"). No resume surface.
  • Data already available: journeyInstances (position) — after #6 this is meaningful; getMasteryMap(userId); getReviewCheckpoints(userId) / getDueReviews. Anonymous users have a curio_uid; logged-in users have session.user.id.

Changes:

  1. Detect "returning": a learner is "returning" if they have any journeyInstances, mastery, or due reviews. For anon users this requires the client curio_uid; the home page is a client component already, so fetch on mount (or add a GET /api/home-summary?userId= that returns { inProgress: [{intentKey, title, position, total}], dueReviewCount, recentMastery: [...] }). For logged-in users, prefer the session id.
  2. New endpoint src/app/api/home-summary/route.ts: aggregates in-progress journeys (join journeyInstancesblueprints, compute position/total), dueReviewCount (from getReviewCheckpoints), and a small recent-mastery slice. Reuse existing queries; add a getInProgressJourneys(userId) query.
  3. Home UI: when summary is non-empty, render (above or beside the intent box) a calm "Continue" section: resume cards linking to /learn/[intentKey] (which #6 resolves to the right lesson via position), a "Review (N due)" link to /review when dueReviewCount > 0, and a "Mastery" link. When empty (first visit), show today's intent-first layout unchanged. Keep editorial calm; no streaks/XP (spec anti-pattern).
  4. i18n: home.continue.*, home.dueReviews, etc., both locales.
  5. Loading/empty states: skeleton while summary loads; never block the intent box on the summary fetch (intent entry must always be instant).

Tests: unit: getInProgressJourneys + home-summary aggregation over seeded data; empty summary → first-visit layout. Playwright: a user with progress sees Continue cards + due-review link; a fresh user sees only the intent entry.

Acceptance: Returning learners (anon or logged-in) land on a home that surfaces in-progress journeys, due-review count, and mastery, each one click away; first-time visitors see the existing clean intent entry; the intent box is never delayed by the summary.


#9 — Streaming generation (skeleton-first)

Goal: replace the 4-second poll with true streamed delivery so a cold-start lesson appears progressively. Spec §8.3 (skeleton-first + buffer).

Current state (verified):

  • Cold start returns an outline state; src/components/lesson-reader.tsx:~97 polls router.refresh() every 4000ms until the lesson is ready. Generation runs in jobs/generate-lesson-job.ts (BullMQ), warming the Redis buffer.
  • Vercel AI SDK supports streaming; client.ts uses structured output (generateObject-style). Lesson generation is schema-constrained, which complicates token-streaming of structured output.

Approach (pick the lower-risk one that fits):

  • Option A — Stream segment-by-segment from the buffer (recommended). Keep generation in the job, but as each segment is generated + T0/T1-verified, push it to the Redis buffer (buffer:{...} per spec §17) and expose GET /api/lessons/stream?key=... as an SSE endpoint that emits segments as they land. LessonReader consumes the SSE and appends segments, replacing the poll. This honors invariant #4 (serve reads buffer, jobs generate) and degrades to the existing poll if SSE drops.
  • Option B — Stream the model directly via streamObject for the reading text only (checkpoints still structured). More premium feel but riskier with schema validation + provenance (source_chunk_ids) + verification. Only if Option A is insufficient.

Changes (Option A):

  1. Buffer incrementally: in generate-lesson-job.ts, write each verified segment to the Redis buffer as it completes (not only the whole lesson at the end). Keep the final whole-lesson cache write for the fast path.
  2. SSE route src/app/api/lessons/stream/route.ts: Node runtime (not edge — needs timeout headroom), reads the buffer, emits segment events as they appear, a ready event when complete, and closes. Respect the per-session budget only for the generation job, not the read.
  3. Client: in lesson-reader.tsx, when in outline/cold-start state, open an EventSource to the stream and append segments with the existing View-Transition reveal; fall back to the 4s poll if EventSource errors or is unsupported. Remove the poll once ready.
  4. Reduced motion: segment-in animation must respect prefers-reduced-motion (already handled in reading.css).

Tests: unit: buffer write-per-segment ordering; SSE route emits segments then ready (mock the buffer). Playwright: cold-start lesson shows segments appearing without a full reload.

Acceptance: A cold-start lesson streams in segment-by-segment instead of popping after a 4s poll; falls back gracefully to polling; no synchronous generation on the request path; reduced-motion respected.


#8 — Due-review email digest

Goal: bring learners back when reviews are due — the spaced-repetition retention loop. Email infra already exists; only transactional sends today.

Current state (verified):

  • src/lib/email/index.ts sendEmail({to,subject,html,text?}) over nodemailer SMTP (dev: jsonTransport→console). src/lib/email/templates.ts has verification/invite/reset templates. No scheduled sending.
  • src/lib/memory/spaced-rep.ts (isDue, scheduleNextReview) + getReviewCheckpoints(userId) / getDueReviews give due items. mastery.nextReview is the schedule. Only logged-in users have an email (users.email); anon users can't be emailed.

Changes:

  1. Digest template: add reviewDigestEmail({ name, dueCount, topConcepts, reviewUrl }) to templates.ts (+ both-locale subject/body via the user's users.locale). Calm, on the learner's side; one clear CTA to /review. No gamified streak language.
  2. Recipient query (src/lib/db/queries.ts): getUsersWithDueReviews(now) → users with email IS NOT NULL, emailVerified, ≥1 mastery row where nextReview <= now, with a due count + a few concept names. Respect an opt-out preference (see 4).
  3. Scheduled job: add a BullMQ repeatable job (or a cron-invoked Route Handler protected by a secret header, e.g. for Vercel Cron) jobs/review-digest-job.ts that runs daily, fetches due users, and sends at most one digest per user per day. Idempotency: key on (userId, yyyy-mm-dd) via the jobs ledger so a re-run never double-sends. Throttle/batch sends.
  4. Opt-out: add emailDigest: boolean (default true) to users.preferences (jsonb exists) with a toggle in settings-client.tsx (+ i18n). Include an unsubscribe link (signed token route) in the email — required for good email hygiene.
  5. Config: guard the whole feature behind an env flag (e.g. REVIEW_DIGEST_ENABLED) and document SMTP + cron setup in .env.example / docs.

Tests: unit (mock sendEmail): getUsersWithDueReviews filters correctly (no email / unverified / opted-out excluded); digest job sends once per user per day (idempotency key blocks re-send); unsubscribe flips the preference. Do not send real email in tests.

Acceptance: A daily job emails verified, opted-in users who have reviews due, at most once/day, idempotently, with a working unsubscribe and locale-correct copy; feature is env-gated and documented; anon users are never emailed.


#11 — Mastery page upgrade

Goal: turn the mastery page from a flat card grid into a calm "state of your learning" dashboard, and surface the actionable thing (due reviews). Spec §16 (mastery map). Stays editorial, non-gamified (spec §7).

Current state (verified):

  • src/components/mastery-map.tsx — title + per-blueprint groups of ConceptCards (name, score bar, %, a small "due" tag). Data from GET /api/mastery?userId=. src/app/mastery/page.tsx just renders <MasteryMap/>. Styles live in src/styles/reading.css under .mastery-*.
  • BUG (fix as part of this): mastery-map.tsx:39-43 scoreLabel() returns hardcoded French ("Maîtrisé/En cours/À revoir") in an i18n app — EN users see French. Also "Mastery Map", "Loading…", "due", and the empty-state string are hardcoded English. None go through useTranslations.

Changes:

  1. Localize everything first (bug fix). Move all strings to messages/en.json + fr.json under a mastery.* namespace (title, loading, empty, due, status.mastered|learning|needsWork, overview labels). Read via useTranslations('mastery'). No hardcoded copy.
  2. Overview header. Above the groups, a summary strip: counts of mastered / learning / needs-work and one progress meter (mastered ÷ total). Derive client-side from the fetched groups. Calm, tabular — not a gamified dashboard.
  3. Due-for-review zone. Pull concepts where nextReview <= now into a top "Due for review (N)" section with a direct Start review → link to /review. Today "due" is a buried tag; it's the actionable item.
  4. Sort + optional filter. Within each blueprint, order concepts needs-work → learning → mastered so weak spots rise. Optional filter chips (All / Due / Needs work) reusing the .home-chip pill idiom from app.css.
  5. Card polish. Add marginalia status border-left using existing --color-mastered / --color-tutor / --color-misconception; demote the raw % to secondary; let the bar carry the signal. Keep the existing role="meter" a11y.
  6. Empty state with CTA → link to home, not a dead sentence.

Tests: unit: scoreStatus/label mapping localized (no French leakage in en); overview counts correct over seeded groups; due-zone includes only nextReview <= now. Playwright: EN locale shows English status labels; due concept shows in the due zone with a working review link.

Acceptance: Mastery page opens with a counts overview + a meter, a prominent due-review zone linking to /review, status-ordered cards, and fully localized copy (no French for EN users). Passes the design floor in both themes.


#12 — Review page upgrade

Goal: make Daily Review diagnose as richly as a lesson and make the mastery movement visible — it's the whole point of reviewing. Spec §11 (memory engine), §4 (tutoring loop).

Current state (verified):

  • src/components/review-reader.tsx — sequential carousel (currentIdx), per-item ScoreBar, CheckpointInput, GradeDisplay, Next/Finish. Data from GET /api/review?userId=.
  • Degraded vs lessons: review-reader.tsx:~162 renders GradeDisplay WITHOUT onSocraticSubmit, onExplain, or the document.startViewTransition considering→reveal beat that lesson-reader.tsx uses. So partial verdicts in review have no Socratic probe, no re-explain, no motion.
  • Completion is a flat "Session complete / scores updated".

Changes:

  1. Restore full diagnosis (parity with lesson). Wire the same handlers lesson-reader.tsx uses: Socratic follow-up (/api/socratic), re-explain (/api/explain), and wrap the grade reveal in document.startViewTransition with the considering beat. Factor the shared grade-handling out of lesson-reader.tsx into a hook (e.g. src/components/use-checkpoint-grading.ts) so both readers share one implementation instead of diverging again.
  2. Visible mastery delta. After grading, animate the ScoreBar from the pre-grade score to the new score (fetch/return updated mastery from the grade response or recompute) — e.g. "62% → 71% ↑". This is the payoff of review.
  3. Session summary. Replace the terminal state with a real recap: concepts reviewed, net mastery change, count still due / next-due hint. Quiet, not a score screen.
  4. Progress bar. Thin top bar with one segment per item filled as you go, replacing/augmenting the "N of M" text.
  5. Keyboard flow. Cmd/Ctrl+Enter to submit, Enter to advance to next. Review is repetitive — keyboard makes it fast. Respect existing a11y/focus.

Tests: unit: shared grading hook drives both readers (partial verdict in review now exposes Socratic probe); delta computed correctly; summary aggregates per session. Playwright: complete a review item with a partial verdict → Socratic probe appears; finishing shows the recap with a net delta.

Acceptance: Review supports Socratic probe + re-explain + the reveal beat exactly like lessons (shared code path), shows the score moving per item, and ends on a meaningful session recap. Reduced-motion respected.


#13 — Settings page upgrade

Goal: bring Settings in line with the app's design system (tokens + CSS file, not inline styles), make it navigable, and reuse existing control idioms.

Current state (verified):

  • src/app/settings/settings-client.tsx — one long stacked column: Profile, Language, Learning (age/difficulty/depth/hint), Security, Danger.
  • DEBT (fix as part of this): the entire page is inline-styled via a const s = {} object (~lines 281-372), with hardcoded #fff on buttons (~354, ~366) and hardcoded error strings ("Failed to save.", "Linked: …", "Failed."). Violates the token-centralization convention (CLAUDE.md design invariants) and diverges from the home/reading idiom. Uses raw OS radios where home has polished segmented controls.

Changes:

  1. De-inline (debt fix). Move all styles to a stylesheet (src/styles/settings.css, imported in globals.css, or extend app.css) using design tokens. Remove the s object and every #fff (→ --color-surface). This also makes the page dark-mode-correct.
  2. Localize stray strings. "Failed to save.", "Linked:", "Failed." → settings.* i18n keys in both locales.
  3. Two-column layout (desktop). Left = section nav (Profile / Learning / Language / Appearance / Security / Danger) as anchor links or tabs; right = panels. Single column on mobile. The page is long enough to warrant structure.
  4. Reuse the segmented-control idiom. Replace the raw radio groups (age / difficulty / depth) with the home .home-depth-options-style pill segments for visual consistency. Keep them real <fieldset>/radio semantics for a11y.
  5. Add the Appearance section (ties to #10). Theme selector (System / Light / Dark) sharing the extracted setTheme helper from src/lib/theme.ts. i18n settings.appearance.*.
  6. Tighten the danger zone. Keep the type-your-email confirm; make the destructive button visually distinct (already uses --color-misconception) and the warning unmissable.

Tests: unit: locale change / preference save handlers unchanged and still call the right endpoints; no inline-style regressions (snapshot or lint rule if one exists). Playwright: section nav scrolls/switches panels; theme selector flips data-theme; settings render correctly in dark mode.

Acceptance: Settings uses tokens via a CSS file (no inline s object, no #fff), is navigable on desktop and stacks on mobile, reuses the segmented-control look, includes a working theme selector, and is fully localized — visually consistent with home and the reading surface in both themes.


Notes for the executor

  • Confirm before large content changes. #6's "generate a multi-lesson arc" and #2's regeneration touch paid generation — keep them idempotent and budget-checked, and surface the design choice (multi-lesson-per-blueprint assumption) before building if anything contradicts it.
  • Don't regress golden evals. #1 and #3 touch the grading/eval path. Run pnpm test:golden before/after; add cases, never weaken thresholds.
  • Each feature: update CLAUDE.md if a convention changes, in the same commit.
  • Keep the reading surface calm. Every UI addition (#3 dashboard, #5 home, #6 progress) must pass the design floor: responsive, AA contrast in both themes, visible focus, reduced-motion, no gamification.