pmx-canvas 0.1.21 → 0.1.23

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.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * SQLite persistence layer for canvas state.
3
+ *
4
+ * Uses Bun's built-in `bun:sqlite` for zero-dependency, synchronous,
5
+ * WAL-mode persistence. Replaces the previous JSON file-based approach.
6
+ */
7
+ import { Database } from 'bun:sqlite';
8
+ import type { CanvasAnnotation, CanvasEdge, CanvasNodeState, CanvasSnapshot, CanvasSnapshotListOptions, ViewportState } from './canvas-state.js';
9
+ export interface PersistedCanvasState {
10
+ version: number;
11
+ viewport: ViewportState;
12
+ nodes: CanvasNodeState[];
13
+ edges: CanvasEdge[];
14
+ annotations?: CanvasAnnotation[];
15
+ contextPins: string[];
16
+ }
17
+ export declare function openCanvasDb(dbPath: string): Database;
18
+ export declare function checkpointCanvasDb(db: Database): void;
19
+ export declare function finalizeCanvasDbForClose(db: Database): void;
20
+ export declare function saveStateToDB(db: Database, state: PersistedCanvasState): void;
21
+ /** Check if the DB has been populated with canvas state at least once. */
22
+ export declare function isDbPopulated(db: Database): boolean;
23
+ export declare function loadStateFromDB(db: Database): PersistedCanvasState | null;
24
+ export declare function saveSnapshotToDB(db: Database, snapshot: CanvasSnapshot, state: PersistedCanvasState): void;
25
+ export declare function loadSnapshotFromDB(db: Database, idOrName: string): {
26
+ snapshot: CanvasSnapshot;
27
+ state: PersistedCanvasState;
28
+ } | null;
29
+ export declare function listSnapshotsFromDB(db: Database, options?: CanvasSnapshotListOptions): CanvasSnapshot[];
30
+ export declare function deleteSnapshotFromDB(db: Database, id: string): boolean;
31
+ export declare function writeBlobToDB(db: Database, sha256: string, jsonValue: string): number;
32
+ export declare function readBlobFromDB(db: Database, sha256: string): string | null;
33
+ export declare function hasBlobInDB(db: Database, sha256: string): boolean;
@@ -35,6 +35,7 @@ export declare function getCanvasNodeTitle(node: CanvasNodeState): string | null
35
35
  export declare function getCanvasNodeContent(node: CanvasNodeState): string | null;
36
36
  export declare function serializeCanvasNode(node: CanvasNodeState): SerializedCanvasNode;
37
37
  export declare function serializeCanvasNodeForAgent(node: CanvasNodeState): SerializedCanvasNode;
38
+ export declare function serializeCanvasNodeCompact(node: CanvasNodeState): SerializedCanvasNode;
38
39
  export declare function serializeCanvasNodeWithBlobSummaries(node: CanvasNodeState): SerializedCanvasNode;
39
40
  export declare function serializeCanvasLayout(layout: CanvasLayout): SerializedCanvasLayout;
40
41
  export declare function serializeCanvasLayoutForAgent(layout: CanvasLayout): SerializedCanvasLayout;
@@ -5,9 +5,11 @@
5
5
  * - Agent tools (Phase 3) can read/mutate canvas state
6
6
  * - Client syncs bidirectionally (SSE for server→client, POST for client→server)
7
7
  *
8
- * Persistence: canvas state auto-saves to `.pmx-canvas/state.json` in the
9
- * workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
8
+ * Persistence: canvas state auto-saves to `.pmx-canvas/canvas.db` (SQLite WAL mode)
9
+ * in the workspace root on every mutation (debounced). Auto-loads on `loadFromDisk()`.
10
+ * Legacy `.pmx-canvas/state.json` is auto-migrated on first boot.
10
11
  */
12
+ import { type PersistedCanvasState } from './canvas-db.js';
11
13
  export declare const PMX_CANVAS_DIR = ".pmx-canvas";
