← 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
A .gitignoreA .portA public/index.htmlA server.jsA start-bar.command
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,'"')}">${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 →