[object Object]

← back to Desktop Dotbar

dotbar (TK-11885): edge auto-hide + top/left/right orientation toggle

62b3811eda5052a27cbd127deecd49df3a61e4bb · 2026-09-17 10:11:51 -0700 · Steve Abrams

Both nav-bar behaviors Steve asked for, in the in-scope Electron app:
(a) EDGE AUTO-HIDE — 📌 Pinned / 👁 Auto-hide toggle. When on, each strip
    slides off its docked edge leaving a 3px peek and slides back in when the
    cursor hits that edge. Cursor-polled (screen.getCursorScreenPoint every
    110ms) because the panel is non-focusable, so DOM hover can't see an
    off-screen bar; hysteresis via a 320ms hide-grace so it doesn't flap; an
    open dropdown or an active grip-drag pins it open.
(b) ORIENTATION top<->left<->right — one-click dock cycle, so a SIDE bar can
    be reverted back to a TOP bar. Side docks render the strip as a vertical
    column (chips stacked, controls at the bottom), grip becomes an ew-resize
    width handle; prefs persist to .barcfg.

Default state (top + pinned) is byte-identical to prior behavior — the
auto-hide poll early-returns when off, so no regression at rest. All 18
orientation x open x hidden bounds combos verified on both displays.
Reversible, local Electron/CSS/JS only.

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

Files touched

Diff

commit 62b3811eda5052a27cbd127deecd49df3a61e4bb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 17 10:11:51 2026 -0700

    dotbar (TK-11885): edge auto-hide + top/left/right orientation toggle
    
    Both nav-bar behaviors Steve asked for, in the in-scope Electron app:
    (a) EDGE AUTO-HIDE — 📌 Pinned / 👁 Auto-hide toggle. When on, each strip
        slides off its docked edge leaving a 3px peek and slides back in when the
        cursor hits that edge. Cursor-polled (screen.getCursorScreenPoint every
        110ms) because the panel is non-focusable, so DOM hover can't see an
        off-screen bar; hysteresis via a 320ms hide-grace so it doesn't flap; an
        open dropdown or an active grip-drag pins it open.
    (b) ORIENTATION top<->left<->right — one-click dock cycle, so a SIDE bar can
        be reverted back to a TOP bar. Side docks render the strip as a vertical
        column (chips stacked, controls at the bottom), grip becomes an ew-resize
        width handle; prefs persist to .barcfg.
    
    Default state (top + pinned) is byte-identical to prior behavior — the
    auto-hide poll early-returns when off, so no regression at rest. All 18
    orientation x open x hidden bounds combos verified on both displays.
    Reversible, local Electron/CSS/JS only.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_017XJaU3fPAuhfFTynAAJmev
---
 .gitignore        |   1 +
 electron-main.js  | 158 ++++++++++++++++++++++++++++++++++++++++++++++--------
 preload.js        |   8 ++-
 public/index.html | 126 ++++++++++++++++++++++++++++---------------
 4 files changed, 228 insertions(+), 65 deletions(-)

diff --git a/.gitignore b/.gitignore
index 389368a..98f6b25 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ tmp/
 debug.log
 .barh
 electron-main.js.bak-*
+.barcfg
diff --git a/electron-main.js b/electron-main.js
index 5a6768d..2f2d9e0 100644
--- a/electron-main.js
+++ b/electron-main.js
@@ -1,12 +1,17 @@
-// Electron shell: a true always-on-top strip across the top of the desktop.
+// Electron shell: a true always-on-top strip that docks to ANY screen edge.
 // Spawns the local server, then floats a frameless bar above every window
 // (including fullscreen) at 'screen-saver' level. Grows only when a dropdown opens.
 //
-// MULTI-DISPLAY (Steve 2026-09-16): one strip PER display, each pinned to the top edge of
-// its OWN screen, so the dot chips + the ⧉ Arrange button are reachable on every monitor
-// (the button used to live only on the primary/left screen while Steve works on the right).
+// MULTI-DISPLAY (Steve 2026-09-16): one strip PER display, each pinned to the same edge of
+// its OWN screen, so the dot chips + the ⧉ Arrange button are reachable on every monitor.
 // Each strip is an independent BrowserWindow; open-state (dropdown grow) is tracked per window,
