diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md
index 97f6014..c8aa513 100644
--- a/BUILD_PLAN.md
+++ b/BUILD_PLAN.md
@@ -180,7 +180,7 @@ Option B ratified. `go-oidc` + `x/oauth2`; config fields already existed. The `R
### Phase 17 — Migrate the `local` user ✅ (2026-07-27) — her writing now lives on her account
Script, app stopped, backup first (OPEN #4). **The "she logs in once first" dependency turned out not to exist**: authentik's default `hashed_user_id` sub mode makes the subject `User.uid`, which is derived from her user id and the instance secret — stable, and readable before she has ever signed in (`ak shell -c "…User.objects.get(username='claire').uid"`). So the data can move *first*, and she signs in to find her writing already there rather than to an empty Petal that fills in later.
-- [ ] `scripts/migrate_local_user.*`: single transaction, `PRAGMA foreign_keys=OFF`, re-point `documents`/`tags`/`vocab_words` **and `images`** (versions/suggestions follow parents; `images` is new in Phase 16 and carries `user_id` directly — miss it and every pasted picture 404s), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has data
+- [x] `scripts/migrate_local_user.*`: single transaction, `PRAGMA foreign_keys=OFF`, re-point `documents`/`tags`/`vocab_words` **and `images`** (versions/suggestions follow parents; `images` is new in Phase 16 and carries `user_id` directly — miss it and every pasted picture 404s), delete the empty provisioned row, verify row counts before commit; refuses to run if the app is up or the target has data
- [x] `scripts/migrate_local_user.py` — dry-run by default, `VACUUM INTO` backup before touching anything, one transaction with `PRAGMA foreign_keys=OFF`, re-points `documents`/`tags`/`vocab_words`/`images`, deletes the old user row, and **verifies every expected row actually moved (and that the source is left owning nothing) before it commits**, rolling back otherwise. Refuses to merge into an account that already owns writing. Runbook in the script header.
- **The "is the app stopped?" guard needed a second attempt.** `BEGIN EXCLUSIVE` — the obvious check — passes straight through against a *running but idle* Petal, because in WAL mode it only conflicts with another writer. That is exactly the case the guard exists to catch, and it would have failed silently. `PRAGMA locking_mode = EXCLUSIVE` conflicts with any connection at all, since it locks the shared-memory index every WAL reader maps; verified against a live server.
- [x] **Run for real.** Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image moved from `local` onto `5f47d955…` (Claire, `clairew8@pm.me`). Sequence: `-backup` snapshot of millenia's live DB (taken while it kept running — `VACUUM INTO` needs no write lock), shipped to the VPS, installed over the throwaway staging database (kept as `petal.db.staging-*`), **started once so migration `0010` applied**, stopped, migrated, started. Verified over public HTTPS with a short-lived probe session, then removed: `/api/me` is her, 8 documents listed, her image 200s, vocabulary garden and version history intact, search returns hits — and 401 without the cookie.
@@ -188,9 +188,15 @@ Script, app stopped, backup first (OPEN #4). **The "she logs in once first" depe
- **A second bug in the guard, found by running it against production rather than a test file.** `PRAGMA locking_mode = EXCLUSIVE` keeps holding the lock after being set back to `NORMAL` — SQLite only releases it on that connection's next database access — so on a **WAL** database the script locked itself out of its own `VACUUM INTO` backup. It passed locally because the test database had come out of `VACUUM INTO` and so wasn't in WAL mode at all — the same shape of miss as the trailing-slash issuer: the fixture didn't look like production. The probe now runs on its own connection and closes it, and the fix was re-verified against a database that had genuinely been served in WAL mode.
- **Startup crash averted while sequencing this**: the image backfill claims unowned files for `local`, which stops existing after the migration — a foreign-key error inside `images.New`, which `main.go` treats as fatal. Petal would have entered a crash loop the first time it started on a migrated database. The backfill now skips a missing owner (there is nothing to claim in that case anyway; the migration moves the image rows itself).
-### Phase 18 — Per-user, per-language client state
-- [ ] Namespace `localStorage` keys by user id once known: `petal.spell.personal`, `petal.companion`, sound/petals prefs
-- [ ] Personal spell dictionary keyed by **user + language** (en and pt-PT word lists must not merge); consider promoting it to a server table so it follows her across devices (nice-to-have, decide during)
+### Phase 18 — Per-user, per-language client state ✅ (2026-07-27)
+- [x] **Preferences namespaced by account** (`web/src/lib/prefs.ts`) — `petal.sound`, `petal.petals` and `petal.companion` now read/write `.u.`. The wrinkle is timing: `sounds.ts` and `petals.ts` read their value at *import* time, long before `/api/me` answers, so rather than block startup on the network for a mute flag, a read before the answer sees the **legacy un-namespaced key** (on a single-writer browser, exactly the right value) and `setPrefsScope` — called from `useSession` the moment `/api/me` resolves — adopts it and fires `onPrefsScopeChange` so each module re-reads. `PetalCompanion` re-reads too, unless she's already swapped mascots in the meantime.
+- [x] **Legacy adoption is a move, not a copy** (user's call): the first account to sign in on a browser inherits whatever was set back when Petal had no accounts, and the key is then deleted so the *second* account starts from Petal's defaults rather than from a stranger's choices. An existing scoped value is never overwritten by the legacy one.
+- [x] **Personal spell dictionary promoted to a server table** (the plan's nice-to-have; user chose it) — new `internal/spell` package + migration `0011_personal_dictionary`: `personal_words (user_id, lang, word, created_at)`, `PRIMARY KEY (user_id, lang, word)`, cascading with the account. `GET/POST/DELETE /api/spell/words`; adds are idempotent, a delete of a word that was never there is a success (the caller's intent already holds), and **every response carries the full resulting list** so the client never has to merge two views of one set. Namespacing it in `localStorage` instead would have *fragmented* the list she already has across her laptop and tablet — strictly worse than before; a table means it follows her.
+- [x] `lang` is the **dictionary's** language, not the writer's — an en-US personal word must not silence a pt-PT flag once the second pair ships. Normalised (`trim`/lowercase, default `en`) so `EN`/`en`/absent can't split one list into three.
+- [x] `useSpellChecker` is server-backed: nspell loads, then the word list arrives from her account and is replayed in. A browser still holding the Phase-7 `petal.spell.personal` key hands it over on first load — but **only lets go of it once the server has accepted it**, so a failed request costs nothing. `addWord` takes effect in the editor immediately and persists in the background: the underline goes away the instant she asks, whatever the network is doing. A word list that fails to load costs correct words being flagged, never writing.
+- [x] Tests: `internal/spell/handlers_test.go` — lifecycle (idempotent add, bulk add, repeat delete, `[]` not `null`), languages-don't-merge incl. the case-normalisation case, junk rejection, and the standing-rule **two-user isolation** (mount twice behind two resolvers over one DB: Bob sees none of Alice's words, his identical word is his own row, his delete doesn't reach hers, deleting the account takes the dictionary with it). `web/src/lib/prefs.test.ts` — legacy adoption, move-not-copy, two accounts on one browser, never-overwrite, listener fires once per real change, storage-throws safety.
+- Verified: go build/vet/test, tsc, vite build, vitest 82/82 all clean; live smoke on a throwaway DB (:8073) — add/bulk-add/list/delete, pt-PT list independent of en, CJK word accepted, 400 on empty.
+- ⚠️ **Not yet deployed, and the migration rehearsal against a copy of the live VPS database did not run** — the snapshot command was blocked by this session's permission classifier. `0011` is a plain `CREATE TABLE` with no table rebuild and no dependency on existing rows (unlike `0005`/`0010`), so the risk is low, but the convention is worth honouring before the container is rebuilt.
### Phase 19 — Langpack extraction (the copy chore)
Pure refactor, zero visible change; prerequisite for every new pair (SUGGESTIONS §2, Q2 settled).
@@ -238,6 +244,7 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6).
- [x] **Phase 14 — companion warmth + bedtime nag + night mode**: more encouraging phrases, a gentle "go to bed" nudge after 11pm, and a calm dark theme + falling stars at night. ✅ (see Phase 14 above)
## Session log
+- 2026-07-27: **Phase 18 — the browser's settings become her settings** (user: "let's continue the build plan"). Two scope calls taken with the user: the personal spell dictionary goes **server-side** rather than being namespaced in place, and the pre-account `localStorage` keys are **adopted then rescoped** by the first writer to sign in. New `web/src/lib/prefs.ts` namespaces `petal.sound`/`petal.petals`/`petal.companion` by user id; the interesting part is timing — those modules read their value at *import* time, before `/api/me` can possibly have answered, so a pre-scope read deliberately sees the legacy key (the right value on a single-writer browser) and `setPrefsScope`, called from `useSession`, adopts it and notifies every reader. Adoption **moves** rather than copies, so account two starts from Petal's defaults instead of inheriting a stranger's mascot. New `internal/spell` package + migration `0011`: `personal_words` keyed `(user_id, lang, word)`, where `lang` is the **dictionary's** language, not the writer's — an English exception must not silence a pt-PT flag when the second pair ships. `useSpellChecker` now replays her list from her account, hands over any browser-held Phase-7 list on first load (releasing it only once the server has taken it), and persists an added word in the background so the underline vanishes the instant she asks. **The reason for the server table over cheaper namespacing**: keying the existing list by user in `localStorage` would have *fragmented* the words she already has across her laptop and tablet — the "cheap" fix was the one that made things worse. Tests: full lifecycle + languages-don't-merge + junk + the standing-rule two-user isolation suite in Go, and legacy-adoption/move-not-copy/two-accounts/storage-throws in vitest. go build/vet/test, tsc, vite, vitest 82/82 clean; live smoke on a throwaway DB. **Not deployed** — and the customary rehearsal of `0011` against a copy of the live VPS database was blocked by the session's permission classifier, so that check is outstanding (it is a plain `CREATE TABLE`, so lower-risk than `0005`/`0010`, but the convention exists for a reason).
- 2026-07-27: **Phase 17 — Claire's writing moved onto her real account** (user: "claire is local user today in Petal. let's make sure to migrate existing data to her account"). `scripts/migrate_local_user.py`: dry-run by default, own `VACUUM INTO` backup, one transaction with foreign keys off, re-points `documents`/`tags`/`vocab_words`/`images`, verifies every expected row moved before committing. **The plan's stated prerequisite — "she logs in once so her sub exists" — turned out to be false**: authentik's `hashed_user_id` sub is `User.uid`, derived from her id and the instance secret, so it is readable in advance and the data could move *first*; she signs in to find her writing already there instead of to an empty Petal. Her 8 documents, 33 snapshots, 103 suggestions, 3 vocabulary words and 1 image now belong to `5f47d955…`, verified end to end over public HTTPS. Per the user's call the VPS is now canonical and millenia was left running and untouched as a frozen fallback (it diverges the moment either is written to — retire it rather than sync it). **Three bugs, each found by a different kind of contact with reality**: (1) the image backfill claims files for `local`, which stops existing after a migration — a foreign-key error inside `images.New`, which `main.go` treats as fatal, so Petal would have crash-looped on first start against a migrated database; (2) `BEGIN EXCLUSIVE` was the wrong liveness check, since in WAL mode it only conflicts with another *writer* and sails past a running-but-idle Petal — exactly the case the guard exists for; (3) the replacement, `PRAGMA locking_mode = EXCLUSIVE`, holds its lock past being reset to `NORMAL`, so on a real WAL database the script locked itself out of its own backup — invisible locally because the test file had come from `VACUUM INTO` and wasn't in WAL mode. Same shape as Phase 16's trailing-slash issuer: the fixture didn't look like production.
- 2026-07-27: **Phase 16 built — Petal authenticates for itself** (user: "let's continue the build plan"; box access granted mid-session). New `internal/auth` surface on top of the Phase-0 `Resolver` seam: `session.go` (opaque cookie, **SHA-256-at-rest**, 30-day sliding expiry throttled to one write an hour, revoke/revoke-all/prune), `oidc.go` (login/callback/logout with state + nonce + PKCE, **lazy retried discovery** so an IdP outage can't stop Petal booting or invalidate live sessions), `users.go` (provisioning upsert keyed on `sub`, `/api/me`, allowlist). Migration `0010` lands `sessions`, `images` and `users.pair_lang` together. `main.go` picks the resolver from config, so a laptop build is unchanged. **Image ownership** closes the capability-URL hole flagged in the Phase-0 audit — one row per owner keeps dedup, a stranger gets 404 not 403, `Cache-Control` dropped to `private`, and pre-existing files are claimed at startup or they'd all 404. Frontend: a single 401 interceptor, a warm bilingual sign-in overlay over a still-visible editor, and a **draft rescue** to localStorage so an expired session can't cost writing — the auto-save stashes the body it couldn't send and reclaims it after re-login. **Three deliberate deviations from the plan**, all noted above: the allowlist matches emails as well as subject ids (a subject doesn't exist until first login, so a subject-only list is unusable in advance); `SESSION_SECRET` was dropped from config rather than left unused (nothing signs anything — sessions are opaque and server-side); and image rows are keyed `(name, user_id)` rather than owned singly, which is what preserves deduplication. **A real bug caught by writing the round-trip test rather than by reading the code**: the one-shot state/nonce/PKCE cookies were cleared in a `defer`, i.e. after the redirect had already written the header, so the clearing `Set-Cookie` was silently dropped. Verified: full go/tsc/vite/vitest suites, migration `0010` against a `VACUUM INTO` copy of the live millenia DB (counts intact, FTS still matching, image claimed), and a live smoke against the binary in both auth-off and auth-on modes including a hand-inserted session (valid → 200; absent/forged/expired → 401). **Then deployed** (user: "do it! register it!"): provider + application registered in Authentik via `ak shell`, `.env` filled in, image rebuilt, and the **Traefik basic-auth gate removed** — Petal holds its own door now. Deploying immediately found two things no test could: the issuer's **trailing slash is significant** (Authentik's has one, OIDC compares byte-for-byte, and my normalising it away broke discovery while the slashless stub kept passing — now a knob with a regression test), and a provider created through the shell rather than the admin UI comes up with **empty `grant_types`**, which authentik answers with `invalid_request` before the login page renders. Verified over public HTTPS: health 200, `/api/docs` 401 with no basic-auth challenge, `/auth/login` → Authentik with state+nonce+PKCE, following it lands on the real sign-in page. Also swapped the emoji favicon for a **drawn sakura** (`web/public/petal.svg`) that renders in Petal's own rose palette everywhere instead of at each platform's discretion, and doubles as the Authentik app tile (inlined as a data URI, since this authentik doesn't serve `/media`). **The allowlist is `prosolis@proton.me` only** — that IdP fronts ~40 accounts, so empty was not an option and guessing her account would either lock her out or let a stranger in; adding her is one `.env` line and a restart.
- 2026-07-27: **Phase 15 finished off on the two boxes** (user granted millenia access mid-session: `ssh reala@192.168.1.212`, and parodia is `ssh reala@100.64.0.1` over headscale). **LLM link**: rather than rebinding vLLM as planned, `deploy/vllm-headscale-proxy.service` (socat) adds a listener on `100.64.0.2` only — `vllm-chat.service` is shared with **Gogobee** and **Open WebUI** (whose endpoint lives in its own DB, not env), so a rebind meant three consumer edits and a 35B reload; the forwarder cost nothing and no downtime. Grammar checkpoint from the VPS now returns real suggestions in ~3s. **Backups**: the user pointed out the VPS already has daily provider VM backups *and* an age-encrypted offsite `parodia-backup` job, so Petal was folded into the latter instead of running a parallel cron — and doing so **exposed a real bug in that job's `sqlite_dump` helper**: Python's `iterdump` does not reproduce an FTS5 virtual table, so any restore would have come back with cross-document search silently missing (fixed with a `VACUUM INTO`-based helper, round-trip verified). The **bigger** find: millenia, which holds her actual writing, had **no scheduled backup at all** — now `petal-backup.timer`, age-encrypted with the parodia public recipient and pushed off-box, neither machine able to decrypt it. **Encryption at rest** (user raised it; correctly): VPS data dir is now LUKS2 covering the DB, images *and* the TTS cache; key on-box as a deliberate availability tradeoff, documented for what it does and doesn't stop. Rehearsing a reboot caught two bugs a clean run would have hidden — the plaintext originals were still on the unencrypted root fs *under* the mount, and `systemd-cryptsetup` wasn't installed so crypttab was being ignored entirely and the volume would never have unlocked at boot. Added a `.volume-ok` guard so an unmounted volume fails loudly instead of serving a blank DB. **Millenia hygiene**: Piper had been dead since the Jul 26 reboot — **26,800+ failed restarts**, read-aloud silently degrading to browser Web Speech — because an OS upgrade moved `/usr/bin/python3` 3.13→3.14 and the venv's `site-packages` went invisible; venv recreated (lands Piper 1.6.0, which is what `TTS_PATH` exists for), both voices verified through Petal. Petal itself was running unsupervised at PPID 1 and is now `petal.service` (verified by `kill -9`); the Piper units got `StartLimitIntervalSec`/`Burst` so a broken service enters `failed` instead of looping forever unnoticed. Remaining: an external uptime-kuma probe (needs the UI), a true VPS reboot test (shared public host, user's call), and millenia is still unencrypted at rest.
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 0fb53b3..d40bfed 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -21,6 +21,7 @@ import (
"gitea.parodia.dev/drwily/petal/internal/images"
"gitea.parodia.dev/drwily/petal/internal/lexicon"
"gitea.parodia.dev/drwily/petal/internal/llm"
+ "gitea.parodia.dev/drwily/petal/internal/spell"
"gitea.parodia.dev/drwily/petal/internal/suggestions"
"gitea.parodia.dev/drwily/petal/internal/tts"
"gitea.parodia.dev/drwily/petal/internal/vocab"
@@ -160,6 +161,11 @@ func main() {
// surfaced for gentle spaced-repetition review.
pr.Mount("/vocab", vocab.New(database).Routes())
+ // The personal spelling dictionary — the words she's told Petal to stop
+ // flagging. Kept server-side (rather than in the browser) so it belongs
+ // to her account and follows her between devices.
+ pr.Mount("/spell", spell.New(database).Routes())
+
// Editor image uploads, stored on disk and served back by content hash
// to whoever owns them. Files already on disk from before ownership
// existed are claimed for the local user at startup.
diff --git a/internal/db/db.go b/internal/db/db.go
index d4aef0e..e82fa52 100644
--- a/internal/db/db.go
+++ b/internal/db/db.go
@@ -436,6 +436,29 @@ CREATE TABLE images (
CREATE INDEX idx_images_user_id ON images(user_id);
ALTER TABLE users ADD COLUMN pair_lang TEXT NOT NULL DEFAULT 'zh';
+`,
+ },
+ {
+ // The personal spelling dictionary moves off the browser. It used to be a
+ // single `petal.spell.personal` key in localStorage, which meant two
+ // people sharing a device shared a word list built from one person's
+ // private writing — and one person writing on two devices had two
+ // unrelated lists.
+ //
+ // `lang` is the *dictionary's* language, not the writer's: a word is only
+ // ever added while a particular Hunspell dictionary flagged it, and an
+ // en-US personal word must not silence a pt-PT flag (or vice versa) once
+ // the second pair ships. `word` is stored as typed; matching is exact,
+ // because case carries meaning to a speller ("polish" vs "Polish").
+ name: "0011_personal_dictionary",
+ stmt: `
+CREATE TABLE personal_words (
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ lang TEXT NOT NULL,
+ word TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ PRIMARY KEY (user_id, lang, word)
+);
`,
},
}
diff --git a/internal/spell/handlers.go b/internal/spell/handlers.go
new file mode 100644
index 0000000..7d494cb
--- /dev/null
+++ b/internal/spell/handlers.go
@@ -0,0 +1,198 @@
+// Package spell owns the personal spelling dictionary — the words a writer has
+// told Petal to stop flagging.
+//
+// It lived in the browser's localStorage until Phase 18, which was wrong twice
+// over: two people sharing a device shared one list (built from one person's
+// private writing), and one person writing on a laptop and a tablet had two
+// lists that never met. It is a small amount of state, but it is *her* state,
+// so it belongs to her account rather than to a browser profile.
+//
+// Everything here is scoped by `lang` as well as by user. That is the language
+// of the *dictionary* that flagged the word, not the writer's own language: an
+// en-US personal word must not silence a pt-PT flag once the second pair ships.
+package spell
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+
+ "gitea.parodia.dev/drwily/petal/internal/auth"
+ "gitea.parodia.dev/drwily/petal/internal/db"
+ "gitea.parodia.dev/drwily/petal/internal/httputil"
+)
+
+// DefaultLang is the dictionary assumed when a caller doesn't name one. Only
+// en-US ships today; pt-PT arrives with the first Latin pair.
+const DefaultLang = "en"
+
+// MaxWordLen bounds a single entry. A personal dictionary holds words, and a
+// pasted paragraph is a bug (or an attempt to use the table as storage).
+const MaxWordLen = 80
+
+// MaxBatch bounds one request. The only bulk caller is the one-time adoption of
+// a browser's pre-Phase-18 list, which is realistically tens of words.
+const MaxBatch = 500
+
+// Handler owns the /api/spell routes.
+type Handler struct {
+ DB *db.DB
+}
+
+func New(database *db.DB) *Handler { return &Handler{DB: database} }
+
+// Routes mounts the personal-dictionary endpoints under /api/spell.
+func (h *Handler) Routes() chi.Router {
+ r := chi.NewRouter()
+ r.Get("/words", h.list)
+ r.Post("/words", h.add)
+ r.Delete("/words", h.remove)
+ return r
+}
+
+type wordsResponse struct {
+ Lang string `json:"lang"`
+ Words []string `json:"words"`
+}
+
+type addRequest struct {
+ Lang string `json:"lang"`
+ // Word and Words are both accepted so the everyday "add this one word" call
+ // stays obvious while the one-shot migration of a browser's old list is a
+ // single request rather than one per word.
+ Word string `json:"word"`
+ Words []string `json:"words"`
+}
+
+// normLang keeps the dictionary tag in one canonical shape so "EN", "en" and a
+// missing value can never split one list into three.
+func normLang(lang string) string {
+ lang = strings.ToLower(strings.TrimSpace(lang))
+ if lang == "" {
+ return DefaultLang
+ }
+ return lang
+}
+
+// list returns the caller's words for one dictionary, alphabetically so the
+// order is stable between requests.
+func (h *Handler) list(w http.ResponseWriter, r *http.Request) {
+ lang := normLang(r.URL.Query().Get("lang"))
+ words, err := h.fetch(auth.UserID(r.Context()), lang)
+ if err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+ httputil.WriteJSON(w, http.StatusOK, wordsResponse{Lang: lang, Words: words})
+}
+
+// add inserts one or more words, idempotently, and answers with the resulting
+// full list — so the client never has to merge two views of the same set.
+func (h *Handler) add(w http.ResponseWriter, r *http.Request) {
+ var req addRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ httputil.BadRequest(w, "invalid JSON body")
+ return
+ }
+ lang := normLang(req.Lang)
+
+ incoming := req.Words
+ if req.Word != "" {
+ incoming = append(incoming, req.Word)
+ }
+ clean := make([]string, 0, len(incoming))
+ for _, word := range incoming {
+ word = strings.TrimSpace(word)
+ if word == "" || len([]rune(word)) > MaxWordLen {
+ continue
+ }
+ clean = append(clean, word)
+ }
+ if len(clean) == 0 {
+ httputil.BadRequest(w, "no word given")
+ return
+ }
+ if len(clean) > MaxBatch {
+ httputil.BadRequest(w, "too many words in one request")
+ return
+ }
+
+ userID := auth.UserID(r.Context())
+ tx, err := h.DB.Begin()
+ if err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+ defer func() { _ = tx.Rollback() }()
+ for _, word := range clean {
+ if _, err := tx.Exec(
+ `INSERT INTO personal_words (user_id, lang, word) VALUES (?, ?, ?)
+ ON CONFLICT(user_id, lang, word) DO NOTHING`,
+ userID, lang, word,
+ ); err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+ }
+ if err := tx.Commit(); err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+
+ words, err := h.fetch(userID, lang)
+ if err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+ httputil.WriteJSON(w, http.StatusOK, wordsResponse{Lang: lang, Words: words})
+}
+
+// remove forgets one word. Deleting something that was never there is a success:
+// the caller's intent — "this word is not in my dictionary" — already holds.
+func (h *Handler) remove(w http.ResponseWriter, r *http.Request) {
+ word := strings.TrimSpace(r.URL.Query().Get("word"))
+ if word == "" {
+ httputil.BadRequest(w, "no word given")
+ return
+ }
+ lang := normLang(r.URL.Query().Get("lang"))
+ userID := auth.UserID(r.Context())
+ if _, err := h.DB.Exec(
+ `DELETE FROM personal_words WHERE user_id = ? AND lang = ? AND word = ?`,
+ userID, lang, word,
+ ); err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+ words, err := h.fetch(userID, lang)
+ if err != nil {
+ httputil.ServerError(w, err)
+ return
+ }
+ httputil.WriteJSON(w, http.StatusOK, wordsResponse{Lang: lang, Words: words})
+}
+
+// fetch reads one (user, dictionary) list. Both keys are always bound — an
+// unscoped read here would hand one writer another's private vocabulary.
+func (h *Handler) fetch(userID, lang string) ([]string, error) {
+ rows, err := h.DB.Query(
+ `SELECT word FROM personal_words WHERE user_id = ? AND lang = ? ORDER BY word`,
+ userID, lang,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ words := []string{} // never nil: the client expects a list, not null
+ for rows.Next() {
+ var word string
+ if err := rows.Scan(&word); err != nil {
+ return nil, err
+ }
+ words = append(words, word)
+ }
+ return words, rows.Err()
+}
diff --git a/internal/spell/handlers_test.go b/internal/spell/handlers_test.go
new file mode 100644
index 0000000..9036d5b
--- /dev/null
+++ b/internal/spell/handlers_test.go
@@ -0,0 +1,213 @@
+package spell
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "gitea.parodia.dev/drwily/petal/internal/auth"
+ "gitea.parodia.dev/drwily/petal/internal/db"
+)
+
+// newTestServer mounts the routes behind the same auth middleware main.go
+// installs — a bare router resolves no caller, so every scoped query would
+// silently match nothing.
+func newTestServer(t *testing.T) (http.Handler, *db.DB) {
+ t.Helper()
+ database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ t.Cleanup(func() { database.Close() })
+ r := chi.NewRouter()
+ r.Mount("/spell", New(database).Routes())
+ return auth.Middleware(auth.StaticResolver(db.LocalUserID))(r), database
+}
+
+func do(t *testing.T, srv http.Handler, method, path, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ var r *http.Request
+ if body != "" {
+ r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
+ } else {
+ r = httptest.NewRequest(method, path, nil)
+ }
+ rec := httptest.NewRecorder()
+ srv.ServeHTTP(rec, r)
+ return rec
+}
+
+func decodeWords(t *testing.T, rec *httptest.ResponseRecorder) wordsResponse {
+ t.Helper()
+ if rec.Code != http.StatusOK {
+ t.Fatalf("code=%d body=%s", rec.Code, rec.Body)
+ }
+ var got wordsResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v (body %s)", err, rec.Body)
+ }
+ return got
+}
+
+func equal(a, b []string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// TestLifecycle walks add → list → re-add → delete, and asserts the two
+// properties the client relies on: adds are idempotent, and every response
+// carries the full resulting list so the browser never has to merge.
+func TestLifecycle(t *testing.T) {
+ srv, _ := newTestServer(t)
+
+ // Empty to start, and a list, never null.
+ got := decodeWords(t, do(t, srv, http.MethodGet, "/spell/words", ""))
+ if got.Lang != "en" || len(got.Words) != 0 {
+ t.Fatalf("fresh list = %+v, want empty en", got)
+ }
+ if !bytes.Contains(
+ do(t, srv, http.MethodGet, "/spell/words", "").Body.Bytes(), []byte(`"words":[]`),
+ ) {
+ t.Fatal("empty list encoded as null, not []")
+ }
+
+ // One word, then a bulk add (the shape the browser's one-time adoption uses).
+ got = decodeWords(t, do(t, srv, http.MethodPost, "/spell/words", `{"word":"Petal"}`))
+ if !equal(got.Words, []string{"Petal"}) {
+ t.Fatalf("after add = %v", got.Words)
+ }
+ got = decodeWords(t, do(t, srv, http.MethodPost, "/spell/words",
+ `{"words":["hanfu","qipao","Petal"]}`))
+ if !equal(got.Words, []string{"Petal", "hanfu", "qipao"}) {
+ t.Fatalf("after bulk add = %v, want sorted and de-duplicated", got.Words)
+ }
+
+ // Re-adding an existing word must not error or duplicate it.
+ got = decodeWords(t, do(t, srv, http.MethodPost, "/spell/words", `{"word":"hanfu"}`))
+ if !equal(got.Words, []string{"Petal", "hanfu", "qipao"}) {
+ t.Fatalf("re-add changed the list: %v", got.Words)
+ }
+
+ // Delete, then delete again — forgetting a word Petal never knew is a
+ // success, since the caller's intent already holds.
+ got = decodeWords(t, do(t, srv, http.MethodDelete, "/spell/words?word=qipao", ""))
+ if !equal(got.Words, []string{"Petal", "hanfu"}) {
+ t.Fatalf("after delete = %v", got.Words)
+ }
+ got = decodeWords(t, do(t, srv, http.MethodDelete, "/spell/words?word=qipao", ""))
+ if !equal(got.Words, []string{"Petal", "hanfu"}) {
+ t.Fatalf("repeat delete = %v", got.Words)
+ }
+}
+
+// TestLanguagesDoNotMerge is the reason `lang` is in the primary key: a word the
+// writer excused in English must not silence the pt-PT dictionary too.
+func TestLanguagesDoNotMerge(t *testing.T) {
+ srv, _ := newTestServer(t)
+
+ do(t, srv, http.MethodPost, "/spell/words", `{"word":"tarde"}`)
+ got := decodeWords(t, do(t, srv, http.MethodPost, "/spell/words",
+ `{"lang":"pt-PT","word":"tarde"}`))
+ if !equal(got.Words, []string{"tarde"}) || got.Lang != "pt-pt" {
+ t.Fatalf("pt list = %+v", got)
+ }
+
+ // Removing it from one dictionary leaves the other alone.
+ do(t, srv, http.MethodDelete, "/spell/words?lang=pt-PT&word=tarde", "")
+ if got = decodeWords(t, do(t, srv, http.MethodGet, "/spell/words?lang=pt-PT", "")); len(got.Words) != 0 {
+ t.Fatalf("pt list after delete = %v", got.Words)
+ }
+ if got = decodeWords(t, do(t, srv, http.MethodGet, "/spell/words", "")); !equal(got.Words, []string{"tarde"}) {
+ t.Fatalf("en list collaterally damaged: %v", got.Words)
+ }
+
+ // Case and whitespace in the tag must not split one list into three.
+ got = decodeWords(t, do(t, srv, http.MethodGet, "/spell/words?lang=EN", ""))
+ if !equal(got.Words, []string{"tarde"}) {
+ t.Fatalf("uppercase lang tag saw a different list: %v", got.Words)
+ }
+}
+
+func TestRejectsJunk(t *testing.T) {
+ srv, _ := newTestServer(t)
+
+ cases := []struct{ name, method, path, body string }{
+ {"empty word", http.MethodPost, "/spell/words", `{"word":" "}`},
+ {"no word at all", http.MethodPost, "/spell/words", `{"lang":"en"}`},
+ {"not json", http.MethodPost, "/spell/words", `nonsense`},
+ {"delete without a word", http.MethodDelete, "/spell/words", ""},
+ }
+ for _, c := range cases {
+ if rec := do(t, srv, c.method, c.path, c.body); rec.Code != http.StatusBadRequest {
+ t.Errorf("%s: code=%d, want 400", c.name, rec.Code)
+ }
+ }
+
+ // An over-long entry is dropped rather than stored — a pasted paragraph is
+ // not a word. Dropping the only entry leaves nothing to add, hence 400.
+ long := `{"word":"` + string(bytes.Repeat([]byte("a"), MaxWordLen+1)) + `"}`
+ if rec := do(t, srv, http.MethodPost, "/spell/words", long); rec.Code != http.StatusBadRequest {
+ t.Errorf("over-long word: code=%d, want 400", rec.Code)
+ }
+}
+
+// TestTwoUsersDoNotShare is the standing rule for every user-scoped endpoint:
+// mount the same routes twice behind two resolvers over one database.
+func TestTwoUsersDoNotShare(t *testing.T) {
+ database, err := db.Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("open db: %v", err)
+ }
+ t.Cleanup(func() { database.Close() })
+ if _, err := database.Exec(
+ `INSERT INTO users (id, email, display_name) VALUES (?, ?, ?)`,
+ "bob", "bob@petal.local", "Bob",
+ ); err != nil {
+ t.Fatalf("seed second user: %v", err)
+ }
+ mount := func(userID string) http.Handler {
+ r := chi.NewRouter()
+ r.Mount("/spell", New(database).Routes())
+ return auth.Middleware(auth.StaticResolver(userID))(r)
+ }
+ alice, bob := mount(db.LocalUserID), mount("bob")
+
+ do(t, alice, http.MethodPost, "/spell/words", `{"words":["Xiaolan","hanfu"]}`)
+
+ // Bob sees none of it — a personal dictionary is built from private writing.
+ if got := decodeWords(t, do(t, bob, http.MethodGet, "/spell/words", "")); len(got.Words) != 0 {
+ t.Fatalf("bob sees alice's words: %v", got.Words)
+ }
+
+ // Bob's own identical word is his own row, and deleting it leaves hers.
+ do(t, bob, http.MethodPost, "/spell/words", `{"word":"hanfu"}`)
+ do(t, bob, http.MethodDelete, "/spell/words?word=hanfu", "")
+ if got := decodeWords(t, do(t, alice, http.MethodGet, "/spell/words", "")); !equal(got.Words, []string{"Xiaolan", "hanfu"}) {
+ t.Fatalf("bob's delete reached alice's list: %v", got.Words)
+ }
+
+ // Deleting the account takes the dictionary with it.
+ if _, err := database.Exec(`DELETE FROM users WHERE id = 'bob'`); err != nil {
+ t.Fatalf("delete user: %v", err)
+ }
+ var n int
+ if err := database.QueryRow(
+ `SELECT COUNT(*) FROM personal_words WHERE user_id = 'bob'`).Scan(&n); err != nil {
+ t.Fatalf("count: %v", err)
+ }
+ if n != 0 {
+ t.Fatalf("%d orphaned rows after the user was deleted", n)
+ }
+}
diff --git a/web/src/api/client.ts b/web/src/api/client.ts
index ad3b9d2..5d70f6e 100644
--- a/web/src/api/client.ts
+++ b/web/src/api/client.ts
@@ -150,6 +150,13 @@ export interface MechanicsFinding {
explanation: string
}
+// One dictionary's worth of personal words — the ones she's excused from
+// spell-check. Keyed by the dictionary's language, not the writer's.
+export interface PersonalWords {
+ lang: string
+ words: string[]
+}
+
// Who's writing. Mirrors the backend db.User.
export interface Me {
id: string
@@ -333,6 +340,24 @@ export const api = {
req(`/vocab/${id}/review`, { method: 'POST', body: JSON.stringify({ grade }) }),
deleteVocab: (id: string) => req(`/vocab/${id}`, { method: 'DELETE' }),
+ // The personal spelling dictionary — words she's told Petal to stop flagging.
+ // Server-side, so it belongs to her account and follows her between devices.
+ // `lang` is the *dictionary's* language: an English exception must not silence
+ // a pt-PT flag. Every call answers with the full resulting list, so the client
+ // never has to merge two views of the same set.
+ listPersonalWords: (lang: string) =>
+ req(`/spell/words?lang=${encodeURIComponent(lang)}`),
+ addPersonalWords: (lang: string, words: string[]) =>
+ req('/spell/words', {
+ method: 'POST',
+ body: JSON.stringify({ lang, words }),
+ }),
+ removePersonalWord: (lang: string, word: string) =>
+ req(
+ `/spell/words?lang=${encodeURIComponent(lang)}&word=${encodeURIComponent(word)}`,
+ { method: 'DELETE' },
+ ),
+
// Current deployed build id — changes whenever a new frontend ships. The
// app polls this to offer a refresh. Bypasses any cache so the answer is live.
version: () => req<{ version: string }>('/version', { cache: 'no-store' }),
diff --git a/web/src/audio/sounds.ts b/web/src/audio/sounds.ts
index 97cee2f..ddbd299 100644
--- a/web/src/audio/sounds.ts
+++ b/web/src/audio/sounds.ts
@@ -13,7 +13,10 @@ import blockUrl from '../assets/sounds/block.mp3'
import baodingUrl from '../assets/sounds/baoding.mp3'
import milestoneUrl from '../assets/sounds/milestone.mp3'
import errorUrl from '../assets/sounds/error.mp3'
+import { onPrefsScopeChange, readPref, writePref } from '../lib/prefs'
+// The mute choice belongs to the account, not the browser — one person's
+// silence must not mute the next writer to sign in here.
const STORAGE_KEY = 'petal.sound'
// Master volume — deliberately gentle. These are background delights, not alerts.
@@ -53,24 +56,27 @@ let enabled = readEnabled()
const listeners = new Set<(on: boolean) => void>()
function readEnabled(): boolean {
- try {
- return localStorage.getItem(STORAGE_KEY) !== 'off'
- } catch {
- return true
- }
+ return readPref(STORAGE_KEY) !== 'off'
}
+// This module reads its value at import time, before /api/me has answered.
+// Re-read once the account is known, in case this writer's choice differs from
+// whatever the browser was holding.
+onPrefsScopeChange(() => {
+ const next = readEnabled()
+ if (next === enabled) return
+ enabled = next
+ listeners.forEach((fn) => fn(next))
+ if (next) void ensureContext()
+})
+
export function isSoundEnabled(): boolean {
return enabled
}
export function setSoundEnabled(on: boolean): void {
enabled = on
- try {
- localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
- } catch {
- /* private mode — choice just won't persist */
- }
+ writePref(STORAGE_KEY, on ? 'on' : 'off')
listeners.forEach((fn) => fn(on))
// Touching the context on enable doubles as a user-gesture unlock + warm-up.
if (on) void ensureContext()
diff --git a/web/src/components/Companion/PetalCompanion.tsx b/web/src/components/Companion/PetalCompanion.tsx
index c80a70c..95573f5 100644
--- a/web/src/components/Companion/PetalCompanion.tsx
+++ b/web/src/components/Companion/PetalCompanion.tsx
@@ -3,6 +3,7 @@ import type { SaveStatus } from '../../hooks/useAutoSave'
import { useCompanion, type Mood } from './useCompanion'
import { LottiePlayer } from './LottiePlayer'
import { COMPANIONS, DEFAULT_COMPANION } from './companions'
+import { onPrefsScopeChange, readPref, writePref } from '../../lib/prefs'
interface Props {
wordCount: number
@@ -38,13 +39,22 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
text,
})
- const [companionId, setCompanionId] = useState(() => {
- try {
- return localStorage.getItem(STORAGE_KEY) || DEFAULT_COMPANION
- } catch {
- return DEFAULT_COMPANION
- }
- })
+ const [companionId, setCompanionId] = useState(
+ () => readPref(STORAGE_KEY) || DEFAULT_COMPANION,
+ )
+
+ // The mascot belongs to the writer, not the browser. That first read happens
+ // before /api/me answers, so pick the choice up again once the account is
+ // known — unless she's already swapped companions in the meantime.
+ const touched = useRef(false)
+ useEffect(
+ () =>
+ onPrefsScopeChange(() => {
+ if (touched.current) return
+ setCompanionId(readPref(STORAGE_KEY) || DEFAULT_COMPANION)
+ }),
+ [],
+ )
const companion = COMPANIONS.find((c) => c.id === companionId) ?? COMPANIONS[0]
const [pickerOpen, setPickerOpen] = useState(false)
const rootRef = useRef(null)
@@ -69,11 +79,8 @@ export function PetalCompanion({ wordCount, saveStatus, llmDown, editTick, accep
function choose(id: string) {
setCompanionId(id)
- try {
- localStorage.setItem(STORAGE_KEY, id)
- } catch {
- /* private mode / storage disabled — selection just won't persist */
- }
+ touched.current = true
+ writePref(STORAGE_KEY, id)
setPickerOpen(false)
}
diff --git a/web/src/effects/petals.ts b/web/src/effects/petals.ts
index e76a2c0..45dcc76 100644
--- a/web/src/effects/petals.ts
+++ b/web/src/effects/petals.ts
@@ -2,6 +2,10 @@
// people find drifting petals distracting rather than cozy, so the whole effect
// is opt-out-able and the choice persists in localStorage so it survives reloads.
// Mirrors the tiny pub/sub used for the sound mute toggle (../audio/sounds).
+// The choice belongs to the account, not the browser, so it reads and writes
+// through the per-user preference scope (../lib/prefs).
+
+import { onPrefsScopeChange, readPref, writePref } from '../lib/prefs'
const STORAGE_KEY = 'petal.petals'
@@ -9,24 +13,26 @@ let enabled = readEnabled()
const listeners = new Set<(on: boolean) => void>()
function readEnabled(): boolean {
- try {
- return localStorage.getItem(STORAGE_KEY) !== 'off'
- } catch {
- return true
- }
+ return readPref(STORAGE_KEY) !== 'off'
}
+// The first read happens at import time, before /api/me has said who is
+// writing. Re-read once it has, in case this writer's choice differs from
+// whatever the browser held.
+onPrefsScopeChange(() => {
+ const next = readEnabled()
+ if (next === enabled) return
+ enabled = next
+ listeners.forEach((fn) => fn(next))
+})
+
export function isPetalsEnabled(): boolean {
return enabled
}
export function setPetalsEnabled(on: boolean): void {
enabled = on
- try {
- localStorage.setItem(STORAGE_KEY, on ? 'on' : 'off')
- } catch {
- /* private mode — choice just won't persist */
- }
+ writePref(STORAGE_KEY, on ? 'on' : 'off')
listeners.forEach((fn) => fn(on))
}
diff --git a/web/src/hooks/useSession.ts b/web/src/hooks/useSession.ts
index 91168a2..ce7f2df 100644
--- a/web/src/hooks/useSession.ts
+++ b/web/src/hooks/useSession.ts
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { api, onUnauthorized, type Me } from '../api/client'
+import { setPrefsScope } from '../lib/prefs'
// useSession tracks who is writing, and notices the moment the server stops
// recognising them.
@@ -18,7 +19,13 @@ export function useSession() {
api
.me()
.then((user) => {
- if (!cancelled) setMe(user)
+ if (cancelled) return
+ // Browser preferences (mute, petals, companion) belong to the writer,
+ // not the machine. This is the moment their storage keys can stop being
+ // shared — and the first account on this browser inherits whatever was
+ // set back when Petal had no accounts at all.
+ setPrefsScope(user.id)
+ setMe(user)
})
.catch(() => {
// A 401 has already flipped signedOut through the interceptor; anything
diff --git a/web/src/hooks/useSpellChecker.ts b/web/src/hooks/useSpellChecker.ts
index aecf9ef..a5551fd 100644
--- a/web/src/hooks/useSpellChecker.ts
+++ b/web/src/hooks/useSpellChecker.ts
@@ -1,13 +1,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import nspell, { type NSpell } from 'nspell'
+import { api } from '../api/client'
// useSpellChecker loads the vendored en-US Hunspell dictionary (served from
// /dictionaries/en, embedded in the Go binary via web/dist) and builds an
// in-browser nspell instance — zero backend round-trips, per spec. The
// dictionary is ~550KB, so it's fetched as a static asset (kept out of the JS
-// bundle) once per app session, not per document. A personal word list lives in
-// localStorage and is replayed into nspell on load; adding a word bumps a
-// `version` so consumers re-run their decorations and the word stops flagging.
+// bundle) once per app session, not per document.
+//
+// The personal word list — the words she's told Petal to stop flagging — lives
+// on the server, keyed by her account and by the dictionary's language. It used
+// to be one localStorage key, which meant two people sharing a device shared a
+// word list built from one person's private writing, and one person on a laptop
+// and a tablet had two lists that never met. It is replayed into nspell on load;
+// adding a word bumps a `version` so consumers re-run their decorations and the
+// word stops flagging.
// SpellChecker is the minimal surface the editor decoration layer consumes.
export interface SpellChecker {
@@ -15,11 +22,21 @@ export interface SpellChecker {
suggest(word: string): string[]
}
-const PERSONAL_KEY = 'petal.spell.personal'
+// The dictionary this hook loads. Only en-US ships today; pt-PT arrives with the
+// first Latin pair, and its personal words are a separate list by design — an
+// English exception must not silence a Portuguese flag.
+const DICT_LANG = 'en'
-function loadPersonal(): string[] {
+// Where the list lived before it had an owner (Phase 7). Read once, handed to
+// the account, and then removed — see takeLegacyWords.
+const LEGACY_KEY = 'petal.spell.personal'
+
+// takeLegacyWords reads the pre-account list without deleting it: the words are
+// only dropped from the browser once the server has actually accepted them, so
+// a failed request costs nothing.
+function readLegacyWords(): string[] {
try {
- const raw = localStorage.getItem(PERSONAL_KEY)
+ const raw = localStorage.getItem(LEGACY_KEY)
const parsed = raw ? JSON.parse(raw) : []
return Array.isArray(parsed) ? parsed.filter((w): w is string => typeof w === 'string') : []
} catch {
@@ -27,11 +44,11 @@ function loadPersonal(): string[] {
}
}
-function savePersonal(words: string[]) {
+function clearLegacyWords() {
try {
- localStorage.setItem(PERSONAL_KEY, JSON.stringify(words))
+ localStorage.removeItem(LEGACY_KEY)
} catch {
- /* storage unavailable — personal words just won't persist this session */
+ /* storage unavailable — nothing was read from it either */
}
}
@@ -52,10 +69,25 @@ export function useSpellChecker() {
])
if (cancelled) return
const sp = nspell(aff, dic)
- for (const w of loadPersonal()) sp.add(w)
spellRef.current = sp
setReady(true)
+
+ // Her own words come from her account. A browser holding a list from
+ // before accounts existed hands it over on the way — but only lets go of
+ // it once the server has taken it.
+ const legacy = readLegacyWords()
+ const stored = legacy.length
+ ? await api.addPersonalWords(DICT_LANG, legacy).then((res) => {
+ clearLegacyWords()
+ return res
+ })
+ : await api.listPersonalWords(DICT_LANG)
+ if (cancelled) return
+ for (const w of stored.words) sp.add(w)
+ setVersion((v) => v + 1)
} catch (err) {
+ // A failure here costs correct words being flagged, not writing. The
+ // checker itself stays usable if only the word list failed to arrive.
console.error('spell checker failed to load', err)
}
})()
@@ -75,13 +107,17 @@ export function useSpellChecker() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, version])
+ // Adding a word takes effect in the editor immediately and is persisted in the
+ // background: the word stops being underlined the instant she asks, whatever
+ // the network is doing.
const addWord = useCallback((word: string) => {
const sp = spellRef.current
if (!sp) return
sp.add(word)
- const next = Array.from(new Set([...loadPersonal(), word]))
- savePersonal(next)
setVersion((v) => v + 1)
+ api.addPersonalWords(DICT_LANG, [word]).catch((err) => {
+ console.error('could not save personal word', err)
+ })
}, [])
return { checker, ready, addWord }
diff --git a/web/src/lib/prefs.test.ts b/web/src/lib/prefs.test.ts
new file mode 100644
index 0000000..f627894
--- /dev/null
+++ b/web/src/lib/prefs.test.ts
@@ -0,0 +1,118 @@
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
+
+// These preferences are small — a mute toggle, a mascot — but they are the
+// difference between "my Petal" and "this browser's Petal". The properties that
+// matter: two accounts on one machine never see each other's choices, and the
+// person who was here before accounts existed doesn't lose hers.
+
+function fakeStorage(): Storage {
+ const map = new Map()
+ return {
+ get length() {
+ return map.size
+ },
+ key: (i: number) => [...map.keys()][i] ?? null,
+ getItem: (k: string) => map.get(k) ?? null,
+ setItem: (k: string, v: string) => void map.set(k, v),
+ removeItem: (k: string) => void map.delete(k),
+ clear: () => map.clear(),
+ } as Storage
+}
+
+// Each test gets a fresh module, since the scope is module-level state that the
+// real app sets exactly once.
+async function freshPrefs() {
+ vi.resetModules()
+ return import('./prefs')
+}
+
+beforeEach(() => {
+ vi.stubGlobal('localStorage', fakeStorage())
+})
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('per-account preferences', () => {
+ it('reads and writes the legacy key until the account is known', async () => {
+ const prefs = await freshPrefs()
+ prefs.writePref('petal.sound', 'off')
+ expect(localStorage.getItem('petal.sound')).toBe('off')
+ expect(prefs.readPref('petal.sound')).toBe('off')
+ })
+
+ it('adopts the pre-account choices for the first writer to sign in', async () => {
+ const prefs = await freshPrefs()
+ localStorage.setItem('petal.sound', 'off')
+ localStorage.setItem('petal.companion', 'happy-dog')
+
+ prefs.setPrefsScope('claire')
+
+ expect(prefs.readPref('petal.sound')).toBe('off')
+ expect(prefs.readPref('petal.companion')).toBe('happy-dog')
+ // Moved, not copied — the next account must not inherit them.
+ expect(localStorage.getItem('petal.sound')).toBeNull()
+ expect(localStorage.getItem('petal.companion')).toBeNull()
+ expect(localStorage.getItem('petal.sound.u.claire')).toBe('off')
+ })
+
+ it('keeps two accounts on one browser apart', async () => {
+ const prefs = await freshPrefs()
+ prefs.setPrefsScope('claire')
+ prefs.writePref('petal.companion', 'happy-dog')
+
+ prefs.resetPrefsScopeForTests()
+ prefs.setPrefsScope('sam')
+ // Sam starts from Petal's defaults, not from Claire's mascot.
+ expect(prefs.readPref('petal.companion')).toBeNull()
+
+ prefs.writePref('petal.companion', 'sleeping-cat')
+ prefs.resetPrefsScopeForTests()
+ prefs.setPrefsScope('claire')
+ expect(prefs.readPref('petal.companion')).toBe('happy-dog')
+ })
+
+ it('never lets an existing choice be overwritten by the legacy one', async () => {
+ const prefs = await freshPrefs()
+ localStorage.setItem('petal.sound', 'off')
+ localStorage.setItem('petal.sound.u.claire', 'on')
+
+ prefs.setPrefsScope('claire')
+
+ expect(prefs.readPref('petal.sound')).toBe('on')
+ expect(localStorage.getItem('petal.sound')).toBeNull()
+ })
+
+ it('notifies listeners once the account is known', async () => {
+ const prefs = await freshPrefs()
+ const seen: (string | null)[] = []
+ prefs.onPrefsScopeChange(() => seen.push(prefs.readPref('petal.petals')))
+
+ localStorage.setItem('petal.petals', 'off')
+ prefs.setPrefsScope('claire')
+ // A second call for the same writer is not a change and must not re-fire.
+ prefs.setPrefsScope('claire')
+
+ expect(seen).toEqual(['off'])
+ })
+
+ it('survives storage being unavailable', async () => {
+ const prefs = await freshPrefs()
+ vi.stubGlobal('localStorage', {
+ getItem: () => {
+ throw new Error('denied')
+ },
+ setItem: () => {
+ throw new Error('denied')
+ },
+ removeItem: () => {
+ throw new Error('denied')
+ },
+ })
+
+ expect(() => prefs.setPrefsScope('claire')).not.toThrow()
+ expect(() => prefs.writePref('petal.sound', 'off')).not.toThrow()
+ expect(prefs.readPref('petal.sound')).toBeNull()
+ })
+})
diff --git a/web/src/lib/prefs.ts b/web/src/lib/prefs.ts
new file mode 100644
index 0000000..4eb1035
--- /dev/null
+++ b/web/src/lib/prefs.ts
@@ -0,0 +1,94 @@
+// Per-account browser preferences.
+//
+// Petal's small "how I like it" settings — the mute toggle, the falling-petals
+// toggle, the chosen companion — live in localStorage, which is a property of
+// the *browser*, not of the writer. Once two people can sign in to one Petal
+// that is a bleed: sharing a laptop would mean sharing a mascot, and one
+// person's silence would mute the other.
+//
+// So every key is namespaced by user id. The wrinkle is timing: these modules
+// read their value the moment they're imported, long before /api/me answers.
+// Rather than block startup on the network for a mute flag, a read before the
+// answer arrives sees the *legacy* un-namespaced key — which on a single-writer
+// browser is exactly the right value — and `setPrefsScope` then adopts it into
+// that writer's namespace and tells everyone to re-read.
+//
+// Adoption is a move, not a copy: the first account to sign in on a browser
+// inherits whatever was set before accounts existed, and the second starts from
+// Petal's defaults rather than from a stranger's choices.
+
+type Listener = () => void
+
+let userID: string | null = null
+const listeners = new Set()
+
+// Every base key that should follow the account rather than the browser. Listed
+// here (not just at each call site) because adoption has to walk them all the
+// moment the scope becomes known.
+const SCOPED_KEYS = ['petal.sound', 'petal.petals', 'petal.companion'] as const
+
+// scopedKey is the storage key actually used for `base` right now. Before the
+// caller is known it is the legacy key, so a reload keeps working offline and
+// pre-login reads see the browser's existing preference.
+export function scopedKey(base: string): string {
+ return userID ? `${base}.u.${userID}` : base
+}
+
+export function readPref(base: string): string | null {
+ try {
+ return localStorage.getItem(scopedKey(base))
+ } catch {
+ return null
+ }
+}
+
+export function writePref(base: string, value: string): void {
+ try {
+ localStorage.setItem(scopedKey(base), value)
+ } catch {
+ /* private mode or a full quota — the choice just won't survive a reload */
+ }
+}
+
+// onPrefsScopeChange fires once the account is known and the keys have moved,
+// so a module that already read a value can read it again. Returns an
+// unsubscribe.
+export function onPrefsScopeChange(fn: Listener): () => void {
+ listeners.add(fn)
+ return () => listeners.delete(fn)
+}
+
+// setPrefsScope names the writer these preferences belong to. Called once, from
+// App, as soon as /api/me answers.
+export function setPrefsScope(id: string): void {
+ if (!id || id === userID) return
+ userID = id
+ adoptLegacy()
+ listeners.forEach((fn) => fn())
+}
+
+// adoptLegacy hands the pre-account values to the first account that signs in
+// on this browser, then removes them so nobody else can inherit them.
+function adoptLegacy(): void {
+ try {
+ for (const base of SCOPED_KEYS) {
+ const legacy = localStorage.getItem(base)
+ if (legacy === null) continue
+ // Never overwrite a choice this account has already made here.
+ if (localStorage.getItem(scopedKey(base)) === null) {
+ localStorage.setItem(scopedKey(base), legacy)
+ }
+ localStorage.removeItem(base)
+ }
+ } catch {
+ /* storage unavailable — nothing to adopt, and nothing breaks */
+ }
+}
+
+// resetPrefsScopeForTests unbinds the account again. Exported for tests only;
+// the app sets the scope once and never clears it (signing out leaves the
+// editor mounted, and the same person usually signs back in).
+export function resetPrefsScopeForTests(): void {
+ userID = null
+ listeners.clear()
+}