pmx-canvas 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/Readme.md +9 -6
  3. package/dist/canvas/global.css +83 -6
  4. package/dist/canvas/index.js +177 -116
  5. package/dist/canvas/mermaid-entry.js +1303 -1303
  6. package/dist/json-render/index.js +101 -102
  7. package/dist/types/client/canvas/canvas-area.d.ts +14 -0
  8. package/dist/types/client/nodes/ExtAppFrame.d.ts +2 -0
  9. package/dist/types/server/canvas-operations.d.ts +10 -0
  10. package/docs/RELEASE.md +14 -0
  11. package/docs/adr-001-bun-only-runtime.md +1 -1
  12. package/docs/bun-webview-integration.md +2 -2
  13. package/docs/product-context-vision-2026-09.md +207 -0
  14. package/docs/product-review-2026-09.md +174 -0
  15. package/docs/product-vision-2026-09.md +200 -0
  16. package/docs/screenshot.png +0 -0
  17. package/package.json +5 -5
  18. package/skills/pmx-canvas/SKILL.md +61 -30
  19. package/skills/pmx-canvas/references/installing-pmx-canvas.md +126 -18
  20. package/skills/web-artifacts-builder/scripts/bundle-artifact.sh +12 -1
  21. package/src/cli/commands/pump.ts +13 -2
  22. package/src/cli/index.ts +1 -0
  23. package/src/client/canvas/CanvasViewport.tsx +11 -0
  24. package/src/client/canvas/TopBar.tsx +85 -45
  25. package/src/client/canvas/canvas-area.ts +33 -0
  26. package/src/client/canvas/use-pan-zoom.ts +7 -0
  27. package/src/client/nodes/ExtAppFrame.tsx +152 -23
  28. package/src/client/state/canvas-store.ts +29 -11
  29. package/src/client/theme/global.css +83 -6
  30. package/src/server/canvas-operations.ts +41 -6
  31. package/src/server/index.ts +4 -2
  32. package/src/server/operations/mcp.ts +45 -31
  33. package/src/server/operations/ops/ax-presence.ts +3 -0
  34. package/src/server/operations/ops/nodes.ts +12 -0
  35. package/src/server/operations/ops/snapshots.ts +6 -19
  36. package/src/server/scope-fence.ts +1 -1
@@ -16,7 +16,15 @@
16
16
  * takes the message from stdin instead. See `renderExecTemplate`.
17
17
  */
18
18
  import { spawn } from 'node:child_process';
19
- import { cmd, getBaseUrl, getStringFlag, invokeOperation, parseFlags, requireFlag } from '../shared.js';
19
+ import {
20
+ cmd,
21
+ getBaseUrl,
22
+ getStringFlag,
23
+ invokeOperation,
24
+ parseFlags,
25
+ requireFlag,
26
+ showCommandHelp,
27
+ } from '../shared.js';
20
28
 
