pmx-canvas 0.1.23 → 0.1.25

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 (54) hide show
  1. package/.github/extensions/pmx-canvas/extension.mjs +591 -0
  2. package/CHANGELOG.md +123 -0
  3. package/Readme.md +36 -5
  4. package/dist/canvas/global.css +36 -3
  5. package/dist/canvas/index.js +54 -54
  6. package/dist/types/client/nodes/ExtAppFrame.d.ts +1 -0
  7. package/dist/types/client/nodes/McpAppNode.d.ts +1 -0
  8. package/dist/types/client/nodes/iframe-document-url.d.ts +8 -0
  9. package/dist/types/client/state/intent-bridge.d.ts +4 -0
  10. package/dist/types/client/types.d.ts +1 -0
  11. package/dist/types/json-render/catalog.d.ts +1 -1
  12. package/dist/types/mcp/canvas-access.d.ts +9 -0
  13. package/dist/types/server/ax-context.d.ts +3 -0
  14. package/dist/types/server/ax-state.d.ts +43 -0
  15. package/dist/types/server/canvas-db.d.ts +5 -0
  16. package/dist/types/server/canvas-operations.d.ts +4 -0
  17. package/dist/types/server/canvas-state.d.ts +20 -3
  18. package/dist/types/server/index.d.ts +6 -0
  19. package/dist/types/server/mutation-history.d.ts +1 -1
  20. package/docs/cli.md +13 -0
  21. package/docs/http-api.md +24 -0
  22. package/docs/mcp.md +20 -2
  23. package/docs/plans/plan-004-pmx-ax-primitives.md +463 -0
  24. package/docs/screenshot.png +0 -0
  25. package/docs/sdk.md +5 -0
  26. package/package.json +2 -1
  27. package/skills/pmx-canvas/SKILL.md +14 -0
  28. package/skills/pmx-canvas/references/codex-app-adapter.md +110 -0
  29. package/skills/pmx-canvas/references/github-copilot-app-adapter.md +125 -0
  30. package/src/cli/agent.ts +34 -0
  31. package/src/cli/index.ts +2 -1
  32. package/src/client/App.tsx +2 -0
  33. package/src/client/canvas/CanvasNode.tsx +7 -0
  34. package/src/client/canvas/CommandPalette.tsx +2 -1
  35. package/src/client/canvas/use-node-drag.ts +29 -7
  36. package/src/client/canvas/use-node-resize.ts +27 -7
  37. package/src/client/nodes/ExtAppFrame.tsx +51 -10
  38. package/src/client/nodes/HtmlNode.tsx +5 -2
  39. package/src/client/nodes/McpAppNode.tsx +13 -1
  40. package/src/client/nodes/iframe-document-url.ts +58 -0
  41. package/src/client/state/intent-bridge.ts +8 -0
  42. package/src/client/state/sse-bridge.ts +3 -3
  43. package/src/client/theme/global.css +36 -3
  44. package/src/client/types.ts +1 -0
  45. package/src/mcp/canvas-access.ts +38 -0
  46. package/src/mcp/server.ts +113 -4
  47. package/src/server/ax-context.ts +38 -0
  48. package/src/server/ax-state.ts +130 -0
  49. package/src/server/canvas-db.ts +36 -1
  50. package/src/server/canvas-operations.ts +96 -4
  51. package/src/server/canvas-state.ts +123 -4
  52. package/src/server/index.ts +29 -2
  53. package/src/server/mutation-history.ts +12 -0
  54. package/src/server/server.ts +312 -14
@@ -1107,10 +1107,13 @@ body,
1107
1107
  position: absolute;
1108
1108
  bottom: 0;
1109
1109
  right: 0;
1110
- width: 16px;
1111
- height: 16px;
1110
+ width: 32px;
1111
+ height: 32px;
1112
+ background: rgba(0, 0, 0, 0.001);
1112
1113
  cursor: nwse-resize;
