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 ( + + ); + })} +
+ +
{ + e.preventDefault(); + submit(); + }} + className="px-[var(--space-6)] py-[var(--space-5)] space-y-[var(--space-4)]" + > +

+ {t(`create.typeDescriptions.${type}`)} +

+ + {type === 'prototype' ? : null} + {type === 'slideDeck' ? ( + + ) : null} + {type === 'template' ? ( + + ) : null} + {type === 'other' ? : null} + + + +
+
+ ); +} 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.fields.template')} + +
+ {templates.map((tmpl) => { + const checked = templateId === tmpl.id; + return ( + + ); + })} +
+ {t('create.fields.templateHint')} +
+ {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 ( + + ); +} 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;