app
Chip Fetcher (cf)
Public Made by Adomby adom
Crawls the web on your behalf to gather everything about the chips you care about: datasheets, symbols, footprints, 3D models, stock and pricing, straight from the manufacturer first, into your EDA tool. Say "cf" to your AI.
← 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
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' });