pmx-canvas 0.1.32 → 0.1.34

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.
@@ -5,6 +5,7 @@ import { buildAppHtml } from '@json-render/mcp/build-app-html';
5
5
  import { applySpecPatch, parseSpecStreamLine, type Spec, type SpecStreamLine } from '@json-render/core';
6
6
  import { allComponentDefinitions, catalog, validateShadcnElementProps, type JsonRenderIssue } from './catalog.js';
7
7
  import { findUnknownDirectiveKey, isDynamicPropValue } from './directives.js';
8
+ import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
8
9
 
9
10
  export interface JsonRenderSpec {
10
11
  root: string;
@@ -944,6 +945,12 @@ export async function buildJsonRenderViewerHtml(options: {
944
945
  nodeId?: string;
945
946
  axToken?: string;
946
947
  axState?: unknown;
948
+ /** Nonce for the content-height reporter so the node can grow to fit the chart. */
949
+ frameToken?: string;
950
+ /** When true, charts render at their natural (intrinsic) height instead of
951
+ * filling the viewport down — so the reported scrollHeight is stable and the
952
+ * node grows to it. Off for strictSize / user-resized nodes (they fill-down). */
953
+ fitContent?: boolean;
947
954
  }): Promise<string> {
948
955
  const sanitizeAxValue = (v?: string): string => (typeof v === 'string' ? v.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80) : '');
949
956
  try {
@@ -966,13 +973,18 @@ export async function buildJsonRenderViewerHtml(options: {
966
973
  // Read-side AX state: seed for initial render + bound under /ax for specs.
967
974
  `window.__PMX_CANVAS_AX_STATE__ = ${JSON.stringify(options.axState ?? null).replace(/</g, '\\u003c')};`,
968
975
  ] : []),
976
+ ...(options.fitContent ? ['window.__PMX_CANVAS_FIT_CONTENT__ = true;'] : []),
969
977
  jsBundle,
970
978
  ].join('\n');
979
+ // Content-height reporter: posts the viewer's natural scrollHeight so the
980
+ // parent node grows to fit (the #48 graph-clipping fix). Shared with the html
981
+ // surface (src/shared, no src/server import) so the two stay identical.
982
+ const heightReporter = options.frameToken ? contentHeightReporterTag(options.frameToken) : '';
971
983
  return buildAppHtml({
972
984
  title: options.title,
973
985
  css: cssBundle,
974
986
  js: escapeInlineScriptSource(boot),
975
- head: '<meta name="color-scheme" content="light dark" />',
987
+ head: `<meta name="color-scheme" content="light dark" />${heightReporter}`,
976
988
  });
977
989
  } catch (error) {
978
990
  const message = error instanceof Error ? error.message : String(error);
package/src/mcp/server.ts CHANGED
@@ -200,6 +200,56 @@ function wantsFullPayload(input: { full?: boolean; verbose?: boolean; includeDat
200
200
  return input.full === true || input.verbose === true || input.includeData === true;
201
201
  }
202
202
 
203
+ interface PendingAxActivityItem {
204
+ kind: 'work-item' | 'approval-gate' | 'elicitation' | 'mode-request';
205
+ id: string;
206
+ title: string;
207
+ status: string;
208
+ nodeIds: string[];
209
+ source: string | null;
210
+ }
211
+
212
+ const OPEN_AX_WORK_STATUSES = new Set(['todo', 'in-progress', 'blocked']);
213
+
214
+ /**
215
+ * Open, agent-actionable canvas-bound AX items (open work items + pending approval
216
+ * gates / elicitations / mode requests). Unlike steering (a directive routed through
217
+ * the claim/ack delivery queue), these are STATE the human curates in the browser —
218
+ * they fire `ax-state-changed` (so resource-subscribers are pushed canvas://ax-work),
219
+ * but an adapterless client that only POLLS the delivery surface never saw them.
220
+ * Surfacing this digest there closes report #43 without conflating state with steering.
221
+ * Optionally excludes items the consumer itself originated (loop prevention), mirroring
222
+ * getPendingSteering.
223
+ */
224
+ function buildPendingAxActivity(
225
+ state: Awaited<ReturnType<CanvasAccess['getAxState']>>,
226
+ consumer?: string,
227
+ ): PendingAxActivityItem[] {
228
+ const notMine = (source: string | null) => !consumer || source !== consumer;
229
+ const out: PendingAxActivityItem[] = [];
230
+ for (const w of state.workItems ?? []) {
231
+ if (OPEN_AX_WORK_STATUSES.has(w.status) && notMine(w.source)) {
232
+ out.push({ kind: 'work-item', id: w.id, title: w.title, status: w.status, nodeIds: w.nodeIds ?? [], source: w.source });
233
+ }
234
+ }
235
+ for (const g of state.approvalGates ?? []) {
236
+ if (g.status === 'pending' && notMine(g.source)) {
237
+ out.push({ kind: 'approval-gate', id: g.id, title: g.title, status: g.status, nodeIds: g.nodeIds ?? [], source: g.source });
238
+ }
239
+ }
240
+ for (const e of state.elicitations ?? []) {
241
+ if (e.status === 'pending' && notMine(e.source)) {
242
+ out.push({ kind: 'elicitation', id: e.id, title: e.prompt, status: e.status, nodeIds: e.nodeIds ?? [], source: e.source });
243
+ }
244
+ }
245
+ for (const m of state.modeRequests ?? []) {
246
+ if (m.status === 'pending' && notMine(m.source)) {
247
+ out.push({ kind: 'mode-request', id: m.id, title: m.reason ? `${m.mode}: ${m.reason}` : `mode: ${m.mode}`, status: m.status, nodeIds: m.nodeIds ?? [], source: m.source });
248
+ }
249
+ }
250
+ return out;
251
+ }
252
+
203
253
  function compactNodePayload(node: Awaited<ReturnType<CanvasAccess['getNode']>>): Record<string, unknown> | null {
204
254
  if (!node) return null;
205
255
  const serialized = serializeCanvasNode(node);
@@ -1688,10 +1738,10 @@ export async function startMcpServer(): Promise<void> {
1688
1738
 
1689
1739
  server.tool(
1690
1740
  'canvas_claim_ax_delivery',
1691
- 'Claim pending PMX AX steering messages for a consumer (adapterless delivery). Returns undelivered steering, excluding messages the consumer itself originated (loop prevention). After acting on a message, call canvas_mark_ax_delivery.',
1741
+ 'Claim pending PMX AX deliveries for a consumer (adapterless delivery). Returns `pending` undelivered steering (mark each with canvas_mark_ax_delivery after acting) AND `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — typically created by the human in the browser. Both exclude items the consumer itself originated (loop prevention). pendingActivity is read-only here: resolve each via its own tool (canvas_resolve_approval / canvas_respond_elicitation / canvas_resolve_mode / canvas_update_work_item), not canvas_mark_ax_delivery.',
1692
1742
  {
1693
1743
  consumer: z.string().optional().describe('Consumer/source label to exclude from results (e.g. copilot, mcp).'),
1694
- limit: z.number().optional().describe('Max messages to return.'),
1744
+ limit: z.number().optional().describe('Max steering messages to return.'),
1695
1745
  },
1696
1746
  async ({ consumer, limit }) => {
1697
1747
  const c = await ensureCanvas();
@@ -1699,7 +1749,8 @@ export async function startMcpServer(): Promise<void> {
1699
1749
  ...(consumer ? { consumer } : {}),
1700
1750
  ...(typeof limit === 'number' ? { limit } : {}),
1701
1751
  });
1702
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, pending }) }] };
1752
+ const pendingActivity = buildPendingAxActivity(await c.getAxState(), consumer);
1753
+ return { content: [{ type: 'text', text: JSON.stringify({ ok: true, pending, pendingActivity }) }] };
1703
1754
  },
