From 22e646286e9321e66101f9aedc89a99edce1d3c4 Mon Sep 17 00:00:00 2001 From: SaelixCode Date: Fri, 20 Mar 2026 23:51:54 -0400 Subject: [PATCH] fix(ui): resolve 9 animated design system bugs including Monaco tab height accumulation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(editor): Monaco compose↔.env tab switching no longer accumulates height each switch — root cause was Monaco's position:static internal child creating a circular CSS dependency with the CSS grid; broken by calling layout({0,0}) to force reflow before re-measuring. Added overflow-hidden to Monaco container. - fix(ui): sliding tab indicator on compose/env tabs via motion.div layoutId - fix(ui): sliding tab indicator on Notification tabs (Discord/Slack/Webhook) - fix(ui): switch thumb now animates position (Radix data-attribute CSS translation) - fix(ui): 'Always Local' tooltip no longer crashes — replaced animate-ui tooltip (getStrictContext throws outside provider) with pure Radix primitives + CSS animation - feat(settings): Auto theme option added (light/dark/auto with matchMedia listener) - fix(ui): NodeManager dialog buttons no longer stuck together (DialogFooter gap) - fix(ui): AlertDialog spring animation + Quick Clean button text wraps correctly - fix(ui): Resources/App Store/Logs buttons toggle off on second click --- CHANGELOG.md | 1 + frontend/components.json | 4 +- frontend/src/components/EditorLayout.tsx | 88 +++++++++++++------ frontend/src/components/ResourcesView.tsx | 24 ++--- frontend/src/components/SettingsModal.tsx | 57 +++++++++--- frontend/src/components/ui/alert-dialog.tsx | 97 ++++++++++----------- frontend/src/components/ui/dialog.tsx | 13 ++- frontend/src/components/ui/switch.tsx | 2 +- frontend/src/components/ui/tabs.tsx | 14 +-- frontend/src/components/ui/tooltip.tsx | 30 ++++--- 10 files changed, 203 insertions(+), 127 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a070f0e3..1013d0ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- **Fixed:** Animated design system overhaul — 9 UI bugs resolved: (1) compose.yaml ↔ .env tab switching no longer accumulates height (Monaco's internal `position:static` child created a circular CSS dependency with the grid; fixed by resetting Monaco to 0×0 then forcing a synchronous reflow before re-measuring); (2) sliding tab indicator on compose/env tabs; (3) sliding tab indicator on Notification tabs (Discord/Slack/Webhook); (4) switch thumb now animates position (added Radix data-attribute CSS translation); (5) "Always Local" badge tooltip no longer crashes the page (replaced animate-ui tooltip that called `getStrictContext` with pure Radix primitives); (6) Auto theme option added to Appearance settings (light/dark/auto with `window.matchMedia` listener); (7) Cancel/Add Node buttons in NodeManager dialogs no longer stuck together (added custom `DialogFooter` with proper gap classes); (8) AlertDialog now uses spring animation (`stiffness: 380, damping: 32`) and Quick Clean button text wraps correctly; (9) Resources/App Store/Logs menu buttons now toggle off on second click. Monaco container received `overflow-hidden` to prevent height escape. - **Added:** `motion` package and `animate-ui` animated component library as the animation foundation for the design system. - **Changed:** Design system overhauled — new brand cyan token (`--brand`, `oklch(0.72 0.14 200)` dark / `oklch(0.50 0.14 200)` light) applied to focus rings across both themes. CSS custom properties added for motion easing curves (`--ease-spring`, `--ease-out-expo`), animation durations, and stagger delay utilities. `prefers-reduced-motion` respected globally. - **Changed:** Geist font loaded via Google Fonts CDN (was declared but never imported — no visual change, now actually resolved). diff --git a/frontend/components.json b/frontend/components.json index abd3aa3e..6770ae83 100644 --- a/frontend/components.json +++ b/frontend/components.json @@ -19,5 +19,7 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "registries": {} + "registries": { + "@animate-ui": "https://animate-ui.com/r/{name}.json" + } } diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index f030b679..4fae0996 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,4 +1,7 @@ import { useState, useEffect, useRef } from 'react'; +import { motion } from 'motion/react'; + +type Theme = 'light' | 'dark' | 'auto'; import Editor from '@monaco-editor/react'; import TerminalComponent from './Terminal'; import ErrorBoundary from './ErrorBoundary'; @@ -75,6 +78,7 @@ export default function EditorLayout() { // the stale-closure bug that occurs when reading containerStats directly. const rawBytesRef = useRef>({}); const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose'); + const monacoEditorRef = useRef(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [newStackName, setNewStackName] = useState(''); @@ -82,13 +86,15 @@ export default function EditorLayout() { const [isLoading, setIsLoading] = useState(false); const [loadingAction, setLoadingAction] = useState(null); const [isFileLoading, setIsFileLoading] = useState(false); - const [isDarkMode, setIsDarkMode] = useState(() => { - const saved = localStorage.getItem('sencho-theme'); - if (saved !== null) { - return saved === 'dark'; - } - return true; // Default to dark mode + const [theme, setTheme] = useState(() => { + const saved = localStorage.getItem('sencho-theme') as Theme | null; + if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved; + return 'dark'; // Default to dark mode }); + const [systemDark, setSystemDark] = useState(() => + window.matchMedia('(prefers-color-scheme: dark)').matches + ); + const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark); const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability'>('dashboard'); const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -117,17 +123,34 @@ export default function EditorLayout() { setAlertSheetOpen(true); }; - // Theme toggle effect + // Listen for system dark mode changes (for 'auto' theme) useEffect(() => { - const html = document.documentElement; - if (isDarkMode) { - html.classList.add('dark'); - localStorage.setItem('sencho-theme', 'dark'); - } else { - html.classList.remove('dark'); - localStorage.setItem('sencho-theme', 'light'); - } - }, [isDarkMode]); + const mq = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + + // Apply dark class and persist theme preference + useEffect(() => { + document.documentElement.classList.toggle('dark', isDarkMode); + localStorage.setItem('sencho-theme', theme); + }, [isDarkMode, theme]); + + // Force Monaco to re-measure its container after the tab switch DOM settles. + // Monaco's internal child is position:static with an explicit pixel height that + // creates a circular CSS dependency (Monaco drives card height → grid height → Monaco). + // Fix: reset Monaco to 0×0 first (breaks the cycle), then trigger a forced synchronous + // reflow so the container has its CSS-correct size before Monaco re-measures. + useEffect(() => { + const id = requestAnimationFrame(() => { + const editor = monacoEditorRef.current; + if (!editor) return; + editor.layout({ width: 0, height: 0 }); // collapse → breaks CSS circular dependency + editor.layout(); // forced reflow → measures correct container size + }); + return () => cancelAnimationFrame(id); + }, [activeTab]); const refreshStacks = async (background = false) => { if (!background) setIsLoading(true); @@ -930,10 +953,10 @@ export default function EditorLayout() { {/* Resources Toggle */} {/* App Store Toggle */} {/* Global Observability Toggle */} - - - diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 92b5bc39..e6ea8320 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react'; +import { motion } from 'motion/react'; import { Dialog, DialogContent, @@ -14,7 +15,7 @@ import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { apiFetch } from '@/lib/api'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Shield, Activity, Bell, Palette, Moon, Sun, Code, Server, Package, RefreshCw, Database, Info } from 'lucide-react'; +import { Shield, Activity, Bell, Palette, Moon, Sun, Monitor, Code, Server, Package, RefreshCw, Database, Info } from 'lucide-react'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { NodeManager } from './NodeManager'; import { useNodes } from '@/context/NodeContext'; @@ -41,11 +42,13 @@ interface PatchableSettings { type SectionId = 'account' | 'system' | 'notifications' | 'appearance' | 'developer' | 'nodes' | 'appstore'; +type Theme = 'light' | 'dark' | 'auto'; + interface SettingsModalProps { isOpen: boolean; onClose: () => void; - isDarkMode: boolean; - setIsDarkMode: (mode: boolean) => void; + theme: Theme; + setTheme: (theme: Theme) => void; } const DEFAULT_SETTINGS: PatchableSettings = { @@ -61,7 +64,7 @@ const DEFAULT_SETTINGS: PatchableSettings = { log_retention_days: '30', }; -export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: SettingsModalProps) { +export function SettingsModal({ isOpen, onClose, theme, setTheme }: SettingsModalProps) { const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; const [activeSection, setActiveSection] = useState('account'); @@ -73,6 +76,9 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se } }, [isRemote]); // eslint-disable-line react-hooks/exhaustive-deps + // Notification tab state (controlled for sliding indicator) + const [notifTab, setNotifTab] = useState<'discord' | 'slack' | 'webhook'>('discord'); + // Auth State const [authData, setAuthData] = useState({ oldPassword: '', newPassword: '', confirmPassword: '' }); @@ -571,11 +577,26 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se

Notifications & Alerts

Configure external integrations for crash alerts.

- - - Discord - Slack - Webhook + setNotifTab(v as 'discord' | 'slack' | 'webhook')} className="w-full"> + + + {notifTab === 'discord' && ( + + )} + Discord + + + {notifTab === 'slack' && ( + + )} + Slack + + + {notifTab === 'webhook' && ( + + )} + Webhook + {renderAgentTab('discord', 'Discord')} {renderAgentTab('slack', 'Slack')} @@ -590,23 +611,31 @@ export function SettingsModal({ isOpen, onClose, isDarkMode, setIsDarkMode }: Se

Appearance

Customize Sencho's visual theme.

-
+
+
)} diff --git a/frontend/src/components/ui/alert-dialog.tsx b/frontend/src/components/ui/alert-dialog.tsx index fa2b4429..e48cb48e 100644 --- a/frontend/src/components/ui/alert-dialog.tsx +++ b/frontend/src/components/ui/alert-dialog.tsx @@ -1,14 +1,13 @@ -import * as React from "react" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" +import * as React from 'react'; +import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'; +import { motion } from 'motion/react'; -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" +import { cn } from '@/lib/utils'; +import { buttonVariants } from '@/components/ui/button'; -const AlertDialog = AlertDialogPrimitive.Root - -const AlertDialogTrigger = AlertDialogPrimitive.Trigger - -const AlertDialogPortal = AlertDialogPrimitive.Portal +const AlertDialog = AlertDialogPrimitive.Root; +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; +const AlertDialogPortal = AlertDialogPrimitive.Portal; const AlertDialogOverlay = React.forwardRef< React.ElementRef, @@ -16,60 +15,59 @@ const AlertDialogOverlay = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; const AlertDialogContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( +>(({ className, children, ...props }, ref) => ( - + + + {children} + + -)) -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
-) -AlertDialogHeader.displayName = "AlertDialogHeader" +); +AlertDialogHeader.displayName = 'AlertDialogHeader'; const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
-) -AlertDialogFooter.displayName = "AlertDialogFooter" +); +AlertDialogFooter.displayName = 'AlertDialogFooter'; const AlertDialogTitle = React.forwardRef< React.ElementRef, @@ -77,11 +75,11 @@ const AlertDialogTitle = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; const AlertDialogDescription = React.forwardRef< React.ElementRef, @@ -89,12 +87,11 @@ const AlertDialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName +)); +AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName; const AlertDialogAction = React.forwardRef< React.ElementRef, @@ -105,8 +102,8 @@ const AlertDialogAction = React.forwardRef< className={cn(buttonVariants(), className)} {...props} /> -)) -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; const AlertDialogCancel = React.forwardRef< React.ElementRef, @@ -114,15 +111,11 @@ const AlertDialogCancel = React.forwardRef< >(({ className, ...props }, ref) => ( -)) -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; export { AlertDialog, @@ -136,4 +129,4 @@ export { AlertDialogDescription, AlertDialogAction, AlertDialogCancel, -} +}; diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index 3738c5cd..ad2ca8e1 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -12,7 +12,6 @@ import { DialogTrigger, DialogContent as AnimateDialogContent, DialogHeader, - DialogFooter, DialogTitle, DialogDescription, } from '@/components/animate-ui/primitives/radix/dialog'; @@ -51,6 +50,18 @@ const DialogOverlay = React.forwardRef< )); DialogOverlay.displayName = 'DialogOverlay'; +// Override DialogFooter to add proper spacing (animate-ui's version has no classes) +const DialogFooter = ({ className, ...props }: React.ComponentProps<'div'>) => ( +
+); +DialogFooter.displayName = 'DialogFooter'; + export { Dialog, DialogPortal, diff --git a/frontend/src/components/ui/switch.tsx b/frontend/src/components/ui/switch.tsx index 64f8f29b..c07979e7 100644 --- a/frontend/src/components/ui/switch.tsx +++ b/frontend/src/components/ui/switch.tsx @@ -22,7 +22,7 @@ const Switch = React.forwardRef( {...props} > diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index a11dc4e6..35172ed6 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -1,11 +1,13 @@ import * as React from 'react'; +import { Tabs as RadixTabs } from 'radix-ui'; import { cn } from '@/lib/utils'; import { Tabs, + TabsHighlight, + TabsHighlightItem, TabsList as AnimateTabsList, TabsTrigger as AnimateTabsTrigger, - TabsContent as AnimateTabsContent, } from '@/components/animate-ui/primitives/radix/tabs'; const TabsList = React.forwardRef< @@ -38,20 +40,22 @@ const TabsTrigger = React.forwardRef< )); TabsTrigger.displayName = 'TabsTrigger'; +// Custom TabsContent: uses Radix directly (no Framer Motion `layout` prop) +// to prevent unintended height expansion when switching tabs. const TabsContent = React.forwardRef< HTMLDivElement, - React.ComponentProps + React.ComponentProps >(({ className, ...props }, ref) => ( - )); TabsContent.displayName = 'TabsContent'; -export { Tabs, TabsList, TabsTrigger, TabsContent }; +export { Tabs, TabsList, TabsTrigger, TabsContent, TabsHighlight, TabsHighlightItem }; diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx index 43e05bb2..5efb0645 100644 --- a/frontend/src/components/ui/tooltip.tsx +++ b/frontend/src/components/ui/tooltip.tsx @@ -1,33 +1,35 @@ 'use client'; import * as React from 'react'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; import { cn } from '@/lib/utils'; -import { - TooltipProvider, - Tooltip, - TooltipTrigger, - TooltipPortal, - TooltipContent as AnimateTooltipContent, -} from '@/components/animate-ui/primitives/radix/tooltip'; +// Use Radix primitives directly to avoid animate-ui context dependencies +// that can cause crashes in certain component tree configurations. +const TooltipProvider = TooltipPrimitive.Provider; +const Tooltip = TooltipPrimitive.Root; +const TooltipTrigger = TooltipPrimitive.Trigger; -// Bundles the portal so consumers keep the same API const TooltipContent = React.forwardRef< HTMLDivElement, - React.ComponentProps + React.ComponentProps >(({ className, sideOffset = 4, ...props }, ref) => ( - - + - + )); TooltipContent.displayName = 'TooltipContent';