app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
Publish 1.0.67
4 files changed
+307−107
dashboard/kicad_dashboard.py+53−2@@ -49,7 +49,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from urllib.parse import unquote, urlparse -VERSION = "2.3.0"+VERSION = "3.0.0" AI_THREAD = "kicad-dashboard" DEFAULT_PORT = 8931 UI_DIR = Path(__file__).resolve().parent / "ui"@@ -98,6 +98,50 @@ HYDROGEN_CONTROL = "http://127.0.0.1:47084" TRY_IT_PARKED = True +def _hydrogen_control_url():+ """The workspace control API base, from the well-known file the panel+ writes (~/.adom/hydrogen-control-url). None when this machine has no+ panel - the caller then falls back to copy."""+ try:+ u = (Path.home() / ".adom" / "hydrogen-control-url").read_text(+ encoding="utf-8").strip()+ return u.rstrip("/") or None+ except Exception:+ return None+++def say_send(phrase):+ """Send-to-AI per the say-strip contract (John's ruling 2026-08-24): open+ a NEW AI thread (staged name + dock tag) and submit the phrase. NEW+ threads only - resuming or typing into existing threads stays parked;+ this endpoint never calls resume and never targets a census tid."""+ import urllib.request++ def _post(base, path, body):+ req = urllib.request.Request(base + path,+ data=json.dumps(body).encode("utf-8"),+ method="POST",+ headers={"Content-Type": "application/json"})+ with urllib.request.urlopen(req, timeout=5) as r:+ return json.loads(r.read().decode("utf-8") or "{}")++ base = _hydrogen_control_url()+ if not base:+ return {"ok": False, "why": "no AI panel on this machine (hydrogen-control-url missing)"}+ try:+ new = _post(base, "/ai-threads/new",+ {"name": "KiCad - say it", "tags": ["dock:kicad-bridge"]})+ d = new.get("data") if isinstance(new.get("data"), dict) else new+ sid = d.get("sid") or d.get("id")+ if not sid:+ return {"ok": False, "why": "ai-threads/new returned no sid"}+ _post(base, "/ai-threads/run-prompt",+ {"sid": sid, "prompt": phrase, "submit": True})+ return {"ok": True, "sid": sid}+ except Exception as e:+ return {"ok": False, "why": f"{e.__class__.__name__}: {e}"}++ def stage_phrase(phrase): """'Try it': stage the phrase (unsubmitted) into this app's own AI thread through the Hydrogen control API. Reachable from WSL2 under mirrored@@ -967,7 +1011,8 @@ def do_action(target, name, args): finally: clear_activity("kicad", target) if name == "demo":- return ab(target, "kicad_demo", {"all": True, "background": True})+ return ab(target, "kicad_demo", {"all": True, "background": True,+ "muted": bool(args.get("muted"))}) if name == "demo_progress": return ab(target, "kicad_demo", {"progress": True}, timeout=60) if name == "upgrade":@@ -1124,6 +1169,12 @@ class Handler(BaseHTTPRequestHandler): return self._json({"ok": True, "delivered": HUB.count(), "_hint": "delivered = live SSE viewers that showed it. " "0 means nobody has the page open - open it first."})+ if path.endswith("/api/say-send"):+ phrase = str((body or {}).get("phrase") or "").strip()+ if not phrase:+ return self._json({"ok": False, "why": "phrase required"}, 400)+ log(f"say-send: {phrase[:60]}")+ return self._json(say_send(phrase)) if path == "/ui/click": t = body.get("target") or "" log(f"ui click {t}")
dashboard/ui/index.html+252−103@@ -30,13 +30,61 @@ header{height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;backg .brand{display:flex;align-items:center;gap:8px;font-family:'Familjen Grotesk',sans-serif;font-weight:600;color:var(--accent);white-space:nowrap;cursor:default} .brand img{width:18px;height:18px;border-radius:4px;display:block} .vsep{width:1px;height:20px;background:var(--border)}-.subject{font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}-.meta{font:12px 'JetBrains Mono',monospace;color:var(--text-2)}+.target-chip{display:inline-flex;align-items:center;gap:7px;font:12px 'JetBrains Mono',monospace;color:var(--text-body);background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:4px 12px;white-space:nowrap}+.target-chip .tdot{width:8px;height:8px} .actions{margin-left:auto;display:flex;gap:8px} .iconbtn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:8px;color:var(--text-body);cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease} .iconbtn:hover{background:var(--elevated);border-color:var(--border)} .iconbtn:active{transform:scale(.9);background:var(--overlay)}-main{max-width:1080px;margin:0 auto;padding:20px 16px 60px}+main{max-width:1080px;margin:0 auto;padding:20px 16px 84px}+/* dock-dashboard token stub (1.9.1): new components speak these names */+:root{--surface2:#1c232c;--line:#262d36;--muted:#8b949e;--teal:#00b8b1;+ --led-green:#3fb950;--led-amber:#d29922;--led-red:#f85149;+ --sans:'Familjen Grotesk',system-ui,sans-serif;--body:'Satoshi',system-ui,sans-serif;+ --mono:'JetBrains Mono',ui-monospace,Consolas,monospace}+/* say-strip (canonical) */+.say-chips{margin-top:8px}+.say-chip{display:inline-flex;align-items:center;gap:8px;background:var(--surface2);border:1px solid var(--line);border-radius:999px;padding:5px 6px 5px 12px;font:400 13.5px var(--body);margin:4px 6px 0 0;cursor:pointer;color:var(--text);transition:border-color .15s ease}+.say-chip:hover{border-color:var(--teal)}+.say-chip em{color:var(--teal);font-style:normal}+.say-chip .copy,.say-chip .send{font:500 11px var(--body);background:none;border:1px solid var(--line);border-radius:999px;padding:2px 9px;cursor:pointer;transition:color .15s ease,border-color .15s ease,background .15s ease}+.say-chip .copy{color:var(--muted)}+.say-chip .send{color:var(--teal);border-color:rgba(0,184,177,.45)}+.say-chip:hover .copy{color:var(--teal);border-color:var(--teal)}+.say-chip .send:hover{background:rgba(0,184,177,.12)}+.say-chip .copy:active,.say-chip .send:active{transform:translateY(1px)}+.say-head{position:relative;display:block;margin-bottom:2px}+.say-head h2{font:600 15px var(--sans);margin:0}+.say-head p{color:var(--muted);font-size:13.5px;margin:4px 0 0;max-width:70ch}+.say-x{position:absolute;top:-4px;right:-6px;background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;padding:4px 8px;border-radius:6px}+.say-x:hover{color:var(--text);background:var(--overlay)}+.say-folded,.fold-line{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:8px 14px;font-size:13px;color:var(--muted);cursor:pointer;margin-top:8px}+.say-folded:hover,.fold-line:hover{border-color:var(--teal);color:var(--text)}+/* status-leds pill row */+.leds{display:flex;flex-wrap:wrap;gap:8px}+.led-pill{display:inline-flex;align-items:center;gap:7px;background:var(--surface);border:1px solid var(--line);border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:pointer;color:var(--text-body);transition:border-color .15s ease}+.led-pill:hover{border-color:var(--teal)}+.led-pill:active{transform:translateY(1px)}+.led-pill .dot{width:8px;height:8px;border-radius:50%;background:var(--muted);flex:none}+.led-pill[data-state=ok] .dot{background:var(--led-green)}+.led-pill[data-state=busy] .dot{background:var(--led-amber);animation:ledpulse 1.1s ease-in-out infinite}+.led-pill[data-state=stuck] .dot{background:var(--led-amber)}+.led-pill[data-state=bad] .dot{background:var(--led-red)}+.led-pill .st{color:var(--muted)}+.led-detail-pop{position:fixed;z-index:99998;max-width:360px;background:var(--overlay);border:1px solid var(--line);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4)}+.led-detail-pop h4{font:600 13px var(--sans);color:var(--text);margin:0 0 6px}+.led-detail-pop .hint{color:var(--muted);margin-top:6px;font-size:12.5px}+.led-detail-pop .winlist{font:12px var(--mono);color:var(--muted);margin-top:6px;white-space:pre-wrap}+/* dash-demo toolbar */+#demoToolbar{position:fixed;left:50%;bottom:56px;transform:translateX(-50%);z-index:99998;display:flex;align-items:center;gap:6px;background:var(--overlay);border:1px solid var(--line);border-radius:999px;padding:6px 12px;box-shadow:0 8px 24px rgba(0,0,0,.45)}+.dt-btn{background:none;border:1px solid transparent;border-radius:8px;color:var(--text);font-size:15px;padding:4px 8px;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}+.dt-btn:hover{background:var(--surface2);border-color:var(--line)}+.dt-btn:active{transform:translateY(1px) scale(.95)}+.dt-btn:focus-visible{outline:2px solid var(--teal);outline-offset:2px}+.dt-sep{width:1px;height:18px;background:var(--line)}+.dt-step{font:12px var(--mono);color:var(--muted);padding:0 4px}+#dtMute.muted{opacity:.45}+#dtExit{color:var(--led-red)} /* "Say it": the first thing on the page (John, 2026-08-23: teach users how to ASK for the app by its noun, first and foremost). */ .say{background:var(--surface);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:12px;padding:14px 16px} .say-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;margin-bottom:10px}@@ -192,51 +240,51 @@ main a:hover{border-bottom-color:var(--accent)} </head> <body> <header>- <div class="brand">+ <div class="brand" id="brand" tabindex="0"> <img src="kicad-logo.png" alt="KiCad" width="18" height="18">- KiCad Dashboard <span style="color:var(--text-2)">· bridge launch page</span>+ KiCad Dashboard <span style="color:var(--text-2);font-weight:400">· the KiCad bridge</span> </div> <div class="vsep"></div>- <div class="subject" id="subject">connecting...</div>- <div class="meta" id="meta"></div>+ <!-- dash-header: the standard target chip (which box answers), LED inside;+ versions live in the brand popover, NEVER in the bar -->+ <span class="target-chip" id="targetChip" data-tip="The box this bridge answers on. Switch boxes with the pills under Status."><span class="tdot" id="chipDot"></span><span id="chipName">connecting...</span></span> <div class="actions">- <button class="iconbtn" id="btnSettings" title="Settings" aria-label="Settings"><!-- the shared Adom cog (mdi:cog), inlined so it follows currentColor; file copy in assets/icons/settings-cog.svg --><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z"/></svg></button>+ <button class="iconbtn" id="btnHelp" title="Show the getting-started helpers" aria-label="Show the getting-started helpers"><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M11 18h2v-2h-2v2m1-16C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8m0-14c-2.21 0-4 1.79-4 4h2c0-1.1.9-2 2-2s2 .9 2 2c0 2-3 1.75-3 5h2c0-2.25 3-2.5 3-5 0-2.21-1.79-4-4-4z"/></svg></button>+ <button class="iconbtn" id="btnSettings" title="Settings" aria-label="Settings"><!-- the shared Adom cog, OUTLINE per the 2026-08-24 fleet ruling (mdi:cog-outline; filled is deprecated) --><svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true"><path fill="currentColor" d="M12 8a4 4 0 0 1 4 4a4 4 0 0 1-4 4a4 4 0 0 1-4-4a4 4 0 0 1 4-4m0 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-2 12c-.25 0-.46-.18-.5-.42l-.37-2.65c-.63-.25-1.17-.59-1.69-.99l-2.49 1.01c-.22.08-.49 0-.61-.22l-2-3.46a.493.493 0 0 1 .12-.64l2.11-1.66L4.5 12l.07-1l-2.11-1.63a.493.493 0 0 1-.12-.64l2-3.46c.12-.22.39-.31.61-.22l2.49 1c.52-.39 1.06-.73 1.69-.98l.37-2.65c.04-.24.25-.42.5-.42h4c.25 0 .46.18.5.42l.37 2.65c.63.25 1.17.59 1.69.98l2.49-1c.22-.09.49 0 .61.22l2 3.46c.13.22.07.49-.12.64L19.43 11l.07 1l-.07 1l2.11 1.63c.19.15.25.42.12.64l-2 3.46c-.12.22-.39.31-.61.22l-2.49-1c-.52.39-1.06.73-1.69.98l-.37 2.65c-.04.24-.25.42-.5.42zm1.25-18l-.37 2.61c-1.2.25-2.26.89-3.03 1.78L5.44 7.35l-.75 1.3L6.8 10.2a5.55 5.55 0 0 0 0 3.6l-2.12 1.56l.75 1.3l2.43-1.04c.77.88 1.82 1.52 3.01 1.76l.37 2.62h1.52l.37-2.61c1.19-.25 2.24-.89 3.01-1.77l2.43 1.04l.75-1.3l-2.12-1.55c.4-1.17.4-2.44 0-3.61l2.11-1.55l-.75-1.3l-2.41 1.04a5.42 5.42 0 0 0-3.03-1.77L12.75 4z"/></svg></button> <button class="iconbtn" id="btnRefresh" data-tip="Re-read every facet's status from the bridge right now (also auto-refreshes every 15s)" aria-label="Refresh"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg></button> </div> </header> <main>+ <!-- say-strip (canonical, adom-ui-design 1.9.1) + the ONE demo button --> <section class="say" id="say" aria-labelledby="sayTitle"> <div class="say-head">- <h2 id="sayTitle" style="margin:0">Say it to your AI</h2>- <span class="say-sub">You do not drive this dashboard. You ask your AI, using the word <b>kicad</b>. Click a phrase or its Try it to copy it, then paste it into any AI thread.</span>+ <h2 id="sayTitle">Say it</h2>+ <p>You never need this page's buttons: just tell your AI what you want, using the word <b class="noun">kicad</b>. Copy a phrase, or send it straight to a new AI thread.</p>+ <button class="say-x" id="sayX" aria-label="Collapse" data-tip="Collapse this helper (the ? in the header brings it back)">×</button> </div>- <div class="say-list" id="sayList"></div>+ <div class="say-chips" id="sayChips"></div>+ <div class="btnrow" style="margin-top:10px"><button class="btn primary" id="btnDemo" data-tip="A guided six-view tour of your own KiCad, narrated, with captions">Play the demo</button></div> </section>+ <div class="say-folded" id="sayFolded" style="display:none">Say it · <span id="sayFoldedCount"></span> phrases · tap to expand</div> - <h2>Status</h2>+ <!-- status-leds: one compact pill row, prose on tap only -->+ <div class="leds" id="ledRow" style="margin-top:16px"></div>+ <div id="ledDetail" class="led-detail-pop" style="display:none"></div>++ <h2>Boxes & actions</h2> <div id="targets"></div> <div class="note" id="boxnote" style="display:none">Many boxes, one bridge: pick where KiCad should run. The bridge only ever drives the KiCad instances IT launched, so your own KiCad is never touched.</div>- <div class="grid" id="ledGrid"></div> <div class="btnrow"> <button class="btn primary" id="btnSetup">Setup</button> <button class="btn" id="btnLaunch">Launch KiCad</button> <button class="btn" id="btnClose">Close KiCad</button> </div>- <div class="wins" id="wins" style="margin-top:12px"></div> <h2>Demos</h2> <div class="card"> <div class="led-label">Guided demo</div> <div class="led-sub">Six beats on a generated sample project: schematic, 2D board, 3D board, symbol, footprint, 3D chip. Runs entirely in the background, and each finished beat streams back a screenshot.</div>- <div class="btnrow">- <button class="btn primary" id="btnDemo">Run the demo</button>- <span id="demoCtl" style="display:none;gap:8px">- <button class="btn" id="btnDemoPause" data-tip="Pause after the current beat finishes">Pause</button>- <button class="btn" id="btnDemoPrev" data-tip="Replay the previous beat">Prev</button>- <button class="btn" id="btnDemoNext" data-tip="Move on to the next beat">Next</button>- <button class="btn" id="btnDemoExit" data-tip="Stop the tour, silence narration, clear the caption" style="color:var(--red)">Exit</button>- </span>- </div>+ <div class="bar" id="demoBar"><div></div></div> <div class="barlabel" id="demoLabel"></div> <div class="shots" id="demoShots"></div>@@ -347,6 +395,16 @@ main a:hover{border-bottom-color:var(--accent)} </div> </dialog> +<!-- dash-demo player: a remote control, fixed bottom-center while the demo runs -->+<div id="demoToolbar" style="display:none">+ <button class="dt-btn" id="dtPrev" data-tip="Previous step" aria-label="Previous step">⏮</button>+ <button class="dt-btn" id="dtPlay" data-tip="Pause or resume" aria-label="Pause or resume">⏸</button>+ <button class="dt-btn" id="dtNext" data-tip="Next step" aria-label="Next step">⏭</button>+ <span class="dt-sep"></span>+ <button class="dt-btn" id="dtMute" data-tip="Mute or unmute the narration" aria-label="Mute or unmute">🔊</button>+ <span class="dt-step" id="dtStep">step 1/6</span>+ <button class="dt-btn" id="dtExit" data-tip="Stop the demo, clear the caption, silence narration" aria-label="Exit the demo">✕</button>+</div> <div id="toasts"></div> <div id="tt" role="tooltip"></div> <div id="appinfo"></div>@@ -391,44 +449,75 @@ async function loadTargets(){ } }catch(e){} }+// status-leds (1.9.1): one compact pill row; prose lives in tap-to-expand.+// A busy pill that stays busy past 15 minutes escalates to "stuck?" - a+// busy-for-a-day is broken, not busy (the fusion add-in lesson).+const BUSY_KEY='kdBusySince';+function busySince(){try{return JSON.parse(localStorage.getItem(BUSY_KEY))||{}}catch(e){return{}}}+function busySave(o){try{localStorage.setItem(BUSY_KEY,JSON.stringify(o))}catch(e){}}+function shortState(l){+ const d=(l.detail||'').trim();+ if(d&&d.length<=30)return d;+ if(d)return d.split(/[.;|]/)[0].slice(0,30);+ return l.state==='ok'?'ok':(l.state||'unknown');+} function renderLeds(st){- const g=$('#ledGrid');g.innerHTML='';+ const g=$('#ledRow');g.innerHTML=''; const leds=st.leds||{}; const ORDER=['relay','box','bridge','kicad','update','plugin','focus','presence','suite']; const keys=ORDER.filter(k=>leds[k]).concat(Object.keys(leds).filter(k=>!ORDER.includes(k)));+ const bs=busySince();const now=Date.now();let bsChanged=false; for(const key of keys){ const l=leds[key];- const cls=l.state==='busy'?'busy':(l.state==='ok'?'on':(l.state==='warn'?'warn':(l.state==='err'?'off':'dim')));- const el=document.createElement('div');el.className='card'+(l.state==='busy'?' busy':'');- el.innerHTML=`<div class="led-row"><span class="led ${cls}"></span><span class="led-label">${l.label||key}</span></div>`+- `<div class="led-detail">${l.detail||''}</div><div class="led-sub">${l._hint||''}</div>`;+ let state=l.state==='ok'?'ok':(l.state==='busy'?'busy':(l.state==='err'?'bad':(l.state==='warn'?'busy':'dim')));+ if(state==='busy'){+ if(!bs[key]){bs[key]=now;bsChanged=true;}+ else if(now-bs[key]>15*60*1000)state='stuck';+ }else if(bs[key]){delete bs[key];bsChanged=true;}+ const el=document.createElement('button');el.className='led-pill';el.dataset.state=state;el.dataset.key=key;+ el.innerHTML=`<span class="dot"></span><b>${l.label||key}</b><span class="st">${state==='stuck'?'stuck?':shortState(l)}</span>`;+ el.onclick=(ev)=>{ev.stopPropagation();showLedDetail(el,key,l,state,st);}; g.appendChild(el); }- $('#subject').textContent=st.target?('target: '+st.target):'no bridge target';- $('#meta').textContent='bridge '+(st.bridgeVersion||'?')+' | dashboard '+(st.dashboardVersion||'');- const w=st.windows||[];- $('#wins').textContent=w.length?('open KiCad windows: '+w.join(' · ')):'no KiCad windows open';+ if(bsChanged)busySave(bs);+ const chip=$('#chipName');if(chip){chip.textContent=st.target?('target: '+st.target):'no bridge target';}+ const dot=$('#chipDot');if(dot){const ok=(leds.relay||{}).state==='ok'&&(leds.box||{}).state==='ok';dot.className='tdot'+(ok?' on':'');} }+// tap-to-expand detail: prose, timestamps, and for a red pill the NEXT STEP+// first (the UI cousin of the CLI Hint: contract). The Plugin pill also+// carries the open-KiCad-windows list (its old home was a raw mono dump).+function showLedDetail(anchor,key,l,state,st){+ const d=$('#ledDetail');+ let h='<h4>'+(l.label||key)+' · '+(state==='stuck'?'stuck?':(l.state||''))+'</h4>';+ if(state==='bad'&&l._hint)h+='<div><b>Next step:</b> '+l._hint+'</div>';+ if(state==='stuck')h+='<div><b>Next step:</b> this has been busy over 15 minutes, which usually means wedged. Press Refresh; if it stays stuck, relaunch from the dock (the version-aware serve replaces a stale instance).</div>';+ if(l.detail)h+='<div style="margin-top:6px">'+l.detail+'</div>';+ if(l._hint&&state!=='bad')h+='<div class="hint">'+l._hint+'</div>';+ if(key==='plugin'||key==='kicad'){+ const w=(st&&st.windows)||[];+ h+='<div class="winlist">'+(w.length?('open KiCad windows:\n'+w.join('\n')):'no KiCad windows open')+'</div>';+ }+ d.innerHTML=h;d.style.display='block';+ const r=anchor.getBoundingClientRect(),M=8;+ let x=Math.max(M,Math.min(r.left,innerWidth-d.offsetWidth-M));+ let y=r.bottom+8;if(y+d.offsetHeight>innerHeight-M)y=r.top-d.offsetHeight-8;+ d.style.left=x+'px';d.style.top=Math.max(M,y)+'px';+}+document.addEventListener('click',()=>{const d=$('#ledDetail');if(d)d.style.display='none';}); const LED_SKELETON=['relay','box','bridge','kicad','update','plugin','focus','presence']; function renderSkeleton(){- const g=$('#ledGrid');if(g.children.length)return; // only before the first real paint- g.innerHTML=LED_SKELETON.map(k=>`<div class="card"><div class="led-row"><span class="led dim"></span>`+- `<span class="led-label" style="text-transform:capitalize">${k}</span></div>`+- `<div class="led-detail">checking...</div></div>`).join('');- $('#subject').textContent='checking '+(curTarget||'the default box')+'...';+ const g=$('#ledRow');if(g.children.length)return; // only before the first real paint+ g.innerHTML=LED_SKELETON.map(k=>`<span class="led-pill" data-state="dim"><span class="dot"></span><b style="text-transform:capitalize">${k}</b><span class="st">checking...</span></span>`).join('');+ const chip=$('#chipName');if(chip)chip.textContent='checking '+(curTarget||'the default box')+'...'; } let refreshing=false; async function refresh(force){ if(refreshing)return;refreshing=true;renderSkeleton();- try{lastStatus=await api(statusUrl(force));renderLeds(lastStatus);}+ try{lastStatus=await api(statusUrl(force));renderLeds(lastStatus);maybeCountLaunch(lastStatus);} catch(e){- $('#subject').textContent='status unavailable';- const g=$('#ledGrid');- g.innerHTML='<div class="card"><div class="led-row"><span class="led off"></span>'+- '<span class="led-label">Status unavailable</span></div>'+- '<div class="led-detail">'+String(e).slice(0,80)+'</div>'+- '<div class="led-sub">The dashboard could not read this box. Pick another with the pills above, or press Refresh.</div></div>';- toast('status failed: '+e,'err');+ const chip=$('#chipName');if(chip)chip.textContent='status unavailable';+ $('#ledRow').innerHTML='<button class="led-pill" data-state="bad"><span class="dot"></span><b>Status</b><span class="st">unavailable</span></button>';+ toast('status failed: '+e+' - pick another box with the pills below, or press Refresh','err'); } finally{refreshing=false;} }@@ -436,19 +525,60 @@ $('#btnRefresh').onclick=()=>{toast('refreshing status');refresh(true);}; $('#btnLaunch').onclick=async()=>{toast('launching KiCad in the background - watch the KiCad LED');await act('launch');}; $('#btnClose').onclick=async()=>{toast('closing the bridge\u2019s KiCad windows');await act('close');setTimeout(()=>refresh(true),4000);}; +// say-strip (canonical, 1.9.1): phrases from dockbar.json `say` via the+// server so the dock interstitial and this strip teach identical words.+// Chip click = copy; Copy = copy; Send to AI = POST api/say-send, which opens+// a NEW AI thread (staged name + dock:kicad-bridge tag) and submits - new+// threads ONLY, resume stays parked. On any failure: error toast + copy, so+// the user is never stranded.+function legacyCopy(text){+ try{const ta=Object.assign(document.createElement('textarea'),{value:text});+ ta.style.cssText='position:fixed;opacity:0';document.body.append(ta);+ ta.select();const ok=document.execCommand('copy');ta.remove();return ok;}catch(e){return false;}+}+function copyPhrase(text){+ const done=()=>toast('Copied to clipboard. Paste it into any AI thread.','ok');+ if(document.hasFocus()){navigator.clipboard.writeText(text).then(done,()=>{if(legacyCopy(text))done();});}+ else if(legacyCopy(text))done();+}+async function buildSay(){+ let phrases=[];+ try{const r=await act('say');phrases=((r.data||r).phrases)||[];}catch(e){}+ const list=$('#sayChips');list.innerHTML='';+ $('#sayFoldedCount').textContent=phrases.length;+ if(!phrases.length){$('#say').style.display='none';return;}+ for(const p of phrases){+ const chip=document.createElement('span');chip.className='say-chip';chip.dataset.say=p;+ chip.innerHTML='“'+p.replace(/kicad/gi,m=>'<em>'+m+'</em>')+'”'+ +'<button class="copy" data-tip="Copy this phrase">Copy</button>'+ +'<button class="send" data-tip="Open a NEW AI thread and send this phrase">Send to AI</button>';+ chip.addEventListener('click',()=>copyPhrase(p));+ chip.querySelector('.copy').addEventListener('click',e=>{e.stopPropagation();copyPhrase(p);});+ chip.querySelector('.send').addEventListener('click',async e=>{+ e.stopPropagation();+ const r=await fetch('api/say-send',{method:'POST',headers:{'Content-Type':'application/json'},+ body:JSON.stringify({phrase:p})}).then(x=>x.json()).catch(()=>null);+ if(r&&r.ok)toast('Sent to a new AI thread.','ok');+ else{copyPhrase(p);toast((r&&r.why)||'Could not reach the AI panel; copied instead.','err');}+ });+ list.appendChild(chip);+ }+} // Guided demo with a REAL progress bar + the stepShot stream (one screenshot per finished beat). $('#btnDemo').onclick=async()=>{ const b=$('#btnDemo');b.disabled=true; toast('starting the guided demo (background)'); $('#demoShots').innerHTML='';- const s=await act('demo');const d=s.data||s;+ const s=await act('demo',{muted:demoMuted()});const d=s.data||s; if(d.success===false){toast('demo failed to start: '+(d.error||'unknown'),'err');b.disabled=false;return;} $('#demoBar').style.display='block';- $('#demoCtl').style.display='inline-flex';- let demoPaused=false;+ $('#demoToolbar').style.display='flex'; const poll=setInterval(async()=>{ const p=await act('demo_progress');const q=p.data||p;- if(typeof q.paused==='boolean'&&q.paused!==demoPaused){demoPaused=q.paused;$('#btnDemoPause').textContent=demoPaused?'Play':'Pause';}+ const paused=(q.stepLabel==='Paused');+ $('#dtPlay').dataset.paused=paused?'1':'0';+ $('#dtPlay').innerHTML=paused?'▶':'⏸';+ if(q.beatIndex&&q.beatCount)$('#dtStep').textContent='step '+q.beatIndex+'/'+q.beatCount; $('#demoBar>div').style.width=(q.percent||0)+'%'; $('#demoLabel').textContent=(q.percent||0)+'% '+(q.stepLabel||''); if(q.stepShot&&q.stepShot.image){@@ -456,7 +586,7 @@ $('#btnDemo').onclick=async()=>{ c.innerHTML='<img src="data:image/png;base64,'+q.stepShot.image+'" alt="'+(q.stepShot.title||'')+'"><div class="cap">'+(q.stepShot.title||q.stepShot.step||'')+'</div>'; $('#demoShots').appendChild(c); }- if(q.done){clearInterval(poll);b.disabled=false;$('#demoCtl').style.display='none';toast(q.error?('demo failed: '+q.error):(q.result&&q.result.exited?'demo stopped':'demo complete'),'' + (q.error?'err':'ok'));refresh(true);}+ if(q.done){clearInterval(poll);b.disabled=false;$('#demoToolbar').style.display='none';toast(q.error?('demo failed: '+q.error):(q.result&&q.result.exited?'demo stopped':'demo complete'),'' + (q.error?'err':'ok'));refresh(true);} },3000); }; // Demo transport: mirrors the ab toast buttons; the bridge applies commands@@ -464,16 +594,12 @@ $('#btnDemo').onclick=async()=>{ async function demoCtl(cmd){ const r=await act('demo_control',{cmd});const d=(r&&r.data)||r||{}; if(d.error){toast('demo control failed: '+d.error,'err');return;}- if(cmd==='pause'){$('#btnDemoPause').textContent='Play';toast('pausing after this beat','ok');}- if(cmd==='play'){$('#btnDemoPause').textContent='Pause';toast('resuming','ok');}+ if(cmd==='pause'){$('#dtPlay').dataset.paused='1';$('#dtPlay').innerHTML='▶';toast('pausing after this step','ok');}+ if(cmd==='play'){$('#dtPlay').dataset.paused='0';$('#dtPlay').innerHTML='⏸';toast('resuming','ok');} if(cmd==='exit')toast('stopping the demo','ok'); if(cmd==='next')toast('skipping ahead','ok');- if(cmd==='prev')toast('replaying the previous beat','ok');+ if(cmd==='prev')toast('replaying the previous step','ok'); }-$('#btnDemoPause').onclick=()=>demoCtl($('#btnDemoPause').textContent==='Pause'?'pause':'play');-$('#btnDemoPrev').onclick=()=>demoCtl('prev');-$('#btnDemoNext').onclick=()=>demoCtl('next');-$('#btnDemoExit').onclick=()=>demoCtl('exit'); $('#btnPartTour').onclick=async()=>{ const page=$('#partPage').value.trim(); const b=$('#btnPartTour');b.disabled=true;@@ -489,52 +615,6 @@ $('#btnPartTour').onclick=async()=>{ // interstitial and this strip teach the same words. Copy on click; Try it // stages the phrase into the app's AI thread when the Hydrogen control API is // reachable, and copies with an honest toast when it is not.-// SHIP-NIGHT PARK (dock thread relaying John, 2026-08-23): "Try it" POSTs-// /ai-threads/run-prompt, whose typing path has an unverified crash fix and-// which wakes ah's parked AI backend - one press could crash the app the user-// is standing in. Copy-only until the dock thread lifts the park; the server-// action and this button code stay, flip the flag to restore.-const TRY_IT_PARKED=true; // still parks the SERVER staging path; the button below never calls it-function copyViaTextarea(txt){- try{const ta=document.createElement('textarea');ta.value=txt;ta.style.position='fixed';ta.style.opacity='0';- document.body.appendChild(ta);ta.select();const ok=document.execCommand('copy');ta.remove();return ok;}catch(e){return false;}-}-async function copyText(txt){- // In an UNFOCUSED window navigator.clipboard.writeText never settles- // ("document is not focused") and would hang this click forever, so route- // synchronously by hasFocus() and race the async API at 800ms anyway- // (parts-search 1.1.2 finding, via the dock thread).- if(!document.hasFocus())return copyViaTextarea(txt);- try{- const ok=await Promise.race([- navigator.clipboard.writeText(txt).then(()=>true),- new Promise(res=>setTimeout(()=>res('timeout'),800)),- ]);- if(ok===true)return true;- }catch(e){}- return copyViaTextarea(txt);-}-async function buildSay(){- let phrases=[];- try{const r=await act('say');phrases=((r.data||r).phrases)||[];}catch(e){}- const list=$('#sayList');list.innerHTML='';- if(!phrases.length){$('#say').style.display='none';return;}- for(const p of phrases){- const item=document.createElement('div');item.className='say-item';- const b=document.createElement('button');b.className='say-phrase';b.type='button';- b.setAttribute('data-tip','Copy this phrase');- b.innerHTML='“'+p.replace(/kicad/gi,m=>'<em>'+m+'</em>')+'”';- b.onclick=async()=>{(await copyText(p))?toast('Copied to clipboard. Paste it into any AI thread.','ok'):toast('could not copy - select and copy it by hand','err');};- item.appendChild(b);- // "Try it" copies the phrase - it makes ZERO /ai-threads calls (John,- // 2026-08-23 evening: no live staging for now; the user pastes into any- // AI thread themselves, and the toast tells them exactly that).- const t=document.createElement('button');t.className='say-try';t.type='button';t.textContent='Try it';- t.setAttribute('data-tip','Copies this phrase - paste it into any AI thread');- t.onclick=async()=>{(await copyText(p))?toast('Copied to clipboard. Paste it into any AI thread.','ok'):toast('could not copy - select and copy it by hand','err');};- item.appendChild(t);list.appendChild(item);- }-} // ---- Settings (settings-layout skill: modal, four dismissals, optimistic saves) ---- // The taskbar captures are native DEVICE pixels (2560-wide display at 150%). // Show them at naturalWidth / devicePixelRatio so they render 1:1 - the same@@ -703,6 +783,7 @@ function onSurfaceEvent(e){ const el=SURF_CARDS[e.surface];if(!el)return; const bar=el.querySelector('.sbar'); if(e.live){ // a real measured frame from the bridge (kicad_progress)+ if(e.caller&&!String(e.caller).startsWith('kicad-dashboard')&&!gradState().aiDriven){gradSave({aiDriven:true});} bar.style.display='block'; bar.classList.toggle('indet', e.percent==null); // unknown -> marquee, never 0% bar.classList.toggle('dim', e.confidence!=='measured'); // a guess never looks measured@@ -794,6 +875,10 @@ function fillAppInfo(){ '<br>engine: kicad-dashboard (Python) + adom-bridge<br>author: adom<br>'+ '<a href="https://wiki.adom.inc/adom/kicad-bridge" target="_blank" rel="noopener">wiki.adom.inc/adom/kicad-bridge</a>'; }+brand.addEventListener('click',()=>{fillAppInfo();ai.classList.toggle('show');+ const r=brand.getBoundingClientRect(),M=8;+ ai.style.left=Math.max(M,Math.min(r.left,innerWidth-ai.offsetWidth-M))+'px';+ ai.style.top=Math.min(r.bottom+8,innerHeight-ai.offsetHeight-M)+'px';}); brand.addEventListener('mouseenter',()=>{clearTimeout(aiHide);aiShow=setTimeout(()=>{ fillAppInfo();ai.classList.add('show'); const r=brand.getBoundingClientRect(),M=8;@@ -807,8 +892,72 @@ ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove( // FIRST PAINT MUST NOT WAIT ON THE NETWORK (ralph iter-2: /api/targets probes // every box, so one wedged laptop left the whole page on "connecting..." with // no LEDs and no pills). Skeleton now, then status and targets INDEPENDENTLY.+// ── newbie-graduation (1.9.1): Fresh -> Graduated -> Recallable ────────────+// Scaffolds fold on dismissal, 3 green launches, or the first AI-driven+// action; the say strip folds LAST (5 launches). The header's ? restores+// everything for this session. Storage is per-app by origin; unreadable+// storage renders Fresh - the safe default is teaching.+const GRAD_KEY='adom-dash-graduation';+function gradState(){try{return JSON.parse(localStorage.getItem(GRAD_KEY))||{}}catch(e){return{}}}+function gradSave(patch){try{localStorage.setItem(GRAD_KEY,JSON.stringify({...gradState(),...patch}))}catch(e){}}+function recalled(){try{return sessionStorage.getItem('adom-dash-recall')==='1'}catch(e){return false}}+const SCAFFOLDS=[+ {id:'say', el:'#say', folded:'#sayFolded', threshold:5},+ {id:'demos', el:null, folded:null, threshold:3}, // Demos + prompts fold via section helper+];+function applyGraduation(){+ const g=gradState();const fresh=recalled();+ const dis=g.dismissed||{};+ const launches=g.launches||0;+ // say strip: last to fold+ const sayFold=!fresh&&(dis.say||g.aiDriven||launches>=5);+ $('#say').style.display=sayFold?'none':'';+ $('#sayFolded').style.display=sayFold?'':'none';+ // demos + prompts sections: fold to their headings at 3+ const fold2=!fresh&&(dis.start||g.aiDriven||launches>=3);+ foldSection('Demos',fold2);foldSection('Copy-paste prompts for your AI',fold2);+}+function foldSection(title,fold){+ for(const h of document.querySelectorAll('main h2')){+ if(h.textContent.trim().startsWith(title)){+ let el=h.nextElementSibling;+ h.style.cursor='pointer';h.dataset.tip=fold?'Tap to expand (the ? in the header restores everything)':'';+ h.onclick=fold?()=>{let e2=h.nextElementSibling;while(e2&&e2.tagName!=='H2'){e2.style.display='';e2=e2.nextElementSibling;}h.onclick=null;}:null;+ while(el&&el.tagName!=='H2'){el.style.display=fold?'none':'';el=el.nextElementSibling;}+ }+ }+}+$('#sayX').onclick=()=>{gradSave({dismissed:{...(gradState().dismissed||{}),say:true}});applyGraduation();toast('Collapsed. The ? in the header brings it back.','ok');};+$('#sayFolded').onclick=()=>{$('#say').style.display='';$('#sayFolded').style.display='none';};+$('#btnHelp').onclick=()=>{try{sessionStorage.setItem('adom-dash-recall','1')}catch(e){};applyGraduation();toast('Helpers restored for this session.','ok');};+// launch counting: one per page load, when the critical row goes green within 60s+let _launchCounted=false;const _loadT=Date.now();+function maybeCountLaunch(st){+ if(_launchCounted||Date.now()-_loadT>60000)return;+ const l=st.leds||{};+ if(['relay','box','bridge','kicad'].every(k=>(l[k]||{}).state==='ok')){+ _launchCounted=true;gradSave({launches:(gradState().launches||0)+1});+ }+}++// ── dash-demo player: toolbar wiring ───────────────────────────────────────+const MUTE_KEY='kdDemoMuted';+function demoMuted(){try{return localStorage.getItem(MUTE_KEY)==='1'}catch(e){return false}}+function setMuteUI(){$('#dtMute').classList.toggle('muted',demoMuted());}+$('#dtMute').onclick=async()=>{+ const m=!demoMuted();try{localStorage.setItem(MUTE_KEY,m?'1':'0')}catch(e){}+ setMuteUI();await act('demo_control',{cmd:m?'mute':'unmute'});+ toast(m?'narration muted':'narration on','ok');+};+$('#dtPlay').onclick=()=>demoCtl($('#dtPlay').dataset.paused==='1'?'play':'pause');+$('#dtPrev').onclick=()=>demoCtl('prev');+$('#dtNext').onclick=()=>demoCtl('next');+$('#dtExit').onclick=()=>demoCtl('exit');+setMuteUI();+ renderSkeleton(); buildSay();+applyGraduation(); buildSurfaces(); refresh(); loadTargets();
package.json+1−1@@ -1,6 +1,6 @@ { "slug": "kicad-bridge",- "version": "1.0.66",+ "version": "1.0.67", "type": "app", "description": "Skills for your container so your AI knows how to drive the KiCad bridge. The bridge runtime itself is the release zip; Adom Bridge loads that.", "tags": [
page.json+1−1@@ -4,7 +4,7 @@ "slug": "kicad-bridge", "title": "KiCad - the KiCad Bridge", "brief": "Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.",- "version": "1.0.66",+ "version": "1.0.67", "tags": [ "kicad", "pcb",