diff --git a/src/Drawd.jsx b/src/Drawd.jsx
index 06f416f..dc5810c 100644
--- a/src/Drawd.jsx
+++ b/src/Drawd.jsx
@@ -49,7 +49,7 @@ export default function Drawd({ initialRoomCode }) {
updateScreenDescription, updateScreenNotes, updateScreenTbd, updateScreenRoles, updateScreenCodeRef, updateScreenCriteria, assignScreenImage, patchScreenImage, quickConnectHotspot,
updateConnection, deleteConnection,
addConnection, convertToConditionalGroup, addToConditionalGroup, saveConnectionGroup, deleteConnectionGroup,
- addState, updateStateName, addDocument, updateDocument, deleteDocument,
+ addState, linkAsState, updateStateName, addDocument, updateDocument, deleteDocument,
replaceAll, mergeAll,
canUndo, canRedo, undo, redo, captureDragSnapshot, commitDragSnapshot,
updateScreenStatus, markAllExisting,
@@ -174,6 +174,7 @@ export default function Drawd({ initialRoomCode }) {
const connInteraction = useConnectionInteraction({
screens, connections, canvasRef, pan, zoom,
addConnection, addToConditionalGroup, convertToConditionalGroup,
+ linkAsState,
});
const {
@@ -181,9 +182,11 @@ export default function Drawd({ initialRoomCode }) {
hoverTarget, setHoverTarget,
selectedConnection, setSelectedConnection,
conditionalPrompt, setConditionalPrompt,
+ connectionTypePrompt, setConnectionTypePrompt,
editingConditionGroup, setEditingConditionGroup,
onDotDragStart, onStartConnect,
onConditionalPromptConfirm, onConditionalPromptCancel,
+ onConnectionTypeNavigate, onConnectionTypeStateVariant,
} = connInteraction;
const hsInteraction = useHotspotInteraction({
@@ -218,6 +221,7 @@ export default function Drawd({ initialRoomCode }) {
hotspotInteraction, setHotspotInteraction,
setSelectedConnection, setHoverTarget,
setConditionalPrompt, setEditingConditionGroup,
+ setConnectionTypePrompt,
setHotspotModal, setConnectionEditModal,
quickConnectHotspot, addConnection, addToConditionalGroup,
onStartConnect,
@@ -468,6 +472,9 @@ export default function Drawd({ initialRoomCode }) {
conditionalPrompt={conditionalPrompt}
onConditionalPromptConfirm={onConditionalPromptConfirm}
onConditionalPromptCancel={onConditionalPromptCancel}
+ connectionTypePrompt={connectionTypePrompt}
+ onConnectionTypeNavigate={onConnectionTypeNavigate}
+ onConnectionTypeStateVariant={onConnectionTypeStateVariant}
collab={collab}
editingConditionGroup={editingConditionGroup}
updateConnection={updateConnection}
diff --git a/src/components/CanvasArea.jsx b/src/components/CanvasArea.jsx
index 696ee65..5adf948 100644
--- a/src/components/CanvasArea.jsx
+++ b/src/components/CanvasArea.jsx
@@ -3,6 +3,7 @@ import { DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT } from "../constants";
import { ScreenNode } from "./ScreenNode";
import { ConnectionLines } from "./ConnectionLines";
import { ConditionalPrompt } from "./ConditionalPrompt";
+import { ConnectionTypePrompt } from "./ConnectionTypePrompt";
import { InlineConditionLabels } from "./InlineConditionLabels";
import { SelectionOverlay } from "./SelectionOverlay";
import { EmptyState } from "./EmptyState";
@@ -41,6 +42,8 @@ export function CanvasArea({
repositionGhost,
// Conditional prompt
conditionalPrompt, onConditionalPromptConfirm, onConditionalPromptCancel,
+ // Connection type prompt
+ connectionTypePrompt, onConnectionTypeNavigate, onConnectionTypeStateVariant,
// Collaboration
collab,
// Inline condition labels
@@ -53,13 +56,18 @@ export function CanvasArea({
return (
{
+ if (connectionTypePrompt) { onConnectionTypeNavigate(); return; }
+ onCanvasMouseDown(e);
+ }}
onMouseMove={onCanvasMouseMove}
onMouseUp={onCanvasMouseUp}
onMouseLeave={onCanvasMouseLeave}
onDragOver={(e) => e.preventDefault()}
onDrop={onCanvasDrop}
- onClick={() => { if (groupContextMenu) setGroupContextMenu(null); }}
+ onClick={() => {
+ if (groupContextMenu) setGroupContextMenu(null);
+ }}
onDoubleClick={(e) => {
if (e.target !== canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
@@ -224,6 +232,14 @@ export function CanvasArea({
onCancel={onConditionalPromptCancel}
/>
)}
+ {connectionTypePrompt && (
+
+ )}
{collab.isConnected &&
}
{editingConditionGroup && (
e.stopPropagation()}
+ >
+
+ Connection type?
+
+
+
+
+
+
+ );
+}
diff --git a/src/hooks/useConnectionInteraction.js b/src/hooks/useConnectionInteraction.js
index 039b5d6..53e5c95 100644
--- a/src/hooks/useConnectionInteraction.js
+++ b/src/hooks/useConnectionInteraction.js
@@ -10,11 +10,13 @@ export function useConnectionInteraction({
addConnection,
addToConditionalGroup: _addToConditionalGroup,
convertToConditionalGroup,
+ linkAsState,
}) {
const [connecting, setConnecting] = useState(null);
const [hoverTarget, setHoverTarget] = useState(null);
const [selectedConnection, setSelectedConnection] = useState(null);
const [conditionalPrompt, setConditionalPrompt] = useState(null);
+ const [connectionTypePrompt, setConnectionTypePrompt] = useState(null);
const [editingConditionGroup, setEditingConditionGroup] = useState(null);
const cancelConnecting = useCallback(() => {
@@ -55,16 +57,31 @@ export function useConnectionInteraction({
setConditionalPrompt(null);
}, [conditionalPrompt, addConnection]);
+ const onConnectionTypeNavigate = useCallback(() => {
+ if (!connectionTypePrompt) return;
+ addConnection(connectionTypePrompt.fromId, connectionTypePrompt.targetScreenId);
+ setConnectionTypePrompt(null);
+ }, [connectionTypePrompt, addConnection]);
+
+ const onConnectionTypeStateVariant = useCallback(() => {
+ if (!connectionTypePrompt) return;
+ linkAsState(connectionTypePrompt.targetScreenId, connectionTypePrompt.fromId);
+ setConnectionTypePrompt(null);
+ }, [connectionTypePrompt, linkAsState]);
+
return {
connecting, setConnecting,
hoverTarget, setHoverTarget,
selectedConnection, setSelectedConnection,
conditionalPrompt, setConditionalPrompt,
+ connectionTypePrompt, setConnectionTypePrompt,
editingConditionGroup, setEditingConditionGroup,
cancelConnecting,
onDotDragStart,
onStartConnect,
onConditionalPromptConfirm,
onConditionalPromptCancel,
+ onConnectionTypeNavigate,
+ onConnectionTypeStateVariant,
};
}
diff --git a/src/hooks/useInteractionCallbacks.js b/src/hooks/useInteractionCallbacks.js
index 66281f1..4bcd01a 100644
--- a/src/hooks/useInteractionCallbacks.js
+++ b/src/hooks/useInteractionCallbacks.js
@@ -7,8 +7,9 @@ export function useInteractionCallbacks({
hotspotInteraction, setHotspotInteraction,
setSelectedConnection, setHoverTarget,
setConditionalPrompt, setEditingConditionGroup,
+ setConnectionTypePrompt,
setHotspotModal, setConnectionEditModal,
- quickConnectHotspot, addConnection, addToConditionalGroup,
+ quickConnectHotspot, addConnection: _addConnection, addToConditionalGroup,
onStartConnect,
activeTool, captureDragSnapshot,
handleDragStart, handleMultiDragStart,
@@ -71,9 +72,12 @@ export function useInteractionCallbacks({
return;
}
- addConnection(fromId, targetScreenId);
+ const fromScreen = screens.find((s) => s.id === fromId);
+ const promptX = fromScreen ? fromScreen.x + (fromScreen.width || DEFAULT_SCREEN_WIDTH) + 20 : 0;
+ const promptY = fromScreen ? fromScreen.y : 0;
+ setConnectionTypePrompt({ fromId, targetScreenId, x: promptX, y: promptY });
cancelConnecting();
- }, [connecting, cancelConnecting, hotspotInteraction, setHotspotInteraction, quickConnectHotspot, addConnection, connections, screens, addToConditionalGroup, setEditingConditionGroup, setHoverTarget, setConditionalPrompt]);
+ }, [connecting, cancelConnecting, hotspotInteraction, setHotspotInteraction, quickConnectHotspot, connections, screens, addToConditionalGroup, setEditingConditionGroup, setHoverTarget, setConditionalPrompt, setConnectionTypePrompt]);
// Open hotspot modal when a draw gesture completes
useEffect(() => {
diff --git a/src/hooks/useScreenManager.js b/src/hooks/useScreenManager.js
index 2275ca8..918fbe6 100644
--- a/src/hooks/useScreenManager.js
+++ b/src/hooks/useScreenManager.js
@@ -780,6 +780,31 @@ export function useScreenManager(pan, zoom, canvasRef) {
setSelectedScreen(newScreen.id);
}, [screens, connections, documents, pushHistory]);
+ const linkAsState = useCallback((screenId, parentScreenId) => {
+ pushHistory(screens, connections, documents);
+ const parent = screens.find((s) => s.id === parentScreenId);
+ const target = screens.find((s) => s.id === screenId);
+ if (!parent || !target) return;
+ if (screenId === parentScreenId) return;
+ if (parent.stateGroup && parent.stateGroup === target.stateGroup) return;
+
+ const groupId = parent.stateGroup || generateId();
+ const siblings = screens.filter((s) => s.stateGroup === groupId);
+ const stateNumber = siblings.length + (parent.stateGroup ? 1 : 2);
+
+ setScreens((prev) =>
+ prev.map((s) => {
+ if (s.id === parentScreenId && !s.stateGroup) {
+ return { ...s, stateGroup: groupId, stateName: DEFAULT_STATE_NAME };
+ }
+ if (s.id === screenId) {
+ return { ...s, stateGroup: groupId, stateName: s.stateName || `State ${stateNumber - 1}` };
+ }
+ return s;
+ })
+ );
+ }, [screens, connections, documents, pushHistory]);
+
const updateStateName = useCallback((screenId, stateName) => {
pushHistory(screens, connections, documents);
setScreens((prev) => prev.map((s) => (s.id === screenId ? { ...s, stateName } : s)));
@@ -873,6 +898,7 @@ export function useScreenManager(pan, zoom, canvasRef) {
saveConnectionGroup,
deleteConnectionGroup,
addState,
+ linkAsState,
updateStateName,
addDocument,
updateDocument,
diff --git a/src/hooks/useScreenManager.test.js b/src/hooks/useScreenManager.test.js
index f371983..00b8532 100644
--- a/src/hooks/useScreenManager.test.js
+++ b/src/hooks/useScreenManager.test.js
@@ -510,6 +510,102 @@ describe("addState", () => {
});
});
+describe("linkAsState", () => {
+ it("links two screens into the same stateGroup", () => {
+ const { result } = setup();
+ act(() => result.current.addScreen(null, "A"));
+ act(() => result.current.addScreen(null, "B"));
+ const idA = result.current.screens[0].id;
+ const idB = result.current.screens[1].id;
+
+ act(() => result.current.linkAsState(idB, idA));
+ expect(result.current.screens[0].stateGroup).toBeTruthy();
+ expect(result.current.screens[1].stateGroup).toBe(result.current.screens[0].stateGroup);
+ });
+
+ it("parent gets stateName='Default', target gets 'State 1'", () => {
+ const { result } = setup();
+ act(() => result.current.addScreen(null, "A"));
+ act(() => result.current.addScreen(null, "B"));
+ const idA = result.current.screens[0].id;
+ const idB = result.current.screens[1].id;
+
+ act(() => result.current.linkAsState(idB, idA));
+ expect(result.current.screens[0].stateName).toBe("Default");
+ expect(result.current.screens[1].stateName).toBe("State 1");
+ });
+
+ it("does nothing when linking a screen to itself", () => {
+ const { result } = setup();
+ act(() => result.current.addScreen(null, "A"));
+ const idA = result.current.screens[0].id;
+
+ act(() => result.current.linkAsState(idA, idA));
+ expect(result.current.screens[0].stateGroup).toBeNull();
+ });
+
+ it("does nothing when both screens are already in the same group", () => {
+ const { result } = setup();
+ act(() => result.current.addScreen(null, "A"));
+ act(() => result.current.addScreen(null, "B"));
+ const idA = result.current.screens[0].id;
+ const idB = result.current.screens[1].id;
+
+ act(() => result.current.linkAsState(idB, idA));
+ const groupId = result.current.screens[0].stateGroup;
+
+ act(() => result.current.linkAsState(idB, idA));
+ expect(result.current.screens[0].stateGroup).toBe(groupId);
+ expect(result.current.screens[1].stateGroup).toBe(groupId);
+ });
+
+ it("reuses existing stateGroup when parent already has one", () => {
+ const { result } = setup();
+ act(() => result.current.addScreen(null, "A"));
+ act(() => result.current.addScreen(null, "B"));
+ act(() => result.current.addScreen(null, "C"));
+ const idA = result.current.screens[0].id;
+ const idB = result.current.screens[1].id;
+ const idC = result.current.screens[2].id;
+
+ act(() => result.current.addState(idA));
+ const groupId = result.current.screens[0].stateGroup;
+
+ act(() => result.current.linkAsState(idB, idA));
+ expect(result.current.screens[1].stateGroup).toBe(groupId);
+
+ act(() => result.current.linkAsState(idC, idA));
+ expect(result.current.screens[2].stateGroup).toBe(groupId);
+ });
+
+ it("is undoable", () => {
+ const { result } = setup();
+ act(() => result.current.addScreen(null, "A"));
+ act(() => result.current.addScreen(null, "B"));
+ const idA = result.current.screens[0].id;
+ const idB = result.current.screens[1].id;
+
+ act(() => result.current.linkAsState(idB, idA));
+ expect(result.current.screens[0].stateGroup).toBeTruthy();
+
+ act(() => result.current.undo());
+ expect(result.current.screens[0].stateGroup).toBeNull();
+ expect(result.current.screens[1].stateGroup).toBeNull();
+ });
+
+ it("preserves existing stateName on target screen", () => {
+ const { result } = setup();
+ act(() => result.current.addScreen(null, "A"));
+ act(() => result.current.addScreen(null, "B"));
+ const idA = result.current.screens[0].id;
+ const idB = result.current.screens[1].id;
+
+ act(() => result.current.updateStateName(idB, "Loading"));
+ act(() => result.current.linkAsState(idB, idA));
+ expect(result.current.screens[1].stateName).toBe("Loading");
+ });
+});
+
describe("saveConnectionGroup", () => {
it("navigate mode saves connection with fromScreenId, toScreenId, label", () => {
const { result } = setup();
diff --git a/src/pages/docs/userGuide.md b/src/pages/docs/userGuide.md
index 137c8de..26134ab 100644
--- a/src/pages/docs/userGuide.md
+++ b/src/pages/docs/userGuide.md
@@ -184,10 +184,14 @@ After creating a conditional group, inline label inputs appear along each connec
Screen states let you model different visual variants of the same logical screen — for example, a loading state, an error state, or a logged-in vs. logged-out view.
-### Adding a state
+### Adding a new state screen
Select a screen, then click "Add State" in the right sidebar. A new variant screen is created 250px to the right of the original, sharing a state group with it. The original screen is automatically labeled "Default".
+### Linking an existing screen as a state variant
+
+Drag a connection from one screen to another. A popup appears asking you to choose between **Navigate** (creates a normal navigation link) and **State Variant** (links the target screen into the source screen's state group). Choosing "State Variant" groups both screens together — a dashed connector line appears between them, and they are treated as a single logical screen in the generated instructions.
+
### Naming states
Each state has a name field visible in the sidebar (e.g., "Loading", "Error", "Empty"). State names appear in the screen header on the canvas and in the generated instructions.