← Commit history

1.3.9: focus-aware clipboard routing on every copy path (hasFocus check, 800 ms race, in-gesture execCommand fallback); scrollbar block verified against the codified skill

John Lauer ·645f24c377 ·1mo ago ·parent 8d553bb
3 files changed +30−16
Cargo.lock+1−1
@@ -4,7 +4,7 @@ version = 4  [[package]] name = "adom-chip-fetcher"-version = "1.3.8"+version = "1.3.9" dependencies = [  "anyhow",  "clap",
Cargo.toml+1−1
@@ -1,6 +1,6 @@ [package] name = "adom-chip-fetcher"-version = "1.3.8"+version = "1.3.9" 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/ui/index.html+28−14
@@ -704,16 +704,30 @@ function escapeHtml(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, m // Relative paths so the page works behind /proxy/<port>/ and inside a webview. const API_BASE = location.pathname.replace(/index\.html$/, '').replace(/\/$/, ''); const TRY_IT_PARKED = true; // ship-night posture: no /ai-threads calls; Try it copies instead (John's ruling, 2026-08-23)+function copyViaTextarea(t) {+  // Must run synchronously inside the click gesture: execCommand('copy')+  // only works during a user gesture.+  try {+    const ta = document.createElement('textarea');+    ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0';+    document.body.appendChild(ta); ta.select();+    const ok = document.execCommand('copy'); ta.remove(); return ok;+  } catch (e) { return false; }+} async function copyText(t) {-  try { await navigator.clipboard.writeText(t); return true; }-  catch (e) {-    try {-      const ta = document.createElement('textarea');-      ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0';-      document.body.appendChild(ta); ta.select();-      const ok = document.execCommand('copy'); ta.remove(); return ok;-    } catch (e2) { return false; }-  }+  // Unfocused windows (a background pup tab, a webview panel) make+  // navigator.clipboard.writeText HANG with "document is not focused"+  // (parts-search 1.1.2 finding). Route synchronously by focus, and race+  // the async path at 800 ms so a hung promise never eats the gesture.+  if (!document.hasFocus()) return copyViaTextarea(t);+  try {+    const ok = await Promise.race([+      navigator.clipboard.writeText(t).then(() => true),+      new Promise(res => setTimeout(() => res('timeout'), 800)),+    ]);+    if (ok === true) return true;+  } catch (e) {}+  return copyViaTextarea(t); } const DASH = '\u2014'; // the data sentinel inventory.rs emits for "unknown"; never shown to a person const isPhone = () => matchMedia('(hover: none)').matches || window.innerWidth < 720;@@ -1051,7 +1065,7 @@ async function buildSay() {     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.innerHTML = '&ldquo;' + escapeHtml(p).replace(/chip ?fetcher|\bcf\b/gi, m => '<em>' + m + '</em>') + '&rdquo;';-    b.onclick = async () => { try { await navigator.clipboard.writeText(p); cfToast('Copied: ' + p, { type: 'ok' }); } catch (e) { cfToast('Could not copy: ' + e.message, { type: 'err' }); } };+    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@@ -1070,7 +1084,7 @@ async function buildSay() {       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 { try { await navigator.clipboard.writeText(p); } catch (e) {} cfToast('Copied instead: ' + (d.reason || 'could not reach your AI thread') + '. Paste it into your AI.'); }+        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; }     };@@ -1255,7 +1269,7 @@ function render() {       const phrase = 'fetch ' + q.toUpperCase() + ' with cf';       cards.innerHTML = '<div class="empty-state">Nothing in your library matches "' + escapeHtml(q) + '".<br>Not fetched yet? Ask your AI: <button class="btn" id="btn-fetch-q">Copy: "' + escapeHtml(phrase) + '"</button></div>';       const b = $id('btn-fetch-q');-      if (b) b.onclick = async () => { try { await navigator.clipboard.writeText(phrase); cfToast('Copied: ' + phrase + '. Paste it to your AI.', { type: 'ok' }); } catch (e) {} };+      if (b) b.onclick = async () => { if (await copyText(phrase)) cfToast('Copied: ' + phrase + '. Paste it to your AI.', { type: 'ok' }); };     } else {       cards.innerHTML = `<div class="empty-state">${LIB.chips.length ? 'No chips match these filters.' : 'No parts in the library yet.'}</div>`;     }@@ -1769,8 +1783,8 @@ $id('btn-welcome-hide').onclick = async () => { }; $id('btn-welcome-example').onclick = async () => {   const p = 'fetch STM32H743VIT6 with cf';-  try { await navigator.clipboard.writeText(p); cfToast('Copied: ' + p + '. Paste it to your AI.', { type: 'ok' }); }-  catch (e) { cfToast('Could not copy: ' + e.message, { type: 'err' }); }+  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 () => {   WELCOME_PINNED = !WELCOME_PINNED;