@syntax-syllogism/flow-delta 0.4.3 → 0.5.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 +42 -10
  2. package/dist/cli.js +200 -37
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,9 +1,14 @@
1
1
  # FlowDelta
2
2
 
3
- A semantic, visual diff for Salesforce Flows: parse two versions of a
4
- `.flow-meta.xml`, diff them by stable node name, and render a self-contained
5
- interactive HTML artifact (plus `diff.json`) that shows added / deleted /
6
- modified / unchanged nodes and edges with per-property deltas.
3
+ <img width="1638" height="1392" alt="Screencast Demo" src="https://github.com/user-attachments/assets/b3f5f248-485a-4f62-9955-805a1b57186b" />
4
+
5
+ [![npm](https://img.shields.io/npm/v/@syntax-syllogism/flow-delta.svg)](https://www.npmjs.com/package/@syntax-syllogism/flow-delta)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+
8
+ **Stop reviewing Salesforce Flow changes as raw XML.** FlowDelta parses two
9
+ versions of a `.flow-meta.xml`, diffs them by stable node name, and renders a
10
+ self-contained interactive HTML artifact (plus `diff.json`) that shows added /
11
+ deleted / modified / unchanged nodes and edges with per-property deltas.
7
12
 
8
13
  Inspired by Google's [Flow Lens](https://github.com/google/flow-lens), with a
9
14
  focus on CI review workflows for GitLab merge requests and GitHub pull requests.
@@ -12,21 +17,44 @@ We also opted for our own HTML output over plantuml, graphviz, or mermaid.
12
17
  - [Sample GitLab project with artifacts](https://gitlab.com/j.p.richter/flow-delta-example/-/merge_requests/)
13
18
  - [Sample GitHub project with artifacts](https://github.com/Syntax-Syllogism/flow-delta-example/pulls)
14
19
 
20
+ ## Quick Start
21
+
22
+ No install required:
23
+
24
+ ```bash
25
+ npx @syntax-syllogism/flow-delta --old before.flow-meta.xml --new after.flow-meta.xml --out ./flow-delta-out
26
+ ```
27
+
28
+ Open the generated `.html` file in a browser to see the interactive diff
29
+ shown above.
30
+
31
+ Prefer a permanent install?
32
+
33
+ ```bash
34
+ npm install -g @syntax-syllogism/flow-delta
35
+ flow-delta --old before.flow-meta.xml --new after.flow-meta.xml --out ./flow-delta-out
36
+ ```
37
+
15
38
  ## Usage
16
39
 
17
- Runs as a TypeScript CLI via `tsx` (no build step). See [docs/cli.md](docs/cli.md)
18
- for full file-mode and git-mode options.
40
+ See [docs/cli.md](docs/cli.md) for full file-mode and git-mode options.
19
41
 
20
42
  ```bash
21
- npx tsx src/cli.ts --old before.flow-meta.xml --new after.flow-meta.xml --out ./flow-delta-out --json
43
+ # File mode compare two local files
44
+ npx @syntax-syllogism/flow-delta --old before.flow-meta.xml --new after.flow-meta.xml --out ./flow-delta-out --json
45
+
46
+ # Git mode — compare two refs in a repo
47
+ npx @syntax-syllogism/flow-delta --repo /path/to/sfdx-repo --from main --to feature-branch --path 'force-app/**/*.flow-meta.xml' --out ./flow-delta-out
22
48
  ```
23
49
 
24
50
  CI reporters read the generated `flow-delta-out/*.diff.json` files and post a
25
- sticky review comment:
51
+ sticky review comment. Install the package first (these binaries don't map
52
+ 1:1 to the package name, so plain `npx` won't resolve them):
26
53
 
27
54
  ```bash
28
- flow-delta-gitlab --in flow-delta-out
29
- flow-delta-github --in flow-delta-out
55
+ npm install @syntax-syllogism/flow-delta
56
+ npx flow-delta-gitlab --in flow-delta-out
57
+ npx flow-delta-github --in flow-delta-out
30
58
  ```
31
59
 
32
60
  The GitHub reporter can also consume `--artifact-urls <manifest.json>` for
@@ -36,10 +64,14 @@ recipes, private-repo artifact viewing, and smoke harnesses.
36
64
 
37
65
  ## Development
38
66
 
67
+ Working from a clone of this repo? Runs as a TypeScript CLI via `tsx`, no
68
+ build step:
69
+
39
70
  ```bash
40
71
  npm install
41
72
  npm test # parser + semantic diff / render / CLI suites
42
73
  npm run render:fixtures # render every diff fixture to flow-delta-out/fixtures/
74
+ npx tsx src/cli.ts --old before.flow-meta.xml --new after.flow-meta.xml --out ./flow-delta-out --json
43
75
  ```
44
76
 
45
77
  Architecture, testing, and the vendored-parser policy are documented in
package/dist/cli.js CHANGED
@@ -1559,6 +1559,7 @@ function snapshotPanelClientScript() {
1559
1559
  }
1560
1560
 
1561
1561
  // src/render/render-html.ts
1562
+ var THEME_STORAGE_KEY = "flow-delta-theme";
1562
1563
  function renderHtml(layout) {
1563
1564
  const data = {
1564
1565
  diff: layout.diff,
@@ -1582,19 +1583,65 @@ function renderHtml(layout) {
1582
1583
  <meta charset="utf-8" />
1583
1584
  <meta name="viewport" content="width=device-width, initial-scale=1" />
1584
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>
1585
1598
  <style>
1586
1599
  :root {
1587
1600
  color-scheme: light;
1588
1601
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
1589
- --bg: #f5f7fb; --surface: #ffffff; --border: #e3e8ef;
1602
+ --bg: #f5f7fb; --surface: #ffffff; --surface-muted: #f8fafc; --surface-subtle: #fbfcfe; --surface-selected: #eff6ff; --border: #e3e8ef; --focus: #93c5fd;
1590
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;
1591
1606
  --added: #16a34a; --added-fill: #dcfce7;
1592
1607
  --deleted: #dc2626; --deleted-fill: #fee2e2;
1593
1608
  --modified: #d97706; --modified-fill: #fef3c7;
1594
1609
  --unchanged: #94a3b8; --unchanged-fill: #e9edf3;
1595
1610
  --edge: #64748b; --fault: #7c3aed;
1611
+ --inserted-text: #14532d; --deleted-text: #7f1d1d; --deleted-line: rgba(127, 29, 29, 0.5);
1596
1612
  --panel-width: 34vw;
1597
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
+ }
1598
1645
  * { box-sizing: border-box; }
1599
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; }
1600
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); }
@@ -1610,7 +1657,7 @@ function renderHtml(layout) {
1610
1657
  .filters { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
1611
1658
  .filters button {
1612
1659
  border: 1px solid var(--border);
1613
- background: #f8fafc;
1660
+ background: var(--surface-muted);
1614
1661
  color: var(--muted);
1615
1662
  padding: 6px 11px;
1616
1663
  border-radius: 999px;
@@ -1621,15 +1668,19 @@ function renderHtml(layout) {
1621
1668
  transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
1622
1669
  }
1623
1670
  .filters button.active {
1624
- background: #eff6ff;
1625
- border-color: #93c5fd;
1671
+ background: var(--surface-selected);
1672
+ border-color: var(--focus);
1626
1673
  color: var(--text);
1627
1674
  box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.12);
1628
1675
  }
1629
1676
  .filters button:focus-visible {
1630
- outline: 2px solid #93c5fd;
1677
+ outline: 2px solid var(--focus);
1631
1678
  outline-offset: 2px;
1632
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; }
1633
1684
  .legend { display: flex; gap: 14px; font-size: 12px; flex-wrap: wrap; color: var(--muted); }
1634
1685
  .legend span::before { content: ""; display: inline-block; width: 9px; height: 9px; border-radius: 2px; margin-right: 6px; vertical-align: 0; }
1635
1686
  .legend .added::before { background: var(--added); }
@@ -1642,8 +1693,8 @@ function renderHtml(layout) {
1642
1693
  svg {
1643
1694
  width: 100%; height: 100%; display: block;
1644
1695
  background:
1645
- radial-gradient(circle, #dfe5ee 1px, transparent 1.4px) -11px -11px / 22px 22px,
1646
- 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));
1647
1698
  }
1648
1699
  .panel { position: relative; min-width: 0; border-left: 1px solid var(--border); background: var(--surface); padding: 20px; overflow-y: auto; overflow-x: hidden; }
1649
1700
  body.panel-collapsed .panel { padding: 0; border-left: 0; overflow: hidden; }
@@ -1651,24 +1702,24 @@ function renderHtml(layout) {
1651
1702
  /* drag-to-resize handle on the panel's left edge */
1652
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; }
1653
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; }
1654
- .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; }
1655
1706
  body.panel-collapsed .panel-resizer { display: none; }
1656
1707
  .panel-head { display: flex; align-items: flex-start; gap: 8px; }
1657
1708
  .panel-head h2 { flex: 1; min-width: 0; }
1658
- .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; }
1659
1710
  .panel-toggle { flex: none; width: 26px; height: 26px; font-size: 16px; display: grid; place-items: center; }
1660
- .panel-toggle:hover, .panel-reopen:hover { color: var(--text); border-color: #93c5fd; background: #eff6ff; }
1661
- .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; }
1662
1713
  /* floating tab to reopen the panel once collapsed */
1663
- .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); }
1664
1715
  body.panel-collapsed .panel-reopen { display: inline-flex; align-items: center; gap: 6px; }
1665
1716
  .panel h2 { margin: 0 0 8px; font-size: 15px; font-weight: 650; letter-spacing: -0.01em; }
1666
- .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; }
1667
1718
  .panel.added .badge { background: var(--added-fill); color: var(--added); }
1668
1719
  .panel.deleted .badge { background: var(--deleted-fill); color: var(--deleted); }
1669
1720
  .panel.modified .badge { background: var(--modified-fill); color: var(--modified); }
1670
1721
  .panel .empty { color: var(--muted); font-size: 13px; }
1671
- .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; }
1672
1723
  .detail-section.snapshot-section { border-left-width: 3px; }
