Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"dependencies": {
"@open-codesign/artifacts": "workspace:*",
"@open-codesign/core": "workspace:*",
"@open-codesign/i18n": "workspace:*",
"@open-codesign/providers": "workspace:*",
"@open-codesign/runtime": "workspace:*",
"@open-codesign/shared": "workspace:*",
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { detectProviderFromKey } from '@open-codesign/providers';
import { BRAND, CodesignError, GeneratePayload } from '@open-codesign/shared';
import { BrowserWindow, app, ipcMain, shell } from 'electron';
import { autoUpdater } from 'electron-updater';
import { registerLocaleIpc } from './locale-ipc';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Expand Down Expand Up @@ -78,6 +79,7 @@ function setupAutoUpdater(): void {

void app.whenReady().then(() => {
registerIpcHandlers();
registerLocaleIpc();
setupAutoUpdater();
createWindow();

Expand Down
50 changes: 50 additions & 0 deletions apps/desktop/src/main/locale-ipc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import { app, ipcMain } from 'electron';

const CONFIG_DIR = join(homedir(), '.config', 'open-codesign');
const LOCALE_FILE = join(CONFIG_DIR, 'locale.json');

interface LocaleFile {
locale: string;
}

async function readPersisted(): Promise<string | null> {
try {
const raw = await readFile(LOCALE_FILE, 'utf8');
const parsed = JSON.parse(raw) as Partial<LocaleFile>;
if (typeof parsed.locale === 'string' && parsed.locale.length > 0) {
return parsed.locale;
}
return null;
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return null;
console.warn(`[locale-ipc] failed to read ${LOCALE_FILE}:`, err);
return null;
}
}

async function writePersisted(locale: string): Promise<void> {
await mkdir(dirname(LOCALE_FILE), { recursive: true });
const payload: LocaleFile = { locale };
await writeFile(LOCALE_FILE, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}

export function registerLocaleIpc(): void {
ipcMain.handle('locale:get-system', () => app.getLocale());

ipcMain.handle('locale:get-current', async () => {
const persisted = await readPersisted();
return persisted ?? app.getLocale();
});

ipcMain.handle('locale:set', async (_e, raw: unknown) => {
if (typeof raw !== 'string' || raw.length === 0) {
throw new Error('locale:set expects a non-empty string');
}
await writePersisted(raw);
return raw;
});
}
5 changes: 5 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const api = {
ipcRenderer.on('codesign:update-available', listener);
return () => ipcRenderer.removeListener('codesign:update-available', listener);
},
locale: {
getSystem: () => ipcRenderer.invoke('locale:get-system') as Promise<string>,
getCurrent: () => ipcRenderer.invoke('locale:get-current') as Promise<string>,
set: (locale: string) => ipcRenderer.invoke('locale:set', locale) as Promise<string>,
},
};

contextBridge.exposeInMainWorld('codesign', api);
Expand Down
40 changes: 26 additions & 14 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { useT } from '@open-codesign/i18n';
import { buildSrcdoc } from '@open-codesign/runtime';
import { BUILTIN_DEMOS } from '@open-codesign/templates';
import { getDemos } from '@open-codesign/templates';
import { Button } from '@open-codesign/ui';
import { Send, Sparkles } from 'lucide-react';
import { useState } from 'react';
import { useCodesignStore } from './store';

export function App() {
const t = useT();
const messages = useCodesignStore((s) => s.messages);
const previewHtml = useCodesignStore((s) => s.previewHtml);
const isGenerating = useCodesignStore((s) => s.isGenerating);
const sendPrompt = useCodesignStore((s) => s.sendPrompt);
const locale = useCodesignStore((s) => s.locale);
const [prompt, setPrompt] = useState('');

const demos = getDemos(locale);

function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!prompt.trim() || isGenerating) return;
Expand All @@ -25,19 +30,23 @@ export function App() {
<header className="px-5 py-4 border-b border-[var(--color-border)]">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-[var(--color-accent)]" />
<span className="font-semibold text-[var(--color-text-primary)]">open-codesign</span>
<span className="ml-auto text-xs text-[var(--color-text-muted)]">pre-alpha</span>
<span className="font-semibold text-[var(--color-text-primary)]">
{t('common.appName')}
</span>
<span className="ml-auto text-xs text-[var(--color-text-muted)]">
{t('common.preAlpha')}
</span>
</div>
</header>

<div className="flex-1 overflow-y-auto px-5 py-4 space-y-4">
{messages.length === 0 ? (
<div>
<p className="text-sm text-[var(--color-text-secondary)] mb-3">
Try a starter prompt:
{t('preview.empty.starterChip')}
</p>
<ul className="space-y-2">
{BUILTIN_DEMOS.map((demo) => (
{demos.map((demo) => (
<li key={demo.id}>
<button
type="button"
Expand Down Expand Up @@ -80,11 +89,17 @@ export function App() {
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe what to design…"
placeholder={t('chat.placeholder')}
aria-label={t('chat.placeholder')}
disabled={isGenerating}
className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none focus:border-[var(--color-accent)]"
/>
<Button type="submit" size="md" disabled={isGenerating || !prompt.trim()}>
<Button
type="submit"
size="md"
disabled={isGenerating || !prompt.trim()}
aria-label={t('common.send')}
>
<Send className="w-4 h-4" />
</Button>
</form>
Expand All @@ -93,11 +108,9 @@ export function App() {
<main className="flex flex-col">
<header className="h-12 px-5 border-b border-[var(--color-border)] flex items-center justify-between">
<span className="text-sm text-[var(--color-text-secondary)]">
{previewHtml ? 'Preview' : 'No design yet'}
</span>
<span className="text-xs text-[var(--color-text-muted)]">
BYOK · local-first · multi-model
{previewHtml ? t('preview.ready') : t('preview.noDesign')}
</span>
<span className="text-xs text-[var(--color-text-muted)]">{t('common.tagline')}</span>
</header>
<div className="flex-1 p-6 overflow-auto">
{previewHtml ? (
Expand All @@ -115,11 +128,10 @@ export function App() {
<Sparkles className="w-7 h-7 text-[var(--color-accent)]" />
</div>
<h2 className="text-lg font-semibold text-[var(--color-text-primary)] mb-2">
Design with AI
{t('preview.empty.title')}
</h2>
<p className="text-sm text-[var(--color-text-secondary)]">
Pick a starter on the left, or describe what you want to design. The result
renders here in a sandboxed preview.
{t('preview.empty.body')}
</p>
</div>
</div>
Expand Down
26 changes: 21 additions & 5 deletions apps/desktop/src/renderer/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
import { initI18n, normalizeLocale } from '@open-codesign/i18n';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
import { useCodesignStore } from './store';

const container = document.getElementById('root');
if (!container) throw new Error('Root element #root not found');

createRoot(container).render(
<StrictMode>
<App />
</StrictMode>,
);
async function bootstrap(): Promise<void> {
const persisted = await window.codesign?.locale.getCurrent();
const initial = normalizeLocale(persisted ?? navigator.language);
await initI18n(initial);
useCodesignStore.setState({ locale: initial });

// Persist any normalization back to disk so subsequent boots are stable.
if (window.codesign && persisted !== initial) {
await window.codesign.locale.set(initial);
}

createRoot(container as HTMLElement).render(
<StrictMode>
<App />
</StrictMode>,
);
}

void bootstrap();
21 changes: 21 additions & 0 deletions apps/desktop/src/renderer/src/store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type Locale, setLocale as applyLocale, normalizeLocale } from '@open-codesign/i18n';
import type { ChatMessage } from '@open-codesign/shared';
import { create } from 'zustand';
import type { CodesignApi } from '../../preload/index';
Expand All @@ -13,6 +14,9 @@ interface CodesignState {
previewHtml: string | null;
isGenerating: boolean;
errorMessage: string | null;
locale: Locale;
initLocale: () => Promise<void>;
setLocale: (locale: string) => Promise<void>;
sendPrompt: (prompt: string) => Promise<void>;
}

Expand All @@ -21,6 +25,23 @@ export const useCodesignStore = create<CodesignState>((set, get) => ({
previewHtml: null,
isGenerating: false,
errorMessage: null,
locale: 'en',

async initLocale() {
if (!window.codesign) return;
const raw = await window.codesign.locale.getCurrent();
const normalized = normalizeLocale(raw);
await applyLocale(normalized);
set({ locale: normalized });
},

async setLocale(locale: string) {
const normalized = await applyLocale(locale);
set({ locale: normalized });
if (window.codesign) {
await window.codesign.locale.set(normalized);
}
},

async sendPrompt(prompt: string) {
if (get().isGenerating) return;
Expand Down
92 changes: 92 additions & 0 deletions docs/I18N.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Internationalization

open-codesign ships with English (`en`) and Simplified Chinese (`zh-CN`). This
doc explains how to add a string, add a locale, and follow our naming rules.

## Stack

- [`i18next`](https://www.i18next.com) (~50 KB) for the core engine
- [`react-i18next`](https://react.i18next.com) (~25 KB) for the React binding
- Locale JSON files live in `packages/i18n/src/locales/`
- Per-locale demo prompts live in `packages/templates/src/locales/`

The `@open-codesign/i18n` package exposes:

| Export | Purpose |
| --- | --- |
| `initI18n(locale)` | One-time initialization; call from `main.tsx` before `createRoot` |
| `setLocale(locale)` | Switch language at runtime; persists nothing on its own |
| `getCurrentLocale()` | Read the active locale outside React |
| `useT()` | React hook returning a typed `t(key, options?)` function |
| `availableLocales` | Tuple of supported locale codes |
| `normalizeLocale(value)` | Map a system/user string to a supported locale (or warn + fall back) |

## How locale auto-detection works

1. On first run, the renderer reads `window.codesign.locale.getCurrent()`.
2. The main process returns the value persisted at
`~/.config/open-codesign/locale.json` if present, otherwise
`app.getLocale()`.
3. `normalizeLocale` maps the raw string to one of `en` / `zh-CN`. Common
variants (`zh`, `zh-Hans`, `zh-CN`, `zh_CN`, `en-US`, `en-GB`) map cleanly;
anything else logs `console.warn` and falls back to `en`.
4. `initI18n(locale)` boots i18next with the resolved value before React
renders.

User overrides flow through `useCodesignStore.getState().setLocale(code)`
which calls `applyLocale(code)` and writes to the locale file via the
`locale:set` IPC channel. (Once `wt/onboarding` lands, this will fold into
`config.toml` under `ui.locale`.)

## Adding a string

1. Pick a namespace (top-level key in the JSON: `common`, `preview`, `chat`,
`settings`, `onboarding`, `commands`, `errors`, `demos`).
2. Add the key to **both** `packages/i18n/src/locales/en.json` and
`packages/i18n/src/locales/zh-CN.json`. Missing entries log a warning and
render the key path — never silent.
3. In the component, call `t('namespace.key')`.
4. For interpolation use `{{name}}`; pass values as the second argument:
`t('onboarding.paste.recognized', { provider: 'Anthropic' })`.
5. For pluralization use the i18next `_one` / `_other` suffix (already wired in
the `onboarding.paste.connected_*` example).

## Naming convention

- Keys are `lowerCamelCase` for words and `kebab-case` only when they refer to
external IDs (HTTP status codes etc).
- Keep nesting shallow: aim for 3 dotted levels (e.g. `preview.empty.title`).
4 levels are allowed only when grouping a tightly related set such as
`onboarding.paste.errors.401`.
- Use full sentences in English. Translators copy your tone.
- Never concatenate localized fragments. Use a full template with
interpolation instead.

## Adding a locale

1. Create `packages/i18n/src/locales/<code>.json` mirroring the existing tree.
2. Add the code to `availableLocales` in `packages/i18n/src/index.ts` and to
the `resources` map.
3. Add a demo file under `packages/templates/src/locales/<code>.ts` and
register it in `packages/templates/src/index.ts` `REGISTRY`.
4. Update `normalizeLocale` if the new code has variants worth coalescing.
5. Add the new code to this doc and the language picker in Settings.

## Missing key policy

We never silently swallow missing keys. Concretely:

- The handler logs `console.warn` with the namespace, key, and active locale.
- In development the rendered text is wrapped in `⟦…⟧` so the bug is loud.
- In production the rendered text is the raw key (no fake fallback string).

If you need an English fallback, write the English entry — don't lean on the
fallback chain to hide a missing translation.

## Tier scope (per `docs/PRINCIPLES.md` §5)

- Tier 1 (now): English + Simplified Chinese, JSON resources bundled with the
renderer, single language switch in Settings.
- Tier 2: lazy-load locales by file, contributed translations via PR, RTL
support.
- Tier 3: in-app translation editor, per-component string extraction tooling.
30 changes: 30 additions & 0 deletions packages/i18n/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@open-codesign/i18n",
"version": "0.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./locales/en": "./src/locales/en.json",
"./locales/zh-CN": "./src/locales/zh-CN.json"
},
"scripts": {
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"i18next": "^23.16.5",
"react-i18next": "^15.1.3"
},
"peerDependencies": {
"react": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"react": "^19.0.0",
"typescript": "^5.7.2",
"vitest": "^2.1.8"
}
}
Loading
Loading