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

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