-// and grip-resize applies to all strips so they stay the same height.
+// and grip-resize applies to all strips so they stay the same thickness.
+//
+// TK-11885 (Steve 2026-09-17): the bar now (a) EDGE AUTO-HIDES — when autohide is on it slides
+// off its docked edge leaving a thin PEEK, and slides back in when the cursor hits that edge
+// (cursor-polled, because the panel is non-focusable so DOM hover can't see an off-screen bar);
+// and (b) supports ORIENTATION top<->left<->right, so a side bar can be reverted back to a top
+// bar. Both are global prefs, applied to every strip and persisted. Scope: local Electron/CSS/JS.
 'use strict';
 const { app, BrowserWindow, ipcMain, screen } = require('electron');
 const { spawn } = require('child_process');
@@ -15,17 +20,44 @@ const fs = require('fs');
 const path = require('path');
 
 const DIR = __dirname;
-const PANEL_H = 360;
-const BAR_DEFAULT = 48, BAR_MIN = 22, BAR_MAX = 140;
+const PANEL_H = 360;                                 // top-dock dropdown grows down by this
+const PANEL_W = 340;                                 // side-dock dropdown grows sideways by this
+const BAR_DEFAULT = 48, BAR_MIN = 22, BAR_MAX = 140; // thickness of a TOP strip (its height)
+const BARW_DEFAULT = 210, BARW_MIN = 120, BARW_MAX = 420; // thickness of a SIDE strip (its width)
+const PEEK = 3;                                       // px of bar left visible when auto-hidden
+const EDGE_TRIGGER = 8;                               // cursor within this many px of the edge reveals
+const REVEAL_MARGIN = 6;                              // keep revealed while cursor is within bounds+this
+const POLL_MS = 110;                                 // cursor poll cadence for auto-hide
+const HIDE_GRACE_MS = 320;                            // wait this long off the bar before hiding
 const BARH_FILE = path.join(DIR, '.barh');
+const CFG_FILE = path.join(DIR, '.barcfg');
+const ORIENTS = ['top', 'left', 'right'];
+
 let wins = [];                                       // one strip per display
 let serverProc, PORT = null;
 const openState = new Map();                         // win.id -> is that strip's dropdown open
-let barH = readBarH();                               // resizable strip height (persisted, shared)
+const revealed = new Map();                          // win.id -> is that strip currently slid in (autohide)
+const lastInside = new Map();                        // win.id -> ts cursor was last over the bar (hide grace)
+let barH = readBarH();                               // resizable TOP-strip height (persisted, shared)
+let cfg = readCfg();                                 // { orientation, autohide, barW } (persisted, shared)
+let gripDragging = false;                            // suppress auto-hide while resizing
 
 function clampBar(h) { return Math.max(BAR_MIN, Math.min(BAR_MAX, Math.round(h || BAR_DEFAULT))); }
