Skip to content

Commit 1867f38

Browse files
authored
feat(core,exporters,desktop): end-to-end first demo + HTML export (#4)
Wires the full prompt → artifact → preview → export loop so the README's "first demo" actually produces a design when an API key is provided. - packages/templates: externalize the design-generator system prompt as SYSTEM_PROMPTS.designGenerator with a sibling design-generator.md for reviewable diffs. Replaces the inline string previously hard-coded in packages/core. Prompt embeds the research-backed Claude Design rules (single artifact, Tailwind CDN, semantic HTML, CSS variable tokens, WCAG AA, no lorem ipsum). - packages/core: pull SYSTEM_PROMPTS.designGenerator from templates, collapse the duplicated artifact-extraction loop into a `collect()` helper, add generate.test.ts (mocks the providers boundary, asserts empty-prompt error, artifact extraction shape, system-prompt wiring). - packages/exporters: real exportHtml() that ensures a doctype, injects the Tailwind CDN tag if missing, stamps a generator meta/banner, and pretty-prints. PDF / PPTX / ZIP each throw CodesignError with code EXPORTER_NOT_READY ("ships in Phase 2") — no silent fallbacks (PRINCIPLES §10). Top-level exportArtifact() dispatches lazily so unused formats stay out of the cold-start bundle (PRINCIPLES §1). - apps/desktop: codesign:export IPC backed by Electron dialog showSaveDialog, validates payload via CodesignError, propagates Phase-2 errors loudly. Preload exposes window.codesign.export(). Store gains exportActive(format) + a toast slot. PreviewToolbar renders an Export dropdown with HTML enabled and PDF/PPTX/ZIP disabled with "Coming in Phase 2" tooltips. - TIER 1 / dev-only fallback: store reads VITE_OPEN_CODESIGN_DEV_KEY so the demo runs before wt/onboarding lands real keychain plumbing. Marked clearly for removal in the integration commit. - examples/calm-spaces: README documents the demo + expected behaviour + intentional loud failure modes. No new third-party dependencies. All UI uses var(--color-*) tokens. Acceptance test (manual): VITE_OPEN_CODESIGN_DEV_KEY=sk-ant-... \ pnpm --filter @open-codesign/desktop dev → click "Calm Spaces meditation app" → Send → iframe renders → Export → HTML → /tmp/out.html → open in browser → Export → PDF → toast "PDF export ships in Phase 2" Signed-off-by: Haoqing Wang <1506751656@qq.com>
1 parent 7da3384 commit 1867f38

21 files changed

Lines changed: 811 additions & 55 deletions

File tree

apps/desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@iarna/toml": "^2.2.5",
1616
"@open-codesign/artifacts": "workspace:*",
1717
"@open-codesign/core": "workspace:*",
18+
"@open-codesign/exporters": "workspace:*",
1819
"@open-codesign/providers": "workspace:*",
1920
"@open-codesign/runtime": "workspace:*",
2021
"@open-codesign/shared": "workspace:*",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { type ExporterFormat, exportArtifact } from '@open-codesign/exporters';
2+
import { CodesignError } from '@open-codesign/shared';
3+
import { type BrowserWindow, dialog, ipcMain } from 'electron';
4+
5+
const FORMAT_FILTERS: Record<ExporterFormat, Electron.FileFilter[]> = {
6+
html: [{ name: 'HTML', extensions: ['html'] }],
7+
pdf: [{ name: 'PDF', extensions: ['pdf'] }],
8+
pptx: [{ name: 'PowerPoint', extensions: ['pptx'] }],
9+
zip: [{ name: 'ZIP archive', extensions: ['zip'] }],
10+
};
11+
12+
export interface ExportRequest {
13+
format: ExporterFormat;
14+
htmlContent: string;
15+
defaultFilename?: string;
16+
}
17+
18+
export interface ExportResponse {
19+
status: 'saved' | 'cancelled';
20+
path?: string;
21+
bytes?: number;
22+
}
23+
24+
function parseRequest(raw: unknown): ExportRequest {
25+
if (raw === null || typeof raw !== 'object') {
26+
throw new CodesignError('export expects an object payload', 'IPC_BAD_INPUT');
27+
}
28+
const r = raw as Record<string, unknown>;
29+
const format = r['format'];
30+
const html = r['htmlContent'];
31+
const defaultFilename = r['defaultFilename'];
32+
if (format !== 'html' && format !== 'pdf' && format !== 'pptx' && format !== 'zip') {
33+
throw new CodesignError(`Unknown export format: ${String(format)}`, 'EXPORTER_UNKNOWN');
34+
}
35+
if (typeof html !== 'string' || html.length === 0) {
36+
throw new CodesignError('export requires non-empty htmlContent', 'IPC_BAD_INPUT');
37+
}
38+
const out: ExportRequest = { format, htmlContent: html };
39+
if (typeof defaultFilename === 'string' && defaultFilename.length > 0) {
40+
out.defaultFilename = defaultFilename;
41+
}
42+
return out;
43+
}
44+
45+
export function registerExporterIpc(getWindow: () => BrowserWindow | null): void {
46+
ipcMain.handle('codesign:export', async (_evt, raw: unknown): Promise<ExportResponse> => {
47+
const req = parseRequest(raw);
48+
const win = getWindow();
49+
const opts: Electron.SaveDialogOptions = {
50+
title: `Export design as ${req.format.toUpperCase()}`,
51+
defaultPath: req.defaultFilename ?? `design.${req.format}`,
52+
filters: FORMAT_FILTERS[req.format],
53+
};
54+
const picked = win ? await dialog.showSaveDialog(win, opts) : await dialog.showSaveDialog(opts);
55+
if (picked.canceled || !picked.filePath) {
56+
return { status: 'cancelled' };
57+
}
58+
59+
// Tier 1: HTML succeeds, others throw EXPORTER_NOT_READY. We deliberately
60+
// do NOT swallow the error here — let it propagate so the renderer can
61+
// surface it as a toast (PRINCIPLES §10).
62+
const result = await exportArtifact(req.format, req.htmlContent, picked.filePath);
63+
return { status: 'saved', path: result.path, bytes: result.bytes };
64+
});
65+
}

apps/desktop/src/main/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import { detectProviderFromKey } from '@open-codesign/providers';
55
import { BRAND, CodesignError, GeneratePayload } from '@open-codesign/shared';
66
import { BrowserWindow, app, ipcMain, shell } from 'electron';
77
import { autoUpdater } from 'electron-updater';
8+
<<<<<<< HEAD
89
import { getApiKeyForProvider, loadConfigOnBoot, registerOnboardingIpc } from './onboarding-ipc';
10+
||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export)
11+
=======
12+
import { registerExporterIpc } from './exporter-ipc';
13+
>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export)
914

