pmx-canvas 0.1.7 → 0.1.8

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,56 @@
3
3
  All notable changes to `pmx-canvas` are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.1.8] - 2026-04-25
7
+
8
+ Retest-driven follow-up to 0.1.7. This next release restores compatibility
9
+ for image and json-render node creation, fixes the counter MCP fixture's
10
+ accidental iframe overflow, and syncs packaged testing guidance with the
11
+ canonical repo skill.
12
+
13
+ ### Fixed
14
+
15
+ - **Image `path` is accepted as a compatibility alias for `content`.**
16
+ `pmx-canvas node add --type image --path <file>`, HTTP
17
+ `/api/canvas/node` payloads with `{ type: "image", path }`, and MCP
18
+ `canvas_add_node({ type: "image", path })` now all validate the file
19
+ and populate `data.src` instead of creating an empty/broken image node.
20
+ - **json-render creation keeps 0.1.7 compatibility.**
21
+ `canvas_add_json_render_node`, `POST /api/canvas/json-render`, and
22
+ CLI `node add --type json-render` accept omitted titles and infer a
23
+ node title from the root element. Bare legacy component specs like
24
+ `{ type: "Badge", props: {...} }` are wrapped into a one-element
25
+ document before validation, while complete `{ root, elements }` specs
26
+ remain the canonical shape.
27
+ - **Counter MCP fixture no longer creates a scrollable iframe by
28
+ accident.** The fixture now uses border-box sizing and hides root
29
+ overflow so `100vh` layouts with padding do not become `100vh +
30
+ padding`. This removes the observed repeated downward scroll in the
31
+ counter app and adds browser coverage for zero iframe body overflow.
32
+
33
+ ### Changed
34
+
35
+ - **Packaged PMX Canvas testing skill is back in sync with canonical
36
+ guidance.** It now documents that `test:coverage` covers only the Bun
37
+ unit suite, names the coverage artifact, and calls out the WebView
38
+ automation timeout caveat used to distinguish environment limits from
39
+ product regressions.
40
+ - **Schema metadata for the `path` alias and relaxed json-render contract
41
+ is version-stable.** `canvas_describe_schema` and `canvas://schema`
42
+ surface the image `path` alias and `title.required: false` on
43
+ json-render so agents discover the new shapes without reading the
44
+ CHANGELOG.
45
+ - **Agent-facing `skills/pmx-canvas/SKILL.md` documents the same
46
+ contract.** The skill now describes the `path` alias for image nodes
47
+ and the relaxed json-render contract (omitted titles, bare component
48
+ specs) so agents do not need to retry across shapes.
49
+
50
+ ### Internal
51
+
52
+ - Regression coverage for image `path` alias handling across CLI/HTTP/MCP,
53
+ json-render compatibility for omitted titles plus bare component specs,
54
+ and hosted counter MCP app iframe overflow.
55
+
6
56
  ## [0.1.7] - 2026-04-26
7
57
 
8
58
  Small retest-driven follow-up to 0.1.6. Three agent-facing ergonomics:
@@ -50,6 +100,7 @@ otherwise have to discover by trial and error.
50
100
  - Regression coverage for snapshot flat-`id` aliases on both MCP and
51
101
  HTTP surfaces, plus async / top-level-`await` WebView script bodies.
52
102
 
103
+ [0.1.8]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.8
53
104
  [0.1.7]: https://github.com/pskoett/pmx-canvas/releases/tag/v0.1.7
54
105
 
55
106
  ## [0.1.6] - 2026-04-26
@@ -4,7 +4,7 @@ export interface JsonRenderSpec {
4
4
  state?: Record<string, unknown>;
5
5
  }
6
6
  export interface JsonRenderNodeInput {
7
- title: string;
7
+ title?: string;
8
8
  spec: unknown;
9
9
  x?: number;
10
10
  y?: number;
@@ -44,6 +44,7 @@ export declare const GRAPH_NODE_SIZE: {
44
44
  height: number;
45
45
  };
46
46
  export type GraphChartType = 'LineChart' | 'BarChart' | 'PieChart' | 'AreaChart' | 'ScatterChart' | 'RadarChart' | 'StackedBarChart' | 'ComposedChart';
47
+ export declare function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback?: string): string;
47
48
  export declare function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec;
