@eventcatalog/core 5.0.0-beta.11 → 5.0.0-beta.13

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 (92) hide show
  1. package/README.md +1 -1
  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 +4 -4
  6. package/dist/catalog-runtime.cjs +1 -1
  7. package/dist/{chunk-MFEMKQZ6.js → chunk-BO2HHVZ3.js} +1 -1
  8. package/dist/{chunk-4YFM63DS.js → chunk-JPT3QC5V.js} +1 -1
  9. package/dist/{chunk-76DAAJLV.js → chunk-PLTLW45Q.js} +5 -5
  10. package/dist/{chunk-WM4QYMIA.js → chunk-T75DOVFP.js} +1 -1
  11. package/dist/{chunk-LQZ7IG7B.js → chunk-XA5W6DXX.js} +1 -1
  12. package/dist/constants.cjs +1 -1
  13. package/dist/constants.js +1 -1
  14. package/dist/eventcatalog.cjs +2 -2
  15. package/dist/eventcatalog.js +14 -14
  16. package/dist/federation/federate.js +3 -3
  17. package/dist/generate.cjs +1 -1
  18. package/dist/generate.js +3 -3
  19. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio-fonts.css.mjs +3 -0
  20. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.css.mjs +3 -0
  21. package/dist/runtime-dependencies/@eventcatalog__visualiser__studio.mjs +3 -0
  22. package/dist/runtime-dependencies/manifest.json +3 -0
  23. package/dist/utils/cli-logger.cjs +1 -1
  24. package/dist/utils/cli-logger.js +2 -2
  25. package/eventcatalog/src/components/ChatPanel/ChatPanel.tsx +2 -0
  26. package/eventcatalog/src/components/MDX/NodeGraph/AstroNodeGraph.tsx +42 -3
  27. package/eventcatalog/src/components/MDX/NodeGraph/NodeGraph.astro +5 -1
  28. package/eventcatalog/src/components/NavigationProgress.astro +7 -0
  29. package/eventcatalog/src/features/mcp/apps/architecture-diagram/node-chat.tsx +6 -45
  30. package/eventcatalog/src/features/mcp/apps/canvas/shared.ts +3 -3
  31. package/eventcatalog/src/features/mcp/apps/canvas/styles.css +3 -1
  32. package/eventcatalog/src/features/mcp/apps/canvas/view.tsx +7 -5
  33. package/eventcatalog/src/features/mcp/apps/generated/canvas.html +132 -70
  34. package/eventcatalog/src/features/mcp/apps/generated/viewer.html +71 -71
  35. package/eventcatalog/src/features/studio/agent-choreography.ts +13 -10
  36. package/eventcatalog/src/features/studio/agents.ts +28 -0
  37. package/eventcatalog/src/features/studio/api/canvases-api.ts +64 -15
  38. package/eventcatalog/src/features/studio/api/client.ts +6 -1
  39. package/eventcatalog/src/features/studio/canvas-actions.ts +160 -34
  40. package/eventcatalog/src/features/studio/canvas-preview.ts +1 -2
  41. package/eventcatalog/src/features/studio/catalog.ts +16 -14
  42. package/eventcatalog/src/features/studio/components/CanvasHeader.tsx +263 -115
  43. package/eventcatalog/src/features/studio/components/CanvasStatusMenu.tsx +15 -14
  44. package/eventcatalog/src/features/studio/components/ConnectAgentDialog.tsx +272 -119
  45. package/eventcatalog/src/features/studio/components/ConnectionsChooser.tsx +1 -2
  46. package/eventcatalog/src/features/studio/components/DeleteCanvasDialog.tsx +28 -44
  47. package/eventcatalog/src/features/studio/components/Dialog.tsx +60 -0
  48. package/eventcatalog/src/features/studio/components/JoinForm.tsx +101 -26
  49. package/eventcatalog/src/features/studio/components/LeftPanel.tsx +119 -230
  50. package/eventcatalog/src/features/studio/components/NodeDetails.tsx +183 -0
  51. package/eventcatalog/src/features/studio/components/OpenInStudioDialog.tsx +79 -0
  52. package/eventcatalog/src/features/studio/components/Presence.tsx +52 -45
  53. package/eventcatalog/src/features/studio/components/ShareDialog.tsx +277 -141
  54. package/eventcatalog/src/features/studio/components/ShortcutsDialog.tsx +41 -0
  55. package/eventcatalog/src/features/studio/components/StudioDesigner.tsx +1286 -411
  56. package/eventcatalog/src/features/studio/document-name.ts +7 -0
  57. package/eventcatalog/src/features/studio/from-visualiser.ts +138 -0
  58. package/eventcatalog/src/features/studio/hooks/presence-sender.ts +19 -2
  59. package/eventcatalog/src/features/studio/hooks/use-canvas-webmcp.ts +9 -3
  60. package/eventcatalog/src/features/studio/hooks/use-comments.ts +2 -2
  61. package/eventcatalog/src/features/studio/hooks/use-follow-camera.ts +8 -3
  62. package/eventcatalog/src/features/studio/hooks/use-studio-flow.ts +223 -30
  63. package/eventcatalog/src/features/studio/identity.ts +23 -1
  64. package/eventcatalog/src/features/studio/limits.ts +2 -0
  65. package/eventcatalog/src/features/studio/pages/[id].astro +2 -1
  66. package/eventcatalog/src/features/studio/pages/index.astro +2 -2
  67. package/eventcatalog/src/features/studio/server/canvas-mcp.ts +8 -23
  68. package/eventcatalog/src/features/studio/server/runtime.ts +16 -9
  69. package/eventcatalog/src/utils/node-graphs/domain-levels-node-graph.ts +3 -3
  70. package/eventcatalog/src/utils/node-graphs/node-resource.ts +67 -0
  71. package/eventcatalog/src/utils/node-graphs/systems-node-graph.ts +1 -1
  72. package/package.json +3 -3
  73. package/eventcatalog/src/features/studio/canvas-doc.ts +0 -414
  74. package/eventcatalog/src/features/studio/clipboard.ts +0 -97
  75. package/eventcatalog/src/features/studio/components/CanvasControls.tsx +0 -154
  76. package/eventcatalog/src/features/studio/components/Comments.tsx +0 -770
  77. package/eventcatalog/src/features/studio/components/ContainerChooser.tsx +0 -145
  78. package/eventcatalog/src/features/studio/components/DragGhost.tsx +0 -115
  79. package/eventcatalog/src/features/studio/components/Picture.tsx +0 -21
  80. package/eventcatalog/src/features/studio/components/PropertiesPanel.tsx +0 -212
  81. package/eventcatalog/src/features/studio/components/canvas-nodes.tsx +0 -255
  82. package/eventcatalog/src/features/studio/components/icons.ts +0 -53
  83. package/eventcatalog/src/features/studio/components/status.ts +0 -53
  84. package/eventcatalog/src/features/studio/edges.ts +0 -56
  85. package/eventcatalog/src/features/studio/flow-state.ts +0 -92
  86. package/eventcatalog/src/features/studio/grouping.ts +0 -226
  87. package/eventcatalog/src/features/studio/layout.ts +0 -42
  88. package/eventcatalog/src/features/studio/levels.ts +0 -282
  89. package/eventcatalog/src/features/studio/node-types.ts +0 -205
  90. package/eventcatalog/src/features/studio/placement.ts +0 -197
  91. package/eventcatalog/src/features/studio/studio.css +0 -273
  92. package/dist/{chunk-KHACCTW2.js → chunk-ZEY2XUJN.js} +4 -4
