From b5810a9b55d76e23b7667802d3a512212754b2a6 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 28 Jun 2026 06:18:02 -0400 Subject: [PATCH] feat: add Reduced motion setting and polish chrome, files, and stack-detail (#1501) A batch of UI/UX polish: - New independent "Reduced motion" appearance setting (separate from Reduced effects). Drives framer-motion via MotionConfig and clamps CSS transitions via data-motion on ; toasts are unaffected. Defaults off (OS preference still honored). - Stack-detail Files tab: rename "Files & Volumes" to "Files", add a persisted word-wrap toggle to the file viewer (default on), and add a fullscreen toggle that collapses the Command Center + Logs column so the editor fills the width. - Create Stack > From Git: remove the nested scroll clamp so the deploy toggle and footer are reachable. - Fleet: full-width tab band with icon-only Refresh / Export Dossier, icon-only Check-for-updates / Add-node on the Overview toolbar, theme-aware empty-state headings (calm drops the italic), and fix the Actions card body overlapping the action-row divider. - Snapshots: restyle Restore and Restore all to the ghost button design used by View / Preview / Download, and right-align the per-stack Restore. - Settings sidebar: App Store gradient active style and standard font size. - Compose Doctor: dismiss the high-risk banner (and clear the tab dot) until the findings change, via a shared fingerprint-keyed hook. - Stack-detail Storage: link the "no recent fleet snapshot" warning to the Fleet Snapshots tab (FleetView tabs are now controlled to support the deep link). --- frontend/src/App.tsx | 37 ++++++-- frontend/src/components/EditorLayout.tsx | 3 + .../EditorLayout/CreateStackDialog.tsx | 90 +++++++++---------- .../components/EditorLayout/EditorView.tsx | 35 +++++++- .../components/EditorLayout/ViewRouter.tsx | 8 +- .../__tests__/useViewNavigationState.test.tsx | 11 +++ .../hooks/useViewNavigationState.ts | 12 ++- frontend/src/components/FleetSnapshots.tsx | 36 ++++---- frontend/src/components/FleetView.tsx | 33 +++++-- .../components/FleetView/OverviewToolbar.tsx | 14 ++- .../__tests__/OverviewToolbar.test.tsx | 6 +- frontend/src/components/NodeManager.tsx | 6 +- frontend/src/components/StackAnatomyPanel.tsx | 16 +++- frontend/src/components/files/FileViewer.tsx | 29 +++++- .../src/components/fleet/FleetEmptyState.tsx | 13 +-- .../components/settings/AppearanceSection.tsx | 15 +++- .../components/settings/SettingsSidebar.tsx | 8 +- .../__tests__/AppearanceSection.test.tsx | 12 +++ .../components/stack/PreflightPanel.test.tsx | 15 +++- .../src/components/stack/PreflightPanel.tsx | 25 +++++- .../src/components/stack/StoragePanel.tsx | 12 +++ .../src/components/ui/fleet-action-card.tsx | 4 +- .../__tests__/usePreflightDismiss.test.ts | 50 +++++++++++ frontend/src/hooks/use-theme.ts | 24 +++++ frontend/src/hooks/usePreflightDismiss.ts | 69 ++++++++++++++ frontend/src/index.css | 12 +++ frontend/src/lib/events.ts | 13 +++ 27 files changed, 489 insertions(+), 119 deletions(-) create mode 100644 frontend/src/hooks/__tests__/usePreflightDismiss.test.ts create mode 100644 frontend/src/hooks/usePreflightDismiss.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4c1c99d5..aea66650 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,7 @@ +import type { ReactNode } from 'react'; +import { MotionConfig } from 'motion/react'; import { AuthProvider, useAuth } from './context/AuthContext'; +import { useReducedMotion } from './hooks/use-theme'; import { NodeProvider } from './context/NodeContext'; import { LicenseProvider } from './context/LicenseContext'; import { Login } from './components/Login'; @@ -9,6 +12,20 @@ import { DeployFeedbackProvider } from './context/DeployFeedbackContext'; import { DeployFeedbackPortal } from './components/DeployFeedbackPortal'; import { ToastContainer } from './components/ui/toast'; +/** Gates framer-motion animations on the "Reduced motion" appearance setting. + * 'always' suppresses transform/layout motion app-wide; 'user' defers to the OS + * prefers-reduced-motion. Sonner toasts do not use framer-motion, so they are + * unaffected. Subscribes only to the motion flag to avoid re-rendering the app + * tree on unrelated theme changes. */ +function MotionProvider({ children }: { children: ReactNode }) { + const reducedMotion = useReducedMotion(); + return ( + + {children} + + ); +} + function AppContent() { const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth(); @@ -33,15 +50,17 @@ function AppContent() { } return ( - - - - {/* Portal lives inside LicenseProvider so the editor surface and its - portalled overlays can read license state via useLicense(). - Outer DeployFeedbackProvider is still an ancestor through App. */} - - - + + + + + {/* Portal lives inside LicenseProvider so the editor surface and its + portalled overlays can read license state via useLicense(). + Outer DeployFeedbackProvider is still an ancestor through App. */} + + + + ); } diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 8ecd05a3..503eb238 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -176,6 +176,7 @@ export default function EditorLayout() { activeView, setActiveView, settingsSection, setSettingsSection, securityTab, setSecurityTab, + fleetTab, setFleetTab, filterNodeId, setFilterNodeId, schedulePrefill, mobileNavOpen, setMobileNavOpen, @@ -762,6 +763,8 @@ export default function EditorLayout() { onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed} securityTab={securityTab} onSecurityTabChange={setSecurityTab} + fleetTab={fleetTab} + onFleetTabConsumed={() => setFleetTab(null)} renderEditor={renderEditor} /> diff --git a/frontend/src/components/EditorLayout/CreateStackDialog.tsx b/frontend/src/components/EditorLayout/CreateStackDialog.tsx index c1791dad..76f816bc 100644 --- a/frontend/src/components/EditorLayout/CreateStackDialog.tsx +++ b/frontend/src/components/EditorLayout/CreateStackDialog.tsx @@ -426,55 +426,53 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks {createMode === 'git' && (
- - -
- - setNewStackName(e.target.value)} - disabled={creatingFromGit} - /> -
- - +
+ + setNewStackName(e.target.value)} disabled={creatingFromGit} - repoUrl={gitRepoUrl} - branch={gitBranch} - composePaths={gitComposePaths} - contextDir={gitContextDir} - syncEnv={gitSyncEnv} - authType={gitAuthType} - token={gitToken} - hasStoredToken={false} - applyMode={gitApplyMode} - onRepoUrlChange={setGitRepoUrl} - onBranchChange={setGitBranch} - onComposePathsChange={setGitComposePaths} - onContextDirChange={setGitContextDir} - onSyncEnvChange={setGitSyncEnv} - onAuthTypeChange={setGitAuthType} - onTokenChange={setGitToken} - onApplyModeChange={setGitApplyMode} - onBrowse={browseGitRepo} /> +
-
- setGitDeployNow(c === true)} - disabled={creatingFromGit} - /> - -
-
-
+ + +
+ setGitDeployNow(c === true)} + disabled={creatingFromGit} + /> + +
+ { + if (!editingCompose || activeTab !== 'files') setFilesFullscreen(false); + }, [editingCompose, activeTab]); + // 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. @@ -324,8 +334,10 @@ export function EditorView(props: EditorViewProps) { return ( -
- {/* Left column: identity + health strip + logs, stacked */} +
+ {/* Left column: identity + health strip + logs, stacked. Hidden in + files fullscreen so the editor card fills the width. */} + {!filesFullscreen && (
{/* Command Center Card (identity + health strip) */} @@ -396,6 +408,7 @@ export function EditorView(props: EditorViewProps) { {/* Logs Section (fills remaining left-column height) */}
+ )} {/* Right column: anatomy panel by default, Monaco editor when editing */} {editingCompose ? ( @@ -415,7 +428,7 @@ export function EditorView(props: EditorViewProps) { - Files & Volumes + Files )} @@ -485,6 +498,20 @@ export function EditorView(props: EditorViewProps) { )} )} + {activeTab === 'files' && ( + + )}
)} @@ -919,16 +921,16 @@ function RestoreButton({ nodeId, nodeName, stackName, hasDossier, restoring, onR return ( <> @@ -1020,16 +1022,16 @@ function RestoreAllButton({ restoring, hasDocumentation, onRestoreAll }: { return ( <> diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 8f079fd2..f82fec72 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -30,14 +30,18 @@ import { FleetActionsTab } from './fleet/FleetActions/FleetActionsTab'; import { SecretsTab } from './fleet/secrets/SecretsTab'; import { DependencyMapTab } from './fleet/DependencyMapTab'; import { useNodeActions } from './nodes/useNodeActions'; +import type { FleetTab } from '@/lib/events'; interface FleetViewProps { onNavigateToNode: (nodeId: number, stackName: string) => void; fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null; onFleetUpdatesIntentConsumed?: () => void; + /** Deep-link target tab (e.g. 'snapshots' from the stack storage warning). */ + fleetTab?: FleetTab | null; + onFleetTabConsumed?: () => void; } -export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdatesIntentConsumed }: FleetViewProps) { +export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdatesIntentConsumed, fleetTab, onFleetTabConsumed }: FleetViewProps) { const { isPaid } = useLicense(); const { isAdmin } = useAuth(); @@ -55,6 +59,10 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates const [initialUpdatesTab, setInitialUpdatesTab] = useState<'nodes' | 'changelog'>('nodes'); + // Controlled tab value so a deep-link (e.g. Snapshots from the stack storage + // warning) can land on the right tab. + const [activeTab, setActiveTab] = useState('overview'); + useEffect(() => { if (fleetUpdatesIntent) { setInitialUpdatesTab(fleetUpdatesIntent.tab); @@ -64,6 +72,13 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates } }, [fleetUpdatesIntent, updateStatus, onFleetUpdatesIntentConsumed]); + useEffect(() => { + if (fleetTab) { + setActiveTab(fleetTab); + onFleetTabConsumed?.(); + } + }, [fleetTab, onFleetTabConsumed]); + const { mastheadStats, lastSyncAt, loading, refreshing } = overview; const { openCreate, openEdit, openDelete, NodeActionModals } = useNodeActions({ @@ -86,8 +101,8 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates loading={loading} /> - -
+ setActiveTab(v as FleetTab)}> +
@@ -146,16 +161,17 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates )} -
+
{isAdmin && ( )}
diff --git a/frontend/src/components/FleetView/OverviewToolbar.tsx b/frontend/src/components/FleetView/OverviewToolbar.tsx index ffdbac52..aac1ec14 100644 --- a/frontend/src/components/FleetView/OverviewToolbar.tsx +++ b/frontend/src/components/FleetView/OverviewToolbar.tsx @@ -259,19 +259,25 @@ 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 8ea5cc5f..7d92e3b5 100644 --- a/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx +++ b/frontend/src/components/FleetView/__tests__/OverviewToolbar.test.tsx @@ -97,17 +97,17 @@ describe('OverviewToolbar', () => { it('renders the Check Updates button and fires onCheckUpdates when provided', () => { const onCheckUpdates = vi.fn(); render(); - fireEvent.click(screen.getByRole('button', { name: /Check Updates/ })); + fireEvent.click(screen.getByRole('button', { name: /Check for updates/ })); expect(onCheckUpdates).toHaveBeenCalledTimes(1); }); it('omits the Check Updates button when onCheckUpdates is not provided', () => { render(); - expect(screen.queryByRole('button', { name: /Check Updates/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Check for updates/ })).not.toBeInTheDocument(); }); it('disables the Check Updates button while a check is in flight', () => { render(); - expect(screen.getByRole('button', { name: /Check Updates/ })).toBeDisabled(); + expect(screen.getByRole('button', { name: /Check for updates/ })).toBeDisabled(); }); }); diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 222144c9..6ec431b8 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -19,7 +19,7 @@ import { useAuth } from '@/context/AuthContext'; import { useNodeActions, type NodeTestInfo } from './nodes/useNodeActions'; import { useFleetSyncStatus } from '@/hooks/useFleetSyncStatus'; import { resetFleetSyncAnchor, STICKY_CONTROL_IDENTITY_MISMATCH } from '@/lib/fleetSyncApi'; -import type { SecurityTab } from '@/lib/events'; +import type { SecurityTab, FleetTab } from '@/lib/events'; interface NodeSchedulingSummary { active_tasks: number; @@ -30,10 +30,12 @@ interface NodeSchedulingSummary { export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate'; export interface SenchoNavigateDetail { - view: 'scheduled-ops' | 'auto-updates' | 'security'; + view: 'scheduled-ops' | 'auto-updates' | 'security' | 'fleet'; nodeId?: number; /** Target tab when navigating to the Security view. */ tab?: SecurityTab; + /** Target tab when navigating to the Fleet view (e.g. 'snapshots'). */ + fleetTab?: FleetTab; } export function NodeManager() { diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index b6b26f32..da62f2e7 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -6,6 +6,7 @@ import { ScrollableTabRow } from './ui/ScrollableTabRow'; import { apiFetch } from '@/lib/api'; import { cn } from '@/lib/utils'; import { type AnatomyMarkdownInput, type PortRow, type VolumeRow } from '@/lib/anatomyMarkdown'; +import { usePreflightDismiss } from '@/hooks/usePreflightDismiss'; import { parseAnatomy, parseEnvKeys, formatGitSource, primaryPublishedHostPort, type GitSourceInfo } from '@/lib/anatomy'; import { buildServiceUrl } from '@/lib/serviceUrl'; import { StackActivityTimeline } from './stack/StackActivityTimeline'; @@ -108,6 +109,12 @@ export default function StackAnatomyPanel({ // active tab content lazily, so the badge cannot come from PreflightPanel; the // parent reads the stored run once per stack/node change. const [preflightSeverity, setPreflightSeverity] = useState(null); + // Findings power the dismiss fingerprint so the dot clears in lockstep with the + // banner and re-appears when the findings change. + const [preflightFindings, setPreflightFindings] = useState | undefined>(undefined); + // The Doctor tab dot clears when the high-risk banner is dismissed, and returns + // when the findings change (shared fingerprint with PreflightPanel). + const { dismissed: doctorDismissed } = usePreflightDismiss(stackName, activeNode?.id, preflightFindings); const [scanStatus, setScanStatus] = useState<{ status: 'ok' | 'partial' | 'failed' | 'skipped' | null; attemptedAt?: number; @@ -126,9 +133,12 @@ export default function StackAnatomyPanel({ const res = await apiFetch(`/stacks/${stackName}/preflight`); if (cancelled || !res.ok) return; const data = await res.json(); - if (!cancelled) setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null); + if (!cancelled) { + setPreflightSeverity(typeof data?.highestSeverity === 'string' ? data.highestSeverity : null); + setPreflightFindings(Array.isArray(data?.findings) ? data.findings : undefined); + } } catch { - if (!cancelled) setPreflightSeverity(null); + if (!cancelled) { setPreflightSeverity(null); setPreflightFindings(undefined); } } })(); return () => { cancelled = true; }; @@ -363,7 +373,7 @@ export default function StackAnatomyPanel({ Doctor - {(preflightSeverity === 'blocker' || preflightSeverity === 'high') && ( + {(preflightSeverity === 'blocker' || preflightSeverity === 'high') && !doctorDismissed && ( { + try { return localStorage.getItem(WORD_WRAP_KEY) !== 'false'; } catch { return true; } + }); + useEffect(() => { + try { localStorage.setItem(WORD_WRAP_KEY, String(wordWrap)); } catch { /* ignore */ } + }, [wordWrap]); + // Stash the latest callback in a ref so the unmount-cleanup effect can be // truly unmount-scoped without re-running every time a parent passes a fresh // function identity. @@ -164,8 +175,9 @@ export function FileViewer({ fontSize: 13, padding: { top: 8 }, scrollBeyondLastLine: false, + wordWrap: wordWrap ? ('on' as const) : ('off' as const), }), - [readOnly], + [readOnly, wordWrap], ); useEffect(() => { @@ -350,6 +362,17 @@ export function FileViewer({
{filename}
+ {readOnly && ( Read-only diff --git a/frontend/src/components/fleet/FleetEmptyState.tsx b/frontend/src/components/fleet/FleetEmptyState.tsx index 78b8be02..19cdfa2b 100644 --- a/frontend/src/components/fleet/FleetEmptyState.tsx +++ b/frontend/src/components/fleet/FleetEmptyState.tsx @@ -8,15 +8,16 @@ interface FleetTabHeadingProps { } /** - * Standardized Fleet tab header: italic-serif title and muted subtitle on the + * Standardized Fleet tab header: heading-styled title and muted subtitle on the * left, an optional primary action on the right. Rendered in both empty and - * populated states so the tab chrome stays consistent. + * populated states so the tab chrome stays consistent. The title follows the + * theme heading style (signature italic or calm) via .font-heading. */ export function FleetTabHeading({ title, subtitle, action }: FleetTabHeadingProps) { return (
-

{title}

+

{title}

{subtitle}

{action} @@ -44,15 +45,15 @@ interface FleetEmptyCardProps { } /** - * Minimal empty-state card: centered icon, italic headline, muted one-line - * description, optional CTA. + * Minimal empty-state card: centered icon, heading-styled headline, muted + * one-line description, optional CTA. */ export function FleetEmptyCard({ icon: Icon, title, description, action }: FleetEmptyCardProps) { return (
-

{title}

+

{title}

{description}

{action} diff --git a/frontend/src/components/settings/AppearanceSection.tsx b/frontend/src/components/settings/AppearanceSection.tsx index fdbf3b45..d74d738b 100644 --- a/frontend/src/components/settings/AppearanceSection.tsx +++ b/frontend/src/components/settings/AppearanceSection.tsx @@ -140,9 +140,9 @@ export function AppearanceSection() { const [topNavAlign, setTopNavAlign] = useTopNavAlign(); const { theme, accent, borderBoost, glow, contrast, uiFont, monoFont, typeScale, - headingStyle, chartStyle, reducedEffects, readability, + headingStyle, chartStyle, reducedEffects, reducedMotion, readability, setTheme, setAccent, setBorderBoost, setGlow, setContrast, setUiFont, setMonoFont, setTypeScale, - setVisualStyle, setHeadingStyle, setChartStyle, setReducedEffects, setReadability, + setVisualStyle, setHeadingStyle, setChartStyle, setReducedEffects, setReducedMotion, setReadability, } = useTheme(); const accentLabel = ACCENTS.find((a) => a.id === accent)?.label ?? 'Cyan'; // Readability is a sticky master: it forces the calm resolution at apply time @@ -252,6 +252,17 @@ export function AppearanceSection() { /> + + + + onSectionChange(item.id)} aria-current={isActive ? 'page' : undefined} className={cn( - 'relative flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors', + 'relative flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors', isActive - ? 'text-stat-value' - : 'text-stat-subtitle hover:bg-accent/40 hover:text-stat-value', + ? 'text-brand bg-gradient-to-r from-brand/[0.12] to-transparent' + : 'text-foreground/80 hover:bg-muted/40', )} > {isActive && (