48
49
  export declare function normalizeGraphType(value: string): GraphChartType;
49
50
  export declare function buildGraphSpec(input: GraphNodeInput): JsonRenderSpec;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Spatial canvas workbench for coding agents — infinite 2D canvas with agent-native CLI, MCP integration, nodes, edges, file watching, and snapshots",
5
5
  "type": "module",
6
6
  "main": "./src/server/index.ts",
@@ -270,6 +270,7 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
270
270
  - `content`: for most types, this is markdown text. For `file` type, pass the **file path**
271
271
  (e.g., `"src/auth/login.ts"`) — the server auto-loads the file content and watches for changes.
272
272
  For `image` type, pass a file path, URL, or data URI.
273
+ - `path`: compatibility alias for image paths only; prefer `content` for new image calls
273
274
  - `x`, `y`: position (auto-placed if omitted — prefer omitting for auto-layout)
274
275
  - `width`, `height`: dimensions (sensible defaults provided)
275
276
  - `color`: semantic color
@@ -306,8 +307,9 @@ If a node type is rejected by `canvas_add_node`, call `canvas_describe_schema` a
306
307
  ```
307
308
 
308
309
  **`canvas_add_json_render_node`** — Add a native json-render node
309
- - Required: `title`, `spec`
310
- - The `spec` must be a complete json-render object with `root`, `elements`, and optional `state`
310
+ - Required: `spec`; `title` is optional and inferred from the root element when omitted
311
+ - Prefer a complete json-render object with `root`, `elements`, and optional `state`
312
+ - Legacy bare component specs like `{ type: "Badge", props: {...} }` are accepted and wrapped into a one-element document for compatibility
311
313
  - Use this when you want a structured UI panel rendered directly inside PMX Canvas
312
314
  - For shadcn `Badge`, prefer `props.text` with variants `default`, `secondary`, `destructive`, or
313
315
  `outline`. Legacy `props.label` and status variants (`success`, `info`, `warning`, `error`,
@@ -40,11 +40,25 @@ bun run test:all # Bun suite + browser smoke
40
40
  Manual browser validation also requires a fresh client bundle. `bun run test:web-canvas`
41
41
  already does this for you.
42
42
 
43
+ ## Coverage Notes
44
+
45
+ - `bun run test:coverage` covers the Bun unit suite under `tests/unit/`
46
+ - Coverage output is written to `coverage/lcov.info` and also printed as a text summary
47
+ - CI currently uses that same unit-test coverage command, then runs browser smoke separately
48
+ - Do not describe `test:coverage` as full-stack coverage; Playwright coverage is not wired in here
49
+
43
50
  ## Current Project Test Surface
44
51
 
45
52
  - Bun tests live under `tests/unit/`
46
53
  - Playwright browser smoke lives under `tests/e2e/`
47
- - CI runs coverage plus the browser smoke flow
54
+ - CI runs Bun coverage plus the browser smoke flow
55
+
56
+ ## WebView Automation Caveat
57
+
58
+ - Some Linux/CI environments expose `Bun.WebView` but still cannot start a usable automation
59
+ session within the timeout window
60
+ - When testing WebView automation, treat a cleanly reported unsupported/timeout runtime boundary
61
+ as distinct from a product regression
48
62
 
49
63
  Prefer extending the existing suites before inventing a one-off script.
50
64
 
package/src/cli/agent.ts CHANGED
@@ -522,11 +522,8 @@ async function buildJsonRenderRequestBody(
522
522
  flags: Record<string, string | true>,
523
523
  ): Promise<Record<string, unknown>> {
524
524
  const hint =
525
- 'Use: pmx-canvas node add --type json-render --title "Ops Dashboard" --spec-file ./dashboard.json';
525
+ 'Use: pmx-canvas node add --type json-render --spec-file ./dashboard.json --title "Ops Dashboard"';
526
526
  const title = typeof flags.title === 'string' ? flags.title.trim() : '';
527
- if (!title) {
528
- die('json-render nodes require --title.', hint);
529
- }
530
527
 
531
528
  const rawSpec = await readTextInput(flags, {
532
529
  fileFlags: ['spec-file'],
@@ -538,7 +535,7 @@ async function buildJsonRenderRequestBody(
538
535
  });
539
536
 
540
537
  const spec = parseJsonValue(rawSpec, 'JSON spec', hint);
541
- const body: Record<string, unknown> = { title, spec };
538
+ const body: Record<string, unknown> = { ...(title ? { title } : {}), spec };
542
539
  applyCommonGeometryFlags(body, flags, {
543
540
  x: 'Use a finite number, e.g. --x 500',
544
541
  y: 'Use a finite number, e.g. --y 300',
@@ -985,8 +982,11 @@ cmd('node add', 'Add a node to the canvas', [
985
982
  const body: Record<string, unknown> = { type };
986
983
  if (flags.title) body.title = flags.title;
987
984
  const webpageUrl = getStringFlag(flags, 'url');
985
+ const imagePath = getStringFlag(flags, 'path');
988
986
  if (type === 'webpage' && webpageUrl) {
989
987
  body.url = webpageUrl;
988
+ } else if (type === 'image' && imagePath && !flags.content) {
989
+ body.content = imagePath;
990
990
  } else if (flags.content) {
991
991
  body.content = flags.content;
992
992
  }
@@ -11,7 +11,7 @@ export interface JsonRenderSpec {
11
11
  }
12
12
 
13
13
  export interface JsonRenderNodeInput {
14
- title: string;
14
+ title?: string;
15
15
  spec: unknown;
16
16
  x?: number;
17
17
  y?: number;
@@ -447,10 +447,38 @@ function normalizeSpec(spec: Record<string, unknown>): Record<string, unknown> {
447
447
  return changed ? { ...spec, elements: normalizedElements } : spec;
448
448
  }
449
449
 
450
- export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec {
450
+ function isBareJsonRenderElement(spec: Record<string, unknown>): boolean {
451
+ return typeof spec.type === 'string' && !('root' in spec) && !('elements' in spec);
452
+ }
453
+
454
+ function normalizeJsonRenderInput(spec: unknown): unknown {
451
455
  const specRecord = asRecord(spec);
456
+ if (!specRecord || !isBareJsonRenderElement(specRecord)) return spec;
457
+
458
+ return {
459
+ root: 'root',
460
+ elements: {
461
+ root: {
462
+ ...specRecord,
463
+ children: Array.isArray(specRecord.children)
464
+ ? specRecord.children.filter((child: unknown) => typeof child === 'string')
465
+ : [],
466
+ },
467
+ },
468
+ };
469
+ }
470
+
471
+ export function inferJsonRenderNodeTitle(spec: JsonRenderSpec, fallback = 'json-render'): string {
472
+ const rootElement = asRecord(spec.elements[spec.root]);
473
+ const rootProps = asRecord(rootElement?.props);
474
+ const title = rootProps?.title ?? rootProps?.text ?? rootElement?.type;
475
+ return typeof title === 'string' && title.trim().length > 0 ? title.trim() : fallback;
476
+ }
477
+
478
+ export function normalizeAndValidateJsonRenderSpec(spec: unknown): JsonRenderSpec {
479
+ const specRecord = asRecord(normalizeJsonRenderInput(spec));
452
480
  if (!specRecord || typeof specRecord.root !== 'string' || !asRecord(specRecord.elements)) {
453
- throw new Error('Missing root and elements in spec.');
481
+ throw new Error('Missing root and elements in spec. Pass a complete {root, elements} document, or a single bare component object with a type field.');
454
482
  }
455
483
 
456
484
  const normalizedSpec = normalizeSpec(specRecord);
package/src/mcp/server.ts CHANGED
@@ -41,11 +41,18 @@ import { listBundledSkills, readBundledSkill } from '../server/bundled-skills.js
41
41
 
42
42
  let canvas: PmxCanvas | null = null;
43
43
 
44
- const jsonRenderSpecSchema = z.object({
45
- root: z.string(),
46
- elements: z.record(z.string(), z.unknown()),
47
- state: z.record(z.string(), z.unknown()).optional(),
48
- }).passthrough();
44
+ const jsonRenderSpecSchema = z.union([
45
+ z.object({
46
+ root: z.string(),
47
+ elements: z.record(z.string(), z.unknown()),
48
+ state: z.record(z.string(), z.unknown()).optional(),
49
+ }).passthrough(),
50
+ z.object({
51
+ type: z.string(),
52
+ props: z.record(z.string(), z.unknown()).optional(),
53
+ children: z.array(z.string()).optional(),
54
+ }).passthrough(),
55
+ ]);
49
56
 
50
57
  function structuredSchemaDescription(): string {
51
58
  const routing = describeCanvasSchema().mcp.nodeTypeRouting;
@@ -140,6 +147,7 @@ export async function startMcpServer(): Promise<void> {
140
147
  .describe('Node type (prefer canvas_create_group for groups)'),
141
148
  title: z.string().optional().describe('Node title'),
142
149
  content: z.string().optional().describe('Node content (markdown for markdown nodes, file path for file nodes, image path/URL/data-URI for image nodes, URL for webpage nodes)'),
150
+ path: z.string().optional().describe('Compatibility alias for image node content. Prefer content for image paths.'),
143
151
  url: z.string().optional().describe('Canonical webpage URL field for webpage nodes. Overrides content when both are provided.'),
144
152
  x: z.number().optional().describe('X position (auto-placed if omitted)'),
145
153
  y: z.number().optional().describe('Y position (auto-placed if omitted)'),
@@ -169,7 +177,10 @@ export async function startMcpServer(): Promise<void> {
169
177
  ...(result.ok ? {} : { isError: true }),
170
178
  };
171
179
  }
172
- const id = c.addNode(input);
180
+ const nodeInput = input.type === 'image' && input.path && !input.content
181
+ ? { ...input, content: input.path }
182
+ : input;
183
+ const id = c.addNode(nodeInput);
173
184
  return {
174
185
  content: [{ type: 'text', text: JSON.stringify(createdNodePayload(c, id), null, 2) }],
175
186
  };
@@ -442,8 +453,8 @@ export async function startMcpServer(): Promise<void> {
442
453
  'canvas_add_json_render_node',
443
454
  'Create a native json-render canvas node from a complete spec. Use this for structured dashboards, forms, tables, and other interactive UI panels that should render directly inside PMX Canvas.',
444
455
  {
445
- title: z.string().describe('Node title'),
446
- spec: jsonRenderSpecSchema.describe('Complete json-render spec with root, elements, and optional state'),
456
+ title: z.string().optional().describe('Optional node title. If omitted, PMX Canvas infers one from the root element.'),
457
+ spec: z.unknown().describe('json-render spec. Prefer a complete {root, elements, state?} document; a single bare component object is accepted for legacy callers.'),
447
458
  x: z.number().optional().describe('Optional X position'),
448
459
  y: z.number().optional().describe('Optional Y position'),
449
460
  width: z.number().optional().describe('Optional node width'),
@@ -453,7 +464,7 @@ export async function startMcpServer(): Promise<void> {
453
464
  const c = await ensureCanvas();
454
465
  try {
455
466
  const result = c.addJsonRenderNode({
456
- title: input.title,
467
+ ...(typeof input.title === 'string' ? { title: input.title } : {}),
457
468
  spec: input.spec,
458
469
  ...(typeof input.x === 'number' ? { x: input.x } : {}),
459
470
  ...(typeof input.y === 'number' ? { y: input.y } : {}),
@@ -24,6 +24,7 @@ import {
24
24
  buildGraphSpec,
25
25
  createJsonRenderNodeData,
26
26
  GRAPH_NODE_SIZE,
27
+ inferJsonRenderNodeTitle,
27
28
  JSON_RENDER_NODE_SIZE,
28
29
  normalizeAndValidateJsonRenderSpec,
29
30
  type GraphNodeInput,
@@ -1000,7 +1001,7 @@ export function createCanvasJsonRenderNode(
1000
1001
  collapsed: false,
1001
1002
  pinned: false,
1002
1003
  dockPosition: null,
1003
- data: createJsonRenderNodeData(id, input.title, spec, {
1004
+ data: createJsonRenderNodeData(id, input.title?.trim() || inferJsonRenderNodeTitle(spec), spec, {
1004
1005
  viewerType: 'json-render',
1005
1006
  }),
1006
1007
  };
@@ -171,7 +171,7 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
171
171
  endpoint: '/api/canvas/node',
172
172
  mcpTool: 'canvas_add_node',
173
173
  fields: [
174
- { name: 'content', type: 'string', required: true, description: 'Image path, URL, or data URI.' },
174
+ { name: 'content', type: 'string', required: true, description: 'Image path, URL, or data URI.', aliases: ['path'] },
175
175
  { name: 'title', type: 'string', required: false, description: 'Optional title override.' },
176
176
  { name: 'data.warning', type: 'string | { title?: string; detail: string }', required: false, description: 'Optional agent-supplied warning shown above the image.' },
177
177
  { name: 'data.warnings', type: 'Array<string | { title?: string; detail: string }>', required: false, description: 'Optional list of agent-supplied image warnings.' },
@@ -280,8 +280,8 @@ const CANVAS_CREATE_TYPES: CanvasCreateTypeSchema[] = [
280
280
  endpoint: '/api/canvas/json-render',
281
281
  mcpTool: 'canvas_add_json_render_node',
282
282
  fields: [
283
- { name: 'title', type: 'string', required: true, description: 'Rendered node title.' },
284
- { name: 'spec', type: 'JsonRenderSpec', required: true, description: 'Complete json-render spec.' },
283
+ { name: 'title', type: 'string', required: false, description: 'Optional rendered node title; inferred from the root element when omitted.' },
284
+ { name: 'spec', type: 'JsonRenderSpec | JsonRenderElement', required: true, description: 'Complete {root, elements} json-render spec, or a legacy single bare component object with a type field.' },
285
285
  { name: 'x', type: 'number', required: false, description: 'Optional X position.' },
286
286
  { name: 'y', type: 'number', required: false, description: 'Optional Y position.' },
287
287
  { name: 'width', type: 'number', required: false, description: 'Optional node width.' },
@@ -1210,12 +1210,15 @@ async function handleCanvasAddNode(req: Request): Promise<Response> {
1210
1210
  const extraData = body.data && typeof body.data === 'object' && !Array.isArray(body.data)
1211
1211
  ? body.data as Record<string, unknown>
1212
1212
  : undefined;
1213
+ const content = type === 'image' && typeof body.path === 'string' && typeof body.content !== 'string'
1214
+ ? body.path
1215
+ : body.content;
1213
1216
  let added: ReturnType<typeof addCanvasNode>;
1214
1217
  try {
1215
1218
  added = addCanvasNode({
1216
1219
  type: type as CanvasNodeState['type'],
1217
1220
  ...(typeof body.title === 'string' ? { title: body.title } : {}),
1218
- ...(typeof body.content === 'string' ? { content: body.content } : {}),
1221
+ ...(typeof content === 'string' ? { content } : {}),
1219
1222
  ...(extraData ? { data: extraData } : {}),
1220
1223
  ...(typeof body.x === 'number' ? { x: body.x } : {}),
1221
1224
  ...(typeof body.y === 'number' ? { y: body.y } : {}),
@@ -1603,13 +1606,10 @@ async function handleCanvasAddJsonRender(req: Request): Promise<Response> {
1603
1606
  const title = typeof body.title === 'string' ? body.title.trim() : '';
1604
1607
  const rawSpec =
1605
1608
  body.spec && typeof body.spec === 'object' && !Array.isArray(body.spec) ? body.spec : body;
1606
- if (!title) {
1607
- return responseJson({ ok: false, error: 'Missing required field: title.' }, 400);
1608
- }
1609
1609
 
1610
1610
  try {
1611
1611
  const result = createCanvasJsonRenderNode({
1612
- title,
1612
+ ...(title ? { title } : {}),
1613
1613
  spec: rawSpec,
1614
1614
  ...(typeof body.x === 'number' ? { x: body.x } : {}),
1615
1615
  ...(typeof body.y === 'number' ? { y: body.y } : {}),