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
10 changes: 10 additions & 0 deletions apps/desktop/src/renderer/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,16 @@
</head>
<body>
<div id="root"></div>
<script>
(function () {
try {
var t = localStorage.getItem('open-codesign:theme');
if (t === 'dark') document.documentElement.classList.add('dark');
} catch (e) {
/* localStorage unavailable; default light theme */
}
})();
</script>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>
191 changes: 73 additions & 118 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,131 +1,86 @@
import { buildSrcdoc } from '@open-codesign/runtime';
import { BUILTIN_DEMOS } from '@open-codesign/templates';
import { Button } from '@open-codesign/ui';
import { Send, Sparkles } from 'lucide-react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { CommandPalette } from './components/CommandPalette';
import { PreviewPane } from './components/PreviewPane';
import { Settings } from './components/Settings';
import { Sidebar } from './components/Sidebar';
import { ToastViewport } from './components/Toast';
import { TopBar } from './components/TopBar';
import { useKeyboard } from './hooks/useKeyboard';
import { useCodesignStore } from './store';

export function App() {
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 isGenerating = useCodesignStore((s) => s.isGenerating);
const openSettings = useCodesignStore((s) => s.openSettings);
const closeSettings = useCodesignStore((s) => s.closeSettings);
const openCommandPalette = useCodesignStore((s) => s.openCommandPalette);
const closeCommandPalette = useCodesignStore((s) => s.closeCommandPalette);
const settingsOpen = useCodesignStore((s) => s.settingsOpen);
const commandPaletteOpen = useCodesignStore((s) => s.commandPaletteOpen);

const [prompt, setPrompt] = useState('');

function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!prompt.trim() || isGenerating) return;
void sendPrompt(prompt);
function submit() {
const trimmed = prompt.trim();
if (!trimmed || isGenerating) return;
void sendPrompt(trimmed);
setPrompt('');
}

return (
<div className="h-full grid grid-cols-[380px_1fr] bg-[var(--color-background)]">
<aside className="flex flex-col border-r border-[var(--color-border)] bg-[var(--color-background-secondary)]">
<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>
</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:
</p>
<ul className="space-y-2">
{BUILTIN_DEMOS.map((demo) => (
<li key={demo.id}>
<button
type="button"
onClick={() => setPrompt(demo.prompt)}
className="w-full text-left px-3 py-2 rounded-[var(--radius-md)] bg-[var(--color-surface)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)] transition-colors"
>
<div className="text-sm font-medium text-[var(--color-text-primary)]">
{demo.title}
</div>
<div className="text-xs text-[var(--color-text-muted)] mt-0.5">
{demo.description}
</div>
</button>
</li>
))}
</ul>
</div>
) : (
messages.map((m, i) => (
<div
// biome-ignore lint/suspicious/noArrayIndexKey: tier-1 chat list with no reordering
key={`${m.role}-${i}`}
className={`px-3 py-2 rounded-[var(--radius-md)] text-sm ${
m.role === 'user'
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
: 'bg-[var(--color-surface)] border border-[var(--color-border)] text-[var(--color-text-primary)]'
}`}
>
{m.content}
</div>
))
)}
</div>

<form
onSubmit={handleSubmit}
className="border-t border-[var(--color-border)] p-3 flex gap-2"
>
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Describe what to design…"
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()}>
<Send className="w-4 h-4" />
</Button>
</form>
</aside>
const bindings = useMemo(
() => [
{
combo: 'mod+enter',
handler: () => {
const trimmed = prompt.trim();
if (!trimmed || isGenerating) return;
void sendPrompt(trimmed);
setPrompt('');
},
},
{
combo: 'mod+,',
handler: () => openSettings(),
},
{
combo: 'mod+k',
handler: () => openCommandPalette(),
},
{
combo: 'escape',
handler: () => {
if (settingsOpen) closeSettings();
else if (commandPaletteOpen) closeCommandPalette();
},
preventDefault: false,
},
],
[
prompt,
isGenerating,
sendPrompt,
settingsOpen,
commandPaletteOpen,
openSettings,
openCommandPalette,
closeSettings,
closeCommandPalette,
],
);
useKeyboard(bindings);

