Skip to content

Commit 23826c6

Browse files
authored
feat: shared screens / reusable components (#41)
Let users explicitly mark screens as reusable components so the AI build instructions describe each component once instead of duplicating code for every placement. - Sidebar + context-menu UI to mark canonical / pick "Instance of…" / unlink - Purple `⟳ Component` and `↗ Instance` pills on the canvas - Instances render the canonical's image and hotspots at draw time, so edits on the canonical propagate live; hotspot editing is canonical-only - generateInstructionFiles emits one `components/<slug>.md` per canonical; instances in `screens.md` link to it instead of duplicating the spec - Auto-promotion: deleting a canonical promotes the first instance, so groups can never become orphaned - importFlow backfills the new fields and enforces one canonical per group - MCP `update_screen` accepts componentId / componentRole - User guide updated Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent 6b762a8 commit 23826c6

15 files changed

Lines changed: 940 additions & 24 deletions

‎mcp-server/src/state.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,8 @@ export class FlowState {
182182
tbdNote,
183183
roles: [],
184184
figmaSource: null,
185+
componentId: null,
186+
componentRole: null,
185187
};
186188

187189
this.screens.push(screen);
@@ -202,6 +204,7 @@ export class FlowState {
202204
"tbd", "tbdNote", "roles", "acceptanceCriteria",
203205
"imageData", "imageWidth", "imageHeight",
204206
"svgContent", "sourceHtml", "wireframe",
207+
"componentId", "componentRole",
205208
];
206209
for (const key of allowed) {
207210
if (updates[key] !== undefined) {

‎mcp-server/src/tools/screen-tools.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ export const screenTools = [
4949
},
5050
{
5151
name: "update_screen",
52-
description: "Update properties of an existing screen (name, description, notes, status, etc.).",
52+
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.",
5353
inputSchema: {
5454
type: "object",
5555
properties: {
@@ -62,6 +62,8 @@ export const screenTools = [
6262
tbdNote: { type: "string" },
6363
roles: { type: "array", items: { type: "string" } },
6464
codeRef: { type: "string" },
65+
componentId: { type: ["string", "null"], description: "Reusable component group key. All screens sharing a componentId belong to the same component. Pass null to unlink." },
66+
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." },
6567
},
6668
required: ["screenId"],
6769
},

‎src/Drawd.jsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ export default function Drawd({ initialRoomCode }) {
7575
pushHistory,
7676
canUndo, canRedo, undo, redo, captureDragSnapshot, commitDragSnapshot,
7777
updateScreenStatus, markAllExisting, updateWireframe,
78+
setScreenComponent,
7879
} = useScreenManager(pan, zoom, canvasRef, {
7980
onDeleteCommentsForScreen: deleteCommentsForScreen,
8081
onDeleteCommentsForScreens: deleteCommentsForScreens,
@@ -565,6 +566,7 @@ export default function Drawd({ initialRoomCode }) {
565566
onTaskLinkChange={setTaskLink}
566567
techStack={techStack}
567568
onTechStackChange={setTechStack}
569+
onSetComponent={setScreenComponent}
568570
isReadOnly={isReadOnly}
569571
/>
570572

@@ -714,6 +716,7 @@ export default function Drawd({ initialRoomCode }) {
714716
onUpdateCodeRef={updateScreenCodeRef}
715717
onUpdateCriteria={updateScreenCriteria}
716718
onUpdateStatus={updateScreenStatus}
719+
onSetComponent={setScreenComponent}
717720
isReadOnly={isReadOnly}
718721
/>
719722
)}

‎src/components/CanvasArea.jsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT } from "../constants";
33
import { copyScreenForFigma, copyScreensForFigma, copyScreensForFigmaEditable, downloadScreenSvg } from "../utils/copyToFigma";
44
import { copyScreensAsImage } from "../utils/copyAsImage";
55
import { ScreenNode } from "./ScreenNode";
6+
import { resolveInstanceVisuals, isResolvedInstance } from "../utils/resolveInstanceVisuals";
67
import { ConnectionLines } from "./ConnectionLines";
78
import { ConditionalPrompt } from "./ConditionalPrompt";
89
import { ConnectionTypePrompt } from "./ConnectionTypePrompt";
@@ -160,10 +161,14 @@ export function CanvasArea({
160161
}}
161162
/>
162163
))}
163-
{screens.map((screen) => (
164+
{screens.map((screen) => {
165+
const visualScreen = resolveInstanceVisuals(screen, screens);
166+
const instanceVisual = isResolvedInstance(screen) && visualScreen !== screen;
167+
return (
164168
<ScreenNode
165169
key={screen.id}
166-
screen={screen}
170+
screen={visualScreen}
171+
isInstanceVisual={instanceVisual}
167172
selected={selectedScreen === screen.id}
168173
onSelect={(id) => { clearSelection(); setSelectedScreen(id); setSelectedStickyNote(null); }}
169174
onDragStart={onDragStart}
@@ -213,7 +218,8 @@ export function CanvasArea({
213218
onCommentPinClick={onCommentPinClick}
214219
onDeselectComment={onDeselectComment}
215220
/>
216-
))}
221+
);
222+
})}
217223
{stickyNotes.map((note) => (
218224
<StickyNote
219225
key={note.id}

‎src/components/ScreenNode.jsx‎

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export function ScreenNode({
2121
selectedCommentId,
2222
onCommentPinClick,
2323
onDeselectComment,
24+
// True when this is an instance whose image/hotspots are inherited from
25+
// its canonical at render time. Hotspot edits are read-only here — users
26+
// must edit the canonical instead.
27+
isInstanceVisual,
2428
}) {
2529
const [imgLoaded, setImgLoaded] = useState(false);
2630
const [isEditingDesc, setIsEditingDesc] = useState(false);
@@ -58,6 +62,9 @@ export function ScreenNode({
5862
// isInScope: undefined = no scope set (all visible), true/false = in or out of scope
5963
const outOfScope = isInScope === false;
6064

65+
const isCanonical = screen.componentRole === "canonical";
66+
const isInstance = screen.componentRole === "instance";
67+
6168
const borderColor = isConnectHoverTarget
6269
? COLORS.success
6370
: isMultiSelected
@@ -66,9 +73,13 @@ export function ScreenNode({
6673
? COLORS.borderActive
6774
: screen.tbd
6875
? COLORS.statusTbd
69-
: isScopeRoot
70-
? COLORS.accent
71-
: STATUS_BORDER[status];
76+
: isCanonical
77+
? COLORS.componentCanonical
78+
: isInstance
79+
? COLORS.componentInstance
80+
: isScopeRoot
81+
? COLORS.accent
82+
: STATUS_BORDER[status];
7283

7384
const handleImgLoad = useCallback(() => {
7485
setImgLoaded(true);
@@ -266,6 +277,42 @@ export function ScreenNode({
266277
⊙ root
267278
</span>
268279
)}
280+
{isCanonical && (
281+
<span
282+
title="Reusable component — single source of truth for the spec"
283+
style={{
284+
fontSize: 9,
285+
fontWeight: 700,
286+
color: COLORS.componentCanonical,
287+
background: COLORS.componentBg,
288+
border: `1px solid ${COLORS.componentCanonical}`,
289+
borderRadius: 4,
290+
padding: "1px 5px",
291+
fontFamily: FONTS.mono,
292+
whiteSpace: "nowrap",
293+
}}
294+
>
295+
⟳ Component
296+
</span>
297+
)}
298+
{isInstance && (
299+
<span
300+
title="Instance of a reusable component — see canonical for spec"
301+
style={{
302+
fontSize: 9,
303+
fontWeight: 600,
304+
color: COLORS.componentInstance,
305+
background: COLORS.componentBgInstance,
306+
border: `1px solid ${COLORS.componentInstance}`,
307+
borderRadius: 4,
308+
padding: "1px 5px",
309+
fontFamily: FONTS.mono,
310+
whiteSpace: "nowrap",
311+
}}
312+
>
313+
↗ Instance
314+
</span>
315+
)}
269316
{(status !== "new" || isScopeRoot) && (
270317
<span
271318
style={{
@@ -323,7 +370,7 @@ export function ScreenNode({
323370
>
324371
S+
325372
</button>
326-
<button
373+
{!isInstanceVisual && <button
327374
className="screen-btn"
328375
onClick={(e) => { e.stopPropagation(); onAddHotspot(screen.id); }}
329376
title="Add tap area / button link"
@@ -339,7 +386,7 @@ export function ScreenNode({
339386
}}
340387
>
341388
+ Link
342-
</button>
389+
</button>}
343390
<button
344391
className="screen-btn"
345392
onClick={(e) => { e.stopPropagation(); onRemoveScreen(screen.id); }}
@@ -387,7 +434,7 @@ export function ScreenNode({
387434
}
388435
}
389436
onSelect(screen.id);
390-
if (screen.imageData && onImageAreaMouseDown) {
437+
if (screen.imageData && !isInstanceVisual && onImageAreaMouseDown) {
391438
onImageAreaMouseDown(e, screen.id);
392439
}
393440
}}
@@ -416,10 +463,12 @@ export function ScreenNode({
416463
className="hotspot-area"
417464
onMouseDown={(e) => {
418465
e.stopPropagation();
466+
if (isInstanceVisual) return;
419467
if (onHotspotMouseDown) onHotspotMouseDown(e, screen.id, hs.id);
420468
}}
421469
onDoubleClick={(e) => {
422470
e.stopPropagation();
471+
if (isInstanceVisual) return;
423472
if (onHotspotDoubleClick) onHotspotDoubleClick(e, screen.id, hs.id);
424473
}}
425474
style={{
@@ -456,7 +505,7 @@ export function ScreenNode({
456505
>
457506
{hs.label || "TAP"}
458507
{/* Drag handle for hotspot-to-screen connect */}
459-
{isSelected && (
508+
{isSelected && !isInstanceVisual && (
460509
<div
461510
className="hotspot-drag-handle"
462511
onMouseDown={(e) => {
@@ -483,7 +532,7 @@ export function ScreenNode({
483532
/>
484533
)}
485534
{/* Resize handles */}
486-
{isSelected && ["nw","n","ne","e","se","s","sw","w"].map((handle) => {
535+
{isSelected && !isInstanceVisual && ["nw","n","ne","e","se","s","sw","w"].map((handle) => {
487536
const pos = {
488537
nw: { left: -4, top: -4, cursor: "nwse-resize" },
489538
n: { left: "50%", top: -4, cursor: "ns-resize", transform: "translateX(-50%)" },

‎src/components/ScreensPanel.jsx‎

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState } from "react";
2-
import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE, Z_INDEX } from "../styles/theme";
2+
import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE, Z_INDEX, COMPONENT_CONFIG } from "../styles/theme";
33
import { SCREENS_PANEL_WIDTH } from "../constants";
44

55
export function ScreensPanel({
@@ -17,19 +17,21 @@ export function ScreensPanel({
1717
onTaskLinkChange,
1818
techStack,
1919
onTechStackChange,
20+
onSetComponent,
2021
isReadOnly,
2122
}) {
2223
const [briefOpen, setBriefOpen] = useState(false);
2324
const [techOpen, setTechOpen] = useState(false);
2425
const [contextMenu, setContextMenu] = useState(null); // { screenId, x, y }
26+
const [instancePickerOpen, setInstancePickerOpen] = useState(false);
2527

2628
const handleContextMenu = (e, screenId) => {
2729
e.preventDefault();
2830
e.stopPropagation();
2931
setContextMenu({ screenId, x: e.clientX, y: e.clientY });
3032
};
3133

32-
const closeContextMenu = () => setContextMenu(null);
34+
const closeContextMenu = () => { setContextMenu(null); setInstancePickerOpen(false); };
3335

3436
const handleStatusClick = (e, screen) => {
3537
e.stopPropagation();
@@ -513,6 +515,78 @@ export function ScreensPanel({
513515
</button>
514516
);
515517
})}
518+
{!isReadOnly && onSetComponent && (() => {
519+
const ctxScreen = screens.find((s) => s.id === contextMenu.screenId);
520+
if (!ctxScreen) return null;
521+
const isCanonical = ctxScreen.componentRole === "canonical";
522+
const isInstance = ctxScreen.componentRole === "instance";
523+
const canonicals = screens.filter((s) => s.componentRole === "canonical" && s.id !== ctxScreen.id);
524+
const itemStyle = {
525+
display: "flex",
526+
alignItems: "center",
527+
gap: 8,
528+
width: "100%",
529+
padding: "7px 14px",
530+
background: "none",
531+
border: "none",
532+
color: COLORS.text,
533+
cursor: "pointer",
534+
textAlign: "left",
535+
fontFamily: FONTS.ui,
536+
fontSize: 12,
537+
};
538+
return (
539+
<>
540+
<div style={{ borderTop: `1px solid ${COLORS.border}` }} />
541+
{!isCanonical && !isInstance && (
542+
<button
543+
onClick={() => { onSetComponent(ctxScreen.id, "canonical"); closeContextMenu(); }}
544+
style={itemStyle}
545+
onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.surfaceHover; }}
546+
onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}
547+
>
548+
<span style={{ width: 8, height: 8, borderRadius: "50%", background: COMPONENT_CONFIG.canonical.color, flexShrink: 0 }} />
549+
Mark as reusable component
550+
</button>
551+
)}
552+
{!isCanonical && !isInstance && canonicals.length > 0 && (
553+
<>
554+
<button
555+
onClick={() => setInstancePickerOpen((v) => !v)}
556+
style={itemStyle}
557+
onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.surfaceHover; }}
558+
onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}
559+
>
560+
<span style={{ width: 8, height: 8, borderRadius: "50%", background: COMPONENT_CONFIG.instance.color, flexShrink: 0 }} />
561+
Make instance of… {instancePickerOpen ? "▾" : "▸"}
562+
</button>
563+
{instancePickerOpen && canonicals.map((c) => (
564+
<button
565+
key={c.componentId}
566+
onClick={() => { onSetComponent(ctxScreen.id, "instance", { componentId: c.componentId }); closeContextMenu(); }}
567+
style={{ ...itemStyle, paddingLeft: 28, fontSize: 11, color: COLORS.textMuted }}
568+
onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.surfaceHover; }}
569+
onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}
570+
>
571+
{c.name || "(unnamed)"}
572+
</button>
573+
))}
574+
</>
575+
)}
576+
{(isCanonical || isInstance) && (
577+
<button
578+
onClick={() => { onSetComponent(ctxScreen.id, "unlink"); closeContextMenu(); }}
579+
style={itemStyle}
580+
onMouseEnter={(e) => { e.currentTarget.style.background = COLORS.surfaceHover; }}
581+
onMouseLeave={(e) => { e.currentTarget.style.background = "none"; }}
582+
>
583+
<span style={{ width: 8, height: 8, borderRadius: "50%", background: COLORS.textDim, flexShrink: 0 }} />
584+
Unlink component
585+
</button>
586+
)}
587+
</>
588+
);
589+
})()}
516590
</div>
517591
)}
518592
</div>

0 commit comments

Comments
 (0)