diff --git a/src/Drawd.jsx b/src/Drawd.jsx index 05d1664..365285a 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { COLORS, FONTS, FONT_LINK, Z_INDEX } from "./styles/theme"; import { HEADER_HEIGHT, DEFAULT_SCREEN_WIDTH, DEFAULT_SCREEN_HEIGHT, FILE_EXTENSION, LEGACY_FILE_EXTENSION } from "./constants"; import { generateInstructionFiles } from "./utils/generateInstructionFiles"; @@ -87,9 +87,17 @@ export default function Drawd() { const [screenGroups, setScreenGroups] = useState([]); const [selectedScreenGroup, setSelectedScreenGroup] = useState(null); + // ── Navigation structure (derived from screenGroups) ───────────────────── + const navigationStructure = useMemo(() => { + const navStacks = screenGroups.filter((g) => g.type === "nav-stack"); + if (navStacks.length === 0) return { type: null, stackGroupIds: [] }; + if (navStacks.length === 1) return { type: "single-stack", stackGroupIds: [navStacks[0].id] }; + return { type: "tab-bar", stackGroupIds: navStacks.map((g) => g.id) }; + }, [screenGroups]); + // ── Screen group callbacks ──────────────────────────────────────────────── - const addScreenGroup = useCallback((name, screenIds = [], color = COLORS.accent008) => { - const group = { id: generateId(), name, screenIds, color, folderHint: "" }; + const addScreenGroup = useCallback((name, screenIds = [], color = COLORS.accent008, type = "feature-area") => { + const group = { id: generateId(), name, screenIds, color, folderHint: "", type, tabIndex: null, tabIcon: "", stackEntryScreenId: null }; setScreenGroups((prev) => [...prev, group]); return group.id; }, []); @@ -161,7 +169,7 @@ export default function Drawd() { const { connectedFileName, saveStatus, isFileSystemSupported, openFile, saveAs, saveNow, disconnect, - } = useFilePersistence(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups); + } = useFilePersistence(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups, navigationStructure); // ── File actions ─────────────────────────────────────────────────── const onOpen = useCallback(async () => { @@ -346,7 +354,7 @@ export default function Drawd() { // ── Import / export ──────────────────────────────────────────────────────────────── const { importConfirm, setImportConfirm, importFileRef, onExport, onImport, onImportFileChange, onImportReplace, onImportMerge } = - useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups }); + useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, navigationStructure, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups }); // ── Keyboard shortcuts ────────────────────────────────────────────────────────────── useKeyboardShortcuts({ @@ -397,6 +405,14 @@ export default function Drawd() { clearSelection(); }, [canvasSelection, addScreenGroup, clearSelection]); + const handleStackSelection = useCallback(() => { + const selectedScreenIds = canvasSelection.filter((i) => i.type === "screen").map((i) => i.id); + if (selectedScreenIds.length === 0) return; + const gid = addScreenGroup("Nav Stack", selectedScreenIds, COLORS.accent008, "nav-stack"); + updateScreenGroup(gid, { stackEntryScreenId: selectedScreenIds[0] }); + clearSelection(); + }, [canvasSelection, addScreenGroup, updateScreenGroup, clearSelection]); + const onDeleteSelection = useCallback(() => { const screenIds = canvasSelection.filter((i) => i.type === "screen").map((i) => i.id); const stickyIds = canvasSelection.filter((i) => i.type === "sticky").map((i) => i.id); @@ -426,18 +442,19 @@ export default function Drawd() { techStack, dataModels, screenGroups, + navigationStructure, scopeScreenIds, allScreens: screens, warnings, }); - }, [screens, connections, documents, featureBrief, scopeScreenIds, taskLink, techStack, dataModels, screenGroups]); + }, [screens, connections, documents, featureBrief, scopeScreenIds, taskLink, techStack, dataModels, screenGroups, navigationStructure]); const onGenerate = useCallback(() => { if (screens.length === 0) return; const scopedScreens = scopeScreenIds ? screens.filter((s) => scopeScreenIds.has(s.id)) : screens; - const warnings = validateInstructions(scopedScreens, connections, { documents }); + const warnings = validateInstructions(scopedScreens, connections, { documents, screenGroups, navigationStructure }); const errors = warnings.filter((w) => w.level === "error"); if ( errors.length > 0 && @@ -789,6 +806,28 @@ export default function Drawd() { > + Create new group… + + + + {/* Entry screen picker (nav-stack only) */} + {isNavStack && memberScreens.length > 0 && ( + + )} + + {!isNavStack && group.folderHint && ( )} + + {/* Navigation info */} + {(() => { + const navType = navigationStructure?.type ?? null; + if (!navType) return null; + const stackGroupIds = navigationStructure?.stackGroupIds || []; + const navStackGroups = (screenGroups || []).filter(g => g.type === "nav-stack"); + const screenStack = navStackGroups.find(g => g.screenIds.includes(screen.id)); + if (!screenStack) return null; + + const tabIndex = stackGroupIds.indexOf(screenStack.id); + const isEntry = screenStack.stackEntryScreenId === screen.id; + + return ( + <> +
+ Navigation +
+
+
{screenStack.name}
+ {navType === "tab-bar" && tabIndex >= 0 && ( +
Tab {tabIndex + 1}
+ )} +
+ {isEntry ? "Entry screen" : "Pushed screen"} +
+
+ + ); + })()} + {/* Incoming connections */} {incomingLinks.length > 0 && ( <> diff --git a/src/components/ToolBar.jsx b/src/components/ToolBar.jsx index 09a9d04..5c20e36 100644 --- a/src/components/ToolBar.jsx +++ b/src/components/ToolBar.jsx @@ -42,6 +42,7 @@ const StickyNoteIcon = () => ( ); + const TOOLS = [ { id: "select", label: "Select", icon: SelectIcon, key: "V" }, { id: "pan", label: "Pan", icon: PanIcon, key: "H" }, diff --git a/src/constants.js b/src/constants.js index b2fe238..abdde59 100644 --- a/src/constants.js +++ b/src/constants.js @@ -5,7 +5,7 @@ export const GITHUB_URL = "https://github.com/trmquang93/drawd"; export const DOMAIN = "drawd.app"; // ── File Format ────────────────────────────── -export const FILE_VERSION = 10; +export const FILE_VERSION = 11; export const FILE_EXTENSION = ".drawd"; export const LEGACY_FILE_EXTENSION = ".flowforge"; export const DEFAULT_EXPORT_FILENAME = "flow-export"; diff --git a/src/hooks/useFilePersistence.js b/src/hooks/useFilePersistence.js index b2042cf..25fcf97 100644 --- a/src/hooks/useFilePersistence.js +++ b/src/hooks/useFilePersistence.js @@ -12,7 +12,7 @@ const DRAWD_FILE_TYPES = [ }, ]; -export function useFilePersistence(screens, connections, pan, zoom, documents = [], featureBrief = "", taskLink = "", techStack = {}, dataModels = [], stickyNotes = [], screenGroups = []) { +export function useFilePersistence(screens, connections, pan, zoom, documents = [], featureBrief = "", taskLink = "", techStack = {}, dataModels = [], stickyNotes = [], screenGroups = [], navigationStructure = null) { const fileHandleRef = useRef(null); const [connectedFileName, setConnectedFileName] = useState(null); const [saveStatus, setSaveStatus] = useState("idle"); @@ -38,7 +38,7 @@ export function useFilePersistence(screens, connections, pan, zoom, documents = setSaveStatus("saving"); try { - const payload = buildPayload(screens, connections, panRef.current, zoomRef.current, documents, featureBriefRef.current, taskLinkRef.current, techStackRef.current, dataModels, stickyNotes, screenGroups); + const payload = buildPayload(screens, connections, panRef.current, zoomRef.current, documents, featureBriefRef.current, taskLinkRef.current, techStackRef.current, dataModels, stickyNotes, screenGroups, navigationStructure); const json = JSON.stringify(payload, null, 2); const writable = await handle.createWritable(); await writable.write(json); @@ -54,7 +54,7 @@ export function useFilePersistence(screens, connections, pan, zoom, documents = fileHandleRef.current = null; setConnectedFileName(null); } - }, [screens, connections, documents, dataModels, stickyNotes, screenGroups]); + }, [screens, connections, documents, dataModels, stickyNotes, screenGroups, navigationStructure]); // Auto-save when screens, connections, or documents change useEffect(() => { @@ -111,7 +111,7 @@ export function useFilePersistence(screens, connections, pan, zoom, documents = skipNextSaveRef.current = false; // Write immediately - const payload = buildPayload(screens, connections, panRef.current, zoomRef.current, documents, featureBriefRef.current, taskLinkRef.current, techStackRef.current, dataModels, stickyNotes, screenGroups); + const payload = buildPayload(screens, connections, panRef.current, zoomRef.current, documents, featureBriefRef.current, taskLinkRef.current, techStackRef.current, dataModels, stickyNotes, screenGroups, navigationStructure); const json = JSON.stringify(payload, null, 2); const writable = await handle.createWritable(); await writable.write(json); @@ -125,7 +125,7 @@ export function useFilePersistence(screens, connections, pan, zoom, documents = console.error("Save As failed:", err); setSaveStatus("error"); } - }, [screens, connections, documents, dataModels, stickyNotes, screenGroups]); + }, [screens, connections, documents, dataModels, stickyNotes, screenGroups, navigationStructure]); const disconnect = useCallback(() => { fileHandleRef.current = null; diff --git a/src/hooks/useImportExport.js b/src/hooks/useImportExport.js index 9449110..07401dd 100644 --- a/src/hooks/useImportExport.js +++ b/src/hooks/useImportExport.js @@ -10,6 +10,7 @@ export function useImportExport({ dataModels, stickyNotes, screenGroups, + navigationStructure, pan, zoom, featureBrief, @@ -26,8 +27,8 @@ export function useImportExport({ const importFileRef = useRef(null); const onExport = useCallback(() => { - exportFlow(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes || [], screenGroups || []); - }, [screens, connections, documents, dataModels, stickyNotes, screenGroups, pan, zoom, featureBrief, taskLink, techStack]); + exportFlow(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes || [], screenGroups || [], navigationStructure); + }, [screens, connections, documents, dataModels, stickyNotes, screenGroups, navigationStructure, pan, zoom, featureBrief, taskLink, techStack]); const onImport = useCallback(() => { importFileRef.current?.click(); diff --git a/src/utils/analyzeNavGraph.js b/src/utils/analyzeNavGraph.js index ff5c93e..295c8a5 100644 --- a/src/utils/analyzeNavGraph.js +++ b/src/utils/analyzeNavGraph.js @@ -2,20 +2,143 @@ * Analyzes the navigation graph of screens and connections to detect * structural patterns like entry points, tab bars, modals, and back loops. * + * When navigationStructure.type is set, builds tab/stack definitions from + * user-defined nav-stack groups instead of heuristic detection. + * * @param {Array} screens - Array of screen objects * @param {Array} connections - Array of connection objects + * @param {Object|null} navigationStructure - { type: "tab-bar"|"single-stack"|null, stackGroupIds: string[] } + * @param {Array} screenGroups - Array of screenGroup objects * @returns {Object} Navigation graph analysis result */ -export function analyzeNavGraph(screens, connections) { +export function analyzeNavGraph(screens, connections, navigationStructure = null, screenGroups = []) { const screenMap = new Map(screens.map(s => [s.id, s])); + const navType = navigationStructure?.type ?? null; + + if (navType) { + return analyzeUserDefined(screens, connections, screenMap, navigationStructure, screenGroups); + } + const entryScreens = findEntryScreens(screens, connections, screenMap); const tabBarPatterns = findTabBarPatterns(screens, connections, screenMap); const modalScreens = findModalScreens(connections, screenMap); const backLoops = findBackLoops(connections, screenMap); const navigationSummary = buildSummary(entryScreens, tabBarPatterns, modalScreens, backLoops); - return { entryScreens, tabBarPatterns, modalScreens, backLoops, navigationSummary }; + return { userDefined: false, entryScreens, tabBarPatterns, modalScreens, backLoops, navigationSummary, stacks: null, tabBar: null }; +} + +function analyzeUserDefined(screens, connections, screenMap, navigationStructure, screenGroups) { + const { type, stackGroupIds } = navigationStructure; + const groupMap = new Map(screenGroups.map(g => [g.id, g])); + + const stacks = stackGroupIds + .map((gid, idx) => { + const group = groupMap.get(gid); + if (!group) return null; + + const memberIds = new Set(group.screenIds); + const entryId = group.stackEntryScreenId; + + // BFS from entry through connections within the group + let orderedScreens; + if (entryId && memberIds.has(entryId)) { + const visited = new Set([entryId]); + const queue = [entryId]; + orderedScreens = []; + while (queue.length > 0) { + const id = queue.shift(); + const s = screenMap.get(id); + if (s) orderedScreens.push(s); + for (const conn of connections) { + if (conn.fromScreenId === id && memberIds.has(conn.toScreenId) && !visited.has(conn.toScreenId)) { + visited.add(conn.toScreenId); + queue.push(conn.toScreenId); + } + } + } + // Append any unreachable group members at the end + for (const sid of memberIds) { + if (!visited.has(sid)) { + const s = screenMap.get(sid); + if (s) orderedScreens.push(s); + } + } + } else { + orderedScreens = group.screenIds.map(id => screenMap.get(id)).filter(Boolean); + } + + return { + groupId: gid, + name: group.name, + tabIndex: idx, + tabIcon: group.tabIcon || "", + entryScreenId: entryId || (orderedScreens[0]?.id ?? null), + screens: orderedScreens.map(s => ({ id: s.id, name: s.name })), + }; + }) + .filter(Boolean); + + const tabBar = type === "tab-bar" + ? { tabs: stacks.map(s => ({ groupId: s.groupId, name: s.name, tabIcon: s.tabIcon, entryScreenId: s.entryScreenId })) } + : null; + + // Entry screens: entry of first stack (or heuristic fallback) + const entryScreenId = stacks[0]?.entryScreenId ?? null; + const entryScreen = entryScreenId ? screenMap.get(entryScreenId) : null; + const entryScreens = entryScreen ? [{ id: entryScreen.id, name: entryScreen.name }] : findEntryScreens(Array.from(screenMap.values()), [], screenMap); + + // Modals and back loops are still heuristic (not affected by user-defined structure) + const modalScreens = findModalScreens(connections, screenMap); + const backLoops = findBackLoops(connections, screenMap); + + const navigationSummary = buildUserDefinedSummary(type, stacks, modalScreens, backLoops); + + return { + userDefined: true, + entryScreens, + tabBarPatterns: [], + modalScreens, + backLoops, + navigationSummary, + stacks, + tabBar, + }; +} + +function buildUserDefinedSummary(type, stacks, modalScreens, backLoops) { + const parts = []; + + if (type === "tab-bar") { + const tabNames = stacks.map(s => s.name).join(", "); + parts.push(`Tab bar with ${stacks.length} tab${stacks.length !== 1 ? "s" : ""}: ${tabNames}.`); + for (const stack of stacks) { + if (stack.screens.length > 1) { + parts.push(`${stack.name} stack: ${stack.screens.map(s => s.name).join(" → ")}.`); + } + } + } else if (type === "single-stack") { + const stack = stacks[0]; + if (stack) { + parts.push(`Single root stack starting at ${stack.entryScreenId ? (stacks[0].screens[0]?.name ?? stack.name) : stack.name}.`); + if (stack.screens.length > 1) { + parts.push(`Stack order: ${stack.screens.map(s => s.name).join(" → ")}.`); + } + } + } + + if (modalScreens.length > 0) { + const descriptions = modalScreens.map(m => `${m.name} from ${m.presentedFrom.name}`).join(", "); + parts.push(`Modal screens: ${descriptions}.`); + } + + if (backLoops.length > 0) { + const descriptions = backLoops.map(b => `${b.from.name} back to ${b.to.name}`).join(", "); + parts.push(`Back navigation: ${descriptions}.`); + } + + return parts.join(" "); } function findEntryScreens(screens, connections, _screenMap) { diff --git a/src/utils/buildPayload.js b/src/utils/buildPayload.js index fa10919..3dd7330 100644 --- a/src/utils/buildPayload.js +++ b/src/utils/buildPayload.js @@ -1,6 +1,6 @@ import { FILE_VERSION, DEFAULT_FLOW_NAME } from "../constants"; -export function buildPayload(screens, connections, pan, zoom, documents = [], featureBrief = "", taskLink = "", techStack = {}, dataModels = [], stickyNotes = [], screenGroups = []) { +export function buildPayload(screens, connections, pan, zoom, documents = [], featureBrief = "", taskLink = "", techStack = {}, dataModels = [], stickyNotes = [], screenGroups = [], navigationStructure = null) { return { version: FILE_VERSION, metadata: { @@ -20,5 +20,6 @@ export function buildPayload(screens, connections, pan, zoom, documents = [], fe dataModels, stickyNotes, screenGroups, + navigationStructure: navigationStructure || { type: null, stackGroupIds: [] }, }; } diff --git a/src/utils/buildPayload.test.js b/src/utils/buildPayload.test.js index a8e3574..49530e7 100644 --- a/src/utils/buildPayload.test.js +++ b/src/utils/buildPayload.test.js @@ -6,9 +6,9 @@ describe("buildPayload", () => { const connections = [{ id: "c1" }]; const documents = [{ id: "d1" }, { id: "d2" }, { id: "d3" }]; - it("sets version to 10", () => { + it("sets version to 11", () => { const payload = buildPayload([], [], { x: 0, y: 0 }, 1); - expect(payload.version).toBe(10); + expect(payload.version).toBe(11); }); it("sets metadata.screenCount to screens.length", () => { diff --git a/src/utils/exportFlow.js b/src/utils/exportFlow.js index c94fc5a..8e4262d 100644 --- a/src/utils/exportFlow.js +++ b/src/utils/exportFlow.js @@ -1,8 +1,8 @@ import { buildPayload } from "./buildPayload"; import { FILE_EXTENSION, DEFAULT_EXPORT_FILENAME } from "../constants"; -export function exportFlow(screens, connections, pan, zoom, documents = [], featureBrief = "", taskLink = "", techStack = {}, dataModels = [], stickyNotes = [], screenGroups = []) { - const payload = buildPayload(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups); +export function exportFlow(screens, connections, pan, zoom, documents = [], featureBrief = "", taskLink = "", techStack = {}, dataModels = [], stickyNotes = [], screenGroups = [], navigationStructure = null) { + const payload = buildPayload(screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups, navigationStructure); const json = JSON.stringify(payload, null, 2); const blob = new Blob([json], { type: "application/json" }); diff --git a/src/utils/generateInstructionFiles.js b/src/utils/generateInstructionFiles.js index 7988977..6751ba1 100644 --- a/src/utils/generateInstructionFiles.js +++ b/src/utils/generateInstructionFiles.js @@ -234,9 +234,26 @@ function generateMainMd(screens, connections, options, navAnalysis, images, docu } const hasGroups = screenGroups.length > 0; - if (hasGroups) { + // Build screenId → nav stack lookup (user-defined) + const screenStackMap = {}; + if (navAnalysis.userDefined && navAnalysis.stacks) { + for (const stack of navAnalysis.stacks) { + for (const s of stack.screens) { + screenStackMap[s.id] = { stackName: stack.name, tabIndex: stack.tabIndex, entryScreenId: stack.entryScreenId }; + } + } + } + const hasNavDefined = navAnalysis.userDefined && navAnalysis.stacks?.length > 0; + + if (hasGroups && hasNavDefined) { + md += `| # | ID | Screen | Image | Spec | Group | Nav | Status | TBD | Access | Role |\n`; + md += `|---|-------|--------|-------|------|-------|-----|--------|-----|--------|------|\n`; + } else if (hasGroups) { md += `| # | ID | Screen | Image | Spec | Group | Status | TBD | Access | Role |\n`; md += `|---|-------|--------|-------|------|-------|--------|-----|--------|------|\n`; + } else if (hasNavDefined) { + md += `| # | ID | Screen | Image | Spec | Nav | Status | TBD | Access | Role |\n`; + md += `|---|-------|--------|-------|------|-----|--------|-----|--------|------|\n`; } else { md += `| # | ID | Screen | Image | Spec | Status | TBD | Access | Role |\n`; md += `|---|-------|--------|-------|------|--------|-----|--------|------|\n`; @@ -260,25 +277,54 @@ function generateMainMd(screens, connections, options, navAnalysis, images, docu const accessLabel = (s.roles && s.roles.length > 0) ? s.roles.join(", ") : "—"; const groupLabel = screenGroupMap[s.id] ? screenGroupMap[s.id].name : "—"; const specRef = `\`screens.md\` > \`${reqId}\``; - if (hasGroups) { - md += `| ${i + 1} | \`${reqId}\` | ${s.name}${stateLabel} | \`${imgRef}\` | ${specRef} | ${groupLabel} | ${statusLabel} | ${tbdLabel} | ${accessLabel} | ${navRoles.length > 0 ? navRoles.join(", ") : "screen"} |\n`; + const navStackInfo = screenStackMap[s.id]; + const navLabel = navStackInfo + ? (navStackInfo.entryScreenId === s.id + ? `${navStackInfo.stackName} (entry)` + : navStackInfo.stackName) + : "—"; + const roleLabel = navRoles.length > 0 ? navRoles.join(", ") : "screen"; + + if (hasGroups && hasNavDefined) { + md += `| ${i + 1} | \`${reqId}\` | ${s.name}${stateLabel} | \`${imgRef}\` | ${specRef} | ${groupLabel} | ${navLabel} | ${statusLabel} | ${tbdLabel} | ${accessLabel} | ${roleLabel} |\n`; + } else if (hasGroups) { + md += `| ${i + 1} | \`${reqId}\` | ${s.name}${stateLabel} | \`${imgRef}\` | ${specRef} | ${groupLabel} | ${statusLabel} | ${tbdLabel} | ${accessLabel} | ${roleLabel} |\n`; + } else if (hasNavDefined) { + md += `| ${i + 1} | \`${reqId}\` | ${s.name}${stateLabel} | \`${imgRef}\` | ${specRef} | ${navLabel} | ${statusLabel} | ${tbdLabel} | ${accessLabel} | ${roleLabel} |\n`; } else { - md += `| ${i + 1} | \`${reqId}\` | ${s.name}${stateLabel} | \`${imgRef}\` | ${specRef} | ${statusLabel} | ${tbdLabel} | ${accessLabel} | ${navRoles.length > 0 ? navRoles.join(", ") : "screen"} |\n`; + md += `| ${i + 1} | \`${reqId}\` | ${s.name}${stateLabel} | \`${imgRef}\` | ${specRef} | ${statusLabel} | ${tbdLabel} | ${accessLabel} | ${roleLabel} |\n`; } }); md += `\n`; // Feature Areas summary (if groups exist) if (hasGroups) { - md += `### Feature Areas\n\n`; - for (const g of screenGroups) { - const memberNames = g.screenIds - .map(id => screens.find(s => s.id === id)?.name) - .filter(Boolean) - .join(", "); - md += `- **${g.name}**${g.folderHint ? ` (\`${g.folderHint}\`)` : ""}: ${memberNames || "no screens"}\n`; + const featureAreaGroups = screenGroups.filter(g => !g.type || g.type === "feature-area"); + const navStackGroups = screenGroups.filter(g => g.type === "nav-stack"); + + if (navStackGroups.length > 0) { + md += `### Navigation Stacks\n\n`; + for (const g of navStackGroups) { + const memberNames = g.screenIds + .map(id => screens.find(s => s.id === id)?.name) + .filter(Boolean) + .join(", "); + md += `- **${g.name}** (stack): ${memberNames || "no screens"}\n`; + } + md += `\n`; + } + + if (featureAreaGroups.length > 0) { + md += `### Feature Areas\n\n`; + for (const g of featureAreaGroups) { + const memberNames = g.screenIds + .map(id => screens.find(s => s.id === id)?.name) + .filter(Boolean) + .join(", "); + md += `- **${g.name}**${g.folderHint ? ` (\`${g.folderHint}\`)` : ""}: ${memberNames || "no screens"}\n`; + } + md += `\n`; } - md += `\n`; } // Context-only screens (existing) @@ -580,6 +626,31 @@ function generateNavigationMd(screens, connections, navAnalysis) { md += `${navAnalysis.navigationSummary}\n\n`; } + // User-defined navigation structure + if (navAnalysis.userDefined && navAnalysis.stacks) { + const isTabBar = navAnalysis.tabBar !== null; + md += `## Navigation Structure\n\n`; + md += `> **This navigation structure was explicitly defined by the designer.**\n\n`; + + if (isTabBar) { + md += `**Type:** Tab Bar + Stacks\n\n`; + md += `| Tab # | Tab Name | Entry Screen | Screens in Stack |\n`; + md += `|-------|----------|--------------|------------------|\n`; + for (const stack of navAnalysis.stacks) { + const screenNames = stack.screens.map(s => s.name).join(", "); + const entryName = stack.screens.find(s => s.id === stack.entryScreenId)?.name ?? "—"; + md += `| ${stack.tabIndex + 1} | ${stack.name} | ${entryName} | ${screenNames} |\n`; + } + md += `\n`; + } else { + md += `**Type:** Single Root Stack\n\n`; + if (navAnalysis.stacks.length > 0) { + const stack = navAnalysis.stacks[0]; + md += `**Stack:** ${stack.screens.map(s => s.name).join(" → ")}\n\n`; + } + } + } + // Entry screens if (navAnalysis.entryScreens.length > 0) { md += `## Entry Screens\n\n`; @@ -589,8 +660,8 @@ function generateNavigationMd(screens, connections, navAnalysis) { md += `\n`; } - // Tab bar patterns - if (navAnalysis.tabBarPatterns.length > 0) { + // Tab bar patterns (heuristic only) + if (!navAnalysis.userDefined && navAnalysis.tabBarPatterns.length > 0) { md += `## Tab Bar Patterns\n\n`; for (const pattern of navAnalysis.tabBarPatterns) { md += `**${pattern.hubScreenName}** hub with ${pattern.tabs.length} tabs:\n`; @@ -647,12 +718,44 @@ function generateNavigationMd(screens, connections, navAnalysis) { return md; } -function generateBuildGuideMd(screens, connections, options, screenGroups = []) { +function generateBuildGuideMd(screens, connections, options, screenGroups = [], navAnalysis = null) { const platform = options.platform || "auto"; const techStack = options.techStack || {}; const hasTechStack = Object.values(techStack).some(Boolean); let md = `# Build Guide\n\n`; + // User-defined navigation setup + if (navAnalysis?.userDefined && navAnalysis.stacks) { + const isTabBar = navAnalysis.tabBar !== null; + md += `## Navigation Setup\n\n`; + md += `> **Set up this navigation structure before implementing individual screens.**\n\n`; + + if (isTabBar) { + md += `**Pattern:** Tab Bar with ${navAnalysis.stacks.length} tabs, each backed by a navigation stack.\n\n`; + md += `### Tab Configuration\n\n`; + md += `| Tab | Entry Screen | Stack Depth |\n`; + md += `|-----|--------------|-------------|\n`; + for (const stack of navAnalysis.stacks) { + const entryName = stack.screens.find(s => s.id === stack.entryScreenId)?.name ?? "—"; + md += `| ${stack.name}${stack.tabIcon ? ` (${stack.tabIcon})` : ""} | ${entryName} | ${stack.screens.length} screen${stack.screens.length !== 1 ? "s" : ""} |\n`; + } + md += `\n`; + md += `### Router Pseudocode\n\n`; + md += `\`\`\`\nTabBar:\n`; + for (const stack of navAnalysis.stacks) { + const screenNames = stack.screens.map(s => s.name); + md += ` Tab "${stack.name}" → Stack(${screenNames.join(", ")})\n`; + } + md += `\`\`\`\n\n`; + } else { + const stack = navAnalysis.stacks[0]; + if (stack) { + md += `**Pattern:** Single root navigation stack.\n\n`; + md += `\`\`\`\nStack: ${stack.screens.map(s => s.name).join(" → ")}\n\`\`\`\n\n`; + } + } + } + // Folder structure hints from screen groups const groupsWithHints = screenGroups.filter(g => g.folderHint); if (groupsWithHints.length > 0) { @@ -861,7 +964,8 @@ export function generateInstructionFiles(screens, connections, options = {}) { const documents = options.documents || []; const dataModels = options.dataModels || []; const screenGroups = options.screenGroups || []; - const navAnalysis = analyzeNavGraph(screens, connections); + const navigationStructure = options.navigationStructure || null; + const navAnalysis = analyzeNavGraph(screens, connections, navigationStructure, screenGroups); const images = extractImages(screens); const generatedAt = new Date().toISOString(); const schemaHeader = `\n\n`; @@ -870,7 +974,7 @@ export function generateInstructionFiles(screens, connections, options = {}) { { name: "main.md", content: generateMainMd(screens, connections, options, navAnalysis, images, documents, screenGroups) }, { name: "screens.md", content: generateScreensMd(screens, connections, images, documents) }, { name: "navigation.md", content: generateNavigationMd(screens, connections, navAnalysis) }, - { name: "build-guide.md", content: generateBuildGuideMd(screens, connections, options, screenGroups) }, + { name: "build-guide.md", content: generateBuildGuideMd(screens, connections, options, screenGroups, navAnalysis) }, ]; const documentsMd = generateDocumentsMd(documents); diff --git a/src/utils/importFlow.js b/src/utils/importFlow.js index 016b36b..1d82c54 100644 --- a/src/utils/importFlow.js +++ b/src/utils/importFlow.js @@ -110,5 +110,18 @@ export function importFlow(fileText) { if (!Array.isArray(data.stickyNotes)) data.stickyNotes = []; if (!Array.isArray(data.screenGroups)) data.screenGroups = []; + // Backward compat: new screenGroup fields (v11) + for (const group of data.screenGroups) { + if (group.type === undefined) group.type = "feature-area"; + if (group.tabIndex === undefined) group.tabIndex = null; + if (group.tabIcon === undefined) group.tabIcon = ""; + if (group.stackEntryScreenId === undefined) group.stackEntryScreenId = null; + } + + // Backward compat: navigationStructure (v11) + if (!data.navigationStructure) { + data.navigationStructure = { type: null, stackGroupIds: [] }; + } + return data; } diff --git a/src/utils/importFlow.test.js b/src/utils/importFlow.test.js index 9facb9f..71047ef 100644 --- a/src/utils/importFlow.test.js +++ b/src/utils/importFlow.test.js @@ -24,9 +24,9 @@ describe("importFlow", () => { ); }); - it("throws for future version > 10", () => { + it("throws for future version > 11", () => { expect(() => - importFlow(JSON.stringify({ version: 11, screens: [], connections: [] })) + importFlow(JSON.stringify({ version: 12, screens: [], connections: [] })) ).toThrow("Unsupported file version"); }); diff --git a/src/utils/validateInstructions.js b/src/utils/validateInstructions.js index 4905a1d..78e6936 100644 --- a/src/utils/validateInstructions.js +++ b/src/utils/validateInstructions.js @@ -4,11 +4,13 @@ * * @param {Array} screens * @param {Array} connections - * @param {Object} options - { documents: [] } + * @param {Object} options - { documents: [], screenGroups: [], navigationStructure: null } * @returns {Array<{ level: "error"|"warning", code: string, message: string, entityId?: string }>} */ export function validateInstructions(screens, connections, options = {}) { const documents = options.documents || []; + const screenGroups = options.screenGroups || []; + const navigationStructure = options.navigationStructure || null; const screenIds = new Set(screens.map((s) => s.id)); const docIds = new Set(documents.map((d) => d.id)); const issues = []; @@ -64,5 +66,54 @@ export function validateInstructions(screens, connections, options = {}) { } } + // Navigation structure checks + if (navigationStructure?.type) { + const stackGroupIds = navigationStructure.stackGroupIds || []; + const navStackGroups = screenGroups.filter(g => g.type === "nav-stack"); + + for (const group of navStackGroups) { + if (!stackGroupIds.includes(group.id)) continue; // not included in nav structure + + // NAV_STACK_NO_ENTRY: nav-stack group has no stackEntryScreenId + if (!group.stackEntryScreenId || !screenIds.has(group.stackEntryScreenId)) { + issues.push({ + level: "warning", + code: "NAV_STACK_NO_ENTRY", + message: `Nav stack "${group.name}" has no entry screen defined`, + entityId: group.id, + }); + continue; + } + + // NAV_ORPHAN_SCREEN: screens in nav-stack unreachable from entry via connections + const memberIds = new Set(group.screenIds.filter(id => screenIds.has(id))); + const entryId = group.stackEntryScreenId; + if (memberIds.size > 1 && memberIds.has(entryId)) { + const visited = new Set([entryId]); + const queue = [entryId]; + while (queue.length > 0) { + const id = queue.shift(); + for (const conn of connections) { + if (conn.fromScreenId === id && memberIds.has(conn.toScreenId) && !visited.has(conn.toScreenId)) { + visited.add(conn.toScreenId); + queue.push(conn.toScreenId); + } + } + } + for (const sid of memberIds) { + if (!visited.has(sid)) { + const s = screens.find(sc => sc.id === sid); + issues.push({ + level: "warning", + code: "NAV_ORPHAN_SCREEN", + message: `Screen "${s?.name ?? sid}" in nav stack "${group.name}" is unreachable from the entry screen`, + entityId: sid, + }); + } + } + } + } + } + return issues; }