@syntax-syllogism/flow-delta 0.3.0 → 0.5.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.
package/README.md CHANGED
@@ -5,10 +5,12 @@ 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.
8
+ Inspired by Google's [Flow Lens](https://github.com/google/flow-lens), with a
9
+ focus on CI review workflows for GitLab merge requests and GitHub pull requests.
10
+ We also opted for our own HTML output over plantuml, graphviz, or mermaid.
10
11
 
11
- [Sample Gitlab Project with artifacts](https://gitlab.com/j.p.richter/flow-delta-example/-/merge_requests/1)
12
+ - [Sample GitLab project with artifacts](https://gitlab.com/j.p.richter/flow-delta-example/-/merge_requests/)
13
+ - [Sample GitHub project with artifacts](https://github.com/Syntax-Syllogism/flow-delta-example/pulls)
12
14
 
13
15
  ## Usage
14
16
 
@@ -19,6 +21,19 @@ for full file-mode and git-mode options.
19
21
  npx tsx src/cli.ts --old before.flow-meta.xml --new after.flow-meta.xml --out ./flow-delta-out --json
20
22
  ```
21
23
 
24
+ CI reporters read the generated `flow-delta-out/*.diff.json` files and post a
25
+ sticky review comment:
26
+
27
+ ```bash
28
+ flow-delta-gitlab --in flow-delta-out
29
+ flow-delta-github --in flow-delta-out
30
+ ```
31
+
32
+ The GitHub reporter can also consume `--artifact-urls <manifest.json>` for
33
+ user-owned live-render links, such as R2 presigned URLs or Cloudflare
34
+ Worker-backed URLs. See [docs/ci.md](docs/ci.md) for GitLab/GitHub workflow
35
+ recipes, private-repo artifact viewing, and smoke harnesses.
36
+
22
37
  ## Development
23
38
 
24
39
  ```bash
package/dist/cli.js CHANGED
@@ -628,6 +628,38 @@ function isPlainObject(value) {
628
628
  return Boolean(value) && typeof value === "object" && !Array.isArray(value);
629
629
  }
630
630
 
631
+ // src/model/flow-header.ts
632
+ import { Parser as Parser2 } from "xml2js";
633
+ var FLOW_HEADER_KEYS = [
634
+ "status",
635
+ "processType",
636
+ "runInMode",
637
+ "apiVersion",
638
+ "triggerOrder",
639
+ "description",
640
+ "interviewLabel",
641
+ "isTemplate"
642
+ ];
643
+ async function extractFlowHeader(xml) {
644
+ try {
645
+ const parsed = await new Parser2({ explicitArray: false }).parseStringPromise(xml);
646
+ const flow = parsed.Flow;
647
+ if (!flow) {
648
+ return {};
649
+ }
650
+ const header = {};
651
+ for (const key of FLOW_HEADER_KEYS) {
652
+ const value = flow[key];
653
+ if (value !== void 0 && (value === null || typeof value !== "object")) {
654
+ header[key] = String(value);
655
+ }
656
+ }
657
+ return header;
658
+ } catch {
659
+ return {};
660
+ }
661
+ }
662
+
631
663
  // src/diff/deep-diff.ts
632
664
  function deepDiff(before, after, path = "") {
633
665
  if (Object.is(before, after)) {
@@ -685,21 +717,32 @@ function diffModel(oldModel, newModel) {
685
717
  const edgeIds = [.../* @__PURE__ */ new Set([...oldEdges.keys(), ...newEdges.keys()])].sort();
686
718
  const nodes = nodeIds.map((id) => classifyNode(oldNodes.get(id), newNodes.get(id)));
687
719
  const edges = edgeIds.map((id) => classifyEdge(oldEdges.get(id), newEdges.get(id)));
720
+ const flowChanges = diffFlowHeaders(oldModel, newModel);
688
721
  const summary = {
689
722
  addedNodes: nodes.filter((node) => node.status === "added").length,
690
723
  removedNodes: nodes.filter((node) => node.status === "deleted").length,
691
724
  modifiedNodes: nodes.filter((node) => node.status === "modified").length,
692
725
  unchangedNodes: nodes.filter((node) => node.status === "unchanged").length,
693
726
  addedEdges: edges.filter((edge) => edge.status === "added").length,
694
- removedEdges: edges.filter((edge) => edge.status === "deleted").length
727
+ removedEdges: edges.filter((edge) => edge.status === "deleted").length,
728
+ changedFlowAttributes: flowChanges.length
695
729
  };
696
730
  return {
697
731
  flowName: selectFlowName(oldModel, newModel),
698
732
  summary,
733
+ ...flowChanges.length > 0 ? { flowChanges } : {},
699
734
  nodes,
700
735
  edges
701
736
  };
702
737
  }
738
+ function diffFlowHeaders(oldModel, newModel) {
739
+ if (!oldModel.header || !newModel.header) {
740
+ return [];
741
+ }
742
+ const changes = deepDiff(oldModel.header, newModel.header);
743
+ const order = new Map(FLOW_HEADER_KEYS.map((key, index) => [key, index]));
744
+ return changes.sort((left, right) => (order.get(left.path) ?? 999) - (order.get(right.path) ?? 999));
745
+ }
703
746
  function selectFlowName(oldModel, newModel) {
704
747
  if (newModel.nodes.length > 0 || newModel.edges.length > 0) {
705
748
  return newModel.flowName;
@@ -1516,6 +1559,7 @@ function snapshotPanelClientScript() {
1516
1559
  }
1517
1560
 
1518
1561
  // src/render/render-html.ts
1562
+ var THEME_STORAGE_KEY = "flow-delta-theme";
1519
1563
  function renderHtml(layout) {
1520
1564
  const data = {
1521
1565
  diff: layout.diff,
@@ -1532,25 +1576,72 @@ function renderHtml(layout) {
1532
1576
  const edgeStat = s.addedEdges + s.removedEdges > 0 ? `<span class="sep">\xB7</span><span class="stat edges">+${s.addedEdges}/\u2212${s.removedEdges} edges</span>` : "";
1533
1577
  const metaHtml = `${stat(s.addedNodes, "added", "added")}<span class="sep">\xB7</span>${stat(s.removedNodes, "deleted", "deleted")}<span class="sep">\xB7</span>${stat(s.modifiedNodes, "modified", "modified")}${edgeStat}`;
1534
1578
  const viewBox = fitViewBox(layout.width, layout.height);
1579
+ const flowBannerHtml = renderFlowBanner(layout.diff.flowChanges);
1535
1580
  return `<!doctype html>
1536
1581
  <html lang="en">
1537
1582
  <head>
1538
1583
  <meta charset="utf-8" />
1539
1584
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1540
1585
  <title>${escapeHtml2(layout.diff.flowName)}</title>
1586
+ <script>
1587
+ (() => {
1588
+ const key = ${JSON.stringify(THEME_STORAGE_KEY)};
1589
+ const allowed = new Set(["system", "light", "dark"]);
1590
+ let theme = "system";
1591
+ try {
1592
+ const stored = localStorage.getItem(key);
1593
+ if (allowed.has(stored)) theme = stored;
1594
+ } catch {}
1595
+ document.documentElement.dataset.theme = theme;
1596
+ })();
1597
+ </script>
1541
1598
  <style>
1542
1599
  :root {
1543
1600
  color-scheme: light;
1544
1601
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
1545
- --bg: #f5f7fb; --surface: #ffffff; --border: #e3e8ef;
1602
+ --bg: #f5f7fb; --surface: #ffffff; --surface-muted: #f8fafc; --surface-subtle: #fbfcfe; --surface-selected: #eff6ff; --border: #e3e8ef; --focus: #93c5fd;
1546
1603
  --text: #1f2937; --muted: #64748b; --faint: #94a3b8;
1604
+ --node-text: #111827; --shadow: rgba(15, 23, 42, 0.12); --shadow-strong: rgba(15, 23, 42, 0.2);
1605
+ --grid-dot: #dfe5ee; --canvas-start: #ffffff; --canvas-end: #f7f9fc;
1547
1606
  --added: #16a34a; --added-fill: #dcfce7;
1548
1607
  --deleted: #dc2626; --deleted-fill: #fee2e2;
1549
1608
  --modified: #d97706; --modified-fill: #fef3c7;
1550
1609
  --unchanged: #94a3b8; --unchanged-fill: #e9edf3;
1551
1610
  --edge: #64748b; --fault: #7c3aed;
1611
+ --inserted-text: #14532d; --deleted-text: #7f1d1d; --deleted-line: rgba(127, 29, 29, 0.5);
1552
1612
  --panel-width: 34vw;
1553
1613
  }
1614
+ @media (prefers-color-scheme: dark) {
1615
+ :root:not([data-theme="light"]) {
1616
+ color-scheme: dark;
1617
+ --bg: #0f172a; --surface: #111827; --surface-muted: #1f2937; --surface-subtle: #172033; --surface-selected: #1e3a5f; --border: #334155; --focus: #60a5fa;
1618
+ --text: #e5e7eb; --muted: #a5b4c7; --faint: #718096;
1619
+ --node-text: #f8fafc; --shadow: rgba(0, 0, 0, 0.35); --shadow-strong: rgba(0, 0, 0, 0.55);
1620
+ --grid-dot: #334155; --canvas-start: #0f172a; --canvas-end: #111827;
1621
+ --added: #4ade80; --added-fill: #123f2a;
1622
+ --deleted: #f87171; --deleted-fill: #4a1d23;
1623
+ --modified: #fbbf24; --modified-fill: #46330d;
1624
+ --unchanged: #64748b; --unchanged-fill: #263241;
1625
+ --edge: #94a3b8; --fault: #c4b5fd;
1626
+ --inserted-text: #bbf7d0; --deleted-text: #fecaca; --deleted-line: rgba(254, 202, 202, 0.55);
1627
+ }
1628
+ }
1629
+ :root[data-theme="light"] {
1630
+ color-scheme: light;
1631
+ }
1632
+ :root[data-theme="dark"] {
1633
+ color-scheme: dark;
1634
+ --bg: #0f172a; --surface: #111827; --surface-muted: #1f2937; --surface-subtle: #172033; --surface-selected: #1e3a5f; --border: #334155; --focus: #60a5fa;
1635
+ --text: #e5e7eb; --muted: #a5b4c7; --faint: #718096;
1636
+ --node-text: #f8fafc; --shadow: rgba(0, 0, 0, 0.35); --shadow-strong: rgba(0, 0, 0, 0.55);
1637
+ --grid-dot: #334155; --canvas-start: #0f172a; --canvas-end: #111827;
1638
+ --added: #4ade80; --added-fill: #123f2a;
1639
+ --deleted: #f87171; --deleted-fill: #4a1d23;
1640
+ --modified: #fbbf24; --modified-fill: #46330d;
1641
+ --unchanged: #64748b; --unchanged-fill: #263241;
1642
+ --edge: #94a3b8; --fault: #c4b5fd;
1643
+ --inserted-text: #bbf7d0; --deleted-text: #fecaca; --deleted-line: rgba(254, 202, 202, 0.55);
1644
+ }
1554
1645
  * { box-sizing: border-box; }
1555
1646
  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; }
1556
1647
  header { display: flex; justify-content: space-between; gap: 16px; align-items: center; padding: 12px 20px; border-bottom: 1px solid var(--border); background: var(--surface); }
@@ -1566,7 +1657,7 @@ function renderHtml(layout) {
1566
1657
  .filters { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
1567
1658
  .filters button {
1568
1659
  border: 1px solid var(--border);
1569
- background: #f8fafc;
1660
+ background: var(--surface-muted);
1570
1661
  color: var(--muted);
1571
1662
  padding: 6px 11px;
1572
1663
  border-radius: 999px;
@@ -1577,15 +1668,19 @@ function renderHtml(layout) {
1577
1668
  transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
1578
1669
  }
1579
1670
  .filters button.active {
1580
- background: #eff6ff;
1581
- border-color: #93c5fd;
1671
+ background: var(--surface-selected);
1672
+ border-color: var(--focus);
1582
1673
  color: var(--text);
1583
1674
  box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12);
1584
1675
  }
1585
1676
  .filters button:focus-visible {
1586
- outline: 2px solid #93c5fd;
1677
+ outline: 2px solid var(--focus);
1587
1678
  outline-offset: 2px;
1588
1679
  }
1680
+ .theme-toggle { display: inline-flex; padding: 2px; border: 1px solid var(--border); border-radius: 999px; background: var(--surface-muted); }
1681
+ .theme-toggle button { border: 0; border-radius: 999px; background: transparent; color: var(--muted); padding: 4px 9px; font: inherit; font-size: 11.5px; font-weight: 650; cursor: pointer; }
1682
+ .theme-toggle button[aria-pressed="true"] { background: var(--surface); color: var(--text); box-shadow: 0 1px 3px var(--shadow); }
1683
+ .theme-toggle button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
1589
1684
  .legend { display: flex; gap: 14px; font-size: 12px; flex-wrap: wrap; color: var(--muted); }
1590
1685
  .legend span::before { content: ""; display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
1591
1686
  .legend .added::before { background: var(--added); }
@@ -1598,8 +1693,8 @@ function renderHtml(layout) {
1598
1693
  svg {
1599
1694
  width: 100%; height: 100%; display: block;
1600
1695
  background:
1601
- radial-gradient(circle, #dfe5ee 1px, transparent 1.4px) -11px -11px / 22px 22px,
1602
- linear-gradient(#ffffff, #f7f9fc);
1696
+ radial-gradient(circle, var(--grid-dot) 1px, transparent 1.4px) -11px -11px / 22px 22px,
1697
+ linear-gradient(var(--canvas-start), var(--canvas-end));
1603
1698
  }
1604
1699
  .panel { position: relative; min-width: 0; border-left: 1px solid var(--border); background: var(--surface); padding: 20px; overflow-y: auto; overflow-x: hidden; }
1605
1700
  body.panel-collapsed .panel { padding: 0; border-left: 0; overflow: hidden; }
@@ -1607,24 +1702,24 @@ function renderHtml(layout) {
1607
1702
  /* drag-to-resize handle on the panel's left edge */
1608
1703
  .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
1704
  .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; }
1705
+ .panel-resizer:hover::before, .panel-resizer.dragging::before { background: var(--focus); width: 3px; }
1611
1706
  body.panel-collapsed .panel-resizer { display: none; }
1612
1707
  .panel-head { display: flex; align-items: flex-start; gap: 8px; }
1613
1708
  .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; }
1709
+ .panel-toggle, .panel-reopen { border: 1px solid var(--border); background: var(--surface-muted); color: var(--muted); border-radius: 7px; cursor: pointer; font: inherit; line-height: 1; }
1615
1710
  .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; }
1711
+ .panel-toggle:hover, .panel-reopen:hover { color: var(--text); border-color: var(--focus); background: var(--surface-selected); }
1712
+ .panel-toggle:focus-visible, .panel-reopen:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
1618
1713
  /* 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); }
1714
+ .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 var(--shadow); }
1620
1715
  body.panel-collapsed .panel-reopen { display: inline-flex; align-items: center; gap: 6px; }
1621
1716
  .panel h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
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; }
1717
+ .panel .badge { display: inline-block; padding: 3px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; letter-spacing: 0.03em; background: var(--unchanged-fill); color: var(--muted); margin-bottom: 16px; }
1623
1718
  .panel.added .badge { background: var(--added-fill); color: var(--added); }
1624
1719
  .panel.deleted .badge { background: var(--deleted-fill); color: var(--deleted); }
1625
1720
  .panel.modified .badge { background: var(--modified-fill); color: var(--modified); }
1626
1721
  .panel .empty { color: var(--muted); font-size: 13px; }
1627
- .detail-section { margin: 0 0 12px; border: 1px solid var(--border); border-radius: 8px; background: #fbfcfe; overflow: hidden; }
1722
+ .detail-section { margin: 0 0 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-subtle); overflow: hidden; }
1628
1723
  .detail-section.snapshot-section { border-left-width: 3px; }
1629
1724
  .detail-section.snapshot-section.added { border-left-color: rgba(22, 163, 74, 0.5); }
1630
1725
  .detail-section.snapshot-section.deleted { border-left-color: rgba(220, 38, 38, 0.45); }
@@ -1640,8 +1735,8 @@ function renderHtml(layout) {
1640
1735
  .change-kind.removed { color: var(--deleted); background: var(--deleted-fill); }
1641
1736
  /* git-diff value grammar */
1642
1737
  .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); }
1738
+ .val.ins { color: var(--inserted-text); background: var(--added-fill); }
1739
+ .val.del { color: var(--deleted-text); background: var(--deleted-fill); text-decoration: line-through; text-decoration-color: var(--deleted-line); }
1645
1740
  .val.faint { color: var(--muted); background: transparent; padding-left: 0; }
1646
1741
  .val.one-sided { color: var(--text); background: transparent; padding-left: 0; }
1647
1742
  .val em { font-style: normal; color: var(--faint); }
@@ -1678,6 +1773,41 @@ function renderHtml(layout) {
1678
1773
  .group-head { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
1679
1774
  .group-label { font-weight: 700; font-size: 12.5px; }
1680
1775
  .outcome-group .changes { margin-bottom: 8px; }
1776
+ /* Flow-level changes: a floating tab over the canvas's top-left corner,
1777
+ styled to match .panel-reopen's collapsed-state language (same surface,
1778
+ border, radius, shadow) so there's one disclosure idiom in the artifact
1779
+ instead of two. It reserves no layout row. */
1780
+ .flow-tab {
1781
+ position: absolute; top: 14px; left: 14px; z-index: 6;
1782
+ display: inline-flex; align-items: center; gap: 7px;
1783
+ border: 1px solid var(--border); background: var(--surface-muted); color: var(--muted);
1784
+ border-radius: 7px; padding: 7px 12px; font: inherit; font-size: 12px; font-weight: 600; line-height: 1;
1785
+ box-shadow: 0 1px 3px var(--shadow); cursor: pointer;
1786
+ }
1787
+ .flow-tab:hover { color: var(--text); border-color: var(--focus); background: var(--surface-selected); }
1788
+ .flow-tab:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
1789
+ .flow-tab .flow-tab-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--modified); flex: none; }
1790
+ .flow-tab.deactivated .flow-tab-dot { background: var(--deleted); }
1791
+ .flow-tab.activated .flow-tab-dot { background: var(--added); }
1792
+ .flow-tab .flow-tab-chev { font-size: 9px; opacity: 0.75; transition: transform 0.12s ease; }
1793
+ .flow-tab[aria-expanded="true"] .flow-tab-chev { transform: rotate(180deg); }
1794
+ .flow-popover {
1795
+ position: absolute; top: 14px; left: 14px; z-index: 6; width: min(300px, calc(100% - 28px));
1796
+ background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
1797
+ box-shadow: 0 16px 40px -12px var(--shadow-strong); overflow: hidden;
1798
+ }
1799
+ .flow-popover[hidden] { display: none; }
1800
+ .flow-popover-head { padding: 10px 12px; border-bottom: 1px solid var(--border); background: var(--surface-muted); font-size: 12px; font-weight: 650; }
1801
+ .flow-popover-body { padding: 12px; max-height: 60vh; overflow: auto; }
1802
+ .flow-banner-callout { margin-bottom: 8px; font-weight: 750; }
1803
+ .flow-banner-callout.deactivated { color: var(--deleted); }
1804
+ .flow-banner-callout.activated { color: var(--added); }
1805
+ .flow-banner-callout.neutral { color: var(--modified); }
1806
+ .flow-banner-list { display: grid; gap: 7px; }
1807
+ .flow-change-row { display: grid; grid-template-columns: minmax(90px, 120px) minmax(0, 1fr); align-items: start; gap: 10px; }
1808
+ .flow-change-row + .flow-change-row { margin-top: 7px; }
1809
+ .flow-change-label { color: var(--muted); font-size: 12px; font-weight: 700; }
1810
+ .flow-change-value { min-width: 0; max-height: 180px; overflow: auto; }
1681
1811
  .edge { fill: none; }
1682
1812
  .edge.unchanged { stroke-width: 1.4; opacity: 0.55; }
1683
1813
  .edge.normal.unchanged { stroke: var(--edge); marker-end: url(#arrow-normal); }
@@ -1688,26 +1818,26 @@ function renderHtml(layout) {
1688
1818
  #arrow-fault path { fill: var(--fault); }
1689
1819
  #arrow-added path { fill: var(--added); }
1690
1820
  #arrow-deleted path { fill: var(--deleted); }
1691
- .node rect { rx: 11; ry: 11; stroke-width: 1.75; filter: drop-shadow(0 1px 2px rgba(15, 23, 42, 0.08)); }
1821
+ .node rect { rx: 11; ry: 11; stroke-width: 1.75; filter: drop-shadow(0 1px 2px var(--shadow)); }
1692
1822
  .node { cursor: pointer; }
1693
- .node text { font-size: 12px; fill: #111827; pointer-events: none; }
1823
+ .node text { font-size: 12px; fill: var(--node-text); pointer-events: none; }
1694
1824
  .node.added rect { fill: var(--added-fill); stroke: var(--added); }
1695
1825
  .node.deleted rect { fill: var(--deleted-fill); stroke: var(--deleted); }
1696
1826
  .node.modified rect { fill: var(--modified-fill); stroke: var(--modified); }
1697
1827
  .node.unchanged rect { fill: var(--unchanged-fill); stroke: var(--unchanged); }
1698
- .node.selected rect { stroke-width: 3; filter: drop-shadow(0 2px 6px rgba(15, 23, 42, 0.18)); }
1828
+ .node.selected rect { stroke-width: 3; filter: drop-shadow(0 2px 6px var(--shadow-strong)); }
1699
1829
  .node-label { white-space: pre; }
1700
1830
  /* Type-based styling \u2014 SHAPE & stroke pattern only. Status (added/deleted/
1701
1831
  modified/unchanged) remains the sole authority on fill/stroke COLOR, so
1702
1832
  the legend stays reliable. Start/end stand out via a pill shape, heavier
1703
1833
  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)); }
1834
+ .node.type-start rect, .node.type-end rect { rx: 24; ry: 24; stroke-width: 2.5; filter: drop-shadow(0 0 0 3px var(--shadow)) drop-shadow(0 1px 3px var(--shadow-strong)); }
1705
1835
  .node.type-recordLookup rect, .node.type-recordCreate rect, .node.type-recordUpdate rect, .node.type-recordDelete rect { rx: 4; ry: 4; }
1706
1836
  .node.type-screen rect { rx: 6; ry: 6; }
1707
1837
  .node.type-subflow rect { stroke-width: 2.5; }
1708
1838
  .node.type-loop rect { stroke-dasharray: 2 3; }
1709
1839
  .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)); }
1840
+ .node.selected.type-start rect, .node.selected.type-end rect { stroke-width: 3.5; filter: drop-shadow(0 0 0 5px var(--shadow-strong)) drop-shadow(0 2px 6px var(--shadow-strong)); }
1711
1841
  @media (max-width: 1000px) {
1712
1842
  .panel { padding: 16px; }
1713
1843
  }
@@ -1720,6 +1850,11 @@ function renderHtml(layout) {
1720
1850
  <div class="meta">${metaHtml}</div>
1721
1851
  </div>
1722
1852
  <div class="header-actions">
1853
+ <div class="theme-toggle" role="group" aria-label="Color theme">
1854
+ <button type="button" data-theme-choice="system" aria-pressed="true">System</button>
1855
+ <button type="button" data-theme-choice="light" aria-pressed="false">Light</button>
1856
+ <button type="button" data-theme-choice="dark" aria-pressed="false">Dark</button>
1857
+ </div>
1723
1858
  <div class="filters" role="group" aria-label="Diff view filters">
1724
1859
  <button type="button" class="active" data-view-mode="all">All</button>
1725
1860
  <button type="button" data-view-mode="after">After</button>
@@ -1736,6 +1871,7 @@ function renderHtml(layout) {
1736
1871
  </header>
1737
1872
  <main>
1738
1873
  <div class="canvas">
1874
+ ${flowBannerHtml}
1739
1875
  <svg id="flow-svg" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Flow diff">
1740
1876
  <defs>
1741
1877
  <marker id="arrow-normal" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
@@ -1777,12 +1913,46 @@ function renderHtml(layout) {
1777
1913
  const panelBadge = document.getElementById("panel-badge");
1778
1914
  const panelBody = document.getElementById("panel-body");
1779
1915
  const filterButtons = [...document.querySelectorAll(".filters button")];
1916
+ const themeButtons = [...document.querySelectorAll(".theme-toggle button")];
1780
1917
  const nodesById = new Map(DATA.nodes.map((node) => [node.id, node]));
1781
1918
  const nodeElements = new Map([...document.querySelectorAll(".node")].map((node) => [node.dataset.nodeId, node]));
1782
1919
  const edgeElements = new Map([...document.querySelectorAll(".edge")].map((edge) => [edge.dataset.edgeId, edge]));
1920
+ const flowTab = document.getElementById("flow-tab");
1921
+ const flowPopover = document.getElementById("flow-popover");
1922
+ function closeFlowPopover() {
1923
+ if (!flowTab || !flowPopover || flowPopover.hidden) return;
1924
+ flowTab.setAttribute("aria-expanded", "false");
1925
+ flowPopover.hidden = true;
1926
+ }
1783
1927
  let viewBox = svg.viewBox.baseVal;
1784
1928
  let dragStart = null;
1785
1929
  let selectedNodeId = null;
1930
+ const THEME_STORAGE_KEY = ${JSON.stringify(THEME_STORAGE_KEY)};
1931
+ const VALID_THEMES = new Set(["system", "light", "dark"]);
1932
+
1933
+ function readStoredTheme() {
1934
+ try {
1935
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
1936
+ return VALID_THEMES.has(stored) ? stored : "system";
1937
+ } catch {
1938
+ return "system";
1939
+ }
1940
+ }
1941
+
1942
+ function persistTheme(theme) {
1943
+ try {
1944
+ localStorage.setItem(THEME_STORAGE_KEY, theme);
1945
+ } catch {}
1946
+ }
1947
+
1948
+ function applyTheme(theme) {
1949
+ const next = VALID_THEMES.has(theme) ? theme : "system";
1950
+ document.documentElement.dataset.theme = next;
1951
+ themeButtons.forEach((button) => {
1952
+ const active = button.dataset.themeChoice === next;
1953
+ button.setAttribute("aria-pressed", active ? "true" : "false");
1954
+ });
1955
+ }
1786
1956
 
1787
1957
  function isNodeVisible(node, mode) {
1788
1958
  if (mode === "after") return node.status !== "deleted";
@@ -1997,6 +2167,7 @@ function renderHtml(layout) {
1997
2167
  document.querySelectorAll(".node").forEach((node) => {
1998
2168
  node.addEventListener("click", (event) => {
1999
2169
  event.stopPropagation();
2170
+ closeFlowPopover();
2000
2171
  selectNode(node.dataset.nodeId);
2001
2172
  });
2002
2173
  });
@@ -2035,6 +2206,14 @@ function renderHtml(layout) {
2035
2206
  });
2036
2207
  });
2037
2208
 
2209
+ themeButtons.forEach((button) => {
2210
+ button.addEventListener("click", () => {
2211
+ const theme = button.dataset.themeChoice;
2212
+ applyTheme(theme);
2213
+ persistTheme(theme);
2214
+ });
2215
+ });
2216
+
2038
2217
  // Panel: collapse to focus on the canvas, and drag the left edge to resize.
2039
2218
  const panelToggle = document.getElementById("panel-toggle");
2040
2219
  const panelReopen = document.getElementById("panel-reopen");
@@ -2068,11 +2247,91 @@ function renderHtml(layout) {
2068
2247
  panelResizer.addEventListener("pointercancel", endResize);
2069
2248
  }
2070
2249
 
2250
+ // Flow-level changes tab: floats over the canvas, opens a popover on click.
2251
+ if (flowTab && flowPopover) {
2252
+ flowTab.addEventListener("click", (event) => {
2253
+ event.stopPropagation();
2254
+ const open = flowPopover.hidden;
2255
+ flowTab.setAttribute("aria-expanded", open ? "true" : "false");
2256
+ flowPopover.hidden = !open;
2257
+ });
2258
+ document.addEventListener("click", (event) => {
2259
+ if (!flowPopover.hidden && !flowPopover.contains(event.target)) {
2260
+ closeFlowPopover();
2261
+ }
2262
+ });
2263
+ document.addEventListener("keydown", (event) => {
2264
+ if (event.key === "Escape" && !flowPopover.hidden) {
2265
+ closeFlowPopover();
2266
+ flowTab.focus();
2267
+ }
2268
+ });
2269
+ }
2270
+
2271
+ applyTheme(readStoredTheme());
2071
2272
  applyView("all");
2072
2273
  </script>
2073
2274
  </body>
2074
2275
  </html>`;
2075
2276
  }
2277
+ function renderFlowBanner(changes) {
2278
+ if (!changes || changes.length === 0) {
2279
+ return "";
2280
+ }
2281
+ const statusChange = changes.find((change) => change.path === "status");
2282
+ const kind = statusChange ? getStatusKind(statusChange.before, statusChange.after) : "neutral";
2283
+ const callout = statusChange ? renderStatusCallout(statusChange.before, statusChange.after, kind) : "";
2284
+ const count = changes.length;
2285
+ const tabLabel = statusChange && kind !== "neutral" ? `${kind === "deactivated" ? "Deactivated" : "Activated"}${count > 1 ? ` \xB7 ${count} changes` : ""}` : `${count} flow-level ${count === 1 ? "change" : "changes"}`;
2286
+ return `<button type="button" id="flow-tab" class="flow-tab ${kind}" aria-expanded="false" aria-controls="flow-popover">
2287
+ <span class="flow-tab-dot"></span>${escapeHtml2(tabLabel)}<span class="flow-tab-chev">\u25BE</span>
2288
+ </button>
2289
+ <div id="flow-popover" class="flow-popover" role="region" aria-label="Flow-level changes" hidden>
2290
+ <div class="flow-popover-head">Flow-level changes</div>
2291
+ <div class="flow-popover-body">
2292
+ ${callout}
2293
+ <div class="flow-banner-list">
2294
+ ${changes.map((change) => `<div class="flow-change-row">
2295
+ <div class="flow-change-label">${escapeHtml2(humanizePath(change.path))}</div>
2296
+ <div class="flow-change-value">${renderValueDelta(change.before, change.after)}</div>
2297
+ </div>`).join("")}
2298
+ </div>
2299
+ </div>
2300
+ </div>`;
2301
+ }
2302
+ function getStatusKind(before, after) {
2303
+ const beforeText = String(before);
2304
+ const afterText = String(after);
2305
+ if (beforeText === "Active" && afterText !== "Active") return "deactivated";
2306
+ if (beforeText !== "Active" && afterText === "Active") return "activated";
2307
+ return "neutral";
2308
+ }
2309
+ function renderStatusCallout(before, after, kind) {
2310
+ const beforeText = String(before);
2311
+ const afterText = String(after);
2312
+ if (kind === "deactivated") {
2313
+ return `<div class="flow-banner-callout deactivated">Deactivated (${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)})</div>`;
2314
+ }
2315
+ if (kind === "activated") {
2316
+ return `<div class="flow-banner-callout activated">Activated (${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)})</div>`;
2317
+ }
2318
+ return `<div class="flow-banner-callout neutral">Status: ${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)}</div>`;
2319
+ }
2320
+ function renderValueDelta(before, after) {
2321
+ if (before === void 0) {
2322
+ return `<span class="val ins">${escapeHtml2(formatValue(after))}</span>`;
2323
+ }
2324
+ if (after === void 0) {
2325
+ return `<span class="val del">${escapeHtml2(formatValue(before))}</span>`;
2326
+ }
2327
+ return `<span class="val del">${escapeHtml2(formatValue(before))}</span><span class="arrow">-></span><span class="val ins">${escapeHtml2(formatValue(after))}</span>`;
2328
+ }
2329
+ function formatValue(value) {
2330
+ if (value === void 0) {
2331
+ return "(missing)";
2332
+ }
2333
+ return String(value);
2334
+ }
2076
2335
  function fitViewBox(contentWidth, contentHeight) {
2077
2336
  const minWidth = 720;
2078
2337
  const minHeight = 540;
@@ -2236,8 +2495,10 @@ async function main(argv = process.argv.slice(2)) {
2236
2495
  }
2237
2496
  }
2238
2497
  async function runFileMode(oldPath, newPath, outDir, writeJson) {
2239
- const oldModel = buildModel(await parseXml(readFlowFromFile(oldPath)));
2240
- const newModel = buildModel(await parseXml(readFlowFromFile(newPath)));
2498
+ const oldXml = readFlowFromFile(oldPath);
2499
+ const newXml = readFlowFromFile(newPath);
2500
+ const oldModel = await buildModelWithHeader(oldXml);
2501
+ const newModel = await buildModelWithHeader(newXml);
2241
2502
  await writeArtifacts(oldModel, newModel, outDir, writeJson);
2242
2503
  }
2243
2504
  async function runGitMode(repo, from, to, pattern, outDir, writeJson, changedOnly) {
@@ -2247,8 +2508,8 @@ async function runGitMode(repo, from, to, pattern, outDir, writeJson, changedOnl
2247
2508
  try {
2248
2509
  const oldXml = readFlowFromGit(repo, from, filePath);
2249
2510
  const newXml = readFlowFromGit(repo, to, filePath);
2250
- const oldModel = oldXml ? buildModel(await parseXml(oldXml)) : buildEmptyModel();
2251
- const newModel = newXml ? buildModel(await parseXml(newXml)) : buildEmptyModel();
2511
+ const oldModel = oldXml ? await buildModelWithHeader(oldXml) : buildEmptyModel();
2512
+ const newModel = newXml ? await buildModelWithHeader(newXml) : buildEmptyModel();
2252
2513
  await writeArtifacts(oldModel, newModel, outDir, writeJson, filePath);
2253
2514
  } catch (error) {
2254
2515
  hadFailure = true;
@@ -2263,6 +2524,11 @@ async function parseXml(xml) {
2263
2524
  const parser = new FlowParser(xml);
2264
2525
  return parser.generateFlowDefinition();
2265
2526
  }
2527
+ async function buildModelWithHeader(xml) {
2528
+ const model = buildModel(await parseXml(xml));
2529
+ model.header = await extractFlowHeader(xml);
2530
+ return model;
2531
+ }
2266
2532
  function buildEmptyModel() {
2267
2533
  return {
2268
2534
  flowName: "(unknown)",
@@ -2281,9 +2547,15 @@ async function writeArtifacts(oldModel, newModel, outDir, writeJson, sourcePath)
2281
2547
  writeFileSync(join(outDir, `${fileStem}.diff.json`), JSON.stringify(diff, null, 2), "utf8");
2282
2548
  }
2283
2549
  console.log(
2284
- `${diff.flowName}: nodes ${diff.summary.addedNodes} added, ${diff.summary.removedNodes} deleted, ${diff.summary.modifiedNodes} modified; edges ${diff.summary.addedEdges} added, ${diff.summary.removedEdges} deleted`
2550
+ `${diff.flowName}: nodes ${diff.summary.addedNodes} added, ${diff.summary.removedNodes} deleted, ${diff.summary.modifiedNodes} modified; edges ${diff.summary.addedEdges} added, ${diff.summary.removedEdges} deleted${formatFlowAttributeSummary(diff.flowChanges)}`
2285
2551
  );
2286
2552
  }
2553
+ function formatFlowAttributeSummary(flowChanges) {
2554
+ if (!flowChanges || flowChanges.length === 0) {
2555
+ return "";
2556
+ }
2557
+ return `; flow attributes: ${flowChanges.length} changed (${flowChanges.map((change) => change.path).join(", ")})`;
2558
+ }
2287
2559
  function discoverGitFiles(repo, from, to, pattern, changedOnly) {
2288
2560
  const matcher = createPathMatcher(pattern);
2289
2561
  if (!changedOnly) {