master
Colby Knox Publish 0.16.68 a3d76cc 5d ago
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142
#!/usr/bin/env node
// nb-dashboard — the Dock Bar dashboard for nb (Adom Native Browser). adom/nb-bridge#30.
//
// THE POINT (John, verbatim intent): "indicate cards for layer 1 and layer 2 of nb so as to
// encourage and advertise to the user to install the adom nbe (native browser extension) cuz if
// they do their experience using adom will be greatly improved." The Layer 2 card IS the product
// here; the rest of the dashboard exists to give it a stage.
//
// Dock publisher contract (adom/hydrogen docs/dockbar.md), the three rules that break every
// first attempt, all honored here:
//   1. serve SELF-BACKGROUNDS: prints the URL, forks a detached child, exits 0.
//   2. PATH is the manifest's job (dockbar.json steps set it); this script assumes nothing.
//   3. serve is IDEMPOTENT and VERSION-AWARE: an instance already up with the same version is
//      reused (print URL, exit 0); a version-mismatched instance is told to shut down and replaced.
// Plus the two riding contracts: prints the DIRECT http://127.0.0.1:<port>/ URL (never the proxy
// form, never the name localhost), and takes ownership of $ADOM_DOCK_WEBVIEW once serving.
'use strict';
const http = require('http');
const { execFile, spawn } = require('child_process');
const fs = require('fs');
const crypto = require('crypto');

const VERSION = '0.16.68';           // 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,
// with the qualifiers they need (browser, profile). Single source is dockbar.json's say[] next to this
// script's package; the built-in list is the fallback when the manifest is not beside us.
const SAY_FALLBACK = [
  'open digikey in nb',
  'open mouser in nb chrome personal',
  'open my wiki page in nb edge',
  'log into autodesk in nb',
  'install the nbe in my chrome',
];
function sayPhrases() {
  try {
    const m = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'dockbar.json'), 'utf8'));
    if (Array.isArray(m.say) && m.say.length) return m.say.slice(0, 6);
  } catch {}
  return SAY_FALLBACK;
}
// SEND TO AI (John's product ruling, 2026-08-24, relayed by the dock thread): the new-thread path of
// the AI-threads API is cleared by ruling (gate fix at the cdp_eval primitive is the engineering basis;
// the formal certification probe never completed). Implement DEFENSIVELY per the dock thread's spec:
// create the thread first, ONE run-prompt attempt in a try/catch, ANY failure = {ok:false, why} so the
// page error-toasts and falls back to copy. No retry loops (the historical failure mode was panel-wide).
// Resume / typing into EXISTING threads stays parked - this backend has no code path that can do it.
function controlUrl() {
  try { const u = fs.readFileSync(path.join(require('os').homedir(), '.adom', 'hydrogen-control-url'), 'utf8').trim(); if (u) return u.replace(/\/$/, ''); } catch {}
  return 'http://127.0.0.1:47084';
}
// Raw pass-through to the control API: bytes in, bytes out. `u` is the path+query exactly as the page
// asked for it. Only the transport is ours; the payload is Hydrogen's.
// AI PICKER (adom/nb-bridge#44, Hydrogen 1.0.213+): when a NEW thread is asked for from this dashboard,
// the HUMAN chooses the AI in Hydrogen's picker (5 s countdown to their default, Escape cancels). That
// happens when the request names no `type`, so the shared module's request is rewritten on the way
// through: `type` stripped, the `app:adom/nb-bridge` tag added (the picker's "remember for this app"
// keys on it; the dock: tag stays for the dock). Only the REQUEST body changes; the reply still passes
// through byte for byte, so `cancelled:true`, `job`, `state` reach the page exactly as Hydrogen sent them.
const APP_TAG = 'app:adom/nb-bridge';
function sendBodyForPicker(u, method, body) {
  if (method !== 'POST' || !u.startsWith('/ai-threads/run-prompt') || u.startsWith('/ai-threads/run-prompt/')) return body;
  try {
    const j = JSON.parse(body || '{}');
    if (j && typeof j === 'object' && j.new === true) {
      delete j.type;
      const tags = Array.isArray(j.tags) ? j.tags.slice() : [];
      if (!tags.includes(APP_TAG)) tags.push(APP_TAG);
      j.tags = tags;
      return JSON.stringify(j);
    }
  } catch {}
  return body;
}
function proxyRaw(method, u, body, res) {
  let up; try { up = new URL(controlUrl() + u); } catch { up = null; }
  const fail = (why) => {
    res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...CORS_ALL });
    res.end(JSON.stringify({ ok: false, error: 'control API unreachable', why, _hint: 'This dashboard proxies ' + u.split('?')[0] + ' to the Hydrogen control API read from ~/.adom/hydrogen-control-url at request time. 503 means the route IS wired but no Hydrogen answered here (not running, or a different host); a 404 would mean the route is missing.' }));
  };
  if (!up) return fail('bad control URL');
  const data = method === 'POST' ? Buffer.from(body || '', 'utf8') : null;
  const req2 = http.request({ host: up.hostname, port: up.port, path: up.pathname + up.search, method, timeout: method === 'POST' ? 120000 : 10000,
    headers: data ? { 'Content-Type': 'application/json', 'Content-Length': data.length } : {} }, (r2) => {
    const chunks = []; r2.on('data', (c) => chunks.push(c)); r2.on('end', () => {
      const buf = Buffer.concat(chunks);
      res.writeHead(r2.statusCode || 502, { 'Content-Type': r2.headers['content-type'] || 'application/json', 'Content-Length': buf.length, 'Cache-Control': 'no-store', ...CORS_ALL });
      res.end(buf);
    });
  });
  req2.on('error', (e) => fail(e.message)); req2.on('timeout', () => { req2.destroy(); fail('timeout'); });
  if (data) req2.write(data); req2.end();
}
const CORS_ALL = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' };
function httpJson(method, url, body, timeoutMs) {
  return new Promise((resolve) => {
    try {
      const u = new URL(url);
      const data = body ? JSON.stringify(body) : null;
      const req = http.request({ host: u.hostname, port: u.port, path: u.pathname + u.search, method, timeout: timeoutMs || 5000,
        headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {} }, (res) => {
        let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => { let j = null; try { j = JSON.parse(b); } catch {} resolve({ status: res.statusCode, json: j, text: b.slice(0, 300) }); });
      });
      req.on('error', (e) => resolve({ status: 0, error: e.message })); req.on('timeout', () => { req.destroy(); resolve({ status: 0, error: 'timeout' }); });
      if (data) req.write(data); req.end();
    } catch (e) { resolve({ status: 0, error: e.message }); }
  });
}
async function saySend(phrase) {
  // ONE CALL composite. NOT the two-step new-then-send-by-sid flow: /ai-threads/new has NEVER
  // returned an id (a thread has no sid until its first submitted prompt), so a client that reads
  // sid||id from /new always aborts and ships a Send button that opens a tab and delivers nothing.
  // `new:true` inside run-prompt creates the thread; name and tags stage on the SAME request.
  //
  // AI PICKER + JOB STATUS (adom/nb-bridge#44, Hydrogen 1.0.213/1.0.218+): no `type`, so the human picks
  // the AI in Hydrogen's picker; `background:true` returns {job, state:"accepted"} at once and the rest
  // runs detached, so the picker's 5 to 45 s never sits inside a request timeout. Then poll
  // GET /ai-threads/run-prompt/status?job=<job> once a second until terminal:true. Every answer carries
  // `human`, the ready toast line for that phase; `cancelled` (Escape in the picker) is final: never
  // retry, never fall back to naming an AI. Older Hydrogen (no job in the answer) keeps the pre-1.0.187
  // delivered_to reading below.
  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 r = await httpJson('POST', base + '/ai-threads/run-prompt', {
      new: true, prompt: phrase, submit: true, background: true,
      name: 'NB - say it', tags: ['dock:nb-bridge', APP_TAG],
    }, 120000);
    if (!(r.status >= 200 && r.status < 300)) {
      const hint = r.json && (r.json._hints ? (typeof r.json._hints === 'string' ? r.json._hints : JSON.stringify(r.json._hints)) : r.json.error);
      return { ok: false, why: hint || (r.status ? `the AI panel refused the prompt (status ${r.status})` : `AI panel unreachable at ${base}`) };
    }
    const j = r.json || {};
    if (j.cancelled === true || j.state === 'cancelled') return { ok: false, cancelled: true, state: 'cancelled', why: j.human || 'you dismissed the AI picker; nothing was opened or sent', job: j.job || null };
    if (!j.job) {
      // Pre-1.0.187 shape: delivered_to is a diff, not a guess, and EMPTY when verification has not caught up.
      const inj = j.inject || {};
      const delivered = j.delivered_to || null;
      const confirmed = (delivered && String(delivered).length) || inj.delivered_in_conversation === true;
      if (confirmed) return { ok: true, state: 'delivered', delivered_to: delivered, inject: inj, body: j };
      if (j.ok === true) return { ok: true, state: 'unconfirmed', delivered_to: delivered, inject: inj, tid: j.tid || null, body: j };
      return { ok: false, state: 'failed', why: 'the AI panel did not confirm the send', body: j };
    }
    const started = Date.now(); const timeline = []; let last = j;
    while (Date.now() - started < 120000) {
      await new Promise((res) => setTimeout(res, 1000));
      const st = await httpJson('GET', base + '/ai-threads/run-prompt/status?job=' + encodeURIComponent(j.job), null, 8000);
      if (!st.json) continue;
      last = st.json;
      const state = last.state || (last.job && last.job.state) || '';
      if (!timeline.length || timeline[timeline.length - 1].state !== state) timeline.push({ t: Math.round((Date.now() - started) / 100) / 10, state, human: last.human || null });
      if (last.terminal === true || state === 'cancelled') break;
    }
    const state = last.state || (last.job && last.job.state) || 'unknown';
    const picked = last.job ? { type: last.job.type || null, picked_by: last.job.picked_by || null } : null;
    const ok = !(['cancelled', 'rejected', 'open_failed', 'open_unconfirmed', 'tab_not_found', 'inject_failed', 'submit_failed', 'failed', 'timeout'].includes(state));
    return { ok, cancelled: state === 'cancelled', state, human: last.human || null, picked, job: j.job, terminal: last.terminal === true, timeline,
      why: ok ? undefined : (last.human || state) };
  } catch (e) { return { ok: false, why: String(e && e.message || e) }; }
}
// ---- the guided demo (dash-demo contract) ----------------------------------
// One step list; the page owns pacing/audio, this backend owns ACTIONS and ab CAPTIONS. Captions go
// through desktop_caption with ONLY text+id+duration - never size/position (fleet ruling: the bridge
// default look, identical across every app's demo). The demo drives a real nb window in its own
// session and exit is safe from any step: clear caption, close the session window, forget state.
const DEMO_SID = 'nbdashdemo';
const HARNESS_URL = 'https://wiki.adom.inc/blob/app/nb-bridge/demo/test-harness.html';
const DEMO_STEPS = [
  { id: 'intro', caption: 'This is nb: your AI, driving your real signed-in browser.', audio: 'demo/audio/intro.mp3', min_ms: 5000, action: null },
  { id: 'open', caption: 'Opening DigiKey in your real browser, in the background. Your focus is never stolen.', audio: 'demo/audio/open.mp3', min_ms: 7000, action: 'open' },
  { id: 'shot', caption: 'Taking a screenshot of the page while the window stays behind your work.', audio: 'demo/audio/shot.mp3', min_ms: 6000, action: 'shot' },
  { id: 'click', caption: 'Clicking with trusted, human-grade input on a background window.', audio: 'demo/audio/click.mp3', min_ms: 7000, action: 'click' },
  { id: 'wrap', caption: 'That is the nbe, layer 2 of nb. Ask your AI to install it in any browser you use.', audio: 'demo/audio/wrap.mp3', min_ms: 6000, action: 'wrap' },
];
let _demoTarget = null;
function demoProfileArgs() { return { sessionId: DEMO_SID, thread: 'nb-dashboard', purpose: 'guided dashboard demo' }; }
async function demoCaption(text) {
  const t = _demoTarget; if (!t) return;
  await cli(['--target', t, '--ai-thread', 'nb-dashboard', 'desktop_caption', JSON.stringify({ text, id: 'nb-demo', duration: 30000 })], 10000);
}
async function demoStep(id) {
  const step = DEMO_STEPS.find((x) => x.id === id);
  if (!step) return { ok: false, why: 'unknown step' };
  if (!_demoTarget) {
    const tg = (cache.targets || [])[0];
    if (!tg) return { ok: false, why: 'no desktop connected' };
    _demoTarget = tg.name;
  }
  const t = _demoTarget;
  await demoCaption(step.caption);
  try {
    if (step.action === 'open') {
      const r = await cli(['--target', t, '--ai-thread', 'nb-dashboard', 'nbrowser_open_window', JSON.stringify({ ...demoProfileArgs(), url: 'https://www.digikey.com' })], 30000);
      return { ok: r.ok === true || r.status === 'ok', detail: 'window opened in the background' };
    }
    if (step.action === 'shot') {
      const r = await cli(['--target', t, '--ai-thread', 'nb-dashboard', 'nbrowser_screenshot', JSON.stringify({ sessionId: DEMO_SID })], 60000);
      // nb-bridge#49: the reply is a file (fullPath) that the CLI pulls here; read the pulled copy.
      const local = [r.localSafePath, r.localFullPath, r.savedTo].find((f) => f && fs.existsSync(f));
      const shot = r.base64 ? 'data:image/png;base64,' + r.base64 : (local ? 'data:image/png;base64,' + fs.readFileSync(local).toString('base64') : null);
      return { ok: r.ok === true || r.status === 'ok', detail: 'captured ' + (r.fullWidth || r.width || '?') + 'px wide without foregrounding', shot };
    }
    if (step.action === 'click') {
      await cli(['--target', t, '--ai-thread', 'nb-dashboard', 'nbrowser_navigate', JSON.stringify({ sessionId: DEMO_SID, url: HARNESS_URL })], 30000);
      await cli(['--target', t, '--ai-thread', 'nb-dashboard', 'nbrowser_wait_for', JSON.stringify({ sessionId: DEMO_SID, selector: '#btn-one' })], 30000);
      const r = await cli(['--target', t, '--ai-thread', 'nb-dashboard', 'nbrowser_click', JSON.stringify({ sessionId: DEMO_SID, selector: '#btn-one' })], 30000);
      return { ok: r.ok === true, detail: 'trusted click dispatched (isTrusted:true), window still background' };
    }
    if (step.action === 'wrap') {
      const r = await cli(['--target', t, '--ai-thread', 'nb-dashboard', 'nbrowser_close_window', JSON.stringify({ sessionId: DEMO_SID })], 20000);
      return { ok: true, detail: 'demo window closed - nothing left behind' };
    }
    return { ok: true };
  } catch (e) { return { ok: false, why: String(e && e.message || e) }; }
}
async function demoExit() {
  const t = _demoTarget; _demoTarget = null;
  if (!t) return { ok: true };
  try {
    await cli(['--target', t, '--ai-thread', 'nb-dashboard', 'desktop_caption', JSON.stringify({ text: '', id: 'nb-demo' })], 8000);
    await cli(['--target', t, '--ai-thread', 'nb-dashboard', 'nbrowser_close_window', JSON.stringify({ sessionId: DEMO_SID })], 15000);
  } catch {}
  return { ok: true };
}
// BUILD = hash of this very file. Version-aware reuse compared the VERSION string alone and happily
// kept serving a stale process whose code had changed under the same version (2026-08-23) - the
// dock guide's version-blind trap at one finer grain. Reuse now requires version AND build to match.
const BUILD = (() => { try { return crypto.createHash('sha1').update(fs.readFileSync(__filename)).digest('hex').slice(0, 10); } catch { return 'unknown'; } })();
const PORT = 8954;
const URL_SELF = `http://127.0.0.1:${PORT}/`;

