Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
c3edca1
docs(proposals): propose localization for the desktop app in English,…
AnishSarkar22 Sep 24, 2026
fa55a0e
docs(localization): add spike findings and run the translation check …
AnishSarkar22 Sep 24, 2026
4a1fb7f
Merge remote-tracking branch 'upstream/dev' into feat/translations
AnishSarkar22 Sep 24, 2026
45b18f6
feat(skills): add a translate skill for the desktop app's interface s…
AnishSarkar22 Sep 24, 2026
df39658
build(local-ui): add FormatJS and precompile translation catalogs wit…
AnishSarkar22 Sep 24, 2026
3d4e416
feat(i18n): bundle English, Japanese and German catalogs behind one i…
AnishSarkar22 Sep 24, 2026
6eaf050
feat(local): let main own the interface language and translate the Vi…
AnishSarkar22 Sep 24, 2026
e7220ac
feat(settings-ui): add a Language setting and translate Settings
AnishSarkar22 Sep 24, 2026
c3a2a25
feat(chat-ui): translate chat and show error kinds in the interface l…
AnishSarkar22 Sep 24, 2026
bd48eba
feat(license-ui): translate license settings and rejection reasons
AnishSarkar22 Sep 24, 2026
8652641
feat(studio-ui): translate Studio formats, podcast briefs and viewers
AnishSarkar22 Sep 24, 2026
07bde51
feat(models-ui): translate model downloads, connections and model set…
AnishSarkar22 Sep 24, 2026
4df1ed3
feat(onboarding-ui): translate onboarding and the workspace rail
AnishSarkar22 Sep 24, 2026
c92f035
feat(sources-ui): translate sources, the dashboard and cloud import
AnishSarkar22 Sep 24, 2026
31bd7d5
feat(network-ui): translate network consent and app update settings
AnishSarkar22 Sep 24, 2026
ab91213
feat(local-ui): translate shared components and format dates and numb…
AnishSarkar22 Sep 24, 2026
dbbf657
chore(i18n): check translation catalogs with formatjs verify and proj…
AnishSarkar22 Sep 24, 2026
c03b68a
docs(localization): describe the FormatJS implementation and update t…
AnishSarkar22 Sep 24, 2026
3484d38
refactor(i18n): declare messages inline and generate en.json with for…
AnishSarkar22 Sep 24, 2026
a9bca18
build(i18n): split the translations script into extract and compile s…
AnishSarkar22 Sep 24, 2026
de7e6f0
feat(models-ui): let messages format sizes, download counts and progr…
AnishSarkar22 Sep 24, 2026
54e8a83
feat(studio-ui): format quiz and flashcard counts, scores and file si…
AnishSarkar22 Sep 24, 2026
9fb1f5d
feat(i18n): format dates, counts and disk space in messages and updat…
AnishSarkar22 Sep 24, 2026
862a3bf
docs(localization): move the proposal into an architecture doc and AD…
AnishSarkar22 Sep 24, 2026
e9cb5be
docs(translate): add step lists for adding a language and new UI text
AnishSarkar22 Sep 24, 2026
4cbc6c8
feat(i18n): accept the en-XA pseudo-locale in main while unpackaged
AnishSarkar22 Sep 24, 2026
01c6330
feat(i18n): compile and offer the en-XA pseudo-locale in dev to spot …
AnishSarkar22 Sep 24, 2026
df9d857
docs(i18n): document the pseudo-locale and the steps to add text or a…
AnishSarkar22 Sep 24, 2026
901e4da
chore(vscode): run Biome only where a biome.json exists
AnishSarkar22 Sep 24, 2026
0d52347
refactor(electron): use one role-based menu in dev and packaged build…
AnishSarkar22 Sep 24, 2026
db27e81
fix(electron): set productName so dev shows SurfSense in the app menu
AnishSarkar22 Sep 24, 2026
9a1a507
fix(app): match the startup error screen to the loader and title bar …
AnishSarkar22 Sep 24, 2026
a038e3a
fix(app): title the window SurfSense
AnishSarkar22 Sep 24, 2026
611dbe7
fix(models-ui): name model types by what they generate and drop Studi…
AnishSarkar22 Sep 24, 2026
4d6ad1f
feat(electron): add a DEV app identity for unpackaged runs and move i…
AnishSarkar22 Sep 24, 2026
4b838db
fix(app): open the app when the saved chat model can't be used and sa…
AnishSarkar22 Sep 24, 2026
161a282
feat(electron): update a DEV app icon in development and keep package…
AnishSarkar22 Sep 24, 2026
cd20742
fix(electron): fill the canvas on Windows and Linux icons and give ma…
AnishSarkar22 Sep 24, 2026
c6c41e1
fix(chat): decide model availability in one place and clear the notic…
AnishSarkar22 Sep 24, 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
80 changes: 80 additions & 0 deletions .agents/skills/translate/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
name: translate
description: Adding, changing or translating interface strings in the SurfSense desktop app (surfsense_local). Use when adding a message to the code, moving hard-coded UI text into messages, translating into every language in LOCALES, or adding a language. Owns the key shape, the tone per language and the glossary.
---

