feat(electrobun): i18n system — @formatjs/intl + Svelte 5 runes + 3 locales

- i18n.svelte.ts: store with $state locale + createIntl(), t() function,
  formatDate/Number/RelativeTime, getDir() for RTL, async setLocale()
- i18n.types.ts: TranslationKey union (codegen from en.json)
- locales/en.json: 200+ strings in ICU MessageFormat
- locales/pl.json: full Polish translation
- locales/ar.json: partial Arabic (validates 6-form plural + RTL)
- scripts/i18n-types.ts: codegen script for type-safe keys
- 6 components wired: StatusBar, AgentPane, CommandPalette,
  SettingsDrawer, SplashScreen, ChatInput
- Language selector in AppearanceSettings
- App.svelte: document.dir reactive for RTL
- CONTRIBUTING_I18N.md: guide for adding languages

Note: currently Electrobun-only. Will extract to @agor/i18n shared
package for both Tauri and Electrobun.
This commit is contained in:
Hibryda 2026-03-22 10:28:13 +01:00
parent eee65070a8
commit aae86a4001
16 changed files with 947 additions and 64 deletions

View file

@ -0,0 +1,104 @@
# i18n Contributing Guide
This project uses ICU MessageFormat via `@formatjs/intl` with a custom Svelte 5 store.
## Adding a New Language
1. Create `locales/<tag>.json` (e.g. `locales/de.json`) with all keys from `locales/en.json`.
2. Register the locale in `src/mainview/i18n.svelte.ts`:
- Add an entry to `AVAILABLE_LOCALES` with tag, label, nativeLabel, and dir.
- Add a dynamic import entry to the `loaders` map.
3. The language will automatically appear in Settings > Appearance > Language.
## Key Naming Convention
Use dot-notation: `component.element.state`
```
sidebar.settings -- component.element
agent.status.running -- component.element.state
settings.appearance -- component.element
common.cancel -- shared across components
errors.connectionFailed -- error messages
```
Group prefixes:
- `sidebar.*` -- left sidebar
- `agent.*` -- agent pane
- `terminal.*` -- terminal tabs
- `settings.*` -- settings drawer
- `statusbar.*` -- bottom status bar
- `notifications.*` -- notification drawer
- `files.*` -- file browser
- `search.*` -- search overlay
- `comms.*` -- communications tab
- `tasks.*` -- task board
- `errors.*` -- error messages
- `splash.*` -- splash screen
- `common.*` -- shared labels (cancel, save, delete)
- `project.*` -- project management
- `palette.*` -- command palette
## ICU MessageFormat Syntax
### Plain text
```json
"common.save": "Save"
```
### Plurals (English: one/other)
```json
"search.resultsCount": "{count, plural, one {{count} result} other {{count} results}}"
```
### Plurals (Polish: one/few/many/other)
```json
"search.resultsCount": "{count, plural, one {{count} wynik} few {{count} wyniki} many {{count} wynikow} other {{count} wynikow}}"
```
### Plurals (Arabic: zero/one/two/few/many/other)
```json
"tasks.taskCount": "{count, plural, =0 {no tasks} one {one task} two {two tasks} few {{count} tasks} many {{count} tasks} other {{count} tasks}}"
```
### Interpolation
```json
"errors.unhandled": "Unhandled error: {message}"
```
### Select
```json
"agent.status": "{status, select, running {Running} idle {Idle} other {Unknown}}"
```
## Using Translations in Components
```svelte
<script lang="ts">
import { t } from './i18n.svelte.ts';
</script>
<button>{t('common.save')}</button>
<span>{t('sidebar.notifCount', { count: 5 })}</span>
```
## Testing Translations
1. Switch language in Settings > Appearance > Language.
2. All `t()` calls update reactively -- no page reload needed.
3. Missing keys fall back to the key name itself (e.g. `"agent.status.running"`).
4. Check the browser console for `[i18n]` warnings about missing or malformed messages.
## Regenerating Types
After adding or removing keys in `locales/en.json`:
```bash
bun scripts/i18n-types.ts
```
This regenerates `src/mainview/i18n.types.ts` with the updated `TranslationKey` union type. TypeScript will then flag any `t()` calls using keys that no longer exist.
## RTL Support
Arabic (`ar`) sets `document.dir = 'rtl'` automatically. CSS should use logical properties (`inline-start`/`inline-end`) instead of `left`/`right` where possible.

View file

