mcpgraph-ux 0.1.2 → 0.1.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.
@@ -18,22 +18,94 @@ import dagre from 'dagre';
18
18
  import 'reactflow/dist/style.css';
19
19
  import styles from './GraphVisualization.module.css';
20
20
 
21
+ export type NodeExecutionState = 'pending' | 'running' | 'completed' | 'error' | 'paused' | 'stopped';
22
+
23
+ export interface NodeExecutionStatus {
24
+ nodeId: string;
25
+ state: NodeExecutionState;
26
+ startTime?: number;
27
+ endTime?: number;
28
+ duration?: number;
29
+ error?: string;
30
+ }
31
+
32
+ interface NodeData {
33
+ label: string;
34
+ nodeType: string;
35
+ tool?: string;
36
+ server?: string;
37
+ args?: Record<string, unknown>;
38
+ transform?: { expr: string };
39
+ conditions?: Array<{ rule?: unknown; target: string }>;
40
+ executionState?: NodeExecutionState;
41
+ isHighlighted?: boolean;
42
+ isCurrentNode?: boolean;
43
+ hasBreakpoint?: boolean;
44
+ onToggleBreakpoint?: (nodeId: string) => void;
45
+ onNodeClick?: (nodeId: string) => void;
46
+ nodeId: string;
47
+ [key: string]: unknown;
48
+ }
49
+
21
50
  interface GraphVisualizationProps {
22
51
  nodes: Node[];
23
52
  edges: Edge[];
24
53
  selectedTool?: string | null;
54
+ executionState?: Map<string, NodeExecutionStatus>;
55
+ highlightedNode?: string | null;
56
+ breakpoints?: Set<string>;
57
+ onToggleBreakpoint?: (nodeId: string) => void;
58
+ onNodeClick?: (nodeId: string) => void;
59
+ currentNodeId?: string | null;
25
60
  }
26
61
 
