diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6703df2c..ff462519 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,6 +32,11 @@ jobs: - name: Typecheck consoles run: bunx turbo typecheck --filter=app --filter=admin + # Compares key sets and source hashes only — no network, no API key, so + # it runs on forks too. Fix a failure with `bun run i18n:sync`. + - name: Check i18n locale parity + run: bun run i18n:check + - name: Lint run: bun run lint diff --git a/app/i18n.sync.json b/app/i18n.sync.json new file mode 100644 index 00000000..ba9dd67e --- /dev/null +++ b/app/i18n.sync.json @@ -0,0 +1,84 @@ +{ + "version": 1, + "hashes": { + "agent:ru": { + "agent_name": "28145084c725", + "agent_resources": "e89b30aa1dc3", + "agent_status": "920e413c7d41", + "agent_template": "0575f29df888", + "agents": "279b44d2ab4b", + "create_agent": "5a614e73e098" + }, + "bridle:ru": { + "chat.agent": "11b39c93777e", + "chat.empty_hint": "df21fa572470", + "chat.empty_title": "012c15d65531", + "chat.error": "a95d0d36ee79", + "chat.input_hint": "8a8c17c82d18", + "chat.placeholder": "57ef13894e3b", + "chat.send": "f6f4688ff23d", + "chat.sending": "a02f1cea3c1d", + "chat.starter_hint": "76085886cbdf", + "chat.you": "08b041935798" + }, + "common:ru": { + "app_name": "d64517995073", + "auth.sign_in": "bfd402b2f6f3", + "auth.sign_out": "48f0d3d397d4", + "auth.sign_up": "5e2b8e96503d", + "demo.agent_greeting": "4741cc1329af", + "demo.agent_working": "9370717ab72c", + "demo.cpu": "db9a4c7d4c19", + "demo.memory": "c3963aedaac6", + "demo.user_request": "87467b7a12ed", + "features.admin_body": "9e1a6af2c7da", + "features.admin_title": "625b6464d0b0", + "features.chat_body": "7bc43aa29827", + "features.chat_title": "a0de0cf7dde9", + "features.logs_body": "1cadc89e731e", + "features.logs_title": "2763db1bbff3", + "features.restart_body": "d3abe64a1f92", + "features.restart_title": "9195a10357ba", + "features.status_body": "66b3943c194c", + "features.status_title": "4de30f8440dd", + "features.templates_body": "371112efb24e", + "features.templates_title": "96eccfa5c632", + "footer.note": "ab7218029e6a", + "hero.badge": "d1afeb3a5261", + "hero.cta_dashboard": "00519f8c9af0", + "hero.cta_deploy": "e852f76c4d13", + "hero.cta_sign_in": "04cca6b25913", + "hero.lede": "05932b878bcc", + "hero.stat_agents": "279b44d2ab4b", + "hero.stat_running": "f4ccae29e1bb", + "hero.stat_uptime": "d63ab4711473", + "hero.title": "9f28248b81cc", + "hero.title_accent": "a37b050de90d", + "landing.cta_button": "baad3429e418", + "landing.cta_lede": "8ef46042d616", + "landing.cta_title": "90c76b13ac6d", + "landing.how_eyebrow": "9c870aa6e5e9", + "landing.how_title": "c025c7422fa3", + "landing.step": "a0cc2a474806", + "landing.what_eyebrow": "9c8ea72883bb", + "landing.what_lede": "1efa22dfed12", + "landing.what_title": "c852637df453", + "locale.label": "a4fe65264ef7", + "nav.agents": "279b44d2ab4b", + "nav.history": "0e7696009337", + "nav.templates": "56b564b75c7f", + "steps.chat_body": "8f80dfeff166", + "steps.chat_title": "e5e09a778387", + "steps.deploy_body": "e358d64527e3", + "steps.deploy_title": "833c2e33e68c", + "steps.template_body": "ae90b69c7099", + "steps.template_title": "21a2714bf862" + }, + "template:ru": { + "template_description": "526e0087cc3f", + "template_image": "140d5d5895d7", + "template_name": "f95e97c2f767", + "templates": "56b564b75c7f" + } + } +} diff --git a/app/slices/agent/i18n/locales/ru.json b/app/slices/agent/i18n/locales/ru.json new file mode 100644 index 00000000..cd7fd4ac --- /dev/null +++ b/app/slices/agent/i18n/locales/ru.json @@ -0,0 +1,8 @@ +{ + "agents": "Агенты", + "create_agent": "Создать агента", + "agent_name": "Имя агента", + "agent_status": "Статус", + "agent_template": "Шаблон", + "agent_resources": "Ресурсы" +} diff --git a/app/slices/agent/nuxt.config.ts b/app/slices/agent/nuxt.config.ts index f81dffa2..1075d3d4 100644 --- a/app/slices/agent/nuxt.config.ts +++ b/app/slices/agent/nuxt.config.ts @@ -1,6 +1,8 @@ import { fileURLToPath } from 'url'; import { dirname } from 'path'; +import { LOCALES } from '../setup/i18n/locales'; + const currentDir = dirname(fileURLToPath(import.meta.url)); export default defineNuxtConfig({ @@ -13,6 +15,6 @@ export default defineNuxtConfig({ modules: ['@nuxtjs/i18n'], i18n: { langDir: 'locales', - locales: [{ code: 'en', file: 'en.json' }], + locales: LOCALES, }, }); diff --git a/app/slices/bridle/components/bridleChat/Empty.vue b/app/slices/bridle/components/bridleChat/Empty.vue index 69de83b2..591b6041 100644 --- a/app/slices/bridle/components/bridleChat/Empty.vue +++ b/app/slices/bridle/components/bridleChat/Empty.vue @@ -3,11 +3,8 @@ class="flex flex-col items-center justify-center h-full text-center text-muted-foreground px-6" >
{{ t('chat.empty_hint') }}
+{{ $t('chat.empty_hint') }}
- diff --git a/app/slices/bridle/components/bridleChat/Input.vue b/app/slices/bridle/components/bridleChat/Input.vue index 4ece3089..d51768a7 100644 --- a/app/slices/bridle/components/bridleChat/Input.vue +++ b/app/slices/bridle/components/bridleChat/Input.vue @@ -1,7 +1,6 @@ diff --git a/app/slices/setup/i18n/i18n/i18n.config.ts b/app/slices/setup/i18n/i18n/i18n.config.ts index a8e1ec50..0a921a9a 100644 --- a/app/slices/setup/i18n/i18n/i18n.config.ts +++ b/app/slices/setup/i18n/i18n/i18n.config.ts @@ -1,7 +1,13 @@ +import { SOURCE_LOCALE } from '../locales'; + export default defineI18nConfig(() => ({ legacy: false, - locale: 'en', - messages: { - en: {}, - }, + locale: SOURCE_LOCALE, + // A key that hasn't been translated yet renders its English text instead of + // the raw key path — so a slice mid-extraction degrades to English rather + // than showing `chat.send` to the user. + fallbackLocale: SOURCE_LOCALE, + // No `messages` here on purpose: every slice ships its own locale files and + // the module merges them. Listing locales again would mean editing this file + // too whenever LOCALES changes. })); diff --git a/app/slices/setup/i18n/locales.ts b/app/slices/setup/i18n/locales.ts new file mode 100644 index 00000000..eef12a6b --- /dev/null +++ b/app/slices/setup/i18n/locales.ts @@ -0,0 +1,25 @@ +/** + * Single source of truth for the locales the app console ships. + * + * Every slice registers this list (`i18n: { langDir: 'locales', locales: LOCALES }`) + * instead of repeating the entries, so adding a language is a one-line change + * here rather than one edit per slice — and slices can't drift apart. + * + * Adding a locale means every slice that registers this list also needs the + * matching file on disk: a declared locale with no `.json` breaks the
+ * build. `bun run i18n:sync` creates and fills them; `bun run i18n:check`
+ * fails CI when one falls behind `en.json`.
+ *
+ * admin/ is deliberately not localized — see
+ * docs/superpowers/specs/2026-08-19-i18n-strategy-design.md.
+ */
+// `as const` on the codes keeps them literal ('en' | 'ru') instead of widening
+// to string: @nuxtjs/i18n types the locale list by the literal codes it knows,
+// and a plain string[] here fails to typecheck in every slice config.
+export const LOCALES = [
+ { code: 'en' as const, file: 'en.json' },
+ { code: 'ru' as const, file: 'ru.json' },
+];
+
+/** The locale translations are written in; every other locale is derived from it. */
+export const SOURCE_LOCALE = 'en';
diff --git a/app/slices/template/i18n/locales/ru.json b/app/slices/template/i18n/locales/ru.json
new file mode 100644
index 00000000..91206961
--- /dev/null
+++ b/app/slices/template/i18n/locales/ru.json
@@ -0,0 +1,6 @@
+{
+ "templates": "Шаблоны",
+ "template_name": "Название шаблона",
+ "template_image": "Docker-образ",
+ "template_description": "Описание"
+}
diff --git a/app/slices/template/nuxt.config.ts b/app/slices/template/nuxt.config.ts
index 4adc9df3..da81b22e 100644
--- a/app/slices/template/nuxt.config.ts
+++ b/app/slices/template/nuxt.config.ts
@@ -1,6 +1,8 @@
import { fileURLToPath } from 'url';
import { dirname } from 'path';
+import { LOCALES } from '../setup/i18n/locales';
+
const currentDir = dirname(fileURLToPath(import.meta.url));
export default defineNuxtConfig({
@@ -13,6 +15,6 @@ export default defineNuxtConfig({
modules: ['@nuxtjs/i18n'],
i18n: {
langDir: 'locales',
- locales: [{ code: 'en', file: 'en.json' }],
+ locales: LOCALES,
},
});
diff --git a/bun.lock b/bun.lock
index 256a484c..aab06eb9 100644
--- a/bun.lock
+++ b/bun.lock
@@ -8,6 +8,7 @@
"@cleanslice/paddock": "^0.2.3",
},
"devDependencies": {
+ "@anthropic-ai/sdk": "^0.117.1",
"turbo": "^2",
},
},
@@ -158,7 +159,7 @@
"@antfu/ni": ["@antfu/ni@0.21.4", "", { "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nu": "bin/nu.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs" } }, "sha512-O0Uv9LbLDSoEg26fnMDdDRiPwFJnQSoD4WnrflDwKCJm8Cx/0mV4cGxwBLXan5mGIrpK4Dd7vizf4rQm0QCEAA=="],
- "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.79.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-ietmtM6glcnnrWq26H+BZm8J07iay9Cob6hRzDTr/A9QWF1m2T//TQhFO4MTKcZht2/7LS8bG9wUYEhcizKRnA=="],
+ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.117.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-Yn2QlXfyCiKJ5YGCOOay7ZE78ISvII2XY621WMCiflmG8IYgwx59IBwPExxki3Xk9jKUtnD/Sj6UvplWr0rZxg=="],
"@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="],
@@ -1148,6 +1149,8 @@
"@speed-highlight/core": ["@speed-highlight/core@1.2.15", "", {}, "sha512-BMq1K3DsElxDWawkX6eLg9+CKJrTVGCBAWVuHXVUV2u0s2711qiChLSId6ikYPfxhdYocLNt3wWwSvDiTvFabw=="],
+ "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
+
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@swc/cli": ["@swc/cli@0.6.0", "", { "dependencies": { "@swc/counter": "^0.1.3", "@xhmikosr/bin-wrapper": "^13.0.5", "commander": "^8.3.0", "fast-glob": "^3.2.5", "minimatch": "^9.0.3", "piscina": "^4.3.1", "semver": "^7.3.8", "slash": "3.0.0", "source-map": "^0.7.3" }, "peerDependencies": { "@swc/core": "^1.2.66", "chokidar": "^4.0.1" }, "optionalPeers": ["chokidar"], "bin": { "swc": "bin/swc.js", "swcx": "bin/swcx.js", "spack": "bin/spack.js" } }, "sha512-Q5FsI3Cw0fGMXhmsg7c08i4EmXCrcl+WnAxb6LYOLHw4JFFC3yzmx9LaXZ7QMbA+JZXbigU2TirI7RAfO0Qlnw=="],
@@ -2212,6 +2215,8 @@
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
+ "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
+
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
@@ -3304,6 +3309,8 @@
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
+ "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
+
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="],
@@ -3792,6 +3799,8 @@
"@babel/traverse/@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="],
+ "@cleanslice/paddock/@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.79.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-ietmtM6glcnnrWq26H+BZm8J07iay9Cob6hRzDTr/A9QWF1m2T//TQhFO4MTKcZht2/7LS8bG9wUYEhcizKRnA=="],
+
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
"@dxup/nuxt/@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="],
diff --git a/docs/superpowers/specs/2026-08-19-i18n-strategy-design.md b/docs/superpowers/specs/2026-08-19-i18n-strategy-design.md
new file mode 100644
index 00000000..4eb7d443
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-19-i18n-strategy-design.md
@@ -0,0 +1,185 @@
+# i18n strategy — design (ADR)
+
+**Date:** 2026-08-19
+**Ticket:** CLEAN-33
+**Status:** approved (decisions confirmed interactively)
+
+## Problem
+
+`@nuxtjs/i18n` v9 is installed in both consoles and every slice carries an
+`i18n/locales/en.json`, so the setup reads as "translations exist, only the
+locale list is missing". The code says otherwise:
+
+| | admin | app |
+|---|---|---|
+| locale files | 15 | 4 |
+| keys in them | 47 | 23 |
+| `.vue` files | 272 | 35 |
+| `.vue` calling `t()` | 2 | 3 |
+
+Six of the 15 admin locale files are empty (`{}`), `i18n.config.ts` ships
+`messages: { en: {} }`, and no console renders a locale switcher. Every visible
+string is hardcoded English in the templates — a few hundred in `app`, low
+thousands in `admin`.
+
+So this is not "top up the missing translations". It is a from-scratch string
+extraction, and the expensive half is the extraction, not the translating —
+translation of short UI strings is exactly what an LLM does well.
+
+## Decisions
+
+### 1. Only `app/` gets localized. `admin/` stays English-only.
+
+`app/` is the customer-facing console; `admin/` is an internal operator tool
+whose users are the team. Localizing `admin` would mean touching ~270 component
+files to serve people who read English anyway.
+
+This is a decision, not a deferral: new `admin` code keeps writing plain English
+in templates, and no `admin` slice gains a `ru.json`. If that ever changes, the
+tooling below already works — only the slice list widens.
+
+### 2. Locales: `en` (default) + `ru`.
+
+`en` stays `defaultLocale`. `strategy: 'no_prefix'` is kept — `app` is a SPA
+(`ssr: false`), so `/ru/` route prefixes would add routing surface with no SEO
+payoff. `detectBrowserLanguage` is already configured with the `i18n_redirected`
+cookie, so a Russian browser lands on `ru` without user action.
+
+A manual switcher still ships in the app shell
+(`app/slices/common/components/layout/Provider.vue`): browser detection is a
+guess, and a user whose browser is English must be able to choose `ru`.
+
+### 3. The locale list lives in one constant.
+
+```ts
+// app/slices/setup/i18n/locales.ts
+export const LOCALES = [
+ { code: 'en', file: 'en.json' },
+ { code: 'ru', file: 'ru.json' },
+];
+```
+
+Every app slice does `i18n: { langDir: 'locales', locales: LOCALES }`. Adding a
+third language is a one-line edit instead of one edit per slice, and the slices
+cannot drift out of sync with each other.
+
+Consequence: because the list is shared, every slice that registers `LOCALES`
+needs both files on disk — a declared locale with no file breaks the build.
+Slices with nothing extracted yet carry an empty `ru.json` (`{}`); slices with no
+`i18n` block at all (`chat`, `user`) stay out until their extraction turn.
+
+### 4. Translations are generated by a local script, verified in CI.
+
+`bun run i18n:sync` (`scripts/i18n-sync.ts`) walks `app/slices/*/i18n/locales/`,
+diffs `en.json` against `ru.json`, sends **only** the missing or stale keys to
+Claude in one request per slice, and writes the result back in `en.json`'s key
+order so the two files read side by side. It runs under bun rather than node so
+it can import `LOCALES` straight from the slice config — the script and the app
+cannot disagree about which languages exist.
+
+Staleness is tracked in `app/i18n.sync.json`: a hash of the English value at the
+time each key was translated. Without it the script would only ever see *new*
+keys, and an edited English string would silently keep its old Russian text. One
+manifest for the whole console, rather than a bookkeeping file per slice.
+
+A key the manifest has never recorded is **adopted**, not retranslated: if
+someone writes or fixes a translation by hand, sync records its hash and leaves
+the wording alone. Only a *changed* English source (recorded hash ≠ current)
+sends a key back to the model. Without this rule the first sync after any manual
+correction would quietly overwrite it.
+
+`bun run i18n:check` is the same script with `--check`: it compares key sets and
+hashes and exits non-zero listing what drifted. It makes no network calls and
+needs no API key, so it runs as an ordinary CI step and works on forks.
+
+Rejected alternatives:
+
+- **Autotranslate in CI** (a bot commits `ru.json` into the PR) — removes the
+ "I forgot" failure mode, but needs an LLM key in repository secrets and push
+ rights for the Action, and lands translations after code review has already
+ happened. The `--check` step closes the same hole without either cost.
+- **Translating by hand** — defensible at today's volume, but it makes every
+ feature PR carry manual work, and that is what makes translations rot.
+
+The script reads `CLAUDE_API_KEY` from `.env.project` (already present, already
+gitignored) or from the environment, and calls `claude-opus-5` through
+`@anthropic-ai/sdk`. It refuses to run without a key rather than silently
+producing nothing. Re-running with no source changes produces an empty diff.
+
+### 5. Key conventions.
+
+Namespacing stays per-slice, as the existing files already do: keys are grouped
+under a feature object (`chat.empty_title`), `snake_case`, at most two levels
+deep. Interpolation uses named placeholders (`{name}`), never positional ones,
+because a translator — human or model — reorders words.
+
+Templates call the injected `$t` (`globalInjection` is on by default), so
+components do not bind `const { t } = useI18n()`. `useI18n()` stays only where a
+component genuinely needs the locale itself — the switcher needs `locale`,
+`locales` and `setLocale`.
+
+### 6. Copy computed in script is held as a key, not as text.
+
+Validation messages, state-dependent headings, button labels that change with a
+pending flag — script decides *which* string, never *what* it says:
+
+```ts
+// script: pick the key
+const error = computed(() => (invalidEmail.value ? 'auth.email_invalid' : null));
+const heading = computed(() => (isRegister.value ? 'auth.create_title' : 'auth.welcome_title'));
+```
+
+```vue
+
+{{ $t(error) }}
+{{ $t(heading) }}
+```
+
+The alternative — calling `t()` inside the script — drags the composable back
+into every component and, worse, leaves user-visible copy inside branching
+logic, where the extraction sweep does not find it. This rule is what makes
+"all translatable text lives in the locale files" true rather than aspirational.
+
+## Out of scope (deliberately)
+
+- **`admin/`** — see decision 1.
+- **The language the agent answers in.** This is not UI i18n: it is a property of
+ the agent (its system prompt / a setting on the agent), and translating the
+ chat frame does not change what the model writes back. Separate ticket; this
+ ADR only draws the line.
+- **API error text.** `api/` keeps returning English messages and machine-readable
+ codes. Where a message reaches the user, the console maps the code to a
+ localized string through the existing `app/slices/setup/error/data/error.mapper.ts`.
+
+## Rollout
+
+CLEAN-33 delivers the mechanism plus one slice as the worked example:
+
+1. `LOCALES` constant, wired into every app slice; `ru.json` in all of them.
+2. `scripts/i18n-sync.ts` + `i18n:sync` / `i18n:check` scripts.
+3. The `i18n:check` step in `.github/workflows/ci.yaml`.
+4. Locale switcher in the app shell, and `fallbackLocale` so a slice that is
+ mid-extraction degrades to English instead of showing raw key paths.
+5. `bridle` and `common` extracted end to end, plus `ru` for the 23 keys that
+ already existed. `bridle` alone turned out to be two strings — the chat was
+ nearly extracted already — so the pilot took `common` as well: it is the
+ shell the switcher lives in plus the landing page, i.e. the first screen a
+ customer sees, and a half-translated landing reads worse than either
+ extreme.
+
+Remaining slices, one PR each, in the order the user meets them:
+`agent` → `chat` → `user` → `template`. Slices `chat` and `user` have no `i18n`
+block at all yet; they get one when their turn comes.
+
+## Acceptance criteria
+
+- `app` renders Russian for a `ru` browser and English otherwise; the switcher
+ overrides both and survives a reload.
+- `bun run i18n:sync` is idempotent — a second run with no source change writes
+ nothing.
+- `bun run i18n:check` fails when a key is added to `en.json` without syncing,
+ when an existing English value is edited without re-syncing, and when a key
+ removed from `en.json` is left behind in `ru.json`.
+- A translation written or corrected by hand survives the next `i18n:sync`.
+- CI runs `i18n:check` without any new repository secret.
+- No `admin` slice gains a `ru.json`.
diff --git a/package.json b/package.json
index 3c4f3ecb..b4657458 100644
--- a/package.json
+++ b/package.json
@@ -17,9 +17,12 @@
"dev:api": "turbo dev --filter=api",
"dev:app": "turbo dev --filter=app",
"dev:admin": "turbo dev --filter=admin",
+ "i18n:sync": "bun scripts/i18n-sync.ts",
+ "i18n:check": "bun scripts/i18n-sync.ts --check",
"release": "npm version --message 'chore(release): v%s'"
},
"devDependencies": {
+ "@anthropic-ai/sdk": "^0.117.1",
"turbo": "^2"
},
"dependencies": {
diff --git a/scripts/i18n-sync.ts b/scripts/i18n-sync.ts
new file mode 100644
index 00000000..5d325028
--- /dev/null
+++ b/scripts/i18n-sync.ts
@@ -0,0 +1,325 @@
+/**
+ * Keeps the app console's non-English locale files in step with `en.json`.
+ *
+ * bun run i18n:sync translate whatever is missing or stale, write it back
+ * bun run i18n:check verify only — no network, no API key, CI-safe
+ *
+ * `en.json` is written by hand and is the source of truth. Every other locale
+ * is generated from it, one Claude request per slice per locale, and only for
+ * the keys that actually need work.
+ *
+ * Staleness lives in app/i18n.sync.json: the hash of the English value at the
+ * time a key was translated. Without it we'd only ever notice *new* keys, and
+ * an edited English string would silently keep its outdated translation.
+ *
+ * Run with bun (not node) — it imports the LOCALES constant straight from the
+ * TypeScript slice config, so the script and the app can't disagree about which
+ * languages exist.
+ */
+import { createHash } from 'node:crypto';
+import {
+ existsSync,
+ readdirSync,
+ readFileSync,
+ statSync,
+ writeFileSync,
+} from 'node:fs';
+import { dirname, join, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import { LOCALES, SOURCE_LOCALE } from '../app/slices/setup/i18n/locales';
+
+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
+const SLICES_DIR = join(ROOT, 'app', 'slices');
+const MANIFEST_PATH = join(ROOT, 'app', 'i18n.sync.json');
+const ENV_FILE = join(ROOT, '.env.project');
+
+const MODEL = 'claude-opus-5';
+
+type FlatMessages = Record;
+type Manifest = { version: number; hashes: Record };
+
+/** Slice with a locale directory: `app/slices/bridle` → `.../i18n/locales`. */
+type Slice = { name: string; localesDir: string };
+
+// ---------------------------------------------------------------- json utils
+
+function readJson(path: string, fallback: T): T {
+ if (!existsSync(path)) return fallback;
+ return JSON.parse(readFileSync(path, 'utf8')) as T;
+}
+
+/** 2-space + trailing newline, matching the hand-written locale files. */
+function writeJson(path: string, value: unknown): void {
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
+}
+
+/** `{ chat: { send: 'Send' } }` → `{ 'chat.send': 'Send' }`. */
+function flatten(value: unknown, prefix = ''): FlatMessages {
+ const out: FlatMessages = {};
+ if (value === null || typeof value !== 'object') return out;
+ for (const [key, child] of Object.entries(value as Record)) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (child !== null && typeof child === 'object') Object.assign(out, flatten(child, path));
+ else if (typeof child === 'string') out[path] = child;
+ }
+ return out;
+}
+
+/**
+ * Rebuilds nested JSON, walking `shape` (the English file) so the translated
+ * file keeps the same key order — the two read side by side in a diff.
+ */
+function nestLike(shape: unknown, flat: FlatMessages, prefix = ''): unknown {
+ const out: Record = {};
+ for (const [key, child] of Object.entries(shape as Record)) {
+ const path = prefix ? `${prefix}.${key}` : key;
+ if (child !== null && typeof child === 'object') out[key] = nestLike(child, flat, path);
+ else if (path in flat) out[key] = flat[path];
+ }
+ return out;
+}
+
+function hash(value: string): string {
+ return createHash('sha256').update(value).digest('hex').slice(0, 12);
+}
+
+// ------------------------------------------------------------------ discovery
+
+/** Every `/i18n/locales` directory under app/slices, at any nesting. */
+function findSlices(dir: string, out: Slice[] = []): Slice[] {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (!entry.isDirectory() || entry.name === 'node_modules') continue;
+ const path = join(dir, entry.name);
+ const localesDir = join(path, 'i18n', 'locales');
+ if (existsSync(localesDir) && statSync(localesDir).isDirectory()) {
+ out.push({ name: relative(SLICES_DIR, path).replace(/\\/g, '/'), localesDir });
+ continue;
+ }
+ findSlices(path, out);
+ }
+ return out;
+}
+
+// ------------------------------------------------------------------- planning
+
+type Work = {
+ slice: Slice;
+ locale: string;
+ source: FlatMessages;
+ target: FlatMessages;
+ /** Keys the target is missing entirely. */
+ missing: string[];
+ /** Keys whose English text changed since they were translated. */
+ stale: string[];
+ /** Translated by hand, never recorded — adopted as-is, not re-translated. */
+ unrecorded: string[];
+ /** Keys the target has but English no longer does. */
+ orphaned: string[];
+};
+
+function plan(manifest: Manifest): Work[] {
+ const targets = LOCALES.filter((l) => l.code !== SOURCE_LOCALE);
+ const work: Work[] = [];
+
+ for (const slice of findSlices(SLICES_DIR)) {
+ const sourceRaw = readJson>(
+ join(slice.localesDir, `${SOURCE_LOCALE}.json`),
+ {},
+ );
+ const source = flatten(sourceRaw);
+
+ for (const { code, file } of targets) {
+ const target = flatten(readJson>(join(slice.localesDir, file), {}));
+ const known = manifest.hashes[`${slice.name}:${code}`] ?? {};
+
+ const present = Object.keys(source).filter((k) => k in target);
+
+ work.push({
+ slice,
+ locale: code,
+ source,
+ target,
+ missing: Object.keys(source).filter((k) => !(k in target)),
+ // A recorded hash that no longer matches means the English text was
+ // edited — retranslate. No recorded hash at all means somebody wrote
+ // this translation by hand, so adopt it instead of overwriting their
+ // wording with a fresh machine translation.
+ stale: present.filter((k) => known[k] !== undefined && known[k] !== hash(source[k]!)),
+ unrecorded: present.filter((k) => known[k] === undefined),
+ orphaned: Object.keys(target).filter((k) => !(k in source)),
+ });
+ }
+ }
+ return work;
+}
+
+// ---------------------------------------------------------------- translation
+
+function readApiKey(): string {
+ const fromEnv = process.env.CLAUDE_API_KEY ?? process.env.ANTHROPIC_API_KEY;
+ if (fromEnv) return fromEnv;
+
+ if (existsSync(ENV_FILE)) {
+ // .env.project is CRLF on Windows checkouts — trim or the key carries a \r
+ // and every request comes back 401.
+ for (const line of readFileSync(ENV_FILE, 'utf8').split(/\r?\n/)) {
+ const match = /^CLAUDE_API_KEY=(.*)$/.exec(line.trim());
+ if (match?.[1]) return match[1].trim();
+ }
+ }
+
+ throw new Error(
+ 'No CLAUDE_API_KEY. Add it to .env.project (gitignored) or export it, then re-run.',
+ );
+}
+
+const SYSTEM_PROMPT = [
+ 'You translate UI strings for Ranch, a platform for deploying AI agents on Kubernetes.',
+ '',
+ 'Rules:',
+ '- Return ONLY a JSON object mapping each key you were given to its translation.',
+ '- Translate the value, never the key.',
+ '- Keep placeholders exactly as they appear: {name}, {count}, @:some.key, and any HTML.',
+ '- Do not translate product or component names: Ranch, Bridle, Paddock, Kubernetes, Argo Workflows, Docker.',
+ '- Match the register of a modern product UI: short, direct, no marketing filler.',
+ '- Keep the ending punctuation and letter case style of the source string.',
+ '- Button and menu labels stay short enough to fit the same control.',
+].join('\n');
+
+async function translate(
+ locale: string,
+ slice: string,
+ entries: FlatMessages,
+): Promise {
+ const { default: Anthropic } = await import('@anthropic-ai/sdk');
+ const client = new Anthropic({ apiKey: readApiKey() });
+
+ const request = [
+ `Target language: ${locale}`,
+ `Context: these strings belong to the "${slice}" area of the app.`,
+ '',
+ JSON.stringify(entries, null, 2),
+ ].join('\n');
+
+ for (let attempt = 1; attempt <= 2; attempt++) {
+ const response = await client.messages.create({
+ model: MODEL,
+ max_tokens: 16000,
+ output_config: { effort: 'low' },
+ system: SYSTEM_PROMPT,
+ messages: [{ role: 'user', content: request }],
+ });
+
+ const text = response.content
+ .filter((block): block is { type: 'text'; text: string } => block.type === 'text')
+ .map((block) => block.text)
+ .join('')
+ .trim()
+ .replace(/^```(?:json)?\s*|\s*```$/g, '');
+
+ try {
+ const parsed = JSON.parse(text) as Record;
+ const missing = Object.keys(entries).filter((k) => typeof parsed[k] !== 'string');
+ if (missing.length) throw new Error(`missing keys in reply: ${missing.join(', ')}`);
+ return parsed as FlatMessages;
+ } catch (error) {
+ if (attempt === 2) throw new Error(`${slice} → ${locale}: ${(error as Error).message}`);
+ }
+ }
+
+ throw new Error('unreachable');
+}
+
+// ---------------------------------------------------------------------- modes
+
+function report(work: Work[]): number {
+ let problems = 0;
+
+ for (const item of work) {
+ const lines: string[] = [];
+ if (item.missing.length) lines.push(` missing (${item.missing.length}): ${item.missing.join(', ')}`);
+ if (item.stale.length) lines.push(` stale (${item.stale.length}): ${item.stale.join(', ')}`);
+ if (item.unrecorded.length)
+ lines.push(` unrecorded (${item.unrecorded.length}): ${item.unrecorded.join(', ')}`);
+ if (item.orphaned.length) lines.push(` orphaned (${item.orphaned.length}): ${item.orphaned.join(', ')}`);
+ if (!lines.length) continue;
+
+ problems +=
+ item.missing.length + item.stale.length + item.unrecorded.length + item.orphaned.length;
+ console.error(`${item.slice.name} → ${item.locale}`);
+ for (const line of lines) console.error(line);
+ }
+
+ if (problems) {
+ console.error(`\n${problems} problem(s). Run: bun run i18n:sync`);
+ return 1;
+ }
+ console.log(`i18n: ${work.length} slice/locale pair(s) in sync.`);
+ return 0;
+}
+
+async function sync(work: Work[], manifest: Manifest): Promise {
+ let translated = 0;
+ let adopted = 0;
+
+ for (const item of work) {
+ const todo = [...item.missing, ...item.stale];
+ const key = `${item.slice.name}:${item.locale}`;
+ const merged: FlatMessages = { ...item.target };
+ for (const orphan of item.orphaned) delete merged[orphan];
+
+ if (todo.length) {
+ const payload = Object.fromEntries(todo.map((k) => [k, item.source[k]!]));
+ console.log(`${key}: translating ${todo.length} key(s)…`);
+ Object.assign(merged, await translate(item.locale, item.slice.name, payload));
+ translated += todo.length;
+ }
+ if (item.orphaned.length) console.log(`${key}: dropped ${item.orphaned.length} orphaned key(s)`);
+ if (item.unrecorded.length) {
+ console.log(`${key}: adopting ${item.unrecorded.length} hand-written key(s)`);
+ adopted += item.unrecorded.length;
+ }
+
+ if (todo.length || item.orphaned.length) {
+ const shape = readJson>(
+ join(item.slice.localesDir, `${SOURCE_LOCALE}.json`),
+ {},
+ );
+ const file = LOCALES.find((l) => l.code === item.locale)!.file;
+ writeJson(join(item.slice.localesDir, file), nestLike(shape, merged));
+ }
+
+ // Record only what the target file actually holds, so a key that failed to
+ // translate stays reported as missing instead of being silently blessed.
+ manifest.hashes[key] = Object.fromEntries(
+ Object.keys(item.source)
+ .filter((k) => k in merged)
+ .sort()
+ .map((k) => [k, hash(item.source[k]!)]),
+ );
+ }
+
+ writeJson(MANIFEST_PATH, {
+ version: manifest.version,
+ hashes: Object.fromEntries(Object.entries(manifest.hashes).sort(([a], [b]) => a.localeCompare(b))),
+ });
+
+ const summary = [
+ translated ? `translated ${translated} key(s)` : '',
+ adopted ? `adopted ${adopted} key(s)` : '',
+ ].filter(Boolean);
+ console.log(summary.length ? `i18n: ${summary.join(', ')}.` : 'i18n: nothing to do.');
+}
+
+// ----------------------------------------------------------------------- main
+
+const checkOnly = process.argv.includes('--check');
+const manifest = readJson(MANIFEST_PATH, { version: 1, hashes: {} });
+const work = plan(manifest);
+
+if (checkOnly) {
+ process.exit(report(work));
+} else {
+ await sync(work, manifest);
+}