pmx-canvas 0.1.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 (226) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/Readme.md +865 -0
  4. package/dist/canvas/global.css +3173 -0
  5. package/dist/canvas/index.js +183 -0
  6. package/dist/json-render/index.css +2 -0
  7. package/dist/json-render/index.js +389 -0
  8. package/dist/types/cli/agent.d.ts +13 -0
  9. package/dist/types/cli/index.d.ts +2 -0
  10. package/dist/types/cli/watch.d.ts +5 -0
  11. package/dist/types/client/App.d.ts +1 -0
  12. package/dist/types/client/canvas/AttentionHistory.d.ts +1 -0
  13. package/dist/types/client/canvas/AttentionToast.d.ts +1 -0
  14. package/dist/types/client/canvas/CanvasNode.d.ts +8 -0
  15. package/dist/types/client/canvas/CanvasViewport.d.ts +8 -0
  16. package/dist/types/client/canvas/CommandPalette.d.ts +4 -0
  17. package/dist/types/client/canvas/ContextMenu.d.ts +24 -0
  18. package/dist/types/client/canvas/ContextPinBar.d.ts +1 -0
  19. package/dist/types/client/canvas/ContextPinHud.d.ts +1 -0
  20. package/dist/types/client/canvas/DockedNode.d.ts +4 -0
  21. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  22. package/dist/types/client/canvas/ExpandedNodeOverlay.d.ts +1 -0
  23. package/dist/types/client/canvas/FocusFieldLayer.d.ts +1 -0
  24. package/dist/types/client/canvas/Minimap.d.ts +23 -0
  25. package/dist/types/client/canvas/SelectionBar.d.ts +1 -0
  26. package/dist/types/client/canvas/ShortcutOverlay.d.ts +3 -0
  27. package/dist/types/client/canvas/SnapshotPanel.d.ts +7 -0
  28. package/dist/types/client/canvas/snap-guides.d.ts +23 -0
  29. package/dist/types/client/canvas/use-node-drag.d.ts +15 -0
  30. package/dist/types/client/canvas/use-node-resize.d.ts +15 -0
  31. package/dist/types/client/canvas/use-pan-zoom.d.ts +16 -0
  32. package/dist/types/client/ext-app/bridge.d.ts +161 -0
  33. package/dist/types/client/icons.d.ts +70 -0
  34. package/dist/types/client/index.d.ts +1 -0
  35. package/dist/types/client/nodes/ContextNode.d.ts +34 -0
  36. package/dist/types/client/nodes/ExtAppFrame.d.ts +18 -0
  37. package/dist/types/client/nodes/FileNode.d.ts +5 -0
  38. package/dist/types/client/nodes/GroupNode.d.ts +6 -0
  39. package/dist/types/client/nodes/ImageNode.d.ts +10 -0
  40. package/dist/types/client/nodes/InlineFormatBar.d.ts +7 -0
  41. package/dist/types/client/nodes/InlineMarkdownEditor.d.ts +14 -0
  42. package/dist/types/client/nodes/LedgerNode.d.ts +4 -0
  43. package/dist/types/client/nodes/MarkdownNode.d.ts +6 -0
  44. package/dist/types/client/nodes/McpAppNode.d.ts +4 -0
  45. package/dist/types/client/nodes/MdFormatBar.d.ts +8 -0
  46. package/dist/types/client/nodes/PromptNode.d.ts +5 -0
  47. package/dist/types/client/nodes/ResponseNode.d.ts +5 -0
  48. package/dist/types/client/nodes/StatusNode.d.ts +4 -0
  49. package/dist/types/client/nodes/StatusSummary.d.ts +4 -0
  50. package/dist/types/client/nodes/TraceNode.d.ts +4 -0
  51. package/dist/types/client/nodes/WebpageNode.d.ts +5 -0
  52. package/dist/types/client/nodes/image-warnings.d.ts +6 -0
  53. package/dist/types/client/nodes/inline-editor-commands.d.ts +11 -0
  54. package/dist/types/client/nodes/md-format.d.ts +25 -0
  55. package/dist/types/client/state/attention-bridge.d.ts +3 -0
  56. package/dist/types/client/state/attention-store.d.ts +25 -0
  57. package/dist/types/client/state/canvas-store.d.ts +74 -0
  58. package/dist/types/client/state/intent-bridge.d.ts +158 -0
  59. package/dist/types/client/state/sse-bridge.d.ts +5 -0
  60. package/dist/types/client/theme/tokens.d.ts +27 -0
  61. package/dist/types/client/types.d.ts +40 -0
  62. package/dist/types/client/utils/ext-app-tool-result.d.ts +1 -0
  63. package/dist/types/client/utils/placement.d.ts +1 -0
  64. package/dist/types/client/utils/platform.d.ts +2 -0
  65. package/dist/types/json-render/catalog.d.ts +815 -0
  66. package/dist/types/json-render/charts/components.d.ts +54 -0
  67. package/dist/types/json-render/charts/definitions.d.ts +103 -0
  68. package/dist/types/json-render/charts/extra-components.d.ts +58 -0
  69. package/dist/types/json-render/charts/extra-definitions.d.ts +181 -0
  70. package/dist/types/json-render/renderer/index.d.ts +16 -0
  71. package/dist/types/json-render/schema.d.ts +46 -0
  72. package/dist/types/json-render/server.d.ts +55 -0
  73. package/dist/types/mcp/server.d.ts +22 -0
  74. package/dist/types/server/agent-context.d.ts +21 -0
  75. package/dist/types/server/artifact-paths.d.ts +3 -0
  76. package/dist/types/server/canvas-operations.d.ts +154 -0
  77. package/dist/types/server/canvas-provenance.d.ts +13 -0
  78. package/dist/types/server/canvas-schema.d.ts +49 -0
  79. package/dist/types/server/canvas-serialization.d.ts +25 -0
  80. package/dist/types/server/canvas-state.d.ts +174 -0
  81. package/dist/types/server/canvas-validation.d.ts +33 -0
  82. package/dist/types/server/chart-template.d.ts +29 -0
  83. package/dist/types/server/code-graph.d.ts +67 -0
  84. package/dist/types/server/context-cards.d.ts +24 -0
  85. package/dist/types/server/diagram-presets.d.ts +28 -0
  86. package/dist/types/server/ext-app-call-registry.d.ts +16 -0
  87. package/dist/types/server/ext-app-tool-result.d.ts +1 -0
  88. package/dist/types/server/file-watcher.d.ts +16 -0
  89. package/dist/types/server/index.d.ts +243 -0
  90. package/dist/types/server/mcp-app-candidate.d.ts +25 -0
  91. package/dist/types/server/mcp-app-host.d.ts +65 -0
  92. package/dist/types/server/mcp-app-runtime.d.ts +47 -0
  93. package/dist/types/server/mutation-history.d.ts +105 -0
  94. package/dist/types/server/placement.d.ts +37 -0
  95. package/dist/types/server/server.d.ts +103 -0
  96. package/dist/types/server/spatial-analysis.d.ts +87 -0
  97. package/dist/types/server/trace-manager.d.ts +48 -0
  98. package/dist/types/server/web-artifacts.d.ts +50 -0
  99. package/dist/types/server/webpage-node.d.ts +25 -0
  100. package/dist/types/shared/auto-arrange.d.ts +29 -0
  101. package/dist/types/shared/ext-app-tool-result.d.ts +9 -0
  102. package/dist/types/shared/placement.d.ts +26 -0
  103. package/dist/types/shared/semantic-attention.d.ts +97 -0
  104. package/package.json +109 -0
  105. package/skills/data-analysis/SKILL.md +324 -0
  106. package/skills/doc-coauthoring/SKILL.md +375 -0
  107. package/skills/frontend-design/SKILL.md +45 -0
  108. package/skills/json-render-codegen/SKILL.md +112 -0
  109. package/skills/json-render-core/SKILL.md +265 -0
  110. package/skills/json-render-ink/SKILL.md +273 -0
  111. package/skills/json-render-mcp/SKILL.md +132 -0
  112. package/skills/json-render-react/SKILL.md +264 -0
  113. package/skills/json-render-shadcn/SKILL.md +159 -0
  114. package/skills/playwright-cli/SKILL.md +67 -0
  115. package/skills/pmx-canvas/SKILL.md +668 -0
  116. package/skills/pmx-canvas/evals/evals.json +186 -0
  117. package/skills/pmx-canvas-testing/SKILL.md +78 -0
  118. package/skills/published-consumer-e2e/SKILL.md +43 -0
  119. package/skills/published-consumer-e2e/scripts/run-published-consumer-e2e.sh +241 -0
  120. package/skills/web-artifacts-builder/SKILL.md +80 -0
  121. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +167 -0
  122. package/skills/web-artifacts-builder/scripts/init-artifact.sh +425 -0
  123. package/skills/web-artifacts-builder/scripts/shadcn-components.tar.gz +0 -0
  124. package/skills/web-design-guidelines/SKILL.md +39 -0
  125. package/src/cli/agent.ts +2144 -0
  126. package/src/cli/index.ts +622 -0
  127. package/src/cli/watch.ts +88 -0
  128. package/src/client/App.tsx +507 -0
  129. package/src/client/canvas/AttentionHistory.tsx +81 -0
  130. package/src/client/canvas/AttentionToast.tsx +19 -0
  131. package/src/client/canvas/CanvasNode.tsx +363 -0
  132. package/src/client/canvas/CanvasViewport.tsx +590 -0
  133. package/src/client/canvas/CommandPalette.tsx +302 -0
  134. package/src/client/canvas/ContextMenu.tsx +601 -0
  135. package/src/client/canvas/ContextPinBar.tsx +25 -0
  136. package/src/client/canvas/ContextPinHud.tsx +22 -0
  137. package/src/client/canvas/DockedNode.tsx +66 -0
  138. package/src/client/canvas/EdgeLayer.tsx +280 -0
  139. package/src/client/canvas/ExpandedNodeOverlay.tsx +260 -0
  140. package/src/client/canvas/FocusFieldLayer.tsx +107 -0
  141. package/src/client/canvas/Minimap.tsx +301 -0
  142. package/src/client/canvas/SelectionBar.tsx +69 -0
  143. package/src/client/canvas/ShortcutOverlay.tsx +69 -0
  144. package/src/client/canvas/SnapshotPanel.tsx +236 -0
  145. package/src/client/canvas/snap-guides.ts +170 -0
  146. package/src/client/canvas/use-node-drag.ts +51 -0
  147. package/src/client/canvas/use-node-resize.ts +59 -0
  148. package/src/client/canvas/use-pan-zoom.ts +191 -0
  149. package/src/client/ext-app/bridge.ts +542 -0
  150. package/src/client/icons.tsx +424 -0
  151. package/src/client/index.tsx +7 -0
  152. package/src/client/nodes/ContextNode.tsx +412 -0
  153. package/src/client/nodes/ExtAppFrame.tsx +509 -0
  154. package/src/client/nodes/FileNode.tsx +256 -0
  155. package/src/client/nodes/GroupNode.tsx +39 -0
  156. package/src/client/nodes/ImageNode.tsx +160 -0
  157. package/src/client/nodes/InlineFormatBar.tsx +169 -0
  158. package/src/client/nodes/InlineMarkdownEditor.tsx +123 -0
  159. package/src/client/nodes/LedgerNode.tsx +37 -0
  160. package/src/client/nodes/MarkdownNode.tsx +359 -0
  161. package/src/client/nodes/McpAppNode.tsx +85 -0
  162. package/src/client/nodes/MdFormatBar.tsx +109 -0
  163. package/src/client/nodes/PromptNode.tsx +597 -0
  164. package/src/client/nodes/ResponseNode.tsx +153 -0
  165. package/src/client/nodes/StatusNode.tsx +84 -0
  166. package/src/client/nodes/StatusSummary.tsx +38 -0
  167. package/src/client/nodes/TraceNode.tsx +120 -0
  168. package/src/client/nodes/WebpageNode.tsx +288 -0
  169. package/src/client/nodes/image-warnings.ts +95 -0
  170. package/src/client/nodes/inline-editor-commands.ts +37 -0
  171. package/src/client/nodes/md-format.ts +206 -0
  172. package/src/client/state/attention-bridge.ts +328 -0
  173. package/src/client/state/attention-store.ts +73 -0
  174. package/src/client/state/canvas-store.ts +631 -0
  175. package/src/client/state/intent-bridge.ts +315 -0
  176. package/src/client/state/sse-bridge.ts +965 -0
  177. package/src/client/theme/global.css +3173 -0
  178. package/src/client/theme/tokens.ts +72 -0
  179. package/src/client/types-shims.d.ts +5 -0
  180. package/src/client/types.ts +81 -0
  181. package/src/client/utils/ext-app-tool-result.ts +4 -0
  182. package/src/client/utils/placement.ts +4 -0
  183. package/src/client/utils/platform.ts +2 -0
  184. package/src/json-render/catalog.ts +256 -0
  185. package/src/json-render/charts/components.tsx +198 -0
  186. package/src/json-render/charts/definitions.ts +81 -0
  187. package/src/json-render/charts/extra-components.tsx +267 -0
  188. package/src/json-render/charts/extra-definitions.ts +145 -0
  189. package/src/json-render/renderer/index.css +174 -0
  190. package/src/json-render/renderer/index.tsx +86 -0
  191. package/src/json-render/schema.ts +62 -0
  192. package/src/json-render/server.ts +597 -0
  193. package/src/mcp/server.ts +1377 -0
  194. package/src/server/agent-context.ts +242 -0
  195. package/src/server/artifact-paths.ts +17 -0
  196. package/src/server/canvas-operations.ts +1279 -0
  197. package/src/server/canvas-provenance.ts +243 -0
  198. package/src/server/canvas-schema.ts +432 -0
  199. package/src/server/canvas-serialization.ts +95 -0
  200. package/src/server/canvas-state.ts +1134 -0
  201. package/src/server/canvas-validation.ts +114 -0
  202. package/src/server/chart-template.ts +449 -0
  203. package/src/server/code-graph.ts +370 -0
  204. package/src/server/context-cards.ts +31 -0
  205. package/src/server/diagram-presets.ts +71 -0
  206. package/src/server/ext-app-call-registry.ts +77 -0
  207. package/src/server/ext-app-tool-result.ts +4 -0
  208. package/src/server/file-watcher.ts +121 -0
  209. package/src/server/index.ts +647 -0
  210. package/src/server/mcp-app-candidate.ts +174 -0
  211. package/src/server/mcp-app-host.ts +814 -0
  212. package/src/server/mcp-app-runtime.ts +459 -0
  213. package/src/server/mutation-history.ts +350 -0
  214. package/src/server/placement.ts +125 -0
  215. package/src/server/server.ts +3846 -0
  216. package/src/server/spatial-analysis.ts +356 -0
  217. package/src/server/trace-manager.ts +333 -0
  218. package/src/server/web-artifacts/scripts/bundle-artifact.sh +167 -0
  219. package/src/server/web-artifacts/scripts/init-artifact.sh +426 -0
  220. package/src/server/web-artifacts/scripts/shadcn-components.tar.gz +0 -0
  221. package/src/server/web-artifacts.ts +442 -0
  222. package/src/server/webpage-node.ts +328 -0
  223. package/src/shared/auto-arrange.ts +439 -0
  224. package/src/shared/ext-app-tool-result.ts +76 -0
  225. package/src/shared/placement.ts +81 -0
  226. package/src/shared/semantic-attention.ts +598 -0
