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
+25
View File
@@ -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<VocabWord>(`/vocab/${id}/review`, { method: 'POST', body: JSON.stringify({ grade }) }),
deleteVocab: (id: string) => req<void>(`/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<PersonalWords>(`/spell/words?lang=${encodeURIComponent(lang)}`),
addPersonalWords: (lang: string, words: string[]) =>
req<PersonalWords>('/spell/words', {
method: 'POST',
body: JSON.stringify({ lang, words }),
}),
removePersonalWord: (lang: string, word: string) =>
req<PersonalWords>(
`/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' }),
+16 -10
View File
@@ -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()
+19 -12
View File
@@ -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<string>(() => {
try {
return localStorage.getItem(STORAGE_KEY) || DEFAULT_COMPANION
} catch {
return DEFAULT_COMPANION
}
})
const [companionId, setCompanionId] = useState<string>(
() => 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<HTMLDivElement>(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)
}
+16 -10
View File
@@ -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))
}
+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 }
+118
View File
@@ -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<string, string>()
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()
})
})
+94
View File
@@ -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<Listener>()
// 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()
}