@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.
- package/README.md +42 -10
- package/dist/cli.js +200 -37
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
# FlowDelta
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
<img width="1638" height="1392" alt="Screencast Demo" src="https://github.com/user-attachments/assets/b3f5f248-485a-4f62-9955-805a1b57186b" />
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@syntax-syllogism/flow-delta)
|
|
6
|
+
[](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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
-
flow-delta-
|
|
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:
|
|
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:
|
|
1625
|
-
border-color:
|
|
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
|
|
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,
|
|
1646
|
-
linear-gradient(
|
|
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:
|
|
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:
|
|
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:
|
|
1661
|
-
.panel-toggle:focus-visible, .panel-reopen:focus-visible { outline: 2px solid
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
1688
|
-
.val.del { color:
|
|
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
|
-
|
|
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;
|
|
1731
|
-
.flow-change-row { display: grid; grid-template-columns: minmax(
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
-
</
|
|
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 (
|
|
2312
|
+
if (kind === "deactivated") {
|
|
2150
2313
|
return `<div class="flow-banner-callout deactivated">Deactivated (${escapeHtml2(beforeText)} -> ${escapeHtml2(afterText)})</div>`;
|
|
2151
2314
|
}
|
|
2152
|
-
if (
|
|
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>`;
|