@ -0,0 +1,75 @@
{
"sidebar.settings": "\u0625\u0639\u062f\u0627\u062f\u0627\u062a",
"sidebar.addGroup": "\u0625\u0636\u0627\u0641\u0629 \u0645\u062c\u0645\u0648\u0639\u0629",
"sidebar.addProject": "\u0625\u0636\u0627\u0641\u0629 \u0645\u0634\u0631\u0648\u0639",
"sidebar.groupName": "\u0627\u0633\u0645 \u0627\u0644\u0645\u062c\u0645\u0648\u0639\u0629",
"sidebar.notifications": "\u0627\u0644\u0625\u0634\u0639\u0627\u0631\u0627\u062a",
"sidebar.notifCount": "{count, plural, =0 {\u0627\u0644\u0625\u0634\u0639\u0627\u0631\u0627\u062a} one {\u0625\u0634\u0639\u0627\u0631 \u0648\u0627\u062d\u062f} two {\u0625\u0634\u0639\u0627\u0631\u0627\u0646} few {{count} \u0625\u0634\u0639\u0627\u0631\u0627\u062a} many {{count} \u0625\u0634\u0639\u0627\u0631\u064b\u0627} other {{count} \u0625\u0634\u0639\u0627\u0631}}",
"sidebar.close": "\u0625\u063a\u0644\u0627\u0642 \u0627\u0644\u0646\u0627\u0641\u0630\u0629",
"sidebar.maximize": "\u062a\u0643\u0628\u064a\u0631 \u0627\u0644\u0646\u0627\u0641\u0630\u0629",
"sidebar.minimize": "\u062a\u0635\u063a\u064a\u0631 \u0627\u0644\u0646\u0627\u0641\u0630\u0629",
"agent.prompt.placeholder": "\u0627\u0637\u0631\u062d \u0633\u0624\u0627\u0644\u0627\u064b \u0623\u0648 \u0635\u0641 \u0645\u0647\u0645\u0629...",
"agent.prompt.send": "\u0625\u0631\u0633\u0627\u0644",
"agent.prompt.stop": "\u0625\u064a\u0642\u0627\u0641 \u0627\u0644\u0648\u0643\u064a\u0644",
"agent.status.running": "\u064a\u0639\u0645\u0644",
"agent.status.idle": "\u062e\u0627\u0645\u0644",
"agent.status.done": "\u0645\u0643\u062a\u0645\u0644",
"agent.status.error": "\u062e\u0637\u0623",
"agent.status.stalled": "\u0645\u062a\u0648\u0642\u0641",
"agent.status.thinking": "\u064a\u0641\u0643\u0631",
"agent.tokens": "{count} \u0631\u0645\u0632",
"settings.title": "\u0627\u0644\u0625\u0639\u062f\u0627\u062f\u0627\u062a",
"settings.close": "\u0625\u063a\u0644\u0627\u0642 \u0627\u0644\u0625\u0639\u062f\u0627\u062f\u0627\u062a",
"settings.appearance": "\u0627\u0644\u0645\u0638\u0647\u0631",
"settings.agents": "\u0627\u0644\u0648\u0643\u0644\u0627\u0621",
"settings.security": "\u0627\u0644\u0623\u0645\u0627\u0646",
"settings.projects": "\u0627\u0644\u0645\u0634\u0627\u0631\u064a\u0639",
"settings.orchestration": "\u0627\u0644\u062a\u0646\u0633\u064a\u0642",
"settings.machines": "\u0627\u0644\u0623\u062c\u0647\u0632\u0629",
"settings.keyboard": "\u0644\u0648\u062d\u0629 \u0627\u0644\u0645\u0641\u0627\u062a\u064a\u062d",
"settings.advanced": "\u0645\u062a\u0642\u062f\u0645",
"settings.marketplace": "\u0627\u0644\u0633\u0648\u0642",
"settings.diagnostics": "\u0627\u0644\u062a\u0634\u062e\u064a\u0635",
"settings.language": "\u0627\u0644\u0644\u063a\u0629",
"statusbar.running": "\u064a\u0639\u0645\u0644",
"statusbar.idle": "\u062e\u0627\u0645\u0644",
"statusbar.stalled": "\u0645\u062a\u0648\u0642\u0641",
"statusbar.attention": "\u0627\u0646\u062a\u0628\u0627\u0647",
"statusbar.projects": "\u0645\u0634\u0627\u0631\u064a\u0639",
"statusbar.session": "\u062c\u0644\u0633\u0629",
"statusbar.tokens": "\u0631\u0645\u0648\u0632",
"statusbar.cost": "\u0627\u0644\u062a\u0643\u0644\u0641\u0629",
"splash.loading": "\u062c\u0627\u0631\u064d \u0627\u0644\u062a\u062d\u0645\u064a\u0644...",
"common.cancel": "\u0625\u0644\u063a\u0627\u0621",
"common.confirm": "\u062a\u0623\u0643\u064a\u062f",
"common.close": "\u0625\u063a\u0644\u0627\u0642",
"common.save": "\u062d\u0641\u0638",
"common.delete": "\u062d\u0630\u0641",
"common.edit": "\u062a\u0639\u062f\u064a\u0644",
"common.refresh": "\u062a\u062d\u062f\u064a\u062b",
"common.add": "\u0625\u0636\u0627\u0641\u0629",
"common.back": "\u0631\u062c\u0648\u0639",
"common.noItems": "\u0644\u0627 \u062a\u0648\u062c\u062f \u0639\u0646\u0627\u0635\u0631",
"tasks.todo": "\u0644\u0644\u062a\u0646\u0641\u064a\u0630",
"tasks.inProgress": "\u0642\u064a\u062f \u0627\u0644\u062a\u0646\u0641\u064a\u0630",
"tasks.review": "\u0645\u0631\u0627\u062c\u0639\u0629",
"tasks.done": "\u0645\u0643\u062a\u0645\u0644",
"tasks.blocked": "\u0645\u062d\u0638\u0648\u0631",
"tasks.taskCount": "{count, plural, =0 {\u0644\u0627 \u0645\u0647\u0627\u0645} one {\u0645\u0647\u0645\u0629 \u0648\u0627\u062d\u062f\u0629} two {\u0645\u0647\u0645\u062a\u0627\u0646} few {{count} \u0645\u0647\u0627\u0645} many {{count} \u0645\u0647\u0645\u0629} other {{count} \u0645\u0647\u0645\u0629}}",
"errors.connectionFailed": "\u0641\u0634\u0644 \u0627\u0644\u0627\u062a\u0635\u0627\u0644",
"errors.sessionExpired": "\u0627\u0646\u062a\u0647\u062a \u0627\u0644\u062c\u0644\u0633\u0629",
"errors.fileNotFound": "\u0627\u0644\u0645\u0644\u0641 \u063a\u064a\u0631 \u0645\u0648\u062c\u0648\u062f",
"errors.generic": "\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627",
"palette.placeholder": "\u0627\u0643\u062a\u0628 \u0623\u0645\u0631\u064b\u0627...",
"palette.newTerminal": "\u0639\u0644\u0627\u0645\u0629 \u062a\u0628\u0648\u064a\u0628 \u0637\u0631\u0641\u064a\u0629 \u062c\u062f\u064a\u062f\u0629",
"palette.openSettings": "\u0641\u062a\u062d \u0627\u0644\u0625\u0639\u062f\u0627\u062f\u0627\u062a",
"palette.searchMessages": "\u0628\u062d\u062b \u0641\u064a \u0627\u0644\u0631\u0633\u0627\u0626\u0644"
}

View file

