pmx-canvas 0.4.5 → 0.4.7

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 (109) hide show
  1. package/CHANGELOG.md +118 -0
  2. package/Readme.md +76 -36
  3. package/dist/canvas/global.css +169 -3
  4. package/dist/canvas/index.js +112 -87
  5. package/dist/canvas/mermaid-entry.js +3563 -0
  6. package/dist/json-render/index.css +1 -1
  7. package/dist/types/cli/agent.d.ts +1 -0
  8. package/dist/types/cli/commands/smoke.d.ts +1 -0
  9. package/dist/types/cli/shared.d.ts +13 -1
  10. package/dist/types/client/canvas/CanvasNode.d.ts +11 -0
  11. package/dist/types/client/canvas/EdgeLayer.d.ts +8 -0
  12. package/dist/types/client/mermaid-entry.d.ts +11 -0
  13. package/dist/types/client/nodes/AxStepControls.d.ts +4 -0
  14. package/dist/types/client/nodes/DiffNode.d.ts +20 -0
  15. package/dist/types/client/nodes/ExtAppFrame.d.ts +0 -2
  16. package/dist/types/client/nodes/FileNode.d.ts +14 -0
  17. package/dist/types/client/nodes/MermaidNode.d.ts +12 -0
  18. package/dist/types/client/state/canvas-store.d.ts +10 -0
  19. package/dist/types/client/state/intent-bridge.d.ts +9 -0
  20. package/dist/types/client/types.d.ts +2 -2
  21. package/dist/types/server/ax-flow-loop.d.ts +5 -0
  22. package/dist/types/server/ax-interaction.d.ts +21 -2
  23. package/dist/types/server/canvas-operations.d.ts +5 -0
  24. package/dist/types/server/canvas-provenance.d.ts +1 -1
  25. package/dist/types/server/canvas-state.d.ts +18 -1
  26. package/dist/types/server/canvas-validation.d.ts +28 -1
  27. package/dist/types/server/file-content.d.ts +46 -0
  28. package/dist/types/server/html-primitives.d.ts +13 -1
  29. package/dist/types/server/html-surface.d.ts +8 -0
  30. package/dist/types/server/index.d.ts +5 -0
  31. package/dist/types/server/operations/ops/ax-work.d.ts +1 -0
  32. package/dist/types/server/operations/ops/nodes.d.ts +1 -1
  33. package/dist/types/server/server.d.ts +1 -0
  34. package/dist/types/server/web-artifacts.d.ts +14 -0
  35. package/dist/types/server/workboard.d.ts +46 -0
  36. package/dist/types/shared/ax-flow.d.ts +51 -0
  37. package/dist/types/shared/ax-surface-protocol.d.ts +7 -0
  38. package/dist/types/shared/node-sizes.d.ts +33 -0
  39. package/docs/cli.md +31 -2
  40. package/docs/environment.md +1 -0
  41. package/docs/http-api.md +29 -0
  42. package/docs/mcp.md +13 -1
  43. package/docs/node-types.md +47 -1
  44. package/docs/screenshot.png +0 -0
  45. package/package.json +5 -4
  46. package/skills/pmx-canvas/SKILL.md +123 -25
  47. package/skills/pmx-canvas/evals/evals.json +54 -3
  48. package/skills/pmx-canvas/references/full-reference.md +11 -6
  49. package/skills/pmx-canvas-testing/SKILL.md +3 -0
  50. package/src/cli/agent.ts +2 -0
  51. package/src/cli/commands/ax.ts +28 -3
  52. package/src/cli/commands/history.ts +1 -1
  53. package/src/cli/commands/nodes.ts +2 -2
  54. package/src/cli/commands/query.ts +1 -1
  55. package/src/cli/commands/smoke.ts +212 -0
  56. package/src/cli/commands/view.ts +1 -1
  57. package/src/cli/index.ts +7 -0
  58. package/src/cli/shared.ts +35 -5
  59. package/src/client/App.tsx +28 -15
  60. package/src/client/canvas/CanvasNode.tsx +54 -5
  61. package/src/client/canvas/CanvasViewport.tsx +6 -0
  62. package/src/client/canvas/EdgeLayer.tsx +44 -24
  63. package/src/client/canvas/ExpandedNodeOverlay.tsx +21 -0
  64. package/src/client/canvas/Minimap.tsx +2 -0
  65. package/src/client/canvas/auto-fit.ts +12 -1
  66. package/src/client/canvas/use-pan-zoom.ts +16 -1
  67. package/src/client/icons.tsx +4 -0
  68. package/src/client/mermaid-entry.ts +76 -0
  69. package/src/client/nodes/AxStepControls.tsx +226 -0
  70. package/src/client/nodes/DiffNode.tsx +114 -0
  71. package/src/client/nodes/ExtAppFrame.tsx +259 -84
  72. package/src/client/nodes/FileNode.tsx +249 -51
  73. package/src/client/nodes/MermaidNode.tsx +81 -0
  74. package/src/client/state/canvas-store.ts +19 -0
  75. package/src/client/state/intent-bridge.ts +32 -0
  76. package/src/client/state/node-factory.ts +2 -0
  77. package/src/client/state/sse-bridge.ts +32 -1
  78. package/src/client/theme/global.css +169 -3
  79. package/src/client/types.ts +6 -0
  80. package/src/mcp/server.ts +19 -1
  81. package/src/server/ax-flow-loop.ts +114 -0
  82. package/src/server/ax-interaction.ts +305 -4
  83. package/src/server/canvas-operations.ts +83 -26
  84. package/src/server/canvas-provenance.ts +2 -0
  85. package/src/server/canvas-schema.ts +33 -0
  86. package/src/server/canvas-state.ts +46 -10
  87. package/src/server/canvas-validation.ts +88 -0
  88. package/src/server/demo-state.json +4605 -523
  89. package/src/server/demo.ts +24 -0
  90. package/src/server/file-content.ts +152 -0
  91. package/src/server/file-watcher.ts +7 -10
  92. package/src/server/html-primitives.ts +636 -0
  93. package/src/server/html-surface.ts +11 -0
  94. package/src/server/index.ts +25 -6
  95. package/src/server/operations/composites.ts +3 -2
  96. package/src/server/operations/ops/ax-work.ts +5 -0
  97. package/src/server/operations/ops/batch.ts +8 -0
  98. package/src/server/operations/ops/json-render.ts +44 -0
  99. package/src/server/operations/ops/nodes.ts +24 -2
  100. package/src/server/operations/ops/viewport.ts +15 -2
  101. package/src/server/server.ts +154 -6
  102. package/src/server/web-artifacts.ts +109 -0
  103. package/src/server/workboard.ts +159 -0
  104. package/src/shared/ax-flow.ts +102 -0
  105. package/src/shared/ax-surface-protocol.ts +8 -0
  106. package/src/shared/node-sizes.ts +52 -0
  107. package/src/shared/surface.ts +2 -0
  108. package/dist/types/server/chart-template.d.ts +0 -29
  109. package/src/server/chart-template.ts +0 -448