21
29
  interface PendingSteer {
22
30
  id: string;
@@ -137,7 +145,10 @@ cmd(
137
145
  `pmx-canvas pump --consumer testbot --exec 'cat' --once`,
138
146
  ],
139
147
  async (args) => {
140
- const { flags } = parseFlags(args, { boolFlags: ['once'] });
148
+ const { flags } = parseFlags(args, { boolFlags: ['once', 'help', 'h'] });
149
+ // Help before required-flag validation — `pump --help` died on
150
+ // "Missing required flag: --consumer" (0.5.0 Codex finding W).
151
+ if (flags.help || flags.h) return showCommandHelp('pump');
141
152
  const consumer = requireFlag(
142
153
  flags,
143
154
  'consumer',
package/src/cli/index.ts CHANGED
@@ -314,6 +314,7 @@ Agent CLI (works against running server):
314
314
  code-graph File dependencies
315
315
  spatial Spatial analysis
316
316
  watch Semantic watch stream
317
+ pump Reactive steering loop for CLI agents (Amp, Codex CLI)
317
318
  smoke Check that a running canvas works here
318
319
  skills sync Sync installed copies of the bundled skills
319
320
  copilot install-extension Install the GitHub Copilot app extension
@@ -704,6 +704,16 @@ export function CanvasViewport({
704
704
  [annotationMode, containerRef, onCanvasContextMenu],
705
705
  );
706
706
 
707
+ // Internal native drags (selected node text, links inside rendered
708
+ // markdown) re-enter this same container as drops and mint webpage nodes
709
+ // from card text (0.5.1 Amp finding D). Kill them at the source — external
710
+ // drags never fire a local dragstart, so file/URL import is untouched.
711
+ // Editable elements keep their native drag (text drag inside an editor).
712
+ const handleDragStart = useCallback((e: DragEvent) => {
713
+ if (isEditableElement(e.target instanceof Element ? e.target : null)) return;
714
+ e.preventDefault();
715
+ }, []);
716
+
707
717
  // ── Drag-and-drop files from filesystem ──
708
718
  const handleDragEnter = useCallback((e: DragEvent) => {
709
719
  e.preventDefault();
@@ -824,6 +834,7 @@ export function CanvasViewport({
824
834
  onPointerUp={handlePointerUp}
825
835
  onContextMenu={handleContextMenu}
826
836
  onDblClick={handleDblClick}
837
+ onDragStart={handleDragStart}
827
838
  onDragEnter={handleDragEnter}
828
839
  onDragOver={handleDragOver}
829
840
  onDragLeave={handleDragLeave}
@@ -31,59 +31,99 @@ import { formatCountdown, gateRemainingMs } from '../../shared/approval-gates.js
31
31
  * session is attached — the quiet board shows nothing here until phase 5
32
32
  * lands the "Start agent session" affordance.
33
33
  */
34
+ const AGENT_CHIP_LIMIT = 3;
35
+ const AGENT_PHASE_RANK: Record<string, number> = { 'waiting-approval': 0, thinking: 1, tooling: 2, idle: 3 };
36
+
34
37
  function AgentChip() {
35
38
  // Every attached session gets its chip — with several agents on the board,
36
- // showing only the first hid the rest from the top bar entirely.
39
+ // showing only the first hid the rest from the top bar entirely. But one
40
+ // chip PER agent floods the bar on a many-agent board (user feedback,
41
+ // 0.5.1 cycle: 16 unshrinkable chips shoved the zoom cluster out of the
42
+ // bar and over the session panel, and the total was invisible): the most
43
+ // active few wear their own chips and the rest fold into a census chip
44
+ // that always names the total.
37
45
  const sessions = attachedSessions.value;
38
46
  if (sessions.length === 0) return null;
47
+ const ranked = [...sessions].sort((a, b) => (AGENT_PHASE_RANK[a.phase] ?? 4) - (AGENT_PHASE_RANK[b.phase] ?? 4));
48
+ const visible = ranked.slice(0, AGENT_CHIP_LIMIT);
49
+ const overflow = ranked.slice(AGENT_CHIP_LIMIT);
50
+ const phaseCounts = sessions.reduce<Record<string, number>>((acc, s) => {
51
+ acc[s.phase] = (acc[s.phase] ?? 0) + 1;
52
+ return acc;
53
+ }, {});
54
+ const breakdown = [
55
+ phaseCounts['waiting-approval'] && `${phaseCounts['waiting-approval']} waiting on approval`,
56
+ phaseCounts.thinking && `${phaseCounts.thinking} thinking`,
57
+ phaseCounts.tooling && `${phaseCounts.tooling} running tools`,
58
+ phaseCounts.idle && `${phaseCounts.idle} idle`,
59
+ ]
60
+ .filter(Boolean)
61
+ .join(', ');
39
62
  return (
40
63
  <>
41
- {sessions.map((session) => {
42
- // Fleet roll-up: workers declaring this session as their parent count
43
- // into its chip instead of growing the bar one chip per worker.
44
- const workers = agentPresences.value.filter(
45
- (presence) =>
46
- presence.parentAgentId != null &&
47
- (presence.parentAgentId === session.sessionId || presence.parentAgentId === session.source),
48
- ).length;
49
- return (
50
- <BarHint
51
- key={session.sessionId}
52
- label={`Agent session — ${session.label}`}
53
- tapToOpen
54
- body="What this attached agent is doing right now: idle, thinking, running a tool, or waiting on your approval. Steer it from the composer below."
55
- >
56
- <span
57
- class={`agent-chip phase-${session.phase}`}
58
- data-phase={session.phase}
59
- style={{ '--identity-color': `hsl(${agentIdentityHue(session.sessionId)} 65% 62%)` }}
64
+ {/* The chips live in their own clipping flex wrapper: each chip keeps
65
+ its natural readable width and the WRAPPER absorbs the squeeze,
66
+ clipping at its edge — no per-child shrink cascade, and chip
67
+ content can never paint over the census, meter, or zoom cluster. */}
68
+ <span class="agent-chips">
69
+ {visible.map((session) => {
70
+ // Fleet roll-up: workers declaring this session as their parent count
71
+ // into its chip instead of growing the bar one chip per worker.
72
+ const workers = agentPresences.value.filter(
73
+ (presence) =>
74
+ presence.parentAgentId != null &&
75
+ (presence.parentAgentId === session.sessionId || presence.parentAgentId === session.source),
76
+ ).length;
77
+ return (
78
+ <BarHint
79
+ key={session.sessionId}
80
+ label={`Agent session — ${session.label}`}
81
+ tapToOpen
82
+ body="What this attached agent is doing right now: idle, thinking, running a tool, or waiting on your approval. Steer it from the composer below."
60
83
  >
61
- <span class="agent-chip-dot" aria-hidden="true" />
62
- <span class="agent-chip-label">{agentPhaseLabel(session)}</span>
63
- <span class="agent-chip-who hud-collapsible-text">{session.label}</span>
64
- {workers > 0 && (
65
- <span class="agent-chip-workers">
66
- +{workers} worker{workers === 1 ? '' : 's'}
67
- </span>
68
- )}
69
- <button
70
- type="button"
71
- class="agent-chip-end"
72
- aria-label={`End ${session.label} session`}
73
- title={`End ${session.label}'s session`}
74
- onClick={(e) => {
75
- // The chip sits inside a tap-to-open hint — ending a session
76
- // must not also open it.
77
- e.stopPropagation();
78
- void endSession({ source: session.source, agentId: session.agentId });
79
- }}
84
+ <span
85
+ class={`agent-chip phase-${session.phase}`}
86
+ data-phase={session.phase}
87
+ style={{ '--identity-color': `hsl(${agentIdentityHue(session.sessionId)} 65% 62%)` }}
80
88
  >
81
- ×
82
- </button>
83
- </span>
84
- </BarHint>
85
- );
86
- })}
89
+ <span class="agent-chip-dot" aria-hidden="true" />
90
+ <span class="agent-chip-label">{agentPhaseLabel(session)}</span>
91
+ <span class="agent-chip-who hud-collapsible-text">{session.label}</span>
92
+ {workers > 0 && (
93
+ <span class="agent-chip-workers">
94
+ +{workers} worker{workers === 1 ? '' : 's'}
95
+ </span>
96
+ )}
97
+ <button
98
+ type="button"
99
+ class="agent-chip-end"
100
+ aria-label={`End ${session.label} session`}
101
+ title={`End ${session.label}'s session`}
102
+ onClick={(e) => {
103
+ // The chip sits inside a tap-to-open hint — ending a session
104
+ // must not also open it.
105
+ e.stopPropagation();
106
+ void endSession({ source: session.source, agentId: session.agentId });
107
+ }}
108
+ >
109
+ ×
110
+ </button>
111
+ </span>
112
+ </BarHint>
113
+ );
114
+ })}
115
+ </span>
116
+ {overflow.length > 0 && (
117
+ <BarHint
118
+ label={`${sessions.length} agent sessions attached`}
119
+ tapToOpen
120
+ body={`${sessions.length} agents on this board — ${breakdown}. The most active wear their own chips; the rest are here, and idle agents' cursors leave the board until they work again.`}
121
+ >
122
+ <span class="agent-chip agent-chip-more" data-testid="agent-chip-more">
123
+ +{overflow.length} · {sessions.length} agents
124
+ </span>
125
+ </BarHint>
126
+ )}
87
127
  </>
88
128
  );
89
129
  }
@@ -39,3 +39,36 @@ export function canvasAreaCenter(): { x: number; y: number } {
39
39
  const rect = canvasArea();
40
40
  return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
41
41
  }
42
+
43
+ export interface CanvasFitInsets {
44
+ top: number;
45
+ right: number;
46
+ bottom: number;
47
+ left: number;
48
+ }
49
+
50
+ /**
51
+ * Screen-space insets Fit must reserve for the floating chrome that overlays
52
+ * the canvas region: the bottom command bar (whose pinned-chip row grows it)
53
+ * and the bottom-right minimap. Measured from the live elements — fit's old
54
+ * world-space padding shrank with the fit zoom and parked nodes underneath
55
+ * both (0.5.0 Amp report, finding A; architecture rule 9's trap).
56
+ */
57
+ export function canvasFitInsets(): CanvasFitInsets {
58
+ let bottom = 16;
59
+ try {
60
+ const area = canvasArea();
61
+ const areaBottom = area.top + area.height;
62
+ for (const selector of ['.command-bar', '.minimap']) {
63
+ const el = document.querySelector(selector);
64
+ if (!(el instanceof HTMLElement) || !el.isConnected) continue;
65
+ const rect = el.getBoundingClientRect();
66
+ if (rect.height <= 0) continue;
67
+ const overlap = areaBottom - rect.top;
68
+ if (overlap > 0) bottom = Math.max(bottom, overlap + 12);
69
+ }
70
+ } catch {
71
+ // Chromeless embeds and unit DOMs: the defaults below are the contract.
72
+ }
73
+ return { top: 16, right: 16, bottom, left: 16 };
74
+ }
@@ -110,6 +110,13 @@ export function usePanZoom({ viewport, onViewportChange, onViewportCommit, disab
110
110
  // Pan tool / held Space: the world layer is pointer-inert (CSS), so
111
111
  // the event target is the container even over nodes.
112
112
  if (e.target !== container) return;
113
+ // A pan gesture must not double as the browser's drag-select: without
114
+ // preventDefault the sweep still extends a text selection through the
115
+ // pointer-inert world, and a later pointerdown inside that selection
116
+ // starts a native text drag the canvas mis-reads as a URL drop —
117
+ // panning from a node minted webpage nodes from card text (0.5.1 Amp
118
+ // finding D).
119
+ e.preventDefault();
113
120
  } else {
114
121
  // Select tool: background drag belongs to the lasso (CanvasViewport).
115
122
  return;
@@ -30,6 +30,8 @@ type McpUiTheme = 'light' | 'dark';
30
30
  type ExtAppBridgeNotifications = Pick<AppBridge, 'sendToolInput' | 'sendToolResult'>;
31
31
  type DisplayMode = 'inline' | 'fullscreen' | 'pip';
32
32
  type ExtAppFrameStatus = 'loading' | 'ready' | 'done';
33
+ /** Paint probe outcome: only a content-carrying tick counts as painted. */
34
+ export type PaintProbeVerdict = 'content-verified' | 'tick-no-content' | 'timeout';
33
35
 
34
36
  interface ExtAppHostDimensionsTarget {
35
37
  clientWidth?: number;
@@ -135,7 +137,7 @@ export function extAppRecoveryLog(nodeId: string, event: string): void {
135
137
  // panel without devtools access — the Copilot WKWebView, exactly where the
136
138
  // black tiles happen — still yields an actionable trace: agents read it back
137
139
  // via GET /api/canvas/debug/ext-app-recovery (0.3.2 report Finding N).
138
- let extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
140
+ const extAppReportQueue: Array<{ t: number; nodeId: string; event: string; visibility?: string }> = [];
139
141
  let extAppReportTimer: ReturnType<typeof setTimeout> | null = null;
140
142
  function queueExtAppRecoveryReport(entry: { t: number; nodeId: string; event: string }): void {
141
143
  extAppReportQueue.push({
@@ -285,10 +287,72 @@ export function buildExtAppBootBeaconScript(frameToken: string, nodeId: string):
285
287
  // rendering pipeline is actually running — a frame whose layer WebKit never
286
288
  // composites stays silent, and that silence drives the parent's recovery
287
289
  // ladder. One unsolicited tick at boot, then on-demand answers to probes.
290
+ // Content oracle v2 (Finding N, 0.5.1 reopen): a LAID-OUT canvas passed the
291
+ // 0.5.1 oracle even when nothing was ever drawn into it — Excalidraw mounts
292
+ // its canvas element the moment React renders, so blank cream tiles read
293
+ // paint-ok (content-verified), the ladder concluded success, and Retry
294
+ // never engaged (Copilot 0.5.1 report: A/C/E cream with a green trail).
295
+ // The oracle now samples the canvas's PIXELS: a readable main drawing
296
+ // surface with uniform pixels is not content, whatever shell chrome or
297
+ // boilerplate text the document laid out around it.
298
+ function canvasInk(el) {
299
+ try {
300
+ if (!el.width || !el.height) return 'blank';
301
+ var sw = Math.min(96, el.width);
302
+ var sh = Math.min(64, el.height);
303
+ var probe = document.createElement('canvas');
304
+ probe.width = sw;
305
+ probe.height = sh;
306
+ var ctx = probe.getContext('2d', { willReadFrequently: true });
307
+ if (!ctx) return 'unknown';
308
+ ctx.drawImage(el, 0, 0, sw, sh);
309
+ var d = ctx.getImageData(0, 0, sw, sh).data;
310
+ var r = d[0], g = d[1], b = d[2], a = d[3];
311
+ for (var i = 4; i < d.length; i += 4) {
312
+ if (Math.abs(d[i] - r) > 3 || Math.abs(d[i + 1] - g) > 3 || Math.abs(d[i + 2] - b) > 3 || Math.abs(d[i + 3] - a) > 3) return 'ink';
313
+ }
314
+ return 'blank';
315
+ } catch (e) {
316
+ return 'unknown'; // tainted or WebGL-unreadable: pixels cannot be judged
317
+ }
318
+ }
319
+ function contentVerdict() {
320
+ try {
321
+ var canvases = document.querySelectorAll('canvas');
322
+ var blankMain = false;
323
+ var unreadable = false;
324
+ for (var i = 0; i < canvases.length; i++) {
325
+ var r = canvases[i].getBoundingClientRect();
326
+ if (r.width <= 8 || r.height <= 8) continue;
327
+ var ink = canvasInk(canvases[i]);
328
+ if (ink === 'ink') return { content: true, detail: 'ink' };
329
+ if (ink === 'unknown') unreadable = true;
330
+ else if (r.width >= 160 && r.height >= 120) blankMain = true;
331
+ }
332
+ // A readable, never-painted main surface vetoes everything below:
333
+ // toolbar icons and shell text around a blank drawing surface is
334
+ // exactly the false green the human reports as an empty cream tile.
335
+ if (blankMain) return { content: false, detail: 'blank-canvas' };
336
+ if (unreadable) return { content: true, detail: 'canvas-unreadable' };
337
+ var svgs = document.querySelectorAll('svg');
338
+ for (var j = 0; j < svgs.length; j++) {
339
+ var sr = svgs[j].getBoundingClientRect();
340
+ if (sr.width > 8 && sr.height > 8 && svgs[j].querySelector('path, rect, circle, ellipse, line, polyline, polygon, text, image, use')) {
341
+ return { content: true, detail: 'svg' };
342
+ }
343
+ }
344
+ var body = document.body;
345
+ if (body && body.innerText && body.innerText.trim().length > 0) return { content: true, detail: 'text' };
346
+ return { content: false, detail: 'empty' };
347
+ } catch (e) {
348
+ return { content: false, detail: 'probe-error' };
349
+ }
350
+ }
288
351
  function sendPaintTick() {
289
352
  requestAnimationFrame(function () {
290
353
  requestAnimationFrame(function () {
291
- window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick' }, '*');
354
+ var v = contentVerdict();
355
+ window.parent.postMessage({ source: '${EXT_APP_BOOT_BEACON_SOURCE}', token: TOKEN, nodeId: NODE_ID, kind: 'paint-tick', content: v.content, detail: v.detail }, '*');
292
356
  });
293
357
  });
294
358
  }
@@ -367,8 +431,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
367
431
  const bridgeReadyRef = useRef(false);
368
432
  const themeUnsubRef = useRef<(() => void) | null>(null);
369
433
  const webkitRemountAttemptsRef = useRef(0);
370
- // Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick.
371
- const paintTickWaitersRef = useRef<Array<() => void>>([]);
434
+ // Paint oracle (Finding N): resolvers waiting for the frame's next paint-tick,
435
+ // each handed the tick's content verdict + the oracle's reason for it.
436
+ const paintTickWaitersRef = useRef<Array<(tick: { content: boolean; detail: string }) => void>>([]);
437
+ // Last tick's oracle detail (ink / blank-canvas / svg / text / …) for the
438
+ // recovery trail — the label is the diagnostic the field reports read.
439
+ const lastPaintDetailRef = useRef<string>('');
372
440
  // Genuine boot signal: set ONLY when the app completes the ui/initialize
373
441
  // handshake (bridge.oninitialized) — NOT by the 1200ms bootstrap fallback,
374
442
  // which flips status via notifications that resolve even into a dead iframe.
@@ -450,7 +518,12 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
450
518
  return;
451
519
  appScriptsRanRef.current = true;
452
520
  if (data.kind === 'paint-tick') {
453
- for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
521
+ const tickData = data as { content?: unknown; detail?: unknown };
522
+ const tick = {
523
+ content: tickData.content === true,
524
+ detail: typeof tickData.detail === 'string' ? tickData.detail : '',
525
+ };
526
+ for (const waiter of paintTickWaitersRef.current.splice(0)) waiter(tick);
454
527
  }
455
528
  }
456
529
  window.addEventListener('message', onBootBeacon);
@@ -503,7 +576,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
503
576
  enqueueWebkitRemount({
504
577
  remount: () => {
505
578
  remountQueuedRef.current = false;
506
- if (unmountedRef.current || expandedNodeId.value === nodeId) {
579
+ if (unmountedRef.current || (!expanded && expandedNodeId.value === nodeId)) {
507
580
  extAppRecoveryLog(nodeId, 'remount-skipped');
508
581
  // The ladder left paintPending set on the promise that this remount
509
582
  // would re-enter it via the new frame's settle path. A skip means
@@ -555,28 +628,43 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
555
628
  // again → serialized remount (bounded by the shared attempt cap) → explicit
556
629
  // Retry affordance. Never silent eternal black. WebKit inline frames only.
557
630
  const PAINT_TICK_TIMEOUT_MS = 1500;
558
- const probePaint = (): Promise<boolean> =>
631
+ const probePaint = (): Promise<PaintProbeVerdict> =>
559
632
  new Promise((resolve) => {
560
633
  const contentWindow = iframeRef.current?.contentWindow;
561
634
  if (!contentWindow || unmountedRef.current) {
562
- resolve(false);
635
+ resolve('timeout');
563
636
  return;
564
637
  }
565
638
  let settled = false;
566
639
  const timer = window.setTimeout(() => {
567
640
  if (settled) return;
568
641
  settled = true;
569
- resolve(false);
642
+ resolve('timeout');
570
643
  }, PAINT_TICK_TIMEOUT_MS);
571
- paintTickWaitersRef.current.push(() => {
644
+ paintTickWaitersRef.current.push((tick) => {
572
645
  if (settled) return;
573
646
  settled = true;
574
647
  window.clearTimeout(timer);
575
- resolve(true);
648
+ lastPaintDetailRef.current = tick.detail;
649
+ // A tick without content is NOT paint-ok: the rAF pipeline runs while
650
+ // the document is empty cream / never composited (Finding N reopen).
651
+ resolve(tick.content ? 'content-verified' : 'tick-no-content');
576
652
  });
577
653
  contentWindow.postMessage({ source: EXT_APP_PAINT_PROBE_SOURCE, token: axToken }, '*');
578
654
  });
579
655
 
656
+ // Bounded wait for the frame's content oracle to turn green — the mount
657
+ // queue's release gate (guidance: never green-light the NEXT app while the
658
+ // previous document is still contentless; D-then-E was the repro).
659
+ const awaitContentVerified = async (maxMs: number): Promise<boolean> => {
660
+ const deadline = Date.now() + maxMs;
661
+ while (Date.now() < deadline && !unmountedRef.current) {
662
+ if ((await probePaint()) === 'content-verified') return true;
663
+ await new Promise((resolve) => window.setTimeout(resolve, 400));
664
+ }
665
+ return false;
666
+ };
667
+
580
668
  // Force a real layer reallocation: grow the iframe's box meaningfully for two
581
669
  // frames, then restore. Invisible under the paint-pending overlay.
582
670
  const runSoftExpandCycle = (): Promise<void> =>
@@ -636,31 +724,52 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
636
724
  // A paint-ok collected while the document claims hidden proves only that the
637
725
  // app's rAF pipeline is alive — NOT that anything composited (Finding N,
638
726
  // 0.4.7). Say so in the trail instead of recording an unqualified success.
639
- const paintOkLabel = (suffix = ''): string =>
640
- typeof document !== 'undefined' && document.visibilityState === 'hidden'
641
- ? `paint-ok${suffix} (unverified: host hidden)`
642
- : `paint-ok${suffix}`;
727
+ // The [detail] bracket names WHAT the oracle verified (ink / svg / text) so
728
+ // field reports can distinguish pixel-verified greens from weaker ones.
729
+ const paintOkLabel = (suffix = ''): string => {
730
+ const detail = lastPaintDetailRef.current ? ` [${lastPaintDetailRef.current}]` : '';
731
+ return typeof document !== 'undefined' && document.visibilityState === 'hidden'
732
+ ? `paint-ok (content-verified)${detail}${suffix} (host hidden)`
733
+ : `paint-ok (content-verified)${detail}${suffix}`;
734
+ };
643
735
 
644
736
  const runPaintLadder = async (): Promise<void> => {
645
- if (expanded) return;
737
+ // Runs for the EXPANDED instance too (Finding N reopen: expanded C was a
738
+ // black frame with no recovery — the ladder used to early-return here).
646
739
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
647
740
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
648
741
  setPaintPending(true);
649
- if (await probePaint()) {
742
+ let verdict = await probePaint();
743
+ if (verdict === 'content-verified') {
650
744
  extAppRecoveryLog(nodeId, paintOkLabel());
651
745
  setPaintPending(false);
652
746
  maybeAssumeVisibleRearm();
653
747
  return;
654
748
  }
655
- extAppRecoveryLog(nodeId, 'paint-fail');
749
+ extAppRecoveryLog(
750
+ nodeId,
751
+ verdict === 'tick-no-content'
752
+ ? `paint-fail (tick without content: ${lastPaintDetailRef.current || 'unknown'})`
753
+ : 'paint-fail (no tick)',
754
+ );
656
755
  await runSoftExpandCycle();
657
- if (await probePaint()) {
756
+ verdict = await probePaint();
757
+ if (verdict === 'content-verified') {
658
758
  extAppRecoveryLog(nodeId, paintOkLabel(' (after soft-expand)'));
659
759
  setPaintPending(false);
660
760
  maybeAssumeVisibleRearm();
661
761
  return;
662
762
  }
663
763
  if (unmountedRef.current) return;
764
+ // A contentless-but-alive app may still be DRAWING its scene: give the
765
+ // oracle a bounded settle before burning a remount attempt on it.
766
+ if (verdict === 'tick-no-content' && (await awaitContentVerified(3_000))) {
767
+ extAppRecoveryLog(nodeId, paintOkLabel(' (after content wait)'));
768
+ setPaintPending(false);
769
+ maybeAssumeVisibleRearm();
770
+ return;
771
+ }
772
+ if (unmountedRef.current) return;
664
773
  // The remounted frame re-enters this ladder from its own settle path.
665
774
  if (!scheduleWebkitRemount('paint-fail')) {
666
775
  extAppRecoveryLog(nodeId, 'recovery-exhausted');
@@ -709,7 +818,8 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
709
818
  // serialized queue (bounded by the shared attempt cap).
710
819
  const WEBKIT_BOOT_WATCHDOG_MS = 6000;
711
820
  useEffect(() => {
712
- if (expanded) return;
821
+ // Covers the expanded instance too — its fresh iframe was black with no
822
+ // recovery at all (Finding N reopen, expanded C).
713
823
  if (typeof navigator === 'undefined' || typeof window === 'undefined') return;
714
824
  if (!isWebKitOnlyHost(navigator.userAgent)) return;
715
825
  if (!iframeDocument.ready || !mountGranted) return;
@@ -725,7 +835,7 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
725
835
  () => () => {
726
836
  unmountedRef.current = true;
727
837
  for (const waiter of bootWaitersRef.current.splice(0)) waiter();
728
- for (const waiter of paintTickWaitersRef.current.splice(0)) waiter();
838
+ for (const waiter of paintTickWaitersRef.current.splice(0)) waiter({ content: false, detail: 'unmounted' });
729
839
  },
730
840
  [],
731
841
  );
@@ -1035,8 +1145,27 @@ export function ExtAppFrame({ node, expanded = false }: { node: CanvasNodeState;
1035
1145
  // its own settle pause covering the draw) only from this genuine path;
1036
1146
  // the bootstrap fallback never releases it (a dead iframe must run the
1037
1147
  // queue's bounded timeout instead of green-lighting the next remount).
1038
- extAppRecoveryLog(nodeId, 'settled');
1039
- for (const waiter of bootWaitersRef.current.splice(0)) waiter();
1148
+ // Release the mount queue only once this document's CONTENT
1149
+ // oracle is green (bounded): a settled-but-empty app must not
1150
+ // green-light the next mount slot (D-then-E repro, Finding N
1151
+ // reopen). WebKit only — elsewhere the oracle race doesn't exist.
1152
+ const releaseQueue = () => {
1153
+ for (const waiter of bootWaitersRef.current.splice(0)) waiter();
1154
+ };
1155
+ if (typeof navigator !== 'undefined' && isWebKitOnlyHost(navigator.userAgent)) {
1156
+ void awaitContentVerified(4_000).then((verified) => {
1157
+ extAppRecoveryLog(
1158
+ nodeId,
1159
+ verified
1160
+ ? 'settled (content-verified)'
1161
+ : `settled (content-timeout${lastPaintDetailRef.current ? `: ${lastPaintDetailRef.current}` : ''})`,
1162
+ );
1163
+ releaseQueue();
1164
+ });
1165
+ } else {
1166
+ extAppRecoveryLog(nodeId, 'settled');
1167
+ releaseQueue();
1168
+ }
1040
1169
  nudgeHostContextAfterLayout();
1041
1170
  schedulePaintLadder();
1042
1171
  })
@@ -9,7 +9,7 @@ import {
9
9
  type ViewportState,
10
10
  } from '../types';
11
11
  import { computeAutoArrange } from '../../shared/auto-arrange';
12
- import { canvasAreaCenter } from '../canvas/canvas-area';
12
+ import { canvasArea, canvasAreaCenter, canvasFitInsets } from '../canvas/canvas-area';
13
13
  import { pushCanvasUpdate, requestBestEffort, requestOk, updateViewportFromClient } from './intent-bridge';
14
14
 
15
15
  function logCanvasStoreError(action: string, error: unknown): void {
@@ -808,16 +808,22 @@ export function fitAll(containerW: number, containerH: number): void {
808
808
  maxY = Math.max(maxY, n.position.y + n.size.height);
809
809
  }
810
810
 
811
- const PAD = 60;
812
- const worldW = maxX - minX + PAD * 2;
813
- const worldH = maxY - minY + PAD * 2;
814
- const scale = Math.min(1, Math.min(containerW / worldW, containerH / worldH));
811
+ // Screen-space fit (architecture rule 9): the margins that must clear the
812
+ // floating chrome are SCREEN constants. The old world-space PAD shrank with
813
+ // the fit zoom, parking fitted nodes under the command bar and minimap
814
+ // (0.5.0 Amp report, finding A).
815
+ const insets = canvasFitInsets();
816
+ const usableW = Math.max(80, containerW - insets.left - insets.right);
817
+ const usableH = Math.max(80, containerH - insets.top - insets.bottom);
818
+ const worldW = Math.max(1, maxX - minX);
819
+ const worldH = Math.max(1, maxY - minY);
820
+ const scale = Math.min(1, Math.min(usableW / worldW, usableH / worldH));
815
821
  const cx = (minX + maxX) / 2;
816
822
  const cy = (minY + maxY) / 2;
817
823
 
818
824
  animateViewport({
819
- x: containerW / 2 - cx * scale,
820
- y: containerH / 2 - cy * scale,
825
+ x: insets.left + usableW / 2 - cx * scale,
826
+ y: insets.top + usableH / 2 - cy * scale,
821
827
  scale,
822
828
  });
823
829
  }
@@ -827,14 +833,26 @@ export function focusNode(id: string, options: { recordHistory?: boolean } = {})
827
833
  const node = nodes.value.get(id);
828
834
  if (!node) return;
829
835
  const v = viewport.value;
836
+ // Same screen-space frame as fitAll: viewport x/y are CONTAINER-relative
837
+ // (screen = world*scale + viewport inside the canvas region), so the centre
838
+ // is built from the region's dimensions + chrome insets — the old
839
+ // window-coordinate canvasAreaCenter() over-shifted focus by the region
840
+ // origin, and centring without insets parked tall nodes (the 960×600
841
+ // mcp-app open) under the command bar (0.5.1 Amp finding C).
842
+ const area = canvasArea();
843
+ const insets = canvasFitInsets();
844
+ const usableW = Math.max(80, area.width - insets.left - insets.right);
845
+ const usableH = Math.max(80, area.height - insets.top - insets.bottom);
846
+ // Shrink (never grow) so the whole node — resize corner included — fits the
847
+ // usable rect clear of the floating chrome.
848
+ const scale = Math.min(v.scale, usableW / node.size.width, usableH / node.size.height);
830
849
  const cx = node.position.x + node.size.width / 2;
831
850
  const cy = node.position.y + node.size.height / 2;
832
- const centre = canvasAreaCenter();
833
851
  animateViewport(
834
852
  {
835
- x: centre.x - cx * v.scale,
836
- y: centre.y - cy * v.scale,
837
- scale: v.scale,
853
+ x: insets.left + usableW / 2 - cx * scale,
854
+ y: insets.top + usableH / 2 - cy * scale,
855
+ scale,
838
856
  },
839
857
  300,
840
858
  options,