diff --git a/BUILD_PLAN.md b/BUILD_PLAN.md index 0045f78..d8c9549 100644 --- a/BUILD_PLAN.md +++ b/BUILD_PLAN.md @@ -296,6 +296,25 @@ Each item independent and small; order within is free (SUGGESTIONS §5–§6). - Tests: `grammarLite.test.ts` (30, every rule in both directions), `invitation.test.ts` (7), `offline_test.go` (the six engine-split cases), `db_test.go` (the 0013 backfill), plus false-friend shape/tone guards in `i18n.test.ts`. - ⚠️ **Not deployed and not seen in a browser.** Same standing gap as Phases 21–22: the pt-PT copy added here is part of the pack a native speaker still has not reviewed. +### Phase 23 — Choosing her own pair (2026-07-27) +Raised by the user, not by the plan: *"I see no way to change my language in the mobile UI."* She was right, and the gap was total — `users.pair_lang` had been readable since Phase 19 and writable by nobody. `/api/me` was GET-only, `Upsert` deliberately skips the column, and no screen anywhere offered the choice. Phases 19–21 built the machinery for a second pair and then left the switch off the wall, which is why ⚠️ *"no pt-PT account exists yet"* stood unresolved through two phases: **nothing could create one.** +- [x] **`PATCH /api/me`** (`auth.UpdateMeHandler`) — answers with the whole updated user rather than 204, so the client re-reads the pair from the server instead of assuming its own request took. One write reaches everything: the langpack, the Hunspell dictionary, the Piper voice, the lexicon provider and the prompt language all read `users.pair_lang` at use time. +- [x] **The server refuses a pair it has no copy for.** `auth.shippedPairs` is deliberately *not* `internal/llm`'s language list — that one names every pair the **prompts** can talk about (cheap to add; fr and es have been in it since Phase 19), this one names every pair Petal can **render itself in**, which needs a langpack. Storing `fr` today would strand her on Chinese copy with no way back except a lucky guess at a button she cannot read. +- [x] **The picker lives in the sidebar footer**, beside her name and the way out — because the sidebar *is* the mobile drawer, and it is the only chrome that is always one tap away on a phone. The status bar was the other candidate and is wrong: it exists only while a document is open, which is exactly the wrong moment to discover the app is speaking a language you can't read. +- [x] **Each language names itself** — 中文, Português, and nothing else. The one place in Petal where bilingual copy would actively get in the way: a writer who has landed on the wrong pair cannot read "Portuguese" written in Chinese. The `aria-label` carries the English for a screen reader, which has no such problem. +- [x] **No reload.** The pack was already a subscription (Phase 19), and `useSpellChecker` already reloads on `pack.code` while read-aloud already reads `pack().locale` — so the 2.66 MB pt-PT dictionary inflates, the wide alphabet turns on and the voice changes on the tap. Nothing here needed new plumbing; the switch is the only part that was missing. +- [x] Tests: `internal/auth/pairlang_test.go` (round-trip and back again — a writer who tries a pair must be able to return; every unshipped code refused with the column unmoved; 400 vs 401 split so a lapsed session still becomes the sign-in overlay). `i18n.test.ts` asserts `shippedPacks()` offers exactly the pairs that have copy, and that every code it offers actually resolves. +- Verified: go build/vet, `go test ./...` clean, tsc, vite build, vitest 173/173. **Not deployed and not seen in a browser** — same standing gap as Phases 21–22. + +### Phase 24 (planned) — the fr and es pairs +Scope agreed with the user 2026-07-27: *"switcher for Chinese and Portuguese now, plan support for others in a later session or two."* Phase 21 is the groove; the work per language is the same five items, and the order below is the order in which each one stops being a blocker for the next. +1. **The langpack** (~450 lines, `web/src/i18n/packs/{fr,es}.ts`). TypeScript names every string a new pack still owes, so this is mechanical to *start* and slow to *finish* — the companion lines, the bedtime proverbs and the false-friend list are written for the pair, not translated from zh. es and fr both have real en-collisions to exploit (*actuellement*/*actually*, *librería*/*library*), so both want the `alsoIn` and false-friend blocks pt-PT proved. Add the code to `auth.shippedPairs` **in the same commit** — the picker and the server's allowlist are two halves of one fact. +2. **A native-speaker review.** Standing at ⚠️ for pt-PT since Phase 21 and inherited here; expect a speaker to change the register before the vocabulary. +3. **Hunspell dictionaries.** `scripts/build_ptpt_dictionary.py` generalizes — the eager-affix-expansion problem is French's and Spanish's too, and both are Latin-script so `extendedAlphabet` already covers them. Watch the same trap that caught pt: check what the *source* actually is before vendoring it (fr has `hunspell-fr-classique` vs `-moderne` vs `-toutesvariantes`; es is packaged per country). +4. **Piper voices.** Phase 21 made this configuration rather than code: a compose service and two `.env` lines per language (`TTS_ENDPOINT_FR`/`TTS_VOICE_FR`). fr and es both have several European voices in Piper's catalogue, and unlike pt-PT the download path is plain ASCII — so this is the cheapest item on the list. +5. **Lexicon coverage.** `dict.db` has held all five languages since Phase 20, so both directions should already answer; measure gloss coverage the way pt-PT's 62% was measured before assuming it. +Not blockers, and cheap because Phase 19 did them: `internal/llm/lang.go` already carries fr and es, and `grammarLite`'s L1 rules already gate *ter 30 anos* / "I am agree" / "since three years" to pt+fr+es. + ### Later / explicitly not now - Learner-facing Chinese writing (the zh pair's second direction) — own phase with its own spec (SUGGESTIONS §4); only after Phases 19–21 prove the pair model - ~~Spanish pair — gated on DreamDict growing an es dataset~~ **ungated 2026-07-26** (DreamDict added Spanish). Now a normal follow-on pair after pt-PT, alongside fr — see Phases 20/21. diff --git a/cmd/server/main.go b/cmd/server/main.go index efdd170..a3989d1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -149,6 +149,12 @@ func main() { // this id and shows the signed-in writer. pr.Get("/me", users.MeHandler()) + // …and the one thing about herself she can change: which language + // Petal is her pair in. It lives here rather than under a /settings + // tree because there is exactly one setting and it is a property of + // the user row — the same row /me reads back. + pr.Patch("/me", users.UpdateMeHandler()) + llmClient := llm.NewLLMClient(cfg) sug := suggestions.New(database, llmClient) diff --git a/internal/auth/pairlang_test.go b/internal/auth/pairlang_test.go new file mode 100644 index 0000000..6d4f4f1 --- /dev/null +++ b/internal/auth/pairlang_test.go @@ -0,0 +1,105 @@ +package auth + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "gitea.parodia.dev/drwily/petal/internal/db" +) + +// patchMe drives UpdateMeHandler as the given user would reach it: behind the +// middleware, which is the only thing that puts an id in the context. +func patchMe(t *testing.T, users *UserStore, id, body string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(http.MethodPatch, "/me", strings.NewReader(body)) + r = r.WithContext(WithUser(r.Context(), id)) + w := httptest.NewRecorder() + users.UpdateMeHandler()(w, r) + return w +} + +func TestSetPairLang(t *testing.T) { + _, users, _ := newStores(t) + + if err := users.SetPairLang("bob", "pt-PT"); err != nil { + t.Fatalf("set pt-PT: %v", err) + } + if u, _ := users.Get("bob"); u.PairLang != "pt-PT" { + t.Fatalf("pair_lang = %q, want pt-PT", u.PairLang) + } + + // And back — a writer who tries a pair and doesn't like it must be able to + // return, which is the whole reason the picker exists. + if err := users.SetPairLang("bob", "zh"); err != nil { + t.Fatalf("set zh: %v", err) + } + if u, _ := users.Get("bob"); u.PairLang != "zh" { + t.Fatalf("pair_lang = %q, want zh", u.PairLang) + } +} + +// A pair the frontend has no langpack for must not be storable. Accepting it +// would leave her looking at Chinese copy with no way back except a lucky guess. +func TestSetPairLangRejectsUnshippedPairs(t *testing.T) { + _, users, _ := newStores(t) + + for _, lang := range []string{"fr", "es", "pt-BR", "klingon", "", " "} { + if err := users.SetPairLang("bob", lang); err == nil { + t.Fatalf("stored unshipped pair %q", lang) + } + } + if u, _ := users.Get("bob"); u.PairLang != "zh" { + t.Fatalf("a refused write still moved pair_lang to %q", u.PairLang) + } +} + +func TestSetPairLangUnknownUser(t *testing.T) { + _, users, _ := newStores(t) + if err := users.SetPairLang("nobody", "pt-PT"); err == nil { + t.Fatal("set a pair language on an account that does not exist") + } +} + +func TestUpdateMeHandler(t *testing.T) { + _, users, _ := newStores(t) + + w := patchMe(t, users, "bob", `{"pair_lang":"pt-PT"}`) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body.String()) + } + // The whole user comes back, so the client can re-read the pair from the + // server instead of assuming its request took. + var got db.User + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.ID != "bob" || got.PairLang != "pt-PT" { + t.Fatalf("response = %+v, want bob on pt-PT", got) + } +} + +func TestUpdateMeHandlerRejects(t *testing.T) { + _, users, _ := newStores(t) + + for name, body := range map[string]string{ + "unshipped pair": `{"pair_lang":"fr"}`, + "missing field": `{}`, + "not json": `pt-PT`, + } { + if w := patchMe(t, users, "bob", body); w.Code != http.StatusBadRequest { + t.Fatalf("%s: status = %d, want 400", name, w.Code) + } + } + if u, _ := users.Get("bob"); u.PairLang != "zh" { + t.Fatalf("a rejected request still moved pair_lang to %q", u.PairLang) + } + + // A caller the middleware never resolved (or whose row is gone) is a lapsed + // session, not a bad request — the client turns 401 into the sign-in overlay. + if w := patchMe(t, users, "nobody", `{"pair_lang":"pt-PT"}`); w.Code != http.StatusUnauthorized { + t.Fatalf("unknown user: status = %d, want 401", w.Code) + } +} diff --git a/internal/auth/users.go b/internal/auth/users.go index dc134c8..12ebe47 100644 --- a/internal/auth/users.go +++ b/internal/auth/users.go @@ -2,6 +2,7 @@ package auth import ( "database/sql" + "encoding/json" "errors" "net/http" "strings" @@ -68,6 +69,85 @@ func (u *UserStore) MeHandler() http.HandlerFunc { } } +// The pairs a writer may actually choose, in the order the picker offers them. +// +// This is deliberately *not* internal/llm's list of languages. That one names +// every pair the prompts know how to talk about, which is a cheap thing to add; +// this one names the pairs Petal can render itself in, which requires a langpack +// on the frontend. Accepting a code with no pack would leave her looking at +// Chinese with no way back except another guess, so the server refuses it. fr +// and es join this list on the day their packs land, not before. +var shippedPairs = []string{"zh", "pt-PT"} + +func pairIsShipped(lang string) bool { + for _, p := range shippedPairs { + if p == lang { + return true + } + } + return false +} + +// SetPairLang moves an account to another (English + X) pair. +func (u *UserStore) SetPairLang(id, lang string) error { + if !pairIsShipped(lang) { + return errors.New("auth: unshipped pair language " + lang) + } + res, err := u.db.Exec(`UPDATE users SET pair_lang = ? WHERE id = ?`, lang, id) + if err != nil { + return err + } + if n, err := res.RowsAffected(); err == nil && n == 0 { + return sql.ErrNoRows + } + return nil +} + +// UpdateMeHandler changes the caller's own settings — today, the one setting +// there is: which language Petal speaks alongside her English. +// +// It answers with the whole updated user rather than an empty 204 so the client +// has one shape to trust: /api/me and this return the same thing, and the app +// re-reads the pair from the response instead of assuming its request took. +// +// The pair language reaches further than the UI copy — it picks her Hunspell +// dictionary, her read-aloud voice, which word-lookup provider answers, and the +// language the prompts ask the model to explain in. All of those read +// `users.pair_lang` at use time, so all of them follow from this one write. +func (u *UserStore) UpdateMeHandler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var body struct { + PairLang string `json:"pair_lang"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + httputil.BadRequest(w, "invalid request body") + return + } + lang := strings.TrimSpace(body.PairLang) + if !pairIsShipped(lang) { + // Name the ones that work. A writer who lands here has picked from a + // stale client, and "not a language" tells her nothing. + httputil.BadRequest(w, "unsupported language pair — Petal speaks "+strings.Join(shippedPairs, ", ")) + return + } + id := UserID(r.Context()) + if err := u.SetPairLang(id, lang); err != nil { + if errors.Is(err, sql.ErrNoRows) { + httputil.ErrorJSON(w, http.StatusUnauthorized, "not signed in") + return + } + httputil.ServerError(w, err) + return + } + user, err := u.Get(id) + if err != nil { + httputil.ServerError(w, err) + return + } + httputil.WriteJSON(w, http.StatusOK, user) + } +} + // Allowlist decides which of Authentik's users may write in this Petal. // Authentik fronts several applications; being a valid user there does not mean // being a user here. diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 92dad79..6eaf908 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -251,6 +251,13 @@ export const api = { // the hardcoded local user, so the frontend needs no separate mode for it. me: () => req('/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', { method: 'PATCH', body: JSON.stringify({ pair_lang: lang }) }), + listDocs: () => req('/docs'), createDoc: () => req('/docs', { method: 'POST' }), getDoc: (id: string) => req(`/docs/${id}`), diff --git a/web/src/components/DocList/DocList.tsx b/web/src/components/DocList/DocList.tsx index a5d64b6..82ffb3b 100644 --- a/web/src/components/DocList/DocList.tsx +++ b/web/src/components/DocList/DocList.tsx @@ -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({ )} + {/* 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. */} + + {/* 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 && ( diff --git a/web/src/components/DocList/LanguagePicker.tsx b/web/src/components/DocList/LanguagePicker.tsx new file mode 100644 index 0000000..fa6bce5 --- /dev/null +++ b/web/src/components/DocList/LanguagePicker.tsx @@ -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(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 ( +
+
+ {t.docs.language} +
+ {packs.map((p) => { + const active = p.code === t.code + return ( + + ) + })} +
+
+ {failed && ( + + {t.docs.languageFailed} + + )} +
+ ) +} diff --git a/web/src/i18n/i18n.test.ts b/web/src/i18n/i18n.test.ts index 6c4031f..4f02d8c 100644 --- a/web/src/i18n/i18n.test.ts +++ b/web/src/i18n/i18n.test.ts @@ -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) diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts index 6bbe0e7..205d9e9 100644 --- a/web/src/i18n/index.ts +++ b/web/src/i18n/index.ts @@ -26,6 +26,15 @@ const PACKS: Partial> = { 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 diff --git a/web/src/i18n/packs/pt-PT.ts b/web/src/i18n/packs/pt-PT.ts index eb8df81..0eb0911 100644 --- a/web/src/i18n/packs/pt-PT.ts +++ b/web/src/i18n/packs/pt-PT.ts @@ -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: { diff --git a/web/src/i18n/packs/zh.ts b/web/src/i18n/packs/zh.ts index 7f3c1ed..65c6711 100644 --- a/web/src/i18n/packs/zh.ts +++ b/web/src/i18n/packs/zh.ts @@ -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: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 9f3b3cc..b3ca5f2 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -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: {