Files
muxplex/docs/plans/2026-03-26-web-tmux-phase2a-frontend-static.md
T
Brian Krabach 8234e2ec05 refactor: restructure project into muxplex/ subdir with brand integration
- Move coordinator/, frontend/, Caddyfile, pyproject.toml, requirements.txt,
  docs/ into muxplex/ subdir in prep for packaging/sharing
- Add brand assets to frontend/: favicon.ico, pwa-192/512.png,
  apple-touch-icon.png, wordmark-on-dark.svg
- Update app: title → muxplex, header → wordmark SVG, brand color tokens
  in style.css, manifest.json updated with muxplex name and brand icons
- Add design system: assets/branding/tokens.css (101 CSS custom properties),
  tokens.json (127 tokens), DESIGN-SYSTEM.md (856-line spec)
- Add assets/branding/: SVG sources, rendered PNGs (icons, favicons, PWA, OG)
- Add scripts/render-brand-assets.py for reproducible PNG generation
- Add muxplex/README.md
2026-03-27 15:06:00 -07:00

1718 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>tmux web</title></head><body></body></html>
```
### Step 3: Create frontend/style.css (stub)
Create `frontend/style.css` with this content:
```css
/* Phase 2a stub — CSS built incrementally in Tasks 613 */
```
### 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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#0a0e14">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="manifest" href="/manifest.json">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.css">
<link rel="stylesheet" href="/style.css">
<title>tmux web</title>
</head>
<body>
<!-- ── Overview (default view) ── -->
<div id="view-overview" class="view view--active">
<header class="app-header">
<h1 class="app-title">tmux web</h1>
<span id="connection-status" class="connection-status" aria-live="polite"></span>
</header>
<div id="session-grid" class="session-grid" role="list"></div>
<div id="empty-state" class="empty-state hidden">
No active tmux sessions — will update automatically
</div>
</div>
<!-- ── Expanded terminal view ── -->
<div id="view-expanded" class="view hidden">
<div class="expanded-header">
<button id="back-btn" class="back-btn" aria-label="Return to overview"></button>
<span id="expanded-session-name" class="expanded-session-name"></span>
<button id="palette-trigger" class="palette-trigger"
aria-label="Switch session (Ctrl+K)" title="Switch session (Ctrl+K)">⌘K</button>
</div>
<div id="terminal-container" class="terminal-container"></div>
<!-- Reconnecting overlay — shown by JS when WebSocket drops -->
<div id="reconnect-overlay" class="reconnect-overlay hidden">
<span class="reconnect-overlay__text">Reconnecting…</span>
</div>
</div>
<!-- ── Command palette (desktop) ── -->
<div id="command-palette" class="command-palette hidden"
role="dialog" aria-modal="true" aria-label="Switch session">
<div class="command-palette__backdrop" id="palette-backdrop"></div>
<div class="command-palette__dialog">
<input id="palette-input" class="command-palette__input" type="text"
placeholder="Jump to session…" autocomplete="off" spellcheck="false">
<ul id="palette-list" class="command-palette__list" role="listbox"
aria-label="Sessions"></ul>
</div>
</div>
<!-- ── Bottom sheet (mobile session switcher) ── -->
<div id="bottom-sheet" class="bottom-sheet hidden"
role="dialog" aria-modal="true" aria-label="Switch session">
<div class="bottom-sheet__backdrop" id="sheet-backdrop"></div>
<div class="bottom-sheet__panel">
<div class="bottom-sheet__handle" aria-hidden="true"></div>
<ul id="sheet-list" class="bottom-sheet__list" role="listbox"
aria-label="Sessions"></ul>
</div>
</div>
<!-- ── Floating session pill (mobile, shown in expanded view) ── -->
<button id="session-pill" class="session-pill hidden" aria-label="Switch session">
<span id="session-pill-label" class="session-pill__label"></span>
<span id="session-pill-bell" class="session-pill__bell hidden" aria-hidden="true">🔔</span>
</button>
<!-- ── Toast notification ── -->
<div id="toast" class="toast hidden"
role="status" aria-live="polite" aria-atomic="true"></div>
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.js"></script>
<script src="/app.js" defer></script>
<script src="/terminal.js" defer></script>
</body>
</html>
```
### 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) */
/* 9001199px: slightly wider tile minimum forces 2 columns at most */
@media (max-width: 1199px) and (min-width: 900px) {
:root { --tile-min-width: 420px; }
}
/* 600899px: 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 46 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)