Repository navigation
Expand file tree
/
Copy patharchitecture.html
More file actions
315 lines (298 loc) · 20.2 KB
/
Copy patharchitecture.html
File metadata and controls
315 lines (298 loc) · 20.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Python 3 Integration — Interactive Architecture (v4.1.0)</title>
<style>
:root{
--base:#1e1e2e; --mantle:#181825; --crust:#11111b; --surface0:#313244;
--surface1:#45475a; --text:#cdd6f4; --subtext:#a6adc8; --overlay:#6c7086;
--blue:#89b4fa; --green:#a6e3a1; --mauve:#cba6f7; --peach:#fab387;
--red:#f38ba8; --teal:#94e2d5; --yellow:#f9e2af; --sky:#89dceb;
--font-sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
--font-mono:"SFMono-Regular",ui-monospace,Menlo,Consolas,monospace;
}
*{box-sizing:border-box}
html,body{margin:0;height:100%;background:var(--base);color:var(--text);font-family:var(--font-sans);overflow:hidden}
#app{display:flex;height:100vh;width:100vw}
#stage{position:relative;flex:1;overflow:hidden;cursor:grab;background:
radial-gradient(circle at 1px 1px, rgba(108,112,134,.18) 1px, transparent 0) 0 0/28px 28px,
var(--base)}
#stage.grabbing{cursor:grabbing}
#viewport{position:absolute;top:0;left:0;transform-origin:0 0;will-change:transform}
#edges{position:absolute;top:0;left:0;overflow:visible;pointer-events:none}
.node{position:absolute;border:1px solid var(--surface1);border-left-width:4px;
background:linear-gradient(180deg,var(--surface0),#2a2b3c);border-radius:10px;
padding:9px 11px;width:188px;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.35);
transition:transform .08s ease, box-shadow .12s ease, opacity .12s ease, border-color .12s}
.node:hover{transform:translateY(-2px);box-shadow:0 8px 22px rgba(0,0,0,.5);border-color:var(--overlay)}
.node .t{font-weight:650;font-size:13px;line-height:1.2}
.node .s{font-size:10.5px;color:var(--subtext);margin-top:3px;font-family:var(--font-mono)}
.node .badge{display:inline-block;margin-top:6px;font-size:9.5px;font-weight:700;letter-spacing:.3px;
padding:1px 6px;border-radius:999px;background:rgba(243,139,168,.16);color:var(--red)}
.node.sel{outline:2px solid var(--blue);outline-offset:2px}
.node.dim{opacity:.22}
.lname{position:absolute;font-size:11px;font-weight:800;letter-spacing:2px;color:var(--overlay);
text-transform:uppercase;writing-mode:vertical-rl;transform:rotate(180deg)}
#side{width:360px;min-width:360px;background:var(--mantle);border-left:1px solid var(--surface0);
display:flex;flex-direction:column;height:100vh}
#side header{padding:16px 18px 12px;border-bottom:1px solid var(--surface0)}
#side h1{font-size:15px;margin:0 0 3px}
#side .ver{font-family:var(--font-mono);font-size:11px;color:var(--green)}
#side .hint{font-size:11.5px;color:var(--subtext);margin-top:8px;line-height:1.5}
#detail{padding:16px 18px;overflow:auto;flex:1}
#detail .empty{color:var(--overlay);font-size:12.5px;line-height:1.6}
#detail h2{font-size:15px;margin:0 0 2px}
#detail .layer{display:inline-block;font-size:10px;font-weight:700;letter-spacing:.5px;
text-transform:uppercase;padding:2px 8px;border-radius:999px;margin-bottom:12px}
#detail .lbl{font-size:10px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;
color:var(--overlay);margin:14px 0 5px}
#detail p{font-size:12.5px;line-height:1.55;margin:0;color:var(--text)}
#detail .files li{font-family:var(--font-mono);font-size:11px;color:var(--sky);line-height:1.7;
list-style:none;word-break:break-all}
#detail .files{margin:0;padding:0}
#detail .chg li{font-size:11.5px;line-height:1.5;margin-bottom:6px;color:var(--peach)}
#detail .chg{margin:0;padding-left:16px}
#detail .conn a{display:inline-block;font-size:11px;color:var(--blue);background:var(--surface0);
padding:2px 8px;border-radius:6px;margin:0 5px 5px 0;cursor:pointer;text-decoration:none}
#bar{position:absolute;top:12px;left:12px;display:flex;gap:8px;z-index:5;align-items:center}
#bar button,#bar input{font-family:var(--font-sans);font-size:12px;border:1px solid var(--surface1);
background:var(--surface0);color:var(--text);border-radius:8px;padding:6px 10px;cursor:pointer}
#bar button:hover{background:var(--surface1)}
#bar input{cursor:text;width:180px}
#legend{position:absolute;bottom:12px;left:12px;background:rgba(24,24,37,.85);border:1px solid var(--surface0);
border-radius:10px;padding:10px 12px;font-size:11px;z-index:5;backdrop-filter:blur(4px)}
#legend .row{display:flex;align-items:center;gap:7px;margin:3px 0;color:var(--subtext)}
#legend .sw{width:12px;height:12px;border-radius:3px;border-left:3px solid}
.edge{fill:none;stroke:var(--overlay);stroke-width:1.5;opacity:.28}
.edge.hi{stroke:var(--blue);stroke-width:2.4;opacity:.95}
.edge.hi.ext{stroke:var(--yellow)}
.elabel{font-family:var(--font-mono);font-size:9.5px;fill:var(--subtext);opacity:0}
.elabel.hi{opacity:1;fill:var(--blue)}
</style>
</head>
<body>
<div id="app">
<div id="stage">
<div id="bar">
<button id="reset">⟲ Reset view</button>
<button id="fit">⤢ Fit</button>
<input id="search" placeholder="filter nodes…"/>
<span style="font-size:11px;color:var(--overlay)">scroll = zoom · drag = pan · click a box</span>
</div>
<div id="viewport">
<svg id="edges"></svg>
</div>
<div id="legend"></div>
</div>
<aside id="side">
<header>
<h1>Python 3 Integration — Architecture</h1>
<div class="ver">module v4.1.0 · com.gaskony.python3</div>
<div class="hint">Runs real CPython 3 from Ignition via a pool of warm subprocesses talking line-based JSON. Four callers (Jython · Designer IDE · React Web UI · REST) funnel into one process pool. Click any node for its files and role; <b>red badges</b> mark this review's findings (see ARCHITECTURE.md §13).</div>
</header>
<div id="detail"><div class="empty">Select a node to see its responsibilities, key source files, and the data flows it participates in.<br><br>Tip: click a connected node name in the details to jump around.</div></div>
</aside>
</div>
<script>
const LAYERS = {
client: {name:"Callers", color:"var(--blue)", badge:"rgba(137,180,250,.16)"},
entry: {name:"Entry / security",color:"var(--mauve)", badge:"rgba(203,166,247,.16)"},
handler: {name:"Handlers", color:"var(--teal)", badge:"rgba(148,226,213,.16)"},
engine: {name:"Engine", color:"var(--green)", badge:"rgba(166,227,161,.16)"},
platform: {name:"Platform svcs",color:"var(--peach)",badge:"rgba(250,179,135,.16)"},
external: {name:"Subprocess", color:"var(--yellow)",badge:"rgba(249,226,175,.16)"},
};
const NODES = [
// callers
{id:"jython",label:"Jython scripts",sub:"system.python3.*",layer:"client",x:30,y:60,
files:["(Ignition project / gateway scripts)"],
desc:"Ignition's built-in Jython 2.7 calls into the module's scripting functions. Gated deny-by-default unless ignition.python3.scriptingFunctions.allowed=true."},
{id:"ide",label:"Designer IDE",sub:"Swing Script Console",layer:"client",x:30,y:190,
files:["designer/Python3IDE.java","designer/Python3RestClient.java","designer/managers/*","designer/ui/*"],
desc:"Swing Python 3 IDE added to the Designer Tools menu. Pure REST client (OkHttp) — no RPC. Long ops on SwingWorker, UI on the EDT."},
{id:"webui",label:"Gateway Web UI",sub:"React SPA",layer:"client",x:30,y:320,
files:["web-ui/src/App.tsx","web-ui/src/components/IDEView.tsx","ScriptsView · PackagesView · VersionsView · DiagnosticsView","web-ui/src/utils/api.ts"],
desc:"React/TS single-page app served at /res/python3integration/. Webpack UMD bundle (React 18) copied into the gateway jar at build time. Bearer-token auth + visibility-aware polling."},
{id:"rest",label:"External REST",sub:"curl / services",layer:"client",x:30,y:470,
files:["docs/api/REST_API.md"],
desc:"Any HTTP client hitting /data/python3integration/api/v1/* with a Bearer token or Gateway session."},
// entry / security
{id:"endpoints",label:"Python3RestEndpoints",sub:"~41 routes + security",layer:"entry",x:330,y:120,
files:["gateway/Python3RestEndpoints.java","common/ApiEndpoints.java"],
desc:"Mounts all routes under /data/python3integration/api/v1. withHandler() wraps every route with security headers + uniform error handling; each route has an accessControl check."},
{id:"scriptmod",label:"Python3ScriptModule",sub:"system.python3.*",layer:"entry",x:330,y:250,
files:["gateway/Python3ScriptModule.java","gateway/resources/Python3ScriptModule.properties"],
desc:"Implements the scripting functions (exec/eval/callModule/callScript/getVersion/…). Every call passes through the RoleResolver scripting gate."},
{id:"security",label:"Security pipeline",sub:"auth · CSRF · IP · rate",layer:"entry",x:330,y:380,
files:["gateway/Python3SecurityService.java","gateway/CsrfProtection.java","gateway/IpWhitelist.java"],
desc:"Gateway auth (session/Bearer/actor), CSRF tokens, IP whitelist for ADMIN, HTTPS enforcement, security headers, 100 req/min per IP."},
{id:"role",label:"RoleResolver",sub:"role gate (C13/C14)",layer:"entry",x:330,y:500,
files:["gateway/RoleResolver.java"],
desc:"Resolves real Ignition roles via WebUiSession (reflection). requireAdministrator() gates REST exec; requireAdministratorForScripting() is deny-by-default for Jython."},
// handlers
{id:"exech",label:"ExecutionHandlers",sub:"exec · eval · shell",layer:"handler",x:640,y:90,
files:["gateway/ExecutionHandlers.java"],
desc:"Handles /exec, /eval, /call-module, /call-script, /check-syntax, /completions, and interactive /shell/* routes."},
{id:"sph",label:"ScriptAndPackageHandlers",sub:"scripts · packages",layer:"handler",x:640,y:220,
files:["gateway/ScriptAndPackageHandlers.java"],
desc:"Saved-script CRUD and package management (catalog/status/install/uninstall/verify/search-pypi)."},
{id:"monh",label:"MonitoringHandlers",sub:"health · metrics",layer:"handler",x:640,y:350,
files:["gateway/MonitoringHandlers.java"],
desc:"version, pool-stats, health, diagnostics, metrics, gateway-impact, circuit-breaker, prometheus, logs, distributions."},
// engine
{id:"hook",label:"GatewayHook",sub:"lifecycle + wiring",layer:"engine",x:640,y:480,
files:["gateway/GatewayHook.java"],
desc:"setup/startup/shutdown. Registers web UI nav + scripting + routes, then spawns pools on a daemon thread (readinessFuture) so startup() never blocks."},
{id:"poolmgr",label:"PoolManager",sub:"per-version pools",layer:"engine",x:940,y:140,
files:["gateway/PoolManager.java","common/PoolConfig.java"],
desc:"Owns one Python3ProcessPool per configured/installed Python version and the default selection."},
{id:"pool",label:"Python3ProcessPool",sub:"3–20 warm procs",layer:"engine",x:940,y:270,
files:["gateway/Python3ProcessPool.java"],
desc:"Borrow/return warm executors via a BlockingQueue. Drives health checks, circuit breaker, metrics, alerts; resizePool(1..20)."},
{id:"executor",label:"Python3Executor",sub:"1 subprocess",layer:"engine",x:940,y:400,
files:["gateway/Python3Executor.java"],
desc:"Wraps one CPython process. Sends one JSON line on stdin, reads one back with a 30s timeout on a shared thread pool. Marks unhealthy on timeout/IO error.",
changes:["v4.1.0: per-process stderr drain thread (fixes pipe-buffer deadlock)","v4.1.0: removed unused InputValidator/ResourceLimits/EnhancedAuditLogger plumbing"]},
{id:"shell",label:"Python3InteractiveShell",sub:"REPL sessions",layer:"engine",x:940,y:530,
files:["gateway/Python3InteractiveShell.java"],
desc:"Stateful REPL ('terminal') sessions for the IDE; closed on shutdown."},
// platform services
{id:"obs",label:"Observability",sub:"metrics · breaker · alerts",layer:"platform",x:1240,y:120,
files:["gateway/MetricsCollector.java","gateway/Python3MetricsCollector.java","gateway/CircuitBreaker.java","gateway/AlertManager.java","gateway/PrometheusExporter.java"],
desc:"Per-pool + module metrics, circuit breaker, alerting, Prometheus scrape endpoint.",
changes:["v4.1.0: audit unified on Python3AuditLogger (redundant EnhancedAuditLogger removed)"]},
{id:"dist",label:"PythonDistributionManager",sub:"detect / download CPython",layer:"platform",x:1240,y:260,
files:["gateway/PythonDistributionManager.java"],
desc:"Detects or auto-downloads CPython; manages multiple installed versions."},
{id:"pkg",label:"Python3PackageManager",sub:"pip · wheels · PyPI",layer:"platform",x:1240,y:390,
files:["gateway/Python3PackageManager.java","gateway/resources/packages.json","gateway/resources/python-packages/*"],
desc:"Installs from bundled offline wheels first, falling back to PyPI. Auto-installs Jedi for autocomplete."},
{id:"repo",label:"Python3ScriptRepository",sub:"saved scripts",layer:"platform",x:1240,y:520,
files:["gateway/Python3ScriptRepository.java"],
desc:"Saved-script CRUD on disk under the Gateway data dir (python3-integration/)."},
// subprocess
{id:"bridge",label:"python_bridge.py",sub:"runs in each process",layer:"external",x:1240,y:650,
files:["gateway/resources/python_bridge.py"],
desc:"The Python side. Reads JSON from stdin, exec/eval/import with full CPython capability (no in-process sandbox by design — OS isolation is the boundary), writes JSON to stdout. Applies RLIMIT/Job-Object memory+CPU caps. security_mode accepted but ignored.",
changes:["§7: no sandbox by design — run the Gateway in a container/VM"]},
];
const EDGES = [
["jython","scriptmod","gated"],["ide","endpoints","REST"],["webui","endpoints","REST"],["rest","endpoints","REST"],
["endpoints","security",""],["endpoints","role",""],["scriptmod","role","scripting gate"],
["endpoints","exech",""],["endpoints","sph",""],["endpoints","monh",""],
["hook","endpoints","mount"],["hook","scriptmod","register"],["hook","poolmgr","spawn (async)"],["hook","dist",""],["hook","pkg",""],["hook","repo",""],
["scriptmod","pool",""],
["exech","pool",""],["exech","shell",""],
["sph","pkg",""],["sph","repo",""],["sph","dist",""],
["monh","obs",""],["monh","poolmgr","stats"],
["poolmgr","pool","per version"],["pool","executor","borrow/return"],["pool","obs","metrics"],
["dist","poolmgr","versions"],
["executor","bridge","stdin/stdout JSON",1],
];
const NW=188, viewport=document.getElementById('viewport'), svg=document.getElementById('edges'),
stage=document.getElementById('stage'), detail=document.getElementById('detail');
const byId=Object.fromEntries(NODES.map(n=>[n.id,n]));
NODES.forEach(n=>{ n.w=NW; n.h = n.changes&&n.changes.length?92:64; });
const colX={}; NODES.forEach(n=>{ colX[n.layer]=Math.min(colX[n.layer]??1e9,n.x); });
const nodeEls={};
NODES.forEach(n=>{
const L=LAYERS[n.layer];
const el=document.createElement('div'); el.className='node'; el.style.left=n.x+'px'; el.style.top=n.y+'px';
el.style.width=n.w+'px'; el.style.borderLeftColor=L.color;
el.innerHTML=`<div class="t">${n.label}</div><div class="s">${n.sub}</div>`+
(n.changes&&n.changes.length?`<div class="badge">${n.changes.length} note${n.changes.length>1?'s':''}</div>`:'');
el.addEventListener('click',e=>{e.stopPropagation();select(n.id);});
viewport.appendChild(el); nodeEls[n.id]=el;
});
Object.entries(colX).forEach(([layer,x])=>{
const lab=document.createElement('div'); lab.className='lname'; lab.textContent=LAYERS[layer].name;
lab.style.left=(x-22)+'px'; lab.style.top='30px'; lab.style.color=LAYERS[layer].color; lab.style.opacity=.55;
viewport.appendChild(lab);
});
function anchor(n,side){ return side==='r'?{x:n.x+n.w,y:n.y+n.h/2}:{x:n.x,y:n.y+n.h/2}; }
const edgeEls=[];
EDGES.forEach(([a,b,label,ext])=>{
const na=byId[a], nb=byId[b]; if(!na||!nb) return;
const rightward = nb.x>=na.x;
const p1=anchor(na,rightward?'r':'l'), p2=anchor(nb,rightward?'l':'r');
const dx=Math.max(40,Math.abs(p2.x-p1.x)*0.5);
const path=document.createElementNS('http://www.w3.org/2000/svg','path');
const c1x=p1.x+(rightward?dx:-dx), c2x=p2.x+(rightward?-dx:dx);
path.setAttribute('d',`M ${p1.x} ${p1.y} C ${c1x} ${p1.y}, ${c2x} ${p2.y}, ${p2.x} ${p2.y}`);
path.setAttribute('class','edge'+(ext?' ext0':''));
svg.appendChild(path);
let lblEl=null;
if(label){ lblEl=document.createElementNS('http://www.w3.org/2000/svg','text');
lblEl.setAttribute('x',(p1.x+p2.x)/2); lblEl.setAttribute('y',(p1.y+p2.y)/2-4);
lblEl.setAttribute('text-anchor','middle'); lblEl.setAttribute('class','elabel'); lblEl.textContent=label;
svg.appendChild(lblEl); }
edgeEls.push({a,b,path,lblEl,ext});
});
svg.setAttribute('width',1600); svg.setAttribute('height',780);
let selected=null;
function select(id){
selected=id; const n=byId[id]; const L=LAYERS[n.layer];
const conn=new Set();
edgeEls.forEach(e=>{
const on = e.a===id||e.b===id;
e.path.classList.toggle('hi',on); e.path.classList.toggle('ext',!!e.ext&&on);
if(e.lblEl) e.lblEl.classList.toggle('hi',on);
if(on){conn.add(e.a);conn.add(e.b);}
});
Object.entries(nodeEls).forEach(([nid,el])=>{
el.classList.toggle('sel',nid===id);
el.classList.toggle('dim',!(nid===id||conn.has(nid)));
});
const connList=[...conn].filter(c=>c!==id);
detail.innerHTML =
`<h2>${n.label}</h2>`+
`<span class="layer" style="background:${L.badge};color:${L.color}">${L.name}</span>`+
`<p>${n.desc}</p>`+
`<div class="lbl">Key files</div><ul class="files">${n.files.map(f=>`<li>${f}</li>`).join('')}</ul>`+
(n.changes&&n.changes.length?`<div class="lbl">Review notes (Jun 2026)</div><ul class="chg">${n.changes.map(c=>`<li>${c}</li>`).join('')}</ul>`:'')+
(connList.length?`<div class="lbl">Connected to</div><div class="conn">${connList.map(c=>`<a data-go="${c}">${byId[c].label}</a>`).join('')}</div>`:'');
detail.querySelectorAll('[data-go]').forEach(a=>a.addEventListener('click',()=>select(a.dataset.go)));
}
function clearSel(){
selected=null;
edgeEls.forEach(e=>{e.path.classList.remove('hi','ext'); if(e.lblEl)e.lblEl.classList.remove('hi');});
Object.values(nodeEls).forEach(el=>el.classList.remove('sel','dim'));
detail.innerHTML=`<div class="empty">Select a node to see its responsibilities, key source files, and the data flows it participates in.<br><br>Tip: click a connected node name in the details to jump around.</div>`;
}
stage.addEventListener('click',clearSel);
let tx=70,ty=30,scale=.78;
function apply(){viewport.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`;}
apply();
stage.addEventListener('wheel',e=>{
e.preventDefault();
const r=stage.getBoundingClientRect(), mx=e.clientX-r.left, my=e.clientY-r.top;
const f=e.deltaY<0?1.12:1/1.12, ns=Math.min(2.2,Math.max(.3,scale*f));
tx=mx-(mx-tx)*(ns/scale); ty=my-(my-ty)*(ns/scale); scale=ns; apply();
},{passive:false});
let drag=null;
stage.addEventListener('pointerdown',e=>{drag={x:e.clientX,y:e.clientY,tx,ty};stage.classList.add('grabbing');stage.setPointerCapture(e.pointerId);});
stage.addEventListener('pointermove',e=>{if(!drag)return;tx=drag.tx+(e.clientX-drag.x);ty=drag.ty+(e.clientY-drag.y);apply();});
stage.addEventListener('pointerup',e=>{drag=null;stage.classList.remove('grabbing');});
document.getElementById('reset').addEventListener('click',()=>{tx=70;ty=30;scale=.78;apply();clearSel();});
document.getElementById('fit').addEventListener('click',()=>{
const xs=NODES.map(n=>n.x), ys=NODES.map(n=>n.y), xe=NODES.map(n=>n.x+n.w), ye=NODES.map(n=>n.y+n.h);
const minX=Math.min(...xs)-40,minY=Math.min(...ys)-40,maxX=Math.max(...xe)+40,maxY=Math.max(...ye)+40;
const r=stage.getBoundingClientRect();
scale=Math.min(r.width/(maxX-minX),r.height/(maxY-minY),1.4);
tx=(r.width-(maxX-minX)*scale)/2-minX*scale; ty=(r.height-(maxY-minY)*scale)/2-minY*scale; apply();
});
document.getElementById('search').addEventListener('input',e=>{
const q=e.target.value.trim().toLowerCase();
Object.entries(nodeEls).forEach(([id,el])=>{
const n=byId[id]; const hit=!q||n.label.toLowerCase().includes(q)||n.sub.toLowerCase().includes(q)||(n.changes||[]).join(' ').toLowerCase().includes(q);
el.style.opacity=hit?'1':'.12'; el.style.outline=q&&hit?'2px solid var(--yellow)':'';
});
});
document.getElementById('legend').innerHTML=Object.values(LAYERS).map(L=>
`<div class="row"><span class="sw" style="border-left-color:${L.color};background:${L.badge}"></span>${L.name}</div>`).join('')
+`<div class="row" style="margin-top:6px"><span class="sw" style="border-left-color:var(--blue);background:transparent;border:1px solid var(--blue)"></span>highlighted flow</div>`;
</script>
</body>
</html>