# 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
No active tmux sessions — will update automatically
Reconnecting…
```
### 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)