@ltm-blueverse/alpha-semantic-hub 0.7.0

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.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1674 -0
  3. package/explorer/README.md +282 -0
  4. package/explorer/src/App.css +0 -0
  5. package/explorer/src/App.tsx +2035 -0
  6. package/explorer/src/ErrorBoundary.tsx +112 -0
  7. package/explorer/src/ExploreWorkspaceTabs.tsx +29 -0
  8. package/explorer/src/assets/hero.png +0 -0
  9. package/explorer/src/assets/react.svg +1 -0
  10. package/explorer/src/assets/vite.svg +1 -0
  11. package/explorer/src/explorerCapabilities.ts +24 -0
  12. package/explorer/src/index.css +71 -0
  13. package/explorer/src/main.tsx +7 -0
  14. package/explorer/src/store/edgePairKeys.d.ts +2 -0
  15. package/explorer/src/store/edgePairKeys.js +7 -0
  16. package/explorer/src/store/graphStore.ts +183 -0
  17. package/explorer/src/store/registryStore.ts +78 -0
  18. package/explorer/src/types.d.ts +16 -0
  19. package/explorer/src/ui/primitives.css +414 -0
  20. package/explorer/src/ui/primitives.tsx +170 -0
  21. package/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx +279 -0
  22. package/explorer/src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx +121 -0
  23. package/explorer/src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx +454 -0
  24. package/explorer/src/workspaces/EnrichWorkspace/RegistryTab.tsx +285 -0
  25. package/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx +2673 -0
  26. package/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx +771 -0
  27. package/explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx +410 -0
  28. package/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +3584 -0
  29. package/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx +705 -0
  30. package/explorer/src/workspaces/GraphWorkspace/SigmaSceneAdapter.tsx +55 -0
  31. package/explorer/src/workspaces/GraphWorkspace/TimelinePanel.tsx +193 -0
  32. package/explorer/src/workspaces/GraphWorkspace/behaviors/clickSelectionBehavior.ts +29 -0
  33. package/explorer/src/workspaces/GraphWorkspace/behaviors/fitViewBehavior.ts +15 -0
  34. package/explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts +25 -0
  35. package/explorer/src/workspaces/GraphWorkspace/behaviors/hoverActivationBehavior.ts +15 -0
  36. package/explorer/src/workspaces/GraphWorkspace/behaviors/pathHighlightBehavior.ts +52 -0
  37. package/explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts +36 -0
  38. package/explorer/src/workspaces/GraphWorkspace/behaviors/types.ts +41 -0
  39. package/explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts +29 -0
  40. package/explorer/src/workspaces/GraphWorkspace/graphAnalytics.ts +779 -0
  41. package/explorer/src/workspaces/GraphWorkspace/graphColorLegend.ts +36 -0
  42. package/explorer/src/workspaces/GraphWorkspace/graphConfig.ts +102 -0
  43. package/explorer/src/workspaces/GraphWorkspace/graphEntityShape.ts +34 -0
  44. package/explorer/src/workspaces/GraphWorkspace/graphLoading.ts +84 -0
  45. package/explorer/src/workspaces/GraphWorkspace/graphSceneLayers.ts +775 -0
  46. package/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts +2753 -0
  47. package/explorer/src/workspaces/GraphWorkspace/graphStructureLayer.ts +335 -0
  48. package/explorer/src/workspaces/GraphWorkspace/graphTheme.ts +1078 -0
  49. package/explorer/src/workspaces/GraphWorkspace/graphViewCopy.ts +41 -0
  50. package/explorer/src/workspaces/GraphWorkspace/localGraphTransition.ts +9 -0
  51. package/explorer/src/workspaces/GraphWorkspace/markdownEditorState.ts +114 -0
  52. package/explorer/src/workspaces/GraphWorkspace/markdownResourceClient.ts +103 -0
  53. package/explorer/src/workspaces/GraphWorkspace/markdownUrlSafety.ts +29 -0
  54. package/explorer/src/workspaces/GraphWorkspace/nodeMarkdownSync.ts +81 -0
  55. package/explorer/src/workspaces/GraphWorkspace/pluginRegistryPredicates.ts +24 -0
  56. package/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx +353 -0
  57. package/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx +522 -0
  58. package/explorer/src/workspaces/GraphWorkspace/plugins/index.ts +16 -0
  59. package/explorer/src/workspaces/GraphWorkspace/plugins/legendPlugin.tsx +120 -0
  60. package/explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx +231 -0
  61. package/explorer/src/workspaces/GraphWorkspace/plugins/temporalOverlayPlugin.tsx +142 -0
  62. package/explorer/src/workspaces/GraphWorkspace/plugins/types.ts +106 -0
  63. package/explorer/src/workspaces/GraphWorkspace/realtimeGraphAttributes.ts +50 -0
  64. package/explorer/src/workspaces/GraphWorkspace/scene.ts +79 -0
  65. package/explorer/src/workspaces/GraphWorkspace/sigmaNativeRendering.ts +421 -0
  66. package/explorer/src/workspaces/GraphWorkspace/smallGraphLayout.ts +135 -0
  67. package/explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts +31 -0
  68. package/explorer/src/workspaces/GraphWorkspace/temporalScrubberBounds.ts +46 -0
  69. package/explorer/src/workspaces/GraphWorkspace/temporalSnapshotGuards.ts +113 -0
  70. package/explorer/src/workspaces/GraphWorkspace/types.ts +395 -0
  71. package/explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts +746 -0
  72. package/explorer/src/workspaces/GraphWorkspace/useMarkdownEditor.ts +177 -0
  73. package/explorer/src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx +196 -0
  74. package/explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx +177 -0
  75. package/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx +291 -0
  76. package/explorer/src/workspaces/ManageWorkspace/OntologySummaryTab.tsx +346 -0
  77. package/explorer/src/workspaces/MemoryWorkspace.tsx +464 -0
  78. package/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx +437 -0
  79. package/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx +214 -0
  80. package/explorer/src/workspaces/OntologyWorkspace/OntologyEditor.tsx +770 -0
  81. package/explorer/src/workspaces/OntologyWorkspace/OntologyLoader.tsx +913 -0
  82. package/explorer/src/workspaces/OntologyWorkspace/OntologyManager.tsx +930 -0
  83. package/explorer/src/workspaces/OntologyWorkspace/OntologySearch.tsx +590 -0
  84. package/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx +429 -0
  85. package/explorer/src/workspaces/OntologyWorkspace/SKOSVocabularyManager.tsx +696 -0
  86. package/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx +363 -0
  87. package/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx +546 -0
  88. package/explorer/src/workspaces/OntologyWorkspace/api.ts +164 -0
  89. package/explorer/src/workspaces/OntologyWorkspace/index.tsx +101 -0
  90. package/explorer/src/workspaces/OntologyWorkspace/ontologyEditorModel.ts +87 -0
  91. package/explorer/src/workspaces/OntologyWorkspace/ontologyUrlState.ts +94 -0
  92. package/explorer/src/workspaces/OntologyWorkspace/types.ts +119 -0
  93. package/explorer/src/workspaces/ReasoningWorkspace.tsx +243 -0
  94. package/explorer/src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx +271 -0
  95. package/explorer/src/workspaces/VocabularyWorkspace/ConceptTree.tsx +97 -0
  96. package/explorer/src/workspaces/VocabularyWorkspace/ImportDropzone.tsx +95 -0
  97. package/explorer/src/workspaces/VocabularyWorkspace/PropertyPanel.tsx +116 -0
  98. package/explorer/src/workspaces/VocabularyWorkspace/Sidebar.tsx +112 -0
  99. package/explorer/src/workspaces/VocabularyWorkspace/VocabularyWorkspace.tsx +70 -0
  100. package/explorer/src/workspaces/VocabularyWorkspace/queries.ts +52 -0
  101. package/explorer/src/workspaces/VocabularyWorkspace/types.ts +24 -0
  102. package/package.json +31 -0
  103. package/plugins/.claude-plugin/README.md +262 -0
  104. package/plugins/.claude-plugin/marketplace.json +20 -0
  105. package/plugins/.claude-plugin/plugin.json +33 -0
  106. package/plugins/.cline-plugin/README.md +32 -0
  107. package/plugins/.cline-plugin/marketplace.json +17 -0
  108. package/plugins/.cline-plugin/plugin.json +35 -0
  109. package/plugins/.codex-plugin/marketplace.json +21 -0
  110. package/plugins/.codex-plugin/plugin.json +35 -0
  111. package/plugins/.continue-plugin/README.md +34 -0
  112. package/plugins/.continue-plugin/marketplace.json +17 -0
  113. package/plugins/.continue-plugin/plugin.json +33 -0
  114. package/plugins/.cursor-plugin/marketplace.json +18 -0
  115. package/plugins/.cursor-plugin/plugin.json +32 -0
  116. package/plugins/.openclaw-plugin/README.md +62 -0
  117. package/plugins/.openclaw-plugin/marketplace.json +18 -0
  118. package/plugins/.openclaw-plugin/plugin.json +46 -0
  119. package/plugins/.pi-plugin/README.md +35 -0
  120. package/plugins/.vscode-plugin/README.md +48 -0
  121. package/plugins/.vscode-plugin/marketplace.json +17 -0
  122. package/plugins/.vscode-plugin/plugin.json +35 -0
  123. package/plugins/.windsurf-plugin/README.md +39 -0
  124. package/plugins/.windsurf-plugin/marketplace.json +17 -0
  125. package/plugins/.windsurf-plugin/plugin.json +35 -0
  126. package/plugins/agents/decision-advisor.md +126 -0
  127. package/plugins/agents/explainability.md +129 -0
  128. package/plugins/agents/kg-assistant.md +73 -0
  129. package/plugins/hooks/hooks.json +12 -0
  130. package/plugins/skills/causal/SKILL.md +74 -0
  131. package/plugins/skills/change/SKILL.md +80 -0
  132. package/plugins/skills/decision/SKILL.md +197 -0
  133. package/plugins/skills/deduplicate/SKILL.md +41 -0
  134. package/plugins/skills/embed/SKILL.md +230 -0
  135. package/plugins/skills/explain/SKILL.md +44 -0
  136. package/plugins/skills/export/SKILL.md +67 -0
  137. package/plugins/skills/extract/SKILL.md +93 -0
  138. package/plugins/skills/ingest/SKILL.md +38 -0
  139. package/plugins/skills/ontology/SKILL.md +89 -0
  140. package/plugins/skills/policy/SKILL.md +66 -0
  141. package/plugins/skills/provenance/SKILL.md +70 -0
  142. package/plugins/skills/query/SKILL.md +84 -0
  143. package/plugins/skills/reason/SKILL.md +201 -0
  144. package/plugins/skills/temporal/SKILL.md +164 -0
  145. package/plugins/skills/validate/SKILL.md +228 -0
  146. package/plugins/skills/visualize/SKILL.md +249 -0
