6 Commits
Author SHA1 Message Date
prosolis 466055020f The garden learns which language a card is in, and read-aloud stops guessing
Phase 28 (c), the last of the phase. A word met inside a Portuguese
document is a Portuguese card: migration 0018 mirrors documents.doc_lang
onto vocab_words, set server-side from the ownership lookup capture was
already making. Every card still reviews — filtering the queue to the
half she is learning would drop the words she actually met.

Read-aloud was the larger surprise. detectLang routed Han/kana to
Chinese and everything else to en-US, so the zh pair was accidentally
right and every Latin pair wrong. doc_lang now reaches the client
read-only on the document JSON, and docLang(text, verdict) answers for a
passage taken out of it — with the script test still winning, because
quoted Chinese must never be spelled out one "Chinese letter" at a time.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
2026-07-28 23:45:26 -07:00
prosolis e9b8595456 Let the garden keep what she was given, not only what she sought
Two halves of the same idea, both read out of work Petal already
records.

Planting: an accepted collocation is a learnable chunk, so it becomes a
phrase card. The scheduler didn't need to know — a three-word chunk
climbs the ladder exactly like a looked-up word. What needed care was
deciding what *isn't* a chunk (single words are word choice; a
six-word-plus "collocation" is a rewritten sentence, and sentences make
miserable flashcards), and that the example must be the *corrected*
sentence — the stored draft still holds the phrasing she just left
behind. Re-accepting the same chunk leaves the existing card alone
rather than resetting a schedule it has been climbing. The whole thing
is best-effort: accepting an edit must never fail because a flashcard
couldn't be made.

The growth journal: kept this month beside kept the month before, the
phrasing that stuck, the patterns that faded. The queries were the easy
part; the honesty is the feature. "Stuck" needs the phrase in a *second*
document, because one document is just the edit where she left it.
"Faded" says nothing at all unless she has been writing lately —
otherwise a month away from Petal comes back to her as progress, which
is the one way this could lie. And a suggestion had to start recording
when she *decided* it, not when the model proposed it, so 0012 adds
resolved_at and backfills the old rows to their created_at.

It lives as a second tab in the garden, and it feeds the kitten: after
an accept she now sometimes hears something true of her alone, once per
line, half the time, never waited for.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 14:16:59 -07:00
prosolis 6901cdbbe4 Multi-user groundwork: request-scoped user identity
Petal ran as a single hardcoded user, with db.LocalUserID named directly
at ~35 query sites. That made the caller's identity a compile-time
constant scattered across every package — nothing a real login could
replace without touching all of them.

New internal/auth moves it into the request context:

  - Middleware(Resolver) resolves the caller once per API request
  - handlers read auth.UserID(r.Context()) instead of naming a user
  - Resolver is the seam an Authentik session check drops into
  - StaticResolver(db.LocalUserID) keeps Petal single-user today

Behavior is unchanged. UserID returns "" rather than panicking when the
middleware is absent, so a mis-wired route fails closed: every query is
WHERE user_id = ?, which then matches nothing.

main.go splits /api into a public group (/health, /version) and an
authenticated group for everything else — a monitoring probe must not
need a session.

Two pre-existing access-control gaps fixed while threading, both
harmless with one user and not with two:

  - setStatus (accept/dismiss) updated a suggestion by bare id with no
    ownership check at all
  - listForDoc/fetchPending read a document's suggestions by doc_id
    alone; a suggestion quotes the sentence it corrects, so that leaked
    the source prose

Both now scope through documents.user_id.

Tests: internal/auth covers the context round-trip, the absent-context
case, and both 401 paths. Two-user isolation suites in docs and
suggestions mount the same routers twice behind two resolvers over one
database and assert a stranger gets 404 on every id-taking path, sees
nothing in list/search, and leaves the owner's data untouched.

Those suites earned their keep immediately: docs.fetch gained a userID
parameter but kept binding db.LocalUserID in the query. Unused
parameters are legal Go, so it compiled clean, vet was silent, and every
existing test passed while the lookup stayed unscoped.

Still global, out of scope and flagged in BUILD_PLAN.md: the image store
has no per-user association, and frontend localStorage keys are
per-browser rather than per-account.
2026-07-26 21:42:37 -07:00
prosolis 8c6bc1604b Code-review follow-ups: httputil, validation caps, a11y
Backend:
- Extract shared internal/httputil (WriteJSON/ErrorJSON/BadRequest/
  ServerError); drop the triple-duplicated helpers in docs, suggestions,
  vocab. ServerError now logs the real error and returns a generic 500 so
  raw DB/internal errors never reach the client.
