refactor: restructure project into muxplex/ subdir with brand integration

- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt,
  docs/ into muxplex/ subdir in prep for packaging/sharing
- Add brand assets to frontend/: favicon.ico, pwa-192/512.png,
  apple-touch-icon.png, wordmark-on-dark.svg
- Update app: title → muxplex, header → wordmark SVG, brand color tokens
  in style.css, manifest.json updated with muxplex name and brand icons
- Add design system: assets/branding/tokens.css (101 CSS custom properties),
  tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec)
- Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG)
- Add scripts/render-brand-assets.py for reproducible PNG generation
- Add muxplex/README.md
This commit is contained in:
Brian Krabach
2026-03-27 15:06:00 -07:00
commit 8234e2ec05
76 changed files with 17006 additions and 0 deletions
@@ -0,0 +1,318 @@
# Web-Tmux Dashboard Design
## Goal
Build a browser-based dashboard for monitoring and interacting with ~12 running tmux sessions on a home/dev device, accessible from multiple devices (laptop, mobile, tablet) over Tailscale.
## Background
Managing a dozen tmux sessions across multiple devices requires constant SSH-ing and manual `tmux attach` commands. There's no unified view of what's running, no bell notifications when something needs attention, and no way to glance at session output from a phone. This project solves that by providing a live overview grid with capture-pane snapshots and on-demand interactive terminal access through the browser.
## Tool Decision: ttyd
Six tools were evaluated for browser-based terminal access:
| Tool | Verdict | Reason |
|------|---------|--------|
| **ttyd** | **Chosen** | Single C binary, wraps `tmux attach` natively with no SSH layer. ~5-10 MB memory. Actively maintained (11.3k stars, March 2024 release). Built-in basic auth, mTLS support, header-based auth for proxy delegation, `-m N` connection limits, `-W` writable mode. |
| WeTTY | Rejected | Requires SSH daemon + Node.js runtime. Higher complexity for the same outcome. |
| GoTTY | Rejected | Original abandoned 2017. Active fork has smaller community than ttyd. |
| Sshwifty | Rejected | Browser SSH client, not a terminal server. Solves a different problem. |
| Guacamole | Rejected | Java/Tomcat + 3 containers. Enterprise-scale overkill. |
| shellinabox | Rejected | No WebSocket support. Effectively a dead project. |
## Approach
**Capture-pane overview + single live ttyd session.**
The overview grid renders text snapshots via `tmux capture-pane -p -t <session>` (polled every 2s) into styled monospace `<pre>` blocks. A single `ttyd` instance handles whichever session is currently expanded. This avoids resize conflicts and simultaneous WebSocket pile-ups. ttyd stays running persistently (not tied to browser sessions).
## Architecture
Three components running directly on the host (no Docker — need access to host tmux sessions), plus a reverse proxy:
```
┌─────────────────────────────────────────────────────────────┐
│ Caddy (reverse proxy) │
│ - HTTPS termination │
│ - Basic auth (basicauth directive) │
│ - / and /api/* → Coordinator (port 8080) │
│ - /terminal/* → active ttyd (port 7682) │
└─────────────┬──────────────────────────────┬────────────────┘
│ │
▼ ▼
┌──────────────────────────┐ ┌──────────────────────────────┐
│ Session Coordinator │ │ ttyd (C binary) │
│ Python server (~150 LOC)│ │ Spawned on demand │
│ Port 8080 │ │ Port 7682 │
│ - Dashboard HTML/JS/CSS │ │ - One instance at a time │
│ - JSON API │ │ - tmux attach -t {name} │
│ - State management │ │ - -m 3 (multi-device) │
│ - Bell detection │ │ - -W (writable) │
│ - ttyd lifecycle │ │ │
└──────────────────────────┘ └──────────────────────────────┘
┌──────────────────────────┐
│ Dashboard Frontend │
│ Vanilla HTML/CSS/JS │
│ - CSS auto-fill grid │
│ - xterm.js terminal │
│ - Bell notifications │
│ - PWA support │
└──────────────────────────┘
```
**Supervisor:** The Coordinator runs as a systemd service for automatic restart after crashes.
**Network access:** Tailscale handles network-level access control. Caddy's basic auth serves as a local backstop.
## Components
### Session Coordinator (Python Server)
A lightweight Python server (~150 lines) serving the dashboard and exposing a JSON API:
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/api/state` | `GET` | Returns full persistent state (active session, session order, bell state) |
| `/api/sessions` | `GET` | Returns all running tmux sessions with capture-pane snapshots and bell flags |
| `/api/sessions/{name}/connect` | `POST` | Kills current ttyd, spawns fresh one on port 7682 for named session, updates state |
| `/api/sessions/current` | `DELETE` | Terminates active ttyd, clears active session from state |
| `/api/state` | `PATCH` | Updates session ordering |
The coordinator owns all state. Browsers are views; the coordinator is the source of truth.
### ttyd Instance
One instance at a time, always on port 7682:
```bash
ttyd -W -m 3 -p 7682 tmux attach -t {name}
```
- `-W` — writable mode
- `-m 3` — allows laptop + tablet + phone simultaneously
- Killed and respawned when switching sessions
- Stays running when browser disconnects (persists until explicitly closed)
### Dashboard Frontend
Vanilla HTML/CSS/JS (no framework).
**Desktop layout:**
- CSS `auto-fill` grid with `minmax(360px, 1fr)` — column count emerges from viewport (4 at 2560px, 2 at 900px, 1 below 600px)
- Fixed 300px tile height
- `capture-pane` text rendered in `<pre>` blocks, showing bottom of output (most recent lines), faded top edge, no scrollbars
**Mobile layout:**
- Three-tier priority list:
1. Sessions with bells — expanded, 4-6 output lines
2. Recently active (< 5 min) — 1 line preview
3. Idle — name + timestamp only
- Tap → full-screen terminal
**Mode transition:** Zoom-in-place — selected tile expands from grid position to fill viewport (~250ms), siblings fade. Reverse on return to grid.
### Caddy (Reverse Proxy)
- HTTPS termination
- `basicauth` directive for authentication
- Routes `/` and `/api/*` → Coordinator (port 8080)
- Routes `/terminal/*` → active ttyd (port 7682)
## Data Flow
### Persistent State
State file location: `~/.local/share/tmux-web/state.json`
```json
{
"active_session": "work",
"session_order": ["main", "work", "logs", "music"],
"sessions": {
"logs-prod": {
"bell": {
"last_fired_at": 1711425900.0,
"seen_at": null,
"unseen_count": 3
}
}
},
"devices": {
"d-a1b2c3": {
"label": "Laptop Chrome",
"viewing_session": "dev-server",
"view_mode": "fullscreen",
"last_interaction_at": 1711425950.0,
"last_heartbeat_at": 1711425958.0
}
}
}
```
**Atomic writes:** All `state.json` writes use `os.replace()` (write to temp, rename) — atomic on POSIX.
**Concurrent write safety:** Coordinator uses `asyncio` with a single write lock around `state.json` updates. All async paths serialize through this lock before calling `os.replace()`.
### Browser Connection Flow
1. New browser calls `GET /api/state`
2. If `active_session` is set, frontend immediately connects to already-running ttyd (no "reopen" needed)
3. Browser is a view; coordinator owns the truth
### ttyd Lifecycle
- Spawned when user explicitly opens a session via `POST /api/sessions/{name}/connect`
- Stays running even when browser disconnects
- New connections on any device attach to same running PTY
- Killed only when user explicitly closes the session or switches to another
### Multi-Device Connections
`-m 3` allows phone + tablet + desktop simultaneously. When a new device connects with a different terminal size, the coordinator issues:
```bash
tmux resize-window -t {session} -x {cols} -y {rows}
```
### Client Heartbeat
Every 5 seconds (via WebSocket or poll):
```json
{
"device_id": "d-a1b2c3",
"viewing_session": "dev-server",
"view_mode": "fullscreen",
"last_interaction_at": 1711425950.0
}
```
Devices are pruned from state after 5 minutes of silence.
## Bell Notification & Acknowledgement
### Core Model
One user, one awareness. Bell state is global — not per-device.
### Detection
Coordinator polls `tmux display-message -t {name} -p "#{window_bell_flag}"` alongside `capture-pane` every 2s. Transitions from false → true increment `unseen_count` and set `last_fired_at`.
### Clear Rule
A bell clears globally (on every device) when:
1. That session is open **full-screen** on a device (`view_mode == "fullscreen"`), AND
2. That device has had a **user interaction** within the last **60 seconds** (`last_interaction_at > now - 60s`)
If laptop is sitting idle on session #1, bells persist everywhere. When user taps session #1 on phone and interacts, it clears on all devices.
### `unseen_count`
Tracks rapid successive bells — shows "3 bells while you were away."
### Visual Indicators
- **Overview tiles:** Amber (#E8A040) pulsing dot/border on tiles with `unseen_count > 0`
- **Mobile list:** Amber badge next to session name; bell sessions sort to top automatically
### Browser Notifications API
On first load, request permission. When poll cycle detects bell transition (false → true), fire `new Notification("Activity in: " + name)`. Shows in OS notification center even when tab is backgrounded. Works on mobile when added to home screen (iOS Safari 16.4+, Android Chrome).
## UI/UX Design
### Session Tile Information Hierarchy
1. **Terminal content** (~90% of tile area) — the `<pre>` IS the tile
2. **Bell indicator** — amber (#E8A040) pulsing dot, top-right of header. Only chrome that interrupts scanning.
3. **Session name** — top-left, 12-13px, medium weight
4. **Last activity time** — top-right, 11-12px, dim. "2s ago" / "5m ago"
### Responsive Breakpoints
| Viewport | Columns | Notes |
|----------|---------|-------|
| < 600px | 1 (list) | Mobile priority list |
| 600-899px | 1 | Single-column tiles |
| 900-1199px | 2 | NOT 768px — at 768px with 2 columns, monospace tiles are only ~26 chars wide (unreadable) |
| 1200px+ | 4 | Full grid |
### Session Switcher
**Desktop:** Command palette triggered by `Ctrl+K`. Arrow keys + number keys 1-9 for navigation, type to fuzzy-filter by name, `G` to return to grid. No sidebar — sidebars steal terminal columns.
**Mobile:** Bottom sheet triggered by floating pill button (48x48px, bottom-right, semi-transparent when idle). 56px row height. Dismiss via swipe-down or tap outside.
### Connection Status
Minimal inline indicators — no persistent status bar. Polling freshness shown via tile timestamp staleness. WebSocket status shown only when degraded (brief "reconnecting..." overlay on the active terminal).
### xterm.js on Mobile
- Use `visualViewport` API (not `window.innerHeight`) to resize terminal rows when keyboard opens
- Never let terminal scroll behind keyboard — pin to fill visual viewport exactly
- `scrollback`: 500 rows on mobile (vs 5000 on desktop)
- Hint toward landscape in expanded mode (~80+ columns)
### PWA Configuration
- `display: standalone` — removes browser chrome
- `theme-color` matched to dark theme — prevents white flash on launch
- `orientation: any` — don't lock orientation
- `apple-mobile-web-app-capable` + `apple-mobile-web-app-status-bar-style: black-translucent` for iOS
- Suppress pinch-zoom on terminal view only
### Accessibility
- `prefers-reduced-motion: reduce` — respect for activity pulse animations
- Arrow keys to navigate grid tiles, Enter to expand, Escape to return
- Focus moves to xterm.js when session expands; returns to tile when closed
## Error Handling
| Scenario | Behavior |
|----------|----------|
| **Session disappears between polls** | Dropped from grid on next poll. If user was connected, ttyd exits, WebSocket closes, frontend returns to overview grid with "session ended" message. |
| **ttyd fails to start** | `POST /connect` returns error JSON. Frontend shows toast: "Couldn't connect to session 'work'". No crashed state. |
| **Orphaned ttyd on coordinator restart** | Startup routine reads PID file, checks if process is still running, kills any orphaned ttyd before registering new state. |
| **WebSocket drops mid-session** | ttyd reconnects on brief drops (ping/pong). Longer outages show "reconnecting..." overlay. On reconnect, frontend re-calls `/connect` for fresh ttyd process. |
| **No tmux sessions running** | Empty state: "No active tmux sessions — will update automatically." |
| **Stale bell state on coordinator restart** | Bell state in memory only; restart treats all bells as fresh. Worst case: see a bell already addressed. No missed bells. |
| **Concurrent state writes** | `asyncio` single write lock serializes all async paths before `os.replace()`. |
## Testing Strategy
### 1. Coordinator Unit Tests (Python)
Session enumeration logic, ttyd process lifecycle (spawn, kill, orphan detection), state file atomic writes, bell detection and acknowledgement logic, active-device gate rule. Mock `subprocess` calls to tmux. No tmux or browser required. Fast, always runnable.
### 2. Integration Tests (Coordinator + tmux)
Spin up real tmux server in test environment (`tmux -L test-server`), create named sessions, verify full polling cycle — capture-pane output, bell flag detection and clearing, ttyd spawns on correct session, state file updated atomically. Requires tmux installed, no browser.
### 3. Browser/E2E Tests
Verify dashboard renders, tiles appear and update, bell indicators show/clear correctly, session expansion works, session switcher opens, mobile layout at 375px and 768px viewport widths.
### Manual Smoke Test Checklist
Run after each deploy:
- [ ] Open two browser tabs; verify state is consistent across both
- [ ] Close browser, reopen on different device; verify state restored correctly
- [ ] Trigger tmux bell with `printf '\a'`; verify appears on non-focused device within one poll cycle
- [ ] Let laptop sit idle on session #1 for 90s; trigger bell in session #1; verify mobile shows bell indicator
- [ ] Actively use session #1 on phone; verify bell clears on laptop within one poll cycle
## Open Questions
1. **Escape key strategy** — double-Escape, `Ctrl+Shift+G`, or hold-duration? Affects daily usability in terminal-heavy workflows (Vim, less, fzf all use Escape).
2. **Tile density toggle** — single density or compact/comfortable toggle?
3. **Session reordering** — drag-to-reorder tiles, or always match tmux session creation order?
4. **Bell flag read behavior** — verify whether `tmux display-message -p "#{window_bell_flag}"` clears the flag or only reads it. Must be tested empirically before implementing bell logic.
5. **Polling interval** — 2s chosen, but 4-5s may be imperceptible for overview scanning and halves subprocess load. Worth benchmarking.