diff --git a/src/Drawd.jsx b/src/Drawd.jsx index 821d244..e0734a4 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -310,8 +310,8 @@ export default function Drawd({ initialRoomCode }) { }); // ── Import / export ──────────────────────────────────────────────────────────────── - const { importConfirm, setImportConfirm, importFileRef, onExport, onImport, onImportFileChange, onImportReplace, onImportMerge } = - useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, comments, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups, setComments }); + const { importConfirm, setImportConfirm, importFileRef, onExport, onExportPrototype, onImport, onImportFileChange, onImportReplace, onImportMerge } = + useImportExport({ screens, connections, documents, dataModels, stickyNotes, screenGroups, comments, pan, zoom, featureBrief, taskLink, techStack, replaceAll, mergeAll, setPan, setZoom, setStickyNotes, setScreenGroups, setComments, scopeScreenIds, connectedFileName }); // ── Toast notification ───────────────────────────────────────────────────────────── const [toast, setToast] = useState(null); @@ -460,6 +460,7 @@ export default function Drawd({ initialRoomCode }) { documentCount={documents.length} dataModelCount={dataModels.length} onExport={onExport} + onExportPrototype={onExportPrototype} onImport={onImport} onGenerate={onGenerate} onDocuments={() => setShowDocuments(true)} diff --git a/src/components/TopBar.jsx b/src/components/TopBar.jsx index 39313d0..ac4b874 100644 --- a/src/components/TopBar.jsx +++ b/src/components/TopBar.jsx @@ -102,7 +102,7 @@ function ShareIcon() { ); } -export function TopBar({ screenCount, connectionCount, onExport, onImport, onGenerate, canUndo, canRedo, onUndo, onRedo, connectedFileName, saveStatus, isFileSystemSupported, onNew, onOpen, onSaveAs, onDocuments, documentCount = 0, onDataModels, dataModelCount = 0, collabState, onShare, collabBadge, collabPresence, onToggleParticipants, showParticipants, onTemplates, onToggleComments, showComments, unresolvedCommentCount = 0, canComment }) { +export function TopBar({ screenCount, connectionCount, onExport, onExportPrototype, onImport, onGenerate, canUndo, canRedo, onUndo, onRedo, connectedFileName, saveStatus, isFileSystemSupported, onNew, onOpen, onSaveAs, onDocuments, documentCount = 0, onDataModels, dataModelCount = 0, collabState, onShare, collabBadge, collabPresence, onToggleParticipants, showParticipants, onTemplates, onToggleComments, showComments, unresolvedCommentCount = 0, canComment }) { const [fileMenuOpen, setFileMenuOpen] = useState(false); const fileMenuRef = useRef(null); @@ -430,6 +430,15 @@ export function TopBar({ screenCount, connectionCount, onExport, onImport, onGen > Export + + )} diff --git a/src/hooks/useImportExport.js b/src/hooks/useImportExport.js index 5eb0638..7936152 100644 --- a/src/hooks/useImportExport.js +++ b/src/hooks/useImportExport.js @@ -2,6 +2,7 @@ import { useState, useRef, useCallback } from "react"; import { exportFlow } from "../utils/exportFlow"; import { importFlow } from "../utils/importFlow"; import { mergeFlow } from "../utils/mergeFlow"; +import { generatePrototype, downloadPrototype } from "../utils/generatePrototype"; export function useImportExport({ screens, @@ -23,6 +24,8 @@ export function useImportExport({ setStickyNotes, setScreenGroups, setComments, + scopeScreenIds, + connectedFileName, }) { const [importConfirm, setImportConfirm] = useState(null); const importFileRef = useRef(null); @@ -78,10 +81,20 @@ export function useImportExport({ setImportConfirm(null); }, [importConfirm, screens, mergeAll]); + const onExportPrototype = useCallback(() => { + if (screens.length === 0) return; + const html = generatePrototype(screens, connections, { + title: connectedFileName || "Prototype", + scopeScreenIds, + }); + downloadPrototype(html); + }, [screens, connections, scopeScreenIds, connectedFileName]); + return { importConfirm, setImportConfirm, importFileRef, onExport, + onExportPrototype, onImport, onImportFileChange, onImportReplace, diff --git a/src/pages/docs/userGuide.md b/src/pages/docs/userGuide.md index 8cf8e8d..f1b268e 100644 --- a/src/pages/docs/userGuide.md +++ b/src/pages/docs/userGuide.md @@ -472,6 +472,39 @@ Click "Download ZIP" in the Instructions panel to download all generated files p > [!TIP] > Requirement IDs (SCR-XXXXXXXX, HSP-XXXXXXXX-XXXX, NAV-XXXX) are stable across regenerations as long as screen and hotspot names stay the same. You can reference them in follow-up prompts to your AI assistant. +## Exporting an Interactive Prototype + +Export your flow as a clickable HTML prototype that stakeholders can tap through in any browser — no code, no server, no dependencies. + +### How to export + +- Open the **File** menu in the top bar and click **Export Prototype** +- A single `.html` file downloads with all screen images and navigation logic embedded + +### What the prototype includes + +- Each screen is displayed as a full-viewport image inside a phone-frame container (430px max-width on desktop, full-width on mobile) +- Hotspots are invisible tap areas positioned over the screen image — tap one to navigate to the connected screen +- A floating **Back** button appears after navigating and returns to the previous screen +- A **sidebar screen list** (toggle via the hamburger icon) lets you jump to any screen directly +- Conditional hotspots show a **choice popup** with labeled options so reviewers can explore different paths + +### Navigation behavior + +- `navigate` and `modal` hotspots go to the connected screen +- `back` hotspots return to the previous screen in the navigation history +- `api` hotspots follow the success path (there is no real backend in the prototype) +- `conditional` hotspots present a menu of labeled branches +- Hotspots without a target are rendered but do nothing when tapped +- Keyboard navigation: `Backspace` or `Alt+Left` to go back, `Escape` to close menus + +### Scope filtering + +If a scope root is active (you are viewing a sub-flow), only the screens in that scope are included in the prototype. + +> [!TIP] +> The exported file is entirely self-contained — share it via email, Slack, or any file host. Recipients just open it in a browser to tap through the flow. + ## Keyboard Shortcuts Press `?` anywhere on the canvas to open the full keyboard shortcuts panel. The shortcuts below are organized by category. diff --git a/src/utils/generatePrototype.js b/src/utils/generatePrototype.js new file mode 100644 index 0000000..f60b772 --- /dev/null +++ b/src/utils/generatePrototype.js @@ -0,0 +1,517 @@ +/** + * Generates a self-contained interactive HTML prototype from flow data. + * Each screen becomes a tappable page with hotspot overlays that navigate + * between screens. Output is a single HTML file with all images inlined + * as base64 — no external dependencies required. + */ + +/** + * Resolve navigation targets for every hotspot across all screens. + * Returns { [screenId]: { [hotspotId]: { action, targetScreenId, conditions } } } + */ +function buildNavigationMap(screens, connections) { + const navMap = {}; + + for (const screen of screens) { + const screenNav = {}; + for (const hs of screen.hotspots || []) { + const action = hs.action || "navigate"; + + if (action === "back") { + screenNav[hs.id] = { action: "back", targetScreenId: null, conditions: null }; + continue; + } + + if (action === "conditional") { + const conditions = (hs.conditions || []) + .filter((c) => c.targetScreenId) + .map((c) => ({ + label: c.label || "Option", + targetScreenId: c.targetScreenId, + })); + screenNav[hs.id] = { action: "conditional", targetScreenId: null, conditions }; + continue; + } + + // navigate, modal, api, custom — resolve a single target + let targetId = null; + + if (action === "navigate" || action === "modal") { + targetId = hs.targetScreenId || null; + } else if (action === "api") { + // Follow success path in prototype (no real backend) + targetId = hs.onSuccessTargetId || null; + } + + // Fallback: look for a connection with this hotspotId + if (!targetId) { + const conn = connections.find( + (c) => c.fromScreenId === screen.id && c.hotspotId === hs.id + ); + if (conn) targetId = conn.toScreenId; + } + + if (targetId) { + screenNav[hs.id] = { action: "navigate", targetScreenId: targetId, conditions: null }; + } + // If no target resolved, omit from navMap — hotspot will be a no-op + } + if (Object.keys(screenNav).length > 0) { + navMap[screen.id] = screenNav; + } + } + + return navMap; +} + +/** + * Serialize screens into a minimal shape for embedding in the HTML. + */ +function buildScreenData(screens) { + return screens.map((s) => ({ + id: s.id, + name: s.name || "Untitled", + imageData: s.imageData || null, + hotspots: (s.hotspots || []).map((h) => ({ + id: h.id, + label: h.label || "", + x: h.x, + y: h.y, + w: h.w, + h: h.h, + })), + })); +} + +const PROTOTYPE_CSS = ` +* { margin: 0; padding: 0; box-sizing: border-box; } +html, body { height: 100%; overflow: hidden; background: #1a1a2e; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } +#app { height: 100%; display: flex; justify-content: center; align-items: stretch; } + +.phone-frame { position: relative; width: 100%; max-width: 430px; + height: 100%; overflow: hidden; background: #000; } + +.screen { position: absolute; inset: 0; display: flex; flex-direction: column; + opacity: 0; pointer-events: none; transition: opacity 0.3s ease; } +.screen.visible { opacity: 1; pointer-events: auto; } + +.screen img { width: 100%; height: 100%; object-fit: contain; + object-position: top center; display: block; } + +.screen .hotspot-layer { position: absolute; } + +.hotspot { position: absolute; cursor: pointer; border: none; + background: transparent; padding: 0; z-index: 2; + -webkit-tap-highlight-color: rgba(0,120,255,0.15); } +.hotspot:active { background: rgba(0,120,255,0.12); } + +@keyframes hotspot-hint { + 0% { background: rgba(0,120,255,0.18); border-color: rgba(0,120,255,0.6); } + 100% { background: transparent; border-color: transparent; } +} +.hotspot.hint { border: 2px solid rgba(0,120,255,0.6); + border-radius: 6px; + animation: hotspot-hint 1.2s ease-out forwards; } + +.no-image { display: flex; align-items: center; justify-content: center; + height: 100%; color: #666; font-size: 18px; text-align: center; + padding: 24px; } + +.back-btn { position: fixed; bottom: 24px; left: 50%; + transform: translateX(-50%); z-index: 100; + background: rgba(0,0,0,0.7); color: #fff; border: none; + border-radius: 24px; padding: 10px 24px; cursor: pointer; + font-size: 14px; backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: none; } +.back-btn.show { display: block; } +.back-btn:hover { background: rgba(0,0,0,0.9); } + +.sidebar-toggle { position: fixed; top: 12px; right: 12px; z-index: 100; + background: rgba(0,0,0,0.7); color: #fff; + border: none; border-radius: 8px; padding: 8px 12px; + cursor: pointer; font-size: 18px; line-height: 1; + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } +.sidebar-toggle:hover { background: rgba(0,0,0,0.9); } + +.sidebar-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); + z-index: 190; display: none; } +.sidebar-overlay.show { display: block; } + +.sidebar { position: fixed; top: 0; right: 0; width: 280px; + height: 100%; background: rgba(20,20,40,0.97); + z-index: 200; transform: translateX(100%); + transition: transform 0.3s ease; overflow-y: auto; + -webkit-overflow-scrolling: touch; + backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } +.sidebar.open { transform: translateX(0); } + +.sidebar-header { padding: 16px; font-size: 13px; font-weight: 600; + color: #888; text-transform: uppercase; letter-spacing: 0.05em; + border-bottom: 1px solid rgba(255,255,255,0.06); } + +.sidebar-item { padding: 14px 16px; color: #ccc; cursor: pointer; + border-bottom: 1px solid rgba(255,255,255,0.04); + font-size: 14px; transition: background 0.15s; } +.sidebar-item:hover { background: rgba(255,255,255,0.06); } +.sidebar-item.active { color: #61dafb; background: rgba(97,218,251,0.08); } + +.condition-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); + z-index: 250; display: none; } +.condition-overlay.show { display: flex; align-items: center; justify-content: center; } + +.condition-menu { background: #2a2a4a; border-radius: 14px; padding: 8px; + min-width: 220px; max-width: 320px; + box-shadow: 0 8px 32px rgba(0,0,0,0.6); } +.condition-menu-title { padding: 12px 14px 8px; font-size: 12px; font-weight: 600; + color: #888; text-transform: uppercase; letter-spacing: 0.04em; } +.condition-option { padding: 14px 14px; color: #ddd; cursor: pointer; + border-radius: 8px; font-size: 15px; + transition: background 0.15s; } +.condition-option:hover { background: rgba(255,255,255,0.08); } + +.screen-name { position: fixed; top: 12px; left: 50%; transform: translateX(-50%); + z-index: 90; background: rgba(0,0,0,0.6); color: #aaa; + font-size: 11px; padding: 4px 12px; border-radius: 12px; + pointer-events: none; white-space: nowrap; + backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); } + +@media (max-width: 430px) { + .phone-frame { max-width: 100%; } + .sidebar { width: 100%; } +} +`; + +function buildRuntimeJS() { + return ` +(function() { + var screens = window.__SCREENS__; + var navMap = window.__NAV_MAP__; + var startId = window.__START_ID__; + + var currentId = startId; + var history = []; + var sidebarOpen = false; + + var app = document.getElementById("app"); + var phoneFrame = document.createElement("div"); + phoneFrame.className = "phone-frame"; + app.appendChild(phoneFrame); + + var screenEls = {}; + var sidebarItems = {}; + + // Calculate rendered image bounds for object-fit: contain + function alignHotspotLayer(img, hotLayer) { + var natW = img.naturalWidth; + var natH = img.naturalHeight; + if (!natW || !natH) { hotLayer.style.inset = "0"; return; } + var contW = img.clientWidth; + var contH = img.clientHeight; + var scale = Math.min(contW / natW, contH / natH); + var renderedW = natW * scale; + var renderedH = natH * scale; + // object-position: top center + var offsetX = (contW - renderedW) / 2; + var offsetY = 0; + hotLayer.style.left = offsetX + "px"; + hotLayer.style.top = offsetY + "px"; + hotLayer.style.width = renderedW + "px"; + hotLayer.style.height = renderedH + "px"; + } + + // Build screen elements + screens.forEach(function(s) { + var div = document.createElement("div"); + div.className = "screen"; + div.dataset.id = s.id; + + var hotLayer = document.createElement("div"); + hotLayer.className = "hotspot-layer"; + + if (s.imageData) { + var img = document.createElement("img"); + img.src = s.imageData; + img.alt = s.name; + img.draggable = false; + img.addEventListener("load", function() { alignHotspotLayer(img, hotLayer); }); + div.appendChild(img); + } else { + var ph = document.createElement("div"); + ph.className = "no-image"; + ph.textContent = s.name; + div.appendChild(ph); + hotLayer.style.inset = "0"; + } + + s.hotspots.forEach(function(h) { + var btn = document.createElement("button"); + btn.className = "hotspot"; + btn.style.left = h.x + "%"; + btn.style.top = h.y + "%"; + btn.style.width = h.w + "%"; + btn.style.height = h.h + "%"; + btn.title = h.label || ""; + btn.setAttribute("aria-label", h.label || "Hotspot"); + btn.addEventListener("click", function() { handleHotspotClick(s.id, h.id); }); + hotLayer.appendChild(btn); + }); + div.appendChild(hotLayer); + + phoneFrame.appendChild(div); + screenEls[s.id] = div; + }); + + // Screen name indicator + var nameEl = document.createElement("div"); + nameEl.className = "screen-name"; + document.body.appendChild(nameEl); + + // Back button + var backBtn = document.createElement("button"); + backBtn.className = "back-btn"; + backBtn.textContent = "\\u2190 Back"; + backBtn.addEventListener("click", goBack); + document.body.appendChild(backBtn); + + // Sidebar toggle + var toggleBtn = document.createElement("button"); + toggleBtn.className = "sidebar-toggle"; + toggleBtn.innerHTML = "☰"; + toggleBtn.addEventListener("click", toggleSidebar); + document.body.appendChild(toggleBtn); + + // Sidebar overlay + var sideOverlay = document.createElement("div"); + sideOverlay.className = "sidebar-overlay"; + sideOverlay.addEventListener("click", closeSidebar); + document.body.appendChild(sideOverlay); + + // Sidebar + var sidebar = document.createElement("div"); + sidebar.className = "sidebar"; + var sideHeader = document.createElement("div"); + sideHeader.className = "sidebar-header"; + sideHeader.textContent = "Screens"; + sidebar.appendChild(sideHeader); + + screens.forEach(function(s) { + var item = document.createElement("div"); + item.className = "sidebar-item"; + item.textContent = s.name; + item.addEventListener("click", function() { + closeSidebar(); + navigate(s.id); + }); + sidebar.appendChild(item); + sidebarItems[s.id] = item; + }); + document.body.appendChild(sidebar); + + // Condition menu + var condOverlay = document.createElement("div"); + condOverlay.className = "condition-overlay"; + condOverlay.addEventListener("click", function(e) { + if (e.target === condOverlay) hideConditionMenu(); + }); + var condMenu = document.createElement("div"); + condMenu.className = "condition-menu"; + condOverlay.appendChild(condMenu); + document.body.appendChild(condOverlay); + + function showScreen(id) { + currentId = id; + Object.keys(screenEls).forEach(function(sid) { + screenEls[sid].classList.toggle("visible", sid === id); + }); + backBtn.classList.toggle("show", history.length > 0); + Object.keys(sidebarItems).forEach(function(sid) { + sidebarItems[sid].classList.toggle("active", sid === id); + }); + var sc = screens.find(function(s) { return s.id === id; }); + nameEl.textContent = sc ? sc.name : ""; + } + + function navigate(targetId) { + if (!targetId || targetId === currentId) return; + if (!screenEls[targetId]) return; + history.push(currentId); + showScreen(targetId); + } + + function goBack() { + if (history.length === 0) return; + var prevId = history.pop(); + showScreen(prevId); + } + + function handleHotspotClick(screenId, hotspotId) { + var entry = navMap[screenId] && navMap[screenId][hotspotId]; + if (!entry) return; + + if (entry.action === "back") { + goBack(); + } else if (entry.action === "conditional" && entry.conditions && entry.conditions.length > 0) { + showConditionMenu(entry.conditions); + } else if (entry.targetScreenId) { + navigate(entry.targetScreenId); + } + } + + function showConditionMenu(conditions) { + condMenu.innerHTML = ""; + var title = document.createElement("div"); + title.className = "condition-menu-title"; + title.textContent = "Choose path"; + condMenu.appendChild(title); + + conditions.forEach(function(c) { + var opt = document.createElement("div"); + opt.className = "condition-option"; + opt.textContent = c.label; + opt.addEventListener("click", function() { + hideConditionMenu(); + navigate(c.targetScreenId); + }); + condMenu.appendChild(opt); + }); + condOverlay.classList.add("show"); + } + + function hideConditionMenu() { + condOverlay.classList.remove("show"); + } + + function toggleSidebar() { + sidebarOpen = !sidebarOpen; + sidebar.classList.toggle("open", sidebarOpen); + sideOverlay.classList.toggle("show", sidebarOpen); + } + + function closeSidebar() { + sidebarOpen = false; + sidebar.classList.remove("open"); + sideOverlay.classList.remove("show"); + } + + // Re-align hotspot layers on resize + window.addEventListener("resize", function() { + screens.forEach(function(s) { + var el = screenEls[s.id]; + if (!el) return; + var img = el.querySelector("img"); + var hotLayer = el.querySelector(".hotspot-layer"); + if (img && hotLayer && img.naturalWidth) { + alignHotspotLayer(img, hotLayer); + } + }); + }); + + // Hint: clicking outside hotspots briefly highlights all hotspot locations + var hintTimeout = null; + phoneFrame.addEventListener("click", function(e) { + if (e.target.closest(".hotspot") || e.target.closest(".back-btn")) return; + var visibleScreen = screenEls[currentId]; + if (!visibleScreen) return; + var hotspots = visibleScreen.querySelectorAll(".hotspot"); + if (hotspots.length === 0) return; + clearTimeout(hintTimeout); + hotspots.forEach(function(btn) { + btn.classList.remove("hint"); + void btn.offsetWidth; + btn.classList.add("hint"); + }); + hintTimeout = setTimeout(function() { + hotspots.forEach(function(btn) { btn.classList.remove("hint"); }); + }, 1300); + }); + + // Keyboard navigation + document.addEventListener("keydown", function(e) { + if (e.key === "Escape") { + hideConditionMenu(); + closeSidebar(); + } else if (e.key === "Backspace" || (e.key === "ArrowLeft" && e.altKey)) { + goBack(); + } + }); + + // Start + showScreen(startId); +})(); +`; +} + +/** + * Generate a self-contained HTML prototype from flow data. + * @param {Array} screens - Screen objects with imageData, hotspots, etc. + * @param {Array} connections - Connection objects + * @param {Object} [options] + * @param {string} [options.title] - Page title (default: "Prototype") + * @param {string|null} [options.startScreenId] - First screen to display + * @param {Set|null} [options.scopeScreenIds] - Only include these screen IDs + * @returns {string} Complete HTML document + */ +export function generatePrototype(screens, connections, options = {}) { + const { title = "Prototype", startScreenId = null, scopeScreenIds = null } = options; + + // Filter and sort screens + let filtered = scopeScreenIds + ? screens.filter((s) => scopeScreenIds.has(s.id)) + : [...screens]; + filtered.sort((a, b) => a.x - b.x || a.y - b.y); + + if (filtered.length === 0) return ""; + + // Filter connections to only those between included screens + const screenIdSet = new Set(filtered.map((s) => s.id)); + const filteredConnections = connections.filter( + (c) => screenIdSet.has(c.fromScreenId) && screenIdSet.has(c.toScreenId) + ); + + const navMap = buildNavigationMap(filtered, filteredConnections); + const screenData = buildScreenData(filtered); + + const startId = startScreenId && screenIdSet.has(startScreenId) + ? startScreenId + : filtered[0].id; + + const escapedTitle = title.replace(//g, ">"); + + return ` + + + + +${escapedTitle} + + + +
+ + +`; +} + +/** + * Download an HTML prototype file. + * @param {string} htmlString - Complete HTML document string + * @param {string} [filename] - Download filename + */ +export function downloadPrototype(htmlString, filename = "prototype.html") { + const blob = new Blob([htmlString], { type: "text/html;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); +} diff --git a/src/utils/generatePrototype.test.js b/src/utils/generatePrototype.test.js new file mode 100644 index 0000000..54cb9d9 --- /dev/null +++ b/src/utils/generatePrototype.test.js @@ -0,0 +1,291 @@ +import { describe, it, expect } from "vitest"; +import { generatePrototype } from "./generatePrototype.js"; + +function makeScreen(overrides = {}) { + return { + id: "s1", + name: "Screen 1", + x: 0, + y: 0, + width: 200, + imageData: "data:image/png;base64,AAAA", + hotspots: [], + ...overrides, + }; +} + +function makeHotspot(overrides = {}) { + return { + id: "hs1", + label: "Tap me", + x: 10, + y: 20, + w: 30, + h: 10, + action: "navigate", + targetScreenId: "s2", + conditions: [], + ...overrides, + }; +} + +describe("generatePrototype", () => { + it("returns a valid HTML document", () => { + const html = generatePrototype([makeScreen()], []); + expect(html).toContain(""); + expect(html).toContain(""); + expect(html).toContain("Prototype"); + }); + + it("returns empty string when no screens", () => { + expect(generatePrototype([], [])).toBe(""); + }); + + it("returns empty string when scope filters out all screens", () => { + const html = generatePrototype( + [makeScreen()], + [], + { scopeScreenIds: new Set(["nonexistent"]) } + ); + expect(html).toBe(""); + }); + + it("embeds screen image data as base64", () => { + const screen = makeScreen({ imageData: "data:image/png;base64,TEST123" }); + const html = generatePrototype([screen], []); + expect(html).toContain("data:image/png;base64,TEST123"); + }); + + it("uses custom title", () => { + const html = generatePrototype([makeScreen()], [], { title: "My App" }); + expect(html).toContain("My App"); + }); + + it("escapes HTML in title", () => { + const html = generatePrototype([makeScreen()], [], { title: "" }); + expect(html).not.toContain(""); + expect(html).toContain("<script>"); + }); + + it("generates hotspot elements with correct percentage positioning", () => { + const screen = makeScreen({ + hotspots: [makeHotspot({ x: 15, y: 25, w: 40, h: 12 })], + }); + const html = generatePrototype([screen], []); + // The hotspot data is serialized in the screen data JSON + expect(html).toContain('"x":15'); + expect(html).toContain('"y":25'); + expect(html).toContain('"w":40'); + expect(html).toContain('"h":12'); + }); + + it("handles screens without images", () => { + const screen = makeScreen({ imageData: null }); + const html = generatePrototype([screen], []); + expect(html).toContain("Screen 1"); + expect(html).toContain("no-image"); + }); + + it("handles screens without hotspots", () => { + const screen = makeScreen({ hotspots: [] }); + const html = generatePrototype([screen], []); + expect(html).toContain(""); + }); + + it("respects scopeScreenIds filtering", () => { + const screens = [ + makeScreen({ id: "s1", name: "Included" }), + makeScreen({ id: "s2", name: "Excluded", x: 100 }), + ]; + const html = generatePrototype(screens, [], { + scopeScreenIds: new Set(["s1"]), + }); + expect(html).toContain("Included"); + expect(html).not.toContain("Excluded"); + }); + + it("resolves navigate target via hotspot.targetScreenId", () => { + const screens = [ + makeScreen({ + id: "s1", + hotspots: [makeHotspot({ id: "hs1", targetScreenId: "s2" })], + }), + makeScreen({ id: "s2", name: "Target", x: 100 }), + ]; + const html = generatePrototype(screens, []); + // NAV_MAP should have s1 -> hs1 -> targetScreenId: s2 + expect(html).toContain('"targetScreenId":"s2"'); + }); + + it("resolves navigate target via connection fallback", () => { + const screens = [ + makeScreen({ + id: "s1", + hotspots: [makeHotspot({ id: "hs1", targetScreenId: null })], + }), + makeScreen({ id: "s2", name: "Target", x: 100 }), + ]; + const connections = [ + { id: "c1", fromScreenId: "s1", toScreenId: "s2", hotspotId: "hs1" }, + ]; + const html = generatePrototype(screens, connections); + expect(html).toContain('"targetScreenId":"s2"'); + }); + + it("resolves conditional hotspots into conditions array", () => { + const screens = [ + makeScreen({ + id: "s1", + hotspots: [ + makeHotspot({ + id: "hs1", + action: "conditional", + targetScreenId: null, + conditions: [ + { id: "c1", label: "Success", targetScreenId: "s2" }, + { id: "c2", label: "Error", targetScreenId: "s3" }, + ], + }), + ], + }), + makeScreen({ id: "s2", name: "Success", x: 100 }), + makeScreen({ id: "s3", name: "Error", x: 200 }), + ]; + const html = generatePrototype(screens, []); + expect(html).toContain('"action":"conditional"'); + expect(html).toContain('"label":"Success"'); + expect(html).toContain('"label":"Error"'); + }); + + it("handles back action hotspots", () => { + const screens = [ + makeScreen({ + id: "s1", + hotspots: [makeHotspot({ id: "hs1", action: "back", targetScreenId: null })], + }), + ]; + const html = generatePrototype(screens, []); + expect(html).toContain('"action":"back"'); + }); + + it("handles API action hotspots via success path", () => { + const screens = [ + makeScreen({ + id: "s1", + hotspots: [ + makeHotspot({ + id: "hs1", + action: "api", + targetScreenId: null, + onSuccessTargetId: "s2", + }), + ], + }), + makeScreen({ id: "s2", name: "API Result", x: 100 }), + ]; + const html = generatePrototype(screens, []); + expect(html).toContain('"targetScreenId":"s2"'); + }); + + it("handles orphan hotspots without errors", () => { + const screens = [ + makeScreen({ + id: "s1", + hotspots: [makeHotspot({ id: "hs1", targetScreenId: null })], + }), + ]; + const html = generatePrototype(screens, []); + // Should produce valid HTML — orphan hotspot omitted from navMap + expect(html).toContain(""); + // Hotspot data still in screen data (rendered as no-op) + expect(html).toContain('"id":"hs1"'); + }); + + it("uses first sorted screen as start when no startScreenId provided", () => { + const screens = [ + makeScreen({ id: "s2", x: 200 }), + makeScreen({ id: "s1", x: 0 }), + ]; + const html = generatePrototype(screens, []); + expect(html).toContain('"s1"'); + // s1 appears as __START_ID__ because it has lower x + expect(html).toContain('window.__START_ID__ = "s1"'); + }); + + it("uses provided startScreenId when given", () => { + const screens = [ + makeScreen({ id: "s1", x: 0 }), + makeScreen({ id: "s2", x: 200 }), + ]; + const html = generatePrototype(screens, [], { startScreenId: "s2" }); + expect(html).toContain('window.__START_ID__ = "s2"'); + }); + + it("falls back to first screen if startScreenId is not in scope", () => { + const screens = [ + makeScreen({ id: "s1", x: 0 }), + makeScreen({ id: "s2", x: 200 }), + ]; + const html = generatePrototype(screens, [], { startScreenId: "nonexistent" }); + expect(html).toContain('window.__START_ID__ = "s1"'); + }); + + it("sorts screens by x then y", () => { + const screens = [ + makeScreen({ id: "c", x: 100, y: 0 }), + makeScreen({ id: "a", x: 0, y: 50 }), + makeScreen({ id: "b", x: 0, y: 10 }), + ]; + const html = generatePrototype(screens, []); + // Sidebar order should be b, a, c + const bIdx = html.indexOf('"id":"b"'); + const aIdx = html.indexOf('"id":"a"'); + const cIdx = html.indexOf('"id":"c"'); + expect(bIdx).toBeLessThan(aIdx); + expect(aIdx).toBeLessThan(cIdx); + }); + + it("filters connections to only included screens", () => { + const screens = [ + makeScreen({ + id: "s1", + hotspots: [makeHotspot({ id: "hs1", targetScreenId: null })], + }), + makeScreen({ id: "s2", x: 100 }), + makeScreen({ id: "s3", x: 200 }), + ]; + const connections = [ + { id: "c1", fromScreenId: "s1", toScreenId: "s2", hotspotId: "hs1" }, + { id: "c2", fromScreenId: "s1", toScreenId: "s3", hotspotId: "hs1" }, + ]; + // Only include s1 and s2 — connection to s3 should be filtered out + const html = generatePrototype(screens, connections, { + scopeScreenIds: new Set(["s1", "s2"]), + }); + expect(html).toContain('"targetScreenId":"s2"'); + expect(html).not.toContain('"s3"'); + }); + + it("includes runtime navigation engine", () => { + const html = generatePrototype([makeScreen()], []); + expect(html).toContain("function navigate("); + expect(html).toContain("function goBack("); + expect(html).toContain("function handleHotspotClick("); + }); + + it("includes sidebar and back button in output", () => { + const html = generatePrototype([makeScreen()], []); + expect(html).toContain("sidebar"); + expect(html).toContain("back-btn"); + }); + + it("includes hotspot hint CSS and click-outside handler", () => { + const screen = makeScreen({ + hotspots: [makeHotspot()], + }); + const html = generatePrototype([screen], []); + expect(html).toContain("hotspot-hint"); + expect(html).toContain(".hotspot.hint"); + expect(html).toContain('classList.add("hint")'); + }); +});