feat(app): i18n strategy — app-only en+ru with a synced translation pipeline (CLEAN-33) - #30
Merged
Merged
Conversation
This was referenced Aug 19, 2026
…(CLEAN-33) The infra reads as "translations exist, locale list missing", but only 5 of 307 .vue files across both consoles call t() and the locale files hold 70 keys total. The real work is extracting hardcoded strings, not translating them, so the ADR decides scope before tooling. Decisions: localize app/ only (admin stays English by choice, not by deferral); en + ru with no_prefix; one shared LOCALES constant instead of per-slice duplication; translations generated by a local i18n:sync script against claude-opus-5 and enforced by a network-free i18n:check step in CI. Agent answer language and API error text are explicitly out of scope. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Implements the ADR: one LOCALES constant every app slice registers, a sync script that translates only what drifted, and a network-free CI check so translations can't silently fall behind. scripts/i18n-sync.ts diffs each slice's en.json against ru.json and sends only missing or stale keys to claude-opus-5, one request per slice, writing the result back in en.json's key order. Staleness comes from app/i18n.sync.json (hash of the English value when it was translated) — without it an edited English string would keep its old translation forever. A key the manifest never recorded is adopted rather than retranslated, so a hand-written or hand-corrected translation is not overwritten on the next run. --check compares key sets and hashes only: no network, no API key, safe on forks. Extraction covers bridle (2 strings left) and common in full — shell, landing hero, demo card and landing page — since common is where the switcher lives and a half-translated first screen reads worse than an English one. The chat input hint keeps its two <kbd> caps as i18n-t slots so the sentence stays one translatable string. fallbackLocale makes any not-yet-extracted key render English instead of a raw path. Russian for this pass was written by hand and adopted by the script: the CLAUDE_API_KEY in .env.project is rejected by the API (401, verified against /v1/messages directly), so the translate path itself has not run yet. Everything else is verified: nuxt typecheck and nuxt build are clean, the ru strings appear in the built client chunks, and i18n:check was exercised against all three drift modes (missing, stale, orphaned). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit shipped Russian written by hand because the API key
in .env.project was rejected. With a working key the translate path has
now actually run: ru.json and the manifest were deleted and rebuilt by
`bun run i18n:sync` — 71 keys, one request per slice.
Only wording moved (bridle, common); agent and template came back
identical to the hand-written pass, and every {year} / {number} /
{enter} / {shiftEnter} placeholder survived. One manual fix on top:
"хабу bridle" → "хабу Bridle", the model lowercased a product name.
Verified after regeneration: a second sync reports "nothing to do"
(idempotent), the hand-corrected string is not overwritten (the adopt
rule works on a real edit, not just in theory), and i18n:check is green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nding (CLEAN-33)
vue-i18n injects $t into every component (globalInjection defaults to
true and nothing disables it here), so `const { t } = useI18n()` was
per-component ceremony for something already available in the template.
Seven components drop the binding; Empty.vue loses its <script setup>
entirely, and the layout keeps useI18n only for locale/locales/setLocale,
which the switcher genuinely needs in script.
Templates are the only place this applies — a string built in script
still needs the composable.
nuxt typecheck and nuxt build clean, ru strings still in the client
chunks, i18n:check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the rule the remaining slices will need: copy a component computes — validation messages, state-dependent headings, pending button labels — lives in script as a key, never as text, and the template renders it with $t. Calling t() in script would drag useI18n back into every component and, worse, leave user-visible copy inside branching logic where an extraction sweep never finds it. Also records that templates use the injected $t and that useI18n stays only where a component needs the locale itself (the switcher). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
maksymhryzodub-prog
force-pushed
the
feat/CLEAN-33-i18n-strategy
branch
from
August 19, 2026 21:07
aa788ec to
27a6083
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements CLEAN-33: https://dreamvention.atlassian.net/browse/CLEAN-33
The ticket read as "the i18n module is wired up, only the locale list is missing". The code said otherwise: across both consoles only 5 of 307
.vuefiles callt(), the locale files hold 70 keys total, six of them are empty{}, and no console renders a switcher. The real work is extracting hardcoded strings, not translating them — so this starts with a decision about scope.Full reasoning:
docs/superpowers/specs/2026-08-19-i18n-strategy-design.md.Decisions
app/gets localized.admin/stays English by choice, not by deferral — it is an internal operator tool, and localizing it would mean touching ~270 files to serve people who read English anyway.en(default) +ru,no_prefixstrategy —appis a SPA, so/ru/routes would add surface with no SEO payoff.detectBrowserLanguagealready handles the first render; the new switcher lets a user override it.LOCALESconstant every slice registers, instead of repeating the locale list per slice.admin/, the language the agent answers in (that's a prompt/agent setting, not UI i18n), and API error text.What's in here
scripts/i18n-sync.ts— diffs each slice'sen.jsonagainstru.jsonand sends only missing or stale keys toclaude-opus-5, one request per slice, writing results back inen.json's key order.app/i18n.sync.json— hash of the English value at translation time. Without it only new keys would ever be noticed and an edited English string would keep its old translation forever.bun run i18n:checkin CI — key sets and hashes only, no network and no API key, so it runs on forks too.fallbackLocale, so a not-yet-extracted key renders English instead of a raw key path.bridle(2 strings were left) andcommonin full — shell, landing hero, demo card, landing page.commoncame along because the switcher lives there and a half-translated first screen reads worse than an English one.Verification
nuxt typecheckandnuxt buildclean; the Russian strings appear in the built client chunks.i18n:checkexercised against all three drift modes — new key, edited English source, key removed fromen.json— each exits non-zero with the offending keys listed.ru.jsonand the manifest were deleted and rebuilt by the script (71 keys). A second run reportsnothing to do, and a hand-correction on top of the output survives the next sync.Follow-up
Remaining slices, one PR each, in the order a user meets them:
agent→chat→user→template.chatanduserhave noi18nblock yet; they get one when their turn comes.🤖 Generated with Claude Code