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
+7
View File
@@ -251,6 +251,13 @@ export const api = {
// the hardcoded local user, so the frontend needs no separate mode for it.
me: () => req<Me>('/me'),
// Move to another (English + X) pair. Answers with the whole updated user, so
// the caller re-reads the pair from the server rather than assuming its own
// request took — a code the server won't ship comes back 400 and the app is
// still on a language it can render.
setPairLang: (lang: string) =>
req<Me>('/me', { method: 'PATCH', body: JSON.stringify({ pair_lang: lang }) }),
listDocs: () => req<DocSummary[]>('/docs'),
createDoc: () => req<Document>('/docs', { method: 'POST' }),
getDoc: (id: string) => req<Document>(`/docs/${id}`),
+6
View File
@@ -3,6 +3,7 @@ import { api, type DocSummary, type Tag, type TagColor } from '../../api/client'
import { DocListItem } from './DocListItem'
import { SearchBox } from './SearchBox'
import { TagChip } from './TagChip'
import { LanguagePicker } from './LanguagePicker'
import { usePack, type Pack } from '../../i18n'
interface Props {
@@ -156,6 +157,11 @@ export function DocList({
</div>
)}
{/* The pair Petal speaks. Unlike the rows above it this is not about any
document, and unlike sign-out it is offered whether or not there is an
account behind the session — a local-dev build still has a langpack. */}
<LanguagePicker />
{/* Who's writing, and the way out. Shown only when there's a real account
behind the session — a local-dev build has nobody to sign out as. */}
{account && (
@@ -0,0 +1,86 @@
import { useState } from 'react'
import { api } from '../../api/client'
import { setPackLang, shippedPacks, usePack } from '../../i18n'
// Which language Petal is her pair in, and how she changes it.
//
// It lives in the sidebar footer next to her name and the way out, because the
// pair is a property of the writer rather than of a document — and because the
// sidebar is the mobile drawer, which is the only chrome that is always one tap
// away on a phone. The status bar would have been the other candidate; it only
// exists while a document is open, which is exactly the wrong time to discover
// the app is speaking a language you can't read.
//
// Each language names itself. A writer who has landed on the wrong pair cannot
// read a label that says "Portuguese" in Chinese, so the buttons say 中文 and
// Português and nothing else — the one place in Petal where bilingual copy would
// actively get in the way.
export function LanguagePicker() {
const t = usePack()
const packs = shippedPacks()
const [saving, setSaving] = useState<string | null>(null)
const [failed, setFailed] = useState(false)
// Nothing to choose between — a deployment with one pack shows no picker
// rather than a single button that does nothing.
if (packs.length < 2) return null
const choose = async (code: string) => {
if (code === t.code || saving) return
setSaving(code)
setFailed(false)
try {
const me = await api.setPairLang(code)
// The server's answer, not the code we asked for. Everything downstream —
// her dictionary, the read-aloud voice, the word lookups — follows the
// pack, so it must follow what was actually stored.
setPackLang(me.pair_lang)
} catch {
// A 401 has already surfaced as the sign-in overlay through the client's
// interceptor; anything else leaves her on the pair she was already on,
// which is a working app and worth saying so plainly.
setFailed(true)
} finally {
setSaving(null)
}
}
return (
<div className="flex flex-col gap-1 px-1">
<div className="flex items-center gap-2 text-xs" style={{ color: 'var(--color-muted)' }}>
<span className="shrink-0 font-semibold">{t.docs.language}</span>
<div className="ml-auto flex shrink-0 gap-1">
{packs.map((p) => {
const active = p.code === t.code
return (
<button
key={p.code}
type="button"
onClick={() => void choose(p.code)}
disabled={saving !== null}
aria-pressed={active}
// The one label a writer on the wrong pair still recognises.
aria-label={`Petal speaks ${p.nativeName}`}
lang={p.code}
className="petal-tap-sm px-2.5 py-1 text-xs font-bold transition-colors disabled:opacity-60"
style={{
borderRadius: 'var(--radius-pill)',
background: active ? 'var(--color-accent)' : 'var(--color-surface)',
color: active ? '#fff' : 'var(--color-plum)',
boxShadow: active ? 'none' : 'var(--shadow-soft)',
}}
>
{p.nativeName}
</button>
)
})}
</div>
</div>
{failed && (
<span className="text-[0.7rem]" style={{ color: 'var(--color-accent)' }}>
{t.docs.languageFailed}
</span>
)}
</div>
)
}
+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)
+9
View File
@@ -26,6 +26,15 @@ const PACKS: Partial<Record<PairLang, Pack>> = { zh, 'pt-PT': ptPT }
const DEFAULT_LANG: PairLang = 'zh'
// The pairs the picker may offer, derived from PACKS rather than listed again —
// a pack that exists is a pair Petal can render itself in, and that is the whole
// condition. The server keeps its own copy of this list (auth.shippedPairs) and
// refuses anything outside it; the two are expected to land together when a new
// pack ships.
export function shippedPacks(): Pack[] {
return Object.values(PACKS).filter((p): p is Pack => Boolean(p))
}
type Listener = () => void
let current: Pack = zh
+2
View File
@@ -270,6 +270,8 @@ export const ptPT: Pack = {
noMatches: 'Sem resultados · No matches',
tags: 'Etiquetas · Tags',
newTagPlaceholder: 'Nova etiqueta · New tag',
language: 'Idioma · Language',
languageFailed: 'Não deu para mudar — continua na mesma língua · Couldnt switch',
},
editor: {
+2
View File
@@ -179,6 +179,8 @@ export const zh: Pack = {
noMatches: '没有找到 · No matches',
tags: '标签 · Tags',
newTagPlaceholder: '新标签 · New tag',
language: '语言 · Language',
languageFailed: '没能换成功,还是原来的语言 · Couldnt switch — still the same language',
},
editor: {
+6
View File
@@ -145,6 +145,12 @@ export interface Pack {
noMatches: string
tags: string
newTagPlaceholder: string
// The language picker in the sidebar. `language` labels it; `languageFailed`
// is what she reads if the change doesn't reach the server — it has to say
// that nothing moved, because the app is still speaking the old pair and a
// silent no-op would read as Petal ignoring her.
language: string
languageFailed: string
}
editor: {