@@ -27,6 +27,19 @@ const LEGACY_SKILL_WEB_ARTIFACT_SCRIPTS_DIR = join(
27
27
  );
28
28
  const DEFAULT_TIMEOUT_MS = 10 * 60_000;
29
29
  const DEFAULT_PACKAGE_MANAGER = 'pnpm@10.33.0';
30
+ const NODE_VERSION_PROBE_TIMEOUT_MS = 15_000;
31
+ /**
32
+ * Minimum Node.js (`major.minor`) each pnpm major declares in its published
33
+ * `engines.node` — pnpm 10.x is `>=18.12`, pnpm 11.x is `>=22.13`. The pnpm that
34
+ * runs a build is not a local dependency (the scripts take it from PATH, `bun x`,
35
+ * or a global install), so its engines field cannot be read from disk; this table
36
+ * carries the same constraint keyed by the pinned major. An unlisted major
37
+ * imposes no preflight floor rather than an invented one.
38
+ */
39
+ const PNPM_MAJOR_MIN_NODE: Record<string, string> = {
40
+ '10': '18.12',
41
+ '11': '22.13',
42
+ };
30
43
  const DEFAULT_WEB_ARTIFACT_NODE_SIZE = { width: 960, height: 720 };
31
44
  const FALLBACK_PATH_DIRS = ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'];