12
14
  export interface PersistedBlobRef {
13
15
  __pmxCanvasBlob: 'v1';
@@ -17,14 +19,7 @@ export interface PersistedBlobRef {
17
19
  bytes: number;
18
20
  jsonBytes: number;
19
21
  }
20
- interface PersistedCanvasState {
21
- version: number;
22
- viewport: ViewportState;
23
- nodes: CanvasNodeState[];
24
- edges: CanvasEdge[];
25
- annotations?: CanvasAnnotation[];
26
- contextPins: string[];
27
- }
22
+ export type { PersistedCanvasState } from './canvas-db.js';
28
23
  interface LoadFromDiskOptions {
29
24
  clearExisting?: boolean;
30
25
  }
@@ -166,6 +161,7 @@ declare class CanvasStateManager {
166
161
  private recomputeParentGroupBounds;
167
162
  private compactGroupChildren;
168
163
  private _stateFilePath;
164
+ private _db;
169
165
  private _saveTimer;
170
166
  /** Set the workspace root to enable auto-persistence. */
171
167
  setWorkspaceRoot(workspaceRoot: string): void;
@@ -185,15 +181,22 @@ declare class CanvasStateManager {
185
181
  * No-op when the new layout already exists.
186
182
  */
187
183
  private migrateLegacyLayout;
184
+ /**
185
+ * One-time migration: import state.json + snapshot JSON files + blob files
186
+ * into the SQLite database. Renames originals to `.bak`.
187
+ */
188
+ private migrateJsonToSqlite;
188
189
  getWorkspaceRoot(): string;
189
190
  private emptyPersistedState;
190
- /** Load canvas state from disk. Call once on server startup. */
191
+ /** Load canvas state from SQLite (or legacy JSON fallback). Call once on server startup. */
191
192
  loadFromDisk(options?: LoadFromDiskOptions): boolean;
192
- /** Debounced save — coalesces rapid mutations into a single disk write. */
193
+ /** Debounced save — coalesces rapid mutations into a single write. */
193
194
  private scheduleSave;
194
195
  flushToDisk(): void;
195
- /** Write current state to disk immediately. */
196
+ /** Write current state to SQLite immediately. */
196
197
  private saveToDisk;
198
+ /** Close the SQLite database cleanly. Call on server shutdown. */
199
+ close(): void;
197
200
  private get snapshotsDir();
198
201
  private applyPersistedState;
199
202
  private readResolvedSnapshot;
@@ -217,6 +220,8 @@ declare class CanvasStateManager {
217
220
  } | null;
218
221
  /** Delete a snapshot. */
219
222
  deleteSnapshot(id: string): boolean;
223
+ /** Remove all snapshots from the DB. Used by test teardown. */
224
+ clearAllSnapshots(): void;
220
225
  get viewport(): ViewportState;
221
226
  addNode(node: CanvasNodeState): void;
222
227
  addJsonRenderNode(node: CanvasNodeState): void;
@@ -250,4 +255,3 @@ declare class CanvasStateManager {
250
255
  clear(): void;
251
256
  }
252
257
  export declare const canvasState: CanvasStateManager;
253
- export {};
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmx-canvas",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
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",
@@ -106,6 +106,6 @@
106
106
  },
107
107
  "homepage": "https://github.com/pskoett/pmx-canvas#readme",
108
108
  "engines": {
109
- "bun": ">=1.3.12"
109
+ "bun": ">=1.3.14"
110
110
  }
111
111
  }
@@ -960,12 +960,16 @@ When the human wants to explore a different approach without losing current work
960
960
 
961
961
  ## Persistence
962
962
 
963
- Canvas state auto-saves to `.pmx-canvas/state.json` on every mutation (debounced 500ms). State
964
- loads automatically on server start. The file is git-committable — spatial knowledge
963
+ Canvas state auto-saves to `.pmx-canvas/canvas.db` on every mutation (debounced 500ms). State
964
+ loads automatically on server start. The SQLite DB is git-committable — spatial knowledge
965
965
  persists across sessions.
966
966
 
967
- Snapshots save to `.pmx-canvas/snapshots/`. Web artifacts land in `.pmx-canvas/artifacts/`.
968
- Legacy `.pmx-canvas.json` and `.pmx-canvas-snapshots/` are auto-migrated on first boot.
967
+ Snapshots, context pins, and large node blobs are stored in the same DB. Web artifacts land in
968
+ `.pmx-canvas/artifacts/`. Legacy JSON state, snapshot, and blob files are auto-imported into
969
+ SQLite and renamed to `.bak` on first boot.
970
+
971
+ Stop the server or flush/close the SDK before committing `canvas.db`; shutdown checkpoints SQLite
972
+ WAL data into the DB file.
969
973
 
970
974
  ## Real-Time Collaboration
971
975
 
package/src/cli/agent.ts CHANGED
@@ -164,7 +164,7 @@ function parseFlags(args: string[]): { positional: string[]; flags: Record<strin
164
164
  // Boolean-only flags (never take a value argument)
165
165
  const BOOL_FLAGS = new Set([
166
166
  'help', 'h', 'ids', 'stdin', 'yes', 'list', 'clear', 'set', 'animated', 'dry-run', 'all',
167
- 'no-open-in-canvas', 'lock-arrange', 'unlock-arrange', 'json', 'compact', 'summary',
167
+ 'no-open-in-canvas', 'lock-arrange', 'unlock-arrange', 'json', 'compact',
168
168
  'verbose', 'include-logs', 'no-pan', 'schema', 'example', 'examples', 'strict-size', 'scroll-overflow',
169
169
  ]);
170
170
  for (let i = 0; i < args.length; i++) {
@@ -1723,6 +1723,16 @@ cmd('external-app add', 'Create a hosted external app node', [
1723
1723
  : result);
1724
1724
  });
1725
1725
 
1726
+ cmd('diagram add', 'Create an Excalidraw diagram node', [
1727
+ 'pmx-canvas diagram add --title "Architecture"',
1728
+ 'pmx-canvas diagram add --title "Architecture" --elements \'[{"type":"rectangle","id":"r1","x":0,"y":0,"width":120,"height":80}]\'',
1729
+ ], async (args) => {
1730
+ const { flags } = parseFlags(args);
1731
+ if (flags.help || flags.h) return showCommandHelp('diagram add');
1732
+ const externalAppAdd = COMMANDS['external-app add'];
1733
+ await externalAppAdd.run([...args, '--kind', 'excalidraw']);
1734
+ });
1735
+
1726
1736
  // ── pin ──────────────────────────────────────────────────────
1727
1737
  cmd('pin', 'Manage context pins', [
1728
1738
  'pmx-canvas pin node1 node2 node3',
@@ -2431,11 +2441,19 @@ function showCommandHelp(name: string): void {
2431
2441
  if (name === 'node add') {
2432
2442
  console.log('\nSchema help:');
2433
2443
  console.log(' pmx-canvas node add --help --type webpage');
2444
+ console.log(' pmx-canvas node add --help --type html');
2434
2445
  console.log(' pmx-canvas node add --help --type json-render --component Table');
2435
2446
  console.log(' pmx-canvas node add --help --type graph');
2436
2447
  console.log(' pmx-canvas html primitive schema --summary');
2437
2448
  console.log(' pmx-canvas node add --help --type webpage --json');
2438
2449
  console.log(' Use --strict-size to keep explicit width/height fixed and scroll overflowing content.');
2450
+ console.log('\nHTML sidecar flags:');
2451
+ console.log(' --summary <text> Explicit human/agent-readable summary');
2452
+ console.log(' --agent-summary <text> Semantic summary for search, pinned context, and spatial context');
2453
+ console.log(' --description <text> Optional longer semantic description');
2454
+ console.log(' --presentation true Mark raw HTML as an explicit presentation deck');
2455
+ console.log(' --slide-title <text> Add a presentation slide title sidecar');
2456
+ console.log(' --embedded-node-id <id> Link represented/embedded canvas node ID');
2439
2457
  }
2440
2458
  if (name === 'html primitive add' || name === 'html primitive schema') {
2441
2459
  console.log('\nPrimitive flags:');
@@ -2534,6 +2552,10 @@ function showCommandHelp(name: string): void {
2534
2552
  console.log(' --initial-file <path> Alias for --elements-file');
2535
2553
  console.log(' --timeout-ms <number> Optional downstream MCP timeout for cold starts');
2536
2554
  }
2555
+ if (name === 'diagram add') {
2556
+ console.log('\nAlias:');
2557
+ console.log(' Equivalent to: pmx-canvas external-app add --kind excalidraw ...');
2558
+ }
2537
2559
  console.log('');
2538
2560
  }
2539
2561
 
@@ -2561,6 +2583,7 @@ Node commands:
2561
2583
  pmx-canvas graph add [options] Add a graph node
2562
2584
  pmx-canvas html primitive add Add an HTML communication primitive
2563
2585
  pmx-canvas html primitive schema List HTML primitive kinds and shapes
2586
+ pmx-canvas diagram add Add an Excalidraw diagram node
2564
2587
 
2565
2588
  Edge commands:
2566
2589
  pmx-canvas edge add [options] Add an edge between nodes
@@ -2636,6 +2659,7 @@ Examples:
2636
2659
  pmx-canvas graph add --graph-type bar --data-file ./metrics.json --x-key label --y-key value
2637
2660
  pmx-canvas html primitive add --kind choice-grid --data-file ./options.json --title "Options"
2638
2661
  pmx-canvas html primitive schema --summary
2662
+ pmx-canvas diagram add --title "Architecture"
2639
2663
  pmx-canvas node add --help --type webpage
2640
2664
  pmx-canvas node schema --type json-render
2641
2665
  pmx-canvas node schema --type json-render --component Table --summary
package/src/cli/index.ts CHANGED
@@ -32,7 +32,7 @@ if (args.includes('--version') || args.includes('-v')) {
32
32
  const AGENT_COMMANDS = new Set([
33
33
  'node', 'edge', 'json-render', 'search', 'layout', 'status', 'arrange', 'focus',
34
34
  'fit', 'screenshot', 'pin', 'undo', 'redo', 'history', 'snapshot', 'diff', 'group', 'webview', 'open',
35
- 'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'graph', 'html', 'batch', 'validate', 'serve',
35
+ 'clear', 'code-graph', 'spatial', 'watch', 'web-artifact', 'external-app', 'diagram', 'graph', 'html', 'batch', 'validate', 'serve',
36
36
  ]);
37
37
 
38
38
  const firstArg = args[0] ?? '';
@@ -506,6 +506,7 @@ Agent CLI (works against running server):
506
506
  watch [--json] [--events ...] Watch low-token semantic canvas changes
507
507
  focus <node-id> Pan to node
508
508
  external-app add Add hosted external apps like Excalidraw
509
+ diagram add Add an Excalidraw diagram node
509
510
  pin <ids...> | --list | --clear Manage context pins
510
511
  undo / redo / history Time travel
511
512
  snapshot save|list|restore|diff|delete
@@ -550,6 +551,7 @@ Examples:
550
551
  pmx-canvas node schema --type json-render Show running-server schema info
551
552
  pmx-canvas web-artifact build --title "Dashboard" --app-file ./App.tsx
552
553
  pmx-canvas external-app add --kind excalidraw --title "Diagram"
554
+ pmx-canvas diagram add --title "Diagram"
553
555
  pmx-canvas validate spec --type graph --graph-type bar --data-file ./metrics.json --x-key label --y-key value
554
556
  pmx-canvas open Open the workbench in a browser
555
557
  pmx-canvas webview status Show WebView automation status
@@ -93,6 +93,10 @@ function isPresentationNavigationKey(key: string): boolean {
93
93
  return key === 'ArrowRight' || key === 'PageDown' || key === ' ' || key === 'ArrowLeft' || key === 'PageUp' || key === 'Home' || key === 'End';
94
94
  }
95
95
 
96
+ function isPresentationExitButtonTarget(target: EventTarget | null): boolean {
97
+ return target instanceof HTMLElement && Boolean(target.closest('.html-presentation-exit'));
98
+ }
99
+
96
100
  export function ExpandedNodeOverlay() {
97
101
  const nodeId = expandedNodeId.value;
98
102
  const node = nodeId ? nodes.value.get(nodeId) : undefined;
@@ -100,6 +104,7 @@ export function ExpandedNodeOverlay() {
100
104
  const [presenting, setPresenting] = useState(false);
101
105
  const [presentationExitToken, setPresentationExitToken] = useState('');
102
106
  const presentationOverlayRef = useRef<HTMLDivElement>(null);
107
+ const presentationExitButtonRef = useRef<HTMLButtonElement>(null);
103
108
 
104
109
  const handleClose = useCallback(() => {
105
110
  setPresenting(false);
@@ -131,6 +136,13 @@ export function ExpandedNodeOverlay() {
131
136
  setPresenting(false);
132
137
  return;
133
138
  }
139
+ if (event.key === 'Tab' && !isPresentationExitButtonTarget(event.target)) {
140
+ event.preventDefault();
141
+ event.stopPropagation();
142
+ presentationExitButtonRef.current?.focus();
143
+ return;
144
+ }
145
+ if ((event.key === ' ' || event.key === 'Enter') && isPresentationExitButtonTarget(event.target)) return;
134
146
  if (!isPresentationNavigationKey(event.key)) return;
135
147
  event.preventDefault();
136
148
  event.stopPropagation();
@@ -165,7 +177,9 @@ export function ExpandedNodeOverlay() {
165
177
  useLayoutEffect(() => {
166
178
  if (!presenting) return;
167
179
  const focusPresentationOverlay = () => {
168
- presentationOverlayRef.current?.focus();
180
+ const overlay = presentationOverlayRef.current;
181
+ if (!overlay || overlay.contains(document.activeElement)) return;
182
+ overlay.focus();
169
183
  };
170
184
  const focusTimers = [0, 50, 150].map((delay) => window.setTimeout(focusPresentationOverlay, delay));
171
185
  const handleMessage = (event: MessageEvent) => {
@@ -354,20 +368,16 @@ export function ExpandedNodeOverlay() {
354
368
  </div>
355
369
  {canPresent && presenting && (
356
370
  <div ref={presentationOverlayRef} class="html-presentation-overlay" role="dialog" aria-modal="true" aria-label={`Present ${title}`} tabIndex={-1} onKeyDownCapture={handlePresentationKeyDown}>
357
- <div class="html-presentation-toolbar">
358
- <div>
359
- <div class="html-presentation-kicker">HTML presentation</div>
360
- <div class="html-presentation-title">{title}</div>
361
- </div>
362
- <button
363
- type="button"
364
- class="html-presentation-exit"
365
- onClick={handleExitPresentation}
366
- title="Exit presentation (Esc)"
367
- >
368
- Exit presentation
369
- </button>
370
- </div>
371
+ <button
372
+ ref={presentationExitButtonRef}
373
+ type="button"
374
+ class="html-presentation-exit"
375
+ onClick={handleExitPresentation}
376
+ title="Exit presentation (Esc)"
377
+ aria-label="Exit presentation"
378
+ >
379
+ Exit presentation
380
+ </button>
371
381
  <div class="html-presentation-stage">
372
382
  <HtmlNode node={node} expanded presentation presentationExitToken={presentationExitToken} />
373
383
  </div>
@@ -255,7 +255,7 @@ export function HtmlNode({
255
255
  minHeight: presentation ? 0 : expanded ? '70vh' : '300px',
256
256
  border: 'none',
257
257
  background: 'var(--c-bg)',
258
- borderRadius: presentation ? '18px' : '6px',
258
+ borderRadius: presentation ? 0 : '6px',
259
259
  display: 'block',
260
260
  }}
261
261
  />
@@ -2472,75 +2472,58 @@ body,
2472
2472
  inset: 0;
2473
2473
  z-index: 10050;
2474
2474
  display: flex;
2475
- flex-direction: column;
2476
- gap: 14px;
2477
- padding: clamp(12px, 2vw, 28px);
2475
+ padding: 0;
2478
2476
  background:
2479
2477
  radial-gradient(circle at top left, var(--c-accent-25), transparent 36rem),
2480
2478
  rgba(3, 7, 18, 0.96);
2481
2479
  color: var(--c-text);
2482
2480
  }
2483
2481
 
2484
- .html-presentation-toolbar {
2485
- display: flex;
2486
- align-items: center;
2487
- justify-content: space-between;
2488
- gap: 16px;
2489
- flex-shrink: 0;
2490
- padding: 10px 12px;
2491
- border: 1px solid var(--c-line);
2492
- border-radius: 16px;
2493
- background: var(--c-panel-glass);
2494
- box-shadow: 0 18px 50px var(--c-shadow-heavy);
2495
- }
2496
-
2497
- .html-presentation-kicker {
2498
- color: var(--c-accent);
2499
- font-size: 10px;
2500
- font-weight: 800;
2501
- letter-spacing: 0.14em;
2502
- text-transform: uppercase;
2503
- }
2504
-
2505
- .html-presentation-title {
2506
- max-width: min(72vw, 900px);
2507
- overflow: hidden;
2508
- color: var(--c-text);
2509
- font-size: 14px;
2510
- font-weight: 700;
2511
- text-overflow: ellipsis;
2512
- white-space: nowrap;
2513
- }
2514
-
2515
2482
  .html-presentation-exit {
2516
- flex-shrink: 0;
2517
- padding: 8px 12px;
2483
+ position: fixed;
2484
+ top: 12px;
2485
+ right: 12px;
2486
+ z-index: 1;
2487
+ padding: 10px 14px;
2518
2488
  border: 1px solid var(--c-line);
2519
2489
  border-radius: 999px;
2520
- background: var(--c-panel-soft);
2490
+ background: var(--c-panel-glass);
2491
+ box-shadow: 0 18px 50px var(--c-shadow-heavy);
2521
2492
  color: var(--c-text-soft);
2522
2493
  cursor: pointer;
2523
2494
  font: 600 12px/1 var(--font);
2495
+ opacity: 0;
2496
+ pointer-events: none;
2497
+ transform: translateY(-6px);
2498
+ transition: opacity 0.15s ease, transform 0.15s ease, border-color 0.15s ease, color 0.15s ease;
2524
2499
  }
2525
2500
 
2526
- .html-presentation-exit:hover {
2501
+ .html-presentation-exit:hover,
2502
+ .html-presentation-exit:focus-visible {
2527
2503
  border-color: var(--c-accent);
2528
2504
  color: var(--c-text);
2529
2505
  }
2530
2506
 
2507
+ .html-presentation-exit:focus,
2508
+ .html-presentation-exit:focus-visible {
2509
+ opacity: 1;
2510
+ pointer-events: auto;
2511
+ transform: translateY(0);
2512
+ }
2513
+
2531
2514
  .html-presentation-stage {
2532
2515
  flex: 1;
2533
2516
  min-height: 0;
2534
2517
  display: flex;
2535
- border-radius: 22px;
2518
+ border-radius: 0;
2536
2519
  background: var(--c-bg);
2537
- box-shadow: 0 24px 90px rgba(0, 0, 0, 0.55);
2538
2520
  overflow: hidden;
2539
2521
  }
2540
2522
 
2541
2523
  .html-node-frame-presentation {
2542
2524
  flex: 1;
2543
2525
  min-height: 0;
2526
+ border-radius: 0 !important;
2544
2527
  }
2545
2528
 
2546
2529
  /* ── Context pin button on node title bar ────────────────────── */