@liduchuan/atlas-loop 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,1339 @@
1
+ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import {
4
+ ReactFlow,
5
+ ReactFlowProvider,
6
+ Background,
7
+ MiniMap,
8
+ Handle,
9
+ Position,
10
+ MarkerType,
11
+ useNodesState,
12
+ useEdgesState,
13
+ useReactFlow,
14
+ } from '@xyflow/react';
15
+ import dagre from '@dagrejs/dagre';
16
+
17
+ const I18N_DICT = {
18
+ zh: {
19
+ skipToCanvas: '跳转到交互画布',
20
+ modelPrefix: '生成模型:',
21
+ backParent: '返回上级地图',
22
+ levelOverview: 'L0 全景图谱',
23
+ levelSubPrefix: 'L1+ 深度专题:',
24
+ allStreams: '全部脉络',
25
+ searchLabel: '搜索节点与提示词',
26
+ searchPlaceholder: '搜索概念、机制、路径或追问…',
27
+ zoomIn: '放大',
28
+ zoomOut: '缩小',
29
+ lock: '锁定位置',
30
+ unlock: '已锁定',
31
+ fitView: '适配全图',
32
+ reset: '重置',
33
+ legend: '图例',
34
+ scopeNotes: '范围与待确认',
35
+ learningPath: '推荐顺序',
36
+ expandDetail: '展开细节',
37
+ collapseDetail: '收起细节',
38
+ detailEdgeLabel: '下钻细节',
39
+ deepDiveBadgeTitle: '打开继续深挖提示词',
40
+ deepDiveModalTitle: '继续深潜(Atlas Loop)',
41
+ tabAsk: '定向追问',
42
+ tabRecursive: '递归生成子地图',
43
+ copyBtn: '复制提示词',
44
+ copiedBtn: '已复制',
45
+ copySnippet: '复制片段',
46
+ openInEditor: '在编辑器中打开',
47
+ editorConfirmHint: '首次点击时浏览器可能会请求打开外部编辑器权限;若文件移动请重新生成。',
48
+ recursiveUsageHint: '将下方提示词发送给 AI,即可围绕当前节点递归生成下一层独立交互式 HTML 子地图:',
49
+ whatItDoes: '核心说明',
50
+ whyItMatters: '关键价值',
51
+ inputsLabel: '前置 / 输入',
52
+ outputsLabel: '结果 / 产出',
53
+ mechanismsLabel: '内部机制与影响',
54
+ watchoutsLabel: '理解注意点',
55
+ anchorsLabel: '关联依据与锚点',
56
+ openDeepDiveBtn: '查看进阶追问与递归指令',
57
+ unverifiedTag: '待确认',
58
+ verifiedTag: '已核实',
59
+ uncoveredTitle: '未覆盖范围',
60
+ unverifiedTitle: '待确认项说明',
61
+ noSearchMatches: '无匹配节点',
62
+ nestedBadge: '内部细节',
63
+ close: '关闭',
64
+ roles: {
65
+ entry: '起点 / 命题',
66
+ core: '核心机制',
67
+ branch: '判断 / 分岔',
68
+ foundation: '底层基石',
69
+ context: '外部关联',
70
+ extension: '延伸演化',
71
+ outcome: '最终结论',
72
+ },
73
+ },
74
+ en: {
75
+ skipToCanvas: 'Skip to interactive canvas',
76
+ modelPrefix: 'Model: ',
77
+ backParent: 'Back to Parent Atlas',
78
+ levelOverview: 'L0 Overview',
79
+ levelSubPrefix: 'L1+ Focus: ',
80
+ allStreams: 'All Streams',
81
+ searchLabel: 'Search nodes & prompts',
82
+ searchPlaceholder: 'Search concepts, paths, or prompts…',
83
+ zoomIn: 'Zoom In',
84
+ zoomOut: 'Zoom Out',
85
+ lock: 'Lock',
86
+ unlock: 'Locked',
87
+ fitView: 'Fit View',
88
+ reset: 'Reset',
89
+ legend: 'Legend',
90
+ scopeNotes: 'Scope & Notes',
91
+ learningPath: 'Learning Order',
92
+ expandDetail: 'Details',
93
+ collapseDetail: 'Collapse',
94
+ detailEdgeLabel: 'Drill-down',
95
+ deepDiveBadgeTitle: 'Open deep-dive prompts',
96
+ deepDiveModalTitle: 'Deep Dive (Atlas Loop)',
97
+ tabAsk: 'Follow-Up Prompts',
98
+ tabRecursive: 'Recursive Sub-Atlas',
99
+ copyBtn: 'Copy Prompt',
100
+ copiedBtn: 'Copied',
101
+ copySnippet: 'Copy Snippet',
102
+ openInEditor: 'Open in Editor',
103
+ editorConfirmHint: 'Your browser may ask permission to open the external editor. Regenerate if files move.',
104
+ recursiveUsageHint: 'Send this prompt to your AI agent to generate a linked child HTML atlas for this node:',
105
+ whatItDoes: 'Summary',
106
+ whyItMatters: 'Why It Matters',
107
+ inputsLabel: 'Inputs / Conditions',
108
+ outputsLabel: 'Outputs / Effects',
109
+ mechanismsLabel: 'Mechanism & Side Effects',
110
+ watchoutsLabel: 'Watchouts',
111
+ anchorsLabel: 'Evidence & Anchors',
112
+ openDeepDiveBtn: 'View Deep-Dive & Recursive Prompts',
113
+ unverifiedTag: 'Unverified',
114
+ verifiedTag: 'Verified',
115
+ uncoveredTitle: 'Uncovered Scope',
116
+ unverifiedTitle: 'Unverified Items',
117
+ noSearchMatches: 'No matching nodes',
118
+ nestedBadge: 'Sub-node',
119
+ close: 'Close',
120
+ roles: {
121
+ entry: 'Entry / Thesis',
122
+ core: 'Core Mechanism',
123
+ branch: 'Decision / Branch',
124
+ foundation: 'Foundation',
125
+ context: 'Context',
126
+ extension: 'Extension',
127
+ outcome: 'Outcome',
128
+ },
129
+ },
130
+ };
131
+
132
+ function resolveI18n(meta = {}) {
133
+ const langRaw = String(meta.language || 'en').toLowerCase();
134
+ const base = langRaw.startsWith('zh') ? I18N_DICT.zh : I18N_DICT.en;
135
+ const custom = meta.uiLabels && typeof meta.uiLabels === 'object' ? meta.uiLabels : {};
136
+ return {
137
+ ...base,
138
+ ...custom,
139
+ roles: { ...base.roles, ...(custom.roles || {}) },
140
+ };
141
+ }
142
+
143
+ // Restrained monochrome + muted semantic indicator dots (no rainbow card fills)
144
+ const ROLE_DOT_COLORS = {
145
+ entry: '#18181b',
146
+ core: '#52525b',
147
+ branch: '#b45309',
148
+ foundation: '#334155',
149
+ context: '#a1a1aa',
150
+ extension: '#6366f1',
151
+ outcome: '#15803d',
152
+ };
153
+
154
+ function IconSearch() {
155
+ return (
156
+ <svg className="atlas-search-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
157
+ <circle cx="11" cy="11" r="8" />
158
+ <path d="m21 21-4.3-4.3" />
159
+ </svg>
160
+ );
161
+ }
162
+
163
+ function IconLock({ locked }) {
164
+ return locked ? (
165
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
166
+ <rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
167
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
168
+ </svg>
169
+ ) : (
170
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
171
+ <rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
172
+ <path d="M7 11V7a5 5 0 0 1 9.9-1" />
173
+ </svg>
174
+ );
175
+ }
176
+
177
+ function IconArrowLeft() {
178
+ return (
179
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
180
+ <path d="m15 18-6-6 6-6" />
181
+ </svg>
182
+ );
183
+ }
184
+
185
+ function copyToClipboard(text) {
186
+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
187
+ return navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
188
+ }
189
+ return Promise.resolve(fallbackCopy(text));
190
+ }
191
+
192
+ function fallbackCopy(text) {
193
+ const ta = document.createElement('textarea');
194
+ ta.value = text;
195
+ ta.setAttribute('readonly', 'true');
196
+ ta.style.position = 'fixed';
197
+ ta.style.top = '-9999px';
198
+ ta.style.left = '-9999px';
199
+ document.body.appendChild(ta);
200
+ ta.select();
201
+ try {
202
+ document.execCommand('copy');
203
+ } catch (_) {
204
+ // ignore
205
+ }
206
+ document.body.removeChild(ta);
207
+ return true;
208
+ }
209
+
210
+ function buildGraphElements(data, activeStream, expandedIds, t, handlers) {
211
+ const g = new dagre.graphlib.Graph();
212
+ g.setDefaultEdgeLabel(() => ({}));
213
+ g.setGraph({
214
+ rankdir: 'LR',
215
+ nodesep: 64,
216
+ ranksep: 116,
217
+ marginx: 44,
218
+ marginy: 44,
219
+ });
220
+
221
+ const rawNodes = Array.isArray(data.nodes) ? data.nodes : [];
222
+ const rawEdges = Array.isArray(data.edges) ? data.edges : [];
223
+
224
+ const visibleTopNodes = rawNodes.filter((n) => {
225
+ if (activeStream === 'all') return true;
226
+ return Array.isArray(n.streamIds) && n.streamIds.includes(activeStream);
227
+ });
228
+
229
+ const visibleTopSet = new Set(visibleTopNodes.map((n) => n.id));
230
+ const flowNodes = [];
231
+ const flowEdges = [];
232
+
233
+ for (const n of visibleTopNodes) {
234
+ const detailCount = Array.isArray(n.detailGraph?.nodes) ? n.detailGraph.nodes.length : 0;
235
+ const isExpanded = expandedIds.has(n.id) && detailCount > 0;
236
+ const width = 296;
237
+ const height = 144;
238
+ g.setNode(n.id, { width, height });
239
+
240
+ flowNodes.push({
241
+ id: n.id,
242
+ type: 'atlasNode',
243
+ position: { x: 0, y: 0 },
244
+ data: {
245
+ item: n,
246
+ isSubNode: false,
247
+ detailCount,
248
+ isExpanded,
249
+ t,
250
+ ...handlers,
251
+ },
252
+ style: { width, minHeight: height },
253
+ });
254
+
255
+ if (isExpanded) {
256
+ const subNodes = n.detailGraph.nodes || [];
257
+ const subEdges = n.detailGraph.edges || [];
258
+ const incomingCount = new Map();
259
+ for (const sn of subNodes) incomingCount.set(sn.id, 0);
260
+ for (const se of subEdges) {
261
+ if (incomingCount.has(se.target)) {
262
+ incomingCount.set(se.target, incomingCount.get(se.target) + 1);
263
+ }
264
+ }
265
+
266
+ for (const sn of subNodes) {
267
+ const subFlowId = `${n.id}::${sn.id}`;
268
+ const sw = 264;
269
+ const sh = 120;
270
+ g.setNode(subFlowId, { width: sw, height: sh });
271
+ flowNodes.push({
272
+ id: subFlowId,
273
+ type: 'atlasNode',
274
+ position: { x: 0, y: 0 },
275
+ data: {
276
+ item: {
277
+ ...sn,
278
+ parentTitle: n.title,
279
+ parentId: n.id,
280
+ },
281
+ isSubNode: true,
282
+ detailCount: 0,
283
+ isExpanded: false,
284
+ t,
285
+ ...handlers,
286
+ },
287
+ style: { width: sw, minHeight: sh },
288
+ });
289
+
290
+ if ((incomingCount.get(sn.id) || 0) === 0) {
291
+ const bridgeId = `bridge::${n.id}->${subFlowId}`;
292
+ g.setEdge(n.id, subFlowId);
293
+ flowEdges.push({
294
+ id: bridgeId,
295
+ source: n.id,
296
+ target: subFlowId,
297
+ label: t.detailEdgeLabel,
298
+ style: { stroke: '#71717a', strokeWidth: 1.4, strokeDasharray: '4 4' },
299
+ labelStyle: { fill: '#52525b', fontSize: 10.5, fontWeight: 500 },
300
+ labelBgStyle: { fill: '#fafafa', fillOpacity: 0.94 },
301
+ labelBgPadding: [5, 2],
302
+ markerEnd: { type: MarkerType.ArrowClosed, color: '#71717a' },
303
+ });
304
+ }
305
+ }
306
+
307
+ for (let idx = 0; idx < subEdges.length; idx++) {
308
+ const se = subEdges[idx];
309
+ const sId = `${n.id}::${se.source}`;
310
+ const tId = `${n.id}::${se.target}`;
311
+ g.setEdge(sId, tId);
312
+ flowEdges.push({
313
+ id: `subedge::${n.id}::${se.id || idx}`,
314
+ source: sId,
315
+ target: tId,
316
+ label: se.label || '',
317
+ style: { stroke: '#a1a1aa', strokeWidth: 1.4 },
318
+ labelStyle: { fill: '#52525b', fontSize: 10.5, fontWeight: 500 },
319
+ labelBgStyle: { fill: '#ffffff', fillOpacity: 0.94 },
320
+ labelBgPadding: [5, 2],
321
+ markerEnd: { type: MarkerType.ArrowClosed, color: '#a1a1aa' },
322
+ });
323
+ }
324
+ }
325
+ }
326
+
327
+ for (let idx = 0; idx < rawEdges.length; idx++) {
328
+ const e = rawEdges[idx];
329
+ if (!visibleTopSet.has(e.source) || !visibleTopSet.has(e.target)) continue;
330
+ g.setEdge(e.source, e.target);
331
+ flowEdges.push({
332
+ id: e.id || `edge-${idx}`,
333
+ source: e.source,
334
+ target: e.target,
335
+ label: e.label || '',
336
+ style: { stroke: '#71717a', strokeWidth: 1.5 },
337
+ labelStyle: { fill: '#27272a', fontSize: 11, fontWeight: 500 },
338
+ labelBgStyle: { fill: '#ffffff', fillOpacity: 0.95 },
339
+ labelBgPadding: [6, 3],
340
+ labelBgBorderRadius: 4,
341
+ markerEnd: { type: MarkerType.ArrowClosed, color: '#71717a' },
342
+ });
343
+ }
344
+
345
+ dagre.layout(g);
346
+
347
+ const positionedNodes = flowNodes.map((fn) => {
348
+ const pos = g.node(fn.id);
349
+ const w = fn.style.width || 296;
350
+ const h = fn.style.minHeight || 144;
351
+ return {
352
+ ...fn,
353
+ targetPosition: Position.Left,
354
+ sourcePosition: Position.Right,
355
+ position: {
356
+ x: Math.round((pos ? pos.x : 0) - w / 2),
357
+ y: Math.round((pos ? pos.y : 0) - h / 2),
358
+ },
359
+ };
360
+ });
361
+
362
+ return { nodes: positionedNodes, edges: flowEdges };
363
+ }
364
+
365
+ function AtlasNode({ id, data, selected }) {
366
+ const { item, isSubNode, detailCount, isExpanded, t, onToggleExpand, onOpenDeepDive, onSelectNode } = data;
367
+ const [hoveredHelp, setHoveredHelp] = useState(false);
368
+ const roleKey = ROLE_DOT_COLORS[item.role] ? item.role : 'core';
369
+ const dotColor = ROLE_DOT_COLORS[roleKey];
370
+ const hasDeepDive = Boolean(
371
+ item.deepDive &&
372
+ ((Array.isArray(item.deepDive.askPrompts) && item.deepDive.askPrompts.length > 0) || item.deepDive.subAtlasPrompt)
373
+ );
374
+ const anchorCount = Array.isArray(item.anchors) ? item.anchors.length : 0;
375
+
376
+ return (
377
+ <div
378
+ data-testid={isSubNode ? 'atlas-subnode-card' : 'atlas-node-card'}
379
+ data-node-id={id}
380
+ className={`atlas-node ${isSubNode ? 'atlas-node-sub' : ''} ${selected ? 'atlas-node-selected' : ''}`}
381
+ >
382
+ <Handle type="target" position={Position.Left} className="atlas-handle" />
383
+
384
+ <div className="atlas-node-header">
385
+ <div className="atlas-node-badges">
386
+ <span className="atlas-role-badge">
387
+ <span className="atlas-role-dot" style={{ backgroundColor: dotColor }} aria-hidden="true" />
388
+ <span>{t.roles[roleKey] || item.role}</span>
389
+ </span>
390
+ {isSubNode && <span className="atlas-sub-pill">{t.nestedBadge}</span>}
391
+ {item.confidence === 'unverified' && (
392
+ <span className="atlas-unverified-pill">{t.unverifiedTag}</span>
393
+ )}
394
+ </div>
395
+
396
+ {hasDeepDive && (
397
+ <div className="atlas-help-wrap nodrag nopan">
398
+ <button
399
+ type="button"
400
+ data-testid="node-deep-dive-btn"
401
+ data-for-node={id}
402
+ aria-label={`${t.deepDiveBadgeTitle}: ${item.title}`}
403
+ className="atlas-help-btn nodrag nopan"
404
+ onMouseEnter={() => setHoveredHelp(true)}
405
+ onMouseLeave={() => setHoveredHelp(false)}
406
+ onFocus={() => setHoveredHelp(true)}
407
+ onBlur={() => setHoveredHelp(false)}
408
+ onPointerDown={(e) => e.stopPropagation()}
409
+ onMouseDown={(e) => e.stopPropagation()}
410
+ onClick={(e) => {
411
+ e.stopPropagation();
412
+ setHoveredHelp(false);
413
+ onOpenDeepDive({
414
+ title: item.title,
415
+ deepDive: item.deepDive,
416
+ });
417
+ }}
418
+ >
419
+ ?
420
+ </button>
421
+ {hoveredHelp && (
422
+ <div role="tooltip" data-testid="deep-dive-tooltip" className="atlas-help-tooltip">
423
+ {item.deepDive.hoverHint || t.deepDiveBadgeTitle}
424
+ </div>
425
+ )}
426
+ </div>
427
+ )}
428
+ </div>
429
+
430
+ <div className="atlas-node-body">
431
+ <div className="atlas-node-title">{item.title}</div>
432
+ {item.summary && <div className="atlas-node-summary">{item.summary}</div>}
433
+ </div>
434
+
435
+ {(detailCount > 0 || anchorCount > 0) && (
436
+ <div className="atlas-node-footer nodrag nopan">
437
+ {detailCount > 0 ? (
438
+ <button
439
+ type="button"
440
+ data-testid="node-expand-btn"
441
+ data-for-node={id}
442
+ aria-expanded={isExpanded}
443
+ className={`atlas-expand-btn nodrag nopan ${isExpanded ? 'active' : ''}`}
444
+ onPointerDown={(e) => e.stopPropagation()}
445
+ onMouseDown={(e) => e.stopPropagation()}
446
+ onClick={(e) => {
447
+ e.stopPropagation();
448
+ onToggleExpand(id);
449
+ }}
450
+ >
451
+ {isExpanded ? `${t.collapseDetail} (${detailCount})` : `${t.expandDetail} (${detailCount})`}
452
+ </button>
453
+ ) : (
454
+ <span />
455
+ )}
456
+ {anchorCount > 0 && (
457
+ <span className="atlas-anchor-count" translate="no">#{anchorCount}</span>
458
+ )}
459
+ </div>
460
+ )}
461
+
462
+ <Handle type="source" position={Position.Right} className="atlas-handle" />
463
+ </div>
464
+ );
465
+ }
466
+
467
+ const NODE_TYPES = { atlasNode: AtlasNode };
468
+
469
+ function CopyButton({ text, label, copiedLabel, testId = 'btn-copy-prompt', small = false }) {
470
+ const [copied, setCopied] = useState(false);
471
+ const timerRef = useRef(null);
472
+
473
+ useEffect(() => {
474
+ return () => {
475
+ if (timerRef.current) clearTimeout(timerRef.current);
476
+ };
477
+ }, []);
478
+
479
+ const handleCopy = () => {
480
+ copyToClipboard(text).then(() => {
481
+ setCopied(true);
482
+ if (timerRef.current) clearTimeout(timerRef.current);
483
+ timerRef.current = setTimeout(() => setCopied(false), 1800);
484
+ });
485
+ };
486
+
487
+ return (
488
+ <>
489
+ <button
490
+ type="button"
491
+ data-testid={testId}
492
+ data-copied={copied ? 'true' : 'false'}
493
+ onClick={handleCopy}
494
+ className={`atlas-copy-btn ${small ? 'small' : ''} ${copied ? 'copied' : ''}`}
495
+ >
496
+ {copied ? copiedLabel : label}
497
+ </button>
498
+ <span className="sr-only" aria-live="polite">
499
+ {copied ? copiedLabel : ''}
500
+ </span>
501
+ </>
502
+ );
503
+ }
504
+
505
+ function DeepDiveModal({ target, t, onClose }) {
506
+ const [tab, setTab] = useState('ask');
507
+
508
+ useEffect(() => {
509
+ setTab('ask');
510
+ }, [target]);
511
+
512
+ useEffect(() => {
513
+ if (!target) return undefined;
514
+ const onKeyDown = (e) => {
515
+ if (e.key === 'Escape') onClose();
516
+ };
517
+ window.addEventListener('keydown', onKeyDown);
518
+ return () => window.removeEventListener('keydown', onKeyDown);
519
+ }, [target, onClose]);
520
+
521
+ if (!target || !target.deepDive) return null;
522
+
523
+ const askPrompts = Array.isArray(target.deepDive.askPrompts) ? target.deepDive.askPrompts : [];
524
+ const subPrompt = target.deepDive.subAtlasPrompt || '';
525
+
526
+ return (
527
+ <div
528
+ className="atlas-modal-backdrop"
529
+ data-testid="deep-dive-backdrop"
530
+ role="presentation"
531
+ onMouseDown={(e) => {
532
+ if (e.target === e.currentTarget) onClose();
533
+ }}
534
+ >
535
+ <div
536
+ className="atlas-modal"
537
+ role="dialog"
538
+ aria-modal="true"
539
+ aria-labelledby="atlas-dive-modal-title"
540
+ data-testid="deep-dive-panel"
541
+ >
542
+ <div className="atlas-modal-header">
543
+ <div>
544
+ <div className="atlas-modal-kicker">{t.deepDiveModalTitle}</div>
545
+ <h3 id="atlas-dive-modal-title" className="atlas-modal-title">{target.title}</h3>
546
+ </div>
547
+ <button
548
+ type="button"
549
+ data-testid="btn-close-deep-dive"
550
+ aria-label={t.close}
551
+ className="atlas-icon-close"
552
+ onClick={onClose}
553
+ >
554
+ {t.close}
555
+ </button>
556
+ </div>
557
+
558
+ <div className="atlas-modal-tabs" role="tablist">
559
+ <button
560
+ type="button"
561
+ role="tab"
562
+ aria-selected={tab === 'ask'}
563
+ data-testid="tab-ask-prompts"
564
+ className={`atlas-tab-btn ${tab === 'ask' ? 'active' : ''}`}
565
+ onClick={() => setTab('ask')}
566
+ >
567
+ {t.tabAsk} ({askPrompts.length})
568
+ </button>
569
+ {subPrompt && (
570
+ <button
571
+ type="button"
572
+ role="tab"
573
+ aria-selected={tab === 'recursive'}
574
+ data-testid="tab-recursive-prompt"
575
+ className={`atlas-tab-btn ${tab === 'recursive' ? 'active' : ''}`}
576
+ onClick={() => setTab('recursive')}
577
+ >
578
+ {t.tabRecursive}
579
+ </button>
580
+ )}
581
+ </div>
582
+
583
+ <div className="atlas-modal-body">
584
+ {tab === 'ask' && (
585
+ <div className="atlas-prompt-list">
586
+ {askPrompts.map((p, i) => (
587
+ <div key={i} className="atlas-prompt-card" data-testid="ask-prompt-card">
588
+ <div className="atlas-prompt-card-head">
589
+ <strong>{p.title}</strong>
590
+ <CopyButton
591
+ text={p.prompt}
592
+ label={t.copyBtn}
593
+ copiedLabel={t.copiedBtn}
594
+ testId={`btn-copy-ask-${i}`}
595
+ />
596
+ </div>
597
+ <pre className="atlas-prompt-pre" data-testid="ask-prompt-text">{p.prompt}</pre>
598
+ </div>
599
+ ))}
600
+ </div>
601
+ )}
602
+
603
+ {tab === 'recursive' && subPrompt && (
604
+ <div className="atlas-prompt-card" data-testid="recursive-prompt-card">
605
+ <p className="atlas-hint-p">{t.recursiveUsageHint}</p>
606
+ <div className="atlas-prompt-card-head">
607
+ <strong translate="no">Atlas Loop · L1+</strong>
608
+ <CopyButton
609
+ text={subPrompt}
610
+ label={t.copyBtn}
611
+ copiedLabel={t.copiedBtn}
612
+ testId="btn-copy-recursive"
613
+ />
614
+ </div>
615
+ <pre className="atlas-prompt-pre" data-testid="recursive-prompt-text">{subPrompt}</pre>
616
+ </div>
617
+ )}
618
+ </div>
619
+ </div>
620
+ </div>
621
+ );
622
+ }
623
+
624
+ function ScopeModal({ open, uncoveredScopes, unverifiedNotes, t, onClose }) {
625
+ useEffect(() => {
626
+ if (!open) return undefined;
627
+ const onKeyDown = (e) => {
628
+ if (e.key === 'Escape') onClose();
629
+ };
630
+ window.addEventListener('keydown', onKeyDown);
631
+ return () => window.removeEventListener('keydown', onKeyDown);
632
+ }, [open, onClose]);
633
+
634
+ if (!open) return null;
635
+
636
+ return (
637
+ <div
638
+ className="atlas-modal-backdrop"
639
+ role="presentation"
640
+ onMouseDown={(e) => {
641
+ if (e.target === e.currentTarget) onClose();
642
+ }}
643
+ >
644
+ <div
645
+ className="atlas-modal"
646
+ role="dialog"
647
+ aria-modal="true"
648
+ aria-labelledby="atlas-scope-modal-title"
649
+ >
650
+ <div className="atlas-modal-header">
651
+ <h3 id="atlas-scope-modal-title" className="atlas-modal-title">{t.scopeNotes}</h3>
652
+ <button
653
+ type="button"
654
+ aria-label={t.close}
655
+ className="atlas-icon-close"
656
+ onClick={onClose}
657
+ >
658
+ {t.close}
659
+ </button>
660
+ </div>
661
+ <div className="atlas-modal-body">
662
+ {uncoveredScopes.length > 0 && (
663
+ <section className="atlas-sec" style={{ marginTop: 0, paddingTop: 0, borderTop: 'none' }}>
664
+ <h4>{t.uncoveredTitle}</h4>
665
+ <ul>
666
+ {uncoveredScopes.map((s, idx) => (
667
+ <li key={idx}>{s}</li>
668
+ ))}
669
+ </ul>
670
+ </section>
671
+ )}
672
+ {unverifiedNotes.length > 0 && (
673
+ <section className="atlas-sec">
674
+ <h4>{t.unverifiedTitle}</h4>
675
+ <ul>
676
+ {unverifiedNotes.map((u, idx) => (
677
+ <li key={idx}>{u}</li>
678
+ ))}
679
+ </ul>
680
+ </section>
681
+ )}
682
+ </div>
683
+ </div>
684
+ </div>
685
+ );
686
+ }
687
+
688
+ function AtlasCanvasApp({ data }) {
689
+ const meta = data.meta || {};
690
+ const t = useMemo(() => resolveI18n(meta), [meta]);
691
+ const streams = Array.isArray(data.streams) ? data.streams : [];
692
+
693
+ const [activeStream, setActiveStream] = useState('all');
694
+ const [expandedIds, setExpandedIds] = useState(() => new Set());
695
+ const [isLocked, setIsLocked] = useState(false);
696
+ const [showLegend, setShowLegend] = useState(false);
697
+ const [showScopeModal, setShowScopeModal] = useState(false);
698
+ const [searchQuery, setSearchQuery] = useState('');
699
+ const [selectedId, setSelectedId] = useState(null);
700
+ const [deepDiveTarget, setDeepDiveTarget] = useState(null);
701
+ const [resetEpoch, setResetEpoch] = useState(0);
702
+
703
+ const rf = useReactFlow();
704
+ const pendingFocusRef = useRef(null);
705
+
706
+ const handleToggleExpand = useCallback((nodeId) => {
707
+ setExpandedIds((prev) => {
708
+ const next = new Set(prev);
709
+ if (next.has(nodeId)) next.delete(nodeId);
710
+ else next.add(nodeId);
711
+ return next;
712
+ });
713
+ }, []);
714
+
715
+ const handleOpenDeepDive = useCallback((payload) => {
716
+ setDeepDiveTarget(payload);
717
+ }, []);
718
+
719
+ const handleSelectNode = useCallback((flowNodeId) => {
720
+ setSelectedId(flowNodeId);
721
+ }, []);
722
+
723
+ const handlers = useMemo(
724
+ () => ({
725
+ onToggleExpand: handleToggleExpand,
726
+ onOpenDeepDive: handleOpenDeepDive,
727
+ onSelectNode: handleSelectNode,
728
+ }),
729
+ [handleToggleExpand, handleOpenDeepDive, handleSelectNode]
730
+ );
731
+
732
+ const computed = useMemo(
733
+ () => buildGraphElements(data, activeStream, expandedIds, t, handlers),
734
+ [data, activeStream, expandedIds, t, handlers, resetEpoch]
735
+ );
736
+
737
+ const [nodes, setNodes, onNodesChange] = useNodesState(computed.nodes);
738
+ const [edges, setEdges, onEdgesChange] = useEdgesState(computed.edges);
739
+
740
+ useEffect(() => {
741
+ setNodes(computed.nodes);
742
+ setEdges(computed.edges);
743
+ }, [computed, setNodes, setEdges]);
744
+
745
+ useEffect(() => {
746
+ setNodes((prev) =>
747
+ prev.map((n) => {
748
+ const isSel = n.id === selectedId;
749
+ return n.selected === isSel ? n : { ...n, selected: isSel };
750
+ })
751
+ );
752
+ }, [selectedId, setNodes]);
753
+
754
+ useEffect(() => {
755
+ if (pendingFocusRef.current) {
756
+ const targetId = pendingFocusRef.current;
757
+ const exists = nodes.some((n) => n.id === targetId);
758
+ if (exists) {
759
+ pendingFocusRef.current = null;
760
+ requestAnimationFrame(() => {
761
+ rf.fitView({ nodes: [{ id: targetId }], padding: 0.45, duration: 0 });
762
+ });
763
+ }
764
+ }
765
+ }, [nodes, rf]);
766
+
767
+ const handleFitViewOnly = useCallback(() => {
768
+ rf.fitView({ padding: 0.18, duration: 0 });
769
+ }, [rf]);
770
+
771
+ const handleFullReset = useCallback(() => {
772
+ setActiveStream('all');
773
+ setExpandedIds(new Set());
774
+ setSearchQuery('');
775
+ setSelectedId(null);
776
+ setDeepDiveTarget(null);
777
+ setShowScopeModal(false);
778
+ setIsLocked(false);
779
+ setResetEpoch((c) => c + 1);
780
+ requestAnimationFrame(() => {
781
+ requestAnimationFrame(() => {
782
+ rf.fitView({ padding: 0.18, duration: 0 });
783
+ });
784
+ });
785
+ }, [rf]);
786
+
787
+ const searchIndex = useMemo(() => {
788
+ const entries = [];
789
+ for (const n of data.nodes || []) {
790
+ const anchorText = (n.anchors || [])
791
+ .map((a) => [a.label, a.filePath, a.symbol, a.content, a.citation, a.url].filter(Boolean).join(' '))
792
+ .join(' ');
793
+ const promptText = (n.deepDive?.askPrompts || []).map((p) => `${p.title} ${p.prompt}`).join(' ');
794
+ entries.push({
795
+ flowId: n.id,
796
+ parentId: null,
797
+ title: n.title,
798
+ summary: n.summary || '',
799
+ isSub: false,
800
+ haystack: `${n.title} ${n.summary || ''} ${n.whyItMatters || ''} ${anchorText} ${promptText}`.toLowerCase(),
801
+ });
802
+ for (const sn of n.detailGraph?.nodes || []) {
803
+ const subAnchorText = (sn.anchors || [])
804
+ .map((a) => [a.label, a.filePath, a.symbol, a.content, a.citation, a.url].filter(Boolean).join(' '))
805
+ .join(' ');
806
+ entries.push({
807
+ flowId: `${n.id}::${sn.id}`,
808
+ parentId: n.id,
809
+ title: sn.title,
810
+ summary: `${n.title} → ${sn.summary || ''}`,
811
+ isSub: true,
812
+ haystack: `${sn.title} ${sn.summary || ''} ${n.title} ${subAnchorText}`.toLowerCase(),
813
+ });
814
+ }
815
+ }
816
+ return entries;
817
+ }, [data.nodes]);
818
+
819
+ const searchMatches = useMemo(() => {
820
+ const q = searchQuery.trim().toLowerCase();
821
+ if (!q) return [];
822
+ return searchIndex.filter((item) => item.haystack.includes(q)).slice(0, 12);
823
+ }, [searchQuery, searchIndex]);
824
+
825
+ const focusSearchMatch = (match) => {
826
+ if (activeStream !== 'all') {
827
+ setActiveStream('all');
828
+ }
829
+ if (match.parentId && !expandedIds.has(match.parentId)) {
830
+ setExpandedIds((prev) => {
831
+ const next = new Set(prev);
832
+ next.add(match.parentId);
833
+ return next;
834
+ });
835
+ }
836
+ setSelectedId(match.flowId);
837
+ pendingFocusRef.current = match.flowId;
838
+ setSearchQuery('');
839
+ const alreadyRendered = nodes.some((n) => n.id === match.flowId);
840
+ if (alreadyRendered) {
841
+ pendingFocusRef.current = null;
842
+ requestAnimationFrame(() => {
843
+ rf.fitView({ nodes: [{ id: match.flowId }], padding: 0.45, duration: 0 });
844
+ });
845
+ }
846
+ };
847
+
848
+ const selectedItem = useMemo(() => {
849
+ if (!selectedId) return null;
850
+ if (selectedId.includes('::')) {
851
+ const [pId, sId] = selectedId.split('::');
852
+ const parent = (data.nodes || []).find((n) => n.id === pId);
853
+ const sub = (parent?.detailGraph?.nodes || []).find((sn) => sn.id === sId);
854
+ return sub ? { ...sub, _parentTitle: parent?.title } : null;
855
+ }
856
+ return (data.nodes || []).find((n) => n.id === selectedId) || null;
857
+ }, [selectedId, data.nodes]);
858
+
859
+ const currentStreamObj = useMemo(() => {
860
+ if (activeStream === 'all') return streams[0] || null;
861
+ return streams.find((s) => s.id === activeStream) || null;
862
+ }, [activeStream, streams]);
863
+
864
+ const nodeTitleMap = useMemo(() => {
865
+ const m = new Map();
866
+ for (const n of data.nodes || []) m.set(n.id, n.title);
867
+ return m;
868
+ }, [data.nodes]);
869
+
870
+ const uncoveredScopes = Array.isArray(meta.uncoveredScopes) ? meta.uncoveredScopes : [];
871
+ const unverifiedNotes = Array.isArray(meta.unverifiedNotes) ? meta.unverifiedNotes : [];
872
+ const scopeCount = uncoveredScopes.length + unverifiedNotes.length;
873
+
874
+ return (
875
+ <div className="atlas-shell-root">
876
+ <a href="#atlas-main" className="atlas-skip-link">{t.skipToCanvas}</a>
877
+
878
+ <header className="atlas-topbar" data-testid="atlas-topbar">
879
+ <div className="atlas-topbar-left">
880
+ <div className="atlas-top-badges">
881
+ {meta.parentMapHtml && (
882
+ <a
883
+ href={meta.parentMapHtml}
884
+ data-testid="parent-map-link"
885
+ className="atlas-parent-link"
886
+ >
887
+ <IconArrowLeft />
888
+ <span>{t.backParent}</span>
889
+ </a>
890
+ )}
891
+ {meta.subjectTag && <span className="atlas-subject-badge">{meta.subjectTag}</span>}
892
+ <span className="atlas-level-badge" data-testid="level-badge">
893
+ {meta.level && Number(meta.level) > 0 && meta.drillDownTarget
894
+ ? `${t.levelSubPrefix}${meta.drillDownTarget}`
895
+ : t.levelOverview}
896
+ </span>
897
+ </div>
898
+ <div className="atlas-title-row">
899
+ <h1 className="atlas-main-title">{meta.title}</h1>
900
+ {meta.subtitle && <p className="atlas-subtitle">{meta.subtitle}</p>}
901
+ </div>
902
+ </div>
903
+
904
+ <div className="atlas-topbar-right">
905
+ <div className="atlas-model-pill" data-testid="model-badge">
906
+ <span>{t.modelPrefix}</span>
907
+ <span translate="no">{meta.generatedByModel}</span>
908
+ </div>
909
+ {scopeCount > 0 && (
910
+ <button
911
+ type="button"
912
+ data-testid="btn-scope-notes"
913
+ className="atlas-scope-btn"
914
+ onClick={() => setShowScopeModal(true)}
915
+ >
916
+ {t.scopeNotes} ({scopeCount})
917
+ </button>
918
+ )}
919
+ </div>
920
+ </header>
921
+
922
+ <div className="atlas-toolbar-row">
923
+ <div className="atlas-streams-bar" data-testid="stream-filter-bar">
924
+ <button
925
+ type="button"
926
+ data-testid="stream-btn-all"
927
+ data-active={activeStream === 'all' ? 'true' : 'false'}
928
+ className={`atlas-stream-pill ${activeStream === 'all' ? 'active' : ''}`}
929
+ onClick={() => setActiveStream('all')}
930
+ >
931
+ {t.allStreams}
932
+ </button>
933
+ {streams.map((st) => {
934
+ const isActive = activeStream === st.id;
935
+ const hasStreamDive = Boolean(
936
+ st.deepDive &&
937
+ ((st.deepDive.askPrompts && st.deepDive.askPrompts.length > 0) || st.deepDive.subAtlasPrompt)
938
+ );
939
+ return (
940
+ <div key={st.id} className={`atlas-stream-pill-group ${isActive ? 'active' : ''}`}>
941
+ <button
942
+ type="button"
943
+ data-testid={`stream-btn-${st.id}`}
944
+ data-active={isActive ? 'true' : 'false'}
945
+ className={`atlas-stream-pill ${isActive ? 'active' : ''}`}
946
+ onClick={() => setActiveStream(st.id)}
947
+ >
948
+ {st.title}
949
+ </button>
950
+ {hasStreamDive && (
951
+ <button
952
+ type="button"
953
+ aria-label={`${t.deepDiveBadgeTitle}: ${st.title}`}
954
+ title={st.deepDive.hoverHint || t.deepDiveBadgeTitle}
955
+ data-testid={`stream-deep-dive-${st.id}`}
956
+ className="atlas-stream-help-btn"
957
+ onClick={() =>
958
+ setDeepDiveTarget({
959
+ title: st.title,
960
+ deepDive: st.deepDive,
961
+ })
962
+ }
963
+ >
964
+ ?
965
+ </button>
966
+ )}
967
+ </div>
968
+ );
969
+ })}
970
+ </div>
971
+
972
+ <div className="atlas-controls-group">
973
+ <div className="atlas-search-box">
974
+ <label htmlFor="atlas-search-input" className="atlas-search-field">
975
+ <span className="sr-only">{t.searchLabel}</span>
976
+ <IconSearch />
977
+ <input
978
+ id="atlas-search-input"
979
+ name="q"
980
+ type="search"
981
+ autoComplete="off"
982
+ spellCheck={false}
983
+ data-testid="search-input"
984
+ value={searchQuery}
985
+ onChange={(e) => setSearchQuery(e.target.value)}
986
+ placeholder={t.searchPlaceholder}
987
+ className="atlas-search-input"
988
+ />
989
+ </label>
990
+ {searchQuery.trim() !== '' && (
991
+ <div className="atlas-search-dropdown" data-testid="search-results" aria-live="polite">
992
+ {searchMatches.length === 0 ? (
993
+ <div className="atlas-search-empty">{t.noSearchMatches}</div>
994
+ ) : (
995
+ searchMatches.map((m) => (
996
+ <button
997
+ key={m.flowId}
998
+ type="button"
999
+ data-testid="search-result-item"
1000
+ data-target-id={m.flowId}
1001
+ className="atlas-search-item"
1002
+ onClick={() => focusSearchMatch(m)}
1003
+ >
1004
+ <div className="atlas-search-item-title">
1005
+ <span>{m.title}</span>
1006
+ {m.isSub && <span className="atlas-sub-pill">{t.nestedBadge}</span>}
1007
+ </div>
1008
+ {m.summary && <div className="atlas-search-item-sub">{m.summary}</div>}
1009
+ </button>
1010
+ ))
1011
+ )}
1012
+ </div>
1013
+ )}
1014
+ </div>
1015
+
1016
+ <div className="atlas-canvas-btns">
1017
+ <button
1018
+ type="button"
1019
+ data-testid="btn-zoom-in"
1020
+ aria-label={t.zoomIn}
1021
+ className="atlas-ctrl-btn"
1022
+ onClick={() => rf.zoomIn({ duration: 0 })}
1023
+ >
1024
+ + {t.zoomIn}
1025
+ </button>
1026
+ <button
1027
+ type="button"
1028
+ data-testid="btn-zoom-out"
1029
+ aria-label={t.zoomOut}
1030
+ className="atlas-ctrl-btn"
1031
+ onClick={() => rf.zoomOut({ duration: 0 })}
1032
+ >
1033
+ − {t.zoomOut}
1034
+ </button>
1035
+ <button
1036
+ type="button"
1037
+ data-testid="btn-lock"
1038
+ data-locked={isLocked ? 'true' : 'false'}
1039
+ aria-pressed={isLocked}
1040
+ className={`atlas-ctrl-btn ${isLocked ? 'locked' : ''}`}
1041
+ onClick={() => setIsLocked((v) => !v)}
1042
+ >
1043
+ <IconLock locked={isLocked} />
1044
+ <span>{isLocked ? t.unlock : t.lock}</span>
1045
+ </button>
1046
+ <button
1047
+ type="button"
1048
+ data-testid="btn-fit-view"
1049
+ className="atlas-ctrl-btn"
1050
+ onClick={handleFitViewOnly}
1051
+ >
1052
+ {t.fitView}
1053
+ </button>
1054
+ <button
1055
+ type="button"
1056
+ data-testid="btn-reset"
1057
+ className="atlas-ctrl-btn"
1058
+ onClick={handleFullReset}
1059
+ >
1060
+ {t.reset}
1061
+ </button>
1062
+ <button
1063
+ type="button"
1064
+ data-testid="btn-legend"
1065
+ aria-pressed={showLegend}
1066
+ className={`atlas-ctrl-btn ${showLegend ? 'active' : ''}`}
1067
+ onClick={() => setShowLegend((v) => !v)}
1068
+ >
1069
+ {t.legend}
1070
+ </button>
1071
+ </div>
1072
+ </div>
1073
+ </div>
1074
+
1075
+ {currentStreamObj && Array.isArray(currentStreamObj.learningOrder) && currentStreamObj.learningOrder.length > 0 && (
1076
+ <nav className="atlas-reading-bar" aria-label={t.learningPath} data-testid="learning-order-bar">
1077
+ <span className="atlas-reading-label">{t.learningPath}</span>
1078
+ <div className="atlas-reading-steps">
1079
+ {currentStreamObj.learningOrder.map((nid, idx) => {
1080
+ const title = nodeTitleMap.get(nid) || nid;
1081
+ return (
1082
+ <button
1083
+ key={nid}
1084
+ type="button"
1085
+ data-testid={`learning-step-${idx}`}
1086
+ className={`atlas-step-chip ${selectedId === nid ? 'active' : ''}`}
1087
+ onClick={() => {
1088
+ setSelectedId(nid);
1089
+ rf.fitView({ nodes: [{ id: nid }], padding: 0.45, duration: 0 });
1090
+ }}
1091
+ >
1092
+ <span className="atlas-step-num">{idx + 1}</span>
1093
+ <span>{title}</span>
1094
+ </button>
1095
+ );
1096
+ })}
1097
+ </div>
1098
+ </nav>
1099
+ )}
1100
+
1101
+ <main id="atlas-main" className="atlas-main-workspace">
1102
+ <div className="atlas-canvas-container" data-testid="atlas-canvas-wrap">
1103
+ <ReactFlow
1104
+ nodes={nodes}
1105
+ edges={edges}
1106
+ nodeTypes={NODE_TYPES}
1107
+ onNodesChange={onNodesChange}
1108
+ onEdgesChange={onEdgesChange}
1109
+ onNodeClick={(_, node) => setSelectedId(node.id)}
1110
+ nodesDraggable={!isLocked}
1111
+ nodesConnectable={false}
1112
+ elementsSelectable={true}
1113
+ fitView
1114
+ fitViewOptions={{ padding: 0.18 }}
1115
+ minZoom={0.15}
1116
+ maxZoom={2.5}
1117
+ proOptions={{ hideAttribution: true }}
1118
+ >
1119
+ <Background gap={22} size={1} color="#e4e4e7" />
1120
+ <MiniMap
1121
+ pannable
1122
+ zoomable
1123
+ maskColor="rgba(244, 244, 245, 0.7)"
1124
+ nodeColor={(n) => {
1125
+ const r = n.data?.item?.role;
1126
+ return ROLE_DOT_COLORS[r] || '#71717a';
1127
+ }}
1128
+ />
1129
+ </ReactFlow>
1130
+
1131
+ {showLegend && (
1132
+ <div className="atlas-legend-card" data-testid="legend-panel">
1133
+ {Object.entries(ROLE_DOT_COLORS).map(([rKey, color]) => (
1134
+ <div key={rKey} className="atlas-legend-row">
1135
+ <span className="atlas-legend-swatch" style={{ backgroundColor: color }} aria-hidden="true" />
1136
+ <span>{t.roles[rKey] || rKey}</span>
1137
+ </div>
1138
+ ))}
1139
+ </div>
1140
+ )}
1141
+ </div>
1142
+
1143
+ {selectedItem && (
1144
+ <aside className="atlas-inspector" data-testid="inspector-panel">
1145
+ <div className="atlas-inspector-head">
1146
+ <div className="atlas-inspector-head-text">
1147
+ {selectedItem._parentTitle && (
1148
+ <div className="atlas-inspector-parent">{selectedItem._parentTitle}</div>
1149
+ )}
1150
+ <h2 className="atlas-inspector-title">{selectedItem.title}</h2>
1151
+ </div>
1152
+ <button
1153
+ type="button"
1154
+ data-testid="btn-close-inspector"
1155
+ aria-label={t.close}
1156
+ className="atlas-icon-close"
1157
+ onClick={() => setSelectedId(null)}
1158
+ >
1159
+ {t.close}
1160
+ </button>
1161
+ </div>
1162
+
1163
+ <div className="atlas-inspector-meta">
1164
+ <span className="atlas-conf-badge">
1165
+ <span
1166
+ className="atlas-role-dot"
1167
+ style={{
1168
+ backgroundColor: selectedItem.confidence === 'unverified' ? '#b45309' : '#15803d',
1169
+ }}
1170
+ aria-hidden="true"
1171
+ />
1172
+ <span>{selectedItem.confidence === 'unverified' ? t.unverifiedTag : t.verifiedTag}</span>
1173
+ </span>
1174
+ {selectedItem.unverifiedReason && (
1175
+ <p className="atlas-unverified-note">{selectedItem.unverifiedReason}</p>
1176
+ )}
1177
+ </div>
1178
+
1179
+ {selectedItem.summary && (
1180
+ <section className="atlas-sec">
1181
+ <h4>{t.whatItDoes}</h4>
1182
+ <p>{selectedItem.summary}</p>
1183
+ </section>
1184
+ )}
1185
+
1186
+ {selectedItem.whyItMatters && (
1187
+ <section className="atlas-sec">
1188
+ <h4>{t.whyItMatters}</h4>
1189
+ <p>{selectedItem.whyItMatters}</p>
1190
+ </section>
1191
+ )}
1192
+
1193
+ {(selectedItem.keyFactors?.inputs || selectedItem.keyFactors?.outputs) && (
1194
+ <section className="atlas-sec">
1195
+ {selectedItem.keyFactors.inputs && (
1196
+ <div className="atlas-kv">
1197
+ <strong>{t.inputsLabel}:</strong>
1198
+ <span>{selectedItem.keyFactors.inputs}</span>
1199
+ </div>
1200
+ )}
1201
+ {selectedItem.keyFactors.outputs && (
1202
+ <div className="atlas-kv">
1203
+ <strong>{t.outputsLabel}:</strong>
1204
+ <span>{selectedItem.keyFactors.outputs}</span>
1205
+ </div>
1206
+ )}
1207
+ </section>
1208
+ )}
1209
+
1210
+ {selectedItem.mechanismsAndEffects && (
1211
+ <section className="atlas-sec">
1212
+ <h4>{t.mechanismsLabel}</h4>
1213
+ <p>{selectedItem.mechanismsAndEffects}</p>
1214
+ </section>
1215
+ )}
1216
+
1217
+ {selectedItem.watchouts && (
1218
+ <section className="atlas-sec atlas-watchout-box">
1219
+ <h4>{t.watchoutsLabel}</h4>
1220
+ <p>{selectedItem.watchouts}</p>
1221
+ </section>
1222
+ )}
1223
+
1224
+ {Array.isArray(selectedItem.anchors) && selectedItem.anchors.length > 0 && (
1225
+ <section className="atlas-sec">
1226
+ <h4>{t.anchorsLabel}</h4>
1227
+ <div className="atlas-anchor-list">
1228
+ {selectedItem.anchors.map((anc, i) => (
1229
+ <div key={i} className="atlas-anchor-card" data-testid="anchor-item">
1230
+ <div className="atlas-anchor-label">{anc.label}</div>
1231
+
1232
+ {anc.kind === 'editor' && (
1233
+ <div className="atlas-anchor-editor">
1234
+ <code translate="no">
1235
+ {anc.filePath}:{anc.line || 1}
1236
+ {anc.symbol ? ` (${anc.symbol})` : ''}
1237
+ </code>
1238
+ {anc.unsupportedNote ? (
1239
+ <div className="atlas-unverified-note">{anc.unsupportedNote}</div>
1240
+ ) : (
1241
+ <>
1242
+ <a
1243
+ href={anc.editorUri}
1244
+ data-testid="editor-jump-link"
1245
+ className="atlas-editor-btn"
1246
+ >
1247
+ {t.openInEditor} ({(anc.editor || 'vscode').toUpperCase()})
1248
+ </a>
1249
+ <div className="atlas-editor-hint">{t.editorConfirmHint}</div>
1250
+ </>
1251
+ )}
1252
+ </div>
1253
+ )}
1254
+
1255
+ {anc.kind === 'snippet' && (
1256
+ <div className="atlas-anchor-snippet">
1257
+ <pre translate="no">{anc.content}</pre>
1258
+ <CopyButton
1259
+ text={anc.content || ''}
1260
+ label={t.copySnippet}
1261
+ copiedLabel={t.copiedBtn}
1262
+ testId={`btn-copy-snippet-${i}`}
1263
+ small
1264
+ />
1265
+ </div>
1266
+ )}
1267
+
1268
+ {anc.kind === 'link' && (
1269
+ <a
1270
+ href={anc.url}
1271
+ target="_blank"
1272
+ rel="noopener noreferrer"
1273
+ className="atlas-ext-link"
1274
+ translate="no"
1275
+ >
1276
+ {anc.url}
1277
+ </a>
1278
+ )}
1279
+
1280
+ {anc.kind === 'source' && (
1281
+ <div className="atlas-source-cite">{anc.citation}</div>
1282
+ )}
1283
+ </div>
1284
+ ))}
1285
+ </div>
1286
+ </section>
1287
+ )}
1288
+
1289
+ {selectedItem.deepDive && (
1290
+ <section className="atlas-sec">
1291
+ <button
1292
+ type="button"
1293
+ data-testid="inspector-open-deep-dive"
1294
+ className="atlas-open-dive-btn"
1295
+ onClick={() =>
1296
+ setDeepDiveTarget({
1297
+ title: selectedItem.title,
1298
+ deepDive: selectedItem.deepDive,
1299
+ })
1300
+ }
1301
+ >
1302
+ ? {t.openDeepDiveBtn}
1303
+ </button>
1304
+ </section>
1305
+ )}
1306
+ </aside>
1307
+ )}
1308
+ </main>
1309
+
1310
+ <DeepDiveModal
1311
+ target={deepDiveTarget}
1312
+ t={t}
1313
+ onClose={() => setDeepDiveTarget(null)}
1314
+ />
1315
+
1316
+ <ScopeModal
1317
+ open={showScopeModal}
1318
+ uncoveredScopes={uncoveredScopes}
1319
+ unverifiedNotes={unverifiedNotes}
1320
+ t={t}
1321
+ onClose={() => setShowScopeModal(false)}
1322
+ />
1323
+ </div>
1324
+ );
1325
+ }
1326
+
1327
+ function mountAtlas() {
1328
+ const el = document.getElementById('atlas-data-json');
1329
+ const data = el ? JSON.parse(el.textContent) : window.__ATLAS_DATA__ || {};
1330
+ const rootEl = document.getElementById('root');
1331
+ const root = createRoot(rootEl);
1332
+ root.render(
1333
+ <ReactFlowProvider>
1334
+ <AtlasCanvasApp data={data} />
1335
+ </ReactFlowProvider>
1336
+ );
1337
+ }
1338
+
1339
+ mountAtlas();