Phase 18: settings that belong to the writer, not the browser

The mute toggle, the falling-petals toggle and the chosen companion lived in
localStorage, which is a property of the machine. Now that two people can sign
in to one Petal, sharing a laptop would have meant sharing a mascot and one
person's silence muting the other. Each key is namespaced by user id.

The awkward part is timing: sounds.ts and petals.ts read their value the moment
they are imported, long before /api/me can have answered. Rather than block
startup on the network for a mute flag, a read before the answer arrives sees
the old un-namespaced key -- on a single-writer browser, exactly the right
value -- and setPrefsScope then adopts it into that account's namespace and
tells every reader to look again. Adoption moves rather than copies, so the
first account inherits what was set before accounts existed and the second
starts from Petal's defaults.

The personal spelling dictionary moves further than that: onto the server. It
is built from her own writing, so it should not be readable by whoever sits
down at the same browser next -- but merely namespacing it would have split the
list she already has between her laptop and her tablet, which is worse than
where we started. A table keyed (user_id, lang, word) follows her instead. The
lang is the dictionary's, not hers: an English exception must not silence a
pt-PT flag once the second pair ships.

Adding a word takes effect in the editor immediately and persists in the
background, so the underline goes away the instant she asks. A browser still
holding the old list hands it over on first load, and only lets go once the
server has taken it.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 08:06:08 -07:00
parent ddc4164228
commit 30d5e691c9
13 changed files with 795 additions and 49 deletions
+8 -1
View File
@@ -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
+48 -12
View File
@@ -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 }