[object Object]

← back to Desktop Dotbar

dotbar: fix '0 live' (scan timeout) + resizable bar (drag/Tab/Shift+Tab)

ef1d433db12d43f9be50e2c9cb35ef8cc524d5b7 · 2026-09-16 08:31:20 -0700 · Steve Abrams

- 0 live: allcolordots --json takes ~9s but server execFile cap was 8s -> every
  refresh timed out -> empty -> '0 live'. Bumped to 25s. Now shows 48 live.
- Resizable strip: drag the bottom grip (screen-Y absolute setBarH), or Tab=expand /
  Shift+Tab=contract while the pointer is over the bar (hover-scoped globalShortcut so
  Tab isn't hijacked fleet-wide). Height clamped 22-140px, persisted to .barh.
- Launcher: start-bar.command used `command -v node` which is empty under launchd
  kickstart (no homebrew in PATH) -> server never started on a launchd restart. Added
  absolute-path fallback so launchctl kickstart works.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AuZ7T3ffxRGXj5rrwgbquD

Files touched

Diff

commit ef1d433db12d43f9be50e2c9cb35ef8cc524d5b7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 16 08:31:20 2026 -0700

    dotbar: fix '0 live' (scan timeout) + resizable bar (drag/Tab/Shift+Tab)
    
    - 0 live: allcolordots --json takes ~9s but server execFile cap was 8s -> every
      refresh timed out -> empty -> '0 live'. Bumped to 25s. Now shows 48 live.
    - Resizable strip: drag the bottom grip (screen-Y absolute setBarH), or Tab=expand /
      Shift+Tab=contract while the pointer is over the bar (hover-scoped globalShortcut so
      Tab isn't hijacked fleet-wide). Height clamped 22-140px, persisted to .barh.
    - Launcher: start-bar.command used `command -v node` which is empty under launchd
      kickstart (no homebrew in PATH) -> server never started on a launchd restart. Added
      absolute-path fallback so launchctl kickstart works.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01AuZ7T3ffxRGXj5rrwgbquD
---
 electron-main.js  | 45 +++++++++++++++++++++++++++++++++++++++------
 preload.js        |  3 +++
 public/index.html | 41 ++++++++++++++++++++++++++++++++++++++++-
 server.js         |  4 +++-
 start-bar.command |  7 +++++--
 5 files changed, 90 insertions(+), 10 deletions(-)

diff --git a/electron-main.js b/electron-main.js
index 317fff5..e3144e9 100644
--- a/electron-main.js
+++ b/electron-main.js
@@ -2,15 +2,23 @@
 // Spawns the local server, then floats a frameless bar above every window
 // (including fullscreen) at 'screen-saver' level. Grows only when a dropdown opens.
 'use strict';
-const { app, BrowserWindow, ipcMain, screen } = require('electron');
+const { app, BrowserWindow, ipcMain, screen, globalShortcut } = require('electron');
 const { spawn } = require('child_process');
 const http = require('http');
 const fs = require('fs');
 const path = require('path');
 
 const DIR = __dirname;
-const BAR_H = 48, PANEL_H = 360;
+const PANEL_H = 360;
+const BAR_DEFAULT = 48, BAR_MIN = 22, BAR_MAX = 140, BAR_STEP = 8;
+const BARH_FILE = path.join(DIR, '.barh');
 let win, serverProc, PORT = null;
+let isOpen = false;                                  // is a dropdown panel open
+let barH = readBarH();                               // resizable strip height (persisted)
+
+function clampBar(h) { return Math.max(BAR_MIN, Math.min(BAR_MAX, Math.round(h || BAR_DEFAULT))); }
+function readBarH() { try { return clampBar(parseInt(fs.readFileSync(BARH_FILE, 'utf8'), 10)); } catch { return BAR_DEFAULT; } }
+function writeBarH() { try { fs.writeFileSync(BARH_FILE, String(barH)); } catch {} }
 
 function portFromFile() { try { return parseInt(fs.readFileSync(path.join(DIR, '.port'), 'utf8'), 10); } catch { return null; } }
 function alive(port) {
@@ -40,7 +48,30 @@ async function ensureServer() {
 
 function bounds(open) {
   const wa = screen.getPrimaryDisplay().workArea; // excludes the menu bar
-  return { x: wa.x, y: wa.y, width: wa.width, height: open ? BAR_H + PANEL_H : BAR_H };
+  return { x: wa.x, y: wa.y, width: wa.width, height: open ? barH + PANEL_H : barH };
+}
+
+// Apply the current barH (+ open state) to the window and tell the renderer its strip height.
+function applyBounds() { if (!win) return; win.setBounds(bounds(isOpen)); try { win.setAlwaysOnTop(true, 'screen-saver'); win.moveTop(); } catch {} }
+function pushBarH() { if (win) try { win.webContents.send('dotbar:barH', barH); } catch {} }
+function setBarH(h) { barH = clampBar(h); writeBarH(); applyBounds(); pushBarH(); }
+
+// Keyboard resize: Tab = expand, Shift+Tab = contract. The bar is a non-focusable panel so it
+// can't receive key events on its own; we register these as GLOBAL shortcuts but ONLY while the
+// pointer is over the bar (renderer sends dotbar:hover), so Tab isn't hijacked fleet-wide.
+let hotkeysOn = false;
+function setHotkeys(on) {
+  if (on === hotkeysOn) return;
+  try {
+    if (on) {
+      globalShortcut.register('Tab', () => setBarH(barH + BAR_STEP));
+      globalShortcut.register('Shift+Tab', () => setBarH(barH - BAR_STEP));
+    } else {
+      globalShortcut.unregister('Tab');
+      globalShortcut.unregister('Shift+Tab');
+    }
+    hotkeysOn = on;
+  } catch {}
 }
 
 async function createWindow() {
@@ -61,15 +92,17 @@ async function createWindow() {
   const raise = () => { try { win.setAlwaysOnTop(true, 'screen-saver'); win.moveTop(); } catch {} };
   const dbg = (tag) => { if (process.env.DOTBAR_DEBUG !== '1') return; try { fs.appendFileSync(path.join(DIR, 'debug.log'),
     JSON.stringify({ tag, t: new Date().toISOString(), bounds: win.getBounds(), visible: win.isVisible(), onTop: win.isAlwaysOnTop() }) + '\n'); } catch {} };
-  win.webContents.on('did-finish-load', () => { raise(); dbg('did-finish-load'); });
+  win.webContents.on('did-finish-load', () => { raise(); pushBarH(); dbg('did-finish-load'); });
   win.webContents.on('did-fail-load', (_e, code, desc) => dbg('FAIL:' + code + ':' + desc));
   setInterval(raise, 2000);   // re-assert top above other apps
-  ipcMain.on('dotbar:setOpen', (_e, open) => { if (win) { win.setBounds(bounds(!!open)); raise(); } });
+  ipcMain.on('dotbar:setOpen', (_e, open) => { isOpen = !!open; applyBounds(); });
+  ipcMain.on('dotbar:setBarH', (_e, h) => setBarH(h));          // absolute height (edge-drag)
+  ipcMain.on('dotbar:hover', (_e, on) => setHotkeys(!!on));     // enable Tab/Shift+Tab only over the bar
   ipcMain.on('dotbar:quit', () => app.quit());
 }
 
 app.on('window-all-closed', () => app.quit());
-app.on('before-quit', () => { if (serverProc) try { serverProc.kill(); } catch {} });
+app.on('before-quit', () => { try { globalShortcut.unregisterAll(); } catch {} if (serverProc) try { serverProc.kill(); } catch {} });
 app.whenReady().then(() => {
   if (app.dock) app.dock.hide();            // accessory app: panels float above active apps
   createWindow().catch(e => console.log('DOTBAR: createWindow ERROR', e && e.stack || e));
diff --git a/preload.js b/preload.js
index c5be619..2985497 100644
--- a/preload.js
+++ b/preload.js
@@ -1,5 +1,8 @@
 const { contextBridge, ipcRenderer } = require('electron');
 contextBridge.exposeInMainWorld('dotbar', {
   setOpen: (open) => ipcRenderer.send('dotbar:setOpen', !!open),
+  setBarH: (h) => ipcRenderer.send('dotbar:setBarH', h),        // absolute strip height (edge-drag)
+  hover: (on) => ipcRenderer.send('dotbar:hover', !!on),        // arm Tab/Shift+Tab only over the bar
+  onBarH: (cb) => ipcRenderer.on('dotbar:barH', (_e, h) => cb(h)),
   quit: () => ipcRenderer.send('dotbar:quit'),
 });
diff --git a/public/index.html b/public/index.html
index 642e00b..e3efd9a 100644
--- a/public/index.html
+++ b/public/index.html
@@ -14,6 +14,14 @@
   #bar { height: var(--bar-h); display:flex; align-items:center; gap:4px; padding:0 10px;
     background:linear-gradient(180deg,#191c22,#14161a); border-bottom:1px solid var(--line);
     -webkit-app-region: drag; }
+  /* drag-to-resize strip along the bottom edge of the bar (Steve 2026-09-16). Sits exactly on the
+     bar's lower border; drag it up/down to change the strip height. Kept OUT of #bar (renderBar()
+     wipes #bar every tick), so it lives as a fixed sibling that tracks --bar-h. */
+  #grip { position:fixed; left:0; right:0; top: calc(var(--bar-h) - 4px); height:8px; z-index:50;
+    cursor:ns-resize; -webkit-app-region:no-drag; }
+  #grip::after { content:""; position:absolute; left:50%; top:3px; width:46px; height:2px;
+    transform:translateX(-50%); border-radius:2px; background:var(--line); opacity:.5; }
+  #grip:hover::after { opacity:1; background:var(--dim); }
   .chip { -webkit-app-region:no-drag; cursor:pointer; display:flex; align-items:center; gap:6px;
     padding:6px 10px; border-radius:9px; border:1px solid transparent; line-height:1; white-space:nowrap; }
   .chip:hover { background:var(--bg2); border-color:var(--line); }
@@ -53,10 +61,11 @@
 </head>
 <body>
   <div id="bar"></div>
+  <div id="grip" title="drag to resize · Tab = expand · Shift+Tab = contract"></div>
   <div id="panel"></div>
 <script>
 const BAR_H = 48, PANEL_H = 360;
-let openColor = null, data = null, _miss = 0;
+let openColor = null, data = null, _miss = 0, curBarH = BAR_H;
 // The four needs-Steve states pulse; green/pink stay solid (Steve 2026-09-15 dot-flash directive).
 const WAIT = new Set(['lightblue','orange','purple','yellow']);
 
@@ -129,6 +138,36 @@ async function tick(){
   renderBar(); renderPanel();
 }
 
+// ---- Resize the bar (Steve 2026-09-16): drag the bottom grip, or Tab/Shift+Tab over the bar ----
+// Main process owns the window height; here we (a) mirror it into --bar-h, (b) turn grip drags into
+// absolute setBarH calls (screen-Y based so it stays stable as the window resizes under the cursor),
+// and (c) arm the Tab/Shift+Tab global shortcuts only while the pointer is over the bar.
+if (window.dotbar && window.dotbar.onBarH) {
+  window.dotbar.onBarH(h => { curBarH = h; document.documentElement.style.setProperty('--bar-h', h + 'px'); });
+}
+(function wireGrip(){
+  const grip = document.getElementById('grip');
+  if (!grip) return;
+  let dragging = false, startY = 0, startH = 0;
+  grip.addEventListener('pointerdown', e => {
+    dragging = true; startY = e.screenY; startH = curBarH;
+    try { grip.setPointerCapture(e.pointerId); } catch(_) {}
+    e.preventDefault();
+  });
+  grip.addEventListener('pointermove', e => {
+    if (!dragging) return;
+    const h = startH + (e.screenY - startY);
+    if (window.dotbar && window.dotbar.setBarH) window.dotbar.setBarH(h);
+  });
+  const end = e => { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch(_) {} };
+  grip.addEventListener('pointerup', end);
+  grip.addEventListener('pointercancel', end);
+})();
+// arm keyboard resize only while the pointer is over the bar (so Tab isn't hijacked fleet-wide)
+const arm = on => { if (window.dotbar && window.dotbar.hover) window.dotbar.hover(on); };
+document.documentElement.addEventListener('mouseenter', () => arm(true));
+document.documentElement.addEventListener('mouseleave', () => arm(false));
+
 // optional deep-link: ?open=orange pre-opens that dropdown
 const _pre = new URLSearchParams(location.search).get('open');
 resize(false);
diff --git a/server.js b/server.js
index 24cf044..67f9297 100755
--- a/server.js
+++ b/server.js
@@ -42,7 +42,9 @@ function ticketOf(label) {
 }
 
 async function getDots() {
-  const { stdout } = await run('bash', [ALLCOLORDOTS, '--json']);
+  // allcolordots scans every terminal and takes ~9-10s on a busy fleet; the default 8s cap
+  // timed out every refresh -> empty output -> the bar showed "0 live". Give it 25s headroom.
+  const { stdout } = await run('bash', [ALLCOLORDOTS, '--json'], 25000);
   let rows = [];
   try { rows = JSON.parse(stdout || '[]'); } catch { rows = []; }
   rows = rows.filter(r => r && r.live); // only live sessions count as "running"
diff --git a/start-bar.command b/start-bar.command
index 508b21e..981019b 100755
--- a/start-bar.command
+++ b/start-bar.command
@@ -12,8 +12,11 @@ pkill -f "Electron.*desktop-dotbar" 2>/dev/null
 pkill -f "desktop-dotbar/server.js" 2>/dev/null
 sleep 0.4
 
-# 1) start the server with real node (absolute path so pkill can find it later)
-NODE="$(command -v node)"
+# 1) start the server with real node (absolute path so pkill can find it later).
+# launchd's PATH lacks homebrew, so `command -v node` is empty under `launchctl kickstart` —
+# fall back to the known homebrew/usr locations so a launchd restart actually starts the server.
+NODE="$(command -v node || true)"
+[ -x "$NODE" ] || NODE="$(for n in /opt/homebrew/bin/node /usr/local/bin/node /usr/bin/node; do [ -x "$n" ] && echo "$n" && break; done)"
 nohup "$NODE" "$DIR/server.js" >"$DIR/server.log" 2>&1 &
 for i in $(seq 1 40); do
   [ -f "$DIR/.port" ] && curl -sf "http://127.0.0.1:$(cat "$DIR/.port")/health" >/dev/null 2>&1 && break

← aa5dd2c TK-11794: pulse the 4 needs-Steve dots on the bar; green/pin  ·  back to Desktop Dotbar  ·  dotbar: keep-last-good so a slow/empty scan never flaps the f72f77b →