@@ -0,0 +1,123 @@
1
+ import { gfm } from '@joplin/turndown-plugin-gfm';
2
+ import TurndownService from 'turndown';
3
+ import { useCallback, useEffect, useRef } from 'preact/hooks';
4
+ import { InlineFormatBar } from './InlineFormatBar';
5
+ import { promptAndInsertLink } from './inline-editor-commands';
6
+
7
+ let _turndown: TurndownService | null = null;
8
+ function getTurndown(): TurndownService {
9
+ if (_turndown) return _turndown;
10
+ const td = new TurndownService({
11
+ headingStyle: 'atx',
12
+ codeBlockStyle: 'fenced',
13
+ emDelimiter: '*',
14
+ bulletListMarker: '-',
15
+ });
16
+ td.use(gfm);
17
+ _turndown = td;
18
+ return td;
19
+ }
20
+
21
+ /** Fully inline WYSIWYG editor. The rendered HTML is the editor.
22
+ *
23
+ * Two persistence signals:
24
+ * - `onChange(md)` — fires on every input. Caller typically debounces.
25
+ * - `onSave(md)` — fires on ⌘S and blur. Caller should persist immediately
26
+ * and cancel any pending debounced save, since `md` is the authoritative
27
+ * latest content. Both receive freshly-serialized markdown so the caller
28
+ * never reads a stale state snapshot. */
29
+ export function InlineMarkdownEditor({
30
+ initialHtml,
31
+ className,
32
+ onChange,
33
+ onSave,
34
+ }: {
35
+ initialHtml: string;
36
+ className?: string;
37
+ onChange: (markdown: string) => void;
38
+ onSave?: (markdown: string) => void;
39
+ }) {
40
+ const rootRef = useRef<HTMLDivElement>(null);
41
+ // Capture mount-time HTML — decouples us from prop identity churn under
42
+ // strict-mode double-invoke. Re-mount via `key` to swap documents.
43
+ const initialHtmlRef = useRef(initialHtml);
44
+
45
+ useEffect(() => {
46
+ const el = rootRef.current;
47
+ if (!el) return;
48
+ el.innerHTML = initialHtmlRef.current;
49
+ }, []);
50
+
51
+ const serialize = useCallback((): string => {
52
+ const el = rootRef.current;
53
+ if (!el) return '';
54
+ return getTurndown().turndown(el.innerHTML);
55
+ }, []);
56
+
57
+ const handleInput = useCallback(() => {
58
+ onChange(serialize());
59
+ }, [onChange, serialize]);
60
+
61
+ const handleSave = useCallback(() => {
62
+ onSave?.(serialize());
63
+ }, [onSave, serialize]);
64
+
65
+ const handleKeyDown = useCallback(
66
+ (e: KeyboardEvent) => {
67
+ if (!(e.metaKey || e.ctrlKey)) return;
68
+ if (e.key === 's') {
69
+ e.preventDefault();
70
+ handleSave();
71
+ return;
72
+ }
73
+ if (e.key === 'b') {
74
+ e.preventDefault();
75
+ document.execCommand('bold');
76
+ handleInput();
77
+ return;
78
+ }
79
+ if (e.key === 'i') {
80
+ e.preventDefault();
81
+ document.execCommand('italic');
82
+ handleInput();
83
+ return;
84
+ }
85
+ if (e.key === 'k') {
86
+ e.preventDefault();
87
+ promptAndInsertLink();
88
+ handleInput();
89
+ }
90
+ },
91
+ [handleInput, handleSave],
92
+ );
93
+
94
+ // Arbitrary rich HTML (Word, the web) would force turndown to sanitize a
95
+ // far wider surface and tends to lose fidelity — plain text is safer.
96
+ const handlePaste = useCallback(
97
+ (e: ClipboardEvent) => {
98
+ const text = e.clipboardData?.getData('text/plain');
99
+ if (text == null) return;
100
+ e.preventDefault();
101
+ document.execCommand('insertText', false, text);
102
+ handleInput();
103
+ },
104
+ [handleInput],
105
+ );
106
+
107
+ return (
108
+ <>
109
+ <div
110
+ ref={rootRef}
111
+ class={className}
112
+ contentEditable
113
+ spellcheck={false}
114
+ onInput={handleInput}
115
+ onKeyDown={handleKeyDown}
116
+ onPaste={handlePaste}
117
+ onBlur={handleSave}
118
+ />
119
+ <InlineFormatBar hostRef={rootRef} onChange={handleInput} />
120
+ </>
121
+ );
122
+ }
123
+
@@ -0,0 +1,37 @@
1
+ import type { CanvasNodeState } from '../types';
2
+
3
+ export function LedgerNode({ node }: { node: CanvasNodeState }) {
4
+ const data = node.data as Record<string, unknown>;
5
+
6
+ // Render key-value pairs from ledger summary
7
+ const entries = Object.entries(data).filter(([key]) => key !== 'title' && key !== '__type');
8
+
9
+ if (entries.length === 0) {
10
+ return (
11
+ <div style={{ color: 'var(--c-dim)', fontSize: '12px', fontStyle: 'italic' }}>No ledger data</div>
12
+ );
13
+ }
14
+
15
+ return (
16
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', fontSize: '12px' }}>
17
+ {entries.map(([key, value]) => (
18
+ <div
19
+ key={key}
20
+ style={{
21
+ display: 'flex',
22
+ justifyContent: 'space-between',
23
+ padding: '3px 0',
24
+ borderBottom: '1px solid rgba(45,55,90,0.3)',
25
+ }}
26
+ >
27
+ <span style={{ color: 'var(--c-muted)', fontSize: '11px' }}>
28
+ {key.replace(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())}
29
+ </span>
30
+ <span style={{ color: 'var(--c-text)', fontFamily: 'var(--mono)', fontSize: '11px' }}>
31
+ {typeof value === 'object' ? JSON.stringify(value) : String(value ?? '—')}
32
+ </span>
33
+ </div>
34
+ ))}
35
+ </div>
36
+ );
37
+ }
@@ -0,0 +1,359 @@
1
+ import type { JSX } from 'preact';
2
+ import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
3
+ import { expandNode, updateNodeData } from '../state/canvas-store';
4
+ import { fetchFile, renderMarkdown, saveFile, updateNodeFromClient } from '../state/intent-bridge';
5
+ import type { CanvasNodeState } from '../types';
6
+ import { MdFormatBar } from './MdFormatBar';
7
+ import { handleFormatShortcut, handleTab } from './md-format';
8
+ import { InlineMarkdownEditor } from './InlineMarkdownEditor';
9
+
10
+ function RenderedMarkdown({
11
+ html,
12
+ className,
13
+ style,
14
+ }: {
15
+ html: string;
16
+ className?: string;
17
+ style?: string | JSX.CSSProperties;
18
+ }) {
19
+ const containerRef = useRef<HTMLDivElement>(null);
20
+
21
+ useEffect(() => {
22
+ const container = containerRef.current;
23
+ if (!container) return;
24
+ container.replaceChildren();
25
+ if (!html) return;
26
+ const template = document.createElement('template');
27
+ template.innerHTML = html;
28
+ container.append(template.content.cloneNode(true));
29
+ }, [html]);
30
+
31
+ return <div ref={containerRef} class={className} style={style} />;
32
+ }
33
+
34
+ export function MarkdownNode({
35
+ node,
36
+ expanded = false,
37
+ }: { node: CanvasNodeState; expanded?: boolean }) {
38
+ const path = node.data.path as string;
39
+ const [content, setContent] = useState('');
40
+ const [rendered, setRendered] = useState('');
41
+ const [loaded, setLoaded] = useState(false);
42
+ const [sourceMode, setSourceMode] = useState(false);
43
+ const [saving, setSaving] = useState(false);
44
+ const [dirty, setDirty] = useState(false);
45
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
46
+ const persistTimerRef = useRef<number | null>(null);
47
+ // Always-current md + saver, so the unmount cleanup can flush a pending
48
+ // debounced save without capturing stale closures.
49
+ const latestMdRef = useRef<string>('');
50
+ const persistFnRef = useRef<((md: string) => Promise<void>) | null>(null);
51
+ const reviewActive = node.data.reviewActive as boolean | undefined;
52
+
53
+ useEffect(() => {
54
+ let cancelled = false;
55
+ (async () => {
56
+ let raw: string;
57
+ if (path) {
58
+ const result = await fetchFile(path);
59
+ if (cancelled) return;
60
+ raw = result.content;
61
+ } else if (node.data.content) {
62
+ raw = node.data.content as string;
63
+ } else {
64
+ setLoaded(true);
65
+ return;
66
+ }
67
+ setContent(raw);
68
+ const html = await renderMarkdown(raw);
69
+ if (cancelled) return;
70
+ setRendered(html);
71
+ setLoaded(true);
72
+ updateNodeData(node.id, { content: raw, rendered: html });
73
+ })();
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, [path, node.id, node.data.content]);
78
+
79
+ const handleInput = useCallback(async (e: Event) => {
80
+ const value = (e.target as HTMLTextAreaElement).value;
81
+ setContent(value);
82
+ setDirty(true);
83
+ const html = await renderMarkdown(value);
84
+ setRendered(html);
85
+ }, []);
86
+
87
+ const persistContent = useCallback(
88
+ async (newContent: string) => {
89
+ if (!path) {
90
+ const html = await renderMarkdown(newContent);
91
+ setRendered(html);
92
+ setDirty(false);
93
+ updateNodeData(node.id, { content: newContent, rendered: html });
94
+ await updateNodeFromClient(node.id, { content: newContent, data: { rendered: html } });
95
+ return;
96
+ }
97
+ setSaving(true);
98
+ const result = await saveFile(path, newContent);
99
+ setSaving(false);
100
+ if (result.ok) {
101
+ const html = await renderMarkdown(newContent);
102
+ setRendered(html);
103
+ setDirty(false);
104
+ updateNodeData(node.id, { content: newContent, rendered: html, savedAt: result.updatedAt });
105
+ await updateNodeFromClient(node.id, {
106
+ content: newContent,
107
+ data: { rendered: html, savedAt: result.updatedAt },
108
+ });
109
+ }
110
+ },
111
+ [path, node.id],
112
+ );
113
+
114
+ const handleSave = useCallback(async () => {
115
+ if (!dirty) return;
116
+ await persistContent(content);
117
+ }, [dirty, content, persistContent]);
118
+
119
+ const handleKeyDown = useCallback(
120
+ (e: KeyboardEvent) => {
121
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
122
+ e.preventDefault();
123
+ handleSave();
124
+ return;
125
+ }
126
+ const ta = textareaRef.current;
127
+ if (ta && handleFormatShortcut(e, ta)) return;
128
+ if (ta && e.key === 'Tab') {
129
+ e.preventDefault();
130
+ handleTab(ta, e.shiftKey);
131
+ }
132
+ },
133
+ [handleSave],
134
+ );
135
+
136
+ // Keep refs in sync so the unmount-cleanup effect below can flush with
137
+ // the freshest values instead of closure captures.
138
+ persistFnRef.current = persistContent;
139
+
140
+ // Editor fires onChange on every keystroke; debounce the server save so we
141
+ // don't hit the backend on every letter.
142
+ const handleInlineChange = useCallback(
143
+ (md: string) => {
144
+ setContent(md);
145
+ setDirty(true);
146
+ latestMdRef.current = md;
147
+ if (persistTimerRef.current !== null) window.clearTimeout(persistTimerRef.current);
148
+ persistTimerRef.current = window.setTimeout(() => {
149
+ persistTimerRef.current = null;
150
+ void persistContent(md);
151
+ }, 800);
152
+ },
153
+ [persistContent],
154
+ );
155
+
156
+ // Fires on ⌘S and blur — persist immediately with whatever markdown the
157
+ // editor just serialized. Cancels any pending debounced save so we don't
158
+ // write twice with slightly different content.
159
+ const handleInlineSave = useCallback(
160
+ (md: string) => {
161
+ setContent(md);
162
+ latestMdRef.current = md;
163
+ if (persistTimerRef.current !== null) {
164
+ window.clearTimeout(persistTimerRef.current);
165
+ persistTimerRef.current = null;
166
+ }
167
+ void persistContent(md);
168
+ },
169
+ [persistContent],
170
+ );
171
+
172
+ // On unmount / node switch, flush any pending debounced save so trailing
173
+ // keystrokes aren't dropped when the user switches to another document.
174
+ useEffect(() => {
175
+ return () => {
176
+ if (persistTimerRef.current === null) return;
177
+ window.clearTimeout(persistTimerRef.current);
178
+ persistTimerRef.current = null;
179
+ const fn = persistFnRef.current;
180
+ if (fn) void fn(latestMdRef.current);
181
+ };
182
+ }, [node.id]);
183
+
184
+ const reviewBanner = reviewActive ? (
185
+ <div
186
+ style={{
187
+ padding: '4px 8px',
188
+ fontSize: '10px',
189
+ background: 'var(--c-ok-10)',
190
+ color: 'var(--c-ok)',
191
+ borderBottom: '1px solid var(--c-ok-20)',
192
+ textTransform: 'uppercase',
193
+ letterSpacing: '0.05em',
194
+ fontWeight: 600,
195
+ }}
196
+ >
197
+ Review active
198
+ </div>
199
+ ) : null;
200
+
201
+ // ── Raw source editor (escape hatch) ──────────────────────────
202
+
203
+ if (sourceMode && expanded) {
204
+ return (
205
+ <div class="md-editor-expanded" onKeyDown={handleKeyDown}>
206
+ <div class="md-editor-toolbar">
207
+ <button type="button" class="md-toolbar-btn" onClick={() => setSourceMode(false)}>
208
+ ← Back to document
209
+ </button>
210
+ <div style={{ display: 'flex', gap: '6px', alignItems: 'center' }}>
211
+ {path && <span class="md-toolbar-path">{path.split('/').pop()}</span>}
212
+ <button
213
+ type="button"
214
+ class={`md-toolbar-btn${dirty ? ' md-toolbar-btn-primary' : ''}`}
215
+ onClick={handleSave}
216
+ disabled={!dirty || saving}
217
+ >
218
+ {saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
219
+ </button>
220
+ </div>
221
+ </div>
222
+ <div class="md-editor-split">
223
+ <div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column' }}>
224
+ <textarea ref={textareaRef} value={content} onInput={handleInput} spellcheck={false} />
225
+ <MdFormatBar textareaRef={textareaRef} />
226
+ </div>
227
+ {/* D5/H4: Trust boundary — rendered HTML comes from server-side marked()
228
+ on the user's own markdown files, served only on 127.0.0.1. No DOMPurify
229
+ needed for this localhost-only rendering of user-owned content. */}
230
+ <RenderedMarkdown html={rendered} className="md-preview" />
231
+ </div>
232
+ </div>
233
+ );
234
+ }
235
+
236
+ if (sourceMode) {
237
+ return (
238
+ <div class="md-editor-split" style={{ height: '100%' }} onKeyDown={handleKeyDown}>
239
+ <div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column' }}>
240
+ <textarea ref={textareaRef} value={content} onInput={handleInput} spellcheck={false} />
241
+ <MdFormatBar textareaRef={textareaRef} />
242
+ </div>
243
+ <RenderedMarkdown html={rendered} className="md-preview" />
244
+ <div
245
+ style={{
246
+ position: 'absolute',
247
+ bottom: '8px',
248
+ right: '8px',
249
+ display: 'flex',
250
+ gap: '6px',
251
+ }}
252
+ >
253
+ <button
254
+ type="button"
255
+ onClick={() => setSourceMode(false)}
256
+ style={{
257
+ padding: '4px 10px',
258
+ fontSize: '11px',
259
+ background: 'var(--c-input-bg)',
260
+ border: '1px solid var(--c-line)',
261
+ borderRadius: '6px',
262
+ color: 'var(--c-text-soft)',
263
+ cursor: 'pointer',
264
+ }}
265
+ >
266
+ Document
267
+ </button>
268
+ <button
269
+ type="button"
270
+ onClick={handleSave}
271
+ disabled={!dirty || saving}
272
+ style={{
273
+ padding: '4px 10px',
274
+ fontSize: '11px',
275
+ background: dirty ? 'var(--c-accent-25)' : 'var(--c-input-bg)',
276
+ border: `1px solid ${dirty ? 'var(--c-accent)' : 'var(--c-line)'}`,
277
+ borderRadius: '6px',
278
+ color: dirty ? 'var(--c-text)' : 'var(--c-dim)',
279
+ cursor: dirty ? 'pointer' : 'default',
280
+ }}
281
+ >
282
+ {saving ? 'Saving…' : dirty ? 'Save' : 'Saved'}
283
+ </button>
284
+ </div>
285
+ </div>
286
+ );
287
+ }
288
+
289
+ // ── Expanded document mode (inline WYSIWYG) ───────────────────
290
+
291
+ if (expanded) {
292
+ return (
293
+ <div style={{ height: '100%', position: 'relative' }}>
294
+ {reviewBanner}
295
+ <div class="md-reader">
296
+ {loaded ? (
297
+ <InlineMarkdownEditor
298
+ key={node.id}
299
+ initialHtml={rendered || '<p><br></p>'}
300
+ className="md-reader-content md-reader-editable"
301
+ onChange={handleInlineChange}
302
+ onSave={handleInlineSave}
303
+ />
304
+ ) : (
305
+ <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '24px' }}>
306
+ Loading…
307
+ </div>
308
+ )}
309
+ </div>
310
+ <button type="button" class="md-edit-fab" onClick={() => setSourceMode(true)}>
311
+ {'</> Source'}
312
+ </button>
313
+ </div>
314
+ );
315
+ }
316
+
317
+ // ── Card preview ──────────────────────────────────────────────
318
+
319
+ return (
320
+ <div style={{ height: '100%', position: 'relative' }}>
321
+ {reviewBanner}
322
+ <RenderedMarkdown
323
+ html={rendered}
324
+ style={{ padding: rendered ? '0' : '12px', color: rendered ? undefined : 'var(--c-dim)' }}
325
+ />
326
+ {!loaded && (
327
+ <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Loading…</div>
328
+ )}
329
+ {loaded && !rendered && (
330
+ <div style={{ color: 'var(--c-dim)', fontStyle: 'italic', padding: '12px' }}>Empty node</div>
331
+ )}
332
+ <button
333
+ type="button"
334
+ onClick={() => expandNode(node.id)}
335
+ style={{
336
+ position: 'absolute',
337
+ top: '4px',
338
+ right: '4px',
339
+ padding: '3px 8px',
340
+ fontSize: '10px',
341
+ background: 'var(--c-panel-overlay)',
342
+ border: '1px solid var(--c-line)',
343
+ borderRadius: '4px',
344
+ color: 'var(--c-text-soft)',
345
+ cursor: 'pointer',
346
+ opacity: 0.7,
347
+ }}
348
+ onMouseEnter={(e) => {
349
+ (e.target as HTMLElement).style.opacity = '1';
350
+ }}
351
+ onMouseLeave={(e) => {
352
+ (e.target as HTMLElement).style.opacity = '0.7';
353
+ }}
354
+ >
355
+ Edit
356
+ </button>
357
+ </div>
358
+ );
359
+ }
@@ -0,0 +1,85 @@
1
+ import type { CanvasNodeState } from '../types';
2
+ import { canvasTheme } from '../state/canvas-store';
3
+ import { ExtAppFrame } from './ExtAppFrame';
4
+
5
+ function withTheme(url: string): string {
6
+ if (!url) return url;
7
+ try {
8
+ const resolved = new URL(url, window.location.origin);
9
+ resolved.searchParams.set('theme', canvasTheme.value === 'light' ? 'light' : 'dark');
10
+ return resolved.toString();
11
+ } catch {
12
+ return url;
13
+ }
14
+ }
15
+
16
+ export function McpAppNode({ node }: { node: CanvasNodeState }) {
17
+ if (node.data.mode === 'ext-app') {
18
+ return <ExtAppFrame node={node} />;
19
+ }
20
+
21
+ const url = withTheme((node.data.url as string) || '');
22
+ const sourceServer = (node.data.sourceServer as string) || '';
23
+ const hostMode = (node.data.hostMode as string) || 'hosted';
24
+ const fallbackReason = node.data.fallbackReason as string | undefined;
25
+ const trustedDomain = node.data.trustedDomain as boolean | undefined;
26
+
27
+ if (hostMode === 'fallback') {
28
+ return (
29
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', fontSize: '12px' }}>
30
+ <div style={{ color: 'var(--c-warn)', display: 'flex', alignItems: 'center', gap: '6px' }}>
31
+ <span>⚠</span>
32
+ <span>Cannot embed — opened externally</span>
33
+ </div>
34
+ {fallbackReason && (
35
+ <div style={{ color: 'var(--c-muted)', fontSize: '11px' }}>Reason: {fallbackReason}</div>
36
+ )}
37
+ <a
38
+ href={url}
39
+ target="_blank"
40
+ rel="noopener noreferrer"
41
+ style={{
42
+ color: 'var(--c-accent)',
43
+ fontSize: '12px',
44
+ wordBreak: 'break-all',
45
+ }}
46
+ >
47
+ {url}
48
+ </a>
49
+ {sourceServer && (
50
+ <div style={{ color: 'var(--c-dim)', fontSize: '10px' }}>Source: {sourceServer}</div>
51
+ )}
52
+ </div>
53
+ );
54
+ }
55
+
56
+ return (
57
+ <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
58
+ {!trustedDomain && (
59
+ <div
60
+ style={{
61
+ padding: '4px 8px',
62
+ fontSize: '10px',
63
+ background: 'var(--c-warn-10)',
64
+ color: 'var(--c-warn)',
65
+ borderBottom: '1px solid var(--c-warn-15)',
66
+ }}
67
+ >
68
+ Unverified domain
69
+ </div>
70
+ )}
71
+ {/* Plain iframe-backed viewers stay on an opaque origin. Hosted ext-apps use
72
+ the explicit postMessage bridge instead, which is the only path that needs
73
+ app/host RPC and broader capabilities. */}
74
+ <iframe
75
+ src={url}
76
+ class="mcp-app-frame"
77
+ sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
78
+ allow="clipboard-read; clipboard-write"
79
+ loading="lazy"
80
+ style={{ flex: 1 }}
81
+ title={`MCP App: ${sourceServer}`}
82
+ />
83
+ </div>
84
+ );
85
+ }