From 7755632e0584ca1554faad4fe71da1ff1f4356ab Mon Sep 17 00:00:00 2001
From: Quang Tran <16215255+trmquang93@users.noreply.github.com>
Date: Mon, 27 Apr 2026 23:15:17 +0700
Subject: [PATCH 1/2] fix: include screen status changes in undo history
updateScreenStatus and markAllExisting were missing pushHistory calls,
so single-screen status edits and the bulk "All existing" action were
invisible to Cmd/Ctrl+Z. Mirrors the existing pattern used by
updateScreenNotes / renameScreen.
Refs backlog 8.2.
---
src/hooks/useScreenManager.js | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/hooks/useScreenManager.js b/src/hooks/useScreenManager.js
index abfdf37..929a643 100644
--- a/src/hooks/useScreenManager.js
+++ b/src/hooks/useScreenManager.js
@@ -258,8 +258,9 @@ export function useScreenManager(pan, zoom, canvasRef, commentCallbacks = {}) {
}, []);
const updateScreenStatus = useCallback((id, status) => {
+ pushHistory(screens, connections, documents);
setScreens((prev) => prev.map((s) => (s.id === id ? { ...s, status } : s)));
- }, []);
+ }, [screens, connections, documents, pushHistory]);
// Component (shared/reusable screen) actions.
// mode: "canonical" | "instance" | "unlink"
@@ -328,8 +329,9 @@ export function useScreenManager(pan, zoom, canvasRef, commentCallbacks = {}) {
}, [screens, connections, documents, pushHistory]);
const markAllExisting = useCallback(() => {
+ pushHistory(screens, connections, documents);
setScreens((prev) => prev.map((s) => ({ ...s, status: "existing" })));
- }, []);
+ }, [screens, connections, documents, pushHistory]);
// Lightweight image patch for collab sync (no undo, no dimension clear)
const patchScreenImage = useCallback((id, imageData) => {
From 439deb98b48fb63a92fce5d6dc440f59207cb1a4 Mon Sep 17 00:00:00 2001
From: Quang Tran <16215255+trmquang93@users.noreply.github.com>
Date: Mon, 27 Apr 2026 23:15:25 +0700
Subject: [PATCH 2/2] feat: bidirectional screen status with right-click menus
The canvas status chip is now always visible (previously hidden for
status="new") and is clickable: left-click cycles, right-click opens a
3-option menu to jump directly to New/Modify/Existing. The right
Sidebar's Build status pill gains the same right-click menu and now
honors isReadOnly. Both popovers render via React portals so they
escape the transformed canvas ancestor and use Z_INDEX.contextMenu.
Read-only viewers see a plain status chip with no interactions.
User guide updated with a Build status subsection covering the three
statuses, the click vs right-click affordances, and the undoable bulk
"All existing" action.
Refs backlog 8.2.
---
src/Drawd.jsx | 1 +
src/components/CanvasArea.jsx | 3 +-
src/components/ScreenNode.jsx | 94 +++++++++++++++++++++++++++++++++--
src/components/Sidebar.jsx | 73 +++++++++++++++++++++++++--
src/pages/docs/userGuide.md | 20 ++++++++
5 files changed, 183 insertions(+), 8 deletions(-)
diff --git a/src/Drawd.jsx b/src/Drawd.jsx
index f363bd2..413ba3f 100644
--- a/src/Drawd.jsx
+++ b/src/Drawd.jsx
@@ -625,6 +625,7 @@ export default function Drawd({ initialRoomCode }) {
toggleSelection={toggleSelection}
onMultiDragStart={onMultiDragStart}
isReadOnly={isReadOnly}
+ onUpdateStatus={updateScreenStatus}
onFormSummary={(screenId) => {
const s = screens.find((sc) => sc.id === screenId);
if (s) setFormSummaryScreen(s);
diff --git a/src/components/CanvasArea.jsx b/src/components/CanvasArea.jsx
index b88d933..9fca43e 100644
--- a/src/components/CanvasArea.jsx
+++ b/src/components/CanvasArea.jsx
@@ -33,7 +33,7 @@ export function CanvasArea({
onHotspotDragHandleMouseDown, onResizeHandleMouseDown, onScreenDimensions,
drawRect, updateScreenDescription, addState, handleDropImage, activeTool,
scopeRoot, scopeScreenIds, canvasSelection, toggleSelection, onMultiDragStart,
- isReadOnly, onFormSummary,
+ isReadOnly, onFormSummary, onUpdateStatus,
// Sticky notes
stickyNotes, selectedStickyNote, updateStickyNote, deleteStickyNote, addStickyNote,
// Selection overlay
@@ -208,6 +208,7 @@ export function CanvasArea({
onToggleSelect={toggleSelection}
onMultiDragStart={onMultiDragStart}
isReadOnly={isReadOnly}
+ onUpdateStatus={onUpdateStatus}
onFormSummary={onFormSummary}
mcpFlash={mcpFlashIds?.has(screen.id)}
commentPins={(comments || []).filter(
diff --git a/src/components/ScreenNode.jsx b/src/components/ScreenNode.jsx
index 941a9ee..0d34f01 100644
--- a/src/components/ScreenNode.jsx
+++ b/src/components/ScreenNode.jsx
@@ -1,5 +1,6 @@
import { useState, useRef, useCallback, useEffect } from "react";
-import { COLORS, FONTS } from "../styles/theme";
+import { createPortal } from "react-dom";
+import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE, Z_INDEX } from "../styles/theme";
import { DEFAULT_SCREEN_WIDTH, DEFAULT_IMAGE_HEIGHT, HEADER_HEIGHT, DESCRIPTION_MAX_LENGTH } from "../constants";
import { CommentPin } from "./CommentPin";
@@ -13,6 +14,7 @@ export function ScreenNode({
scopeRoot, isInScope, onContextMenu,
isMultiSelected, onToggleSelect, onMultiDragStart,
isReadOnly,
+ onUpdateStatus,
onFormSummary,
mcpFlash,
// Comment mode
@@ -31,6 +33,7 @@ export function ScreenNode({
const [draftDesc, setDraftDesc] = useState("");
const [isDragOver, setIsDragOver] = useState(false);
const [altHeld, setAltHeld] = useState(false);
+ const [statusMenu, setStatusMenu] = useState(null); // { x, y } | null
const imgRef = useRef(null);
const [prevImageData, setPrevImageData] = useState(screen.imageData);
@@ -313,7 +316,7 @@ export function ScreenNode({
↗ Instance
)}
- {(status !== "new" || isScopeRoot) && (
+ {isReadOnly ? (
{STATUS_CHIP[status].label}
+ ) : (
+ { e.stopPropagation(); }}
+ onClick={(e) => {
+ e.stopPropagation();
+ onUpdateStatus?.(screen.id, STATUS_CYCLE[status]);
+ }}
+ onContextMenu={(e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ setStatusMenu({ x: e.clientX, y: e.clientY });
+ }}
+ style={{
+ fontSize: 9,
+ fontWeight: 600,
+ color: STATUS_CHIP[status].color,
+ background: STATUS_CHIP[status].bg,
+ border: "none",
+ borderRadius: 4,
+ padding: "1px 5px",
+ fontFamily: FONTS.mono,
+ whiteSpace: "nowrap",
+ cursor: "pointer",
+ lineHeight: 1.4,
+ }}
+ >
+ {STATUS_CHIP[status].label}
+
)}
@@ -808,6 +840,62 @@ export function ScreenNode({
border: `2px solid ${COLORS.surface}`,
}}
/>
+ {statusMenu && createPortal(
+
setStatusMenu(null)}
+ onContextMenu={(e) => { e.preventDefault(); setStatusMenu(null); }}
+ style={{ position: "fixed", inset: 0, zIndex: Z_INDEX.contextMenu - 1 }}
+ >
+
e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ style={{
+ position: "fixed",
+ top: statusMenu.y,
+ left: statusMenu.x,
+ background: COLORS.surface,
+ border: `1px solid ${COLORS.border}`,
+ borderRadius: 6,
+ boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
+ zIndex: Z_INDEX.contextMenu,
+ minWidth: 160,
+ overflow: "hidden",
+ padding: "4px 0",
+ }}
+ >
+ {(["new", "modify", "existing"]).map((s) => {
+ const cfg = STATUS_CONFIG[s];
+ const isCurrent = status === s;
+ return (
+ { onUpdateStatus?.(screen.id, s); setStatusMenu(null); }}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ width: "100%",
+ padding: "7px 14px",
+ background: isCurrent ? COLORS.accent01 : "none",
+ border: "none",
+ color: COLORS.text,
+ cursor: "pointer",
+ textAlign: "left",
+ fontFamily: FONTS.ui,
+ fontSize: 12,
+ }}
+ onMouseEnter={(e) => { if (!isCurrent) e.currentTarget.style.background = COLORS.surfaceHover; }}
+ onMouseLeave={(e) => { if (!isCurrent) e.currentTarget.style.background = "none"; }}
+ >
+
+ Mark as {cfg.label}
+
+ );
+ })}
+
+
,
+ document.body
+ )}
);
}
diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx
index 5aeacac..d8b4785 100644
--- a/src/components/Sidebar.jsx
+++ b/src/components/Sidebar.jsx
@@ -1,5 +1,6 @@
-import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE, COMPONENT_CONFIG } from "../styles/theme";
+import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE, COMPONENT_CONFIG, Z_INDEX } from "../styles/theme";
import { useEffect, useState } from "react";
+import { createPortal } from "react-dom";
import { COPY_FEEDBACK_MS, SIDEBAR_WIDTH } from "../constants";
export function Sidebar({ screen, screens, connections, onClose, onRename, onAddHotspot, onEditHotspot, onAddState, onSelectScreen, onUpdateStateName, onUpdateNotes, onUpdateCodeRef, onUpdateCriteria, onUpdateStatus, onUpdateTbd, onUpdateRoles, onSetComponent, isReadOnly }) {
@@ -13,6 +14,7 @@ export function Sidebar({ screen, screens, connections, onClose, onRename, onAdd
const [newRole, setNewRole] = useState("");
const [rolesScreenId, setRolesScreenId] = useState(screen.id);
const [idCopied, setIdCopied] = useState(false);
+ const [statusMenu, setStatusMenu] = useState(null); // { x, y } | null
// Reset the "Copied!" flag when switching to a different screen so it
// never lingers from a previous screen's click.
@@ -212,8 +214,15 @@ export function Sidebar({ screen, screens, connections, onClose, onRename, onAdd
Build status
onUpdateStatus?.(screen.id, STATUS_CYCLE[status])}
- title="Click to cycle: New → Modify → Existing"
+ onClick={() => { if (isReadOnly) return; onUpdateStatus?.(screen.id, STATUS_CYCLE[status]); }}
+ onContextMenu={(e) => {
+ if (isReadOnly) return;
+ e.preventDefault();
+ e.stopPropagation();
+ setStatusMenu({ x: e.clientX, y: e.clientY });
+ }}
+ disabled={isReadOnly}
+ title={isReadOnly ? statusCfg.label : "Click to cycle • Right-click for options"}
style={{
padding: "3px 9px",
borderRadius: 4,
@@ -223,13 +232,69 @@ export function Sidebar({ screen, screens, connections, onClose, onRename, onAdd
fontFamily: FONTS.mono,
fontSize: 11,
fontWeight: 600,
- cursor: "pointer",
+ cursor: isReadOnly ? "default" : "pointer",
letterSpacing: "0.03em",
}}
>
{statusCfg.label}
+ {statusMenu && createPortal(
+ setStatusMenu(null)}
+ onContextMenu={(e) => { e.preventDefault(); setStatusMenu(null); }}
+ style={{ position: "fixed", inset: 0, zIndex: Z_INDEX.contextMenu - 1 }}
+ >
+
e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ style={{
+ position: "fixed",
+ top: statusMenu.y,
+ left: statusMenu.x,
+ background: COLORS.surface,
+ border: `1px solid ${COLORS.border}`,
+ borderRadius: 6,
+ boxShadow: "0 4px 16px rgba(0,0,0,0.4)",
+ zIndex: Z_INDEX.contextMenu,
+ minWidth: 160,
+ overflow: "hidden",
+ padding: "4px 0",
+ }}
+ >
+ {(["new", "modify", "existing"]).map((s) => {
+ const cfg = STATUS_CONFIG[s];
+ const isCurrent = status === s;
+ return (
+ { onUpdateStatus?.(screen.id, s); setStatusMenu(null); }}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ width: "100%",
+ padding: "7px 14px",
+ background: isCurrent ? COLORS.accent01 : "none",
+ border: "none",
+ color: COLORS.text,
+ cursor: "pointer",
+ textAlign: "left",
+ fontFamily: FONTS.ui,
+ fontSize: 12,
+ }}
+ onMouseEnter={(e) => { if (!isCurrent) e.currentTarget.style.background = COLORS.surfaceHover; }}
+ onMouseLeave={(e) => { if (!isCurrent) e.currentTarget.style.background = "none"; }}
+ >
+
+ Mark as {cfg.label}
+
+ );
+ })}
+
+
,
+ document.body
+ )}
{/* Reusable Component */}
[!TIP]
> Multiple images can be uploaded or dropped at once. Drawd auto-arranges them in a grid layout on the canvas.
+### Build status
+
+Every screen carries a build status that drives instruction generation:
+
+- `New` — screen needs to be built. Becomes a "to build" task in the generated AI instructions.
+- `Modify` — screen already exists in the codebase but needs changes. Treated as a build task with edit context.
+- `Existing` — screen is already built and only referenced for navigation context. Excluded from build tasks.
+
+The status chip is always visible in the canvas card header and in the right Sidebar's "Build status" pill. To change it:
+
+- **Left-click** the chip or pill to cycle: `New → Modify → Existing → New`.
+- **Right-click** the chip or pill to open a menu and jump directly to any status.
+- In the left Screens Panel, the per-row chip cycles on click and right-clicking the row opens the same menu.
+- The "All existing" header button in the Screens Panel marks every screen as `Existing` in one click — useful when adding a single new screen to a fully built app.
+
+All status changes (single-screen and bulk) participate in undo history — press `Cmd/Ctrl+Z` to revert.
+
+> [!NOTE]
+> Read-only viewers (collab guests on a shared flow) see the status chip but cannot click or right-click to change it.
+
## Canvas Navigation
The canvas is an infinite workspace where you arrange and connect screens. You can pan freely in any direction and zoom in or out.