Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mcp-server/src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ export class FlowState {
tbdNote,
roles: [],
figmaSource: null,
componentId: null,
componentRole: null,
};

this.screens.push(screen);
Expand All @@ -202,6 +204,7 @@ export class FlowState {
"tbd", "tbdNote", "roles", "acceptanceCriteria",
"imageData", "imageWidth", "imageHeight",
"svgContent", "sourceHtml", "wireframe",
"componentId", "componentRole",
];
for (const key of allowed) {
if (updates[key] !== undefined) {
Expand Down
4 changes: 3 additions & 1 deletion mcp-server/src/tools/screen-tools.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const screenTools = [
},
{
name: "update_screen",
description: "Update properties of an existing screen (name, description, notes, status, etc.).",
description: "Update properties of an existing screen (name, description, notes, status, etc.). To mark a screen as a reusable component, set componentRole to 'canonical' and supply a componentId (any unique string). To mark a screen as an instance of an existing component, set componentRole to 'instance' and pass the same componentId as the canonical. To unlink, set both to null.",
inputSchema: {
type: "object",
properties: {
Expand All @@ -62,6 +62,8 @@ export const screenTools = [
tbdNote: { type: "string" },
roles: { type: "array", items: { type: "string" } },
codeRef: { type: "string" },
componentId: { type: ["string", "null"], description: "Reusable component group key. All screens sharing a componentId belong to the same component. Pass null to unlink." },
componentRole: { type: ["string", "null"], enum: ["canonical", "instance", null], description: "Role within the component group. 'canonical' = owns the spec; 'instance' = references the canonical's spec. Pass null to unlink." },
},
required: ["screenId"],
},
Expand Down
3 changes: 3 additions & 0 deletions src/Drawd.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export default function Drawd({ initialRoomCode }) {
pushHistory,
canUndo, canRedo, undo, redo, captureDragSnapshot, commitDragSnapshot,
updateScreenStatus, markAllExisting, updateWireframe,
setScreenComponent,
} = useScreenManager(pan, zoom, canvasRef, {
onDeleteCommentsForScreen: deleteCommentsForScreen,
onDeleteCommentsForScreens: deleteCommentsForScreens,
Expand Down Expand Up @@ -565,6 +566,7 @@ export default function Drawd({ initialRoomCode }) {
onTaskLinkChange={setTaskLink}
techStack={techStack}
onTechStackChange={setTechStack}
onSetComponent={setScreenComponent}
isReadOnly={isReadOnly}
/>

Expand Down Expand Up @@ -714,6 +716,7 @@ export default function Drawd({ initialRoomCode }) {
onUpdateCodeRef={updateScreenCodeRef}
onUpdateCriteria={updateScreenCriteria}
onUpdateStatus={updateScreenStatus}
onSetComponent={setScreenComponent}
isReadOnly={isReadOnly}
/>
)}
Expand Down
12 changes: 9 additions & 3 deletions src/components/CanvasArea.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT } from "../constants";
import { copyScreenForFigma, copyScreensForFigma, copyScreensForFigmaEditable, downloadScreenSvg } from "../utils/copyToFigma";
import { copyScreensAsImage } from "../utils/copyAsImage";
import { ScreenNode } from "./ScreenNode";
import { resolveInstanceVisuals, isResolvedInstance } from "../utils/resolveInstanceVisuals";
import { ConnectionLines } from "./ConnectionLines";
import { ConditionalPrompt } from "./ConditionalPrompt";
import { ConnectionTypePrompt } from "./ConnectionTypePrompt";
Expand Down Expand Up @@ -160,10 +161,14 @@ export function CanvasArea({
}}
/>
))}
{screens.map((screen) => (
{screens.map((screen) => {
const visualScreen = resolveInstanceVisuals(screen, screens);
const instanceVisual = isResolvedInstance(screen) && visualScreen !== screen;
return (
<ScreenNode
key={screen.id}
screen={screen}
screen={visualScreen}
isInstanceVisual={instanceVisual}
selected={selectedScreen === screen.id}
onSelect={(id) => { clearSelection(); setSelectedScreen(id); setSelectedStickyNote(null); }}
onDragStart={onDragStart}
Expand Down Expand Up @@ -213,7 +218,8 @@ export function CanvasArea({
onCommentPinClick={onCommentPinClick}
onDeselectComment={onDeselectComment}
/>
))}
);
})}
{stickyNotes.map((note) => (
<StickyNote
key={note.id}
Expand Down
65 changes: 57 additions & 8 deletions src/components/ScreenNode.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export function ScreenNode({
selectedCommentId,
onCommentPinClick,
onDeselectComment,
// True when this is an instance whose image/hotspots are inherited from
// its canonical at render time. Hotspot edits are read-only here — users
// must edit the canonical instead.
isInstanceVisual,
}) {
const [imgLoaded, setImgLoaded] = useState(false);
const [isEditingDesc, setIsEditingDesc] = useState(false);
Expand Down Expand Up @@ -58,6 +62,9 @@ export function ScreenNode({
// isInScope: undefined = no scope set (all visible), true/false = in or out of scope
const outOfScope = isInScope === false;

const isCanonical = screen.componentRole === "canonical";
const isInstance = screen.componentRole === "instance";

const borderColor = isConnectHoverTarget
? COLORS.success
: isMultiSelected
Expand All @@ -66,9 +73,13 @@ export function ScreenNode({
? COLORS.borderActive
: screen.tbd
? COLORS.statusTbd
: isScopeRoot
? COLORS.accent
: STATUS_BORDER[status];
: isCanonical
? COLORS.componentCanonical
: isInstance
? COLORS.componentInstance
: isScopeRoot
? COLORS.accent
: STATUS_BORDER[status];

const handleImgLoad = useCallback(() => {
setImgLoaded(true);
Expand Down Expand Up @@ -266,6 +277,42 @@ export function ScreenNode({
⊙ root
</span>
)}
{isCanonical && (
<span
title="Reusable component — single source of truth for the spec"
style={{
fontSize: 9,
fontWeight: 700,
color: COLORS.componentCanonical,
background: COLORS.componentBg,
border: `1px solid ${COLORS.componentCanonical}`,
borderRadius: 4,
padding: "1px 5px",
fontFamily: FONTS.mono,
whiteSpace: "nowrap",
}}
>
⟳ Component
</span>
)}
{isInstance && (
<span
title="Instance of a reusable component — see canonical for spec"
style={{
fontSize: 9,
fontWeight: 600,
color: COLORS.componentInstance,
background: COLORS.componentBgInstance,
border: `1px solid ${COLORS.componentInstance}`,
borderRadius: 4,
padding: "1px 5px",
fontFamily: FONTS.mono,
whiteSpace: "nowrap",
}}
>
↗ Instance
</span>
)}
{(status !== "new" || isScopeRoot) && (
<span
style={{
Expand Down Expand Up @@ -323,7 +370,7 @@ export function ScreenNode({
>
S+
</button>
<button
{!isInstanceVisual && <button
className="screen-btn"
onClick={(e) => { e.stopPropagation(); onAddHotspot(screen.id); }}
title="Add tap area / button link"
Expand All @@ -339,7 +386,7 @@ export function ScreenNode({
}}
>
+ Link
</button>
</button>}
<button
className="screen-btn"
onClick={(e) => { e.stopPropagation(); onRemoveScreen(screen.id); }}
Expand Down Expand Up @@ -387,7 +434,7 @@ export function ScreenNode({
}
}
onSelect(screen.id);
if (screen.imageData && onImageAreaMouseDown) {
if (screen.imageData && !isInstanceVisual && onImageAreaMouseDown) {
onImageAreaMouseDown(e, screen.id);
}
}}
Expand Down Expand Up @@ -416,10 +463,12 @@ export function ScreenNode({
className="hotspot-area"
onMouseDown={(e) => {
e.stopPropagation();
if (isInstanceVisual) return;
if (onHotspotMouseDown) onHotspotMouseDown(e, screen.id, hs.id);
}}
onDoubleClick={(e) => {
e.stopPropagation();
if (isInstanceVisual) return;
if (onHotspotDoubleClick) onHotspotDoubleClick(e, screen.id, hs.id);
}}
style={{
Expand Down Expand Up @@ -456,7 +505,7 @@ export function ScreenNode({
>
{hs.label || "TAP"}
{/* Drag handle for hotspot-to-screen connect */}
{isSelected && (
{isSelected && !isInstanceVisual && (
<div
className="hotspot-drag-handle"
onMouseDown={(e) => {
Expand All @@ -483,7 +532,7 @@ export function ScreenNode({
/>
)}
{/* Resize handles */}
{isSelected && ["nw","n","ne","e","se","s","sw","w"].map((handle) => {
{isSelected && !isInstanceVisual && ["nw","n","ne","e","se","s","sw","w"].map((handle) => {
const pos = {
nw: { left: -4, top: -4, cursor: "nwse-resize" },
n: { left: "50%", top: -4, cursor: "ns-resize", transform: "translateX(-50%)" },
Expand Down
78 changes: 76 additions & 2 deletions src/components/ScreensPanel.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState } from "react";
import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE, Z_INDEX } from "../styles/theme";
import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE, Z_INDEX, COMPONENT_CONFIG } from "../styles/theme";
import { SCREENS_PANEL_WIDTH } from "../constants";

export function ScreensPanel({
Expand All @@ -17,19 +17,21 @@ export function ScreensPanel({
onTaskLinkChange,
techStack,
onTechStackChange,
onSetComponent,
isReadOnly,
}) {
const [briefOpen, setBriefOpen] = useState(false);
const [techOpen, setTechOpen] = useState(false);
const [contextMenu, setContextMenu] = useState(null); // { screenId, x, y }
const [instancePickerOpen, setInstancePickerOpen] = useState(false);

const handleContextMenu = (e, screenId) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ screenId, x: e.clientX, y: e.clientY });
};

const closeContextMenu = () => setContextMenu(null);
const closeContextMenu = () => { setContextMenu(null); setInstancePickerOpen(false); };

const handleStatusClick = (e, screen) => {
e.stopPropagation();
Expand Down Expand Up @@ -513,6 +515,78 @@ export function ScreensPanel({
</button>
);
})}
{!isReadOnly && onSetComponent && (() => {
const ctxScreen = screens.find((s) => s.id === contextMenu.screenId);
if (!ctxScreen) return null;
const isCanonical = ctxScreen.componentRole === "canonical";
const isInstance = ctxScreen.componentRole === "instance";
const canonicals = screens.filter((s) => s.componentRole === "canonical" && s.id !== ctxScreen.id);
const itemStyle = {
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "7px 14px",
background: "none",
border: "none",
color: COLORS.text,
cursor: "pointer",
textAlign: "left",
fontFamily: FONTS.ui,
fontSize: 12,
};
return (
<>
<div style={{ borderTop: `1px solid ${COLORS.border}` }} />
{!isCanonical && !isInstance && (
<button
onClick={() => { onSetComponent(ctxScreen.id, "canonical"); closeContextMenu(); }}
style={itemStyle}
onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.surfaceHover; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: COMPONENT_CONFIG.canonical.color, flexShrink: 0 }} />
Mark as reusable component
</button>
)}
{!isCanonical && !isInstance && canonicals.length > 0 && (
<>
<button
onClick={() => setInstancePickerOpen((v) => !v)}
style={itemStyle}
onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.surfaceHover; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: COMPONENT_CONFIG.instance.color, flexShrink: 0 }} />
Make instance of… {instancePickerOpen ? "▾" : "▸"}
</button>
{instancePickerOpen && canonicals.map((c) => (
<button
key={c.componentId}
onClick={() => { onSetComponent(ctxScreen.id, "instance", { componentId: c.componentId }); closeContextMenu(); }}
style={{ ...itemStyle, paddingLeft: 28, fontSize: 11, color: COLORS.textMuted }}
onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.surfaceHover; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}
>
{c.name || "(unnamed)"}
</button>
))}
</>
)}
{(isCanonical || isInstance) && (
<button
onClick={() => { onSetComponent(ctxScreen.id, "unlink"); closeContextMenu(); }}
style={itemStyle}
onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.surfaceHover; }}
onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}
>
<span style={{ width: 8, height: 8, borderRadius: "50%", background: COLORS.textDim, flexShrink: 0 }} />
Unlink component
</button>
)}
</>
);
})()}
</div>
)}
</div>
Expand Down
Loading
Loading