// ---------- probes (run in the background; /api/status answers from cache in ms) ----------
const cache = {
  at: 0,
  facets: [],           // [{label, state, detail, _hint}]
  state: 'unknown',
  targets: [],          // [{name, readiness}]
  version: VERSION,
};

function cli(args, timeoutMs = 8000) {
  return new Promise((resolve) => {
    execFile('adom-bridge', args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
      const out = String(stdout || '');
      try {
        let d = JSON.parse(out);
        if (typeof d.output === 'string' && d.output.trim().startsWith('{')) { try { d = { ...d, ...JSON.parse(d.output) }; } catch {} }
        resolve(d);
      } catch { resolve(err ? { _err: String((err && err.message) || 'cli failed').slice(0, 200) } : { _raw: out.slice(0, 200) }); }
    });
  });
}

async function refresh() {
  const facets = [];
  const targetRows = [];
  // Facet 1: Bridge (ab) — is the relay reachable and is any desktop connected?
  const tg = await cli(['targets']);
  const targets = (tg && tg.targets) || [];
  if (tg && tg._err && !targets.length) {
    facets.push({ label: 'Adom Bridge', state: 'fail', detail: 'adom-bridge CLI failed or is not installed', _hint: 'Install/repair the ab CLI: adom-wiki pkg install adom/adom-bridge, then adom-bridge targets.' });
  } else if (!targets.length) {
    facets.push({ label: 'Adom Bridge', state: 'fail', detail: 'no desktop connected to the relay', _hint: 'Start Adom Bridge on the user\'s desktop (it connects to the relay automatically).' });
  } else {
    facets.push({ label: 'Adom Bridge', state: 'ok', detail: `${targets.length} desktop(s): ${targets.map((t) => t.name).join(', ')}` });
  }
  // Facets 2+3: layer 1 / layer 2, judged across every connected desktop (any-ok wins).
  let l1ok = false; let l2ready = false; let anyMissing = false; let anyDisabled = false;
  const matrix = [];   // one row per machine x browser-profile, in every state incl. deliberately-disabled
  for (const t of targets.slice(0, 4)) {
    const r = await cli(['--target', t.name, '--ai-thread', 'nb-dashboard', 'nbrowser_readiness', '{}']);
    targetRows.push({ name: t.name, state: r.state || null, profiles: r.profiles || [], extensionVersions: r.extensionVersions || {}, chromeInstalled: r.chromeInstalled });
    if (r.state) l1ok = true;                       // the nb bridge answered = layer 1 verbs route
    if (r.ready === true) l2ready = true;
    const pr = await cli(['--target', t.name, '--ai-thread', 'nb-dashboard', 'nbrowser_profiles', '{}']);
    for (const p of (pr.profiles || [])) {
      if (/pending-\d+/.test(String(p.profile || '')) || p.unresolved) continue; // mid-identification, not a real row
      // Four states. 'disabled' is a USER CHOICE (they installed it, then switched it off - the
      // browser-native way of saying "nb may not drive this browser"). It is respected: grey, never
      // counted against health, never upsold. John does exactly this on Edge, on purpose.
      const st = p.live ? 'connected'
        : (p.extensionInstalled && p.extensionEnabled === false) ? 'disabled'
        : p.extensionInstalled ? 'asleep'
        : 'missing';
      if (st === 'missing') anyMissing = true;
      if (st === 'disabled') anyDisabled = true;
      matrix.push({ machine: t.name, browser: p.browser, profile: p.profile, email: p.email || '', state: st,
        version: (r.extensionVersions || {})[p.profile] || null });
    }
  }
  const l2installable = anyMissing;
  facets.push(l1ok
    ? { label: 'Layer 1 - Native Browser', state: 'ok', detail: 'extension-free opens into the user\'s real browser work now', _hint: 'nbrowser_open_os_window / nbrowser_profiles are live.' }
    : { label: 'Layer 1 - Native Browser', state: targets.length ? 'warn' : 'fail', detail: targets.length ? 'nb bridge not answering on the connected desktop(s)' : 'needs a connected desktop', _hint: 'adom-bridge refresh_bridges {"name":"native-browser"} on the desktop, then re-check nbrowser_readiness.' });
  facets.push(l2ready
    ? { label: 'Layer 2 - nbe (extension)', state: 'ok', detail: anyMissing ? 'nbe live (some browsers still without it)' : 'nbe connected - full driving is live', _hint: 'Drive with nbrowser_open_window. Rows marked DISABLED are the user\'s explicit choice - treat like nbrowser_profile_block: never nag, never try to enable, never drive.' }
    : anyMissing
      ? { label: 'Layer 2 - nbe (extension)', state: 'warn', detail: 'nbe is NOT installed - the user is missing the best part', _hint: 'OFFER the install (installing-the-extension skill runs it FOR the user in about a minute). But a browser where the extension is INSTALLED-AND-DISABLED is the user saying no - respect it like nbrowser_profile_block.' }
      : anyDisabled
        ? { label: 'Layer 2 - nbe (extension)', state: 'ok', detail: 'disabled everywhere by the user - their choice, respected', _hint: 'The user deliberately disabled nbe. Do NOT upsell, enable, or work around it - offer layer 1 only.' }
        : { label: 'Layer 2 - nbe (extension)', state: 'fail', detail: 'no desktop to check', _hint: 'Connect Adom Bridge first.' });
  // Rollup is OURS: the dashboard is healthy when layer 1 works; a missing nbe is a warn (that is
  // the upsell state, not an outage).
  const prev = new Map((cache.facets || []).map((f) => [f.label, f]));
  for (const f of facets) { const p = prev.get(f.label); f.since = (p && p.state === f.state && p.since) ? p.since : Date.now(); }
  cache.facets = facets;
  cache.targets = targetRows;
  cache.matrix = matrix;
  try {
    const t0 = targets[0];
    if (t0) {
      const act = await cli(['--target', t0.name, '--ai-thread', 'nb-dashboard', 'nbrowser_activity', '{"limit":30}']);
      const rows2 = (act && act.activeSessions) || [];
      cache.aiDriven = rows2.some((r) => r.thread && !/^nb[- ]dashboard$/i.test(String(r.thread)));
    }
  } catch {}
  cache.state = l1ok ? (l2ready ? 'ok' : 'warn') : (targets.length ? 'warn' : 'fail');
  cache.at = Date.now();
}

