@eventcatalog/core 5.0.0-beta.10 → 5.0.0-beta.12

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 (61) hide show
  1. package/README.md +5 -5
  2. package/dist/analytics/analytics.cjs +1 -1
  3. package/dist/analytics/analytics.js +2 -2
  4. package/dist/analytics/log-build.cjs +1 -1
  5. package/dist/analytics/log-build.js +3 -3
  6. package/dist/{chunk-CRKJBDN7.js → chunk-BWC4YKW3.js} +1 -1
  7. package/dist/{chunk-LJMXOEEC.js → chunk-CX2VB7AI.js} +1 -1
  8. package/dist/{chunk-IONYKKOY.js → chunk-FY6MSP6J.js} +1 -1
  9. package/dist/{chunk-2LBDRHE4.js → chunk-QB7X34OD.js} +1 -1
  10. package/dist/{chunk-EM36OL6A.js → chunk-TSY5CC27.js} +1 -1
  11. package/dist/constants.cjs +1 -1
  12. package/dist/constants.js +1 -1
  13. package/dist/eventcatalog.cjs +1 -1
  14. package/dist/eventcatalog.js +5 -5
  15. package/dist/generate.cjs +1 -1
  16. package/dist/generate.js +3 -3
  17. package/dist/utils/cli-logger.cjs +1 -1
  18. package/dist/utils/cli-logger.js +2 -2
  19. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  20. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +34 -3
  21. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  22. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  23. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  24. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +67 -67
  25. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +69 -69
  26. package/eventcatalog/src/features/studio/agent-choreography.ts +8 -6
  27. package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
  28. package/eventcatalog/src/features/studio/api/client.ts +6 -1
  29. package/eventcatalog/src/features/studio/canvas-actions.ts +140 -22
  30. package/eventcatalog/src/features/studio/canvas-doc.ts +165 -10
  31. package/eventcatalog/src/features/studio/catalog.ts +15 -10
  32. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +54 -89
  33. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +43 -26
  34. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +8 -11
  35. package/eventcatalog/src/features/studio/components/Comments.tsx +15 -8
  36. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +27 -43
  37. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  38. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +32 -4
  39. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  40. package/eventcatalog/src/features/studio/components/Presence.tsx +47 -38
  41. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +323 -99
  42. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +33 -17
  43. package/eventcatalog/src/features/studio/flow-state.ts +29 -0
  44. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  45. package/eventcatalog/src/features/studio/grouping.ts +4 -0
  46. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +1 -1
  47. package/eventcatalog/src/features/studio/hooks/use-counter-scale.ts +20 -0
  48. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
  49. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +67 -13
  50. package/eventcatalog/src/features/studio/layout.ts +29 -15
  51. package/eventcatalog/src/features/studio/level-layout.ts +145 -0
  52. package/eventcatalog/src/features/studio/levels.ts +82 -27
  53. package/eventcatalog/src/features/studio/limits.ts +2 -0
  54. package/eventcatalog/src/features/studio/node-types.ts +44 -4
  55. package/eventcatalog/src/features/studio/pages/index.astro +2 -1
  56. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +1 -1
  57. package/eventcatalog/src/features/studio/server/runtime.ts +12 -4
  58. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  59. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  60. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  61. package/package.json +3 -3
@@ -2,7 +2,8 @@ import { memo, useCallback, useMemo, useState, type ReactNode } from 'react';
2
2
  import { Check, ChevronRight, Plus, Search } from 'lucide-react';
3
3
  import type { CatalogResource } from '../catalog-resources';
4
4
  import { catalogNodeData } from '../catalog';
5
- import { NODE_CATEGORIES, nodeDefinitions } from '../node-types';
5
+ import { isShownOnLevel, NODE_CATEGORIES, nodeDefinitions } from '../node-types';
6
+ import type { Level } from '../levels';
6
7
  import type { Thread } from '../hooks/use-comments';
7
8
  import { CommentList } from './Comments';
8
9
  import { useDragGhost, type StartDrag } from './DragGhost';
@@ -70,6 +71,8 @@ type Tab = 'components' | 'catalog' | 'comments';
70
71
 
71
72
  /** Components and catalog resources to drag onto the canvas, and the comments on it */