32
45
  const WEB_ARTIFACT_CONTEXT_MAX_LENGTH = 1200;
@@ -470,6 +483,101 @@ function summarizeArtifactLog(text: string): WebArtifactLogSummary | undefined {
470
483
  };
471
484
  }
472
485
 
486
+ export type NodeVersionCheck = { ok: true } | { ok: false; message: string };
487
+
488
+ function parseMajorMinor(value: string): { major: number; minor: number } | null {
489
+ const match = /^v?(\d+)\.(\d+)/.exec(value.trim());
490
+ if (!match) return null;
491
+ return { major: Number(match[1]), minor: Number(match[2]) };
492
+ }
493
+
494
+ /**
495
+ * Pure preflight comparison used before any package-manager invocation.
496
+ * `detected` is raw `node --version` output ("v20.11.1"), or null when no node
497
+ * executable answered; `required` is a `major.minor` floor; `packageManager` is
498
+ * the pin that imposes it (e.g. "pnpm@11.1.2"). Returns an actionable message
499
+ * instead of throwing so every caller surfaces it on its own error channel.
500
+ */
501
+ export function checkWebArtifactNodeVersion(
502
+ detected: string | null,
503
+ required: string,
504
+ packageManager: string,
505
+ ): NodeVersionCheck {
506
+ const requiredParts = parseMajorMinor(required);
507
+ if (!requiredParts) return { ok: true };
508
+
509
+ const detectedLabel = detected === null ? null : detected.trim();
510
+ const detectedParts = detectedLabel === null ? null : parseMajorMinor(detectedLabel);
511
+ if (
512
+ detectedParts &&
513
+ (detectedParts.major > requiredParts.major ||
514
+ (detectedParts.major === requiredParts.major && detectedParts.minor >= requiredParts.minor))
515
+ ) {
516
+ return { ok: true };
517
+ }
518
+
519
+ const found =
520
+ detectedLabel && detectedParts
521
+ ? `the build environment has Node.js ${detectedLabel}`
522
+ : 'no usable `node` executable was found on the build PATH';
523
+ return {
524
+ ok: false,
525
+ message:
526
+ `Web-artifact build preflight failed: ${packageManager} requires Node.js >= ${required}, but ${found}. ` +
527
+ `Run PMX Canvas under Node.js ${required} or newer, then retry the build.`,
528
+ };
529
+ }
530
+
531
+ /** `node --version` as the build subprocesses see it, or null when node is unavailable. */
532
+ async function detectSubprocessNodeVersion(cwd: string): Promise<string | null> {
533
+ try {
534
+ const { stdout } = await runProcess('node', ['--version'], { cwd, timeoutMs: NODE_VERSION_PROBE_TIMEOUT_MS });
535
+ return stdout.trim() || null;
536
+ } catch {
537
+ // A missing or non-runnable node IS the preflight failure — reported below
538
+ // with the required version, not rethrown as a spawn error here.
539
+ return null;
540
+ }
541
+ }
542
+
543
+ /**
544
+ * The `packageManager` pin that governs this build: a reused artifact project
545
+ * carries its own pin, a fresh one gets the bundled default that init-artifact.sh
546
+ * and ensurePackageManagerBoundary write.
547
+ */
548
+ function resolvePinnedPackageManager(projectPath: string): string {
549
+ const packageJsonPath = join(projectPath, 'package.json');
550
+ if (!existsSync(packageJsonPath)) return DEFAULT_PACKAGE_MANAGER;
551
+ try {
552
+ const parsed: unknown = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
553
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
554
+ const pinned = (parsed as Record<string, unknown>).packageManager;
555
+ if (typeof pinned === 'string' && pinned.includes('@')) return pinned;
556
+ }
557
+ } catch {
558
+ // A corrupt project package.json is the init script's problem; the preflight
559
+ // falls back to the pin PMX Canvas would write anyway.
560
+ }
561
+ return DEFAULT_PACKAGE_MANAGER;
562
+ }
563
+
564
+ /**
565
+ * Fail fast when the Node.js available to the build subprocesses is older than
566
+ * the pinned package manager supports. Without this, pnpm's engine error lands
567
+ * deep inside the init/bundle script output and reads as an unrelated build
568
+ * failure (reported: pnpm 11 needs Node 22.13+, the build ran on Node 20).
569
+ */
570
+ async function preflightBuildRuntime(cwd: string, projectPath: string): Promise<void> {
571
+ const packageManager = resolvePinnedPackageManager(projectPath);
572
+ const [name, version] = packageManager.split('@');
573
+ if (name !== 'pnpm') return;
574
+ const required = PNPM_MAJOR_MIN_NODE[version.split('.')[0]];
575
+ if (!required) return;
576
+
577
+ const check = checkWebArtifactNodeVersion(await detectSubprocessNodeVersion(cwd), required, packageManager);
578
+ if (!check.ok) throw new Error(check.message);
579
+ }
580
+
473
581
  export async function executeWebArtifactBuild(input: WebArtifactBuildInput): Promise<WebArtifactBuildOutput> {
474
582
  const workspaceRoot = currentWorkspaceRoot();
475
583
  const artifactsDir = ensureArtifactsDir(workspaceRoot);
@@ -490,6 +598,7 @@ export async function executeWebArtifactBuild(input: WebArtifactBuildInput): Pro
490
598
  const parentDir = dirname(projectPath);
491
599
  mkdirSync(parentDir, { recursive: true });
492
600
  ensurePackageManagerBoundary(parentDir);
601
+ await preflightBuildRuntime(parentDir, projectPath);
493
602
 
494
603
  let stdout = '';
495
604
  let stderr = '';
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Live AX work-item board, materialized as a json-render node.
3
+ *
4
+ * `buildWorkboardSpec` is the pure spec builder (status columns of work-item
5
+ * cards); `renderWorkboard` is the operation core (create-or-rebuild the
6
+ * single `data.workboard === true` json-render node); `refreshWorkboardNodes`
7
+ * is the live-refresh hook this module registers on `canvasState` so every
8
+ * addWorkItem/updateWorkItem rebuilds the board from the fresh work-item list
9
+ * through the SAME spec/update path the operation uses.
10
+ *
11
+ * The refresh runs inside `withSuppressedRecording` so live board rebuilds do
12
+ * not spam undo/redo history (persistence and SSE emits are unaffected).
13
+ */
14
+ import type { JsonRenderSpec } from '../json-render/server.js';
15
+ import type { PmxAxWorkItem, PmxAxWorkItemStatus } from './ax-state.js';
16
+ import { buildJsonRenderNodeUpdate, createCanvasJsonRenderNode, emitCanvasLayoutUpdate } from './canvas-operations.js';
17
+ import { canvasState, type CanvasNodeState } from './canvas-state.js';
18
+
19
+ export const WORKBOARD_NODE_TITLE = 'Work Board';
20
+ export const WORKBOARD_NODE_SIZE = { width: 960, height: 560 };
21
+
22
+ const WORKBOARD_STATUS_ORDER: PmxAxWorkItemStatus[] = ['todo', 'in-progress', 'blocked', 'done', 'cancelled'];
23
+
24
+ const WORKBOARD_STATUS_LABELS: Record<PmxAxWorkItemStatus, string> = {
25
+ todo: 'To Do',
26
+ 'in-progress': 'In Progress',
27
+ blocked: 'Blocked',
28
+ done: 'Done',
29
+ cancelled: 'Cancelled',
30
+ };
31
+
32
+ /**
33
+ * Build the workboard json-render spec from a work-item list: one column per
34
+ * status (todo → in-progress → blocked → done → cancelled; empty statuses are
35
+ * omitted), each item a Card with an agentId Badge and detail Text when
36
+ * present. An empty list renders a single muted "No work items" text block.
37
+ */
38
+ export function buildWorkboardSpec(workItems: PmxAxWorkItem[]): JsonRenderSpec {
39
+ if (workItems.length === 0) {
40
+ return {
41
+ root: 'empty',
42
+ elements: {
43
+ empty: { type: 'Text', props: { text: 'No work items', variant: 'muted' }, children: [] },
44
+ },
45
+ };
46
+ }
47
+
48
+ const elements: Record<string, unknown> = {};
49
+ const columnIds: string[] = [];
50
+ for (const status of WORKBOARD_STATUS_ORDER) {
51
+ const items = workItems.filter((item) => item.status === status);
52
+ if (items.length === 0) continue;
53
+ const columnId = `col-${status}`;
54
+ const headerId = `${columnId}-header`;
55
+ elements[headerId] = {
56
+ type: 'Heading',
57
+ props: { level: 'h4', text: `${WORKBOARD_STATUS_LABELS[status]} (${items.length})` },
58
+ children: [],
59
+ };
60
+ const columnChildren = [headerId];
61
+ for (const item of items) {
62
+ const cardId = `item-${item.id}`;
63
+ const cardChildren: string[] = [];
64
+ if (item.agentId) {
65
+ elements[`${cardId}-agent`] = {
66
+ type: 'Badge',
67
+ props: { text: item.agentId, variant: 'secondary' },
68
+ children: [],
69
+ };
70
+ cardChildren.push(`${cardId}-agent`);
71
+ }
72
+ if (item.detail) {
73
+ elements[`${cardId}-detail`] = {
74
+ type: 'Text',
75
+ props: { text: item.detail, variant: 'muted' },
76
+ children: [],
77
+ };
78
+ cardChildren.push(`${cardId}-detail`);
79
+ }
80
+ elements[cardId] = { type: 'Card', props: { title: item.title }, children: cardChildren };
81
+ columnChildren.push(cardId);
82
+ }
83
+ elements[columnId] = {
84
+ type: 'Stack',
85
+ props: { direction: 'vertical', gap: 'sm' },
86
+ children: columnChildren,
87
+ };
88
+ columnIds.push(columnId);
89
+ }
90
+ elements.board = {
91
+ type: 'Stack',
92
+ props: { direction: 'horizontal', gap: 'md', align: 'start' },
93
+ children: columnIds,
94
+ };
95
+ return { root: 'board', elements };
96
+ }
97
+
98
+ function findWorkboardNodes(): CanvasNodeState[] {
99
+ return canvasState.getLayout().nodes.filter((node) => node.type === 'json-render' && node.data.workboard === true);
100
+ }
101
+
102
+ /** Rebuild one workboard node's spec in place (shared by the op and the live refresh). */
103
+ function applyWorkboardSpec(node: CanvasNodeState, spec: JsonRenderSpec): void {
104
+ const title = typeof node.data.title === 'string' && node.data.title ? node.data.title : WORKBOARD_NODE_TITLE;
105
+ const update = buildJsonRenderNodeUpdate(node, { title, spec });
106
+ canvasState.updateNode(node.id, { data: update.data });
107
+ }
108
+
109
+ /**
110
+ * Operation core for `render.workboard`: rebuild the existing workboard node
111
+ * in place, or create one (tagged `data.workboard: true`) when none exists.
112
+ */
113
+ export function renderWorkboard(input: { x?: number; y?: number } = {}): {
114
+ ok: true;
115
+ id: string;
116
+ created: boolean;
117
+ itemCount: number;
118
+ } {
119
+ const workItems = canvasState.getWorkItems();
120
+ const spec = buildWorkboardSpec(workItems);
121
+ const existing = findWorkboardNodes()[0];
122
+ if (existing) {
123
+ applyWorkboardSpec(existing, spec);
124
+ return { ok: true, id: existing.id, created: false, itemCount: workItems.length };
125
+ }
126
+ const created = createCanvasJsonRenderNode({
127
+ title: WORKBOARD_NODE_TITLE,
128
+ spec,
129
+ width: WORKBOARD_NODE_SIZE.width,
130
+ height: WORKBOARD_NODE_SIZE.height,
131
+ ...(input.x !== undefined ? { x: input.x } : {}),
132
+ ...(input.y !== undefined ? { y: input.y } : {}),
133
+ });
134
+ // Tag the node as THE workboard. Suppressed: the tag is part of the single
135
+ // logical "create workboard" mutation, not a second undo step.
136
+ canvasState.withSuppressedRecording(() => {
137
+ canvasState.updateNode(created.id, { data: { ...created.node.data, workboard: true } });
138
+ });
139
+ return { ok: true, id: created.id, created: true, itemCount: workItems.length };
140
+ }
141
+
142
+ /**
143
+ * Live refresh: rebuild every workboard node's spec from the current work-item
144
+ * list. Registered below as the canvasState work-item change listener. Node
145
+ * updates never touch work items, so this cannot recurse.
146
+ */
147
+ export function refreshWorkboardNodes(): void {
148
+ const nodes = findWorkboardNodes();
149
+ if (nodes.length === 0) return;
150
+ const spec = buildWorkboardSpec(canvasState.getWorkItems());
151
+ canvasState.withSuppressedRecording(() => {
152
+ for (const node of nodes) {
153
+ applyWorkboardSpec(node, spec);
154
+ }
155
+ });
156
+ emitCanvasLayoutUpdate();
157
+ }
158
+
159
+ canvasState.setWorkItemsChangedListener(refreshWorkboardNodes);
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The node-data contract for a materialized AX flow.
3
+ *
4
+ * `ax.flow.materialize` stamps every step node with `data.axStep` and the anchor
5
+ * (first) step with `data.axFlow`. Three consumers read those stamps and none of
6
+ * them owns the shape, so it lives here:
7
+ * - the server loop (`ax-flow-loop.ts`) advances the flow from work-item changes
8
+ * - the browser renders native Start/Done/Blocked + Run loop/Stop controls
9
+ * - snapshots/restore carry the stamps as ordinary node data
10
+ *
11
+ * Node data is `Record<string, unknown>` on both sides, so the readers below are
12
+ * the ONLY way to narrow it: they validate rather than cast, because a stamp can
13
+ * also arrive from a restored snapshot or a hand-edited node.
14
+ */
15
+
16
+ /** Hard ceiling on loop passes, whatever a node's stamped `maxRuns` claims. */
17
+ export const AX_FLOW_LOOP_HARD_CAP = 20;
18
+
19
+ /** One step of a flow, as recorded on the anchor node. */
20
+ export interface AxFlowStepRef {
21
+ /** 1-based position in the flow. */
22
+ index: number;
23
+ nodeId: string;
24
+ workItemId: string;
25
+ title: string;
26
+ }
27
+
28
+ export interface AxFlowLoopState {
29
+ /** The loop only advances while this is true; Stop persists `false`. */
30
+ running: boolean;
31
+ /** Completed passes. `run >= maxRuns` ends the loop. */
32
+ run: number;
33
+ maxRuns: number;
34
+ }
35
+
36
+ /** `data.axStep` — stamped on EVERY step node, including the anchor. */
37
+ export interface AxFlowStepStamp {
38
+ flowId: string;
39
+ index: number;
40
+ total: number;
41
+ workItemId: string;
42
+ }
43
+
44
+ /** `data.axFlow` — stamped on the anchor (first) step node only. */
45
+ export interface AxFlowStamp {
46
+ flowId: string;
47
+ title: string;
48
+ steps: AxFlowStepRef[];
49
+ loop: AxFlowLoopState;
50
+ }
51
+
52
+ /** Clamp a stamped `maxRuns` into [1, AX_FLOW_LOOP_HARD_CAP]. */
53
+ export function clampAxFlowMaxRuns(value: unknown): number {
54
+ const n = typeof value === 'number' && Number.isFinite(value) ? Math.floor(value) : 1;
55
+ return Math.max(1, Math.min(AX_FLOW_LOOP_HARD_CAP, n));
56
+ }
57
+
58
+ function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
60
+ }
61
+
62
+ function nonEmptyString(value: unknown): value is string {
63
+ return typeof value === 'string' && value.length > 0;
64
+ }
65
+
66
+ /** Read `data.axStep`, or null when the node carries no valid step stamp. */
67
+ export function readAxStep(data: Record<string, unknown>): AxFlowStepStamp | null {
68
+ const step = data.axStep;
69
+ if (!isRecord(step)) return null;
70
+ if (!nonEmptyString(step.flowId) || !nonEmptyString(step.workItemId)) return null;
71
+ if (typeof step.index !== 'number' || typeof step.total !== 'number') return null;
72
+ return { flowId: step.flowId, index: step.index, total: step.total, workItemId: step.workItemId };
73
+ }
74
+
75
+ /** Read `data.axFlow`, or null when the node is not a flow anchor. */
76
+ export function readAxFlow(data: Record<string, unknown>): AxFlowStamp | null {
77
+ const flow = data.axFlow;
78
+ if (!isRecord(flow) || !nonEmptyString(flow.flowId) || !Array.isArray(flow.steps)) return null;
79
+ const steps: AxFlowStepRef[] = [];
80
+ for (const raw of flow.steps) {
81
+ if (!isRecord(raw) || !nonEmptyString(raw.nodeId) || !nonEmptyString(raw.workItemId)) return null;
82
+ if (typeof raw.index !== 'number') return null;
83
+ steps.push({
84
+ index: raw.index,
85
+ nodeId: raw.nodeId,
86
+ workItemId: raw.workItemId,
87
+ title: typeof raw.title === 'string' ? raw.title : '',
88
+ });
89
+ }
90
+ if (steps.length === 0) return null;
91
+ const loop = isRecord(flow.loop) ? flow.loop : {};
92
+ return {
93
+ flowId: flow.flowId,
94
+ title: typeof flow.title === 'string' ? flow.title : 'Flow',
95
+ steps,
96
+ loop: {
97
+ running: loop.running === true,
98
+ run: typeof loop.run === 'number' && Number.isFinite(loop.run) ? Math.max(0, Math.floor(loop.run)) : 0,
99
+ maxRuns: clampAxFlowMaxRuns(loop.maxRuns),
100
+ },
101
+ };
102
+ }
@@ -20,3 +20,11 @@ export const HTML_SURFACE_PUSH_SOURCE = 'pmx-canvas-html-node';
20
20
 
