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.
This commit is contained in:
Anso
2026-06-28 08:17:34 -04:00
committed by GitHub
parent 04e69021e0
commit d6ce60d280
17 changed files with 167 additions and 91 deletions
@@ -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 && (
<div className="flex flex-col gap-6 min-h-0">
{/* 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 && (
<Card className="rounded-xl border-muted bg-card shrink-0">
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-3">
@@ -404,9 +415,16 @@ export function EditorView(props: EditorViewProps) {
/>
</CardContent>
</Card>
)}
{/* Logs Section (fills remaining left-column height) */}
<StackLogsSection stackName={stackName} logsMode={logsMode} setLogsMode={setLogsMode} />
<StackLogsSection
stackName={stackName}
logsMode={logsMode}
setLogsMode={setLogsMode}
logsExpanded={logsExpanded}
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
/>
</div>
)}
@@ -185,6 +185,7 @@ export function ViewRouter({
<LazyView>
<FleetView
onNavigateToNode={onFleetNavigateToNode}
onOpenSettingsSection={onOpenSettingsSection}
fleetUpdatesIntent={fleetUpdatesIntent}
onFleetUpdatesIntentConsumed={onFleetUpdatesIntentConsumed}
fleetTab={fleetTab}
@@ -555,10 +555,14 @@ export interface StackLogsSectionProps {
stackName: string;
logsMode: 'structured' | 'raw';
setLogsMode: (mode: 'structured' | 'raw') => 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 (
<div className="flex-1 min-h-0 flex flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between">
@@ -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
</div>
{logsMode === 'structured' ? (
<ErrorBoundary>
<StructuredLogViewer stackName={stackName} />
<StructuredLogViewer stackName={stackName} expanded={logsExpanded} onToggleExpand={onToggleLogsExpand} />
</ErrorBoundary>
) : (
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
+28 -30
View File
@@ -590,22 +590,34 @@ export default function FleetSnapshots() {
.find(s => s.nodeId === node.nodeId && s.stackName === stack.stackName)?.dossier;
return (
<div key={stackKey}>
<button
onClick={() => toggleStack(stackKey)}
className="flex items-center gap-2 w-full px-3 py-2 text-left rounded-md hover:bg-muted/50 transition-colors"
>
{stackExpanded
? <ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
: <ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
}
<Layers className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-xs font-mono font-medium flex-1 truncate">
{stack.stackName}
</span>
<Badge variant="outline" className="text-[10px] font-mono tabular-nums px-1.5 py-0 h-4 shrink-0">
{stack.files.length} file{stack.files.length !== 1 ? 's' : ''}
</Badge>
</button>
<div className="flex items-center gap-2 pr-3 rounded-md hover:bg-muted/50 transition-colors">
<button
onClick={() => toggleStack(stackKey)}
className="flex items-center gap-2 flex-1 min-w-0 px-3 py-2 text-left"
>
{stackExpanded
? <ChevronDown className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
: <ChevronRight className="w-3.5 h-3.5 shrink-0 text-muted-foreground" />
}
<Layers className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-xs font-mono font-medium flex-1 truncate">
{stack.stackName}
</span>
<Badge variant="outline" className="text-[10px] font-mono tabular-nums px-1.5 py-0 h-4 shrink-0">
{stack.files.length} file{stack.files.length !== 1 ? 's' : ''}
</Badge>
</button>
{isAdmin && (
<RestoreButton
nodeId={node.nodeId}
nodeName={node.nodeName}
stackName={stack.stackName}
hasDossier={!!dossier}
restoring={restoringStack === `${node.nodeId}:${stack.stackName}`}
onRestore={handleRestore}
/>
)}
</div>
{/* Files */}
{stackExpanded && (
@@ -650,20 +662,6 @@ export default function FleetSnapshots() {
{/* Preserved dossier notes (read-only) */}
{dossier && <DossierBlock dossier={dossier} />}
{/* Restore (admin only), right-aligned to match the file action row */}
{isAdmin && (
<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>
)}
</div>
+9 -4
View File
@@ -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
<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]">
{/* Flatten the list's own pill band so the tabs sit directly in
the single full-width band, not a nested second band. */}
<TabsList className="border-transparent bg-transparent 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">
<TabsTrigger value="overview">Overview</TabsTrigger>
@@ -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}
@@ -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({
<Button
variant="outline"
size="sm"
className="h-9 w-9 p-0 shrink-0"
className="h-9 gap-2 shrink-0"
onClick={onCheckUpdates}
disabled={checkingUpdates}
title="Check for updates"
aria-label="Check for updates"
title="Check for node updates"
aria-label="Check for node updates"
>
<RefreshCcwDot className={`w-4 h-4 ${checkingUpdates ? 'animate-spin' : ''}`} />
Node Update
</Button>
)}
{onAddNode && (
<Button
variant="outline"
size="sm"
className="h-9 w-9 p-0 shrink-0"
onClick={onAddNode}
title="Add node"
aria-label="Add node"
title="Manage nodes"
aria-label="Manage nodes"
>
<Plus className="w-4 h-4" strokeWidth={1.5} />
<ChartNetwork className="w-4 h-4" strokeWidth={1.5} />
</Button>
)}
</div>
@@ -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(<OverviewToolbar {...props({ onAddNode })} />);
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(<OverviewToolbar {...props()} />);
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(<OverviewToolbar {...props({ onCheckUpdates })} />);
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(<OverviewToolbar {...props()} />);
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(<OverviewToolbar {...props({ onCheckUpdates: vi.fn(), checkingUpdates: true })} />);
expect(screen.getByRole('button', { name: /Check for updates/ })).toBeDisabled();
expect(screen.getByRole('button', { name: /Check for node updates/ })).toBeDisabled();
});
});
+7 -3
View File
@@ -396,8 +396,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
</PageMasthead>
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SecurityTab)}>
<TabsList className="mb-4">
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
{/* Standard full-width tab band (matches Fleet): the list's own pill band
is flattened so the tabs sit directly in this single band. */}
<div className="flex items-center gap-3 mb-4 flex-wrap rounded-lg border border-card-border bg-card/40 px-2.5 py-1.5">
<TabsList className="border-transparent bg-transparent 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">
<TabsTrigger value="overview"><LayoutDashboard className="w-4 h-4 mr-1.5" />Overview</TabsTrigger>
</TabsHighlightItem>
@@ -424,7 +427,8 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
<TabsTrigger value="scanner"><Wrench className="w-4 h-4 mr-1.5" />Scanner setup</TabsTrigger>
</TabsHighlightItem>
</TabsHighlight>
</TabsList>
</TabsList>
</div>
{tabPanels}
</Tabs>
+21 -21
View File
@@ -64,7 +64,7 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
return (
<div className="grid grid-cols-[72px_1fr] gap-3 border-t border-muted py-2 first:border-t-0">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle pt-0.5">{label}</span>
<div className="min-w-0 text-[12px] text-foreground/90">{children}</div>
<div className="min-w-0 text-xs text-foreground/90">{children}</div>
</div>
);
}
@@ -359,18 +359,18 @@ export default function StackAnatomyPanel({
<div className="flex items-center justify-between border-b border-muted px-3 py-1.5 gap-2">
<ScrollableTabRow surface="card" wrapperClassName="min-w-0 flex-1">
<TabsList className="h-7 w-max gap-0.5 bg-transparent border-none p-0">
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Activity</TabsTrigger>
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Dossier</TabsTrigger>
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Drift</TabsTrigger>
<TabsTrigger value="anatomy" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Anatomy</TabsTrigger>
<TabsTrigger value="activity" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Activity</TabsTrigger>
<TabsTrigger value="dossier" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Dossier</TabsTrigger>
<TabsTrigger value="drift" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Drift</TabsTrigger>
{envInventoryEnabled && (
<TabsTrigger value="environment" data-testid="environment-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Environment</TabsTrigger>
<TabsTrigger value="environment" data-testid="environment-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Environment</TabsTrigger>
)}
{networkingEnabled && (
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Networking</TabsTrigger>
<TabsTrigger value="networking" data-testid="networking-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Networking</TabsTrigger>
)}
{doctorEnabled && (
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">
<TabsTrigger value="doctor" data-testid="doctor-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">
<span className="inline-flex items-center gap-1">
Doctor
{(preflightSeverity === 'blocker' || preflightSeverity === 'high') && !doctorDismissed && (
@@ -383,7 +383,7 @@ export default function StackAnatomyPanel({
</TabsTrigger>
)}
{storageEnabled && (
<TabsTrigger value="storage" data-testid="storage-tab" className="h-6 px-2.5 font-mono text-[10px] uppercase tracking-[0.18em]">Storage</TabsTrigger>
<TabsTrigger value="storage" data-testid="storage-tab" className="h-6 px-2.5 font-mono text-xs uppercase tracking-[0.18em]">Storage</TabsTrigger>
)}
</TabsList>
</ScrollableTabRow>
@@ -417,7 +417,7 @@ export default function StackAnatomyPanel({
<TabsContent value="anatomy" className="flex flex-col flex-1 min-h-0 mt-0">
<div className="flex-1 min-h-0 overflow-y-auto px-3">
{!anatomy ? (
<div className="py-3 font-mono text-[11px] text-stat-subtitle">Unable to parse compose.yaml.</div>
<div className="py-3 font-mono text-xs text-stat-subtitle">Unable to parse compose.yaml.</div>
) : (
<>
<Row label="services">
@@ -426,7 +426,7 @@ export default function StackAnatomyPanel({
) : (
<div className="flex flex-wrap gap-1">
{anatomy.services.map(s => (
<span key={s} className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-[11px] text-brand">
<span key={s} className="rounded-md bg-brand/15 px-1.5 py-0.5 font-mono text-xs text-brand">
{s}
</span>
))}
@@ -437,7 +437,7 @@ export default function StackAnatomyPanel({
{Object.keys(anatomy.ports).length === 0 ? (
<span className="text-stat-subtitle">none</span>
) : (
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
<div className="flex flex-col gap-0.5 font-mono text-xs">
{Object.entries(anatomy.ports).flatMap(([svc, rows]) =>
rows.map((r, i) => (
<div key={`${svc}-${i}`} className="flex items-center gap-1.5">
@@ -457,7 +457,7 @@ export default function StackAnatomyPanel({
{Object.keys(anatomy.volumes).length === 0 ? (
<span className="text-stat-subtitle">none</span>
) : (
<div className="flex flex-col gap-0.5 font-mono text-[11px]">
<div className="flex flex-col gap-0.5 font-mono text-xs">
{Object.entries(anatomy.volumes).flatMap(([svc, rows]) =>
rows.map((r, i) => (
<div key={`${svc}-${i}`} className="flex items-center gap-1.5 min-w-0">
@@ -474,19 +474,19 @@ export default function StackAnatomyPanel({
)}
</Row>
<Row label="restart">
<span className="font-mono text-[11px]">{anatomy.restart ?? <span className="text-stat-subtitle">default</span>}</span>
<span className="font-mono text-xs">{anatomy.restart ?? <span className="text-stat-subtitle">default</span>}</span>
</Row>
<Row label="env_file">
{!firstEnvFile ? (
<span className="text-stat-subtitle">none</span>
) : (
<div className="flex flex-col gap-0.5">
<div className="font-mono text-[11px]">
<div className="font-mono text-xs">
<span className="text-foreground/90">{firstEnvFile}</span>
<span className="text-stat-subtitle"> · {envVarCount} var{envVarCount === 1 ? '' : 's'}</span>
</div>
{missingVars.length > 0 && (
<div className="flex flex-wrap gap-1 font-mono text-[11px] text-destructive">
<div className="flex flex-wrap gap-1 font-mono text-xs text-destructive">
{missingVars.map(v => (
<span key={v}>{'${'}{v}{'}'} missing</span>
))}
@@ -496,14 +496,14 @@ export default function StackAnatomyPanel({
)}
</Row>
<Row label="network">
<span className="font-mono text-[11px]">{networkName} <span className="text-stat-subtitle">· bridge</span></span>
<span className="font-mono text-xs">{networkName} <span className="text-stat-subtitle">· bridge</span></span>
</Row>
<Row label="source">
<button
type="button"
onClick={onOpenGitSource}
aria-label="Git Source"
className="inline-flex items-center gap-1.5 font-mono text-[11px] text-left hover:text-brand transition-colors"
className="inline-flex items-center gap-1.5 font-mono text-xs text-left hover:text-brand transition-colors"
>
<GitBranch className="h-3 w-3 shrink-0" strokeWidth={1.5} />
{activeGitSource ? (
@@ -522,7 +522,7 @@ export default function StackAnatomyPanel({
<div data-testid="update-available-banner" className={cn('mt-3 mb-3 rounded-lg border p-3', bannerTone)}>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="font-mono text-[11px] uppercase tracking-wide">
<div className="font-mono text-xs uppercase tracking-wide">
Update available
{updatePreview.summary.current_tag && updatePreview.summary.next_tag && (
<span className="text-foreground">
@@ -533,7 +533,7 @@ export default function StackAnatomyPanel({
</span>
)}
</div>
<div className="mt-1 font-mono text-[11px] text-foreground/80 leading-relaxed">
<div className="mt-1 font-mono text-xs text-foreground/80 leading-relaxed">
{[
bumpLabel,
bannerLeadIn,
@@ -562,7 +562,7 @@ export default function StackAnatomyPanel({
)}
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && (
<div
className="mx-3 my-2 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/[0.06] px-2 py-1.5 text-[11px] text-warning"
className="mx-3 my-2 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/[0.06] px-2 py-1.5 text-xs text-warning"
role="status"
title={scanStatus.errorMessage ?? undefined}
>
@@ -1,12 +1,15 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from './ui/button';
import { Download, RefreshCw } from 'lucide-react';
import { Download, RefreshCw, Maximize2, Minimize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useLogChipColorMode } from '@/hooks/use-log-chip-color-mode';
import { hashLabel } from '@/lib/label-colors';
interface StructuredLogViewerProps {
stackName: string;
/** When set, renders an expand/collapse control next to the download button. */
expanded?: boolean;
onToggleExpand?: () => void;
}
type LogLevel = 'info' | 'warn' | 'err';
@@ -65,7 +68,7 @@ function formatTs(iso: string | null): string {
return `${hh}:${mm}:${ss}`;
}
export default function StructuredLogViewer({ stackName }: StructuredLogViewerProps) {
export default function StructuredLogViewer({ stackName, expanded, onToggleExpand }: StructuredLogViewerProps) {
const [rows, setRows] = useState<LogRow[]>([]);
const [filter, setFilter] = useState<Filter>('all');
const [following, setFollowing] = useState(true);
@@ -281,6 +284,21 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
</button>
))}
<div className="mx-1 h-4 w-px bg-muted" />
{onToggleExpand && (
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onToggleExpand}
aria-label={expanded ? 'Collapse logs' : 'Expand logs'}
title={expanded ? 'Collapse logs' : 'Expand logs'}
>
{expanded
? <Minimize2 className="h-3.5 w-3.5" strokeWidth={1.5} />
: <Maximize2 className="h-3.5 w-3.5" strokeWidth={1.5} />}
</Button>
)}
<Button type="button" size="sm" variant="ghost" className="h-7 w-7 p-0" onClick={downloadLogs} aria-label="Download logs">
<Download className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
@@ -289,7 +307,7 @@ export default function StructuredLogViewer({ stackName }: StructuredLogViewerPr
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 min-h-0 overflow-y-auto font-mono text-[11px] leading-[1.5]"
className="flex-1 min-h-0 overflow-y-auto font-mono text-xs leading-[1.5]"
>
{filtered.length === 0 ? (
<div className="px-3 py-2 text-stat-subtitle">Waiting for log output</div>
@@ -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 (
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
<motion.div
@@ -80,7 +82,7 @@ function DialogOverlay({
initial={{ opacity: 0, filter: 'blur(4px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, filter: 'blur(4px)' }}
transition={transition}
transition={resolvedTransition}
{...props}
/>
</DialogPrimitive.Overlay>
@@ -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 (
<DialogPrimitive.Content
@@ -140,7 +143,7 @@ function DialogContent({
filter: 'blur(4px)',
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
transition={transition}
transition={resolvedTransition}
{...props}
/>
</DialogPrimitive.Content>
@@ -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 (
<AnimatePresence>
@@ -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 (
<AnimatePresence>
@@ -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}
/>
@@ -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 (
<PopoverPrimitive.Content
asChild
@@ -118,7 +120,7 @@ function PopoverContent({
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
transition={transition}
transition={resolvedTransition}
{...props}
/>
</PopoverPrimitive.Content>
@@ -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 (
<SheetPrimitive.Overlay asChild forceMount>
<motion.div
@@ -80,7 +82,7 @@ function SheetOverlay({
initial={{ opacity: 0, filter: 'blur(4px)' }}
animate={{ opacity: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, filter: 'blur(4px)' }}
transition={transition}
transition={resolvedTransition}
{...props}
/>
</SheetPrimitive.Overlay>
@@ -102,6 +104,7 @@ function SheetContent({
...props
}: SheetContentProps) {
const axis = side === 'left' || side === 'right' ? 'x' : 'y';
const resolvedTransition = useReducedTransition(transition);
const offscreen: Record<Side, { x?: string; y?: string; opacity: number }> = {
right: { x: '100%', opacity: 0 },
@@ -131,7 +134,7 @@ function SheetContent({
...positionStyle[side],
...style,
}}
transition={transition}
transition={resolvedTransition}
>
{children}
</motion.div>
@@ -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<T>(transition: T): T | typeof INSTANT {
return useReducedMotion() ? INSTANT : transition;
}
@@ -16,7 +16,7 @@ export function BlueprintEmptyState({ onCreate, canCreate }: BlueprintEmptyState
<span className="font-mono text-[10px] uppercase tracking-[0.2em]">Deployments · Blueprints</span>
</div>
</div>
<h3 className="font-serif text-2xl italic leading-tight tracking-[-0.01em] text-stat-value">
<h3 className="font-serif text-2xl [font-style:var(--heading-style)] leading-tight tracking-[-0.01em] text-stat-value">
Declare once. Distribute everywhere.
</h3>
<p className="font-sans text-sm leading-relaxed text-stat-subtitle">
@@ -47,7 +47,7 @@ function Step({ kicker, title, copy }: { kicker: string; title: string; copy: st
return (
<div className="flex flex-col gap-1.5">
<span className="font-mono text-[10px] uppercase tracking-[0.18em] text-brand">{kicker}</span>
<span className="font-serif italic text-base text-stat-value">{title}</span>
<span className="font-serif [font-style:var(--heading-style)] text-base text-stat-value">{title}</span>
<span className="text-xs text-stat-subtitle leading-relaxed">{copy}</span>
</div>
);
+1 -1
View File
@@ -32,7 +32,7 @@ const TabsTrigger = React.forwardRef<
<AnimateTabsTrigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 hover:bg-muted/50 hover:text-foreground data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow data-[state=active]:hover:bg-background',
className
)}
{...props}