From 273288038f8bf1c3fccd997c2f5744c1c556f2bd Mon Sep 17 00:00:00 2001 From: nether403 Date: Tue, 12 May 2026 03:15:56 +0200 Subject: [PATCH] feat(web): convert Blueprint Builder to 4-step wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 gap #1: the Blueprint Builder was a single form with idea + constraints. This PR splits the flow into a guided 4-step wizard and adds an integrated scaffold export step so users can go from idea to downloadable starter repo in one page. Steps 1. Idea — required textarea, focused and distraction-free 2. Constraints — optional free-form constraints plus structured fields for budget, timeline, and team size (all wired to the BlueprintRequest schema) 3. Results — existing BlueprintOutputCard rendered inside the wizard 4. Export — scaffold generation via /api/v1/scaffolds with project name input, file list preview, warnings panel, and client-side ZIP download using the existing archive-generator UX - Progress indicator shows completed/active/pending steps with a data-testid for E2E assertions - Back / Next controls with per-step loading states and a "Start over" action on the final step - Errors from blueprint generation and scaffold generation are surfaced inline with retry affordances - Regenerate button on the export step in case the user tweaks the project name after scaffolding E2E coverage - tests/e2e/mvp-flows.spec.ts walks the full wizard: idea → constraints (with budget/timeline selects) → results (asserts "Recommended Architecture", "Primary Stack", "Harmony Score") → export (asserts "Download ZIP" is visible) - Saves a screenshot at each step to test-results/wizard-step-*.png for visual evidence (test-results/ is gitignored) - Bumps this test's timeout to 180s because blueprint generation can take 25-30s when Gemini provider does the full fan-out Quality gate (local) - pnpm -r type-check: 0 errors - pnpm -r lint: 0 errors - pnpm -r test: 60 unit/API tests pass - pnpm -r build: web + api + packages - pnpm test:e2e: 4 passed, including the new multi-step wizard flow --- apps/web/src/pages/BlueprintBuilder.tsx | 685 +++++++++++++++++++++--- tests/e2e/mvp-flows.spec.ts | 29 +- 2 files changed, 631 insertions(+), 83 deletions(-) diff --git a/apps/web/src/pages/BlueprintBuilder.tsx b/apps/web/src/pages/BlueprintBuilder.tsx index baeef94..4ed2c1d 100644 --- a/apps/web/src/pages/BlueprintBuilder.tsx +++ b/apps/web/src/pages/BlueprintBuilder.tsx @@ -1,112 +1,637 @@ -import { useState } from "react"; -import { useGenerateBlueprint } from "../hooks/useApi"; -import { Loader2, Sparkles, AlertCircle, ArrowRight } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + AlertCircle, + ArrowLeft, + ArrowRight, + Check, + Download, + FileText, + Loader2, + Sparkles, +} from "lucide-react"; +import type { ScaffoldResponse } from "@stackfast/schemas"; +import { useGenerateBlueprint, useGenerateScaffold } from "../hooks/useApi"; import { BlueprintOutputCard } from "../components/BlueprintOutputCard"; import { Layout } from "../components/Layout"; +import { downloadArchive, generateArchive } from "../lib/archive-generator"; + +type WizardStep = "idea" | "constraints" | "results" | "export"; + +const STEPS: { id: WizardStep; label: string }[] = [ + { id: "idea", label: "Idea" }, + { id: "constraints", label: "Constraints" }, + { id: "results", label: "Results" }, + { id: "export", label: "Export" }, +]; + +type BudgetOption = "low" | "medium" | "high" | "enterprise"; +type TimelineOption = "prototype" | "mvp" | "production"; export function BlueprintBuilder() { + const [step, setStep] = useState("idea"); const [idea, setIdea] = useState(""); const [constraints, setConstraints] = useState(""); - const { mutate: generateBlueprint, data: blueprint, isPending, error } = useGenerateBlueprint(); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!idea) return; - - const constraintsArray = constraints - .split('\n') - .map(c => c.trim()) - .filter(Boolean); - - generateBlueprint({ - idea, - constraints: constraintsArray.length > 0 ? constraintsArray : undefined - }); + const [budget, setBudget] = useState(""); + const [timeline, setTimeline] = useState(""); + const [teamSize, setTeamSize] = useState(""); + const [projectName, setProjectName] = useState("stackfast-app"); + const [downloadError, setDownloadError] = useState(null); + + const { + mutate: generateBlueprint, + data: blueprint, + isPending: isGenerating, + error: generateError, + reset: resetBlueprint, + } = useGenerateBlueprint(); + + const { + mutate: generateScaffold, + data: scaffold, + isPending: isScaffolding, + error: scaffoldError, + reset: resetScaffold, + } = useGenerateScaffold(); + + const stepIndex = STEPS.findIndex((s) => s.id === step); + const canGoNext = useMemo(() => { + if (step === "idea") return idea.trim().length > 0; + if (step === "constraints") return true; + if (step === "results") return !!blueprint; + return false; + }, [step, idea, blueprint]); + + const constraintsArray = useMemo( + () => + constraints + .split("\n") + .map((line) => line.trim()) + .filter(Boolean), + [constraints], + ); + + const handleBack = () => { + const previous = STEPS[stepIndex - 1]; + if (previous) setStep(previous.id); + }; + + const handleGenerate = () => { + if (!idea.trim()) return; + const teamSizeValue = teamSize ? Number(teamSize) : undefined; + generateBlueprint( + { + idea: idea.trim(), + ...(constraintsArray.length > 0 ? { constraints: constraintsArray } : {}), + ...(budget ? { budget } : {}), + ...(timeline ? { timeline } : {}), + ...(teamSizeValue && Number.isFinite(teamSizeValue) + ? { teamSize: teamSizeValue } + : {}), + }, + { + onSuccess: () => { + setStep("results"); + }, + }, + ); + }; + + const handleGenerateScaffold = () => { + if (!blueprint) return; + setDownloadError(null); + generateScaffold( + { + toolIds: blueprint.recommendedStack.toolIds, + projectName: projectName.trim() || "stackfast-app", + }, + { + onSuccess: () => { + setStep("export"); + }, + }, + ); + }; + + const handleDownload = async () => { + if (!scaffold) return; + setDownloadError(null); + try { + const blob = await generateArchive(scaffold.files, scaffold.format, projectName); + downloadArchive(blob, `${projectName || "stackfast-app"}.${scaffold.format}`); + } catch (error) { + setDownloadError( + error instanceof Error ? error.message : "Failed to download archive.", + ); + } + }; + + const handleStartOver = () => { + resetBlueprint(); + resetScaffold(); + setStep("idea"); + setDownloadError(null); }; return (
-
-
- -
-

Idea to Stack

-

- Describe your application idea, and we'll architect the perfect modern tech stack for you. +

+
+ +
+

Idea to Stack

+

+ Describe your application idea, refine the constraints, and export a ready-to-clone + starter repo. +

+
+ + + + {step === "idea" && ( + + )} + + {step === "constraints" && ( + + )} + + {step === "results" && blueprint && ( +
+ +
+ )} + + {step === "export" && ( + + )} + + { + if (step === "idea") setStep("constraints"); + else if (step === "constraints") handleGenerate(); + else if (step === "results") handleGenerateScaffold(); + }} + onStartOver={handleStartOver} + /> +
+ + ); +} + +// --------------------------------------------------------------------------- +// Progress indicator +// --------------------------------------------------------------------------- + +function WizardProgress({ currentStep }: { currentStep: WizardStep }) { + const currentIndex = STEPS.findIndex((s) => s.id === currentStep); + + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Step: idea +// --------------------------------------------------------------------------- + +interface IdeaStepProps { + idea: string; + onIdeaChange: (value: string) => void; +} + +function IdeaStep({ idea, onIdeaChange }: IdeaStepProps) { + return ( +
+
+
+ +