1015
const __filename = fileURLToPath(import.meta.url);
1116
const __dirname = dirname(__filename);
@@ -81,7 +86,12 @@ function setupAutoUpdater(): void {
8186
void app.whenReady().then(async () => {
8287
await loadConfigOnBoot();
8388
registerIpcHandlers();
89+
<<<<<<< HEAD
8490
registerOnboardingIpc();
91+
||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export)
92+
=======
93+
registerExporterIpc(() => mainWindow);
94+
>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export)
8595
setupAutoUpdater();
8696
createWindow();
8797

apps/desktop/src/preload/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
} from '@open-codesign/shared';
77
import { contextBridge, ipcRenderer } from 'electron';
88

9+
<<<<<<< HEAD
910
export interface ValidateKeyResult {
1011
ok: true;
1112
modelCount: number;
@@ -16,6 +17,16 @@ export interface ValidateKeyError {
1617
message: string;
1718
}
1819

20+
||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export)
21+
=======
22+
export type ExportFormat = 'html' | 'pdf' | 'pptx' | 'zip';
23+
export interface ExportInvokeResponse {
24+
status: 'saved' | 'cancelled';
25+
path?: string;
26+
bytes?: number;
27+
}
28+
29+
>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export)
1930
const api = {
2031
detectProvider: (key: string) =>
2132
ipcRenderer.invoke('codesign:detect-provider', key) as Promise<string | null>,
@@ -25,6 +36,8 @@ const api = {
2536
model: ModelRef;
2637
baseUrl?: string;
2738
}) => ipcRenderer.invoke('codesign:generate', payload),
39+
export: (payload: { format: ExportFormat; htmlContent: string; defaultFilename?: string }) =>
40+
ipcRenderer.invoke('codesign:export', payload) as Promise<ExportInvokeResponse>,
2841
checkForUpdates: () => ipcRenderer.invoke('codesign:check-for-updates'),
2942
downloadUpdate: () => ipcRenderer.invoke('codesign:download-update'),
3043
installUpdate: () => ipcRenderer.invoke('codesign:install-update'),

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@ import { buildSrcdoc } from '@open-codesign/runtime';
22
import { BUILTIN_DEMOS } from '@open-codesign/templates';
33
import { Button } from '@open-codesign/ui';
44
import { Send, Sparkles } from 'lucide-react';
5+
<<<<<<< HEAD
56
import { useEffect, useState } from 'react';
67
import { Onboarding } from './onboarding';
8+
||||||| parent of bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export)
9+
import { useState } from 'react';
10+
=======
11+
import { useState } from 'react';
12+
import { PreviewToolbar } from './components/PreviewToolbar';
13+
>>>>>>> bca116c (feat(core,exporters,desktop): end-to-end first demo + HTML export)
714
import { useCodesignStore } from './store';
815

