@tuongaz/seeflow 0.1.63 → 0.1.65

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.
@@ -4,23 +4,22 @@
4
4
  "nodes": [
5
5
  {
6
6
  "id": "node-cQOUPXanaX",
7
- "type": "shapeNode",
7
+ "type": "user",
8
8
  "data": {
9
- "shape": "user",
10
9
  "name": "Customer",
11
10
  "description": "Web / Mobile / Partner"
12
11
  }
13
12
  },
14
13
  {
15
14
  "id": "node-CbwYqb7NfB",
16
- "type": "playNode",
15
+ "type": "rectangle",
17
16
  "data": {
18
17
  "name": "API Gateway",
18
+ "description": "Auth, rate-limiting, routing.",
19
+ "detail": "file://detail.md",
19
20
  "stateSource": {
20
21
  "kind": "request"
21
22
  },
22
- "description": "Auth, rate-limiting, routing.",
23
- "detail": "file://detail.md",
24
23
  "playAction": {
25
24
  "kind": "script",
26
25
  "interpreter": "bun",
@@ -31,38 +30,38 @@
31
30
  },
32
31
  {
33
32
  "id": "node-3zFtHg6ENc",
34
- "type": "stateNode",
33
+ "type": "rectangle",
35
34
  "data": {
36
35
  "name": "Auth Service",
36
+ "description": "JWT issuance + OAuth2 / OIDC.",
37
+ "detail": "file://detail.md",
37
38
  "stateSource": {
38
39
  "kind": "request"
39
- },
40
- "description": "JWT issuance + OAuth2 / OIDC.",
41
- "detail": "file://detail.md"
40
+ }
42
41
  }
43
42
  },
44
43
  {
45
44
  "id": "node-kwBY8YPmYM",
46
- "type": "stateNode",
45
+ "type": "rectangle",
47
46
  "data": {
48
47
  "name": "Product Catalog",
48
+ "description": "SKUs, variants, pricing, full-text search.",
49
+ "detail": "file://detail.md",
49
50
  "stateSource": {
50
51
  "kind": "request"
51
- },
52
- "description": "SKUs, variants, pricing, full-text search.",
53
- "detail": "file://detail.md"
52
+ }
54
53
  }
55
54
  },
56
55
  {
57
56
  "id": "node-5F424NWbEu",
58
- "type": "playNode",
57
+ "type": "rectangle",
59
58
  "data": {
60
59
  "name": "Cart Service",
60
+ "description": "Add/remove items, apply coupons, checkout.",
61
+ "detail": "file://detail.md",
61
62
  "stateSource": {
62
63
  "kind": "request"
63
64
  },
64
- "description": "Add/remove items, apply coupons, checkout.",
65
- "detail": "file://detail.md",
66
65
  "playAction": {
67
66
  "kind": "script",
68
67
  "interpreter": "bun",
@@ -73,14 +72,14 @@
73
72
  },
74
73
  {
75
74
  "id": "node-yKrg9DV5fJ",
76
- "type": "playNode",
75
+ "type": "rectangle",
77
76
  "data": {
78
77
  "name": "Order Service",
78
+ "description": "pending → confirmed → shipped → delivered.",
79
+ "detail": "file://detail.md",
79
80
  "stateSource": {
80
81
  "kind": "event"
81
82
  },
82
- "description": "pending → confirmed → shipped → delivered.",
83
- "detail": "file://detail.md",
84
83
  "playAction": {
85
84
  "kind": "script",
86
85
  "interpreter": "bun",
@@ -91,40 +90,39 @@
91
90
  },
92
91
  {
93
92
  "id": "node-mPqan8rFYN",
94
- "type": "stateNode",
93
+ "type": "rectangle",
95
94
  "data": {
96
95
  "name": "Payment Service",
96
+ "description": "Stripe + PayPal gateway integration.",
97
+ "detail": "file://detail.md",
97
98
  "stateSource": {
98
99
  "kind": "event"
99
- },
100
- "description": "Stripe + PayPal gateway integration.",
101
- "detail": "file://detail.md"
100
+ }
102
101
  }
103
102
  },
104
103
  {
105
104
  "id": "node-fkptXw7uvs",
106
- "type": "stateNode",
105
+ "type": "rectangle",
107
106
  "data": {
108
107
  "name": "Notification Service",
108
+ "description": "Email + SMS + push via AWS SES / SNS.",
109
+ "detail": "file://detail.md",
109
110
  "stateSource": {
110
111
  "kind": "event"
111
- },
112
- "description": "Email + SMS + push via AWS SES / SNS.",
113
- "detail": "file://detail.md"
112
+ }
114
113
  }
115
114
  },
116
115
  {
117
116
  "id": "node-5SDiw3Wz6s",
118
- "type": "shapeNode",
117
+ "type": "database",
119
118
  "data": {
120
- "shape": "database",
121
119
  "name": "PostgreSQL",
122
120
  "description": "Orders, users, products — primary OLTP store"
123
121
  }
124
122
  },
125
123
  {
126
124
  "id": "node-XwygzfKPZ5",
127
- "type": "htmlNode",
125
+ "type": "html",
128
126
  "data": {
129
127
  "name": "Platform Health",
130
128
  "html": "file://view.html"
@@ -4,14 +4,14 @@
4
4
  "nodes": [
5
5
  {
6
6
  "id": "node-XKIyds0TDg",
7
- "type": "playNode",
7
+ "type": "rectangle",
8
8
  "data": {
9
9
  "name": "POST /orders",
10
+ "description": "Creates order, kicks off the pipeline.",
11
+ "detail": "file://detail.md",
10
12
  "stateSource": {
11
13
  "kind": "request"
12
14
  },
13
- "description": "Creates order, kicks off the pipeline.",
14
- "detail": "file://detail.md",
15
15
  "playAction": {
16
16
  "kind": "script",
17
17
  "interpreter": "bun",
@@ -22,39 +22,39 @@
22
22
  },
23
23
  {
24
24
  "id": "node-GXTKUcE3ye",
25
- "type": "stateNode",
25
+ "type": "rectangle",
26
26
  "data": {
27
27
  "name": "Inventory Service",
28
- "stateSource": {
29
- "kind": "event"
30
- },
31
28
  "description": "Reserves stock.",
32
29
  "detail": "file://detail.md",
33
- "icon": "a-arrow-down-icon"
30
+ "icon": "a-arrow-down-icon",
31
+ "stateSource": {
32
+ "kind": "event"
33
+ }
34
34
  }
35
35
  },
36
36
  {
37
37
  "id": "node-YOYiHJpY0i",
38
- "type": "stateNode",
38
+ "type": "rectangle",
39
39
  "data": {
40
40
  "name": "Payment Service",
41
+ "description": "Charges card.",
42
+ "detail": "file://detail.md",
41
43
  "stateSource": {
42
44
  "kind": "event"
43
- },
44
- "description": "Charges card.",
45
- "detail": "file://detail.md"
45
+ }
46
46
  }
47
47
  },
48
48
  {
49
49
  "id": "node-zUIH7WFnhK",
50
- "type": "stateNode",
50
+ "type": "rectangle",
51
51
  "data": {
52
52
  "name": "Fulfillment Service",
53
+ "description": "Enqueues shipment.",
54
+ "detail": "file://detail.md",
53
55
  "stateSource": {
54
56
  "kind": "event"
55
- },
56
- "description": "Enqueues shipment.",
57
- "detail": "file://detail.md"
57
+ }
58
58
  }
59
59
  }
60
60
  ],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tuongaz/seeflow",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
4
4
  "description": "Local studio that hosts file-defined demos as React Flow canvases wired to a running app via REST + SSE + Zod schema.",
5
5
  "keywords": [
6
6
  "seeflow",
package/src/api.ts CHANGED
@@ -345,14 +345,7 @@ export function createApi(options: ApiOptions): Hono {
345
345
  }
346
346
  const flow = flowParse.data;
347
347
  const result = await computeLayout(
348
- flow.nodes.map((n) => ({
349
- id: n.id,
350
- type: n.type,
351
- // Only `shape` matters for layout (floating-annotation detection +
352
- // shape-specific sizing). Other Flow data fields are irrelevant.
353
- data:
354
- n.type === 'shapeNode' ? { shape: (n.data as { shape?: string }).shape } : undefined,
355
- })),
348
+ flow.nodes.map((n) => ({ id: n.id, type: n.type })),
356
349
  flow.connectors.map((c) => ({ id: c.id, source: c.source, target: c.target })),
357
350
  options,
358
351
  );
@@ -375,12 +368,18 @@ export function createApi(options: ApiOptions): Hono {
375
368
  .map((n) => ({
376
369
  id: n.id,
377
370
  type: n.type as
378
- | 'playNode'
379
- | 'stateNode'
380
- | 'shapeNode'
381
- | 'imageNode'
382
- | 'iconNode'
383
- | 'htmlNode',
371
+ | 'rectangle'
372
+ | 'ellipse'
373
+ | 'sticky'
374
+ | 'text'
375
+ | 'database'
376
+ | 'server'
377
+ | 'user'
378
+ | 'queue'
379
+ | 'cloud'
380
+ | 'image'
381
+ | 'html'
382
+ | 'icon',
384
383
  data:
385
384
  typeof n.width === 'number' && typeof n.height === 'number'
386
385
  ? { width: n.width, height: n.height }
@@ -582,7 +581,7 @@ export function createApi(options: ApiOptions): Hono {
582
581
  });
583
582
 
584
583
  // POST /api/projects/:id/files/open — shell out to `$EDITOR <abs>` so the
585
- // user can edit a project-scoped file (htmlNode block, image asset) in
584
+ // user can edit a project-scoped file (type:'html' block, image asset) in
586
585
  // their IDE. The endpoint always returns the resolved absolute path in
587
586
  // the response body so the frontend can copy-to-clipboard when $EDITOR
588
587
  // isn't set or the spawn fails. Path safety mirrors the GET route.
@@ -815,13 +814,10 @@ export function createApi(options: ApiOptions): Hono {
815
814
 
816
815
  const node = merged.flow.nodes.find((n) => n.id === nodeId);
817
816
  if (!node) return c.json({ error: `Unknown nodeId: ${nodeId}` }, 404);
818
- if (
819
- node.type === 'shapeNode' ||
820
- node.type === 'imageNode' ||
821
- node.type === 'iconNode' ||
822
- node.type === 'htmlNode' ||
823
- !node.data.playAction
824
- ) {
817
+ // playAction is optional on every node type post-flat-types. The runtime
818
+ // gate is purely "is the field set?" — visual kind doesn't constrain
819
+ // playability.
820
+ if (!node.data.playAction) {
825
821
  return c.json({ error: `Node ${nodeId} has no playAction` }, 400);
826
822
  }
827
823
 
@@ -1007,7 +1003,7 @@ export function createApi(options: ApiOptions): Hono {
1007
1003
  });
1008
1004
 
1009
1005
  // PATCH a single node — partial update of position, label, detail, visual
1010
- // fields, or shapeNode-only fields. Every UI-driven node edit (other than
1006
+ // fields, or geometric-only fields. Every UI-driven node edit (other than
1011
1007
  // the high-frequency drag fast-path above) flows through here. The mutation
1012
1008
  // is performed against the raw parsed JSON (so unknown v2 fields the schema
1013
1009
  // doesn't yet recognize survive round-trips) and the WHOLE resulting demo
package/src/cli-e2e.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // End-to-end validator used by `seeflow e2e <flowId>`. Opens an SSE channel to
2
- // the studio, triggers every playNode's playAction (sequentially), then drains
2
+ // the studio, triggers every node carrying a playAction (sequentially), then drains
3
3
  // the channel for node:done/error + node:status reports. Mirrors the algorithm
4
4
  // in skills/seeflow/scripts/validate-end-to-end.ts — kept here so the CLI does
5
5
  // not depend on the skill folder.
@@ -167,15 +167,14 @@ function openSseChannel(body: ReadableStream<Uint8Array>): SseChannel {
167
167
  }
168
168
 
169
169
  function hasPlayAction(node: NodeShape): boolean {
170
- return (
171
- (node.type === 'playNode' || node.type === 'stateNode') && node.data?.playAction !== undefined
172
- );
170
+ // Flat-types refactor: capabilities are top-level data fields on every
171
+ // type, not gated by the type tag. The e2e runner iterates every node
172
+ // carrying a playAction regardless of variant.
173
+ return node.data?.playAction !== undefined;
173
174
  }
174
175
 
175
176
  function hasStatusAction(node: NodeShape): boolean {
176
- return (
177
- (node.type === 'playNode' || node.type === 'stateNode') && node.data?.statusAction !== undefined
178
- );
177
+ return node.data?.statusAction !== undefined;
179
178
  }
180
179
 
181
180
  export async function validateEndToEnd(options: ValidateOptions): Promise<ValidationReport> {
@@ -281,7 +281,7 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
281
281
  },
282
282
  requiresStudio: false,
283
283
  examples: [
284
- 'seeflow flow:add-bulk abc12345 --json \'{"nodes":[{"id":"a","type":"shapeNode","data":{"shape":"rectangle"}}],"connectors":[]}\'',
284
+ 'seeflow flow:add-bulk abc12345 --json \'{"nodes":[{"id":"a","type":"rectangle","data":{}}],"connectors":[]}\'',
285
285
  'seeflow flow:add-bulk abc12345 --file batch.json',
286
286
  ],
287
287
  },
@@ -344,7 +344,7 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
344
344
  flags: BODY_FLAGS,
345
345
  body: {
346
346
  example: {
347
- type: 'stateNode',
347
+ type: 'rectangle',
348
348
  data: { name: 'hello', stateSource: { kind: 'request' } },
349
349
  },
350
350
  },
@@ -360,9 +360,7 @@ export const COMMAND_MANIFEST: CommandManifestEntry[] = [
360
360
  ],
361
361
  },
362
362
  requiresStudio: false,
363
- examples: [
364
- 'seeflow nodes:add abc12345 --json \'{"type":"shapeNode","data":{"shape":"rectangle"}}\'',
365
- ],
363
+ examples: ['seeflow nodes:add abc12345 --json \'{"type":"rectangle","data":{}}\''],
366
364
  },
367
365
  {
368
366
  name: 'nodes:get',
package/src/cli.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  clearPid,
21
21
  defaultPidPath,
22
22
  isPidAlive,
23
+ portInUse,
23
24
  readConfig,
24
25
  readPid,
25
26
  studioUrl,
@@ -266,6 +267,20 @@ async function runHelp() {
266
267
  console.log(renderCommandList());
267
268
  }
268
269
 
270
+ // Pre-flight: refuse to start if the studio port already has a TCP listener.
271
+ // We deliberately do NOT probe Vite's port (5173) here — `bun run dev` spawns
272
+ // Vite alongside the studio, so Vite legitimately owns 5173 in dev mode and a
273
+ // probe can't distinguish "our Vite" from a stranger. If Vite's port is taken
274
+ // by something else, Vite itself surfaces the conflict.
275
+ async function assertPortFree(studioPort: number, host: string): Promise<void> {
276
+ if (!(await portInUse(host, studioPort))) return;
277
+ console.error(
278
+ `Cannot start SeeFlow: port ${studioPort} already in use.\n` +
279
+ `Stop the running server on ${studioPort} first, then retry.`,
280
+ );
281
+ process.exit(1);
282
+ }
283
+
269
284
  async function runStart() {
270
285
  mkdirSync(seeflowHome(), { recursive: true });
271
286
  const config = readConfig();
@@ -294,6 +309,12 @@ async function runStart() {
294
309
  return;
295
310
  }
296
311
 
312
+ // Defense-in-depth: parent already checked in spawnDaemon, but a race
313
+ // between parent-check and child-bind can still let another process grab
314
+ // the port. Re-check here so the child fails fast with a clear error
315
+ // instead of EADDRINUSE at bind time.
316
+ await assertPortFree(port, config.host);
317
+
297
318
  // persist the chosen address so other subcommands can find us
298
319
  writeConfig({ port, host: config.host });
299
320
 
@@ -368,6 +389,11 @@ async function spawnDaemon(port: number, host: string) {
368
389
  return;
369
390
  }
370
391
 
392
+ // Studio port must be free before we fork a detached child — otherwise the
393
+ // user waits HEALTH_TIMEOUT_MS for a doomed health probe before seeing a
394
+ // generic timeout error.
395
+ await assertPortFree(port, host);
396
+
371
397
  const proc = spawnDetachedStudio(port);
372
398
  writePid(proc.pid);
373
399
  writeConfig({ port, host });
package/src/diagram.ts CHANGED
@@ -240,9 +240,10 @@ const normalizeConnectors = (
240
240
  // Single-flow-node graphs short-circuit so callers can pin standalone
241
241
  // nodes via `layout.positions`.
242
242
  const isFloatingAnnotation = (n: Record<string, unknown>): boolean => {
243
- if (n.type !== 'shapeNode') return false;
244
- const shape = (n.data as { shape?: string } | undefined)?.shape;
245
- return shape === 'sticky' || shape === 'text';
243
+ // Flat-types refactor: visual kind IS the type. The annotation shapes
244
+ // (sticky + text) live as top-level type tags rather than nested shape
245
+ // under a generic shape variant.
246
+ return n.type === 'sticky' || n.type === 'text';
246
247
  };
247
248
 
248
249
  const autoLayout = async (
@@ -354,12 +355,14 @@ export const validateDemo = (req: ValidateRequest): ValidateReport => {
354
355
 
355
356
  const playable = nodes.filter((n) => {
356
357
  const data = n.data as { playAction?: unknown } | undefined;
357
- return n.type === 'playNode' || (n.type === 'stateNode' && data?.playAction !== undefined);
358
+ // Flat-types refactor: a node is playable iff it carries a playAction
359
+ // capability, regardless of variant.
360
+ return data?.playAction !== undefined;
358
361
  });
359
362
  if (tier !== 'static' && playable.length === 0) {
360
363
  issues.push({
361
364
  kind: 'tier-mismatch',
362
- message: `Tier '${tier}' requires at least one playable node; found 0. Either add a playNode or set tier=static.`,
365
+ message: `Tier '${tier}' requires at least one playable node; found 0. Set data.playAction on a node or set tier=static.`,
363
366
  });
364
367
  }
365
368
 
package/src/layout.ts CHANGED
@@ -51,7 +51,7 @@ export interface LayoutResult {
51
51
  export interface LayoutNode {
52
52
  id: string;
53
53
  type: FlowNode['type'];
54
- data?: { width?: number; height?: number; shape?: string };
54
+ data?: { width?: number; height?: number };
55
55
  }
56
56
 
57
57
  export interface LayoutEdge {
@@ -60,42 +60,38 @@ export interface LayoutEdge {
60
60
  target: string;
61
61
  }
62
62
 
63
+ // Per-type default size used when a node has no explicit width/height. Mirrors
64
+ // the canvas's SHAPE_DEFAULT_SIZE so ELK's layout matches what the canvas will
65
+ // paint.
63
66
  const DEFAULT_DIMENSIONS: Record<FlowNode['type'], { width: number; height: number }> = {
64
- playNode: { width: 220, height: 100 },
65
- stateNode: { width: 220, height: 100 },
66
- shapeNode: { width: 160, height: 80 },
67
- iconNode: { width: 80, height: 80 },
68
- htmlNode: { width: 320, height: 200 },
69
- imageNode: { width: 200, height: 150 },
70
- };
71
-
72
- const SHAPE_OVERRIDES: Record<string, { width: number; height: number }> = {
67
+ rectangle: { width: 200, height: 120 },
68
+ ellipse: { width: 200, height: 120 },
69
+ sticky: { width: 180, height: 180 },
73
70
  text: { width: 160, height: 40 },
74
- sticky: { width: 160, height: 180 },
71
+ database: { width: 120, height: 140 },
72
+ server: { width: 140, height: 120 },
73
+ user: { width: 100, height: 140 },
74
+ queue: { width: 220, height: 80 },
75
+ cloud: { width: 180, height: 120 },
76
+ image: { width: 200, height: 150 },
77
+ html: { width: 320, height: 200 },
78
+ icon: { width: 80, height: 80 },
75
79
  };
76
80
 
77
- const FLOATING_SHAPES = new Set(['sticky', 'text']);
81
+ // Sticky / text variants are floating annotations. They never participate in
82
+ // layered layout — they sit in a side column so the orthogonal flow stays
83
+ // clean.
84
+ const FLOATING_TYPES: ReadonlySet<FlowNode['type']> = new Set(['sticky', 'text']);
78
85
 
79
86
  const nodeDimensions = (node: LayoutNode): { width: number; height: number } => {
80
87
  const data = node.data ?? {};
81
88
  if (typeof data.width === 'number' && typeof data.height === 'number') {
82
89
  return { width: data.width, height: data.height };
83
90
  }
84
- if (node.type === 'shapeNode' && data.shape) {
85
- const override = SHAPE_OVERRIDES[data.shape];
86
- if (override) return override;
87
- }
88
91
  return DEFAULT_DIMENSIONS[node.type];
89
92
  };
90
93
 
91
- // Sticky / text shapes are floating annotations. They never participate in
92
- // layered layout — they sit in a side column so the orthogonal flow stays
93
- // clean.
94
- const isFloatingAnnotation = (node: LayoutNode): boolean => {
95
- if (node.type !== 'shapeNode') return false;
96
- const shape = node.data?.shape;
97
- return shape !== undefined && FLOATING_SHAPES.has(shape);
98
- };
94
+ const isFloatingAnnotation = (node: LayoutNode): boolean => FLOATING_TYPES.has(node.type);
99
95
 
100
96
  // Schema vocabulary: SourceHandle ∈ {r, b}, TargetHandle ∈ {t, l}. After
101
97
  // ELK lays out positions we pick handles geometrically — the layered LR
@@ -162,7 +158,7 @@ export const computeLayout = async (
162
158
  'elk.separateConnectedComponents': 'true',
163
159
  },
164
160
  children: laidOut.map((n) => {
165
- const d = dims.get(n.id) ?? DEFAULT_DIMENSIONS.playNode;
161
+ const d = dims.get(n.id) ?? DEFAULT_DIMENSIONS.rectangle;
166
162
  return { id: n.id, width: d.width, height: d.height };
167
163
  }),
168
164
  edges: connectors
@@ -193,7 +189,7 @@ export const computeLayout = async (
193
189
  const columnX = laidOut.length > 0 ? maxRight + 200 : 0;
194
190
  let cursorY = 0;
195
191
  for (const n of floatingNodes) {
196
- const d = dims.get(n.id) ?? DEFAULT_DIMENSIONS.shapeNode;
192
+ const d = dims.get(n.id) ?? DEFAULT_DIMENSIONS.rectangle;
197
193
  result.nodes[n.id] = { position: { x: columnX, y: cursorY } };
198
194
  cursorY += d.height + nodeSpacing;
199
195
  }
package/src/mcp.ts CHANGED
@@ -461,7 +461,7 @@ const buildTools = (ops: Operations): McpTool[] => [
461
461
  {
462
462
  name: 'seeflow_add_node',
463
463
  description:
464
- 'Append a new node to a flow (cascade-safe; id auto-generated when omitted). Text content fields (detail on every node; html on htmlNode) are auto-externalized to <project>/nodes/<id>/ and stored as file:// refs in flow.json; reads inline the resolved content transparently.',
464
+ "Append a new node to a flow (cascade-safe; id auto-generated when omitted). Text content fields (detail on every node; html on type:'html') are auto-externalized to <project>/nodes/<id>/ and stored as file:// refs in flow.json; reads inline the resolved content transparently.",
465
465
  inputSchema: inputSchemaFromZod(AddNodeInputSchema),
466
466
  handler: async (args) => {
467
467
  const parsed = AddNodeInputSchema.safeParse(args);
@@ -585,7 +585,7 @@ const buildTools = (ops: Operations): McpTool[] => [
585
585
  {
586
586
  name: 'seeflow_patch_node',
587
587
  description:
588
- 'Update fields on an existing node (position, name, description, detail, icon, colors, border, font, shape, dimensions, autoSize, plus iconNode-only color/strokeWidth/alt). Setting detail (every node) or html (htmlNode) writes the content to <project>/nodes/<id>/{detail.md|view.html}; the file:// ref on the node persists. Empty-string detail empties the file but keeps the ref.',
588
+ "Update fields on an existing node (position, name, description, detail, icon, colors, border, font, dimensions, autoSize, plus type:'icon'-only color/strokeWidth/alt and capabilities playAction/statusAction/stateSource). `type` can flip a node between any of the 12 visual variants (rectangle/ellipse/sticky/text/database/server/user/queue/cloud/image/html/icon); the post-merge schema reparse gates required fields on the new type (image.data.path, icon.data.icon). Setting detail (every node) or html (type:'html') writes the content to <project>/nodes/<id>/{detail.md|view.html}; the file:// ref on the node persists. Empty-string detail empties the file but keeps the ref.",
589
589
  inputSchema: inputSchemaFromZod(PatchNodeInputSchema),
590
590
  handler: async (args) => {
591
591
  const parsed = PatchNodeInputSchema.safeParse(args);
package/src/merge.ts CHANGED
@@ -39,10 +39,12 @@ export function mergeFlowAndStyle(flow: Flow, style: Style): ResolvedFlow {
39
39
  }
40
40
 
41
41
  // Fields that live in a node's `data` block on flow.json. Every other data
42
- // field is visual and routes to style.json.
42
+ // field is visual and routes to style.json. The flat-types refactor folds
43
+ // the visual kind into `node.type` itself (no more nested data.shape / data.kind)
44
+ // and makes every capability (playAction / statusAction / stateSource) valid
45
+ // on every type.
43
46
  const NODE_DATA_FLOW_KEYS = new Set([
44
47
  'name',
45
- 'kind',
46
48
  'stateSource',
47
49
  'handlerModule',
48
50
  'icon',
@@ -50,7 +52,6 @@ const NODE_DATA_FLOW_KEYS = new Set([
50
52
  'detail',
51
53
  'playAction',
52
54
  'statusAction',
53
- 'shape',
54
55
  'path',
55
56
  'alt',
56
57
  'html',
package/src/node-files.ts CHANGED
@@ -14,7 +14,7 @@ export interface ExternalizedFieldSpec {
14
14
 
15
15
  export const EXTERNALIZED_NODE_FIELDS: readonly ExternalizedFieldSpec[] = [
16
16
  { field: 'detail', fileName: 'detail.md' },
17
- { field: 'html', fileName: 'view.html', nodeTypes: ['htmlNode'] },
17
+ { field: 'html', fileName: 'view.html', nodeTypes: ['html'] },
18
18
  ];
19
19
 
20
20
  export const externalizedFieldsForNodeType = (