pmx-canvas 0.1.19 → 0.1.20

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 (57) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/Readme.md +19 -6
  3. package/dist/canvas/global.css +35 -2
  4. package/dist/canvas/index.js +70 -69
  5. package/dist/json-render/index.js +109 -109
  6. package/dist/types/client/canvas/CanvasViewport.d.ts +1 -1
  7. package/dist/types/client/icons.d.ts +2 -0
  8. package/dist/types/client/state/canvas-store.d.ts +2 -0
  9. package/dist/types/client/types.d.ts +2 -1
  10. package/dist/types/json-render/charts/components.d.ts +5 -1
  11. package/dist/types/json-render/renderer/index.d.ts +1 -0
  12. package/dist/types/json-render/server.d.ts +1 -0
  13. package/dist/types/mcp/canvas-access.d.ts +3 -0
  14. package/dist/types/server/canvas-operations.d.ts +4 -0
  15. package/dist/types/server/canvas-schema.d.ts +19 -3
  16. package/dist/types/server/canvas-serialization.d.ts +1 -0
  17. package/dist/types/server/canvas-state.d.ts +6 -2
  18. package/dist/types/server/html-primitives.d.ts +34 -0
  19. package/dist/types/server/index.d.ts +19 -0
  20. package/docs/cli.md +4 -1
  21. package/docs/http-api.md +10 -0
  22. package/docs/mcp.md +6 -4
  23. package/docs/node-types.md +30 -2
  24. package/docs/screenshot.png +0 -0
  25. package/docs/sdk.md +11 -0
  26. package/package.json +1 -1
  27. package/skills/pmx-canvas/SKILL.md +8 -0
  28. package/src/cli/agent.ts +150 -5
  29. package/src/client/App.tsx +20 -1
  30. package/src/client/canvas/AnnotationLayer.tsx +33 -12
  31. package/src/client/canvas/CanvasViewport.tsx +88 -7
  32. package/src/client/canvas/CommandPalette.tsx +1 -1
  33. package/src/client/canvas/ContextMenu.tsx +2 -2
  34. package/src/client/canvas/ExpandedNodeOverlay.tsx +7 -1
  35. package/src/client/icons.tsx +13 -0
  36. package/src/client/nodes/McpAppNode.tsx +12 -4
  37. package/src/client/state/canvas-store.ts +15 -5
  38. package/src/client/state/sse-bridge.ts +4 -3
  39. package/src/client/theme/global.css +35 -2
  40. package/src/client/types.ts +2 -1
  41. package/src/json-render/charts/components.tsx +41 -7
  42. package/src/json-render/charts/extra-components.tsx +13 -12
  43. package/src/json-render/renderer/index.tsx +1 -0
  44. package/src/json-render/server.ts +3 -1
  45. package/src/mcp/canvas-access.ts +23 -0
  46. package/src/mcp/server.ts +83 -27
  47. package/src/server/agent-context.ts +17 -0
  48. package/src/server/canvas-operations.ts +91 -38
  49. package/src/server/canvas-schema.ts +83 -3
  50. package/src/server/canvas-serialization.ts +9 -2
  51. package/src/server/canvas-state.ts +9 -4
  52. package/src/server/demo-state.json +1143 -0
  53. package/src/server/demo.ts +25 -777
  54. package/src/server/html-primitives.ts +990 -0
  55. package/src/server/index.ts +43 -2
  56. package/src/server/server.ts +138 -14
  57. package/src/server/spatial-analysis.ts +3 -3
package/src/cli/agent.ts CHANGED
@@ -73,6 +73,15 @@ interface CanvasSchemaResponse {
73
73
  'line' | 'bar' | 'pie' | 'area' | 'scatter' | 'radar' | 'stacked-bar' | 'composed'
74
74
  >;
75
75
  };
76
+ htmlPrimitives?: Array<{
77
+ kind: string;
78
+ title: string;
79
+ description: string;
80
+ useWhen: string;
81
+ defaultSize: { width: number; height: number };
82
+ dataShape: string;
83
+ example: Record<string, unknown>;
84
+ }>;
76
85
  mcp: {
77
86
  tools: string[];
78
87
  resources: string[];
@@ -480,6 +489,14 @@ function parseJsonValue(raw: string, label: string, hint: string): unknown {
480
489
  }
481
490
  }
482
491
 
