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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 · Couldn’t switch',
|
||||
},
|
||||
|
||||
editor: {
|
||||
|
||||
@@ -179,6 +179,8 @@ export const zh: Pack = {
|
||||
noMatches: '没有找到 · No matches',
|
||||
tags: '标签 · Tags',
|
||||
newTagPlaceholder: '新标签 · New tag',
|
||||
language: '语言 · Language',
|
||||
languageFailed: '没能换成功,还是原来的语言 · Couldn’t switch — still the same language',
|
||||
},
|
||||
|
||||
editor: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user