72
73
  export default memo(function LeftPanel({
74
+ level,
75
+ onShowAll,
73
76
  resources,
74
77
  keysOnCanvas,
75
78
  onAddComponent,
@@ -79,6 +82,10 @@ export default memo(function LeftPanel({
79
82
  onToggleResolved,
80
83
  onOpenThread,
81
84
  }: {
85
+ /** The level shown: only what it shows is listed, so what's added stays on it */
86
+ level: Level;
87
+ /** Goes to L3, which shows everything */
88
+ onShowAll: () => void;
82
89
  resources: CatalogResource[];
83
90
  /** The catalog resources on the canvas */
84
91
  keysOnCanvas: Set<string>;
@@ -94,6 +101,10 @@ export default memo(function LeftPanel({
94
101
  const { startDrag, ghost } = useDragGhost();
95
102
  const sections = useCollapsed();
96
103
  const openThreads = threads.filter((thread) => !thread.resolved).length;
104
+ const shownResources = useMemo(
105
+ () => (level === 3 ? resources : resources.filter((resource) => isShownOnLevel(resource.node.type, level))),
106
+ [resources, level]
107
+ );
97
108
  const tabClass = (active: boolean) =>
98
109
  `flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-1.5 py-1 text-xs font-medium ${
99
110
  active ? 'bg-[rgb(var(--ec-page-bg))] shadow-sm' : 'text-[rgb(var(--ec-page-text-muted))]'
@@ -117,10 +128,11 @@ export default memo(function LeftPanel({
117
128
  )}
118
129
  </button>
119
130
  </div>
120
- {tab === 'components' && <ComponentList onAdd={onAddComponent} startDrag={startDrag} sections={sections} />}
131
+ {tab !== 'comments' && level !== 3 && <LevelHint level={level} onShowAll={onShowAll} />}
132
+ {tab === 'components' && <ComponentList level={level} onAdd={onAddComponent} startDrag={startDrag} sections={sections} />}
121
133
  {tab === 'catalog' && (
122
134
  <CatalogList
123
- resources={resources}
135
+ resources={shownResources}
124
136
  keysOnCanvas={keysOnCanvas}
125
137
  onAdd={onAddResource}
126
138
  startDrag={startDrag}
@@ -140,16 +152,30 @@ const listedDefinitions = NODE_CATEGORIES.map((category) => ({
140
152
  definitions: nodeDefinitions.filter((definition) => definition.category === category.id && !definition.unlisted),
141
153
  }));
142
154
 
155
+ /** On L1 and L2: only what the level shows is listed (added there, it stays there), the rest is on L3 */
156
+ function LevelHint({ level, onShowAll }: { level: Level; onShowAll: () => void }) {
157
+ return (
158
+ <p className="mx-3 mb-2 text-[11px] leading-snug text-[rgb(var(--ec-page-text-muted))]">
159
+ Showing what L{level} shows.{' '}
160
+ <button onClick={onShowAll} className="font-medium text-[rgb(var(--ec-accent))] hover:underline">
161
+ Go to L3 for everything
162
+ </button>
163
+ </p>
164
+ );
165
+ }
166
+
143
167
  // Each component's data, made once, for the node shown while it's dragged
144
168
  const componentPreviews = new Map(
145
169
  nodeDefinitions.map((definition) => [definition.type, { type: definition.type, data: definition.createData() }])
146
170
  );
147
171
 
148
172
  const ComponentList = memo(function ComponentList({
173
+ level,
149
174
  onAdd,
150
175
  startDrag,
151
176
  sections: { collapsed, toggle },
152
177
  }: {
178
+ level: Level;
153
179
  onAdd: (type: string) => void;
154
180
  startDrag: StartDrag;
155
181
  sections: Collapsed;
@@ -157,6 +183,8 @@ const ComponentList = memo(function ComponentList({
157
183
  return (
158
184
  <div className="min-h-0 flex-1 space-y-3 overflow-y-auto px-3 pb-3">
159
185
  {listedDefinitions.map((category) => {
186
+ const definitions = category.definitions.filter((definition) => isShownOnLevel(definition.type, level));
187
+ if (definitions.length === 0) return null;
160
188
  const id = `components:${category.id}`;
161
189
  const open = !collapsed.has(id);
162
190
  return (
@@ -165,7 +193,7 @@ const ComponentList = memo(function ComponentList({
165
193
  {category.label}
166
194
  </SectionHeading>
167
195
  {open &&
168
- category.definitions.map(({ type, label }) => {
196
+ definitions.map(({ type, label }) => {
169
197
  const { icon: Icon, className } = NODE_ICONS[type];
170
198
  return (
171
199
  <div
@@ -0,0 +1,79 @@
1
+ import { useState, type FormEvent } from 'react';
2
+ import { MAX_TITLE_LENGTH } from '../limits';
3
+ import Dialog, { PRIMARY_BUTTON, SECONDARY_BUTTON } from './Dialog';
4
+ import { STATUS } from './status';
5
+
6
+ /**
7
+ * Naming the canvas a diagram is opened in (the visualiser's "Open in Studio"), named after the diagram to start
8
+ * with, like a new canvas on the Studio page. Loaded with the visualiser, so it imports nothing that loads a canvas.
9
+ */
10
+ export default function OpenInStudioDialog({
11
+ title,
12
+ onOpen,
13
+ onClose,
14
+ }: {
15
+ /** The diagram's title */
16
+ title?: string;
17
+ /** Makes the canvas with this name and opens it (rejects with what went wrong) */
18
+ onOpen: (title: string | undefined) => Promise<void>;
19
+ onClose: () => void;
20
+ }) {
21
+ const [name, setName] = useState((title ?? '').slice(0, MAX_TITLE_LENGTH));
22
+ const [opening, setOpening] = useState(false);
23
+ const [error, setError] = useState<string>();
24
+
25
+ const submit = async (event: FormEvent) => {
26
+ event.preventDefault();
27
+ setOpening(true);
28
+ setError(undefined);
29
+ try {
30
+ // Stays open (and busy) while the canvas page loads
31
+ await onOpen(name.trim() || undefined);
32
+ } catch (failure) {
33
+ setError((failure as Error).message);
34
+ setOpening(false);
35
+ }
36
+ };
37
+
38
+ return (
39
+ <Dialog labelledBy="open-in-studio-title" busy={opening} onClose={onClose}>
40
+ <form onSubmit={submit}>
41
+ <div className="border-b px-5 py-4 border-[rgb(var(--ec-page-border))]">
42
+ <h2 id="open-in-studio-title" className="text-base font-semibold">
43
+ Open in Studio
44
+ </h2>
45
+ <p className="mt-0.5 text-sm text-[rgb(var(--ec-page-text-muted))]">
46
+ A new canvas starts with this diagram. Name it for what you're designing, so your team can find it.
47
+ </p>
48
+ </div>
49
+ <div className="px-5 py-4">
50
+ <label htmlFor="open-in-studio-name" className="block text-sm font-semibold">
51
+ Name
52
+ </label>
53
+ <input
54
+ id="open-in-studio-name"
55
+ // Ready to type a new name, or keep the diagram's
56
+ autoFocus
57
+ onFocus={(event) => event.target.select()}
58
+ value={name}
59
+ onChange={(event) => setName(event.target.value)}
60
+ maxLength={MAX_TITLE_LENGTH}
61
+ autoComplete="off"
62
+ placeholder="e.g. Payments redesign"
63
+ disabled={opening}
64
+ className="mt-2 w-full rounded-lg border px-3 py-2 text-sm shadow-sm bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))] placeholder:text-[rgb(var(--ec-page-text-muted))] focus:outline-none focus:border-[rgb(var(--ec-accent))] focus:ring-2 focus:ring-[rgb(var(--ec-accent)/0.25)]"
65
+ />
66
+ {error && <p className={`mt-2 text-sm ${STATUS.danger.text}`}>{error}</p>}
67
+ </div>
68
+ <div className="flex justify-end gap-2 border-t px-5 py-4 border-[rgb(var(--ec-page-border))]">
69
+ <button type="button" onClick={onClose} disabled={opening} className={SECONDARY_BUTTON}>
70
+ Cancel
71
+ </button>
72
+ <button type="submit" disabled={opening} className={PRIMARY_BUTTON}>
73
+ {opening ? 'Opening…' : 'Open in Studio'}
74
+ </button>
75
+ </div>
76
+ </form>
77
+ </Dialog>
78
+ );
79
+ }
@@ -3,14 +3,15 @@ import { useInternalNode, useStore, useStoreApi, ViewportPortal, type XYPosition
3
3
  import { Bot, Eye, EyeOff } from 'lucide-react';
4
4
  import { sizeOf } from '../grouping';
5
5
  import { usePeople, type Peer, type PresenceStore } from '../hooks/presence-store';
6
+ import { COUNTER_SCALE, useCounterScale } from '../hooks/use-counter-scale';
6
7
  import { useFollowCamera } from '../hooks/use-follow-camera';
7
8
  import Picture from './Picture';
8
9
 
9
10
  /**
10
11
  * Other people on the canvas: their pointers, selections and the connections they're dragging. Drawn in the
11
12
  * canvas's own coordinates (React Flow's viewport portal), so panning and zooming move them without
12
- * re-rendering, and counter-scaled so they stay the same size on screen. Each person's pointer and marks follow
13
- * them on their own, so one pointer moving doesn't re-render anyone else's.
13
+ * re-rendering, and counter-scaled (COUNTER_SCALE, set outside React) so they stay the same size on screen. Each
14
+ * person's pointer and marks follow them on their own, so one pointer moving doesn't re-render anyone else's.
14
15
  */
15
16
  export const RemotePresence = memo(function RemotePresence({
16
17
  store,
@@ -20,18 +21,18 @@ export const RemotePresence = memo(function RemotePresence({
20
21
  clientId: number | null;
21
22
  }) {
22
23
  const people = usePeople(store);
23
- const zoom = useStore((state) => state.transform[2]);
24
- const scale = 1 / zoom;
24
+ const layer = useRef<HTMLDivElement>(null);
25
+ useCounterScale(layer);
25
26
  const others = store ? people.filter((peer) => peer.clientId !== clientId) : [];
26
27
 
27
28
  return (
28
29
  <ViewportPortal>
29
30
  {/* Above nodes (selected ones are raised to 1000) */}
30
- <div className="pointer-events-none absolute left-0 top-0" style={{ zIndex: 2000 }}>
31
+ <div ref={layer} className="pointer-events-none absolute left-0 top-0" style={{ zIndex: 2000 }}>
31
32
  {others.map((peer) => (
32
33
  <Fragment key={peer.clientId}>
33
- <PeerMarks store={store!} clientId={peer.clientId} scale={scale} />
34
- {peer.pointer && <PeerPointer store={store!} peer={peer} scale={scale} />}
34
+ <PeerMarks store={store!} clientId={peer.clientId} />
35
+ {peer.pointer && <PeerPointer store={store!} peer={peer} />}
35
36
  </Fragment>
36
37
  ))}
37
38
  </div>
@@ -51,33 +52,39 @@ const marksKey = (peer: Peer | undefined) =>
51
52
  : '';
52
53
 
53
54
  /** Someone's selection, and the connection they're dragging (a dashed line from where it starts to their pointer) */
54
- const PeerMarks = memo(function PeerMarks({ store, clientId, scale }: { store: PresenceStore; clientId: number; scale: number }) {
55
+ const PeerMarks = memo(function PeerMarks({ store, clientId }: { store: PresenceStore; clientId: number }) {
55
56
  useSyncExternalStore(store.subscribe, () => marksKey(findPeer(store, clientId)));
56
57
  const peer = findPeer(store, clientId);
57
58
  if (!peer) return null;
58
59
  return (
59
60
  <>
60
61
  {peer.selection.map((id) => (
61
- <SelectionOutline key={id} nodeId={id} name={peer.name} color={peer.color} scale={scale} />
62
+ <SelectionOutline key={id} nodeId={id} name={peer.name} color={peer.color} />
62
63
  ))}
63
- {peer.pointer && peer.connecting && (
64
- <svg className="absolute left-0 top-0 overflow-visible" width={1} height={1}>
65
- <line
66
- x1={peer.connecting.x}
67
- y1={peer.connecting.y}
68
- x2={peer.pointer.x}
69
- y2={peer.pointer.y}
70
- stroke={peer.color}
71
- strokeWidth={2 * scale}
72
- strokeDasharray={`${6 * scale} ${4 * scale}`}
73
- />
74
- <circle cx={peer.connecting.x} cy={peer.connecting.y} r={4 * scale} fill={peer.color} />
75
- </svg>
76
- )}
64
+ {peer.pointer && peer.connecting && <ConnectingLine from={peer.connecting} to={peer.pointer} color={peer.color} />}
77
65
  </>
78
66
  );
79
67
  });
80
68
 
69
+ /** A connection someone's dragging: only shown while they drag one, so it can follow the zoom itself */
70
+ function ConnectingLine({ from, to, color }: { from: XYPosition; to: XYPosition; color: string }) {
71
+ const scale = 1 / useStore((state) => state.transform[2]);
72
+ return (
73
+ <svg className="absolute left-0 top-0 overflow-visible" width={1} height={1}>
74
+ <line
75
+ x1={from.x}
76
+ y1={from.y}
77
+ x2={to.x}
78
+ y2={to.y}
79
+ stroke={color}
80
+ strokeWidth={2 * scale}
81
+ strokeDasharray={`${6 * scale} ${4 * scale}`}
82
+ />
83
+ <circle cx={from.x} cy={from.y} r={4 * scale} fill={color} />
84
+ </svg>
85
+ );
86
+ }
87
+
81
88
  /** Longest a pointer takes to glide to where it was last seen (updates come about every 33 ms) */
82
89
  const MAX_GLIDE_MS = 120;
83
90
 
@@ -85,19 +92,15 @@ const MAX_GLIDE_MS = 120;
85
92
  * Someone's pointer, gliding from where it was to each new position over the time since the last one (so a
86
93
  * steady stream of updates looks like steady movement). Moved with a transform outside React: no re-renders.
87
94
  */
88
- const PeerPointer = memo(function PeerPointer({ store, peer, scale }: { store: PresenceStore; peer: Peer; scale: number }) {
95
+ const PeerPointer = memo(function PeerPointer({ store, peer }: { store: PresenceStore; peer: Peer }) {
89
96
  const ref = useRef<HTMLDivElement>(null);
90
97
  const shown = useRef<XYPosition | null>(null);
91
- const scaleRef = useRef(scale);
92
- scaleRef.current = scale;
93
98
 
94
99
  const draw = () => {
95
100
  if (ref.current && shown.current)
96
- ref.current.style.transform = `translate(${shown.current.x}px, ${shown.current.y}px) scale(${scaleRef.current})`;
101
+ ref.current.style.transform = `translate(${shown.current.x}px, ${shown.current.y}px) scale(var(${COUNTER_SCALE}, 1))`;
97
102
  };
98
103
 
99
- useLayoutEffect(draw, [scale]);
100
-
101
104
  useLayoutEffect(() => {
102
105
  let target: XYPosition | null = null;
103
106
  let from: XYPosition = { x: 0, y: 0 };
@@ -157,17 +160,18 @@ const PeerPointer = memo(function PeerPointer({ store, peer, scale }: { store: P
157
160
  );
158
161
  });
159
162
 
163
+ // Screen pixels, in the canvas's coordinates (counter-scaled by the layer's COUNTER_SCALE)
164
+ const onScreen = (px: number) => `calc(${px}px * var(${COUNTER_SCALE}, 1))`;
165
+
160
166
  /** Someone's selection around a node: follows that node only, so other nodes moving don't re-render it */
161
167
  const SelectionOutline = memo(function SelectionOutline({
162
168
  nodeId,
163
169
  name,
164
170
  color,
165
- scale,
166
171
  }: {
167
172
  nodeId: string;
168
173
  name: string;
169
174
  color: string;
170
- scale: number;
171
175
  }) {
172
176
  const node = useInternalNode(nodeId);
173
177
  if (!node) return null;
@@ -177,17 +181,21 @@ const SelectionOutline = memo(function SelectionOutline({
177
181
  <div
178
182
  className="absolute"
179
183
  style={{
180
- left: x - 4 * scale,
181
- top: y - 4 * scale,
182
- width: width + 8 * scale,
183
- height: height + 8 * scale,
184
- border: `${2 * scale}px solid ${color}`,
185
- borderRadius: 8 * scale,
184
+ left: `calc(${x}px - ${onScreen(4)})`,
185
+ top: `calc(${y}px - ${onScreen(4)})`,
186
+ width: `calc(${width}px + ${onScreen(8)})`,
187
+ height: `calc(${height}px + ${onScreen(8)})`,
188
+ border: `${onScreen(2)} solid ${color}`,
189
+ borderRadius: onScreen(8),
186
190
  }}
187
191
  >
188
192
  <span
189
193
  className="absolute right-0 top-0 whitespace-nowrap rounded px-1.5 text-[10px] leading-4 text-white"
190
- style={{ backgroundColor: color, transform: `translateY(-100%) scale(${scale})`, transformOrigin: 'bottom right' }}
194
+ style={{
195
+ backgroundColor: color,
196
+ transform: `translateY(-100%) scale(var(${COUNTER_SCALE}, 1))`,
197
+ transformOrigin: 'bottom right',
198
+ }}
191
199
  >
192
200
  {name}
193
201
  </span>
@@ -253,6 +261,7 @@ export const AgentActivity = memo(function AgentActivity({
253
261
  const working = people.filter((peer) => peer.clientId !== clientId && peer.agent && peer.pointer);
254
262
  const camera = useFollowCamera({
255
263
  enabled: following && enabled,
264
+ fitsContent: enabled,
256
265
  agentsWorking: working.length > 0,
257
266
  getAgents: () => (store?.getPeers() ?? []).filter((peer: Peer) => peer.clientId !== clientId && peer.agent && peer.pointer),
258
267
  });