From d6ce60d280200a35d005f6749aa6ffeb81f5ba70 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 28 Jun 2026 08:17:34 -0400 Subject: [PATCH] fix: make Reduced motion gate overlays, standardize the tab band, and polish fleet/snapshots (#1504) * fix: address review feedback on motion, fleet band, and snapshots - Reduced motion now also skips the animate-ui overlay open/close animations (dialog, sheet, popover, dropdown-menu) via a shared useReducedTransition that zeroes the transition when reduced motion is active; MotionConfig alone only neutralized transform/layout, leaving the opacity/blur fade. - Fleet tab band: flatten the list's own pill band so the tabs sit in a single full-width band instead of a nested second band. - Fleet Overview: label the node-update button "Node Update"; the add-node button becomes a ChartNetwork icon that opens Settings > Nodes (outline style, matching the node-update button) instead of the add-node modal. - Blueprint deployments empty state: drive the serif headings from the theme heading-style token so Calm drops the italic. - Snapshots: move the per-stack Restore onto the stack header row (right side), matching the ghost action style. * feat: standardize the tab band on Security and add a tab hover state - Apply the Fleet full-width tab band to the Security page (single band, the list's pill background flattened so the tabs sit directly in it). - Add a hover highlight to tabs so the band reads as interactive, not flat; the active tab is unaffected. * fix: drive reduced-motion transitions from the app setting, not the OS query useReducedTransition called framer-motion's useReducedMotion(), which only reflects the OS prefers-reduced-motion media query and ignores our MotionConfig / appearance toggle, so dialogs kept animating with the setting on. Read the app's useReducedMotion selector instead. Verified: with the setting on, the dialog opacity snaps 0->1 in one frame; with it off, it ramps over the spring. * feat: standardize stack-detail body text to text-xs and add a log expand toggle - Bump the anatomy panel body text (rows, field values, warnings) and the structured log lines from 10/11/12px to text-xs (12px), leaving the uppercase kicker labels and the log level/source badges as-is. - Add an expand control next to the log download button that collapses the Command Center so the logs pane fills the left column; toggles back to restore. --- .../components/EditorLayout/EditorView.tsx | 22 ++++++- .../components/EditorLayout/ViewRouter.tsx | 1 + .../EditorLayout/editor-view-blocks.tsx | 12 ++-- frontend/src/components/FleetSnapshots.tsx | 58 +++++++++---------- frontend/src/components/FleetView.tsx | 13 +++-- .../components/FleetView/OverviewToolbar.tsx | 16 ++--- .../__tests__/OverviewToolbar.test.tsx | 14 ++--- frontend/src/components/SecurityView.tsx | 10 +++- frontend/src/components/StackAnatomyPanel.tsx | 42 +++++++------- .../src/components/StructuredLogViewer.tsx | 24 +++++++- .../animate-ui/primitives/radix/dialog.tsx | 7 ++- .../primitives/radix/dropdown-menu.tsx | 7 ++- .../animate-ui/primitives/radix/popover.tsx | 4 +- .../animate-ui/primitives/radix/sheet.tsx | 7 ++- .../primitives/use-reduced-transition.ts | 15 +++++ .../blueprints/BlueprintEmptyState.tsx | 4 +- frontend/src/components/ui/tabs.tsx | 2 +- 17 files changed, 167 insertions(+), 91 deletions(-) create mode 100644 frontend/src/components/animate-ui/primitives/use-reduced-transition.ts diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index 34f25593..96208d49 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -324,6 +324,15 @@ export function EditorView(props: EditorViewProps) { if (!editingCompose || activeTab !== 'files') setFilesFullscreen(false); }, [editingCompose, activeTab]); + // Expand the logs by collapsing the Command Center card so the logs pane + // fills the left column. Toggled from the structured log viewer header. + const [logsExpanded, setLogsExpanded] = useState(false); + // The expand control lives only in the structured viewer; reset when the + // raw terminal is selected so the Command Center can't be stranded hidden. + useEffect(() => { + if (logsMode === 'raw') setLogsExpanded(false); + }, [logsMode]); + // Below md, render the segmented full-screen mobile detail instead of the // desktop two-pane grid. All hooks above run unconditionally before this // branch so hook order stays stable across breakpoints. @@ -339,7 +348,9 @@ export function EditorView(props: EditorViewProps) { files fullscreen so the editor card fills the width. */} {!filesFullscreen && (
- {/* Command Center Card (identity + health strip) */} + {/* Command Center Card (identity + health strip). Hidden when + the logs are expanded so the logs pane fills the column. */} + {!logsExpanded && (
@@ -404,9 +415,16 @@ export function EditorView(props: EditorViewProps) { /> + )} {/* Logs Section (fills remaining left-column height) */} - + setLogsExpanded((v) => !v)} + />
)} diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index ee108bc9..a10cee12 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -185,6 +185,7 @@ export function ViewRouter({ void; + /** When set, the structured viewer shows an expand control that collapses + * the Command Center to give the logs more vertical room. */ + logsExpanded?: boolean; + onToggleLogsExpand?: () => void; } // Logs pane: structured / raw-terminal toggle + the live viewer. -export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogsSectionProps) { +export function StackLogsSection({ stackName, logsMode, setLogsMode, logsExpanded, onToggleLogsExpand }: StackLogsSectionProps) { return (
@@ -568,7 +572,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogs type="button" onClick={() => setLogsMode('structured')} className={cn( - 'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors', + 'rounded px-2 py-0.5 font-mono text-xs uppercase tracking-wide transition-colors', logsMode === 'structured' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground', )} > @@ -578,7 +582,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogs type="button" onClick={() => setLogsMode('raw')} className={cn( - 'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors', + 'rounded px-2 py-0.5 font-mono text-xs uppercase tracking-wide transition-colors', logsMode === 'raw' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground', )} > @@ -588,7 +592,7 @@ export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogs
{logsMode === 'structured' ? ( - + ) : (
diff --git a/frontend/src/components/FleetSnapshots.tsx b/frontend/src/components/FleetSnapshots.tsx index 5906180f..bfeeb390 100644 --- a/frontend/src/components/FleetSnapshots.tsx +++ b/frontend/src/components/FleetSnapshots.tsx @@ -590,22 +590,34 @@ export default function FleetSnapshots() { .find(s => s.nodeId === node.nodeId && s.stackName === stack.stackName)?.dossier; return (
- +
+ + {isAdmin && ( + + )} +
{/* Files */} {stackExpanded && ( @@ -650,20 +662,6 @@ export default function FleetSnapshots() { {/* Preserved dossier notes (read-only) */} {dossier && } - - {/* Restore (admin only), right-aligned to match the file action row */} - {isAdmin && ( -
- -
- )}
)}
diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index f82fec72..c15855f4 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -31,9 +31,12 @@ import { SecretsTab } from './fleet/secrets/SecretsTab'; import { DependencyMapTab } from './fleet/DependencyMapTab'; import { useNodeActions } from './nodes/useNodeActions'; import type { FleetTab } from '@/lib/events'; +import type { SectionId } from '@/components/settings/types'; interface FleetViewProps { onNavigateToNode: (nodeId: number, stackName: string) => void; + /** Opens a Settings section (used to send "Add node" to Settings > Nodes). */ + onOpenSettingsSection?: (section: SectionId) => void; fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null; onFleetUpdatesIntentConsumed?: () => void; /** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */ @@ -41,7 +44,7 @@ interface FleetViewProps { onFleetTabConsumed?: () => void; } -export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) { +export function FleetView({ onNavigateToNode, onOpenSettingsSection, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) { const { isPaid } = useLicense(); const { isAdmin } = useAuth(); @@ -81,7 +84,7 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates const { mastheadStats, lastSyncAt, loading, refreshing } = overview; - const { openCreate, openEdit, openDelete, NodeActionModals } = useNodeActions({ + const { openEdit, openDelete, NodeActionModals } = useNodeActions({ onNodeChange: () => { void overview.fetchOverview(true); }, }); @@ -103,7 +106,9 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates setActiveTab(v as FleetTab)}>
- + {/* Flatten the list's own pill band so the tabs sit directly in + the single full-width band, not a nested second band. */} + Overview @@ -216,7 +221,7 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates onCordonChange={() => { void overview.fetchOverview(true); }} onEditNode={isAdmin ? openEdit : undefined} onDeleteNode={isAdmin ? openDelete : undefined} - onAddNode={isAdmin ? openCreate : undefined} + onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined} onCheckUpdates={updateStatus.checkUpdates} checkingUpdates={updateStatus.checkingUpdates} topologyMode={topology.prefs.mode} diff --git a/frontend/src/components/FleetView/OverviewToolbar.tsx b/frontend/src/components/FleetView/OverviewToolbar.tsx index aac1ec14..2df5cae2 100644 --- a/frontend/src/components/FleetView/OverviewToolbar.tsx +++ b/frontend/src/components/FleetView/OverviewToolbar.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Search, ArrowUpDown, AlertTriangle, Play, Square, - LayoutGrid, Network, SlidersHorizontal, Plus, RefreshCcwDot, + LayoutGrid, Network, SlidersHorizontal, ChartNetwork, RefreshCcwDot, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -259,25 +259,27 @@ export function OverviewToolbar({ )} {onAddNode && ( )}
diff --git a/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx index 7d92e3b5..a2b76a1b 100644 --- a/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx +++ b/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx @@ -82,32 +82,32 @@ describe('OverviewToolbar', () => { expect(screen.queryByText('Tags')).not.toBeInTheDocument(); }); - it('renders the Add node button and fires onAddNode when provided', () => { + it('renders the manage-nodes button and fires onAddNode when provided', () => { const onAddNode = vi.fn(); render(); - fireEvent.click(screen.getByRole('button', { name: 'Add node' })); + fireEvent.click(screen.getByRole('button', { name: 'Manage nodes' })); expect(onAddNode).toHaveBeenCalledTimes(1); }); - it('omits the Add node button when onAddNode is not provided', () => { + it('omits the manage-nodes button when onAddNode is not provided', () => { render(); - expect(screen.queryByRole('button', { name: 'Add node' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Manage nodes' })).not.toBeInTheDocument(); }); it('renders the Check Updates button and fires onCheckUpdates when provided', () => { const onCheckUpdates = vi.fn(); render(); - fireEvent.click(screen.getByRole('button', { name: /Check for updates/ })); + fireEvent.click(screen.getByRole('button', { name: /Check for node updates/ })); expect(onCheckUpdates).toHaveBeenCalledTimes(1); }); it('omits the Check Updates button when onCheckUpdates is not provided', () => { render(); - expect(screen.queryByRole('button', { name: /Check for updates/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Check for node updates/ })).not.toBeInTheDocument(); }); it('disables the Check Updates button while a check is in flight', () => { render(); - expect(screen.getByRole('button', { name: /Check for updates/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Check for node updates/ })).toBeDisabled(); }); }); diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index de4fb61b..b2dd89a8 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -396,8 +396,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security onTabChange(v as SecurityTab)}> - - + {/* Standard full-width tab band (matches Fleet): the list's own pill band + is flattened so the tabs sit directly in this single band. */} +
+ + Overview @@ -424,7 +427,8 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security Scanner setup - + +
{tabPanels}
diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index da62f2e7..a5710991 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -64,7 +64,7 @@ function Row({ label, children }: { label: string; children: React.ReactNode }) return (
{label} -
{children}
+
{children}
); } @@ -359,18 +359,18 @@ export default function StackAnatomyPanel({
- Anatomy - Activity - Dossier - Drift + Anatomy + Activity + Dossier + Drift {envInventoryEnabled && ( - Environment + Environment )} {networkingEnabled && ( - Networking + Networking )} {doctorEnabled && ( - + Doctor {(preflightSeverity === 'blocker' || preflightSeverity === 'high') && !doctorDismissed && ( @@ -383,7 +383,7 @@ export default function StackAnatomyPanel({ )} {storageEnabled && ( - Storage + Storage )} @@ -417,7 +417,7 @@ export default function StackAnatomyPanel({
{!anatomy ? ( -
Unable to parse compose.yaml.
+
Unable to parse compose.yaml.
) : ( <> @@ -426,7 +426,7 @@ export default function StackAnatomyPanel({ ) : (
{anatomy.services.map(s => ( - + {s} ))} @@ -437,7 +437,7 @@ export default function StackAnatomyPanel({ {Object.keys(anatomy.ports).length === 0 ? ( none ) : ( -
+
{Object.entries(anatomy.ports).flatMap(([svc, rows]) => rows.map((r, i) => (
@@ -457,7 +457,7 @@ export default function StackAnatomyPanel({ {Object.keys(anatomy.volumes).length === 0 ? ( none ) : ( -
+
{Object.entries(anatomy.volumes).flatMap(([svc, rows]) => rows.map((r, i) => (
@@ -474,19 +474,19 @@ export default function StackAnatomyPanel({ )} - {anatomy.restart ?? default} + {anatomy.restart ?? default} {!firstEnvFile ? ( none ) : (
-
+
{firstEnvFile} · {envVarCount} var{envVarCount === 1 ? '' : 's'}
{missingVars.length > 0 && ( -
+
{missingVars.map(v => ( {'${'}{v}{'}'} missing ))} @@ -496,14 +496,14 @@ export default function StackAnatomyPanel({ )} - {networkName} · bridge + {networkName} · bridge ))}
+ {onToggleExpand && ( + + )} @@ -289,7 +307,7 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
{filtered.length === 0 ? (
Waiting for log output…
diff --git a/frontend/src/components/animate-ui/primitives/radix/dialog.tsx b/frontend/src/components/animate-ui/primitives/radix/dialog.tsx index 3730aead..b8e12b7c 100644 --- a/frontend/src/components/animate-ui/primitives/radix/dialog.tsx +++ b/frontend/src/components/animate-ui/primitives/radix/dialog.tsx @@ -7,6 +7,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react'; import { useControlledState } from '@/hooks/use-controlled-state'; import { getStrictContext } from '@/lib/get-strict-context'; +import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition'; type DialogContextType = { isOpen: boolean; @@ -73,6 +74,7 @@ function DialogOverlay({ transition = { duration: 0.2, ease: 'easeInOut' }, ...props }: DialogOverlayProps) { + const resolvedTransition = useReducedTransition(transition); return ( @@ -111,6 +113,7 @@ function DialogContent({ from === 'bottom' || from === 'left' ? '20deg' : '-20deg'; const isVertical = from === 'top' || from === 'bottom'; const rotateAxis = isVertical ? 'rotateX' : 'rotateY'; + const resolvedTransition = useReducedTransition(transition); return ( diff --git a/frontend/src/components/animate-ui/primitives/radix/dropdown-menu.tsx b/frontend/src/components/animate-ui/primitives/radix/dropdown-menu.tsx index b828b906..22edb6f1 100644 --- a/frontend/src/components/animate-ui/primitives/radix/dropdown-menu.tsx +++ b/frontend/src/components/animate-ui/primitives/radix/dropdown-menu.tsx @@ -13,6 +13,7 @@ import { } from '@/components/animate-ui/primitives/effects/highlight'; import { getStrictContext } from '@/lib/get-strict-context'; import { useControlledState } from '@/hooks/use-controlled-state'; +import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition'; import { useDataState } from '@/hooks/use-data-state'; type DropdownMenuContextType = { @@ -198,6 +199,7 @@ function DropdownMenuSubContent({ ...props }: DropdownMenuSubContentProps) { const { isOpen } = useDropdownMenuSub(); + const resolvedTransition = useReducedTransition(transition); return ( @@ -226,7 +228,7 @@ function DropdownMenuSubContent({ initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} - transition={transition} + transition={resolvedTransition} style={{ willChange: 'opacity, transform', ...style }} {...props} /> @@ -295,6 +297,7 @@ function DropdownMenuContent({ ...props }: DropdownMenuContentProps) { const { isOpen } = useDropdownMenu(); + const resolvedTransition = useReducedTransition(transition); return ( @@ -325,7 +328,7 @@ function DropdownMenuContent({ initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} - transition={transition} + transition={resolvedTransition} style={{ willChange: 'opacity, transform', ...style }} {...props} /> diff --git a/frontend/src/components/animate-ui/primitives/radix/popover.tsx b/frontend/src/components/animate-ui/primitives/radix/popover.tsx index c022ac4b..da7c8b52 100644 --- a/frontend/src/components/animate-ui/primitives/radix/popover.tsx +++ b/frontend/src/components/animate-ui/primitives/radix/popover.tsx @@ -7,6 +7,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react'; import { getStrictContext } from '@/lib/get-strict-context'; import { useControlledState } from '@/hooks/use-controlled-state'; +import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition'; type PopoverContextType = { isOpen: boolean; @@ -91,6 +92,7 @@ function PopoverContent({ transition = { type: 'spring', stiffness: 300, damping: 25 }, ...props }: PopoverContentProps) { + const resolvedTransition = useReducedTransition(transition); return ( diff --git a/frontend/src/components/animate-ui/primitives/radix/sheet.tsx b/frontend/src/components/animate-ui/primitives/radix/sheet.tsx index 59f7a0ae..11dfdaad 100644 --- a/frontend/src/components/animate-ui/primitives/radix/sheet.tsx +++ b/frontend/src/components/animate-ui/primitives/radix/sheet.tsx @@ -7,6 +7,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react'; import { getStrictContext } from '@/lib/get-strict-context'; import { useControlledState } from '@/hooks/use-controlled-state'; +import { useReducedTransition } from '@/components/animate-ui/primitives/use-reduced-transition'; type SheetContextType = { isOpen: boolean; @@ -72,6 +73,7 @@ function SheetOverlay({ transition = { duration: 0.2, ease: 'easeInOut' }, ...props }: SheetOverlayProps) { + const resolvedTransition = useReducedTransition(transition); return ( @@ -102,6 +104,7 @@ function SheetContent({ ...props }: SheetContentProps) { const axis = side === 'left' || side === 'right' ? 'x' : 'y'; + const resolvedTransition = useReducedTransition(transition); const offscreen: Record = { right: { x: '100%', opacity: 0 }, @@ -131,7 +134,7 @@ function SheetContent({ ...positionStyle[side], ...style, }} - transition={transition} + transition={resolvedTransition} > {children} diff --git a/frontend/src/components/animate-ui/primitives/use-reduced-transition.ts b/frontend/src/components/animate-ui/primitives/use-reduced-transition.ts new file mode 100644 index 00000000..b6532471 --- /dev/null +++ b/frontend/src/components/animate-ui/primitives/use-reduced-transition.ts @@ -0,0 +1,15 @@ +import { useReducedMotion } from '@/hooks/use-theme'; + +const INSTANT = { duration: 0 } as const; + +/** + * Returns an instant (zero-duration) transition when the "Reduced motion" + * appearance setting is on, otherwise the supplied transition. This reads our + * app setting (not framer-motion's useReducedMotion, which only reflects the OS + * prefers-reduced-motion). `MotionConfig`'s reducedMotion only neutralizes + * transform/layout animations, so overlay primitives that fade or blur + * (opacity/filter) still animate; this collapses those too. + */ +export function useReducedTransition(transition: T): T | typeof INSTANT { + return useReducedMotion() ? INSTANT : transition; +} diff --git a/frontend/src/components/blueprints/BlueprintEmptyState.tsx b/frontend/src/components/blueprints/BlueprintEmptyState.tsx index 3ec41398..25c693e2 100644 --- a/frontend/src/components/blueprints/BlueprintEmptyState.tsx +++ b/frontend/src/components/blueprints/BlueprintEmptyState.tsx @@ -16,7 +16,7 @@ export function BlueprintEmptyState({ onCreate, canCreate }: BlueprintEmptyState Deployments · Blueprints
-

+

Declare once. Distribute everywhere.

@@ -47,7 +47,7 @@ function Step({ kicker, title, copy }: { kicker: string; title: string; copy: st return (

{kicker} - {title} + {title} {copy}
); diff --git a/frontend/src/components/ui/tabs.tsx b/frontend/src/components/ui/tabs.tsx index 0ebb2dc6..a2a77a61 100644 --- a/frontend/src/components/ui/tabs.tsx +++ b/frontend/src/components/ui/tabs.tsx @@ -32,7 +32,7 @@ const TabsTrigger = React.forwardRef<