@@ -1,26 +1,58 @@
1
1
  import { memo, useEffect, useRef, useState, type ReactNode } from 'react';
2
- import { Bot, ChevronDown, Link2, Pencil, Plus, Trash2, UserPlus } from 'lucide-react';
2
+ import {
3
+ CircleHelp,
4
+ Download,
5
+ FolderOpen,
6
+ ImageDown,
7
+ Link2,
8
+ MoreVertical,
9
+ Pencil,
10
+ Plug,
11
+ Plus,
12
+ Share2,
13
+ Trash2,
14
+ Users,
15
+ } from 'lucide-react';
16
+ import {
17
+ DIAGRAM_MENU,
18
+ DIAGRAM_MENU_BUTTON_ICON,
19
+ DIAGRAM_MENU_ITEM,
20
+ DIAGRAM_MENU_ITEM_ICON,
21
+ DIAGRAM_MENU_SEPARATOR,
22
+ DIAGRAM_MENU_TITLE,
23
+ } from '@eventcatalog/visualiser';
3
24
  import { usePeople, type Peer, type PresenceStore } from '../hooks/presence-store';
25
+ import { MAX_NAME_LENGTH, PERSON_COLORS } from '../identity';
4
26
  import type { WebMcpStatus } from '../hooks/use-canvas-webmcp';
5
27
  import type { Status, Transport } from '../hooks/use-studio-flow';
6
- import type { CanvasStatus, StatusChange } from '../canvas-doc';
28
+ import { type CanvasStatus, type StatusChange, STATUS, Picture, AgentMark } from '@eventcatalog/visualiser/studio';
7
29
  import CanvasStatusMenu from './CanvasStatusMenu';
8
- import { CONNECTION_DOT, STATUS } from './status';
9
- import Picture from './Picture';
30
+
31
+ /** The dot showing whether the canvas is connected */
32
+ const CONNECTION_DOT: Record<Status, string> = {
33
+ connected: STATUS.success.dot,
34
+ connecting: STATUS.warning.dot,
35
+ disconnected: STATUS.danger.dot,
36
+ };
10
37
 
