diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx
index 702034d5..92504474 100644
--- a/apps/desktop/src/renderer/src/App.tsx
+++ b/apps/desktop/src/renderer/src/App.tsx
@@ -1,4 +1,5 @@
import { useT } from '@open-codesign/i18n';
+import { ChevronLeft } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { CommandPalette } from './components/CommandPalette';
import { PreviewPane } from './components/PreviewPane';
@@ -9,6 +10,7 @@ import { TopBar } from './components/TopBar';
import { useKeyboard } from './hooks/useKeyboard';
import { Onboarding } from './onboarding';
import { useCodesignStore } from './store';
+import { HubView } from './views/HubView';
export function App() {
const t = useT();
@@ -73,6 +75,10 @@ export function App() {
}
if (view === 'settings') {
setView('workspace');
+ return;
+ }
+ if (view === 'workspace') {
+ setView('hub');
}
},
preventDefault: false,
@@ -110,12 +116,31 @@ export function App() {
{view === 'settings' ? (
+ ) : view === 'hub' ? (
+
{
+ setPrompt(p);
+ setView('workspace');
+ }}
+ />
) : (
-
-
-
- setPrompt(p)} />
-
+
+
+
+
+
+
+
+ setPrompt(p)} />
+
+
)}
diff --git a/apps/desktop/src/renderer/src/store.test.ts b/apps/desktop/src/renderer/src/store.test.ts
index 027c10af..622dc1f3 100644
--- a/apps/desktop/src/renderer/src/store.test.ts
+++ b/apps/desktop/src/renderer/src/store.test.ts
@@ -1,6 +1,7 @@
import { initI18n } from '@open-codesign/i18n';
-import type { OnboardingState } from '@open-codesign/shared';
+import type { LocalInputFile, OnboardingState, SelectedElement } from '@open-codesign/shared';
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { GenerationStage } from './store';
import { useCodesignStore } from './store';
const READY_CONFIG: OnboardingState = {
@@ -231,8 +232,8 @@ describe('useCodesignStore generation cancellation', () => {
});
describe('useCodesignStore view navigation', () => {
- it('starts on workspace view', () => {
- expect(useCodesignStore.getState().view).toBe('workspace');
+ it('starts on hub view', () => {
+ expect(useCodesignStore.getState().view).toBe('hub');
});
it('setView("settings") switches to settings and closes command palette', () => {
@@ -331,3 +332,107 @@ describe('useCodesignStore active provider routing', () => {
expect(payload.model.modelId).toBe('gpt-4o');
});
});
+
+describe('useCodesignStore project storage error surfacing', () => {
+ beforeAll(async () => {
+ await initI18n('en');
+ });
+
+ it('createProject pushes a toast when localStorage.setItem throws and keeps the project in memory', () => {
+ const setItem = vi.fn(() => {
+ throw new Error('QuotaExceededError');
+ });
+ const getItem = vi.fn(() => null);
+
+ vi.stubGlobal('window', {
+ localStorage: { setItem, getItem, removeItem: vi.fn(), clear: vi.fn() },
+ setTimeout,
+ });
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+
+ const projectsBefore = useCodesignStore.getState().projects;
+ const toastsBefore = useCodesignStore.getState().toasts.length;
+
+ const project = useCodesignStore
+ .getState()
+ .createProject({ name: 'My Project', type: 'slideDeck' });
+
+ const state = useCodesignStore.getState();
+
+ // In-memory state stays consistent — the project was added even though persist failed.
+ expect(state.projects[0]).toEqual(project);
+ expect(state.projects).toHaveLength(projectsBefore.length + 1);
+ expect(state.currentProjectId).toBe(project.id);
+
+ // Toast surfaced with the i18n title and the underlying error message.
+ expect(state.toasts.length).toBe(toastsBefore + 1);
+ expect(state.toasts.at(-1)).toMatchObject({
+ variant: 'error',
+ description: 'QuotaExceededError',
+ });
+ expect(state.toasts.at(-1)?.title).toBeTruthy();
+
+ expect(setItem).toHaveBeenCalledOnce();
+ expect(warnSpy).toHaveBeenCalled();
+
+ warnSpy.mockRestore();
+ });
+
+ it('createProject resets project-scoped workspace state when switching to the new project', () => {
+ vi.stubGlobal('window', {
+ localStorage: {
+ setItem: vi.fn(),
+ getItem: vi.fn(() => null),
+ removeItem: vi.fn(),
+ clear: vi.fn(),
+ },
+ setTimeout,
+ });
+
+ const staleFile: LocalInputFile = {
+ path: '/tmp/old.png',
+ name: 'old.png',
+ size: 1,
+ };
+ const staleSelection: SelectedElement = {
+ selector: '.stale',
+ tag: 'div',
+ outerHTML: 'old
',
+ rect: { top: 0, left: 0, width: 10, height: 10 },
+ };
+
+ useCodesignStore.setState({
+ messages: [{ role: 'user', content: 'old prompt' }],
+ previewHtml: 'old',
+ inputFiles: [staleFile],
+ referenceUrl: 'https://example.com/old',
+ selectedElement: staleSelection,
+ lastPromptInput: { prompt: 'old prompt', attachments: [staleFile] },
+ isGenerating: true,
+ activeGenerationId: 'gen-old',
+ errorMessage: 'stale error',
+ lastError: 'stale error',
+ generationStage: 'streaming' as GenerationStage,
+ });
+
+ const project = useCodesignStore
+ .getState()
+ .createProject({ name: 'Fresh Project', type: 'prototype' });
+
+ const state = useCodesignStore.getState();
+ expect(state.currentProjectId).toBe(project.id);
+ expect(state.view).toBe('workspace');
+ expect(state.createProjectModalOpen).toBe(false);
+ expect(state.messages).toEqual([]);
+ expect(state.previewHtml).toBeNull();
+ expect(state.inputFiles).toEqual([]);
+ expect(state.referenceUrl).toBe('');
+ expect(state.selectedElement).toBeNull();
+ expect(state.lastPromptInput).toBeNull();
+ expect(state.isGenerating).toBe(false);
+ expect(state.activeGenerationId).toBeNull();
+ expect(state.errorMessage).toBeNull();
+ expect(state.lastError).toBeNull();
+ expect(state.generationStage).toBe('idle');
+ });
+});
diff --git a/apps/desktop/src/renderer/src/store.ts b/apps/desktop/src/renderer/src/store.ts
index 86eccce8..2d150f1b 100644
--- a/apps/desktop/src/renderer/src/store.ts
+++ b/apps/desktop/src/renderer/src/store.ts
@@ -1,11 +1,14 @@
import { i18n } from '@open-codesign/i18n';
-import type {
- ChatMessage,
- LocalInputFile,
- ModelRef,
- OnboardingState,
- SelectedElement,
- SupportedOnboardingProvider,
+import {
+ type ChatMessage,
+ type LocalInputFile,
+ type ModelRef,
+ type OnboardingState,
+ PROJECT_SCHEMA_VERSION,
+ Project,
+ type ProjectDraft,
+ type SelectedElement,
+ type SupportedOnboardingProvider,
} from '@open-codesign/shared';
import { create } from 'zustand';
import type { StoreApi } from 'zustand';
@@ -45,7 +48,8 @@ export interface ConnectionStatus {
}
export type Theme = 'light' | 'dark';
-export type AppView = 'workspace' | 'settings';
+export type AppView = 'hub' | 'workspace' | 'settings';
+export type HubTab = 'recent' | 'your' | 'examples' | 'designSystems';
interface PromptRequest {
prompt: string;
@@ -69,6 +73,10 @@ interface CodesignState {
theme: Theme;
view: AppView;
+ hubTab: HubTab;
+ projects: Project[];
+ currentProjectId: string | null;
+ createProjectModalOpen: boolean;
commandPaletteOpen: boolean;
toasts: Toast[];
iframeErrors: string[];
@@ -108,6 +116,11 @@ interface CodesignState {
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
setView: (view: AppView) => void;
+ setHubTab: (tab: HubTab) => void;
+ openCreateProjectModal: () => void;
+ closeCreateProjectModal: () => void;
+ createProject: (draft: ProjectDraft) => Project;
+ openProject: (id: string) => void;
openCommandPalette: () => void;
closeCommandPalette: () => void;
@@ -116,6 +129,63 @@ interface CodesignState {
}
const THEME_STORAGE_KEY = 'open-codesign:theme';
+const PROJECTS_STORAGE_KEY = 'open-codesign:projects:v1';
+
+type ProjectsReadResult = { projects: Project[]; error: string | null };
+
+function readStoredProjects(): ProjectsReadResult {
+ if (typeof window === 'undefined') return { projects: [], error: null };
+ let raw: string | null;
+ try {
+ raw = window.localStorage.getItem(PROJECTS_STORAGE_KEY);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.warn('[open-codesign] Failed to read projects from storage:', err);
+ return { projects: [], error: msg };
+ }
+ if (!raw) return { projects: [], error: null };
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.warn('[open-codesign] Failed to parse stored projects:', err);
+ return { projects: [], error: msg };
+ }
+ if (!Array.isArray(parsed)) {
+ const msg = 'Invalid projects storage payload: expected array';
+ console.warn(`[open-codesign] ${msg}`);
+ return { projects: [], error: msg };
+ }
+ const projects: Project[] = [];
+ let invalidCount = 0;
+ for (const item of parsed) {
+ const result = Project.safeParse(item);
+ if (result.success && result.data.schemaVersion === PROJECT_SCHEMA_VERSION) {
+ projects.push(result.data);
+ } else {
+ invalidCount += 1;
+ }
+ }
+ if (invalidCount > 0) {
+ const msg = `Skipped ${invalidCount} invalid project record(s) in storage`;
+ console.warn(`[open-codesign] ${msg}`);
+ return { projects, error: msg };
+ }
+ return { projects, error: null };
+}
+
+function persistProjects(projects: Project[]): { error: string | null } {
+ if (typeof window === 'undefined') return { error: null };
+ try {
+ window.localStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects));
+ return { error: null };
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ console.warn('[open-codesign] Failed to persist projects to storage:', err);
+ return { error: msg };
+ }
+}
function readInitialTheme(): Theme {
if (typeof window === 'undefined') return 'light';
@@ -277,6 +347,8 @@ function buildPromptRequest(
};
}
+const initialProjectsRead = readStoredProjects();
+
export const useCodesignStore = create((set, get) => ({
messages: [],
previewHtml: null,
@@ -292,7 +364,11 @@ export const useCodesignStore = create((set, get) => ({
connectionStatus: { state: 'no_provider', lastTestedAt: null, lastError: null },
theme: readInitialTheme(),
- view: 'workspace' as AppView,
+ view: 'hub' as AppView,
+ hubTab: 'recent' as HubTab,
+ projects: initialProjectsRead.projects,
+ currentProjectId: null,
+ createProjectModalOpen: false,
commandPaletteOpen: false,
toasts: [],
iframeErrors: [],
@@ -613,6 +689,80 @@ export const useCodesignStore = create((set, get) => ({
set({ view, commandPaletteOpen: false });
},
+ setHubTab(tab) {
+ set({ hubTab: tab });
+ },
+
+ openCreateProjectModal() {
+ set({ createProjectModalOpen: true });
+ },
+
+ closeCreateProjectModal() {
+ set({ createProjectModalOpen: false });
+ },
+
+ createProject(draft) {
+ const now = new Date().toISOString();
+ const project: Project = {
+ schemaVersion: PROJECT_SCHEMA_VERSION,
+ id: newId(),
+ name: draft.name.trim(),
+ type: draft.type,
+ createdAt: now,
+ updatedAt: now,
+ ...(draft.fidelity ? { fidelity: draft.fidelity } : {}),
+ ...(draft.speakerNotes !== undefined ? { speakerNotes: draft.speakerNotes } : {}),
+ ...(draft.templateId ? { templateId: draft.templateId } : {}),
+ };
+ const next = [project, ...get().projects];
+ const persist = persistProjects(next);
+ set({
+ projects: next,
+ currentProjectId: project.id,
+ view: 'workspace',
+ createProjectModalOpen: false,
+ messages: [],
+ previewHtml: null,
+ inputFiles: [],
+ referenceUrl: '',
+ selectedElement: null,
+ lastPromptInput: null,
+ generationStage: 'idle' as GenerationStage,
+ isGenerating: false,
+ activeGenerationId: null,
+ errorMessage: null,
+ lastError: null,
+ });
+ if (persist.error) {
+ get().pushToast({
+ variant: 'error',
+ title: tr('errors.projectStorageFailed'),
+ description: persist.error,
+ });
+ }
+ return project;
+ },
+
+ openProject(id) {
+ const project = get().projects.find((p) => p.id === id);
+ if (!project) return;
+ set({
+ currentProjectId: id,
+ view: 'workspace',
+ messages: [],
+ previewHtml: null,
+ inputFiles: [],
+ referenceUrl: '',
+ selectedElement: null,
+ lastPromptInput: null,
+ generationStage: 'idle' as GenerationStage,
+ isGenerating: false,
+ activeGenerationId: null,
+ errorMessage: null,
+ lastError: null,
+ });
+ },
+
openCommandPalette() {
set({ commandPaletteOpen: true });
},
@@ -635,3 +785,14 @@ export const useCodesignStore = create((set, get) => ({
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) }));
},
}));
+
+if (initialProjectsRead.error && typeof window !== 'undefined') {
+ // Defer so i18n + UI have a chance to mount before the toast renders.
+ setTimeout(() => {
+ useCodesignStore.getState().pushToast({
+ variant: 'error',
+ title: tr('errors.projectStorageFailed'),
+ description: initialProjectsRead.error ?? '',
+ });
+ }, 0);
+}
diff --git a/apps/desktop/src/renderer/src/views/HubView.tsx b/apps/desktop/src/renderer/src/views/HubView.tsx
new file mode 100644
index 00000000..7fd74a1d
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/HubView.tsx
@@ -0,0 +1,76 @@
+import { useT } from '@open-codesign/i18n';
+import { Plus } from 'lucide-react';
+import { type HubTab, useCodesignStore } from '../store';
+import { CreateProjectModal } from './create/CreateProjectModal';
+import { DesignSystemsTab } from './hub/DesignSystemsTab';
+import { ExamplesTab } from './hub/ExamplesTab';
+import { RecentTab } from './hub/RecentTab';
+import { YourDesignsTab } from './hub/YourDesignsTab';
+
+const TABS: HubTab[] = ['recent', 'your', 'examples', 'designSystems'];
+
+export interface HubViewProps {
+ onUseExamplePrompt?: (prompt: string) => void;
+}
+
+export function HubView({ onUseExamplePrompt }: HubViewProps = {}) {
+ const t = useT();
+ const hubTab = useCodesignStore((s) => s.hubTab);
+ const setHubTab = useCodesignStore((s) => s.setHubTab);
+ const openCreateProjectModal = useCodesignStore((s) => s.openCreateProjectModal);
+ const createProjectModalOpen = useCodesignStore((s) => s.createProjectModalOpen);
+
+ return (
+
+
+
+
+ {t('hub.tabs.your')}
+
+
+
+
+
+
+
+ {hubTab === 'recent' ? : null}
+ {hubTab === 'your' ? : null}
+ {hubTab === 'examples' ? (
+ onUseExamplePrompt?.(example.prompt)} />
+ ) : null}
+ {hubTab === 'designSystems' ? : null}
+
+
+ {createProjectModalOpen ?
: null}
+
+ );
+}
diff --git a/apps/desktop/src/renderer/src/views/create/CreateProjectModal.test.ts b/apps/desktop/src/renderer/src/views/create/CreateProjectModal.test.ts
new file mode 100644
index 00000000..d3d6831a
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/create/CreateProjectModal.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'vitest';
+import { buildDraft } from './CreateProjectModal';
+
+describe('CreateProjectModal.buildDraft', () => {
+ it('returns null when name is empty (CTA stays disabled)', () => {
+ expect(
+ buildDraft({ type: 'prototype', name: ' ', speakerNotes: false, templateId: '' }),
+ ).toBeNull();
+ });
+
+ it('builds a prototype draft', () => {
+ const draft = buildDraft({
+ type: 'prototype',
+ name: ' Onboarding flow ',
+ speakerNotes: false,
+ templateId: '',
+ });
+ expect(draft).toEqual({ name: 'Onboarding flow', type: 'prototype' });
+ });
+
+ it('builds a slide-deck draft and preserves speakerNotes', () => {
+ const draft = buildDraft({
+ type: 'slideDeck',
+ name: 'Pitch',
+ speakerNotes: true,
+ templateId: '',
+ });
+ expect(draft).toEqual({ name: 'Pitch', type: 'slideDeck', speakerNotes: true });
+ });
+
+ it('refuses a template draft when no template is picked', () => {
+ expect(
+ buildDraft({ type: 'template', name: 'Demo', speakerNotes: false, templateId: '' }),
+ ).toBeNull();
+ });
+
+ it('builds a from-template draft when a template id is provided', () => {
+ const draft = buildDraft({
+ type: 'template',
+ name: 'Demo',
+ speakerNotes: false,
+ templateId: 'meditation-app',
+ });
+ expect(draft).toEqual({ name: 'Demo', type: 'template', templateId: 'meditation-app' });
+ });
+
+ it('builds an other draft', () => {
+ const draft = buildDraft({
+ type: 'other',
+ name: 'Free form',
+ speakerNotes: false,
+ templateId: '',
+ });
+ expect(draft).toEqual({ name: 'Free form', type: 'other' });
+ });
+});
diff --git a/apps/desktop/src/renderer/src/views/create/CreateProjectModal.tsx b/apps/desktop/src/renderer/src/views/create/CreateProjectModal.tsx
new file mode 100644
index 00000000..069ec52c
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/create/CreateProjectModal.tsx
@@ -0,0 +1,181 @@
+import { getCurrentLocale, useT } from '@open-codesign/i18n';
+import type { ProjectDraft, ProjectType } from '@open-codesign/shared';
+import { type DemoTemplate, getDemos } from '@open-codesign/templates';
+import { X } from 'lucide-react';
+import { useEffect, useId, useMemo, useState } from 'react';
+import { useCodesignStore } from '../../store';
+import { FromTemplateForm, buildFromTemplateDraft } from './FromTemplateForm';
+import { OtherForm, buildOtherDraft } from './OtherForm';
+import { PrototypeForm, buildPrototypeDraft } from './PrototypeForm';
+import { SlideDeckForm, buildSlideDeckDraft } from './SlideDeckForm';
+
+const TYPES: ProjectType[] = ['prototype', 'slideDeck', 'template', 'other'];
+
+export interface BuildDraftInput {
+ type: ProjectType;
+ name: string;
+ speakerNotes: boolean;
+ templateId: string;
+}
+
+export function buildDraft({
+ type,
+ name,
+ speakerNotes,
+ templateId,
+}: BuildDraftInput): ProjectDraft | null {
+ const trimmed = name.trim();
+ if (!trimmed) return null;
+ switch (type) {
+ case 'prototype':
+ return buildPrototypeDraft(trimmed);
+ case 'slideDeck':
+ return buildSlideDeckDraft(trimmed, speakerNotes);
+ case 'template':
+ if (!templateId) return null;
+ return buildFromTemplateDraft(trimmed, templateId);
+ case 'other':
+ return buildOtherDraft(trimmed);
+ }
+}
+
+export function CreateProjectModal() {
+ const t = useT();
+ const closeModal = useCodesignStore((s) => s.closeCreateProjectModal);
+ const createProject = useCodesignStore((s) => s.createProject);
+ const titleId = useId();
+
+ const templates = useMemo(() => getDemos(getCurrentLocale()), []);
+
+ const [type, setType] = useState('prototype');
+ const [name, setName] = useState('');
+ const [speakerNotes, setSpeakerNotes] = useState(true);
+ const [templateId, setTemplateId] = useState(templates[0]?.id ?? '');
+
+ useEffect(() => {
+ function onKey(e: KeyboardEvent): void {
+ if (e.key === 'Escape') closeModal();
+ }
+ window.addEventListener('keydown', onKey);
+ return () => window.removeEventListener('keydown', onKey);
+ }, [closeModal]);
+
+ const draft = buildDraft({ type, name, speakerNotes, templateId });
+ const canSubmit = draft !== null;
+
+ function submit(): void {
+ if (!draft) return;
+ createProject(draft);
+ }
+
+ return (
+ conflicts with our custom backdrop click + overlay token theming.
+ role="dialog"
+ aria-modal="true"
+ aria-labelledby={titleId}
+ >
+
+
+
+
+
+ {TYPES.map((kind) => {
+ const active = kind === type;
+ return (
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/src/views/create/FromTemplateForm.tsx b/apps/desktop/src/renderer/src/views/create/FromTemplateForm.tsx
new file mode 100644
index 00000000..e59fb1c3
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/create/FromTemplateForm.tsx
@@ -0,0 +1,70 @@
+import { useT } from '@open-codesign/i18n';
+import type { ProjectDraft } from '@open-codesign/shared';
+import type { DemoTemplate } from '@open-codesign/templates';
+import { FieldHelp, NameField } from './fields';
+
+export interface FromTemplateFormProps {
+ name: string;
+ setName: (next: string) => void;
+ templateId: string;
+ setTemplateId: (next: string) => void;
+ templates: DemoTemplate[];
+}
+
+export function FromTemplateForm({
+ name,
+ setName,
+ templateId,
+ setTemplateId,
+ templates,
+}: FromTemplateFormProps) {
+ const t = useT();
+ return (
+
+
+
+
{t('create.help.templates')}
+
+ );
+}
+
+export function buildFromTemplateDraft(name: string, templateId: string): ProjectDraft {
+ return { name: name.trim(), type: 'template', templateId };
+}
diff --git a/apps/desktop/src/renderer/src/views/create/OtherForm.tsx b/apps/desktop/src/renderer/src/views/create/OtherForm.tsx
new file mode 100644
index 00000000..51e2c402
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/create/OtherForm.tsx
@@ -0,0 +1,22 @@
+import { useT } from '@open-codesign/i18n';
+import type { ProjectDraft } from '@open-codesign/shared';
+import { FieldHelp, NameField } from './fields';
+
+export interface OtherFormProps {
+ name: string;
+ setName: (next: string) => void;
+}
+
+export function OtherForm({ name, setName }: OtherFormProps) {
+ const t = useT();
+ return (
+
+
+ {t('create.help.share')}
+
+ );
+}
+
+export function buildOtherDraft(name: string): ProjectDraft {
+ return { name: name.trim(), type: 'other' };
+}
diff --git a/apps/desktop/src/renderer/src/views/create/PrototypeForm.tsx b/apps/desktop/src/renderer/src/views/create/PrototypeForm.tsx
new file mode 100644
index 00000000..82c2091b
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/create/PrototypeForm.tsx
@@ -0,0 +1,28 @@
+import { useT } from '@open-codesign/i18n';
+import type { ProjectDraft } from '@open-codesign/shared';
+import { FieldHelp, NameField } from './fields';
+
+export interface PrototypeFormProps {
+ name: string;
+ setName: (next: string) => void;
+}
+
+export function PrototypeForm({ name, setName }: PrototypeFormProps) {
+ const t = useT();
+ return (
+
+
+
+
+ {t('create.fields.fidelity')}
+
+ {t('create.fields.fidelityComingSoon')}
+
+
{t('create.help.share')}
+
+ );
+}
+
+export function buildPrototypeDraft(name: string): ProjectDraft {
+ return { name: name.trim(), type: 'prototype' };
+}
diff --git a/apps/desktop/src/renderer/src/views/create/SlideDeckForm.tsx b/apps/desktop/src/renderer/src/views/create/SlideDeckForm.tsx
new file mode 100644
index 00000000..a85806e7
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/create/SlideDeckForm.tsx
@@ -0,0 +1,43 @@
+import { useT } from '@open-codesign/i18n';
+import type { ProjectDraft } from '@open-codesign/shared';
+import { FieldHelp, NameField } from './fields';
+
+export interface SlideDeckFormProps {
+ name: string;
+ setName: (next: string) => void;
+ speakerNotes: boolean;
+ setSpeakerNotes: (next: boolean) => void;
+}
+
+export function SlideDeckForm({
+ name,
+ setName,
+ speakerNotes,
+ setSpeakerNotes,
+}: SlideDeckFormProps) {
+ const t = useT();
+ return (
+
+
+
+ {t('create.help.share')}
+
+ );
+}
+
+export function buildSlideDeckDraft(name: string, speakerNotes: boolean): ProjectDraft {
+ return { name: name.trim(), type: 'slideDeck', speakerNotes };
+}
diff --git a/apps/desktop/src/renderer/src/views/create/fields.tsx b/apps/desktop/src/renderer/src/views/create/fields.tsx
new file mode 100644
index 00000000..60093313
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/create/fields.tsx
@@ -0,0 +1,41 @@
+import { useT } from '@open-codesign/i18n';
+import type { ReactNode } from 'react';
+
+export interface NameFieldProps {
+ value: string;
+ onChange: (next: string) => void;
+ inputId: string;
+}
+
+export function NameField({ value, onChange, inputId }: NameFieldProps) {
+ const t = useT();
+ return (
+
+ );
+}
+
+export interface FieldHelpProps {
+ children: ReactNode;
+}
+
+export function FieldHelp({ children }: FieldHelpProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/desktop/src/renderer/src/views/hub/DesignSystemsTab.tsx b/apps/desktop/src/renderer/src/views/hub/DesignSystemsTab.tsx
new file mode 100644
index 00000000..a3ea4310
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/hub/DesignSystemsTab.tsx
@@ -0,0 +1,15 @@
+import { useT } from '@open-codesign/i18n';
+
+export function DesignSystemsTab() {
+ const t = useT();
+ return (
+
+
+ {t('hub.designSystems.title')}
+
+
+ {t('hub.designSystems.comingSoon')}
+
+
+ );
+}
diff --git a/apps/desktop/src/renderer/src/views/hub/ProjectGrid.tsx b/apps/desktop/src/renderer/src/views/hub/ProjectGrid.tsx
new file mode 100644
index 00000000..86052789
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/hub/ProjectGrid.tsx
@@ -0,0 +1,50 @@
+import { useT } from '@open-codesign/i18n';
+import type { Project } from '@open-codesign/shared';
+import { useCodesignStore } from '../../store';
+
+export interface ProjectGridProps {
+ projects: Project[];
+ emptyLabel: string;
+}
+
+export function ProjectGrid({ projects, emptyLabel }: ProjectGridProps) {
+ const t = useT();
+ const openProject = useCodesignStore((s) => s.openProject);
+
+ if (projects.length === 0) {
+ return (
+
+ {emptyLabel}
+
+ );
+ }
+
+ return (
+
+ {projects.map((p) => (
+ -
+
+
+ ))}
+
+ );
+}
diff --git a/apps/desktop/src/renderer/src/views/hub/RecentTab.tsx b/apps/desktop/src/renderer/src/views/hub/RecentTab.tsx
new file mode 100644
index 00000000..362354b0
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/hub/RecentTab.tsx
@@ -0,0 +1,14 @@
+import { useT } from '@open-codesign/i18n';
+import { useCodesignStore } from '../../store';
+import { ProjectGrid } from './ProjectGrid';
+
+const RECENT_LIMIT = 6;
+
+export function RecentTab() {
+ const t = useT();
+ const projects = useCodesignStore((s) => s.projects);
+ const recent = [...projects]
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1))
+ .slice(0, RECENT_LIMIT);
+ return ;
+}
diff --git a/apps/desktop/src/renderer/src/views/hub/YourDesignsTab.tsx b/apps/desktop/src/renderer/src/views/hub/YourDesignsTab.tsx
new file mode 100644
index 00000000..b9caf162
--- /dev/null
+++ b/apps/desktop/src/renderer/src/views/hub/YourDesignsTab.tsx
@@ -0,0 +1,10 @@
+import { useT } from '@open-codesign/i18n';
+import { useCodesignStore } from '../../store';
+import { ProjectGrid } from './ProjectGrid';
+
+export function YourDesignsTab() {
+ const t = useT();
+ const projects = useCodesignStore((s) => s.projects);
+ const sorted = [...projects].sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
+ return ;
+}
diff --git a/packages/core/src/brand/tailwindExtractor.ts b/packages/core/src/brand/tailwindExtractor.ts
index 705f006a..7e50dc4c 100644
--- a/packages/core/src/brand/tailwindExtractor.ts
+++ b/packages/core/src/brand/tailwindExtractor.ts
@@ -70,7 +70,7 @@ function extractBodyAt(text: string, bracePos: number): string | null {
let inBlockComment = false;
for (let i = bracePos; i < text.length; i++) {
- const ch = text[i]!;
+ const ch = text[i] as string;
const next = text[i + 1];
if (inLineComment) {
diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json
index d9aa7c63..a8d9af94 100644
--- a/packages/i18n/src/locales/en.json
+++ b/packages/i18n/src/locales/en.json
@@ -391,7 +391,8 @@
"onboardingIncomplete": "Onboarding is not complete.",
"modelListFailed": "Could not load model list.",
"unknown": "Unknown error",
- "localePersistFailed": "Failed to save language preference"
+ "localePersistFailed": "Failed to save language preference",
+ "projectStorageFailed": "Couldn't read or write projects on this device. Your changes may not persist."
},
"demos": {
"meditationApp": {
@@ -450,5 +451,78 @@
"pitch": "Generate the first 3 slides of a pitch deck for a fintech startup. Title slide, problem, solution.",
"mobile": "Design a 3-screen mobile app onboarding flow. Welcome, permissions, first action.",
"dashboard": "Build a data dashboard with 3 charts: bar chart for sales, line chart for retention, pie chart for segments. Use a clean dark theme."
+ },
+ "hub": {
+ "newDesign": "New design",
+ "backToHub": "All designs",
+ "tabs": {
+ "recent": "Recent",
+ "your": "Your designs",
+ "examples": "Examples",
+ "designSystems": "Design systems"
+ },
+ "recent": {
+ "title": "Recent",
+ "empty": "Nothing here yet — start a new design to see it pinned at the top."
+ },
+ "your": {
+ "title": "Your designs",
+ "empty": "No designs saved yet. Click \"New design\" to create your first one.",
+ "openAria": "Open {{name}}"
+ },
+ "examples": {
+ "title": "Examples",
+ "comingSoon": "A curated gallery of starter prompts is on the way."
+ },
+ "designSystems": {
+ "title": "Design systems",
+ "comingSoon": "Reusable brand systems and templates are on the way."
+ },
+ "card": {
+ "type": {
+ "prototype": "Prototype",
+ "slideDeck": "Slide deck",
+ "template": "From template",
+ "other": "Other"
+ },
+ "createdAt": "Created {{date}}"
+ }
+ },
+ "create": {
+ "title": "Start a new design",
+ "subtitle": "Pick a type so we can tailor the first prompt.",
+ "close": "Close",
+ "types": {
+ "prototype": "Prototype",
+ "slideDeck": "Slide deck",
+ "template": "From template",
+ "other": "Other"
+ },
+ "typeDescriptions": {
+ "prototype": "Interactive UI mockups with a phone or browser frame.",
+ "slideDeck": "A multi-slide deck for talks, pitches, or summaries.",
+ "template": "Start from one of the bundled starter prompts.",
+ "other": "A blank canvas — describe anything else you have in mind."
+ },
+ "fields": {
+ "name": "Project name",
+ "namePlaceholder": "Untitled design",
+ "fidelity": "Fidelity",
+ "fidelityWireframe": "Wireframe",
+ "fidelityWireframeHint": "Greyscale blocks, no imagery — quick exploration.",
+ "fidelityHigh": "High-fidelity",
+ "fidelityHighHint": "Polished colors, real-looking content.",
+ "fidelityComingSoon": "Wireframe vs. high-fidelity selection ships in a follow-up.",
+ "speakerNotes": "Include speaker notes",
+ "speakerNotesHint": "Adds a notes section under each slide for delivery cues.",
+ "template": "Template",
+ "templateHint": "Choose a starter — the first prompt will be pre-filled."
+ },
+ "cta": "Create",
+ "disabledHint": "Add a project name to continue.",
+ "help": {
+ "share": "Designs stay on this device. Sharing happens through bundle export — no cloud account needed.",
+ "templates": "More starters arrive in a follow-up; this list will keep growing."
+ }
}
}
diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json
index 26e276b9..dc88f8ea 100644
--- a/packages/i18n/src/locales/zh-CN.json
+++ b/packages/i18n/src/locales/zh-CN.json
@@ -390,7 +390,8 @@
"onboardingIncomplete": "引导流程尚未完成。",
"modelListFailed": "无法加载模型列表。",
"unknown": "未知错误",
- "localePersistFailed": "无法保存语言偏好"
+ "localePersistFailed": "无法保存语言偏好",
+ "projectStorageFailed": "本机的项目数据读写失败,改动可能未保存。"
},
"demos": {
"meditationApp": {
@@ -449,5 +450,78 @@
"pitch": "为一家金融科技创业公司生成演讲稿前 3 页:标题页、问题页、解决方案页。",
"mobile": "设计一个 3 屏的移动端引导流程:欢迎、权限请求、第一个动作。",
"dashboard": "做一个数据看板,含 3 张图:条形图(销量)、折线图(留存)、饼图(细分)。深色主题。"
+ },
+ "hub": {
+ "newDesign": "新建设计",
+ "backToHub": "全部设计",
+ "tabs": {
+ "recent": "最近",
+ "your": "我的设计",
+ "examples": "示例",
+ "designSystems": "设计系统"
+ },
+ "recent": {
+ "title": "最近",
+ "empty": "还没有最近项目——新建一个设计后会出现在这里。"
+ },
+ "your": {
+ "title": "我的设计",
+ "empty": "还没有保存的设计。点击「新建设计」开始第一个项目。",
+ "openAria": "打开 {{name}}"
+ },
+ "examples": {
+ "title": "示例",
+ "comingSoon": "策划中的范例提示词画廊即将上线。"
+ },
+ "designSystems": {
+ "title": "设计系统",
+ "comingSoon": "可复用的品牌系统与模板正在路上。"
+ },
+ "card": {
+ "type": {
+ "prototype": "原型",
+ "slideDeck": "演示稿",
+ "template": "模板",
+ "other": "其他"
+ },
+ "createdAt": "创建于 {{date}}"
+ }
+ },
+ "create": {
+ "title": "新建设计",
+ "subtitle": "选择类型后,我们会按需调整首条提示词。",
+ "close": "关闭",
+ "types": {
+ "prototype": "原型",
+ "slideDeck": "演示稿",
+ "template": "套用模板",
+ "other": "其他"
+ },
+ "typeDescriptions": {
+ "prototype": "带手机或浏览器外框的可点击 UI 原型。",
+ "slideDeck": "用于演讲、路演或总结的多页演示稿。",
+ "template": "从内置范例提示词出发。",
+ "other": "空白画布 — 任何其他想法都可以。"
+ },
+ "fields": {
+ "name": "项目名称",
+ "namePlaceholder": "未命名设计",
+ "fidelity": "保真度",
+ "fidelityWireframe": "线框图",
+ "fidelityWireframeHint": "灰阶方块、无图片 — 适合快速探索。",
+ "fidelityHigh": "高保真",
+ "fidelityHighHint": "完整配色与真实感内容。",
+ "fidelityComingSoon": "线框 / 高保真选择将在后续版本上线。",
+ "speakerNotes": "包含演讲备注",
+ "speakerNotesHint": "为每页幻灯片附加备注,便于现场讲解。",
+ "template": "模板",
+ "templateHint": "选一个起点 — 首条提示词会自动填好。"
+ },
+ "cta": "创建",
+ "disabledHint": "请先填写项目名称。",
+ "help": {
+ "share": "设计仅保存在本机。需要分享时通过打包导出,无需云端账号。",
+ "templates": "更多模板会陆续加入,列表会不断扩展。"
+ }
}
}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 387bcac2..22e831d7 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -167,6 +167,36 @@ export const BRAND = {
backgroundColor: '#faf8f3',
} as const;
+export const PROJECT_SCHEMA_VERSION = 1 as const;
+
+export const ProjectType = z.enum(['prototype', 'slideDeck', 'template', 'other']);
+export type ProjectType = z.infer;
+
+export const ProjectFidelity = z.enum(['wireframe', 'highFidelity']);
+export type ProjectFidelity = z.infer;
+
+export const Project = z.object({
+ schemaVersion: z.literal(PROJECT_SCHEMA_VERSION),
+ id: z.string().min(1),
+ name: z.string().min(1),
+ type: ProjectType,
+ createdAt: z.string(),
+ updatedAt: z.string(),
+ fidelity: ProjectFidelity.optional(),
+ speakerNotes: z.boolean().optional(),
+ templateId: z.string().optional(),
+});
+export type Project = z.infer;
+
+export const ProjectDraft = z.object({
+ name: z.string().min(1),
+ type: ProjectType,
+ fidelity: ProjectFidelity.optional(),
+ speakerNotes: z.boolean().optional(),
+ templateId: z.string().optional(),
+});
+export type ProjectDraft = z.infer;
+
export class CodesignError extends Error {
constructor(
message: string,
diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css
index 84e9cd0d..aa368dbd 100644
--- a/packages/ui/src/tokens.css
+++ b/packages/ui/src/tokens.css
@@ -128,6 +128,21 @@
/* Minimum width for menus, popovers, and stage panels */
--size-stage-min: 200px;
+ /* Hub sidebar (Designs hub left rail) */
+ --size-hub-sidebar: 360px;
+
+ /* Modal widths */
+ --size-modal-md: 560px;
+
+ /* Grid card minimum (project / artifact cards) */
+ --size-card-min: 240px;
+
+ /* Reading-width caps for prose / descriptions */
+ --size-prose-narrow: 60ch;
+
+ /* Card hover lift offset */
+ --lift-card-hover: 1px;
+
/* Press feedback scale */
--scale-hover-up: 1.04;
--scale-press-down: 0.96;