1673
1724
  .detail-section.snapshot-section.added { border-left-color: rgba(22, 163, 74, 0.5); }
1674
1725
  .detail-section.snapshot-section.deleted { border-left-color: rgba(220, 38, 38, 0.45); }
@@ -1684,8 +1735,8 @@ function renderHtml(layout) {
1684
1735
  .change-kind.removed { color: var(--deleted); background: var(--deleted-fill); }
1685
1736
  /* git-diff value grammar */
1686
1737
  .val { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 12px; border-radius: 4px; padding: 1px 5px; overflow-wrap: anywhere; }
1687
- .val.ins { color: #14532d; background: var(--added-fill); }
1688
- .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); }
1689
1740
  .val.faint { color: var(--muted); background: transparent; padding-left: 0; }
1690
1741
  .val.one-sided { color: var(--text); background: transparent; padding-left: 0; }
1691
1742
  .val em { font-style: normal; color: var(--faint); }
@@ -1722,13 +1773,39 @@ function renderHtml(layout) {
1722
1773
  .group-head { display: flex; align-items: center; gap: 7px; margin-bottom: 8px; }
1723
1774
  .group-label { font-weight: 700; font-size: 12.5px; }
1724
1775
  .outcome-group .changes { margin-bottom: 8px; }
1725
- .flow-banner { border-bottom: 1px solid var(--border); background: #fffaf0; padding: 12px 20px; }
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; }
1726
1802
  .flow-banner-callout { margin-bottom: 8px; font-weight: 750; }
1727
1803
  .flow-banner-callout.deactivated { color: var(--deleted); }
1728
1804
  .flow-banner-callout.activated { color: var(--added); }
1729
1805
  .flow-banner-callout.neutral { color: var(--modified); }
1730
- .flow-banner-list { display: grid; gap: 7px; max-width: 960px; }
1731
- .flow-change-row { display: grid; grid-template-columns: minmax(120px, 190px) minmax(0, 1fr); align-items: start; gap: 10px; }
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; }
1732
1809
  .flow-change-label { color: var(--muted); font-size: 12px; font-weight: 700; }
