@syntax-syllogism/flow-delta 0.1.2 → 0.2.1

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 +444 -39
  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
@@ -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,66 @@ 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 SECTION_SCHEMAS = {
884
+ decision: [{
885
+ name: "Outcomes",
886
+ paths: ["rules"],
887
+ render: "grouped-table",
888
+ groupLabelPath: "label",
889
+ innerArray: "conditions",
890
+ innerColumns: [
891
+ { path: "leftValueReference", label: "Resource" },
892
+ { path: "operator", label: "Operator" },
893
+ { path: "rightValue", label: "Value" }
894
+ ]
895
+ }],
896
+ assignment: [{
897
+ name: "Assignment Items",
898
+ paths: ["assignmentItems"],
899
+ render: "table",
900
+ columns: [
901
+ { path: "assignToReference", label: "Variable" },
902
+ { path: "operator", label: "Operator" },
903
+ { path: "value", label: "Value" }
904
+ ]
905
+ }],
906
+ recordCreate: [{ name: "Field Mappings", paths: ["inputAssignments"], render: "table", columns: MAPPING_COLUMNS }],
907
+ recordUpdate: [{ name: "Field Mappings", paths: ["inputAssignments"], render: "table", columns: MAPPING_COLUMNS }],
908
+ recordLookup: [{ name: "Filters", paths: ["filters"], render: "table", columns: FILTER_COLUMNS }],
909
+ recordDelete: [{ name: "Filters", paths: ["filters"], render: "table", columns: FILTER_COLUMNS }],
910
+ actionCall: [{ name: "Parameters", paths: ["inputParameters", "outputParameters"], render: "table", columns: PARAM_COLUMNS }],
911
+ apexPluginCall: [{ name: "Parameters", paths: ["inputParameters", "outputParameters"], render: "table", columns: PARAM_COLUMNS }],
912
+ wait: [{
913
+ name: "Wait Events",
914
+ paths: ["waitEvents"],
915
+ render: "table",
916
+ columns: [
917
+ { path: "label", label: "Label" },
918
+ { path: "offset", label: "Offset" },
919
+ { path: "offsetUnit", label: "Unit" },
920
+ { path: "resumeDateReference", label: "Resume Date" }
921
+ ]
922
+ }]
923
+ };
924
+ function getSectionSchemas(type) {
925
+ return SECTION_SCHEMAS[type] ?? [];
926
+ }
927
+
866
928
  // src/render/render-html.ts