@ -0,0 +1,150 @@
{
"sidebar.settings": "Settings",
"sidebar.addGroup": "Add group",
"sidebar.addProject": "Add project",
"sidebar.groupName": "Group name",
"sidebar.notifications": "Notifications",
"sidebar.notifCount": "{count, plural, =0 {Notifications} one {{count} notification} other {{count} notifications}}",
"sidebar.close": "Close window",
"sidebar.maximize": "Maximize window",
"sidebar.minimize": "Minimize window",
"agent.prompt.placeholder": "Ask a question or describe a task...",
"agent.prompt.send": "Send",
"agent.prompt.stop": "Stop agent",
"agent.status.running": "Running",
"agent.status.idle": "Idle",
"agent.status.done": "Done",
"agent.status.error": "Error",
"agent.status.stalled": "Stalled",
"agent.status.thinking": "Thinking",
"agent.tokens": "{count} tok",
"agent.toolCall": "Tool call",
"agent.toolResult": "Tool result",
"agent.contextMeter": "Context: {pct}%",
"terminal.shell": "Shell",
"terminal.addTab": "New tab",
"terminal.closeTab": "Close tab",
"terminal.collapse": "Collapse",
"terminal.expand": "Expand",
"settings.title": "Settings",
"settings.close": "Close settings",
"settings.appearance": "Appearance",
"settings.agents": "Agents",
"settings.security": "Security",
"settings.projects": "Projects",
"settings.orchestration": "Orchestration",
"settings.machines": "Machines",
"settings.keyboard": "Keyboard",
"settings.advanced": "Advanced",
"settings.marketplace": "Marketplace",
"settings.diagnostics": "Diagnostics",
"settings.theme": "Theme",
"settings.uiFont": "UI Font",
"settings.termFont": "Terminal Font",
"settings.termCursor": "Terminal Cursor",
"settings.scrollback": "Scrollback",
"settings.language": "Language",
"settings.editTheme": "Edit Theme",
"settings.customTheme": "+ Custom",
"settings.deleteTheme": "Delete theme",
"settings.cursorBlink": "Blink",
"settings.cursorOn": "On",
"settings.cursorOff": "Off",
"settings.scrollbackHint": "lines (100-100k)",
"statusbar.running": "running",
"statusbar.idle": "idle",
"statusbar.stalled": "stalled",
"statusbar.attention": "attention",
"statusbar.needsAttention": "Needs attention",
"statusbar.burnRate": "Burn rate",
"statusbar.activeGroup": "Active group",
"statusbar.projects": "projects",
"statusbar.session": "session",
"statusbar.tokens": "tokens",
"statusbar.cost": "cost",
"statusbar.search": "Search (Ctrl+Shift+F)",
"notifications.title": "Notifications",
"notifications.clearAll": "Clear all",
"notifications.noNotifications": "No notifications",
"files.open": "Open",
"files.save": "Save",
"files.saving": "Saving...",
"files.modified": "Modified",
"files.tooLarge": "File too large to display",
"files.empty": "Empty file",
"search.placeholder": "Search messages, tasks, comms...",
"search.noResults": "No results found",
"search.searching": "Searching...",
"search.resultsCount": "{count, plural, one {{count} result} other {{count} results}}",
"comms.channels": "Channels",
"comms.directMessages": "Direct Messages",
"comms.sendMessage": "Send message",
"comms.placeholder": "Type a message...",
"tasks.todo": "To Do",
"tasks.inProgress": "In Progress",
"tasks.review": "Review",
"tasks.done": "Done",
"tasks.blocked": "Blocked",
"tasks.addTask": "Add task",
"tasks.deleteTask": "Delete task",
"tasks.taskCount": "{count, plural, =0 {No tasks} one {{count} task} other {{count} tasks}}",
"errors.connectionFailed": "Connection failed",
"errors.sessionExpired": "Session expired",
"errors.fileNotFound": "File not found",
"errors.unhandled": "Unhandled error: {message}",
"errors.generic": "Something went wrong",
"splash.loading": "Loading...",
"common.cancel": "Cancel",
"common.confirm": "Confirm",
"common.close": "Close",
"common.save": "Save",
"common.delete": "Delete",
"common.edit": "Edit",
"common.refresh": "Refresh",
"common.add": "Add",
"common.back": "Back",
"common.noItems": "No items",
"project.name": "Project name",
"project.cwd": "Working directory (e.g. ~/code/myproject)",
"project.deleteConfirm": "Delete project \"{name}\"?",
"project.emptyGroup": "No projects in {group}",
"project.clone": "Clone",
"project.cloneBranch": "Branch name",
"palette.title": "Command Palette",
"palette.placeholder": "Type a command...",
"palette.newTerminal": "New Terminal Tab",
"palette.openSettings": "Open Settings",
"palette.searchMessages": "Search Messages",
"palette.addProject": "Add Project",
"palette.clearAgent": "Clear Agent Context",
"palette.copyCost": "Copy Session Cost",
"palette.openDocs": "Open Documentation",
"palette.changeTheme": "Change Theme",
"palette.splitH": "Split Horizontally",
"palette.splitV": "Split Vertically",
"palette.focusNext": "Focus Next Project",
"palette.focusPrev": "Focus Previous Project",
"palette.closeTab": "Close Tab",
"palette.toggleTerminal": "Toggle Terminal",
"palette.reloadPlugins": "Reload Plugins",
"palette.toggleSidebar": "Toggle Sidebar",
"palette.zoomIn": "Zoom In",
"palette.zoomOut": "Zoom Out",
"palette.addProjectDesc": "Open a project directory",
"palette.clearAgentDesc": "Reset agent session",
"palette.changeThemeDesc": "Switch between 17 themes"
}

View file

