diff --git a/docs/plans/2026-03-09-glow-edge-design.md b/docs/plans/2026-03-09-glow-edge-design.md new file mode 100644 index 00000000..0943cb11 --- /dev/null +++ b/docs/plans/2026-03-09-glow-edge-design.md @@ -0,0 +1,48 @@ +# Glow Edge System — Flow Editor + +> **Date:** 2026-03-09 + +## Overview + +Replace flat `smoothstep` edges in the troubleshooting flow editor with custom bezier edges featuring gradient strokes, soft glow, and directional animation on node selection. + +## Default Edges (no selection) + +- **Curve type:** Bezier (replacing right-angled `smoothstep`) +- **Stroke:** ~1.5px, subtle white/gray gradient with soft `drop-shadow` glow +- **Feel:** Clean, understated, dark-mode friendly + +## Selected Node — Downstream Edges + +- **Color:** Cyan brand gradient (`#06b6d4` → `#22d3ee`) +- **Animation:** Flowing dash animation moving downward (`stroke-dashoffset` keyframe) +- **Scope:** All edges from selected node through entire subtree (children, grandchildren, etc.) +- **Glow:** Soft cyan `drop-shadow` filter +- **Stroke:** 2px + +## Selected Node — Upstream Edges + +- **Color:** Amber gradient (`#f59e0b` → `#fbbf24`) +- **Animation:** Softer pulse/breathing opacity animation moving upward toward root +- **Scope:** All edges from selected node back to root +- **Glow:** Soft amber `drop-shadow` filter +- **Stroke:** 2px + +## Cross-reference Edges + +- Keep dashed + animated + cyan with arrows +- Use new bezier curves + glow treatment + +## Implementation + +- One custom `GlowEdge` component registered as the default edge type in React Flow +- `useTreeLayout` passes `edgeState: 'default' | 'downstream' | 'upstream'` in edge data based on `selectedNodeId` +- SVG `linearGradient` + `filter` defined once in a `` block +- CSS keyframe animation for flowing dash effect + +## Files Touched + +- **New:** `frontend/src/components/tree-editor/GlowEdge.tsx` (~60 lines) +- **Modified:** `useTreeLayout.ts` — add ancestor/descendant calculation, edge state +- **Modified:** `FlowCanvas.tsx` — register custom edge type +- **Modified:** `index.css` — keyframe animation for flowing dashes diff --git a/frontend/src/components/tree-editor/FlowCanvas.tsx b/frontend/src/components/tree-editor/FlowCanvas.tsx index 32330b9b..bc4b1f34 100644 --- a/frontend/src/components/tree-editor/FlowCanvas.tsx +++ b/frontend/src/components/tree-editor/FlowCanvas.tsx @@ -12,10 +12,10 @@ import { PanOnScrollMode, type NodeMouseHandler, } from '@xyflow/react' -import '@xyflow/react/dist/style.css' import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode' import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode' +import { GlowEdge, GlowEdgeDefs } from './GlowEdge' import { useTreeLayout } from './useTreeLayout' import { cn } from '@/lib/utils' import { Map as MapIcon, MapPinOff } from 'lucide-react' @@ -27,6 +27,10 @@ const nodeTypes = { answerStub: FlowCanvasAnswerNode, } +const edgeTypes = { + glowEdge: GlowEdge, +} + interface FlowCanvasProps { selectedNodeId: string | null onNodeSelect: (nodeId: string | null) => void @@ -36,7 +40,7 @@ interface FlowCanvasProps { function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: FlowCanvasProps) { const { fitView, setCenter } = useReactFlow() - const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout() + const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout(selectedNodeId) const [minimapVisible, setMinimapVisible] = useState(true) // Inject callbacks into node data (because useTreeLayout creates placeholder functions) @@ -124,6 +128,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onN onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} onNodeClick={handleNodeClick} onPaneClick={handlePaneClick} fitView @@ -139,6 +144,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onN proOptions={{ hideAttribution: true }} className="dark bg-accent/30" > + {minimapVisible && ( diff --git a/frontend/src/components/tree-editor/GlowEdge.tsx b/frontend/src/components/tree-editor/GlowEdge.tsx new file mode 100644 index 00000000..a1b80db5 --- /dev/null +++ b/frontend/src/components/tree-editor/GlowEdge.tsx @@ -0,0 +1,188 @@ +import { memo } from 'react' +import { BaseEdge, getBezierPath, type EdgeProps } from '@xyflow/react' + +export type EdgeState = 'default' | 'downstream' | 'upstream' | 'crossref' + +export interface GlowEdgeData { + edgeState?: EdgeState + label?: string + [key: string]: unknown +} + +const EDGE_CONFIGS: Record = { + default: { + gradientId: 'edge-gradient-default', + strokeWidth: 1.5, + filterClass: 'glow-edge-default', + animationClass: '', + }, + downstream: { + gradientId: 'edge-gradient-downstream', + strokeWidth: 2, + filterClass: 'glow-edge-downstream', + animationClass: 'glow-edge-flow-downstream', + }, + upstream: { + gradientId: 'edge-gradient-upstream', + strokeWidth: 2, + filterClass: 'glow-edge-upstream', + animationClass: 'glow-edge-flow-upstream', + }, + crossref: { + gradientId: 'edge-gradient-downstream', + strokeWidth: 2, + filterClass: 'glow-edge-downstream', + animationClass: 'glow-edge-flow-downstream', + }, +} + +/** Shared SVG — render once inside the ReactFlow container. */ +export function GlowEdgeDefs() { + return ( + + + {/* Default: subtle white/gray */} + + + + + + {/* Downstream: cyan brand */} + + + + + + {/* Upstream: amber */} + + + + + + {/* Glow filters */} + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +function GlowEdgeComponent({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + label, + labelStyle, + labelBgStyle, + markerEnd, +}: EdgeProps) { + const edgeState = (data as GlowEdgeData)?.edgeState ?? 'default' + const config = EDGE_CONFIGS[edgeState] + const isCrossref = edgeState === 'crossref' + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + }) + + const filterId = edgeState === 'default' ? 'glow-default' + : edgeState === 'upstream' ? 'glow-amber' + : 'glow-cyan' + + return ( + <> + {/* Glow layer (wider, blurred) */} + + + {/* Main edge path */} + + + {/* Animated dash overlay for downstream/upstream */} + {(edgeState === 'downstream' || edgeState === 'upstream' || isCrossref) && ( + + )} + + {/* Label */} + {label && ( + + + + {String(label)} + + + )} + + ) +} + +export const GlowEdge = memo(GlowEdgeComponent) diff --git a/frontend/src/components/tree-editor/useTreeLayout.ts b/frontend/src/components/tree-editor/useTreeLayout.ts index 5dd78c7d..d6aa5ba9 100644 --- a/frontend/src/components/tree-editor/useTreeLayout.ts +++ b/frontend/src/components/tree-editor/useTreeLayout.ts @@ -69,6 +69,41 @@ function collectCrossRefEdges(root: TreeStructure): Array<{ source: string; targ return refs } +/** Collect all descendant node IDs of a given node (not including itself). */ +function collectDescendantIds(root: TreeStructure, targetId: string): Set { + const ids = new Set() + function findAndCollect(node: TreeStructure): boolean { + if (node.id === targetId) { + // Found — collect all children recursively + function collectAll(n: TreeStructure) { + n.children?.forEach(c => { ids.add(c.id); collectAll(c) }) + } + collectAll(node) + return true + } + return node.children?.some(findAndCollect) ?? false + } + findAndCollect(root) + return ids +} + +/** Collect all ancestor node IDs from a node up to root. */ +function collectAncestorIds(root: TreeStructure, targetId: string): Set { + const ids = new Set() + function walk(node: TreeStructure, path: string[]): boolean { + if (node.id === targetId) { + path.forEach(id => ids.add(id)) + return true + } + for (const child of node.children ?? []) { + if (walk(child, [...path, node.id])) return true + } + return false + } + walk(root, []) + return ids +} + interface UseTreeLayoutResult { nodes: Node[] edges: Edge[] @@ -77,7 +112,7 @@ interface UseTreeLayoutResult { onNodesMeasured: (measuredNodes: Node[]) => void } -export function useTreeLayout(): UseTreeLayoutResult { +export function useTreeLayout(selectedNodeId?: string | null): UseTreeLayoutResult { const treeStructure = useTreeEditorStore(s => s.treeStructure) const validationErrors = useTreeEditorStore(s => s.validationErrors) const [collapsedNodeIds, setCollapsedNodeIds] = useState>(new Set()) @@ -93,6 +128,15 @@ export function useTreeLayout(): UseTreeLayoutResult { }) }, []) + // Compute ancestor/descendant sets for selected node + const { descendantIds, ancestorIds } = useMemo(() => { + if (!treeStructure || !selectedNodeId) return { descendantIds: new Set(), ancestorIds: new Set() } + return { + descendantIds: collectDescendantIds(treeStructure, selectedNodeId), + ancestorIds: collectAncestorIds(treeStructure, selectedNodeId), + } + }, [treeStructure, selectedNodeId]) + // Convert tree structure to flat nodes and edges const { rawNodes, rawEdges } = useMemo(() => { const nodes: Node[] = [] @@ -100,6 +144,21 @@ export function useTreeLayout(): UseTreeLayoutResult { if (!treeStructure) return { rawNodes: nodes, rawEdges: edges } + /** Determine edge state based on source/target relation to selected node */ + function getEdgeState(sourceId: string, targetId: string): 'default' | 'downstream' | 'upstream' { + if (!selectedNodeId) return 'default' + // Downstream: selected node → child, or descendant → descendant's child + if ((sourceId === selectedNodeId && descendantIds.has(targetId)) || + (descendantIds.has(sourceId) && descendantIds.has(targetId))) { + return 'downstream' + } + // Upstream: ancestor → ancestor, or ancestor → selected node + if ((ancestorIds.has(sourceId) && (ancestorIds.has(targetId) || targetId === selectedNodeId))) { + return 'upstream' + } + return 'default' + } + // eslint-disable-next-line @typescript-eslint/no-unused-vars function walk(node: TreeStructure, _parentId?: string | null) { const isCollapsed = collapsedNodeIds.has(node.id) @@ -148,17 +207,18 @@ export function useTreeLayout(): UseTreeLayoutResult { for (const { child, optionLabel } of orderedChildren) { const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined + const edgeState = getEdgeState(node.id, child.id) edges.push({ id: `${node.id}->${child.id}`, source: node.id, target: child.id, - type: 'smoothstep', + type: 'glowEdge', label: edgeLabel, labelStyle: { fill: 'var(--color-muted-foreground)', fontSize: 11 }, labelBgStyle: { fill: 'var(--color-card)', fillOpacity: 0.9 }, labelBgPadding: [4, 2] as [number, number], - style: { stroke: 'var(--color-border)' }, + data: { edgeState }, }) walk(child, node.id) @@ -168,11 +228,10 @@ export function useTreeLayout(): UseTreeLayoutResult { walk(treeStructure, null) - // Add cross-reference edges (dashed, purple) + // Add cross-reference edges if (treeStructure) { const crossRefs = collectCrossRefEdges(treeStructure) for (const ref of crossRefs) { - // Only add if both source and target nodes are visible (not collapsed away) const sourceVisible = nodes.some(n => n.id === ref.source) const targetVisible = nodes.some(n => n.id === ref.target) if (sourceVisible && targetVisible) { @@ -180,17 +239,12 @@ export function useTreeLayout(): UseTreeLayoutResult { id: `xref-${ref.source}->${ref.target}`, source: ref.source, target: ref.target, - type: 'smoothstep', - animated: true, + type: 'glowEdge', label: ref.label ? truncateLabel(ref.label) : undefined, labelStyle: { fill: 'var(--color-primary)', fontSize: 10, fontWeight: 500 }, labelBgStyle: { fill: 'var(--color-card)', fillOpacity: 0.95 }, labelBgPadding: [4, 2] as [number, number], - style: { - stroke: 'var(--color-primary)', - strokeWidth: 2, - strokeDasharray: '6 3', - }, + data: { edgeState: 'crossref' as const }, markerEnd: { type: 'arrowclosed' as const, color: 'var(--color-primary)', @@ -203,7 +257,7 @@ export function useTreeLayout(): UseTreeLayoutResult { } return { rawNodes: nodes, rawEdges: edges } - }, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights]) + }, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights, selectedNodeId, descendantIds, ancestorIds]) // Run dagre layout const { nodes, edges } = useMemo(() => {