27
- // Custom node styles based on node type
28
- const getNodeStyle = (nodeType: string) => {
29
- const baseStyle = {
62
+ // Custom node styles based on node type and execution state
63
+ const getNodeStyle = (nodeType: string, executionState?: NodeExecutionState) => {
64
+ const baseStyle: React.CSSProperties = {
30
65
  padding: '10px',
31
66
  borderRadius: '8px',
32
- border: '2px solid',
67
+ borderStyle: 'solid',
68
+ borderWidth: '2px',
33
69
  fontSize: '12px',
34
70
  fontWeight: 500,
71
+ transition: 'all 0.3s ease',
72
+ boxSizing: 'border-box', // Include border in width/height to prevent shifting
35
73
  };
36
74
 
75
+ // Override colors based on execution state
76
+ if (executionState === 'running') {
77
+ return {
78
+ ...baseStyle,
79
+ backgroundColor: '#fff9c4',
80
+ borderColor: '#fbc02d',
81
+ color: '#f57f17',
82
+ boxShadow: '0 0 10px rgba(251, 192, 45, 0.5)',
83
+ animation: 'pulse 1.5s ease-in-out infinite',
84
+ };
85
+ } else if (executionState === 'paused') {
86
+ return {
87
+ ...baseStyle,
88
+ backgroundColor: '#e0f2f7',
89
+ borderColor: '#00bcd4',
90
+ color: '#006064',
91
+ boxShadow: '0 0 0 3px #00bcd4',
92
+ };
93
+ } else if (executionState === 'completed') {
94
+ return {
95
+ ...baseStyle,
96
+ backgroundColor: '#c8e6c9',
97
+ borderColor: '#66bb6a',
98
+ color: '#2e7d32',
99
+ };
100
+ } else if (executionState === 'error') {
101
+ return {
102
+ ...baseStyle,
103
+ backgroundColor: '#ffcdd2',
104
+ borderColor: '#ef5350',
105
+ color: '#c62828',
106
+ };
107
+ }
108
+
37
109
  switch (nodeType) {
38
110
  case 'entry':
39
111
  return {
@@ -80,27 +152,235 @@ const getNodeStyle = (nodeType: string) => {
80
152
  }
81
153
  };
82
154
 
83
- // Custom node component with left/right handles for horizontal flow
84
- function CustomNode({ data }: { data: any }) {
155
+ // Node type icon component
156
+ function NodeTypeIcon({ nodeType }: { nodeType: string }) {
157
+ const iconStyle: React.CSSProperties = {
158
+ width: '1.5em',
159
+ height: '1.5em',
160
+ color: '#000',
161
+ flexShrink: 0,
162
+ display: 'inline-block',
163
+ };
164
+
165
+ switch (nodeType) {
166
+ case 'mcp':
167
+ return (
168
+ <svg
169
+ fill="currentColor"
170
+ fillRule="evenodd"
171
+ height="1.5em"
172
+ style={iconStyle}
173
+ viewBox="0 0 24 24"
174
+ width="1.5em"
175
+ xmlns="http://www.w3.org/2000/svg"
176
+ >
177
+ <title>ModelContextProtocol</title>
178
+ <path d="M15.688 2.343a2.588 2.588 0 00-3.61 0l-9.626 9.44a.863.863 0 01-1.203 0 .823.823 0 010-1.18l9.626-9.44a4.313 4.313 0 016.016 0 4.116 4.116 0 011.204 3.54 4.3 4.3 0 013.609 1.18l.05.05a4.115 4.115 0 010 5.9l-8.706 8.537a.274.274 0 000 .393l1.788 1.754a.823.823 0 010 1.18.863.863 0 01-1.203 0l-1.788-1.753a1.92 1.92 0 010-2.754l8.706-8.538a2.47 2.47 0 000-3.54l-.05-.049a2.588 2.588 0 00-3.607-.003l-7.172 7.034-.002.002-.098.097a.863.863 0 01-1.204 0 .823.823 0 010-1.18l7.273-7.133a2.47 2.47 0 00-.003-3.537z"></path>
179
+ <path d="M14.485 4.703a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a4.115 4.115 0 000 5.9 4.314 4.314 0 006.016 0l7.12-6.982a.823.823 0 000-1.18.863.863 0 00-1.204 0l-7.119 6.982a2.588 2.588 0 01-3.61 0 2.47 2.47 0 010-3.54l7.12-6.982z"></path>
180
+ </svg>
181
+ );
182
+ case 'entry':
183
+ return (
184
+ <svg
185
+ fill="currentColor"
186
+ height="1.5em"
187
+ style={iconStyle}
188
+ viewBox="0 0 24 24"
189
+ width="1.5em"
190
+ xmlns="http://www.w3.org/2000/svg"
191
+ >
192
+ <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2"/>
193
+ <path d="M10 8l6 4-6 4V8z" />
194
+ </svg>
195
+ );
196
+ case 'exit':
197
+ return (
198
+ <svg
199
+ fill="currentColor"
200
+ height="1.5em"
201
+ style={iconStyle}
202
+ viewBox="0 0 24 24"
203
+ width="1.5em"
204
+ xmlns="http://www.w3.org/2000/svg"
205
+ >
206
+ <circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2"/>
207
+ <rect x="9" y="9" width="6" height="6" fill="currentColor"/>
208
+ </svg>
209
+ );
210
+ case 'transform':
211
+ return (
212
+ <svg
213
+ fill="currentColor"
214
+ height="1.5em"
215
+ style={iconStyle}
216
+ viewBox="0 0 24 24"
217
+ width="1.5em"
218
+ xmlns="http://www.w3.org/2000/svg"
219
+ >
220
+ <path d="M10 18h4v-2h-4v2zM3 6v2h18V6H3zm3 7h12v-2H6v2z" />
221
+ </svg>
222
+ );
223
+ case 'switch':
224
+ return (
225
+ <svg
226
+ fill="currentColor"
227
+ height="1.5em"
228
+ style={iconStyle}
229
+ viewBox="0 0 24 24"
230
+ width="1.5em"
231
+ xmlns="http://www.w3.org/2000/svg"
232
+ >
233
+ <path d="M12 2L2 12l10 10 10-10L12 2zm0 2.83L19.17 12 12 19.17 4.83 12 12 4.83z" />
234
+ </svg>
235
+ );
236
+ default:
237
+ return null;
238
+ }
239
+ }
240
+
241
+ // Custom node component with top/bottom handles for vertical flow
242
+ function CustomNode({ data }: { data: NodeData }) {
85
243
  const nodeType = data.nodeType || 'unknown';
86
- const style = getNodeStyle(nodeType);
244
+ const executionState = data.executionState;
245
+ const isHighlighted = data.isHighlighted;
246
+ const isCurrentNode = data.isCurrentNode;
247
+ const hasBreakpoint = data.hasBreakpoint;
248
+ const onToggleBreakpoint = data.onToggleBreakpoint;
249
+ const onNodeClick = data.onNodeClick;
250
+ const nodeId = data.nodeId;
251
+
252
+ const baseStyle = getNodeStyle(nodeType, executionState);
253
+
254
+ // Make current node very obvious with outline (doesn't affect layout)
255
+ let style: React.CSSProperties = { ...baseStyle };
256
+ if (isCurrentNode) {
257
+ // Use outline to create thicker border effect without changing element size
258
+ style = {
259
+ ...baseStyle,
260
+ outline: '4px solid',
261
+ outlineColor: baseStyle.borderColor as string || '#333',
262
+ outlineOffset: '-2px', // Overlap with border to create thicker effect
263
+ zIndex: 10,
264
+ };
265
+ }
266
+
267
+ if (isHighlighted) {
268
+ // Use outline for highlight to avoid layout shift (outline doesn't affect box model)
269
+ style = {
270
+ ...style,
271
+ outline: isCurrentNode ? '4px solid rgba(255, 193, 7, 0.8)' : '3px solid rgba(255, 193, 7, 0.5)',
272
+ outlineOffset: isCurrentNode ? '-2px' : '2px',
273
+ boxShadow: '0 0 20px rgba(255, 193, 7, 0.3)',
274
+ zIndex: 10,
275
+ };
276
+ }
87
277
  const isEntry = nodeType === 'entry';
88
278
  const isExit = nodeType === 'exit';
89
279
 
280
+ // Add status indicator
281
+ const statusIndicator = executionState === 'running' ? '⏳' :
282
+ executionState === 'completed' ? '✓' :
283
+ executionState === 'error' ? '✗' : null;
284
+
285
+ const handleBreakpointClick = (e: React.MouseEvent) => {
286
+ e.stopPropagation();
287
+ if (onToggleBreakpoint && nodeId) {
288
+ onToggleBreakpoint(nodeId);
289
+ }
290
+ };
291
+
292
+ const handleNodeClick = (e: React.MouseEvent) => {
293
+ // Don't trigger node click if clicking on breakpoint
294
+ if (e.target instanceof HTMLElement && e.target.closest('button')) {
295
+ return;
296
+ }
297
+ if (onNodeClick && nodeId) {
298
+ onNodeClick(nodeId);
299
+ }
300
+ };
301
+
90
302
  return (
91
- <div style={style}>
303
+ <div style={{ ...style, cursor: onNodeClick ? 'pointer' : 'default' }} onClick={handleNodeClick}>
92
304
  {!isEntry && (
93
305
  <Handle
94
306
  type="target"
95
- position={Position.Left}
307
+ position={Position.Top}
96
308
  style={{ background: '#555' }}
97
309
  />
98
310
  )}
99
- <div>{data.label}</div>
311
+ <div style={{ display: 'flex', alignItems: 'center', gap: '5px', width: '100%' }}>
312
+ {/* Node type icon at far left */}
313
+ <NodeTypeIcon nodeType={nodeType} />
314
+
315
+ {/* Label */}
316
+ <span>{data.label}</span>
317
+
318
+ {/* Duration */}
319
+ {typeof data.duration === 'number' && (
320
+ <span style={{ fontSize: '10px', opacity: 0.7 }}>
321
+ ({data.duration}ms)
322
+ </span>
323
+ )}
324
+
325
+ {/* Breakpoint button */}
326
+ {onToggleBreakpoint && (
327
+ <button
328
+ onClick={handleBreakpointClick}
329
+ style={{
330
+ width: 24,
331
+ height: 24,
332
+ borderRadius: '50%',
333
+ cursor: 'pointer',
334
+ padding: 0,
335
+ display: 'flex',
336
+ alignItems: 'center',
337
+ justifyContent: 'center',
338
+ border: 'none',
339
+ background: 'transparent',
340
+ flexShrink: 0,
341
+ }}
342
+ title={hasBreakpoint ? 'Click to remove breakpoint' : 'Click to add breakpoint'}
343
+ >
344
+ <span
345
+ style={{
346
+ width: 12,
347
+ height: 12,
348
+ borderRadius: '50%',
349
+ background: hasBreakpoint ? '#ef5350' : 'transparent',
350
+ border: hasBreakpoint ? '2px solid white' : '1px solid #999',
351
+ display: 'flex',
352
+ alignItems: 'center',
353
+ justifyContent: 'center',
354
+ fontSize: hasBreakpoint ? '8px' : '0',
355
+ boxShadow: hasBreakpoint ? '0 2px 4px rgba(0,0,0,0.2)' : 'none',
356
+ opacity: hasBreakpoint ? 1 : 0.5,
357
+ transition: 'opacity 0.2s, background 0.2s, border 0.2s',
358
+ }}
359
+ onMouseEnter={(e) => {
360
+ if (!hasBreakpoint) {
361
+ e.currentTarget.style.opacity = '1';
362
+ }
363
+ }}
364
+ onMouseLeave={(e) => {
365
+ if (!hasBreakpoint) {
366
+ e.currentTarget.style.opacity = '0.5';
367
+ }
368
+ }}
369
+ >
370
+ {hasBreakpoint && '●'}
371
+ </span>
372
+ </button>
373
+ )}
374
+
375
+ {/* Status indicators (running/completed/error) at far right */}
376
+ {statusIndicator && (
377
+ <span style={{ marginLeft: 'auto' }}>{statusIndicator}</span>
378
+ )}
379
+ </div>
100
380
  {!isExit && (
101
381
  <Handle
102
382
  type="source"
103
- position={Position.Right}
383
+ position={Position.Bottom}
104
384
  style={{ background: '#555' }}
105
385
  />
106
386
  )}
@@ -108,6 +388,7 @@ function CustomNode({ data }: { data: any }) {
108
388
  );
109
389
  }
110
390
 
391
+ // Define nodeTypes outside component to avoid React Flow warning
111
392
  const nodeTypes = {
112
393
  custom: CustomNode,
113
394
  };
@@ -118,9 +399,9 @@ const layoutNodes = (nodes: Node[], edges: Edge[]): Node[] => {
118
399
  const g = new dagre.graphlib.Graph();
119
400
  g.setDefaultEdgeLabel(() => ({}));
120
401
  g.setGraph({
121
- rankdir: 'LR', // Left to right layout
122
- nodesep: 100, // Horizontal spacing between nodes
123
- ranksep: 150, // Vertical spacing between ranks
402
+ rankdir: 'TB', // Top to bottom layout
403
+ nodesep: 50, // Vertical spacing between nodes
404
+ ranksep: 75, // Horizontal spacing between ranks
124
405
  marginx: 50,
125
406
  marginy: 50,
126
407
  });
@@ -128,7 +409,8 @@ const layoutNodes = (nodes: Node[], edges: Edge[]): Node[] => {
128
409
  // Add nodes to dagre graph
129
410
  nodes.forEach(node => {
130
411
  // Estimate node dimensions (dagre needs width/height)
131
- const nodeType = (node.data as any)?.nodeType || 'unknown';
412
+ const nodeData = node.data as NodeData;
413
+ const nodeType = nodeData?.nodeType || 'unknown';
132
414
  const label = node.id;
133
415
  // Rough estimate: ~10px per character + padding
134
416
  const width = Math.max(150, label.length * 8 + 40);
@@ -147,7 +429,8 @@ const layoutNodes = (nodes: Node[], edges: Edge[]): Node[] => {
147
429
 
148
430
  // Map dagre positions back to React Flow nodes
149
431
  const positionedNodes = nodes.map(node => {
150
- const nodeType = (node.data as any)?.nodeType || 'unknown';
432
+ const nodeData = node.data as NodeData;
433
+ const nodeType = nodeData?.nodeType || 'unknown';
151
434
  const dagreNode = g.node(node.id);
152
435
 
153
436
  return {
@@ -171,10 +454,38 @@ export default function GraphVisualization({
171
454
  nodes,
172
455
  edges,
173
456
  selectedTool,
457
+ executionState,
458
+ highlightedNode,
459
+ breakpoints,
460
+ onToggleBreakpoint,
461
+ onNodeClick,
462
+ currentNodeId,
174
463
  }: GraphVisualizationProps) {
175
464
  const layoutedNodes = useMemo(() => layoutNodes(nodes, edges), [nodes, edges]);
176
465
 
177
- const [flowNodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
466
+ // Merge execution state, highlight, and breakpoints into nodes
467
+ const nodesWithExecutionState = useMemo(() => {
468
+ return layoutedNodes.map(node => {
469
+ const status = executionState?.get(node.id);
470
+ const isCurrentNode = currentNodeId === node.id;
471
+ return {
472
+ ...node,
473
+ data: {
474
+ ...node.data,
475
+ nodeId: node.id,
476
+ executionState: status?.state || 'pending',
477
+ duration: status?.duration,
478
+ isHighlighted: highlightedNode === node.id,
479
+ isCurrentNode, // Mark current node explicitly
480
+ hasBreakpoint: breakpoints?.has(node.id) || false,
481
+ onToggleBreakpoint,
482
+ onNodeClick,
483
+ },
484
+ };
485
+ });
486
+ }, [layoutedNodes, executionState, highlightedNode, breakpoints, onToggleBreakpoint, onNodeClick, currentNodeId]);
487
+
488
+ const [flowNodes, setNodes, onNodesChange] = useNodesState(nodesWithExecutionState);
178
489
  const [flowEdges, setEdges, onEdgesChange] = useEdgesState(
179
490
  edges.map(edge => ({
180
491
  ...edge,
@@ -189,7 +500,26 @@ export default function GraphVisualization({
189
500
 
190
501
  useEffect(() => {
191
502
  const layouted = layoutNodes(nodes, edges);
192
- setNodes(layouted);
503
+ // Merge execution state, highlight, and breakpoints into nodes
504
+ const nodesWithState = layouted.map(node => {
505
+ const status = executionState?.get(node.id);
506
+ const isCurrentNode = currentNodeId === node.id;
507
+ return {
508
+ ...node,
509
+ data: {
510
+ ...node.data,
511
+ nodeId: node.id,
512
+ executionState: status?.state || 'pending',
513
+ duration: status?.duration,
514
+ isHighlighted: highlightedNode === node.id,
515
+ isCurrentNode, // Mark current node explicitly
516
+ hasBreakpoint: breakpoints?.has(node.id) || false,
517
+ onToggleBreakpoint,
518
+ onNodeClick,
519
+ },
520
+ };
521
+ });
522
+ setNodes(nodesWithState);
193
523
  setEdges(
194
524
  edges.map(edge => ({
195
525
  ...edge,
@@ -201,59 +531,12 @@ export default function GraphVisualization({
201
531
  style: { strokeWidth: 2 },
202
532
  }))
203
533
  );
204
- }, [nodes, edges, setNodes, setEdges]);
534
+ }, [nodes, edges, executionState, highlightedNode, breakpoints, onToggleBreakpoint, onNodeClick, currentNodeId, setNodes, setEdges]);
205
535
 
206
- // Filter nodes/edges for selected tool if provided
207
- const filteredNodes = useMemo(() => {
208
- if (!selectedTool) return flowNodes;
209
- return flowNodes.filter(node => {
210
- const data = node.data as any;
211
- return (
212
- (data.nodeType === 'entry' && data.tool === selectedTool) ||
213
- (data.nodeType === 'exit' && data.tool === selectedTool) ||
214
- flowEdges.some(edge => {
215
- // Include nodes that are reachable from entry or lead to exit
216
- const entryNode = flowNodes.find(
217
- n => (n.data as any)?.nodeType === 'entry' && (n.data as any)?.tool === selectedTool
218
- );
219
- const exitNode = flowNodes.find(
220
- n => (n.data as any)?.nodeType === 'exit' && (n.data as any)?.tool === selectedTool
221
- );
222
-
223
- if (!entryNode || !exitNode) return false;
224
-
225
- // Simple reachability check
226
- const visited = new Set<string>();
227
- const queue = [entryNode.id];
228
- visited.add(entryNode.id);
229
-
230
- while (queue.length > 0) {
231
- const current = queue.shift()!;
232
- if (current === node.id) return true;
233
-
234
- flowEdges
235
- .filter(e => e.source === current)
236
- .forEach(e => {
237
- if (!visited.has(e.target)) {
238
- visited.add(e.target);
239
- queue.push(e.target);
240
- }
241
- });
242
- }
243
-
244
- return false;
245
- })
246
- );
247
- });
248
- }, [flowNodes, flowEdges, selectedTool]);
249
-
250
- const filteredEdges = useMemo(() => {
251
- if (!selectedTool) return flowEdges;
252
- const filteredNodeIds = new Set(filteredNodes.map(n => n.id));
253
- return flowEdges.filter(
254
- edge => filteredNodeIds.has(edge.source) && filteredNodeIds.has(edge.target)
255
- );
256
- }, [flowEdges, filteredNodes, selectedTool]);
536
+ // No need to filter - the API already returns nodes for the selected tool
537
+ // The selectedTool prop is kept for potential future use but filtering is done server-side
538
+ const filteredNodes = flowNodes;
539
+ const filteredEdges = flowEdges;
257
540
 
258
541
  return (
259
542
  <div className={styles.container}>
@@ -0,0 +1,146 @@
1
+ .container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 12px;
5
+ }
6
+
7
+ .form {
8
+ display: flex;
9
+ flex-direction: column;
10
+ gap: 16px;
11
+ }
12
+
13
+ .inputs {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: 16px;
17
+ }
18
+
19
+ .field {
20
+ display: flex;
21
+ flex-direction: column;
22
+ gap: 4px;
23
+ }
24
+
25
+ .label {
26
+ font-weight: 600;
27
+ color: #333;
28
+ font-size: 0.9rem;
29
+ }
30
+
31
+ .checkboxLabel {
32
+ display: flex;
33
+ align-items: center;
34
+ gap: 8px;
35
+ font-weight: 600;
36
+ color: #333;
37
+ font-size: 0.9rem;
38
+ cursor: pointer;
39
+ }
40
+
41
+ .required {
42
+ color: #d32f2f;
43
+ margin-left: 4px;
44
+ }
45
+
46
+ .input {
47
+ padding: 8px 12px;
48
+ border: 1px solid #ddd;
49
+ border-radius: 4px;
50
+ font-size: 0.9rem;
51
+ font-family: inherit;
52
+ }
53
+
54
+ .input:focus {
55
+ outline: none;
56
+ border-color: #2196f3;
57
+ box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
58
+ }
59
+
60
+ .input:disabled {
61
+ background-color: #f5f5f5;
62
+ cursor: not-allowed;
63
+ }
64
+
65
+ .textarea {
66
+ padding: 8px 12px;
67
+ border: 1px solid #ddd;
68
+ border-radius: 4px;
69
+ font-size: 0.9rem;
70
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
71
+ resize: vertical;
72
+ min-height: 60px;
73
+ }
74
+
75
+ .textarea:focus {
76
+ outline: none;
77
+ border-color: #2196f3;
78
+ box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.1);
79
+ }
80
+
81
+ .textarea:disabled {
82
+ background-color: #f5f5f5;
83
+ cursor: not-allowed;
84
+ }
85
+
86
+ .checkbox {
87
+ width: 18px;
88
+ height: 18px;
89
+ cursor: pointer;
90
+ }
91
+
92
+ .checkbox:disabled {
93
+ cursor: not-allowed;
94
+ }
95
+
96
+ .hint {
97
+ font-size: 0.85rem;
98
+ color: #666;
99
+ font-style: italic;
100
+ }
101
+
102
+ .loading {
103
+ padding: 1rem;
104
+ text-align: center;
105
+ color: #666;
106
+ }
107
+
108
+ .error {
109
+ padding: 1rem;
110
+ background-color: #ffebee;
111
+ color: #c62828;
112
+ border-radius: 4px;
113
+ border: 1px solid #ffcdd2;
114
+ display: flex;
115
+ align-items: center;
116
+ justify-content: space-between;
117
+ gap: 12px;
118
+ }
119
+
120
+ .dismissButton {
121
+ background: none;
122
+ border: none;
123
+ color: #c62828;
124
+ font-size: 1.5rem;
125
+ line-height: 1;
126
+ cursor: pointer;
127
+ padding: 0;
128
+ width: 24px;
129
+ height: 24px;
130
+ display: flex;
131
+ align-items: center;
132
+ justify-content: center;
133
+ border-radius: 4px;
134
+ flex-shrink: 0;
135
+ }
136
+
137
+ .dismissButton:hover {
138
+ background-color: rgba(198, 40, 40, 0.1);
139
+ }
140
+
141
+ .dismissButton:focus {
142
+ outline: 2px solid #c62828;
143
+ outline-offset: 2px;
144
+ }
145
+
146
+