+function clampBarW(w) { return Math.max(BARW_MIN, Math.min(BARW_MAX, Math.round(w || BARW_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 readCfg() {
+  const d = { orientation: 'top', autohide: false, barW: BARW_DEFAULT };
+  try {
+    const j = JSON.parse(fs.readFileSync(CFG_FILE, 'utf8'));
+    if (ORIENTS.includes(j.orientation)) d.orientation = j.orientation;
+    d.autohide = !!j.autohide;
+    d.barW = clampBarW(j.barW);
+  } catch {}
+  return d;
+}
+let _cfgTimer = null;
+function writeCfg() { clearTimeout(_cfgTimer); _cfgTimer = setTimeout(() => { try { fs.writeFileSync(CFG_FILE, JSON.stringify(cfg)); } catch {} }, 200); }
 
 function portFromFile() { try { return parseInt(fs.readFileSync(path.join(DIR, '.port'), 'utf8'), 10); } catch { return null; } }
 function alive(port) {
@@ -53,40 +85,117 @@ async function ensureServer() {
   return portFromFile() || 9787;
 }
 
-// Bounds for a strip on a given display: full display width, at its workArea top (below the menu
-// bar, which only the primary display has). Grows downward by PANEL_H when its dropdown is open.
-function boundsFor(display, open) {
+// Pure bounds for a strip on a display, given dropdown-open + hidden(auto-hide) flags and the
+// current orientation. Grows along the axis away from its docked edge when the dropdown is open;
+// when hidden, slides off its edge leaving PEEK px visible (thickness stays the closed thickness).
+function boundsCore(display, open, hidden) {
   const wa = display.workArea;
-  return { x: wa.x, y: wa.y, width: wa.width, height: open ? barH + PANEL_H : barH };
+  const o = cfg.orientation;
+  if (o === 'top') {
+    const height = hidden ? barH : (open ? barH + PANEL_H : barH);
+    const y = hidden ? wa.y + PEEK - barH : wa.y;      // slide up, PEEK visible at top
+    return { x: wa.x, y, width: wa.width, height };
+  }
+  const bw = cfg.barW;
+  const width = hidden ? bw : (open ? bw + PANEL_W : bw);
+  if (o === 'left') {
+    const x = hidden ? wa.x + PEEK - bw : wa.x;         // slide left, PEEK visible at left
+    return { x, y: wa.y, width, height: wa.height };
+  }
+  // right: closed sits flush with the right edge; open grows leftward; hidden slides off right.
+  let x;
+  if (hidden) x = wa.x + wa.width - PEEK;
+  else x = wa.x + wa.width - (open ? bw + PANEL_W : bw);
+  return { x, y: wa.y, width, height: wa.height };
 }
+function isHidden(w) { return cfg.autohide && w && !revealed.get(w.id) && !openState.get(w.id) && !gripDragging; }
+function boundsFor(w, display, open) { return boundsCore(display, open, isHidden(w)); }
+
 function displayOf(w) {
   return screen.getAllDisplays().find(d => d.id === w._displayId) || screen.getPrimaryDisplay();
 }
 function raise(w) { try { if (!w.isDestroyed()) { w.setAlwaysOnTop(true, 'screen-saver'); w.moveTop(); } } catch {} }
 function applyBoundsFor(w) {
   if (!w || w.isDestroyed()) return;
-  w.setBounds(boundsFor(displayOf(w), !!openState.get(w.id)));
+  w.setBounds(boundsFor(w, displayOf(w), !!openState.get(w.id)));
   raise(w);
 }
+function applyAll() { for (const w of wins) applyBoundsFor(w); }
 function pushBarH(w) { try { if (!w.isDestroyed()) w.webContents.send('dotbar:barH', barH); } catch {} }
+function pushCfg(w) { try { if (!w.isDestroyed()) w.webContents.send('dotbar:config', { orientation: cfg.orientation, autohide: cfg.autohide, barW: cfg.barW }); } catch {} }
 
-// setBarH fires on EVERY pointermove during a grip drag, so keep it cheap: resize each strip and
-// mirror the height to its renderer live, but do NOT re-assert always-on-top per frame (the 2s
-// raise interval covers that) and do NOT hit the disk per frame — persist once, debounced.
+// setBarH/setBarW fire on EVERY pointermove during a grip drag, so keep them cheap: resize each
+// strip and mirror the value to its renderer live, but do NOT re-assert always-on-top per frame
+// (the 2s raise interval covers that) and do NOT hit disk per frame — persist once, debounced.
 let _persistTimer = null;
 function setBarH(h) {
   barH = clampBar(h);
   for (const w of wins) {
     if (w.isDestroyed()) continue;
-    w.setBounds(boundsFor(displayOf(w), !!openState.get(w.id)));   // live resize only; no per-frame moveTop
+    w.setBounds(boundsFor(w, displayOf(w), !!openState.get(w.id)));
     pushBarH(w);
   }
   clearTimeout(_persistTimer); _persistTimer = setTimeout(writeBarH, 300);
 }
+let _persistWTimer = null;
+function setBarW(wpx) {
+  cfg.barW = clampBarW(wpx);
+  for (const w of wins) {
+    if (w.isDestroyed()) continue;
+    w.setBounds(boundsFor(w, displayOf(w), !!openState.get(w.id)));
+    pushCfg(w);
+  }
+  clearTimeout(_persistWTimer); _persistWTimer = setTimeout(writeCfg, 300);
+}
+
+function setOrientation(o) {
+  if (!ORIENTS.includes(o) || o === cfg.orientation) return;
+  cfg.orientation = o;
+  revealed.clear();                                  // recompute reveal against the new edge
+  applyAll();
+  for (const w of wins) pushCfg(w);
+  writeCfg();
+}
+function setAutohide(on) {
+  cfg.autohide = !!on;
+  revealed.clear(); lastInside.clear();
+  applyAll();                                        // off -> everything shows; on -> hide (unless open)
+  for (const w of wins) pushCfg(w);
+  writeCfg();
+}
+
+// ---- Auto-hide: cursor-polled reveal/conceal (the panel is non-focusable, so DOM hover can't
+// see it while it's slid off-screen; a global cursor poll is the reliable trigger). ----
+function cursorNearEdge(display, c) {
+  const wa = display.workArea, o = cfg.orientation;
+  if (o === 'top')  return c.x >= wa.x && c.x < wa.x + wa.width  && c.y <= wa.y + EDGE_TRIGGER && c.y >= wa.y - 2;
+  if (o === 'left') return c.y >= wa.y && c.y < wa.y + wa.height && c.x <= wa.x + EDGE_TRIGGER && c.x >= wa.x - 2;
+  return c.y >= wa.y && c.y < wa.y + wa.height && c.x >= wa.x + wa.width - EDGE_TRIGGER; // right
+}
+function cursorOverRevealed(w, display, c) {
+  const b = boundsCore(display, !!openState.get(w.id), false);   // full (shown) bounds
+  return c.x >= b.x - REVEAL_MARGIN && c.x < b.x + b.width + REVEAL_MARGIN
+      && c.y >= b.y - REVEAL_MARGIN && c.y < b.y + b.height + REVEAL_MARGIN;
+}
+function pollAutohide() {
+  if (!cfg.autohide) return;
+  let c; try { c = screen.getCursorScreenPoint(); } catch { return; }
+  const now = Date.now();
+  for (const w of wins) {
+    if (w.isDestroyed()) continue;
+    const d = displayOf(w);
+    const forceShow = openState.get(w.id) || gripDragging;   // never yank an open dropdown / active drag
+    const inZone = cursorNearEdge(d, c) || cursorOverRevealed(w, d, c);
+    let want;
+    if (forceShow || inZone) { want = true; lastInside.set(w.id, now); }
+    else { want = (now - (lastInside.get(w.id) || 0)) < HIDE_GRACE_MS; }  // grace before hiding
+    if (want !== (revealed.get(w.id) || false)) { revealed.set(w.id, want); applyBoundsFor(w); }
+  }
+}
 
 function createBarFor(display) {
   const win = new BrowserWindow({
-    ...boundsFor(display, false),
+    ...boundsCore(display, false, false),
     type: 'panel',                 // NSPanel: floats above normal windows, non-activating
     frame: false, resizable: false, movable: true, minimizable: false, maximizable: false,
     fullscreenable: false, skipTaskbar: true, hasShadow: false, transparent: false,
@@ -99,7 +208,7 @@ function createBarFor(display) {
   win.loadURL(`http://127.0.0.1:${PORT}/`);
   const dbg = (tag) => { if (process.env.DOTBAR_DEBUG !== '1') return; try { fs.appendFileSync(path.join(DIR, 'debug.log'),
     JSON.stringify({ tag, id: win.id, t: new Date().toISOString(), bounds: win.isDestroyed() ? null : win.getBounds() }) + '\n'); } catch {} };
-  win.webContents.on('did-finish-load', () => { raise(win); pushBarH(win); dbg('did-finish-load'); });
+  win.webContents.on('did-finish-load', () => { raise(win); pushBarH(win); pushCfg(win); applyBoundsFor(win); dbg('did-finish-load'); });
   win.webContents.on('did-fail-load', (_e, code, desc) => dbg('FAIL:' + code + ':' + desc));
   wins.push(win);
   return win;
@@ -109,7 +218,7 @@ function createBarFor(display) {
 // and a removed display leaves no orphan window.
 function rebuildBars() {
   for (const w of wins) { try { if (!w.isDestroyed()) w.destroy(); } catch {} }
-  wins = []; openState.clear();
+  wins = []; openState.clear(); revealed.clear(); lastInside.clear();
   for (const d of screen.getAllDisplays()) createBarFor(d);
 }
 
@@ -117,6 +226,7 @@ async function createWindows() {
   PORT = await ensureServer();
   for (const d of screen.getAllDisplays()) createBarFor(d);
   setInterval(() => wins.forEach(raise), 2000);   // re-assert top above other apps, every strip
+  setInterval(pollAutohide, POLL_MS);             // auto-hide reveal/conceal
   // React to monitor changes (a display added/removed, or geometry changed e.g. arrangement swap).
   screen.on('display-added', rebuildBars);
   screen.on('display-removed', rebuildBars);
@@ -125,9 +235,15 @@ async function createWindows() {
     const w = BrowserWindow.fromWebContents(e.sender);
     if (!w) return;
     openState.set(w.id, !!open);
+    if (open) { revealed.set(w.id, true); lastInside.set(w.id, Date.now()); }  // opening reveals
     applyBoundsFor(w);
   });
-  ipcMain.on('dotbar:setBarH', (_e, h) => setBarH(h));   // grip drag: applies to all strips
+  ipcMain.on('dotbar:setBarH', (_e, h) => setBarH(h));   // grip drag (top): applies to all strips
+  ipcMain.on('dotbar:setBarW', (_e, w) => setBarW(w));   // grip drag (side): applies to all strips
+  ipcMain.on('dotbar:gripDrag', (_e, on) => { gripDragging = !!on; if (!on && cfg.autohide) revealed.clear(); });
+  ipcMain.on('dotbar:setOrientation', (_e, o) => setOrientation(o));
+  ipcMain.on('dotbar:cycleOrientation', () => setOrientation(ORIENTS[(ORIENTS.indexOf(cfg.orientation) + 1) % ORIENTS.length]));
+  ipcMain.on('dotbar:setAutohide', (_e, on) => setAutohide(on));
   ipcMain.on('dotbar:quit', () => app.quit());
 }
 
diff --git a/preload.js b/preload.js
index 1928aee..f32e96d 100644
--- a/preload.js
+++ b/preload.js
@@ -1,7 +1,13 @@
 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 (grip edge-drag)
+  setBarH: (h) => ipcRenderer.send('dotbar:setBarH', h),        // absolute strip height (grip edge-drag, top dock)
+  setBarW: (w) => ipcRenderer.send('dotbar:setBarW', w),        // absolute strip width (grip edge-drag, side dock)
   onBarH: (cb) => ipcRenderer.on('dotbar:barH', (_e, h) => cb(h)),
+  onConfig: (cb) => ipcRenderer.on('dotbar:config', (_e, c) => cb(c)),   // { orientation, autohide, barW }
+  gripDrag: (on) => ipcRenderer.send('dotbar:gripDrag', !!on),  // suppress auto-hide during a grip drag
+  setOrientation: (o) => ipcRenderer.send('dotbar:setOrientation', o),   // 'top' | 'left' | 'right'
+  cycleOrientation: () => ipcRenderer.send('dotbar:cycleOrientation'),
+  setAutohide: (on) => ipcRenderer.send('dotbar:setAutohide', !!on),
   quit: () => ipcRenderer.send('dotbar:quit'),
 });
diff --git a/public/index.html b/public/index.html
index d0ddcdc..12625e9 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,56 +5,77 @@
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>Dot Bar</title>
 <style>
-  :root { --bar-h: 48px; --bg:#14161a; --bg2:#1c1f26; --line:#2a2e37; --fg:#e8eaed; --dim:#9aa0aa; }
+  :root { --bar-h: 48px; --bar-w: 210px; --bg:#14161a; --bg2:#1c1f26; --line:#2a2e37; --fg:#e8eaed; --dim:#9aa0aa; }
   * { box-sizing: border-box; }
   html, body { margin:0; height:100%; background:transparent; overflow:hidden;
     font: 13px -apple-system, "SF Pro Text", system-ui, sans-serif; color: var(--fg);
     -webkit-user-select:none; user-select:none; }
+  /* Body is a flex container; orientation decides the axis. TOP = bar over panel (column);
+     LEFT = bar left of panel (row); RIGHT = bar right of panel (row-reverse). TK-11885. */
+  body { display:flex; }
+  body.orient-top { flex-direction:column; }
+  body.orient-left { flex-direction:row; }
+  body.orient-right { flex-direction:row-reverse; }
+
   /* the always-visible strip */
-  #bar { height: var(--bar-h); display:flex; align-items:center; gap:4px; padding:0 10px;
+  #bar { 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. */
-  /* Anchor the grip so its full 8px height sits INSIDE the closed-bar window (which is only
-     --bar-h tall with overflow:hidden); otherwise its lower half is clipped and the grab target
-     shrinks to ~4px. It's the sole resize affordance now, so keep the target grabbable. */
-  #grip { position:fixed; left:0; right:0; top: calc(var(--bar-h) - 8px); height:8px; z-index:50;
-    cursor:ns-resize; -webkit-app-region:no-drag; }
-  #grip::after { content:""; position:absolute; left:50%; bottom:2px; width:46px; height:2px;
-    transform:translateX(-50%); border-radius:2px; background:var(--line); opacity:.5; }
+  body.orient-top #bar { height: var(--bar-h); width:100%; flex-direction:row; }
+  /* SIDE dock: the strip is a vertical column, chips stacked, controls at the bottom. */
+  body.orient-left #bar, body.orient-right #bar {
+    width: var(--bar-w); height:100%; flex-direction:column; align-items:stretch;
+    padding:8px 6px; gap:4px; overflow-y:auto; border-bottom:none; }
+  body.orient-left #bar { border-right:1px solid var(--line); }
+  body.orient-right #bar { border-left:1px solid var(--line); }
+
+  /* Drag-to-resize grip on the bar's inner edge. TOP: bottom edge (ns-resize, changes height).
+     SIDE: inner vertical edge (ew-resize, changes width). Kept OUT of #bar (renderBar() wipes
+     #bar every tick), so it lives as a fixed sibling that tracks the bar thickness. */
+  #grip { position:fixed; z-index:50; -webkit-app-region:no-drag; }
+  body.orient-top #grip { left:0; right:0; top: calc(var(--bar-h) - 8px); height:8px; cursor:ns-resize; }
+  body.orient-left #grip { top:0; bottom:0; left: calc(var(--bar-w) - 8px); width:8px; cursor:ew-resize; }
+  body.orient-right #grip { top:0; bottom:0; right: calc(var(--bar-w) - 8px); width:8px; cursor:ew-resize; }
+  #grip::after { content:""; position:absolute; border-radius:2px; background:var(--line); opacity:.5; }
+  body.orient-top #grip::after { left:50%; bottom:2px; width:46px; height:2px; transform:translateX(-50%); }
+  body.orient-left #grip::after, body.orient-right #grip::after { top:50%; left:2px; width:2px; height:46px; transform:translateY(-50%); }
   #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); }
   .chip.active { background:var(--bg2); border-color:var(--line); }
-  .dot { width:12px; height:12px; border-radius:50%; box-shadow:0 0 0 1px rgba(0,0,0,.35) inset; }
-  /* Steve 2026-09-15 dot-flash directive: the four needs-Steve states PULSE; green/pink stay
-     solid. This CSS animation is the REAL visible pulse — one already-running process (this
-     bar) animating the waiting rows. There is NO per-tab background loop anywhere; the tab
-     side carries only a static two-frame attention glyph in the badge. */
+  body.orient-left .chip, body.orient-right .chip { justify-content:flex-start; }
+  .dot { width:12px; height:12px; border-radius:50%; box-shadow:0 0 0 1px rgba(0,0,0,.35) inset; flex:0 0 auto; }
+  /* Steve 2026-09-15 dot-flash directive: the four needs-Steve states PULSE; green/pink stay solid. */
   @keyframes dotpulse { 0%,100%{ opacity:1; transform:scale(1); } 50%{ opacity:.35; transform:scale(1.4); } }
   .dot.pulse, .rdot.pulse { animation: dotpulse 1.1s ease-in-out infinite; }
   @media (prefers-reduced-motion: reduce){ .dot.pulse, .rdot.pulse { animation:none; opacity:1; } }
   .cnt { font-variant-numeric:tabular-nums; font-weight:700; font-size:14px; }
   .nm { color:var(--dim); font-size:11px; }
+  /* the flexible gap that pushes #meta to the far end (right on top; bottom on side) */
   #spacer { flex:1; -webkit-app-region:drag; }
   #meta { -webkit-app-region:no-drag; color:var(--dim); font-size:11px; display:flex; gap:10px; align-items:center; }
+  body.orient-left #meta, body.orient-right #meta { flex-direction:column; align-items:stretch; gap:6px; }
   #meta b { color:var(--fg); font-variant-numeric:tabular-nums; }
   .icobtn { -webkit-app-region:no-drag; cursor:pointer; color:var(--dim); padding:4px 6px; border-radius:6px; }
   .icobtn:hover { background:var(--bg2); color:var(--fg); }
-  /* One-click re-tile of every screen (Steve 2026-09-16): after dragging windows around, click
-     this to snap them all back into the grid (green left, colour bands right). */
+  /* orientation / auto-hide toggle buttons (TK-11885) */
+  .cfgbtn { -webkit-app-region:no-drag; cursor:pointer; font-size:11px; padding:5px 9px; border-radius:7px;
+    border:1px solid var(--line); background:var(--bg2); color:var(--fg); white-space:nowrap; }
+  .cfgbtn:hover { background:#2a2e37; }
+  .cfgbtn.on { border-color:#35507a; background:#1d2836; color:#cfe2ff; }
+  /* One-click re-tile of every screen. */
   .arrbtn { -webkit-app-region:no-drag; cursor:pointer; font-weight:700; font-size:12px;
     padding:5px 11px; border-radius:8px; border:1px solid #35507a; background:#1d2836; color:#cfe2ff;
     white-space:nowrap; }
   .arrbtn:hover { background:#25344a; border-color:#4a6ea8; }
   .arrbtn.busy { opacity:.6; cursor:progress; }
   /* the dropdown region (only visible when a chip is open; window grows to fit) */
-  #panel { display:none; background:var(--bg); border-bottom:1px solid var(--line);
-    max-height: calc(100vh - var(--bar-h)); overflow:auto; }
+  #panel { display:none; background:var(--bg); overflow:auto; }
   #panel.open { display:block; }
+  body.orient-top #panel { border-bottom:1px solid var(--line); max-height: calc(100vh - var(--bar-h)); }
+  body.orient-left #panel, body.orient-right #panel { flex:1; height:100%; max-height:100%; }
   .phead { padding:8px 14px; color:var(--dim); font-size:11px; letter-spacing:.04em; text-transform:uppercase;
     position:sticky; top:0; background:var(--bg); border-bottom:1px solid var(--line); }
   .row { display:flex; align-items:center; gap:10px; padding:9px 14px; border-bottom:1px solid #20232b; }
@@ -69,15 +90,18 @@
   .empty { padding:14px; color:var(--dim); }
 </style>
 </head>
-<body>
+<body class="orient-top">
   <div id="bar"></div>
   <div id="grip" title="drag to resize the bar"></div>
   <div id="panel"></div>
 <script>
 const BAR_H = 48, PANEL_H = 360;
-let openColor = null, data = null, _miss = 0, curBarH = BAR_H, arranging = false, arrangeMsg = '';
+let openColor = null, data = null, _miss = 0, curBarH = BAR_H, curBarW = 210, arranging = false, arrangeMsg = '';
+// Live config from the main process (orientation + auto-hide). Defaults match a fresh install.
+let cfg = { orientation: 'top', autohide: false, barW: 210 };
 // 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']);
+const ORIENT_LABEL = { top: '▲ Top', left: '◀ Left', right: '▶ Right' };
 
 async function fetchDots(){ try { const r = await fetch('/api/dots'); return await r.json(); } catch { return null; } }
 
@@ -93,15 +117,20 @@ function renderBar(){
   for (const g of data.groups){
     const chip = document.createElement('div');
     chip.className = 'chip' + (openColor===g.color ? ' active':'');
+    // On a side dock the chip always shows its name (there's vertical room); on top only when open.
+    const showName = (openColor===g.color) || cfg.orientation !== 'top';
     chip.innerHTML = `<span class="dot${WAIT.has(g.color)?' pulse':''}" style="background:${g.css}"></span>`
       + `<span class="cnt">${g.count}</span>`
-      + (openColor===g.color ? `<span class="nm">${g.name}</span>` : '');
+      + (showName ? `<span class="nm">${g.name}</span>` : '');
     chip.onclick = () => toggle(g.color);
     bar.appendChild(chip);
   }
   const sp = document.createElement('div'); sp.id='spacer'; bar.appendChild(sp);
   const meta = document.createElement('div'); meta.id='meta';
+  const nextO = cfg.orientation==='top'?'left':cfg.orientation==='left'?'right':'top';
   meta.innerHTML = `<button class="arrbtn${arranging?' busy':''}" ${arranging?'disabled':''} title="run the master dot-screen arrangement now" onclick="arrange()">${arranging?'⧉ Arranging…':(arrangeMsg?('⧉ '+arrangeMsg):'⧉ Arrange master')}</button>`
+    + `<button class="cfgbtn" title="dock the bar on another edge (revert to Top from a side)" onclick="cycleOrient()">${ORIENT_LABEL[cfg.orientation]||'▲ Top'} ▸ ${ORIENT_LABEL[nextO].replace(/^[^ ]+ /,'')}</button>`
+    + `<button class="cfgbtn${cfg.autohide?' on':''}" title="edge auto-hide: slide the bar off its edge; hover the edge to reveal" onclick="toggleAutohide()">${cfg.autohide?'👁 Auto-hide':'📌 Pinned'}</button>`
     + `<span><b>${data.total}</b> live</span>`
     + `<span class="icobtn" title="refresh" onclick="tick()">⟳</span>`
     + `<span class="icobtn" title="quit bar" onclick="(window.dotbar&&window.dotbar.quit)&&window.dotbar.quit()">✕</span>`;
@@ -137,14 +166,14 @@ async function reveal(tty){
   try { await fetch('/api/reveal', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({tty}) }); } catch(e){}
 }
 
-// One-click "Arrange": ask the server to run the dot-screen router now, re-tiling every screen
-// into the grid. Shows a busy state on the button until the (multi-pass, converging) run returns.
+// Dock the bar on the next edge (top -> left -> right -> top). This is how a SIDE bar reverts to a
+// TOP bar (Steve TK-11885). The main process owns bounds + persistence; here we just ask it.
+function cycleOrient(){ if (window.dotbar && window.dotbar.cycleOrientation) window.dotbar.cycleOrientation(); }
+function toggleAutohide(){ if (window.dotbar && window.dotbar.setAutohide) window.dotbar.setAutohide(!cfg.autohide); }
+
 async function arrange(){
   if (arranging) return;
   arrangeMsg = ''; arranging = true; renderBar();
-  // Surface the outcome on the button instead of swallowing it — an invisible failure (the old
-  // `catch(e){}`) is exactly why "the button does nothing" was undiagnosable. Show moved-count on
-  // success, or the server err / HTTP status / "no server" on failure.
   let msg;
   try {
     const r = await fetch('/api/arrange', { method:'POST' });
@@ -153,45 +182,56 @@ async function arrange(){
   } catch (e) { msg = '✗ ' + ((e && e.message) || 'no server'); }
   arranging = false; arrangeMsg = msg; renderBar();
   clearTimeout(window._arrTimer); window._arrTimer = setTimeout(() => { arrangeMsg = ''; renderBar(); }, 2200);
-  tick();   // refresh counts/labels once windows have settled
+  tick();
 }
 
 async function tick(){
   const d = await fetchDots();
   if (!d) return;
   data = d;
-  // if the open color went to zero, close it
-  // close an open dropdown only after its color is empty for 2 ticks (debounce live churn)
   if (openColor && !data.groups.some(g=>g.color===openColor && g.count>0)) {
     if (++_miss >= 2) { openColor=null; resize(false); _miss=0; }
   } else { _miss = 0; }
   renderBar(); renderPanel();
 }
 
-// ---- Resize the bar (Steve 2026-09-16): drag the bottom grip ----
-// Main process owns the window height; here we (a) mirror it into --bar-h and (b) turn grip drags
-// into absolute setBarH calls (screen-Y based so it stays stable as the window resizes under the
-// cursor). Keyboard resize (bare Tab/Shift+Tab global shortcuts) was removed: DTD 2026-09-16 —
-// a bare-Tab global shortcut is consumed OS-wide and silently kills Tab in the focused app; the
-// grip already covers resize, so no global keyboard hotkey is registered.
+// ---- Config from main (orientation + auto-hide + side width) ----
+function applyCfg(c){
+  cfg = Object.assign(cfg, c || {});
+  document.body.className = 'orient-' + (cfg.orientation || 'top');
+  if (cfg.barW) { curBarW = cfg.barW; document.documentElement.style.setProperty('--bar-w', cfg.barW + 'px'); }
+  if (data) renderBar();
+}
+if (window.dotbar && window.dotbar.onConfig) window.dotbar.onConfig(applyCfg);
 if (window.dotbar && window.dotbar.onBarH) {
   window.dotbar.onBarH(h => { curBarH = h; document.documentElement.style.setProperty('--bar-h', h + 'px'); });
 }
+
+// ---- Resize the bar: drag the grip. TOP grip changes height (setBarH, screen-Y); SIDE grip
+// changes width (setBarW, screen-X, inverted for a right dock so dragging inward shrinks it). ----
 (function wireGrip(){
   const grip = document.getElementById('grip');
   if (!grip) return;
-  let dragging = false, startY = 0, startH = 0;
+  let dragging = false, startX = 0, startY = 0, startH = 0, startW = 0;
   grip.addEventListener('pointerdown', e => {
-    dragging = true; startY = e.screenY; startH = curBarH;
+    dragging = true; startX = e.screenX; startY = e.screenY; startH = curBarH; startW = curBarW;
     try { grip.setPointerCapture(e.pointerId); } catch(_) {}
+    if (window.dotbar && window.dotbar.gripDrag) window.dotbar.gripDrag(true);
     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);
+    if (cfg.orientation === 'top') {
+      const h = startH + (e.screenY - startY);
+      if (window.dotbar && window.dotbar.setBarH) window.dotbar.setBarH(h);
+    } else {
+      const dx = e.screenX - startX;
+      const w = startW + (cfg.orientation === 'right' ? -dx : dx);   // right dock grows leftward
+      if (window.dotbar && window.dotbar.setBarW) window.dotbar.setBarW(w);
+    }
   });
-  const end = e => { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch(_) {} };
+  const end = e => { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch(_) {}
+    if (window.dotbar && window.dotbar.gripDrag) window.dotbar.gripDrag(false); };
   grip.addEventListener('pointerup', end);
   grip.addEventListener('pointercancel', end);
 })();

← 5bfe0b0 keep desktop bar supervised by launchd  ·  back to Desktop Dotbar  ·  chore: v1.1.0 — nav bar edge auto-hide + orientation toggle 2415402 →