diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index 627e67ca7..e7a8e463d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@anthropic-ai/sdk": "0.32.1", + "@formkit/auto-animate": "0.9.0", "@open-design/components": "workspace:*", "@open-design/contracts": "workspace:*", "@open-design/host": "workspace:*", @@ -36,6 +37,7 @@ "@open-design/sidecar": "workspace:*", "@open-design/sidecar-proto": "workspace:*", "lucide-react": "1.16.0", + "motion": "12.40.0", "next": "16.2.6", "openai": "6.38.0", "posthog-js": "1.374.2", diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 29c36f436..9c024fd93 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { flushSync } from 'react-dom'; +import { AnimatePresence, motion, MotionConfig } from 'motion/react'; import { useAnalytics } from './analytics/provider'; import { trackFileUploadResult, @@ -213,10 +214,18 @@ function mergeAmrModelsIntoAgents( } export function App() { + // `reducedMotion="user"` makes every motion/react component honor the OS + // `prefers-reduced-motion` setting: transform/layout animations are zeroed + // out while opacity-only changes are kept. The CSS `@media (prefers-reduced- + // motion: reduce)` block covers the CSS-keyframe surfaces, but the dialogs, + // toasts and popovers that moved to motion/react need this gate too — without + // it they keep springing/sliding for users who asked us not to animate. return ( - - - + + + + + ); } @@ -1790,7 +1799,9 @@ function AppInner() { route={route} projects={projects} /> -
{appMain}
+
+ {appMain} +
{clientType === 'desktop' ? null : ( )} + {settingsOpen ? ( ) : null} + openSettings('memory')} /> {/* First-run privacy consent banner. It waits for daemon config hydration because privacyDecisionAt is daemon-owned and stripped @@ -1850,7 +1863,14 @@ function AppInner() { finish both flip the flag). Independent of Settings: z-index in index.css sits above modal backdrops so opening Settings does not hide the banner. */} + {showPrivacyConsent ? ( + { // Default opt-in: clicking "I get it" enables the same telemetry @@ -1869,7 +1889,9 @@ function AppInner() { }); }} /> + ) : null} + ); } diff --git a/apps/web/src/components/DesignSystemsSection.tsx b/apps/web/src/components/DesignSystemsSection.tsx index a3568d507..894b5008b 100644 --- a/apps/web/src/components/DesignSystemsSection.tsx +++ b/apps/web/src/components/DesignSystemsSection.tsx @@ -11,6 +11,7 @@ import { import { DesignSystemPreviewModal } from './DesignSystemPreviewModal'; import { Icon } from './Icon'; import { orderDesignSystemGroups } from './design-system-group-order'; +import { AnimatePresence } from 'motion/react'; // Sibling Settings section that hosts the design-systems registry. // Lifted out of the previous LibrarySection so each surface (functional @@ -562,12 +563,14 @@ export function DesignSystemsSection({ cfg, setCfg, onDesignSystemsChanged }: Pr )} - {previewSystem ? ( - setPreviewSystem(null)} - /> - ) : null} + + {previewSystem ? ( + setPreviewSystem(null)} + /> + ) : null} + {renameTarget ? (
) : null} - {deleteToast ? ( - setDeleteToast(null)} - /> - ) : null} + + {deleteToast ? ( + setDeleteToast(null)} + /> + ) : null} + ); } diff --git a/apps/web/src/components/EntryHelpMenu.tsx b/apps/web/src/components/EntryHelpMenu.tsx index 14e1352ad..01dc75d98 100644 --- a/apps/web/src/components/EntryHelpMenu.tsx +++ b/apps/web/src/components/EntryHelpMenu.tsx @@ -11,6 +11,8 @@ // in the user's language. import { useEffect, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; +import { popoverIn } from '../motion'; import { useAnalytics } from '../analytics/provider'; import { trackHelpPopoverClick, @@ -93,11 +95,16 @@ export function EntryHelpMenu() { > - {open ? ( -
+ {open ? ( + Join Discord -
+ ) : null} + ); } diff --git a/apps/web/src/components/EntryShell.tsx b/apps/web/src/components/EntryShell.tsx index a09bcf7a9..8d093a0d1 100644 --- a/apps/web/src/components/EntryShell.tsx +++ b/apps/web/src/components/EntryShell.tsx @@ -115,6 +115,7 @@ import { AMR_LOGIN_POLL_INTERVAL_MS, amrLoginPollOutcome, } from './amrLoginPolling'; +import { AnimatePresence } from 'motion/react'; import { renderModelOptions } from './modelOptions'; import { providerModelsCacheKey, @@ -352,6 +353,22 @@ function navElementForView( } } +// Tab views stay mounted (so previews/thumbnails survive a tab switch) but the +// inactive ones must leave the accessibility tree and tab order — otherwise +// keyboard users tab into off-screen controls and screen readers announce +// several pages at once. `content-visibility: hidden` only skips paint, so the +// inactive wrapper also gets `inert` (drops it from focus + a11y) and +// `aria-hidden`. React renders `inert={false}` as no attribute and +// `inert={true}` as the real boolean attribute, so toggling on `!active` is +// enough — the active view stays fully interactive. +function inactiveViewProps(active: boolean) { + return { + style: active ? undefined : ({ contentVisibility: 'hidden' } as const), + inert: !active, + 'aria-hidden': !active, + }; +} + export function EntryShell({ skills, designTemplates, @@ -658,7 +675,7 @@ export function EntryShell({ view === 'home' ? '' : ' entry-main__inner--wide' }`} > - {view === 'home' ? ( +
changeView('projects')} onBrowseRegistry={() => changeView('plugins')} onOpenNewProject={(tab) => { - // Stage B of plugin-driven-flow-plan: the rail's - // "From template" chip wires through here so the - // existing modal-based create flow still owns the - // template picker UI. Future tabs (e.g. live-artifact - // import) can reuse the same callback. openNewProject(tab); }} promptHandoff={homePromptHandoff} @@ -682,9 +694,9 @@ export function EntryShell({ connectors={connectors} promptTemplates={promptTemplates} /> - ) : null} - {view === 'projects' ? ( - projectsLoading || skillsLoading || designSystemsLoading ? ( +
+
+ {projectsLoading || skillsLoading || designSystemsLoading ? ( ) : (
@@ -702,25 +714,25 @@ export function EntryShell({ onNewProject={() => openNewProject()} />
- ) - ) : null} - {view === 'tasks' ? ( + )} +
+
- ) : null} - {view === 'plugins' ? ( +
+
- ) : null} - {view === 'design-systems' ? ( - designSystemsLoading ? ( +
+
+ {designSystemsLoading ? ( ) : (
@@ -738,8 +750,8 @@ export function EntryShell({ onPreview={(id) => setPreviewSystemId(id)} />
- ) - ) : null} + )} +
{view === 'integrations' ? ( - {previewSystem ? ( - setPreviewSystemId(null)} - /> - ) : null} + + {previewSystem ? ( + setPreviewSystemId(null)} + /> + ) : null} + ) => string; @@ -461,6 +462,7 @@ export function ExamplesTab({ skills: rawSkills, onUsePrompt }: Props) { ))} )} + {(() => { if (!previewSkill) return null; const unavailableKind = previewUnavailable[previewSkill.id]; @@ -497,6 +499,7 @@ export function ExamplesTab({ skills: rawSkills, onUsePrompt }: Props) { /> ); })()} + ); } diff --git a/apps/web/src/components/FileWorkspace.tsx b/apps/web/src/components/FileWorkspace.tsx index cc4068e0c..2042bcede 100644 --- a/apps/web/src/components/FileWorkspace.tsx +++ b/apps/web/src/components/FileWorkspace.tsx @@ -61,6 +61,7 @@ import { parseSketchWorkspaceDocument, type SketchItem, } from './sketch-model'; +import { AnimatePresence } from 'motion/react'; import { GenerationPreviewStage } from './GenerationPreviewStage'; import { AmrGuidance } from './AmrGuidance'; import { buildGenerationPreviewState } from '../runtime/generation-preview'; @@ -1200,30 +1201,34 @@ export function FileWorkspace({ style={{ display: 'none' }} onChange={handleFilePicked} /> - {showPasteDialog ? ( - setShowPasteDialog(false)} - onSave={async (name, content) => { - setShowPasteDialog(false); - const file = await writeProjectTextFile(projectId, name, content); - if (file) { - await onRefreshFiles(); - openFile(file.name); - } - }} - /> - ) : null} - {quickSwitcherOpen ? ( - { - openFile(name); - setQuickSwitcherOpen(false); - }} - onClose={() => setQuickSwitcherOpen(false)} - /> - ) : null} + + {showPasteDialog ? ( + setShowPasteDialog(false)} + onSave={async (name, content) => { + setShowPasteDialog(false); + const file = await writeProjectTextFile(projectId, name, content); + if (file) { + await onRefreshFiles(); + openFile(file.name); + } + }} + /> + ) : null} + + + {quickSwitcherOpen ? ( + { + openFile(name); + setQuickSwitcherOpen(false); + }} + onClose={() => setQuickSwitcherOpen(false)} + /> + ) : null} + ); } diff --git a/apps/web/src/components/HomeView.tsx b/apps/web/src/components/HomeView.tsx index 71a4b95bc..fc114718d 100644 --- a/apps/web/src/components/HomeView.tsx +++ b/apps/web/src/components/HomeView.tsx @@ -73,6 +73,7 @@ import type { PluginLoopSubmit } from './PluginLoopHome'; import type { FacetSelection } from './plugins-home/facets'; import type { PluginUseAction } from './plugins-home/useActions'; import { RecentProjectsStrip } from './RecentProjectsStrip'; +import { AnimatePresence } from 'motion/react'; interface ActivePlugin { record: InstalledPluginRecord; @@ -1385,14 +1386,16 @@ export function HomeView({ presetSelection={presetStartersSelection} /> - {detailsRecord ? ( - setDetailsRecord(null)} - onUse={(record) => requestPluginContextUse(record, 'use')} - isApplying={pendingApplyId === detailsRecord.id} - /> - ) : null} + + {detailsRecord ? ( + setDetailsRecord(null)} + onUse={(record) => requestPluginContextUse(record, 'use')} + isApplying={pendingApplyId === detailsRecord.id} + /> + ) : null} + {pendingReplacement ? (
{LOCALE_LABEL[locale]} - {open ? ( -
- {LOCALES.map((code) => { - const active = locale === code; - return ( - - ); - })} -
- ) : null} + + {open ? ( + + + {LOCALES.map((code) => { + const active = locale === code; + return ( + { + setLocale(code as Locale); + setOpen(false); + }} + variants={listItem} + > + {LOCALE_LABEL[code]} + {code} + {active ? ( + + + + ) : null} + + ); + })} + + + ) : null} +
); } diff --git a/apps/web/src/components/MemoryToast.tsx b/apps/web/src/components/MemoryToast.tsx index b125fd982..1271d4d6a 100644 --- a/apps/web/src/components/MemoryToast.tsx +++ b/apps/web/src/components/MemoryToast.tsx @@ -8,8 +8,10 @@ // be dropped into App.tsx with no other plumbing. import { useEffect, useRef, useState } from 'react'; import type { CSSProperties } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; import type { MemoryChangeEvent } from '@open-design/contracts'; import { useT } from '../i18n'; +import { toastSlideUp } from '../motion'; interface ActiveToast { key: number; @@ -76,18 +78,14 @@ export function MemoryToast({ onOpenMemory }: Props) { }; }, [toast]); - if (!toast) return null; - - const label = t('settings.memoryToastChanged'); - const detail = - toast.source === 'llm' + const label = toast ? t('settings.memoryToastChanged') : ''; + const detail = toast + ? toast.source === 'llm' ? `(${toast.count} · LLM)` - : `(${toast.count})`; - const clickHint = t('settings.memoryToastClickHint'); + : `(${toast.count})` + : ''; + const clickHint = toast ? t('settings.memoryToastClickHint') : ''; - // Reset native button styling. The pill needs to look identical to - // the previous div + carry button semantics so screen readers and - // keyboard users can activate it. const pillStyle: CSSProperties = { position: 'fixed', bottom: 20, @@ -109,49 +107,58 @@ export function MemoryToast({ onOpenMemory }: Props) { transition: 'transform 0.15s ease, box-shadow 0.15s ease', }; - if (!onOpenMemory) { - return ( -
- - {label} - {detail} -
- ); - } - return ( - + + {toast ? ( + !onOpenMemory ? ( + + + {label} + {detail} + + ) : ( + + + {label} + {detail} + + {clickHint} → + + + ) + ) : null} + ); } diff --git a/apps/web/src/components/NewProjectModal.tsx b/apps/web/src/components/NewProjectModal.tsx index d3027ade4..1419c7908 100644 --- a/apps/web/src/components/NewProjectModal.tsx +++ b/apps/web/src/components/NewProjectModal.tsx @@ -9,8 +9,10 @@ // clicks the backdrop / Esc. import { useEffect, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; import type { ConnectorDetail } from '@open-design/contracts'; import type { OpenDesignHostProjectImportSuccess } from '@open-design/host'; +import { modalOverlay, modalContent } from '../motion'; import type { DesignSystemSummary, MediaProviderCredentials, @@ -49,8 +51,22 @@ interface Props { initialTab?: CreateTab; } -export function NewProjectModal({ - open, +// The `open` flag stays the public API, but the close animation has to play +// before the modal leaves the DOM. So the outer component never early-returns +// null: it renders the body inside `AnimatePresence` and gates the body on +// `open`. When `open` flips to false the body unmounts through +// `AnimatePresence`, which runs the `exit` variants on the overlay + content +// first. The body lives in its own component so its mount effects (focus the +// close button, lock body scroll, bind Esc) fire exactly when it appears. +export function NewProjectModal({ open, ...rest }: Props) { + return ( + + {open ? : null} + + ); +} + +function NewProjectModalBody({ skills, designSystems, defaultDesignSystemId, @@ -68,37 +84,30 @@ export function NewProjectModal({ onOpenConnectorsTab, onClose, initialTab, -}: Props) { +}: Omit) { const closeRef = useRef(null); const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); useEffect(() => { - if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !creating) onClose(); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); - }, [creating, open, onClose]); + }, [creating, onClose]); useEffect(() => { - if (!open) return; const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = prev; }; - }, [open]); + }, []); useEffect(() => { - if (!open) return; - setCreating(false); - setCreateError(null); closeRef.current?.focus(); - }, [open]); - - if (!open) return null; + }, []); async function handleCreate(input: CreateInput & { requestId?: string }) { if (creating) return; @@ -119,7 +128,7 @@ export function NewProjectModal({ } return ( -
{ if (e.target === e.currentTarget && !creating) onClose(); }} + variants={modalOverlay} + initial="hidden" + animate="visible" + exit="exit" > -
+