<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
</span>
</header>
<div className="flex-1 p-6 overflow-auto">
{previewHtml ? (
<iframe
key={previewHtml.length}
title="design-preview"
sandbox="allow-scripts"
srcDoc={buildSrcdoc(previewHtml)}
className="w-full h-full bg-white rounded-[var(--radius-2xl)] shadow-[var(--shadow-card)] border border-[var(--color-border)]"
/>
) : (
<div className="h-full flex items-center justify-center">
<div className="text-center max-w-md">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-[var(--color-surface)] border border-[var(--color-border)] flex items-center justify-center">
<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
</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.
</p>
</div>
</div>
)}
</div>
</main>
return (
<div className="h-full flex flex-col bg-[var(--color-background)]">
<TopBar />
<div className="flex-1 grid grid-cols-[380px_1fr] min-h-0">
<Sidebar prompt={prompt} setPrompt={setPrompt} onSubmit={submit} />
<main className="flex flex-col min-h-0">
<PreviewPane onPickStarter={(p) => setPrompt(p)} />
</main>
</div>
<Settings />
<CommandPalette />
<ToastViewport />
</div>
);
}
175 changes: 175 additions & 0 deletions apps/desktop/src/renderer/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { Download, Moon, Plus, Settings as SettingsIcon } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { useCodesignStore } from '../store';

interface PaletteAction {
id: string;
label: string;
hint: string;
icon: typeof Plus;
run: () => void;
}

export function CommandPalette() {
const open = useCodesignStore((s) => s.commandPaletteOpen);
const close = useCodesignStore((s) => s.closeCommandPalette);
const openSettings = useCodesignStore((s) => s.openSettings);
const toggleTheme = useCodesignStore((s) => s.toggleTheme);
const pushToast = useCodesignStore((s) => s.pushToast);

const [query, setQuery] = useState('');
const [cursor, setCursor] = useState(0);

const actions: PaletteAction[] = useMemo(
() => [
{
id: 'new-design',
label: 'New Design',
hint: 'Clear chat and start fresh',
icon: Plus,
run: () => {
useCodesignStore.setState({
messages: [],
previewHtml: null,
errorMessage: null,
});
pushToast({ variant: 'info', title: 'Workspace cleared' });
},
},
{
id: 'toggle-theme',
label: 'Toggle Theme',
hint: 'Switch between light and dark',
icon: Moon,
run: toggleTheme,
},
{
id: 'open-settings',
label: 'Open Settings',
hint: 'Models, appearance, storage',
icon: SettingsIcon,
run: openSettings,
},
{
id: 'export',
label: 'Export',
hint: 'Coming soon',
icon: Download,
run: () =>
pushToast({
variant: 'info',
title: 'Export not available yet',
description: 'PDF and PPTX exporters land in v0.2.',
}),
},
],
[openSettings, toggleTheme, pushToast],
);

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),
);
}, [actions, query]);

useEffect(() => {
if (!open) {
setQuery('');
setCursor(0);
}
}, [open]);

useEffect(() => {
if (cursor >= filtered.length) setCursor(0);
}, [cursor, filtered.length]);

if (!open) return null;

function runAt(i: number) {
const action = filtered[i];
if (!action) return;
action.run();
close();
}

return (
<div
// biome-ignore lint/a11y/useSemanticElements: native <dialog> top-layer rendering interferes with our overlay stack
role="dialog"
aria-modal="true"
aria-label="Command palette"
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) => {
if (e.key === 'Escape') close();
}}
>
<div
className="w-full max-w-lg rounded-[var(--radius-2xl)] bg-[var(--color-background)] border border-[var(--color-border)] shadow-[var(--shadow-elevated)] overflow-hidden animate-[panel-in_160ms_ease-out]"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setCursor((c) => Math.min(c + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setCursor((c) => Math.max(c - 1, 0));
} else if (e.key === 'Enter') {
e.preventDefault();
runAt(cursor);
} else if (e.key === 'Escape') {
close();
}
}}
role="document"
>
<input
// biome-ignore lint/a11y/noAutofocus: command palette is intentionally focused on open
autoFocus
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setCursor(0);
}}
placeholder="Type a command…"
className="w-full px-5 h-12 bg-transparent border-b border-[var(--color-border)] 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-sm text-[var(--color-text-muted)]">No matches.</li>
) : (
filtered.map((a, i) => {
const Icon = a.icon;
const active = i === cursor;
return (
<li key={a.id}>
<button
type="button"
onMouseEnter={() => setCursor(i)}
onClick={() => runAt(i)}
className={`w-full flex items-center gap-3 px-5 py-2.5 text-left transition-colors ${
active
? 'bg-[var(--color-surface-active)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
>
<Icon className="w-4 h-4 text-[var(--color-text-secondary)] shrink-0" />
<span className="flex-1 min-w-0">
<span className="block text-sm text-[var(--color-text-primary)]">
{a.label}
</span>
<span className="block text-xs text-[var(--color-text-muted)]">{a.hint}</span>
</span>
</button>
</li>
);
})
)}
</ul>
</div>
</div>
);
}
Loading
Loading