@ -0,0 +1,150 @@
{
"sidebar.settings": "Ustawienia",
"sidebar.addGroup": "Dodaj grupe",
"sidebar.addProject": "Dodaj projekt",
"sidebar.groupName": "Nazwa grupy",
"sidebar.notifications": "Powiadomienia",
"sidebar.notifCount": "{count, plural, =0 {Powiadomienia} one {{count} powiadomienie} few {{count} powiadomienia} many {{count} powiadomien} other {{count} powiadomien}}",
"sidebar.close": "Zamknij okno",
"sidebar.maximize": "Maksymalizuj okno",
"sidebar.minimize": "Minimalizuj okno",
"agent.prompt.placeholder": "Zadaj pytanie lub opisz zadanie...",
"agent.prompt.send": "Wyslij",
"agent.prompt.stop": "Zatrzymaj agenta",
"agent.status.running": "Dziala",
"agent.status.idle": "Bezczynny",
"agent.status.done": "Gotowe",
"agent.status.error": "Blad",
"agent.status.stalled": "Zawieszony",
"agent.status.thinking": "Mysli",
"agent.tokens": "{count} tok",
"agent.toolCall": "Wywolanie narzedzia",
"agent.toolResult": "Wynik narzedzia",
"agent.contextMeter": "Kontekst: {pct}%",
"terminal.shell": "Powloka",
"terminal.addTab": "Nowa karta",
"terminal.closeTab": "Zamknij karte",
"terminal.collapse": "Zwin",
"terminal.expand": "Rozwin",
"settings.title": "Ustawienia",
"settings.close": "Zamknij ustawienia",
"settings.appearance": "Wyglad",
"settings.agents": "Agenci",
"settings.security": "Bezpieczenstwo",
"settings.projects": "Projekty",
"settings.orchestration": "Orkiestracja",
"settings.machines": "Maszyny",
"settings.keyboard": "Klawiatura",
"settings.advanced": "Zaawansowane",
"settings.marketplace": "Sklep",
"settings.diagnostics": "Diagnostyka",
"settings.theme": "Motyw",
"settings.uiFont": "Czcionka UI",
"settings.termFont": "Czcionka terminala",
"settings.termCursor": "Kursor terminala",
"settings.scrollback": "Bufor przewijania",
"settings.language": "Jezyk",
"settings.editTheme": "Edytuj motyw",
"settings.customTheme": "+ Wlasny",
"settings.deleteTheme": "Usun motyw",
"settings.cursorBlink": "Mruganie",
"settings.cursorOn": "Wl.",
"settings.cursorOff": "Wyl.",
"settings.scrollbackHint": "linii (100-100k)",
"statusbar.running": "dziala",
"statusbar.idle": "bezczynny",
"statusbar.stalled": "zawieszony",
"statusbar.attention": "uwaga",
"statusbar.needsAttention": "Wymaga uwagi",
"statusbar.burnRate": "Zuzycie",
"statusbar.activeGroup": "Aktywna grupa",
"statusbar.projects": "projekty",
"statusbar.session": "sesja",
"statusbar.tokens": "tokeny",
"statusbar.cost": "koszt",
"statusbar.search": "Szukaj (Ctrl+Shift+F)",
"notifications.title": "Powiadomienia",
"notifications.clearAll": "Wyczysc wszystko",
"notifications.noNotifications": "Brak powiadomien",
"files.open": "Otworz",
"files.save": "Zapisz",
"files.saving": "Zapisywanie...",
"files.modified": "Zmodyfikowany",
"files.tooLarge": "Plik za duzy do wyswietlenia",
"files.empty": "Pusty plik",
"search.placeholder": "Szukaj wiadomosci, zadan, komunikacji...",
"search.noResults": "Brak wynikow",
"search.searching": "Szukanie...",
"search.resultsCount": "{count, plural, one {{count} wynik} few {{count} wyniki} many {{count} wynikow} other {{count} wynikow}}",
"comms.channels": "Kanaly",
"comms.directMessages": "Wiadomosci prywatne",
"comms.sendMessage": "Wyslij wiadomosc",
"comms.placeholder": "Napisz wiadomosc...",
"tasks.todo": "Do zrobienia",
"tasks.inProgress": "W toku",
"tasks.review": "Przeglad",
"tasks.done": "Gotowe",
"tasks.blocked": "Zablokowane",
"tasks.addTask": "Dodaj zadanie",
"tasks.deleteTask": "Usun zadanie",
"tasks.taskCount": "{count, plural, =0 {Brak zadan} one {{count} zadanie} few {{count} zadania} many {{count} zadan} other {{count} zadan}}",
"errors.connectionFailed": "Polaczenie nieudane",
"errors.sessionExpired": "Sesja wygasla",
"errors.fileNotFound": "Nie znaleziono pliku",
"errors.unhandled": "Nieobsluzony blad: {message}",
"errors.generic": "Cos poszlo nie tak",
"splash.loading": "Ladowanie...",
"common.cancel": "Anuluj",
"common.confirm": "Potwierdz",
"common.close": "Zamknij",
"common.save": "Zapisz",
"common.delete": "Usun",
"common.edit": "Edytuj",
"common.refresh": "Odswiez",
"common.add": "Dodaj",
"common.back": "Wstecz",
"common.noItems": "Brak elementow",
"project.name": "Nazwa projektu",
"project.cwd": "Katalog roboczy (np. ~/code/myproject)",
"project.deleteConfirm": "Usunac projekt \"{name}\"?",
"project.emptyGroup": "Brak projektow w {group}",
"project.clone": "Klonuj",
"project.cloneBranch": "Nazwa galezi",
"palette.title": "Paleta polecen",
"palette.placeholder": "Wpisz polecenie...",
"palette.newTerminal": "Nowa karta terminala",
"palette.openSettings": "Otworz ustawienia",
"palette.searchMessages": "Szukaj wiadomosci",
"palette.addProject": "Dodaj projekt",
"palette.clearAgent": "Wyczysc kontekst agenta",
"palette.copyCost": "Kopiuj koszt sesji",
"palette.openDocs": "Otworz dokumentacje",
"palette.changeTheme": "Zmien motyw",
"palette.splitH": "Podziel poziomo",
"palette.splitV": "Podziel pionowo",
"palette.focusNext": "Nastepny projekt",
"palette.focusPrev": "Poprzedni projekt",
"palette.closeTab": "Zamknij karte",
"palette.toggleTerminal": "Przelacz terminal",
"palette.reloadPlugins": "Przeladuj wtyczki",
"palette.toggleSidebar": "Przelacz pasek boczny",
"palette.zoomIn": "Przybliz",
"palette.zoomOut": "Oddal",
"palette.addProjectDesc": "Otworz katalog projektu",
"palette.clearAgentDesc": "Zresetuj sesje agenta",
"palette.changeThemeDesc": "Wybierz sposrod 17 motywow"
}

View file

@ -10,7 +10,8 @@
"hmr": "vite --port 9760",
"build:canary": "vite build && electrobun build --env=canary",
"test": "bun test src/bun/__tests__/",
"test:e2e": "wdio run tests/e2e/wdio.conf.js"
"test:e2e": "wdio run tests/e2e/wdio.conf.js",
"i18n:types": "bun scripts/i18n-types.ts"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",

View file

@ -0,0 +1,37 @@
#!/usr/bin/env bun
/**
* Reads locales/en.json and generates src/mainview/i18n.types.ts
* with a TranslationKey union type covering all keys.
*
* Usage: bun scripts/i18n-types.ts
*/
import { readFileSync, writeFileSync } from 'fs';
import { resolve } from 'path';
const ROOT = resolve(import.meta.dir, '..');
const EN_PATH = resolve(ROOT, 'locales/en.json');
const OUT_PATH = resolve(ROOT, 'src/mainview/i18n.types.ts');
const en: Record<string, string> = JSON.parse(readFileSync(EN_PATH, 'utf-8'));
const keys = Object.keys(en).sort();
const lines: string[] = [
'/**',
' * Auto-generated by scripts/i18n-types.ts — do not edit manually.',
' * Run: bun scripts/i18n-types.ts',
' */',
'',
'export type TranslationKey =',
];
keys.forEach((key, i) => {
const prefix = i === 0 ? ' | ' : ' | ';
const suffix = i === keys.length - 1 ? ';' : '';
lines.push(`${prefix}'${key}'${suffix}`);
});
lines.push('');
writeFileSync(OUT_PATH, lines.join('\n'), 'utf-8');
console.log(`[i18n-types] Generated ${keys.length} keys -> ${OUT_PATH}`);

View file

