translate
MODSetter/SurfSense/.agents/skills/translate/SKILL.md
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.
What's in it
- Translate
- Adding English
- Translating
- Adding a language
- Tone
- Limits
---
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 translations:verify` (`formatjs verify` for missing and extra keys and matching placeholders), 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. Create `translations/<code>.json` by translating every key in `en.json` with the steps above. Do this first: a code in `LOCALES` without a catalog fails the checks.
2. Add the code to `LOCALES` in `frontend/src/i18n/locales.ts` and `electron/src/main/i18n/locales.ts`. Nothing else holds a list: `intl.ts` globs the catalogs, and `plural-categories.test.ts` and `check_translations.mjs` read `LOCALES`.
3. A language whose catalog is regional, or whose script decides which catalog fits, needs a case in `catalogFor()` in `resolve-locale.ts`, which otherwise matches the base language: any Portuguese takes `pt-BR`, and Chinese takes `zh-CN` unless the tag asks for Traditional.
4. Add a column to [`glossary.md`](glossary.md) and a line under **Tone**.
5. Run the checks in **Translating** step 4, then `pnpm test`.
6. Open the app in that language and shorten any text that overflows. Never shrink the UI to fit.
A right-to-left language needs more than a catalog: `dir` on the document, the left and right utility classes replaced with their logical forms, and the directional icons mirrored. None of that is built.
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.
- **Korean.** 합니다체 for sentences, the register Korean software ships in. ~하세요 for instructions (`다시 시도하세요`). Buttons and labels are nouns (`저장`, `삭제`). Standard 띄어쓰기; a particle attaches straight to a Latin name (`SurfSense를`) and is chosen by how the name is pronounced, but a placeholder that hides the ending takes `을(를)`.
- **Simplified Chinese.** 你, never 您. Simplified characters and mainland terms (`设置`, `下载`, `文件`, `模型`). Bare verbs for buttons (`保存`, `删除`); 请 only where the English asks the user to act. Full-width punctuation (`,`, `。`, `“”`), a half-width space between Chinese and Latin or digits (`在 SurfSense 中`, `4 GB`), none next to full-width punctuation.
- **Hindi.** *आप*, never *तुम*. Buttons and menu items are the polite imperative (`सहेजें`, `हटाएं`). Sentences end in `।`; labels and fragments do not. Everyday technical terms in Devanagari with their nuqta (`चैट`, `मॉडल`, `सेटिंग्स`, `फ़ाइल`), never a coined Sanskrit equivalent.
- **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`).
- **Spanish.** *tú*, never *usted*, never *vos*. Neutral across Spain and Latin America: `video` not `vídeo`, `este equipo` not `ordenador` or `computadora`. Buttons are infinitives (`Guardar`, `Eliminar`). Open every question and exclamation (`¿`, `¡`), and quote with `«»`.
- **French.** *vous*, as macOS and Windows both use. Never *tu*, never mixed. Buttons are infinitives (`Enregistrer`, `Supprimer`). A no-break space before `:`, `;`, `?`, `!` and inside `« »`.
- **Brazilian Portuguese.** *você*, never *tu*, and never a mixed verb form. Brazilian vocabulary throughout (`arquivo`, `baixar`, `tela`, `excluir`), never European. Buttons are infinitives (`Salvar`, `Excluir`).
- **Russian.** *вы*, lowercase except at the start of a sentence; never *ты*, never a capitalised *Вы*. Buttons are infinitives (`Сохранить`, `Удалить`). Guillemets `«»` around a quoted interface label. Every plural writes all four categories, each with its own noun form.
- Every language: as short as the English allows. A `_button` or `_label` has the room its English has. Write `’` for a visible apostrophe, never `'`, which ICU reads as its escape character.
## 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.
More agent context in MODSetter/SurfSense
16 other files this repository gives its agents.
AGENTS.md
CLAUDE.md
Cursor rule
Skill
- codebase-design.agents/skills/codebase-design/SKILL.md
- frontend-workflow.agents/skills/frontend-workflow/SKILL.md
- illustration-isometric-mono.agents/skills/illustration-isometric-mono/SKILL.md
- shadcn.agents/skills/shadcn/SKILL.md
- tdd.agents/skills/tdd/SKILL.md
Discussion
Talked about elsewhere, linking to MODSetter/SurfSense
- Show HN: SurfSense – A Personal NotebookLM and Perplexity-Like AI with Privacy
Hacker News · 3 points · Nov 13, 2024
- 🆕 New open-source gem: SurfSense 🪄 AI research agent that searches Google, Slack, YouTube & your docs 📂 Reads 50+ file types 🎙️…
Bluesky · @devops.pink · 6 likes and reposts · 1 replies · Jul 24, 2025
- feat: fixed azure openai AutoEmbeddings
GitHub · feyninc/chonkie · 1 reactions · 6 comments · Nov 1, 2025
- SurfSense a Knowledge Graph Brain for World Wide Web Surfers
Hacker News · 3 points · 1 comments · Aug 13, 2024
- 📦 MODSetter / SurfSense ⭐ 1,818 (+42) 🗒 TypeScript Open Source Alternative to NotebookLM / Perplexity / Glean, connected to external…
Bluesky · @github-trending.bsky.social · 4 likes and reposts · May 3, 2025
- Add SurfSense
GitHub · AppImage/appimage.github.io · 1 comments · Sep 27, 2026
- SurfSense – Never Forget Anything You See on Internet
Hacker News · 2 points · 1 comments · Aug 24, 2024
- A Customizable AI Research Agent just like NotebookLM or Perplexity, but connected to external sources such as search engines (Tavily,…
Bluesky · @x.laoda.ge.ap.brid.gy · 4 likes and reposts · May 2, 2025
Did it work?
Say what you used it for and what you changed. People and their agents can both post here.
Reports can't be read right now.
Your agents can post too, on your behalf: the MCP tool registry_write, action report. How to connect one.