21
21
  /** Ext-app boot beacon: the iframe's scripts executed (WebKit watchdog liveness). */
22
22
  export const EXT_APP_BOOT_BEACON_SOURCE = 'pmx-canvas-ext-app-alive';
23
+
24
+ /**
25
+ * Parent→ext-app paint probe (Finding N): the parent asks the app document to
26
+ * answer with a double-rAF `paint-tick` beacon. A frame whose rendering
27
+ * pipeline is live answers within a frame or two; silence past the timeout is
28
+ * the paint-fail signal that drives the recovery ladder.
29
+ */
30
+ export const EXT_APP_PAINT_PROBE_SOURCE = 'pmx-canvas-ext-app-paint-probe';
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Per-type minimum node size — the readability floor.
3
+ *
4
+ * Agents frequently create nodes with explicit frames far too small for their
5
+ * content (clipped markdown, charts squeezed behind inner scrollbars), so the
6
+ * server clamps undersized explicit sizes UP at creation and `validate` reports
7
+ * anything below the floor.
8
+ *
9
+ * This lives in `shared/` because BOTH sides must honor it. The 0.4.6 report's
10
+ * Finding AA: the server clamped a 200x100 markdown node up to 360x180, and
11
+ * then the browser's DOM auto-fit measured the (short) content and persisted
12
+ * 360x132 — undoing the guarantee on the most common path, a connected
13
+ * workbench. A floor only one side knows about is not a floor.
14
+ *
15
+ * `strictSize: true` is the escape hatch for a deliberately small fixed frame,
16
+ * and it exempts a node from both the creation clamp and the auto-fit floor.
17
+ * Types absent from the table are intentionally unclamped — `trace` is small by
18
+ * design, `group` sizes to its children.
19
+ */
20
+ export const NODE_MIN_SIZES: Record<string, { width: number; height: number }> = {
21
+ markdown: { width: 360, height: 180 },
22
+ context: { width: 360, height: 180 },
23
+ file: { width: 360, height: 200 },
24
+ diff: { width: 420, height: 240 },
25
+ mermaid: { width: 360, height: 240 },
26
+ status: { width: 280, height: 120 },
27
+ ledger: { width: 320, height: 200 },
28
+ image: { width: 240, height: 180 },
29
+ html: { width: 420, height: 280 },
30
+ webpage: { width: 420, height: 280 },
31
+ 'json-render': { width: 420, height: 280 },
32
+ graph: { width: 420, height: 280 },
33
+ 'mcp-app': { width: 480, height: 320 },
34
+ };
35
+
36
+ /** The readability floor for a node type, or undefined when it has none. */
37
+ export function nodeMinSize(type: string): { width: number; height: number } | undefined {
38
+ return NODE_MIN_SIZES[type];
39
+ }
40
+
41
+ /** Creation-time clamp: an explicit size below the floor is raised to it. */
42
+ export function clampCreateNodeSize(
43
+ type: string,
44
+ width: number,
45
+ height: number,
46
+ strictSize?: boolean,
47
+ ): { width: number; height: number } {
48
+ if (strictSize === true) return { width, height };
49
+ const min = nodeMinSize(type);
50
+ if (!min) return { width, height };
51
+ return { width: Math.max(width, min.width), height: Math.max(height, min.height) };
52
+ }
@@ -17,6 +17,8 @@ export function canOpenNodeAsSurface(type: string, data: Record<string, unknown>
17
17
  (typeof data.html === 'string' && data.html.length > 0) ||
18
18
  (typeof data.content === 'string' && data.content.length > 0)
19
19
  );
