import { useEffect, useId, useMemo, useRef, useState } from 'react'; import { createTabToTracking } from '@open-design/contracts/analytics'; import { isOpenDesignHostAvailable, pickAndImportHostProject, type OpenDesignHostProjectImportSuccess, } from '@open-design/host'; import { useAnalytics } from '../analytics/provider'; import { trackDesignSystemApplyResult, trackNewProjectModalElementClick, trackNewProjectModalSurfaceView, trackNewProjectModalTabClick, } from '../analytics/events'; import type { ConnectorDetail } from '@open-design/contracts'; import type { TrackingDesignSystemApplyTargetKind, TrackingDesignSystemOrigin, TrackingDesignSystemStatusValue, } from '@open-design/contracts/analytics'; import { useT } from '../i18n'; import type { Dict } from '../i18n/types'; import { fetchPromptTemplate } from '../providers/registry'; import { isStoredMediaProviderEntryPresent } from '../state/config'; import { isMediaProviderPickerReady } from '../media/provider-readiness'; import type { AudioKind, DesignSystemSummary, MediaAspect, ProjectKind, ProjectMetadata, ProjectPlatform, ProjectTemplate, MediaProviderCredentials, PromptTemplateSummary, SkillSummary, } from '../types'; import { AUDIO_DURATIONS_SEC, AUDIO_MODELS_BY_KIND, DEFAULT_AUDIO_MODEL, DEFAULT_IMAGE_MODEL, DEFAULT_VIDEO_MODEL, findProvider, IMAGE_MODELS, MEDIA_ASPECTS, type MediaModel, VIDEO_LENGTHS_SEC, VIDEO_MODELS, } from '../media/models'; import { formatPickAndImportFailure } from '../utils/pickAndImportError'; import { Icon } from './Icon'; import { Skeleton } from './Loading'; import { Toast } from './Toast'; // Snapshot of a curated prompt template, captured at New Project time and // folded into ProjectMetadata.promptTemplate. The user may have edited the // prompt body before clicking Create — that edited copy lives here. type PromptTemplatePick = { summary: PromptTemplateSummary; prompt: string; }; const SFX_AUDIO_DURATIONS_SEC = AUDIO_DURATIONS_SEC.filter((sec) => sec <= 30); type TranslateFn = (key: keyof Dict, vars?: Record) => string; type NewProjectPlatform = Exclude; const DESIGN_PLATFORMS: Array<{ value: NewProjectPlatform; labelKey: keyof Dict; hintKey: keyof Dict; }> = [ { value: 'responsive', labelKey: 'newproj.platform.responsive.label', hintKey: 'newproj.platform.responsive.hint', }, { value: 'web-desktop', labelKey: 'newproj.platform.webDesktop.label', hintKey: 'newproj.platform.webDesktop.hint', }, { value: 'mobile-ios', labelKey: 'newproj.platform.mobileIos.label', hintKey: 'newproj.platform.mobileIos.hint', }, { value: 'mobile-android', labelKey: 'newproj.platform.mobileAndroid.label', hintKey: 'newproj.platform.mobileAndroid.hint', }, { value: 'tablet', labelKey: 'newproj.platform.tablet.label', hintKey: 'newproj.platform.tablet.hint', }, { value: 'desktop-app', labelKey: 'newproj.platform.desktopApp.label', hintKey: 'newproj.platform.desktopApp.hint', }, ]; export type CreateTab = 'prototype' | 'live-artifact' | 'deck' | 'template' | 'media' | 'other'; export type MediaSurface = 'image' | 'video' | 'audio'; export interface CreateInput { name: string; skillId: string | null; designSystemId: string | null; metadata: ProjectMetadata; } export type ImportClaudeDesignOutcome = | { ok: true } | { ok: false; message?: string; details?: string }; interface Props { skills: SkillSummary[]; designSystems: DesignSystemSummary[]; defaultDesignSystemId: string | null; templates: ProjectTemplate[]; onDeleteTemplate?: (id: string) => Promise; promptTemplates: PromptTemplateSummary[]; onCreate: (input: CreateInput & { requestId?: string }) => void; onImportClaudeDesign?: ( file: File, ) => Promise | ImportClaudeDesignOutcome | void; // Web fallback: the user types an absolute baseDir into the manual // input and the renderer POSTs `/api/import/folder` itself. Browser // builds have no `shell.openPath` surface, so the renderer naming a // path here cannot escalate (PR #974 trust model). onImportFolder?: (baseDir: string) => Promise | void; // Host flow: the desktop main process owns the picker dialog and // the import call atomically (`pickAndImport` IPC). The renderer // never sees the path or the HMAC token; it only receives the // host-owned project identifiers and forwards them here so App-level // state can refresh through the daemon API. onImportFolderResponse?: (response: OpenDesignHostProjectImportSuccess) => Promise | void; mediaProviders?: Record; connectors?: ConnectorDetail[]; connectorsLoading?: boolean; onOpenConnectorsTab?: () => void; loading?: boolean; initialTab?: CreateTab; } const TAB_LABEL_KEYS: Record = { prototype: 'newproj.tabPrototype', 'live-artifact': 'newproj.tabLiveArtifact', deck: 'newproj.tabDeck', template: 'newproj.tabTemplate', media: 'newproj.tabMedia', other: 'newproj.tabOther', }; // Maps the New Project tab + media surface to the apply-result target // kind enum. `media` collapses to image/video/audio inside callers; // this helper covers the non-media tabs and the live-artifact special // case. Media surfaces map case-by-case at the call site. function newProjectTabToApplyKind( tab: CreateTab, ): TrackingDesignSystemApplyTargetKind { switch (tab) { case 'prototype': return 'prototype'; case 'deck': return 'slide_deck'; case 'live-artifact': return 'live_artifact'; case 'media': // Media tab has its own surface picker; the apply emission // happens before the user selects image/video/audio, so we // mark it `unknown` rather than guessing. The picker is also // typically hidden under media but the helper stays total. return 'unknown'; case 'template': case 'other': return 'unknown'; } } // Maps a `DesignSystemSummary.source` value to the DS origin enum used // by `design_system_apply_result.design_system_source`. The summary // shape only carries `'built-in' | 'installed' | 'user'`; we map them // onto the doc's enum: user → manual_create, built-in → official_preset, // installed → template. function deriveDesignSystemOrigin( system: DesignSystemSummary | undefined, ): TrackingDesignSystemOrigin | undefined { if (!system) return undefined; switch (system.source) { case 'user': return 'manual_create'; case 'built-in': return 'official_preset'; case 'installed': return 'template'; default: return 'unknown'; } } function deriveDesignSystemStatusValue( system: DesignSystemSummary | undefined, ): TrackingDesignSystemStatusValue | undefined { if (!system) return undefined; switch (system.status) { case 'draft': case 'published': return system.status; default: return 'unknown'; } } const MEDIA_SURFACE_LABEL_KEYS: Record = { image: 'newproj.surfaceImage', video: 'newproj.surfaceVideo', audio: 'newproj.surfaceAudio', }; export function defaultDesignSystemSelection( defaultDesignSystemId: string | null, designSystems: DesignSystemSummary[], ): string[] { if (!defaultDesignSystemId) return []; return designSystems.some((d) => d.id === defaultDesignSystemId) ? [defaultDesignSystemId] : []; } export function buildDesignSystemCreateSelection( showDesignSystemPicker: boolean, selectedIds: string[], ): { primary: string | null; inspirations: string[] } { return showDesignSystemPicker ? { primary: selectedIds[0] ?? null, inspirations: selectedIds.slice(1), } : { primary: null, inspirations: [] }; } export function NewProjectPanel({ skills, designSystems, defaultDesignSystemId, templates, onDeleteTemplate, promptTemplates, onCreate, onImportClaudeDesign, onImportFolder, onImportFolderResponse, mediaProviders, connectors, connectorsLoading = false, onOpenConnectorsTab, loading = false, initialTab = 'prototype', }: Props) { const t = useT(); const analytics = useAnalytics(); const importInputRef = useRef(null); const [importing, setImporting] = useState(false); const [importZipError, setImportZipError] = useState< { message: string; details?: string } | null >(null); const [baseDir, setBaseDir] = useState(''); const [importingFolder, setImportingFolder] = useState(false); // PR #974 round-4 (mrcfps): pickAndImport now returns structured // failure shapes (`desktop auth secret not registered`, `web sidecar // URL not available`, `daemon returned HTTP X`) — surfacing them // gives the user a recovery hint instead of a silent no-op. // Shape: `{ message, details? }`. `null` means no toast. const [importFolderError, setImportFolderError] = useState< { message: string; details?: string } | null >(null); const [tab, setTab] = useState(initialTab); // P0 analytics — fire surface_view once per (panel mount, tab) pair so the // funnel sees both initial open and tab switches without double-counting on // unrelated re-renders. Ref keys on a tab string because the panel is a // long-lived component the modal mounts/unmounts as the user opens/closes it. const newProjectViewedTabRef = useRef(null); useEffect(() => { if (newProjectViewedTabRef.current === tab) return; newProjectViewedTabRef.current = tab; trackNewProjectModalSurfaceView(analytics.track, { page_name: 'home', area: 'new_project_modal', tab_name: createTabToTracking(tab), }); }, [tab, analytics.track]); // Media tab consolidates image / video / audio. The active surface picks // which set of options + skill resolution applies; submission still maps // back to the existing image/video/audio ProjectKind branches so the // backend contract is unchanged. const [mediaSurface, setMediaSurface] = useState('image'); const tabsRef = useRef(null); const [tabScroll, setTabScroll] = useState({ left: false, right: false }); const [name, setName] = useState(''); // Design-system selection is now an *array* internally so the same // component can drive both single-select and multi-select modes without // duplicating state. Single-select coerces to length 0/1. const initialDefaultDsSelection = useMemo( () => defaultDesignSystemSelection(defaultDesignSystemId, designSystems), [defaultDesignSystemId, designSystems], ); const [selectedDsIds, setSelectedDsIds] = useState( () => initialDefaultDsSelection, ); const [dsSelectionTouched, setDsSelectionTouched] = useState(false); const [dsMulti, setDsMulti] = useState(false); // Per-tab metadata. Tracked independently so switching tabs preserves // each tab's pick rather than resetting to defaults. const [fidelity, setFidelity] = useState<'wireframe' | 'high-fidelity'>( 'high-fidelity', ); const [platformTargets, setPlatformTargets] = useState(['responsive']); const [includeLandingPage, setIncludeLandingPage] = useState(false); const [includeOsWidgets, setIncludeOsWidgets] = useState(false); const [speakerNotes, setSpeakerNotes] = useState(false); const [animations, setAnimations] = useState(false); const [templateId, setTemplateId] = useState(null); const [imageModel, setImageModel] = useState(DEFAULT_IMAGE_MODEL); const [imageAspect, setImageAspect] = useState('1:1'); const [videoModel, setVideoModel] = useState(DEFAULT_VIDEO_MODEL); const [videoModelTouched, setVideoModelTouched] = useState(false); const [videoAspect, setVideoAspect] = useState('16:9'); const [videoLength, setVideoLength] = useState(5); const [audioKind, setAudioKind] = useState('speech'); const [audioModel, setAudioModel] = useState(DEFAULT_AUDIO_MODEL.speech); const [audioDuration, setAudioDuration] = useState(10); const [voice, setVoice] = useState(''); // Per-surface curated prompt template the user picked. Tracked // independently for image vs video so flipping tabs doesn't clobber the // other one's pick. The body is editable in-line and the edited copy is // what gets carried to the agent — that's the "optimize the template" // affordance the design brief asks for. const [imagePromptTemplate, setImagePromptTemplate] = useState(null); const [videoPromptTemplate, setVideoPromptTemplate] = useState(null); // Design system is meaningful only for the structured/visual surfaces // (prototype, deck, template, and the freeform "other" canvas). The // media surfaces use prompt templates instead — design tokens don't map // onto image/video/audio generations, and the picker just adds noise // there. Keep this list explicit so future tabs declare their intent. const tabSupportsDesignSystem = tab === 'prototype' || tab === 'deck' || tab === 'template' || tab === 'other'; // Orbit briefings ship their own complete visual language baked into // example.html and explicitly opt out of DESIGN.md injection via // `od.design_system.requires: false`. Hide the picker only for those // Orbit scenario skills; the general prototype creation surface should // still honor the user's configured default design system even when a // non-Orbit default skill does not require one. const tabDefaultSkillForcesNoDs = useMemo(() => { const tabSkillId = ((): string | null => { if (tab === 'prototype' || tab === 'live-artifact') { const list = skills.filter((s) => s.mode === 'prototype'); return list.find((s) => s.defaultFor.includes('prototype'))?.id ?? list[0]?.id ?? null; } if (tab === 'deck') { const list = skills.filter((s) => s.mode === 'deck'); return list.find((s) => s.defaultFor.includes('deck'))?.id ?? list[0]?.id ?? null; } return null; })(); if (!tabSkillId) return false; const s = skills.find((x) => x.id === tabSkillId); return s ? s.scenario === 'orbit' && s.designSystemRequired === false : false; }, [tab, skills]); const showDesignSystemPicker = tabSupportsDesignSystem && !tabDefaultSkillForcesNoDs; useEffect(() => { if (dsSelectionTouched) return; setSelectedDsIds(initialDefaultDsSelection); }, [dsSelectionTouched, initialDefaultDsSelection]); // Fires `design_system_apply_result` with `auto_select` when the // picker mounts/refreshes and pre-selects the user's default DS // without an explicit click. Only emits once per default-id while // the picker is showing, and only while the user hasn't manually // changed the selection (so the dashboard separates auto vs manual // attribution). The picker visibility guard skips media tabs where // the DS picker isn't rendered. const autoSelectFiredForRef = useRef(null); useEffect(() => { if (!showDesignSystemPicker) return; if (dsSelectionTouched) return; const primary = initialDefaultDsSelection[0]; if (!primary) return; if (autoSelectFiredForRef.current === primary) return; autoSelectFiredForRef.current = primary; const picked = designSystems.find((d) => d.id === primary); trackDesignSystemApplyResult(analytics.track, { page_name: 'home', area: 'design_system_picker', action: 'auto_select', result: 'success', target_project_kind: newProjectTabToApplyKind(tab), design_system_id: primary, design_system_source: deriveDesignSystemOrigin(picked), design_system_status: deriveDesignSystemStatusValue(picked), design_system_applied: true, design_system_selection_mode: 'default', is_default: true, is_auto_selected: true, available_design_system_count: designSystems.length, duration_ms: 0, }); }, [ analytics.track, designSystems, dsSelectionTouched, initialDefaultDsSelection, showDesignSystemPicker, tab, ]); // When entering the template tab, snap to the first user-saved template // if there is one (and we don't already have a valid pick). The template // tab no longer offers a built-in fallback — the entire point is to // start from a template *the user* created via Share. useEffect(() => { if (tab !== 'template') return; if (templates.length === 0) { setTemplateId(null); return; } if (templateId == null || !templates.some((t) => t.id === templateId)) { setTemplateId(templates[0]!.id); } }, [tab, templates, templateId]); // The skill the request still routes through — kept so prototype/deck // pick a default-rendered skill (so the agent gets the right SKILL.md // body) without requiring the user to choose one explicitly. const skillIdForTab = useMemo(() => { if (tab === 'other') return null; if (tab === 'prototype') { const list = skills.filter((s) => s.mode === 'prototype'); return list.find((s) => s.defaultFor.includes('prototype'))?.id ?? list[0]?.id ?? null; } if (tab === 'live-artifact') { const exact = skills.find((s) => s.id === 'live-artifact' || s.name === 'live-artifact'); if (exact) return exact.id; const hinted = skills.find((s) => { const haystack = `${s.id} ${s.name} ${s.description} ${s.triggers.join(' ')}`.toLowerCase(); return haystack.includes('live artifact') || haystack.includes('live-artifact'); }); if (hinted) return hinted.id; const prototypes = skills.filter((s) => s.mode === 'prototype'); return prototypes.find((s) => s.defaultFor.includes('prototype'))?.id ?? prototypes[0]?.id ?? null; } if (tab === 'deck') { const list = skills.filter((s) => s.mode === 'deck'); return list.find((s) => s.defaultFor.includes('deck'))?.id ?? list[0]?.id ?? null; } if (tab === 'media') { const list = skills.filter( (s) => s.mode === mediaSurface || s.surface === mediaSurface, ); // The HyperFrames-HTML render path lives in the `hyperframes` skill. // When the user has chosen `hyperframes-html` (via dropdown or template), // pin the project to that skill explicitly. if (mediaSurface === 'video' && videoModel === 'hyperframes-html') { const hyper = list.find((s) => s.id === 'hyperframes'); if (hyper) return hyper.id; } return list.find((s) => s.defaultFor.includes(mediaSurface))?.id ?? list[0]?.id ?? null; } return null; }, [tab, mediaSurface, skills, videoModel]); // When the user picks a curated prompt template, propagate the template's // declared `model` and `aspect` onto the actual project state. Without // this the user picks (e.g.) a HyperFrames template but `videoModel` // stays on the default seedance — the agent then dispatches the wrong // model and the render path mismatches the prompt. function handleImagePromptTemplate(pick: PromptTemplatePick | null) { setImagePromptTemplate(pick); const m = pick?.summary.model; if (m && IMAGE_MODELS.some((x) => x.id === m)) setImageModel(m); const a = pick?.summary.aspect; if (a && (MEDIA_ASPECTS as readonly string[]).includes(a)) { setImageAspect(a as MediaAspect); } } function handleVideoPromptTemplate(pick: PromptTemplatePick | null) { setVideoPromptTemplate(pick); const m = pick?.summary.model; if (m && VIDEO_MODELS.some((x) => x.id === m)) { setVideoModel(m); setVideoModelTouched(true); } const a = pick?.summary.aspect; if (a && (MEDIA_ASPECTS as readonly string[]).includes(a)) { setVideoAspect(a as MediaAspect); } } function handleVideoModel(id: string) { setVideoModel(id); setVideoModelTouched(true); } // The HyperFrames skill renders HTML compositions through a local // `npx hyperframes render` path, which dispatches under the // `hyperframes-html` model — not seedance/veo/sora. When the resolved // skill for the video tab is hyperframes, default `videoModel` so the // model dropdown matches the actual render path. Once the user has // explicitly chosen a model (via the dropdown or by picking a template // that declares a model), `videoModelTouched` latches and this effect // becomes a no-op for the rest of the panel session — re-entering the // Media tab's Video surface no longer silently rewrites their override back to // hyperframes-html. useEffect(() => { if (tab !== 'media' || mediaSurface !== 'video') return; if (skillIdForTab !== 'hyperframes') return; if (videoModelTouched) return; if (videoPromptTemplate) return; if (!VIDEO_MODELS.some((m) => m.id === 'hyperframes-html')) return; setVideoModel('hyperframes-html'); // Intentionally leaving videoPromptTemplate / videoModel out of deps // so this only fires when the user toggles the tab or the skill // resolution shifts — not whenever the user changes the dropdown. // eslint-disable-next-line react-hooks/exhaustive-deps }, [tab, mediaSurface, skillIdForTab, videoModelTouched]); const canCreate = !loading && (tab !== 'template' || templateId != null); function updateTabScrollState() { const el = tabsRef.current; if (!el) return; const maxLeft = el.scrollWidth - el.clientWidth; setTabScroll({ left: el.scrollLeft > 2, right: el.scrollLeft < maxLeft - 2, }); } function scrollTabs(direction: -1 | 1) { const el = tabsRef.current; if (!el) return; el.scrollBy({ left: direction * Math.max(120, el.clientWidth * 0.65), behavior: 'smooth', }); } function handleDesignSystemChange(ids: string[]) { setDsSelectionTouched(true); setSelectedDsIds(ids); const previousPrimary = selectedDsIds[0] ?? null; const nextPrimary = ids[0] ?? null; // Only emit when the primary actually changed; secondary reorders // inside multi-select don't count as a fresh apply. if (previousPrimary === nextPrimary) return; const targetKind = newProjectTabToApplyKind(tab); if (ids.length === 0) { trackDesignSystemApplyResult(analytics.track, { page_name: 'home', area: 'design_system_picker', action: 'clear_selection', result: 'success', target_project_kind: targetKind, design_system_applied: false, design_system_selection_mode: 'none', is_default: false, is_auto_selected: false, available_design_system_count: designSystems.length, duration_ms: 0, }); return; } if (!nextPrimary) return; const picked = designSystems.find((d) => d.id === nextPrimary); const isDefault = nextPrimary === defaultDesignSystemId; trackDesignSystemApplyResult(analytics.track, { page_name: 'home', area: 'design_system_picker', action: 'select_design_system', result: 'success', target_project_kind: targetKind, design_system_id: nextPrimary, design_system_source: deriveDesignSystemOrigin(picked), design_system_status: deriveDesignSystemStatusValue(picked), design_system_applied: true, design_system_selection_mode: isDefault ? 'default' : 'manual', is_default: isDefault, // `is_auto_selected` reports whether this row was picked by the // app (initial default selection from `initialDefaultDsSelection`) // rather than by the user. Once `dsSelectionTouched` is set we // know any subsequent change came from a click. is_auto_selected: false, available_design_system_count: designSystems.length, duration_ms: 0, }); } useEffect(() => { const el = tabsRef.current; if (!el) return; updateTabScrollState(); const onScroll = () => updateTabScrollState(); el.addEventListener('scroll', onScroll, { passive: true }); const ro = new ResizeObserver(updateTabScrollState); ro.observe(el); return () => { el.removeEventListener('scroll', onScroll); ro.disconnect(); }; }, []); useEffect(() => { const el = tabsRef.current; const active = el?.querySelector('.newproj-tab.active'); active?.scrollIntoView({ behavior: 'smooth', inline: 'nearest', block: 'nearest' }); window.setTimeout(updateTabScrollState, 180); }, [tab]); function handleCreate() { if (!canCreate) return; // Media surfaces don't carry a design system pick. Force the primary // and inspiration ids to empty there so the New Project panel can't // accidentally bind a stale DS that the user can no longer see in the // form (the picker is hidden for image/video/audio). const { primary: primaryDs, inspirations } = buildDesignSystemCreateSelection(showDesignSystemPicker, selectedDsIds); const promptTemplatePick = tab === 'media' ? mediaSurface === 'image' ? imagePromptTemplate : mediaSurface === 'video' ? videoPromptTemplate : null : null; const trimmedName = name.trim(); const metadata = buildMetadata({ tab, mediaSurface, fidelity, platformTargets, includeLandingPage, includeOsWidgets, speakerNotes, animations, templateId, templates, imageModel, imageAspect, videoModel, videoAspect, videoLength, audioKind, audioModel, audioDuration, voice, inspirationIds: inspirations, promptTemplate: promptTemplatePick, }); // Generate the click→result correlation id here so the home_click and // the eventual project_create_result share request_id. const requestId = analytics.newRequestId(); // v2 emits ui_click element=create on the New project modal; the // project_create_result correlated through `requestId` carries the // project_kind / fidelity payload, so we no longer duplicate them // on the click event. trackNewProjectModalElementClick( analytics.track, { page_name: 'home', area: 'new_project_modal', element: 'create', tab_name: createTabToTracking(tab), }, { requestId }, ); onCreate({ name: trimmedName || autoName(tab, mediaSurface, t), skillId: skillIdForTab, designSystemId: primaryDs, metadata: { ...metadata, nameSource: trimmedName ? 'user' : 'generated', }, requestId, }); } async function handleImportPicked(ev: React.ChangeEvent) { const file = ev.target.files?.[0]; ev.target.value = ''; if (!file || !onImportClaudeDesign) return; setImporting(true); setImportZipError(null); try { const result = await onImportClaudeDesign(file); if (result?.ok === false) { setImportZipError({ message: result.message ? `Import failed: ${result.message}` : 'Import failed', details: result.details, }); } } catch (err) { setImportZipError({ message: err instanceof Error ? `Import failed: ${err.message}` : 'Import failed', }); } finally { setImporting(false); } } // PR #974: the host bridge does not expose raw folder paths to the // renderer. The desktop flow uses `pickAndImport`, which performs the // picker + the HMAC-gated import atomically in the main process and // returns host-owned project identifiers. // The web fallback continues to use the manual baseDir input — // browser builds have no `shell.openPath` surface so a renderer-named // path cannot escalate. const hasHostPickAndImport = isOpenDesignHostAvailable(); async function handleOpenFolder() { if (hasHostPickAndImport) { if (!onImportFolderResponse) return; setImportFolderError(null); setImportingFolder(true); try { const result = await pickAndImportHostProject({ skillId: skillIdForTab, }); if (!result) return; if (result.ok === true) { await onImportFolderResponse(result); return; } // Round-4 (mrcfps #2): every non-OK shape used to fall through // a silent `return`. Reserve silent for the explicit cancel // case; surface the structured reason for everything else // (auth-not-registered, web-sidecar-down, daemon HTTP errors, // network errors). The pickAndImport handler already pre-shapes // these into a `{ ok: false, reason, details? }` envelope. if ('canceled' in result && result.canceled === true) return; setImportFolderError(formatPickAndImportFailure(result)); } finally { setImportingFolder(false); } return; } if (!onImportFolder) return; const trimmed = baseDir.trim(); if (!trimmed) { setImportFolderError({ message: 'Path cannot be empty' }); return; } setImportFolderError(null); setImportingFolder(true); try { await onImportFolder(trimmed); } catch (err) { setImportFolderError({ message: err instanceof Error ? err.message : 'Failed to import folder', }); } finally { setImportingFolder(false); } } return (
{(Object.keys(TAB_LABEL_KEYS) as CreateTab[]).map((entry) => ( ))}

