From a0aaf16f4553c67065165d6fafb73ef8b736dab7 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sun, 19 Apr 2026 03:16:57 +0800 Subject: [PATCH 1/7] feat(desktop): designs hub + multi-type create wizard (Claude Design adoption PR-A) Replaces the straight-to-composer launch flow with a designs hub modeled on Claude Design's Recent / Your designs / Examples / Design systems navigation plus a typed create wizard (Prototype / Slide deck / From template / Other). - Hub view with four sibling tabs and a primary "New design" CTA - Modal create flow; CTA stays disabled until a project name is provided - Per-type forms; PR-F will fill in the wireframe vs high-fidelity cards - Examples and Design systems tabs ship as placeholders for PR-B / PR-C - Project schema lives in shared with schemaVersion=1; persisted to localStorage for now (SQLite migration arrives with PR-C) - Full en + zh-CN coverage; tokens from packages/ui (no hardcoded values) - Vitest covering the create-draft logic; existing 144-test suite untouched Compatibility: green - no IPC/main changes, no schema breaks. Upgradeability: green - schemaVersion field on every Project payload. No bloat: green - no new dependencies; reuses lucide-react + zustand. Elegance: green - single store action per intent; per-type forms < 40 LOC. Signed-off-by: hqhq1025 <1506751656@qq.com> --- apps/desktop/src/renderer/src/App.tsx | 30 ++- apps/desktop/src/renderer/src/store.test.ts | 4 +- apps/desktop/src/renderer/src/store.ts | 108 ++++++++++- .../src/renderer/src/views/HubView.tsx | 70 +++++++ .../views/create/CreateProjectModal.test.ts | 56 ++++++ .../src/views/create/CreateProjectModal.tsx | 181 ++++++++++++++++++ .../src/views/create/FromTemplateForm.tsx | 70 +++++++ .../renderer/src/views/create/OtherForm.tsx | 22 +++ .../src/views/create/PrototypeForm.tsx | 28 +++ .../src/views/create/SlideDeckForm.tsx | 43 +++++ .../src/renderer/src/views/create/fields.tsx | 41 ++++ .../src/views/hub/DesignSystemsTab.tsx | 15 ++ .../renderer/src/views/hub/ProjectGrid.tsx | 50 +++++ .../src/renderer/src/views/hub/RecentTab.tsx | 14 ++ .../renderer/src/views/hub/YourDesignsTab.tsx | 10 + packages/i18n/src/locales/en.json | 73 +++++++ packages/i18n/src/locales/zh-CN.json | 73 +++++++ packages/shared/src/index.ts | 30 +++ 18 files changed, 902 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/renderer/src/views/HubView.tsx create mode 100644 apps/desktop/src/renderer/src/views/create/CreateProjectModal.test.ts create mode 100644 apps/desktop/src/renderer/src/views/create/CreateProjectModal.tsx create mode 100644 apps/desktop/src/renderer/src/views/create/FromTemplateForm.tsx create mode 100644 apps/desktop/src/renderer/src/views/create/OtherForm.tsx create mode 100644 apps/desktop/src/renderer/src/views/create/PrototypeForm.tsx create mode 100644 apps/desktop/src/renderer/src/views/create/SlideDeckForm.tsx create mode 100644 apps/desktop/src/renderer/src/views/create/fields.tsx create mode 100644 apps/desktop/src/renderer/src/views/hub/DesignSystemsTab.tsx create mode 100644 apps/desktop/src/renderer/src/views/hub/ProjectGrid.tsx create mode 100644 apps/desktop/src/renderer/src/views/hub/RecentTab.tsx create mode 100644 apps/desktop/src/renderer/src/views/hub/YourDesignsTab.tsx diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 702034d5..6b8222a2 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,26 @@ export function App() {
{view === 'settings' ? ( + ) : view === 'hub' ? ( + ) : ( -
- -
- 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..0f9fc7c2 100644 --- a/apps/desktop/src/renderer/src/store.test.ts +++ b/apps/desktop/src/renderer/src/store.test.ts @@ -231,8 +231,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', () => { diff --git a/apps/desktop/src/renderer/src/store.ts b/apps/desktop/src/renderer/src/store.ts index 86eccce8..41f4d343 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, + type 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,34 @@ interface CodesignState { } const THEME_STORAGE_KEY = 'open-codesign:theme'; +const PROJECTS_STORAGE_KEY = 'open-codesign:projects:v1'; + +function readStoredProjects(): Project[] { + if (typeof window === 'undefined') return []; + try { + const raw = window.localStorage.getItem(PROJECTS_STORAGE_KEY); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return []; + return parsed.filter( + (p): p is Project => + typeof p === 'object' && + p !== null && + (p as { schemaVersion?: unknown }).schemaVersion === PROJECT_SCHEMA_VERSION, + ); + } catch { + return []; + } +} + +function persistProjects(projects: Project[]): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects)); + } catch { + // localStorage unavailable + } +} function readInitialTheme(): Theme { if (typeof window === 'undefined') return 'light'; @@ -292,7 +333,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: readStoredProjects(), + currentProjectId: null, + createProjectModalOpen: false, commandPaletteOpen: false, toasts: [], iframeErrors: [], @@ -613,6 +658,51 @@ 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]; + persistProjects(next); + set({ + projects: next, + currentProjectId: project.id, + view: 'workspace', + createProjectModalOpen: false, + messages: [], + previewHtml: null, + generationStage: 'idle' as GenerationStage, + }); + return project; + }, + + openProject(id) { + const project = get().projects.find((p) => p.id === id); + if (!project) return; + set({ currentProjectId: id, view: 'workspace' }); + }, + openCommandPalette() { set({ commandPaletteOpen: true }); }, 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..a95405d2 --- /dev/null +++ b/apps/desktop/src/renderer/src/views/HubView.tsx @@ -0,0 +1,70 @@ +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 function HubView() { + 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' ? : 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..39a7f057 --- /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..068227dc --- /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..07445b3a --- /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..9543e500 --- /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/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index d9aa7c63..ab2e3d7d 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -450,5 +450,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..96185cf1 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -449,5 +449,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, From f5bf3d60dd22c17ca325178a7e47887687de8df5 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sun, 19 Apr 2026 04:23:26 +0800 Subject: [PATCH 2/7] fix(desktop): surface project storage errors via toast + warn (no silent fallback) - readStoredProjects/persistProjects now console.warn and return an error string instead of swallowing exceptions; createProject pushes an error toast on persist failure while keeping in-memory state consistent. - Validate stored projects with the Project zod schema (safeParse) and count rejected records so corrupted entries surface a toast instead of silently disappearing. - openProject resets project-scoped workspace state (messages, preview, inputs, generation flags) to prevent cross-project state leakage. - Add errors.projectStorageFailed i18n key (en + zh-CN). - Vitest: mock localStorage.setItem to throw, assert toast pushed and the new project is still added to in-memory state. Addresses Codex blocker on PR #51. Signed-off-by: hqhq1025 <1506751656@qq.com> --- apps/desktop/src/renderer/src/store.test.ts | 46 +++++++++ apps/desktop/src/renderer/src/store.ts | 107 ++++++++++++++++---- packages/i18n/src/locales/en.json | 3 +- packages/i18n/src/locales/zh-CN.json | 3 +- 4 files changed, 135 insertions(+), 24 deletions(-) diff --git a/apps/desktop/src/renderer/src/store.test.ts b/apps/desktop/src/renderer/src/store.test.ts index 0f9fc7c2..0b53f4a8 100644 --- a/apps/desktop/src/renderer/src/store.test.ts +++ b/apps/desktop/src/renderer/src/store.test.ts @@ -331,3 +331,49 @@ 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(); + }); +}); diff --git a/apps/desktop/src/renderer/src/store.ts b/apps/desktop/src/renderer/src/store.ts index 41f4d343..904821fe 100644 --- a/apps/desktop/src/renderer/src/store.ts +++ b/apps/desktop/src/renderer/src/store.ts @@ -5,7 +5,7 @@ import { type ModelRef, type OnboardingState, PROJECT_SCHEMA_VERSION, - type Project, + Project, type ProjectDraft, type SelectedElement, type SupportedOnboardingProvider, @@ -131,30 +131,59 @@ interface CodesignState { const THEME_STORAGE_KEY = 'open-codesign:theme'; const PROJECTS_STORAGE_KEY = 'open-codesign:projects:v1'; -function readStoredProjects(): Project[] { - if (typeof window === 'undefined') return []; +type ProjectsReadResult = { projects: Project[]; error: string | null }; + +function readStoredProjects(): ProjectsReadResult { + if (typeof window === 'undefined') return { projects: [], error: null }; + let raw: string | null; try { - const raw = window.localStorage.getItem(PROJECTS_STORAGE_KEY); - if (!raw) return []; - const parsed = JSON.parse(raw) as unknown; - if (!Array.isArray(parsed)) return []; - return parsed.filter( - (p): p is Project => - typeof p === 'object' && - p !== null && - (p as { schemaVersion?: unknown }).schemaVersion === PROJECT_SCHEMA_VERSION, - ); - } catch { - return []; + 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[]): void { - if (typeof window === 'undefined') return; +function persistProjects(projects: Project[]): { error: string | null } { + if (typeof window === 'undefined') return { error: null }; try { window.localStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects)); - } catch { - // localStorage unavailable + 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 }; } } @@ -318,6 +347,8 @@ function buildPromptRequest( }; } +const initialProjectsRead = readStoredProjects(); + export const useCodesignStore = create((set, get) => ({ messages: [], previewHtml: null, @@ -335,7 +366,7 @@ export const useCodesignStore = create((set, get) => ({ theme: readInitialTheme(), view: 'hub' as AppView, hubTab: 'recent' as HubTab, - projects: readStoredProjects(), + projects: initialProjectsRead.projects, currentProjectId: null, createProjectModalOpen: false, commandPaletteOpen: false, @@ -684,7 +715,7 @@ export const useCodesignStore = create((set, get) => ({ ...(draft.templateId ? { templateId: draft.templateId } : {}), }; const next = [project, ...get().projects]; - persistProjects(next); + const persist = persistProjects(next); set({ projects: next, currentProjectId: project.id, @@ -694,13 +725,34 @@ export const useCodesignStore = create((set, get) => ({ previewHtml: null, generationStage: 'idle' as GenerationStage, }); + 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' }); + 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() { @@ -725,3 +777,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/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index ab2e3d7d..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": { diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 96185cf1..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": { From 61b84528498207fc70857679eb1bef090a77790c Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sun, 19 Apr 2026 04:57:04 +0800 Subject: [PATCH 3/7] fix(desktop): replace hardcoded checkbox sizing with --size-icon-md token SlideDeckForm checkbox used `w-4 h-4` literals which violate the token-only UI constraint. Swap to `var(--size-icon-md)` (16px) so the control scales with centralized theming. Addresses Codex blocker on PR #51. Signed-off-by: hqhq1025 <1506751656@qq.com> --- apps/desktop/src/renderer/src/views/create/SlideDeckForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/src/views/create/SlideDeckForm.tsx b/apps/desktop/src/renderer/src/views/create/SlideDeckForm.tsx index 068227dc..a85806e7 100644 --- a/apps/desktop/src/renderer/src/views/create/SlideDeckForm.tsx +++ b/apps/desktop/src/renderer/src/views/create/SlideDeckForm.tsx @@ -24,7 +24,7 @@ export function SlideDeckForm({ type="checkbox" checked={speakerNotes} onChange={(e) => setSpeakerNotes(e.target.checked)} - className="mt-[var(--space-1)] w-4 h-4 accent-[var(--color-accent)]" + className="mt-[var(--space-1)] w-[var(--size-icon-md)] h-[var(--size-icon-md)] accent-[var(--color-accent)]" /> From 9d36b9e50f3bd7a0a0078bfd95f90d07a438ce4e Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sun, 19 Apr 2026 04:58:11 +0800 Subject: [PATCH 4/7] chore: clear pre-existing biome errors blocking pre-push hook - tailwindExtractor: replace non-null assertion with safe cast - InlineCommentComposer: apply formatter Signed-off-by: hqhq1025 <1506751656@qq.com> --- packages/core/src/brand/tailwindExtractor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) { From 78f00605f499b9d4535f0519037dcfab1dfcfd35 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sun, 19 Apr 2026 07:39:57 +0800 Subject: [PATCH 5/7] fix(desktop): wire ExamplesTab onUsePrompt after rebase onto PR-B PR-B merged ExamplesTab as a real component requiring an onUsePrompt callback. After rebase, surface that prop through HubView and have App prefill the workspace prompt + switch view. Signed-off-by: hqhq1025 <1506751656@qq.com> --- apps/desktop/src/renderer/src/App.tsx | 7 ++++++- apps/desktop/src/renderer/src/views/HubView.tsx | 10 ++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 6b8222a2..1a98c9f9 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -117,7 +117,12 @@ export function App() { {view === 'settings' ? ( ) : view === 'hub' ? ( - + { + setPrompt(p); + setView('workspace'); + }} + /> ) : (
diff --git a/apps/desktop/src/renderer/src/views/HubView.tsx b/apps/desktop/src/renderer/src/views/HubView.tsx index a95405d2..7fd74a1d 100644 --- a/apps/desktop/src/renderer/src/views/HubView.tsx +++ b/apps/desktop/src/renderer/src/views/HubView.tsx @@ -9,7 +9,11 @@ import { YourDesignsTab } from './hub/YourDesignsTab'; const TABS: HubTab[] = ['recent', 'your', 'examples', 'designSystems']; -export function HubView() { +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); @@ -60,7 +64,9 @@ export function HubView() {
{hubTab === 'recent' ? : null} {hubTab === 'your' ? : null} - {hubTab === 'examples' ? : null} + {hubTab === 'examples' ? ( + onUseExamplePrompt?.(example.prompt)} /> + ) : null} {hubTab === 'designSystems' ? : null}
From 3bd78df5e202853c6c20e8d6d714ec5acb73d9a0 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sun, 19 Apr 2026 08:39:36 +0800 Subject: [PATCH 6/7] fix(desktop): reset project-scoped workspace state in createProject createProject was only clearing messages/previewHtml/generationStage, leaving inputFiles, referenceUrl, selectedElement, lastPromptInput, and generation/error flags inherited from the previously open project. Mirror openProject's full reset so a freshly created project starts with a clean workspace and prompt context. Adds vitest coverage for the reset. Refs codex review on PR #51. Signed-off-by: hqhq1025 <1506751656@qq.com> --- apps/desktop/src/renderer/src/store.test.ts | 61 ++++++++++++++++++++- apps/desktop/src/renderer/src/store.ts | 8 +++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/src/store.test.ts b/apps/desktop/src/renderer/src/store.test.ts index 0b53f4a8..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 = { @@ -376,4 +377,62 @@ describe('useCodesignStore project storage error surfacing', () => { 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 904821fe..2d150f1b 100644 --- a/apps/desktop/src/renderer/src/store.ts +++ b/apps/desktop/src/renderer/src/store.ts @@ -723,7 +723,15 @@ export const useCodesignStore = create((set, get) => ({ 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({ From e73e6a36ea08346a03a6b41c16aeca61eaaa1fc7 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Sun, 19 Apr 2026 09:23:43 +0800 Subject: [PATCH 7/7] fix(ui): replace hardcoded sizing in hub/create with new tokens PR-A introduced raw arbitrary-value Tailwind classes (360px sidebar, 560px modal, 60ch prose, 240px card minimum, 1px hover lift) that violate the token-only constraint in CLAUDE.md. Add five tokens to packages/ui/src/tokens.css and route every call site through them so themes and density tweaks stay centralized. --- apps/desktop/src/renderer/src/App.tsx | 2 +- .../src/views/create/CreateProjectModal.tsx | 2 +- .../renderer/src/views/hub/DesignSystemsTab.tsx | 2 +- .../src/renderer/src/views/hub/ProjectGrid.tsx | 6 +++--- packages/ui/src/tokens.css | 15 +++++++++++++++ 5 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 1a98c9f9..92504474 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -135,7 +135,7 @@ export function App() { {t('hub.backToHub')}
-
+
setPrompt(p)} /> diff --git a/apps/desktop/src/renderer/src/views/create/CreateProjectModal.tsx b/apps/desktop/src/renderer/src/views/create/CreateProjectModal.tsx index 39a7f057..069ec52c 100644 --- a/apps/desktop/src/renderer/src/views/create/CreateProjectModal.tsx +++ b/apps/desktop/src/renderer/src/views/create/CreateProjectModal.tsx @@ -82,7 +82,7 @@ export function CreateProjectModal() { onClick={closeModal} className="absolute inset-0 bg-[var(--color-overlay)]" /> -
+

+

{t('hub.designSystems.title')}

diff --git a/apps/desktop/src/renderer/src/views/hub/ProjectGrid.tsx b/apps/desktop/src/renderer/src/views/hub/ProjectGrid.tsx index 9543e500..86052789 100644 --- a/apps/desktop/src/renderer/src/views/hub/ProjectGrid.tsx +++ b/apps/desktop/src/renderer/src/views/hub/ProjectGrid.tsx @@ -13,21 +13,21 @@ export function ProjectGrid({ projects, emptyLabel }: ProjectGridProps) { if (projects.length === 0) { return ( -

+

{emptyLabel}

); } return ( -
    +
      {projects.map((p) => (