# Translate

The desktop app's interface text lives in `surfsense_local/frontend/translations/`, one ICU MessageFormat file per language. English is the source; every other language in `LOCALES` is translated from it. How it works: [`docs/architecture/localization.md`](../../../docs/architecture/localization.md).

| File | Holds |
|---|---|
| `translations/en.json` | generated by `formatjs extract` from every `defaultMessage` in the code; never edited by hand |
| `translations/<code>.json` (`ja`, `de`, …) | `{"key": "ICU string"}`, flat, sorted, two-space indent, written with the steps below |
| [`glossary.md`](glossary.md) | terms that stay English, and the chosen term per language |

Only fixed text in the app's code is translated. Never user content, model output, model names or descriptions, file paths, or logs.

## Adding English

- Key: `<feature>_<surface>_<purpose>`, snake_case. `<feature>` is a folder under `frontend/src/features/`, or `app` for the shell. `<purpose>` is one of `_title`, `_body`, `_label`, `_placeholder`, `_button`, `_tooltip`, `_empty`, `_error`, `_toast`, `_aria`, `_link`, `_status`.
- Name the meaning, not the words: `sources_delete_confirm_title`, not `are_you_sure`.
- A backend error code maps to `<feature>_error_<code>`.
- One key per place. Never reuse another feature's "Cancel"; never add a `common` key.
- A whole sentence per value. No joining keys, no leading or trailing space. A link that is an action becomes its own key and its own element.
- Visible apostrophes are `’`, never `'`: ICU uses `'` as its escape character.
- Pass numbers raw, never preformatted text, and format them in the message with an ICU skeleton: `{size, number, ::unit/gigabyte .#}`, `{percent, number, ::percent}` (a fraction), `{downloads, number, ::compact-short}`. ICU cannot pick a unit, so megabytes and gigabytes are two messages.
- A count or position is `{count, number}`, never a plain `{count}`, which prints raw digits. Ids and HTTP status codes stay plain: they are not amounts.
- A date goes in as a `Date`, formatted in the message: `{date, date, ::yyyyMMMd}`.
- Named placeholders only: `{name}`, `{count}`. Plurals are ICU: `{count, plural, one {# document} other {# documents}}`; FormatJS prints `#` with the language's digit grouping. Never `(s)`.
- Write the English in the call, as FormatJS recommends: `intl.formatMessage({ id: "sources_list_empty", defaultMessage: "No sources yet" })`, or `intl.formatMessage({ id, defaultMessage }, { name })` with values. The id is literal and explicit. Import `intl` from `@/i18n/intl`. Then run `pnpm translations` in `surfsense_local/frontend` (or only `pnpm translations:extract`), which regenerates `en.json`; commit it with the code.
- A styled part of a sentence (a bold name, an accent) is an ICU tag, FormatJS's native rich text: `"Using <b>{name}</b> via {source}"`, rendered with `intl.formatMessage({ id, defaultMessage }, { b: (chunks) => <span …>{chunks}</span> })`. An element can also be a value: `{ time: <RelativeTime … /> }`.
- Outside a message, numbers, dates, relative times, lists and language names go through `intl.formatNumber`, `formatDate`, `formatRelativeTime`, `formatList`, `formatDisplayName`, never a bare `Intl.*`, `toLocale*` or a raw `{count}` in JSX.

New UI text, step by step:

1. Write each piece of fixed text as `intl.formatMessage({ id, defaultMessage })`, following the rules above.
2. Run `pnpm translations` in `surfsense_local/frontend` to regenerate `en.json`.
3. Translate the new keys into every other language in `LOCALES` with the steps below.
4. Run the checks, then commit the code, `en.json` and every translated file together.

## Translating

1. **Find the keys.** A key needs work in a language when it is missing there, or when its English changed since that language was last translated:

```bash
cd surfsense_local/frontend
base=$(git log -1 --format=%H -- translations/ja.json)
git diff "$base" -- translations/en.json
```