1733
1810
  .flow-change-value { min-width: 0; max-height: 180px; overflow: auto; }
1734
1811
  .edge { fill: none; }
@@ -1741,26 +1818,26 @@ function renderHtml(layout) {
1741
1818
  #arrow-fault path { fill: var(--fault); }
1742
1819
  #arrow-added path { fill: var(--added); }
1743
1820
  #arrow-deleted path { fill: var(--deleted); }
1744
- .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)); }
1745
1822
  .node { cursor: pointer; }
1746
- .node text { font-size: 12px; fill: #111827; pointer-events: none; }
1823
+ .node text { font-size: 12px; fill: var(--node-text); pointer-events: none; }
1747
1824
  .node.added rect { fill: var(--added-fill); stroke: var(--added); }
1748
1825
  .node.deleted rect { fill: var(--deleted-fill); stroke: var(--deleted); }
1749
1826
  .node.modified rect { fill: var(--modified-fill); stroke: var(--modified); }
1750
1827
  .node.unchanged rect { fill: var(--unchanged-fill); stroke: var(--unchanged); }
1751
- .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)); }
1752
1829
  .node-label { white-space: pre; }
1753
1830
  /* Type-based styling \u2014 SHAPE & stroke pattern only. Status (added/deleted/
1754
1831
  modified/unchanged) remains the sole authority on fill/stroke COLOR, so
1755
1832
  the legend stays reliable. Start/end stand out via a pill shape, heavier
1756
1833
  stroke, and a neutral emphasis halo \u2014 never via a status-like color. */
