diff --git a/mcp-server/src/state.js b/mcp-server/src/state.js index 3461d7c..0a6b6a4 100644 --- a/mcp-server/src/state.js +++ b/mcp-server/src/state.js @@ -182,6 +182,8 @@ export class FlowState { tbdNote, roles: [], figmaSource: null, + componentId: null, + componentRole: null, }; this.screens.push(screen); @@ -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) { diff --git a/mcp-server/src/tools/screen-tools.js b/mcp-server/src/tools/screen-tools.js index a34d762..c2a71db 100644 --- a/mcp-server/src/tools/screen-tools.js +++ b/mcp-server/src/tools/screen-tools.js @@ -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: { @@ -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"], }, diff --git a/src/Drawd.jsx b/src/Drawd.jsx index aa3eeed..f363bd2 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -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, @@ -565,6 +566,7 @@ export default function Drawd({ initialRoomCode }) { onTaskLinkChange={setTaskLink} techStack={techStack} onTechStackChange={setTechStack} + onSetComponent={setScreenComponent} isReadOnly={isReadOnly} /> @@ -714,6 +716,7 @@ export default function Drawd({ initialRoomCode }) { onUpdateCodeRef={updateScreenCodeRef} onUpdateCriteria={updateScreenCriteria} onUpdateStatus={updateScreenStatus} + onSetComponent={setScreenComponent} isReadOnly={isReadOnly} /> )} diff --git a/src/components/CanvasArea.jsx b/src/components/CanvasArea.jsx index f59e13b..b88d933 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 { 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"; @@ -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 ( { clearSelection(); setSelectedScreen(id); setSelectedStickyNote(null); }} onDragStart={onDragStart} @@ -213,7 +218,8 @@ export function CanvasArea({ onCommentPinClick={onCommentPinClick} onDeselectComment={onDeselectComment} /> - ))} + ); + })} {stickyNotes.map((note) => ( { setImgLoaded(true); @@ -266,6 +277,42 @@ export function ScreenNode({ ⊙ root )} + {isCanonical && ( + + ⟳ Component + + )} + {isInstance && ( + + ↗ Instance + + )} {(status !== "new" || isScopeRoot) && ( S+ - + } ); })} + {!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 ( + <> +
+ {!isCanonical && !isInstance && ( + + )} + {!isCanonical && !isInstance && canonicals.length > 0 && ( + <> + + {instancePickerOpen && canonicals.map((c) => ( + + ))} + + )} + {(isCanonical || isInstance) && ( + + )} + + ); + })()}
)} diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index 2f63b9e..6a6476b 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1,8 +1,8 @@ -import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE } from "../styles/theme"; +import { COLORS, FONTS, STATUS_CONFIG, STATUS_CYCLE, COMPONENT_CONFIG } 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, isReadOnly }) { +export function Sidebar({ screen, screens, connections, onClose, onRename, onAddHotspot, onEditHotspot, onAddState, onSelectScreen, onUpdateStateName, onUpdateNotes, onUpdateCodeRef, onUpdateCriteria, onUpdateStatus, onUpdateTbd, onUpdateRoles, onSetComponent, isReadOnly }) { const [draftNotes, setDraftNotes] = useState(screen.notes || ""); const [notesScreenId, setNotesScreenId] = useState(screen.id); const [draftCodeRef, setDraftCodeRef] = useState(screen.codeRef || ""); @@ -35,6 +35,17 @@ export function Sidebar({ screen, screens, connections, onClose, onRename, onAdd const status = screen.status || "new"; const statusCfg = STATUS_CONFIG[status]; + // Component (shared/reusable screen) state derivations. + const isCanonical = screen.componentRole === "canonical"; + const isInstance = screen.componentRole === "instance"; + const canonicalScreens = screens.filter((s) => s.componentRole === "canonical"); + const myCanonical = isInstance + ? canonicalScreens.find((s) => s.componentId === screen.componentId) + : null; + const instanceCount = isCanonical + ? screens.filter((s) => s.componentId === screen.componentId && s.id !== screen.id).length + : 0; + return (
+ + {/* Reusable Component */} +
+
+ + Reusable Component + + {(isCanonical || isInstance) && !isReadOnly && ( + + )} +
+ + {!isCanonical && !isInstance && ( + <> + + {canonicalScreens.length > 0 && ( +
+
+ …or use an existing component: +
+ +
+ )} + + )} + + {isCanonical && ( +
+ + Component + + This screen is a reusable component. {instanceCount} instance{instanceCount === 1 ? "" : "s"}. +
+ )} + + {isInstance && ( +
+
+ + Instance + + of {myCanonical?.name || "(unknown)"}. +
+
+ Spec is defined on the canonical screen.{" "} + {myCanonical && ( + + )} +
+
+ )} +
+ + {/* Spec fields are hidden on instances — the canonical owns the spec. + We keep Hotspots and Screen States editable below since navigation context + can legitimately differ per placement of a component. */} + {!isInstance && ( + <> {/* Roles */}
+ + )} {/* Screen States */}
s.id === id); setScreens((prev) => { - const remaining = prev.filter((s) => s.id !== id); + let remaining = prev.filter((s) => s.id !== id); // Auto-cleanup: if only one screen left in the stateGroup, clear it if (removedScreen?.stateGroup) { const siblings = remaining.filter((s) => s.stateGroup === removedScreen.stateGroup); if (siblings.length === 1) { - return remaining.map((s) => + remaining = remaining.map((s) => s.stateGroup === removedScreen.stateGroup ? { ...s, stateGroup: null, stateName: "" } : s ); } } + // Auto-promote: if the removed screen was a canonical component, promote the first instance. + if (removedScreen?.componentRole === "canonical" && removedScreen.componentId) { + const firstInstance = remaining.find( + (s) => s.componentId === removedScreen.componentId && s.componentRole === "instance" + ); + if (firstInstance) { + remaining = remaining.map((s) => + s.id === firstInstance.id ? { ...s, componentRole: "canonical" } : s + ); + } + } return remaining; }); setConnections((prev) => @@ -247,6 +260,72 @@ export function useScreenManager(pan, zoom, canvasRef, commentCallbacks = {}) { setScreens((prev) => prev.map((s) => (s.id === id ? { ...s, status } : s))); }, []); + // Component (shared/reusable screen) actions. + // mode: "canonical" | "instance" | "unlink" + // For "canonical": assigns a fresh componentId (or re-uses existing) and componentRole "canonical". + // For "instance": caller passes componentId of an existing canonical screen. + // For "unlink": clears both fields. If the screen was canonical with instances, auto-promote + // the first instance to canonical so the group survives. + const setScreenComponent = useCallback((id, mode, opts = {}) => { + pushHistory(screens, connections, documents); + setScreens((prev) => { + const target = prev.find((s) => s.id === id); + if (!target) return prev; + + if (mode === "canonical") { + // If already canonical, no-op. If instance, promote it (keep its componentId). + // Otherwise mint a new componentId. + const newComponentId = + target.componentRole === "canonical" + ? target.componentId + : target.componentId || generateId(); + return prev.map((s) => + s.id === id + ? { ...s, componentId: newComponentId, componentRole: "canonical" } + : s + ); + } + + if (mode === "instance") { + const targetComponentId = opts.componentId; + if (!targetComponentId) return prev; + // Verify a canonical exists for that componentId + const canonicalExists = prev.some( + (s) => s.componentId === targetComponentId && s.componentRole === "canonical" + ); + if (!canonicalExists) return prev; + return prev.map((s) => + s.id === id + ? { ...s, componentId: targetComponentId, componentRole: "instance" } + : s + ); + } + + if (mode === "unlink") { + const wasCanonical = target.componentRole === "canonical"; + const groupId = target.componentId; + if (wasCanonical && groupId) { + // Auto-promote: find first instance and make it canonical + const firstInstance = prev.find( + (s) => s.id !== id && s.componentId === groupId && s.componentRole === "instance" + ); + if (firstInstance) { + return prev.map((s) => { + if (s.id === id) return { ...s, componentId: null, componentRole: null }; + if (s.id === firstInstance.id) return { ...s, componentRole: "canonical" }; + return s; + }); + } + } + return prev.map((s) => + s.id === id ? { ...s, componentId: null, componentRole: null } : s + ); + } + + return prev; + }); + }, [screens, connections, documents, pushHistory]); + const markAllExisting = useCallback(() => { setScreens((prev) => prev.map((s) => ({ ...s, status: "existing" }))); }, []); @@ -300,6 +379,27 @@ export function useScreenManager(pan, zoom, canvasRef, commentCallbacks = {}) { ); } }); + // Auto-promote any orphaned component groups: if a canonical was removed and instances remain, + // promote the first remaining instance to canonical. + const removedCanonicalGroups = new Set( + removedScreens + .filter((s) => s.componentRole === "canonical" && s.componentId) + .map((s) => s.componentId) + ); + removedCanonicalGroups.forEach((groupId) => { + const stillCanonical = remaining.some( + (s) => s.componentId === groupId && s.componentRole === "canonical" + ); + if (stillCanonical) return; + const firstInstance = remaining.find( + (s) => s.componentId === groupId && s.componentRole === "instance" + ); + if (firstInstance) { + remaining = remaining.map((s) => + s.id === firstInstance.id ? { ...s, componentRole: "canonical" } : s + ); + } + }); return remaining; }); setConnections((prev) => @@ -1028,6 +1128,11 @@ export function useScreenManager(pan, zoom, canvasRef, commentCallbacks = {}) { return cloned; }); + // If we duplicated a canonical screen, the copy must not also be canonical + // (would create two canonicals for one componentId). Demote the copy to instance, + // which expresses the user intent: "I want this same component used again here." + const clonedRole = s.componentRole === "canonical" ? "instance" : s.componentRole; + return { ...s, id: screenIdMap.get(s.id), @@ -1038,6 +1143,7 @@ export function useScreenManager(pan, zoom, canvasRef, commentCallbacks = {}) { : null, stateName: s.stateGroup && stateGroupMap.has(s.stateGroup) ? s.stateName : "", hotspots: clonedHotspots, + componentRole: clonedRole, }; }); @@ -1097,6 +1203,7 @@ export function useScreenManager(pan, zoom, canvasRef, commentCallbacks = {}) { updateScreenCodeRef, updateScreenCriteria, updateScreenStatus, + setScreenComponent, markAllExisting, assignScreenImage, patchScreenImage, diff --git a/src/pages/docs/userGuide.md b/src/pages/docs/userGuide.md index 471525a..6f70f29 100644 --- a/src/pages/docs/userGuide.md +++ b/src/pages/docs/userGuide.md @@ -283,6 +283,45 @@ State groups are reflected in the AI build instructions. Each variant screen is > [!NOTE] > Deleting the only remaining variant in a state group automatically removes the group, returning that screen to standalone status. +## Reusable Components + +When the same screen (a login modal, a toast, a bottom-nav bar) appears in multiple flows, mark it as a reusable component. The AI build instructions will then describe it once in `components/.md` and have every other placement reference that single file — no duplicate code generation. + +### Marking a screen as a reusable component + +1. Select the screen. +2. In the right sidebar, find the **Reusable Component** section. +3. Tick **Mark as reusable component**. A purple `⟳ Component` badge appears on the screen and its border tints purple. + +You can also right-click the screen and choose **Mark as reusable component** from the context menu. + +### Marking another screen as an instance + +1. Select a different screen that should be the same component. +2. In the **Reusable Component** section, use the **Instance of…** dropdown to pick the canonical component you just created. +3. The screen's spec fields (description, code reference, acceptance criteria, roles) collapse — the spec lives on the canonical screen. The screen gets a `↗ Instance` badge. +4. The instance's image and hotspots on the canvas mirror the canonical's automatically. Edit the canonical and every instance updates. + +> [!TIP] +> Hotspot positions, dimensions, and the `+ Link` button are read-only on instances — the canonical owns the visual spec. To edit a hotspot or replace the image, jump to the canonical via the **Open canonical →** link in the sidebar. + +### Unlinking and conversions + +Click **Unlink** in the sidebar's component section (or right-click → Unlink component) to detach a screen from its component group. + +If you unlink a canonical that has instances, the first instance is auto-promoted to canonical so the group survives. If you delete the canonical, the same auto-promote happens. + +### What this changes in the AI build instructions + +When you generate instructions, you'll see: + +- A new `components/` directory with one markdown file per reusable component. +- The component file contains the full spec plus a placements table listing every screen where the component is used. +- In `screens.md`, the canonical screen still has its full spec, but each instance is replaced with a one-line stub: *"Instance of [ComponentName](components/component-name.md). See component spec."* +- `main.md` advertises the `components/` directory in its instruction-files table. + +The result: the AI agent sees the component definition once, then sees several lightweight references — exactly how a real codebase reuses a component. + ## Sticky Notes Sticky notes are colored annotations that float on the canvas. Use them for reminders, comments, or project notes alongside your screen designs. diff --git a/src/styles/theme.js b/src/styles/theme.js index 021ab9a..ea679f4 100644 --- a/src/styles/theme.js +++ b/src/styles/theme.js @@ -38,6 +38,12 @@ export const COLORS = { statusExisting: "#636e72", statusExistingBorder: "#444", statusTbd: "#f0932b", + // Reusable component (shared screen) colors. Purple distinguishes them from status (green/yellow/grey) + // and TBD (orange) so a glance at the canvas tells the user what's a component vs. a normal screen. + componentCanonical: "#a78bfa", + componentInstance: "#7c3aed", + componentBg: "rgba(167,139,250,0.15)", + componentBgInstance: "rgba(124,58,237,0.12)", imageAreaBg: "#0d0d15", // Multi-object selection selection: "rgba(97,175,239,0.08)", @@ -89,6 +95,13 @@ export const STATUS_CONFIG = { // Maps each status to the next in the cycle export const STATUS_CYCLE = { new: "modify", modify: "existing", existing: "new" }; +// Reusable component badge config. Mirrors STATUS_CONFIG so consumers can render +// a component pill alongside the status pill with the same styling pattern. +export const COMPONENT_CONFIG = { + canonical: { label: "Component", color: COLORS.componentCanonical, bg: COLORS.componentBg }, + instance: { label: "Instance", color: COLORS.componentInstance, bg: COLORS.componentBgInstance }, +}; + export const styles = { monoLabel: { color: COLORS.textMuted, diff --git a/src/utils/generateInstructionFiles.js b/src/utils/generateInstructionFiles.js index 81bf373..3e7ac84 100644 --- a/src/utils/generateInstructionFiles.js +++ b/src/utils/generateInstructionFiles.js @@ -16,6 +16,47 @@ export function sortedScreens(screens) { return [...screens].sort((a, b) => a.x - b.x || a.y - b.y); } +// --- Reusable component helpers --- +// componentRole: "canonical" (owns spec) | "instance" (references canonical) | null +// componentId: shared key linking canonical + instances +// +// Returns: +// { +// canonicalByComponentId: Map, // the screen that owns the spec +// instancesByComponentId: Map, // all instances (excluding canonical) +// componentSlugByComponentId: Map, // file slug for components/.md +// } +// Falsy componentId / missing canonical -> screen treated as a normal (non-component) screen. +export function getComponentGroups(screens) { + const canonicalByComponentId = new Map(); + const instancesByComponentId = new Map(); + const componentSlugByComponentId = new Map(); + + for (const s of screens) { + if (!s.componentId) continue; + if (s.componentRole === "canonical") canonicalByComponentId.set(s.componentId, s); + } + for (const s of screens) { + if (!s.componentId) continue; + if (!canonicalByComponentId.has(s.componentId)) continue; + if (s.componentRole === "instance") { + const list = instancesByComponentId.get(s.componentId) || []; + list.push(s); + instancesByComponentId.set(s.componentId, list); + } + } + // Disambiguate slugs in case two components share a name. + const seenSlugs = new Map(); + for (const [componentId, canonical] of canonicalByComponentId) { + const base = slugify(canonical.name || "component") || "component"; + const count = (seenSlugs.get(base) || 0) + 1; + seenSlugs.set(base, count); + componentSlugByComponentId.set(componentId, count === 1 ? base : `${base}-${count}`); + } + + return { canonicalByComponentId, instancesByComponentId, componentSlugByComponentId }; +} + export function detectDeviceType(imageWidth, imageHeight) { if (!imageWidth || !imageHeight) return null; @@ -97,7 +138,7 @@ const INSTRUCTION_SCHEMA_VERSION = 1; // --- Sub-generators --- -function generateMainMd(screens, connections, options, navAnalysis, images, documents = [], screenGroups = []) { +function generateMainMd(screens, connections, options, navAnalysis, images, documents = [], screenGroups = [], componentInfo = null) { const sorted = sortedScreens(screens); const platform = options.platform || "auto"; const platformLabel = platform === "auto" @@ -182,6 +223,9 @@ function generateMainMd(screens, connections, options, navAnalysis, images, docu if (documents.length > 0) { md += `| \`documents.md\` | API specs, design guides, and project reference documents | When a hotspot references an API call or external document |\n`; } + if (componentInfo && componentInfo.canonicalByComponentId.size > 0) { + md += `| \`components/\` | Reusable component specs — implement once, reuse for every instance | Before implementing any screen marked as a component or instance |\n`; + } md += `\n`; md += `## How to Use These Instructions\n\n`; @@ -527,14 +571,20 @@ function generateDocumentsMd(documents) { return md; } -function generateScreensMd(screens, connections, images, documents = []) { +function generateScreensMd(screens, connections, images, documents = [], componentInfo = null) { const sorted = sortedScreens(screens); + const { canonicalByComponentId, componentSlugByComponentId } = componentInfo || getComponentGroups(screens); // Separate screens by status const toBuild = sorted.filter(s => !s.status || s.status === "new" || s.status === "modify"); const contextOnly = sorted.filter(s => s.status === "existing"); let md = `# Screens\n\n`; + if (canonicalByComponentId.size > 0) { + md += `> **Reusable components:** Some screens below are marked as components or instances.\n`; + md += `> Component specs live in \`components/.md\` — implement each component **once**\n`; + md += `> and reuse it everywhere it appears. Instances do not duplicate the spec.\n\n`; + } // Build stateGroup map const stateGroups = {}; @@ -566,9 +616,33 @@ function generateScreensMd(screens, connections, images, documents = []) { }).join(", ")}` : ""; + // Reusable component handling: canonical gets a "see component file" header but + // still includes the full spec inline (so screens.md remains self-contained for AI). + // Instances are reduced to a one-line stub pointing at the component file — no spec dup. + const isCanonical = s.componentRole === "canonical" && canonicalByComponentId.has(s.componentId); + const isInstance = s.componentRole === "instance" && canonicalByComponentId.has(s.componentId); + const componentSlug = (isCanonical || isInstance) ? componentSlugByComponentId.get(s.componentId) : null; + const canonicalScreen = isInstance ? canonicalByComponentId.get(s.componentId) : null; + const componentTag = isCanonical ? " 🔁 *(reusable component)*" : isInstance ? " ↗ *(instance)*" : ""; + + if (isInstance) { + output.add(s.id); + md += `## Screen ${screenNum}: ${s.name}${statusTag}${tbdTag}${rolesTag}${componentTag} \`[${screenReqId(s)}]\`\n\n`; + md += `> **Instance of [${canonicalScreen.name}](components/${componentSlug}.md).** See the component\n`; + md += `> file for the full spec — do not regenerate or duplicate it here. Implement this screen by\n`; + md += `> reusing the component; only the navigation context (incoming/outgoing links) is specific\n`; + md += `> to this placement.\n\n`; + md += `---\n\n`; + return; + } + if (s.stateGroup && stateGroups[s.stateGroup]?.length >= 2) { const group = stateGroups[s.stateGroup]; - md += `## Screen ${screenNum}: ${s.name}${statusTag}${tbdTag}${rolesTag} \`[${screenReqId(s)}]\`\n\n`; + md += `## Screen ${screenNum}: ${s.name}${statusTag}${tbdTag}${rolesTag}${componentTag} \`[${screenReqId(s)}]\`\n\n`; + if (isCanonical) { + md += `> **Reusable component.** The full spec also lives in \`components/${componentSlug}.md\`.\n`; + md += `> Implement this screen **once** as a component and reuse it everywhere it appears.\n\n`; + } md += `*This screen has ${group.length} states:*\n\n`; group.forEach((gs) => { @@ -580,7 +654,11 @@ function generateScreensMd(screens, connections, images, documents = []) { md += `---\n\n`; } else { output.add(s.id); - md += `## Screen ${screenNum}: ${s.name}${statusTag}${tbdTag}${rolesTag} \`[${screenReqId(s)}]\`\n\n`; + md += `## Screen ${screenNum}: ${s.name}${statusTag}${tbdTag}${rolesTag}${componentTag} \`[${screenReqId(s)}]\`\n\n`; + if (isCanonical) { + md += `> **Reusable component.** The full spec also lives in \`components/${componentSlug}.md\`.\n`; + md += `> Implement this screen **once** as a component and reuse it everywhere it appears.\n\n`; + } if (s.tbd && s.tbdNote) { md += `> ⚠️ **TBD:** ${s.tbdNote}\n\n`; } @@ -605,6 +683,41 @@ function generateScreensMd(screens, connections, images, documents = []) { return md; } +// Emit one markdown file per reusable component group. The file is the single source of +// truth for the component's spec and lists every placement (canonical + instances) where +// it appears, so the AI agent knows exactly where the same component is reused. +function generateComponentFiles(screens, connections, images, documents, componentInfo) { + const { canonicalByComponentId, instancesByComponentId, componentSlugByComponentId } = componentInfo; + const files = []; + for (const [componentId, canonical] of canonicalByComponentId) { + const slug = componentSlugByComponentId.get(componentId); + const instances = instancesByComponentId.get(componentId) || []; + let md = `# Component: ${canonical.name}\n\n`; + md += `> **Reusable component — single source of truth.** Implement this component **once**\n`; + md += `> and reuse it for every placement listed below. Do not regenerate the spec for each\n`; + md += `> instance; \`screens.md\` references this file instead of duplicating it.\n\n`; + md += `**Component ID:** \`${screenReqId(canonical)}\`\n\n`; + + md += `## Placements\n\n`; + md += `| Placement | Role | Incoming Links | Outgoing Links |\n`; + md += `|-----------|------|----------------|----------------|\n`; + const placements = [canonical, ...instances]; + for (const p of placements) { + const incoming = connections.filter((c) => c.toScreenId === p.id).length; + const outgoing = connections.filter((c) => c.fromScreenId === p.id).length; + const role = p.id === canonical.id ? "canonical" : "instance"; + md += `| ${p.name} | ${role} | ${incoming} | ${outgoing} |\n`; + } + md += `\n`; + + md += `## Spec\n\n`; + md += generateScreenDetailMd(canonical, screens, connections, images, documents); + + files.push({ name: `components/${slug}.md`, content: md }); + } + return files; +} + function generateNavigationMd(screens, connections, navAnalysis) { let md = `# Navigation Architecture\n\n`; @@ -923,16 +1036,21 @@ export function generateInstructionFiles(screens, connections, options = {}) { const screenGroups = options.screenGroups || []; const navAnalysis = analyzeNavGraph(screens, connections); const images = extractImages(screens); + const componentInfo = getComponentGroups(screens); const generatedAt = new Date().toISOString(); const schemaHeader = `\n\n`; const contentFiles = [ - { name: "main.md", content: generateMainMd(screens, connections, options, navAnalysis, images, documents, screenGroups) }, - { name: "screens.md", content: generateScreensMd(screens, connections, images, documents) }, + { name: "main.md", content: generateMainMd(screens, connections, options, navAnalysis, images, documents, screenGroups, componentInfo) }, + { name: "screens.md", content: generateScreensMd(screens, connections, images, documents, componentInfo) }, { name: "navigation.md", content: generateNavigationMd(screens, connections, navAnalysis) }, { name: "build-guide.md", content: generateBuildGuideMd(screens, connections, options, screenGroups) }, ]; + // Emit one file per reusable component group (single source of truth for AI). + const componentFiles = generateComponentFiles(screens, connections, images, documents, componentInfo); + for (const cf of componentFiles) contentFiles.push(cf); + const documentsMd = generateDocumentsMd(documents); if (documentsMd) { contentFiles.push({ name: "documents.md", content: documentsMd }); diff --git a/src/utils/generateInstructionFiles.test.js b/src/utils/generateInstructionFiles.test.js index c5ec8ff..3e22299 100644 --- a/src/utils/generateInstructionFiles.test.js +++ b/src/utils/generateInstructionFiles.test.js @@ -5,6 +5,7 @@ import { detectDeviceType, mostCommon, generateInstructionFiles, + getComponentGroups, } from "./generateInstructionFiles.js"; // --- Helper unit tests --- @@ -268,3 +269,136 @@ describe("generateInstructionFiles", () => { expect(buildGuide.content).not.toContain(".accessibilityLabel"); }); }); + +// --- Reusable component tests --- + +describe("getComponentGroups", () => { + const baseScreen = { id: "s", name: "Card", x: 0, y: 0, width: 390, imageWidth: 390, imageHeight: 844, hotspots: [] }; + + it("returns empty maps when no screens have componentId", () => { + const result = getComponentGroups([{ ...baseScreen, id: "s1" }]); + expect(result.canonicalByComponentId.size).toBe(0); + expect(result.instancesByComponentId.size).toBe(0); + }); + + it("groups canonical and instances by componentId", () => { + const screens = [ + { ...baseScreen, id: "s1", name: "Card", componentId: "c1", componentRole: "canonical" }, + { ...baseScreen, id: "s2", name: "Card on Home", componentId: "c1", componentRole: "instance" }, + { ...baseScreen, id: "s3", name: "Card on Detail", componentId: "c1", componentRole: "instance" }, + ]; + const result = getComponentGroups(screens); + expect(result.canonicalByComponentId.get("c1").id).toBe("s1"); + expect(result.instancesByComponentId.get("c1").map((s) => s.id)).toEqual(["s2", "s3"]); + expect(result.componentSlugByComponentId.get("c1")).toBe("card"); + }); + + it("drops orphan instances that have no canonical", () => { + const screens = [ + { ...baseScreen, id: "s1", name: "Orphan", componentId: "c1", componentRole: "instance" }, + ]; + const result = getComponentGroups(screens); + expect(result.canonicalByComponentId.size).toBe(0); + expect(result.instancesByComponentId.size).toBe(0); + }); + + it("disambiguates slugs when two components share a name", () => { + const screens = [ + { ...baseScreen, id: "s1", name: "Card", componentId: "c1", componentRole: "canonical" }, + { ...baseScreen, id: "s2", name: "Card", componentId: "c2", componentRole: "canonical" }, + ]; + const result = getComponentGroups(screens); + const slugs = [result.componentSlugByComponentId.get("c1"), result.componentSlugByComponentId.get("c2")]; + expect(slugs).toContain("card"); + expect(slugs).toContain("card-2"); + }); +}); + +describe("generateInstructionFiles - reusable components", () => { + const minimalScreen = { + id: "s1", + name: "Home", + x: 0, + y: 0, + width: 390, + imageWidth: 390, + imageHeight: 844, + hotspots: [], + }; + const opts = { platform: "swiftui" }; + + it("emits exactly one components/.md per canonical", () => { + const screens = [ + { ...minimalScreen, id: "s1", name: "Card", componentId: "c1", componentRole: "canonical" }, + { ...minimalScreen, id: "s2", name: "Home", x: 400, componentId: "c1", componentRole: "instance" }, + { ...minimalScreen, id: "s3", name: "Detail", x: 800, componentId: "c1", componentRole: "instance" }, + ]; + const result = generateInstructionFiles(screens, [], opts); + const componentFiles = result.files.filter((f) => f.name.startsWith("components/")); + expect(componentFiles.length).toBe(1); + expect(componentFiles[0].name).toBe("components/card.md"); + }); + + it("emits no component files when no screens are marked canonical", () => { + const result = generateInstructionFiles([minimalScreen], [], opts); + const componentFiles = result.files.filter((f) => f.name.startsWith("components/")); + expect(componentFiles.length).toBe(0); + }); + + it("renders instance screens as a stub linking to the canonical component file", () => { + const screens = [ + { ...minimalScreen, id: "s1", name: "Card", componentId: "c1", componentRole: "canonical" }, + { ...minimalScreen, id: "s2", name: "Home", x: 400, componentId: "c1", componentRole: "instance" }, + ]; + const result = generateInstructionFiles(screens, [], opts); + const screensFile = result.files.find((f) => f.name === "screens.md"); + expect(screensFile.content).toContain("Instance of [Card](components/card.md)"); + }); + + it("does not duplicate hotspot tables for instance screens", () => { + const hotspot = { + id: "h1", + label: "Login", + elementType: "button", + interactionType: "tap", + action: "navigate", + x: 10, y: 10, w: 80, h: 15, + }; + const screens = [ + { ...minimalScreen, id: "s1", name: "Card", hotspots: [hotspot], componentId: "c1", componentRole: "canonical" }, + { ...minimalScreen, id: "s2", name: "Home", x: 400, hotspots: [hotspot], componentId: "c1", componentRole: "instance" }, + ]; + const result = generateInstructionFiles(screens, [], opts); + const screensFile = result.files.find((f) => f.name === "screens.md"); + // Instance section should NOT include a hotspots table — only the canonical does. + // Hotspots table emits a "| Login |" row; canonical contributes one such row. + const matches = screensFile.content.match(/\| Login \|/g) || []; + expect(matches.length).toBe(1); + }); + + it("adds a components/ row to main.md only when canonicals exist", () => { + const noComponentResult = generateInstructionFiles([minimalScreen], [], opts); + const noComponentMain = noComponentResult.files.find((f) => f.name === "main.md"); + expect(noComponentMain.content).not.toContain("Reusable component specs"); + + const screens = [ + { ...minimalScreen, id: "s1", name: "Card", componentId: "c1", componentRole: "canonical" }, + ]; + const withComponentResult = generateInstructionFiles(screens, [], opts); + const withComponentMain = withComponentResult.files.find((f) => f.name === "main.md"); + expect(withComponentMain.content).toContain("Reusable component specs"); + }); + + it("component file lists placements with canonical and instances", () => { + const screens = [ + { ...minimalScreen, id: "s1", name: "Card", componentId: "c1", componentRole: "canonical" }, + { ...minimalScreen, id: "s2", name: "Home", x: 400, componentId: "c1", componentRole: "instance" }, + ]; + const result = generateInstructionFiles(screens, [], opts); + const componentFile = result.files.find((f) => f.name === "components/card.md"); + expect(componentFile.content).toContain("# Component: Card"); + expect(componentFile.content).toContain("canonical"); + expect(componentFile.content).toContain("instance"); + expect(componentFile.content).toContain("Home"); + }); +}); diff --git a/src/utils/importFlow.js b/src/utils/importFlow.js index 35d9aa3..1622fbb 100644 --- a/src/utils/importFlow.js +++ b/src/utils/importFlow.js @@ -49,6 +49,8 @@ export function importFlow(fileText) { if (screen.sourceWidth === undefined) screen.sourceWidth = null; if (screen.sourceHeight === undefined) screen.sourceHeight = null; if (screen.wireframe === undefined) screen.wireframe = null; + if (screen.componentId === undefined) screen.componentId = null; + if (screen.componentRole === undefined) screen.componentRole = null; if (Array.isArray(screen.hotspots)) { for (const hs of screen.hotspots) { if (!hs.elementType) hs.elementType = "button"; @@ -141,5 +143,39 @@ export function importFlow(fileText) { // Backward compat: comments (v14+) if (!Array.isArray(data.comments)) data.comments = []; + // Defensive: enforce at most one canonical per componentId. If duplicates exist + // (e.g. from a hand-edited file), keep the first canonical and demote the rest to instances. + // Also clear stray componentRole when there's no canonical at all. + { + const seenCanonical = new Set(); + const groupHasCanonical = new Set(); + for (const s of data.screens) { + if (s.componentRole === "canonical" && s.componentId) groupHasCanonical.add(s.componentId); + } + for (const s of data.screens) { + if (!s.componentId) { + s.componentRole = null; + continue; + } + if (s.componentRole === "canonical") { + if (seenCanonical.has(s.componentId)) { + s.componentRole = "instance"; + } else { + seenCanonical.add(s.componentId); + } + } else if (s.componentRole === "instance") { + // Instance without a canonical somewhere in the file -> drop component link. + if (!groupHasCanonical.has(s.componentId)) { + s.componentId = null; + s.componentRole = null; + } + } else { + // Has componentId but no role -> treat as not linked. + s.componentId = null; + s.componentRole = null; + } + } + } + return data; } diff --git a/src/utils/resolveInstanceVisuals.js b/src/utils/resolveInstanceVisuals.js new file mode 100644 index 0000000..d0dfdd7 --- /dev/null +++ b/src/utils/resolveInstanceVisuals.js @@ -0,0 +1,44 @@ +// resolveInstanceVisuals +// +// When a screen is marked as a component instance, on the canvas it should +// look identical to its canonical (same image, same hotspot positions, same +// dimensions). The canonical is the single source of truth for the visual +// spec — instances are placements. +// +// This helper is the render-time bridge: pass any screen and the full screens +// array, get back either the screen unchanged (canonical / unlinked / orphan +// instance) or a shallow-merged screen whose visual fields come from the +// canonical while identity fields (id, x, y, name, status, comments, etc.) +// stay from the instance. +// +// We intentionally do NOT copy data into instances on save — keeping this at +// render time means edits to the canonical propagate to every instance +// automatically. + +const VISUAL_FIELDS = [ + "imageData", + "imageWidth", + "imageHeight", + "width", + "hotspots", +]; + +export function resolveInstanceVisuals(screen, screens) { + if (!screen || screen.componentRole !== "instance" || !screen.componentId) { + return screen; + } + const canonical = (screens || []).find( + (s) => s && s.componentId === screen.componentId && s.componentRole === "canonical" + ); + if (!canonical) return screen; + + const merged = { ...screen }; + for (const field of VISUAL_FIELDS) { + if (canonical[field] !== undefined) merged[field] = canonical[field]; + } + return merged; +} + +export function isResolvedInstance(screen) { + return !!(screen && screen.componentRole === "instance" && screen.componentId); +} diff --git a/src/utils/resolveInstanceVisuals.test.js b/src/utils/resolveInstanceVisuals.test.js new file mode 100644 index 0000000..9da4347 --- /dev/null +++ b/src/utils/resolveInstanceVisuals.test.js @@ -0,0 +1,122 @@ +import { describe, it, expect } from "vitest"; +import { resolveInstanceVisuals, isResolvedInstance } from "./resolveInstanceVisuals.js"; + +const baseScreen = (overrides = {}) => ({ + id: "s", + name: "Card", + x: 0, + y: 0, + width: 390, + imageWidth: 390, + imageHeight: 844, + imageData: null, + hotspots: [], + ...overrides, +}); + +describe("resolveInstanceVisuals", () => { + it("returns screen unchanged when not an instance", () => { + const screen = baseScreen({ id: "s1", componentRole: "canonical", componentId: "c1" }); + const result = resolveInstanceVisuals(screen, [screen]); + expect(result).toBe(screen); + }); + + it("returns screen unchanged when componentRole is null", () => { + const screen = baseScreen({ id: "s1" }); + const result = resolveInstanceVisuals(screen, [screen]); + expect(result).toBe(screen); + }); + + it("returns screen unchanged when canonical is missing (orphan instance)", () => { + const orphan = baseScreen({ id: "s1", componentRole: "instance", componentId: "c1" }); + const result = resolveInstanceVisuals(orphan, [orphan]); + expect(result).toBe(orphan); + }); + + it("merges canonical's image + dimensions + hotspots into instance", () => { + const canonical = baseScreen({ + id: "s1", + name: "Card", + componentId: "c1", + componentRole: "canonical", + imageData: "data:image/png;base64,CANONICAL", + imageWidth: 800, + imageHeight: 600, + width: 800, + hotspots: [{ id: "h1", label: "Tap", x: 10, y: 10, w: 50, h: 20 }], + }); + const instance = baseScreen({ + id: "s2", + name: "Card on Home", + x: 1000, + y: 200, + componentId: "c1", + componentRole: "instance", + imageData: "data:image/png;base64,STALE", + hotspots: [], + }); + const result = resolveInstanceVisuals(instance, [canonical, instance]); + expect(result.imageData).toBe("data:image/png;base64,CANONICAL"); + expect(result.imageWidth).toBe(800); + expect(result.imageHeight).toBe(600); + expect(result.width).toBe(800); + expect(result.hotspots).toEqual(canonical.hotspots); + }); + + it("preserves identity fields (id, name, position) from the instance", () => { + const canonical = baseScreen({ + id: "s1", + name: "Card", + componentId: "c1", + componentRole: "canonical", + imageData: "data:image/png;base64,CANONICAL", + }); + const instance = baseScreen({ + id: "s2", + name: "Card on Home", + x: 1000, + y: 200, + componentId: "c1", + componentRole: "instance", + }); + const result = resolveInstanceVisuals(instance, [canonical, instance]); + expect(result.id).toBe("s2"); + expect(result.name).toBe("Card on Home"); + expect(result.x).toBe(1000); + expect(result.y).toBe(200); + expect(result.componentRole).toBe("instance"); + }); + + it("does not mutate the original instance", () => { + const canonical = baseScreen({ + id: "s1", + componentId: "c1", + componentRole: "canonical", + imageData: "data:image/png;base64,CANONICAL", + }); + const instance = baseScreen({ + id: "s2", + componentId: "c1", + componentRole: "instance", + imageData: "data:image/png;base64,STALE", + }); + const before = { ...instance }; + resolveInstanceVisuals(instance, [canonical, instance]); + expect(instance).toEqual(before); + }); + + it("returns falsy input unchanged", () => { + expect(resolveInstanceVisuals(null, [])).toBe(null); + expect(resolveInstanceVisuals(undefined, [])).toBe(undefined); + }); +}); + +describe("isResolvedInstance", () => { + it("returns true only for instances with a componentId", () => { + expect(isResolvedInstance({ componentRole: "instance", componentId: "c1" })).toBe(true); + expect(isResolvedInstance({ componentRole: "instance", componentId: null })).toBe(false); + expect(isResolvedInstance({ componentRole: "canonical", componentId: "c1" })).toBe(false); + expect(isResolvedInstance({})).toBe(false); + expect(isResolvedInstance(null)).toBe(false); + }); +});