Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
888cc37
feat(i18n): set up i18next infra and extract core UI + settings strin…
Davidnet Jul 6, 2026
5ef8b70
feat(i18n): extract dashboard & sidebar strings (en/fr)
Davidnet Jul 7, 2026
3fc8a7a
feat(i18n): extract activity & mappings strings (en/fr)
Davidnet Jul 7, 2026
89525a3
feat(i18n): extract onboarding, about & modals strings (en/fr)
Davidnet Jul 7, 2026
c0172bb
feat(i18n): add language selector with persistence (#577)
Davidnet Jul 7, 2026
d1a2172
feat(i18n): translate Electron native menu & tray (#578)
Davidnet Jul 7, 2026
6a6f94e
chore(i18n): add parity check, lint rule & docs (#579)
Davidnet Jul 7, 2026
fd0209e
i18n(fr): rename Playground to "Zone d'essai"
Davidnet Jul 7, 2026
859fd6a
i18n(fr): change Playground badge to "masquage"
Davidnet Jul 7, 2026
7950300
i18n(fr): change sidebar section header to "Configurer"
Davidnet Jul 7, 2026
b897be0
i18n(fr): localize KPI delta-window unit (7d -> 7j)
Davidnet Jul 7, 2026
01e80cc
i18n(fr): improve latency caption phrasing
Davidnet Jul 7, 2026
540be4c
i18n(fr): change donut period label to "Période sélectionnée"
Davidnet Jul 7, 2026
888a0b0
i18n(fr): make PII feminine-consistent on KPI card
Davidnet Jul 7, 2026
9ca9607
i18n(fr): refine dashboard copy and localize number formatting
Davidnet Jul 7, 2026
de3cf6a
i18n(fr): standardize on "renseignements personnels"
Davidnet Jul 7, 2026
4a548f5
i18n(fr): donut center label -> "renseignements détectés"
Davidnet Jul 7, 2026
8a1e680
i18n(fr): consistent donut title, hide overflowing center label
Davidnet Jul 7, 2026
f5ed576
i18n(fr): unify playground badges to renseignements personnels
Davidnet Jul 7, 2026
0cc7ffd
i18n(fr): playground wording + locale-aware confidence
Davidnet Jul 7, 2026
ca4fadb
i18n(fr): clearer Mappings subtitle and column headers
Davidnet Jul 7, 2026
75e1552
i18n(fr): more natural Settings copy
Davidnet Jul 7, 2026
bd3a2d7
i18n(fr): align PII section subtitle with renseignements personnels
Davidnet Jul 7, 2026
69f6351
i18n(fr): lighter cert wording and 'Opérationnel' model status
Davidnet Jul 7, 2026
f3b266f
i18n(fr): use 'certificat racine' and 'Installez' for CA cert UI
Davidnet Jul 7, 2026
46169d7
i18n(fr): more natural certificates-section copy
Davidnet Jul 7, 2026
8ecb0b7
updated admin gate
hanneshapke Jul 15, 2026
4fe082c
Merge branch 'main' into feat/i18n-french
hanneshapke Jul 15, 2026
e9a7c2a
fix(ui): derive AppShell default view instead of setState-in-effect
hanneshapke Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/lint-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,10 @@ jobs:
working-directory: src/frontend
run: npm run type-check

- name: Check i18n translation parity
working-directory: src/frontend
run: npm run i18n:check

actionlint:
runs-on: ubuntu-latest
name: Workflow Lint (actionlint)
Expand Down
113 changes: 113 additions & 0 deletions docs/10-internationalization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Chapter 10: Internationalization (i18n)

The desktop/renderer UI is localized with [react-i18next](https://react.i18next.com/).
English (`en`) is the base language and French (`fr`) is the first translation.
This chapter covers how the translation system is wired and how to add or change
strings.

## Overview

- **Framework:** `i18next` + `react-i18next`, initialized in
`src/frontend/src/i18n/index.ts` and imported once for its side effects from
`src/frontend/index.js`.
- **Detection & persistence:** `i18next-browser-languagedetector` reads the
language from `localStorage` (falling back to `navigator.language`) and caches
the choice back to `localStorage`. Region subtags collapse to the base
language (`fr-FR` → `fr`) via `load: "languageOnly"`.
- **Supported languages:** declared in `SUPPORTED_LANGUAGES` (`["en", "fr"]`).
- **Bundled resources:** all locale JSON is imported statically and initialized
synchronously, so `react: { useSuspense: false }` is safe (the class-based
`ErrorBoundary` renders translations without an async gate).

## Where the strings live

Translations are namespaced JSON under
`src/frontend/src/i18n/locales/<lang>/<namespace>.json`:

| Namespace | Covers |
| ------------ | ------------------------------------------------------------- |
| `common` | Shared UI, the Playground, language labels, error boundary |
| `settings` | Settings view and all its sections |
| `dashboard` | Dashboard view and the sidebar |
| `activity` | Activity (logs) view |
| `mappings` | Mappings view |
| `about` | About view |
| `onboarding` | Welcome / first-run modal |
| `modals` | CA-certificate setup and misclassification-report dialogs |

Every namespace exists in both `en/` and `fr/`. To add a namespace, create the
JSON in both locales and register it in `NAMESPACES` + `resources` in
`src/i18n/index.ts`.

## Using translations in components

Function components use the hook; class components use the HOC (needed for
`ErrorBoundary`):

```tsx
const { t } = useTranslation("dashboard");
return <h1>{t("title")}</h1>;
```

- **Interpolation:** `t("kpi.requestsToday", { value: fmt(n) })` with
`"{{value}} today"`. Pre-format numbers/dates and pass them as plain vars so
formatting is preserved; avoid the reserved `count` var unless you want
pluralization.
- **Pluralization:** use `key_one` / `key_other` (English) with `{{count}}`:
`t("entryCount", { count: total })`. See the plural note below for French.
- **Embedded markup** (links, bold): use `<Trans>` with a `components` map, e.g.
the CA-cert instructions in `modals.json` render `<b>`, `<code>`, `<accent>`,
and `<amber>` tags. Keep the tag pairs balanced in every locale.
- **Cross-namespace keys:** reference another namespace with a prefix, e.g.
`t("common:actions.close")`.

Brand names (`Kiji`, `OpenAI`, …), shell commands, certificate paths, and other
technical literals are intentionally left untranslated.

### Plurals and the French `many` category

English needs only `one` and `other`. French cardinal rules add a `many`
category (triggered by multiples of 1,000,000), and **i18next does not fall back
from `many` to `other`** — a missing `_many` renders the raw key. So every
pluralized French key must provide `_one`, `_many`, and `_other`. The parity
check (below) enforces exactly the categories each locale's CLDR rules require.

## Language selector and the Electron menu

- The in-app selector lives in `src/components/settings/LanguageSection.tsx` and
is shown in Settings for both the web and desktop builds. It calls
`i18n.changeLanguage(...)`; persistence is handled by the detector's
`localStorage` cache.
- The native application and tray menus are built in the Electron **main**
process, which has no access to react-i18next. Their strings live in
`src/electron/menu-i18n.js`. The renderer pushes its resolved language to the
main process over the `set-language` IPC channel (on init and on every
`languageChanged`); the main process persists it and rebuilds the menus, and
seeds the menu language from the persisted config at startup.

## Adding or changing strings

1. Add the key to the **English** namespace JSON (the base/source of truth).
2. Add the same key to the **French** JSON with the translation. French drafts
are machine-translated pending native review — flag anything uncertain.
3. Reference it from the component with `t(...)` (or `<Trans>` for markup).
4. Run the checks:

```bash
cd src/frontend
npm run i18n:check # plural-aware en↔fr parity (hard gate, CI)
npm run lint # includes an advisory no-literal-string warning
npm run type-check
```

### Tooling

- **`scripts/check-i18n-parity.js`** (`npm run i18n:check`) — the hard gate, run
in CI. Per namespace it verifies: base-key parity between locales (plural
suffixes normalized away), plural completeness against each locale's CLDR
categories, matching `{{placeholder}}` sets, and matching/balanced `<Trans>`
tags.
- **`eslint-plugin-i18next`** — `no-literal-string` runs in `jsx-text-only` mode
as a **warning** over `src/**/*.{jsx,tsx}`, surfacing un-extracted visible text
without gating CI (attributes/expressions and technical literals are out of
scope).
16 changes: 16 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,22 @@ Route terminal coding agents through the proxy so PII in prompts, code, and tool

---

### [Chapter 10: Internationalization (i18n)](10-internationalization.md)

Localize the desktop/renderer UI with react-i18next. English is the base
language; French is the first translation.

**Topics:**
- How i18next is wired (namespaces, detection, persistence)
- Using `t()` and `<Trans>`, interpolation, and pluralization
- The French `many` plural category and why it matters
- Language selector and the Electron native-menu sync
- Adding/changing strings and the parity + lint tooling

**Start here if you're:** Adding UI strings, adding a language, or reviewing translations.

---

## Quick Links

### Getting Started
Expand Down
124 changes: 121 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 15 additions & 0 deletions src/frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const tsPlugin = require("@typescript-eslint/eslint-plugin");
const tsParser = require("@typescript-eslint/parser");
const reactPlugin = require("eslint-plugin-react");
const reactHooksPlugin = require("eslint-plugin-react-hooks");
const i18nextPlugin = require("eslint-plugin-i18next");

module.exports = [
// Global ignores (replaces .eslintignore)
Expand Down Expand Up @@ -118,4 +119,18 @@ module.exports = [
"@typescript-eslint/no-require-imports": "off",
},
},

// i18n: flag un-extracted UI copy in React components. Scoped to the
// renderer's JSX/TSX and limited to `jsx-text-only` (visible text between
// tags) so it does not flag brand names, class names, or technical literals
// in attributes/expressions. Advisory (warn) so it guides incremental
// extraction without gating CI — the hard i18n gate is the plural-aware
// parity check (scripts/check-i18n-parity.js, run as `npm run i18n:check`).
{
files: ["src/**/*.{jsx,tsx}"],
plugins: { i18next: i18nextPlugin },
rules: {
"i18next/no-literal-string": ["warn", { mode: "jsx-text-only" }],
},
},
];
1 change: 1 addition & 0 deletions src/frontend/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./src/styles/styles.css";
import "./src/i18n";
import AppShell from "./src/components/AppShell.tsx";
import ErrorBoundary from "./src/components/ErrorBoundary.tsx";
import * as Sentry from "@sentry/electron/renderer";
Expand Down
Loading
Loading