diff --git a/src/App.tsx b/src/App.tsx
index b936a05..f197e85 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -19,10 +19,15 @@ import {
} from "@/components/ui/studio-sidebar";
import { BlockPreviewPage, BlockCodePage } from "@/components/blocks";
import { ErrorPagePreview } from "@/components/error-pages";
+import { PrototypeBuilder } from "@/components/prototype-builder";
function AppShell() {
const { activeComponent } = useNavigation();
+ if (activeComponent === "prototype-builder") {
+ return ;
+ }
+
if (activeComponent.startsWith("block-preview-")) {
const id = activeComponent.slice("block-preview-".length);
return ;
diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx
index 900c2e3..6be23f7 100644
--- a/src/components/app-sidebar.tsx
+++ b/src/components/app-sidebar.tsx
@@ -27,6 +27,7 @@ const data = {
{
title: "Tools",
items: [
+ { id: "prototype-builder", title: "Prototype Builder" },
{ id: "playground", title: "Playground" },
{ id: "blocks", title: "Blocks" },
{ id: "settings", title: "Settings" },
diff --git a/src/components/prototype-builder/Canvas.tsx b/src/components/prototype-builder/Canvas.tsx
new file mode 100644
index 0000000..94420e7
--- /dev/null
+++ b/src/components/prototype-builder/Canvas.tsx
@@ -0,0 +1,255 @@
+import * as React from "react";
+import { useDroppable } from "@dnd-kit/core";
+import {
+ SortableContext,
+ useSortable,
+ verticalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
+import {
+ ArrowDown,
+ ArrowUp,
+ Copy,
+ GripVertical,
+ Trash2,
+ Zap,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+
+import { ElementView } from "./element-renderer";
+import { DEVICE_WIDTH } from "./constants";
+import { usePrototypeStore, useCurrentPage } from "./store";
+import type { PrototypeElement } from "./types";
+
+export function Canvas() {
+ const { state, dispatch } = usePrototypeStore();
+ const page = useCurrentPage();
+ const width = DEVICE_WIDTH[state.device];
+
+ const getVar = React.useCallback(
+ (name: string) => state.variables.find((v) => v.name === name)?.value ?? "",
+ [state.variables]
+ );
+ const setVar = React.useCallback(
+ (name: string, value: string) => {
+ const idx = state.variables.findIndex((v) => v.name === name);
+ if (idx >= 0) dispatch({ t: "updateVariable", index: idx, value });
+ else dispatch({ t: "addVariable", name });
+ },
+ [dispatch, state.variables]
+ );
+
+ const { setNodeRef, isOver } = useDroppable({ id: "canvas-dropzone" });
+
+ return (
+
dispatch({ t: "select", id: null })}
+ >
+
e.stopPropagation()}
+ >
+ {/* Device chrome */}
+
+
+
+
+ {page?.name}
+ {width}px
+
+
+
+ {page && page.elements.length > 0 ? (
+
e.id)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {page.elements.map((el, i) => (
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+function EmptyCanvas() {
+ return (
+
+
+
Empty screen
+
+ Click a component in the library on the left to add it here, or drag
+ it onto the canvas.
+
+
+
+ );
+}
+
+interface CanvasElementProps {
+ element: PrototypeElement;
+ index: number;
+ count: number;
+ selected: boolean;
+ getVar: (name: string) => string;
+ setVar: (name: string, value: string) => void;
+}
+
+function CanvasElement({
+ element,
+ index,
+ count,
+ selected,
+ getVar,
+ setVar,
+}: CanvasElementProps) {
+ const { dispatch } = usePrototypeStore();
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: element.id,
+ });
+
+ const hasActions = element.actions.length > 0;
+
+ return (
+ {
+ e.stopPropagation();
+ dispatch({ t: "select", id: element.id });
+ }}
+ >
+ {/* Interaction indicator */}
+ {hasActions && (
+
+
+
+ )}
+
+ {/* Hover / selected toolbar */}
+
e.stopPropagation()}
+ >
+
+
+ {element.name}
+
+
+ dispatch({ t: "moveElement", id: element.id, dir: -1 })
+ }
+ >
+
+
+
dispatch({ t: "moveElement", id: element.id, dir: 1 })}
+ >
+
+
+
dispatch({ t: "duplicateElement", id: element.id })}
+ >
+
+
+
dispatch({ t: "deleteElement", id: element.id })}
+ >
+
+
+
+
+
+
+
+
+ );
+}
+
+function ToolbarButton({
+ children,
+ onClick,
+ disabled,
+ title,
+}: {
+ children: React.ReactNode;
+ onClick: () => void;
+ disabled?: boolean;
+ title: string;
+}) {
+ return (
+
+ );
+}
diff --git a/src/components/prototype-builder/ComponentLibrary.tsx b/src/components/prototype-builder/ComponentLibrary.tsx
new file mode 100644
index 0000000..059093e
--- /dev/null
+++ b/src/components/prototype-builder/ComponentLibrary.tsx
@@ -0,0 +1,112 @@
+import * as React from "react";
+import { useDraggable } from "@dnd-kit/core";
+import { Search } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Input } from "@/components/ui/input";
+
+import { catalogItems, type CatalogItem } from "./catalog";
+import { usePrototypeStore, useCurrentPage } from "./store";
+
+export function ComponentLibrary() {
+ const [query, setQuery] = React.useState("");
+ const page = useCurrentPage();
+ const { dispatch } = usePrototypeStore();
+
+ const filtered = React.useMemo(() => {
+ const q = query.trim().toLowerCase();
+ return catalogItems.filter(
+ (i) =>
+ !q ||
+ i.name.toLowerCase().includes(q) ||
+ i.category.toLowerCase().includes(q)
+ );
+ }, [query]);
+
+ const grouped = React.useMemo(() => {
+ const map = new Map();
+ for (const item of filtered) {
+ if (!map.has(item.category)) map.set(item.category, []);
+ map.get(item.category)!.push(item);
+ }
+ return Array.from(map.entries());
+ }, [filtered]);
+
+ return (
+
+
+
+ Components
+
+
+
+ setQuery(e.target.value)}
+ />
+
+
+
+
+ {grouped.map(([category, items]) => (
+
+
+ {category}
+
+
+ {items.map((item) => (
+
+ dispatch({
+ t: "addElement",
+ pageId: page.id,
+ catalogId: item.id,
+ })
+ }
+ />
+ ))}
+
+
+ ))}
+ {grouped.length === 0 && (
+
+ No components match “{query}”.
+
+ )}
+
+
+ );
+}
+
+function PaletteItem({
+ item,
+ onAdd,
+}: {
+ item: CatalogItem;
+ onAdd: () => void;
+}) {
+ const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
+ id: `palette:${item.id}`,
+ });
+ const Icon = item.icon;
+ return (
+
+ );
+}
diff --git a/src/components/prototype-builder/FlowView.tsx b/src/components/prototype-builder/FlowView.tsx
new file mode 100644
index 0000000..0d49be2
--- /dev/null
+++ b/src/components/prototype-builder/FlowView.tsx
@@ -0,0 +1,318 @@
+import * as React from "react";
+import { Flag, LayoutGrid, Maximize2, Minus, Pencil, Plus } from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Badge } from "@/components/ui/badge";
+
+import { catalogById } from "./catalog";
+import { usePrototypeStore } from "./store";
+import type { PrototypePage } from "./types";
+
+const NODE_W = 224;
+const NODE_H = 148;
+
+interface Edge {
+ from: string;
+ to: string;
+ count: number;
+}
+
+export function FlowView() {
+ const { state, dispatch } = usePrototypeStore();
+ const [zoom, setZoom] = React.useState(0.9);
+ const [pan, setPan] = React.useState({ x: 40, y: 20 });
+ const dragRef = React.useRef<{
+ mode: "pan" | "node";
+ id?: string;
+ sx: number;
+ sy: number;
+ ox: number;
+ oy: number;
+ } | null>(null);
+
+ // Derive edges from navigate actions.
+ const edges = React.useMemo(() => {
+ const map = new Map();
+ for (const page of state.pages) {
+ for (const el of page.elements) {
+ for (const a of el.actions) {
+ if (
+ a.type === "navigate" &&
+ a.targetPageId &&
+ a.targetPageId !== page.id
+ ) {
+ const key = `${page.id}->${a.targetPageId}`;
+ const existing = map.get(key);
+ if (existing) existing.count += 1;
+ else map.set(key, { from: page.id, to: a.targetPageId, count: 1 });
+ }
+ }
+ }
+ }
+ return Array.from(map.values());
+ }, [state.pages]);
+
+ const pageById = React.useMemo(
+ () =>
+ Object.fromEntries(state.pages.map((p) => [p.id, p])) as Record<
+ string,
+ PrototypePage
+ >,
+ [state.pages]
+ );
+
+ const onPointerDown = (
+ e: React.PointerEvent,
+ mode: "pan" | "node",
+ id?: string
+ ) => {
+ (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
+ const page = id ? pageById[id] : null;
+ dragRef.current = {
+ mode,
+ id,
+ sx: e.clientX,
+ sy: e.clientY,
+ ox: page ? page.flowX : pan.x,
+ oy: page ? page.flowY : pan.y,
+ };
+ };
+
+ const onPointerMove = (e: React.PointerEvent) => {
+ const d = dragRef.current;
+ if (!d) return;
+ const dx = (e.clientX - d.sx) / zoom;
+ const dy = (e.clientY - d.sy) / zoom;
+ if (d.mode === "node" && d.id) {
+ dispatch({
+ t: "pageFlowPos",
+ id: d.id,
+ x: Math.round(d.ox + dx),
+ y: Math.round(d.oy + dy),
+ });
+ } else {
+ setPan({ x: d.ox + (e.clientX - d.sx), y: d.oy + (e.clientY - d.sy) });
+ }
+ };
+
+ const endDrag = () => {
+ dragRef.current = null;
+ };
+
+ const autoArrange = () => {
+ state.pages.forEach((p, i) => {
+ dispatch({
+ t: "pageFlowPos",
+ id: p.id,
+ x: 40 + i * 300,
+ y: 160 + (i % 2) * 60,
+ });
+ });
+ setPan({ x: 40, y: 20 });
+ setZoom(0.8);
+ };
+
+ return (
+
+ {/* Controls */}
+
+
+
+
+
+
+
+ {Math.round(zoom * 100)}%
+
+
+
+
+
+ {/* Pannable stage */}
+
onPointerDown(e, "pan")}
+ onPointerMove={onPointerMove}
+ onPointerUp={endDrag}
+ onPointerLeave={endDrag}
+ >
+
+ {/* Edges */}
+
+
+ {/* Nodes */}
+ {state.pages.map((page) => (
+
onPointerDown(e, "node", page.id)}
+ onOpen={() => {
+ dispatch({ t: "currentPage", id: page.id });
+ dispatch({ t: "view", view: "design" });
+ }}
+ />
+ ))}
+
+
+
+
+ Drag nodes to arrange · drag background to pan · connections follow
+ “Navigate” interactions
+
+
+ );
+}
+
+function FlowNode({
+ page,
+ active,
+ isStart,
+ onPointerDownHeader,
+ onOpen,
+}: {
+ page: PrototypePage;
+ active: boolean;
+ isStart: boolean;
+ onPointerDownHeader: (e: React.PointerEvent) => void;
+ onOpen: () => void;
+}) {
+ return (
+
+
+ {isStart &&
}
+
{page.name}
+
+
+
+ {page.group && (
+
+ {page.group}
+
+ )}
+
+ {page.elements.slice(0, 3).map((el) => {
+ const Icon = catalogById[el.type]?.icon;
+ return (
+
+ {Icon && }
+ {el.name}
+
+ );
+ })}
+ {page.elements.length === 0 && (
+
+ Empty
+
+ )}
+ {page.elements.length > 3 && (
+
+ +{page.elements.length - 3} more
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/prototype-builder/InteractionPanel.tsx b/src/components/prototype-builder/InteractionPanel.tsx
new file mode 100644
index 0000000..e44e5a8
--- /dev/null
+++ b/src/components/prototype-builder/InteractionPanel.tsx
@@ -0,0 +1,749 @@
+import * as React from "react";
+import {
+ Lightbulb,
+ Plus,
+ Settings2,
+ Trash2,
+ Variable,
+ Zap,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import { Switch } from "@/components/ui/switch";
+import { Badge } from "@/components/ui/badge";
+
+import { catalogById, type FieldSchema } from "./catalog";
+import { usePrototypeStore, useSelectedElement, useCurrentPage } from "./store";
+import type {
+ ActionType,
+ ConditionOp,
+ PrototypeAction,
+ PrototypeElement,
+ TransitionType,
+ TriggerType,
+} from "./types";
+
+const ACTION_LABELS: Record = {
+ navigate: "Navigate to page",
+ back: "Go back",
+ openModal: "Open modal",
+ closeModal: "Close modal",
+ showComponent: "Show component",
+ hideComponent: "Hide component",
+ toggleComponent: "Toggle component",
+ showToast: "Show toast",
+ setVariable: "Set variable",
+ delay: "Delay / wait",
+};
+
+const TRIGGERS: TriggerType[] = ["click", "change", "hover", "submit"];
+const TRANSITIONS: TransitionType[] = [
+ "none",
+ "slide-left",
+ "slide-right",
+ "slide-up",
+ "fade",
+];
+
+// A native