1113
- z-index: 2;
1114
+ z-index: 30;
1115
+ pointer-events: auto;
1116
+ touch-action: none;
1114
1117
  }
1115
1118
 
1116
1119
  .canvas-node .node-resize-handle::after {
@@ -1130,6 +1133,36 @@ body,
1130
1133
  opacity: 1;
1131
1134
  }
1132
1135
 
1136
+ html.is-node-resizing,
1137
+ html.is-node-resizing * {
1138
+ cursor: nwse-resize !important;
1139
+ }
1140
+
1141
+ html.is-node-resizing .canvas-node {
1142
+ transition: box-shadow 0.15s ease !important;
1143
+ }
1144
+
1145
+ html.is-node-dragging .attention-field-layer {
1146
+ visibility: hidden;
1147
+ }
1148
+
1149
+ html.is-node-dragging,
1150
+ html.is-node-dragging * {
1151
+ cursor: grabbing !important;
1152
+ user-select: none !important;
1153
+ -webkit-user-select: none !important;
1154
+ }
1155
+
1156
+ html.is-node-dragging iframe,
1157
+ html.is-node-dragging .ext-app-preview-catcher {
1158
+ pointer-events: none !important;
1159
+ }
1160
+
1161
+ html.is-node-resizing iframe,
1162
+ html.is-node-resizing .ext-app-preview-catcher {
1163
+ pointer-events: none !important;
1164
+ }
1165
+
1133
1166
  /* Pinned node indicator */
