Let her choose her own pair

Raised by the user, not by the plan: there was no way to change language
in the mobile UI. There was no way anywhere. `users.pair_lang` has been
readable since Phase 19 and writable by nobody — /api/me was GET-only and
Upsert deliberately skips the column — which is also why "no pt-PT account
exists yet" has stood through two phases. Nothing could create one.

PATCH /api/me answers with the whole user rather than 204, so the client
re-reads the pair from the server instead of trusting its own request. One
write reaches everything: langpack, Hunspell dictionary, Piper voice,
lexicon provider and prompt language all read the column at use time.

The server refuses a pair it has no copy for, and auth.shippedPairs is
deliberately not internal/llm's list. That one names pairs the prompts can
talk about (fr and es, since Phase 19); this one names pairs Petal can
render itself in, which needs a langpack. Storing fr today would strand
her on Chinese with no way back except a lucky guess at a button she
cannot read.

The picker sits in the sidebar footer because the sidebar is the mobile
drawer — always one tap away. The status bar exists only while a document
is open, which is the wrong moment to find the app speaking a language you
can't read. Each language names itself, 中文 and Português: the one place
bilingual copy would get in the way.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
This commit is contained in:
prosolis
2026-07-27 15:06:33 -07:00
parent 1bbc8fc8d3
commit 1f4ca4775a
12 changed files with 346 additions and 1 deletions
+18 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { onPackChange, pack, resetPackForTests, setPackLang } from './index'
import { onPackChange, pack, resetPackForTests, setPackLang, shippedPacks } from './index'
import { zh } from './packs/zh'
import { ptPT } from './packs/pt-PT'
import type { Pack } from './types'
@@ -61,6 +61,23 @@ describe('pack selection', () => {
expect(seen).toHaveBeenCalledTimes(1)
})
// What the sidebar picker offers. It is derived from the packs rather than
// listed a second time, so a pack that ships is a pair she can choose — and a
// pair with no pack can never be offered, which is the invariant the server's
// matching allowlist exists to enforce from the other side.
it('offers exactly the pairs it has copy for', () => {
const codes = shippedPacks().map((p) => p.code)
expect(codes.sort()).toEqual(['pt-PT', 'zh'])
// Every offered pair names itself, because a writer stranded on the wrong
// pack can only read the label that is in her own language.
for (const p of shippedPacks()) expect(p.nativeName.length).toBeGreaterThan(0)
// Anything the picker offers must actually resolve.
for (const code of codes) {
setPackLang(code)
expect(pack().code).toBe(code)
}
})
it('stops notifying after unsubscribe', () => {
const seen = vi.fn()
const off = onPackChange(seen)