From 6cd89a78d37f17fc289d3b79d85c21e9cf5787df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Sat, 1 Aug 2026 23:11:18 +0200 Subject: [PATCH] feat(i18n): add scoped i18n foundation with one migrated page (#2097) Co-authored-by: Kandev Agent --- .github/workflows/frontend-tests.yml | 9 + AGENTS.md | 23 + apps/backend/AGENTS.md | 21 + apps/backend/internal/backendapp/helpers.go | 14 +- apps/backend/internal/i18n/i18n.go | 180 +++++ apps/backend/internal/i18n/i18n_test.go | 102 +++ apps/backend/internal/i18n/locales/en.json | 5 + .../backend/internal/i18n/locales/pseudo.json | 5 + apps/backend/internal/webapp/dev_handler.go | 6 +- apps/backend/internal/webapp/handler.go | 7 +- apps/backend/internal/webapp/payload.go | 9 + apps/backend/internal/webapp/shell.go | 62 +- apps/backend/internal/webapp/shell_test.go | 69 ++ apps/pnpm-lock.yaml | 239 +++--- apps/web/AGENTS.md | 54 ++ .../sections/settings/general-group.tsx | 10 +- apps/web/components/settings/general-nav.ts | 56 +- .../components/settings/general-settings.tsx | 82 +- .../components/settings/language-settings.tsx | 76 ++ .../settings/settings-floating-save.tsx | 147 ++-- .../settings/settings-layout-client.tsx | 159 +++- .../settings/startup-page-settings-card.tsx | 31 +- .../settings/system-metrics-settings-card.tsx | 47 +- .../e2e/tests/i18n/language-switch.spec.ts | 68 ++ .../e2e/tests/i18n/pseudo-coverage.spec.ts | 117 +++ apps/web/eslint.config.mjs | 15 + apps/web/eslint.i18n.config.mjs | 36 + apps/web/eslint.i18n.options.mjs | 170 ++++ apps/web/lib/i18n/boot.test.ts | 49 ++ apps/web/lib/i18n/boot.ts | 37 + apps/web/lib/i18n/cookie.test.ts | 42 + apps/web/lib/i18n/cookie.ts | 36 + apps/web/lib/i18n/formats.test.ts | 45 ++ apps/web/lib/i18n/formats.ts | 56 ++ apps/web/lib/i18n/index.test.ts | 71 ++ apps/web/lib/i18n/index.ts | 155 ++++ apps/web/lib/i18n/init-sync.test.tsx | 65 ++ apps/web/lib/i18n/option-label.ts | 28 + apps/web/lib/i18n/provider.test.tsx | 44 ++ apps/web/lib/i18n/provider.tsx | 33 + apps/web/lib/utils/time.ts | 22 +- apps/web/package.json | 9 +- apps/web/scripts/check-i18n-keys.mjs | 141 ++++ apps/web/scripts/check-inline-plurals.mjs | 139 ++++ apps/web/scripts/check-module-scope-t.mjs | 104 +++ apps/web/scripts/check-trans-indices.mjs | 224 ++++++ apps/web/scripts/externalize-strings.mjs | 743 ++++++++++++++++++ apps/web/scripts/fix-folded-elements.mjs | 233 ++++++ apps/web/scripts/fix-inline-plurals.mjs | 273 +++++++ apps/web/scripts/fix-trans-indices.mjs | 165 ++++ apps/web/scripts/generate-pseudo-locale.mjs | 133 ++++ apps/web/scripts/lib/display-context.mjs | 130 +++ apps/web/scripts/lib/template-literals.mjs | 108 +++ apps/web/src/app-shell.tsx | 65 +- apps/web/src/boot-payload.ts | 10 + apps/web/src/locales/en/common.json | 19 + apps/web/src/locales/en/settings.json | 95 +++ apps/web/src/locales/pseudo/common.json | 19 + apps/web/src/locales/pseudo/settings.json | 95 +++ apps/web/vite-env.d.ts | 1 + apps/web/vitest.setup.ts | 16 + docs/i18n.md | 327 ++++++++ docs/specs/INDEX.md | 1 + docs/specs/platform/i18n.md | 196 +++++ 64 files changed, 5416 insertions(+), 332 deletions(-) create mode 100644 apps/backend/internal/i18n/i18n.go create mode 100644 apps/backend/internal/i18n/i18n_test.go create mode 100644 apps/backend/internal/i18n/locales/en.json create mode 100644 apps/backend/internal/i18n/locales/pseudo.json create mode 100644 apps/web/components/settings/language-settings.tsx create mode 100644 apps/web/e2e/tests/i18n/language-switch.spec.ts create mode 100644 apps/web/e2e/tests/i18n/pseudo-coverage.spec.ts create mode 100644 apps/web/eslint.i18n.config.mjs create mode 100644 apps/web/eslint.i18n.options.mjs create mode 100644 apps/web/lib/i18n/boot.test.ts create mode 100644 apps/web/lib/i18n/boot.ts create mode 100644 apps/web/lib/i18n/cookie.test.ts create mode 100644 apps/web/lib/i18n/cookie.ts create mode 100644 apps/web/lib/i18n/formats.test.ts create mode 100644 apps/web/lib/i18n/formats.ts create mode 100644 apps/web/lib/i18n/index.test.ts create mode 100644 apps/web/lib/i18n/index.ts create mode 100644 apps/web/lib/i18n/init-sync.test.tsx create mode 100644 apps/web/lib/i18n/option-label.ts create mode 100644 apps/web/lib/i18n/provider.test.tsx create mode 100644 apps/web/lib/i18n/provider.tsx create mode 100644 apps/web/scripts/check-i18n-keys.mjs create mode 100644 apps/web/scripts/check-inline-plurals.mjs create mode 100644 apps/web/scripts/check-module-scope-t.mjs create mode 100644 apps/web/scripts/check-trans-indices.mjs create mode 100644 apps/web/scripts/externalize-strings.mjs create mode 100644 apps/web/scripts/fix-folded-elements.mjs create mode 100644 apps/web/scripts/fix-inline-plurals.mjs create mode 100644 apps/web/scripts/fix-trans-indices.mjs create mode 100644 apps/web/scripts/generate-pseudo-locale.mjs create mode 100644 apps/web/scripts/lib/display-context.mjs create mode 100644 apps/web/scripts/lib/template-literals.mjs create mode 100644 apps/web/src/locales/en/common.json create mode 100644 apps/web/src/locales/en/settings.json create mode 100644 apps/web/src/locales/pseudo/common.json create mode 100644 apps/web/src/locales/pseudo/settings.json create mode 100644 apps/web/vite-env.d.ts create mode 100644 docs/i18n.md create mode 100644 docs/specs/platform/i18n.md diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index 9cc4fc0695..d70fb90fe3 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -146,6 +146,15 @@ jobs: - name: Run lint run: pnpm --filter @kandev/web lint + # Unlike the eslint literal-string guard (scoped to already-migrated paths + # via i18nGuardFiles), these four checks scan components/app/hooks/lib + # repo-wide. Each one pins a class of i18n bug that fails silently at + # runtime: a key with no catalog entry, tag markup rendered + # verbatim, an English plural ending passed as a value, and t() at module + # scope frozen at the boot locale. See docs/i18n.md. + - name: Run i18n checks + run: pnpm --filter @kandev/web i18n:check + - name: Run tests run: pnpm --filter @kandev/web test diff --git a/AGENTS.md b/AGENTS.md index cbabbd9535..1ac7a452a3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,6 +68,29 @@ When you hit a limit: extract a helper function, custom hook, or sub-component. Every code change must include tests for new or changed logic. Backend: `*_test.go` files alongside the source. Frontend: `*.test.ts` files for utility functions, hooks, API clients, and store slices. Exceptions: config files, generated code, React component markup. Use `/tdd` for test-driven development. +### Internationalization + +The web UI is localized with i18next (`namespace:key`). **All new user-facing copy +must go through `t()` / ``** — never a hardcoded literal — regardless of +which directory you are in. + +The migration of existing strings is in progress, **one directory per PR**. The +lint guard (`i18next/no-literal-string`) is an error, but only for the paths in +`i18nGuardFiles` (`apps/web/eslint.i18n.options.mjs`), so unrelated PRs are never +blocked by strings in code nobody has migrated. **When you migrate a directory, +append it to that list in the same PR**; never remove an entry to make a build +pass. + +Three rules that cause silent, hard-to-find bugs when broken: never translate a +string compared with `===` (type-to-confirm tokens become impossible to type); +never call `t()` at module scope (it freezes at the boot locale and the +pseudo-locale cannot detect it); never pass an English plural ending as a value — +use `count` with `_one`/`_other`. `pnpm run i18n:check` enforces the last two. + +A clean lint is not proof a file is done — the rule only sees plain literals in +JSX. The pseudo-locale (Settings → General → Appearance, dev/e2e builds) is the +completeness check. Full guide: [`docs/i18n.md`](docs/i18n.md). + ### Knowledge - **Public docs:** Website-ready user documentation lives in `docs/public/**`. Use `/docs-maintainer` when a change affects CLI commands, config keys, install/deploy flows, workflows, executors, public APIs, screenshots, or user-facing terminology. - **Specs:** Feature specs live in `docs/specs//spec.md` — the durable "what & why" of a feature, written before coding. Use `/spec` to write or update a spec. See `docs/specs/INDEX.md`. diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md index 233a29ef61..13b1f09f5a 100644 --- a/apps/backend/AGENTS.md +++ b/apps/backend/AGENTS.md @@ -275,6 +275,27 @@ Every long-running goroutine must have a single owner with explicit start and st You may still list the column in the `CREATE TABLE` so fresh DBs get it inline, but the migration is the source of truth for evolution and must stand alone. New columns also need: the struct field in `models/`, the DTO field + `ToAPI` in `pkg/api/v1/`, and every `CreateX`/`UpdateX`/bulk write in the repo that should set it. +## Internationalization + +`internal/i18n` covers only what Go renders **directly to a browser**, which today +is just the SPA-unavailable error pages. Everything else stays English by design — +the ~1,100 `http.Error` / `gin.H{"error": …}` strings are diagnostics the SPA maps +onto its own translated copy, and log lines, agent/ACP output, and CLI output are +not display copy. The shared-task artifacts (`share.html`, gist README and +description) are server-rendered and in scope for localization, but not done yet. + +- `i18n.T(locale, key)` for lookup, `i18n.FromRequest(r)` at the HTTP boundary. +- The package has no placeholder interpolation and no context-carried locale. + Both are needed for the share artifacts; add them with that work rather than + ahead of it. +- Catalogs are embedded JSON (`internal/i18n/locales/.json`). `pseudo` is + **generated** — run `pnpm run i18n:pseudo` from `apps/web`, which writes both + the frontend and backend catalogs. A per-package test fails if an `en` key is + missing from `pseudo`. +- **For new user-facing output, prefer returning a stable error code** the + frontend translates. Reach for `i18n.T` only when Go itself writes the bytes a + user reads. + ## Code-quality limits Enforced by `apps/backend/.golangci.yml` (errors on new code only): diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index f82603c266..32a7b80f73 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -49,6 +49,7 @@ import ( "github.com/kandev/kandev/internal/gitlab" "github.com/kandev/kandev/internal/health" "github.com/kandev/kandev/internal/health/oslimits" + "github.com/kandev/kandev/internal/i18n" "github.com/kandev/kandev/internal/improvekandev" "github.com/kandev/kandev/internal/jira" "github.com/kandev/kandev/internal/linear" @@ -64,6 +65,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/plugins" pluginstore "github.com/kandev/kandev/internal/plugins/store" + "github.com/kandev/kandev/internal/profiles" promptcontroller "github.com/kandev/kandev/internal/prompts/controller" prompthandlers "github.com/kandev/kandev/internal/prompts/handlers" "github.com/kandev/kandev/internal/repoclone" @@ -706,18 +708,26 @@ func webAppHandlerOptions(p routeParams) []webapp.HandlerOption { } } -func webRuntimeConfig(debug bool) webapp.RuntimeConfig { +// webRuntimeConfig builds the SPA's runtime block. `req` supplies the active +// locale (from the kandev_locale cookie) so the shell can set and the +// client can activate the right catalog before first paint. +func webRuntimeConfig(debug bool, req *http.Request) webapp.RuntimeConfig { return webapp.RuntimeConfig{ APIPrefix: "/api/v1", WebSocketPath: "/ws", Debug: debug, + // Gates QA-only UI (the pseudo-locale option). Separate from Debug: the + // e2e harness serves a PRODUCTION bundle, so the frontend cannot infer + // this from its own build mode. + NonProduction: profiles.DetectEnvironment() != profiles.EnvProd, + Locale: i18n.FromRequest(req), } } func bootPayload(ctx context.Context, req *http.Request, p routeParams, route webapp.RouteClassification) webapp.BootPayload { payload := webapp.NewBootPayload( route, - webRuntimeConfig(p.devMode), + webRuntimeConfig(p.devMode, req), bootInitialState(ctx, req, p, route), ) payload.RouteData = bootRouteData(ctx, req, p, route) diff --git a/apps/backend/internal/i18n/i18n.go b/apps/backend/internal/i18n/i18n.go new file mode 100644 index 0000000000..7698802be4 --- /dev/null +++ b/apps/backend/internal/i18n/i18n.go @@ -0,0 +1,180 @@ +// Package i18n localizes the strings the backend renders directly to a browser. +// +// Scope is deliberately narrow. Almost every string the backend produces is +// diagnostic — API error payloads ("invalid payload", "wrong instance id"), log +// lines, and agent output. Those stay English: the SPA maps API failures onto its +// own translated copy, and translating a diagnostic would make logs and support +// harder without helping a user. See docs/i18n.md ("Backend"). +// +// What this package covers is the surface a user reads straight from Go: the +// plain-text error pages served when the SPA bundle cannot be delivered, and any +// future server-rendered copy. +// +// Catalogs mirror the frontend's shape (`locales/.json`, flat +// `key: message`) and are embedded, so no runtime file access is needed. +package i18n + +import ( + "embed" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" +) + +//go:embed locales/*.json +var catalogFS embed.FS + +// DefaultLocale is the source locale; every other catalog falls back to it. +const DefaultLocale = "en" + +// LocaleCookie is written by the SPA language switcher and is the source of +// truth for the active locale. Kept in sync with the frontend constant in +// apps/web/lib/i18n/cookie.ts. +const LocaleCookie = "kandev_locale" + +// supportedLocales enumerates the locales with a committed catalog. Keep in sync +// with SUPPORTED_LOCALES in apps/web/lib/i18n/index.ts. +var supportedLocales = map[string]bool{ + "en": true, + "pseudo": true, +} + +var ( + loadOnce sync.Once + catalogs map[string]map[string]string +) + +func load() { + loadOnce.Do(func() { + catalogs = make(map[string]map[string]string) + entries, err := catalogFS.ReadDir("locales") + if err != nil { + return + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, readErr := catalogFS.ReadFile("locales/" + entry.Name()) + if readErr != nil { + continue + } + messages := map[string]string{} + if json.Unmarshal(data, &messages) != nil { + continue + } + catalogs[strings.TrimSuffix(entry.Name(), ".json")] = messages + } + }) +} + +// Supported reports whether locale has a committed catalog. +func Supported(locale string) bool { return supportedLocales[locale] } + +// Normalize returns locale when it is supported, otherwise DefaultLocale. Used +// for both `` and message lookup so they can never disagree. +func Normalize(locale string) string { + if supportedLocales[locale] { + return locale + } + return DefaultLocale +} + +// FromRequest resolves the active locale: the kandev_locale cookie first (the +// SPA's explicit user choice), then the browser's Accept-Language, then the +// default. Unknown values coerce to DefaultLocale rather than erroring. +func FromRequest(r *http.Request) string { + if r == nil { + return DefaultLocale + } + if cookie, err := r.Cookie(LocaleCookie); err == nil && Supported(cookie.Value) { + return cookie.Value + } + for _, tag := range parseAcceptLanguage(r.Header.Get("Accept-Language")) { + if Supported(tag) { + return tag + } + // Match "en-GB" against the "en" catalog. + if base := strings.SplitN(tag, "-", 2)[0]; Supported(base) { + return base + } + } + return DefaultLocale +} + +// parseAcceptLanguage returns the header's tags in descending q-value order. +// Malformed entries are skipped rather than failing the request. +func parseAcceptLanguage(header string) []string { + if header == "" { + return nil + } + type weighted struct { + tag string + q float64 + } + var items []weighted + for _, part := range strings.Split(header, ",") { + fields := strings.Split(strings.TrimSpace(part), ";") + tag := strings.ToLower(strings.TrimSpace(fields[0])) + if tag == "" { + continue + } + q := 1.0 + for _, field := range fields[1:] { + field = strings.TrimSpace(field) + if !strings.HasPrefix(field, "q=") { + continue + } + if _, err := fmt.Sscanf(field, "q=%f", &q); err != nil { + q = 1.0 + } + } + items = append(items, weighted{tag: tag, q: q}) + } + // Stable insertion sort: preserves header order among equal q-values. + for i := 1; i < len(items); i++ { + for j := i; j > 0 && items[j].q > items[j-1].q; j-- { + items[j], items[j-1] = items[j-1], items[j] + } + } + tags := make([]string, 0, len(items)) + for _, item := range items { + tags = append(tags, item.tag) + } + return tags +} + +// T returns the message for key in locale, falling back to DefaultLocale and +// finally to the key itself. A missing key therefore degrades to +// visible-but-wrong rather than blank — the same contract as the frontend. +func T(locale, key string) string { + load() + normalized := Normalize(locale) + if messages, ok := catalogs[normalized]; ok { + if message, found := messages[key]; found && message != "" { + return message + } + } + if normalized != DefaultLocale { + if messages, ok := catalogs[DefaultLocale]; ok { + if message, found := messages[key]; found && message != "" { + return message + } + } + } + return key +} + +// Keys lists every key in the source catalog; used by the drift test that keeps +// translations in step with `en`. +func Keys() []string { + load() + messages := catalogs[DefaultLocale] + keys := make([]string, 0, len(messages)) + for key := range messages { + keys = append(keys, key) + } + return keys +} diff --git a/apps/backend/internal/i18n/i18n_test.go b/apps/backend/internal/i18n/i18n_test.go new file mode 100644 index 0000000000..ec9afe49d0 --- /dev/null +++ b/apps/backend/internal/i18n/i18n_test.go @@ -0,0 +1,102 @@ +package i18n + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestNormalize(t *testing.T) { + t.Parallel() + for _, tc := range []struct{ in, want string }{ + {"en", "en"}, + {"pseudo", "pseudo"}, + {"", "en"}, + {"fr", "en"}, + {"EN", "en"}, + } { + if got := Normalize(tc.in); got != tc.want { + t.Fatalf("Normalize(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestTranslatesAndFallsBack(t *testing.T) { + t.Parallel() + en := T("en", "webapp.shellUnavailable") + if en == "webapp.shellUnavailable" || en == "" { + t.Fatalf("en catalog did not resolve the key, got %q", en) + } + // The pseudo catalog must differ from en; that is what makes it usable as the + // externalization oracle. + if pseudo := T("pseudo", "webapp.shellUnavailable"); pseudo == en { + t.Fatalf("pseudo message should differ from en, both %q", en) + } + // An unknown locale falls back to en rather than erroring or blanking. + if got := T("klingon", "webapp.shellUnavailable"); got != en { + t.Fatalf("unknown locale = %q, want en fallback %q", got, en) + } + // A missing key degrades to the key itself, never empty. + if got := T("en", "does.not.exist"); got != "does.not.exist" { + t.Fatalf("missing key = %q, want the key echoed back", got) + } +} + +func TestCatalogsHaveMatchingKeys(t *testing.T) { + t.Parallel() + // Every en key must exist in pseudo, otherwise the QA locale silently shows + // English and stops proving that a string was externalized. + for _, key := range Keys() { + if T("pseudo", key) == key { + t.Fatalf("pseudo catalog is missing key %q", key) + } + } +} + +func TestFromRequest(t *testing.T) { + t.Parallel() + + t.Run("cookie wins", func(t *testing.T) { + t.Parallel() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: LocaleCookie, Value: "pseudo"}) + r.Header.Set("Accept-Language", "en") + if got := FromRequest(r); got != "pseudo" { + t.Fatalf("got %q, want pseudo", got) + } + }) + + t.Run("invalid cookie ignored", func(t *testing.T) { + t.Parallel() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.AddCookie(&http.Cookie{Name: LocaleCookie, Value: "klingon"}) + if got := FromRequest(r); got != DefaultLocale { + t.Fatalf("got %q, want %q", got, DefaultLocale) + } + }) + + t.Run("accept-language honored by q-value", func(t *testing.T) { + t.Parallel() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Accept-Language", "fr;q=0.9, pseudo;q=1.0") + if got := FromRequest(r); got != "pseudo" { + t.Fatalf("got %q, want pseudo", got) + } + }) + + t.Run("region subtag matches base locale", func(t *testing.T) { + t.Parallel() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("Accept-Language", "en-GB") + if got := FromRequest(r); got != "en" { + t.Fatalf("got %q, want en", got) + } + }) + + t.Run("nil request is safe", func(t *testing.T) { + t.Parallel() + if got := FromRequest(nil); got != DefaultLocale { + t.Fatalf("got %q, want %q", got, DefaultLocale) + } + }) +} diff --git a/apps/backend/internal/i18n/locales/en.json b/apps/backend/internal/i18n/locales/en.json new file mode 100644 index 0000000000..4025b32bfa --- /dev/null +++ b/apps/backend/internal/i18n/locales/en.json @@ -0,0 +1,5 @@ +{ + "webapp.assetReadFailed": "Could not read a web asset.", + "webapp.devServerUnavailable": "The Vite dev server is unavailable.", + "webapp.shellUnavailable": "The Kandev web app is unavailable." +} diff --git a/apps/backend/internal/i18n/locales/pseudo.json b/apps/backend/internal/i18n/locales/pseudo.json new file mode 100644 index 0000000000..843935add6 --- /dev/null +++ b/apps/backend/internal/i18n/locales/pseudo.json @@ -0,0 +1,5 @@ +{ + "webapp.assetReadFailed": "Ćōũĺď ńōţ ŕēàď à ŵēƀ àśśēţ.", + "webapp.devServerUnavailable": "Ţĥē Vĩţē ďēv śēŕvēŕ ĩś ũńàvàĩĺàƀĺē.", + "webapp.shellUnavailable": "Ţĥē Ķàńďēv ŵēƀ àƥƥ ĩś ũńàvàĩĺàƀĺē." +} diff --git a/apps/backend/internal/webapp/dev_handler.go b/apps/backend/internal/webapp/dev_handler.go index e44b257569..ec656ed429 100644 --- a/apps/backend/internal/webapp/dev_handler.go +++ b/apps/backend/internal/webapp/dev_handler.go @@ -7,6 +7,8 @@ import ( "net/http/httputil" "net/url" "strings" + + "github.com/kandev/kandev/internal/i18n" ) // DevHandler fronts a Vite dev server with the same Go boot-payload shell used @@ -78,13 +80,13 @@ func (h *DevHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { indexHTML, err := h.fetchViteIndex(r) if err != nil { - http.Error(w, "Vite dev server unavailable", http.StatusBadGateway) + http.Error(w, i18n.T(i18n.FromRequest(r), "webapp.devServerUnavailable"), http.StatusBadGateway) return } payload := h.payloadFor(r, route) html, err := RenderShellHTML(indexHTML, payload) if err != nil { - http.Error(w, "web app shell unavailable", http.StatusServiceUnavailable) + http.Error(w, i18n.T(i18n.FromRequest(r), "webapp.shellUnavailable"), http.StatusServiceUnavailable) return } diff --git a/apps/backend/internal/webapp/handler.go b/apps/backend/internal/webapp/handler.go index 6e151ebc7d..fb4b6ba8ef 100644 --- a/apps/backend/internal/webapp/handler.go +++ b/apps/backend/internal/webapp/handler.go @@ -7,6 +7,8 @@ import ( "net/http" "path" "strings" + + "github.com/kandev/kandev/internal/i18n" ) const IndexHTML = "index.html" @@ -79,9 +81,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } payload := h.payloadFor(r, route) + locale := NormalizeLocale(payload.Runtime.Locale) html, err := RenderShell(h.assets, h.indexPath, payload) if err != nil { - http.Error(w, "web app shell unavailable", http.StatusServiceUnavailable) + http.Error(w, i18n.T(locale, "webapp.shellUnavailable"), http.StatusServiceUnavailable) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") @@ -117,7 +120,7 @@ func (h *Handler) serveAsset(w http.ResponseWriter, r *http.Request) bool { if !ok { data, readErr := io.ReadAll(file) if readErr != nil { - http.Error(w, "read web asset", http.StatusInternalServerError) + http.Error(w, i18n.T(i18n.FromRequest(r), "webapp.assetReadFailed"), http.StatusInternalServerError) return true } _, _ = w.Write(data) diff --git a/apps/backend/internal/webapp/payload.go b/apps/backend/internal/webapp/payload.go index 78afdf4531..d8ad4e3a3d 100644 --- a/apps/backend/internal/webapp/payload.go +++ b/apps/backend/internal/webapp/payload.go @@ -36,6 +36,15 @@ type RuntimeConfig struct { APIPrefix string `json:"apiPrefix"` WebSocketPath string `json:"webSocketPath"` Debug bool `json:"debug,omitempty"` + // NonProduction marks a dev or e2e build. Distinct from Debug (which the SPA + // uses for verbose logging): this gates QA-only UI such as the pseudo-locale + // option, which the e2e harness needs even though it serves a PRODUCTION + // frontend bundle — so `import.meta.env.PROD` cannot answer this question. + NonProduction bool `json:"nonProduction,omitempty"` + // Locale is the active UI locale (BCP-47-ish tag) the SPA should activate + // before first paint. Sourced from the kandev_locale cookie; defaults to + // "en". Also drives the shell's so first paint matches. + Locale string `json:"locale,omitempty"` } // BootError is a serializable non-fatal boot-data error for partial hydration. diff --git a/apps/backend/internal/webapp/shell.go b/apps/backend/internal/webapp/shell.go index c69caba6b8..d20e93c1cc 100644 --- a/apps/backend/internal/webapp/shell.go +++ b/apps/backend/internal/webapp/shell.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "io/fs" + + "github.com/kandev/kandev/internal/i18n" ) const bootPayloadGlobal = "window.__KANDEV_BOOT_PAYLOAD__" @@ -13,6 +15,16 @@ const maxInt = int(^uint(0) >> 1) var headCloseTag = []byte("") +var htmlOpenTag = []byte("` and message lookup can +// never disagree. +func NormalizeLocale(locale string) string { return i18n.Normalize(locale) } + // RenderShell reads indexPath from assets and injects the boot payload before // . The assets FS can be an embedded Vite dist in production or an // in-memory filesystem in tests. @@ -32,7 +44,55 @@ func RenderShellHTML(indexHTML []byte, payload BootPayload) ([]byte, error) { if err != nil { return nil, err } - return injectBeforeHeadClose(indexHTML, script), nil + localized := rewriteHTMLLang(indexHTML, NormalizeLocale(payload.Runtime.Locale)) + return injectBeforeHeadClose(localized, script), nil +} + +// rewriteHTMLLang sets the lang="" attribute on the shell's opening tag +// to locale so first paint matches the SPA's active locale. If the tag has no +// lang attribute, one is inserted. Only the opening tag is touched. +func rewriteHTMLLang(indexHTML []byte, locale string) []byte { + start := bytes.Index(indexHTML, htmlOpenTag) + if start < 0 { + return indexHTML + } + end := bytes.IndexByte(indexHTML[start:], '>') + if end < 0 { + return indexHTML + } + end += start + tag := indexHTML[start : end+1] + langAttr := []byte(` lang="` + locale + `"`) + + out := make([]byte, 0, len(indexHTML)+len(langAttr)) + out = append(out, indexHTML[:start]...) + out = append(out, replaceLangInTag(tag, locale, langAttr)...) + out = append(out, indexHTML[end+1:]...) + return out +} + +func replaceLangInTag(tag []byte, locale string, langAttr []byte) []byte { + idx := bytes.Index(tag, []byte(` lang="`)) + if idx < 0 { + // No lang attribute: insert one right after "Kandev`), + }, + } + payload := NewBootPayload(ClassifyRoute("/"), RuntimeConfig{Locale: tc.locale}, nil) + html, err := RenderShell(assets, "index.html", payload) + if err != nil { + t.Fatalf("RenderShell: %v", err) + } + got := string(html) + if !strings.Contains(got, tc.wantLang) { + t.Fatalf("expected %q in shell, got: %s", tc.wantLang, got) + } + // Exactly one lang attribute on the tag. + if n := strings.Count(got, "lang="); n != 1 { + t.Fatalf("expected one lang attribute, found %d: %s", n, got) + } + }) + } +} + +func TestRenderShellInsertsLangWhenAbsent(t *testing.T) { + t.Parallel() + + assets := fstest.MapFS{ + "index.html": {Data: []byte(``)}, + } + payload := NewBootPayload(ClassifyRoute("/"), RuntimeConfig{Locale: "pseudo"}, nil) + html, err := RenderShell(assets, "index.html", payload) + if err != nil { + t.Fatalf("RenderShell: %v", err) + } + if !strings.Contains(string(html), ``) { + t.Fatalf("expected inserted lang attribute, got: %s", html) + } +} + +func TestNormalizeLocale(t *testing.T) { + t.Parallel() + + for _, tc := range []struct{ in, want string }{ + {"en", "en"}, + {"pseudo", "pseudo"}, + {"", "en"}, + {"fr", "en"}, + } { + if got := NormalizeLocale(tc.in); got != tc.want { + t.Fatalf("NormalizeLocale(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + func TestBytesCapacityReturnsZeroOnOverflow(t *testing.T) { t.Parallel() diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index 835360a5ec..bc7dfbc34c 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -403,7 +403,7 @@ importers: version: 4.25.4(@codemirror/language@6.12.1)(@codemirror/state@6.5.3)(@codemirror/view@6.39.10) '@uiw/react-codemirror': specifier: ^4.25.4 - version: 4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.2)(@codemirror/search@6.6.0)(@codemirror/state@6.5.3)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.10)(codemirror@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 4.25.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.2)(@codemirror/search@6.6.0)(@codemirror/state@6.5.3)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.10)(codemirror@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@xterm/addon-attach': specifier: ^0.12.0 version: 0.12.0 @@ -446,6 +446,9 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) + i18next: + specifier: ^26.3.6 + version: 26.3.6(typescript@5.9.3) immer: specifier: ^10.1.1 version: 10.2.0 @@ -473,6 +476,9 @@ importers: react-dom: specifier: 19.2.3 version: 19.2.3(react@19.2.3) + react-i18next: + specifier: ^17.0.11 + version: 17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3) react-markdown: specifier: ^10.1.0 version: 10.1.0(@types/react@19.2.8)(react@19.2.3) @@ -525,6 +531,9 @@ importers: specifier: ^5.0.8 version: 5.0.10(@types/react@19.2.8)(immer@10.2.0)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) devDependencies: + '@babel/core': + specifier: ^7.29.6 + version: 7.29.7 '@eslint/js': specifier: ^9.39.2 version: 9.39.2 @@ -551,13 +560,16 @@ importers: version: 19.2.3(@types/react@19.2.8) '@vitejs/plugin-react': specifier: ^6.0.2 - version: 6.0.2(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)) + version: 6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)) cross-env: specifier: ^7.0.3 version: 7.0.3 eslint: specifier: ^9 version: 9.39.2(jiti@2.6.1) + eslint-plugin-i18next: + specifier: ^6.1.5 + version: 6.1.5 eslint-plugin-react-hooks: specifier: ^7.0.1 version: 7.1.1(eslint@9.39.2(jiti@2.6.1)) @@ -602,10 +614,6 @@ packages: resolution: {integrity: sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA==} hasBin: true - '@babel/code-frame@7.28.6': - resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -618,10 +626,6 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.6': - resolution: {integrity: sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} engines: {node: '>=6.9.0'} @@ -640,10 +644,6 @@ packages: peerDependencies: '@babel/core': ^7.29.6 - '@babel/helper-globals@7.28.0': - resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} - engines: {node: '>=6.9.0'} - '@babel/helper-globals@7.29.7': resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} engines: {node: '>=6.9.0'} @@ -652,20 +652,10 @@ packages: resolution: {integrity: sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': - resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.29.7': resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.6': - resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.29.6 - '@babel/helper-module-transforms@7.29.7': resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} engines: {node: '>=6.9.0'} @@ -762,18 +752,14 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': - resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} '@babel/template@7.29.7': resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.6': - resolution: {integrity: sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} @@ -2454,6 +2440,23 @@ packages: cpu: [x64] os: [win32] + '@rolldown/plugin-babel@0.2.3': + resolution: {integrity: sha512-+zEk16yGlz1F9STiRr6uG9hmIXb6nprjLczV/htGptYuLoCuxb+itZ03RKCEeOhBpDDd1NU7qF6x1VLMUp62bw==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.6 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -4020,6 +4023,10 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-plugin-i18next@6.1.5: + resolution: {integrity: sha512-xCTfstbK9ZpQ6UFT5S1s6zbZMhKn2o2jRSQYiU2UkV7wt8y1m3WjkUYGkGlipgRdFInYI9+LAqE+JQU/L73HmA==} + engines: {node: '>=18.10.0'} + eslint-plugin-react-hooks@7.1.1: resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} engines: {node: '>=18'} @@ -4389,6 +4396,9 @@ packages: resolution: {integrity: sha512-XcuyW9qE2kJn07PkecMOBd5Vq/hMy7mmGw+idz1yblbg9N17ijJODrvPkn7/dwL3Kulj8LcRJ69DLOWf91dRUg==} engines: {node: '>=16.9.0'} + html-parse-stringify@4.0.1: + resolution: {integrity: sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -4411,6 +4421,14 @@ packages: resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} + i18next@26.3.6: + resolution: {integrity: sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -5293,10 +5311,6 @@ packages: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.4: - resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} - engines: {node: '>=12'} - picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} @@ -5479,6 +5493,22 @@ packages: peerDependencies: react: ^19.2.3 + react-i18next@17.0.11: + resolution: {integrity: sha512-cDtkXgxjuFTWUH6V+aQn1Ve5vDiUztCNPWW5GtSHDccsgRXO1nE6QFWCEmc1KAutrb3OUv87wFShJL5RhUwPXg==} + peerDependencies: + i18next: '>= 26.2.0' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + typescript: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -5616,6 +5646,10 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requireindex@1.1.0: + resolution: {integrity: sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==} + engines: {node: '>=0.10.5'} + reselect@5.1.1: resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} @@ -6348,12 +6382,6 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.0.2 - '@babel/code-frame@7.28.6': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -6382,14 +6410,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.28.6': - dependencies: - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - '@babel/generator@7.29.7': dependencies: '@babel/parser': 7.29.7 @@ -6400,7 +6420,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.6 + '@babel/types': 7.29.7 '@babel/helper-compilation-targets@7.29.7': dependencies: @@ -6418,26 +6438,17 @@ snapshots: '@babel/helper-optimise-call-expression': 7.27.1 '@babel/helper-replace-supers': 7.28.6(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.27.1 - '@babel/traverse': 7.28.6 + '@babel/traverse': 7.29.7 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-globals@7.28.0': {} - '@babel/helper-globals@7.29.7': {} '@babel/helper-member-expression-to-functions@7.28.5': dependencies: - '@babel/traverse': 7.28.6 - '@babel/types': 7.28.6 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.28.6': - dependencies: - '@babel/traverse': 7.28.6 - '@babel/types': 7.28.6 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -6448,15 +6459,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.7)': - dependencies: - '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.28.6 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.6 - transitivePeerDependencies: - - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -6468,7 +6470,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.6 + '@babel/types': 7.29.7 '@babel/helper-plugin-utils@7.28.6': {} @@ -6477,14 +6479,14 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.28.5 '@babel/helper-optimise-call-expression': 7.27.1 - '@babel/traverse': 7.28.6 + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: - '@babel/traverse': 7.28.6 - '@babel/types': 7.28.6 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -6526,7 +6528,7 @@ snapshots: '@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.7) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helper-plugin-utils': 7.28.6 transitivePeerDependencies: - supports-color @@ -6555,11 +6557,7 @@ snapshots: '@babel/runtime@7.28.6': {} - '@babel/template@7.28.6': - dependencies: - '@babel/code-frame': 7.28.6 - '@babel/parser': 7.28.6 - '@babel/types': 7.28.6 + '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': dependencies: @@ -6567,18 +6565,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.28.6': - dependencies: - '@babel/code-frame': 7.28.6 - '@babel/generator': 7.28.6 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.6 - '@babel/template': 7.28.6 - '@babel/types': 7.28.6 - debug: 4.4.3 - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7': dependencies: '@babel/code-frame': 7.29.7 @@ -6617,7 +6603,7 @@ snapshots: '@base-ui/utils@0.2.3(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@floating-ui/utils': 0.2.10 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -6825,7 +6811,7 @@ snapshots: '@commitlint/is-ignored@20.4.1': dependencies: '@commitlint/types': 20.4.0 - semver: 7.7.4 + semver: 7.8.5 '@commitlint/lint@20.4.2': dependencies: @@ -6925,10 +6911,10 @@ snapshots: dotenv: 17.2.3 eciesjs: 0.4.16 execa: 5.1.1 - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.5) ignore: 5.3.2 object-treeify: 1.1.33 - picomatch: 4.0.4 + picomatch: 4.0.5 which: 4.0.0 '@ecies/ciphers@0.2.5(@noble/ciphers@1.3.0)': @@ -8338,6 +8324,16 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true + '@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0))': + dependencies: + '@babel/core': 7.29.7 + picomatch: 4.0.5 + rolldown: 1.0.3 + optionalDependencies: + '@babel/runtime': 7.29.7 + vite: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0) + optional: true + '@rolldown/pluginutils@1.0.1': {} '@sec-ant/readable-stream@0.4.1': {} @@ -8517,8 +8513,8 @@ snapshots: '@testing-library/dom@10.4.1': dependencies: - '@babel/code-frame': 7.28.6 - '@babel/runtime': 7.28.6 + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 '@types/aria-query': 5.0.4 aria-query: 5.3.0 dom-accessibility-api: 0.5.16 @@ -9092,7 +9088,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.53.0 debug: 4.4.3 minimatch: 9.0.9 - semver: 7.7.4 + semver: 7.8.5 tinyglobby: 0.2.17 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -9147,9 +9143,9 @@ snapshots: '@codemirror/state': 6.5.3 '@codemirror/view': 6.39.10 - '@uiw/react-codemirror@4.25.4(@babel/runtime@7.28.6)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.2)(@codemirror/search@6.6.0)(@codemirror/state@6.5.3)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.10)(codemirror@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@uiw/react-codemirror@4.25.4(@babel/runtime@7.29.7)(@codemirror/autocomplete@6.20.0)(@codemirror/language@6.12.1)(@codemirror/lint@6.9.2)(@codemirror/search@6.6.0)(@codemirror/state@6.5.3)(@codemirror/theme-one-dark@6.1.3)(@codemirror/view@6.39.10)(codemirror@6.0.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 '@codemirror/commands': 6.10.1 '@codemirror/state': 6.5.3 '@codemirror/theme-one-dark': 6.1.3 @@ -9171,10 +9167,12 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.2(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)))(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.1 vite: 8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.0.3)(vite@8.0.16(@types/node@20.19.28)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)) '@vitest/expect@4.1.10': dependencies: @@ -9797,7 +9795,7 @@ snapshots: dom-helpers@5.2.1: dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 csstype: 3.2.3 dompurify@3.4.12: @@ -9913,6 +9911,10 @@ snapshots: escape-string-regexp@5.0.0: {} + eslint-plugin-i18next@6.1.5: + dependencies: + requireindex: 1.1.0 + eslint-plugin-react-hooks@7.1.1(eslint@9.39.2(jiti@2.6.1)): dependencies: '@babel/core': 7.29.7 @@ -10124,10 +10126,6 @@ snapshots: dependencies: reusify: 1.1.0 - fdir@6.5.0(picomatch@4.0.4): - optionalDependencies: - picomatch: 4.0.4 - fdir@6.5.0(picomatch@4.0.5): optionalDependencies: picomatch: 4.0.5 @@ -10263,7 +10261,7 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.7.4 + semver: 7.8.5 serialize-error: 7.0.1 global-directory@4.0.1: @@ -10416,6 +10414,8 @@ snapshots: hono@4.12.32: {} + html-parse-stringify@4.0.1: {} + html-url-attributes@3.0.1: {} html-void-elements@3.0.0: {} @@ -10439,6 +10439,10 @@ snapshots: human-signals@8.0.1: {} + i18next@26.3.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -11390,7 +11394,7 @@ snapshots: parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.28.6 + '@babel/code-frame': 7.29.7 error-ex: 1.3.4 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 @@ -11423,8 +11427,6 @@ snapshots: picomatch@2.3.2: {} - picomatch@4.0.4: {} - picomatch@4.0.5: {} pkce-challenge@5.0.1: {} @@ -11709,6 +11711,17 @@ snapshots: react: 19.2.3 scheduler: 0.27.0 + react-i18next@17.0.11(i18next@26.3.6(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + html-parse-stringify: 4.0.1 + i18next: 26.3.6(typescript@5.9.3) + react: 19.2.3 + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + typescript: 5.9.3 + react-is@16.13.1: {} react-is@17.0.2: {} @@ -11775,7 +11788,7 @@ snapshots: react-transition-group@4.4.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: - '@babel/runtime': 7.28.6 + '@babel/runtime': 7.29.7 dom-helpers: 5.2.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -11894,6 +11907,8 @@ snapshots: require-from-string@2.0.2: {} + requireindex@1.1.0: {} + reselect@5.1.1: {} resolve-from@4.0.0: {} diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index cdd29e00aa..a59ff6e45a 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -189,6 +189,60 @@ surface. hover/title/tooltip metadata. Tasks with no repository, or only a non-repo local folder, should not render a repo chip. +## Internationalization (i18n) + +**The migration is in progress, one directory per PR.** The runtime, the gates, +and Settings → General → Appearance are done; most of the app still holds English +literals. New user-facing copy must go through `t()` / `` wherever you +write it, even in a directory that has not been migrated yet. + +User-facing strings are localized with i18next + react-i18next, keyed as +`namespace:key`. Add the English text to `src/locales/en/.json`, then +reference it with `t("settings:deleteExecutor")` (`useTranslation()` in +components, the module-level `t` from `@/lib/i18n` in plain helpers). Use +`` only for copy containing markup — and never a +`t()` call inside its children, which shifts the message's tag indices. + +Never translate user/domain data, code identifiers, `data-testid`, or a literal +that is also compared with `===`, used as a map key, or typed as a string-literal +union. When a prop is both display copy and logic (`label: "Reviewers" | +"Assignees"`), split it into a `kind`/`origin` discriminant plus a translated +label rather than translating in place. + +`lib/i18n/provider.tsx` initializes i18next at module load. Do not remove that +call: react-i18next suspends on an uninitialized instance and there is no +Suspense boundary above the root, so the app renders a blank page with no error +of any kind. Unit tests cannot catch it — `vitest.setup.ts` pre-initializes. + +Never write a plural ending yourself: use `t(key, { count })` with `_one`/`_other` +keys. Passing the morpheme as a value (`{ s: n === 1 ? "" : "s" }`) is +untranslatable — the plural rule ends up at the call site. + +Never assign `t()` to a module-level constant. It resolves at import, before a +locale is active, and never updates on a switch — and the pseudo-locale cannot +see it, because the text _is_ translated, just frozen. Store the key and resolve +at render, or make the value a component. `check-module-scope-t.mjs` enforces it. + +`pnpm lint` fails on hardcoded UI strings (`i18next/no-literal-string` is an +**error**), but **only on the `i18nGuardFiles` allowlist** in +`eslint.i18n.options.mjs` — paths already migrated. That scoping is deliberate: +a repo-wide error breaks every unrelated PR that adds a label, which is what made +the first attempt at this migration unmergeable. **When you migrate a directory, +append it to `i18nGuardFiles` in the same PR** — that is the step that stops it +drifting back. Never delete an entry to make a build pass. Use +`pnpm run lint:i18n ` to preview the guard on a path that is not on the +list yet. + +The rule also **only sees plain literals in JSX** — template literals, +`confirm()` arguments and copy in plain `.ts` helpers are invisible to it, so a +clean lint is not proof a file is done. `pnpm run i18n:check` gates key/catalog +drift, `` tag indices, inline plurals and module-scope `t()`, and the +**pseudo-locale** (Settings → General → Appearance, dev/e2e) is the completeness +check — any plain-English text under it was never externalized. The tooling needs +**Node 24**. Full guide: +[`docs/i18n.md`](../../docs/i18n.md); spec: +[`docs/specs/platform/i18n.md`](../../docs/specs/platform/i18n.md). + ## Markdown safety Any renderer that enables embedded raw HTML must pair `rehype-raw` immediately diff --git a/apps/web/components/app-sidebar/sections/settings/general-group.tsx b/apps/web/components/app-sidebar/sections/settings/general-group.tsx index 469c3b6c86..30c23e0798 100644 --- a/apps/web/components/app-sidebar/sections/settings/general-group.tsx +++ b/apps/web/components/app-sidebar/sections/settings/general-group.tsx @@ -1,7 +1,7 @@ "use client"; - import { IconSettings } from "@tabler/icons-react"; -import { GENERAL_NAV_ITEMS } from "@/components/settings/general-nav"; +import { useTranslation } from "react-i18next"; +import { useGeneralNavItems } from "@/components/settings/general-nav"; import { SettingsGroup, SettingsLeaf } from "./settings-nav-primitives"; const GENERAL_HREF = "/settings/general"; @@ -13,16 +13,18 @@ type GeneralGroupProps = { }; export function GeneralGroup({ pathname, expanded, onToggle }: GeneralGroupProps) { + const navItems = useGeneralNavItems(); + const { t } = useTranslation(); return ( - {GENERAL_NAV_ITEMS.map(({ href, label, icon }) => ( + {navItems.map(({ href, label, icon }) => ( ({ + href: item.href, + icon: item.icon, + label: t(item.labelKey), + description: t(item.descriptionKey), + })); +} diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 348da2c7b3..1dee0a5d6e 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -1,6 +1,6 @@ "use client"; - import { useCallback, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; import Link from "@/components/routing/app-link"; import { useTheme } from "@/components/theme/app-theme"; import { @@ -21,12 +21,13 @@ import { SettingsSection } from "@/components/settings/settings-section"; import { SettingsCard } from "@/components/settings/settings-card"; import { KeyboardShortcutsCard } from "@/components/settings/keyboard-shortcuts-card"; import { SystemMetricsSettingsCard } from "@/components/settings/system-metrics-settings-card"; -import { GENERAL_NAV_ITEMS } from "@/components/settings/general-nav"; +import { useGeneralNavItems } from "@/components/settings/general-nav"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { updateUserSettings } from "@/lib/api"; import type { Theme } from "@/lib/settings/types"; import type { UserSettingsState } from "@/lib/state/slices/settings/types"; import { ArchiveConfirmationSettings } from "@/components/settings/archive-confirmation-settings"; +import { LanguageSettings } from "@/components/settings/language-settings"; import { MCPTaskAgentProfileDefaultSettings } from "@/components/settings/mcp-task-agent-profile-default-settings"; import { UnreadDividerSettings } from "@/components/settings/unread-divider-settings"; import { AnchoredPromptBarSettings } from "@/components/settings/anchored-prompt-bar-settings"; @@ -45,10 +46,11 @@ function ThemeSettingsCard({ isDirty: boolean; onChange: (theme: Theme) => void; }) { + const { t } = useTranslation(); return ( - Color Theme + {t("settings:colorTheme")}
@@ -57,9 +59,9 @@ function ThemeSettingsCard({ - System - Light - Dark + {t("common:system")} + {t("settings:light")} + {t("settings:dark")}
@@ -77,27 +79,28 @@ function ChatSubmitKeyCard({ isDirty: boolean; onChange: (value: "enter" | "cmd_enter") => void; }) { + const { t } = useTranslation(); return ( - Submit Shortcut + {t("settings:submitShortcut")}
- +

{value === "cmd_enter" - ? "Press Cmd/Ctrl+Enter to send messages. Press Enter for newlines." - : "Press Enter to send messages. Press Shift+Enter for newlines."} + ? t("settings:pressCmdCtrlEnterToSend") + : t("settings:pressEnterToSendMessagesPress")}

@@ -114,14 +117,15 @@ function ChangesPanelLayoutCard({ isDirty: boolean; onChange: (value: "flat" | "tree") => void; }) { + const { t } = useTranslation(); return ( - Changes Panel Layout + {t("settings:changesPanelLayout")}
- +

- Display changed files as a flat list with full paths, or as a folder tree. + {t("settings:displayChangedFilesAsAFlat")}

@@ -170,14 +174,15 @@ function StartupPageSettingsSection({ isDirty: boolean; onChange: (startupPage: UserSettingsState["startupPage"]) => void; }) { + const { t } = useTranslation(); return ( <> } - title="Startup Page" - description="Choose what opens when Kandev starts or you visit its bare home page" + title={t("settings:startupPage")} + description={t("settings:chooseWhatOpensWhenKandevStarts")} > @@ -196,11 +201,12 @@ function AppearanceThemeSection({ isDirty: boolean; onChange: (theme: Theme) => void; }) { + const { t } = useTranslation(); return ( } - title="Appearance" - description="Customize how the application looks" + title={t("settings:appearance")} + description={t("settings:customizeHowTheApplicationLooks")} > @@ -208,10 +214,11 @@ function AppearanceThemeSection({ } export function GeneralSettings() { + const navItems = useGeneralNavItems(); return (
- {GENERAL_NAV_ITEMS.map(({ href, label, description, icon: Icon }) => ( + {navItems.map(({ href, label, description, icon: Icon }) => ( @@ -232,12 +239,13 @@ export function GeneralSettings() { } export function TaskActionsSettings() { + const { t } = useTranslation(); return (
} - title="Task Actions" - description="Configure archive safeguards and defaults for tasks created by agents" + title={t("settings:taskActions")} + description={t("settings:configureArchiveSafeguardsAndDefaultsFor")} >
@@ -250,8 +258,8 @@ export function TaskActionsSettings() { } - title="Transcript Navigation" - description="Choose which transcript navigation controls appear, including the desktop-only sticky prompt bar" + title={t("settings:transcriptNavigation")} + description={t("settings:chooseWhichTranscriptNavigationControls")} > @@ -260,6 +268,7 @@ export function TaskActionsSettings() { } export function AppearanceSettings() { + const { t } = useTranslation(); const userSettings = useAppStore((state) => state.userSettings); const setUserSettings = useAppStore((state) => state.setUserSettings); const storeApi = useAppStoreApi(); @@ -332,10 +341,14 @@ export function AppearanceSettings() { onChange={(startupPage) => updateDraft({ startupPage })} /> + + + + } - title="Changes Panel" - description="Customize how changed files are displayed" + title={t("settings:changesPanel")} + description={t("settings:customizeHowChangedFilesAreDisplayed")} > } - title="Resource Metrics" - description="Configure backend and execution resource sampling" + title={t("settings:resourceMetrics")} + description={t("settings:configureBackendAndExecutionResourceSampling")} > state.userSettings); const setUserSettings = useAppStore((state) => state.setUserSettings); const storeApi = useAppStoreApi(); @@ -400,8 +414,8 @@ export function KeyboardShortcutsSettings() {
} - title="Chat Input" - description="Configure chat input behavior" + title={t("settings:chatInput")} + description={t("settings:configureChatInputBehavior")} > } - title="Keyboard Shortcuts" - description="Customize keyboard shortcuts for the command panel" + title={t("settings:keyboardShortcuts")} + description={t("settings:customizeKeyboardShortcutsForTheCommand")} >
@@ -90,57 +94,104 @@ export function SettingsFloatingSave({ <> {isHostedByConfigChat ? createPortal(saveAction, configChatFloatingActionsHost) : saveAction} - - - - Save changes before leaving? - - This page has unsaved changes. Save them, discard them, or continue editing. - - - - - Continue editing - - - { - event.preventDefault(); - void onSave(); - }} - > - {isSaving ? "Saving..." : "Save and leave"} - - - - + ); } +// LeaveWithUnsavedChangesDialog is the navigation-guard prompt offering save, +// discard, or continue editing when leaving a page with unsaved settings. +function LeaveWithUnsavedChangesDialog({ + open, + isBusy, + isInvalid, + isDiscarding, + isSaving, + onSave, + onDiscardAndLeave, + onContinueEditing, +}: { + open: boolean; + isBusy: boolean; + isInvalid: boolean; + isDiscarding: boolean; + isSaving: boolean; + onSave: () => Promise; + onDiscardAndLeave: () => Promise | void; + onContinueEditing: () => void; +}) { + const { t } = useTranslation(); + return ( + + + + {t("settings:saveChangesBeforeLeaving")} + + {t("settings:thisPageHasUnsavedChangesSave")} + + + + + {t("settings:continueEditing")} + + + { + event.preventDefault(); + void onSave(); + }} + > + {isSaving ? t("settings:saving") : t("settings:saveAndLeave")} + + + + + ); +} + function SaveButtonIcon({ status }: { status: SettingsSaveStatus }) { if (status === "saving") return ; if (status === "saved") return ; return ; } -function getAccessibleLabel(status: SettingsSaveStatus, buttonLabel: string): string { - if (status === "saving") return "Saving changes"; - if (status === "saved") return "Saved"; - return buttonLabel; +/** + * Catalog keys for the save button, by status. + * + * Returns KEYS, not resolved copy: the caller resolves them with `t()` at render + * so a locale switch re-renders. The visible label is deliberately shorter than + * the accessible name while saving ("Saving…" vs "Saving changes"), so the two + * are separate keys rather than one string compared against itself. + */ +function saveButtonKeys(status: SettingsSaveStatus): { label: string; accessible: string } { + if (status === "saving") { + return { label: "settings:saving", accessible: "settings:savingChanges" }; + } + if (status === "saved") return { label: "settings:saved", accessible: "settings:saved" }; + if (status === "error") return { label: "settings:retrySave", accessible: "settings:retrySave" }; + return { label: "settings:saveChanges", accessible: "settings:saveChanges" }; } diff --git a/apps/web/components/settings/settings-layout-client.tsx b/apps/web/components/settings/settings-layout-client.tsx index dc73e79f2d..d18c0c6eeb 100644 --- a/apps/web/components/settings/settings-layout-client.tsx +++ b/apps/web/components/settings/settings-layout-client.tsx @@ -15,6 +15,8 @@ import { IntegrationCopyConfigMenu } from "@/components/integrations/integration import { integrationFromPathname } from "@/components/integrations/integration-copy-config"; import { safeDecodePathSegment } from "@/lib/routing/path"; import { SettingsSaveProvider } from "@/components/settings/settings-save-provider"; +import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; import { connectionIssueDetails } from "@/components/app-status-bar/connection-status-item"; import { linkToTaskOverview } from "@/lib/links"; import { cn } from "@/lib/utils"; @@ -33,7 +35,52 @@ const SEGMENT_LABEL_OVERRIDES: Record = { vscode: "VS Code", }; -function titleCase(segment: string): string { +/** + * Catalog key per settings path segment. The breadcrumb's page title used to be + * title-cased straight off the URL, which no lint rule can catch (there is no + * literal to flag) and no locale can translate — the pseudo-locale QA pass is + * what surfaced it. `SEGMENT_LABEL_OVERRIDES` above stays for brand names, which + * are the same in every language. + */ +const SEGMENT_LABEL_KEYS: Record = { + agent: "settings:agent", + agents: "common:agents", + appearance: "settings:appearance", + automations: "common:automations", + changelog: "common:changelog", + "changes-panel": "settings:changesPanel", + "chat-input": "settings:chatInput", + editors: "settings:editors", + executor: "settings:executor", + executors: "common:executors", + "external-mcp": "common:externalMcp", + general: "settings:general", + integrations: "common:integrations", + "keyboard-shortcuts": "settings:keyboardShortcuts", + layouts: "settings:layouts", + new: "settings:new", + notifications: "settings:notifications", + plugins: "common:plugins", + prompts: "common:prompts", + "resource-metrics": "settings:resourceMetrics", + secrets: "settings:secrets", + shell: "common:shell", + sprites: "settings:sprites", + system: "common:system", + "task-actions": "settings:taskActions", + terminal: "settings:terminal", + "utility-agents": "settings:utilityAgents", + "voice-mode": "settings:voiceMode", + workspace: "common:workspace", + workspaces: "common:workspaces", +}; + +/** + * Display name for a path segment: a translated page name, a brand override, or + * (for an unmapped route) dash-aware title casing, which stays English. + */ +function segmentLabel(segment: string, t: (key: string) => string): string { + if (SEGMENT_LABEL_KEYS[segment]) return t(SEGMENT_LABEL_KEYS[segment]); if (SEGMENT_LABEL_OVERRIDES[segment]) return SEGMENT_LABEL_OVERRIDES[segment]; return segment .split("-") @@ -45,13 +92,13 @@ function titleCase(segment: string): string { // deepest non-id path segment. /settings → null (the topbar still shows // "Settings" as the page itself). UUID-looking segments are skipped so e.g. // /settings/workspace/ resolves to "Workspace" not the raw id. -function deriveCurrentPageLabel(pathname: string): string | null { +function deriveCurrentPageLabel(pathname: string, t: (key: string) => string): string | null { const segments = pathname.split("/").filter(Boolean); if (segments.length <= 1) return null; // just /settings for (let i = segments.length - 1; i >= 1; i--) { const seg = segments[i]; if (/^[0-9a-f-]{8,}$/i.test(seg)) continue; // skip ids - return titleCase(seg); + return segmentLabel(seg, t); } return null; } @@ -65,7 +112,7 @@ function deriveParents(pathname: string): Array<{ label: string; href: string }> if (segments.length <= 1) return []; const parents: Array<{ label: string; href: string }> = [ - { label: "Settings", href: "/settings" }, + { label: t("common:settings"), href: "/settings" }, ]; const automationsMatch = pathname.match( @@ -76,7 +123,7 @@ function deriveParents(pathname: string): Array<{ label: string; href: string }> // edit), not on the listing page itself — the listing page title is // already "Automations". parents.push({ - label: "Automations", + label: t("common:automations"), href: `/settings/workspace/${automationsMatch[1]}/automations`, }); } @@ -85,6 +132,7 @@ function deriveParents(pathname: string): Array<{ label: string; href: string }> } export function SettingsLayoutClient({ children }: { children: React.ReactNode }) { + const { t } = useTranslation(); const pathname = usePathname(); const isAgentDetail = pathname.startsWith("/settings/agents/") && pathname !== "/settings/agents"; const showIntegrationCopyAction = integrationFromPathname(pathname) !== null; @@ -92,9 +140,9 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode } if (isAgentDetail) { return ( @@ -103,8 +151,8 @@ export function SettingsLayoutClient({ children }: { children: React.ReactNode } ); } - const pageLabel = deriveCurrentPageLabel(pathname); - const title = pageLabel ?? "Settings"; + const pageLabel = deriveCurrentPageLabel(pathname, t); + const title = pageLabel ?? t("settings:settings"); const parents = deriveParents(pathname); return ( @@ -144,10 +192,67 @@ function IntegrationCopyConfigAction() { ); } +/** + * Status entry in the mobile settings menu. Extracted from SettingsMobileMenu to + * keep that function under the max-lines-per-function limit. + */ +function SettingsMobileStatusButton({ + issueSeverity, + issueDescription, + dotClass, + onClick, +}: { + issueSeverity: string; + issueDescription: string | null; + dotClass: string | undefined; + onClick: () => void; +}) { + const { t } = useTranslation(); + return ( + + ); +} + function SettingsMobileMenu({ pathname }: { pathname: string }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const { enabled: statusDrawerEnabled, issueSeverity, openStatusDrawer } = useAppStatusDrawer(); const issueDetails = issueSeverity === "none" ? null : connectionIssueDetails(issueSeverity); + // `connectionIssueDetails` lives in a directory that has not been migrated yet + // and returns English, so the severity is mapped to a catalog key here rather + // than interpolating its `description` into an otherwise-translated label. + const issueDescription = + issueSeverity === "none" + ? null + : t( + issueSeverity === "lost" + ? "settings:connectionLostDescription" + : "settings:connectionUnstableDescription", + ); const closeOnLinkClick = (event: MouseEvent) => { if (event.target instanceof Element && event.target.closest("a[href]")) { @@ -172,7 +277,11 @@ function SettingsMobileMenu({ pathname }: { pathname: string }) { issueSeverity === "unstable" && "text-amber-500", )} aria-label={ - issueDetails ? `${issueDetails.description} Open settings menu` : "Open settings menu" + issueDescription + ? t("settings:openSettingsMenuWithConnectionIssue", { + description: issueDescription, + }) + : t("settings:openSettingsMenu") } data-testid="settings-mobile-menu-button" data-connection-severity={issueSeverity === "none" ? undefined : issueSeverity} @@ -195,32 +304,16 @@ function SettingsMobileMenu({ pathname }: { pathname: string }) { data-testid="settings-mobile-menu" > - Settings + {t("common:settings")}