New project

-
-
+ + ); } diff --git a/apps/web/src/components/PasteTextDialog.tsx b/apps/web/src/components/PasteTextDialog.tsx index 13defce17..c1315cced 100644 --- a/apps/web/src/components/PasteTextDialog.tsx +++ b/apps/web/src/components/PasteTextDialog.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; +import { motion } from 'motion/react'; import { Button, Input, Textarea } from '@open-design/components'; import { useT } from '../i18n'; +import { modalOverlay, modalContent } from '../motion'; interface Props { onSave: (name: string, content: string) => void; @@ -20,8 +22,22 @@ export function PasteTextDialog({ onSave, onClose }: Props) { } return ( -
-
e.stopPropagation()}> + + e.stopPropagation()} + variants={modalContent} + initial="hidden" + animate="visible" + exit="exit" + >

{t('pasteDialog.title')}

{t('pasteDialog.hint')}

-
- + + ); } diff --git a/apps/web/src/components/PluginsHomeSection.tsx b/apps/web/src/components/PluginsHomeSection.tsx index f2b81a4f4..9c04baf8d 100644 --- a/apps/web/src/components/PluginsHomeSection.tsx +++ b/apps/web/src/components/PluginsHomeSection.tsx @@ -26,6 +26,7 @@ import { usePluginFacets } from './plugins-home/usePluginFacets'; import { useSavedPluginIds } from './plugins-home/savedPlugins'; import type { PluginUseAction } from './plugins-home/useActions'; import { Toast } from './Toast'; +import { AnimatePresence } from 'motion/react'; const INITIAL_PLUGIN_RENDER_LIMIT = 60; const PLUGIN_RENDER_BATCH_SIZE = 60; @@ -239,13 +240,15 @@ export function PluginsHomeSection({ )} )} - {saveToast ? ( - setSaveToast(null)} - /> - ) : null} + + {saveToast ? ( + setSaveToast(null)} + /> + ) : null} + ); } diff --git a/apps/web/src/components/PluginsView.tsx b/apps/web/src/components/PluginsView.tsx index 12569990b..fdccb28d7 100644 --- a/apps/web/src/components/PluginsView.tsx +++ b/apps/web/src/components/PluginsView.tsx @@ -40,6 +40,7 @@ import { TrustBadge } from './TrustBadge'; import { useI18n } from '../i18n'; import { copyToClipboard } from '../lib/copy-to-clipboard'; import type { PluginUseAction } from './plugins-home/useActions'; +import { AnimatePresence } from 'motion/react'; type PluginsTab = 'installed' | 'available' | 'sources' | 'team'; @@ -523,24 +524,28 @@ export function PluginsView({ {activeTab === 'team' ? : null} - {detailsRecord ? ( - setDetailsRecord(null)} - onUse={(record) => void handleUsePlugin(record, 'use')} - isApplying={pendingApplyId === detailsRecord.id} - /> - ) : null} - {availableDetails ? ( - { - if (pendingInstallEntry !== availableDetails.key) setAvailableDetails(null); - }} - onInstall={(plugin) => void handleInstallAvailable(plugin)} - /> - ) : null} + + {detailsRecord ? ( + setDetailsRecord(null)} + onUse={(record) => void handleUsePlugin(record, 'use')} + isApplying={pendingApplyId === detailsRecord.id} + /> + ) : null} + + + {availableDetails ? ( + { + if (pendingInstallEntry !== availableDetails.key) setAvailableDetails(null); + }} + onInstall={(plugin) => void handleInstallAvailable(plugin)} + /> + ) : null} + {shareConfirm ? ( -
+
+
diff --git a/apps/web/src/components/ProjectView.tsx b/apps/web/src/components/ProjectView.tsx index 49f3e3840..badb2261b 100644 --- a/apps/web/src/components/ProjectView.tsx +++ b/apps/web/src/components/ProjectView.tsx @@ -9,6 +9,7 @@ import { type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, } from 'react'; +import { AnimatePresence } from 'motion/react'; import { createHtmlArtifactManifest, inferLegacyManifest } from '../artifacts/manifest'; import { resolveHtmlPointerArtifactTarget } from '../artifacts/pointer'; import { validateHtmlArtifact } from '../artifacts/validate'; @@ -4471,14 +4472,16 @@ export function ProjectView({ )} />
- {projectActionsToast ? ( - setProjectActionsToast(null)} - /> - ) : null} + + {projectActionsToast ? ( + setProjectActionsToast(null)} + /> + ) : null} +
); } diff --git a/apps/web/src/components/QuickSwitcher.tsx b/apps/web/src/components/QuickSwitcher.tsx index 946dfcd0b..456d4a426 100644 --- a/apps/web/src/components/QuickSwitcher.tsx +++ b/apps/web/src/components/QuickSwitcher.tsx @@ -6,6 +6,8 @@ // surface first, then the rest of the file list by mtime. import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { motion } from 'motion/react'; +import { modalOverlay, scaleIn } from '../motion'; import { useT } from '../i18n'; import { pushRecent, readRecents } from '../quickSwitcherRecents'; import type { ProjectFile } from '../types'; @@ -110,8 +112,24 @@ export function QuickSwitcher({ projectId, files, onOpenFile, onClose }: Props) const emptyLabel = hasQuery ? t('quickSwitcher.noMatches') : t('quickSwitcher.empty'); return ( -
-
e.stopPropagation()}> + + e.stopPropagation()} + variants={scaleIn} + initial="hidden" + animate="visible" + exit="exit" + > {t('quickSwitcher.open')} esc {t('quickSwitcher.close')}
-
-
+ + ); } diff --git a/apps/web/src/components/TasksView.tsx b/apps/web/src/components/TasksView.tsx index 71240da45..c0caebafe 100644 --- a/apps/web/src/components/TasksView.tsx +++ b/apps/web/src/components/TasksView.tsx @@ -854,7 +854,7 @@ export function TasksView({ skills = [], designTemplates = [], connectors = [] }
) : null} -
+
{filteredTemplates.map((template) => ( ) : null} -
+ ); } diff --git a/apps/web/src/components/UpdaterPopup.tsx b/apps/web/src/components/UpdaterPopup.tsx index 1e03d135b..fd52098aa 100644 --- a/apps/web/src/components/UpdaterPopup.tsx +++ b/apps/web/src/components/UpdaterPopup.tsx @@ -1,7 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { AnimatePresence, motion } from 'motion/react'; import type { OpenDesignHostUpdaterStatusSnapshot } from '@open-design/host'; import { Icon } from './Icon'; +import { popoverIn } from '../motion'; import { deriveUpdaterModel, openUpdaterInstaller, @@ -285,39 +287,45 @@ export function UpdaterPopup() { - {panelOpen ? ( -
-
- -
-
-

{t('updater.ready')}

-

{versionText(t, model)}

- {channelLabel != null ? {channelLabel} : null} -
-
- - -
-
- ) : null} + + {panelOpen ? ( + +
+ +
+
+

{t('updater.ready')}

+

{versionText(t, model)}

+ {channelLabel != null ? {channelLabel} : null} +
+
+ + +
+
+ ) : null} +
); } diff --git a/apps/web/src/components/UseEverywhereModal.tsx b/apps/web/src/components/UseEverywhereModal.tsx index 988359401..7e024dd73 100644 --- a/apps/web/src/components/UseEverywhereModal.tsx +++ b/apps/web/src/components/UseEverywhereModal.tsx @@ -9,10 +9,12 @@ // modal only owns rendering + clipboard interactions. import { useEffect, useMemo, useRef, useState } from 'react'; +import { motion } from 'motion/react'; import { useAnalytics } from '../analytics/provider'; import { trackIntegrationsUseEverywhereTabClick } from '../analytics/events'; import { Icon } from './Icon'; import { useT } from '../i18n'; +import { modalOverlay, modalContent } from '../motion'; import type { Dict } from '../i18n/types'; import { buildAgentGuideMarkdown, @@ -82,7 +84,7 @@ export function UseEverywhereModal({ }, []); return ( -
{ if (e.target === e.currentTarget) onClose(); }} + variants={modalOverlay} + initial="hidden" + animate="visible" + exit="exit" > -
+
{t('integrations.kicker')} @@ -120,8 +132,8 @@ export function UseEverywhereModal({ daemonUrl={daemonUrl} versionHint={versionHint} /> -
-
+ + ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index fa0a5e4fb..267616104 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -24,3 +24,4 @@ @import './styles/viewer/plugin-rail.css'; @import './styles/viewer/plugin-inputs.css'; @import './styles/viewer/routines.css'; +@import './styles/entrance.css'; diff --git a/apps/web/src/motion.ts b/apps/web/src/motion.ts new file mode 100644 index 000000000..5658b2a04 --- /dev/null +++ b/apps/web/src/motion.ts @@ -0,0 +1,93 @@ +import type { Transition, Variants } from 'motion/react'; + +const spring: Transition = { + type: 'spring', + stiffness: 500, + damping: 30, + mass: 0.8, +}; + +export const modalOverlay: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1, transition: { duration: 0.2 } }, + exit: { opacity: 0, transition: { duration: 0.15 } }, +}; + +export const modalContent: Variants = { + hidden: { opacity: 0, scale: 0.96, y: 10 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { + ...spring, + stiffness: 400, + damping: 28, + }, + }, + exit: { + opacity: 0, + scale: 0.97, + y: 5, + transition: { duration: 0.15 }, + }, +}; + +export const scaleIn: Variants = { + hidden: { opacity: 0, scale: 0.95 }, + visible: { opacity: 1, scale: 1, transition: spring }, + exit: { opacity: 0, scale: 0.97, transition: { duration: 0.15 } }, +}; + +export const toastSlideUp: Variants = { + hidden: { opacity: 0, y: 20, scale: 0.95 }, + visible: { + opacity: 1, + y: 0, + scale: 1, + transition: spring, + }, + exit: { + opacity: 0, + y: -10, + scale: 0.95, + transition: { duration: 0.2 }, + }, +}; + +export const listItem: Variants = { + hidden: { opacity: 0, y: 8 }, + visible: { opacity: 1, y: 0 }, + exit: { opacity: 0, y: -4, transition: { duration: 0.1 } }, +}; + +export const staggerContainer: Variants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + staggerChildren: 0.04, + delayChildren: 0.02, + }, + }, +}; + +export const popoverIn: Variants = { + hidden: { opacity: 0, scale: 0.92, y: -4 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { + type: 'spring', + stiffness: 500, + damping: 25, + mass: 0.6, + }, + }, + exit: { + opacity: 0, + scale: 0.95, + transition: { duration: 0.12 }, + }, +}; diff --git a/apps/web/src/styles/entrance.css b/apps/web/src/styles/entrance.css new file mode 100644 index 000000000..ad1d5eb69 --- /dev/null +++ b/apps/web/src/styles/entrance.css @@ -0,0 +1,510 @@ +/* Shared CSS entrance animations — zero JS overhead. + Uses the product ease: cubic-bezier(0.23, 1, 0.32, 1). */ + +/* ================================================================ + Keyframes + ================================================================ */ + +@keyframes od-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes od-fade-slide-up { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes od-fade-slide-down { + from { opacity: 0; transform: translateY(-8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes od-scale-in { + from { opacity: 0; transform: scale(0.96); } + to { opacity: 1; transform: scale(1); } +} + +@keyframes od-popover-in { + from { opacity: 0; transform: scale(0.93) translateY(-3px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +@keyframes od-slide-left { + from { opacity: 0; transform: translateX(16px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ================================================================ + Page / section entrance + ================================================================ */ + +.app, +.entry-shell { + animation: od-fade-in 180ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Home hero + ================================================================ */ + +.home-hero__brand { + animation: od-fade-slide-up 350ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.home-hero__title { + animation: od-fade-slide-up 400ms cubic-bezier(0.23, 1, 0.32, 1) 40ms both; +} + +.home-hero__subtitle { + animation: od-fade-slide-up 400ms cubic-bezier(0.23, 1, 0.32, 1) 80ms both; +} + +.home-hero__input-card { + animation: od-fade-slide-up 350ms cubic-bezier(0.23, 1, 0.32, 1) 100ms both; +} + +.home-hero__rail-group { + animation: od-fade-in 300ms cubic-bezier(0.23, 1, 0.32, 1) 160ms both; +} + +/* Example prompts grid stagger */ +.home-hero__prompt-examples-grid > * { + animation: od-fade-slide-up 300ms cubic-bezier(0.23, 1, 0.32, 1) both; +} +.home-hero__prompt-examples-grid > *:nth-child(1) { animation-delay: 80ms; } +.home-hero__prompt-examples-grid > *:nth-child(2) { animation-delay: 120ms; } +.home-hero__prompt-examples-grid > *:nth-child(3) { animation-delay: 160ms; } +.home-hero__prompt-examples-grid > *:nth-child(4) { animation-delay: 200ms; } +.home-hero__prompt-examples-grid > *:nth-child(5) { animation-delay: 240ms; } +.home-hero__prompt-examples-grid > *:nth-child(6) { animation-delay: 280ms; } + +/* Plugin preset list stagger */ +.home-hero__plugin-presets > * { + animation: od-fade-slide-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both; +} +.home-hero__plugin-presets > *:nth-child(1) { animation-delay: 60ms; } +.home-hero__plugin-presets > *:nth-child(2) { animation-delay: 100ms; } +.home-hero__plugin-presets > *:nth-child(3) { animation-delay: 140ms; } +.home-hero__plugin-presets > *:nth-child(4) { animation-delay: 180ms; } +.home-hero__plugin-presets > *:nth-child(5) { animation-delay: 220ms; } +.home-hero__plugin-presets > *:nth-child(6) { animation-delay: 260ms; } + +/* ================================================================ + Recent projects strip + ================================================================ */ + + +/* ================================================================ + Plugin cards grid + ================================================================ */ + + +/* ================================================================ + Design grid + ================================================================ */ + + +/* ================================================================ + Automations template grid + ================================================================ */ + +/* ================================================================ + Integrations view + ================================================================ */ + +.integrations-view__hero { + animation: od-fade-slide-up 350ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.integrations-view__tabs { + animation: od-fade-in 300ms cubic-bezier(0.23, 1, 0.32, 1) 60ms both; +} + +.integrations-view__panel { + animation: od-fade-slide-up 300ms cubic-bezier(0.23, 1, 0.32, 1) 100ms both; +} + +.integrations-view__coming-soon { + animation: od-fade-slide-up 350ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Nav rail + ================================================================ */ + +.entry-nav-rail__btn { + transition: background-color 120ms ease, color 120ms ease, transform 100ms ease; +} +.entry-nav-rail__btn:active:not(:disabled) { + transform: scale(0.97); +} + +/* ================================================================ + Project actions toolbar + ================================================================ */ + +.project-actions-toolbar { + animation: od-fade-slide-down 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Popovers & dropdowns + ================================================================ */ + +.conv-menu { + animation: od-popover-in 160ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.od-select-menu { + animation: od-popover-in 140ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.entry-help-popover { + animation: od-popover-in 150ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.chat-history-menu { + animation: od-popover-in 160ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Live artifact badges + ================================================================ */ + + +/* ================================================================ + Chat pane + ================================================================ */ + +.chat-empty-wrap { + animation: od-fade-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + File viewer & workspace + ================================================================ */ + +.viewer-empty { + animation: od-fade-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Inspector & comment panels (slide in from right) + ================================================================ */ + +.inspect-panel { + animation: od-slide-left 220ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.comment-side-panel { + animation: od-slide-left 220ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + + +/* ================================================================ + Settings dialog + ================================================================ */ + + +.settings-section { + animation: od-fade-slide-up 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Memory panels + ================================================================ */ + + +.memory-suggestion-panel { + animation: od-fade-slide-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.memory-management-panel { + animation: od-fade-slide-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + MCP section + ================================================================ */ + +.mcp-picker { + animation: od-fade-slide-up 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Marketplace + ================================================================ */ + +.marketplace-view__header { + animation: od-fade-slide-up 300ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.marketplace-view__empty { + animation: od-fade-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Design systems section & cards + ================================================================ */ + +.library-section-header { + animation: od-fade-slide-up 300ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + + + +/* ================================================================ + Design files panel + ================================================================ */ + + + +/* ================================================================ + Workspace tabs + ================================================================ */ + +.workspace-tab { + transition: background-color 120ms ease, color 120ms ease, transform 100ms ease; +} +.workspace-tab:active { + transform: scale(0.98); +} + +/* ================================================================ + Updater popup + ================================================================ */ + +.updater-popup { + animation: od-fade-slide-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Design card interactions + ================================================================ */ + +/* Marketplace card hover (no existing hover in stylesheet) */ +.marketplace-view__card { + transition: border-color 120ms ease, box-shadow 160ms ease, transform 160ms ease; +} +.marketplace-view__card:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-sm); + transform: translateY(-2px); +} +.marketplace-view__card:active { + transform: translateY(0) scale(0.99); +} + +/* Card press feedback */ +.design-card:active, +.plugins-home__card:active, +.recent-projects__card:active { + transform: translateY(0) scale(0.98); + transition-duration: 80ms; +} + +/* ================================================================ + Comment card + ================================================================ */ + + +/* ================================================================ + Plugin action panel (right side) + ================================================================ */ + +.plugin-action-panel { + animation: od-slide-left 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Empty states + ================================================================ */ + +.chat-history-empty, +.comments-empty { + animation: od-fade-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Automation / routine history rows + ================================================================ */ + + +/* ================================================================ + Example cards (ExamplesTab) + ================================================================ */ + + +/* ================================================================ + Entry sections (Home tabs) + ================================================================ */ + + +/* ================================================================ + Tab content panels + ================================================================ */ + + +/* ================================================================ + Agent / model cards + ================================================================ */ + + +/* ================================================================ + Connector drawer & sections + ================================================================ */ + + +/* ================================================================ + Error / alert banners + ================================================================ */ + +.home-hero__error, +.connector-panel-alert, +.deploy-error, +.library-import-error, +.library-install-error, +.memory-disabled-banner, +.memory-noprovider-banner, +.df-upload-banner, +.mcp-error { + animation: od-fade-slide-down 220ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.field-error { + animation: od-fade-in 160ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Skills section rows + ================================================================ */ + + +/* ================================================================ + Onboarding / new project panels + ================================================================ */ + +.onboarding-view__panel { + animation: od-fade-slide-up 300ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.newproj-section { + animation: od-fade-slide-up 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Design system detail / evidence / revision + ================================================================ */ + +.ds-evidence-panel { + animation: od-slide-left 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Share menu items + ================================================================ */ + + +/* ================================================================ + Staged rows (attachments, files) + ================================================================ */ + + +/* ================================================================ + Mention / slash autocomplete items + ================================================================ */ + + +/* ================================================================ + Plugin hover card + ================================================================ */ + +.home-hero__plugin-hover-card { + animation: od-popover-in 160ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Code viewer + ================================================================ */ + + +/* ================================================================ + Live artifact panels + ================================================================ */ + +.live-artifact-refresh-panel { + animation: od-slide-left 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.live-artifact-refresh-section { + animation: od-fade-slide-up 220ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Use everywhere sections + ================================================================ */ + +.use-everywhere-section { + animation: od-fade-slide-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Orbit automation rows + ================================================================ */ + +.orbit-automation-lock-banner { + animation: od-fade-slide-down 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Notification cards in settings + ================================================================ */ + + +/* ================================================================ + Preview modal + ================================================================ */ + +.ds-modal-backdrop { + animation: od-fade-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +.ds-modal { + animation: od-scale-in 250ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + Plugin detail view + ================================================================ */ + +.plugin-details-modal { + animation: od-fade-slide-up 280ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +/* ================================================================ + DS picker items + ================================================================ */ + + +/* ================================================================ + Generic button press feedback + ================================================================ */ + +button.primary:active:not(:disabled), +button.primary-ghost:active:not(:disabled) { + transform: scale(0.97); + transition: transform 80ms ease; +} + +/* ================================================================ + Reduce motion preference + ================================================================ */ + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-delay: 0ms !important; + transition-duration: 0.01ms !important; + } +} diff --git a/apps/web/tests/components/reduced-motion.test.tsx b/apps/web/tests/components/reduced-motion.test.tsx new file mode 100644 index 000000000..ffae4e761 --- /dev/null +++ b/apps/web/tests/components/reduced-motion.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment jsdom + +// Regression guard for the reduced-motion gate on the motion/react layer. +// +// The CSS `@media (prefers-reduced-motion: reduce)` block only disables CSS +// keyframes/transitions; the dialogs, toasts and popovers that animate through +// motion/react need their own gate. `App` wraps the tree in +// ``, which makes motion honor the OS +// `prefers-reduced-motion` preference (transform/spring variants drop, opacity +// is kept). Real animation behavior can't be observed here because the test +// suite aliases `motion/react` to a stub, so this asserts the wiring contract: +// the App root mounts a MotionConfig configured with `reducedMotion="user"`. + +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { App } from '../../src/App'; +import type { AppConfig } from '../../src/types'; +import { + daemonIsLive, + fetchAgents, + fetchAppVersionInfo, + fetchDesignSystems, + fetchPromptTemplates, + fetchSkills, +} from '../../src/providers/registry'; +import { listProjects, listTemplates } from '../../src/state/projects'; +import { + fetchComposioConfigFromDaemon, + fetchDaemonConfig, + loadConfig, + mergeDaemonConfig, + saveConfig, + syncComposioConfigToDaemon, + syncConfigToDaemon, +} from '../../src/state/config'; + +vi.mock('../../src/router', () => ({ + navigate: vi.fn(), + useRoute: () => ({ kind: 'home' as const, view: 'home' as const }), +})); + +vi.mock('../../src/components/EntryView', () => ({ + EntryView: () =>
Entry view
, +})); + +vi.mock('../../src/components/ProjectView', () => ({ + ProjectView: () =>
Project view
, +})); + +vi.mock('../../src/components/pet/PetOverlay', () => ({ + PetOverlay: () => null, +})); + +vi.mock('../../src/components/pet/pets', () => ({ + migrateCustomPetAtlas: vi.fn().mockResolvedValue(null), +})); + +vi.mock('../../src/components/SettingsDialog', () => ({ + SettingsDialog: () =>
, +})); + +vi.mock('../../src/providers/registry', async () => { + const actual = await vi.importActual( + '../../src/providers/registry', + ); + return { + ...actual, + daemonIsLive: vi.fn(), + fetchAgents: vi.fn(), + fetchAppVersionInfo: vi.fn(), + fetchDesignSystems: vi.fn(), + fetchPromptTemplates: vi.fn(), + fetchSkills: vi.fn(), + }; +}); + +vi.mock('../../src/state/projects', async () => { + const actual = await vi.importActual( + '../../src/state/projects', + ); + return { + ...actual, + listProjects: vi.fn(), + listTemplates: vi.fn(), + }; +}); + +vi.mock('../../src/state/config', async () => { + const actual = await vi.importActual( + '../../src/state/config', + ); + return { + ...actual, + loadConfig: vi.fn(), + mergeDaemonConfig: vi.fn(), + saveConfig: vi.fn(), + fetchDaemonConfig: vi.fn().mockResolvedValue({}), + syncConfigToDaemon: vi.fn().mockResolvedValue(undefined), + syncComposioConfigToDaemon: vi.fn().mockResolvedValue(true), + fetchComposioConfigFromDaemon: vi.fn().mockResolvedValue(null), + }; +}); + +const baseConfig: AppConfig = { + mode: 'api', + apiKey: '', + apiProtocol: 'anthropic', + apiVersion: '', + baseUrl: 'https://api.anthropic.com', + model: 'claude-sonnet-4-5', + apiProviderBaseUrl: 'https://api.anthropic.com', + apiProtocolConfigs: {}, + agentId: null, + skillId: null, + designSystemId: null, + onboardingCompleted: true, + mediaProviders: {}, + composio: {}, + agentModels: {}, + agentCliEnv: {}, +}; + +beforeEach(() => { + vi.mocked(daemonIsLive).mockResolvedValue(true); + vi.mocked(fetchAgents).mockResolvedValue([]); + vi.mocked(fetchSkills).mockResolvedValue([]); + vi.mocked(fetchDesignSystems).mockResolvedValue([]); + vi.mocked(fetchPromptTemplates).mockResolvedValue([]); + vi.mocked(fetchAppVersionInfo).mockResolvedValue(null); + vi.mocked(listProjects).mockResolvedValue([]); + vi.mocked(listTemplates).mockResolvedValue([]); + vi.mocked(fetchDaemonConfig).mockResolvedValue({}); + vi.mocked(fetchComposioConfigFromDaemon).mockResolvedValue(null); + vi.mocked(mergeDaemonConfig).mockImplementation((local) => local); + vi.mocked(saveConfig).mockImplementation(() => {}); + vi.mocked(syncConfigToDaemon).mockResolvedValue(undefined); + vi.mocked(syncComposioConfigToDaemon).mockResolvedValue(true); + vi.mocked(loadConfig).mockReturnValue({ ...baseConfig }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }), + ); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe('reduced-motion gate for the motion/react layer', () => { + it('wraps the app in MotionConfig reducedMotion="user"', async () => { + render(); + + const config = await waitFor(() => screen.getByTestId('motion-config')); + expect(config.getAttribute('data-reduced-motion')).toBe('user'); + }); +}); diff --git a/apps/web/tests/helpers/motion-mock.tsx b/apps/web/tests/helpers/motion-mock.tsx new file mode 100644 index 000000000..f183d137a --- /dev/null +++ b/apps/web/tests/helpers/motion-mock.tsx @@ -0,0 +1,45 @@ +import { forwardRef, type ComponentProps, type ElementType } from 'react'; + +function AnimatePresence({ children }: { children?: React.ReactNode }) { + return <>{children}; +} + +// Surfaces the reduced-motion preference to tests. Real motion/react uses this +// to gate transform animations on the OS `prefers-reduced-motion` setting; the +// mock just exposes the configured value so wiring can be asserted. +function MotionConfig({ + reducedMotion, + children, +}: { + reducedMotion?: 'always' | 'never' | 'user'; + children?: React.ReactNode; +}) { + return
{children}
; +} + +const motionHandler: ProxyHandler = { + get(_target, prop: string) { + const Component = forwardRef>((props, ref) => { + const { + variants: _variants, + initial: _initial, + animate: _animate, + exit: _exit, + whileHover: _whileHover, + whileTap: _whileTap, + transition: _transition, + layout: _layout, + layoutId: _layoutId, + ...rest + } = props as Record; + const Tag = prop as ElementType; + return ; + }); + Component.displayName = `motion.${prop}`; + return Component; + }, +}; + +const motion = new Proxy({}, motionHandler); + +export { AnimatePresence, MotionConfig, motion }; diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 2742e3b56..83f681f20 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -1,6 +1,12 @@ +import { resolve } from 'node:path'; import { defineConfig } from 'vitest/config'; export default defineConfig({ + resolve: { + alias: { + 'motion/react': resolve(__dirname, 'tests/helpers/motion-mock.tsx'), + }, + }, test: { environment: 'node', include: ['tests/**/*.test.{ts,tsx}'], diff --git a/e2e/ui/entry-chrome-flows.test.ts b/e2e/ui/entry-chrome-flows.test.ts index 699b69a38..8d3a8e6a0 100644 --- a/e2e/ui/entry-chrome-flows.test.ts +++ b/e2e/ui/entry-chrome-flows.test.ts @@ -135,10 +135,14 @@ test('[P1] home view exposes the redesigned hero, recent projects, and starters' await createProject(page, 'Home structure recent project'); await gotoEntryHome(page); + // The redesigned entry shell keeps every view mounted (only the active one + // is visible), so `plugins-home-section` exists in both the home and plugins + // views; scope the lookup to the home view to keep the locator unambiguous. + const home = page.getByTestId('entry-view-home'); await expect(page.getByTestId('recent-projects-strip')).toBeVisible(); await expect(page.getByTestId('recent-projects-view-all')).toBeVisible(); - await expect(page.getByTestId('plugins-home-section')).toBeVisible(); - await expect(page.getByTestId('plugins-home-browse-registry')).toBeVisible(); + await expect(home.getByTestId('plugins-home-section')).toBeVisible(); + await expect(home.getByTestId('plugins-home-browse-registry')).toBeVisible(); await expect(page.getByTestId('home-hero')).toBeVisible(); await expect(page.getByTestId('entry-nav-home')).toHaveAttribute('aria-current', 'page'); @@ -493,7 +497,11 @@ test('[P2] home starters shows the empty catalog state when no plugins are avail }); await gotoEntryHome(page); - await expect(page.getByTestId('plugins-home-section')).toContainText('Catalog is empty.'); + // `plugins-home-section` is rendered in both the home and plugins views (both + // stay mounted), so scope to the home view to keep the locator unambiguous. + await expect(page.getByTestId('entry-view-home').getByTestId('plugins-home-section')).toContainText( + 'Catalog is empty.', + ); }); test('[P2] home starters search and facet filters narrow the visible gallery', async ({ page }) => { @@ -569,12 +577,16 @@ test('[P2] home starters search can enter a no-results state and recover with cl await gotoEntryHome(page); - await page.getByTestId('plugins-home-pill-category-all').click(); - await page.getByTestId('plugins-home-search').fill('no-such-starter'); - await expect(page.getByTestId('plugins-home-section')).toContainText( + // `plugins-home-section` and its children are rendered in both the home and + // plugins views (both stay mounted), so scope to the home view to keep these + // strict-mode locators unambiguous. + const home = page.getByTestId('entry-view-home'); + await home.getByTestId('plugins-home-pill-category-all').click(); + await home.getByTestId('plugins-home-search').fill('no-such-starter'); + await expect(home.getByTestId('plugins-home-section')).toContainText( 'No plugins match the current filters.', ); - await page.getByRole('button', { name: /Clear filters/i }).click(); + await home.getByRole('button', { name: /Clear filters/i }).click(); await expect(page.locator('[data-plugin-id="localized-plugin"]')).toBeVisible(); await expect(page.locator('[data-plugin-id="deck-writer"]')).toBeVisible(); }); diff --git a/e2e/ui/visual-home.test.ts b/e2e/ui/visual-home.test.ts index 6e594a0c4..8fd620746 100644 --- a/e2e/ui/visual-home.test.ts +++ b/e2e/ui/visual-home.test.ts @@ -22,9 +22,15 @@ test('[P2] captures the home plugin catalog surface', async ({ page }) => { await configureVisualPage(page); await gotoVisualHome(page); + // The redesigned entry shell keeps every view mounted (only the active one + // is visible) so tab switches don't reload thumbnails. That means + // `plugins-home-section` exists in both the home and plugins views, so + // scope the lookup to the home view to keep these strict-mode locators + // unambiguous. + const home = page.getByTestId('entry-view-home'); await expect(page.getByTestId('recent-projects-strip')).toBeVisible(); - await expect(page.getByTestId('plugins-home-section')).toBeVisible(); - await expect(page.getByTestId('plugins-home-chip-saved')).toBeVisible(); + await expect(home.getByTestId('plugins-home-section')).toBeVisible(); + await expect(home.getByTestId('plugins-home-chip-saved')).toBeVisible(); await captureVisual(page, 'visual-home-catalog'); }); @@ -33,8 +39,9 @@ test('[P2] captures the home plugin filtered surface', async ({ page }) => { await configureVisualPage(page); await gotoVisualHome(page); - await page.getByTestId('plugins-home-pill-category-deck').click(); - await expect(page.locator('article.plugins-home__card[data-plugin-id="visual-deck-writer"]')).toBeVisible(); + const home = page.getByTestId('entry-view-home'); + await home.getByTestId('plugins-home-pill-category-deck').click(); + await expect(home.locator('article.plugins-home__card[data-plugin-id="visual-deck-writer"]')).toBeVisible(); await captureVisual(page, 'visual-home-plugin-filter'); }); @@ -43,11 +50,12 @@ test('[P2] captures the home plugin detail surface', async ({ page }) => { await configureVisualPage(page); await gotoVisualHome(page); - await page.getByTestId('plugins-home-pill-category-deck').click(); - const card = page.locator('article.plugins-home__card[data-plugin-id="visual-deck-writer"]'); + const home = page.getByTestId('entry-view-home'); + await home.getByTestId('plugins-home-pill-category-deck').click(); + const card = home.locator('article.plugins-home__card[data-plugin-id="visual-deck-writer"]'); await expect(card).toBeVisible(); await card.hover(); - await page.getByTestId('plugins-home-details-visual-deck-writer').click({ force: true }); + await home.getByTestId('plugins-home-details-visual-deck-writer').click({ force: true }); await expect(page.getByRole('dialog', { name: /Deck Writer preview/i })).toBeVisible(); await expect(page.getByTestId('plugin-details-use-visual-deck-writer')).toBeVisible(); await expect(page.locator('.ds-modal-stage-iframe-scaler iframe')).toBeVisible(); @@ -84,8 +92,9 @@ test('[P2] captures the projects page surface', async ({ page }) => { await page.getByTestId('entry-nav-projects').click(); await expect(page).toHaveURL(/\/projects$/); - await expect(page.getByRole('heading', { name: 'Projects' })).toBeVisible(); - await expect(page.getByText('Launchpad dashboard')).toBeVisible(); + const projects = page.getByTestId('entry-view-projects'); + await expect(projects.getByRole('heading', { name: 'Projects' })).toBeVisible(); + await expect(projects.getByText('Launchpad dashboard').first()).toBeVisible(); await waitForVisualFonts(page); await captureVisual(page, 'visual-projects'); @@ -96,9 +105,10 @@ test('[P2] captures the projects kanban surface', async ({ page }) => { await gotoVisualHome(page); await page.getByTestId('entry-nav-projects').click(); - await page.getByTestId('designs-view-kanban').click(); - await expect(page.getByTestId('designs-view-kanban')).toHaveAttribute('aria-pressed', 'true'); - await expect(page.getByText('Launchpad dashboard')).toBeVisible(); + const projects = page.getByTestId('entry-view-projects'); + await projects.getByTestId('designs-view-kanban').click(); + await expect(projects.getByTestId('designs-view-kanban')).toHaveAttribute('aria-pressed', 'true'); + await expect(projects.getByText('Launchpad dashboard').first()).toBeVisible(); await waitForVisualFonts(page); await captureVisual(page, 'visual-projects-kanban'); @@ -125,9 +135,10 @@ test('[P2] captures the plugins page surface', async ({ page }) => { await page.getByTestId('entry-nav-plugins').click(); await expect(page).toHaveURL(/\/plugins$/); - await expect(page.getByRole('heading', { name: 'Plugins', exact: true })).toBeVisible(); - await expect(page.getByTestId('plugins-tab-installed')).toBeVisible(); - await expect(page.getByText('Prototype Starter').first()).toBeVisible(); + const plugins = page.getByTestId('entry-view-plugins'); + await expect(plugins.getByRole('heading', { name: 'Plugins', exact: true })).toBeVisible(); + await expect(plugins.getByTestId('plugins-tab-installed')).toBeVisible(); + await expect(plugins.getByText('Prototype Starter').first()).toBeVisible(); await waitForVisualFonts(page); await captureVisual(page, 'visual-plugins'); diff --git a/nix/pnpm-deps.nix b/nix/pnpm-deps.nix index 3af9a0491..819f52808 100644 --- a/nix/pnpm-deps.nix +++ b/nix/pnpm-deps.nix @@ -10,5 +10,5 @@ # 2. Run the relevant nix build/flake check # 3. Copy the expected hash printed by Nix into the matching field below daemonHash = "sha256-I2NzPscFPwgyocWyCUKfruqEAojzB6SuI4U/klrfdCI="; - webHash = "sha256-FeCqszNQooPNx9zbKeJc30CYxfUanZ6pAO2FwnUI/Lk="; + webHash = "sha256-HJElsw92u9Q0L22m8susnyRVtUnw/yfgnV98+NltmPg="; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73a4f9ea1..7243a4fcc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -264,6 +264,9 @@ importers: '@anthropic-ai/sdk': specifier: 0.32.1 version: 0.32.1 + '@formkit/auto-animate': + specifier: 0.9.0 + version: 0.9.0 '@open-design/components': specifier: workspace:* version: link:../../packages/components @@ -285,6 +288,9 @@ importers: lucide-react: specifier: 1.16.0 version: 1.16.0(react@18.3.1) + motion: + specifier: 12.40.0 + version: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next: specifier: 16.2.6 version: 16.2.6(@opentelemetry/api@1.9.1)(@playwright/test@1.60.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1327,6 +1333,9 @@ packages: '@fontsource-variable/playfair-display@5.2.8': resolution: {integrity: sha512-ZzVIXPOrL85yyOvZYoBzUszIJM+xKkHqni4IYn2CVLaGQQdJR8sBeC8yFNgjxSJ7ludTwta8qpULeOFuk5X75A==} + '@formkit/auto-animate@0.9.0': + resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -3014,6 +3023,20 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + framer-motion@12.40.0: + resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -3700,6 +3723,26 @@ packages: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true + motion-dom@12.40.0: + resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} + + motion-utils@12.39.0: + resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} + + motion@12.40.0: + resolution: {integrity: sha512-yjrHUrBFW6kQvjJwRsoiPSAhC5tRwRqNGJWmiJ4CrGnbKp0V88AdzkhBmDoqIsIPfarOe0Uddd37Xq43/gIocA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -5814,6 +5857,8 @@ snapshots: '@fontsource-variable/playfair-display@5.2.8': {} + '@formkit/auto-animate@0.9.0': {} + '@hono/node-server@1.19.14(hono@4.12.19)': dependencies: hono: 4.12.19 @@ -7661,6 +7706,15 @@ snapshots: forwarded@0.2.0: {} + framer-motion@12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + motion-dom: 12.40.0 + motion-utils: 12.39.0 + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + fresh@2.0.0: {} fs-constants@1.0.0: {} @@ -8567,6 +8621,20 @@ snapshots: dependencies: minimist: 1.2.8 + motion-dom@12.40.0: + dependencies: + motion-utils: 12.39.0 + + motion-utils@12.39.0: {} + + motion@12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + framer-motion: 12.40.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + tslib: 2.8.1 + optionalDependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + mrmime@2.0.1: {} ms@2.1.3: {}