← Commit history

1.3.11: guided demo tour with a play/pause/next/prev/exit floaty, ab desktop captions, pre-baked adom-tts voiceover clips behind a checkbox, spotlighting, and a front-and-center Play-the-1-minute-demo button in the single-X newbie panel

John Lauer ·a79bc208b5 ·1mo ago ·parent 8bd4535
5 files changed +209−23
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-chip-fetcher"-version = "1.3.10"+version = "1.3.11" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-chip-fetcher"-version = "1.3.10"+version = "1.3.11" edition = "2021" description = "Crawls the web on your behalf to gather everything about the chips you care about (datasheet, symbol, footprint, 3D, stock) into one library, by driving Chrome via pup." 
src/serve.rs+92
@@ -213,6 +213,27 @@ pub fn run(port: u16, print_url: bool, detached_child: bool) -> Result<()> {                 }             }             (Method::Get, "/api/durations") => json_response(crate::taskbar::durations_json()),+            (Method::Post, "/api/demo/caption") => {+                let mut body = String::new();+                let _ = request.as_reader().read_to_string(&mut body);+                let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();+                let text = v.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();+                std::thread::spawn(move || demo_caption(&text));+                json_response("{\"ok\":true}".into())+            }+            (Method::Post, "/api/demo/say") => {+                let mut body = String::new();+                let _ = request.as_reader().read_to_string(&mut body);+                let v: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();+                let text = v.get("text").and_then(|t| t.as_str()).unwrap_or("").to_string();+                let step = v.get("step").and_then(|n| n.as_u64()).unwrap_or(0);+                std::thread::spawn(move || demo_say(&text, step));+                json_response("{\"ok\":true}".into())+            }+            (Method::Post, "/api/demo/quiet") => {+                std::thread::spawn(demo_quiet);+                json_response("{\"ok\":true}".into())+            }             (Method::Get, path) if path.starts_with("/assets/") || path.contains("/assets/") => {                 let rel = path[path.find("/assets/").unwrap() + 8..].to_string();                 serve_ui_asset(&rel)@@ -1545,3 +1566,74 @@ pub fn open_in_hydrogen(port: u16) -> Result<()> {     }     Ok(()) }+++// ---- The guided demo's desktop side: ab captions + optional adom-tts voice. ----+// All best-effort: no desktop, no bridge, no tts = the floaty still narrates.++fn demo_caption(text: &str) {+    if !crate::bridge::available() {+        return;+    }+    if text.is_empty() {+        let _ = crate::bridge::call("desktop_caption", &serde_json::json!({"action": "hide", "id": "cf-tour"}));+        return;+    }+    // Same id so steps replace each other; auto-expires as a safety net.+    let _ = crate::bridge::call(+        "desktop_caption",+        &serde_json::json!({"id": "cf-tour", "text": text, "position": "bottom", "size": "medium", "duration": 20000}),+    );+    crate::dock::console_push("info", &format!("tour caption: {text}"), "demo");+}++fn demo_say(text: &str, step: u64) {+    // Pre-baked clips ship with the app (src/ui/assets/tts/, John's rule:+    // never synthesize on the fly during the demo). Play the shipped file;+    // fall back to live synthesis only when the asset is missing.+    let home = std::env::var("HOME").unwrap_or_default();+    let clip = [+        PathBuf::from("/home/adom/project/chip-fetcher/src/ui/assets/tts").join(format!("cf-tour-{step}.mp3")),+        PathBuf::from(&home).join(".local/share/adom-chip-fetcher/assets/tts").join(format!("cf-tour-{step}.mp3")),+    ]+    .into_iter()+    .find(|p| p.is_file());+    let st = match clip {+        Some(p) => std::process::Command::new("adom-tts")+            .args(["play", p.to_str().unwrap_or(""), "--allow-repeat"])+            .stdout(std::process::Stdio::null())+            .stderr(std::process::Stdio::null())+            .status(),+        None => {+            if text.is_empty() {+                return;+            }+            std::process::Command::new("adom-tts")+                .args([+                    "say", text, "--out", &format!("/tmp/cf-tour-{step}.mp3"), "--play", "--allow-repeat",+                    "--thread", "chip fetcher demo",+                    "--desc", &format!("cf guided tour, step {step}"),+                ])+                .stdout(std::process::Stdio::null())+                .stderr(std::process::Stdio::null())+                .status()+        }+    };+    if !st.map(|s| s.success()).unwrap_or(false) {+        crate::dock::console_push("warn", "tour voiceover unavailable (adom-tts failed or missing)", "demo");+    }+}++/// Stop tour output politely: hide our caption, and pause the SHARED tts+/// player only when the clip playing right now is one of ours.+fn demo_quiet() {+    if crate::bridge::available() {+        let _ = crate::bridge::call("desktop_caption", &serde_json::json!({"action": "hide", "id": "cf-tour"}));+    }+    if let Ok(out) = std::process::Command::new("adom-tts").arg("status").output() {+        let text = String::from_utf8_lossy(&out.stdout);+        if text.contains("now playing \"cf-tour-") {+            let _ = std::process::Command::new("adom-tts").args(["control", "pause"]).status();+        }+    }+}
src/settings.rs+1
@@ -23,6 +23,7 @@ pub const SCHEMA: &[(&str, &str, &[&str], &str)] = &[     ("show_stock_badges", "true", &[], "Stock and lifecycle badges on cards"),     ("show_welcome", "true", &[], "Newbie panel on the dashboard"),     ("seed_samples", "true", &[], "Seed sample parts into an empty library"),+    ("demo_voiceover", "true", &[], "Voiceover during the guided demo"),     ("activity_stale_seconds", "90", &[], "Heartbeat considered stale after (seconds)"), ]; 
src/ui/index.html+114−21
@@ -136,6 +136,16 @@ header{flex:0 0 44px;height:44px;display:flex;align-items:center;gap:12px;paddin .led.warn{background:var(--yellow)} .led.fail{background:var(--red)} +/* ---- Guided tour: floaty transport + spotlight ---- */+.tour-hud{position:fixed;top:64px;right:24px;z-index:var(--z-hud,300);width:300px;background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.45);padding:12px 14px;display:flex;flex-direction:column;gap:8px}+.tour-grip{cursor:grab;display:flex;align-items:center;justify-content:space-between;font:600 13px var(--brand-heading)}+.tour-step-num{color:var(--text-2);font:500 11px var(--mono)}+.tour-text{font:12.5px/1.5 var(--brand-body);color:var(--text-body);min-height:56px}+.tour-controls{display:flex;gap:6px;align-items:center}+.tour-controls .iconbtn{width:34px;height:30px}+.tour-voice{display:flex;align-items:center;gap:8px;font:12px var(--brand-body);color:var(--text-2);margin-left:auto}+.tour-spotlight{outline:2px solid var(--accent);outline-offset:4px;border-radius:12px;transition:outline-color .2s ease}+@media (max-width:720px){.tour-hud{left:12px;right:12px;width:auto;top:auto;bottom:12px}} /* ---- The newbie panel: say strip + LEDs + getting started, one closable block ---- */ .newbie{position:relative} .newbie-close{position:absolute;top:10px;right:10px;z-index:2}@@ -580,12 +590,13 @@ dialog.settings{width:min(680px,92vw);overflow:hidden}       <div class="welcome-head">         <h2>Getting started</h2>       </div>+      <p>New here? Take the tour: captions on your screen, optional voiceover, and a real fetch landing in front of you.</p>+      <p><button class="btn btn-primary" id="btn-tour">Play the 1-minute demo</button> <label class="tour-voice" style="display:inline-flex;margin-left:10px"><input type="checkbox" id="tour-voice-pref" checked> Voiceover</label></p>       <p>Once cf feels familiar, close this whole panel with the X in its corner; the ? in the header brings it back. The library starts with a few sample parts seeded from the Adom Wiki so the cards below are real; delete them any time.</p>       <ol>         <li><span class="num">1</span><div>Check the component lights above and fix anything that is not green. The Components card in Settings has an Install button for each one.<br><button class="btn" id="btn-welcome-settings">Open Settings to Components</button></div></li>         <li><span class="num">2</span><div>Ask your AI for a chip by part number, using the word <b>cf</b>. Try it right now with a serious one, the STM32H743 (a 480 MHz Cortex-M7):<br><button class="btn btn-primary" id="btn-welcome-example">Copy: "fetch STM32H743VIT6 with cf"</button></div></li>-        <li><span class="num">3</span><div>Or watch one happen right now: the demo pulls a published part off the Adom Wiki the same way a real lookup ends, and its card appears below within seconds.<br><button class="btn btn-primary" id="btn-demo">Run a demo fetch</button> <span class="card-status" id="demoStatus"></span></div></li>-        <li><span class="num">4</span><div>Cards appear here as parts are fetched. Each card carries the datasheet, the KiCad symbol, the footprint and the 3D model, with one-click open and send-to-EDA buttons.</div></li>+        <li><span class="num">3</span><div>Cards appear here as parts are fetched. Each card carries the datasheet, the KiCad symbol, the footprint and the 3D model, with one-click open and send-to-EDA buttons. The demo above runs one fetch for you, live.</div></li>       </ol>     </section>     </section>@@ -597,6 +608,17 @@ dialog.settings{width:min(680px,92vw);overflow:hidden} </div> </div> +<div class="tour-hud" id="tourHud" hidden role="dialog" aria-label="Guided tour">+  <div class="tour-grip" id="tourGrip"><span>Chip Fetcher tour</span><span class="tour-step-num" id="tourNum">1/7</span></div>+  <div class="tour-text" id="tourText"></div>+  <div class="tour-controls">+    <button class="iconbtn" id="tourPrev" aria-label="Previous step" data-tooltip="Previous step"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 18l-6-6 6-6"/></svg></button>+    <button class="iconbtn" id="tourPlay" aria-label="Pause" data-tooltip="Pause or resume the tour"><svg id="tourPlayIco" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M10 5v14M15 5v14"/></svg></button>+    <button class="iconbtn" id="tourNext" aria-label="Next step" data-tooltip="Next step"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6l6 6-6 6"/></svg></button>+    <button class="iconbtn" id="tourExit" aria-label="Exit the tour" data-tooltip="Exit the tour"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6L6 18M6 6l12 12"/></svg></button>+    <label class="tour-voice"><input type="checkbox" id="tourVoice"> Voiceover</label>+  </div>+</div> <dialog id="dlgSettings" class="settings" tabindex="-1" aria-labelledby="settingsTitle">   <div class="settings-head" id="settingsHead">     <h2 id="settingsTitle">Chip Fetcher Settings <span class="card-status" id="settingsVersion"></span></h2>@@ -1712,6 +1734,95 @@ async function renderSources() {   host.innerHTML = `<div class="srcwrap"><div class="srctitle">Sourcing order: how chip fetcher searches the internet</div><div class="srcsub">The real, code-backed order, served from <code>/api/sources</code> so it can never drift from behaviour. Three concerns, each searched top to bottom. <b>Manufacturer first for CAD; the Adom data tools (keys baked in, always work) for metadata; the manual-crawl skills are the fallback; LCSC is last.</b></div>${concerns}</div>`; } +// ---- Guided tour: floaty transport (play/pause/next/prev/exit), ab captions+// on the desktop via the server, pre-baked adom-tts voiceover (checkbox).+const TOUR = [+  { text: "Welcome to Chip Fetcher, cf for short. You do not drive this page by hand: your AI does the work. This one-minute tour shows you around.", dur: 11000, act: () => spotlight('newbie') },+  { text: "These are the phrases you say to your AI. Click any one to copy it, paste it to your AI, and cf goes and gets that part.", dur: 10000, act: () => spotlight('say') },+  { text: "These lights are cf's health check: the dashboard, your AI's skills, thirteen helper tools, your desktop, and your parts library. Green means go.", dur: 11000, act: () => spotlight('leds') },+  { text: "Now watch a real lookup land: cf is pulling a resistor bundle from the Adom Wiki right now. For a new part it crawls the manufacturer's site the same way.", dur: 12000, act: tourRunFetch },+  { text: "Every part becomes a card: the datasheet, the KiCad symbol, the footprint, and the 3D model, with checks that only go green when the bundle is complete.", dur: 11000, act: tourSelectDemo },+  { text: "The side pane opens every file, aligns pin one, and sends the part straight to KiCad, Fusion, Altium, or OrCAD.", dur: 9000, act: () => spotlight('detail') },+  { text: "That is Chip Fetcher. Ask your AI for any part number, with the word cf, and watch it land here. Close the tour with the X.", dur: 9000, act: () => spotlight('newbie') },+];+let tourIdx = -1, tourTimer = null, tourPaused = false, tourSpot = null;+function spotlight(id) {+  if (tourSpot) tourSpot.classList.remove('tour-spotlight');+  tourSpot = $id(id);+  if (tourSpot) { tourSpot.classList.add('tour-spotlight'); tourSpot.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); }+}+function tourRunFetch() {+  spotlight('cards');+  postJson('/api/jobs', { action: 'demo_fetch' }).catch(() => {});+}+function tourSelectDemo() {+  const c = LIB && LIB.chips.find(x => x.mpn === '0603WAF100JT5E');+  if (c) { select(c.mpn); spotlight('cards'); } else spotlight('cards');+}+function voiceOn() { return $id('tourVoice').checked; }+async function tourShow(i) {+  tourIdx = Math.max(0, Math.min(TOUR.length - 1, i));+  const st = TOUR[tourIdx];+  $id('tourNum').textContent = (tourIdx + 1) + '/' + TOUR.length;+  $id('tourText').textContent = st.text;+  postJson('/api/demo/caption', { text: st.text }).catch(() => {});+  if (voiceOn()) postJson('/api/demo/say', { text: st.text, step: tourIdx }).catch(() => {});+  try { st.act && st.act(); } catch (e) {}+  clearTimeout(tourTimer);+  if (!tourPaused) tourTimer = setTimeout(() => { tourIdx >= TOUR.length - 1 ? tourExit(true) : tourShow(tourIdx + 1); }, st.dur);+  postJson('/state/ui', { tour_step: tourIdx }).catch(() => {});+}+function tourSetPaused(p) {+  tourPaused = p;+  $id('tourPlayIco').innerHTML = p ? '<path d="M8 5v14l11-7z" fill="currentColor" stroke="none"/>' : '<path d="M10 5v14M15 5v14"/>';+  $id('tourPlay').setAttribute('aria-label', p ? 'Resume' : 'Pause');+  clearTimeout(tourTimer);+  if (!p && tourIdx >= 0) tourTimer = setTimeout(() => { tourIdx >= TOUR.length - 1 ? tourExit(true) : tourShow(tourIdx + 1); }, 4000);+  if (p) postJson('/api/demo/quiet', {}).catch(() => {});+}+function tourStart() {+  $id('tourVoice').checked = $id('tour-voice-pref') ? $id('tour-voice-pref').checked : (SETTINGS.demo_voiceover !== false);+  $id('tourHud').hidden = false;+  tourSetPausedIcon();+  tourPaused = false;+  tourShow(0);+}+function tourSetPausedIcon() { $id('tourPlayIco').innerHTML = '<path d="M10 5v14M15 5v14"/>'; }+function tourExit(finished) {+  clearTimeout(tourTimer); tourTimer = null; tourIdx = -1; tourPaused = false;+  $id('tourHud').hidden = true;+  if (tourSpot) { tourSpot.classList.remove('tour-spotlight'); tourSpot = null; }+  postJson('/api/demo/quiet', {}).catch(() => {});+  postJson('/state/ui', { tour_step: null }).catch(() => {});+  if (finished) cfToast('Tour done. Copy a phrase above and ask your AI for a part.', { type: 'ok' });+}+$id('btn-tour').onclick = tourStart;+$id('tourExit').onclick = () => tourExit(false);+$id('tourNext').onclick = () => { if (tourIdx >= 0) tourShow(tourIdx + 1); };+$id('tourPrev').onclick = () => { if (tourIdx > 0) tourShow(tourIdx - 1); };+$id('tourPlay').onclick = () => tourSetPaused(!tourPaused);+$id('tourVoice').onchange = async () => {+  try { await postJson('/api/settings', { demo_voiceover: $id('tourVoice').checked }); SETTINGS.demo_voiceover = $id('tourVoice').checked; } catch (e) {}+  if (!$id('tourVoice').checked) postJson('/api/demo/quiet', {}).catch(() => {});+};+$id('tour-voice-pref').onchange = async () => {+  try { await postJson('/api/settings', { demo_voiceover: $id('tour-voice-pref').checked }); SETTINGS.demo_voiceover = $id('tour-voice-pref').checked; } catch (e) {}+};+window.addEventListener('beforeunload', () => { if (tourIdx >= 0) navigator.sendBeacon && navigator.sendBeacon(API_BASE + '/api/demo/quiet', '{}'); });+// draggable by the grip (floating-windows skill)+(function () {+  const hud = $id('tourHud'), grip = $id('tourGrip');+  let sx = 0, sy = 0, ox = 0, oy = 0, drag = false;+  grip.addEventListener('pointerdown', e => { drag = true; sx = e.clientX; sy = e.clientY; const r = hud.getBoundingClientRect(); ox = r.left; oy = r.top; grip.setPointerCapture(e.pointerId); });+  grip.addEventListener('pointermove', e => {+    if (!drag) return;+    const x = Math.max(8, Math.min(innerWidth - hud.offsetWidth - 8, ox + e.clientX - sx));+    const y = Math.max(8, Math.min(innerHeight - hud.offsetHeight - 8, oy + e.clientY - sy));+    hud.style.left = x + 'px'; hud.style.top = y + 'px'; hud.style.right = 'auto'; hud.style.bottom = 'auto';+  });+  grip.addEventListener('pointerup', () => { drag = false; });+})();+ // Taskbar choice-card screenshots: real captures of the dashboard's taskbar // button on a Windows desktop (settings-layout: never fabricate). The PNGs are // native device pixels from a 150%-scaled display; render at@@ -1787,6 +1898,7 @@ function openSettings(cardId) { $id('btn-settings').onclick = () => openSettings(); $id('btn-welcome-settings').onclick = () => openSettings('card-components'); function applyNewbiePanel() {+  const pref = $id('tour-voice-pref'); if (pref && SETTINGS.demo_voiceover !== undefined) pref.checked = SETTINGS.demo_voiceover !== false;   $id('newbie').hidden = SETTINGS.show_welcome === false;   $id('btn-help').setAttribute('aria-pressed', String(SETTINGS.show_welcome !== false)); }@@ -1795,25 +1907,6 @@ $id('btn-newbie-hide').onclick = async () => {   try { await postJson('/api/settings', { show_welcome: false }); } catch (e) {}   cfToast('Newbie panel tucked away. The ? button in the header brings it back.', { type: 'ok' }); };-$id('btn-demo').onclick = async () => {-  const btn = $id('btn-demo'), st = $id('demoStatus');-  btn.disabled = true; st.textContent = 'starting';-  try {-    const r = await (await postJson('/api/jobs', { action: 'demo_fetch' })).json();-    if (!r.ok) throw new Error(r.error || 'could not start');-    const poll = setInterval(async () => {-      try {-        const j = await api('/api/jobs/' + r.id);-        st.textContent = j.state === 'running' ? 'fetching from the Adom Wiki' : j.state;-        if (j.state !== 'running') {-          clearInterval(poll); btn.disabled = false;-          if (j.state === 'done') { cfToast('Demo done: the card below just arrived from the Adom Wiki.', { type: 'ok' }); st.textContent = 'done'; }-          else cfToast('Demo failed; see the console (adom-chip-fetcher ui console).', { type: 'err' });-        }-      } catch (e) { clearInterval(poll); btn.disabled = false; }-    }, 800);-  } catch (e) { btn.disabled = false; st.textContent = ''; cfToast('Demo could not start: ' + e.message, { type: 'err' }); }-}; $id('btn-welcome-example').onclick = async () => {   const p = 'fetch STM32H743VIT6 with cf';   if (await copyText(p)) cfToast('Copied: ' + p + '. Paste it to your AI.', { type: 'ok' });