feat(i18n): multilingual UI with react-i18next and a full az translation#312
Merged
Conversation
… switcher
Establish the i18n foundation the rest of the migration builds on:
- Add i18next + react-i18next + i18next-browser-languagedetector (and
i18next-parser as a dev tool).
- src/i18n/index.ts initializes i18next once (imported from main.tsx): language
detected from localStorage (devlane-language) then the browser, fallback en,
flat dotted keys, and <html lang/dir> kept in sync for future RTL locales.
- Locale files under src/i18n/locales/{en,az}/translation.json (en is the source
of truth), an Intl-based src/i18n/format.ts for locale-aware dates/numbers,
an i18next-parser config + `npm run i18n:extract` script, and an i18n README
documenting the key convention and how to add a language.
- The dead Language dropdown in Settings → Preferences now drives
i18n.changeLanguage and lists English + Azerbaijani; the old unused `language`
state is removed.
First migrated strings: the Settings language label + help text. The bulk
string migration follows in subsequent commits.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap user-facing strings in t()/<Trans> across ~30 pages (auth, setup, CreateWorkspace, Profile/Home/Notifications, list pages, detail pages, views, analytics), English text as the default value. Shared words use common.* keys. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap user-facing strings with t()/<Trans> across the remaining large pages and the component tree, English text as the default value: - SettingsPage (all sub-areas) and the work-item pages (IssueDetail/List, Intake, Board) - layout: Sidebar, GlobalCommandPalette, InstanceAdminLayout, all page headers - create/update modals (work item, project, cycle, module, icon, image) - work-item components (comment editor, activity feed, PR sidebar, links, relations, attachments, all layouts) and filter/display panels - settings components (webhooks, tokens, estimates, notifications, nav, modals), integrations, stickies, page-editor slash commands Shared words reuse common.* keys; module-level helpers thread a TFunction where needed. typecheck + lint + build pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Finish the string migration: - all instance-admin pages - remaining headers, page-editor toolbar/typography, export/date-range/create modals, favorites tree, reactions and misc components - reactively translate the enum/label constants at their render sites (module statuses, settings sections, org-size option, saved-view display properties, relation types, state groups) so the switcher flips them too - formatRelativeTime now uses Intl.RelativeTimeFormat keyed to the active language (locale-aware, no per-unit keys) typecheck + lint + build pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ing gate
- Generate the full English catalog with i18next-parser and a complete
Azerbaijani translation (full key parity, placeholders + markup preserved).
- Register template-literal ("dynamic") keys the parser can't see (module
statuses, state groups, priorities, display properties, settings and
instance-admin sections, webhook events, relation types) in
src/i18n/dynamicKeys.ts so both locales stay complete.
- Migrate the last stragglers earlier batches missed (loading fallbacks,
notification sentences, snooze menu, move/select-parent modals, repo-sync
settings, etc.).
- Add the eslint-plugin-i18next no-literal-string gate (jsx-text-only) to catch
new hardcoded strings. Non-Latin glyphs/emoji, brand names and a few verbatim
code/acronym tokens are excluded via config so the gate stays quiet on
intentional literals.
typecheck + lint (with the gate) pass; the Settings switcher flips the whole UI
between English and Azerbaijani and persists across reloads.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Point CreateViewModal at the existing `views.createFailed` key and give the export modal its own `settings.export.modalTitle` so no key is used with two different English defaults. - Align the mid-sentence `common.project` / `common.unknown` fallbacks and the loading/saving ellipsis (`…`) to the canonical catalog values. - Re-run i18next-parser: the extractor now reports no conflicting keys and the en/az catalogs sort identically (2112 keys each, full parity, none empty). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
Author
|
@martian56 this is the rebuild of the i18n work from scratch against the #32 description — thanks for writing that up, it made the scope really clear. What's in here:
It's split into small commits so it's reviewable but lands as one PR like the issue asks. Heads up: CodeRabbit auto-skipped because the diff is over its 150-file limit, so there's no bot review here — the parser + lint gate + typecheck are the automated checks. Anything you'd like changed, or is this good to merge? |
martian56
approved these changes
Jul 13, 2026
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.
Feature summary
The web app can now run in more than one language. Every user-facing string reads through
t(), the Language dropdown in Settings → Preferences finally does something, and Azerbaijani (az) ships as a full translation alongside English (en). Switching languages flips the whole UI and the choice survives a reload.Linked issues / discussion
Closes #32
User-facing behavior
Settings → Account → Preferences → "Language & Time" has a Language dropdown. It used to be a dead control (wrote to a local
useState('en')nothing read). It now callsi18n.changeLanguage(lng); the language detector persists the choice tolocalStorageunderdevlane-language, so a reload keeps it. Pick Azərbaycanca and the entire interface — sidebar, home, work items, modals, menus, empty states, toasts, settings, instance-admin — switches to Azerbaijani. Dates, relative times, and numbers reformat to the active locale viaIntl.<html lang>updates to match (anddir, ready for a future RTL language).What changed
API (
apps/api/)user_timezone) is called out as an optional follow-up.UI (
apps/web/)src/i18n/index.tsinitializes i18next once (react-i18next + language detector,fallbackLng: 'en', detection order['localStorage','navigator']cached tolocalStorage,escapeValue: false). Imported insrc/main.tsxbefore<App />so the language is ready on first paint.src/i18n/locales/{en,az}/translation.json, flat dotted keys grouped by area. 2112 keys each, full parity, none empty.enis the source of truth;azis a complete human-reviewed translation.src/moved ontot()/<Trans>(pages, shared components, modals, menus, empty states, toasts, auth/setup flows, instance-admin). English text is passed as thet()default so the extractor builds theencatalog from the code.t(\module.status.${id}`, …)) the parser can't see statically; they're registered insrc/i18n/dynamicKeys.ts` so both catalogs stay complete.languagestate is gone. Languages come from oneSUPPORTED_LANGUAGESarray, so adding a third is one JSON file + one entry.Intl.*keyed to the active language.i18next-parserconfig +npm run i18n:extract(documented insrc/i18n/README.md), plus theeslint-plugin-i18nextno-literal-stringrule (jsx-text-only) so new hardcoded strings fail lint. It runs in the existingui-cilint step — no new workflow.Database
Why this design
I followed the issue's approach as written: react-i18next on i18next, mirroring how
ThemeContextalready handles a client preference (read localStorage → apply → persist), with thedevlane-languagekey to matchdevlane-theme. Flat dotted keys keep a 2000+ key set greppable and makeen/aztrivial to diff for parity. English defaults live at the call site so the parser stays the single source for theencatalog and translators only touch JSON.A couple of deliberate calls:
eslint-plugin-i18nextor a simple custom rule." I used the plugin injsx-text-onlymode so it flags visible JSX text without fighting every technical string. Intentional literals (decorative glyphs/emoji, brand names, a few verbatim code/acronym tokens likeCSV,.pem) are excluded in the eslint config rather than sprinkled with inline disables, so the config is the one place that documents what's intentionally not translated.Test plan
There's no web test runner in the repo (root
validate= typecheck + lint + prettier + go vet/test), so the automated coverage here is the type checker, the lint gate, and the parser:npm run validategreen (typecheck + lint with the no-literal-string gate + prettier)npm run i18n:extractreports no untracked strings and no conflicting keys;en/azstay at full parity (2112 keys, none empty)localStoragedevlane-language=az,<html lang="az">).Out of scope (follow-ups)
user_timezone-style) so it follows across devices — the issue marks this optional.Rollout notes
None. No env vars, no migrations, no instance settings. Default language stays English via
fallbackLng.AI assistance
Claude Code (Opus 4.8)— and AI-assisted commits include aCo-Authored-By:trailerChecklist
internal/config/config.go(none)--no-verifybypass (hooks ran on every commit)