← Commit history

Publish 0.12.0

John Lauer ·36dff4be1a ·2mo ago ·parent 6baaf85
3 files changed +243−89
bin/nb-dashboard+167−82
@@ -20,13 +20,29 @@ const { execFile, spawn } = require('child_process'); const fs = require('fs'); const crypto = require('crypto'); -const VERSION = '0.11.2';           // keep in lockstep with package.json (pkg version)-// EMERGENCY PARK (dock thread, 2026-08-24): the Send-to-AI SUCCESS path crashed the ah dev shell on-// ConfRoomROG during kicad's cold-verify - run-prompt is empirically NOT crash-safe (the gate fix does-// not cover it). While parked, /api/say-send answers {ok:false, parked:true} WITHOUT touching the-// control API at all; the page's failure path (error toast + clipboard fallback) carries users, and the-// two-button UI stays. The main ah build thread owns certification; flip to false on their broadcast.-const SAYSEND_PARKED = true;+const VERSION = '0.12.0';           // keep in lockstep with package.json (pkg version)+// SEND TO AI IS UN-PARKED (John: "yeah, we are re-enabling the send to ai. it seems to be good now").+// The ah build thread certified the crash family dead on the gate-fixed build and the delivery leg is+// proven end to end. The park const is DELETED rather than flipped, so no dead branch can resurrect it.+// SHELL-VERSION GATE: published Hydrogen 1.0.141 PREDATES the fix, so a stock 1.0.141 shell still+// carries the defect. Gate on GET /updater/status -> updater.current_version >= 1.0.142, compared+// NUMERICALLY (a lexical compare passes "1.0.99", which is OLDER and still crashes). Deliberately NOT+// /buildinfo: its build_sha lies on incremental builds, and build_epoch would pin the feature to one+// machine's build clock and refuse every user on a stock signed release forever. If /updater/status is+// unreachable we FAIL OPEN and let the call itself be the capability probe (falling back to copy on+// failure) - refusing everyone because a status endpoint hiccuped is the worse error.+const MIN_SHELL = [1, 0, 142];+function cmpVer(a, b) {+  const pa = String(a || '').split('.').map((n) => parseInt(n, 10) || 0);+  for (let i = 0; i < b.length; i++) { const x = pa[i] || 0; if (x !== b[i]) return x - b[i]; }+  return 0;+}+async function shellVersionOk() {+  const r = await httpJson('GET', controlUrl() + '/updater/status');+  const v = r.json && ((r.json.updater && r.json.updater.current_version) || r.json.current_version);+  if (!r.status || !v) return { ok: true, unknown: true };        // fail OPEN: the call is the probe+  return { ok: cmpVer(v, MIN_SHELL) >= 0, version: v };+} const path = require('path'); // TEACH THE NOUN (John, 2026-08-23): "I say 'open digikey in nb' cuz for that I might know I need nb // since it has my login. Or 'open mouser in nb chrome personal'..." The phrases users should SAY,@@ -72,6 +88,8 @@ function httpJson(method, url, body) { } async function saySend(phrase) {   const base = controlUrl();+  const gate = await shellVersionOk();+  if (!gate.ok) return { ok: false, why: `your Hydrogen is ${gate.version} and Send to AI needs 1.0.142 or newer (1.0.141 carries a crash this would trigger) - update Hydrogen to enable it; the phrase is on your clipboard` };   try {     const mk = await httpJson('POST', base + '/ai-threads/new', { name: 'Native Browser - say it', tags: ['dock:nb-bridge'] });     const sid = mk.json && (mk.json.sid || mk.json.id);@@ -265,24 +283,29 @@ function page() {   ::-webkit-scrollbar-corner { background: transparent; }   html, body { overflow-x: hidden; }   /* wide content scrolls in its own container, never the page */   body { background:var(--bg); color:var(--ink); font:15px/1.5 system-ui,-apple-system,'Segoe UI',sans-serif; min-height:100vh; }-  header { display:flex; align-items:center; gap:12px; padding:0 20px; height:44px; border-bottom:1px solid var(--line); background:#10151c; }-  .brand { display:flex; align-items:center; gap:9px; position:relative; outline:none; }-  header h1 { font:600 15px var(--sans); }-  .subtitle { color:var(--muted); font-size:13px; }-  .brand-pop { display:none; position:absolute; top:38px; left:0; z-index:40; background:var(--surface2); border:1px solid var(--line); border-radius:10px; padding:10px 14px; min-width:230px; font-size:12.5px; box-shadow:0 12px 32px rgba(0,0,0,.45); }-  .brand:hover .brand-pop, .brand:focus .brand-pop { display:block; transition-delay:.6s; }-  .brand:hover .brand-pop { animation:popin .01s .6s both; } @keyframes popin { from { opacity:0; } to { opacity:1; } }-  .pop-row { display:flex; justify-content:space-between; gap:18px; color:var(--muted); padding:2px 0; } .pop-row span:last-child { color:var(--text); font-family:var(--mono); font-size:11.5px; }+  /* dash-header 1.22.1: 44px, EDGE TO EDGE, padding inside the bar, no version anywhere */+  header { display:flex; align-items:center; gap:10px; height:44px; padding:0 16px; margin:0; border-radius:0;+    border-bottom:1px solid var(--line); background:var(--surface); }+  .brand { display:flex; align-items:center; gap:8px; position:relative; outline:none; }+  header h1 { font:600 15px var(--sans); color:var(--text); }+  .subtitle { font-size:13px; color:var(--muted); margin-left:0; }+  .brand-pop { display:none; position:absolute; top:34px; left:0; z-index:40; background:var(--surface2);+    border:1px solid var(--line); border-radius:10px; padding:10px 14px; min-width:230px; font-size:12.5px;+    box-shadow:0 12px 32px rgba(0,0,0,.45); }+  .brand:hover .brand-pop, .brand:focus .brand-pop { display:block; }+  .pop-row { display:flex; justify-content:space-between; gap:18px; color:var(--muted); padding:2px 0; }+  .pop-row span:last-child { color:var(--text); font-family:var(--mono); font-size:11.5px; }   .brand-pop a { color:var(--teal); text-decoration:none; font-size:12px; display:block; margin-top:6px; }   .chips { display:flex; gap:8px; margin-left:auto; }-  .tchip { display:inline-flex; align-items:center; gap:7px; font:11.5px var(--mono); color:var(--muted); background:var(--surface); border:1px solid var(--line); border-radius:999px; padding:3px 11px; }+  .tchip { display:inline-flex; align-items:center; gap:7px; font:11.5px var(--mono); color:var(--muted);+    background:var(--surface2); border:1px solid var(--line); border-radius:999px; padding:3px 11px; }   .tchip .dot { width:7px; height:7px; border-radius:50%; background:var(--led-green); }   .led { width:10px; height:10px; border-radius:50%; display:inline-block; flex:none; }   .led.ok { background:var(--teal); box-shadow:0 0 8px rgba(0,206,194,.7); }   .led.warn { background:var(--amber); box-shadow:0 0 8px rgba(243,178,62,.6); }   .led.fail { background:var(--red); box-shadow:0 0 8px rgba(239,91,91,.6); }   .led.unknown { background:#3a4a5a; }-  main { max-width:980px; margin:0 auto; padding:24px 20px 40px; }+  main { padding:20px 20px 24px; }   /* the CONTENT REGION owns the padding, not the panel: the gap under the header is identical panel-open and panel-closed */   .cards { display:grid; grid-template-columns:1fr 1fr; gap:16px; }   @media (max-width:760px) { .cards { grid-template-columns:1fr; } }   .card { background:var(--card); border:1px solid var(--edge); border-radius:12px; padding:18px 18px 16px; }@@ -301,23 +324,46 @@ function page() {   .mini { background:transparent; border:1px solid var(--teal); color:var(--teal); border-radius:6px; padding:2px 10px; font-size:12px; cursor:pointer; }   .mini:hover { background:rgba(0,206,194,.12); }   tr.off td { color:var(--dim); opacity:.75; }-  .say { background:var(--surface); border:1px solid var(--line); border-left:3px solid var(--teal); border-radius:10px; padding:14px 16px; margin-bottom:14px; position:relative; }-  .say h2 { font:600 15px var(--sans); margin:0; }-  .say-head p { color:var(--muted); font-size:13.5px; margin:4px 0 0; max-width:70ch; } .say-head p b { color:var(--teal); }-  .say-x { position:absolute; top:8px; right:10px; background:none; border:0; color:var(--muted); font-size:17px; cursor:pointer; border-radius:6px; width:26px; height:26px; } .say-x:hover { color:var(--text); background:rgba(255,255,255,.06); }-  .say-chips { margin-top:10px; }-  .say-chip { display:inline-flex; align-items:center; gap:8px; background:var(--surface2); border:1px solid var(--line); border-radius:999px; padding:5px 6px 5px 12px; font:13.5px var(--body); margin:4px 6px 0 0; cursor:pointer; }-  .say-chip em { color:var(--teal); font-style:normal; }-  .say-chip .copy, .say-chip .send { font:500 11px var(--body); background:none; border:1px solid var(--line); border-radius:999px; padding:2px 9px; cursor:pointer; }-  .say-chip .copy { color:var(--muted); } .say-chip .send { color:var(--teal); border-color:rgba(0,184,177,.45); }-  .say-chip:hover .copy { color:var(--teal); border-color:var(--teal); } .say-chip .send:hover { background:rgba(0,184,177,.12); }-  .say-demo-row { display:flex; align-items:center; gap:12px; margin-top:12px; }-  .say-folded { display:flex; align-items:center; gap:10px; color:var(--muted); font-size:13px; background:var(--surface); border:1px solid var(--line); border-radius:8px; padding:7px 12px; margin-bottom:14px; }-  .say-note { font-size:12px; color:var(--muted); }+  /* newbie-panel 1.22.1: ONE full-width band, two columns, prompts as a TABLE (never pills) */+  .panel { background:var(--surface); border:1px solid var(--line); border-left:3px solid var(--teal);+    border-radius:12px; padding:18px 20px 20px; margin:0 0 20px; position:relative; }+  .panel-head { display:flex; align-items:flex-start; }+  .panel h2 { font:600 15px var(--sans); color:var(--text); }+  #panel-x { position:absolute; top:12px; right:12px; }+  .panel-explain { font-size:13.5px; color:var(--muted); margin:4px 0 14px; max-width:70ch; }+  .panel-explain .noun { color:var(--teal); font-weight:700; }+  .panel-cols { display:grid; grid-template-columns:220px 1fr; gap:24px; align-items:start; }+  .panel-right { border-left:1px solid var(--line); padding-left:24px; }+  @media (max-width:760px) { .panel-cols { grid-template-columns:1fr; }+    .panel-right { border-left:0; border-top:1px solid var(--line); padding-left:0; padding-top:16px; } }+  .demo-btn { display:inline-flex; align-items:center; gap:8px; background:var(--teal); color:#05221f;+    border:0; border-radius:8px; padding:10px 18px; font:600 14px var(--sans); cursor:pointer; }+  .demo-btn:hover { filter:brightness(1.07); } .demo-btn:active { transform:translateY(1px); }+  .demo-btn[disabled] { opacity:.6; cursor:default; filter:none; }+  .demo-btn:focus-visible { outline:2px solid var(--teal); outline-offset:2px; }+  .demo-sub { font-size:12px; color:var(--muted); margin-top:8px; }+  /* prompts TABLE: 40px rows, action cell tracks the PHRASE (left-packed), never the window edge */+  .prompts { display:block; }+  .prow { display:grid; grid-template-columns:minmax(0,max-content) 72px; align-items:center;+    height:40px; border-bottom:1px solid var(--line); cursor:pointer; }+  .prow:last-child { border-bottom:0; }+  .prow:hover { background:rgba(255,255,255,.03); }+  .pphrase { font:14px var(--body); color:var(--text); white-space:nowrap; overflow:hidden;+    text-overflow:ellipsis; padding-right:16px; }+  .pphrase em { color:var(--teal); font-style:normal; }+  .pacts { display:flex; gap:6px; }+  .iconbtn { width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center;+    background:none; border:1px solid transparent; border-radius:6px; color:var(--muted); cursor:pointer; padding:0; }+  .iconbtn:hover { color:var(--teal); border-color:var(--line); background:rgba(0,184,177,.08); }+  .iconbtn:focus-visible { outline:2px solid var(--teal); outline-offset:2px; }+  .tip { position:fixed; z-index:60; background:var(--surface2); border:1px solid var(--line);+    border-radius:6px; padding:5px 9px; font-size:12px; color:var(--text); pointer-events:none;+    box-shadow:0 6px 18px rgba(0,0,0,.4); }    .icon-btn { background:transparent; border:0; color:var(--dim); width:32px; height:32px; border-radius:8px; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; transition:background .12s, color .12s, transform .06s; }   .icon-btn:hover { background:rgba(255,255,255,.07); color:var(--ink); } .icon-btn:active { transform:translateY(1px) scale(.96); } .icon-btn:focus-visible { outline:2px solid var(--teal); outline-offset:2px; }-  header #cog { margin-left:10px; }+  .icon-btn[aria-pressed="true"] { color:var(--teal); background:rgba(0,184,177,.10); }+  .icon-btn.sm { width:28px; height:28px; }   dialog.settings:focus, dialog.settings:focus-visible { outline:none; }  /* we focus the dialog on open (open-at-top rule); the default focus ring painted a white border around the whole box */   dialog.settings { margin:auto; border:1px solid #30363d; border-radius:12px; padding:0; background:#161b22; color:#c9d1d9; width:min(880px, 94vw); overflow:hidden; }   dialog.settings::backdrop { background:rgba(0,0,0,.25); }@@ -386,8 +432,8 @@ function page() { </style></head><body> <header>   <div class="brand" id="brand" tabindex="0">-    <img src="icon.svg" width="20" height="20" alt="">-    <h1>Native Browser</h1><span class="subtitle">&middot; AI browser control</span>+    <img src="icon.svg" width="18" height="18" alt="">+    <h1>Native Browser</h1><span class="subtitle">AI browser control</span>     <div class="brand-pop" id="brand-pop" role="tooltip">       <div class="pop-row"><span>Dashboard</span><span>v${VERSION}</span></div>       <div id="pop-boxes"></div>@@ -395,34 +441,45 @@ function page() {     </div>   </div>   <div class="chips" id="target-chips"></div>-  <button class="icon-btn" id="help" aria-label="Show the getting-started help" title="Show help" onclick="recallScaffolding()">?</button>-  <button class="icon-btn" id="cog" aria-label="Settings" title="Settings" onclick="openSettings()"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true"><circle cx="12" cy="12" r="3.2"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33h.01a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v.01a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>+  <button class="icon-btn" id="help" aria-pressed="true" aria-label="Hide the prompts panel" data-tooltip="Hide the prompts panel" onclick="togglePanel()"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 0 1 4.8.9c0 1.7-2.4 2.2-2.4 3.9"/><path d="M12 17.6h.01"/></svg></button>+  <button class="icon-btn" id="cog" aria-label="Settings" data-tooltip="Settings" onclick="openSettings()"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button> </header> <main>-  <section class="say" id="say">-    <div class="say-head"><h2>Say it</h2>-      <p>You never need this page's buttons: just tell your AI what you want, using the word <b class="noun">nb</b>. Copy a phrase, or send it straight to a new AI thread.</p>-      <button class="say-x" aria-label="Collapse" onclick="dismissSay()">&times;</button></div>-    <div class="say-chips" id="say-chips"></div>-    <div class="say-demo-row" id="demo-entry"><button class="btn" onclick="demoStart()">Play the 1-minute demo</button><span class="say-note" id="say-note"></span></div>-  </section>-  <div class="say-folded" id="say-folded" hidden><span>Say it &middot; <span id="fold-count"></span> phrases</span><button class="mini" onclick="unfoldSay()">Show</button></div>-  <div class="leds" id="leds"></div>-  <div class="led-pop" id="led-pop" hidden></div>-  <div class="cards">-    <div class="card" id="card-l1">-      <div class="layer">Layer 1 &middot; works out of the box</div>-      <h2><span class="led unknown" id="led-l1"></span>Native Browser</h2>-      <p>Your AI opens pages in <strong>your real, signed-in browser</strong> - nothing to install. Real Chrome and Edge windows, your profiles, your logins.</p>-      <div class="gain">open a URL in the right profile (work vs personal)</div>-      <div class="gain">background by default: your focus is never stolen</div>-      <div class="gain">perfect for OAuth consent: you just click Approve</div>-      <p class="hintline" id="l1-detail"></p>+  <!-- newbie panel: a SIBLING of the app body, never inside its grid (newbie-panel 1.22.1) -->+  <section class="panel" id="panel">+    <div class="panel-head">+      <h2>Say it</h2>+      <button class="icon-btn sm" id="panel-x" aria-label="Close the prompts panel" data-tooltip="Close the prompts panel" onclick="closePanel()">&times;</button>+    </div>+    <p class="panel-explain">You never need this page's buttons: just tell your AI what you want, using the word <b class="noun">nb</b>. Copy a phrase, or send it straight to a new AI thread.</p>+    <div class="panel-cols">+      <div class="panel-left">+        <button class="demo-btn" id="demo-btn" onclick="demoStart()"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 4.8v14.4a.6.6 0 0 0 .92.5l11.3-7.2a.6.6 0 0 0 0-1l-11.3-7.2a.6.6 0 0 0-.92.5z"/></svg><span>Play the 1-minute demo</span></button>+        <div class="demo-sub">5 steps, about a minute</div>+      </div>+      <div class="panel-right">+        <div class="prompts" id="prompts" role="table" aria-label="Sample AI prompts"></div>+      </div>     </div>-    <div class="card upsell" id="card-l2">-      <div class="layer">Layer 2 &middot; the good stuff</div>-      <h2><span class="led unknown" id="led-l2"></span>nbe &mdash; the Native Browser Extension</h2>-      <div id="l2-body"></div>+  </section>+  <div class="appbody" id="appbody">+    <div class="leds" id="leds"></div>+    <div class="led-pop" id="led-pop" hidden></div>+    <div class="cards">+      <div class="card" id="card-l1">+        <div class="layer">Layer 1 &middot; works out of the box</div>+        <h2><span class="led unknown" id="led-l1"></span>Native Browser</h2>+        <p>Your AI opens pages in <strong>your real, signed-in browser</strong> - nothing to install. Real Chrome and Edge windows, your profiles, your logins.</p>+        <div class="gain">open a URL in the right profile (work vs personal)</div>+        <div class="gain">background by default: your focus is never stolen</div>+        <div class="gain">perfect for OAuth consent: you just click Approve</div>+        <p class="hintline" id="l1-detail"></p>+      </div>+      <div class="card upsell" id="card-l2">+        <div class="layer">Layer 2 &middot; the good stuff</div>+        <h2><span class="led unknown" id="led-l2"></span>nbe &mdash; the Native Browser Extension</h2>+        <div id="l2-body"></div>+      </div>     </div>   </div> </main>@@ -482,6 +539,8 @@ function page() {   </div> </dialog> <script>+const ICON_COPY = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="12" height="12" rx="2.5"/><path d="M6 15H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v1"/></svg>';+const ICON_SEND = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21.5 2.5 10.5 13.5"/><path d="M21.5 2.5 14.8 21.5l-4.3-8-8-4.3z"/></svg>'; const UPSELL = '<p><strong>Not installed yet</strong> - and it is the single best upgrade to your Adom experience. With the nbe your AI can:</p>'   + '<div class="gain">log into sites with your own saved passwords (pup never can)</div>'   + '<div class="gain">click and type with trusted, human-grade input - on a background window</div>'@@ -526,11 +585,13 @@ async function loadSay(){   try {     const r = await fetch('api/say'); const d = await r.json();     window.__say = d.say || [];-    document.getElementById('say-chips').innerHTML = window.__say.map((p,i)=>-      '<span class="say-chip" data-i="'+i+'" onclick="sayCopy('+i+')">&ldquo;'+hiNoun(p)+'&rdquo;'-      + '<button class="copy" onclick="event.stopPropagation();sayCopy('+i+')">Copy</button>'-      + '<button class="send" onclick="event.stopPropagation();saySendTo('+i+',this)">Send to AI</button></span>').join('');-    document.getElementById('fold-count').textContent = window.__say.length;+    document.getElementById('prompts').innerHTML = window.__say.map((p,i)=>+      '<div class="prow" role="row" onclick="sayCopy('+i+')">'+      + '<div class="pphrase" role="cell" title="'+p.replace(/"/g,'&quot;')+'">&ldquo;'+hiNoun(p)+'&rdquo;</div>'+      + '<div class="pacts" role="cell">'+      + '<button class="iconbtn" aria-label="Copy this prompt to your clipboard" data-tooltip="Copy this prompt to your clipboard" onclick="event.stopPropagation();sayCopy('+i+')">'+ICON_COPY+'</button>'+      + '<button class="iconbtn" aria-label="Send this prompt to a new AI thread" data-tooltip="Send this prompt to a new AI thread" onclick="event.stopPropagation();saySendTo('+i+',this)">'+ICON_SEND+'</button>'+      + '</div></div>').join('');   } catch(e){} } async function sayCopy(i){@@ -539,31 +600,53 @@ async function sayCopy(i){   else toast('Could not copy automatically - select the phrase and copy it yourself.', false); } async function saySendTo(i, btn){-  // one attempt, defensive (John's 2026-08-24 ruling: new threads only; failure = toast + copy fallback)-  const phrase = window.__say[i]; btn.disabled = true; btn.textContent = '...';+  // ONE attempt, defensive: any failure error-toasts AND copies, so the user is never stranded.+  const phrase = window.__say[i]; btn.disabled = true;   let r = null;   try { r = await fetch('api/say-send', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ phrase }) }).then(x=>x.json()); } catch(e){}-  btn.disabled = false; btn.textContent = 'Send to AI';-  if (r && r.ok) { toast('Sent to a new AI thread.', true); gradSave({ aiDriven: true }); applyGrad(); }+  btn.disabled = false;+  if (r && r.ok) { toast('Sent to a new AI thread.', true); gradSave({ aiDriven: true }); }   else { await writeClipboard(phrase); toast((r && r.why) || 'Could not reach the AI panel; copied instead.', false); } }+// ---- tooltips (600ms, body-appended, viewport-clamped; NEVER a title attr alongside) ----+let _tipEl=null,_tipT=null;+document.addEventListener('mouseover',(e)=>{+  const t=e.target.closest('[data-tooltip]'); if(!t) return;+  clearTimeout(_tipT);+  _tipT=setTimeout(()=>{+    _tipEl=document.createElement('div'); _tipEl.className='tip'; _tipEl.textContent=t.getAttribute('data-tooltip');+    document.body.appendChild(_tipEl);+    const r=t.getBoundingClientRect(), tr=_tipEl.getBoundingClientRect();+    _tipEl.style.left=Math.max(6,Math.min(r.left,innerWidth-tr.width-6))+'px';+    _tipEl.style.top=(r.bottom+6)+'px';+  },600);+});+document.addEventListener('mouseout',(e)=>{ if(e.target.closest('[data-tooltip]')){ clearTimeout(_tipT); if(_tipEl){_tipEl.remove();_tipEl=null;} } }); // ---- newbie graduation (localStorage; this port is its own origin so state is per-app) ---- const GRAD_KEY = 'adom-dash-graduation'; function gradState(){ try { return JSON.parse(localStorage.getItem(GRAD_KEY)) ?? {}; } catch { return {}; } } function gradSave(patch){ try { localStorage.setItem(GRAD_KEY, JSON.stringify({ ...gradState(), ...patch })); } catch {} }-let _recalled = false; try { _recalled = sessionStorage.getItem('adom-dash-recall') === '1'; } catch {}-function recallScaffolding(){ try { sessionStorage.setItem('adom-dash-recall','1'); } catch {} _recalled = true; applyGrad(); }-function dismissSay(){ gradSave({ dismissed: { ...(gradState().dismissed||{}), say: true } }); _recalled = false; try { sessionStorage.removeItem('adom-dash-recall'); } catch {} applyGrad(); }-function unfoldSay(){ recallScaffolding(); }-function applyGrad(){+// The panel is ONE state driven by BOTH the X and the header toggle - they never drift apart.+// The X FULLY closes it (no residual one-liner: that stub is retired), and the app body moves up+// because the content region owns the page padding, not the panel.+let _panelOpen = true;+function panelShouldOpen(){   const g = gradState();-  const launches = g.launches || 0;-  const demoFolded = !_recalled && (launches >= 3 || g.aiDriven || (g.dismissed||{}).say);-  const sayFolded = !_recalled && ((g.dismissed||{}).say || g.aiDriven || launches >= 5);   // the say strip folds LAST-  document.getElementById('say').hidden = sayFolded;-  document.getElementById('say-folded').hidden = !sayFolded;-  document.getElementById('demo-entry').style.display = demoFolded && !sayFolded ? 'none' : 'flex';-}+  if (g.panelClosed === true) return false;+  if (g.panelClosed === false) return true;                       // an explicit re-open sticks+  return !((g.dismissed||{}).say || g.aiDriven || (g.launches||0) >= 3);+}+function setPanel(open){+  _panelOpen = open;+  document.getElementById('panel').hidden = !open;+  const h = document.getElementById('help');+  h.setAttribute('aria-pressed', open ? 'true' : 'false');+  const label = open ? 'Hide the prompts panel' : 'Show the prompts panel';+  h.setAttribute('aria-label', label); h.setAttribute('data-tooltip', label);+}+function togglePanel(){ const n = !_panelOpen; gradSave({ panelClosed: !n }); setPanel(n); }+function closePanel(){ gradSave({ panelClosed: true }); setPanel(false); }+function applyGrad(){ setPanel(panelShouldOpen()); } let _launchCounted = false; function countLaunch(state){   if (_launchCounted || state !== 'ok') return;   // a launch is successful when the row goes green@@ -578,6 +661,8 @@ async function demoStart(){   try { const d = await fetch('api/demo').then(x=>x.json()); _demo.steps = d.steps || []; } catch { toast('Could not load the demo.', false); return; }   if (!_demo.steps.length) { toast('Could not load the demo.', false); return; }   document.getElementById('demo-bar').hidden = false;+  const db = document.getElementById('demo-btn');+  if (db) { db.disabled = true; db.querySelector('span').textContent = 'Demo running'; }   document.getElementById('demo-mute').textContent = _demo.muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';   _demo.i = -1; _demo.playing = true; demoAdvance(1); }@@ -622,6 +707,8 @@ async function demoExit(){   clearTimeout(_demo.timer);   const au = document.getElementById('demo-audio'); au.pause(); au.src = '';   document.getElementById('demo-bar').hidden = true;+  const db = document.getElementById('demo-btn');+  if (db) { db.disabled = false; db.querySelector('span').textContent = 'Play the 1-minute demo'; }   _demo.playing = false; _demo.i = -1;   try { await fetch('api/demo/exit', { method:'POST' }); } catch {} }@@ -870,9 +957,7 @@ function startServer() {     if (u.startsWith('/api/say-send') && req.method === 'POST') {       let b = ''; req.on('data', (c) => (b += c)); req.on('end', async () => {         let phrase = ''; try { phrase = String(JSON.parse(b).phrase || ''); } catch {}-        const out = SAYSEND_PARKED-          ? { ok: false, parked: true, why: 'Send to AI is temporarily paused (a platform fix is being verified) - copied to your clipboard instead' }-          : phrase ? await saySend(phrase) : { ok: false, why: 'phrase required' };+        const out = phrase ? await saySend(phrase) : { ok: false, why: 'phrase required' };         res.writeHead(200, { 'Content-Type': 'application/json', ...CORS }); res.end(JSON.stringify(out));       });       return;
package.json+1−1
@@ -1,6 +1,6 @@ {   "slug": "nb-bridge",-  "version": "0.11.2",+  "version": "0.12.0",   "type": "app",   "title": "NB - Native Browser Bridge",   "description": "This package installs the SKILLS into your container so your AI knows how to drive your real signed-in Chrome and Edge; Bridge loads the bridge runtime itself from the release zip.",
page.json+75−6
@@ -16,10 +16,10 @@     "chrome",     "edge",     "nbrowser",-    "ai-agent",-    "developer-tools",-    "vibe-coding",-    "native-messaging"+    "pup",+    "native-messaging",+    "credentials",+    "chip-fetcher"   ],   "status": "live",   "discovery_triggers": [@@ -70,5 +70,74 @@   },   "visibility": "public",   "brief": "Your real browser, driven by AI.",-  "discovery_pitch": "nbe (the Adom Native Browser Extension, layer 2 of nb) drives the user's REAL signed-in Chrome/Edge via nbrowser_* verbs: use it for LOGGING IN, filling forms, and downloading behind auth with their OWN saved passwords - pup cannot (it is a fresh Chrome-for-Testing with no logins). Next moves: run 'adom-bridge bridge_list' to check it is installed; if not, OFFER to install it (see the installing-the-extension skill) - never make the user do it. If this container lacks the skills, 'adom-wiki pkg install adom/nb-bridge'. Check readiness with 'adom-bridge nbrowser_readiness' (state:needs-extension means offer the install)."-}+  "discovery_pitch": "nbe (the Adom Native Browser Extension, layer 2 of nb) drives the user's REAL signed-in Chrome/Edge via nbrowser_* verbs: use it for LOGGING IN, filling forms, and downloading behind auth with their OWN saved passwords - pup cannot (it is a fresh Chrome-for-Testing with no logins). Next moves: run 'adom-bridge bridge_list' to check it is installed; if not, OFFER to install it (see the installing-the-extension skill) - never make the user do it. If this container lacks the skills, 'adom-wiki pkg install adom/nb-bridge'. Check readiness with 'adom-bridge nbrowser_readiness' (state:needs-extension means offer the install).",+  "version": "0.12.0",+  "type": "app",+  "description": "This package installs the SKILLS into your container so your AI knows how to drive your real signed-in Chrome and Edge; Bridge loads the bridge runtime itself from the release zip.",+  "install": {+    "binary_name": "nb-bridge"+  },+  "files": [+    "screenshots/hero.png",+    "skills/adom-native-browser/SKILL.md",+    "skills/driving-sites/SKILL.md",+    "skills/driving-the-desktop/SKILL.md",+    "skills/driving-the-extension/SKILL.md",+    "skills/driving-the-extension-demo/SKILL.md",+    "skills/installing-the-extension/SKILL.md",+    "skills/native-browser-recording/SKILL.md",+    "skills/native-browser-safety/SKILL.md",+    "skills/reading-google-messages/SKILL.md",+    "skills/uninstalling-the-extension/SKILL.md",+    "bin/nb-dashboard",+    "docs/img/dock-nb.svg",+    "docs/img/settings/overlay-on.png",+    "docs/img/settings/overlay-off.png",+    "docs/img/settings/activity-progress.png",+    "docs/img/settings/activity-flash.png",+    "docs/img/settings/activity-off.png",+    "demo/audio/intro.mp3",+    "demo/audio/open.mp3",+    "demo/audio/shot.mp3",+    "demo/audio/click.mp3",+    "demo/audio/wrap.mp3",+    "dockbar.json",+    "install.sh",+    "uninstall.sh",+    "package.json"+  ],+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  },+  "dependencies": {+    "adom/adom-bridge": "^2.0.3"+  },+  "sample_prompts": [+    {+      "label": "Open in my browser",+      "prompt": "open the wiki in my real chrome work profile"+    },+    {+      "label": "Log in for me",+      "prompt": "log into digikey for me using my saved password"+    },+    {+      "label": "Form behind a login",+      "prompt": "fill out this form on the site I'm already signed into"+    },+    {+      "label": "Download behind auth",+      "prompt": "download that datasheet, it's behind a login"+    },+    {+      "label": "Install the extension",+      "prompt": "install the adom native browser extension in my chrome"+    }+  ],+  "org": "adom",+  "author": {+    "name": "John Lauer",+    "email": "[email protected]"+  }+}\ No newline at end of file