From 12a796bcbb3416a580a577c5b09e91dd2ffe97a6 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 22 Dec 2025 18:33:49 -0500 Subject: [PATCH] branch filtering --- apps/ui/src/components/views/board-view.tsx | 2 + .../graph-view/components/dependency-edge.tsx | 32 ++- .../components/graph-filter-controls.tsx | 198 ++++++++++++++++++ .../views/graph-view/components/index.ts | 1 + .../views/graph-view/components/task-node.tsx | 58 ++--- .../views/graph-view/graph-canvas.tsx | 54 ++++- .../views/graph-view/graph-view.tsx | 6 + .../views/graph-view/hooks/index.ts | 8 +- .../graph-view/hooks/use-graph-filter.ts | 165 +++++++++++++++ .../views/graph-view/hooks/use-graph-nodes.ts | 42 +++- apps/ui/src/styles/global.css | 69 +++++- 11 files changed, 591 insertions(+), 44 deletions(-) create mode 100644 apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx create mode 100644 apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 0038b6d3..700595cd 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -1036,6 +1036,8 @@ export function BoardView() { currentWorktreePath={currentWorktreePath} currentWorktreeBranch={currentWorktreeBranch} projectPath={currentProject?.path || null} + searchQuery={searchQuery} + onSearchQueryChange={setSearchQuery} onEditFeature={(feature) => setEditingFeature(feature)} onViewOutput={handleViewOutput} /> diff --git a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx index ad0ba3bd..cbf3cdb9 100644 --- a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx +++ b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx @@ -7,6 +7,8 @@ import { Feature } from '@/store/app-store'; export interface DependencyEdgeData { sourceStatus: Feature['status']; targetStatus: Feature['status']; + isHighlighted?: boolean; + isDimmed?: boolean; } const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => { @@ -52,11 +54,17 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { curvature: 0.25, }); - const edgeColor = edgeData - ? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus) - : 'var(--border)'; + const isHighlighted = edgeData?.isHighlighted ?? false; + const isDimmed = edgeData?.isDimmed ?? false; - const isCompleted = edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified'; + const edgeColor = isHighlighted + ? 'var(--brand-500)' + : edgeData + ? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus) + : 'var(--border)'; + + const isCompleted = + edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified'; const isInProgress = edgeData?.targetStatus === 'in_progress'; return ( @@ -66,8 +74,9 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { id={`${id}-bg`} path={edgePath} style={{ - strokeWidth: 4, + strokeWidth: isHighlighted ? 6 : 4, stroke: 'var(--background)', + opacity: isDimmed ? 0.3 : 1, }} /> @@ -78,13 +87,20 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { className={cn( 'transition-all duration-300', animated && 'animated-edge', - isInProgress && 'edge-flowing' + isInProgress && 'edge-flowing', + isHighlighted && 'graph-edge-highlighted', + isDimmed && 'graph-edge-dimmed' )} style={{ - strokeWidth: selected ? 3 : 2, + strokeWidth: isHighlighted ? 4 : selected ? 3 : isDimmed ? 1 : 2, stroke: edgeColor, strokeDasharray: isCompleted ? 'none' : '5 5', - filter: selected ? 'drop-shadow(0 0 3px var(--brand-500))' : 'none', + filter: isHighlighted + ? 'drop-shadow(0 0 6px var(--brand-500))' + : selected + ? 'drop-shadow(0 0 3px var(--brand-500))' + : 'none', + opacity: isDimmed ? 0.2 : 1, }} /> diff --git a/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx b/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx new file mode 100644 index 00000000..7747b8e1 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx @@ -0,0 +1,198 @@ +import { Panel } from '@xyflow/react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Switch } from '@/components/ui/switch'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Filter, X, Eye, EyeOff, ChevronDown } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { GraphFilterState } from '../hooks/use-graph-filter'; + +interface GraphFilterControlsProps { + filterState: GraphFilterState; + availableCategories: string[]; + hasActiveFilter: boolean; + onSearchQueryChange: (query: string) => void; + onCategoriesChange: (categories: string[]) => void; + onNegativeFilterChange: (isNegative: boolean) => void; + onClearFilters: () => void; +} + +export function GraphFilterControls({ + filterState, + availableCategories, + hasActiveFilter, + onSearchQueryChange, + onCategoriesChange, + onNegativeFilterChange, + onClearFilters, +}: GraphFilterControlsProps) { + const { selectedCategories, isNegativeFilter } = filterState; + + const handleCategoryToggle = (category: string) => { + if (selectedCategories.includes(category)) { + onCategoriesChange(selectedCategories.filter((c) => c !== category)); + } else { + onCategoriesChange([...selectedCategories, category]); + } + }; + + const handleSelectAllCategories = () => { + if (selectedCategories.length === availableCategories.length) { + onCategoriesChange([]); + } else { + onCategoriesChange([...availableCategories]); + } + }; + + const categoryButtonLabel = + selectedCategories.length === 0 + ? 'All Categories' + : selectedCategories.length === 1 + ? selectedCategories[0] + : `${selectedCategories.length} Categories`; + + return ( + + +
+ {/* Category Filter Dropdown */} + + + + + + + + Filter by Category + + +
+
+ Categories +
+ + {/* Select All option */} +
+ 0 + } + onCheckedChange={handleSelectAllCategories} + /> + + {selectedCategories.length === availableCategories.length + ? 'Deselect All' + : 'Select All'} + +
+ +
+ + {/* Category list */} +
+ {availableCategories.length === 0 ? ( +
+ No categories available +
+ ) : ( + availableCategories.map((category) => ( +
handleCategoryToggle(category)} + > + handleCategoryToggle(category)} + /> + {category} +
+ )) + )} +
+
+ + + + {/* Divider */} +
+ + {/* Positive/Negative Filter Toggle */} + + +
+ + +
+
+ + {isNegativeFilter + ? 'Negative filter: Highlighting non-matching nodes' + : 'Positive filter: Highlighting matching nodes'} + +
+ + {/* Clear Filters Button - only show when filters are active */} + {hasActiveFilter && ( + <> +
+ + + + + Clear All Filters + + + )} +
+ + + ); +} diff --git a/apps/ui/src/components/views/graph-view/components/index.ts b/apps/ui/src/components/views/graph-view/components/index.ts index eb509a97..1732f3b6 100644 --- a/apps/ui/src/components/views/graph-view/components/index.ts +++ b/apps/ui/src/components/views/graph-view/components/index.ts @@ -2,3 +2,4 @@ export { TaskNode } from './task-node'; export { DependencyEdge } from './dependency-edge'; export { GraphControls } from './graph-controls'; export { GraphLegend } from './graph-legend'; +export { GraphFilterControls } from './graph-filter-controls'; diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx index ebc8dcc2..3696a3d5 100644 --- a/apps/ui/src/components/views/graph-view/components/task-node.tsx +++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx @@ -72,14 +72,16 @@ const priorityConfig = { 3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' }, }; -export const TaskNode = memo(function TaskNode({ - data, - selected, -}: TaskNodeProps) { +export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) { const config = statusConfig[data.status] || statusConfig.backlog; const StatusIcon = config.icon; const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null; + // Filter highlight states + const isMatched = data.isMatched ?? false; + const isHighlighted = data.isHighlighted ?? false; + const isDimmed = data.isDimmed ?? false; + return ( <> {/* Target handle (left side - receives dependencies) */} @@ -89,39 +91,46 @@ export const TaskNode = memo(function TaskNode({ className={cn( 'w-3 h-3 !bg-border border-2 border-background', 'transition-colors duration-200', - 'hover:!bg-brand-500' + 'hover:!bg-brand-500', + isDimmed && 'opacity-30' )} />
{/* Header with status and actions */} -
+
- - {config.label} - + {config.label}
{/* Priority badge */} {priorityConf && ( - + {data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'} )} @@ -161,11 +170,7 @@ export const TaskNode = memo(function TaskNode({ {/* Actions dropdown */} - @@ -212,9 +217,7 @@ export const TaskNode = memo(function TaskNode({ {data.isRunning && (
-
+
Running...
@@ -240,7 +243,8 @@ export const TaskNode = memo(function TaskNode({ 'hover:!bg-brand-500', data.status === 'completed' || data.status === 'verified' ? '!bg-[var(--status-success)]' - : '' + : '', + isDimmed && 'opacity-30' )} /> diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx index 6251017d..40b5b487 100644 --- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -14,8 +14,20 @@ import { import '@xyflow/react/dist/style.css'; import { Feature } from '@/store/app-store'; -import { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components'; -import { useGraphNodes, useGraphLayout, type TaskNodeData } from './hooks'; +import { + TaskNode, + DependencyEdge, + GraphControls, + GraphLegend, + GraphFilterControls, +} from './components'; +import { + useGraphNodes, + useGraphLayout, + useGraphFilter, + type TaskNodeData, + type GraphFilterState, +} from './hooks'; import { cn } from '@/lib/utils'; // Define custom node and edge types - using any to avoid React Flow's strict typing @@ -32,6 +44,8 @@ const edgeTypes: any = { interface GraphCanvasProps { features: Feature[]; runningAutoTasks: string[]; + searchQuery: string; + onSearchQueryChange: (query: string) => void; onNodeClick?: (featureId: string) => void; onNodeDoubleClick?: (featureId: string) => void; backgroundStyle?: React.CSSProperties; @@ -41,6 +55,8 @@ interface GraphCanvasProps { function GraphCanvasInner({ features, runningAutoTasks, + searchQuery, + onSearchQueryChange, onNodeClick, onNodeDoubleClick, backgroundStyle, @@ -49,10 +65,25 @@ function GraphCanvasInner({ const [isLocked, setIsLocked] = useState(false); const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR'); - // Transform features to nodes and edges + // Filter state (category and negative toggle are local to graph view) + const [selectedCategories, setSelectedCategories] = useState([]); + const [isNegativeFilter, setIsNegativeFilter] = useState(false); + + // Combined filter state + const filterState: GraphFilterState = { + searchQuery, + selectedCategories, + isNegativeFilter, + }; + + // Calculate filter results + const filterResult = useGraphFilter(features, filterState); + + // Transform features to nodes and edges with filter results const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({ features, runningAutoTasks, + filterResult, }); // Apply layout @@ -80,6 +111,13 @@ function GraphCanvasInner({ [runLayout] ); + // Handle clear all filters + const handleClearFilters = useCallback(() => { + onSearchQueryChange(''); + setSelectedCategories([]); + setIsNegativeFilter(false); + }, [onSearchQueryChange]); + // Handle node click const handleNodeClick = useCallback( (_event: React.MouseEvent, node: Node) => { @@ -158,6 +196,16 @@ function GraphCanvasInner({ layoutDirection={layoutDirection} /> + +
diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index f745f37d..61b2db14 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -9,6 +9,8 @@ interface GraphViewProps { currentWorktreePath: string | null; currentWorktreeBranch: string | null; projectPath: string | null; + searchQuery: string; + onSearchQueryChange: (query: string) => void; onEditFeature: (feature: Feature) => void; onViewOutput: (feature: Feature) => void; } @@ -19,6 +21,8 @@ export function GraphView({ currentWorktreePath, currentWorktreeBranch, projectPath, + searchQuery, + onSearchQueryChange, onEditFeature, onViewOutput, }: GraphViewProps) { @@ -79,6 +83,8 @@ export function GraphView({ ; + highlightedNodeIds: Set; + highlightedEdgeIds: Set; + availableCategories: string[]; + hasActiveFilter: boolean; +} + +/** + * Traverses up the dependency tree to find all ancestors of a node + */ +function getAncestors( + featureId: string, + featureMap: Map, + visited: Set +): void { + if (visited.has(featureId)) return; + visited.add(featureId); + + const feature = featureMap.get(featureId); + if (!feature?.dependencies) return; + + for (const depId of feature.dependencies) { + if (featureMap.has(depId)) { + getAncestors(depId, featureMap, visited); + } + } +} + +/** + * Traverses down to find all descendants (features that depend on this one) + */ +function getDescendants(featureId: string, features: Feature[], visited: Set): void { + if (visited.has(featureId)) return; + visited.add(featureId); + + for (const feature of features) { + if (feature.dependencies?.includes(featureId)) { + getDescendants(feature.id, features, visited); + } + } +} + +/** + * Gets all edges in the highlighted path + */ +function getHighlightedEdges(highlightedNodeIds: Set, features: Feature[]): Set { + const edges = new Set(); + + for (const feature of features) { + if (!highlightedNodeIds.has(feature.id)) continue; + if (!feature.dependencies) continue; + + for (const depId of feature.dependencies) { + if (highlightedNodeIds.has(depId)) { + edges.add(`${depId}->${feature.id}`); + } + } + } + + return edges; +} + +/** + * Hook to calculate graph filter results based on search query, categories, and filter mode + */ +export function useGraphFilter( + features: Feature[], + filterState: GraphFilterState +): GraphFilterResult { + const { searchQuery, selectedCategories, isNegativeFilter } = filterState; + + return useMemo(() => { + // Extract all unique categories + const availableCategories = Array.from( + new Set(features.map((f) => f.category).filter(Boolean)) + ).sort(); + + const normalizedQuery = searchQuery.toLowerCase().trim(); + const hasSearchQuery = normalizedQuery.length > 0; + const hasCategoryFilter = selectedCategories.length > 0; + const hasActiveFilter = hasSearchQuery || hasCategoryFilter || isNegativeFilter; + + // If no filters active, return empty sets (show all nodes normally) + if (!hasActiveFilter) { + return { + matchedNodeIds: new Set(), + highlightedNodeIds: new Set(), + highlightedEdgeIds: new Set(), + availableCategories, + hasActiveFilter: false, + }; + } + + // Find directly matched nodes + const matchedNodeIds = new Set(); + const featureMap = new Map(features.map((f) => [f.id, f])); + + for (const feature of features) { + let matchesSearch = true; + let matchesCategory = true; + + // Check search query match (title or description) + if (hasSearchQuery) { + const titleMatch = feature.title?.toLowerCase().includes(normalizedQuery); + const descMatch = feature.description?.toLowerCase().includes(normalizedQuery); + matchesSearch = titleMatch || descMatch; + } + + // Check category match + if (hasCategoryFilter) { + matchesCategory = selectedCategories.includes(feature.category); + } + + // Both conditions must be true for a match + if (matchesSearch && matchesCategory) { + matchedNodeIds.add(feature.id); + } + } + + // Apply negative filter if enabled (invert the matched set) + let effectiveMatchedIds: Set; + if (isNegativeFilter) { + effectiveMatchedIds = new Set( + features.filter((f) => !matchedNodeIds.has(f.id)).map((f) => f.id) + ); + } else { + effectiveMatchedIds = matchedNodeIds; + } + + // Calculate full path (ancestors + descendants) for highlighted nodes + const highlightedNodeIds = new Set(); + + for (const id of effectiveMatchedIds) { + // Add the matched node itself + highlightedNodeIds.add(id); + + // Add all ancestors (dependencies) + getAncestors(id, featureMap, highlightedNodeIds); + + // Add all descendants (dependents) + getDescendants(id, features, highlightedNodeIds); + } + + // Get edges in the highlighted path + const highlightedEdgeIds = getHighlightedEdges(highlightedNodeIds, features); + + return { + matchedNodeIds: effectiveMatchedIds, + highlightedNodeIds, + highlightedEdgeIds, + availableCategories, + hasActiveFilter: true, + }; + }, [features, searchQuery, selectedCategories, isNegativeFilter]); +} diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts index cb1e038e..308bbe2e 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts @@ -2,26 +2,37 @@ import { useMemo } from 'react'; import { Node, Edge } from '@xyflow/react'; import { Feature } from '@/store/app-store'; import { getBlockingDependencies } from '@automaker/dependency-resolver'; +import { GraphFilterResult } from './use-graph-filter'; export interface TaskNodeData extends Feature { isBlocked: boolean; isRunning: boolean; blockingDependencies: string[]; + // Filter highlight states + isMatched?: boolean; + isHighlighted?: boolean; + isDimmed?: boolean; } export type TaskNode = Node; -export type DependencyEdge = Edge<{ sourceStatus: Feature['status']; targetStatus: Feature['status'] }>; +export type DependencyEdge = Edge<{ + sourceStatus: Feature['status']; + targetStatus: Feature['status']; + isHighlighted?: boolean; + isDimmed?: boolean; +}>; interface UseGraphNodesProps { features: Feature[]; runningAutoTasks: string[]; + filterResult?: GraphFilterResult; } /** * Transforms features into React Flow nodes and edges * Creates dependency edges based on feature.dependencies array */ -export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps) { +export function useGraphNodes({ features, runningAutoTasks, filterResult }: UseGraphNodesProps) { const { nodes, edges } = useMemo(() => { const nodeList: TaskNode[] = []; const edgeList: DependencyEdge[] = []; @@ -30,11 +41,22 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps // Create feature map for quick lookups features.forEach((f) => featureMap.set(f.id, f)); + // Extract filter state + const hasActiveFilter = filterResult?.hasActiveFilter ?? false; + const matchedNodeIds = filterResult?.matchedNodeIds ?? new Set(); + const highlightedNodeIds = filterResult?.highlightedNodeIds ?? new Set(); + const highlightedEdgeIds = filterResult?.highlightedEdgeIds ?? new Set(); + // Create nodes features.forEach((feature) => { const isRunning = runningAutoTasks.includes(feature.id); const blockingDeps = getBlockingDependencies(feature, features); + // Calculate filter highlight states + const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id); + const isHighlighted = hasActiveFilter && highlightedNodeIds.has(feature.id); + const isDimmed = hasActiveFilter && !highlightedNodeIds.has(feature.id); + const node: TaskNode = { id: feature.id, type: 'task', @@ -44,6 +66,10 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps isBlocked: blockingDeps.length > 0, isRunning, blockingDependencies: blockingDeps, + // Filter states + isMatched, + isHighlighted, + isDimmed, }, }; @@ -55,8 +81,14 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps // Only create edge if the dependency exists in current view if (featureMap.has(depId)) { const sourceFeature = featureMap.get(depId)!; + const edgeId = `${depId}->${feature.id}`; + + // Calculate edge highlight states + const edgeIsHighlighted = hasActiveFilter && highlightedEdgeIds.has(edgeId); + const edgeIsDimmed = hasActiveFilter && !highlightedEdgeIds.has(edgeId); + const edge: DependencyEdge = { - id: `${depId}->${feature.id}`, + id: edgeId, source: depId, target: feature.id, type: 'dependency', @@ -64,6 +96,8 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps data: { sourceStatus: sourceFeature.status, targetStatus: feature.status, + isHighlighted: edgeIsHighlighted, + isDimmed: edgeIsDimmed, }, }; edgeList.push(edge); @@ -73,7 +107,7 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps }); return { nodes: nodeList, edges: edgeList }; - }, [features, runningAutoTasks]); + }, [features, runningAutoTasks, filterResult]); return { nodes, edges }; } diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 351007db..cd7f8145 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -1049,12 +1049,79 @@ border-radius: 0 !important; } +/* Graph Filter Highlight States */ + +/* Matched node - direct search/filter match */ +.graph-node-matched { + box-shadow: + 0 0 0 3px var(--brand-500), + 0 0 20px 4px var(--brand-500); + border-color: var(--brand-500) !important; + z-index: 10; +} + +/* Animated glow for matched nodes */ +@keyframes matched-node-glow { + 0%, + 100% { + box-shadow: + 0 0 0 3px var(--brand-500), + 0 0 15px 2px var(--brand-500); + } + 50% { + box-shadow: + 0 0 0 3px var(--brand-500), + 0 0 25px 6px var(--brand-500); + } +} + +.graph-node-matched { + animation: matched-node-glow 2s ease-in-out infinite; +} + +/* Highlighted path node - part of the dependency path */ +.graph-node-highlighted { + box-shadow: + 0 0 0 2px var(--brand-400), + 0 0 12px 2px var(--brand-400); + z-index: 5; +} + +/* Dimmed node - not part of filter results */ +.graph-node-dimmed { + opacity: 0.25; + filter: grayscale(60%); + transition: + opacity 0.3s ease, + filter 0.3s ease; +} + +.graph-node-dimmed:hover { + opacity: 0.4; + filter: grayscale(40%); +} + +/* Highlighted edge styles */ +.graph-edge-highlighted path { + stroke: var(--brand-500) !important; + stroke-width: 4px !important; + filter: drop-shadow(0 0 6px var(--brand-500)); +} + +/* Dimmed edge styles */ +.graph-edge-dimmed path { + opacity: 0.15; + stroke-width: 1px !important; + filter: none !important; +} + /* Reduce motion preference */ @media (prefers-reduced-motion: reduce) { .graph-canvas .animated-edge path, .graph-canvas .edge-flowing path, .animate-pulse-subtle, - .animate-progress-indeterminate { + .animate-progress-indeterminate, + .graph-node-matched { animation: none; } }