{titleForTab(tab, mediaSurface, t)} {tab === 'live-artifact' ? ( // "Beta" is an internationally adopted brand-style status marker; // intentionally not run through t() (consistent with short product // status pills that read the same across our supported locales). Beta ) : null}

setName(e.target.value)} />
{showDesignSystemPicker ? ( ) : null} {tab === 'media' ? (
{(Object.keys(MEDIA_SURFACE_LABEL_KEYS) as MediaSurface[]).map((surface) => ( ))}
) : null} {tab === 'media' && mediaSurface === 'image' ? ( ) : null} {tab === 'media' && mediaSurface === 'video' ? ( ) : null} {tab === 'prototype' || tab === 'live-artifact' || tab === 'template' || tab === 'other' ? ( ) : null} {tab === 'prototype' || tab === 'live-artifact' || tab === 'template' || tab === 'other' ? ( ) : null} {/* Live artifact always renders at high fidelity — its whole point is data-bound polished UI, so the wireframe option is hidden. */} {tab === 'prototype' ? ( ) : null} {tab === 'live-artifact' ? ( ) : null} {tab === 'deck' ? ( ) : null} {tab === 'template' ? ( <> ) : null} {tab === 'media' && mediaSurface === 'image' ? ( ) : null} {tab === 'media' && mediaSurface === 'video' ? ( ) : null} {tab === 'media' && mediaSurface === 'audio' ? ( { setAudioKind(kind); setAudioModel(DEFAULT_AUDIO_MODEL[kind]); if (kind === 'sfx') { setAudioDuration((duration) => Math.min(duration, SFX_AUDIO_DURATIONS_SEC.at(-1) ?? 30)); } }} onAudioModel={setAudioModel} onAudioDuration={setAudioDuration} onVoice={setVoice} /> ) : null} {onImportClaudeDesign ? ( <> ) : null} {(hasHostPickAndImport ? onImportFolderResponse : onImportFolder) ? (
{!hasHostPickAndImport ? ( setBaseDir(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') void handleOpenFolder(); }} disabled={importingFolder} /> ) : null}
) : null}
{t('newproj.privacyFooter')}
{importZipError ? ( setImportZipError(null)} /> ) : null} {importFolderError ? ( setImportFolderError(null)} /> ) : null}
); } function PlatformPicker({ value, onChange, }: { value: NewProjectPlatform[]; onChange: (v: NewProjectPlatform[]) => void; }) { const t = useT(); const [open, setOpen] = useState(false); const wrapRef = useRef(null); const listboxId = useId(); function togglePlatform(next: NewProjectPlatform) { const active = value.includes(next); const updated = active ? value.filter((item) => item !== next) : [...value, next]; onChange(updated.length > 0 ? updated : ['responsive']); } useEffect(() => { if (!open) return; function onPointer(e: MouseEvent) { if (wrapRef.current?.contains(e.target as Node)) return; setOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false); } // Defer listener registration by a tick so the very click that opened // the popover doesn't get re-interpreted as an outside-click on the // mousedown that follows in the same event cycle. const tid = window.setTimeout(() => { document.addEventListener('mousedown', onPointer); document.addEventListener('keydown', onKey); }, 0); return () => { window.clearTimeout(tid); document.removeEventListener('mousedown', onPointer); document.removeEventListener('keydown', onKey); }; }, [open]); const primary = DESIGN_PLATFORMS.find((o) => o.value === value[0]) ?? null; const extraCount = Math.max(0, value.length - 1); return (
{open ? (
{DESIGN_PLATFORMS.map((option) => { const active = value.includes(option.value); return ( ); })}
) : null}
); } function SurfaceOptions({ includeLandingPage, includeOsWidgets, onIncludeLandingPage, onIncludeOsWidgets, }: { includeLandingPage: boolean; includeOsWidgets: boolean; onIncludeLandingPage: (v: boolean) => void; onIncludeOsWidgets: (v: boolean) => void; }) { const t = useT(); return (
); } // Lightweight inline toggle row. The hint moves to a native tooltip so the // row stays one line tall — used by SurfaceOptions where the toggles are // secondary controls and the full card treatment of ToggleRow felt too heavy. function CompactToggle({ label, hint, checked, onChange, disabled, }: { label: string; hint?: string; checked: boolean; disabled?: boolean; onChange: (v: boolean) => void; }) { return ( ); } function FidelityPicker({ value, onChange, }: { value: 'wireframe' | 'high-fidelity'; onChange: (v: 'wireframe' | 'high-fidelity') => void; }) { const t = useT(); return (
onChange('wireframe')} label={t('newproj.fidelityWireframe')} variant="wireframe" /> onChange('high-fidelity')} label={t('newproj.fidelityHigh')} variant="high-fidelity" />
); } /* ============================================================ Connectors section (live-artifact only). - Lists configured connectors as compact chips so the user can see at a glance what data sources this artifact can pull from. - When no connector is configured (or the list hasn't loaded yet and ended up empty), shows a guidance card that, on click, opens the Settings → Connectors surface (the new home of the catalog). ============================================================ */ function ConnectorsSection({ connectors, loading, onOpenConnectorsTab, }: { connectors?: ConnectorDetail[]; loading: boolean; onOpenConnectorsTab?: () => void; }) { const t = useT(); const configured = useMemo( () => (connectors ?? []).filter((c) => c.status === 'connected'), [connectors], ); const hasConfigured = configured.length > 0; if (loading && !connectors) { return (
); } return (
{hasConfigured ? ( ) : null}
{hasConfigured ? ( <> {configured.length === 1 ? t('newproj.connectorsCountOne', { n: configured.length }) : t('newproj.connectorsCountMany', { n: configured.length })} · {t('newproj.connectorsHint')}
    {configured.map((c) => (
  • {c.name}
  • ))}
) : ( )}
); } function FidelityCard({ active, onClick, label, variant, }: { active: boolean; onClick: () => void; label: string; variant: 'wireframe' | 'high-fidelity'; }) { return ( ); } function WireframeArt() { return ( ); } function HighFidelityArt() { return ( ); } function ToggleRow({ label, hint, checked, onChange, disabled, }: { label: string; hint?: string; checked: boolean; disabled?: boolean; onChange: (v: boolean) => void; }) { return ( ); } function TemplatePicker({ templates, value, onChange, onDelete, }: { templates: ProjectTemplate[]; value: string | null; onChange: (id: string | null) => void; onDelete?: (id: string) => Promise; }) { const t = useT(); const [confirmDelete, setConfirmDelete] = useState< { id: string; name: string } | null >(null); const [deleting, setDeleting] = useState(false); const [deleteError, setDeleteError] = useState(false); function closeConfirm() { setConfirmDelete(null); setDeleting(false); setDeleteError(false); } async function runDelete() { if (!confirmDelete || !onDelete) return; setDeleting(true); setDeleteError(false); let ok = false; try { ok = await onDelete(confirmDelete.id); } catch { ok = false; } if (ok) { if (value === confirmDelete.id) onChange(null); closeConfirm(); } else { setDeleting(false); setDeleteError(true); } } return (
{templates.length === 0 ? (
{t('newproj.noTemplatesTitle')} {t('newproj.noTemplatesBody')}
) : (
{templates.map((tpl) => { const fallbackDesc = `${t('newproj.savedTemplate')} · ${tpl.files.length} ${ tpl.files.length === 1 ? t('newproj.fileSingular') : t('newproj.filePlural') }`; return ( onChange(tpl.id)} onDelete={onDelete ? () => setConfirmDelete({ id: tpl.id, name: tpl.name }) : () => {}} name={tpl.name} description={tpl.description ?? fallbackDesc} /> ); })}
)} {confirmDelete ? (
e.stopPropagation()} role="alertdialog" aria-modal="true" >

{t('newproj.deleteTemplateTitle')}

{t('newproj.deleteTemplateConfirm', { name: confirmDelete.name })}

{deleteError ? (

{t('newproj.deleteTemplateError')}

) : null}
) : null}
); } /* ============================================================ Prompt template picker — for the image/video tabs only. - Trigger card (mirrors the design-system trigger) opens a popover with a search field and a thumbnail-card list filtered by surface. - When a template is picked we lazily fetch the full prompt body via fetchPromptTemplate(...) and drop it into a textarea so the user can tune ("optimize") the wording before clicking Create. - The (possibly edited) body lands in metadata.promptTemplate.prompt and becomes part of the system prompt — the agent treats it as a stylistic + structural reference for the generation request. ============================================================ */ function PromptTemplatePicker({ surface, templates, value, onChange, }: { surface: 'image' | 'video'; templates: PromptTemplateSummary[]; value: PromptTemplatePick | null; onChange: (next: PromptTemplatePick | null) => void; }) { const t = useT(); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [loadingId, setLoadingId] = useState(null); const [error, setError] = useState(null); // Last template we tried to pick that failed — kept so the inline // banner can offer a one-click retry without making the user re-find // the card in the popover (which auto-closed on success). Cleared as // soon as a pick succeeds or the user picks a different template. const [lastFailedPick, setLastFailedPick] = useState(null); const wrapRef = useRef(null); const searchRef = useRef(null); const surfaceScoped = useMemo( () => templates.filter((tpl) => tpl.surface === surface), [templates, surface], ); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); if (!q) return surfaceScoped; return surfaceScoped.filter((tpl) => { return ( tpl.title.toLowerCase().includes(q) || tpl.summary.toLowerCase().includes(q) || (tpl.category || '').toLowerCase().includes(q) || (tpl.tags ?? []).some((tag) => tag.toLowerCase().includes(q)) ); }); }, [surfaceScoped, query]); useEffect(() => { if (!open) return; const id = window.setTimeout(() => searchRef.current?.focus(), 30); return () => window.clearTimeout(id); }, [open]); useEffect(() => { if (!open) return; function onPointer(e: MouseEvent) { if (wrapRef.current?.contains(e.target as Node)) return; setOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === 'Escape') setOpen(false); } const id = window.setTimeout(() => { document.addEventListener('mousedown', onPointer); document.addEventListener('keydown', onKey); }, 0); return () => { window.clearTimeout(id); document.removeEventListener('mousedown', onPointer); document.removeEventListener('keydown', onKey); }; }, [open]); async function pickTemplate(summary: PromptTemplateSummary) { setLoadingId(summary.id); setError(null); try { const detail = await fetchPromptTemplate(summary.surface, summary.id); if (!detail) { setError(t('promptTemplates.fetchError')); setLastFailedPick(summary); return; } onChange({ summary, prompt: detail.prompt }); setLastFailedPick(null); setOpen(false); setQuery(''); } catch { // fetchPromptTemplate already swallows errors and returns null in // the happy path; this catch is a defensive net for unexpected // throws so the inline banner still surfaces and the user can // retry instead of being stuck on a permanent loading spinner. setError(t('promptTemplates.fetchError')); setLastFailedPick(summary); } finally { setLoadingId(null); } } function clear() { onChange(null); setLastFailedPick(null); setError(null); setOpen(false); setQuery(''); } const triggerTitle = value?.summary.title ?? t('newproj.promptTemplateNoneTitle'); const triggerSub = value ? value.summary.category || value.summary.summary || t('newproj.promptTemplateRefSub') : t('newproj.promptTemplateNoneSub'); return (
{open ? (
setQuery(e.target.value)} />
{filtered.length === 0 ? (
{surfaceScoped.length === 0 ? t('newproj.promptTemplateEmpty') : t('promptTemplates.emptyNoMatch')}
) : ( filtered.map((tpl) => { const active = value?.summary.id === tpl.id; return ( ); }) )}
) : null} {error ? (
{error} {lastFailedPick ? ( ) : null}
) : null} {value ? (
{t('newproj.promptTemplateBodyLabel')} {t('newproj.promptTemplateOptimizeHint')}