From 61399be94e614fb8dc6f7e4fa11cea559c4ea9fb Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Wed, 1 Apr 2026 10:02:20 +0700 Subject: [PATCH] feat: add transition type annotations to connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the existing transitionType field with an expanded set of transition options (push, modal, fullScreenCover, replace, pop, tab, fade, slideUp, slideLeft, custom) in both ConnectionEditModal and HotspotModal. Extracts TRANSITION_TYPES and TRANSITION_LABELS to constants.js as a single source of truth. Canvas connection badges now render a distinct SVG icon alongside the transition label so types are visually distinguishable at a glance. Navigation.md outputs human-readable transition labels. Build-guide.md includes a platform-specific Transition Types table for all four supported frameworks (SwiftUI, React Native, Flutter, Jetpack Compose) as well as expanded per-type guidance in the auto-platform path. No file format change — transitionType field already exists and importFlow.js already backfills it. --- src/components/ConnectionEditModal.jsx | 10 ++-- src/components/ConnectionLines.jsx | 65 ++++++++++++++++++-- src/components/HotspotModal.jsx | 10 ++-- src/constants.js | 18 ++++++ src/utils/generateInstructionFiles.js | 24 ++++++-- src/utils/generateInstructionFiles.test.js | 34 +++++++++++ src/utils/instructionRenderers.js | 69 ++++++++++++++++++++++ src/utils/instructionRenderers.test.js | 54 +++++++++++++++++ 8 files changed, 263 insertions(+), 21 deletions(-) diff --git a/src/components/ConnectionEditModal.jsx b/src/components/ConnectionEditModal.jsx index 984d81a..dd8e73c 100644 --- a/src/components/ConnectionEditModal.jsx +++ b/src/components/ConnectionEditModal.jsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { COLORS, styles } from "../styles/theme"; import { generateId } from "../utils/generateId"; import { DataFlowEditor } from "./DataFlowEditor"; +import { TRANSITION_TYPES } from "../constants"; export function ConnectionEditModal({ connection, groupConnections, screens, fromScreen, onSave, onDelete, onClose }) { const isConditional = groupConnections.length > 1 || !!connection.conditionGroupId; @@ -230,12 +231,9 @@ export function ConnectionEditModal({ connection, groupConnections, screens, fro style={styles.select} > - - - - - - + {TRANSITION_TYPES.map(t => ( + + ))} diff --git a/src/components/ConnectionLines.jsx b/src/components/ConnectionLines.jsx index 87896d9..2d51435 100644 --- a/src/components/ConnectionLines.jsx +++ b/src/components/ConnectionLines.jsx @@ -1,6 +1,57 @@ import { COLORS, FONTS } from "../styles/theme"; import { HEADER_HEIGHT, BORDER_WIDTH, DEFAULT_SCREEN_WIDTH, DEFAULT_IMAGE_HEIGHT, BEZIER_FACTOR, BEZIER_MIN_CP } from "../constants"; +// Small SVG icon renderers for transition types, each drawn into a ~10x10px space. +// Each function takes (cx, cy, fill) where cx/cy is the icon center point. +const TRANSITION_ICONS = { + push: (cx, cy, fill) => ( + + ), + modal: (cx, cy, fill) => ( + + ), + fullScreenCover: (cx, cy, fill) => ( + + ), + replace: (cx, cy, fill) => ( + <> + + + + + + ), + pop: (cx, cy, fill) => ( + + ), + tab: (cx, cy, fill) => ( + <> + + + + + ), + fade: (cx, cy, fill) => ( + <> + + + + ), + slideUp: (cx, cy, fill) => ( + + ), + slideLeft: (cx, cy, fill) => ( + + ), + custom: (cx, cy, fill) => ( + * + ), +}; + const BORDER = BORDER_WIDTH; function getScreenCenterY(screen) { @@ -220,14 +271,17 @@ export function ConnectionLines({ {conn.transitionType && (() => { const mx = (fromX + toX) / 2; const my = (fromY + toY) / 2 + (conn.label || conn.condition ? 12 : 0); - const badgeLabel = conn.transitionType === "custom" + const shortLabel = conn.transitionType === "custom" ? (conn.transitionLabel || "custom") : conn.transitionType; - const badgeW = badgeLabel.length * 5.5 + 10; + const iconFn = TRANSITION_ICONS[conn.transitionType]; + const iconW = iconFn ? 14 : 0; + const badgeW = shortLabel.length * 5.5 + 10 + iconW; + const badgeX = mx - badgeW / 2; return ( + {iconFn && iconFn(badgeX + 7, my, COLORS.accentLight)} - {badgeLabel} + {shortLabel} ); diff --git a/src/components/HotspotModal.jsx b/src/components/HotspotModal.jsx index 0ce96f6..492f878 100644 --- a/src/components/HotspotModal.jsx +++ b/src/components/HotspotModal.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { COLORS, FONTS, styles } from "../styles/theme"; import { generateId } from "../utils/generateId"; import { DataFlowEditor } from "./DataFlowEditor"; +import { TRANSITION_TYPES } from "../constants"; function FollowUpSection({ title, titleColor, action, setAction, targetId, setTargetId, customDesc, setCustomDesc, otherScreens, dataFlow, onDataFlowChange }) { @@ -748,12 +749,9 @@ export function HotspotModal({ screen, hotspot, connection, screens, documents = style={styles.select} > - - - - - - + {TRANSITION_TYPES.map(t => ( + + ))} diff --git a/src/constants.js b/src/constants.js index 1b0d0ff..361d271 100644 --- a/src/constants.js +++ b/src/constants.js @@ -71,3 +71,21 @@ export const COLLAB_CURSOR_FADE_MS = 3000; // ── Sticky Notes ───────────────────────────── export const STICKY_NOTE_MIN_WIDTH = 150; + +// ── Transition Types ───────────────────────── +export const TRANSITION_TYPES = [ + { value: "push", label: "Push (stack navigation)" }, + { value: "modal", label: "Modal sheet" }, + { value: "fullScreenCover", label: "Full-screen cover" }, + { value: "replace", label: "Replace stack" }, + { value: "pop", label: "Pop (go back)" }, + { value: "tab", label: "Tab switch" }, + { value: "fade", label: "Fade" }, + { value: "slideUp", label: "Slide up" }, + { value: "slideLeft", label: "Slide from left" }, + { value: "custom", label: "Custom..." }, +]; + +export const TRANSITION_LABELS = Object.fromEntries( + TRANSITION_TYPES.map(t => [t.value, t.label]) +); diff --git a/src/utils/generateInstructionFiles.js b/src/utils/generateInstructionFiles.js index 816ce1b..8ec3a25 100644 --- a/src/utils/generateInstructionFiles.js +++ b/src/utils/generateInstructionFiles.js @@ -1,6 +1,7 @@ import { analyzeNavGraph } from "./analyzeNavGraph.js"; -import { PLATFORM_TERMINOLOGY, renderHotspotDetailBlock, renderBuildGuideActionTable } from "./instructionRenderers.js"; +import { PLATFORM_TERMINOLOGY, renderHotspotDetailBlock, renderBuildGuideActionTable, renderBuildGuideTransitionTable } from "./instructionRenderers.js"; import { screenReqId, connectionReqId } from "./generateReqIds.js"; +import { TRANSITION_LABELS } from "../constants.js"; // --- Helpers --- @@ -664,7 +665,9 @@ function generateNavigationMd(screens, connections, navAnalysis) { else if (c.connectionPath === "api-error") actionCol += " (error)"; else if (c.connectionPath && c.connectionPath.startsWith("condition-")) actionCol += " (conditional)"; const transitionCol = c.transitionType - ? (c.transitionType === "custom" ? (c.transitionLabel || "custom") : c.transitionType) + ? (c.transitionType === "custom" + ? (c.transitionLabel || "custom") + : (TRANSITION_LABELS[c.transitionType] || c.transitionType)) : "\u2014"; const conditionCol = c.condition || "\u2014"; const dataCol = c.dataFlow?.length > 0 @@ -726,7 +729,17 @@ function generateBuildGuideMd(screens, connections, options, screenGroups = []) md += ` - **conditional** — Branch to different screens based on a condition (see screens.md for branch definitions)\n`; md += ` - **custom** — Implement custom logic as described in screens.md\n`; md += `6. Set up proper navigation stack/router with all routes\n`; - md += `7. Add smooth transitions between screens matching platform conventions\n`; + md += `7. For connections with a specified transition type (see Transition column in navigation.md), implement the matching animation:\n`; + md += ` - **push** — Standard stack push (default slide-from-right)\n`; + md += ` - **modal** — Modal sheet sliding up from the bottom\n`; + md += ` - **fullScreenCover** — Full-screen modal covering the entire display\n`; + md += ` - **replace** — Replace the current screen without adding to the back stack\n`; + md += ` - **pop** — Dismiss/pop back to the previous screen\n`; + md += ` - **tab** — Switch to a tab in the tab bar\n`; + md += ` - **fade** — Cross-fade between screens\n`; + md += ` - **slideUp** — Slide in from the bottom\n`; + md += ` - **slideLeft** — Slide in from the right (left direction)\n`; + md += ` - **custom** — See connection label for transition description\n`; md += `8. Ensure responsive layout that adapts to different screen sizes\n`; md += `\n`; md += `## Sub-Agent Implementation Workflow\n\n`; @@ -755,12 +768,15 @@ function generateBuildGuideMd(screens, connections, options, screenGroups = []) md += renderBuildGuideActionTable(platform); + const transitionTable = renderBuildGuideTransitionTable(platform); + if (transitionTable) md += transitionTable; + md += `### Steps\n\n`; md += `1. Implement each screen from screens.md as a separate ${pt.name} view/component\n`; md += `2. For EACH screen, open its reference image from the \`images/\` folder and replicate the visual design exactly — colors, typography, spacing, layout, and component hierarchy\n`; md += `3. Wire up navigation flows from navigation.md using the patterns above\n`; md += `4. Handle API actions with proper error handling and loading states\n`; - md += `5. Add smooth transitions matching ${pt.name} platform conventions\n`; + md += `5. For each connection with a specified transition type, use the matching pattern from the Transition Types table above\n`; md += `\n`; md += `## Sub-Agent Implementation Workflow\n\n`; md += `Each screen is implemented by a dedicated sub-agent. The sub-agent MUST follow these\n`; diff --git a/src/utils/generateInstructionFiles.test.js b/src/utils/generateInstructionFiles.test.js index 0fa4025..03d4b58 100644 --- a/src/utils/generateInstructionFiles.test.js +++ b/src/utils/generateInstructionFiles.test.js @@ -184,4 +184,38 @@ describe("generateInstructionFiles", () => { const navFile = result.files.find((f) => f.name === "navigation.md"); expect(navFile.content).toContain("Settings"); }); + + it("renders human-readable transition labels in navigation.md", () => { + const screens = [ + { ...minimalScreen, id: "s1", name: "Home" }, + { ...minimalScreen, id: "s2", name: "Detail", x: 400 }, + ]; + const connections = [ + { id: "c1", fromScreenId: "s1", toScreenId: "s2", transitionType: "fullScreenCover" }, + { id: "c2", fromScreenId: "s1", toScreenId: "s2", transitionType: "slideUp" }, + { id: "c3", fromScreenId: "s1", toScreenId: "s2", transitionType: "fade" }, + ]; + const result = generateInstructionFiles(screens, connections, defaultOptions); + const navFile = result.files.find((f) => f.name === "navigation.md"); + expect(navFile.content).toContain("Full-screen cover"); + expect(navFile.content).toContain("Slide up"); + expect(navFile.content).toContain("Fade"); + expect(navFile.content).not.toContain("fullScreenCover"); + expect(navFile.content).not.toContain("slideUp"); + }); + + it("includes Transition Types section in build-guide.md for platform-specific output", () => { + const result = generateInstructionFiles([minimalScreen], [], { platform: "swiftui" }); + const buildGuide = result.files.find((f) => f.name === "build-guide.md"); + expect(buildGuide.content).toContain("### Transition Types"); + expect(buildGuide.content).toContain(".fullScreenCover"); + expect(buildGuide.content).toContain(".transition(.opacity)"); + }); + + it("includes transition type guidance in build-guide.md for auto platform", () => { + const result = generateInstructionFiles([minimalScreen], [], { platform: "auto" }); + const buildGuide = result.files.find((f) => f.name === "build-guide.md"); + expect(buildGuide.content).toContain("fullScreenCover"); + expect(buildGuide.content).toContain("slideUp"); + }); }); diff --git a/src/utils/instructionRenderers.js b/src/utils/instructionRenderers.js index abfc70f..bd0724d 100644 --- a/src/utils/instructionRenderers.js +++ b/src/utils/instructionRenderers.js @@ -11,6 +11,18 @@ export const PLATFORM_TERMINOLOGY = { custom: "Add a `// TODO: custom action` comment with the description.", stack: "Set up a `NavigationStack` with a path-based router using `@State private var path = NavigationPath()`.", tabs: "Use `TabView` with `.tabItem { Label(\"Title\", systemImage: \"icon\") }` for each tab.", + transitions: { + push: "Standard `NavigationStack` push via `.navigationDestination(for:)`.", + modal: "`.sheet(isPresented:)` — presents as a card that slides up from the bottom.", + fullScreenCover: "`.fullScreenCover(isPresented:)` — covers the entire screen including safe areas.", + replace: "Replace the navigation path: `path = NavigationPath([newScreen])`.", + pop: "`dismiss()` via `@Environment(\\.dismiss)` to pop the current view.", + tab: "`TabView` selection binding: `@State private var selectedTab: Int`.", + fade: "`.transition(.opacity)` inside `withAnimation(.easeInOut(duration: 0.3))`.", + slideUp: "`.transition(.move(edge: .bottom))` inside `withAnimation(.easeOut(duration: 0.3))`.", + slideLeft: "`.transition(.move(edge: .trailing))` inside `withAnimation(.easeOut(duration: 0.3))`.", + custom: "Custom `AnyTransition` — see connection label for transition description.", + }, }, "react-native": { name: "React Native", @@ -22,6 +34,18 @@ export const PLATFORM_TERMINOLOGY = { custom: "Add a `// TODO: custom action` comment with the description.", stack: "Set up `createNativeStackNavigator()` with `NavigationContainer` wrapping `Stack.Navigator`.", tabs: "Use `createBottomTabNavigator()` with `Tab.Screen` for each tab.", + transitions: { + push: "Default stack push — `navigation.navigate('ScreenName')` with no extra options.", + modal: "`presentation: 'modal'` on the `Stack.Screen` options.", + fullScreenCover: "`presentation: 'fullScreenModal'` on the `Stack.Screen` options.", + replace: "`navigation.replace('ScreenName')` to swap without adding to the stack.", + pop: "`navigation.goBack()` or `navigation.pop()` to return to the previous screen.", + tab: "`navigation.navigate('TabName')` targeting a tab in `createBottomTabNavigator()`.", + fade: "`animation: 'fade'` in `Stack.Screen` options — fades between screens.", + slideUp: "`animation: 'slide_from_bottom'` in `Stack.Screen` options.", + slideLeft: "`animation: 'slide_from_right'` in `Stack.Screen` options (default for iOS push).", + custom: "Custom `cardStyleInterpolator` or `transitionSpec` in `Stack.Screen` options — see connection label.", + }, }, flutter: { name: "Flutter", @@ -33,6 +57,18 @@ export const PLATFORM_TERMINOLOGY = { custom: "Add a `// TODO: custom action` comment with the description.", stack: "Set up `MaterialApp` with named routes or `GoRouter` for declarative routing.", tabs: "Use `BottomNavigationBar` inside a `Scaffold` with an `IndexedStack` for tab content.", + transitions: { + push: "`Navigator.push(context, MaterialPageRoute(builder: (_) => TargetScreen()))` — default slide-from-right.", + modal: "`showModalBottomSheet(context: context, builder: (_) => TargetWidget())`.", + fullScreenCover: "`MaterialPageRoute(builder: (_) => TargetScreen(), fullscreenDialog: true)`.", + replace: "`Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => TargetScreen()))`.", + pop: "`Navigator.pop(context)` to return to the previous route.", + tab: "`DefaultTabController` with `TabBar` + `TabBarView`, or update `_selectedIndex` for `BottomNavigationBar`.", + fade: "`PageRouteBuilder` with `FadeTransition(opacity: animation, child: page)` as `transitionsBuilder`.", + slideUp: "`PageRouteBuilder` with `SlideTransition(position: Tween(begin: Offset(0, 1)).animate(animation))`.", + slideLeft: "`PageRouteBuilder` with `SlideTransition(position: Tween(begin: Offset(-1, 0)).animate(animation))`.", + custom: "Custom `PageRouteBuilder` with a `transitionsBuilder` — see connection label for details.", + }, }, "jetpack-compose": { name: "Jetpack Compose", @@ -44,6 +80,18 @@ export const PLATFORM_TERMINOLOGY = { custom: "Add a `// TODO: custom action` comment with the description.", stack: "Set up `NavHost(navController, startDestination)` with `composable(\"route\") { }` for each screen.", tabs: "Use `Scaffold` with `NavigationBar` and `NavigationBarItem` for each tab.", + transitions: { + push: "`navController.navigate(\"route\")` — pair with `enterTransition`/`exitTransition` on `composable()`.", + modal: "`ModalBottomSheet { }` or `Dialog { }` composable for overlay presentation.", + fullScreenCover: "`composable()` with `enterTransition = { slideInVertically(initialOffsetY = { it }) }` covering the full screen.", + replace: "`navController.navigate(\"route\") { popUpTo(currentRoute) { inclusive = true } }` to replace without backstack.", + pop: "`navController.popBackStack()` to return to the previous destination.", + tab: "`NavigationBar` with `NavigationBarItem`, update `selectedDestination` state to switch tabs.", + fade: "`enterTransition = { fadeIn() }` and `exitTransition = { fadeOut() }` on `composable()`.", + slideUp: "`enterTransition = { slideInVertically(initialOffsetY = { it }) }` on `composable()`.", + slideLeft: "`enterTransition = { slideInHorizontally(initialOffsetX = { -it }) }` on `composable()`.", + custom: "Custom `EnterTransition`/`ExitTransition` on `composable()` — see connection label for details.", + }, }, }; @@ -147,6 +195,27 @@ export function renderHotspotDetailBlock(h, screens, documents) { return renderer.detailBlock(h, screens, documents); } +// Generate the full ### Transition Types table for a specific platform. +// Returns null when platform is "auto" or unknown — callers handle those paths separately. +export function renderBuildGuideTransitionTable(platform) { + const pt = PLATFORM_TERMINOLOGY[platform]; + if (!pt || !pt.transitions) return null; + + let md = `### Transition Types\n\n`; + md += `| Transition | Implementation |\n`; + md += `|------------|---------------|\n`; + + const transitionOrder = ["push", "modal", "fullScreenCover", "replace", "pop", "tab", "fade", "slideUp", "slideLeft", "custom"]; + for (const t of transitionOrder) { + if (pt.transitions[t]) { + md += `| **${t}** | ${pt.transitions[t]} |\n`; + } + } + md += `\n`; + + return md; +} + // Generate the full ### Action Types table for a specific platform. // Returns null when platform is "auto" or unknown — callers handle those paths separately. export function renderBuildGuideActionTable(platform) { diff --git a/src/utils/instructionRenderers.test.js b/src/utils/instructionRenderers.test.js index 456d80f..04b63d6 100644 --- a/src/utils/instructionRenderers.test.js +++ b/src/utils/instructionRenderers.test.js @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { renderHotspotDetailBlock, renderBuildGuideActionTable, + renderBuildGuideTransitionTable, } from "./instructionRenderers.js"; const makeScreen = (id, name) => ({ id, name }); @@ -151,3 +152,56 @@ describe("renderBuildGuideActionTable", () => { expect(result).toContain("**custom**"); }); }); + +describe("renderBuildGuideTransitionTable", () => { + it("returns null for platform 'auto'", () => { + expect(renderBuildGuideTransitionTable("auto")).toBeNull(); + }); + + it("returns null for unknown platform", () => { + expect(renderBuildGuideTransitionTable("cobol")).toBeNull(); + }); + + it("returns a table string for swiftui with platform-specific patterns", () => { + const result = renderBuildGuideTransitionTable("swiftui"); + expect(result).toContain("### Transition Types"); + expect(result).toContain(".sheet"); + expect(result).toContain(".fullScreenCover"); + expect(result).toContain(".transition(.opacity)"); + }); + + it("returns a table string for react-native with platform-specific patterns", () => { + const result = renderBuildGuideTransitionTable("react-native"); + expect(result).toContain("### Transition Types"); + expect(result).toContain("fullScreenModal"); + expect(result).toContain("slide_from_bottom"); + }); + + it("returns a table string for flutter with platform-specific patterns", () => { + const result = renderBuildGuideTransitionTable("flutter"); + expect(result).toContain("### Transition Types"); + expect(result).toContain("FadeTransition"); + expect(result).toContain("fullscreenDialog"); + }); + + it("returns a table string for jetpack-compose with platform-specific patterns", () => { + const result = renderBuildGuideTransitionTable("jetpack-compose"); + expect(result).toContain("### Transition Types"); + expect(result).toContain("fadeIn"); + expect(result).toContain("slideInVertically"); + }); + + it("includes all 10 transition types in the generated table", () => { + const result = renderBuildGuideTransitionTable("swiftui"); + expect(result).toContain("**push**"); + expect(result).toContain("**modal**"); + expect(result).toContain("**fullScreenCover**"); + expect(result).toContain("**replace**"); + expect(result).toContain("**pop**"); + expect(result).toContain("**tab**"); + expect(result).toContain("**fade**"); + expect(result).toContain("**slideUp**"); + expect(result).toContain("**slideLeft**"); + expect(result).toContain("**custom**"); + }); +});