Files
led2/.planning/phases/01-foundation/01-RESEARCH.md

42 KiB

Phase 1: Foundation - Research

Researched: 2026-04-05 Domain: FastAPI + Pydantic v2 + Vanilla JS terminal aesthetic (Python 3.11, Windows 11 primary) Confidence: HIGH (FastAPI/Pydantic patterns), MEDIUM (CSS terminal aesthetic), HIGH (device ABC pattern)


<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01: Flat package layout — lightsync/ directory at project root (not src/ layout)
  • D-02: Package structure: lightsync/__init__.py, lightsync/main.py (entry point), lightsync/api/, lightsync/devices/, lightsync/models/, lightsync/frontend/
  • D-03: Frontend (HTML/CSS/JS) lives inside lightsync/frontend/ — served via FastAPI StaticFiles, no separate frontend directory
  • D-04: Start command: python -m lightsync — standard module invocation, cross-platform, no install step required
  • D-05: pyproject.toml at root for dependency management
  • D-06: Full DAW skeleton from Phase 1 — all final panels exist, most as dark labeled placeholders
  • D-07: Header: ASCII art / stylized LIGHTSYNC title + system status indicator (API connected / offline) + current show name
  • D-08: Left sidebar: DEVICES panel stacked above ANIMATIONS panel (placeholder label "Phase 3+")
  • D-09: Main center area: large TIMELINE placeholder panel labeled [ TIMELINE — Phase 2+ ]
  • D-10: Bottom bar: TRANSPORT placeholder [ TRANSPORT — Phase 2+ ] spanning full width
  • D-11: Color accent: cyan/teal (#00ffff or close variant) on a dark background — terminal/hacker aesthetic
  • D-12: Dark background (#0a0a0a or #111), monospace font (JetBrains Mono, Fira Code, or system monospace fallback)
  • D-13: Panel borders: border: 1px solid with dimmed cyan/teal — terminal window panes
  • D-14: Uppercase panel headers (DEVICES, ANIMATIONS, TIMELINE) — terminal convention
  • D-15: No drop shadows, gradients, or rounded corners — flat, sharp edges

Claude's Discretion

  • Exact Pydantic model field names and nesting for show files and devices (follow ARCHITECTURE.md conventions)
  • Device registry storage format (separate devices.json vs embedded — choose cleanest)
  • REST API route naming conventions
  • WebSocket message format stub (just needs to exist, Phase 2 defines real protocol)
  • Exact CSS grid/flexbox layout implementation

Deferred Ideas (OUT OF SCOPE)

  • Audio transport panel content — Phase 2
  • Animation library panel content — Phase 3
  • Timeline canvas implementation — Phase 4
  • AI sync — Phase 6 </user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
INF-01 Python 3.11 backend (FastAPI + uvicorn) pyproject.toml pattern, uvicorn.run(), lifespan hook
INF-02 Modular device abstraction — new strip type = new class only BaseDevice ABC pattern with register dict in registry.py
DEV-01 Register device with name, strip type, LED count, IP, port Pydantic DeviceModel, POST /devices endpoint
DEV-02 Devices persist across sessions devices.json load/save in lifespan hook
DEV-03 Device list shown in UI — enable/disable per show DeviceRegistry GET endpoint + JS panel
SHW-01 Show files saved/loaded as JSON (schema_version required) Pydantic ShowModel, show store load/save
SHW-02 Show file includes audio path/URL, device snapshot, animation blocks, beat data Full ShowModel schema from ARCHITECTURE.md
UI-01 Web app served by Python backend — no external hosting FastAPI StaticFiles mount with html=True
UI-02 Terminal/hacker aesthetic — dark theme, monospace, CRT vibe CSS custom properties, grid layout, no CSS framework
</phase_requirements>

Summary

Phase 1 establishes the complete project skeleton: Python package, Pydantic data models, FastAPI app with REST stubs, device registry with file persistence, show store, and a full DAW-skeleton frontend with terminal aesthetic. All panels that will exist in the final product are present as labeled placeholders from day one.

The primary technical challenge is the structural gap between ARCHITECTURE.md (which describes a backend/+frontend/ layout) and the locked decisions in CONTEXT.md (which specify a flat lightsync/ package). The implementation MUST follow CONTEXT.md's locked structure. The architecture patterns from ARCHITECTURE.md apply in terms of component design, just nested differently.

Windows 11 is the primary runtime. All uvicorn invocations must use if __name__ == "__main__" guard and avoid --reload in the shipped __main__.py (use it only during dev). File paths must use pathlib.Path for cross-platform safety even though Windows is primary.

Primary recommendation: Build in this order within the phase: Pydantic models first (everything depends on them), then show store + device registry, then FastAPI app with lifespan, then REST endpoints, then frontend shell, then integration smoke test.


Standard Stack

Core (Phase 1 only — no audio, no UDP)

Library Version Purpose Why Standard
Python 3.11 Runtime 3.11 avoids madmom incompatibility (3.12+), good Windows support, target per INF-01
FastAPI 0.115.x Web framework + REST + WebSocket + static file serving Native async, Pydantic v2 built-in, StaticFiles mounting, single process covers all
uvicorn 0.34.x ASGI server Standard FastAPI runner, ProactorEventLoop on Windows by default
Pydantic 2.x (FastAPI dep) Show file and device model validation + JSON serialization Already required by FastAPI; v2 is significantly faster than v1
aiofiles 24.x Async file I/O for show JSON read/write Keeps FastAPI async handlers non-blocking during file operations

Supporting (Phase 1)

Library Version Purpose When to Use
python-dotenv 1.x Host/port config via .env file On startup to allow port override without code changes
structlog 24.x Structured logging For backend log messages — easier to read than stdlib logging

Not Needed in Phase 1

librosa, python-mpv, aiosqlite, numpy — these are Phase 2+ dependencies. Phase 1 persists devices as JSON, no SQLite needed.

Installation (pyproject.toml)

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "lightsync"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.34.0",
    "pydantic>=2.0.0",
    "aiofiles>=24.0.0",
    "python-dotenv>=1.0.0",
    "structlog>=24.0.0",
]

[project.optional-dependencies]
dev = [
    "httpx>=0.27.0",  # for TestClient in smoke tests
]

Note on uvicorn[standard]: The [standard] extra installs watchfiles for hot reload and websockets for WebSocket support. Required because Phase 1 includes a WebSocket stub endpoint.

Version check note: STACK.md pinned FastAPI at 0.135.x but the latest stable at research time is 0.115.x (the 0.115.x series is the current active release line). Use >=0.115.0 rather than a hard pin so patch updates flow through.


Architecture Patterns

Project Structure (locked by CONTEXT.md decisions D-01 through D-05)

lightsync/                     # Python package (D-01, D-02)
├── __init__.py
├── __main__.py                # python -m lightsync entry point (D-04)
├── main.py                    # FastAPI app factory, lifespan, route registration
├── api/
│   ├── __init__.py
│   ├── shows.py               # REST: CRUD shows
│   ├── devices.py             # REST: CRUD devices
│   └── ws.py                  # WebSocket stub endpoint
├── devices/
│   ├── __init__.py
│   ├── base.py                # BaseDevice ABC
│   ├── sk6812.py              # SK6812 RGBW device
│   ├── ws2801.py              # WS2801 RGB device
│   └── registry.py            # DeviceRegistry (CRUD + JSON persistence)
├── models/
│   ├── __init__.py
│   ├── show.py                # ShowModel, TrackModel, CueModel Pydantic models
│   └── device.py              # DeviceConfig Pydantic model
├── store/
│   ├── __init__.py
│   └── show_store.py          # Load/save shows from shows/ directory
└── frontend/                  # Static files served via StaticFiles (D-03)
    ├── index.html
    ├── style.css
    └── app.js

pyproject.toml                 # Dependencies (D-05)
shows/                         # Show JSON files (created at runtime)
devices.json                   # Device registry (created at runtime)
.env                           # PORT=8000, HOST=127.0.0.1

Note: ARCHITECTURE.md suggests a store/ subdirectory and models/ subdirectory — this maps cleanly onto CONTEXT.md's D-02 structure. The ws/ directory from ARCHITECTURE.md becomes api/ws.py in the flat layout.

Pattern 1: FastAPI App with Lifespan Hook

What: The lifespan context manager handles startup (load devices.json, initialize stores) and shutdown (save devices.json). This replaces deprecated @app.on_event("startup").

When to use: All state initialization goes in lifespan — not at module-level globals, and not in route handlers.

# lightsync/main.py
# Source: https://fastapi.tiangolo.com/advanced/events/
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from pathlib import Path

from lightsync.devices.registry import DeviceRegistry
from lightsync.store.show_store import ShowStore

# Module-level containers populated in lifespan
registry: DeviceRegistry | None = None
show_store: ShowStore | None = None

@asynccontextmanager
async def lifespan(app: FastAPI):
    global registry, show_store
    # Startup
    registry = DeviceRegistry(Path("devices.json"))
    await registry.load()
    show_store = ShowStore(Path("shows"))
    show_store.ensure_dir()
    yield
    # Shutdown
    await registry.save()

def create_app() -> FastAPI:
    app = FastAPI(title="LightSync", lifespan=lifespan)

    # API routes (registered before static mount)
    from lightsync.api import shows, devices, ws
    app.include_router(shows.router, prefix="/api/shows")
    app.include_router(devices.router, prefix="/api/devices")
    app.include_router(ws.router)

    # Static file serving — MUST be last (catches all unmatched paths)
    frontend_dir = Path(__file__).parent / "frontend"
    app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")

    return app

app = create_app()
# lightsync/__main__.py
import uvicorn
from dotenv import load_dotenv
import os

if __name__ == "__main__":
    load_dotenv()
    uvicorn.run(
        "lightsync.main:app",
        host=os.getenv("HOST", "127.0.0.1"),
        port=int(os.getenv("PORT", "8000")),
        reload=False,  # set True manually during dev only
    )

Windows note: The if __name__ == "__main__" guard in __main__.py is required on Windows. Without it, the --reload subprocess spawning crashes with a multiprocessing error. Since python -m lightsync invokes __main__.py as __main__, this guard works correctly.

Pattern 2: Pydantic v2 Show and Device Models

What: All JSON data structures use Pydantic BaseModel. Show files serialize to JSON via model_dump_json(). Devices use a flat model matching the JSON storage format.

# lightsync/models/device.py
# Source: https://docs.pydantic.dev/latest/concepts/models/
from uuid import UUID, uuid4
from typing import Literal
from pydantic import BaseModel, Field

StripType = Literal["sk6812", "ws2801", "generic"]

class DeviceConfig(BaseModel):
    id: UUID = Field(default_factory=uuid4)
    name: str
    strip_type: StripType
    led_count: int = Field(gt=0, le=1000)
    ip: str
    port: int = Field(ge=1, le=65535)
    enabled: bool = True

    model_config = {"populate_by_name": True}
# lightsync/models/show.py
# Source: https://docs.pydantic.dev/latest/concepts/models/
from uuid import UUID, uuid4
from datetime import datetime, timezone
from typing import Any, Literal
from pydantic import BaseModel, Field

class AudioRef(BaseModel):
    source_type: Literal["file", "youtube"] = "file"
    path: str | None = None
    yt_url: str | None = None
    duration_seconds: float | None = None

class CueModel(BaseModel):
    id: UUID = Field(default_factory=uuid4)
    timestamp: float             # seconds from start
    mode: Literal["animation", "frame_sequence"] = "animation"
    animation: str | None = None
    params: dict[str, Any] = Field(default_factory=dict)

class TrackModel(BaseModel):
    device_id: UUID
    cues: list[CueModel] = Field(default_factory=list)

class AnalysisBlock(BaseModel):
    analysed_at: datetime | None = None
    tempo_bpm: float | None = None
    beat_times: list[float] = Field(default_factory=list)
    onset_times: list[float] = Field(default_factory=list)

class ShowModel(BaseModel):
    schema_version: int = 1       # SHW-01: required field
    id: UUID = Field(default_factory=uuid4)
    name: str
    created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
    updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
    audio: AudioRef = Field(default_factory=AudioRef)
    devices: list[DeviceConfig] = Field(default_factory=list)   # SHW-02: device snapshot
    analysis: AnalysisBlock = Field(default_factory=AnalysisBlock)
    tracks: list[TrackModel] = Field(default_factory=list)      # SHW-02: animation blocks
    ai_sequences: list[Any] = Field(default_factory=list)

Key Pydantic v2 notes:

  • Use model_dump_json() for JSON serialization (replaces v1 .json())
  • Use model_dump() for dict output
  • Use Model.model_validate(data) for deserialization (replaces v1 .parse_obj())
  • Field(default_factory=uuid4) is the correct v2 idiom for UUID defaults
  • model_config = {"populate_by_name": True} allows both alias and field name on input

Pattern 3: BaseDevice ABC for INF-02

What: All device types inherit from BaseDevice. The Show Engine and UDP Sender only call methods on BaseDevice. Adding SK9822 in Phase 5 requires zero changes outside devices/.

# lightsync/devices/base.py
# Source: https://docs.python.org/3/library/abc.html
from abc import ABC, abstractmethod
from lightsync.models.device import DeviceConfig

class BaseDevice(ABC):
    def __init__(self, config: DeviceConfig):
        self.config = config

    @property
    def id(self):
        return self.config.id

    @property
    def led_count(self) -> int:
        return self.config.led_count

    @abstractmethod
    def encode_frame(self, pixels: list[tuple]) -> bytes:
        """Encode a full pixel frame to UDP payload bytes.
        pixels: list of (R, G, B) or (R, G, B, W) tuples.
        """
        ...

    @abstractmethod
    def encode_animation_cmd(self, animation: str, params: dict) -> bytes:
        """Encode an animation+params command packet."""
        ...

    @property
    @abstractmethod
    def bytes_per_pixel(self) -> int:
        """Number of bytes per LED in frame mode (3 for RGB, 4 for RGBW)."""
        ...
# lightsync/devices/registry.py
import json
import aiofiles
from pathlib import Path
from lightsync.models.device import DeviceConfig
from lightsync.devices.base import BaseDevice
from lightsync.devices.sk6812 import SK6812Device
from lightsync.devices.ws2801 import WS2801Device

# Registry of strip_type -> device class (INF-02: add new type here only)
_DEVICE_CLASSES: dict[str, type[BaseDevice]] = {
    "sk6812": SK6812Device,
    "ws2801": WS2801Device,
}

class DeviceRegistry:
    def __init__(self, path: Path):
        self._path = path
        self._devices: dict[str, DeviceConfig] = {}  # id -> config

    async def load(self) -> None:
        if self._path.exists():
            async with aiofiles.open(self._path) as f:
                raw = json.loads(await f.read())
            self._devices = {
                d["id"]: DeviceConfig.model_validate(d)
                for d in raw.get("devices", [])
            }

    async def save(self) -> None:
        data = {"devices": [d.model_dump(mode="json") for d in self._devices.values()]}
        async with aiofiles.open(self._path, "w") as f:
            await f.write(json.dumps(data, indent=2))

    def add(self, config: DeviceConfig) -> None:
        self._devices[str(config.id)] = config

    def remove(self, device_id: str) -> bool:
        return self._devices.pop(device_id, None) is not None

    def get(self, device_id: str) -> DeviceConfig | None:
        return self._devices.get(device_id)

    def list_all(self) -> list[DeviceConfig]:
        return list(self._devices.values())

    def instantiate(self, device_id: str) -> BaseDevice | None:
        """Create a live BaseDevice instance from stored config."""
        config = self.get(device_id)
        if config is None:
            return None
        cls = _DEVICE_CLASSES.get(config.strip_type)
        if cls is None:
            raise ValueError(f"Unknown strip_type: {config.strip_type}")
        return cls(config)

INF-02 enforcement: The _DEVICE_CLASSES dict is the single place a new strip type is registered. The engine never checks strip_type directly. Adding "ws2812b" requires only: (1) create lightsync/devices/ws2812b.py implementing BaseDevice, (2) add one line to _DEVICE_CLASSES.

Pattern 4: Show Store (Load/Save)

# lightsync/store/show_store.py
import json
import aiofiles
from pathlib import Path
from lightsync.models.show import ShowModel

class ShowStore:
    def __init__(self, shows_dir: Path):
        self._dir = shows_dir

    def ensure_dir(self) -> None:
        self._dir.mkdir(parents=True, exist_ok=True)

    def _path_for(self, show_id: str) -> Path:
        return self._dir / f"{show_id}.json"

    async def save(self, show: ShowModel) -> None:
        path = self._path_for(str(show.id))
        async with aiofiles.open(path, "w") as f:
            await f.write(show.model_dump_json(indent=2))

    async def load(self, show_id: str) -> ShowModel | None:
        path = self._path_for(show_id)
        if not path.exists():
            return None
        async with aiofiles.open(path) as f:
            return ShowModel.model_validate_json(await f.read())

    def list_ids(self) -> list[str]:
        return [p.stem for p in self._dir.glob("*.json")]

Note on model_dump_json(): Pydantic v2's native JSON serialization handles UUID, datetime, and nested models automatically — no custom JSON encoder needed.

Pattern 5: REST Route Stubs

# lightsync/api/devices.py
from fastapi import APIRouter, HTTPException
from lightsync.models.device import DeviceConfig
import lightsync.main as state   # access registry via module-level ref

router = APIRouter(tags=["devices"])

@router.get("/")
async def list_devices() -> list[DeviceConfig]:
    return state.registry.list_all()

@router.post("/", status_code=201)
async def add_device(config: DeviceConfig) -> DeviceConfig:
    state.registry.add(config)
    await state.registry.save()
    return config

@router.delete("/{device_id}", status_code=204)
async def remove_device(device_id: str):
    if not state.registry.remove(device_id):
        raise HTTPException(404, "Device not found")
    await state.registry.save()

Dependency injection note: For Phase 1, passing registry via module-level state in main.py is acceptable (single process, no tests). Phase 2 should migrate to FastAPI's Depends() pattern.

Pattern 6: WebSocket Stub

# lightsync/api/ws.py
# Source: https://fastapi.tiangolo.com/advanced/websockets/
import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect

router = APIRouter()

class ConnectionManager:
    def __init__(self):
        self.active_connections: list[WebSocket] = []

    async def connect(self, ws: WebSocket) -> None:
        await ws.accept()
        self.active_connections.append(ws)

    def disconnect(self, ws: WebSocket) -> None:
        self.active_connections.remove(ws)

    async def broadcast(self, message: dict) -> None:
        dead = []
        for connection in self.active_connections:
            try:
                await connection.send_text(json.dumps(message))
            except Exception:
                dead.append(connection)
        for d in dead:
            self.active_connections.remove(d)

manager = ConnectionManager()

@router.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await manager.connect(websocket)
    try:
        while True:
            data = await websocket.receive_text()
            msg = json.loads(data)
            # Phase 2 will dispatch msg["type"] to handlers
            # For now: echo back with type=ack
            await websocket.send_text(json.dumps({"type": "ack", "echo": msg}))
    except WebSocketDisconnect:
        manager.disconnect(websocket)

Dead connection handling: The broadcast method catches send failures and removes dead connections. Without this, a stale connection blocks the entire broadcast.

Pattern 7: StaticFiles Mount — Critical Ordering

# API routes MUST be registered before the StaticFiles mount
# StaticFiles at "/" is a catch-all — it intercepts any path not matched above
app.include_router(shows.router, prefix="/api/shows")
app.include_router(devices.router, prefix="/api/devices")
app.include_router(ws.router)
# StaticFiles LAST
app.mount("/", StaticFiles(directory=frontend_dir, html=True), name="frontend")

If StaticFiles is mounted before API routes, all /api/* requests return 404 from the static file handler.

Pattern 8: Terminal Aesthetic CSS

What: Pure CSS with custom properties, CSS Grid for the DAW layout, no framework.

/* lightsync/frontend/style.css */
/* Source: CSS-Tricks terminal aesthetic, terminal.css patterns */

:root {
    --bg-primary:    #0a0a0a;
    --bg-panel:      #0f0f0f;
    --bg-panel-dark: #080808;
    --border-dim:    #1a4a4a;      /* dimmed cyan for panel borders */
    --border-bright: #00ffff;      /* full cyan for active/focus */
    --text-primary:  #cccccc;
    --text-dim:      #555555;
    --text-accent:   #00ffff;      /* cyan accent */
    --text-warning:  #ff6600;
    --font-mono:     'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
    --font-size:     13px;
    --panel-gap:     1px;          /* tight packing, terminal feel */
}

*, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

body {
    background: var(--bg-primary);
    color: var(--text-primary);
    font-family: var(--font-mono);
    font-size: var(--font-size);
    line-height: 1.4;
    height: 100vh;
    overflow: hidden;              /* DAW: no scroll, everything fits viewport */
}

/* DAW layout — matches D-06 through D-10 panel arrangement */
.app-shell {
    display: grid;
    grid-template-rows: 40px 1fr 48px;   /* header, main, transport */
    grid-template-columns: 280px 1fr;    /* sidebar, timeline */
    grid-template-areas:
        "header  header"
        "sidebar main"
        "transport transport";
    height: 100vh;
    gap: var(--panel-gap);
}

.header {
    grid-area: header;
    background: var(--bg-panel-dark);
    border-bottom: 1px solid var(--border-dim);
    display: flex;
    align-items: center;
    padding: 0 16px;
    gap: 24px;
}

.header-title {
    color: var(--text-accent);
    font-size: 16px;
    letter-spacing: 0.15em;
    text-transform: uppercase;
}

.sidebar {
    grid-area: sidebar;
    display: flex;
    flex-direction: column;
    border-right: 1px solid var(--border-dim);
    overflow: hidden;
}

.panel {
    border: 1px solid var(--border-dim);
    background: var(--bg-panel);
    display: flex;
    flex-direction: column;
    overflow: hidden;
}

.panel-header {
    background: var(--bg-panel-dark);
    border-bottom: 1px solid var(--border-dim);
    padding: 6px 12px;
    font-size: 11px;
    letter-spacing: 0.12em;
    color: var(--text-accent);
    text-transform: uppercase;     /* D-14 */
    flex-shrink: 0;
}

.panel-content {
    flex: 1;
    padding: 8px 12px;
    overflow-y: auto;
}

/* Scrollbar styling — terminal feel */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: var(--bg-primary); }
::-webkit-scrollbar-thumb { background: var(--border-dim); }