11
38
  /**
12
- * The canvas's header, like Figma or Miro: two small floating bars. On the left the canvas (its title, and a
13
- * menu), on the right who's here, connecting an agent and sharing.
39
+ * The canvas's header, as Studio on the web's top bar: on the left the canvas (its title, its status and a menu), on
40
+ * the right who's here, connecting an agent and sharing, each a small card over the canvas. Its menus look like the
41
+ * visualiser's (`DIAGRAM_MENU_*`).
14
42
  */
15
43
 
16
44
  const barClass =
17
- 'pointer-events-auto flex h-10 items-center rounded-xl border shadow-md bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))]';
45
+ 'pointer-events-auto flex h-[38px] items-center rounded-[9px] border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] shadow-sm';
18
46
  // The header lets the pointer through to the canvas around its bars, so its menus take it back
19
- const menuClass =
20
- 'pointer-events-auto absolute top-full z-50 mt-2 min-w-52 rounded-xl border p-1 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]';
21
- const menuItemLayout = 'flex w-full items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-xs';
22
- const menuItemClass = `${menuItemLayout} hover:bg-[rgb(var(--ec-page-border)/0.5)]`;
47
+ const popoverClass = 'pointer-events-auto absolute top-full mt-2';
48
+ const menuClass = `${popoverClass} ${DIAGRAM_MENU}`;
49
+ const menuItemClass = `${DIAGRAM_MENU_ITEM} w-full text-left`;
23
50
  const MAX_AVATARS = 4;
51
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform);
52
+ const MOD = isMac ? 'Cmd' : 'Ctrl';
53
+ const itemClass =
54
+ 'flex h-[34px] w-full cursor-pointer items-center gap-3 rounded-[7px] px-2 text-left text-sm outline-none hover:bg-[rgb(var(--ec-content-hover))]';
55
+ const itemIconClass = 'flex flex-none [&_svg]:h-[17px] [&_svg]:w-[17px] [&_svg]:stroke-[1.75]';
24
56
 
25
57
  /** Closes a popover on outside clicks and Escape */