867
929
  function renderHtml(layout) {
868
930
  const data = {
@@ -871,7 +933,8 @@ function renderHtml(layout) {
871
933
  edges: layout.edges,
872
934
  width: layout.width,
873
935
  height: layout.height,
874
- layouts: layout.views
936
+ layouts: layout.views,
937
+ sectionSchemas: Object.fromEntries(layout.diff.nodes.map((node) => [node.type, getSectionSchemas(node.type)]))
875
938
  };
876
939
  const json = JSON.stringify(data).replace(/</g, "\\u003c");
877
940
  const s = layout.diff.summary;
@@ -896,6 +959,7 @@ function renderHtml(layout) {
896
959
  --modified: #d97706; --modified-fill: #fef3c7;
897
960
  --unchanged: #94a3b8; --unchanged-fill: #e9edf3;
898
961
  --edge: #64748b; --fault: #7c3aed;
962
+ --panel-width: 34vw;
899
963
  }
900
964
  * { box-sizing: border-box; }
901
965
  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 +1002,8 @@ function renderHtml(layout) {
938
1002
  .legend .deleted::before { background: var(--deleted); }
939
1003
  .legend .modified::before { background: var(--modified); }
940
1004
  .legend .unchanged::before { background: var(--unchanged); }
941
- main { display: grid; grid-template-columns: minmax(0, 1fr) clamp(360px, 32vw, 520px); min-height: 0; }
1005
+ main { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) clamp(300px, var(--panel-width), 70vw); min-height: 0; }
1006
+ body.panel-collapsed main { grid-template-columns: minmax(0, 1fr) 0; }
942
1007
  .canvas { position: relative; overflow: hidden; }
943
1008
  svg {
944
1009
  width: 100%; height: 100%; display: block;
@@ -946,22 +1011,67 @@ function renderHtml(layout) {
946
1011
  radial-gradient(circle, #dfe5ee 1px, transparent 1.4px) -11px -11px / 22px 22px,
947
1012
  linear-gradient(#ffffff, #f7f9fc);
948
1013
  }
949
- .panel { min-width: 0; border-left: 1px solid var(--border); background: var(--surface); padding: 20px; overflow-y: auto; overflow-x: hidden; }
1014
+ .panel { position: relative; min-width: 0; border-left: 1px solid var(--border); background: var(--surface); padding: 20px; overflow-y: auto; overflow-x: hidden; }
1015
+ body.panel-collapsed .panel { padding: 0; border-left: 0; overflow: hidden; }
1016
+ body.panel-collapsed .panel > *:not(.panel-resizer) { display: none; }
1017
+ /* drag-to-resize handle on the panel's left edge */
1018
+ .panel-resizer { position: absolute; left: 0; top: 0; bottom: 0; width: 9px; transform: translateX(-50%); cursor: col-resize; z-index: 6; touch-action: none; }
1019
+ .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; }
1020
+ .panel-resizer:hover::before, .panel-resizer.dragging::before { background: #93c5fd; width: 3px; }
1021
+ body.panel-collapsed .panel-resizer { display: none; }
1022
+ .panel-head { display: flex; align-items: flex-start; gap: 8px; }
1023
+ .panel-head h2 { flex: 1; min-width: 0; }
1024
+ .panel-toggle, .panel-reopen { border: 1px solid var(--border); background: #f8fafc; color: var(--muted); border-radius: 7px; cursor: pointer; font: inherit; line-height: 1; }
1025
+ .panel-toggle { flex: none; width: 26px; height: 26px; font-size: 16px; display: grid; place-items: center; }
1026
+ .panel-toggle:hover, .panel-reopen:hover { color: var(--text); border-color: #93c5fd; background: #eff6ff; }
1027
+ .panel-toggle:focus-visible, .panel-reopen:focus-visible { outline: 2px solid #93c5fd; outline-offset: 2px; }
1028
+ /* floating tab to reopen the panel once collapsed */
1029
+ .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); }
1030
+ body.panel-collapsed .panel-reopen { display: inline-flex; align-items: center; gap: 6px; }
950
1031
  .panel h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
951
1032
  .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
1033
  .panel.added .badge { background: var(--added-fill); color: var(--added); }
953
1034
  .panel.deleted .badge { background: var(--deleted-fill); color: var(--deleted); }
954
1035
  .panel.modified .badge { background: var(--modified-fill); color: var(--modified); }
955
1036
  .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; }
1037
+ .detail-section { margin: 0 0 12px; border: 1px solid var(--border); border-radius: 8px; background: #fbfcfe; overflow: hidden; }
1038
+ .detail-section summary { cursor: pointer; padding: 11px 13px; font-weight: 650; }
1039
+ .detail-section[open] summary { border-bottom: 1px solid var(--border); }
1040
+ .section-count { color: var(--muted); font-size: 11px; font-weight: 500; }
1041
+ .section-body { padding: 12px; overflow-x: auto; }
1042
+ .change-kind { display: inline-block; padding: 2px 7px; border-radius: 999px; font-size: 10px; font-weight: 700; letter-spacing: 0.02em; }
1043
+ .change-kind.added { color: var(--added); background: var(--added-fill); }
1044
+ .change-kind.modified { color: var(--modified); background: var(--modified-fill); }
1045
+ .change-kind.removed { color: var(--deleted); background: var(--deleted-fill); }
1046
+ /* git-diff value grammar */
1047
+ .val { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; border-radius: 4px; padding: 1px 5px; overflow-wrap: anywhere; }
1048
+ .val.ins { color: #14532d; background: var(--added-fill); }
1049
+ .val.del { color: #7f1d1d; background: var(--deleted-fill); text-decoration: line-through; text-decoration-color: rgba(127, 29, 29, 0.5); }
1050
+ .val.faint { color: var(--muted); background: transparent; padding-left: 0; }
1051
+ .val em { font-style: normal; color: var(--faint); }
1052
+ .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; }
1053
+ .arrow { color: var(--faint); margin: 0 2px; }
1054
+ /* scalar / fallback change lines */
1055
+ .changes { list-style: none; padding: 0; margin: 0; }
1056
+ .changes li { min-width: 0; margin-bottom: 9px; }
1057
+ .changes li:last-child { margin-bottom: 0; }
1058
+ .change-line-head { display: flex; align-items: center; gap: 7px; margin-bottom: 4px; }
1059
+ .change-line-label { font-weight: 600; font-size: 12px; }
1060
+ .change-line-value { padding-left: 2px; }
1061
+ /* item tables (field mappings, conditions, filters, parameters) */
1062
+ .change-table { width: 100%; border-collapse: collapse; font-size: 12px; }
1063
+ .change-table th, .change-table td { padding: 7px 8px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; overflow-wrap: anywhere; }
1064
+ .change-table th { color: var(--muted); font-size: 10.5px; letter-spacing: 0.04em; text-transform: uppercase; }
1065
+ .change-table tr:last-child td { border-bottom: 0; }
1066
+ .change-table .row-idx { color: var(--faint); font-variant-numeric: tabular-nums; width: 1%; white-space: nowrap; }
1067
+ .change-table tr.row-added { background: rgba(22, 163, 74, 0.06); }
1068
+ .change-table tr.row-removed { background: rgba(220, 38, 38, 0.05); }
1069
+ /* outcome groups (decisions) */
1070
+ .outcome-group { margin-bottom: 12px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); }
1071
+ .outcome-group:last-child { margin-bottom: 0; }
1072
+ .group-head { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
1073
+ .group-label { font-weight: 700; font-size: 12.5px; }
1074
+ .outcome-group .changes { margin-bottom: 8px; }
965
1075
  .edge { fill: none; }
966
1076
  .edge.unchanged { stroke-width: 1.4; opacity: 0.55; }
967
1077
  .edge.normal.unchanged { stroke: var(--edge); marker-end: url(#arrow-normal); }
@@ -981,8 +1091,18 @@ function renderHtml(layout) {
981
1091
  .node.unchanged rect { fill: var(--unchanged-fill); stroke: var(--unchanged); }
982
1092
  .node.selected rect { stroke-width: 3; filter: drop-shadow(0 2px 6px rgba(15, 23, 42, 0.18)); }
983
1093
  .node-label { white-space: pre; }
1094
+ /* Type-based styling \u2014 SHAPE & stroke pattern only. Status (added/deleted/
1095
+ modified/unchanged) remains the sole authority on fill/stroke COLOR, so
1096
+ the legend stays reliable. Start/end stand out via a pill shape, heavier
1097
+ stroke, and a neutral emphasis halo \u2014 never via a status-like color. */
1098
+ .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)); }
1099
+ .node.type-recordLookup rect, .node.type-recordCreate rect, .node.type-recordUpdate rect, .node.type-recordDelete rect { rx: 4; ry: 4; }
1100
+ .node.type-screen rect { rx: 6; ry: 6; }
1101
+ .node.type-subflow rect { stroke-width: 2.5; }
1102
+ .node.type-loop rect { stroke-dasharray: 2 3; }
1103
+ .node.type-wait rect { stroke-dasharray: 4 4; }
1104
+ .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
1105
  @media (max-width: 1000px) {
985
- main { grid-template-columns: minmax(0, 1fr) minmax(320px, 42vw); }
986
1106
  .panel { padding: 16px; }
987
1107
  }
988
1108
  </style>
@@ -1032,10 +1152,15 @@ function renderHtml(layout) {
1032
1152
  </svg>
1033
1153
  </div>
1034
1154
  <aside class="panel">
1035
- <h2 id="panel-title">Select a node</h2>
1155
+ <div class="panel-resizer" id="panel-resizer" role="separator" aria-orientation="vertical" aria-label="Resize panel"></div>
1156
+ <div class="panel-head">
1157
+ <h2 id="panel-title">Select a node</h2>
1158
+ <button type="button" id="panel-toggle" class="panel-toggle" aria-label="Collapse panel" title="Collapse panel">\u203A</button>
1159
+ </div>
1036
1160
  <div id="panel-badge" class="badge">No selection</div>
1037
1161
  <div id="panel-body">Click a node to inspect its properties.</div>
1038
1162
  </aside>
1163
+ <button type="button" id="panel-reopen" class="panel-reopen" aria-label="Show details panel" title="Show details">\u2039 Details</button>
1039
1164
  </main>
1040
1165
  <script>
1041
1166
  const DATA = ${json};
@@ -1188,7 +1313,7 @@ function renderHtml(layout) {
1188
1313
  panelBadge.textContent = node.status.toUpperCase();
1189
1314
  const changes = node.changes || [];
1190
1315
  panelBody.innerHTML = changes.length
1191
- ? "<ul class='changes'>" + changes.map(formatChange).join("") + "</ul>"
1316
+ ? organizeChanges(node).map((section) => formatSection(section, node)).join("")
1192
1317
  : node.status === "added"
1193
1318
  ? "<div class='empty'>This node was added in the new version.</div>"
1194
1319
  : node.status === "deleted"
@@ -1196,6 +1321,228 @@ function renderHtml(layout) {
1196
1321
  : "<div class='empty'>No property changes.</div>";
1197
1322
  }
1198
1323
 
1324
+ function owns(prefix, path) {
1325
+ return path === prefix || path.startsWith(prefix + ".") || path.startsWith(prefix + "[");
1326
+ }
1327
+
1328
+ function changeKind(change) {
1329
+ return change.before === undefined ? "added" : change.after === undefined ? "removed" : "modified";
1330
+ }
1331
+
1332
+ function capitalize(value) {
1333
+ return value.charAt(0).toUpperCase() + value.slice(1);
1334
+ }
1335
+
1336
+ // Parse "base[idx]rest" \u2014 returns the item index and trailing path, or null.
1337
+ function matchItem(path, base) {
1338
+ if (!path.startsWith(base + "[")) return null;
1339
+ const close = path.indexOf("]", base.length + 1);
1340
+ if (close === -1) return null;
1341
+ const idx = path.slice(base.length + 1, close);
1342
+ if (!/^\\d+$/.test(idx)) return null;
1343
+ return { idx, rest: path.slice(close + 1) };
1344
+ }
1345
+
1346
+ // Group a node's flat change list into ordered sections: schema-driven first,
1347
+ // then a generic fallback (array-prefixed groups, then a Configuration bucket).
1348
+ function organizeChanges(node) {
1349
+ const schemas = DATA.sectionSchemas[node.type] || [];
1350
+ const sections = schemas.map((schema) => ({ schema, changes: [] }));
1351
+ const fallback = new Map();
1352
+ for (const change of node.changes || []) {
1353
+ const sec = sections.find((s) => s.schema.paths.some((path) => owns(path, change.path)));
1354
+ if (sec) { sec.changes.push(change); continue; }
1355
+ const match = change.path.match(/^([^.[]+)(\\[\\d+\\])?/);
1356
+ const isArray = !!(match && match[2] !== undefined);
1357
+ const key = isArray ? match[1] : "Configuration";
1358
+ if (!fallback.has(key)) fallback.set(key, []);
1359
+ fallback.get(key).push(change);
1360
+ }
1361
+ const out = [];
1362
+ for (const s of sections) {
1363
+ if (s.changes.length) out.push({ name: s.schema.name, schema: s.schema, changes: s.changes });
1364
+ }
1365
+ for (const [key, changes] of fallback) {
1366
+ out.push({
1367
+ name: key === "Configuration" ? "Configuration" : humanizePath(key),
1368
+ schema: { render: "lines", paths: [key] },
1369
+ changes,
1370
+ });
1371
+ }
1372
+ return out;
1373
+ }
1374
+
1375
+ function formatSection(section, node) {
1376
+ const body = renderSectionBody(section, node);
1377
+ return "<details class='detail-section' open><summary>" + escapeHtml(section.name)
1378
+ + " <span class='section-count'>(" + section.changes.length + ")</span></summary>"
1379
+ + "<div class='section-body'>" + body + "</div></details>";
1380
+ }
1381
+
1382
+ function renderSectionBody(section, node) {
1383
+ const render = section.schema.render;
1384
+ if (render === "grouped-table") return renderGroupedTable(section, node);
1385
+ if (render === "table" && section.schema.columns) {
1386
+ return renderTable(section.changes, section.schema.paths, section.schema.columns, { before: node.before, after: node.after, prefix: "" });
1387
+ }
1388
+ return renderLines(section.changes, {});
1389
+ }
1390
+
1391
+ // Labeled before \u2192 after lines, for scalar changes and the generic fallback.
1392
+ // The plain option drops per-line badges \u2014 used under a wholly added/removed
1393
+ // outcome, where the single group badge already states the change (Finding 3).
1394
+ function renderLines(changes, options) {
1395
+ const plain = options && options.plain;
1396
+ return "<ul class='changes'>" + changes.map((change) => {
1397
+ const kind = changeKind(change);
1398
+ const badge = plain ? "" : "<span class='change-kind " + kind + "'>" + capitalize(kind) + "</span>";
1399
+ return "<li><div class='change-line-head'>" + badge
1400
+ + "<span class='change-line-label'>" + escapeHtml(humanizePath(change.path)) + "</span></div>"
1401
+ + "<div class='change-line-value'>" + diffValue(change.before, change.after) + "</div></li>";
1402
+ }).join("") + "</ul>";
1403
+ }
1404
+
1405
+ // Walk a path like "rules[0].conditions[2]" into an object.
1406
+ function resolvePath(obj, path) {
1407
+ if (obj == null || !path) return undefined;
1408
+ let cur = obj;
1409
+ const tokens = path.match(/[^.[\\]]+|\\[\\d+\\]/g) || [];
1410
+ for (const token of tokens) {
1411
+ if (cur == null || typeof cur !== "object") return undefined;
1412
+ cur = token[0] === "[" ? cur[Number(token.slice(1, -1))] : cur[token];
1413
+ }
1414
+ return cur;
1415
+ }
1416
+
1417
+ // Collapse arr[i].* leaf changes (and whole-item changes) into one row per
1418
+ // item index, then fill any unchanged columns from the item snapshot so a
1419
+ // modified row still shows its sibling context (Resource/Operator/etc.).
1420
+ function buildRows(changes, basePaths, columns, ctx) {
1421
+ const rows = new Map();
1422
+ let order = 0;
1423
+ for (const change of changes) {
1424
+ let base = null, idx = null, rest = "";
1425
+ for (const p of basePaths) {
1426
+ const m = matchItem(change.path, p);
1427
+ if (m) { base = p; idx = m.idx; rest = m.rest; break; }
1428
+ }
1429
+ if (base === null) continue;
1430
+ const itemKey = base + "[" + idx + "]";
1431
+ if (!rows.has(itemKey)) rows.set(itemKey, { kind: "modified", order: order++, idx, base, cells: {} });
1432
+ const row = rows.get(itemKey);
1433
+ if (rest === "") {
1434
+ row.kind = changeKind(change);
1435
+ for (const col of columns) {
1436
+ const before = extractField(change.before, col.path);
1437
+ const after = extractField(change.after, col.path);
1438
+ row.cells[col.path] = { before, after, changed: !valuesEqual(before, after) };
1439
+ }
1440
+ continue;
1441
+ }
1442
+ const sub = rest.replace(/^\\./, "");
1443
+ const col = columns.find((c) => sub === c.path || sub.startsWith(c.path + ".") || sub.startsWith(c.path + "["));
1444
+ if (col) row.cells[col.path] = { before: unwrapValue(change.before), after: unwrapValue(change.after), changed: true };
1445
+ }
1446
+ const result = [...rows.values()].sort((a, b) => a.order - b.order);
1447
+ if (ctx) {
1448
+ for (const row of result) {
1449
+ const abs = ctx.prefix ? ctx.prefix + "." + row.base + "[" + row.idx + "]" : row.base + "[" + row.idx + "]";
1450
+ const snapshot = row.kind === "removed" ? resolvePath(ctx.before, abs) : resolvePath(ctx.after, abs);
1451
+ if (!snapshot || typeof snapshot !== "object") continue;
1452
+ for (const col of columns) {
1453
+ if (row.cells[col.path]) continue;
1454
+ const value = extractField(snapshot, col.path);
1455
+ if (value !== undefined) row.cells[col.path] = { before: value, after: value, changed: false };
1456
+ }
1457
+ }
1458
+ }
1459
+ return result;
1460
+ }
1461
+
1462
+ // options.uniformKind (a wholly added/removed group): drop the Change
1463
+ // column and per-row badges, colouring every cell by that single kind.
1464
+ function renderTable(changes, basePaths, columns, ctx, options) {
1465
+ const uniform = options && options.uniformKind;
1466
+ const rows = buildRows(changes, basePaths, columns, ctx);
1467
+ if (!rows.length) return renderLines(changes, {});
1468
+ const head = "<tr><th class='row-idx'>#</th>" + columns.map((c) => "<th>" + escapeHtml(c.label) + "</th>").join("")
1469
+ + (uniform ? "" : "<th>Change</th>") + "</tr>";
1470
+ const body = rows.map((row) => renderRow(row, columns, uniform)).join("");
1471
+ return "<table class='change-table'><thead>" + head + "</thead><tbody>" + body + "</tbody></table>";
1472
+ }
1473
+
1474
+ function renderRow(row, columns, uniform) {
1475
+ const kind = uniform || row.kind;
1476
+ const cells = columns.map((col) => {
1477
+ const cell = row.cells[col.path];
1478
+ if (!cell) return "<td></td>";
1479
+ if (kind === "added") return "<td>" + insVal(cell.after !== undefined ? cell.after : cell.before) + "</td>";
1480
+ if (kind === "removed") return "<td>" + delVal(cell.before !== undefined ? cell.before : cell.after) + "</td>";
1481
+ return "<td>" + (cell.changed ? diffValue(cell.before, cell.after) : faintVal(cell.after)) + "</td>";
1482
+ }).join("");
1483
+ const badge = uniform ? "" : "<td><span class='change-kind " + row.kind + "'>" + capitalize(row.kind) + "</span></td>";
1484
+ return "<tr class='row-" + kind + "'><td class='row-idx'>" + (Number(row.idx) + 1) + "</td>" + cells + badge + "</tr>";
1485
+ }
1486
+
1487
+ // Decisions et al.: group by the outer array (one group per outcome/rule).
1488
+ // A wholly added/removed outcome shows ONE group badge + plain context; a
1489
+ // modified outcome shows its changed conditions in a per-row-badged table.
1490
+ function renderGroupedTable(section, node) {
1491
+ const schema = section.schema;
1492
+ const groups = new Map();
1493
+ let order = 0;
1494
+ for (const change of section.changes) {
1495
+ let matched = null;
1496
+ for (const p of schema.paths) {
1497
+ const m = matchItem(change.path, p);
1498
+ if (m) { matched = { base: p, idx: m.idx, rest: m.rest }; break; }
1499
+ }
1500
+ if (!matched) continue;
1501
+ const key = matched.idx;
1502
+ if (!groups.has(key)) groups.set(key, { idx: matched.idx, base: matched.base, order: order++, label: null, kind: "modified", whole: false, inner: [], scalars: [] });
1503
+ const g = groups.get(key);
1504
+ if (matched.rest === "") {
1505
+ g.kind = changeKind(change);
1506
+ g.whole = true;
1507
+ const obj = change.after !== undefined ? change.after : change.before;
1508
+ if (obj && typeof obj === "object") {
1509
+ if (schema.groupLabelPath && obj[schema.groupLabelPath]) g.label = obj[schema.groupLabelPath];
1510
+ const inner = obj[schema.innerArray];
1511
+ if (Array.isArray(inner)) {
1512
+ inner.forEach((item, j) => g.inner.push({
1513
+ path: schema.innerArray + "[" + j + "]",
1514
+ before: g.kind === "removed" ? item : undefined,
1515
+ after: g.kind === "removed" ? undefined : item,
1516
+ }));
1517
+ }
1518
+ for (const k of Object.keys(obj)) {
1519
+ if (k === schema.innerArray || k === schema.groupLabelPath) continue;
1520
+ g.scalars.push({ path: k, before: g.kind === "removed" ? obj[k] : undefined, after: g.kind === "removed" ? undefined : obj[k] });
1521
+ }
1522
+ }
1523
+ continue;
1524
+ }
1525
+ const sub = matched.rest.replace(/^\\./, "");
1526
+ if (sub === schema.groupLabelPath) g.label = change.after !== undefined ? change.after : change.before;
1527
+ if (sub === schema.innerArray || sub.startsWith(schema.innerArray + "[")) {
1528
+ g.inner.push({ path: sub, before: change.before, after: change.after });
1529
+ } else {
1530
+ g.scalars.push({ path: sub, before: change.before, after: change.after });
1531
+ }
1532
+ }
1533
+ return [...groups.values()].sort((a, b) => a.order - b.order).map((g) => {
1534
+ const label = g.label != null ? String(g.label) : "Outcome " + (Number(g.idx) + 1);
1535
+ const head = "<div class='group-head'><span class='change-kind " + g.kind + "'>" + capitalize(g.kind) + "</span>"
1536
+ + "<span class='group-label'>" + escapeHtml(label) + "</span></div>";
1537
+ const prefix = g.base + "[" + g.idx + "]";
1538
+ const scalars = g.scalars.length ? renderLines(g.scalars, { plain: g.whole }) : "";
1539
+ const inner = g.inner.length
1540
+ ? renderTable(g.inner, [schema.innerArray], schema.innerColumns, { before: node.before, after: node.after, prefix }, g.whole ? { uniformKind: g.kind } : {})
1541
+ : "";
1542
+ return "<div class='outcome-group'>" + head + scalars + inner + "</div>";
1543
+ }).join("");
1544
+ }
1545
+
1199
1546
  function selectNode(id) {
1200
1547
  selectedNodeId = id;
1201
1548
  document.querySelectorAll(".node").forEach((el) => el.classList.remove("selected"));
@@ -1205,24 +1552,6 @@ function renderHtml(layout) {
1205
1552
  if (node) updatePanel(node);
1206
1553
  }
1207
1554
 
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
1555
  function humanizePath(path) {
1227
1556
  const names = {
1228
1557
  assignmentItems: "Assignment item",
@@ -1237,11 +1566,54 @@ function renderHtml(layout) {
1237
1566
  }).join(" \u203A ");
1238
1567
  }
1239
1568
 
1240
- function formatValue(value) {
1241
- if (value === undefined) return "<em>Not present</em>";
1242
- if (value === null) return "<em>Empty value</em>";
1569
+ // Collapse Salesforce typed-value wrappers to their scalar so reviewers see
1570
+ // "true" / "Something Else" / "{!myVar}" instead of a JSON blob.
1571
+ function unwrapValue(value) {
1572
+ if (value === null || value === undefined || typeof value !== "object" || Array.isArray(value)) return value;
1573
+ if ("elementReference" in value) return "{!" + value.elementReference + "}";
1574
+ const keys = Object.keys(value);
1575
+ if (keys.length === 1) {
1576
+ const key = keys[0];
1577
+ if (key === "stringValue" || key === "numberValue" || key === "dateValue" || key === "dateTimeValue") return value[key];
1578
+ if (key === "booleanValue") return String(value[key]) === "true" ? "true" : "false";
1579
+ }
1580
+ return value;
1581
+ }
1582
+
1583
+ function extractField(obj, path) {
1584
+ if (obj === undefined || obj === null) return undefined;
1585
+ let cur = obj;
1586
+ for (const segment of path.split(".")) {
1587
+ if (cur === null || typeof cur !== "object") return undefined;
1588
+ cur = cur[segment];
1589
+ }
1590
+ return unwrapValue(cur);
1591
+ }
1592
+
1593
+ function valuesEqual(a, b) {
1594
+ if (a === b) return true;
1595
+ if (a && b && typeof a === "object" && typeof b === "object") return JSON.stringify(a) === JSON.stringify(b);
1596
+ return false;
1597
+ }
1598
+
1599
+ function renderScalar(value) {
1600
+ if (value === undefined) return "<em>\u2014</em>";
1601
+ if (value === null) return "<em>empty</em>";
1243
1602
  if (typeof value === "object") return "<pre>" + escapeHtml(JSON.stringify(value, null, 2)) + "</pre>";
1244
- return "<code>" + escapeHtml(String(value)) + "</code>";
1603
+ return escapeHtml(String(value));
1604
+ }
1605
+
1606
+ function insVal(value) { return "<span class='val ins'>" + renderScalar(value) + "</span>"; }
1607
+ function delVal(value) { return "<span class='val del'>" + renderScalar(value) + "</span>"; }
1608
+ function faintVal(value) { return "<span class='val faint'>" + renderScalar(value) + "</span>"; }
1609
+
1610
+ // git-diff value grammar: removed (red strike) \u2192 added (green).
1611
+ function diffValue(before, after) {
1612
+ before = unwrapValue(before);
1613
+ after = unwrapValue(after);
1614
+ if (before === undefined) return insVal(after);
1615
+ if (after === undefined) return delVal(before);
1616
+ return delVal(before) + " <span class='arrow'>\u2192</span> " + insVal(after);
1245
1617
  }
1246
1618
 
1247
1619
  function pickInitialNode(visibleNodeIds) {
@@ -1356,6 +1728,39 @@ function renderHtml(layout) {
1356
1728
  });
1357
1729
  });
1358
1730
 
1731
+ // Panel: collapse to focus on the canvas, and drag the left edge to resize.
1732
+ const panelToggle = document.getElementById("panel-toggle");
1733
+ const panelReopen = document.getElementById("panel-reopen");
1734
+ const panelResizer = document.getElementById("panel-resizer");
1735
+
1736
+ function setPanelCollapsed(collapsed) {
1737
+ document.body.classList.toggle("panel-collapsed", collapsed);
1738
+ if (panelToggle) panelToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
1739
+ }
1740
+ if (panelToggle) panelToggle.addEventListener("click", () => setPanelCollapsed(true));
1741
+ if (panelReopen) panelReopen.addEventListener("click", () => setPanelCollapsed(false));
1742
+
1743
+ if (panelResizer) {
1744
+ let resizeStart = null;
1745
+ panelResizer.addEventListener("pointerdown", (event) => {
1746
+ resizeStart = { x: event.clientX, width: panel.getBoundingClientRect().width };
1747
+ panelResizer.classList.add("dragging");
1748
+ panelResizer.setPointerCapture(event.pointerId);
1749
+ event.preventDefault();
1750
+ });
1751
+ panelResizer.addEventListener("pointermove", (event) => {
1752
+ if (!resizeStart) return;
1753
+ // The handle is on the panel's left edge, so dragging left widens it.
1754
+ const next = resizeStart.width - (event.clientX - resizeStart.x);
1755
+ const max = Math.max(320, window.innerWidth - 360);
1756
+ const clamped = Math.max(300, Math.min(max, next));
1757
+ document.documentElement.style.setProperty("--panel-width", clamped + "px");
1758
+ });
1759
+ const endResize = () => { resizeStart = null; panelResizer.classList.remove("dragging"); };
1760
+ panelResizer.addEventListener("pointerup", endResize);
1761
+ panelResizer.addEventListener("pointercancel", endResize);
1762
+ }
1763
+
1359
1764
  applyView("all");
1360
1765
  </script>
1361
1766
  </body>
@@ -1390,7 +1795,7 @@ function renderNode(node) {
1390
1795
  const lines = wrapLabel(node.label, 22);
1391
1796
  const lineHeight = 14;
1392
1797
  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})">
1798
+ return `<g class="node ${node.status} type-${node.type}" data-node-id="${escapeHtml(node.id)}" transform="translate(${node.x},${node.y})">
1394
1799
  <rect width="${node.width}" height="${node.height}"></rect>
1395
1800
  <text x="${node.width / 2}" y="${textY}" text-anchor="middle" class="node-label">
1396
1801
  ${lines.map((line, index) => `<tspan x="${node.width / 2}" dy="${index === 0 ? 0 : lineHeight}">${escapeHtml(line)}</tspan>`).join("")}
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.2.1",
4
4
  "type": "module",
5
5
  "description": "Semantic visual diff for Salesforce Flows",
6
6
  "license": "MIT",