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

@ -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}