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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user