)}
- {group.folderHint && (
+
+ {/* Type toggle */}
+
+
+ {/* 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;
}