- vocab capture: validate doc_id ownership (blank -> none, unknown -> 400
  instead of a leaked FK 500); rune-safe clamp word/gloss/definition/
  phonetic/example.
- vocab review(): wrap the read-modify-write in a transaction (TOCTOU).
- /api request-size cap via MaxBytesReader middleware (2 MiB), exempting
  /api/images (own 10 MiB limit).

Frontend:
- StatusBar: drive the checking/voicing/collocating indicators from one
  array; llmDown uses !anyBusy.
- Slide-overs: new useFocusTrap hook (focus-in, Tab trap, focus-restore)
  on GardenPanel + HistoryPanel, both role=dialog/aria-modal/aria-label.
- speech.ts: export stopSpeech(); GardenPanel cancels audio on unmount.

Tests: add doc_id-validation and field-clamp coverage; full suite green.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 16:59:23 -07:00
prosolis 4161830da6 Code-review fixes for collocation coach + vocab garden
Correctness:
- useCheckpoint: clear the busy flag unconditionally so overlapping
  explicit passes don't strand each other's spinner; explicit actions
  now also supersede a queued auto-check and clear the stranded
  "checking" dot. Deduped runVoice/runCollocation into runExplicitPass.
- EditorCore: token-guard the auto-capture so a late capture can't
  resurrect a removed word; move toggleSaveWord side effects out of the
  setWordInfo updater (StrictMode double-fire); fix sentenceAround offset
  desync via shared exampleAt (textBetween + parentOffset, single resolve);
  optimistic saved state so the heart doesn't flash unsaved.
- vocab capture: normalize word to lower+trim (matches lexicon) so
  "Apple"/"apple" don't make duplicate cards; check rows.Err() in queryList.
- GardenPanel: Promise.allSettled so a /due failure doesn't blank the
  whole garden; scrim click during review ends the review (mirrors Esc);
  gate footer on !error; O(1) due lookup via a Set.

Features requested in review:
- Definition-only review card: add vocab_words.definition (migration
  0007) as an English fallback meaning, threaded through capture and used
  by review/garden when there's no Chinese gloss.
- Scheduler caps: maxEase 3.0 + maxInterval 365d so "easy" growth can't
  push a word out of rotation for years.

Tests: TestCaptureCaseInsensitive, TestCaptureStoresDefinitionFallback,
TestCapsBoundGrowth. go build/vet/test, tsc, vitest 51/51, vite build clean.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 16:41:28 -07:00
prosolis 8aa437ec82 Phase 12 + 13: collocation coach + vocabulary garden
Phase 12 — collocation coach: a third suggestion family for gentle
"natives usually say…" hints on non-native word pairings, reusing the
existing runPass/pendingScope/rail machinery.
- llm/collocation.go (RunCollocation, 25s floor, reuses ParseCheckpoint)
  + collocationSystemPrompt/CollocationMessages (warm, Mandarin gloss,
  defers grammar/spelling to the grammar family)
- migration 0005 rebuilds the suggestions table to extend the type CHECK
  (SQLite can't ALTER a CHECK)
- collocationScope + CollocationLimit + POST /{id}/collocation
- fix: grammarScope was `type != 'voice'` and would wipe the new
  collocation flags; now `type NOT IN ('voice','collocation')`
- frontend: --color-blossom, "Make it sound natural 🌸" pill,
  collocating/runCollocation in useCheckpoint, StatusBar dot

Phase 13 — vocabulary garden: capture looked-up words and surface them
for gentle spaced repetition.
- new internal/vocab package: migration 0006 (vocab_words, SM-2-lite
  columns, doc_id ON DELETE SET NULL, UNIQUE(user_id,word)),
  scheduler.go (Leitner ladder 1/3/7/16/35 then geometric; gentle
  "again", no streak-shaming), handlers (capture-upsert/list/due/
  review/delete, owner-scoped, SQLite-side datetime math)
- auto-capture on word lookup (dictionary-known words only, captures
  the surrounding sentence + doc_id) + 🤍/💚 toggle on WordCard
- GardenPanel: blossom grid (bloom by reps), flashcard review (sentence
  blanked, flip, again/good/easy, recognition↔production), sleepy-kitten
  footer; opened from a global 🌷 header button

Tests: TestCollocationPassCoexists, vocab scheduler + handlers, db CHECK
extended. go build/vet/test + tsc + vite + vitest (51/51) clean;
migration verified against a copy of the live DB; live backend smoke
walked the full vocab lifecycle + the warm-502 collocation path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-26 15:50:25 -07:00