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 <html>; 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).
This commit is contained in:
Anso
2026-06-28 06:18:02 -04:00
committed by GitHub
parent 083442d5ea
commit b5810a9b55
27 changed files with 489 additions and 119 deletions
+28 -9
View File
@@ -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 (
<MotionConfig reducedMotion={reducedMotion ? 'always' : 'user'}>
{children}
</MotionConfig>
);
}
function AppContent() {
const { appStatus, isAuthenticated, needsSetup, completeSetup } = useAuth();
@@ -33,15 +50,17 @@ function AppContent() {
}
return (
<NodeProvider>
<LicenseProvider>
<EditorLayout />
{/* 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. */}
<DeployFeedbackPortal />
</LicenseProvider>
</NodeProvider>
<MotionProvider>
<NodeProvider>
<LicenseProvider>
<EditorLayout />
{/* 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. */}
<DeployFeedbackPortal />
</LicenseProvider>
</NodeProvider>
</MotionProvider>
);
}
+3
View File
@@ -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}
/>
</div>
@@ -426,55 +426,53 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks
{createMode === 'git' && (
<div role="tabpanel" id={panelId('git')} aria-labelledby={tabId('git')}>
<ScrollArea block className="max-h-[60vh]">
<ModalBody>
<div className="space-y-2">
<Label htmlFor="create-git-stack-name">Stack Name</Label>
<Input
id="create-git-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => setNewStackName(e.target.value)}
disabled={creatingFromGit}
/>
</div>
<GitSourceFields
variant="create"
<ModalBody>
<div className="space-y-2">
<Label htmlFor="create-git-stack-name">Stack Name</Label>
<Input
id="create-git-stack-name"
placeholder="Stack name (e.g., myapp)"
value={newStackName}
onChange={(e) => 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}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="create-git-deploy-now"
checked={gitDeployNow}
onCheckedChange={(c) => setGitDeployNow(c === true)}
disabled={creatingFromGit}
/>
<Label htmlFor="create-git-deploy-now" className="text-xs cursor-pointer">
Deploy after create
</Label>
</div>
</ModalBody>
</ScrollArea>
<GitSourceFields
variant="create"
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}
/>
<div className="flex items-center gap-2">
<Checkbox
id="create-git-deploy-now"
checked={gitDeployNow}
onCheckedChange={(c) => setGitDeployNow(c === true)}
disabled={creatingFromGit}
/>
<Label htmlFor="create-git-deploy-now" className="text-xs cursor-pointer">
Deploy after create
</Label>
</div>
</ModalBody>
<ModalFooter
hint="HTTPS REPOS ONLY"
secondary={
@@ -1,4 +1,4 @@
import { Suspense, useRef, useEffect } from 'react';
import { Suspense, useRef, useEffect, useState } from 'react';
import { Editor } from '@/lib/monacoLoader';
import {
Save,
@@ -8,6 +8,8 @@ import {
ChevronDown,
GitBranch,
FolderOpen,
Maximize2,
Minimize2,
} from 'lucide-react';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader } from '../ui/card';
@@ -314,6 +316,14 @@ export function EditorView(props: EditorViewProps) {
}
}, [activeTab, canRead, setActiveTab]);
// Fullscreen the file browser + editor by collapsing the left column. Only
// meaningful on the files tab; reset when leaving it or closing the editor so
// it can never strand the compose/env panels in a single-column layout.
const [filesFullscreen, setFilesFullscreen] = useState(false);
useEffect(() => {
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 (
<ErrorBoundary>
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2 min-h-[600px] h-[calc(100vh-160px)] max-h-[1040px]">
{/* Left column: identity + health strip + logs, stacked */}
<div className={`grid gap-6 ${filesFullscreen ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'} min-h-[600px] h-[calc(100vh-160px)] max-h-[1040px]`}>
{/* Left column: identity + health strip + logs, stacked. Hidden in
files fullscreen so the editor card fills the width. */}
{!filesFullscreen && (
<div className="flex flex-col gap-6 min-h-0">
{/* Command Center Card (identity + health strip) */}
<Card className="rounded-xl border-muted bg-card shrink-0">
@@ -396,6 +408,7 @@ export function EditorView(props: EditorViewProps) {
{/* Logs Section (fills remaining left-column height) */}
<StackLogsSection stackName={stackName} logsMode={logsMode} setLogsMode={setLogsMode} />
</div>
)}
{/* Right column: anatomy panel by default, Monaco editor when editing */}
{editingCompose ? (
@@ -415,7 +428,7 @@ export function EditorView(props: EditorViewProps) {
<TabsHighlightItem value="files">
<TabsTrigger value="files">
<FolderOpen className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
Files &amp; Volumes
Files
</TabsTrigger>
</TabsHighlightItem>
)}
@@ -485,6 +498,20 @@ export function EditorView(props: EditorViewProps) {
)}
</>
)}
{activeTab === 'files' && (
<Button
size="sm"
variant="ghost"
className="rounded-lg h-8 w-8 p-0"
onClick={() => setFilesFullscreen((v) => !v)}
aria-label={filesFullscreen ? 'Exit full screen' : 'Full screen'}
title={filesFullscreen ? 'Exit full screen' : 'Full screen'}
>
{filesFullscreen
? <Minimize2 className="w-4 h-4" strokeWidth={1.5} />
: <Maximize2 className="w-4 h-4" strokeWidth={1.5} />}
</Button>
)}
<Button
size="sm"
variant="ghost"
@@ -13,7 +13,7 @@ import HomeDashboard from '../HomeDashboard';
import type { NotificationItem } from '../dashboard/types';
import type { ScheduleTaskPrefill } from '../ScheduledOperationsView';
import type { ActiveView } from './hooks/useViewNavigationState';
import type { SecurityTab } from '@/lib/events';
import type { SecurityTab, FleetTab } from '@/lib/events';
// Paid-tier views are loaded on demand. Their internal PaidGate /
// CapabilityGate wrappers render
@@ -89,6 +89,8 @@ export interface ViewRouterProps {
onSecurityTabChange: (tab: SecurityTab) => void;
fleetUpdatesIntent?: { tab: 'nodes' | 'changelog' } | null;
onFleetUpdatesIntentConsumed?: () => void;
fleetTab?: FleetTab | null;
onFleetTabConsumed?: () => void;
// Render slot for the inline editor view. Kept as a callback so the
// (large) editor JSX is only allocated when activeView === 'editor',
// not on every parent render that lands on a different view.
@@ -116,6 +118,8 @@ export function ViewRouter({
onSecurityTabChange,
fleetUpdatesIntent,
onFleetUpdatesIntentConsumed,
fleetTab,
onFleetTabConsumed,
renderEditor,
}: ViewRouterProps): ReactNode {
const { can } = useAuth();
@@ -183,6 +187,8 @@ export function ViewRouter({
onNavigateToNode={onFleetNavigateToNode}
fleetUpdatesIntent={fleetUpdatesIntent}
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
fleetTab={fleetTab}
onFleetTabConsumed={onFleetTabConsumed}
/>
</LazyView>
</CapabilityGate>
@@ -155,6 +155,17 @@ describe('useViewNavigationState', () => {
expect(result.current.filterNodeId).toBe(5);
});
it('SENCHO_NAVIGATE_EVENT to fleet sets fleetTab then activeView (deep-link, no race)', () => {
const { result } = renderHook(() => useViewNavigationState());
act(() => {
window.dispatchEvent(
new CustomEvent(SENCHO_NAVIGATE_EVENT, { detail: { view: 'fleet', fleetTab: 'snapshots' } }),
);
});
expect(result.current.activeView).toBe('fleet');
expect(result.current.fleetTab).toBe('snapshots');
});
it('SENCHO_NAVIGATE_EVENT with no nodeId sets filterNodeId to null', () => {
const { result } = renderHook(() => useViewNavigationState());
act(() => {
@@ -9,7 +9,7 @@ import { useLicense } from '@/context/LicenseContext';
import { useNodes } from '@/context/NodeContext';
import { SENCHO_NAVIGATE_EVENT } from '@/components/NodeManager';
import type { SenchoNavigateDetail } from '@/components/NodeManager';
import type { SecurityTab } from '@/lib/events';
import type { SecurityTab, FleetTab } from '@/lib/events';
import type { SectionId } from '@/components/settings/types';
import type { ScheduleTaskPrefill } from '@/components/ScheduledOperationsView';
@@ -61,6 +61,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
const [activeView, setActiveView] = useState<ActiveView>('dashboard');
const [settingsSection, setSettingsSection] = useState<SectionId>('appearance');
const [securityTab, setSecurityTab] = useState<SecurityTab>('overview');
const [fleetTab, setFleetTab] = useState<FleetTab | null>(null);
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
const [schedulePrefill, setSchedulePrefill] = useState<ScheduleTaskPrefill | null>(null);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
@@ -96,6 +97,14 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
setFilterNodeId(detail.nodeId ?? null);
return;
}
if (detail.view === 'fleet') {
// Set the target sub-tab before switching so the controlled FleetView
// lands on it (e.g. Snapshots from the stack storage warning).
if (detail.fleetTab) setFleetTab(detail.fleetTab);
setActiveView('fleet');
setFilterNodeId(detail.nodeId ?? null);
return;
}
setActiveView(detail.view as ActiveView);
setFilterNodeId(detail.nodeId ?? null);
};
@@ -146,6 +155,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions)
activeView, setActiveView,
settingsSection, setSettingsSection,
securityTab, setSecurityTab,
fleetTab, setFleetTab,
filterNodeId, setFilterNodeId,
schedulePrefill, setSchedulePrefill,
mobileNavOpen, setMobileNavOpen,
+19 -17
View File
@@ -651,16 +651,18 @@ export default function FleetSnapshots() {
{/* Preserved dossier notes (read-only) */}
{dossier && <DossierBlock dossier={dossier} />}
{/* Restore button (admin only) */}
{/* Restore (admin only), right-aligned to match the file action row */}
{isAdmin && (
<RestoreButton
nodeId={node.nodeId}
nodeName={node.nodeName}
stackName={stack.stackName}
hasDossier={!!dossier}
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
onRestore={handleRestore}
/>
<div className="flex justify-end px-3">
<RestoreButton
nodeId={node.nodeId}
nodeName={node.nodeName}
stackName={stack.stackName}
hasDossier={!!dossier}
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
onRestore={handleRestore}
/>
</div>
)}
</div>
)}
@@ -919,16 +921,16 @@ function RestoreButton({ nodeId, nodeName, stackName, hasDossier, restoring, onR
return (
<>
<Button
variant="outline"
variant="ghost"
size="sm"
className="h-7 px-2.5 text-xs gap-1.5 ml-3 mt-1"
className="h-6 px-2 text-xs"
disabled={restoring}
onClick={() => setOpen(true)}
>
{restoring ? (
<Loader2 className="w-3 h-3 animate-spin" />
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<RotateCcw className="w-3 h-3" strokeWidth={1.5} />
<RotateCcw className="w-3 h-3 mr-1" strokeWidth={1.5} />
)}
Restore
</Button>
@@ -1020,16 +1022,16 @@ function RestoreAllButton({ restoring, hasDocumentation, onRestoreAll }: {
return (
<>
<Button
variant="outline"
variant="ghost"
size="sm"
className="h-8 gap-1.5 shrink-0"
className="h-6 px-2 text-xs shrink-0"
disabled={restoring}
onClick={() => setOpen(true)}
>
{restoring ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" strokeWidth={1.5} />
<RotateCcw className="w-3 h-3 mr-1" strokeWidth={1.5} />
)}
Restore all
</Button>
+25 -8
View File
@@ -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<FleetTab>('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}
/>
<Tabs defaultValue="overview">
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap">
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as FleetTab)}>
<div className="flex items-center justify-between gap-3 mb-4 flex-wrap rounded-lg border border-card-border bg-card/40 px-2.5 py-1.5">
<TabsList className="max-md:w-full max-md:overflow-x-auto max-md:[scrollbar-width:none]">
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="overview">
@@ -146,16 +161,17 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
)}
</TabsHighlight>
</TabsList>
<div className="flex items-center gap-2 max-md:w-full max-md:flex-wrap">
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
size="sm"
onClick={() => overview.fetchOverview(true)}
disabled={refreshing}
className="gap-2"
className="h-9 w-9 p-0"
title="Refresh"
aria-label="Refresh"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
{isAdmin && (
<Button
@@ -163,10 +179,11 @@ export function FleetView({ onNavigateToNode, fleetUpdatesIntent, onFleetUpdates
size="sm"
onClick={() => { void exportDossier(); }}
disabled={exporting}
className="gap-2"
className="h-9 w-9 p-0"
title="Export Dossier"
aria-label="Export Dossier"
>
<FileDown className={`w-4 h-4 ${exporting ? 'animate-pulse' : ''}`} />
{exporting ? 'Exporting…' : 'Export Dossier'}
</Button>
)}
</div>
@@ -259,19 +259,25 @@ export function OverviewToolbar({
<Button
variant="outline"
size="sm"
className="gap-2 shrink-0 h-9"
className="h-9 w-9 p-0 shrink-0"
onClick={onCheckUpdates}
disabled={checkingUpdates}
title="Check for updates"
aria-label="Check for updates"
>
<RefreshCcwDot className={`w-4 h-4 ${checkingUpdates ? 'animate-spin' : ''}`} />
Check Updates
</Button>
)}
{onAddNode && (
<Button size="sm" className="gap-1.5 shrink-0 h-9" onClick={onAddNode}>
<Button
size="sm"
className="h-9 w-9 p-0 shrink-0"
onClick={onAddNode}
title="Add node"
aria-label="Add node"
>
<Plus className="w-4 h-4" strokeWidth={1.5} />
Add node
</Button>
)}
</div>
@@ -97,17 +97,17 @@ describe('OverviewToolbar', () => {
it('renders the Check Updates button and fires onCheckUpdates when provided', () => {
const onCheckUpdates = vi.fn();
render(<OverviewToolbar {...props({ onCheckUpdates })} />);
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(<OverviewToolbar {...props()} />);
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(<OverviewToolbar {...props({ onCheckUpdates: vi.fn(), checkingUpdates: true })} />);
expect(screen.getByRole('button', { name: /Check Updates/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Check for updates/ })).toBeDisabled();
});
});
+4 -2
View File
@@ -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() {
+13 -3
View File
@@ -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<string | null>(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<Array<{ ruleId: string; severity: string; service?: string }> | 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({
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">
<span className="inline-flex items-center gap-1">
Doctor
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && (
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && !doctorDismissed && (
<span
data-testid="doctor-tab-dot"
className={cn('h-1.5 w-1.5 rounded-full', preflightSeverity === 'blocker' ? 'bg-destructive' : 'bg-warning')}
+26 -3
View File
@@ -1,12 +1,14 @@
import { useState, useEffect, useMemo, useRef, Suspense } from 'react';
import { Editor } from '@/lib/monacoLoader';
import { AlertCircle, FileIcon, Download, Loader2, Save } from 'lucide-react';
import { AlertCircle, FileIcon, Download, Loader2, Save, WrapText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/toast-store';
import { readStackFile, writeStackFile, downloadStackFile, FileConflictError } from '@/lib/stackFilesApi';
import { extensionToLanguage } from '@/lib/monacoLanguages';
import { formatBytes } from '@/lib/utils';
import { cn, formatBytes } from '@/lib/utils';
const WORD_WRAP_KEY = 'sencho.fileViewer.wordWrap';
interface FileViewerProps {
stackName: string;
@@ -131,6 +133,15 @@ export function FileViewer({
const readOnly = !canEdit;
const hasChanges = content !== originalContent;
// Word wrap, persisted across files and sessions. Defaults on so wide files
// do not require horizontal scrolling; only an explicit 'false' disables it.
const [wordWrap, setWordWrap] = useState(() => {
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({
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-glass-border shrink-0">
<span className="font-mono text-xs text-stat-subtitle truncate">{filename}</span>
<div className="flex items-center gap-2 shrink-0">
<Button
size="sm"
variant="ghost"
className={cn('h-7 w-7 p-0', wordWrap ? 'text-brand' : 'text-stat-subtitle')}
onClick={() => setWordWrap((v) => !v)}
aria-pressed={wordWrap}
title={wordWrap ? 'Word wrap on' : 'Word wrap off'}
aria-label={wordWrap ? 'Disable word wrap' : 'Enable word wrap'}
>
<WrapText className="w-3.5 h-3.5" strokeWidth={1.5} />
</Button>
{readOnly && (
<span className="text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle border border-border rounded px-1.5 py-0.5">
Read-only
@@ -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 (
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="font-display italic text-[1.5rem] leading-tight text-stat-value">{title}</h2>
<h2 className="font-heading text-[1.5rem] leading-tight text-stat-value">{title}</h2>
<p className="text-sm text-stat-subtitle">{subtitle}</p>
</div>
{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 (
<div className="mx-auto max-w-xl rounded-xl border border-card-border/60 bg-popover/30 p-8 text-center space-y-4">
<Icon className="mx-auto w-8 h-8 text-stat-subtitle" />
<div>
<h3 className="font-display italic text-[1.25rem] text-stat-value">{title}</h3>
<h3 className="font-heading text-[1.25rem] text-stat-value">{title}</h3>
<p className="text-sm text-stat-subtitle leading-relaxed mt-1">{description}</p>
</div>
{action}
@@ -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() {
/>
</SettingsField>
<SettingsField
label="Reduced motion"
helper="Minimizes interface animations and transitions (dialogs, menus, expand and collapse). Toasts are unaffected."
>
<TogglePill
checked={reducedMotion}
onChange={setReducedMotion}
aria-label="Reduced motion"
/>
</SettingsField>
<SettingsField
label="Ambient glow"
helper="Intensity of the accent-tinted glow behind the page."
@@ -81,16 +81,16 @@ export function SettingsSidebar({ currentSection, onSectionChange, dirtyFlags, o
onClick={() => 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 && (
<span
aria-hidden="true"
className="absolute inset-y-1 left-0 w-[3px] rounded-full bg-brand shadow-[0_0_8px_color-mix(in_oklch,var(--brand)_30%,transparent)]"
className="absolute inset-y-0 left-0 w-[2px] rounded-sm bg-brand"
/>
)}
<span
@@ -13,6 +13,7 @@ function resetTheme() {
result.current.setVisualStyle('signature');
result.current.setContrast(0);
result.current.setGlow(0.16);
result.current.setReducedMotion(false);
});
}
@@ -50,6 +51,17 @@ describe('AppearanceSection', () => {
expect(container.querySelectorAll('[data-disabled]').length).toBeGreaterThan(0);
});
it('reduced motion is independent of readability and toggles data-motion on <html>', () => {
render(<AppearanceSection />);
const motion = () => screen.getByRole('switch', { name: 'Reduced motion' }) as HTMLButtonElement;
expect(document.documentElement.dataset.motion).toBeUndefined();
// Readability flattens effects but must not disable the motion toggle.
fireEvent.click(screen.getByRole('switch', { name: 'Readability mode' }));
expect(motion().disabled).toBe(false);
fireEvent.click(motion());
expect(document.documentElement.dataset.motion).toBe('reduced');
});
it('readability also locks the Visual style cards and the Border brightness slider', () => {
const { container } = render(<AppearanceSection />);
const calmCard = () => screen.getByRole('button', { name: /readable default/i }) as HTMLButtonElement;
@@ -42,7 +42,7 @@ function jsonRes(body: unknown, ok = true) {
return { ok, status: ok ? 200 : 500, json: async () => body, text: async () => '' } as unknown as Response;
}
beforeEach(() => { vi.clearAllMocks(); });
beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); });
describe('PreflightPanel', () => {
it('shows the never-run empty state', async () => {
@@ -75,6 +75,19 @@ describe('PreflightPanel', () => {
expect(screen.getByText('Image uses a moving tag')).toBeInTheDocument();
});
it('dismisses only the result banner, keeping the finding rows', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
status: 'high', highestSeverity: 'high',
findings: [{ ruleId: 'privileged', severity: 'high', title: 'Privileged container', message: 'runs privileged', service: 'web' }],
})));
render(<PreflightPanel stackName="web" />);
expect(await screen.findByTestId('preflight-status')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('preflight-dismiss-btn'));
expect(screen.queryByTestId('preflight-status')).not.toBeInTheDocument();
// Only the summary banner is dismissed; the finding row remains.
expect(screen.getByText('Privileged container')).toBeInTheDocument();
});
it('surfaces the unrenderable state with the render error', async () => {
vi.mocked(apiFetch).mockResolvedValue(jsonRes(report({
renderable: false, status: 'unrenderable', highestSeverity: 'blocker',
@@ -1,12 +1,13 @@
import { useEffect, useState } from 'react';
import {
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, type LucideIcon,
Check, TriangleAlert, ShieldAlert, Info, RefreshCw, Stethoscope, X, type LucideIcon,
} from 'lucide-react';
import { apiFetch } from '@/lib/api';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui/toast-store';
import { formatTimeAgo } from '@/lib/relativeTime';
import { useNodes } from '@/context/NodeContext';
import { usePreflightDismiss } from '@/hooks/usePreflightDismiss';
// Mirrors the backend payload shape (the frontend never imports backend).
type PreflightSeverity = 'blocker' | 'high' | 'warning' | 'info';
@@ -152,6 +153,10 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
const SummaryIcon = summary?.icon;
const busy = loading || running;
// Dismiss the result banner (and the Doctor tab dot) until the findings change.
const { dismissed, dismiss } = usePreflightDismiss(stackName, nodeId, report?.findings);
const hasFindings = (report?.findings.length ?? 0) > 0;
return (
<div data-testid="preflight-panel" className="flex-1 min-h-0 overflow-y-auto px-3 py-3 flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
@@ -192,9 +197,21 @@ export default function PreflightPanel({ stackName }: { stackName: string }) {
</div>
) : (
<>
{summary && SummaryIcon && (
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone)}>
<div className="flex items-center gap-2">
{summary && SummaryIcon && !dismissed && (
<div data-testid="preflight-status" data-status={report.status} className={cn(CARD_CLASS, summary.tone, 'relative')}>
{hasFindings && (
<button
type="button"
onClick={dismiss}
data-testid="preflight-dismiss-btn"
aria-label="Dismiss until findings change"
title="Dismiss until findings change"
className="absolute right-2 top-2 inline-flex h-5 w-5 items-center justify-center rounded text-current/70 hover:bg-current/10 hover:text-current"
>
<X className="h-3.5 w-3.5" strokeWidth={1.5} />
</button>
)}
<div className="flex items-center gap-2 pr-6">
<SummaryIcon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
<span className="font-mono text-[11px] uppercase tracking-wide">{summary.label}</span>
{report.ranAt && (
@@ -8,6 +8,7 @@ import { toast } from '@/components/ui/toast-store';
import { formatTimeAgo } from '@/lib/relativeTime';
import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from '@/components/NodeManager';
// Mirrors the backend /storage payload (the frontend never imports backend).
type PortabilityStatus = 'portable' | 'partially-portable' | 'node-bound' | 'unknown';
@@ -221,6 +222,17 @@ export default function StoragePanel({ stackName }: { stackName: string }) {
This stack has persistent storage but no fleet snapshot in the last 7 days.
</span>
</div>
{activeNode?.type !== 'remote' && (
<button
type="button"
onClick={() => window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
detail: { view: 'fleet', fleetTab: 'snapshots' },
}))}
className="mt-1.5 text-[12px] font-medium text-brand hover:underline"
>
Take a fleet snapshot
</button>
)}
</div>
)}
{isAdmin && inventory.stateful && snapshot.recent && snapshot.at && (
@@ -174,7 +174,9 @@ export function FleetActionCard(props: FleetActionCardProps) {
/>
</div>
<div className="px-6 flex-1">
{/* pt-4 so the first SheetSection (which zeroes its own top padding) clears
the action row's bottom border instead of overlapping it. */}
<div className="px-6 pt-4 flex-1">
{children}
</div>
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { usePreflightDismiss } from '../usePreflightDismiss';
const findings = (sev: string) => [
{ ruleId: 'DS001', severity: sev, service: 'web' },
{ ruleId: 'DS002', severity: 'warning' },
];
describe('usePreflightDismiss', () => {
beforeEach(() => localStorage.clear());
it('is not dismissed until dismiss() is called', () => {
const { result } = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
expect(result.current.dismissed).toBe(false);
act(() => result.current.dismiss());
expect(result.current.dismissed).toBe(true);
});
it('stays dismissed for an identical finding set (order-independent)', () => {
const first = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
act(() => first.result.current.dismiss());
// A fresh consumer with the same findings in a different order reads dismissed.
const reordered = [...findings('high')].reverse();
const second = renderHook(() => usePreflightDismiss('app', 1, reordered));
expect(second.result.current.dismissed).toBe(true);
});
it('re-surfaces when the findings change', () => {
const first = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
act(() => first.result.current.dismiss());
// Severity changed -> different fingerprint -> not dismissed.
const changed = renderHook(() => usePreflightDismiss('app', 1, findings('blocker')));
expect(changed.result.current.dismissed).toBe(false);
});
it('keys per stack and node', () => {
const a = renderHook(() => usePreflightDismiss('app', 1, findings('high')));
act(() => a.result.current.dismiss());
expect(renderHook(() => usePreflightDismiss('other', 1, findings('high'))).result.current.dismissed).toBe(false);
expect(renderHook(() => usePreflightDismiss('app', 2, findings('high'))).result.current.dismissed).toBe(false);
});
it('treats an empty finding set as never dismissed', () => {
const { result } = renderHook(() => usePreflightDismiss('app', 1, []));
expect(result.current.dismissed).toBe(false);
act(() => result.current.dismiss());
expect(result.current.dismissed).toBe(false);
});
});
+24
View File
@@ -44,6 +44,9 @@ export interface ThemeState {
headingStyle: HeadingStyle;
chartStyle: ChartStyle;
reducedEffects: boolean;
/** Independent of reducedEffects (surface flattening): minimizes UI motion
* (dialogs, menus, overlays, transitions). Not part of a visual-style preset. */
reducedMotion: boolean;
readability: boolean;
}
@@ -136,6 +139,9 @@ const DEFAULT_STATE: ThemeState = {
theme: 'dim', accent: 'cyan', borderBoost: 0, glow: 0.16, contrast: 0,
uiFont: 'Geist', monoFont: 'Geist Mono', typeScale: 1,
...CALM_PRESET,
// Independent of the visual-style presets; defaults off so the OS
// prefers-reduced-motion still governs via MotionConfig's 'user' mode.
reducedMotion: false,
};
const MODE_IDS = new Set<string>(THEME_MODES.map((m) => m.id));
@@ -200,6 +206,7 @@ function readStored(): ThemeState {
headingStyle: isHeadingStyle(p.headingStyle) ? p.headingStyle : SIGNATURE_PRESET.headingStyle,
chartStyle: isChartStyle(p.chartStyle) ? p.chartStyle : SIGNATURE_PRESET.chartStyle,
reducedEffects: isBool(p.reducedEffects) ? p.reducedEffects : SIGNATURE_PRESET.reducedEffects,
reducedMotion: isBool(p.reducedMotion) ? p.reducedMotion : false,
readability: isBool(p.readability) ? p.readability : SIGNATURE_PRESET.readability,
};
}
@@ -252,6 +259,9 @@ function applyToDom(s: ThemeState, systemDark: boolean) {
root.dataset.chartStyle = chart;
if (reduced) root.dataset.effects = 'reduced';
else delete root.dataset.effects;
// Motion is independent of effects/readability: only the explicit toggle.
if (s.reducedMotion) root.dataset.motion = 'reduced';
else delete root.dataset.motion;
root.style.setProperty('--border-boost', String(rd ? 0.03 : s.borderBoost));
root.style.setProperty('--glow', String(reduced ? s.glow * 0.4 : s.glow));
root.style.setProperty('--contrast', String(s.contrast + (rd ? 0.18 : 0)));
@@ -284,6 +294,7 @@ function sameState(a: ThemeState, b: ThemeState): boolean {
&& a.uiFont === b.uiFont && a.monoFont === b.monoFont && a.typeScale === b.typeScale
&& a.visualStyle === b.visualStyle && a.headingStyle === b.headingStyle
&& a.chartStyle === b.chartStyle && a.reducedEffects === b.reducedEffects
&& a.reducedMotion === b.reducedMotion
&& a.readability === b.readability;
}
@@ -312,6 +323,16 @@ function getSnapshot(): ThemeSnapshot {
return snapshot;
}
/** Lean selector for just the reduced-motion flag, so a wrapper like MotionConfig
* re-renders only when motion changes, not on every theme tweak. */
export function useReducedMotion(): boolean {
return useSyncExternalStore(
subscribe,
() => persisted.reducedMotion,
() => DEFAULT_STATE.reducedMotion,
);
}
if (typeof window !== 'undefined') {
// Cross-tab sync: another tab wrote a new look.
window.addEventListener('storage', (e) => {
@@ -366,6 +387,7 @@ export function useTheme() {
const setHeadingStyle = useCallback((headingStyle: HeadingStyle) => setState({ headingStyle }), []);
const setChartStyle = useCallback((chartStyle: ChartStyle) => setState({ chartStyle }), []);
const setReducedEffects = useCallback((reducedEffects: boolean) => setState({ reducedEffects }), []);
const setReducedMotion = useCallback((reducedMotion: boolean) => setState({ reducedMotion }), []);
const setReadability = useCallback((readability: boolean) => setState({ readability }), []);
const resolvedTheme = resolveWith(s.theme, s.systemDark);
return {
@@ -381,6 +403,7 @@ export function useTheme() {
headingStyle: s.headingStyle,
chartStyle: s.chartStyle,
reducedEffects: s.reducedEffects,
reducedMotion: s.reducedMotion,
readability: s.readability,
resolvedTheme,
isDarkMode: resolvedTheme !== 'light',
@@ -396,6 +419,7 @@ export function useTheme() {
setHeadingStyle,
setChartStyle,
setReducedEffects,
setReducedMotion,
setReadability,
} as const;
}
+69
View File
@@ -0,0 +1,69 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
/** Minimal shape of a preflight finding needed to fingerprint a result set. */
interface FindingLike {
ruleId: string;
severity: string;
service?: string;
}
// Bumped when a dismiss is written so sibling consumers (the Doctor tab dot and
// the banner) re-read localStorage and agree, without a full page reload.
const DISMISS_EVENT = 'sencho:preflight-dismiss-changed';
const keyFor = (stackName: string, nodeId: number | undefined) =>
`sencho.doctorDismissed.${stackName}.${nodeId ?? 'local'}`;
/** Stable, content-based fingerprint of the findings. Order-independent so a
* reordered-but-identical result still counts as dismissed; any added, removed,
* or re-severitied finding changes it, which re-surfaces the banner. */
function fingerprint(findings: FindingLike[] | undefined): string {
if (!findings || findings.length === 0) return '';
return findings
.map((f) => `${f.ruleId}:${f.severity}:${f.service ?? ''}`)
.sort()
.join('|');
}
/**
* Per-stack dismiss for the Compose Doctor high-risk banner, persisted in
* localStorage and keyed to a fingerprint of the findings: the dismissal sticks
* across reloads and re-runs that produce identical findings, and clears
* automatically once the findings change. Used by both the banner (to hide
* itself) and the Doctor tab dot (to clear), kept in sync via a window event.
*/
export function usePreflightDismiss(
stackName: string,
nodeId: number | undefined,
findings: FindingLike[] | undefined,
) {
const fp = useMemo(() => fingerprint(findings), [findings]);
const storageKey = keyFor(stackName, nodeId);
const read = useCallback(() => {
try { return localStorage.getItem(storageKey); } catch { return null; }
}, [storageKey]);
const [storedFp, setStoredFp] = useState<string | null>(() => read());
useEffect(() => {
setStoredFp(read());
const handler = () => setStoredFp(read());
window.addEventListener(DISMISS_EVENT, handler);
window.addEventListener('storage', handler);
return () => {
window.removeEventListener(DISMISS_EVENT, handler);
window.removeEventListener('storage', handler);
};
}, [read]);
const dismissed = fp !== '' && storedFp === fp;
const dismiss = useCallback(() => {
try { localStorage.setItem(storageKey, fp); } catch { /* ignore */ }
setStoredFp(fp);
window.dispatchEvent(new Event(DISMISS_EVENT));
}, [storageKey, fp]);
return { dismissed, dismiss };
}
+12
View File
@@ -759,6 +759,18 @@ body {
}
}
/* The "Reduced motion" appearance setting (data-motion="reduced" on <html>)
clamps CSS animations/transitions, mirroring the OS preference above. Sonner
toasts animate via inline transform/opacity (React state), not CSS, so they
are unaffected. framer-motion is handled separately by MotionConfig. */
[data-motion="reduced"] *,
[data-motion="reduced"] *::before,
[data-motion="reduced"] *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
/* ─────────────────────────────────────────────────────────────
XTERM.JS
───────────────────────────────────────────────────────────── */
+13
View File
@@ -34,3 +34,16 @@ export type SecurityTab =
| 'suppressions'
| 'history'
| 'scanner';
/** Fleet view sub-tabs, used for deep-link navigation (e.g. the stack storage
* warning linking to Snapshots). Mirrors the TabsTrigger values in FleetView. */
export type FleetTab =
| 'overview'
| 'snapshots'
| 'configuration'
| 'dependencies'
| 'deployments'
| 'routing'
| 'federation'
| 'actions'
| 'secrets';