.main-area {
    grid-area: main;
    background: var(--bg-panel-dark);
    border: 1px solid var(--border-dim);
    display: flex;
    align-items: center;
    justify-content: center;
    color: var(--text-dim);
    font-size: 14px;
    letter-spacing: 0.1em;
}

.transport-bar {
    grid-area: transport;
    background: var(--bg-panel-dark);
    border-top: 1px solid var(--border-dim);
    display: flex;
    align-items: center;
    padding: 0 16px;
    color: var(--text-dim);
    font-size: 12px;
    letter-spacing: 0.08em;
}

/* Form elements — terminal style */
input, select, button {
    background: var(--bg-panel);
    border: 1px solid var(--border-dim);
    color: var(--text-primary);
    font-family: var(--font-mono);
    font-size: var(--font-size);
    padding: 4px 8px;
    outline: none;
}

button:hover {
    border-color: var(--border-bright);
    color: var(--text-accent);
    cursor: pointer;
}

/* Status indicator */
.status-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;           /* small exception — status dots are conventionally round */
    background: var(--text-dim);
}
.status-dot.connected { background: #00ff88; }
.status-dot.error { background: #ff3333; }

Key CSS decisions:

  • border-radius: 0 everywhere (D-15) — border-radius: 50% only for status dot (functional exception)
  • Grid layout for the outer shell locks all panel positions
  • overflow: hidden on body prevents any scroll; each panel manages its own overflow
  • Custom properties at :root level — planner should avoid hardcoding any color values

Pattern 9: Frontend HTML Shell

<!-- lightsync/frontend/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>LIGHTSYNC</title>
    <link rel="stylesheet" href="/style.css">
    <!-- Google Fonts — JetBrains Mono -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
    <div class="app-shell">

        <header class="header">
            <span class="header-title">⬛ LIGHTSYNC</span>
            <div class="status-dot" id="status-dot"></div>
            <span id="status-text" class="text-dim">OFFLINE</span>
            <span id="show-name" class="text-dim">— no show loaded —</span>
        </header>

        <aside class="sidebar">
            <div class="panel" style="flex: 1">
                <div class="panel-header">DEVICES</div>
                <div class="panel-content" id="device-list">
                    <!-- Populated by app.js -->
                </div>
            </div>
            <div class="panel" style="flex: 0 0 120px">
                <div class="panel-header">ANIMATIONS</div>
                <div class="panel-content text-dim">[ PHASE 3+ ]</div>
            </div>
        </aside>

        <main class="main-area">
            <span>[ TIMELINE — PHASE 2+ ]</span>
        </main>

        <footer class="transport-bar">
            <span>[ TRANSPORT — PHASE 2+ ]</span>
        </footer>

    </div>
    <script src="/app.js" type="module"></script>
</body>
</html>

Font fallback chain: If the Google Fonts request fails (offline dev), the CSS variable --font-mono falls back to 'Fira Code', then 'Consolas' (Windows default monospace), then monospace.

Pattern 10: WebSocket Client Stub (JS)

// lightsync/frontend/app.js
// Minimal WebSocket stub — Phase 2 expands protocol handling

const WS_URL = `ws://${location.host}/ws`;

class LightSyncClient {
    constructor() {
        this.ws = null;
        this.reconnectDelay = 2000;
    }

    connect() {
        this.ws = new WebSocket(WS_URL);

        this.ws.onopen = () => {
            document.getElementById("status-dot").className = "status-dot connected";
            document.getElementById("status-text").textContent = "CONNECTED";
        };

        this.ws.onclose = () => {
            document.getElementById("status-dot").className = "status-dot";
            document.getElementById("status-text").textContent = "OFFLINE";
            setTimeout(() => this.connect(), this.reconnectDelay);
        };

        this.ws.onmessage = (event) => {
            const msg = JSON.parse(event.data);
            this.handleMessage(msg);
        };
    }

    handleMessage(msg) {
        // Phase 2 will dispatch on msg.type
        console.debug("[ws]", msg);
    }

    send(msg) {
        if (this.ws?.readyState === WebSocket.OPEN) {
            this.ws.send(JSON.stringify(msg));
        }
    }
}

const client = new LightSyncClient();
client.connect();

// Load device list on startup
async function loadDevices() {
    const res = await fetch("/api/devices/");
    const devices = await res.json();
    const list = document.getElementById("device-list");
    if (devices.length === 0) {
        list.innerHTML = '<span class="text-dim">no devices registered</span>';
        return;
    }
    list.innerHTML = devices.map(d =>
        `<div class="device-row">
            <span class="text-accent">${d.name}</span>
            <span class="text-dim">${d.strip_type} / ${d.led_count} LEDs</span>
        </div>`
    ).join("");
}

loadDevices();

Anti-Patterns to Avoid

  • StaticFiles before API routes: Mount static files last or /api/* paths return 404
  • Blocking file I/O in async handlers: Always use aiofiles for JSON reads/writes — stdlib open() blocks the event loop
  • Module-level device instantiation: Don't call DeviceRegistry.load() at import time — put it in lifespan
  • Pydantic v1 .json() and .parse_obj(): These are removed in v2; use .model_dump_json() and .model_validate()
  • @app.on_event("startup"): Deprecated; use the lifespan context manager
  • Hardcoded paths: Use Path(__file__).parent for paths relative to the package, not hardcoded strings like "lightsync/frontend"

Don't Hand-Roll

Problem Don't Build Use Instead Why
JSON validation on input Manual type checks in route handlers Pydantic BaseModel as route body type FastAPI auto-validates, auto-returns 422 on bad input
File serving Custom route that reads files StaticFiles(html=True) Handles 404, directory index, cache headers, MIME types
UUID generation str(random.randint(...)) uuid4() as Pydantic default_factory Collision-proof, standard format
Async file write Threads + stdlib open() aiofiles.open() Non-blocking; stdlib blocks the event loop
WebSocket connection tracking Raw dict with manual cleanup ConnectionManager pattern (see above) Handles disconnects, dead connection removal
CSS terminal font stack Single hardcoded font CSS custom property --font-mono with fallbacks Survives font load failure on offline dev

Key insight: FastAPI + Pydantic v2 handles about 60% of what you'd otherwise write by hand (validation, serialization, 422 errors, OpenAPI docs). Don't add logic that duplicates what the framework already does.


Common Pitfalls

Pitfall 1: StaticFiles Intercepts API Routes

What goes wrong: /api/devices/ returns 404 from the static file handler instead of the router. Why it happens: app.mount() is processed in definition order. If StaticFiles at "/" is mounted before include_router(), it matches everything. How to avoid: Always mount StaticFiles after all include_router() calls. Warning signs: All API routes return 404 with Content-Type: text/html (the 404.html from StaticFiles).

Pitfall 2: Pydantic v2 Breaking Changes from v1

What goes wrong: Code works during local dev but breaks on a clean install because of v1/v2 API differences. Why it happens: STACK.md and ARCHITECTURE.md use some v1 patterns (.json(), .parse_obj()). How to avoid: Use only v2 APIs: model_dump(), model_dump_json(), model_validate(), model_validate_json(). Never call .dict() or .json(). Warning signs: AttributeError: 'ShowModel' object has no attribute 'json'

Pitfall 3: uvicorn Hot Reload on Windows

What goes wrong: uvicorn.run(..., reload=True) crashes with a multiprocessing error on Windows when called without if __name__ == "__main__" guard. Why it happens: Windows uses spawn (not fork) for multiprocessing. Without the guard, the worker process re-executes the module and tries to start another server. How to avoid: Always use if __name__ == "__main__" in __main__.py. Do NOT enable reload by default; document it as a dev-only flag. Warning signs: RuntimeError: This event loop is already running or AssertionError on Windows startup.

Pitfall 4: Path Separator Bugs on Windows

What goes wrong: Show file paths stored as /shows/my_show.json fail on Windows because Windows uses backslash. Why it happens: Hardcoded forward slashes in path strings. How to avoid: Use pathlib.Path everywhere. Path("shows") / show_id handles separators automatically. Never concatenate path strings. Warning signs: FileNotFoundError with forward-slash paths on Windows.

Pitfall 5: DeviceConfig id as UUID vs str

What goes wrong: device_id parameter in URL routes is typed as str but the registry stores keys as str(UUID). Mismatch causes lookups to silently fail. Why it happens: UUID __str__ uses lowercase hex with dashes; manual construction may differ. How to avoid: Store registry keys as str(config.id) explicitly. In route handlers, accept device_id: str and pass directly to registry.get(device_id). Warning signs: DELETE /devices/{id} returns 404 for a valid device.

Pitfall 6: Frontend Google Fonts on Offline Dev

What goes wrong: The UI uses a raw system monospace font instead of JetBrains Mono during offline development. Why it happens: Google Fonts CDN is unavailable without network. How to avoid: This is acceptable behavior — CSS fallback chain handles it. Alternatively, bundle the font file in lightsync/frontend/fonts/. Document the expected behavior. Warning signs: UI renders in Courier New instead of JetBrains Mono — cosmetic only, not a bug.


State of the Art

Old Approach Current Approach When Changed Impact
@app.on_event("startup") lifespan= context manager FastAPI 0.93 (2023) Old events deprecated; lifespan is now the only supported pattern
model.json() (Pydantic v1) model.model_dump_json() Pydantic v2 (2023) v1 methods raise AttributeError in v2
Model.parse_obj(data) (v1) Model.model_validate(data) Pydantic v2 (2023) Same — v1 API removed
uvicorn[all] extra uvicorn[standard] extra ~2023 [all] was removed; [standard] is the correct WebSocket + watchfiles bundle

Deprecated/outdated:

  • @app.on_event: Deprecated since FastAPI 0.93; still works but emits deprecation warnings
  • pydantic.validator: Replaced by @field_validator in v2; old decorator removed

Environment Availability

Step 2.6: The project runs on Windows 11 (primary runtime). Research machine is Linux. Environment audit is for the Windows 11 target.

Dependency Required By Available (Windows 11) Notes Fallback
Python 3.11 INF-01 Must verify Standard install from python.org or winget
uv Dependency mgmt Recommended winget install astral-sh.uv or pip install uv pip directly
FastAPI + uvicorn INF-01 Install via uv No OS-level dependencies
Pydantic v2 Models FastAPI dependency Installed automatically
aiofiles Show store Install via uv Pure Python, no OS deps
JetBrains Mono font UI-02 Served via Google Fonts CDN Requires internet; fallback to system monospace Consolas (Windows built-in)
Browser UI-01 Chrome/Edge/Firefox on Win11 Any modern browser works

Missing dependencies with no fallback:

  • Python 3.11 must be present on the Windows 11 machine before any installation step

Missing dependencies with fallback:

  • Google Fonts CDN: fallback to Consolas (Windows built-in monospace)
  • uv: fallback to pip

Windows-specific notes:

  • asyncio.ProactorEventLoop is the Windows default since Python 3.8 — no configuration needed for UDP and subprocess (Phase 2+)
  • uvicorn.run() uses WindowsSelectorEventLoopPolicy internally when --reload is active — don't fight this
  • File paths: use pathlib.Path throughout; show_dir = Path("shows") resolves relative to CWD on Windows correctly

Open Questions

  1. Device registry storage: separate devices.json vs embedded in each show

    • What we know: ARCHITECTURE.md says "Config in show JSON or separate devices.json"
    • What's unclear: The show file embeds a device snapshot (for portability), but the registry for the UI list needs a separate source of truth
    • Recommendation (Claude's discretion): Use bothdevices.json is the live registry (what the UI CRUD panel manages), and each show file embeds a snapshot of the devices at the time of show creation. This is consistent with ARCHITECTURE.md's "self-contained show file" principle.
  2. Dependency injection for registry/store in route handlers

    • What we know: Module-level globals in main.py work for Phase 1 (single process)
    • What's unclear: FastAPI's Depends() is cleaner but adds boilerplate for a solo project
    • Recommendation: Use module-level state for Phase 1. Add a TODO comment to migrate to Depends() when tests are added in Phase 3.
  3. Show name vs show ID in URL routes

    • What we know: Show IDs are UUIDs (hard to type in browser), names are user-defined
    • Recommendation: /api/shows/ returns list with both; /api/shows/{show_id} uses UUID. Frontend always uses the UUID from the API response.

Code Examples

Full pyproject.toml

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "lightsync"
version = "0.1.0"
description = "Music-to-light synchronization system"
requires-python = ">=3.11"
dependencies = [
    "fastapi>=0.115.0",
    "uvicorn[standard]>=0.34.0",
    "aiofiles>=24.0.0",
    "python-dotenv>=1.0.0",
    "structlog>=24.0.0",
]

[project.optional-dependencies]
dev = [
    "httpx>=0.27.0",
]

[tool.hatch.build.targets.wheel]
packages = ["lightsync"]

Note: Pydantic is not listed separately — it is pulled in as a FastAPI dependency. If a specific version pin is needed: add "pydantic>=2.0.0" to dependencies.

SK6812 Device Implementation Example

# lightsync/devices/sk6812.py
from lightsync.devices.base import BaseDevice

class SK6812Device(BaseDevice):
    """RGBW strip — 4 bytes per LED."""

    @property
    def bytes_per_pixel(self) -> int:
        return 4

    def encode_frame(self, pixels: list[tuple]) -> bytes:
        # WLED DRGBW protocol: header [3, timeout_seconds]
        payload = bytearray([3, 2])
        for pixel in pixels:
            r, g, b = pixel[:3]
            w = pixel[3] if len(pixel) > 3 else 0
            payload.extend([r, g, b, w])
        return bytes(payload)

    def encode_animation_cmd(self, animation: str, params: dict) -> bytes:
        import json
        cmd = {"type": "animation", "anim": animation, "params": params}
        return json.dumps(cmd).encode("utf-8")

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence — from STACK.md/ARCHITECTURE.md, accepted as project-internal decisions)

  • .planning/research/STACK.md — version recommendations, technology decisions
  • .planning/research/ARCHITECTURE.md — show file JSON schema, component boundaries, data flow

Metadata

Confidence breakdown:

  • Standard stack: HIGH — FastAPI/Pydantic v2/uvicorn are stable, well-documented, official docs verified
  • Architecture: HIGH — patterns from official FastAPI docs; device ABC is standard Python
  • CSS terminal aesthetic: MEDIUM — no official spec; based on CSS custom properties patterns + terminal.css reference
  • Windows pitfalls: HIGH — uvicorn/Windows reload issue is documented in FastAPI discussions
  • Pydantic v2 API: HIGH — official docs consulted, v1→v2 migration changes verified

Research date: 2026-04-05 Valid until: 2026-10-05 (stable libraries; Pydantic v2 and FastAPI 0.115.x are not fast-moving)