← Commit history

2.12.4: demo transport + narration MP3s; captured KiCad evidence frames

John Lauer ·efc9b26bd0 ·1mo ago ·parent b14756c
8 files changed +130−57
Cargo.lock+1−1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"  [[package]] name = "adom-lbr"-version = "2.12.3"+version = "2.12.4" dependencies = [  "altium-codec",  "anyhow",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-lbr"-version = "2.12.3"+version = "2.12.4" edition = "2021" description = "adom-lbr generator / EDA translator: the canonical adom-lbr JSON is the hub, with native converters to Altium (.SchLib/.PcbLib/.IntLib), EAGLE/Fusion (.lbr), and KiCad. Altium output is written natively — no Altium needed at conversion time." 
VERSION+1−1
@@ -1 +1 @@-2.12.3+2.12.4
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "adom-lbr",-  "version": "2.12.3",+  "version": "2.12.4",   "type": "app",   "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},
page.json+9−12
@@ -1,8 +1,8 @@ {   "slug": "adom-lbr",-  "version": "2.12.3",+  "version": "2.12.4",   "type": "app",-  "description": "adom-lbr — the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad ⇄ Altium ⇄ EAGLE/Fusion) through one canonical adom-lbr JSON — symbol + footprint + 3D + pin↔pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",+  "description": "adom-lbr \u2014 the EDA library translator. Bring a component in from any supported EDA tool and convert it to any other (KiCad \u21c4 Altium \u21c4 EAGLE/Fusion) through one canonical adom-lbr JSON \u2014 symbol + footprint + 3D + pin\u2194pad map. Altium is written natively (no Altium needed). Includes the adom-lbr Manager: browse wiki components and view symbol/footprint/3D + the per-EDA layer stackup.",   "dependencies": {},   "tags": [     "eda",@@ -52,24 +52,21 @@   ],   "files": [     "bin/**",-    "assets/**",     "SKILL.md",-    "skills/**",     "README.md",     "install.sh",     "uninstall.sh",-    "screenshots/**",-    "dockbar.json"+    "screenshots/**"   ],-  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools — KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion — through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",+  "discovery_pitch": "Convert electronic-component symbols + footprints between any two EDA tools \u2014 KiCad, Altium (written natively, no Altium needed), and EAGLE/Fusion \u2014 through one canonical adom-lbr JSON, silk/courtyard/fab layers preserved. Plus a Manager to browse and inspect any wiki part.",   "sample_prompts": [     {       "prompt": "Convert this KiCad symbol and footprint to an Altium SchLib and PcbLib",-      "label": "KiCad → Altium"+      "label": "KiCad \u2192 Altium"     },     {       "prompt": "Import this Altium footprint into the adom-lbr JSON",-      "label": "Altium → JSON"+      "label": "Altium \u2192 JSON"     },     {       "prompt": "Bundle this part into a single Altium IntLib",@@ -77,15 +74,15 @@     },     {       "prompt": "Convert this Altium SchLib and PcbLib to a KiCad symbol and footprint",-      "label": "Altium → KiCad"+      "label": "Altium \u2192 KiCad"     },     {       "prompt": "Export this adom-lbr JSON to a KiCad .kicad_sym and .kicad_mod",-      "label": "JSON → KiCad"+      "label": "JSON \u2192 KiCad"     },     {       "prompt": "Open the adom-lbr Manager to browse wiki components and view the layer stackup",       "label": "Browse parts"     }   ]-}\ No newline at end of file+}
skills/adom-lbr-dockbar/SKILL.md+10−2
@@ -129,8 +129,16 @@ live streaming console of each verb and its result, and evidence screenshots, matching ab's own web-control demo so the user is never running blind. Evidence is FINAL frames only, never a mid-operation screenshot (a step shot usually still shows the previous part and reads as "you opened the wrong-thing"; John, 2026-08-20). KiCad evidence comes from the show verb's own-reply, else `kicad_state {includeImages:true}`.+thing"; John, 2026-08-20). KiCad evidence uses the web-control shot contract+(adom-bridge#91): every show verb is called with `capture:true` and returns a+`thumbnailDataUrl` frame of the window it opened, canvas-paint-polled so a+blank can never pose as proof. The Library send opens ALL THREE surfaces+(symbol, footprint, 3D chip) and renders one captured frame of each - a+library IS all three. Frame titles are verified against the part name and+captioned "unverified" when they do not match (the 3D Viewer's own title is+accepted for the 3D frame). The Demo tour ships pre-rendered adom-tts+narration (assets/tour/step-NN.mp3, served at /tour/) with a floating+play/pause/prev/next/exit transport; nothing is synthesized at view time.  Detection (`GET /health`) lights the toolbar's dot. It works when the page is served at its 127.0.0.1 address, which is exactly how the dock opens it; a
src/manage.html+94−39
@@ -86,6 +86,13 @@   #tour-card button:hover{border-color:var(--accent)}   #tour-card button.primary{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}   #tour-card button:active{transform:translateY(1px)}+  #tour-bar{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);z-index:3002;display:none;align-items:center;gap:6px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;padding:6px 10px;box-shadow:0 12px 36px #000c}+  #tour-bar.show{display:flex}+  #tour-bar button{width:34px;height:34px;border-radius:50%;border:1px solid var(--border);background:#1a2130;color:var(--ink);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;transition:border-color .12s,background .12s,transform .06s}+  #tour-bar button:hover{border-color:var(--accent);color:var(--accent)}+  #tour-bar button:active{transform:translateY(1px)}+  #tour-bar button.play{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}+  #tour-bar .tn{font:600 11.5px var(--brand-body);color:var(--text-2);padding:0 6px;min-width:44px;text-align:center}   #btnDemo{display:inline-flex;align-items:center;gap:6px;background:#12302f;border:1px solid var(--acc);color:var(--acc);border-radius:8px;padding:4px 12px;font:700 12px var(--brand-heading);cursor:pointer;transition:filter .12s,transform .06s}   #btnDemo:hover{filter:brightness(1.2)}   #btnDemo:active{transform:translateY(1px)}@@ -328,13 +335,14 @@   <div id="tour-ring"></div>   <div id="tour-card" role="dialog" aria-labelledby="tour-title">     <h3><span class="step-n" id="tour-n"></span><span id="tour-title"></span></h3>-    <p id="tour-text"></p>-    <div class="row">-      <button id="tour-back">Back</button>-      <span class="spacer" id="tour-progress"></span>-      <button id="tour-exit">Exit</button>-      <button class="primary" id="tour-next">Next</button>-    </div>+    <p id="tour-text" style="margin-bottom:0"></p>+  </div>+  <div id="tour-bar" role="toolbar" aria-label="Demo controls">+    <button id="tour-prev" title="Previous step (left arrow)" aria-label="Previous"><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/></svg></button>+    <button id="tour-play" class="play" title="Pause (space)" aria-label="Play or pause"><svg id="tour-play-ic" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg></button>+    <button id="tour-next" title="Next step (right arrow)" aria-label="Next"><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg></button>+    <span class="tn" id="tour-progress">1 / 13</span>+    <button id="tour-exit" title="Exit the demo (Esc)" aria-label="Exit"><svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>   </div>    <dialog id="dlgSettings" class="settings" tabindex="-1" aria-labelledby="settingsTitle">@@ -978,9 +986,10 @@ function renderEvidence(frames,expectTokens,cmd){   let allVerified=true;   frames.slice(0,4).forEach(x=>{     const title=x.title||x.step||'frame';-    const hit=expectTokens.some(t=>t&&title.toLowerCase().includes(t.toLowerCase()));+    const unrendered=x.canvasRendered===false;+    const hit=!unrendered&&expectTokens.some(t=>t&&title.toLowerCase().includes(t.toLowerCase()));     if(!hit)allVerified=false;-    sp.shot(x.thumbnailDataUrl||x.image||x.base64||x.png,(hit?'':'⚠ unverified: ')+title);+    sp.shot(x.thumbnailDataUrl||x.image||x.base64||x.png,(unrendered?'⚠ canvas not painted: ':hit?'':'⚠ unverified: ')+title);   });   if(allVerified)sp.log('  evidence: '+frames.length+' frame(s), titles name the part');   else sp.log('  WARNING: some evidence titles do not name this part; the editor may be showing something else');@@ -1013,32 +1022,47 @@ async function sendKicad(kind){   sp.bar(3);sp.step('preparing files…');   sp.log('GET api/export/'+currentPart);   const k=await kicadInstall(currentPart);+  // capture:true is the designed web-control evidence contract (adom-bridge#91):+  // the verb screenshots the window it opened, polls until the canvas actually+  // painted, and returns thumbnailDataUrl frames a page can render directly.+  const CAP={capture:true,captureMaxWidth:640};   const shows={-    'kicad-symbol':   ['kicad_show_symbol',   {symbolName:k.symbol,libraryName:k.lib}],-    'kicad-footprint':['kicad_show_footprint',{footprintName:k.footprint,libraryName:k.lib,library:k.lib}],-    'kicad-3d':       ['kicad_show_3d_chip',  {footprintName:k.footprint,libraryName:k.lib,library:k.lib}],-    'kicad-library':  ['kicad_show_library',  {libraryName:k.lib,partName:k.symbol}],+    'kicad-symbol':   ['kicad_show_symbol',   Object.assign({symbolName:k.symbol,libraryName:k.lib},CAP)],+    'kicad-footprint':['kicad_show_footprint',Object.assign({footprintName:k.footprint,libraryName:k.lib,library:k.lib},CAP)],+    'kicad-3d':       ['kicad_show_3d_chip',  Object.assign({footprintName:k.footprint,libraryName:k.lib,library:k.lib},CAP),['3D Viewer']],   };-  const [cmd,args]=shows[kind];-  sp.bar(80);sp.step('opening in KiCad…');-  sp.log(cmd+' '+JSON.stringify(args));-  let r=await abVerb(cmd,args);   const failed=x=>!(x.s===200&&x.j&&x.j.success!==false&&!x.j.errorCode);-  if(failed(r)&&r.j&&(r.j.errorCode==='library_cache_stale'||String(r.j.error||'').includes('indexed its libraries'))){-    // The open editor indexed its libraries before our install. The bridge's-    // own remedy: rescan:true reopens the editor to re-index (~1 minute cold-    // library scan; it never closes a HUMAN-opened editor without this).-    sp.log('  editor library index is stale; reopening with rescan:true (the cold re-index takes about a minute)');-    sp.step('re-indexing KiCad’s libraries (about a minute)…');sp.bar(null);-    r=await abVerb(cmd,Object.assign({rescan:true},args));+  const bare=n=>String(n||'').split(':').pop();+  const expect=[bare(k.symbol),k.footprint,k.lib+':'];+  async function runShow(cmd,args,pct,extraExpect){+    sp.bar(pct);sp.step('opening in KiCad…');+    sp.log(cmd+' '+JSON.stringify(args));+    let r=await abVerb(cmd,args);+    if(failed(r)&&r.j&&(r.j.errorCode==='library_cache_stale'||String(r.j.error||'').includes('indexed its libraries'))){+      // Stale editor index: the bridge's own remedy is rescan:true (reopens+      // the editor; the cold re-index takes about a minute).+      sp.log('  editor library index is stale; reopening with rescan:true (about a minute)');+      sp.step('re-indexing KiCad’s libraries (about a minute)…');sp.bar(null);+      r=await abVerb(cmd,Object.assign({rescan:true},args));+    }+    if(failed(r)){+      if(r.j&&r.j._hint)sp.log('  hint: '+r.j._hint);+      throw new Error(cmd+': '+((r.j&&(r.j.error||r.j.errorCode))||r.s));+    }+    sp.log('  '+cmd+' ok'+((r.j&&r.j.autoRescanned)?' (editor re-indexed)':''));+    return renderEvidence(findShots(r.j),expect.concat(extraExpect||[]),cmd);   }-  if(failed(r)){-    if(r.j&&r.j._hint)sp.log('  hint: '+r.j._hint);-    throw new Error(cmd+': '+((r.j&&(r.j.error||r.j.errorCode))||r.s));+  let verified;+  if(kind==='kicad-library'){+    // A library IS a symbol + a footprint + a 3D model (John's doctrine), so+    // the Library send opens ALL THREE and renders one captured frame of each.+    verified =await runShow('kicad_show_symbol',   Object.assign({symbolName:k.symbol,libraryName:k.lib},CAP),78);+    verified&=await runShow('kicad_show_footprint',Object.assign({footprintName:k.footprint,libraryName:k.lib,library:k.lib},CAP),86);+    verified&=await runShow('kicad_show_3d_chip',  Object.assign({footprintName:k.footprint,libraryName:k.lib,library:k.lib},CAP),94,['3D Viewer']);+  } else {+    const [cmd,args,extra]=shows[kind];+    verified=await runShow(cmd,args,80,extra);   }-  sp.log('  '+cmd+' ok'+((r.j&&r.j.autoRescanned)?' (editor re-indexed)':''));-  const bare=n=>String(n||'').split(':').pop();-  const verified=renderEvidence(findShots(r.j),[bare(k.symbol),k.footprint,k.lib+':'],cmd);   sp.bar(100);sp.step(verified?'done — open in KiCad':'done, but verify the KiCad window yourself');   toast('Open in KiCad: '+kind.replace('kicad-',''),false,verified?'success':undefined); }@@ -1132,11 +1156,23 @@ const TOUR=[  {sel:'#btnSettings',title:'Settings',text:'The cog holds the rest: the Say it strip switch, whose boards count as My projects, the list cache, and package Update/Uninstall with a live console. Every section explains itself.'},  {sel:'#say',title:'That is the tour',text:'From here: browse <b>Parts</b>, open anything, and when you want real work done, copy a phrase and ask your AI, the word is <b>lbr</b>. Enjoy.'} ];-let tourI=-1;+let tourI=-1,tourPlaying=false,tourTimer=null;+const tourAudio=new Audio();+tourAudio.preload='auto';+tourAudio.onended=()=>{ if(tourPlaying&&tourI>=0){ if(tourI<TOUR.length-1)tourShow(tourI+1); else tourStop(); } };+tourAudio.onerror=()=>{ if(tourPlaying&&tourI>=0){ clearTimeout(tourTimer); tourTimer=setTimeout(()=>{ if(tourPlaying&&tourI<TOUR.length-1)tourShow(tourI+1); else if(tourPlaying)tourStop(); },7000); } };+function tourPaintPlay(){+  const ic=document.getElementById('tour-play-ic');+  ic.innerHTML=tourPlaying?'<path fill="currentColor" d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>':'<path fill="currentColor" d="M8 5v14l11-7z"/>';+  document.getElementById('tour-play').title=tourPlaying?'Pause (space)':'Play (space)';+} function tourStop(){-  tourI=-1;demoUnhover();+  tourI=-1;tourPlaying=false;clearTimeout(tourTimer);+  try{tourAudio.pause();}catch(e){}+  demoUnhover();   document.getElementById('tour-ring').style.display='none';   document.getElementById('tour-card').style.display='none';+  document.getElementById('tour-bar').classList.remove('show'); } async function tourShow(i){   const prev=TOUR[tourI];@@ -1158,10 +1194,19 @@ async function tourShow(i){   document.getElementById('tour-n').textContent=(i+1)+' / '+TOUR.length;   document.getElementById('tour-title').textContent=st.title;   document.getElementById('tour-text').innerHTML=st.text;-  document.getElementById('tour-progress').textContent='';-  document.getElementById('tour-back').style.visibility=i===0?'hidden':'visible';-  document.getElementById('tour-next').textContent=i===TOUR.length-1?'Done':'Next';+  document.getElementById('tour-progress').textContent=(i+1)+' / '+TOUR.length;   card.style.display='block';+  document.getElementById('tour-bar').classList.add('show');+  tourPaintPlay();+  // pre-rendered narration (adom-tts, shipped with the app - nothing generated live)+  clearTimeout(tourTimer);+  try{tourAudio.pause();}catch(e){}+  tourAudio.src='tour/step-'+String(i+1).padStart(2,'0')+'.mp3';+  if(tourPlaying){+    tourAudio.play().catch(()=>{ // autoplay refused: keep the tour moving on a timer+      tourTimer=setTimeout(()=>{ if(tourPlaying&&tourI<TOUR.length-1)tourShow(tourI+1); },7000);+    });+  }   // place the card near the ring, clamped on screen, never covering it   const rr=el2?el2.getBoundingClientRect():{left:innerWidth/2-200,top:innerHeight/2,bottom:innerHeight/2,width:0};   const cw=Math.min(400,innerWidth*0.9),ch=card.offsetHeight||170;@@ -1170,11 +1215,21 @@ async function tourShow(i){   if(y+ch>innerHeight-12)y=Math.max(12,rr.top-ch-14);   card.style.left=x+'px';card.style.top=y+'px'; }-document.getElementById('btnDemo').onclick=()=>tourShow(0);-document.getElementById('tour-next').onclick=()=>tourShow(tourI+1);-document.getElementById('tour-back').onclick=()=>tourShow(tourI-1);+document.getElementById('btnDemo').onclick=()=>{tourPlaying=true;tourShow(0);};+document.getElementById('tour-next').onclick=()=>{ if(tourI<TOUR.length-1)tourShow(tourI+1); else tourStop(); };+document.getElementById('tour-prev').onclick=()=>{ if(tourI>0)tourShow(tourI-1); };+document.getElementById('tour-play').onclick=()=>{+  tourPlaying=!tourPlaying;tourPaintPlay();+  if(tourPlaying){ (tourAudio.src&&tourAudio.paused)?tourAudio.play().catch(()=>{}):tourShow(tourI); }+  else { try{tourAudio.pause();}catch(e){} clearTimeout(tourTimer); }+}; document.getElementById('tour-exit').onclick=tourStop;-document.addEventListener('keydown',e=>{if(tourI>=0){if(e.key==='Escape')tourStop();if(e.key==='ArrowRight'||e.key==='Enter')tourShow(tourI+1);if(e.key==='ArrowLeft')tourShow(tourI-1);}});+document.addEventListener('keydown',e=>{if(tourI>=0){+  if(e.key==='Escape')tourStop();+  if(e.key==='ArrowRight'||e.key==='Enter'){e.preventDefault();document.getElementById('tour-next').click();}+  if(e.key==='ArrowLeft'){e.preventDefault();document.getElementById('tour-prev').click();}+  if(e.key===' '){e.preventDefault();document.getElementById('tour-play').click();}+}}); addEventListener('resize',()=>{if(tourI>=0)tourShow(tourI);});  document.getElementById('btn-update').onclick=()=>runJob('update');
src/manage.rs+13
@@ -138,6 +138,19 @@ fn route_inner(method: &Method, url: &str, path: &str, body: &str) -> Response<C         (Method::Post, "/api/apply") => json_resp(&start_apply(body).to_string()),         (Method::Get, "/api/apply-progress") => json_resp(&get_progress().to_string()),         (Method::Get, "/") => html(INDEX_HTML),+        // Pre-rendered tour narration (adom-tts, built at release time - never+        // generated on the fly; John's ruling 2026-08-24). Served from assets/.+        (Method::Get, p) if p.starts_with("/tour/step-") && p.ends_with(".mp3") => {+            let name = &p["/tour/".len()..];+            let ok_name = name.len() == 11 && name.starts_with("step-") && name[5..7].bytes().all(|b| b.is_ascii_digit());+            let bytes = if ok_name {+                dock::assets_dir().map(|d| d.join("tour").join(name)).and_then(|f| std::fs::read(f).ok())+            } else { None };+            match bytes {+                Some(b) => Response::from_data(b).with_header(hdr("audio/mpeg")),+                None => Response::from_string("no narration").with_status_code(404),+            }+        }         (Method::Get, p) if p.ends_with("/favicon.svg") || p.ends_with("/favicon.ico") => {             Response::from_string(FAVICON_SVG).with_header(hdr("image/svg+xml"))         }