pmx-canvas 0.1.9 → 0.1.10

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
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",
package/src/cli/agent.ts CHANGED
@@ -241,6 +241,14 @@ function optionalPositiveFiniteFlagWithAliases(
241
241
  return undefined;
242
242
  }
243
243
 
244
+ function optionalBooleanFlag(flags: Record<string, string | true>, name: string, hint: string): boolean | undefined {
245
+ const val = flags[name];
246
+ if (val === undefined) return undefined;
247
+ if (val === true || val === 'true') return true;
248
+ if (val === 'false') return false;
249
+ die(`Invalid value for --${name}: ${String(val)}`, hint);
250
+ }
251
+
244
252
  function isRecord(value: unknown): value is Record<string, unknown> {
245
253
  return !!value && typeof value === 'object' && !Array.isArray(value);
246
254
  }
@@ -1216,6 +1224,7 @@ cmd('node update', 'Update a node by ID', [
1216
1224
  'pmx-canvas node update <node-id> --width 840 --height 620',
1217
1225
  'pmx-canvas node update <node-id> --spec-file ./dashboard.json',
1218
1226
  'pmx-canvas node update <graph-id> --data-file ./metrics.json --chart-height 420',
1227
+ 'pmx-canvas node update <node-id> --pinned true',
1219
1228
  'pmx-canvas node update <node-id> --lock-arrange',
1220
1229
  ], async (args) => {
1221
1230
  const { positional, flags } = parseFlags(args);
@@ -1234,6 +1243,17 @@ cmd('node update', 'Update a node by ID', [
1234
1243
  const y = optionalFiniteFlag(flags, 'y', 'Use a finite number, e.g. --y 300');
1235
1244
  const width = optionalPositiveFiniteFlag(flags, 'width', 'Use a positive number, e.g. --width 840');
1236
1245
  const height = optionalPositiveFiniteFlag(flags, 'height', 'Use a positive number, e.g. --height 620');
1246
+ const nodeHeight = optionalPositiveFiniteFlagWithAliases(
1247
+ flags,
1248
+ 'Use a positive number, e.g. --node-height 620',
1249
+ 'node-height',
1250
+ 'nodeHeight',
1251
+ );
1252
+ if (height !== undefined && nodeHeight !== undefined) {
1253
+ die('Use either --height/--node-height, not both.');
1254
+ }
1255
+ const frameHeight = height ?? nodeHeight;
1256
+ const pinned = optionalBooleanFlag(flags, 'pinned', 'Use --pinned true or --pinned false');
1237
1257
  if (flags['lock-arrange'] && flags['unlock-arrange']) {
1238
1258
  die('Use either --lock-arrange or --unlock-arrange, not both.');
1239
1259
  }
@@ -1243,7 +1263,7 @@ cmd('node update', 'Update a node by ID', [
1243
1263
  ? false
1244
1264
  : undefined;
1245
1265
 
1246
- if (x !== undefined || y !== undefined || width !== undefined || height !== undefined || arrangeLocked !== undefined) {
1266
+ if (x !== undefined || y !== undefined || width !== undefined || frameHeight !== undefined || arrangeLocked !== undefined) {
1247
1267
  const existing = await api('GET', `/api/canvas/node/${encodeURIComponent(id)}`) as {
1248
1268
  position: { x: number; y: number };
1249
1269
  size: { width: number; height: number };
@@ -1257,10 +1277,10 @@ cmd('node update', 'Update a node by ID', [
1257
1277
  };
1258
1278
  }
1259
1279
 
1260
- if (width !== undefined || height !== undefined) {
1280
+ if (width !== undefined || frameHeight !== undefined) {
1261
1281
  body.size = {
1262
1282
  width: width ?? existing.size.width,
1263
- height: height ?? existing.size.height,
1283
+ height: frameHeight ?? existing.size.height,
1264
1284
  };
1265
1285
  }
1266
1286
 
@@ -1269,10 +1289,12 @@ cmd('node update', 'Update a node by ID', [
1269
1289
  }
1270
1290
  }
1271
1291
 
1292
+ if (pinned !== undefined) body.pinned = pinned;
1293
+
1272
1294
  if (Object.keys(body).length === 0) {
1273
1295
  die(
1274
1296
  'No updates specified',
1275
- 'Use --title, --content, --x, --y, --width, --height, --lock-arrange, --unlock-arrange, or --stdin',
1297
+ 'Use --title, --content, --x, --y, --width, --height, --pinned, --lock-arrange, --unlock-arrange, or --stdin',
1276
1298
  );
1277
1299
  }
1278
1300
 
package/src/cli/index.ts CHANGED
@@ -30,7 +30,7 @@ if (args.includes('--version') || args.includes('-v')) {
30
30
  // If first arg is a known subcommand (not a --flag), route to the agent CLI.
31
31
  const AGENT_COMMANDS = new Set([
32
32
  'node', 'edge', 'search', 'layout', 'status', 'arrange', 'focus',
33
- 'pin', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
33
+ 'fit', 'pin', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
34
34
  'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'graph', 'batch', 'validate', 'serve',
35
35
  ]);
36
36
 
@@ -1,14 +1,14 @@
1
1
  import type { CanvasNodeState } from '../types';
2
2
 
3
- export const AUTO_FIT_MAX_HEIGHT = 600;
4
3
  export const AUTO_FIT_TITLEBAR_HEIGHT = 37;
4
+ export const AUTO_FIT_MAX_HEIGHT = 600;
5
5
 
6
6
  function isExtAppNode(node: CanvasNodeState): boolean {
7
7
  return node.type === 'mcp-app' && node.data.mode === 'ext-app';
8
8
  }
9
9
 
10
10
  function hasExplicitStructuredFrame(node: CanvasNodeState): boolean {
11
- return (node.type === 'graph' || node.type === 'json-render') && node.size.height > AUTO_FIT_MAX_HEIGHT;
11
+ return node.type === 'graph' || node.type === 'json-render';
12
12
  }
13
13
 
14
14
  export function shouldAutoFitNode(node: CanvasNodeState): boolean {
@@ -7,13 +7,22 @@
7
7
  */
8
8
 
9
9
  import { defineCatalog } from '@json-render/core';
10
+ import { z } from 'zod';
10
11
  import { schema } from './schema.js';
11
12
  import { shadcnComponentDefinitions } from '@json-render/shadcn/catalog';
12
13
  import { chartComponentDefinitions } from './charts/definitions';
13
14
  import { extraChartComponentDefinitions } from './charts/extra-definitions';
14
15
 
16
+ const badgeDefinition = shadcnComponentDefinitions.Badge;
17
+
15
18
  export const allComponentDefinitions = {
16
19
  ...shadcnComponentDefinitions,
20
+ Badge: {
21
+ ...badgeDefinition,
22
+ props: badgeDefinition.props.extend({
23
+ variant: z.enum(['default', 'secondary', 'destructive', 'outline', 'success', 'info', 'warning', 'error', 'danger']).nullable(),
24
+ }),
25
+ },
17
26
  ...chartComponentDefinitions,
18
27
  ...extraChartComponentDefinitions,
19
28
  };
@@ -143,6 +143,67 @@ button {
143
143
  cursor: pointer;
144
144
  }
145
145
 
146
+ .pmx-badge {
147
+ display: inline-flex;
148
+ width: fit-content;
149
+ align-items: center;
150
+ justify-content: center;
151
+ gap: 0.25rem;
152
+ overflow: hidden;
153
+ white-space: nowrap;
154
+ border: 1px solid transparent;
155
+ border-radius: 9999px;
156
+ padding: 0.125rem 0.5rem;
157
+ font-size: 0.75rem;
158
+ font-weight: 500;
159
+ line-height: 1.25rem;
160
+ }
161
+
162
+ .pmx-badge--default {
163
+ background: var(--primary);
164
+ color: var(--primary-foreground);
165
+ }
166
+
167
+ .pmx-badge--secondary {
168
+ background: var(--secondary);
169
+ color: var(--secondary-foreground);
170
+ }
171
+
172
+ .pmx-badge--destructive {
173
+ background: var(--destructive);
174
+ color: var(--destructive-foreground);
175
+ }
176
+
177
+ .pmx-badge--outline {
178
+ border-color: var(--border);
179
+ color: var(--foreground);
180
+ }
181
+
182
+ .pmx-badge--success {
183
+ border-color: color-mix(in oklch, var(--chart-2) 45%, transparent);
184
+ background: color-mix(in oklch, var(--chart-2) 16%, transparent);
185
+ color: var(--chart-2);
186
+ }
187
+
188
+ .pmx-badge--info {
189
+ border-color: color-mix(in oklch, var(--chart-1) 45%, transparent);
190
+ background: color-mix(in oklch, var(--chart-1) 14%, transparent);
191
+ color: var(--chart-1);
192
+ }
193
+
194
+ .pmx-badge--warning {
195
+ border-color: color-mix(in oklch, var(--chart-3) 50%, transparent);
196
+ background: color-mix(in oklch, var(--chart-3) 18%, transparent);
197
+ color: var(--chart-3);
198
+ }
199
+
200
+ .pmx-badge--error,
201
+ .pmx-badge--danger {
202
+ border-color: color-mix(in oklch, var(--destructive) 55%, transparent);
203
+ background: color-mix(in oklch, var(--destructive) 18%, transparent);
204
+ color: var(--destructive);
205
+ }
206
+
146
207
  /* -- Chart components -- */
147
208
 
148
209
  .pmx-chart {
@@ -15,9 +15,31 @@ import { catalog } from '../catalog';
15
15
  import { chartComponents } from '../charts/components';
16
16
  import { extraChartComponents } from '../charts/extra-components';
17
17
 
18
+ type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline' | 'success' | 'info' | 'warning' | 'error' | 'danger';
19
+ type BadgeProps = {
20
+ text: string;
21
+ variant?: BadgeVariant | null;
22
+ className?: string | null;
23
+ };
24
+
25
+ function Badge({ props }: { props: BadgeProps }) {
26
+ const variant = props.variant;
27
+ const resolvedVariant = variant ?? 'default';
28
+ return (
29
+ <span
30
+ data-slot="badge"
31
+ data-variant={resolvedVariant}
32
+ className={`pmx-badge pmx-badge--${resolvedVariant}`}
33
+ >
34
+ {props.text}
35
+ </span>
36
+ );
37
+ }
38
+
18
39
  const { registry } = defineRegistry(catalog as never, {
19
40
  components: {
20
41
  ...shadcnComponents,
42
+ Badge,
21
43
  ...chartComponents,
22
44
  ...extraChartComponents,
23
45
  } as never,
@@ -262,14 +262,6 @@ function normalizeButtonVariant(value: unknown): unknown {
262
262
  return value;
263
263
  }
264
264
 
265
- function normalizeBadgeVariant(value: unknown): unknown {
266
- if (value === 'success') return 'default';
267
- if (value === 'info') return 'secondary';
268
- if (value === 'warning') return 'outline';
269
- if (value === 'error' || value === 'danger') return 'destructive';
270
- return value;
271
- }
272
-
273
265
  function deriveElementName(elementKey: string): string {
274
266
  const normalized = elementKey.replace(/[^a-zA-Z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
275
267
  return normalized || 'field';
@@ -344,9 +336,6 @@ function normalizeElementProps(
344
336
  if ('label' in props) {
345
337
  delete props.label;
346
338
  }
347
- if ('variant' in props) {
348
- props.variant = normalizeBadgeVariant(props.variant);
349
- }
350
339
  }
351
340
 
352
341
  if (type === 'Select' || type === 'Radio') {
@@ -49,6 +49,13 @@ function fullyContains(group: CanvasNodeState, child: CanvasNodeState): boolean
49
49
  );
50
50
  }
51
51
 
52
+ function isGroupChildPair(group: CanvasNodeState, child: CanvasNodeState): boolean {
53
+ if (group.type !== 'group') return false;
54
+ if (child.data.parentGroup === group.id) return true;
55
+ const children = group.data.children;
56
+ return Array.isArray(children) && children.includes(child.id);
57
+ }
58
+
52
59
  function pair(a: CanvasNodeState, b: CanvasNodeState): CanvasValidationPair {
53
60
  return {
54
61
  aId: a.id,
@@ -78,11 +85,11 @@ export function validateCanvasLayout(layout: CanvasLayout): CanvasValidationResu
78
85
  const b = layout.nodes[j]!;
79
86
  if (!overlaps(a, b)) continue;
80
87
 
81
- if (a.type === 'group' && b.data.parentGroup === a.id) {
88
+ if (isGroupChildPair(a, b)) {
82
89
  (fullyContains(a, b) ? containments : containmentViolations).push(containment(a, b));
83
90
  continue;
84
91
  }
85
- if (b.type === 'group' && a.data.parentGroup === b.id) {
92
+ if (isGroupChildPair(b, a)) {
86
93
  (fullyContains(b, a) ? containments : containmentViolations).push(containment(b, a));
87
94
  continue;
88
95
  }
@@ -115,6 +115,52 @@ function elementHasCameraUpdate(elements: Array<Record<string, unknown>>): boole
115
115
  return elements.some((element) => element.type === 'cameraUpdate');
116
116
  }
117
117
 
118
+ function normalizeExcalidrawBoundText(elements: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
119
+ const elementsById = new Map<string, Record<string, unknown>>();
120
+ for (const element of elements) {
121
+ if (typeof element.id === 'string') elementsById.set(element.id, element);
122
+ }
123
+
124
+ let changed = false;
125
+ const boundElementIdsByContainer = new Map<string, Set<string>>();
126
+
127
+ for (const element of elements) {
128
+ if (element.type !== 'text' || typeof element.id !== 'string' || typeof element.containerId !== 'string') continue;
129
+ if (!elementsById.has(element.containerId)) continue;
130
+ const ids = boundElementIdsByContainer.get(element.containerId) ?? new Set<string>();
131
+ ids.add(element.id);
132
+ boundElementIdsByContainer.set(element.containerId, ids);
133
+ }
134
+
135
+ const normalized = elements.map((element) => {
136
+ if (typeof element.id !== 'string') return element;
137
+ const boundTextIds = boundElementIdsByContainer.get(element.id);
138
+ if (!boundTextIds || boundTextIds.size === 0) return element;
139
+
140
+ const existing = Array.isArray(element.boundElements)
141
+ ? element.boundElements.filter(isRecord)
142
+ : [];
143
+ const existingTextIds = new Set(
144
+ existing
145
+ .filter((boundElement) => boundElement.type === 'text' && typeof boundElement.id === 'string')
146
+ .map((boundElement) => boundElement.id as string),
147
+ );
148
+ const missing = [...boundTextIds].filter((id) => !existingTextIds.has(id));
149
+ if (missing.length === 0) return element;
150
+
151
+ changed = true;
152
+ return {
153
+ ...element,
154
+ boundElements: [
155
+ ...existing,
156
+ ...missing.map((id) => ({ type: 'text', id })),
157
+ ],
158
+ };
159
+ });
160
+
161
+ return changed ? normalized : elements;
162
+ }
163
+
118
164
  function resolveExcalidrawCameraSize(width: number, height: number): { width: number; height: number } {
119
165
  const requiredWidth = Math.max(EXCALIDRAW_MIN_CAMERA_WIDTH, width);
120
166
  const requiredHeight = Math.max(EXCALIDRAW_MIN_CAMERA_HEIGHT, height);
@@ -211,13 +257,13 @@ export function normalizeExcalidrawElements(elements: unknown): string {
211
257
  export function normalizeExcalidrawElementsForToolInput(elements: unknown): string {
212
258
  const parsed = parseExcalidrawElements(elements);
213
259
  const seeded = parsed.length > 0 ? parsed : [...DEFAULT_EXCALIDRAW_ELEMENTS];
214
- return JSON.stringify(withInferredCameraUpdate(seeded));
260
+ return JSON.stringify(withInferredCameraUpdate(normalizeExcalidrawBoundText(seeded)));
215
261
  }
216
262
 
217
263
  export function normalizeExcalidrawCheckpointDataForToolInput(data: unknown): string | null {
218
264
  const elements = parseExcalidrawCheckpointElements(data);
219
265
 
220
- return elements ? JSON.stringify(withInferredCameraUpdate(elements)) : null;
266
+ return elements ? JSON.stringify(withInferredCameraUpdate(normalizeExcalidrawBoundText(elements))) : null;
221
267
  }
222
268
 
223
269
  export function buildExcalidrawRestoreCheckpointToolInput(checkpointId: string, data?: unknown): string {