From 8234e2ec05ef51e7b8dc18bec8ba0de083ff78ef Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Fri, 27 Mar 2026 15:06:00 -0700 Subject: [PATCH] refactor: restructure project into muxplex/ subdir with brand integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Caddyfile | 11 + README.md | 45 + assets/branding/DESIGN-SYSTEM.md | 857 ++++ assets/branding/README.md | 40 + assets/branding/favicons/apple-touch-icon.png | Bin 0 -> 2721 bytes assets/branding/favicons/favicon-16.png | Bin 0 -> 397 bytes assets/branding/favicons/favicon-32.png | Bin 0 -> 669 bytes assets/branding/favicons/favicon-48.png | Bin 0 -> 919 bytes assets/branding/favicons/favicon.ico | Bin 0 -> 401 bytes assets/branding/icons/muxplex-icon-1024.png | Bin 0 -> 17611 bytes assets/branding/icons/muxplex-icon-128.png | Bin 0 -> 2049 bytes assets/branding/icons/muxplex-icon-16.png | Bin 0 -> 397 bytes assets/branding/icons/muxplex-icon-192.png | Bin 0 -> 2923 bytes assets/branding/icons/muxplex-icon-22.png | Bin 0 -> 495 bytes assets/branding/icons/muxplex-icon-24.png | Bin 0 -> 535 bytes assets/branding/icons/muxplex-icon-256.png | Bin 0 -> 3695 bytes assets/branding/icons/muxplex-icon-32.png | Bin 0 -> 669 bytes assets/branding/icons/muxplex-icon-48.png | Bin 0 -> 919 bytes assets/branding/icons/muxplex-icon-512.png | Bin 0 -> 7998 bytes assets/branding/icons/muxplex-icon-64.png | Bin 0 -> 1136 bytes assets/branding/lockup/lockup-on-dark-32.png | Bin 0 -> 2674 bytes assets/branding/lockup/lockup-on-dark-64.png | Bin 0 -> 5169 bytes assets/branding/lockup/lockup-on-light-32.png | Bin 0 -> 3155 bytes assets/branding/lockup/lockup-on-light-64.png | Bin 0 -> 5965 bytes assets/branding/og/og-dark.png | Bin 0 -> 20433 bytes assets/branding/og/og-light.png | Bin 0 -> 20635 bytes assets/branding/pwa/pwa-192.png | Bin 0 -> 2923 bytes assets/branding/pwa/pwa-512.png | Bin 0 -> 7998 bytes .../branding/svg/icon/muxplex-icon-dark.svg | 18 + .../branding/svg/icon/muxplex-icon-light.svg | 18 + assets/branding/svg/lockup/lockup-on-dark.svg | 20 + .../branding/svg/lockup/lockup-on-light.svg | 21 + .../svg/wordmark/wordmark-on-dark.svg | 11 + .../svg/wordmark/wordmark-on-light.svg | 11 + assets/branding/tokens.css | 355 ++ assets/branding/tokens.json | 214 + .../branding/wordmark/wordmark-on-dark-32.png | Bin 0 -> 2460 bytes .../branding/wordmark/wordmark-on-dark-64.png | Bin 0 -> 4588 bytes .../wordmark/wordmark-on-light-32.png | Bin 0 -> 2533 bytes .../wordmark/wordmark-on-light-64.png | Bin 0 -> 4704 bytes coordinator/__init__.py | 0 coordinator/bells.py | 177 + coordinator/main.py | 345 ++ coordinator/sessions.py | 139 + coordinator/spike_bell_flag.py | 80 + coordinator/state.py | 184 + coordinator/tests/__init__.py | 0 coordinator/tests/test_api.py | 645 +++ coordinator/tests/test_bells.py | 318 ++ coordinator/tests/test_frontend_css.py | 92 + coordinator/tests/test_frontend_html.py | 165 + coordinator/tests/test_integration.py | 213 + coordinator/tests/test_sessions.py | 251 ++ coordinator/tests/test_state.py | 311 ++ coordinator/tests/test_ttyd.py | 232 ++ coordinator/ttyd.py | 156 + .../2026-03-26-web-tmux-dashboard-design.md | 318 ++ .../2026-03-26-web-tmux-phase1-backend.md | 3489 +++++++++++++++++ ...-03-26-web-tmux-phase2a-frontend-static.md | 1717 ++++++++ .../2026-03-26-web-tmux-phase2b-javascript.md | 1909 +++++++++ frontend/app.js | 935 +++++ frontend/apple-touch-icon.png | Bin 0 -> 2721 bytes frontend/favicon-32.png | Bin 0 -> 669 bytes frontend/favicon.ico | Bin 0 -> 401 bytes frontend/index.html | 74 + frontend/manifest.json | 14 + frontend/pwa-192.png | Bin 0 -> 2923 bytes frontend/pwa-512.png | Bin 0 -> 7998 bytes frontend/style.css | 747 ++++ frontend/terminal.js | 219 ++ frontend/tests/test_app.mjs | 1638 ++++++++ frontend/tests/test_terminal.mjs | 545 +++ frontend/wordmark-on-dark.svg | 11 + pyproject.toml | 16 + requirements.txt | 7 + scripts/render-brand-assets.py | 438 +++ 76 files changed, 17006 insertions(+) create mode 100644 Caddyfile create mode 100644 README.md create mode 100644 assets/branding/DESIGN-SYSTEM.md create mode 100644 assets/branding/README.md create mode 100644 assets/branding/favicons/apple-touch-icon.png create mode 100644 assets/branding/favicons/favicon-16.png create mode 100644 assets/branding/favicons/favicon-32.png create mode 100644 assets/branding/favicons/favicon-48.png create mode 100644 assets/branding/favicons/favicon.ico create mode 100644 assets/branding/icons/muxplex-icon-1024.png create mode 100644 assets/branding/icons/muxplex-icon-128.png create mode 100644 assets/branding/icons/muxplex-icon-16.png create mode 100644 assets/branding/icons/muxplex-icon-192.png create mode 100644 assets/branding/icons/muxplex-icon-22.png create mode 100644 assets/branding/icons/muxplex-icon-24.png create mode 100644 assets/branding/icons/muxplex-icon-256.png create mode 100644 assets/branding/icons/muxplex-icon-32.png create mode 100644 assets/branding/icons/muxplex-icon-48.png create mode 100644 assets/branding/icons/muxplex-icon-512.png create mode 100644 assets/branding/icons/muxplex-icon-64.png create mode 100644 assets/branding/lockup/lockup-on-dark-32.png create mode 100644 assets/branding/lockup/lockup-on-dark-64.png create mode 100644 assets/branding/lockup/lockup-on-light-32.png create mode 100644 assets/branding/lockup/lockup-on-light-64.png create mode 100644 assets/branding/og/og-dark.png create mode 100644 assets/branding/og/og-light.png create mode 100644 assets/branding/pwa/pwa-192.png create mode 100644 assets/branding/pwa/pwa-512.png create mode 100755 assets/branding/svg/icon/muxplex-icon-dark.svg create mode 100755 assets/branding/svg/icon/muxplex-icon-light.svg create mode 100755 assets/branding/svg/lockup/lockup-on-dark.svg create mode 100755 assets/branding/svg/lockup/lockup-on-light.svg create mode 100755 assets/branding/svg/wordmark/wordmark-on-dark.svg create mode 100755 assets/branding/svg/wordmark/wordmark-on-light.svg create mode 100644 assets/branding/tokens.css create mode 100644 assets/branding/tokens.json create mode 100644 assets/branding/wordmark/wordmark-on-dark-32.png create mode 100644 assets/branding/wordmark/wordmark-on-dark-64.png create mode 100644 assets/branding/wordmark/wordmark-on-light-32.png create mode 100644 assets/branding/wordmark/wordmark-on-light-64.png create mode 100644 coordinator/__init__.py create mode 100644 coordinator/bells.py create mode 100644 coordinator/main.py create mode 100644 coordinator/sessions.py create mode 100644 coordinator/spike_bell_flag.py create mode 100644 coordinator/state.py create mode 100644 coordinator/tests/__init__.py create mode 100644 coordinator/tests/test_api.py create mode 100644 coordinator/tests/test_bells.py create mode 100644 coordinator/tests/test_frontend_css.py create mode 100644 coordinator/tests/test_frontend_html.py create mode 100644 coordinator/tests/test_integration.py create mode 100644 coordinator/tests/test_sessions.py create mode 100644 coordinator/tests/test_state.py create mode 100644 coordinator/tests/test_ttyd.py create mode 100644 coordinator/ttyd.py create mode 100644 docs/plans/2026-03-26-web-tmux-dashboard-design.md create mode 100644 docs/plans/2026-03-26-web-tmux-phase1-backend.md create mode 100644 docs/plans/2026-03-26-web-tmux-phase2a-frontend-static.md create mode 100644 docs/plans/2026-03-26-web-tmux-phase2b-javascript.md create mode 100644 frontend/app.js create mode 100644 frontend/apple-touch-icon.png create mode 100644 frontend/favicon-32.png create mode 100644 frontend/favicon.ico create mode 100644 frontend/index.html create mode 100644 frontend/manifest.json create mode 100644 frontend/pwa-192.png create mode 100644 frontend/pwa-512.png create mode 100644 frontend/style.css create mode 100644 frontend/terminal.js create mode 100644 frontend/tests/test_app.mjs create mode 100644 frontend/tests/test_terminal.mjs create mode 100755 frontend/wordmark-on-dark.svg create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 scripts/render-brand-assets.py 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 0000000000000000000000000000000000000000..3b8665fe3ce99a8f22ec5047145333b1e598df7f GIT binary patch literal 2721 zcmb_ec{JPE9{-`$HkNczYKwWTCA6WoQerDcqm8y0F&Y$6G*WaG#9B2}Ybkw#391yW zph!3b)7aI%X6!q)LYv+Yycmhb+KevGe08)LYj zf!?0Jx@n~)n12*3qs z){A!n{xgqjl5&gIdCJ;&3;Cjn5{@dtOXgUDR84O-%2*`K#T;vYVE<4oCh3M97Uy@; z#0O!$mC@%HfWee_kwuNIk&hpv3M}(nC}}7`KX>;j`pI|XYW+ReW2S2(MVV$|%INdh zjy|;V)0~l08j&B<5hDD^*XP}jha0_w3MQL4b;z0zhuMJU?w0x>Zt_yHKI`;{E-Rn2 zD>)izQnzNR*}WvaB6jY;XE-<kEFim{3xy1wbd7bX2E zuuEOSTJ0|@APvolXL-FwM@F8y?XOf^6Gnt8?Qz_^QTf7^w7bpDpNob&%F#J^sD9Bw z+H$gIy55=_%rW&NO^bSPB(Swr%Xw0o8bNYnxY9U#ln7ografM>nHr?dh6irSL5Hhh zZ0#sBPoq(4_Ct$${W914Q+UeVa)TAhwAT=yZ6}G;RqqezPNhjlnVvJc2`c;2JkvN? z;1K^k>Pn#&x{thTIfZ|vy!6X-i?TpT{(U+!ZnhJ*v_iIp;NhlXS2}*aC`rWNn5rtv zIaOvY7`3_O&v_oCD=JI5RdJTwqR8`r1a-zI*IQ|*E@fa1oQ_Yud3KU@bnG>LF?j!J zo{#dg2r2p6<81elu*u#HuT(wM?oXPW2^G2@ajPLhpkX}d1 zxwrRo8k*)CC$9-5s%jit^c+bE+V-Eq^B;=vo~X?=9F%_nc>#}65{dQQ_nl&i*n2YX zE$XGFy)0_ir)sUj+p7z~M%AVNSYN#oH;bS>ihcWwP$X=Q&uf~sOthTy;hPjD;m>fK zY8@T$ur3EbUyO+EW4)8U+qHvcu{#S38M8OOz`L#)i2F&^ouQ-?gy5^}@4jFC1)TlN zRc3Th{U_oFnLFeN>MpRm%NIgbNo*PT@K91-Uf#I1d#AP#&FbM`MuwAT<^2=F(mKce zAc<)v%W}#_6oh5;5%NoJFwdW5dnoK&xBY)3kHK%?Ni3d zXhNsB3Dz6PMeRqkWr73}!JA^iAKyJHwYLx5DPG>1vNR(W#6`?hZXXN$!uL;$~$Pk8H0`%<@V1fSZ|*{f9~Sx zS-HHH({yCuWhqU}Vx8VD68{JNfWqW&{E3;eCL$9l8+{`Rf$Ib4{P3K-uM2a7S={ht z5y-vRKOeJX;VO!XST2q?gBtS6?F4UV7F7GK*t)qjyhqvy{^~Q-HD9I9Du{xfZtyu9 zcsMaaO7<@o{+kN_ZYy_Ni-U4e6li5g#YeJDH!pk0+HFGi_DP_Ljx~~|K=Kn7i|GUt+1phy8(TyT@tG2(uno$(3<^Ia{`bF#=4R=TX*YO*0 zt({Z-@95^AIyzQL84WHP?)f1^VkkF*%bB*RAiX?otKfN>9XhH`(VY&i;~>MhUK#lr zMy=l)bPe}Pa<#e!h0D7$#TI?SOZg8(Kmjm#?5%oJ-V$FIMVUTdCt z+NY21uL~$^Z1(MlGy%b2I3a(ht_FC!X_YsmN@jT#(L#xB3FM*RcCvCJ`}9}CkCfeI z%;`j(2(vZCYfq4P zi@}ZV9aT)D?W(U;*1U8I-Ji>iaD|}4hFzSvy%U>xUhmkK+q!F!Oneea`3XeJ{u6yu zV*j6Z*yp+PmGiDi)gG~v3970WL4y{Ta_e_={PQ)X-bOIMPR9;;)*eV-kB=YivXfWY-hhUB!<{y&=Xk0O69o7wyi@$2I3&2=~ zcu$T6-#~go^3K%#HL;DJL7pL@F%TfGjy(gy@Yfs(c+gmBrKouX*yqzw>Fcz8ON!!Z OP5@zQVM4xiEA%%Z0U>(; literal 0 HcmV?d00001 diff --git a/assets/branding/favicons/favicon-16.png b/assets/branding/favicons/favicon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..4dfe7b471f627c0140d9cb9c069a549f8918cfda GIT binary patch literal 397 zcmV;80doF{P)2&d(DL&v15nLOQ*Kli$bL z-J9q_JU&@Bo7vo|5a4+rguwFypBRI}<@s$WnTVT5hX;S$-bXSKH$}BtyIx*dUzT4?~f0s?^{jX=M^tqu5+O^h)S^yC482V)U~Ce<_$C7k>Y z;tR-)cu>zKs396Xc<=-02@R%3MH7s~7zIrD1Y(R)ptx<>ot^PeqO|FoLbe?EpJr#D zm*4F3?93Qi>({_>>Z1umYptheKfg_-UWyJ12m*9}qtFx0b)!v~@a z_t`|kg8ePc3|{J|^JtvGf%5>ky=U<{x^TPu@QxhYSb)@wgVr!H=a6$1U*_le_-O_y zCF9SY17N+M#K<~`Z(lLzX4eg1t$n!vVv&b$mdJS;0N3?MoH|V;5+Rq%kjrWvTZv8U}&v%pqYzg0o=NMcWU&`y?Bs`2QKzc4PCwvw*Z`M zMlLQc1(_cwn~_lT7t}*msY%nc*cNRB%D0EF6&WWDKrGgTFep(?wa zsT7&jamZ>wh{_++bghyAbuYmdK-~dKHlbDutrQqWSx2Q+!PwB{ZP7X(uT}t0x-2Yx zFCVZ->GY49ZHu)zOJG@HVzH)*j!O9~FE0Sl*tioR%!>AolSWFnHUJ?6W|e>je`{_E ztLjEn?tR@$umw`q1K!uHH&B-REi!e)uT9rnXxxkk6lDa;KoERAs7NVNltR|s&0D|U)caP|RF4voE5K)fN3}4(RV5;t>WSY#D-jWZ(`U~9)$Kl;FI!O_ zI-NsL2{P=giym=cDL2J#Y7d|HpLjdZ(KEk{E4FGC1WcG_I`z{_Jo<@%z zKhaOlLUW!&D8%BEwA(F=@iALQ1VCDClpcY#`zE^6Y4!j7Q zO+0TVcgGuqQGhX{Nequ>bGC4dD2f>A!LDs8pF&duWivHUHd6y-lWjo#$KAH1To)ln z-4yuD!+___FgsKH(0VZJ;`(tz;SqLQ61wR;cr>L}AEa*xgf%G%BA$l{XNl(89vvz;UYKr_(t! t09r79K2YgDdpN)~px6{~Fwnr&e*jDJJf{6VT>$_9002ovPDHLkV1mh*uciP1 literal 0 HcmV?d00001 diff --git a/assets/branding/favicons/favicon.ico b/assets/branding/favicons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..fbf7eb17fedf28940b8e9a9f85624eafb4f846b0 GIT binary patch literal 401 zcmV;C0dD>P0096201yxW0000W0DA!d02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|5C8xG z5C{eU001BJ|6u?C0YXVcK~#90rIWo+0znYPf4hePA|J7_I1A|wCSqYUN@K9IvZJ)L z(Er24PX8J!4F*XxBotbjfL=mkGziMw;qF2&d(DL&v15nLOQ*K zli$bL-J9q_JU&@Bo7vo|5a4+rguwFypBRI}<@s$WnTVT5hX;S$-bXSKH$}BtyIx*d zUzT4?~f0s?^{jXYS&1oC%$@BRPh&8zcf-k*8@4wIa+*IsMwwbx#4?+|s%Q1{4h z0>42JbVTpE)@=yd2X6O42M>Ttn9#*=a5-S7ud4+yn14CVg-H-}8q(AH{Z2r}GA{6? zy>AGEK`nbX6=eGC=5ryfj{U|jtbaGo)8WyUGrn{A!Ugwzc?YbLJR+m`w9g21>?gC= zO}o!XM=s~EUijSiUP0lj!J745Dvf=MCSY#=tbFed4ny!fiuUe&L(}fZVnc?@f?`tH z9n9Uixq`IY^Tg~5gBTWkTrpyHmqmBx;Ou}warskRa4t`OtBDep$+to`Y}MFuqwu3yndS zwnEb^UFVSt+pfAeM8BWkkhk9!*`WaDy4K=3jrv~*#juuNs zFNy9^`5_Mub_RHP>ttNr{5=iPRTxt)I$Dz_xYExG><2Ahn|#J zRH?gF548l3kr?W%kDVJXfft(W&5|>W{m5PJqs$Si%=2?`X`3fo>b8>~)`Az1P_^47 zQHo(RE4W`Mg1K^$7O)q=Ju-g0o`);p#y>Xb4vE@D{8K{(50}0PirHJmMemZR&OhD% zZk#;pqqSLR!$juu)xbE}Aq~M~^_zJO7DygK_Azx8I?cuefb2Dy-$;&w%*%5YCV!Cp zY38@)0j2?fz95<35Wn+V;rChHl~N;bg_%Iw#!}IdqU899cZEzfFG+E48XCZ=f|mP3 znM&msqH~?%jYEE6_!rSJb@@Nd!{42dOtC@y|GFTa=nz>p3%)Ob@E8x!U`On(mg-NDRr*TjQDO!ZCHiW>SqTBt2~id9hap4nl9B4)VsX)5a>1?dh}2;7~F z#$fr-%?VPSU5Afu3+KZt0&xL5gVZRttP0!ZDry!f6}>E|ZTxkc`mlDwv*T53Och35 zDRg}>8;uDKHLSKMyUI|1J2D=al8Rg6@1Jy|I;&HnxI!`m6L93SqBh-C=SEELW$ze4ZDG_x$AJVq^ z&cZ$C_PfG6ZWQ`c8#4W;&f9p6z9Chnv?wc%*%Ja8dnIOgAv~SpPuNsw;hE3$|K1c9 zHfI_{xCy85W1`aLg#^olL|_ay$AY}IjBG6iXUoZu)*WrF9`6&-X~^d#BunKvIy4ix z%hSO{lZuby9X7#4MMj!7#%43zMb)=I4|#sSVp2T+URWSbM-cJlFrn8Y*jkNJap>rP zaGQrrtEf7KWHNKgDP+Q&1y)eMWCGur4dbmc!d=WA78)rl!};oNhLNlJw#FYC$EpG| z>2(hBc@D|?C&v#HB13pOO0p}Id5-#`V~8~G{+cAub^#qff?5^FCI-E0@mb>=Zt zZDXFu(4DZRxL1gy&~qg8?RVC;&r07UXNsIIMU7~9dvcsp?hU(U?S;D|=s#!exqY+m z;DVxz%4W`18*Pd^-J`{vc%E}C`lJ%tb8Yul2Dx|bZPuskhp;)494VfVM_xeZXRSX! znM9EL*e2C-Tn+^AD+oR=YRetPKpPy8o8>P12HM7n{+@N&tKWs<*k_X@U)*4mh zk4C>3HgoGZBDeX1QwJaDRG7sIedT7!3Ezi2Qq}Ja+gm()3hkY({2I}nQRnS;F`hxp zBsJ@!ZyR;p2KFKzHs|KKnT|iUyPH|RvlIL}D=ms!196*9(^VJlEjZkmFS}7XkN#na z7n)6syKR)l9ip*g!@;1@^6q*26VF4M5r-flaj3KvS1S^o(Cjrh`iOL`6^%e`?d)Hg zu`a~(n<%VcO)4uocoHHp!Nj+tA=JXRS@QGJEk7y)$gWezl&0!L$uA$h++~yV#mdQr zj9vs}ylYh5W4=>cU;?cz%H$JJPB`KcwAmNYW-g=6MIsi;#LF}X#@rC5>uL$EWA~FX zTk-OK<4sB$1K(V;HkO?_WI}hse>E>t-b!>Dg>K;jh83tru(O9(%09$ZFjT4%*%@41 zD>oHmDw0}`ndr9EyRO9~!>idAphgyEwJH(>cy z#JE(I^_y~g7?A|8`?6#K2x7@`;W;ugQ?2AKOw;faC%+6-kbi9SQ+fH};oC;V8Hi?K zk?q>B_%LP4it!0;;?<2@$!O(O7`ijUXzLcX$y;^aF>7~3ZcipV(n~hmS*dYN0Zu1m zCwTSGU`-(8*Fay(GwD*LXntJ}E~6=TNkCwvh?j(tnYe7@kK`To&IjvA-M1S}xQhx7 zB%ar6n)@O) zGiQyN7jk?37@hhyVA*6DO}Q(c;UU@?J3GBqq4NyPN$qxxW_v=wDz~nQ(W0KnPMfFa zmcDHU=o(0hIT|0fJx?vn>Q8c{hBz9u5bCfB4L4jHh2V6&hS2Q&P>~Tni%*zs~=Z?gBH|Sa@cnV z^%diLc^WT`?I_Rf@3EWLRX(}B_e&#@yHtHxUZL7j8V!BXmZVhd8@yvymE{e)udalqmsirtS*EmWh;8IVfS$o6m|QW28n^S7*-*j`7%l$PZiGBJ7<} zwD}-Kj$OT7((ywzKB4IF%O%&jwt3x+uAsMBEYO_{=-age0@_V$+DDZYu_hlxVy|Tr zrzXOP+3K59_o~0ZE6?!A*qm5{?O6j}UZwFiDZfsbxB_^&;<-$-y37SFNV4Qa2$D|`*PlDwDVyGEKX0W8B) zUg@b`W)QG>VCE`(s)bQO1u)9~YMhz0FhFo+^7%n`_ORM^j77pm~OD zENugH9+MuLj|MCVvgGK}aeFcFB7AFk{m_dKXkr8V@1 zkcWl}`L7#+=m~rV;*Z;d5zX@5Z(cA#b~;dzUZaFV&{r6k*Cv-%?TW`=THn?C{21g# z|J2M%STxD1v|J`t7Zzk0pEtsLWCsvUvcZZwKKtdmLF*UQVeewAI!VW)p%0nLyMH!t zANHC6S=6Q_0aJ>pIV(n1cCOuD>?H1qU9wF={BgFK@;oa;%dJrxT=-ux^%)KS=v96muo16W?-0d{u6 zHYUiB$iMM!_lw&3Kg5KM`r=+VAqDP)SU-*w`lSCquh zM{mL&*z~HcO=Gz)xcM>ItgX19R1;QBUe%u@F}a1|Fd_1YkpKXidQ?BlLwZ@b3=}1Q zrFlfSqU+qL!Ynxt$EdFEAd$fwvXncTsnztNy+eIaN$746GI86SMo9z5!g zSL=hESLfy*s#n)eo*PexiIO>>9MOG9=^EX0Yd8GEoBiJH-6hrH-6ux9dM@3@eEFnk z>2We4_(w8WPF5RweNd{5JNrTC>q(XzeVAX*6F|43W1!`p+HE6PUY;S_jp6}rU*CT`rFyO4n?bW{a66#$6K3QQz`dGlt^;!%TS zg5{uZ%*w^kPVFJi>y5qfs&{C}UY13=i^m7;m%Hjb2OzazFoEL;Ld)Yu%65%!hX(8W z6-7%Xb+_7yb4=4IOmTCSWaYXfnICcM* z*B|7xHq<6doeC|Ov$plsxDBGtY@#rE^L zgo)7?%wLRn7G#7GwD1S_Np-#zQ-Vnum`BIpU^VhzQgLy2sh%?D-qA3_WomO!o7FL> z;oIm-1!!(!)W=t3!?sr zP-uer(%s&!RXs7Zo93mmL;p1nklVbO7%`>#Y`Xi>UON&G3Hj38{NIAvvGiz!0T$ok ze4U7g#)@%wSF+DT_d@~Rh2f#9ayD5EGob|#7A@>a$(HwUxZz&s3pstscFdyFS1&nwcgXWT^fwY*~ZEZqg8*ukt z5mErSu_>=%_QsziLMfPL*=V(keH}+{pN@qOzc8$L|0eW z7TKXy50+Qu=#)oljAkhKz34>A+<>t_MF*KY`SjWwTqy~;HGZkj+6G6fVf6p_VQ&>o z$}e`Z|K7AA(=GaenZJU`I$@I3Uw8Wv3pDo(Fv(1v{hF(wxzF`xvn1v05K2AZ;`_4q zdO*f0-s}{_A3Opn);|b5pu))oHH@O<1DsT9q89z=bI;U-=dF$!n5fd0Z40twWr!)Z zJRLs5B&oA+_Cu-qU`f&XJtUYmE!+AkS5+DI;vD)ZueIPsv+^6Tyn)p^LSJ1qF`Ln9 zCZ`}2cUU{$&^3cLh~RdAfAlC{;;8p^6k&)shJ;VkT+G zL#yPKo^f>K9)q%h)g$9=g~?0rLUa!S4LAf@ua?Fhy){2UB$?9b{(+eeo;&=u`mi?x zh8i#wy4I7DkautX>N}I2u%Eq5lF@+VLn{5Qhr77_7KzS ztc8W$wF>QcRqXV&#LiJ=Cv?3TtMIOPD0WXIL;%`pOOvnoScJ&A>BvZ*RZi_UwpW#403i zEzcXqG_Khsfw7ia)Tp%l>`-IV-0jBK-40>r2;K&|2SF#rp-rTId3y8)s*7OiyD|Hg z(M5IHg2UmYVxw$ekMe`pUmBsewgQO!T^ymSKLG7~&yV%}TPr{>{-J+lQpSE+r@lsn zymZ~UHCjscdNHPbbo3tZFYn!P_vj-|>e&dL+i@|Nm`)FALs*{t0S%cV2+BMIZARMC z-GP=$uZ0iSuZ!S$O-xZOs_MJ8*&y~it{%8v2=9HZhLRu^9x&Rq2mbS0-R7UpR*2u3 z!ta<4oBNkOtN@KiHUdA1fk(>+~dZr-V^eu?BOiSyos{0L`2QKDm=k~l7 zeoO-}P+Rj_&Syp=-v+GPR1o$_w?4dJsESsNJLMAGAS6P6R^swY`vs{}ocRFD-d-lU zqy4X(P2Fn}p_0+(6VT5Dly?ssx9(7X3E>BIhn2#p;*W)w=(*m^*pZVgKXkzX;+PoE>WkDu#Q?uYe0J~i@NW|$q#4I#dQ8s8Lc zNELLy+S>9!#c;RY5|7J<;{&ZqE$9D4E&-X;kox?EfK<%{=-p0vEp&g79VJ)BzTu8f z5GAPULD0rEXyJE!qmNXJPki`Tgv*M(>XZ-Bt^ZMd=%?NKy=SLDilV9rm|dT%6(sB& z;HO#(TAn$KwTiq zbh24R2amAFnp%PU+`@tXnndO?HmBe-OkP1m`ZRbw%oHtW+aNYEf!p1AwirOt^@z7L zQ{Bowtg$;bM*5Rin8&}&Dh+Ytxc_9JS=02>hz^;)JqP0XSGO8h=I;G`{W;fj+o8Dd zZNE#$&qL^rwiJzY#3AUp97~Q}3u13PVAsh#rg*GLv`vN~%M}qupSyQ;7}QN}OE)ho zc}`c^@{L%BT_c{~AU-?g9z73Ze5+d?X}t*mqHA33ek_=2|0;dSqoRwu_yXsR`x#{l zCPplfyaaUT8)ulRa736|w*My$M$8q@_O9Bxvvv_y{<}v=W*Cjjp}tf5iwnQTc!zG6 zDF3_~CS+#o?FKl%V{o(miz@yY8-D8spq%SB(JQypnWBrY*U*RUhcuz0cT9shpY_;; z{v(@imD}`Ki`l7hKXnMYdJIyz4g~~6 zi_SbRl#2BiX}X*or+6D%B^#wzgi_BM>CeX03jvm(v3$&*Y6<$$oEXz*upG`=OlPW_gKp;7Z`ruC~!n z6x7WY5A(A^oQBZCX_g#?XY4MX-`o$+e8cI&YuzGpB?sKhuM9_f*vj)1GF;rOD(qBn z#}pew&shG`S%7yw%O14sG=(gjtNK z^4+m^owv4|+m7h`s*ue)`$>E8*-MAZp&`bbVT{rR+IRoF)Yk^J`=N#3ST)%XL7BDW z$`<9V;=?oh8{<2VruSl=Otkx`hWgEF1@C_HB~@KS;|5TFFzYMqBAX+gqo4>Up%FOZ zd|4%ICCagif8O@7aYo>r(oCsGh{SNI`2=aLOKou}W(-D&0V({6UK};r>v5>Gi$yDi zxIiQM=P}svvEyY*m>%jr2oe6P;7=b1bxX?RLrL&arc8Ruf)b;WgsuNO^UnN{n;kN! z*O9i?S5BtjWKZ^*V(5I8Pk5yG;$YMOi{(a4R>hinPzPMNY9nLv_Z*x%de+Q}zg3ww zsZ3k;CQRC9ion%ltV7~-E{o9qa$coW2kni|f~_BK?n|zv$&I21HaJjq^@;jEh)Guf&|qtKs7>>Uhk6F;u^qwQm`y4&VF$ z4}K>^P*|x8xv5az7@}C-0zS-S{48c1FCEDz5TBYAZ|5Z%9Yu=l ziYh8I7pk!|)mnMxniop&HEtwXWG}Kq!t%^Dz})+6&_?Y?uC}rZg8QHE4>4c;Tie$E z`cCP;vDf`~CnQs@|7@Q*=LMn-A%Ixw*Ug_|wsLV@D`qcE$d&4m2U-Yfj#a_`U+(wE zVcCXt!4Zh$f9VKB=C2bK|Hg*>-)i{ppFR0|JtCP4?w_qkP3Eirz{#C|NqU!Wb0XbA zypU((p=FG{BRBnfOO9xepvN7jc zm{T~U`GFYM!nZ6E-O{pGG(;?(hhRlGm(I;)gRfr=v`U6-&yl54%~+u^ z$!H4>&V)`)#x3G`&HZHZk@inIVIcNs$71ijbRcRsmr05~d&bi~-FEA*LHd)c!_J%N zPy2U4ns<^r$u)2D?ag!hpvqgQA-K=SnW7Q;7q(236WSa{;Bn+6 zOnrH-a2B~Ld-*Z~Q+7S)&wbE^hd{W$+NYTlfAdlaF z8zLNxx*@PYeS5@*xA3L9EM`ozmVZIS5XPeN2bAMQBqOlRN&9@#bl`15daJs|C;A^Z z^W^jd!1V40p8aDs;LIVPyxd6Vy4u~4%fwydt

1o~k*SXanBzhFNj%zHcw86OSYu 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 Ws*MyP4&Nc%=>W>v6G4aLGX4dEj-^ik literal 0 HcmV?d00001 diff --git a/assets/branding/icons/muxplex-icon-16.png b/assets/branding/icons/muxplex-icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..4dfe7b471f627c0140d9cb9c069a549f8918cfda GIT binary patch literal 397 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 literal 0 HcmV?d00001 diff --git a/assets/branding/icons/muxplex-icon-24.png b/assets/branding/icons/muxplex-icon-24.png new file mode 100644 index 0000000000000000000000000000000000000000..498dde26d394374d388038275b65966cfbf66971 GIT binary patch literal 535 zcmV+y0_gpTP)Fu3+PPgA4SdiY)T^r3RbAam zL?Ylge+Q8g5jn8jjngy>o6H=BHtoM(?`Qyo@X%6V@0?w<%lnN5?A0rm?S+-|7O-Xa zu-(3P%euuS3rx3!5WxIYTsgVGrBizuroy9_ntOL|Ga41Fu03Jv+(q)!XV9NMV%Hwd zRLMl(!SnA7QepK)%tl;r{l-n=B*8gH`1n40qfhYc>CCNCA|gkQp2)L24`z<8fenKE z-P>1z5)p}_H&QyMniObhExq0`HJ+k_-7L*A+oV8fZPx#=ZgrDMeK5ZpcJJDQY5T52 z7!G3q=I1*=wWGx-XKQ;F#|T5bPv#Z_bcp3ULk-+|OP*KvR1D*~IF z30nD{grY190FvaZH=UEJ&C??PIc2g-O(j*PQo-C@hqm$m8gcRkfX>1`FFz`B27_<) z0(Jj|s%8DL|KXUHu0*-g>+En7A0K>n@0jj{IR3n&&#wZCbn1v{G0tg~h`a`RjSGws Z{QzM8tT1z4Xtw|W002ovPDHLkV1i%L{8az| literal 0 HcmV?d00001 diff --git a/assets/branding/icons/muxplex-icon-256.png b/assets/branding/icons/muxplex-icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..28eca39510093e9936e83e4a41acd2438f0af7ce GIT binary patch literal 3695 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 literal 0 HcmV?d00001 diff --git a/assets/branding/icons/muxplex-icon-32.png b/assets/branding/icons/muxplex-icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..8dd589285a23f20769365671656ea99f291faf01 GIT binary patch literal 669 zcmV;O0%HA%P)=M^tqu5+O^h)S^yC482V)U~Ce<_$C7k>Y z;tR-)cu>zKs396Xc<=-02@R%3MH7s~7zIrD1Y(R)ptx<>ot^PeqO|FoLbe?EpJr#D zm*4F3?93Qi>({_>>Z1umYptheKfg_-UWyJ12m*9}qtFx0b)!v~@a z_t`|kg8ePc3|{J|^JtvGf%5>ky=U<{x^TPu@QxhYSb)@wgVr!H=a6$1U*_le_-O_y zCF9SY17N+M#K<~`Z(lLzX4eg1t$n!vVv&b$mdJS;0N3?MoH|V;5+Rq%kjrWvTZv8U}&v%pqYzg0o=NMcWU&`y?Bs`2QKzc4PCwvw*Z`M zMlLQc1(_cwn~_lT7t}*msY%nc*cNRB%D0EF6&WWDKrGgTFep(?wa zsT7&jamZ>wh{_++bghyAbuYmdK-~dKHlbDutrQqWSx2Q+!PwB{ZP7X(uT}t0x-2Yx zFCVZ->GY49ZHu)zOJG@HVzH)*j!O9~FE0Sl*tioR%!>AolSWFnHUJ?6W|e>je`{_E ztLjEn?tR@$umw`q1K!uHH&B-REi!e)uT9rnXxxkk6lDa;KoERAs7NVNltR|s&0D|U)caP|RF4voE5K)fN3}4(RV5;t>WSY#D-jWZ(`U~9)$Kl;FI!O_ zI-NsL2{P=giym=cDL2J#Y7d|HpLjdZ(KEk{E4FGC1WcG_I`z{_Jo<@%z zKhaOlLUW!&D8%BEwA(F=@iALQ1VCDClpcY#`zE^6Y4!j7Q zO+0TVcgGuqQGhX{Nequ>bGC4dD2f>A!LDs8pF&duWivHUHd6y-lWjo#$KAH1To)ln z-4yuD!+___FgsKH(0VZJ;`(tz;SqLQ61wR;cr>L}AEa*xgf%G%BA$l{XNl(89vvz;UYKr_(t! t09r79K2YgDdpN)~px6{~Fwnr&e*jDJJf{6VT>$_9002ovPDHLkV1mh*uciP1 literal 0 HcmV?d00001 diff --git a/assets/branding/icons/muxplex-icon-512.png b/assets/branding/icons/muxplex-icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..9707b0f903f548bd0746580299f0256e6f0a233a GIT binary patch literal 7998 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 Gd-`vM`NPNn literal 0 HcmV?d00001 diff --git a/assets/branding/icons/muxplex-icon-64.png b/assets/branding/icons/muxplex-icon-64.png new file mode 100644 index 0000000000000000000000000000000000000000..d7da4791ce477c73b6f859ed87ad04d97dac513d GIT binary patch literal 1136 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d4075e4d9ca6bdda62d401ce3b660d9ea820a15a GIT binary patch literal 5169 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..7546c456ceffbd4954733b7a0e75b97aa3b85c7c GIT binary patch literal 3155 zcmV-Z46O5sP)r001Be1^@s6%>dx+00006VoOIv0RI60 z0RN!9r;`8x3 zd3YYq?;Otg-QRrg`TfqhcMuE)g9eacgAJCUmE7$a*l>Tq?ru-O2J^@%E$3{h1^`c) z1D(C)cXd7{MA^FS7rx!N`Hlta*s%lezBB5M`G1hGI`ab4_MZgci53b{`bFQ>`Ir!8 z#i}*snu#`&YEK*%6$qGq8JPY9(g2+CwnJARVmoXK;i&^TCKsw#TkGr z2BUpr;pPJssZ`9EIlEzIZNonf^M_eYA;Ry82z$o^uz!C#$;m&Fm30;^0-;6rbSbh& z%Pla)hWi@k53>>}Ab`ZEmP(C$l1KyuK#DP?*l6mVXqpa*;T^W@Zh5!QFia%OKE9og9lQp)fRPsA2`U`b>9Hs?d?U%=49u7{n}UKu00co`(!{Z1{A*aUbU7z7vqbq{Nr?ou zYs;Se%dF140DzCPBU7UTS)P&0l8kelE!7~ilhQ>YXHZ}>o)7T7t?YdGVkuvw<*+la zh$3wjUNQ&bd_5T+(waw_dWd$Hw3WP{nuY%AeXVeIU|Mv0lb97XdM4~Xj#j4!KxQXl zW^6EyS9g>w zP1m?sMhs6v5Co#4BIwtDAbP!V@p8{!VDqk<27~bv=+irac5MRy z$SN*lX~sDK+Pb@N?uv#rS*Ginr`EFNOab$no?%m;aGEx9x~a7^0Pm+}^2vd0^oH83 zexXdu+N?a*W#uudvmbNfIv@bvGDln;?D*`^Y0=ih$$_^!`-$?HKQmaAepZwhck3jQ z1k4cSnWvvJRy_@jB9)2_8#dn1uDG~_VoJ>FE?&CCb1w`+by;OXlDar}=rAMR8pE1Z zU)|O`4RsbizH1z#-+GfdbLRuFW9RS8U-%`XMh+)msbu)b(W1TYLrq!u#peh@{oHV> zSk0+o%NcdA@-kgF?PlMAI6RyjEZSYe)O}e@|NE3EJ4z)qbCHo>UV&Cug8`U(BnPP= z@b5>10hk`$o%zFIeSW<`(A932$T)~BQB*|PS!vP;St z`Ny#v&gW=RDIe7#$>8?QQ6?sE`jze!BqlICq_wE~{74S_3W@=6kV;sW5QeLRF*j7! z=oz^E04FY&G4!`I@o!qV%J`~#=X!Ciw(xUv^Vzv`zgbx2u zV}TIj)tFwbe0XC=8p|?r0a$-BkEv0C_`5Z-=x6q!9C5qp>g&ad9-Wa~&0>um>{u8d z!UeUKpUxDZHyD_AB!{S8VF38MHDXbG2rs7`28`qkTfYmf&RCpC1%c$Ap}0HR*Nba4 zqf}K@Q(0MM_D7>Jo;fNiDlOVnR#sVEJadrC$G&qzzwd>&6-jap0`PfUN90#O z7z42NT%lDz+jDF6!Je*cKCNV9cD~6^TQ?WcX4|$(2@*!jmUlGkn!$K2fH zEMK|^JG&b%W6VS5KY&K|Qlgr;lagyB_URJKXBl<1jv{UC&9^B1tjXEJ9IjO9DAZKa z#JQHF3*$pbRbD2$q+A?}^YvtMgyjr;Po7cCLAVFndzx~MiLBGrP*rv7S@U|-O=1!= zhv93G3b(fkcGvmT=?zrtO*5xPS3`~7a$n5qtzhm1f4@N@slA3vmx|EoboDa8wO$FB z-+BO6C$HnXAGVl`snu#;O-$mqU$)}lP=8;qi#_=&LcAK+8`tP+*k4#I%5CJ9&nGfF zDGFyt3N@7g4C~N}8L>fD;{v!{cjidkk;b_zrldbuSimc{?|%5vs*jVWvKcpF zvM9^ta~^t=^tA4@yUT~o)7TFOIBO{8i05II!6yE)oZay92PERG)CyIJ&;r`8hv z?Vo%Y8AxY^oIJIb`DthQ`D}q`GoY=n)rtpTM5i{aJaK_)y`Hiv9ns(X#h5OBbntA< zsS-7_4rWuJspRLg1xN*feFNeM^Kk>hCANKb2z{$3^+mPo@#nK9I!=<`EP3ILd+o33 zIb>GXsiYA;haOA4LZ;Vb2M`*q*=k`}LR*9w3rLtE*w)_Je4yRyodgQr7nh zrG=}E)jdN=_#qX&!9by=k|DnyX8SYICYi|UVb)sNdo$&6MMdQeFZ`dt2U8~U)RVo< z>ZDRBAtAM7u6st=wmpSqU#$@3zEAZL`#WAB-}N4FQp#{6}h4Am7;p4YV_?LAxp z=+VNPv7!E=E+zLOvk%v{`{5obj{^eycy-9jfbqedIYdN+iRs_c8+bi?JWk6N#;x+< zPPKQZy}dojtCsWa#?7o;olJIi4ibrksK{_ej~anWb(x>HZU?~6&)C`2>-8Ksc!*a9 zzXTYYd>>4iWD>7yTr8i@o=I9-x;S<$<2ao{Oy?&N1lA>l67!G;%QA8~T~dzR!H$Q$ zjSmH1Y}c$_wG*mvV_1h)00eou*1OiRq5kw~?ZX#oXV|N}Ouo7TUl%9BecTz-#ZM#w zz<<;g)aVVOzviwohIVLa;_svAKy-QoWmP%=a<6DmSL<-GH&R5X`*n@}FTBXJ{hk(| z^<-z~THJcPz1`{G{|Qo4(>Qw6eD5MC$d9-wO5VjVLzU3~c|+z=oT{e*v4Fr}FuPR$>4E002ovPDHLkV1jn7EXx1@ literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a7c1a905720ad73df55df8888c4fbca585e928e8 GIT binary patch literal 5965 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 aJCDHm1Fq-s3Ls@OJh-EGyF}~htN#XTZca}C literal 0 HcmV?d00001 diff --git a/assets/branding/og/og-light.png b/assets/branding/og/og-light.png new file mode 100644 index 0000000000000000000000000000000000000000..e6778f9faf1c0644c43d5fd9f46781b5853244cf GIT binary patch literal 20635 zcmeFZXHZk!-!{s9i-ID81yGS9pa=*k2q*~Ls5B9fE;S;(3kZ=K1p$>NARr|~={>a2 zAvYk>JE4bO6MBFEY476yJZI+2ob%56;e0q>o(wZ&va+-HUhB7h<+`rl{_;{wmHFKD zb98ic%<5{-bm{2mOX%qS_GbJWeDW{fIujk;%Qx!Jl=R=HZeXFW4feTYsGj9#)yH`K zx~?Q$Fsmj`XIz-7+srp`G9~_usx9&}EI?h*H_NTQD#3L=ra^s7U2T^Xyt2}gap_5K zN5e;gFU6ok9EFcV7_r zY=ORNW#tyzyM@!`a;}(r6Sj1Bubl=*2uXhWq-$bwyfNlZSnh$C8YbW@a`OwjyA4H6 zUCTA2D<1Hi{fq8*@;_?(6nsYx&3mXQoI_mQi(g;0z);k!?#PXS=iNW~oPW{r+H|wL z)U29$v>2UeA;j$3XxozO;%klaT-a{Mw;36VuGmY!N}xOJuJA$|BS>5YO8xOJzH+;h||LYOZ(4< zt+#IFxqfUf6cW?&0s%2IR8QrLikA05l3i*7PHgP+;2?@ce)m;=9Ua}zN>;`%D)1>; zBa1-w{JPithDZxhFfrRq8Y+L$(Y<-jOMfyQA6e<>c(duB(9wPPaf$K&F);O1ZIDe< z^DeiMj@cFK;j=ie^Vi)tGi_XTKN=K_kjaEpn=V6vOJ9P5YF#EPbM*x6;)Dy&UgBXF z{1bmhMC6ib7DJs)Jh3}>EE7cblGCh=>ghH^mo9UkI|n--#JfYI87q4S|HKO^btxGL zz0}n`UP`n=lUz-dTWLLR4h|0TzV9XiNfy_xtr-aI4&y4&MQRZJMk*qUx%)1<&cbK( zT`4QwhpVO5->r=w%HE&X#t+UOUuxC3Y(edeM8Qs-JzaK{qf}y6*gB1-NcK#MP!N0+8?i?g$zIr5ce>v)By>+no zk7LJsq>N6ddK#5OBjJk6Y5^06*wJjTxCnB)J-kV9F{c@O{uzJK$c2BaTozfCpAl0Y0JBc8@hp}O6>JgDC%YLWs^S0}p z;uR2GLx_#T^{9fNBhI8crXb!|H$F$jdi; zHF|Ds=eGU4#dpmh(r5qDNW|ZCtW1C3lq%*!yBr!xJHJ8-vkChc6ZeGIdCv#!@vh=x zed*}tm3T9kF%H*<@%1~2SvI$mUC_V8y;u8dg`XPimIZqX=Sy@{>lrFrDYBp32eeV( zY3qm}eUplzvth+Jny;=Yv~eOsHD_unwNvEEl|`?OiNL3jjc*q2PvDU+E@hu?LU|R` zj{<9!_Wb*%`aq_3|IX)5i~gS){Y#n88`F}KN{Q2_hK6Jwb|ixLeG2gnyo^Dngf2hB z+8cX)J~oqf#^UGb{xM_xB%rRGs7Q}JW0Yw_=^Zs$Hz~zNDjjbHILP;;$UIv7hGjap z8%WAJ!+h?7oO9owNSTLC)mqqRt)|SQ+&?ICgil_V*`A*36e}{#3J3*8Z_}>kb?Ud8 zGU;j1@+lG93ry_M+E>JxB?v)57uZA@PBYgUmOd%Bjf|4_Mt8*7y+A0`<=Xr3#pFU1 z(dKmta9NbPM!{eX=l-Q8&;2e1@uK%WUT??~zstV<^f4(hH9S&(v}9_qc0-SjZp;H* zzLD0#PlCJB%0hmFm?T&+r_qD>b+*ELRc?v-7k){bnp*6%46-S* z7vH%nW^P#KZLIE~ZtP=WtJ1LEJr%JRR2Xrv?;$tZ*VXg(>pM+jo!W!8ED_kNT7SoZ ze6}nnrf?0T@5Q1*B7IugU45N*+dFd+@bQO&1$GNTo6H2Jviglr6Ybi!OP1~k2~n&M zb9e$ClNYo@wNv-Zm0SNrm-lq^ZjLnIdrvWNdu;zT$-IhC6L75UXbn%S-|y{*hBgv6 zs;un#l1$@OahQV994K<*%-#$P9dLZG2LE*X*NAXI?o|c99awhvk;nAl+3FNPzWGg{V6@O@Cktjo+OdjOkdTpqK7~-`+B&oq3FL5bn%`Wr za*DpUru)v1`&Mlc*ZgJ_d7Q`7kD3^G-cJa@R!2}8DALro`T5vHh|hk!kp*dwaM5YB z7@KJq{`IQ@b#W+K@o4WrF08^4v&$mL4cq0>0>}F-Y8JcwiAdt{-*ewj>*?!~!?m~Y zyVwO&iOZAW?Bd1K<)@%izq&@?1-qSsX};MdCAq#Ch2o;38-({fjAEYT;J|U< ziZDjuqgq~-WiJk#RaDi@_X>nxuC4f*dBvTV^-?6tsq0TP;>Sry#W+dNWlXgfy=t7F z*C@_CSRiedk8Yi5-no2x+GkYJ0=Cezy%hkAgw)>>El+O$&$xk+*#y!J@$K}ekeHYc zwKdTdy|5|V%V6j_QaD~U<-KD5bo0^9;R5v?#-^ugLfF4&M#_8Y(2+)T9Ln|@{Cf_J zbIC7H;^h7MzRBfZW#p7`ys}&e4N#;#Uf7g#i_Fx&CDt&HyN5uN`C`=S_sByA{yR$u zt*Ol1%En(|8?V%PQw=M2w;;5`ZldI9LKx+~xR40Vxp%&A`EKJeLEW#)!Na3!MD?9x z417~9ggKuvs46g-xP6G2Nm4-H{Th9BE8%sjx{gnUgJ$uv7sI&dde_xGph-yeFk z9URtzS>$Ekj>_|E2jl&=C@kJ%4*jB4SYusXlgPFlEx334oGK)62@{jk1Szy=I8GyR zUoB}7b8C>V2o0U(sG1^=rpc!#@jaxaYuNQh%xdDclBG9tx{B*>$Lcot5o_ma?VlU; z<(>{XWkFv2gADxqn7P>P;HwmjZ0#Q=IE}U%{vNrh77;yH&f!KI+zy^O%vTwj70+fX zdQ1u~4M5NigOix}?~?xAob)ac02NVUGD0xw%{{e$I*nyVyr7( z?fFHp#3e~dk32^#NL=qQQlmvCsQm0U6FoEkE*I#93D21?ag7DXdTeZL(5e0C4d{f+ zVGd%{%nkhm{kmyK5boQgoG1pd=!ujsMX7g1c$Ss(_$_`^tE7P=;O+-UDYfwx!e7HR zw7C68zOsolCw6vq`OOc6m6b`fB<80H-tHM&TheY5HAzUFTSf{u?v{U%M;yIf?PH3S zd{bob%FOqG!HS__cYKtPaNB3qJv?D!A8|2^x>c~&m438u@hyI*nM&awr^>h)>Vm+7zIv%if-|Ek8&;GNQ1vgMz zN^Xt&S_T&N9!s5ZT8}7hGFX!apH@e|BfYF)5D))sys=B!1Nw>|h(x(Dh(gw!Ltu_d)t7Phr`OLmiO;M8?8Xj_kav)P~ zRH(k&kNOSE>A~-Ghef~mVR5-5=j-$jX(W-rgFakXSIcL%H>72M#PSJA?X8@KMn_Sh z>mfYRq(BFHo_Ttu?L&(O8Q+JO$qD~3I!jyl?4zur6n#2ktzq_D^i7@dBHE9AwwYz9 z4ZDxB#yyueVcT;wKf*?)RAnOqmR362$PjpN#36J|{=e#RsvvCsk0~970*w z5l##XI0T+hSr_-Y<-#LswH2QbDz8&mSnGx?ZgW6SPvTe>`66uyUlTn|cCa|-r`CvC zVoQX{?rhTnr0i^PEpK()*LJodZh(7Ni#pp-=++i6@qig3$Qb0L;xFS7Xep32;xiX! zto|7o}Ke)sv>slhFA~;u*iBq-WU1nao-y>GZ;S=ieNVtlDr#U)8mtn8LwE3{Ovf9&;at z@H_Jwq}zPA*HW%hWNa4x)F387?J>%2#sxvTL%#iQY}xKYSk#RG@=tOh!!m7yGhD%050v<0}@`?{{LqQRtPvB_$oLJ|(U!Wi1bGdn(-E z>`3|+lgk)1siH-Fvl%;jVWQYl%P@a3Pf78vd1;>$zx3MIqs*hgwXbjs>bq4-IoF|T z-WTv9k8;FB&emT@*hMOToKT2JAI*Mhh;+bgoRe8dK7^`>iF|o|?tu(HC4_RkQY{~X z+bud(;;>nb=XttW1EVM!SJ>}Zs@@4Ff4QL5 zySo~EYjap)sQmF^+|BSQ?>4MXkwU{+gv&bFbE{y3I>Hn7&a9XiN1DM$tVG*tLq6LJxNxOD%|PCp%05UVd0McUezjPX#1J zM`ARpv&USgzRabD0j96s0ZZi*B@QJXA+1@YMr!MJf6{isM=VLIh(}qq>4+zii6DA$ z?&yK4x1+mZpLfi6zDoKhA2bbhbljU-6N^oGug@*Ms9+bj@Yv0>yM0tL)T8l(3ks@p z7Wuc3;4}LzRXS#79>+;5#TMppp%a9o?;*Z^JKQLk*B+#Q{4Cf+_SU~Bn710tX7W70 z*18Ng$XnX5&#QOZP3B2iA_svkztb@Q+Q@uvh!mAuhe0$=F5x`jg?kko2V025YfYhf zOShb&VRqK)s0+>TIuA{KnO7AZNW!W`Lq_hiLV!hLO6>XBz(e<410#r0Btsv1HFmpg5*VzSz$VzP*_z3p#0}(^bZ&&{ zWv~|tPvaH&11&5TMiIB$z_l@;ca~zMQdF;-**UncDIg<%TyynuS~>RGnrSc+phTFw zeBbj~vpU`TBv|TAb8>ZX5goq#)+6n?e1n&DJsE!k_yo+}_zL8P&)!bVi{9Z>*c7Qx zC!;O1He7V1iGl0wpIJSAq5Z|7zQ%(Yn#&-6^y6csutju@4o51qH;}Z+rJg4`R>)Ss z@}APwnqu(z*t}pgfom6y%yte9e{F|+<=IYaG(kxZyJ7WeQ3`oZ1!~7NetX}c*=`fY zZ>l0wcc~E}F%f|+2j*u`jyKeW-e~dC=Qc{UUi9xY%@`>*_v`iHh)*zicr3@4^u2a% z3kKqq6Maa~Y`UU_f|ZJacK%gG%XtJ6eLvQnv^RbbiZA|>In3@h7`p_f$6%lR+z9td z;KPIPnv1FGDVJu%?$*A0zfYVhCY)pv`4Aa@Nj9aoIFi+}fluvLc#TFe%_B~b4L0g3 z$_c5%j>LTb`y=aXCSpr;9KvTaL?5cd8B&LzqvgiM5q>YQjOOVH`sbymr|X?T7{u>2 zI%4v{dSC8Xx#)!-30R+FC@U+JEyx#N7OR;rH}R!v8q`1bInbL4k-u4f36x~t;Kmt7 zG766ak3sNc(z~fBvT#H>wo`W3ZV57I6-3MB3p=HOno|3!tfPw$JnNKTCr6S}PzML566I=MH?>#A3Bs)7-VakZv{Qz79Xa0#n3#tE|AiXD zNH0A7QMjVQCdqpOREU~h9GLq~tvFA`8yqe@DvykV{VII?kqQ~wSM}d*eq7?vZk%* zFWbCHaMK6Agl}BS@S2$gzx``d8JdXO`{&B2TYsgUHoi6~T?}<3O0#T~ZmdflP-Z{F z58OuY=Kta-5_`1LQhGDoY@}*I!6jK`u+IzPPs7w_#n;cy?F>PZZZsWfE3u|d7Epe1 zRe~a{@N!dA9UbbqiHS)#dk^O@67upJ+5?l{+?@W%b9uOVlEb9Ze)^|Ua8M8#<1})G zO*BOgYOycIots`qbDvoY)<#LZ4X`YR9~Rw&VyPGze!HGzt}-nUsvfOaJeVJ1;goVb zoJ3OhhW41m8}BZ%;@_?IQycxH6*!HZP`$`M9oO&D~C^6^xK)x@>PI@b-RCJW3Ux zpJpGMrnWv%yTe_?84Y~JK-+@qv(n%l*wAu=jFr-lXNmt6>8JYwu2AJNE9%Lger5bg zy=PBRBZr?2Pc*;eJF_4=`9Q4NX*4b7dk}>2)4%NLHsQ;vJYK&o{5A&wHbEwn_tE;g zy0Q*~P(~EMHUeHw;B3+;WArOOk#m+M>&^iTim~5 zV+TRhvOC&+X;fMi7axE0JA(zFt0Cg?*4G@qWXPhfPGxMQ6>;WJrn&QhE6=VO*Yqrh zI#1tY3<5ddR?n;LZm1+ANkTAo!xa7A?wCB9-78;jMPPIqNtLo$GV;IPG}0DP@f%XP z3!M@BnOc3a-znNK|eU9#im?W)sZY-lGO{r%@m2I0t;%KT7yp+RwallRPy< zOMd7&454{oi|f|+3LadgS28rT-I{I?d$^Sv9oj%xTH)dG-B$XNxz)Ngg6fjgmX~%Q z=cdr8iCF05zL5jCw}^;{H2GSD*B^(i2Erd1NTBc7%>kLC=5Sh6B`;dyZ5vl{JvmnG zIdcG!62=|4ZUj3VMbAk1c2~s=qp5vH#Il1>$K(t=Np5PmqO)YORs}7VmoRx}t)_Go$Jn z0=;-l;P2A93h{R4@xD*3UEJ_{^42jYsY-=g&Nt=%E1ljcP^@2!JA0W!b^E3kEB#Y% z{CLA0lE!kO`7-0jo>%bH)Ja$BWFi9Qf07>>Y%o73x|$<=aHW=8jpH`>UMcLfY)wrK zblRK1fe3iF+QeW`+EFA2^*!!rn+_nMx#YY*m&CCslHX(Ud)!8cmX|ua#K#?8Kj>ma zqIcY^+9GL7JYfOba{)B}d$FBJ!xwuPEHqd|}!KpU`PEAM4*K z1)JE~KxuNYXWt`9`GC0Sn5+HhnHr=eu zJ%6@M1=9_(YVA-My8%j6X&CPj%&VrY4cnVzxc(rQr%~VCy<=9g*En%*r3V2!@ZnG{ z`&R7`cbi4=z1yx}N6T2LPs@G5^RnrB^x=|F*8%(Pv%!4+RsnrK^13ueR1AtjAA@jK z_Exh}yq&U`3vcOLD?&QB9x$k$Umw4te>)YRGc{W+)9EqsX$Ry$3IXOun#ndXHt{CQ z@rQSH2J`-CbjBF-_;WVeveLT{JbA{;p#oX6RqHo1EANPiq&)Uf(b0L(B`mGZ7h^wG zV$l{=By)U02&E})|FXZ-3h%i1RdlddZsV}ITzjN|h!06LNQ{FA5aCsoPNU1UoK*Is z&;~E>ai3X2-{Xtf9aB?N3|r_NuL47Zny){FRicl)X_SL6nO8Um^KHUOTQ#KLaSUPN zQXu*5Le?%*{WcXyseorOyVCfYFXsJWlV6JT7u#6DM;DxD*5glAg}sSx@AR7cragS8Z|P3I zl4O&4D{T@933gTu($S-T;uum^wmS-;y`5`jjN&S&6w0_Va? zMk9w)!~STq5Dp#WCx-Wmpnesu{>If-=9@euJpuUR5>jQ1^Zg>mASld&wGlYHI+U}2 z&xay@q0^jvthyqFCI%E1asCPq8=DoELNnuF=x+maQ3rm5!J#3ZfK}?BNKR$bgD6us z$H_`feaxjVzfTb<8R~KQ)BOgm$wcZxFMChR=C5P`a@8LZ_#M7ZQtbFHu`@B!JBCLPKe5RArBUA*(@Qo;hgG%r;;&fhu2kV&qKa52DK44hlItCi&C~l0Bktk z-q(koFH;W22GrWn@-QTLI*% zdRXPRWcRike%(5Gn{O`Pz}B=*yTn3wd*p*F&t6jy37Spnw=*C4bnjz{fdyRBw=%LM zsgrA>)Mq9eqZovriCcMw>97fq3z>(nHO3Yu}%Qo+Z3jX7#_p_W|=sdqrQ7cVjBu5}(<&Tbs1TP~l{_(T4uKnjGeJ z;4-;4sPIc6prF!^VIPO7&fYxwFmnlHEG@-yTi-wFoB>eI@(x$!jM&*YtvZ1v$mxMv z0Q`7QqRLXVoSM`8)&rY-Rs?+dzX1mBZmcJ*z>;Vf$so<*H8~lEy2~1xbbqo=(@}b0 zVp?p6CneUY2PLL7+x?898XZ+HS9jY>RjyBF-}t`vEOkLzuV;^C+vT2Gt=d3o z>cjQZwYMw)Rf3m+jq7R?v1tTh{etPRid8!z=7OQX-?$io^kVKS8^@hateExlicUPdXD7XHj;y7Z(h^BM>v0?h8ju={5Tg zdt)oD-@y|^lV@OkSV8h!g+N4nLc#$6@MN5lkK-=MsQdDj!alPF&v8wc9s#5XaJD^e z5gj2S!a+Y1o^1<-U=+1(GX{w$z^@tzs+T*ujQ?)!@{`G<>SUoYd4X8i<}`j0s;c_R z^Qpw(rM`_(@kd1ee~*C}N16qgs&@08{c}$m;&LJ4>U9dZ(P-eX)IIw@k`VU~hXu~R z1JP~0l=M^J{FS1OD#!jif+8Wz5Axy~OuKMAq9P%AC}a>ZIZi*)w(c6Jw1bILm$Bp8E0!L)e(*`n()3F7+> zIqrlc9jpalQAVSytWp9;BvhbN*fMkTV}+WL-z7rIsH`~ai+``;cPKb)6G;KYv*qsUm`6URJrTL^BuwV(N;LvUJjgi z8E9Bk&0Jvdnd=A2F7&NU(U^CJUhY7>S`l3)!DAc*I`rxhG)l>Aqcu zz$At9bwO>)uNwU&-z0xHmS0Mjq^_jjsaM4(8xp%+bplQ2K95gG1jYGP1F2kTpo`Ld z-Tk8!hCUi$WU?Tv;M=sv$YPfv5S@)57EGQuG%Rdgk~TWNEY<7zFt0R4+lkgLrF2|m ze@x48->JVJYog3c|H0a{#$##C4sN7-Y3a-N*4A4&0NBD|P)l$29A6nSMXj2lDho3U z>nEZ|V-1i>tk$DrTxU_nk!N<53pBKRq52gCx`~N}@`0=E_hvj;EpEV!fil_Le=S}w zTPw1Dop@49VMdiLq#f7`Pc&go_3=*4k1hQEwhT6T`$T(Js;ek3*b&g`+a>qQjXoV8 z9Gdg5a9Bvr*R^*&!2sCww|;&x{5?o0Ayi@kFsP0kL;yep9CP1mrc_w_KMz+W_DN=H*VEsZ4R zY#hb`)pWOb&Lxn^kPSiJ%mv)}kA&P?IUASwfk9t1ZS?m6FB209_Fq@n3>2RTz&cj< zWvDJw)jR4}?T1(IpRoD+utwRlR?4dY(qu;pIed?c&p&f6TthJxl?zt3L(MJT$aHzB z_5mZC=Fp|4D#7#l@}_)xtLfT*0?_Z2FP-s){b`m{(X7dbtq4S%#+O@P`S3YDF;Sbe zmLqV!!n$Mdmd2Bw6@ejAi-P~^TwAR46qZ)Km7yIf=u)lr-9W#dNC>(jy^TgJhdkOU z1-cWIoo|j)3;A9&M`Co+F_ z2-V$NzsEWIggRbn$)v^#iHa*)uI8qjyrrEh1$Fbb+@-nOvI-REXCM&Hm-nF=3GWBz zjR6j~W|x*icCLIq*Gv$v7cdCRGwg%q^pYIL^S_^`Y&55TBgEBJx!y34j2W{mIAh5T z^Xg0uLCx}g(>FG?a38G8h(q+8oR$#m7WeqhiRN=#4mZN=CEdd5)@Wd2vf!3UB^+~F zajDAH<*7I(19v+A&BR1w7%xjGuor1YbWL)>Dwx1X5qxC{+YnD25JdU7jl@dZ3!e_sYzi`}I&6W{P|LL?`1oZ{y>STqALP zAjF$ZnB2T8j%*5^078Ozp#s2yeg&Kp8RevRlu%n;z4*P!uIN?iczw-XVX+3F5b3y4 zT3zT5{KnFp0`J3gJpxD*pC_L2xw)lmR-e5%5~HYs(Y}>^rPxfbg($0!t>xr>5~%p; zdupB7Yp$HLdLq`gwl@L~EiT`FVNrSaw43pzFVBL{oSt+p_gccYNk~dwQ1tKHS#a`- zV~zhN3393zse4!vWTSWYfGR+Uk^b9rs&gEcx92F+%CLI!3PKOP=QL4)C_WrQu}s6X z^EvbK1qFK^mfKBo#d)c8LD0vR^Ybq`Ol}9wW)>G^-~^w5qA+lD^`)daG%DG5eMGd% z#cZ4*`(~uMenm@oOIPqu*wC>Y%D&n&T;)`XjI)`bR8{nFn%q3v{MEbGoXcBW6`J{+ zmR*9Zx~9WLrgo$aKfoqOw)bGqCx23V;M~tEDCei?o(okrBCk!DdynTG8Ja3%FtQ;b z${c3z94e%on&Y$;NfR(yB2J7Ny^;oVh*qlO3K+h4LUjSX3;=mmxX`98$>_LkRGz0B z*P}<)Ft>3r8ft-~>gJ{)=RajFE#=h9EfcJ~^HP$y4UF)n0Kl1=e|xZQS#T3-Ni-7VK~*zW^DYsqgFi!0k8f z{kVZ5<$-*xuKK-z@%*@HF7u(T1C?I!#}4-N;iqW*C<6h?vtnqRuRLc6e1ivwMTUv0 zqrYKa26uNeW~j?mDNEqC>uz=NAPxx*ThZgkd6uT~vU}}SjocEpI3q$r!pP#2y%+)$ z^4gBMK-OT!t6sf}^ub|~Neyo_4|QLj1zu}rw9bg0FxR3;A*T|=BDN0`3TXZT)8e7z2JHIp9%;vFVhNM@4oD`CDCdLRa5gpj8= zmx5(jV(2JrdjkuhkZZ*oIgbfJ_AK?r7G(#AzxXxM$0|nw0!iUNW1FM8E$s)&EHGOJ zmB!}WEw-w>^s_`zsz}DlA^{0g-dA_W|3Y4FZuMp;TH&xiBj%k!MlprJyde1EJVX#k z>q^6Dj{t7$TDRmjS8!VCo^AISddA-gpu`Kmc82FJO4@3vnRXO@k_R2RKF$K*o7>B!3Z(ma-v6_M)iFW&k z7N`^N3$%(nKHWP64t?6l7T|!j4_h3FQ^bGtMNdn4Z~O-9Ls(tVR?)?!GxcHv`dyV+ z$#cIKq5=1$<~Z#~3+4&jb3eJGn6pFC0e04(JeAm#?pb#apQ4d$1f=k`*^uG7r-!mq zIj*+HH4PI$Fq5}syLr%QCz%)qKTgxF_UJD_G~9QwXqUBNV_RSs4PVSf*)<}LfC7&H z&J}vtcq6`+@J$Sdo%?w(;7lXojTHSA1f>%BE-{?k269wdezM9908#+2t!&W>wg}ij zV?G0LTgJH!4u#=2e(6Z*c>)E9CsjG|nQlI0Mq$*Plmnseq}92(jTXBcmVw=42lt!< zk+dc8c``7dYuodJ;8K_%jw4?&el)w^SuKLkq=viz&7LRAQjQbb8qEv55(nI$zm(gW zbYH+;U#X&j+eBr3`h1hS;(j1C18fHVH9^2umNmkkuqT;S?~g+t3_<#e&#nlC^WJ#H0J!?M1&ATEZa)fn4f|%%eh_Ip}4HB*JBPpqdig>&; z_7qQ3M~S|w-d^_<&mS=5<0mc*vQZ$1i|NsA;wI+04d;N)#f);4S*#7)Zt8Atew&e6 z=u|Z+o9`%TP0vlHw&aBg#Kj2QbFI11D8YD?Gt0< zeD^-{<6F4)S(%vFJB$3`G4bjk-UUaoh z%kMV~AhnVttxn<965|Ug)(7btq1yHYBrz($&$^w&4a;%~+>EC*pTQQWzPOJ1dCy<+ zgxEzYh!O(jd8(aZ4T~zE|Q#)7q8|x{(H)Fob0%=y%qvQ4&%AhhwDrrX$p;C5s8wo?( zB*GaEHiqQ<^Q=L`N<+Z#;-so3cYh&O!6oH_{m4(qkZ;j0u8@{o_VsICh={7_of|bj z->&YRPWjGV;QCMbvEAP1*B9M97Hu1E%Y5vvRPsStU`K22fmJihU*+trCn#DZF$}Bq?)5N15sD?)C=|ps=?6N&H*==oj^P`S*4TU5+*l zu#n<@yO8Klg>fEiN+s_#JLaV@Qx>n7t2T!l0Jo~nDJC4fK28Fa%(AlkxQe3`*l4rd zlc9MpEp*&F{W3V7?I8ICjAjx_Ou2T%8XIUzSIid8!y$>b3t$MKu2#Haj|FT3Go6B zrMVCk*|^@WCEAM{-vmLYCHio-8gbx=UHq)o(~=nU?=OkrOXYTb+Lrw$b{(Gf3 zcnQCQpzks@@7Bs%!;q&yJ_kMoOr z8vv)PilS_li6GM?q>^3q-TQB&d*gtDCN<$J6@6i|1cTs{T@Wy!KKMxp^~M_vBoY!V zXsyJU2cG&X>~;r*IK#o4b^8&Hq?v0if1Fo&dB*gCr95%ps5Z%|t}e^sZ*A=`jnMxh zrEYflG(IycOP<<(3W#Dfz4%clsy@j+1(;?QS~fpdJ?;++mB8~wcuO5PfB#)TMvPOd zfD}?O)k8%97A|T5a-gaUSWC}G#_yg|E3K-4`0_-#QlXua4Vx@A{c%pYHA}Ewvh2ZE zC!>kw6@@apt$efs(BZXGNUCKNH=vIC@>I1*bf$2{H%}BamPr=}ki2E4w^x9k2XE$s zL0!JBbq*pJqxSgijAF7Bwt~0{)Cdh*X?4xf-1JSq)n~AWqbdACsM7^;G1Qshs;zPR zh452OHG9wI<4-Z{Ip~UGBAv@zu5;AC`-iso*6t{kI8cp9C^|Ru!h~;UQ6b>Ewc7;d zr#vUy6iOp{JA*-KdpxxjMmi1y(mT)`L}%c!yq0r9n&oZsJ-?Y4aiX`9KiVsgdFOG$ zrq%UXVBT+^s8@GBS@EyUeGKFFAvRdXe4pUU61*h7J_Ackh}6ZZJ=iUmn11{&tnx@_ z`o*gu+2I1x^xP@D+Z#Gke48MZOlG;5O1nbe#5@VTHk|J+#r?R-h?HAN!|v@zBf2fF zs0*Ed`#+xp;z*C?TWuG;3916aA5wn3JmL*}|0U%HrUUvv>Ri!)Uboy@z7X^?J$)XC zFw*4QEDm)sZ=Y9<2wkiC3IKU(!0d;oEgPlBJXf0Dz5-o^mL7%h2JE ze%A`50@0ZWAm*<<(FRZst^D>K;vjYl$jKkTna71jX)PbsI>&%T%g_J$b2dH^;e?SG z#zIU#pn&Ye`*>Ud`n9Nx~Z(eLoz7CG0Cg0d$(^98Gc8m95m2}F56zs1f$ z#>NKDWo!8=2>PCg5vR4C0(ciu!FWM18TDp=_Oq*=0FWlqNh^he=FPyJ@t~lIvPfb1 zhT}F#eZP*?o~r2`%;?1_L3aA46Y^?G$e9&4&|s=B_gIlc4hOPdo)b~$q?Y`FQI4O+aKqmy&~^Pz=2@O@}edOMDjHrxABBr#JC^^ zB*1@{bojp8I+&KD;U}_=P3@mN7r1nS7oTiGQ1e+wan7Z8Uf`OTfo);u@P%<|0#u)^ zZ|pt%?-8}$8xuo)vdG*!r`^N|5|Y!8oZBYunZ~E7O_c;BLN84Ia;VmHN(l#mF3?|W zZ>LS#W{oC5L&w(~Xk#uZIR|SgT(w!_A|t5^caxRl<*zTPda2m6(yx8_T~0c1G7}6k zeKGPqo`*6b0|X4A;>zbS2|Rvu(&9y?RlcxQ+L?_?jomP@J9pl1uEC1xTIA(_r?hIC zOI)8T6-i1;qWSJM0pfH!D{vrJr$v>!R#8+mh-QblZ?)W-_{`I~H(R*9^Zix{m!6-7 z1pt*1H&6tK_i8fxrf+ahud)1OfcKslmQ%&k)AuU&`ZCOYy_kcz$=aw7^!C7OiJEXV zm5O*t3_>Pg{|HnuQcuhSeSo{%K^$Lfj!!&~U{${L22^i9lR-Zi@wh-7>oFAR<C4@E6K5pSoByN1qzpJmY zn3;pE)g$f*ZUnw2*{X9NmqhgdRU;9--Y-R#81Pa^DL+I)ls&yjb6hEia?Lum=ai8R zIpQjp4xIw2rBqSWlnr=D{t@&p-REbCMRF{D#2&xUsexLrg6r4cpaY0h$KP!yCcVB+ zvqNhFi1zQflkNqD{RpYn);SF+hrz=+hOMHSFFnBMfy4S4NMW2dD;5${0L2aek*QIi z19mb(&VeXzI=ViPVZ7!5T7O6_9exTxK=zG;(^o|4sk3AlfMfU=KdI4hhv}|a|Vhjz#b~6U1u(2hkZTGeV z_+vZkGYfDIjMmoHsotGDfBXrpikpq{6R)aQxa(kt)X5V)eNGhGQ~s$(F}xZmNpEW> znQVLvj8PNdK3-|)Ngi20Q!`RziiP%>$9zw|=RSx)k~9Re@^e7NKvF&L?>!TEarqOZ zLZrRHwN5NfdmJAl?>9=VJJfvxT-mgo|LkbnhPMe(x+Z25ZknT|atu@={(kd6EmhA) zkUxw|hDIip`g@%AKmywoZD4IMH2}A~q zX3u=U)IM5s=+fYSDD*&7?fdum(KJb`ye0Z4yz02c#gkPBQrtgq|G~3)u9gcI9=}HG zSG`Q??(PmCjyuf+9DtT7GL^*R4;luo+M+mR0_Rm)EG;Z7(2WhyNv}k#H@3zNF>R1U znRM#A-e+_Kij~%?P2>$ANX&D+`5frYOHHdDd*c%n#UcB+#Tuk`ikxHMTt3QxvYxGs zO$C9i#IN8FfO4kF-IW|!Sg>`PalAnO+ij2O>b)_Q(KE~KV)(t7>prw&dpsNsz{TeI zB_=}y2OamJsrBhd{JxW+8_`e`M810y<+88>9jy`IRWX1tojVkhLgBcD0kQZ}&!emy zEX@a?S^`bh0B8nHf5}urUx;$o3GDne0rc36AR%bi|GXwEU1Ys*{r&|-x>yO6IjAT! zNZ$1oZfzhZ0gWG^uj>F{3|-Gh*x3`opCLV)Ptr9n$sxS0?Ssw^N7I*})3&~MAHQtl z+j2RnzB>Y>tdu3kZ-7Dr{iUgZJs;V{h&&%nNH8kAW1fOYiit@8jlwks*wh3sPauB! z(h(U|@@SWI@q@LRz$Ktkd`0{4l969NW_6GkslmK1Y8?j(3bqam?w|Zursw~^Jqf6! z@tF;zU_HD;Ph*0P?llwWuNPTdTFR~s0aHaLcTVKq(p+A`Xr4UZ!TIKixXiF98D&54 z0449-;8o|lzboRS)RoX3;KFZ3< zv8vh{8csmBhaWLDpq7E**^j6Obvn9S*^l2Q_(VmEK!Z6@(C|x#-~Nm4=hy#!M#lf) zp%DMur$zkV2ZZ@C%q1bO8XWhzpcHzKKf2_eq$*ctr!O(rmn<-m4UF(@|$}3@B=M3m*IyyDxkKb?)H-BUR z`T#sC<07bysUI2E0lNO*WA>xP8<8?*DL64k5qWtE@KEemu4L9;{IB0jK2h-kMj5G0 zSk80(-)8Kl^~W5wNtf6s-m_~MNx!d7)#rsL+$0Zl*ZQB zX)VQQrwLU7h_mB6y-}gMEF1brrUjqyqr2AKi^_M+6t>j5^EFTVJm{F@8RovZzKZ@Rk zfE9~~#E>Nf`}Ctyxin!FlKNT#RPtQbHT_Y>97D^(4G?uRsadGelKpac-xW#G73|&d zC;xIM2qN%@qZptJsuFMG1U^WX!S;bVP6uB1IO}&@02km$zjXu^f7`bCJk(f>!?`qL z={k67N4q_);O5wI8vI$UD>rSrG}Hh>@Z=WAWeHs+IeQboaG0fq%c4*Hc7a%%7A}Ie zQY)GStTIkx`5O)iKWPR`w#1j1z^6&IdGM;BE@8joR8jR9GP&Y8Sw!=!#AID!x3R9< zc->K|IBi<~WKuU^0_R2*sR5@`IUeHpV$fJX3JQ|!KLaFb&IiQCS@Jj_F2$)^;M(?z z2XNjOF_{`68;|>k@;cK4_%L2>S|ql0uM}CEDV}@xmi8@)P(?>aM^#ls;xZN4{)%#U zX6+3!i9l%Y8y-lPDISbcX4sg=N7yO^8wjN^L{xPT0Q>8*zo_VfMniCPtL^R@q>@pvqRMyl7%2GccAEEs^!K`dO^I&s? zzPgaMsG70GRz@6(kB87JufY0{O$?9mAXe(62`HcN zlRiI6o<^21X9urJ1TBq+JL5glOSIBuG^X?ILQ(R}xpDly#`b(mKv?$d?@xZ3k%r}u z#A4jH=cn{WMj{GK?SyXwybHIgXj%Oh5k{x240^AsOU1gCovm`IaDrPZYLrhiELNTB%{Tw#_16(e4XhE>SU(Nq>QRNgnJx zAcum8*Byh63U?r{EkRk)!E<2dbgpVQc)`H>1!SI_2g4DAFQ;m~^0I>uYlR57OM{3D zzi0DGmF*%apR7tbNbqce8#dg=6Z{*=+Xht01q_r}l;d*q|2Q3~))_;MM6ZrK?fVgm z>K8Zw%Ov>K7X`*ge!uDnJftirh&k|iU9l_0UxA7|<*rf}+pBMCz7U!E+P7{!E)=32 z!YIS#uH6fu)!Hh9HApI}bjvV(e%iAY=fa(|^0gnf@P=5Ik*?&N*!_gs5-(ZZJzRt# z_QSG7+vuw!qDe|Fgn66oql%J{l|ksRUo(DMA52!s`Go2HYmAQ>z~T;BeP{V2oPFS|7k;r$m|8rgnsbm=-j!%j-30ej01KDqb#KHT{g z-vy_!FsPedBNTo;xO$yH!O!kq84b*$@58Roj)mSquvX6Q`MtL+Wb5vbz{%#?V)awi zJp20~H?8NEa5lVnqoFq z_(*5%EQVIVXKtW4SdKPX(PmH^F(J88sP-#O*Tv0ELcR5`k5N>4S?ng`MwVtR*O)I- z_u!LFOT(fkr9|g(-(9v`K^V`tD6ua!y>R!HDl%L!ss0dzn4Vx5Oxgt@QA5!xl^U!5 z7N?k-k!)?xQQzj2Yc48So008En(dDG%Zh~_PbD4+<_Vdvg7+;g>~4X0(4Clkn;VQQ zxU1MXp7*~$A;wKP;yX%vlnWphMQf16zv$-CH6*ow#C!8&KbZ_97Il1k=KPP;|1qh)RW)-8wXDlKBZ(A@OY8wjmC49Vu^f&SYIv~vNViv6 z0ld4xy@)&G7lX}q(+q(lU12^5=e3(gctVo7D6F4DIMSRdI?{?VDGNoD9f_klGPSlB z+)bKIr0IpWW>LD_MKpxwdc1gK9vPQ<|4U7&=}0GBDZ28Mx}y)olxs9ZZHbtYcntvD zsnVk%@=Jbi6NaIk8oTLg${u%k&=9U~4BkKKDA|v2a`4!9#A31n8=c25i9pR8#kYSp zA24Y%lX5p(txV*mH3=|kQV}krA~}FrC)xn3Zq}I+uJH5%B>yCAR_eB~wj50;L?qnC|w5{OkHrF8F~!^O^f2%$P^iYE1k zq#}>eyvS%w*XIaWG5`TI+9`p?vkTk>9~DSVnj<~ANgaW(&siHsc!9!)6vNi|GQ2a`u$gwxwO@IV zk)y9`G)4c~ONV!zL(0~@a6WZS>biwb223noUm$97g*1Ey`tDp_g>eTd1{!uNi=JVn zIESG=E3LnH&fYlNPXt5lDy=^yRzdd&3zJ*&8zhb9a-K3I{JVep?}SNeOt3@E&FR-o zO@oKsSame!wvV>=lmj+nIIvMW9k=&YRMdeDJi5sFoolz@qQDTcH;2_3UPCQp* z?@i?iXz|zyr5F#f+8(gKvWbg^r|4BlpxYVfCTh%4N`*lBA`fu zARr>dgcfR$-VvpT5PF1!5<(!MBy(`htTq2<)_mX0nw1}CCwa?0?|%04?h<>;!tCII zqXz&0IC$-<$sGU?21Q|D-(K*s3h&zhAA8-*%}fBn&L^`b?{5H*XJ0e9Xa#?}Kn){3 z2o4)(t@nj6G;ZRL6ZaA4A6!J=hWw#6pLDN}6#x6)6KK(9)qoD=US;StC#^4$vHITo z5|4g0Bt4C9&w6;gHz2J3+i{&M(J(|scz>Zj`+1bwL7j32Ht-`7*Mq8GV23|q4Dhh} z<{pH&6~i9INfw0l9u0RgIVKNbD7p0)!FB;MX>;hIV-Q=j#u*6f6`Ew&L5#5FKVG|F zoJ8k6FsVMT=pqjhQAb$-_hS6w(V=FI#-8@)LQ%$WvJg0;SZVSTddTZ>u@G>YnTtNJ z)IkhfVu*ws1;Vfxv&K!u$>dkBMFG`1Go?ukQO%J=CzEV}>9AUT#cm8*om;epG zZDJC#DZwu~D9#g#I_w*dzFchBgNa3(cVPDds-3;1X!w)iy8sY_c%rT-@3?jtxbQBq z$h7gkhoum3c>c3m5BoJbO)dNoka@;{XxOt!5Ooz51LEVWr9${X(i-i)~1&y+qj?_27YhnZC_9q6S(^FHBE<)=pT~TT_yKH6v?Sf~{MK$h= zqV)UNZ*6>8fN#BwF-mOZwV+e5?UL^iEu)2B0AOAM6sk} z_9;zxInD=oy+mTHyS#t|Z=87ET)Vkg{2iB(o*w6;>mn`SE70Q1H7e9<*Er^Gi~+wr zmQL8QFVCC4;T0sz&5V9Sh{#E8*R02WSeU+C4pW&=hoLvDjpLhNrJ79{q?-D%)tkh{ zluvZ94PB!bx(lhF1&s%ML&xRIYLBQWldC<0PJiaqTK~m`m>nX_Or7 z5{K7&5BcDYruyz$B+{XD`+z#3O-&!!3=3Ti(sCA;Yn&k=?Yzpdd!6c1v0`12#SZUD z>|TN@fhtv2C#)iRNs}uwoAqQ9wvQ`o4p4?MW^V>Uw3Z{ii9(O-{`9P#ZwzK7E9GU~ z?%7*L_4j{^(e9xgq79SIeD+Iugg4S{WVmdqu&U`sDkJXhyC{q6sO1JY+i?uyy0H?o zi{klurLE0w>zQ=RWU)nJ#FHoYD23FZ%O%Eq{=>*QSEY?d5$(H2GXR++8>P7KxRP@& zYtyB@FfmvQ$~H`%HH=D6ByTfkhkn<7YvvEyLew%6S&!4urEd4(sJ8-C0+j7l?$x7lpmtFQM*Reke(fk07vV0Qh(f5UYbS-mtLJOi6Oc zSNN|CKdk)=Z74F(X$991>~L>;Bbdcs=U+CQizQpPBEEmu4rnfLk)}tkZCx6WO^)~^ zCz?mrmlCwFNpVc{kFJ&ivZbn*}ZFerJJp3i^sJvj7#;p zd9!Pm2Azv8i|d{5-tHVG2>6?lwQH0yq)j!ia7i_SI`ach`K4H}Jn=(rqqTkz7Ret? zgEL&Otd1rKpR~&du1@L!Ek;iip}dvI12mr5*4E-vU0jJ*HM7k})OGXAM!zd>hC3 zf8*_CwY?&hH#VW4`t&MW_%@xj6MhEd!e8!5$FEkkAQ`eOLMl|J*?f#gn>g5Gr$FYI zKdZVFackg{y&Rq2Vcg;p4pR+JMx)^~3OIQbyuHl>6X4(F*;&2)tQR%Z(1BG}RIIez zqT0dRaLGn{&%FOg@NyFBLNr#nehxjUut`gGNNKX~L0x5573nKbJ?*8Iko4C9<3cm? zy<^|gX{&<)jd=d`VfWFlErptM2y&3gj$E%$tyokWvXYr=iQW#aY9jnj21~CPv&)gH~l0Y%9d8#Z?G6 z< zmg0Ou8meDb@f0G!trixkR{bNM*aH07d3A4Hfu&Qqn>g=CvVc><*%c`~@uYfkMl-=k z*J`R-5E<50;98L-t~esOV@iaGg3Y{`_yg9u|G7MkbS!N>Av3dsea#C;~6VzlSyYzrtXc+PB~ zYCd_&HnnW+T2Exi&#xtSU4P*q^rQnrFi_;f#7_n)hFzsVorY{a@UX8KzLEW$esH;U z7bRf%vO~_N$Zvr-=l04D!upta>=eczH@Vb+%wD}S;G8mG7ldMmwqu254k<|j7$iJ= z*mce^MIup^uTv)^!c@TMXE~G)871XGIgRy$oh>81MzgvHTth>Hkj045AfR98j?gl< z;RWw6+(rGhdusZIyaYQK!e&R%*C zxWeNfo;8b-JtqP%kvHBk4%SDD7^e$mB)431rK zZZ$6Kb(&!Bu#W6GP{rKyuj9+dry|Z(KHo4P4R!e@4a=cA}tRFyL%H+OuMZHSEUDgSyFUpqUpvs{8K{&Y~y$iYGB z%CXEo-GlVe3xq4D0RGfQ4ABh!6&dv1O)ZFhX|Q^wU7!k@fV5CnNMKdEh59BaMB=}4 z_GS&nZD49$*ACbHB9jkr2hKKx!ZyDZ#9>n6GPtnss< zw%Z>SuGlP>9;$nMG#?;Mf8?bti4DrG*^*tiBq=n(7jgIRx#>ILima6qEa}U?y z(_Y2ks|?&94t4jj+N^@kkq}Cc3mcyte9w&=PMO;f@#Smi|xlvsYl7B@-1-D+B ze5ZY+P*dP@lA&RaHDf&ONd;X_8GJ)UMLv%>!Q---9c5-N%sGMUr^s$WY)($j6Dd>@ zl=GT>Jsf$FUEq4`4W=(W)%8%R;AoL$NYqNa6W|m;jfe<$=iv9GVV7ElQ(>BZ+p@5s z!*s0eb~eQYFTE7jBVz7ooSvRQK&h2#^l2Ye^QYx(0=wW zq2+}HHmlGZW zF-5)nmC+RwlM*Njrc-;ji=xtE9Awi6TU}Nd|8is)82bHtoNWYi_6MhckX_U88b?+y zsxHzWR8><`YdooGSo6g7(CSMJQW3gY!3J4bdSg2+V#+5Aj24M173s6OWO;XbLQ@~icsv2TQZ`E(_pw`cjb42IqRlR69xlGhCYfekwe`ZF{7^ZH0qJ`w~W5EC!KJ+;3n5=D&_+ z?X&7>6#>s}Q)B3;Kew~6SM$+Q_*iJ}RE`4wQBVi99GVGYZxqfyshJoP^CSdDBXPfR zx4p23wYB^C(9LFYq;{?gf9u<#*a!b={ag!_OW!%#o%W5ohgTIrJVFClYw!sXl$4`a z97R-9@4;jEt}-Z zv(S}Nzux@TLS)qxN#&RXYF!s~guK-u@XMrR;BXl6NUl3;O`B$4Vem^zb|_b~Y3sB= zEk&Ngct&0RcBiD4J1irpqnVWz9xXQ!ZaM^<&b-|-c@o^}WD7n~N!)j7pbR$pEg?{Y z0ucnYt)zj3P+7V*1foI6HO-@9}Mr&r0Fb)0-_iPRqxL}MBY({;rk zw|m>^PxcbH#+C_-y)9-sM?1zW&ebS6NWWD8L8|O+yxj( zoqExc9mZ?wt!6SHRPV99n9E?(V_MBhk$s#H|at1%I zc?pHD$p$aaURSbLW8;Hq35l0o-B-?O%a!7`mA!RcmQtYi@^%v5hdIK>UsC{~DA!;9 zDLL$?uOTha@I}4TC69os?L*gnb#_v_*n_)({+Yv|epmk^Gyg9NJBj@QmQ%ET*7hL< zNQ&2XVG&^_ViCAEGhtY9L^ee0-+jy(RAZnkeOA$JeA6lq0FGY-t3-#C{|^cYM3JlZ zDg9Z)zNW>!@t+Vo>RrCpwWF~6UoHQ;Mj=z?*WJ^#$LRVjG>etr`g>L6B8|(n3aX6LGS-!=LE@PKi?%g3nAlkB zUf;x5MhC2QrCzRYYLU;&ii!q$SpKanbc>{BbF*-5Z`-qP-ifB}*4mz)<>30|%z%)O zy$_o{w)Ma4KgKHdAv&o4)@%!E`eN?&0*l=vqWXf?_w=U42(Q?%)Il9wBLPu}pt=<3 zy7l)RH9)203?GZjZ!>C_SJFc|iuQSzx_|PZ_pEr>OZ_bwk^T0q-As^FRZhRRS;v># z?{;5^9$APbn%bF}NB&Koej$-9z$r0YrnQ%go@fz?JdyZQo9y$T+~l^T8j5wY@9 zawkjQEPyjibmqH0^v`=>UWy6bO1iSAhw^*%JZHWdJD)J$S-)KH%_qEXf7Dab`4<;I ze!KnG=GKw-Z`RxEx#=45qVHwio~n8CQAaep?(|31|Eoaz-}_!K{-S$W*6o{1HL7#A zdL;Fy>tYS@jBw`hZ4lWInP06DMl+IcVkYPut7D+;b6j*+Zk35O@1Pg5*tZbUE+75! zrCGUc#^Blq31Ckf+pv<_L~OaYdm7R^r->BPzK`1kvwggF~EX&Kbp;Ln7d9GIG#)zZx;NSJ;~ z%G(3?c%ELNCiqMD(}jg@eYWuW6?7&uu5y$}KE*kH?zP{mtv_@yXi%^SB#4D|cEa#| zj|#1H+U1X%W^`ZjWdyrL_*$1Z6t?~@*Bt3MuijK&?>j>~&Njq=b9%i?v#HqlD^!Z; zx_ZxM?_7`@@_z1}yplVUx+TinF2nAHdLK6T^}YEKB<1D_Gqd7+KG{J` zd+Q`uIBPeYeP%aNEiS~zaHK1wvvUD$;q?w==c%JnPeGnad*dV*!Z)-lv@rmgGWHbm zUa|+IAa`Jd#~$~vzj3oDk&rTI(F@Yxd@16_&4r)n^30Z;S^$eOq|}h`Q%9jIUb;6d ziV`asrUy;{2XvrN$;7Q;XZMBJ5B(q61&#&%AUagT#r|{&*$h`U&F`*iMRyR@-asea z0Ag!azjrE#cL%WA{D=Ckuigo!Cwc-BRCgG&X71)+(T{W-JTQ;*Jd5><&oGG(2Qsdo z+Tq*TQK?;4IpK_&vLnj)yvcnaj}GEM9@T`s^1ROt!>c9Izzh3d1S9{i#!r7$z2UM3 z#3%jdi*1K7$vceslQx+wqbK3+jQc`o|1TWiS5LXrjK*txR6i@XmFurXKA6DE}^4f}Ga~c6*fXxF!=H z#oM#=()19pXFf%099CPBZ_(k{;i6Hbp1Som~BT)AB>j%>W4lYdn>^PYB~K^CR3 zyH~bBsfV5Rdf29oaSc)9(%I=NA*5zrPKkXzJRh?FQn+acI}JN570A{eQycc6F1j&? z2q!8fH+~4PmF5{kgsF4U9U5Q&J4^R+LwGupw(;F-h_8z?e3zt{~Nd4SE+e^nW?^5>UO3 zr5B0bQ|2JdwTz9A^$O=d8QT9(>OTEzQ zf3RKz-1^|(uQ>Vi=g8oTDfBQY$e>B%7Z1x_z$cQA(&T4{6pt{I>OAHna8~u{4x(Wk zx|JQFh(v0QBWJpI1D`Twm43D+2Ne~GYUy!k?e>iQUF-pH88^6MCK@(`|yzx z*i@anY@ZUjm?#J+Uy|--S4%=%(P$MZBT3-18`w5g0XvG1TZ~DRR3Q)hfmpN`mH!N= zTi!&N7;=gX1n7(i*?TD-6s5^O(A=w9f#7=63R845zQH0-lbEr!oUvkvx?)nTJt_wP z4b#nB-X0XL3a>z-0ca9i>Wj2#qr&s&dxS2$a+ZZGy+Di80!qOWcpaF|FIb92_?m33 z|7<5qL1Y!(Ff%>-0k!XmU_03umDsb>PFCv(;cZ6pjc1P?w|bo-`5bJB1J_I~Oo}eu Gd-`vM`NPNn literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3174597b71094fa924cdc32689c6cd4c6d5879c4 GIT binary patch literal 2460 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..390b50193f367dd4a933084f57ad9f5bf049458f GIT binary patch literal 2533 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1ea47a83f90ffea8e43a3a656d83cc6f8f098239 GIT binary patch literal 4704 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 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 0000000000000000000000000000000000000000..3b8665fe3ce99a8f22ec5047145333b1e598df7f GIT binary patch literal 2721 zcmb_ec{JPE9{-`$HkNczYKwWTCA6WoQerDcqm8y0F&Y$6G*WaG#9B2}Ybkw#391yW zph!3b)7aI%X6!q)LYv+Yycmhb+KevGe08)LYj zf!?0Jx@n~)n12*3qs z){A!n{xgqjl5&gIdCJ;&3;Cjn5{@dtOXgUDR84O-%2*`K#T;vYVE<4oCh3M97Uy@; z#0O!$mC@%HfWee_kwuNIk&hpv3M}(nC}}7`KX>;j`pI|XYW+ReW2S2(MVV$|%INdh zjy|;V)0~l08j&B<5hDD^*XP}jha0_w3MQL4b;z0zhuMJU?w0x>Zt_yHKI`;{E-Rn2 zD>)izQnzNR*}WvaB6jY;XE-<kEFim{3xy1wbd7bX2E zuuEOSTJ0|@APvolXL-FwM@F8y?XOf^6Gnt8?Qz_^QTf7^w7bpDpNob&%F#J^sD9Bw z+H$gIy55=_%rW&NO^bSPB(Swr%Xw0o8bNYnxY9U#ln7ografM>nHr?dh6irSL5Hhh zZ0#sBPoq(4_Ct$${W914Q+UeVa)TAhwAT=yZ6}G;RqqezPNhjlnVvJc2`c;2JkvN? z;1K^k>Pn#&x{thTIfZ|vy!6X-i?TpT{(U+!ZnhJ*v_iIp;NhlXS2}*aC`rWNn5rtv zIaOvY7`3_O&v_oCD=JI5RdJTwqR8`r1a-zI*IQ|*E@fa1oQ_Yud3KU@bnG>LF?j!J zo{#dg2r2p6<81elu*u#HuT(wM?oXPW2^G2@ajPLhpkX}d1 zxwrRo8k*)CC$9-5s%jit^c+bE+V-Eq^B;=vo~X?=9F%_nc>#}65{dQQ_nl&i*n2YX zE$XGFy)0_ir)sUj+p7z~M%AVNSYN#oH;bS>ihcWwP$X=Q&uf~sOthTy;hPjD;m>fK zY8@T$ur3EbUyO+EW4)8U+qHvcu{#S38M8OOz`L#)i2F&^ouQ-?gy5^}@4jFC1)TlN zRc3Th{U_oFnLFeN>MpRm%NIgbNo*PT@K91-Uf#I1d#AP#&FbM`MuwAT<^2=F(mKce zAc<)v%W}#_6oh5;5%NoJFwdW5dnoK&xBY)3kHK%?Ni3d zXhNsB3Dz6PMeRqkWr73}!JA^iAKyJHwYLx5DPG>1vNR(W#6`?hZXXN$!uL;$~$Pk8H0`%<@V1fSZ|*{f9~Sx zS-HHH({yCuWhqU}Vx8VD68{JNfWqW&{E3;eCL$9l8+{`Rf$Ib4{P3K-uM2a7S={ht z5y-vRKOeJX;VO!XST2q?gBtS6?F4UV7F7GK*t)qjyhqvy{^~Q-HD9I9Du{xfZtyu9 zcsMaaO7<@o{+kN_ZYy_Ni-U4e6li5g#YeJDH!pk0+HFGi_DP_Ljx~~|K=Kn7i|GUt+1phy8(TyT@tG2(uno$(3<^Ia{`bF#=4R=TX*YO*0 zt({Z-@95^AIyzQL84WHP?)f1^VkkF*%bB*RAiX?otKfN>9XhH`(VY&i;~>MhUK#lr zMy=l)bPe}Pa<#e!h0D7$#TI?SOZg8(Kmjm#?5%oJ-V$FIMVUTdCt z+NY21uL~$^Z1(MlGy%b2I3a(ht_FC!X_YsmN@jT#(L#xB3FM*RcCvCJ`}9}CkCfeI z%;`j(2(vZCYfq4P zi@}ZV9aT)D?W(U;*1U8I-Ji>iaD|}4hFzSvy%U>xUhmkK+q!F!Oneea`3XeJ{u6yu zV*j6Z*yp+PmGiDi)gG~v3970WL4y{Ta_e_={PQ)X-bOIMPR9;;)*eV-kB=YivXfWY-hhUB!<{y&=Xk0O69o7wyi@$2I3&2=~ zcu$T6-#~go^3K%#HL;DJL7pL@F%TfGjy(gy@Yfs(c+gmBrKouX*yqzw>Fcz8ON!!Z OP5@zQVM4xiEA%%Z0U>(; literal 0 HcmV?d00001 diff --git a/frontend/favicon-32.png b/frontend/favicon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..8dd589285a23f20769365671656ea99f291faf01 GIT binary patch literal 669 zcmV;O0%HA%P)=M^tqu5+O^h)S^yC482V)U~Ce<_$C7k>Y z;tR-)cu>zKs396Xc<=-02@R%3MH7s~7zIrD1Y(R)ptx<>ot^PeqO|FoLbe?EpJr#D zm*4F3?93Qi>({_>>Z1umYptheKfg_-UWyJ12m*9}qtFx0b)!v~@a z_t`|kg8ePc3|{J|^JtvGf%5>ky=U<{x^TPu@QxhYSb)@wgVr!H=a6$1U*_le_-O_y zCF9SY17N+M#K<~`Z(lLzX4eg1t$n!vVv&b$mdJS;0N3?MoH|V;5+Rq%kjrWvTZv8U}&v%pqYzg0o=NMcWU&`y?Bs`2QKzc4PCwvw*Z`M zMlLQc1(_cwn~_lT7t}*msY%nc*cNRB%D0EF6&WWDKrGgTFep(?wa zsT7&jamZ>wh{_++bghyAbuYmdK-~dKHlbDutrQqWSx2Q+!PwB{ZP7X(uT}t0x-2Yx zFCVZ->GY49ZHu)zOJG@HVzH)*j!O9~FE0Sl*tioR%!>AolSWFnHUJ?6W|e>je`{_E ztLjEn?tR@$umw`q1K!uHH&B-REi!e)uT9rnXxxkk6lDP0096201yxW0000W0DA!d02TlM0EtjeM-2)Z3IG5A4M|8uQUCw|5C8xG z5C{eU001BJ|6u?C0YXVcK~#90rIWo+0znYPf4hePA|J7_I1A|wCSqYUN@K9IvZJ)L z(Er24PX8J!4F*XxBotbjfL=mkGziMw;qF2&d(DL&v15nLOQ*K zli$bL-J9q_JU&@Bo7vo|5a4+rguwFypBRI}<@s$WnTVT5hX;S$-bXSKH$}BtyIx*d zUzT4?~f0s?^{jX + + + + + + + + + + + + + + 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 0000000000000000000000000000000000000000..ba914ac1a5a1c8b35feba0e75d9166096e9de5c1 GIT binary patch literal 2923 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 Gd-`vM`NPNn literal 0 HcmV?d00001 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")