1757
- .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)); }
1758
1835
  .node.type-recordLookup rect, .node.type-recordCreate rect, .node.type-recordUpdate rect, .node.type-recordDelete rect { rx: 4; ry: 4; }
1759
1836
  .node.type-screen rect { rx: 6; ry: 6; }
1760
1837
  .node.type-subflow rect { stroke-width: 2.5; }
1761
1838
  .node.type-loop rect { stroke-dasharray: 2 3; }
1762
1839
  .node.type-wait rect { stroke-dasharray: 4 4; }
1763
- .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)); }
1764
1841
  @media (max-width: 1000px) {
1765
1842
  .panel { padding: 16px; }
1766
1843
  }
@@ -1773,6 +1850,11 @@ function renderHtml(layout) {
1773
1850
  <div class="meta">${metaHtml}</div>
1774
1851
  </div>
1775
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>
1776
1858
  <div class="filters" role="group" aria-label="Diff view filters">
1777
1859
  <button type="button" class="active" data-view-mode="all">All</button>
1778
1860
  <button type="button" data-view-mode="after">After</button>
@@ -1787,9 +1869,9 @@ function renderHtml(layout) {
1787
1869
  </div>
1788
1870
  </div>
1789
1871
  </header>
1790
- ${flowBannerHtml}
1791
1872
  <main>
1792
1873
  <div class="canvas">
1874
+ ${flowBannerHtml}
1793
1875
  <svg id="flow-svg" viewBox="${viewBox}" preserveAspectRatio="xMidYMid meet" role="img" aria-label="Flow diff">
1794
1876
  <defs>
1795
1877
  <marker id="arrow-normal" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto" markerUnits="userSpaceOnUse">
@@ -1831,12 +1913,46 @@ function renderHtml(layout) {
1831
1913
  const panelBadge = document.getElementById("panel-badge");
1832
1914
  const panelBody = document.getElementById("panel-body");
1833
1915
  const filterButtons = [...document.querySelectorAll(".filters button")];
1916
+ const themeButtons = [...document.querySelectorAll(".theme-toggle button")];
1834
1917
  const nodesById = new Map(DATA.nodes.map((node) => [node.id, node]));
1835
1918
  const nodeElements = new Map([...document.querySelectorAll(".node")].map((node) => [node.dataset.nodeId, node]));
1836
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
+ }
1837
1927
  let viewBox = svg.viewBox.baseVal;
1838
1928
  let dragStart = null;
1839
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
+ }
1840
1956
 
1841
1957
  function isNodeVisible(node, mode) {
1842
1958
  if (mode === "after") return node.status !== "deleted";
@@ -2051,6 +2167,7 @@ function renderHtml(layout) {
2051
2167
  document.querySelectorAll(".node").forEach((node) => {
2052
2168
  node.addEventListener("click", (event) => {
2053
2169
  event.stopPropagation();
2170
+ closeFlowPopover();
2054
2171
  selectNode(node.dataset.nodeId);
2055
2172
  });
2056
2173
  });
@@ -2089,6 +2206,14 @@ function renderHtml(layout) {
2089
2206
  });
2090
2207
  });