916
export function App() {
@@ -78,7 +85,6 @@ export function App() {
7885
) : (
7986
messages.map((m, i) => (
8087
<div
81-
// biome-ignore lint/suspicious/noArrayIndexKey: tier-1 chat list with no reordering
8288
key={`${m.role}-${i}`}
8389
className={`px-3 py-2 rounded-[var(--radius-md)] text-sm ${
8490
m.role === 'user'
@@ -119,6 +125,7 @@ export function App() {
119125
BYOK · local-first · multi-model
120126
</span>
121127
</header>
128+
<PreviewToolbar />
122129
<div className="flex-1 p-6 overflow-auto">
123130
{previewHtml ? (
124131
<iframe
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { Download } from 'lucide-react';
2+
import { type ReactElement, useEffect, useRef, useState } from 'react';
3+
import type { ExportFormat } from '../../../preload/index';
4+
import { useCodesignStore } from '../store';
5+
6+
interface ExportItem {
7+
format: ExportFormat;
8+
label: string;
9+
hint?: string;
10+
ready: boolean;
11+
}
12+
13+
const EXPORT_ITEMS: ExportItem[] = [
14+
{ format: 'html', label: 'HTML', ready: true },
15+
{ format: 'pdf', label: 'PDF', ready: false, hint: 'Coming in Phase 2' },
16+
{ format: 'pptx', label: 'PPTX', ready: false, hint: 'Coming in Phase 2' },
17+
{ format: 'zip', label: 'ZIP bundle', ready: false, hint: 'Coming in Phase 2' },
18+
];
19+
20+
export function PreviewToolbar(): ReactElement {
21+
const previewHtml = useCodesignStore((s) => s.previewHtml);
22+
const exportActive = useCodesignStore((s) => s.exportActive);
23+
const toastMessage = useCodesignStore((s) => s.toastMessage);
24+
const dismissToast = useCodesignStore((s) => s.dismissToast);
25+
const [open, setOpen] = useState(false);
26+
const ref = useRef<HTMLDivElement | null>(null);
27+
28+
useEffect(() => {
29+
if (!open) return;
30+
function onClick(e: MouseEvent): void {
31+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
32+
}
33+
document.addEventListener('mousedown', onClick);
34+
return () => document.removeEventListener('mousedown', onClick);
35+
}, [open]);
36+
37+
useEffect(() => {
38+
if (!toastMessage) return;
39+
const t = setTimeout(() => dismissToast(), 4000);
40+
return () => clearTimeout(t);
41+
}, [toastMessage, dismissToast]);
42+
43+
const disabled = !previewHtml;
44+
45+
return (
46+
<div className="flex items-center justify-end gap-2 px-5 py-2 border-b border-[var(--color-border)] bg-[var(--color-background-secondary)]">
47+
{toastMessage && (
48+
<output className="mr-auto text-xs text-[var(--color-text-secondary)] truncate max-w-[60%]">
49+
{toastMessage}
50+
</output>
51+
)}
52+
53+
<div className="relative" ref={ref}>
54+
<button
55+
type="button"
56+
disabled={disabled}
57+
onClick={() => setOpen((v) => !v)}
58+
className="inline-flex items-center gap-1.5 h-8 px-3 rounded-[var(--radius-md)] text-sm font-medium border border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50 disabled:pointer-events-none transition-colors"
59+
aria-haspopup="menu"
60+
aria-expanded={open}
61+
>
62+
<Download className="w-4 h-4" aria-hidden="true" />
63+
Export
64+
</button>
65+
66+
{open && (
67+
<div
68+
role="menu"
69+
className="absolute right-0 top-full mt-1 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-[var(--shadow-card)] py-1 z-10"
70+
>
71+
{EXPORT_ITEMS.map((item) => (
72+
<button
73+
key={item.format}
74+
type="button"
75+
role="menuitem"
76+
disabled={!item.ready}
77+
title={item.ready ? undefined : item.hint}
78+
onClick={() => {
79+
setOpen(false);
80+
void exportActive(item.format);
81+
}}
82+
className="w-full flex items-center justify-between gap-3 px-3 py-2 text-sm text-left text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] disabled:opacity-50 disabled:hover:bg-transparent disabled:cursor-not-allowed transition-colors"
83+
>
84+
<span>{item.label}</span>
85+
{!item.ready && (
86+
<span className="text-xs text-[var(--color-text-muted)]">{item.hint}</span>
87+
)}
88+
</button>
89+
))}
90+
</div>
91+
)}
92+
</div>
93+
</div>
94+
);
95+
}

0 commit comments

Comments
 (0)