Repeat for each language. Every added or changed line in that diff is a key to translate.
2. **Read the context.** The component that calls it (`grep -rn '"<key>"' src`). Translate the meaning in that place, not the English words.
3. **Translate.** Follow the tone below and [`glossary.md`](glossary.md). Keep every placeholder name, tag name and `select` branch exactly. Write the plural categories the language has, not the English ones: for example, Japanese only `other`, German `one` and `other`.
4. **Check.** From `surfsense_local/frontend`: `pnpm exec formatjs verify "translations/*.json" --source-locale en --missing-keys --extra-keys --structural-equality`, then `node scripts/check_translations.mjs` from the repo root. The `formatjs-verify` and `check-translations` pre-commit hooks run the same. Stop and fix on any failure; do not commit around it.

## Adding a language

1. Add the code to `LOCALES` in `frontend/src/i18n/locales.ts`, `electron/src/main/i18n/locales.ts` and `scripts/check_translations.mjs`.
2. Import its catalog into `catalogs` in `frontend/src/i18n/intl.ts` (from `./compiled/`), and add it to the cases in `plural-categories.test.ts`.
3. For a region code such as `pt-BR`, make `resolve-locale.ts` match the full tag; it matches the base language only.
4. Add a column to [`glossary.md`](glossary.md) and a line under **Tone**.
5. Create `translations/<code>.json` by translating every key in `en.json` with the steps above.
6. Run the checks in **Translating** step 4, then `pnpm test`.
7. Open the app in that language and shorten any text that overflows. Never shrink the UI to fit.

To check layout before any translation exists, pick **English (Pseudo-Accents)** (`en-XA`) under Settings › General in `pnpm dev`. It is longer, accented English; text that stays plain is not in a message.

## Tone

- **Japanese.** です/ます for sentences, the default in Microsoft's Japanese style guide. Plain form for short labels and buttons (`保存`, `削除`). 〜してください for instructions. Full-width punctuation (`。`, `、`), no space between Japanese and Latin text unless the product name needs it.
- **German.** *du*, as macOS has used since Sierra. Never *Sie*, never mixed. Capitalize *du* only at the start of a sentence. Buttons are infinitives (`Speichern`, `Löschen`).
- Both: as short as the English allows. A `_button` or `_label` has the room its English has.

## Limits

- Never reword English while translating. A problem with the English is a separate change.
- Never translate a key name, a placeholder name or a tag name.
- Never guess. When a key's meaning is unclear from its component, stop and say which key and why.
- Never send strings to an outside translation service or CLI. Nothing leaves without a decision ([ADR 0017](../../../docs/adr/0017-egress-off-by-default.md)).
- A new term that will recur goes into [`glossary.md`](glossary.md) in the same change.
43 changes: 43 additions & 0 deletions .agents/skills/translate/glossary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Glossary