20
+ case 'mermaid':
21
+ return typeof data.content === 'string' && data.content.trim().length > 0;
20
22
  case 'json-render':
21
23
  case 'graph':
22
24
  return true;
@@ -1,29 +0,0 @@
1
- /**
2
- * Chart HTML template generator — produces self-contained ext-app HTML
3
- * documents that render interactive Chart.js charts inside the canvas
4
- * ExtAppFrame iframe.
5
- *
6
- * The generated HTML:
7
- * 1. Renders immediately from inline data (no bridge needed)
8
- * 2. Connects to host AppBridge via the embedded ext-app App SDK runtime
9
- * 3. Accepts updated data via toolInput for re-rendering
10
- */
11
- export interface ChartDataset {
12
- label: string;
13
- values: number[];
14
- color?: string;
15
- }
16
- export interface ChartConfig {
17
- title: string;
18
- chartType: 'bar' | 'line' | 'pie' | 'scatter' | 'doughnut' | 'radar';
19
- labels: string[];
20
- datasets: ChartDataset[];
21
- xAxisLabel?: string;
22
- yAxisLabel?: string;
23
- stacked?: boolean;
24
- }
25
- /**
26
- * Generate a self-contained HTML document that renders a Chart.js chart
27
- * and optionally connects to the host via the ext-app App SDK.
28
- */
29
- export declare function generateChartHtml(config: ChartConfig): string;