@syntax-syllogism/flow-delta 0.1.1 → 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 +745 -63
  3. package/package.json +4 -4
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) {
@@ -766,7 +768,38 @@ function toEdgeDiff(edge, status) {
766
768
  // src/render/layout.ts
767
769
  import ELK from "elkjs/lib/elk.bundled.js";
768
770
  async function layoutDiff(diff) {
769
- const elk = new ELK();
771
+ const union = await layoutView(diff, () => true, () => true);
772
+ const after = await layoutView(
773
+ diff,
774
+ (node) => node.status !== "deleted",
775
+ (edge) => edge.status !== "deleted"
776
+ );
777
+ const before = await layoutView(
778
+ diff,
779
+ (node) => node.status !== "added",
780
+ (edge) => edge.status !== "added"
781
+ );
782
+ return {
783
+ diff,
784
+ nodes: union.nodes,
785
+ edges: union.edges,
786
+ width: union.width,
787
+ height: union.height,
788
+ views: {
789
+ union,
790
+ after,
791
+ before
792
+ }
793
+ };
794
+ }
795
+ var elk = new ELK();
796
+ async function layoutView(diff, nodeVisible, edgeVisible) {
797
+ const nodes = diff.nodes.filter(nodeVisible);
798
+ const nodeIds = new Set(nodes.map((node) => node.id));
799
+ const edges = diff.edges.filter((edge) => edgeVisible(edge) && nodeIds.has(edge.source) && nodeIds.has(edge.target));
800
+ if (nodes.length === 0) {
801
+ return { nodes: [], edges: [], width: 40, height: 40 };
802
+ }
770
803
  const graph = {
771
804
  id: "root",
772
805
  layoutOptions: {
@@ -775,12 +808,12 @@ async function layoutDiff(diff) {
775
808
  "elk.layered.spacing.nodeNodeBetweenLayers": 60,
776
809
  "elk.spacing.nodeNode": 40
777
810
  },
778
- children: diff.nodes.map((node) => ({
811
+ children: nodes.map((node) => ({
779
812
  id: node.id,
780
813
  width: estimateWidth(node.label),
781
814
  height: 48
782
815
  })),
783
- edges: diff.edges.map((edge) => ({
816
+ edges: edges.map((edge) => ({
784
817
  id: edge.id,
785
818
  sources: [edge.source],
786
819
  targets: [edge.target]
@@ -788,10 +821,10 @@ async function layoutDiff(diff) {
788
821
  };
789
822
  const laidOut = await elk.layout(graph);
790
823
  const children = laidOut.children ?? [];
791
- const edges = laidOut.edges ?? [];
824
+ const edgeLayouts = laidOut.edges ?? [];
792
825
  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) => {
826
+ const edgeMap = new Map(edgeLayouts.map((edge) => [edge.id, edge]));
827
+ const positionedNodes = nodes.map((node) => {
795
828
  const positioned = nodeMap.get(node.id);
796
829
  return {
797
830
  ...node,
@@ -801,14 +834,13 @@ async function layoutDiff(diff) {
801
834
  height: positioned?.height ?? 48
802
835
  };
803
836
  });
804
- const positionedEdges = diff.edges.map((edge) => ({
837
+ const positionedEdges = edges.map((edge) => ({
805
838
  ...edge,
806
839
  sections: normalizeSections(edgeMap.get(edge.id)?.sections)
807
840
  }));
808
- const bounds = measureBounds(nodes);
841
+ const bounds = measureBounds(positionedNodes);
809
842
  return {
810
- diff,
811
- nodes,
843
+ nodes: positionedNodes,
812
844
  edges: positionedEdges,
813
845
  width: bounds.width,
814
846
  height: bounds.height
@@ -833,6 +865,66 @@ function measureBounds(nodes) {
833
865
  return { width: maxX + 40, height: maxY + 40 };
834
866
  }
835
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
+
836
928
  // src/render/render-html.ts
837
929
  function renderHtml(layout) {
838
930
  const data = {
@@ -840,7 +932,9 @@ function renderHtml(layout) {
840
932
  nodes: layout.nodes,
841
933
  edges: layout.edges,
842
934
  width: layout.width,
843
- height: layout.height
935
+ height: layout.height,
936
+ layouts: layout.views,
937
+ sectionSchemas: Object.fromEntries(layout.diff.nodes.map((node) => [node.type, getSectionSchemas(node.type)]))
844
938
  };
845
939
  const json = JSON.stringify(data).replace(/</g, "\\u003c");
846
940
  const s = layout.diff.summary;
@@ -865,6 +959,7 @@ function renderHtml(layout) {
865
959
  --modified: #d97706; --modified-fill: #fef3c7;
866
960
  --unchanged: #94a3b8; --unchanged-fill: #e9edf3;
867
961
  --edge: #64748b; --fault: #7c3aed;
962
+ --panel-width: 34vw;
868
963
  }
869
964
  * { box-sizing: border-box; }
870
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; }
@@ -877,13 +972,38 @@ function renderHtml(layout) {
877
972
  header .meta .deleted:not(.zero) { color: var(--deleted); }
878
973
  header .meta .modified:not(.zero) { color: var(--modified); }
879
974
  header .meta .sep { color: var(--border); }
975
+ .header-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 10px; }
976
+ .filters { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
977
+ .filters button {
978
+ border: 1px solid var(--border);
979
+ background: #f8fafc;
980
+ color: var(--muted);
981
+ padding: 6px 11px;
982
+ border-radius: 999px;
983
+ font: inherit;
984
+ font-size: 12px;
985
+ font-weight: 600;
986
+ cursor: pointer;
987
+ transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
988
+ }
989
+ .filters button.active {
990
+ background: #eff6ff;
991
+ border-color: #93c5fd;
992
+ color: var(--text);
993
+ box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12);
994
+ }
995
+ .filters button:focus-visible {
996
+ outline: 2px solid #93c5fd;
997
+ outline-offset: 2px;
998
+ }
880
999
  .legend { display: flex; gap: 14px; font-size: 12px; flex-wrap: wrap; color: var(--muted); }
881
1000
  .legend span::before { content: ""; display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
882
1001
  .legend .added::before { background: var(--added); }
883
1002
  .legend .deleted::before { background: var(--deleted); }
884
1003
  .legend .modified::before { background: var(--modified); }
885
1004
  .legend .unchanged::before { background: var(--unchanged); }
886
- 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; }
887
1007
  .canvas { position: relative; overflow: hidden; }
888
1008
  svg {
889
1009
  width: 100%; height: 100%; display: block;
@@ -891,27 +1011,77 @@ function renderHtml(layout) {
891
1011
  radial-gradient(circle, #dfe5ee 1px, transparent 1.4px) -11px -11px / 22px 22px,
892
1012
  linear-gradient(#ffffff, #f7f9fc);
893
1013
  }
894
- .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; }
895
1031
  .panel h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
896
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; }
897
1033
  .panel.added .badge { background: var(--added-fill); color: var(--added); }
898
1034
  .panel.deleted .badge { background: var(--deleted-fill); color: var(--deleted); }
899
1035
  .panel.modified .badge { background: var(--modified-fill); color: var(--modified); }
900
1036
  .panel .empty { color: var(--muted); font-size: 13px; }
901
- .changes { list-style: none; padding: 0; margin: 4px 0 0; }
902
- .changes li { min-width: 0; margin-bottom: 12px; padding: 13px 14px; border: 1px solid var(--border); border-radius: 8px; background: #fbfcfe; }
903
- .change-title { font-weight: 600; margin-bottom: 5px; }
904
- .change-path { margin-bottom: 12px; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
905
- .change-values { display: grid; gap: 10px; min-width: 0; }
906
- .change-value { min-width: 0; }
907
- .change-value-label { margin-bottom: 4px; color: #475569; font-size: 10.5px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; }
908
- .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
- .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); }
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; }
1075
+ .edge { fill: none; }
1076
+ .edge.unchanged { stroke-width: 1.4; opacity: 0.55; }
1077
+ .edge.normal.unchanged { stroke: var(--edge); marker-end: url(#arrow-normal); }
1078
+ .edge.fault.unchanged { stroke: var(--fault); stroke-dasharray: 6 4; marker-end: url(#arrow-fault); }
1079
+ .edge.added { stroke: var(--added); stroke-width: 3; marker-end: url(#arrow-added); }
1080
+ .edge.deleted { stroke: var(--deleted); stroke-width: 2; stroke-dasharray: 7 5; marker-end: url(#arrow-deleted); }
913
1081
  #arrow-normal path { fill: var(--edge); }
914
1082
  #arrow-fault path { fill: var(--fault); }
1083
+ #arrow-added path { fill: var(--added); }
1084
+ #arrow-deleted path { fill: var(--deleted); }
915
1085
  .node rect { rx: 11; ry: 11; stroke-width: 1.75; filter: drop-shadow(0 1px 2px rgba(15, 23, 42, 0.08)); }
916
1086
  .node { cursor: pointer; }
917
1087
  .node text { font-size: 12px; fill: #111827; pointer-events: none; }
@@ -921,8 +1091,18 @@ function renderHtml(layout) {
921
1091
  .node.unchanged rect { fill: var(--unchanged-fill); stroke: var(--unchanged); }
922
1092
  .node.selected rect { stroke-width: 3; filter: drop-shadow(0 2px 6px rgba(15, 23, 42, 0.18)); }
923
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)); }
924
1105
  @media (max-width: 1000px) {
925
- main { grid-template-columns: minmax(0, 1fr) minmax(320px, 42vw); }
926
1106
  .panel { padding: 16px; }
927
1107
  }
928
1108
  </style>
@@ -933,11 +1113,19 @@ function renderHtml(layout) {
933
1113
  <h1>${escapeHtml(layout.diff.flowName)}</h1>
934
1114
  <div class="meta">${metaHtml}</div>
935
1115
  </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>
1116
+ <div class="header-actions">
1117
+ <div class="filters" role="group" aria-label="Diff view filters">
1118
+ <button type="button" class="active" data-view-mode="all">All</button>
1119
+ <button type="button" data-view-mode="after">After</button>
1120
+ <button type="button" data-view-mode="before">Before</button>
1121
+ <button type="button" data-view-mode="changes">Changes only</button>
1122
+ </div>
1123
+ <div class="legend">
1124
+ <span class="added">Added</span>
1125
+ <span class="deleted">Deleted</span>
1126
+ <span class="modified">Modified</span>
1127
+ <span class="unchanged">Unchanged</span>
1128
+ </div>
941
1129
  </div>
942
1130
  </header>
943
1131
  <main>
@@ -950,6 +1138,12 @@ function renderHtml(layout) {
950
1138
  <marker id="arrow-fault" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
951
1139
  <path d="M0,0 L8,4 L0,8 Z"></path>
952
1140
  </marker>
1141
+ <marker id="arrow-added" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
1142
+ <path d="M0,0 L8,4 L0,8 Z"></path>
1143
+ </marker>
1144
+ <marker id="arrow-deleted" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
1145
+ <path d="M0,0 L8,4 L0,8 Z"></path>
1146
+ </marker>
953
1147
  </defs>
954
1148
  <g id="viewport">
955
1149
  ${layout.edges.map(renderEdge).join("")}
@@ -958,10 +1152,15 @@ function renderHtml(layout) {
958
1152
  </svg>
959
1153
  </div>
960
1154
  <aside class="panel">
961
- <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>
962
1160
  <div id="panel-badge" class="badge">No selection</div>
963
1161
  <div id="panel-body">Click a node to inspect its properties.</div>
964
1162
  </aside>
1163
+ <button type="button" id="panel-reopen" class="panel-reopen" aria-label="Show details panel" title="Show details">\u2039 Details</button>
965
1164
  </main>
966
1165
  <script>
967
1166
  const DATA = ${json};
@@ -971,9 +1170,13 @@ function renderHtml(layout) {
971
1170
  const panelTitle = document.getElementById("panel-title");
972
1171
  const panelBadge = document.getElementById("panel-badge");
973
1172
  const panelBody = document.getElementById("panel-body");
1173
+ const filterButtons = [...document.querySelectorAll(".filters button")];
974
1174
  const nodesById = new Map(DATA.nodes.map((node) => [node.id, node]));
1175
+ const nodeElements = new Map([...document.querySelectorAll(".node")].map((node) => [node.dataset.nodeId, node]));
1176
+ const edgeElements = new Map([...document.querySelectorAll(".edge")].map((edge) => [edge.dataset.edgeId, edge]));
975
1177
  let viewBox = svg.viewBox.baseVal;
976
1178
  let dragStart = null;
1179
+ let selectedNodeId = null;
977
1180
 
978
1181
  function escapeHtml(value) {
979
1182
  return String(value)
@@ -983,18 +1186,134 @@ function renderHtml(layout) {
983
1186
  .replace(/"/g, "&quot;");
984
1187
  }
985
1188
 
986
- function selectNode(id) {
1189
+ function isNodeVisible(node, mode) {
1190
+ if (mode === "after") return node.status !== "deleted";
1191
+ if (mode === "before") return node.status !== "added";
1192
+ if (mode === "changes") return node.status !== "unchanged";
1193
+ return true;
1194
+ }
1195
+
1196
+ function isEdgeVisible(edge, mode, visibleNodeIds) {
1197
+ if (!visibleNodeIds.has(edge.source) || !visibleNodeIds.has(edge.target)) {
1198
+ return false;
1199
+ }
1200
+ if (mode === "after") return edge.status !== "deleted";
1201
+ if (mode === "before") return edge.status !== "added";
1202
+ if (mode === "changes") {
1203
+ const source = nodesById.get(edge.source);
1204
+ const target = nodesById.get(edge.target);
1205
+ const sourceChanged = source?.status !== "unchanged";
1206
+ const targetChanged = target?.status !== "unchanged";
1207
+ return edge.status !== "unchanged" || (sourceChanged && targetChanged);
1208
+ }
1209
+ return true;
1210
+ }
1211
+
1212
+ function edgePath(sections) {
1213
+ const points = sections.flatMap((section, index) => {
1214
+ const start = index === 0 ? [section.startPoint] : [];
1215
+ const bends = section.bendPoints ?? [];
1216
+ const end = [section.endPoint];
1217
+ return [...start, ...bends, ...end];
1218
+ });
1219
+ if (points.length === 0) {
1220
+ return "";
1221
+ }
1222
+ return points.map((point, index) => \`\${index === 0 ? "M" : "L"} \${point.x} \${point.y}\`).join(" ");
1223
+ }
1224
+
1225
+ function updateToolbar(mode) {
1226
+ filterButtons.forEach((button) => {
1227
+ const active = button.dataset.viewMode === mode;
1228
+ button.classList.toggle("active", active);
1229
+ button.setAttribute("aria-pressed", active ? "true" : "false");
1230
+ });
1231
+ }
1232
+
1233
+ function round(value) {
1234
+ return Math.round(value * 100) / 100;
1235
+ }
1236
+
1237
+ function collectEdgePoints(sections) {
1238
+ return sections.flatMap((section, index) => {
1239
+ const start = index === 0 ? [section.startPoint] : [];
1240
+ const bends = section.bendPoints ?? [];
1241
+ const end = [section.endPoint];
1242
+ return [...start, ...bends, ...end];
1243
+ });
1244
+ }
1245
+
1246
+ function measureVisibleBounds(nodes, edges) {
1247
+ const points = [
1248
+ ...nodes.flatMap((node) => ([
1249
+ { x: node.x, y: node.y },
1250
+ { x: node.x + node.width, y: node.y + node.height },
1251
+ ])),
1252
+ ...edges.flatMap((edge) => collectEdgePoints(edge.sections)),
1253
+ ];
1254
+
1255
+ if (points.length === 0) {
1256
+ return null;
1257
+ }
1258
+
1259
+ let minX = points[0].x;
1260
+ let minY = points[0].y;
1261
+ let maxX = points[0].x;
1262
+ let maxY = points[0].y;
1263
+ for (const point of points.slice(1)) {
1264
+ if (point.x < minX) minX = point.x;
1265
+ if (point.y < minY) minY = point.y;
1266
+ if (point.x > maxX) maxX = point.x;
1267
+ if (point.y > maxY) maxY = point.y;
1268
+ }
1269
+
1270
+ return {
1271
+ x: minX - 20,
1272
+ y: minY - 20,
1273
+ width: (maxX - minX) + 40,
1274
+ height: (maxY - minY) + 40,
1275
+ };
1276
+ }
1277
+
1278
+ function fitViewBoxRect(bounds) {
1279
+ const minWidth = 720;
1280
+ const minHeight = 540;
1281
+ const width = Math.max(bounds.width, minWidth);
1282
+ const height = Math.max(bounds.height, minHeight);
1283
+ const x = bounds.x + (bounds.width - width) / 2;
1284
+ const y = bounds.y + (bounds.height - height) / 2;
1285
+ return {
1286
+ x: round(x),
1287
+ y: round(y),
1288
+ width: round(width),
1289
+ height: round(height),
1290
+ };
1291
+ }
1292
+
1293
+ function updateViewBox(bounds) {
1294
+ const box = fitViewBoxRect(bounds);
1295
+ viewBox.x = box.x;
1296
+ viewBox.y = box.y;
1297
+ viewBox.width = box.width;
1298
+ viewBox.height = box.height;
1299
+ }
1300
+
1301
+ function clearSelection(message) {
1302
+ selectedNodeId = null;
987
1303
  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;
1304
+ panel.className = "panel";
1305
+ panelTitle.textContent = "No visible nodes";
1306
+ panelBadge.textContent = "Hidden";
1307
+ panelBody.innerHTML = "<div class='empty'>" + escapeHtml(message) + "</div>";
1308
+ }
1309
+
1310
+ function updatePanel(node) {
992
1311
  panel.className = "panel " + node.status;
993
1312
  panelTitle.textContent = node.label;
994
1313
  panelBadge.textContent = node.status.toUpperCase();
995
1314
  const changes = node.changes || [];
996
1315
  panelBody.innerHTML = changes.length
997
- ? "<ul class='changes'>" + changes.map(formatChange).join("") + "</ul>"
1316
+ ? organizeChanges(node).map((section) => formatSection(section, node)).join("")
998
1317
  : node.status === "added"
999
1318
  ? "<div class='empty'>This node was added in the new version.</div>"
1000
1319
  : node.status === "deleted"
@@ -1002,22 +1321,235 @@ function renderHtml(layout) {
1002
1321
  : "<div class='empty'>No property changes.</div>";
1003
1322
  }
1004
1323
 
1005
- function formatChange(change) {
1006
- const beforeMissing = change.before === undefined;
1007
- const afterMissing = change.after === undefined;
1008
- const action = beforeMissing ? "Added" : afterMissing ? "Removed" : "Changed";
1009
- const values = beforeMissing
1010
- ? formatLabeledValue("New value", change.after)
1011
- : afterMissing
1012
- ? formatLabeledValue("Previous value", change.before)
1013
- : formatLabeledValue("Before", change.before) + formatLabeledValue("After", change.after);
1014
- return "<li><div class='change-title'>" + action + ": " + escapeHtml(humanizePath(change.path)) + "</div>"
1015
- + "<div class='change-path'>Metadata path: <code>" + escapeHtml(change.path) + "</code></div>"
1016
- + "<div class='change-values'>" + values + "</div></li>";
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>";
1017
1472
  }
1018
1473
 
1019
- function formatLabeledValue(label, value) {
1020
- return "<div class='change-value'><div class='change-value-label'>" + label + "</div>" + formatValue(value) + "</div>";
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
+
1546
+ function selectNode(id) {
1547
+ selectedNodeId = id;
1548
+ document.querySelectorAll(".node").forEach((el) => el.classList.remove("selected"));
1549
+ const selected = document.querySelector(\`.node[data-node-id="\${CSS.escape(id)}"]\`);
1550
+ if (selected) selected.classList.add("selected");
1551
+ const node = nodesById.get(id);
1552
+ if (node) updatePanel(node);
1021
1553
  }
1022
1554
 
1023
1555
  function humanizePath(path) {
@@ -1034,11 +1566,125 @@ function renderHtml(layout) {
1034
1566
  }).join(" \u203A ");
1035
1567
  }
1036
1568
 
1037
- function formatValue(value) {
1038
- if (value === undefined) return "<em>Not present</em>";
1039
- 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>";
1040
1602
  if (typeof value === "object") return "<pre>" + escapeHtml(JSON.stringify(value, null, 2)) + "</pre>";
1041
- 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);
1617
+ }
1618
+
1619
+ function pickInitialNode(visibleNodeIds) {
1620
+ return DATA.nodes.find((node) => visibleNodeIds.has(node.id) && node.status === "modified")
1621
+ || DATA.nodes.find((node) => visibleNodeIds.has(node.id) && node.status !== "unchanged")
1622
+ || DATA.nodes.find((node) => visibleNodeIds.has(node.id))
1623
+ || null;
1624
+ }
1625
+
1626
+ function applyView(mode) {
1627
+ const layout = mode === "all" || mode === "changes"
1628
+ ? DATA.layouts.union
1629
+ : DATA.layouts[mode];
1630
+ const nodeLayout = new Map(layout.nodes.map((node) => [node.id, node]));
1631
+ const edgeLayout = new Map(layout.edges.map((edge) => [edge.id, edge]));
1632
+ const visibleNodeIds = new Set();
1633
+ const visibleNodes = [];
1634
+ const visibleEdges = [];
1635
+
1636
+ DATA.nodes.forEach((node) => {
1637
+ const element = nodeElements.get(node.id);
1638
+ if (!element) return;
1639
+ if (!isNodeVisible(node, mode)) {
1640
+ element.style.display = "none";
1641
+ return;
1642
+ }
1643
+ const positioned = nodeLayout.get(node.id);
1644
+ if (!positioned) {
1645
+ element.style.display = "none";
1646
+ return;
1647
+ }
1648
+ element.style.display = "";
1649
+ element.setAttribute("transform", "translate(" + positioned.x + "," + positioned.y + ")");
1650
+ visibleNodeIds.add(node.id);
1651
+ visibleNodes.push(positioned);
1652
+ });
1653
+
1654
+ DATA.edges.forEach((edge) => {
1655
+ const element = edgeElements.get(edge.id);
1656
+ if (!element) return;
1657
+ if (!isEdgeVisible(edge, mode, visibleNodeIds)) {
1658
+ element.style.display = "none";
1659
+ return;
1660
+ }
1661
+ const positioned = edgeLayout.get(edge.id);
1662
+ if (!positioned) {
1663
+ element.style.display = "none";
1664
+ return;
1665
+ }
1666
+ element.style.display = "";
1667
+ element.setAttribute("d", edgePath(positioned.sections));
1668
+ visibleEdges.push(positioned);
1669
+ });
1670
+
1671
+ updateToolbar(mode);
1672
+ updateViewBox(measureVisibleBounds(visibleNodes, visibleEdges) || { x: 0, y: 0, width: layout.width, height: layout.height });
1673
+
1674
+ if (selectedNodeId && visibleNodeIds.has(selectedNodeId)) {
1675
+ const node = nodesById.get(selectedNodeId);
1676
+ if (node) {
1677
+ updatePanel(node);
1678
+ }
1679
+ return;
1680
+ }
1681
+
1682
+ const initialNode = pickInitialNode(visibleNodeIds);
1683
+ if (initialNode) {
1684
+ selectNode(initialNode.id);
1685
+ } else {
1686
+ clearSelection("This view has no visible nodes.");
1687
+ }
1042
1688
  }
1043
1689
 
1044
1690
  document.querySelectorAll(".node").forEach((node) => {
@@ -1076,10 +1722,46 @@ function renderHtml(layout) {
1076
1722
  svg.addEventListener("pointerup", () => { dragStart = null; });
1077
1723
  svg.addEventListener("pointercancel", () => { dragStart = null; });
1078
1724
 
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);
1725
+ filterButtons.forEach((button) => {
1726
+ button.addEventListener("click", () => {
1727
+ applyView(button.dataset.viewMode);
1728
+ });
1729
+ });
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
+
1764
+ applyView("all");
1083
1765
  </script>
1084
1766
  </body>
1085
1767
  </html>`;
@@ -1107,13 +1789,13 @@ function renderEdge(edge) {
1107
1789
  return "";
1108
1790
  }
1109
1791
  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>`;
1792
+ return `<path class="edge ${edge.kind} ${edge.status}" d="${d}" data-edge-id="${escapeHtml(edge.id)}"></path>`;
1111
1793
  }
1112
1794
  function renderNode(node) {
1113
1795
  const lines = wrapLabel(node.label, 22);
1114
1796
  const lineHeight = 14;
1115
1797
  const textY = node.height / 2 - (lines.length - 1) * lineHeight / 2 + 5;
1116
- 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})">
1117
1799
  <rect width="${node.width}" height="${node.height}"></rect>
1118
1800
  <text x="${node.width / 2}" y="${textY}" text-anchor="middle" class="node-label">
1119
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.1",
3
+ "version": "0.2.1",
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": {