app
Adom Bridge
Public Made by Adomby adom
Adom Bridge unleashes your AI onto your full PC: full power, full safety. The AI breaks out of the container it runs in and onto your real machine, managing and running your entire laptop as you: launch apps, move files, drive any window, control KiCad, Fusion 360 or your real signed-in browser. Works with any AI, cloud or local (Claude Desktop, Claude Code, Codex), no Hydrogen required. Bridge and its bridges are an operating system for AI, with managed Node and Python runtimes, and a human-onl
master
John Lauer
web control: land the progress bar on the real outcome (was painting red stopped over a green 3/3 pass)
fc51f84
2mo ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 24 24%22%3E%3Cpath fill=%22%23fff%22 d=%22M12 2 3 21h4l5-11 5 11h4L12 2z%22/%3E%3C/svg%3E">
<title>Adom Bridge web control - local test harness</title>
<link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAEuklEQVR4nLWXT2xUVRTGf/e+13mddkrbaYf+AbRCBUGMogGMgDEGXBB1YVxoQlxgiBg34MaFCTHqVsW4EILGxIWy0kRNCAkRRFloNNUEW6UUWirt0HaY/pl2+mbee9fFmce00BkmtJzkzZ2Ze3PPd757znfuUxgDSsGpM+1EMvtwzSN4ns3dMNv2cNSf5GLH2PnUEMagABgb2E5f/9cQrCTrhouX1rnnyRh1wOj/WH//S9S3n1Nc+KONwcG/iDUmSCY9IhGNUmZpvRfMGEUuF9DSajORHuW+VQ/bXBx4neZEguEhD8e5O9SHphQ4juZa0qOtPUHflf0apTYxM21wHHVXnc81x1HMTBuU2iQR2/binBsjD0iUqoLtbFthjFo85caAZUGiGTSQnYX0RBHM7XAs2nlzE+RcOHlaMn3dati4AVIpmJq+LYg7B2AMNDbA0DAcPASzOaiywfdgw3o4dACa4jCWAq1LblN6phKLReHwUXHw0FroWAWrO+DyJdh7UJhZFivmx5IBCM99fBJG0tC6HLI5CAKZ6+gA34f3D0O8qexWi2NAa7CtWyOcnYUVbXC+B853Q2N9SRYWB6CcKQWWDRcuQbS6JIDKk9Dc+JBh7oZKIV2lkPHGQHUE8p70lKD0trcHEDqyrPn/RaOS9fk8ZLPye64Y9Q1AdTXs2AKjYyXLsTyAsNRiURifKhyYBYEPVVXChLIg60oChpEGvuTAm69BxIHJzB0ACEVmaBjeOgrX06ALLGjAzYvzLz4UIPl8Yd4XIA11kMmKDpQRo4UBhGWWc0VktAWtifkU66xErhARymbnO5rKFNeWsYWrwBjR9p9/k83X3FPIaqvw6EIOVEmiZbPgB1L7vi/fK2xKpY9AK/DykmizuSIwCWvhKijnMLz63exmwcVKSVTr1oi2l5HSsha2aaWgNirj3NZdFkB6Qrrag+vh6rCU1M1rPF+yv5Tz2lo5sogDmWkZHQtqaiRpSwIILZWCtw/IJv39heTTEI1AcgSWN0LDMjn3ufQaA4m4AO9YCclrsPtFkeXOTujrh7ZWMDkUJ779nrq6Z8lkfMCaByAIiv3+3cPQ3SPymveEkY/egZblkB4vAjBG6E6Owd4DsG0zTE7BTFYYSMTh5I8+Xx2zWNHyQ3kh0hpS16GuFj5+TyL495LI644tsuHNda4UzLqw9VF4+gno6RWQ9cskoMtX4PHN8ORW6P2nAilWSm42U9OwagWsXSNCMzq2sMIpBXYETp2F1Lg411qY0Rqa45DJwOlfoDleYTcMazo9AVeTMJy89dxD+p2I3Atf3S+OQufhvCqMe96Ant4CAM+rrM7CJNR64ZpXCtyctN/Pj0AsJrSHa5UqdFIFX35ieKDTaIzpoqZW4bpL8zZkDHg52LkdmhpgJFUEEQQwloaaqGH3LgV2l6bz3k+ZSo3S0mrjuh7GBIB/x48xPhHH5+yvPid/8mlL+LiuT3rCZ3omoCXu0T9oc/ybEZ7ZfUS4mRjaRnfvcfQiX049IALk8nJP7O2HxzZC19/w/Cvw2QfwwnPw3YlB9ux9GaXOqRtS+fuZdiYz+/CX4vVcQd5AfZWMJoDui7BhrYfjdLFj1zGUGsYY/geDsv+dOi74jwAAAABJRU5ErkJggg==">
<style>
:root { color-scheme: dark; }
html, body { margin: 0; padding: 0; background: #0d1117; color: #e6edf3; font-family: system-ui, "Segoe UI", sans-serif; }
/* Border-box everywhere so padding can never push a child past the viewport
and create a page-level horizontal scrollbar (always a bug, never a feature). */
*, *::before, *::after { box-sizing: border-box; }
/* FLUID column. It was pinned at 1000px, which on a 1900px window left ~420px
of dead space on EACH side and made the page look like it was ignoring the
window. It now grows with the viewport up to a readable ceiling, and the
.cards grid (auto-fill minmax) turns that extra width into more columns
instead of longer lines. Padding scales with the viewport too. */
.wrap {
width: 100%;
/* NO max-width. A cap leaves dead space on a wide monitor - John caught the
gap at ~1780px on a 2000px window. The .cards grid is auto-fill/minmax, so
extra width becomes MORE COLUMNS rather than longer lines, which is the
thing a max-width usually exists to prevent. Padding still scales with the
viewport, so the content never touches the frame. */
margin: 0;
padding: 24px clamp(16px, 2.5vw, 40px);
}
/* ── Scrollbars: the Adom pretty one, per adom-ui-design/skills/scrollbars ──
A default OS scrollbar in an Adom app is hideous (John, 2026-08-18). The
content is the subject; the scrollbar is a hint, not furniture. */
/* Firefox + the standard property */
* { scrollbar-width: thin; scrollbar-color: rgba(139,148,158,.38) transparent; }
/* `stable` (NOT `both-edges`): stable alone reserves the scrollbar's own gutter
so the layout never jumps when it appears. `both-edges` additionally reserves
a MATCHING gutter on the opposite side, which holds the scrollbar visibly off
the right edge of the window - John caught that immediately. The centering
argument for both-edges does not apply once the column is fluid. */
html { scrollbar-gutter: stable; }
/* Chromium / WebKit */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; } /* a solid track is what reads as "OS gutter" */
::-webkit-scrollbar-thumb {
background: rgba(139,148,158,.32);
border-radius: 999px; /* a pill, never a rectangle */
border: 2px solid transparent; /* with background-clip, insets the pill off the edge */
background-clip: padding-box;
}
::-webkit-scrollbar-thumb:hover { background: rgba(0,184,177,.55); background-clip: padding-box; }
::-webkit-scrollbar-corner { background: transparent; }
/* Fade-in-while-scrolling, the variant the skill prefers for DENSE surfaces -
and the per-card consoles are the densest thing on this page. Thumb is
invisible at rest, appears while scrolling, and dims back out. */
.slog::-webkit-scrollbar-thumb,
pre#log::-webkit-scrollbar-thumb,
.doc pre::-webkit-scrollbar-thumb { background: transparent; transition: background .25s ease; }
.slog.is-scrolling::-webkit-scrollbar-thumb,
pre#log.is-scrolling::-webkit-scrollbar-thumb,
.doc pre.is-scrolling::-webkit-scrollbar-thumb { background: rgba(139,148,158,.38); background-clip: padding-box; }
.slog:hover::-webkit-scrollbar-thumb,
pre#log:hover::-webkit-scrollbar-thumb,
.doc pre:hover::-webkit-scrollbar-thumb { background: rgba(139,148,158,.28); background-clip: padding-box; }
/* ROUNDED CORNERS: each of these scrolls ITSELF and carries a radius, so an
un-inset track paints straight through the top and bottom curves and the
box reads as clipped. Inset the track by at least the radius. */
.slog::-webkit-scrollbar-track { margin: 4px 0; } /* radius 4 */
pre#log::-webkit-scrollbar-track { margin: 6px 0; } /* radius 6 */
.doc pre::-webkit-scrollbar-track { margin: 6px 0; } /* radius 6 */
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { font-size: 12px; color: #8b949e; margin-bottom: 18px; }
.sub code { color: #79c0ff; }
/* Symmetric margin: with margin-bottom only, a bar that FOLLOWS a cards grid
(the part picker under "Notify me") sat flush against it and read as part of
the same block. */
.bar { display: flex; align-items: center; gap: 10px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 10px 14px; margin: 18px 0; flex-wrap: wrap; }
.bar label { font-size: 12px; color: #8b949e; }
select { background: #0d1117; color: #e6edf3; border: 1px solid #30363d; border-radius: 6px; padding: 6px 8px; font-size: 13px; }
#dot { width: 11px; height: 11px; border-radius: 50%; background: #484f58; display: inline-block; }
.grp { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: #6e7681; margin: 18px 0 8px; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; align-items: start; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 10px; display: flex; flex-direction: column; gap: 8px; }
.card button { padding: 8px 10px; border-radius: 6px; border: 1px solid #30363d; background: #21262d; color: #e6edf3; cursor: pointer; font-size: 13px; text-align: left; transition: opacity .12s, border-color .12s; }
.card.read button { background: #12181f; }
.card.verb button { background: #1a2230; border-color: #2b3a55; }
.card.multi button { background: #24202e; border-color: #4a3a63; }
.card button:hover:not(:disabled) { border-color: #8b949e; }
.card button:disabled { opacity: .5; cursor: default; }
.res { font-size: 12px; line-height: 1.35; min-height: 16px; color: #8b949e; word-break: break-word; }
.res .tag { font-weight: 600; }
.res.idle { color: #6e7681; }
.res.running { color: #8b949e; } /* neutral: started, nothing to worry about */
.res.pending { color: #d29922; }
.res.ok { color: #3fb950; }
.res.err { color: #f85149; }
.res small { color: #8b949e; font-weight: 400; }
.slog { margin: 4px 0 0; padding: 8px; background: #010409; border: 1px solid #21262d; border-radius: 4px; font-size: 11px; max-height: 180px; overflow: auto; white-space: pre-wrap; color: #9aa4af; line-height: 1.45; }
.shots { margin-top: 8px; display: flex; flex-direction: column; gap: 10px; }
.shots .cap { font-size: 10px; color: #8b949e; margin-bottom: 3px; }
.shots img { max-width: 100%; border: 1px solid #30363d; border-radius: 4px; display: block; }
.pbar { margin: 8px 0 2px; height: 16px; background: #010409; border: 1px solid #21262d; border-radius: 8px; overflow: hidden; position: relative; }
.pbar-fill { height: 100%; width: 0; background: linear-gradient(90deg, #1f6feb, #2ea043); transition: width .35s ease; }
.pbar-meta { font-size: 11px; color: #9aa4af; margin-bottom: 4px; }
.pbar-meta .beat { color: #6e7681; }
/* Progress states - each encodes an honesty rule, see renderProgressBlock(). */
/* Not "measured": the bridge is guessing, so it must not look as confident. */
.pbar-fill.pbar-estimated { opacity: .5; }
/* Parked on a HUMAN: amber, and it stops advancing. */
.pbar-fill.pbar-blocked { background: linear-gradient(90deg, #9e6a03, #d29922); }
.pbar-fill.pbar-failed { background: linear-gradient(90deg, #8b1a1a, #f85149); }
/* Working, but the bridge cannot estimate: sweep instead of a fake 0%. */
.pbar-fill.pbar-indeterminate {
background: repeating-linear-gradient(90deg, #1f6feb 0 12px, #10305f 12px 24px);
animation: pbar-sweep 1s linear infinite;
}
@keyframes pbar-sweep { from { background-position: 0 0; } to { background-position: 24px 0; } }
@media (prefers-reduced-motion: reduce) { .pbar-fill.pbar-indeterminate { animation: none; } }
.pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid #30363d; color: #8b949e; }
details { margin-top: 20px; } summary { cursor: pointer; color: #8b949e; font-size: 12px; }
pre#log { background: #010409; border: 1px solid #21262d; border-radius: 6px; padding: 12px; font-size: 11px; max-height: 240px; overflow: auto; white-space: pre-wrap; margin: 8px 0 0; color: #c9d1d9; }
.doc { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px 18px; font-size: 13px; line-height: 1.55; color: #c9d1d9; }
.doc h3 { font-size: 13px; margin: 18px 0 6px; color: #e6edf3; }
.doc h3:first-of-type { margin-top: 4px; }
.doc p { margin: 0 0 10px; }
.doc code { background: #0d1117; border: 1px solid #21262d; border-radius: 4px; padding: 1px 5px; font-size: 12px; color: #79c0ff; }
.doc pre { background: #010409; border: 1px solid #21262d; border-radius: 6px; padding: 12px; overflow: auto; margin: 0 0 10px; }
.doc pre code { background: none; border: none; color: #c9d1d9; padding: 0; font-size: 12px; line-height: 1.55; display: block; white-space: pre; }
.doc ul { margin: 6px 0 0; padding-left: 20px; } .doc li { margin: 5px 0; }
.doc .warn { color: #d29922; }
</style>
</head>
<body>
<div class="wrap">
<h1><span id="dot"></span> Adom Bridge web control - local test harness</h1>
<div class="sub">
Page origin: <code id="origin">...</code> · API: <code>http://127.0.0.1:24680</code>
· ab: <span id="ver" class="pill">unknown</span>
· web control: <span id="wc" class="pill">?</span>
</div>
<div id="sandbox-warn" style="display:none; margin:10px 0; padding:10px 14px; border:1px solid #f85149; border-radius:8px; background:rgba(248,81,73,.08); color:#f85149; font-size:14px;">
<b>This embed cannot reach Adom Bridge.</b> The page is running in a sandboxed view (origin <code>null</code>)
whose security policy blocks every fetch to <code>127.0.0.1</code>, so ALL buttons here will fail with
“Failed to fetch”. Open the page directly instead:
<a href="http://127.0.0.1:24681/" target="_blank" rel="noopener" style="color:#58a6ff">http://127.0.0.1:24681/</a>
(any real browser tab works).
</div>
<div class="bar">
<label for="target">Run on machine:</label>
<select id="target"><option value="">this machine (self)</option></select>
<button id="refresh-targets" style="background:#21262d;color:#e6edf3;border:1px solid #30363d;border-radius:6px;padding:6px 10px;cursor:pointer;font-size:13px">Detect my computers</button>
<span class="pill" id="target-count">self only until detected</span>
</div>
<div class="grp">Detect - open to any page, no permission prompt</div>
<div class="cards" id="grp-read"></div>
<div class="grp">Info verbs - each asks your permission</div>
<div class="cards" id="grp-info"></div>
<div class="grp">Do something - each asks permission, streams live status, then captures the app's FULL window state</div>
<div class="cards" id="grp-do"></div>
<div class="bar" id="part-bar">
<span>Demo part:</span>
<span id="part-picker"></span>
<span id="part-note" class="pill" style="border:none"></span>
<a id="part-link" href="#" target="_blank" rel="noopener" style="font-size:12px">open on the wiki ↗</a>
</div>
<div class="grp">KiCad surfaces - a REAL wiki part, end to end. Each button fetches the SELECTED part's files from its wiki page, installs them into your real KiCad, opens JUST that surface in the background, and renders it from kicad_state. This is the whole circle: wiki component to the app the EE designs in.</div>
<div class="cards" id="grp-kicad"></div>
<div class="grp">Fusion surfaces - the SAME selected part (.lbr + .step from its page), one first-class verb per content type (fusion_show_*, v1.9.10+). Component surfaces stage the library automatically; design surfaces additionally need the part project staged (fusion-bridge #30).</div>
<div class="cards" id="grp-fusion"></div>
<div class="grp">AI threads - hand a written prompt to the AI running in Hydrogen. The wiki composes the prompt; Adom Bridge opens a NAMED thread and stages it. It is left UNSENT by default: you read it and press enter. Evidence is a screenshot of the thread itself.</div>
<div class="bar" id="ai-bar">
<span>Thread name:</span>
<input id="ai-name" type="text" value="wiki thread" style="width:170px" />
<span>Prompt:</span>
<input id="ai-prompt" type="text" style="flex:1;min-width:260px"
value="Open the KiCad bridge page on the wiki and tell me what it does." />
<label style="font-size:12px;display:flex;align-items:center;gap:4px">
<input id="ai-submit" type="checkbox" /> send it (skip the human read)
</label>
</div>
<div class="cards" id="grp-ai"></div>
<div class="grp">Multi-machine - fan a verb across every connected computer</div>
<div class="cards" id="grp-multi"></div>
<div class="grp">How a web page drives Adom Bridge (the embed pattern + sample code)</div>
<div class="doc">
<p>There is <b>no SDK and no library</b>. A web page drives the machine with a plain <code>fetch()</code> (AJAX) to Adom Bridge's loopback HTTP API at <code>http://127.0.0.1:24680</code>. That is the whole integration: the wiki, or any other Adom surface, embeds a page whose buttons do the fetches below. Every state-changing call pops an Allow / Deny prompt in the app before anything runs.</p>
<h3>1. Detect it (open to any page, no prompt)</h3>
<pre><code>const r = await fetch("http://127.0.0.1:24680/health");
const h = await r.json(); // { ok: true, service: "adom-bridge" }
const running = h.service === "adom-bridge";</code></pre>
<h3>2. Run one verb (pops Allow / Deny in the app, then runs)</h3>
<pre><code>const r = await fetch("http://127.0.0.1:24680/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: "notify_user", args: { title: "Hi", body: "from a web page" } })
});
const out = await r.json();
// 200 = ran. 403 { errorCode: "web_denied" } = the user clicked Deny.
// A screenshot comes back as base64 you render inline:
// { command: "desktop_screenshot_base64", args: { hwnd: 12345, maxWidth: 900 } } -> out.image</code></pre>
<h3>3. Stream a long op (open app → wait for its window → screenshot), live status over ONE connection</h3>
<pre><code>const resp = await fetch("http://127.0.0.1:24680/command/stream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ label: "Open KiCad", steps: [
{ command: "kicad_open_symbol_editor", args: {}, label: "Launching KiCad", usuallySec: 30 },
{ waitForWindow: { titleContains: "Symbol Editor", timeoutSec: 90 }, label: "Waiting for the window" }
]})
});
// Read the Server-Sent-Events stream:
const reader = resp.body.getReader(), dec = new TextDecoder(); let buf = "";
for (;;) {
const { value, done } = await reader.read(); if (done) break;
buf += dec.decode(value, { stream: true }); let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const block = buf.slice(0, i); buf = buf.slice(i + 2);
let ev = "message", data = "";
block.split("\n").forEach(l => {
if (l.startsWith("event:")) ev = l.slice(6).trim();
else if (l.startsWith("data:")) data += l.slice(5).trim();
});
console.log(ev, JSON.parse(data)); // ev = start | progress | image | done
}
}</code></pre>
<h3>To capture an app's FULL state, screenshot every one of its windows</h3>
<pre><code>// 1) list windows, 2) keep the app's, 3) screenshot EACH by hwnd (the parent/child array).
const wins = JSON.parse((await (await fetch(API + "/command", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: "desktop_list_windows" }) })).json()).output).data.windows;
const mine = wins.filter(w => /kicad|symbol editor|pcb editor/i.test(w.title));
for (const w of mine) {
const shot = await (await fetch(API + "/command", { method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: "desktop_screenshot_base64", args: { hwnd: w.hwnd, maxWidth: 900 } }) })).json();
render(shot.image, w.title); // shot.image is base64 PNG
}</code></pre>
<h3>Security and embedding notes</h3>
<ul>
<li>Adom Bridge binds <b>127.0.0.1 only</b> and runs the human Allow / Deny gate on every command. Detection (<code>/health</code>, <code>/status</code>) is open; commands are gated. It also supports an optional cloud-K-V handshake so only the real signed-in wiki can even reach the prompt.</li>
<li><code>http://127.0.0.1</code> is a browser <b>secure context</b>, so an HTTPS page reaching it is <b>not</b> blocked as mixed content, and Adom Bridge returns the CORS + Private-Network-Access headers a public page needs.</li>
<li><span class="warn">Caveat - the wiki readme iframe cannot do this yet.</span> The wiki serves a readme.html inside a sandboxed, null-origin iframe with <code>Content-Security-Policy: ... connect-src 'self'</code>, which blocks the fetch to loopback. A normally-served page (like this one) works fine; the wiki-embedded case needs the wiki to allow <code>connect-src http://127.0.0.1:*</code> (filed as a wiki issue). Until then, embed this on a normally-served Adom page, not inside the sandboxed readme iframe.</li>
</ul>
</div>
<details><summary>Full request/response log</summary><pre id="log">Ready.</pre></details>
</div>
<script>
(function () {
var AB = "http://127.0.0.1:24680";
var log = document.getElementById("log");
var dot = document.getElementById("dot");
var sel = document.getElementById("target");
document.getElementById("origin").textContent = location.origin || "(null)";
// A sandboxed embed (wiki readme iframe, an in-app webview) runs at origin
// "null" and its CSP blocks EVERY loopback fetch - the whole page is dead
// there. Say so up front instead of letting each button fail cryptically.
var SANDBOXED = !location.origin || location.origin === "null";
function sandboxWarn() { document.getElementById("sandbox-warn").style.display = "block"; }
if (SANDBOXED) sandboxWarn();
// A dead fetch means the SANDBOX banner ONLY when we actually are sandboxed.
// In a real tab the same TypeError is a network transient (ab restarting, a
// stale keep-alive socket after a reboot) - showing the null-origin banner
// there was a lie John caught on 2026-08-17. Real-tab failures are handled
// by the one-shot POST retry in postRaw below.
function fetchDied(e) { if (SANDBOXED && /failed to fetch|networkerror/i.test(String(e))) sandboxWarn(); }
// Chrome auto-retries a GET on a stale reused connection but NEVER a POST -
// so one dead keep-alive socket makes exactly one button fail instantly.
// Retry a network-failed POST once after 600ms; a repeat failure is real.
function postRaw(body) {
var doFetch = function () {
return fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
};
return doFetch().catch(function (e) {
if (SANDBOXED || !/failed to fetch|networkerror/i.test(String(e))) throw e;
logLine(" (network drop - retrying the POST once)");
return new Promise(function (r) { setTimeout(r, 600); }).then(doFetch);
});
}
function logLine(m) { log.textContent += "\n" + m; log.scrollTop = log.scrollHeight; }
function chosenTarget() { return sel.value || null; }
// WHO is "this machine": ab 2.0.53+ reports its relay client name at
// /status.machine, so every card can say "on AdomLapper" instead of a vague
// "this machine" (John: "always tell me which machine this demo is running on").
var selfName = null, selfVersion = null, latestVersion = null;
function loadSelfName() {
return fetch(AB + "/status").then(function (r) { return r.json(); }).then(function (s) {
var m = s && s.machine;
if (m && (m.clientName || m.hostname)) selfName = m.clientName || m.hostname;
if (s && s.version) selfVersion = s.version;
return selfName;
}).catch(function () { return null; });
}
// The LATEST published ab, from the wiki's own update manifest (the same file
// the auto-updater reads) - so the picker can say whether a machine is
// up to date, not just what it runs (John, 2026-08-17).
function loadLatestVersion() {
return fetch("https://wiki.adom.inc/api/v1/pages/adom-bridge/files/version.json")
.then(function (r) { return r.json(); })
.then(function (v) { latestVersion = v && v.version; return latestVersion; })
.catch(function () { return null; });
}
function verParts(v) { return String(v || "0").split(".").map(function (x) { return parseInt(x, 10) || 0; }); }
function verBehind(v) { // true when v < latestVersion
if (!v || !latestVersion) return false;
var a = verParts(v), b = verParts(latestVersion);
for (var i = 0; i < 3; i++) { if ((a[i] || 0) < (b[i] || 0)) return true; if ((a[i] || 0) > (b[i] || 0)) return false; }
return false;
}
function verTag(v) { // "(v2.0.54 - update available)" | "(v2.0.55, latest)" | "(v2.0.55)"
if (!v) return "";
if (!latestVersion) return " (v" + v + ")";
return verBehind(v) ? " (v" + v + " - update available, latest " + latestVersion + ")" : " (v" + v + ", latest)";
}
function selfOptionLabel() { return "this machine (" + selfLabel() + ")" + (selfVersion ? verTag(selfVersion) : ""); }
Promise.all([loadSelfName(), loadLatestVersion()]).then(function () {
// Name + version the "self" option as soon as we know - no Detect needed.
if (selfName && sel.options.length && !sel.options[0].value) sel.options[0].textContent = selfOptionLabel();
});
// The label for where a run lands. Verbs that pass no target run on SELF.
function machineLabel(target) {
if (target === undefined) target = chosenTarget();
if (target === "all") return "ALL machines";
return target || selfName || "this machine";
}
function selfLabel() { return selfName || "this machine"; }
function setRes(el, cls, tag, detail) {
// Any result CANCELS a queued "asking permission" escalation (see setAwaiting).
el._awaitToken = (el._awaitToken || 0) + 1;
// If this run never had to ask, say so. A standing grant is invisible
// otherwise, and the user is left guessing whether approval happened.
if (cls === "ok" && el._promptShown === false) {
detail = (detail ? detail + " - " : "") + "auto-approved (no prompt needed)";
}
el.className = "res " + cls;
el.innerHTML = "<span class='tag'>" + tag + "</span>" + (detail ? " <small>" + detail + "</small>" : "");
}
// Start a run NEUTRALLY, and only claim we are waiting on a human if we still
// are after a beat.
//
// Every card used to announce "asking permission (choose Allow 1 hr)" in ORANGE
// the instant it started - even when the origin was already on Auto mode, where
// no prompt ever appears and the call returns in milliseconds. That reads as a
// permission PROBLEM when there is none (John, 2026-08-20). The page cannot
// simply read the grant list: a cross-origin /status deliberately projects it
// away (that disclosure was fixed in ab 2.0.65), so the honest signal available
// here is TIME - if the call has not come back, a human is being asked.
function setAwaiting(el, promptTag, promptDetail, runningDetail) {
// Say "running", and NOTHING about permission.
//
// Two earlier attempts got this wrong in the same direction. First every card
// announced "asking permission" the instant it started, which is a lie in Auto
// mode where no prompt ever appears. Then I escalated to that copy if the call
// was still outstanding after 1.2s - but a surface open legitimately runs 30-60
// SECONDS, so a slow verb looks exactly like a pending prompt and the lie came
// back. Elapsed time cannot tell those apart, so no threshold will ever fix it.
//
// A page cannot see its own grant either: a cross-origin /status deliberately
// projects the grants list away (ab#89). So the honest thing is to claim
// nothing while the call is in flight, and speak only when the RESULT tells us
// something real - which the failure paths now do, naming a permission problem
// only when the response actually is one.
el._promptShown = false;
el.className = "res running";
el.innerHTML = "<span class='tag'>running ...</span>" +
(runningDetail ? " <small>" + runningDetail + "</small>" : "");
el._awaitToken = (el._awaitToken || 0) + 1;
}
// Most desktop verbs return {success, output:"<stringified json>"}. Parse output up.
function flat(j) {
if (j && typeof j.output === "string" && j.output.charAt(0) === "{") {
try { return Object.assign({}, j, JSON.parse(j.output)); } catch (e) {}
}
return j;
}
function esc(s) { return (s || "").replace(/[<>&]/g, function (c) { return { "<": "<", ">": ">", "&": "&" }[c]; }); }
function doGet(el, path, summarize) {
setRes(el, "pending", "checking...", "");
logLine("GET " + AB + path);
fetch(AB + path).then(function (r) { return r.json().then(function (j) { return { s: r.status, j: j }; }); })
.then(function (res) { logLine(" <- HTTP " + res.s); if (res.s === 200) setRes(el, "ok", "OK", summarize ? summarize(res.j) : ""); else setRes(el, "err", "HTTP " + res.s, ""); })
.catch(function (e) { fetchDied(e); setRes(el, "err", "unreachable", SANDBOXED ? "sandboxed embed blocks loopback - open http://127.0.0.1:24681 directly (see banner)" : String(e)); });
}
function doVerb(el, command, args, target, summarize) {
var body = { command: command }; if (args) body.args = args;
if (target === undefined) target = chosenTarget(); if (target) body.target = target;
if (target) { body.args = body.args || {}; if (!body.args.caller) body.args.caller = { aiThread: "web-control demo page", containerName: selfLabel() }; }
var who = machineLabel(target || null);
setAwaiting(el, "asking permission...", "on " + esc(who) + " - approve the Adom Bridge toast (no banner? Windows may have tucked it in the notification center - press Win+N)", "on " + esc(who));
logLine("POST /command " + JSON.stringify(body) + " [on " + who + "]");
postRaw(body)
.then(function (r) { return r.json().then(function (j) { return { s: r.status, j: j }; }); })
.then(function (res) {
logLine(" <- HTTP " + res.s + " " + JSON.stringify(res.j).slice(0, 300));
if (res.s === 200) { var sum = summarize ? summarize(flat(res.j)) : ""; setRes(el, "ok", "Allowed & ran", "on " + esc(who) + (sum ? " - " + sum : "")); }
else if (res.j && res.j.errorCode === "web_control_disabled") setRes(el, "err", "Refused: web control OFF", "re-enable in Settings → Web Control");
else setRes(el, "err", "Not approved", ((res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s)) + " - missed the prompt? The toast waits in your notification center for 1 hour; Allow 1 hr there, then click again");
})
.catch(function (e) { fetchDied(e); setRes(el, "err", "blocked", SANDBOXED ? "sandboxed embed blocks loopback - open http://127.0.0.1:24681 directly (see banner)" : "network drop to ab (stale connection or ab restarting) - retried once; click again in a moment. " + String(e)); });
}
// POST that returns the flattened JSON. Optional target.
function postVerb(command, args, target) {
var body = { command: command }; if (args) body.args = args; if (target) body.target = target;
// A relayed verb arrives at the OTHER machine via_relay, where ab requires
// caller identity - name this page as the thread so the run is attributed.
if (target) { body.args = body.args || {}; if (!body.args.caller) body.args.caller = { aiThread: "web-control demo page", containerName: selfLabel() }; }
logLine("POST /command " + JSON.stringify(body));
return postRaw(body)
.then(function (r) { return r.json().then(function (j) { return { s: r.status, j: flat(j) }; }); });
}
// Loopback self POST (the window array + per-window screenshots run on THIS machine).
function postSelf(command, args) {
return fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command: command, args: args || {} }) })
.then(function (r) { return r.json().then(function (j) { return { s: r.status, j: flat(j) }; }); });
}
function windowsList() {
return postSelf("desktop_list_windows", {}).then(function (res) {
var j = res.j, out = j.output; try { if (typeof out === "string") out = JSON.parse(out); } catch (e) {}
return (out && out.data && out.data.windows) || (out && out.windows) || j.windows || [];
});
}
function card(groupId, cls, label) {
var c = document.createElement("div"); c.className = "card " + cls;
var b = document.createElement("button"); b.textContent = label;
var r = document.createElement("div"); r.className = "res idle"; r.textContent = "not run yet";
c.appendChild(b); c.appendChild(r); document.getElementById(groupId).appendChild(c);
return { btn: b, res: r, card: c };
}
// Per-card live stream log (right under the button).
function cardLog(card) {
var el = card.querySelector("pre.slog");
if (!el) { el = document.createElement("pre"); el.className = "slog"; card.appendChild(el); }
return el;
}
function clog(c, msg) { var el = cardLog(c.card); el.textContent += (el.textContent ? "\n" : "") + msg; el.scrollTop = el.scrollHeight; }
// Per-card gallery of screenshots (the app's window array).
function shotsBox(card) { var b = card.querySelector("div.shots"); if (!b) { b = document.createElement("div"); b.className = "shots"; card.appendChild(b); } return b; }
function addShot(card, b64, label) {
var box = shotsBox(card);
// Demo runs number their evidence in ARRIVAL order (1., 2., ...) so the
// gallery reads as the receipt stream it is - John's ask, 2026-08-16.
if (card._numberEvidence) { card._seq = (card._seq || 0) + 1; label = card._seq + ". " + label; }
var wrap = document.createElement("div");
var cap = document.createElement("div"); cap.className = "cap"; cap.textContent = label;
// A bridge may hand us a BARE base64 payload OR an already-complete data:
// URL - kicad's stepShot.image is the latter. Prefixing a data: URL again
// yields "data:image/png;base64,data:image/png;base64,..." which renders as
// a silently BROKEN image, so the failure reads as a bad screenshot from the
// bridge rather than the string bug it actually is. Normalize here, at the
// one sink every card feeds, instead of at each call site.
var img = document.createElement("img");
var src = String(b64 || "");
img.src = /^data:/i.test(src) ? src : "data:image/png;base64," + src;
wrap.appendChild(cap); wrap.appendChild(img); box.appendChild(wrap);
}
// Capture the app's FULL window state: list windows, keep the ones matching this
// app (include patterns, minus exclude patterns), and screenshot EACH by hwnd.
// This is the parent/child window array - the real "state of the union" of the app.
function captureWindows(c, spec) {
clog(c, "Capturing the app's full window state (all its windows) ...");
var inc = (spec.include || []).map(function (s) { return s.toLowerCase(); });
var exc = (spec.exclude || []).map(function (s) { return s.toLowerCase(); });
// A just-opened window (esp. a browser) may still be LOADING - its title is
// not the page title yet. So poll a few seconds before giving up.
var attempts = 0, maxAttempts = 8;
function findMatches() {
return windowsList().then(function (wins) {
return wins.filter(function (w) { var t = (w.title || "").toLowerCase(); return inc.some(function (p) { return t.indexOf(p) >= 0; }) && !exc.some(function (p) { return t.indexOf(p) >= 0; }); });
});
}
function attempt() {
attempts++;
return findMatches().then(function (match) {
if (!match.length) {
if (attempts < maxAttempts) { clog(c, " [" + attempts + "] no matching window yet - waiting for it to finish loading ..."); return new Promise(function (r) { setTimeout(r, 1200); }).then(attempt); }
clog(c, " no windows matched after " + attempts + " tries - is the app actually open?"); setRes(c.res, "err", "no windows to capture", "the app may not have opened, or its title never matched");
return;
}
clog(c, " found " + match.length + " window(s): " + match.map(function (w) { return '"' + (w.title || "").slice(0, 42) + '"'; }).join(", "));
var i = 0;
function next() {
if (i >= match.length) { setRes(c.res, "ok", "Done - full state below", match.length + " window(s)"); clog(c, "DONE - captured the app's full window state (" + match.length + " windows)."); return; }
var w = match[i++];
return postSelf("desktop_screenshot_base64", { hwnd: w.hwnd, maxWidth: 900 }).then(function (sr) {
if (sr.s === 200 && sr.j.image) { clog(c, " captured window " + i + "/" + match.length + ': "' + (w.title || "").slice(0, 50) + '" ' + sr.j.width + "x" + sr.j.height); addShot(c.card, sr.j.image, w.title || "window"); }
else { clog(c, " could not capture: \"" + (w.title || "") + "\""); }
return next();
});
}
return next();
});
}
return attempt();
}
// ---- SSE streaming (open -> wait for window), then capture the window array ----
function onStream(c, ev, dataStr) {
var d = {}; try { d = JSON.parse(dataStr); } catch (e) {}
var t = Math.round((d.elapsedMs || 0) / 1000);
var u = (d.usuallySec != null) ? (" (usually ~" + d.usuallySec + "s)") : "";
var k = (d.tookMs != null) ? (" in " + (d.tookMs / 1000).toFixed(1) + "s") : "";
var w = (d.waitedSec != null ? d.waitedSec : t);
if (ev === "start") { clog(c, "Adom Bridge accepted the recipe (" + d.steps + " steps). Streaming live:"); return; }
if (ev === "progress") {
if (d.state === "running") { setRes(c.res, "pending", d.label + " ...", "running" + u); clog(c, "> step " + d.step + "/" + d.of + ": " + d.label + u + " ..."); }
else if (d.state === "working") { setRes(c.res, "pending", d.label, w + "s - still working" + u); clog(c, " [" + w + "s] still working" + u + " ..."); }
else if (d.state === "waiting") { setRes(c.res, "pending", "waiting for the window", w + "s" + u); clog(c, " [" + w + "s] waiting for the window" + u + " ..."); }
else if (d.state === "appeared") { setRes(c.res, "pending", "window appeared" + k, d.window || ""); clog(c, ' window appeared: "' + (d.window || "") + '"' + k); }
else if (d.state === "ok") { clog(c, " step " + d.step + " done" + k); }
else if (d.state === "failed") { setRes(c.res, "err", "step " + d.step + " failed", JSON.stringify(d.result || {}).slice(0, 120)); clog(c, " step " + d.step + " FAILED" + k); }
return;
}
if (ev === "error") { setRes(c.res, "err", "error", d.error || ""); clog(c, " ERROR: " + (d.error || "")); return; }
if (ev === "done") {
if (d.ok) { clog(c, " launch confirmed (total " + t + "s). Now grabbing the full window state ..."); if (c._capture) captureWindows(c, c._capture); }
else { setRes(c.res, "err", "did not complete", d.reason || ("failed step " + d.failedStep)); clog(c, "DONE - did not complete."); }
return;
}
}
function streamRecipe(c, steps, capture) {
c._capture = capture;
c.btn.disabled = true;
cardLog(c.card).textContent = "";
var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";
setRes(c.res, "pending", "opening a live connection ...", "approve the toast, then watch");
logLine("POST /command/stream " + c.btn.textContent);
function finish() { c.btn.disabled = false; }
fetch(AB + "/command/stream", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label: c.btn.textContent, steps: steps }) })
.then(function (resp) {
if (!resp.ok) { return resp.json().catch(function () { return {}; }).then(function (j) { setRes(c.res, "err", (j.errorCode === "web_control_disabled" ? "web control OFF" : "refused"), j.error || j.errorCode || ("HTTP " + resp.status)); finish(); }); }
var reader = resp.body.getReader(), dec = new TextDecoder(), buf = "";
function pump() {
return reader.read().then(function (rr) {
if (rr.done) { finish(); return; }
buf += dec.decode(rr.value, { stream: true }); var idx;
while ((idx = buf.indexOf("\n\n")) >= 0) {
var raw = buf.slice(0, idx); buf = buf.slice(idx + 2);
var ev = "message", data = ""; raw.split("\n").forEach(function (l) { if (l.indexOf("event:") === 0) ev = l.slice(6).trim(); else if (l.indexOf("data:") === 0) data += l.slice(5).trim(); });
if (data) onStream(c, ev, data);
}
return pump();
});
}
return pump();
})
.catch(function (e) { setRes(c.res, "err", "blocked", String(e)); finish(); });
}
// ---- Bridge-OWNED demo verb: one long-running call, real progress bar ----
// This is the modern pattern: the BRIDGE owns a "demo" verb that runs the whole
// tour, reports structured progress (percent/stepLabel/beat/elapsed/estimated),
// and returns its full window state as inline screenshots. The web page just
// starts it and polls - no piecing bridge commands together, no title-guessing.
// (The KiCad bridge shipped kicad_demo in 0.9.92; Fusion's fusion_demo is next.)
function progressBar(card) {
var box = card.querySelector("div.pbar");
if (!box) {
var meta = document.createElement("div"); meta.className = "pbar-meta"; meta.textContent = "starting ...";
box = document.createElement("div"); box.className = "pbar";
var fill = document.createElement("div"); fill.className = "pbar-fill"; box.appendChild(fill);
// insert right after the .res line
var res = card.querySelector("div.res");
if (res && res.nextSibling) { card.insertBefore(meta, res.nextSibling); card.insertBefore(box, meta.nextSibling); }
else { card.appendChild(meta); card.appendChild(box); }
}
return { fill: box.querySelector(".pbar-fill"), meta: card.querySelector(".pbar-meta") };
}
function setProgress(card, pct, label, beatIdx, beatCount, elapsed, estimate) {
var p = progressBar(card);
p.fill.style.width = Math.max(0, Math.min(100, pct || 0)) + "%";
var beat = (beatCount ? ' <span class="beat">- step ' + Math.min((beatIdx || 0) + 1, beatCount) + "/" + beatCount + "</span>" : "");
var timing = (elapsed != null && estimate != null) ? ' <span class="beat">- ' + elapsed + "s of ~" + estimate + "s</span>" : "";
p.meta.innerHTML = (pct != null ? pct + "% " : "") + esc(label || "") + beat + timing;
}
// Drive the fade-in-while-scrolling scrollbar (adom-ui-design/skills/scrollbars).
// Delegated + capturing, so it covers consoles created LATER by a card run
// without re-wiring anything per element.
(function () {
var timers = new WeakMap();
document.addEventListener('scroll', function (e) {
var el = e.target;
if (!el || !el.classList || !(el.matches && el.matches('.slog, pre#log, .doc pre'))) return;
el.classList.add('is-scrolling');
clearTimeout(timers.get(el));
timers.set(el, setTimeout(function () { el.classList.remove('is-scrolling'); }, 900));
}, true);
})();
function esc(s) { return String(s).replace(/[&<>]/g, function (c) { return { "&": "&", "<": "<", ">": ">" }[c]; }); }
// ==========================================================================
// REFERENCE IMPLEMENTATION - rendering a bridge's measured progress on a web
// page. THIS is the part to copy onto wiki.adom.inc.
// --------------------------------------------------------------------------
// Everything below is bridge-AGNOSTIC. It never mentions kicad or fusion, and
// it works for a bridge that does not exist yet, because it matches on the
// SHAPE of the measured-progress contract every bridge emits:
//
// { phase, stepLabel, percent, elapsedSec, estimatedSec, etaSec,
// confidence, startKind, blockedBy?, measured? }
//
// Two functions, two jobs: readProgressBlock() gets the block out of whatever
// the bridge answered with, renderProgressBlock() draws it. Keeping them apart
// is deliberate - the reading half is where the traps are, the drawing half is
// where the honesty rules are, and you will want to restyle one without
// touching the other.
// ==========================================================================
// ---- 1. READ ------------------------------------------------------------
// A bridge reports progress at TWO different moments, and a page that reads
// only the first can show a bar that never moves:
//
// a) TERMINAL FRAME - a top-level `progress` on the slow verb's own
// response. Always arrives at percent:100, because by the time the verb
// answers, the work is finished. On its own this is a completion
// receipt, NOT a progress bar.
// b) LIVE POLL - an `active[]` array of those same blocks, one per
// in-flight phase, from the bridge's fast progress verb while the slow
// work is still running. THIS is the one that animates: poll it about
// once a second and you get the stream of rising frames a bar needs.
//
// ⚠ THE TRAP: `active` is overloaded in the wild. The canonical contract uses
// an ARRAY of blocks; some bridges' demo verbs use a BOOLEAN meaning
// "still running". Disambiguate by TYPE, never by truthiness - `active:false`
// and `active:[]` both mean idle, but `if (resp.active)` is true for a
// non-empty array AND for the boolean, and false for BOTH idle forms, so a
// truthiness test silently conflates a running job with a finished one.
function readProgressBlock(resp) {
if (!resp || typeof resp !== "object") return null;
// Terminal frame wins when present: it is a verb's authoritative last word,
// and it must not be overridden by a stale in-flight row.
if (isProgressBlock(resp.progress)) return resp.progress;
if (Array.isArray(resp.active)) {
var rows = resp.active.filter(isProgressBlock);
if (!rows.length) return null; // idle - draw nothing
// Several phases in flight: draw the LEAST COMPLETE one. The honest answer
// to "how far along is this?" is bounded by the slowest phase; drawing the
// furthest-along one promises a finish the work has not earned. A row with
// no percent sorts last so a row that HAS one wins.
return rows.slice().sort(function (a, b) {
return pctOf(a) - pctOf(b);
})[0];
}
return null;
}
function pctOf(b) {
return typeof b.percent === "number" ? b.percent : Infinity;
}
// A response can carry an unrelated field called `progress` (the toast
// contract uses {title, value, status}), so a key name proves nothing.
// Require a field only the measured-progress contract defines.
function isProgressBlock(o) {
return !!o && typeof o === "object" && !Array.isArray(o) &&
("percent" in o || "phase" in o || "etaSec" in o);
}
// ---- 2. RENDER ----------------------------------------------------------
// Three honesty rules are baked in here on purpose. They are what separate a
// progress bar the user can trust from one that is merely decorative:
//
// 1. An ESTIMATE IS MARKED AS ONE. `confidence` is the bridge telling you
// how it got the number: "measured" means it timed this phase on THIS
// machine, anything else is a default or a small sample. A guess that
// renders identically to a measurement teaches the user to distrust
// both, so a non-measured bar is drawn dimmed and labelled.
// 2. `blockedBy` STOPS THE BAR. It means the work is parked on a HUMAN - a
// modal dialog, a login, a save prompt. Nothing will move until the user
// acts, so the bar goes amber, stops advancing, and says what it wants.
// A bar that keeps sliding while nothing can happen is worse than none.
// 3. NO PERCENT MEANS INDETERMINATE, not zero. A bridge that honestly says
// "I am working but cannot estimate" must not render as a bar stuck at
// 0%, which reads as "broken".
function renderProgressBlock(card, b) {
var p = progressBar(card);
var blocked = typeof b.blockedBy === "string" && b.blockedBy.trim();
var estimated = b.confidence && b.confidence !== "measured";
var pct = typeof b.percent === "number"
? Math.max(0, Math.min(100, b.percent)) : null;
p.fill.style.width = (pct === null ? 100 : pct) + "%";
p.fill.classList.toggle("pbar-indeterminate", pct === null && !blocked);
p.fill.classList.toggle("pbar-estimated", !!estimated && !blocked);
p.fill.classList.toggle("pbar-blocked", !!blocked);
p.fill.classList.toggle("pbar-failed", b.failed === true);
// The human line: the bridge's own wording, never ours. `stepLabel` is
// written for a person; `phase` is the machine-readable namespaced id and
// is only a fallback.
var bits = [];
if (pct !== null) bits.push(Math.round(pct) + "%");
var label = b.stepLabel || b.phase || "working";
// Time: prefer what is LEFT (what a person actually wants) over what has
// elapsed, and show the estimate it is measured against.
if (typeof b.etaSec === "number" && b.etaSec >= 0) bits.push(secs(b.etaSec) + " left");
else if (typeof b.elapsedSec === "number") {
bits.push(secs(b.elapsedSec) + " elapsed" +
(typeof b.estimatedSec === "number" ? " of ~" + secs(b.estimatedSec) : ""));
}
if (b.startKind) bits.push(b.startKind); // cold | warm
if (estimated) bits.push(String(b.confidence)); // rule 1
if (blocked) bits.push("waiting on you: " + b.blockedBy.trim()); // rule 2
p.meta.innerHTML = esc(label) +
(bits.length ? ' <span class="beat">- ' + esc(bits.join(" - ")) + "</span>" : "");
return { blocked: !!blocked, percent: pct };
}
function secs(n) {
if (typeof n !== "number" || !isFinite(n) || n < 0) return "";
if (n < 60) return Math.round(n) + "s";
var m = Math.floor(n / 60), s = Math.round(n % 60);
return s ? m + "m " + s + "s" : m + "m";
}
// ---- 3. EVIDENCE --------------------------------------------------------
// A progress frame may carry `stepShot {step, title, image}` - a screenshot
// the bridge captured of the thing it just did. Render it verbatim as the
// step's thumbnail: it is the difference between a bar claiming a footprint
// opened and the user SEEING that it opened. `image` is a data: URL or bare
// base64 PNG.
function readStepShot(frameOrResp) {
var f = frameOrResp || {};
return f.stepShot || (f.result && f.result.stepShot) || null;
}
// Drive a bridge's demo verb: start (background) -> poll progress -> render the
// returned window array. cfg = { startCmd, startArgs, pollArgs, stateCmd }.
function runBridgeDemo(c, cfg) {
// The demo HONORS the machine picker: pick ConfRoomROG and the tour runs
// THERE (this page's ab relays the verbs to that machine). Captured ONCE
// at click so changing the picker mid-run cannot split the run.
var target = chosenTarget();
if (target === "all") { setRes(c.res, "err", "pick ONE machine", "a demo streams per-machine progress - fan-out is not supported here"); return; }
var who = machineLabel(target);
c._who = who;
c._bridgeShots = false;
c.card._numberEvidence = true; c.card._seq = 0; // fresh numbering per run
c.btn.disabled = true;
cardLog(c.card).textContent = "";
var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";
setProgress(c.card, 0, "starting ...");
setAwaiting(c.res, "asking permission (choose “Allow 1 hr”) ...", "runs on " + esc(who) + " - the demo runs many steps; a 1-hour grant keeps the progress silent", "runs on " + esc(who));
clog(c, "Target machine: " + who + (target ? " (relayed through this machine's Adom Bridge)" : " (the machine serving this page)"));
clog(c, "Starting the bridge's own demo verb: " + cfg.startCmd + " " + JSON.stringify(cfg.startArgs || {}));
// 1) Start the background tour (cross-origin -> ONE consent prompt).
postVerb(cfg.startCmd, cfg.startArgs || {}, target).then(function (res) {
if (res.s !== 200 || !res.j || (res.j.started !== true && res.j.success !== true)) {
if (res.j && res.j.errorCode === "web_control_disabled") { setRes(c.res, "err", "Refused: web control OFF", "re-enable in Settings → Web Control"); }
else { setRes(c.res, "err", "could not start", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s)); }
c.btn.disabled = false; return;
}
var est = res.j.estimatedSec || 100;
// Thread an id from the START response into the poll args when the bridge
// hands one back (e.g. Fusion's fusion_demo returns a demoId that
// fusion_demo_progress needs). KiCad's poll is stateless, so no idFrom.
var pollArgs = Object.assign({}, cfg.pollArgs || { progress: true });
if (cfg.idFrom) {
var idVal = res.j[cfg.idFrom];
if (idVal == null && res.j.data) idVal = res.j.data[cfg.idFrom];
if (idVal != null) { pollArgs[cfg.idAs || cfg.idFrom] = idVal; clog(c, " tracking " + (cfg.idAs || cfg.idFrom) + "=" + idVal); }
}
clog(c, "Started (bridge estimates ~" + est + "s). Polling for live progress ...");
setProgress(c.card, res.j.percent || 2, "preparing ...", 0, 0, 0, est);
setRes(c.res, "pending", "running the demo ...", "usually ~" + est + "s - watch the bar");
// LIVE PROOF: at every step transition, snap the app's current state via
// the bridge's state verb and pin it in the card - the user SEES each
// stage of the tour as it happens, not just text + a final gallery
// (John, 2026-08-16). One snap in flight at a time; a slow snap skips
// steps rather than queueing behind the tour.
var lastStep = null, snapBusy = false;
function maybeSnap(label) {
if (!cfg.snap || snapBusy) return;
snapBusy = true;
var done = function () { snapBusy = false; };
// HONEST label: this is the page's own capture taken DURING the step,
// not the bridge's evidence OF the step - the window shown may lag the
// label. Bridge-supplied stepShot (above) supersedes these entirely.
var pin = function (img, title) { addShot(c.card, img, "during “" + label + "”: " + (title || "app window")); clog(c, " [page capture during \"" + label + "\": " + (title || "app window") + "]"); };
if (cfg.snap.viaHwnd) {
// fusion_state's own screenshot exceeds the 4MB inline cap (it comes
// back as a PATH on the far machine), so snap via the hwnd instead:
// state verb -> hwnd -> desktop_screenshot_base64 (always inline).
postVerb(cfg.snap.viaHwnd, { screenshot: false }, target).then(function (wr) {
var wd = (wr.j && (wr.j.data || wr.j)) || {};
if (!wd.hwnd) { done(); return; }
return postVerb("desktop_screenshot_base64", { hwnd: wd.hwnd, maxWidth: 640 }, target).then(function (sh) {
done();
if (sh.s === 200 && sh.j && sh.j.image) pin(sh.j.image, wd.title);
});
}).catch(done);
return;
}
postVerb(cfg.snap.cmd, Object.assign({}, cfg.snap.args || {}), target).then(function (sr) {
done();
if (!(sr.s === 200 && sr.j)) return;
var withImg = findShots(sr.j).filter(function (s) { return s && (s.image || s.base64 || s.png); });
if (!withImg.length) return;
var s = withImg[withImg.length - 1]; // the newest window is the step's surface
pin(s.image || s.base64 || s.png, s.title);
}).catch(done);
}
// 2) Poll progress every 2.5s (the Allow-1hr grant makes these silent).
var polls = 0, maxPolls = 240; // ~10 min ceiling
function poll() {
polls++;
if (polls > maxPolls) { setRes(c.res, "err", "timed out", "the demo did not finish in time"); c.btn.disabled = false; return; }
postVerb(cfg.pollCmd || cfg.startCmd, pollArgs, target).then(function (pr) {
if (pr.s !== 200 || !pr.j) { return new Promise(function (r) { setTimeout(r, 2500); }).then(poll); }
var d = pr.j;
if (d.error && d.active === undefined && d.done === undefined) {
// job already gone - treat as done, go fetch the final state
clog(c, " progress job ended; fetching final state ...");
return finishDemo(c, cfg, est, target);
}
setProgress(c.card, d.percent, d.stepLabel || d.step, d.beatIndex, d.beatCount, d.elapsedSec, d.estimatedSec || est);
// BRIDGE-SUPPLIED step evidence is authoritative: the bridge knows
// the exact moment a step completed and which window is that step's
// surface, so a `stepShot {step, title, image}` on a progress frame
// is pinned verbatim and disables the page's own guess-snaps (which
// race the tour and can label the wrong window - John's catch).
var ss = d.stepShot || (d.result && d.result.stepShot);
if (ss && (ss.image || ss.base64)) {
c._bridgeShots = true;
// ⛔ Do NOT render an in-progress screenshot as evidence.
// A step shot is the app MID-OPERATION, so it usually still shows the
// PREVIOUS part - which reads as "you opened the wrong thing" even
// when the final result is correct. John, 2026-08-20: "i keep
// thinking you had it wrong, when possibly you didn't."
// The step TEXT is kept because it is useful; only the misleading
// picture is dropped. Evidence = the FINAL frame, nothing else.
clog(c, " step: " + (ss.step || ss.title || d.stepLabel || "step"));
}
// Log + snap only on CHANGE - a 2.5s poll of a 30s step used to spam
// the same line eight times.
if (d.stepLabel && d.stepLabel !== lastStep) {
lastStep = d.stepLabel;
clog(c, " " + (d.percent != null ? d.percent + "% " : "") + d.stepLabel + (d.beatCount ? " (step " + ((d.beatIndex || 0) + 1) + "/" + d.beatCount + ")" : ""));
if (!c._bridgeShots) maybeSnap(d.stepLabel);
}
if (d.done === true || d.active === false) {
setProgress(c.card, 100, "done", d.beatCount, d.beatCount, d.elapsedSec, d.estimatedSec || est);
// The bridge rides its final state on the last poll (result.state.shots).
// Render it if present; otherwise ask the state verb.
if (!renderShots(c, d)) { return finishDemo(c, cfg, est, target); }
return;
}
return new Promise(function (r) { setTimeout(r, 2500); }).then(poll);
}).catch(function (e) { return new Promise(function (r) { setTimeout(r, 2500); }).then(poll); });
}
poll();
}).catch(function (e) { setRes(c.res, "err", "blocked", String(e)); c.btn.disabled = false; });
}
// Pull the bridge's full state (the window array) and render it.
function finishDemo(c, cfg, est, target) {
if (!cfg.stateCmd) { setRes(c.res, "ok", "Demo finished", "the bridge closed its windows"); c.btn.disabled = false; return; }
clog(c, "Fetching the bridge's full window state: " + cfg.stateCmd);
return postVerb(cfg.stateCmd, {}, target).then(function (sr) {
if (sr.s === 200 && sr.j && renderShots(c, sr.j)) return;
// The state verb's own capture may not ride the relay inline (fusion's
// exceeds the 4MB cap and comes back as a far-machine PATH). Same
// fallback as the live snaps: hwnd -> desktop_screenshot_base64.
if (cfg.snap && cfg.snap.viaHwnd) {
clog(c, " no inline captures - falling back to an hwnd screenshot of the app");
return postVerb(cfg.snap.viaHwnd, { screenshot: false }, target).then(function (wr) {
var wd = (wr.j && (wr.j.data || wr.j)) || {};
if (!wd.hwnd) throw new Error("no app window reported");
return postVerb("desktop_screenshot_base64", { hwnd: wd.hwnd, maxWidth: 900 }, target).then(function (sh) {
if (!(sh.s === 200 && sh.j && sh.j.image)) throw new Error("screenshot failed");
addShot(c.card, sh.j.image, wd.title || "app window");
setRes(c.res, "ok", "Demo done - final window below", "1 window on " + esc(c._who || selfLabel()) + " (generic capture)");
c.btn.disabled = false;
});
}).catch(function (e2) {
setRes(c.res, "ok", "Demo finished", "the bridge already closed its windows - nothing to show (" + String(e2 && e2.message || e2).slice(0, 60) + ")");
c.btn.disabled = false;
});
}
setRes(c.res, "ok", "Demo finished", "the bridge already closed its windows - nothing to show");
clog(c, "DONE - the demo completed; the bridge closed its windows before we captured state.");
c.btn.disabled = false;
}).catch(function (e) { setRes(c.res, "err", "state fetch failed", String(e)); c.btn.disabled = false; });
}
// Find the bridge's window array wherever it rides: the final poll embeds it at
// result.state.shots; kicad_state returns it at shots. Handle every known path.
function findShots(d) {
// Bridges ride their window array on the final frame in various shapes -
// KiCad at result.state.shots, Fusion's final fusion_demo_progress poll at
// (an array called shots OR screenshots, possibly nested under data/result).
// Check every plausible path so a new bridge's demo renders with no rewrite.
var cands = [
d.shots, d.screenshots, d.images, d.windows,
d.data && d.data.shots, d.data && d.data.screenshots, d.data && d.data.images,
d.state && d.state.shots, d.state && d.state.screenshots,
d.result && d.result.shots, d.result && d.result.screenshots,
d.result && d.result.state && d.result.state.shots,
d.result && d.result.state && d.result.state.screenshots
];
for (var i = 0; i < cands.length; i++) {
if (Array.isArray(cands[i]) && cands[i].length) return cands[i];
}
// fusion_state (1.9.42+) is a SINGLE-window server-side render: a top-level
// base64 `image` (with imageAs:"base64") plus title/hwnd. Wrap it so the
// one-window shape renders through the same path as the arrays.
var dd = d.data || d;
var single = dd.image || dd.imageBase64 || dd.screenshotBase64;
if (typeof single === "string" && single.length > 100) {
return [{ title: dd.title || "app window", image: single }];
}
return [];
}
// Render a bridge state payload's screenshots (each item: {title, image, ...}).
// Returns true if it rendered any window, false if there was nothing to show.
// pickTitle (optional): show JUST the window(s) whose title contains it — the
// surface buttons ask for ONE surface ("just the symbol"), and the state verb
// returns every open window. A zero-match filter falls back to all windows
// (better to over-show than render nothing when a title changes).
function renderShots(c, d, pickTitle) {
var narrate = (d.result && d.result.narrate) || d.narrate;
var failed = (d.result && d.result.failedSteps) || d.failedSteps;
if (narrate) clog(c, " bridge says: " + narrate);
if (Array.isArray(failed) && failed.length) clog(c, " note: the bridge could not open: " + failed.join(", "));
var shots = findShots(d);
var withImg = shots.filter(function (s) { return s && (s.image || s.base64 || s.png); });
if (!withImg.length) return false;
if (pickTitle) {
var want = pickTitle.toLowerCase();
var picked = withImg.filter(function (s) { return ((s.title || s.label || "") + "").toLowerCase().indexOf(want) >= 0; });
if (picked.length) { withImg = picked; }
else clog(c, ' (no window titled like "' + pickTitle + '" - showing all ' + withImg.length + ')');
}
clog(c, "Rendering " + withImg.length + " window(s).");
withImg.forEach(function (s) { addShot(c.card, s.image || s.base64 || s.png, s.title || s.label || s.window || "window"); });
// Is what we are about to render actually the part that was ASKED for?
// The window title carries the part name, so this is cheap - and it is the
// check that would have caught a green "Done" printed over screenshots of
// the previous part (John, 2026-08-20) instead of shipping it as success.
// Resolve the expected name NOW, not when the card was clicked. The symbol's
// real name is only learned during staging (kicad_install_symbol reports it),
// which runs AFTER the click - so capturing a value up front pinned the file
// stem and flagged a correct open as a mismatch.
var expect = (typeof c._verifyName === "function") ? c._verifyName() : c._verifyName;
if (expect) {
var want = norm(expect);
var titles = withImg.map(function (s) { return (s.title || s.label || s.window || "").toString(); });
if (titles.length && !titles.some(function (t) { return norm(t).indexOf(want) >= 0; })) {
withImg.forEach(function (s) { addShot(c.card, s.image || s.base64 || s.png, "MISMATCH: " + (s.title || s.label || "window")); });
setRes(c.res, "err", "WRONG PART on screen",
"asked for " + esc(expect) + " but the surface shows " + esc(titles.slice(0, 2).join(" | ")) +
" - the evidence below is NOT this part");
clog(c, " MISMATCH: no rendered window mentions '" + expect + "' -> " + titles.join(" | "));
c.btn.disabled = false;
return true;
}
}
if (typeof c._onShown === "function") { try { c._onShown(); } catch (e) {} }
setRes(c.res, "ok", withImg.length === 1 ? "Done - the surface is below" : "Demo done - full state below", withImg.length + " window(s) on " + esc(c._who || selfLabel()));
c.btn.disabled = false;
return true;
}
// Open ONE surface via a bridge's first-class show verb, then render it from the
// bridge's state verb. The show verbs open in the BACKGROUND by design, so we
// SHOW the window by screenshotting state - we never foreground the app. On a
// prerequisite miss the bridge returns a _hint; we surface it.
// cfg.stage (optional): a function(c) -> Promise run BEFORE the show verb — the
// staging chain (fetch the wiki part, install its library, generate the demo
// project). Electronics is a dependency ladder: you cannot show a 3D board
// without a routed 2D board, which needs a schematic, which needs the part's
// library installed. stage() owns climbing that ladder idempotently.
// cfg.showArgs may be a FUNCTION (called after stage) so args can carry paths
// the staging step just produced.
// cfg.pickTitle: render just the one window this surface is about.
// cfg.fallback {cmd,args}: retried when the primary verb is unknown (a not-yet-
// shipped show verb degrades to today's open verb instead of a dead button).
// Live progress for a surface open, using the SAME canonical renderer as the
// demo cards. Opening a symbol / board / 3D view is a 30-60s job, so a card
// that shows only a text log leaves the user with no sense of how far along it
// is - the exact gap the measured-progress contract exists to close.
//
// The poll verb is DERIVED from the show verb's namespace
// (kicad_show_symbol -> kicad_progress), so any bridge that ships one gets a
// bar here with no per-app code, and one that does not (fusion today, which
// has only fusion_demo_progress) simply keeps the text log: the poll 404s and
// is ignored. That is the generic-renderer rule applied to the demo itself.
//
// It polls ALONGSIDE the slow verb rather than after it, for the usual reason:
// the show verb's own response cannot animate anything, because it does not
// arrive until the work is already done.
function startSurfaceProgress(c, showCmd) {
var prefix = String(showCmd || "").split("_")[0];
if (!prefix) return;
var pollCmd = prefix + "_progress";
c._lastShotKey = null;
(function tick() {
// The card disables its button for the whole run and re-enables it on
// EVERY exit path, so that flag is the one stop condition that cannot
// leave this loop polling forever.
if (!c.btn.disabled) { finishSurfaceBar(c); return; }
postVerb(pollCmd, {}).then(function (res) {
if (!c.btn.disabled) return;
var block = readProgressBlock(res.j);
if (block) {
c._sawProgress = true;
renderProgressBlock(c.card, block);
var ss = readStepShot(block);
var img = ss && (ss.image || ss.base64);
if (img) {
var key = (ss.step || "") + "|" + (ss.title || "") + "|" + String(img).length;
if (key !== c._lastShotKey) {
c._lastShotKey = key;
// in-progress frame: log it, never render it (see note above)
clog(c, " step: " + (ss.step || ss.title || "step"));
}
}
}
setTimeout(tick, 1000);
}).catch(function () { setTimeout(tick, 1500); });
})();
}
// Land the bar on a FINAL frame when the run ends.
//
// The poll loop stops the moment the card re-enables its button, and it used to
// just return - leaving the bar frozen wherever the last poll happened to catch
// it. That produced the thing John caught: a card reading "Done - the surface is
// below" in green above a bar still saying "show_footprint starting - 23% - 3s
// left". The verb finished between polls, so the last frame was mid-flight and
// nothing ever superseded it.
//
// A FAILED run does not get a triumphant 100% - it lands red and says stopped.
//
// PASS THE OUTCOME IN. This used to read it off the card's result class, which
// sounds honest ("read, don't assume") but is a race in disguise: every caller
// lands the bar BEFORE it calls setRes, so the class was still "running" and
// every successful run painted a red "stopped" bar under a green "Done - all 3
// library artifacts opened" header (John, 2026-08-25). The AI-thread card had
// the same bug for the same reason. The outcome is knowable at the call site,
// so it is an argument now.
//
// `ok` omitted falls back to the class read - correct ONLY for the poll loop,
// which runs after the card has already settled.
function finishSurfaceBar(c, ok) {
if (!c._sawProgress) return; // no bar was ever drawn; nothing to land
c._sawProgress = false; // once per run
if (typeof ok !== "boolean") {
ok = !!(c.res && c.res.classList && c.res.classList.contains("ok"));
}
renderProgressBlock(c.card, ok
? { percent: 100, stepLabel: "done", etaSec: 0 }
: { percent: 100, stepLabel: "stopped", failed: true });
}
// Normalize for comparison: a part's name differs in case and punctuation
// across the wiki slug, the library entry and the window title
// (IN-S42ATR / in-s42atr, s10b-ph-sm4-tb / S10B_PH_SM4_TB).
function norm(x) { return String(x || "").toLowerCase().replace(/[^a-z0-9]/g, ""); }
function showSurface(c, cfg) {
c.btn.disabled = true;
c._verifyName = cfg.verifyName || null;
c._onShown = cfg.onShown || null;
cardLog(c.card).textContent = "";
var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";
setAwaiting(c.res, "asking permission (choose “Allow 1 hr”) ...", "on " + esc(selfLabel()) + " - " + (cfg.note || "opens in the background, then we screenshot it"), "on " + esc(selfLabel()) + " - " + (cfg.note || "opens in the background, then we screenshot it"));
clog(c, "Target machine: " + selfLabel());
startSurfaceProgress(c, cfg.showCmd);
var staged = cfg.stage ? cfg.stage(c) : Promise.resolve();
staged.then(function () {
var args = (typeof cfg.showArgs === "function") ? cfg.showArgs() : (cfg.showArgs || {});
clog(c, "Calling " + cfg.showCmd + " " + JSON.stringify(args) + (cfg.note ? " (" + cfg.note + ")" : ""));
return postVerb(cfg.showCmd, args).then(function (res) {
if (res.j && res.j.windowEtiquette === "background") clog(c, " (opens in the BACKGROUND by design - the bridge's window etiquette; we render it below instead of stealing focus)");
if (res.j && res.j._hint) clog(c, " bridge hint: " + res.j._hint);
var unknown = res.j && /unknown command/i.test(res.j.error || "");
if (unknown && cfg.fallback) {
var fb = (typeof cfg.fallback === "function") ? cfg.fallback() : cfg.fallback;
clog(c, " " + cfg.showCmd + " is not shipped yet on this bridge - falling back to " + fb.cmd);
return postVerb(fb.cmd, fb.args || args).then(function (r2) { return handleShow(r2); });
}
return handleShow(res);
});
function handleShow(res) {
// An EXPLICIT failure is a failure, full stop. This used to OR together
// several optional fields (`state`, `result`, `shots`, ...) as evidence
// of success - but a kicad ERROR response also carries `state`, so a
// refusal like library_cache_stale scored as OK and the card printed a
// green "Done" over screenshots of the PREVIOUS part. Believing a bridge
// when it says success:false is the whole fix; the optional fields are a
// fallback only for a bridge that omits `success` entirely.
var jj = res.j || {};
var explicitFail = jj.success === false || !!jj.errorCode ||
(typeof jj.error === "string" && jj.error.trim() !== "");
var ok = res.s === 200 && !explicitFail &&
(jj.success === true || jj.ok === true || jj.surface || jj.shots || jj.result || jj.state);
// The host app may simply not be running yet (fusion_not_running). If the
// bridge names a start verb, run it (it blocks until ready) and retry ONCE.
if (!ok && cfg.startCmd && !c._autoStarted && res.j && /not running/i.test((res.j.error || "") + " " + (res.j._hint || ""))) {
c._autoStarted = true;
clog(c, " " + (res.j.error || "app not running") + " - calling " + cfg.startCmd + " (blocks until the add-in is ready) ...");
setRes(c.res, "pending", "starting the app ...", "cold start can take a couple of minutes");
return postVerb(cfg.startCmd, {}).then(function (st) {
if (!(st.s === 200 && st.j && st.j.success !== false)) {
setRes(c.res, "err", "could not start the app", (st.j && (st.j.error || st.j._hint)) || ("HTTP " + st.s));
c.btn.disabled = false; return;
}
clog(c, " app is ready - retrying " + cfg.showCmd);
return postVerb(cfg.showCmd, args).then(handleShow);
});
}
if (!ok) {
if (res.j && res.j.errorCode === "web_control_disabled") setRes(c.res, "err", "web control OFF", "re-enable in Settings → Web Control");
else {
// Name a permission failure for what it is - this is the ONLY moment the
// page actually knows one happened.
var em = ((res.j && (res.j.error || res.j.errorCode)) || "") + "";
var perm = res.s === 403 || /not approved|denied|consent|permission/i.test(em);
if (perm) setRes(c.res, "err", "needs your approval",
"Adom Bridge asked and got no Allow - click Allow 1 hr on the prompt (check the notification center, Win+N) then run this again");
else setRes(c.res, "err", "not opened", (res.j && (res.j.error || res.j.errorCode || res.j._hint)) || ("HTTP " + res.s));
}
c.btn.disabled = false; return;
}
clog(c, " opened" + (res.j.surface ? " (surface: " + res.j.surface + ")" : "") + " - capturing state ...");
// The show verb MAY already carry shots; else pull the state verb.
if (renderShots(c, res.j, cfg.pickTitle)) return;
return settleThenCapture(c, cfg.stateCmd).then(function (sr) {
if (sr.s === 200 && sr.j && renderShots(c, sr.j, cfg.pickTitle)) return;
// The state verb may not be shipped on this bridge yet (fusion_state is
// "next release" per fusion-bridge #28). Fall back to a GENERIC window
// capture: ask the bridge which window is its app's, then screenshot it.
if (cfg.windowInfoCmd) {
clog(c, " " + cfg.stateCmd + " returned no captures - falling back to a window screenshot (" + cfg.windowInfoCmd + " + desktop_screenshot_base64)");
return postVerb(cfg.windowInfoCmd, {}).then(function (wi) {
var wd = (wi.j && (wi.j.data || wi.j)) || {};
if (!wd.hwnd) throw new Error("no app window reported");
return postVerb("desktop_screenshot_base64", { hwnd: wd.hwnd, maxWidth: 900 }).then(function (sh) {
if (!(sh.s === 200 && sh.j && sh.j.image)) throw new Error("screenshot failed");
addShot(c.card, sh.j.image, wd.title || "app window");
setRes(c.res, "ok", "Done - captured the app window", "generic capture (the bridge's state verb isn't shipped yet)");
c.btn.disabled = false;
});
}).catch(function (e2) {
setRes(c.res, "ok", "Opened" + (res.j.surface ? " (" + res.j.surface + ")" : ""), "no window captured: " + String(e2 && e2.message || e2).slice(0, 90));
c.btn.disabled = false;
});
}
setRes(c.res, "ok", "Opened" + (res.j.surface ? " (" + res.j.surface + ")" : ""), "no window captured yet (may need a prerequisite - see the hint)");
c.btn.disabled = false;
});
}
}).catch(function (e) {
setRes(c.res, "err", e && e.staged ? "staging failed" : "blocked", String(e && e.message || e).slice(0, 140));
c.btn.disabled = false;
});
}
// The app may still be mid-load when the show verb returns (KiCad's "Load PCB"
// progress dialog raced the first capture and produced a bounding-box 3D render).
// Poll the state verb CHEAPLY (no images) until the window set is stable and no
// modal dialog remains, then take the real capture. Errors fall through to a
// direct capture so a bridge without includeImages support still renders.
function settleThenCapture(c, stateCmd) {
// ONE capture after a fixed settle delay - NO probe polling. The old loop
// polled the state verb up to 8x waiting for the window set to stabilize,
// and under an "Allow once" grant EVERY poll was its own consent prompt:
// John got NINE toasts for one screenshot (2026-08-17). Each cross-origin
// call costs one consent, so the page makes as few as possible. The
// bridges are being asked to own settle+capture inside the show verbs
// (capture:true) so this becomes zero extra calls.
clog(c, " waiting ~3.5s for the surface to settle, then ONE capture ...");
return new Promise(function (r) { setTimeout(r, 3500); })
.then(function () { return postVerb(stateCmd, { maxWidth: 900 }); });
}
// summaries
function n(x) { return (x === undefined || x === null) ? 0 : x; }
function bridgesSummary(j) { var d = j.data || j; var a = d.bridges || []; return a.length + " bridge(s): " + a.map(function (b) { return b.name; }).slice(0, 6).join(", "); }
function targetsSummary(j) { var d = j.data || j; var a = d.targets || d.clients || []; return (a.length || 1) + " machine(s): " + a.map(function (t) { return t.name || t.hostname; }).join(", "); }
function windowsSummary(j) { var d = j.data || j; var a = d.windows || []; return a.length + " open. Top 3: " + a.slice(0, 3).map(function (w) { return '"' + (w.title || "").slice(0, 26) + '"'; }).join(", "); }
function browsersSummary(j) { var d = j.data || j; var a = d.browsers || (Array.isArray(d) ? d : []); if (!Array.isArray(a)) a = []; return a.length + " installed. Top 3: " + a.slice(0, 3).map(function (b) { return b.name || b.browser || b.displayName || b.id || "browser"; }).join(", "); }
// ---- Detect (reads) ----
var cDetect = card("grp-read", "read", "Detect Adom Bridge");
cDetect.btn.onclick = function () {
setRes(cDetect.res, "pending", "checking...", "");
fetch(AB + "/health").then(function (r) { return r.json(); }).then(function (h) {
dot.style.background = (h && h.service === "adom-bridge") ? "#2ea043" : "#d29922";
return fetch(AB + "/status").then(function (r) { return r.json(); }).then(function (s) {
if (s.machine && (s.machine.clientName || s.machine.hostname)) selfName = s.machine.clientName || s.machine.hostname;
document.getElementById("ver").textContent = "v" + (s.version || "?") + (selfName ? " on " + selfName : "");
var wc = (s && s.webControl) || {}; var el = document.getElementById("wc");
var on = wc.enabled !== false; el.textContent = on ? "ON" : "OFF"; el.style.color = on ? "#2ea043" : "#f85149";
setRes(cDetect.res, "ok", "Running: v" + (s.version || "?"), "web control " + (on ? "ON" : "OFF"));
});
}).catch(function (e) { fetchDied(e); dot.style.background = "#484f58"; setRes(cDetect.res, "err", "not reachable", SANDBOXED ? "sandboxed embed blocks loopback - open http://127.0.0.1:24681 directly (see banner)" : String(e)); });
};
var cStatus = card("grp-read", "read", "Read status");
cStatus.btn.onclick = function () { doGet(cStatus.res, "/status", function (s) { var st = n(s.bridgeUpdates && s.bridgeUpdates.staleCount); return "v" + s.version + " - web control " + ((s.webControl && s.webControl.enabled === false) ? "OFF" : "ON") + (st ? (" - " + st + " bridge update(s)") : ""); }); };
// ---- Info verbs (with top-3 lists) ----
var cBridges = card("grp-info", "verb", "List installed bridges");
cBridges.btn.onclick = function () { doVerb(cBridges.res, "bridge_list", null, undefined, bridgesSummary); };
var cTargets = card("grp-info", "verb", "List my computers (targets)");
cTargets.btn.onclick = function () { doVerb(cTargets.res, "targets", null, null, targetsSummary); };
var cWindows = card("grp-info", "verb", "List open windows (top 3)");
cWindows.btn.onclick = function () { doVerb(cWindows.res, "desktop_list_windows", null, undefined, windowsSummary); };
var cApps = card("grp-info", "verb", "Which browsers are installed (top 3)");
cApps.btn.onclick = function () { doVerb(cApps.res, "desktop_list_browsers", null, undefined, browsersSummary); };
// ---- Do something: stream the launch, then capture the FULL window array ----
// ==========================================================================
// THE CANONICAL PATTERN - drive ANY slow bridge verb and animate its progress.
// --------------------------------------------------------------------------
// The two cards above poll a bridge's purpose-built *_demo verb. This one does
// NOT: it calls an ordinary slow verb, the kind a real page actually needs to
// wait on, and animates it purely from the measured-progress contract. Nothing
// below knows what KiCad is - swap the two verb names and it drives any bridge.
//
// The shape is the whole lesson, and it is not obvious the first time:
//
// fire the slow verb -------------------------------------> resolves (terminal frame)
// poll <prefix>_progress every 1s --> --> --> --> --> stop
//
// TWO CONCURRENT REQUESTS. The slow verb's own response cannot animate
// anything, because it does not arrive until the work is done - by then the
// only honest number it can carry is 100%. The poll running ALONGSIDE it is
// what produces the rising frames. Firing the verb and awaiting it before
// starting the poll is the single most common way to end up with a bar that
// jumps 0 -> 100 and nothing in between.
// ==========================================================================
function runCanonicalProgress(c, cfg) {
var target = chosenTarget();
var who = machineLabel(target);
c.btn.disabled = true;
cardLog(c.card).textContent = "";
var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";
setAwaiting(c.res, "asking permission (choose “Allow 1 hr”) ...",
"on " + esc(who) + " - one slow verb, animated purely from its progress block",
"on " + esc(who) + " - one slow verb, animated purely from its progress block");
var finished = false, frames = 0, sawMoving = false, lastLabel = null;
var startedAt = Date.now(); // to tell 'too fast to poll' from 'never reported'
c._lastShotKey = null; // fresh evidence stream per run
// ---- the poll leg: this is what animates -------------------------------
function poll() {
if (finished) return;
postVerb(cfg.pollCmd, cfg.pollArgs || {}, target).then(function (res) {
if (finished) return;
var block = readProgressBlock(res.j);
if (block) {
frames++;
var st = renderProgressBlock(c.card, block);
if (st.percent !== null && st.percent < 100) sawMoving = true;
var label = block.stepLabel || block.phase;
if (label && label !== lastLabel) {
lastLabel = label;
clog(c, " poll -> " + (block.percent != null ? block.percent + "% " : "") + label +
(block.confidence ? " [" + block.confidence + "]" : "") +
(block.blockedBy ? " BLOCKED: " + block.blockedBy : ""));
}
// A frame can carry its own evidence screenshot - show it verbatim.
// DEDUPE: the same stepShot rides EVERY poll frame until the bridge
// moves to the next step, so appending on each poll stacks identical
// thumbnails and makes one step look like several. Key on the step +
// the image itself so a genuine re-capture of the same step still shows.
var ss = readStepShot(block) || readStepShot(res.j);
var img = ss && (ss.image || ss.base64);
if (img) {
var key = (ss.step || "") + "|" + (ss.title || "") + "|" + String(img).length;
if (key !== c._lastShotKey) {
c._lastShotKey = key;
// in-progress frame: log it, never render it (see note above)
clog(c, " step: " + (ss.step || ss.title || label || "step"));
}
}
}
setTimeout(poll, 1000);
}).catch(function () { if (!finished) setTimeout(poll, 1000); });
}
// ---- the work leg: fire it, do NOT await before polling -----------------
clog(c, "POST " + cfg.slowCmd + " (the slow verb; it will not answer until it is done)");
clog(c, "POST " + cfg.pollCmd + " every 1s alongside it <- this is what moves the bar");
var work = postVerb(cfg.slowCmd, cfg.slowArgs || {}, target);
poll();
work.then(function (res) {
finished = true;
// The TERMINAL frame: the verb's authoritative last word. Render it so the
// bar lands on the real final numbers rather than wherever the last poll
// happened to catch it.
var block = readProgressBlock(res.j);
if (block) {
renderProgressBlock(c.card, block);
clog(c, "terminal frame -> " + JSON.stringify(block));
} else {
clog(c, "terminal frame -> (this verb reported no progress block)");
}
var ok = res.s === 200 && res.j && res.j.success !== false;
// Be honest about WHY a bar did not move, because the two reasons need
// opposite responses and look identical from here. A verb that finished in
// a couple of seconds simply outran a 1s poll - that is the bridge being
// FAST, not the bridge being silent, and reporting it as "does not report
// progress" sends a bridge author hunting a bug that is not there (the
// kicad author caught exactly that wording, kicad-bridge#39).
var ranMs = Date.now() - startedAt;
var note = frames + " live frame(s) polled" +
(sawMoving ? ", bar animated"
: frames > 0 ? ", all at 100% - the run finished before a poll caught it mid-flight"
: ranMs < 6000 ? ", but the verb finished in " + (ranMs / 1000).toFixed(1) + "s - too fast for a 1s poll to " +
"catch a frame, so only the terminal frame was drawn (not a bridge fault)"
: ", and none arrived in " + (ranMs / 1000).toFixed(1) + "s - this bridge may not populate active[] " +
"for this verb, so the bar could only draw the terminal frame");
setRes(c.res, ok ? "ok" : "err", ok ? "Allowed & ran" : "Failed",
"on " + esc(who) + " - " + esc(note));
c.btn.disabled = false;
}).catch(function (e) {
finished = true;
setRes(c.res, "err", "blocked", String(e));
c.btn.disabled = false;
});
}
var cCanon = card("grp-do", "verb", "Animate ANY slow verb (the canonical pattern)");
cCanon.btn.onclick = function () {
// Ordinary verbs, not demo verbs. Swap these two names for any other bridge.
runCanonicalProgress(cCanon, {
slowCmd: "kicad_show_footprint",
slowArgs: { footprintName: "R_0603_1608Metric", library: "Resistor_SMD" },
pollCmd: "kicad_progress",
pollArgs: {}
});
};
var cKicad = card("grp-do", "verb", "Run the KiCad demo (bridge-driven, live progress)");
cKicad.btn.onclick = function () {
// The KiCad bridge OWNS this demo (kicad_demo, 0.9.92+): it builds a sample
// project, opens the symbol/footprint/3D/board/schematic views, reports real
// progress, and returns every window as a screenshot. We just start + poll.
runBridgeDemo(cKicad, {
startCmd: "kicad_demo", startArgs: { all: true, background: true },
pollCmd: "kicad_demo", pollArgs: { progress: true },
stateCmd: "kicad_state",
snap: { cmd: "kicad_state", args: { maxWidth: 640 } }
});
};
var cFusion = card("grp-do", "verb", "Run the Fusion demo (bridge-driven, live progress)");
cFusion.btn.onclick = function () {
// The Fusion bridge OWNS this demo (fusion_demo, v1.9.9+): an async tour over
// symbol / footprint / device / 3D-package (+ schematic/2D/3D board) off its
// bundled sample library. Start returns a demoId that fusion_demo_progress
// needs, and the final poll carries the screenshot array. Same harness as
// KiCad, just a demoId to thread through.
runBridgeDemo(cFusion, {
startCmd: "fusion_demo", startArgs: { mode: "tour" },
pollCmd: "fusion_demo_progress", pollArgs: {},
idFrom: "demoId", idAs: "demoId",
stateCmd: "fusion_state",
snap: { viaHwnd: "fusion_state" }
});
};
// ---- The wiki part every surface button shows: Inolux IN-S42ATR ----
// The part's page publishes the component artifacts (symbol, footprint, STEP,
// .lbr); the buttons fetch them straight off the wiki (the files API sends
// Access-Control-Allow-Origin: *), install them through the bridges' install
// verbs, and only then open the surface. Electronics is a dependency ladder —
// 3D board needs a routed board, needs a schematic, needs the library — so
// each stage function climbs exactly as far as its surface requires, and each
// is MEMOIZED (one staging per page load; every later button reuses it).
// The demo part is PICKABLE, not hardcoded. Ten real wiki parts spanning the
// size range that actually matters here: a 40 KB resistor up to a 3.9 MB
// connector. STEP size is the interesting axis because staging goes through
// write_file as base64 inside the /command JSON body, which caps out around
// 2.5 MB - so the last three deliberately cross that line (see ab#90). Sizes
// are the real .step bytes, measured off each page's files listing.
//
// Every entry was verified to carry ALL FOUR required files
// (.kicad_sym + .kicad_mod + .step + .lbr). Plenty of jellybeans on the wiki
// (1N4148, 2N7000, LM1117, TL074) are missing one and would fail halfway
// through staging, so they are deliberately not offered.
var PARTS = [
{ name: "CR0603-FX-5601ELF", owner: "adom" , slug: "cr0603-fx-5601elf", step: 40618, kind: "resistor 0603" },
{ name: "0402B102J500CT", owner: "adom" , slug: "0402b102j500ct", step: 43502, kind: "capacitor 0402" },
{ name: "WS2812B-2020", owner: "adom" , slug: "ws2812b-2020", step: 120696, kind: "addressable LED" },
{ name: "CL21B104KBCNFNC", owner: "adom" , slug: "cl21b104kbcnfnc", step: 155456, kind: "capacitor 0805" },
{ name: "IN-S42ATR", owner: "adom" , slug: "in-s42atr", step: 214421, kind: "red 0402 LED" },
{ name: "MachinePinLargeStandard", owner: "adom", slug: "machinepinlargestandard", step: 293811, kind: "machine pin, mechanical" },
{ name: "tja1051t-3-118", owner: "adom", slug: "tja1051t-3-118", step: 434142, kind: "CAN transceiver" },
{ name: "nRF52840-CKAA", owner: "john", slug: "nrf52840-ckaa", step: 593793, kind: "BLE SoC (WLCSP)" },
{ name: "nRF54L15", owner: "john", slug: "nrf54l15", step: 1088914, kind: "BLE SoC - crosses the 1 MB chunk threshold" },
{ name: "s10b-ph-sm4-tb", owner: "adom", slug: "s10b-ph-sm4-tb", step: 3921735, kind: "JST PH 10-pin header - over the /command body cap" },
// 2026-08-22: ten Adom basic parts never exercised before, for a fresh
// KiCad library matrix. All ten are adom-owned and carry sym + mod + step.
{ name: "IN-S42ATG", owner: "adom", slug: "in-s42atg", step: 77782, kind: "green 0402 LED" },
{ name: "IN-S63BTR", owner: "adom", slug: "in-s63btr", step: 77068, kind: "red 0603 LED" },
{ name: "IN-S63BT5UW", owner: "adom", slug: "in-s63bt5uw", step: 77068, kind: "white 0603 LED" },
{ name: "BLM15AX601SN1D", owner: "adom", slug: "blm15ax601sn1d", step: 43502, kind: "ferrite bead 0402" },
{ name: "LQM18PN1R0MFRL", owner: "adom", slug: "lqm18pn1r0mfrl", step: 43450, kind: "1.0 uH inductor 0603" },
{ name: "CL21A106KOQNNNG", owner: "adom", slug: "cl21a106koqnnng", step: 155456, kind: "10 uF capacitor 0805" },
{ name: "CL21A226MPQNNNE", owner: "adom", slug: "cl21a226mpqnnne", step: 155456, kind: "22 uF capacitor 0805" },
{ name: "MT15N100J500CT", owner: "adom", slug: "mt15n100j500ct", step: 43502, kind: "10 pF NP0 capacitor 0402" },
{ name: "RMCF0603FT1R50", owner: "adom", slug: "rmcf0603ft1r50", step: 40618, kind: "1.5 ohm resistor 0603" },
{ name: "CQ03SAF1004T5E", owner: "adom", slug: "cq03saf1004t5e", step: 40618, kind: "1 M resistor 0603" },
// 2026-08-22 (round 2): thirty more Adom basic parts, none previously exercised.
{ name: "0402B103J500CT", owner: "adom", slug: "0402b103j500ct", step: 43502 , kind: "capacitor 0402" },
{ name: "0402B104J160CT", owner: "adom", slug: "0402b104j160ct", step: 43502 , kind: "capacitor 0402" },
{ name: "0402B223K250CT", owner: "adom", slug: "0402b223k250ct", step: 43502 , kind: "capacitor 0402" },
{ name: "0402B224K160CT", owner: "adom", slug: "0402b224k160ct", step: 43502 , kind: "capacitor 0402" },
{ name: "0402B332J500CT", owner: "adom", slug: "0402b332j500ct", step: 43502 , kind: "capacitor 0402" },
{ name: "0603B103J250CT", owner: "adom", slug: "0603b103j250ct", step: 43450 , kind: "capacitor 0603" },
{ name: "0603B104J500CT", owner: "adom", slug: "0603b104j500ct", step: 43450 , kind: "capacitor 0603" },
{ name: "0603B105K160CT", owner: "adom", slug: "0603b105k160ct", step: 43450 , kind: "capacitor 0603" },
{ name: "0603B224K250CT", owner: "adom", slug: "0603b224k250ct", step: 43450 , kind: "capacitor 0603" },
{ name: "0603SAF1500T5E", owner: "adom", slug: "0603saf1500t5e", step: 40618 , kind: "resistor 0603" },
{ name: "0603SAF1501T5E", owner: "adom", slug: "0603saf1501t5e", step: 40618 , kind: "resistor 0603" },
{ name: "0603SAF1502T5E", owner: "adom", slug: "0603saf1502t5e", step: 40618 , kind: "resistor 0603" },
{ name: "0603SAF1503T5E", owner: "adom", slug: "0603saf1503t5e", step: 40618 , kind: "resistor 0603" },
{ name: "0603WAF100JT5E", owner: "adom", slug: "0603waf100jt5e", step: 40618 , kind: "resistor 0603" },
{ name: "0603X106K100CT", owner: "adom", slug: "0603x106k100ct", step: 43450 , kind: "capacitor 0603 X7R" },
{ name: "0603X225K160CT", owner: "adom", slug: "0603x225k160ct", step: 43450 , kind: "capacitor 0603 X7R" },
{ name: "0603X475K100CT", owner: "adom", slug: "0603x475k100ct", step: 43450 , kind: "capacitor 0603 X7R" },
{ name: "BLM15BA470SN1D", owner: "adom", slug: "blm15ba470sn1d", step: 43502 , kind: "ferrite bead 0402" },
{ name: "CL21A225KAFNNNG", owner: "adom", slug: "cl21a225kafnnng", step: 155456 , kind: "capacitor 0805" },
{ name: "CL21A475KAQNNNG", owner: "adom", slug: "cl21a475kaqnnng", step: 155456 , kind: "capacitor 0805" },
{ name: "CL21A476MQYNNNE", owner: "adom", slug: "cl21a476mqynnne", step: 155456 , kind: "capacitor 0805" },
{ name: "CL21B105KBFNNNE", owner: "adom", slug: "cl21b105kbfnnne", step: 155456 , kind: "capacitor 0805" },
{ name: "CR0402-FX-1000GLF", owner: "adom", slug: "cr0402-fx-1000glf", step: 40756 , kind: "resistor 0402" },
{ name: "IN-S42ATA", owner: "adom", slug: "in-s42ata", step: 77782 , kind: "0402 LED" },
{ name: "IN-S42ATB", owner: "adom", slug: "in-s42atb", step: 77782 , kind: "0402 LED" },
{ name: "IN-S42ATUW", owner: "adom", slug: "in-s42atuw", step: 77782 , kind: "0402 LED" },
{ name: "IN-S63BT5B", owner: "adom", slug: "in-s63bt5b", step: 77068 , kind: "0603 LED" },
{ name: "LQM18DH100M70L", owner: "adom", slug: "lqm18dh100m70l", step: 43450 , kind: "inductor 0603" },
{ name: "LQM18JNR10J00D", owner: "adom", slug: "lqm18jnr10j00d", step: 43450 , kind: "inductor 0603" },
{ name: "LQM18PN2R2MFRL", owner: "adom", slug: "lqm18pn2r2mfrl", step: 43450 , kind: "inductor 0603" },
];
// A spec's label is a FUNCTION when it names the part (so it can be re-rendered
// when the part changes) and a plain STRING when it does not ("2D board", ...).
// Accept both rather than forcing every spec to become a function.
function labelOf(spec) { return typeof spec.label === "function" ? spec.label() : spec.label; }
function labelFnOf(spec) { return typeof spec.label === "function" ? spec.label : function () { return spec.label; }; }
// Cards whose LABEL names the part, so switching parts re-renders them.
var PART_CARDS = [];
var KICAD_SHOWN = null; // last part actually shown in a KiCad editor
function fmtKB(n) { return n >= 1048576 ? (n / 1048576).toFixed(2) + " MB" : Math.round(n / 1024) + " KB"; }
// Switch the demo part. Everything the OLD part staged is per-part state and
// must be dropped, or a later click would open the previous part's project:
// kicadProject / lbrPath / stepPath are absolute paths under the old part's
// directory, and _fusionPart caches the staged library.
function setPart(idx) {
PART = partFrom(PARTS[idx]);
_fusionPart = null;
_kicadPart = null; // re-stage: symbol name, library and paths are per-part
PART_CARDS.forEach(function (e) {
e.cc.btn.textContent = e.label();
setRes(e.cc.res, "idle", "not run yet");
var log = e.cc.card.querySelector("pre.slog"); if (log) log.textContent = "";
var shots = e.cc.card.querySelector("div.shots"); if (shots) shots.innerHTML = "";
var bar = e.cc.card.querySelector("div.pbar"); if (bar) bar.remove();
var meta = e.cc.card.querySelector(".pbar-meta"); if (meta) meta.remove();
});
var link = document.getElementById("part-link");
if (link) { link.href = PART.page; link.textContent = "open " + PART.name + " on the wiki ↗"; }
var note = document.getElementById("part-note");
if (note) {
note.innerHTML = esc(PART.kind) + " - .step is <strong>" + fmtKB(PART.stepBytes) + "</strong>" +
(PART.stepBytes > 1048576
? ' <span style="color:#3fb950">- over the ~2.5 MB /command body cap once base64-inflated, so it is staged in ~1 MB chunks via write_file_chunk (ab#90)</span>'
: "");
}
}
function buildPartPicker() {
var host = document.getElementById("part-picker");
if (!host) return;
var sel = document.createElement("select");
PARTS.forEach(function (d, i) {
var o = document.createElement("option");
o.value = String(i);
o.textContent = d.name + " - " + d.kind + " (" + fmtKB(d.step) + ")";
sel.appendChild(o);
});
sel.value = "3";
sel.addEventListener("change", function () { setPart(parseInt(sel.value, 10)); });
host.appendChild(sel);
setPart(3);
}
function partFrom(d) {
return {
name: d.name,
kicadLib: "Adom", // kicad_install_symbol reports the real library back
stepBytes: d.step,
kind: d.kind,
page: "https://wiki.adom.inc/" + d.owner + "/" + d.slug,
filesBase: "https://wiki.adom.inc/api/pages/" + d.owner + "/" + d.slug + "/files/",
dir: "~/Documents/adom-demo-parts/" + d.name + "/" // staged via write_file (~ expands)
};
}
var PART = partFrom(PARTS[3]); // IN-S42ATR: small, fast, known-good default
// ANONYMOUS FIRST, then ask ab to fetch it as the signed-in user.
//
// The browser has no Adom session for wiki.adom.inc, so a part page that is
// PRIVATE to the user simply 404s here - which is what made 3 of the 10 picker
// parts fail with a mystery "blocked" until the anonymous-vs-authenticated
// difference was spotted. ab IS signed in, so wiki_fetch (ab 2.0.74) can read
// it on the page's behalf.
//
// Anonymous stays the DEFAULT deliberately: the credential is used only for the
// reads that actually need it, and the fallback pops the normal consent prompt
// naming the URL, so nothing reads your private wiki without you saying yes.
function wikiFetch(f, asBytes) {
var url = PART.filesBase + f;
return fetch(url).then(function (r) {
if (r.ok) return asBytes ? r.arrayBuffer() : r.text();
if (r.status !== 404 && r.status !== 403) throw new Error("wiki fetch " + f + ": HTTP " + r.status);
// Private to this user: ask ab, which is signed in.
return postSelf("wiki_fetch", { url: url }).then(function (res) {
var j = res.j || {};
if (!(res.s === 200 && j.success && j.base64)) {
throw new Error("wiki fetch " + f + ": HTTP " + r.status + " anonymously" +
(j.error ? ", and signed-in fetch said: " + j.error : "") );
}
var bin = atob(j.base64);
if (!asBytes) {
// base64 -> UTF-8 text
var bytes = new Uint8Array(bin.length);
for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return new TextDecoder("utf-8").decode(bytes);
}
var buf = new ArrayBuffer(bin.length), view = new Uint8Array(buf);
for (var k = 0; k < bin.length; k++) view[k] = bin.charCodeAt(k);
return buf;
});
});
}
function fetchPartText(f) { return wikiFetch(f, false); }
function fetchPartBytes(f) { return wikiFetch(f, true); }
function b64Text(s) { return btoa(unescape(encodeURIComponent(s))); }
function b64Bytes(buf) {
var u8 = new Uint8Array(buf), out = "", CH = 0x8000;
for (var i = 0; i < u8.length; i += CH) out += String.fromCharCode.apply(null, u8.subarray(i, i + CH));
return btoa(out);
}
function stageFail(msg) { var e = new Error(msg); e.staged = true; return e; }
// Write one part file onto the PC; resolves the ABSOLUTE path.
//
// Small files go through write_file in a single request. Anything big enough to
// threaten the /command body cap goes through write_file_chunk instead
// (ab 2.0.72, ab#90): base64 inflates ~33%, and a body over ~2.5 MB is refused
// with 413 - which is why a real .step 3D model could not be staged at all
// before. The chunked path sends ~1 MB of RAW bytes per request, so the request
// size stops depending on the file size.
var CHUNK_RAW = 1024 * 1024; // ~1.37 MB of base64 per request
var SINGLE_SHOT_MAX = 1024 * 1024; // above this, chunk it
function stageFile(c, name, b64) {
var path = PART.dir + name;
// b64 length -> raw bytes (4 base64 chars encode 3 bytes)
var rawBytes = Math.floor(b64.length * 3 / 4);
if (rawBytes <= SINGLE_SHOT_MAX) {
return postSelf("write_file", { path: path, content: b64, encoding: "base64" }).then(function (r) {
if (!(r.s === 200 && r.j && r.j.success)) throw stageFail("write_file " + name + ": " + ((r.j && (r.j.error || r.j.errorCode)) || r.s));
clog(c, " staged " + name + " -> " + r.j.path);
return r.j.path;
});
}
return stageFileChunked(c, name, path, b64, rawBytes);
}
// Chunk on BASE64 boundaries: 4 base64 chars == 3 raw bytes, so slicing at a
// multiple of 4 decodes to exactly the bytes we intend and every chunk's offset
// is computable without decoding anything client-side.
function stageFileChunked(c, name, path, b64, rawBytes) {
var b64Step = Math.floor(CHUNK_RAW / 3) * 4;
var total = Math.ceil(b64.length / b64Step);
clog(c, " " + name + " is " + fmtKB(rawBytes) + " - too big for one /command body, sending " + total + " chunks via write_file_chunk");
var i = 0, offset = 0, finalPath = path;
function next() {
if (i >= total) return finalPath;
var slice = b64.substr(i * b64Step, b64Step);
var isLast = (i === total - 1);
return postSelf("write_file_chunk", {
path: path, dataB64: slice, offset: offset, "final": isLast
}).then(function (r) {
if (!(r.s === 200 && r.j && r.j.success)) {
throw stageFail("write_file_chunk " + name + " @" + offset + ": " + ((r.j && (r.j.error || r.j.errorCode)) || r.s));
}
finalPath = r.j.path || finalPath;
offset = r.j.nextOffset;
i++;
if (isLast) clog(c, " staged " + name + " -> " + finalPath + " (" + fmtKB(r.j.size) + ", sha " + String(r.j.sha256 || "").slice(0, 12) + ")");
else if (i % 2 === 0 || total <= 4) clog(c, " chunk " + i + "/" + total + " (" + fmtKB(offset) + " of " + fmtKB(rawBytes) + ")");
return next();
});
}
return Promise.resolve().then(next);
}
// KiCad component staging (the kicad-bridge's own recipe, adom-bridge-sdk#4
// reply 1007/1032): fetch symbol+footprint+STEP from the wiki, write all three
// onto the PC, then install by filePath. kicad_install_footprint's `model`
// arg (0.9.108) installs the .step into 3dmodels/Adom and WIRES the
// footprint's (model ...) reference itself — the page's old hand-splice is
// retired; the bridge owns model wiring now.
// A LIBRARY is a symbol PLUS a footprint PLUS a 3D model, so the proof that a
// library works is all three of those opening - not a picture of the library
// tree. The old card showed whatever the Symbol Editor happened to be holding
// (ADOM_MECHANICAL_PIN, which sorts first), which proved nothing and looked
// like the wrong part had loaded. Measured across a 10-part run: this card's
// shot was byte-identical for 8 of the 10 parts.
//
// John, 2026-08-20: "a library is all 3 of those items".
// `expect` is the "LIB:NAME" the caller asked for. A KiCad editor titles itself
// "<library>:<part> - <Editor>", so the library is IN the evidence and must be
// checked. Measured 2026-08-20: kicad_show_symbol returned success with the
// editor on stock "LED:WS2812B-2020" when the caller named "Adom", and this
// card scored it 3/3 because it only matched on "symbol editor". A card that
// renders a picture of the WRONG library under a green tick is worse than one
// that fails: the picture is what a human trusts.
function waitForKicad(c, tries) {
var n = 0;
function probe() {
n++;
return postVerb("kicad_status", {}).then(function (r) {
var alive = r.s === 200 && r.j && r.j.success !== false && !r.j.errorCode;
if (alive) { clog(c, " KiCad is back after ~" + (n * 5) + "s"); return true; }
if (n >= tries) { clog(c, " KiCad did not come back within ~" + (n * 5) + "s"); return false; }
return new Promise(function (rs) { setTimeout(rs, 5000); }).then(probe);
}, function () {
if (n >= tries) return false;
return new Promise(function (rs) { setTimeout(rs, 5000); }).then(probe);
});
}
clog(c, " waiting for the restarted KiCad to answer ...");
return probe();
}
function appendPicked(c, d, pickTitle, label, expect) {
var shots = findShots(d).filter(function (s) { return s && (s.image || s.base64 || s.png); });
if (!shots.length) return false;
var titleOf = function (s) { return ((s.title || s.label || s.window || "") + ""); };
var want = (pickTitle || "").toLowerCase();
var picked = want ? shots.filter(function (s) { return titleOf(s).toLowerCase().indexOf(want) >= 0; }) : shots;
if (!picked.length) picked = [shots[shots.length - 1]];
if (expect) {
var e = expect.toLowerCase();
var exact = picked.filter(function (s) { return titleOf(s).toLowerCase().indexOf(e) >= 0; });
if (!exact.length) {
var got = titleOf(picked[0]);
clog(c, " WRONG LIBRARY: asked for '" + expect + "' but the window is '" + got + "'");
addShot(c.card, picked[0].image || picked[0].base64 || picked[0].png,
"REJECTED " + label + " - wrong library: " + got);
return false;
}
picked = exact;
}
var s0 = picked[0];
addShot(c.card, s0.image || s0.base64 || s0.png, label + " - " + titleOf(s0));
return true;
}
function showLibraryProof(c) {
c.btn.disabled = true;
cardLog(c.card).textContent = "";
var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";
setAwaiting(c.res, "asking permission (choose “Allow 1 hr”) ...",
"on " + esc(selfLabel()) + " - opens the symbol, the footprint and the 3D model as proof",
"on " + esc(selfLabel()) + " - opens the symbol, the footprint and the 3D model as proof");
clog(c, "Target machine: " + selfLabel());
startSurfaceProgress(c, "kicad_show_library");
var results = [];
stageKicadPart(c).then(function () {
// Browse mode WITH symbolName actually loads the part into the canvas -
// the bridge says so in its own hint: "To load a part into the canvas,
// call again with symbolName too."
clog(c, "Opening the '" + PART.kicadLib + "' library at " + (PART.kicadSymbolName || PART.name) + " ...");
return postVerb("kicad_show_library", {
libraryName: PART.kicadLib,
symbolName: PART.kicadSymbolName || PART.name
});
}).then(function () {
var steps = [
{ cmd: "kicad_show_symbol", label: "symbol", pick: "symbol editor",
expect: function () { return PART.kicadLib + ":" + (PART.kicadSymbolName || PART.name); },
args: function () { return { symbolName: PART.kicadSymbolName || PART.name, libraryName: PART.kicadLib, library: PART.kicadLib }; } },
{ cmd: "kicad_show_footprint", label: "footprint", pick: "footprint editor",
expect: function () { return PART.kicadLib + ":" + (PART.kicadFootprintName || PART.name); },
args: function () { return { footprintName: PART.kicadFootprintName || PART.name, libraryName: PART.kicadLib, library: PART.kicadLib }; } },
{ cmd: "kicad_show_3d_chip", label: "3D model", pick: "3d viewer",
args: function () { return { footprintName: PART.kicadFootprintName || PART.name, libraryName: PART.kicadLib, library: PART.kicadLib }; } }
];
return steps.reduce(function (chain, st) {
return chain.then(function () {
clog(c, "Proving the " + st.label + " ...");
return postVerb(st.cmd, st.args()).then(function (r) {
var jj = r.j || {};
var ok = r.s === 200 && jj.success !== false && !jj.errorCode &&
!(typeof jj.error === "string" && jj.error.trim() !== "");
// A KiCad restart is a SUCCESSFUL remedy that reports itself as a
// failure. The bridge notices its footprint cache predates the part,
// restarts KiCad, rebuilds the cache - and then runs out of time
// waiting for the instance it just started, so the caller is told
// "failed" about work that has already been done. Measured: the same
// part that failed this way passed on a later call with no
// intervention.
//
// So retry ONCE. "This will never work" and "this works in ten
// seconds" need opposite responses, and only one of them should be
// reported to a human as a broken library.
// Asked kicad-bridge#44 for a retryable errorCode + retryAfterMs so
// this can key on a field instead of on prose.
var msg = ((jj.error || "") + " " + (jj._hint || "")).toLowerCase();
var restarted = !ok && /cache predated|restarted kicad|ran out of time waiting/.test(msg);
if (restarted && !st._retried) {
st._retried = true;
clog(c, " " + st.label + ": the bridge restarted KiCad to rebuild its cache and timed out waiting for it. That work is DONE, so retrying once ...");
// Wait for the RESTART, not for a guess. A fixed 6s pause fired
// while KiCad was still coming up, so the retry failed too and a
// recoverable condition was reported as a missing artifact.
// Poll kicad_status until the fresh instance answers (cap ~2 min),
// because a library is only proven when all three artifacts open -
// giving up early turns a slow success into a false failure.
return waitForKicad(c, 24).then(function () {
return postVerb(st.cmd, st.args()).then(function (r2) {
var j2 = r2.j || {};
var ok2 = r2.s === 200 && j2.success !== false && !j2.errorCode &&
!(typeof j2.error === "string" && j2.error.trim() !== "");
results.push({ label: st.label, ok: ok2, retried: true });
if (!ok2) { clog(c, " " + st.label + " FAILED after the retry: " + (j2.error || j2.errorCode || ("HTTP " + r2.s))); return; }
clog(c, " " + st.label + " opened on the retry (the restart had already fixed it)");
return settleThenCapture(c, "kicad_state").then(function (sr) {
var proven2 = sr.s === 200 && sr.j && appendPicked(c, sr.j, st.pick, st.label, st.expect && st.expect());
if (!proven2) {
clog(c, " " + st.label + ": retry reported success but the capture did not prove it");
for (var i = results.length - 1; i >= 0; i--) {
if (results[i].label === st.label) { results[i].ok = false; break; }
}
}
});
});
});
}
results.push({ label: st.label, ok: ok });
if (!ok) {
clog(c, " " + st.label + " FAILED: " + (jj.error || jj.errorCode || ("HTTP " + r.s)));
return;
}
return settleThenCapture(c, "kicad_state").then(function (sr) {
var proven = sr.s === 200 && sr.j && appendPicked(c, sr.j, st.pick, st.label, st.expect && st.expect());
if (!proven) {
// The verb said success; the evidence does not back it. Believe
// the evidence - that is the whole point of capturing it.
clog(c, " " + st.label + ": the verb reported success but the capture did not prove it");
for (var i = results.length - 1; i >= 0; i--) {
if (results[i].label === st.label) { results[i].ok = false; break; }
}
}
});
});
});
}, Promise.resolve());
}).then(function () {
var good = results.filter(function (r) { return r.ok; }).length;
var missing = results.filter(function (r) { return !r.ok; }).map(function (r) { return r.label; });
finishSurfaceBar(c, good === 3);
if (good === 3) setRes(c.res, "ok", "Done - all 3 library artifacts opened",
"symbol + footprint + 3D model, each captured below");
else setRes(c.res, "err", good + "/3 library artifacts opened",
"missing: " + missing.join(", ") + " - a library is only proven when all three open");
c.btn.disabled = false;
}).catch(function (e) {
finishSurfaceBar(c, false);
setRes(c.res, "err", "library proof failed", (e && e.message) || String(e));
c.btn.disabled = false;
});
}
var _kicadPart = null;
function stageKicadPart(c) {
if (_kicadPart) { clog(c, "Part already staged this session (reusing)."); return _kicadPart; }
clog(c, "Staging " + PART.name + " from " + PART.page + " ...");
_kicadPart = Promise.all([
fetchPartText(PART.name + ".kicad_sym"),
fetchPartText(PART.name + ".kicad_mod"),
fetchPartBytes(PART.name + ".step")
]).then(function (got) {
clog(c, " fetched symbol (" + got[0].length + " B), footprint (" + got[1].length + " B), STEP (" + got[2].byteLength + " B) from the wiki");
return Promise.all([
stageFile(c, PART.name + ".kicad_sym", b64Text(got[0])),
stageFile(c, PART.name + ".kicad_mod", b64Text(got[1])),
stageFile(c, PART.name + ".step", b64Bytes(got[2]))
]);
}).then(function (paths) {
return postVerb("kicad_install_symbol", { filePath: paths[0], quietInstall: true }).then(function (r) {
if (!(r.s === 200 && r.j && r.j.success)) throw stageFail("kicad_install_symbol: " + ((r.j && r.j.error) || r.s));
if (r.j.libraryName) PART.kicadLib = r.j.libraryName;
// The symbol's name INSIDE the .kicad_sym is often NOT the filename:
// tja1051t-3-118 declares "TJA1051T_3,118", type-c-31-m-12 declares
// "USB_C_Receptacle_USB2.0_16P", CL21B104KBCNFNC drops its last letter.
// Asking for the file stem makes the bridge correctly fail to find it -
// 4 of 10 parts in the picker. The bridge reports the real name here, so
// use it for the show call AND for the wrong-part check.
if (r.j.symbolNames && r.j.symbolNames.length) {
PART.kicadSymbolName = r.j.symbolNames[0];
if (PART.kicadSymbolName !== PART.name) clog(c, " symbol is named '" + PART.kicadSymbolName + "' inside the file (not '" + PART.name + "')");
}
clog(c, " installed symbol into the '" + PART.kicadLib + "' library");
return postVerb("kicad_install_footprint", { filePath: paths[1], model: { filePath: paths[2] }, quietInstall: true });
}).then(function (r) {
if (!(r.s === 200 && r.j && r.j.success)) throw stageFail("kicad_install_footprint: " + ((r.j && r.j.error) || r.s));
// Same trap as the symbol, one level down: install_footprint registers
// under the name INSIDE the .kicad_mod when the caller passes only a
// filePath - tja1051t-3-118 lands as "SOIC127P600X175-8N". Asking for the
// file stem then finds nothing, which the bridge now reports honestly as
// existsInLibrary:false. It hands back the registered name, so use it.
if (r.j.footprintName) {
PART.kicadFootprintName = r.j.footprintName;
if (PART.kicadFootprintName !== PART.name) clog(c, " footprint registered as '" + PART.kicadFootprintName + "' (not '" + PART.name + "')");
}
clog(c, " installed footprint; the bridge wired the 3D model reference (0.9.108 model arg)");
});
});
_kicadPart.catch(function () { _kicadPart = null; }); // a failed staging retries on the next click
return _kicadPart;
}
// KiCad PROJECT staging — kicad_make_part_project (0.9.108+, closed #29):
// generates a real project around the part and returns the file paths the
// project/schematic/board buttons need. 0.9.112: SINGLE-COMPONENT by design
// (John's ruling — just the part, no companion resistor), refdes from the
// part's own Reference property, part-dominant tight edge cuts so an 0402
// fills the 3D view instead of rendering as a speck.
var _kicadProj = null;
function stageKicadProject(c) {
if (_kicadProj) return _kicadProj;
_kicadProj = stageKicadPart(c).then(function () {
clog(c, "Generating the part's demo project (kicad_make_part_project) ...");
return postVerb("kicad_make_part_project", { partName: PART.name }).then(function (r) {
if (r.s === 200 && r.j && r.j.success) {
PART.kicadProject = {
proPath: r.j.projectFile,
schPath: r.j.schematicFile,
pcbPath: r.j.boardFile
};
clog(c, " project generated (" + (r.j.reference || PART.name) + " on a part-dominant board): " + (r.j.projectFile || "?"));
return;
}
throw stageFail("kicad_make_part_project: " + ((r.j && (r.j.error || r.j.errorCode)) || r.s));
});
});
_kicadProj.catch(function () { _kicadProj = null; });
return _kicadProj;
}
// ---- KiCad surfaces: the eight-surface ladder for the wiki part ----
// ALL EIGHT open cold + background (the 3D warm-only limitation is obsolete:
// kicad-bridge #26 closed via the Win32 menu tier in 0.9.99).
[
{ label: function () { return "Symbol (" + PART.name + ")"; }, cmd: "kicad_show_symbol", args: function () { return { symbolName: PART.kicadSymbolName || PART.name, libraryName: PART.kicadLib }; }, stage: stageKicadPart, pick: "symbol editor", note: "installs the part from its wiki page, then shows JUST the symbol" },
{ label: function () { return "Footprint (" + PART.name + ")"; }, cmd: "kicad_show_footprint", args: function () { return { footprintName: PART.kicadFootprintName || PART.name, libraryName: PART.kicadLib, library: PART.kicadLib }; }, stage: stageKicadPart, pick: "footprint editor", note: "just the land pattern: pads + courtyard" },
{ label: function () { return "3D chip (" + PART.name + ")"; }, cmd: "kicad_show_3d_chip", args: function () { return { footprintName: PART.kicadFootprintName || PART.name, libraryName: PART.kicadLib, library: PART.kicadLib }; }, stage: stageKicadPart, pick: "3d viewer", note: "the part's STEP model on its footprint" },
{ label: function () { return "Library (" + PART.name + " in '" + PART.kicadLib + "') - proves all 3"; }, cmd: "kicad_show_library", proof: true, stage: stageKicadPart, note: "a library IS a symbol + a footprint + a 3D model, so this opens all three and captures one screenshot of each. It reports N/3, and only 3/3 is a pass - a library with a symbol but no 3D model is not a working library." },
{ label: function () { return "Schematic (just the " + PART.name + ")"; }, cmd: "kicad_show_schematic", args: function () { return { filePath: (PART.kicadProject || {}).schPath }; }, stage: stageKicadProject, pick: "schematic editor", note: "the generated single-component schematic (kicad_make_part_project)" },
{ label: "2D board (part placed, tight edge cuts)", cmd: "kicad_show_2d_board", args: function () { return { filePath: (PART.kicadProject || {}).pcbPath }; }, stage: stageKicadProject, pick: "pcb editor", note: "the part-dominant board" },
{ label: "3D board (real 3D body on the board)", cmd: "kicad_show_3d_board", args: function () { return { filePath: (PART.kicadProject || {}).pcbPath }; }, stage: stageKicadProject, pick: "3d viewer", note: "the populated board in 3D - the whole ladder underneath it" },
{ label: "Project (top of the tree)", cmd: "kicad_show_project", args: function () { return { filePath: (PART.kicadProject || {}).proPath }; }, stage: stageKicadProject, pick: "kicad 10", note: "the Project Manager: schematic + board hang off it" }
].forEach(function (s) {
var cc = card("grp-kicad", "verb", labelOf(s));
PART_CARDS.push({ cc: cc, label: labelFnOf(s) });
if (s.proof) {
// proof:"fusion" drives the Fusion verbs; anything else is the KiCad card.
var proofFn = (s.proof === "fusion") ? showFusionLibraryProof : showLibraryProof;
cc.btn.onclick = function () { proofFn(cc); };
return;
}
cc.btn.onclick = function () {
showSurface(cc, {
showCmd: s.cmd,
// A KiCad editor indexes its libraries when it OPENS, so after switching
// parts the already-open editor cannot see the new symbol and keeps
// showing the previous one (errorCode library_cache_stale). Ask the
// bridge to close and reopen it - but ONLY when the part actually
// changed, because the reopen pays the cold library index (~1 min,
// kicad-bridge #35).
showArgs: function () {
var a = (typeof s.args === "function") ? s.args() : (s.args || {});
// Rescan unless we JUST showed this exact part. On the first open we
// cannot know what the already-open editor holds, and assuming it is
// right is precisely how a stale editor gets reported as success.
if (KICAD_SHOWN !== PART.name) a.rescan = true;
return a;
},
stage: s.stage, pickTitle: s.pick, stateCmd: "kicad_state", note: s.note,
// Verify against the name we ACTUALLY asked for, which for a symbol is
// the in-file name, not the file stem.
// WHAT can be verified by window title differs per surface, because the
// WINDOW differs. Only verify where the title actually names the part:
// symbol -> "Adom:<NAME> - Symbol Editor" verifiable
// footprint -> "Adom:<NAME> - Footprint Editor" verifiable
// 3d chip -> "3D Viewer" NOT verifiable, generic title
// library -> whichever symbol the editor landed on, not the part
// Checking the last two reports a mismatch on a CORRECT open, and a false
// alarm is worse than no alarm: it teaches you to ignore the real one. The
// honest cost is that those two surfaces open but go unverified.
verifyName: function () {
return (s.cmd === "kicad_show_symbol") ? (PART.kicadSymbolName || PART.name)
: (s.cmd === "kicad_show_footprint") ? (PART.kicadFootprintName || PART.name)
: null;
},
onShown: function () { KICAD_SHOWN = PART.name; }
});
};
});
// ── AI thread card ────────────────────────────────────────────────────────
// The whole point of the demo: a wiki page composes a prompt and hands it to
// the AI on the user's machine. Same shape as the KiCad/Fusion cards - a slow
// verb, a bar polled ALONGSIDE it, and a screenshot as the receipt.
var cAi = card("grp-ai", "verb", "Open a named AI thread with this prompt");
cAi.btn.onclick = function () {
var name = (document.getElementById("ai-name").value || "").trim();
var prompt = (document.getElementById("ai-prompt").value || "").trim();
var submit = !!document.getElementById("ai-submit").checked;
if (!prompt) { cAi.res.className = "res bad"; cAi.res.textContent = "type a prompt first"; return; }
cAi.btn.disabled = true;
cAi.res.className = "res run";
cAi.res.textContent = submit ? "opening a thread and SENDING..." : "opening a thread, staging the prompt...";
cardLog(cAi.card).textContent = "";
shotsBox(cAi.card).innerHTML = "";
clog(cAi, "opening a thread named " + JSON.stringify(name || "Untitled"));
if (!submit) clog(cAi, "submit is OFF - the prompt waits in the composer for you");
// Poll the fast companion WHILE the slow verb runs. Awaiting the slow call
// first would collapse the bar to a single 100% frame.
startSurfaceProgress(cAi, "ai_thread");
postVerb("ai_thread", {
prompt: prompt,
name: name,
submit: submit,
tags: ["source:web-control"],
reason: "A wiki page asked to open an AI thread with a written prompt"
}).then(function (res) {
var j = res.j || {};
finishSurfaceBar(cAi, !!(j.ok || j.success));
cAi.btn.disabled = false;
if (!j.ok && !j.success) {
cAi.res.className = "res bad";
cAi.res.textContent = (j.errorCode || "failed") + ": " + String(j.error || "").slice(0, 160);
if (j._hint) clog(cAi, "hint: " + j._hint);
return;
}
cAi.res.className = "res ok";
cAi.res.textContent = (j.submitted ? "sent to " : "staged in ") + (j.name || "thread")
+ " (" + (j.seconds || "?") + "s)";
clog(cAi, "thread " + (j.tid || "?") + (j.lateAttach ? " (attached late)" : ""));
// Say which surface the evidence came from. A whole-window grab instead of
// the panel is still evidence, but the viewer deserves to know which they
// are looking at rather than inferring it from the pixels.
if (j.evidenceKind) clog(cAi, "evidence: " + j.evidenceKind);
if (j.evidenceNote) clog(cAi, "note: " + j.evidenceNote);
// The verb returns a PATH on the user's machine; read it back as base64 so
// the page can actually show it (screenshot_base64 re-reads a prior shot).
if (j.evidence) {
postVerb("screenshot_base64", { path: j.evidence, maxWidth: 900 }).then(function (r2) {
var b64 = (r2.j && (r2.j.image || (r2.j.data && r2.j.data.image))) || null;
if (b64) addShot(cAi.card, b64, "AI thread (" + (j.evidenceKind || "capture") + ")");
else clog(cAi, "evidence saved on the PC: " + j.evidence);
}).catch(function () { clog(cAi, "evidence saved on the PC: " + j.evidence); });
}
}).catch(function (e) {
finishSurfaceBar(cAi, false);
cAi.btn.disabled = false;
cAi.res.className = "res bad";
cAi.res.textContent = "failed: " + e;
});
};
var cKState = card("grp-kicad", "read", "Show KiCad state (every window)");
cKState.btn.onclick = function () { showSurface(cKState, { showCmd: "kicad_state", showArgs: { maxWidth: 900 }, stateCmd: "kicad_state", note: "captures every KiCad window as a screenshot" }); };
// Fusion component staging: the SAME wiki part's .lbr + .step, written onto the
// PC (the fusion_show_* verbs take a libraryPath / stepPath).
// The same standard as the KiCad library card, applied to Fusion: a library IS
// a symbol plus a footprint plus a 3D package, so proving one means opening all
// three and capturing each. John, 2026-08-20: "a library is all 3 of those items".
// Fusion runs ONE command at a time on its main thread. A slow surface (one was
// measured at 546s, fusion-bridge#40) keeps the thread busy AFTER the verb has
// returned, so the next call queues and times out - and the bridge reports that
// timeout as "add-in not responding", which reads like a dead add-in rather
// than a busy one. Measured here: the library open succeeded, then the symbol
// and footprint both failed that way, giving 0/3 on a perfectly healthy Fusion.
// So wait for drivable between artifacts instead of firing into a busy thread.
function waitForFusion(c, tries) {
var n = 0;
function probe() {
n++;
return postVerb("fusion_readiness", {}).then(function (r) {
var ready = r.s === 200 && r.j && r.j.ready === true;
if (ready) return true;
if (r.j && r.j.running === false) { clog(c, " Fusion is not running - stopping the wait"); return false; }
if (n >= tries) { clog(c, " Fusion still busy after ~" + (n * 6) + "s"); return false; }
return new Promise(function (rs) { setTimeout(rs, 6000); }).then(probe);
}, function () {
if (n >= tries) return false;
return new Promise(function (rs) { setTimeout(rs, 6000); }).then(probe);
});
}
return probe();
}
// fusion_state often returns NO captures (it is documented as "next release" in
// fusion-bridge#28 and still returns an empty set here). The single-surface
// path already falls back to asking the bridge which window is Fusion's and
// screenshotting it; the proof card needs the same, or a perfectly good open
// is scored as unproven because the STATE verb, not the SHOW verb, came back
// empty. Measured: symbol reported success, zero shots, 0/3.
function fusionCapture(c, label, expect) {
return settleThenCapture(c, "fusion_state").then(function (sr) {
if (sr.s === 200 && sr.j && appendPicked(c, sr.j, "", label, expect)) return true;
clog(c, " fusion_state returned no captures - falling back to a window screenshot");
return postVerb("fusion_window_info", {}).then(function (wi) {
var wd = (wi.j && (wi.j.data || wi.j)) || {};
if (!wd.hwnd) { clog(c, " no Fusion window reported"); return false; }
return postVerb("desktop_screenshot_base64", { hwnd: wd.hwnd, maxWidth: 900 }).then(function (sh) {
if (!(sh.s === 200 && sh.j && sh.j.image)) { clog(c, " window screenshot failed"); return false; }
var title = (wd.title || "") + "";
if (expect && title.toLowerCase().indexOf(String(expect).toLowerCase()) < 0) {
clog(c, " WRONG WINDOW: asked for '" + expect + "' but the window is '" + title + "'");
addShot(c.card, sh.j.image, "REJECTED " + label + " - " + title);
return false;
}
addShot(c.card, sh.j.image, label + " - " + (title || "Fusion window"));
return true;
});
}, function () { clog(c, " fusion_window_info failed"); return false; });
});
}
// fusion-bridge 1.9.100 owns name resolution now: it resolves near-misses
// itself (CL21B104KBCNFNC -> CL21B104KBCNFN), returns available[]/closestMatches[]
// when it cannot, and exposes fusion_list_library. So the .lbr XML parsing this
// used to do is DELETED - that was me reimplementing what the bridge can see.
// What remains is asking the library what it holds, which is a one-call read.
function resolveLbrNames(c) {
return postVerb("fusion_list_library", { libraryPath: PART.lbrPath }).then(function (r) {
var j = (r.j && (r.j.data || r.j)) || {};
var sy = j.symbols || [], pk = j.packages || [], ds = j.devicesets || [];
PART.fusionSymbol = sy.length ? sy[0] : PART.name;
PART.fusionPackage = pk.length ? pk[0] : null;
PART.fusionDeviceset = ds.length ? ds[0] : PART.name;
PART.fusionHas3d = j.has3d === true;
clog(c, " library holds: " + sy.length + " symbol(s), " + pk.length + " package(s), 3D bound: " + PART.fusionHas3d);
if (!PART.fusionPackage) clog(c, " this library contains NO package - a footprint cannot be shown for this part");
}, function () { clog(c, " (fusion_list_library unavailable - falling back to the file stem)"); });
}
function showFusionLibraryProof(c) {
c.btn.disabled = true;
cardLog(c.card).textContent = "";
var sb = c.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";
setAwaiting(c.res, "asking permission (choose “Allow 1 hr”) ...",
"on " + esc(selfLabel()) + " - opens the symbol, the footprint and the 3D package as proof",
"on " + esc(selfLabel()) + " - opens the symbol, the footprint and the 3D package as proof");
clog(c, "Target machine: " + selfLabel());
startSurfaceProgress(c, "fusion_show_library");
var results = [];
stageFusionPart(c).then(function () {
return resolveLbrNames(c).then(function () {
// Bind the STEP as REAL 3D first. Without this the library has no
// package3d and Fusion renders a grey "Placeholder" slab - which counted
// as a captured 3D artifact and is exactly the false pass John caught.
if (PART.fusionHas3d) { clog(c, "3D already bound - skipping the bind"); return; }
clog(c, "Binding the STEP as real 3D (fusion_bind_library_3d) ...");
return postVerb("fusion_bind_library_3d", { libraryPath: PART.lbrPath, stepPath: PART.stepPath })
.then(function (b) {
var bj = b.j || {};
if (bj.bound || bj.wip_urn) clog(c, " bound: " + (bj.wip_urn || "yes"));
else clog(c, " 3D bind did NOT report a binding: " + (bj.error || bj.errorCode || ("HTTP " + b.s)));
PART.fusionBound = !!(bj.bound || bj.wip_urn);
}, function () { clog(c, " 3D bind call failed"); });
}).then(function () {
clog(c, "Opening the library " + PART.name + ".lbr ...");
return postVerb("fusion_show_library", { libraryPath: PART.lbrPath });
});
}).then(function () {
var steps = [
{ cmd: "fusion_show_symbol", label: "symbol",
args: function () { return { name: PART.fusionSymbol || PART.name, libraryPath: PART.lbrPath }; } },
{ cmd: "fusion_show_footprint", label: "footprint",
args: function () { return { name: PART.fusionPackage || PART.name, libraryPath: PART.lbrPath }; } },
{ cmd: "fusion_show_3d_package", label: "3D package",
args: function () { return { name: PART.fusionPackage || PART.name, libraryPath: PART.lbrPath, stepPath: PART.stepPath }; } }
];
return steps.reduce(function (chain, st) {
return chain.then(function () {
clog(c, "Waiting for Fusion to be idle before the " + st.label + " ...");
return waitForFusion(c, 100).then(function () {
clog(c, "Proving the " + st.label + " ...");
return postVerb(st.cmd, st.args()).then(function (r) {
var jj = r.j || {};
var ok = r.s === 200 && jj.success !== false && !jj.errorCode &&
!(typeof jj.error === "string" && jj.error.trim() !== "");
results.push({ label: st.label, ok: ok });
if (!ok) {
clog(c, " " + st.label + " FAILED: " + (jj.error || jj.errorCode || ("HTTP " + r.s)));
return;
}
// Fusion drives ONE window, so the part name in the title is the
// identity check available here - there is no LIB:NAME prefix.
return fusionCapture(c, st.label, PART.name).then(function (got) {
if (!got) {
clog(c, " " + st.label + ": reported success but the capture did not prove it");
for (var i = results.length - 1; i >= 0; i--) {
if (results[i].label === st.label) { results[i].ok = false; break; }
}
}
});
});
});
});
}, Promise.resolve());
}).then(function () {
var good = results.filter(function (r) { return r.ok; }).length;
var missing = results.filter(function (r) { return !r.ok; }).map(function (r) { return r.label; });
finishSurfaceBar(c, good === 3);
if (good === 3) setRes(c.res, "ok", "Done - all 3 library artifacts opened",
"symbol + footprint + 3D package, each captured below");
else setRes(c.res, "err", good + "/3 library artifacts opened",
"missing: " + missing.join(", ") + " - a library is only proven when all three open");
c.btn.disabled = false;
}).catch(function (e) {
finishSurfaceBar(c, false);
setRes(c.res, "err", "library proof failed", (e && e.message) || String(e));
c.btn.disabled = false;
});
}
var _fusionPart = null;
function stageFusionPart(c) {
if (_fusionPart) { clog(c, "Part already staged this session (reusing)."); return _fusionPart; }
clog(c, "Staging " + PART.name + ".lbr + .step from " + PART.page + " ...");
_fusionPart = Promise.all([
fetchPartText(PART.name + ".lbr"),
fetchPartBytes(PART.name + ".step")
]).then(function (got) {
return Promise.all([
stageFile(c, PART.name + ".lbr", b64Text(got[0])),
stageFile(c, PART.name + ".step", b64Bytes(got[1]))
]).then(function (paths) { PART.lbrPath = paths[0]; PART.stepPath = paths[1]; });
});
_fusionPart.catch(function () { _fusionPart = null; });
return _fusionPart;
}
// Fusion PROJECT staging — the fusion-bridge's own per-part flow (#30 reply,
// v1.9.41): (1) fusion_bind_library_3d mints the part's cloud 3D urn and binds
// it into the .lbr (signed-in Autodesk session required; best-effort — the
// design still stages without it, just with a placeholder 3D body), then
// (2) fusion_make_test_project generates a SINGLE-COMPONENT design (John's
// ruling: just the part — one symbol, one element on a small board) with the
// part's real refdes. After it, schematic / 2D / 3D board show the real design.
var _fusionProj = null;
function stageFusionProject(c) {
if (_fusionProj) return _fusionProj;
_fusionProj = stageFusionPart(c).then(function () {
clog(c, "Binding the part's 3D body into the library (fusion_bind_library_3d; needs a signed-in Autodesk session) ...");
return postVerb("fusion_bind_library_3d", { libraryPath: PART.lbrPath, stepPath: PART.stepPath }).then(function (r) {
if (r.s === 200 && r.j && r.j.success) clog(c, " 3D body bound into the library");
else clog(c, " 3D bind skipped (" + ((r.j && (r.j.error || r.j.errorCode)) || r.s) + ") - continuing; the 3D board may show a placeholder body");
clog(c, "Generating the single-component design (fusion_make_test_project) ...");
return postVerb("fusion_make_test_project", { libraryPath: PART.lbrPath });
}).then(function (r) {
if (r.s === 200 && r.j && r.j.success) { clog(c, " design generated + open (" + (r.j.reference || PART.name) + ")"); return; }
throw stageFail("fusion_make_test_project: " + ((r.j && (r.j.error || r.j.errorCode)) || r.s));
});
});
_fusionProj.catch(function () { _fusionProj = null; });
return _fusionProj;
}
// ---- Fusion surfaces: same part, same ladder ----
// All eight fusion_show_* verbs are live (v1.9.41) and open in the BACKGROUND;
// fusion_state (fusion-bridge #28) is the render path, so pickTitle:"fusion"
// pulls the surface's window out of the state array.
[
{ label: function () { return "Symbol (" + PART.name + ")"; }, cmd: "fusion_show_symbol", args: function () { return { name: PART.name, libraryPath: PART.lbrPath }; }, stage: stageFusionPart, note: "full pinout in the symbol editor" },
{ label: function () { return "Footprint (" + PART.name + ")"; }, cmd: "fusion_show_footprint", args: function () { return { name: PART.name, libraryPath: PART.lbrPath }; }, stage: stageFusionPart, note: "pads + layers in the footprint editor" },
{ label: function () { return "3D package (" + PART.name + ")"; }, cmd: "fusion_show_3d_package", args: function () { return { name: PART.name, libraryPath: PART.lbrPath, stepPath: PART.stepPath }; }, stage: stageFusionPart, note: "attaches the part's STEP to its footprint" },
{ label: function () { return "Library (" + PART.name + ".lbr) - proves all 3"; }, cmd: "fusion_show_library", proof: "fusion", stage: stageFusionPart, note: "a library IS a symbol + a footprint + a 3D package, so this opens all three and captures one screenshot of each. Only 3/3 is a pass." },
{ label: function () { return "Schematic (just the " + PART.name + ")"; }, cmd: "fusion_show_schematic", args: function () { return {}; }, stage: stageFusionProject, note: "the generated single-component design's schematic view" },
{ label: "2D board (part placed)", cmd: "fusion_show_2d_board", args: function () { return {}; }, stage: stageFusionProject, note: "the PCB editor view of the staged design" },
{ label: "3D board (real 3D body)", cmd: "fusion_show_3d_board", args: function () { return {}; }, stage: stageFusionProject, note: "populated board in 3D: the bound urn renders the real component" },
{ label: "Project (top of the design)", cmd: "fusion_show_project", args: function () { return {}; }, stage: stageFusionProject, note: "the generated project's top level" }
].forEach(function (s) {
var cc = card("grp-fusion", "verb", labelOf(s));
PART_CARDS.push({ cc: cc, label: labelFnOf(s) });
// The KiCad loop honours `proof` and this one did not, so the Fusion library
// card carried the "proves all 3" LABEL while still running the ordinary
// single-surface path. The label promised three artifacts and the handler
// delivered one - the exact mismatch this card exists to eliminate.
if (s.proof) { cc.btn.onclick = function () { showFusionLibraryProof(cc); }; return; }
cc.btn.onclick = function () {
// fallback stays a FUNCTION: its args reference paths the stage step produces.
showSurface(cc, { showCmd: s.cmd, showArgs: s.args, stage: s.stage, fallback: s.fallback, pickTitle: "fusion", stateCmd: "fusion_state", startCmd: "fusion_start", windowInfoCmd: "fusion_window_info", note: s.note,
// Fusion DESIGN surfaces (schematic / board / project) open a generated
// design whose window is not named after the part, so only the COMPONENT
// surfaces can be verified by title.
verifyName: function () { return s.verifiable === false ? null : PART.name; } });
};
});
// Single full-screen shot (keep the simple case too).
var cShot = card("grp-do", "verb", "Take a screenshot (whole screen)");
cShot.btn.onclick = function () {
var sb = cShot.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";
setAwaiting(cShot.res, "asking permission...", "approve the toast", "capturing the screen");
postVerb("desktop_screenshot_base64", { maxWidth: 900 }, chosenTarget()).then(function (res) {
if (res.s === 200 && res.j.image) { setRes(cShot.res, "ok", "Captured " + res.j.width + "x" + res.j.height, ""); addShot(cShot.card, res.j.image, "whole screen"); }
else if (res.j && res.j.errorCode === "web_control_disabled") setRes(cShot.res, "err", "Refused: web control OFF", "re-enable in Settings");
else setRes(cShot.res, "err", "no image", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s));
}).catch(function (e) { setRes(cShot.res, "err", "blocked", String(e)); });
};
var cUrl = card("grp-do", "verb", "Open a URL (then screenshot the REAL browser)");
cUrl.btn.onclick = function () {
streamRecipe(cUrl, [
{ command: "desktop_open_url", args: { url: "https://wiki.adom.inc" }, label: "Opening wiki.adom.inc in your default browser", usuallySec: 2 },
{ waitForWindow: { titleContains: "Adom Wiki", timeoutSec: 30 }, label: "Waiting for the page to load", usuallySec: 4 }
], { include: ["adom wiki", "wiki.adom.inc"], exclude: ["for testing", "127.0.0.1", "harness", "puppeteer"] });
};
// Notify + VISIBLE state re-query.
var cNotify = card("grp-do", "verb", "Notify me (sticky + query state)");
var notifyId = null;
cNotify.btn.onclick = function () {
notifyId = "webdemo-" + (Math.floor(Math.random() * 1e9));
setAwaiting(cNotify.res, "asking permission...", "approve the toast", "sending the toast");
postVerb("notify_user", { title: "Hello from a web page", body: "Sticky toast. Click Got it or Dismiss, then Check toast state.", scenario: "reminder", id: notifyId, buttons: [{ label: "Got it" }, { label: "Dismiss" }] }, chosenTarget()).then(function (res) {
var act = res.j && res.j.action;
if (res.s === 200 && act && String(act).toLowerCase().indexOf("error") === 0) { setRes(cNotify.res, "err", "notify rejected", String(act).slice(0, 140)); return; }
if (res.s === 200) {
if (res.j.id) notifyId = res.j.id;
setRes(cNotify.res, "ok", "Toast shown (id " + notifyId + ")", "click Got it / Dismiss on it, then Check toast state");
if (!cNotify.card.querySelector("button.chk")) {
var cb = document.createElement("button"); cb.className = "chk"; cb.textContent = "Check toast state";
cb.style.cssText = "margin-top:6px;background:#12181f";
cb.onclick = function () {
setRes(cNotify.res, "pending", "re-querying toast state ...", "GET notify_state id=" + notifyId);
postVerb("notify_state", { id: notifyId }, chosenTarget()).then(function (sr) {
if (sr.s === 200) {
var st = sr.j.state || "unknown";
var extra = sr.j.action ? (" (you clicked: " + sr.j.action + ")") : (sr.j.onScreen ? " (still on screen)" : "");
setRes(cNotify.res, st === "responded" ? "ok" : "pending", "state: " + st + extra, sr.j.note || "click a toast button, then check again");
logLine(" notify_state -> " + JSON.stringify(sr.j).slice(0, 200));
} else setRes(cNotify.res, "err", "state query failed", (sr.j && (sr.j.error || sr.j.errorCode)) || ("HTTP " + sr.s));
}).catch(function (e) { setRes(cNotify.res, "err", "state query error", String(e)); });
};
cNotify.card.appendChild(cb);
}
} else if (res.j && res.j.errorCode === "web_control_disabled") setRes(cNotify.res, "err", "Refused: web control OFF", "re-enable in Settings");
else setRes(cNotify.res, "err", "not approved", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s));
}).catch(function (e) { setRes(cNotify.res, "err", "blocked", String(e)); });
};
// ---- Multi-machine ----
var cNotifyAll = card("grp-multi", "multi", "Notify ALL my computers");
cNotifyAll.btn.onclick = function () { doVerb(cNotifyAll.res, "notify_user", { title: "Fan-out from the web", body: "Sent to every connected Adom Bridge." }, "all", function (j) { var s = j.summary || (j.data && j.data.summary); return s ? (s.ok + "/" + s.total + " machines notified") : "sent to all machines"; }); };
var cShotSel = card("grp-multi", "multi", "Screenshot the SELECTED machine");
cShotSel.btn.onclick = function () {
var sb = cShotSel.card.querySelector("div.shots"); if (sb) sb.innerHTML = "";
var who = machineLabel();
setAwaiting(cShotSel.res, "asking permission...", "capturing " + who, "capturing " + who);
postVerb("desktop_screenshot_base64", { maxWidth: 900 }, chosenTarget()).then(function (res) {
if (res.s === 200 && res.j.image) { setRes(cShotSel.res, "ok", "Captured " + res.j.width + "x" + res.j.height, "on " + who); addShot(cShotSel.card, res.j.image, who); }
else if (res.j && res.j.errorCode === "web_control_disabled") setRes(cShotSel.res, "err", "Refused: web control OFF", "re-enable in Settings");
else setRes(cShotSel.res, "err", "no image", (res.j && (res.j.error || res.j.errorCode)) || ("HTTP " + res.s));
}).catch(function (e) { setRes(cShotSel.res, "err", "blocked", String(e)); });
};
// ---- target picker ----
document.getElementById("refresh-targets").onclick = function () {
var btn = this, pill = document.getElementById("target-count");
// The user must SEE the click did something, instantly - and see the
// failure in the pill, not buried in the collapsed log (John, 2026-08-16).
btn.disabled = true; pill.style.color = ""; pill.textContent = "detecting ... (approve the Adom Bridge prompt if one appears)";
logLine("POST /command {targets}");
// Resolve the self name first so the dedupe + "self" label are right even
// when Detect was never clicked (an old ab just returns no machine block).
(selfName ? Promise.resolve(selfName) : loadSelfName())
.then(function () { return fetch(AB + "/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command: "targets" }) }); })
.then(function (r) { return r.json(); })
.then(function (j) {
btn.disabled = false;
// The direct API answers with clients[]; the relay-CLI shape is targets[].
var d = j.data || j; var list = d.targets || d.clients || [];
// The relay lists THIS machine by name too - that row is redundant with
// the "self" option, so fold it in (it names the self option instead).
var peers = list.filter(function (t) {
var n = (t.name || t.hostname || "").toLowerCase();
return !(selfName && n === selfName.toLowerCase());
});
sel.innerHTML = '<option value="">' + esc(selfOptionLabel()) + '</option>';
peers.forEach(function (t) { var o = document.createElement("option"); o.value = t.name || t.hostname; o.textContent = (t.name || t.hostname) + verTag(t.version); sel.appendChild(o); });
if (peers.length >= 1) { var a = document.createElement("option"); a.value = "all"; a.textContent = "ALL machines (fan out)"; sel.appendChild(a); }
document.getElementById("target-count").textContent = selfLabel() + (peers.length ? " + " + peers.length + " more" : " only") + (latestVersion ? " · latest ab v" + latestVersion : "");
})
.catch(function (e) {
btn.disabled = false; fetchDied(e);
pill.style.color = "#f85149";
pill.textContent = SANDBOXED ? "blocked: sandboxed embed (see banner)" : ("detect failed: " + String(e).slice(0, 80));
logLine("targets failed: " + e);
});
};
// Build the part picker LAST: it re-labels the KiCad/Fusion cards, so every
// card must already exist.
buildPartPicker();
})();
</script>
</body>
</html>