// ---------- the page ----------
function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])); }
function page() {
  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NB Dashboard</title>
<link rel="icon" href="icon.svg" type="image/svg+xml">
<style>
  :root { --bg:#0d1117; --surface:#161b22; --surface2:#1c232c; --line:#262d36;
    --text:#e6edf3; --muted:#8b949e; --teal:#00b8b1;
    --led-green:#3fb950; --led-amber:#d29922; --led-red:#f85149;
    --sans:"Familjen Grotesk", system-ui, sans-serif; --body:system-ui,-apple-system,"Segoe UI",sans-serif;
    --mono:"JetBrains Mono", ui-monospace, Consolas, monospace;
    /* legacy aliases so the settings dialog + cards keep rendering */
    --card:var(--surface); --edge:var(--line); --ink:var(--text); --dim:var(--muted); --amber:var(--led-amber); --red:var(--led-red); }
  * { box-sizing:border-box; margin:0; }
  [hidden] { display:none !important; }   /* a display:flex class must never beat the hidden attribute */
  /* house scrollbar (adom-ui-design scrollbars sub-skill, John 2026-08-18): slim dark pill, teal on hover,
     transparent track - consistent with the wiki and the dock rail */
  * { scrollbar-width: thin; scrollbar-color: rgba(139,148,158,.38) transparent; }
  ::-webkit-scrollbar { width: 10px; height: 10px; }
  ::-webkit-scrollbar-track { background: transparent; }
  ::-webkit-scrollbar-thumb { background: rgba(139,148,158,.32); border-radius: 999px; border: 2px solid transparent; background-clip: padding-box; }
  ::-webkit-scrollbar-thumb:hover { background: rgba(0,184,177,.55); background-clip: padding-box; }
  ::-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; }
  /* 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(--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; }
  /* 16px HORIZONTAL is the fleet gutter (adom-ui-design 2.3.0): the panel's border box must be
     documentElement.clientWidth - 32. Four apps passed selfCheck at his identical 744px pane and
     still rendered differently - 734, 735, 702 and mine 694 - because each page wrapped the mount
     point in a different amount of its own padding. Mine at 20px a side (plus a 10px scrollbar)
     put the panel at 694, UNDER the module's 700 two-column threshold, so nb alone collapsed to
     one column. The panel was conformant; the page around it was not. Vertical padding is
     unchanged; anything needing a wider margin goes on the app body BELOW the panel. */
  main { padding:20px 16px 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:900px) { .cards { grid-template-columns:1fr; } }   /* prose columns need width: at 632px viewport two cards render as unreadable ribbons (measured in ah) */
  .card { background:var(--card); border:1px solid var(--edge); border-radius:12px; padding:18px 18px 16px; }
  .card h2 { display:flex; align-items:center; gap:10px; font-size:16px; margin-bottom:2px; }
  .layer { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.12em; margin-bottom:10px; }
  .card p { color:var(--dim); font-size:13.5px; margin:6px 0; }
  .card p strong { color:var(--ink); font-weight:600; }
  .gain { display:flex; gap:8px; align-items:baseline; font-size:13.5px; color:var(--dim); margin:5px 0; }
  .gain::before { content:'+'; color:var(--teal); font-weight:700; }
  .upsell { border-color:rgba(0,206,194,.45); box-shadow:0 0 24px rgba(0,206,194,.06) inset; }
  .prompt { display:flex; gap:8px; margin-top:12px; }
  .prompt code { flex:1; background:#0a0f14; border:1px solid var(--edge); border-radius:8px; padding:9px 12px; font:12.5px/1.4 ui-monospace,Consolas,monospace; color:var(--teal); overflow-x:auto; white-space:nowrap; }
  .prompt button { background:var(--teal); color:#04211f; border:0; border-radius:8px; padding:0 14px; font-weight:700; font-size:13px; cursor:pointer; }
  .prompt button:active { transform:translateY(1px); }
  .hintline { font-size:12px; color:var(--dim); margin-top:6px; }
  .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; }
  /* The newbie panel's styling now ships INSIDE the shared module, with Familjen Grotesk and
     Satoshi embedded as data URIs - which is what fixes my silent system-ui fallback, with no
     install step to get wrong. Nothing about the panel is styled here any more. */

  .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; }
  .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); }
  .settings-head { display:flex; align-items:center; justify-content:space-between; padding:12px 14px 12px 18px; border-bottom:1px solid #30363d; }
  .settings-head h2 { font-size:15px; font-weight:700; color:#e6edf3; }
  .settings-body { max-height:70vh; overflow:auto; padding:18px; scrollbar-gutter:stable; }
  .settings-card { background:#1c2128; border:1px solid #30363d; border-left:3px solid color-mix(in srgb, #00b8b1 45%, transparent); border-radius:10px; padding:.9rem 1.1rem 1rem; margin-bottom:1rem; }
  .card-title { font-size:13px; font-weight:600; color:#e6edf3; display:flex; align-items:center; gap:10px; } .card-title .tt { color:#8fd3d4; }
  .card-status { font-size:11px; color:#8b949e; font-weight:400; margin-left:auto; } .card-status.ok { color:#3fb950; }
  .card-desc { font-size:12.5px; line-height:1.55; color:#8b949e; max-width:60ch; margin:.5rem 0 .75rem; } .card-desc code { font-size:11px; background:rgba(110,118,129,.2); padding:1px 4px; border-radius:4px; }
  .shots { display:flex; gap:14px; margin:0 0 .75rem; flex-wrap:wrap; }
  .setting-shot { margin:0; } .setting-shot img { display:block; image-rendering:pixelated; border:1px solid #30363d; border-radius:8px; background:#161b22; }
  .setting-shot figcaption { margin-top:.35rem; font-size:.75rem; color:#8b949e; text-align:center; }
  .setting-row { display:flex; align-items:center; gap:10px; padding:.4rem 0; }
  .row-label { font-size:.875rem; color:#c9d1d9; }
  .default-hint { font-size:.75rem; color:#8b949e; margin-top:.15rem; }
  .toggle { background:none; border:none; padding:2px; cursor:pointer; flex-shrink:0; transition:transform .06s; }
  .toggle-track { display:block; width:32px; height:18px; background:#21262d; border:1px solid #30363d; border-radius:9px; position:relative; transition:background .15s, border-color .15s; }
  .toggle:hover .toggle-track { border-color:#00b8b1; } .toggle:active { transform:translateY(1px) scale(.97); }
  .toggle[aria-checked="true"] .toggle-track { background:#00b8b1; border-color:#00b8b1; }
  .toggle-thumb { position:absolute; top:2px; left:2px; width:12px; height:12px; background:#fff; border-radius:50%; transition:transform .18s, width .12s; }
  .toggle[aria-checked="true"] .toggle-thumb { transform:translateX(14px); } .toggle:active .toggle-thumb { width:16px; } .toggle[aria-checked="true"]:active .toggle-thumb { transform:translateX(10px); }
  .toggle:focus-visible { outline:2px solid #00b8b1; outline-offset:2px; border-radius:10px; }
  .choice-set { border:none; margin:0; padding:0; display:flex; flex-wrap:wrap; gap:.75rem; }
  .choice-set legend { margin-bottom:.5rem; }
  .choice { flex:1 1 200px; display:flex; flex-direction:column; gap:.35rem; padding:.6rem; cursor:pointer; background:#161b22; border:1px solid #30363d; border-radius:8px; position:relative; transition:border-color .15s, background .15s, transform .06s; }
  .choice input { position:absolute; opacity:0; pointer-events:none; }
  .choice img { display:block; image-rendering:pixelated; border-radius:4px; background:#0b1117; }
  .choice-name { font-size:.875rem; font-weight:600; color:#e6edf3; } .choice-desc { font-size:.75rem; color:#8b949e; line-height:1.5; }
  .choice:hover { border-color:#00b8b1; background:#1c2128; } .choice:active { transform:translateY(1px) scale(.995); }
  .choice[data-selected="true"] { border-color:#00b8b1; box-shadow:inset 0 0 0 1px #00b8b1; } .choice:focus-within { outline:2px solid #00b8b1; outline-offset:2px; }
  select { background:#21262d; color:#e6edf3; border:1px solid #30363d; border-radius:6px; padding:5px 8px; font-size:13px; }
  .btn { background:#00b8b1; color:#06231f; border:0; border-radius:6px; padding:6px 12px; font-weight:600; font-size:.875rem; cursor:pointer; margin-left:auto; } .btn:hover { filter:brightness(1.08); } .btn:active { transform:translateY(1px); } .btn[disabled] { opacity:.6; cursor:default; }
  .console { background:#0a0f14; border:1px solid #30363d; border-radius:8px; padding:10px 12px; font:12px/1.5 ui-monospace,Consolas,monospace; color:#c9d1d9; max-height:200px; overflow:auto; white-space:pre-wrap; margin-top:.5rem; }
  .settings-footer { margin-top:1.5rem; padding-top:1rem; border-top:1px solid #30363d; display:flex; flex-direction:column; gap:.5rem; }
  .footer-note { font-size:.75rem; color:#8b949e; } .footer-note code { font-size:11px; }
  .restore-btn { width:fit-content; padding:.4rem .85rem; background:transparent; border:1px solid #30363d; border-radius:.375rem; color:#c9d1d9; font-size:.8125rem; font-weight:600; cursor:pointer; }
  .restore-btn:hover { background:#1c2128; } .restore-btn.armed { color:#f85149; border-color:rgba(255,107,107,.5); background:rgba(255,107,107,.08); }
  .toast { position:fixed; left:50%; bottom:28px; transform:translate(-50%, 16px); display:flex; align-items:center; gap:10px;
    background:#132420; color:#e8eef4; border:1px solid rgba(63,185,80,.55); border-left:3px solid #3fb950; border-radius:10px;
    padding:10px 16px; font-size:13.5px; opacity:0; pointer-events:none; transition:opacity .18s ease, transform .18s ease; z-index:50; max-width:90vw; }
  .toast.show { opacity:1; transform:translate(-50%, 0); }
  .toast.warn { border-color:rgba(243,178,62,.55); border-left-color:var(--amber); background:#241f13; }
  .toast-ico { font-size:15px; color:#3fb950; } .toast.warn .toast-ico { color:var(--amber); }
  .explain { margin-top:12px; border:1px solid rgba(0,206,194,.35); border-radius:10px; padding:12px 14px; background:rgba(0,206,194,.05); }
  .tblwrap { overflow-x:auto; scrollbar-gutter:stable; margin-top:10px; }
  table { width:100%; border-collapse:collapse; font-size:13px; }
  th { text-align:left; color:var(--dim); font-weight:500; font-size:11.5px; text-transform:uppercase; letter-spacing:.08em; padding:4px 8px; border-bottom:1px solid var(--edge); }
  td { padding:6px 8px; border-bottom:1px solid rgba(30,42,54,.5); }
  .leds { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:18px; }
  .led-pill { display:inline-flex; align-items:center; gap:7px; background:var(--surface); border:1px solid var(--line); border-radius:999px; padding:4px 12px; font-size:12.5px; cursor:pointer; }
  .led-pill .dot { width:8px; height:8px; border-radius:50%; background:#3a4a5a; }
  .led-pill[data-state=ok] .dot { background:var(--led-green); }
  .led-pill[data-state=busy] .dot { background:var(--led-amber); }
  .led-pill[data-state=bad] .dot { background:var(--led-red); }
  .led-pill .st { color:var(--muted); }
  .led-pop { position:fixed; z-index:45; background:var(--surface2); border:1px solid var(--line); border-radius:10px; padding:12px 14px; max-width:420px; font-size:12.5px; color:var(--text); box-shadow:0 12px 32px rgba(0,0,0,.5); }
  .led-pop .hint { color:var(--muted); margin-top:6px; } .led-pop code { font-family:var(--mono); font-size:11px; background:rgba(110,118,129,.2); padding:1px 4px; border-radius:4px; }
  .demo-bar { position:fixed; left:50%; bottom:24px; transform:translateX(-50%); display:flex; align-items:center; gap:6px; background:var(--surface2); border:1px solid var(--line); border-radius:999px; padding:6px 10px; z-index:48; box-shadow:0 10px 30px rgba(0,0,0,.5); }
  .dbtn { background:none; border:0; color:var(--text); font-size:15px; width:32px; height:32px; border-radius:50%; cursor:pointer; } .dbtn:hover { background:rgba(255,255,255,.08); } .dbtn:active { transform:translateY(1px); }
  .dsep { width:1px; height:18px; background:var(--line); margin:0 3px; }
  .dstep { font:11.5px var(--mono); color:var(--muted); padding:0 4px; }
  footer { text-align:center; color:var(--dim); font-size:12px; padding:18px; }
  footer a { color:var(--teal); text-decoration:none; }
  .age { font-size:11px; color:var(--dim); }
</style></head><body>
<header>
  <div class="brand" id="brand" tabindex="0">
    <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>
      <a href="https://wiki.adom.inc/adom/nb-bridge" target="_blank" rel="noopener">adom/nb-bridge on the wiki</a>
    </div>
  </div>
  <div class="chips" id="target-chips"></div>
  <button class="icon-btn" id="help" aria-label="Show or hide the prompts panel"><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>
  <!-- The SHARED newbie panel mounts here. I own my phrases, my noun, and what the demo button
       does; the module owns the card, grid, table, icons, clipboard, send path, toasts, tooltips,
       the X, the header toggle and the open/closed memory. Sibling of the app body, per contract. -->
  <div id="newbie"></div>
  <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: the Native Browser Extension</h2>
        <div id="l2-body"></div>
      </div>
    </div>
  </div>
</main>
<div class="demo-bar" id="demo-bar" hidden>
  <button class="dbtn" onclick="demoPrev()" aria-label="Previous step">&#9198;</button>
  <button class="dbtn" id="demo-play" onclick="demoToggle()" aria-label="Play or pause">&#9208;</button>
  <button class="dbtn" onclick="demoNext()" aria-label="Next step">&#9197;</button>
  <span class="dsep"></span>
  <button class="dbtn" id="demo-mute" onclick="demoMute()" aria-label="Mute narration">&#128266;</button>
  <span class="dstep" id="demo-stepno"></span>
  <span class="dsep"></span>
  <button class="dbtn" onclick="demoExit()" aria-label="Exit the demo">&#10005;</button>
</div>
<audio id="demo-audio"></audio>
<footer><a href="https://wiki.adom.inc/adom/nb-bridge" target="_blank" rel="noopener">adom/nb-bridge on the wiki</a></footer>
<dialog class="settings" id="settings" tabindex="-1" aria-labelledby="settings-title">
  <div class="settings-head"><h2 id="settings-title">NB Settings</h2><button class="icon-btn" aria-label="Close settings" title="Close" onclick="document.getElementById('settings').close()">&#x2715;</button></div>
  <div class="settings-body" id="settings-body">
    <div class="settings-card">
      <div class="card-title">Taskbar: <span class="tt">&ldquo;Adom is driving&rdquo; badge</span><span class="card-status" id="st-overlay"></span></div>
      <p class="card-desc">While nb is driving one of your browser windows, the bridge composites your own profile avatar with the Adom mark in its corner and paints it as the overlay icon on that window's Windows taskbar button. It is the trust tell: a glance at the taskbar says which windows an AI is working in and which profile it is using. The overlay goes on when a window is opened or driven and fades back to your plain avatar after about three minutes of no driving; it never blanks the overlay slot, so your profile avatar is always visible. Turning this off restores the native avatar on every window immediately.</p>
      <div class="shots">
        <figure class="setting-shot"><img src="assets/settings/overlay-on.png" alt="The Chrome taskbar button with the profile avatar carrying the small Adom mark in its corner" loading="lazy"><figcaption>On</figcaption></figure>
        <figure class="setting-shot"><img src="assets/settings/overlay-off.png" alt="The same button with the plain profile avatar and no Adom mark" loading="lazy"><figcaption>Off</figcaption></figure>
      </div>
      <div class="setting-row"><button class="toggle" id="tg-overlay" role="switch" aria-checked="true" aria-label="Adom is driving badge" onclick="setSetting('overlayBadge', !(this.getAttribute('aria-checked')==='true'))"><span class="toggle-track"><span class="toggle-thumb"></span></span></button><span class="row-label">Show the badge while nb is driving</span></div>
      <div class="default-hint">Default: On</div>
    </div>
    <div class="settings-card">
      <div class="card-title">Taskbar: <span class="tt">activity while a verb runs</span><span class="card-status" id="st-activity"></span></div>
      <p class="card-desc">What the driven window's taskbar button does from the moment nb dispatches a verb until the browser answers. The progress bar is determinate: the bridge keeps a rolling average of how long each verb usually takes on your machine and fills the bar over that estimate, never past 95% until the reply lands, so it cannot claim to be done early. Open-ended work (waiting for a page, recording, a login the AI is walking through) shows an indeterminate marquee instead. The orange flash is the older cue: it tells you work started, not how far along it is.</p>
      <fieldset class="choice-set" role="radiogroup" aria-labelledby="act-label" id="choice-activity">
        <legend id="act-label" class="row-label">Taskbar activity</legend>
        <label class="choice" data-value="progress"><input type="radio" name="taskbarActivity" value="progress" onchange="setSetting('taskbarActivity','progress')"><img src="assets/settings/activity-progress.png" alt="The taskbar button's underline filled about two thirds with a blue progress bar" loading="lazy"><span class="choice-name">Progress bar</span><span class="choice-desc">Fills over the verb's usual duration, so you can see how far along it is.</span></label>
        <label class="choice" data-value="flash"><input type="radio" name="taskbarActivity" value="flash" onchange="setSetting('taskbarActivity','flash')"><img src="assets/settings/activity-flash.png" alt="The same button tinted salmon with a highlighted underline" loading="lazy"><span class="choice-name">Orange flash</span><span class="choice-desc">Highlights the button when work starts. Tells you something happened, not how long is left.</span></label>
        <label class="choice" data-value="off"><input type="radio" name="taskbarActivity" value="off" onchange="setSetting('taskbarActivity','off')"><img src="assets/settings/activity-off.png" alt="The same button with no bar and no highlight" loading="lazy"><span class="choice-name">Off</span><span class="choice-desc">The button never changes while a verb runs.</span></label>
      </fieldset>
      <div class="default-hint">Default: Progress bar</div>
    </div>
    <div class="settings-card">
      <div class="card-title">Taskbar: <span class="tt">auto-flash on come-look moments</span><span class="card-status" id="st-flash"></span></div>
      <p class="card-desc">Separate from the activity above: nb flashes a window's button orange at the moments you would want to look (a page finished loading, a screenshot was taken, something was refused, a login or 2FA step needs you, a page spawned a window). Quiet flashes only at those moments. Chatty flashes on every action, for when you want to watch the AI work. Off never auto-flashes; the AI can still flash deliberately at a human gate.</p>
      <div class="setting-row"><span class="row-label">Auto-flash</span>
        <select id="sel-flash" onchange="setSetting('flashMode', this.value)"><option value="quiet">Quiet</option><option value="chatty">Chatty</option><option value="off">Off</option></select></div>
      <div class="default-hint">Default: Quiet</div>
    </div>
    <div class="settings-card">
      <div class="card-title">New windows: <span class="tt">how big they open</span><span class="card-status" id="st-size"></span></div>
      <p class="card-desc">Every window nb opens is placed on your primary monitor's work area. Fill is pup's placement rule: the work area inset 10 pixels on every edge, the visible sign of a window that was placed on purpose, never the OS-maximized state, which raises. A percentage centers a smaller window. Without any of this Chrome sizes an extension-created window with its own cascaded default, about 60 percent of a large screen, and never the size you last set by hand. A caller that passes its own bounds always wins.</p>
      <div class="setting-row"><span class="row-label">New window size</span>
        <select id="sel-size" onchange="setSetting('newWindowSize', this.value)"><option value="fill">Fill the work area (10 px inset, same as pup)</option><option value="95">95% centered</option><option value="90">90% centered</option><option value="75">75% centered</option><option value="chrome">Chrome default</option></select></div>
      <div class="default-hint">Default: Fill the work area</div>
    </div>
    <div class="settings-card">
      <div class="card-title">Browser toolbar: <span class="tt">the nbe icon</span><span class="card-status" id="st-icon"></span></div>
      <p class="card-desc">What the extension shows in the browser's toolbar slot. Auto keeps Chrome's default for an unpacked extension: the icon exists but stays unpinned under the puzzle menu, a small surface, so a user's own browser does not advertise that the nbe is there. On enables the icon with an nb badge and a clear title, for a host that pins it (pup pins the extensions it loads into its own window). Off hides it as far as an extension can hide itself. Pinning is the profile's own preference; this setting only decides what the slot shows.</p>
      <div class="setting-row"><span class="row-label">Toolbar icon</span>
        <select id="sel-icon" onchange="setSetting('toolbarIcon', this.value)"><option value="auto">Auto (unpinned, Chrome default)</option><option value="on">On (icon + nb badge)</option><option value="off">Off (hidden)</option></select></div>
      <div class="default-hint">Default: Auto</div>
    </div>
    <div class="settings-card">
      <div class="card-title">nb bridge <span class="tt">on your desktops</span><span class="card-status" id="st-bridge"></span></div>
      <p class="card-desc">The nb bridge is a small Node runtime Adom Bridge keeps in its cache on each desktop and updates from the wiki release. Refresh asks Bridge to re-check the published release, swap in a newer runtime if there is one, and restart the bridge process; the extension reconnects on its own within a few seconds. This runs on every connected desktop and streams what each one said.</p>
      <div class="setting-row"><span class="row-label" id="bridge-rows">Connected desktops</span><button class="btn" id="btn-refresh" onclick="refreshBridge(this)">Refresh bridge</button></div>
      <pre class="console" id="console-refresh" hidden></pre>
    </div>
    <footer class="settings-footer">
      <p class="footer-note">Settings are stored on each desktop in <code>~/.adom/native-browser/settings.json</code> and applied to every desktop connected right now. Restoring returns every option above to its default.</p>
      <button class="restore-btn" id="restore" onclick="restoreDefaults(this)">Restore defaults</button>
    </footer>
  </div>
</dialog>
<script src="newbie-panel.js"></script>
<script>
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>'
  + '<div class="gain">fill forms, download behind logins, and clean up after itself</div>'
  + '<div class="gain">screenshot pages in the background while you keep working</div>'
  + '<div class="gain">show a gliding cursor that narrates what it is doing</div>'
  + '<p><strong>Installing takes your AI, not a download.</strong> There is no store listing yet, so the nbe installs in developer mode - and your AI drives that whole flow: loads the extension, wires the secure native host, verifies the connection. About a minute; you approve one folder picker.</p>'
  + '<div class="prompt"><code id="pr">install the adom native browser extension in my chrome</code><button onclick="copyPrompt()">Copy</button></div>'
  + '<p class="hintline">Paste that into any Adom AI thread to start.</p>';
function copyPrompt(){ try { navigator.clipboard.writeText(document.getElementById('pr').textContent); } catch(e){} }
function writeClipboard(text){
  // navigator.clipboard hangs on an UNFOCUSED document ("document is not focused" - parts-search
  // 1.1.2): synchronous hasFocus() routes to the in-gesture execCommand fallback; the async path
  // races an 800ms timer so a silent hang still resolves.
  return new Promise((resolve) => {
    if (!document.hasFocus()) { resolve(fallbackCopy(text)); return; }
    let done = false;
    const settle = (v) => { if (!done) { done = true; resolve(v); } };
    setTimeout(() => settle(fallbackCopy(text)), 800);
    try { navigator.clipboard.writeText(text).then(() => settle(true), () => settle(fallbackCopy(text))); }
    catch(e) { settle(fallbackCopy(text)); }
  });
}
function fallbackCopy(text){
  try {
    const ta = document.createElement('textarea');
    ta.value = text; ta.setAttribute('readonly',''); ta.style.position='fixed'; ta.style.left='-9999px';
    document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); ta.remove(); return ok;
  } catch(e){ return false; }
}
let _toastT = null;
function toast(msg, ok){
  let t = document.getElementById('toast');
  if (!t) { t = document.createElement('div'); t.id='toast'; document.body.appendChild(t); }
  t.className = 'toast ' + (ok ? 'ok' : 'warn');
  t.innerHTML = '<span class="toast-ico">' + (ok ? '&#10003;' : '&#9888;') + '</span>' + msg;
  t.classList.add('show');
  clearTimeout(_toastT); _toastT = setTimeout(() => t.classList.remove('show'), 4000);
}
// ---- the shared newbie panel ------------------------------------------------
// I own only: my phrases, my noun, and what the demo button does. Everything else - the card,
// the table, both icons, the clipboard path, the send path with its version gate and
// three-state verification, the toasts, the tooltips, the X and the header toggle, and the
// open/closed memory - lives in the module, so a fix there reaches every dashboard at once.
async function mountNewbie(){
  let phrases = [];
  try { phrases = (await (await fetch('api/say')).json()).say || []; } catch(e) {}
  window.__say = phrases;
  if (!window.AdomNewbiePanel) { console.warn('newbie panel module did not load'); return; }
  AdomNewbiePanel.mount({
    mount: '#newbie',
    app: 'nb',                       // the noun the panel teaches
    appName: 'NB Dashboard',
    slug: 'adom-nb-bridge',          // dock: tag rides this - matches my card id
    apiBase: '',                     // same origin: this backend proxies /updater/status and
                                     // /ai-threads/run-prompt, so the DYNAMIC control port stays server-side
    toggleButton: '#help',
    demo: { label: 'Play the demo', meta: '5 steps, about a minute', onPlay: demoStart },
    phrases,
  });
}
// ---- graduation bookkeeping (the module owns the panel's own open/closed memory) ----
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 {} }
function applyGrad(){}
let _launchCounted = false;
function countLaunch(state){
  if (_launchCounted || state !== 'ok') return;   // a launch is successful when the row goes green
  _launchCounted = true;
  gradSave({ launches: (gradState().launches || 0) + 1 });
  applyGrad();
}
// ---- guided demo (dash-demo): page paces steps; backend runs actions + ab captions ----
let _demo = { steps: [], i: -1, playing: false, muted: false, timer: null, actionDone: false, audioDone: false, minDone: false };
try { _demo.muted = localStorage.getItem('adom-demo-muted') === '1'; } catch {}
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);
}
function demoStepDone(){
  if (_demo.actionDone && _demo.audioDone && _demo.minDone && _demo.playing) demoAdvance(1);
}
async function demoRun(idx){
  const st = _demo.steps[idx]; if (!st) { demoExit(); return; }
  _demo.i = idx; _demo.actionDone = false; _demo.audioDone = false; _demo.minDone = false;
  document.getElementById('demo-stepno').textContent = 'step ' + (idx+1) + '/' + _demo.steps.length;
  const au = document.getElementById('demo-audio');
  au.pause(); au.src = st.audio; au.muted = _demo.muted; au.currentTime = 0;
  au.onended = () => { _demo.audioDone = true; demoStepDone(); };
  au.play().catch(() => { _demo.audioDone = true; });   // autoplay refusal must not wedge the demo
  clearTimeout(_demo.timer); _demo.timer = setTimeout(() => { _demo.minDone = true; demoStepDone(); }, st.min_ms || 4000);
  try { const r = await fetch('api/demo/step', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ id: st.id }) }).then(x=>x.json());
    if (r && r.ok === false && r.why) toast(r.why, false);
  } catch(e){}
  _demo.actionDone = true; demoStepDone();
}
function demoAdvance(dir){
  const next = _demo.i + dir;
  if (next >= _demo.steps.length) { demoExit(); return; }
  if (next < 0) return;
  demoRun(next);
}
function demoToggle(){
  _demo.playing = !_demo.playing;
  const au = document.getElementById('demo-audio');
  if (_demo.playing) { au.play().catch(()=>{}); document.getElementById('demo-play').textContent = '\u23F8'; demoStepDone(); }
  else { au.pause(); document.getElementById('demo-play').textContent = '\u25B6'; }
}
function demoPrev(){ demoAdvance(-1); }
function demoNext(){ demoAdvance(1); }
function demoMute(){
  _demo.muted = !_demo.muted;
  document.getElementById('demo-audio').muted = _demo.muted;
  document.getElementById('demo-mute').textContent = _demo.muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';
  try { localStorage.setItem('adom-demo-muted', _demo.muted ? '1' : '0'); } catch {}
}
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 demo'; }
  _demo.playing = false; _demo.i = -1;
  try { await fetch('api/demo/exit', { method:'POST' }); } catch {}
}
mountNewbie();
// ---- settings modal ----// ---- settings modal ----
const dlg = document.getElementById('settings');
dlg.addEventListener('click', (e) => {   // click OUTSIDE closes; a click on the dialog's own padding does not
  if (e.target !== dlg) return;
  const r = dlg.getBoundingClientRect();
  const inside = e.clientX >= r.left && e.clientX <= r.right && e.clientY >= r.top && e.clientY <= r.bottom;
  if (!inside) dlg.close();
});
function openSettings(){
  dlg.showModal(); dlg.focus();
  const body = document.getElementById('settings-body'); body.scrollTop = 0; requestAnimationFrame(() => { body.scrollTop = 0; });
  sizeShots(); loadSettings();
}
function sizeShots(){ // render at device pixels: naturalWidth / devicePixelRatio, so the picture is the real physical size
  for (const img of dlg.querySelectorAll('img')) { const f = () => { if (img.naturalWidth) { img.style.width = (img.naturalWidth / devicePixelRatio) + 'px'; } }; if (img.complete) f(); else img.onload = f; }
}
let _settingsPer = [];
function paintSettings(per){
  _settingsPer = per || [];
  const live = _settingsPer.filter(p => p.settings);
  const agg = (k) => { const vals = [...new Set(live.map(p => String(p.settings[k])))]; return vals.length === 1 ? vals[0] : (vals.length ? 'mixed' : 'unknown'); };
  const ov = agg('overlayBadge'), ac = agg('taskbarActivity'), fm = agg('flashMode'), ti = agg('toolbarIcon'), ws = agg('newWindowSize');
  const selS = document.getElementById('sel-size'); if (selS) selS.value = ['fill','95','90','75','chrome'].includes(ws) ? ws : 'fill';
  const stS = document.getElementById('st-size'); if (stS) { stS.textContent = ws === 'chrome' ? 'Chrome default' : ws === 'fill' ? 'fill' : (ws + '%'); stS.className = 'card-status' + (ws === 'fill' ? ' ok' : ''); }
  const selI = document.getElementById('sel-icon'); if (selI) selI.value = ['auto','on','off'].includes(ti) ? ti : 'auto';
  const stI = document.getElementById('st-icon'); if (stI) { stI.textContent = ({auto:'auto', on:'on', off:'off'})[ti] || ti; stI.className = 'card-status' + (ti === 'auto' ? ' ok' : ''); }
  const tg = document.getElementById('tg-overlay'); tg.setAttribute('aria-checked', ov === 'true' ? 'true' : 'false');
  const stO = document.getElementById('st-overlay'); stO.textContent = ov === 'true' ? 'on' : ov === 'false' ? 'off' : ov; stO.className = 'card-status' + (ov === 'true' ? ' ok' : '');
  for (const c of document.querySelectorAll('#choice-activity .choice')) { const on = c.dataset.value === ac; c.dataset.selected = on ? 'true' : 'false'; c.querySelector('input').checked = on; }
  const stA = document.getElementById('st-activity'); stA.textContent = ({progress:'Progress bar', flash:'Orange flash', off:'Off'})[ac] || ac; stA.className = 'card-status' + (ac === 'progress' ? ' ok' : '');
  document.getElementById('sel-flash').value = ['quiet','chatty','off'].includes(fm) ? fm : 'quiet';
  const stF = document.getElementById('st-flash'); stF.textContent = fm; stF.className = 'card-status' + (fm === 'quiet' ? ' ok' : '');
  document.getElementById('st-bridge').textContent = _settingsPer.map(p => p.target + (p.settings ? ' ok' : ' no answer')).join(' · ');
  document.getElementById('bridge-rows').textContent = 'Connected desktops: ' + (_settingsPer.map(p => p.target).join(', ') || 'none');
}
async function loadSettings(){
  try { const r = await fetch('api/settings'); const d = await r.json(); paintSettings(d.per); } catch(e) {}
}
async function setSetting(key, value){
  // optimistic: reflect the click now, then read the STORED literal back from every desktop
  if (key === 'overlayBadge') document.getElementById('tg-overlay').setAttribute('aria-checked', value ? 'true' : 'false');
  if (key === 'taskbarActivity') for (const c of document.querySelectorAll('#choice-activity .choice')) c.dataset.selected = (c.dataset.value === value) ? 'true' : 'false';
  const ctrl = new AbortController(); const t = setTimeout(() => ctrl.abort(), 5000);
  try {
    const r = await fetch('api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ set: { [key]: value } }), signal: ctrl.signal });
    const d = await r.json(); paintSettings(d.per);
  } catch(e) { console.warn('settings save did not complete', e); loadSettings(); }
  clearTimeout(t);
}
async function refreshBridge(btn){
  const out = document.getElementById('console-refresh'); out.hidden = false; out.textContent = ''; btn.disabled = true;
  try {
    const r = await fetch('api/action/refresh-bridge', { method:'POST' });
    const rd = r.body.getReader(); const dec = new TextDecoder();
    while (true) { const { value, done } = await rd.read(); if (done) break; out.textContent += dec.decode(value); out.scrollTop = out.scrollHeight; }
  } catch(e) { out.textContent += '\\nerror: ' + e.message; }
  btn.disabled = false; loadSettings();
}
let _armed = 0;
async function restoreDefaults(btn){
  if (!_armed) { _armed = setTimeout(() => { _armed = 0; btn.classList.remove('armed'); btn.textContent = 'Restore defaults'; }, 4000); btn.classList.add('armed'); btn.textContent = 'Click again to confirm'; return; }
  clearTimeout(_armed); _armed = 0; btn.classList.remove('armed'); btn.textContent = 'Restore defaults';
  const r = await fetch('api/settings', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ set: { overlayBadge: true, taskbarActivity: 'progress', flashMode: 'quiet', toolbarIcon: 'auto', newWindowSize: 'fill' } }) });
  const d = await r.json(); paintSettings(d.per);
}
function copyPrompt2(btn){ try { navigator.clipboard.writeText(document.getElementById('pr2').textContent); btn.textContent = 'Copied!'; } catch(e){} }
function installPrompt(i){
  const r = (window.__matrix||[])[i]; if (!r) return;
  const p = 'install the adom native browser extension in my ' + r.browser + ' on ' + r.machine;
  const box = document.getElementById('install-explain');
  box.innerHTML = '<div class="explain">'
    + '<p><strong>Installing the nbe takes your AI, not a download.</strong> There is no store listing yet '
    + '(the Chrome/Edge review queue takes a while for an extension with these permissions), so it installs '
    + 'in developer mode - and your AI drives that whole flow for you: it loads the extension into <strong>'
    + r.browser + ' on ' + r.machine + '</strong>, wires the secure native-messaging host, and verifies the '
    + 'connection. Takes about a minute; you approve one folder picker.</p>'
    + '<p>Paste this to any Adom AI thread:</p>'
    + '<div class="prompt"><code id="pr2">' + p + '</code>'
    + '<button onclick="copyPrompt2(this)">Copy</button></div>'
    + '</div>';
  box.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function led(el, s){ el.className = 'led ' + (s||'unknown'); }
async function tick(){
  try {
    const r = await fetch('api/status'); const d = await r.json();
    renderPills(d); renderChips(d); renderPop(d);
    countLaunch(d.state);
    if (d.aiDriven && !gradState().aiDriven) { gradSave({ aiDriven: true }); applyGrad(); }
    const f = {}; for (const x of d.facets) f[x.label] = x;
    const l1 = f['Layer 1 - Native Browser'] || {}; const l2 = f['Layer 2 - nbe (extension)'] || {};
    led(document.getElementById('led-l1'), l1.state); led(document.getElementById('led-l2'), l2.state);
    document.getElementById('l1-detail').textContent = l1.detail || '';
    const body = document.getElementById('l2-body');
    // ?preview=upsell forces the upsell rendering (clearly badged) so a verifier can see the card's
    // whole point without tearing down a healthy fleet. Preview affects THIS page only, never /api/status.
    const preview = new URLSearchParams(location.search).get('preview') === 'upsell';
    if (preview) {
      led(document.getElementById('led-l2'), 'warn');
      body.innerHTML = '<p style="color:var(--amber);font-size:11px;text-transform:uppercase;letter-spacing:.1em">Preview mode - not the live state</p>' + UPSELL;
    } else {
      const m = d.matrix || [];
      let rows = '';
      for (let i = 0; i < m.length; i++) {
        const r = m[i];
        const who = r.email || r.profile;
        if (r.state === 'connected')
          rows += '<tr><td>'+r.machine+'</td><td>'+r.browser+'</td><td>'+who+'</td><td><span class="led ok"></span> live'+(r.version?' v'+r.version:'')+'</td></tr>';
        else if (r.state === 'asleep')
          rows += '<tr><td>'+r.machine+'</td><td>'+r.browser+'</td><td>'+who+'</td><td><span class="led ok" style="opacity:.45"></span> installed, wakes on demand</td></tr>';
        else if (r.state === 'disabled')
          rows += '<tr class="off"><td>'+r.machine+'</td><td>'+r.browser+'</td><td>'+who+'</td><td><span class="led unknown"></span> disabled by you - nb will not drive this browser</td></tr>';
        else
          rows += '<tr><td>'+r.machine+'</td><td>'+r.browser+'</td><td>'+who+'</td><td><button class="mini" onclick="installPrompt('+i+')">Install&hellip;</button></td></tr>';
      }
      const anyLive = m.some(r => r.state === 'connected');
      const anyMissing = m.some(r => r.state === 'missing');
      body.innerHTML =
        (anyLive ? '<p><strong>Connected.</strong> Full driving is live: trusted clicks, logins, background capture, downloads behind auth.</p>'
                 : anyMissing ? UPSELL : '<p>Per-browser status:</p>')
        + (rows ? '<div class="tblwrap"><table><tr><th>Machine</th><th>Browser</th><th>Profile</th><th>nbe</th></tr>'+rows+'</table></div>' : '')
        + (anyLive && anyMissing ? '<p class="hintline">Browsers without nbe have an Install button - it explains the AI-driven install and hands you the prompt.</p>' : '')
        + '<div id="install-explain"></div>';
      window.__matrix = m;
    }
  } catch(e) {}
}
// ---- status pills (status-leds): dot + label + short state; prose only on tap; stuck? past 15 min ----
const PILL_LABELS = { 'Adom Bridge': 'Bridge', 'Layer 1 - Native Browser': 'Layer 1 nb', 'Layer 2 - nbe (extension)': 'Layer 2 nbe' };
function pillState(f){
  if (f.state === 'ok') return 'ok';
  if (f.state === 'warn') return (f.since && Date.now() - f.since > 15*60*1000) ? 'busy-stuck' : 'busy';
  return 'bad';
}
function shortState(f, ps){
  if (ps === 'busy-stuck') return 'stuck?';
  const d = String(f.detail || '');
  return d.length > 34 ? d.slice(0, 32) + '\u2026' : d || f.state;
}
let _facets = [];
function renderPills(d){
  _facets = [{ label: 'Dashboard', state: 'ok', detail: 'v' + d.version + ' serving', _hint: 'This page and its /api/status are served by nb-dashboard on 127.0.0.1:8954.' }, ...(d.facets || [])];
  document.getElementById('leds').innerHTML = _facets.map((f, i) => {
    const ps = pillState(f);
    return '<button class="led-pill" data-state="' + (ps === 'busy-stuck' ? 'busy' : ps) + '" onclick="pillPop(' + i + ',this)"><span class="dot"></span>'
      + (PILL_LABELS[f.label] || f.label) + '<span class="st">' + shortState(f, ps) + '</span></button>';
  }).join('');
}
function pillPop(i, el){
  const f = _facets[i]; const pop = document.getElementById('led-pop');
  if (!pop.hidden && pop.dataset.i == i) { pop.hidden = true; return; }
  const stuck = pillState(f) === 'busy-stuck';
  pop.innerHTML = '<strong>' + f.label + '</strong><div>' + (f.detail || '') + '</div>'
    + (f._hint ? '<div class="hint">' + f._hint + '</div>' : '')
    + (stuck ? '<div class="hint">This has been in this state over 15 minutes - likely stuck. Refresh the bridge from Settings, or ask your AI: <code>check nb readiness</code>.</div>' : '')
    + (f.since ? '<div class="hint">in this state since ' + new Date(f.since).toLocaleTimeString() + '</div>' : '');
  pop.dataset.i = i; pop.hidden = false;
  const r = el.getBoundingClientRect();
  pop.style.left = Math.min(r.left, innerWidth - 440) + 'px'; pop.style.top = (r.bottom + 8) + 'px';
}
document.addEventListener('click', (e) => { const pop = document.getElementById('led-pop'); if (!pop.hidden && !pop.contains(e.target) && !e.target.closest('.led-pill')) pop.hidden = true; });
// ---- header: target chips + version popover content ----
function renderChips(d){
  document.getElementById('target-chips').innerHTML = (d.targets || []).map((t) =>
    '<span class="tchip"><span class="dot"' + (t.state ? '' : ' style="background:var(--led-red)"') + '></span>target: ' + t.name + '</span>').join('');
}
function renderPop(d){
  document.getElementById('pop-boxes').innerHTML = (d.targets || []).map((t) => {
    const vs = Object.entries(t.extensionVersions || {}).map(([p, v]) => p.split(':')[0] + ' v' + v).join(', ');
    return '<div class="pop-row"><span>' + t.name + '</span><span>' + (vs || 'nb bridge') + '</span></div>';
  }).join('');
}
tick(); setInterval(tick, 5000);
</script>
</body></html>`;
}

// ---------- server ----------
function startServer() {
  // CORS on every /api/* route: the dock's health probe is a cross-origin fetch from the Hydrogen
  // shell page, so without Access-Control-Allow-Origin the browser blocks it and the card LED reads
  // red "not answering" while curl happily gets a 200 (ConfRoomROG launch run, 2026-08-23).
  const CORS = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type' };
  const srv = http.createServer((req, res) => {
    const u = String(req.url || '/');
    // Preflight for /api/*, AND for the two PROXIED control routes. The module's checkSend probes
    // the send route with OPTIONS, and until 0.15.1 the page catch-all answered it with HTML 200,
    // so the 'send route wired' row passed for the wrong reason. A real 204 makes it pass for the
    // right one, and is what a cross-origin caller needs anyway (2026-08-25).
    // Preflight ONLY for paths that exist (control-api-proxy trap 1): a blanket 204 makes checkSend's
    // "send route wired" row read 204/204 for a real route and a nonsense path alike, which is a bug in
    // the proxy, not the check. Exact paths for the three proxied routes; /api/* stays a prefix because
    // every /api route is ours.
    const uPath = u.split('?')[0];
    if (req.method === 'OPTIONS' && (uPath.startsWith('/api/') || ['/updater/status', '/ai-threads/run-prompt', '/ai-threads/run-prompt/status'].includes(uPath))) {
      res.writeHead(204, CORS); res.end(); return;
    }
    if (u.startsWith('/api/status')) {
      res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...CORS });
      res.end(JSON.stringify({ state: cache.state, version: VERSION, build: BUILD, facets: cache.facets, targets: cache.targets, matrix: cache.matrix || [], aiDriven: !!cache.aiDriven, cacheAgeMs: Date.now() - cache.at }));
      return;
    }
    if (u.startsWith('/icon.svg')) {
      try { const svg = fs.readFileSync(path.join(__dirname, '..', 'docs', 'img', 'dock-nb.svg')); res.writeHead(200, { 'Content-Type': 'image/svg+xml', 'Cache-Control': 'max-age=3600' }); res.end(svg); }
      catch { res.writeHead(404); res.end(); }
      return;
    }
    if (u.includes('/assets/settings/')) {
      // by SUFFIX, so it also works behind the proxy sub-path
      const nm = u.split('/assets/settings/')[1].split('?')[0].replace(/[^a-z0-9._-]/gi, '');
      try { const png = fs.readFileSync(path.join(__dirname, '..', 'docs', 'img', 'settings', nm)); res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=600' }); res.end(png); }
      catch { res.writeHead(404); res.end(); }
      return;
    }
    if (u.startsWith('/api/settings') && req.method === 'GET') {
      (async () => {
        const per = [];
        for (const t of (cache.targets || [])) {
          const r = await cli(['--target', t.name, '--ai-thread', 'nb-dashboard', 'nbrowser_settings', '{}']);
          per.push({ target: t.name, settings: r.settings || null, defaults: r.defaults || null, enums: r.enums || null, error: r.settings ? null : (r.error || r._err || 'no answer') });
        }
        res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...CORS });
        res.end(JSON.stringify({ ok: true, per }));
      })();
      return;
    }
    if (u.startsWith('/api/settings') && req.method === 'POST') {
      let b = ''; req.on('data', (c) => (b += c)); req.on('end', async () => {
        let set = {}; try { set = JSON.parse(b).set || {}; } catch {}
        // settings are PER DESKTOP: apply to every connected one, return each STORED literal
        const per = [];
        for (const t of (cache.targets || [])) {
          const r = await cli(['--target', t.name, '--ai-thread', 'nb-dashboard', 'nbrowser_settings', JSON.stringify({ set })]);
          per.push({ target: t.name, settings: r.settings || null, changed: r.changed || [], error: r.settings && r.ok !== false ? null : (r.error || r._err || 'no answer') });
        }
        res.writeHead(200, { 'Content-Type': 'application/json', ...CORS }); res.end(JSON.stringify({ ok: per.every((p) => !p.error), per }));
      });
      return;
    }
    if (u.startsWith('/api/action/refresh-bridge') && req.method === 'POST') {
      // LIVE CONSOLE: chunked text, one line per step, exit line, then a verify line
      res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', ...CORS });
      (async () => {
        for (const t of (cache.targets || [])) {
          res.write(`$ adom-bridge --target ${t.name} refresh_bridges {"name":"native-browser"}\n`);
          const r = await cli(['--target', t.name, '--ai-thread', 'nb-dashboard', 'refresh_bridges', '{"name":"native-browser"}'], 60000);
          res.write(`  ${r.status || (r._err ? 'error' : 'ok')} ${r._err || ''}\n`);
          await new Promise((k) => setTimeout(k, 4000));
          const rd = await cli(['--target', t.name, '--ai-thread', 'nb-dashboard', 'nbrowser_readiness', '{}']);
          res.write(`  verify: state=${rd.state || '?'} expectedExtension=${rd.expectedExtensionVersion || '?'}\n`);
        }
        res.write('done\n'); res.end();
        refresh().catch(() => {});
      })();
      return;
    }
    if (u.startsWith('/newbie-panel.js')) {
      // The SHARED module, served byte-for-byte as published. Never reformatted, never
      // "fixed" - its regexes are built with RegExp() on purpose (a page assembled as a
      // template literal turns \b into a backspace and eats word boundaries silently).
      try { const js = fs.readFileSync(path.join(__dirname, '..', 'vendor', 'newbie-panel.js'));
        res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8', 'Cache-Control': 'max-age=300', ...CORS }); res.end(js); }
      catch { res.writeHead(404); res.end(); }
      return;
    }
    // The module talks to the AI panel from the PAGE, so these two paths proxy through this
    // backend: same-origin for the page, and the control URL (a DYNAMIC port) stays server-side.
    // CONTROL API PROXY (control-api-proxy skill): three routes, forwarded RAW. Status code, content-type
    // and body BYTES pass through untouched (a JSON round trip reorders keys, drops 1.0 to 1, loses
    // precision past 2^53, and turns a non-JSON body into an invented error while every probe stays
    // green). The query string travels intact: /ai-threads/run-prompt/status is called as ?job=<uuid>
    // and a proxy that forwards only the path answers about no job at all (Hydrogen 1.0.187+, newbie-panel
    // 2.22.0). When ah is absent the answer is 503 WITH A HINT, never 404: 404 means "not wired".
    if ((u.startsWith('/updater/status') && req.method === 'GET') ||
        (u.startsWith('/ai-threads/run-prompt/status') && req.method === 'GET') ||
        (u.startsWith('/ai-threads/run-prompt') && !u.startsWith('/ai-threads/run-prompt/') && req.method === 'POST')) {
      let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => proxyRaw(req.method, u, sendBodyForPicker(u, req.method, b), res));
      return;
    }
    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 = phrase ? await saySend(phrase) : { ok: false, why: 'phrase required' };
        res.writeHead(200, { 'Content-Type': 'application/json', ...CORS }); res.end(JSON.stringify(out));
      });
      return;
    }
    if (u.startsWith('/api/say')) {
      res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...CORS });
      res.end(JSON.stringify({ say: sayPhrases() }));
      return;
    }
    // demo engine: the page drives steps; ACTIONS run here (bridge verbs), captions are ab captions at
    // the bridge DEFAULT size/position (text only - the fleet-consistency ruling), audio ships as
    // pre-created adom-tts mp3s under demo/audio/.
    if (u.includes('/demo/audio/')) {
      const nm = u.split('/demo/audio/')[1].split('?')[0].replace(/[^a-z0-9._-]/gi, '');
      try { const mp3 = fs.readFileSync(path.join(__dirname, '..', 'demo', 'audio', nm)); res.writeHead(200, { 'Content-Type': 'audio/mpeg', 'Cache-Control': 'max-age=600', ...CORS }); res.end(mp3); }
      catch { res.writeHead(404); res.end(); }
      return;
    }
    if (u.startsWith('/api/demo/step') && req.method === 'POST') {
      let b = ''; req.on('data', (c) => (b += c)); req.on('end', async () => {
        let id = ''; try { id = String(JSON.parse(b).id || ''); } catch {}
        const out = await demoStep(id);
        res.writeHead(200, { 'Content-Type': 'application/json', ...CORS }); res.end(JSON.stringify(out));
      });
      return;
    }
    if (u.startsWith('/api/demo/exit') && req.method === 'POST') {
      demoExit().then((out) => { res.writeHead(200, { 'Content-Type': 'application/json', ...CORS }); res.end(JSON.stringify(out)); });
      return;
    }
    if (u.startsWith('/api/demo')) {
      res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...CORS });
      res.end(JSON.stringify({ steps: DEMO_STEPS.map(({ id, caption, audio, min_ms }) => ({ id, caption, audio, min_ms })) }));
      return;
    }
    if (u.startsWith('/api/health')) {   // status-leds: the dock and the pill row read ONE truth
      res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...CORS });
      res.end(JSON.stringify({ state: cache.state, version: VERSION, build: BUILD, facets: cache.facets, cacheAgeMs: Date.now() - cache.at }));
      return;
    }
    if (u.startsWith('/api/shutdown') && req.method === 'POST') {
      res.writeHead(200, { 'Content-Type': 'application/json', ...CORS });
      res.end(JSON.stringify({ ok: true, bye: VERSION }));
      setTimeout(() => process.exit(0), 150);
      return;
    }
    // 404 EVERYTHING ELSE. This used to serve page() as a catch-all, which meant a missing
    // settings PNG, demo mp3 or favicon came back as HTML 200 - and the ONE thing the launch
    // console watches for is 404s on those assets, so the catch-all hid the exact failure the
    // watch exists to catch. proxy-fidelity row 3 wants YOUR 404 here (2026-08-25).
    if (u === '/' || u === '/index.html') {
      res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
      res.end(page());
      return;
    }
    res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
    res.end(JSON.stringify({ ok: false, error: 'not found', path: u }));
  });
  srv.listen(PORT, '127.0.0.1', () => {
    // WEBVIEW OWNERSHIP: the dock told us which tab it pre-opened; take it over. Skip when unset.
    const tab = process.env.ADOM_DOCK_WEBVIEW;
    if (tab) {
      execFile('adom-cli', ['hydrogen', 'webview', 'open-or-refresh', '--name', tab, '--url', URL_SELF], { timeout: 15000 }, () => {});
    }
    refresh().catch(() => {});
    setInterval(() => refresh().catch(() => {}), 12000);
  });
  srv.on('error', (e) => { process.stderr.write(`nb-dashboard: ${e.message}\n`); process.exit(1); });
}

function probeExisting() {
  return new Promise((resolve) => {
    const req = http.get({ host: '127.0.0.1', port: PORT, path: '/api/status', timeout: 1500 }, (res) => {
      let b = ''; res.on('data', (c) => (b += c)); res.on('end', () => { try { resolve(JSON.parse(b)); } catch { resolve(null); } });
    });
    req.on('error', () => resolve(null));
    req.on('timeout', () => { req.destroy(); resolve(null); });
  });
}

async function serve(printUrl) {
  const existing = await probeExisting();
  if (existing && existing.version === VERSION && existing.build === BUILD) {
    if (printUrl) process.stdout.write(URL_SELF + '\n');
    process.exit(0);
  }
  if (existing) {
    // Version-blind reuse once pinned a bridge's users to a stale server - restart instead.
    await new Promise((resolve) => {
      const req = http.request({ host: '127.0.0.1', port: PORT, path: '/api/shutdown', method: 'POST', timeout: 2000 }, () => resolve());
      req.on('error', () => resolve()); req.on('timeout', () => { req.destroy(); resolve(); }); req.end();
    });
    await new Promise((r) => setTimeout(r, 500));
  }
  // Self-background: fork a detached child that runs the real server, print the URL, exit 0.
  const child = spawn(process.execPath, [__filename, '__run'], { detached: true, stdio: 'ignore', env: process.env });
  child.unref();
  // Wait for it to answer so the dock's readiness probe cannot race a slow start.
  for (let i = 0; i < 40; i++) {
    await new Promise((r) => setTimeout(r, 250));
    const up = await probeExisting();
    if (up && up.version === VERSION && up.build === BUILD) { if (printUrl) process.stdout.write(URL_SELF + '\n'); process.exit(0); }
  }
  process.stderr.write('nb-dashboard: server did not come up\n');
  process.exit(1);
}

const cmd = process.argv[2] || 'serve';
if (cmd === '__run') startServer();
else if (cmd === 'serve') serve(process.argv.includes('--print-url'));
else if (cmd === 'status') probeExisting().then((d) => { console.log(JSON.stringify(d || { down: true }, null, 2)); process.exit(d ? 0 : 1); });
else if (cmd === 'stop') {
  const req = http.request({ host: '127.0.0.1', port: PORT, path: '/api/shutdown', method: 'POST' }, () => { console.log('stopped'); process.exit(0); });
  req.on('error', () => { console.log('not running'); process.exit(0); }); req.end();
} else if (cmd === 'selfcheck') {
  // Ship gate: the page's inline script must PARSE (a quoting slip once shipped a blank dashboard).
  const vm = require('vm');
  const m = /<script>([\s\S]*?)<\/script>/.exec(page());
  try { new vm.Script(m ? m[1] : ''); console.log('selfcheck ok: inline script parses; build ' + BUILD); process.exit(0); }
  catch (e) { console.error('selfcheck FAILED: inline script does not parse: ' + e.message); process.exit(1); }
}
else if (cmd === '--version' || cmd === 'version') { console.log(VERSION); process.exit(0); }
else { console.log('usage: nb-dashboard serve [--print-url] | status | stop | version'); process.exit(1); }