diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0302b62..6fd464c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -170,6 +170,12 @@ function Flow() { const [activeHelpTab, setActiveHelpTab] = useState(null); const [updateInfo, setUpdateInfo] = useState<{ latest: string; url: string } | null>(null); const [menuOpen, setMenuOpen] = useState(false); + const [menuClosing, setMenuClosing] = useState(false); + const closeMenu = useCallback(() => { + if (!menuOpen || menuClosing) return; + setMenuClosing(true); + setTimeout(() => { setMenuOpen(false); setMenuClosing(false); }, 150); + }, [menuOpen, menuClosing]); const flowContainerRef = useRef(null); const panTimerRef = useRef | null>(null); @@ -2226,18 +2232,21 @@ function Flow() { if (!menuOpen) return; const handler = (e: MouseEvent) => { if (floatingMenuRef.current && !floatingMenuRef.current.contains(e.target as HTMLElement)) { - setMenuOpen(false); + closeMenu(); } }; document.addEventListener('pointerdown', handler); return () => document.removeEventListener('pointerdown', handler); }, [menuOpen]); - // Auto-dismiss status toast after 5 seconds + // Auto-dismiss status toast after 5 seconds with close animation + const [toastClosing, setToastClosing] = useState(false); useEffect(() => { if (!status.text) return; - const timer = setTimeout(() => setStatus({ text: '', level: 'info' }), 5000); - return () => clearTimeout(timer); + setToastClosing(false); + const fadeTimer = setTimeout(() => setToastClosing(true), 4700); + const removeTimer = setTimeout(() => { setToastClosing(false); setStatus({ text: '', level: 'info' }); }, 5000); + return () => { clearTimeout(fadeTimer); clearTimeout(removeTimer); }; }, [status]); useEffect(() => { @@ -2444,43 +2453,43 @@ function Flow() {
{/* Floating menu */}
- - {menuOpen && ( -
- -
- - - - {window.pywebview && ( - )}
- - - {updateInfo && ( @@ -2496,8 +2505,8 @@ function Flow() {
{/* Status toast */} - {status.text && ( -
{status.text}
+ {(status.text || toastClosing) && ( +
{status.text}
)} {/* React Flow canvas */} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4de3eea..fe122ae 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -188,6 +188,34 @@ html, body, #root { gap: 3px; min-width: 150px; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + animation: menu-open 0.15s ease-out; + transform-origin: top left; +} + +@keyframes menu-open { + from { + opacity: 0; + transform: scale(0.9) translateY(-4px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +.floating-menu-dropdown.closing { + animation: menu-close 0.15s ease-in forwards; +} + +@keyframes menu-close { + from { + opacity: 1; + transform: scale(1) translateY(0); + } + to { + opacity: 0; + transform: scale(0.9) translateY(-4px); + } } .floating-menu-dropdown .btn { @@ -252,10 +280,23 @@ html, body, #root { border: 1px solid var(--border-toolbar); max-width: 60%; text-align: center; + animation: toast-in 0.2s ease-out; +} +.status-toast.closing { + animation: toast-out 0.3s ease-in forwards; } .status-toast.info { color: var(--accent-light); } .status-toast.error { color: var(--error-text); background: var(--error-bg); } +@keyframes toast-in { + from { opacity: 0; transform: translateX(-50%) translateY(8px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +@keyframes toast-out { + from { opacity: 1; transform: translateX(-50%) translateY(0); } + to { opacity: 0; transform: translateX(-50%) translateY(8px); } +} + /* ── React Flow container ──────────────────────────────────────────── */ .flow-container { flex: 1;