@ -2,6 +2,7 @@
import { tick } from 'svelte';
import ChatInput from './ChatInput.svelte';
import type { AgentMessage, AgentStatus } from './agent-store.svelte.ts';
import { t } from './i18n.svelte.ts';
interface Props {
messages: AgentMessage[];
@ -68,10 +69,10 @@
}
function statusLabel(s: AgentStatus) {
if (s === 'running') return 'Running';
if (s === 'error') return 'Error';
if (s === 'done') return 'Done';
return 'Idle';
if (s === 'running') return t('agent.status.running');
if (s === 'error') return t('agent.status.error');
if (s === 'done') return t('agent.status.done');
return t('agent.status.idle');
}
function onResizeMouseDown(e: MouseEvent) {
@ -106,7 +107,7 @@
<span class="strip-sep" aria-hidden="true"></span>
<span class="strip-cost">{fmtCost(costUsd)}</span>
{#if status === 'running' && onStop}
<button class="strip-stop-btn" onclick={onStop} title="Stop agent" aria-label="Stop agent">
<button class="strip-stop-btn" onclick={onStop} title={t('agent.prompt.stop')} aria-label={t('agent.prompt.stop')}>
<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<rect x="3" y="3" width="10" height="10" rx="1" />
</svg>
@ -206,6 +207,7 @@
{model}
{provider}
{contextPct}
placeholder={t('agent.prompt.placeholder')}
onSend={handleSend}
onInput={(v) => (promptText = v)}
/>

View file

@ -14,6 +14,7 @@
import { trackProject } from './health-store.svelte.ts';
import { setAgentToastFn } from './agent-store.svelte.ts';
import { appRpc } from './rpc.ts';
import { initI18n, getDir, getLocale, t } from './i18n.svelte.ts';
// ── Types ─────────────────────────────────────────────────────
type AgentStatus = 'running' | 'idle' | 'stalled';
@ -285,6 +286,12 @@
});
}
// ── i18n: keep <html> lang and dir in sync ─────────────────────
$effect(() => {
document.documentElement.lang = getLocale();
document.documentElement.dir = getDir();
});
// ── Init ───────────────────────────────────────────────────────
onMount(() => {
// Wire agent toast callback
@ -296,6 +303,7 @@
// Fix #8: Load groups FIRST, then apply saved active_group.
// Other init tasks run in parallel.
const initTasks = [
initI18n().catch(console.error),
themeStore.initTheme(appRpc).catch(console.error),
fontStore.initFonts(appRpc).catch(console.error),
keybindingStore.init(appRpc).catch(console.error),

View file

@ -7,6 +7,7 @@
provider?: string;
contextPct?: number;
disabled?: boolean;
placeholder?: string;
onSend?: () => void;
onInput?: (v: string) => void;
}
@ -17,6 +18,7 @@
provider = 'claude',
contextPct = 78,
disabled = false,
placeholder = 'Ask Claude anything...',
onSend,
onInput,
}: Props = $props();
@ -94,7 +96,7 @@
<textarea
bind:this={textareaEl}
class="chat-textarea"
placeholder="Ask Claude anything..."
{placeholder}
rows="1"
{value}
oninput={handleInput}

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { tick } from 'svelte';
import { t } from './i18n.svelte.ts';
interface Props {
open: boolean;
@ -21,27 +22,46 @@
window.dispatchEvent(new CustomEvent('palette-command', { detail: name }));
}
const COMMANDS: Command[] = [
{ id: 'new-terminal', label: 'New Terminal Tab', shortcut: 'Ctrl+`', action: () => dispatch('new-terminal') },
{ id: 'settings', label: 'Open Settings', shortcut: 'Ctrl+,', action: () => dispatch('settings') },
{ id: 'search', label: 'Search Messages', shortcut: 'Ctrl+Shift+F', action: () => dispatch('search') },
{ id: 'new-project', label: 'Add Project', description: 'Open a project directory', action: () => dispatch('new-project') },
{ id: 'clear-agent', label: 'Clear Agent Context', description: 'Reset agent session', action: () => dispatch('clear-agent') },
{ id: 'copy-cost', label: 'Copy Session Cost', action: () => dispatch('copy-cost') },
{ id: 'docs', label: 'Open Documentation', shortcut: 'F1', action: () => dispatch('docs') },
{ id: 'theme', label: 'Change Theme', description: 'Switch between 17 themes', action: () => dispatch('theme') },
{ id: 'split-h', label: 'Split Horizontally', shortcut: 'Ctrl+\\', action: () => dispatch('split-h') },
{ id: 'split-v', label: 'Split Vertically', shortcut: 'Ctrl+Shift+\\', action: () => dispatch('split-v') },
{ id: 'focus-next', label: 'Focus Next Project', shortcut: 'Ctrl+Tab', action: () => dispatch('focus-next') },
{ id: 'focus-prev', label: 'Focus Previous Project', shortcut: 'Ctrl+Shift+Tab', action: () => dispatch('focus-prev') },
{ id: 'close-tab', label: 'Close Tab', shortcut: 'Ctrl+W', action: () => dispatch('close-tab') },
{ id: 'toggle-terminal', label: 'Toggle Terminal', shortcut: 'Ctrl+J', action: () => dispatch('toggle-terminal') },
{ id: 'reload-plugins', label: 'Reload Plugins', action: () => dispatch('reload-plugins') },
{ id: 'toggle-sidebar', label: 'Toggle Sidebar', shortcut: 'Ctrl+B', action: () => dispatch('toggle-sidebar') },
{ id: 'zoom-in', label: 'Zoom In', shortcut: 'Ctrl+=', action: () => dispatch('zoom-in') },
{ id: 'zoom-out', label: 'Zoom Out', shortcut: 'Ctrl+-', action: () => dispatch('zoom-out') },
// Command definitions — labels resolved reactively via t()
interface CommandDef {
id: string;
labelKey: string;
descKey?: string;
shortcut?: string;
action: () => void;
}
const COMMAND_DEFS: CommandDef[] = [
{ id: 'new-terminal', labelKey: 'palette.newTerminal', shortcut: 'Ctrl+`', action: () => dispatch('new-terminal') },
{ id: 'settings', labelKey: 'palette.openSettings', shortcut: 'Ctrl+,', action: () => dispatch('settings') },
{ id: 'search', labelKey: 'palette.searchMessages', shortcut: 'Ctrl+Shift+F', action: () => dispatch('search') },
{ id: 'new-project', labelKey: 'palette.addProject', descKey: 'palette.addProjectDesc', action: () => dispatch('new-project') },
{ id: 'clear-agent', labelKey: 'palette.clearAgent', descKey: 'palette.clearAgentDesc', action: () => dispatch('clear-agent') },
{ id: 'copy-cost', labelKey: 'palette.copyCost', action: () => dispatch('copy-cost') },
{ id: 'docs', labelKey: 'palette.openDocs', shortcut: 'F1', action: () => dispatch('docs') },
{ id: 'theme', labelKey: 'palette.changeTheme', descKey: 'palette.changeThemeDesc', action: () => dispatch('theme') },
{ id: 'split-h', labelKey: 'palette.splitH', shortcut: 'Ctrl+\\', action: () => dispatch('split-h') },
{ id: 'split-v', labelKey: 'palette.splitV', shortcut: 'Ctrl+Shift+\\', action: () => dispatch('split-v') },
{ id: 'focus-next', labelKey: 'palette.focusNext', shortcut: 'Ctrl+Tab', action: () => dispatch('focus-next') },
{ id: 'focus-prev', labelKey: 'palette.focusPrev', shortcut: 'Ctrl+Shift+Tab', action: () => dispatch('focus-prev') },
{ id: 'close-tab', labelKey: 'palette.closeTab', shortcut: 'Ctrl+W', action: () => dispatch('close-tab') },
{ id: 'toggle-terminal', labelKey: 'palette.toggleTerminal', shortcut: 'Ctrl+J', action: () => dispatch('toggle-terminal') },
{ id: 'reload-plugins', labelKey: 'palette.reloadPlugins', action: () => dispatch('reload-plugins') },
{ id: 'toggle-sidebar', labelKey: 'palette.toggleSidebar', shortcut: 'Ctrl+B', action: () => dispatch('toggle-sidebar') },
{ id: 'zoom-in', labelKey: 'palette.zoomIn', shortcut: 'Ctrl+=', action: () => dispatch('zoom-in') },
{ id: 'zoom-out', labelKey: 'palette.zoomOut', shortcut: 'Ctrl+-', action: () => dispatch('zoom-out') },
];
let COMMANDS = $derived<Command[]>(
COMMAND_DEFS.map(d => ({
id: d.id,
label: t(d.labelKey as any),
description: d.descKey ? t(d.descKey as any) : undefined,
shortcut: d.shortcut,
action: d.action,
}))
);
let query = $state('');
let selectedIdx = $state(0);
let inputEl = $state<HTMLInputElement | undefined>(undefined);
@ -110,7 +130,7 @@
class="palette-input"
type="text"
role="combobox"
placeholder="Type a command..."
placeholder={t('palette.placeholder')}
bind:this={inputEl}
bind:value={query}
onkeydown={handleKeydown}

View file

@ -9,6 +9,7 @@
import KeyboardSettings from './settings/KeyboardSettings.svelte';
import RemoteMachinesSettings from './settings/RemoteMachinesSettings.svelte';
import DiagnosticsTab from './settings/DiagnosticsTab.svelte';
import { t } from './i18n.svelte.ts';
interface Props {
open: boolean;
@ -25,19 +26,23 @@
icon: string;
}
const CATEGORIES: Category[] = [
{ id: 'appearance', label: 'Appearance', icon: '🎨' },
{ id: 'agents', label: 'Agents', icon: '🤖' },
{ id: 'security', label: 'Security', icon: '🔒' },
{ id: 'projects', label: 'Projects', icon: '📁' },
{ id: 'orchestration', label: 'Orchestration', icon: '⚙' },
{ id: 'machines', label: 'Machines', icon: '🖥' },
{ id: 'keyboard', label: 'Keyboard', icon: '⌨' },
{ id: 'advanced', label: 'Advanced', icon: '🔧' },
{ id: 'marketplace', label: 'Marketplace', icon: '🛒' },
{ id: 'diagnostics', label: 'Diagnostics', icon: '📊' },
const CATEGORY_DEFS: Array<{ id: CategoryId; icon: string; key: string }> = [
{ id: 'appearance', icon: '🎨', key: 'settings.appearance' },
{ id: 'agents', icon: '🤖', key: 'settings.agents' },
{ id: 'security', icon: '🔒', key: 'settings.security' },
{ id: 'projects', icon: '📁', key: 'settings.projects' },
{ id: 'orchestration', icon: '⚙', key: 'settings.orchestration' },
{ id: 'machines', icon: '🖥', key: 'settings.machines' },
{ id: 'keyboard', icon: '⌨', key: 'settings.keyboard' },
{ id: 'advanced', icon: '🔧', key: 'settings.advanced' },
{ id: 'marketplace', icon: '🛒', key: 'settings.marketplace' },
{ id: 'diagnostics', icon: '📊', key: 'settings.diagnostics' },
];
let CATEGORIES = $derived<Category[]>(
CATEGORY_DEFS.map(d => ({ id: d.id, label: t(d.key as any), icon: d.icon }))
);
let activeCategory = $state<CategoryId>('appearance');
function handleBackdropClick(e: MouseEvent) {
@ -66,8 +71,8 @@
<!-- Header -->
<header class="drawer-header">
<h2 class="drawer-title">Settings</h2>
<button class="drawer-close" onclick={onClose} aria-label="Close settings">×</button>
<h2 class="drawer-title">{t('settings.title')}</h2>
<button class="drawer-close" onclick={onClose} aria-label={t('settings.close')}>×</button>
</header>
<!-- Body: sidebar + content -->

View file

@ -4,6 +4,7 @@
* Auto-dismisses when the `ready` prop becomes true.
* Fade-out transition: 300ms opacity.
*/
import { t } from './i18n.svelte.ts';
interface Props {
/** Set to true when app initialization is complete. */
@ -41,7 +42,7 @@
<span class="dot"></span>
<span class="dot"></span>
</div>
<div class="loading-label">Loading...</div>
<div class="loading-label">{t('splash.loading')}</div>
</div>
</div>

View file

@ -4,6 +4,7 @@
getAttentionQueue,
type ProjectHealth,
} from './health-store.svelte.ts';
import { t } from './i18n.svelte.ts';
interface Props {
projectCount: number;
@ -60,21 +61,21 @@
<span class="status-segment">
<span class="dot green pulse-dot" aria-hidden="true"></span>
<span class="val">{health.running}</span>
<span>running</span>
<span>{t('statusbar.running')}</span>
</span>
{/if}
{#if health.idle > 0}
<span class="status-segment">
<span class="dot gray" aria-hidden="true"></span>
<span class="val">{health.idle}</span>
<span>idle</span>
<span>{t('statusbar.idle')}</span>
</span>
{/if}
{#if health.stalled > 0}
<span class="status-segment stalled">
<span class="dot orange" aria-hidden="true"></span>
<span class="val">{health.stalled}</span>
<span>stalled</span>
<span>{t('statusbar.stalled')}</span>
</span>
{/if}
@ -84,11 +85,11 @@
class="status-segment attn-btn"
class:attn-open={showAttention}
onclick={() => showAttention = !showAttention}
title="Needs attention"
title={t('statusbar.needsAttention')}
>
<span class="dot orange pulse-dot" aria-hidden="true"></span>
<span class="val">{attentionQueue.length}</span>
<span>attention</span>
<span>{t('statusbar.attention')}</span>
</button>
{/if}
@ -96,32 +97,32 @@
<!-- Right: aggregates -->
{#if health.totalBurnRatePerHour > 0}
<span class="status-segment burn" title="Burn rate">
<span class="status-segment burn" title={t('statusbar.burnRate')}>
<span class="val">{formatRate(health.totalBurnRatePerHour)}</span>
</span>
{/if}
<span class="status-segment" title="Active group">
<span class="status-segment" title={t('statusbar.activeGroup')}>
<span class="val">{groupName}</span>
</span>
<span class="status-segment" title="Projects">
<span class="status-segment" title={t('statusbar.projects')}>
<span class="val">{projectCount}</span>
<span>projects</span>
<span>{t('statusbar.projects')}</span>
</span>
<span class="status-segment" title="Session duration">
<span>session</span>
<span class="status-segment" title={t('statusbar.session')}>
<span>{t('statusbar.session')}</span>
<span class="val">{sessionDuration}</span>
</span>
<span class="status-segment" title="Total tokens">
<span>tokens</span>
<span class="status-segment" title={t('statusbar.tokens')}>
<span>{t('statusbar.tokens')}</span>
<span class="val">{fmtTokens(totalTokens)}</span>
</span>
<span class="status-segment" title="Total cost">
<span>cost</span>
<span class="status-segment" title={t('statusbar.cost')}>
<span>{t('statusbar.cost')}</span>
<span class="val cost">{fmtCost(totalCost)}</span>
</span>
<kbd class="palette-hint" title="Search (Ctrl+Shift+F)">Ctrl+Shift+F</kbd>
<kbd class="palette-hint" title={t('statusbar.search')}>Ctrl+Shift+F</kbd>
</footer>
<!-- Attention dropdown -->

View file

@ -0,0 +1,153 @@
/**
* i18n store Svelte 5 runes + @formatjs/intl.
*
* Usage:
* import { t, setLocale, getLocale, getDir, initI18n } from './i18n.svelte.ts';
* const label = t('agent.status.running');
* const msg = t('sidebar.notifCount', { count: 3 });
*/
import { createIntl, createIntlCache, type IntlShape } from '@formatjs/intl';
import type { TranslationKey } from './i18n.types';
import { appRpc } from './rpc.ts';
// ── Locale metadata ──────────────────────────────────────────────────────────
export interface LocaleMeta {
tag: string;
label: string;
nativeLabel: string;
dir: 'ltr' | 'rtl';
}
export const AVAILABLE_LOCALES: LocaleMeta[] = [
{ tag: 'en', label: 'English', nativeLabel: 'English', dir: 'ltr' },
{ tag: 'pl', label: 'Polish', nativeLabel: 'Polski', dir: 'ltr' },
{ tag: 'ar', label: 'Arabic', nativeLabel: '\u0627\u0644\u0639\u0631\u0628\u064a\u0629', dir: 'rtl' },
];
// ── Reactive state ───────────────────────────────────────────────────────────
let locale = $state<string>('en');
/** Version counter — incremented on every setLocale() so $derived consumers re-evaluate. */
let _v = $state(0);
// ── @formatjs/intl instance ──────────────────────────────────────────────────
const cache = createIntlCache();
type Messages = Record<string, string>;
let _messages: Messages = {};
let _intl: IntlShape<string> = createIntl({ locale: 'en', messages: {} }, cache);
// ── Locale loaders (dynamic import) ──────────────────────────────────────────
const loaders: Record<string, () => Promise<Messages>> = {
en: () => import('../../locales/en.json').then(m => m.default as Messages),
pl: () => import('../../locales/pl.json').then(m => m.default as Messages),
ar: () => import('../../locales/ar.json').then(m => m.default as Messages),
};
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Change the active locale. Dynamically loads the JSON message file,
* recreates the intl instance, and bumps the reactivity version counter.
*/
export async function setLocale(tag: string): Promise<void> {
const loader = loaders[tag];
if (!loader) {
console.warn(`[i18n] unknown locale: ${tag}`);
return;
}
try {
_messages = await loader();
} catch (err) {
console.error(`[i18n] failed to load locale "${tag}":`, err);
return;
}
locale = tag;
_intl = createIntl({ locale: tag, messages: _messages }, cache);
_v++;
// Persist preference
try {
await appRpc?.request['settings.set']({ key: 'locale', value: tag });
} catch { /* non-critical */ }
}
/**
* Translate a message key, optionally with ICU values.
* Reads `_v` to trigger Svelte 5 reactivity on locale change.
*/
export function t(key: TranslationKey, values?: Record<string, any>): string {
// Touch reactive version so $derived consumers re-run.
void _v;
const msg = _messages[key];
if (!msg) return key;
try {
return _intl.formatMessage({ id: key, defaultMessage: msg }, values);
} catch (err) {
console.warn(`[i18n] format error for "${key}":`, err);
return msg;
}
}
/** Format a Date using the current locale. */
export function formatDate(date: Date | number, options?: Intl.DateTimeFormatOptions): string {
void _v;
return _intl.formatDate(date, options);
}
/** Format a number using the current locale. */
export function formatNumber(num: number, options?: Intl.NumberFormatOptions): string {
void _v;
return _intl.formatNumber(num, options);
}
/** Format a relative time (e.g. -5, 'minute' -> "5 minutes ago"). */
export function formatRelativeTime(
value: number,
unit: Intl.RelativeTimeFormatUnit,
options?: Parameters<IntlShape['formatRelativeTime']>[2],
): string {
void _v;
return _intl.formatRelativeTime(value, unit, options);
}
/** Current locale tag (e.g. 'en', 'pl', 'ar'). */
export function getLocale(): string {
void _v;
return locale;
}
/** Text direction for the current locale. */
export function getDir(): 'ltr' | 'rtl' {
void _v;
const meta = AVAILABLE_LOCALES.find(l => l.tag === locale);
return meta?.dir ?? 'ltr';
}
/**
* Initialize i18n on app startup.
* Loads saved locale from settings, falls back to 'en'.
*/
export async function initI18n(): Promise<void> {
// Load PluralRules polyfill for WebKitGTK (needed for Arabic 6-form plurals)
await import('@formatjs/intl-pluralrules/polyfill-force.js').catch(() => {});
let savedLocale = 'en';
try {
const result = await appRpc?.request['settings.get']({ key: 'locale' });
if (result?.value && loaders[result.value]) {
savedLocale = result.value;
}
} catch { /* use default */ }
await setLocale(savedLocale);
}

View file

@ -0,0 +1,140 @@
/**
* Auto-generated by scripts/i18n-types.ts do not edit manually.
* Run: bun scripts/i18n-types.ts
*/
export type TranslationKey =
| 'agent.contextMeter'
| 'agent.prompt.placeholder'
| 'agent.prompt.send'
| 'agent.prompt.stop'
| 'agent.status.done'
| 'agent.status.error'
| 'agent.status.idle'
| 'agent.status.running'
| 'agent.status.stalled'
| 'agent.status.thinking'
| 'agent.tokens'
| 'agent.toolCall'
| 'agent.toolResult'
| 'common.add'
| 'common.back'
| 'common.cancel'
| 'common.close'
| 'common.confirm'
| 'common.delete'
| 'common.edit'
| 'common.noItems'
| 'common.refresh'
| 'common.save'
| 'comms.channels'
| 'comms.directMessages'
| 'comms.placeholder'
| 'comms.sendMessage'
| 'errors.connectionFailed'
| 'errors.fileNotFound'
| 'errors.generic'
| 'errors.sessionExpired'
| 'errors.unhandled'
| 'files.empty'
| 'files.modified'
| 'files.open'
| 'files.save'
| 'files.saving'
| 'files.tooLarge'
| 'notifications.clearAll'
| 'notifications.noNotifications'
| 'notifications.title'
| 'palette.addProject'
| 'palette.addProjectDesc'
| 'palette.changeTheme'
| 'palette.changeThemeDesc'
| 'palette.clearAgent'
| 'palette.clearAgentDesc'
| 'palette.closeTab'
| 'palette.copyCost'
| 'palette.focusNext'
| 'palette.focusPrev'
| 'palette.newTerminal'
| 'palette.openDocs'
| 'palette.openSettings'
| 'palette.placeholder'
| 'palette.reloadPlugins'
| 'palette.searchMessages'
| 'palette.splitH'
| 'palette.splitV'
| 'palette.title'
| 'palette.toggleSidebar'
| 'palette.toggleTerminal'
| 'palette.zoomIn'
| 'palette.zoomOut'
| 'project.clone'
| 'project.cloneBranch'
| 'project.cwd'
| 'project.deleteConfirm'
| 'project.emptyGroup'
| 'project.name'
| 'search.noResults'
| 'search.placeholder'
| 'search.resultsCount'
| 'search.searching'
| 'settings.advanced'
| 'settings.agents'
| 'settings.appearance'
| 'settings.close'
| 'settings.cursorBlink'
| 'settings.cursorOff'
| 'settings.cursorOn'
| 'settings.customTheme'
| 'settings.deleteTheme'
| 'settings.diagnostics'
| 'settings.editTheme'
| 'settings.keyboard'
| 'settings.language'
| 'settings.machines'
| 'settings.marketplace'
| 'settings.orchestration'
| 'settings.projects'
| 'settings.scrollback'
| 'settings.scrollbackHint'
| 'settings.security'
| 'settings.termCursor'
| 'settings.termFont'
| 'settings.theme'
| 'settings.title'
| 'settings.uiFont'
| 'sidebar.addGroup'
| 'sidebar.addProject'
| 'sidebar.close'
| 'sidebar.groupName'
| 'sidebar.maximize'
| 'sidebar.minimize'
| 'sidebar.notifCount'
| 'sidebar.notifications'
| 'sidebar.settings'
| 'splash.loading'
| 'statusbar.activeGroup'
| 'statusbar.attention'
| 'statusbar.burnRate'
| 'statusbar.cost'
| 'statusbar.idle'
| 'statusbar.needsAttention'
| 'statusbar.projects'
| 'statusbar.running'
| 'statusbar.search'
| 'statusbar.session'
| 'statusbar.stalled'
| 'statusbar.tokens'
| 'tasks.addTask'
| 'tasks.blocked'
| 'tasks.deleteTask'
| 'tasks.done'
| 'tasks.inProgress'
| 'tasks.review'
| 'tasks.taskCount'
| 'tasks.todo'
| 'terminal.addTab'
| 'terminal.closeTab'
| 'terminal.collapse'
| 'terminal.expand'
| 'terminal.shell';

View file

@ -4,6 +4,7 @@
import { THEMES, THEME_GROUPS, getPalette, type ThemeId, type ThemeMeta } from '../themes.ts';
import { themeStore } from '../theme-store.svelte.ts';
import { fontStore } from '../font-store.svelte.ts';
import { t, getLocale, setLocale, AVAILABLE_LOCALES } from '../i18n.svelte.ts';
import ThemeEditor from './ThemeEditor.svelte';
const UI_FONTS = [
@ -50,6 +51,16 @@
let themeOpen = $state(false);
let uiFontOpen = $state(false);
let termFontOpen = $state(false);
let langOpen = $state(false);
// ── Language ──────────────────────────────────────────────────────────────
let currentLocale = $derived(getLocale());
let langLabel = $derived(AVAILABLE_LOCALES.find(l => l.tag === currentLocale)?.nativeLabel ?? 'English');
function selectLang(tag: string): void {
langOpen = false;
setLocale(tag);
}
// ── All themes (built-in + custom) ────────────────────────────────────────
let allThemes = $derived<ThemeMeta[]>([
@ -106,7 +117,7 @@
appRpc?.request['settings.set']({ key: 'scrollback', value: String(v) }).catch(console.error);
}
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; }
function closeAll(): void { themeOpen = false; uiFontOpen = false; termFontOpen = false; langOpen = false; }
function handleOutsideClick(e: MouseEvent): void {
if (!(e.target as HTMLElement).closest('.dd-wrap')) closeAll();
}
@ -149,7 +160,7 @@
/>
{:else}
<h3 class="sh">Theme</h3>
<h3 class="sh">{t('settings.theme')}</h3>
<div class="field">
<div class="dd-wrap">
<button class="dd-btn" onclick={() => { themeOpen = !themeOpen; uiFontOpen = false; termFontOpen = false; }}>
@ -186,7 +197,7 @@
</div>
</div>
<h3 class="sh">UI Font</h3>
<h3 class="sh">{t('settings.uiFont')}</h3>
<div class="field row">
<div class="dd-wrap flex1">
<button class="dd-btn" onclick={() => { uiFontOpen = !uiFontOpen; themeOpen = false; termFontOpen = false; }}>
@ -211,7 +222,7 @@
</div>
</div>
<h3 class="sh">Terminal Font</h3>
<h3 class="sh">{t('settings.termFont')}</h3>
<div class="field row">
<div class="dd-wrap flex1">
<button class="dd-btn" onclick={() => { termFontOpen = !termFontOpen; themeOpen = false; uiFontOpen = false; }}>
@ -236,7 +247,7 @@
</div>
</div>
<h3 class="sh">Terminal Cursor</h3>
<h3 class="sh">{t('settings.termCursor')}</h3>
<div class="field row">
<div class="seg">
{#each ['block', 'line', 'underline'] as s}
@ -249,12 +260,35 @@
</label>
</div>
<h3 class="sh">Scrollback</h3>
<h3 class="sh">{t('settings.scrollback')}</h3>
<div class="field row">
<input type="number" class="num-in" min="100" max="100000" step="100" value={scrollback}
onchange={e => persistScrollback(parseInt((e.target as HTMLInputElement).value, 10) || 1000)}
aria-label="Scrollback lines" />
<span class="hint">lines (100100k)</span>
<span class="hint">{t('settings.scrollbackHint')}</span>
</div>
<h3 class="sh">{t('settings.language')}</h3>
<div class="field">
<div class="dd-wrap">
<button class="dd-btn" onclick={() => { langOpen = !langOpen; themeOpen = false; uiFontOpen = false; termFontOpen = false; }}>
{langLabel}
<svg class="chev" class:open={langOpen} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{#if langOpen}
<ul class="dd-list" role="listbox">
{#each AVAILABLE_LOCALES as loc}
<li class="dd-item" class:sel={currentLocale === loc.tag} role="option" aria-selected={currentLocale === loc.tag}
tabindex="0" onclick={() => selectLang(loc.tag)}
onkeydown={e => (e.key === 'Enter' || e.key === ' ') && selectLang(loc.tag)}
>
<span class="dd-item-label">{loc.nativeLabel}</span>
<span style="color: var(--ctp-overlay0); font-size: 0.6875rem;">{loc.label}</span>
</li>
{/each}
</ul>
{/if}
</div>
</div>
{/if}