From e0473a01d7d2b323f0d6e6902b2993395f02f014 Mon Sep 17 00:00:00 2001
From: Quang Tran <16215255+trmquang93@users.noreply.github.com>
Date: Fri, 10 Apr 2026 05:55:38 +0700
Subject: [PATCH] feat: add flow comparison to diff two .drawd files (#28)
Add "Compare Flows" to the file menu that lets users select a .drawd
file and see a structured diff against the current canvas -- added,
removed, and modified entities across all categories with field-level
change details.
---
src/Drawd.jsx | 33 +++
src/components/FlowDiffModal.jsx | 364 ++++++++++++++++++++++++++++++
src/components/ModalsLayer.jsx | 11 +
src/components/TopBar.jsx | 15 +-
src/utils/diffFlows.js | 310 +++++++++++++++++++++++++
src/utils/diffFlows.test.js | 374 +++++++++++++++++++++++++++++++
6 files changed, 1106 insertions(+), 1 deletion(-)
create mode 100644 src/components/FlowDiffModal.jsx
create mode 100644 src/utils/diffFlows.js
create mode 100644 src/utils/diffFlows.test.js
diff --git a/src/Drawd.jsx b/src/Drawd.jsx
index 821d244..3377988 100644
--- a/src/Drawd.jsx
+++ b/src/Drawd.jsx
@@ -34,6 +34,8 @@ import { CollabPresence } from "./components/CollabPresence";
import { CollabBadge } from "./components/CollabBadge";
import { importFlow } from "./utils/importFlow";
import { detectDrawdFile, findDrawdItem } from "./utils/detectDrawdFile";
+import { diffFlows } from "./utils/diffFlows";
+import { buildPayload } from "./utils/buildPayload";
export default function Drawd({ initialRoomCode }) {
@@ -198,6 +200,7 @@ export default function Drawd({ initialRoomCode }) {
const [showShortcuts, setShowShortcuts] = useState(false);
const [formSummaryScreen, setFormSummaryScreen] = useState(null);
const [showTemplateBrowser, setShowTemplateBrowser] = useState(false);
+ const [flowDiffResult, setFlowDiffResult] = useState(null);
// ── Template inserter ─────────────────────────────────────────────────
const { insertTemplate } = useTemplateInserter({
@@ -210,6 +213,33 @@ export default function Drawd({ initialRoomCode }) {
setShowTemplateBrowser(false);
}, [insertTemplate]);
+ // ── Flow comparison ───────────────────────────────────────────────────
+ const onCompareFlows = useCallback(async () => {
+ if (!isFileSystemSupported) return;
+ try {
+ const [handle] = await window.showOpenFilePicker({
+ types: [{
+ description: "Drawd files",
+ accept: { "application/json": [".drawd", ".drawd.json"] },
+ }],
+ multiple: false,
+ });
+ const file = await handle.getFile();
+ const text = await file.text();
+ const baseFlow = importFlow(text);
+ const currentFlow = buildPayload(
+ screens, connections, pan, zoom, documents,
+ featureBrief, taskLink, techStack,
+ dataModels, stickyNotes, screenGroups, comments,
+ );
+ const diff = diffFlows(baseFlow, currentFlow);
+ setFlowDiffResult({ diff, fileName: file.name });
+ } catch (err) {
+ if (err.name === "AbortError") return;
+ console.error("Compare failed:", err);
+ }
+ }, [isFileSystemSupported, screens, connections, pan, zoom, documents, featureBrief, taskLink, techStack, dataModels, stickyNotes, screenGroups, comments]);
+
// ── Instruction generation ─────────────────────────────────────────────
const { instructions, showInstructions, setShowInstructions, onGenerate, buildInstructionResult } =
useInstructionGeneration({
@@ -494,6 +524,7 @@ export default function Drawd({ initialRoomCode }) {
onToggleParticipants={() => setShowParticipants((v) => !v)}
showParticipants={showParticipants}
onTemplates={onTemplates}
+ onCompareFlows={onCompareFlows}
canComment={canComment}
showComments={showComments}
onToggleComments={() => setShowComments((v) => !v)}
@@ -733,6 +764,8 @@ export default function Drawd({ initialRoomCode }) {
showTemplateBrowser={showTemplateBrowser}
setShowTemplateBrowser={setShowTemplateBrowser}
onInsertTemplate={onInsertTemplate}
+ flowDiffResult={flowDiffResult}
+ setFlowDiffResult={setFlowDiffResult}
showComments={showComments}
setShowComments={setShowComments}
comments={comments}
diff --git a/src/components/FlowDiffModal.jsx b/src/components/FlowDiffModal.jsx
new file mode 100644
index 0000000..1c84062
--- /dev/null
+++ b/src/components/FlowDiffModal.jsx
@@ -0,0 +1,364 @@
+import { useState } from "react";
+import { COLORS, FONTS, styles } from "../styles/theme";
+
+const CATEGORY_LABELS = {
+ screens: "Screens",
+ connections: "Connections",
+ documents: "Documents",
+ dataModels: "Data Models",
+ stickyNotes: "Sticky Notes",
+ screenGroups: "Screen Groups",
+};
+
+function Chevron({ expanded }) {
+ return (
+
+ ▶
+
+ );
+}
+
+function SummaryBadge({ label, count, color }) {
+ if (count === 0) return null;
+ return (
+
+ {label} {count}
+
+ );
+}
+
+function ChangeItem({ name, type, color, changes, expanded, onToggle }) {
+ const isModified = type === "modified";
+ const prefix = type === "added" ? "+" : type === "removed" ? "\u2212" : "~";
+
+ return (
+
+
+
+ {prefix}
+
+ {name}
+ {isModified && (
+
+ {changes.length} change{changes.length !== 1 ? "s" : ""}
+ {" "}
+
+
+ )}
+
+
+ {isModified && expanded && (
+
+ {changes.map((change, i) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+function ChangeDetail({ change }) {
+ // Hotspot sub-diff
+ if (change.field === "hotspots") {
+ const parts = [];
+ if (change.addedCount > 0) parts.push(`+${change.addedCount} added`);
+ if (change.removedCount > 0) parts.push(`${change.removedCount} removed`);
+ if (change.modifiedCount > 0) parts.push(`${change.modifiedCount} modified`);
+
+ return (
+
+
+ hotspots
+ {": "}
+ {parts.join(", ")}
+
+ {change.details && change.details.map((d, i) => (
+
+
+ {d.type === "added" ? "+" : d.type === "removed" ? "\u2212" : "~"}
+
+ {" "}
+ {d.label}
+ {d.changes && (
+
+ {" "}({d.changes.map((c) => c.field).join(", ")})
+
+ )}
+
+ ))}
+
+ );
+ }
+
+ // Regular field change
+ return (
+
+ {change.field}
+ {": "}
+ {change.from}
+ {" \u2192 "}
+ {change.to}
+
+ );
+}
+
+function CategorySection({ label, data, expandedItems, onToggleItem }) {
+ const [expanded, setExpanded] = useState(true);
+ const changeCount = data.added.length + data.removed.length + data.modified.length;
+
+ if (changeCount === 0) return null;
+
+ return (
+
+
setExpanded((v) => !v)}
+ style={{
+ display: "flex",
+ alignItems: "center",
+ gap: 8,
+ padding: "6px 0",
+ cursor: "pointer",
+ userSelect: "none",
+ }}
+ >
+
+
+ {label}
+
+
+ ({changeCount} change{changeCount !== 1 ? "s" : ""})
+
+
+
+ {expanded && (
+
+ {data.added.map((item) => (
+
+ ))}
+ {data.removed.map((item) => (
+
+ ))}
+ {data.modified.map((item) => (
+ onToggleItem(item.id)}
+ />
+ ))}
+
+ )}
+
+ );
+}
+
+export function FlowDiffModal({ diffResult, baseFileName, onClose }) {
+ const [expandedItems, setExpandedItems] = useState(new Set());
+ const { summary, categories, metadata } = diffResult;
+
+ const totalChanges = summary.added + summary.removed + summary.modified;
+ const hasMetadataChanges = metadata.modified.length > 0;
+
+ const toggleItem = (id) => {
+ setExpandedItems((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+ };
+
+ return (
+
+
e.stopPropagation()}
+ style={{
+ ...styles.modalCard,
+ width: 560,
+ maxHeight: "80vh",
+ display: "flex",
+ flexDirection: "column",
+ padding: 0,
+ }}
+ >
+ {/* Header */}
+
+
+ Flow Comparison
+
+
+ Comparing against: {baseFileName}
+
+
+ {/* Summary bar */}
+
+
+
+
+ {summary.unchanged > 0 && (
+
+ {summary.unchanged} unchanged
+
+ )}
+
+
+
+ {/* Content */}
+
+ {totalChanges === 0 && !hasMetadataChanges ? (
+
+ No differences found. The flows are identical.
+
+ ) : (
+ <>
+ {hasMetadataChanges && (
+
+
+ Metadata
+
+
+ {metadata.modified.map((change, i) => (
+
+ ))}
+
+
+ )}
+
+ {Object.entries(categories).map(([key, data]) => (
+
+ ))}
+ >
+ )}
+
+
+ {/* Footer */}
+
+
+
+
+
+ );
+}
diff --git a/src/components/ModalsLayer.jsx b/src/components/ModalsLayer.jsx
index b24ef39..ea1b51a 100644
--- a/src/components/ModalsLayer.jsx
+++ b/src/components/ModalsLayer.jsx
@@ -14,6 +14,7 @@ import { FormSummaryPanel } from "./FormSummaryPanel";
import { TemplateBrowserModal } from "./TemplateBrowserModal";
import { CommentsPanel } from "./CommentsPanel";
import { CommentComposer } from "./CommentComposer";
+import { FlowDiffModal } from "./FlowDiffModal";
export function ModalsLayer({
// Hotspot modal
@@ -48,6 +49,8 @@ export function ModalsLayer({
formSummaryScreen, setFormSummaryScreen,
// Template browser
showTemplateBrowser, setShowTemplateBrowser, onInsertTemplate,
+ // Flow diff
+ flowDiffResult, setFlowDiffResult,
// Comments
showComments, setShowComments,
comments, connections,
@@ -265,6 +268,14 @@ export function ModalsLayer({
onCancel={() => setCommentComposer(null)}
/>
)}
+
+ {flowDiffResult && (
+ setFlowDiffResult(null)}
+ />
+ )}
>
);
}
diff --git a/src/components/TopBar.jsx b/src/components/TopBar.jsx
index 39313d0..389b8e2 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, 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, onCompareFlows, onToggleComments, showComments, unresolvedCommentCount = 0, canComment }) {
const [fileMenuOpen, setFileMenuOpen] = useState(false);
const fileMenuRef = useRef(null);
@@ -430,6 +430,19 @@ export function TopBar({ screenCount, connectionCount, onExport, onImport, onGen
>
Export
+
+ {isFileSystemSupported && (
+ <>
+
+
+ >
+ )}
)}
diff --git a/src/utils/diffFlows.js b/src/utils/diffFlows.js
new file mode 100644
index 0000000..66ad5e1
--- /dev/null
+++ b/src/utils/diffFlows.js
@@ -0,0 +1,310 @@
+/**
+ * Compares two normalized .drawd flow payloads and returns a structured diff
+ * describing added, removed, and modified entities across all categories.
+ *
+ * Both flows should be normalized through importFlow() before diffing to
+ * ensure consistent field defaults and avoid false positives.
+ */
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+/**
+ * Cheap fingerprint for large strings (imageData, svgContent, sourceHtml).
+ * Avoids multi-MB string comparisons by checking length + boundary chars.
+ */
+function fingerprint(str) {
+ if (!str) return "";
+ return `${str.length}:${str.slice(0, 32)}:${str.slice(-32)}`;
+}
+
+/**
+ * Formats a value for human-readable display in change details.
+ */
+function displayValue(val) {
+ if (val === null || val === undefined) return "(none)";
+ if (val === "") return "(empty)";
+ if (typeof val === "boolean") return val ? "true" : "false";
+ if (typeof val === "string" && val.length > 60) return val.slice(0, 57) + "...";
+ if (typeof val === "object") {
+ const json = JSON.stringify(val);
+ return json.length > 60 ? json.slice(0, 57) + "..." : json;
+ }
+ return String(val);
+}
+
+/**
+ * Compare two values. For arrays/objects, uses JSON serialization.
+ * For fingerprint fields, compares fingerprints instead of full content.
+ */
+function valuesEqual(a, b, useFp) {
+ if (useFp) return fingerprint(a) === fingerprint(b);
+ if (a === b) return true;
+ if (a == null && b == null) return true;
+ if (a == null || b == null) return false;
+ if (typeof a === "object" || typeof b === "object") {
+ return JSON.stringify(a) === JSON.stringify(b);
+ }
+ return false;
+}
+
+/**
+ * Diff two entities field-by-field.
+ * @param {Object} a - Entity from flow A
+ * @param {Object} b - Entity from flow B
+ * @param {string[]} fields - Fields to compare with strict/JSON equality
+ * @param {string[]} fpFields - Fields to compare with fingerprinting
+ * @param {string[]} existFields - Fields to compare by existence (truthy vs falsy)
+ * @returns {Array|null} Array of { field, from, to } changes, or null if identical
+ */
+function diffEntity(a, b, fields, fpFields = [], existFields = []) {
+ const changes = [];
+
+ for (const f of fields) {
+ if (!valuesEqual(a[f], b[f], false)) {
+ changes.push({ field: f, from: displayValue(a[f]), to: displayValue(b[f]) });
+ }
+ }
+
+ for (const f of fpFields) {
+ if (!valuesEqual(a[f], b[f], true)) {
+ const fromLabel = a[f] ? "present" : "(none)";
+ const toLabel = b[f] ? "changed" : "(none)";
+ changes.push({ field: f, from: fromLabel, to: toLabel });
+ }
+ }
+
+ for (const f of existFields) {
+ const aHas = !!a[f];
+ const bHas = !!b[f];
+ if (aHas !== bHas) {
+ changes.push({ field: f, from: aHas ? "present" : "(none)", to: bHas ? "present" : "(none)" });
+ }
+ }
+
+ return changes.length > 0 ? changes : null;
+}
+
+// ---------------------------------------------------------------------------
+// Hotspot sub-diffing
+// ---------------------------------------------------------------------------
+
+const HOTSPOT_FIELDS = [
+ "label", "x", "y", "w", "h",
+ "action", "elementType", "interactionType",
+ "targetScreenId", "transitionType", "transitionLabel",
+ "apiEndpoint", "apiMethod", "requestSchema", "responseSchema", "documentId",
+ "customDescription",
+ "onSuccessAction", "onSuccessTargetId", "onSuccessCustomDesc",
+ "onErrorAction", "onErrorTargetId", "onErrorCustomDesc",
+ "tbd", "tbdNote",
+ "conditions", "dataFlow", "onSuccessDataFlow", "onErrorDataFlow",
+ "accessibility", "validation",
+];
+
+function diffHotspots(hotspotsA, hotspotsB) {
+ const mapA = new Map((hotspotsA || []).map((h) => [h.id, h]));
+ const mapB = new Map((hotspotsB || []).map((h) => [h.id, h]));
+
+ let addedCount = 0;
+ let removedCount = 0;
+ let modifiedCount = 0;
+ const details = [];
+
+ for (const [id, hs] of mapB) {
+ if (!mapA.has(id)) {
+ addedCount++;
+ details.push({ type: "added", id, label: hs.label || id });
+ }
+ }
+
+ for (const [id, hs] of mapA) {
+ if (!mapB.has(id)) {
+ removedCount++;
+ details.push({ type: "removed", id, label: hs.label || id });
+ }
+ }
+
+ for (const [id, hsA] of mapA) {
+ const hsB = mapB.get(id);
+ if (!hsB) continue;
+ const changes = diffEntity(hsA, hsB, HOTSPOT_FIELDS);
+ if (changes) {
+ modifiedCount++;
+ details.push({ type: "modified", id, label: hsA.label || id, changes });
+ }
+ }
+
+ if (addedCount === 0 && removedCount === 0 && modifiedCount === 0) return null;
+ return { field: "hotspots", addedCount, removedCount, modifiedCount, details };
+}
+
+// ---------------------------------------------------------------------------
+// Collection diffing
+// ---------------------------------------------------------------------------
+
+const SCREEN_FIELDS = [
+ "name", "x", "y", "width",
+ "description", "notes", "status", "codeRef",
+ "tbd", "tbdNote",
+ "roles", "acceptanceCriteria",
+ "stateGroup", "stateName",
+];
+const SCREEN_FP_FIELDS = ["imageData", "svgContent", "sourceHtml"];
+const SCREEN_EXIST_FIELDS = ["wireframe", "figmaSource"];
+
+const CONNECTION_FIELDS = [
+ "fromScreenId", "toScreenId", "hotspotId",
+ "label", "condition", "connectionPath",
+ "conditionGroupId", "transitionType", "transitionLabel",
+ "dataFlow",
+];
+
+const DOCUMENT_FIELDS = ["name", "content"];
+const DATA_MODEL_FIELDS = ["name", "schema"];
+const STICKY_NOTE_FIELDS = ["x", "y", "content", "color"];
+const SCREEN_GROUP_FIELDS = ["name", "screenIds", "color"];
+
+/**
+ * Extracts a display name for an entity based on the category.
+ */
+function entityName(entity, category, flowScreens) {
+ if (category === "stickyNotes") {
+ const text = entity.content || "";
+ return text.length > 40 ? text.slice(0, 37) + "..." : text || "(empty note)";
+ }
+ if (category === "connections") {
+ const from = flowScreens?.get(entity.fromScreenId)?.name || entity.fromScreenId;
+ const to = flowScreens?.get(entity.toScreenId)?.name || entity.toScreenId;
+ return `${from} -> ${to}`;
+ }
+ return entity.name || entity.id;
+}
+
+function diffCollection(collA, collB, category, fields, opts = {}) {
+ const { fpFields = [], existFields = [], includeHotspots = false, screenMapA, screenMapB } = opts;
+
+ const mapA = new Map((collA || []).map((e) => [e.id, e]));
+ const mapB = new Map((collB || []).map((e) => [e.id, e]));
+
+ const added = [];
+ const removed = [];
+ const modified = [];
+ let unchanged = 0;
+
+ // Detect added (in B but not in A)
+ for (const [id, entity] of mapB) {
+ if (!mapA.has(id)) {
+ added.push({ id, name: entityName(entity, category, screenMapB) });
+ }
+ }
+
+ // Detect removed (in A but not in B)
+ for (const [id, entity] of mapA) {
+ if (!mapB.has(id)) {
+ removed.push({ id, name: entityName(entity, category, screenMapA) });
+ }
+ }
+
+ // Detect modified (in both, but different)
+ for (const [id, entityA] of mapA) {
+ const entityB = mapB.get(id);
+ if (!entityB) continue;
+
+ const changes = diffEntity(entityA, entityB, fields, fpFields, existFields);
+ const hotspotDiff = includeHotspots ? diffHotspots(entityA.hotspots, entityB.hotspots) : null;
+
+ if (changes || hotspotDiff) {
+ const allChanges = changes || [];
+ if (hotspotDiff) allChanges.push(hotspotDiff);
+ modified.push({
+ id,
+ name: entityName(entityA, category, screenMapA),
+ changes: allChanges,
+ });
+ } else {
+ unchanged++;
+ }
+ }
+
+ return { added, removed, modified, unchanged };
+}
+
+// ---------------------------------------------------------------------------
+// Metadata diffing
+// ---------------------------------------------------------------------------
+
+const METADATA_FIELDS = ["name", "featureBrief", "taskLink", "techStack"];
+
+function diffMetadata(metaA, metaB) {
+ const a = metaA || {};
+ const b = metaB || {};
+ const modified = [];
+
+ for (const f of METADATA_FIELDS) {
+ if (!valuesEqual(a[f], b[f], false)) {
+ modified.push({ field: f, from: displayValue(a[f]), to: displayValue(b[f]) });
+ }
+ }
+
+ return { modified };
+}
+
+// ---------------------------------------------------------------------------
+// Main export
+// ---------------------------------------------------------------------------
+
+export function diffFlows(flowA, flowB) {
+ const screenMapA = new Map((flowA.screens || []).map((s) => [s.id, s]));
+ const screenMapB = new Map((flowB.screens || []).map((s) => [s.id, s]));
+
+ const categories = {
+ screens: diffCollection(
+ flowA.screens, flowB.screens, "screens",
+ SCREEN_FIELDS,
+ { fpFields: SCREEN_FP_FIELDS, existFields: SCREEN_EXIST_FIELDS, includeHotspots: true, screenMapA, screenMapB },
+ ),
+ connections: diffCollection(
+ flowA.connections, flowB.connections, "connections",
+ CONNECTION_FIELDS,
+ { screenMapA, screenMapB },
+ ),
+ documents: diffCollection(
+ flowA.documents, flowB.documents, "documents",
+ DOCUMENT_FIELDS,
+ ),
+ dataModels: diffCollection(
+ flowA.dataModels, flowB.dataModels, "dataModels",
+ DATA_MODEL_FIELDS,
+ ),
+ stickyNotes: diffCollection(
+ flowA.stickyNotes, flowB.stickyNotes, "stickyNotes",
+ STICKY_NOTE_FIELDS,
+ ),
+ screenGroups: diffCollection(
+ flowA.screenGroups, flowB.screenGroups, "screenGroups",
+ SCREEN_GROUP_FIELDS,
+ ),
+ };
+
+ const metadata = diffMetadata(flowA.metadata, flowB.metadata);
+
+ let added = 0;
+ let removed = 0;
+ let modified = 0;
+ let unchanged = 0;
+
+ for (const cat of Object.values(categories)) {
+ added += cat.added.length;
+ removed += cat.removed.length;
+ modified += cat.modified.length;
+ unchanged += cat.unchanged;
+ }
+
+ return {
+ summary: { added, removed, modified, unchanged },
+ categories,
+ metadata,
+ };
+}
diff --git a/src/utils/diffFlows.test.js b/src/utils/diffFlows.test.js
new file mode 100644
index 0000000..b887aad
--- /dev/null
+++ b/src/utils/diffFlows.test.js
@@ -0,0 +1,374 @@
+import { describe, it, expect } from "vitest";
+import { diffFlows } from "./diffFlows";
+
+function makeFlow(overrides = {}) {
+ return {
+ version: 14,
+ metadata: {
+ name: "Test Flow",
+ exportedAt: new Date().toISOString(),
+ screenCount: 0,
+ connectionCount: 0,
+ documentCount: 0,
+ featureBrief: "",
+ taskLink: "",
+ techStack: {},
+ },
+ viewport: { pan: { x: 0, y: 0 }, zoom: 1 },
+ screens: [],
+ connections: [],
+ documents: [],
+ dataModels: [],
+ stickyNotes: [],
+ screenGroups: [],
+ comments: [],
+ ...overrides,
+ };
+}
+
+function makeScreen(id, overrides = {}) {
+ return {
+ id,
+ name: `Screen ${id}`,
+ x: 0,
+ y: 0,
+ width: 220,
+ imageData: null,
+ description: "",
+ notes: "",
+ codeRef: "",
+ status: "new",
+ acceptanceCriteria: [],
+ roles: [],
+ tbd: false,
+ tbdNote: "",
+ stateGroup: null,
+ stateName: "",
+ figmaSource: null,
+ svgContent: null,
+ sourceHtml: null,
+ wireframe: null,
+ hotspots: [],
+ ...overrides,
+ };
+}
+
+function makeConnection(id, from, to, overrides = {}) {
+ return {
+ id,
+ fromScreenId: from,
+ toScreenId: to,
+ hotspotId: null,
+ label: "",
+ condition: "",
+ connectionPath: "default",
+ conditionGroupId: null,
+ transitionType: null,
+ transitionLabel: "",
+ dataFlow: [],
+ ...overrides,
+ };
+}
+
+function makeHotspot(id, overrides = {}) {
+ return {
+ id,
+ label: `Hotspot ${id}`,
+ x: 10,
+ y: 10,
+ w: 20,
+ h: 20,
+ action: "navigate",
+ elementType: "button",
+ interactionType: "tap",
+ targetScreenId: null,
+ transitionType: null,
+ transitionLabel: "",
+ apiEndpoint: "",
+ apiMethod: "",
+ requestSchema: "",
+ responseSchema: "",
+ documentId: null,
+ customDescription: "",
+ onSuccessAction: "",
+ onSuccessTargetId: "",
+ onSuccessCustomDesc: "",
+ onErrorAction: "",
+ onErrorTargetId: "",
+ onErrorCustomDesc: "",
+ tbd: false,
+ tbdNote: "",
+ conditions: [],
+ dataFlow: [],
+ onSuccessDataFlow: [],
+ onErrorDataFlow: [],
+ accessibility: null,
+ validation: null,
+ ...overrides,
+ };
+}
+
+// ---------------------------------------------------------------------------
+
+describe("diffFlows", () => {
+ it("reports zero changes for identical flows", () => {
+ const flow = makeFlow({
+ screens: [makeScreen("s1")],
+ connections: [makeConnection("c1", "s1", "s1")],
+ });
+
+ const result = diffFlows(flow, JSON.parse(JSON.stringify(flow)));
+
+ expect(result.summary.added).toBe(0);
+ expect(result.summary.removed).toBe(0);
+ expect(result.summary.modified).toBe(0);
+ expect(result.summary.unchanged).toBe(2); // 1 screen + 1 connection
+ });
+
+ it("detects added screens", () => {
+ const flowA = makeFlow({ screens: [makeScreen("s1")] });
+ const flowB = makeFlow({ screens: [makeScreen("s1"), makeScreen("s2")] });
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.categories.screens.added).toHaveLength(1);
+ expect(result.categories.screens.added[0].id).toBe("s2");
+ expect(result.summary.added).toBe(1);
+ });
+
+ it("detects removed screens", () => {
+ const flowA = makeFlow({ screens: [makeScreen("s1"), makeScreen("s2")] });
+ const flowB = makeFlow({ screens: [makeScreen("s1")] });
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.categories.screens.removed).toHaveLength(1);
+ expect(result.categories.screens.removed[0].id).toBe("s2");
+ expect(result.summary.removed).toBe(1);
+ });
+
+ it("detects modified screen fields", () => {
+ const flowA = makeFlow({ screens: [makeScreen("s1", { name: "Login" })] });
+ const flowB = makeFlow({ screens: [makeScreen("s1", { name: "Sign In" })] });
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.categories.screens.modified).toHaveLength(1);
+ const mod = result.categories.screens.modified[0];
+ expect(mod.id).toBe("s1");
+ expect(mod.changes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ field: "name", from: "Login", to: "Sign In" }),
+ ]),
+ );
+ });
+
+ it("detects image changes via fingerprint", () => {
+ const imgA = "data:image/png;base64," + "A".repeat(1000);
+ const imgB = "data:image/png;base64," + "B".repeat(1000);
+
+ const flowA = makeFlow({ screens: [makeScreen("s1", { imageData: imgA })] });
+ const flowB = makeFlow({ screens: [makeScreen("s1", { imageData: imgB })] });
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.categories.screens.modified).toHaveLength(1);
+ const changes = result.categories.screens.modified[0].changes;
+ expect(changes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ field: "imageData" }),
+ ]),
+ );
+ });
+
+ it("treats identical images as unchanged", () => {
+ const img = "data:image/png;base64," + "X".repeat(500);
+
+ const flowA = makeFlow({ screens: [makeScreen("s1", { imageData: img })] });
+ const flowB = makeFlow({ screens: [makeScreen("s1", { imageData: img })] });
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.categories.screens.modified).toHaveLength(0);
+ expect(result.categories.screens.unchanged).toBe(1);
+ });
+
+ it("detects hotspot additions within a screen", () => {
+ const flowA = makeFlow({ screens: [makeScreen("s1", { hotspots: [] })] });
+ const flowB = makeFlow({ screens: [makeScreen("s1", { hotspots: [makeHotspot("h1")] })] });
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.categories.screens.modified).toHaveLength(1);
+ const hotspotChange = result.categories.screens.modified[0].changes.find(
+ (c) => c.field === "hotspots",
+ );
+ expect(hotspotChange).toBeDefined();
+ expect(hotspotChange.addedCount).toBe(1);
+ });
+
+ it("detects hotspot removals within a screen", () => {
+ const flowA = makeFlow({ screens: [makeScreen("s1", { hotspots: [makeHotspot("h1")] })] });
+ const flowB = makeFlow({ screens: [makeScreen("s1", { hotspots: [] })] });
+
+ const result = diffFlows(flowA, flowB);
+
+ const hotspotChange = result.categories.screens.modified[0].changes.find(
+ (c) => c.field === "hotspots",
+ );
+ expect(hotspotChange.removedCount).toBe(1);
+ });
+
+ it("detects hotspot modifications within a screen", () => {
+ const flowA = makeFlow({
+ screens: [makeScreen("s1", { hotspots: [makeHotspot("h1", { label: "Login" })] })],
+ });
+ const flowB = makeFlow({
+ screens: [makeScreen("s1", { hotspots: [makeHotspot("h1", { label: "Sign In" })] })],
+ });
+
+ const result = diffFlows(flowA, flowB);
+
+ const hotspotChange = result.categories.screens.modified[0].changes.find(
+ (c) => c.field === "hotspots",
+ );
+ expect(hotspotChange.modifiedCount).toBe(1);
+ expect(hotspotChange.details[0].changes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ field: "label" }),
+ ]),
+ );
+ });
+
+ it("detects connection changes", () => {
+ const flowA = makeFlow({
+ screens: [makeScreen("s1"), makeScreen("s2")],
+ connections: [makeConnection("c1", "s1", "s2", { label: "Next" })],
+ });
+ const flowB = makeFlow({
+ screens: [makeScreen("s1"), makeScreen("s2")],
+ connections: [makeConnection("c1", "s1", "s2", { label: "Continue" })],
+ });
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.categories.connections.modified).toHaveLength(1);
+ expect(result.categories.connections.modified[0].changes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ field: "label", from: "Next", to: "Continue" }),
+ ]),
+ );
+ });
+
+ it("detects document, dataModel, stickyNote, screenGroup changes", () => {
+ const flowA = makeFlow({
+ documents: [{ id: "d1", name: "API Docs", content: "old", createdAt: "" }],
+ dataModels: [{ id: "dm1", name: "User", schema: { name: { type: "string" } }, createdAt: "" }],
+ stickyNotes: [{ id: "sn1", x: 0, y: 0, width: 220, content: "Todo", color: "yellow", author: "" }],
+ screenGroups: [{ id: "sg1", name: "Auth", screenIds: ["s1"], color: "#61afef", folderHint: "" }],
+ });
+ const flowB = makeFlow({
+ documents: [{ id: "d1", name: "API Reference", content: "new", createdAt: "" }],
+ dataModels: [{ id: "dm1", name: "Account", schema: { name: { type: "string" } }, createdAt: "" }],
+ stickyNotes: [{ id: "sn1", x: 0, y: 0, width: 220, content: "Done", color: "green", author: "" }],
+ screenGroups: [{ id: "sg1", name: "Authentication", screenIds: ["s1", "s2"], color: "#61afef", folderHint: "" }],
+ });
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.categories.documents.modified).toHaveLength(1);
+ expect(result.categories.dataModels.modified).toHaveLength(1);
+ expect(result.categories.stickyNotes.modified).toHaveLength(1);
+ expect(result.categories.screenGroups.modified).toHaveLength(1);
+ });
+
+ it("excludes viewport differences", () => {
+ const flowA = makeFlow({ viewport: { pan: { x: 0, y: 0 }, zoom: 1 } });
+ const flowB = makeFlow({ viewport: { pan: { x: 999, y: -500 }, zoom: 3.5 } });
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.summary.added).toBe(0);
+ expect(result.summary.removed).toBe(0);
+ expect(result.summary.modified).toBe(0);
+ });
+
+ it("excludes metadata.exportedAt from diff", () => {
+ const flowA = makeFlow();
+ const flowB = makeFlow();
+ flowA.metadata.exportedAt = "2025-01-01T00:00:00Z";
+ flowB.metadata.exportedAt = "2026-04-10T12:00:00Z";
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.metadata.modified).toHaveLength(0);
+ });
+
+ it("detects metadata field changes", () => {
+ const flowA = makeFlow();
+ const flowB = makeFlow();
+ flowA.metadata.featureBrief = "old brief";
+ flowB.metadata.featureBrief = "new brief";
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.metadata.modified).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ field: "featureBrief" }),
+ ]),
+ );
+ });
+
+ it("summary counts match category totals", () => {
+ const flowA = makeFlow({
+ screens: [makeScreen("s1"), makeScreen("s2"), makeScreen("s3")],
+ connections: [makeConnection("c1", "s1", "s2")],
+ documents: [{ id: "d1", name: "Doc", content: "", createdAt: "" }],
+ });
+ const flowB = makeFlow({
+ screens: [makeScreen("s1", { name: "Renamed" }), makeScreen("s3"), makeScreen("s4")],
+ connections: [],
+ documents: [{ id: "d1", name: "Doc", content: "", createdAt: "" }, { id: "d2", name: "New", content: "", createdAt: "" }],
+ });
+
+ const result = diffFlows(flowA, flowB);
+
+ let totalAdded = 0;
+ let totalRemoved = 0;
+ let totalModified = 0;
+ let totalUnchanged = 0;
+
+ for (const cat of Object.values(result.categories)) {
+ totalAdded += cat.added.length;
+ totalRemoved += cat.removed.length;
+ totalModified += cat.modified.length;
+ totalUnchanged += cat.unchanged;
+ }
+
+ expect(result.summary.added).toBe(totalAdded);
+ expect(result.summary.removed).toBe(totalRemoved);
+ expect(result.summary.modified).toBe(totalModified);
+ expect(result.summary.unchanged).toBe(totalUnchanged);
+ });
+
+ it("detects wireframe/figmaSource existence changes", () => {
+ const flowA = makeFlow({ screens: [makeScreen("s1", { wireframe: null, figmaSource: null })] });
+ const flowB = makeFlow({
+ screens: [makeScreen("s1", {
+ wireframe: { type: "basic" },
+ figmaSource: { fileKey: "abc", nodeId: "1:2" },
+ })],
+ });
+
+ const result = diffFlows(flowA, flowB);
+
+ expect(result.categories.screens.modified).toHaveLength(1);
+ const changes = result.categories.screens.modified[0].changes;
+ expect(changes).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ field: "wireframe" }),
+ expect.objectContaining({ field: "figmaSource" }),
+ ]),
+ );
+ });
+});