Skip to content

Commit 755d610

Browse files
Merge pull request #20 from OpenCoworkAI/feat/desktop-language-toggle
feat(desktop): surface language toggle
2 parents 9120dec + bca121f commit 755d610

20 files changed

Lines changed: 418 additions & 156 deletions

File tree

‎apps/desktop/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"@open-codesign/artifacts": "workspace:*",
1717
"@open-codesign/core": "workspace:*",
1818
"@open-codesign/exporters": "workspace:*",
19+
"@open-codesign/i18n": "workspace:*",
1920
"@open-codesign/providers": "workspace:*",
2021
"@open-codesign/runtime": "workspace:*",
2122
"@open-codesign/shared": "workspace:*",

‎apps/desktop/src/main/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { autoUpdater } from 'electron-updater';
99
import { scanDesignSystem } from './design-system';
1010
import { BrowserWindow, app, dialog, ipcMain, shell } from './electron-runtime';
1111
import { registerExporterIpc } from './exporter-ipc';
12+
import { registerLocaleIpc } from './locale-ipc';
1213
import { getLogPath, getLogger, initLogger } from './logger';
1314
import {
1415
getApiKeyForProvider,
@@ -32,6 +33,7 @@ function createWindow(): void {
3233
height: 820,
3334
minWidth: 960,
3435
minHeight: 640,
36+
autoHideMenuBar: process.platform !== 'darwin',
3537
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
3638
backgroundColor: BRAND.backgroundColor,
3739
show: false,
@@ -254,6 +256,7 @@ void app.whenReady().then(async () => {
254256
initLogger();
255257
await loadConfigOnBoot();
256258
registerIpcHandlers();
259+
registerLocaleIpc();
257260
registerOnboardingIpc();
258261
registerExporterIpc(() => mainWindow);
259262
setupAutoUpdater();

‎apps/desktop/src/preload/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ const api = {
5252
ipcRenderer.invoke('codesign:clear-design-system') as Promise<OnboardingState>,
5353
export: (payload: { format: ExportFormat; htmlContent: string; defaultFilename?: string }) =>
5454
ipcRenderer.invoke('codesign:export', payload) as Promise<ExportInvokeResponse>,
55+
locale: {
56+
getSystem: () => ipcRenderer.invoke('locale:get-system') as Promise<string>,
57+
getCurrent: () => ipcRenderer.invoke('locale:get-current') as Promise<string>,
58+
set: (locale: string) => ipcRenderer.invoke('locale:set', locale) as Promise<string>,
59+
},
5560
checkForUpdates: () => ipcRenderer.invoke('codesign:check-for-updates'),
5661
downloadUpdate: () => ipcRenderer.invoke('codesign:download-update'),
5762
installUpdate: () => ipcRenderer.invoke('codesign:install-update'),

‎apps/desktop/src/renderer/src/App.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useT } from '@open-codesign/i18n';
12
import { useEffect, useMemo, useState } from 'react';
23
import { CommandPalette } from './components/CommandPalette';
34
import { PreviewPane } from './components/PreviewPane';
@@ -10,6 +11,7 @@ import { Onboarding } from './onboarding';
1011
import { useCodesignStore } from './store';
1112

1213
export function App() {
14+
const t = useT();
1315
const config = useCodesignStore((s) => s.config);
1416
const configLoaded = useCodesignStore((s) => s.configLoaded);
1517
const loadConfig = useCodesignStore((s) => s.loadConfig);
@@ -90,7 +92,7 @@ export function App() {
9092
if (!configLoaded) {
9193
return (
9294
<div className="h-full flex items-center justify-center bg-[var(--color-background)] text-[var(--text-sm)] text-[var(--color-text-muted)]">
93-
Loading…
95+
{t('common.loading')}
9496
</div>
9597
);
9698
}

‎apps/desktop/src/renderer/src/components/CanvasErrorBar.tsx‎

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,9 @@
1-
/**
2-
* CanvasErrorBar — slim red strip shown above the preview iframe when the
3-
* sandbox runtime postMessages an IFRAME_ERROR.
4-
*
5-
* Loud surface (PRINCIPLES §10): every JS exception thrown inside the
6-
* generated HTML lands here with file + line. Users can dismiss the bar
7-
* (it clears the store slice) but errors are never auto-hidden.
8-
*/
9-
1+
import { useT } from '@open-codesign/i18n';
102
import { X } from 'lucide-react';
113
import { useCodesignStore } from '../store';
124

135
export function CanvasErrorBar() {
6+
const t = useT();
147
const errors = useCodesignStore((s) => s.iframeErrors);
158
const clear = useCodesignStore((s) => s.clearIframeErrors);
169
if (errors.length === 0) return null;
@@ -24,7 +17,8 @@ export function CanvasErrorBar() {
2417
<span className="mt-0.5 inline-block w-2 h-2 rounded-full bg-[var(--color-error)] shrink-0" />
2518
<div className="flex-1 min-w-0">
2619
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--color-error)]">
27-
Preview runtime error{errors.length > 1 ? ` (${errors.length})` : ''}
20+
{t('preview.runtimeError')}
21+
{errors.length > 1 ? ` (${errors.length})` : ''}
2822
</div>
2923
<div className="text-sm text-[var(--color-text-primary)] truncate" title={latest}>
3024
{latest}
@@ -33,7 +27,7 @@ export function CanvasErrorBar() {
3327
<button
3428
type="button"
3529
onClick={clear}
36-
aria-label="Dismiss preview errors"
30+
aria-label={t('preview.dismissErrors')}
3731
className="shrink-0 p-1 rounded-[var(--radius-md)] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
3832
>
3933
<X className="w-4 h-4" />

‎apps/desktop/src/renderer/src/components/CommandPalette.tsx‎

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useT } from '@open-codesign/i18n';
12
import { Download, Moon, Plus, Settings as SettingsIcon } from 'lucide-react';
23
import { useEffect, useMemo, useState } from 'react';
34
import { useCodesignStore } from '../store';
@@ -11,6 +12,7 @@ interface PaletteAction {
1112
}
1213

1314
export function CommandPalette() {
15+
const t = useT();
1416
const open = useCodesignStore((s) => s.commandPaletteOpen);
1517
const close = useCodesignStore((s) => s.closeCommandPalette);
1618
const openSettings = useCodesignStore((s) => s.openSettings);
@@ -24,8 +26,8 @@ export function CommandPalette() {
2426
() => [
2527
{
2628
id: 'new-design',
27-
label: 'New Design',
28-
hint: 'Clear chat and start fresh',
29+
label: t('commands.items.newDesign'),
30+
hint: t('commands.hints.newDesign'),
2931
icon: Plus,
3032
run: () => {
3133
useCodesignStore.setState({
@@ -35,44 +37,44 @@ export function CommandPalette() {
3537
iframeErrors: [],
3638
selectedElement: null,
3739
});
38-
pushToast({ variant: 'info', title: 'Workspace cleared' });
40+
pushToast({ variant: 'info', title: t('commands.cleared') });
3941
},
4042
},
4143
{
4244
id: 'toggle-theme',
43-
label: 'Toggle Theme',
44-
hint: 'Switch between light and dark',
45+
label: t('commands.items.toggleTheme'),
46+
hint: t('commands.hints.toggleTheme'),
4547
icon: Moon,
4648
run: toggleTheme,
4749
},
4850
{
4951
id: 'open-settings',
50-
label: 'Open Settings',
51-
hint: 'Models, appearance, storage',
52+
label: t('commands.items.openSettings'),
53+
hint: t('commands.hints.openSettings'),
5254
icon: SettingsIcon,
5355
run: openSettings,
5456
},
5557
{
5658
id: 'export',
57-
label: 'Export',
58-
hint: 'PDF and PPTX coming soon',
59+
label: t('commands.items.export'),
60+
hint: t('commands.hints.export'),
5961
icon: Download,
6062
run: () =>
6163
pushToast({
6264
variant: 'info',
63-
title: 'Export not available yet',
64-
description: 'PDF and PPTX exporters land in v0.2. HTML export is in the toolbar.',
65+
title: t('commands.exportUseToolbarTitle'),
66+
description: t('commands.exportUseToolbarBody'),
6567
}),
6668
},
6769
],
68-
[openSettings, toggleTheme, pushToast],
70+
[openSettings, pushToast, t, toggleTheme],
6971
);
7072

7173
const filtered = useMemo(() => {
7274
const q = query.trim().toLowerCase();
7375
if (!q) return actions;
7476
return actions.filter(
75-
(a) => a.label.toLowerCase().includes(q) || a.hint.toLowerCase().includes(q),
77+
(action) => action.label.toLowerCase().includes(q) || action.hint.toLowerCase().includes(q),
7678
);
7779
}, [actions, query]);
7880

@@ -89,8 +91,8 @@ export function CommandPalette() {
8991

9092
if (!open) return null;
9193

92-
function runAt(i: number) {
93-
const action = filtered[i];
94+
function runAt(index: number) {
95+
const action = filtered[index];
9496
if (!action) return;
9597
action.run();
9698
close();
@@ -101,7 +103,7 @@ export function CommandPalette() {
101103
// biome-ignore lint/a11y/useSemanticElements: native <dialog> top-layer rendering interferes with our overlay stack
102104
role="dialog"
103105
aria-modal="true"
104-
aria-label="Command palette"
106+
aria-label={t('commands.title')}
105107
className="fixed inset-0 z-50 flex items-start justify-center pt-24 px-6 bg-[var(--color-overlay)] animate-[overlay-in_120ms_ease-out]"
106108
onClick={close}
107109
onKeyDown={(e) => {
@@ -114,10 +116,10 @@ export function CommandPalette() {
114116
onKeyDown={(e) => {
115117
if (e.key === 'ArrowDown') {
116118
e.preventDefault();
117-
setCursor((c) => Math.min(c + 1, filtered.length - 1));
119+
setCursor((current) => Math.min(current + 1, filtered.length - 1));
118120
} else if (e.key === 'ArrowUp') {
119121
e.preventDefault();
120-
setCursor((c) => Math.max(c - 1, 0));
122+
setCursor((current) => Math.max(current - 1, 0));
121123
} else if (e.key === 'Enter') {
122124
e.preventDefault();
123125
runAt(cursor);
@@ -136,24 +138,24 @@ export function CommandPalette() {
136138
setQuery(e.target.value);
137139
setCursor(0);
138140
}}
139-
placeholder="Type a command…"
141+
placeholder={t('commands.placeholder')}
140142
className="w-full px-5 h-12 bg-transparent border-b border-[var(--color-border)] text-[var(--text-sm)] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] focus:outline-none"
141143
/>
142144
<ul className="max-h-72 overflow-y-auto py-2">
143145
{filtered.length === 0 ? (
144146
<li className="px-5 py-3 text-[var(--text-sm)] text-[var(--color-text-muted)]">
145-
No matches.
147+
{t('commands.noMatches')}
146148
</li>
147149
) : (
148-
filtered.map((a, i) => {
149-
const Icon = a.icon;
150-
const active = i === cursor;
150+
filtered.map((action, index) => {
151+
const Icon = action.icon;
152+
const active = index === cursor;
151153
return (
152-
<li key={a.id}>
154+
<li key={action.id}>
153155
<button
154156
type="button"
155-
onMouseEnter={() => setCursor(i)}
156-
onClick={() => runAt(i)}
157+
onMouseEnter={() => setCursor(index)}
158+
onClick={() => runAt(index)}
157159
className={`w-full flex items-center gap-3 px-5 py-2.5 text-left transition-colors ${
158160
active
159161
? 'bg-[var(--color-surface-active)]'
@@ -163,10 +165,10 @@ export function CommandPalette() {
163165
<Icon className="w-4 h-4 text-[var(--color-text-secondary)] shrink-0" />
164166
<span className="flex-1 min-w-0">
165167
<span className="block text-[var(--text-sm)] text-[var(--color-text-primary)]">
166-
{a.label}
168+
{action.label}
167169
</span>
168170
<span className="block text-[var(--text-xs)] text-[var(--color-text-muted)]">
169-
{a.hint}
171+
{action.hint}
170172
</span>
171173
</span>
172174
</button>

‎apps/desktop/src/renderer/src/components/InlineCommentComposer.tsx‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useT } from '@open-codesign/i18n';
12
import type { SelectedElement } from '@open-codesign/shared';
23
import { MessageSquareText, X } from 'lucide-react';
34
import { useState } from 'react';
@@ -16,6 +17,7 @@ interface InlineCommentComposerCardProps {
1617
}
1718

1819
function InlineCommentComposerCard({ selectedElement }: InlineCommentComposerCardProps) {
20+
const t = useT();
1921
const clearCanvasElement = useCodesignStore((s) => s.clearCanvasElement);
2022
const applyInlineComment = useCodesignStore((s) => s.applyInlineComment);
2123
const isGenerating = useCodesignStore((s) => s.isGenerating);
@@ -27,7 +29,7 @@ function InlineCommentComposerCard({ selectedElement }: InlineCommentComposerCar
2729
<div className="min-w-0">
2830
<div className="inline-flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-primary)]">
2931
<MessageSquareText className="h-4 w-4 text-[var(--color-accent)]" />
30-
Comment on <code className="text-[11px]">{selectedElement.tag}</code>
32+
{t('inlineComment.title')} <code className="text-[11px]">{selectedElement.tag}</code>
3133
</div>
3234
<p
3335
className="mt-1 truncate text-[11px] text-[var(--color-text-muted)]"
@@ -40,21 +42,20 @@ function InlineCommentComposerCard({ selectedElement }: InlineCommentComposerCar
4042
type="button"
4143
onClick={clearCanvasElement}
4244
className="rounded-[var(--radius-md)] p-1 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]"
43-
aria-label="Close inline comment composer"
45+
aria-label={t('inlineComment.closeComposer')}
4446
>
4547
<X className="h-4 w-4" />
4648
</button>
4749
</div>
4850

4951
<div className="space-y-3 p-4">
5052
<p className="text-[12px] leading-[1.5] text-[var(--color-text-secondary)]">
51-
Clicked elements stay selected in the canvas. Describe the visual or content change you
52-
want, and open-codesign will rewrite the artifact around that target.
53+
{t('inlineComment.description')}
5354
</p>
5455
<textarea
5556
value={draft}
5657
onChange={(e) => setDraft(e.target.value)}
57-
placeholder="Make this section more compact, sharpen the headline, and align it with the linked design system..."
58+
placeholder={t('inlineComment.placeholder')}
5859
rows={4}
5960
disabled={isGenerating}
6061
className="w-full resize-none rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-background)] px-3 py-2 text-[13px] leading-[1.5] text-[var(--color-text-primary)] placeholder:text-[var(--color-text-muted)] transition-[box-shadow,border-color] duration-150 focus:border-[var(--color-accent)] focus:shadow-[0_0_0_3px_var(--color-focus-ring)] focus:outline-none"
@@ -65,15 +66,15 @@ function InlineCommentComposerCard({ selectedElement }: InlineCommentComposerCar
6566
onClick={clearCanvasElement}
6667
className="text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]"
6768
>
68-
Cancel
69+
{t('common.cancel')}
6970
</button>
7071
<button
7172
type="button"
7273
disabled={!draft.trim() || isGenerating}
7374
onClick={() => void applyInlineComment(draft)}
7475
className="inline-flex items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-accent)] px-3 py-2 text-[12px] font-medium text-white shadow-[var(--shadow-soft)] transition-colors hover:bg-[var(--color-accent-hover)] disabled:pointer-events-none disabled:opacity-40"
7576
>
76-
{isGenerating ? 'Applying...' : 'Apply change'}
77+
{isGenerating ? t('inlineComment.applying') : t('inlineComment.applyChange')}
7778
</button>
7879
</div>
7980
</div>
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { setLocale as applyLocale, getCurrentLocale, useT } from '@open-codesign/i18n';
2+
import type { Locale } from '@open-codesign/i18n';
3+
import { Globe } from 'lucide-react';
4+
import { useEffect, useState } from 'react';
5+
6+
function nextLocale(locale: Locale): Locale {
7+
return locale === 'en' ? 'zh-CN' : 'en';
8+
}
9+
10+
function localeLabel(locale: Locale): string {
11+
return locale === 'zh-CN' ? 'ZH' : 'EN';
12+
}
13+
14+
export function LanguageToggle() {
15+
const t = useT();
16+
const [locale, setLocaleState] = useState<Locale>(getCurrentLocale());
17+
18+
useEffect(() => {
19+
setLocaleState(getCurrentLocale());
20+
}, []);
21+
22+
async function handleToggle(): Promise<void> {
23+
const target = nextLocale(locale);
24+
const persisted = window.codesign ? await window.codesign.locale.set(target) : target;
25+
const applied = await applyLocale(persisted);
26+
setLocaleState(applied);
27+
}
28+
29+
return (
30+
<button
31+
type="button"
32+
onClick={() => void handleToggle()}
33+
className="inline-flex items-center gap-2 h-8 px-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] text-[12px] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
34+
aria-label={t('settings.language.label')}
35+
title={t('settings.language.label')}
36+
>
37+
<Globe className="w-3.5 h-3.5 text-[var(--color-text-secondary)]" />
38+
<span>{localeLabel(locale)}</span>
39+
</button>
40+
);
41+
}

‎apps/desktop/src/renderer/src/components/PreviewPane.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useT } from '@open-codesign/i18n';
12
import { buildSrcdoc, isIframeErrorMessage, isOverlayMessage } from '@open-codesign/runtime';
23
import { useEffect, useRef } from 'react';
34
import { EmptyState } from '../preview/EmptyState';
@@ -20,6 +21,7 @@ export function isTrustedPreviewMessageSource(
2021
}
2122

2223
export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
24+
const t = useT();
2325
const previewHtml = useCodesignStore((s) => s.previewHtml);
2426
const isGenerating = useCodesignStore((s) => s.isGenerating);
2527
const errorMessage = useCodesignStore((s) => s.errorMessage);
@@ -74,7 +76,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
7476
<div className="h-full p-6">
7577
<div className="relative h-full">
7678
<div className="absolute left-5 top-5 z-10 rounded-full border border-[var(--color-border)] bg-[rgba(255,255,255,0.88)] px-3 py-1 text-[11px] text-[var(--color-text-secondary)] shadow-[var(--shadow-soft)] backdrop-blur">
77-
Click any element in the preview to leave an inline comment.
79+
{t('preview.clickToComment')}
7880
</div>
7981
<iframe
8082
ref={iframeRef}

0 commit comments

Comments
 (0)