492
+ function parseJsonRecord(raw: string, label: string, hint: string): Record<string, unknown> {
493
+ const parsed = parseJsonValue(raw, label, hint);
494
+ if (!isRecord(parsed)) {
495
+ die(`${label} must be a JSON object.`, hint);
496
+ }
497
+ return parsed;
498
+ }
499
+
483
500
  async function readTextInput(
484
501
  flags: Record<string, string | true>,
485
502
  options: {
@@ -615,6 +632,34 @@ async function buildJsonRenderRequestBody(
615
632
  return body;
616
633
  }
617
634
 
635
+ async function buildHtmlPrimitiveRequestBody(
636
+ flags: Record<string, string | true>,
637
+ ): Promise<Record<string, unknown>> {
638
+ const hint = 'Use: pmx-canvas html primitive add --kind choice-grid --data-file ./primitive.json --title "Options"';
639
+ const kind = getStringFlag(flags, 'kind', 'primitive');
640
+ if (!kind) die('HTML primitives require --kind.', hint);
641
+ const body: Record<string, unknown> = { type: 'html', primitive: kind };
642
+ if (typeof flags.title === 'string') body.title = flags.title;
643
+ const rawData = await readOptionalTextInput(flags, {
644
+ fileFlags: ['data-file'],
645
+ valueFlags: ['data-json', 'data'],
646
+ allowStdin: true,
647
+ label: 'HTML primitive data',
648
+ hint,
649
+ });
650
+ if (rawData !== undefined) {
651
+ body.data = parseJsonRecord(rawData, 'HTML primitive data', hint);
652
+ }
653
+ applyCommonGeometryFlags(body, flags, {
654
+ x: 'Use a finite number, e.g. --x 500',
655
+ y: 'Use a finite number, e.g. --y 300',
656
+ width: 'Use a positive number, e.g. --width 980',
657
+ height: 'Use a positive number, e.g. --height 720',
658
+ });
659
+ applyStrictSizeFlags(body, flags);
660
+ return body;
661
+ }
662
+
618
663
  async function buildGraphRequestBody(
619
664
  flags: Record<string, string | true>,
620
665
  options: { requireData?: boolean; allowStdin?: boolean } = {},
@@ -967,6 +1012,35 @@ function filterJsonRenderSchemaView(
967
1012
  return flags.summary ? summarizeJsonRenderComponent(component) : component;
968
1013
  }
969
1014
 
1015
+ function summarizeHtmlPrimitive(primitive: NonNullable<CanvasSchemaResponse['htmlPrimitives']>[number]): Record<string, unknown> {
1016
+ return {
1017
+ kind: primitive.kind,
1018
+ title: primitive.title,
1019
+ description: primitive.description,
1020
+ useWhen: primitive.useWhen,
1021
+ defaultSize: primitive.defaultSize,
1022
+ dataShape: primitive.dataShape,
1023
+ };
1024
+ }
1025
+
1026
+ function filterHtmlPrimitiveSchemaView(
1027
+ schema: CanvasSchemaResponse,
1028
+ flags: Record<string, string | true>,
1029
+ ): Record<string, unknown> {
1030
+ const primitives = schema.htmlPrimitives ?? [];
1031
+ const kind = getStringFlag(flags, 'kind', 'primitive');
1032
+ if (!kind) {
1033
+ return {
1034
+ primitives: flags.summary ? primitives.map((entry) => summarizeHtmlPrimitive(entry)) : primitives,
1035
+ };
1036
+ }
1037
+ const primitive = primitives.find((entry) => entry.kind === kind);
1038
+ if (!primitive) {
1039
+ die(`Unknown HTML primitive: ${kind}`, 'Run: pmx-canvas html primitive schema --summary');
1040
+ }
1041
+ return flags.summary ? summarizeHtmlPrimitive(primitive) : primitive;
1042
+ }
1043
+
970
1044
  // ── Commands ─────────────────────────────────────────────────
971
1045
 
972
1046
  const COMMANDS: Record<string, { run: (args: string[]) => Promise<void>; help: string; examples: string[] }> = {};
@@ -1028,6 +1102,7 @@ cmd('node add', 'Add a node to the canvas', [
1028
1102
  'pmx-canvas node add --type file --content "src/index.ts"',
1029
1103
  'pmx-canvas node add --type webpage --url "https://example.com/docs"',
1030
1104
  'pmx-canvas node add --type html --title "Widget" --content "<main>Hello</main>"',
1105
+ 'pmx-canvas node add --type html --primitive choice-grid --data-file ./options.json --title "Options"',
1031
1106
  'pmx-canvas node add --type markdown --title "Note" --x 100 --y 200',
1032
1107
  'pmx-canvas node add --type json-render --title "Ops Dashboard" --spec-file ./dashboard.json',
1033
1108
  'pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value',
@@ -1055,6 +1130,18 @@ cmd('node add', 'Add a node to the canvas', [
1055
1130
  return;
1056
1131
  }
1057
1132
 
1133
+ if (type === 'html-primitive') {
1134
+ const result = await api('POST', '/api/canvas/node', await buildHtmlPrimitiveRequestBody(flags));
1135
+ output(result);
1136
+ return;
1137
+ }
1138
+
1139
+ if (type === 'html' && getStringFlag(flags, 'primitive', 'kind')) {
1140
+ const result = await api('POST', '/api/canvas/node', await buildHtmlPrimitiveRequestBody(flags));
1141
+ output(result);
1142
+ return;
1143
+ }
1144
+
1058
1145
  if (type === 'mcp-app') {
1059
1146
  die(
1060
1147
  'mcp-app nodes require tool-backed app metadata and cannot be created with generic node add.',
@@ -1136,6 +1223,28 @@ cmd('json-render', 'Show json-render schema and canonical examples', [
1136
1223
  output(filterJsonRenderSchemaView(schema.jsonRender, flags));
1137
1224
  });
1138
1225
 
1226
+ cmd('html primitive add', 'Create a reusable sandboxed HTML communication primitive', [
1227
+ 'pmx-canvas html primitive add --kind choice-grid --data-file ./options.json --title "Options"',
1228
+ 'pmx-canvas html primitive add --kind plan-timeline --data-json \'{"milestones":[{"title":"Ship","detail":"Implement and verify","status":"next"}]}\'',
1229
+ 'pmx-canvas html primitive add --kind triage-board --data-file ./tickets.json --strict-size',
1230
+ ], async (args) => {
1231
+ const { flags } = parseFlags(args);
1232
+ if (flags.help || flags.h) return showCommandHelp('html primitive add');
1233
+ const result = await api('POST', '/api/canvas/node', await buildHtmlPrimitiveRequestBody(flags));
1234
+ output(result);
1235
+ });
1236
+
1237
+ cmd('html primitive schema', 'Describe reusable HTML communication primitives', [
1238
+ 'pmx-canvas html primitive schema --summary',
1239
+ 'pmx-canvas html primitive schema --kind choice-grid',
1240
+ 'pmx-canvas html primitive schema --kind triage-board --summary',
1241
+ ], async (args) => {
1242
+ const { flags } = parseFlags(args);
1243
+ if (flags.help || flags.h) return showCommandHelp('html primitive schema');
1244
+ const schema = await loadCanvasSchema();
1245
+ output(filterHtmlPrimitiveSchemaView(schema, flags));
1246
+ });
1247
+
1139
1248
  cmd('graph add', 'Add a graph node to the canvas', [
1140
1249
  'pmx-canvas graph add --graph-type bar --data-file ./metrics.json --x-key label --y-key value',
1141
1250
  'pmx-canvas graph add --graphType composed --data \'[{"day":"Mon","visits":10,"conversion":0.4}]\' --xKey day --barKey visits --lineKey conversion',
@@ -1176,6 +1285,7 @@ cmd('node schema', 'Describe server-supported node create schemas and canonical
1176
1285
  rootShape: result.jsonRender.rootShape,
1177
1286
  },
1178
1287
  graph: result.graph,
1288
+ htmlPrimitives: result.htmlPrimitives?.map((entry) => summarizeHtmlPrimitive(entry)) ?? [],
1179
1289
  mcp: result.mcp,
1180
1290
  });
1181
1291
  return;
@@ -1898,19 +2008,31 @@ cmd('validate', 'Validate the current layout for collisions and missing edge end
1898
2008
  cmd('validate spec', 'Validate a json-render spec or graph payload without creating a node', [
1899
2009
  'pmx-canvas validate spec --type json-render --spec-file ./dashboard.json',
1900
2010
  'pmx-canvas validate spec --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value',
2011
+ 'pmx-canvas validate spec --type html-primitive --kind choice-grid --data-file ./options.json',
1901
2012
  'pmx-canvas validate spec --type json-render --spec-file ./dashboard.json --summary',
1902
2013
  ], async (args) => {
1903
2014
  const { flags } = parseFlags(args);
1904
2015
  if (flags.help || flags.h) return showCommandHelp('validate spec');
1905
2016
 
1906
2017
  const type = getStringFlag(flags, 'type');
1907
- if (type !== 'json-render' && type !== 'graph') {
1908
- die('validate spec requires --type json-render or --type graph.');
2018
+ if (type !== 'json-render' && type !== 'graph' && type !== 'html-primitive') {
2019
+ die('validate spec requires --type json-render, --type graph, or --type html-primitive.');
1909
2020
  }
1910
2021
 
1911
- const body = type === 'json-render'
1912
- ? { type, spec: (await buildJsonRenderRequestBody({ ...flags, title: String(flags.title ?? 'Validation') })).spec }
1913
- : { type, ...(await buildGraphRequestBody(flags)) };
2022
+ let body: Record<string, unknown>;
2023
+ if (type === 'json-render') {
2024
+ body = { type, spec: (await buildJsonRenderRequestBody({ ...flags, title: String(flags.title ?? 'Validation') })).spec };
2025
+ } else if (type === 'html-primitive') {
2026
+ const primitiveBody = await buildHtmlPrimitiveRequestBody(flags);
2027
+ body = {
2028
+ type,
2029
+ kind: primitiveBody.primitive,
2030
+ ...(typeof primitiveBody.title === 'string' ? { title: primitiveBody.title } : {}),
2031
+ ...(isRecord(primitiveBody.data) ? { data: primitiveBody.data } : {}),
2032
+ };
2033
+ } else {
2034
+ body = { type, ...(await buildGraphRequestBody(flags)) };
2035
+ }
1914
2036
 
1915
2037
  const result = await api('POST', '/api/canvas/schema/validate', body) as Record<string, unknown>;
1916
2038
  if (flags.summary) {
@@ -2302,9 +2424,17 @@ function showCommandHelp(name: string): void {
2302
2424
  console.log(' pmx-canvas node add --help --type webpage');
2303
2425
  console.log(' pmx-canvas node add --help --type json-render --component Table');
2304
2426
  console.log(' pmx-canvas node add --help --type graph');
2427
+ console.log(' pmx-canvas html primitive schema --summary');
2305
2428
  console.log(' pmx-canvas node add --help --type webpage --json');
2306
2429
  console.log(' Use --strict-size to keep explicit width/height fixed and scroll overflowing content.');
2307
2430
  }
2431
+ if (name === 'html primitive add' || name === 'html primitive schema') {
2432
+ console.log('\nPrimitive flags:');
2433
+ console.log(' --kind <name> Run `pmx-canvas html primitive schema --summary` for the full catalog');
2434
+ console.log(' --data-file <path> JSON object payload for the primitive');
2435
+ console.log(' --data-json, --data <json> Inline JSON object payload');
2436
+ console.log(' --stdin Read JSON object payload from stdin');
2437
+ }
2308
2438
  if (name === 'json-render') {
2309
2439
  console.log('\nOptions:');
2310
2440
  console.log(' --schema Show json-render catalog schema (default)');
@@ -2319,6 +2449,10 @@ function showCommandHelp(name: string): void {
2319
2449
  console.log(' Use --node-height/--nodeHeight for canvas frame height; use --chart-height for chart content height. --height is kept as a frame-height alias for compatibility.');
2320
2450
  console.log(' Pass --show-legend false to hide legends in compact node layouts.');
2321
2451
  }
2452
+ if (name === 'validate spec') {
2453
+ console.log('\nHTML primitive flags:');
2454
+ console.log(' --type html-primitive --kind <name> --data-file ./payload.json');
2455
+ }
2322
2456
  if (name === 'node schema') {
2323
2457
  console.log('\nFilters:');
2324
2458
  console.log(' --summary Show compact schema summaries');
@@ -2328,6 +2462,7 @@ function showCommandHelp(name: string): void {
2328
2462
  if (name === 'validate spec') {
2329
2463
  console.log('\nOutput control:');
2330
2464
  console.log(' --summary Return only validation summary metadata');
2465
+ console.log(' For --type html-primitive, pass --kind plus optional --data-file/--data-json.');
2331
2466
  }
2332
2467
  if (name === 'snapshot list') {
2333
2468
  console.log('\nOptions:');
@@ -2415,6 +2550,8 @@ Node commands:
2415
2550
  pmx-canvas node remove <id> Remove a node
2416
2551
  pmx-canvas json-render Show json-render schema/examples
2417
2552
  pmx-canvas graph add [options] Add a graph node
2553
+ pmx-canvas html primitive add Add an HTML communication primitive
2554
+ pmx-canvas html primitive schema List HTML primitive kinds and shapes
2418
2555
 
2419
2556
  Edge commands:
2420
2557
  pmx-canvas edge add [options] Add an edge between nodes
@@ -2488,6 +2625,8 @@ Examples:
2488
2625
  pmx-canvas node add --type web-artifact --title "Dashboard" --app-file ./App.tsx
2489
2626
  pmx-canvas node add --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value
2490
2627
  pmx-canvas graph add --graph-type bar --data-file ./metrics.json --x-key label --y-key value
2628
+ pmx-canvas html primitive add --kind choice-grid --data-file ./options.json --title "Options"
2629
+ pmx-canvas html primitive schema --summary
2491
2630
  pmx-canvas node add --help --type webpage
2492
2631
  pmx-canvas node schema --type json-render
2493
2632
  pmx-canvas node schema --type json-render --component Table --summary
@@ -2534,6 +2673,12 @@ export async function runAgentCli(args: string[]): Promise<void> {
2534
2673
  return;
2535
2674
  }
2536
2675
 
2676
+ const threeWord = `${args[0]} ${args[1] ?? ''} ${args[2] ?? ''}`.trim();
2677
+ if (COMMANDS[threeWord]) {
2678
+ await COMMANDS[threeWord].run(args.slice(3));
2679
+ return;
2680
+ }
2681
+
2537
2682
  // Try two-word command first (e.g., "node add"), then one-word (e.g., "search")
2538
2683
  const twoWord = `${args[0]} ${args[1] ?? ''}`.trim();
2539
2684
  if (COMMANDS[twoWord]) {
@@ -52,6 +52,7 @@ import {
52
52
  IconShortcuts,
53
53
  IconSnapshot,
54
54
  IconSun,
55
+ IconTextAnnotation,
55
56
  IconTrace,
56
57
  IconZoomIn,
57
58
  IconZoomOut,
@@ -73,7 +74,7 @@ function sendIntent(type: string, payload: Record<string, unknown> = {}): void {
73
74
  });
74
75
  }
75
76
 
76
- type AnnotationTool = 'pen' | 'eraser' | null;
77
+ type AnnotationTool = 'pen' | 'eraser' | 'text' | null;
77
78
 
78
79
  function ToolbarHint({
79
80
  label,
@@ -115,6 +116,7 @@ function Toolbar({
115
116
  annotationTool,
116
117
  onToggleAnnotationMode,
117
118
  onToggleAnnotationEraser,
119
+ onToggleTextAnnotation,
118
120
  }: {
119
121
  minimapVisible: boolean;
120
122
  onToggleMinimap: () => void;
@@ -126,6 +128,7 @@ function Toolbar({
126
128
  annotationTool: AnnotationTool;
127
129
  onToggleAnnotationMode: () => void;
128
130
  onToggleAnnotationEraser: () => void;
131
+ onToggleTextAnnotation: () => void;
129
132
  }) {
130
133
  const status = connectionStatus.value;
131
134
  const hasSynced = hasInitialServerLayout.value;
@@ -316,6 +319,20 @@ function Toolbar({
316
319
  <IconEraser />
317
320
  </button>
318
321
  </ToolbarHint>
322
+ <ToolbarHint
323
+ label={annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}
324
+ detail="Click anywhere to type an intent note"
325
+ >
326
+ <button
327
+ type="button"
328
+ onClick={onToggleTextAnnotation}
329
+ aria-label={annotationTool === 'text' ? 'Stop text annotations' : 'Text annotations'}
330
+ aria-pressed={annotationTool === 'text'}
331
+ style={{ color: annotationTool === 'text' ? 'var(--c-accent)' : undefined }}
332
+ >
333
+ <IconTextAnnotation />
334
+ </button>
335
+ </ToolbarHint>
319
336
 
320
337
  <div class="separator" />
321
338
 
@@ -392,6 +409,7 @@ export function App() {
392
409
  const handleCloseSnapshot = useCallback(() => setSnapshotOpen(false), []);
393
410
  const handleToggleAnnotationMode = useCallback(() => setAnnotationTool((tool) => tool === 'pen' ? null : 'pen'), []);
394
411
  const handleToggleAnnotationEraser = useCallback(() => setAnnotationTool((tool) => tool === 'eraser' ? null : 'eraser'), []);
412
+ const handleToggleTextAnnotation = useCallback(() => setAnnotationTool((tool) => tool === 'text' ? null : 'text'), []);
395
413
 
396
414
  const handleMinimapNavigate = useCallback((x: number, y: number) => {
397
415
  animateViewport({ x, y, scale: viewport.value.scale }, 200);
@@ -520,6 +538,7 @@ export function App() {
520
538
  annotationTool={annotationTool}
521
539
  onToggleAnnotationMode={handleToggleAnnotationMode}
522
540
  onToggleAnnotationEraser={handleToggleAnnotationEraser}
541
+ onToggleTextAnnotation={handleToggleTextAnnotation}
523
542
  />
524
543
  <div class="hud-right">
525
544
  {dockedRight.map((n) => (
@@ -11,18 +11,39 @@ export function AnnotationLayer({ annotations }: { annotations: CanvasAnnotation
11
11
 
12
12
  return (
13
13
  <svg class="annotation-layer" aria-hidden="true">
14
- {annotations.map((annotation) => (
15
- <path
16
- key={annotation.id}
17
- d={pointsToPath(annotation.points)}
18
- fill="none"
19
- stroke={annotation.color === 'currentColor' ? 'var(--c-annotation)' : annotation.color}
20
- stroke-width={annotation.width}
21
- stroke-linecap="round"
22
- stroke-linejoin="round"
23
- opacity="0.9"
24
- />
25
- ))}
14
+ {annotations.map((annotation) => {
15
+ const color = annotation.color === 'currentColor' ? 'var(--c-annotation)' : annotation.color;
16
+ if (annotation.type === 'text') {
17
+ const point = annotation.points[0];
18
+ if (!point || !annotation.text) return null;
19
+ return (
20
+ <text
21
+ key={annotation.id}
22
+ x={point.x}
23
+ y={point.y}
24
+ fill={color}
25
+ font-size={annotation.width}
26
+ font-family="var(--font)"
27
+ font-weight="700"
28
+ opacity="0.95"
29
+ >
30
+ {annotation.text}
31
+ </text>
32
+ );
33
+ }
34
+ return (
35
+ <path
36
+ key={annotation.id}
37
+ d={pointsToPath(annotation.points)}
38
+ fill="none"
39
+ stroke={color}
40
+ stroke-width={annotation.width}
41
+ stroke-linecap="round"
42
+ stroke-linejoin="round"
43
+ opacity="0.9"
44
+ />
45
+ );
46
+ })}
26
47
  </svg>
27
48
  );
28
49
  }
@@ -102,6 +102,7 @@ function findAnnotationAtPoint(
102
102
  point.y < annotation.bounds.y - pad ||
103
103
  point.y > annotation.bounds.y + annotation.bounds.height + pad
104
104
  ) continue;
105
+ if (annotation.type === 'text') return annotation;
105
106
  for (let index = 1; index < annotation.points.length; index++) {
106
107
  const start = annotation.points[index - 1];
107
108
  const end = annotation.points[index];
@@ -122,19 +123,27 @@ interface LassoRect {
122
123
 
123
124
  interface AnnotationDraft {
124
125
  id: string;
125
- type: 'freehand';
126
+ type: 'freehand' | 'text';
126
127
  points: Array<{ x: number; y: number }>;
127
128
  bounds: { x: number; y: number; width: number; height: number };
128
129
  color: string;
129
130
  width: number;
131
+ text?: string;
130
132
  createdAt: string;
131
133
  }
132
134
 
135
+ interface TextAnnotationDraft {
136
+ x: number;
137
+ y: number;
138
+ value: string;
139
+ }
140
+
133
141
  const ANNOTATION_COLOR = 'currentColor';
134
142
  const ANNOTATION_WIDTH = 4;
143
+ const TEXT_ANNOTATION_WIDTH = 24;
135
144
  const ERASER_HIT_RADIUS = 14;
136
145
 
137
- type AnnotationTool = 'pen' | 'eraser' | null;
146
+ type AnnotationTool = 'pen' | 'eraser' | 'text' | null;
138
147
 
139
148
  interface CanvasViewportProps {
140
149
  onNodeContextMenu?: (e: MouseEvent, nodeId: string) => void;
@@ -233,6 +242,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
233
242
  const annotationPoints = useRef<Array<{ x: number; y: number }>>([]);
234
243
  const [lasso, setLasso] = useState<LassoRect | null>(null);
235
244
  const [draftAnnotation, setDraftAnnotation] = useState<AnnotationDraft | null>(null);
245
+ const [textDraft, setTextDraftState] = useState<TextAnnotationDraft | null>(null);
246
+ const textDraftRef = useRef<TextAnnotationDraft | null>(null);
236
247
  const [dropActive, setDropActive] = useState(false);
237
248
  const dropCounter = useRef(0);
238
249
  // Ref mirrors lasso state so pointer handlers always read the latest value
@@ -256,6 +267,11 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
256
267
  },
257
268
  });
258
269
 
270
+ const setTextDraft = useCallback((next: TextAnnotationDraft | null) => {
271
+ textDraftRef.current = next;
272
+ setTextDraftState(next);
273
+ }, []);
274
+
259
275
  const createWebpageNodes = useCallback(async (urls: string[], centerX: number, centerY: number) => {
260
276
  if (urls.length === 0) return;
261
277
 
@@ -305,6 +321,23 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
305
321
  return;
306
322
  }
307
323
 
324
+ if (annotationTool === 'text') {
325
+ const target = e.target instanceof Element ? e.target : null;
326
+ if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
327
+ e.preventDefault();
328
+ e.stopPropagation();
329
+ activeNodeId.value = null;
330
+ clearSelection();
331
+ const rect = container.getBoundingClientRect();
332
+ const vp = viewport.value;
333
+ setTextDraft({
334
+ x: (e.clientX - rect.left - vp.x) / vp.scale,
335
+ y: (e.clientY - rect.top - vp.y) / vp.scale,
336
+ value: '',
337
+ });
338
+ return;
339
+ }
340
+
308
341
  if (annotationTool === 'pen') {
309
342
  const target = e.target instanceof Element ? e.target : null;
310
343
  if (target?.closest('.hud-layer, .snapshot-panel, .context-menu, .command-palette')) return;
@@ -450,11 +483,32 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
450
483
 
451
484
  useEffect(() => {
452
485
  if (annotationMode) return;
453
- if (!isAnnotating.current && !draftAnnotation) return;
486
+ if (!isAnnotating.current && !draftAnnotation && !textDraft) return;
454
487
  isAnnotating.current = false;
455
488
  annotationPoints.current = [];
456
489
  setDraftAnnotation(null);
457
- }, [annotationMode, draftAnnotation]);
490
+ setTextDraft(null);
491
+ }, [annotationMode, draftAnnotation, setTextDraft, textDraft]);
492
+
493
+ const commitTextDraft = useCallback(() => {
494
+ const draft = textDraftRef.current;
495
+ if (!draft) return;
496
+ const text = draft.value.trim();
497
+ setTextDraft(null);
498
+ if (!text) return;
499
+ const point = { x: draft.x, y: draft.y };
500
+ void createAnnotationFromClient({
501
+ type: 'text',
502
+ points: [point],
503
+ color: ANNOTATION_COLOR,
504
+ width: TEXT_ANNOTATION_WIDTH,
505
+ text,
506
+ });
507
+ }, [setTextDraft]);
508
+
509
+ useEffect(() => {
510
+ if (annotationTool !== 'text' && textDraft) setTextDraft(null);
511
+ }, [annotationTool, setTextDraft, textDraft]);
458
512
 
459
513
  // ── Drag-to-connect: track cursor in world space, hit-test on drop ──
460
514
  useEffect(() => {
@@ -517,8 +571,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
517
571
  const wx = (e.clientX - rect.left - v.x) / v.scale;
518
572
  const wy = (e.clientY - rect.top - v.y) / v.scale;
519
573
  // Offset so node centers on click point
520
- const nodeW = 360;
521
- const nodeH = 200;
574
+ const nodeW = 520;
575
+ const nodeH = 360;
522
576
  createNodeFromClient({
523
577
  type: 'markdown',
524
578
  title: 'New note',
@@ -697,6 +751,8 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
697
751
  overflow: 'hidden',
698
752
  cursor: annotationTool === 'eraser'
699
753
  ? 'cell'
754
+ : annotationTool === 'text'
755
+ ? 'text'
700
756
  : annotationMode || draggingEdge.value || isLassoing.current
701
757
  ? 'crosshair'
702
758
  : 'grab',
@@ -737,7 +793,32 @@ export function CanvasViewport({ onNodeContextMenu, onCanvasContextMenu, annotat
737
793
  </svg>
738
794
  )}
739
795
  </div>
740
- {annotationMode && <div class={`annotation-capture-layer${annotationTool === 'eraser' ? ' erasing' : ''}`} aria-hidden="true" />}
796
+ {annotationMode && <div class={`annotation-capture-layer${annotationTool === 'eraser' ? ' erasing' : ''}${annotationTool === 'text' ? ' text' : ''}`} aria-hidden="true" />}
797
+ {textDraft && (
798
+ <input
799
+ class="annotation-text-input"
800
+ value={textDraft.value}
801
+ autoFocus
802
+ style={{
803
+ left: `${textDraft.x * v.scale + v.x}px`,
804
+ top: `${textDraft.y * v.scale + v.y}px`,
805
+ fontSize: `${TEXT_ANNOTATION_WIDTH * v.scale}px`,
806
+ }}
807
+ onInput={(e) => setTextDraft({ ...textDraft, value: (e.target as HTMLInputElement).value })}
808
+ onBlur={commitTextDraft}
809
+ onPointerDown={(e) => e.stopPropagation()}
810
+ onKeyDown={(e) => {
811
+ if (e.key === 'Enter') {
812
+ e.preventDefault();
813
+ commitTextDraft();
814
+ }
815
+ if (e.key === 'Escape') {
816
+ e.preventDefault();
817
+ setTextDraft(null);
818
+ }
819
+ }}
820
+ />
821
+ )}
741
822
  {lassoStyle && <div class="lasso-rect" style={lassoStyle} />}
742
823
  {dropActive && (
743
824
  <div class="drop-zone-overlay">
@@ -135,7 +135,7 @@ export function CommandPalette({
135
135
 
136
136
  // Action items
137
137
  const actions: Array<{ label: string; badge: string; action: () => void }> = [
138
- { label: 'New note (markdown node)', badge: 'CREATE', action: () => { createNodeFromClient({ type: 'markdown', title: 'New note' }); onClose(); } },
138
+ { label: 'New note (markdown node)', badge: 'CREATE', action: () => { createNodeFromClient({ type: 'markdown', title: 'New note', width: 520, height: 360 }); onClose(); } },
139
139
  { label: 'Fit all nodes', badge: 'VIEW', action: () => { fitAll(window.innerWidth, window.innerHeight); onClose(); } },
140
140
  { label: 'Auto-arrange (grid)', badge: 'LAYOUT', action: () => { autoArrange(); onClose(); } },
141
141
  { label: 'Auto-arrange (graph-aware)', badge: 'LAYOUT', action: () => { forceDirectedArrange(); onClose(); } },
@@ -319,8 +319,8 @@ function buildCanvasMenuItems(canvasX: number, canvasY: number): MenuItem[] {
319
319
  {
320
320
  label: 'New note',
321
321
  action: () => {
322
- const width = 360;
323
- const height = 200;
322
+ const width = 520;
323
+ const height = 360;
324
324
  const position = centeredPosition(canvasX, canvasY, width, height);
325
325
  void createNodeFromClient({
326
326
  type: 'markdown',
@@ -122,6 +122,7 @@ export function ExpandedNodeOverlay() {
122
122
  const isCtxPinned = nodeId ? contextPinnedNodeIds.value.has(nodeId) : false;
123
123
  const hasText = textContent.length > 0;
124
124
  const pendingClose = pendingExpandedNodeCloseId.value === nodeId;
125
+ const isEmbeddedViewer = node.type === 'mcp-app' || node.type === 'webpage' || node.type === 'json-render' || node.type === 'graph';
125
126
 
126
127
  return (
127
128
  <div
@@ -260,9 +261,14 @@ export function ExpandedNodeOverlay() {
260
261
  overflow: 'auto',
261
262
  padding: '16px',
262
263
  minHeight: 0,
264
+ ...(isEmbeddedViewer ? { display: 'flex', flexDirection: 'column' } : {}),
263
265
  }}
264
266
  >
265
- {renderContent(node, true)}
267
+ {isEmbeddedViewer ? (
268
+ <div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
269
+ {renderContent(node, true)}
270
+ </div>
271
+ ) : renderContent(node, true)}
266
272
  </div>
267
273
  </div>
268
274
  </div>
@@ -139,6 +139,19 @@ export function IconEraser(p: IconProps): JSX.Element {
139
139
  );
140
140
  }
141
141
 
142
+ /** Text cursor — canvas text annotation mode */
143
+ export function IconTextAnnotation(p: IconProps): JSX.Element {
144
+ return (
145
+ <Icon {...p}>
146
+ <path d="M3 4h10" />
147
+ <path d="M8 4v8" />
148
+ <path d="M6 12h4" />
149
+ <path d="M4.5 4 4 6" />
150
+ <path d="M11.5 4 12 6" />
151
+ </Icon>
152
+ );
153
+ }
154
+
142
155
  /** Camera — snapshots */
143
156
  export function IconSnapshot(p: IconProps): JSX.Element {
144
157
  return (
@@ -2,11 +2,12 @@ import type { CanvasNodeState } from '../types';
2
2
  import { canvasTheme } from '../state/canvas-store';
3
3
  import { ExtAppFrame } from './ExtAppFrame';
4
4
 
5
- function withTheme(url: string): string {
5
+ function withViewerParams(url: string, expanded: boolean): string {
6
6
  if (!url) return url;
7
7
  try {
8
8
  const resolved = new URL(url, window.location.origin);
9
9
  resolved.searchParams.set('theme', canvasTheme.value === 'light' ? 'light' : 'dark');
10
+ if (expanded) resolved.searchParams.set('display', 'expanded');
10
11
  return resolved.toString();
11
12
  } catch {
12
13
  return url;
@@ -18,7 +19,7 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
18
19
  return <ExtAppFrame node={node} expanded={expanded} />;
19
20
  }
20
21
 
21
- const url = withTheme((node.data.url as string) || '');
22
+ const url = withViewerParams((node.data.url as string) || '', expanded);
22
23
  const sourceServer = (node.data.sourceServer as string) || '';
23
24
  const hostMode = (node.data.hostMode as string) || 'hosted';
24
25
  const fallbackReason = node.data.fallbackReason as string | undefined;
@@ -54,7 +55,14 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
54
55
  }
55
56
 
56
57
  return (
57
- <div style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
58
+ <div
59
+ style={{
60
+ height: '100%',
61
+ display: 'flex',
62
+ flexDirection: 'column',
63
+ ...(expanded ? { flex: 1, minHeight: 0, width: '100%' } : {}),
64
+ }}
65
+ >
58
66
  {!trustedDomain && (
59
67
  <div
60
68
  style={{
@@ -77,7 +85,7 @@ export function McpAppNode({ node, expanded = false }: { node: CanvasNodeState;
77
85
  sandbox="allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox"
78
86
  allow="clipboard-read; clipboard-write"
79
87
  loading="lazy"
80
- style={{ flex: 1 }}
88
+ style={{ flex: 1, minHeight: 0, width: '100%' }}
81
89
  title={`MCP App: ${sourceServer}`}
82
90
  />
83
91
  </div>