archrip 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,12 @@ import { getCategoryColors, getCategoryLabel } from '../types.ts';
2
2
 
3
3
  interface LegendProps {
4
4
  categories: string[];
5
+ hiddenCategories: Set<string>;
6
+ onToggleCategory: (category: string) => void;
7
+ onShowAll: () => void;
5
8
  }
6
9
 
7
- export function Legend({ categories }: LegendProps) {
10
+ export function Legend({ categories, hiddenCategories, onToggleCategory, onShowAll }: LegendProps) {
8
11
  return (
9
12
  <div
10
13
  className="absolute bottom-3 right-3 z-10 rounded-lg p-3 border"
@@ -23,19 +26,46 @@ export function Legend({ categories }: LegendProps) {
23
26
  <div className="grid grid-cols-2 gap-x-4 gap-y-1">
24
27
  {categories.map((cat) => {
25
28
  const colors = getCategoryColors(cat);
29
+ const hidden = hiddenCategories.has(cat);
26
30
  return (
27
- <div key={cat} className="flex items-center gap-1.5">
31
+ <button
32
+ key={cat}
33
+ onClick={() => onToggleCategory(cat)}
34
+ className="flex items-center gap-1.5 cursor-pointer bg-transparent border-none p-0 text-left"
35
+ >
28
36
  <div
29
37
  className="w-3 h-3 rounded-sm border"
30
- style={{ background: colors.bg, borderColor: colors.border }}
38
+ style={{
39
+ background: colors.bg,
40
+ borderColor: colors.border,
41
+ opacity: hidden ? 0.3 : 1,
42
+ transition: 'opacity 0.2s',
43
+ }}
31
44
  />
32
- <span className="text-xs" style={{ color: 'var(--color-content-secondary)' }}>
45
+ <span
46
+ className="text-xs"
47
+ style={{
48
+ color: 'var(--color-content-secondary)',
49
+ opacity: hidden ? 0.4 : 1,
50
+ textDecoration: hidden ? 'line-through' : 'none',
51
+ transition: 'opacity 0.2s',
52
+ }}
53
+ >
33
54
  {getCategoryLabel(cat)}
34
55
  </span>
35
- </div>
56
+ </button>
36
57
  );
37
58
  })}
38
59
  </div>
60
+ {hiddenCategories.size > 0 && (
61
+ <button
62
+ onClick={onShowAll}
63
+ className="mt-2 text-xs cursor-pointer hover:underline bg-transparent border-none p-0"
64
+ style={{ color: 'var(--color-interactive-primary)' }}
65
+ >
66
+ Show All
67
+ </button>
68
+ )}
39
69
  </div>
40
70
  );
41
71
  }
@@ -1,12 +1,53 @@
1
1
  import type { UseCase } from '../types.ts';
2
+ import type { FlowAnimationState } from '../hooks/useFlowAnimation.ts';
2
3
 
3
4
  interface UseCaseFilterProps {
4
5
  useCases: UseCase[];
5
6
  selectedUseCase: string | null;
6
7
  onSelect: (useCaseId: string | null) => void;
8
+ flowInfo: FlowAnimationState;
7
9
  }
8
10
 
9
- export function UseCaseFilter({ useCases, selectedUseCase, onSelect }: UseCaseFilterProps) {
11
+ function FlowButton({ label, onClick }: { label: string; onClick: () => void }) {
12
+ return (
13
+ <button
14
+ onClick={onClick}
15
+ className="w-7 h-7 flex items-center justify-center rounded border text-xs cursor-pointer"
16
+ style={{
17
+ background: 'var(--color-surface-secondary)',
18
+ borderColor: 'var(--color-border-primary)',
19
+ color: 'var(--color-content-secondary)',
20
+ }}
21
+ aria-label={label}
22
+ >
23
+ {label === 'Previous' && (
24
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
25
+ <path d="M8 1.5L3.5 6L8 10.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
26
+ </svg>
27
+ )}
28
+ {label === 'Play' && (
29
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
30
+ <path d="M3 1.5L10 6L3 10.5V1.5Z" />
31
+ </svg>
32
+ )}
33
+ {label === 'Pause' && (
34
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
35
+ <rect x="2.5" y="1.5" width="2.5" height="9" rx="0.5" />
36
+ <rect x="7" y="1.5" width="2.5" height="9" rx="0.5" />
37
+ </svg>
38
+ )}
39
+ {label === 'Next' && (
40
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor">
41
+ <path d="M4 1.5L8.5 6L4 10.5" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round" strokeLinejoin="round" />
42
+ </svg>
43
+ )}
44
+ </button>
45
+ );
46
+ }
47
+
48
+ export function UseCaseFilter({ useCases, selectedUseCase, onSelect, flowInfo }: UseCaseFilterProps) {
49
+ const selectedUc = selectedUseCase ? useCases.find((uc) => uc.id === selectedUseCase) : undefined;
50
+
10
51
  return (
11
52
  <div
12
53
  className="rounded-lg p-3 w-64 border"
@@ -40,11 +81,29 @@ export function UseCaseFilter({ useCases, selectedUseCase, onSelect }: UseCaseFi
40
81
  </option>
41
82
  ))}
42
83
  </select>
43
- {selectedUseCase && (
84
+ {selectedUc && (
44
85
  <div className="mt-2">
45
86
  <p className="text-xs" style={{ color: 'var(--color-content-secondary)' }}>
46
- {useCases.find((uc) => uc.id === selectedUseCase)?.description}
87
+ {selectedUc.description}
47
88
  </p>
89
+ {flowInfo.flowNodeIds && (
90
+ <div className="mt-1.5">
91
+ <p
92
+ className="text-xs font-mono mb-1.5"
93
+ style={{ color: 'var(--color-interactive-primary)' }}
94
+ >
95
+ Step {flowInfo.activeStep + 1}/{flowInfo.flowNodeIds.length}
96
+ </p>
97
+ <div className="flex items-center gap-1">
98
+ <FlowButton label="Previous" onClick={flowInfo.prev} />
99
+ <FlowButton
100
+ label={flowInfo.isPlaying ? 'Pause' : 'Play'}
101
+ onClick={flowInfo.isPlaying ? flowInfo.pause : flowInfo.play}
102
+ />
103
+ <FlowButton label="Next" onClick={flowInfo.next} />
104
+ </div>
105
+ </div>
106
+ )}
48
107
  <button
49
108
  onClick={() => onSelect(null)}
50
109
  className="mt-1.5 text-xs cursor-pointer hover:underline"
@@ -0,0 +1,36 @@
1
+ import { useCallback, useMemo } from 'react';
2
+ import { useQueryState, parseAsString } from 'nuqs';
3
+
4
+ interface CategoryFilterState {
5
+ hiddenCategories: Set<string>;
6
+ toggleCategory: (category: string) => void;
7
+ showAll: () => void;
8
+ }
9
+
10
+ export function useCategoryFilter(): CategoryFilterState {
11
+ const [hideParam, setHideParam] = useQueryState('hide', parseAsString.withOptions({ history: 'replace' }));
12
+
13
+ const hiddenCategories = useMemo(() => {
14
+ if (!hideParam) return new Set<string>();
15
+ return new Set(hideParam.split(',').filter(Boolean));
16
+ }, [hideParam]);
17
+
18
+ const toggleCategory = useCallback(
19
+ (category: string) => {
20
+ const next = new Set(hiddenCategories);
21
+ if (next.has(category)) {
22
+ next.delete(category);
23
+ } else {
24
+ next.add(category);
25
+ }
26
+ void setHideParam(next.size > 0 ? [...next].join(',') : null);
27
+ },
28
+ [hiddenCategories, setHideParam],
29
+ );
30
+
31
+ const showAll = useCallback(() => {
32
+ void setHideParam(null);
33
+ }, [setHideParam]);
34
+
35
+ return { hiddenCategories, toggleCategory, showAll };
36
+ }
@@ -0,0 +1,91 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+
3
+ import type { ArchFlowNode } from '../types.ts';
4
+
5
+ export interface CommandPaletteResult {
6
+ id: string;
7
+ label: string;
8
+ category: string;
9
+ description: string;
10
+ }
11
+
12
+ interface CommandPaletteState {
13
+ isOpen: boolean;
14
+ toggle: () => void;
15
+ close: () => void;
16
+ query: string;
17
+ setQuery: (q: string) => void;
18
+ results: CommandPaletteResult[];
19
+ activeIndex: number;
20
+ moveUp: () => void;
21
+ moveDown: () => void;
22
+ activeItem: CommandPaletteResult | null;
23
+ }
24
+
25
+ const MAX_RESULTS = 20;
26
+
27
+ export function useCommandPalette(
28
+ nodes: ArchFlowNode[],
29
+ hiddenCategories: Set<string>,
30
+ ): CommandPaletteState {
31
+ const [isOpen, setIsOpen] = useState(false);
32
+ const [query, setQueryRaw] = useState('');
33
+ const [activeIndex, setActiveIndex] = useState(0);
34
+
35
+ const toggle = useCallback(() => {
36
+ setIsOpen((prev) => {
37
+ if (!prev) {
38
+ setQueryRaw('');
39
+ setActiveIndex(0);
40
+ }
41
+ return !prev;
42
+ });
43
+ }, []);
44
+
45
+ const close = useCallback(() => {
46
+ setIsOpen(false);
47
+ }, []);
48
+
49
+ const setQuery = useCallback((q: string) => {
50
+ setQueryRaw(q);
51
+ setActiveIndex(0);
52
+ }, []);
53
+
54
+ const results = useMemo(() => {
55
+ const searchable = nodes.filter(
56
+ (n) => !n.hidden && !hiddenCategories.has(n.data.category),
57
+ );
58
+
59
+ if (!query.trim()) {
60
+ return searchable.slice(0, MAX_RESULTS).map((n) => ({
61
+ id: n.id,
62
+ label: n.data.label,
63
+ category: n.data.category,
64
+ description: n.data.description,
65
+ }));
66
+ }
67
+
68
+ const q = query.toLowerCase();
69
+ return searchable
70
+ .filter((n) => n.data.label.toLowerCase().includes(q))
71
+ .slice(0, MAX_RESULTS)
72
+ .map((n) => ({
73
+ id: n.id,
74
+ label: n.data.label,
75
+ category: n.data.category,
76
+ description: n.data.description,
77
+ }));
78
+ }, [nodes, hiddenCategories, query]);
79
+
80
+ const moveUp = useCallback(() => {
81
+ setActiveIndex((prev) => (prev <= 0 ? prev : prev - 1));
82
+ }, []);
83
+
84
+ const moveDown = useCallback(() => {
85
+ setActiveIndex((prev) => (prev >= results.length - 1 ? prev : prev + 1));
86
+ }, [results.length]);
87
+
88
+ const activeItem = results[activeIndex] ?? null;
89
+
90
+ return { isOpen, toggle, close, query, setQuery, results, activeIndex, moveUp, moveDown, activeItem };
91
+ }
@@ -0,0 +1,82 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+
3
+ import type { UseCase } from '../types.ts';
4
+
5
+ export interface FlowAnimationState {
6
+ activeStep: number;
7
+ flowNodeIds: string[] | null;
8
+ isPlaying: boolean;
9
+ play: () => void;
10
+ pause: () => void;
11
+ next: () => void;
12
+ prev: () => void;
13
+ }
14
+
15
+ const NOOP = () => {};
16
+
17
+ const IDLE_STATE: FlowAnimationState = {
18
+ activeStep: -1,
19
+ flowNodeIds: null,
20
+ isPlaying: false,
21
+ play: NOOP,
22
+ pause: NOOP,
23
+ next: NOOP,
24
+ prev: NOOP,
25
+ };
26
+
27
+ export function useFlowAnimation(
28
+ useCases: UseCase[],
29
+ selectedUseCase: string | null,
30
+ ): FlowAnimationState {
31
+ const [activeStep, setActiveStep] = useState(0);
32
+ const [isPlaying, setIsPlaying] = useState(true);
33
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
34
+
35
+ const uc = selectedUseCase ? useCases.find((u) => u.id === selectedUseCase) : undefined;
36
+ const flow = uc?.flow;
37
+ const flowLength = flow?.length ?? 0;
38
+
39
+ // Reset on use case change
40
+ useEffect(() => {
41
+ setActiveStep(0);
42
+ setIsPlaying(true);
43
+ }, [selectedUseCase]);
44
+
45
+ // Auto-advance timer
46
+ useEffect(() => {
47
+ if (!flow || flowLength < 2 || !isPlaying) return;
48
+
49
+ timerRef.current = setTimeout(() => {
50
+ setActiveStep((prev) => {
51
+ const next = prev + 1;
52
+ return next >= flowLength ? 0 : next;
53
+ });
54
+ }, 800);
55
+
56
+ return () => {
57
+ if (timerRef.current !== null) {
58
+ clearTimeout(timerRef.current);
59
+ timerRef.current = null;
60
+ }
61
+ };
62
+ }, [flow, flowLength, isPlaying, activeStep]);
63
+
64
+ const play = useCallback(() => setIsPlaying(true), []);
65
+ const pause = useCallback(() => setIsPlaying(false), []);
66
+
67
+ const next = useCallback(() => {
68
+ setIsPlaying(false);
69
+ setActiveStep((prev) => (prev + 1 >= flowLength ? prev : prev + 1));
70
+ }, [flowLength]);
71
+
72
+ const prev = useCallback(() => {
73
+ setIsPlaying(false);
74
+ setActiveStep((prev) => (prev <= 0 ? 0 : prev - 1));
75
+ }, []);
76
+
77
+ if (!flow || flowLength < 2) {
78
+ return IDLE_STATE;
79
+ }
80
+
81
+ return { activeStep, flowNodeIds: flow, isPlaying, play, pause, next, prev };
82
+ }
@@ -0,0 +1,39 @@
1
+ import { useEffect } from 'react';
2
+
3
+ interface KeyboardShortcutActions {
4
+ onTogglePalette: () => void;
5
+ onEscape: () => void;
6
+ isPaletteOpen: boolean;
7
+ }
8
+
9
+ export function useKeyboardShortcuts(actions: KeyboardShortcutActions): void {
10
+ useEffect(() => {
11
+ function handleKeyDown(e: KeyboardEvent) {
12
+ // Skip when focused on form elements
13
+ const tag = (e.target as HTMLElement).tagName;
14
+ if (tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA') {
15
+ // Allow Escape even in input
16
+ if (e.key === 'Escape') {
17
+ actions.onEscape();
18
+ return;
19
+ }
20
+ return;
21
+ }
22
+
23
+ // Cmd/Ctrl+K: toggle palette
24
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
25
+ e.preventDefault();
26
+ actions.onTogglePalette();
27
+ return;
28
+ }
29
+
30
+ if (e.key === 'Escape') {
31
+ actions.onEscape();
32
+ return;
33
+ }
34
+ }
35
+
36
+ window.addEventListener('keydown', handleKeyDown);
37
+ return () => window.removeEventListener('keydown', handleKeyDown);
38
+ }, [actions]);
39
+ }
@@ -3,6 +3,7 @@ import { useQueryState, parseAsString } from 'nuqs';
3
3
  import type { Edge } from '@xyflow/react';
4
4
 
5
5
  import type { ArchFlowNode, UseCase } from '../types.ts';
6
+ import { useFlowAnimation } from './useFlowAnimation.ts';
6
7
 
7
8
  function isNodeActive(node: ArchFlowNode, activeIds: Set<string>): boolean {
8
9
  if (node.data.isGroup && node.data.memberNodes) {
@@ -11,8 +12,20 @@ function isNodeActive(node: ArchFlowNode, activeIds: Set<string>): boolean {
11
12
  return activeIds.has(node.id);
12
13
  }
13
14
 
15
+ /** Resolve a node's effective ID for flow matching (group → first active member) */
16
+ function getFlowId(node: ArchFlowNode, flowSet: Set<string>): string | null {
17
+ if (flowSet.has(node.id)) return node.id;
18
+ if (node.data.isGroup && node.data.memberNodes) {
19
+ const member = node.data.memberNodes.find((m) => flowSet.has(m.id));
20
+ if (member) return member.id;
21
+ }
22
+ return null;
23
+ }
24
+
14
25
  export function useUseCaseFilter(nodes: ArchFlowNode[], edges: Edge[], useCases: UseCase[]) {
15
26
  const [selectedUseCase, setSelectedUseCase] = useQueryState('uc', parseAsString.withOptions({ history: 'replace' }));
27
+ const flowState = useFlowAnimation(useCases, selectedUseCase);
28
+ const { activeStep, flowNodeIds } = flowState;
16
29
 
17
30
  const categories = useMemo(() => {
18
31
  const set = new Set<string>();
@@ -27,19 +40,77 @@ export function useUseCaseFilter(nodes: ArchFlowNode[], edges: Edge[], useCases:
27
40
  const uc = useCases.find((u) => u.id === selectedUseCase);
28
41
  if (!uc) return nodes;
29
42
  const activeIds = new Set(uc.nodeIds);
43
+
44
+ // No flow — static highlight (backward compat)
45
+ if (!flowNodeIds) {
46
+ return nodes.map((node) => {
47
+ if (node.hidden) return node;
48
+ const active = isNodeActive(node, activeIds);
49
+ return {
50
+ ...node,
51
+ style: {
52
+ ...node.style,
53
+ opacity: active ? 1 : 0.15,
54
+ transition: 'opacity 0.3s',
55
+ },
56
+ };
57
+ });
58
+ }
59
+
60
+ // Flow animation mode
61
+ const flowSet = new Set(flowNodeIds);
62
+ const activeFlowId = flowNodeIds[activeStep];
63
+ const passedIds = new Set(flowNodeIds.slice(0, activeStep));
64
+
30
65
  return nodes.map((node) => {
31
66
  if (node.hidden) return node;
32
67
  const active = isNodeActive(node, activeIds);
68
+ if (!active) {
69
+ // Non-participating node
70
+ return {
71
+ ...node,
72
+ style: { ...node.style, opacity: 0.08, transition: 'opacity 0.3s' },
73
+ };
74
+ }
75
+
76
+ const flowId = getFlowId(node, flowSet);
77
+ if (!flowId) {
78
+ // In use case but not in flow — dim slightly
79
+ return {
80
+ ...node,
81
+ style: { ...node.style, opacity: 0.3, transition: 'opacity 0.3s' },
82
+ };
83
+ }
84
+
85
+ if (flowId === activeFlowId) {
86
+ // Active step — glow + scale
87
+ return {
88
+ ...node,
89
+ style: {
90
+ ...node.style,
91
+ opacity: 1,
92
+ boxShadow: 'var(--shadow-node-flow-active)',
93
+ transform: 'scale(1.02)',
94
+ transition: 'opacity 0.3s, box-shadow 0.3s, transform 0.3s',
95
+ },
96
+ };
97
+ }
98
+
99
+ if (passedIds.has(flowId)) {
100
+ // Already passed
101
+ return {
102
+ ...node,
103
+ style: { ...node.style, opacity: 1, transition: 'opacity 0.3s' },
104
+ };
105
+ }
106
+
107
+ // Not yet reached
33
108
  return {
34
109
  ...node,
35
- style: {
36
- ...node.style,
37
- opacity: active ? 1 : 0.15,
38
- transition: 'opacity 0.3s',
39
- },
110
+ style: { ...node.style, opacity: 0.3, transition: 'opacity 0.3s' },
40
111
  };
41
112
  });
42
- }, [nodes, selectedUseCase, useCases]);
113
+ }, [nodes, selectedUseCase, useCases, flowNodeIds, activeStep]);
43
114
 
44
115
  const filteredEdges = useMemo(() => {
45
116
  if (!selectedUseCase) return edges;
@@ -55,19 +126,93 @@ export function useUseCaseFilter(nodes: ArchFlowNode[], edges: Edge[], useCases:
55
126
  }
56
127
  }
57
128
 
58
- return edges.map((edge) => ({
59
- ...edge,
60
- style: {
61
- ...edge.style,
62
- opacity: activeNodeIds.has(edge.source) && activeNodeIds.has(edge.target) ? 1 : 0.08,
63
- transition: 'opacity 0.3s',
64
- },
65
- labelStyle: {
66
- ...((edge.labelStyle as Record<string, unknown>) ?? {}),
67
- opacity: activeNodeIds.has(edge.source) && activeNodeIds.has(edge.target) ? 1 : 0,
68
- },
69
- }));
70
- }, [edges, nodes, selectedUseCase, useCases]);
71
-
72
- return { selectedUseCase, setSelectedUseCase, categories, filteredNodes, filteredEdges };
129
+ // No flow — static highlight (backward compat)
130
+ if (!flowNodeIds) {
131
+ return edges.map((edge) => ({
132
+ ...edge,
133
+ style: {
134
+ ...edge.style,
135
+ opacity: activeNodeIds.has(edge.source) && activeNodeIds.has(edge.target) ? 1 : 0.08,
136
+ transition: 'opacity 0.3s',
137
+ },
138
+ labelStyle: {
139
+ ...((edge.labelStyle as Record<string, unknown>) ?? {}),
140
+ opacity: activeNodeIds.has(edge.source) && activeNodeIds.has(edge.target) ? 1 : 0,
141
+ },
142
+ }));
143
+ }
144
+
145
+ // Flow animation mode — build flow edge lookup
146
+ // A flow edge is one connecting flow[i] → flow[i+1]
147
+ const flowEdgeMap = new Map<string, 'active' | 'passed' | 'upcoming'>();
148
+ for (let i = 0; i < flowNodeIds.length - 1; i++) {
149
+ const src = flowNodeIds[i];
150
+ const tgt = flowNodeIds[i + 1];
151
+ // Check both edge ID conventions
152
+ const key1 = `${src}->${tgt}`;
153
+ const key2 = `${tgt}->${src}`;
154
+ let status: 'active' | 'passed' | 'upcoming';
155
+ if (i + 1 === activeStep) {
156
+ status = 'active';
157
+ } else if (i + 1 <= activeStep) {
158
+ status = 'passed';
159
+ } else {
160
+ status = 'upcoming';
161
+ }
162
+ flowEdgeMap.set(key1, status);
163
+ flowEdgeMap.set(key2, status);
164
+ }
165
+
166
+ return edges.map((edge) => {
167
+ const bothActive = activeNodeIds.has(edge.source) && activeNodeIds.has(edge.target);
168
+ if (!bothActive) {
169
+ return {
170
+ ...edge,
171
+ style: { ...edge.style, opacity: 0.08, transition: 'opacity 0.3s' },
172
+ className: '',
173
+ labelStyle: {
174
+ ...((edge.labelStyle as Record<string, unknown>) ?? {}),
175
+ opacity: 0,
176
+ },
177
+ };
178
+ }
179
+
180
+ const flowStatus = flowEdgeMap.get(edge.id);
181
+ if (flowStatus === 'active') {
182
+ return {
183
+ ...edge,
184
+ style: { ...edge.style, opacity: 1, stroke: undefined, strokeWidth: undefined },
185
+ className: 'flow-edge-active',
186
+ labelStyle: {
187
+ ...((edge.labelStyle as Record<string, unknown>) ?? {}),
188
+ opacity: 1,
189
+ },
190
+ };
191
+ }
192
+ if (flowStatus === 'passed') {
193
+ return {
194
+ ...edge,
195
+ style: { ...edge.style, opacity: 1, transition: 'opacity 0.3s' },
196
+ className: '',
197
+ labelStyle: {
198
+ ...((edge.labelStyle as Record<string, unknown>) ?? {}),
199
+ opacity: 1,
200
+ },
201
+ };
202
+ }
203
+
204
+ // In use case but not a flow edge, or upcoming flow edge
205
+ return {
206
+ ...edge,
207
+ style: { ...edge.style, opacity: 0.3, transition: 'opacity 0.3s' },
208
+ className: '',
209
+ labelStyle: {
210
+ ...((edge.labelStyle as Record<string, unknown>) ?? {}),
211
+ opacity: 0.3,
212
+ },
213
+ };
214
+ });
215
+ }, [edges, nodes, selectedUseCase, useCases, flowNodeIds, activeStep]);
216
+
217
+ return { selectedUseCase, setSelectedUseCase, categories, filteredNodes, filteredEdges, flowInfo: flowState };
73
218
  }
@@ -33,6 +33,7 @@
33
33
  --shadow-panel: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
34
34
  --shadow-node: 0 1px 3px rgba(0, 0, 0, 0.1);
35
35
  --shadow-node-selected: 0 0 0 2px rgba(37, 99, 235, 0.25);
36
+ --shadow-node-flow-active: 0 0 12px 2px rgba(59, 130, 246, 0.5);
36
37
 
37
38
  /* Category colors (light) */
38
39
  --cat-controller-bg: #dbeafe;
@@ -50,6 +51,9 @@
50
51
  --cat-model-bg: #fee2e2;
51
52
  --cat-model-border: #ef4444;
52
53
  --cat-model-text: #991b1b;
54
+ --cat-database-bg: #fef3c7;
55
+ --cat-database-border: #d97706;
56
+ --cat-database-text: #92400e;
53
57
  --cat-external-bg: #f3f4f6;
54
58
  --cat-external-border: #6b7280;
55
59
  --cat-external-text: #374151;
@@ -88,6 +92,7 @@
88
92
  --shadow-panel: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px -1px rgba(0, 0, 0, 0.4);
89
93
  --shadow-node: 0 1px 3px rgba(0, 0, 0, 0.3);
90
94
  --shadow-node-selected: 0 0 0 2px rgba(96, 165, 250, 0.3);
95
+ --shadow-node-flow-active: 0 0 12px 2px rgba(96, 165, 250, 0.4);
91
96
 
92
97
  /* Category colors (dark) */
93
98
  --cat-controller-bg: #1e293b;
@@ -105,6 +110,9 @@
105
110
  --cat-model-bg: #2b1111;
106
111
  --cat-model-border: #f87171;
107
112
  --cat-model-text: #fca5a5;
113
+ --cat-database-bg: #2b1f06;
114
+ --cat-database-border: #fbbf24;
115
+ --cat-database-text: #fde68a;
108
116
  --cat-external-bg: #1e2330;
109
117
  --cat-external-border: #9ca3af;
110
118
  --cat-external-text: #d1d5db;
@@ -119,6 +127,17 @@
119
127
  --cat-fallback-text: #d6d3d1;
120
128
  }
121
129
 
130
+ /* ─── Flow Animation ─── */
131
+ @keyframes dash-flow {
132
+ to { stroke-dashoffset: -20; }
133
+ }
134
+ .react-flow__edge.flow-edge-active .react-flow__edge-path {
135
+ stroke: var(--color-interactive-primary) !important;
136
+ stroke-width: 2.5 !important;
137
+ stroke-dasharray: 8 4;
138
+ animation: dash-flow 0.6s linear infinite;
139
+ }
140
+
122
141
  html,
123
142
  body,
124
143
  #root {