Skip to content
Merged
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 @@ -16,6 +16,7 @@
"@open-codesign/artifacts": "workspace:*",
"@open-codesign/core": "workspace:*",
"@open-codesign/exporters": "workspace:*",
"@open-codesign/i18n": "workspace:*",
"@open-codesign/providers": "workspace:*",
"@open-codesign/runtime": "workspace:*",
"@open-codesign/shared": "workspace:*",
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { autoUpdater } from 'electron-updater';
import { scanDesignSystem } from './design-system';
import { BrowserWindow, app, dialog, ipcMain, shell } from './electron-runtime';
import { registerExporterIpc } from './exporter-ipc';
import { registerLocaleIpc } from './locale-ipc';
import { getLogPath, getLogger, initLogger } from './logger';
import {
getApiKeyForProvider,
Expand All @@ -32,6 +33,7 @@ function createWindow(): void {
height: 820,
minWidth: 960,
minHeight: 640,
autoHideMenuBar: process.platform !== 'darwin',
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
backgroundColor: BRAND.backgroundColor,
show: false,
Expand Down Expand Up @@ -254,6 +256,7 @@ void app.whenReady().then(async () => {
initLogger();
await loadConfigOnBoot();
registerIpcHandlers();
registerLocaleIpc();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Activating registerLocaleIpc() makes locale persistence live, but that implementation currently writes to a hardcoded ~/.config/open-codesign/locale.json path (apps/desktop/src/main/locale-ipc.ts:19). The repo explicitly says not to hard-code paths and to respect XDG / Electron path helpers. Can this be moved under app.getPath("userData") or the same config-dir helper used elsewhere?

Suggested fix:

import { join } from 'node:path';
import { app } from './electron-runtime';

const LOCALE_FILE = join(app.getPath('userData'), 'locale.json');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Activating registerLocaleIpc() makes locale persistence live, but locale-ipc.ts still writes to a hardcoded ~/.config/open-codesign/locale.json path (apps/desktop/src/main/locale-ipc.ts:19). CLAUDE.md explicitly forbids hardcoded paths. Can this move under app.getPath("userData") or the same config-dir helper used elsewhere?

Suggested fix:

import { join } from 'node:path';
import { app } from './electron-runtime';

const LOCALE_FILE = join(app.getPath('userData'), 'locale.json');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Activating registerLocaleIpc() makes locale persistence live, but locale-ipc.ts still writes to a hardcoded ~/.config/open-codesign/locale.json path (apps/desktop/src/main/locale-ipc.ts:19). CLAUDE.md forbids hardcoded paths. Can this move under app.getPath('userData')?

Suggested fix:

import { join } from 'node:path';
import { app } from './electron-runtime';

const LOCALE_FILE = join(app.getPath('userData'), 'locale.json');

registerOnboardingIpc();
registerExporterIpc(() => mainWindow);
setupAutoUpdater();
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ const api = {
ipcRenderer.invoke('codesign:clear-design-system') as Promise<OnboardingState>,
export: (payload: { format: ExportFormat; htmlContent: string; defaultFilename?: string }) =>
ipcRenderer.invoke('codesign:export', payload) as Promise<ExportInvokeResponse>,
locale: {
getSystem: () => ipcRenderer.invoke('locale:get-system') as Promise<string>,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new locale IPC channels are unversioned (locale:get-system, locale:get-current, locale:set). docs/PRINCIPLES.md §5b requires every IPC channel to be versioned so protocol changes can ship side-by-side without breaking older renderers.

Suggested fix:

const LOCALE_GET_SYSTEM = 'locale:v1:get-system';
const LOCALE_GET_CURRENT = 'locale:v1:get-current';
const LOCALE_SET = 'locale:v1:set';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These new locale IPC channels are unversioned. docs/PRINCIPLES.md §5b requires every IPC contract to ship as vN so future renderer/main mismatches can coexist safely.

Suggested fix:

const LOCALE_GET_SYSTEM = 'locale:v1:get-system';
const LOCALE_GET_CURRENT = 'locale:v1:get-current';
const LOCALE_SET = 'locale:v1:set';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These locale IPC channels are still unversioned. docs/PRINCIPLES.md §5b requires every IPC contract to ship as vN so future renderer/main mismatches can coexist safely.

Suggested fix:

const LOCALE_GET_SYSTEM = 'locale:v1:get-system';
const LOCALE_GET_CURRENT = 'locale:v1:get-current';
const LOCALE_SET = 'locale:v1:set';

getCurrent: () => ipcRenderer.invoke('locale:get-current') as Promise<string>,
set: (locale: string) => ipcRenderer.invoke('locale:set', locale) as Promise<string>,
},
checkForUpdates: () => ipcRenderer.invoke('codesign:check-for-updates'),
downloadUpdate: () => ipcRenderer.invoke('codesign:download-update'),
installUpdate: () => ipcRenderer.invoke('codesign:install-update'),
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useT } from '@open-codesign/i18n';
import { useEffect, useMemo, useState } from 'react';
import { CommandPalette } from './components/CommandPalette';
import { PreviewPane } from './components/PreviewPane';
Expand All @@ -10,6 +11,7 @@ import { Onboarding } from './onboarding';
import { useCodesignStore } from './store';

export function App() {
const t = useT();
const config = useCodesignStore((s) => s.config);
const configLoaded = useCodesignStore((s) => s.configLoaded);
const loadConfig = useCodesignStore((s) => s.loadConfig);
Expand Down Expand Up @@ -90,7 +92,7 @@ export function App() {
if (!configLoaded) {
return (
<div className="h-full flex items-center justify-center bg-[var(--color-background)] text-[var(--text-sm)] text-[var(--color-text-muted)]">
Loading…
{t('common.loading')}
</div>
);
}
Expand Down
16 changes: 5 additions & 11 deletions apps/desktop/src/renderer/src/components/CanvasErrorBar.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,9 @@
/**
* CanvasErrorBar — slim red strip shown above the preview iframe when the
* sandbox runtime postMessages an IFRAME_ERROR.
*
* Loud surface (PRINCIPLES §10): every JS exception thrown inside the
* generated HTML lands here with file + line. Users can dismiss the bar
* (it clears the store slice) but errors are never auto-hidden.
*/

import { useT } from '@open-codesign/i18n';
import { X } from 'lucide-react';
import { useCodesignStore } from '../store';

export function CanvasErrorBar() {
const t = useT();
const errors = useCodesignStore((s) => s.iframeErrors);
const clear = useCodesignStore((s) => s.clearIframeErrors);
if (errors.length === 0) return null;
Expand All @@ -24,7 +17,8 @@ export function CanvasErrorBar() {
<span className="mt-0.5 inline-block w-2 h-2 rounded-full bg-[var(--color-error)] shrink-0" />
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold uppercase tracking-wide text-[var(--color-error)]">
Preview runtime error{errors.length > 1 ? ` (${errors.length})` : ''}
{t('preview.runtimeError')}
{errors.length > 1 ? ` (${errors.length})` : ''}
</div>
<div className="text-sm text-[var(--color-text-primary)] truncate" title={latest}>
{latest}
Expand All @@ -33,7 +27,7 @@ export function CanvasErrorBar() {
<button
type="button"
onClick={clear}
aria-label="Dismiss preview errors"
aria-label={t('preview.dismissErrors')}
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"
>
<X className="w-4 h-4" />
Expand Down
58 changes: 30 additions & 28 deletions apps/desktop/src/renderer/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useT } from '@open-codesign/i18n';
import { Download, Moon, Plus, Settings as SettingsIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useCodesignStore } from '../store';
Expand All @@ -11,6 +12,7 @@ interface PaletteAction {
}

export function CommandPalette() {
const t = useT();
const open = useCodesignStore((s) => s.commandPaletteOpen);
const close = useCodesignStore((s) => s.closeCommandPalette);
const openSettings = useCodesignStore((s) => s.openSettings);
Expand All @@ -24,8 +26,8 @@ export function CommandPalette() {
() => [
{
id: 'new-design',
label: 'New Design',
hint: 'Clear chat and start fresh',
label: t('commands.items.newDesign'),
hint: t('commands.hints.newDesign'),
icon: Plus,
run: () => {
useCodesignStore.setState({
Expand All @@ -35,44 +37,44 @@ export function CommandPalette() {
iframeErrors: [],
selectedElement: null,
});
pushToast({ variant: 'info', title: 'Workspace cleared' });
pushToast({ variant: 'info', title: t('commands.cleared') });
},
},
{
id: 'toggle-theme',
label: 'Toggle Theme',
hint: 'Switch between light and dark',
label: t('commands.items.toggleTheme'),
hint: t('commands.hints.toggleTheme'),
icon: Moon,
run: toggleTheme,
},
{
id: 'open-settings',
label: 'Open Settings',
hint: 'Models, appearance, storage',
label: t('commands.items.openSettings'),
hint: t('commands.hints.openSettings'),
icon: SettingsIcon,
run: openSettings,
},
{
id: 'export',
label: 'Export',
hint: 'PDF and PPTX coming soon',
label: t('commands.items.export'),
hint: t('commands.hints.export'),
icon: Download,
run: () =>
pushToast({
variant: 'info',
title: 'Export not available yet',
description: 'PDF and PPTX exporters land in v0.2. HTML export is in the toolbar.',
title: t('commands.exportUseToolbarTitle'),
description: t('commands.exportUseToolbarBody'),
}),
},
],
[openSettings, toggleTheme, pushToast],
[openSettings, pushToast, t, toggleTheme],
);

const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return actions;
return actions.filter(
(a) => a.label.toLowerCase().includes(q) || a.hint.toLowerCase().includes(q),
(action) => action.label.toLowerCase().includes(q) || action.hint.toLowerCase().includes(q),
);
}, [actions, query]);

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

if (!open) return null;

function runAt(i: number) {
const action = filtered[i];
function runAt(index: number) {
const action = filtered[index];
if (!action) return;
action.run();
close();
Expand All @@ -101,7 +103,7 @@ export function CommandPalette() {
// biome-ignore lint/a11y/useSemanticElements: native <dialog> top-layer rendering interferes with our overlay stack
role="dialog"
aria-modal="true"
aria-label="Command palette"
aria-label={t('commands.title')}
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]"
onClick={close}
onKeyDown={(e) => {
Expand All @@ -114,10 +116,10 @@ export function CommandPalette() {
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setCursor((c) => Math.min(c + 1, filtered.length - 1));
setCursor((current) => Math.min(current + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setCursor((c) => Math.max(c - 1, 0));
setCursor((current) => Math.max(current - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
runAt(cursor);
Expand All @@ -136,24 +138,24 @@ export function CommandPalette() {
setQuery(e.target.value);
setCursor(0);
}}
placeholder="Type a command…"
placeholder={t('commands.placeholder')}
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"
/>
<ul className="max-h-72 overflow-y-auto py-2">
{filtered.length === 0 ? (
<li className="px-5 py-3 text-[var(--text-sm)] text-[var(--color-text-muted)]">
No matches.
{t('commands.noMatches')}
</li>
) : (
filtered.map((a, i) => {
const Icon = a.icon;
const active = i === cursor;
filtered.map((action, index) => {
const Icon = action.icon;
const active = index === cursor;
return (
<li key={a.id}>
<li key={action.id}>
<button
type="button"
onMouseEnter={() => setCursor(i)}
onClick={() => runAt(i)}
onMouseEnter={() => setCursor(index)}
onClick={() => runAt(index)}
className={`w-full flex items-center gap-3 px-5 py-2.5 text-left transition-colors ${
active
? 'bg-[var(--color-surface-active)]'
Expand All @@ -163,10 +165,10 @@ export function CommandPalette() {
<Icon className="w-4 h-4 text-[var(--color-text-secondary)] shrink-0" />
<span className="flex-1 min-w-0">
<span className="block text-[var(--text-sm)] text-[var(--color-text-primary)]">
{a.label}
{action.label}
</span>
<span className="block text-[var(--text-xs)] text-[var(--color-text-muted)]">
{a.hint}
{action.hint}
</span>
</span>
</button>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useT } from '@open-codesign/i18n';
import type { SelectedElement } from '@open-codesign/shared';
import { MessageSquareText, X } from 'lucide-react';
import { useState } from 'react';
Expand All @@ -16,6 +17,7 @@ interface InlineCommentComposerCardProps {
}

function InlineCommentComposerCard({ selectedElement }: InlineCommentComposerCardProps) {
const t = useT();
const clearCanvasElement = useCodesignStore((s) => s.clearCanvasElement);
const applyInlineComment = useCodesignStore((s) => s.applyInlineComment);
const isGenerating = useCodesignStore((s) => s.isGenerating);
Expand All @@ -27,7 +29,7 @@ function InlineCommentComposerCard({ selectedElement }: InlineCommentComposerCar
<div className="min-w-0">
<div className="inline-flex items-center gap-2 text-[12px] font-medium text-[var(--color-text-primary)]">
<MessageSquareText className="h-4 w-4 text-[var(--color-accent)]" />
Comment on <code className="text-[11px]">{selectedElement.tag}</code>
{t('inlineComment.title')} <code className="text-[11px]">{selectedElement.tag}</code>
</div>
<p
className="mt-1 truncate text-[11px] text-[var(--color-text-muted)]"
Expand All @@ -40,21 +42,20 @@ function InlineCommentComposerCard({ selectedElement }: InlineCommentComposerCar
type="button"
onClick={clearCanvasElement}
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)]"
aria-label="Close inline comment composer"
aria-label={t('inlineComment.closeComposer')}
>
<X className="h-4 w-4" />
</button>
</div>

<div className="space-y-3 p-4">
<p className="text-[12px] leading-[1.5] text-[var(--color-text-secondary)]">
Clicked elements stay selected in the canvas. Describe the visual or content change you
want, and open-codesign will rewrite the artifact around that target.
{t('inlineComment.description')}
</p>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Make this section more compact, sharpen the headline, and align it with the linked design system..."
placeholder={t('inlineComment.placeholder')}
rows={4}
disabled={isGenerating}
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"
Expand All @@ -65,15 +66,15 @@ function InlineCommentComposerCard({ selectedElement }: InlineCommentComposerCar
onClick={clearCanvasElement}
className="text-[12px] text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)]"
>
Cancel
{t('common.cancel')}
</button>
<button
type="button"
disabled={!draft.trim() || isGenerating}
onClick={() => void applyInlineComment(draft)}
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"
>
{isGenerating ? 'Applying...' : 'Apply change'}
{isGenerating ? t('inlineComment.applying') : t('inlineComment.applyChange')}
</button>
</div>
</div>
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/renderer/src/components/LanguageToggle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { setLocale as applyLocale, getCurrentLocale, useT } from '@open-codesign/i18n';
import type { Locale } from '@open-codesign/i18n';
import { Globe } from 'lucide-react';
import { useEffect, useState } from 'react';

function nextLocale(locale: Locale): Locale {
return locale === 'en' ? 'zh-CN' : 'en';
}

function localeLabel(locale: Locale): string {
return locale === 'zh-CN' ? 'ZH' : 'EN';
}

export function LanguageToggle() {
const t = useT();
const [locale, setLocaleState] = useState<Locale>(getCurrentLocale());

useEffect(() => {
setLocaleState(getCurrentLocale());
}, []);

async function handleToggle(): Promise<void> {
const target = nextLocale(locale);
const persisted = window.codesign ? await window.codesign.locale.set(target) : target;
const applied = await applyLocale(persisted);
setLocaleState(applied);
}

return (
<button
type="button"
onClick={() => void handleToggle()}
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"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

text-[12px] introduces a new hardcoded UI literal on a brand-new component. The repo rule is that visual values come from packages/ui tokens, so this should use an existing token or add one first.

Suggested fix:

className="... text-[var(--text-xs)] ..."

aria-label={t('settings.language.label')}
title={t('settings.language.label')}
>
<Globe className="w-3.5 h-3.5 text-[var(--color-text-secondary)]" />
<span>{localeLabel(locale)}</span>
</button>
);
}
4 changes: 3 additions & 1 deletion apps/desktop/src/renderer/src/components/PreviewPane.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useT } from '@open-codesign/i18n';
import { buildSrcdoc, isIframeErrorMessage, isOverlayMessage } from '@open-codesign/runtime';
import { useEffect, useRef } from 'react';
import { EmptyState } from '../preview/EmptyState';
Expand All @@ -20,6 +21,7 @@ export function isTrustedPreviewMessageSource(
}

export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
const t = useT();
const previewHtml = useCodesignStore((s) => s.previewHtml);
const isGenerating = useCodesignStore((s) => s.isGenerating);
const errorMessage = useCodesignStore((s) => s.errorMessage);
Expand Down Expand Up @@ -74,7 +76,7 @@ export function PreviewPane({ onPickStarter }: PreviewPaneProps) {
<div className="h-full p-6">
<div className="relative h-full">
<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">
Click any element in the preview to leave an inline comment.
{t('preview.clickToComment')}
</div>
<iframe
ref={iframeRef}
Expand Down
Loading
Loading