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>
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:
- All LLM calls go through
src/lib/llm/client.ts. Never call a provider/AI-SDK model directly. Generator and grader must differ. - Prompts live in the versioned registry
src/lib/llm/prompts/index.ts({ id, version, template }). Never inline a prompt. Bumpversionon any semantic change. - Contracts are Zod schemas in
src/schemas/, shared by LLM ↔ API ↔ UI. Validate LLM structured output against them. - Serve path reads the buffer; jobs generate. No synchronous generation in request handlers except the documented cold-start gate. Postgres is source of truth.
- Paid background jobs are idempotent (idempotency key + durable
jobsledger row). A retry must never regenerate-and-rebill. - Per-session token budget is enforced in code (
src/lib/budget/index.ts) — callcheckBudgetbefore any new paid LLM call,recordUsageafter. - Migrations are append-only. Never edit an applied migration. Add a new one via
pnpm db:generatethenpnpm db:migrate. (Note: the migrations dir already contains a few accidental duplicate migrations — do not "fix" them, just add new ones.) - i18n: every user-facing string goes in BOTH
messages/en.jsonandmessages/fr.jsonunder the right namespace, read viauseTranslations(client) orgetTranslations(server). No hardcoded copy. - Tests with every change. Unit tests mock the LLM (Vitest, deterministic fixtures). Real-model calls only in
tests/golden/. Runpnpm testbefore declaring done. - 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:
- #7 (audit migration) — small, de-risks the account story others build on.
- #10 (dark-mode polish) — small, isolated; extracts the
setThemehelper #13 reuses. - #13 (settings upgrade) — needs #10's
setThemehelper; otherwise self-contained UI. - #12 (review upgrade) — extracts the shared grading hook from the lesson reader.
- #2 (content versioning) — foundational; #1 and #3 lean on version/provenance.
- #1 (novel-misconception harvest loop) — moat; table already exists.
- #3 (false-fail dashboard) — consumes #1's labeled data + grades.
- #6 (multi-lesson journey) — product; #5 and #11's due-zone build on journey/mastery state.
- #11 (mastery upgrade) — surfaces due reviews; pairs with #6/#5.
- #5 (returning-learner home) — product; needs #6's journey state.
- #9 (streaming generation) — infra polish.
- #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.tsexists. In a transaction it reassignsresponses,mastery(with on-conflict skip when the auth user already has that concept), andjourneyInstancesfromanonUserId→authUserId, then renames the Redisbudget:{id}key. Has__tests__/.- Called from
src/app/auth/login/login-client.tsxandsrc/app/auth/register/page.tsxafter auth success; clearssessionStoragecurio_uid.
Goal: make it correct and complete, not bigger.
Changes / checks:
- Mastery conflict policy. Today conflicting concepts are skipped (anon progress dropped). Decide intended behavior: keep the higher
score/ more-recentlastSeeninstead of silently dropping. If changing, do it in theonConflictDoUpdateand document why. - 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 asignInevent insrc/auth.tsevents) — without it, OAuth users lose anon progress. - Idempotency / double-call. Calling migrate twice (or with no anon data) must be a no-op, not an error. Verify.
- Validation & authz. The route must take
anonUserIdfrom the request butauthUserIdstrictly from the server session (never trust a client-supplied authUserId). Confirm a maliciousanonUserIdcan only move data INTO the caller's own account. accounts/sessionstables 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.csshas 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.tsxruns a no-flash theme bootstrap (readslocalStorage('curio-theme'), setsdata-themeon<html>).src/components/site-header.tsxhas aThemeTogglecycling system → dark → light.
Goal: verified-correct dark mode on every surface + a settings entry.
Changes:
- Settings selector. Add a theme control to
src/app/settings/settings-client.tsx(System / Light / Dark) that writes the samelocalStorage('curio-theme')key +data-themethe header toggle uses (share one helper — extractsetTheme()to e.g.src/lib/theme.ts). Add i18n keys undersettings.appearance.*in both locales. - 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/uncertainand their-subtlepairs, plus--color-tutormarginalia and accent on--color-surface. Fix any token that fails. This is the highest-risk part — the diagnosis colors carry meaning. - 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/stylesand replace with tokens. - 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.contentVersionexist but are never incremented and never read (generate-blueprint.ts:~85writesmodelVersion = process.env.LLM_GENERATOR_MODEL,contentVersion = 1).- Prompt registry has versions (e.g.
GENERATE_LESSONis v5) but nothing ties a blueprint to the prompt version it was built with. jobs/promote-blueprint-job.tsalready 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:
- Schema migration: add
blueprints.promptVersion(int) andblueprints.staleAt(timestamp, nullable). Optionallyblueprints.lastBuiltModelifmodelVersionsemantics are ambiguous. Generate viapnpm db:generate. - Stamp on generation: in
src/lib/generation/generate-lesson.ts/generate-blueprint.ts, write the currentprompts.GENERATE_LESSON.versionand generator model id at build time. - Staleness check: add
src/lib/generation/staleness.tsexportingisBlueprintStale(blueprint): boolean(compares stored model+promptVersion to current config) andmarkStale(blueprintId). - Invalidation trigger: add an admin action (button on
src/app/admin/blueprints/[id]) + an internal function to mark a blueprint stale and bumpcontentVersion. On the serve path (getLessonResponseinsrc/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). - Regeneration job: add
jobs/regenerate-blueprint-job.ts(or extendgenerate-lesson-job) that regenerates segments/checkpoints at a newcontentVersion, re-runs T0/T1 (and T2 at promotion), and swaps the cache. Idempotency key must include the targetcontentVersionso a retry never double-bills. Reuse thejobsledger pattern fromgenerate-lesson-job.ts. - Flagged-item regeneration:
autoFlagCheckpointIfNeeded(already inqueries.ts) sets a checkpoint toverifyStatus='fail'. Add an endpoint/admin action to regenerate just the flagged checkpoint at a bumpedcontentVersion(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.misconceptionTagcan be'novel'.src/lib/verification/grader.ts:~183already enqueues novel cases into thenovelMisconductQueuetable ({ 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 intomisconceptions, 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:
- 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 intomisconceptionswithverifyStatus='pending', (b) marks the queue rowprocessed=true, in one transaction;dismissNovelQueueItem(queueId)for false positives. - Admin page
src/app/admin/misconceptions/(new): list unprocessed queue items showing the learner response with theevidenceSpanhighlighted (reuse the marginalia highlight idiom fromgrade-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). - Verification hook: after labeling, the new misconception is
verifyStatus='pending'. Run it through the existingverifyMisconceptions(src/lib/verification/verify-misconceptions.ts) — either inline on label or via a small job — so only verified misconceptions go live. - Golden-set growth: add
appendMisconceptionGoldenCase()that writes a new case totests/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. - 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. Useclient.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.tscomputes 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),contentReportswithreason='grade_wrong'(learner-disputed grades),getCheckpointFailureRate+autoFlagCheckpointIfNeededinqueries.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_wrongreport rate vs total grades; misconception/uncertain verdict distribution; checkpoints auto-flagged.
Changes:
- Persist golden runs: new table
eval_runs(id, suite, falseFailRate, accuracy, total, model, promptVersion, gitSha nullable, createdAt). Makerun-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. - Aggregation queries (
src/lib/db/queries.tsorsrc/lib/metrics.ts):getEvalTrend(suite, limit);getOnlineGradeMetrics(window)→ counts by verdict,grade_wrongreport rate, # auto-flagged checkpoints, optionally per-blueprint. - 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) fromeval_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. - 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):
blueprints→concepts(ord) →lessons(ord, depth, locale) →segments→checkpoints.journeyInstances.positionis incremented byadvanceJourneyPosition(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).LessonCompleteinlesson-reader.tsxcallsPOST /api/advancethen 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:
- Serve by position: extend
getLessonResponse(or addgetLessonAtPosition(intentKey, userId, position, ...)) to return the lesson at the learner's currentjourneyInstances.position(default 0 → first lesson bylessons.ord)./learn/[key]/page.tsxreads the user's position to pick the lesson. - Advance → next: on lesson complete, after
advanceJourneyPosition, if a next lesson exists (or can be generated), navigate to it (e.g./learn/[key]?pos=Nor just re-fetch since position advanced). If the next lesson isn't generated yet, enqueuegenerate-lesson-jobfor(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"). - Lesson generation for position N:
generate-lesson.ts/generate-lesson-job.tscurrently assume ord 0. Parameterize byord/positionand pass the concept(s) for that position. Idempotency key must includeord. - Progress UI: add a journey progress indicator to the reading surface header (e.g. "Lesson 2 of 5") sourced from
lessonscount + position. Calm, non-gamified (spec §7). i18n both locales. - 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.tsxis 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 acurio_uid; logged-in users havesession.user.id.
Changes:
- Detect "returning": a learner is "returning" if they have any
journeyInstances,mastery, or due reviews. For anon users this requires the clientcurio_uid; the home page is a client component already, so fetch on mount (or add aGET /api/home-summary?userId=that returns{ inProgress: [{intentKey, title, position, total}], dueReviewCount, recentMastery: [...] }). For logged-in users, prefer the session id. - New endpoint
src/app/api/home-summary/route.ts: aggregates in-progress journeys (joinjourneyInstances→blueprints, compute position/total),dueReviewCount(fromgetReviewCheckpoints), and a small recent-mastery slice. Reuse existing queries; add agetInProgressJourneys(userId)query. - 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/reviewwhendueReviewCount > 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). - i18n:
home.continue.*,home.dueReviews, etc., both locales. - 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
outlinestate;src/components/lesson-reader.tsx:~97pollsrouter.refresh()every 4000ms until the lesson isready. Generation runs injobs/generate-lesson-job.ts(BullMQ), warming the Redis buffer. - Vercel AI SDK supports streaming;
client.tsuses 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 exposeGET /api/lessons/stream?key=...as an SSE endpoint that emits segments as they land.LessonReaderconsumes 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
streamObjectfor 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):
- 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. - SSE route
src/app/api/lessons/stream/route.ts: Node runtime (not edge — needs timeout headroom), reads the buffer, emitssegmentevents as they appear, areadyevent when complete, and closes. Respect the per-session budget only for the generation job, not the read. - Client: in
lesson-reader.tsx, when in outline/cold-start state, open anEventSourceto the stream and append segments with the existing View-Transition reveal; fall back to the 4s poll ifEventSourceerrors or is unsupported. Remove the poll onceready. - Reduced motion: segment-in animation must respect
prefers-reduced-motion(already handled inreading.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.tssendEmail({to,subject,html,text?})over nodemailer SMTP (dev: jsonTransport→console).src/lib/email/templates.tshas verification/invite/reset templates. No scheduled sending.src/lib/memory/spaced-rep.ts(isDue,scheduleNextReview) +getReviewCheckpoints(userId)/getDueReviewsgive due items.mastery.nextReviewis the schedule. Only logged-in users have an email (users.email); anon users can't be emailed.
Changes:
- Digest template: add
reviewDigestEmail({ name, dueCount, topConcepts, reviewUrl })totemplates.ts(+ both-locale subject/body via the user'susers.locale). Calm, on the learner's side; one clear CTA to/review. No gamified streak language. - Recipient query (
src/lib/db/queries.ts):getUsersWithDueReviews(now)→ users withemail IS NOT NULL,emailVerified, ≥1 mastery row wherenextReview <= now, with a due count + a few concept names. Respect an opt-out preference (see 4). - 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.tsthat runs daily, fetches due users, and sends at most one digest per user per day. Idempotency: key on(userId, yyyy-mm-dd)via thejobsledger so a re-run never double-sends. Throttle/batch sends. - Opt-out: add
emailDigest: boolean(default true) tousers.preferences(jsonb exists) with a toggle insettings-client.tsx(+ i18n). Include an unsubscribe link (signed token route) in the email — required for good email hygiene. - 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 ofConceptCards (name, score bar, %, a small "due" tag). Data fromGET /api/mastery?userId=.src/app/mastery/page.tsxjust renders<MasteryMap/>. Styles live insrc/styles/reading.cssunder.mastery-*.- BUG (fix as part of this):
mastery-map.tsx:39-43scoreLabel()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 throughuseTranslations.
Changes:
- Localize everything first (bug fix). Move all strings to
messages/en.json+fr.jsonunder amastery.*namespace (title,loading,empty,due,status.mastered|learning|needsWork, overview labels). Read viauseTranslations('mastery'). No hardcoded copy. - Overview header. Above the groups, a summary strip: counts of
mastered / learning / needs-workand one progress meter (mastered ÷ total). Derive client-side from the fetched groups. Calm, tabular — not a gamified dashboard. - Due-for-review zone. Pull concepts where
nextReview <= nowinto 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. - 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-chippill idiom fromapp.css. - 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 existingrole="meter"a11y. - 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-itemScoreBar,CheckpointInput,GradeDisplay, Next/Finish. Data fromGET /api/review?userId=.- Degraded vs lessons:
review-reader.tsx:~162rendersGradeDisplayWITHOUTonSocraticSubmit,onExplain, or thedocument.startViewTransitionconsidering→reveal beat thatlesson-reader.tsxuses. So partial verdicts in review have no Socratic probe, no re-explain, no motion. - Completion is a flat "Session complete / scores updated".
Changes:
- Restore full diagnosis (parity with lesson). Wire the same handlers
lesson-reader.tsxuses: Socratic follow-up (/api/socratic), re-explain (/api/explain), and wrap the grade reveal indocument.startViewTransitionwith the considering beat. Factor the shared grade-handling out oflesson-reader.tsxinto a hook (e.g.src/components/use-checkpoint-grading.ts) so both readers share one implementation instead of diverging again. - Visible mastery delta. After grading, animate the
ScoreBarfrom 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. - 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.
- Progress bar. Thin top bar with one segment per item filled as you go, replacing/augmenting the "N of M" text.
- 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#fffon 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:
- De-inline (debt fix). Move all styles to a stylesheet (
src/styles/settings.css, imported inglobals.css, or extendapp.css) using design tokens. Remove thesobject and every#fff(→--color-surface). This also makes the page dark-mode-correct. - Localize stray strings. "Failed to save.", "Linked:", "Failed." →
settings.*i18n keys in both locales. - 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.
- 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. - Add the Appearance section (ties to #10). Theme selector (System / Light / Dark) sharing the extracted
setThemehelper fromsrc/lib/theme.ts. i18nsettings.appearance.*. - 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:goldenbefore/after; add cases, never weaken thresholds. - Each feature: update
CLAUDE.mdif 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.