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.4.0: dock dashboard contract (adom-ui-design 1.9.1): canonical Say it strip with Copy + Send to AI (/api/say-send, new threads only, dock:adom-chip-fetcher tag), outline cog, target box chip in the subject zone, dash-demo toolbar (bottom-center, mute, default-size ab captions, quiet between steps), status pills expand prose on tap, newbie graduation (localStorage: dismissal / 3 green launches / first AI action; say strip folds last to the one-line thread back)
5 files changed
+173−72
Cargo.lock+1−1@@ -4,7 +4,7 @@ version = 4 [[package]] name = "adom-chip-fetcher"-version = "1.3.15"+version = "1.4.0" dependencies = [ "anyhow", "clap",
Cargo.toml+1−1@@ -1,6 +1,6 @@ [package] name = "adom-chip-fetcher"-version = "1.3.15"+version = "1.4.0" 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/dock.rs+1@@ -568,6 +568,7 @@ pub fn status_json(force: bool) -> String { "state": f.state, "leds": f.leds, "say": say_phrases(),+ "target": crate::bridge::target(), "missing_tools": f.missing_tools, "_hint": "GET /api/library for the cards, GET /api/activity for the live heartbeat, POST /ui/toast {message,kind} to raise a toast, POST /ui/command {cmd,...} to drive the front-end.", })
src/serve.rs+57−1@@ -230,6 +230,17 @@ pub fn run(port: u16, print_url: bool, detached_child: bool) -> Result<()> { std::thread::spawn(move || demo_say(&text, step)); json_response("{\"ok\":true}".into()) }+ (Method::Post, "/api/say-send") => {+ 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 phrase = v.get("phrase").and_then(|p| p.as_str()).unwrap_or("").to_string();+ if phrase.is_empty() {+ error_response(400, "{\"ok\":false,\"why\":\"phrase required\"}".into())+ } else {+ json_response(say_send(&phrase).to_string())+ }+ } (Method::Post, "/api/demo/quiet") => { std::thread::spawn(demo_quiet); json_response("{\"ok\":true}".into())@@ -1586,7 +1597,7 @@ fn demo_caption(text: &str) { // 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}),+ &serde_json::json!({"id": "cf-tour", "text": text, "duration": 20000}), ); crate::dock::console_push("info", &format!("tour caption: {text}"), "demo"); }@@ -1641,3 +1652,48 @@ fn demo_quiet() { } } }+++// ---- Send to AI (say-strip skill, John's 2026-08-24 ruling): open a NEW AI+// thread via the workspace control API and submit the phrase. New threads+// ONLY; resume/typing into existing threads stays parked.++fn hydrogen_control_base() -> String {+ let home = std::env::var("HOME").unwrap_or_default();+ std::fs::read_to_string(std::path::Path::new(&home).join(".adom/hydrogen-control-url"))+ .map(|s| s.trim().trim_end_matches('/').to_string())+ .unwrap_or_else(|_| "http://127.0.0.1:47084".into())+}++fn control_post(path: &str, body: &serde_json::Value) -> Option<serde_json::Value> {+ let url = format!("{}{}", hydrogen_control_base(), path);+ let out = std::process::Command::new("curl")+ .args(["-fsS", "-m", "6", "-X", "POST", &url, "-H", "Content-Type: application/json", "-d", &body.to_string()])+ .output()+ .ok()?;+ if !out.status.success() {+ return None;+ }+ serde_json::from_slice(&out.stdout).ok()+}++fn say_send(phrase: &str) -> serde_json::Value {+ let staged = control_post(+ "/ai-threads/new",+ &serde_json::json!({"name": "Chip Fetcher - say it", "tags": ["dock:adom-chip-fetcher"]}),+ );+ let sid = staged+ .as_ref()+ .and_then(|v| v.get("sid").or_else(|| v.get("id")))+ .and_then(|s| s.as_str().map(String::from).or_else(|| s.as_i64().map(|n| n.to_string())));+ let Some(sid) = sid else {+ return serde_json::json!({"ok": false, "why": "could not open a new AI thread (is the Hydrogen panel reachable from this workspace?)"});+ };+ match control_post("/ai-threads/run-prompt", &serde_json::json!({"sid": sid, "prompt": phrase, "submit": true})) {+ Some(r) if r.get("ok").and_then(|b| b.as_bool()).unwrap_or(true) => {+ crate::dock::console_push("info", &format!("say-send: new thread {sid}: {phrase}"), "say");+ serde_json::json!({"ok": true, "sid": sid})+ }+ _ => serde_json::json!({"ok": false, "why": "the new thread opened but the prompt could not be submitted"}),+ }+}
src/ui/index.html+113−69@@ -127,6 +127,20 @@ header{flex:0 0 44px;height:44px;display:flex;align-items:center;gap:12px;paddin .say-phrase em{font-style:normal;color:var(--accent)} .say-phrase:hover{background:var(--overlay)} .say-try{display:inline-flex;align-items:center;white-space:nowrap;padding:0 12px;border:none;border-left:1px solid var(--border);background:none;color:var(--text-2);font:600 12px var(--brand-heading)}+.say-send{display:inline-flex;align-items:center;white-space:nowrap;padding:0 12px;border:none;border-left:1px solid var(--border);background:none;color:var(--accent);font:600 12px var(--brand-heading)}+.say-send:hover{background:var(--accent-10)}+.say-send:active{transform:translateY(1px)}+.say-send:focus-visible{outline-offset:-2px}+.say.say-folded .say-sub,.say.say-folded .say-list,.say.say-folded h2{display:none}+.say.say-folded{padding:6px 12px}+.say-fold{display:block;width:100%;text-align:left;background:none;border:none;color:var(--text-2);font:500 12.5px var(--brand-body);cursor:pointer;padding:4px 2px}+.say-fold:hover{color:var(--accent)}+.led-detail{flex-basis:100%;color:var(--text-2);font:12.5px/1.5 var(--brand-body);padding:2px 6px 6px;max-width:70ch}+.led-detail .btn{margin-left:8px}+.target-chip{display:none;align-items:center;gap:6px;font:11px var(--mono);color:var(--text-2);border:1px solid var(--border);border-radius:999px;padding:3px 9px;flex-shrink:0;white-space:nowrap}+.target-chip.show{display:inline-flex}+.target-chip .tdot{width:7px;height:7px;border-radius:50%;background:var(--green)}+.target-chip.down .tdot{background:var(--text-3)} .say-try:hover{background:var(--overlay);color:var(--accent)} .say-phrase:focus-visible,.say-try:focus-visible{outline-offset:-2px} @@ -141,15 +155,15 @@ header{flex:0 0 44px;height:44px;display:flex;align-items:center;gap:12px;paddin .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-hud{position:fixed;bottom:70px;left:50%;transform:translateX(-50%);z-index:var(--z-hud,300);max-width:520px;background:var(--overlay);border:1px solid var(--border);border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.45);padding:8px 12px;display:flex;flex-direction:column;gap:6px}+.tour-grip{display:flex;align-items:center;justify-content:space-between;font:600 12px var(--brand-heading);gap:12px} .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-text{font:12px/1.45 var(--brand-body);color:var(--text-2);max-width:60ch;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical} .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}}+@media (max-width:720px){.tour-hud{left:12px;right:12px;max-width:none;transform:none;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}@@ -537,6 +551,7 @@ dialog.settings{width:min(680px,92vw);overflow:hidden} <div class="subject"> <span class="subject-name idle" id="subjectName">No chip selected</span> <span class="state-pill" id="subjectPill"></span>+ <span class="target-chip" id="targetChip" data-tooltip="Which desktop box the Adom Bridge answers on; pup and the taskbar bar run there."><span class="tdot"></span><span id="targetName"></span></span> <span class="subject-meta" id="subjectMeta"> <span class="sep">·</span><span id="metaCards" data-tooltip="Each tile below is a card: one part's full CAD bundle. Tell your AI, for example, open the B2B-PH-K-S card.">0 cards</span> <span class="sep">·</span><span id="metaFull" data-tooltip="Cards that have every core file: datasheet, STEP 3D, KiCad symbol and footprint, plus the Altium and Fusion exports.">0 full</span>@@ -550,7 +565,7 @@ dialog.settings{width:min(680px,92vw);overflow:hidden} <button class="iconbtn" id="btn-arch" aria-label="Architecture map" data-tooltip="Architecture map: every tool chip fetcher depends on. Local CLIs that run in your container, native Rust libraries, remote service containers called over HTTPS, and the sub-apps you may also need to modify. Open this before editing chip fetcher."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 3L3 6v15l6-3 6 3 6-3V3l-6 3-6-3z"/><path d="M9 3v15M15 6v15"/></svg></button> <button class="iconbtn" id="btn-reveal-lib" aria-label="Library folder" data-tooltip="Reveal the chip fetcher library folder in the VS Code Explorer sidebar. Opens ~/project/chip-fetcher/library/ so you can see every chip directory."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7.5a2 2 0 0 1 2-2h4l2 2.5h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7.5z"/></svg></button> <button class="iconbtn" id="btn-help" aria-pressed="false" aria-label="Getting started" data-tooltip="Show or hide the newbie panel: the say-it phrases, the component lights, and getting started with its demo fetch."><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 1 1 3.4 2.33c-.7.28-1.4.84-1.4 1.67v.5"/><path d="M12 17h.01"/></svg></button>- <button class="iconbtn" id="btn-settings" aria-label="Settings" data-tooltip="Settings: components and their install buttons, the taskbar progress mode, dashboard behaviour, and vendor logins."><!-- the shared Adom cog (assets/icons/settings-cog.svg), inlined so it follows currentColor --><svg viewBox="0 0 24 24" 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="btn-settings" aria-label="Settings" data-tooltip="Settings: components and their install buttons, the taskbar progress mode, dashboard behaviour, and vendor logins."><!-- the shared Adom cog (assets/icons/settings-cog.svg), inlined so it follows currentColor --><svg viewBox="0 0 24 24" 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.46c-.13-.22-.07-.49.12-.64l2.11-1.66c-.04-.32-.07-.64-.07-.97s.03-.66.07-1L2.46 9.37c-.19-.15-.25-.42-.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.12.22.07.49-.12.64l-2.11 1.63c.04.34.07.67.07 1s-.03.65-.07.97l2.11 1.66c.19.15.24.42.12.64l-2 3.46c-.12.22-.39.3-.61.22l-2.49-1.01c-.52.4-1.06.74-1.69.99l-.37 2.65c-.04.24-.25.42-.5.42h-4Z"/></svg></button> </div> <div class="hover-popover" id="app-info" role="dialog" aria-label="App information"> <div class="popover-title">Chip Fetcher</div>@@ -584,10 +599,11 @@ dialog.settings{width:min(680px,92vw);overflow:hidden} <button class="iconbtn newbie-close" id="btn-newbie-hide" aria-label="Close the newbie panel" data-tooltip="Tuck the whole panel away (phrases, component lights, getting started). The ? in the header brings it back."><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> <section class="say" id="say" aria-labelledby="sayTitle"> <div class="say-head">- <h2 id="sayTitle">Say it to your AI</h2>- <span class="say-sub">You do not drive this dashboard by hand. You ask your AI, using the words <b>chip fetcher</b>, or just <b>cf</b> for short. Click a phrase to copy it, then paste it to your AI.</span>+ <h2 id="sayTitle">Say it</h2>+ <span class="say-sub">You never need this page's buttons: just tell your AI what you want, using the word <b>cf</b> (chip fetcher). Copy a phrase, or send it straight to a new AI thread.</span> </div> <div class="say-list" id="sayList"></div>+ <button class="say-fold" id="sayFold" hidden>Say it: 6 phrases for your AI. Tap to expand.</button> </section> <div class="leds" id="leds" aria-label="Component status"></div> <section class="welcome" id="welcome">@@ -595,7 +611,7 @@ dialog.settings{width:min(680px,92vw);overflow:hidden} <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><button class="btn btn-primary" id="btn-tour">Play the 1-minute demo</button></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>@@ -619,8 +635,8 @@ dialog.settings{width:min(680px,92vw);overflow:hidden} <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="tourMute" aria-pressed="false" aria-label="Mute narration" data-tooltip="Mute or unmute the narration. Audio is on by default."><svg id="tourMuteIco" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5L6 9H3v6h3l5 4V5z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8 8 0 0 1 0 12"/></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">@@ -1102,32 +1118,31 @@ async function buildSay() { 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-tooltip', 'Copy this phrase to your clipboard, then paste it to your AI.');+ b.setAttribute('data-tooltip', 'Copy this phrase to your clipboard.'); b.innerHTML = '“' + escapeHtml(p).replace(/chip ?fetcher|\bcf\b/gi, m => '<em>' + m + '</em>') + '”';- b.onclick = async () => { if (await copyText(p)) cfToast('Copied: ' + p, { type: 'ok' }); else cfToast('Could not copy; select the phrase and copy it by hand.', { type: 'err' }); };- const t = document.createElement('button'); t.className = 'say-try'; t.type = 'button'; t.textContent = 'Try it';- if (TRY_IT_PARKED) {- // John's ruling (2026-08-23): no live AI-thread staging for now, but the- // button stays, copies the prompt, and SAYS SO in a toast. The staging- // branch below stays dormant for the all-clear.- t.setAttribute('data-tooltip', 'Copies this phrase to your clipboard; paste it into any AI thread.');- t.onclick = async () => {- if (await copyText(p)) cfToast('Copied to clipboard. Paste it into any AI thread.', { type: 'ok' });- else cfToast('Could not reach the clipboard. Select the phrase and copy it by hand.', { type: 'err' });- };- item.appendChild(b); item.appendChild(t); list.appendChild(item); continue;- }- t.setAttribute('data-tooltip', "Stage this phrase in this app's AI thread. It is not sent; you press Enter there.");+ b.onclick = async () => {+ if (await copyText(p)) cfToast('Copied to clipboard. Paste it into any AI thread.', { type: 'ok' });+ else cfToast('Could not reach the clipboard. Select the phrase and copy it by hand.', { type: 'err' });+ };+ const c = document.createElement('button'); c.className = 'say-try'; c.type = 'button'; c.textContent = 'Copy';+ c.setAttribute('data-tooltip', 'Copy this phrase to your clipboard.');+ c.onclick = b.onclick;+ const t = document.createElement('button'); t.className = 'say-send'; t.type = 'button'; t.textContent = 'Send to AI';+ t.setAttribute('data-tooltip', 'Open a NEW AI thread and send this phrase to it. Existing threads are never touched.'); t.onclick = async () => { t.disabled = true; try {- const d = await (await postJson('/api/say/try', { phrase: p })).json();- if (d.staged) cfToast('Staged in your AI thread' + (d.where ? ' (' + d.where + ')' : '') + ': ' + p + '. Press Enter there to send.', { type: 'ok' });- else { await copyText(p); cfToast('Copied instead: ' + (d.reason || 'could not reach your AI thread') + '. Paste it into your AI.'); }- } catch (e) { cfToast('Try failed: ' + e.message, { type: 'err' }); }- finally { t.disabled = false; }+ const r = await (await postJson('/api/say-send', { phrase: p })).json();+ if (r.ok) { cfToast('Sent to a new AI thread.', { type: 'ok' }); gradSave({ aiDriven: true }); applyGraduation(); }+ else {+ if (await copyText(p)) cfToast((r.why || 'Could not reach the AI panel') + '; copied instead.', { type: 'err' });+ else cfToast(r.why || 'Could not reach the AI panel.', { type: 'err' });+ }+ } catch (e) {+ if (await copyText(p)) cfToast('Could not reach the AI panel; copied instead.', { type: 'err' });+ } finally { t.disabled = false; } };- item.appendChild(b); item.appendChild(t); list.appendChild(item);+ item.appendChild(b); item.appendChild(c); item.appendChild(t); list.appendChild(item); } } @@ -1142,7 +1157,16 @@ function renderLeds() { const el = document.createElement('div'); el.className = 'ledchip'; el.setAttribute('data-tooltip', (l._hint || '') + (l.state !== 'ok' ? '\nOpen Settings to see the Install button for this component.' : '')); el.innerHTML = `<span class="led ${escapeHtml(l.state)}"></span><span class="lab">${escapeHtml(l.label || k)}</span><span class="det">${escapeHtml(l.detail || '')}</span>`;- el.onclick = () => openSettings('card-components');+ el.onclick = () => {+ const open = el.classList.toggle('expanded');+ let d = el.nextElementSibling;+ if (open) {+ d = document.createElement('div'); d.className = 'led-detail';+ d.innerHTML = escapeHtml((l.detail || '') + '. ' + (l._hint || '')) + (l.state !== 'ok' ? ' <button class="btn" data-goto>Open Settings to fix it</button>' : '');+ el.after(d);+ const g = d.querySelector('[data-goto]'); if (g) g.onclick = (e) => { e.stopPropagation(); openSettings('card-components'); };+ } else if (d && d.classList.contains('led-detail')) d.remove();+ }; el.style.cursor = 'pointer'; host.appendChild(el); }@@ -1764,14 +1788,26 @@ 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; }+function demoMuted() { try { return localStorage.getItem('cf-demo-muted') === '1'; } catch (e) { return false; } }+function setDemoMuted(m) {+ try { localStorage.setItem('cf-demo-muted', m ? '1' : '0'); } catch (e) {}+ $id('tourMute').setAttribute('aria-pressed', String(m));+ $id('tourMute').classList.toggle('is-on', m);+ $id('tourMuteIco').innerHTML = m+ ? '<path d="M11 5L6 9H3v6h3l5 4V5z"/><path d="M22 9l-6 6M16 9l6 6"/>'+ : '<path d="M11 5L6 9H3v6h3l5 4V5z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18 6a8 8 0 0 1 0 12"/>';+ if (m) postJson('/api/demo/quiet', {}).catch(() => {});+}+function voiceOn() { return !demoMuted(); } 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(() => {});+ postJson('/api/demo/quiet', {}).then(() => {+ postJson('/api/demo/caption', { text: st.text }).catch(() => {});+ if (voiceOn()) postJson('/api/demo/say', { text: st.text, step: tourIdx }).catch(() => {});+ }).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);@@ -1786,7 +1822,7 @@ function tourSetPaused(p) { 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);+ setDemoMuted(demoMuted()); $id('tourHud').hidden = false; tourSetPausedIcon(); tourPaused = false;@@ -1806,27 +1842,8 @@ $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) {}-};+$id('tourMute').onclick = () => setDemoMuted(!demoMuted()); 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@@ -1902,26 +1919,53 @@ 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));-}-$id('btn-newbie-hide').onclick = async () => {- SETTINGS.show_welcome = false; applyNewbiePanel();- 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' });+// ---- Newbie graduation (newbie-graduation skill; John's one-X ruling kept:+// the panel's single X dismisses everything, and the say strip survives as+// the canonical one-line thread back). localStorage, per-origin, try/catch.+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(Object.assign(gradState(), patch))); } catch (e) {} }+let NEWBIE_SESSION_FRESH = false; // the ? restores Fresh for this session+function graduatedStart() { const g = gradState(); return !!(g.dismissed && g.dismissed.start) || (g.launches || 0) >= 3 || !!g.aiDriven; }+function graduatedSay() { const g = gradState(); return !!(g.dismissed && g.dismissed.say) || (g.launches || 0) >= 5 || !!g.aiDriven; }+function applyGraduation() {+ const fresh = NEWBIE_SESSION_FRESH;+ $id('welcome').hidden = !fresh && graduatedStart();+ const sayFolded = !fresh && graduatedSay();+ $id('say').classList.toggle('say-folded', sayFolded);+ $id('sayFold').hidden = !sayFolded;+ $id('newbie').hidden = false;+ $id('btn-help').setAttribute('aria-pressed', String(fresh));+}+function applyNewbiePanel() { applyGraduation(); }+$id('sayFold').onclick = () => { NEWBIE_SESSION_FRESH = true; applyGraduation(); };+// Count a successful launch: page loaded AND the status row green within the first minute.+(function () {+ let counted = false;+ const t0 = Date.now();+ const iv = setInterval(() => {+ if (counted || Date.now() - t0 > 60000) { clearInterval(iv); return; }+ if (STATUS && (STATUS.state === 'ok' || STATUS.state === 'warn')) {+ counted = true; clearInterval(iv);+ gradSave({ launches: (gradState().launches || 0) + 1 });+ }+ }, 3000);+})();+$id('btn-newbie-hide').onclick = () => {+ NEWBIE_SESSION_FRESH = false;+ gradSave({ dismissed: { say: true, start: true } });+ applyGraduation();+ cfToast('Tucked away. The ? in the header brings it back any time.', { type: 'ok' }); }; $id('btn-welcome-example').onclick = async () => { const p = 'get me all the files for the STM32H757XIH6 with cf'; if (await copyText(p)) cfToast('Copied: ' + p + '. Paste it to your AI.', { type: 'ok' }); else cfToast('Could not copy; type it into your AI instead.', { type: 'err' }); };-$id('btn-help').onclick = async () => {- const show = SETTINGS.show_welcome === false;- SETTINGS.show_welcome = show; applyNewbiePanel();- try { await postJson('/api/settings', { show_welcome: show }); } catch (e) {}- if (show) $id('newbie').scrollIntoView({ block: 'start' });+$id('btn-help').onclick = () => {+ NEWBIE_SESSION_FRESH = !NEWBIE_SESSION_FRESH;+ applyGraduation();+ if (NEWBIE_SESSION_FRESH) $id('newbie').scrollIntoView({ block: 'start' }); }; $id('btnCloseSettings').onclick = () => dlg.close(); dlg.addEventListener('close', reportUi);