2091
2208
 
2209
+ themeButtons.forEach((button) => {
2210
+ button.addEventListener("click", () => {
2211
+ const theme = button.dataset.themeChoice;
2212
+ applyTheme(theme);
2213
+ persistTheme(theme);
2214
+ });
2215
+ });
2216
+
2092
2217
  // Panel: collapse to focus on the canvas, and drag the left edge to resize.
2093
2218
  const panelToggle = document.getElementById("panel-toggle");
2094
2219
  const panelReopen = document.getElementById("panel-reopen");
@@ -2122,6 +2247,28 @@ function renderHtml(layout) {
2122
2247
  panelResizer.addEventListener("pointercancel", endResize);
2123
2248
  }
2124
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());
2125
2272
  applyView("all");
2126
2273
  </script>
2127
2274
  </body>
@@ -2132,24 +2279,40 @@ function renderFlowBanner(changes) {
2132
2279
  return "";
2133
2280
  }
2134
2281
  const statusChange = changes.find((change) => change.path === "status");
2135
- const callout = statusChange ? renderStatusCallout(statusChange.before, statusChange.after) : "";
2136
- return `<section class="flow-banner" aria-label="Flow-level changes">
2137
- ${callout}
2138
- <div class="flow-banner-list">
2139
- ${changes.map((change) => `<div class="flow-change-row">
2140
- <div class="flow-change-label">${escapeHtml2(humanizePath(change.path))}</div>
2141
- <div class="flow-change-value">${renderValueDelta(change.before, change.after)}</div>
2142
- </div>`).join("")}
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>
2143
2299
  </div>
2144
- </section>`;
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";
2145
2308
  }
2146
- function renderStatusCallout(before, after) {
2309
+ function renderStatusCallout(before, after, kind) {
2147
2310
  const beforeText = String(before);
2148
2311
  const afterText = String(after);
2149
- if (beforeText === "Active" && afterText !== "Active") {
2312
+ if (kind === "deactivated") {
2150
2313
  return `<div class="flow-banner-callout deactivated">Deactivated (${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)})</div>`;
2151
2314
  }
2152
- if (beforeText !== "Active" && afterText === "Active") {
2315
+ if (kind === "activated") {
2153
2316
  return `<div class="flow-banner-callout activated">Activated (${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)})</div>`;
2154
2317
  }
2155
2318
  return `<div class="flow-banner-callout neutral">Status: ${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)}</div>`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syntax-syllogism/flow-delta",
3
- "version": "0.4.3",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "description": "Semantic visual diff for Salesforce Flows",
6
6
  "license": "MIT",