@@ -0,0 +1,705 @@
1
+ import {
2
+ useEffect,
3
+ useId,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ type CSSProperties,
9
+ type KeyboardEvent,
10
+ } from "react";
11
+ import ReactMarkdown, { type Components } from "react-markdown";
12
+ import remarkGfm from "remark-gfm";
13
+ import {
14
+ Check,
15
+ Code2,
16
+ Copy,
17
+ Eye,
18
+ ExternalLink,
19
+ Image as ImageIcon,
20
+ Loader2,
21
+ Pencil,
22
+ RefreshCw,
23
+ X,
24
+ } from "lucide-react";
25
+ import { GRAPH_THEME } from "./graphTheme";
26
+ import type { MarkdownApplyResult } from "./markdownResourceClient";
27
+ import type { MarkdownResourceRef } from "./markdownEditorState";
28
+ import { isSafeUrl } from "./markdownUrlSafety";
29
+ import { useMarkdownEditor } from "./useMarkdownEditor";
30
+
31
+ export interface MarkdownContentViewerProps {
32
+ content?: string | null;
33
+ resource?: MarkdownResourceRef;
34
+ onApplied?: (result: MarkdownApplyResult) => void;
35
+ onDirtyChange?: (dirty: boolean) => void;
36
+ className?: string;
37
+ defaultMode?: "preview" | "source";
38
+ }
39
+
40
+ // Exported for unit-testing the roving-tabindex navigation logic without a DOM.
41
+ // Given the ordered list of tab modes and the currently focused mode, returns
42
+ // the mode that should receive focus for a given keyboard key. Returns null if
43
+ // the key is not a navigation key so callers can handle the default case.
44
+ // eslint-disable-next-line react-refresh/only-export-components
45
+ export function resolveTabNavigation(
46
+ current: "preview" | "source",
47
+ key: string,
48
+ ): "preview" | "source" | null {
49
+ const order = ["preview", "source"] as const;
50
+ const idx = order.indexOf(current);
51
+ switch (key) {
52
+ case "ArrowRight": return order[(idx + 1) % order.length];
53
+ case "ArrowLeft": return order[(idx - 1 + order.length) % order.length];
54
+ case "Home": return order[0];
55
+ case "End": return order[order.length - 1];
56
+ default: return null;
57
+ }
58
+ }
59
+
60
+ export function MarkdownContentViewer({
61
+ content,
62
+ resource,
63
+ onApplied,
64
+ onDirtyChange,
65
+ className,
66
+ defaultMode = "preview",
67
+ }: MarkdownContentViewerProps) {
68
+ const [activeMode, setActiveMode] = useState<"preview" | "source">(defaultMode);
69
+ const [copied, setCopied] = useState(false);
70
+ const modeBeforeEditRef = useRef<"preview" | "source">(defaultMode);
71
+ const resourceKey = resource ? `${resource.kind}:${resource.id}` : "";
72
+ const [activeResourceKey, setActiveResourceKey] = useState(resourceKey);
73
+ const editor = useMarkdownEditor({ resource, onApplied, onDirtyChange });
74
+ const {
75
+ session,
76
+ error,
77
+ dirty,
78
+ editing,
79
+ saving,
80
+ loading,
81
+ } = editor;
82
+
83
+ if (activeResourceKey !== resourceKey) {
84
+ setActiveResourceKey(resourceKey);
85
+ setCopied(false);
86
+ setActiveMode(defaultMode);
87
+ }
88
+
89
+ // Track the content value for which the copied indicator is valid.
90
+ const [copiedForContent, setCopiedForContent] = useState<string | null | undefined>(content);
91
+ if (copiedForContent !== content) {
92
+ setCopiedForContent(content);
93
+ if (copied) setCopied(false);
94
+ }
95
+
96
+ // useId (not hardcoded strings) so the ids stay unique if more than one viewer
97
+ // is ever mounted at once — the same pattern GraphWorkspace uses for its search
98
+ // combobox. Hardcoded ids would collide silently in that case.
99
+ const baseId = useId();
100
+ const previewTabId = `${baseId}-tab-preview`;
101
+ const sourceTabId = `${baseId}-tab-source`;
102
+ const panelId = `${baseId}-panel`;
103
+
104
+ // Roving tabindex: the tablist is one Tab stop and arrows move focus within it.
105
+ // Focus is tracked separately from selection because activation is manual (see
106
+ // handleTabKeyDown), so a tab can hold focus without being the selected one.
107
+ //
108
+ // focusedMode is stored in a ref rather than state so that moving focus with
109
+ // arrow keys does NOT trigger a React re-render. A re-render here is expensive:
110
+ // react-markdown@10 has no internal memoisation and calls processor.parse() +
111
+ // processor.runSync() unconditionally on every render — measured at 385ms for a
112
+ // 1000-row GFM table and 1.4s at 2000 rows (#1118). Using a ref means arrow-key
113
+ // navigation is free of Markdown re-parses while still keeping the DOM tabIndex
114
+ // attributes correct via direct mutation (the same pattern used by WAI-ARIA APG
115
+ // keyboard examples for roving tabindex).
116
+ //
117
+ // The JSX tabIndex props use activeMode (not the ref) to satisfy the
118
+ // react-hooks/refs lint rule that bars ref reads during render. JSX provides the
119
+ // correct value on initial render and after selectMode() calls (which always keep
120
+ // focusedModeRef.current === activeMode at React render boundaries). A
121
+ // useLayoutEffect (see below) corrects any JSX overwrite that occurs when focus
122
+ // and selection temporarily differ during arrow navigation.
123
+ const focusedModeRef = useRef<"preview" | "source">(defaultMode);
124
+ const previewTabRef = useRef<HTMLButtonElement>(null);
125
+ const sourceTabRef = useRef<HTMLButtonElement>(null);
126
+
127
+ const focusTab = (mode: "preview" | "source") => {
128
+ focusedModeRef.current = mode;
129
+ // Imperatively update tabIndex on both buttons so the roving tabindex
130
+ // DOM state is correct without scheduling a React re-render.
131
+ if (previewTabRef.current) previewTabRef.current.tabIndex = mode === "preview" ? 0 : -1;
132
+ if (sourceTabRef.current) sourceTabRef.current.tabIndex = mode === "source" ? 0 : -1;
133
+ (mode === "preview" ? previewTabRef : sourceTabRef).current?.focus();
134
+ };
135
+
136
+ const selectMode = (mode: "preview" | "source") => {
137
+ // Keep the ref in sync before setActiveMode so the upcoming re-render reads
138
+ // the correct focusedModeRef.current when evaluating JSX tabIndex props.
139
+ focusedModeRef.current = mode;
140
+ setActiveMode(mode);
141
+ };
142
+
143
+ // Manual activation (APG permits it, and here it is required): arrows move
144
+ // focus only, Enter/Space activates via the native button click. Automatic
145
+ // activation would re-run the full markdown parse on every arrow keypress —
146
+ // measured at 385ms for a 1000-row GFM table and 1.4s at 2000 rows (#1118).
147
+ const handleTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
148
+ const next = resolveTabNavigation(focusedModeRef.current, event.key);
149
+ if (next === null) return;
150
+ event.preventDefault();
151
+ focusTab(next);
152
+ };
153
+
154
+ const copyTimeoutRef = useRef<number | undefined>(undefined);
155
+ useEffect(() => {
156
+ return () => {
157
+ clearTimeout(copyTimeoutRef.current);
158
+ };
159
+ }, []);
160
+
161
+ // When the viewed resource/node changes, reset focusedModeRef to match the
162
+ // incoming defaultMode. The render-phase setActiveMode(defaultMode) above
163
+ // resets React state, but refs are not state and must be updated separately.
164
+ // useLayoutEffect fires synchronously before paint so the ref is correct before
165
+ // the no-deps tabIndex-correction effect (declared next) reads it.
166
+ // Using resourceKey as the dep means this runs exactly once per resource change,
167
+ // immediately after the render that detected the change.
168
+ useLayoutEffect(() => {
169
+ focusedModeRef.current = defaultMode;
170
+ }, [resourceKey, defaultMode]);
171
+
172
+ // After every render, restore the DOM tabIndex to match focusedModeRef.current.
173
+ // This is necessary because the JSX tabIndex props derive from activeMode, which
174
+ // is correct for initial render and for renders triggered by selectMode(). However,
175
+ // when focus and selection differ (i.e. after arrow-key navigation, before Enter/Space),
176
+ // any unrelated re-render (copy-button click, parent update, etc.) will reconcile JSX
177
+ // tabIndex={activeMode === X} and overwrite the imperative tabIndex values set by
178
+ // focusTab(). useLayoutEffect fires synchronously after React's DOM mutations, before
179
+ // paint, so it corrects any such overwrite before the user sees it. It does not
180
+ // schedule another render — the two property writes are pure DOM mutations.
181
+ // No deps array: intentional. The correction must run after every render, not just mount.
182
+ // SSR-safe: useLayoutEffect is silently skipped on the server; the JSX tabIndex from
183
+ // activeMode provides the correct initial value (focusedModeRef.current === activeMode
184
+ // at mount). Strict Mode: runs twice on remount — both runs write the same values,
185
+ // no state mutation, no render triggered.
186
+ useLayoutEffect(() => {
187
+ if (previewTabRef.current) {
188
+ previewTabRef.current.tabIndex = focusedModeRef.current === "preview" ? 0 : -1;
189
+ }
190
+ if (sourceTabRef.current) {
191
+ sourceTabRef.current.tabIndex = focusedModeRef.current === "source" ? 0 : -1;
192
+ }
193
+ });
194
+
195
+ const rawContent = editor.editing
196
+ ? editor.session?.draft ?? ""
197
+ : (typeof content === "string" ? content : "");
198
+
199
+ const previewContent = useMemo(() => {
200
+ if (!editor.editing) return rawContent;
201
+ const lines = rawContent.split(/\r?\n/);
202
+ if (lines[0] !== "---") return rawContent;
203
+ const closingIndex = lines.findIndex((line, index) => index > 0 && line === "---");
204
+ return closingIndex < 0 ? rawContent : lines.slice(closingIndex + 1).join("\n").replace(/^\n/, "");
205
+ }, [editor.editing, rawContent]);
206
+
207
+ const hasContent = rawContent.trim().length > 0;
208
+
209
+ const renderedMarkdown = useMemo(
210
+ () => (
211
+ <ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={MARKDOWN_COMPONENTS}>
212
+ {previewContent}
213
+ </ReactMarkdown>
214
+ ),
215
+ [previewContent],
216
+ );
217
+
218
+ const handleCopy = async () => {
219
+ if (!hasContent) return;
220
+ try {
221
+ await navigator.clipboard.writeText(rawContent);
222
+ clearTimeout(copyTimeoutRef.current);
223
+ setCopied(true);
224
+ copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 1500);
225
+ } catch {
226
+ // Clipboard write unavailable.
227
+ }
228
+ };
229
+
230
+ const handleEdit = async () => {
231
+ modeBeforeEditRef.current = activeMode;
232
+ selectMode("source");
233
+ if (!await editor.beginEdit()) {
234
+ selectMode(modeBeforeEditRef.current);
235
+ }
236
+ };
237
+
238
+ const handleCancel = () => {
239
+ editor.discard();
240
+ selectMode(modeBeforeEditRef.current);
241
+ };
242
+
243
+ const handleApply = async () => {
244
+ if (await editor.save()) {
245
+ selectMode("preview");
246
+ }
247
+ };
248
+
249
+ return (
250
+ <div className={className} style={viewerContainerStyle}>
251
+ <div style={viewerHeaderStyle}>
252
+ <div style={{ display: "flex", gap: 4 }} role="tablist" aria-label="Content view mode">
253
+ <button
254
+ type="button"
255
+ role="tab"
256
+ id={previewTabId}
257
+ ref={previewTabRef}
258
+ aria-selected={activeMode === "preview"}
259
+ aria-controls={panelId}
260
+ tabIndex={activeMode === "preview" ? 0 : -1}
261
+ onClick={() => selectMode("preview")}
262
+ onKeyDown={handleTabKeyDown}
263
+ style={{ ...tabBtnStyle, ...(activeMode === "preview" ? activeTabBtnStyle : {}) }}
264
+ >
265
+ <Eye size={12} style={{ marginRight: 5 }} aria-hidden="true" />
266
+ Preview
267
+ </button>
268
+ <button
269
+ type="button"
270
+ role="tab"
271
+ id={sourceTabId}
272
+ ref={sourceTabRef}
273
+ aria-selected={activeMode === "source"}
274
+ aria-controls={panelId}
275
+ tabIndex={activeMode === "source" ? 0 : -1}
276
+ onClick={() => selectMode("source")}
277
+ onKeyDown={handleTabKeyDown}
278
+ style={{ ...tabBtnStyle, ...(activeMode === "source" ? activeTabBtnStyle : {}) }}
279
+ >
280
+ <Code2 size={12} style={{ marginRight: 5 }} aria-hidden="true" />
281
+ Source
282
+ </button>
283
+ </div>
284
+
285
+ <div style={{ display: "flex", alignItems: "center", gap: 6 }}>
286
+ {hasContent && (
287
+ <button type="button" onClick={() => void handleCopy()} style={copyBtnStyle} title="Copy raw content">
288
+ {copied ? (
289
+ <>
290
+ <Check size={12} color="#3fb950" style={{ marginRight: 4 }} aria-hidden="true" />
291
+ <span style={{ color: "#3fb950", fontSize: 11 }}>Copied</span>
292
+ </>
293
+ ) : (
294
+ <>
295
+ <Copy size={12} style={{ marginRight: 4 }} aria-hidden="true" />
296
+ <span style={{ fontSize: 11 }}>Copy</span>
297
+ </>
298
+ )}
299
+ </button>
300
+ )}
301
+ {resource && !editing && !loading ? (
302
+ <button type="button" onClick={() => void handleEdit()} style={copyBtnStyle}>
303
+ <Pencil size={12} style={{ marginRight: 4 }} aria-hidden="true" />
304
+ Edit
305
+ </button>
306
+ ) : null}
307
+ {loading ? (
308
+ <button type="button" disabled style={{ ...copyBtnStyle, opacity: 0.65 }}>
309
+ <Loader2 size={12} className="animate-spin" style={{ marginRight: 4 }} aria-hidden="true" />
310
+ Loading…
311
+ </button>
312
+ ) : null}
313
+ {editing ? (
314
+ <>
315
+ <button type="button" onClick={handleCancel} disabled={saving} style={copyBtnStyle}>
316
+ <X size={12} style={{ marginRight: 4 }} aria-hidden="true" />
317
+ Cancel
318
+ </button>
319
+ <button
320
+ type="button"
321
+ onClick={() => void handleApply()}
322
+ disabled={saving || !dirty}
323
+ title={!dirty ? "Make a change before applying" : undefined}
324
+ style={{ ...saveBtnStyle, opacity: saving || !dirty ? 0.55 : 1 }}
325
+ >
326
+ {saving ? (
327
+ <Loader2 size={12} className="animate-spin" style={{ marginRight: 4 }} aria-hidden="true" />
328
+ ) : (
329
+ <Check size={12} style={{ marginRight: 4 }} aria-hidden="true" />
330
+ )}
331
+ {saving ? "Applying…" : "Apply"}
332
+ </button>
333
+ </>
334
+ ) : null}
335
+ </div>
336
+ </div>
337
+
338
+ {error ? (
339
+ <div id="markdown-editor-error" role="alert" style={errorStyle}>
340
+ <span>{error.message}</span>
341
+ {error.kind === "conflict" ? (
342
+ <button type="button" onClick={() => void editor.reloadLatest()} style={errorActionStyle}>
343
+ <RefreshCw size={12} style={{ marginRight: 4 }} aria-hidden="true" />
344
+ Reload latest
345
+ </button>
346
+ ) : null}
347
+ </div>
348
+ ) : null}
349
+
350
+ {/* Both tabs point aria-controls at this one panel: only the active view is
351
+ ever rendered inside (edit/preview/source/empty), so per-tab panel ids
352
+ would leave the inactive tab referencing an element not in the DOM.
353
+ Wrapping all branches — including empty state and editing textarea —
354
+ keeps every aria-controls reference resolvable at all times.
355
+ tabIndex=0 because the panel is a scroll container (viewerBodyStyle caps
356
+ its height), so keyboard users need to be able to focus and scroll it.
357
+ aria-busy signals to assistive tech that the content is loading/saving. */}
358
+ <div
359
+ role="tabpanel"
360
+ id={panelId}
361
+ aria-labelledby={activeMode === "preview" ? previewTabId : sourceTabId}
362
+ aria-busy={saving || loading}
363
+ tabIndex={0}
364
+ style={viewerBodyStyle}
365
+ >
366
+ {activeMode === "source" && editing ? (
367
+ <textarea
368
+ aria-label="Markdown source"
369
+ aria-describedby={error ? "markdown-editor-error" : undefined}
370
+ aria-invalid={error?.kind === "validation" || undefined}
371
+ value={session?.draft ?? ""}
372
+ onChange={(event) => editor.changeDraft(event.target.value)}
373
+ disabled={saving}
374
+ spellCheck={false}
375
+ style={editorStyle}
376
+ />
377
+ ) : !hasContent ? (
378
+ <div style={emptyTextStyle}>No content available for this node.</div>
379
+ ) : activeMode === "source" ? (
380
+ <pre style={sourcePreStyle}>
381
+ <code style={sourceCodeStyle}>{rawContent}</code>
382
+ </pre>
383
+ ) : (
384
+ <div style={previewStyle}>{renderedMarkdown}</div>
385
+ )}
386
+ </div>
387
+ </div>
388
+ );
389
+ }
390
+
391
+ /* ─── Markdown rendering config ───────────────────────────────────── */
392
+
393
+ // Both props are hoisted to module scope so they keep a stable identity across
394
+ // renders. As inline literals they allocated a fresh plugin array and ~20 fresh
395
+ // arrow components on every render, which made React treat every mapped tag as a
396
+ // new element type and remount the entire rendered subtree instead of updating
397
+ // it (issue #1118). The arrow bodies only read the style constants below at call
398
+ // time, so declaring the map before them is safe.
399
+ const REMARK_PLUGINS = [remarkGfm];
400
+
401
+ const MARKDOWN_COMPONENTS: Components = {
402
+ // C-1: react-markdown passes a HAST `node` prop (the raw AST
403
+ // Element) to every custom component override via passNode:true.
404
+ // In React 19 any unknown prop spreads onto a native element are
405
+ // serialised as HTML attributes, producing node="[object Object]"
406
+ // on every rendered link. Fix: destructure `node` by name so it
407
+ // is explicitly discarded, then spread `...rest` to preserve all
408
+ // other legitimate HAST/remark-gfm attributes — e.g. the `id`,
409
+ // `aria-describedby`, `aria-label`, `data-footnote-ref`,
410
+ // `data-footnote-backref`, and `class` attrs that GFM footnotes
411
+ // require for correct in-page navigation and accessibility.
412
+ //
413
+ // C-2: fragment links (#anchor, GFM footnote backlinks) must
414
+ // navigate within the current document. External links continue
415
+ // to use target="_blank" with noopener noreferrer.
416
+ //
417
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
418
+ a: ({ href, children, title, node: _node, ...rest }) => {
419
+ if (!isSafeUrl(href)) {
420
+ return <span style={{ color: GRAPH_THEME.ui.text.muted, textDecoration: "line-through" }}>{children}</span>;
421
+ }
422
+ // isSafeUrl returning true guarantees href is a non-empty string.
423
+ const safeHref = href ?? "";
424
+ // Fragment links (#section, footnote backlinks like
425
+ // #user-content-fnref-1) are in-document anchors. Opening them
426
+ // in a new tab would break GFM footnote back-navigation.
427
+ const isFragment = safeHref.startsWith("#");
428
+ if (isFragment) {
429
+ return (
430
+ <a href={safeHref} title={title} style={linkStyle} {...rest}>
431
+ {children}
432
+ </a>
433
+ );
434
+ }
435
+ return (
436
+ <a href={safeHref} title={title} target="_blank" rel="noopener noreferrer" style={linkStyle} {...rest}>
437
+ {children}
438
+ <ExternalLink size={10} style={{ marginLeft: 3, verticalAlign: "middle", display: "inline" }} />
439
+ </a>
440
+ );
441
+ },
442
+ img: ({ src, alt }) => (
443
+ <span style={imageBadgeStyle} title={src || "Image"}>
444
+ <ImageIcon size={12} style={{ marginRight: 5 }} />
445
+ <span>Image: {alt || src || "unlabeled"}</span>
446
+ </span>
447
+ ),
448
+ h1: ({ children }) => <h1 style={h1Style}>{children}</h1>,
449
+ h2: ({ children }) => <h2 style={h2Style}>{children}</h2>,
450
+ h3: ({ children }) => <h3 style={h3Style}>{children}</h3>,
451
+ h4: ({ children }) => <h4 style={h4Style}>{children}</h4>,
452
+ p: ({ children }) => <p style={{ margin: "0 0 8px 0" }}>{children}</p>,
453
+ ul: ({ children }) => <ul style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ul>,
454
+ ol: ({ children }) => <ol style={{ margin: "0 0 8px 0", paddingLeft: 18 }}>{children}</ol>,
455
+ li: ({ children }) => <li style={{ marginBottom: 3 }}>{children}</li>,
456
+ blockquote: ({ children }) => <blockquote style={blockquoteStyle}>{children}</blockquote>,
457
+ hr: () => <hr style={{ border: "none", borderTop: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, margin: "10px 0" }} />,
458
+ table: ({ children }) => (
459
+ <div style={{ width: "100%", overflowX: "auto", margin: "8px 0", borderRadius: 6, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>
460
+ <table style={{ width: "100%", borderCollapse: "collapse", fontSize: 12 }}>{children}</table>
461
+ </div>
462
+ ),
463
+ thead: ({ children }) => <thead style={{ background: "rgba(255, 255, 255, 0.04)" }}>{children}</thead>,
464
+ tbody: ({ children }) => <tbody>{children}</tbody>,
465
+ tr: ({ children }) => <tr style={{ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</tr>,
466
+ th: ({ children }) => <th style={{ padding: "6px 8px", textAlign: "left", fontWeight: 700, color: GRAPH_THEME.ui.text.strong, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</th>,
467
+ td: ({ children }) => <td style={{ padding: "6px 8px", color: GRAPH_THEME.ui.text.body, borderRight: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}` }}>{children}</td>,
468
+ pre: ({ children }) => <pre style={preBlockStyle}>{children}</pre>,
469
+ // C-1: discard `node` here too — code elements are custom components
470
+ // and would otherwise receive node="[object Object]" in the DOM.
471
+ code: ({ className: codeClass, children }) => {
472
+ const isInline = !codeClass && typeof children === "string" && !children.includes("\n");
473
+ return (
474
+ <code style={isInline ? inlineCodeStyle : blockCodeStyle}>
475
+ {children}
476
+ </code>
477
+ );
478
+ },
479
+ };
480
+
481
+ /* ─── Styles ──────────────────────────────────────────────────────── */
482
+
483
+ const viewerContainerStyle: CSSProperties = {
484
+ display: "flex",
485
+ flexDirection: "column",
486
+ background: "rgba(255, 255, 255, 0.025)",
487
+ border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
488
+ borderRadius: 12,
489
+ overflow: "hidden",
490
+ };
491
+
492
+ const viewerHeaderStyle: CSSProperties = {
493
+ display: "flex",
494
+ alignItems: "center",
495
+ justifyContent: "space-between",
496
+ gap: 8,
497
+ flexWrap: "wrap",
498
+ padding: "6px 10px",
499
+ background: "rgba(0, 0, 0, 0.2)",
500
+ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
501
+ };
502
+
503
+ const tabBtnStyle: CSSProperties = {
504
+ display: "inline-flex",
505
+ alignItems: "center",
506
+ padding: "4px 9px",
507
+ borderRadius: 6,
508
+ border: "1px solid transparent",
509
+ background: "transparent",
510
+ color: GRAPH_THEME.ui.text.muted,
511
+ fontSize: 12,
512
+ fontWeight: 600,
513
+ cursor: "pointer",
514
+ transition: "all 150ms ease",
515
+ };
516
+
517
+ const activeTabBtnStyle: CSSProperties = {
518
+ background: GRAPH_THEME.ui.timeline.playheadSoft,
519
+ border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
520
+ color: GRAPH_THEME.ui.timeline.playhead,
521
+ };
522
+
523
+ const copyBtnStyle: CSSProperties = {
524
+ display: "inline-flex",
525
+ alignItems: "center",
526
+ padding: "3px 8px",
527
+ borderRadius: 6,
528
+ border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
529
+ background: "rgba(255, 255, 255, 0.04)",
530
+ color: GRAPH_THEME.ui.text.subtle,
531
+ fontSize: 11,
532
+ cursor: "pointer",
533
+ };
534
+
535
+ const saveBtnStyle: CSSProperties = {
536
+ ...copyBtnStyle,
537
+ background: GRAPH_THEME.ui.control.primaryBg,
538
+ border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`,
539
+ color: GRAPH_THEME.ui.control.primaryText,
540
+ fontWeight: 700,
541
+ };
542
+
543
+ const errorStyle: CSSProperties = {
544
+ display: "flex",
545
+ alignItems: "center",
546
+ justifyContent: "space-between",
547
+ gap: 8,
548
+ padding: "8px 12px",
549
+ color: "#ffb4ad",
550
+ background: "rgba(248, 81, 73, 0.1)",
551
+ borderBottom: "1px solid rgba(248, 81, 73, 0.25)",
552
+ fontSize: 12,
553
+ lineHeight: 1.5,
554
+ };
555
+
556
+ const errorActionStyle: CSSProperties = {
557
+ ...copyBtnStyle,
558
+ flexShrink: 0,
559
+ color: "#ffb4ad",
560
+ border: "1px solid rgba(248, 81, 73, 0.32)",
561
+ };
562
+
563
+ const viewerBodyStyle: CSSProperties = {
564
+ padding: 12,
565
+ maxHeight: 380,
566
+ overflowY: "auto",
567
+ };
568
+
569
+ const editorStyle: CSSProperties = {
570
+ display: "block",
571
+ boxSizing: "border-box",
572
+ width: "100%",
573
+ minHeight: 280,
574
+ resize: "vertical",
575
+ padding: 10,
576
+ borderRadius: 8,
577
+ border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
578
+ background: "rgba(0, 0, 0, 0.3)",
579
+ color: GRAPH_THEME.ui.text.strong,
580
+ fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
581
+ fontSize: 12,
582
+ lineHeight: 1.6,
583
+ };
584
+
585
+ const emptyTextStyle: CSSProperties = {
586
+ color: GRAPH_THEME.ui.text.muted,
587
+ fontSize: 12,
588
+ lineHeight: 1.5,
589
+ fontStyle: "italic",
590
+ };
591
+
592
+ const sourcePreStyle: CSSProperties = {
593
+ margin: 0,
594
+ padding: 10,
595
+ borderRadius: 8,
596
+ background: "rgba(0, 0, 0, 0.3)",
597
+ border: "1px solid rgba(255, 255, 255, 0.05)",
598
+ overflowX: "auto",
599
+ };
600
+
601
+ const sourceCodeStyle: CSSProperties = {
602
+ fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
603
+ fontSize: 12,
604
+ lineHeight: 1.6,
605
+ color: GRAPH_THEME.ui.text.strong,
606
+ whiteSpace: "pre-wrap",
607
+ wordBreak: "break-word",
608
+ userSelect: "text",
609
+ };
610
+
611
+ const previewStyle: CSSProperties = {
612
+ color: GRAPH_THEME.ui.text.body,
613
+ fontSize: 13,
614
+ lineHeight: 1.6,
615
+ wordBreak: "break-word",
616
+ };
617
+
618
+ const h1Style: CSSProperties = {
619
+ fontSize: 16,
620
+ fontWeight: 700,
621
+ color: GRAPH_THEME.ui.text.strong,
622
+ marginTop: 8,
623
+ marginBottom: 6,
624
+ paddingBottom: 3,
625
+ borderBottom: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
626
+ };
627
+
628
+ const h2Style: CSSProperties = {
629
+ fontSize: 14,
630
+ fontWeight: 700,
631
+ color: GRAPH_THEME.ui.text.strong,
632
+ marginTop: 8,
633
+ marginBottom: 4,
634
+ };
635
+
636
+ const h3Style: CSSProperties = {
637
+ fontSize: 13,
638
+ fontWeight: 600,
639
+ color: GRAPH_THEME.ui.text.strong,
640
+ marginTop: 6,
641
+ marginBottom: 4,
642
+ };
643
+
644
+ const h4Style: CSSProperties = {
645
+ fontSize: 12,
646
+ fontWeight: 600,
647
+ color: GRAPH_THEME.ui.text.strong,
648
+ marginTop: 4,
649
+ marginBottom: 2,
650
+ };
651
+
652
+ const blockquoteStyle: CSSProperties = {
653
+ margin: "8px 0",
654
+ padding: "6px 12px",
655
+ borderLeft: `3px solid ${GRAPH_THEME.ui.timeline.playhead}`,
656
+ background: "rgba(98, 226, 205, 0.05)",
657
+ borderRadius: "0 6px 6px 0",
658
+ color: GRAPH_THEME.ui.text.body,
659
+ fontStyle: "italic",
660
+ };
661
+
662
+ const linkStyle: CSSProperties = {
663
+ color: "#ff8fa0",
664
+ textDecoration: "underline",
665
+ textUnderlineOffset: "3px",
666
+ wordBreak: "break-all",
667
+ };
668
+
669
+ const imageBadgeStyle: CSSProperties = {
670
+ display: "inline-flex",
671
+ alignItems: "center",
672
+ padding: "3px 7px",
673
+ background: "rgba(255, 255, 255, 0.04)",
674
+ border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
675
+ borderRadius: 6,
676
+ color: GRAPH_THEME.ui.text.muted,
677
+ fontSize: 11,
678
+ margin: "3px 0",
679
+ };
680
+
681
+ const inlineCodeStyle: CSSProperties = {
682
+ fontFamily: "'JetBrains Mono', monospace",
683
+ fontSize: 12,
684
+ padding: "2px 5px",
685
+ borderRadius: 4,
686
+ background: "rgba(255, 255, 255, 0.07)",
687
+ color: "#ffeaed",
688
+ border: "1px solid rgba(255, 255, 255, 0.08)",
689
+ };
690
+
691
+ const preBlockStyle: CSSProperties = {
692
+ margin: "8px 0",
693
+ padding: 10,
694
+ borderRadius: 8,
695
+ background: "rgba(0, 0, 0, 0.35)",
696
+ border: "1px solid rgba(255, 255, 255, 0.08)",
697
+ overflowX: "auto",
698
+ };
699
+
700
+ const blockCodeStyle: CSSProperties = {
701
+ fontFamily: "'JetBrains Mono', monospace",
702
+ fontSize: 12,
703
+ lineHeight: 1.5,
704
+ color: "#ffeaed",
705
+ };