@syntax-syllogism/flow-delta 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +306 -29
  2. package/package.json +4 -4
package/dist/cli.js CHANGED
@@ -766,7 +766,38 @@ function toEdgeDiff(edge, status) {
766
766
  // src/render/layout.ts
767
767
  import ELK from "elkjs/lib/elk.bundled.js";
768
768
  async function layoutDiff(diff) {
769
- const elk = new ELK();
769
+ const union = await layoutView(diff, () => true, () => true);
770
+ const after = await layoutView(
771
+ diff,
772
+ (node) => node.status !== "deleted",
773
+ (edge) => edge.status !== "deleted"
774
+ );
775
+ const before = await layoutView(
776
+ diff,
777
+ (node) => node.status !== "added",
778
+ (edge) => edge.status !== "added"
779
+ );
780
+ return {
781
+ diff,
782
+ nodes: union.nodes,
783
+ edges: union.edges,
784
+ width: union.width,
785
+ height: union.height,
786
+ views: {
787
+ union,
788
+ after,
789
+ before
790
+ }
791
+ };
792
+ }
793
+ var elk = new ELK();
794
+ async function layoutView(diff, nodeVisible, edgeVisible) {
795
+ const nodes = diff.nodes.filter(nodeVisible);
796
+ const nodeIds = new Set(nodes.map((node) => node.id));
797
+ const edges = diff.edges.filter((edge) => edgeVisible(edge) && nodeIds.has(edge.source) && nodeIds.has(edge.target));
798
+ if (nodes.length === 0) {
799
+ return { nodes: [], edges: [], width: 40, height: 40 };
800
+ }
770
801
  const graph = {
771
802
  id: "root",
772
803
  layoutOptions: {
@@ -775,12 +806,12 @@ async function layoutDiff(diff) {
775
806
  "elk.layered.spacing.nodeNodeBetweenLayers": 60,
776
807
  "elk.spacing.nodeNode": 40
777
808
  },
778
- children: diff.nodes.map((node) => ({
809
+ children: nodes.map((node) => ({
779
810
  id: node.id,
780
811
  width: estimateWidth(node.label),
781
812
  height: 48
782
813
  })),
783
- edges: diff.edges.map((edge) => ({
814
+ edges: edges.map((edge) => ({
784
815
  id: edge.id,
785
816
  sources: [edge.source],
786
817
  targets: [edge.target]
@@ -788,10 +819,10 @@ async function layoutDiff(diff) {
788
819
  };
789
820
  const laidOut = await elk.layout(graph);
790
821
  const children = laidOut.children ?? [];
791
- const edges = laidOut.edges ?? [];
822
+ const edgeLayouts = laidOut.edges ?? [];
792
823
  const nodeMap = new Map(children.map((node) => [node.id, node]));
793
- const edgeMap = new Map(edges.map((edge) => [edge.id, edge]));
794
- const nodes = diff.nodes.map((node) => {
824
+ const edgeMap = new Map(edgeLayouts.map((edge) => [edge.id, edge]));
825
+ const positionedNodes = nodes.map((node) => {
795
826
  const positioned = nodeMap.get(node.id);
796
827
  return {
797
828
  ...node,
@@ -801,14 +832,13 @@ async function layoutDiff(diff) {
801
832
  height: positioned?.height ?? 48
802
833
  };
803
834
  });
804
- const positionedEdges = diff.edges.map((edge) => ({
835
+ const positionedEdges = edges.map((edge) => ({
805
836
  ...edge,
806
837
  sections: normalizeSections(edgeMap.get(edge.id)?.sections)
807
838
  }));
808
- const bounds = measureBounds(nodes);
839
+ const bounds = measureBounds(positionedNodes);
809
840
  return {
810
- diff,
811
- nodes,
841
+ nodes: positionedNodes,
812
842
  edges: positionedEdges,
813
843
  width: bounds.width,
814
844
  height: bounds.height
@@ -840,7 +870,8 @@ function renderHtml(layout) {
840
870
  nodes: layout.nodes,
841
871
  edges: layout.edges,
842
872
  width: layout.width,
843
- height: layout.height
873
+ height: layout.height,
874
+ layouts: layout.views
844
875
  };
845
876
  const json = JSON.stringify(data).replace(/</g, "\\u003c");
846
877
  const s = layout.diff.summary;
@@ -877,6 +908,30 @@ function renderHtml(layout) {
877
908
  header .meta .deleted:not(.zero) { color: var(--deleted); }
878
909
  header .meta .modified:not(.zero) { color: var(--modified); }
879
910
  header .meta .sep { color: var(--border); }
911
+ .header-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
912
+ .filters { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
913
+ .filters button {
914
+ border: 1px solid var(--border);
915
+ background: #f8fafc;
916
+ color: var(--muted);
917
+ padding: 6px 11px;
918
+ border-radius: 999px;
919
+ font: inherit;
920
+ font-size: 12px;
921
+ font-weight: 600;
922
+ cursor: pointer;
923
+ transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
924
+ }
925
+ .filters button.active {
926
+ background: #eff6ff;
927
+ border-color: #93c5fd;
928
+ color: var(--text);
929
+ box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12);
930
+ }
931
+ .filters button:focus-visible {
932
+ outline: 2px solid #93c5fd;
933
+ outline-offset: 2px;
934
+ }
880
935
  .legend { display: flex; gap: 14px; font-size: 12px; flex-wrap: wrap; color: var(--muted); }
881
936
  .legend span::before { content: ""; display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
882
937
  .legend .added::before { background: var(--added); }
@@ -907,11 +962,16 @@ function renderHtml(layout) {
907
962
  .change-value-label { margin-bottom: 4px; color: #475569; font-size: 10.5px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
908
963
  .changes code { font-size: 12px; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; background: #eef1f6; padding: 2px 5px; border-radius: 4px; overflow-wrap: anywhere; }
909
964
  .changes pre { max-width: 100%; margin: 0; padding: 11px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; line-height: 1.5; }
910
- .edge { fill: none; stroke-width: 1.75; }
911
- .edge.normal { stroke: var(--edge); marker-end: url(#arrow-normal); }
912
- .edge.fault { stroke: var(--fault); stroke-dasharray: 6 4; marker-end: url(#arrow-fault); }
965
+ .edge { fill: none; }
966
+ .edge.unchanged { stroke-width: 1.4; opacity: 0.55; }
967
+ .edge.normal.unchanged { stroke: var(--edge); marker-end: url(#arrow-normal); }
968
+ .edge.fault.unchanged { stroke: var(--fault); stroke-dasharray: 6 4; marker-end: url(#arrow-fault); }
969
+ .edge.added { stroke: var(--added); stroke-width: 3; marker-end: url(#arrow-added); }
970
+ .edge.deleted { stroke: var(--deleted); stroke-width: 2; stroke-dasharray: 7 5; marker-end: url(#arrow-deleted); }
913
971
  #arrow-normal path { fill: var(--edge); }
914
972
  #arrow-fault path { fill: var(--fault); }
973
+ #arrow-added path { fill: var(--added); }
974
+ #arrow-deleted path { fill: var(--deleted); }
915
975
  .node rect { rx: 11; ry: 11; stroke-width: 1.75; filter: drop-shadow(0 1px 2px rgba(15, 23, 42, 0.08)); }
916
976
  .node { cursor: pointer; }
917
977
  .node text { font-size: 12px; fill: #111827; pointer-events: none; }
@@ -933,11 +993,19 @@ function renderHtml(layout) {
933
993
  <h1>${escapeHtml(layout.diff.flowName)}</h1>
934
994
  <div class="meta">${metaHtml}</div>
935
995
  </div>
936
- <div class="legend">
937
- <span class="added">Added</span>
938
- <span class="deleted">Deleted</span>
939
- <span class="modified">Modified</span>
940
- <span class="unchanged">Unchanged</span>
996
+ <div class="header-actions">
997
+ <div class="filters" role="group" aria-label="Diff view filters">
998
+ <button type="button" class="active" data-view-mode="all">All</button>
999
+ <button type="button" data-view-mode="after">After</button>
1000
+ <button type="button" data-view-mode="before">Before</button>
1001
+ <button type="button" data-view-mode="changes">Changes only</button>
1002
+ </div>
1003
+ <div class="legend">
1004
+ <span class="added">Added</span>
1005
+ <span class="deleted">Deleted</span>
1006
+ <span class="modified">Modified</span>
1007
+ <span class="unchanged">Unchanged</span>
1008
+ </div>
941
1009
  </div>
942
1010
  </header>
943
1011
  <main>
@@ -950,6 +1018,12 @@ function renderHtml(layout) {
950
1018
  <marker id="arrow-fault" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
951
1019
  <path d="M0,0 L8,4 L0,8 Z"></path>
952
1020
  </marker>
1021
+ <marker id="arrow-added" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
1022
+ <path d="M0,0 L8,4 L0,8 Z"></path>
1023
+ </marker>
1024
+ <marker id="arrow-deleted" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
1025
+ <path d="M0,0 L8,4 L0,8 Z"></path>
1026
+ </marker>
953
1027
  </defs>
954
1028
  <g id="viewport">
955
1029
  ${layout.edges.map(renderEdge).join("")}
@@ -971,9 +1045,13 @@ function renderHtml(layout) {
971
1045
  const panelTitle = document.getElementById("panel-title");
972
1046
  const panelBadge = document.getElementById("panel-badge");
973
1047
  const panelBody = document.getElementById("panel-body");
1048
+ const filterButtons = [...document.querySelectorAll(".filters button")];
974
1049
  const nodesById = new Map(DATA.nodes.map((node) => [node.id, node]));
1050
+ const nodeElements = new Map([...document.querySelectorAll(".node")].map((node) => [node.dataset.nodeId, node]));
1051
+ const edgeElements = new Map([...document.querySelectorAll(".edge")].map((edge) => [edge.dataset.edgeId, edge]));
975
1052
  let viewBox = svg.viewBox.baseVal;
976
1053
  let dragStart = null;
1054
+ let selectedNodeId = null;
977
1055
 
978
1056
  function escapeHtml(value) {
979
1057
  return String(value)
@@ -983,12 +1061,128 @@ function renderHtml(layout) {
983
1061
  .replace(/"/g, "&quot;");
984
1062
  }
985
1063
 
986
- function selectNode(id) {
1064
+ function isNodeVisible(node, mode) {
1065
+ if (mode === "after") return node.status !== "deleted";
1066
+ if (mode === "before") return node.status !== "added";
1067
+ if (mode === "changes") return node.status !== "unchanged";
1068
+ return true;
1069
+ }
1070
+
1071
+ function isEdgeVisible(edge, mode, visibleNodeIds) {
1072
+ if (!visibleNodeIds.has(edge.source) || !visibleNodeIds.has(edge.target)) {
1073
+ return false;
1074
+ }
1075
+ if (mode === "after") return edge.status !== "deleted";
1076
+ if (mode === "before") return edge.status !== "added";
1077
+ if (mode === "changes") {
1078
+ const source = nodesById.get(edge.source);
1079
+ const target = nodesById.get(edge.target);
1080
+ const sourceChanged = source?.status !== "unchanged";
1081
+ const targetChanged = target?.status !== "unchanged";
1082
+ return edge.status !== "unchanged" || (sourceChanged && targetChanged);
1083
+ }
1084
+ return true;
1085
+ }
1086
+
1087
+ function edgePath(sections) {
1088
+ const points = sections.flatMap((section, index) => {
1089
+ const start = index === 0 ? [section.startPoint] : [];
1090
+ const bends = section.bendPoints ?? [];
1091
+ const end = [section.endPoint];
1092
+ return [...start, ...bends, ...end];
1093
+ });
1094
+ if (points.length === 0) {
1095
+ return "";
1096
+ }
1097
+ return points.map((point, index) => \`\${index === 0 ? "M" : "L"} \${point.x} \${point.y}\`).join(" ");
1098
+ }
1099
+
1100
+ function updateToolbar(mode) {
1101
+ filterButtons.forEach((button) => {
1102
+ const active = button.dataset.viewMode === mode;
1103
+ button.classList.toggle("active", active);
1104
+ button.setAttribute("aria-pressed", active ? "true" : "false");
1105
+ });
1106
+ }
1107
+
1108
+ function round(value) {
1109
+ return Math.round(value * 100) / 100;
1110
+ }
1111
+
1112
+ function collectEdgePoints(sections) {
1113
+ return sections.flatMap((section, index) => {
1114
+ const start = index === 0 ? [section.startPoint] : [];
1115
+ const bends = section.bendPoints ?? [];
1116
+ const end = [section.endPoint];
1117
+ return [...start, ...bends, ...end];
1118
+ });
1119
+ }
1120
+
1121
+ function measureVisibleBounds(nodes, edges) {
1122
+ const points = [
1123
+ ...nodes.flatMap((node) => ([
1124
+ { x: node.x, y: node.y },
1125
+ { x: node.x + node.width, y: node.y + node.height },
1126
+ ])),
1127
+ ...edges.flatMap((edge) => collectEdgePoints(edge.sections)),
1128
+ ];
1129
+
1130
+ if (points.length === 0) {
1131
+ return null;
1132
+ }
1133
+
1134
+ let minX = points[0].x;
1135
+ let minY = points[0].y;
1136
+ let maxX = points[0].x;
1137
+ let maxY = points[0].y;
1138
+ for (const point of points.slice(1)) {
1139
+ if (point.x < minX) minX = point.x;
1140
+ if (point.y < minY) minY = point.y;
1141
+ if (point.x > maxX) maxX = point.x;
1142
+ if (point.y > maxY) maxY = point.y;
1143
+ }
1144
+
1145
+ return {
1146
+ x: minX - 20,
1147
+ y: minY - 20,
1148
+ width: (maxX - minX) + 40,
1149
+ height: (maxY - minY) + 40,
1150
+ };
1151
+ }
1152
+
1153
+ function fitViewBoxRect(bounds) {
1154
+ const minWidth = 720;
1155
+ const minHeight = 540;
1156
+ const width = Math.max(bounds.width, minWidth);
1157
+ const height = Math.max(bounds.height, minHeight);
1158
+ const x = bounds.x + (bounds.width - width) / 2;
1159
+ const y = bounds.y + (bounds.height - height) / 2;
1160
+ return {
1161
+ x: round(x),
1162
+ y: round(y),
1163
+ width: round(width),
1164
+ height: round(height),
1165
+ };
1166
+ }
1167
+
1168
+ function updateViewBox(bounds) {
1169
+ const box = fitViewBoxRect(bounds);
1170
+ viewBox.x = box.x;
1171
+ viewBox.y = box.y;
1172
+ viewBox.width = box.width;
1173
+ viewBox.height = box.height;
1174
+ }
1175
+
1176
+ function clearSelection(message) {
1177
+ selectedNodeId = null;
987
1178
  document.querySelectorAll(".node").forEach((el) => el.classList.remove("selected"));
988
- const selected = document.querySelector(\`.node[data-node-id="\${CSS.escape(id)}"]\`);
989
- if (selected) selected.classList.add("selected");
990
- const node = nodesById.get(id);
991
- if (!node) return;
1179
+ panel.className = "panel";
1180
+ panelTitle.textContent = "No visible nodes";
1181
+ panelBadge.textContent = "Hidden";
1182
+ panelBody.innerHTML = "<div class='empty'>" + escapeHtml(message) + "</div>";
1183
+ }
1184
+
1185
+ function updatePanel(node) {
992
1186
  panel.className = "panel " + node.status;
993
1187
  panelTitle.textContent = node.label;
994
1188
  panelBadge.textContent = node.status.toUpperCase();
@@ -1002,6 +1196,15 @@ function renderHtml(layout) {
1002
1196
  : "<div class='empty'>No property changes.</div>";
1003
1197
  }
1004
1198
 
1199
+ function selectNode(id) {
1200
+ selectedNodeId = id;
1201
+ document.querySelectorAll(".node").forEach((el) => el.classList.remove("selected"));
1202
+ const selected = document.querySelector(\`.node[data-node-id="\${CSS.escape(id)}"]\`);
1203
+ if (selected) selected.classList.add("selected");
1204
+ const node = nodesById.get(id);
1205
+ if (node) updatePanel(node);
1206
+ }
1207
+
1005
1208
  function formatChange(change) {
1006
1209
  const beforeMissing = change.before === undefined;
1007
1210
  const afterMissing = change.after === undefined;
@@ -1041,6 +1244,77 @@ function renderHtml(layout) {
1041
1244
  return "<code>" + escapeHtml(String(value)) + "</code>";
1042
1245
  }
1043
1246
 
1247
+ function pickInitialNode(visibleNodeIds) {
1248
+ return DATA.nodes.find((node) => visibleNodeIds.has(node.id) && node.status === "modified")
1249
+ || DATA.nodes.find((node) => visibleNodeIds.has(node.id) && node.status !== "unchanged")
1250
+ || DATA.nodes.find((node) => visibleNodeIds.has(node.id))
1251
+ || null;
1252
+ }
1253
+
1254
+ function applyView(mode) {
1255
+ const layout = mode === "all" || mode === "changes"
1256
+ ? DATA.layouts.union
1257
+ : DATA.layouts[mode];
1258
+ const nodeLayout = new Map(layout.nodes.map((node) => [node.id, node]));
1259
+ const edgeLayout = new Map(layout.edges.map((edge) => [edge.id, edge]));
1260
+ const visibleNodeIds = new Set();
1261
+ const visibleNodes = [];
1262
+ const visibleEdges = [];
1263
+
1264
+ DATA.nodes.forEach((node) => {
1265
+ const element = nodeElements.get(node.id);
1266
+ if (!element) return;
1267
+ if (!isNodeVisible(node, mode)) {
1268
+ element.style.display = "none";
1269
+ return;
1270
+ }
1271
+ const positioned = nodeLayout.get(node.id);
1272
+ if (!positioned) {
1273
+ element.style.display = "none";
1274
+ return;
1275
+ }
1276
+ element.style.display = "";
1277
+ element.setAttribute("transform", "translate(" + positioned.x + "," + positioned.y + ")");
1278
+ visibleNodeIds.add(node.id);
1279
+ visibleNodes.push(positioned);
1280
+ });
1281
+
1282
+ DATA.edges.forEach((edge) => {
1283
+ const element = edgeElements.get(edge.id);
1284
+ if (!element) return;
1285
+ if (!isEdgeVisible(edge, mode, visibleNodeIds)) {
1286
+ element.style.display = "none";
1287
+ return;
1288
+ }
1289
+ const positioned = edgeLayout.get(edge.id);
1290
+ if (!positioned) {
1291
+ element.style.display = "none";
1292
+ return;
1293
+ }
1294
+ element.style.display = "";
1295
+ element.setAttribute("d", edgePath(positioned.sections));
1296
+ visibleEdges.push(positioned);
1297
+ });
1298
+
1299
+ updateToolbar(mode);
1300
+ updateViewBox(measureVisibleBounds(visibleNodes, visibleEdges) || { x: 0, y: 0, width: layout.width, height: layout.height });
1301
+
1302
+ if (selectedNodeId && visibleNodeIds.has(selectedNodeId)) {
1303
+ const node = nodesById.get(selectedNodeId);
1304
+ if (node) {
1305
+ updatePanel(node);
1306
+ }
1307
+ return;
1308
+ }
1309
+
1310
+ const initialNode = pickInitialNode(visibleNodeIds);
1311
+ if (initialNode) {
1312
+ selectNode(initialNode.id);
1313
+ } else {
1314
+ clearSelection("This view has no visible nodes.");
1315
+ }
1316
+ }
1317
+
1044
1318
  document.querySelectorAll(".node").forEach((node) => {
1045
1319
  node.addEventListener("click", (event) => {
1046
1320
  event.stopPropagation();
@@ -1076,10 +1350,13 @@ function renderHtml(layout) {
1076
1350
  svg.addEventListener("pointerup", () => { dragStart = null; });
1077
1351
  svg.addEventListener("pointercancel", () => { dragStart = null; });
1078
1352
 
1079
- const initialNode = DATA.nodes.find((node) => node.status === "modified")
1080
- || DATA.nodes.find((node) => node.status !== "unchanged")
1081
- || DATA.nodes[0];
1082
- selectNode(initialNode?.id);
1353
+ filterButtons.forEach((button) => {
1354
+ button.addEventListener("click", () => {
1355
+ applyView(button.dataset.viewMode);
1356
+ });
1357
+ });
1358
+
1359
+ applyView("all");
1083
1360
  </script>
1084
1361
  </body>
1085
1362
  </html>`;
@@ -1107,7 +1384,7 @@ function renderEdge(edge) {
1107
1384
  return "";
1108
1385
  }
1109
1386
  const d = points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
1110
- return `<path class="edge ${edge.kind}" d="${d}" data-edge-id="${escapeHtml(edge.id)}"></path>`;
1387
+ return `<path class="edge ${edge.kind} ${edge.status}" d="${d}" data-edge-id="${escapeHtml(edge.id)}"></path>`;
1111
1388
  }
1112
1389
  function renderNode(node) {
1113
1390
  const lines = wrapLabel(node.label, 22);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/flow-delta",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Semantic visual diff for Salesforce Flows",
6
6
  "license": "MIT",
@@ -27,19 +27,19 @@
27
27
  "scripts": {
28
28
  "build": "node scripts/build.mjs",
29
29
  "smoke:gitlab": "node --import tsx scripts/smoke-gitlab.ts",
30
- "test": "node --import tsx --test test/parser.test.ts test/semantic-diff.test.ts test/gitlab-report.test.ts",
30
+ "test": "node --import tsx --test test/parser.test.ts test/semantic-diff.test.ts test/gitlab-report.test.ts test/smoke-gitlab.test.ts",
31
31
  "test:parser": "node --import tsx --test test/parser.test.ts",
32
32
  "prepublishOnly": "npm run build && npm test",
33
33
  "render:fixtures": "bash bin/render-fixtures.sh"
34
34
  },
35
35
  "devDependencies": {
36
36
  "esbuild": "^0.28.0",
37
- "@types/node": "^22.0.0",
37
+ "@types/node": "^25.9.2",
38
38
  "@types/xml2js": "^0.4.14",
39
39
  "tsx": "^4.19.0"
40
40
  },
41
41
  "dependencies": {
42
- "elkjs": "^0.10.0",
42
+ "elkjs": "^0.11.1",
43
43
  "xml2js": "^0.6.2"
44
44
  },
45
45
  "bin": {