One term per concept, so the app says the same thing everywhere. Terms marked with † come from the per-language search data in [`03-international.md`](../../../plans/community-local/seo/03-international.md#glossaries), so the app matches the website. Add a row when a term will recur; change a row only with every key that uses it.

## Stays in English

SurfSense, Studio, llama.cpp, GGUF, Hugging Face, API, MCP, and every model and provider name. German also keeps Workspace (`Mein Workspace`, `Workspace-Artefakte`); Japanese writes ワークスペース, since `マイWorkspace` reads as a mistake.

## Per language

| Concept | Japanese | German |
|---|---|---|
| local LLM | ローカルLLM † | lokales LLM † |
| local AI | ローカルAI † | lokale KI † |
| offline | オフライン † | offline † |
| open source | オープンソース † | Open Source † |
| privacy | プライバシー | Datenschutz † |
| source (an added file or folder) | ソース | Quelle |
| document | ドキュメント | Dokument |
| chat | チャット | Chat |
| model | モデル | Modell |
| download | ダウンロード † | herunterladen |
| settings | 設定 | Einstellungen |
| slides | スライド † | Präsentation † |
| summary | 要約 † | Zusammenfassung † |
| mind map | マインドマップ † | Mindmap † |
| flashcards | フラッシュカード | Karteikarten † |
| quiz | クイズ | Quiz † |
| study guide | 学習ガイド | Lernzettel † |
| podcast | ポッドキャスト | Podcast |
| workspace | ワークスペース | Workspace |
| artifact (a Studio output) | 生成物 | Artefakt |
| chunk (a cited passage) | チャンク | Abschnitt |
| citation | 引用 | Quellenangabe |
| Model setup | モデル設定 | Modell-Einrichtung |
| vision (model badge) | 画像認識 | Vision |
| build (a quantized variant) | ビルド | Variante |
| in use | 使用中 | Aktiv |
| server (a model server) | サーバー | Server |
| provider | プロバイダー | Anbieter |
| this computer | このコンピューター | dieser Computer |
| retry | 再試行 | Erneut versuchen |
| View (menu) | 表示 | Darstellung |
30 changes: 30 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,36 @@ repos:
always_run: true
stages: [pre-commit]

# The desktop app's translation catalogs. formatjs-extract regenerates
# en.json from the code, so a stale en.json fails the commit as a modified
# file. formatjs-verify: every key translated, no extra keys, the same
# placeholders and tags. check-translations adds the SurfSense rules.
- repo: local
hooks:
- id: formatjs-extract
name: formatjs-extract
entry: formatjs extract "surfsense_local/frontend/src/**/*.{ts,tsx}" --ignore "**/*.test.*" --format simple --out-file surfsense_local/frontend/translations/en.json
language: node
additional_dependencies: ["@formatjs/cli@6.16.32"]
files: ^surfsense_local/frontend/(src/|translations/en\.json)
pass_filenames: false
stages: [pre-commit]
- id: formatjs-verify
name: formatjs-verify
entry: formatjs verify surfsense_local/frontend/translations/*.json --source-locale en --missing-keys --extra-keys --structural-equality
language: node
additional_dependencies: ["@formatjs/cli@6.16.32"]
files: ^surfsense_local/frontend/translations/
pass_filenames: false
stages: [pre-commit]
- id: check-translations
name: check-translations
entry: node scripts/check_translations.mjs
language: system
files: ^surfsense_local/frontend/(translations/|src/features/)
pass_filenames: false
stages: [pre-commit]

# Commit message linting
- repo: https://github.com/commitizen-tools/commitizen
rev: v4.8.3
Expand Down
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"biome.configurationPath": "./surfsense_web/biome.json",
"biome.requireConfiguration": true,
"deepscan.ignoreConfirmWarning": true,
"python.defaultInterpreterPath": "${workspaceFolder}/surfsense_local/backend/.venv/bin/python",
"basedpyright.analysis.extraPaths": [
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ Canonical dir: `.agents/skills/`. `.claude/skills` is a symlink to it.
| `frontend-workflow` | Frontend work in `surfsense_web` and `surfsense_local/frontend`: React/Next performance, color tokens, UI polish, motion. Bundles `react-performance/`, `color/`, `polish/`, `motion/`; routes out to `shadcn`. |
| `shadcn` | UI components in a tree with `components.json`. Standalone — it inspects the project live and grants its own CLI, which only works as a discovered skill. Load it after `frontend-workflow`, not instead of it. |
| `migrate-radix-to-base` | Radix UI → Base UI migration. Stays top-level; not part of `frontend-workflow`. |
| `translate` | Interface strings in `surfsense_local`: adding a message in code (`en.json` is generated from it), moving hard-coded text into messages, translating Japanese and German. Holds the key shape, tone and glossary. |

Do not install skill catalogs. Do not add `CONTEXT.md` or a second rules tree.

Expand Down
24 changes: 24 additions & 0 deletions docs/adr/0029-icu-translation-catalogs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# ADR 0029: Interface text lives in ICU MessageFormat catalogs, one flat JSON file per language

- **Status:** Accepted
- **Date:** 2026-09-24
- **Source:** [localization proposal L49–116](https://github.com/MODSetter/SurfSense/blob/c03b68aaa4986d3f2cc5ba98dfb192f72c93f884/docs/proposals/localization.md#L49-L116), [FormatJS ICU syntax](https://formatjs.github.io/docs/core-concepts/icu-syntax), commit [c3edca1b9](https://github.com/MODSetter/SurfSense/commit/c3edca1b9d44de178e7d8338d4a0b80144cd6fc7)

## Context

The desktop app's interface was English written into components. Japanese and German were next ([`03-international.md`](../../plans/community-local/seo/03-international.md)), and more languages would follow. The translations had to outlive whichever library renders them, be written by an agent and reviewed by anyone who reads the language, and survive plurals and word order that differ from English. Localization as built is in [localization](../architecture/localization.md).

## Decision

- One file per language in `surfsense_local/frontend/translations/`, a flat `{"id": "ICU string"}`. No nesting, no `common` block. `en.json` is generated from the code ([ADR 0030](0030-formatjs-renders-interface-text.md)); the others are written by translators.
- Messages are ICU MessageFormat 1: named placeholders, `plural` with each language's CLDR categories and `#`, `select`, and tags for styled parts of a sentence.
- Ids are `<feature>_<surface>_<purpose>`: the feature folder, then the component, then what the text does. Each place gets its own id, even when the English repeats.
- A value is a whole sentence. No joining two messages, no pieces around a link.
- Keys sorted with a two-space indent, the layout `formatjs extract` writes, the same order in every language.

## Consequences

- FormatJS, i18next with `i18next-icu`, Lingui and every major translation platform read the files, so the library can change without rewriting a translation.
- Word order stays with the translator: Japanese and German move placeholders and tags where their grammar needs them.
- Repetition in `en.json` is expected, and a feature's ids leave with the feature.
- The files hold no translator notes: the `simple` format has no field for them, and the key and the calling component, where the English is written, are the context.
27 changes: 27 additions & 0 deletions docs/adr/0030-formatjs-renders-interface-text.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# ADR 0030: FormatJS renders interface text from precompiled, bundled catalogs, with no network at build or run time

- **Status:** Accepted
- **Date:** 2026-09-25
- **Source:** [localization proposal L35–47](https://github.com/MODSetter/SurfSense/blob/c03b68aaa4986d3f2cc5ba98dfb192f72c93f884/docs/proposals/localization.md#L35-L47), [inlang: installing plugins](https://inlang.com/docs/install-plugin), [FormatJS performance guide](https://github.com/formatjs/formatjs/blob/main/docs/src/docs/guides/performance.mdx), commit [c03b68aaa](https://github.com/MODSetter/SurfSense/commit/c03b68aaa4986d3f2cc5ba98dfb192f72c93f884)

## Context

The app is air-gapped, so no translation may be fetched at run time, and a release build should come only from the lockfile. The catalogs are ICU ([ADR 0029](0029-icu-translation-catalogs.md)). Paraglide JS was built first: it compiles messages to typed functions, but reads ICU only through a separate inlang plugin, first released in January 2026, that inlang recommends loading from a CDN; loading it from `node_modules` is supported but described as local-only. inlang also removed its lint rules in 2.0, and its native rich text needs inlang's own format. Localization as built is in [localization](../architecture/localization.md).

## Decision

- `react-intl` renders the frontend and `@formatjs/cli` compiles and checks the catalogs. Both come from npm, pinned by the lockfile.
- Electron main translates nothing. Its application menu is Electron roles only, labelled by Electron and the OS; translating the few labels main writes itself mixed two languages in one menu.
- `formatjs compile-folder --ast` precompiles the catalogs before every build, and Vite aliases the ICU parser to its no-parser build.
- All three languages are bundled. One `createIntl` instance serves the page, given to React through `RawIntlProvider`, and a language change reloads the window.
- Messages are declared inline with their usage, as the FormatJS docs recommend: `intl.formatMessage({ id, defaultMessage }, values)`, with an explicit id and no wrapper. `formatjs extract` generates `en.json` from them, and `@formatjs/unplugin` strips `defaultMessage` from the bundle.
- FormatJS's ESLint plugin checks every call: English present (`enforce-default-message`), every placeholder given (`enforce-placeholders`), the id shape (`enforce-id`). Ids are typed through `FormatjsIntl.Message`, keyed on `en.json`.

## Consequences

- The build needs no network, and text is on screen at the first render. Measured on the real catalogs: about 48 KB gzipped for all three languages, about 1 µs per message.
- Rich text, formatting, extraction, `verify` and the lint rules are FormatJS's own, so the project keeps only a plural-category test and a short check for its id prefix and file layout.
- `en.json` cannot hold a message the code does not use, or miss one it does.
- The English sits next to where it is shown, which makes calls longer.
- A language change drops in-memory state such as an open dialog.
- Replacing FormatJS means rewriting every call site; the catalogs stay.
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,5 @@ The Date is the day the decision was first written down. Source links are permal
| 0026 | [Curated models are ordered by their position in the manifest, with no score](0026-curated-order-is-list-position.md) | Accepted, in part revised by the model catalog proposal |
| 0027 | [Egress consent is per host, so model search and downloads share one](0027-egress-consent-per-host.md) | Accepted |
| 0028 | [Model-written code may run on the user's machine without a sandbox, and the agent asks before each shell command](0028-model-written-code-runs-with-approval.md) | Accepted |
| 0029 | [Interface text lives in ICU MessageFormat catalogs, one flat JSON file per language](0029-icu-translation-catalogs.md) | Accepted |
| 0030 | [FormatJS renders interface text from precompiled, bundled catalogs, with no network at build or run time](0030-formatjs-renders-interface-text.md) | Accepted |
Loading
Loading