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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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