[object Object]

← back to Desktop Dotbar

desktop-dotbar: always-on-top color-dot nav strip with ticket dropdowns + jump-to-terminal

1f2db8a6c159eace580adc5cf4284a89fd4527fd · 2026-09-15 17:43:42 -0700 · Steve

Files touched

Diff

commit 1f2db8a6c159eace580adc5cf4284a89fd4527fd
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Sep 15 17:43:42 2026 -0700

    desktop-dotbar: always-on-top color-dot nav strip with ticket dropdowns + jump-to-terminal
---
 .gitignore        |   5 ++
 .port             |   1 +
 public/index.html | 123 ++++++++++++++++++++++++++++++++++++++++++++++++
 server.js         | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 start-bar.command |  31 ++++++++++++
 5 files changed, 298 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b38eead
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+.env*
+*.log
+.DS_Store
+tmp/
diff --git a/.port b/.port
new file mode 100644
index 0000000..3252c40
--- /dev/null
+++ b/.port
@@ -0,0 +1 @@
+9788
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..4c8ab38
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,123 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<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; }
+  * { 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; }
+  /* the always-visible strip */
+  #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; }
+  .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; }
+  .cnt { font-variant-numeric:tabular-nums; font-weight:700; font-size:14px; }
+  .nm { color:var(--dim); font-size:11px; }
+  #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; }
+  #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); }
+  /* 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.open { display:block; }
+  .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; }
+  .row:hover { background:#191c22; }
+  .rdot { width:9px; height:9px; border-radius:50%; flex:0 0 auto; }
+  .tk { font-weight:700; min-width:82px; font-variant-numeric:tabular-nums; }
+  .doing { color:var(--fg); flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+  .tty { color:var(--dim); font-size:11px; font-variant-numeric:tabular-nums; }
+  .open { -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; }
+  .open:hover { background:#2a2e37; }
+  .empty { padding:14px; color:var(--dim); }
+</style>
+</head>
+<body>
+  <div id="bar"></div>
+  <div id="panel"></div>
+<script>
+const BAR_H = 48, PANEL_H = 360;
+let openColor = null, data = null;
+
+async function fetchDots(){ try { const r = await fetch('/api/dots'); return await r.json(); } catch { return null; } }
+
+function resize(open){
+  // Chrome --app windows own their size: thin strip when closed, grow when a dropdown is open.
+  try { window.resizeTo(screen.availWidth, open ? BAR_H + PANEL_H : BAR_H); } catch(e){}
+}
+
+function renderBar(){
+  const bar = document.getElementById('bar');
+  bar.innerHTML = '';
+  for (const g of data.groups){
+    const chip = document.createElement('div');
+    chip.className = 'chip' + (openColor===g.color ? ' active':'');
+    chip.innerHTML = `<span class="dot" style="background:${g.css}"></span>`
+      + `<span class="cnt">${g.count}</span>`
+      + (openColor===g.color ? `<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';
+  meta.innerHTML = `<span><b>${data.total}</b> live</span>`
+    + `<span class="icobtn" title="refresh" onclick="tick()">⟳</span>`;
+  bar.appendChild(meta);
+}
+
+function renderPanel(){
+  const panel = document.getElementById('panel');
+  if (!openColor){ panel.className=''; panel.innerHTML=''; return; }
+  const g = data.groups.find(x=>x.color===openColor);
+  panel.className = 'open';
+  if (!g || !g.sessions.length){ panel.innerHTML = `<div class="empty">No ${openColor} sessions.</div>`; return; }
+  let html = `<div class="phead">${g.emoji} ${g.name} — ${g.count}</div>`;
+  for (const s of g.sessions){
+    html += `<div class="row">`
+      + `<span class="rdot" style="background:${g.css}"></span>`
+      + `<span class="tk">${s.ticket || '—'}</span>`
+      + `<span class="doing" title="${(s.doing||'').replace(/"/g,'&quot;')}">${s.doing || '(no label)'}</span>`
+      + `<span class="tty">${s.tty}</span>`
+      + `<button class="open" onclick='reveal(${JSON.stringify(s.tty)})'>open terminal ↗</button>`
+      + `</div>`;
+  }
+  panel.innerHTML = html;
+}
+
+function toggle(color){
+  openColor = (openColor===color) ? null : color;
+  resize(!!openColor);
+  renderBar(); renderPanel();
+}
+
+async function reveal(tty){
+  try { await fetch('/api/reveal', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({tty}) }); } catch(e){}
+}
+
+async function tick(){
+  const d = await fetchDots();
+  if (!d) return;
+  data = d;
+  // if the open color went to zero, close it
+  if (openColor && !data.groups.some(g=>g.color===openColor && g.count>0)) { openColor=null; resize(false); }
+  renderBar(); renderPanel();
+}
+
+resize(false);
+tick();
+setInterval(tick, 3000);
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100755
index 0000000..86cb864
--- /dev/null
+++ b/server.js
@@ -0,0 +1,138 @@
+#!/usr/bin/env node
+// desktop-dotbar — always-on-top top strip showing each color dot + live count,
+// click a dot to see its tickets + what each is doing, click a ticket to jump to
+// that iTerm2 terminal. Zero deps (Node built-ins only). $0 local.
+'use strict';
+
+const http = require('http');
+const { execFile } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const ALLCOLORDOTS = `${process.env.HOME}/.claude/skills/allcolordots/allcolordots.sh`;
+
+// Urgency order (matches allcolordots): needs-Steve first, working/parked last.
+const ORDER = ['lightblue', 'orange', 'purple', 'yellow', 'green', 'pink', 'none'];
+const META = {
+  lightblue: { emoji: '🔵', css: '#3aa0ff', name: 'needs Steve' },
+  orange:    { emoji: '🟠', css: '#ff8c00', name: 'paste waiting' },
+  purple:    { emoji: '🟣', css: '#a45cff', name: 'gated' },
+  yellow:    { emoji: '🟡', css: '#ffd21e', name: 'needs direction' },
+  green:     { emoji: '🟢', css: '#2ecc57', name: 'working' },
+  pink:      { emoji: '🩷', css: '#ff8fc8', name: 'parked' },
+  none:      { emoji: '⚪', css: '#8a8a8a', name: 'no dot' },
+};
+
+function run(cmd, args, timeoutMs = 8000) {
+  return new Promise((resolve) => {
+    execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 1 << 20 }, (err, stdout) => {
+      resolve({ err, stdout: stdout || '' });
+    });
+  });
+}
+
+// Strip a leading dot emoji + spaces from a label so the text reads cleanly.
+function cleanLabel(label) {
+  if (!label) return '';
+  return label.replace(/^[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}️\s]+/u, '').trim();
+}
+function ticketOf(label) {
+  const m = /(TK-\d+)/i.exec(label || '');
+  return m ? m[1].toUpperCase() : '';
+}
+
+async function getDots() {
+  const { stdout } = await run('bash', [ALLCOLORDOTS, '--json']);
+  let rows = [];
+  try { rows = JSON.parse(stdout || '[]'); } catch { rows = []; }
+  rows = rows.filter(r => r && r.live); // only live sessions count as "running"
+  const groups = {};
+  for (const key of ORDER) groups[key] = [];
+  for (const r of rows) {
+    const color = META[r.color] ? r.color : 'none';
+    groups[color].push({
+      tty: r.tty,
+      pid: r.pid,
+      ticket: ticketOf(r.label),
+      doing: cleanLabel(r.label),
+    });
+  }
+  const out = ORDER
+    .map(color => ({ color, ...META[color], count: groups[color].length, sessions: groups[color] }))
+    .filter(g => g.count > 0 || g.color === 'green'); // always show green; hide empty others
+  return { updated: Date.now(), total: rows.length, groups: out };
+}
+
+// Bring the iTerm2 session whose tty matches to the front.
+async function reveal(tty) {
+  const safe = String(tty || '').replace(/[^a-z0-9]/gi, '');
+  if (!safe) return { ok: false, error: 'bad tty' };
+  // Index-based walk with try around each access — iTerm2's `repeat with s in
+  // sessions of t` throws -1719 when a tab has split panes; this form doesn't.
+  const script = `
+tell application "iTerm2"
+  activate
+  repeat with wi from 1 to (count of windows)
+    set w to window wi
+    repeat with ti from 1 to (count of tabs of w)
+      set t to tab ti of w
+      repeat with si from 1 to (count of sessions of t)
+        try
+          set s to session si of t
+          if (tty of s) contains "${safe}" then
+            select w
+            tell t to select
+            tell s to select
+            return "ok"
+          end if
+        end try
+      end repeat
+    end repeat
+  end repeat
+end tell
+return "notfound"`;
+  const { stdout, err } = await run('osascript', ['-e', script]);
+  return { ok: /ok/.test(stdout) && !err, result: (stdout || '').trim(), error: err ? String(err) : null };
+}
+
+function send(res, code, body, type = 'application/json') {
+  res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
+  res.end(typeof body === 'string' ? body : JSON.stringify(body));
+}
+
+const server = http.createServer(async (req, res) => {
+  try {
+    const url = new URL(req.url, 'http://x');
+    if (url.pathname === '/api/dots') return send(res, 200, await getDots());
+    if (url.pathname === '/api/reveal' && req.method === 'POST') {
+      let raw = '';
+      req.on('data', c => (raw += c));
+      req.on('end', async () => {
+        let tty = '';
+        try { tty = JSON.parse(raw).tty; } catch {}
+        send(res, 200, await reveal(tty));
+      });
+      return;
+    }
+    if (url.pathname === '/' || url.pathname === '/index.html') {
+      return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html'), 'utf8'), 'text/html');
+    }
+    send(res, 404, { error: 'not found' });
+  } catch (e) {
+    send(res, 500, { error: String(e) });
+  }
+});
+
+// Bind to the requested PORT; if taken, walk upward to the next free one.
+function listen(port, tries = 20) {
+  server.once('error', (e) => {
+    if (e.code === 'EADDRINUSE' && tries > 0) return listen(port + 1, tries - 1);
+    throw e;
+  });
+  server.listen(port, '127.0.0.1', () => {
+    const p = server.address().port;
+    fs.writeFileSync(path.join(__dirname, '.port'), String(p));
+    console.log(`desktop-dotbar on http://127.0.0.1:${p}`);
+  });
+}
+listen(parseInt(process.env.PORT || '9787', 10));
diff --git a/start-bar.command b/start-bar.command
new file mode 100755
index 0000000..3ea619f
--- /dev/null
+++ b/start-bar.command
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# Double-click to launch the desktop dot bar (or run: bash start-bar.command).
+# Starts the local server, then opens a frameless Chrome --app window pinned as a
+# thin strip across the top of the desktop. Idempotent: reuses a running server.
+set -u
+DIR="$HOME/Projects/desktop-dotbar"
+cd "$DIR" || exit 1
+
+# 1) ensure the server is up
+if [ -f .port ] && curl -sf "http://127.0.0.1:$(cat .port)/api/dots" >/dev/null 2>&1; then
+  PORT="$(cat .port)"
+  echo "server already up on :$PORT"
+else
+  pkill -f "node .*desktop-dotbar/server.js" 2>/dev/null
+  nohup node "$DIR/server.js" >"$DIR/server.log" 2>&1 &
+  for i in $(seq 1 30); do [ -f "$DIR/.port" ] && break; sleep 0.2; done
+  PORT="$(cat "$DIR/.port" 2>/dev/null || echo 9787)"
+  echo "server started on :$PORT"
+fi
+
+# 2) open the Chrome --app strip across the top (self-resizes via window.resizeTo)
+CHROME="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
+PROFILE="$DIR/.chrome-profile"
+"$CHROME" \
+  --app="http://127.0.0.1:$PORT/" \
+  --user-data-dir="$PROFILE" \
+  --window-position=0,0 \
+  --window-size=4480,48 \
+  --no-first-run --no-default-browser-check --disable-features=Translate \
+  >/dev/null 2>&1 &
+echo "dot bar opened. Close this window."

(oldest)  ·  back to Desktop Dotbar  ·  Electron always-on-top strip: color-dot counts, ticket dropd 994870a →