← Commit history

Thread Tiles legibility and Settings: enforce a font-size floor so long thread names are truncated rather than shrunk to unreadable, add the missing Thread Tiles toggle to the Settings dialog with live-rendered example taskbar icons, retitle the dialog to Settings, and cut enum_windows traffic by coalescing fresh callers, slowing the birth poll, and ignoring the button nudge's own window events.

John Lauer ·786c5f79b0 ·2mo ago ·parent 154a3db
4 files changed +163−27
adom-bridge-puppeteer-manifest.json+4−4
@@ -1,10 +1,10 @@ {   "manifest_version": 1,   "name": "puppeteer",-  "version": "2.0.171",-  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.171/adom-bridge-puppeteer-v2.0.171.zip",-  "sha256": "b20e8a79d8f7ec09f9ec035dd19768e9c1b53819b844986deac79645ae893073",-  "size": 912414,+  "version": "2.0.172",+  "url": "https://wiki.adom.inc/download/adom/pup-bridge/2.0.172/adom-bridge-puppeteer-v2.0.172.zip",+  "sha256": "5cb1b23742a4d1886a5d3c1dc2725d4bd6a52bcae9b9f0b56ebc0a79ba6de29c",+  "size": 916473,   "verbPrefixes": [     "browser_",     "desktop_recorder_",
src/BRIDGE_VERSION+1−1
@@ -1 +1 @@-2.0.171+2.0.172
src/bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "puppeteer",   "displayName": "Puppeteer (Chrome for Testing + Native)",-  "version": "2.0.171",+  "version": "2.0.172",   "description": "Drives a browser ALREADY on the machine \u2014 installed Chrome, else Microsoft Edge (every Windows PC ships with Edge) \u2014 launched with a FRESH isolated profile, so the common case has NO ~150 MB Chrome-for-Testing download and it never touches the user's real tabs/logins. Binds its local server to LOOPBACK (127.0.0.1) only, so no Windows Firewall 'allow Node through the firewall' prompt ever pops. Chrome for Testing is fetched only as a LAST RESORT if the machine has no Chromium browser at all. SPAWN-VERIFIED fallback: pup tries each detected browser (Chrome \u2192 Edge \u2192 CfT) and actually launches it, falling through to the next if one won't start (and handling browsers like Edge that relaunch onto another PID), so a corrupt/partial install never hard-fails an open. The browser that launches is cached as the default (persisted across restarts) so it isn't re-probed each time. On spawn the bridge SELF-WARMS: detects browsers + disk, and only if the box has no Chrome/Edge and no cached CfT does it background-fetch CfT so the first open is instant. pup_use pins a specific browser (chrome | edge | cft | auto); {browser:'chrome',install:true} downloads & installs REAL Google Chrome (user-scoped, no admin) on an Edge-only box. pup_readiness (READ-ONLY) reports which browser will be used + the full candidate list + the default + free disk. Every failure returns a specific errorCode + _hint (chrome_install_no_disk, chrome_download_network, chrome_install_permission, chrome_stable_not_detected, \u2026). Launches with FULL browser permissions by default for full debug (strictPermissions:true opts out). Headful automation: open/close windows+tabs, navigate, screenshot, eval JS, record. pup is a GENERIC, non-profile browser only \u2014 every session is a fresh isolated Chrome/Edge process, never the user's real signed-in browser; profile defaults to sessionId. To drive the user's REAL Chrome/Edge (their profile, cookies, SSO), use the Adom browser EXTENSION's nbrowser_* verbs \u2014 a separate bridge, not pup's job. macOS (v1.9.74+): full parity with mac-native mechanics \u2014 background-by-default is the app-level hide (windows stay drivable/screenshottable while hidden), raise/lower map to unhide+activate/hide, pup_alert_window posts a native toast whose click focuses the window, pup_use {browser:'chrome',install:true} installs Chrome from the official dmg, and taskbar identity/badging remains Windows-only.",   "homepage": "https://wiki.adom.inc/adom/adom-bridge",   "author": "Adom Inc.",
src/server.js+157−21
@@ -4301,7 +4301,7 @@ function writeAumidIcon(appId, iconPath) { // NAME rendered on the teal tile. The name is rendered in the real Adom font (Familjen Grotesk) by // CHROME — the ONE renderer guaranteed to have the woff2 — into a 256px PNG, which sharp downscales // to a multi-size .ico. Rendered once per thread name and cached on disk.-const THREAD_TILE_GEN = 't5';   // t5: bottom-left-anchored text (2.0.170, overlay owns top-right). Gen bump = virgin AUMIDs + fresh ab icon-handle cache keys; the janitor reaps prior gens+const THREAD_TILE_GEN = 't6';   // t6: font-size FLOOR + truncate-not-shrink (2.0.172, John: small text unreadable); bottom-left anchored. Gen bump = virgin AUMIDs + fresh ab icon-handle cache keys; the janitor reaps prior gens const THREAD_TILE_DIR = path.join(os.tmpdir(), 'pup-thread-tiles', THREAD_TILE_GEN); let _fgFontB64 = null; function fgFontB64() {@@ -4397,21 +4397,53 @@ async function renderThreadTileIco(page, name) {           const box = 256, cv = document.createElement('canvas'); cv.width = box; cv.height = box;           const ctx = cv.getContext('2d');           ctx.fillStyle = '#00b8b1'; ctx.fillRect(0, 0, box, box);-          const fit = (text) => {-            const lines = text.split('\n'); let s = box * 0.9;-            for (let i = 0; i < 90; i++) { ctx.font = '700 ' + s + "px FGtile,'Segoe UI',sans-serif"; let w = 0; for (const ln of lines) w = Math.max(w, ctx.measureText(ln).width); if (w <= box * 0.90 && lines.length * s * 0.95 <= box * 0.88) break; s *= 0.95; }+          // v2.0.172 (John: "some text is too small to read — go bigger and only show as many chars+          // as you can get in"). Shrink-to-fit alone made long names microscopic ("rock climbing+          // berlin" wrapped to 3 unreadable lines). Now there is a hard FLOOR on the font size: a+          // name that cannot fit at the floor is TRUNCATED instead of shrunk, so every tile stays+          // legible at 32px and shows the leading characters that identify the thread. The floor is+          // calibrated to "reclaimed" (John's readable reference: 2 lines, ~4-5 chars each).+          // Line 1 of a 2-line tile also stays narrower than line 2 to duck the top-right badge.+          const FLOOR = box * 0.36;+          const wFor = (i, total) => (total === 2 && i === 0) ? box * 0.80 : box * 0.92;+          const setF = (s) => { ctx.font = '700 ' + s + "px FGtile,'Segoe UI',sans-serif"; };+          const wid = (t, s) => { setF(s); return ctx.measureText(t).width; };+          const fitSize = (lines) => {+            let s = box * 0.9;+            for (let i = 0; i < 90; i++) {+              let ok = true;+              for (let j = 0; j < lines.length; j++) { if (wid(lines[j], s) > wFor(j, lines.length)) { ok = false; break; } }+              if (ok && lines.length * s * 0.95 <= box * 0.88) break;+              s *= 0.95;+            }             return s;           };-          const cands = [nm]; const sp = nm.lastIndexOf(' '), hy = nm.indexOf('-');-          if (sp > 0) cands.push(nm.slice(0, sp) + '\n' + nm.slice(sp + 1)); else if (hy > 0) cands.push(nm.slice(0, hy + 1) + '\n' + nm.slice(hy + 1));-          if (sp < 0 && hy < 0 && nm.length >= 7) { const m = Math.ceil(nm.length / 2); cands.push(nm.slice(0, m) + '\n' + nm.slice(m)); }-          let best = nm, bf = 0; for (const t of cands) { const f = fit(t); if (f > bf) { bf = f; best = t; } }-          // v2.0.170 (John): anchor the name BOTTOM-LEFT instead of centered — the overlay badge-          // owns the top-right corner, so bottom-left keeps the most text visible under it.-          ctx.font = '700 ' + bf + "px FGtile,'Segoe UI',sans-serif"; ctx.fillStyle = '#05221f'; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';-          const lines = best.split('\n'), lh = bf * 0.95;-          const mx = box * 0.055, baseY = box - box * 0.05 - bf * 0.14;   // left margin + descender room-          lines.forEach((ln, i) => ctx.fillText(ln, mx, baseY - (lines.length - 1 - i) * lh));+          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 m = Math.ceil(nm.length / 2); cands.push([nm.slice(0, m), nm.slice(m)]); }+          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 = (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);+          }+          // v2.0.170 (John): anchor the name BOTTOM-LEFT — the overlay badge owns the top-right+          // corner, so bottom-left keeps the most text visible under it.+          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((ln, i) => ctx.fillText(ln, mx, baseY - (best.length - 1 - i) * lh));           return cv.toDataURL('image/png');         } catch (e) { return null; }       }, name, fgFontB64()), 8000, 'tile.evaluate');@@ -4478,8 +4510,10 @@ function pruneStaleAumids() { // the same script skips the nudge when the window is FOREGROUND (blinking the user's active window // for 600ms would be disruptive). Fire-and-forget; ~1-2s of powershell incl. Add-Type, once per // window per thread appId.+let _nudgeUntil = 0;   // v2.0.172: while set, ignore window-opened events caused by our own nudge function nudgeTaskbarButton(hwnd, sessionId) {   if (process.platform !== 'win32' || !hwnd) return;+  _nudgeUntil = Date.now() + 6000;   const ps = `$s=@' using System; using System.Runtime.InteropServices; public static class SW {@@ -7147,15 +7181,35 @@ async function hwndBelongsToPup(hwnd, session) { // a brand-new window the instant it appears are the birth-time set-diff and the off-screen // re-capture move-detect loops; both pass {fresh:true} and bypass the cache entirely. const ENUM_CACHE_MS = 1500;+// v2.0.172 (John, 3rd report: "are you still spamming ab with enum_windows?"). He was right, and+// the 2.0.151 cache was never the whole story: {fresh:true} bypassed BOTH the cache AND the+// in-flight coalescing, and the two fresh callers are POLL LOOPS — birth capture (up to 20 rounds)+// and off-screen re-capture (up to 8) — so every window open sprayed ~8 enum calls/second at ab,+// and opening 7 windows multiplied that. Fresh callers genuinely cannot use a 1.5s snapshot (they+// must see a brand-new window), but they can share ONE call with each other: a 250ms freshness+// window is far below the ~100ms a window takes to appear, yet collapses simultaneous pollers and+// halves each loop's rate. Net: same detection latency, a fraction of the ab log noise.+const ENUM_FRESH_MS = 250; let _enumInflight = null;+let _enumFreshInflight = null; let _enumCache = { at: 0, rows: null };+let _enumCalls = 0;            // lifetime count of REAL ab calls (exposed on /status for auditing)+let _enumWindow = { at: 0, n: 0 };   // rolling 60s rate, so "is it spamming?" is answerable with data async function enumChromeWidgetWindows(opts) {   if (process.platform !== 'win32') return [];   const fresh = !!(opts && opts.fresh);+  if (fresh) {+    if (_enumCache.rows && Date.now() - _enumCache.at < ENUM_FRESH_MS) return _enumCache.rows;+    if (_enumFreshInflight) return _enumFreshInflight;+  }   if (!fresh && _enumCache.rows && Date.now() - _enumCache.at < ENUM_CACHE_MS) return _enumCache.rows;   if (!fresh && _enumInflight) return _enumInflight;   const run = (async () => {     try {+      _enumCalls++;+      const _now = Date.now();+      if (_now - _enumWindow.at > 60000) _enumWindow = { at: _now, n: 0 };+      _enumWindow.n++;       const d = abPayload(await chrome.abCommand('desktop_enum_windows', { className: 'Chrome_WidgetWin_1', includeOffscreen: true }, { timeoutMs: 8000 }));       const rows = [];       for (const w of ((d && d.windows) || [])) {@@ -7168,7 +7222,8 @@ async function enumChromeWidgetWindows(opts) {       return rows;     } catch (e) { return []; }   })();-  if (!fresh) { _enumInflight = run; run.finally(() => { if (_enumInflight === run) _enumInflight = null; }); }+  if (fresh) { _enumFreshInflight = run; run.finally(() => { if (_enumFreshInflight === run) _enumFreshInflight = null; }); }+  else { _enumInflight = run; run.finally(() => { if (_enumInflight === run) _enumInflight = null; }); }   return run; } // v1.9.309 (John: "how many times did you say you couldn't look up a window by title... you keep@@ -7400,7 +7455,7 @@ async function resolveSessionHwndByBounds(session, sessionId) {               const moved = rr.filter(r => !hwndOwnedByOtherSession(r.h, sessionId) && _before.has(r.h) && _before.get(r.h) !== `${r.L},${r.T},${r.W},${r.H}`);               if (moved.length === 1) got = moved[0].h;               else if (moved.length > 1) { console.log(`[hwnd] "${sessionId}" re-capture saw ${moved.length} windows move at once — refusing (concurrent motion)`); break; }-              else await new Promise(r => setTimeout(r, 120));+              else await new Promise(r => setTimeout(r, 260));             }             if (!got) console.log(`[hwnd] "${sessionId}" re-capture probe: no unique moved window — leaving bare`);             // restore the ORIGINAL rect exactly (never dump the window at 0,0 — that repositioning@@ -7943,7 +7998,12 @@ async function drainWindowOpenedEvents() {     //   2. At most one check per THROTTLE_MS, no matter how many events arrive.     // v1.9.47: a window-opened event may also mean an UNMANAGED window appeared (openerless popup),     // which the splittable check below cannot see — it only looks at tabs we already track.+    // v2.0.172: pup's OWN thread-tile button nudge (hide + re-show) fires window-opened, which used+    // to kick a reclaim sweep — pup reacting to itself, and a burst of enum calls right when a+    // window opens. Ignore the ring while a nudge is in flight; a real new window is still caught by+    // the next drain or the 90s safety-net sweep.     const THROTTLE_RECLAIM = 4000;+    if (_nudgeUntil && Date.now() < _nudgeUntil) return;     if (Date.now() - _lastReclaim > THROTTLE_RECLAIM) {       _lastReclaim = Date.now();       reclaimUnmanagedWindows().catch(() => {});@@ -9421,7 +9481,10 @@ async function createPageInNewWindow(browser, profileName) {         if (!fresh) await new Promise(r => setTimeout(r, 100));       }       if (process.platform === 'win32') {-        for (let i = 0; i < 20 && !capturedHwnd; i++) {+        // v2.0.172: 20x120ms -> 12x260ms. The window normally appears on round 1-2, so the tail+        // rounds were pure ab noise; the total wait (~3.1s) is unchanged and the shared 250ms fresh+        // window means concurrent births now cost ONE enum instead of one each.+        for (let i = 0; i < 12 && !capturedHwnd; i++) {           const rows = await enumChromeWidgetWindows({ fresh: true });           const fresh2 = rows.filter(r => !beforeHwnds.has(Number(r.h)) && !hwndOwnedByOtherSession(r.h, null));           if (fresh2.length === 1) { capturedHwnd = fresh2[0].h; break; }@@ -9434,7 +9497,7 @@ async function createPageInNewWindow(browser, profileName) {             for (const r of pool) for (const sc of [1, 1.25, 1.5, 1.75, 2]) { const e = Math.abs(r.L - Math.round(birthLeft * sc)); if (e < bestErr) { bestErr = e; best = r.h; } }             capturedHwnd = best; break;           }-          await new Promise(r => setTimeout(r, 120));+          await new Promise(r => setTimeout(r, 260));         }       }     });@@ -11100,6 +11163,12 @@ select,input{background:#111b23;color:#e6edf3;border:1px solid #23303c;border-ra .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} .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}@@ -11205,8 +11274,8 @@ footer{color:#5c6c7a;font-size:11px;padding:8px 18px 22px} <div id="toasts"></div> <div id="dlgWrap" class="hidden">   <div id="dlgBg"></div>-  <div id="dlg" role="dialog" aria-label="pup settings">-    <div class="dlghead">pup settings<button class="ibtn" id="dlgClose" title="close settings" aria-label="close"><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 id="dlg" role="dialog" aria-label="Settings">+    <div class="dlghead">Settings<button class="ibtn" id="dlgClose" title="close settings" aria-label="close"><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 id="dlgBody"></div>   </div> </div>@@ -11391,6 +11460,7 @@ const SETTINGS_DEF=[  {k:'aumidIcons',l:'AUMID taskbar icons',info:'AUMID = Application User Model ID, a feature built into Windows itself. Every taskbar button is really a GROUP: Windows collects windows that share the same ID onto one button. Normally all windows of one app share the app ID, which is why 5 Chrome windows stack under one Chrome button. But the ID can be set PER WINDOW: pup writes a unique ID onto each pup window using the Windows property system (the same mechanism apps like Office use), and Windows then gives that window its own taskbar button with its own icon. The catch, and why this is an experiment: whenever a window ID changes, Windows destroys and recreates its taskbar button, and that churn is a suspect in the Chrome crashes. pup stamps each window exactly once, at creation, to minimize it.',d:'EXPERIMENT: give every pup window its OWN taskbar icon by stamping a per-window identity. Toggle on/off to test whether AUMID stamping is what crashes Chrome. Off = all windows share one Chrome button (current mode). Already-stamped windows keep their icon until closed.',img:'aumid'},  {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 — e.g. 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'],dep:'aumidIcons',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'],dep:'aumidIcons',imgBy:{standard:'iconstylestd',category:'iconstylecat'}},+ {k:'threadTiles',l:'Thread Tiles',info:'Every AI thread you work in (the --ai-thread name: "kicad", "ah build", "shotlog") gets ONE taskbar button, and that button\'s icon is the thread NAME itself, drawn in the Adom font on the Adom teal tile. All windows and tabs that thread owns collapse onto it, so a glance at the taskbar tells you which of your threads have windows open and where each one is. The name is rendered bottom-left so the little favicon badge in the top-right corner (which shows what the active tab is) covers as little of it as possible. Short names print big; longer ones wrap to two lines. Takes precedence over AUMID grouping and icon style; windows with no owning thread fall back to those normal modes.',d:'Group every window of one AI thread under ONE taskbar button whose icon is the thread NAME on the Adom teal tile. Examples below are live-rendered by the same code that draws the real icons.',dep:'aumidIcons',threadex:1},  {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. Off by default. Quirk: the FIRST right-click can blip the menu closed — right-click again ~1s 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'},@@ -11487,9 +11557,71 @@ function renderDlg(){     const testRow=def.testflash?'<div class="sub"><span class="sdesc">'+((SNAP&&SNAP.counts&&SNAP.counts.windows)||0)+' pup window(s) open right now; their taskbar buttons will flash. Note: Windows never flashes the button of the window you are LOOKING AT, so if this dashboard is your active window its own button stays unlit.</span>'       +'<button class="btn sm" data-act="flash-all" title="flash every open pup taskbar button so you can see what this looks like">test flash</button>'       +'<button class="btn sm" data-act="unflash-all" title="clear every taskbar flash">clear flash</button></div>':'';-    return '<div class="srow"><div class="stxt"><div class="slbl">'+esc(def.l)+(def.info?' <span class="ibadge" data-tip="'+esc(def.info)+'">i</span>':'')+'</div><div class="sdesc">'+esc(def.d)+'</div>'+exImg+testRow+'</div>'+    // v2.0.172 (John: "in that section you should have tons of example taskbar icons like the aumid+    // section, so the user understands the difference"): Thread Tiles examples are LIVE-DRAWN by the+    // dialog (same teal, same font, same bottom-left anchor + wrap rules as the real .ico renderer),+    // so they can never drift from the icons Windows actually shows, and they need no shipped PNGs.+    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 active tab\'s favicon.</div></div>':'';+    return '<div class="srow"><div class="stxt"><div class="slbl">'+esc(def.l)+(def.info?' <span class="ibadge" data-tip="'+esc(def.info)+'">i</span>':'')+'</div><div class="sdesc">'+esc(def.d)+'</div>'+exImg+threadEx+testRow+'</div>'       +'<div class="tgl'+(on?' is-on':'')+'" role="switch" tabindex="0" aria-checked="'+on+'" aria-label="'+esc(def.l)+'" data-tgl="'+def.k+'"></div></div>';   }).join('')+importSectionHtml();+  paintThreadExamples();+}+// 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;+  const live=[];+  try{ ((SNAP&&SNAP.windows)||[]).forEach(w=>{const t=w.owner||w.ownerThread;if(t&&live.indexOf(t)<0)live.push(t);}); }catch(e){}+  const fill=['kicad','nb','fusion','ah build','shotlog','adom-tts','pup bridge maintenance'];+  const names=live.concat(fill.filter(f=>live.indexOf(f)<0)).slice(0,8);+  strip.innerHTML=names.map(n=>'<div class="tex"><canvas width="128" height="128" data-tname="'+esc(n)+'"></canvas>'+    +'<span class="texn">'+esc(n)+'</span></div>').join('');+  strip.querySelectorAll('canvas[data-tname]').forEach(cv=>{+    const nm=cv.getAttribute('data-tname'), box=128, ctx=cv.getContext('2d');+    ctx.fillStyle='#00b8b1'; ctx.fillRect(0,0,box,box);+    // Same rules as renderThreadTileIco on the server: font-size FLOOR, truncate rather than shrink.+    const FLOOR=box*0.36;+    const wFor=(i,total)=>(total===2&&i===0)?box*0.80:box*0.92;+    const setF=(s)=>{ctx.font='700 '+s+"px 'Familjen Grotesk','Segoe UI',sans-serif";};+    const wid=(t,s)=>{setF(s);return ctx.measureText(t).width;};+    const fitSize=(lines)=>{let s=box*0.9;+      for(let i=0;i<90;i++){let ok=true;+        for(let j=0;j<lines.length;j++){if(wid(lines[j],s)>wFor(j,lines.length)){ok=false;break;}}+        if(ok&&lines.length*s*0.95<=box*0.88)break;s*=0.95;}+      return s;};+    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 m=Math.ceil(nm.length/2);cands.push([nm.slice(0,m),nm.slice(m)]);}+    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=(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((ln,i)=>ctx.fillText(ln,mx,baseY-(best.length-1-i)*lh));+    // the favicon badge that rides the top-right corner on the real button+    const bs=box*0.34,bx=box-bs-box*0.02,by=box*0.02;+    ctx.fillStyle='#8b5cf6';ctx.beginPath();+    if(ctx.roundRect)ctx.roundRect(bx,by,bs,bs,bs*0.22);else ctx.rect(bx,by,bs,bs);+    ctx.fill();+    ctx.fillStyle='#0d1117';const r=bs*0.11;+    [[0.32,0.34],[0.66,0.34],[0.32,0.68],[0.66,0.68]].forEach(p=>{ctx.beginPath();ctx.arc(bx+bs*p[0],by+bs*p[1],r,0,7);ctx.fill();});+  }); } async function setSetting(key,value){   const j=await act({action:'set-setting',key,value});@@ -12059,6 +12191,10 @@ const server = http.createServer(async (req, res) => {         toolbar: { enabled: pupSettings().toolbarExtension !== 'off', ownerBadge: pupSettings().tbOwnerBadge !== 'off', snipHotkey: pupSettings().tbSnipHotkey !== 'off', floaty: pupSettings().tbFloatyPencil !== 'off' },         browserCount: browsers.size,         sessionCount: sessions.size,+        // v2.0.172: enum_windows call accounting, so "is pup spamming ab?" is answered with a+        // number instead of an argument. lastMin is REAL ab calls in the last 60s (cache hits and+        // coalesced callers are not counted, which is the point).+        enumWindows: { total: _enumCalls, lastMin: (Date.now() - _enumWindow.at < 60000 ? _enumWindow.n : 0) },         activeSession: activeSessionId,         sessions: sessionList,         chrome: (chrome && chrome.readiness) ? chrome.readiness() : { ready: false, initializing: true },