@syntax-syllogism/flow-delta 0.1.2 → 0.3.0

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 (3) hide show
  1. package/README.md +5 -0
  2. package/dist/cli.js +790 -78
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -5,6 +5,11 @@ A semantic, visual diff for Salesforce Flows: parse two versions of a
5
5
  interactive HTML artifact (plus `diff.json`) that shows added / deleted /
6
6
  modified / unchanged nodes and edges with per-property deltas.
7
7
 
8
+ Inspired by Google's [Flow Lens](https://github.com/google/flow-lens), with
9
+ a focus on making it work with GitLab's pipelines. We also opted for our own HTML output over plantuml, graphviz, or mermaid.
10
+
11
+ [Sample Gitlab Project with artifacts](https://gitlab.com/j.p.richter/flow-delta-example/-/merge_requests/1)
12
+
8
13
  ## Usage
9
14
 
10
15
  Runs as a TypeScript CLI via `tsx` (no build step). See [docs/cli.md](docs/cli.md)
package/dist/cli.js CHANGED
@@ -714,10 +714,10 @@ function classifyNode(oldNode, newNode) {
714
714
  throw new Error("Cannot classify absent node");
715
715
  }
716
716
  if (!oldNode) {
717
- return { id: newNode.id, type: newNode.type, label: newNode.label, status: "added" };
717
+ return { id: newNode.id, type: newNode.type, label: newNode.label, status: "added", after: newNode.properties };
718
718
  }
719
719
  if (!newNode) {
720
- return { id: oldNode.id, type: oldNode.type, label: oldNode.label, status: "deleted" };
720
+ return { id: oldNode.id, type: oldNode.type, label: oldNode.label, status: "deleted", before: oldNode.properties };
721
721
  }
722
722
  const changes = [
723
723
  ...deepDiff(oldNode.label, newNode.label, "label"),
@@ -737,7 +737,9 @@ function classifyNode(oldNode, newNode) {
737
737
  type: newNode.type,
738
738
  label: newNode.label,
739
739
  status: "modified",
740
- changes
740
+ changes,
741
+ before: oldNode.properties,
742
+ after: newNode.properties
741
743
  };
742
744
  }
743
745
  function classifyEdge(oldEdge, newEdge) {
@@ -863,6 +865,656 @@ function measureBounds(nodes) {
863
865
  return { width: maxX + 40, height: maxY + 40 };
864
866
  }
865
867
 
868
+ // src/render/section-schemas.ts
869
+ var MAPPING_COLUMNS = [
870
+ { path: "field", label: "Field" },
871
+ { path: "value", label: "Value" }
872
+ ];
873
+ var FILTER_COLUMNS = [
874
+ { path: "field", label: "Field" },
875
+ { path: "operator", label: "Operator" },
876
+ { path: "value", label: "Value" }
877
+ ];
878
+ var PARAM_COLUMNS = [
879
+ { path: "name", label: "Name" },
880
+ { path: "value", label: "Value" },
881
+ { path: "assignToReference", label: "Assign To" }
882
+ ];
883
+ var SCREEN_SETTING_PATHS = [
884
+ "allowBack",
885
+ "allowFinish",
886
+ "allowPause",
887
+ "showFooter",
888
+ "showHeader"
889
+ ];
890
+ var SECTION_SCHEMAS = {
891
+ screen: [
892
+ { name: "Screen Settings", paths: SCREEN_SETTING_PATHS, render: "lines" }
893
+ ],
894
+ start: [
895
+ {
896
+ name: "Trigger",
897
+ paths: ["object", "triggerType", "recordTriggerType", "schedule", "filterLogic"],
898
+ render: "lines"
899
+ },
900
+ { name: "Entry Conditions", paths: ["filters"], render: "table", columns: FILTER_COLUMNS },
901
+ {
902
+ name: "Scheduled Paths",
903
+ paths: ["scheduledPaths"],
904
+ render: "table",
905
+ columns: [
906
+ { path: "name", label: "Name" },
907
+ { path: "label", label: "Label" },
908
+ { path: "offsetNumber", label: "Offset" },
909
+ { path: "offsetUnit", label: "Unit" },
910
+ { path: "timeSource", label: "Time Source" }
911
+ ]
912
+ }
913
+ ],
914
+ decision: [{
915
+ name: "Outcomes",
916
+ paths: ["rules"],
917
+ render: "grouped-table",
918
+ groupLabelPath: "label",
919
+ innerArray: "conditions",
920
+ innerColumns: [
921
+ { path: "leftValueReference", label: "Resource" },
922
+ { path: "operator", label: "Operator" },
923
+ { path: "rightValue", label: "Value" }
924
+ ]
925
+ }],
926
+ assignment: [{
927
+ name: "Assignment Items",
928
+ paths: ["assignmentItems"],
929
+ render: "table",
930
+ columns: [
931
+ { path: "assignToReference", label: "Variable" },
932
+ { path: "operator", label: "Operator" },
933
+ { path: "value", label: "Value" }
934
+ ]
935
+ }],
936
+ recordCreate: [{ name: "Field Mappings", paths: ["inputAssignments"], render: "table", columns: MAPPING_COLUMNS }],
937
+ recordUpdate: [{ name: "Field Mappings", paths: ["inputAssignments"], render: "table", columns: MAPPING_COLUMNS }],
938
+ recordLookup: [{ name: "Filters", paths: ["filters"], render: "table", columns: FILTER_COLUMNS }],
939
+ recordDelete: [{ name: "Filters", paths: ["filters"], render: "table", columns: FILTER_COLUMNS }],
940
+ subflow: [{ name: "Parameters", paths: ["inputAssignments", "outputAssignments"], render: "table", columns: PARAM_COLUMNS }],
941
+ actionCall: [{ name: "Parameters", paths: ["inputParameters", "outputParameters"], render: "table", columns: PARAM_COLUMNS }],
942
+ apexPluginCall: [{ name: "Parameters", paths: ["inputParameters", "outputParameters"], render: "table", columns: PARAM_COLUMNS }],
943
+ wait: [{
944
+ name: "Wait Events",
945
+ paths: ["waitEvents"],
946
+ render: "table",
947
+ columns: [
948
+ { path: "label", label: "Label" },
949
+ { path: "offset", label: "Offset" },
950
+ { path: "offsetUnit", label: "Unit" },
951
+ { path: "resumeDateReference", label: "Resume Date" }
952
+ ]
953
+ }],
954
+ customError: [{
955
+ name: "Error Messages",
956
+ paths: ["customErrorMessages"],
957
+ render: "table",
958
+ columns: [
959
+ { path: "name", label: "Name" },
960
+ { path: "errorMessage", label: "Message" }
961
+ ]
962
+ }],
963
+ transform: [
964
+ {
965
+ name: "Value Mappings",
966
+ paths: ["transformValues", "valueMappings"],
967
+ render: "table",
968
+ columns: [
969
+ { path: "name", label: "Name" },
970
+ { path: "sourceDataType", label: "Source Type" },
971
+ { path: "targetDataType", label: "Target Type" },
972
+ { path: "value", label: "Value" }
973
+ ]
974
+ },
975
+ {
976
+ name: "Data Type Mappings",
977
+ paths: ["dataTypeMappings"],
978
+ render: "table",
979
+ columns: [
980
+ { path: "name", label: "Name" },
981
+ { path: "sourceDataType", label: "Source Type" },
982
+ { path: "targetDataType", label: "Target Type" }
983
+ ]
984
+ }
985
+ ],
986
+ collectionProcessor: [
987
+ { name: "Collection Settings", paths: ["collectionReference", "operation"], render: "lines" },
988
+ { name: "Filters", paths: ["filters"], render: "table", columns: FILTER_COLUMNS },
989
+ {
990
+ name: "Sort Options",
991
+ paths: ["sortOptions"],
992
+ render: "table",
993
+ columns: [
994
+ { path: "field", label: "Field" },
995
+ { path: "sortOrder", label: "Order" }
996
+ ]
997
+ }
998
+ ],
999
+ orchestratedStage: [
1000
+ { name: "Stage Settings", paths: ["stageLabel", "stageOrder"], render: "lines" }
1001
+ ]
1002
+ };
1003
+ function getSectionSchemas(type) {
1004
+ return SECTION_SCHEMAS[type] ?? [];
1005
+ }
1006
+
1007
+ // src/render/snapshot-panel.ts
1008
+ var LONG_TEXT_THRESHOLD = 160;
1009
+ var COLLECTION_KEY_NAMES = [
1010
+ "assignmentItems",
1011
+ "choiceReferences",
1012
+ "choices",
1013
+ "conditions",
1014
+ "customErrorMessages",
1015
+ "dataTypeMappings",
1016
+ "fields",
1017
+ "filters",
1018
+ "inputAssignments",
1019
+ "inputParameters",
1020
+ "outputParameters",
1021
+ "outputAssignments",
1022
+ "processMetadataValues",
1023
+ "rules",
1024
+ "scheduledPaths",
1025
+ "sortOptions",
1026
+ "stageSteps",
1027
+ "steps",
1028
+ "storeOutputParameters",
1029
+ "transformValues",
1030
+ "valueMappings",
1031
+ "waitEvents"
1032
+ ];
1033
+ var COLLECTION_KEYS = new Set(COLLECTION_KEY_NAMES);
1034
+ function renderNodePanelBody(node, schemas = []) {
1035
+ if (node.status === "unchanged") {
1036
+ return "<div class='empty'>No property changes.</div>";
1037
+ }
1038
+ const before = node.status === "added" ? void 0 : node.before;
1039
+ const after = node.status === "deleted" ? void 0 : node.after;
1040
+ const options = { showUnchanged: node.status !== "modified", nodeStatus: node.status };
1041
+ const sections = renderSections(before, after, schemas || [], options);
1042
+ if (!sections.length) {
1043
+ return "<div class='empty'>No configuration.</div>";
1044
+ }
1045
+ return sections.map((section, index) => {
1046
+ const open = node.status === "modified" || index === 0 ? " open" : "";
1047
+ return "<details class='detail-section snapshot-section " + node.status + "'" + open + "><summary>" + escapeHtml(section.name) + " <span class='section-count'>(" + section.count + ")</span></summary><div class='section-body'>" + section.html + "</div></details>";
1048
+ }).join("");
1049
+ }
1050
+ function renderSections(before, after, schemas, options) {
1051
+ const sections = [];
1052
+ const owned = /* @__PURE__ */ new Set();
1053
+ for (const schema of schemas) {
1054
+ for (const path of schema.paths) owned.add(path);
1055
+ const rendered = renderSchemaSection(before, after, schema, options);
1056
+ if (rendered) sections.push(rendered);
1057
+ }
1058
+ const keys = unionKeys(before, after).filter((key) => !ownsAny(owned, key));
1059
+ const settingsRows = [];
1060
+ let settingsCount = 0;
1061
+ const nestedSections = [];
1062
+ for (const key of keys) {
1063
+ const childBefore = getField(before, key);
1064
+ const childAfter = getField(after, key);
1065
+ const childChanged = hasChange(childBefore, childAfter, key);
1066
+ const scalar = scalarPair(childBefore, childAfter);
1067
+ if (scalar) {
1068
+ if (options.showUnchanged || childChanged) {
1069
+ settingsRows.push(scalarRow(key, scalar.before, scalar.after, statusOf(childBefore, childAfter), options.nodeStatus));
1070
+ }
1071
+ if (childChanged) settingsCount += 1;
1072
+ continue;
1073
+ }
1074
+ const rendered = renderValue(getField(before, key), getField(after, key), {
1075
+ ...options,
1076
+ label: humanizePath(key),
1077
+ pathKey: key,
1078
+ depth: 0
1079
+ });
1080
+ if (rendered.html) {
1081
+ nestedSections.push({ name: humanizePath(key), html: rendered.html, count: rendered.count });
1082
+ }
1083
+ }
1084
+ if (settingsRows.length) {
1085
+ sections.push({
1086
+ name: "Settings",
1087
+ html: "<table class='snapshot-kv'><tbody>" + settingsRows.join("") + "</tbody></table>",
1088
+ count: settingsCount
1089
+ });
1090
+ }
1091
+ sections.push(...nestedSections);
1092
+ return sections;
1093
+ }
1094
+ function renderSchemaSection(before, after, schema, options) {
1095
+ let rendered;
1096
+ if (schema.render === "table" && schema.columns) {
1097
+ rendered = renderTable(before, after, schema.paths, schema.columns, options);
1098
+ } else if (schema.render === "grouped-table" && schema.innerArray && schema.innerColumns) {
1099
+ rendered = renderGroupedTable(before, after, schema, options);
1100
+ } else {
1101
+ const sectionBefore = pickSectionObject(before, schema.paths);
1102
+ const sectionAfter = pickSectionObject(after, schema.paths);
1103
+ rendered = renderValue(sectionBefore, sectionAfter, {
1104
+ ...options,
1105
+ label: schema.name,
1106
+ pathKey: schema.paths[0] || schema.name,
1107
+ depth: 0
1108
+ });
1109
+ }
1110
+ if (!rendered.html) return null;
1111
+ return { name: schema.name, html: rendered.html, count: rendered.count };
1112
+ }
1113
+ function renderValue(before, after, ctx) {
1114
+ const changed = hasChange(before, after, ctx.pathKey);
1115
+ if (!ctx.showUnchanged && !changed) return { html: "", changed: false, count: 0 };
1116
+ const scalar = scalarPair(before, after);
1117
+ if (scalar) {
1118
+ return {
1119
+ html: renderLine(ctx.label, scalar.before, scalar.after, statusOf(before, after), ctx.nodeStatus),
1120
+ changed,
1121
+ count: changed ? 1 : 0
1122
+ };
1123
+ }
1124
+ if (isCollection(before, after, ctx.pathKey)) {
1125
+ const rendered2 = renderArray(before, after, ctx);
1126
+ return { html: rendered2.html, changed, count: rendered2.count || (changed ? 1 : 0) };
1127
+ }
1128
+ const rendered = renderObject(before, after, ctx);
1129
+ return { html: rendered.html, changed, count: rendered.count || (changed ? 1 : 0) };
1130
+ }
1131
+ function renderObject(before, after, ctx) {
1132
+ const beforeObj = isObj(before) ? before : {};
1133
+ const afterObj = isObj(after) ? after : {};
1134
+ const keys = unionKeys(beforeObj, afterObj);
1135
+ const scalarRows = [];
1136
+ const nested = [];
1137
+ let count = 0;
1138
+ for (const key of keys) {
1139
+ const childBefore = getField(beforeObj, key);
1140
+ const childAfter = getField(afterObj, key);
1141
+ const childChanged = hasChange(childBefore, childAfter, key);
1142
+ const scalar = scalarPair(childBefore, childAfter);
1143
+ if (scalar) {
1144
+ if (ctx.showUnchanged || childChanged || ctx.depth > 0) {
1145
+ scalarRows.push(scalarRow(key, scalar.before, scalar.after, statusOf(childBefore, childAfter), ctx.nodeStatus));
1146
+ }
1147
+ if (childChanged) count += 1;
1148
+ continue;
1149
+ }
1150
+ const rendered = renderValue(childBefore, childAfter, {
1151
+ ...ctx,
1152
+ label: humanizePath(key),
1153
+ pathKey: key,
1154
+ depth: ctx.depth + 1
1155
+ });
1156
+ if (rendered.html) {
1157
+ nested.push("<div class='snapshot-nested'><div class='snapshot-nested-title'>" + escapeHtml(humanizePath(key)) + "</div>" + rendered.html + "</div>");
1158
+ count += rendered.count;
1159
+ }
1160
+ }
1161
+ const table = scalarRows.length ? "<table class='snapshot-kv'><tbody>" + scalarRows.join("") + "</tbody></table>" : "";
1162
+ return { html: table + nested.join(""), changed: count > 0, count };
1163
+ }
1164
+ function renderArray(before, after, ctx) {
1165
+ const beforeItems = arr(before);
1166
+ const afterItems = arr(after);
1167
+ if (looksLikeNameValueArray(beforeItems, afterItems)) {
1168
+ return renderTableFromItems(before, after, [""], [
1169
+ { path: "name", label: "Name" },
1170
+ { path: "value", label: "Value" }
1171
+ ], ctx);
1172
+ }
1173
+ const max = Math.max(beforeItems.length, afterItems.length);
1174
+ const cards = [];
1175
+ let count = 0;
1176
+ for (let i = 0; i < max; i += 1) {
1177
+ const childBefore = beforeItems[i];
1178
+ const childAfter = afterItems[i];
1179
+ const childChanged = hasChange(childBefore, childAfter, "");
1180
+ if (!ctx.showUnchanged && !childChanged) continue;
1181
+ const title = itemTitle(childBefore, childAfter, i);
1182
+ const rendered = renderValue(childBefore, childAfter, {
1183
+ ...ctx,
1184
+ label: title,
1185
+ pathKey: "",
1186
+ depth: ctx.depth + 1
1187
+ });
1188
+ if (!rendered.html) continue;
1189
+ count += rendered.count || (childChanged ? 1 : 0);
1190
+ cards.push("<div class='snapshot-card " + statusOf(childBefore, childAfter) + "'><div class='snapshot-card-title'>" + escapeHtml(title) + "</div>" + rendered.html + "</div>");
1191
+ }
1192
+ return { html: cards.join(""), changed: count > 0, count };
1193
+ }
1194
+ function renderTable(before, after, paths, columns, options) {
1195
+ return renderTableFromItems(before, after, paths, columns, { ...options, label: "", pathKey: paths[0] || "", depth: 0 });
1196
+ }
1197
+ function renderTableFromItems(before, after, paths, columns, options) {
1198
+ const rows = [];
1199
+ for (const path of paths) {
1200
+ const beforeItems = arr(resolvePath(before, path));
1201
+ const afterItems = arr(resolvePath(after, path));
1202
+ const max = Math.max(beforeItems.length, afterItems.length);
1203
+ for (let i = 0; i < max; i += 1) {
1204
+ const itemBefore = beforeItems[i];
1205
+ const itemAfter = afterItems[i];
1206
+ if (!options.showUnchanged && !hasChange(itemBefore, itemAfter, path)) continue;
1207
+ const cells = {};
1208
+ for (const col of columns) {
1209
+ const cellBefore = resolvePath(itemBefore, col.path);
1210
+ const cellAfter = resolvePath(itemAfter, col.path);
1211
+ cells[col.path] = {
1212
+ before: unwrapValue(cellBefore),
1213
+ after: unwrapValue(cellAfter),
1214
+ changed: hasChange(cellBefore, cellAfter, col.path)
1215
+ };
1216
+ }
1217
+ rows.push({ idx: i, kind: statusOf(itemBefore, itemAfter), cells });
1218
+ }
1219
+ }
1220
+ if (!rows.length) return { html: "", changed: false, count: 0 };
1221
+ const showChange = options.nodeStatus === "modified" && !options.suppressChangeColumn;
1222
+ const head = "<tr><th class='row-idx'>#</th>" + columns.map((col) => "<th>" + escapeHtml(col.label) + "</th>").join("") + (showChange ? "<th>Change</th>" : "") + "</tr>";
1223
+ const body = rows.map((row) => renderRow(row, columns, showChange, options.nodeStatus)).join("");
1224
+ return {
1225
+ html: "<table class='change-table'><thead>" + head + "</thead><tbody>" + body + "</tbody></table>",
1226
+ changed: rows.some((row) => row.kind !== "unchanged" || Object.values(row.cells).some((cell) => cell.changed)),
1227
+ count: rows.filter((row) => row.kind !== "unchanged" || Object.values(row.cells).some((cell) => cell.changed)).length
1228
+ };
1229
+ }
1230
+ function renderRow(row, columns, showChange, nodeStatus) {
1231
+ const cells = columns.map((col) => {
1232
+ const cell = row.cells[col.path];
1233
+ return "<td>" + renderValueCell(cell.before, cell.after, cell.changed ? row.kind : "unchanged", nodeStatus) + "</td>";
1234
+ }).join("");
1235
+ const badge = showChange ? "<td><span class='change-kind " + row.kind + "'>" + capitalize(row.kind) + "</span></td>" : "";
1236
+ return "<tr class='row-" + row.kind + "'><td class='row-idx'>" + (row.idx + 1) + "</td>" + cells + badge + "</tr>";
1237
+ }
1238
+ function renderGroupedTable(before, after, schema, options) {
1239
+ const groups = [];
1240
+ let count = 0;
1241
+ for (const path of schema.paths) {
1242
+ const beforeGroups = arr(resolvePath(before, path));
1243
+ const afterGroups = arr(resolvePath(after, path));
1244
+ const max = Math.max(beforeGroups.length, afterGroups.length);
1245
+ for (let i = 0; i < max; i += 1) {
1246
+ const groupBefore = beforeGroups[i];
1247
+ const groupAfter = afterGroups[i];
1248
+ if (!options.showUnchanged && !hasChange(groupBefore, groupAfter, path)) continue;
1249
+ const kind = statusOf(groupBefore, groupAfter);
1250
+ const present = groupAfter !== void 0 ? groupAfter : groupBefore;
1251
+ const labelValue = schema.groupLabelPath && present ? resolvePath(present, schema.groupLabelPath) : void 0;
1252
+ const label = labelValue != null ? stripHtml(String(labelValue)) : "Outcome " + (i + 1);
1253
+ const scalarRows = renderGroupScalars(groupBefore, groupAfter, schema, options);
1254
+ const inner = renderTableFromItems(groupBefore, groupAfter, [schema.innerArray || ""], schema.innerColumns || [], {
1255
+ ...options,
1256
+ suppressChangeColumn: kind !== "modified",
1257
+ label,
1258
+ pathKey: schema.innerArray || "",
1259
+ depth: 1
1260
+ }).html;
1261
+ const head = "<div class='group-head'><span class='change-kind " + kind + "'>" + capitalize(kind) + "</span><span class='group-label'>" + escapeHtml(label) + "</span></div>";
1262
+ groups.push("<div class='outcome-group'>" + head + scalarRows + inner + "</div>");
1263
+ count += 1;
1264
+ }
1265
+ }
1266
+ return { html: groups.join(""), changed: count > 0, count };
1267
+ }
1268
+ function renderGroupScalars(before, after, schema, options) {
1269
+ const beforeObj = isObj(before) ? before : {};
1270
+ const afterObj = isObj(after) ? after : {};
1271
+ const skip = new Set([schema.innerArray, schema.groupLabelPath].filter(Boolean));
1272
+ const rows = unionKeys(beforeObj, afterObj).filter((key) => !skip.has(key)).map((key) => {
1273
+ const cellBefore = getField(beforeObj, key);
1274
+ const cellAfter = getField(afterObj, key);
1275
+ const scalar = scalarPair(cellBefore, cellAfter);
1276
+ if (!scalar) return "";
1277
+ if (!options.showUnchanged && !hasChange(cellBefore, cellAfter, key)) return "";
1278
+ return scalarRow(key, scalar.before, scalar.after, statusOf(cellBefore, cellAfter), options.nodeStatus);
1279
+ }).filter(Boolean).join("");
1280
+ return rows ? "<table class='snapshot-kv'><tbody>" + rows + "</tbody></table>" : "";
1281
+ }
1282
+ function scalarRow(key, before, after, kind, nodeStatus) {
1283
+ return "<tr><th>" + escapeHtml(humanizePath(key)) + "</th><td>" + renderValueCell(before, after, kind, nodeStatus) + "</td></tr>";
1284
+ }
1285
+ function renderLine(label, before, after, kind, nodeStatus) {
1286
+ const badge = nodeStatus === "modified" ? "<span class='change-kind " + kind + "'>" + capitalize(kind) + "</span>" : "";
1287
+ return "<ul class='changes'><li><div class='change-line-head'>" + badge + "<span class='change-line-label'>" + escapeHtml(label) + "</span></div><div class='change-line-value'>" + renderValueCell(before, after, kind, nodeStatus) + "</div></li></ul>";
1288
+ }
1289
+ function renderValueCell(before, after, kind, nodeStatus) {
1290
+ if (nodeStatus === "added") return oneSidedVal(after !== void 0 ? after : before);
1291
+ if (nodeStatus === "deleted") return oneSidedVal(before !== void 0 ? before : after);
1292
+ if (kind === "added") return insVal(after !== void 0 ? after : before);
1293
+ if (kind === "removed") return delVal(before !== void 0 ? before : after);
1294
+ if (kind === "modified") return diffValue(before, after);
1295
+ return faintVal(after !== void 0 ? after : before);
1296
+ }
1297
+ function statusOf(before, after) {
1298
+ if (before === void 0) return "added";
1299
+ if (after === void 0) return "removed";
1300
+ return valuesEqual(before, after) ? "unchanged" : "modified";
1301
+ }
1302
+ function hasChange(before, after, pathKey = "") {
1303
+ if (before === void 0 || after === void 0) return before !== after;
1304
+ const scalar = scalarPair(before, after);
1305
+ if (scalar) return !valuesEqual(scalar.before, scalar.after);
1306
+ if (isCollection(before, after, pathKey)) {
1307
+ const beforeItems = arr(before);
1308
+ const afterItems = arr(after);
1309
+ const max = Math.max(beforeItems.length, afterItems.length);
1310
+ for (let i = 0; i < max; i += 1) {
1311
+ if (hasChange(beforeItems[i], afterItems[i], "")) return true;
1312
+ }
1313
+ return false;
1314
+ }
1315
+ const beforeObj = isObj(before) ? before : {};
1316
+ const afterObj = isObj(after) ? after : {};
1317
+ for (const key of unionKeys(beforeObj, afterObj)) {
1318
+ if (hasChange(beforeObj[key], afterObj[key], key)) return true;
1319
+ }
1320
+ return false;
1321
+ }
1322
+ function scalarPair(before, after) {
1323
+ const beforeUnwrapped = unwrapValue(before);
1324
+ const afterUnwrapped = unwrapValue(after);
1325
+ const beforeScalar = beforeUnwrapped == null || typeof beforeUnwrapped !== "object";
1326
+ const afterScalar = afterUnwrapped == null || typeof afterUnwrapped !== "object";
1327
+ return beforeScalar && afterScalar ? { before: beforeUnwrapped, after: afterUnwrapped } : null;
1328
+ }
1329
+ function arr(value) {
1330
+ if (value === void 0 || value === null) return [];
1331
+ return Array.isArray(value) ? value : [value];
1332
+ }
1333
+ function isObj(value) {
1334
+ return !!value && typeof value === "object" && !Array.isArray(value);
1335
+ }
1336
+ function isCollection(before, after, pathKey) {
1337
+ return Array.isArray(before) || Array.isArray(after) || isCollectionKey(pathKey);
1338
+ }
1339
+ function isCollectionKey(key) {
1340
+ return COLLECTION_KEYS.has(key);
1341
+ }
1342
+ function unionKeys(before, after) {
1343
+ return [.../* @__PURE__ */ new Set([...Object.keys(before || {}), ...Object.keys(after || {})])].sort();
1344
+ }
1345
+ function ownsAny(paths, key) {
1346
+ for (const path of paths) {
1347
+ if (key === path || key.startsWith(path + ".") || key.startsWith(path + "[")) return true;
1348
+ }
1349
+ return false;
1350
+ }
1351
+ function pickSectionObject(obj, paths) {
1352
+ if (!obj) return void 0;
1353
+ const out = {};
1354
+ for (const path of paths) {
1355
+ const value = resolvePath(obj, path);
1356
+ if (value !== void 0) out[path] = value;
1357
+ }
1358
+ return Object.keys(out).length ? out : void 0;
1359
+ }
1360
+ function getField(obj, key) {
1361
+ return obj ? obj[key] : void 0;
1362
+ }
1363
+ function resolvePath(obj, path) {
1364
+ if (obj == null || !path) return obj;
1365
+ let cur = obj;
1366
+ const tokens = path.match(/[^.[\]]+|\[\d+\]/g) || [];
1367
+ for (const token of tokens) {
1368
+ if (cur == null || typeof cur !== "object") return void 0;
1369
+ cur = token[0] === "[" ? cur[Number(token.slice(1, -1))] : cur[token];
1370
+ }
1371
+ return cur;
1372
+ }
1373
+ function looksLikeNameValueArray(beforeItems, afterItems) {
1374
+ const items = [...beforeItems, ...afterItems].filter(isObj);
1375
+ return items.length > 0 && items.every((item) => "name" in item && "value" in item);
1376
+ }
1377
+ function itemTitle(before, after, index) {
1378
+ const item = isObj(after) ? after : before;
1379
+ if (item) {
1380
+ for (const key of ["fieldText", "label", "name", "fieldType", "field"]) {
1381
+ const value = unwrapValue(item[key]);
1382
+ if (value !== void 0 && value !== null && typeof value !== "object") {
1383
+ return stripHtml(String(value));
1384
+ }
1385
+ }
1386
+ }
1387
+ return "Item " + (index + 1);
1388
+ }
1389
+ function stripHtml(value) {
1390
+ return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
1391
+ }
1392
+ function capitalize(value) {
1393
+ return value.charAt(0).toUpperCase() + value.slice(1);
1394
+ }
1395
+ function humanizePath(path) {
1396
+ const names = {
1397
+ assignmentItems: "Assignment item",
1398
+ choiceReferences: "Choice reference",
1399
+ choices: "Choice",
1400
+ conditions: "Condition",
1401
+ customErrorMessages: "Custom error message",
1402
+ dataTypeMappings: "Data type mapping",
1403
+ fields: "Field",
1404
+ filters: "Filter",
1405
+ inputAssignments: "Input assignment",
1406
+ inputParameters: "Input parameter",
1407
+ outputParameters: "Output parameter",
1408
+ processMetadataValues: "Process metadata value",
1409
+ rules: "Rule",
1410
+ scheduledPaths: "Scheduled path",
1411
+ storeOutputParameters: "Store output parameter",
1412
+ transformValues: "Transform value",
1413
+ valueMappings: "Value mapping",
1414
+ waitEvents: "Wait event"
1415
+ };
1416
+ return path.split(".").map((segment) => {
1417
+ const match = segment.match(/^([^[]+)(?:\[(\d+)\])?$/);
1418
+ if (!match) return segment;
1419
+ const name = names[match[1]] || match[1].replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase());
1420
+ return match[2] === void 0 ? name : name + " " + (Number(match[2]) + 1);
1421
+ }).join(" > ");
1422
+ }
1423
+ function unwrapValue(value) {
1424
+ if (value === null || value === void 0 || typeof value !== "object" || Array.isArray(value)) return value;
1425
+ if ("elementReference" in value) return "{!" + value.elementReference + "}";
1426
+ const keys = Object.keys(value);
1427
+ if (keys.length === 1) {
1428
+ const key = keys[0];
1429
+ const obj = value;
1430
+ if (key === "stringValue" || key === "numberValue" || key === "dateValue" || key === "dateTimeValue") return obj[key];
1431
+ if (key === "booleanValue") return String(obj[key]) === "true" ? "true" : "false";
1432
+ }
1433
+ return value;
1434
+ }
1435
+ function valuesEqual(a, b) {
1436
+ if (a === b) return true;
1437
+ if (a && b && typeof a === "object" && typeof b === "object") return JSON.stringify(a) === JSON.stringify(b);
1438
+ return false;
1439
+ }
1440
+ function renderScalar(value) {
1441
+ if (value === void 0) return "<em>-</em>";
1442
+ if (value === null) return "<em>empty</em>";
1443
+ if (typeof value === "object") return "<pre>" + escapeHtml(JSON.stringify(value, null, 2)) + "</pre>";
1444
+ const text = String(value);
1445
+ if (text.length > LONG_TEXT_THRESHOLD || text.includes("\n")) {
1446
+ return "<pre>" + escapeHtml(text) + "</pre>";
1447
+ }
1448
+ return escapeHtml(text);
1449
+ }
1450
+ function insVal(value) {
1451
+ return "<span class='val ins'>" + renderScalar(value) + "</span>";
1452
+ }
1453
+ function delVal(value) {
1454
+ return "<span class='val del'>" + renderScalar(value) + "</span>";
1455
+ }
1456
+ function faintVal(value) {
1457
+ return "<span class='val faint'>" + renderScalar(value) + "</span>";
1458
+ }
1459
+ function oneSidedVal(value) {
1460
+ return "<span class='val one-sided'>" + renderScalar(value) + "</span>";
1461
+ }
1462
+ function diffValue(before, after) {
1463
+ before = unwrapValue(before);
1464
+ after = unwrapValue(after);
1465
+ if (before === void 0) return insVal(after);
1466
+ if (after === void 0) return delVal(before);
1467
+ return delVal(before) + " <span class='arrow'>-></span> " + insVal(after);
1468
+ }
1469
+ function escapeHtml(value) {
1470
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1471
+ }
1472
+ function snapshotPanelClientScript() {
1473
+ const functions = [
1474
+ renderNodePanelBody,
1475
+ renderSections,
1476
+ renderSchemaSection,
1477
+ renderValue,
1478
+ renderObject,
1479
+ renderArray,
1480
+ renderTable,
1481
+ renderTableFromItems,
1482
+ renderRow,
1483
+ renderGroupedTable,
1484
+ renderGroupScalars,
1485
+ scalarRow,
1486
+ renderLine,
1487
+ renderValueCell,
1488
+ statusOf,
1489
+ hasChange,
1490
+ scalarPair,
1491
+ arr,
1492
+ isObj,
1493
+ isCollection,
1494
+ isCollectionKey,
1495
+ unionKeys,
1496
+ ownsAny,
1497
+ pickSectionObject,
1498
+ getField,
1499
+ resolvePath,
1500
+ looksLikeNameValueArray,
1501
+ itemTitle,
1502
+ stripHtml,
1503
+ capitalize,
1504
+ humanizePath,
1505
+ unwrapValue,
1506
+ valuesEqual,
1507
+ renderScalar,
1508
+ insVal,
1509
+ delVal,
1510
+ faintVal,
1511
+ oneSidedVal,
1512
+ diffValue,
1513
+ escapeHtml
1514
+ ].map((fn) => fn.toString()).join("\n\n");
1515
+ return "const LONG_TEXT_THRESHOLD = " + LONG_TEXT_THRESHOLD + ";\nconst COLLECTION_KEYS = new Set(" + JSON.stringify(COLLECTION_KEY_NAMES) + ");\n\n" + functions;
1516
+ }
1517
+
866
1518
  // src/render/render-html.ts
867
1519
  function renderHtml(layout) {
868
1520
  const data = {
@@ -871,7 +1523,8 @@ function renderHtml(layout) {
871
1523
  edges: layout.edges,
872
1524
  width: layout.width,
873
1525
  height: layout.height,
874
- layouts: layout.views
1526
+ layouts: layout.views,
1527
+ sectionSchemas: Object.fromEntries(layout.diff.nodes.map((node) => [node.type, getSectionSchemas(node.type)]))
875
1528
  };
876
1529
  const json = JSON.stringify(data).replace(/</g, "\\u003c");
877
1530
  const s = layout.diff.summary;
@@ -884,7 +1537,7 @@ function renderHtml(layout) {
884
1537
  <head>
885
1538
  <meta charset="utf-8" />
886
1539
  <meta name="viewport" content="width=device-width, initial-scale=1" />
887
- <title>${escapeHtml(layout.diff.flowName)}</title>
1540
+ <title>${escapeHtml2(layout.diff.flowName)}</title>
888
1541
  <style>
889
1542
  :root {
890
1543
  color-scheme: light;
@@ -896,6 +1549,7 @@ function renderHtml(layout) {
896
1549
  --modified: #d97706; --modified-fill: #fef3c7;
897
1550
  --unchanged: #94a3b8; --unchanged-fill: #e9edf3;
898
1551
  --edge: #64748b; --fault: #7c3aed;
1552
+ --panel-width: 34vw;
899
1553
  }
900
1554
  * { box-sizing: border-box; }
901
1555
  body { margin: 0; height: 100vh; display: grid; grid-template-rows: auto 1fr; background: var(--bg); color: var(--text); font-size: 14px; line-height: 1.45; }
@@ -938,7 +1592,8 @@ function renderHtml(layout) {
938
1592
  .legend .deleted::before { background: var(--deleted); }
939
1593
  .legend .modified::before { background: var(--modified); }
940
1594
  .legend .unchanged::before { background: var(--unchanged); }
941
- main { display: grid; grid-template-columns: minmax(0, 1fr) clamp(360px, 32vw, 520px); min-height: 0; }
1595
+ main { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) clamp(300px, var(--panel-width), 70vw); min-height: 0; }
1596
+ body.panel-collapsed main { grid-template-columns: minmax(0, 1fr) 0; }
942
1597
  .canvas { position: relative; overflow: hidden; }
943
1598
  svg {
944
1599
  width: 100%; height: 100%; display: block;
@@ -946,22 +1601,83 @@ function renderHtml(layout) {
946
1601
  radial-gradient(circle, #dfe5ee 1px, transparent 1.4px) -11px -11px / 22px 22px,
947
1602
  linear-gradient(#ffffff, #f7f9fc);
948
1603
  }
949
- .panel { min-width: 0; border-left: 1px solid var(--border); background: var(--surface); padding: 20px; overflow-y: auto; overflow-x: hidden; }
1604
+ .panel { position: relative; min-width: 0; border-left: 1px solid var(--border); background: var(--surface); padding: 20px; overflow-y: auto; overflow-x: hidden; }
1605
+ body.panel-collapsed .panel { padding: 0; border-left: 0; overflow: hidden; }
1606
+ body.panel-collapsed .panel > *:not(.panel-resizer) { display: none; }
1607
+ /* drag-to-resize handle on the panel's left edge */
1608
+ .panel-resizer { position: absolute; left: 0; top: 0; bottom: 0; width: 9px; transform: translateX(-50%); cursor: col-resize; z-index: 6; touch-action: none; }
1609
+ .panel-resizer::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--border); transform: translateX(-50%); transition: background-color 0.12s ease, width 0.12s ease; }
1610
+ .panel-resizer:hover::before, .panel-resizer.dragging::before { background: #93c5fd; width: 3px; }
1611
+ body.panel-collapsed .panel-resizer { display: none; }
1612
+ .panel-head { display: flex; align-items: flex-start; gap: 8px; }
1613
+ .panel-head h2 { flex: 1; min-width: 0; }
1614
+ .panel-toggle, .panel-reopen { border: 1px solid var(--border); background: #f8fafc; color: var(--muted); border-radius: 7px; cursor: pointer; font: inherit; line-height: 1; }
1615
+ .panel-toggle { flex: none; width: 26px; height: 26px; font-size: 16px; display: grid; place-items: center; }
1616
+ .panel-toggle:hover, .panel-reopen:hover { color: var(--text); border-color: #93c5fd; background: #eff6ff; }
1617
+ .panel-toggle:focus-visible, .panel-reopen:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
1618
+ /* floating tab to reopen the panel once collapsed */
1619
+ .panel-reopen { display: none; position: absolute; top: 14px; right: 14px; z-index: 7; padding: 7px 12px; font-size: 12px; font-weight: 600; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.12); }
1620
+ body.panel-collapsed .panel-reopen { display: inline-flex; align-items: center; gap: 6px; }
950
1621
  .panel h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
951
1622
  .panel .badge { display: inline-block; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.03em; background: #eef1f6; color: var(--muted); margin-bottom: 16px; }
952
1623
  .panel.added .badge { background: var(--added-fill); color: var(--added); }
953
1624
  .panel.deleted .badge { background: var(--deleted-fill); color: var(--deleted); }
954
1625
  .panel.modified .badge { background: var(--modified-fill); color: var(--modified); }
955
1626
  .panel .empty { color: var(--muted); font-size: 13px; }
956
- .changes { list-style: none; padding: 0; margin: 4px 0 0; }
957
- .changes li { min-width: 0; margin-bottom: 12px; padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: #fbfcfe; }
958
- .change-title { font-weight: 600; margin-bottom: 5px; }
959
- .change-path { margin-bottom: 12px; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
960
- .change-values { display: grid; gap: 10px; min-width: 0; }
961
- .change-value { min-width: 0; }
962
- .change-value-label { margin-bottom: 4px; color: #475569; font-size: 10.5px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
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; }
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; }
1627
+ .detail-section { margin: 0 0 12px; border: 1px solid var(--border); border-radius: 8px; background: #fbfcfe; overflow: hidden; }
1628
+ .detail-section.snapshot-section { border-left-width: 3px; }
1629
+ .detail-section.snapshot-section.added { border-left-color: rgba(22, 163, 74, 0.5); }
1630
+ .detail-section.snapshot-section.deleted { border-left-color: rgba(220, 38, 38, 0.45); }
1631
+ .detail-section.snapshot-section.added summary { background: rgba(220, 252, 231, 0.35); }
1632
+ .detail-section.snapshot-section.deleted summary { background: rgba(254, 226, 226, 0.35); }
1633
+ .detail-section summary { cursor: pointer; padding: 11px 13px; font-weight: 650; }
1634
+ .detail-section[open] summary { border-bottom: 1px solid var(--border); }
1635
+ .section-count { color: var(--muted); font-size: 11px; font-weight: 500; }
1636
+ .section-body { padding: 12px; overflow-x: auto; }
1637
+ .change-kind { display: inline-block; padding: 2px 7px; border-radius: 999px; font-size: 10px; font-weight: 700; letter-spacing: 0.02em; }
1638
+ .change-kind.added { color: var(--added); background: var(--added-fill); }
1639
+ .change-kind.modified { color: var(--modified); background: var(--modified-fill); }
1640
+ .change-kind.removed { color: var(--deleted); background: var(--deleted-fill); }
1641
+ /* git-diff value grammar */
1642
+ .val { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; border-radius: 4px; padding: 1px 5px; overflow-wrap: anywhere; }
1643
+ .val.ins { color: #14532d; background: var(--added-fill); }
1644
+ .val.del { color: #7f1d1d; background: var(--deleted-fill); text-decoration: line-through; text-decoration-color: rgba(127, 29, 29, 0.5); }
1645
+ .val.faint { color: var(--muted); background: transparent; padding-left: 0; }
1646
+ .val.one-sided { color: var(--text); background: transparent; padding-left: 0; }
1647
+ .val em { font-style: normal; color: var(--faint); }
1648
+ .val pre { margin: 4px 0 0; padding: 8px 10px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11.5px; line-height: 1.5; max-height: 220px; overflow: auto; }
1649
+ .arrow { color: var(--faint); margin: 0 2px; }
1650
+ /* scalar / fallback change lines */
1651
+ .changes { list-style: none; padding: 0; margin: 0; }
1652
+ .changes li { min-width: 0; margin-bottom: 9px; }
1653
+ .changes li:last-child { margin-bottom: 0; }
1654
+ .change-line-head { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
1655
+ .change-line-label { font-weight: 600; font-size: 12px; }
1656
+ .change-line-value { padding-left: 2px; }
1657
+ /* item tables (field mappings, conditions, filters, parameters) */
1658
+ .change-table { width: 100%; border-collapse: collapse; font-size: 12px; }
1659
+ .change-table th, .change-table td { padding: 7px 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
1660
+ .change-table th { color: var(--muted); font-size: 10.5px; letter-spacing: 0.04em; text-transform: uppercase; }
1661
+ .change-table tr:last-child td { border-bottom: 0; }
1662
+ .change-table .row-idx { color: var(--faint); font-variant-numeric: tabular-nums; width: 1%; white-space: nowrap; }
1663
+ .change-table tr.row-added { background: rgba(22, 163, 74, 0.06); }
1664
+ .change-table tr.row-removed { background: rgba(220, 38, 38, 0.05); }
1665
+ .snapshot-kv { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 10px; }
1666
+ .snapshot-kv th, .snapshot-kv td { padding: 6px 7px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
1667
+ .snapshot-kv th { color: var(--muted); width: 34%; font-weight: 650; }
1668
+ .snapshot-kv tr:last-child th, .snapshot-kv tr:last-child td { border-bottom: 0; }
1669
+ .snapshot-nested { border-left: 2px solid var(--border); padding-left: 10px; margin: 10px 0; }
1670
+ .snapshot-nested-title { color: var(--muted); font-size: 11px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; margin-bottom: 7px; }
1671
+ .snapshot-card { border: 1px dashed var(--border); border-radius: 7px; padding: 9px 10px; margin: 8px 0; background: var(--surface); }
1672
+ .snapshot-card.added { border-left: 2px solid rgba(22, 163, 74, 0.5); }
1673
+ .snapshot-card.removed { border-left: 2px solid rgba(220, 38, 38, 0.45); }
1674
+ .snapshot-card-title { font-size: 12px; font-weight: 700; margin-bottom: 7px; overflow-wrap: anywhere; }
1675
+ /* outcome groups (decisions) */
1676
+ .outcome-group { margin-bottom: 12px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); }
1677
+ .outcome-group:last-child { margin-bottom: 0; }
1678
+ .group-head { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
1679
+ .group-label { font-weight: 700; font-size: 12.5px; }
1680
+ .outcome-group .changes { margin-bottom: 8px; }
965
1681
  .edge { fill: none; }
966
1682
  .edge.unchanged { stroke-width: 1.4; opacity: 0.55; }
967
1683
  .edge.normal.unchanged { stroke: var(--edge); marker-end: url(#arrow-normal); }
@@ -981,8 +1697,18 @@ function renderHtml(layout) {
981
1697
  .node.unchanged rect { fill: var(--unchanged-fill); stroke: var(--unchanged); }
982
1698
  .node.selected rect { stroke-width: 3; filter: drop-shadow(0 2px 6px rgba(15, 23, 42, 0.18)); }
983
1699
  .node-label { white-space: pre; }
1700
+ /* Type-based styling \u2014 SHAPE & stroke pattern only. Status (added/deleted/
1701
+ modified/unchanged) remains the sole authority on fill/stroke COLOR, so
1702
+ the legend stays reliable. Start/end stand out via a pill shape, heavier
1703
+ stroke, and a neutral emphasis halo \u2014 never via a status-like color. */
1704
+ .node.type-start rect, .node.type-end rect { rx: 24; ry: 24; stroke-width: 2.5; filter: drop-shadow(0 0 0 3px rgba(15, 23, 42, 0.16)) drop-shadow(0 1px 3px rgba(15, 23, 42, 0.18)); }
1705
+ .node.type-recordLookup rect, .node.type-recordCreate rect, .node.type-recordUpdate rect, .node.type-recordDelete rect { rx: 4; ry: 4; }
1706
+ .node.type-screen rect { rx: 6; ry: 6; }
1707
+ .node.type-subflow rect { stroke-width: 2.5; }
1708
+ .node.type-loop rect { stroke-dasharray: 2 3; }
1709
+ .node.type-wait rect { stroke-dasharray: 4 4; }
1710
+ .node.selected.type-start rect, .node.selected.type-end rect { stroke-width: 3.5; filter: drop-shadow(0 0 0 5px rgba(15, 23, 42, 0.22)) drop-shadow(0 2px 6px rgba(15, 23, 42, 0.2)); }
984
1711
  @media (max-width: 1000px) {
985
- main { grid-template-columns: minmax(0, 1fr) minmax(320px, 42vw); }
986
1712
  .panel { padding: 16px; }
987
1713
  }
988
1714
  </style>
@@ -990,7 +1716,7 @@ function renderHtml(layout) {
990
1716
  <body>
991
1717
  <header>
992
1718
  <div>
993
- <h1>${escapeHtml(layout.diff.flowName)}</h1>
1719
+ <h1>${escapeHtml2(layout.diff.flowName)}</h1>
994
1720
  <div class="meta">${metaHtml}</div>
995
1721
  </div>
996
1722
  <div class="header-actions">
@@ -1032,10 +1758,15 @@ function renderHtml(layout) {
1032
1758
  </svg>
1033
1759
  </div>
1034
1760
  <aside class="panel">
1035
- <h2 id="panel-title">Select a node</h2>
1761
+ <div class="panel-resizer" id="panel-resizer" role="separator" aria-orientation="vertical" aria-label="Resize panel"></div>
1762
+ <div class="panel-head">
1763
+ <h2 id="panel-title">Select a node</h2>
1764
+ <button type="button" id="panel-toggle" class="panel-toggle" aria-label="Collapse panel" title="Collapse panel">\u203A</button>
1765
+ </div>
1036
1766
  <div id="panel-badge" class="badge">No selection</div>
1037
1767
  <div id="panel-body">Click a node to inspect its properties.</div>
1038
1768
  </aside>
1769
+ <button type="button" id="panel-reopen" class="panel-reopen" aria-label="Show details panel" title="Show details">\u2039 Details</button>
1039
1770
  </main>
1040
1771
  <script>
1041
1772
  const DATA = ${json};
@@ -1053,14 +1784,6 @@ function renderHtml(layout) {
1053
1784
  let dragStart = null;
1054
1785
  let selectedNodeId = null;
1055
1786
 
1056
- function escapeHtml(value) {
1057
- return String(value)
1058
- .replace(/&/g, "&amp;")
1059
- .replace(/</g, "&lt;")
1060
- .replace(/>/g, "&gt;")
1061
- .replace(/"/g, "&quot;");
1062
- }
1063
-
1064
1787
  function isNodeVisible(node, mode) {
1065
1788
  if (mode === "after") return node.status !== "deleted";
1066
1789
  if (mode === "before") return node.status !== "added";
@@ -1186,16 +1909,11 @@ function renderHtml(layout) {
1186
1909
  panel.className = "panel " + node.status;
1187
1910
  panelTitle.textContent = node.label;
1188
1911
  panelBadge.textContent = node.status.toUpperCase();
1189
- const changes = node.changes || [];
1190
- panelBody.innerHTML = changes.length
1191
- ? "<ul class='changes'>" + changes.map(formatChange).join("") + "</ul>"
1192
- : node.status === "added"
1193
- ? "<div class='empty'>This node was added in the new version.</div>"
1194
- : node.status === "deleted"
1195
- ? "<div class='empty'>This node was removed in the new version.</div>"
1196
- : "<div class='empty'>No property changes.</div>";
1912
+ panelBody.innerHTML = renderNodePanelBody(node, DATA.sectionSchemas[node.type] || []);
1197
1913
  }
1198
1914
 
1915
+ ${snapshotPanelClientScript()}
1916
+
1199
1917
  function selectNode(id) {
1200
1918
  selectedNodeId = id;
1201
1919
  document.querySelectorAll(".node").forEach((el) => el.classList.remove("selected"));
@@ -1205,45 +1923,6 @@ function renderHtml(layout) {
1205
1923
  if (node) updatePanel(node);
1206
1924
  }
1207
1925
 
1208
- function formatChange(change) {
1209
- const beforeMissing = change.before === undefined;
1210
- const afterMissing = change.after === undefined;
1211
- const action = beforeMissing ? "Added" : afterMissing ? "Removed" : "Changed";
1212
- const values = beforeMissing
1213
- ? formatLabeledValue("New value", change.after)
1214
- : afterMissing
1215
- ? formatLabeledValue("Previous value", change.before)
1216
- : formatLabeledValue("Before", change.before) + formatLabeledValue("After", change.after);
1217
- return "<li><div class='change-title'>" + action + ": " + escapeHtml(humanizePath(change.path)) + "</div>"
1218
- + "<div class='change-path'>Metadata path: <code>" + escapeHtml(change.path) + "</code></div>"
1219
- + "<div class='change-values'>" + values + "</div></li>";
1220
- }
1221
-
1222
- function formatLabeledValue(label, value) {
1223
- return "<div class='change-value'><div class='change-value-label'>" + label + "</div>" + formatValue(value) + "</div>";
1224
- }
1225
-
1226
- function humanizePath(path) {
1227
- const names = {
1228
- assignmentItems: "Assignment item",
1229
- conditions: "Condition",
1230
- rules: "Rule",
1231
- };
1232
- return path.split(".").map((segment) => {
1233
- const match = segment.match(/^([^[]+)(?:\\[(\\d+)\\])?$/);
1234
- if (!match) return segment;
1235
- const name = names[match[1]] || match[1].replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase());
1236
- return match[2] === undefined ? name : name + " " + (Number(match[2]) + 1);
1237
- }).join(" \u203A ");
1238
- }
1239
-
1240
- function formatValue(value) {
1241
- if (value === undefined) return "<em>Not present</em>";
1242
- if (value === null) return "<em>Empty value</em>";
1243
- if (typeof value === "object") return "<pre>" + escapeHtml(JSON.stringify(value, null, 2)) + "</pre>";
1244
- return "<code>" + escapeHtml(String(value)) + "</code>";
1245
- }
1246
-
1247
1926
  function pickInitialNode(visibleNodeIds) {
1248
1927
  return DATA.nodes.find((node) => visibleNodeIds.has(node.id) && node.status === "modified")
1249
1928
  || DATA.nodes.find((node) => visibleNodeIds.has(node.id) && node.status !== "unchanged")
@@ -1356,6 +2035,39 @@ function renderHtml(layout) {
1356
2035
  });
1357
2036
  });
1358
2037
 
2038
+ // Panel: collapse to focus on the canvas, and drag the left edge to resize.
2039
+ const panelToggle = document.getElementById("panel-toggle");
2040
+ const panelReopen = document.getElementById("panel-reopen");
2041
+ const panelResizer = document.getElementById("panel-resizer");
2042
+
2043
+ function setPanelCollapsed(collapsed) {
2044
+ document.body.classList.toggle("panel-collapsed", collapsed);
2045
+ if (panelToggle) panelToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
2046
+ }
2047
+ if (panelToggle) panelToggle.addEventListener("click", () => setPanelCollapsed(true));
2048
+ if (panelReopen) panelReopen.addEventListener("click", () => setPanelCollapsed(false));
2049
+
2050
+ if (panelResizer) {
2051
+ let resizeStart = null;
2052
+ panelResizer.addEventListener("pointerdown", (event) => {
2053
+ resizeStart = { x: event.clientX, width: panel.getBoundingClientRect().width };
2054
+ panelResizer.classList.add("dragging");
2055
+ panelResizer.setPointerCapture(event.pointerId);
2056
+ event.preventDefault();
2057
+ });
2058
+ panelResizer.addEventListener("pointermove", (event) => {
2059
+ if (!resizeStart) return;
2060
+ // The handle is on the panel's left edge, so dragging left widens it.
2061
+ const next = resizeStart.width - (event.clientX - resizeStart.x);
2062
+ const max = Math.max(320, window.innerWidth - 360);
2063
+ const clamped = Math.max(300, Math.min(max, next));
2064
+ document.documentElement.style.setProperty("--panel-width", clamped + "px");
2065
+ });
2066
+ const endResize = () => { resizeStart = null; panelResizer.classList.remove("dragging"); };
2067
+ panelResizer.addEventListener("pointerup", endResize);
2068
+ panelResizer.addEventListener("pointercancel", endResize);
2069
+ }
2070
+
1359
2071
  applyView("all");
1360
2072
  </script>
1361
2073
  </body>
@@ -1384,16 +2096,16 @@ function renderEdge(edge) {
1384
2096
  return "";
1385
2097
  }
1386
2098
  const d = points.map((point, index) => `${index === 0 ? "M" : "L"} ${point.x} ${point.y}`).join(" ");
1387
- return `<path class="edge ${edge.kind} ${edge.status}" d="${d}" data-edge-id="${escapeHtml(edge.id)}"></path>`;
2099
+ return `<path class="edge ${edge.kind} ${edge.status}" d="${d}" data-edge-id="${escapeHtml2(edge.id)}"></path>`;
1388
2100
  }
1389
2101
  function renderNode(node) {
1390
2102
  const lines = wrapLabel(node.label, 22);
1391
2103
  const lineHeight = 14;
1392
2104
  const textY = node.height / 2 - (lines.length - 1) * lineHeight / 2 + 5;
1393
- return `<g class="node ${node.status}" data-node-id="${escapeHtml(node.id)}" transform="translate(${node.x},${node.y})">
2105
+ return `<g class="node ${node.status} type-${node.type}" data-node-id="${escapeHtml2(node.id)}" transform="translate(${node.x},${node.y})">
1394
2106
  <rect width="${node.width}" height="${node.height}"></rect>
1395
2107
  <text x="${node.width / 2}" y="${textY}" text-anchor="middle" class="node-label">
1396
- ${lines.map((line, index) => `<tspan x="${node.width / 2}" dy="${index === 0 ? 0 : lineHeight}">${escapeHtml(line)}</tspan>`).join("")}
2108
+ ${lines.map((line, index) => `<tspan x="${node.width / 2}" dy="${index === 0 ? 0 : lineHeight}">${escapeHtml2(line)}</tspan>`).join("")}
1397
2109
  </text>
1398
2110
  </g>`;
1399
2111
  }
@@ -1415,7 +2127,7 @@ function wrapLabel(label, maxChars) {
1415
2127
  }
1416
2128
  return lines.length ? lines.slice(0, 3) : [label];
1417
2129
  }
1418
- function escapeHtml(value) {
2130
+ function escapeHtml2(value) {
1419
2131
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1420
2132
  }
1421
2133
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/flow-delta",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Semantic visual diff for Salesforce Flows",
6
6
  "license": "MIT",