← Commit history

rust: Phase 2 part 1 (tabs, input, dialogs, viewport, history, fetch, render_html, maximize; event pump; recovery + health loop; raise/lower/alert/progress; configure/use/quit_idle/rescan); measured on winvm incl. kill+restart recovery

John Lauer ·6da42345af ·1mo ago ·parent 71c0e87
13 files changed +2137−19
rust/bridge/src/main.rs+3−7
@@ -40,7 +40,7 @@ fn main() {     let ab: Arc<dyn pup_core::AbClient> = match Loopback::from_env() { Some(l) => Arc::new(l), None => { pup_core::log("[ab] ADOM_DIRECT_API_URL not set: desktop verbs degrade (hand-launched exe?)"); Arc::new(NoAb) } };     let ctx = Arc::new(Ctx {         host: h.clone(), ab, settings: Arc::new(Settings::new(&state_dir)), installer: Arc::new(pup_browser::Installer::new()),-        sessions: Arc::new(pup_session::Sessions::new(&state_dir.join("pup-sessions"))), browsers: Mutex::new(Default::default()),+        sessions: Arc::new(pup_session::Sessions::new(&state_dir.join("pup-sessions"))), browsers: Mutex::new(Default::default()), tabstate: Ctx::new_tabstate(),         bridge_dir, port, started: Instant::now(), ad_version: Mutex::new(None),     }); @@ -62,12 +62,8 @@ fn main() {                 else if cands.is_empty() { pup_core::log("[warmup] no installed Chromium on this box — background-fetching Chrome for Testing as the last-resort browser"); }                 if c.prefer_cft() || cands.is_empty() { c.installer.ensure_background(c.host.clone()); }             }-            // Stale session files (Phase 1: drop files whose browser pid is dead; Phase 2 recovers live ones).-            for f in c.sessions.read_files() {-                let id = f["sessionId"].as_str().unwrap_or("").to_string();-                let pid = f["pid"].as_u64().unwrap_or(0) as u32;-                if !id.is_empty() && pid > 0 && !c.host.alive(pid) { pup_core::log(format!("Session \"{id}\" (PID {pid}) is dead — removing stale file")); c.sessions.delete_file(&id); }-            }+            pup_verbs::recovery::recover_at_boot(&c);+            pup_verbs::recovery::start_health_loop(c.clone());             if let Ok(v) = c.ab.call("bridge_info", json!({}), &Caller::default(), Duration::from_secs(4)) { *c.ad_version.lock().unwrap() = v.get("version").and_then(|x| x.as_str()).map(|s| s.to_string()); }         });     }
rust/cdp/src/lib.rs+6−1
@@ -164,6 +164,7 @@ impl Cdp {      pub fn page_enable(&self, session: &str) -> Result<(), CdpError> {         self.send(Some(session), "Page.enable", json!({}), Duration::from_secs(10)).map(|_| ())?;+        let _ = self.send(Some(session), "Emulation.setFocusEmulationEnabled", json!({ "enabled": true }), Duration::from_secs(5));   // a background page must still take keystrokes (puppeteer sets this on every page)         self.send(Some(session), "Runtime.enable", json!({}), Duration::from_secs(10)).map(|_| ())     } @@ -181,7 +182,11 @@ impl Cdp {     /// Runtime.evaluate with the devtools-console semantics the Node build used: expression     /// first (returnByValue, awaitPromise), the error surfaced as `__error` like pup_eval did.     pub fn evaluate(&self, session: &str, expression: &str, timeout: Duration) -> Result<Value, CdpError> {-        let r = self.send(Some(session), "Runtime.evaluate", json!({ "expression": expression, "returnByValue": true, "awaitPromise": true, "userGesture": true, "replMode": true }), timeout)?;+        // Expression form first (awaitPromise resolves an async IIFE); a SyntaxError means the caller+        // wrote STATEMENTS, so retry in replMode (devtools-console semantics), like pup_eval did.+        let mut r = self.send(Some(session), "Runtime.evaluate", json!({ "expression": expression, "returnByValue": true, "awaitPromise": true, "userGesture": true }), timeout)?;+        let is_syntax = r.get("exceptionDetails").map(|ex| ex.get("exception").and_then(|e| e.get("description")).and_then(|d| d.as_str()).unwrap_or("").starts_with("SyntaxError") || ex.get("text").and_then(|t| t.as_str()).unwrap_or("").contains("SyntaxError")).unwrap_or(false);+        if is_syntax { r = self.send(Some(session), "Runtime.evaluate", json!({ "expression": expression, "returnByValue": true, "awaitPromise": true, "userGesture": true, "replMode": true }), timeout)?; }         if let Some(ex) = r.get("exceptionDetails") {             let desc = ex.get("exception").and_then(|e| e.get("description")).and_then(|d| d.as_str()).or_else(|| ex.get("text").and_then(|t| t.as_str())).unwrap_or("evaluation threw");             return Ok(json!({ "__error": desc }));
rust/core/src/lib.rs+2−1
@@ -99,7 +99,8 @@ impl Loopback {             let home = std::env::var("USERPROFILE").or_else(|_| std::env::var("HOME")).unwrap_or_default();             if let Ok(port) = std::fs::read_to_string(Path::new(&home).join(".adom").join("direct-api-port")) {                 let port = port.trim();-                if !port.is_empty() { base = format!("http://127.0.0.1:{port}"); }+                // the file carries either a bare port or host:port (ab 2.1.x writes "127.0.0.1:24680")+                if !port.is_empty() { base = if port.contains(':') { format!("http://{port}") } else { format!("http://127.0.0.1:{port}") }; }             }         }         if base.is_empty() { return None; }
rust/dashboard/assets/dashboard.htmladded+1026
@@ -0,0 +1,1026 @@+<!doctype html><html lang="en"><head>+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">+<title>Pup live windows</title>+<link rel="icon" type="image/svg+xml" href="/dashboard/icon.svg?v=${BRIDGE_VERSION}">+<style>+@import url('https://adom.inc/fonts/fonts.css');+*{box-sizing:border-box} html,body{margin:0;background:#0d1117;color:#c9d1d9;font:14px/1.5 Satoshi,"Segoe UI",system-ui,sans-serif}+/* v2.0.97 (John): brand scrollbars everywhere — the stock chrome bar looked bolted-on in the+   settings dialog. Thin, rounded, track invisible, thumb on the border token. */+::-webkit-scrollbar{width:9px;height:9px}+::-webkit-scrollbar-track{background:transparent}+::-webkit-scrollbar-thumb{background:#30363d;border-radius:8px;border:2px solid #0d1117}+::-webkit-scrollbar-thumb:hover{background:#484f58}+::-webkit-scrollbar-corner{background:transparent}+a{color:#64abff}+header{position:sticky;top:0;z-index:10;background:#161b22;border-bottom:1px solid #30363d;height:44px;padding:0 16px;display:flex;gap:10px;align-items:center}+/* ── DOCK CONTRACT regions ───────────────────────────────────────────────────────────────────+   The newbie panel is NO LONGER hand-rolled here. adom-ui-design 2.0.0 ships it as ONE shared+   module and pup mounts it (see /dashboard/newbie-panel.js). Its CSS, markup, icons, clipboard+   path, send path, toasts, tooltips and open/closed memory all live in the module. Every rule the+   old hand-rolled panel declared is DELETED rather than left beside the module: a stray .btn or+   .ibtn declared after the module's rules is exactly how other apps silently lost their styling to+   source order. What remains here is the status pill row, which is its own contract region. */+/* THE FLEET GUTTER (adom-ui-design 2.4.0). ONE number, declared once, used by the panel's mount+   container AND by every region under it, so they share an edge because there is a single source+   of truth rather than because two literals happen to match. That "by luck rather than by rule"+   distinction was my own observation about this page and it is now a fleet rule.+   pup was full-bleed at ZERO a side, not the ~4px it looked like: #newbie is a direct child of+   body with no padding, so the panel measured exactly clientWidth and the 9px gap from the 744+   pane was entirely the scrollbar. selfCheck could not see it while it only measured inside the+   panel.+   2.5.0 no longer cares WHICH element carries the padding: it finds the nearest scrolling ancestor+   and checks that the panel sits 16px inside it on both sides. The contract is where the edges+   land, not the structure. (Earlier versions named an element and were wrong twice: measuring+   documentElement failed correct app-shell pages by a scrollbar width, and measuring the mount div+   would have red-lined every page that carried the gutter on an outer container.)+   pup stays with the TOKEN rather than nesting, and that is now the fleet ruling for this shape:+   the pill row and filter bar below carry FULL-BLEED bottom borders as separators, and a rule that+   spans the pane reads as a divider between regions, while an inset one reads as a card edge. The+   panel is the only card in that stack, so the separators stay full width and the gutter is shared+   through one token instead. */+:root{--gutter:16px}+#newbie{padding:0 var(--gutter)}+.ledrow{display:flex;flex-wrap:wrap;gap:10px;padding:12px var(--gutter);border-bottom:1px solid #30363d}+.led-pill{display:inline-flex;align-items:center;gap:7px;background:#161b22;border:1px solid #30363d;border-radius:999px;padding:4px 12px;font-size:12.5px;cursor:default}+.led-pill .d{width:8px;height:8px;border-radius:50%;flex:none}+.led-pill .d.ok{background:#3fb950}.led-pill .d.warn{background:#d29922}.led-pill .d.fail{background:#f85149}+.led-pill .lb{font-weight:600;color:#e6edf3}.led-pill .sh{color:#8b949e}+#recall[aria-pressed="true"]{color:#00b8b1;background:#21262d}+/* 620px, NOT 760px (adom-ui-design 1.25.0). Measured dock panes run about 744px to 853px, so a+   760px breakpoint sat INSIDE that band and the two-column panel collapsed in the very place it+   was designed for: John's pane is 744px, so he never once saw the layout this panel specifies.+   620px clears the whole pane band while still collapsing on a phone (390px). */+@media(max-width:620px){.nb-cols{grid-template-columns:1fr}.nb-right{border-left:0;border-top:1px solid #30363d;padding-left:0;padding-top:16px}.play{width:100%;justify-content:center}}+.brand{position:relative;display:flex;align-items:center;gap:7px;color:#00b8b1;font-family:"Familjen Grotesk",Satoshi,sans-serif;font-weight:600;font-size:14px;cursor:default}+.brand .bicon{width:16px;height:16px;color:#e6edf3}+.brand .bsub{color:#8b949e;font-weight:500}+.vsep{width:1px;height:22px;background:#30363d;flex:0 0 1px}+.subject{display:flex;align-items:baseline;gap:10px;min-width:0;flex:1 1 auto;overflow:hidden}+.sname{font-weight:700;color:#e6edf3;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.smeta{color:#8b949e;font-size:12px;font-family:"JetBrains Mono",monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.smeta b{color:#c9d1d9;font-weight:500}+@media(max-width:720px){.smeta{display:none}}+.acts2{margin-left:auto;display:flex;gap:6px}+.ibtn{width:30px;height:30px;display:flex;align-items:center;justify-content:center;background:transparent;border:1px solid #30363d;border-radius:8px;color:#e6edf3;cursor:pointer;transition:background .15s ease}+.ibtn:hover{background:#1c2128;border-color:#00b8b1}+.ibtn.danger:hover{border-color:#f85149;color:#f85149}+.ibtn svg{width:16px;height:16px}+.pop{position:fixed;z-index:99999;background:#21262d;border:1px solid #30363d;border-radius:8px;padding:10px 12px;font-size:12px;color:#c9d1d9;font-weight:400;font-family:Satoshi,sans-serif;display:none;box-shadow:0 8px 24px rgba(0,0,0,.5);pointer-events:auto;line-height:1.7}+.pop.show{display:block}+#bar{display:flex;gap:10px;align-items:center;padding:8px 12px;background:#0d1117;border-bottom:1px solid #30363d;flex-wrap:wrap}+.dot{width:8px;height:8px;border-radius:50%;background:#3fb950;display:inline-block;margin-right:6px}+.dot.stale{background:#d29922}+.pill{background:#111b23;border:1px solid #1d2833;border-radius:999px;padding:3px 10px;font-size:12px;color:#9fb0c0}+.pill b{color:#e6edf3;font-weight:600}+select,input{background:#111b23;color:#e6edf3;border:1px solid #23303c;border-radius:6px;padding:5px 9px;font-size:13px}+.btn{background:#16222c;border:1px solid #2a3a47;color:#cfe0ea;border-radius:6px;padding:4px 10px;font-size:12px;cursor:pointer}+.btn:hover{background:#1d2b36;border-color:#00b8b1}+.btn.sm{padding:2px 8px;font-size:11px}+.btn.xs{padding:0 6px;font-size:11px;line-height:16px}+.btn.danger{border-color:#5c2b2b;color:#ff9d95}+.btn.danger:hover{background:#2a1717;border-color:#f85149}+.sub{margin-left:auto;color:#7d8f9f;font-size:12px}+.loops{margin:18px 0 6px;padding:10px 12px;border:1px solid #30363d;border-radius:8px;background:#161b22;font:11.5px ui-monospace,monospace;color:#8b949e}+.lpt{color:#7d8f9f;margin:2px 0 6px}+.lp{display:inline-block;margin:0 10px 6px 0;padding:2px 7px;border:1px solid #30363d;border-radius:6px;color:#c9d1d9}+.lp b{color:#00b8b1}+.lp.hot{border-color:#f85149;color:#f85149}+.lp.hot b{color:#f85149}++/* taskbar strip: mimic of the real Windows taskbar */+#toasts{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:99999;display:flex;flex-direction:column-reverse;gap:8px;align-items:center}+.toast{background:#21262d;border:1px solid #30363d;border-left:3px solid #00b8b1;color:#c9d1d9;border-radius:8px;padding:8px 14px;font-size:13px;box-shadow:0 8px 24px rgba(0,0,0,.5);transition:opacity .3s ease}+.toast.success{border-left-color:#3fb950}+.toast.error{border-left-color:#f85149}+.toast.out{opacity:0}+/* ===== Settings modal (adom-ui-design / settings-layout, v2.0.296) =====+   Tokens first: a scheme swap must change nothing but these. */+#dlgWrap{position:fixed;inset:0;z-index:99998;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;--text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;--accent:#00b8b1;--on-accent:#05221f;--red:#f85149;--green:#3fb950;--brand-heading:"Familjen Grotesk",Satoshi,sans-serif;--brand-body:Satoshi,"Familjen Grotesk",sans-serif}+/* the backdrop law: DIM 25%, never blur (settings often change the very thing behind the modal) */+#dlgBg{position:absolute;inset:0;background:rgba(0,0,0,.25)}+/* the radius element never scrolls: #dlg clips, .settings-body is the scroller */+#dlg{position:absolute;top:48px;left:50%;transform:translateX(-50%);width:min(880px,94vw);max-height:calc(100vh - 96px);display:flex;flex-direction:column;overflow:hidden;padding:0;background:var(--surface);border:1px solid var(--border);border-radius:12px;box-shadow:0 16px 48px rgba(0,0,0,.6);outline:none}+.dlghead{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;padding:10px 16px;background:var(--surface);border-bottom:1px solid var(--border);font-family:var(--brand-heading);font-weight:600;font-size:15px;color:var(--text)}+.settings-content{display:flex;flex:1 1 auto;min-height:0}+.settings-side{width:13rem;flex-shrink:0;border-right:1px solid var(--border);overflow:auto;padding:.75rem .5rem}+.settings-body{flex:1;min-width:0;overflow:auto;padding:1rem 1.25rem;scroll-behavior:smooth;max-height:70vh}+/* house scrollbar: thin, transparent track, translucent pill, teal on hover */+.settings-body::-webkit-scrollbar,.settings-side::-webkit-scrollbar{width:8px;height:8px}+.settings-body::-webkit-scrollbar-track,.settings-side::-webkit-scrollbar-track{background:transparent;margin:12px 0}+.settings-body::-webkit-scrollbar-thumb,.settings-side::-webkit-scrollbar-thumb{background:rgba(139,148,158,.35);border-radius:8px;border:2px solid transparent;background-clip:padding-box}+.settings-body::-webkit-scrollbar-thumb:hover,.settings-side::-webkit-scrollbar-thumb:hover{background:var(--accent);background-clip:padding-box}+/* sidebar: every entry a button; parents expanded; indentation is the hierarchy cue */+.side-sec{width:100%;padding:.3rem .5rem;border-radius:6px;display:flex;align-items:center;gap:.25rem;background:none;border:none;color:var(--text);font-family:var(--brand-body);font-size:.8125rem;text-align:left;cursor:pointer;user-select:none}+.side-sub{width:100%;padding:.25rem .5rem .25rem 1.5rem;border-radius:6px;display:flex;align-items:center;background:none;border:none;color:var(--text-body);font-family:var(--brand-body);font-size:.75rem;text-align:left;cursor:pointer;user-select:none}+.side-sec:hover,.side-sub:hover{color:var(--accent);background:rgba(255,255,255,.06)}+.side-sec.selected,.side-sub.selected{color:var(--accent);font-weight:700}+.side-sec:focus-visible,.side-sub:focus-visible{outline:2px solid var(--accent);outline-offset:1px}+/* content hierarchy: section (h2) > card (h3) > row */+.sec-title{height:2rem;display:flex;align-items:center;margin:.25rem 0 .5rem;font-family:var(--brand-heading);font-size:1.6825rem;font-weight:800;color:var(--accent);scroll-margin-top:.75rem}+.settings-card{background:var(--elevated);border:1px solid var(--border);border-left:3px solid color-mix(in srgb,var(--accent) 45%,transparent);border-radius:10px;padding:.9rem 1.1rem 1rem;margin-bottom:1rem;scroll-margin-top:.75rem}+.card-title{height:2rem;display:flex;align-items:center;gap:10px;margin:0;font-family:var(--brand-heading);font-size:1.3125rem;font-weight:600;color:var(--accent)}+.card-status{font-family:var(--brand-body);font-size:11px;color:var(--text-2);font-weight:400}+.card-status.ok{color:var(--green)}+.card-desc{margin:.2rem 0 .5rem;font-family:var(--brand-body);font-size:.8125rem;line-height:1.55;color:var(--text-2);max-width:60ch}+.card-desc code{font-size:11px;background:rgba(110,118,129,.2);padding:1px 4px;border-radius:4px}+.divider{height:1px;background:var(--border);margin:.55rem 0}+.set-row{padding:.5rem 0;display:flex;flex-direction:column;gap:.25rem}+.row-head{display:flex;align-items:center;justify-content:space-between;gap:12px}+.row-name{margin:.125rem 0;font-family:var(--brand-heading);font-size:.9375rem;font-weight:600;color:var(--text)}+.row-name .title{color:#8fd3d4}+.row-desc{margin:0;font-family:var(--brand-body);font-size:.8125rem;line-height:1.55;color:var(--text-2);max-width:60ch}+.default-hint{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}+.row-why{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);font-style:italic}+/* dependent settings: indented under the parent with a spine, dimmed (never hidden) when the parent is off */+.setting-children{margin-left:.75rem;padding-left:1rem;border-left:2px solid var(--border)}+.setting-children[data-enabled='false']{opacity:.55}+.setting-children[data-enabled='false'] .row-name{color:var(--text-2)}+.setting-children[data-enabled='false'] .row-name .title{color:var(--text-2)}+/* screenshots of the thing the setting changes: real captures only */+.setting-shot{margin:0 0 .5rem}+.setting-shot img{display:block;max-width:min(100%,520px);height:auto;border:1px solid var(--border);border-radius:8px;background:var(--surface)}+.setting-shot figcaption{margin-top:.35rem;font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}+.shot-pending{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);font-style:italic}+/* the house toggle (compact modal size 32x18), role=switch, white thumb in both states */+.toggle{background:none;border:none;padding:2px;cursor:pointer;flex-shrink:0;transition:transform .06s ease;border-radius:10px}+.toggle-track{display:block;width:32px;height:18px;background:var(--overlay);border:1px solid var(--border);border-radius:9px;position:relative;transition:background .15s ease,border-color .15s ease}+.toggle:hover .toggle-track{border-color:var(--accent)}+.toggle:active{transform:translateY(1px) scale(.97)}+.toggle.is-on .toggle-track{background:var(--accent);border-color:var(--accent)}+.toggle-thumb{position:absolute;top:2px;left:2px;width:12px;height:12px;background:#fff;border-radius:50%;transition:transform .18s ease,width .12s ease}+.toggle.is-on .toggle-thumb{transform:translateX(14px)}+.toggle:active .toggle-thumb{width:16px}+.toggle.is-on:active .toggle-thumb{transform:translateX(10px)}+.toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.toggle[disabled]{cursor:not-allowed}+/* enum controls */+.set-row select{background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font-family:var(--brand-body);font-size:.875rem}+.set-row select:hover{border-color:var(--accent)}+.set-row select:focus-visible{outline:2px solid var(--accent);outline-offset:1px}+/* alternatives: one choice card per option, each with its own REAL screenshot, teal ring = selected */+.choice-set{border:none;margin:.25rem 0 .5rem;padding:0;display:flex;flex-wrap:wrap;gap:.75rem}+.choice{position:relative;flex:1 1 200px;max-width:270px;display:flex;flex-direction:column;gap:.35rem;padding:.6rem;cursor:pointer;background:var(--surface);border:1px solid var(--border);border-radius:8px;transition:border-color .15s ease,background .15s ease,transform .06s ease}+.choice input{position:absolute;opacity:0;pointer-events:none}+.choice img{display:block;width:auto;max-width:100%;height:auto;margin:0 auto;border-radius:4px;background:#0d1117}+.choice-name{font-family:var(--brand-heading);font-size:.875rem;font-weight:600;color:var(--text)}+.choice-desc{font-family:var(--brand-body);font-size:.75rem;color:var(--text-2);line-height:1.5}+.choice:hover{border-color:var(--accent);background:var(--elevated)}+.choice:active{transform:translateY(1px) scale(.995)}+.choice[data-selected='true']{border-color:var(--accent);box-shadow:inset 0 0 0 1px var(--accent)}+.choice:focus-within{outline:2px solid var(--accent);outline-offset:2px}+.choice-set[data-enabled='false']{opacity:.55;pointer-events:none}+/* sliders: custom track so the thumb reaches both ends */+.set-row .slider-line{display:flex;align-items:center;gap:10px}+.set-row input[type=range]{flex:1 1 auto;-webkit-appearance:none;appearance:none;height:18px;background:transparent;margin:0}+.set-row input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:3px;background:var(--overlay);border:1px solid var(--border)}+.set-row input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;border-radius:50%;background:var(--accent);margin-top:-6px;box-shadow:0 0 0 1px #0d1117}+.set-row input[type=range]:focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}+.sval{font-family:ui-monospace,monospace;font-size:.8125rem;color:var(--text-body);min-width:3.5em;text-align:right}+/* footer: note + two-click Restore defaults (auto-disarms after 4s) */+.settings-footer{margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:.5rem}+.footer-note{margin:0;font-family:var(--brand-body);font-size:.75rem;color:var(--text-2)}+.restore-btn{width:fit-content;display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .85rem;background:transparent;border:1px solid var(--border);border-radius:.375rem;color:var(--text-body);font-family:var(--brand-heading);font-size:.8125rem;font-weight:600;cursor:pointer;transition:background .12s ease,transform .06s ease}+.restore-btn:hover{background:var(--elevated)}+.restore-btn:active{transform:translateY(1px)}+.restore-btn:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.restore-btn.armed{color:var(--red);border-color:rgba(248,81,73,.5);background:rgba(248,81,73,.08)}+#dlgBody{padding:0}+.ibadge{display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;border:1px solid #8b949e;color:#8b949e;font-size:10px;font-style:italic;cursor:help;vertical-align:2px}+.ibadge:hover{border-color:#00b8b1;color:#00b8b1}+.sexample{display:block;max-width:100%;margin-top:8px;border:1px solid #30363d;border-radius:8px}+.texwrap{margin-top:10px}+.texstrip{display:flex;flex-wrap:wrap;gap:12px;padding:12px 10px;background:#0d1117;border:1px solid #30363d;border-radius:8px}+.tex{display:flex;flex-direction:column;align-items:center;gap:5px;width:64px}+.tex canvas{width:44px;height:44px;border-radius:7px;display:block}+.texn{font:400 9.5px ui-monospace,monospace;color:#8b949e;text-align:center;line-height:1.2;word-break:break-word}+.texcap{margin-top:6px;font-size:11px;color:#8b949e}+.texwide{width:auto}+.texbase{width:44px;height:44px;border-radius:7px;display:block}+.texmatrix{display:grid;grid-template-columns:repeat(3,minmax(70px,1fr));gap:10px;margin-bottom:8px}+.mcell{position:relative;display:flex;flex-direction:column;align-items:center;gap:5px;padding:8px 4px;border:1px solid #30363d;border-radius:8px;cursor:pointer}+/* v2.0.266 (John: "why is there some random 'default' pill in the upper right corner?"): this was+   position:absolute inside .stlbl, which is NOT positioned — so it escaped all the way out to #dlg+   and parked itself on top of the dialog's X button. An absolutely-positioned badge that depends on+   an ancestor nobody made relative is a trap; make it a plain INLINE chip in the label instead, where+   it reads as "1. tiles + overlay (current) default" and has no positioning to get wrong. */+.mdef{display:inline-block;margin-left:6px;vertical-align:1px;font:600 8.5px 'Familjen Grotesk',sans-serif;color:#00b8b1;border:1px solid #00b8b1;border-radius:4px;padding:0 4px;letter-spacing:.04em;white-space:nowrap}+.mcell:hover{border-color:#8b949e}+.mcell.mcur{border-color:#00b8b1;box-shadow:0 0 0 1px #00b8b1 inset}+.mic{position:relative;display:inline-block}+.mic canvas.mbase,.mic canvas[data-mtile],.mic canvas[data-tname],.mic img.mbase{width:44px;height:44px;border-radius:7px;display:block}+.mic .mbadge{position:absolute;top:-7px;right:-7px;width:20px;height:20px;border-radius:5px}+.texnames{display:flex;flex-wrap:wrap;gap:12px;padding-top:10px;border-top:1px solid #30363d}+.texdim{opacity:.45}+.st6{display:flex;flex-direction:column;gap:8px}+/* v2.0.266: flex-wrap + position:relative. Measured live: this row's content wanted 516px inside a+   439px strip, so the sample icons spilled past .texstrip's dark background and drew a hard vertical+   seam down the panel ("why is the background behind the taskbar identity choices so whacked"). */+.strow6{position:relative;display:flex;align-items:center;flex-wrap:wrap;gap:12px;padding:7px 10px;border:1px solid #30363d;border-radius:8px;cursor:pointer}+.strow6:hover{border-color:#8b949e}+.strow6.mcur{border-color:#00b8b1;box-shadow:0 0 0 1px #00b8b1 inset}+.stlbl{flex:0 0 150px;font:600 12px 'Familjen Grotesk',sans-serif;color:#c9d1d9;line-height:1.35}+.sticons{flex:1 1 220px;min-width:0;display:flex;gap:10px;flex-wrap:wrap;align-items:center;padding:7px 7px 0 0}+.mbcard{position:absolute;top:-7px;right:-7px;width:20px;height:20px;border-radius:5px;background:#202124;display:flex;align-items:center;justify-content:center;overflow:hidden}+.mbcard img{width:16px;height:16px;object-fit:contain}+.srow{display:flex;align-items:flex-start;gap:12px;padding:11px 0;border-bottom:1px solid #21262d}+.srow:last-child{border-bottom:none}+.srow .stxt{flex:1 1 auto;min-width:0}+.srow .slbl{color:#e6edf3;font-weight:600;font-size:13px}+.srow .sdesc{color:#8b949e;font-size:12px;line-height:1.5;margin-top:2px}+/* .tgl retired in v2.0.296: the settings dialog uses the house .toggle (settings-layout skill) */+.sub{margin-left:26px;padding:8px 0 2px;display:flex;align-items:center;gap:10px}+.sub.sdis{opacity:.4;pointer-events:none}+.sub input[type=range]{flex:1 1 auto;accent-color:#00b8b1;-webkit-appearance:none;appearance:none;height:18px;background:transparent;margin:0}.sub input[type=range]::-webkit-slider-runnable-track{height:6px;border-radius:3px;background:#2a3038}.sub input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;border-radius:50%;background:#00b8b1;margin-top:-5px;box-shadow:0 0 0 1px #0d1117}+.sub .sval{color:#c9d1d9;font-family:"JetBrains Mono",monospace;font-size:12px;min-width:44px;text-align:right}+.sub select{background:#0d1117;color:#c9d1d9;border:1px solid #30363d;border-radius:6px;padding:4px 8px}+#taskbar{position:sticky;top:44px;z-index:9;display:flex;gap:6px;align-items:center;padding:8px 18px;background:#0a0f14;border-bottom:1px solid #1d2833;overflow-x:auto}+#taskbar .lbl{color:#5c6c7a;font-size:11px;margin-right:6px;flex:0 0 auto}+.tbi{position:relative;width:48px;height:42px;border-radius:8px;background:#0f1720;border:1px solid #1d2833;display:flex;align-items:center;justify-content:center;cursor:pointer;flex:0 0 auto}+.tbi.ghost{color:#5c6c7c;font-size:18px;font-weight:700;border-style:dashed;border-color:#7c3a3a}+.tbi:hover{border-color:#00b8b1;background:#12202a}+.tbi.fg{border-color:#00b8b1}+.tbi img.base{width:30px;height:30px;object-fit:contain}+.tbi img.ov{position:absolute;top:1px;right:1px;width:17px;height:17px;object-fit:contain;border-radius:4px}+.tbi .n{position:absolute;bottom:-6px;left:-6px;background:#00b8b1;color:#05221f;font-size:10px;font-weight:700;border-radius:8px;min-width:16px;height:16px;line-height:16px;text-align:center;padding:0 3px}+.tbi .u{position:absolute;bottom:2px;left:50%;transform:translateX(-50%);width:16px;height:3px;border-radius:2px;background:#00b8b1}++/* tree */+main{padding:14px 18px}+.node{margin:0}+.tw{margin-left:0}+.row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:7px}+.row:hover{background:#0f1720}+.tog{width:16px;flex:0 0 16px;color:#7d8f9f;cursor:pointer;user-select:none;text-align:center}+.tog.leaf{visibility:hidden}+.ico{width:22px;height:22px;flex:0 0 22px;object-fit:contain;border-radius:5px;background:#0f1720}+.ico.sm{width:16px;height:16px;flex:0 0 16px}+.fttray{display:inline-flex;gap:3px;align-items:center;flex-wrap:wrap}+.ft{font-size:9px;line-height:1;padding:2px 5px;border-radius:4px;background:#161f29;color:#9fb2c2;border:1px solid #24303c;white-space:nowrap}+.ft.dim{opacity:.55}+.ft.k-ok{background:#0f2a1c;color:#5fe0a0;border-color:#1c4a33}+.ft.k-gen{background:#2a230f;color:#e0c15f;border-color:#4a3d1c}+.ft.k-red{background:#2a0f14;color:#ff8a95;border-color:#4a1c24}+.ft.k-dark{background:#12202e;color:#7fc0ff;border-color:#1c3a52}+.ft.k-light{background:#26240f;color:#e8e07a;border-color:#48431c}+.ft.k-warn{background:#2a1a0f;color:#ffab6a;border-color:#4a2f1c}+.led{display:inline-block;width:9px;height:9px;border-radius:50%;flex:0 0 9px;vertical-align:middle;box-shadow:0 0 0 1px rgba(0,0,0,.4) inset}+.led.green{background:#22e07a;box-shadow:0 0 6px 1px rgba(34,224,122,.65)}+.led.amber{background:#f0b429;box-shadow:0 0 6px 1px rgba(240,180,41,.5)}+.led.red{background:#ff5b6a;box-shadow:0 0 6px 1px rgba(255,91,106,.6)}+.tbi .led{position:absolute;bottom:-5px;right:-5px;box-shadow:0 0 0 2px #0d1117}+.icostack{position:relative;flex:0 0 22px;width:22px;height:22px}+.icostack .ico{position:absolute;inset:0}+.icostack .icoov{position:absolute;top:-3px;right:-3px;width:13px;height:13px;object-fit:contain;border-radius:3px}+.nm{font-weight:600}+.nm.dim{font-weight:400;color:#c3d0da}+.meta{color:#7d8f9f;font-size:12px}+.tag{font-size:11px;border-radius:5px;padding:1px 7px;border:1px solid #23303c;color:#9fb0c0}+.tag.fg{background:#00b8b11a;border-color:#00b8b1;color:#00b8b1}+.tag.ok{background:#3fb9501a;border-color:#3fb950;color:#3fb950}+.tag.hf{background:#d299221a;border-color:#d29922;color:#e3b341}+.tag.warn{background:#f851491a;border-color:#f85149;color:#ff7b72}+.tag.vp{background:#a371f71a;border-color:#a371f7;color:#c297ff}+.acts{margin-left:auto;display:flex;gap:4px;opacity:0;transition:opacity .1s}+.row:hover .acts{opacity:1}+.tbnode{border:1px solid #1d2833;border-radius:10px;margin-bottom:12px;background:#0c131a;overflow:hidden}+.tbnode>.row{background:#111b23;border-bottom:1px solid #1d2833;border-radius:0}+.win{margin:2px 0 2px 20px;border-left:2px solid #1a2530}+.win>.row .nm{font-size:13px}+.tabs{margin:0 0 4px 44px}+.why{margin:4px 0 8px 44px;padding:6px 9px;background:#0d1117;border:1px solid #1d2833;border-radius:6px;color:#8fa3b3;font-size:11px;line-height:1.6}+.why b{display:block;color:#00b8b1;margin-bottom:2px}+.empty{color:#7d8f9f;padding:40px;text-align:center}+.hidden{display:none}+footer{color:#5c6c7a;font-size:11px;padding:8px 18px 22px}+</style></head><body>+<header>+  <div class="brand" id="brand">+    <svg class="bicon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="2.75" y="4.5" width="18.5" height="15" rx="2.5"/><path d="M2.75 8.75h18.5"/><circle cx="12" cy="14.2" r="3.1"/></svg><!-- v2.0.339: the header mark is now the SAME drawing as the favicon and the dock card (docs/img/dock-pup.svg): window frame + aperture ring. It used to be the retired two-windows-plus-heartbeat concept, so this page showed one pup mark in its header and a different one in its tab. stroke stays currentColor here and ONLY here: an inline mark should inherit the header colour, while the favicon is an isolated document where currentColor resolves to black, which is why /dashboard/icon.svg hardcodes #e6edf3. -->+    <span class="bname">Pup</span><span class="bsub">&middot; live windows</span>+  </div>+  <div class="pop" id="pop">+    <div>version <b id="cV">?</b></div>+    <div id="popMeta">connecting</div>+    <div><a href="https://wiki.adom.inc/adom/pup-bridge" target="_blank" rel="noopener">wiki: pup-bridge</a></div>+  </div>+  <div class="vsep"></div>+  <div class="subject">+    <span class="sname"><span class="dot" id="live"></span><span id="subjName">all windows</span></span>+    <span class="smeta"><b id="cW">0</b> windows &middot; <b id="cT">0</b> tabs &middot; <b id="cB">0</b> icons &middot; <span id="cAY" title="windows surfing on the shared adom-you profile / total work windows. More adom-you = a richer shared login store = pup auto-fills more sites for you over time.">?</span> adom-you &middot; <span id="cLed" title="green: every taskbar button wears its real page-favicon overlay and no untracked pup windows exist. red: a window is bare or unmodeled."><span class="led red" id="cLedDot"></span> <b id="cLedTxt">?</b></span> &middot; <span id="age"></span></span>+  </div>+  <div class="acts2">+    <button class="ibtn" id="recall" aria-pressed="true" title="Hide the prompts panel" aria-label="Hide the prompts panel"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M9.6 9.2a2.5 2.5 0 1 1 3.2 3.2c-.5.2-.8.7-.8 1.3v.3"/><path d="M12 17.2h.01"/></svg></button>+    <button class="ibtn" id="gear" title="Settings" aria-label="Settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="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 1 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 1 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.33h0a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51h0a1.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.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>+    <button class="ibtn" data-act="regen-overlays" data-tip="Regenerate ALL overlay icons: re-scan every open window and tab and re-run the from-scratch overlay algorithm (re-resolve each window handle, re-pick the favicon, recompose + repaint the taskbar badge) and refresh these dashboard icons to match. A clean sweep to verify correctness and surface any drift." title="regenerate all overlay icons (clean sweep)" aria-label="regenerate overlays"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg></button>+    <button class="ibtn" data-act="flash-all" title="flash every pup taskbar button (the Windows taskbar flash, accent-colored on your theme)" aria-label="flash all"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/></svg></button>+    <button class="ibtn" data-act="unflash-all" title="clear every taskbar flash" aria-label="clear taskbar flashes"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.7 21a2 2 0 0 1-3.4 0"/><line x1="3" y1="3" x2="21" y2="21"/></svg></button>+    <button class="ibtn danger" data-act="close-all" data-confirm="Close ALL pup windows?" data-tip="Close ALL pup windows on this desktop (every window in the list below, not this dashboard page). Asks before doing it." aria-label="close all pup windows"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="8" width="13" height="11" rx="1.6"/><path d="M4 15V6a1.5 1.5 0 0 1 1.5-1.5H16"/><path d="M12.2 11.2l4.6 4.6M16.8 11.2l-4.6 4.6"/></svg></button>+  </div>+</header>+<div id="newbie"></div>+<div class="ledrow" id="leds"></div>+<div id="bar">+  <span class="pill" id="cM" style="display:none">minimal-touch</span>+  <select id="f"><option value="">all AI threads</option></select>+  <input id="q" placeholder="filter url / window / ai-thread..." size="20">+</div>+<div id="toasts"></div>+<div id="dlgWrap" class="hidden">+  <div id="dlgBg"></div>+  <div id="dlg" role="dialog" aria-modal="true" aria-label="pup Settings" tabindex="-1">+    <div class="dlghead">pup Settings<button class="ibtn" id="dlgClose" title="Close settings" aria-label="Close settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button></div>+    <div class="settings-content">+      <nav class="settings-side" id="dlgSide" aria-label="Settings sections"></nav>+      <div class="settings-body" id="dlgScroller"><div id="dlgBody"></div></div>+    </div>+  </div>+</div>+<div id="taskbar"></div>+<main id="m"><div class="empty">connecting...</div></main>+<div id="loops" class="loops"></div>+<footer id="ft"></footer>+<script src="/dashboard/newbie-panel.js"></script>+<script>+const _q=s=>document.querySelector(s); let SNAP=null; const COLL=new Set();+// v1.9.258 (John hovered a tooltip that his stale page could not render): the page's code is baked+// at load, but the bridge updates underneath it on every ship. When the SSE reports a different+// bridge version than this page was served by, reload once - the dashboard always follows the bridge.+const PAGE_VERSION='${BRIDGE_VERSION}';+const ago=ms=>{if(ms==null)return'-';const s=Math.round(ms/1000);if(s<60)return s+'s';const m=Math.round(s/60);if(m<60)return m+'m';const h=Math.floor(m/60);return h+'h '+(m%60)+'m';};+const esc=t=>String(t==null?'':t).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));+// v1.9.311 (John: "why do i keep seeing these refresh blip non-stop?"): the badge URL used to+// cache-bust with SNAP.ts, which changes every SSE tick, so the no-store image refetched a couple+// times a SECOND — a constant flicker. Version it by the badge's actual PAINT TIME instead: the URL+// only changes when the overlay is genuinely repainted, so between repaints the browser serves the+// image from cache and there is zero blip even though render() rebuilds the DOM each tick.+const badge=sid=>{var v=0;try{var w=(SNAP&&SNAP.windows||[]).find(function(x){return x.sessionId===sid;});v=(w&&w.overlayWhy&&w.overlayWhy.at)||0;}catch(e){}return '/dashboard/badge?session='+encodeURIComponent(sid)+'&v='+v;};+const fav=(sid,tid)=>'/dashboard/favicon?session='+encodeURIComponent(sid)+'&tab='+encodeURIComponent(tid);+// v1.9.238: per-tab favicon acquisition state as compact chips + a full tooltip.+function favTags(f){+  if(!f) return '<span class="ft dim" title="favicon not resolved yet">fav ?</span>';+  const K={'page-favicon':['favicon','k-ok'],'sibling-tab-favicon':['sibling','k-ok'],'favicon.ico':['favicon.ico','k-ok'],'app-provided':['app icon','k-ok'],'generic-web':['generic web','k-gen'],'generic-pdf':['generic pdf','k-gen'],'none':['none','k-red']};+  const kd=K[f.kind]||[f.kind||'?','k-gen'];+  const bits=[];+  bits.push('<span class="ft '+kd[1]+'">'+esc(kd[0])+'</span>');+  if(f.format) bits.push('<span class="ft">'+esc(String(f.format).toUpperCase())+'</span>');+  if(f.mode&&f.mode!=='default') bits.push('<span class="ft '+(f.mode==='dark'?'k-dark':f.mode==='light'?'k-light':'')+'">'+esc(f.mode)+'-mode</span>');+  if(f.transparent===true) bits.push('<span class="ft'+(f.darkDominant?' k-warn':'')+'">transparent'+(f.darkDominant?' · dark':'')+'</span>');+  else if(f.transparent===false) bits.push('<span class="ft">opaque</span>');+  const tip=['kind: '+(f.kind||'?'),'format: '+(f.format||'?'),'color-scheme: '+(f.mode||'?'),'transparent: '+(f.transparent===null?'n/a':f.transparent),f.luminance!=null?'luminance: '+f.luminance+(f.darkDominant?' (dark)':''):'',f.via?'fetched via: '+f.via:'',f.candidates!=null?'candidates seen: '+f.candidates:'',f.href&&!/^generic|^app-provided/.test(f.href)?'src: '+f.href:'',f.note?'warning: '+f.note:''].filter(Boolean).join(String.fromCharCode(10));+  return '<span class="fttray" title="'+esc(tip)+'">'+bits.join('')+'</span>';+}++function render(){+  if(!SNAP)return; const d=SNAP;+  if(d.bridge&&d.bridge.version&&d.bridge.version!==PAGE_VERSION){location.reload();return;}+  _q('#cW').textContent=d.counts.windows; _q('#cT').textContent=d.counts.tabs;+  _q('#cB').textContent=(d.taskbarButtons||[]).length; _q('#cV').textContent=d.bridge.version;+  (function(){const ws=(d.windows||[]).filter(w=>w.sessionId!=='pup-dashboard');const ay=ws.filter(w=>w.profile==='adom-you').length;_q('#cAY').textContent=ay+'/'+ws.length;})();+  _q('#popMeta').textContent=d.bridge.platform+' \u00b7 bridge up '+ago(d.bridge.uptimeMs)+' \u00b7 deps '+(d.bridge.depsOk?'ok':'missing');+  _q('#subjName').textContent=(_q('#f').value||'all windows');+  for(const t of (d.toasts||[])){ if(t.id>SEENTOAST){SEENTOAST=t.id;toast(t.msg,t.type);} }+  _q('#cM').style.display=d.bridge.minimalTouch?'':'none';+  // header overlay-match rollup: red if ANY button bare, amber if any generic, else green+  const bl=(d.taskbarButtons||[]).map(b=>b.led||'red');+  let roll=bl.includes('red')?'red':bl.includes('amber')?'amber':(bl.length?'green':'red');+  if((d.untrackedWindows||[]).length) roll='red';   // an unmodeled real button = the mimic CANNOT claim MATCH+  _q('#cLedDot').className='led '+roll;+  _q('#cLedTxt').textContent=(d.untrackedWindows||[]).length?((d.untrackedWindows||[]).length+' UNTRACKED'):roll==='green'?'MATCH':roll==='amber'?'generic':'MISMATCH';+  _q('#age').textContent='updated '+new Date(d.ts).toLocaleTimeString()+(d.regen?(d.regen.running?' · regenerating '+d.regen.inFlight+' windows…':(d.regen.changed!=null?' · last sweep: '+d.regen.changed+'/'+d.regen.swept+' corrected':'')):'');+  const sel=_q('#f'),cur=sel.value;+  if(sel.options.length-1!==d.threads.length){sel.innerHTML='<option value="">all AI threads</option>'+d.threads.map(t=>'<option>'+esc(t)+'</option>').join('');sel.value=cur;}+  const q=_q('#q').value.toLowerCase(),th=sel.value;+  const byId=Object.fromEntries(d.windows.map(w=>[w.sessionId,w]));+  const match=w=>{if(th&&(w.owner||w.ownerThread)!==th)return false;if(q){const hay=(w.sessionId+' '+(w.url||'')+' '+(w.owner||'')+' '+w.tabs.map(t=>t.url||'').join(' ')).toLowerCase();if(!hay.includes(q))return false;}return true;};+  const groups=(d.taskbarButtons||[]).map(b=>({...b,wins:b.windows.map(s=>byId[s]).filter(w=>w&&match(w))})).filter(g=>g.wins.length);++  // taskbar strip (real order, real badge)+  // v1.9.235 (John's audit): each strip button mimics the REAL taskbar button - the browser/category+  // BASE icon full-size, with the painted overlay badge in the TOP-RIGHT corner at ~55% (measured+  // from his actual taskbar), and NO overlay at all when pup has not painted one (a bare Chrome+  // button must render bare here too, otherwise the mimic lies).+  const ghosts=(d.untrackedWindows||[]).map(h=>'<div class="tbi ghost" title="a window of the pup-managed Chrome is on the real taskbar but pup has NO window record for it (hwnd '+h+'). The mimic cannot know its icon - this is why the header LED is red.">?<span class="led red"></span></div>').join('');+  _q('#taskbar').innerHTML='<span class="lbl" title="mimics ONLY the taskbar buttons of pup-managed Chrome windows. Other Adom apps (Adom Hydrogen, Adom Bridge) have their own teal-tile buttons that are out of pup scope.">TASKBAR (pup)</span>'+groups.map((g,i)=>{+    const fg=g.wins.some(w=>w.foreground);+    return '<div class="tbi'+(fg?' fg':'')+'" data-jump="g'+i+'" title="'+esc(g.label)+': '+esc(g.ledWhy||'')+'">'+      +'<img class="base" src="'+esc(g.baseIcon||'')+'" data-fallback="hide">'+      +(g.badgeSession?'<img class="ov" src="'+badge(g.badgeSession)+'" data-fallback="hide">':'')+      +'<span class="led '+esc(g.led||'red')+'" title="'+esc(g.ledWhy||'')+'"></span>'+      +(g.wins.length>1?'<span class="n">'+g.wins.length+'</span>':'')+      +(fg?'<span class="u"></span>':'')+'</div>';+  }).join('')+ghosts;++  // tree: taskbar icon -> windows -> tabs+  _q('#m').innerHTML=groups.length?groups.map((g,gi)=>{+    const gid='g'+gi, gcoll=COLL.has(gid);+    return '<div class="tbnode" id="'+gid+'">'+      +'<div class="row"><span class="tog" data-toggle="'+gid+'">'+(gcoll?'▸':'▾')+'</span>'+        +'<span class="icostack"><img class="ico" src="'+esc(g.baseIcon||'')+'" data-fallback="hide">'+        +(g.badgeSession?'<img class="icoov" src="'+badge(g.badgeSession)+'" data-fallback="hide">':'')+'</span>'+        +'<span class="led '+esc(g.led||'red')+'" title="overlay match: '+esc(g.ledWhy||'')+'"></span>'+        +'<span class="nm">'+esc(g.label)+'</span>'+        +'<span class="meta">'+g.wins.length+' window'+(g.wins.length>1?'s':'')+' &middot; '+esc(g.grouping)+' &middot; badge from '+esc(g.badgeVia||'?')+'</span>'+        +'<span class="acts"><button class="btn sm" data-act="flash-group" data-ids="'+esc(g.windows.join(','))+'">flash</button>'+          +'<button class="btn sm" data-act="unflash-group" data-ids="'+esc(g.windows.join(','))+'">clear</button></span></div>'+      +'<div class="tw'+(gcoll?' hidden':'')+'">'+g.wins.map(w=>{+        const wid=gid+'-'+esc(w.sessionId), wcoll=COLL.has(wid);+        const crash=w.tabs.some(t=>t.crashed)||w.lostBrowser;+        return '<div class="win">'+          +'<div class="row"><span class="tog" data-toggle="'+wid+'">'+(wcoll?'▸':'▾')+'</span>'+            +'<span class="led '+esc(w.overlayLed||'red')+'" title="'+(w.overlayLed==='green'?'real page favicon painted — matches taskbar':w.overlayLed==='amber'?'painted, generic fallback (page has no favicon)':'NOT painted — taskbar would be bare/stale')+'"></span>'+            +'<img class="ico" src="'+badge(w.sessionId)+'" data-fallback="hide">'+            +'<span class="nm">'+esc(w.sessionId)+'</span>'+            +'<span class="tag '+(w.foreground?'fg':'')+'">'+(w.foreground?'FG':'bg')+'</span>'+            +(w.sessionId==='pup-dashboard'?'<span class="tag">dashboard</span>':(w.profile==='adom-you'?'<span class="tag ok" title="surfing on the shared adom-you profile: every login this window makes enriches the shared credential store, so pup can auto-fill it everywhere later">surfing as adom-you</span>':'<span class="tag warn" title="NOT on adom-you: logins made here do NOT build the shared credential store. Prefer adom-you unless isolation is genuinely needed.">NOT adom-you: '+esc(w.profile)+'</span>'))+            +(w.owner?'<span class="tag">'+esc(w.owner)+(w.ownerSource==='caller-identity'?' ✓':'')+'</span>':'<span class="tag warn">UNOWNED</span>')+            +(w.wiki?'<span class="tag '+(w.wiki.authed?'ok':'warn')+'" title="'+(w.wiki.authed?'This Adom wiki window is LOGGED IN — you see private source, drafts, owner-only cards, reply/edit':'This Adom wiki window is the PUBLIC / logged-OUT view — exactly what the world sees')+'">🔑 wiki: '+(w.wiki.authed?'signed in':'public')+'</span>':'')+            +(w.highFps?'<span class="tag hf">highFps '+w.highFpsMinutesLeft+'m</span>':'')+            +(w.emulatedViewport?'<span class="tag vp">vp '+w.emulatedViewport.width+'x'+w.emulatedViewport.height+'</span>':'')+            +(crash?'<span class="tag warn">'+(w.lostBrowser?'browser lost':'crashed tab')+'</span>':'')+            +(w.errorCount?'<span class="tag warn" title="'+esc((w.recentErrors||[]).map(e=>'['+e.type+'] '+e.text).join(String.fromCharCode(10))||'no detail captured')+'">'+w.errorCount+' err</span>':'')+            +'<span class="meta">'+esc(w.profile)+' &middot; '+ago(w.uptimeMs)+'</span>'+            +'<span class="acts">'+              +'<button class="btn sm" data-act="fg" data-sid="'+esc(w.sessionId)+'">bring to fg</button>'+              +'<button class="btn sm" data-act="bg" data-sid="'+esc(w.sessionId)+'">send to bg</button>'+              +'<button class="btn sm" data-act="flash" data-sid="'+esc(w.sessionId)+'">flash</button>'+              +'<button class="btn sm" data-act="unflash" data-sid="'+esc(w.sessionId)+'">clear</button>'+              +'<button class="btn sm danger" data-act="close-window" data-sid="'+esc(w.sessionId)+'" data-confirm="Close '+esc(w.sessionId)+'?">close</button></span></div>'+          +'<div class="'+(wcoll?'hidden':'')+'">'+          +(w.foreground&&w.foregroundReason?'<div class="why"><b>why foreground</b>'+esc(w.foregroundReason)+'</div>':'')+          +(w.overlayWhy?'<div class="why"><b>how this icon was chosen</b><span class="led '+esc(w.overlayLed||'red')+'"></span> '+(w.overlayLed==='green'?'real page favicon':w.overlayLed==='amber'?'generic fallback (no favicon on the page)':'not painted')+'<br>tab '+esc(w.overlayWhy.tabId||'?')+', '+esc(w.overlayWhy.tabPickedBy)+'<br>source: '+esc(w.overlayWhy.source||'none')+'<br>via: '+esc(w.overlayWhy.fetchedVia)+'<br>painted by: '+esc(w.overlayWhy.paintedBy)+'</div>':'')+          +'<div class="tabs">'+w.tabs.map(t=>+            '<div class="row"><span class="tog leaf">•</span>'+            +'<img class="ico sm" src="'+fav(w.sessionId,t.tabId)+'" data-fallback="hide">'+            +'<span class="nm dim'+(t.active?'"':'"')+'">'+esc(t.title||t.url||'-')+'</span>'+            +(t.active?'<span class="tag fg">active</span>':'')+            +(t.crashed?'<span class="tag warn">crashed</span>':'')+            +favTags(t.fav)+            +'<span class="acts"><button class="btn xs" data-act="fg" data-sid="'+esc(w.sessionId)+'" data-tid="'+esc(t.tabId)+'">fg</button>'+            +'<button class="btn xs" data-act="bg" data-sid="'+esc(w.sessionId)+'">bg</button>'+            +'<button class="btn xs danger" data-act="close-tab" data-sid="'+esc(w.sessionId)+'" data-tid="'+esc(t.tabId)+'">close</button></span></div>'+          ).join('')+'</div></div></div>';+      }).join('')+'</div></div>';+  }).join(''):'<div class="empty">no pup windows open'+(th||q?' matching this filter':'')+'</div>';+  _q('#ft').textContent='bridge up '+ago(d.bridge.uptimeMs)+' \u00b7 '+d.bridge.platform+' \u00b7 deps '+(d.bridge.depsOk?'ok':'MISSING')+' \u00b7 win32 '+(d.bridge.win32InProcess?'in-process':'powershell fallback');+  // v2.0.315 LOOP RADAR (John): the periodic loops and in-process Win32 calls no longer show in ab's+  // activity log, so they are shown HERE, per minute, with the declared cadence next to each so a+  // spamming loop stands out (ticks/min far above 60000/cadence).+  try{+    const lp=d.bridge.loops||{}; const w=d.bridge.win32Calls||{};+    const rows=Object.entries(lp).map(([n,v])=>{const exp=Math.max(1,Math.ceil(60000/v.cadenceMs));const hot=v.lastMin>exp*1.5;return '<span class="lp'+(hot?' hot':'')+'" title="declared cadence '+v.cadenceMs+'ms = '+exp+'/min; avg '+v.avgMs+'ms, max '+v.maxMs+'ms, '+v.ticks+' ticks total, '+v.slowTicks+' slow">'+n+' <b>'+v.lastMin+'</b>/'+exp+'</span>';}).join('');+    const wc=Object.entries(w).map(([n,v])=>n+'='+v).join(' ');+    _q('#loops').innerHTML='<div class="lpt">loops, ticks in the last minute (actual/declared)</div>'+rows+'<div class="lpt">in-process win32 calls since boot: '+(wc||'none')+' \u00b7 ab enum_windows last minute: '+(d.bridge.abEnumLastMin||0)+'</div>';+  }catch(e){}+}+function toast(msg,type){+  const t=document.createElement('div');t.className='toast '+(type||'info');t.textContent=msg;+  _q('#toasts').appendChild(t);+  setTimeout(()=>{t.classList.add('out');setTimeout(()=>t.remove(),350)},type==='error'?8000:4000);+}+const ACTMSG={'flash':'flashed the taskbar button of','unflash':'taskbar flash cleared on','flash-all':'flashed every pup taskbar button','unflash-all':'cleared every taskbar flash','close-all':'closed all pup windows','close-window':'closed','close-tab':'closed tab in','fg':'bringing to front:','bg':'sent to background:','set-setting':'setting saved','test-caption':'sample caption shown on your screen','restore-defaults':'every setting restored to its default','regen-overlays':'regenerated all overlay icons','import-scan':'scanned your browsers for logins','import-run':'import'};+async function act(p){+  try{+    // settings-layout: every settings round trip is BOUNDED (5s), so a stalled bridge can never+    // leave a control visually stuck; the click already reflected in the DOM optimistically.+    const ac=new AbortController(); const tm=setTimeout(()=>ac.abort(),5000);+    const r=await fetch('/dashboard/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(p),signal:ac.signal}).finally(()=>clearTimeout(tm));+    const j=await r.json().catch(()=>({}));+    const what=(ACTMSG[p.action]||p.action)+(p.sessionId?' '+p.sessionId:'');+    if(p.action==='regen-overlays'){ if(j.ok&&j.started) toast('Regenerating overlays on '+j.swept+' windows: watch the icons update live','info'); else if(j.ok&&j.alreadyRunning) toast('A regenerate sweep is already running','info'); else toast('regenerate failed'+(j.error?': '+j.error:''),'error'); return j; }+    if(j.ok) toast(what+(p.action==='unflash-all'&&j.swept!=null?' ('+j.swept+' windows swept)':'')+(p.action==='flash-all'&&j.delivered!=null?' ('+j.delivered+' of '+j.n+' delivered)':''), p.action.indexOf('close')===0?'success':'info');+    else toast(what+' failed'+(j.error?': '+j.error:''),'error');+    return j;+  }catch(e){toast(p.action+' failed: '+e.message,'error');}+}+let SEENTOAST=0;+// proper tooltip (adom-ui-design tooltips skill): body-appended fixed div, 600ms reveal,+// clamped to the viewport on BOTH axes, never a title attr for rich content.+(function(){+  let tt=null,show=null;+  function hide(){clearTimeout(show);if(tt){tt.remove();tt=null;}}+  document.addEventListener('mouseover',ev=>{+    // v2.0.97 (John: double tooltips — a raw title attr AND this tooltip): any title attr is+    // absorbed into data-tip the first time the cursor arrives, well inside the ~1s native+    // delay, so the browser tooltip never fires and every hint renders through this one system.+    const t=ev.target.closest('[title]');+    if(t){const v=t.getAttribute('title');if(v&&!t.dataset.tip)t.dataset.tip=v;t.removeAttribute('title');}+    const el=ev.target.closest('[data-tip]');+    if(!el){hide();return;}+    clearTimeout(show);+    show=setTimeout(()=>{+      hide();+      tt=document.createElement('div');+      tt.style.cssText='position:fixed;z-index:100000;max-width:380px;background:#21262d;border:1px solid #30363d;border-radius:8px;padding:10px 12px;font-size:12px;line-height:1.6;color:#c9d1d9;box-shadow:0 8px 24px rgba(0,0,0,.5);pointer-events:none';+      tt.textContent=el.dataset.tip;+      document.body.appendChild(tt);+      const r=el.getBoundingClientRect();+      const M=8,w=tt.offsetWidth,h=tt.offsetHeight;+      let x=r.left+14,y=r.bottom+10;+      if(y+h>innerHeight-M)y=Math.max(M,r.top-h-10);+      x=Math.max(M,Math.min(x,innerWidth-w-M));+      y=Math.max(M,Math.min(y,innerHeight-h-M));+      tt.style.left=x+'px';tt.style.top=y+'px';+    },600);+  });+  document.addEventListener('scroll',hide,true);+})();+// v1.9.259: the settings dialog. Rows defined once; state comes from SNAP.bridge.settings; every+// change POSTs set-setting and answers back with a toast.+const SETTINGS_DEF=[+ {k:'taskbarMode',mainsel:1,threadex:1,l:'Taskbar identity',info:'Three ALTERNATE modes for how pup brands your Windows taskbar, from most to least branded. TILES: every AI thread gets ONE button whose icon is the thread NAME drawn on the Adom teal tile - all of that thread\u2019s windows group under it, so the taskbar reads as a list of your threads. AUMID: every pup window gets its own button with a pup/category icon (per-window identity, no thread grouping) - the AUMID grouping and icon-style options below apply to this mode only. PLAIN: pup never touches identity; windows stack under the stock Chrome-for-Testing button like any unbranded app. The favicon overlay badge is separate - the Overlay badges toggle below works in tiles and aumid modes.',d:'Click a row in the matrix below to switch modes; the current one is ringed.',sel:['tiles','aumid','plain']},+ // v2.0.269 (John): the badge rows live HERE, directly under Taskbar identity, because they are+ // part of the same decision — the identity picker's own examples are 'tiles + overlay' vs 'tiles',+ // so having the overlay toggle sit fifteen rows further down was a genuine discoverability bug.+ {k:'overlayBadges',l:'Favicon overlay badges',d:'Paint each pup taskbar icon with the favicon of its active tab in the top-right corner.',pair:[{img:'obon',cap:'On: the wiki favicon badged on the tile'},{img:'oboff',cap:'Off: the bare thread tile'}]},+ {k:'overlaySize',l:'Badge size',d:'How big the badge is drawn, as a percent of full size. Each card is the real tile at that size.',sel:['50','66','75','100'],selLabels:{'50':'50%, smallest, maximum text clearance','66':'66%','75':'75%, balanced','100':'100%, full size'},dep:'overlayBadges'},+ {k:'taskbarGrouping',l:'AUMID grouping',d:'Only applies when AUMID icons are on. split = one taskbar button per window. grouped = windows grouped BY CATEGORY when icon-style is "category" (up to 5 buttons: signed-in wiki, public wiki, open web, Adom app, plain: for example all your signed-in-wiki windows collapse under the one padlock button); with icon-style "standard" grouped collapses everything under a single Adom Pup button.',sel:['split','grouped'],depMode:'aumid',imgBy:{split:'groupsplit',grouped:'groupgrouped'}},+ {k:'aumidIconStyle',l:'AUMID icon style',d:'Only applies when AUMID icons are on: standard = one pup tile for every window; category = the custom set (wiki signed-out, wiki signed-in padlock, world icon for the open web, adom app icon, mixed).',sel:['standard','category'],depMode:'aumid',imgBy:{standard:'iconstylestd',category:'iconstylecat'}},+ {k:'jumpLists',l:'Jump lists',d:'The menu Windows shows when you right-click a pup taskbar icon (real screenshot below, captured on this machine). Requires AUMID icons on. Quirk: the FIRST right-click can blip the menu closed; right-click again about a second later and it holds (a Windows shell timing race, not a pup bug).',img:'jumplist'},+ {k:'jlViewTasks',l:'',d:'Wiki view tasks: Switch to logged-in view / Switch to public view on every wiki window.',dep:'jumpLists',subtgl:1,img:'jlviewtasks'},+ {k:'jlCloseAll',l:'',d:'The Close all pup windows task on every pup icon.',dep:'jumpLists',subtgl:1,img:'jlcloseall'},+ {k:'jlDashboard',l:'',d:'The Open pup dashboard task on every pup icon: one click opens the live window/tab/thread map.',dep:'jumpLists',subtgl:1,img:'jldashboard'},+ {k:'jlAnnotate',l:'',d:'The Annotate this page task: draw on the page; Done captures it for the AI thread that owns the window.',dep:'jumpLists',subtgl:1,img:'jlannotate'},+ {k:'annCaptionBar',l:'Caption bar on captures',d:'Bake a thin context strip under every annotation/snip capture (thread, page, time) so any AI you paste it into sees where it came from.',img:'anncapbar'},+ {k:'toolbarExtension',l:'Adom toolbar in Chrome',d:'The Adom Pup extension button in the browser toolbar (annotate, snip, view toggle, dashboard, owner, close). Requires the one-time extension install.',img:'toolbarext'},+ {k:'tbSnipHotkey',l:'',d:'Alt+Shift+A starts annotate (Auto: capture on mouse-up); Alt+Shift+M starts it in Multi (compose several shapes, capture once on Done).',dep:'toolbarExtension',subtgl:1},+ {k:'tbFloatyPencil',l:'',d:'The tiny pencil in the upper-right corner of each page: one click starts annotate. Off by default: the corner hover zone can fight buttons in app headers, and the toolbar pen button covers discovery.',dep:'toolbarExtension',subtgl:1},+ {k:'floatyHitPx',l:'',d:'',sliderLabel:'pencil corner hit area',unit:'px',slider:[6,120,2],dep:'tbFloatyPencil'},+ // v2.0.293 (John: "why did you make progress bar vs orange flash a child of Favicon overlay+ // badges? that hierarchy doesn't make sense"): taskbarActivity is its OWN top-level row here in+ // the activity/foreground section, NOT inside the taskbar-identity cluster where its position+ // between the badge/AUMID rows rendered it as a sub-setting of overlay badges.+ {k:'taskbarActivity',l:'Taskbar activity signal',d:'Pick what the button shows. Each card is a real capture of the same pup button on a dark taskbar.',sel:['progress','flash','off'],selLabels:{'progress':'progress bar (default)','flash':'orange flash (old)','off':'off'},imgBy:{progress:'tbactprogress',flash:'tbactflash',off:'tbactoff'}},+ {k:'fgCaption',l:'Caption on AI foreground',d:'When an AI brings a window to your screen, show its stated reason as an on-screen caption. The AI is always forced to give a reason; this shows it to you.',img:'fgcaption'},+ {k:'fgCaptionMs',l:'',d:'',sliderLabel:'minimum caption time (longer text auto-extends at reading speed, 12s max)',unit:'s',slider:[500,8000,250],dep:'fgCaption',test:1},+ {k:'sleepRecovery',l:'Sleep and crash recovery',d:'After the laptop wakes or Chrome dies, automatically bring every pup window back at its last page, signed in. Never restarts a live Chrome.'},+ {k:'autoLogin',l:'Auto-fill saved logins',d:'Fill a matching saved login when a login page loads in a pup window (background, no Chrome dropdown). pup never submits on its own: submitting a stored password is reason-gated and confirms the live domain first, to guard against lookalike/phishing sites.'},+ {k:'captureLogins',l:'Capture logins to the vault',d:'As you sign into sites in pup windows, store the username and password in the OS keychain (encrypted at rest, never logged) so return visits auto-fill. This is how the shared adom-you identity grows over time.'},+ {k:'reportIssues',l:'Issue-report hints',d:'Let pup invite AIs to file a wiki issue when they hit something broken.'},+ {k:'titleTag',l:'ai-thread tag in the window title',d:'Append "(ai-thread: <name>)" to each window title so YOU can tell windows apart from the title bar. Off by default because it can look like the page mangled its own title. Purely cosmetic now: pup identifies its own windows by a Win32 property tag it writes at birth, so it never needs the title.'},+ {k:'titlePrefix',l:'Thread name prefixed to the title',d:'Prepend the owning thread (and wiki logged-in/out state) to the start of each window title. Off by default.'},+ {k:'ownerChip',l:'Floating owner chip in each window',d:'Show a tiny non-interactive chip in the bottom-left of each pup window naming the AI thread that owns it and its profile. It cannot be clicked through to (pointer-events off) and lives in an isolated layer so it never affects the page. Turn off if you do not want any overlay in your windows.',img:'ownerchip'},+ {k:'notMineCaption',l:'"Not foregrounded by Adom" caption',d:'When a window comes to the front on its own (a page’s JavaScript, a focus change, Chrome itself, for example a PDF viewer self-activating), briefly caption that Adom did NOT do it. Stays SILENT when YOU raised the window (detected via recent taskbar/keyboard input). This is the single control for self-foreground captions, including the 20s right after a window opens. Off by default.'},+ {k:'minimalTouch',l:'Minimal-touch mode',d:'The safety mode from the Chrome crash hunt. ON means pup keeps its hands off your windows: it only opens them and drives the pages inside, and skips the extra Windows machinery (identity stamps, window sweeps, shell churn) that was suspected of freezing Chrome. The toggles above are the real per-feature controls now and they win over this switch. Rule of thumb: leave this ON; if pup ever gets crashy, make sure it is ON and turn features back on one at a time to find the culprit.'},+];+// Import-logins picker (the adom-you seed). Discovery runs today via pup_import_scan; the+// actual decrypt is AD-core (adom/adom-bridge #51) so the Import button reports honest-pending.+let _importScan=null; let _importSel={};+function _idTag(a){return '<span style="display:inline-block;background:rgba(120,150,205,.22);border-radius:4px;padding:1px 6px;margin:0 4px 2px 0;font-size:11px;white-space:nowrap">'+esc(a)+'</span>';}+function _idBadges(accts){return (accts&&accts.length)?accts.map(_idTag).join(''):'<span style="opacity:.55;font-size:11px">no linked account</span>';}+function importSectionHtml(){+  const s=_importScan; let rows='';+  if(s&&s.profiles){+    rows=s.profiles.length?s.profiles.map(p=>{+      const key=p.browser+'/'+p.profileDir, on=_importSel[key]!==false;+      const cnt=(p.passwordCount==null?'?':p.passwordCount)+' logins';+      const lock=p.abe?'<span style="opacity:.6;font-size:11px" title="App-Bound Encrypted - needs admin approval to decrypt">🔒 encrypted</span>':'';+      return '<div class="srow" style="padding:8px 0"><label class="stxt" style="cursor:pointer;display:flex;gap:10px;align-items:flex-start;width:100%">'+        +'<input type="checkbox" data-imchk="'+esc(key)+'"'+(on?' checked':'')+' style="margin-top:3px;flex:0 0 auto">'+        +'<span style="flex:1 1 auto;min-width:0"><div class="slbl">'+esc(p.browserName||p.browser)+' &mdash; '+esc(p.profileName||p.profileDir)+'</div>'+        +'<div class="sdesc">'+cnt+' &nbsp; '+_idBadges(p.accounts)+' &nbsp; '+lock+'</div></span></label></div>';+    }).join(''):'<div class="sdesc">No Chrome / Edge / Brave profiles found on this machine.</div>';+  }+  const has=s&&s.profiles&&s.profiles.length;+  const summary=s?'<div class="sdesc" style="margin:8px 0">Found <b>'+(s.profiles?s.profiles.length:0)+'</b> profile(s), ~<b>'+(s.totalPasswords||0)+'</b> saved logins across '+((s.accounts&&s.accounts.length)?s.accounts.map(esc).join(', '):'no linked accounts')+'.'+(s.pythonAvailable?'':' <span style="opacity:.6">(login counts unavailable - python not found)</span>')+'</div>':'';+  return '<div class="srow" style="flex-direction:column;align-items:stretch;border-top:2px solid rgba(255,255,255,.16);margin-top:16px;padding-top:14px">'+    +'<div class="slbl" style="font-size:15px">Import logins from Chrome / Edge <span class="ibadge" data-tip="Seeds the shared adom-you profile with the logins you already have, tagged by which account they came from ([email protected] vs [email protected] vs [email protected]) so the agent acts as the right you on each site. Discovery runs now; the actual decrypt needs one Windows admin approval, being built in Adom Bridge core (issue #51).">i</span></div>'+    +'<div class="sdesc">Build a comprehensive adom-you profile from every browser profile on this machine. Each login stays tagged by its source account so the agent picks the right identity per site.</div>'+    +'<div style="display:flex;gap:8px;margin:12px 0;flex-wrap:wrap"><button class="btn sm" id="importScanBtn">'+(s?'Rescan browsers':'Scan my browsers')+'</button>'+    +(has?'<button class="btn sm" id="importRunBtn">Import selected logins</button>':'')+'</div>'+    +summary+rows+    +(has?'<div id="importStatus" class="sdesc" style="opacity:.6;margin-top:10px">Import pops one Adom Bridge consent dialog + one Windows admin (UAC) prompt, then decrypts your selected logins into the adom-you vault, tagged by account. Passwords are never shown or logged.</div>':'')+    +'</div>';+}+async function runImportScan(){const b=_q('#importScanBtn');if(b){b.disabled=true;b.textContent='Scanning…';}const j=await act({action:'import-scan'});_importScan=(j&&j.ok)?j:{profiles:[],accounts:[]};renderDlg();}+async function runImport(){+  const sel=((_importScan&&_importScan.profiles)||[]).filter(p=>_importSel[p.browser+'/'+p.profileDir]!==false);+  if(!sel.length){toast('Select at least one profile','error');return;}+  const btn=_q('#importRunBtn');if(btn){btn.disabled=true;btn.textContent='Approve on your desktop…';}+  const el=_q('#importStatus');if(el)el.textContent='Waiting for the Adom Bridge consent dialog and Windows admin (UAC) prompt on your screen…';+  const r=await fetch('/dashboard/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'import-run',profiles:sel.map(p=>({browser:p.browser,profileDir:p.profileDir}))})});+  const j=await r.json().catch(()=>({}));+  if(btn){btn.disabled=false;btn.textContent='Import selected logins';}+  if(el)el.textContent=j.message||j.error||'Done.';+  toast(j.ok?('Imported '+((j.added||0)+(j.updated||0))+' logins into the adom-you vault'):(j.message||j.error||'Import failed'),j.ok?'success':'error');+}+// ===== Settings renderer (adom-ui-design / settings-layout, v2.0.296) =====+// One continuously scrolling pane: sections (h2) > cards (h3) > rows, a sidebar that jumps within it+// and scroll-spies, every row a breadcrumb name + explanation + (real) screenshot + control + default.+const SET_NAMES={taskbarMode:'Identity mode',overlayBadges:'Favicon overlay badges',overlaySize:'Badge size',taskbarGrouping:'Grouping',aumidIconStyle:'Icon style',jumpLists:'Jump lists',jlViewTasks:'Wiki view tasks',jlCloseAll:'Close all windows task',jlDashboard:'Open dashboard task',jlAnnotate:'Annotate this page task',annCaptionBar:'Caption bar on captures',toolbarExtension:'Adom toolbar in Chrome',tbSnipHotkey:'Snip hotkey',tbFloatyPencil:'Corner pencil',floatyHitPx:'Pencil hit area',taskbarActivity:'Activity signal',fgCaption:'Caption on AI foreground',fgCaptionMs:'Minimum caption time',notMineCaption:'Not-by-Adom caption',sleepRecovery:'Sleep and crash recovery',autoLogin:'Auto-fill saved logins',captureLogins:'Capture logins to the vault',reportIssues:'Issue-report hints',titleTag:'ai-thread tag in the title',titlePrefix:'Thread name prefixed to the title',ownerChip:'Floating owner chip',minimalTouch:'Minimal-touch mode'};+const SET_DEFAULTS={taskbarMode:'Tiles',overlayBadges:'On',overlaySize:'75%, balanced',taskbarGrouping:'Split',aumidIconStyle:'Category',jumpLists:'On',jlViewTasks:'On',jlCloseAll:'On',jlDashboard:'On',jlAnnotate:'On',annCaptionBar:'On',toolbarExtension:'On',tbSnipHotkey:'On',tbFloatyPencil:'Off',floatyHitPx:'24px',taskbarActivity:'Progress bar',fgCaption:'On',fgCaptionMs:'3.5s',notMineCaption:'Off',sleepRecovery:'On',autoLogin:'On',captureLogins:'On',reportIssues:'On',titleTag:'Off',titlePrefix:'Off',ownerChip:'On',minimalTouch:'On'};+// choice cards: the option name + the one-line tradeoff + the REAL screenshot per option+const SET_CHOICES={+ overlaySize:[{v:'50',n:'50%',d:'Smallest badge, maximum clearance for the tile text.',img:'obsize50',alt:'The pup tile with a small favicon badge in its corner'},+              {v:'66',n:'66%',d:'Small, still clear of the text.',img:'obsize66',alt:'The pup tile with a medium-small favicon badge'},+              {v:'75',n:'75%',d:'Balanced: readable badge, readable text.',img:'obsize75',alt:'The pup tile with a medium favicon badge'},+              {v:'100',n:'100%',d:'Full size. Covers the corner of the tile text.',img:'obsize100',alt:'The pup tile with a large favicon badge overlapping the text corner'}],+ taskbarActivity:[{v:'progress',n:'Progress bar',d:'Fills over the operation’s usual duration, so you can see how far along it is.',img:'tbactprogress',alt:'The pup taskbar button with a blue progress bar filling underneath it'},+                  {v:'flash',n:'Orange flash',d:'Highlights the button until you look at it. Tells you something happened, not how long is left.',img:'tbactflash',alt:'The same taskbar button with the attention highlight underneath it'},+                  {v:'off',n:'Off',d:'The taskbar button never changes.',img:'tbactoff',alt:'The same taskbar button with no highlight of any kind'}],+ taskbarGrouping:[{v:'split',n:'Split',d:'One taskbar button per window.',img:'groupsplit',alt:'Five separate pup taskbar buttons, one per window'},+                  {v:'grouped',n:'Grouped',d:'Windows of the same category share one button.',img:'groupgrouped',alt:'The same five windows collapsed under category buttons'}],+ aumidIconStyle:[{v:'standard',n:'Standard',d:'One pup tile for every window.',img:'iconstylestd',alt:'Pup taskbar buttons all wearing the same pup tile'},+                 {v:'category',n:'Category',d:'A tile per kind: signed-in wiki, public wiki, open web, Adom app, mixed.',img:'iconstylecat',alt:'Pup taskbar buttons each wearing the icon of its category'}],+};+const SET_SECTIONS=[+ {id:'s-identity',t:'Taskbar identity',cards:[+   {id:'c-mode',t:'Identity mode',status:st=>st.taskbarMode,desc:'How pup brands your Windows taskbar. Tiles gives every AI thread one button whose icon is the thread name on the Adom teal tile, so the taskbar reads as a list of your threads. AUMID gives every window its own button with a pup or category icon. Plain leaves identity alone and windows stack under the stock Chrome for Testing button. The example tiles are drawn live by the same code that draws the real icons, so they cannot drift from what Windows shows.',rows:['taskbarMode']},+   {id:'c-badges',t:'Overlay badges',status:st=>st.overlayBadges==='on'?'on':'off',desc:'The favicon of the active tab, composited into the corner of the taskbar button so you can tell at a glance which site a window is on. Works in tiles and AUMID modes.',rows:['overlayBadges','overlaySize']},+   {id:'c-aumid',t:'AUMID options',status:st=>st.taskbarMode==='aumid'?'active':'inert in '+st.taskbarMode+' mode',desc:'These two only apply in AUMID mode, where each window carries its own button.',rows:['taskbarGrouping','aumidIconStyle'],depMode:'aumid'},+ ]},+ {id:'s-jumplists',t:'Jump lists',cards:[+   {id:'c-jl',t:'Right-click menu',status:st=>st.jumpLists==='on'?'on':'off',desc:'The menu Windows shows when you right-click a pup taskbar button. pup registers the tasks below through the Windows jump-list API, so they appear exactly where every other app puts them. Requires an identity mode other than plain.',rows:['jumpLists','jlViewTasks','jlCloseAll','jlDashboard','jlAnnotate']},+ ]},+ {id:'s-capture',t:'Annotate and capture',cards:[+   {id:'c-capbar',t:'Captures',status:st=>st.annCaptionBar==='on'?'caption bar on':'caption bar off',desc:'When you draw on a page and finish, pup captures the marked-up region as a PNG and copies it to your clipboard.',rows:['annCaptionBar']},+   {id:'c-toolbar',t:'Adom toolbar in Chrome',status:st=>st.toolbarExtension==='on'?'on':'off',desc:'A small family of extension buttons that ride the Chrome for Testing launch: the pup menu, the annotate pen, and your adom-you identity tile. They are staged on every launch, so they need no setup.',rows:['toolbarExtension','tbSnipHotkey','tbFloatyPencil','floatyHitPx']},+ ]},+ {id:'s-activity',t:'Activity and foreground',cards:[+   {id:'c-activity',t:'Taskbar activity signal',status:st=>({progress:'progress bar',flash:'orange flash',off:'off'})[st.taskbarActivity]||st.taskbarActivity,desc:'What a window’s taskbar button does while pup is working on it. pup keeps a running average of how long each operation takes (applying the window icon, taking a screenshot) and fills the bar over that estimate, capping just short of done until the work actually finishes. Windows only.',rows:['taskbarActivity']},+   {id:'c-captions',t:'Foreground captions',status:st=>st.fgCaption==='on'?'on':'off',desc:'pup opens every window in the background and never brings one to your screen without a reason. When an AI does raise a window, it must state why, and that reason is shown as an on-screen caption BEFORE the window comes up, naming the thread that asked. Caption time scales with the amount of text: the slider sets the minimum, and longer reasons extend at reading speed up to 12 seconds.',rows:['fgCaption','fgCaptionMs','notMineCaption']},+ ]},+ {id:'s-logins',t:'Logins',cards:[+   {id:'c-vault',t:'Credential vault',status:st=>st.captureLogins==='on'?'capturing':'not capturing',desc:'pup runs its own password manager backed by the OS keychain, so an AI can sign into sites in the background without Chrome’s foreground autofill dropdown. Passwords are encrypted at rest and never logged or echoed.',rows:['autoLogin','captureLogins']},+   {id:'c-import',t:'Import logins',html:()=>importSectionHtml()},+ ]},+ {id:'s-inpage',t:'In-page identity',cards:[+   {id:'c-labels',t:'Window labels',status:st=>st.ownerChip==='on'?'owner chip on':'owner chip off',desc:'Ways to tell which AI thread owns a window from inside it. pup identifies its own windows by a Win32 property it writes at birth, so none of these are needed for bookkeeping; they exist only for you.',rows:['ownerChip','titleTag','titlePrefix']},+ ]},+ {id:'s-safety',t:'Recovery and safety',cards:[+   {id:'c-safety',t:'Recovery and safety',status:st=>st.sleepRecovery==='on'?'recovery on':'recovery off',desc:'What pup does when things go wrong, and how much it touches your windows beyond driving the pages inside them.',rows:['sleepRecovery','reportIssues','minimalTouch']},+ ]},+];+const SET_PREFIX={taskbarMode:'Taskbar: ',overlayBadges:'Taskbar: ',overlaySize:'Overlay badges: ',taskbarGrouping:'AUMID: ',aumidIconStyle:'AUMID: ',jumpLists:'Taskbar: ',jlViewTasks:'Jump lists: ',jlCloseAll:'Jump lists: ',jlDashboard:'Jump lists: ',jlAnnotate:'Jump lists: ',annCaptionBar:'Captures: ',toolbarExtension:'Chrome: ',tbSnipHotkey:'Toolbar: ',tbFloatyPencil:'Toolbar: ',floatyHitPx:'Corner pencil: ',taskbarActivity:'Taskbar: ',fgCaption:'Foreground: ',fgCaptionMs:'Caption: ',notMineCaption:'Foreground: ',sleepRecovery:'Recovery: ',autoLogin:'Vault: ',captureLogins:'Vault: ',reportIssues:'Safety: ',titleTag:'Title: ',titlePrefix:'Title: ',ownerChip:'In page: ',minimalTouch:'Safety: '};+const SET_DEFS=Object.fromEntries(SETTINGS_DEF.map(d=>[d.k,d]));+const SET_SHOT_PENDING=new Set(['tbFloatyPencil','titleTag','titlePrefix','notMineCaption']);+let _dlgSelected=null;+function _rowName(k){return '<h4 class="row-name" title="'+esc(k)+'"><span class="prefix">'+esc(SET_PREFIX[k]||'')+'</span><span class="title">'+esc(SET_NAMES[k]||k)+'</span></h4>';}+function _toggle(k,on,dis){return '<button class="toggle'+(on?' is-on':'')+'" role="switch" aria-checked="'+on+'" aria-label="'+esc(SET_NAMES[k]||k)+'" data-tgl="'+k+'"'+(dis?' disabled':'')+'><span class="toggle-track"><span class="toggle-thumb"></span></span></button>';}+function _shot(name,alt,cap){return name?'<figure class="setting-shot"><img src="/dashboard/example?n='+name+'&v='+PAGE_VERSION+'" alt="'+esc(alt||'')+'" loading="lazy" data-fallback="hide">'+(cap?'<figcaption>'+esc(cap)+'</figcaption>':'')+'</figure>':'';}+function _dflt(k){return '<span class="default-hint">Default: '+esc(SET_DEFAULTS[k]||'')+'</span>';}+function renderRow(k,st,enabled){+  const def=SET_DEFS[k]; if(!def) return '';+  const dis=!enabled;+  if(def.slider){+    const v=Number(st[k])||(def.unit==='px'?24:3500); const unit=def.unit||'s';+    const disp=unit==='px'?v+'px':(v/1000).toFixed(1)+'s';+    return '<div class="set-row" id="row-'+k+'">'+_rowName(k)+'<p class="row-desc">'+esc(def.sliderLabel||'')+'</p>'+      +'<div class="slider-line"><input type="range" min="'+def.slider[0]+'" max="'+def.slider[1]+'" step="'+def.slider[2]+'" value="'+v+'" data-slider="'+k+'" data-unit="'+unit+'" aria-label="'+esc(SET_NAMES[k]||k)+'"'+(dis?' disabled':'')+'>'+      +'<span class="sval" id="val-'+k+'">'+disp+'</span>'+      +(def.test?'<button class="btn sm" data-testcap="1" title="Show a sample caption on your screen now, at the configured duration"'+(dis?' disabled':'')+'>test</button>':'')+'</div>'+_dflt(k)+'</div>';+  }+  if(SET_CHOICES[k]){+    const cur=st[k];+    return '<div class="set-row" id="row-'+k+'">'+_rowName(k)+'<p class="row-desc">'+esc(def.d)+'</p>'+      +'<fieldset class="choice-set" role="radiogroup" aria-label="'+esc(SET_NAMES[k]||k)+'" data-enabled="'+(!dis)+'">'+      +SET_CHOICES[k].map(c=>'<label class="choice" data-selected="'+(cur===c.v)+'"><input type="radio" name="sc-'+k+'" value="'+c.v+'" data-choice="'+k+'"'+(cur===c.v?' checked':'')+(dis?' disabled':'')+'>'+        +'<img src="/dashboard/example?n='+c.img+'&v='+PAGE_VERSION+'" alt="'+esc(c.alt)+'" loading="lazy" data-fallback="hide">'+        +'<span class="choice-name">'+esc(c.n)+'</span><span class="choice-desc">'+esc(c.d)+'</span></label>').join('')+      +'</fieldset>'+_dflt(k)+'</div>';+  }+  if(def.sel){+    const selHtml='<select data-sel="'+k+'" aria-label="'+esc(SET_NAMES[k]||k)+'"'+(dis?' disabled':'')+'>'+def.sel.map(x=>'<option value="'+esc(x)+'"'+(st[k]===x?' selected':'')+'>'+esc((def.selLabels&&def.selLabels[x])||x)+'</option>').join('')+'</select>';+    const threadEx=def.threadex?'<div class="texwrap"><div class="texstrip" id="texstrip"></div><div class="texcap">Your real thread names, drawn exactly as the taskbar draws them. The badge in the corner is the favicon of the active tab.</div></div>':'';+    return '<div class="set-row" id="row-'+k+'"><div class="row-head">'+_rowName(k)+selHtml+'</div><p class="row-desc">'+esc(def.d)+'</p>'+threadEx+_dflt(k)+'</div>';+  }+  const on=st[k]==='on';+  const shot=def.pair?'<div style="display:flex;gap:.75rem;flex-wrap:wrap">'+def.pair.map(q=>_shot(q.img,q.cap,q.cap)).join('')+'</div>'+    :def.img?_shot(def.img,SET_NAMES[k]+' as it appears on this machine',null):(SET_SHOT_PENDING.has(k)?'<div class="shot-pending">Screenshot pending: a real capture of this will be added; nothing is mocked up in its place.</div>':'');+  return '<div class="set-row" id="row-'+k+'"><div class="row-head">'+_rowName(k)+_toggle(k,on,dis)+'</div><p class="row-desc">'+esc(def.d)+'</p>'+shot+_dflt(k)+'</div>';+}+function renderCard(card,st){+  const status=card.status?card.status(st):'';+  const okish=/^(on|active|tiles|aumid|progress bar|orange flash|capturing|recovery on|owner chip on|caption bar on)$/.test(status);+  let body='';+  if(card.html){ body=card.html(); }+  else{+    const keys=card.rows;+    const parentKey=keys[0];+    const parentDef=SET_DEFS[parentKey];+    const cardOn=card.depMode?(st.taskbarMode===card.depMode):true;+    // the first row is the parent; rows that declare dep on it (or on a sibling) are its children+    const children=keys.slice(1).filter(k=>SET_DEFS[k]&&(SET_DEFS[k].dep===parentKey||keys.includes(SET_DEFS[k].dep)));+    const siblings=keys.slice(1).filter(k=>!children.includes(k));+    body+=renderRow(parentKey,st,cardOn);+    if(children.length){+      const pOn=cardOn&&(parentDef&&parentDef.sel?true:st[parentKey]==='on');+      body+='<div class="setting-children" data-enabled="'+pOn+'" title="'+(pOn?'':'Available when '+esc(SET_NAMES[parentKey])+' is on')+'">'+        +(pOn?'':'<div class="row-why">Available when '+esc(SET_NAMES[parentKey])+' is on.</div>')+        +children.map((k,i)=>{+          const d=SET_DEFS[k]; const gp=d.dep&&d.dep!==parentKey?d.dep:null;   // grandchild (e.g. pencil hit area under corner pencil)+          const en=pOn&&(!gp||st[gp]==='on');+          return (i?'<div class="divider"></div>':'')+renderRow(k,st,en);+        }).join('')+'</div>';+    }+    siblings.forEach(k=>{ body+='<div class="divider"></div>'+renderRow(k,st,cardOn); });+  }+  return '<section class="settings-card" id="'+card.id+'"><h3 class="card-title">'+esc(card.t)+(status?' <span class="card-status'+(okish?' ok':'')+'">'+esc(status)+'</span>':'')+'</h3>'+    +(card.desc?'<p class="card-desc">'+esc(card.desc)+'</p>':'')+body+'</section>';+}+function renderDlg(){+  const st=(SNAP&&SNAP.bridge&&SNAP.bridge.settings)||{};+  st.taskbarMode=st.aumidIcons!=='on'?'plain':(st.threadTiles==='on'?'tiles':'aumid');+  _q('#dlgSide').innerHTML=SET_SECTIONS.map(sec=>'<button class="side-sec'+(_dlgSelected===sec.id?' selected':'')+'" data-jump="'+sec.id+'"><span aria-hidden="true" style="font-size:.7em;opacity:.7">\u25BE</span>'+esc(sec.t)+'</button>'+    +sec.cards.map(c=>'<button class="side-sub'+(_dlgSelected===c.id?' selected':'')+'" data-jump="'+c.id+'">'+esc(c.t)+'</button>').join('')).join('');+  _q('#dlgBody').innerHTML=SET_SECTIONS.map(sec=>'<h2 class="sec-title" id="'+sec.id+'">'+esc(sec.t)+'</h2>'+sec.cards.map(c=>renderCard(c,st)).join('')).join('')+    +'<footer class="settings-footer"><p class="footer-note">Settings are stored on this machine (~/.adom/pup-settings.json) and apply to live windows immediately. Restoring returns every option above to its default; your identity avatar is kept.</p>'+    +'<button class="restore-btn" id="restoreBtn">Restore defaults</button></footer>';+  paintThreadExamples();+}+function openDlg(){+  renderDlg(); _q('#dlgWrap').classList.remove('hidden');+  const dlg=_q('#dlg'), sc=_q('#dlgScroller');+  try{dlg.focus();}catch(e){}+  sc.scrollTop=0; requestAnimationFrame(()=>{sc.scrollTop=0; spyDlg();});+}+function closeDlg(){_q('#dlgWrap').classList.add('hidden');}+let _spyRaf=0,_spyMuteUntil=0;+function spyDlg(){+  const pane=_q('#dlgScroller'); if(!pane) return;+  const line=pane.getBoundingClientRect().top+pane.clientHeight*0.33;+  let cur=null;+  for(const h of pane.querySelectorAll('.sec-title,.settings-card')){ if(h.getBoundingClientRect().top<=line) cur=h.id; else break; }+  if(cur&&cur!==_dlgSelected){_dlgSelected=cur; document.querySelectorAll('#dlgSide [data-jump]').forEach(b=>b.classList.toggle('selected',b.dataset.jump===cur));}+}+let _restoreArmed=0;+// v2.0.172 THREAD TILE EXAMPLES — draw sample taskbar tiles on canvas using the SAME rules as+// renderThreadTileIco on the server (teal #00b8b1, ink #05221f, auto-fit, 2-line wrap on the last+// space or the first hyphen, text anchored BOTTOM-LEFT so the top-right favicon badge stays clear).+// Uses the threads that actually have windows open right now, then fills out with canonical examples+// so the strip always shows a range of name lengths.+function paintThreadExamples(){+  const strip=_q('#texstrip'); if(!strip) return;+  // v2.0.204 (John, the final spec): the examples are FIVE FIXED CANONICAL SAMPLES — wiki page,+  // dashboard app, ti.com, mouser, digikey — rendered identically under every one of the six+  // states, from BUNDLED ground-truth art (each vendor's own favicon, the real category tiles, the+  // real Chrome-for-Testing icon). Always present, never dependent on which windows are open: the+  // rows exist to teach what the modes MEAN.+  const st2=(SNAP&&SNAP.bridge&&SNAP.bridge.settings)||{};+  const mode=st2.aumidIcons!=='on'?'plain':(st2.threadTiles==='on'?'tiles':'aumid');+  const ovOn=st2.overlayBadges!=='off';+  const V='v='+(typeof PAGE_VERSION!=='undefined'?PAGE_VERSION:'0');+  // v2.0.206 (John: the wiki has TWO icon styles — signed-in and public — both are canonical+  // samples): SIX fixed samples per state row.+  const SAMPLES=[+    {tile:'wiki in',  fav:'/dashboard/sample-favicon?n=wiki&'+V,    cat:'wikiin'},+    {tile:'wiki out', fav:'/dashboard/sample-favicon?n=wiki&'+V,    cat:'wiki'},+    {tile:'dash',     fav:'/dashboard/icon.svg?'+V,                 cat:'app'},+    {tile:'ti.com',   fav:'/dashboard/sample-favicon?n=ti&'+V,      cat:'web'},+    {tile:'mouser',   fav:'/dashboard/sample-favicon?n=mouser&'+V,  cat:'web'},+    {tile:'digikey',  fav:'/dashboard/sample-favicon?n=digikey&'+V, cat:'web'},+  ];+  function fb(sm){ return '<span class="mbcard"><img src="'+sm.fav+'" alt=""></span>'; }+  function tileIc(sm,ov){ return '<span class="mic"><canvas width="128" height="128" data-tname="'+esc(sm.tile)+'"></canvas>'+(ov?fb(sm):'')+'</span>'; }+  function aumidIc(sm,ov){ return '<span class="mic"><img class="mbase" src="/dashboard/sample-cat?k='+sm.cat+'&'+V+'" data-catfb="'+sm.cat+'">'+(ov?fb(sm):'')+'</span>'; }+  // v2.0.205 (John: "I want all 5 icons for each of the 6 states"): plain rows show all five+  // samples too — each as the stock Chrome icon (that IS what plain looks like per window), with+  // its OWN site badge in the overlay row. The stacking note stays as a caption, not a collapse.+  function plainIc(sm,ov){ return '<span class="mic"><img class="mbase" src="/dashboard/baseicon?profile=adom-you&'+V+'">'+(ov?fb(sm):'')+'</span>'; }+  function plainGroup(ov){+    var cards=SAMPLES.map(function(sm){+      return '<span style="display:inline-flex;flex-direction:column;width:44px;height:34px;border:1px solid #3a4a52;border-radius:4px;overflow:hidden;background:#e8eaed;flex:none">'+        +'<span style="display:flex;align-items:center;gap:3px;height:12px;background:#202124;padding:0 3px">'+        +'<img src="'+sm.fav+'" style="width:8px;height:8px;object-fit:contain">'+        +'<span style="flex:1"></span><span style="color:#9aa0a6;font-size:7px;line-height:1">\u2715</span></span>'+        +'<span style="flex:1;background:linear-gradient(#ffffff,#e8eaed)"></span></span>';+    }).join('');+    // v2.0.266: this strip of six 44px flex:none cards did NOT wrap, so it was a hard ~298px floor+    // that no amount of shrinking elsewhere could beat — the last 49px of the panel's horizontal+    // overflow (and the seam past .texstrip's background) came from exactly here. Let it wrap.+    return '<div style="display:flex;flex-direction:column;align-items:center;gap:3px;max-width:100%;min-width:0">'+      +'<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:4px;padding:5px 6px;border:1px solid #2f3f46;border-radius:6px;background:#1b2429;max-width:100%">'+cards+'</div>'+      +'<span style="color:#4a5a58;font-size:10px;line-height:1.2">\u25B2 what hovering the ONE button shows</span>'+      +'<span class="mic"><img class="mbase" src="/dashboard/baseicon?profile=adom-you&'+V+'">'+(ov?fb(SAMPLES[3]):'')+'</span>'+      +'</div>';+  }+  function showApplyProgress(){+    // v2.0.214b: a setting click re-renders the whole dialog, destroying anything inserted at+    // click time — so the panel re-finds (or re-creates) itself on EVERY poll tick instead.+    function ensureEl(){+      var el=document.getElementById('applyprog');+      if(el&&el.isConnected) return el;+      var anchor=document.querySelector('.st6'); if(!anchor) return null;+      el=document.createElement('div'); el.id='applyprog';+      el.style.cssText='margin:10px 4px 2px;padding:10px 12px;border:1px solid #2a3f3e;border-radius:8px;background:#0f1817;font:12px/1.8 "JetBrains Mono",Consolas,monospace;color:#9fb3b1';+      (anchor.parentElement||anchor).insertAdjacentElement('afterend',el);+      return el;+    }+    var t0=Date.now();+    function paint(a){+      var el=ensureEl(); if(!el) return false;+      el.style.display='block';+      if(!a || (Date.now()-a.at>60000 && !a.done)){ el.innerHTML='<span style="color:#00b8b1">Applying\u2026</span>'; return false; }+      var h='<style>@keyframes apr{to{transform:rotate(360deg)}}.apspin{display:inline-block;animation:apr 1s linear infinite}</style>';+      a.steps.forEach(function(st){+        var mark = st.state==='done' ? '<span style="color:#3fb950">\u2713</span>'+                 : st.state==='run' ? '<span class="apspin" style="color:#00b8b1">\u25CC</span>'+                 : '<span style="color:#4a5a58">\u25CB</span>';+        h+='<div>'+mark+' '+st.label+'</div>';+      });+      if(a.done) h+='<div style="margin-top:6px;color:#3fb950;font-weight:700">\u2713 Done: your taskbar now matches this state</div>';+      el.innerHTML=h;+      return !!a.done;+    }+    (function tick(){+      fetch('/dashboard/apply-progress').then(function(r){return r.json()}).then(function(j){+        var fin=paint(j&&j.apply);+        if(fin){ setTimeout(function(){ var e2=document.getElementById('applyprog'); if(e2) e2.style.display='none'; },7000); return; }+        if(Date.now()-t0<45000) setTimeout(tick,600);+        else { var e3=ensureEl(); if(e3) e3.innerHTML='<span style="color:#d29922">Still settling in the background\u2026 (check the taskbar in a few seconds)</span>'; }+      }).catch(function(){ if(Date.now()-t0<45000) setTimeout(tick,900); });+    })();+  }+  const states=[+    ['tiles',true, '1. tiles + overlay'],+    ['tiles',false,'2. tiles'],+    ['aumid',true, '3. aumid + overlay'],+    ['aumid',false,'4. aumid'],+    ['plain',true, '5. plain + overlay'],+    ['plain',false,'6. plain'],+  ];+  strip.innerHTML='<div class="st6">'+states.map(function(cfg,i){+    const m=cfg[0],ov=cfg[1],lbl=cfg[2];+    const cur=(m===mode&&ov===ovOn);+    const isDefault=(m==='tiles'&&ov);+    let icons='';+    if(m==='tiles'){ icons=SAMPLES.map(function(sm){return tileIc(sm,ov);}).join(''); }+    else if(m==='aumid'){ icons=SAMPLES.map(function(sm){return aumidIc(sm,ov);}).join(''); }+    else {+      var ptxt = ov+        ? 'ALL pup windows share this ONE stock Chrome button. They stay separate windows: hover the button and Windows fans out a preview of each (drawn above). You just cannot ungroup them on the bar, because the per-app identity that separates buttons is exactly what plain mode removes. The single badge belongs to the most recently updated window.'+        : 'ALL pup windows share this ONE stock Chrome button. They stay separate windows: hover the button and Windows fans out a preview of each (drawn above). You just cannot ungroup them on the bar, because the per-app identity that separates buttons is exactly what plain mode removes.';+      icons=plainGroup(ov)+'<span class="texn" style="align-self:center;flex:1 1 190px;min-width:0;max-width:100%;text-align:left">'+ptxt+'</span>';+    }+    return '<div class="strow6'+(cur?' mcur':'')+'" data-si="'+i+'" title="click to switch to this state">'+      +'<span class="stlbl">'+lbl+(cur?' (current)':'')+(isDefault?' <span class="mdef">default</span>':'')+'</span>'+      +'<div class="sticons">'+icons+'</div></div>';+  }).join('')+'</div>';+  const cap=strip.parentElement?strip.parentElement.querySelector('.texcap'):null;+  if(cap)cap.textContent='Six canonical sample windows (signed-in wiki, public wiki, the pup dashboard app, ti.com, mouser, digikey) rendered under every state so you can compare like for like. Tiles are drawn by the real tile renderer; category icons and the Chrome icon are the real registered art; badges are each site\u2019s own favicon. Click a row to switch to it; a step list shows the apply progress and a green check when your taskbar matches.';+  strip.querySelectorAll('canvas[data-tname]').forEach(function(cv){ drawNameTile(cv.getContext('2d'),128,cv.getAttribute('data-tname')); });+  strip.querySelectorAll('img[data-catfb]').forEach(function(im){+    im.onerror=function(){+      const cv=document.createElement('canvas'); cv.width=128; cv.height=128; cv.className='mbase';+      const ctx=cv.getContext('2d'),box=128;+      rr(ctx,0,0,box,box,box*0.14,'#00b8b1');+      rr(ctx,box*0.18,box*0.18,box*0.64,box*0.64,box*0.1,'#053b38');+      ctx.strokeStyle='#7fe7e2';ctx.lineWidth=box*0.045;+      ctx.beginPath();ctx.arc(box/2,box/2,box*0.19,0,7);ctx.stroke();+      im.replaceWith(cv);+    };+  });+  strip.querySelectorAll('.strow6').forEach(function(row,i){+    row.onclick=function(){+      const c=states[i];+      setSetting('taskbarMode',c[0]);+      setSetting('overlayBadges',c[1]?'on':'off');+      showApplyProgress();+    };+  });+}+function rr(ctx,x,y,w,h,r,fill){ctx.fillStyle=fill;ctx.beginPath();if(ctx.roundRect)ctx.roundRect(x,y,w,h,r);else ctx.rect(x,y,w,h);ctx.fill();}+function drawNameTile(ctx,box,nm){+  rr(ctx,0,0,box,box,0,'#00b8b1');+  const FLOOR=box*0.36;+  const wFor=function(i,total){return (total===2&&i===0)?box*0.80:box*0.92;};+  const setF=function(sz){ctx.font='700 '+sz+"px 'Familjen Grotesk','Segoe UI',sans-serif";};+  const wid=function(t,sz){setF(sz);return ctx.measureText(t).width;};+  const fitSize=function(lines){let sz=box*0.9;+    for(let i=0;i<90;i++){let okf=true;+      for(let j=0;j<lines.length;j++){if(wid(lines[j],sz)>wFor(j,lines.length)){okf=false;break;}}+      if(okf&&lines.length*sz*0.95<=box*0.88)break;sz*=0.95;}+    return sz;};+  const cands=[[nm]];const sp=nm.lastIndexOf(' '),hy=nm.indexOf('-');+  if(sp>0)cands.push([nm.slice(0,sp),nm.slice(sp+1)]);else if(hy>0)cands.push([nm.slice(0,hy+1),nm.slice(hy+1)]);+  if(sp<0&&hy<0&&nm.length>=7){const mm=Math.ceil(nm.length/2);cands.push([nm.slice(0,mm),nm.slice(mm)]);}+  let best=[nm],bf=0;for(const c of cands){const f=fitSize(c);if(f>bf){bf=f;best=c;}}+  if(bf<FLOOR){bf=FLOOR;+    const take=function(str,maxw){let n=0;for(let i=1;i<=str.length;i++){if(wid(str.slice(0,i),FLOOR)<=maxw)n=i;else break;}return n;};+    let rest=String(nm).trim();const out=[];+    for(let ln=0;ln<2&&rest;ln++){+      let n=take(rest,wFor(ln,2));+      if(n>=rest.length){out.push(rest);rest='';break;}+      if(n<1)n=1;+      const seg=rest.slice(0,n+1);+      const bp=Math.max(seg.lastIndexOf(' '),seg.lastIndexOf('-'));+      const cut=(bp>=Math.ceil(n*0.5))?bp+(seg.charAt(bp)==='-'?1:0):n;+      out.push(rest.slice(0,cut).trim());+      rest=rest.slice(cut).replace(/^[ -]+/,'');}+    best=out.filter(Boolean);}+  setF(bf);ctx.fillStyle='#05221f';ctx.textAlign='left';ctx.textBaseline='alphabetic';+  const lh=bf*0.95,mx=box*0.055,baseY=box-box*0.05-bf*0.14;+  best.forEach(function(ln,i){ctx.fillText(ln,mx,baseY-(best.length-1-i)*lh);});+}++async function setSetting(key,value){+  // optimistic: the control already reflects the click; the re-render after the save must not+  // jump the scroller (settings-layout: one continuous pane, position preserved across saves).+  const sc=_q('#dlgScroller'); const keepTop=sc?sc.scrollTop:0;+  const j=await act({action:'set-setting',key,value});+  if(j&&j.ok&&j.settings&&SNAP&&SNAP.bridge){SNAP.bridge.settings=j.settings;renderDlg();if(sc){sc.scrollTop=keepTop;}}+}+document.addEventListener('click',ev=>{+  if(ev.target.closest('#gear')){openDlg();return;}+  if(ev.target.closest('#dlgClose')||ev.target.id==='dlgBg'){closeDlg();return;}+  const j=ev.target.closest('[data-jump]');+  if(j){_dlgSelected=j.dataset.jump;_spyMuteUntil=Date.now()+600;document.querySelectorAll('#dlgSide [data-jump]').forEach(b=>b.classList.toggle('selected',b===j));const el=document.getElementById(j.dataset.jump);if(el)el.scrollIntoView({block:'start'});return;}+  if(ev.target.closest('#restoreBtn')){+    const b=_q('#restoreBtn');+    if(!_restoreArmed){_restoreArmed=Date.now();b.classList.add('armed');b.textContent='Click again to confirm';setTimeout(()=>{if(_restoreArmed&&Date.now()-_restoreArmed>=3900){_restoreArmed=0;b.classList.remove('armed');b.textContent='Restore defaults';}},4000);return;}+    _restoreArmed=0;b.classList.remove('armed');b.textContent='Restoring…';+    act({action:'restore-defaults'}).then(j=>{if(j&&j.ok&&j.settings&&SNAP&&SNAP.bridge){SNAP.bridge.settings=j.settings;}renderDlg();});+    return;+  }+  const t=ev.target.closest('[data-tgl]');+  if(t){if(t.disabled)return;const next=t.classList.contains('is-on')?'off':'on';t.classList.toggle('is-on',next==='on');t.setAttribute('aria-checked',next==='on');setSetting(t.dataset.tgl,next);return;}+  if(ev.target.closest('[data-testcap]')){act({action:'test-caption'});return;}+  if(ev.target.closest('#importScanBtn')){runImportScan();return;}+  if(ev.target.closest('#importRunBtn')){runImport();return;}+});+document.addEventListener('change',ev=>{+  const c=ev.target.closest('[data-imchk]');+  if(c){_importSel[c.dataset.imchk]=c.checked;}+});+document.addEventListener('input',ev=>{+  const r=ev.target.closest('[data-slider]');+  if(r){_q('#val-'+r.dataset.slider).textContent=r.dataset.unit==='px'?r.value+'px':(Number(r.value)/1000).toFixed(1)+'s';}+});+document.addEventListener('keydown',ev=>{ if(ev.key==='Escape'&&!_q('#dlgWrap').classList.contains('hidden')){closeDlg();} });+document.addEventListener('scroll',ev=>{ if(ev.target&&ev.target.id==='dlgScroller'){ if(_spyRaf)return; _spyRaf=requestAnimationFrame(()=>{_spyRaf=0; if(Date.now()<_spyMuteUntil)return; spyDlg();}); } },true);+document.addEventListener('change',ev=>{+  const c=ev.target.closest('[data-choice]');+  if(c){const set=c.closest('.choice-set');if(set)set.querySelectorAll('.choice').forEach(l=>l.dataset.selected=String(l.contains(c)));setSetting(c.dataset.choice,c.value);return;}+  const r=ev.target.closest('[data-slider]');+  if(r){setSetting(r.dataset.slider,Number(r.value));return;}+  const sl=ev.target.closest('[data-sel]');+  if(sl){+    const def=SETTINGS_DEF.find(d=>d.k===sl.dataset.sel);+    if(def&&def.imgBy){const im=sl.closest('.sub').querySelector('.sexample');if(im)im.src='/dashboard/example?n='+def.imgBy[sl.value]+'&v='+PAGE_VERSION;}+    setSetting(sl.dataset.sel,sl.value);+  }+});+(function(){+  const b=_q('#brand'),pop=_q('#pop');let show=null,hide=null;+  function place(){const r=b.getBoundingClientRect();pop.style.left=Math.max(8,r.left)+'px';pop.style.top=(r.bottom+8)+'px';}+  b.addEventListener('mouseenter',()=>{clearTimeout(hide);show=setTimeout(()=>{place();pop.classList.add('show');},600);});+  b.addEventListener('mouseleave',()=>{clearTimeout(show);hide=setTimeout(()=>pop.classList.remove('show'),220);});+  pop.addEventListener('mouseenter',()=>clearTimeout(hide));+  pop.addEventListener('mouseleave',()=>{hide=setTimeout(()=>pop.classList.remove('show'),220);});+})();+document.addEventListener('error',ev=>{const i=ev.target;if(i&&i.tagName==='IMG'&&i.dataset&&i.dataset.fallback==='hide')i.style.visibility='hidden';},true);+document.addEventListener('click',ev=>{+  const tog=ev.target.closest('[data-toggle]');+  if(tog){const id=tog.dataset.toggle;if(COLL.has(id))COLL.delete(id);else COLL.add(id);render();return;}+  const jmp=ev.target.closest('[data-jump]');+  if(jmp){const t=document.getElementById(jmp.dataset.jump);if(t){t.scrollIntoView({behavior:'smooth',block:'start'});t.style.transition='box-shadow .3s';t.style.boxShadow='0 0 0 2px #00b8b1';setTimeout(()=>t.style.boxShadow='',900);}return;}+  const b=ev.target.closest('[data-act]');if(!b)return;+  const a=b.dataset.act,c=b.dataset.confirm;if(c&&!confirm(c))return;+  if(a==='flash-group'||a==='unflash-group'){(b.dataset.ids||'').split(',').filter(Boolean).forEach(sid=>act({action:a==='flash-group'?'flash':'unflash',sessionId:sid}));return;}+  act({action:a,sessionId:b.dataset.sid,tabId:b.dataset.tid});+});+_q('#f').onchange=render; _q('#q').oninput=render;+const es=new EventSource('/dashboard/events');+es.onmessage=e=>{const d=JSON.parse(e.data);if(d.error){_q('#m').innerHTML='<div class="empty">snapshot error: '+esc(d.error)+'</div>';_q('#live').classList.add('stale');return;}SNAP=d;_q('#live').classList.remove('stale');render();};+es.onerror=()=>_q('#live').classList.add('stale');++/* ── the SHARED newbie panel (adom-ui-design 2.0.0) ───────────────────────────────────────────+   pup owns exactly three things here: the phrases, the noun, and what the demo button does.+   Everything else (card, grid, table, icons, clipboard, send with its version gate and+   three-state verification, toasts, tooltips, the X and the header toggle) belongs to the module.+   apiBase is left at its default so the two Hydrogen calls go SAME-ORIGIN and this bridge relays+   them; see the /updater/status and /ai-threads/run-prompt routes in server.js. */+AdomNewbiePanel.mount({+  mount: '#newbie',+  app: 'pup',+  appName: 'Pup',+  slug: 'pup-bridge',+  toggleButton: '#recall',+  demo: {+    label: 'Play the demo',+    meta: '5 steps, about 2 minutes',+    onPlay: function () {+      AdomNewbiePanel.toast('Handing the demo script to a new AI thread. This can take up to two minutes.');+      fetch('/api/demo-send', {method:'POST'})+        .then(function(r){ return r.json(); })+        .then(function(j){ AdomNewbiePanel.toast(j.ok ? 'The demo is running in a new AI thread.' : (j.why || 'Could not reach the AI panel.'), j.ok ? '' : 'err'); })+        .catch(function(){ AdomNewbiePanel.toast('Could not reach the AI panel.', 'err'); });+    }+  },+  phrases: ${JSON.stringify(SAY_PHRASES)}+});++/* Status pills, straight from /api/status so the pills and the LED feed cannot drift. */+async function paintLeds(){+  var host=document.getElementById('leds'); if(!host) return;+  try{+    var j=await (await fetch('/api/status',{cache:'no-store'})).json();+    host.innerHTML=(j.leds||[]).map(function(l){+      var esc=function(t){ return String(t==null?'':t).replace(/&/g,'&amp;').replace(/</g,'&lt;'); };+      return '<span class="led-pill" title="'+esc(l.detail)+'"><span class="d '+esc(l.state)+'"></span>'+        +'<span class="lb">'+esc(l.label)+'</span><span class="sh">'+esc(l.short)+'</span></span>';+    }).join('');+  }catch(e){}+}+paintLeds(); setInterval(paintLeds,15000);+</script></body></html>\ No newline at end of file
rust/session/src/lib.rs+3−1
@@ -37,11 +37,13 @@ pub struct Session {     pub app_id: Option<String>,     pub last_agent_update_ms: u64,     pub next_tab_seq: u32,+    pub emulated: Option<Value>,+    pub last_note: Option<String>, }  impl Session {     pub fn new(id: &str, profile: &str, owner: Option<&str>, owner_container: Option<&str>, cdp_port: u16, pid: Option<u32>) -> Session {-        Session { id: id.into(), profile: profile.into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1 }+        Session { id: id.into(), profile: profile.into(), owner: owner.map(|s| s.into()), owner_container: owner_container.filter(|s| !s.is_empty()).map(|s| s.into()), created_ms: pup_core::now_ms(), created_iso: pup_core::iso_now(), hwnd: None, pid, cdp_port, tabs: Vec::new(), active_tab: 0, foreground: false, app_id: None, last_agent_update_ms: pup_core::now_ms(), next_tab_seq: 1, emulated: None, last_note: None }     }     pub fn next_tab_id(&mut self) -> String { let id = format!("tab-{}", self.next_tab_seq); self.next_tab_seq += 1; id }     pub fn active(&self) -> Option<&Tab> { self.tabs.get(self.active_tab) }
rust/verbs/src/events.rsadded+73
@@ -0,0 +1,73 @@+//! The event pump: one thread per connected browser reading every CDP event and keeping the+//! per-tab state the verbs report (console errors, exceptions, pending JS dialogs, URL/title+//! changes, closed targets). Node had this spread over page.on handlers per tab; here it is one+//! subscription on the browser socket, routed by the flattened sessionId.++use crate::TabState;+use pup_cdp::Cdp;+use pup_session::Sessions;+use serde_json::{json, Value};+use std::collections::HashMap;+use std::sync::{Arc, Mutex};+use std::time::Duration;++pub type TabStates = Arc<Mutex<HashMap<String, TabState>>>;++const ERROR_CAP: usize = 300;++pub fn start_pump(profile: String, cdp: Cdp, sessions: Arc<Sessions>, states: TabStates) {+    let _ = cdp.send(None, "Target.setDiscoverTargets", json!({ "discover": true }), Duration::from_secs(5));+    let rx = cdp.subscribe(None, None);+    std::thread::Builder::new().name(format!("cdp-events-{profile}")).spawn(move || {+        while let Ok(ev) = rx.recv() {+            let sid = ev.session_id.clone().unwrap_or_default();+            match ev.method.as_str() {+                "Runtime.exceptionThrown" => {+                    let d = &ev.params["exceptionDetails"];+                    let text = d["exception"]["description"].as_str().or(d["text"].as_str()).unwrap_or("uncaught exception").to_string();+                    push_error(&states, &sid, json!({ "type": "exception", "text": text, "ts": pup_core::now_ms() }));+                }+                "Runtime.consoleAPICalled" if ev.params["type"] == json!("error") => {+                    let text = ev.params["args"].as_array().map(|a| a.iter().map(|x| x["value"].as_str().map(|s| s.to_string()).or_else(|| x["description"].as_str().map(|s| s.to_string())).unwrap_or_else(|| x["value"].to_string())).collect::<Vec<_>>().join(" ")).unwrap_or_default();+                    push_error(&states, &sid, json!({ "type": "console.error", "text": text, "ts": pup_core::now_ms() }));+                }+                "Page.javascriptDialogOpening" => {+                    let mut st = states.lock().unwrap();+                    st.entry(sid.clone()).or_default().dialog = Some(json!({ "type": ev.params["type"], "message": ev.params["message"], "defaultValue": ev.params["defaultPrompt"], "url": ev.params["url"], "since": pup_core::now_ms() }));+                    pup_core::log(format!("[dialog] {} dialog opened on session {}: {}", ev.params["type"].as_str().unwrap_or("?"), &sid[..sid.len().min(8)], ev.params["message"].as_str().unwrap_or("").chars().take(80).collect::<String>()));+                }+                "Page.javascriptDialogClosed" => { states.lock().unwrap().entry(sid.clone()).or_default().dialog = None; }+                "Page.frameNavigated" if ev.params["frame"]["parentId"].is_null() => {+                    let url = ev.params["frame"]["url"].as_str().unwrap_or("").to_string();+                    states.lock().unwrap().entry(sid.clone()).or_default().url = Some(url.clone());+                    for s in sessions.all() { if s.tabs.iter().any(|t| t.cdp_session == sid) { sessions.with_mut(&s.id, |x| { if let Some(t) = x.tabs.iter_mut().find(|t| t.cdp_session == sid) { t.url = url.clone(); } }); } }+                }+                "Target.targetInfoChanged" => {+                    let info = &ev.params["targetInfo"];+                    let tid = info["targetId"].as_str().unwrap_or("").to_string();+                    let (url, title) = (info["url"].as_str().unwrap_or("").to_string(), info["title"].as_str().unwrap_or("").to_string());+                    for s in sessions.all() { if s.tabs.iter().any(|t| t.target_id == tid) { sessions.with_mut(&s.id, |x| { if let Some(t) = x.tabs.iter_mut().find(|t| t.target_id == tid) { t.url = url.clone(); t.title = title.clone(); } }); } }+                }+                "Target.targetDestroyed" => {+                    let tid = ev.params["targetId"].as_str().unwrap_or("").to_string();+                    for s in sessions.all() {+                        if let Some(pos) = s.tabs.iter().position(|t| t.target_id == tid) {+                            let left = sessions.with_mut(&s.id, |x| { let t = x.tabs.remove(pos); states.lock().unwrap().remove(&t.cdp_session); if x.active_tab >= x.tabs.len() && !x.tabs.is_empty() { x.active_tab = x.tabs.len() - 1; } x.tabs.len() }).unwrap_or(0);+                            if left == 0 { pup_core::log(format!("[health] session \"{}\" dead (its last tab was closed outside pup) — removing", s.id)); sessions.remove(&s.id); sessions.delete_file(&s.id); }+                            else if let Some(x) = sessions.get(&s.id) { sessions.save_file(&x); }+                        }+                    }+                }+                _ => {}+            }+        }+        pup_core::log(format!("[events] pump for profile \"{profile}\" ended (browser connection closed)"));+    }).ok();+}++fn push_error(states: &TabStates, sid: &str, e: Value) {+    let mut st = states.lock().unwrap();+    let t = st.entry(sid.to_string()).or_default();+    t.errors.push(e);+    if t.errors.len() > ERROR_CAP { let n = t.errors.len() - ERROR_CAP; t.errors.drain(0..n); }+}
rust/verbs/src/input.rsadded+325
@@ -0,0 +1,325 @@+//! Input and page verbs: click, type, press_key, hover, scroll, fill, wait, reload, back/forward,+//! set_viewport, errors, dialog, input_dispatch, render_html, fetch_url, maximize. Trusted input+//! goes through Input.dispatch{Mouse,Key}Event; element location is one JS round-trip+//! (scrollIntoView + getBoundingClientRect), exactly the Node "coordinate" method.++use crate::{browser_for, keys, resolve, touch, Ctx};+use pup_cdp::Cdp;+use pup_core::{err, ok, Caller};+use pup_session::{Session, Tab};+use serde_json::{json, Value};+use std::sync::Arc;+use std::time::Duration;++const T: Duration = Duration::from_secs(10);++fn js_str(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) }++/// Session + the tab addressed by args.tabId (default: active) + its live browser.+pub fn resolve_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<(Session, Tab, Arc<crate::BrowserHandle>), Value> {+    let s = resolve(ctx, caller, args)?;+    let t = args.get("tabId").and_then(|t| t.as_str()).map(|id| s.tab_by_id(id).cloned()).unwrap_or_else(|| s.active().cloned()).ok_or_else(|| err("no_tab", "the window has no tabs", None))?;+    let b = browser_for(ctx, &s.profile).ok_or_else(|| err("browser_gone", "the browser behind this window is not connected", Some("pup_close_window then pup_open_window; recovery reconnects live Chromes at boot.")))?;+    Ok((s, t, b))+}++fn out(sess: &Session, tab: &Tab, mut v: Value) -> Value {+    // Node returned these verbs as {success:true, output:"<json string>"}; keep that envelope.+    v["sessionId"] = json!(sess.id); v["tabId"] = json!(tab.tab_id); v["ok"] = json!(true);+    ok(json!({ "success": true, "output": v.to_string(), "window": sess.id, "aiThread": sess.owner }))+}++/// Locate an element by selector (or visible text): scrolls it into view, returns its center.+fn locate(cdp: &Cdp, sid: &str, selector: Option<&str>, text: Option<&str>) -> Result<Option<Value>, Value> {+    let expr = format!(r#"(() => {{+      const sel = {sel}, txt = {txt};+      let el = null;+      if (sel) {{ try {{ el = document.querySelector(sel); }} catch (e) {{ return {{ error: 'bad selector: ' + e.message }}; }} }}+      if (!el && txt) {{+        const t = txt.toLowerCase(); const all = document.querySelectorAll('a,button,input,label,span,div,li,td,th,h1,h2,h3,h4,p,summary,[role=button],[role=link],[role=tab],[role=menuitem]');+        for (const c of all) {{ const own = (c.innerText || c.value || c.getAttribute('aria-label') || '').trim(); if (own && own.toLowerCase().indexOf(t) !== -1) {{ const r = c.getBoundingClientRect(); if (r.width > 0 && r.height > 0) {{ el = c; if (own.toLowerCase() === t) break; }} }} }}+      }}+      if (!el) return null;+      el.scrollIntoView({{ block: 'center', inline: 'center' }});+      const r = el.getBoundingClientRect();+      return {{ x: r.x + r.width / 2, y: r.y + r.height / 2, w: r.width, h: r.height, tag: el.tagName, id: el.id || null }};+    }})()"#, sel = selector.map(js_str).unwrap_or("null".into()), txt = text.map(js_str).unwrap_or("null".into()));+    match cdp.evaluate(sid, &expr, T) {+        Ok(v) if v.get("error").is_some() => Err(err("bad_selector", v["error"].as_str().unwrap_or("bad selector"), None)),+        Ok(v) if v.is_null() => Ok(None),+        Ok(v) => Ok(Some(v)),+        Err(e) => Err(err("eval_failed", e.to_string(), None)),+    }+}++fn mouse(cdp: &Cdp, sid: &str, kind: &str, x: f64, y: f64, button: &str, click_count: u32, modifiers: u32) -> Result<(), Value> {+    cdp.send(Some(sid), "Input.dispatchMouseEvent", json!({ "type": kind, "x": x, "y": y, "button": button, "clickCount": click_count, "modifiers": modifiers }), T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None))+}++fn modifier_bits(mods: &[String]) -> Result<u32, Value> {+    let mut bits = 0u32;+    for m in mods {+        bits |= match m.to_ascii_lowercase().as_str() { "control" | "ctrl" => 2, "shift" => 8, "alt" | "option" => 1, "meta" | "cmd" | "command" => 4, other => return Err(err("bad_modifier", format!("Unknown modifier: {other}"), Some("modifiers must be any of [\"Control\",\"Shift\",\"Alt\",\"Meta\"] (aliases ctrl/cmd/command/option accepted)."))) };+    }+    Ok(bits)+}++fn key_event(cdp: &Cdp, sid: &str, kind: &str, key: &str, code: &str, vk: u32, text: Option<&str>, modifiers: u32) -> Result<(), Value> {+    let mut p = json!({ "type": kind, "key": key, "code": code, "windowsVirtualKeyCode": vk, "nativeVirtualKeyCode": vk, "modifiers": modifiers });+    if let Some(t) = text { if kind == "keyDown" { p["text"] = json!(t); p["unmodifiedText"] = json!(t); } }+    cdp.send(Some(sid), "Input.dispatchKeyEvent", p, T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None))+}++/// One key press (down + up) by name or single character, with the CDP modifier bits held.+fn press(cdp: &Cdp, sid: &str, name: &str, modifiers: u32) -> Result<(), Value> {+    let (key, code, vk, text): (String, String, u32, Option<String>) = match keys::lookup(name) {+        Some(d) => (d.key.into(), d.code.into(), d.key_code, d.text.map(|t| t.to_string()).or_else(|| if d.key.chars().count() == 1 { Some(d.key.to_string()) } else { None })),   // puppeteer: a 1-char key IS its text+        None if name.chars().count() == 1 => (name.into(), String::new(), 0, Some(name.into())),+        None => return Err(err("bad_key", format!("Unknown key \"{name}\""), Some("Use a DOM key name (Enter, Escape, Tab, ArrowDown, F5, a, A, 1 ...)."))),+    };+    key_event(cdp, sid, if text.is_some() { "keyDown" } else { "rawKeyDown" }, &key, &code, vk, text.as_deref(), modifiers)?;+    key_event(cdp, sid, "keyUp", &key, &code, vk, None, modifiers)+}++fn modifier_keys(bits: u32) -> Vec<&'static str> { let mut v = Vec::new(); if bits & 2 != 0 { v.push("Control"); } if bits & 8 != 0 { v.push("Shift"); } if bits & 1 != 0 { v.push("Alt"); } if bits & 4 != 0 { v.push("Meta"); } v }++pub fn click(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let sel = args.get("selector").and_then(|x| x.as_str()); let txt = args.get("text").and_then(|x| x.as_str());+    let clicks = args.get("clickCount").and_then(|c| c.as_u64()).unwrap_or(1) as u32;+    let button = args.get("button").and_then(|x| x.as_str()).unwrap_or("left");+    if sel.is_some() && args.get("domClick").and_then(|d| d.as_bool()) == Some(true) {+        let expr = format!("(() => {{ const el = document.querySelector({}); if (!el) return {{ found: false }}; el.scrollIntoView({{ block: 'center', inline: 'center' }}); el.click(); return {{ found: true, tag: el.tagName }}; }})()", js_str(sel.unwrap()));+        return match b.cdp.evaluate(&t.cdp_session, &expr, T) {+            Ok(v) if v["found"] == json!(true) => { touch(ctx, &s.id, None); out(&s, &t, json!({ "clicked": sel, "method": "domClick", "_hint": "Synthetic element.click() dispatched (NOT trusted). Screenshot to confirm the effect." })) }+            Ok(_) => err("selector_not_found", format!("pup_click(domClick): selector \"{}\" not found.", sel.unwrap()), Some("pup_eval document.querySelectorAll(...) to check the selector; the element may be inside an iframe or shadow root.")),+            Err(e) => err("eval_failed", e.to_string(), None),+        };+    }+    let (x, y, how) = if let (Some(x), Some(y)) = (args.get("x").and_then(|v| v.as_f64()), args.get("y").and_then(|v| v.as_f64())) { (x, y, json!("coordinate")) }+        else if sel.is_some() || txt.is_some() {+            match locate(&b.cdp, &t.cdp_session, sel, txt) { Ok(Some(v)) => (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0), json!("coordinate")), Ok(None) => return err("selector_not_found", format!("pup_click: {} not found", sel.map(|s| format!("selector \"{s}\"")).unwrap_or_else(|| format!("text \"{}\"", txt.unwrap_or("")))), Some("Check the selector with pup_eval; if the control ignores trusted clicks pass domClick:true.")), Err(e) => return e }+        } else { return err("click_target_required", "pup_click needs {selector} or {text} or {x,y}", None) };+    let mods = args.get("modifiers").and_then(|m| m.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect::<Vec<_>>()).unwrap_or_default();+    let bits = match modifier_bits(&mods) { Ok(b) => b, Err(e) => return e };+    if let Err(e) = mouse(&b.cdp, &t.cdp_session, "mouseMoved", x, y, "none", 0, bits) { return e; }+    if let Err(e) = mouse(&b.cdp, &t.cdp_session, "mousePressed", x, y, button, clicks, bits) { return e; }+    if let Err(e) = mouse(&b.cdp, &t.cdp_session, "mouseReleased", x, y, button, clicks, bits) { return e; }+    touch(ctx, &s.id, None);+    out(&s, &t, json!({ "clicked": sel.or(txt).map(|v| v.to_string()).unwrap_or_else(|| format!("{x},{y}")), "method": how, "x": x, "y": y, "_hint": "Click dispatched (isTrusted) at the element's center. Screenshot to confirm the effect; SPA routing may need pup_wait." }))+}++pub fn hover(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let sel = args.get("selector").and_then(|x| x.as_str()); let txt = args.get("text").and_then(|x| x.as_str());+    let (x, y) = if let (Some(x), Some(y)) = (args.get("x").and_then(|v| v.as_f64()), args.get("y").and_then(|v| v.as_f64())) { (x, y) }+        else { match locate(&b.cdp, &t.cdp_session, sel, txt) { Ok(Some(v)) => (v["x"].as_f64().unwrap_or(0.0), v["y"].as_f64().unwrap_or(0.0)), Ok(None) => return err("selector_not_found", "pup_hover: target not found", None), Err(e) => return e } };+    let steps = args.get("steps").and_then(|v| v.as_u64()).unwrap_or(8).max(1) as usize;+    for i in 1..=steps { let f = i as f64 / steps as f64; if let Err(e) = mouse(&b.cdp, &t.cdp_session, "mouseMoved", x * f, y * f, "none", 0, 0) { return e; } std::thread::sleep(Duration::from_millis(12)); }+    out(&s, &t, json!({ "hovered": sel.or(txt).map(|v| v.to_string()).unwrap_or_else(|| format!("{x},{y}")), "x": x, "y": y, "_hint": "Pointer parked over the target; tooltips/hover menus should be visible in the next screenshot." }))+}++pub fn type_text(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let Some(text) = args.get("text").and_then(|x| x.as_str()) else { return err("text_required", "pup_type needs {text}", None) };+    let sel = args.get("selector").and_then(|x| x.as_str());+    if let Some(sel) = sel {+        let expr = format!("(() => {{ const el = document.querySelector({}); if (!el) return false; el.scrollIntoView({{ block: 'center' }}); el.focus(); return true; }})()", js_str(sel));+        match b.cdp.evaluate(&t.cdp_session, &expr, T) { Ok(v) if v == json!(true) => {}, Ok(_) => return err("selector_not_found", format!("pup_type: selector \"{sel}\" not found"), None), Err(e) => return err("eval_failed", e.to_string(), None) }+    }+    if args.get("clear").and_then(|c| c.as_bool()) == Some(true) {+        if let Err(e) = press(&b.cdp, &t.cdp_session, "a", 2) { return e; }+        if let Err(e) = press(&b.cdp, &t.cdp_session, "Delete", 0) { return e; }+    }+    for ch in text.chars() {+        let cs = ch.to_string();+        let r = match keys::lookup(&cs) { Some(d) => { let shift = d.shift_key.is_none() && ch.is_uppercase(); press(&b.cdp, &t.cdp_session, &cs, if shift { 8 } else { 0 }) } None => b.cdp.send(Some(&t.cdp_session), "Input.insertText", json!({ "text": cs }), T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None)) };+        if let Err(e) = r { return e; }+    }+    let read = b.cdp.evaluate(&t.cdp_session, "(() => { const a = document.activeElement; if (!a) return null; const v = a.value !== undefined ? a.value : (a.isContentEditable ? a.innerText : null); return v === null ? null : v.length; })()", T).ok().and_then(|v| v.as_u64());+    let landed = read.map(|n| n >= text.chars().count().min(1) as u64);+    touch(ctx, &s.id, None);+    let mut o = json!({ "typed": format!("{} chars", text.chars().count()), "into": sel.unwrap_or("active element"), "valueLanded": landed, "fieldValueLength": read });+    o["_hint"] = json!(if landed == Some(false) { "KEYSTROKES DISPATCHED BUT THE FIELD IS STILL EMPTY: this control ignores synthetic keystrokes while the window is in the background. Use pup_fill {selector,text} (native setter + input/change events) or foreground the window first." } else { "Typed via trusted keystrokes. valueLanded confirms the field took them (null when the focused element has no value)." });+    out(&s, &t, o)+}++pub fn press_key(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let Some(key) = args.get("key").and_then(|x| x.as_str()) else { return err("key_required", "pup_press_key needs {key}", None) };+    let mods = args.get("modifiers").and_then(|m| m.as_array()).map(|a| a.iter().filter_map(|x| x.as_str().map(|s| s.to_string())).collect::<Vec<_>>()).unwrap_or_default();+    let bits = match modifier_bits(&mods) { Ok(b) => b, Err(e) => return e };+    for m in modifier_keys(bits) { if let Some(d) = keys::lookup(m) { if let Err(e) = key_event(&b.cdp, &t.cdp_session, "rawKeyDown", d.key, d.code, d.key_code, None, bits) { return e; } } }+    let r = press(&b.cdp, &t.cdp_session, key, bits);+    for m in modifier_keys(bits).into_iter().rev() { if let Some(d) = keys::lookup(m) { let _ = key_event(&b.cdp, &t.cdp_session, "keyUp", d.key, d.code, d.key_code, None, 0); } }+    if let Err(e) = r { return e; }+    touch(ctx, &s.id, None);+    out(&s, &t, json!({ "pressed": key, "modifiers": mods, "_hint": "Key dispatched (trusted). Screenshot to confirm." }))+}++pub fn scroll(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let expr = format!(r#"(() => {{+      const dy = {dy}, dx = {dx}, toSel = {ts}, toText = {tt}, block = {bl};+      const scroller = () => {{ const cands = [document.scrollingElement || document.documentElement, ...document.querySelectorAll('*')]; let best = document.scrollingElement || document.documentElement, bestArea = 0;+        for (const el of cands) {{ const cs = getComputedStyle(el); if (!/(auto|scroll)/.test(cs.overflowY) && el !== best) continue; if (el.scrollHeight > el.clientHeight + 4) {{ const a = el.clientWidth * el.clientHeight; if (a > bestArea) {{ bestArea = a; best = el; }} }} }}+        return best; }};+      let target = null;+      if (toSel) {{ try {{ target = document.querySelector(toSel); }} catch (e) {{}} }}+      if (!target && toText) {{ const t = toText.toLowerCase(); for (const c of document.querySelectorAll('*')) {{ const own = (c.innerText || '').trim().toLowerCase(); if (own && own.indexOf(t) !== -1 && c.children.length < 6) {{ target = c; break; }} }} }}+      if (toSel || toText) {{ if (!target) return {{ found: false }}; target.scrollIntoView({{ block: block || 'center', behavior: 'instant' }}); const r = target.getBoundingClientRect(); return {{ found: true, scrolledTo: toSel || toText, top: r.top }}; }}+      const el = scroller(); const before = el.scrollTop; el.scrollBy({{ top: dy, left: dx, behavior: 'instant' }});+      return {{ found: true, scrollElement: el === document.scrollingElement || el === document.documentElement ? 'html' : (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '')), scrollTop: el.scrollTop, scrollLeft: el.scrollLeft, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, moved: el.scrollTop - before }};+    }})()"#, dy = args.get("dy").and_then(|v| v.as_f64()).unwrap_or(0.0), dx = args.get("dx").and_then(|v| v.as_f64()).unwrap_or(0.0), ts = args.get("toSelector").and_then(|v| v.as_str()).map(js_str).unwrap_or("null".into()), tt = args.get("toText").and_then(|v| v.as_str()).map(js_str).unwrap_or("null".into()), bl = args.get("block").and_then(|v| v.as_str()).map(js_str).unwrap_or("null".into()));+    match b.cdp.evaluate(&t.cdp_session, &expr, T) {+        Ok(v) if v["found"] == json!(false) => err("scroll_target_not_found", "pup_scroll: the selector/text was not found", None),+        Ok(v) => { touch(ctx, &s.id, None); out(&s, &t, pup_core::merge(v, json!({ "_hint": "Scrolled the ACTUAL scrolling element (nested-scroll SPAs included). Screenshot to see the new viewport." }))) }+        Err(e) => err("eval_failed", e.to_string(), None),+    }+}++pub fn fill(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let (Some(sel), Some(text)) = (args.get("selector").and_then(|x| x.as_str()), args.get("text").and_then(|x| x.as_str())) else { return err("args_required", "pup_fill needs {selector, text}", None) };+    let expr = format!(r#"(() => {{ const el = document.querySelector({sel}); if (!el) return {{ found: false }};+      const proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : (el.tagName === 'SELECT' ? HTMLSelectElement.prototype : HTMLInputElement.prototype);+      const d = Object.getOwnPropertyDescriptor(proto, 'value'); el.focus();+      if (d && d.set) d.set.call(el, {val}); else if (el.isContentEditable) el.innerText = {val}; else el.value = {val};+      el.dispatchEvent(new Event('input', {{ bubbles: true }})); el.dispatchEvent(new Event('change', {{ bubbles: true }}));+      return {{ found: true, value: el.value !== undefined ? String(el.value).length : null, tag: el.tagName }}; }})()"#, sel = js_str(sel), val = js_str(text));+    match b.cdp.evaluate(&t.cdp_session, &expr, T) {+        Ok(v) if v["found"] == json!(false) => err("selector_not_found", format!("pup_fill: selector \"{sel}\" not found"), None),+        Ok(v) => { touch(ctx, &s.id, None); out(&s, &t, json!({ "filled": sel, "valueLength": v["value"], "tag": v["tag"], "_hint": "Value set through the native setter with input+change events (React/Vue/Angular see it). No keystrokes, no foreground needed." })) }+        Err(e) => err("eval_failed", e.to_string(), None),+    }+}++pub fn wait(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let t0 = std::time::Instant::now();+    if let Some(sel) = args.get("selector").and_then(|x| x.as_str()) {+        let max = Duration::from_millis(args.get("ms").and_then(|m| m.as_u64()).unwrap_or(30000).min(55000));+        let expr = format!("!!document.querySelector({})", js_str(sel));+        loop {+            if b.cdp.evaluate(&t.cdp_session, &expr, T).ok() == Some(json!(true)) { return ok(json!({ "waited": t0.elapsed().as_millis() as u64, "found": sel, "sessionId": s.id, "window": s.id })); }+            if t0.elapsed() > max { return err("wait_timeout", format!("selector \"{sel}\" did not appear within {}ms", max.as_millis()), Some("Screenshot to see what the page shows instead; the selector may be wrong or inside an iframe.")); }+            std::thread::sleep(Duration::from_millis(250));+        }+    }+    let ms = args.get("ms").and_then(|m| m.as_u64()).unwrap_or(1000).min(55000);+    std::thread::sleep(Duration::from_millis(ms));+    ok(json!({ "waited": ms, "sessionId": s.id, "window": s.id }))+}++fn nav_wait(cdp: &Cdp, sid: &str, f: impl FnOnce() -> Result<Value, pup_cdp::CdpError>, wait: Duration) -> Result<bool, Value> {+    let rx = cdp.subscribe(Some(sid), Some("Page.loadEventFired"));+    f().map_err(|e| err("navigate_failed", e.to_string(), None))?;+    Ok(Cdp::wait_event(&rx, wait).is_some())+}++pub fn reload(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let loaded = match nav_wait(&b.cdp, &t.cdp_session, || b.cdp.send(Some(&t.cdp_session), "Page.reload", json!({ "ignoreCache": args.get("ignoreCache").and_then(|x| x.as_bool()).unwrap_or(false) }), T), Duration::from_secs(25)) { Ok(l) => l, Err(e) => return e };+    if let Some(h) = s.hwnd { ctx.host.send_to_bottom_no_activate(h); }+    touch(ctx, &s.id, None);+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "loaded": loaded, "renderCheck": Value::Null, "_verifyRender": crate::VERIFY_RENDER, "_next": ["pup_screenshot"] }))+}++pub fn history(ctx: &Ctx, caller: &Caller, args: &Value, back: bool) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let h = match b.cdp.send(Some(&t.cdp_session), "Page.getNavigationHistory", json!({}), T) { Ok(h) => h, Err(e) => return err("navigate_failed", e.to_string(), None) };+    let cur = h["currentIndex"].as_i64().unwrap_or(0); let entries = h["entries"].as_array().cloned().unwrap_or_default();+    let idx = if back { cur - 1 } else { cur + 1 };+    if idx < 0 || idx as usize >= entries.len() { return out(&s, &t, json!({ "moved": false, "_hint": if back { "No history entry to go back to." } else { "No history entry to go forward to." } })); }+    let id = entries[idx as usize]["id"].clone(); let url = entries[idx as usize]["url"].clone();+    let loaded = match nav_wait(&b.cdp, &t.cdp_session, || b.cdp.send(Some(&t.cdp_session), "Page.navigateToHistoryEntry", json!({ "entryId": id }), T), Duration::from_secs(20)) { Ok(l) => l, Err(e) => return e };+    touch(ctx, &s.id, None);+    out(&s, &t, json!({ "moved": true, "url": url, "loaded": loaded }))+}++pub fn set_viewport(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    if args.get("reset").and_then(|r| r.as_bool()) == Some(true) {+        if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Emulation.clearDeviceMetricsOverride", json!({}), T) { return err("viewport_failed", e.to_string(), None); }+        ctx.sessions.with_mut(&s.id, |x| x.emulated = None);+        return out(&s, &t, json!({ "emulated": false, "reset": true, "_hint": "Viewport override cleared; the page tracks the OS window size again." }));+    }+    let (Some(w), Some(h)) = (args.get("width").and_then(|v| v.as_u64()), args.get("height").and_then(|v| v.as_u64())) else { return err("args_required", "pup_set_viewport needs {width, height} (or reset:true)", None) };+    let dsf = args.get("deviceScaleFactor").and_then(|v| v.as_f64()).unwrap_or(1.0);+    if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Emulation.setDeviceMetricsOverride", json!({ "width": w, "height": h, "deviceScaleFactor": dsf, "mobile": false }), T) { return err("viewport_failed", e.to_string(), None); }+    ctx.sessions.with_mut(&s.id, |x| x.emulated = Some(json!({ "width": w, "height": h, "deviceScaleFactor": dsf })));+    out(&s, &t, json!({ "emulated": true, "applied": { "width": w, "height": h, "deviceScaleFactor": dsf }, "_hint": "Viewport pinned (CDP device metrics). It PERSISTS across navigations until pup_set_viewport {reset:true}." }))+}++pub fn errors(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, _b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let clear = args.get("clear").and_then(|c| c.as_bool()).unwrap_or(true);+    let list = { let mut st = ctx.tabstate.lock().unwrap(); let e = st.entry(t.cdp_session.clone()).or_default(); let v = e.errors.clone(); if clear { e.errors.clear(); } v };+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "tabId": t.tab_id, "count": list.len(), "errors": list, "cleared": clear }))+}++pub fn dialog(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let pending = ctx.tabstate.lock().unwrap().get(&t.cdp_session).and_then(|x| x.dialog.clone());+    let Some(d) = pending else { return ok(json!({ "dialog": Value::Null, "sessionId": s.id, "window": s.id, "_hint": "No dialog is open on this window, so there was nothing to answer. If a verb is timing out, the cause is something else." })) };+    let action = args.get("action").and_then(|a| a.as_str()).unwrap_or("");+    if action != "accept" && action != "dismiss" {+        return pup_core::merge(err("dialog_action_required", format!("a {} dialog is open; pass action:\"accept\" or action:\"dismiss\"", d["type"].as_str().unwrap_or("?")), Some("accept = press the confirming button (Reload / OK). dismiss = press Cancel. pup will not choose for you.")), json!({ "dialog": d }));+    }+    let mut p = json!({ "accept": action == "accept" });+    if let Some(txt) = args.get("promptText").and_then(|x| x.as_str()) { p["promptText"] = json!(txt); }+    if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Page.handleJavaScriptDialog", p, T) { return err("dialog_failed", e.to_string(), None); }+    ctx.tabstate.lock().unwrap().entry(t.cdp_session.clone()).or_default().dialog = None;+    ok(json!({ "handled": action, "dialog": d, "sessionId": s.id, "window": s.id }))+}++pub fn input_dispatch(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let kind = args.get("type").and_then(|x| x.as_str()).unwrap_or("");+    let (x, y) = (args.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0), args.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0));+    let r = match kind {+        "click" => mouse(&b.cdp, &t.cdp_session, "mouseMoved", x, y, "none", 0, 0).and_then(|_| mouse(&b.cdp, &t.cdp_session, "mousePressed", x, y, "left", 1, 0)).and_then(|_| mouse(&b.cdp, &t.cdp_session, "mouseReleased", x, y, "left", 1, 0)),+        "mouseMoved" | "mousePressed" | "mouseReleased" | "mouseWheel" => {+            let mut p = json!({ "type": kind, "x": x, "y": y, "button": args.get("button").and_then(|v| v.as_str()).unwrap_or("left"), "clickCount": 1 });+            if kind == "mouseWheel" { p["deltaX"] = json!(args.get("deltaX").and_then(|v| v.as_f64()).unwrap_or(0.0)); p["deltaY"] = json!(args.get("deltaY").and_then(|v| v.as_f64()).unwrap_or(0.0)); }+            b.cdp.send(Some(&t.cdp_session), "Input.dispatchMouseEvent", p, T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None))+        }+        "keyDown" | "keyUp" | "rawKeyDown" | "char" => b.cdp.send(Some(&t.cdp_session), "Input.dispatchKeyEvent", json!({ "type": kind, "key": args.get("key"), "text": args.get("text"), "code": args.get("code") }), T).map(|_| ()).map_err(|e| err("input_failed", e.to_string(), None)),+        _ => Err(err("bad_type", "type must be click | mouseMoved | mousePressed | mouseReleased | mouseWheel | keyDown | keyUp | rawKeyDown | char", None)),+    };+    if let Err(e) = r { return e; }+    out(&s, &t, json!({ "dispatched": kind, "x": x, "y": y }))+}++pub fn render_html(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let Some(html) = args.get("html").and_then(|x| x.as_str()) else { return err("html_required", "pup_render_html needs {html}", None) };+    let frame = match b.cdp.send(Some(&t.cdp_session), "Page.getFrameTree", json!({}), T) { Ok(v) => v["frameTree"]["frame"]["id"].as_str().unwrap_or("").to_string(), Err(e) => return err("render_failed", e.to_string(), None) };+    if let Err(e) = b.cdp.send(Some(&t.cdp_session), "Page.setDocumentContent", json!({ "frameId": frame, "html": html }), T) { return err("render_failed", e.to_string(), None); }+    let title = b.cdp.evaluate(&t.cdp_session, "document.title", T).ok().and_then(|v| v.as_str().map(|x| x.to_string())).unwrap_or_default();+    touch(ctx, &s.id, args.get("updateNote").and_then(|n| n.as_str()));+    out(&s, &t, json!({ "title": title, "bytes": html.len(), "_hint": "Rendered your HTML in the tab (UTF-8 correct, Page.setDocumentContent). Screenshot to verify." }))+}++pub fn fetch_url(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let Some(url) = args.get("url").and_then(|x| x.as_str()) else { return err("url_required", "pup_fetch_url requires {url}", None) };+    let method = args.get("method").and_then(|x| x.as_str()).unwrap_or("GET");+    let expr = format!(r#"(async () => {{ try {{ const r = await fetch({u}, {{ method: {m}, credentials: 'include', headers: {h} }}); const ct = r.headers.get('content-type') || ''; const text = await r.text(); return {{ ok: r.ok, status: r.status, contentType: ct, length: text.length, text: text.slice(0, 1000000) }}; }} catch (e) {{ return {{ error: e.message }}; }} }})()"#, u = js_str(url), m = js_str(method), h = args.get("headers").cloned().unwrap_or(json!({})));+    match b.cdp.evaluate(&t.cdp_session, &expr, Duration::from_secs(40)) {+        Ok(v) if v.get("error").is_some() => err("fetch_failed", format!("pup_fetch_url failed: {}", v["error"].as_str().unwrap_or("?")), Some("The page's origin must be allowed to fetch that URL (CORS); navigate to the URL instead when it is cross-origin.")),+        Ok(v) if v.get("status").is_some() => ok(json!({ "success": true, "output": v.to_string(), "sessionId": s.id, "window": s.id, "aiThread": s.owner, "url": url, "status": v["status"], "contentType": v["contentType"], "length": v["length"] })),+        Ok(v) => err("fetch_failed", format!("pup_fetch_url: unexpected evaluate result {}", v.to_string().chars().take(200).collect::<String>()), None),+        Err(e) => err("fetch_failed", e.to_string(), None),+    }+}++pub fn maximize(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let (s, t, b) = match resolve_tab(ctx, caller, args) { Ok(x) => x, Err(e) => return e };+    let state = args.get("state").and_then(|x| x.as_str()).unwrap_or("maximized");+    let (wid, _) = match b.cdp.window_for_target(&t.target_id) { Ok(w) => w, Err(e) => return err("window_failed", e.to_string(), None) };+    if let Err(e) = b.cdp.set_window_bounds(wid, json!({ "windowState": "normal" })) { return err("window_failed", e.to_string(), None); }+    if state != "normal" { if let Err(e) = b.cdp.set_window_bounds(wid, json!({ "windowState": state })) { return err("window_failed", e.to_string(), None); } }+    if let Some(h) = s.hwnd { if state != "minimized" { ctx.host.send_to_bottom_no_activate(h); } }+    out(&s, &t, json!({ "state": state, "windowId": wid, "_hint": "Window state changed via CDP without foregrounding it." }))+}
rust/verbs/src/keys.rsadded+261
@@ -0,0 +1,261 @@+//! US keyboard layout for Input.dispatchKeyEvent (generated by tools/gen-keys.py from puppeteer-core's USKeyboardLayout.js, Apache-2.0).++pub struct KeyDef { pub key: &'static str, pub code: &'static str, pub key_code: u32, pub text: Option<&'static str>, pub shift_key: Option<&'static str>, pub shift_key_code: Option<u32>, pub location: u32 }++pub static KEYS: &[(&str, KeyDef)] = &[+    ("0", KeyDef { key: "0", code: "Digit0", key_code: 48, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("1", KeyDef { key: "1", code: "Digit1", key_code: 49, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("2", KeyDef { key: "2", code: "Digit2", key_code: 50, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("3", KeyDef { key: "3", code: "Digit3", key_code: 51, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("4", KeyDef { key: "4", code: "Digit4", key_code: 52, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("5", KeyDef { key: "5", code: "Digit5", key_code: 53, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("6", KeyDef { key: "6", code: "Digit6", key_code: 54, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("7", KeyDef { key: "7", code: "Digit7", key_code: 55, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("8", KeyDef { key: "8", code: "Digit8", key_code: 56, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("9", KeyDef { key: "9", code: "Digit9", key_code: 57, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Power", KeyDef { key: "Power", code: "Power", key_code: 0, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Eject", KeyDef { key: "Eject", code: "Eject", key_code: 0, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Abort", KeyDef { key: "Cancel", code: "Abort", key_code: 3, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Help", KeyDef { key: "Help", code: "Help", key_code: 6, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Backspace", KeyDef { key: "Backspace", code: "Backspace", key_code: 8, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Tab", KeyDef { key: "Tab", code: "Tab", key_code: 9, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Numpad5", KeyDef { key: "Clear", code: "Numpad5", key_code: 12, text: None, shift_key: Some("5"), shift_key_code: Some(101), location: 3 }),+    ("NumpadEnter", KeyDef { key: "Enter", code: "NumpadEnter", key_code: 13, text: Some("\u{d}"), shift_key: None, shift_key_code: None, location: 3 }),+    ("Enter", KeyDef { key: "Enter", code: "Enter", key_code: 13, text: Some("\u{d}"), shift_key: None, shift_key_code: None, location: 0 }),+    ("\\r", KeyDef { key: "Enter", code: "Enter", key_code: 13, text: Some("\u{d}"), shift_key: None, shift_key_code: None, location: 0 }),+    ("\\n", KeyDef { key: "Enter", code: "Enter", key_code: 13, text: Some("\u{d}"), shift_key: None, shift_key_code: None, location: 0 }),+    ("ShiftLeft", KeyDef { key: "Shift", code: "ShiftLeft", key_code: 16, text: None, shift_key: None, shift_key_code: None, location: 1 }),+    ("ShiftRight", KeyDef { key: "Shift", code: "ShiftRight", key_code: 16, text: None, shift_key: None, shift_key_code: None, location: 2 }),+    ("ControlLeft", KeyDef { key: "Control", code: "ControlLeft", key_code: 17, text: None, shift_key: None, shift_key_code: None, location: 1 }),+    ("ControlRight", KeyDef { key: "Control", code: "ControlRight", key_code: 17, text: None, shift_key: None, shift_key_code: None, location: 2 }),+    ("AltLeft", KeyDef { key: "Alt", code: "AltLeft", key_code: 18, text: None, shift_key: None, shift_key_code: None, location: 1 }),+    ("AltRight", KeyDef { key: "Alt", code: "AltRight", key_code: 18, text: None, shift_key: None, shift_key_code: None, location: 2 }),+    ("Pause", KeyDef { key: "Pause", code: "Pause", key_code: 19, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("CapsLock", KeyDef { key: "CapsLock", code: "CapsLock", key_code: 20, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Escape", KeyDef { key: "Escape", code: "Escape", key_code: 27, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Convert", KeyDef { key: "Convert", code: "Convert", key_code: 28, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("NonConvert", KeyDef { key: "NonConvert", code: "NonConvert", key_code: 29, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Space", KeyDef { key: " ", code: "Space", key_code: 32, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Numpad9", KeyDef { key: "PageUp", code: "Numpad9", key_code: 33, text: None, shift_key: Some("9"), shift_key_code: Some(105), location: 3 }),+    ("PageUp", KeyDef { key: "PageUp", code: "PageUp", key_code: 33, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Numpad3", KeyDef { key: "PageDown", code: "Numpad3", key_code: 34, text: None, shift_key: Some("3"), shift_key_code: Some(99), location: 3 }),+    ("PageDown", KeyDef { key: "PageDown", code: "PageDown", key_code: 34, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("End", KeyDef { key: "End", code: "End", key_code: 35, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Numpad1", KeyDef { key: "End", code: "Numpad1", key_code: 35, text: None, shift_key: Some("1"), shift_key_code: Some(97), location: 3 }),+    ("Home", KeyDef { key: "Home", code: "Home", key_code: 36, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Numpad7", KeyDef { key: "Home", code: "Numpad7", key_code: 36, text: None, shift_key: Some("7"), shift_key_code: Some(103), location: 3 }),+    ("ArrowLeft", KeyDef { key: "ArrowLeft", code: "ArrowLeft", key_code: 37, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Numpad4", KeyDef { key: "ArrowLeft", code: "Numpad4", key_code: 37, text: None, shift_key: Some("4"), shift_key_code: Some(100), location: 3 }),+    ("Numpad8", KeyDef { key: "ArrowUp", code: "Numpad8", key_code: 38, text: None, shift_key: Some("8"), shift_key_code: Some(104), location: 3 }),+    ("ArrowUp", KeyDef { key: "ArrowUp", code: "ArrowUp", key_code: 38, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("ArrowRight", KeyDef { key: "ArrowRight", code: "ArrowRight", key_code: 39, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Numpad6", KeyDef { key: "ArrowRight", code: "Numpad6", key_code: 39, text: None, shift_key: Some("6"), shift_key_code: Some(102), location: 3 }),+    ("Numpad2", KeyDef { key: "ArrowDown", code: "Numpad2", key_code: 40, text: None, shift_key: Some("2"), shift_key_code: Some(98), location: 3 }),+    ("ArrowDown", KeyDef { key: "ArrowDown", code: "ArrowDown", key_code: 40, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Select", KeyDef { key: "Select", code: "Select", key_code: 41, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Open", KeyDef { key: "Execute", code: "Open", key_code: 43, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("PrintScreen", KeyDef { key: "PrintScreen", code: "PrintScreen", key_code: 44, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Insert", KeyDef { key: "Insert", code: "Insert", key_code: 45, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Numpad0", KeyDef { key: "Insert", code: "Numpad0", key_code: 45, text: None, shift_key: Some("0"), shift_key_code: Some(96), location: 3 }),+    ("Delete", KeyDef { key: "Delete", code: "Delete", key_code: 46, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("NumpadDecimal", KeyDef { key: "\u{0}", code: "NumpadDecimal", key_code: 46, text: None, shift_key: Some("."), shift_key_code: Some(110), location: 3 }),+    ("Digit0", KeyDef { key: "0", code: "Digit0", key_code: 48, text: None, shift_key: Some(")"), shift_key_code: None, location: 0 }),+    ("Digit1", KeyDef { key: "1", code: "Digit1", key_code: 49, text: None, shift_key: Some("!"), shift_key_code: None, location: 0 }),+    ("Digit2", KeyDef { key: "2", code: "Digit2", key_code: 50, text: None, shift_key: Some("@"), shift_key_code: None, location: 0 }),+    ("Digit3", KeyDef { key: "3", code: "Digit3", key_code: 51, text: None, shift_key: Some("#"), shift_key_code: None, location: 0 }),+    ("Digit4", KeyDef { key: "4", code: "Digit4", key_code: 52, text: None, shift_key: Some("$"), shift_key_code: None, location: 0 }),+    ("Digit5", KeyDef { key: "5", code: "Digit5", key_code: 53, text: None, shift_key: Some("%"), shift_key_code: None, location: 0 }),+    ("Digit6", KeyDef { key: "6", code: "Digit6", key_code: 54, text: None, shift_key: Some("^"), shift_key_code: None, location: 0 }),+    ("Digit7", KeyDef { key: "7", code: "Digit7", key_code: 55, text: None, shift_key: Some("&"), shift_key_code: None, location: 0 }),+    ("Digit8", KeyDef { key: "8", code: "Digit8", key_code: 56, text: None, shift_key: Some("*"), shift_key_code: None, location: 0 }),+    ("Digit9", KeyDef { key: "9", code: "Digit9", key_code: 57, text: None, shift_key: Some("("), shift_key_code: None, location: 0 }),+    ("KeyA", KeyDef { key: "a", code: "KeyA", key_code: 65, text: None, shift_key: Some("A"), shift_key_code: None, location: 0 }),+    ("KeyB", KeyDef { key: "b", code: "KeyB", key_code: 66, text: None, shift_key: Some("B"), shift_key_code: None, location: 0 }),+    ("KeyC", KeyDef { key: "c", code: "KeyC", key_code: 67, text: None, shift_key: Some("C"), shift_key_code: None, location: 0 }),+    ("KeyD", KeyDef { key: "d", code: "KeyD", key_code: 68, text: None, shift_key: Some("D"), shift_key_code: None, location: 0 }),+    ("KeyE", KeyDef { key: "e", code: "KeyE", key_code: 69, text: None, shift_key: Some("E"), shift_key_code: None, location: 0 }),+    ("KeyF", KeyDef { key: "f", code: "KeyF", key_code: 70, text: None, shift_key: Some("F"), shift_key_code: None, location: 0 }),+    ("KeyG", KeyDef { key: "g", code: "KeyG", key_code: 71, text: None, shift_key: Some("G"), shift_key_code: None, location: 0 }),+    ("KeyH", KeyDef { key: "h", code: "KeyH", key_code: 72, text: None, shift_key: Some("H"), shift_key_code: None, location: 0 }),+    ("KeyI", KeyDef { key: "i", code: "KeyI", key_code: 73, text: None, shift_key: Some("I"), shift_key_code: None, location: 0 }),+    ("KeyJ", KeyDef { key: "j", code: "KeyJ", key_code: 74, text: None, shift_key: Some("J"), shift_key_code: None, location: 0 }),+    ("KeyK", KeyDef { key: "k", code: "KeyK", key_code: 75, text: None, shift_key: Some("K"), shift_key_code: None, location: 0 }),+    ("KeyL", KeyDef { key: "l", code: "KeyL", key_code: 76, text: None, shift_key: Some("L"), shift_key_code: None, location: 0 }),+    ("KeyM", KeyDef { key: "m", code: "KeyM", key_code: 77, text: None, shift_key: Some("M"), shift_key_code: None, location: 0 }),+    ("KeyN", KeyDef { key: "n", code: "KeyN", key_code: 78, text: None, shift_key: Some("N"), shift_key_code: None, location: 0 }),+    ("KeyO", KeyDef { key: "o", code: "KeyO", key_code: 79, text: None, shift_key: Some("O"), shift_key_code: None, location: 0 }),+    ("KeyP", KeyDef { key: "p", code: "KeyP", key_code: 80, text: None, shift_key: Some("P"), shift_key_code: None, location: 0 }),+    ("KeyQ", KeyDef { key: "q", code: "KeyQ", key_code: 81, text: None, shift_key: Some("Q"), shift_key_code: None, location: 0 }),+    ("KeyR", KeyDef { key: "r", code: "KeyR", key_code: 82, text: None, shift_key: Some("R"), shift_key_code: None, location: 0 }),+    ("KeyS", KeyDef { key: "s", code: "KeyS", key_code: 83, text: None, shift_key: Some("S"), shift_key_code: None, location: 0 }),+    ("KeyT", KeyDef { key: "t", code: "KeyT", key_code: 84, text: None, shift_key: Some("T"), shift_key_code: None, location: 0 }),+    ("KeyU", KeyDef { key: "u", code: "KeyU", key_code: 85, text: None, shift_key: Some("U"), shift_key_code: None, location: 0 }),+    ("KeyV", KeyDef { key: "v", code: "KeyV", key_code: 86, text: None, shift_key: Some("V"), shift_key_code: None, location: 0 }),+    ("KeyW", KeyDef { key: "w", code: "KeyW", key_code: 87, text: None, shift_key: Some("W"), shift_key_code: None, location: 0 }),+    ("KeyX", KeyDef { key: "x", code: "KeyX", key_code: 88, text: None, shift_key: Some("X"), shift_key_code: None, location: 0 }),+    ("KeyY", KeyDef { key: "y", code: "KeyY", key_code: 89, text: None, shift_key: Some("Y"), shift_key_code: None, location: 0 }),+    ("KeyZ", KeyDef { key: "z", code: "KeyZ", key_code: 90, text: None, shift_key: Some("Z"), shift_key_code: None, location: 0 }),+    ("MetaLeft", KeyDef { key: "Meta", code: "MetaLeft", key_code: 91, text: None, shift_key: None, shift_key_code: None, location: 1 }),+    ("MetaRight", KeyDef { key: "Meta", code: "MetaRight", key_code: 92, text: None, shift_key: None, shift_key_code: None, location: 2 }),+    ("ContextMenu", KeyDef { key: "ContextMenu", code: "ContextMenu", key_code: 93, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("NumpadMultiply", KeyDef { key: "*", code: "NumpadMultiply", key_code: 106, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    ("NumpadAdd", KeyDef { key: "+", code: "NumpadAdd", key_code: 107, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    ("NumpadSubtract", KeyDef { key: "-", code: "NumpadSubtract", key_code: 109, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    ("NumpadDivide", KeyDef { key: "/", code: "NumpadDivide", key_code: 111, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    ("F1", KeyDef { key: "F1", code: "F1", key_code: 112, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F2", KeyDef { key: "F2", code: "F2", key_code: 113, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F3", KeyDef { key: "F3", code: "F3", key_code: 114, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F4", KeyDef { key: "F4", code: "F4", key_code: 115, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F5", KeyDef { key: "F5", code: "F5", key_code: 116, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F6", KeyDef { key: "F6", code: "F6", key_code: 117, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F7", KeyDef { key: "F7", code: "F7", key_code: 118, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F8", KeyDef { key: "F8", code: "F8", key_code: 119, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F9", KeyDef { key: "F9", code: "F9", key_code: 120, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F10", KeyDef { key: "F10", code: "F10", key_code: 121, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F11", KeyDef { key: "F11", code: "F11", key_code: 122, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F12", KeyDef { key: "F12", code: "F12", key_code: 123, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F13", KeyDef { key: "F13", code: "F13", key_code: 124, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F14", KeyDef { key: "F14", code: "F14", key_code: 125, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F15", KeyDef { key: "F15", code: "F15", key_code: 126, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F16", KeyDef { key: "F16", code: "F16", key_code: 127, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F17", KeyDef { key: "F17", code: "F17", key_code: 128, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F18", KeyDef { key: "F18", code: "F18", key_code: 129, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F19", KeyDef { key: "F19", code: "F19", key_code: 130, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F20", KeyDef { key: "F20", code: "F20", key_code: 131, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F21", KeyDef { key: "F21", code: "F21", key_code: 132, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F22", KeyDef { key: "F22", code: "F22", key_code: 133, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F23", KeyDef { key: "F23", code: "F23", key_code: 134, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F24", KeyDef { key: "F24", code: "F24", key_code: 135, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("NumLock", KeyDef { key: "NumLock", code: "NumLock", key_code: 144, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("ScrollLock", KeyDef { key: "ScrollLock", code: "ScrollLock", key_code: 145, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("AudioVolumeMute", KeyDef { key: "AudioVolumeMute", code: "AudioVolumeMute", key_code: 173, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("AudioVolumeDown", KeyDef { key: "AudioVolumeDown", code: "AudioVolumeDown", key_code: 174, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("AudioVolumeUp", KeyDef { key: "AudioVolumeUp", code: "AudioVolumeUp", key_code: 175, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("MediaTrackNext", KeyDef { key: "MediaTrackNext", code: "MediaTrackNext", key_code: 176, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("MediaTrackPrevious", KeyDef { key: "MediaTrackPrevious", code: "MediaTrackPrevious", key_code: 177, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("MediaStop", KeyDef { key: "MediaStop", code: "MediaStop", key_code: 178, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("MediaPlayPause", KeyDef { key: "MediaPlayPause", code: "MediaPlayPause", key_code: 179, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Semicolon", KeyDef { key: ";", code: "Semicolon", key_code: 186, text: None, shift_key: Some(":"), shift_key_code: None, location: 0 }),+    ("Equal", KeyDef { key: "=", code: "Equal", key_code: 187, text: None, shift_key: Some("+"), shift_key_code: None, location: 0 }),+    ("NumpadEqual", KeyDef { key: "=", code: "NumpadEqual", key_code: 187, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    ("Comma", KeyDef { key: ",", code: "Comma", key_code: 188, text: None, shift_key: Some("<"), shift_key_code: None, location: 0 }),+    ("Minus", KeyDef { key: "-", code: "Minus", key_code: 189, text: None, shift_key: Some("_"), shift_key_code: None, location: 0 }),+    ("Period", KeyDef { key: ".", code: "Period", key_code: 190, text: None, shift_key: Some(">"), shift_key_code: None, location: 0 }),+    ("Slash", KeyDef { key: "/", code: "Slash", key_code: 191, text: None, shift_key: Some("?"), shift_key_code: None, location: 0 }),+    ("Backquote", KeyDef { key: "`", code: "Backquote", key_code: 192, text: None, shift_key: Some("~"), shift_key_code: None, location: 0 }),+    ("BracketLeft", KeyDef { key: "[", code: "BracketLeft", key_code: 219, text: None, shift_key: Some("{"), shift_key_code: None, location: 0 }),+    ("Backslash", KeyDef { key: "\\", code: "Backslash", key_code: 220, text: None, shift_key: Some("|"), shift_key_code: None, location: 0 }),+    ("BracketRight", KeyDef { key: "BracketRight", code: "BracketRight", key_code: 221, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Quote", KeyDef { key: "Quote", code: "Quote", key_code: 222, text: None, shift_key: Some("\""), shift_key_code: None, location: 0 }),+    ("AltGraph", KeyDef { key: "AltGraph", code: "AltGraph", key_code: 225, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Props", KeyDef { key: "CrSel", code: "Props", key_code: 247, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Cancel", KeyDef { key: "Cancel", code: "Abort", key_code: 3, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Clear", KeyDef { key: "Clear", code: "Numpad5", key_code: 12, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    ("Shift", KeyDef { key: "Shift", code: "ShiftLeft", key_code: 16, text: None, shift_key: None, shift_key_code: None, location: 1 }),+    ("Control", KeyDef { key: "Control", code: "ControlLeft", key_code: 17, text: None, shift_key: None, shift_key_code: None, location: 1 }),+    ("Alt", KeyDef { key: "Alt", code: "AltLeft", key_code: 18, text: None, shift_key: None, shift_key_code: None, location: 1 }),+    ("Accept", KeyDef { key: "Accept", code: "", key_code: 30, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("ModeChange", KeyDef { key: "ModeChange", code: "", key_code: 31, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    (" ", KeyDef { key: " ", code: "Space", key_code: 32, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Print", KeyDef { key: "Print", code: "", key_code: 42, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Execute", KeyDef { key: "Execute", code: "Open", key_code: 43, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("\\u0000", KeyDef { key: "\u{0}", code: "NumpadDecimal", key_code: 46, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    ("a", KeyDef { key: "a", code: "KeyA", key_code: 65, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("b", KeyDef { key: "b", code: "KeyB", key_code: 66, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("c", KeyDef { key: "c", code: "KeyC", key_code: 67, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("d", KeyDef { key: "d", code: "KeyD", key_code: 68, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("e", KeyDef { key: "e", code: "KeyE", key_code: 69, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("f", KeyDef { key: "f", code: "KeyF", key_code: 70, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("g", KeyDef { key: "g", code: "KeyG", key_code: 71, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("h", KeyDef { key: "h", code: "KeyH", key_code: 72, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("i", KeyDef { key: "i", code: "KeyI", key_code: 73, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("j", KeyDef { key: "j", code: "KeyJ", key_code: 74, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("k", KeyDef { key: "k", code: "KeyK", key_code: 75, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("l", KeyDef { key: "l", code: "KeyL", key_code: 76, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("m", KeyDef { key: "m", code: "KeyM", key_code: 77, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("n", KeyDef { key: "n", code: "KeyN", key_code: 78, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("o", KeyDef { key: "o", code: "KeyO", key_code: 79, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("p", KeyDef { key: "p", code: "KeyP", key_code: 80, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("q", KeyDef { key: "q", code: "KeyQ", key_code: 81, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("r", KeyDef { key: "r", code: "KeyR", key_code: 82, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("s", KeyDef { key: "s", code: "KeyS", key_code: 83, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("t", KeyDef { key: "t", code: "KeyT", key_code: 84, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("u", KeyDef { key: "u", code: "KeyU", key_code: 85, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("v", KeyDef { key: "v", code: "KeyV", key_code: 86, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("w", KeyDef { key: "w", code: "KeyW", key_code: 87, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("x", KeyDef { key: "x", code: "KeyX", key_code: 88, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("y", KeyDef { key: "y", code: "KeyY", key_code: 89, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("z", KeyDef { key: "z", code: "KeyZ", key_code: 90, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Meta", KeyDef { key: "Meta", code: "MetaLeft", key_code: 91, text: None, shift_key: None, shift_key_code: None, location: 1 }),+    ("*", KeyDef { key: "*", code: "NumpadMultiply", key_code: 106, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    ("+", KeyDef { key: "+", code: "NumpadAdd", key_code: 107, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    ("-", KeyDef { key: "-", code: "NumpadSubtract", key_code: 109, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    ("/", KeyDef { key: "/", code: "NumpadDivide", key_code: 111, text: None, shift_key: None, shift_key_code: None, location: 3 }),+    (";", KeyDef { key: ";", code: "Semicolon", key_code: 186, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("=", KeyDef { key: "=", code: "Equal", key_code: 187, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    (",", KeyDef { key: ",", code: "Comma", key_code: 188, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    (".", KeyDef { key: ".", code: "Period", key_code: 190, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("`", KeyDef { key: "`", code: "Backquote", key_code: 192, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("[", KeyDef { key: "[", code: "BracketLeft", key_code: 219, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("\\\\", KeyDef { key: "\\", code: "Backslash", key_code: 220, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("]", KeyDef { key: "]", code: "BracketRight", key_code: 221, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Attn", KeyDef { key: "Attn", code: "", key_code: 246, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("CrSel", KeyDef { key: "CrSel", code: "Props", key_code: 247, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("ExSel", KeyDef { key: "ExSel", code: "", key_code: 248, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("EraseEof", KeyDef { key: "EraseEof", code: "", key_code: 249, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Play", KeyDef { key: "Play", code: "", key_code: 250, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("ZoomOut", KeyDef { key: "ZoomOut", code: "", key_code: 251, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    (")", KeyDef { key: ")", code: "Digit0", key_code: 48, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("!", KeyDef { key: "!", code: "Digit1", key_code: 49, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("@", KeyDef { key: "@", code: "Digit2", key_code: 50, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("#", KeyDef { key: "#", code: "Digit3", key_code: 51, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("%", KeyDef { key: "%", code: "Digit5", key_code: 53, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("^", KeyDef { key: "^", code: "Digit6", key_code: 54, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("&", KeyDef { key: "&", code: "Digit7", key_code: 55, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("(", KeyDef { key: "(", code: "Digit9", key_code: 57, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("A", KeyDef { key: "A", code: "KeyA", key_code: 65, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("B", KeyDef { key: "B", code: "KeyB", key_code: 66, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("C", KeyDef { key: "C", code: "KeyC", key_code: 67, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("D", KeyDef { key: "D", code: "KeyD", key_code: 68, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("E", KeyDef { key: "E", code: "KeyE", key_code: 69, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("F", KeyDef { key: "F", code: "KeyF", key_code: 70, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("G", KeyDef { key: "G", code: "KeyG", key_code: 71, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("H", KeyDef { key: "H", code: "KeyH", key_code: 72, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("I", KeyDef { key: "I", code: "KeyI", key_code: 73, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("J", KeyDef { key: "J", code: "KeyJ", key_code: 74, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("K", KeyDef { key: "K", code: "KeyK", key_code: 75, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("L", KeyDef { key: "L", code: "KeyL", key_code: 76, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("M", KeyDef { key: "M", code: "KeyM", key_code: 77, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("N", KeyDef { key: "N", code: "KeyN", key_code: 78, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("O", KeyDef { key: "O", code: "KeyO", key_code: 79, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("P", KeyDef { key: "P", code: "KeyP", key_code: 80, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Q", KeyDef { key: "Q", code: "KeyQ", key_code: 81, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("R", KeyDef { key: "R", code: "KeyR", key_code: 82, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("S", KeyDef { key: "S", code: "KeyS", key_code: 83, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("T", KeyDef { key: "T", code: "KeyT", key_code: 84, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("U", KeyDef { key: "U", code: "KeyU", key_code: 85, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("V", KeyDef { key: "V", code: "KeyV", key_code: 86, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("W", KeyDef { key: "W", code: "KeyW", key_code: 87, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("X", KeyDef { key: "X", code: "KeyX", key_code: 88, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Y", KeyDef { key: "Y", code: "KeyY", key_code: 89, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("Z", KeyDef { key: "Z", code: "KeyZ", key_code: 90, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    (":", KeyDef { key: ":", code: "Semicolon", key_code: 186, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("<", KeyDef { key: "<", code: "Comma", key_code: 188, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("_", KeyDef { key: "_", code: "Minus", key_code: 189, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    (">", KeyDef { key: ">", code: "Period", key_code: 190, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("?", KeyDef { key: "?", code: "Slash", key_code: 191, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("~", KeyDef { key: "~", code: "Backquote", key_code: 192, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("{", KeyDef { key: "{", code: "BracketLeft", key_code: 219, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("|", KeyDef { key: "|", code: "Backslash", key_code: 220, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("}", KeyDef { key: "}", code: "", key_code: 221, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("\"", KeyDef { key: "\"", code: "Quote", key_code: 222, text: None, shift_key: None, shift_key_code: None, location: 0 }),+    ("SoftLeft", KeyDef { key: "SoftLeft", code: "SoftLeft", key_code: 0, text: None, shift_key: None, shift_key_code: None, location: 4 }),+    ("SoftRight", KeyDef { key: "SoftRight", code: "SoftRight", key_code: 0, text: None, shift_key: None, shift_key_code: None, location: 4 }),+    ("Camera", KeyDef { key: "Camera", code: "Camera", key_code: 44, text: None, shift_key: None, shift_key_code: None, location: 4 }),+    ("Call", KeyDef { key: "Call", code: "Call", key_code: 0, text: None, shift_key: None, shift_key_code: None, location: 4 }),+    ("EndCall", KeyDef { key: "EndCall", code: "EndCall", key_code: 95, text: None, shift_key: None, shift_key_code: None, location: 4 }),+    ("VolumeDown", KeyDef { key: "VolumeDown", code: "VolumeDown", key_code: 182, text: None, shift_key: None, shift_key_code: None, location: 4 }),+    ("VolumeUp", KeyDef { key: "VolumeUp", code: "VolumeUp", key_code: 183, text: None, shift_key: None, shift_key_code: None, location: 4 }),+];++pub fn lookup(name: &str) -> Option<&'static KeyDef> { KEYS.iter().find(|(n, _)| *n == name).map(|(_, d)| d) }
rust/verbs/src/lib.rs+58−9
@@ -15,6 +15,17 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant};  pub mod image;+pub mod events;+pub mod tabs;+pub mod input;+pub mod keys;+pub mod recovery;+pub mod window;+pub mod settings;++/// Per-tab state the event pump maintains (keyed by CDP session id).+#[derive(Debug, Clone, Default)]+pub struct TabState { pub errors: Vec<Value>, pub dialog: Option<Value>, pub url: Option<String>, pub title: Option<String> }  pub const MAX_SCREENSHOT_DIM: u32 = 1568;   // Node: MAX_SCREENSHOT_DIM (Claude's optimal max) const OFFSCREEN: i32 = -32000;              // Node: _OFFX/_OFFY birth position@@ -29,6 +40,7 @@ pub struct Ctx {     pub installer: Arc<Installer>,     pub sessions: Arc<Sessions>,     pub browsers: Mutex<HashMap<String, Arc<BrowserHandle>>>,+    pub tabstate: events::TabStates,     pub bridge_dir: PathBuf,     pub port: u16,     pub started: Instant,@@ -36,12 +48,15 @@ pub struct Ctx { }  impl Ctx {+    pub fn new_tabstate() -> events::TabStates { Arc::new(Mutex::new(HashMap::new())) }     pub fn prefer_cft(&self) -> bool { self.settings.get("toolbarExtension", "on") != "off" }     pub fn shots_dir(&self) -> PathBuf { let d = self.bridge_dir.join("screenshots"); let _ = std::fs::create_dir_all(&d); d } }  /// The verbs this build serves natively.-pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewarm", "pup_status", "pup_list_windows", "pup_my_windows", "pup_open_window", "pup_navigate", "pup_eval", "pup_evaluate", "pup_screenshot", "pup_close_window", "pup_close", "pup_window_info"];+pub const PHASE1_VERBS: &[&str] = &["pup_describe", "pup_readiness", "pup_prewarm", "pup_status", "pup_list_windows", "pup_my_windows", "pup_open_window", "pup_navigate", "pup_eval", "pup_evaluate", "pup_screenshot", "pup_close_window", "pup_close", "pup_window_info",+    "pup_open_tab", "pup_list_tabs", "pup_switch_tab", "pup_close_tab", "pup_switch_window", "pup_click", "pup_hover", "pup_type", "pup_press_key", "pup_scroll", "pup_fill", "pup_wait", "pup_reload", "pup_back", "pup_forward", "pup_set_viewport", "pup_errors", "pup_dialog", "pup_input_dispatch", "pup_render_html", "pup_fetch_url", "pup_maximize",+    "pup_raise_os_window", "pup_focus_window", "pup_lower_os_window", "pup_alert_window", "pup_progress", "pup_configure", "pup_use", "pup_quit_idle_browsers", "pup_rescan"];  pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {     let t0 = Instant::now();@@ -58,6 +73,36 @@ pub fn dispatch(ctx: &Ctx, caller: &Caller, verb: &str, args: &Value) -> Value {         "pup_screenshot" => screenshot(ctx, caller, args),         "pup_close_window" | "pup_close" => close_window(ctx, caller, args),         "pup_window_info" => window_info(ctx, caller, args),+        "pup_open_tab" => tabs::open_tab(ctx, caller, args),+        "pup_list_tabs" => tabs::list_tabs(ctx, caller, args),+        "pup_switch_tab" => tabs::switch_tab(ctx, caller, args),+        "pup_close_tab" => tabs::close_tab(ctx, caller, args),+        "pup_switch_window" => tabs::switch_window(ctx, caller, args),+        "pup_click" => input::click(ctx, caller, args),+        "pup_hover" => input::hover(ctx, caller, args),+        "pup_type" => input::type_text(ctx, caller, args),+        "pup_press_key" => input::press_key(ctx, caller, args),+        "pup_scroll" => input::scroll(ctx, caller, args),+        "pup_fill" => input::fill(ctx, caller, args),+        "pup_wait" => input::wait(ctx, caller, args),+        "pup_reload" => input::reload(ctx, caller, args),+        "pup_back" => input::history(ctx, caller, args, true),+        "pup_forward" => input::history(ctx, caller, args, false),+        "pup_set_viewport" => input::set_viewport(ctx, caller, args),+        "pup_errors" => input::errors(ctx, caller, args),+        "pup_dialog" => input::dialog(ctx, caller, args),+        "pup_input_dispatch" => input::input_dispatch(ctx, caller, args),+        "pup_render_html" => input::render_html(ctx, caller, args),+        "pup_fetch_url" => input::fetch_url(ctx, caller, args),+        "pup_maximize" => input::maximize(ctx, caller, args),+        "pup_raise_os_window" | "pup_focus_window" => window::raise(ctx, caller, args),+        "pup_lower_os_window" => window::lower(ctx, caller, args),+        "pup_alert_window" => window::alert(ctx, caller, args),+        "pup_progress" => window::progress(ctx, caller, args),+        "pup_configure" => settings::configure(ctx, args),+        "pup_use" => settings::use_browser(ctx, args),+        "pup_quit_idle_browsers" => settings::quit_idle_browsers(ctx),+        "pup_rescan" => recovery::rescan(ctx, args),         v if v.starts_with("pup_") || v.starts_with("desktop_record") || v.starts_with("desktop_recorder") || v.starts_with("credential_") => err("not_in_rust_yet", format!("{v} is not served by the Rust bridge yet (Phase 1 carries {} verbs)", PHASE1_VERBS.len()), Some("This is the Rust port of pup (docs/PLAN-rust-port.md). The verb exists in the contract and lands in a later phase; the Node build still serves it on the public tier.")),         v => err("unknown_verb", format!("unknown verb {v}"), Some("pup_describe lists every verb this bridge serves.")),     };@@ -72,7 +117,7 @@ fn window_arg(args: &Value) -> Option<String> {     None } -fn resolve(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<Session, Value> {+pub fn resolve(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<Session, Value> {     let explicit = window_arg(args);     let id = match explicit.clone() {         Some(id) => id,@@ -99,7 +144,9 @@ fn resolve(ctx: &Ctx, caller: &Caller, args: &Value) -> Result<Session, Value> {     } } -fn browser_for(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> { ctx.browsers.lock().unwrap().get(profile).cloned().filter(|b| b.cdp.is_alive()) }+pub fn touch(ctx: &Ctx, sid: &str, note: Option<&str>) { ctx.sessions.with_mut(sid, |x| { x.last_agent_update_ms = pup_core::now_ms(); if let Some(n) = note { x.last_note = Some(n.to_string()); } }); }++pub fn browser_for(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> { ctx.browsers.lock().unwrap().get(profile).cloned().filter(|b| b.cdp.is_alive()) }  /// Get or launch the browser for a profile: reuse a live DevTools port, else spawn-verify each /// candidate in order (Node: getOrLaunchBrowser).@@ -112,6 +159,7 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -             let cand = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()).into_iter().find(|c| kind.contains("Chrome") && (c.kind == "chrome-for-testing" || c.kind == "chrome") || kind.contains("Edg") && c.kind == "edge").unwrap_or(Candidate { kind: "chrome".into(), source: "reconnect".into(), executable_path: PathBuf::new(), build_id: None });             pup_core::log(format!("[launch] reconnected to a live {} on :{port} for profile \"{profile}\"", cand.kind));             let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid: 0, cdp_port: port, candidate: cand, profile_dir, reused: true } });+            events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());             ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());             return Ok(h);         }@@ -135,6 +183,7 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -                     pup_core::log(format!("[launch] {}({}) up on :{} pid {} in {}ms", c.kind, c.source, l.cdp_port, l.pid, t0.elapsed().as_millis()));                     pup_browser::write_default(&*ctx.host, c, false);                     let h = Arc::new(BrowserHandle { cdp, launched: l });+                    events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());                     ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());                     return Ok(h);                 }@@ -147,7 +196,7 @@ fn get_or_launch(ctx: &Ctx, profile: &str, high_fps: bool, insecure_dev: bool) -     Err(err("browser_launch_failed", format!("every browser candidate failed to launch: {}", errors.join(" | ")), Some("pup_readiness {} shows the candidates and the last install error; pup_use can pin a different browser."))) } -fn attach_tab(s: &mut Session, cdp: &Cdp, target_id: &str) -> Result<String, Value> {+pub fn attach_tab(s: &mut Session, cdp: &Cdp, target_id: &str) -> Result<String, Value> {     let sid = cdp.attach(target_id).map_err(|e| err("cdp_attach_failed", e.to_string(), None))?;     let _ = cdp.page_enable(&sid);     let tab_id = s.next_tab_id();@@ -156,7 +205,7 @@ fn attach_tab(s: &mut Session, cdp: &Cdp, target_id: &str) -> Result<String, Val     Ok(tab_id) } -fn refresh_tab(cdp: &Cdp, t: &mut Tab) {+pub fn refresh_tab(cdp: &Cdp, t: &mut Tab) {     if let Ok(info) = cdp.target_info(&t.cdp_session) {         if let Some(u) = info.get("url").and_then(|u| u.as_str()) { t.url = u.into(); }         if let Some(ti) = info.get("title").and_then(|u| u.as_str()) { t.title = ti.into(); }@@ -211,9 +260,9 @@ fn prewarm(ctx: &Ctx, args: &Value) -> Value {     pup_core::merge(rd, json!({ "started": true, "_hint": "Chrome for Testing provisioning is running in the background (or already cached). Poll pup_readiness for percent + ETA; pass wait:true to block until it lands." })) } -fn session_row(ctx: &Ctx, s: &Session) -> Value {+pub fn session_row(ctx: &Ctx, s: &Session) -> Value {     let active_tab = s.active();-    json!({ "sessionId": s.id, "window": s.id, "url": active_tab.map(|t| t.url.clone()), "title": active_tab.map(|t| t.title.clone()), "active": ctx.sessions.active_id().as_deref() == Some(&s.id), "owner": s.owner, "aiThread": s.owner, "profile": s.profile, "tabCount": s.tabs.len(), "hwnd": s.hwnd, "foreground": s.foreground, "ageMinutes": s.age_minutes() })+    json!({ "sessionId": s.id, "window": s.id, "url": active_tab.map(|t| t.url.clone()), "title": active_tab.map(|t| t.title.clone()), "active": ctx.sessions.active_id().as_deref() == Some(&s.id), "owner": s.owner, "aiThread": s.owner, "profile": s.profile, "tabCount": s.tabs.len(), "hwnd": s.hwnd, "foreground": s.foreground, "ageMinutes": s.age_minutes(), "emulatedViewport": s.emulated, "lastAgentUpdate": s.last_agent_update_ms, "errorCount": s.tabs.iter().map(|t| ctx.tabstate.lock().unwrap().get(&t.cdp_session).map(|x| x.errors.len()).unwrap_or(0)).sum::<usize>() }) }  fn status(ctx: &Ctx) -> Value {@@ -319,7 +368,7 @@ fn open_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     resp } -const VERIFY_RENDER: &str = "VERIFY WHAT YOU JUST PUT ON SCREEN — do not assume it rendered. ok:true means the NAVIGATION succeeded; it says NOTHING about whether the page actually painted. Take a screenshot and READ the image: pup_screenshot {} returns a real PNG path. Look for a blank page, an unstyled flash of plain HTML, a framework error overlay, missing images, a dev-server 'Cannot GET /' page, or the wrong build. If it is wrong: fix the source, reload, screenshot AGAIN.";+pub const VERIFY_RENDER: &str = "VERIFY WHAT YOU JUST PUT ON SCREEN — do not assume it rendered. ok:true means the NAVIGATION succeeded; it says NOTHING about whether the page actually painted. Take a screenshot and READ the image: pup_screenshot {} returns a real PNG path. Look for a blank page, an unstyled flash of plain HTML, a framework error overlay, missing images, a dev-server 'Cannot GET /' page, or the wrong build. If it is wrong: fix the source, reload, screenshot AGAIN.";  fn navigate(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };@@ -377,7 +426,7 @@ fn window_info(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     ok(pup_core::merge(session_row(ctx, &s), json!({ "output": json!({ "sessionId": s.id, "hwnd": s.hwnd, "isForeground": s.hwnd.is_some() && s.hwnd == Some(fg), "tabs": s.tabs.iter().map(|t| json!({ "tabId": t.tab_id, "url": t.url, "title": t.title })).collect::<Vec<_>>() }).to_string(), "lastAgentUpdate": s.last_agent_update_ms, "ownerContainer": s.owner_container }))) } -fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+pub fn close_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {     // pup_close {} with no ids closes the caller's window; pup_close {all:true} closes everything of theirs.     let mut targets: Vec<Session> = Vec::new();     if args.get("all").and_then(|b| b.as_bool()) == Some(true) { targets = if caller.thread.is_empty() { ctx.sessions.all() } else { ctx.sessions.owned_by(&caller.thread) }; }
rust/verbs/src/recovery.rsadded+152
@@ -0,0 +1,152 @@+//! Recovery: reconnect the windows a previous bridge left running (session files + a live+//! DevTools port), the 15 s health loop, and pup_rescan. ONE implementation (Node had six paths).+//! A slow Chrome is never killed here (2.0.392 lesson): a probe miss leaves the process and the+//! session file alone for the next tick.++use crate::{events, BrowserHandle, Ctx};+use pup_browser::{Candidate, Launched};+use pup_cdp::Cdp;+use pup_core::ok;+use pup_host::Host;+use pup_session::{Session, Tab};+use serde_json::{json, Value};+use std::sync::Arc;+use std::time::Duration;++/// Connect to a profile's live browser (by its DevToolsActivePort) and register the handle.+fn reconnect_profile(ctx: &Ctx, profile: &str) -> Option<Arc<BrowserHandle>> {+    if let Some(b) = ctx.browsers.lock().unwrap().get(profile) { if b.cdp.is_alive() { return Some(b.clone()); } }+    let profile_dir = pup_browser::profiles_dir(&*ctx.host).join(profile);+    let port = pup_browser::existing_port(&profile_dir)?;+    let cdp = Cdp::connect_port(port, Duration::from_secs(8)).ok()?;+    let product = cdp.version().ok().and_then(|v| v["product"].as_str().map(|s| s.to_string())).unwrap_or_default();+    let cand = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft()).into_iter().find(|c| (product.contains("Edg") && c.kind == "edge") || (!product.contains("Edg") && c.kind != "edge")).unwrap_or(Candidate { kind: "chrome".into(), source: "reconnect".into(), executable_path: Default::default(), build_id: None });+    let h = Arc::new(BrowserHandle { cdp, launched: Launched { pid: 0, cdp_port: port, candidate: cand, profile_dir, reused: true } });+    events::start_pump(profile.into(), h.cdp.clone(), ctx.sessions.clone(), ctx.tabstate.clone());+    ctx.browsers.lock().unwrap().insert(profile.into(), h.clone());+    pup_core::log(format!("Reconnected to Chrome on port {port} for profile \"{profile}\""));+    Some(h)+}++fn page_targets(cdp: &Cdp) -> Vec<Value> { cdp.targets().unwrap_or_default().into_iter().filter(|t| t["type"] == json!("page")).collect() }++/// Rebuild one session from its file against a connected browser. Match by the recorded+/// targetId first (exact), then by URL prefix (the Node rule), never adopting about:blank.+fn recover_one(ctx: &Ctx, file: &Value, h: &BrowserHandle, taken: &mut Vec<String>) -> Option<Session> {+    let id = file["sessionId"].as_str()?.to_string();+    let profile = file["profile"].as_str().unwrap_or("adom-you").to_string();+    let targets = page_targets(&h.cdp);+    let want_tid = file["targetId"].as_str().map(|s| s.to_string());+    let want_url = file["url"].as_str().unwrap_or("").split('?').next().unwrap_or("").to_string();+    let pick = targets.iter().find(|t| want_tid.as_deref().is_some_and(|w| t["targetId"] == json!(w)))+        .or_else(|| targets.iter().find(|t| { let u = t["url"].as_str().unwrap_or(""); !want_url.is_empty() && u != "about:blank" && u.starts_with(&want_url) && !taken.contains(&t["targetId"].as_str().unwrap_or("").to_string()) }))?;+    let tid = pick["targetId"].as_str()?.to_string();+    taken.push(tid.clone());+    let sid = h.cdp.attach(&tid).ok()?;+    let _ = h.cdp.page_enable(&sid);+    let mut s = Session::new(&id, &profile, file["owner"].as_str(), None, h.launched.cdp_port, file["pid"].as_u64().map(|p| p as u32));+    s.created_iso = file["createdAt"].as_str().unwrap_or(&s.created_iso).to_string();+    s.foreground = file["foreground"].as_bool().unwrap_or(false);+    s.app_id = file["appId"].as_str().map(|a| a.to_string());+    let tab_id = s.next_tab_id();+    s.tabs.push(Tab { tab_id, target_id: tid, cdp_session: sid, url: pick["url"].as_str().unwrap_or("").into(), title: pick["title"].as_str().unwrap_or("").into(), error_count: 0 });+    // the OS window: the persisted handle if it is still alive, else the one carrying our tag+    let tag = s.tag_string();+    let hwnd = file["_hwnd"].as_i64().filter(|&h| h > 0 && ctx.host.is_alive(h))+        .or_else(|| ctx.host.windows_for_pids(&[], Some("Chrome_WidgetWin_1")).into_iter().find(|w| ctx.host.has_tag(w.hwnd, &tag)).map(|w| w.hwnd));+    if let Some(hh) = hwnd { s.hwnd = Some(hh); ctx.host.tag(hh, &tag); if !s.foreground { ctx.host.send_to_bottom_no_activate(hh); } }+    Some(s)+}++/// Boot: every session file names a browser we may still own.+pub fn recover_at_boot(ctx: &Ctx) {+    let files = ctx.sessions.read_files();+    if files.is_empty() { pup_core::log("Recovering 0 session(s) from disk..."); return; }+    pup_core::log(format!("Recovering {} session(s) from disk...", files.len()));+    let mut taken: Vec<String> = Vec::new();+    for f in &files {+        let id = f["sessionId"].as_str().unwrap_or("").to_string();+        if id.is_empty() { continue; }+        let pid = f["pid"].as_u64().unwrap_or(0) as u32;+        let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();+        if pid > 0 && !ctx.host.alive(pid) && pup_browser::existing_port(&pup_browser::profiles_dir(&*ctx.host).join(&profile)).is_none() {+            pup_core::log(format!("Session \"{id}\" (PID {pid}) is dead — removing stale file")); ctx.sessions.delete_file(&id); continue;+        }+        let Some(h) = reconnect_profile(ctx, &profile) else { pup_core::log(format!("Session \"{id}\": profile \"{profile}\" has no live DevTools port — leaving the file for the health loop")); continue };+        match recover_one(ctx, f, &h, &mut taken) {+            Some(s) => { pup_core::log(format!("Recovered session \"{id}\" (profile: {profile}, URL: {}, hwnd: {:?})", s.active().map(|t| t.url.clone()).unwrap_or_default(), s.hwnd)); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); }+            None => { pup_core::log(format!("Session \"{id}\" — no matching page in Chrome, removing")); ctx.sessions.delete_file(&id); }+        }+    }+    pup_core::log(format!("Recovery complete. {} session(s) active.", ctx.sessions.count()));+}++/// One health tick: dead browser connections are reconnected (never killed), sessions whose+/// target vanished are dropped, stale files are swept.+pub fn health_tick(ctx: &Ctx) {+    let profiles: Vec<String> = ctx.browsers.lock().unwrap().iter().filter(|(_, b)| !b.cdp.is_alive()).map(|(p, _)| p.clone()).collect();+    for p in profiles {+        pup_core::log(format!("[health] browser for profile \"{p}\" disconnected — attempting reconnect"));+        ctx.browsers.lock().unwrap().remove(&p);+        match reconnect_profile(ctx, &p) {+            Some(h) => {+                let mut taken = Vec::new();+                for s in ctx.sessions.for_profile(&p) {+                    let f = s.file_json();+                    match recover_one(ctx, &f, &h, &mut taken) { Some(ns) => { ctx.sessions.insert(ns.clone()); ctx.sessions.save_file(&ns); } None => { pup_core::log(format!("[health] session \"{}\" not found after reconnect — removing", s.id)); ctx.sessions.remove(&s.id); ctx.sessions.delete_file(&s.id); } }+                }+            }+            None => pup_core::log(format!("[health] could not reconnect profile \"{p}\" — its sessions wait for the next tick (Chrome may be asleep or restarting)")),+        }+    }+    for s in ctx.sessions.all() {+        if let Some(h) = s.hwnd { if !ctx.host.is_alive(h) { ctx.sessions.with_mut(&s.id, |x| x.hwnd = None); } }+    }+    for f in ctx.sessions.read_files() {+        let id = f["sessionId"].as_str().unwrap_or("").to_string(); let pid = f["pid"].as_u64().unwrap_or(0) as u32;+        if !id.is_empty() && !ctx.sessions.exists(&id) && pid > 0 && !ctx.host.alive(pid) { pup_core::log(format!("[health] stale session file \"{id}\" (PID {pid} dead) — deleting")); ctx.sessions.delete_file(&id); }+    }+}++pub fn start_health_loop(ctx: Arc<Ctx>) {+    std::thread::Builder::new().name("health".into()).spawn(move || loop { std::thread::sleep(Duration::from_secs(15)); health_tick(&ctx); }).ok();+}++/// pup_rescan: reconnect dropped connections and rebuild sessions from files; adoptOrphans:true+/// also adopts pages no session owns (as sessions named after their target).+pub fn rescan(ctx: &Ctx, args: &Value) -> Value {+    let before = ctx.sessions.count();+    health_tick(ctx);+    let mut reattached = 0; let mut adopted = 0; let mut reconnected = Vec::new(); let mut unreachable = Vec::new();+    let mut taken: Vec<String> = ctx.sessions.all().iter().flat_map(|s| s.tabs.iter().map(|t| t.target_id.clone())).collect();+    for f in ctx.sessions.read_files() {+        let id = f["sessionId"].as_str().unwrap_or("").to_string();+        if id.is_empty() || ctx.sessions.exists(&id) { continue; }+        let profile = f["profile"].as_str().unwrap_or("adom-you").to_string();+        match reconnect_profile(ctx, &profile) {+            Some(h) => { if !reconnected.contains(&profile) { reconnected.push(profile.clone()); } if let Some(s) = recover_one(ctx, &f, &h, &mut taken) { ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); reattached += 1; } }+            None => { if !unreachable.contains(&profile) { unreachable.push(profile.clone()); } }+        }+    }+    if args.get("adoptOrphans").and_then(|b| b.as_bool()) == Some(true) {+        let handles: Vec<(String, Arc<BrowserHandle>)> = ctx.browsers.lock().unwrap().iter().map(|(p, h)| (p.clone(), h.clone())).collect();+        for (profile, h) in handles {+            for t in page_targets(&h.cdp) {+                let tid = t["targetId"].as_str().unwrap_or("").to_string(); let url = t["url"].as_str().unwrap_or("");+                if taken.contains(&tid) || url == "about:blank" || url.starts_with("chrome://") || url.starts_with("devtools://") { continue; }+                if let Ok(sid) = h.cdp.attach(&tid) {+                    let _ = h.cdp.page_enable(&sid);+                    let id = format!("adopted-{}", &tid[..tid.len().min(8)].to_lowercase());+                    let mut s = Session::new(&id, &profile, None, None, h.launched.cdp_port, None);+                    let tab_id = s.next_tab_id();+                    s.tabs.push(Tab { tab_id, target_id: tid.clone(), cdp_session: sid, url: url.into(), title: t["title"].as_str().unwrap_or("").into(), error_count: 0 });+                    taken.push(tid); ctx.sessions.insert(s.clone()); ctx.sessions.save_file(&s); adopted += 1;+                }+            }+        }+    }+    ok(json!({ "rescanned": true, "liveSessions": ctx.sessions.count(), "before": before, "reattached": reattached, "orphansAdopted": adopted, "profilesReconnected": reconnected, "profilesUnreachable": unreachable, "disconnectedSessions": 0, "_hint": "Reconnected dropped CDP sockets and rebuilt sessions from their files. pup_list_windows shows the result." }))+}++#[allow(dead_code)]+fn _host(_: &dyn Host) {}
rust/verbs/src/settings.rsadded+83
@@ -0,0 +1,83 @@+//! Preference verbs: pup_configure (get/set the ~/.adom/pup-settings.json keys, same names and+//! defaults as the Node build), pup_use (pin the browser), pup_quit_idle_browsers.++use crate::Ctx;+use pup_core::{err, ok};+use serde_json::{json, Value};+use std::time::Duration;++/// key -> (default, allowed values or "int" or "str")+pub const KEYS: &[(&str, &str, &str)] = &[+    ("taskbarMode", "tiles", "tiles|aumid|plain"), ("taskbarGrouping", "split", "split|grouped"), ("aumidIconStyle", "category", "standard|category"),+    ("aumidIcons", "on", "on|off"), ("threadTiles", "on", "on|off"), ("overlayBadges", "on", "on|off"), ("overlaySize", "75", "50|66|75|100"),+    ("jumpLists", "on", "on|off"), ("jlViewTasks", "on", "on|off"), ("jlCloseAll", "on", "on|off"), ("jlDashboard", "on", "on|off"), ("jlAnnotate", "on", "on|off"),+    ("toolbarExtension", "on", "on|off"), ("tbSnipHotkey", "on", "on|off"), ("tbFloatyPencil", "off", "on|off"), ("floatyHitPx", "24", "int"), ("tbOwnerBadge", "on", "on|off"),+    ("annCaptionBar", "on", "on|off"),+    ("openFlash", "on", "on|off"), ("autoFlash", "on", "on|off"), ("agentActivity", "on", "on|off"), ("taskbarActivity", "progress", "progress|flash|off"),+    ("fgWatch", "on", "on|off"), ("fgCaption", "on", "on|off"), ("fgCaptionMs", "3500", "int"), ("notMineCaption", "off", "on|off"),+    ("captureLogins", "on", "on|off"), ("autoLogin", "on", "on|off"),+    ("ownerChip", "on", "on|off"), ("titleTag", "off", "on|off"), ("titlePrefix", "off", "on|off"),+    ("sleepRecovery", "on", "on|off"), ("reportIssues", "on", "on|off"), ("minimalTouch", "on", "on|off"),+];++pub fn snapshot(ctx: &Ctx) -> Value {+    let mut m = serde_json::Map::new();+    for (k, d, _) in KEYS { m.insert(k.to_string(), json!(ctx.settings.get(k, d))); }+    // taskbarMode is derived from the two underlying keys when not set explicitly (Node ladder)+    let mode = ctx.settings.all().get("taskbarMode").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_else(|| if ctx.settings.get("aumidIcons", "on") != "on" { "plain".into() } else if ctx.settings.get("threadTiles", "on") == "on" { "tiles".into() } else { "aumid".into() });+    m.insert("taskbarMode".into(), json!(mode));+    Value::Object(m)+}++pub fn configure(ctx: &Ctx, args: &Value) -> Value {+    let mut changed = Vec::new(); let mut rejected = Vec::new();+    if let Some(o) = args.as_object() {+        for (k, v) in o {+            let Some((_, _, allowed)) = KEYS.iter().find(|(kk, _, _)| kk == k) else { rejected.push(json!({ "key": k, "why": "unknown setting" })); continue };+            let sv = match v { Value::String(s) => s.clone(), Value::Bool(b) => if *b { "on".into() } else { "off".into() }, Value::Number(n) => n.to_string(), _ => { rejected.push(json!({ "key": k, "why": "value must be a string, bool or number" })); continue } };+            let okv = match *allowed { "int" => sv.parse::<i64>().is_ok(), "str" => true, list => list.split('|').any(|a| a == sv) };+            if !okv { rejected.push(json!({ "key": k, "why": format!("value must be one of {allowed}"), "got": sv })); continue }+            let _ = ctx.settings.set(k, json!(sv));+            changed.push(k.clone());+            // the taskbarMode ladder writes its two underlying keys (Node: taskbarMode maps onto them)+            if k == "taskbarMode" { let (a, t) = match sv.as_str() { "plain" => ("off", "off"), "aumid" => ("on", "off"), _ => ("on", "on") }; let _ = ctx.settings.set("aumidIcons", json!(a)); let _ = ctx.settings.set("threadTiles", json!(t)); }+        }+    }+    let out = json!({ "ok": true, "settings": snapshot(ctx), "changed": changed, "rejected": rejected, "_hint": if changed.is_empty() { "Current pup preferences (persisted in ~/.adom/pup-settings.json). Pass {key:value} to change one; every key applies live, no restart." } else { "Saved. Live windows pick the change up on their next identity pass; toolbarExtension changes apply to the NEXT browser launch." } });+    ok(json!({ "success": true, "output": out.to_string(), "settings": out["settings"], "changed": out["changed"], "rejected": out["rejected"] }))+}++pub fn use_browser(ctx: &Ctx, args: &Value) -> Value {+    let cands = pup_browser::launch_candidates(&*ctx.host, ctx.prefer_cft());+    let want = args.get("browser").and_then(|b| b.as_str()).unwrap_or("").to_ascii_lowercase();+    let temporary = args.get("temporary").and_then(|b| b.as_bool()).unwrap_or(false);+    let picked = match want.as_str() {+        "" | "auto" => {+            let p = ctx.host.cft_cache_dir().join("pup-browser-default.json"); let _ = std::fs::remove_file(p);+            return ok(json!({ "success": true, "cleared": true, "picked": cands.first().map(|c| c.to_json()), "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": Value::Null, "output": json!({ "ok": true, "cleared": true }).to_string(), "_hint": "Default pin cleared: pup picks Chrome for Testing when cached (the toolbar build), else the installed browser. Relaunch windows to converge." }));+        }+        "cft" | "chrome-for-testing" => {+            match pup_browser::detect_cft(&*ctx.host) { Some(c) => c, None => { ctx.installer.ensure_background(ctx.host.clone()); return pup_core::merge(err("chrome_for_testing_installing", "Chrome for Testing is not cached yet; fetching it now", Some("Poll pup_readiness until chromeForTestingInstalled:true, then pup_use {browser:\"cft\"} again (or just open a window: it converges on its own).")), json!({ "installing": true, "stillRunning": true, "statusVerb": "pup_readiness" })); } }+        }+        "chrome" | "edge" => match cands.iter().find(|c| c.kind == want) { Some(c) => c.clone(), None => return err("browser_not_found", format!("no installed {want} found on this box"), Some("pup_readiness lists the candidates pup can see.")) },+        other => { let p = std::path::PathBuf::from(other); if p.is_file() { pup_browser::Candidate { kind: "custom".into(), source: "explicit".into(), executable_path: p, build_id: None } } else { return err("bad_browser", "browser must be chrome | edge | cft | auto, or an executable path", None) } }+    };+    pup_browser::write_default(&*ctx.host, &picked, true);+    if temporary { let p = ctx.host.cft_cache_dir().join("pup-browser-default.json"); if let Ok(s) = std::fs::read_to_string(&p) { if let Ok(mut v) = serde_json::from_str::<Value>(&s) { v["temporary"] = json!(true); let _ = std::fs::write(&p, v.to_string()); } } }+    let out = json!({ "ok": true, "picked": picked.to_json(), "temporary": temporary });+    ok(json!({ "success": true, "picked": picked.to_json(), "cleared": false, "candidates": cands.iter().map(|c| c.to_json()).collect::<Vec<_>>(), "defaultBrowser": pup_browser::read_default(&*ctx.host), "output": out.to_string(),+        "_hint": if picked.kind != "chrome-for-testing" { "Pinned. NOTE: the Adom toolbar rides ONLY Chrome for Testing; windows on this browser have no toolbar. A window already open keeps its browser; new launches use the pin." } else { "Pinned to Chrome for Testing (the toolbar build). New launches use it." } }))+}++pub fn quit_idle_browsers(ctx: &Ctx) -> Value {+    let idle: Vec<String> = ctx.browsers.lock().unwrap().keys().filter(|p| ctx.sessions.for_profile(p).is_empty()).cloned().collect();+    let mut quit = Vec::new();+    for p in idle {+        if let Some(b) = ctx.browsers.lock().unwrap().remove(&p) {+            let _ = b.cdp.send(None, "Browser.close", json!({}), Duration::from_secs(5)); b.cdp.close();+            if b.launched.pid > 0 { std::thread::sleep(Duration::from_millis(800)); if ctx.host.alive(b.launched.pid) { ctx.host.kill(b.launched.pid); } }+            quit.push(p);+        }+    }+    ok(json!({ "quit": quit, "count": quit.len(), "remaining": ctx.browsers.lock().unwrap().len(), "_hint": "Browsers with no pup windows were closed; the next open launches a fresh one (with any new extension code)." }))+}
rust/verbs/src/tabs.rsadded+66
@@ -0,0 +1,66 @@+//! Tab verbs: pup_open_tab, pup_list_tabs, pup_switch_tab, pup_close_tab, pup_switch_window.+//! Shapes follow docs/contract/pup_{open_tab,list_tabs,switch_tab,close_tab,switch_window}.json.++use crate::{attach_tab, browser_for, refresh_tab, resolve, session_row, touch, Ctx};+use pup_core::{err, ok, Caller};+use serde_json::{json, Value};+use std::time::Duration;++fn tab_row(ctx: &Ctx, s: &pup_session::Session, t: &pup_session::Tab, active: bool) -> Value {+    let errs = ctx.tabstate.lock().unwrap().get(&t.cdp_session).map(|x| x.errors.len()).unwrap_or(0);+    json!({ "tabId": t.tab_id, "url": t.url, "title": t.title, "active": active, "errorCount": errs, "opener": "agent", "openerTabId": Value::Null, "sessionId": s.id, "window": s.id, "aiThread": s.owner })+}++pub fn open_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };+    let url = args.get("url").and_then(|u| u.as_str()).map(|u| u.trim()).filter(|u| !u.is_empty()).unwrap_or("about:blank").to_string();+    let target = match b.cdp.create_target(&url, false, None) { Ok(t) => t, Err(e) => return err("cdp_create_target_failed", e.to_string(), None) };+    let mut sess = s.clone();+    let tab_id = match attach_tab(&mut sess, &b.cdp, &target) { Ok(t) => t, Err(e) => return e };+    let _ = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": target }), Duration::from_secs(5));+    if url != "about:blank" { if let Some(t) = sess.tabs.last_mut() { let rx = b.cdp.subscribe(Some(&t.cdp_session), Some("Page.loadEventFired")); let _ = pup_cdp::Cdp::wait_event(&rx, Duration::from_secs(15)); refresh_tab(&b.cdp, t); } }+    let row = sess.tabs.last().map(|t| tab_row(ctx, &sess, t, true)).unwrap_or(Value::Null);+    let count = sess.tabs.len();+    ctx.sessions.insert(sess.clone()); ctx.sessions.save_file(&sess);+    touch(ctx, &sess.id, args.get("updateNote").and_then(|n| n.as_str()));+    pup_core::merge(ok(row), json!({ "tabCount": count, "activeTabId": tab_id, "renderCheck": Value::Null, "_hint": "New tab opened and activated in the existing window (no new OS window). Screenshot to verify the render.", "_next": ["pup_screenshot", "pup_switch_tab", "pup_close_tab"] }))+}++pub fn list_tabs(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let mut s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    if let Some(b) = browser_for(ctx, &s.profile) { for t in s.tabs.iter_mut() { refresh_tab(&b.cdp, t); } ctx.sessions.with_mut(&s.id, |x| x.tabs = s.tabs.clone()); }+    let active = s.active().map(|t| t.tab_id.clone());+    let rows: Vec<Value> = s.tabs.iter().map(|t| tab_row(ctx, &s, t, Some(&t.tab_id) == active.as_ref())).collect();+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "count": rows.len(), "tabs": rows, "activeTabId": active, "lastAgentUpdate": s.last_agent_update_ms }))+}++pub fn switch_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    let Some(tab_id) = args.get("tabId").and_then(|t| t.as_str()) else { return err("tab_id_required", "pup_switch_tab needs {tabId}", Some("pup_list_tabs shows the ids.")) };+    let Some(t) = s.tab_by_id(tab_id).cloned() else { return err("tab_not_found", format!("no tab \"{tab_id}\" in window \"{}\"", s.id), Some("pup_list_tabs shows the ids.")) };+    let b = match browser_for(ctx, &s.profile) { Some(b) => b, None => return err("browser_gone", "the browser behind this window is not connected", None) };+    let activated = b.cdp.send(None, "Target.activateTarget", json!({ "targetId": t.target_id }), Duration::from_secs(5)).is_ok();+    ctx.sessions.with_mut(&s.id, |x| { if let Some(i) = x.tabs.iter().position(|tt| tt.tab_id == t.tab_id) { x.active_tab = i; } });+    touch(ctx, &s.id, args.get("updateNote").and_then(|n| n.as_str()));+    let mut t2 = t.clone(); refresh_tab(&b.cdp, &mut t2);+    pup_core::merge(ok(tab_row(ctx, &s, &t2, true)), json!({ "activated": activated, "_hint": "Tab activated within its window (the window itself was NOT raised)." }))+}++pub fn close_tab(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    let t = match args.get("tabId").and_then(|t| t.as_str()).map(|id| s.tab_by_id(id).cloned()).unwrap_or_else(|| s.active().cloned()) { Some(t) => t, None => return err("tab_not_found", "no such tab", None) };+    if s.tabs.len() <= 1 { return crate::close_window(ctx, caller, &json!({ "window": s.id })); }+    if let Some(b) = browser_for(ctx, &s.profile) { let _ = b.cdp.close_target(&t.target_id); }+    let remaining = ctx.sessions.with_mut(&s.id, |x| { x.tabs.retain(|tt| tt.tab_id != t.tab_id); if x.active_tab >= x.tabs.len() { x.active_tab = x.tabs.len().saturating_sub(1); } x.tabs.len() }).unwrap_or(0);+    ctx.tabstate.lock().unwrap().remove(&t.cdp_session);+    let active = ctx.sessions.get(&s.id).and_then(|x| x.active().map(|tt| tt.tab_id.clone()));+    if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "closedTab": t.tab_id, "remaining": remaining, "activeTabId": active }))+}++pub fn switch_window(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    ctx.sessions.set_active(&s.id);+    pup_core::merge(ok(session_row(ctx, &s)), json!({ "activeTabId": s.active().map(|t| t.tab_id.clone()), "background": if s.foreground { "foreground" } else { "backgrounded" }, "ownerSource": "session", "emulatedViewport": Value::Null, "errorCount": 0, "lastAgentUpdate": s.last_agent_update_ms, "_hint": "This window is now the active one for verbs that default to it. It was NOT raised." }))+}
rust/verbs/src/window.rsadded+79
@@ -0,0 +1,79 @@+//! OS-window etiquette verbs: raise (gated on a reason, toast + caption first), lower, alert+//! (taskbar flash), focus (alias of raise), progress (taskbar progress bar). Everything the+//! desktop does here is an ab verb; the host only re-asserts "bottom" in-process.++use crate::{resolve, touch, Ctx};+use pup_core::{err, ok, Caller};+use serde_json::{json, Value};+use std::time::Duration;++const AB_T: Duration = Duration::from_secs(8);++fn hwnd_of(ctx: &Ctx, s: &pup_session::Session) -> Option<i64> {+    s.hwnd.filter(|&h| ctx.host.is_alive(h)).or_else(|| {+        let tag = s.tag_string();+        ctx.host.windows_for_pids(&[], Some("Chrome_WidgetWin_1")).into_iter().find(|w| ctx.host.has_tag(w.hwnd, &tag)).map(|w| { ctx.sessions.with_mut(&s.id, |x| x.hwnd = Some(w.hwnd)); w.hwnd })+    })+}++pub fn raise(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    let reason = args.get("foregroundReason").and_then(|r| r.as_str()).map(|r| r.trim()).filter(|r| r.len() >= 8);+    let Some(reason) = reason else {+        return pup_core::merge(err("foreground_reason_required", "Raise refused: foregroundReason is required.", Some("pup windows stay in the BACKGROUND by default. Pass foregroundReason:\"<why the user asked to see this window>\"; it is captioned on their screen. For a nudge without stealing focus use pup_alert_window.")), json!({ "window": s.id, "sessionId": s.id }));+    };+    let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", Some("pup_rescan {} re-resolves handles; if the window exists it will be tagged again.")) };+    if ctx.host.foreground() == h {+        return ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "focused": true, "placement": "foreground", "windowEtiquette": "foreground", "alreadyForeground": true, "reason": reason, "_hint": "This window is already the user's foreground window; nothing to do." }));+    }+    let thread = if caller.thread.is_empty() { "an AI thread".to_string() } else { caller.thread.clone() };+    if ctx.settings.get("fgCaption", "on") != "off" {+        let ms: u64 = ctx.settings.get("fgCaptionMs", "3500").parse().unwrap_or(3500);+        let _ = ctx.ab.call("desktop_caption", json!({ "text": format!("{thread}: {reason}"), "duration": ms, "expiresInMs": ms, "position": "bottom", "size": "medium", "id": format!("pup-fg-{}", s.id) }), caller, AB_T);+    }+    let _ = ctx.ab.call("notify_user", json!({ "title": format!("pup: {thread} is showing you a window"), "body": reason, "focus": false, "level": "info" }), caller, AB_T);+    let r = ctx.ab.call("desktop_bring_to_front", json!({ "hwnd": h }), caller, AB_T);+    let applied = r.is_ok();+    if !applied { let _ = ctx.ab.call("desktop_window_show", json!({ "hwnd": h }), caller, AB_T); }+    ctx.sessions.with_mut(&s.id, |x| x.foreground = true); touch(ctx, &s.id, Some(reason));+    if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "focused": applied, "applied": applied, "placement": "foreground", "windowEtiquette": "foreground", "reason": reason, "captioned": true, "_hint": "Raised onto the user's screen with a caption and a toast naming why. Send it back with pup_lower_os_window when the demo is over.", "_next": ["pup_lower_os_window", "pup_screenshot"] }))+}++pub fn lower(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", None) };+    let mut applied = ctx.host.send_to_bottom_no_activate(h);+    if ctx.ab.call("desktop_set_window_state", json!({ "hwnd": h, "state": "bottom", "force": true }), caller, AB_T).is_ok() { applied = true; }+    ctx.sessions.with_mut(&s.id, |x| x.foreground = false);+    if let Some(x) = ctx.sessions.get(&s.id) { ctx.sessions.save_file(&x); }+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "applied": applied, "placement": "background", "windowEtiquette": "background", "_hint": "Sent behind the user's windows without stealing focus; still fully drivable." }))+}++pub fn alert(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", None) };+    let stop = args.get("stop").and_then(|b| b.as_bool()).unwrap_or(false);+    let r = if stop { ctx.ab.call("desktop_flash_window", json!({ "hwnd": h, "stop": true }), caller, AB_T) } else { ctx.ab.call("desktop_flash_window", json!({ "hwnd": h }), caller, AB_T) };+    let applied = r.is_ok();+    let ab_error = r.as_ref().err().map(|e| e.to_string());+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "alerted": !stop && applied, "cleared": stop && applied, "applied": applied, "abError": ab_error, "clearMethod": if stop { "desktop_flash_window stop" } else { Value::Null.as_str().unwrap_or("") }, "_hint": if stop { "Taskbar highlight cleared." } else { "Taskbar button flashing (no focus stolen). The user notices it on their own time." } }))+}++pub fn progress(ctx: &Ctx, caller: &Caller, args: &Value) -> Value {+    let s = match resolve(ctx, caller, args) { Ok(s) => s, Err(e) => return e };+    let Some(h) = hwnd_of(ctx, &s) else { return err("no_window_handle", "pup has no OS window handle for this window yet", None) };+    let mode = args.get("mode").and_then(|m| m.as_str()).unwrap_or("indeterminate");+    let progress = match mode {+        "off" | "none" => json!({ "state": "none" }),+        "value" => json!({ "state": "normal", "value": args.get("value").and_then(|v| v.as_f64()).unwrap_or(0.0).clamp(0.0, 100.0) / 100.0 }),+        "error" => json!({ "state": "error" }),+        "paused" => json!({ "state": "paused" }),+        _ => json!({ "state": "indeterminate" }),+    };+    let mut p = json!({ "hwnd": h, "progress": progress });+    if let Some(note) = args.get("note").and_then(|n| n.as_str()) { p["overlay"] = json!({ "tooltip": format!("Adom Pup — {note}") }); }+    let applied = ctx.ab.call("desktop_taskbar", p, caller, AB_T).is_ok();+    touch(ctx, &s.id, args.get("note").and_then(|n| n.as_str()));+    ok(json!({ "sessionId": s.id, "window": s.id, "aiThread": s.owner, "mode": mode, "applied": applied, "_hint": "Taskbar progress painted on this window's button. Clear it with mode:\"off\" when the long task ends." }))+}