← back to Dot Palette
dot-palette: always-on-top click palette to set the current iTerm2 tab's status dot
2629bec17f232ad3710b7477f5090d8b40ac4d73 · 2026-09-16 16:51:12 -0700 · Steve
Files touched
A .gitignoreA electron-main.jsA package.jsonA preload.jsA public/index.htmlA server.jsA start.command
Diff
commit 2629bec17f232ad3710b7477f5090d8b40ac4d73
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Sep 16 16:51:12 2026 -0700
dot-palette: always-on-top click palette to set the current iTerm2 tab's status dot
---
.gitignore | 10 ++++++
electron-main.js | 80 ++++++++++++++++++++++++++++++++++++++++++++++++
package.json | 7 +++++
preload.js | 5 +++
public/index.html | 91 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 87 ++++++++++++++++++++++++++++++++++++++++++++++++++++
start.command | 15 +++++++++
7 files changed, 295 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c4be4df
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+.port
+.pos
diff --git a/electron-main.js b/electron-main.js
new file mode 100644
index 0000000..20e5685
--- /dev/null
+++ b/electron-main.js
@@ -0,0 +1,80 @@
+// dot-palette — Electron shell: a small always-on-top, NON-ACTIVATING NSPanel
+// with 6 colored chips. Clicking a chip sets the CURRENT iTerm2 tab's status dot
+// via the local server (which resolves iTerm2's current-session tty + calls the
+// dot engine). Reuses the desktop-dotbar NSPanel recipe: type:'panel' +
+// focusable:false + alwaysOnTop('screen-saver') so clicks never steal focus from
+// the terminal you're working in.
+'use strict';
+const { app, BrowserWindow, ipcMain, screen } = require('electron');
+const { spawn } = require('child_process');
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const DIR = __dirname;
+const W = 250, H = 52;
+const POS_FILE = path.join(DIR, '.pos');
+let win, serverProc, PORT = null;
+
+function portFromFile() { try { return parseInt(fs.readFileSync(path.join(DIR, '.port'), 'utf8'), 10); } catch { return null; } }
+function alive(port) {
+ return new Promise(res => {
+ if (!port) return res(false);
+ const r = http.get({ host: '127.0.0.1', port, path: '/health', timeout: 800 }, x => { x.resume(); res(x.statusCode === 200); });
+ r.on('error', () => res(false)); r.on('timeout', () => { r.destroy(); res(false); });
+ });
+}
+// Spawn our own server via Electron-as-node (no separate node install needed).
+async function ensureServer() {
+ serverProc = spawn(process.execPath, [path.join(DIR, 'server.js')], {
+ stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' } });
+ for (let i = 0; i < 40; i++) {
+ await new Promise(r => setTimeout(r, 150));
+ const p = portFromFile();
+ if (await alive(p)) return p;
+ }
+ return portFromFile() || 9791;
+}
+
+function readPos() {
+ try {
+ const [x, y] = fs.readFileSync(POS_FILE, 'utf8').split(',').map(n => parseInt(n, 10));
+ if (Number.isFinite(x) && Number.isFinite(y)) return { x, y };
+ } catch {}
+ const wa = screen.getPrimaryDisplay().workArea; // default: top-right of primary
+ return { x: wa.x + wa.width - W - 24, y: wa.y + 24 };
+}
+function writePos() { try { if (win && !win.isDestroyed()) { const b = win.getBounds(); fs.writeFileSync(POS_FILE, `${b.x},${b.y}`); } } catch {} }
+function raise() { try { if (win && !win.isDestroyed()) { win.setAlwaysOnTop(true, 'screen-saver'); win.moveTop(); } } catch {} }
+
+async function createWindow() {
+ PORT = await ensureServer();
+ const pos = readPos();
+ win = new BrowserWindow({
+ x: pos.x, y: pos.y, width: W, height: H,
+ type: 'panel', // NSPanel: floats above normal windows, non-activating
+ frame: false, resizable: false, movable: true, minimizable: false, maximizable: false,
+ fullscreenable: false, skipTaskbar: true, hasShadow: true, transparent: false,
+ focusable: false, // never steal focus from the terminal being colored
+ backgroundColor: '#14161a',
+ webPreferences: { preload: path.join(DIR, 'preload.js'), contextIsolation: true, nodeIntegration: false },
+ });
+ win.setAlwaysOnTop(true, 'screen-saver');
+ win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
+ win.loadURL(`http://127.0.0.1:${PORT}/`);
+ win.on('moved', writePos);
+ setInterval(raise, 2000); // re-assert top above other apps
+ ipcMain.on('palette:quit', () => app.quit());
+ ipcMain.on('palette:move', (_e, dx, dy) => {
+ if (!win || win.isDestroyed()) return;
+ const b = win.getBounds(); win.setBounds({ ...b, x: b.x + Math.round(dx), y: b.y + Math.round(dy) }); writePos();
+ });
+}
+
+app.on('window-all-closed', () => app.quit());
+app.on('before-quit', () => { if (serverProc) try { serverProc.kill(); } catch {} });
+app.whenReady().then(() => {
+ if (app.dock) app.dock.hide(); // accessory app: panel floats above active apps
+ createWindow().catch(e => console.log('PALETTE createWindow ERROR', e && e.stack || e));
+});
+process.on('uncaughtException', e => console.log('PALETTE uncaught', e && e.stack || e));
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..feec484
--- /dev/null
+++ b/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "dot-palette",
+ "version": "1.0.0",
+ "private": true,
+ "description": "Always-on-top clickable palette to set the CURRENT iTerm2 tab's status dot.",
+ "main": "electron-main.js"
+}
diff --git a/preload.js b/preload.js
new file mode 100644
index 0000000..f5be7d7
--- /dev/null
+++ b/preload.js
@@ -0,0 +1,5 @@
+const { contextBridge, ipcRenderer } = require('electron');
+contextBridge.exposeInMainWorld('palette', {
+ quit: () => ipcRenderer.send('palette:quit'),
+ move: (dx, dy) => ipcRenderer.send('palette:move', dx, dy),
+});
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..8d3914b
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,91 @@
+<!doctype html>
+<html>
+<head>
+<meta charset="utf-8">
+<title>dot-palette</title>
+<style>
+ :root { color-scheme: dark; }
+ * { box-sizing: border-box; -webkit-user-select: none; user-select: none; }
+ html, body { margin: 0; height: 100%; background: #14161a; overflow: hidden;
+ font: 12px -apple-system, system-ui, sans-serif; color: #cbd2da; }
+ #bar { display: flex; align-items: center; gap: 6px; height: 100%; padding: 0 8px; }
+ /* Left grip: the drag handle (the window is non-activating, so we move it via IPC deltas). */
+ #grip { cursor: grab; color: #4a525c; font-size: 13px; letter-spacing: -2px; padding: 0 2px; }
+ #grip:active { cursor: grabbing; }
+ .chip { width: 30px; height: 30px; border-radius: 50%; border: 2px solid rgba(255,255,255,.14);
+ display: flex; align-items: center; justify-content: center; font-size: 15px; cursor: pointer;
+ transition: transform .08s ease, box-shadow .12s ease; }
+ .chip:hover { transform: scale(1.12); }
+ .chip:active { transform: scale(.92); }
+ .chip.ok { box-shadow: 0 0 0 3px rgba(255,255,255,.85); }
+ .chip.bad { box-shadow: 0 0 0 3px #ff3b30; }
+ #x { margin-left: 2px; color: #565e69; cursor: pointer; font-size: 14px; padding: 0 3px; }
+ #x:hover { color: #ff5b52; }
+</style>
+</head>
+<body>
+ <div id="bar">
+ <div id="grip" title="drag to move">⠿</div>
+ <div id="chips"></div>
+ <div id="x" title="close palette">×</div>
+ </div>
+ <script>
+ const CHIPS = [
+ { c: 'green', bg: '#00c853', em: '🟢', name: 'WORKING' },
+ { c: 'yellow', bg: '#ffcc00', em: '🟡', name: 'DIRECTION?' },
+ { c: 'orange', bg: '#ff8c00', em: '🟠', name: 'PASTE waiting' },
+ { c: 'purple', bg: '#9400d3', em: '🟣', name: 'GATED' },
+ { c: 'lightblue', bg: '#00b0f0', em: '🔵', name: 'NEEDS STEVE' },
+ { c: 'pink', bg: '#ff69b4', em: '🩷', name: 'PARKED' },
+ ];
+ const chipsEl = document.getElementById('chips');
+ chipsEl.style.display = 'flex';
+ chipsEl.style.gap = '6px';
+
+ for (const k of CHIPS) {
+ const el = document.createElement('div');
+ el.className = 'chip';
+ el.textContent = k.em;
+ el.style.background = k.bg + '22'; // faint fill; the ring + emoji carry the colour
+ el.style.borderColor = k.bg;
+ el.title = k.c + ' — ' + k.name;
+ el.addEventListener('click', async () => {
+ try {
+ const r = await fetch('/api/setdot', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ color: k.c }),
+ });
+ const j = await r.json();
+ el.classList.remove('ok', 'bad');
+ void el.offsetWidth; // restart the CSS transition
+ el.classList.add(j.ok ? 'ok' : 'bad');
+ el.title = j.ok ? (k.c + ' — set on ' + j.tty) : (k.c + ' — ' + (j.error || 'failed'));
+ setTimeout(() => el.classList.remove('ok', 'bad'), 650);
+ } catch (e) {
+ el.classList.add('bad');
+ setTimeout(() => el.classList.remove('bad'), 650);
+ }
+ });
+ chipsEl.appendChild(el);
+ }
+
+ // Drag-to-move: the panel is focusable:false so native title-bar drag won't fire;
+ // track pointer deltas on the grip and nudge the window via the main process.
+ const grip = document.getElementById('grip');
+ let dragging = false, lastX = 0, lastY = 0;
+ grip.addEventListener('pointerdown', (e) => {
+ dragging = true; lastX = e.screenX; lastY = e.screenY;
+ grip.setPointerCapture(e.pointerId); e.preventDefault();
+ });
+ grip.addEventListener('pointermove', (e) => {
+ if (!dragging) return;
+ const dx = e.screenX - lastX, dy = e.screenY - lastY;
+ lastX = e.screenX; lastY = e.screenY;
+ if (dx || dy) window.palette.move(dx, dy);
+ });
+ grip.addEventListener('pointerup', (e) => { dragging = false; try { grip.releasePointerCapture(e.pointerId); } catch {} });
+
+ document.getElementById('x').addEventListener('click', () => window.palette.quit());
+ </script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..5d23a70
--- /dev/null
+++ b/server.js
@@ -0,0 +1,87 @@
+#!/usr/bin/env node
+// dot-palette — tiny local server behind the always-on-top color palette.
+// One endpoint: POST /api/setdot {color,label?} -> resolve the CURRENT iTerm2
+// pane's tty via AppleScript, then call the dot engine so the ~/.claude/tab-dots
+// registry (what dot-screen-router reads) stays the single source of truth.
+// Zero deps (Node built-ins only). $0 local — reads/sets only your own tabs.
+'use strict';
+
+const http = require('http');
+const { execFile } = require('child_process');
+const fs = require('fs');
+const path = require('path');
+
+const ENGINE = `${process.env.HOME}/Projects/terminal-status/terminal_status.py`;
+// The six status colours the engine accepts (COLORS in terminal_status.py).
+const COLORS = ['green', 'yellow', 'orange', 'purple', 'lightblue', 'pink'];
+
+function run(cmd, args, timeoutMs = 8000) {
+ return new Promise((resolve) => {
+ execFile(cmd, args, { timeout: timeoutMs, maxBuffer: 1 << 20 }, (err, stdout, stderr) => {
+ resolve({ err, stdout: (stdout || '').trim(), stderr: (stderr || '').trim() });
+ });
+ });
+}
+
+// The crux: ask iTerm2 for ITS current session's tty (the tab Steve last used),
+// NOT the OS frontmost app — so a non-activating palette click still targets the
+// right pane even though iTerm2 may not be the active application at click time.
+async function frontTty() {
+ const script = 'tell application "iTerm2" to get tty of current session of current window';
+ const { stdout } = await run('osascript', ['-e', script], 5000);
+ return /^\/dev\/ttys\d+$/.test(stdout) ? stdout : '';
+}
+
+async function setDot(color, label) {
+ if (!COLORS.includes(color)) return { ok: false, error: `bad color: ${color}` };
+ const tty = await frontTty();
+ if (!tty) return { ok: false, error: 'no current iTerm2 session (is iTerm2 open?)' };
+ const args = [ENGINE, 'set', color];
+ if (label) args.push(String(label).slice(0, 120));
+ args.push('--tty', tty);
+ const { err, stdout, stderr } = await run('python3', args, 8000);
+ return { ok: !err, tty, color, out: stdout, error: err ? (stderr || String(err)).slice(0, 200) : 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 === '/health') {
+ return send(res, 200, { ok: true, port: server.address() && server.address().port });
+ }
+ if (url.pathname === '/api/setdot' && req.method === 'POST') {
+ let raw = '';
+ req.on('data', c => (raw += c));
+ req.on('end', async () => {
+ let color = '', label = '';
+ try { const j = JSON.parse(raw); color = j.color; label = j.label || ''; } catch {}
+ send(res, 200, await setDot(color, label));
+ });
+ 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) });
+ }
+});
+
+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(`dot-palette on http://127.0.0.1:${p}`);
+ });
+}
+listen(parseInt(process.env.PORT || '9791', 10));
diff --git a/start.command b/start.command
new file mode 100755
index 0000000..4654ff6
--- /dev/null
+++ b/start.command
@@ -0,0 +1,15 @@
+#!/bin/zsh
+# Launch the always-on-top dot palette. Reuses desktop-dotbar's Electron runtime
+# so there is nothing to npm-install. Double-click this file, or run it from a shell.
+cd "$(dirname "$0")"
+ELECTRON="$HOME/Projects/desktop-dotbar/node_modules/.bin/electron"
+if [[ ! -x "$ELECTRON" ]]; then
+ echo "Electron not found at $ELECTRON — run 'npm i' in ~/Projects/desktop-dotbar first." >&2
+ exit 1
+fi
+# Single instance: if a palette is already up, don't stack another.
+if pgrep -f "dot-palette/electron-main.js" >/dev/null 2>&1; then
+ echo "dot-palette already running."
+ exit 0
+fi
+exec "$ELECTRON" . >/tmp/dot-palette.log 2>&1
(oldest)
·
back to Dot Palette
·
dot-palette: side-effect-free test seam + selftest + safe-by bc3244f →