← 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)

John Lauer ·c2c76315bd ·1mo ago ·parent 4feb897
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">&middot;</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">&middot;</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 = '&ldquo;' + escapeHtml(p).replace(/chip ?fetcher|\bcf\b/gi, m => '<em>' + m + '</em>') + '&rdquo;';-    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);