26
58
  function usePopover() {
@@ -48,7 +80,7 @@ function Avatar({ peer, size = 28, children }: { peer: Peer; size?: number; chil
48
80
  className="relative flex items-center justify-center rounded-full text-[11px] font-semibold text-white ring-2 ring-[rgb(var(--ec-card-bg))]"
49
81
  style={{ backgroundColor: peer.color, width: size, height: size }}
50
82
  >
51
- {children ?? (peer.agent ? <Bot size={14} /> : peer.name.slice(0, 2).toUpperCase())}
83
+ {children ?? (peer.agent ? <AgentMark name={peer.name} size={14} /> : peer.name.slice(0, 2).toUpperCase())}
52
84
  {!children && <Picture src={peer.picture} />}
53
85
  </span>
54
86
  );
@@ -61,15 +93,33 @@ function CanvasMenu({
61
93
  onRetitle,
62
94
  shareUrl,
63
95
  newCanvasUrl,
96
+ onConnectAgent,
97
+ webMcp,
98
+ agentsHere,
64
99
  onDelete,
100
+ onOpenFile,
101
+ onSaveToFile,
102
+ onExportImage,
103
+ onLiveCollaboration,
104
+ onHelp,
65
105
  children,
66
106
  }: {
107
+ /** Opens a .eventcatalog file as a new canvas (not offered when not given, e.g. inside a chat) */
108
+ onOpenFile?: () => void;
109
+ onSaveToFile: () => void;
110
+ onExportImage: () => void;
111
+ onLiveCollaboration: () => void;
112
+ onHelp: () => void;
67
113
  title?: string;
68
114
  status: Status;
69
115
  transport: Transport;
70
116
  onRetitle: (title: string) => void;
71
117
  shareUrl?: string;
72
118
  newCanvasUrl?: string;
119
+ onConnectAgent: () => void;
120
+ webMcp: WebMcpStatus;
121
+ /** An AI agent is on the canvas */
122
+ agentsHere: boolean;
73
123
  /** Asks to delete the canvas (not offered when not given, e.g. inside a chat) */
74
124
  onDelete?: () => void;
75
125
  /** Shown after the title (the canvas's status) */
@@ -84,10 +134,29 @@ function CanvasMenu({
84
134
  disconnected: 'Offline, reconnecting…',
85
135
  }[status];
86
136
  const statusColor = CONNECTION_DOT[status];
137
+ /** A menu item, as Studio on the web's: its icon, what it does, and its shortcut (or something else on the right) */
138
+ const item = (icon: ReactNode, label: string, onSelect: () => void, shortcut?: ReactNode, className = '') => (
139
+ <button
140
+ type="button"
141
+ className={`${itemClass} ${className}`}
142
+ onClick={() => {
143
+ menu.setOpen(false);
144
+ onSelect();
145
+ }}
146
+ >
147
+ <span className={itemIconClass}>{icon}</span>
148
+ <span className="min-w-0 flex-1 truncate">{label}</span>
149
+ {typeof shortcut === 'string' ? (
150
+ <span className="flex-none text-xs font-normal text-[rgb(var(--ec-page-text-muted))]">{shortcut}</span>
151
+ ) : (
152
+ shortcut
153
+ )}
154
+ </button>
155
+ );
87
156
 
88
157
  return (
89
158
  <div ref={menu.ref} className="relative">
90
- <div className={`${barClass} gap-1 pl-3 pr-1`}>
159
+ <div className={`${barClass} gap-1.5 pl-3 pr-1`}>
91
160
  <span title={statusLabel} className={`h-2 w-2 shrink-0 rounded-full ${statusColor}`} />
92
161
  <input
93
162
  ref={titleInput}
@@ -101,59 +170,57 @@ function CanvasMenu({
101
170
  }}
102
171
  onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()}
103
172
  size={Math.max(12, (draft ?? title ?? 'Untitled canvas').length)}
104
- className="max-w-64 truncate rounded-md bg-transparent px-1.5 py-1 text-sm font-semibold placeholder:font-semibold placeholder:text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-border)/0.4)] focus:bg-[rgb(var(--ec-input-bg))] focus:outline-none"
173
+ className={`${DIAGRAM_MENU_TITLE} max-w-64 truncate rounded-md bg-transparent px-1.5 py-1 placeholder:text-[rgb(var(--ec-page-text-muted))] hover:bg-[rgb(var(--ec-page-border)/0.4)] focus:bg-[rgb(var(--ec-input-bg))] focus:outline-none`}
105
174
  />
106
175
  {children}
107
176
  <button
108
177
  aria-label="Canvas menu"
109
178
  onClick={() => menu.setOpen((open) => !open)}
110
- className="rounded-md p-1.5 text-[rgb(var(--ec-icon-color))] hover:bg-[rgb(var(--ec-page-border)/0.5)]"
179
+ className="group rounded-md p-1 hover:bg-[rgb(var(--ec-accent-subtle))]"
111
180
  >
112
- <ChevronDown size={14} />
181
+ <MoreVertical className={DIAGRAM_MENU_BUTTON_ICON} />
113
182
  </button>
114
183
  </div>
115
184
  {menu.open && (
116
- <div className={`${menuClass} left-0`}>
117
- <button
118
- className={menuItemClass}
119
- onClick={() => {
120
- menu.setOpen(false);
121
- titleInput.current?.focus();
122
- titleInput.current?.select();
123
- }}
124
- >
125
- <Pencil size={14} /> Rename canvas
126
- </button>
127
- <button
128
- className={menuItemClass}
129
- onClick={() => {
130
- void navigator.clipboard.writeText(shareUrl ?? window.location.href);
131
- menu.setOpen(false);
132
- }}
133
- >
134
- <Link2 size={14} /> Copy link
135
- </button>
185
+ <div
186
+ className={`${popoverClass} left-0 z-50 w-72 rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] p-1.5 text-[rgb(var(--ec-page-text))] shadow-xl`}
187
+ >
188
+ {/* As Studio on the web's menu: files and images, people and agents, help */}
189
+ {onOpenFile && item(<FolderOpen />, 'Open…', onOpenFile, `${MOD}+O`)}
190
+ {item(<Download />, 'Save to file…', onSaveToFile, `${MOD}+S`)}
191
+ {item(<ImageDown />, 'Export image…', onExportImage, `${MOD}+Shift+E`)}
192
+ {item(<Users />, 'Live collaboration…', onLiveCollaboration)}
193
+ {item(
194
+ <Plug />,
195
+ agentsHere ? 'AI agent connected' : 'Connect your AI agent…',
196
+ onConnectAgent,
197
+ webMcp.state === 'on' || agentsHere ? (
198
+ <span className={`h-1.5 w-1.5 rounded-full ${STATUS.success.dot}`} />
199
+ ) : undefined
200
+ )}
201
+ {item(<CircleHelp />, 'Help', onHelp, '?')}
202
+ <div className="my-1.5 h-px bg-[rgb(var(--ec-page-border))]" />
203
+ {item(<Pencil />, 'Rename canvas', () => {
204
+ titleInput.current?.focus();
205
+ titleInput.current?.select();
206
+ })}
207
+ {item(<Link2 />, 'Copy link', () => void navigator.clipboard.writeText(shareUrl ?? window.location.href))}
136
208
  {newCanvasUrl && (
137
- <a className={menuItemClass} href={newCanvasUrl}>
138
- <Plus size={14} /> New canvas
209
+ <a className={itemClass} href={newCanvasUrl}>
210
+ <span className={itemIconClass}>
211
+ <Plus />
212
+ </span>
213
+ <span className="min-w-0 flex-1 truncate">New canvas</span>
139
214
  </a>
140
215
  )}
141
216
  {onDelete && (
142
217
  <>
143
- <div className="my-1 h-px bg-[rgb(var(--ec-page-border))]" />
144
- <button
145
- className={`${menuItemLayout} ${STATUS.danger.text} ${STATUS.danger.hover}`}
146
- onClick={() => {
147
- menu.setOpen(false);
148
- onDelete();
149
- }}
150
- >
151
- <Trash2 size={14} /> Delete canvas
152
- </button>
218
+ <div className="my-1.5 h-px bg-[rgb(var(--ec-page-border))]" />
219
+ {item(<Trash2 />, 'Delete canvas', onDelete, undefined, `${STATUS.danger.text} ${STATUS.danger.hover}`)}
153
220
  </>
154
221
  )}
155
- <div className="my-1 h-px bg-[rgb(var(--ec-page-border))]" />
156
- <p className="flex items-center gap-2 px-2.5 py-1.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
222
+ <div className="my-1.5 h-px bg-[rgb(var(--ec-page-border))]" />
223
+ <p className="flex items-center gap-2 px-2 py-1.5 text-[11px] text-[rgb(var(--ec-page-text-muted))]">
157
224
  <span className={`h-1.5 w-1.5 rounded-full ${statusColor}`} />
158
225
  {statusLabel}
159
226
  </p>
@@ -163,48 +230,106 @@ function CanvasMenu({
163
230
  );
164
231
  }
165
232
 
166
- /** You: change the name others see you by (unless you're signed in, when it's the name you signed in with) */
167
- function YouAvatar({ peer, onRename }: { peer: Peer; onRename?: (name: string) => void }) {
233
+ const initialsOf = (name: string) => {
234
+ const words = name.trim().split(/\s+/).filter(Boolean);
235
+ return (words.length > 1 ? words.map((word) => word[0]).join('') : name.trim()).slice(0, 2).toUpperCase();
236
+ };
237
+
238
+ /**
239
+ * Who you are here, top right (as Studio on the web's): your avatar, opening your name and colour to change. They're
240
+ * on your pointer and comments. Signed in, your name and picture are the ones you signed in with.
241
+ */
242
+ function ProfileMenu({
243
+ me,
244
+ onRename,
245
+ onRecolor,
246
+ }: {
247
+ me: { name: string; color: string; picture?: string; agent?: boolean };
248
+ /** Not given when you're signed in */
249
+ onRename?: (name: string) => void;
250
+ onRecolor: (color: string) => void;
251
+ }) {
168
252
  const popover = usePopover();
169
- const [draft, setDraft] = useState(peer.name);
170
- const commit = () => {
171
- const trimmed = draft.trim();
172
- if (trimmed && trimmed !== peer.name) onRename?.(trimmed);
173
- popover.setOpen(false);
253
+ const [draft, setDraft] = useState(me.name);
254
+ useEffect(() => setDraft(me.name), [me.name]);
255
+ const save = () => {
256
+ const next = draft.trim();
257
+ if (!next) return setDraft(me.name);
258
+ if (next !== me.name) onRename?.(next);
174
259
  };
175
-
176
- // Signed in: you're shown in the app's header already
177
- if (!onRename) return null;
260
+ const avatar = (size: string, text: string) => (
261
+ <span
262
+ className={`relative flex ${size} flex-none items-center justify-center rounded-full text-[10px] font-semibold text-white`}
263
+ style={{ background: me.color }}
264
+ >
265
+ {initialsOf(text)}
266
+ <Picture src={me.picture} />
267
+ </span>
268
+ );
269
+ // Here as an AI agent (e.g. a chat's view, as the chat's agent): who you are is its logo, not yours to change
270
+ if (me.agent)
271
+ return (
272
+ <span title={`You're here as ${me.name} (AI agent)`} className={`${barClass} pointer-events-auto w-[38px] justify-center`}>
273
+ <span className="flex h-6 w-6 items-center justify-center rounded-full text-white" style={{ background: me.color }}>
274
+ <AgentMark name={me.name} size={14} />
275
+ </span>
276
+ </span>
277
+ );
178
278
  return (
179
- <div ref={popover.ref} className="relative">
279
+ <div ref={popover.ref} className="pointer-events-auto relative">
180
280
  <button
181
- title={`${peer.name} (you)`}
182
- onClick={() => {
183
- setDraft(peer.name);
184
- popover.setOpen((open) => !open);
185
- }}
186
- className="block rounded-full transition-transform hover:scale-110"
281
+ type="button"
282
+ title={onRename ? `${me.name}: change your name and colour` : `${me.name}: change your colour`}
283
+ onClick={() => popover.setOpen((open) => !open)}
284
+ className={`${barClass} w-[38px] justify-center hover:bg-[rgb(var(--ec-content-hover))]`}
187
285
  >
188
- <Avatar peer={peer} />
286
+ {avatar('h-6 w-6', me.name)}
189
287
  </button>
190
288
  {popover.open && (
191
- <div className={`${menuClass} right-0 w-60 space-y-2 p-3`}>
192
- <label className="block space-y-1.5 text-[11px] font-medium text-[rgb(var(--ec-page-text-muted))]">
193
- Your name on this canvas
194
- <input
195
- autoFocus
196
- value={draft}
197
- onChange={(e) => setDraft(e.target.value)}
198
- onKeyDown={(e) => e.key === 'Enter' && commit()}
199
- className="w-full rounded-md border px-2 py-1.5 text-xs bg-[rgb(var(--ec-input-bg))] border-[rgb(var(--ec-input-border))] text-[rgb(var(--ec-input-text))]"
200
- />
201
- </label>
202
- <button
203
- onClick={commit}
204
- className="w-full rounded-md px-2 py-1.5 text-xs font-medium bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))]"
205
- >
206
- Save
207
- </button>
289
+ <div
290
+ className={`${popoverClass} right-0 z-[70] w-72 rounded-xl border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-card-bg))] p-4 text-[rgb(var(--ec-page-text))] shadow-xl`}
291
+ >
292
+ <div className="flex items-center gap-3">
293
+ {avatar('h-9 w-9 text-xs', draft || me.name)}
294
+ <div className="min-w-0">
295
+ <p className="truncate text-sm font-semibold">{draft || me.name}</p>
296
+ <p className="text-xs text-[rgb(var(--ec-page-text-muted))]">On your pointer and comments</p>
297
+ </div>
298
+ </div>
299
+ {onRename && (
300
+ <label className="mt-4 block text-xs font-medium text-[rgb(var(--ec-page-text-muted))]">
301
+ Your name
302
+ <input
303
+ value={draft}
304
+ maxLength={MAX_NAME_LENGTH}
305
+ onChange={(event) => setDraft(event.target.value)}
306
+ onBlur={save}
307
+ onKeyDown={(event) => event.key === 'Enter' && event.currentTarget.blur()}
308
+ className="mt-1.5 block h-9 w-full rounded-md border border-[rgb(var(--ec-page-border))] bg-[rgb(var(--ec-page-bg))] px-3 text-sm text-[rgb(var(--ec-page-text))] outline-none focus:border-[rgb(var(--ec-accent))]"
309
+ />
310
+ </label>
311
+ )}
312
+ <div className="mt-3 text-xs font-medium text-[rgb(var(--ec-page-text-muted))]">
313
+ Your colour
314
+ <div role="radiogroup" aria-label="Your colour" className="mt-2 flex gap-2">
315
+ {PERSON_COLORS.map((color) => (
316
+ <button
317
+ key={color}
318
+ type="button"
319
+ role="radio"
320
+ aria-checked={color === me.color}
321
+ aria-label={color}
322
+ onClick={() => onRecolor(color)}
323
+ className={`h-6 w-6 rounded-full transition-transform hover:scale-110 ${
324
+ color === me.color
325
+ ? 'ring-2 ring-[rgb(var(--ec-page-text))] ring-offset-2 ring-offset-[rgb(var(--ec-card-bg))]'
326
+ : ''
327
+ }`}
328
+ style={{ background: color }}
329
+ />
330
+ ))}
331
+ </div>
332
+ </div>
208
333
  </div>
209
334
  )}
210
335
  </div>
@@ -212,20 +337,34 @@ function YouAvatar({ peer, onRename }: { peer: Peer; onRename?: (name: string) =
212
337
  }
213
338
 
214
339
  /** Who's here: click someone to jump to them; everyone past the first few is in a list */
340
+ /**
341
+ * Who else is here. An agent is shown once, however many places it's in (e.g. its MCP tools and its chat's canvas
342
+ * view), and not at all when it's you.
343
+ */
344
+ const othersHere = (peers: Peer[], clientId: number | null, me: { name: string; agent?: boolean }) => {
345
+ const agentsSeen = new Set(me.agent ? [me.name] : []);
346
+ return peers.filter((peer) => {
347
+ if (peer.clientId === clientId) return false;
348
+ if (!peer.agent) return true;
349
+ if (agentsSeen.has(peer.name)) return false;
350
+ agentsSeen.add(peer.name);
351
+ return true;
352
+ });
353
+ };
354
+
215
355
  function People({
216
356
  peers,
217
357
  clientId,
358
+ me,
218
359
  onJumpTo,
219
- onRename,
220
360
  }: {
221
361
  peers: Peer[];
222
362
  clientId: number | null;
363
+ me: { name: string; agent?: boolean };
223
364
  onJumpTo: (peer: Peer) => void;
224
- onRename?: (name: string) => void;
225
365
  }) {
226
366
  const overflow = usePopover();
227
- const you = peers.find((peer) => peer.clientId === clientId);
228
- const others = peers.filter((peer) => peer.clientId !== clientId);
367
+ const others = othersHere(peers, clientId, me);
229
368
  const shown = others.slice(0, MAX_AVATARS);
230
369
  const hidden = others.slice(MAX_AVATARS);
231
370
  const describe = (peer: Peer) => `${peer.name}${peer.agent ? ` (AI agent)${peer.activity ? `: ${peer.activity}` : ''}` : ''}`;
@@ -272,7 +411,6 @@ function People({
272
411
  </div>
273
412
  )}
274
413
  </div>
275
- {you && <YouAvatar peer={you} onRename={onRename} />}
276
414
  </div>
277
415
  );
278
416
  }
@@ -285,12 +423,19 @@ export default memo(function CanvasHeader({
285
423
  presence,
286
424
  clientId,
287
425
  onJumpTo,
426
+ me,
288
427
  onRename,
428
+ onRecolor,
289
429
  onConnectAgent,
290
430
  webMcp,
291
431
  shareUrl,
292
432
  newCanvasUrl,
293
433
  onShare,
434
+ onOpenFile,
435
+ onSaveToFile,
436
+ onExportImage,
437
+ onLiveCollaboration,
438
+ onHelp,
294
439
  canvasStatus,
295
440
  statusHistory,
296
441
  onStatusChange,
@@ -306,19 +451,30 @@ export default memo(function CanvasHeader({
306
451
  presence: PresenceStore | null;
307
452
  clientId: number | null;
308
453
  onJumpTo: (peer: Peer) => void;
454
+ /** You: your name, colour and (signed in) picture */
455
+ me: { name: string; color: string; picture?: string; agent?: boolean };
309
456
  /** Not given when you're signed in */
310
457
  onRename?: (name: string) => void;
458
+ onRecolor: (color: string) => void;
311
459
  onConnectAgent: () => void;
312
460
  webMcp: WebMcpStatus;
313
461
  shareUrl?: string;
314
462
  newCanvasUrl?: string;
315
463
  /** Open the share dialog */
316
464
  onShare: () => void;
465
+ /** Opens a .eventcatalog file as a new canvas (not given where it can't, e.g. inside a chat) */
466
+ onOpenFile?: () => void;
467
+ onSaveToFile: () => void;
468
+ onExportImage: () => void;
469
+ onLiveCollaboration: () => void;
470
+ onHelp: () => void;
317
471
  /** Ask to delete the canvas (not given where it can't be, e.g. inside a chat) */
318
472
  onDelete?: () => void;
319
473
  }) {
320
474
  const peers = usePeople(presence);
321
475
  const agentsHere = peers.some((peer) => peer.agent);
476
+ // Who else is here (you're your own avatar, next to it)
477
+ const showPeople = othersHere(peers, clientId, me).length > 0;
322
478
 
323
479
  return (
324
480
  <div className="pointer-events-none absolute left-3 right-3 top-3 z-10 flex items-start justify-between gap-3">
@@ -329,39 +485,31 @@ export default memo(function CanvasHeader({
329
485
  onRetitle={onRetitle}
330
486
  shareUrl={shareUrl}
331
487
  newCanvasUrl={newCanvasUrl}
488
+ onConnectAgent={onConnectAgent}
489
+ webMcp={webMcp}
490
+ agentsHere={agentsHere}
332
491
  onDelete={onDelete}
492
+ onOpenFile={onOpenFile}
493
+ onSaveToFile={onSaveToFile}
494
+ onExportImage={onExportImage}
495
+ onLiveCollaboration={onLiveCollaboration}
496
+ onHelp={onHelp}
333
497
  >
334
498
  <CanvasStatusMenu status={canvasStatus} history={statusHistory} onChange={onStatusChange} />
335
499
  </CanvasMenu>
336
500
 
337
- <div className={`${barClass} gap-2 px-1.5`}>
338
- <div className="pl-1">
339
- <People peers={peers} clientId={clientId} onJumpTo={onJumpTo} onRename={onRename} />
340
- </div>
341
- <div className="h-5 w-px bg-[rgb(var(--ec-page-border))]" />
342
- <button
343
- onClick={onConnectAgent}
344
- title={
345
- webMcp.state === 'on'
346
- ? `Connect an AI agent to this canvas. WebMCP is on in this tab${webMcp.lastCall ? ` (last: ${webMcp.lastCall})` : ''}`
347
- : 'Connect an AI agent to this canvas'
348
- }
349
- // A button of its own next to Share (the two ways to bring someone in): people, or an AI agent
350
- className="relative flex h-7 items-center gap-1.5 rounded-lg border px-2.5 text-xs font-semibold transition-colors border-[rgb(var(--ec-accent)/0.35)] bg-[rgb(var(--ec-accent-subtle))] text-[rgb(var(--ec-accent))] hover:border-[rgb(var(--ec-accent)/0.7)] hover:bg-[rgb(var(--ec-accent)/0.15)]"
351
- >
352
- <Bot size={15} />
353
- {agentsHere ? 'Agent connected' : 'Connect agent'}
354
- {(webMcp.state === 'on' || agentsHere) && (
355
- <span
356
- className={`absolute -right-1 -top-1 h-2.5 w-2.5 rounded-full ring-2 ring-[rgb(var(--ec-card-bg))] ${STATUS.success.dot}`}
357
- />
358
- )}
359
- </button>
501
+ <div className="flex items-center gap-2">
502
+ {showPeople && (
503
+ <div className={`${barClass} px-1.5`}>
504
+ <People peers={peers} clientId={clientId} me={me} onJumpTo={onJumpTo} />
505
+ </div>
506
+ )}
507
+ <ProfileMenu me={me} onRename={onRename} onRecolor={onRecolor} />
360
508
  <button
361
509
  onClick={onShare}
362
- className="flex h-7 items-center gap-1.5 rounded-lg px-3 text-xs font-semibold bg-[rgb(var(--ec-button-bg))] text-[rgb(var(--ec-button-text))] hover:bg-[rgb(var(--ec-button-bg-hover))]"
510
+ className="pointer-events-auto inline-flex h-[38px] items-center gap-2 rounded-[9px] bg-[rgb(var(--ec-page-text))] px-4 text-sm font-semibold text-[rgb(var(--ec-page-bg))] shadow-sm transition-colors hover:bg-[rgb(var(--ec-page-text)/0.85)]"
363
511
  >
364
- <UserPlus size={14} />
512
+ <Share2 size={16} aria-hidden />
365
513
  Share
366
514
  </button>
367
515
  </div>
@@ -1,8 +1,13 @@
1
1
  import { memo, useEffect, useRef, useState } from 'react';
2
2
  import { Check, ChevronDown } from 'lucide-react';
3
- import { CANVAS_STATUSES, type CanvasStatus, type StatusChange } from '../canvas-doc';
4
- import { timeAgo } from './Comments';
5
- import { CANVAS_STATUS_LOOK } from './status';
3
+ import { DIAGRAM_MENU, DIAGRAM_MENU_ITEM, DIAGRAM_MENU_SEPARATOR } from '@eventcatalog/visualiser';
4
+ import {
5
+ CANVAS_STATUSES,
6
+ type CanvasStatus,
7
+ type StatusChange,
8
+ timeAgo,
9
+ CANVAS_STATUS_LOOK,
10
+ } from '@eventcatalog/visualiser/studio';
6
11
 
7
12
  /** Status changes shown in the menu, newest first */
8
13
  const HISTORY_SHOWN = 8;
@@ -58,18 +63,14 @@ export default memo(function CanvasStatusMenu({
58
63
  <ChevronDown size={12} />
59
64
  </button>
60
65
  {open && (
61
- <div className="absolute left-0 top-full z-50 mt-2 w-72 rounded-xl border p-1 shadow-xl bg-[rgb(var(--ec-card-bg))] border-[rgb(var(--ec-page-border))] text-[rgb(var(--ec-page-text))]">
62
- <p className="px-2.5 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
66
+ <div className={`absolute left-0 top-full mt-2 w-72 ${DIAGRAM_MENU}`}>
67
+ <p className="px-3 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
63
68
  Status
64
69
  </p>
65
70
  {CANVAS_STATUSES.map((value) => {
66
71
  const option = CANVAS_STATUS_LOOK[value];
67
72
  return (
68
- <button
69
- key={value}
70
- onClick={() => choose(value)}
71
- className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left hover:bg-[rgb(var(--ec-page-border)/0.5)]"
72
- >
73
+ <button key={value} onClick={() => choose(value)} className={`${DIAGRAM_MENU_ITEM} w-full text-left`}>
73
74
  <span className={`h-2 w-2 shrink-0 rounded-full ${option.dot}`} />
74
75
  <span className="min-w-0 flex-1">
75
76
  <span className="block text-xs font-medium">{option.label}</span>
@@ -79,7 +80,7 @@ export default memo(function CanvasStatusMenu({
79
80
  </button>
80
81
  );
81
82
  })}
82
- <div className="px-2.5 py-2">
83
+ <div className="px-3 py-2">
83
84
  <input
84
85
  value={note}
85
86
  onChange={(event) => setNote(event.target.value)}
@@ -90,11 +91,11 @@ export default memo(function CanvasStatusMenu({
90
91
  </div>
91
92
  {recent.length > 0 && (
92
93
  <>
93
- <div className="my-1 h-px bg-[rgb(var(--ec-page-border))]" />
94
- <p className="px-2.5 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
94
+ <div className={DIAGRAM_MENU_SEPARATOR} />
95
+ <p className="px-3 pb-1 pt-1.5 text-[11px] font-semibold uppercase tracking-wide text-[rgb(var(--ec-page-text-muted))]">
95
96
  History
96
97
  </p>
97
- <ul className="max-h-48 space-y-1.5 overflow-y-auto px-2.5 pb-2">
98
+ <ul className="max-h-48 space-y-1.5 overflow-y-auto px-3 pb-2">
98
99
  {recent.map((change) => (
99
100
  <li key={`${change.at}-${change.status}`} className="text-[11px]">
100
101
  <span className="flex items-center gap-1.5">