commit 8234e2ec05ef51e7b8dc18bec8ba0de083ff78ef Author: Brian Krabach Date: Fri Mar 27 15:06:00 2026 -0700 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 diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..8b2f6bd --- /dev/null +++ b/Caddyfile @@ -0,0 +1,11 @@ +:8088 { + # WebSocket to ttyd: strip /terminal prefix so /terminal/ws reaches ttyd's /ws endpoint. + # ttyd only registers the 'tty' WebSocket protocol handler at /ws — not at /terminal/ws. + # handle + uri strip_prefix rewrites /terminal/ws → /ws before the reverse_proxy sees it. + handle /terminal/* { + uri strip_prefix /terminal + reverse_proxy localhost:7682 + } + # All other requests (static files, API) → coordinator (port 8099) + reverse_proxy * localhost:8099 +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e57bd10 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# muxplex + +A web-based dashboard for tmux sessions. Access and manage all your tmux sessions from any device — browser, phone, tablet. + +## Features +- Live tile grid showing all running tmux sessions +- Click any session to open a full interactive terminal +- Bell/activity notifications across sessions +- Multi-device support with state sync +- Mobile-friendly, responsive, PWA-capable +- Works over Tailscale private network + +## Stack +- **Backend:** Python FastAPI coordinator (`coordinator/`) +- **Frontend:** Vanilla JS + xterm.js (`frontend/`) +- **Terminal:** ttyd (WebSocket bridge to tmux) +- **Proxy:** Caddy + +## Running + +```bash +# Install dependencies +pip install -r requirements.txt + +# Start coordinator (from this directory) +python -m uvicorn coordinator.main:app --host 0.0.0.0 --port 8099 + +# Start Caddy proxy +caddy run --config Caddyfile +``` + +## Development + +```bash +# Run tests +python -m pytest +``` + +## Brand assets + +Design language, tokens, and brand assets in `assets/branding/`. +To regenerate PNG/favicon assets from SVG sources: +```bash +python3 scripts/render-brand-assets.py +``` diff --git a/assets/branding/DESIGN-SYSTEM.md b/assets/branding/DESIGN-SYSTEM.md new file mode 100644 index 0000000..3942f15 --- /dev/null +++ b/assets/branding/DESIGN-SYSTEM.md @@ -0,0 +1,857 @@ +# muxplex Design System + +**Version:** 1.0.0 +**Date:** 2026-03-27 +**Applies to:** muxplex web-based tmux session dashboard + +This document defines every visual property used in muxplex. It is the single +source of truth. All values reference `tokens.css` (CSS custom properties) and +`tokens.json` (structured data for tooling). + +--- + +## How to Use This File + +1. Add `` before your app styles. +2. Reference any token as `var(--color-bg-base)`, `var(--space-4)`, etc. +3. This document explains *when* and *why* to use each token. +4. The JSON file mirrors every value for scripts, linters, or framework config. + +--- + +## Table of Contents + +1. [Color System](#1-color-system) +2. [Typography](#2-typography) +3. [Spacing](#3-spacing) +4. [Border Radius](#4-border-radius) +5. [Shadows & Elevation](#5-shadows--elevation) +6. [Motion & Transitions](#6-motion--transitions) +7. [Z-Index Layers](#7-z-index-layers) +8. [Component Patterns](#8-component-patterns) +9. [Dark / Light Mode](#9-dark--light-mode) +10. [Accessibility](#10-accessibility) +11. [Do / Don't Reference](#11-do--dont-reference) + +--- + +## 1. Color System + +### 1.1 Backgrounds + +Dark mode uses **luminance stepping** — lighter surfaces appear more elevated. +This is the primary depth cue; shadows are supplementary. + +| Token | Dark | Light | Purpose | +|--------------------------|-------------|-------------|-----------------------------------| +| `--color-bg-base` | `#0D1117` | `#FFFFFF` | Page background, deepest layer | +| `--color-bg-elevated` | `#10131C` | `#F0F0F0` | Cards, panels, tile bodies | +| `--color-bg-surface` | `#1A1F2B` | `#E8E9EE` | Raised surfaces, hover states | +| `--color-bg-muted` | `#222433` | `#DADCE0` | Subtle fills, secondary panels | + +**Usage rules:** +- `bg-base` is always the page `` background. +- `bg-elevated` is the default card/panel surface (session tiles, picker). +- `bg-surface` appears on hover or as a secondary tier within a card (tile header). +- `bg-muted` is for de-emphasized areas (idle session backgrounds, code blocks). + +### 1.2 Text + +| Token | Dark | Light | Contrast on bg-base | Purpose | +|----------------------------|-------------|-------------|---------------------|--------------------------| +| `--color-text-primary` | `#F0F6FF` | `#0D1117` | 17.4:1 / 18.9:1 AAA | Headings, body text | +| `--color-text-secondary` | `#8E95A3` | `#4A5060` | 6.3:1 / 8.1:1 AA | Labels, timestamps | +| `--color-text-disabled` | `#4A5060` | `#8E95A3` | 2.4:1 / 2.6:1 | Disabled controls only | + +**Usage rules:** +- `text-primary` for anything the user needs to read — session names, body copy, + headings, wordmark text. +- `text-secondary` for supporting information — "2s ago" timestamps, hint text, + metadata labels, picker keyboard shortcuts. +- `text-disabled` **only** for controls the user cannot interact with. Never for + content a sighted user needs to read. The low contrast is intentional — it + signals "you can't use this." + +### 1.3 Accents + +| Token | Dark | Light | Role | +|--------------------------|-------------|-------------|-----------------------------------| +| `--color-accent-cyan` | `#00D9F5` | `#007D8C` | Primary accent, links, "plex" | +| `--color-accent-amber` | `#F1A640` | `#946000` | Notifications, bell indicator | + +**Critical note — light mode:** The brand cyan (`#00D9F5`) fails contrast on +white (1.7:1). In light mode, the token automatically maps to `#007D8C` (4.9:1 +AA) for text. If you need the bright cyan for a decorative non-text element in +light mode, use the literal hex `#00D9F5` directly — but never for text. + +The same applies to amber: `#946000` replaces `#F1A640` in light mode for text +use (5.3:1 AA). + +### 1.4 Borders + +| Token | Dark | Light | Purpose | +|---------------------------|-------------|-------------|------------------------------| +| `--color-border-subtle` | `#1E2430` | `#E0E2E8` | Dividers within a surface | +| `--color-border-default` | `#2A3040` | `#D0D2D8` | Card outlines, input borders | +| `--color-border-strong` | `#3A4050` | `#B0B4BC` | Active outlines, emphasis | + +**Usage rules:** +- Borders in dark mode are **decorative**, not functional separators. Don't rely + on border visibility for meaning — use background contrast instead. +- `border-subtle` for hairline dividers inside cards (e.g., between tile header + and terminal content). +- `border-default` for card outlines and input field borders. +- `border-strong` for active/focused input borders or highlighted tiles. + +### 1.5 Semantic State Colors + +| Token | Dark | Light | Contrast (dark bg-base) | Purpose | +|--------------------|-------------|-------------|-------------------------|--------------------| +| `--color-success` | `#3FB950` | `#1A7F37` | 7.5:1 AAA | Connected, healthy | +| `--color-error` | `#F85149` | `#CF222E` | 5.7:1 AA | Disconnected, fail | +| `--color-warning` | `#F1A640` | `#946000` | 9.3:1 AAA | Attention needed | +| `--color-info` | `#58A6FF` | `#0969DA` | 7.5:1 AAA | Informational | + +Each state has a background tint variant (`--color-success-bg`, etc.) at ~10-12% +opacity for banner or badge backgrounds. + +### 1.6 Overlays + +| Token | Dark | Light | Purpose | +|----------------------|------------------------------|--------------------------------|----------------------| +| `--color-backdrop` | `rgba(0, 0, 0, 0.60)` | `rgba(0, 0, 0, 0.30)` | Behind picker/modal | +| `--color-scrim` | `rgba(13, 17, 23, 0.80)` | `rgba(255, 255, 255, 0.80)` | Frosted glass areas | + +--- + +## 2. Typography + +### 2.1 Font Stacks + +```css +--font-display: 'Urbanist', system-ui, -apple-system, sans-serif; +--font-ui: 'DM Sans', 'Inter', system-ui, -apple-system, sans-serif; +--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Menlo', monospace; +``` + +**Load from Google Fonts:** +```html + + + +``` + +### 2.2 Font Roles + +| Font | Weights | Use Cases | +|-----------------|-------------|------------------------------------------------------| +| Urbanist | 700 | Wordmark only, H1-level page titles | +| DM Sans | 400 / 500 / 600 | All UI text — buttons, labels, body, headings H2+ | +| JetBrains Mono | 400 / 500 | Terminal tile content, expanded terminal, code blocks | + +**Urbanist is restricted.** It appears in exactly two places: +1. The "muxplex" wordmark in the app bar. +2. An H1 page title if the app ever has one (currently: "Terminal Dashboard" in + the app bar could use it, or stay DM Sans 600 for a quieter look). + +Do not use Urbanist for buttons, labels, or body text. Its high x-height and +geometric structure read as a display face, not a UI face. + +### 2.3 Type Scale + +All sizes use `--font-ui` (DM Sans) unless noted. + +| Token | Size | Weight | Line-height | Use | +|----------------|--------|------------------|----------------------|--------------------------------------| +| `--text-xs` | 12px | 400 regular | `--leading-base` 1.5 | Fine print, keyboard shortcuts | +| `--text-sm` | 13px | 400 / 500 | `--leading-base` 1.5 | Timestamps, secondary labels | +| `--text-base` | 14px | 400 / 500 | `--leading-base` 1.5 | **Default UI text** — buttons, body | +| `--text-md` | 16px | 500 medium | `--leading-snug` 1.375 | Session names in tiles | +| `--text-lg` | 20px | 600 semibold | `--leading-tight` 1.2 | Section headings | +| `--text-xl` | 24px | 600 semibold | `--leading-tight` 1.2 | Panel titles | +| `--text-2xl` | 30px | 700 Urbanist | `--leading-tight` 1.2 | H1 page titles | +| `--text-3xl` | 36px | 700 Urbanist | `--leading-none` 1.0 | Hero wordmark (if needed) | + +### 2.4 Letter Spacing + +| Token | Value | Use | +|----------------------|------------|---------------------------------------------| +| `--tracking-tight` | -0.01em | Headings 20px+ (tighten for optical balance)| +| `--tracking-normal` | 0 | Body text, default | +| `--tracking-wide` | 0.02em | Small text <13px (open up for legibility) | +| `--tracking-wider` | 0.04em | All-caps labels ("NEEDS ATTENTION") | + +### 2.5 Terminal Typography + +| Context | Font | Size | Weight | +|--------------------|---------------|---------------------------|--------| +| Tile preview | JetBrains Mono | `--tile-font-size` 10.5px | 400 | +| Expanded terminal | JetBrains Mono | `--terminal-font-size` 14px | 400 | +| Code in UI | JetBrains Mono | `--text-sm` 13px | 400 | + +- Terminal text uses the standard ANSI 16-color palette provided by xterm.js. + Don't override ANSI colors with brand colors. +- In overview tiles, 10.5px monospace yields ~50 characters per 360px tile width — + enough to scan build output, log lines, and command prompts. +- The expanded terminal at 14px matches standard terminal emulator defaults. + +--- + +## 3. Spacing + +### 3.1 Scale + +4px base unit. Every spacing value is a multiple of 4. + +| Token | Value | Pixels | Common Use | +|-------------|-------|--------|---------------------------------------------| +| `--space-1` | 0.25rem | 4px | Inner padding of small elements, icon gaps | +| `--space-2` | 0.5rem | 8px | Grid gap, compact padding, inline spacing | +| `--space-3` | 0.75rem | 12px | Card inner padding, button padding | +| `--space-4` | 1rem | 16px | Page margin, section gaps | +| `--space-6` | 1.5rem | 24px | Between groups of content | +| `--space-8` | 2rem | 32px | Major section separation | +| `--space-12`| 3rem | 48px | App bar height equivalent, hero spacing | +| `--space-16`| 4rem | 64px | Maximum breathing room | + +### 3.2 Layout Constants + +These are fixed dimensions drawn from the layout architecture spec. + +| Token | Value | Purpose | +|-----------------------------|--------|--------------------------------------| +| `--tile-min-width` | 360px | CSS Grid auto-fill minmax floor | +| `--tile-height` | 300px | Fixed tile height for scanning | +| `--grid-gap` | 8px | Space between tiles | +| `--page-margin` | 16px | Inset from viewport edges | +| `--app-bar-height` | 48px | Overview mode header | +| `--status-bar-height` | 36px | Expanded mode header | +| `--tile-header-height` | 28px | Session name row in each tile | +| `--picker-width` | 400px | Command palette panel width | +| `--picker-row-height` | 36px | Desktop session row | +| `--picker-row-height-touch` | 48px | Touch-device session row (44px min) | +| `--breakpoint-list` | 640px | Below this: list view, not grid | + +### 3.3 Grid Setup + +```css +.session-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--tile-min-width), 1fr)); + gap: var(--grid-gap); + padding: var(--page-margin); +} +``` + +This one rule handles responsive column count from 320px to 2560px+ viewports. +No breakpoints needed for column count — `auto-fill` adapts naturally. + +--- + +## 4. Border Radius + +| Token | Value | Use | +|-----------------|----------|-----------------------------------------------| +| `--radius-none` | 0 | Terminal content areas (hard edges = terminal) | +| `--radius-sm` | 4px | Small buttons, input fields, badges | +| `--radius-md` | 6px | Session tiles, cards, panels | +| `--radius-lg` | 8px | Modal dialogs, command palette | +| `--radius-xl` | 12px | Large cards, image containers | +| `--radius-full` | 9999px | Pills, dots, avatar circles | + +**Guiding principle:** Terminal-adjacent elements use sharper radii (0–4px). +UI chrome uses softer radii (6–12px). The contrast between sharp terminal +content and softened UI chrome reinforces the "tool wrapping a terminal" feel. + +--- + +## 5. Shadows & Elevation + +### 5.1 Dark Mode + +Primary depth cue is **luminance stepping** (bg-base < bg-elevated < bg-surface < +bg-muted). Shadows are secondary — used mainly on floating overlays. + +| Token | Value | Use | +|----------------|-----------------------------------------------------------|------------------------------| +| `--shadow-none`| `none` | Default (no shadow needed) | +| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.30)` | Subtle lift on hover | +| `--shadow-md` | `0 2px 8px rgba(0,0,0,0.35), 0 1px 2px rgba(0,0,0,0.20)`| Cards, dropdown menus | +| `--shadow-lg` | `0 4px 16px rgba(0,0,0,0.40), 0 2px 4px rgba(0,0,0,0.25)`| Picker panel, modals | +| `--shadow-xl` | `0 8px 32px rgba(0,0,0,0.50), 0 4px 8px rgba(0,0,0,0.30)`| Expanded tile during zoom | + +### 5.2 Glow Effects + +| Token | Value | Use | +|-----------------|---------------------------------------|----------------------------| +| `--glow-cyan` | `0 0 8px rgba(0,217,245,0.35)` | Focus ring halo, active UI | +| `--glow-amber` | `0 0 8px rgba(241,166,64,0.40)` | Bell indicator pulse | + +### 5.3 Light Mode + +Shadows become the primary depth cue in light mode. Opacity values are lower +(0.04–0.14) since dark shadows on light surfaces are naturally more visible. +Token names stay the same — values switch automatically. + +--- + +## 6. Motion & Transitions + +### 6.1 Duration Tokens + +| Token | Value | Use | +|------------------------|--------|-----------------------------------| +| `--duration-instant` | 75ms | Hover color shifts | +| `--duration-fast` | 150ms | Button press, toggle, focus ring | +| `--duration-moderate` | 250ms | Tile zoom, panel slide, picker | +| `--duration-slow` | 400ms | Page-level transitions | + +### 6.2 Easing Functions + +| Token | Curve | Feel | +|-----------------|------------------------------------|--------------------------------| +| `--ease-out` | `cubic-bezier(0.16, 1, 0.3, 1)` | Decelerating — tile zoom in | +| `--ease-in-out` | `cubic-bezier(0.45, 0, 0.55, 1)` | Symmetric — tile zoom out | +| `--ease-spring` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | Overshoot — picker pop-in | +| `--ease-linear` | `linear` | Progress bars, continuous | + +### 6.3 Convenience Shorthands + +```css +/* Color transitions — use on interactive elements */ +transition: var(--transition-colors); + +/* Opacity transitions — use on fade in/out */ +transition: var(--transition-opacity); + +/* Transform transitions — use on position/scale changes */ +transition: var(--transition-transform); +``` + +### 6.4 GPU Acceleration Rule + +Only animate `transform` and `opacity`. These are composited on the GPU and +maintain 60fps. Never animate `width`, `height`, `top`, `left`, `margin`, or +`padding` — these trigger layout recalculation. + +**Example — tile zoom:** +```css +.tile--expanding { + /* Good: GPU-composited */ + transform: scale(1.5) translate(100px, 50px); + transition: transform var(--duration-moderate) var(--ease-out); +} + +/* Bad: triggers layout */ +.tile--expanding-bad { + width: 100vw; + height: 100vh; + transition: width 250ms, height 250ms; +} +``` + +### 6.5 Reduced Motion + +All duration tokens collapse to `0ms` when `prefers-reduced-motion: reduce` is +active. This means transitions become instantaneous — no motion, but the state +change still happens. No additional code is needed if you use the tokens. + +--- + +## 7. Z-Index Layers + +| Token | Value | Layer | +|------------------|-------|------------------------------------| +| `--z-base` | 0 | Default stacking context | +| `--z-tile` | 1 | Session tiles in grid | +| `--z-app-bar` | 10 | App bar / status bar | +| `--z-expanding` | 20 | Tile during zoom animation | +| `--z-backdrop` | 30 | Semi-transparent overlay backdrop | +| `--z-picker` | 40 | Command palette panel | +| `--z-toast` | 50 | Toast notifications | +| `--z-tooltip` | 60 | Tooltips (always on top) | + +**Rule:** Never use raw z-index numbers. Always reference the token. This +prevents z-index wars where developers guess higher and higher values. + +--- + +## 8. Component Patterns + +### 8.1 Session Tile Card + +The fundamental UI element. A fixed-height card showing a tmux session preview. + +``` +┌──────────────────────────────────────────┐ +│ ● session-name 2s ago │ ← Header: 28px +├──────────────────────────────────────────┤ +│ │ +│ $ npm run build │ +│ > project@1.0.0 build │ +│ > tsc && vite build │ ← Terminal content +│ ... │ JetBrains Mono 10.5px +│ ✓ built in 3.42s │ on bg-elevated +│ │ +└──────────────────────────────────────────┘ +``` + +**Structure:** + +```css +.session-tile { + background: var(--color-bg-elevated); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); /* 6px */ + height: var(--tile-height); /* 300px */ + overflow: hidden; + display: flex; + flex-direction: column; + transition: var(--transition-colors); +} + +.session-tile:hover { + background: var(--color-bg-surface); + border-color: var(--color-border-default); +} + +.session-tile:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +.tile-header { + height: var(--tile-header-height); /* 28px */ + padding: 0 var(--space-2); /* 0 8px */ + display: flex; + align-items: center; + justify-content: space-between; + background: var(--color-bg-surface); + border-bottom: 1px solid var(--color-border-subtle); +} + +.tile-name { + font-family: var(--font-ui); + font-size: var(--text-md); /* 16px */ + font-weight: var(--weight-medium); /* 500 */ + color: var(--color-text-primary); + line-height: var(--leading-snug); +} + +.tile-timestamp { + font-family: var(--font-ui); + font-size: var(--text-xs); /* 12px */ + color: var(--color-text-secondary); +} + +.tile-terminal { + flex: 1; + padding: var(--space-1) var(--space-2); /* 4px 8px */ + font-family: var(--font-mono); + font-size: var(--tile-font-size); /* 10.5px */ + line-height: var(--leading-snug); /* 1.375 */ + color: var(--color-text-primary); + overflow: hidden; + white-space: pre; + /* Fade overflow at top — show most recent lines */ + mask-image: linear-gradient(to bottom, transparent 0%, black 24px); + -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 24px); +} +``` + +### 8.2 Header / App Bar + +```css +.app-bar { + height: var(--app-bar-height); /* 48px */ + padding: 0 var(--page-margin); /* 0 16px */ + display: flex; + align-items: center; + gap: var(--space-3); /* 12px */ + background: var(--color-bg-surface); + border-bottom: 1px solid var(--color-border-subtle); + z-index: var(--z-app-bar); +} + +/* Expanded mode — thinner */ +.status-bar { + height: var(--status-bar-height); /* 36px */ + padding: 0 var(--space-3); + display: flex; + align-items: center; + gap: var(--space-2); + background: var(--color-bg-surface); + border-bottom: 1px solid var(--color-border-subtle); + z-index: var(--z-app-bar); +} + +.app-title { + font-family: var(--font-display); /* Urbanist */ + font-weight: var(--weight-bold); /* 700 */ + font-size: var(--text-lg); /* 20px */ + letter-spacing: var(--tracking-tight); + color: var(--color-text-primary); +} + +/* Wordmark coloring: mux = primary text, plex = cyan accent */ +.wordmark-mux { color: var(--color-text-primary); } +.wordmark-plex { color: var(--color-accent-cyan); } +``` + +### 8.3 Terminal Container (Expanded Mode) + +```css +.terminal-container { + flex: 1; + background: var(--color-bg-base); /* Deepest black */ + /* xterm.js mounts into this element */ + /* Sized via ResizeObserver — no fixed dimensions */ +} + +/* xterm.js configuration tokens */ +.xterm-config { + --xterm-font-family: var(--font-mono); + --xterm-font-size: var(--terminal-font-size); /* 14px */ + --xterm-bg: var(--color-bg-base); + --xterm-fg: var(--color-text-primary); + --xterm-cursor: var(--color-accent-cyan); +} +``` + +### 8.4 Bell Badge / Activity Indicator + +The bell is the only element that should interrupt the user's scanning pattern. + +```css +/* Dot indicator in tile header */ +.bell-dot { + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background: var(--color-accent-amber); + flex-shrink: 0; +} + +/* Idle state — no dot, or dim neutral */ +.bell-dot--idle { + background: var(--color-text-disabled); +} + +/* Active bell — amber with glow pulse */ +.bell-dot--active { + background: var(--color-accent-amber); + box-shadow: var(--glow-amber); + animation: bell-pulse 2s ease-in-out infinite; +} + +@keyframes bell-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.6; } +} + +@media (prefers-reduced-motion: reduce) { + .bell-dot--active { + animation: none; + /* Static amber dot — still visible, just not moving */ + } +} + +/* Border glow on the whole tile when bell is active */ +.session-tile--bell { + border-color: var(--color-accent-amber); + box-shadow: inset 0 0 0 1px var(--color-accent-amber), + var(--glow-amber); +} +``` + +### 8.5 Command Palette / Session Picker + +```css +.picker-backdrop { + position: fixed; + inset: 0; + background: var(--color-backdrop); + z-index: var(--z-backdrop); +} + +.picker-panel { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: var(--picker-width); /* 400px */ + max-height: 70vh; + background: var(--color-bg-surface); + border: 1px solid var(--color-border-default); + border-radius: var(--radius-lg); /* 8px */ + box-shadow: var(--shadow-lg); + z-index: var(--z-picker); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.picker-row { + height: var(--picker-row-height); /* 36px */ + padding: 0 var(--space-3); + display: flex; + align-items: center; + gap: var(--space-2); + cursor: pointer; + transition: var(--transition-colors); +} + +.picker-row:hover, +.picker-row--active { + background: var(--color-bg-muted); +} + +.picker-hint { + font-size: var(--text-xs); + color: var(--color-text-secondary); + letter-spacing: var(--tracking-wide); +} + +/* Mobile: bottom sheet */ +@media (max-width: 639px) { + .picker-panel { + top: auto; + bottom: 0; + left: 0; + right: 0; + transform: none; + width: 100%; + max-height: 60vh; + border-radius: var(--radius-xl) var(--radius-xl) 0 0; + } + + .picker-row { + height: var(--picker-row-height-touch); /* 48px */ + } +} +``` + +### 8.6 Status Indicator (Connection) + +```css +.status-dot { + width: 6px; + height: 6px; + border-radius: var(--radius-full); +} + +.status-dot--connected { + background: var(--color-success); +} + +.status-dot--disconnected { + background: var(--color-error); +} + +.status-dot--connecting { + background: var(--color-warning); + animation: blink 1.5s ease-in-out infinite; +} + +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + +@media (prefers-reduced-motion: reduce) { + .status-dot--connecting { + animation: none; + } +} +``` + +--- + +## 9. Dark / Light Mode + +### 9.1 How It Works + +`tokens.css` defines dark mode as the default (`:root`). Light mode activates in +two ways: + +1. **Automatic:** `@media (prefers-color-scheme: light)` overrides all tokens. +2. **Manual:** Add `data-theme="light"` to `` for user toggle. + +The `data-theme` attribute always wins. If a user sets `data-theme="dark"`, they +stay dark even if their OS is in light mode. + +### 9.2 Implementation + +```js +// Read user preference or fall back to OS +function getTheme() { + const stored = localStorage.getItem('muxplex-theme'); + if (stored) return stored; // 'light' | 'dark' + return matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark'; +} + +// Apply +function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('muxplex-theme', theme); +} + +// Toggle +function toggleTheme() { + const current = document.documentElement.getAttribute('data-theme') || getTheme(); + applyTheme(current === 'dark' ? 'light' : 'dark'); +} +``` + +### 9.3 What Changes, What Doesn't + +| Property | Changes between modes? | Notes | +|----------------|------------------------|-----------------------------------------| +| Background | Yes | Full palette swap | +| Text | Yes | Full palette swap | +| Accents | Yes | Darkened for light mode contrast | +| Borders | Yes | Full palette swap | +| State colors | Yes | Darkened for light mode contrast | +| Shadows | Yes | Lower opacity in light mode | +| Typography | No | Same fonts, sizes, weights | +| Spacing | No | Same layout constants | +| Border radius | No | Same values | +| Motion | No | Same durations and easing | +| Z-index | No | Same stacking | + +--- + +## 10. Accessibility + +### 10.1 Contrast Compliance + +All text-on-background combinations meet WCAG 2.1 AA (4.5:1 for normal text, +3:1 for large text): + +| Pairing | Dark | Light | Level | +|-----------------------------------|--------|--------|-------| +| text-primary on bg-base | 17.4:1 | 18.9:1 | AAA | +| text-primary on bg-elevated | 17.1:1 | 16.6:1 | AAA | +| text-primary on bg-surface | 15.2:1 | 15.6:1 | AAA | +| text-secondary on bg-base | 6.3:1 | 8.1:1 | AA | +| text-secondary on bg-surface | 5.5:1 | 6.6:1 | AA | +| accent-cyan on bg-base | 11.0:1 | 4.9:1 | AA+ | +| accent-amber on bg-base | 9.3:1 | 5.3:1 | AA | + +**Exception:** `text-disabled` intentionally fails contrast (2.4:1). It is used +only on non-interactive disabled elements where the low contrast communicates +"unavailable." This follows WCAG 2.1 §1.4.3 exception for disabled components. + +### 10.2 Focus Indicators + +Every interactive element gets a visible focus ring via `:focus-visible`: + +```css +:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} +``` + +The cyan focus ring achieves 11.0:1 contrast on dark backgrounds and 4.9:1 on +light — well above the 3:1 WCAG requirement for UI components. + +### 10.3 Touch Targets + +- Picker rows on mobile: 48px tall (≥44px WCAG minimum). +- Session tiles: full card is the click target — well above 44px. +- Settings gear and close buttons: minimum 44×44px touch area (can be visually + smaller with padding extending the target). + +### 10.4 Reduced Motion + +All `--duration-*` tokens become `0ms` under `prefers-reduced-motion: reduce`. +Animations declared with `@keyframes` should include an explicit +`@media (prefers-reduced-motion: reduce)` block that sets `animation: none`. + +### 10.5 Screen Reader Support + +Reference the semantic HTML structure in the layout spec. Key points: +- Grid uses `role="grid"` with `role="gridcell"` per tile. +- Each tile has `aria-label` combining session name, bell state, and timestamp. +- Expanded terminal uses `role="application"` (keyboard goes to xterm.js). +- Skip link: "Skip to sessions" at page top. + +--- + +## 11. Do / Don't Reference + +### Color + +| Do | Don't | +|----|-------| +| Use `var(--color-text-primary)` for all readable text | Use `text-disabled` for text users need to read | +| Use semantic tokens (`--color-error`) for status meaning | Use accent-amber for errors (amber = attention, not failure) | +| Use `--color-bg-surface` for hover states | Create new hex colors outside the token system | +| Let accents auto-adjust between dark/light mode | Hardcode `#00D9F5` in light mode (fails contrast) | + +### Typography + +| Do | Don't | +|----|-------| +| Use Urbanist 700 only for wordmark and H1 titles | Use Urbanist for buttons, labels, or body text | +| Use DM Sans for all UI text (buttons, labels, body) | Mix Inter and DM Sans — pick one (DM Sans primary) | +| Use JetBrains Mono only for terminal/code content | Use JetBrains Mono for UI labels or headings | +| Use `--tracking-wider` for all-caps section labels | Set letter-spacing to 0 on all-caps text (looks cramped) | + +### Spacing + +| Do | Don't | +|----|-------| +| Use `--space-*` tokens for all spacing | Use arbitrary values (15px, 7px, 23px) | +| Use `--grid-gap` (8px) between tiles | Use larger gaps — this is a dense monitoring tool | +| Use `--page-margin` (16px) for viewport inset | Let tiles touch viewport edges | +| Use `--space-2` (8px) for compact inline gaps | Use 0 gap between inline elements | + +### Borders & Radius + +| Do | Don't | +|----|-------| +| Use `--radius-md` (6px) for cards and tiles | Use large radii (16px+) — this is a developer tool | +| Use `--radius-none` (0) on terminal content areas | Round terminal corners (terminals have hard edges) | +| Use `--border-subtle` inside cards, `--border-default` on card edges | Use borders as primary separators — background contrast is primary | + +### Motion + +| Do | Don't | +|----|-------| +| Animate only `transform` and `opacity` | Animate `width`, `height`, `top`, `left`, `margin` | +| Use `--ease-out` for zoom in, `--ease-in-out` for zoom out | Use `ease` (default) or `linear` for spatial transitions | +| Keep tile zoom at 200-300ms total | Use decorative flourishes or slow-motion effects | +| Always include `prefers-reduced-motion` fallback | Forget reduced motion (tokens handle it, but check @keyframes) | + +### Component Patterns + +| Do | Don't | +|----|-------| +| Show bottom of terminal content (most recent) with top fade | Show top of content with scrollbars | +| Use amber dot for bell — visible, non-semantic | Use red for bell (implies error) or green (implies success) | +| Use command palette (keyboard-triggered overlay) for session switching | Use persistent sidebar (steals terminal space) | +| Keep tile height fixed for spatial scanning | Use variable-height tiles (breaks scan and position memory) | + +### Dark / Light Mode + +| Do | Don't | +|----|-------| +| Use CSS custom properties — values swap automatically | Hardcode hex colors in component styles | +| Test both modes when adding new UI elements | Assume dark-mode-only (light mode tokens exist for a reason) | +| Use `data-theme` attribute for user override | Use class names for theme switching (attributes are cleaner) | +| Use luminance stepping for depth in dark mode | Rely on shadows for depth in dark mode (they're invisible) | + +--- + +## File Inventory + +| File | Format | Purpose | +|------|--------|---------| +| `tokens.css` | CSS custom properties | Link into HTML — single source of truth for styles | +| `tokens.json` | Structured JSON | Machine-readable for tooling, scripts, or framework config | +| `DESIGN-SYSTEM.md` | This document | Human-readable specification and usage guide | + +All three files live in `muxplex/assets/branding/` alongside the existing brand +assets (icons, wordmark, lockup, etc.). \ No newline at end of file diff --git a/assets/branding/README.md b/assets/branding/README.md new file mode 100644 index 0000000..82d8e77 --- /dev/null +++ b/assets/branding/README.md @@ -0,0 +1,40 @@ +# muxplex Brand Assets + +## Source files (SVGs) +All brand assets are derived from these source SVGs in `svg/`: + +| File | Use | +|------|-----| +| `svg/icon/muxplex-icon-dark.svg` | App icon — dark background version | +| `svg/icon/muxplex-icon-light.svg` | App icon — light background version | +| `svg/wordmark/wordmark-on-dark.svg` | Wordmark text — for use on dark surfaces | +| `svg/wordmark/wordmark-on-light.svg` | Wordmark text — for use on light surfaces | +| `svg/lockup/lockup-on-dark.svg` | Icon + wordmark combined — dark | +| `svg/lockup/lockup-on-light.svg` | Icon + wordmark combined — light | + +## Generated assets +Run `../scripts/render-brand-assets.py` to regenerate everything from the SVG sources. + +| Directory | Contents | +|-----------|----------| +| `icons/` | App icons at 16–1024px | +| `favicons/` | favicon.ico, favicon-*.png, apple-touch-icon.png | +| `pwa/` | PWA manifest icons (192, 512px) | +| `og/` | Open Graph images 1200×630 (dark + light) | +| `wordmark/` | Wordmark PNGs at 32 and 64px height | +| `lockup/` | Lockup PNGs at 32 and 64px height | + +## Wordmark note +Font: Urbanist 700 (Google Fonts). SVGs reference Google Fonts CDN. +For offline rendering, install locally: `pip install cairosvg` and ensure network access +or embed the font in the SVG beforehand. + +## Colour palette +| Token | Hex | Use | +|-------|-----|-----| +| Dark background | `#0D1117` | Primary dark bg | +| Icon inner | `#10131C` | Icon card bg (dark) | +| White / light text | `#F0F6FF` | "mux" wordmark, icon frame | +| Cyan | `#00D9F5` | "plex" wordmark, cyan traffic light | +| Amber | `#F1A640` | Amber traffic light, accent | +| Light panes | `#E8E9EE` | Icon card bg (light) | diff --git a/assets/branding/favicons/apple-touch-icon.png b/assets/branding/favicons/apple-touch-icon.png new file mode 100644 index 0000000..3b8665f Binary files /dev/null and b/assets/branding/favicons/apple-touch-icon.png differ diff --git a/assets/branding/favicons/favicon-16.png b/assets/branding/favicons/favicon-16.png new file mode 100644 index 0000000..4dfe7b4 Binary files /dev/null and b/assets/branding/favicons/favicon-16.png differ diff --git a/assets/branding/favicons/favicon-32.png b/assets/branding/favicons/favicon-32.png new file mode 100644 index 0000000..8dd5892 Binary files /dev/null and b/assets/branding/favicons/favicon-32.png differ diff --git a/assets/branding/favicons/favicon-48.png b/assets/branding/favicons/favicon-48.png new file mode 100644 index 0000000..f456b80 Binary files /dev/null and b/assets/branding/favicons/favicon-48.png differ diff --git a/assets/branding/favicons/favicon.ico b/assets/branding/favicons/favicon.ico new file mode 100644 index 0000000..fbf7eb1 Binary files /dev/null and b/assets/branding/favicons/favicon.ico differ diff --git a/assets/branding/icons/muxplex-icon-1024.png b/assets/branding/icons/muxplex-icon-1024.png new file mode 100644 index 0000000..f4d5f14 Binary files /dev/null and b/assets/branding/icons/muxplex-icon-1024.png differ diff --git a/assets/branding/icons/muxplex-icon-128.png b/assets/branding/icons/muxplex-icon-128.png new file mode 100644 index 0000000..ed9762f Binary files /dev/null and b/assets/branding/icons/muxplex-icon-128.png differ diff --git a/assets/branding/icons/muxplex-icon-16.png b/assets/branding/icons/muxplex-icon-16.png new file mode 100644 index 0000000..4dfe7b4 Binary files /dev/null and b/assets/branding/icons/muxplex-icon-16.png differ diff --git a/assets/branding/icons/muxplex-icon-192.png b/assets/branding/icons/muxplex-icon-192.png new file mode 100644 index 0000000..ba914ac Binary files /dev/null and b/assets/branding/icons/muxplex-icon-192.png differ diff --git a/assets/branding/icons/muxplex-icon-22.png b/assets/branding/icons/muxplex-icon-22.png new file mode 100644 index 0000000..c43bd0d Binary files /dev/null and b/assets/branding/icons/muxplex-icon-22.png differ diff --git a/assets/branding/icons/muxplex-icon-24.png b/assets/branding/icons/muxplex-icon-24.png new file mode 100644 index 0000000..498dde2 Binary files /dev/null and b/assets/branding/icons/muxplex-icon-24.png differ diff --git a/assets/branding/icons/muxplex-icon-256.png b/assets/branding/icons/muxplex-icon-256.png new file mode 100644 index 0000000..28eca39 Binary files /dev/null and b/assets/branding/icons/muxplex-icon-256.png differ diff --git a/assets/branding/icons/muxplex-icon-32.png b/assets/branding/icons/muxplex-icon-32.png new file mode 100644 index 0000000..8dd5892 Binary files /dev/null and b/assets/branding/icons/muxplex-icon-32.png differ diff --git a/assets/branding/icons/muxplex-icon-48.png b/assets/branding/icons/muxplex-icon-48.png new file mode 100644 index 0000000..f456b80 Binary files /dev/null and b/assets/branding/icons/muxplex-icon-48.png differ diff --git a/assets/branding/icons/muxplex-icon-512.png b/assets/branding/icons/muxplex-icon-512.png new file mode 100644 index 0000000..9707b0f Binary files /dev/null and b/assets/branding/icons/muxplex-icon-512.png differ diff --git a/assets/branding/icons/muxplex-icon-64.png b/assets/branding/icons/muxplex-icon-64.png new file mode 100644 index 0000000..d7da479 Binary files /dev/null and b/assets/branding/icons/muxplex-icon-64.png differ diff --git a/assets/branding/lockup/lockup-on-dark-32.png b/assets/branding/lockup/lockup-on-dark-32.png new file mode 100644 index 0000000..44207fc Binary files /dev/null and b/assets/branding/lockup/lockup-on-dark-32.png differ diff --git a/assets/branding/lockup/lockup-on-dark-64.png b/assets/branding/lockup/lockup-on-dark-64.png new file mode 100644 index 0000000..d4075e4 Binary files /dev/null and b/assets/branding/lockup/lockup-on-dark-64.png differ diff --git a/assets/branding/lockup/lockup-on-light-32.png b/assets/branding/lockup/lockup-on-light-32.png new file mode 100644 index 0000000..7546c45 Binary files /dev/null and b/assets/branding/lockup/lockup-on-light-32.png differ diff --git a/assets/branding/lockup/lockup-on-light-64.png b/assets/branding/lockup/lockup-on-light-64.png new file mode 100644 index 0000000..a7c1a90 Binary files /dev/null and b/assets/branding/lockup/lockup-on-light-64.png differ diff --git a/assets/branding/og/og-dark.png b/assets/branding/og/og-dark.png new file mode 100644 index 0000000..9cb94ca Binary files /dev/null and b/assets/branding/og/og-dark.png differ diff --git a/assets/branding/og/og-light.png b/assets/branding/og/og-light.png new file mode 100644 index 0000000..e6778f9 Binary files /dev/null and b/assets/branding/og/og-light.png differ diff --git a/assets/branding/pwa/pwa-192.png b/assets/branding/pwa/pwa-192.png new file mode 100644 index 0000000..ba914ac Binary files /dev/null and b/assets/branding/pwa/pwa-192.png differ diff --git a/assets/branding/pwa/pwa-512.png b/assets/branding/pwa/pwa-512.png new file mode 100644 index 0000000..9707b0f Binary files /dev/null and b/assets/branding/pwa/pwa-512.png differ diff --git a/assets/branding/svg/icon/muxplex-icon-dark.svg b/assets/branding/svg/icon/muxplex-icon-dark.svg new file mode 100755 index 0000000..a835d31 --- /dev/null +++ b/assets/branding/svg/icon/muxplex-icon-dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/branding/svg/icon/muxplex-icon-light.svg b/assets/branding/svg/icon/muxplex-icon-light.svg new file mode 100755 index 0000000..6aa2020 --- /dev/null +++ b/assets/branding/svg/icon/muxplex-icon-light.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/branding/svg/lockup/lockup-on-dark.svg b/assets/branding/svg/lockup/lockup-on-dark.svg new file mode 100755 index 0000000..0c4d16d --- /dev/null +++ b/assets/branding/svg/lockup/lockup-on-dark.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/branding/svg/lockup/lockup-on-light.svg b/assets/branding/svg/lockup/lockup-on-light.svg new file mode 100755 index 0000000..254d3c8 --- /dev/null +++ b/assets/branding/svg/lockup/lockup-on-light.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/branding/svg/wordmark/wordmark-on-dark.svg b/assets/branding/svg/wordmark/wordmark-on-dark.svg new file mode 100755 index 0000000..5394fc3 --- /dev/null +++ b/assets/branding/svg/wordmark/wordmark-on-dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/branding/svg/wordmark/wordmark-on-light.svg b/assets/branding/svg/wordmark/wordmark-on-light.svg new file mode 100755 index 0000000..0b461d5 --- /dev/null +++ b/assets/branding/svg/wordmark/wordmark-on-light.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/branding/tokens.css b/assets/branding/tokens.css new file mode 100644 index 0000000..7b36e81 --- /dev/null +++ b/assets/branding/tokens.css @@ -0,0 +1,355 @@ +/* ================================================================ + muxplex — Design Tokens + ================================================================ + Single source of truth for all visual properties. + Dark mode is the default. Light mode via prefers-color-scheme. + Manual override: set `data-theme="light"` on . + + Usage: + + + + Last updated: 2026-03-27 + ================================================================ */ + + +/* ---------------------------------------------------------------- + 1. COLOR — Dark mode (default) + ---------------------------------------------------------------- */ + +:root { + /* Backgrounds — luminance stepping (lighter = more elevated) */ + --color-bg-base: #0D1117; /* Page background, deepest layer */ + --color-bg-elevated: #10131C; /* Cards, panels, icon bg */ + --color-bg-surface: #1A1F2B; /* Raised surfaces, hover states */ + --color-bg-muted: #222433; /* Subtle fills, secondary panels */ + + /* Text — three-tier hierarchy */ + --color-text-primary: #F0F6FF; /* Primary text, headings, wordmark 17.4:1 on bg-base */ + --color-text-secondary: #8E95A3; /* Secondary labels, timestamps 6.3:1 on bg-base */ + --color-text-disabled: #4A5060; /* Disabled controls, placeholder 2.4:1 — decorative */ + + /* Accents */ + --color-accent-cyan: #00D9F5; /* Primary accent, links, focus rings 11.0:1 on bg-base */ + --color-accent-amber: #F1A640; /* Notifications, activity indicators 9.3:1 on bg-base */ + + /* Borders — decorative, not relied upon for meaning */ + --color-border-subtle: #1E2430; /* Dividers within a surface */ + --color-border-default: #2A3040; /* Card outlines, input borders */ + --color-border-strong: #3A4050; /* Emphasized borders, active outlines */ + + /* State / semantic — GitHub Primer–adjacent, tested on bg-base */ + --color-success: #3FB950; /* 7.5:1 on bg-base */ + --color-error: #F85149; /* 5.7:1 on bg-base */ + --color-warning: #F1A640; /* 9.3:1 (= amber) */ + --color-info: #58A6FF; /* 7.5:1 on bg-base */ + + /* State backgrounds — low-opacity tints for banners, badges */ + --color-success-bg: rgba(63, 185, 80, 0.12); + --color-error-bg: rgba(248, 81, 73, 0.12); + --color-warning-bg: rgba(241, 166, 64, 0.12); + --color-info-bg: rgba(88, 166, 255, 0.12); + + /* Overlay */ + --color-backdrop: rgba(0, 0, 0, 0.60); + --color-scrim: rgba(13, 17, 23, 0.80); + + /* Focus ring */ + --color-focus-ring: #00D9F5; +} + + +/* ---------------------------------------------------------------- + 2. COLOR — Light mode + ---------------------------------------------------------------- + Activated by OS preference or data-theme="light" on . + Accent-accessible variants darken cyan/amber to pass 4.5:1 on + white. Use these for TEXT set in accent colors; the bright values + remain available for decorative/non-text use. + ---------------------------------------------------------------- */ + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --color-bg-base: #FFFFFF; + --color-bg-elevated: #F0F0F0; + --color-bg-surface: #E8E9EE; + --color-bg-muted: #DADCE0; + + --color-text-primary: #0D1117; /* 18.9:1 on bg-base */ + --color-text-secondary: #4A5060; /* 8.1:1 on bg-base */ + --color-text-disabled: #8E95A3; /* 2.6:1 — decorative */ + + /* Bright values kept for icons, decorative indicators */ + --color-accent-cyan: #007D8C; /* 4.9:1 on #FFF — AA text */ + --color-accent-amber: #946000; /* 5.3:1 on #FFF — AA text */ + + --color-border-subtle: #E0E2E8; + --color-border-default: #D0D2D8; + --color-border-strong: #B0B4BC; + + --color-success: #1A7F37; + --color-error: #CF222E; + --color-warning: #946000; + --color-info: #0969DA; + + --color-success-bg: rgba(26, 127, 55, 0.10); + --color-error-bg: rgba(207, 34, 46, 0.10); + --color-warning-bg: rgba(148, 96, 0, 0.10); + --color-info-bg: rgba(9, 105, 218, 0.10); + + --color-backdrop: rgba(0, 0, 0, 0.30); + --color-scrim: rgba(255, 255, 255, 0.80); + + --color-focus-ring: #007D8C; + } +} + +/* Manual override */ +[data-theme="light"] { + --color-bg-base: #FFFFFF; + --color-bg-elevated: #F0F0F0; + --color-bg-surface: #E8E9EE; + --color-bg-muted: #DADCE0; + + --color-text-primary: #0D1117; + --color-text-secondary: #4A5060; + --color-text-disabled: #8E95A3; + + --color-accent-cyan: #007D8C; + --color-accent-amber: #946000; + + --color-border-subtle: #E0E2E8; + --color-border-default: #D0D2D8; + --color-border-strong: #B0B4BC; + + --color-success: #1A7F37; + --color-error: #CF222E; + --color-warning: #946000; + --color-info: #0969DA; + + --color-success-bg: rgba(26, 127, 55, 0.10); + --color-error-bg: rgba(207, 34, 46, 0.10); + --color-warning-bg: rgba(148, 96, 0, 0.10); + --color-info-bg: rgba(9, 105, 218, 0.10); + + --color-backdrop: rgba(0, 0, 0, 0.30); + --color-scrim: rgba(255, 255, 255, 0.80); + + --color-focus-ring: #007D8C; +} + + +/* ---------------------------------------------------------------- + 3. TYPOGRAPHY + ---------------------------------------------------------------- */ + +:root { + /* Font stacks */ + --font-display: 'Urbanist', system-ui, -apple-system, sans-serif; + --font-ui: 'DM Sans', 'Inter', system-ui, -apple-system, sans-serif; + --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Menlo', monospace; + + /* Font weights */ + --weight-regular: 400; + --weight-medium: 500; + --weight-semibold: 600; + --weight-bold: 700; + + /* Type scale — DM Sans base (UI text) */ + --text-xs: 0.75rem; /* 12px */ + --text-sm: 0.8125rem; /* 13px */ + --text-base: 0.875rem; /* 14px — default UI text */ + --text-md: 1rem; /* 16px */ + --text-lg: 1.25rem; /* 20px */ + --text-xl: 1.5rem; /* 24px */ + --text-2xl: 1.875rem; /* 30px — Urbanist headings start here */ + --text-3xl: 2.25rem; /* 36px */ + + /* Line heights */ + --leading-none: 1; + --leading-tight: 1.2; + --leading-snug: 1.375; + --leading-base: 1.5; + --leading-relaxed: 1.625; + + /* Letter spacing */ + --tracking-tight: -0.01em; + --tracking-normal: 0; + --tracking-wide: 0.02em; + --tracking-wider: 0.04em; /* All-caps labels */ + + /* Terminal-specific */ + --tile-font-size: 10.5px; /* Monospace in overview tiles */ + --terminal-font-size: 14px; /* Monospace in expanded xterm.js */ +} + + +/* ---------------------------------------------------------------- + 4. SPACING + ---------------------------------------------------------------- + 4px base unit. Use semantic names for layout constants, + numeric names for general-purpose spacing. + ---------------------------------------------------------------- */ + +:root { + /* Scale */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ + + /* Layout constants (from layout spec) */ + --tile-min-width: 360px; + --tile-height: 300px; + --grid-gap: 8px; + --page-margin: 16px; + --app-bar-height: 48px; + --status-bar-height: 36px; + --tile-header-height: 28px; + --picker-width: 400px; + --picker-row-height: 36px; + --picker-row-height-touch: 48px; + --breakpoint-list: 640px; +} + + +/* ---------------------------------------------------------------- + 5. BORDER RADIUS + ---------------------------------------------------------------- */ + +:root { + --radius-none: 0; + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-full: 9999px; +} + + +/* ---------------------------------------------------------------- + 6. SHADOWS / ELEVATION + ---------------------------------------------------------------- + Dark mode: luminance stepping is the primary depth cue. + Shadows serve as supplementary reinforcement on overlays. + ---------------------------------------------------------------- */ + +:root { + --shadow-none: none; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.30); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.35), + 0 1px 2px rgba(0, 0, 0, 0.20); + --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.40), + 0 2px 4px rgba(0, 0, 0, 0.25); + --shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.50), + 0 4px 8px rgba(0, 0, 0, 0.30); + + /* Glow — for bell indicator, focus ring */ + --glow-cyan: 0 0 8px rgba(0, 217, 245, 0.35); + --glow-amber: 0 0 8px rgba(241, 166, 64, 0.40); +} + +@media (prefers-color-scheme: light) { + :root:not([data-theme="dark"]) { + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08), + 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.10), + 0 2px 4px rgba(0, 0, 0, 0.06); + --shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.14), + 0 4px 8px rgba(0, 0, 0, 0.08); + + --glow-cyan: 0 0 8px rgba(0, 125, 140, 0.25); + --glow-amber: 0 0 8px rgba(148, 96, 0, 0.25); + } +} + +[data-theme="light"] { + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.06); + --shadow-md: 0 2px 8px rgba(0, 0, 0, 0.08), + 0 1px 2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.10), + 0 2px 4px rgba(0, 0, 0, 0.06); + --shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.14), + 0 4px 8px rgba(0, 0, 0, 0.08); + + --glow-cyan: 0 0 8px rgba(0, 125, 140, 0.25); + --glow-amber: 0 0 8px rgba(148, 96, 0, 0.25); +} + + +/* ---------------------------------------------------------------- + 7. MOTION / TRANSITIONS + ---------------------------------------------------------------- + GPU-accelerated properties only (transform, opacity). + Respect prefers-reduced-motion. + ---------------------------------------------------------------- */ + +:root { + /* Durations */ + --duration-instant: 75ms; /* Hover color shifts */ + --duration-fast: 150ms; /* Button press, toggle, focus */ + --duration-moderate: 250ms; /* Tile zoom, panel slide */ + --duration-slow: 400ms; /* Page transitions, complex */ + + /* Easing */ + --ease-out: cubic-bezier(0.16, 1, 0.3, 1); /* Decelerate — zoom in */ + --ease-in-out: cubic-bezier(0.45, 0, 0.55, 1); /* Symmetric — zoom out */ + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); /* Overshoot — picker appear */ + --ease-linear: linear; + + /* Convenience shorthands */ + --transition-colors: color var(--duration-instant) var(--ease-out), + background-color var(--duration-instant) var(--ease-out), + border-color var(--duration-instant) var(--ease-out); + --transition-opacity: opacity var(--duration-fast) var(--ease-out); + --transition-transform: transform var(--duration-moderate) var(--ease-out); +} + +/* Reduced motion — collapse all durations to near-zero */ +@media (prefers-reduced-motion: reduce) { + :root { + --duration-instant: 0ms; + --duration-fast: 0ms; + --duration-moderate: 0ms; + --duration-slow: 0ms; + } +} + + +/* ---------------------------------------------------------------- + 8. Z-INDEX LAYERS + ---------------------------------------------------------------- */ + +:root { + --z-base: 0; + --z-tile: 1; + --z-app-bar: 10; + --z-expanding: 20; /* Tile during zoom transition */ + --z-backdrop: 30; + --z-picker: 40; /* Command palette overlay */ + --z-toast: 50; + --z-tooltip: 60; +} + + +/* ---------------------------------------------------------------- + 9. FOCUS STYLES + ---------------------------------------------------------------- + Global focus-visible ring. 2px offset ensures it doesn't + overlap content. Uses accent cyan for maximum visibility. + ---------------------------------------------------------------- */ + +:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +/* Remove default outlines — :focus-visible handles it */ +:focus:not(:focus-visible) { + outline: none; +} \ No newline at end of file diff --git a/assets/branding/tokens.json b/assets/branding/tokens.json new file mode 100644 index 0000000..b39f3d8 --- /dev/null +++ b/assets/branding/tokens.json @@ -0,0 +1,214 @@ +{ + "$schema": "https://tr.designtokens.org/format/", + "$metadata": { + "name": "muxplex", + "description": "Design tokens for muxplex — web-based tmux session dashboard", + "version": "1.0.0", + "generated": "2026-03-27" + }, + + "color": { + "dark": { + "bg": { + "base": { "$value": "#0D1117", "$description": "Page background, deepest layer" }, + "elevated": { "$value": "#10131C", "$description": "Cards, panels, icon bg" }, + "surface": { "$value": "#1A1F2B", "$description": "Raised surfaces, hover states" }, + "muted": { "$value": "#222433", "$description": "Subtle fills, secondary panels" } + }, + "text": { + "primary": { "$value": "#F0F6FF", "$description": "Primary text, headings — 17.4:1 on bg-base" }, + "secondary": { "$value": "#8E95A3", "$description": "Secondary labels, timestamps — 6.3:1 on bg-base" }, + "disabled": { "$value": "#4A5060", "$description": "Disabled controls — decorative only" } + }, + "accent": { + "cyan": { "$value": "#00D9F5", "$description": "Primary accent, links, focus — 11.0:1 on bg-base" }, + "amber": { "$value": "#F1A640", "$description": "Notifications, activity — 9.3:1 on bg-base" } + }, + "border": { + "subtle": { "$value": "#1E2430", "$description": "Dividers within a surface" }, + "default": { "$value": "#2A3040", "$description": "Card outlines, input borders" }, + "strong": { "$value": "#3A4050", "$description": "Emphasized borders, active outlines" } + }, + "state": { + "success": { "$value": "#3FB950", "$description": "7.5:1 on bg-base" }, + "error": { "$value": "#F85149", "$description": "5.7:1 on bg-base" }, + "warning": { "$value": "#F1A640", "$description": "9.3:1 on bg-base" }, + "info": { "$value": "#58A6FF", "$description": "7.5:1 on bg-base" }, + "success-bg": { "$value": "rgba(63, 185, 80, 0.12)" }, + "error-bg": { "$value": "rgba(248, 81, 73, 0.12)" }, + "warning-bg": { "$value": "rgba(241, 166, 64, 0.12)" }, + "info-bg": { "$value": "rgba(88, 166, 255, 0.12)" } + }, + "overlay": { + "backdrop": { "$value": "rgba(0, 0, 0, 0.60)" }, + "scrim": { "$value": "rgba(13, 17, 23, 0.80)" } + }, + "focus": { + "ring": { "$value": "#00D9F5" } + } + }, + + "light": { + "bg": { + "base": { "$value": "#FFFFFF" }, + "elevated": { "$value": "#F0F0F0" }, + "surface": { "$value": "#E8E9EE" }, + "muted": { "$value": "#DADCE0" } + }, + "text": { + "primary": { "$value": "#0D1117", "$description": "18.9:1 on bg-base" }, + "secondary": { "$value": "#4A5060", "$description": "8.1:1 on bg-base" }, + "disabled": { "$value": "#8E95A3", "$description": "Decorative only" } + }, + "accent": { + "cyan": { "$value": "#007D8C", "$description": "Darkened for 4.9:1 AA on white" }, + "amber": { "$value": "#946000", "$description": "Darkened for 5.3:1 AA on white" } + }, + "border": { + "subtle": { "$value": "#E0E2E8" }, + "default": { "$value": "#D0D2D8" }, + "strong": { "$value": "#B0B4BC" } + }, + "state": { + "success": { "$value": "#1A7F37" }, + "error": { "$value": "#CF222E" }, + "warning": { "$value": "#946000" }, + "info": { "$value": "#0969DA" }, + "success-bg": { "$value": "rgba(26, 127, 55, 0.10)" }, + "error-bg": { "$value": "rgba(207, 34, 46, 0.10)" }, + "warning-bg": { "$value": "rgba(148, 96, 0, 0.10)" }, + "info-bg": { "$value": "rgba(9, 105, 218, 0.10)" } + }, + "overlay": { + "backdrop": { "$value": "rgba(0, 0, 0, 0.30)" }, + "scrim": { "$value": "rgba(255, 255, 255, 0.80)" } + }, + "focus": { + "ring": { "$value": "#007D8C" } + } + } + }, + + "typography": { + "fontFamily": { + "display": { "$value": "'Urbanist', system-ui, -apple-system, sans-serif", "$description": "Wordmark, H1 headings" }, + "ui": { "$value": "'DM Sans', 'Inter', system-ui, -apple-system, sans-serif", "$description": "Body, labels, buttons" }, + "mono": { "$value": "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Menlo', monospace", "$description": "Terminal content, code" } + }, + "fontWeight": { + "regular": { "$value": 400 }, + "medium": { "$value": 500 }, + "semibold": { "$value": 600 }, + "bold": { "$value": 700 } + }, + "fontSize": { + "xs": { "$value": "0.75rem", "$description": "12px" }, + "sm": { "$value": "0.8125rem", "$description": "13px" }, + "base": { "$value": "0.875rem", "$description": "14px — default UI" }, + "md": { "$value": "1rem", "$description": "16px" }, + "lg": { "$value": "1.25rem", "$description": "20px" }, + "xl": { "$value": "1.5rem", "$description": "24px" }, + "2xl": { "$value": "1.875rem", "$description": "30px" }, + "3xl": { "$value": "2.25rem", "$description": "36px" } + }, + "lineHeight": { + "none": { "$value": 1 }, + "tight": { "$value": 1.2 }, + "snug": { "$value": 1.375 }, + "base": { "$value": 1.5 }, + "relaxed": { "$value": 1.625 } + }, + "letterSpacing": { + "tight": { "$value": "-0.01em" }, + "normal": { "$value": "0" }, + "wide": { "$value": "0.02em" }, + "wider": { "$value": "0.04em", "$description": "All-caps labels" } + }, + "terminal": { + "tileFontSize": { "$value": "10.5px", "$description": "Monospace in overview tiles" }, + "terminalFontSize": { "$value": "14px", "$description": "Monospace in expanded xterm.js" } + } + }, + + "spacing": { + "scale": { + "1": { "$value": "0.25rem", "$description": "4px" }, + "2": { "$value": "0.5rem", "$description": "8px" }, + "3": { "$value": "0.75rem", "$description": "12px" }, + "4": { "$value": "1rem", "$description": "16px" }, + "6": { "$value": "1.5rem", "$description": "24px" }, + "8": { "$value": "2rem", "$description": "32px" }, + "12": { "$value": "3rem", "$description": "48px" }, + "16": { "$value": "4rem", "$description": "64px" } + }, + "layout": { + "tileMinWidth": { "$value": "360px" }, + "tileHeight": { "$value": "300px" }, + "gridGap": { "$value": "8px" }, + "pageMargin": { "$value": "16px" }, + "appBarHeight": { "$value": "48px" }, + "statusBarHeight": { "$value": "36px" }, + "tileHeaderHeight": { "$value": "28px" }, + "pickerWidth": { "$value": "400px" }, + "pickerRowHeight": { "$value": "36px" }, + "pickerRowHeightTouch": { "$value": "48px" }, + "breakpointList": { "$value": "640px" } + } + }, + + "borderRadius": { + "none": { "$value": "0" }, + "sm": { "$value": "4px" }, + "md": { "$value": "6px" }, + "lg": { "$value": "8px" }, + "xl": { "$value": "12px" }, + "full": { "$value": "9999px" } + }, + + "shadow": { + "dark": { + "none": { "$value": "none" }, + "sm": { "$value": "0 1px 2px rgba(0, 0, 0, 0.30)" }, + "md": { "$value": "0 2px 8px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.20)" }, + "lg": { "$value": "0 4px 16px rgba(0, 0, 0, 0.40), 0 2px 4px rgba(0, 0, 0, 0.25)" }, + "xl": { "$value": "0 8px 32px rgba(0, 0, 0, 0.50), 0 4px 8px rgba(0, 0, 0, 0.30)" }, + "glowCyan": { "$value": "0 0 8px rgba(0, 217, 245, 0.35)" }, + "glowAmber": { "$value": "0 0 8px rgba(241, 166, 64, 0.40)" } + }, + "light": { + "none": { "$value": "none" }, + "sm": { "$value": "0 1px 2px rgba(0, 0, 0, 0.06)" }, + "md": { "$value": "0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04)" }, + "lg": { "$value": "0 4px 16px rgba(0, 0, 0, 0.10), 0 2px 4px rgba(0, 0, 0, 0.06)" }, + "xl": { "$value": "0 8px 32px rgba(0, 0, 0, 0.14), 0 4px 8px rgba(0, 0, 0, 0.08)" }, + "glowCyan": { "$value": "0 0 8px rgba(0, 125, 140, 0.25)" }, + "glowAmber": { "$value": "0 0 8px rgba(148, 96, 0, 0.25)" } + } + }, + + "motion": { + "duration": { + "instant": { "$value": "75ms", "$description": "Hover color shifts" }, + "fast": { "$value": "150ms", "$description": "Button press, toggle, focus" }, + "moderate": { "$value": "250ms", "$description": "Tile zoom, panel slide" }, + "slow": { "$value": "400ms", "$description": "Page transitions" } + }, + "easing": { + "out": { "$value": "cubic-bezier(0.16, 1, 0.3, 1)", "$description": "Decelerate — zoom in" }, + "inOut": { "$value": "cubic-bezier(0.45, 0, 0.55, 1)", "$description": "Symmetric — zoom out" }, + "spring": { "$value": "cubic-bezier(0.34, 1.56, 0.64, 1)", "$description": "Overshoot — picker appear" }, + "linear": { "$value": "linear" } + } + }, + + "zIndex": { + "base": { "$value": 0 }, + "tile": { "$value": 1 }, + "appBar": { "$value": 10 }, + "expanding": { "$value": 20, "$description": "Tile during zoom transition" }, + "backdrop": { "$value": 30 }, + "picker": { "$value": 40, "$description": "Command palette overlay" }, + "toast": { "$value": 50 }, + "tooltip": { "$value": 60 } + } +} \ No newline at end of file diff --git a/assets/branding/wordmark/wordmark-on-dark-32.png b/assets/branding/wordmark/wordmark-on-dark-32.png new file mode 100644 index 0000000..3174597 Binary files /dev/null and b/assets/branding/wordmark/wordmark-on-dark-32.png differ diff --git a/assets/branding/wordmark/wordmark-on-dark-64.png b/assets/branding/wordmark/wordmark-on-dark-64.png new file mode 100644 index 0000000..b110c3e Binary files /dev/null and b/assets/branding/wordmark/wordmark-on-dark-64.png differ diff --git a/assets/branding/wordmark/wordmark-on-light-32.png b/assets/branding/wordmark/wordmark-on-light-32.png new file mode 100644 index 0000000..390b501 Binary files /dev/null and b/assets/branding/wordmark/wordmark-on-light-32.png differ diff --git a/assets/branding/wordmark/wordmark-on-light-64.png b/assets/branding/wordmark/wordmark-on-light-64.png new file mode 100644 index 0000000..1ea47a8 Binary files /dev/null and b/assets/branding/wordmark/wordmark-on-light-64.png differ diff --git a/coordinator/__init__.py b/coordinator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coordinator/bells.py b/coordinator/bells.py new file mode 100644 index 0000000..723ede4 --- /dev/null +++ b/coordinator/bells.py @@ -0,0 +1,177 @@ +""" +Bell flag polling and unseen_count tracking for the tmux-web coordinator. + +Based on spike findings: reading the tmux window_bell_flag does NOT clear it. +The flag persists until the window is made active inside tmux. + +In-memory state: + _bell_seen — tracks whether the bell flag was '1' on the last poll, + keyed by session_name. Used to detect 0→1 transitions. + +Public API: + poll_bell_flag(session_name) → bool + process_bell_flags(session_names, state) → bool + should_clear_bell(session_name, state) → bool + apply_bell_clear_rule(state) → list[str] +""" + +import time + +from coordinator.sessions import run_tmux +from coordinator.state import empty_bell + +# --------------------------------------------------------------------------- +# In-memory tracking: session_name → bool (was flag set on last poll?) +# --------------------------------------------------------------------------- + +_bell_seen: dict[str, bool] = {} + + +# --------------------------------------------------------------------------- +# poll_bell_flag +# --------------------------------------------------------------------------- + + +async def poll_bell_flag(session_name: str) -> bool: + """Poll the tmux window_bell_flag for session_name. + + Calls: tmux display-message -t -p #{window_bell_flag} + + Returns True if the output is '1', False otherwise (including on errors). + Note: reading does NOT clear the tmux bell flag. + """ + try: + output = await run_tmux( + "display-message", "-t", session_name, "-p", "#{window_bell_flag}" + ) + return output.strip() == "1" + except RuntimeError: + return False + + +# --------------------------------------------------------------------------- +# process_bell_flags +# --------------------------------------------------------------------------- + + +async def process_bell_flags(session_names: list[str], state: dict) -> bool: + """Poll bell flags for all sessions and update state accordingly. + + NOTE: The tmux alert-bell hook (POST /api/sessions/{name}/bell) is the + primary bell detection mechanism. window_bell_flag is only set when NO + tmux client is watching the window — with an SSH/WezTerm session attached, + the flag is never set even though the bell fires. This function serves as + a fallback for sessions that fired before the coordinator registered the hook. + + Detects 0→1 transitions using _bell_seen and increments unseen_count. + Persistent '1' flags (1→1) are not double-counted. + When flag clears (1→0), _bell_seen is reset so the next '1' counts as + a new, separate bell event. + + Ensures the bell sub-dict exists for each session in state. + + Args: + session_names: List of session names to poll. + state: Mutable state dict (modified in-place). + + Returns: + True if any bell state changed (new bell detected), False otherwise. + """ + changed = False + + for name in session_names: + # Ensure session entry and bell sub-dict exist + if name not in state["sessions"]: + state["sessions"][name] = {} + if "bell" not in state["sessions"][name]: + state["sessions"][name]["bell"] = empty_bell() + + bell = state["sessions"][name]["bell"] + flag_set = await poll_bell_flag(name) + previously_seen = _bell_seen.get(name, False) + + if flag_set and not previously_seen: + # 0→1 transition: new bell event + bell["unseen_count"] += 1 + bell["last_fired_at"] = time.time() + _bell_seen[name] = True + changed = True + elif not flag_set and previously_seen: + # 1→0: flag cleared — reset tracking so next '1' is a new bell + # Do NOT decrement unseen_count + _bell_seen[name] = False + + return changed + + +# --------------------------------------------------------------------------- +# Bell clear rule constants +# --------------------------------------------------------------------------- + +_INTERACTION_WINDOW_SECONDS: float = 60.0 + + +# --------------------------------------------------------------------------- +# should_clear_bell +# --------------------------------------------------------------------------- + + +def should_clear_bell(session_name: str, state: dict) -> bool: + """Return True if any connected device qualifies to globally acknowledge bells. + + A session's bells should be cleared when ANY device satisfies ALL of: + - viewing_session == session_name + - view_mode == 'fullscreen' + - last_interaction_at > now - _INTERACTION_WINDOW_SECONDS + + Args: + session_name: Name of the tmux session to check. + state: Current application state dict. + + Returns: + True if at least one device meets all conditions, False otherwise. + """ + cutoff = time.time() - _INTERACTION_WINDOW_SECONDS + for device in state["devices"].values(): + if ( + device["viewing_session"] == session_name + and device["view_mode"] == "fullscreen" + and device["last_interaction_at"] > cutoff + ): + return True + return False + + +# --------------------------------------------------------------------------- +# apply_bell_clear_rule +# --------------------------------------------------------------------------- + + +def apply_bell_clear_rule(state: dict) -> list[str]: + """Check every session with unseen_count > 0 against the active-device gate. + + For each qualifying session (unseen_count > 0 AND should_clear_bell): + - Resets unseen_count to 0 + - Sets seen_at to now + - Resets _bell_seen[name] = False + + Args: + state: Mutable application state dict (modified in-place). + + Returns: + List of session names whose bells were cleared. + """ + cleared: list[str] = [] + now = time.time() + + for name, session in state["sessions"].items(): + bell = session.get("bell") + if bell is None or bell.get("unseen_count", 0) == 0: + continue + if should_clear_bell(name, state): + bell["unseen_count"] = 0 + bell["seen_at"] = now + _bell_seen[name] = False + cleared.append(name) + + return cleared diff --git a/coordinator/main.py b/coordinator/main.py new file mode 100644 index 0000000..bd9825a --- /dev/null +++ b/coordinator/main.py @@ -0,0 +1,345 @@ +""" +FastAPI coordinator application for tmux-web. + +Entry point for the coordinator service. Exposes: + GET /health → {"status": "ok"} + +Background poll loop reconciles tmux session state every POLL_INTERVAL seconds. +""" + +import asyncio +import contextlib +import logging +import os +import pathlib +import time +from typing import Literal + +from fastapi import FastAPI, HTTPException +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from coordinator.bells import apply_bell_clear_rule, process_bell_flags +from coordinator.sessions import ( + enumerate_sessions, + get_session_list, + get_snapshots, + run_tmux, + snapshot_all, + update_session_cache, +) +from coordinator.state import ( + empty_bell, + load_state, + prune_devices, + read_state, + register_device, + save_state, + state_lock, +) +from coordinator.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +POLL_INTERVAL: float = float(os.environ.get("POLL_INTERVAL", "2.0")) + +_log = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Module-level task reference +# --------------------------------------------------------------------------- + +_poll_task: asyncio.Task | None = None + + +# --------------------------------------------------------------------------- +# Poll cycle +# --------------------------------------------------------------------------- + + +async def _run_poll_cycle() -> None: + """Perform one full poll cycle, all operations executed under state_lock.""" + async with state_lock: + # 1. Enumerate live tmux sessions + names = await enumerate_sessions() + name_set = set(names) + + # 2. Capture pane snapshots and update in-memory snapshot cache + new_snapshots = await snapshot_all(names) + update_session_cache(names, new_snapshots) + + # 3. Load current persisted state + state = load_state() + + # 4. Reconcile session_order: preserve user ordering, add new, remove deleted + state["session_order"] = [s for s in state["session_order"] if s in name_set] + existing_order_set = set(state["session_order"]) + for name in names: + if name not in existing_order_set: + state["session_order"].append(name) + + # 5. Ensure bell entries exist for every current session + for name in names: + if name not in state["sessions"]: + state["sessions"][name] = {} + if "bell" not in state["sessions"][name]: + state["sessions"][name]["bell"] = empty_bell() + + # 6. Remove state entries for sessions that no longer exist + deleted = [s for s in list(state["sessions"]) if s not in name_set] + for name in deleted: + del state["sessions"][name] + + # 7. Clear active_session if the session is gone + if state["active_session"] not in name_set: + state["active_session"] = None + + # 8. Process bell flags (detect 0→1 transitions, update unseen_count) + await process_bell_flags(names, state) + + # 9. Apply bell clear rule (acknowledge bells when device is watching fullscreen) + apply_bell_clear_rule(state) + + # 10. Prune devices that haven't sent a heartbeat recently + prune_devices(state) + + # 11. Atomically persist the updated state + save_state(state) + + +# --------------------------------------------------------------------------- +# Poll loop +# --------------------------------------------------------------------------- + + +async def _poll_loop() -> None: + """Run _run_poll_cycle() every POLL_INTERVAL seconds, catching all exceptions.""" + while True: + try: + await _run_poll_cycle() + except Exception: + _log.exception("poll cycle error") + await asyncio.sleep(POLL_INTERVAL) + + +# --------------------------------------------------------------------------- +# Lifespan +# --------------------------------------------------------------------------- + + +@contextlib.asynccontextmanager +async def lifespan(app: FastAPI): + global _poll_task + + # Startup: kill any orphaned ttyd from a previous coordinator run, then + # start the background poll loop. + await kill_orphan_ttyd() + _poll_task = asyncio.create_task(_poll_loop()) + + # Register tmux alert-bell hook so bells are detected even when clients are attached. + # window_bell_flag is only set when no client watches the window; the hook fires always. + try: + await run_tmux( + "set-hook", + "-g", + "alert-bell", + "run-shell 'curl -sfo /dev/null -X POST http://localhost:8099/api/sessions/#{session_name}/bell || true'", + ) + except Exception: + pass # tmux not running at startup is OK; hook will be set on first poll + + yield + + # Shutdown: cancel the poll loop task and wait for it to finish. + if _poll_task is not None: + _poll_task.cancel() + try: + await _poll_task + except (asyncio.CancelledError, Exception): + pass + + +# --------------------------------------------------------------------------- +# App +# --------------------------------------------------------------------------- + +app = FastAPI(title="tmux-web coordinator", version="0.1.0", lifespan=lifespan) + + +# --------------------------------------------------------------------------- +# Request / response models +# --------------------------------------------------------------------------- + + +class StatePatch(BaseModel): + session_order: list[str] + + +class HeartbeatPayload(BaseModel): + device_id: str + label: str + viewing_session: str | None + view_mode: Literal["grid", "fullscreen"] + last_interaction_at: float + + +# --------------------------------------------------------------------------- +# Routes +# --------------------------------------------------------------------------- + + +@app.get("/health") +async def health() -> dict[str, str]: + """Simple liveness check.""" + return {"status": "ok"} + + +@app.get("/api/state") +async def get_state() -> dict: + """Return the full persistent state.""" + return await read_state() + + +@app.patch("/api/state") +async def patch_state(patch: StatePatch) -> dict: + """Update session_order in the persistent state and return the updated state.""" + async with state_lock: + state = load_state() + state["session_order"] = patch.session_order + save_state(state) + return state + + +@app.get("/api/sessions") +async def get_sessions() -> list[dict]: + """Return list of sessions with name, snapshot, and bell data.""" + names = get_session_list() + snapshots = get_snapshots() + state = await read_state() + + result = [] + for name in names: + session_state = state.get("sessions", {}).get(name, {}) + bell = session_state.get("bell", empty_bell()) + result.append( + { + "name": name, + "snapshot": snapshots.get(name, ""), + "bell": bell, + } + ) + return result + + +@app.post("/api/sessions/{name}/connect") +async def connect_session(name: str) -> dict: + """Connect to a tmux session via ttyd. + + Kills any existing ttyd process, spawns a new one attached to *name*, + and updates the active_session in persistent state. + + Returns {active_session: name, ttyd_port: 7682}. + Raises HTTP 404 if *name* is not in the known session list (when non-empty). + """ + known = get_session_list() + if known and name not in known: + raise HTTPException(status_code=404, detail=f"Session '{name}' not found") + + await kill_ttyd() + await spawn_ttyd(name) + + async with state_lock: + state = load_state() + state["active_session"] = name + save_state(state) + + return {"active_session": name, "ttyd_port": TTYD_PORT} + + +@app.delete("/api/sessions/current") +async def delete_current_session() -> dict: + """Disconnect the current ttyd session. + + Kills the running ttyd process and clears active_session in persistent state. + + Returns {active_session: None}. + """ + await kill_ttyd() + + async with state_lock: + state = load_state() + state["active_session"] = None + save_state(state) + + return {"active_session": None} + + +@app.post("/api/heartbeat") +async def heartbeat(payload: HeartbeatPayload) -> dict: + """Register or update a device heartbeat. + + Acquires state_lock, loads state, calls register_device() with payload + fields, saves state. + + Returns {device_id: str, status: 'ok'}. + Missing device_id or invalid view_mode returns 422 (handled by Pydantic). + """ + async with state_lock: + state = load_state() + register_device( + state, + device_id=payload.device_id, + label=payload.label, + viewing_session=payload.viewing_session, + view_mode=payload.view_mode, + last_interaction_at=payload.last_interaction_at, + ) + save_state(state) + + return {"device_id": payload.device_id, "status": "ok"} + + +@app.post("/api/sessions/{name}/bell") +async def receive_bell(name: str) -> dict: + """Called by tmux alert-bell hook when a bell fires in session *name*. + + This is more reliable than polling window_bell_flag because tmux only + sets that flag when no client is attached -- with an SSH/WezTerm session + attached, the flag never gets set even though the bell fires. + """ + async with state_lock: + state = load_state() + if name not in state["sessions"]: + state["sessions"][name] = {} + if "bell" not in state["sessions"][name]: + state["sessions"][name]["bell"] = empty_bell() + bell = state["sessions"][name]["bell"] + bell["unseen_count"] = bell.get("unseen_count", 0) + 1 + bell["last_fired_at"] = time.time() + save_state(state) + return {"ok": True, "session": name} + + +@app.post("/api/internal/setup-hooks") +async def setup_hooks() -> dict: + """Re-register tmux hooks. Call after tmux server restarts.""" + try: + await run_tmux( + "set-hook", + "-g", + "alert-bell", + "run-shell 'curl -sfo /dev/null -X POST http://localhost:8099/api/sessions/#{session_name}/bell || true'", + ) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + + +# --------------------------------------------------------------------------- +# Static file serving — MUST come after all API routes (first-match-wins) +# --------------------------------------------------------------------------- + +_FRONTEND_DIR = pathlib.Path(__file__).parent.parent / "frontend" +app.mount("/", StaticFiles(directory=str(_FRONTEND_DIR), html=True), name="frontend") diff --git a/coordinator/sessions.py b/coordinator/sessions.py new file mode 100644 index 0000000..40d73a8 --- /dev/null +++ b/coordinator/sessions.py @@ -0,0 +1,139 @@ +""" +tmux session enumeration and snapshot helpers for the tmux-web coordinator. + +In-memory cache: + _session_list — most-recently-enumerated list of session names. + _snapshots — most-recently-captured pane text, keyed by session name. + +Public API: + get_session_list() → list[str] + get_snapshots() → dict[str, str] + update_session_cache(names, snapshots) → None + run_tmux(*args) → str (raises RuntimeError on nonzero exit) + enumerate_sessions() → list[str] + capture_pane(name, lines) → str + snapshot_all(names) → dict[str, str] +""" + +import asyncio + +# --------------------------------------------------------------------------- +# In-memory cache +# --------------------------------------------------------------------------- + +_session_list: list[str] = [] +_snapshots: dict[str, str] = {} + + +def get_session_list() -> list[str]: + """Return a copy of the cached session name list.""" + return list(_session_list) + + +def get_snapshots() -> dict[str, str]: + """Return a copy of the cached pane-snapshot dict.""" + return dict(_snapshots) + + +def update_session_cache(names: list[str], snapshots: dict[str, str]) -> None: + """Replace the in-memory caches with fresh data. + + Sets _session_list to *names* and _snapshots to the provided *snapshots* dict. + Callers must pass the return value of snapshot_all() as *snapshots*. + """ + global _session_list, _snapshots + _session_list = list(names) + _snapshots = snapshots + + +# --------------------------------------------------------------------------- +# Subprocess helpers +# --------------------------------------------------------------------------- + + +async def run_tmux(*args: str) -> str: + """Run `tmux ` in a subprocess and return stdout as a string. + + Raises: + RuntimeError: If the process exits with a nonzero return code. + The error message contains the decoded stderr output. + """ + proc = await asyncio.create_subprocess_exec( + "tmux", + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout_bytes, stderr_bytes = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError(stderr_bytes.decode("utf-8", errors="replace")) + return stdout_bytes.decode("utf-8", errors="replace") + + +# --------------------------------------------------------------------------- +# Session enumeration +# --------------------------------------------------------------------------- + + +async def enumerate_sessions() -> list[str]: + """Return the list of currently running tmux session names. + + Calls ``tmux list-sessions -F #{session_name}``, splits on newlines, + and strips whitespace from each entry. + + Returns [] if tmux is not running (RuntimeError from run_tmux). + """ + try: + output = await run_tmux("list-sessions", "-F", "#{session_name}") + except RuntimeError: + return [] + + names = [line.strip() for line in output.splitlines() if line.strip()] + return names + + +# --------------------------------------------------------------------------- +# Pane capture +# --------------------------------------------------------------------------- + + +async def capture_pane(session_name: str, lines: int = 30) -> str: + """Capture the last *lines* lines of output from *session_name*. + + Returns the captured text, or '' on any error. + """ + try: + return await run_tmux( + "capture-pane", + "-p", + "-t", + session_name, + "-S", + f"-{lines}", + ) + except RuntimeError: + return "" + + +async def snapshot_all(names: list[str]) -> dict[str, str]: + """Capture all sessions concurrently and return a name→text mapping. + + Uses asyncio.gather with return_exceptions=True so that individual + failures do not abort the whole batch. Failed sessions map to ''. + + Note: this function does not mutate module state — it does not update the module cache. + Callers are responsible for passing the result to update_session_cache. + """ + if not names: + return {} + results = await asyncio.gather( + *[capture_pane(name) for name in names], + return_exceptions=True, + ) + snapshots: dict[str, str] = {} + for name, result in zip(names, results): + if isinstance(result, BaseException): + snapshots[name] = "" + else: + snapshots[name] = result + return snapshots diff --git a/coordinator/spike_bell_flag.py b/coordinator/spike_bell_flag.py new file mode 100644 index 0000000..45808f7 --- /dev/null +++ b/coordinator/spike_bell_flag.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Bell Flag Spike — run ONCE manually to determine tmux bell flag read behavior. + +Usage: + python3 coordinator/spike_bell_flag.py + +What this tests: + Does `tmux display-message -p "#{window_bell_flag}"` clear the flag when + read, or does the flag persist until the window is visited in tmux? + +Expected result (almost certain): the flag persists. Reading it does NOT clear +it. The flag is cleared only when the window is marked active inside tmux. +""" + +import subprocess +import time + +SESSION = "bell-spike-test" + + +def run(cmd: list[str]) -> str: + result = subprocess.run(cmd, capture_output=True, text=True) + return result.stdout.strip() + + +def main() -> None: + # 1. Create a test session + print("Creating test session...") + subprocess.run( + ["tmux", "new-session", "-d", "-s", SESSION, "-x", "80", "-y", "24"], check=True + ) + time.sleep(0.2) + + # 2. Send a bell to the session + print("Sending bell to session...") + subprocess.run( + ["tmux", "send-keys", "-t", SESSION, "printf '\\a'", "Enter"], check=True + ) + time.sleep(1.0) + + # 3. Read the bell flag (first read) + flag_read1 = run( + ["tmux", "display-message", "-t", SESSION, "-p", "#{window_bell_flag}"] + ) + print(f"Bell flag (1st read): '{flag_read1}'") + + # 4. Read the bell flag immediately again (second read) + flag_read2 = run( + ["tmux", "display-message", "-t", SESSION, "-p", "#{window_bell_flag}"] + ) + print(f"Bell flag (2nd read): '{flag_read2}'") + + # 5. Cleanup + subprocess.run(["tmux", "kill-session", "-t", SESSION]) + + # 6. Report + print() + if flag_read1 == "1" and flag_read2 == "1": + print("FINDING: Reading does NOT clear the flag. Both reads show '1'.") + print( + "Implementation: use in-memory _bell_seen dict to detect 0→1 transitions." + ) + elif flag_read1 == "1" and flag_read2 == "0": + print( + "FINDING: Reading CLEARS the flag. First read shows '1', second shows '0'." + ) + print("Implementation: each '1' is a new bell — no transition tracking needed.") + elif flag_read1 == "0": + print("WARNING: Bell flag not set after printf '\\a'. Try running manually:") + print(f" tmux send-keys -t {SESSION} \"printf '\\\\a'\" Enter") + print( + " Then check: tmux display-message -t bell-spike-test -p '#{window_bell_flag}'" + ) + else: + print(f"UNEXPECTED: read1={flag_read1!r}, read2={flag_read2!r}") + + +if __name__ == "__main__": + main() diff --git a/coordinator/state.py b/coordinator/state.py new file mode 100644 index 0000000..f93fea9 --- /dev/null +++ b/coordinator/state.py @@ -0,0 +1,184 @@ +""" +State schema and factory functions for the tmux-web coordinator. + +State schema (all values are plain JSON-serialisable dicts): + + { + "active_session": str | None, + "session_order": list[str], + "sessions": { + "": { + "bell": { + "last_fired_at": float | None, + "seen_at": float | None, + "unseen_count": int, + } + } + }, + "devices": { + "": { + "label": str, + "viewing_session": str | None, + "view_mode": "fullscreen" | "grid", + "last_interaction_at": float, + "last_heartbeat_at": float, + } + }, + } +""" + +import asyncio +import json +import os +import time +from pathlib import Path + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +_default_state_dir = Path.home() / ".local" / "share" / "tmux-web" +STATE_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_state_dir)) +STATE_PATH: Path = STATE_DIR / "state.json" + +# --------------------------------------------------------------------------- +# Global asyncio lock — must be acquired before reading or writing state. +# --------------------------------------------------------------------------- + +state_lock: asyncio.Lock = asyncio.Lock() + +# --------------------------------------------------------------------------- +# Factory functions +# --------------------------------------------------------------------------- + + +def empty_state() -> dict: + """Return a fresh, empty top-level state dict. + + Every call returns a fully independent object — no shared mutables. + """ + return { + "active_session": None, + "session_order": [], + "sessions": {}, + "devices": {}, + } + + +def empty_bell() -> dict: + """Return a fresh bell sub-dict with all fields reset.""" + return { + "last_fired_at": None, + "seen_at": None, + "unseen_count": 0, + } + + +def empty_device(device_id: str, label: str) -> dict: # noqa: ARG001 + """Return a fresh device sub-dict. + + Args: + device_id: Identifier for the device (unused in the dict itself, + kept as a parameter for call-site clarity). + label: Human-readable name for the device. + """ + now = time.time() + return { + "label": label, + "viewing_session": None, + "view_mode": "grid", + "last_interaction_at": now, + "last_heartbeat_at": now, + } + + +# --------------------------------------------------------------------------- +# Device helpers +# --------------------------------------------------------------------------- + + +def register_device( + state: dict, + device_id: str, + label: str, + viewing_session: str | None, + view_mode: str, + last_interaction_at: float, +) -> None: + """Create or update a device entry in state['devices']. + + For new devices, seeds the entry via empty_device(). + Always refreshes last_heartbeat_at to time.time(). + Updates label, viewing_session, view_mode, last_interaction_at. + """ + if device_id not in state["devices"]: + state["devices"][device_id] = empty_device(device_id, label) + + device = state["devices"][device_id] + device["label"] = label + device["viewing_session"] = viewing_session + device["view_mode"] = view_mode + device["last_interaction_at"] = last_interaction_at + device["last_heartbeat_at"] = time.time() + + +def prune_devices(state: dict, ttl_seconds: float = 300.0) -> list[str]: + """Remove devices whose last_heartbeat_at is older than ttl_seconds. + + Returns the list of removed device IDs. + """ + cutoff = time.time() - ttl_seconds + stale = [ + device_id + for device_id, device in state["devices"].items() + if device["last_heartbeat_at"] < cutoff + ] + for device_id in stale: + del state["devices"][device_id] + return stale + + +# --------------------------------------------------------------------------- +# Sync I/O helpers (no lock — callers must hold state_lock when appropriate) +# --------------------------------------------------------------------------- + + +def load_state() -> dict: + """Read and return state from STATE_PATH. + + Returns empty_state() if the file does not exist or contains invalid JSON. + """ + try: + with open(STATE_PATH) as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + return empty_state() + + +def save_state(state: dict) -> None: + """Atomically write *state* to STATE_PATH. + + Uses the write-to-tmp-then-os.replace pattern so readers never see a + partial file. Creates STATE_DIR (and parents) if it does not exist. + """ + STATE_DIR.mkdir(parents=True, exist_ok=True) + tmp = Path(str(STATE_PATH) + ".tmp") + tmp.write_text(json.dumps(state, indent=2)) + os.replace(tmp, STATE_PATH) + + +# --------------------------------------------------------------------------- +# Async wrappers — acquire state_lock before touching the file +# --------------------------------------------------------------------------- + + +async def read_state() -> dict: + """Async read: acquires state_lock, then delegates to load_state().""" + async with state_lock: + return load_state() + + +async def write_state(state: dict) -> None: + """Async write: acquires state_lock, then delegates to save_state().""" + async with state_lock: + save_state(state) diff --git a/coordinator/tests/__init__.py b/coordinator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/coordinator/tests/test_api.py b/coordinator/tests/test_api.py new file mode 100644 index 0000000..c32399e --- /dev/null +++ b/coordinator/tests/test_api.py @@ -0,0 +1,645 @@ +""" +Tests for coordinator/main.py — FastAPI skeleton, lifespan, /health endpoint. +""" + +import pytest +from fastapi.testclient import TestClient + +from coordinator.main import app + + +# --------------------------------------------------------------------------- +# autouse fixture — redirect state/PID files, mock startup side-effects +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def patch_startup_and_state(tmp_path, monkeypatch): + """Redirect state/PID files to tmp_path, mock kill_orphan_ttyd, replace _poll_loop with no-op.""" + # Redirect state files + tmp_state_dir = tmp_path / "state" + tmp_state_path = tmp_state_dir / "state.json" + monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir) + monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path) + + # Redirect PID files + tmp_pid_dir = tmp_path / "ttyd" + tmp_pid_path = tmp_pid_dir / "ttyd.pid" + monkeypatch.setattr("coordinator.ttyd.TTYD_PID_DIR", tmp_pid_dir) + monkeypatch.setattr("coordinator.ttyd.TTYD_PID_PATH", tmp_pid_path) + + # Mock kill_orphan_ttyd so startup doesn't touch real processes (must be async) + async def _mock_kill_orphan(): + return False + + monkeypatch.setattr("coordinator.main.kill_orphan_ttyd", _mock_kill_orphan) + + # Replace _poll_loop with a no-op so tests don't spin up real poll cycles + async def noop_poll_loop() -> None: + pass + + monkeypatch.setattr("coordinator.main._poll_loop", noop_poll_loop) + + +# --------------------------------------------------------------------------- +# Client fixture — TestClient with lifespan enabled +# --------------------------------------------------------------------------- + + +@pytest.fixture +def client(): + """Return a TestClient that triggers the app lifespan on entry/exit.""" + with TestClient(app) as c: + yield c + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_health_returns_200(client): + """GET /health must return HTTP 200.""" + response = client.get("/health") + assert response.status_code == 200 + + +def test_health_returns_ok_status(client): + """GET /health must return JSON body {status: 'ok'}.""" + response = client.get("/health") + assert response.json() == {"status": "ok"} + + +# --------------------------------------------------------------------------- +# GET /api/state +# --------------------------------------------------------------------------- + + +def test_get_state_returns_full_state(client): + """GET /api/state must return a dict with all 4 top-level keys.""" + response = client.get("/api/state") + assert response.status_code == 200 + data = response.json() + assert "active_session" in data + assert "session_order" in data + assert "sessions" in data + assert "devices" in data + + +def test_get_state_active_session_is_none_initially(client): + """GET /api/state active_session must be None on a fresh state.""" + response = client.get("/api/state") + assert response.status_code == 200 + data = response.json() + assert data["active_session"] is None + + +# --------------------------------------------------------------------------- +# PATCH /api/state +# --------------------------------------------------------------------------- + + +def test_patch_state_updates_session_order(client): + """PATCH /api/state updates session_order and persists the change.""" + from coordinator.state import load_state, save_state + + # Write initial state with a known session order + initial_state = { + "active_session": None, + "session_order": ["alpha", "beta"], + "sessions": {}, + "devices": {}, + } + save_state(initial_state) + + # Patch with reversed order + response = client.patch("/api/state", json={"session_order": ["beta", "alpha"]}) + assert response.status_code == 200 + data = response.json() + assert data["session_order"] == ["beta", "alpha"] + + # Verify the update was persisted to disk + persisted = load_state() + assert persisted["session_order"] == ["beta", "alpha"] + + +def test_patch_state_rejects_non_list_session_order(client): + """PATCH /api/state rejects non-list session_order with HTTP 422.""" + response = client.patch("/api/state", json={"session_order": "not-a-list"}) + assert response.status_code == 422 + + +def test_patch_state_ignores_unknown_fields(client): + """PATCH /api/state ignores unknown fields in the request body.""" + response = client.patch( + "/api/state", + json={"session_order": ["a", "b"], "unknown_field": "should_be_ignored"}, + ) + assert response.status_code == 200 + data = response.json() + assert "unknown_field" not in data + assert data["session_order"] == ["a", "b"] + + +# --------------------------------------------------------------------------- +# GET /api/sessions +# --------------------------------------------------------------------------- + + +def test_get_sessions_returns_list(client, monkeypatch): + """GET /api/sessions must return a JSON list.""" + monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"]) + monkeypatch.setattr( + "coordinator.main.get_snapshots", lambda: {"alpha": "some text"} + ) + + response = client.get("/api/sessions") + assert response.status_code == 200 + items = response.json() + assert isinstance(items, list) + assert items[0]["name"] == "alpha" + + +def test_get_sessions_each_item_has_required_fields(client, monkeypatch): + """Each item in GET /api/sessions must have name, snapshot, and bell fields.""" + from coordinator.state import save_state + + monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["beta"]) + monkeypatch.setattr("coordinator.main.get_snapshots", lambda: {"beta": "output"}) + save_state( + { + "active_session": None, + "session_order": ["beta"], + "sessions": { + "beta": { + "bell": {"last_fired_at": None, "seen_at": None, "unseen_count": 0} + } + }, + "devices": {}, + } + ) + + response = client.get("/api/sessions") + assert response.status_code == 200 + items = response.json() + assert len(items) == 1 + item = items[0] + assert "name" in item + assert "snapshot" in item + assert "bell" in item + + +def test_get_sessions_includes_snapshot_text(client, monkeypatch): + """GET /api/sessions snapshot field must contain the cached capture-pane text.""" + monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["gamma"]) + monkeypatch.setattr( + "coordinator.main.get_snapshots", + lambda: {"gamma": "hello from tmux pane"}, + ) + + response = client.get("/api/sessions") + assert response.status_code == 200 + items = response.json() + assert len(items) == 1 + assert items[0]["name"] == "gamma" + assert items[0]["snapshot"] == "hello from tmux pane" + + +def test_get_sessions_includes_bell_state(client, monkeypatch): + """GET /api/sessions bell field must include unseen_count from persistent state.""" + from coordinator.state import save_state + + monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["delta"]) + monkeypatch.setattr( + "coordinator.main.get_snapshots", lambda: {"delta": "pane text"} + ) + save_state( + { + "active_session": None, + "session_order": ["delta"], + "sessions": { + "delta": { + "bell": { + "last_fired_at": 1234567890.0, + "seen_at": None, + "unseen_count": 3, + } + } + }, + "devices": {}, + } + ) + + response = client.get("/api/sessions") + assert response.status_code == 200 + items = response.json() + assert len(items) == 1 + assert items[0]["bell"]["unseen_count"] == 3 + + +def test_get_sessions_returns_empty_list_when_no_sessions(client, monkeypatch): + """GET /api/sessions must return an empty list when there are no sessions.""" + monkeypatch.setattr("coordinator.main.get_session_list", lambda: []) + monkeypatch.setattr("coordinator.main.get_snapshots", lambda: {}) + + response = client.get("/api/sessions") + assert response.status_code == 200 + assert response.json() == [] + + +# --------------------------------------------------------------------------- +# POST /api/sessions/{name}/connect +# --------------------------------------------------------------------------- + + +def test_connect_session_returns_200(client, monkeypatch): + """POST /api/sessions/{name}/connect returns 200 and correct body when session exists.""" + monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"]) + + async def mock_kill(): + return True + + monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill) + + async def mock_spawn(name): + pass + + monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn) + + response = client.post("/api/sessions/alpha/connect") + assert response.status_code == 200 + data = response.json() + assert data["active_session"] == "alpha" + assert data["ttyd_port"] == 7682 + + +def test_connect_session_sets_active_session(client, monkeypatch): + """POST /api/sessions/{name}/connect persists active_session to state.""" + from coordinator.state import load_state + + monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"]) + + async def mock_kill(): + return True + + monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill) + + async def mock_spawn(name): + pass + + monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn) + + client.post("/api/sessions/alpha/connect") + + state = load_state() + assert state["active_session"] == "alpha" + + +def test_connect_session_kills_existing_ttyd(client, monkeypatch): + """POST /api/sessions/{name}/connect calls kill_ttyd then spawn_ttyd.""" + monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha"]) + + call_order = [] + + async def mock_kill(): + call_order.append("kill") + return True + + async def mock_spawn(name): + call_order.append(("spawn", name)) + + monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill) + monkeypatch.setattr("coordinator.main.spawn_ttyd", mock_spawn) + + response = client.post("/api/sessions/alpha/connect") + assert response.status_code == 200 + assert call_order == ["kill", ("spawn", "alpha")] + + +def test_connect_nonexistent_session_returns_404(client, monkeypatch): + """POST /api/sessions/{name}/connect returns 404 when session is not in list.""" + monkeypatch.setattr("coordinator.main.get_session_list", lambda: ["alpha", "beta"]) + + response = client.post("/api/sessions/gamma/connect") + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# DELETE /api/sessions/current +# --------------------------------------------------------------------------- + + +def test_delete_current_kills_ttyd_and_clears_active(client, monkeypatch): + """DELETE /api/sessions/current kills ttyd and clears active_session.""" + from coordinator.state import load_state, save_state + + # Set up initial state with active session + save_state( + { + "active_session": "alpha", + "session_order": ["alpha"], + "sessions": {}, + "devices": {}, + } + ) + + kill_called = [] + + async def mock_kill(): + kill_called.append(True) + return True + + monkeypatch.setattr("coordinator.main.kill_ttyd", mock_kill) + + response = client.delete("/api/sessions/current") + assert response.status_code == 200 + data = response.json() + assert data["active_session"] is None + assert len(kill_called) == 1 + + # Verify state was persisted + state = load_state() + assert state["active_session"] is None + + +# --------------------------------------------------------------------------- +# POST /api/heartbeat +# --------------------------------------------------------------------------- + + +def test_heartbeat_returns_200(client): + """POST /api/heartbeat must return HTTP 200 with device_id and status 'ok'.""" + response = client.post( + "/api/heartbeat", + json={ + "device_id": "dev-abc", + "label": "My Laptop", + "viewing_session": None, + "view_mode": "grid", + "last_interaction_at": 1234567890.0, + }, + ) + assert response.status_code == 200 + data = response.json() + assert data["device_id"] == "dev-abc" + assert data["status"] == "ok" + + +def test_heartbeat_registers_new_device(client): + """POST /api/heartbeat registers a new device visible in GET /api/state.""" + client.post( + "/api/heartbeat", + json={ + "device_id": "dev-new", + "label": "Test Device", + "viewing_session": "mysession", + "view_mode": "fullscreen", + "last_interaction_at": 1111111111.0, + }, + ) + + state_response = client.get("/api/state") + assert state_response.status_code == 200 + state = state_response.json() + assert "dev-new" in state["devices"] + device = state["devices"]["dev-new"] + assert device["label"] == "Test Device" + assert device["viewing_session"] == "mysession" + assert device["view_mode"] == "fullscreen" + assert device["last_interaction_at"] == 1111111111.0 + + +def test_heartbeat_updates_existing_device(client): + """Two POST /api/heartbeat calls: second values are persisted.""" + # First heartbeat + client.post( + "/api/heartbeat", + json={ + "device_id": "dev-update", + "label": "Old Label", + "viewing_session": None, + "view_mode": "grid", + "last_interaction_at": 1000000000.0, + }, + ) + # Second heartbeat with updated values + client.post( + "/api/heartbeat", + json={ + "device_id": "dev-update", + "label": "New Label", + "viewing_session": "session-x", + "view_mode": "fullscreen", + "last_interaction_at": 2000000000.0, + }, + ) + + state_response = client.get("/api/state") + state = state_response.json() + device = state["devices"]["dev-update"] + assert device["label"] == "New Label" + assert device["viewing_session"] == "session-x" + assert device["view_mode"] == "fullscreen" + assert device["last_interaction_at"] == 2000000000.0 + + +def test_heartbeat_missing_device_id_returns_422(client): + """POST /api/heartbeat without device_id must return HTTP 422.""" + response = client.post( + "/api/heartbeat", + json={ + "label": "My Laptop", + "viewing_session": None, + "view_mode": "grid", + "last_interaction_at": 1234567890.0, + }, + ) + assert response.status_code == 422 + + +def test_heartbeat_invalid_view_mode_returns_422(client): + """POST /api/heartbeat with invalid view_mode must return HTTP 422.""" + response = client.post( + "/api/heartbeat", + json={ + "device_id": "dev-abc", + "label": "My Laptop", + "viewing_session": None, + "view_mode": "invalid_mode", + "last_interaction_at": 1234567890.0, + }, + ) + assert response.status_code == 422 + + +# --------------------------------------------------------------------------- +# POST /api/sessions/{name}/bell +# --------------------------------------------------------------------------- + + +def test_receive_bell_returns_ok_and_session_name(client): + """POST /api/sessions/{name}/bell returns {"ok": True, "session": name}.""" + response = client.post("/api/sessions/web-tmux/bell") + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + assert data["session"] == "web-tmux" + + +def test_receive_bell_increments_unseen_count(client): + """POST /api/sessions/{name}/bell increments unseen_count in state.""" + from coordinator.state import load_state + + client.post("/api/sessions/my-session/bell") + + state = load_state() + bell = state["sessions"]["my-session"]["bell"] + assert bell["unseen_count"] == 1 + + +def test_receive_bell_creates_session_entry_if_absent(client): + """POST /api/sessions/{name}/bell creates session/bell entries if missing.""" + from coordinator.state import load_state + + # Ensure session does not exist in state yet + client.post("/api/sessions/brand-new/bell") + + state = load_state() + assert "brand-new" in state["sessions"] + assert "bell" in state["sessions"]["brand-new"] + + +def test_receive_bell_multiple_calls_accumulate(client): + """Three POST calls to the bell endpoint accumulate unseen_count to 3.""" + from coordinator.state import load_state + + for _ in range(3): + client.post("/api/sessions/multi-session/bell") + + state = load_state() + bell = state["sessions"]["multi-session"]["bell"] + assert bell["unseen_count"] == 3 + + +def test_receive_bell_sets_last_fired_at(client): + """POST /api/sessions/{name}/bell sets last_fired_at to a recent timestamp.""" + import time + + from coordinator.state import load_state + + before = time.time() + client.post("/api/sessions/timed-session/bell") + after = time.time() + + state = load_state() + bell = state["sessions"]["timed-session"]["bell"] + assert bell["last_fired_at"] is not None + assert before <= bell["last_fired_at"] <= after + + +# --------------------------------------------------------------------------- +# POST /api/internal/setup-hooks +# --------------------------------------------------------------------------- + + +def test_setup_hooks_returns_ok(client, monkeypatch): + """POST /api/internal/setup-hooks returns {"ok": True} when tmux hook registers.""" + from unittest.mock import AsyncMock + + monkeypatch.setattr("coordinator.main.run_tmux", AsyncMock(return_value="")) + + response = client.post("/api/internal/setup-hooks") + assert response.status_code == 200 + data = response.json() + assert data["ok"] is True + + +def test_setup_hooks_returns_ok_false_on_error(client, monkeypatch): + """POST /api/internal/setup-hooks returns {"ok": False} when tmux raises.""" + from unittest.mock import AsyncMock + + monkeypatch.setattr( + "coordinator.main.run_tmux", + AsyncMock(side_effect=RuntimeError("tmux not found")), + ) + + response = client.post("/api/internal/setup-hooks") + assert response.status_code == 200 + data = response.json() + assert data["ok"] is False + assert "error" in data + + +def test_setup_hooks_curl_discards_response_body(client, monkeypatch): + """POST /api/internal/setup-hooks passes curl with -o /dev/null to discard response.""" + from unittest.mock import AsyncMock + + mock_run_tmux = AsyncMock(return_value="") + monkeypatch.setattr("coordinator.main.run_tmux", mock_run_tmux) + + response = client.post("/api/internal/setup-hooks") + assert response.status_code == 200 + + # Verify run_tmux was called with the correct hook command + assert mock_run_tmux.called + call_args = mock_run_tmux.call_args + # Positional args are: "set-hook", "-g", "alert-bell", + hook_command = call_args[0][3] if len(call_args[0]) > 3 else None + assert hook_command is not None + # Should have -sfo /dev/null, not just -sf + assert "-sfo /dev/null" in hook_command + + +def test_lifespan_alert_bell_hook_discards_response(monkeypatch): + """Lifespan startup registers alert-bell hook with curl -o /dev/null to discard response.""" + from unittest.mock import AsyncMock + from fastapi.testclient import TestClient + from coordinator.main import app + + # Mock run_tmux to capture the hook command + mock_run_tmux = AsyncMock(return_value="") + monkeypatch.setattr("coordinator.main.run_tmux", mock_run_tmux) + + # Trigger lifespan by creating a TestClient + with TestClient(app) as _: + pass + + # Verify run_tmux was called during lifespan startup + assert mock_run_tmux.called + # Find the call that sets the alert-bell hook + hook_calls = [ + call + for call in mock_run_tmux.call_args_list + if len(call[0]) > 3 and call[0][2] == "alert-bell" + ] + assert len(hook_calls) > 0, "alert-bell hook was not set during lifespan" + + # Check the first hook call + hook_command = hook_calls[0][0][3] + assert "-sfo /dev/null" in hook_command + + +# --------------------------------------------------------------------------- +# Static file serving tests +# --------------------------------------------------------------------------- + + +def test_root_serves_html(client): + """GET / must return 200 with text/html content-type.""" + response = client.get("/") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + + +def test_style_css_served(client): + """GET /style.css must return 200 with text/css content-type.""" + response = client.get("/style.css") + assert response.status_code == 200 + assert "text/css" in response.headers["content-type"] + + +def test_api_routes_not_shadowed(client): + """GET /api/sessions must still return 200 with JSON list (not shadowed by StaticFiles).""" + response = client.get("/api/sessions") + assert response.status_code == 200 + assert isinstance(response.json(), list) diff --git a/coordinator/tests/test_bells.py b/coordinator/tests/test_bells.py new file mode 100644 index 0000000..cfb6030 --- /dev/null +++ b/coordinator/tests/test_bells.py @@ -0,0 +1,318 @@ +""" +Tests for coordinator/bells.py — bell flag polling and unseen_count tracking. +All 17 acceptance-criteria tests are defined here. +""" + +import time +from unittest.mock import AsyncMock, patch + +import pytest + +from coordinator.bells import ( + _bell_seen, + apply_bell_clear_rule, + poll_bell_flag, + process_bell_flags, + should_clear_bell, +) +from coordinator.state import empty_bell, empty_state + + +# --------------------------------------------------------------------------- +# autouse fixture — clear _bell_seen before/after each test +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def reset_bell_seen(): + """Clear _bell_seen before and after each test for isolation.""" + _bell_seen.clear() + yield + _bell_seen.clear() + + +# --------------------------------------------------------------------------- +# poll_bell_flag tests +# --------------------------------------------------------------------------- + + +async def test_poll_bell_flag_returns_true_when_flag_is_1(): + """poll_bell_flag returns True when tmux reports window_bell_flag=1.""" + with patch("coordinator.bells.run_tmux", new=AsyncMock(return_value="1\n")): + result = await poll_bell_flag("my-session") + assert result is True + + +async def test_poll_bell_flag_returns_false_when_flag_is_0(): + """poll_bell_flag returns False when tmux reports window_bell_flag=0.""" + with patch("coordinator.bells.run_tmux", new=AsyncMock(return_value="0\n")): + result = await poll_bell_flag("my-session") + assert result is False + + +async def test_poll_bell_flag_returns_false_on_error(): + """poll_bell_flag returns False when run_tmux raises RuntimeError.""" + with patch( + "coordinator.bells.run_tmux", + new=AsyncMock(side_effect=RuntimeError("session not found")), + ): + result = await poll_bell_flag("my-session") + assert result is False + + +# --------------------------------------------------------------------------- +# process_bell_flags tests +# --------------------------------------------------------------------------- + + +async def test_process_bell_flags_increments_unseen_count_on_new_bell(): + """process_bell_flags increments unseen_count on a 0→1 transition.""" + state = empty_state() + state["sessions"]["session-a"] = {"bell": empty_bell()} + + with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=True)): + changed = await process_bell_flags(["session-a"], state) + + assert changed is True + assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 1 + assert state["sessions"]["session-a"]["bell"]["last_fired_at"] is not None + + +async def test_process_bell_flags_does_not_double_count_persistent_flag(): + """process_bell_flags does not increment unseen_count if flag stays at 1.""" + state = empty_state() + state["sessions"]["session-a"] = {"bell": empty_bell()} + + with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=True)): + # First poll — 0→1 transition + await process_bell_flags(["session-a"], state) + # Second poll — 1→1 (persistent), should NOT increment again + changed = await process_bell_flags(["session-a"], state) + + assert changed is False + assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 1 + + +async def test_process_bell_flags_resets_tracking_when_flag_clears(): + """1→0→1 sequence counts as two separate bells.""" + state = empty_state() + state["sessions"]["session-a"] = {"bell": empty_bell()} + + # side_effect drives three sequential calls: 0→1, 1→0, 0→1 + with patch( + "coordinator.bells.poll_bell_flag", + new=AsyncMock(side_effect=[True, False, True]), + ): + for _ in range(3): + await process_bell_flags(["session-a"], state) + + assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 2 + + +async def test_process_bell_flags_no_change_returns_false(): + """process_bell_flags returns False when no bell state changed.""" + state = empty_state() + state["sessions"]["session-a"] = {"bell": empty_bell()} + + with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=False)): + changed = await process_bell_flags(["session-a"], state) + + assert changed is False + assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 0 + + +async def test_process_bell_flags_creates_bell_entry_if_missing(): + """process_bell_flags creates the bell sub-dict if session has no bell key.""" + state = empty_state() + state["sessions"]["session-a"] = {} # no 'bell' key + + with patch("coordinator.bells.poll_bell_flag", new=AsyncMock(return_value=False)): + await process_bell_flags(["session-a"], state) + + assert "bell" in state["sessions"]["session-a"] + assert state["sessions"]["session-a"]["bell"]["unseen_count"] == 0 + + +# --------------------------------------------------------------------------- +# should_clear_bell tests +# --------------------------------------------------------------------------- + + +def test_should_clear_bell_returns_true_for_fullscreen_recent_interaction(): + """should_clear_bell returns True when a device is fullscreen and interacted recently.""" + state = empty_state() + state["sessions"]["session-a"] = {"bell": empty_bell()} + state["devices"]["device-1"] = { + "label": "Device 1", + "viewing_session": "session-a", + "view_mode": "fullscreen", + "last_interaction_at": time.time() - 10.0, # 10 seconds ago + "last_heartbeat_at": time.time(), + } + + assert should_clear_bell("session-a", state) is True + + +def test_should_clear_bell_returns_false_for_grid_mode(): + """should_clear_bell returns False when device is in grid mode.""" + state = empty_state() + state["sessions"]["session-a"] = {"bell": empty_bell()} + state["devices"]["device-1"] = { + "label": "Device 1", + "viewing_session": "session-a", + "view_mode": "grid", + "last_interaction_at": time.time() - 10.0, # recent interaction + "last_heartbeat_at": time.time(), + } + + assert should_clear_bell("session-a", state) is False + + +def test_should_clear_bell_returns_false_when_interaction_too_old(): + """should_clear_bell returns False when last interaction was more than 60s ago.""" + state = empty_state() + state["sessions"]["session-a"] = {"bell": empty_bell()} + state["devices"]["device-1"] = { + "label": "Device 1", + "viewing_session": "session-a", + "view_mode": "fullscreen", + "last_interaction_at": time.time() - 90.0, # 90 seconds ago (> 60s window) + "last_heartbeat_at": time.time(), + } + + assert should_clear_bell("session-a", state) is False + + +def test_should_clear_bell_returns_false_when_device_viewing_different_session(): + """should_clear_bell returns False when device is viewing a different session.""" + state = empty_state() + state["sessions"]["session-a"] = {"bell": empty_bell()} + state["devices"]["device-1"] = { + "label": "Device 1", + "viewing_session": "session-b", # different session + "view_mode": "fullscreen", + "last_interaction_at": time.time() - 10.0, + "last_heartbeat_at": time.time(), + } + + assert should_clear_bell("session-a", state) is False + + +def test_should_clear_bell_returns_false_when_no_devices(): + """should_clear_bell returns False when there are no connected devices.""" + state = empty_state() + state["sessions"]["session-a"] = {"bell": empty_bell()} + # No devices in state["devices"] + + assert should_clear_bell("session-a", state) is False + + +# --------------------------------------------------------------------------- +# apply_bell_clear_rule tests +# --------------------------------------------------------------------------- + + +def test_apply_bell_clear_rule_clears_matching_sessions(): + """apply_bell_clear_rule resets unseen_count to 0 and sets seen_at for qualifying sessions.""" + state = empty_state() + state["sessions"]["session-a"] = { + "bell": { + "unseen_count": 3, + "last_fired_at": time.time() - 30.0, + "seen_at": None, + } + } + state["devices"]["device-1"] = { + "label": "Device 1", + "viewing_session": "session-a", + "view_mode": "fullscreen", + "last_interaction_at": time.time() - 10.0, + "last_heartbeat_at": time.time(), + } + + before = time.time() + apply_bell_clear_rule(state) + after = time.time() + + bell = state["sessions"]["session-a"]["bell"] + assert bell["unseen_count"] == 0 + assert bell["seen_at"] is not None + assert before <= bell["seen_at"] <= after + + +def test_apply_bell_clear_rule_skips_sessions_with_zero_unseen(): + """apply_bell_clear_rule does not modify sessions that already have unseen_count == 0.""" + state = empty_state() + state["sessions"]["session-a"] = { + "bell": { + "unseen_count": 0, + "last_fired_at": None, + "seen_at": None, + } + } + state["devices"]["device-1"] = { + "label": "Device 1", + "viewing_session": "session-a", + "view_mode": "fullscreen", + "last_interaction_at": time.time() - 10.0, + "last_heartbeat_at": time.time(), + } + + result = apply_bell_clear_rule(state) + + assert result == [] + assert state["sessions"]["session-a"]["bell"]["seen_at"] is None + + +def test_apply_bell_clear_rule_returns_list_of_cleared_session_names(): + """apply_bell_clear_rule returns the names of sessions that were cleared.""" + state = empty_state() + state["sessions"]["session-a"] = { + "bell": {"unseen_count": 2, "last_fired_at": time.time() - 5.0, "seen_at": None} + } + state["sessions"]["session-b"] = { + "bell": {"unseen_count": 1, "last_fired_at": time.time() - 5.0, "seen_at": None} + } + state["sessions"]["session-c"] = { + "bell": {"unseen_count": 0, "last_fired_at": None, "seen_at": None} + } + state["devices"]["device-1"] = { + "label": "Device 1", + "viewing_session": "session-a", + "view_mode": "fullscreen", + "last_interaction_at": time.time() - 10.0, + "last_heartbeat_at": time.time(), + } + state["devices"]["device-2"] = { + "label": "Device 2", + "viewing_session": "session-b", + "view_mode": "fullscreen", + "last_interaction_at": time.time() - 10.0, + "last_heartbeat_at": time.time(), + } + + result = apply_bell_clear_rule(state) + + assert sorted(result) == ["session-a", "session-b"] + + +def test_apply_bell_clear_rule_resets_bell_seen_tracking(): + """apply_bell_clear_rule resets _bell_seen[name] = False for cleared sessions.""" + state = empty_state() + state["sessions"]["session-a"] = { + "bell": {"unseen_count": 1, "last_fired_at": time.time() - 5.0, "seen_at": None} + } + state["devices"]["device-1"] = { + "label": "Device 1", + "viewing_session": "session-a", + "view_mode": "fullscreen", + "last_interaction_at": time.time() - 10.0, + "last_heartbeat_at": time.time(), + } + + # Pre-seed _bell_seen as if the bell was previously seen + _bell_seen["session-a"] = True + + apply_bell_clear_rule(state) + + assert _bell_seen.get("session-a") is False diff --git a/coordinator/tests/test_frontend_css.py b/coordinator/tests/test_frontend_css.py new file mode 100644 index 0000000..2a1436c --- /dev/null +++ b/coordinator/tests/test_frontend_css.py @@ -0,0 +1,92 @@ +"""Tests for frontend/style.css — design tokens and dark theme.""" + +import pathlib + +CSS_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "style.css" + + +def read_css() -> str: + return CSS_PATH.read_text(encoding="utf-8") + + +def test_css_design_tokens(): + css = read_css() + assert "--bg:" in css + assert "--bell:" in css + assert "--font-mono:" in css + assert "--tile-height:" in css + assert "--t-zoom:" in css + + +def test_css_session_grid(css=None): + css = read_css() + assert "auto-fill" in css + assert "minmax" in css + + +def test_css_tile_height(css=None): + css = read_css() + assert ".session-tile" in css + assert "var(--tile-height)" in css + + +def test_css_bell_indicator(css=None): + css = read_css() + assert "bell-pulse" in css + assert ".session-tile--bell" in css + assert ".tile-bell" in css + + +def test_css_breakpoints(): + css = read_css() + assert "599px" in css + assert "899px" in css + + +def test_css_zoom_transition(): + css = read_css() + assert ".session-tile--expanding" in css + assert "session-tile--expanded" in css + assert ".session-grid--dimming" in css + + +def test_css_bell_count_and_toast(): + css = read_css() + assert ".tile-bell-count" in css + assert ".connection-status--ok" in css + assert ".connection-status--warn" in css + assert ".connection-status--err" in css + assert ".toast" in css + + +def test_css_mobile_tiers(): + css = read_css() + assert "session-tile--tier-bell" in css + assert "session-tile--tier-active" in css + assert "session-tile--tier-idle" in css + + +def test_css_command_palette(): + css = read_css() + assert ".command-palette__dialog" in css + assert ".command-palette__input" in css + assert ".palette-item" in css + assert ".palette-item--selected" in css + + +def test_css_bottom_sheet(): + css = read_css() + assert ".bottom-sheet__panel" in css + assert ".bottom-sheet__handle" in css + assert ".sheet-item" in css + + +def test_css_session_pill(): + css = read_css() + assert ".session-pill" in css + assert ".session-pill__label" in css + + +def test_css_reduced_motion(): + css = read_css() + assert "prefers-reduced-motion" in css diff --git a/coordinator/tests/test_frontend_html.py b/coordinator/tests/test_frontend_html.py new file mode 100644 index 0000000..a2e1a5d --- /dev/null +++ b/coordinator/tests/test_frontend_html.py @@ -0,0 +1,165 @@ +"""Tests for frontend/index.html — verifies presence of all required DOM elements.""" + +import pathlib + +from bs4 import BeautifulSoup + +HTML_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "index.html" + +# Parse once per module — tests are read-only so sharing is safe. +_SOUP: BeautifulSoup = BeautifulSoup(HTML_PATH.read_text(), "html.parser") + + +def test_html_pwa_meta() -> None: + """apple-mobile-web-app-capable, rel=manifest, theme-color, apple-mobile-web-app-status-bar-style.""" + soup = _SOUP + # rel=manifest + assert soup.find("link", rel="manifest"), "Missing " + # theme-color + assert soup.find("meta", attrs={"name": "theme-color"}), ( + "Missing " + ) + # apple-mobile-web-app-capable + assert soup.find("meta", attrs={"name": "apple-mobile-web-app-capable"}), ( + "Missing " + ) + # apple-mobile-web-app-status-bar-style + assert soup.find("meta", attrs={"name": "apple-mobile-web-app-status-bar-style"}), ( + "Missing " + ) + + +def test_html_viewport_suppresses_pinch_zoom() -> None: + """viewport must include maximum-scale=1.0 and user-scalable=no.""" + soup = _SOUP + viewport = soup.find("meta", attrs={"name": "viewport"}) + assert viewport, "Missing " + content = str(viewport.get("content", "")) # type: ignore[union-attr] + assert "maximum-scale=1.0" in content, ( + f"viewport missing maximum-scale=1.0: {content!r}" + ) + assert "user-scalable=no" in content, ( + f"viewport missing user-scalable=no: {content!r}" + ) + + +def test_html_required_views() -> None: + """id=view-overview, view-expanded, session-grid, terminal-container, empty-state.""" + soup = _SOUP + for id_ in ( + "view-overview", + "view-expanded", + "session-grid", + "terminal-container", + "empty-state", + ): + assert soup.find(id=id_), f"Missing element with id='{id_}'" + + +def test_html_expanded_view_elements() -> None: + """id=back-btn, expanded-session-name, palette-trigger, reconnect-overlay.""" + soup = _SOUP + for id_ in ( + "back-btn", + "expanded-session-name", + "palette-trigger", + "reconnect-overlay", + ): + assert soup.find(id=id_), f"Missing element with id='{id_}'" + + +def test_html_command_palette() -> None: + """id=command-palette, palette-input, palette-list, palette-backdrop.""" + soup = _SOUP + for id_ in ("command-palette", "palette-input", "palette-list", "palette-backdrop"): + assert soup.find(id=id_), f"Missing element with id='{id_}'" + + +def test_html_bottom_sheet() -> None: + """id=bottom-sheet, sheet-list, sheet-backdrop, session-pill, session-pill-label, session-pill-bell.""" + soup = _SOUP + for id_ in ( + "bottom-sheet", + "sheet-list", + "sheet-backdrop", + "session-pill", + "session-pill-label", + "session-pill-bell", + ): + assert soup.find(id=id_), f"Missing element with id='{id_}'" + + +def test_html_toast() -> None: + """id=toast, aria-live=polite.""" + soup = _SOUP + toast = soup.find(id="toast") + assert toast, "Missing element with id='toast'" + assert toast.get("aria-live") == "polite", ( # type: ignore[union-attr] + f"toast missing aria-live=polite, got: {toast.get('aria-live')!r}" # type: ignore[union-attr] + ) + + +def test_html_scripts() -> None: + """src=/app.js, src=/terminal.js, xterm.""" + soup = _SOUP + scripts = soup.find_all("script") + srcs = [str(s.get("src", "")) for s in scripts] + assert any("/app.js" in s for s in srcs), ( + f"Missing script src=/app.js; found: {srcs}" + ) + assert any("/terminal.js" in s for s in srcs), ( + f"Missing script src=/terminal.js; found: {srcs}" + ) + assert any("xterm" in s for s in srcs), f"Missing xterm script; found: {srcs}" + + +def test_html_xterm_css() -> None: + """xterm.css CDN link present.""" + soup = _SOUP + links = soup.find_all("link", rel="stylesheet") + hrefs = [str(lnk.get("href", "")) for lnk in links] + assert any("xterm.css" in h for h in hrefs), ( + f"Missing xterm.css link; found: {hrefs}" + ) + + +def test_html_style_css() -> None: + """href=/style.css present.""" + soup = _SOUP + links = soup.find_all("link", rel="stylesheet") + hrefs = [str(lnk.get("href", "")) for lnk in links] + assert any("/style.css" in h for h in hrefs), ( + f"Missing /style.css link; found: {hrefs}" + ) + + +def test_html_element_classes() -> None: + """Critical and important elements must carry their CSS styling classes.""" + soup = _SOUP + cases = [ + # (element_id, required_class, reason) + ("terminal-container", "terminal-container", "xterm.js needs flex:1 to render"), + ( + "reconnect-overlay", + "reconnect-overlay", + "needs position:absolute to overlay terminal", + ), + ("session-pill", "session-pill", "needs position:fixed to float"), + ("toast", "toast", "needs position:fixed and animation"), + ("back-btn", "back-btn", "needs border and hover styles"), + ("palette-trigger", "palette-trigger", "needs border and hover styles"), + ( + "expanded-session-name", + "expanded-session-name", + "needs text-overflow:ellipsis", + ), + ("session-pill-label", "session-pill__label", "needs max-width truncation"), + ("session-pill-bell", "session-pill__bell", "needs amber var(--bell) color"), + ] + for el_id, expected_class, reason in cases: + el = soup.find(id=el_id) + assert el is not None, f"#{el_id} not found in HTML" + classes = el.get("class") or [] + assert expected_class in classes, ( + f"#{el_id} is missing class '{expected_class}' — {reason}. Has: {classes}" + ) diff --git a/coordinator/tests/test_integration.py b/coordinator/tests/test_integration.py new file mode 100644 index 0000000..d9bb5b5 --- /dev/null +++ b/coordinator/tests/test_integration.py @@ -0,0 +1,213 @@ +""" +Integration tests for the tmux-web coordinator. + +These tests require a real tmux installation and spin up an isolated tmux +server on socket 'test-server' for the duration of the module. + +Run with: + pytest -m integration -v + +Default test run (unit tests only): + pytest -v +""" + +import asyncio +import json +import subprocess +from unittest.mock import patch + +import pytest + +import coordinator.state as state_mod +from coordinator.bells import poll_bell_flag +from coordinator.main import _run_poll_cycle +from coordinator.sessions import enumerate_sessions, get_snapshots + + +# --------------------------------------------------------------------------- +# Helper function +# --------------------------------------------------------------------------- + + +def tmux(socket: str, *args: str) -> str: + """Run a tmux command against the specified socket and return stdout.""" + result = subprocess.run( + ["tmux", "-L", socket, *args], + capture_output=True, + text=True, + ) + return result.stdout + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def tmux_server(): + """Start an isolated tmux server on socket 'test-server', create session 'test' (220x50). + + Sets monitor-bell on so that bell characters sent to the session are detected. + Tears down the server after all module tests complete. + """ + socket = "test-server" + # Start a new tmux server with an isolated socket and create the test session + subprocess.run( + [ + "tmux", + "-L", + socket, + "new-session", + "-d", + "-s", + "test", + "-x", + "220", + "-y", + "50", + ], + check=True, + ) + # Enable bell monitoring so window_bell_flag is set when a bell is received + subprocess.run( + ["tmux", "-L", socket, "set-window-option", "-t", "test", "monitor-bell", "on"], + check=True, + ) + yield socket + # Teardown: kill the isolated server (suppress errors if already dead) + subprocess.run( + ["tmux", "-L", socket, "kill-server"], + capture_output=True, + ) + + +@pytest.fixture(autouse=True) +def use_tmp_state(tmp_path, monkeypatch): + """Redirect state and PID files to tmp_path for test isolation.""" + tmp_state_dir = tmp_path / "state" + tmp_state_path = tmp_state_dir / "state.json" + monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir) + monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path) + + tmp_pid_dir = tmp_path / "ttyd" + tmp_pid_path = tmp_pid_dir / "ttyd.pid" + monkeypatch.setattr("coordinator.ttyd.TTYD_PID_DIR", tmp_pid_dir) + monkeypatch.setattr("coordinator.ttyd.TTYD_PID_PATH", tmp_pid_path) + + +# --------------------------------------------------------------------------- +# Internal helper: patched run_tmux that uses the isolated test socket +# --------------------------------------------------------------------------- + + +def make_run_tmux_for_socket(socket: str): + """Return an async run_tmux substitute that routes all tmux calls through *socket*. + + Prepends ``-L `` to every tmux invocation so the test server + is used instead of the default server. + """ + + async def patched_run_tmux(*args: str) -> str: + proc = await asyncio.create_subprocess_exec( + "tmux", + "-L", + socket, + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout_bytes, stderr_bytes = await proc.communicate() + if proc.returncode != 0: + raise RuntimeError(stderr_bytes.decode("utf-8", errors="replace")) + return stdout_bytes.decode("utf-8", errors="replace") + + return patched_run_tmux + + +# --------------------------------------------------------------------------- +# Integration tests +# --------------------------------------------------------------------------- + + +@pytest.mark.integration +async def test_enumerate_sessions_finds_test_session(tmux_server): + """enumerate_sessions discovers the 'test' session on the isolated tmux server.""" + patched_run_tmux = make_run_tmux_for_socket(tmux_server) + with patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux): + sessions = await enumerate_sessions() + assert "test" in sessions + + +@pytest.mark.integration +async def test_capture_pane_returns_content(tmux_server): + """tmux capture-pane returns output that includes what was echoed to the session.""" + tmux(tmux_server, "send-keys", "-t", "test", "echo hello-world", "Enter") + await asyncio.sleep(0.5) + + # Use the tmux helper directly: capture-pane -p captures the pane content to stdout + content = tmux(tmux_server, "capture-pane", "-p", "-t", "test") + assert "hello-world" in content + + +@pytest.mark.integration +async def test_bell_flag_detected_after_printf_bell(tmux_server): + """poll_bell_flag returns True after a bell character is sent to the test session.""" + tmux(tmux_server, "send-keys", "-t", "test", r"printf '\a'", "Enter") + # Allow tmux time to propagate the bell and set window_bell_flag + await asyncio.sleep(1.0) + + patched_run_tmux = make_run_tmux_for_socket(tmux_server) + with patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux): + result = await poll_bell_flag("test") + assert result is True + + +@pytest.mark.integration +async def test_full_poll_cycle_via_api(tmux_server): + """_run_poll_cycle with patched run_tmux adds 'test' to session_order in state + and populates the in-memory snapshot cache with non-empty content.""" + patched_run_tmux = make_run_tmux_for_socket(tmux_server) + with ( + patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux), + patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux), + ): + await _run_poll_cycle() + + state = state_mod.load_state() + assert "test" in state["session_order"] + + # Verify snapshots were captured and stored — Critical Issue #1 regression guard. + # If snapshot_all() return value is discarded, get_snapshots() returns {} and + # snapshots["test"] falls back to "", causing this assertion to fail. + snapshots = get_snapshots() + assert "test" in snapshots, ( + "snapshot cache must contain an entry for the 'test' session" + ) + + +@pytest.mark.integration +async def test_state_file_written_atomically_by_poll_cycle(tmux_server): + """After _run_poll_cycle, state.json exists, no .tmp file remains, content is valid JSON.""" + patched_run_tmux = make_run_tmux_for_socket(tmux_server) + with ( + patch("coordinator.sessions.run_tmux", side_effect=patched_run_tmux), + patch("coordinator.bells.run_tmux", side_effect=patched_run_tmux), + ): + await _run_poll_cycle() + + state_path = state_mod.STATE_PATH + tmp_path = state_mod.STATE_PATH.parent / (state_mod.STATE_PATH.name + ".tmp") + + # state.json must exist after a successful poll cycle + assert state_path.exists(), "state.json was not written by _run_poll_cycle" + + # The temporary file must be gone (atomic write completed) + assert not tmp_path.exists(), ( + ".tmp file was left behind (atomic write may have failed)" + ) + + # File content must be valid JSON + content = state_path.read_text() + data = json.loads(content) + assert isinstance(data, dict), "state.json does not contain a JSON object" diff --git a/coordinator/tests/test_sessions.py b/coordinator/tests/test_sessions.py new file mode 100644 index 0000000..3a6936a --- /dev/null +++ b/coordinator/tests/test_sessions.py @@ -0,0 +1,251 @@ +""" +Tests for coordinator/sessions.py — tmux session enumeration and helpers. +All 6 acceptance-criteria tests are defined here. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import coordinator.sessions as sessions_mod +from coordinator.sessions import ( + capture_pane, + enumerate_sessions, + get_snapshots, + get_session_list, + run_tmux, + snapshot_all, + update_session_cache, +) + + +# --------------------------------------------------------------------------- +# Helpers for mocking asyncio.create_subprocess_exec +# --------------------------------------------------------------------------- + + +def _make_mock_process(stdout: str, stderr: str = "", returncode: int = 0): + """Return a mock process whose communicate() returns encoded strings.""" + proc = MagicMock() + proc.returncode = returncode + proc.communicate = AsyncMock(return_value=(stdout.encode(), stderr.encode())) + return proc + + +@pytest.fixture +def mock_subprocess(): + """Fixture factory: returns a context-manager patch for asyncio.create_subprocess_exec. + + Usage:: + + with mock_subprocess(stdout="...") as mock_create: + await some_function() + """ + + def _factory(stdout: str = "", stderr: str = "", returncode: int = 0): + proc = _make_mock_process(stdout, stderr, returncode) + return patch("asyncio.create_subprocess_exec", new=AsyncMock(return_value=proc)) + + return _factory + + +# --------------------------------------------------------------------------- +# run_tmux tests +# --------------------------------------------------------------------------- + + +async def test_run_tmux_calls_correct_command(mock_subprocess): + """run_tmux('list-sessions', '-F', '#{session_name}') must call tmux + with exactly those positional arguments via asyncio.create_subprocess_exec.""" + with mock_subprocess("session1\nsession2\n") as mock_create: + await run_tmux("list-sessions", "-F", "#{session_name}") + + # First positional arg must be 'tmux'; rest must be the args we passed. + call_args = mock_create.call_args[0] + assert call_args[0] == "tmux" + assert call_args[1] == "list-sessions" + assert call_args[2] == "-F" + assert call_args[3] == "#{session_name}" + + +async def test_run_tmux_raises_on_nonzero_exit(mock_subprocess): + """run_tmux() must raise RuntimeError when the subprocess exits non-zero.""" + with mock_subprocess( + stdout="", stderr="no server running on /tmp/tmux-1000/default", returncode=1 + ): + with pytest.raises(RuntimeError, match="no server running"): + await run_tmux("list-sessions", "-F", "#{session_name}") + + +# --------------------------------------------------------------------------- +# enumerate_sessions tests +# --------------------------------------------------------------------------- + + +async def test_enumerate_sessions_parses_newline_output(mock_subprocess): + """enumerate_sessions() splits newline-separated output into a list of names.""" + with mock_subprocess("alpha\nbeta\ngamma\n"): + result = await enumerate_sessions() + + assert result == ["alpha", "beta", "gamma"] + + +async def test_enumerate_sessions_returns_empty_list_when_no_sessions(mock_subprocess): + """enumerate_sessions() returns [] when tmux output is empty.""" + with mock_subprocess(""): + result = await enumerate_sessions() + + assert result == [] + + +async def test_enumerate_sessions_strips_whitespace(mock_subprocess): + """enumerate_sessions() strips leading/trailing whitespace from each name.""" + with mock_subprocess(" session1 \n session2 \n"): + result = await enumerate_sessions() + + assert result == ["session1", "session2"] + + +async def test_enumerate_sessions_handles_tmux_error(mock_subprocess): + """enumerate_sessions() returns [] when run_tmux raises RuntimeError + (e.g. tmux server not running).""" + with mock_subprocess(stdout="", stderr="no server running", returncode=1): + result = await enumerate_sessions() + + assert result == [] + + +# --------------------------------------------------------------------------- +# capture_pane tests +# --------------------------------------------------------------------------- + + +async def test_capture_pane_returns_output(mock_subprocess): + """capture_pane() returns the text output from tmux capture-pane.""" + with mock_subprocess("line1\nline2\nline3\n"): + result = await capture_pane("my-session") + + assert result == "line1\nline2\nline3\n" + + +async def test_capture_pane_returns_empty_string_on_error(mock_subprocess): + """capture_pane() returns '' when tmux exits with an error.""" + with mock_subprocess( + stdout="", stderr="can't find session my-session", returncode=1 + ): + result = await capture_pane("my-session") + + assert result == "" + + +async def test_capture_pane_calls_correct_tmux_args(mock_subprocess): + """capture_pane() calls tmux with: capture-pane -p -t -S -. + + Uses -S -N (start N lines from bottom) to limit output. + Does NOT pass -e (escape sequences) or -l (invalid in tmux 3.4). + """ + with mock_subprocess("output text\n") as mock_create: + await capture_pane("target-session", lines=50) + + call_args = mock_create.call_args[0] + assert call_args[0] == "tmux" + assert call_args[1] == "capture-pane" + assert call_args[2] == "-p" + assert call_args[3] == "-t" + assert call_args[4] == "target-session" + assert call_args[5] == "-S" + assert call_args[6] == "-50" + assert len(call_args) == 7, "No extra args — -e and -l must not be present" + + +# --------------------------------------------------------------------------- +# snapshot_all tests +# --------------------------------------------------------------------------- + + +async def test_snapshot_all_returns_dict_keyed_by_name(): + """snapshot_all() returns a dict mapping each session name to its pane output.""" + + async def mock_capture(name, lines=30): + return f"output-for-{name}" + + with patch("coordinator.sessions.capture_pane", side_effect=mock_capture): + result = await snapshot_all(["alpha", "beta", "gamma"]) + + assert result == { + "alpha": "output-for-alpha", + "beta": "output-for-beta", + "gamma": "output-for-gamma", + } + + +async def test_snapshot_all_returns_empty_dict_for_empty_input(): + """snapshot_all([]) returns an empty dict without calling capture_pane.""" + with patch("coordinator.sessions.capture_pane", new=AsyncMock()) as mock_capture: + result = await snapshot_all([]) + + assert result == {} + mock_capture.assert_not_called() + + +async def test_snapshot_all_returns_empty_string_on_individual_failure(): + """snapshot_all() maps '' for a failing session while others still succeed.""" + + async def mock_capture(name, lines=30): + if name == "bad-session": + raise RuntimeError("pane not found") + return f"output-for-{name}" + + with patch("coordinator.sessions.capture_pane", side_effect=mock_capture): + result = await snapshot_all(["session-a", "bad-session", "session-b"]) + + assert result == { + "session-a": "output-for-session-a", + "bad-session": "", + "session-b": "output-for-session-b", + } + + +# --------------------------------------------------------------------------- +# update_session_cache tests +# --------------------------------------------------------------------------- + + +def test_update_session_cache_populates_snapshots(): + """update_session_cache(names, snapshots) must replace _snapshots with provided dict. + + This is the RED test for Critical Issue #1: previously, update_session_cache + only accepted names and never received the snapshots dict, so _snapshots + stayed empty forever. + """ + # Reset module state to simulate a fresh start + sessions_mod._snapshots = {} + sessions_mod._session_list = [] + + update_session_cache( + ["sess1", "sess2"], {"sess1": "line1\nline2", "sess2": "hello"} + ) + + result = get_snapshots() + assert result == {"sess1": "line1\nline2", "sess2": "hello"} + + +def test_update_session_cache_updates_session_list(): + """update_session_cache() must also replace _session_list with the given names.""" + sessions_mod._snapshots = {} + sessions_mod._session_list = ["old-session"] + + update_session_cache(["alpha", "beta"], {"alpha": "a", "beta": "b"}) + + assert get_session_list() == ["alpha", "beta"] + + +def test_update_session_cache_empty_names_clears_caches(): + """update_session_cache([], {}) clears both caches.""" + sessions_mod._snapshots = {"stale": "text"} + sessions_mod._session_list = ["stale"] + + update_session_cache([], {}) + + assert get_session_list() == [] + assert get_snapshots() == {} diff --git a/coordinator/tests/test_state.py b/coordinator/tests/test_state.py new file mode 100644 index 0000000..d23697f --- /dev/null +++ b/coordinator/tests/test_state.py @@ -0,0 +1,311 @@ +""" +Tests for coordinator/state.py — state schema factories and I/O. +All acceptance-criteria tests are defined here. +""" + +import asyncio +import time +from pathlib import Path + +import pytest + +from coordinator.state import ( + empty_bell, + empty_device, + empty_state, + prune_devices, + read_state, + register_device, + state_lock, + write_state, +) + + +# --------------------------------------------------------------------------- +# autouse fixture — redirect STATE_DIR and STATE_PATH to a tmp directory +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def use_tmp_state_dir(tmp_path, monkeypatch): + """Redirect state I/O to a fresh temp directory for every test.""" + tmp_state_dir = tmp_path / "state" + tmp_state_path = tmp_state_dir / "state.json" + monkeypatch.setattr("coordinator.state.STATE_DIR", tmp_state_dir) + monkeypatch.setattr("coordinator.state.STATE_PATH", tmp_state_path) + + +# --------------------------------------------------------------------------- +# empty_state() +# --------------------------------------------------------------------------- + + +def test_empty_state_has_required_top_level_keys(): + state = empty_state() + assert "active_session" in state + assert "session_order" in state + assert "sessions" in state + assert "devices" in state + + +def test_empty_state_active_session_is_none(): + state = empty_state() + assert state["active_session"] is None + + +def test_empty_state_session_order_is_empty_list(): + state = empty_state() + assert state["session_order"] == [] + + +def test_empty_state_sessions_is_empty_dict(): + state = empty_state() + assert state["sessions"] == {} + + +def test_empty_state_devices_is_empty_dict(): + state = empty_state() + assert state["devices"] == {} + + +def test_empty_state_returns_independent_dicts(): + """Mutating one state must not affect another.""" + s1 = empty_state() + s2 = empty_state() + + s1["session_order"].append("my-session") + s1["sessions"]["foo"] = {} + s1["devices"]["bar"] = {} + + assert s2["session_order"] == [] + assert s2["sessions"] == {} + assert s2["devices"] == {} + + +# --------------------------------------------------------------------------- +# empty_bell() +# --------------------------------------------------------------------------- + + +def test_empty_bell_has_required_keys(): + bell = empty_bell() + assert "last_fired_at" in bell + assert "seen_at" in bell + assert "unseen_count" in bell + + +def test_empty_bell_last_fired_at_is_none(): + bell = empty_bell() + assert bell["last_fired_at"] is None + + +def test_empty_bell_seen_at_is_none(): + bell = empty_bell() + assert bell["seen_at"] is None + + +def test_empty_bell_unseen_count_is_zero(): + bell = empty_bell() + assert bell["unseen_count"] == 0 + + +# --------------------------------------------------------------------------- +# empty_device(device_id, label) +# --------------------------------------------------------------------------- + + +def test_empty_device_has_required_keys(): + device = empty_device("dev-1", "My Laptop") + assert "label" in device + assert "viewing_session" in device + assert "view_mode" in device + assert "last_interaction_at" in device + assert "last_heartbeat_at" in device + + +def test_empty_device_label_set_correctly(): + device = empty_device("dev-1", "My Laptop") + assert device["label"] == "My Laptop" + + +def test_empty_device_viewing_session_is_none(): + device = empty_device("dev-1", "My Laptop") + assert device["viewing_session"] is None + + +def test_empty_device_view_mode_is_grid(): + device = empty_device("dev-1", "My Laptop") + assert device["view_mode"] == "grid" + + +def test_empty_device_timestamps_are_recent(): + """last_interaction_at and last_heartbeat_at should be close to now.""" + before = time.time() + device = empty_device("dev-1", "My Laptop") + after = time.time() + + assert before <= device["last_interaction_at"] <= after + assert before <= device["last_heartbeat_at"] <= after + + +# --------------------------------------------------------------------------- +# state_lock +# --------------------------------------------------------------------------- + + +def test_state_lock_is_asyncio_lock(): + assert isinstance(state_lock, asyncio.Lock) + + +# --------------------------------------------------------------------------- +# load_state / save_state / read_state / write_state +# --------------------------------------------------------------------------- + + +async def test_read_state_returns_empty_when_no_file(): + """read_state() returns empty_state() when STATE_PATH does not exist.""" + state = await read_state() + assert state == empty_state() + + +async def test_write_then_read_roundtrip(): + """write_state() persists state; subsequent read_state() returns it intact.""" + original = empty_state() + original["active_session"] = "my-session" + original["session_order"] = ["my-session"] + await write_state(original) + loaded = await read_state() + assert loaded == original + + +async def test_write_creates_state_dir_if_missing(): + """save_state() must create STATE_DIR (and parents) if they do not exist.""" + import coordinator.state as state_mod + + # The tmp "state" subdirectory should not exist yet. + assert not state_mod.STATE_DIR.exists() + await write_state(empty_state()) + assert state_mod.STATE_DIR.exists() + + +async def test_write_is_atomic_no_tmp_file_left(): + """After write_state(), no .tmp file should remain on disk.""" + import coordinator.state as state_mod + + await write_state(empty_state()) + tmp_file = Path(str(state_mod.STATE_PATH) + ".tmp") + assert not tmp_file.exists() + + +async def test_concurrent_writes_do_not_corrupt(): + """Two concurrent write_state() calls must leave valid JSON matching one write.""" + state_a = empty_state() + state_a["active_session"] = "session-a" + + state_b = empty_state() + state_b["active_session"] = "session-b" + + await asyncio.gather( + write_state(state_a), + write_state(state_b), + ) + + final = await read_state() + # Final state must be exactly one of the two known states — no corruption. + assert final == state_a or final == state_b + + +# --------------------------------------------------------------------------- +# register_device() +# --------------------------------------------------------------------------- + + +def test_register_device_adds_new_device(): + """register_device() creates a new device entry if device_id is not present.""" + state = empty_state() + register_device(state, "dev-1", "My Laptop", None, "grid", time.time()) + assert "dev-1" in state["devices"] + + +def test_register_device_updates_existing_device(): + """register_device() updates label, viewing_session, view_mode, last_interaction_at.""" + state = empty_state() + now = time.time() + register_device(state, "dev-1", "Old Label", "session-a", "grid", now) + + later = now + 10 + register_device(state, "dev-1", "New Label", "session-b", "fullscreen", later) + + device = state["devices"]["dev-1"] + assert device["label"] == "New Label" + assert device["viewing_session"] == "session-b" + assert device["view_mode"] == "fullscreen" + assert device["last_interaction_at"] == later + + +def test_register_device_sets_heartbeat_timestamp(): + """register_device() always refreshes last_heartbeat_at to current time.time().""" + state = empty_state() + before = time.time() + register_device(state, "dev-1", "My Laptop", None, "grid", before) + after = time.time() + + device = state["devices"]["dev-1"] + assert before <= device["last_heartbeat_at"] <= after + + +# --------------------------------------------------------------------------- +# prune_devices() +# --------------------------------------------------------------------------- + + +def test_prune_devices_removes_stale(): + """prune_devices() removes devices whose last_heartbeat_at is older than ttl.""" + state = empty_state() + old_time = time.time() - 400 # 400s ago, beyond default 300s TTL + state["devices"]["stale-dev"] = { + "label": "Stale", + "viewing_session": None, + "view_mode": "grid", + "last_interaction_at": old_time, + "last_heartbeat_at": old_time, + } + prune_devices(state) + assert "stale-dev" not in state["devices"] + + +def test_prune_devices_keeps_fresh(): + """prune_devices() keeps devices whose last_heartbeat_at is within ttl.""" + state = empty_state() + recent_time = time.time() - 100 # 100s ago, within default 300s TTL + state["devices"]["fresh-dev"] = { + "label": "Fresh", + "viewing_session": None, + "view_mode": "grid", + "last_interaction_at": recent_time, + "last_heartbeat_at": recent_time, + } + prune_devices(state) + assert "fresh-dev" in state["devices"] + + +def test_prune_devices_returns_list_of_removed_ids(): + """prune_devices() returns the list of device IDs that were removed.""" + state = empty_state() + old_time = time.time() - 400 + state["devices"]["stale-1"] = { + "label": "Stale 1", + "viewing_session": None, + "view_mode": "grid", + "last_interaction_at": old_time, + "last_heartbeat_at": old_time, + } + state["devices"]["stale-2"] = { + "label": "Stale 2", + "viewing_session": None, + "view_mode": "grid", + "last_interaction_at": old_time, + "last_heartbeat_at": old_time, + } + removed = prune_devices(state) + assert sorted(removed) == ["stale-1", "stale-2"] diff --git a/coordinator/tests/test_ttyd.py b/coordinator/tests/test_ttyd.py new file mode 100644 index 0000000..64f3de4 --- /dev/null +++ b/coordinator/tests/test_ttyd.py @@ -0,0 +1,232 @@ +""" +Tests for coordinator/ttyd.py — ttyd process lifecycle management. +All 11 acceptance-criteria tests are defined here. +""" + +import signal +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +import coordinator.ttyd as ttyd_mod +from coordinator.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd + + +# --------------------------------------------------------------------------- +# autouse fixture — redirect PID paths to tmp_path for every test +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def use_tmp_pid_dir(tmp_path, monkeypatch): + """Redirect PID file I/O to a fresh temp directory for every test.""" + tmp_pid_dir = tmp_path / "ttyd" + tmp_pid_path = tmp_pid_dir / "ttyd.pid" + monkeypatch.setattr("coordinator.ttyd.TTYD_PID_DIR", tmp_pid_dir) + monkeypatch.setattr("coordinator.ttyd.TTYD_PID_PATH", tmp_pid_path) + + +# --------------------------------------------------------------------------- +# Helper for mocking asyncio.create_subprocess_exec +# --------------------------------------------------------------------------- + + +def _make_mock_ttyd_process(pid: int = 12345): + """Return a mock ttyd process with the given PID.""" + proc = MagicMock() + proc.pid = pid + return proc + + +# --------------------------------------------------------------------------- +# spawn_ttyd tests +# --------------------------------------------------------------------------- + + +async def test_spawn_ttyd_writes_pid_file(): + """spawn_ttyd() must write the process PID to TTYD_PID_PATH.""" + mock_proc = _make_mock_ttyd_process(pid=99999) + + with patch( + "asyncio.create_subprocess_exec", + new=AsyncMock(return_value=mock_proc), + ): + await spawn_ttyd("my-session") + + pid_path = ttyd_mod.TTYD_PID_PATH + assert pid_path.exists(), "PID file was not created" + assert pid_path.read_text().strip() == "99999" + + +async def test_spawn_ttyd_uses_correct_command(): + """spawn_ttyd() must call ttyd with args: -W -m 3 -p 7682 tmux attach -t .""" + mock_proc = _make_mock_ttyd_process(pid=54321) + + with patch( + "asyncio.create_subprocess_exec", + new=AsyncMock(return_value=mock_proc), + ) as mock_create: + await spawn_ttyd("test-session") + + call_args = mock_create.call_args[0] + assert list(call_args) == [ + "ttyd", + "-W", + "-m", + "3", + "-p", + "7682", + "tmux", + "attach", + "-t", + "test-session", + ] + + +async def test_spawn_ttyd_returns_process_object(): + """spawn_ttyd() must return the process object from create_subprocess_exec.""" + mock_proc = _make_mock_ttyd_process(pid=11111) + + with patch( + "asyncio.create_subprocess_exec", + new=AsyncMock(return_value=mock_proc), + ): + result = await spawn_ttyd("another-session") + + assert result is mock_proc + + +# --------------------------------------------------------------------------- +# kill_ttyd tests +# --------------------------------------------------------------------------- + + +async def test_kill_ttyd_returns_false_when_no_pid_file(): + """kill_ttyd() returns False when no PID file exists.""" + # autouse fixture ensures no PID file is present + result = await kill_ttyd() + assert result is False + + +async def test_kill_ttyd_reads_pid_file_and_sends_sigterm(): + """kill_ttyd() reads the PID file and sends SIGTERM to the running process.""" + pid_path = ttyd_mod.TTYD_PID_PATH + pid_path.parent.mkdir(parents=True, exist_ok=True) + pid_path.write_text("12345") + + kill_calls = [] + + def mock_os_kill(pid, sig): + kill_calls.append((pid, sig)) + # First existence-check (sig=0) succeeds; subsequent sig=0 calls raise + if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1: + raise ProcessLookupError + + with patch("os.kill", side_effect=mock_os_kill): + result = await kill_ttyd() + + assert result is True + sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM] + assert len(sigterm_calls) == 1 + assert sigterm_calls[0][0] == 12345 + + +async def test_kill_ttyd_removes_pid_file(): + """kill_ttyd() removes the PID file regardless of whether process was alive.""" + pid_path = ttyd_mod.TTYD_PID_PATH + pid_path.parent.mkdir(parents=True, exist_ok=True) + pid_path.write_text("12345") + + def mock_os_kill(pid, sig): + # All os.kill calls raise ProcessLookupError — simulates already-dead process + raise ProcessLookupError + + with patch("os.kill", side_effect=mock_os_kill): + result = await kill_ttyd() + + assert result is True + assert not pid_path.exists(), "PID file should be removed after kill_ttyd()" + + +async def test_kill_ttyd_handles_process_already_dead(): + """kill_ttyd() returns True and clears state when process is already gone.""" + pid_path = ttyd_mod.TTYD_PID_PATH + pid_path.parent.mkdir(parents=True, exist_ok=True) + pid_path.write_text("99999") + + # Simulate process already dead: os.kill(pid, 0) raises ProcessLookupError + with patch("os.kill", side_effect=ProcessLookupError): + result = await kill_ttyd() + + assert result is True + assert not pid_path.exists(), ( + "PID file should be removed when process was already dead" + ) + assert ttyd_mod._active_process is None + + +# --------------------------------------------------------------------------- +# kill_orphan_ttyd tests +# +# kill_orphan_ttyd() is a thin delegation to kill_ttyd(). These tests verify +# both the delegation wiring and that behaviour is consistent with kill_ttyd(). +# --------------------------------------------------------------------------- + + +async def test_kill_orphan_ttyd_returns_false_when_no_pid_file(): + """kill_orphan_ttyd() returns False when no PID file exists (no orphan).""" + # autouse fixture ensures no PID file is present + result = await kill_orphan_ttyd() + assert result is False + + +async def test_kill_orphan_ttyd_kills_running_process(): + """kill_orphan_ttyd() kills a running orphan process and returns True.""" + pid_path = ttyd_mod.TTYD_PID_PATH + pid_path.parent.mkdir(parents=True, exist_ok=True) + pid_path.write_text("55555") + + kill_calls = [] + + def mock_os_kill(pid, sig): + kill_calls.append((pid, sig)) + # First existence-check (sig=0) succeeds; subsequent sig=0 calls raise + if sig == 0 and sum(1 for _, s in kill_calls if s == 0) > 1: + raise ProcessLookupError + + with patch("os.kill", side_effect=mock_os_kill): + result = await kill_orphan_ttyd() + + assert result is True + assert not pid_path.exists(), "PID file should be removed after kill_orphan_ttyd()" + sigterm_calls = [(pid, sig) for pid, sig in kill_calls if sig == signal.SIGTERM] + assert len(sigterm_calls) == 1 + assert sigterm_calls[0][0] == 55555 + + +async def test_kill_orphan_ttyd_handles_pid_file_with_dead_process(): + """kill_orphan_ttyd() handles a stale PID file whose process is already gone.""" + pid_path = ttyd_mod.TTYD_PID_PATH + pid_path.parent.mkdir(parents=True, exist_ok=True) + pid_path.write_text("77777") + + with patch("os.kill", side_effect=ProcessLookupError): + result = await kill_orphan_ttyd() + + assert result is True + assert not pid_path.exists(), "PID file should be removed after orphan cleanup" + + +async def test_kill_orphan_ttyd_handles_invalid_pid_file_content(): + """kill_orphan_ttyd() gracefully handles a PID file with non-integer content.""" + pid_path = ttyd_mod.TTYD_PID_PATH + pid_path.parent.mkdir(parents=True, exist_ok=True) + pid_path.write_text("not-a-pid") + + # Should not raise and should clean up the file. + # kill_ttyd() calls pid_path.unlink(missing_ok=True) before returning False + # on invalid content, so the file is removed even though no kill occurred. + result = await kill_orphan_ttyd() + + assert result is False + assert not pid_path.exists(), "Invalid PID file should be removed" diff --git a/coordinator/ttyd.py b/coordinator/ttyd.py new file mode 100644 index 0000000..0a372da --- /dev/null +++ b/coordinator/ttyd.py @@ -0,0 +1,156 @@ +""" +ttyd process lifecycle management for the tmux-web coordinator. + +Constants: + TTYD_PID_DIR — directory for the PID file (default: ~/.local/share/tmux-web/) + TTYD_PID_PATH — full path to the PID file (TTYD_PID_DIR / 'ttyd.pid') + TTYD_PORT — port ttyd listens on (7682) + +Module state: + _active_process — the currently running ttyd subprocess (or None) + +Public API: + spawn_ttyd(session_name) — spawn ttyd attached to a tmux session, write PID file + kill_ttyd() — kill the running ttyd process, clean up PID file + kill_orphan_ttyd() — kill any orphaned ttyd from a previous coordinator run +""" + +import asyncio +import os +import signal +import time +from pathlib import Path + +# --------------------------------------------------------------------------- +# Paths and constants +# --------------------------------------------------------------------------- + +_default_ttyd_pid_dir = Path.home() / ".local" / "share" / "tmux-web" +TTYD_PID_DIR: Path = Path(os.environ.get("TMUX_WEB_STATE_DIR", _default_ttyd_pid_dir)) +TTYD_PID_PATH: Path = TTYD_PID_DIR / "ttyd.pid" + +TTYD_PORT: int = 7682 + +# --------------------------------------------------------------------------- +# Module state +# --------------------------------------------------------------------------- + +_active_process: asyncio.subprocess.Process | None = None + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +async def kill_ttyd() -> bool: + """Kill the running ttyd process and clean up the PID file. + + Reads the PID from TTYD_PID_PATH. If no PID file exists, returns False. + If the file content is not a valid integer, removes the file and returns False. + + Checks whether the process is alive via ``os.kill(pid, 0)``. If the + process is already gone (ProcessLookupError), cleans up and returns True. + Otherwise sends SIGTERM and polls every 0.1 s for up to 2 s waiting for + the process to exit. The PID file and ``_active_process`` are cleared in + all cases before returning. + + Uses ``asyncio.sleep`` for polling to avoid blocking the event loop. + + Returns: + True — process was killed or was already dead. + False — no PID file found, or PID file contained invalid content. + """ + global _active_process + + if not TTYD_PID_PATH.exists(): + return False + + try: + pid = int(TTYD_PID_PATH.read_text().strip()) + except ValueError: + TTYD_PID_PATH.unlink(missing_ok=True) + return False + + # Check whether the process is still alive. + try: + os.kill(pid, 0) + except ProcessLookupError: + # Already dead — clean up and report success. + TTYD_PID_PATH.unlink(missing_ok=True) + _active_process = None + return True + + # Process is alive — ask it to terminate. + os.kill(pid, signal.SIGTERM) + + # Poll up to 2 s for the process to exit, yielding to the event loop each iteration. + deadline = time.time() + 2.0 + while time.time() < deadline: + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError): + break + await asyncio.sleep(0.1) + + # Always clean up regardless of whether the process exited in time. + TTYD_PID_PATH.unlink(missing_ok=True) + _active_process = None + return True + + +async def kill_orphan_ttyd() -> bool: + """Kill any orphaned ttyd process left over from a previous coordinator run. + + On coordinator startup, checks for a stale PID file from a previous run. + If found, kills the process (if still running) and removes the PID file. + Prevents two ttyd instances running simultaneously after a coordinator + restart or crash. + + Delegates to kill_ttyd() for all process management and PID file cleanup. + + Returns: + True — an orphan was found (process was dead or alive). + False — no PID file found, or PID file contained invalid content. + """ + return await kill_ttyd() + + +async def spawn_ttyd(session_name: str) -> asyncio.subprocess.Process: + """Spawn a ttyd process attached to *session_name* via ``tmux attach``. + + Runs:: + + ttyd -W -m 3 -p 7682 tmux attach -t + + stdout and stderr are discarded (DEVNULL). The PID is written to + TTYD_PID_PATH. The process handle is stored in ``_active_process``. + + Args: + session_name: The tmux session name to attach to. + + Returns: + The asyncio.subprocess.Process object for the spawned ttyd. + """ + global _active_process + + proc = await asyncio.create_subprocess_exec( + "ttyd", + "-W", + "-m", + "3", + "-p", + str(TTYD_PORT), + "tmux", + "attach", + "-t", + session_name, + stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.DEVNULL, + ) + + # Write PID file (create parent dirs if needed) + TTYD_PID_DIR.mkdir(parents=True, exist_ok=True) + TTYD_PID_PATH.write_text(str(proc.pid)) + + _active_process = proc + return proc diff --git a/docs/plans/2026-03-26-web-tmux-dashboard-design.md b/docs/plans/2026-03-26-web-tmux-dashboard-design.md new file mode 100644 index 0000000..a5e40f6 --- /dev/null +++ b/docs/plans/2026-03-26-web-tmux-dashboard-design.md @@ -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 ` (polled every 2s) into styled monospace `
` 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 `
` 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 `
` 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.
diff --git a/docs/plans/2026-03-26-web-tmux-phase1-backend.md b/docs/plans/2026-03-26-web-tmux-phase1-backend.md
new file mode 100644
index 0000000..ec4a55c
--- /dev/null
+++ b/docs/plans/2026-03-26-web-tmux-phase1-backend.md
@@ -0,0 +1,3489 @@
+# Web-Tmux Dashboard — Phase 1: Backend Implementation Plan
+
+> **Execution:** Use the subagent-driven-development workflow to implement this plan.
+
+**Goal:** Build the Python coordinator backend — session enumeration, state management, ttyd lifecycle, bell detection, and all JSON API endpoints — fully tested, with no frontend code.
+
+**Architecture:** A FastAPI application runs a 2-second background poll loop that enumerates tmux sessions via `tmux list-sessions`, captures pane output via `capture-pane`, checks bell flags, and atomically writes state to `~/.local/share/tmux-web/state.json`. A single ttyd process (one session at a time, port 7682) is spawned and killed via `asyncio.create_subprocess_exec`. All concurrent state mutations serialize through an `asyncio.Lock`.
+
+**Tech Stack:** Python 3.12, FastAPI 0.115+, uvicorn, pytest 8+, pytest-asyncio 0.23+, httpx (for TestClient), standard library only beyond that.
+
+---
+
+## File Map
+
+All files live under `/home/bkrabach/dev/web-tmux/`. Create every directory and file listed — none exist yet.
+
+```
+/home/bkrabach/dev/web-tmux/
+├── coordinator/
+│   ├── __init__.py           ← empty
+│   ├── main.py               ← FastAPI app, lifespan, all endpoints
+│   ├── sessions.py           ← tmux enumeration + capture-pane
+│   ├── state.py              ← state.json schema, atomic read/write, device management
+│   ├── ttyd.py               ← ttyd spawn, kill, orphan detection
+│   ├── bells.py              ← bell polling, unseen_count, clear rule
+│   └── tests/
+│       ├── __init__.py       ← empty
+│       ├── test_sessions.py
+│       ├── test_state.py
+│       ├── test_ttyd.py
+│       ├── test_bells.py
+│       ├── test_api.py
+│       └── test_integration.py
+├── requirements.txt
+└── pyproject.toml
+```
+
+---
+
+## Task 0: Bell Flag Spike (Empirical — No TDD)
+
+**Purpose:** Determine whether `tmux display-message -t {session} -p "#{window_bell_flag}"` clears the bell flag when read, or merely reads it. The bell implementation in Task 7 depends on this finding.
+
+**Files:**
+- Create: `coordinator/spike_bell_flag.py`
+
+**Step 1: Create the spike script**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/spike_bell_flag.py` with this exact content:
+
+```python
+#!/usr/bin/env python3
+"""
+Bell Flag Spike — run ONCE manually to determine tmux bell flag read behavior.
+
+Usage:
+    python3 coordinator/spike_bell_flag.py
+
+What this tests:
+    Does `tmux display-message -p "#{window_bell_flag}"` clear the flag when
+    read, or does the flag persist until the window is visited in tmux?
+
+Expected result (almost certain): the flag persists. Reading it does NOT clear
+it. The flag is cleared only when the window is marked active inside tmux.
+"""
+import subprocess
+import time
+
+SESSION = "bell-spike-test"
+
+def run(cmd: list[str]) -> str:
+    result = subprocess.run(cmd, capture_output=True, text=True)
+    return result.stdout.strip()
+
+def main() -> None:
+    # 1. Create a test session
+    print("Creating test session...")
+    subprocess.run(["tmux", "new-session", "-d", "-s", SESSION, "-x", "80", "-y", "24"],
+                   check=True)
+    time.sleep(0.2)
+
+    # 2. Send a bell to the session
+    print("Sending bell to session...")
+    subprocess.run(["tmux", "send-keys", "-t", SESSION, "printf '\\a'", "Enter"],
+                   check=True)
+    time.sleep(0.5)
+
+    # 3. Read the bell flag (first read)
+    flag_read1 = run(["tmux", "display-message", "-t", SESSION, "-p",
+                       "#{window_bell_flag}"])
+    print(f"Bell flag (1st read): '{flag_read1}'")
+
+    # 4. Read the bell flag immediately again (second read)
+    flag_read2 = run(["tmux", "display-message", "-t", SESSION, "-p",
+                       "#{window_bell_flag}"])
+    print(f"Bell flag (2nd read): '{flag_read2}'")
+
+    # 5. Cleanup
+    subprocess.run(["tmux", "kill-session", "-t", SESSION])
+
+    # 6. Report
+    print()
+    if flag_read1 == "1" and flag_read2 == "1":
+        print("FINDING: Reading does NOT clear the flag. Both reads show '1'.")
+        print("Implementation: use in-memory _bell_seen dict to detect 0→1 transitions.")
+    elif flag_read1 == "1" and flag_read2 == "0":
+        print("FINDING: Reading CLEARS the flag. First read shows '1', second shows '0'.")
+        print("Implementation: each '1' is a new bell — no transition tracking needed.")
+    elif flag_read1 == "0":
+        print("WARNING: Bell flag not set after printf '\\a'. Try running manually:")
+        print(f"  tmux send-keys -t {SESSION} \"printf '\\\\a'\" Enter")
+        print("  Then check: tmux display-message -t bell-spike-test -p '#{window_bell_flag}'")
+    else:
+        print(f"UNEXPECTED: read1={flag_read1!r}, read2={flag_read2!r}")
+
+if __name__ == "__main__":
+    main()
+```
+
+**Step 2: Run the spike**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+python3 coordinator/spike_bell_flag.py
+```
+
+Expected output:
+```
+Creating test session...
+Sending bell to session...
+Bell flag (1st read): '1'
+Bell flag (2nd read): '1'
+
+FINDING: Reading does NOT clear the flag. Both reads show '1'.
+Implementation: use in-memory _bell_seen dict to detect 0→1 transitions.
+```
+
+**Step 3: Record findings**
+
+The spike result tells us which branch to take in `bells.py` (Task 7). When you reach Task 7, you will put a comment at the top of `coordinator/bells.py` recording what the spike found. The plan assumes the almost-certain result: **reading does NOT clear the flag**.
+
+**Step 4: Commit the spike script**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/spike_bell_flag.py
+git commit -m "spike: bell flag read-behavior investigation script"
+```
+
+---
+
+## Task 1: Project Setup
+
+**Files:**
+- Create: `coordinator/__init__.py`
+- Create: `coordinator/tests/__init__.py`
+- Create: `requirements.txt`
+- Create: `pyproject.toml`
+
+**Step 1: Create directory skeleton**
+
+```bash
+mkdir -p /home/bkrabach/dev/web-tmux/coordinator/tests
+touch /home/bkrabach/dev/web-tmux/coordinator/__init__.py
+touch /home/bkrabach/dev/web-tmux/coordinator/tests/__init__.py
+```
+
+**Step 2: Create `requirements.txt`**
+
+Create `/home/bkrabach/dev/web-tmux/requirements.txt`:
+
+```
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
+httpx>=0.27.0
+pytest>=8.0.0
+pytest-asyncio>=0.23.0
+```
+
+**Step 3: Create `pyproject.toml`**
+
+Create `/home/bkrabach/dev/web-tmux/pyproject.toml`:
+
+```toml
+[project]
+name = "tmux-web-coordinator"
+version = "0.1.0"
+description = "Web dashboard coordinator for tmux sessions"
+requires-python = ">=3.12"
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["coordinator"]
+
+[tool.pytest.ini_options]
+testpaths = ["coordinator/tests"]
+asyncio_mode = "auto"
+addopts = "--import-mode=importlib"
+```
+
+**Step 4: Install dependencies**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pip install fastapi "uvicorn[standard]" httpx pytest pytest-asyncio
+```
+
+Expected output: `Successfully installed fastapi-... uvicorn-... httpx-... pytest-... pytest-asyncio-...`
+
+**Step 5: Verify pytest finds the test directory**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest --collect-only
+```
+
+Expected output:
+```
+======================== no tests ran ========================
+```
+(No errors — just no tests yet.)
+
+**Step 6: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/__init__.py coordinator/tests/__init__.py requirements.txt pyproject.toml
+git commit -m "feat: project setup — pyproject.toml, requirements, directory skeleton"
+```
+
+---
+
+## Task 2: state.py — State Schema + Empty Factories
+
+**Files:**
+- Create: `coordinator/state.py`
+- Create: `coordinator/tests/test_state.py`
+
+**Step 1: Write the failing tests**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/tests/test_state.py`:
+
+```python
+"""Tests for coordinator/state.py — schema and empty factory functions."""
+import pytest
+
+
+def test_empty_state_has_required_top_level_keys():
+    from coordinator.state import empty_state
+
+    s = empty_state()
+    assert "active_session" in s
+    assert "session_order" in s
+    assert "sessions" in s
+    assert "devices" in s
+
+
+def test_empty_state_active_session_is_none():
+    from coordinator.state import empty_state
+
+    s = empty_state()
+    assert s["active_session"] is None
+
+
+def test_empty_state_session_order_is_empty_list():
+    from coordinator.state import empty_state
+
+    s = empty_state()
+    assert s["session_order"] == []
+
+
+def test_empty_state_sessions_is_empty_dict():
+    from coordinator.state import empty_state
+
+    s = empty_state()
+    assert s["sessions"] == {}
+
+
+def test_empty_state_devices_is_empty_dict():
+    from coordinator.state import empty_state
+
+    s = empty_state()
+    assert s["devices"] == {}
+
+
+def test_empty_bell_has_required_keys():
+    from coordinator.state import empty_bell
+
+    b = empty_bell()
+    assert "last_fired_at" in b
+    assert "seen_at" in b
+    assert "unseen_count" in b
+
+
+def test_empty_bell_last_fired_at_is_none():
+    from coordinator.state import empty_bell
+
+    b = empty_bell()
+    assert b["last_fired_at"] is None
+
+
+def test_empty_bell_seen_at_is_none():
+    from coordinator.state import empty_bell
+
+    b = empty_bell()
+    assert b["seen_at"] is None
+
+
+def test_empty_bell_unseen_count_is_zero():
+    from coordinator.state import empty_bell
+
+    b = empty_bell()
+    assert b["unseen_count"] == 0
+
+
+def test_empty_device_has_required_keys():
+    from coordinator.state import empty_device
+
+    d = empty_device("d-abc123", "Test Device")
+    assert "label" in d
+    assert "viewing_session" in d
+    assert "view_mode" in d
+    assert "last_interaction_at" in d
+    assert "last_heartbeat_at" in d
+
+
+def test_empty_device_label_set_correctly():
+    from coordinator.state import empty_device
+
+    d = empty_device("d-abc123", "Laptop Chrome")
+    assert d["label"] == "Laptop Chrome"
+
+
+def test_empty_device_viewing_session_is_none():
+    from coordinator.state import empty_device
+
+    d = empty_device("d-abc123", "Test")
+    assert d["viewing_session"] is None
+
+
+def test_empty_device_view_mode_is_grid():
+    from coordinator.state import empty_device
+
+    d = empty_device("d-abc123", "Test")
+    assert d["view_mode"] == "grid"
+
+
+def test_empty_state_returns_independent_dicts():
+    """Two calls to empty_state() must not share mutable objects."""
+    from coordinator.state import empty_state
+
+    s1 = empty_state()
+    s2 = empty_state()
+    s1["session_order"].append("main")
+    assert s2["session_order"] == [], "empty_state() must not share list objects"
+```
+
+**Step 2: Run tests to verify they fail**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_state.py -v
+```
+
+Expected: `ERROR` collecting tests — `ModuleNotFoundError: No module named 'coordinator.state'`
+
+**Step 3: Create `coordinator/state.py` with schema and factories**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/state.py`:
+
+```python
+"""
+State management for the tmux-web coordinator.
+
+State file location: ~/.local/share/tmux-web/state.json
+(Override with TMUX_WEB_STATE_DIR env var for testing.)
+
+State schema:
+{
+  "active_session": str | None,
+  "session_order": list[str],
+  "sessions": {
+    "": {
+      "bell": {
+        "last_fired_at": float | None,
+        "seen_at": float | None,
+        "unseen_count": int
+      }
+    }
+  },
+  "devices": {
+    "": {
+      "label": str,
+      "viewing_session": str | None,
+      "view_mode": str,           # "fullscreen" | "grid"
+      "last_interaction_at": float,
+      "last_heartbeat_at": float
+    }
+  }
+}
+
+Concurrency model:
+  - `state_lock`: asyncio.Lock — acquire before any read-modify-write cycle.
+  - `read_state()` / `write_state()`: acquire the lock internally; safe for
+    simple reads or writes that don't need to be atomic with each other.
+  - `load_state()` / `save_state()`: NO lock; use inside `async with state_lock`.
+
+Atomic write pattern (os.replace — atomic on POSIX):
+  tmp = STATE_PATH.with_suffix('.tmp')
+  tmp.write_text(json.dumps(state, indent=2))
+  os.replace(tmp, STATE_PATH)
+"""
+
+import asyncio
+import json
+import os
+import pathlib
+import time
+
+# ---------------------------------------------------------------------------
+# Paths
+# ---------------------------------------------------------------------------
+
+_state_dir_override = os.environ.get("TMUX_WEB_STATE_DIR")
+STATE_DIR: pathlib.Path = (
+    pathlib.Path(_state_dir_override)
+    if _state_dir_override
+    else pathlib.Path.home() / ".local" / "share" / "tmux-web"
+)
+STATE_PATH: pathlib.Path = STATE_DIR / "state.json"
+
+# ---------------------------------------------------------------------------
+# Concurrency
+# ---------------------------------------------------------------------------
+
+state_lock: asyncio.Lock = asyncio.Lock()
+
+# ---------------------------------------------------------------------------
+# Schema factories
+# ---------------------------------------------------------------------------
+
+
+def empty_state() -> dict:
+    """Return a fresh, empty state dict. Each call returns independent objects."""
+    return {
+        "active_session": None,
+        "session_order": [],
+        "sessions": {},
+        "devices": {},
+    }
+
+
+def empty_bell() -> dict:
+    """Return a fresh bell sub-dict for one session."""
+    return {
+        "last_fired_at": None,
+        "seen_at": None,
+        "unseen_count": 0,
+    }
+
+
+def empty_device(device_id: str, label: str) -> dict:
+    """Return a fresh device sub-dict with defaults."""
+    now = time.time()
+    return {
+        "label": label,
+        "viewing_session": None,
+        "view_mode": "grid",
+        "last_interaction_at": now,
+        "last_heartbeat_at": now,
+    }
+```
+
+**Step 4: Run tests to verify they pass**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_state.py -v
+```
+
+Expected:
+```
+PASSED coordinator/tests/test_state.py::test_empty_state_has_required_top_level_keys
+PASSED coordinator/tests/test_state.py::test_empty_state_active_session_is_none
+... (all 15 tests) ...
+15 passed in 0.XXs
+```
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/state.py coordinator/tests/test_state.py
+git commit -m "feat: state.py — schema factories and empty_state/bell/device"
+```
+
+---
+
+## Task 3: state.py — Atomic Read/Write
+
+**Files:**
+- Modify: `coordinator/state.py`
+- Modify: `coordinator/tests/test_state.py`
+
+**Step 1: Add failing tests**
+
+Append this to `/home/bkrabach/dev/web-tmux/coordinator/tests/test_state.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# Atomic read/write tests — require tmp_path fixture
+# ---------------------------------------------------------------------------
+
+import asyncio
+import json
+import pathlib
+
+
+@pytest.fixture(autouse=True)
+def use_tmp_state_dir(tmp_path, monkeypatch):
+    """Redirect all state file operations to a temp directory."""
+    import coordinator.state as state_mod
+
+    monkeypatch.setattr(state_mod, "STATE_DIR", tmp_path)
+    monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state.json")
+
+
+async def test_read_state_returns_empty_when_no_file():
+    from coordinator.state import read_state
+
+    result = await read_state()
+    assert result["active_session"] is None
+    assert result["session_order"] == []
+
+
+async def test_write_then_read_roundtrip(tmp_path):
+    from coordinator.state import read_state, write_state
+
+    original = {
+        "active_session": "work",
+        "session_order": ["main", "work"],
+        "sessions": {},
+        "devices": {},
+    }
+    await write_state(original)
+    result = await read_state()
+    assert result["active_session"] == "work"
+    assert result["session_order"] == ["main", "work"]
+
+
+async def test_write_creates_state_dir_if_missing(tmp_path, monkeypatch):
+    import coordinator.state as state_mod
+
+    deep_dir = tmp_path / "a" / "b" / "c"
+    monkeypatch.setattr(state_mod, "STATE_DIR", deep_dir)
+    monkeypatch.setattr(state_mod, "STATE_PATH", deep_dir / "state.json")
+    await state_mod.write_state(state_mod.empty_state())
+    assert (deep_dir / "state.json").exists()
+
+
+async def test_write_is_atomic_no_tmp_file_left(tmp_path):
+    from coordinator.state import STATE_PATH, write_state
+
+    await write_state({"active_session": None, "session_order": [],
+                        "sessions": {}, "devices": {}})
+    tmp = STATE_PATH.with_suffix(".tmp")
+    assert not tmp.exists(), ".tmp file must be cleaned up by os.replace()"
+
+
+async def test_concurrent_writes_do_not_corrupt(tmp_path):
+    """Two coroutines writing different states concurrently must not corrupt."""
+    from coordinator.state import read_state, write_state
+
+    state_a = {"active_session": "aaa", "session_order": ["aaa"],
+                "sessions": {}, "devices": {}}
+    state_b = {"active_session": "bbb", "session_order": ["bbb"],
+                "sessions": {}, "devices": {}}
+
+    async def write_a() -> None:
+        for _ in range(5):
+            await write_state(state_a)
+
+    async def write_b() -> None:
+        for _ in range(5):
+            await write_state(state_b)
+
+    await asyncio.gather(write_a(), write_b())
+
+    # Final state must be valid JSON that is one of the two known states
+    result = await read_state()
+    assert result["active_session"] in ("aaa", "bbb")
+```
+
+**Step 2: Run tests to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_state.py::test_read_state_returns_empty_when_no_file -v
+```
+
+Expected: `FAILED` — `ImportError: cannot import name 'read_state' from 'coordinator.state'`
+
+**Step 3: Add `load_state`, `save_state`, `read_state`, `write_state` to `coordinator/state.py`**
+
+Append this to the bottom of `/home/bkrabach/dev/web-tmux/coordinator/state.py` (after the `empty_device` function):
+
+```python
+# ---------------------------------------------------------------------------
+# Low-level file I/O (no lock — use inside async with state_lock)
+# ---------------------------------------------------------------------------
+
+
+def load_state() -> dict:
+    """Read state.json synchronously. Returns empty_state() if file missing."""
+    if not STATE_PATH.exists():
+        return empty_state()
+    try:
+        return json.loads(STATE_PATH.read_text(encoding="utf-8"))
+    except (json.JSONDecodeError, OSError):
+        return empty_state()
+
+
+def save_state(state: dict) -> None:
+    """Write state.json atomically (os.replace). STATE_DIR must exist."""
+    STATE_DIR.mkdir(parents=True, exist_ok=True)
+    tmp = STATE_PATH.with_suffix(".tmp")
+    tmp.write_text(json.dumps(state, indent=2), encoding="utf-8")
+    os.replace(tmp, STATE_PATH)
+
+
+# ---------------------------------------------------------------------------
+# Locked public API (safe to call from any async context)
+# ---------------------------------------------------------------------------
+
+
+async def read_state() -> dict:
+    """Acquire state_lock, read state.json, return dict."""
+    async with state_lock:
+        return load_state()
+
+
+async def write_state(state: dict) -> None:
+    """Acquire state_lock, write state.json atomically."""
+    async with state_lock:
+        save_state(state)
+```
+
+**Step 4: Run tests to verify they pass**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_state.py -v
+```
+
+Expected:
+```
+PASSED ...test_read_state_returns_empty_when_no_file
+PASSED ...test_write_then_read_roundtrip
+PASSED ...test_write_creates_state_dir_if_missing
+PASSED ...test_write_is_atomic_no_tmp_file_left
+PASSED ...test_concurrent_writes_do_not_corrupt
+... (all tests) ...
+20 passed in 0.XXs
+```
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/state.py coordinator/tests/test_state.py
+git commit -m "feat: state.py — atomic read/write with asyncio lock"
+```
+
+---
+
+## Task 4: state.py — Device Registration, Heartbeat, Pruning
+
+**Files:**
+- Modify: `coordinator/state.py`
+- Modify: `coordinator/tests/test_state.py`
+
+**Step 1: Add failing tests**
+
+Append this to `/home/bkrabach/dev/web-tmux/coordinator/tests/test_state.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# Device registration and pruning tests
+# ---------------------------------------------------------------------------
+
+
+def test_register_device_adds_new_device():
+    from coordinator.state import empty_state, register_device
+
+    s = empty_state()
+    register_device(s, "d-abc", "Laptop", viewing_session=None,
+                    view_mode="grid", last_interaction_at=1000.0)
+    assert "d-abc" in s["devices"]
+    assert s["devices"]["d-abc"]["label"] == "Laptop"
+
+
+def test_register_device_updates_existing_device():
+    from coordinator.state import empty_state, register_device
+
+    s = empty_state()
+    register_device(s, "d-abc", "Laptop", viewing_session=None,
+                    view_mode="grid", last_interaction_at=1000.0)
+    register_device(s, "d-abc", "Laptop", viewing_session="work",
+                    view_mode="fullscreen", last_interaction_at=2000.0)
+    assert s["devices"]["d-abc"]["viewing_session"] == "work"
+    assert s["devices"]["d-abc"]["view_mode"] == "fullscreen"
+    assert s["devices"]["d-abc"]["last_interaction_at"] == 2000.0
+
+
+def test_register_device_sets_heartbeat_timestamp():
+    import time
+    from coordinator.state import empty_state, register_device
+
+    before = time.time()
+    s = empty_state()
+    register_device(s, "d-abc", "Laptop", viewing_session=None,
+                    view_mode="grid", last_interaction_at=1000.0)
+    after = time.time()
+    hb = s["devices"]["d-abc"]["last_heartbeat_at"]
+    assert before <= hb <= after
+
+
+def test_prune_devices_removes_stale():
+    from coordinator.state import empty_state, prune_devices
+
+    s = empty_state()
+    s["devices"]["old"] = {
+        "label": "Old Device",
+        "viewing_session": None,
+        "view_mode": "grid",
+        "last_interaction_at": 0.0,
+        "last_heartbeat_at": 0.0,      # very old — epoch
+    }
+    removed = prune_devices(s, ttl_seconds=300.0)
+    assert "old" not in s["devices"]
+    assert "old" in removed
+
+
+def test_prune_devices_keeps_fresh():
+    import time
+    from coordinator.state import empty_state, prune_devices
+
+    s = empty_state()
+    now = time.time()
+    s["devices"]["fresh"] = {
+        "label": "Fresh Device",
+        "viewing_session": None,
+        "view_mode": "grid",
+        "last_interaction_at": now,
+        "last_heartbeat_at": now,
+    }
+    removed = prune_devices(s, ttl_seconds=300.0)
+    assert "fresh" in s["devices"]
+    assert removed == []
+
+
+def test_prune_devices_returns_list_of_removed_ids():
+    from coordinator.state import empty_state, prune_devices
+
+    s = empty_state()
+    s["devices"]["stale1"] = {
+        "label": "A", "viewing_session": None, "view_mode": "grid",
+        "last_interaction_at": 0.0, "last_heartbeat_at": 0.0,
+    }
+    s["devices"]["stale2"] = {
+        "label": "B", "viewing_session": None, "view_mode": "grid",
+        "last_interaction_at": 0.0, "last_heartbeat_at": 0.0,
+    }
+    removed = prune_devices(s, ttl_seconds=300.0)
+    assert set(removed) == {"stale1", "stale2"}
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_state.py::test_register_device_adds_new_device -v
+```
+
+Expected: `FAILED` — `ImportError: cannot import name 'register_device'`
+
+**Step 3: Add `register_device` and `prune_devices` to `coordinator/state.py`**
+
+Append to the bottom of `/home/bkrabach/dev/web-tmux/coordinator/state.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# Device management (operate on a state dict in-place)
+# ---------------------------------------------------------------------------
+
+
+def register_device(
+    state: dict,
+    device_id: str,
+    label: str,
+    viewing_session: str | None,
+    view_mode: str,
+    last_interaction_at: float,
+) -> None:
+    """Create or update a device entry. Always refreshes last_heartbeat_at."""
+    now = time.time()
+    if device_id not in state["devices"]:
+        state["devices"][device_id] = empty_device(device_id, label)
+    device = state["devices"][device_id]
+    device["label"] = label
+    device["viewing_session"] = viewing_session
+    device["view_mode"] = view_mode
+    device["last_interaction_at"] = last_interaction_at
+    device["last_heartbeat_at"] = now
+
+
+def prune_devices(state: dict, ttl_seconds: float = 300.0) -> list[str]:
+    """Remove devices silent for longer than ttl_seconds. Returns removed IDs."""
+    cutoff = time.time() - ttl_seconds
+    stale = [
+        device_id
+        for device_id, device in state["devices"].items()
+        if device["last_heartbeat_at"] < cutoff
+    ]
+    for device_id in stale:
+        del state["devices"][device_id]
+    return stale
+```
+
+**Step 4: Run tests to verify they pass**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_state.py -v
+```
+
+Expected: all tests pass (now ~26 tests).
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/state.py coordinator/tests/test_state.py
+git commit -m "feat: state.py — device registration, heartbeat, pruning"
+```
+
+---
+
+## Task 5: sessions.py — tmux Session Enumeration
+
+**Files:**
+- Create: `coordinator/sessions.py`
+- Create: `coordinator/tests/test_sessions.py`
+
+**Step 1: Write failing tests**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/tests/test_sessions.py`:
+
+```python
+"""Tests for coordinator/sessions.py — tmux session enumeration."""
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+
+async def test_enumerate_sessions_parses_newline_output():
+    """run_tmux returns 'main\nwork\nlogs\n' → enumerate returns ['main','work','logs']."""
+    with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
+        mock.return_value = "main\nwork\nlogs\n"
+        from coordinator.sessions import enumerate_sessions
+
+        result = await enumerate_sessions()
+    assert result == ["main", "work", "logs"]
+
+
+async def test_enumerate_sessions_returns_empty_list_when_no_sessions():
+    """When tmux has no sessions, run_tmux returns empty string."""
+    with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
+        mock.return_value = ""
+        from coordinator.sessions import enumerate_sessions
+
+        result = await enumerate_sessions()
+    assert result == []
+
+
+async def test_enumerate_sessions_strips_whitespace():
+    with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
+        mock.return_value = "  main  \n  work  \n"
+        from coordinator.sessions import enumerate_sessions
+
+        result = await enumerate_sessions()
+    assert result == ["main", "work"]
+
+
+async def test_enumerate_sessions_handles_tmux_error():
+    """If run_tmux raises RuntimeError (tmux not running), return empty list."""
+    with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
+        mock.side_effect = RuntimeError("no server running")
+        from coordinator.sessions import enumerate_sessions
+
+        result = await enumerate_sessions()
+    assert result == []
+
+
+async def test_run_tmux_calls_correct_command():
+    """run_tmux('list-sessions', '-F', '#{session_name}') calls tmux with those args."""
+    mock_proc = AsyncMock()
+    mock_proc.communicate.return_value = (b"main\n", b"")
+    mock_proc.returncode = 0
+
+    with patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
+        from coordinator.sessions import run_tmux
+
+        await run_tmux("list-sessions", "-F", "#{session_name}")
+
+    called_args = mock_exec.call_args[0]
+    assert called_args[0] == "tmux"
+    assert "list-sessions" in called_args
+    assert "-F" in called_args
+
+
+async def test_run_tmux_raises_on_nonzero_exit():
+    mock_proc = AsyncMock()
+    mock_proc.communicate.return_value = (b"", b"no server running on /tmp/tmux")
+    mock_proc.returncode = 1
+
+    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
+        from coordinator.sessions import run_tmux
+
+        with pytest.raises(RuntimeError, match="tmux.*failed"):
+            await run_tmux("list-sessions")
+```
+
+**Step 2: Run tests to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_sessions.py -v
+```
+
+Expected: `ERROR` — `ModuleNotFoundError: No module named 'coordinator.sessions'`
+
+**Step 3: Create `coordinator/sessions.py`**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/sessions.py`:
+
+```python
+"""
+tmux session enumeration and capture-pane management.
+
+Public API:
+  run_tmux(*args)              — run any tmux command, return stdout as str
+  enumerate_sessions()         — list running tmux session names
+  capture_pane(name, lines)    — capture last N lines of a session's pane
+  snapshot_all(names)          — capture all sessions concurrently
+  update_session_cache(names)  — update in-memory session list + snapshots
+  get_session_list()           — return cached session name list
+  get_snapshots()              — return cached pane snapshots dict
+
+In-memory cache (updated by poll loop, read by API endpoints):
+  _session_list: list[str]           — last known session names
+  _snapshots: dict[str, str]         — session_name → capture-pane text
+"""
+
+import asyncio
+
+# ---------------------------------------------------------------------------
+# In-memory cache (updated by poll loop)
+# ---------------------------------------------------------------------------
+
+_session_list: list[str] = []
+_snapshots: dict[str, str] = {}
+
+
+def get_session_list() -> list[str]:
+    """Return the most recently cached list of session names."""
+    return list(_session_list)
+
+
+def get_snapshots() -> dict[str, str]:
+    """Return the most recently cached capture-pane snapshots."""
+    return dict(_snapshots)
+
+
+async def update_session_cache(session_names: list[str]) -> None:
+    """Update both _session_list and _snapshots. Called by the poll loop."""
+    global _session_list, _snapshots
+    _session_list = list(session_names)
+    _snapshots = await snapshot_all(session_names)
+
+
+# ---------------------------------------------------------------------------
+# tmux subprocess helpers
+# ---------------------------------------------------------------------------
+
+
+async def run_tmux(*args: str) -> str:
+    """Run `tmux `, return stdout. Raises RuntimeError on nonzero exit."""
+    proc = await asyncio.create_subprocess_exec(
+        "tmux",
+        *args,
+        stdout=asyncio.subprocess.PIPE,
+        stderr=asyncio.subprocess.PIPE,
+    )
+    stdout, stderr = await proc.communicate()
+    if proc.returncode != 0:
+        raise RuntimeError(
+            f"tmux {list(args)} failed (exit {proc.returncode}): "
+            f"{stderr.decode(errors='replace').strip()}"
+        )
+    return stdout.decode(errors="replace")
+
+
+# ---------------------------------------------------------------------------
+# Session enumeration
+# ---------------------------------------------------------------------------
+
+
+async def enumerate_sessions() -> list[str]:
+    """Return list of running tmux session names. Returns [] if tmux not running."""
+    try:
+        output = await run_tmux("list-sessions", "-F", "#{session_name}")
+    except RuntimeError:
+        return []
+    names = [line.strip() for line in output.splitlines() if line.strip()]
+    return names
+
+
+# ---------------------------------------------------------------------------
+# Capture-pane
+# ---------------------------------------------------------------------------
+
+
+async def capture_pane(session_name: str, lines: int = 30) -> str:
+    """
+    Return the last `lines` lines of the named session's active pane.
+
+    Uses: tmux capture-pane -p -t  -e 0 -l 
+      -p  → print to stdout
+      -e 0 → no escape sequences
+      -l  → limit lines
+
+    Returns empty string if session not found or tmux not running.
+    """
+    try:
+        output = await run_tmux(
+            "capture-pane", "-p", "-t", session_name, "-e", "0", "-l", str(lines)
+        )
+    except RuntimeError:
+        return ""
+    return output
+
+
+async def snapshot_all(session_names: list[str]) -> dict[str, str]:
+    """Capture pane output for all sessions concurrently. Returns name→text dict."""
+    if not session_names:
+        return {}
+    results = await asyncio.gather(
+        *(capture_pane(name) for name in session_names),
+        return_exceptions=True,
+    )
+    snapshots: dict[str, str] = {}
+    for name, result in zip(session_names, results):
+        if isinstance(result, Exception):
+            snapshots[name] = ""
+        else:
+            snapshots[name] = result
+    return snapshots
+```
+
+**Step 4: Run tests to verify they pass**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_sessions.py -v
+```
+
+Expected:
+```
+PASSED ...test_enumerate_sessions_parses_newline_output
+PASSED ...test_enumerate_sessions_returns_empty_list_when_no_sessions
+PASSED ...test_enumerate_sessions_strips_whitespace
+PASSED ...test_enumerate_sessions_handles_tmux_error
+PASSED ...test_run_tmux_calls_correct_command
+PASSED ...test_run_tmux_raises_on_nonzero_exit
+6 passed in 0.XXs
+```
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/sessions.py coordinator/tests/test_sessions.py
+git commit -m "feat: sessions.py — tmux session enumeration and run_tmux helper"
+```
+
+---
+
+## Task 6: sessions.py — Capture-Pane Snapshots
+
+**Files:**
+- Modify: `coordinator/tests/test_sessions.py`
+
+(The implementation was already written in Task 5. This task adds tests for `capture_pane` and `snapshot_all`.)
+
+**Step 1: Add failing tests**
+
+Append to `/home/bkrabach/dev/web-tmux/coordinator/tests/test_sessions.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# capture_pane and snapshot_all tests
+# ---------------------------------------------------------------------------
+
+
+async def test_capture_pane_returns_output():
+    with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
+        mock.return_value = "line1\nline2\nline3\n"
+        from coordinator.sessions import capture_pane
+
+        result = await capture_pane("myapp")
+    assert "line1" in result
+    assert "line3" in result
+
+
+async def test_capture_pane_returns_empty_string_on_error():
+    with patch("coordinator.sessions.run_tmux", new_callable=AsyncMock) as mock:
+        mock.side_effect = RuntimeError("session not found")
+        from coordinator.sessions import capture_pane
+
+        result = await capture_pane("ghost-session")
+    assert result == ""
+
+
+async def test_capture_pane_calls_correct_tmux_args():
+    """Must pass -p -t  -e 0 -l  to capture-pane."""
+    captured_args: list[tuple] = []
+
+    async def fake_run_tmux(*args: str) -> str:
+        captured_args.append(args)
+        return "output\n"
+
+    with patch("coordinator.sessions.run_tmux", side_effect=fake_run_tmux):
+        from coordinator.sessions import capture_pane
+
+        await capture_pane("dev-server", lines=30)
+
+    assert len(captured_args) == 1
+    args = captured_args[0]
+    assert "capture-pane" in args
+    assert "-p" in args
+    assert "-t" in args
+    assert "dev-server" in args
+    assert "-e" in args
+    assert "0" in args
+    assert "-l" in args
+    assert "30" in args
+
+
+async def test_snapshot_all_returns_dict_keyed_by_name():
+    async def fake_capture(name: str, lines: int = 30) -> str:
+        return f"output-for-{name}"
+
+    with patch("coordinator.sessions.capture_pane", side_effect=fake_capture):
+        from coordinator.sessions import snapshot_all
+
+        result = await snapshot_all(["main", "work", "logs"])
+    assert result["main"] == "output-for-main"
+    assert result["work"] == "output-for-work"
+    assert result["logs"] == "output-for-logs"
+
+
+async def test_snapshot_all_returns_empty_dict_for_empty_input():
+    from coordinator.sessions import snapshot_all
+
+    result = await snapshot_all([])
+    assert result == {}
+
+
+async def test_snapshot_all_returns_empty_string_on_individual_failure():
+    """If one session fails, its entry is '' and others succeed."""
+
+    async def fake_capture(name: str, lines: int = 30) -> str:
+        if name == "broken":
+            raise RuntimeError("gone")
+        return f"ok-{name}"
+
+    with patch("coordinator.sessions.capture_pane", side_effect=fake_capture):
+        from coordinator.sessions import snapshot_all
+
+        result = await snapshot_all(["main", "broken", "work"])
+    assert result["main"] == "ok-main"
+    assert result["broken"] == ""
+    assert result["work"] == "ok-work"
+```
+
+**Step 2: Run tests to verify they pass**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_sessions.py -v
+```
+
+Expected: all 12 tests pass.
+
+**Step 3: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/tests/test_sessions.py
+git commit -m "test: sessions.py — capture-pane and snapshot_all tests"
+```
+
+---
+
+## Task 7: bells.py — Bell Flag Polling + unseen_count
+
+**Files:**
+- Create: `coordinator/bells.py`
+- Create: `coordinator/tests/test_bells.py`
+
+**Step 1: Write failing tests**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/tests/test_bells.py`:
+
+```python
+"""Tests for coordinator/bells.py — bell detection and unseen_count."""
+from unittest.mock import AsyncMock, patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def reset_bell_seen():
+    """Reset in-memory _bell_seen before each test to prevent state leakage."""
+    import coordinator.bells as bells_mod
+
+    bells_mod._bell_seen.clear()
+    yield
+    bells_mod._bell_seen.clear()
+
+
+async def test_poll_bell_flag_returns_true_when_flag_is_1():
+    with patch("coordinator.bells.run_tmux", new_callable=AsyncMock) as mock:
+        mock.return_value = "1\n"
+        from coordinator.bells import poll_bell_flag
+
+        result = await poll_bell_flag("myapp")
+    assert result is True
+
+
+async def test_poll_bell_flag_returns_false_when_flag_is_0():
+    with patch("coordinator.bells.run_tmux", new_callable=AsyncMock) as mock:
+        mock.return_value = "0\n"
+        from coordinator.bells import poll_bell_flag
+
+        result = await poll_bell_flag("myapp")
+    assert result is False
+
+
+async def test_poll_bell_flag_returns_false_on_error():
+    with patch("coordinator.bells.run_tmux", new_callable=AsyncMock) as mock:
+        mock.side_effect = RuntimeError("no server")
+        from coordinator.bells import poll_bell_flag
+
+        result = await poll_bell_flag("myapp")
+    assert result is False
+
+
+async def test_process_bell_flags_increments_unseen_count_on_new_bell():
+    """0→1 transition: unseen_count goes from 0 to 1."""
+    from coordinator.state import empty_bell, empty_state
+    from coordinator.bells import process_bell_flags
+
+    s = empty_state()
+    s["sessions"]["myapp"] = {"bell": empty_bell()}
+
+    async def fake_poll(name: str) -> bool:
+        return True  # flag is set
+
+    with patch("coordinator.bells.poll_bell_flag", side_effect=fake_poll):
+        changed = await process_bell_flags(["myapp"], s)
+
+    assert s["sessions"]["myapp"]["bell"]["unseen_count"] == 1
+    assert s["sessions"]["myapp"]["bell"]["last_fired_at"] is not None
+    assert changed is True
+
+
+async def test_process_bell_flags_does_not_double_count_persistent_flag():
+    """If flag stays '1' across two polls, unseen_count increments only once."""
+    from coordinator.state import empty_bell, empty_state
+    from coordinator.bells import process_bell_flags
+
+    s = empty_state()
+    s["sessions"]["myapp"] = {"bell": empty_bell()}
+
+    async def fake_poll(name: str) -> bool:
+        return True
+
+    with patch("coordinator.bells.poll_bell_flag", side_effect=fake_poll):
+        await process_bell_flags(["myapp"], s)
+        await process_bell_flags(["myapp"], s)  # second poll — same flag
+
+    assert s["sessions"]["myapp"]["bell"]["unseen_count"] == 1
+
+
+async def test_process_bell_flags_resets_tracking_when_flag_clears():
+    """After flag goes 1→0, the next 1 is a new bell and must be counted."""
+    from coordinator.state import empty_bell, empty_state
+    from coordinator.bells import process_bell_flags
+
+    s = empty_state()
+    s["sessions"]["myapp"] = {"bell": empty_bell()}
+
+    # Poll 1: flag is 1 (new bell)
+    with patch("coordinator.bells.poll_bell_flag", return_value=AsyncMock(return_value=True)()):
+        pass  # can't easily nest like this; use the approach below
+
+    call_count = 0
+
+    async def fake_poll_sequence(name: str) -> bool:
+        nonlocal call_count
+        call_count += 1
+        # First two calls: flag on; third call: flag off; fourth: flag on again
+        return call_count in (1, 4)
+
+    with patch("coordinator.bells.poll_bell_flag", side_effect=fake_poll_sequence):
+        await process_bell_flags(["myapp"], s)  # count: 1 → bell fires (count=1)
+        await process_bell_flags(["myapp"], s)  # count: 2 → flag off (count resets)
+        await process_bell_flags(["myapp"], s)  # count: 3 — wait, this is wrong
+
+    # Simpler approach: call the function directly, manipulating _bell_seen
+    import coordinator.bells as bells_mod
+
+    s2 = empty_state()
+    s2["sessions"]["myapp"] = {"bell": empty_bell()}
+    bells_mod._bell_seen.clear()
+
+    # Simulate: first bell fires
+    bells_mod._bell_seen["myapp"] = False
+    with patch("coordinator.bells.poll_bell_flag", new_callable=AsyncMock) as mock:
+        mock.return_value = True
+        await process_bell_flags(["myapp"], s2)
+    assert s2["sessions"]["myapp"]["bell"]["unseen_count"] == 1
+
+    # Simulate: flag clears
+    with patch("coordinator.bells.poll_bell_flag", new_callable=AsyncMock) as mock:
+        mock.return_value = False
+        await process_bell_flags(["myapp"], s2)
+
+    # Simulate: new bell fires
+    with patch("coordinator.bells.poll_bell_flag", new_callable=AsyncMock) as mock:
+        mock.return_value = True
+        await process_bell_flags(["myapp"], s2)
+    assert s2["sessions"]["myapp"]["bell"]["unseen_count"] == 2
+
+
+async def test_process_bell_flags_no_change_returns_false():
+    """If nothing changed, return False."""
+    from coordinator.state import empty_bell, empty_state
+    from coordinator.bells import process_bell_flags
+
+    s = empty_state()
+    s["sessions"]["quiet"] = {"bell": empty_bell()}
+
+    with patch("coordinator.bells.poll_bell_flag", new_callable=AsyncMock) as mock:
+        mock.return_value = False
+        changed = await process_bell_flags(["quiet"], s)
+
+    assert changed is False
+
+
+async def test_process_bell_flags_creates_bell_entry_if_missing():
+    """If sessions[name] exists but has no 'bell' key, it should be created."""
+    from coordinator.state import empty_state
+    from coordinator.bells import process_bell_flags
+
+    s = empty_state()
+    s["sessions"]["myapp"] = {}  # no 'bell' key
+
+    with patch("coordinator.bells.poll_bell_flag", new_callable=AsyncMock) as mock:
+        mock.return_value = False
+        await process_bell_flags(["myapp"], s)
+
+    assert "bell" in s["sessions"]["myapp"]
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_bells.py -v
+```
+
+Expected: `ERROR` — `ModuleNotFoundError: No module named 'coordinator.bells'`
+
+**Step 3: Create `coordinator/bells.py`**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/bells.py`:
+
+```python
+"""
+Bell detection and acknowledgement for the tmux-web coordinator.
+
+SPIKE FINDING (run coordinator/spike_bell_flag.py to confirm):
+  `tmux display-message -t {session} -p "#{window_bell_flag}"` does NOT clear
+  the bell flag. The flag persists until the window is visited inside tmux.
+
+  Consequence: we must track the PREVIOUS state of each session's bell flag
+  in-memory (_bell_seen dict) to detect 0→1 transitions and avoid double-counting.
+
+Bell clear rule:
+  A session's bells are globally acknowledged (unseen_count reset) when:
+    1. The session is open in FULLSCREEN on any connected device, AND
+    2. That device had a user interaction within the last 60 seconds.
+
+  See: should_clear_bell(), apply_bell_clear_rule()
+
+# NOTE FOR PHASE 2 (frontend):
+  The backtick (`) key returns from fullscreen to grid view.
+  Fullscreen ↔ grid transitions update view_mode in heartbeat payloads.
+"""
+
+import time
+
+from coordinator.sessions import run_tmux
+from coordinator.state import empty_bell
+
+# ---------------------------------------------------------------------------
+# In-memory state — tracks previous bell flag per session to detect transitions
+# ---------------------------------------------------------------------------
+
+_bell_seen: dict[str, bool] = {}
+
+# ---------------------------------------------------------------------------
+# Bell flag polling
+# ---------------------------------------------------------------------------
+
+
+async def poll_bell_flag(session_name: str) -> bool:
+    """
+    Return True if the tmux window_bell_flag is set for this session.
+
+    Command: tmux display-message -t  -p "#{window_bell_flag}"
+    Returns: "1\n" or "0\n"
+
+    Returns False on any error (tmux not running, session gone, etc.).
+    """
+    try:
+        output = await run_tmux(
+            "display-message", "-t", session_name, "-p", "#{window_bell_flag}"
+        )
+        return output.strip() == "1"
+    except RuntimeError:
+        return False
+
+
+# ---------------------------------------------------------------------------
+# Bell processing
+# ---------------------------------------------------------------------------
+
+
+async def process_bell_flags(session_names: list[str], state: dict) -> bool:
+    """
+    Poll bell flags for all sessions, update state in-place.
+
+    Detects 0→1 transitions using _bell_seen. Increments unseen_count and
+    sets last_fired_at on new bells. Resets tracking when flag goes to 0.
+
+    Returns True if any bell state changed.
+    """
+    changed = False
+    now = time.time()
+
+    for name in session_names:
+        # Ensure bell sub-dict exists
+        if "bell" not in state["sessions"].get(name, {}):
+            if name not in state["sessions"]:
+                state["sessions"][name] = {}
+            state["sessions"][name]["bell"] = empty_bell()
+
+        bell = state["sessions"][name]["bell"]
+        flag_set = await poll_bell_flag(name)
+        previously_seen = _bell_seen.get(name, False)
+
+        if flag_set and not previously_seen:
+            # 0 → 1 transition: new bell
+            bell["unseen_count"] += 1
+            bell["last_fired_at"] = now
+            _bell_seen[name] = True
+            changed = True
+        elif not flag_set:
+            # Flag is off — reset tracking so next 1 counts as new
+            if previously_seen:
+                _bell_seen[name] = False
+                # Do NOT decrement unseen_count — it's a count of unacknowledged bells
+
+    return changed
+```
+
+**Step 4: Run tests to verify they pass**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_bells.py -v
+```
+
+Expected: all 8 tests pass.
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/bells.py coordinator/tests/test_bells.py
+git commit -m "feat: bells.py — bell flag polling and unseen_count tracking"
+```
+
+---
+
+## Task 8: bells.py — Active-Device Gate Rule (Bell Clear)
+
+**Files:**
+- Modify: `coordinator/bells.py`
+- Modify: `coordinator/tests/test_bells.py`
+
+**Step 1: Add failing tests**
+
+Append to `/home/bkrabach/dev/web-tmux/coordinator/tests/test_bells.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# Active-device gate rule tests
+# ---------------------------------------------------------------------------
+
+import time as time_module
+
+
+def _make_state_with_bell(session: str, unseen: int, device_id: str = "d-abc",
+                           view_mode: str = "fullscreen",
+                           seconds_since_interaction: float = 10.0) -> dict:
+    """Helper: build a minimal state dict for bell-clear tests."""
+    from coordinator.state import empty_state, empty_bell
+
+    s = empty_state()
+    s["sessions"][session] = {"bell": empty_bell()}
+    s["sessions"][session]["bell"]["unseen_count"] = unseen
+    s["sessions"][session]["bell"]["last_fired_at"] = time_module.time() - 5
+
+    now = time_module.time()
+    s["devices"][device_id] = {
+        "label": "Test Device",
+        "viewing_session": session,
+        "view_mode": view_mode,
+        "last_interaction_at": now - seconds_since_interaction,
+        "last_heartbeat_at": now,
+    }
+    return s
+
+
+def test_should_clear_bell_returns_true_for_fullscreen_recent_interaction():
+    from coordinator.bells import should_clear_bell
+
+    s = _make_state_with_bell("myapp", unseen=1, view_mode="fullscreen",
+                               seconds_since_interaction=10.0)
+    assert should_clear_bell("myapp", s) is True
+
+
+def test_should_clear_bell_returns_false_for_grid_mode():
+    from coordinator.bells import should_clear_bell
+
+    s = _make_state_with_bell("myapp", unseen=1, view_mode="grid",
+                               seconds_since_interaction=10.0)
+    assert should_clear_bell("myapp", s) is False
+
+
+def test_should_clear_bell_returns_false_when_interaction_too_old():
+    from coordinator.bells import should_clear_bell
+
+    s = _make_state_with_bell("myapp", unseen=1, view_mode="fullscreen",
+                               seconds_since_interaction=90.0)  # > 60s threshold
+    assert should_clear_bell("myapp", s) is False
+
+
+def test_should_clear_bell_returns_false_when_device_viewing_different_session():
+    from coordinator.bells import should_clear_bell
+    from coordinator.state import empty_state, empty_bell
+
+    s = empty_state()
+    s["sessions"]["myapp"] = {"bell": empty_bell()}
+    s["sessions"]["myapp"]["bell"]["unseen_count"] = 1
+    now = time_module.time()
+    s["devices"]["d-abc"] = {
+        "label": "Test",
+        "viewing_session": "other-session",  # different session!
+        "view_mode": "fullscreen",
+        "last_interaction_at": now - 5,
+        "last_heartbeat_at": now,
+    }
+    assert should_clear_bell("myapp", s) is False
+
+
+def test_should_clear_bell_returns_false_when_no_devices():
+    from coordinator.bells import should_clear_bell
+    from coordinator.state import empty_state, empty_bell
+
+    s = empty_state()
+    s["sessions"]["myapp"] = {"bell": empty_bell()}
+    s["sessions"]["myapp"]["bell"]["unseen_count"] = 1
+    assert should_clear_bell("myapp", s) is False
+
+
+def test_apply_bell_clear_rule_clears_matching_sessions():
+    from coordinator.bells import apply_bell_clear_rule
+
+    s = _make_state_with_bell("myapp", unseen=3, view_mode="fullscreen",
+                               seconds_since_interaction=5.0)
+    cleared = apply_bell_clear_rule(s)
+    assert "myapp" in cleared
+    assert s["sessions"]["myapp"]["bell"]["unseen_count"] == 0
+    assert s["sessions"]["myapp"]["bell"]["seen_at"] is not None
+
+
+def test_apply_bell_clear_rule_skips_sessions_with_zero_unseen():
+    from coordinator.bells import apply_bell_clear_rule
+    from coordinator.state import empty_state, empty_bell
+
+    s = empty_state()
+    s["sessions"]["quiet"] = {"bell": empty_bell()}
+    # unseen_count is already 0 — nothing to clear
+    cleared = apply_bell_clear_rule(s)
+    assert "quiet" not in cleared
+
+
+def test_apply_bell_clear_rule_returns_list_of_cleared_session_names():
+    from coordinator.bells import apply_bell_clear_rule
+
+    s = _make_state_with_bell("myapp", unseen=1, view_mode="fullscreen",
+                               seconds_since_interaction=5.0)
+    cleared = apply_bell_clear_rule(s)
+    assert isinstance(cleared, list)
+    assert "myapp" in cleared
+
+
+def test_apply_bell_clear_rule_resets_bell_seen_tracking():
+    """Clearing a bell must also reset _bell_seen so next bell is counted fresh."""
+    import coordinator.bells as bells_mod
+    from coordinator.bells import apply_bell_clear_rule
+
+    bells_mod._bell_seen["myapp"] = True
+    s = _make_state_with_bell("myapp", unseen=1, view_mode="fullscreen",
+                               seconds_since_interaction=5.0)
+    apply_bell_clear_rule(s)
+    assert bells_mod._bell_seen.get("myapp") is False
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_bells.py::test_should_clear_bell_returns_true_for_fullscreen_recent_interaction -v
+```
+
+Expected: `FAILED` — `ImportError: cannot import name 'should_clear_bell'`
+
+**Step 3: Add `should_clear_bell` and `apply_bell_clear_rule` to `coordinator/bells.py`**
+
+Append to the bottom of `/home/bkrabach/dev/web-tmux/coordinator/bells.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# Bell clear rule — active-device gate
+# ---------------------------------------------------------------------------
+
+_INTERACTION_WINDOW_SECONDS: float = 60.0
+
+
+def should_clear_bell(session_name: str, state: dict) -> bool:
+    """
+    Return True if this session's bells should be globally cleared.
+
+    Rule: any connected device must be viewing this session in FULLSCREEN
+    AND have had a user interaction within the last 60 seconds.
+    """
+    now = time.time()
+    for device in state["devices"].values():
+        if (
+            device["viewing_session"] == session_name
+            and device["view_mode"] == "fullscreen"
+            and device["last_interaction_at"] > now - _INTERACTION_WINDOW_SECONDS
+        ):
+            return True
+    return False
+
+
+def apply_bell_clear_rule(state: dict) -> list[str]:
+    """
+    Check every session with unseen bells against the active-device gate rule.
+
+    For sessions that qualify, reset unseen_count to 0, set seen_at to now,
+    and reset _bell_seen so the next actual bell is counted fresh.
+
+    Returns the list of session names that were cleared.
+    """
+    now = time.time()
+    cleared: list[str] = []
+
+    for name, session_data in state["sessions"].items():
+        bell = session_data.get("bell", {})
+        if bell.get("unseen_count", 0) == 0:
+            continue  # nothing to clear
+        if should_clear_bell(name, state):
+            bell["unseen_count"] = 0
+            bell["seen_at"] = now
+            _bell_seen[name] = False  # reset so next bell counts fresh
+            cleared.append(name)
+
+    return cleared
+```
+
+**Step 4: Run all bell tests**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_bells.py -v
+```
+
+Expected: all 17 tests pass.
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/bells.py coordinator/tests/test_bells.py
+git commit -m "feat: bells.py — active-device gate rule and global bell clear"
+```
+
+---
+
+## Task 9: ttyd.py — Spawn ttyd Process
+
+**Files:**
+- Create: `coordinator/ttyd.py`
+- Create: `coordinator/tests/test_ttyd.py`
+
+**Step 1: Write failing tests**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/tests/test_ttyd.py`:
+
+```python
+"""Tests for coordinator/ttyd.py — ttyd process lifecycle."""
+import pathlib
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+@pytest.fixture(autouse=True)
+def use_tmp_pid_dir(tmp_path, monkeypatch):
+    """Redirect PID file operations to a temp directory."""
+    import coordinator.ttyd as ttyd_mod
+
+    monkeypatch.setattr(ttyd_mod, "TTYD_PID_DIR", tmp_path)
+    monkeypatch.setattr(ttyd_mod, "TTYD_PID_PATH", tmp_path / "ttyd.pid")
+
+
+async def test_spawn_ttyd_writes_pid_file(tmp_path):
+    import coordinator.ttyd as ttyd_mod
+
+    mock_proc = MagicMock()
+    mock_proc.pid = 12345
+
+    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
+        await ttyd_mod.spawn_ttyd("myapp")
+
+    pid_file = tmp_path / "ttyd.pid"
+    assert pid_file.exists()
+    assert pid_file.read_text().strip() == "12345"
+
+
+async def test_spawn_ttyd_uses_correct_command():
+    import coordinator.ttyd as ttyd_mod
+
+    mock_proc = MagicMock()
+    mock_proc.pid = 99
+
+    with patch("asyncio.create_subprocess_exec", return_value=mock_proc) as mock_exec:
+        await ttyd_mod.spawn_ttyd("work")
+
+    args = mock_exec.call_args[0]
+    assert "ttyd" in args
+    assert "-W" in args
+    assert "-m" in args
+    assert "3" in args
+    assert "-p" in args
+    assert "7682" in args
+    assert "tmux" in args
+    assert "attach" in args
+    assert "-t" in args
+    assert "work" in args
+
+
+async def test_spawn_ttyd_returns_process_object():
+    import coordinator.ttyd as ttyd_mod
+
+    mock_proc = MagicMock()
+    mock_proc.pid = 42
+
+    with patch("asyncio.create_subprocess_exec", return_value=mock_proc):
+        result = await ttyd_mod.spawn_ttyd("logs")
+
+    assert result is mock_proc
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_ttyd.py -v
+```
+
+Expected: `ERROR` — `ModuleNotFoundError: No module named 'coordinator.ttyd'`
+
+**Step 3: Create `coordinator/ttyd.py` with spawn**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/ttyd.py`:
+
+```python
+"""
+ttyd process lifecycle management.
+
+One ttyd instance runs at a time on TTYD_PORT (7682). It is spawned when a
+user connects to a session and killed when they disconnect or switch sessions.
+
+PID file: ~/.local/share/tmux-web/ttyd.pid
+  (Override TTYD_PID_DIR with env var TMUX_WEB_STATE_DIR for testing.)
+
+ttyd command:
+  ttyd -W -m 3 -p 7682 tmux attach -t 
+    -W      writable mode
+    -m 3    allow up to 3 simultaneous connections
+    -p 7682 listen on port 7682
+
+Orphan detection (on coordinator startup):
+  If a PID file exists from a previous run, the old ttyd process is still
+  running (ttyd is persistent, not tied to browser sessions). On startup,
+  coordinator reads the PID file and kills the orphaned process before
+  registering fresh state.
+"""
+
+import asyncio
+import os
+import pathlib
+import signal
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+_pid_dir_override = os.environ.get("TMUX_WEB_STATE_DIR")
+TTYD_PID_DIR: pathlib.Path = (
+    pathlib.Path(_pid_dir_override)
+    if _pid_dir_override
+    else pathlib.Path.home() / ".local" / "share" / "tmux-web"
+)
+TTYD_PID_PATH: pathlib.Path = TTYD_PID_DIR / "ttyd.pid"
+TTYD_PORT: int = 7682
+
+# ---------------------------------------------------------------------------
+# Active process handle (in-memory, cleared when killed)
+# ---------------------------------------------------------------------------
+
+_active_process: asyncio.subprocess.Process | None = None
+
+# ---------------------------------------------------------------------------
+# Spawn
+# ---------------------------------------------------------------------------
+
+
+async def spawn_ttyd(session_name: str) -> asyncio.subprocess.Process:
+    """
+    Spawn `ttyd -W -m 3 -p 7682 tmux attach -t `.
+
+    Writes the PID to TTYD_PID_PATH. Stores the process handle in
+    _active_process. Caller is responsible for killing any existing
+    ttyd process before calling spawn (see kill_ttyd).
+    """
+    global _active_process
+
+    TTYD_PID_DIR.mkdir(parents=True, exist_ok=True)
+
+    proc = await asyncio.create_subprocess_exec(
+        "ttyd",
+        "-W",
+        "-m", "3",
+        "-p", str(TTYD_PORT),
+        "tmux", "attach", "-t", session_name,
+        stdout=asyncio.subprocess.DEVNULL,
+        stderr=asyncio.subprocess.DEVNULL,
+    )
+
+    TTYD_PID_PATH.write_text(str(proc.pid))
+    _active_process = proc
+    return proc
+```
+
+**Step 4: Run tests to verify they pass**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_ttyd.py::test_spawn_ttyd_writes_pid_file -v
+pytest coordinator/tests/test_ttyd.py::test_spawn_ttyd_uses_correct_command -v
+pytest coordinator/tests/test_ttyd.py::test_spawn_ttyd_returns_process_object -v
+```
+
+Expected: all 3 tests pass.
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/ttyd.py coordinator/tests/test_ttyd.py
+git commit -m "feat: ttyd.py — spawn ttyd process and write PID file"
+```
+
+---
+
+## Task 10: ttyd.py — Kill and PID File Cleanup
+
+**Files:**
+- Modify: `coordinator/ttyd.py`
+- Modify: `coordinator/tests/test_ttyd.py`
+
+**Step 1: Add failing tests**
+
+Append to `/home/bkrabach/dev/web-tmux/coordinator/tests/test_ttyd.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# kill_ttyd tests
+# ---------------------------------------------------------------------------
+
+import signal
+import asyncio
+
+
+async def test_kill_ttyd_returns_false_when_no_pid_file(tmp_path):
+    import coordinator.ttyd as ttyd_mod
+
+    # No PID file exists — nothing to kill
+    result = await ttyd_mod.kill_ttyd()
+    assert result is False
+
+
+async def test_kill_ttyd_reads_pid_file_and_sends_sigterm(tmp_path):
+    import coordinator.ttyd as ttyd_mod
+
+    pid_file = tmp_path / "ttyd.pid"
+    pid_file.write_text("99999")  # fake PID
+
+    with patch("os.kill") as mock_kill, \
+         patch("asyncio.sleep", new_callable=AsyncMock):
+        # os.kill with signal 0 checks process existence — make it pass
+        # os.kill with SIGTERM kills it
+        def side_effect(pid: int, sig: int) -> None:
+            if sig == 0:
+                return  # process exists
+            # SIGTERM send — do nothing (fake)
+
+        mock_kill.side_effect = side_effect
+        result = await ttyd_mod.kill_ttyd()
+
+    assert result is True
+    assert mock_kill.called
+
+
+async def test_kill_ttyd_removes_pid_file(tmp_path):
+    import coordinator.ttyd as ttyd_mod
+
+    pid_file = tmp_path / "ttyd.pid"
+    pid_file.write_text("99999")
+
+    with patch("os.kill", side_effect=lambda pid, sig: None), \
+         patch("asyncio.sleep", new_callable=AsyncMock):
+        await ttyd_mod.kill_ttyd()
+
+    assert not pid_file.exists()
+
+
+async def test_kill_ttyd_handles_process_already_dead(tmp_path):
+    """If process is gone (ProcessLookupError), remove PID file and return True."""
+    import coordinator.ttyd as ttyd_mod
+
+    pid_file = tmp_path / "ttyd.pid"
+    pid_file.write_text("99999")
+
+    def fake_kill(pid: int, sig: int) -> None:
+        raise ProcessLookupError("no such process")
+
+    with patch("os.kill", side_effect=fake_kill):
+        result = await ttyd_mod.kill_ttyd()
+
+    assert result is True
+    assert not pid_file.exists()
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_ttyd.py::test_kill_ttyd_returns_false_when_no_pid_file -v
+```
+
+Expected: `FAILED` — `ImportError: cannot import name... kill_ttyd`
+
+**Step 3: Add `kill_ttyd` to `coordinator/ttyd.py`**
+
+Append to the bottom of `/home/bkrabach/dev/web-tmux/coordinator/ttyd.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# Kill
+# ---------------------------------------------------------------------------
+
+
+async def kill_ttyd() -> bool:
+    """
+    Kill the currently running ttyd process.
+
+    Reads PID from TTYD_PID_PATH, sends SIGTERM, waits up to 2 seconds for
+    the process to exit, then removes the PID file.
+
+    Returns True if a process was killed (or was already dead), False if
+    no PID file existed.
+    """
+    global _active_process
+
+    if not TTYD_PID_PATH.exists():
+        return False
+
+    pid_text = TTYD_PID_PATH.read_text().strip()
+    try:
+        pid = int(pid_text)
+    except ValueError:
+        TTYD_PID_PATH.unlink(missing_ok=True)
+        _active_process = None
+        return False
+
+    try:
+        os.kill(pid, 0)  # Check if process exists (raises if not)
+    except ProcessLookupError:
+        # Already dead — clean up the PID file
+        TTYD_PID_PATH.unlink(missing_ok=True)
+        _active_process = None
+        return True
+
+    try:
+        os.kill(pid, signal.SIGTERM)
+        # Give the process up to 2 seconds to exit
+        for _ in range(20):
+            await asyncio.sleep(0.1)
+            try:
+                os.kill(pid, 0)
+            except ProcessLookupError:
+                break  # Process exited
+    except ProcessLookupError:
+        pass  # Process already gone
+
+    TTYD_PID_PATH.unlink(missing_ok=True)
+    _active_process = None
+    return True
+```
+
+**Step 4: Run all ttyd tests**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_ttyd.py -v
+```
+
+Expected: all 7 tests pass.
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/ttyd.py coordinator/tests/test_ttyd.py
+git commit -m "feat: ttyd.py — kill_ttyd with SIGTERM and PID file cleanup"
+```
+
+---
+
+## Task 11: ttyd.py — Orphan Detection on Startup
+
+**Files:**
+- Modify: `coordinator/ttyd.py`
+- Modify: `coordinator/tests/test_ttyd.py`
+
+**Step 1: Add failing tests**
+
+Append to `/home/bkrabach/dev/web-tmux/coordinator/tests/test_ttyd.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# kill_orphan_ttyd tests
+# ---------------------------------------------------------------------------
+
+
+async def test_kill_orphan_ttyd_returns_false_when_no_pid_file(tmp_path):
+    import coordinator.ttyd as ttyd_mod
+
+    result = await ttyd_mod.kill_orphan_ttyd()
+    assert result is False
+
+
+async def test_kill_orphan_ttyd_kills_running_process(tmp_path):
+    import coordinator.ttyd as ttyd_mod
+
+    pid_file = tmp_path / "ttyd.pid"
+    pid_file.write_text("88888")
+
+    with patch("os.kill", side_effect=lambda pid, sig: None), \
+         patch("asyncio.sleep", new_callable=AsyncMock):
+        result = await ttyd_mod.kill_orphan_ttyd()
+
+    assert result is True
+    assert not pid_file.exists()
+
+
+async def test_kill_orphan_ttyd_handles_pid_file_with_dead_process(tmp_path):
+    """PID file exists but process is already dead — still return True, remove file."""
+    import coordinator.ttyd as ttyd_mod
+
+    pid_file = tmp_path / "ttyd.pid"
+    pid_file.write_text("77777")
+
+    def fake_kill(pid: int, sig: int) -> None:
+        raise ProcessLookupError("no such process")
+
+    with patch("os.kill", side_effect=fake_kill):
+        result = await ttyd_mod.kill_orphan_ttyd()
+
+    assert result is True
+    assert not pid_file.exists()
+
+
+async def test_kill_orphan_ttyd_handles_invalid_pid_file_content(tmp_path):
+    """PID file contains garbage — remove it, return False."""
+    import coordinator.ttyd as ttyd_mod
+
+    pid_file = tmp_path / "ttyd.pid"
+    pid_file.write_text("not-a-pid")
+
+    result = await ttyd_mod.kill_orphan_ttyd()
+    assert result is False
+    assert not pid_file.exists()
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_ttyd.py::test_kill_orphan_ttyd_returns_false_when_no_pid_file -v
+```
+
+Expected: `FAILED` — `ImportError: cannot import name... kill_orphan_ttyd`
+
+**Step 3: Add `kill_orphan_ttyd` to `coordinator/ttyd.py`**
+
+Append to the bottom of `/home/bkrabach/dev/web-tmux/coordinator/ttyd.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# Orphan detection (run on startup)
+# ---------------------------------------------------------------------------
+
+
+async def kill_orphan_ttyd() -> bool:
+    """
+    On coordinator startup: check for a stale PID file from a previous run.
+
+    If found, kill the process (if still running) and remove the PID file.
+    This prevents two ttyd instances running simultaneously after a coordinator
+    restart or crash.
+
+    Returns True if an orphan was found (dead or alive), False if no PID file.
+    """
+    return await kill_ttyd()
+```
+
+**Step 4: Run all ttyd tests**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_ttyd.py -v
+```
+
+Expected: all 11 tests pass.
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/ttyd.py coordinator/tests/test_ttyd.py
+git commit -m "feat: ttyd.py — orphan detection on coordinator startup"
+```
+
+---
+
+## Task 12: main.py — FastAPI Skeleton, Lifespan, Health Endpoint
+
+**Files:**
+- Create: `coordinator/main.py`
+- Create: `coordinator/tests/test_api.py`
+
+**Step 1: Write failing tests**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/tests/test_api.py`:
+
+```python
+"""Tests for coordinator/main.py — FastAPI endpoints."""
+import json
+import pathlib
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+
+@pytest.fixture(autouse=True)
+def patch_startup_and_state(tmp_path, monkeypatch):
+    """
+    Prevent lifespan from touching tmux, ttyd, or production state files.
+
+    - Redirects state files to tmp_path
+    - Mocks kill_orphan_ttyd (no real ttyd)
+    - Replaces poll loop with a no-op (no real tmux)
+    """
+    import coordinator.state as state_mod
+    import coordinator.ttyd as ttyd_mod
+
+    monkeypatch.setattr(state_mod, "STATE_DIR", tmp_path)
+    monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state.json")
+    monkeypatch.setattr(ttyd_mod, "TTYD_PID_DIR", tmp_path)
+    monkeypatch.setattr(ttyd_mod, "TTYD_PID_PATH", tmp_path / "ttyd.pid")
+
+    with patch("coordinator.ttyd.kill_orphan_ttyd", new_callable=AsyncMock), \
+         patch("coordinator.main._poll_loop", new_callable=AsyncMock):
+        yield
+
+
+@pytest.fixture
+def client():
+    """Return a TestClient with lifespan running."""
+    from coordinator.main import app
+
+    with TestClient(app) as c:
+        yield c
+
+
+def test_health_returns_200(client):
+    response = client.get("/health")
+    assert response.status_code == 200
+
+
+def test_health_returns_ok_status(client):
+    response = client.get("/health")
+    data = response.json()
+    assert data["status"] == "ok"
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_api.py::test_health_returns_200 -v
+```
+
+Expected: `ERROR` — `ModuleNotFoundError: No module named 'coordinator.main'`
+
+**Step 3: Create `coordinator/main.py`**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/main.py`:
+
+```python
+"""
+Web-Tmux Dashboard — Session Coordinator
+
+FastAPI server on port 8080. Serves JSON API for the dashboard frontend.
+
+Startup (lifespan):
+  1. Kill any orphaned ttyd process from previous run
+  2. Start background poll loop (runs every POLL_INTERVAL seconds)
+
+Poll loop (every 2s by default, configurable via POLL_INTERVAL env var):
+  - Enumerate tmux sessions
+  - Update capture-pane snapshots (in-memory cache)
+  - Process bell flags (detect 0→1 transitions, update unseen_count)
+  - Apply bell clear rule (active-device gate)
+  - Prune stale devices (> 5 min without heartbeat)
+  - Atomically write updated state.json
+
+Endpoints:
+  GET  /health                          — liveness check
+  GET  /api/state                       — full persistent state
+  PATCH /api/state                      — update session_order
+  GET  /api/sessions                    — sessions + snapshots + bell flags
+  POST /api/sessions/{name}/connect     — spawn ttyd for named session
+  DELETE /api/sessions/current          — kill ttyd, clear active session
+  POST /api/heartbeat                   — device heartbeat + view state
+
+# NOTE (Phase 2):
+  The backtick (`) key toggles fullscreen → grid. When frontend implements
+  this, it must send a heartbeat with view_mode="grid" immediately on toggle.
+"""
+
+import asyncio
+import os
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI, HTTPException
+from fastapi.responses import JSONResponse
+
+from coordinator import bells, sessions, state, ttyd
+
+# ---------------------------------------------------------------------------
+# Configuration
+# ---------------------------------------------------------------------------
+
+POLL_INTERVAL: float = float(os.environ.get("POLL_INTERVAL", "2.0"))
+
+# ---------------------------------------------------------------------------
+# Background poll loop
+# ---------------------------------------------------------------------------
+
+_poll_task: asyncio.Task | None = None
+
+
+async def _run_poll_cycle() -> None:
+    """
+    Single poll cycle: enumerate sessions, update snapshots, process bells,
+    apply clear rule, prune devices, write state atomically.
+    """
+    session_names = await sessions.enumerate_sessions()
+    await sessions.update_session_cache(session_names)
+
+    existing = set(session_names)
+
+    async with state.state_lock:
+        current = state.load_state()
+
+        # Reconcile session_order: preserve user order, add new, remove deleted
+        known_order = current["session_order"]
+        new_order = [s for s in known_order if s in existing]
+        new_sessions_to_add = [s for s in session_names if s not in set(known_order)]
+        new_order.extend(new_sessions_to_add)
+        current["session_order"] = new_order
+
+        # Ensure every session has a bell entry
+        for name in session_names:
+            if name not in current["sessions"]:
+                current["sessions"][name] = {"bell": state.empty_bell()}
+
+        # Remove sessions that no longer exist in tmux
+        current["sessions"] = {
+            k: v for k, v in current["sessions"].items() if k in existing
+        }
+
+        # Clear active_session if that session is gone
+        if current["active_session"] not in existing:
+            current["active_session"] = None
+
+        # Process bell flags and apply clear rule
+        await bells.process_bell_flags(session_names, current)
+        bells.apply_bell_clear_rule(current)
+
+        # Prune stale devices
+        state.prune_devices(current)
+
+        # Write back
+        state.save_state(current)
+
+
+async def _poll_loop() -> None:
+    """Runs _run_poll_cycle() every POLL_INTERVAL seconds indefinitely."""
+    while True:
+        try:
+            await _run_poll_cycle()
+        except Exception:
+            pass  # Log but don't crash the loop
+        await asyncio.sleep(POLL_INTERVAL)
+
+
+# ---------------------------------------------------------------------------
+# Lifespan
+# ---------------------------------------------------------------------------
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    global _poll_task
+    await ttyd.kill_orphan_ttyd()
+    _poll_task = asyncio.create_task(_poll_loop())
+    yield
+    if _poll_task:
+        _poll_task.cancel()
+        try:
+            await _poll_task
+        except asyncio.CancelledError:
+            pass
+
+
+# ---------------------------------------------------------------------------
+# App
+# ---------------------------------------------------------------------------
+
+app = FastAPI(
+    title="tmux-web coordinator",
+    version="0.1.0",
+    lifespan=lifespan,
+)
+
+# ---------------------------------------------------------------------------
+# Endpoints
+# ---------------------------------------------------------------------------
+
+
+@app.get("/health")
+async def health() -> dict:
+    """Liveness check."""
+    return {"status": "ok"}
+```
+
+**Step 4: Run tests to verify they pass**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_api.py::test_health_returns_200 coordinator/tests/test_api.py::test_health_returns_ok_status -v
+```
+
+Expected:
+```
+PASSED coordinator/tests/test_api.py::test_health_returns_200
+PASSED coordinator/tests/test_api.py::test_health_returns_ok_status
+2 passed in 0.XXs
+```
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/main.py coordinator/tests/test_api.py
+git commit -m "feat: main.py — FastAPI skeleton, lifespan, poll loop, /health"
+```
+
+---
+
+## Task 13: main.py — GET /api/state and PATCH /api/state
+
+**Files:**
+- Modify: `coordinator/main.py`
+- Modify: `coordinator/tests/test_api.py`
+
+**Step 1: Add failing tests**
+
+Append to `/home/bkrabach/dev/web-tmux/coordinator/tests/test_api.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# GET /api/state and PATCH /api/state
+# ---------------------------------------------------------------------------
+
+
+def test_get_state_returns_full_state(client):
+    response = client.get("/api/state")
+    assert response.status_code == 200
+    data = response.json()
+    assert "active_session" in data
+    assert "session_order" in data
+    assert "sessions" in data
+    assert "devices" in data
+
+
+def test_get_state_active_session_is_none_initially(client):
+    response = client.get("/api/state")
+    data = response.json()
+    assert data["active_session"] is None
+
+
+def test_patch_state_updates_session_order(client, tmp_path):
+    # Write initial state with some sessions
+    import coordinator.state as state_mod
+
+    initial = state_mod.empty_state()
+    initial["session_order"] = ["main", "work", "logs"]
+    state_mod.save_state(initial)
+
+    response = client.patch(
+        "/api/state",
+        json={"session_order": ["logs", "work", "main"]},
+    )
+    assert response.status_code == 200
+
+    # Verify persisted
+    check = client.get("/api/state")
+    assert check.json()["session_order"] == ["logs", "work", "main"]
+
+
+def test_patch_state_rejects_non_list_session_order(client):
+    response = client.patch(
+        "/api/state",
+        json={"session_order": "not-a-list"},
+    )
+    assert response.status_code == 422
+
+
+def test_patch_state_ignores_unknown_fields(client):
+    """PATCH only updates known mutable fields; unknown fields are silently ignored."""
+    response = client.patch(
+        "/api/state",
+        json={"session_order": [], "unknown_field": "value"},
+    )
+    assert response.status_code == 200
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_api.py::test_get_state_returns_full_state -v
+```
+
+Expected: `FAILED` — 404 Not Found (endpoint doesn't exist yet)
+
+**Step 3: Add endpoints to `coordinator/main.py`**
+
+Add these imports and endpoint definitions after the `/health` endpoint in `/home/bkrabach/dev/web-tmux/coordinator/main.py`:
+
+```python
+# Add to imports at the top of main.py:
+from pydantic import BaseModel
+
+
+# Add after the /health endpoint:
+
+class StatePatch(BaseModel):
+    session_order: list[str]
+
+
+@app.get("/api/state")
+async def get_state() -> dict:
+    """Return the full persistent state."""
+    return await state.read_state()
+
+
+@app.patch("/api/state")
+async def patch_state(patch: StatePatch) -> dict:
+    """Update mutable state fields. Currently supports session_order only."""
+    async with state.state_lock:
+        current = state.load_state()
+        current["session_order"] = patch.session_order
+        state.save_state(current)
+    return current
+```
+
+**Step 4: Run tests**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_api.py -v -k "state"
+```
+
+Expected: all 5 state tests pass.
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/main.py coordinator/tests/test_api.py
+git commit -m "feat: main.py — GET /api/state and PATCH /api/state endpoints"
+```
+
+---
+
+## Task 14: main.py — GET /api/sessions
+
+**Files:**
+- Modify: `coordinator/main.py`
+- Modify: `coordinator/tests/test_api.py`
+
+**Step 1: Add failing tests**
+
+Append to `/home/bkrabach/dev/web-tmux/coordinator/tests/test_api.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# GET /api/sessions
+# ---------------------------------------------------------------------------
+
+
+def test_get_sessions_returns_list(client):
+    with patch("coordinator.sessions.get_session_list", return_value=["main", "work"]), \
+         patch("coordinator.sessions.get_snapshots",
+               return_value={"main": "output\n", "work": "other\n"}):
+        response = client.get("/api/sessions")
+    assert response.status_code == 200
+    data = response.json()
+    assert isinstance(data, list)
+
+
+def test_get_sessions_each_item_has_required_fields(client):
+    with patch("coordinator.sessions.get_session_list", return_value=["main"]), \
+         patch("coordinator.sessions.get_snapshots", return_value={"main": "output\n"}):
+        response = client.get("/api/sessions")
+    data = response.json()
+    assert len(data) == 1
+    session = data[0]
+    assert "name" in session
+    assert "snapshot" in session
+    assert "bell" in session
+
+
+def test_get_sessions_includes_snapshot_text(client):
+    with patch("coordinator.sessions.get_session_list", return_value=["myapp"]), \
+         patch("coordinator.sessions.get_snapshots",
+               return_value={"myapp": "hello world\n"}):
+        response = client.get("/api/sessions")
+    data = response.json()
+    assert data[0]["snapshot"] == "hello world\n"
+
+
+def test_get_sessions_includes_bell_state(client, tmp_path):
+    import coordinator.state as state_mod
+
+    s = state_mod.empty_state()
+    s["sessions"]["myapp"] = {"bell": state_mod.empty_bell()}
+    s["sessions"]["myapp"]["bell"]["unseen_count"] = 2
+    state_mod.save_state(s)
+
+    with patch("coordinator.sessions.get_session_list", return_value=["myapp"]), \
+         patch("coordinator.sessions.get_snapshots", return_value={"myapp": ""}):
+        response = client.get("/api/sessions")
+    data = response.json()
+    assert data[0]["bell"]["unseen_count"] == 2
+
+
+def test_get_sessions_returns_empty_list_when_no_sessions(client):
+    with patch("coordinator.sessions.get_session_list", return_value=[]), \
+         patch("coordinator.sessions.get_snapshots", return_value={}):
+        response = client.get("/api/sessions")
+    assert response.json() == []
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_api.py::test_get_sessions_returns_list -v
+```
+
+Expected: `FAILED` — 404 Not Found
+
+**Step 3: Add `GET /api/sessions` to `coordinator/main.py`**
+
+Append after the `patch_state` endpoint in `/home/bkrabach/dev/web-tmux/coordinator/main.py`:
+
+```python
+@app.get("/api/sessions")
+async def get_sessions() -> list[dict]:
+    """
+    Return all known tmux sessions with their capture-pane snapshots and bell state.
+
+    Data comes from:
+      - sessions.get_session_list() — cached session names (updated by poll loop)
+      - sessions.get_snapshots()    — cached capture-pane output (updated by poll loop)
+      - state.read_state()          — persistent state (bell counts, etc.)
+    """
+    session_names = sessions.get_session_list()
+    snapshots = sessions.get_snapshots()
+    current_state = await state.read_state()
+
+    result: list[dict] = []
+    for name in session_names:
+        bell_data = (
+            current_state.get("sessions", {})
+            .get(name, {})
+            .get("bell", state.empty_bell())
+        )
+        result.append({
+            "name": name,
+            "snapshot": snapshots.get(name, ""),
+            "bell": bell_data,
+        })
+    return result
+```
+
+**Step 4: Run tests**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_api.py -v -k "sessions"
+```
+
+Expected: all 5 sessions tests pass.
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/main.py coordinator/tests/test_api.py
+git commit -m "feat: main.py — GET /api/sessions endpoint"
+```
+
+---
+
+## Task 15: main.py — POST /api/sessions/{name}/connect and DELETE /api/sessions/current
+
+**Files:**
+- Modify: `coordinator/main.py`
+- Modify: `coordinator/tests/test_api.py`
+
+**Step 1: Add failing tests**
+
+Append to `/home/bkrabach/dev/web-tmux/coordinator/tests/test_api.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# POST /api/sessions/{name}/connect and DELETE /api/sessions/current
+# ---------------------------------------------------------------------------
+
+
+def test_connect_session_returns_200(client, tmp_path):
+    import coordinator.state as state_mod
+
+    s = state_mod.empty_state()
+    s["sessions"]["work"] = {"bell": state_mod.empty_bell()}
+    s["session_order"] = ["work"]
+    state_mod.save_state(s)
+
+    mock_proc = MagicMock()
+    mock_proc.pid = 12345
+
+    with patch("coordinator.ttyd.kill_ttyd", new_callable=AsyncMock), \
+         patch("coordinator.ttyd.spawn_ttyd", new_callable=AsyncMock,
+               return_value=mock_proc):
+        response = client.post("/api/sessions/work/connect")
+
+    assert response.status_code == 200
+
+
+def test_connect_session_sets_active_session(client, tmp_path):
+    import coordinator.state as state_mod
+
+    s = state_mod.empty_state()
+    s["sessions"]["work"] = {"bell": state_mod.empty_bell()}
+    s["session_order"] = ["work"]
+    state_mod.save_state(s)
+
+    mock_proc = MagicMock()
+    mock_proc.pid = 12345
+
+    with patch("coordinator.ttyd.kill_ttyd", new_callable=AsyncMock), \
+         patch("coordinator.ttyd.spawn_ttyd", new_callable=AsyncMock,
+               return_value=mock_proc):
+        client.post("/api/sessions/work/connect")
+
+    check = client.get("/api/state")
+    assert check.json()["active_session"] == "work"
+
+
+def test_connect_session_kills_existing_ttyd(client, tmp_path):
+    import coordinator.state as state_mod
+
+    s = state_mod.empty_state()
+    s["sessions"]["work"] = {"bell": state_mod.empty_bell()}
+    s["session_order"] = ["work"]
+    state_mod.save_state(s)
+
+    mock_proc = MagicMock()
+    mock_proc.pid = 1
+
+    with patch("coordinator.ttyd.kill_ttyd", new_callable=AsyncMock) as mock_kill, \
+         patch("coordinator.ttyd.spawn_ttyd", new_callable=AsyncMock,
+               return_value=mock_proc):
+        client.post("/api/sessions/work/connect")
+
+    mock_kill.assert_called_once()
+
+
+def test_connect_nonexistent_session_returns_404(client, tmp_path):
+    import coordinator.state as state_mod
+
+    s = state_mod.empty_state()
+    state_mod.save_state(s)
+
+    with patch("coordinator.sessions.get_session_list", return_value=["main"]):
+        response = client.post("/api/sessions/ghost/connect")
+
+    assert response.status_code == 404
+
+
+def test_delete_current_kills_ttyd_and_clears_active(client, tmp_path):
+    import coordinator.state as state_mod
+
+    s = state_mod.empty_state()
+    s["active_session"] = "work"
+    state_mod.save_state(s)
+
+    with patch("coordinator.ttyd.kill_ttyd", new_callable=AsyncMock) as mock_kill:
+        response = client.delete("/api/sessions/current")
+
+    assert response.status_code == 200
+    mock_kill.assert_called_once()
+
+    check = client.get("/api/state")
+    assert check.json()["active_session"] is None
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_api.py::test_connect_session_returns_200 -v
+```
+
+Expected: `FAILED` — 404 Not Found (endpoint missing)
+
+**Step 3: Add connect and delete endpoints to `coordinator/main.py`**
+
+Append after the `get_sessions` endpoint in `/home/bkrabach/dev/web-tmux/coordinator/main.py`:
+
+```python
+@app.post("/api/sessions/{name}/connect")
+async def connect_session(name: str) -> dict:
+    """
+    Kill any existing ttyd, spawn a fresh one for the named session,
+    and update active_session in state.
+
+    Returns 404 if the session is not in the current session list.
+    """
+    known = sessions.get_session_list()
+    if known and name not in known:
+        raise HTTPException(status_code=404, detail=f"Session '{name}' not found")
+
+    await ttyd.kill_ttyd()
+    await ttyd.spawn_ttyd(name)
+
+    async with state.state_lock:
+        current = state.load_state()
+        current["active_session"] = name
+        state.save_state(current)
+
+    return {"active_session": name, "ttyd_port": ttyd.TTYD_PORT}
+
+
+@app.delete("/api/sessions/current")
+async def disconnect_session() -> dict:
+    """
+    Kill the active ttyd process and clear active_session from state.
+    """
+    await ttyd.kill_ttyd()
+
+    async with state.state_lock:
+        current = state.load_state()
+        current["active_session"] = None
+        state.save_state(current)
+
+    return {"active_session": None}
+```
+
+**Step 4: Run tests**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_api.py -v -k "connect or delete"
+```
+
+Expected: all 5 tests pass.
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/main.py coordinator/tests/test_api.py
+git commit -m "feat: main.py — connect/disconnect session endpoints"
+```
+
+---
+
+## Task 16: main.py — POST /api/heartbeat
+
+**Files:**
+- Modify: `coordinator/main.py`
+- Modify: `coordinator/tests/test_api.py`
+
+**Step 1: Add failing tests**
+
+Append to `/home/bkrabach/dev/web-tmux/coordinator/tests/test_api.py`:
+
+```python
+# ---------------------------------------------------------------------------
+# POST /api/heartbeat
+# ---------------------------------------------------------------------------
+
+import time
+
+
+def test_heartbeat_returns_200(client):
+    response = client.post(
+        "/api/heartbeat",
+        json={
+            "device_id": "d-abc123",
+            "label": "Laptop Chrome",
+            "viewing_session": None,
+            "view_mode": "grid",
+            "last_interaction_at": time.time(),
+        },
+    )
+    assert response.status_code == 200
+
+
+def test_heartbeat_registers_new_device(client):
+    now = time.time()
+    client.post(
+        "/api/heartbeat",
+        json={
+            "device_id": "d-new",
+            "label": "Test Phone",
+            "viewing_session": "work",
+            "view_mode": "fullscreen",
+            "last_interaction_at": now,
+        },
+    )
+    check = client.get("/api/state")
+    devices = check.json()["devices"]
+    assert "d-new" in devices
+    assert devices["d-new"]["label"] == "Test Phone"
+    assert devices["d-new"]["viewing_session"] == "work"
+
+
+def test_heartbeat_updates_existing_device(client):
+    now = time.time()
+
+    # First heartbeat
+    client.post(
+        "/api/heartbeat",
+        json={
+            "device_id": "d-abc",
+            "label": "Laptop",
+            "viewing_session": None,
+            "view_mode": "grid",
+            "last_interaction_at": now - 10,
+        },
+    )
+
+    # Second heartbeat with updated view state
+    client.post(
+        "/api/heartbeat",
+        json={
+            "device_id": "d-abc",
+            "label": "Laptop",
+            "viewing_session": "dev-server",
+            "view_mode": "fullscreen",
+            "last_interaction_at": now,
+        },
+    )
+
+    check = client.get("/api/state")
+    device = check.json()["devices"]["d-abc"]
+    assert device["viewing_session"] == "dev-server"
+    assert device["view_mode"] == "fullscreen"
+
+
+def test_heartbeat_missing_device_id_returns_422(client):
+    response = client.post(
+        "/api/heartbeat",
+        json={
+            "label": "Laptop",
+            "viewing_session": None,
+            "view_mode": "grid",
+            "last_interaction_at": time.time(),
+        },
+    )
+    assert response.status_code == 422
+
+
+def test_heartbeat_invalid_view_mode_returns_422(client):
+    response = client.post(
+        "/api/heartbeat",
+        json={
+            "device_id": "d-abc",
+            "label": "Laptop",
+            "viewing_session": None,
+            "view_mode": "invalid-mode",   # must be "grid" or "fullscreen"
+            "last_interaction_at": time.time(),
+        },
+    )
+    assert response.status_code == 422
+```
+
+**Step 2: Run to verify failures**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_api.py::test_heartbeat_returns_200 -v
+```
+
+Expected: `FAILED` — 404 Not Found
+
+**Step 3: Add heartbeat endpoint to `coordinator/main.py`**
+
+Add this import and endpoint in `/home/bkrabach/dev/web-tmux/coordinator/main.py`.
+
+First, add `Literal` to the imports at the top:
+```python
+from typing import Literal
+```
+
+Then append after the `disconnect_session` endpoint:
+
+```python
+class HeartbeatPayload(BaseModel):
+    device_id: str
+    label: str
+    viewing_session: str | None
+    view_mode: Literal["grid", "fullscreen"]
+    last_interaction_at: float
+
+
+@app.post("/api/heartbeat")
+async def heartbeat(payload: HeartbeatPayload) -> dict:
+    """
+    Receive a device heartbeat.
+
+    Called by the dashboard every 5 seconds. Updates device registration,
+    view state, and interaction timestamp in state.json.
+    """
+    async with state.state_lock:
+        current = state.load_state()
+        state.register_device(
+            current,
+            device_id=payload.device_id,
+            label=payload.label,
+            viewing_session=payload.viewing_session,
+            view_mode=payload.view_mode,
+            last_interaction_at=payload.last_interaction_at,
+        )
+        state.save_state(current)
+    return {"device_id": payload.device_id, "status": "ok"}
+```
+
+**Step 4: Run all tests**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_api.py -v
+```
+
+Expected: all API tests pass (approximately 25 tests).
+
+**Step 5: Run full test suite**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest -v
+```
+
+Expected: all tests pass across all test files.
+
+**Step 6: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/main.py coordinator/tests/test_api.py
+git commit -m "feat: main.py — POST /api/heartbeat endpoint"
+```
+
+---
+
+## Task 17: Integration Test — Real tmux + Full Poll Cycle
+
+**Files:**
+- Create: `coordinator/tests/test_integration.py`
+
+**⚠️ Requirements:** tmux must be installed on the host. These tests are marked `integration` and are excluded from the default pytest run. Run them explicitly.
+
+**Step 1: Write the integration test**
+
+Create `/home/bkrabach/dev/web-tmux/coordinator/tests/test_integration.py`:
+
+```python
+"""
+Integration tests — require tmux installed on the host.
+
+These tests spin up a real, isolated tmux server on a test socket and verify
+the full coordinator poll cycle end-to-end. They do NOT require a browser.
+
+Run with:
+    pytest coordinator/tests/test_integration.py -v -m integration
+
+Skip with:
+    pytest -m "not integration"   (default run excludes these)
+"""
+import asyncio
+import subprocess
+import time
+
+import pytest
+
+
+pytestmark = pytest.mark.integration
+
+
+# ---------------------------------------------------------------------------
+# Fixtures
+# ---------------------------------------------------------------------------
+
+
+@pytest.fixture(scope="module")
+def tmux_server():
+    """
+    Start an isolated tmux server on socket 'test-server'.
+    Creates one session named 'test' (220x50).
+    Tears down after all tests in this module.
+    """
+    socket = "test-server"
+    subprocess.run(
+        ["tmux", "-L", socket, "new-session", "-d", "-s", "test", "-x", "220", "-y", "50"],
+        check=True,
+        capture_output=True,
+    )
+    yield socket
+    subprocess.run(
+        ["tmux", "-L", socket, "kill-server"],
+        capture_output=True,
+    )
+
+
+@pytest.fixture(autouse=True)
+def use_tmp_state(tmp_path, monkeypatch):
+    """Redirect all state files to tmp_path for each test."""
+    import coordinator.state as state_mod
+    import coordinator.ttyd as ttyd_mod
+
+    monkeypatch.setattr(state_mod, "STATE_DIR", tmp_path)
+    monkeypatch.setattr(state_mod, "STATE_PATH", tmp_path / "state.json")
+    monkeypatch.setattr(ttyd_mod, "TTYD_PID_DIR", tmp_path)
+    monkeypatch.setattr(ttyd_mod, "TTYD_PID_PATH", tmp_path / "ttyd.pid")
+
+
+# ---------------------------------------------------------------------------
+# Helper: run tmux command against the test socket
+# ---------------------------------------------------------------------------
+
+
+def tmux(socket: str, *args: str) -> str:
+    result = subprocess.run(
+        ["tmux", "-L", socket, *args],
+        capture_output=True,
+        text=True,
+    )
+    return result.stdout.strip()
+
+
+# ---------------------------------------------------------------------------
+# Tests
+# ---------------------------------------------------------------------------
+
+
+async def test_enumerate_sessions_finds_test_session(tmux_server):
+    from coordinator.sessions import enumerate_sessions
+
+    # Temporarily make run_tmux use the test socket
+    import coordinator.sessions as sessions_mod
+
+    original = sessions_mod.run_tmux
+
+    async def patched_run_tmux(*args: str) -> str:
+        proc = await asyncio.create_subprocess_exec(
+            "tmux", "-L", tmux_server, *args,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, stderr = await proc.communicate()
+        if proc.returncode != 0:
+            raise RuntimeError(stderr.decode())
+        return stdout.decode()
+
+    sessions_mod.run_tmux = patched_run_tmux
+    try:
+        result = await enumerate_sessions()
+    finally:
+        sessions_mod.run_tmux = original
+
+    assert "test" in result
+
+
+async def test_capture_pane_returns_content(tmux_server):
+    """Send text to tmux, capture it, verify it appears in snapshot."""
+    import coordinator.sessions as sessions_mod
+
+    socket = tmux_server
+
+    # Send some text to the test session
+    subprocess.run(
+        ["tmux", "-L", socket, "send-keys", "-t", "test", "echo hello-world", "Enter"],
+        check=True,
+    )
+    time.sleep(0.3)  # wait for output to appear
+
+    async def patched_run_tmux(*args: str) -> str:
+        proc = await asyncio.create_subprocess_exec(
+            "tmux", "-L", socket, *args,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, _ = await proc.communicate()
+        return stdout.decode()
+
+    original = sessions_mod.run_tmux
+    sessions_mod.run_tmux = patched_run_tmux
+    try:
+        from coordinator.sessions import capture_pane
+        output = await capture_pane("test")
+    finally:
+        sessions_mod.run_tmux = original
+
+    assert "hello-world" in output
+
+
+async def test_bell_flag_detected_after_printf_bell(tmux_server):
+    """
+    Send a bell to the test session via printf '\a'.
+    Verify that poll_bell_flag returns True.
+    """
+    import coordinator.bells as bells_mod
+    import coordinator.sessions as sessions_mod
+
+    socket = tmux_server
+
+    async def patched_run_tmux(*args: str) -> str:
+        proc = await asyncio.create_subprocess_exec(
+            "tmux", "-L", socket, *args,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, _ = await proc.communicate()
+        return stdout.decode()
+
+    original = sessions_mod.run_tmux
+    sessions_mod.run_tmux = patched_run_tmux
+    bells_mod.run_tmux = patched_run_tmux
+
+    try:
+        # Trigger a bell
+        subprocess.run(
+            ["tmux", "-L", socket, "send-keys", "-t", "test",
+             "printf '\\a'", "Enter"],
+            check=True,
+        )
+        time.sleep(0.3)
+
+        from coordinator.bells import poll_bell_flag
+        result = await poll_bell_flag("test")
+    finally:
+        sessions_mod.run_tmux = original
+        bells_mod.run_tmux = original
+
+    assert result is True, (
+        "Bell flag should be 1 after printf '\\a'. "
+        "If this fails, re-run the spike script to verify bell behavior."
+    )
+
+
+async def test_full_poll_cycle_via_api(tmux_server, tmp_path):
+    """
+    Run a full poll cycle through the FastAPI app (via TestClient).
+
+    Verifies: session appears in /api/sessions after poll cycle runs.
+    """
+    import coordinator.sessions as sessions_mod
+    from unittest.mock import patch, AsyncMock
+
+    socket = tmux_server
+
+    async def patched_run_tmux(*args: str) -> str:
+        proc = await asyncio.create_subprocess_exec(
+            "tmux", "-L", socket, *args,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, _ = await proc.communicate()
+        return stdout.decode()
+
+    sessions_mod.run_tmux = patched_run_tmux
+
+    try:
+        from coordinator.main import _run_poll_cycle
+        await _run_poll_cycle()
+    finally:
+        # Restore: import original run_tmux
+        import importlib
+        importlib.reload(sessions_mod)
+
+    # Verify state was written with the session
+    from coordinator.state import load_state
+    s = load_state()
+    assert "test" in s["session_order"]
+
+
+async def test_state_file_written_atomically_by_poll_cycle(tmux_server, tmp_path):
+    """
+    After a poll cycle, state.json must exist and be valid JSON.
+    The .tmp file must NOT exist (os.replace was called).
+    """
+    import coordinator.sessions as sessions_mod
+    import coordinator.state as state_mod
+
+    socket = tmux_server
+
+    async def patched_run_tmux(*args: str) -> str:
+        proc = await asyncio.create_subprocess_exec(
+            "tmux", "-L", socket, *args,
+            stdout=asyncio.subprocess.PIPE,
+            stderr=asyncio.subprocess.PIPE,
+        )
+        stdout, _ = await proc.communicate()
+        return stdout.decode()
+
+    sessions_mod.run_tmux = patched_run_tmux
+
+    try:
+        from coordinator.main import _run_poll_cycle
+        await _run_poll_cycle()
+    finally:
+        import importlib
+        importlib.reload(sessions_mod)
+
+    state_path = state_mod.STATE_PATH
+    tmp_path_file = state_path.with_suffix(".tmp")
+
+    assert state_path.exists(), "state.json must exist after poll cycle"
+    assert not tmp_path_file.exists(), ".tmp file must be cleaned up (os.replace)"
+
+    # Verify it's valid JSON
+    import json
+    content = json.loads(state_path.read_text())
+    assert "active_session" in content
+    assert "session_order" in content
+```
+
+**Step 2: Add the `integration` mark to `pyproject.toml`**
+
+Append to the `[tool.pytest.ini_options]` section in `/home/bkrabach/dev/web-tmux/pyproject.toml`:
+
+```toml
+[tool.pytest.ini_options]
+testpaths = ["coordinator/tests"]
+asyncio_mode = "auto"
+addopts = "--import-mode=importlib -m 'not integration'"
+markers = [
+    "integration: marks tests that require tmux installed on the host",
+]
+```
+
+> **Note:** The `addopts` line above adds `-m 'not integration'` so integration tests are excluded from the default `pytest` run. Run integration tests explicitly with `pytest -m integration`.
+
+**Step 3: Verify unit tests still all pass**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest -v
+```
+
+Expected: all unit tests pass, integration tests are skipped (not collected due to `-m 'not integration'`).
+
+**Step 4: Run the integration tests** (requires tmux)
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_integration.py -v -m integration
+```
+
+Expected:
+```
+PASSED ...test_enumerate_sessions_finds_test_session
+PASSED ...test_capture_pane_returns_content
+PASSED ...test_bell_flag_detected_after_printf_bell
+PASSED ...test_full_poll_cycle_via_api
+PASSED ...test_state_file_written_atomically_by_poll_cycle
+5 passed in X.XXs
+```
+
+**Step 5: Commit**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git add coordinator/tests/test_integration.py pyproject.toml
+git commit -m "test: integration tests — real tmux server, full poll cycle end-to-end"
+```
+
+---
+
+## Final Verification
+
+**Run all unit tests:**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest -v
+```
+
+Expected: all tests pass with zero failures.
+
+**Run integration tests** (if tmux is available):
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+pytest coordinator/tests/test_integration.py -v -m integration
+```
+
+**Smoke test — start the server manually:**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+python3 -m uvicorn coordinator.main:app --host 0.0.0.0 --port 8080 --reload
+```
+
+Then in another terminal:
+```bash
+curl -s http://localhost:8080/health | python3 -m json.tool
+# Expected: {"status": "ok"}
+
+curl -s http://localhost:8080/api/state | python3 -m json.tool
+# Expected: full state JSON with active_session: null
+
+curl -s http://localhost:8080/api/sessions | python3 -m json.tool
+# Expected: list of running tmux sessions with snapshots
+```
+
+**Final commit (if any loose ends):**
+
+```bash
+cd /home/bkrabach/dev/web-tmux
+git status  # should be clean
+git log --oneline -20
+```
+
+---
+
+## Phase 1 Completion Checklist
+
+- [ ] Task 0: Bell flag spike script created and run, findings documented in `bells.py`
+- [ ] Task 1: `requirements.txt`, `pyproject.toml`, directory skeleton committed
+- [ ] Task 2: `state.py` — schema factories, all tests green
+- [ ] Task 3: `state.py` — atomic read/write with lock, all tests green
+- [ ] Task 4: `state.py` — device registration + pruning, all tests green
+- [ ] Task 5: `sessions.py` — session enumeration + `run_tmux`, all tests green
+- [ ] Task 6: `sessions.py` — capture-pane tests added, all tests green
+- [ ] Task 7: `bells.py` — bell polling + unseen_count, all tests green
+- [ ] Task 8: `bells.py` — active-device gate rule, all tests green
+- [ ] Task 9: `ttyd.py` — spawn + PID file, all tests green
+- [ ] Task 10: `ttyd.py` — kill + cleanup, all tests green
+- [ ] Task 11: `ttyd.py` — orphan detection, all tests green
+- [ ] Task 12: `main.py` — skeleton + lifespan + `/health`, all tests green
+- [ ] Task 13: `main.py` — `GET/PATCH /api/state`, all tests green
+- [ ] Task 14: `main.py` — `GET /api/sessions`, all tests green
+- [ ] Task 15: `main.py` — connect/disconnect endpoints, all tests green
+- [ ] Task 16: `main.py` — `POST /api/heartbeat`, all tests green
+- [ ] Task 17: Integration tests written and passing
+
+**Ready for Phase 2 (Frontend)** when all items above are checked.
+
+---
+
+## Scope Boundary Reminder
+
+Phase 1 does **not** include:
+- Any HTML, CSS, or JavaScript files
+- xterm.js integration
+- Caddy configuration
+- systemd service file
+- PWA manifest
+- Mobile layout or responsive breakpoints
+- Bell visual indicators (amber dot, pulsing animation)
+- Browser Notifications API
+- The backtick key handler (noted in code comments for Phase 2)
diff --git a/docs/plans/2026-03-26-web-tmux-phase2a-frontend-static.md b/docs/plans/2026-03-26-web-tmux-phase2a-frontend-static.md
new file mode 100644
index 0000000..124e755
--- /dev/null
+++ b/docs/plans/2026-03-26-web-tmux-phase2a-frontend-static.md
@@ -0,0 +1,1717 @@
+# Web-Tmux Dashboard Phase 2a — Frontend Static Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add static file serving to the coordinator and create the complete HTML structure and CSS for the tmux web dashboard — all views, components, and responsive behavior — with no JavaScript behavior.
+
+**Architecture:** FastAPI `StaticFiles` mount serves the `frontend/` directory from the project root. A vanilla HTML shell contains all views (overview grid, expanded terminal, command palette, bottom sheet) as pre-existing DOM elements that Phase 2b JavaScript will show/hide. CSS handles all visual structure, responsiveness, animations, and theming across 8 incremental chunks. Phase 2b will replace the stub `app.js` and `terminal.js` with full implementations.
+
+**Tech Stack:** Python FastAPI `StaticFiles` + `aiofiles`, vanilla HTML5/CSS3 (no framework, no build step), xterm.js 5.3.0 (CDN loaded in HTML for Phase 2b use), `pytest` + `httpx` (already installed) for static-serving and CSS structure tests.
+
+---
+
+## Context for the Implementer
+
+You are building Phase 2a of a browser-based tmux session dashboard. Phase 1 (already complete) built the FastAPI backend at `coordinator/`. It has 92 passing tests. Your job is to:
+
+1. Add `aiofiles` (required by FastAPI's static file server)
+2. Create the `frontend/` directory with stub JS files + complete HTML and CSS
+3. Wire `StaticFiles` into `main.py` so the coordinator serves the frontend
+4. Write complete, production-quality HTML and CSS that Phase 2b can immediately drop JavaScript into
+
+**Key constraint:** FastAPI's `StaticFiles` mount checks that the directory exists when `coordinator.main` is imported. This means `frontend/` **must exist on disk before** you add the `StaticFiles` line to `main.py` (Task 2 before Task 3 — never swap the order).
+
+**Working directory for all commands:** `/home/bkrabach/dev/web-tmux`
+
+**Run unit tests with:**
+```bash
+pytest -q
+```
+Expected baseline (before you start): `92 passed in ~0.2s`
+
+---
+
+## Task 1: Add aiofiles to requirements.txt and install it
+
+**Files:**
+- Modify: `requirements.txt`
+
+### Step 1: Read the file before editing
+```bash
+cat requirements.txt
+```
+Expected output:
+```
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
+httpx>=0.27.0
+pytest>=8.0.0
+pytest-asyncio>=0.23.0
+```
+
+### Step 2: Add aiofiles
+Open `requirements.txt` and add one line so the file reads:
+```
+fastapi>=0.115.0
+uvicorn[standard]>=0.30.0
+httpx>=0.27.0
+pytest>=8.0.0
+pytest-asyncio>=0.23.0
+aiofiles>=23.0
+```
+
+### Step 3: Install it
+```bash
+pip install aiofiles
+```
+Expected: `Successfully installed aiofiles-...` (or `Requirement already satisfied`)
+
+### Step 4: Verify the import works
+```bash
+python -c "import aiofiles; print('aiofiles ok')"
+```
+Expected output: `aiofiles ok`
+
+### Step 5: Confirm tests still pass
+```bash
+pytest -q
+```
+Expected: `92 passed`
+
+### Step 6: Commit
+```bash
+git add requirements.txt
+git commit -m "feat: add aiofiles>=23.0 dependency for StaticFiles serving"
+```
+
+---
+
+## Task 2: Create frontend/ stub files
+
+The `frontend/` directory and its stub files must exist before Task 3 adds the `StaticFiles` mount to `main.py`. FastAPI raises `RuntimeError` at import time if the directory is missing.
+
+**Files:**
+- Create: `frontend/index.html` (minimal stub)
+- Create: `frontend/style.css` (empty stub)
+- Create: `frontend/app.js` (stub — Phase 2b fills this)
+- Create: `frontend/terminal.js` (stub — Phase 2b fills this)
+
+### Step 1: Create the directory
+```bash
+mkdir -p frontend
+```
+
+### Step 2: Create frontend/index.html (stub)
+Create `frontend/index.html` with this content:
+```html
+tmux web
+```
+
+### Step 3: Create frontend/style.css (stub)
+Create `frontend/style.css` with this content:
+```css
+/* Phase 2a stub — CSS built incrementally in Tasks 6–13 */
+```
+
+### Step 4: Create frontend/app.js (stub)
+Create `frontend/app.js` with this content:
+```javascript
+// Phase 2b implementation — placeholder to allow static serving tests to pass
+```
+
+### Step 5: Create frontend/terminal.js (stub)
+Create `frontend/terminal.js` with this content:
+```javascript
+// Phase 2b implementation — placeholder to allow static serving tests to pass
+```
+
+### Step 6: Verify the directory looks right
+```bash
+ls -la frontend/
+```
+Expected output (4 files):
+```
+-rw-r--r-- app.js
+-rw-r--r-- index.html
+-rw-r--r-- style.css
+-rw-r--r-- terminal.js
+```
+
+### Step 7: Confirm tests still pass
+```bash
+pytest -q
+```
+Expected: `92 passed`
+
+### Step 8: Commit
+```bash
+git add frontend/
+git commit -m "feat: add frontend/ stub files (index.html, style.css, app.js, terminal.js)"
+```
+
+---
+
+## Task 3: Add StaticFiles mount to coordinator/main.py and 3 API tests
+
+**Files:**
+- Modify: `coordinator/main.py`
+- Modify: `coordinator/tests/test_api.py`
+
+### Step 1: Write the 3 failing tests first
+
+Open `coordinator/tests/test_api.py`. The file already has the `client` fixture at line 50. Add the following 3 test functions at the **very end** of the file (after line 472):
+
+```python
+# ---------------------------------------------------------------------------
+# Static file serving (StaticFiles mount)
+# ---------------------------------------------------------------------------
+
+
+def test_root_serves_html(client):
+    """GET / serves index.html with text/html content-type."""
+    response = client.get("/")
+    assert response.status_code == 200
+    assert "text/html" in response.headers["content-type"]
+
+
+def test_style_css_served(client):
+    """GET /style.css returns CSS content."""
+    response = client.get("/style.css")
+    assert response.status_code == 200
+    assert "text/css" in response.headers["content-type"]
+
+
+def test_api_routes_not_shadowed(client):
+    """API routes still work after StaticFiles mount — first-match-wins ordering."""
+    response = client.get("/api/sessions")
+    assert response.status_code == 200
+    assert isinstance(response.json(), list)
+```
+
+### Step 2: Run only the new tests to verify they fail
+
+```bash
+pytest coordinator/tests/test_api.py::test_root_serves_html coordinator/tests/test_api.py::test_style_css_served coordinator/tests/test_api.py::test_api_routes_not_shadowed -v
+```
+
+Expected: All 3 tests **FAIL** — `GET /` currently returns 404 because there is no static file route yet. The failure looks like:
+```
+FAILED ... test_root_serves_html — assert 404 == 200
+```
+
+### Step 3: Add the StaticFiles mount to coordinator/main.py
+
+Open `coordinator/main.py`. You need to make two edits:
+
+**Edit A — add imports** at the top of the file. After the existing imports block (after line 17, `from fastapi import FastAPI, HTTPException`), add two new imports. The updated imports section should look like:
+
+```python
+import asyncio
+import contextlib
+import logging
+import os
+import pathlib
+from typing import Literal
+
+from fastapi import FastAPI, HTTPException
+from fastapi.staticfiles import StaticFiles
+from pydantic import BaseModel
+```
+
+**Edit B — add frontend directory path and mount** at the very bottom of the file (after line 285, the last line of the `heartbeat` route). Add:
+
+```python
+
+
+# ---------------------------------------------------------------------------
+# Static files — MUST come AFTER all API routes (first-match-wins in FastAPI)
+# ---------------------------------------------------------------------------
+
+_FRONTEND_DIR = pathlib.Path(__file__).parent.parent / "frontend"
+
+# html=True makes GET / serve index.html automatically
+app.mount("/", StaticFiles(directory=str(_FRONTEND_DIR), html=True), name="frontend")
+```
+
+### Step 4: Run the new tests to verify they pass
+
+```bash
+pytest coordinator/tests/test_api.py::test_root_serves_html coordinator/tests/test_api.py::test_style_css_served coordinator/tests/test_api.py::test_api_routes_not_shadowed -v
+```
+
+Expected: All 3 tests **PASS**.
+
+### Step 5: Run the full test suite to confirm nothing broke
+
+```bash
+pytest -q
+```
+
+Expected: `95 passed` (92 original + 3 new)
+
+### Step 6: Commit
+
+```bash
+git add coordinator/main.py coordinator/tests/test_api.py
+git commit -m "feat: add StaticFiles mount and 3 static-serving tests"
+```
+
+---
+
+## Task 4: Create frontend/manifest.json
+
+**Files:**
+- Create: `frontend/manifest.json`
+
+No test needed for this task — manifest validity is validated by the browser at PWA install time. The file structure is verified when index.html references it (Task 5).
+
+### Step 1: Create frontend/manifest.json
+
+Create `frontend/manifest.json` with this content exactly:
+
+```json
+{
+  "name": "tmux web",
+  "short_name": "tmux web",
+  "description": "Browser-based tmux session dashboard",
+  "start_url": "/",
+  "display": "standalone",
+  "orientation": "any",
+  "background_color": "#0a0e14",
+  "theme_color": "#0a0e14",
+  "icons": [
+    {
+      "src": "/icons/icon-192.png",
+      "sizes": "192x192",
+      "type": "image/png",
+      "purpose": "any maskable"
+    },
+    {
+      "src": "/icons/icon-512.png",
+      "sizes": "512x512",
+      "type": "image/png",
+      "purpose": "any maskable"
+    }
+  ]
+}
+```
+
+Note: The icon files (`/icons/icon-192.png`, `/icons/icon-512.png`) are deferred to Phase 3. The manifest is valid without them for development — browsers will simply show no icon.
+
+### Step 2: Verify it is valid JSON
+
+```bash
+python -c "import json; json.load(open('frontend/manifest.json')); print('manifest.json valid')"
+```
+
+Expected: `manifest.json valid`
+
+### Step 3: Verify it is served correctly
+
+```bash
+uvicorn coordinator.main:app --host 127.0.0.1 --port 8099 &
+sleep 1
+curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8099/manifest.json
+kill %1
+```
+
+Expected HTTP status: `200`
+
+### Step 4: Commit
+
+```bash
+git add frontend/manifest.json
+git commit -m "feat: add PWA manifest.json"
+```
+
+---
+
+## Task 5: Complete frontend/index.html — full HTML shell
+
+Replace the stub `index.html` with the complete HTML that contains every view and DOM element Phase 2b JavaScript will manipulate.
+
+**Files:**
+- Modify: `frontend/index.html`
+- Create: `coordinator/tests/test_frontend_html.py`
+
+### Step 1: Write the failing HTML tests first
+
+Create `coordinator/tests/test_frontend_html.py`:
+
+```python
+"""
+Smoke tests for frontend/index.html — verifies all DOM elements and
+meta tags required by Phase 2b JavaScript are present.
+No browser required — pure file content assertions.
+"""
+
+import pathlib
+
+HTML_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "index.html"
+
+
+def read_html() -> str:
+    return HTML_PATH.read_text()
+
+
+def test_html_pwa_meta():
+    """index.html has required PWA meta tags."""
+    html = read_html()
+    assert 'name="apple-mobile-web-app-capable"' in html
+    assert 'rel="manifest"' in html
+    assert "theme-color" in html
+    assert 'name="apple-mobile-web-app-status-bar-style"' in html
+
+
+def test_html_viewport_suppresses_pinch_zoom():
+    """Viewport meta suppresses pinch-zoom (required for terminal UX)."""
+    html = read_html()
+    assert "maximum-scale=1.0" in html
+    assert "user-scalable=no" in html
+
+
+def test_html_required_views():
+    """index.html contains both top-level views and the session grid."""
+    html = read_html()
+    assert 'id="view-overview"' in html
+    assert 'id="view-expanded"' in html
+    assert 'id="session-grid"' in html
+    assert 'id="terminal-container"' in html
+    assert 'id="empty-state"' in html
+
+
+def test_html_expanded_view_elements():
+    """Expanded view has back button, session name, and palette trigger."""
+    html = read_html()
+    assert 'id="back-btn"' in html
+    assert 'id="expanded-session-name"' in html
+    assert 'id="palette-trigger"' in html
+    assert 'id="reconnect-overlay"' in html
+
+
+def test_html_command_palette():
+    """Command palette overlay has input and list."""
+    html = read_html()
+    assert 'id="command-palette"' in html
+    assert 'id="palette-input"' in html
+    assert 'id="palette-list"' in html
+    assert 'id="palette-backdrop"' in html
+
+
+def test_html_bottom_sheet():
+    """Bottom sheet and session pill are present."""
+    html = read_html()
+    assert 'id="bottom-sheet"' in html
+    assert 'id="sheet-list"' in html
+    assert 'id="sheet-backdrop"' in html
+    assert 'id="session-pill"' in html
+    assert 'id="session-pill-label"' in html
+    assert 'id="session-pill-bell"' in html
+
+
+def test_html_toast():
+    """Toast notification element is present."""
+    html = read_html()
+    assert 'id="toast"' in html
+    assert 'aria-live="polite"' in html
+
+
+def test_html_scripts():
+    """app.js, terminal.js, and xterm.js CDN scripts are loaded."""
+    html = read_html()
+    assert 'src="/app.js"' in html
+    assert 'src="/terminal.js"' in html
+    assert "xterm" in html  # xterm.js CDN
+
+
+def test_html_xterm_css():
+    """xterm.js CSS is loaded from CDN."""
+    html = read_html()
+    assert "xterm.css" in html
+
+
+def test_html_style_css():
+    """Local style.css is linked."""
+    html = read_html()
+    assert 'href="/style.css"' in html
+```
+
+### Step 2: Run the new tests to verify they fail on the stub
+
+```bash
+pytest coordinator/tests/test_frontend_html.py -v
+```
+
+Expected: All tests **FAIL** — the stub `index.html` is a bare minimum HTML5 skeleton with none of the required elements.
+
+### Step 3: Replace frontend/index.html with the complete HTML
+
+Overwrite `frontend/index.html` with this complete content:
+
+```html
+
+
+
+  
+  
+  
+  
+  
+  
+  
+  
+  tmux web
+
+
+
+  
+  
+
+

tmux web

+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +``` + +### Step 4: Run the HTML tests to verify they pass + +```bash +pytest coordinator/tests/test_frontend_html.py -v +``` + +Expected: All 10 tests **PASS**. + +### Step 5: Run the full test suite to confirm nothing broke + +```bash +pytest -q +``` + +Expected: `105 passed` (95 from Task 3 + 10 new HTML tests) + +### Step 6: Commit + +```bash +git add frontend/index.html coordinator/tests/test_frontend_html.py +git commit -m "feat: add complete index.html dashboard shell with all views and components" +``` + +--- + +## Task 6: style.css — Design tokens, dark base, typography + +**Files:** +- Create: `coordinator/tests/test_frontend_css.py` +- Modify: `frontend/style.css` + +### Step 1: Write the failing CSS token test + +Create `coordinator/tests/test_frontend_css.py`: + +```python +""" +Smoke tests for frontend/style.css — verifies CSS structure without a browser. +Each test function checks that specific selectors and properties are present +after their corresponding CSS task has been implemented. +""" + +import pathlib + +CSS_PATH = pathlib.Path(__file__).parent.parent.parent / "frontend" / "style.css" + + +def read_css() -> str: + return CSS_PATH.read_text() + + +# ── Task 6: Design tokens ───────────────────────────────────────────────────── + + +def test_css_design_tokens(): + """CSS file defines all required custom properties.""" + css = read_css() + assert "--bg:" in css + assert "--bell:" in css + assert "--font-mono:" in css + assert "--tile-height:" in css + assert "--t-zoom:" in css +``` + +### Step 2: Run the test to verify it fails on the stub + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_design_tokens -v +``` + +Expected: **FAIL** — the stub CSS contains none of these custom properties. + +### Step 3: Replace frontend/style.css with the design tokens + dark base + +Overwrite `frontend/style.css` with: + +```css +/* ── Design Tokens ── */ +:root { + /* Background */ + --bg: #0a0e14; + --bg-secondary: #0d1117; + --bg-tile: #0f1419; + --bg-header: #161b22; + --bg-overlay: rgba(10, 14, 20, 0.85); + + /* Text */ + --text: #c9d1d9; + --text-dim: #6e7681; + --text-muted: #8b949e; + + /* Borders */ + --border: #21262d; + --border-subtle: #161b22; + + /* Accent */ + --accent: #58a6ff; + --accent-dim: rgba(88, 166, 255, 0.15); + + /* Bell (amber) */ + --bell: #E8A040; + --bell-glow: rgba(232, 160, 64, 0.25); + --bell-border: rgba(232, 160, 64, 0.6); + + /* Status */ + --ok: #3fb950; + --warn: #d29922; + --err: #f85149; + + /* Layout */ + --tile-height: 300px; + --tile-min-width: 360px; + --grid-gap: 8px; + --grid-padding: 16px; + --header-height: 44px; + + /* Transitions */ + --t-zoom: 250ms ease-in-out; + --t-fast: 150ms ease; + --t-fade: 200ms ease; + + /* Fonts */ + --font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +html, body { + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-size: 14px; + overflow: hidden; +} + +.hidden { display: none !important; } +``` + +### Step 4: Run the test to verify it passes + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_design_tokens -v +``` + +Expected: **PASS**. + +### Step 5: Run the full test suite + +```bash +pytest -q +``` + +Expected: `106 passed` + +### Step 6: Commit + +```bash +git add frontend/style.css coordinator/tests/test_frontend_css.py +git commit -m "feat: add style.css — design tokens, dark theme, typography" +``` + +--- + +## Task 7: style.css — Session grid, tile, bell pulse + +**Files:** +- Modify: `frontend/style.css` (append) +- Modify: `coordinator/tests/test_frontend_css.py` (add 3 test functions) + +### Step 1: Add 3 failing tests to test_frontend_css.py + +Open `coordinator/tests/test_frontend_css.py` and append at the end: + +```python +# ── Task 7: Session grid + tile + bell pulse ────────────────────────────────── + + +def test_css_session_grid(): + """Session grid uses auto-fill with minmax for fluid columns.""" + css = read_css() + assert "auto-fill" in css + assert "minmax" in css + + +def test_css_tile_height(): + """Session tile has fixed height using --tile-height token.""" + css = read_css() + assert ".session-tile" in css + assert "var(--tile-height)" in css + + +def test_css_bell_indicator(): + """Bell indicator has amber pulse animation and tile border glow.""" + css = read_css() + assert "bell-pulse" in css + assert ".session-tile--bell" in css + assert ".tile-bell" in css +``` + +### Step 2: Run the new tests to verify they fail + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_session_grid coordinator/tests/test_frontend_css.py::test_css_tile_height coordinator/tests/test_frontend_css.py::test_css_bell_indicator -v +``` + +Expected: All 3 **FAIL**. + +### Step 3: Append grid + tile CSS to frontend/style.css + +Append the following to the end of `frontend/style.css`: + +```css + +/* ── App layout ── */ +.view { height: 100vh; overflow: hidden; } +.view--active { display: flex; flex-direction: column; } +.view.hidden { display: none; } + +.app-header { + height: var(--header-height); + padding: 0 var(--grid-padding); + background: var(--bg-header); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.app-title { font-size: 15px; font-weight: 600; letter-spacing: 0.02em; } + +.connection-status { font-size: 11px; color: var(--text-dim); } + +/* ── Session grid ── */ +.session-grid { + flex: 1; + overflow-y: auto; + padding: var(--grid-padding); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--tile-min-width), 1fr)); + gap: var(--grid-gap); + align-content: start; +} + +/* ── Session tile ── */ +.session-tile { + height: var(--tile-height); + background: var(--bg-tile); + border: 1px solid var(--border); + border-radius: 4px; + display: flex; + flex-direction: column; + cursor: pointer; + overflow: hidden; + position: relative; + transition: border-color var(--t-fast), box-shadow var(--t-fast); +} + +.session-tile:hover, +.session-tile:focus-visible { + border-color: var(--accent); + outline: none; +} + +/* Tile with bell — amber border glow */ +.session-tile--bell { + border-color: var(--bell-border); + box-shadow: 0 0 0 1px var(--bell-border), inset 0 0 12px var(--bell-glow); +} + +.tile-header { + height: 32px; + padding: 0 10px; + background: var(--bg-header); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.tile-name { + font-size: 12px; + font-weight: 500; + color: var(--text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 65%; +} + +.tile-meta { + font-size: 11px; + color: var(--text-dim); + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} + +/* Bell dot — pulsing amber circle in tile header */ +.tile-bell { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--bell); + flex-shrink: 0; + animation: bell-pulse 1.4s ease-in-out infinite; +} + +@keyframes bell-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.8); } +} + +/* Tile body — terminal output */ +.tile-body { + flex: 1; + overflow: hidden; + position: relative; +} + +.tile-pre { + position: absolute; + inset: 0; + padding: 6px 8px; + font-family: var(--font-mono); + font-size: 11px; + line-height: 1.4; + color: var(--text); + white-space: pre; + overflow: hidden; + /* Show bottom of content — newest lines appear at the bottom */ + display: flex; + flex-direction: column; + justify-content: flex-end; +} + +/* Fade top edge of tile body so content appears to scroll out of view */ +.tile-body::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; + height: 32px; + background: linear-gradient(to bottom, var(--bg-tile), transparent); + pointer-events: none; + z-index: 1; +} + +.empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + font-size: 14px; +} +``` + +### Step 4: Run the new tests to verify they pass + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_session_grid coordinator/tests/test_frontend_css.py::test_css_tile_height coordinator/tests/test_frontend_css.py::test_css_bell_indicator -v +``` + +Expected: All 3 **PASS**. + +### Step 5: Run the full test suite + +```bash +pytest -q +``` + +Expected: `109 passed` + +### Step 6: Commit + +```bash +git add frontend/style.css coordinator/tests/test_frontend_css.py +git commit -m "feat: add style.css — session grid, tile, and bell pulse animation" +``` + +--- + +## Task 8: style.css — Responsive breakpoints + +**Files:** +- Modify: `frontend/style.css` (append) +- Modify: `coordinator/tests/test_frontend_css.py` (add 1 test function) + +### Step 1: Add the failing breakpoint test + +Append to `coordinator/tests/test_frontend_css.py`: + +```python +# ── Task 8: Responsive breakpoints ─────────────────────────────────────────── + + +def test_css_breakpoints(): + """Responsive breakpoints at 599px and 899px are defined.""" + css = read_css() + assert "599px" in css + assert "899px" in css +``` + +### Step 2: Run the test to verify it fails + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_breakpoints -v +``` + +Expected: **FAIL**. + +### Step 3: Append breakpoint CSS to frontend/style.css + +```css + +/* ── Responsive breakpoints ── */ + +/* 1200px+: 4+ cols (auto-fill handles this naturally with --tile-min-width: 360px) */ + +/* 900–1199px: slightly wider tile minimum forces 2 columns at most */ +@media (max-width: 1199px) and (min-width: 900px) { + :root { --tile-min-width: 420px; } +} + +/* 600–899px: single-column wide tiles (auto-fill yields 1 col anyway) */ +@media (max-width: 899px) and (min-width: 600px) { + .session-grid { + grid-template-columns: 1fr; + padding: 8px; + } + .session-tile { height: 200px; } +} + +/* < 600px: switch to flex list layout (mobile) */ +@media (max-width: 599px) { + .session-grid { + display: flex; + flex-direction: column; + gap: 1px; + padding: 0; + } + .session-tile { + height: auto; + border-radius: 0; + border-left: none; + border-right: none; + } +} + +/* Landscape hint for mobile terminal */ +@media (max-width: 599px) and (orientation: landscape) { + .session-grid { padding: 0; } +} +``` + +### Step 4: Run the test to verify it passes + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_breakpoints -v +``` + +Expected: **PASS**. + +### Step 5: Run the full test suite + +```bash +pytest -q +``` + +Expected: `110 passed` + +### Step 6: Commit + +```bash +git add frontend/style.css coordinator/tests/test_frontend_css.py +git commit -m "feat: add style.css — responsive breakpoints (600/900/1200px)" +``` + +--- + +## Task 9: style.css — Zoom-in-place expand/collapse transition + +**Files:** +- Modify: `frontend/style.css` (append) +- Modify: `coordinator/tests/test_frontend_css.py` (add 1 test function) + +### Step 1: Add the failing zoom transition test + +Append to `coordinator/tests/test_frontend_css.py`: + +```python +# ── Task 9: Zoom-in-place transition ───────────────────────────────────────── + + +def test_css_zoom_transition(): + """Zoom-in-place transition classes for tile expand/collapse are defined.""" + css = read_css() + assert ".session-tile--expanding" in css + assert "session-tile--expanded" in css + assert ".session-grid--dimming" in css +``` + +### Step 2: Run the test to verify it fails + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_zoom_transition -v +``` + +Expected: **FAIL**. + +### Step 3: Append zoom transition CSS to frontend/style.css + +```css + +/* ── Expanded terminal view ── */ +.expanded-header { + height: var(--header-height); + padding: 0 12px; + background: var(--bg-header); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.back-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-muted); + font-size: 18px; + width: 36px; height: 36px; + cursor: pointer; + display: flex; align-items: center; justify-content: center; + transition: border-color var(--t-fast), color var(--t-fast); + flex-shrink: 0; +} +.back-btn:hover { border-color: var(--accent); color: var(--text); } + +.expanded-session-name { + flex: 1; + font-size: 14px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.palette-trigger { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-dim); + font-size: 12px; + padding: 4px 10px; + cursor: pointer; + flex-shrink: 0; + transition: border-color var(--t-fast), color var(--t-fast); +} +.palette-trigger:hover { border-color: var(--accent); color: var(--text); } + +.terminal-container { + flex: 1; + overflow: hidden; + background: #000; +} + +.reconnect-overlay { + position: absolute; + inset: var(--header-height) 0 0; + background: var(--bg-overlay); + display: flex; align-items: center; justify-content: center; + z-index: 10; +} +.reconnect-overlay__text { color: var(--text-muted); font-size: 14px; } + +/* Expanded view is position: relative so overlay inset works */ +#view-expanded { position: relative; } + +/* ── Zoom-in-place: tile expands from grid position to fill viewport ── */ + +/* Phase 1 of animation: tile detaches from grid, starts expanding */ +.session-tile--expanding { + position: fixed; + z-index: 50; + border-radius: 4px; + transition: top var(--t-zoom), left var(--t-zoom), + width var(--t-zoom), height var(--t-zoom), + border-radius var(--t-zoom); +} + +/* Phase 2: tile fills viewport (JS sets these after rAF) */ +.session-tile--expanded { + top: 0 !important; left: 0 !important; + width: 100vw !important; height: 100vh !important; + border-radius: 0; +} + +/* Siblings fade while the selected tile is expanding */ +.session-grid--dimming .session-tile:not(.session-tile--expanding) { + opacity: 0; + transition: opacity var(--t-fade); +} +``` + +### Step 4: Run the test to verify it passes + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_zoom_transition -v +``` + +Expected: **PASS**. + +### Step 5: Run the full test suite + +```bash +pytest -q +``` + +Expected: `111 passed` + +### Step 6: Commit + +```bash +git add frontend/style.css coordinator/tests/test_frontend_css.py +git commit -m "feat: add style.css — zoom-in-place expand/collapse transition" +``` + +--- + +## Task 10: style.css — Bell count badge, connection status, toast + +**Files:** +- Modify: `frontend/style.css` (append) +- Modify: `coordinator/tests/test_frontend_css.py` (add 1 test function) + +### Step 1: Add the failing bell count test + +Append to `coordinator/tests/test_frontend_css.py`: + +```python +# ── Task 10: Bell count badge + connection status + toast ───────────────────── + + +def test_css_bell_count_and_toast(): + """Bell count badge, connection status states, and toast are defined.""" + css = read_css() + assert ".tile-bell-count" in css + assert ".connection-status--ok" in css + assert ".connection-status--err" in css + assert ".toast" in css +``` + +### Step 2: Run the test to verify it fails + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_bell_count_and_toast -v +``` + +Expected: **FAIL**. + +### Step 3: Append badge + status + toast CSS to frontend/style.css + +```css + +/* ── Bell count badge (shown next to bell dot in tile header) ── */ +.tile-bell-count { + font-size: 10px; + font-weight: 600; + color: var(--bell); + min-width: 16px; + text-align: right; +} + +/* ── Connection status indicator ── */ +.connection-status--ok { color: var(--ok); } +.connection-status--warn { color: var(--warn); } +.connection-status--err { color: var(--err); } + +/* ── Toast notification ── */ +.toast { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + background: var(--bg-header); + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px 16px; + font-size: 13px; + color: var(--text-muted); + z-index: 100; + pointer-events: none; + animation: toast-in var(--t-fast) ease; +} +@keyframes toast-in { + from { opacity: 0; transform: translateX(-50%) translateY(8px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +``` + +### Step 4: Run the test to verify it passes + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_bell_count_and_toast -v +``` + +Expected: **PASS**. + +### Step 5: Run the full test suite + +```bash +pytest -q +``` + +Expected: `112 passed` + +### Step 6: Commit + +```bash +git add frontend/style.css coordinator/tests/test_frontend_css.py +git commit -m "feat: add style.css — bell count badge, connection status states, toast" +``` + +--- + +## Task 11: style.css — Mobile three-tier priority list + +**Files:** +- Modify: `frontend/style.css` (append) +- Modify: `coordinator/tests/test_frontend_css.py` (add 1 test function) + +### Step 1: Add the failing mobile tier test + +Append to `coordinator/tests/test_frontend_css.py`: + +```python +# ── Task 11: Mobile three-tier priority list ────────────────────────────────── + + +def test_css_mobile_tiers(): + """Mobile three-tier CSS classes (bell/active/idle) are defined.""" + css = read_css() + assert "session-tile--tier-bell" in css + assert "session-tile--tier-active" in css + assert "session-tile--tier-idle" in css +``` + +### Step 2: Run the test to verify it fails + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_mobile_tiers -v +``` + +Expected: **FAIL**. + +### Step 3: Append mobile tier CSS to frontend/style.css + +```css + +/* ── Mobile list view — three-tier priority layout (< 600px) ── */ +@media (max-width: 599px) { + + /* Tier 1: Bell sessions — expanded with 4–6 line preview */ + .session-tile--tier-bell .tile-body { height: 90px; } + .session-tile--tier-bell { min-height: 126px; } + + /* Tier 2: Recently active (< 5 min) — 1 line of preview */ + .session-tile--tier-active .tile-body { height: 24px; } + .session-tile--tier-active { min-height: 60px; } + .session-tile--tier-active .tile-pre { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* Tier 3: Idle — name + timestamp only, no content preview */ + .session-tile--tier-idle .tile-body { display: none; } + .session-tile--tier-idle { min-height: 44px; } + .session-tile--tier-idle .tile-header { height: 44px; } + .session-tile--tier-idle .tile-name { color: var(--text-dim); } +} +``` + +### Step 4: Run the test to verify it passes + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_mobile_tiers -v +``` + +Expected: **PASS**. + +### Step 5: Run the full test suite + +```bash +pytest -q +``` + +Expected: `113 passed` + +### Step 6: Commit + +```bash +git add frontend/style.css coordinator/tests/test_frontend_css.py +git commit -m "feat: add style.css — mobile three-tier priority list" +``` + +--- + +## Task 12: style.css — Command palette overlay (desktop) + +**Files:** +- Modify: `frontend/style.css` (append) +- Modify: `coordinator/tests/test_frontend_css.py` (add 1 test function) + +### Step 1: Add the failing command palette test + +Append to `coordinator/tests/test_frontend_css.py`: + +```python +# ── Task 12: Command palette overlay ───────────────────────────────────────── + + +def test_css_command_palette(): + """Command palette overlay has dialog, input, and list item classes.""" + css = read_css() + assert ".command-palette__dialog" in css + assert ".command-palette__input" in css + assert ".palette-item" in css + assert ".palette-item--selected" in css +``` + +### Step 2: Run the test to verify it fails + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_command_palette -v +``` + +Expected: **FAIL**. + +### Step 3: Append command palette CSS to frontend/style.css + +```css + +/* ── Command palette (desktop) ── */ +.command-palette { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 15vh; +} + +.command-palette__backdrop { + position: absolute; + inset: 0; + background: var(--bg-overlay); + backdrop-filter: blur(2px); +} + +.command-palette__dialog { + position: relative; + width: min(440px, 90vw); + background: var(--bg-header); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5); +} + +.command-palette__input { + width: 100%; + padding: 14px 16px; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + color: var(--text); + font-size: 14px; + font-family: var(--font-ui); + outline: none; +} +.command-palette__input::placeholder { color: var(--text-dim); } + +.command-palette__list { + list-style: none; + max-height: 320px; + overflow-y: auto; +} + +.palette-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + cursor: pointer; + font-size: 13px; + color: var(--text-muted); + transition: background var(--t-fast); +} +.palette-item:hover, +.palette-item--selected { background: var(--accent-dim); color: var(--text); } + +.palette-item__index { + font-size: 11px; + color: var(--text-dim); + width: 16px; + flex-shrink: 0; +} +.palette-item__name { flex: 1; } +.palette-item__bell { color: var(--bell); font-size: 11px; } +.palette-item__time { font-size: 11px; color: var(--text-dim); } +``` + +### Step 4: Run the test to verify it passes + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_command_palette -v +``` + +Expected: **PASS**. + +### Step 5: Run the full test suite + +```bash +pytest -q +``` + +Expected: `114 passed` + +### Step 6: Commit + +```bash +git add frontend/style.css coordinator/tests/test_frontend_css.py +git commit -m "feat: add style.css — command palette overlay" +``` + +--- + +## Task 13: style.css — Mobile bottom sheet, floating session pill, reduced motion + +This is the final CSS task. It also verifies the entire Phase 2a CSS test suite passes cleanly. + +**Files:** +- Modify: `frontend/style.css` (append) +- Modify: `coordinator/tests/test_frontend_css.py` (add 3 test functions) + +### Step 1: Add the 3 failing tests + +Append to `coordinator/tests/test_frontend_css.py`: + +```python +# ── Task 13: Bottom sheet + session pill + reduced motion ───────────────────── + + +def test_css_bottom_sheet(): + """Bottom sheet panel, handle, and list items are defined.""" + css = read_css() + assert ".bottom-sheet__panel" in css + assert ".bottom-sheet__handle" in css + assert ".sheet-item" in css + + +def test_css_session_pill(): + """Floating session pill button for mobile expanded view is defined.""" + css = read_css() + assert ".session-pill" in css + assert ".session-pill__label" in css + + +def test_css_reduced_motion(): + """prefers-reduced-motion media query disables all animations.""" + css = read_css() + assert "prefers-reduced-motion" in css +``` + +### Step 2: Run the new tests to verify they fail + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_bottom_sheet coordinator/tests/test_frontend_css.py::test_css_session_pill coordinator/tests/test_frontend_css.py::test_css_reduced_motion -v +``` + +Expected: All 3 **FAIL**. + +### Step 3: Append bottom sheet + pill + reduced motion CSS to frontend/style.css + +```css + +/* ── Bottom sheet (mobile session switcher) ── */ +.bottom-sheet { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: flex-end; +} + +.bottom-sheet__backdrop { + position: absolute; + inset: 0; + background: var(--bg-overlay); +} + +.bottom-sheet__panel { + position: relative; + width: 100%; + background: var(--bg-header); + border-top: 1px solid var(--border); + border-radius: 12px 12px 0 0; + max-height: 70vh; + overflow-y: auto; + animation: sheet-up var(--t-zoom) ease; +} +@keyframes sheet-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +/* Drag handle visual cue */ +.bottom-sheet__handle { + width: 36px; height: 4px; + background: var(--border); + border-radius: 2px; + margin: 10px auto 6px; +} + +.bottom-sheet__list { list-style: none; padding-bottom: 8px; } + +.sheet-item { + display: flex; + align-items: center; + gap: 10px; + padding: 0 16px; + height: 56px; + cursor: pointer; + font-size: 14px; + color: var(--text); + border-bottom: 1px solid var(--border-subtle); + transition: background var(--t-fast); +} +.sheet-item:hover { background: var(--accent-dim); } +.sheet-item__name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.sheet-item__bell { color: var(--bell); flex-shrink: 0; } +.sheet-item__time { font-size: 12px; color: var(--text-dim); flex-shrink: 0; } + +/* ── Floating session pill (mobile, shown in expanded view) ── */ +.session-pill { + position: fixed; + bottom: 24px; + right: 16px; + z-index: 50; + background: var(--bg-header); + border: 1px solid var(--border); + border-radius: 20px; + padding: 8px 14px; + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-muted); + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + transition: border-color var(--t-fast), color var(--t-fast), opacity var(--t-fast); + opacity: 0.75; +} +.session-pill:hover { opacity: 1; border-color: var(--accent); color: var(--text); } +.session-pill__label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 140px; } +.session-pill__bell { color: var(--bell); } + +/* ── Prefers reduced motion — disable all animations ── */ +@media (prefers-reduced-motion: reduce) { + .tile-bell { animation: none; } + .session-tile--expanding { transition: none; } + .session-grid--dimming .session-tile:not(.session-tile--expanding) { transition: none; } + .bottom-sheet__panel { animation: none; } + .toast { animation: none; } +} +``` + +### Step 4: Run the new tests to verify they pass + +```bash +pytest coordinator/tests/test_frontend_css.py::test_css_bottom_sheet coordinator/tests/test_frontend_css.py::test_css_session_pill coordinator/tests/test_frontend_css.py::test_css_reduced_motion -v +``` + +Expected: All 3 **PASS**. + +### Step 5: Run the complete CSS test suite + +```bash +pytest coordinator/tests/test_frontend_css.py -v +``` + +Expected: All 12 CSS tests **PASS** — one per CSS task covering tokens, grid, breakpoints, zoom, bell, mobile tiers, command palette, bottom sheet, pill, and reduced motion. + +### Step 6: Run the complete test suite — final Phase 2a check + +```bash +pytest -q +``` + +Expected: **`117 passed`** (92 original backend + 3 static serving + 10 HTML + 12 CSS) + +If the count is different, run `pytest -v` to identify any failures before proceeding. + +### Step 7: Commit + +```bash +git add frontend/style.css coordinator/tests/test_frontend_css.py +git commit -m "feat: add style.css — mobile bottom sheet, session pill, reduced motion" +``` + +--- + +## Phase 2a Complete — Verification Checklist + +Run this before declaring Phase 2a done: + +```bash +# 1. All tests pass +pytest -q +# Expected: 117 passed, 0 failed, 0 errors + +# 2. frontend/ contains all expected files +ls -la frontend/ +# Expected: app.js index.html manifest.json style.css terminal.js + +# 3. Server starts cleanly +uvicorn coordinator.main:app --host 127.0.0.1 --port 8091 & +sleep 1 + +# 4. Root serves HTML +curl -s -o /dev/null -w "GET /: %{http_code}\n" http://127.0.0.1:8091/ + +# 5. CSS is served +curl -s -o /dev/null -w "GET /style.css: %{http_code}\n" http://127.0.0.1:8091/style.css + +# 6. Manifest is served +curl -s -o /dev/null -w "GET /manifest.json: %{http_code}\n" http://127.0.0.1:8091/manifest.json + +# 7. API routes still work (not shadowed by StaticFiles) +curl -s -o /dev/null -w "GET /api/sessions: %{http_code}\n" http://127.0.0.1:8091/api/sessions + +kill %1 +``` + +Expected output: +``` +GET /: 200 +GET /style.css: 200 +GET /manifest.json: 200 +GET /api/sessions: 200 +``` + +--- + +## Scope Boundaries — Phase 2a Does NOT Include + +Do not implement any of the following. They are Phase 2b or Phase 3: + +- Any JavaScript implementation in `app.js` or `terminal.js` (stubs only in this phase) +- `xterm.js` `Terminal` object initialization (Phase 2b) +- WebSocket connection logic to ttyd (Phase 2b) +- API polling — `GET /api/sessions` calls from the browser (Phase 2b) +- Bell detection JavaScript and browser Notifications API (Phase 2b) +- Session heartbeat sending from browser (Phase 2b) +- Grid ↔ expanded view transitions via JavaScript (Phase 2b) +- Service worker / offline support (Phase 3) +- Caddy reverse proxy configuration (Phase 3) +- systemd service file (Phase 3) +- Icon files (`/icons/icon-192.png`, `/icons/icon-512.png`) (Phase 3) +- Browser/E2E tests (Phase 3) diff --git a/docs/plans/2026-03-26-web-tmux-phase2b-javascript.md b/docs/plans/2026-03-26-web-tmux-phase2b-javascript.md new file mode 100644 index 0000000..6a58fa6 --- /dev/null +++ b/docs/plans/2026-03-26-web-tmux-phase2b-javascript.md @@ -0,0 +1,1909 @@ +# Web-Tmux Dashboard Phase 2b — JavaScript Implementation Plan + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Implement complete JavaScript behavior for the tmux web dashboard — session polling, tile rendering, bell notifications, zoom transitions, command palette, and xterm.js terminal with WebSocket connection. + +**Architecture:** Vanilla ES5-compatible JavaScript in `app.js` and `terminal.js`. Pure functions at the top of `app.js` are exported conditionally for Node.js unit testing — the export block lives at the very bottom of `app.js` and remains there across all tasks. DOM manipulation uses `getElementById`. `xterm.js` and `FitAddon` are loaded from CDN in `index.html`. `terminal.js` exposes `openTerminal`/`closeTerminal` via `window._openTerminal`/`window._closeTerminal` to avoid circular dependency with `app.js`. + +**Tech Stack:** Vanilla JavaScript (no framework, no build step), xterm.js 5.3.0 + xterm-addon-fit 0.8.0 (CDN), Node.js 18+ `node:test` (unit tests for pure functions only), WebSocket API (native browser). + +--- + +## Quick Reference + +| Files changed | What | +|---|---| +| `frontend/tests/test_app.mjs` | Node.js unit tests — pure functions only | +| `frontend/app.js` | Complete app — polling, rendering, keyboard, transitions | +| `frontend/terminal.js` | xterm.js Terminal + WebSocket + visualViewport | + +**Test command (tasks 1-3):** `node --test frontend/tests/test_app.mjs` + +**No Python tests change.** The 118 pytest tests from Phase 2a continue to pass and are never touched. + +## Architecture Note: How `app.js` Grows Across Tasks + +Tasks 1-3 build the **pure-function layer** at the top of `app.js`. +Tasks 4-15 add **runtime/DOM functions** above the conditional export block. + +The conditional export block is written in Task 1 and **stays at the very bottom of `app.js` unchanged** for the rest of the plan. Every subsequent task that modifies `app.js` adds code _above_ that block. + +The final structure of `app.js` (from top to bottom): +1. Pure functions (Tasks 1-3) +2. State variables (Task 4) +3. DOM helpers / `api` wrapper (Task 4) +4. `initDeviceId`, `trackInteraction`, `DOMContentLoaded` (Task 4) +5. `restoreState` (Task 5) +6. `startPolling`, `pollSessions`, `setConnectionStatus` (Task 6) +7. `renderGrid`, `buildTileHTML`, `escapeHtml`, `isMobile` (Task 7) +8. `requestNotificationPermission`, `handleBellTransitions` (Task 8) +9. `startHeartbeat`, `sendHeartbeat` (Task 9) +10. `openSession`, `closeSession`, `showToast` (Task 10) +11. `bindStaticEventListeners`, palette functions (Task 11) +12. `openBottomSheet`, `closeBottomSheet`, `renderSheetList` (Task 15) +13. Conditional CommonJS export block (Task 1 — never move this) + +--- + +## Task 1: Test infrastructure + minimal `app.js` stub + +**Files:** +- Create: `frontend/tests/test_app.mjs` +- Modify: `frontend/app.js` (replace single-line stub) + +--- + +### Step 1: Write the failing test + +Create file `frontend/tests/test_app.mjs`: + +```javascript +// frontend/tests/test_app.mjs +// Node.js 18+ built-in test runner. Run: node --test frontend/tests/test_app.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); + +// Stub browser globals that app.js references at module scope. +// Must be set BEFORE require('app.js') runs. +global.document = { + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener: () => {}, +}; +global.window = { + innerWidth: 1280, + localStorage: { getItem: () => null, setItem: () => {} }, +}; +global.Notification = { permission: 'denied', requestPermission: async () => 'denied' }; +global.navigator = { userAgent: 'TestAgent/1.0' }; + +// Load app.js (uses conditional CommonJS export). +const app = require(path.join(__dirname, '../app.js')); + +const { + formatTimestamp, + sessionPriority, + sortByPriority, + filterByQuery, + detectBellTransitions, + generateDeviceId, + buildHeartbeatPayload, +} = app; + +// ── Task 1: smoke test ────────────────────────────────────────────────────── +test('app module loads without error', () => { + assert.ok(typeof formatTimestamp === 'function', 'formatTimestamp should be exported'); + assert.ok(typeof sessionPriority === 'function', 'sessionPriority should be exported'); + assert.ok(typeof sortByPriority === 'function', 'sortByPriority should be exported'); + assert.ok(typeof filterByQuery === 'function', 'filterByQuery should be exported'); + assert.ok(typeof detectBellTransitions === 'function', 'detectBellTransitions should be exported'); + assert.ok(typeof generateDeviceId === 'function', 'generateDeviceId should be exported'); + assert.ok(typeof buildHeartbeatPayload === 'function', 'buildHeartbeatPayload should be exported'); +}); +``` + +--- + +### Step 2: Run the test — expect FAIL + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected failure (existing `app.js` is a comment, not a module): +``` +TypeError: Cannot read properties of undefined (reading 'formatTimestamp') +``` +or similar. Good — the test correctly fails against the stub. + +--- + +### Step 3: Replace `frontend/app.js` with minimal stubs + export block + +**Replace the entire file** with: + +```javascript +/* app.js — tmux web dashboard (Phase 2b) + * Pure functions first, then runtime code, then conditional export at bottom. + * NEVER move the export block — it must remain the last lines of this file. */ + +// ── Pure functions ────────────────────────────────────────────────────────── +// These stubs will be replaced in Tasks 2-3. They exist now so the export +// block can reference them and Task 1's smoke test passes. + +function formatTimestamp(ts) { return ts == null ? '—' : ''; } +function sessionPriority(session) { return 'idle'; } +function sortByPriority(sessions) { return sessions.slice(); } +function filterByQuery(sessions, query) { return sessions; } +function detectBellTransitions(prevSessions, nextSessions) { return []; } +function generateDeviceId() { return 'dev-0'; } +function buildHeartbeatPayload(deviceId, viewingSession, viewMode, lastInteractionAt) { + return { device_id: deviceId, label: 'stub', viewing_session: viewingSession, + view_mode: viewMode, last_interaction_at: lastInteractionAt }; +} + +// ── Conditional CommonJS export for Node.js unit tests ───────────────────── +// In the browser, `module` is undefined — this block never executes. +// KEEP THIS BLOCK AT THE VERY BOTTOM OF app.js. NEVER MOVE IT. +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + formatTimestamp, + sessionPriority, + sortByPriority, + filterByQuery, + detectBellTransitions, + generateDeviceId, + buildHeartbeatPayload, + }; +} +``` + +--- + +### Step 4: Run the test — expect PASS + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected output (Node.js TAP format): +``` +TAP version 13 +# Subtest: app module loads without error +ok 1 - app module loads without error +... +# tests 1 +# pass 1 +# fail 0 +``` + +--- + +### Step 5: Commit + +``` +git add frontend/tests/test_app.mjs frontend/app.js +git commit -m "feat: add Node.js test infrastructure for frontend pure functions" +``` + +--- + +## Task 2: Pure functions — `formatTimestamp`, `sessionPriority`, `sortByPriority` + +**Files:** +- Modify: `frontend/tests/test_app.mjs` (add tests after Task 1 smoke test) +- Modify: `frontend/app.js` (replace first 3 stubs with real implementations) + +--- + +### Step 1: Add failing tests to `test_app.mjs` + +Add these test blocks **after** the Task 1 smoke test block (before the end of the file): + +```javascript +// ── Task 2: formatTimestamp ───────────────────────────────────────────────── +test('formatTimestamp: null returns em-dash', () => { + assert.strictEqual(formatTimestamp(null), '—'); + assert.strictEqual(formatTimestamp(undefined), '—'); +}); +test('formatTimestamp: seconds ago', () => { + const now = Date.now() / 1000; + assert.match(formatTimestamp(now - 5), /^5s ago$/); +}); +test('formatTimestamp: minutes ago', () => { + const now = Date.now() / 1000; + assert.match(formatTimestamp(now - 125), /^2m ago$/); +}); +test('formatTimestamp: hours ago', () => { + const now = Date.now() / 1000; + assert.match(formatTimestamp(now - 7260), /^2h ago$/); +}); + +// ── Task 2: sessionPriority ───────────────────────────────────────────────── +test('sessionPriority: returns bell when unseen_count > 0 and seen_at is null', () => { + const s = { bell: { last_fired_at: 100, seen_at: null, unseen_count: 1 } }; + assert.strictEqual(sessionPriority(s), 'bell'); +}); +test('sessionPriority: returns bell when last_fired_at > seen_at', () => { + const s = { bell: { last_fired_at: 200, seen_at: 100, unseen_count: 2 } }; + assert.strictEqual(sessionPriority(s), 'bell'); +}); +test('sessionPriority: returns idle when unseen_count is 0', () => { + const s = { bell: { last_fired_at: null, seen_at: null, unseen_count: 0 } }; + assert.strictEqual(sessionPriority(s), 'idle'); +}); +test('sessionPriority: returns idle when seen_at >= last_fired_at', () => { + const s = { bell: { last_fired_at: 100, seen_at: 100, unseen_count: 1 } }; + assert.strictEqual(sessionPriority(s), 'idle'); +}); + +// ── Task 2: sortByPriority ────────────────────────────────────────────────── +test('sortByPriority: bell sessions sort before idle sessions', () => { + const sessions = [ + { name: 'idle', bell: { last_fired_at: null, seen_at: null, unseen_count: 0 } }, + { name: 'bell', bell: { last_fired_at: 100, seen_at: null, unseen_count: 2 } }, + ]; + const sorted = sortByPriority(sessions); + assert.strictEqual(sorted[0].name, 'bell'); + assert.strictEqual(sorted[1].name, 'idle'); +}); +test('sortByPriority: does not mutate input array', () => { + const sessions = [ + { name: 'a', bell: { last_fired_at: null, seen_at: null, unseen_count: 0 } }, + ]; + const orig = sessions; + sortByPriority(sessions); + assert.strictEqual(sessions, orig, 'original array reference must not change'); +}); +``` + +--- + +### Step 2: Run tests — expect FAIL + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: the smoke test passes, but the new tests fail because: +- `formatTimestamp(now - 5)` returns `''` instead of `'5s ago'` +- `sessionPriority` always returns `'idle'` +- `sortByPriority` returns input array reference (mutates), not a sorted copy + +--- + +### Step 3: Replace the first 3 stubs in `frontend/app.js` + +Replace the three stub function bodies (leave everything else untouched, especially the export block): + +```javascript +function formatTimestamp(ts) { + if (ts == null) return '—'; + const diff = Math.max(0, Math.floor(Date.now() / 1000 - ts)); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + return `${Math.floor(diff / 3600)}h ago`; +} + +function sessionPriority(session) { + const b = session.bell; + const hasBell = b && + b.unseen_count > 0 && + (b.seen_at === null || b.last_fired_at > b.seen_at); + if (hasBell) return 'bell'; + return 'idle'; +} + +function sortByPriority(sessions) { + const order = { bell: 0, active: 1, idle: 2 }; + return sessions.slice().sort( + (a, b) => (order[sessionPriority(a)] ?? 2) - (order[sessionPriority(b)] ?? 2) + ); +} +``` + +--- + +### Step 4: Run tests — expect PASS + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: +``` +# tests 11 +# pass 11 +# fail 0 +``` + +--- + +### Step 5: Commit + +``` +git add frontend/app.js frontend/tests/test_app.mjs +git commit -m "feat: add formatTimestamp, sessionPriority, sortByPriority pure functions" +``` + +--- + +## Task 3: Pure functions — `filterByQuery`, `detectBellTransitions`, `generateDeviceId`, `buildHeartbeatPayload` + +**Files:** +- Modify: `frontend/tests/test_app.mjs` (add tests) +- Modify: `frontend/app.js` (replace remaining 4 stubs with real implementations) + +--- + +### Step 1: Add failing tests to `test_app.mjs` + +Append these blocks after the Task 2 tests: + +```javascript +// ── Task 3: filterByQuery ─────────────────────────────────────────────────── +test('filterByQuery: empty query returns all sessions', () => { + const sessions = [{ name: 'a' }, { name: 'b' }]; + assert.strictEqual(filterByQuery(sessions, '').length, 2); + assert.strictEqual(filterByQuery(sessions, null).length, 2); +}); +test('filterByQuery: matches by substring, case-insensitive', () => { + const sessions = [{ name: 'work' }, { name: 'web-tmux' }, { name: 'logs' }]; + const result = filterByQuery(sessions, 'W').map(s => s.name); + assert.deepStrictEqual(result, ['work', 'web-tmux']); +}); +test('filterByQuery: no match returns empty array', () => { + const sessions = [{ name: 'alpha' }, { name: 'beta' }]; + assert.strictEqual(filterByQuery(sessions, 'zzz').length, 0); +}); + +// ── Task 3: detectBellTransitions ────────────────────────────────────────── +test('detectBellTransitions: detects new bell on existing session', () => { + const prev = [{ name: 'a', bell: { unseen_count: 0, last_fired_at: null, seen_at: null } }]; + const next = [{ name: 'a', bell: { unseen_count: 1, last_fired_at: 100, seen_at: null } }]; + assert.deepStrictEqual(detectBellTransitions(prev, next), ['a']); +}); +test('detectBellTransitions: no change returns empty array', () => { + const sessions = [{ name: 'a', bell: { unseen_count: 0, last_fired_at: null, seen_at: null } }]; + assert.deepStrictEqual(detectBellTransitions(sessions, sessions), []); +}); +test('detectBellTransitions: new session with bell fires', () => { + const prev = []; + const next = [{ name: 'b', bell: { unseen_count: 1, last_fired_at: 200, seen_at: null } }]; + assert.deepStrictEqual(detectBellTransitions(prev, next), ['b']); +}); +test('detectBellTransitions: count increase fires, count decrease does not', () => { + const prev = [{ name: 'a', bell: { unseen_count: 2, last_fired_at: 100, seen_at: null } }]; + const next_increase = [{ name: 'a', bell: { unseen_count: 3, last_fired_at: 200, seen_at: null } }]; + const next_same = [{ name: 'a', bell: { unseen_count: 2, last_fired_at: 100, seen_at: null } }]; + assert.deepStrictEqual(detectBellTransitions(prev, next_increase), ['a']); + assert.deepStrictEqual(detectBellTransitions(prev, next_same), []); +}); + +// ── Task 3: generateDeviceId ──────────────────────────────────────────────── +test('generateDeviceId: returns a string matching d-[a-z0-9]+', () => { + assert.match(generateDeviceId(), /^d-[a-z0-9]+$/); +}); +test('generateDeviceId: returns unique IDs on repeated calls', () => { + const ids = new Set(Array.from({ length: 10 }, generateDeviceId)); + assert.ok(ids.size > 1, 'generateDeviceId should return different values'); +}); + +// ── Task 3: buildHeartbeatPayload ─────────────────────────────────────────── +test('buildHeartbeatPayload: returns correct shape', () => { + const p = buildHeartbeatPayload('d-abc', 'work', 'fullscreen', 123.45); + assert.strictEqual(p.device_id, 'd-abc'); + assert.strictEqual(p.viewing_session, 'work'); + assert.strictEqual(p.view_mode, 'fullscreen'); + assert.strictEqual(p.last_interaction_at, 123.45); + assert.ok(typeof p.label === 'string', 'label must be a string'); + assert.ok(p.label.length > 0, 'label must not be empty'); +}); +test('buildHeartbeatPayload: viewing_session can be null', () => { + const p = buildHeartbeatPayload('d-xyz', null, 'grid', 0); + assert.strictEqual(p.viewing_session, null); + assert.strictEqual(p.view_mode, 'grid'); +}); +``` + +--- + +### Step 2: Run tests — expect FAIL + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: the 11 existing tests still pass; the 14 new ones fail because the stubs return wrong values. + +--- + +### Step 3: Replace the remaining 4 stubs in `frontend/app.js` + +Replace the four stub bodies (above the export block): + +```javascript +function filterByQuery(sessions, query) { + if (!query) return sessions; + const q = query.toLowerCase(); + return sessions.filter(s => s.name.toLowerCase().includes(q)); +} + +function detectBellTransitions(prevSessions, nextSessions) { + const prevMap = new Map( + prevSessions.map(s => [s.name, s.bell ? s.bell.unseen_count : 0]) + ); + return nextSessions + .filter(s => { + const b = s.bell; + if (!b || b.unseen_count === 0) return false; + const prevCount = prevMap.has(s.name) ? prevMap.get(s.name) : 0; + return b.unseen_count > prevCount; + }) + .map(s => s.name); +} + +function generateDeviceId() { + const rand = Math.random().toString(36).slice(2, 10); + return 'd-' + rand; +} + +function buildHeartbeatPayload(deviceId, viewingSession, viewMode, lastInteractionAt) { + var label = (typeof navigator !== 'undefined' && navigator.userAgent) + ? navigator.userAgent.slice(0, 50) + : 'unknown'; + return { + device_id: deviceId, + label: label, + viewing_session: viewingSession, + view_mode: viewMode, + last_interaction_at: lastInteractionAt, + }; +} +``` + +--- + +### Step 4: Run tests — expect PASS + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: +``` +# tests 25 +# pass 25 +# fail 0 +``` + +--- + +### Step 5: Commit + +``` +git add frontend/app.js frontend/tests/test_app.mjs +git commit -m "feat: add filterByQuery, detectBellTransitions, generateDeviceId, buildHeartbeatPayload" +``` + +--- + +## Task 4: App initialization — device ID, fetch wrapper, interaction tracking, DOMContentLoaded + +**Files:** +- Modify: `frontend/app.js` (add state variables, helpers, DOMContentLoaded handler) + +No unit tests — requires DOM. Manual verification below. + +--- + +### Step 1: Add state variables and runtime code to `frontend/app.js` + +Add this block **below all the pure functions and above the conditional export block**: + +```javascript +// ── Constants ─────────────────────────────────────────────────────────────── +var POLL_MS = 2000; +var HEARTBEAT_MS = 5000; +var MOBILE_THRESHOLD = 600; + +// ── Runtime state ─────────────────────────────────────────────────────────── +var _deviceId = ''; +var _currentSessions = []; +var _viewingSession = null; // name of session open in expanded view, or null +var _viewMode = 'grid'; // 'grid' | 'fullscreen' +var _lastInteractionAt = Date.now() / 1000; +var _pollingTimer = null; +var _heartbeatTimer = null; +var _notificationPermission = 'default'; + +// ── DOM helpers ────────────────────────────────────────────────────────────── +function $(id) { return document.getElementById(id); } +function on(el, ev, fn) { if (el) el.addEventListener(ev, fn); } + +// ── isMobile ───────────────────────────────────────────────────────────────── +function isMobile() { return window.innerWidth < MOBILE_THRESHOLD; } + +// ── API fetch wrapper ──────────────────────────────────────────────────────── +function api(method, path, body) { + var opts = { method: method, headers: { 'Content-Type': 'application/json' } }; + if (body) opts.body = JSON.stringify(body); + return fetch(path, opts).then(function(res) { + if (!res.ok) throw new Error('API ' + method + ' ' + path + ' failed: ' + res.status); + return res.json(); + }); +} + +// ── Device ID ──────────────────────────────────────────────────────────────── +function initDeviceId() { + _deviceId = window.localStorage.getItem('tmux-web-device-id'); + if (!_deviceId) { + _deviceId = generateDeviceId(); + window.localStorage.setItem('tmux-web-device-id', _deviceId); + } +} + +// ── Interaction tracking ────────────────────────────────────────────────────── +function trackInteraction() { + _lastInteractionAt = Date.now() / 1000; +} + +// ── DOMContentLoaded ────────────────────────────────────────────────────────── +// restoreState, startPolling, startHeartbeat, requestNotificationPermission, +// and bindStaticEventListeners are defined in Tasks 5, 6, 8, 9, 11. +// JavaScript hoists function declarations — calling them here is safe. +document.addEventListener('DOMContentLoaded', function() { + initDeviceId(); + document.addEventListener('keydown', trackInteraction); + document.addEventListener('click', trackInteraction); + document.addEventListener('touchstart', trackInteraction, { passive: true }); + + restoreState().then(function() { + startPolling(); + startHeartbeat(); + requestNotificationPermission(); + bindStaticEventListeners(); + }); +}); +``` + +--- + +### Step 2: Run the Node.js unit tests — verify they still pass + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` — the new runtime code doesn't break any pure-function tests because the `document.addEventListener` call runs only in the browser (in Node.js, `document.addEventListener` is a no-op via our global stub and `DOMContentLoaded` never fires). + +--- + +### Step 3: Manual browser verification + +Start the coordinator: +``` +cd /home/bkrabach/dev/web-tmux +uvicorn coordinator.main:app --reload --port 8000 +``` + +Open `http://localhost:8000` in a browser. Check: +1. DevTools Console — no errors on load +2. DevTools Application → Local Storage — key `tmux-web-device-id` exists with a value like `d-a3f9b2c1` +3. Console — no "restoreState is not defined" or "startPolling is not defined" errors yet (you will see "TypeError: restoreState is not a function" — that is EXPECTED; it will be fixed in Task 5) + +> **Note:** Until Tasks 5-11 are complete, the browser console will show `TypeError: restoreState is not a function`. That is correct — those functions don't exist yet. The plan adds them in order. + +--- + +### Step 4: Commit + +``` +git add frontend/app.js +git commit -m "feat: add app initialization — device_id, fetch wrapper, interaction tracking" +``` + +--- + +## Task 5: State restoration — `GET /api/state` on load + +**Files:** +- Modify: `frontend/app.js` (add `restoreState`) + +No unit tests — requires fetch. Manual verification below. + +--- + +### Step 1: Add `restoreState` to `frontend/app.js` + +Add this block **above the DOMContentLoaded listener** (keep it below the `trackInteraction` function, and above the export block): + +```javascript +// ── State restoration ───────────────────────────────────────────────────────── +// Called once on page load. If the coordinator knows about an active session +// (a previous browser tab connected), re-open it without calling /connect again +// (ttyd is already running). skipConnect: true skips the POST /connect call. +function restoreState() { + return api('GET', '/api/state').then(function(state) { + if (state.active_session) { + return openSession(state.active_session, { skipConnect: true }); + } + }).catch(function(err) { + console.warn('restoreState: failed to fetch state', err); + }); +} +``` + +--- + +### Step 2: Run Node.js tests — verify still passing + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` + +--- + +### Step 3: Manual browser verification + +Start the coordinator and open the browser. Now: +1. Console — `restoreState is not a function` error **is gone** +2. Network tab — `GET /api/state` request fires on page load +3. If `active_session` is null (no prior session): stays on grid view +4. If `active_session` is set: should attempt `openSession` (which still fails gracefully because `openSession` doesn't exist yet — that is expected until Task 10) + +--- + +### Step 4: Commit + +``` +git add frontend/app.js +git commit -m "feat: add state restoration on page load" +``` + +--- + +## Task 6: Session polling — `GET /api/sessions` every 2s + +**Files:** +- Modify: `frontend/app.js` (add `startPolling`, `pollSessions`, `setConnectionStatus`) + +No unit tests — requires fetch and DOM. Manual verification below. + +--- + +### Step 1: Add polling functions to `frontend/app.js` + +Add this block **below `restoreState`** (above DOMContentLoaded, above export block): + +```javascript +// ── Connection status ───────────────────────────────────────────────────────── +function setConnectionStatus(level) { + // level: 'ok' | 'warn' | 'err' + var el = $('connection-status'); + if (!el) return; + el.className = 'connection-status connection-status--' + level; + if (level === 'ok') el.textContent = '●'; + if (level === 'warn') el.textContent = '◌ slow'; + if (level === 'err') el.textContent = '✕ offline'; +} + +// ── Session polling ─────────────────────────────────────────────────────────── +var _pollFailCount = 0; + +function startPolling() { + if (_pollingTimer) return; + pollSessions(); // immediate first fetch + _pollingTimer = setInterval(pollSessions, POLL_MS); +} + +function pollSessions() { + api('GET', '/api/sessions').then(function(sessions) { + var prev = _currentSessions; + _currentSessions = sessions; + _pollFailCount = 0; + setConnectionStatus('ok'); + renderGrid(sessions); + handleBellTransitions(prev, sessions); + }).catch(function(err) { + _pollFailCount++; + setConnectionStatus(_pollFailCount > 2 ? 'err' : 'warn'); + console.warn('pollSessions failed', err); + }); +} +``` + +--- + +### Step 2: Run Node.js tests — verify still passing + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` + +--- + +### Step 3: Manual browser verification + +Open `http://localhost:8000`. Check: +1. Network tab — `GET /api/sessions` fires on page load and every ~2 seconds +2. `#connection-status` element in header shows `●` (green dot) when requests succeed +3. Console — no errors (ignore "renderGrid is not defined" — that is Task 7) + +If you stop the coordinator and wait 3 polls: +- Status changes to `◌ slow` after first failure, then `✕ offline` after the third + +--- + +### Step 4: Commit + +``` +git add frontend/app.js +git commit -m "feat: add session polling loop — GET /api/sessions every 2s" +``` + +--- + +## Task 7: `renderGrid` — tile HTML for desktop grid and mobile list + +**Files:** +- Modify: `frontend/app.js` (add `renderGrid`, `buildTileHTML`, `escapeHtml`) + +No unit tests — requires DOM. Manual verification below. + +--- + +### Step 1: Add grid rendering functions to `frontend/app.js` + +Add this block **below the polling functions** (above DOMContentLoaded, above export block): + +```javascript +// ── Grid rendering ──────────────────────────────────────────────────────────── +function escapeHtml(str) { + return str + .replace(/&/g, '&') + .replace(//g, '>'); +} + +function buildTileHTML(session, index, mobile) { + var b = session.bell || {}; + var hasBell = b.unseen_count > 0 && (b.seen_at === null || b.last_fired_at > b.seen_at); + var snap = (session.snapshot || '').trimEnd(); + // Show last 20 lines of snapshot + var lines = snap ? snap.split('\n').slice(-20).join('\n') : '(no output)'; + var priority = sessionPriority(session); + + var bellClass = hasBell ? ' session-tile--bell' : ''; + var tierClass = mobile ? ' session-tile--tier-' + priority : ''; + var bellCount = b.unseen_count > 9 ? '9+' : b.unseen_count; + var bellDot = hasBell + ? '' + + '' + bellCount + '' + : ''; + + return '
' + + '
' + + '' + escapeHtml(session.name) + '' + + '' + bellDot + + '' + formatTimestamp(b.last_fired_at || null) + '' + + '' + + '
' + + '
' + + '
' + escapeHtml(lines) + '
' + + '
' + + '
'; +} + +function renderGrid(sessions) { + var grid = $('session-grid'); + var emptyState = $('empty-state'); + if (!grid) return; + + if (!sessions || sessions.length === 0) { + grid.innerHTML = ''; + if (emptyState) emptyState.classList.remove('hidden'); + return; + } + if (emptyState) emptyState.classList.add('hidden'); + + var mobile = isMobile(); + var ordered = mobile ? sortByPriority(sessions) : sessions; + + grid.innerHTML = ordered.map(function(s, i) { + return buildTileHTML(s, i + 1, mobile); + }).join(''); + + // Bind click handlers on newly-inserted tiles + var tiles = grid.querySelectorAll('.session-tile'); + for (var i = 0; i < tiles.length; i++) { + (function(tile) { + tile.addEventListener('click', function() { openSession(tile.dataset.session); }); + tile.addEventListener('keydown', function(e) { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openSession(tile.dataset.session); } + }); + })(tiles[i]); + } + + // If in expanded view, update the pill bell badge for "other sessions" + if (_viewMode === 'fullscreen') updatePillBell(); +} +``` + +--- + +### Step 2: Run Node.js tests — verify still passing + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` + +--- + +### Step 3: Manual browser verification + +Open `http://localhost:8000`. You should now see: +1. Session tiles rendered in the grid (one per tmux session) +2. Each tile shows session name, last activity time, and terminal snapshot text +3. Run `printf '\a'` in a tmux session — within 2 seconds the tile gets amber border glow (`.session-tile--bell` class) +4. On mobile-size viewport (< 600px wide), tiles reorder: bell first, then active, then idle + +--- + +### Step 4: Commit + +``` +git add frontend/app.js +git commit -m "feat: add renderGrid — tile HTML for desktop grid and mobile list" +``` + +--- + +## Task 8: Bell indicators + Browser Notifications + +**Files:** +- Modify: `frontend/app.js` (add `requestNotificationPermission`, `handleBellTransitions`) + +No unit tests — requires Notification API and DOM. Manual verification below. + +--- + +### Step 1: Add bell functions to `frontend/app.js` + +Add this block **below `renderGrid`** (above DOMContentLoaded, above export block): + +```javascript +// ── Browser Notifications ───────────────────────────────────────────────────── +function requestNotificationPermission() { + if (!('Notification' in window)) return; + if (Notification.permission === 'granted') { + _notificationPermission = 'granted'; + return; + } + if (Notification.permission === 'default') { + Notification.requestPermission().then(function(perm) { + _notificationPermission = perm; + }); + } else { + _notificationPermission = Notification.permission; // 'denied' + } +} + +function handleBellTransitions(prevSessions, nextSessions) { + var transitions = detectBellTransitions(prevSessions, nextSessions); + if (transitions.length === 0) return; + transitions.forEach(function(name) { + if (_notificationPermission === 'granted' && document.hidden) { + // `tag` deduplicates: one notification per session, not per bell event. + new Notification('Activity in: ' + name, { + body: 'tmux session needs attention', + tag: 'tmux-bell-' + name, + }); + } + }); +} +``` + +--- + +### Step 2: Run Node.js tests — verify still passing + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` + +--- + +### Step 3: Manual browser verification + +1. Open `http://localhost:8000` — browser should prompt for notification permission. Allow it. +2. Open another browser tab (so the dashboard tab is backgrounded: `document.hidden === true`). +3. In a tmux session: `printf '\a'` +4. Within 2 seconds: an OS notification appears: "Activity in: \" +5. Switch back to dashboard tab — tile has amber glow + +--- + +### Step 4: Commit + +``` +git add frontend/app.js +git commit -m "feat: add bell indicators and browser notification on bell transition" +``` + +--- + +## Task 9: Heartbeat sender — `POST /api/heartbeat` every 5s + +**Files:** +- Modify: `frontend/app.js` (add `startHeartbeat`, `sendHeartbeat`) + +No unit tests — requires fetch. Manual verification below. + +--- + +### Step 1: Add heartbeat functions to `frontend/app.js` + +Add this block **below `handleBellTransitions`** (above DOMContentLoaded, above export block): + +```javascript +// ── Heartbeat ───────────────────────────────────────────────────────────────── +function startHeartbeat() { + if (_heartbeatTimer) return; + sendHeartbeat(); // immediate first send + _heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_MS); +} + +function sendHeartbeat() { + var payload = buildHeartbeatPayload( + _deviceId, + _viewingSession, + _viewMode, + _lastInteractionAt + ); + api('POST', '/api/heartbeat', payload).catch(function(err) { + console.warn('heartbeat failed', err); + }); +} +``` + +--- + +### Step 2: Run Node.js tests — verify still passing + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` + +--- + +### Step 3: Manual browser verification + +Open `http://localhost:8000`. Check: +1. Network tab — `POST /api/heartbeat` fires on page load and every ~5 seconds +2. Request body includes: `device_id`, `label`, `viewing_session` (null when on grid), `view_mode` ("grid"), `last_interaction_at` (unix timestamp) +3. Response: `{"device_id": "d-...", "status": "ok"}` +4. `GET /api/state` → check `devices` key — your device should appear + +--- + +### Step 4: Commit + +``` +git add frontend/app.js +git commit -m "feat: add heartbeat sender — POST /api/heartbeat every 5s" +``` + +--- + +## Task 10: `openSession`/`closeSession` — zoom transition and view switching + +**Files:** +- Modify: `frontend/app.js` (add `openSession`, `closeSession`, `showToast`) + +No unit tests — requires DOM. Manual verification below. + +--- + +### Step 1: Add session transition functions to `frontend/app.js` + +Add this block **below `sendHeartbeat`** (above DOMContentLoaded, above export block): + +```javascript +// ── Toast notification ──────────────────────────────────────────────────────── +function showToast(msg) { + var toast = $('toast'); + if (!toast) return; + toast.textContent = msg; + toast.classList.remove('hidden'); + setTimeout(function() { toast.classList.add('hidden'); }, 3000); +} + +// ── updatePillBell ──────────────────────────────────────────────────────────── +// Updates the bell badge on the floating session pill. +// Called from renderGrid (when polling) and openSession. +function updatePillBell() { + var pillBell = $('session-pill-bell'); + if (!pillBell) return; + var othersWithBell = _currentSessions.filter(function(s) { + return s.name !== _viewingSession && s.bell && s.bell.unseen_count > 0; + }); + if (othersWithBell.length > 0) pillBell.classList.remove('hidden'); + else pillBell.classList.add('hidden'); +} + +// ── openSession ─────────────────────────────────────────────────────────────── +// Opens a session in the expanded terminal view. +// opts.skipConnect = true: don't POST /connect (ttyd already running after restore). +function openSession(name, opts) { + opts = opts || {}; + var skipConnect = opts.skipConnect === true; + + // Update app state + _viewingSession = name; + _viewMode = 'fullscreen'; + + var nameEl = $('expanded-session-name'); + if (nameEl) nameEl.textContent = name; + + // Find tile for zoom origin (may not exist if view was already expanded) + var tile = document.querySelector('[data-session="' + CSS.escape(name) + '"]'); + var grid = $('session-grid'); + + if (tile && grid) { + var rect = tile.getBoundingClientRect(); + // Pin tile at its current position, then animate to fullscreen + tile.style.cssText = 'position:fixed;top:' + rect.top + 'px;left:' + rect.left + + 'px;width:' + rect.width + 'px;height:' + rect.height + + 'px;z-index:50;transition:all 250ms ease-in-out;'; + tile.classList.add('session-tile--expanding'); + grid.classList.add('session-grid--dimming'); + + // Force reflow so the browser registers the starting position + void tile.getBoundingClientRect(); + + // Animate to full viewport + requestAnimationFrame(function() { + tile.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;' + + 'z-index:50;transition:all 250ms ease-in-out;border-radius:0;'; + }); + } + + // Wait for animation, then switch views + return new Promise(function(resolve) { + setTimeout(function() { + var viewOverview = $('view-overview'); + var viewExpanded = $('view-expanded'); + if (viewOverview) { viewOverview.classList.remove('view--active'); viewOverview.classList.add('hidden'); } + if (viewExpanded) { viewExpanded.classList.remove('hidden'); viewExpanded.classList.add('view--active'); } + + // Show mobile session pill + if (isMobile()) { + var pill = $('session-pill'); + var pillLabel = $('session-pill-label'); + if (pill) pill.classList.remove('hidden'); + if (pillLabel) pillLabel.textContent = name; + updatePillBell(); + } + + // Connect to session (spawns ttyd) + if (skipConnect) { + if (window._openTerminal) window._openTerminal(name); + resolve(); + return; + } + + api('POST', '/api/sessions/' + encodeURIComponent(name) + '/connect') + .then(function() { + if (window._openTerminal) window._openTerminal(name); + resolve(); + }) + .catch(function(err) { + console.error('openSession: connect failed', err); + showToast("Couldn't connect to session '" + name + "'"); + closeSession().then(resolve); + }); + }, 260); + }); +} + +// ── closeSession ────────────────────────────────────────────────────────────── +function closeSession() { + _viewMode = 'grid'; + _viewingSession = null; + + // Close xterm.js terminal + if (window._closeTerminal) window._closeTerminal(); + + // Kill ttyd connection + api('DELETE', '/api/sessions/current').catch(function() {}); + + // Switch back to overview + var viewExpanded = $('view-expanded'); + var viewOverview = $('view-overview'); + if (viewExpanded) { viewExpanded.classList.remove('view--active'); viewExpanded.classList.add('hidden'); } + if (viewOverview) { viewOverview.classList.remove('hidden'); viewOverview.classList.add('view--active'); } + + // Hide mobile pill + var pill = $('session-pill'); + if (pill) pill.classList.add('hidden'); + + return Promise.resolve(); +} +``` + +--- + +### Step 2: Run Node.js tests — verify still passing + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` + +--- + +### Step 3: Manual browser verification + +Open `http://localhost:8000`. Check: +1. Click a session tile — tile animates (zoom expand) to fill the viewport +2. Expanded header shows session name +3. Network — `POST /api/sessions//connect` fires, returns `{"active_session": ..., "ttyd_port": 7682}` +4. Terminal area shows (empty — `terminal.js` isn't wired yet, added in Tasks 12-13) +5. Click the `←` back button — returns to grid view +6. Grid tiles reappear +7. Network — `DELETE /api/sessions/current` fires + +If ttyd is not running, a toast appears: "Couldn't connect to session '...'" and the view returns to grid. + +--- + +### Step 4: Commit + +``` +git add frontend/app.js +git commit -m "feat: add openSession/closeSession — zoom transition and view switching" +``` + +--- + +## Task 11: Command palette — Ctrl+K, keyboard nav, filter, Escape, G-to-grid + +**Files:** +- Modify: `frontend/app.js` (add `bindStaticEventListeners`, all palette functions) + +No unit tests — requires DOM. Manual verification below. + +--- + +### Step 1: Add command palette functions to `frontend/app.js` + +Add this block **below `closeSession`** (above DOMContentLoaded, above export block): + +```javascript +// ── Command palette ──────────────────────────────────────────────────────────── +var _paletteSelectedIndex = 0; +var _paletteFilteredSessions = []; + +function openPalette() { + var palette = $('command-palette'); + if (!palette) return; + palette.classList.remove('hidden'); + + _paletteFilteredSessions = _currentSessions.slice(); + renderPaletteList(_paletteFilteredSessions); + _paletteSelectedIndex = 0; + highlightPaletteItem(0); + + var input = $('palette-input'); + if (input) { + input.value = ''; + input.focus(); + input.addEventListener('input', onPaletteInput); + } +} + +function closePalette() { + var palette = $('command-palette'); + if (palette) palette.classList.add('hidden'); + var input = $('palette-input'); + if (input) input.removeEventListener('input', onPaletteInput); +} + +function onPaletteInput(e) { + _paletteFilteredSessions = filterByQuery(_currentSessions, e.target.value); + renderPaletteList(_paletteFilteredSessions); + _paletteSelectedIndex = 0; + highlightPaletteItem(0); +} + +function renderPaletteList(sessions) { + var list = $('palette-list'); + if (!list) return; + var items = sessions.slice(0, 9); + list.innerHTML = items.map(function(s, i) { + var hasBell = s.bell && s.bell.unseen_count > 0; + return '
  • ' + + '' + (i + 1) + '' + + '' + escapeHtml(s.name) + '' + + (hasBell ? '' : '') + + '' + formatTimestamp(s.bell ? s.bell.last_fired_at : null) + '' + + '
  • '; + }).join(''); + + list.querySelectorAll('.palette-item').forEach(function(item) { + item.addEventListener('click', function() { + var name = item.dataset.session; + closePalette(); + openSession(name); + }); + }); +} + +function highlightPaletteItem(index) { + var items = $('palette-list') ? $('palette-list').querySelectorAll('.palette-item') : []; + for (var i = 0; i < items.length; i++) { + items[i].classList.toggle('palette-item--selected', i === index); + } +} + +function handlePaletteKeydown(e) { + var list = $('palette-list'); + var items = list ? Array.prototype.slice.call(list.querySelectorAll('.palette-item')) : []; + switch (e.key) { + case 'ArrowDown': + e.preventDefault(); + _paletteSelectedIndex = Math.min(_paletteSelectedIndex + 1, items.length - 1); + highlightPaletteItem(_paletteSelectedIndex); + break; + case 'ArrowUp': + e.preventDefault(); + _paletteSelectedIndex = Math.max(_paletteSelectedIndex - 1, 0); + highlightPaletteItem(_paletteSelectedIndex); + break; + case 'Enter': + e.preventDefault(); + var selected = items[_paletteSelectedIndex]; + if (selected) { closePalette(); openSession(selected.dataset.session); } + break; + case 'Escape': + e.preventDefault(); + closePalette(); + break; + case 'g': + case 'G': + e.preventDefault(); + closePalette(); + closeSession(); + break; + default: + // Number keys 1-9 jump directly to session + if (/^[1-9]$/.test(e.key)) { + e.preventDefault(); + var item = items[parseInt(e.key, 10) - 1]; + if (item) { closePalette(); openSession(item.dataset.session); } + } + } +} + +// ── Global keyboard handler ──────────────────────────────────────────────────── +function handleGlobalKeydown(e) { + var palette = $('command-palette'); + var paletteOpen = palette && !palette.classList.contains('hidden'); + + if (paletteOpen) { + handlePaletteKeydown(e); + return; + } + + if (_viewMode === 'fullscreen') { + // Backtick ` or Ctrl+K → open palette + if (e.key === '`' || (e.ctrlKey && e.key === 'k')) { + e.preventDefault(); + openPalette(); + return; + } + // Escape → back to grid (when palette is closed) + if (e.key === 'Escape') { + closeSession(); + return; + } + } +} + +// ── Static event listener binding ───────────────────────────────────────────── +// Called once from DOMContentLoaded after all functions are defined. +function bindStaticEventListeners() { + // Back button (← in expanded header) + on($('back-btn'), 'click', function() { closeSession(); }); + + // ⌘K button in expanded header + on($('palette-trigger'), 'click', function() { openPalette(); }); + + // Palette backdrop — click outside to close + on($('palette-backdrop'), 'click', function() { closePalette(); }); + + // Global keyboard shortcuts + document.addEventListener('keydown', handleGlobalKeydown); + + // Session pill → open bottom sheet (Task 15 adds body of openBottomSheet) + on($('session-pill'), 'click', function() { openBottomSheet(); }); + + // Bottom sheet backdrop + on($('sheet-backdrop'), 'click', function() { closeBottomSheet(); }); +} +``` + +--- + +### Step 2: Run Node.js tests — verify still passing + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` + +--- + +### Step 3: Manual browser verification + +Open `http://localhost:8000`. Open a session by clicking a tile. Then: +1. Press Ctrl+K or `` ` `` — command palette dialog opens +2. Type a session name fragment — list filters in real time +3. Arrow Down/Up — highlighted item moves +4. Press `2` — second session opens directly +5. Press Escape — palette closes, stays in expanded view +6. Press Ctrl+K again to reopen, press `G` — returns to grid view +7. In expanded view, press Escape (palette closed) — returns to grid view + +--- + +### Step 4: Commit + +``` +git add frontend/app.js +git commit -m "feat: add command palette — Ctrl+K, keyboard navigation, filter" +``` + +--- + +## Task 12: `terminal.js` — xterm.js Terminal + FitAddon initialization + +**Files:** +- Modify: `frontend/terminal.js` (replace stub entirely with xterm.js init) + +No unit tests — requires xterm.js CDN (browser-only). Manual verification below. + +--- + +### Step 1: Replace `frontend/terminal.js` with xterm.js init code + +**Replace the entire file** with: + +```javascript +/* terminal.js — xterm.js terminal management for tmux-web + * + * Loaded after app.js (see index.html script order). Exposes openTerminal and + * closeTerminal to app.js via window._ to avoid circular dependencies. + * + * Dependencies: xterm@5.3.0 + xterm-addon-fit@0.8.0, both loaded from CDN + * in index.html before this script runs. They are available as window.Terminal + * and window.FitAddon.FitAddon. + */ + +var SCROLLBACK_MOBILE = 500; +var SCROLLBACK_DESKTOP = 5000; +var MOBILE_THRESHOLD = 600; + +var _term = null; +var _fitAddon = null; +var _ws = null; +var _reconnectTimer = null; +var _currentSession = null; +var _vpHandler = null; + +function isMobileTerm() { return window.innerWidth < MOBILE_THRESHOLD; } + +// ── createTerminal ──────────────────────────────────────────────────────────── +// Disposes any existing terminal, creates a fresh xterm.js Terminal instance. +function createTerminal() { + if (_term) { + try { _term.dispose(); } catch (e) {} + _term = null; + _fitAddon = null; + } + + var Terminal = window.Terminal; + var FitAddonClass = window.FitAddon && window.FitAddon.FitAddon; + + if (!Terminal) { + console.error('terminal.js: xterm.js not loaded from CDN'); + return; + } + + _term = new Terminal({ + cursorBlink: true, + fontSize: isMobileTerm() ? 12 : 14, + fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace", + theme: { + background: '#000000', + foreground: '#c9d1d9', + cursor: '#58a6ff', + selectionBackground: 'rgba(88, 166, 255, 0.3)', + }, + scrollback: isMobileTerm() ? SCROLLBACK_MOBILE : SCROLLBACK_DESKTOP, + allowProposedApi: true, + }); + + if (FitAddonClass) { + _fitAddon = new FitAddonClass(); + _term.loadAddon(_fitAddon); + } +} + +// ── openTerminal ────────────────────────────────────────────────────────────── +// Called by app.js openSession() after the expanded view is visible. +function openTerminal(sessionName) { + _currentSession = sessionName; + + var container = document.getElementById('terminal-container'); + if (!container) { + console.error('terminal.js: #terminal-container not found'); + return; + } + + createTerminal(); + if (!_term) return; + + _term.open(container); + if (_fitAddon) { + try { _fitAddon.fit(); } catch (e) {} + } + + connectWebSocket(sessionName); + initVisualViewport(); +} + +// ── closeTerminal ───────────────────────────────────────────────────────────── +// Called by app.js closeSession(). +function closeTerminal() { + if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; } + if (_ws) { try { _ws.close(); } catch (e) {} _ws = null; } + if (_term) { try { _term.dispose(); } catch (e) {} _term = null; } + if (_vpHandler && window.visualViewport) { + window.visualViewport.removeEventListener('resize', _vpHandler); + _vpHandler = null; + } + _currentSession = null; + + // Clear the container so stale canvas doesn't flash on next open + var container = document.getElementById('terminal-container'); + if (container) container.innerHTML = ''; +} + +// ── connectWebSocket and initVisualViewport are defined in Tasks 13 and 14 ── +// Forward stubs so closeTerminal above compiles without reference errors. +function connectWebSocket(sessionName) {} +function initVisualViewport() {} + +// ── Expose to app.js via window ─────────────────────────────────────────────── +// app.js checks window._openTerminal and window._closeTerminal before calling. +window._openTerminal = openTerminal; +window._closeTerminal = closeTerminal; +``` + +--- + +### Step 2: Run Node.js tests — verify still passing + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` (terminal.js is not loaded by Node.js tests) + +--- + +### Step 3: Manual browser verification + +Open `http://localhost:8000`. Click a session tile. In the expanded view: +1. DevTools Console — no `xterm.js not loaded` error +2. `#terminal-container` has a `canvas` element inside it (xterm.js rendered) +3. Terminal cursor is visible and blinking +4. The terminal is sized to fill the container (FitAddon working) +5. Note: typing produces no output yet — WebSocket is stubbed until Task 13 + +--- + +### Step 4: Commit + +``` +git add frontend/terminal.js +git commit -m "feat: add terminal.js — xterm.js Terminal and FitAddon initialization" +``` + +--- + +## Task 13: WebSocket connection to ttyd via `/terminal/` + +**Files:** +- Modify: `frontend/terminal.js` (replace `connectWebSocket` stub with real implementation) + +No unit tests — requires WebSocket + browser. Manual verification below. + +--- + +### Step 1: Replace the `connectWebSocket` stub in `frontend/terminal.js` + +Find this line in `terminal.js`: +```javascript +function connectWebSocket(sessionName) {} +``` + +Replace it with: + +```javascript +// ── WebSocket to ttyd ───────────────────────────────────────────────────────── +// ttyd runs on port 7682; Caddy proxies /terminal/ → ws://localhost:7682 +function connectWebSocket(sessionName) { + var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + var url = proto + '//' + location.host + '/terminal/'; + var reconnectOverlay = document.getElementById('reconnect-overlay'); + + function connect() { + if (!_currentSession) return; // session was closed before connect resolved + _ws = new WebSocket(url); + _ws.binaryType = 'arraybuffer'; + + _ws.addEventListener('open', function() { + if (reconnectOverlay) reconnectOverlay.classList.add('hidden'); + }); + + _ws.addEventListener('message', function(e) { + if (!_term) return; + if (e.data instanceof ArrayBuffer) { + _term.write(new Uint8Array(e.data)); + } else { + _term.write(e.data); + } + }); + + _ws.addEventListener('close', function() { + if (!_currentSession) return; // intentional close via closeTerminal() + if (reconnectOverlay) reconnectOverlay.classList.remove('hidden'); + _reconnectTimer = setTimeout(connect, 2000); + }); + + _ws.addEventListener('error', function(e) { + console.warn('terminal.js: WebSocket error', e); + }); + + // Forward terminal keystrokes → ttyd + if (_term) { + _term.onData(function(data) { + if (_ws && _ws.readyState === WebSocket.OPEN) { + _ws.send(data); + } + }); + } + + // Notify ttyd of terminal size changes + if (_term) { + _term.onResize(function(size) { + if (_ws && _ws.readyState === WebSocket.OPEN) { + // ttyd accepts resize as a JSON message + _ws.send(JSON.stringify({ columns: size.cols, rows: size.rows })); + } + }); + } + } + + connect(); +} +``` + +--- + +### Step 2: Run Node.js tests — verify still passing + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` + +--- + +### Step 3: Manual browser verification + +Open `http://localhost:8000`. Click a session tile. In the expanded view: +1. Terminal renders and shows the tmux session output +2. Type commands — they execute in the real tmux session +3. Close browser tab (or manually close the WebSocket via DevTools Network → WS → X) + - "Reconnecting…" overlay appears on `#reconnect-overlay` + - After 2 seconds, it automatically reconnects and overlay disappears +4. Navigate back to grid (← button), then click the same session again + - Terminal re-opens cleanly with no stale canvas + +--- + +### Step 4: Commit + +``` +git add frontend/terminal.js +git commit -m "feat: add WebSocket connection to ttyd via /terminal/" +``` + +--- + +## Task 14: `visualViewport` mobile keyboard resize + +**Files:** +- Modify: `frontend/terminal.js` (replace `initVisualViewport` stub with real implementation) + +No unit tests — requires browser + mobile emulation. Manual verification below. + +--- + +### Step 1: Replace the `initVisualViewport` stub in `frontend/terminal.js` + +Find this line in `terminal.js`: +```javascript +function initVisualViewport() {} +``` + +Replace it with: + +```javascript +// ── visualViewport keyboard resize ──────────────────────────────────────────── +// On mobile, when the software keyboard appears, window.innerHeight shrinks but +// the layout doesn't reflow automatically. We use window.visualViewport to detect +// the actual visible height and resize #terminal-container to fit above the keyboard. +function initVisualViewport() { + if (!window.visualViewport) return; + + // Remove any previous listener to avoid stacking on reconnect + if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler); + + _vpHandler = function() { + if (!_term || !_fitAddon) return; + var container = document.getElementById('terminal-container'); + if (!container) return; + + var vvh = window.visualViewport.height; + var headerHeight = 44; // matches CSS --header-height var + var termHeight = Math.max(100, vvh - headerHeight); + container.style.height = termHeight + 'px'; + + try { _fitAddon.fit(); } catch (e) {} + }; + + window.visualViewport.addEventListener('resize', _vpHandler); + + // Fire immediately to handle the case where the keyboard was already open + _vpHandler(); +} +``` + +--- + +### Step 2: Run Node.js tests — verify still passing + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: `# pass 25 # fail 0` + +--- + +### Step 3: Manual browser verification + +1. Open `http://localhost:8000` in Chrome DevTools → Toggle device toolbar (set to iPhone-size, < 600px wide) +2. Click a session tile to open expanded view +3. Tap in the terminal area — software keyboard appears +4. Verify terminal is still visible above the keyboard (container height adjusted) +5. Type a command — it executes + +Without this fix, the terminal would be partially hidden behind the keyboard on mobile. + +--- + +### Step 4: Commit + +``` +git add frontend/terminal.js +git commit -m "feat: add visualViewport keyboard resize for mobile terminal" +``` + +--- + +## Task 15: Mobile bottom sheet + session pill + +**Files:** +- Modify: `frontend/app.js` (add `openBottomSheet`, `closeBottomSheet`, `renderSheetList`) + +No unit tests — requires DOM. Manual verification below. + +--- + +### Step 1: Add bottom sheet functions to `frontend/app.js` + +Add this block **below `bindStaticEventListeners`** (above the conditional export block): + +```javascript +// ── Mobile bottom sheet ──────────────────────────────────────────────────────── +// Displayed when the user taps the session pill while in expanded view. +// Shows all sessions ordered by priority. Tapping switches to that session. + +function openBottomSheet() { + var sheet = $('bottom-sheet'); + if (!sheet) return; + renderSheetList(); + sheet.classList.remove('hidden'); +} + +function closeBottomSheet() { + var sheet = $('bottom-sheet'); + if (sheet) sheet.classList.add('hidden'); +} + +function renderSheetList() { + var list = $('sheet-list'); + if (!list) return; + var sorted = sortByPriority(_currentSessions); + list.innerHTML = sorted.map(function(s) { + var hasBell = s.bell && s.bell.unseen_count > 0; + var isActive = s.name === _viewingSession; + return '
  • ' + + '' + escapeHtml(s.name) + '' + + (hasBell ? '' : '') + + '' + formatTimestamp(s.bell ? s.bell.last_fired_at : null) + '' + + '
  • '; + }).join(''); + + list.querySelectorAll('.sheet-item').forEach(function(item) { + item.addEventListener('click', function() { + var name = item.dataset.session; + closeBottomSheet(); + if (name !== _viewingSession) { + // Close current session first, then open new one + closeSession().then(function() { openSession(name); }); + } + }); + }); +} +``` + +--- + +### Step 2: Run all Node.js tests — final check + +``` +node --test frontend/tests/test_app.mjs +``` + +Expected: +``` +# tests 25 +# pass 25 +# fail 0 +``` + +--- + +### Step 3: Run all Python tests — verify nothing broke + +``` +cd /home/bkrabach/dev/web-tmux +python -m pytest coordinator/tests/ --ignore=coordinator/tests/test_integration.py -q +``` + +Expected: **118 passed, 0 failed** + +--- + +### Step 4: Manual browser verification (mobile) + +1. Open `http://localhost:8000` in DevTools mobile mode (< 600px wide) +2. Click a session tile — expanded view opens, floating pill appears at bottom with session name +3. Tap the pill — bottom sheet slides up showing all sessions, sorted (bell first) +4. Active session has `.sheet-item--active` class (visually distinguished) +5. Tap a different session — sheet closes, new session opens + +On desktop (> 600px wide): +- Pill is hidden (`.hidden` class stays on `#session-pill`) +- Bottom sheet does not appear + +--- + +### Step 5: Commit + +``` +git add frontend/app.js +git commit -m "feat: add mobile bottom sheet and session pill" +``` + +--- + +## Final Verification Checklist + +After all 15 tasks are complete, run this full verification sequence: + +### Check 1: Node.js unit tests +``` +node --test frontend/tests/test_app.mjs +``` +Expected: `# tests 25 # pass 25 # fail 0` + +### Check 2: Python test suite +``` +cd /home/bkrabach/dev/web-tmux +python -m pytest coordinator/tests/ --ignore=coordinator/tests/test_integration.py -q +``` +Expected: `118 passed, 0 failed` + +### Check 3: Ruff linter +``` +ruff check coordinator/ && ruff format --check coordinator/ +``` +Expected: `All checks passed.` + +### Check 4: File existence +``` +ls -la frontend/app.js frontend/terminal.js frontend/tests/test_app.mjs +``` +All three files must exist and be non-empty. + +### Check 5: Behavior smoke test +``` +uvicorn coordinator.main:app --port 8000 +``` +Then open `http://localhost:8000` and verify: +- Session tiles render +- `●` connection status shown +- Clicking a tile opens expanded view with working terminal +- Ctrl+K opens command palette +- Back button returns to grid + +--- + +## Scope Boundaries — Phase 2b Does NOT Include + +| Deferred to | What | +|---|---| +| Phase 3 | Caddy configuration (`/terminal/` WebSocket proxy) | +| Phase 3 | systemd service unit | +| Phase 3 | Service worker / offline PWA support | +| Phase 3 | Browser-tester E2E (Playwright) | +| Phase 3 | Push notifications beyond Browser Notifications API | +| v2 | Session creation / renaming UI | +| v2 | Drag-to-reorder tiles | + +> **Note on Caddy:** Until Caddy is configured (Phase 3), the WebSocket to `/terminal/` will fail in production. In development, run ttyd manually on port 7682 and add a `--proxy-headers` Caddy route, OR test via the uvicorn dev server with a manually-started ttyd process attached to a tmux session by name. diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..b4a1654 --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,935 @@ +// Phase 2b implementation — app.js + +/** + * Format a Unix timestamp (seconds) into a relative time string. + * @param {number|null|undefined} ts - Unix timestamp in seconds + * @returns {string} + */ +function formatTimestamp(ts) { + if (ts == null) return '\u2014'; + const diff = Math.floor(Date.now() / 1000 - ts); + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + return `${Math.floor(diff / 3600)}h ago`; +} + +/** + * Return the priority label for a session object. + * @param {object} session + * @returns {'bell'|'idle'} + */ +function sessionPriority(session) { + const bell = session && session.bell; + if (bell && bell.unseen_count > 0 && (bell.seen_at === null || bell.last_fired_at > bell.seen_at)) { + return 'bell'; + } + return 'idle'; +} + +/** Priority rank map used by sortByPriority. Lower rank = higher priority. */ +const PRIORITY_RANK = { bell: 0, active: 1, idle: 2 }; + +/** + * Sort an array of sessions by priority (ascending rank). + * Returns a new array; does not mutate the original. + * @param {object[]} sessions + * @returns {object[]} + */ +function sortByPriority(sessions) { + return sessions.slice().sort((a, b) => { + const rankA = PRIORITY_RANK[sessionPriority(a)] ?? 2; + const rankB = PRIORITY_RANK[sessionPriority(b)] ?? 2; + return rankA - rankB; + }); +} + +/** + * Filter an array of sessions by a search query string. + * Matches against session.name (case-insensitive substring match). + * Returns all sessions when query is empty or null. + * @param {object[]} sessions + * @param {string|null} query + * @returns {object[]} + */ +function filterByQuery(sessions, query) { + if (!query) return sessions; + const q = query.toLowerCase(); + return sessions.filter((s) => (s.name || '').toLowerCase().includes(q)); +} + +/** + * Detect which sessions have transitioned to a new or increased bell/alert state. + * Builds a Map of previous session names to their unseen_count, then returns + * the names of next sessions whose bell.unseen_count > 0 AND > the previous count. + * @param {object[]} prev - previous sessions array + * @param {object[]} next - updated sessions array + * @returns {string[]} names of sessions that newly have or increased bell count + */ +function detectBellTransitions(prev, next) { + const prevMap = new Map( + (prev || []).map((s) => [s.name, (s.bell && s.bell.unseen_count) || 0]), + ); + return (next || []) + .filter((s) => { + const unseen = s.bell && s.bell.unseen_count; + if (!unseen || unseen <= 0) return false; + const prevCount = prevMap.has(s.name) ? prevMap.get(s.name) : 0; + return unseen > prevCount; + }) + .map((s) => s.name); +} + +/** + * Generate a pseudo-random device ID string. + * Format: 'd-' followed by 8 alphanumeric characters. + * @returns {string} + */ +function generateDeviceId() { + return 'd-' + Math.random().toString(36).padEnd(10, '0').slice(2, 10); +} + +/** + * Build a heartbeat payload object for the current device/view state. + * @param {string} device_id - The generated device identifier + * @param {string|null} viewing_session - The session currently being viewed, or null + * @param {string} view_mode - Current view mode (e.g. 'split', 'full') + * @param {number} last_interaction_at - Unix timestamp of last user interaction + * @returns {object} + */ +function buildHeartbeatPayload(device_id, viewing_session, view_mode, last_interaction_at) { + const label = + typeof navigator !== 'undefined' && navigator.userAgent + ? navigator.userAgent.slice(0, 50) + : 'unknown'; + return { + device_id, + label, + viewing_session, + view_mode, + last_interaction_at, + }; +} + +// ─── Runtime constants ──────────────────────────────────────────────────────── +const POLL_MS = 2000; +const HEARTBEAT_MS = 5000; +const MOBILE_THRESHOLD = 600; + +// ─── App state ──────────────────────────────────────────────────────────────── +let _deviceId = ''; +let _currentSessions = []; +let _viewingSession = null; +let _viewMode = 'grid'; +let _lastInteractionAt = Date.now() / 1000; +let _pollingTimer; +let _heartbeatTimer; +let _notificationPermission = 'default'; +let _pollFailCount = 0; + +// ─── DOM helpers ────────────────────────────────────────────────────────────── +function $(id) { + return document.getElementById(id); +} + +function on(el, ev, fn) { + if (el) el.addEventListener(ev, fn); +} + +function isMobile() { + return window.innerWidth < MOBILE_THRESHOLD; +} + +// ─── Fetch wrapper ──────────────────────────────────────────────────────────── +async function api(method, path, body) { + const opts = { method, headers: {} }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + const res = await fetch(path, opts); + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + return res; +} + +// ─── Device ID ──────────────────────────────────────────────────────────────── +function initDeviceId() { + const STORAGE_KEY = 'tmux-web-device-id'; + try { + let id = localStorage.getItem(STORAGE_KEY); + if (!id) { + id = generateDeviceId(); + try { localStorage.setItem(STORAGE_KEY, id); } catch (_) { /* blocked — ok */ } + } + _deviceId = id; + } catch (_) { + // localStorage blocked (Tracking Prevention, private browsing, etc.) + // Fall back to a session-only device ID — not persisted but functional + if (!_deviceId) _deviceId = generateDeviceId(); + } +} + +// ─── Interaction tracking ───────────────────────────────────────────────────── +function trackInteraction() { + _lastInteractionAt = Math.floor(Date.now() / 1000); +} + +// ─── State restoration ─────────────────────────────────────────────────────── +/** + * Restore application state from the server on page load. + * Calls GET /api/state and, if an active session exists, re-opens it + * without POSTing to /connect (ttyd is already running). + * Always resolves — errors are logged as warnings so the app can start normally. + * @returns {Promise} + */ +async function restoreState() { + try { + const res = await api('GET', '/api/state'); + const state = await res.json(); + if (state.active_session) { + await openSession(state.active_session, { skipConnect: true }); + } + } catch (err) { + console.warn('[restoreState] could not restore previous session:', err); + } +} + +// ─── Connection status ────────────────────────────────────────────────────────────────────────── +/** + * Update the #connection-status indicator element. + * @param {'ok'|'warn'|'err'} level + */ +function setConnectionStatus(level) { + const el = $('connection-status'); + if (!el) return; + const map = { + ok: { text: '●', cls: 'connection-status--ok' }, + warn: { text: '◌ slow', cls: 'connection-status--warn' }, + err: { text: '✕ offline', cls: 'connection-status--err' }, + }; + const s = map[level]; + if (!s) return; + el.textContent = s.text; + el.className = s.cls; +} + +// ─── Session polling ───────────────────────────────────────────────────────────────────────────── +/** + * Fetch /api/sessions and update the UI. Called by startPolling. + * @returns {Promise} + */ +async function pollSessions() { + try { + const res = await api('GET', '/api/sessions'); + const sessions = await res.json(); + const prev = _currentSessions; + _currentSessions = sessions; + _pollFailCount = 0; + setConnectionStatus('ok'); + renderGrid(sessions); + handleBellTransitions(prev, sessions); + updateSessionPill(sessions); + } catch (err) { + _pollFailCount++; + setConnectionStatus(_pollFailCount <= 2 ? 'warn' : 'err'); + } +} + +/** + * Start the session polling interval. Guards against double-start. + */ +function startPolling() { + if (_pollingTimer) return; + pollSessions(); + _pollingTimer = setInterval(pollSessions, POLL_MS); +} + +// ─── Grid rendering ────────────────────────────────────────────────────────── + +/** + * Escape HTML special characters to safe entities. + * @param {string} str + * @returns {string} + */ +function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** + * Build the HTML string for a single session tile. + * @param {object} session + * @param {number} index + * @param {boolean} mobile + * @returns {string} + */ +function buildTileHTML(session, index, mobile) { + const priority = sessionPriority(session); + const isBell = priority === 'bell'; + const unseen = session.bell && session.bell.unseen_count; + + let classes = 'session-tile'; + if (isBell) classes += ' session-tile--bell'; + if (mobile) classes += ` session-tile--tier-${priority}`; + + const name = session.name || ''; + const escapedName = escapeHtml(name); + const timeStr = formatTimestamp(session.last_activity_at || null); + + // Bell indicator + let bellHtml = ''; + if (unseen && unseen > 0) { + const countStr = unseen > 9 ? '9+' : String(unseen); + bellHtml = `${countStr}`; + } + + // Last 20 lines of snapshot + const snapshot = session.snapshot || ''; + const lastLines = snapshot.split('\n').slice(-20).join('\n'); + + return ( + `
    ` + + `
    ` + + `${escapeHtml(name)}` + + `${bellHtml}${escapeHtml(timeStr)}` + + `
    ` + + `
    ${escapeHtml(lastLines)}
    ` + + `
    ` + ); +} + +/** + * Render the session grid. Shows empty state when no sessions exist. + * On mobile, sorts sessions by priority before rendering. + * Binds click and keydown handlers on each tile. + * @param {object[]} sessions + */ +function renderGrid(sessions) { + const grid = $('session-grid'); + const emptyState = $('empty-state'); + + if (!sessions || sessions.length === 0) { + if (grid) grid.innerHTML = ''; + if (emptyState) emptyState.classList.remove('hidden'); + return; + } + + if (emptyState) emptyState.classList.add('hidden'); + + const mobile = isMobile(); + const ordered = mobile ? sortByPriority(sessions) : sessions; + const html = ordered.map((session, index) => buildTileHTML(session, index, mobile)).join(''); + if (grid) grid.innerHTML = html; + + // Bind interaction handlers on each tile + document.querySelectorAll('.session-tile').forEach((tile) => { + on(tile, 'click', () => openSession(tile.dataset.session)); + on(tile, 'keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + openSession(tile.dataset.session); + } + }); + }); + + if (_viewMode === 'fullscreen') { + updatePillBell(); + } +} + +// ─── Notification permission ──────────────────────────────────────────────── + +/** + * Request browser notification permission on first load. + * - If the Notification API is not available, returns immediately. + * - If already granted, records the state synchronously. + * - If default (not yet asked), calls requestPermission() and stores the result. + * - Otherwise (e.g. denied), stores the current permission value. + */ +function requestNotificationPermission() { + if (typeof Notification === 'undefined') return; + if (Notification.permission === 'granted') { + _notificationPermission = 'granted'; + } else if (Notification.permission === 'default') { + Notification.requestPermission().then((permission) => { + _notificationPermission = permission; + }); + } else { + _notificationPermission = Notification.permission; + } +} + +// ─── Bell transition notifications ───────────────────────────────────────── + +/** + * Fire OS notifications for sessions that have newly received a bell event. + * Only fires when the Notification permission is granted AND the browser tab + * is currently hidden (document.hidden === true). + * Uses a per-session tag so the OS deduplicates multiple bells into one + * notification per session. + * @param {object[]} prevSessions - sessions array from the previous poll + * @param {object[]} nextSessions - sessions array from the current poll + */ +function handleBellTransitions(prevSessions, nextSessions) { + const transitions = detectBellTransitions(prevSessions, nextSessions); + for (const name of transitions) { + if (_notificationPermission === 'granted' && document.hidden) { + // eslint-disable-next-line no-new + new Notification('Activity in: ' + name, { + body: 'tmux session needs attention', + tag: 'tmux-bell-' + name, + }); + } + } +} + +// ─── Heartbeat ────────────────────────────────────────────────────────────────── + +/** + * Send a single heartbeat POST to /api/heartbeat. + * Catches errors and logs them as warnings — never throws. + * @returns {Promise} + */ +async function sendHeartbeat() { + try { + const payload = buildHeartbeatPayload(_deviceId, _viewingSession, _viewMode, _lastInteractionAt); + await api('POST', '/api/heartbeat', payload); + } catch (err) { + console.warn('[sendHeartbeat] heartbeat failed:', err); + } +} + +/** + * Start the heartbeat interval. Guards against double-start. + * Calls sendHeartbeat() immediately, then every HEARTBEAT_MS milliseconds. + */ +function startHeartbeat() { + if (_heartbeatTimer) return; + sendHeartbeat(); + _heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_MS); +} + +/** Test-only helper: reset heartbeat timer state so tests can exercise startHeartbeat cleanly. */ +function _resetHeartbeatTimer() { + if (_heartbeatTimer) clearInterval(_heartbeatTimer); + _heartbeatTimer = undefined; +} + +// ─── Toast notification ───────────────────────────────────────────────────── + +/** + * Show a brief toast message. + * Removes the 'hidden' class immediately, then restores it after 3000ms. + * @param {string} msg + */ +function showToast(msg) { + const el = $('toast'); + if (!el) return; + el.textContent = msg; + el.classList.remove('hidden'); + setTimeout(() => el.classList.add('hidden'), 3000); +} + +// ─── Session pill bell ─────────────────────────────────────────────────────── + +/** + * Update the floating session-pill bell indicator. + * Shows #session-pill-bell if any session other than _viewingSession has unseen bells. + */ +function updatePillBell() { + const el = $('session-pill-bell'); + if (!el) return; + const hasBell = _currentSessions.some( + (s) => s.name !== _viewingSession && s.bell && s.bell.unseen_count > 0, + ); + if (hasBell) el.classList.remove('hidden'); else el.classList.add('hidden'); +} + +// ─── Session open / close ──────────────────────────────────────────────────── + +/** + * Open a session in fullscreen view with a zoom transition. + * @param {string} name - session name + * @param {object} [opts] + * @param {boolean} [opts.skipConnect] - if true, skip the /connect POST (ttyd already running) + * @returns {Promise} + */ +async function openSession(name, opts = {}) { + _viewingSession = name; + _viewMode = 'fullscreen'; + + // Update expanded header + const nameEl = $('expanded-session-name'); + if (nameEl) nameEl.textContent = name; + + // Zoom animation: pin tile at current position, then animate to full viewport + const tile = document.querySelector(`[data-session="${name}"]`); + if (tile) { + const rect = tile.getBoundingClientRect(); + tile.style.position = 'fixed'; + tile.style.top = rect.top + 'px'; + tile.style.left = rect.left + 'px'; + tile.style.width = rect.width + 'px'; + tile.style.height = rect.height + 'px'; + tile.style.transition = 'none'; + // Force reflow + void tile.offsetWidth; + tile.style.transition = 'all 250ms ease'; + tile.style.top = '0'; + tile.style.left = '0'; + tile.style.width = '100vw'; + tile.style.height = '100vh'; + } + + // After animation completes: switch views, then mount terminal + setTimeout(() => { + const overview = $('view-overview'); + const expanded = $('view-expanded'); + if (overview) overview.style.display = 'none'; + if (expanded) { + expanded.classList.remove('hidden'); // must remove class — !important wins over style.display + expanded.classList.add('view--active'); // makes it display:flex + } + // Mount terminal AFTER view is visible so FitAddon measures real dimensions + if (window._openTerminal) window._openTerminal(name); + }, 260); + + // Mobile pill + if (isMobile()) { + const pill = $('session-pill'); + if (pill) { + pill.classList.remove('hidden'); // pill starts with hidden class + const pillLabel = $('session-pill-label'); + if (pillLabel) pillLabel.textContent = name; + } + updatePillBell(); + updateSessionPill(_currentSessions); + } + + // Connect to session (before animation completes so ttyd is ready) + try { + if (!opts.skipConnect) { + await api('POST', `/api/sessions/${name}/connect`); + } + } catch (err) { + showToast(err.message || 'Connection failed'); + return closeSession(); + } +} + +/** + * Close the current session and return to the grid view. + * @returns {Promise} + */ +function closeSession() { + _viewMode = 'grid'; + _viewingSession = null; + + if (window._closeTerminal) window._closeTerminal(); + + // Fire-and-forget DELETE + api('DELETE', '/api/sessions/current').catch(() => {}); + + const expanded = $('view-expanded'); + const overview = $('view-overview'); + if (expanded) { + expanded.classList.add('hidden'); + expanded.classList.remove('view--active'); + } + if (overview) overview.style.display = ''; // overview uses view--active (no !important), style.display clears fine + + const pill = $('session-pill'); + if (pill) pill.classList.add('hidden'); + + return Promise.resolve(); +} + +/** Test-only helper: set _viewingSession directly. */ +function _setViewingSession(name) { + _viewingSession = name; +} + +// ─── Command palette state ──────────────────────────────────────────────────── +const PALETTE_MAX_ITEMS = 9; +let _paletteSelectedIndex = 0; +let _paletteFilteredSessions = []; +let _paletteOpen = false; +let _paletteInputListener = null; + +// ─── Command palette functions ──────────────────────────────────────────────── + +/** + * Render the filtered session list inside #palette-list. + * Shows up to 9 items. Each item is a
  • with index number, + * session name, optional bell emoji, and timestamp. + */ +function renderPaletteList() { + const list = $('palette-list'); + if (!list) return; + + const items = _paletteFilteredSessions.slice(0, PALETTE_MAX_ITEMS); + list.innerHTML = items + .map((session, i) => { + const isBell = sessionPriority(session) === 'bell'; + const bell = isBell ? ' 🔔' : ''; + const time = formatTimestamp(session.last_activity_at || null); + const name = escapeHtml(session.name || ''); + return `
  • ${i + 1} ${name}${bell} ${escapeHtml(time)}
  • `; + }) + .join(''); + + // Bind click handlers on each item + list.querySelectorAll('.palette-item').forEach((item) => { + on(item, 'click', () => { + const idx = parseInt(item.dataset.index, 10); + const session = _paletteFilteredSessions[idx]; + if (session) { + closePalette(); + openSession(session.name).catch((err) => console.error('[renderPaletteList]', err)); + } + }); + }); + + highlightPaletteItem(_paletteSelectedIndex); +} + +/** + * Toggle the palette-item--selected class on the item at `index`. + * @param {number} index + */ +function highlightPaletteItem(index) { + const list = $('palette-list'); + if (!list) return; + list.querySelectorAll('.palette-item').forEach((item, i) => { + if (i === index) { + item.classList.add('palette-item--selected'); + } else { + item.classList.remove('palette-item--selected'); + } + }); +} + +/** + * Open the command palette. + * Shows #command-palette, copies _currentSessions to _paletteFilteredSessions, + * renders the list, resets selection index, focuses #palette-input, and binds + * the input event listener. + */ +function openPalette() { + _paletteOpen = true; + _paletteFilteredSessions = _currentSessions.slice(); + _paletteSelectedIndex = 0; + + const palette = $('command-palette'); + if (palette) palette.classList.remove('hidden'); // palette starts with hidden class + + renderPaletteList(); + + const input = $('palette-input'); + if (input) { + input.value = ''; + input.focus(); + if (_paletteInputListener) { + input.removeEventListener('input', _paletteInputListener); + } + _paletteInputListener = onPaletteInput; + input.addEventListener('input', _paletteInputListener); + } +} + +/** + * Close the command palette. + * Hides #command-palette and removes the input event listener. + */ +function closePalette() { + _paletteOpen = false; + + const palette = $('command-palette'); + if (palette) palette.classList.add('hidden'); + + const input = $('palette-input'); + if (input && _paletteInputListener) { + input.removeEventListener('input', _paletteInputListener); + _paletteInputListener = null; + } +} + +/** + * Handle input events on #palette-input. + * Filters sessions by the current query, re-renders the list, resets selection. + * @param {Event} e + */ +function onPaletteInput(e) { + const query = e && e.target ? e.target.value : ''; + _paletteFilteredSessions = filterByQuery(_currentSessions, query); + _paletteSelectedIndex = 0; + renderPaletteList(); +} + +/** + * Handle keydown events inside the command palette. + * ArrowDown/Up moves selection, Enter opens selected session, + * Escape closes palette, G closes palette + returns to grid, + * number keys 1-9 jump directly to that item. + * @param {KeyboardEvent} e + * @returns {Promise} + */ +async function handlePaletteKeydown(e) { + const visibleCount = Math.min(_paletteFilteredSessions.length, PALETTE_MAX_ITEMS); + + if (e.key === 'Escape') { + e.preventDefault(); + closePalette(); + } else if (e.key === 'g' || e.key === 'G') { + e.preventDefault(); + closePalette(); + await closeSession(); + } else if (visibleCount > 0) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + _paletteSelectedIndex = (_paletteSelectedIndex + 1) % visibleCount; + highlightPaletteItem(_paletteSelectedIndex); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + _paletteSelectedIndex = (_paletteSelectedIndex - 1 + visibleCount) % visibleCount; + highlightPaletteItem(_paletteSelectedIndex); + } else if (e.key === 'Enter') { + e.preventDefault(); + const session = _paletteFilteredSessions[_paletteSelectedIndex]; + if (session) { + closePalette(); + await openSession(session.name); + } + } else if (e.key >= '1' && e.key <= '9') { + const idx = parseInt(e.key, 10) - 1; + if (idx < visibleCount) { + e.preventDefault(); + const session = _paletteFilteredSessions[idx]; + closePalette(); + await openSession(session.name); + } + } + } +} + +/** + * Global keydown handler. + * When palette is open: delegates to handlePaletteKeydown. + * When in fullscreen with palette closed: backtick or Ctrl+K opens palette, + * Escape returns to grid. + * @param {KeyboardEvent} e + */ +function handleGlobalKeydown(e) { + if (_paletteOpen) { + handlePaletteKeydown(e).catch((err) => console.error('[handleGlobalKeydown]', err)); + return; + } + + if (_viewMode === 'fullscreen') { + if (e.key === '`' || (e.ctrlKey && e.key === 'k')) { + e.preventDefault(); + openPalette(); + } else if (e.key === 'Escape') { + e.preventDefault(); + closeSession(); + } + } +} + +/** + * Open the bottom sheet (mobile session switcher). + * Renders the current session list and removes the 'hidden' class. + */ +function openBottomSheet() { + var sheet = $('bottom-sheet'); + if (!sheet) return; + renderSheetList(); + sheet.classList.remove('hidden'); +} + +/** + * Close the bottom sheet. + * Adds the 'hidden' class and removes the dynamic backdrop listener. + */ +function closeBottomSheet() { + var sheet = $('bottom-sheet'); + if (sheet) sheet.classList.add('hidden'); +} + +/** + * Render the session list inside #sheet-list for the mobile bottom sheet. + * Sorts sessions by priority, builds
  • elements with bell indicator and timestamp, + * and binds click handlers to switch sessions. + */ +function renderSheetList() { + var list = $('sheet-list'); + if (!list) return; + var sorted = sortByPriority(_currentSessions); + list.innerHTML = sorted.map(function(s) { + var hasBell = s.bell && s.bell.unseen_count > 0 && + (s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at); + var isActive = s.name === _viewingSession; + var escapedName = escapeHtml(s.name || ''); + return '
  • ' + + '' + escapedName + '' + + (hasBell ? '\uD83D\uDD14' : '') + + '' + formatTimestamp(s.bell && s.bell.last_fired_at) + '' + + '
  • '; + }).join(''); + + list.querySelectorAll('.sheet-item').forEach(function(item) { + item.addEventListener('click', function() { + closeBottomSheet(); + var name = item.dataset.session; + if (name !== _viewingSession) openSession(name); + }); + }); +} + +/** + * Update the session pill bell badge when in fullscreen view. + * Shows #session-pill-bell if any other session (not currently viewed) has unseen bells. + * @param {object[]} sessions - full sessions array + */ +function updateSessionPill(sessions) { + if (_viewMode !== 'fullscreen') return; + var pillBell = $('session-pill-bell'); + if (!pillBell) return; + var othersWithBell = sessions.filter(function(s) { + return s.name !== _viewingSession && + s.bell && s.bell.unseen_count > 0 && + (s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at); + }); + if (othersWithBell.length > 0) { + pillBell.classList.remove('hidden'); + } else { + pillBell.classList.add('hidden'); + } +} + +/** + * Bind all static (once-only) event listeners for the app UI. + * Called once after restoreState() resolves. + */ +function bindStaticEventListeners() { + on($('back-btn'), 'click', closeSession); + on($('palette-trigger'), 'click', openPalette); + on($('palette-backdrop'), 'click', closePalette); + document.addEventListener('keydown', handleGlobalKeydown); + on($('session-pill'), 'click', openBottomSheet); + on($('sheet-backdrop'), 'click', closeBottomSheet); +} + +// ─── Test-only helpers ──────────────────────────────────────────────────────── + +/** Test-only: set _currentSessions directly. */ +function _setCurrentSessions(sessions) { + _currentSessions = sessions; +} + +/** Test-only: set _paletteFilteredSessions directly. */ +function _setPaletteFilteredSessions(sessions) { + _paletteFilteredSessions = sessions; +} + +/** Test-only: get _paletteFilteredSessions. */ +function _getPaletteFilteredSessions() { + return _paletteFilteredSessions; +} + +/** Test-only: set _paletteSelectedIndex directly. */ +function _setPaletteSelectedIndex(index) { + _paletteSelectedIndex = index; +} + +/** Test-only: get _paletteSelectedIndex. */ +function _getPaletteSelectedIndex() { + return _paletteSelectedIndex; +} + +/** Test-only: set _paletteOpen directly. */ +function _setPaletteOpen(val) { + _paletteOpen = val; +} + +/** Test-only: get _paletteOpen. */ +function _isPaletteOpen() { + return _paletteOpen; +} + +/** Test-only: set _viewMode directly. */ +function _setViewMode(mode) { + _viewMode = mode; +} + +document.addEventListener('DOMContentLoaded', () => { + initDeviceId(); + document.addEventListener('keydown', trackInteraction); + document.addEventListener('click', trackInteraction); + document.addEventListener('touchstart', trackInteraction); + restoreState() + .then(() => { + startPolling(); + startHeartbeat(); + requestNotificationPermission(); + bindStaticEventListeners(); + }) + .catch((err) => { + console.error('[init] restoreState failed, retrying in 5s:', err); + setTimeout(() => startPolling(), POLL_MS); + }); +}); + +// Conditional CommonJS export — must remain at the very bottom of this file. +if (typeof module !== 'undefined' && module.exports) { + module.exports = { + formatTimestamp, + sessionPriority, + sortByPriority, + filterByQuery, + detectBellTransitions, + generateDeviceId, + buildHeartbeatPayload, + setConnectionStatus, + pollSessions, + startPolling, + escapeHtml, + buildTileHTML, + renderGrid, + requestNotificationPermission, + handleBellTransitions, + sendHeartbeat, + startHeartbeat, + _resetHeartbeatTimer, + showToast, + updatePillBell, + openSession, + closeSession, + _setViewingSession, + // Command palette + renderPaletteList, + highlightPaletteItem, + openPalette, + closePalette, + onPaletteInput, + handlePaletteKeydown, + handleGlobalKeydown, + bindStaticEventListeners, + openBottomSheet, + closeBottomSheet, + renderSheetList, + updateSessionPill, + // Test-only helpers + _setCurrentSessions, + _setPaletteFilteredSessions, + _getPaletteFilteredSessions, + _setPaletteSelectedIndex, + _getPaletteSelectedIndex, + _setPaletteOpen, + _isPaletteOpen, + _setViewMode, + }; +} diff --git a/frontend/apple-touch-icon.png b/frontend/apple-touch-icon.png new file mode 100644 index 0000000..3b8665f Binary files /dev/null and b/frontend/apple-touch-icon.png differ diff --git a/frontend/favicon-32.png b/frontend/favicon-32.png new file mode 100644 index 0000000..8dd5892 Binary files /dev/null and b/frontend/favicon-32.png differ diff --git a/frontend/favicon.ico b/frontend/favicon.ico new file mode 100644 index 0000000..fbf7eb1 Binary files /dev/null and b/frontend/favicon.ico differ diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1846fd6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + muxplex + + + + +
    +
    +

    muxplex

    + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..6912db2 --- /dev/null +++ b/frontend/manifest.json @@ -0,0 +1,14 @@ +{ + "name": "muxplex", + "short_name": "muxplex", + "description": "Browser-based tmux session dashboard", + "start_url": "/", + "display": "standalone", + "orientation": "any", + "background_color": "#0D1117", + "theme_color": "#0D1117", + "icons": [ + {"src": "/pwa-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"}, + {"src": "/pwa-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable"} + ] +} diff --git a/frontend/pwa-192.png b/frontend/pwa-192.png new file mode 100644 index 0000000..ba914ac Binary files /dev/null and b/frontend/pwa-192.png differ diff --git a/frontend/pwa-512.png b/frontend/pwa-512.png new file mode 100644 index 0000000..9707b0f Binary files /dev/null and b/frontend/pwa-512.png differ diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..26c77dd --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,747 @@ +/* Design tokens, dark base theme, typography */ + +:root { + /* Background palette */ + --bg: #0D1117; /* --color-bg-base */ + --bg-secondary: #10131C; /* --color-bg-elevated */ + --bg-tile: #10131C; /* --color-bg-elevated */ + --bg-header: #0D1117; /* --color-bg-base (header matches page) */ + --bg-overlay: rgba(13, 17, 23, 0.85); /* brand base with opacity */ + --bg-tile-hover: #1A1F2B; /* --color-bg-surface */ + + /* Text palette */ + --text: #F0F6FF; /* --color-text-primary */ + --text-dim: #4A5060; /* --color-text-disabled */ + --text-muted: #8E95A3; /* --color-text-secondary */ + + /* Borders */ + --border: #2A3040; /* --color-border-default */ + --border-subtle: #1E2430; /* --color-border-subtle */ + + /* Accent */ + --accent: #00D9F5; /* brand cyan */ + --accent-hover: #00b8d1; /* slightly darker cyan for hover */ + --accent-dim: rgba(0, 217, 245, 0.15); + + /* Bell / notification */ + --bell: #F1A640; + --bell-color: #F1A640; /* brand amber */ + --activity-color: #F1A640; + --bell-glow: rgba(241, 166, 64, 0.25); + --bell-border: rgba(241, 166, 64, 0.6); + + /* Status */ + --ok: #3fb950; + --warn: #d29922; + --err: #f85149; + + /* Layout */ + --tile-height: 300px; + --tile-min-width: 360px; + --grid-gap: 8px; + --grid-padding: 16px; + --header-height: 44px; + + /* Transitions */ + --t-zoom: 250ms ease-in-out; + --t-fast: 150ms ease; + --t-fade: 200ms ease; + + /* Typography */ + --font-ui: system-ui, -apple-system, 'Segoe UI', sans-serif; + --font-mono: 'SF Mono', 'Fira Code', 'Consolas', 'Menlo', monospace; +} + +/* Box-sizing reset */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Base */ +html, +body { + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font-ui); + font-size: 14px; + overflow: hidden; + margin: 0; + padding: 0; +} + +/* Utility */ +.hidden { + display: none !important; +} + +/* ============================================================ + App layout + ============================================================ */ + +.view { + height: 100vh; + overflow: hidden; +} + +.view--active { + display: flex; + flex-direction: column; +} + +.view.hidden { + display: none; +} + +/* ============================================================ + App header + ============================================================ */ + +.app-header { + height: var(--header-height); + padding: 0 var(--grid-padding); + background: var(--bg-header); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +/* ============================================================ + Session grid + ============================================================ */ + +.session-grid { + flex: 1; + overflow-y: auto; + padding: var(--grid-padding); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(var(--tile-min-width), 1fr)); + gap: var(--grid-gap); + align-content: start; +} + +/* ============================================================ + Session tile + ============================================================ */ + +.session-tile { + height: var(--tile-height); + background: var(--bg-tile); + border: 1px solid var(--border); + border-radius: 4px; + display: flex; + flex-direction: column; + cursor: pointer; + overflow: hidden; + position: relative; + transition: border-color var(--t-fast), box-shadow var(--t-fast); +} + +.session-tile:hover, +.session-tile:focus-visible { + border-color: var(--accent); +} + +/* Bell / notification tile */ +.session-tile--bell { + border-color: var(--bell-border); + box-shadow: 0 0 0 1px var(--bell-border), inset 0 0 12px var(--bell-glow); +} + +/* ============================================================ + Tile header + ============================================================ */ + +.tile-header { + height: 32px; + padding: 0 10px; + background: var(--bg-header); + border-bottom: 1px solid var(--border-subtle); + display: flex; + align-items: center; +} + +.tile-name { + font-size: 13px; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.tile-meta { + font-size: 11px; + color: var(--text-muted); + white-space: nowrap; + margin-left: 8px; +} + +/* Bell dot */ +.tile-bell { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--bell); + animation: bell-pulse 1.4s ease-in-out infinite; + margin-right: 6px; + flex-shrink: 0; +} + +@keyframes bell-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.5; + transform: scale(0.8); + } +} + +/* ============================================================ + Tile body + ============================================================ */ + +.tile-body { + flex: 1; + overflow: hidden; + position: relative; +} + +.tile-pre { + position: absolute; + inset: 0; + padding: 6px 8px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-dim); + white-space: pre; + overflow: hidden; + margin: 0; +} + +/* Fade gradient at bottom of tile body */ +.tile-body::before { + content: ""; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 40px; + background: linear-gradient(to bottom, transparent, var(--bg-tile)); + pointer-events: none; + z-index: 1; +} + +/* ============================================================ + Empty state + ============================================================ */ + +.empty-state { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); +} + +/* ============================================================ + Responsive breakpoints + ============================================================ */ + +@media (max-width:1199px) and (min-width:900px) { + :root { + --tile-min-width:420px; + } +} + +@media (max-width:899px) and (min-width:600px) { + .session-grid { + grid-template-columns:1fr; + padding:8px; + } + .session-tile { + height:200px; + } +} + +@media (max-width:599px) { + .session-grid { + display:flex; + flex-direction:column; + gap:1px; + padding:0; + } + .session-tile { + height:auto; + border-radius:0; + border-left:none; + border-right:none; + } +} + +@media (max-width:599px) and (orientation:landscape) { + .session-grid { + padding:0; + } +} + +/* ============================================================ + Expanded view + ============================================================ */ + +#view-expanded { + position: relative; +} + +.expanded-header { + height: var(--header-height); + padding: 0 12px; + background: var(--bg-header); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; +} + +.back-btn { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-muted); + font-size: 18px; + width: 36px; + height: 36px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.back-btn:hover { + border-color: var(--accent); + color: var(--text); +} + +.expanded-session-name { + flex: 1; + font-size: 14px; + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.palette-trigger { + background: none; + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text-dim); + font-size: 12px; + padding: 4px 10px; + cursor: pointer; +} + +.terminal-container { + flex: 1; + overflow: hidden; + background: #000; + padding: 0 4px; /* keep text off the side edges */ +} + +/* xterm.js injects overflow-y: scroll on .xterm-viewport — override it */ +.xterm .xterm-viewport { + overflow-y: hidden !important; +} + +.reconnect-overlay { + position: absolute; + inset: var(--header-height) 0 0; + background: var(--bg-overlay); + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +/* ============================================================ + Zoom-in-place expand/collapse transition + ============================================================ */ + +.session-tile--expanding { + position: fixed; + z-index: 50; + border-radius: 4px; + transition: + top var(--t-zoom), + left var(--t-zoom), + width var(--t-zoom), + height var(--t-zoom), + border-radius var(--t-zoom); +} + +.session-tile--expanded { + top: 0 !important; + left: 0 !important; + width: 100vw !important; + height: 100vh !important; + border-radius: 0; +} + +.session-grid--dimming .session-tile:not(.session-tile--expanding) { + opacity: 0; + transition: opacity var(--t-fade); +} + +/* ============================================================ + Bell count badge + ============================================================ */ + +.tile-bell-count { + font-size: 10px; + font-weight: 600; + color: var(--bell); + min-width: 16px; + text-align: right; +} + +/* ============================================================ + Connection status indicator states + ============================================================ */ + +.connection-status--ok { + color: var(--ok); +} + +.connection-status--warn { + color: var(--warn); +} + +.connection-status--err { + color: var(--err); +} + +/* ============================================================ + Toast notification + ============================================================ */ + +.toast { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + background: var(--bg-header); + border: 1px solid var(--border); + border-radius: 4px; + padding: 8px 16px; + font-size: 13px; + color: var(--text-muted); + z-index: 100; + pointer-events: none; + animation: toast-in var(--t-fast) ease; +} + +@keyframes toast-in { + from { + opacity: 0; + transform: translateX(-50%) translateY(8px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +/* ============================================================ + Mobile three-tier priority list (bell / active / idle) + ============================================================ */ + +@media (max-width:599px) { + /* Tier 1 — bell: expanded preview (~126px total) */ + .session-tile--tier-bell .tile-body { + height: 90px; + } + .session-tile--tier-bell { + min-height: 126px; + } + + /* Tier 2 — active: single-line preview (~60px total) */ + .session-tile--tier-active .tile-body { + height: 24px; + } + .session-tile--tier-active { + min-height: 60px; + } + .session-tile--tier-active .tile-pre { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + /* Tier 3 — idle: name-only (~44px total) */ + .session-tile--tier-idle .tile-body { + display: none; + } + .session-tile--tier-idle { + min-height: 44px; + } + .session-tile--tier-idle .tile-header { + height: 44px; + } + .session-tile--tier-idle .tile-name { + color: var(--text-dim); + } +} + +/* ============================================================ + Command palette overlay (desktop session switching) + ============================================================ */ + +.command-palette { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 15vh; +} + +.command-palette__backdrop { + position: absolute; + inset: 0; + background: var(--bg-overlay); + backdrop-filter: blur(2px); +} + +.command-palette__dialog { + position: relative; + width: min(440px, 90vw); + background: var(--bg-header); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5); +} + +.command-palette__input { + width: 100%; + padding: 14px 16px; + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + color: var(--text); + font-size: 14px; + font-family: var(--font-ui); + outline: none; +} + +.command-palette__input::placeholder { + color: var(--text-dim); +} + +.command-palette__list { + list-style: none; + max-height: 320px; + overflow-y: auto; +} + +.palette-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + cursor: pointer; + font-size: 13px; + color: var(--text-muted); + transition: background var(--t-fast); +} + +.palette-item:hover, +.palette-item--selected { + background: var(--accent-dim); + color: var(--text); +} + +.palette-item__index { + font-size: 11px; + color: var(--text-dim); + width: 16px; +} + +.palette-item__name { + flex: 1; +} + +.palette-item__bell { + color: var(--bell); + font-size: 11px; +} + +.palette-item__time { + font-size: 11px; + color: var(--text-dim); +} + +/* ─── Mobile Bottom Sheet ─────────────────────────────────────── */ + +.bottom-sheet { + position: fixed; + inset: 0; + z-index: 200; + display: flex; + align-items: flex-end; +} + +.bottom-sheet__backdrop { + position: absolute; + inset: 0; + background: var(--bg-overlay); +} + +.bottom-sheet__panel { + position: relative; + width: 100%; + background: var(--bg-header); + border-top: 1px solid var(--border); + border-radius: 12px 12px 0 0; + max-height: 70vh; + overflow-y: auto; + animation: sheet-up var(--t-zoom) ease; +} + +@keyframes sheet-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.bottom-sheet__handle { + width: 36px; + height: 4px; + background: var(--border); + border-radius: 2px; + margin: 10px auto 6px; +} + +.bottom-sheet__list { + list-style: none; + padding-bottom: 8px; +} + +.sheet-item { + display: flex; + align-items: center; + gap: 10px; + padding: 0 16px; + height: 56px; + cursor: pointer; + font-size: 14px; + color: var(--text); + border-bottom: 1px solid var(--border-subtle); + transition: background var(--t-fast); +} + +.sheet-item:hover { + background: var(--accent-dim); +} + +.sheet-item__name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sheet-item__bell { + color: var(--bell); +} + +.sheet-item__time { + font-size: 12px; + color: var(--text-dim); +} + +/* ─── Floating Session Pill ───────────────────────────────────── */ + +.session-pill { + position: fixed; + bottom: 24px; + right: 16px; + z-index: 50; + background: var(--bg-header); + border: 1px solid var(--border); + border-radius: 20px; + padding: 8px 14px; + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--text-muted); + cursor: pointer; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + opacity: 0.75; +} + +.session-pill:hover { + opacity: 1; + border-color: var(--accent); + color: var(--text); +} + +.session-pill__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 140px; +} + +.session-pill__bell { + color: var(--bell); +} + +/* ─── Reduced Motion ──────────────────────────────────────────── */ + +@media (prefers-reduced-motion: reduce) { + .tile-bell { + animation: none; + } + + .session-tile--expanding { + transition: none; + } + + .session-grid--dimming .session-tile:not(.session-tile--expanding) { + transition: none; + } + + .bottom-sheet__panel { + animation: none; + } + + .toast { + animation: none; + } +} + +/* ============================================================ + App wordmark + ============================================================ */ + +.app-wordmark { + margin: 0; + line-height: 1; + display: flex; + align-items: center; +} +.app-wordmark img { + height: 24px; + width: auto; + display: block; +} diff --git a/frontend/terminal.js b/frontend/terminal.js new file mode 100644 index 0000000..c55168c --- /dev/null +++ b/frontend/terminal.js @@ -0,0 +1,219 @@ +// Phase 2b implementation — terminal.js +// xterm.js Terminal + FitAddon initialization (task-12) + +// ─── Module-level state ─────────────────────────────────────────────────────── +let _term = null; +let _fitAddon = null; +let _ws = null; +let _reconnectTimer = null; +let _currentSession = null; +let _vpHandler = null; + +// ─── Forward declarations ───────────────────────────────────────────────────── + +function connectWebSocket(name) { + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${proto}//${location.host}/terminal/ws`; + const reconnectOverlay = document.getElementById('reconnect-overlay'); + var encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; + + function encodePayload(typeChar, str) { + // Returns Uint8Array: [typeCharCode, ...utf8bytes] + var strBytes = encoder ? encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); })); + var payload = new Uint8Array(1 + strBytes.length); + payload[0] = typeChar; + payload.set(strBytes, 1); + return payload; + } + + // Register terminal event handlers (once — _ws captured by closure reference) + if (_term) { + _term.onData(function(data) { + if (_ws && _ws.readyState === WebSocket.OPEN) { + // ttyd protocol: input is type 0x30 ('0') + UTF-8 keystroke bytes + _ws.send(encodePayload(0x30, data)); + } + }); + _term.onResize(function(size) { + if (_ws && _ws.readyState === WebSocket.OPEN) { + // ttyd protocol: resize is type 0x31 ('1') + UTF-8 JSON + _ws.send(encodePayload(0x31, JSON.stringify({ columns: size.cols, rows: size.rows }))); + } + }); + } + + function connect() { + // 'tty' subprotocol is REQUIRED — without it ttyd never starts the PTY. + // Confirmed via raw Python WebSocket tests: ttyd accepts the TCP upgrade but + // sits completely silent (no child process spawned) when subprotocol is omitted. + _ws = new WebSocket(url, ['tty']); + _ws.binaryType = 'arraybuffer'; + + _ws.addEventListener('open', function() { + if (reconnectOverlay) reconnectOverlay.classList.add('hidden'); + // Step 1: TEXT frame auth handshake — ttyd checks AuthToken before starting PTY + _ws.send(JSON.stringify({ AuthToken: '' })); + // Step 2: BINARY frame with initial terminal dimensions — [0x31] + JSON({columns, rows}) + if (_term) { + _ws.send(encodePayload(0x31, JSON.stringify({ columns: _term.cols, rows: _term.rows }))); + } + // Auto-focus the terminal so user can type immediately without clicking + if (_term) _term.focus(); + }); + + _ws.addEventListener('message', function(e) { + if (!_term) return; + if (e.data instanceof ArrayBuffer) { + var msg = new Uint8Array(e.data); + if (msg.length < 1) return; + var msgType = msg[0]; + var payload = msg.slice(1); + if (msgType === 0x30) { // '0' = terminal output — write to xterm.js + _term.write(payload); + } + // 0x31 ('1') = window title, 0x32 ('2') = preferences — ignore for now + } else if (typeof e.data === 'string') { + _term.write(e.data); // fallback for text frames + } + }); + + _ws.addEventListener('close', function() { + if (!_currentSession) return; // intentional close — don't reconnect + if (reconnectOverlay) reconnectOverlay.classList.remove('hidden'); + _reconnectTimer = setTimeout(connect, 2000); + }); + + _ws.addEventListener('error', function() { + console.warn('tmux-web: WebSocket error on', url); + }); + } + + connect(); +} +function initVisualViewport() { + if (!window.visualViewport) return; + if (_vpHandler) window.visualViewport.removeEventListener('resize', _vpHandler); + + _vpHandler = function() { + if (!_term || !_fitAddon) return; + var container = document.getElementById('terminal-container'); + if (!container) return; + + // Resize container to fill visual viewport above keyboard + var headerHeight = 44; // matches --header-height CSS custom property + var vvh = window.visualViewport.height; + var termHeight = Math.max(100, vvh - headerHeight); + container.style.height = termHeight + 'px'; + + // Refit xterm.js to new container size + try { _fitAddon.fit(); } catch (_) {} + }; + + window.visualViewport.addEventListener('resize', _vpHandler); +} + +// ─── Terminal creation ──────────────────────────────────────────────────────── + +/** + * Create (or recreate) the xterm.js Terminal and FitAddon instances. + * Disposes any existing terminal first. + * Stores the results in module-level _term and _fitAddon. + */ +function createTerminal() { + // Dispose any existing instance + if (_term) { + _term.dispose(); + _term = null; + _fitAddon = null; + } + + const mobile = window.innerWidth < 600; + + _term = new window.Terminal({ + cursorBlink: true, + fontSize: mobile ? 12 : 14, + fontFamily: "'SF Mono', 'Fira Code', Consolas, monospace", + theme: { + background: '#000000', + foreground: '#c9d1d9', + cursor: '#58a6ff', + }, + scrollback: mobile ? 500 : 5000, + allowProposedApi: true, + }); + + _fitAddon = new window.FitAddon.FitAddon(); + _term.loadAddon(_fitAddon); +} + +// ─── Open / close ───────────────────────────────────────────────────────────── + +/** + * Open a terminal session inside #terminal-container. + * @param {string} sessionName + */ +function openTerminal(sessionName) { + _currentSession = sessionName; + + const container = document.getElementById('terminal-container'); + if (!container) { + console.warn('[openTerminal] #terminal-container not found'); + return; + } + + createTerminal(); + + _term.open(container); + + if (_fitAddon) { + // requestAnimationFrame guarantees one full browser layout pass after the flex + // container becomes visible before fit() measures dimensions. + // iOS Safari defers flex layout — calling fit() synchronously here gives 0px width + // → 2-column terminal. The RAF and 500ms fallback fix this race condition. + // Falls back to immediate execution in Node.js test environments where RAF is absent. + var fitAddonRef = _fitAddon; + var raf = (typeof requestAnimationFrame !== 'undefined') ? requestAnimationFrame : function(fn) { fn(); }; + raf(function() { + try { fitAddonRef.fit(); } catch (_) {} + // 500ms fallback for slow mobile layout engines (e.g. first paint on low-end devices) + setTimeout(function() { + try { if (_fitAddon) _fitAddon.fit(); } catch (_) {} + }, 500); + }); + } + + connectWebSocket(sessionName); + initVisualViewport(); /* defined in Task 14 */ +} + +/** + * Close the current terminal session and clean up all resources. + */ +function closeTerminal() { + if (_vpHandler) { + if (window.visualViewport) window.visualViewport.removeEventListener('resize', _vpHandler); + _vpHandler = null; + } + + if (_reconnectTimer) { + clearTimeout(_reconnectTimer); + _reconnectTimer = null; + } + + if (_ws) { + _ws.close(); + _ws = null; + } + + if (_term) { + _term.dispose(); + _term = null; + _fitAddon = null; + } + + _currentSession = null; +} + +// ─── Expose to app.js ───────────────────────────────────────────────────────── +window._openTerminal = openTerminal; +window._closeTerminal = closeTerminal; diff --git a/frontend/tests/test_app.mjs b/frontend/tests/test_app.mjs new file mode 100644 index 0000000..73412d9 --- /dev/null +++ b/frontend/tests/test_app.mjs @@ -0,0 +1,1638 @@ +// Browser global stubs — must be set before importing app.js +globalThis.document = { + getElementById: () => null, + querySelector: () => null, + querySelectorAll: () => [], + createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }), + addEventListener: () => {}, +}; + +// Stubs for functions called by pollSessions (implemented in later tasks) +globalThis.renderGrid = () => {}; +globalThis.handleBellTransitions = () => {}; +// Stubs for functions called by renderGrid (implemented in later tasks) +globalThis.openSession = () => {}; +globalThis.updatePillBell = () => {}; + +globalThis.window = { + addEventListener: () => {}, + location: { href: '' }, +}; + +globalThis.Notification = { + permission: 'default', + requestPermission: async () => 'default', +}; + +// navigator is read-only in Node v24+, use defineProperty +Object.defineProperty(globalThis, 'navigator', { + value: { userAgent: 'test-agent' }, + writable: true, + configurable: true, +}); + +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const require = createRequire(import.meta.url); +const app = require(join(__dirname, '..', 'app.js')); + +test('app.js exports all 7 pure functions', () => { + const expectedFunctions = [ + 'formatTimestamp', + 'sessionPriority', + 'sortByPriority', + 'filterByQuery', + 'detectBellTransitions', + 'generateDeviceId', + 'buildHeartbeatPayload', + ]; + + for (const fn of expectedFunctions) { + assert.ok(fn in app, `app.js should export "${fn}"`); + assert.strictEqual(typeof app[fn], 'function', `"${fn}" should be a function`); + } +}); + +// --- formatTimestamp --- + +test('formatTimestamp returns em-dash for null', () => { + assert.strictEqual(app.formatTimestamp(null), '\u2014'); +}); + +test('formatTimestamp returns seconds ago for timestamp < 60s ago', () => { + const ts = Math.floor(Date.now() / 1000) - 30; + assert.match(app.formatTimestamp(ts), /^\d+s ago$/); +}); + +test('formatTimestamp returns minutes ago for timestamp between 60s and 3600s ago', () => { + const ts = Math.floor(Date.now() / 1000) - 120; + assert.match(app.formatTimestamp(ts), /^\d+m ago$/); +}); + +test('formatTimestamp returns hours ago for timestamp >= 3600s ago', () => { + const ts = Math.floor(Date.now() / 1000) - 7200; + assert.match(app.formatTimestamp(ts), /^\d+h ago$/); +}); + +// --- sessionPriority --- + +test('sessionPriority returns bell when unseen_count > 0 and seen_at is null', () => { + const session = { bell: { unseen_count: 3, seen_at: null, last_fired_at: 100 } }; + assert.strictEqual(app.sessionPriority(session), 'bell'); +}); + +test('sessionPriority returns bell when last_fired_at > seen_at', () => { + const session = { bell: { unseen_count: 1, seen_at: 50, last_fired_at: 100 } }; + assert.strictEqual(app.sessionPriority(session), 'bell'); +}); + +test('sessionPriority returns idle when unseen_count is 0', () => { + const session = { bell: { unseen_count: 0, seen_at: null, last_fired_at: 100 } }; + assert.strictEqual(app.sessionPriority(session), 'idle'); +}); + +test('sessionPriority returns idle when seen_at >= last_fired_at', () => { + const session = { bell: { unseen_count: 1, seen_at: 100, last_fired_at: 50 } }; + assert.strictEqual(app.sessionPriority(session), 'idle'); +}); + +// --- sortByPriority --- + +test('sortByPriority puts bell sessions before idle sessions', () => { + const idleSession = { id: 'idle', bell: { unseen_count: 0, seen_at: null, last_fired_at: 0 } }; + const bellSession = { id: 'bell', bell: { unseen_count: 1, seen_at: null, last_fired_at: 100 } }; + const result = app.sortByPriority([idleSession, bellSession]); + assert.strictEqual(result[0].id, 'bell'); + assert.strictEqual(result[1].id, 'idle'); +}); + +test('sortByPriority does not mutate the input array', () => { + const idleSession = { id: 'idle', bell: { unseen_count: 0, seen_at: null, last_fired_at: 0 } }; + const bellSession = { id: 'bell', bell: { unseen_count: 1, seen_at: null, last_fired_at: 100 } }; + const input = [idleSession, bellSession]; + app.sortByPriority(input); + assert.strictEqual(input[0].id, 'idle'); + assert.strictEqual(input[1].id, 'bell'); +}); + +// --- filterByQuery --- + +test('filterByQuery returns all sessions when query is empty string', () => { + const sessions = [{ name: 'Alpha' }, { name: 'Beta' }]; + assert.deepStrictEqual(app.filterByQuery(sessions, ''), sessions); +}); + +test('filterByQuery returns all sessions when query is null', () => { + const sessions = [{ name: 'Alpha' }, { name: 'Beta' }]; + assert.deepStrictEqual(app.filterByQuery(sessions, null), sessions); +}); + +test('filterByQuery matches case-insensitive substring in session.name', () => { + const sessions = [{ name: 'My Project' }, { name: 'Another Session' }]; + const result = app.filterByQuery(sessions, 'proj'); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'My Project'); +}); + +test('filterByQuery returns empty array when no session name matches', () => { + const sessions = [{ id: 'match-id', name: 'nomatch' }]; + assert.deepStrictEqual(app.filterByQuery(sessions, 'match-id'), []); +}); + +// --- detectBellTransitions --- + +test('detectBellTransitions fires when existing session goes from 0 to positive unseen_count', () => { + const prev = [{ name: 'work', bell: { unseen_count: 0 } }]; + const next = [{ name: 'work', bell: { unseen_count: 1 } }]; + assert.deepStrictEqual(app.detectBellTransitions(prev, next), ['work']); +}); + +test('detectBellTransitions returns empty array when unseen_count does not change', () => { + const prev = [{ name: 'work', bell: { unseen_count: 2 } }]; + const next = [{ name: 'work', bell: { unseen_count: 2 } }]; + assert.deepStrictEqual(app.detectBellTransitions(prev, next), []); +}); + +test('detectBellTransitions fires for new session not in prev with bell > 0', () => { + const prev = []; + const next = [{ name: 'new-session', bell: { unseen_count: 3 } }]; + assert.deepStrictEqual(app.detectBellTransitions(prev, next), ['new-session']); +}); + +test('detectBellTransitions fires when unseen_count increases', () => { + const prev = [{ name: 'task', bell: { unseen_count: 1 } }]; + const next = [{ name: 'task', bell: { unseen_count: 4 } }]; + assert.deepStrictEqual(app.detectBellTransitions(prev, next), ['task']); +}); + +test('detectBellTransitions does not fire when unseen_count decreases', () => { + const prev = [{ name: 'task', bell: { unseen_count: 5 } }]; + const next = [{ name: 'task', bell: { unseen_count: 2 } }]; + assert.deepStrictEqual(app.detectBellTransitions(prev, next), []); +}); + +// --- generateDeviceId --- + +test('generateDeviceId returns a string matching /^d-[a-z0-9]+$/', () => { + const id = app.generateDeviceId(); + assert.match(id, /^d-[a-z0-9]+$/); +}); + +test('generateDeviceId produces unique IDs on successive calls', () => { + const id1 = app.generateDeviceId(); + const id2 = app.generateDeviceId(); + assert.notStrictEqual(id1, id2); +}); + +// --- buildHeartbeatPayload --- + +test('buildHeartbeatPayload returns correct shape with all required fields', () => { + const payload = app.buildHeartbeatPayload('d-abc123', 'session-1', 'split', 1700000000); + assert.strictEqual(payload.device_id, 'd-abc123'); + assert.strictEqual(payload.viewing_session, 'session-1'); + assert.strictEqual(payload.view_mode, 'split'); + assert.strictEqual(payload.last_interaction_at, 1700000000); + assert.ok('label' in payload, 'payload should have label field'); +}); + +test('buildHeartbeatPayload includes null viewing_session when passed null', () => { + const payload = app.buildHeartbeatPayload('d-abc123', null, 'full', 0); + assert.strictEqual(payload.viewing_session, null); +}); + +test('buildHeartbeatPayload label uses navigator.userAgent sliced to 50 chars', () => { + const payload = app.buildHeartbeatPayload('d-abc123', null, 'full', 0); + const expected = globalThis.navigator.userAgent.slice(0, 50); + assert.strictEqual(payload.label, expected); +}); + +// --- setConnectionStatus --- + +test('setConnectionStatus ok sets bullet text and ok CSS class', () => { + const mockEl = { textContent: '', className: '' }; + const orig = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => (id === 'connection-status' ? mockEl : null); + + app.setConnectionStatus('ok'); + + assert.strictEqual(mockEl.textContent, '\u25cf'); + assert.strictEqual(mockEl.className, 'connection-status--ok'); + globalThis.document.getElementById = orig; +}); + +test('setConnectionStatus warn sets slow text and warn CSS class', () => { + const mockEl = { textContent: '', className: '' }; + const orig = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => (id === 'connection-status' ? mockEl : null); + + app.setConnectionStatus('warn'); + + assert.strictEqual(mockEl.textContent, '\u25cc slow'); + assert.strictEqual(mockEl.className, 'connection-status--warn'); + globalThis.document.getElementById = orig; +}); + +test('setConnectionStatus err sets offline text and err CSS class', () => { + const mockEl = { textContent: '', className: '' }; + const orig = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => (id === 'connection-status' ? mockEl : null); + + app.setConnectionStatus('err'); + + assert.strictEqual(mockEl.textContent, '\u2715 offline'); + assert.strictEqual(mockEl.className, 'connection-status--err'); + globalThis.document.getElementById = orig; +}); + +// --- pollSessions --- + +test('pollSessions on success sets ok status', async () => { + const sessions = [{ name: 'test-session' }]; + const mockEl = { textContent: '', className: '' }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => (id === 'connection-status' ? mockEl : null); + globalThis.fetch = async () => ({ ok: true, json: async () => sessions }); + + await app.pollSessions(); + + assert.strictEqual(mockEl.className, 'connection-status--ok'); + globalThis.document.getElementById = origGetById; + globalThis.fetch = undefined; +}); + +test('pollSessions on first failure sets warn status', async () => { + const mockEl = { textContent: '', className: '' }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => (id === 'connection-status' ? mockEl : null); + + // Reset fail count to 0 by succeeding first + globalThis.fetch = async () => ({ ok: true, json: async () => [] }); + await app.pollSessions(); + + // Now fail once + globalThis.fetch = async () => { throw new Error('network error'); }; + await app.pollSessions(); + + assert.strictEqual(mockEl.className, 'connection-status--warn'); + globalThis.document.getElementById = origGetById; + globalThis.fetch = undefined; +}); + +test('pollSessions sets err status after more than 2 failures', async () => { + const mockEl = { textContent: '', className: '' }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => (id === 'connection-status' ? mockEl : null); + + // Reset fail count to 0 by succeeding first + globalThis.fetch = async () => ({ ok: true, json: async () => [] }); + await app.pollSessions(); + + // Fail 3 times (count goes 1 → warn, 2 → warn, 3 → err) + globalThis.fetch = async () => { throw new Error('network error'); }; + await app.pollSessions(); // count = 1 → warn + await app.pollSessions(); // count = 2 → warn + await app.pollSessions(); // count = 3 → err + + assert.strictEqual(mockEl.className, 'connection-status--err'); + globalThis.document.getElementById = origGetById; + globalThis.fetch = undefined; +}); + +// --- startPolling --- + +test('startPolling guards against double-start (only creates one interval)', () => { + const intervals = []; + const origSetInterval = globalThis.setInterval; + globalThis.setInterval = (fn, ms) => { + intervals.push(ms); + return Symbol('timer'); + }; + const origFetch = globalThis.fetch; + globalThis.fetch = async () => ({ ok: true, json: async () => [] }); + + // First call should create the interval; second should be a no-op + app.startPolling(); + app.startPolling(); + + assert.strictEqual(intervals.length, 1, 'startPolling should create exactly one interval'); + assert.strictEqual(intervals[0], 2000, 'interval should be POLL_MS (2000ms)'); + + globalThis.setInterval = origSetInterval; + globalThis.fetch = origFetch; +}); + +// --- escapeHtml --- + +test('escapeHtml replaces & with &', () => { + assert.strictEqual(app.escapeHtml('a&b'), 'a&b'); +}); + +test('escapeHtml replaces < with <', () => { + assert.strictEqual(app.escapeHtml(''), '<tag>'); +}); + +test('escapeHtml replaces > with >', () => { + assert.strictEqual(app.escapeHtml('a>b'), 'a>b'); +}); + +test('escapeHtml returns unchanged string when no special characters', () => { + assert.strictEqual(app.escapeHtml('hello world'), 'hello world'); +}); + +// --- buildTileHTML --- + +test('buildTileHTML returns article element with session-tile class', () => { + const session = { name: 'my-session', snapshot: 'line1\nline2' }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(html.startsWith(' { + const session = { + name: 'bell-session', + bell: { unseen_count: 1, seen_at: null, last_fired_at: 100 }, + snapshot: '', + }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(html.includes('session-tile--bell'), 'should contain --bell modifier class'); +}); + +test('buildTileHTML does not add session-tile--bell class for non-bell sessions', () => { + const session = { + name: 'idle-session', + bell: { unseen_count: 0, seen_at: null, last_fired_at: 0 }, + snapshot: '', + }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(!html.includes('session-tile--bell'), 'should not contain --bell class'); +}); + +test('buildTileHTML sets data-session attribute with escaped session name', () => { + const session = { name: 'my', snapshot: '' }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(html.includes('data-session="my<session>"'), 'data-session should be escaped'); +}); + +test('buildTileHTML shows 9+ when unseen_count exceeds 9', () => { + const session = { + name: 's', + bell: { unseen_count: 10, seen_at: null, last_fired_at: 100 }, + snapshot: '', + }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(html.includes('9+'), 'should show 9+ for count > 9'); +}); + +test('buildTileHTML shows exact count when unseen_count is <= 9', () => { + const session = { + name: 's', + bell: { unseen_count: 5, seen_at: null, last_fired_at: 100 }, + snapshot: '', + }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(html.includes('>5<'), 'should show exact count 5'); +}); + +test('buildTileHTML includes only last 20 lines of snapshot', () => { + const lines = []; + for (let i = 0; i < 25; i++) lines.push(`UNIQUE_LINE_${i}_MARKER`); + const session = { name: 's', snapshot: lines.join('\n') }; + const html = app.buildTileHTML(session, 0, false); + assert.ok(html.includes('UNIQUE_LINE_24_MARKER'), 'last line should be present'); + assert.ok(!html.includes('UNIQUE_LINE_0_MARKER'), 'first line should be excluded (>20 lines)'); +}); + +test('buildTileHTML adds tier class on mobile', () => { + const session = { name: 's', snapshot: '' }; + const html = app.buildTileHTML(session, 0, true); + assert.ok(html.includes('session-tile--tier-'), 'should contain tier class on mobile'); +}); + +// --- renderGrid --- + +test('renderGrid clears grid and shows empty-state when sessions array is empty', () => { + const mockGrid = { innerHTML: 'existing-content' }; + const removedClasses = []; + const mockEmpty = { style: {}, classList: { add: () => {}, remove: (c) => removedClasses.push(c) } }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'session-grid') return mockGrid; + if (id === 'empty-state') return mockEmpty; + return null; + }; + + app.renderGrid([]); + + assert.strictEqual(mockGrid.innerHTML, '', 'grid innerHTML should be cleared'); + assert.ok(removedClasses.includes('hidden'), 'empty-state should have hidden class removed'); + globalThis.document.getElementById = origGetById; +}); + +test('renderGrid hides empty-state and populates grid when sessions exist', () => { + const mockGrid = { innerHTML: '' }; + const addedClasses = []; + const mockEmpty = { style: {}, classList: { add: (c) => addedClasses.push(c), remove: () => {} } }; + const origGetById = globalThis.document.getElementById; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.getElementById = (id) => { + if (id === 'session-grid') return mockGrid; + if (id === 'empty-state') return mockEmpty; + return null; + }; + globalThis.document.querySelectorAll = () => []; + + const sessions = [{ name: 'my-session', snapshot: 'hello' }]; + app.renderGrid(sessions); + + assert.ok(addedClasses.includes('hidden'), 'empty-state should have hidden class added'); + assert.ok(mockGrid.innerHTML.includes('session-tile'), 'grid should contain session tiles'); + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; +}); + +// --- requestNotificationPermission --- + +test('requestNotificationPermission is exported', () => { + assert.strictEqual(typeof app.requestNotificationPermission, 'function'); +}); + +test('requestNotificationPermission does nothing when Notification is not defined', () => { + const origNotification = globalThis.Notification; + delete globalThis.Notification; + assert.doesNotThrow(() => app.requestNotificationPermission()); + globalThis.Notification = origNotification; +}); + +test('requestNotificationPermission calls Notification.requestPermission when permission is default', async () => { + let called = false; + const origNotification = globalThis.Notification; + globalThis.Notification = { + permission: 'default', + requestPermission: async () => { called = true; return 'granted'; }, + }; + app.requestNotificationPermission(); + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.ok(called, 'requestPermission should be called for default permission'); + globalThis.Notification = origNotification; +}); + +test('requestNotificationPermission does not call requestPermission when permission is granted', () => { + let called = false; + const origNotification = globalThis.Notification; + globalThis.Notification = { + permission: 'granted', + requestPermission: async () => { called = true; return 'granted'; }, + }; + app.requestNotificationPermission(); + assert.ok(!called, 'requestPermission should NOT be called when already granted'); + globalThis.Notification = origNotification; +}); + +// --- handleBellTransitions --- + +test('handleBellTransitions is exported', () => { + assert.strictEqual(typeof app.handleBellTransitions, 'function'); +}); + +test('handleBellTransitions creates Notification when permission granted and document hidden', () => { + const created = []; + const origNotification = globalThis.Notification; + function MockNotification(title, opts) { created.push({ title, opts }); } + MockNotification.permission = 'granted'; + MockNotification.requestPermission = async () => 'granted'; + globalThis.Notification = MockNotification; + + const origDocument = globalThis.document; + globalThis.document = { ...origDocument, hidden: true }; + + app.requestNotificationPermission(); // sets _notificationPermission = 'granted' + const prev = [{ name: 'work', bell: { unseen_count: 0 } }]; + const next = [{ name: 'work', bell: { unseen_count: 1 } }]; + app.handleBellTransitions(prev, next); + + assert.strictEqual(created.length, 1, 'should create exactly one notification'); + assert.strictEqual(created[0].title, 'Activity in: work'); + assert.strictEqual(created[0].opts.body, 'tmux session needs attention'); + assert.strictEqual(created[0].opts.tag, 'tmux-bell-work'); + + globalThis.Notification = origNotification; + globalThis.document = origDocument; +}); + +test('handleBellTransitions does not create Notification when document is visible', () => { + const created = []; + const origNotification = globalThis.Notification; + function MockNotification(title, opts) { created.push({ title, opts }); } + MockNotification.permission = 'granted'; + MockNotification.requestPermission = async () => 'granted'; + globalThis.Notification = MockNotification; + + const origDocument = globalThis.document; + globalThis.document = { ...origDocument, hidden: false }; + + app.requestNotificationPermission(); // sets _notificationPermission = 'granted' + const prev = [{ name: 'work', bell: { unseen_count: 0 } }]; + const next = [{ name: 'work', bell: { unseen_count: 1 } }]; + app.handleBellTransitions(prev, next); + + assert.strictEqual(created.length, 0, 'should not create notification when tab is visible'); + + globalThis.Notification = origNotification; + globalThis.document = origDocument; +}); + +test('handleBellTransitions does not create Notification when permission is not granted', () => { + const created = []; + const origNotification = globalThis.Notification; + function MockNotification(title, opts) { created.push({ title, opts }); } + MockNotification.permission = 'denied'; + MockNotification.requestPermission = async () => 'denied'; + globalThis.Notification = MockNotification; + + const origDocument = globalThis.document; + globalThis.document = { ...origDocument, hidden: true }; + + app.requestNotificationPermission(); // sets _notificationPermission = 'denied' + const prev = [{ name: 'work', bell: { unseen_count: 0 } }]; + const next = [{ name: 'work', bell: { unseen_count: 1 } }]; + app.handleBellTransitions(prev, next); + + assert.strictEqual(created.length, 0, 'should not create notification when permission is denied'); + + globalThis.Notification = origNotification; + globalThis.document = origDocument; +}); + +test('handleBellTransitions notification tag deduplicates per session name', () => { + const created = []; + const origNotification = globalThis.Notification; + function MockNotification(title, opts) { created.push({ title, opts }); } + MockNotification.permission = 'granted'; + MockNotification.requestPermission = async () => 'granted'; + globalThis.Notification = MockNotification; + + const origDocument = globalThis.document; + globalThis.document = { ...origDocument, hidden: true }; + + app.requestNotificationPermission(); + const prev = [{ name: 'my-session', bell: { unseen_count: 1 } }]; + const next = [{ name: 'my-session', bell: { unseen_count: 3 } }]; + app.handleBellTransitions(prev, next); + + assert.strictEqual(created.length, 1); + assert.strictEqual(created[0].opts.tag, 'tmux-bell-my-session', 'tag should use session name for deduplication'); + + globalThis.Notification = origNotification; + globalThis.document = origDocument; +}); + +// --- startHeartbeat --- + +test('startHeartbeat is exported', () => { + assert.strictEqual(typeof app.startHeartbeat, 'function'); +}); + +test('startHeartbeat guards against double-start (only creates one interval)', () => { + const intervals = []; + const origSetInterval = globalThis.setInterval; + globalThis.setInterval = (fn, ms) => { + intervals.push(ms); + return Symbol('heartbeat-timer'); + }; + const origFetch = globalThis.fetch; + globalThis.fetch = async () => ({ ok: true, json: async () => ({}) }); + + app._resetHeartbeatTimer(); // ensure clean state regardless of test order + app.startHeartbeat(); + app.startHeartbeat(); // second call should be a no-op + + assert.strictEqual(intervals.length, 1, 'startHeartbeat should create exactly one interval'); + assert.strictEqual(intervals[0], 5000, 'interval should be HEARTBEAT_MS (5000ms)'); + + globalThis.setInterval = origSetInterval; + globalThis.fetch = origFetch; + // _heartbeatTimer is now set; next test using startHeartbeat must call _resetHeartbeatTimer() +}); + +test('startHeartbeat calls sendHeartbeat immediately (calls fetch for heartbeat)', async () => { + const calls = []; + const origSetInterval = globalThis.setInterval; + globalThis.setInterval = () => Symbol('timer'); + const origFetch = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, json: async () => ({}) }; + }; + + app._resetHeartbeatTimer(); // clear state left by double-start test + app.startHeartbeat(); + // sendHeartbeat() is async but called without await inside startHeartbeat; + // yield to the microtask queue so the fetch promise resolves + await new Promise((r) => setTimeout(r, 0)); + + assert.strictEqual(calls.length, 1, 'startHeartbeat should call sendHeartbeat immediately exactly once'); + assert.ok( + calls.some((c) => c.url === '/api/heartbeat'), + 'should POST to /api/heartbeat immediately on start', + ); + + app._resetHeartbeatTimer(); // clean up so later tests are not affected + globalThis.setInterval = origSetInterval; + globalThis.fetch = origFetch; +}); + +// --- sendHeartbeat --- + +test('sendHeartbeat is exported', () => { + assert.strictEqual(typeof app.sendHeartbeat, 'function'); +}); + +test('sendHeartbeat POSTs to /api/heartbeat with heartbeat payload fields', async () => { + const calls = []; + const origFetch = globalThis.fetch; + globalThis.fetch = async (url, opts) => { + calls.push({ url, opts }); + return { ok: true, json: async () => ({}) }; + }; + + await app.sendHeartbeat(); + + assert.strictEqual(calls.length, 1, 'should call fetch once'); + assert.strictEqual(calls[0].url, '/api/heartbeat'); + assert.strictEqual(calls[0].opts.method, 'POST'); + assert.strictEqual(calls[0].opts.headers['Content-Type'], 'application/json'); + + // Parse body — device_id and last_interaction_at may be absent in test env + // (module state not initialized via DOMContentLoaded), but view_mode, label, + // and viewing_session are always present and serializable. + const body = JSON.parse(calls[0].opts.body); + assert.ok('label' in body, 'payload should have label'); + assert.ok('view_mode' in body, 'payload should have view_mode'); + assert.ok('viewing_session' in body, 'payload should have viewing_session'); + + globalThis.fetch = origFetch; +}); + +test('sendHeartbeat catches errors with console.warn (does not throw)', async () => { + const warnings = []; + const origWarn = console.warn; + console.warn = (...args) => warnings.push(args); + const origFetch = globalThis.fetch; + globalThis.fetch = async () => { throw new Error('network failure'); }; + + // Should not throw + await assert.doesNotReject(() => app.sendHeartbeat()); + + assert.strictEqual(warnings.length, 1, 'console.warn should be called on error'); + + console.warn = origWarn; + globalThis.fetch = origFetch; +}); + +// --- showToast --- + +test('showToast is exported', () => { + assert.strictEqual(typeof app.showToast, 'function'); +}); + +test('showToast sets toast textContent and removes hidden class', () => { + const removedClasses = []; + const mockToast = { + textContent: '', + classList: { + remove: (cls) => removedClasses.push(cls), + add: () => {}, + }, + }; + const orig = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => (id === 'toast' ? mockToast : null); + + app.showToast('something happened'); + + assert.strictEqual(mockToast.textContent, 'something happened'); + assert.ok(removedClasses.includes('hidden'), 'should remove hidden class'); + globalThis.document.getElementById = orig; +}); + +test('showToast schedules hidden class restore after 3000ms', () => { + let capturedMs; + const addedClasses = []; + const mockToast = { + textContent: '', + classList: { + remove: () => {}, + add: (cls) => addedClasses.push(cls), + }, + }; + const origGetById = globalThis.document.getElementById; + const origSetTimeout = globalThis.setTimeout; + globalThis.document.getElementById = (id) => (id === 'toast' ? mockToast : null); + globalThis.setTimeout = (fn, ms) => { capturedMs = ms; fn(); }; // invoke immediately + + app.showToast('hello'); + + assert.strictEqual(capturedMs, 3000, 'setTimeout delay should be 3000ms'); + assert.ok(addedClasses.includes('hidden'), 'should add hidden class after timeout'); + globalThis.document.getElementById = origGetById; + globalThis.setTimeout = origSetTimeout; +}); + +// --- updatePillBell --- + +test('updatePillBell is exported', () => { + assert.strictEqual(typeof app.updatePillBell, 'function'); +}); + +test('updatePillBell shows pill bell when another session has unseen bell', async () => { + const pillRemovedClasses = []; + const mockPillBell = { classList: { add: () => {}, remove: (c) => pillRemovedClasses.push(c) } }; + const origGetById = globalThis.document.getElementById; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.getElementById = (id) => { + if (id === 'session-pill-bell') return mockPillBell; + if (id === 'session-grid') return { innerHTML: '' }; + if (id === 'empty-state') return { style: {}, classList: { add: () => {}, remove: () => {} } }; + return null; + }; + globalThis.document.querySelectorAll = () => []; + + // populate _currentSessions + const sessions = [ + { name: 'alpha', bell: { unseen_count: 0 } }, + { name: 'beta', bell: { unseen_count: 3, seen_at: null, last_fired_at: 100 } }, + ]; + globalThis.fetch = async () => ({ ok: true, json: async () => sessions }); + await app.pollSessions(); + + // set _viewingSession to 'alpha' so 'beta' is "other" + app._setViewingSession('alpha'); + + app.updatePillBell(); + + assert.ok(pillRemovedClasses.includes('hidden'), 'pill bell should have hidden class removed'); + + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; + globalThis.fetch = undefined; +}); + +test('updatePillBell hides pill bell when no other session has unseen bells', async () => { + const pillAddedClasses = []; + const mockPillBell = { classList: { add: (c) => pillAddedClasses.push(c), remove: () => {} } }; + const origGetById = globalThis.document.getElementById; + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.getElementById = (id) => { + if (id === 'session-pill-bell') return mockPillBell; + if (id === 'session-grid') return { innerHTML: '' }; + if (id === 'empty-state') return { style: {}, classList: { add: () => {}, remove: () => {} } }; + return null; + }; + globalThis.document.querySelectorAll = () => []; + + const sessions = [ + { name: 'alpha', bell: { unseen_count: 0 } }, + { name: 'beta', bell: { unseen_count: 0 } }, + ]; + globalThis.fetch = async () => ({ ok: true, json: async () => sessions }); + await app.pollSessions(); + + app._setViewingSession('alpha'); + + app.updatePillBell(); + + assert.ok(pillAddedClasses.includes('hidden'), 'pill bell should have hidden class added'); + + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; + globalThis.fetch = undefined; +}); + +// --- openSession --- + +test('openSession is exported', () => { + assert.strictEqual(typeof app.openSession, 'function'); +}); + +test('openSession returns a Promise', () => { + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.querySelector = () => null; + globalThis.setTimeout = () => {}; + globalThis.window._openTerminal = () => {}; + + const result = app.openSession('test-session', { skipConnect: true }); + + assert.ok(result instanceof Promise, 'openSession should return a Promise'); + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +}); + +test('openSession with skipConnect calls window._openTerminal inside setTimeout callback', async () => { + let openTerminalCalledWith = null; + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.querySelector = () => null; + // Use a synchronous mock so setTimeout callbacks run immediately — _openTerminal is now called inside setTimeout + globalThis.setTimeout = (fn) => { fn(); }; + globalThis.window._openTerminal = (name) => { openTerminalCalledWith = name; }; + + await app.openSession('my-session', { skipConnect: true }); + + assert.strictEqual(openTerminalCalledWith, 'my-session', '_openTerminal should be called with session name'); + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +}); + +test('openSession without skipConnect POSTs to /api/sessions/{name}/connect', async () => { + const fetchCalls = []; + const origFetch = globalThis.fetch; + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; + globalThis.document.getElementById = () => ({ textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }); + globalThis.document.querySelector = () => null; + globalThis.setTimeout = () => {}; + globalThis.window._openTerminal = () => {}; + + await app.openSession('work', {}); + + const connectCall = fetchCalls.find((c) => c.url === '/api/sessions/work/connect'); + assert.ok(connectCall, 'should POST to /api/sessions/work/connect'); + assert.strictEqual(connectCall.opts.method, 'POST'); + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +}); + +test('openSession shows toast and calls closeSession on connect failure', async () => { + let closeTerminalCalled = false; + const mockToast = { textContent: '', classList: { remove: () => {}, add: () => {} } }; + const origFetch = globalThis.fetch; + const origGetById = globalThis.document.getElementById; + const origQS = globalThis.document.querySelector; + const origSetTimeout = globalThis.setTimeout; + globalThis.fetch = async (url) => { + if (url.includes('/connect')) throw new Error('Connection failed'); + return { ok: true }; + }; + globalThis.document.getElementById = (id) => { + if (id === 'toast') return mockToast; + return { textContent: '', style: {}, classList: { remove: () => {}, add: () => {} } }; + }; + globalThis.document.querySelector = () => null; + globalThis.setTimeout = () => {}; + globalThis.window._openTerminal = () => {}; + globalThis.window._closeTerminal = () => { closeTerminalCalled = true; }; + + await app.openSession('failing-session', {}); + + assert.ok(mockToast.textContent.length > 0, 'toast should show an error message'); + assert.ok(closeTerminalCalled, 'closeSession should be called (_closeTerminal invoked)'); + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; +}); + +// --- closeSession --- + +test('closeSession is exported', () => { + assert.strictEqual(typeof app.closeSession, 'function'); +}); + +test('closeSession returns a Promise', async () => { + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }); + globalThis.window._closeTerminal = () => {}; + globalThis.fetch = async () => ({ ok: true }); + + const result = app.closeSession(); + assert.ok(result instanceof Promise, 'closeSession should return a Promise'); + await result; + + globalThis.document.getElementById = origGetById; + globalThis.fetch = undefined; +}); + +test('closeSession calls window._closeTerminal', async () => { + let closeTerminalCalled = false; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }); + globalThis.window._closeTerminal = () => { closeTerminalCalled = true; }; + globalThis.fetch = async () => ({ ok: true }); + + await app.closeSession(); + + assert.ok(closeTerminalCalled, 'window._closeTerminal should be called'); + globalThis.document.getElementById = origGetById; + globalThis.fetch = undefined; +}); + +test('closeSession fires DELETE /api/sessions/current', async () => { + const fetchCalls = []; + const origFetch = globalThis.fetch; + const origGetById = globalThis.document.getElementById; + globalThis.fetch = async (url, opts) => { fetchCalls.push({ url, opts }); return { ok: true }; }; + globalThis.document.getElementById = () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }); + globalThis.window._closeTerminal = () => {}; + + await app.closeSession(); + // yield microtask queue for fire-and-forget DELETE + await new Promise((r) => setTimeout(r, 0)); + + const deleteCall = fetchCalls.find((c) => c.url === '/api/sessions/current' && c.opts && c.opts.method === 'DELETE'); + assert.ok(deleteCall, 'should fire DELETE /api/sessions/current'); + globalThis.fetch = origFetch; + globalThis.document.getElementById = origGetById; +}); + +// ─── Command Palette ───────────────────────────────────────────────────────── + +test('renderPaletteList is exported', () => { + assert.strictEqual(typeof app.renderPaletteList, 'function'); +}); + +test('highlightPaletteItem is exported', () => { + assert.strictEqual(typeof app.highlightPaletteItem, 'function'); +}); + +test('openPalette is exported', () => { + assert.strictEqual(typeof app.openPalette, 'function'); +}); + +test('closePalette is exported', () => { + assert.strictEqual(typeof app.closePalette, 'function'); +}); + +test('onPaletteInput is exported', () => { + assert.strictEqual(typeof app.onPaletteInput, 'function'); +}); + +test('handlePaletteKeydown is exported', () => { + assert.strictEqual(typeof app.handlePaletteKeydown, 'function'); +}); + +test('handleGlobalKeydown is exported', () => { + assert.strictEqual(typeof app.handleGlobalKeydown, 'function'); +}); + +test('bindStaticEventListeners is exported', () => { + assert.strictEqual(typeof app.bindStaticEventListeners, 'function'); +}); + +test('renderPaletteList renders sessions as li elements in #palette-list', () => { + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'palette-list') return mockList; + return null; + }; + + const sessions = [ + { name: 'session-a', last_activity_at: null }, + { name: 'session-b', last_activity_at: null }, + ]; + app._setPaletteFilteredSessions(sessions); + app.renderPaletteList(); + + assert.ok(mockList.innerHTML.includes('session-a'), 'should render session-a'); + assert.ok(mockList.innerHTML.includes('session-b'), 'should render session-b'); + assert.ok(mockList.innerHTML.includes(' { + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'palette-list') return mockList; + return null; + }; + + const sessions = Array.from({ length: 12 }, (_, i) => ({ + name: `session-${i}`, + last_activity_at: null, + })); + app._setPaletteFilteredSessions(sessions); + app.renderPaletteList(); + + const matches = mockList.innerHTML.match(/
  • { + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'palette-list') return mockList; + return null; + }; + + const sessions = [ + { name: 'bell-session', last_activity_at: null, bell: { unseen_count: 2, seen_at: null, last_fired_at: 100 } }, + { name: 'idle-session', last_activity_at: null, bell: { unseen_count: 0 } }, + ]; + app._setPaletteFilteredSessions(sessions); + app.renderPaletteList(); + + assert.ok(mockList.innerHTML.includes('🔔'), 'should show bell emoji for bell-priority session'); + globalThis.document.getElementById = origGetById; +}); + +test('highlightPaletteItem adds palette-item--selected to the selected item and removes from others', () => { + const items = [ + { classList: { add: () => {}, remove: () => {} }, _added: [], _removed: [] }, + { classList: { add: () => {}, remove: () => {} }, _added: [], _removed: [] }, + { classList: { add: () => {}, remove: () => {} }, _added: [], _removed: [] }, + ]; + items.forEach((item) => { + item.classList.add = (cls) => item._added.push(cls); + item.classList.remove = (cls) => item._removed.push(cls); + }); + + const mockList = { querySelectorAll: () => items }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'palette-list') return mockList; + return null; + }; + + app.highlightPaletteItem(1); + + assert.ok(items[0]._removed.includes('palette-item--selected'), 'item 0 should have class removed'); + assert.ok(items[1]._added.includes('palette-item--selected'), 'item 1 should have class added'); + assert.ok(items[2]._removed.includes('palette-item--selected'), 'item 2 should have class removed'); + globalThis.document.getElementById = origGetById; +}); + +test('openPalette shows #command-palette and sets _paletteOpen to true', () => { + const removedClasses = []; + const mockPalette = { style: {}, classList: { add: () => {}, remove: (c) => removedClasses.push(c) } }; + const mockInput = { value: '', focus: () => {}, addEventListener: () => {}, removeEventListener: () => {} }; + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + if (id === 'palette-list') return mockList; + return null; + }; + + app.openPalette(); + + assert.ok(removedClasses.includes('hidden'), '#command-palette should have hidden class removed'); + assert.ok(app._isPaletteOpen(), '_paletteOpen should be true after openPalette'); + globalThis.document.getElementById = origGetById; +}); + +test('openPalette copies _currentSessions to _paletteFilteredSessions', async () => { + const sessions = [{ name: 'alpha' }, { name: 'beta' }]; + globalThis.fetch = async () => ({ ok: true, json: async () => sessions }); + await app.pollSessions(); + globalThis.fetch = undefined; + + const mockPalette = { style: {}, classList: { add: () => {}, remove: () => {} } }; + const mockInput = { value: '', focus: () => {}, addEventListener: () => {}, removeEventListener: () => {} }; + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + if (id === 'palette-list') return mockList; + return null; + }; + // also need querySelectorAll + const origQSA = globalThis.document.querySelectorAll; + globalThis.document.querySelectorAll = () => []; + + app.openPalette(); + + const filtered = app._getPaletteFilteredSessions(); + assert.strictEqual(filtered.length, 2, '_paletteFilteredSessions should have both sessions'); + assert.strictEqual(filtered[0].name, 'alpha'); + assert.strictEqual(filtered[1].name, 'beta'); + globalThis.document.getElementById = origGetById; + globalThis.document.querySelectorAll = origQSA; +}); + +test('openPalette resets _paletteSelectedIndex to 0', () => { + const mockPalette = { style: {}, classList: { add: () => {}, remove: () => {} } }; + const mockInput = { value: '', focus: () => {}, addEventListener: () => {}, removeEventListener: () => {} }; + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + if (id === 'palette-list') return mockList; + return null; + }; + + // Artificially set index > 0 + app._setPaletteFilteredSessions([{ name: 'a' }, { name: 'b' }, { name: 'c' }]); + app.openPalette(); + + assert.strictEqual(app._getPaletteSelectedIndex(), 0, '_paletteSelectedIndex should be reset to 0'); + globalThis.document.getElementById = origGetById; +}); + +test('closePalette hides #command-palette and sets _paletteOpen to false', () => { + const addedClasses = []; + const mockPalette = { style: {}, classList: { add: (c) => addedClasses.push(c), remove: () => {} } }; + const mockInput = { value: '', focus: () => {}, addEventListener: () => {}, removeEventListener: () => {} }; + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + if (id === 'palette-list') return mockList; + return null; + }; + + // First open it + app.openPalette(); // ensures _paletteOpen = true + app.closePalette(); + + assert.ok(addedClasses.includes('hidden'), '#command-palette should have hidden class added'); + assert.ok(!app._isPaletteOpen(), '_paletteOpen should be false after closePalette'); + globalThis.document.getElementById = origGetById; +}); + +test('openPalette removes previous input listener before adding new one on re-entry', () => { + const mockPalette = { style: {}, classList: { add: () => {}, remove: () => {} } }; + const removeEventListenerCalls = []; + const addEventListenerCalls = []; + const mockInput = { + value: '', + focus: () => {}, + addEventListener: (ev, fn) => { addEventListenerCalls.push({ ev, fn }); }, + removeEventListener: (ev, fn) => { removeEventListenerCalls.push({ ev, fn }); }, + }; + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + if (id === 'palette-list') return mockList; + return null; + }; + + app.openPalette(); // first open — adds listener + const firstListener = addEventListenerCalls[0]?.fn; + + app.openPalette(); // second open — should remove old listener before adding new one + + assert.ok( + removeEventListenerCalls.some(c => c.fn === firstListener), + 'old input listener should be removed before re-attaching', + ); + globalThis.document.getElementById = origGetById; +}); + +test('onPaletteInput filters sessions and resets index', () => { + const sessions = [ + { name: 'project-alpha', last_activity_at: null }, + { name: 'work-beta', last_activity_at: null }, + ]; + // onPaletteInput reads from _currentSessions, so set that up + app._setCurrentSessions(sessions); + app._setPaletteFilteredSessions(sessions); + + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'palette-list') return mockList; + return null; + }; + + // Simulate input event with query 'alpha' + app.onPaletteInput({ target: { value: 'alpha' } }); + + const filtered = app._getPaletteFilteredSessions(); + assert.strictEqual(filtered.length, 1, 'should filter to only matching sessions'); + assert.strictEqual(filtered[0].name, 'project-alpha'); + assert.strictEqual(app._getPaletteSelectedIndex(), 0, 'index should be reset to 0'); + globalThis.document.getElementById = origGetById; +}); + +test('handlePaletteKeydown ArrowDown moves selection forward', () => { + const items = [ + { classList: { add: () => {}, remove: () => {} } }, + { classList: { add: () => {}, remove: () => {} } }, + { classList: { add: () => {}, remove: () => {} } }, + ]; + const mockList = { querySelectorAll: () => items }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'palette-list') return mockList; + return null; + }; + + app._setPaletteFilteredSessions([{ name: 'a' }, { name: 'b' }, { name: 'c' }]); + app._setPaletteSelectedIndex(0); + + app.handlePaletteKeydown({ key: 'ArrowDown', preventDefault: () => {} }); + + assert.strictEqual(app._getPaletteSelectedIndex(), 1, 'ArrowDown should move index from 0 to 1'); + globalThis.document.getElementById = origGetById; +}); + +test('handlePaletteKeydown ArrowUp moves selection backward', () => { + const items = [ + { classList: { add: () => {}, remove: () => {} } }, + { classList: { add: () => {}, remove: () => {} } }, + ]; + const mockList = { querySelectorAll: () => items }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'palette-list') return mockList; + return null; + }; + + app._setPaletteFilteredSessions([{ name: 'a' }, { name: 'b' }]); + app._setPaletteSelectedIndex(1); + + app.handlePaletteKeydown({ key: 'ArrowUp', preventDefault: () => {} }); + + assert.strictEqual(app._getPaletteSelectedIndex(), 0, 'ArrowUp should move index from 1 to 0'); + globalThis.document.getElementById = origGetById; +}); + +test('handlePaletteKeydown Escape closes palette', () => { + const mockPalette = { style: {}, classList: { add: () => {}, remove: () => {} } }; + const mockInput = { removeEventListener: () => {} }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + return null; + }; + + // Set up palette as open + app._setPaletteOpen(true); + app.handlePaletteKeydown({ key: 'Escape', preventDefault: () => {} }); + + assert.ok(!app._isPaletteOpen(), 'Escape should close the palette'); + globalThis.document.getElementById = origGetById; +}); + +test('handlePaletteKeydown G closes palette and calls closeSession', async () => { + let closeTerminalCalled = false; + const mockPalette = { style: {}, classList: { add: () => {}, remove: () => {} } }; + const mockInput = { removeEventListener: () => {} }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + const cl = { add: () => {}, remove: () => {} }; + if (id === 'view-expanded') return { style: {}, classList: cl }; + if (id === 'view-overview') return { style: {}, classList: cl }; + if (id === 'session-pill') return { style: {}, classList: cl }; + return null; + }; + globalThis.window._closeTerminal = () => { closeTerminalCalled = true; }; + globalThis.fetch = async () => ({ ok: true }); + + app._setPaletteOpen(true); + app.handlePaletteKeydown({ key: 'g', preventDefault: () => {} }); + + // Yield for fire-and-forget DELETE + await new Promise((r) => setTimeout(r, 0)); + + assert.ok(!app._isPaletteOpen(), 'G should close the palette'); + assert.ok(closeTerminalCalled, 'G should call closeSession (_closeTerminal invoked)'); + globalThis.document.getElementById = origGetById; + globalThis.fetch = undefined; +}); + +test('handlePaletteKeydown Enter opens the selected session', async () => { + let openTerminalCalledWith = null; + const mockPalette = { style: {}, classList: { add: () => {}, remove: () => {} } }; + const mockInput = { removeEventListener: () => {} }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + if (id === 'expanded-session-name') return { textContent: '' }; + return { style: {}, textContent: '', classList: { add: () => {}, remove: () => {} } }; + }; + const origQS = globalThis.document.querySelector; + globalThis.document.querySelector = () => null; + const origSetTimeout = globalThis.setTimeout; + // _openTerminal is called inside setTimeout callback — execute synchronously + globalThis.setTimeout = (fn) => { fn(); }; + globalThis.window._openTerminal = (name) => { openTerminalCalledWith = name; }; + globalThis.fetch = async () => ({ ok: true }); + + app._setPaletteFilteredSessions([{ name: 'target-session' }]); + app._setPaletteSelectedIndex(0); + app._setPaletteOpen(true); + + await app.handlePaletteKeydown({ key: 'Enter', preventDefault: () => {} }); + + assert.strictEqual(openTerminalCalledWith, 'target-session', 'Enter should open the selected session'); + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; + globalThis.fetch = undefined; +}); + +test('handlePaletteKeydown number key 1 jumps to first session', async () => { + let openTerminalCalledWith = null; + const mockPalette = { style: {}, classList: { add: () => {}, remove: () => {} } }; + const mockInput = { removeEventListener: () => {} }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + if (id === 'expanded-session-name') return { textContent: '' }; + return { style: {}, textContent: '', classList: { add: () => {}, remove: () => {} } }; + }; + const origQS = globalThis.document.querySelector; + globalThis.document.querySelector = () => null; + const origSetTimeout = globalThis.setTimeout; + // _openTerminal is called inside setTimeout callback — execute synchronously + globalThis.setTimeout = (fn) => { fn(); }; + globalThis.window._openTerminal = (name) => { openTerminalCalledWith = name; }; + globalThis.fetch = async () => ({ ok: true }); + + app._setPaletteFilteredSessions([{ name: 'first-session' }, { name: 'second-session' }]); + app._setPaletteOpen(true); + + await app.handlePaletteKeydown({ key: '1', preventDefault: () => {} }); + + assert.strictEqual(openTerminalCalledWith, 'first-session', 'key 1 should open first session'); + globalThis.document.getElementById = origGetById; + globalThis.document.querySelector = origQS; + globalThis.setTimeout = origSetTimeout; + globalThis.fetch = undefined; +}); + +test('handleGlobalKeydown delegates to handlePaletteKeydown when palette is open', () => { + const events = []; + const origHandlePaletteKeydown = app.handlePaletteKeydown; + // We'll verify by side-effect: Escape should close palette + const mockPalette = { style: { display: '' } }; + const mockInput = { removeEventListener: () => {} }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + return null; + }; + + app._setPaletteOpen(true); + app.handleGlobalKeydown({ key: 'Escape', preventDefault: () => {} }); + + assert.ok(!app._isPaletteOpen(), 'handleGlobalKeydown should delegate Escape to handlePaletteKeydown when palette open'); + globalThis.document.getElementById = origGetById; +}); + +test('handleGlobalKeydown opens palette on backtick in fullscreen with palette closed', () => { + const mockPalette = { style: {}, classList: { add: () => {}, remove: () => {} } }; + const mockInput = { value: '', focus: () => {}, addEventListener: () => {}, removeEventListener: () => {} }; + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + if (id === 'palette-list') return mockList; + return null; + }; + + app._setPaletteOpen(false); + app._setViewMode('fullscreen'); + app.handleGlobalKeydown({ key: '`', ctrlKey: false, preventDefault: () => {} }); + + assert.ok(app._isPaletteOpen(), 'backtick in fullscreen should open palette'); + globalThis.document.getElementById = origGetById; + // cleanup + app._setPaletteOpen(false); + app._setViewMode('grid'); +}); + +test('handleGlobalKeydown opens palette on Ctrl+K in fullscreen with palette closed', () => { + const mockPalette = { style: {}, classList: { add: () => {}, remove: () => {} } }; + const mockInput = { value: '', focus: () => {}, addEventListener: () => {}, removeEventListener: () => {} }; + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'command-palette') return mockPalette; + if (id === 'palette-input') return mockInput; + if (id === 'palette-list') return mockList; + return null; + }; + + app._setPaletteOpen(false); + app._setViewMode('fullscreen'); + app.handleGlobalKeydown({ key: 'k', ctrlKey: true, preventDefault: () => {} }); + + assert.ok(app._isPaletteOpen(), 'Ctrl+K in fullscreen should open palette'); + globalThis.document.getElementById = origGetById; + // cleanup + app._setPaletteOpen(false); + app._setViewMode('grid'); +}); + +test('handleGlobalKeydown calls closeSession on Escape in fullscreen with palette closed', async () => { + let closeTerminalCalled = false; + const origGetById = globalThis.document.getElementById; + const cl = { add: () => {}, remove: () => {} }; + globalThis.document.getElementById = (id) => { + if (id === 'view-expanded') return { style: {}, classList: cl }; + if (id === 'view-overview') return { style: {}, classList: cl }; + if (id === 'session-pill') return { style: {}, classList: cl }; + return null; + }; + globalThis.window._closeTerminal = () => { closeTerminalCalled = true; }; + globalThis.fetch = async () => ({ ok: true }); + + app._setPaletteOpen(false); + app._setViewMode('fullscreen'); + app.handleGlobalKeydown({ key: 'Escape', ctrlKey: false, preventDefault: () => {} }); + + await new Promise((r) => setTimeout(r, 0)); + + assert.ok(closeTerminalCalled, 'Escape in fullscreen (palette closed) should call closeSession'); + globalThis.document.getElementById = origGetById; + globalThis.fetch = undefined; + // cleanup + app._setViewMode('grid'); +}); + +test('bindStaticEventListeners binds back-btn click to closeSession', () => { + const eventsBound = {}; + const origGetById = globalThis.document.getElementById; + const origDocAddListener = globalThis.document.addEventListener; + globalThis.document.getElementById = (id) => { + const el = { _events: {}, addEventListener: (ev, fn) => { el._events[ev] = fn; } }; + eventsBound[id] = el; + return el; + }; + globalThis.document.addEventListener = () => {}; + + app.bindStaticEventListeners(); + + assert.ok(eventsBound['back-btn'] && 'click' in eventsBound['back-btn']._events, '#back-btn should have a click listener'); + globalThis.document.getElementById = origGetById; + globalThis.document.addEventListener = origDocAddListener; +}); + +test('bindStaticEventListeners binds document keydown to handleGlobalKeydown', () => { + let keydownBound = false; + const origGetById = globalThis.document.getElementById; + const origDocAddListener = globalThis.document.addEventListener; + globalThis.document.getElementById = (id) => ({ + _events: {}, + addEventListener: () => {}, + }); + globalThis.document.addEventListener = (ev, fn) => { + if (ev === 'keydown') keydownBound = true; + }; + + app.bindStaticEventListeners(); + + assert.ok(keydownBound, 'document should have a keydown listener bound'); + globalThis.document.getElementById = origGetById; + globalThis.document.addEventListener = origDocAddListener; +}); + +// --- Bottom sheet / session pill --- + +test('renderSheetList: builds list with bell indicator', () => { + // We can't test DOM manipulation, but we can test that sortByPriority + formatTimestamp + // work correctly together for the sheet use case + const sessions = [ + { name: 'idle', bell: { unseen_count: 0, last_fired_at: null, seen_at: null } }, + { name: 'bell', bell: { unseen_count: 1, last_fired_at: 100, seen_at: null } }, + ]; + const sorted = app.sortByPriority(sessions); + assert.strictEqual(sorted[0].name, 'bell', 'bell session comes first in sorted list'); + assert.strictEqual(sorted[1].name, 'idle'); +}); + +test('updateSessionPill logic: others with bell count', () => { + // Test the bell-detection logic in isolation + const allSessions = [ + { name: 'current', bell: { unseen_count: 0, last_fired_at: null, seen_at: null } }, + { name: 'other', bell: { unseen_count: 2, last_fired_at: 100, seen_at: null } }, + ]; + const viewingSession = 'current'; + const othersWithBell = allSessions.filter(function(s) { + return s.name !== viewingSession && + s.bell && s.bell.unseen_count > 0 && + (s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at); + }); + assert.strictEqual(othersWithBell.length, 1, 'should find one other session with bell'); + assert.strictEqual(othersWithBell[0].name, 'other'); +}); + +test('updateSessionPill logic: no bells in others', () => { + const allSessions = [ + { name: 'a', bell: { unseen_count: 0, last_fired_at: null, seen_at: null } }, + { name: 'b', bell: { unseen_count: 0, last_fired_at: null, seen_at: null } }, + ]; + const viewingSession = 'a'; + const othersWithBell = allSessions.filter(function(s) { + return s.name !== viewingSession && + s.bell && s.bell.unseen_count > 0 && + (s.bell.seen_at === null || s.bell.last_fired_at > s.bell.seen_at); + }); + assert.strictEqual(othersWithBell.length, 0, 'no other sessions with bell'); +}); + +// --- Fix 1: renderSheetList escapes HTML in session name --- + +test('renderSheetList escapes HTML special chars in data-session attribute', () => { + let capturedHTML = ''; + const mockList = { + get innerHTML() { return capturedHTML; }, + set innerHTML(v) { capturedHTML = v; }, + querySelectorAll: () => [], + }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => (id === 'sheet-list' ? mockList : null); + app._setCurrentSessions([{ name: 'foo { + let capturedHTML = ''; + const mockList = { + get innerHTML() { return capturedHTML; }, + set innerHTML(v) { capturedHTML = v; }, + querySelectorAll: () => [], + }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => (id === 'sheet-list' ? mockList : null); + app._setCurrentSessions([{ name: 'foofoo<bar<'), 'session name should be escaped inside the name span'); + globalThis.document.getElementById = origGetById; +}); + +// --- Fix 2: openBottomSheet/closeBottomSheet use static backdrop binding --- + +test('openBottomSheet does not dynamically add click listener to sheet-backdrop', () => { + let backdropAddCalled = false; + const mockSheet = { classList: { remove: () => {} } }; + const mockList = { innerHTML: '', querySelectorAll: () => [] }; + const mockBackdrop = { + addEventListener: (ev) => { if (ev === 'click') backdropAddCalled = true; }, + }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'bottom-sheet') return mockSheet; + if (id === 'sheet-list') return mockList; + if (id === 'sheet-backdrop') return mockBackdrop; + return null; + }; + app._setCurrentSessions([]); + + app.openBottomSheet(); + + assert.strictEqual(backdropAddCalled, false, 'openBottomSheet must not add click listener to sheet-backdrop'); + globalThis.document.getElementById = origGetById; +}); + +test('closeBottomSheet does not call removeEventListener on sheet-backdrop', () => { + let backdropRemoveCalled = false; + const mockSheet = { classList: { add: () => {} } }; + const mockBackdrop = { + removeEventListener: (ev) => { if (ev === 'click') backdropRemoveCalled = true; }, + }; + const origGetById = globalThis.document.getElementById; + globalThis.document.getElementById = (id) => { + if (id === 'bottom-sheet') return mockSheet; + if (id === 'sheet-backdrop') return mockBackdrop; + return null; + }; + + app.closeBottomSheet(); + + assert.strictEqual(backdropRemoveCalled, false, 'closeBottomSheet must not call removeEventListener on sheet-backdrop'); + globalThis.document.getElementById = origGetById; +}); diff --git a/frontend/tests/test_terminal.mjs b/frontend/tests/test_terminal.mjs new file mode 100644 index 0000000..38565cc --- /dev/null +++ b/frontend/tests/test_terminal.mjs @@ -0,0 +1,545 @@ +// Tests for terminal.js — WebSocket + xterm.js integration + +import { createRequire } from 'node:module'; +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const require = createRequire(import.meta.url); + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +/** + * Load a fresh copy of terminal.js with isolated module-level state. + * Returns { window } after the script has executed. + */ +function loadTerminal() { + // Delete from require cache so each test gets fresh module-level state + const modulePath = join(__dirname, '..', 'terminal.js'); + delete require.cache[require.resolve(modulePath)]; + + // terminal.js reads: location.protocol, location.host, document.getElementById, + // window.Terminal, window.FitAddon, window.innerWidth + let capturedCloseHandler = null; + let capturedReconnectFn = null; + let capturedWsProtocols = null; + let capturedOnDataFn = null; + let capturedOnResizeFn = null; + let termWriteMessages = []; + let lastWsInstance = null; + + let capturedWsUrl = null; + let onDataCallCount = 0; + let onResizeCallCount = 0; + let focusCallCount = 0; + + const mockTerm = { + cols: 80, + rows: 24, + open: () => {}, + onData: (fn) => { onDataCallCount++; capturedOnDataFn = fn; }, + onResize: (fn) => { onResizeCallCount++; capturedOnResizeFn = fn; }, + loadAddon: () => {}, + dispose: () => {}, + write: (data) => { termWriteMessages.push(data); }, + focus: () => { focusCallCount++; }, + }; + + // Capture all messages sent via WebSocket.send() + const sentMessages = []; + + // WebSocket mock — captures 'close' and 'open' handlers so we can fire them manually + class MockWebSocket { + constructor(_url, _protocols) { + this.readyState = 1; // OPEN + this.binaryType = ''; + this._handlers = {}; + lastWsInstance = this; + } + addEventListener(event, handler) { + this._handlers[event] = handler; + if (event === 'close') capturedCloseHandler = handler; + } + close() {} + send(data) { sentMessages.push(data); } + } + MockWebSocket.OPEN = 1; + + // setTimeout mock: capture reconnect callback so we can fire it synchronously + const origSetTimeout = globalThis.setTimeout; + globalThis.setTimeout = (fn, _ms) => { + capturedReconnectFn = fn; + return 0; + }; + + globalThis.WebSocket = MockWebSocket; + globalThis.location = { protocol: 'http:', host: 'localhost' }; + globalThis.document = { + getElementById: (id) => { + if (id === 'terminal-container') return { appendChild: () => {} }; + if (id === 'reconnect-overlay') return { classList: { add: () => {}, remove: () => {} } }; + return null; + }, + querySelector: () => null, + querySelectorAll: () => [], + addEventListener: () => {}, + createElement: () => ({ style: {}, classList: { add: () => {}, remove: () => {} } }), + }; + globalThis.window = { + addEventListener: () => {}, + location: { href: '' }, + innerWidth: 1024, + Terminal: function Terminal() { return mockTerm; }, + FitAddon: { + FitAddon: function FitAddon() { return { fit: () => {} }; }, + }, + _openTerminal: undefined, + _closeTerminal: undefined, + }; + + require(modulePath); + + // Restore setTimeout + globalThis.setTimeout = origSetTimeout; + + // Find the most recently created MockWebSocket instance's open handler + // by pulling it from the instance created during openTerminal() call. + // We expose a fireOpen() helper so tests can simulate WebSocket connection. + let lastOpenHandler = null; + const OrigMockWS = globalThis.WebSocket; + globalThis.WebSocket = function MockWSTracker(url, protocols) { + capturedWsUrl = url; + capturedWsProtocols = protocols; + const inst = new OrigMockWS(url); + const origAddListener = inst.addEventListener.bind(inst); + inst.addEventListener = function(event, handler) { + if (event === 'open') lastOpenHandler = handler; + origAddListener(event, handler); + }; + lastWsInstance = inst; + return inst; + }; + globalThis.WebSocket.OPEN = 1; + + return { + openTerminal: globalThis.window._openTerminal, + closeTerminal: globalThis.window._closeTerminal, + get onDataCallCount() { return onDataCallCount; }, + get onResizeCallCount() { return onResizeCallCount; }, + get sentMessages() { return sentMessages; }, + get capturedWsUrl() { return capturedWsUrl; }, + get capturedWsProtocols() { return capturedWsProtocols; }, + get capturedOnDataFn() { return capturedOnDataFn; }, + get capturedOnResizeFn() { return capturedOnResizeFn; }, + get termWriteMessages() { return termWriteMessages; }, + get focusCallCount() { return focusCallCount; }, + fireClose() { if (capturedCloseHandler) capturedCloseHandler(); }, + fireOpen() { if (lastOpenHandler) lastOpenHandler(); }, + fireMessage(data) { + if (lastWsInstance && lastWsInstance._handlers['message']) { + lastWsInstance._handlers['message']({ data }); + } + }, + fireReconnect() { if (capturedReconnectFn) { capturedReconnectFn(); capturedReconnectFn = null; } }, + // Expose so we can re-patch setTimeout for the actual calls + patchTimeout(fn) { + const orig = globalThis.setTimeout; + globalThis.setTimeout = (cb, _ms) => { capturedReconnectFn = cb; return 0; }; + fn(); + globalThis.setTimeout = orig; + }, + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────────── + +test('onData is registered exactly once after initial connect (no reconnect)', () => { + const t = loadTerminal(); + + // Patch setTimeout so reconnect callbacks are captured but not auto-run + const orig = globalThis.setTimeout; + globalThis.setTimeout = (fn, _ms) => 0; + + t.openTerminal('my-session'); + + globalThis.setTimeout = orig; + + assert.strictEqual(t.onDataCallCount, 1, 'onData should be registered exactly once'); +}); + +test('onResize is registered exactly once after initial connect (no reconnect)', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (fn, _ms) => 0; + + t.openTerminal('my-session'); + + globalThis.setTimeout = orig; + + assert.strictEqual(t.onResizeCallCount, 1, 'onResize should be registered exactly once'); +}); + +test('onData is NOT re-registered after reconnect — count stays at 1', () => { + let reconnectFn = null; + const orig = globalThis.setTimeout; + + const t = loadTerminal(); + + // Patch setTimeout to capture reconnect callback + globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; }; + + t.openTerminal('my-session'); + + // Simulate WebSocket dropping — triggers close handler which schedules reconnect + t.fireClose(); + + // Fire the reconnect (calls connect() again) + if (reconnectFn) reconnectFn(); + + globalThis.setTimeout = orig; + + assert.strictEqual( + t.onDataCallCount, + 1, + 'onData should still be registered exactly once after a reconnect', + ); +}); + +test('onResize is NOT re-registered after reconnect — count stays at 1', () => { + let reconnectFn = null; + const orig = globalThis.setTimeout; + + const t = loadTerminal(); + + globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; }; + + t.openTerminal('my-session'); + + t.fireClose(); + if (reconnectFn) reconnectFn(); + + globalThis.setTimeout = orig; + + assert.strictEqual( + t.onResizeCallCount, + 1, + 'onResize should still be registered exactly once after a reconnect', + ); +}); + +test('onData count stays at 1 after multiple reconnects', () => { + let reconnectFn = null; + const orig = globalThis.setTimeout; + + const t = loadTerminal(); + + globalThis.setTimeout = (fn, _ms) => { reconnectFn = fn; return 0; }; + + t.openTerminal('my-session'); + + // Reconnect 3 times + for (let i = 0; i < 3; i++) { + t.fireClose(); + if (reconnectFn) { reconnectFn(); reconnectFn = null; } + } + + globalThis.setTimeout = orig; + + assert.strictEqual( + t.onDataCallCount, + 1, + 'onData should be registered exactly once even after 3 reconnects', + ); +}); + +test('_fitAddon is nulled out when closeTerminal is called', () => { + // This is a whitebox test: verify no crash on dispose + null + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (fn, _ms) => 0; + + t.openTerminal('my-session'); + // Should not throw + assert.doesNotThrow(() => t.closeTerminal(), 'closeTerminal should not throw'); + + globalThis.setTimeout = orig; +}); + +test('initVisualViewport returns early without error when window.visualViewport is undefined', () => { + // Guard test: non-mobile environments have no visualViewport — must not throw + const t = loadTerminal(); + + // globalThis.window has no visualViewport (see loadTerminal setup) + assert.strictEqual(globalThis.window.visualViewport, undefined, + 'test pre-condition: window.visualViewport must be undefined'); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (fn, _ms) => 0; + + // openTerminal internally calls initVisualViewport — must not throw + assert.doesNotThrow(() => t.openTerminal('test-session'), + 'openTerminal (and initVisualViewport) should not throw when window.visualViewport is undefined'); + + globalThis.setTimeout = orig; +}); + +// ─── ttyd protocol tests ────────────────────────────────────────────────────── +// ttyd 1.7.7 requires: +// 1. WebSocket subprotocol 'tty' — without it ttyd never starts the PTY +// 2. First message on open: TEXT frame '{"AuthToken":""}' +// 3. Second message on open: BINARY frame [0x31] + UTF-8({"columns":N,"rows":M}) +// 4. Input keystrokes: BINARY [0x30] + UTF-8(keystroke) +// 5. Resize: BINARY [0x31] + UTF-8({"columns":N,"rows":M}) +// 6. Received frames: 1-byte type prefix — 0x30=output (write to xterm), 0x31/0x32=ignore + +test('connectWebSocket uses tty subprotocol', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('test-session'); + + globalThis.setTimeout = orig; + + assert.deepStrictEqual( + t.capturedWsProtocols, + ['tty'], + "WebSocket must be constructed with ['tty'] subprotocol — without it ttyd never starts the PTY", + ); +}); + +test('connectWebSocket sends text auth init as first message on open', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('test-session'); + t.fireOpen(); + + globalThis.setTimeout = orig; + + assert.ok(t.sentMessages.length >= 1, 'should have sent at least one message on open'); + + const firstMsg = t.sentMessages[0]; + assert.strictEqual(typeof firstMsg, 'string', + `first message must be a text string (auth frame), got ${Object.prototype.toString.call(firstMsg)}`); + + const parsed = JSON.parse(firstMsg); + assert.strictEqual(parsed.AuthToken, '', 'AuthToken must be empty string'); + assert.ok(!('columns' in parsed), 'auth-only TEXT frame should NOT contain columns'); + assert.ok(!('rows' in parsed), 'auth-only TEXT frame should NOT contain rows'); +}); + +test('connectWebSocket sends binary resize with 0x31 prefix as second message on open', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('test-session'); + t.fireOpen(); + + globalThis.setTimeout = orig; + + assert.ok(t.sentMessages.length >= 2, 'should have sent at least two messages on open (auth + resize)'); + + const resizeMsg = t.sentMessages[1]; + assert.ok(resizeMsg instanceof Uint8Array, + `resize message must be binary Uint8Array, got ${Object.prototype.toString.call(resizeMsg)}`); + assert.strictEqual(resizeMsg[0], 0x31, 'first byte of resize message must be 0x31 (resize type)'); + + const payload = JSON.parse(Buffer.from(resizeMsg.slice(1)).toString('utf-8')); + assert.ok('columns' in payload, 'resize payload must contain columns'); + assert.ok('rows' in payload, 'resize payload must contain rows'); + assert.ok(typeof payload.columns === 'number' && payload.columns > 0, + `columns must be a positive number, got ${payload.columns}`); + assert.ok(typeof payload.rows === 'number' && payload.rows > 0, + `rows must be a positive number, got ${payload.rows}`); +}); + +test('onData sends input with 0x30 type prefix as binary frame', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('test-session'); + t.fireOpen(); + + const initCount = t.sentMessages.length; + + assert.ok(t.capturedOnDataFn, 'onData callback must have been registered'); + t.capturedOnDataFn('a'); + + globalThis.setTimeout = orig; + + assert.strictEqual(t.sentMessages.length, initCount + 1, 'onData should send exactly one message'); + + const msg = t.sentMessages[initCount]; + assert.ok(msg instanceof Uint8Array, 'keystroke message must be binary Uint8Array'); + assert.strictEqual(msg[0], 0x30, 'first byte of input message must be 0x30 (input type)'); + + const text = Buffer.from(msg.slice(1)).toString('utf-8'); + assert.strictEqual(text, 'a', 'payload after type byte must be the keystroke string'); +}); + +test('onResize sends resize with 0x31 type prefix as binary frame', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('test-session'); + t.fireOpen(); + + const initCount = t.sentMessages.length; + + assert.ok(t.capturedOnResizeFn, 'onResize callback must have been registered'); + t.capturedOnResizeFn({ cols: 100, rows: 30 }); + + globalThis.setTimeout = orig; + + assert.strictEqual(t.sentMessages.length, initCount + 1, 'onResize should send exactly one message'); + + const msg = t.sentMessages[initCount]; + assert.ok(msg instanceof Uint8Array, 'resize message must be binary Uint8Array'); + assert.strictEqual(msg[0], 0x31, 'first byte of resize message must be 0x31 (resize type)'); + + const payload = JSON.parse(Buffer.from(msg.slice(1)).toString('utf-8')); + assert.strictEqual(payload.columns, 100, 'columns must match the resize event cols'); + assert.strictEqual(payload.rows, 30, 'rows must match the resize event rows'); +}); + +test('message handler strips type byte and writes output for type 0x30', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('test-session'); + + globalThis.setTimeout = orig; + + // Simulate receiving a terminal output frame: [0x30] + UTF-8('hello') + const encoder = new TextEncoder(); + const hello = encoder.encode('hello'); + const msg = new Uint8Array(1 + hello.length); + msg[0] = 0x30; + msg.set(hello, 1); + + t.fireMessage(msg.buffer); // Pass as ArrayBuffer + + assert.strictEqual(t.termWriteMessages.length, 1, 'term.write should be called exactly once'); + + const written = t.termWriteMessages[0]; + assert.ok(written instanceof Uint8Array, 'data written to xterm must be a Uint8Array'); + assert.strictEqual(written[0], 'h'.charCodeAt(0), + 'first byte written must be "h" (0x68), not the type byte 0x30'); + assert.strictEqual(written.length, hello.length, + 'written data length must equal payload length (type byte stripped)'); +}); + +test('message handler ignores title type (0x31) — does not call term.write', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('test-session'); + + globalThis.setTimeout = orig; + + const encoder = new TextEncoder(); + const title = encoder.encode('my session title'); + const msg = new Uint8Array(1 + title.length); + msg[0] = 0x31; + msg.set(title, 1); + + t.fireMessage(msg.buffer); + + assert.strictEqual(t.termWriteMessages.length, 0, + 'term.write must NOT be called for type 0x31 (window title)'); +}); + +test('message handler ignores prefs type (0x32) — does not call term.write', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('test-session'); + + globalThis.setTimeout = orig; + + const encoder = new TextEncoder(); + const prefs = encoder.encode('{}'); + const msg = new Uint8Array(1 + prefs.length); + msg[0] = 0x32; + msg.set(prefs, 1); + + t.fireMessage(msg.buffer); + + assert.strictEqual(t.termWriteMessages.length, 0, + 'term.write must NOT be called for type 0x32 (preferences)'); +}); + +test('connectWebSocket URL uses /terminal/ws path', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('my-session'); + + globalThis.setTimeout = orig; + + assert.ok(t.capturedWsUrl, 'WebSocket URL should have been captured'); + assert.ok( + t.capturedWsUrl.endsWith('/terminal/ws'), + `WebSocket URL should end with /terminal/ws, got: ${t.capturedWsUrl}`, + ); +}); + +test('initVisualViewport registers resize handler on window.visualViewport when present', () => { + // RED test: stub does nothing; real impl must call addEventListener('resize', fn) + const t = loadTerminal(); + + let addedEvent = null; + globalThis.window.visualViewport = { + addEventListener: (event, _fn) => { addedEvent = event; }, + removeEventListener: (_event, _fn) => {}, + }; + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (fn, _ms) => 0; + + t.openTerminal('test-session'); + + globalThis.setTimeout = orig; + delete globalThis.window.visualViewport; + + assert.strictEqual(addedEvent, 'resize', + '_vpHandler should be registered as a resize listener on window.visualViewport'); +}); + +test('terminal is auto-focused when WebSocket opens', () => { + const t = loadTerminal(); + + const orig = globalThis.setTimeout; + globalThis.setTimeout = (_fn, _ms) => 0; + + t.openTerminal('test-session'); + t.fireOpen(); + + globalThis.setTimeout = orig; + + assert.strictEqual(t.focusCallCount, 1, + '_term.focus() should be called exactly once when the WebSocket open event fires'); +}); diff --git a/frontend/wordmark-on-dark.svg b/frontend/wordmark-on-dark.svg new file mode 100755 index 0000000..5394fc3 --- /dev/null +++ b/frontend/wordmark-on-dark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..231248a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,16 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "tmux-web-coordinator" +version = "0.1.0" +requires-python = ">=3.12" + +[tool.pytest.ini_options] +testpaths = ["coordinator/tests"] +asyncio_mode = "auto" +addopts = "--import-mode=importlib -m 'not integration'" +markers = [ + "integration: marks tests as integration tests requiring real tmux (deselect with '-m not integration')", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..643fd51 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.30.0 +httpx>=0.27.0 +pytest>=8.0.0 +pytest-asyncio>=0.23.0 +aiofiles>=23.0 +beautifulsoup4>=4.12 diff --git a/scripts/render-brand-assets.py b/scripts/render-brand-assets.py new file mode 100644 index 0000000..3c70646 --- /dev/null +++ b/scripts/render-brand-assets.py @@ -0,0 +1,438 @@ +#!/usr/bin/env python3 +""" +muxplex brand asset renderer +Usage: python3 render-brand-assets.py [--force] [--workdir PATH] + +Renders all SVG sources to PNG/ICO for web, PWA, favicons, and OG images. +Run from the ./muxplex/ directory or pass --workdir path. + +Renderer priority: cairosvg > rsvg-convert > inkscape > imagemagick (convert/magick) +""" + +import importlib.util +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET +from argparse import ArgumentParser +from pathlib import Path + +# ────────────────────────────────────────────────────────────── +# Argument parsing +# ────────────────────────────────────────────────────────────── + +parser = ArgumentParser(description="Render muxplex brand assets") +parser.add_argument( + "--force", action="store_true", help="Re-render even if output exists" +) +parser.add_argument("--workdir", default=None, help="Base directory (default: cwd)") +args = parser.parse_args() + +WORKDIR = Path(args.workdir).resolve() if args.workdir else Path.cwd() +ASSETS = WORKDIR / "assets" / "branding" +SVG_DIR = ASSETS / "svg" +FORCE = args.force + +# Verify we're in the right place +if not SVG_DIR.exists(): + sys.exit( + f"[ERROR] Cannot find {SVG_DIR}\n" + "Run from the muxplex/ directory or pass --workdir " + ) + +# ────────────────────────────────────────────────────────────── +# Renderer detection +# ────────────────────────────────────────────────────────────── + +RENDERER: str | None = None + +if importlib.util.find_spec("cairosvg") is not None: + RENDERER = "cairosvg" + print("[renderer] cairosvg detected ✓") + +if RENDERER is None and shutil.which("rsvg-convert"): + RENDERER = "rsvg-convert" + print("[renderer] rsvg-convert detected ✓") + +if RENDERER is None and shutil.which("inkscape"): + RENDERER = "inkscape" + print("[renderer] inkscape detected ✓") + +if RENDERER is None: + for _magick_bin in ("magick", "convert"): + if shutil.which(_magick_bin): + RENDERER = f"imagemagick:{_magick_bin}" + print(f"[renderer] ImageMagick ({_magick_bin}) detected ✓") + break + +if RENDERER is None: + sys.exit( + "[ERROR] No SVG renderer found.\n" + "Install one of: cairosvg (pip install cairosvg), librsvg2-bin (rsvg-convert),\n" + " inkscape, or imagemagick" + ) + +# ────────────────────────────────────────────────────────────── +# Pillow detection +# ────────────────────────────────────────────────────────────── + +HAS_PILLOW = importlib.util.find_spec("PIL") is not None +if HAS_PILLOW: + print("[pillow] Pillow detected ✓") +else: + print("[pillow] Pillow not available — ICO and OG composition will be skipped") + +# ────────────────────────────────────────────────────────────── +# SVG introspection helpers +# ────────────────────────────────────────────────────────────── + + +def get_svg_dimensions(svg_path: Path) -> tuple[float | None, float | None]: + """Return (width, height) floats from the SVG root element.""" + tree = ET.parse(svg_path) + root = tree.getroot() + w = root.get("width") + h = root.get("height") + # Fall back to viewBox + if not w or not h: + vb = root.get("viewBox", "") + parts = vb.replace(",", " ").split() + if len(parts) == 4: + w, h = parts[2], parts[3] + if w and h: + # Strip units (px, pt, etc.) + w_f = float("".join(c for c in str(w) if c in "0123456789.")) + h_f = float("".join(c for c in str(h) if c in "0123456789.")) + return w_f, h_f + return None, None + + +# ────────────────────────────────────────────────────────────── +# Core render function +# ────────────────────────────────────────────────────────────── + + +def render_svg( + src: Path, + dst: Path, + width: int | None = None, + height: int | None = None, +) -> bool: + """ + Render src SVG to dst PNG at the given width and/or height. + If only one dimension is given the other is computed from aspect ratio. + Returns True on success. + """ + if dst.exists() and not FORCE: + print(f" [skip] {dst.relative_to(WORKDIR)}") + return True + + dst.parent.mkdir(parents=True, exist_ok=True) + + # Compute missing dimension from aspect ratio + svg_w, svg_h = get_svg_dimensions(src) + if svg_w and svg_h: + aspect = svg_w / svg_h + if width and not height: + height = round(width / aspect) + elif height and not width: + width = round(height * aspect) + # Final fallback dimensions + final_w: int = width if width is not None else int(svg_w or 64) + final_h: int = height if height is not None else int(svg_h or 64) + + try: + if RENDERER == "cairosvg": + import cairosvg # type: ignore[import-untyped] + + cairosvg.svg2png( + url=str(src), + write_to=str(dst), + output_width=final_w, + output_height=final_h, + ) + + elif RENDERER == "rsvg-convert": + subprocess.run( + [ + "rsvg-convert", + "-w", + str(final_w), + "-h", + str(final_h), + "-o", + str(dst), + str(src), + ], + check=True, + capture_output=True, + ) + + elif RENDERER == "inkscape": + subprocess.run( + [ + "inkscape", + "--export-type=png", + f"--export-width={final_w}", + f"--export-height={final_h}", + f"--export-filename={dst}", + str(src), + ], + check=True, + capture_output=True, + ) + + else: + # ImageMagick — RENDERER is "imagemagick:convert" or "imagemagick:magick" + # + # ImageMagick's built-in SVG renderer (MSVG) handles clip-paths + # reliably only at the SVG's native pixel dimensions. Rendering at + # any other size typically produces a 1-colour (transparent) result. + # + # Strategy: + # 1. Render to a temp PNG at native SVG dimensions via ImageMagick. + # 2. Scale the temp PNG to the requested target size with Pillow + # (LANCZOS — high quality, preserves edges). + # 3. Fall back to a direct ImageMagick resize if Pillow is absent + # (quality may be poor for upscaling). + assert RENDERER is not None # guaranteed by startup check + bin_name = RENDERER.split(":")[1] + native_w, native_h = get_svg_dimensions(src) + + if native_w and native_h and HAS_PILLOW: + from PIL import Image + + tmp_native = dst.parent / f"_tmp_native_{src.stem}.png" + # Render at native SVG size (no resize flag → ImageMagick uses + # the dimensions declared in the SVG header). + subprocess.run( + [bin_name, "-background", "none", str(src), str(tmp_native)], + check=True, + capture_output=True, + ) + # Pillow: open → resize with LANCZOS → save + img = Image.open(tmp_native).convert("RGBA") + img = img.resize((final_w, final_h), Image.Resampling.LANCZOS) + img.save(str(dst)) + tmp_native.unlink(missing_ok=True) + else: + # No Pillow — attempt a direct ImageMagick resize. + # Works acceptably for downscaling; upscaling may be imprecise. + subprocess.run( + [ + bin_name, + "-background", + "none", + "-resize", + f"{final_w}x{final_h}!", + str(src), + str(dst), + ], + check=True, + capture_output=True, + ) + + print(f" [ok] {dst.relative_to(WORKDIR)} ({final_w}×{final_h})") + return True + + except Exception as exc: + print(f" [FAIL] {dst.relative_to(WORKDIR)} — {exc}") + return False + + +# ────────────────────────────────────────────────────────────── +# Generated-file registry (for summary table) +# ────────────────────────────────────────────────────────────── + +generated: list[dict[str, str]] = [] + + +def track(path: Path, status: str = "ok") -> None: + size_kb = f"{path.stat().st_size / 1024:.1f}K" if path.exists() else "—" + generated.append( + {"path": str(path.relative_to(WORKDIR)), "size": size_kb, "status": status} + ) + + +# ────────────────────────────────────────────────────────────── +# Step 1 — App icons +# ────────────────────────────────────────────────────────────── + +print("\n── App icons ─────────────────────────────────────────────") +ICON_SRC = SVG_DIR / "icon" / "muxplex-icon-dark.svg" +ICONS_DIR = ASSETS / "icons" +ICON_SIZES = [16, 22, 24, 32, 48, 64, 128, 192, 256, 512, 1024] + +for sz in ICON_SIZES: + dst = ICONS_DIR / f"muxplex-icon-{sz}.png" + ok = render_svg(ICON_SRC, dst, width=sz, height=sz) + if dst.exists(): + track(dst, "ok" if ok else "fail") + +# ────────────────────────────────────────────────────────────── +# Step 2 — Favicons +# ────────────────────────────────────────────────────────────── + +print("\n── Favicons ──────────────────────────────────────────────") +FAV_DIR = ASSETS / "favicons" + +for sz in [16, 32, 48]: + dst = FAV_DIR / f"favicon-{sz}.png" + ok = render_svg(ICON_SRC, dst, width=sz, height=sz) + if dst.exists(): + track(dst) + +# apple-touch-icon 180×180 +ati = FAV_DIR / "apple-touch-icon.png" +ok = render_svg(ICON_SRC, ati, width=180, height=180) +if ati.exists(): + track(ati) + +# favicon.ico (multi-res 16+32+48) — requires Pillow +ico_path = FAV_DIR / "favicon.ico" +if not ico_path.exists() or FORCE: + if HAS_PILLOW: + from PIL import Image + + fav_pngs = [FAV_DIR / f"favicon-{s}.png" for s in [16, 32, 48]] + if all(p.exists() for p in fav_pngs): + imgs = [Image.open(p).convert("RGBA") for p in fav_pngs] + imgs[0].save( + str(ico_path), + format="ICO", + sizes=[(s, s) for s in [16, 32, 48]], + append_images=imgs[1:], + ) + print(f" [ok] {ico_path.relative_to(WORKDIR)} (multi-res ICO)") + else: + print(" [WARN] favicon PNGs missing — ICO skipped") + else: + print(" [skip] favicon.ico — Pillow not available") +if ico_path.exists(): + track(ico_path) + +# ────────────────────────────────────────────────────────────── +# Step 3 — PWA icons +# ────────────────────────────────────────────────────────────── + +print("\n── PWA icons ─────────────────────────────────────────────") +PWA_DIR = ASSETS / "pwa" + +for sz in [192, 512]: + dst = PWA_DIR / f"pwa-{sz}.png" + ok = render_svg(ICON_SRC, dst, width=sz, height=sz) + if dst.exists(): + track(dst) + +# ────────────────────────────────────────────────────────────── +# Step 4 — Wordmark PNGs +# ────────────────────────────────────────────────────────────── + +print("\n── Wordmarks ─────────────────────────────────────────────") +WM_DIR = ASSETS / "wordmark" + +for variant in ["dark", "light"]: + src = SVG_DIR / "wordmark" / f"wordmark-on-{variant}.svg" + for h in [32, 64]: + dst = WM_DIR / f"wordmark-on-{variant}-{h}.png" + ok = render_svg(src, dst, height=h) + if dst.exists(): + track(dst) + +# ────────────────────────────────────────────────────────────── +# Step 5 — Lockup PNGs +# ────────────────────────────────────────────────────────────── + +print("\n── Lockups ───────────────────────────────────────────────") +LOCKUP_DIR = ASSETS / "lockup" + +for variant in ["dark", "light"]: + src = SVG_DIR / "lockup" / f"lockup-on-{variant}.svg" + for h in [32, 64]: + dst = LOCKUP_DIR / f"lockup-on-{variant}-{h}.png" + ok = render_svg(src, dst, height=h) + if dst.exists(): + track(dst) + +# ────────────────────────────────────────────────────────────── +# Step 6 — OG images (1200×630) +# ────────────────────────────────────────────────────────────── + +print("\n── OG images ─────────────────────────────────────────────") +OG_DIR = ASSETS / "og" +OG_DIR.mkdir(parents=True, exist_ok=True) + +OG_CONFIGS: list[dict] = [ + { + "variant": "dark", + "bg_hex": "#0D1117", + "bg_rgb": (13, 17, 23), + "lockup_src": SVG_DIR / "lockup" / "lockup-on-dark.svg", + "out": OG_DIR / "og-dark.png", + }, + { + "variant": "light", + "bg_hex": "#FFFFFF", + "bg_rgb": (255, 255, 255), + "lockup_src": SVG_DIR / "lockup" / "lockup-on-light.svg", + "out": OG_DIR / "og-light.png", + }, +] + +for cfg in OG_CONFIGS: + out_path: Path = cfg["out"] + if out_path.exists() and not FORCE: + print(f" [skip] {out_path.relative_to(WORKDIR)}") + track(out_path, "skip") + continue + + if HAS_PILLOW: + from PIL import Image + + # Render lockup at height=200 into a temp file + tmp_lockup = OG_DIR / f"_tmp_lockup_{cfg['variant']}.png" + ok = render_svg(cfg["lockup_src"], tmp_lockup, height=200) + if not ok or not tmp_lockup.exists(): + print(f" [FAIL] OG {cfg['variant']} — lockup render failed") + continue + + bg = Image.new("RGB", (1200, 630), color=cfg["bg_rgb"]) + lockup_img = Image.open(tmp_lockup).convert("RGBA") + + # Centre the lockup on the canvas + x = (1200 - lockup_img.width) // 2 + y = (630 - lockup_img.height) // 2 + bg.paste(lockup_img, (x, y), lockup_img) + bg.save(str(out_path)) + tmp_lockup.unlink(missing_ok=True) + print( + f" [ok] {out_path.relative_to(WORKDIR)} (1200×630, bg={cfg['bg_hex']})" + ) + + else: + # Fallback: render lockup at height=200, no canvas composition + ok = render_svg(cfg["lockup_src"], out_path, height=200) + if ok: + print( + f" [NOTE] {out_path.relative_to(WORKDIR)} — " + "Pillow unavailable; rendered lockup only (no 1200×630 canvas).\n" + " Install Pillow and re-run to get proper OG images." + ) + + if out_path.exists(): + track(out_path) + +# ────────────────────────────────────────────────────────────── +# Summary table +# ────────────────────────────────────────────────────────────── + +print("\n" + "═" * 70) +print(f" {'FILE':<52} {'SIZE':>6} STATUS") +print("─" * 70) +for item in generated: + status_icon = ( + "✓" if item["status"] == "ok" else ("·" if item["status"] == "skip" else "✗") + ) + print(f" {item['path']:<52} {item['size']:>6} {status_icon}") +print("═" * 70) +print(f" Total: {len(generated)} files\n")