← Commit history

Publish 1.0.67

John Lauer ·3e42b99f18 ·1mo ago ·parent a39f3e8
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)">&middot; bridge launch page</span>+    KiCad Dashboard <span style="color:var(--text-2);font-weight:400">&middot; 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)">&times;</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 &middot; <span id="sayFoldedCount"></span> phrases &middot; 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 &amp; 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">&#9198;</button>+  <button class="dt-btn" id="dtPlay" data-tip="Pause or resume" aria-label="Pause or resume">&#9208;</button>+  <button class="dt-btn" id="dtNext" data-tip="Next step" aria-label="Next step">&#9197;</button>+  <span class="dt-sep"></span>+  <button class="dt-btn" id="dtMute" data-tip="Mute or unmute the narration" aria-label="Mute or unmute">&#128266;</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">&#10005;</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)+' &middot; '+(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='&ldquo;'+p.replace(/kicad/gi,m=>'<em>'+m+'</em>')+'&rdquo;'+      +'<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?'&#9654;':'&#9208;';+    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='&#9654;';toast('pausing after this step','ok');}+  if(cmd==='play'){$('#dtPlay').dataset.paused='0';$('#dtPlay').innerHTML='&#9208;';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='&ldquo;'+p.replace(/kicad/gi,m=>'<em>'+m+'</em>')+'&rdquo;';-    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",