1704
1755
  );
1705
1756
 
@@ -2304,15 +2355,16 @@ export async function startMcpServer(): Promise<void> {
2304
2355
  'canvas://ax-pending-steering',
2305
2356
  {
2306
2357
  description:
2307
- 'Undelivered PMX AX steering messages an MCP client can claim and act on without a host-native adapter. After delivering an instruction to your agent, mark it via canvas_mark_ax_delivery so it is not handed out again.',
2358
+ 'Adapterless AX delivery surface. `pending`: undelivered steering messages to claim and act on, then mark via canvas_mark_ax_delivery. `pendingActivity`: open canvas-bound AX items awaiting the agent (open work items, pending approval gates / elicitations / mode requests) — usually created by the human in the browser; these fire ax-state-changed (resource-subscribers are also pushed canvas://ax-work). Resolve pendingActivity via its own tool, not canvas_mark_ax_delivery. Use canvas_claim_ax_delivery for the loop-safe, consumer-scoped view.',
2308
2359
  mimeType: 'application/json',
2309
2360
  },
2310
2361
  async () => {
2311
2362
  const c = await ensureCanvas();
2312
- const pending = await c.getPendingSteering();
2363
+ const [pending, state] = await Promise.all([c.getPendingSteering(), c.getAxState()]);
2364
+ const pendingActivity = buildPendingAxActivity(state);
2313
2365
  return {
2314
2366
  contents: [
2315
- { uri: 'canvas://ax-pending-steering', mimeType: 'application/json', text: JSON.stringify({ pending }, null, 2) },
2367
+ { uri: 'canvas://ax-pending-steering', mimeType: 'application/json', text: JSON.stringify({ pending, pendingActivity }, null, 2) },
2316
2368
  ],
2317
2369
  };
2318
2370
  },
@@ -1363,7 +1363,7 @@ export function addCanvasEdge(input: {
1363
1363
  label?: string;
1364
1364
  style?: CanvasEdge['style'];
1365
1365
  animated?: boolean;
1366
- }): { id: string; from: string; to: string } {
1366
+ }): CanvasEdge {
1367
1367
  const fromResult = resolveCanvasNode({
1368
1368
  ...(typeof input.from === 'string' ? { id: input.from } : {}),
1369
1369
  ...(typeof input.fromSearch === 'string' ? { search: input.fromSearch } : {}),
@@ -1393,7 +1393,7 @@ export function addCanvasEdge(input: {
1393
1393
  if (!added) {
1394
1394
  throw new Error('Duplicate or self-edge.');
1395
1395
  }
1396
- return { id, from: fromResult.node.id, to: toResult.node.id };
1396
+ return edge;
1397
1397
  }
1398
1398
 
1399
1399
  export function removeCanvasEdge(id: string): { removed: boolean } {
@@ -15,6 +15,8 @@
15
15
  * postMessage required.
16
16
  */
17
17
 
18
+ import { contentHeightReporterTag } from '../shared/content-height-reporter.js';
19
+
18
20
  export type SurfaceTheme = 'dark' | 'light' | 'high-contrast';
19
21
 
20
22
  /** Path the surface document links for its theme tokens (served from dist/canvas). */
@@ -150,6 +152,16 @@ export function buildAxStateBridge(axToken: string, snapshotJson: string): strin
150
152
  </script>`;
151
153
  }
152
154
 
155
+ /**
156
+ * Reports the surface's natural content height to the parent canvas so the node
157
+ * can GROW to fit it (the fix for iframe nodes the parent can't measure — graph,
158
+ * json-render, html, web-artifact). Thin wrapper over the shared reporter so this
159
+ * and the json-render injection site stay byte-identical (no drift).
160
+ */
161
+ export function buildContentHeightReporter(frameToken: string): string {
162
+ return contentHeightReporterTag(frameToken);
163
+ }
164
+
153
165
  /** Escape a string for safe interpolation into element text (e.g. `<title>`). */
154
166
  function escapeSurfaceHtml(value: string): string {
155
167
  return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -178,6 +190,8 @@ export interface HtmlSurfaceOptions {
178
190
  * axBridge is enabled). Kept live via parent → iframe `ax-update` messages.
179
191
  */
180
192
  axState?: unknown;
193
+ /** Nonce for the content-height reporter (lets the node grow to fit content). */
194
+ contentHeightToken?: string;
181
195
  }
182
196
 
183
197
  /**
@@ -202,7 +216,10 @@ export function buildHtmlSurfaceDocument(userHtml: string, options: HtmlSurfaceO
202
216
  options.axState !== undefined ? JSON.stringify(options.axState).replace(/</g, '\\u003c') : 'null',
203
217
  )
204
218
  : '';
205
- const injectedHeadContent = `${link}${themeBridge}${presentationBridge}${axBridge}${axStateBridge}`;
219
+ const contentHeightBridge = options.contentHeightToken
220
+ ? buildContentHeightReporter(sanitizeToken(options.contentHeightToken))
221
+ : '';
222
+ const injectedHeadContent = `${link}${themeBridge}${presentationBridge}${axBridge}${axStateBridge}${contentHeightBridge}`;
206
223
  const presentationAttr = options.presentation ? ' data-pmx-presentation-mode="present"' : '';
207
224
  const trimmed = userHtml.trim();
208
225
  const isFullDoc = /<html[\s>]/i.test(trimmed);
@@ -48,7 +48,7 @@ import type {
48
48
  ListToolsResult,
49
49
  } from '@modelcontextprotocol/sdk/types.js';
50
50
  import { type CanvasAnnotation, type CanvasEdge, type CanvasLayout, type CanvasNodeState, IMAGE_MIME_MAP, canvasState } from './canvas-state.js';
51
- import { buildAxBridge, buildAxStateBridge, buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
51
+ import { buildAxBridge, buildAxStateBridge, buildContentHeightReporter, buildHtmlSurfaceDocument, HTML_SURFACE_SANDBOX, normalizeSurfaceTheme } from './html-surface.js';
52
52
  import { findCanvasExtAppNodeId as findCanvasExtAppNodeIdShared } from './ext-app-lookup.js';
53
53
  import { normalizeExtAppToolResult } from './ext-app-tool-result.js';
54
54
  import { getMcpAppHostSnapshot } from './mcp-app-host.js';
@@ -1051,16 +1051,25 @@ function rotatePrimaryWorkbenchSessionIfNeeded(): void {
1051
1051
  primaryWorkbenchSessionId = `pmx-${Date.now().toString(36)}`;
1052
1052
  }
1053
1053
 
1054
- function readJson(req: Request): Promise<Record<string, unknown>> {
1055
- return req.json()
1056
- .then((value) => {
1057
- if (!value || typeof value !== 'object') return {};
1058
- return value as Record<string, unknown>;
1059
- })
1060
- .catch((error) => {
1061
- logWorkbenchWarning('readJson', error);
1062
- return {};
1063
- });
1054
+ async function readJson(req: Request): Promise<Record<string, unknown>> {
1055
+ let text = '';
1056
+ try {
1057
+ text = await req.text();
1058
+ } catch (error) {
1059
+ logWorkbenchWarning('readJson', error);
1060
+ return {};
1061
+ }
1062
+
1063
+ if (!text.trim()) return {};
1064
+
1065
+ try {
1066
+ const value = JSON.parse(text) as unknown;
1067
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
1068
+ return value as Record<string, unknown>;
1069
+ } catch (error) {
1070
+ logWorkbenchWarning('readJson', error);
1071
+ return {};
1072
+ }
1064
1073
  }
1065
1074
 
1066
1075
  function htmlEscape(value: string): string {
@@ -1447,6 +1456,8 @@ function handleNodeSurface(pathname: string, url: URL): Response {
1447
1456
  nodeId: node.id,
1448
1457
  // Seed the read-side bridge with the current AX state (only for AX surfaces).
1449
1458
  ...(axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
1459
+ // Content-height reporter nonce (lets an html node grow to fit its content).
1460
+ ...(url.searchParams.get('frameToken') ? { contentHeightToken: url.searchParams.get('frameToken') as string } : {}),
1450
1461
  });
1451
1462
  return surfaceHtmlResponse(doc, HTML_SURFACE_SANDBOX);
1452
1463
  }
@@ -2413,7 +2424,14 @@ async function handleCanvasAddGraph(req: Request): Promise<Response> {
2413
2424
  const x = pickFiniteNumber(body, 'x') ?? (position ? pickFiniteNumber(position, 'x') : undefined);
2414
2425
  const y = pickFiniteNumber(body, 'y') ?? (position ? pickFiniteNumber(position, 'y') : undefined);
2415
2426
  const width = pickPositiveNumber(body, 'width') ?? (size ? pickPositiveNumber(size, 'width') : undefined);
2416
- const nodeHeight = pickPositiveNumber(body, 'nodeHeight') ?? (size ? pickPositiveNumber(size, 'height') : undefined);
2427
+ // Node FRAME height. `body.height` is the CHART plot height (passed through as
2428
+ // `input.height` below), so the node frame accepts `nodeHeight` / `heightPx` /
2429
+ // `size.height` as aliases — `heightPx` matches createCanvasGraphNode's own input
2430
+ // field, the natural thing a caller reaches for. (With content-fit the node grows
2431
+ // to the chart anyway; this just removes the silent "height ignored" surprise.)
2432
+ const nodeHeight = pickPositiveNumber(body, 'nodeHeight')
2433
+ ?? pickPositiveNumber(body, 'heightPx')
2434
+ ?? (size ? pickPositiveNumber(size, 'height') : undefined);
2417
2435
  const showLegend = typeof body.showLegend === 'boolean' ? body.showLegend : undefined;
2418
2436
  const showLabels = typeof body.showLabels === 'boolean' ? body.showLabels : undefined;
2419
2437
  const colorBy =
@@ -2540,6 +2558,8 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
2540
2558
  url.searchParams.get('devtools') === '1';
2541
2559
  const axToken = url.searchParams.get('axToken');
2542
2560
  const axEnabled = resolveNodeAxCapabilities(node).enabled;
2561
+ const frameToken = url.searchParams.get('frameToken');
2562
+ const fitContent = url.searchParams.get('fit') === 'content';
2543
2563
  const html = await buildJsonRenderViewerHtml({
2544
2564
  title,
2545
2565
  spec,
@@ -2549,6 +2569,9 @@ async function handleJsonRenderView(url: URL): Promise<Response> {
2549
2569
  ...(axToken ? { nodeId, axToken } : {}),
2550
2570
  // Seed the read-side AX state (only for AX-enabled nodes) so specs can bind /ax.
2551
2571
  ...(axToken && axEnabled ? { axState: buildCanvasAxSurfaceSnapshot() } : {}),
2572
+ // Content-fit: report natural height (charts render intrinsic) so the node grows.
2573
+ ...(frameToken ? { frameToken } : {}),
2574
+ ...(fitContent ? { fitContent: true } : {}),
2552
2575
  });
2553
2576
  return new Response(html, {
2554
2577
  headers: {
@@ -2627,6 +2650,14 @@ function handleArtifactView(url: URL): Response {
2627
2650
  : `${bridge}${content}`;
2628
2651
  }
2629
2652
  }
2653
+ // Content-height reporter so a web-artifact node grows to fit its app (#48).
2654
+ const frameToken = url.searchParams.get('frameToken');
2655
+ if (frameToken) {
2656
+ const reporter = buildContentHeightReporter(frameToken.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80));
2657
+ content = content.includes('</head>')
2658
+ ? content.replace('</head>', `${reporter}</head>`)
2659
+ : `${reporter}${content}`;
2660
+ }
2630
2661
  return new Response(content, {
2631
2662
  headers: {
2632
2663
  'Content-Type': 'text/html; charset=utf-8',
@@ -3861,9 +3892,10 @@ function handleGetAxSurfaceSnapshot(): Response {
3861
3892
 
3862
3893
  // Open a node's surface in the user's real system browser (for hosts whose
3863
3894
  // embedded browser makes window.open('_blank') feel in-place, e.g. Codex).
3864
- // Accepts ONLY { nodeId } and opens this server's own surface URL — never an
3865
- // arbitrary URL — so it can't be used to launch external sites (no SSRF). Honors
3866
- // the PMX_CANVAS_DISABLE_BROWSER_OPEN kill switch via openUrlInExternalBrowser.
3895
+ // Accepts ONLY { nodeId, url? } and opens this server's own surface URL — never
3896
+ // an arbitrary URL — so it can't be used to launch external sites (no SSRF).
3897
+ // The optional URL is limited to the same node surface route so callers can keep
3898
+ // safe presentation query params like the current theme.
3867
3899
  async function handleOpenExternalSurface(req: Request): Promise<Response> {
3868
3900
  const body = await readJson(req);
3869
3901
  const nodeId = typeof body.nodeId === 'string' ? body.nodeId : '';
@@ -3872,7 +3904,14 @@ async function handleOpenExternalSurface(req: Request): Promise<Response> {
3872
3904
  if (!node) return responseJson({ ok: false, error: `Node "${nodeId}" not found.` }, 404);
3873
3905
  const port = getCanvasServerPort();
3874
3906
  if (!port) return responseJson({ ok: false, opened: false, error: 'Server port unavailable.' }, 503);
3875
- const surfacePath = `/api/canvas/surface/${encodeURIComponent(nodeId)}`;
3907
+ const defaultSurfacePath = `/api/canvas/surface/${encodeURIComponent(nodeId)}`;
3908
+ const rawUrl = typeof body.url === 'string' ? body.url : defaultSurfacePath;
3909
+ const parsedUrl = new URL(rawUrl, `http://localhost:${port}`);
3910
+ if (parsedUrl.origin !== `http://localhost:${port}` || parsedUrl.pathname !== defaultSurfacePath) {
3911
+ return responseJson({ ok: false, error: 'url must target the requested node surface.' }, 400);
3912
+ }
3913
+ const theme = normalizeSurfaceTheme(parsedUrl.searchParams.get('theme'));
3914
+ const surfacePath = `${defaultSurfacePath}?theme=${encodeURIComponent(theme)}`;
3876
3915
  const opened = openUrlInExternalBrowser(`http://localhost:${port}${surfacePath}`);
3877
3916
  return responseJson({ ok: true, opened, url: surfacePath });
3878
3917
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Content-height reporter — injected into iframe-backed canvas surfaces so the
3
+ * parent canvas can grow the node to fit its content (the #48 graph-clipping fix).
4
+ *
5
+ * The surface posts its natural `document` scrollHeight to `window.parent` over a
6
+ * nonce-validated channel; the parent (use-iframe-content-height) grows the node
7
+ * grow-only to fit. Debounced (~100ms) + dead-banded (>4px) so a stray re-measure
8
+ * can't spam, and grow-only growth on the parent side cannot oscillate.
9
+ *
10
+ * Shared by both injection sites — src/server/html-surface.ts (html / web-artifact
11
+ * surfaces) and src/json-render/server.ts (the json-render/graph viewer) — so the
12
+ * two stay byte-identical. This module is framework-agnostic and imports nothing
13
+ * from src/server, preserving the json-render package's decoupling.
14
+ */
15
+
16
+ /** Sanitize a nonce for safe interpolation into an inline script literal. */
17
+ export function sanitizeFrameToken(token: string): string {
18
+ return token.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 80);
19
+ }
20
+
21
+ /** Inline JS (no `<script>` wrapper) that reports content height to the parent. */
22
+ export function contentHeightReporterSource(frameToken: string): string {
23
+ const token = JSON.stringify(sanitizeFrameToken(frameToken));
24
+ return `(function(){var T=${token};var last=0,timer=null;`
25
+ + `function m(){var d=document.documentElement;return Math.max(d?d.scrollHeight:0,document.body?document.body.scrollHeight:0);}`
26
+ + `function r(){var h=m();if(Math.abs(h-last)<=4)return;last=h;window.parent.postMessage({source:'pmx-canvas-frame',type:'content-height',token:T,height:h},'*');}`
27
+ + `function s(){if(timer)return;timer=setTimeout(function(){timer=null;r();},100);}`
28
+ + `if(document.readyState!=='loading')s();window.addEventListener('load',s);`
29
+ + `try{new ResizeObserver(s).observe(document.documentElement);}catch(e){}setTimeout(s,60);})();`;
30
+ }
31
+
32
+ /** `<script>`-wrapped reporter for injection into an HTML `<head>` / document. */
33
+ export function contentHeightReporterTag(frameToken: string): string {
34
+ return `<script data-pmx-canvas-content-height>${contentHeightReporterSource(frameToken)}</script>`;
35
+ }