diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..0839f12
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,2 @@
+VITE_SUPABASE_URL=https://your-project.supabase.co
+VITE_SUPABASE_ANON_KEY=your-anon-key-here
diff --git a/.gitignore b/.gitignore
index 8404604..0f13008 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,8 @@
node_modules
dist
.DS_Store
+.env
+.env.local
*.md
!README.md
!src/pages/docs/userGuide.md
diff --git a/package-lock.json b/package-lock.json
index c51639f..1cac420 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,7 @@
"name": "drawd",
"version": "1.0.0",
"dependencies": {
+ "@supabase/supabase-js": "^2.99.2",
"@vercel/analytics": "^2.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
@@ -1596,6 +1597,86 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@supabase/auth-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.99.2.tgz",
+ "integrity": "sha512-uRGNXMKEw4VhwouNW7N0XDAGqJP9redHNDmWi17dTrcO1lvFfyRiXsqqfgnVC8aqtRn8kLkLPEzHjiRWsni+oQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.99.2.tgz",
+ "integrity": "sha512-xuXQARvjdfB1UPK1yUceZ5EGjOLkVz4rBAaloS9foXiAuseWEdgWBCxkIAFRxGBLGX8Uzo8kseq90jhPb+07Vg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.99.2.tgz",
+ "integrity": "sha512-ueiOVkbkTQ7RskwVmjR8zxWYw3VKOMxo1+qep+Dx/SgApqyhWBGd92waQb45tbLc7ydB5x8El8utXOLQTuTojQ==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.99.2.tgz",
+ "integrity": "sha512-J6Jm9601dkpZf3+EJ48ki2pM4sFtCNm/BI0l8iEnrczgg+JSEQkMoOW5VSpM54t0pNs69bsz5PTmYJahDZKiIQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/phoenix": "^1.6.6",
+ "@types/ws": "^8.18.1",
+ "tslib": "2.8.1",
+ "ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.99.2.tgz",
+ "integrity": "sha512-V/FF8kX8JGSefsVCG1spCLSrHdNR/JFeUMn1jS9KG/Eizjx+evtdKQKLJXFgIylY/bKTXKhc2SYDPIGrIhzsug==",
+ "license": "MIT",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.99.2",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.99.2.tgz",
+ "integrity": "sha512-179rn5wq0wBAqqGwAwR7TUGg2NOaP+fkd5FCVbYJXby85fsRNPFoNJN8YRBepqX2tN7JJcnTjqaAMXuNjiyisA==",
+ "license": "MIT",
+ "dependencies": {
+ "@supabase/auth-js": "2.99.2",
+ "@supabase/functions-js": "2.99.2",
+ "@supabase/postgrest-js": "2.99.2",
+ "@supabase/realtime-js": "2.99.2",
+ "@supabase/storage-js": "2.99.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@@ -1730,6 +1811,30 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/node": {
+ "version": "25.5.0",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
+ "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@types/phoenix": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
+ "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==",
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@vercel/analytics": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-2.0.1.tgz",
@@ -3586,6 +3691,15 @@
"node": ">= 14"
}
},
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -5407,6 +5521,12 @@
"node": ">=20"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -5527,6 +5647,12 @@
"node": ">=20.18.1"
}
},
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "license": "MIT"
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -5901,6 +6027,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
diff --git a/package.json b/package.json
index 355cb0d..673342d 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"test": "vitest run"
},
"dependencies": {
+ "@supabase/supabase-js": "^2.99.2",
"@vercel/analytics": "^2.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0"
diff --git a/src/App.jsx b/src/App.jsx
index d3dfa4b..da6d656 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -8,11 +8,17 @@ const Drawd = lazy(() => import("./Drawd"));
function getRoute() {
const hash = window.location.hash;
- if (hash === "#/editor") return "editor";
+ if (hash.startsWith("#/editor")) return "editor";
if (hash === "#/docs") return "docs";
return "landing";
}
+function getRoomCodeFromHash() {
+ const hash = window.location.hash;
+ const match = hash.match(/[?&]room=([A-Za-z0-9]+)/);
+ return match ? match[1].toUpperCase() : null;
+}
+
export default function App() {
const [route, setRoute] = useState(getRoute);
@@ -45,7 +51,7 @@ export default function App() {
}
>
-
+
);
} else if (route === "docs") {
diff --git a/src/Drawd.jsx b/src/Drawd.jsx
index 98c213a..b726966 100644
--- a/src/Drawd.jsx
+++ b/src/Drawd.jsx
@@ -1,4 +1,4 @@
-import { useState, useCallback, useEffect } from "react";
+import { useState, useCallback, useEffect, useRef } from "react";
import { COLORS, FONTS, FONT_LINK, Z_INDEX } from "./styles/theme";
import { HEADER_HEIGHT, DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT, FILE_EXTENSION, LEGACY_FILE_EXTENSION } from "./constants";
import { generateInstructionFiles } from "./utils/generateInstructionFiles";
@@ -12,6 +12,7 @@ import { useCanvasMouseHandlers } from "./hooks/useCanvasMouseHandlers";
import { useImportExport } from "./hooks/useImportExport";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { useCanvasSelection } from "./hooks/useCanvasSelection";
+import { useCollaboration } from "./hooks/useCollaboration";
import { ScreenNode } from "./components/ScreenNode";
import { ConnectionLines } from "./components/ConnectionLines";
import { HotspotModal } from "./components/HotspotModal";
@@ -36,9 +37,15 @@ import { ScreenGroup } from "./components/ScreenGroup";
import { importFlow } from "./utils/importFlow";
import { detectDrawdFile } from "./utils/detectDrawdFile";
import { generateId } from "./utils/generateId";
+import { ShareModal } from "./components/ShareModal";
+import { CollabPresence } from "./components/CollabPresence";
+import { CollabBadge } from "./components/CollabBadge";
+import { RemoteCursors } from "./components/RemoteCursors";
+import { HostLeftModal } from "./components/HostLeftModal";
+import { ParticipantsPanel } from "./components/ParticipantsPanel";
-export default function Drawd() {
+export default function Drawd({ initialRoomCode }) {
// ── Active tool ──────────────────────────────────────────────────────────
const [activeTool, setActiveTool] = useState("select");
@@ -53,7 +60,7 @@ export default function Drawd() {
fileInputRef, addScreen, addScreenAtCenter, removeScreen, removeScreens, renameScreen, moveScreen, moveScreens,
handleImageUpload, onFileChange, handlePaste, handleCanvasDrop,
saveHotspot, deleteHotspot, deleteHotspots, moveHotspot, moveHotspotToScreen, resizeHotspot, updateScreenDimensions,
- updateScreenDescription, updateScreenNotes, updateScreenTbd, updateScreenRoles, updateScreenCodeRef, updateScreenCriteria, assignScreenImage, quickConnectHotspot,
+ updateScreenDescription, updateScreenNotes, updateScreenTbd, updateScreenRoles, updateScreenCodeRef, updateScreenCriteria, assignScreenImage, patchScreenImage, quickConnectHotspot,
updateConnection, deleteConnection,
addConnection, convertToConditionalGroup, addToConditionalGroup, saveConnectionGroup, deleteConnectionGroup,
addState, updateStateName, addDocument, updateDocument, deleteDocument,
@@ -143,6 +150,70 @@ export default function Drawd() {
setDataModels((prev) => prev.filter((m) => m.id !== id));
}, []);
+ // ── Collaboration ──────────────────────────────────────────────────────────
+ const [showShareModal, setShowShareModal] = useState(!!initialRoomCode);
+ const [showParticipants, setShowParticipants] = useState(false);
+ const pendingRemoteStateRef = useRef(null);
+
+ const screensRef = useRef(screens);
+ useEffect(() => { screensRef.current = screens; }, [screens]);
+
+ const applyRemotePayload = useCallback((payload) => {
+ const incomingScreens = payload.screens || [];
+ // Preserve existing imageData for screens that arrive without it.
+ // buildCollabPayload strips imageData to stay under Supabase's size limit,
+ // so we merge with the guest's current images to avoid flicker.
+ const currentScreens = screensRef.current;
+ const merged = incomingScreens.map((s) => {
+ if (!s.imageData) {
+ const existing = currentScreens.find((e) => e.id === s.id);
+ if (existing?.imageData) {
+ return { ...s, imageData: existing.imageData };
+ }
+ }
+ return s;
+ });
+ replaceAll(
+ merged,
+ payload.connections || [],
+ merged.length + 1,
+ payload.documents || [],
+ );
+ if (payload.featureBrief !== undefined) setFeatureBrief(payload.featureBrief);
+ if (payload.taskLink !== undefined) setTaskLink(payload.taskLink);
+ if (payload.techStack !== undefined) setTechStack(payload.techStack);
+ if (payload.dataModels !== undefined) setDataModels(payload.dataModels);
+ if (payload.stickyNotes !== undefined) setStickyNotes(payload.stickyNotes);
+ if (payload.screenGroups !== undefined) setScreenGroups(payload.screenGroups);
+ }, [replaceAll]);
+
+ const applyPendingRemoteState = useCallback((payload) => {
+ applyRemotePayload(payload);
+ }, [applyRemotePayload]);
+
+ const collab = useCollaboration({
+ screens, connections, documents,
+ featureBrief, taskLink, techStack,
+ dataModels, stickyNotes, screenGroups,
+ applyRemoteState: (payload) => {
+ // If user is mid-drag, queue the update
+ if (dragging || hotspotInteraction?.mode === "draw" || hotspotInteraction?.mode === "reposition" || hotspotInteraction?.mode === "resize") {
+ pendingRemoteStateRef.current = payload;
+ return;
+ }
+ applyRemotePayload(payload);
+ },
+ applyRemoteImage: patchScreenImage,
+ canvasRef, pan, zoom,
+ });
+
+ const isReadOnly = collab.isReadOnly;
+
+ // Auto-close participants panel when disconnecting
+ useEffect(() => {
+ if (!collab.isConnected) setShowParticipants(false);
+ }, [collab.isConnected]);
+
// BFS forward from scopeRoot following connections
const scopeScreenIds = scopeRoot ? (() => {
const visited = new Set([scopeRoot]);
@@ -346,6 +417,9 @@ export default function Drawd() {
activeTool,
setSelectedStickyNote,
setSelectedScreenGroup,
+ isReadOnly,
+ pendingRemoteStateRef,
+ applyPendingRemoteState,
});
// ── Import / export ────────────────────────────────────────────────────────────────
@@ -380,6 +454,7 @@ export default function Drawd() {
useKeyboardShortcuts({
hotspotModal, connectionEditModal, renameModal, importConfirm,
showInstructions, showDocuments, showShortcuts, setShowShortcuts,
+ showParticipants,
conditionalPrompt, editingConditionGroup,
connecting, cancelConnecting,
hotspotInteraction, cancelHotspotInteraction,
@@ -392,6 +467,7 @@ export default function Drawd() {
selectedScreenGroup, setSelectedScreenGroup, deleteScreenGroup,
undo, redo, saveNow, isFileSystemSupported, onSaveAs, onExport, onOpen,
setActiveTool,
+ isReadOnly,
});
// ── Paste handler ───────────────────────────────────────────────────────────────
@@ -557,6 +633,25 @@ export default function Drawd() {
onNew={onNew}
onOpen={onOpen}
onSaveAs={onSaveAs}
+ collabState={collab}
+ onShare={() => setShowShareModal(true)}
+ collabBadge={collab.isConnected ? (
+
+ ) : null}
+ collabPresence={collab.isConnected ? (
+
+ ) : null}
+ onToggleParticipants={() => setShowParticipants((v) => !v)}
+ showParticipants={showParticipants}
/>
@@ -575,6 +670,7 @@ export default function Drawd() {
onTaskLinkChange={setTaskLink}
techStack={techStack}
onTechStackChange={setTechStack}
+ isReadOnly={isReadOnly}
/>
{/* Canvas */}
@@ -674,6 +770,7 @@ export default function Drawd() {
isMultiSelected={canvasSelection.some((i) => i.type === "screen" && i.id === screen.id)}
onToggleSelect={toggleSelection}
onMultiDragStart={onMultiDragStart}
+ isReadOnly={isReadOnly}
/>
))}
{stickyNotes.map((note) => (
@@ -750,6 +847,7 @@ export default function Drawd() {
onCancel={onConditionalPromptCancel}
/>
)}
+ {collab.isConnected &&
}
{editingConditionGroup && (
addScreenAtCenter()}
+ isReadOnly={isReadOnly}
onAddStickyNote={() => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
@@ -911,6 +1010,7 @@ export default function Drawd() {
onUpdateCodeRef={updateScreenCodeRef}
onUpdateCriteria={updateScreenCriteria}
onUpdateStatus={updateScreenStatus}
+ isReadOnly={isReadOnly}
/>
)}
@@ -1022,7 +1122,48 @@ export default function Drawd() {
/>
)}
+ {showParticipants && collab.isConnected && (
+ setShowParticipants(false)}
+ />
+ )}
+
{showShortcuts && setShowShortcuts(false)} />}
+
+ {showShareModal && (
+ {
+ collab.createRoom(name, color);
+ setShowShareModal(false);
+ }}
+ onJoinRoom={(code, name, color) => {
+ collab.joinRoom(code, name, color);
+ setShowShareModal(false);
+ }}
+ onClose={() => setShowShareModal(false)}
+ />
+ )}
+
+ {collab.hostLeft && (
+ {
+ collab.dismissHostLeft();
+ collab.leaveRoom();
+ }}
+ onLeave={() => {
+ collab.dismissHostLeft();
+ collab.leaveRoom();
+ }}
+ />
+ )}
);
}
diff --git a/src/collab/supabaseClient.js b/src/collab/supabaseClient.js
new file mode 100644
index 0000000..9ae9b72
--- /dev/null
+++ b/src/collab/supabaseClient.js
@@ -0,0 +1,6 @@
+import { createClient } from "@supabase/supabase-js";
+
+const url = import.meta.env.VITE_SUPABASE_URL;
+const key = import.meta.env.VITE_SUPABASE_ANON_KEY;
+
+export const supabase = url && key ? createClient(url, key) : null;
diff --git a/src/components/CollabBadge.jsx b/src/components/CollabBadge.jsx
new file mode 100644
index 0000000..f0e8241
--- /dev/null
+++ b/src/components/CollabBadge.jsx
@@ -0,0 +1,98 @@
+import { useState } from "react";
+import { COLORS, FONTS } from "../styles/theme";
+import { COPY_FEEDBACK_MS } from "../constants";
+
+export function CollabBadge({ roomCode, isReadOnly, isConnected, onLeave }) {
+ const [copied, setCopied] = useState(false);
+
+ const copyCode = () => {
+ navigator.clipboard.writeText(roomCode).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), COPY_FEEDBACK_MS);
+ });
+ };
+
+ return (
+
+ {/* Room code pill */}
+
+
+ {/* Viewing badge */}
+ {isReadOnly && (
+
+ Viewing
+
+ )}
+
+ {/* Leave button */}
+
+
+ );
+}
diff --git a/src/components/CollabPresence.jsx b/src/components/CollabPresence.jsx
new file mode 100644
index 0000000..2a620e9
--- /dev/null
+++ b/src/components/CollabPresence.jsx
@@ -0,0 +1,158 @@
+import { useState, useRef, useEffect } from "react";
+import { COLORS, FONTS } from "../styles/theme";
+
+function PeerCircle({ peer, isHost, onSetRole }) {
+ const [hovered, setHovered] = useState(false);
+ const [menuOpen, setMenuOpen] = useState(false);
+ const menuRef = useRef(null);
+
+ useEffect(() => {
+ if (!menuOpen) return;
+ const onClick = (e) => {
+ if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false);
+ };
+ document.addEventListener("mousedown", onClick);
+ return () => document.removeEventListener("mousedown", onClick);
+ }, [menuOpen]);
+
+ const initial = (peer.displayName || "?")[0].toUpperCase();
+ const isViewer = peer.role === "viewer";
+ const isPeerHost = peer.role === "host";
+
+ return (
+
+
setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ onClick={() => { if (isHost && !isPeerHost) setMenuOpen((v) => !v); }}
+ style={{
+ width: 28,
+ height: 28,
+ borderRadius: "50%",
+ background: peer.color,
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "center",
+ fontSize: 12,
+ fontWeight: 700,
+ fontFamily: FONTS.mono,
+ color: "#fff",
+ cursor: isHost && !isPeerHost ? "pointer" : "default",
+ position: "relative",
+ border: "2px solid rgba(255,255,255,0.15)",
+ transition: "transform 0.15s",
+ transform: hovered ? "scale(1.1)" : "scale(1)",
+ }}
+ >
+ {initial}
+ {/* Host crown */}
+ {isPeerHost && (
+
+ ♚
+
+ )}
+ {/* Viewer eye */}
+ {isViewer && (
+
+ ◉
+
+ )}
+
+
+ {/* Tooltip */}
+ {hovered && !menuOpen && (
+
+ {peer.displayName} ({peer.role})
+
+ )}
+
+ {/* Role dropdown (host only) */}
+ {menuOpen && isHost && !isPeerHost && (
+
+
+ {peer.displayName}
+
+ {["editor", "viewer"].map((r) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function CollabPresence({ peers, isHost, onSetRole }) {
+ if (peers.length === 0) return null;
+
+ return (
+
+ {peers.map((peer) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/HostLeftModal.jsx b/src/components/HostLeftModal.jsx
new file mode 100644
index 0000000..61bedde
--- /dev/null
+++ b/src/components/HostLeftModal.jsx
@@ -0,0 +1,28 @@
+import { styles, COLORS, FONTS } from "../styles/theme";
+
+export function HostLeftModal({ onKeepState, onLeave }) {
+ return (
+
+
+
Session Ended
+
+ The host has left the collaboration session. You can keep the current state and continue working locally, or leave the session.
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ParticipantsPanel.jsx b/src/components/ParticipantsPanel.jsx
new file mode 100644
index 0000000..6c1c98d
--- /dev/null
+++ b/src/components/ParticipantsPanel.jsx
@@ -0,0 +1,283 @@
+import { useState, useRef, useEffect } from "react";
+import { COLORS, FONTS, Z_INDEX } from "../styles/theme";
+import { TOPBAR_HEIGHT, PARTICIPANTS_PANEL_WIDTH } from "../constants";
+
+function RoleDropdown({ peer, onSetRole }) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const onClick = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) setOpen(false);
+ };
+ document.addEventListener("mousedown", onClick);
+ return () => document.removeEventListener("mousedown", onClick);
+ }, [open]);
+
+ return (
+
+
+ {open && (
+
+ {["editor", "viewer"].map((r) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function RoleBadge({ role }) {
+ if (role === "host") {
+ return (
+
+ ♚ Host
+
+ );
+ }
+ if (role === "viewer") {
+ return (
+
+ ◉ Viewer
+
+ );
+ }
+ return (
+
+ Editor
+
+ );
+}
+
+function ParticipantRow({ name, color, role, isSelf, isHost, peer, onSetRole }) {
+ const initial = (name || "?")[0].toUpperCase();
+
+ return (
+
+
+ {initial}
+
+
+
+ {name}
+ {isSelf && (
+ (You)
+ )}
+
+
+ {/* Host can change peer roles, but not their own */}
+ {isHost && peer && !isSelf ? (
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export function ParticipantsPanel({ peers, selfDisplayName, selfColor, selfRole, isHost, onSetRole, onClose }) {
+ const totalCount = 1 + peers.length;
+ const sortedPeers = [...peers].sort((a, b) =>
+ (a.displayName || "").localeCompare(b.displayName || "")
+ );
+
+ return (
+
+ {/* Header */}
+
+
+
+ Participants
+
+
+ {totalCount}
+
+
+
+
+
+ {/* Participant list */}
+
+ {/* Self always first */}
+
+
+ {/* Peers sorted alphabetically */}
+ {sortedPeers.map((peer) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/RemoteCursors.jsx b/src/components/RemoteCursors.jsx
new file mode 100644
index 0000000..ecc63cf
--- /dev/null
+++ b/src/components/RemoteCursors.jsx
@@ -0,0 +1,82 @@
+import { useState, useEffect, useRef } from "react";
+import { FONTS, Z_INDEX } from "../styles/theme";
+import { COLLAB_CURSOR_FADE_MS } from "../constants";
+
+function CursorArrow({ color }) {
+ return (
+
+ );
+}
+
+function RemoteCursor({ cursor }) {
+ const [labelVisible, setLabelVisible] = useState(true);
+ const fadeTimerRef = useRef(null);
+ const prevPosRef = useRef({ x: cursor.x, y: cursor.y });
+
+ useEffect(() => {
+ // Detect movement
+ if (cursor.x !== prevPosRef.current.x || cursor.y !== prevPosRef.current.y) {
+ prevPosRef.current = { x: cursor.x, y: cursor.y };
+ setLabelVisible(true);
+ if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current);
+ fadeTimerRef.current = setTimeout(() => setLabelVisible(false), COLLAB_CURSOR_FADE_MS);
+ }
+ return () => {
+ if (fadeTimerRef.current) clearTimeout(fadeTimerRef.current);
+ };
+ }, [cursor.x, cursor.y]);
+
+ return (
+
+
+
+ {cursor.displayName}
+
+
+ );
+}
+
+export function RemoteCursors({ cursors }) {
+ if (!cursors || cursors.length === 0) return null;
+
+ return (
+ <>
+ {cursors.map((c) => (
+
+ ))}
+ >
+ );
+}
diff --git a/src/components/ScreenNode.jsx b/src/components/ScreenNode.jsx
index 96e6b91..6ac120c 100644
--- a/src/components/ScreenNode.jsx
+++ b/src/components/ScreenNode.jsx
@@ -11,6 +11,7 @@ export function ScreenNode({
onUpdateDescription, isSpaceHeld, onAddState, onDropImage, activeTool,
scopeRoot, isInScope, onContextMenu,
isMultiSelected, onToggleSelect, onMultiDragStart,
+ isReadOnly,
}) {
const [imgLoaded, setImgLoaded] = useState(false);
const [isEditingDesc, setIsEditingDesc] = useState(false);
@@ -76,6 +77,7 @@ export function ScreenNode({
if (e.target.closest(".connection-dot-right")) return;
if (e.target.closest(".hotspot-drag-handle")) return;
if (e.target.closest(".description-area")) return;
+ if (isReadOnly) { onSelect(screen.id); return; }
if (e.shiftKey || e.metaKey) {
e.stopPropagation();
onToggleSelect?.("screen", screen.id);
@@ -274,7 +276,7 @@ export function ScreenNode({
{/* Action buttons */}
-
+ {!isReadOnly &&
-
+
}
{/* Image */}
diff --git a/src/components/ScreensPanel.jsx b/src/components/ScreensPanel.jsx
index 7298a0b..72040bd 100644
--- a/src/components/ScreensPanel.jsx
+++ b/src/components/ScreensPanel.jsx
@@ -17,6 +17,7 @@ export function ScreensPanel({
onTaskLinkChange,
techStack,
onTechStackChange,
+ isReadOnly,
}) {
const [briefOpen, setBriefOpen] = useState(false);
const [techOpen, setTechOpen] = useState(false);
diff --git a/src/components/ShareModal.jsx b/src/components/ShareModal.jsx
new file mode 100644
index 0000000..47fd11a
--- /dev/null
+++ b/src/components/ShareModal.jsx
@@ -0,0 +1,231 @@
+import { useState, useEffect } from "react";
+import { COLORS, FONTS, styles } from "../styles/theme";
+import { COPY_FEEDBACK_MS, DOMAIN } from "../constants";
+
+export function ShareModal({ onClose, onCreateRoom, onJoinRoom, initialRoomCode, isCollabAvailable }) {
+ const [tab, setTab] = useState(initialRoomCode ? "join" : "create");
+ const [displayName, setDisplayName] = useState("");
+ const [color, setColor] = useState(COLORS.cursorPalette[0]);
+ const [joinCode, setJoinCode] = useState(initialRoomCode || "");
+ const [createdCode, setCreatedCode] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === "Escape") onClose(); };
+ document.addEventListener("keydown", onKey);
+ return () => document.removeEventListener("keydown", onKey);
+ }, [onClose]);
+
+ const handleCreate = () => {
+ if (!displayName.trim()) return;
+ onCreateRoom(displayName.trim(), color);
+ };
+
+ const handleJoin = () => {
+ if (!displayName.trim() || joinCode.trim().length < 6) return;
+ onJoinRoom(joinCode.trim().toUpperCase(), displayName.trim(), color);
+ };
+
+ const copyCode = (code) => {
+ navigator.clipboard.writeText(code).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), COPY_FEEDBACK_MS);
+ });
+ };
+
+ const shareLink = createdCode ? `https://${DOMAIN}/#/editor?room=${createdCode}` : "";
+
+ if (!isCollabAvailable) {
+ return (
+
+
e.stopPropagation()}>
+
Share Session
+
+ Real-time collaboration requires Supabase configuration. Add VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY to your .env file.
+
+
+
+
+ );
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
Share Session
+
+ {/* Tab switcher */}
+
+ {["create", "join"].map((t) => (
+
+ ))}
+
+
+ {/* Display name */}
+
+
+ setDisplayName(e.target.value)}
+ placeholder="Your name"
+ autoFocus
+ style={styles.input}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") {
+ if (tab === "create") handleCreate();
+ else handleJoin();
+ }
+ }}
+ />
+
+
+ {/* Color picker */}
+
+
+
+ {COLORS.cursorPalette.map((c) => (
+
+
+
+ {tab === "create" && !createdCode && (
+
+ )}
+
+ {tab === "create" && createdCode && (
+
+
+ Room Code
+
+
copyCode(createdCode)}
+ style={{
+ fontSize: 32,
+ fontWeight: 700,
+ fontFamily: FONTS.mono,
+ color: COLORS.accent,
+ letterSpacing: "0.2em",
+ cursor: "pointer",
+ padding: "12px 0",
+ background: COLORS.accent008,
+ borderRadius: 12,
+ border: `1px solid ${COLORS.accent025}`,
+ marginBottom: 12,
+ userSelect: "all",
+ }}
+ title="Click to copy"
+ >
+ {createdCode}
+
+
+ {copied ? "Copied!" : "Click code to copy"}
+
+
+
+
+
+ )}
+
+ {tab === "join" && (
+ <>
+
+
+ setJoinCode(e.target.value.toUpperCase().slice(0, 6))}
+ placeholder="XXXXXX"
+ maxLength={6}
+ style={{ ...styles.input, fontSize: 18, fontWeight: 700, letterSpacing: "0.15em", textAlign: "center" }}
+ onKeyDown={(e) => { if (e.key === "Enter") handleJoin(); }}
+ />
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/ShortcutsPanel.jsx b/src/components/ShortcutsPanel.jsx
index 0603ba3..b61d7a4 100644
--- a/src/components/ShortcutsPanel.jsx
+++ b/src/components/ShortcutsPanel.jsx
@@ -36,6 +36,13 @@ const SHORTCUTS = [
{ keys: ["\u2318/Ctrl", "O"], desc: "Open file" },
],
},
+ {
+ category: "Collaboration",
+ items: [
+ { keys: ["Share button"], desc: "Create or join a collaboration room" },
+ { keys: ["Click room code"], desc: "Copy room code to clipboard" },
+ ],
+ },
];
const kbdStyle = {
diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx
index 08ce012..2f63b9e 100644
--- a/src/components/Sidebar.jsx
+++ b/src/components/Sidebar.jsx
@@ -2,7 +2,7 @@ import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE } from "../styles/theme";
import { useState } from "react";
import { SIDEBAR_WIDTH } from "../constants";
-export function Sidebar({ screen, screens, connections, onClose, onRename, onAddHotspot, onEditHotspot, onAddState, onSelectScreen, onUpdateStateName, onUpdateNotes, onUpdateCodeRef, onUpdateCriteria, onUpdateStatus, onUpdateTbd, onUpdateRoles }) {
+export function Sidebar({ screen, screens, connections, onClose, onRename, onAddHotspot, onEditHotspot, onAddState, onSelectScreen, onUpdateStateName, onUpdateNotes, onUpdateCodeRef, onUpdateCriteria, onUpdateStatus, onUpdateTbd, onUpdateRoles, isReadOnly }) {
const [draftNotes, setDraftNotes] = useState(screen.notes || "");
const [notesScreenId, setNotesScreenId] = useState(screen.id);
const [draftCodeRef, setDraftCodeRef] = useState(screen.codeRef || "");
@@ -86,21 +86,23 @@ export function Sidebar({ screen, screens, connections, onClose, onRename, onAdd
{screen.name}
-
+ {!isReadOnly && (
+
+ )}
{/* TBD toggle */}
@@ -306,12 +308,13 @@ export function Sidebar({ screen, screens, connections, onClose, onRename, onAdd