1134
1167
  .canvas-node.pinned {
1135
1168
  border-style: dashed;
@@ -108,6 +108,7 @@ export function isExcalidrawNode(node: CanvasNodeState): boolean {
108
108
 
109
109
  export interface CanvasLayout {
110
110
  viewport: ViewportState;
111
+ theme?: 'dark' | 'light' | 'high-contrast';
111
112
  nodes: CanvasNodeState[];
112
113
  edges: CanvasEdge[];
113
114
  annotations?: CanvasAnnotation[];
@@ -9,6 +9,7 @@ import {
9
9
  type CanvasSnapshot,
10
10
  type PmxCanvas,
11
11
  } from '../server/index.js';
12
+ import type { PmxAxSource } from '../server/ax-state.js';
12
13
 
13
14
  type AddNodeInput = Parameters<PmxCanvas['addNode']>[0];
14
15
  type AddWebpageNodeInput = Parameters<PmxCanvas['addWebpageNode']>[0];
@@ -31,6 +32,9 @@ type ArrangeLayout = Parameters<PmxCanvas['arrange']>[0];
31
32
  type FocusNodeResult = ReturnType<PmxCanvas['focusNode']>;
32
33
  type FitViewOptions = Parameters<PmxCanvas['fitView']>[0];
33
34
  type FitViewResult = ReturnType<PmxCanvas['fitView']>;
35
+ type AxStateResult = ReturnType<PmxCanvas['getAxState']>;
36
+ type AxContextResult = ReturnType<PmxCanvas['getAxContext']>;
37
+ type SetAxFocusResult = ReturnType<PmxCanvas['setAxFocus']>;
34
38
  type SearchResult = ReturnType<PmxCanvas['search']>;
35
39
  type UndoRedoResult = Awaited<ReturnType<PmxCanvas['undo']>>;
36
40
  type HistoryResult = ReturnType<PmxCanvas['getHistory']>;
@@ -118,6 +122,9 @@ export interface CanvasAccess {
118
122
  arrange(layout?: ArrangeLayout): Promise<void>;
119
123
  focusNode(id: string, options?: { noPan?: boolean }): Promise<FocusNodeResult>;
120
124
  fitView(options?: FitViewOptions): Promise<FitViewResult>;
125
+ getAxState(): Promise<AxStateResult>;
126
+ getAxContext(): Promise<AxContextResult>;
127
+ setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult>;
121
128
  clear(): Promise<void>;
122
129
  search(query: string): Promise<SearchResult>;
123
130
  undo(): Promise<UndoRedoResult>;
@@ -247,6 +254,18 @@ class LocalCanvasAccess implements CanvasAccess {
247
254
  return this.canvas.fitView(options);
248
255
  }
249
256
 
257
+ async getAxState(): Promise<AxStateResult> {
258
+ return this.canvas.getAxState();
259
+ }
260
+
261
+ async getAxContext(): Promise<AxContextResult> {
262
+ return this.canvas.getAxContext();
263
+ }
264
+
265
+ async setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult> {
266
+ return this.canvas.setAxFocus(nodeIds, { source: options?.source ?? 'mcp' });
267
+ }
268
+
250
269
  async clear(): Promise<void> {
251
270
  this.canvas.clear();
252
271
  }
@@ -587,6 +606,25 @@ class RemoteCanvasAccess implements CanvasAccess {
587
606
  return await this.requestJson<HistoryResult>('GET', '/api/canvas/history');
588
607
  }
589
608
 
609
+ async getAxState(): Promise<AxStateResult> {
610
+ const response = await this.requestJson<{ state?: AxStateResult }>('GET', '/api/canvas/ax');
611
+ if (!response.state) throw new Error('Remote canvas did not return AX state.');
612
+ return response.state;
613
+ }
614
+
615
+ async getAxContext(): Promise<AxContextResult> {
616
+ return await this.requestJson<AxContextResult>('GET', '/api/canvas/ax/context');
617
+ }
618
+
619
+ async setAxFocus(nodeIds: string[], options?: { source?: PmxAxSource }): Promise<SetAxFocusResult> {
620
+ const response = await this.requestJson<{ focus?: SetAxFocusResult }>('POST', '/api/canvas/ax/focus', {
621
+ nodeIds,
622
+ source: options?.source ?? 'mcp',
623
+ });
624
+ if (!response.focus) throw new Error('Remote canvas did not return AX focus.');
625
+ return response.focus;
626
+ }
627
+
590
628
  async setContextPins(nodeIds: string[], mode: 'set' | 'add' | 'remove' = 'set'): Promise<SetContextPinsResult> {
591
629
  const existing = mode === 'set' ? [] : await this.getPinnedNodeIds();
592
630
  const requested = new Set(nodeIds);
package/src/mcp/server.ts CHANGED
@@ -100,13 +100,17 @@ function sleep(ms: number): Promise<void> {
100
100
  return new Promise((resolveSleep) => setTimeout(resolveSleep, ms));
101
101
  }
102
102
 
103
- function sendCanvasResourceNotifications(type: 'nodes' | 'pins' = 'nodes'): void {
103
+ function sendCanvasResourceNotifications(type: 'nodes' | 'pins' | 'ax' = 'nodes'): void {
104
104
  const server = resourceNotificationServer;
105
105
  if (!server) return;
106
106
  try {
107
107
  if (type === 'pins') {
108
108
  server.server.sendResourceUpdated({ uri: 'canvas://pinned-context' });
109
109
  }
110
+ if (type === 'pins' || type === 'ax') {
111
+ server.server.sendResourceUpdated({ uri: 'canvas://ax' });
112
+ server.server.sendResourceUpdated({ uri: 'canvas://ax-context' });
113
+ }
110
114
  server.server.sendResourceUpdated({ uri: 'canvas://layout' });
111
115
  server.server.sendResourceUpdated({ uri: 'canvas://summary' });
112
116
  server.server.sendResourceUpdated({ uri: 'canvas://spatial-context' });
@@ -121,7 +125,9 @@ function handleRemoteSseFrame(frame: string): void {
121
125
  const eventLine = frame.split('\n').find((line) => line.startsWith('event: '));
122
126
  const event = eventLine?.slice('event: '.length).trim() ?? '';
123
127
  if (!event || event === 'connected' || event === 'ping') return;
124
- sendCanvasResourceNotifications(event === 'context-pins-changed' ? 'pins' : 'nodes');
128
+ sendCanvasResourceNotifications(
129
+ event === 'context-pins-changed' ? 'pins' : event === 'ax-state-changed' ? 'ax' : 'nodes',
130
+ );
125
131
  }
126
132
 
127
133
  async function watchRemoteCanvasEvents(baseUrl: string): Promise<void> {
@@ -904,7 +910,7 @@ export async function startMcpServer(): Promise<void> {
904
910
  // ── canvas_update_node ─────────────────────────────────────────
905
911
  server.tool(
906
912
  'canvas_update_node',
907
- 'Update an existing node. You can change its content, title, position, size, or data.',
913
+ 'Update an existing node. You can change its content, title, position, size, dock placement, or data.',
908
914
  {
909
915
  id: z.string().describe('Node ID to update'),
910
916
  title: z.string().optional().describe('New title'),
@@ -926,12 +932,14 @@ export async function startMcpServer(): Promise<void> {
926
932
  resultSummary: z.string().optional().describe('Trace node result summary'),
927
933
  error: z.string().optional().describe('Trace node error message'),
928
934
  collapsed: z.boolean().optional().describe('Collapse or expand the node'),
935
+ dockPosition: z.enum(['left', 'right']).nullable().optional().describe('Dock the node to the left/right HUD column, or pass null to return it to the canvas'),
936
+ pinned: z.boolean().optional().describe('Pin or unpin the node to exclude it from auto-arrange'),
929
937
  arrangeLocked: z.boolean().optional().describe('Prevent auto-arrange from moving this node. Pinned nodes are also skipped.'),
930
938
  full: z.boolean().optional().describe('Return the full updated node payload. Default false returns compact metadata.'),
931
939
  verbose: z.boolean().optional().describe('Alias for full:true.'),
932
940
  },
933
941
  async (input) => {
934
- const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, arrangeLocked, toolName, category, status, duration, resultSummary, error } = input;
942
+ const { id, title, content, x, y, width, height, spec, graphType, data, xKey, yKey, chartHeight, collapsed, dockPosition, pinned, arrangeLocked, toolName, category, status, duration, resultSummary, error } = input;
935
943
  const c = await ensureCanvas();
936
944
  const node = await c.getNode(id);
937
945
  if (!node) {
@@ -950,6 +958,12 @@ export async function startMcpServer(): Promise<void> {
950
958
  if (collapsed !== undefined) {
951
959
  patch.collapsed = collapsed;
952
960
  }
961
+ if (dockPosition !== undefined) {
962
+ patch.dockPosition = dockPosition;
963
+ }
964
+ if (pinned !== undefined) {
965
+ patch.pinned = pinned;
966
+ }
953
967
  if (title !== undefined) patch.title = title;
954
968
  if (content !== undefined) patch.content = content;
955
969
  if (spec !== undefined) patch.spec = spec;
@@ -1120,6 +1134,55 @@ export async function startMcpServer(): Promise<void> {
1120
1134
  },
1121
1135
  );
1122
1136
 
1137
+ // ── AX context and focus ───────────────────────────────────────
1138
+ server.tool(
1139
+ 'canvas_get_ax',
1140
+ 'Read the host-agnostic PMX AX state and agent-ready AX context. Use this when you need pinned context plus the current focus field.',
1141
+ {
1142
+ includeContext: z.boolean().optional().describe('Include serialized agent-ready AX context. Default true.'),
1143
+ },
1144
+ async ({ includeContext }) => {
1145
+ const c = await ensureCanvas();
1146
+ const state = await c.getAxState();
1147
+ const context = includeContext === false ? undefined : await c.getAxContext();
1148
+ return {
1149
+ content: [
1150
+ {
1151
+ type: 'text',
1152
+ text: JSON.stringify({
1153
+ ok: true,
1154
+ state,
1155
+ ...(context ? { context } : {}),
1156
+ }),
1157
+ },
1158
+ ],
1159
+ };
1160
+ },
1161
+ );
1162
+
1163
+ server.tool(
1164
+ 'canvas_set_ax_focus',
1165
+ 'Set the PMX AX focus field without requiring viewport movement. Focus is persisted and available through canvas://ax-context.',
1166
+ {
1167
+ nodeIds: z.array(z.string()).describe('Node IDs to place in the AX focus field. Missing nodes are ignored.'),
1168
+ source: z.enum(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system'])
1169
+ .optional()
1170
+ .describe('Optional host/source label for adapter-originated focus. Defaults to mcp. Use codex from the Codex app adapter.'),
1171
+ },
1172
+ async ({ nodeIds, source }) => {
1173
+ const c = await ensureCanvas();
1174
+ const focus = await c.setAxFocus(nodeIds, { source: source ?? 'mcp' });
1175
+ return {
1176
+ content: [
1177
+ {
1178
+ type: 'text',
1179
+ text: JSON.stringify({ ok: true, focus }),
1180
+ },
1181
+ ],
1182
+ };
1183
+ },
1184
+ );
1185
+
1123
1186
  server.tool(
1124
1187
  'canvas_fit_view',
1125
1188
  'Fit the canvas viewport to all nodes or a selected subset. Useful before screenshots and whole-board review.',
@@ -1497,6 +1560,52 @@ export async function startMcpServer(): Promise<void> {
1497
1560
  },
1498
1561
  );
1499
1562
 
1563
+ server.resource(
1564
+ 'ax-state',
1565
+ 'canvas://ax',
1566
+ {
1567
+ description:
1568
+ 'Host-agnostic PMX AX state. This includes canvas-bound collaboration primitives such as the current AX focus.',
1569
+ mimeType: 'application/json',
1570
+ },
1571
+ async () => {
1572
+ const c = await ensureCanvas();
1573
+ const state = await c.getAxState();
1574
+ return {
1575
+ contents: [
1576
+ {
1577
+ uri: 'canvas://ax',
1578
+ mimeType: 'application/json',
1579
+ text: JSON.stringify({ state }, null, 2),
1580
+ },
1581
+ ],
1582
+ };
1583
+ },
1584
+ );
1585
+
1586
+ server.resource(
1587
+ 'ax-context',
1588
+ 'canvas://ax-context',
1589
+ {
1590
+ description:
1591
+ 'Agent-ready PMX AX context combining pinned context, focus, and surface metadata.',
1592
+ mimeType: 'application/json',
1593
+ },
1594
+ async () => {
1595
+ const c = await ensureCanvas();
1596
+ const context = await c.getAxContext();
1597
+ return {
1598
+ contents: [
1599
+ {
1600
+ uri: 'canvas://ax-context',
1601
+ mimeType: 'application/json',
1602
+ text: JSON.stringify(context, null, 2),
1603
+ },
1604
+ ],
1605
+ };
1606
+ },
1607
+ );
1608
+
1500
1609
  server.resource(
1501
1610
  'canvas-layout',
1502
1611
  'canvas://layout',
@@ -0,0 +1,38 @@
1
+ import { buildAgentContextPreamble, serializeNodeForAgentContext } from './agent-context.js';
2
+ import { buildAxContext, type PmxAxContext, type PmxAxPinnedContext } from './ax-state.js';
3
+ import { canvasState, type CanvasNodeState } from './canvas-state.js';
4
+
5
+ function serializeNodes(nodes: CanvasNodeState[]) {
6
+ return nodes.map((node) => serializeNodeForAgentContext(node, {
7
+ defaultTextLength: 700,
8
+ webpageTextLength: 1600,
9
+ includePosition: true,
10
+ }));
11
+ }
12
+
13
+ export function buildCanvasAxPinnedContext(): PmxAxPinnedContext {
14
+ const nodeIds = Array.from(canvasState.contextPinnedNodeIds);
15
+ const nodes = nodeIds
16
+ .map((id) => canvasState.getNode(id))
17
+ .filter((node): node is CanvasNodeState => node !== undefined);
18
+ return {
19
+ preamble: nodes.length > 0 ? buildAgentContextPreamble(nodes) : '',
20
+ nodeIds,
21
+ count: nodeIds.length,
22
+ nodes: serializeNodes(nodes),
23
+ };
24
+ }
25
+
26
+ export function buildCanvasAxContext(): PmxAxContext {
27
+ const layout = canvasState.getLayout();
28
+ const focus = canvasState.getAxFocus();
29
+ const focusNodes = focus.nodeIds
30
+ .map((id) => canvasState.getNode(id))
31
+ .filter((node): node is CanvasNodeState => node !== undefined);
32
+ return buildAxContext({
33
+ layout,
34
+ pinned: buildCanvasAxPinnedContext(),
35
+ focus,
36
+ focusNodes: serializeNodes(focusNodes),
37
+ });
38
+ }
@@ -0,0 +1,130 @@
1
+ import type { CanvasLayout, CanvasNodeState } from './canvas-state.js';
2
+ import type { AgentContextNode } from './agent-context.js';
3
+
4
+ export type PmxAxSource = 'agent' | 'api' | 'browser' | 'cli' | 'codex' | 'copilot' | 'mcp' | 'sdk' | 'system';
5
+
6
+ export interface PmxAxFocusState {
7
+ nodeIds: string[];
8
+ primaryNodeId: string | null;
9
+ updatedAt: string | null;
10
+ source: PmxAxSource | null;
11
+ }
12
+
13
+ export interface PmxAxState {
14
+ version: 1;
15
+ focus: PmxAxFocusState;
16
+ }
17
+
18
+ export interface PmxAxPinnedContext {
19
+ preamble: string;
20
+ nodeIds: string[];
21
+ count: number;
22
+ nodes: AgentContextNode[];
23
+ }
24
+
25
+ export interface PmxAxFocusContext extends PmxAxFocusState {
26
+ nodes: AgentContextNode[];
27
+ }
28
+
29
+ export interface PmxAxContext {
30
+ version: 1;
31
+ generatedAt: string;
32
+ surface: {
33
+ nodeCount: number;
34
+ edgeCount: number;
35
+ };
36
+ pinned: PmxAxPinnedContext;
37
+ focus: PmxAxFocusContext;
38
+ }
39
+
40
+ const AX_SOURCES = new Set<PmxAxSource>(['agent', 'api', 'browser', 'cli', 'codex', 'copilot', 'mcp', 'sdk', 'system']);
41
+
42
+ function isRecord(value: unknown): value is Record<string, unknown> {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
44
+ }
45
+
46
+ function normalizeSource(value: unknown): PmxAxSource | null {
47
+ return typeof value === 'string' && AX_SOURCES.has(value as PmxAxSource)
48
+ ? value as PmxAxSource
49
+ : null;
50
+ }
51
+
52
+ function normalizeTimestamp(value: unknown): string | null {
53
+ if (typeof value !== 'string') return null;
54
+ const parsed = Date.parse(value);
55
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
56
+ }
57
+
58
+ function normalizeNodeIds(value: unknown, validNodeIds?: Set<string>): string[] {
59
+ if (!Array.isArray(value)) return [];
60
+ const ids: string[] = [];
61
+ for (const item of value) {
62
+ if (typeof item !== 'string') continue;
63
+ if (validNodeIds && !validNodeIds.has(item)) continue;
64
+ if (!ids.includes(item)) ids.push(item);
65
+ }
66
+ return ids;
67
+ }
68
+
69
+ export function createEmptyAxFocusState(): PmxAxFocusState {
70
+ return {
71
+ nodeIds: [],
72
+ primaryNodeId: null,
73
+ updatedAt: null,
74
+ source: null,
75
+ };
76
+ }
77
+
78
+ export function createEmptyAxState(): PmxAxState {
79
+ return {
80
+ version: 1,
81
+ focus: createEmptyAxFocusState(),
82
+ };
83
+ }
84
+
85
+ export function normalizeAxFocusState(input: unknown, validNodeIds?: Set<string>): PmxAxFocusState {
86
+ if (!isRecord(input)) return createEmptyAxFocusState();
87
+ const nodeIds = normalizeNodeIds(input.nodeIds, validNodeIds);
88
+ const primaryNodeId = typeof input.primaryNodeId === 'string' && nodeIds.includes(input.primaryNodeId)
89
+ ? input.primaryNodeId
90
+ : nodeIds[0] ?? null;
91
+ return {
92
+ nodeIds,
93
+ primaryNodeId,
94
+ updatedAt: normalizeTimestamp(input.updatedAt),
95
+ source: normalizeSource(input.source),
96
+ };
97
+ }
98
+
99
+ export function normalizeAxState(input: unknown, validNodeIds?: Set<string>): PmxAxState {
100
+ if (!isRecord(input)) return createEmptyAxState();
101
+ return {
102
+ version: 1,
103
+ focus: normalizeAxFocusState(input.focus, validNodeIds),
104
+ };
105
+ }
106
+
107
+ export function buildAxContext(input: {
108
+ layout: CanvasLayout;
109
+ pinned: PmxAxPinnedContext;
110
+ focus: PmxAxFocusState;
111
+ focusNodes: AgentContextNode[];
112
+ }): PmxAxContext {
113
+ return {
114
+ version: 1,
115
+ generatedAt: new Date().toISOString(),
116
+ surface: {
117
+ nodeCount: input.layout.nodes.length,
118
+ edgeCount: input.layout.edges.length,
119
+ },
120
+ pinned: input.pinned,
121
+ focus: {
122
+ ...input.focus,
123
+ nodes: input.focusNodes,
124
+ },
125
+ };
126
+ }
127
+
128
+ export function nodeSetFromLayout(nodes: CanvasNodeState[]): Set<string> {
129
+ return new Set(nodes.map((node) => node.id));
130
+ }
@@ -17,11 +17,18 @@ import type {
17
17
  CanvasSnapshotListOptions,
18
18
  ViewportState,
19
19
  } from './canvas-state.js';
20
+ import { createEmptyAxState, normalizeAxState, type PmxAxState } from './ax-state.js';
20
21
 
21
22
  // ── Schema ──────────────────────────────────────────────────────
22
23
 
23
24
  const SCHEMA_VERSION = 1;
24
25
 
26
+ export type CanvasTheme = 'dark' | 'light' | 'high-contrast';
27
+
28
+ export function normalizeCanvasTheme(value: unknown, fallback: CanvasTheme = 'dark'): CanvasTheme {
29
+ return value === 'dark' || value === 'light' || value === 'high-contrast' ? value : fallback;
30
+ }
31
+
25
32
  const SCHEMA_SQL = `
26
33
  CREATE TABLE IF NOT EXISTS meta (
27
34
  key TEXT PRIMARY KEY,
@@ -68,6 +75,11 @@ const SCHEMA_SQL = `
68
75
  node_id TEXT PRIMARY KEY
69
76
  );
70
77
 
78
+ CREATE TABLE IF NOT EXISTS ax_state (
79
+ key TEXT PRIMARY KEY,
80
+ value TEXT NOT NULL
81
+ );
82
+
71
83
  CREATE TABLE IF NOT EXISTS snapshots (
72
84
  id TEXT PRIMARY KEY,
73
85
  name TEXT NOT NULL,
@@ -149,15 +161,26 @@ function normalizeSnapshotTimestamp(value: string | undefined): string | undefin
149
161
  return Number.isFinite(parsed) ? new Date(parsed).toISOString() : undefined;
150
162
  }
151
163
 
164
+ function parsePersistedAxState(raw: string | null | undefined): PmxAxState {
165
+ if (!raw) return createEmptyAxState();
166
+ try {
167
+ return normalizeAxState(JSON.parse(raw));
168
+ } catch {
169
+ return createEmptyAxState();
170
+ }
171
+ }
172
+
152
173
  // ── Persisted State Interface ───────────────────────────────────
153
174
 
154
175
  export interface PersistedCanvasState {
155
176
  version: number;
177
+ theme?: CanvasTheme;
156
178
  viewport: ViewportState;
157
179
  nodes: CanvasNodeState[];
158
180
  edges: CanvasEdge[];
159
181
  annotations?: CanvasAnnotation[];
160
182
  contextPins: string[];
183
+ ax?: PmxAxState;
161
184
  }
162
185
 
163
186
  // ── Database Management ─────────────────────────────────────────
@@ -199,8 +222,10 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
199
222
  db.run('DELETE FROM edges');
200
223
  db.run('DELETE FROM annotations');
201
224
  db.run('DELETE FROM context_pins');
225
+ db.run('DELETE FROM ax_state');
202
226
 
203
- // Save viewport
227
+ // Save viewport and UI preferences
228
+ db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['theme', normalizeCanvasTheme(state.theme)]);
204
229
  db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_x', String(state.viewport.x)]);
205
230
  db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_y', String(state.viewport.y)]);
206
231
  db.run('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)', ['viewport_scale', String(state.viewport.scale)]);
@@ -270,6 +295,8 @@ export function saveStateToDB(db: Database, state: PersistedCanvasState): void {
270
295
  for (const pinId of state.contextPins) {
271
296
  insertPin.run(pinId);
272
297
  }
298
+
299
+ db.run('INSERT INTO ax_state (key, value) VALUES (?, ?)', ['state', JSON.stringify(state.ax ?? createEmptyAxState())]);
273
300
  });
274
301
 
275
302
  transaction();
@@ -298,6 +325,8 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
298
325
  y: getMetaValue('viewport_y'),
299
326
  scale: getMetaValue('viewport_scale') || 1,
300
327
  };
328
+ const themeValue = db.query<{ value: string }, [string]>('SELECT value FROM meta WHERE key = ?').get('theme')?.value;
329
+ const theme = themeValue ? normalizeCanvasTheme(themeValue) : undefined;
301
330
 
302
331
  // Load nodes
303
332
  interface NodeRow {
@@ -377,13 +406,17 @@ export function loadStateFromDB(db: Database): PersistedCanvasState | null {
377
406
  const pinRows = db.query<PinRow, []>('SELECT node_id FROM context_pins').all();
378
407
  const contextPins = pinRows.map((row) => row.node_id);
379
408
 
409
+ const axRow = db.query<{ value: string }, [string]>('SELECT value FROM ax_state WHERE key = ?').get('state');
410
+
380
411
  return {
381
412
  version: 1,
413
+ theme,
382
414
  viewport,
383
415
  nodes,
384
416
  edges,
385
417
  annotations,
386
418
  contextPins,
419
+ ax: parsePersistedAxState(axRow?.value),
387
420
  };
388
421
  }
389
422
 
@@ -405,6 +438,7 @@ export function saveSnapshotToDB(
405
438
  db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'viewport_x', String(state.viewport.x)]);
406
439
  db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'viewport_y', String(state.viewport.y)]);
407
440
  db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'viewport_scale', String(state.viewport.scale)]);
441
+ db.run('INSERT INTO snapshot_meta (snapshot_id, key, value) VALUES (?, ?, ?)', [snapshot.id, 'ax_state', JSON.stringify(state.ax ?? createEmptyAxState())]);
408
442
 
409
443
  // Insert snapshot nodes
410
444
  const insertNode = db.prepare(
@@ -616,6 +650,7 @@ export function loadSnapshotFromDB(
616
650
  edges,
617
651
  annotations,
618
652
  contextPins,
653
+ ax: parsePersistedAxState(metaMap.get('ax_state')),
619
654
  },
620
655
  };
621
656
  }