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

46 KiB
Raw Blame History

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:

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

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

pip install aiofiles

Expected: Successfully installed aiofiles-... (or Requirement already satisfied)

Step 4: Verify the import works

python -c "import aiofiles; print('aiofiles ok')"

Expected output: aiofiles ok

Step 5: Confirm tests still pass

pytest -q

Expected: 92 passed

Step 6: Commit

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

mkdir -p frontend

Step 2: Create frontend/index.html (stub)

Create frontend/index.html with this content:

<!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:

/* Phase 2a stub — CSS built incrementally in Tasks 613 */

Step 4: Create frontend/app.js (stub)

Create frontend/app.js with this content:

// 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:

// Phase 2b implementation — placeholder to allow static serving tests to pass

Step 6: Verify the directory looks right

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

pytest -q

Expected: 92 passed

Step 8: Commit

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):

# ---------------------------------------------------------------------------
# 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

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 FAILGET / 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:

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:



# ---------------------------------------------------------------------------
# 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

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

pytest -q

Expected: 95 passed (92 original + 3 new)

Step 6: Commit

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:

{
  "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

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

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

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:

"""
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

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:

<!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

pytest coordinator/tests/test_frontend_html.py -v

Expected: All 10 tests PASS.

Step 5: Run the full test suite to confirm nothing broke

pytest -q

Expected: 105 passed (95 from Task 3 + 10 new HTML tests)

Step 6: Commit

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:

"""
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

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:

/* ── 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

pytest coordinator/tests/test_frontend_css.py::test_css_design_tokens -v

Expected: PASS.

Step 5: Run the full test suite

pytest -q

Expected: 106 passed

Step 6: Commit

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:

# ── 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

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:


/* ── 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

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

pytest -q

Expected: 109 passed

Step 6: Commit

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:

# ── 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

pytest coordinator/tests/test_frontend_css.py::test_css_breakpoints -v

Expected: FAIL.

Step 3: Append breakpoint CSS to frontend/style.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

pytest coordinator/tests/test_frontend_css.py::test_css_breakpoints -v

Expected: PASS.

Step 5: Run the full test suite

pytest -q

Expected: 110 passed

Step 6: Commit

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:

# ── 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

pytest coordinator/tests/test_frontend_css.py::test_css_zoom_transition -v

Expected: FAIL.

Step 3: Append zoom transition CSS to frontend/style.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

pytest coordinator/tests/test_frontend_css.py::test_css_zoom_transition -v

Expected: PASS.

Step 5: Run the full test suite

pytest -q

Expected: 111 passed

Step 6: Commit

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:

# ── 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

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


/* ── 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

pytest coordinator/tests/test_frontend_css.py::test_css_bell_count_and_toast -v

Expected: PASS.

Step 5: Run the full test suite

pytest -q

Expected: 112 passed

Step 6: Commit

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:

# ── 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

pytest coordinator/tests/test_frontend_css.py::test_css_mobile_tiers -v

Expected: FAIL.

Step 3: Append mobile tier CSS to frontend/style.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

pytest coordinator/tests/test_frontend_css.py::test_css_mobile_tiers -v

Expected: PASS.

Step 5: Run the full test suite

pytest -q

Expected: 113 passed

Step 6: Commit

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:

# ── 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

pytest coordinator/tests/test_frontend_css.py::test_css_command_palette -v

Expected: FAIL.

Step 3: Append command palette CSS to frontend/style.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

pytest coordinator/tests/test_frontend_css.py::test_css_command_palette -v

Expected: PASS.

Step 5: Run the full test suite

pytest -q

Expected: 114 passed

Step 6: Commit

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:

# ── 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

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


/* ── 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

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

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

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

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:

# 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)