docs(01-foundation): create phase plan — 3 plans in 2 waves

This commit is contained in:
Claude
2026-04-05 18:40:20 +00:00
parent 519849e855
commit 5721bef791
4 changed files with 791 additions and 7 deletions

View File

@@ -0,0 +1,279 @@
---
phase: 01-foundation
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- pyproject.toml
- lightsync/__init__.py
- lightsync/__main__.py
- lightsync/main.py
- lightsync/models/__init__.py
- lightsync/models/device.py
- lightsync/models/show.py
- lightsync/devices/__init__.py
- lightsync/devices/base.py
- lightsync/devices/sk6812.py
- lightsync/devices/ws2801.py
- lightsync/devices/registry.py
- lightsync/store/__init__.py
- lightsync/store/show_store.py
- lightsync/api/__init__.py
- lightsync/api/devices.py
- lightsync/api/shows.py
- lightsync/api/ws.py
- .env
autonomous: true
requirements:
- INF-01
- INF-02
- DEV-01
- DEV-02
- SHW-01
- SHW-02
must_haves:
truths:
- "A DeviceConfig can be created with name, strip_type, led_count, ip, port and serialized to JSON"
- "A ShowModel can be created with schema_version=1, audio ref, device snapshot, empty tracks and serialized to JSON"
- "Adding a new strip type requires only a new device class file and one entry in _DEVICE_CLASSES dict"
- "DeviceRegistry loads from and saves to devices.json — data survives process restart"
- "ShowStore saves a ShowModel as JSON file and loads it back identically"
- "python -m lightsync starts the FastAPI server without error"
artifacts:
- path: "lightsync/models/device.py"
provides: "DeviceConfig Pydantic model"
contains: "class DeviceConfig(BaseModel)"
- path: "lightsync/models/show.py"
provides: "ShowModel with schema_version, AudioRef, TrackModel, CueModel, AnalysisBlock"
contains: "schema_version: int = 1"
- path: "lightsync/devices/base.py"
provides: "BaseDevice ABC with encode_frame, encode_animation_cmd, bytes_per_pixel"
contains: "class BaseDevice(ABC)"
- path: "lightsync/devices/registry.py"
provides: "DeviceRegistry with load/save/add/remove/list_all"
contains: "_DEVICE_CLASSES"
- path: "lightsync/store/show_store.py"
provides: "ShowStore with save/load/list_ids"
contains: "model_dump_json"
key_links:
- from: "lightsync/devices/registry.py"
to: "lightsync/devices/base.py"
via: "_DEVICE_CLASSES maps strip_type to BaseDevice subclass"
pattern: "_DEVICE_CLASSES.*BaseDevice"
- from: "lightsync/devices/registry.py"
to: "lightsync/models/device.py"
via: "DeviceConfig used for persistence"
pattern: "DeviceConfig.model_validate"
- from: "lightsync/store/show_store.py"
to: "lightsync/models/show.py"
via: "ShowModel serialization"
pattern: "ShowModel.model_validate_json"
---
<objective>
Build the complete Python backend scaffold: package structure, Pydantic data models, device abstraction (BaseDevice ABC + SK6812/WS2801), device registry with JSON persistence, show store with JSON save/load, FastAPI app with lifespan, and WebSocket stub.
Purpose: Establishes every backend module, data model, and persistence mechanism that all future phases depend on. The models defined here are the contracts for the entire system.
Output: A runnable `python -m lightsync` that starts FastAPI with route stubs, device registry, and show store.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-RESEARCH.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Create package structure, Pydantic models, and pyproject.toml</name>
<files>
pyproject.toml
.env
lightsync/__init__.py
lightsync/models/__init__.py
lightsync/models/device.py
lightsync/models/show.py
lightsync/devices/__init__.py
lightsync/devices/base.py
lightsync/devices/sk6812.py
lightsync/devices/ws2801.py
</files>
<read_first>
.planning/phases/01-foundation/01-RESEARCH.md
.planning/phases/01-foundation/01-CONTEXT.md
</read_first>
<action>
Create pyproject.toml (per D-05) with:
- [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"]
Create .env with HOST=0.0.0.0 and PORT=8000.
Create lightsync/__init__.py with `__version__ = "0.1.0"`.
Create lightsync/models/__init__.py that re-exports DeviceConfig, ShowModel.
Create lightsync/models/device.py (per Research Pattern 2):
- 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}
Create lightsync/models/show.py (per Research Pattern 2):
- 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, 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, 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), analysis: AnalysisBlock = Field(default_factory=AnalysisBlock), tracks: list[TrackModel] = Field(default_factory=list), ai_sequences: list[Any] = Field(default_factory=list)
Create lightsync/devices/__init__.py (empty).
Create lightsync/devices/base.py (per Research Pattern 3):
- class BaseDevice(ABC) with __init__(self, config: DeviceConfig), properties id and led_count
- @abstractmethod encode_frame(self, pixels: list[tuple]) -> bytes
- @abstractmethod encode_animation_cmd(self, animation: str, params: dict) -> bytes
- @property @abstractmethod bytes_per_pixel(self) -> int
Create lightsync/devices/sk6812.py:
- class SK6812Device(BaseDevice) with bytes_per_pixel = 4
- encode_frame: iterate pixels, for each (r, g, b, w) append 4 bytes to bytearray; return bytes
- encode_animation_cmd: stub returning b"" (Phase 3 implements real encoding)
Create lightsync/devices/ws2801.py:
- class WS2801Device(BaseDevice) with bytes_per_pixel = 3
- encode_frame: iterate pixels, for each (r, g, b) append 3 bytes to bytearray; return bytes
- encode_animation_cmd: stub returning b"" (Phase 3 implements real encoding)
</action>
<verify>
<automated>cd /home/claude/led2 && python -c "from lightsync.models.device import DeviceConfig; d = DeviceConfig(name='test', strip_type='sk6812', led_count=60, ip='192.168.1.10', port=21324); print(d.model_dump_json())" && python -c "from lightsync.models.show import ShowModel; s = ShowModel(name='test'); print(s.schema_version); assert s.schema_version == 1; print(s.model_dump_json()[:100])" && python -c "from lightsync.devices.sk6812 import SK6812Device; from lightsync.models.device import DeviceConfig; d = SK6812Device(DeviceConfig(name='t', strip_type='sk6812', led_count=1, ip='0', port=1)); assert d.bytes_per_pixel == 4; print('OK')"</automated>
</verify>
<acceptance_criteria>
- pyproject.toml contains `name = "lightsync"` and `fastapi>=0.115.0` and `aiofiles>=24.0.0`
- lightsync/models/device.py contains `class DeviceConfig(BaseModel)` and `StripType = Literal["sk6812", "ws2801", "generic"]`
- lightsync/models/show.py contains `schema_version: int = 1` and `class ShowModel(BaseModel)` and `class AudioRef(BaseModel)` and `class TrackModel(BaseModel)` and `class CueModel(BaseModel)` and `class AnalysisBlock(BaseModel)`
- lightsync/devices/base.py contains `class BaseDevice(ABC)` and `def encode_frame` and `def encode_animation_cmd` and `def bytes_per_pixel`
- lightsync/devices/sk6812.py contains `class SK6812Device(BaseDevice)` and `bytes_per_pixel` returning 4
- lightsync/devices/ws2801.py contains `class WS2801Device(BaseDevice)` and `bytes_per_pixel` returning 3
- `python -c "from lightsync.models.show import ShowModel; s = ShowModel(name='x'); assert s.schema_version == 1"` exits 0
</acceptance_criteria>
<done>All Pydantic models serialize/deserialize correctly. BaseDevice ABC exists with two concrete implementations. pyproject.toml lists all Phase 1 dependencies.</done>
</task>
<task type="auto">
<name>Task 2: Create device registry, show store, FastAPI app with lifespan, API route stubs, and WebSocket stub</name>
<files>
lightsync/devices/registry.py
lightsync/store/__init__.py
lightsync/store/show_store.py
lightsync/api/__init__.py
lightsync/api/devices.py
lightsync/api/shows.py
lightsync/api/ws.py
lightsync/main.py
lightsync/__main__.py
</files>
<read_first>
.planning/phases/01-foundation/01-RESEARCH.md
lightsync/models/device.py
lightsync/models/show.py
lightsync/devices/base.py
</read_first>
<action>
Create lightsync/devices/registry.py (per Research Pattern 3):
- _DEVICE_CLASSES: dict[str, type[BaseDevice]] = {"sk6812": SK6812Device, "ws2801": WS2801Device}
- class DeviceRegistry with __init__(self, path: Path), _devices: dict[str, DeviceConfig]
- async load(): read JSON from self._path, parse each entry via DeviceConfig.model_validate
- async save(): serialize all devices via model_dump(mode="json"), write JSON with indent=2
- add(config: DeviceConfig), remove(device_id: str) -> bool, get(device_id: str) -> DeviceConfig | None, list_all() -> list[DeviceConfig]
- instantiate(device_id: str) -> BaseDevice | None: lookup config, lookup class in _DEVICE_CLASSES, return instance
Create lightsync/store/__init__.py (empty).
Create lightsync/store/show_store.py (per Research Pattern 4):
- class ShowStore with __init__(self, shows_dir: Path)
- ensure_dir(): self._dir.mkdir(parents=True, exist_ok=True)
- _path_for(show_id: str) -> Path: return self._dir / f"{show_id}.json"
- async save(show: ShowModel): write show.model_dump_json(indent=2) to _path_for(str(show.id))
- async load(show_id: str) -> ShowModel | None: read file, return ShowModel.model_validate_json(content)
- list_ids() -> list[str]: return [p.stem for p in self._dir.glob("*.json")]
Create lightsync/api/__init__.py (empty).
Create lightsync/api/devices.py (per Research Pattern 5):
- router = APIRouter(tags=["devices"])
- GET / -> list[DeviceConfig]: return state.registry.list_all()
- POST / status_code=201 -> DeviceConfig: registry.add(config), await registry.save(), return config
- DELETE /{device_id} status_code=204: registry.remove or raise HTTPException(404)
- Import registry via `import lightsync.main as state` and access `state.registry`
Create lightsync/api/shows.py:
- router = APIRouter(tags=["shows"])
- GET / -> list of show summaries: iterate show_store.list_ids(), load each, return list of {id, name, created_at}
- POST / status_code=201: accept ShowModel body, save via show_store, return show
- GET /{show_id}: load from show_store or raise 404
- Import show_store via `import lightsync.main as state` and access `state.show_store`
Create lightsync/api/ws.py (per Research Pattern 6):
- class ConnectionManager with active_connections list, connect/disconnect/broadcast methods
- module-level `manager = ConnectionManager()`
- @router.websocket("/ws"): accept, loop receive_text, echo back {"type": "ack", "echo": msg}
- Handle WebSocketDisconnect with manager.disconnect
Create lightsync/main.py (per Research Pattern 1):
- Module-level: registry: DeviceRegistry | None = None, show_store: ShowStore | None = None
- @asynccontextmanager async def lifespan(app): initialize registry from Path("devices.json"), await registry.load(), initialize show_store from Path("shows"), show_store.ensure_dir(), yield, await registry.save()
- def create_app() -> FastAPI: create app with title="LightSync" and lifespan, include_router for shows (prefix="/api/shows"), devices (prefix="/api/devices"), ws; mount StaticFiles LAST at "/" with directory=Path(__file__).parent / "frontend" and html=True
- app = create_app()
Create lightsync/__main__.py (per Research Pattern 1):
- if __name__ == "__main__": load_dotenv(), uvicorn.run("lightsync.main:app", host from env default "0.0.0.0", port from env default 8000, reload=False)
</action>
<verify>
<automated>cd /home/claude/led2 && pip install -e ".[dev]" 2>&1 | tail -3 && python -c "from lightsync.devices.registry import DeviceRegistry; from lightsync.store.show_store import ShowStore; print('imports OK')" && timeout 5 python -m lightsync 2>&1 || true</automated>
</verify>
<acceptance_criteria>
- lightsync/devices/registry.py contains `_DEVICE_CLASSES` and `class DeviceRegistry` and `async def load` and `async def save` and `def instantiate`
- lightsync/store/show_store.py contains `class ShowStore` and `model_dump_json` and `model_validate_json` and `def list_ids`
- lightsync/api/devices.py contains `router = APIRouter` and `async def list_devices` and `async def add_device` and `async def remove_device`
- lightsync/api/shows.py contains `router = APIRouter` and `/api/shows` routes for GET and POST
- lightsync/api/ws.py contains `class ConnectionManager` and `@router.websocket("/ws")`
- lightsync/main.py contains `async def lifespan` and `StaticFiles` and `include_router` and routes registered BEFORE StaticFiles mount
- lightsync/__main__.py contains `if __name__ == "__main__"` and `uvicorn.run`
- `pip install -e .` completes without error
- `python -m lightsync` starts and listens on port 8000 (may fail on missing frontend dir — that is OK, plan 01-02 creates it)
</acceptance_criteria>
<done>FastAPI app starts via `python -m lightsync`. Device registry persists to devices.json. Show store saves/loads JSON files. All API route stubs respond. WebSocket stub echoes messages.</done>
</task>
</tasks>
<verification>
1. `pip install -e ".[dev]"` succeeds
2. `python -c "from lightsync.models.show import ShowModel; s = ShowModel(name='test'); assert s.schema_version == 1"` passes
3. `python -c "from lightsync.devices.registry import DeviceRegistry; print('OK')"` passes
4. `python -m lightsync` starts without import errors (may warn about missing frontend/ directory)
</verification>
<success_criteria>
- All Python modules under lightsync/ import without error
- Pydantic models serialize to and deserialize from JSON correctly
- DeviceRegistry round-trips through devices.json
- ShowStore round-trips through shows/ directory
- FastAPI app starts and registers all route prefixes
- Adding a new strip type requires only a new file in devices/ and one dict entry
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,251 @@
---
phase: 01-foundation
plan: 02
type: execute
wave: 1
depends_on: []
files_modified:
- lightsync/frontend/index.html
- lightsync/frontend/style.css
- lightsync/frontend/app.js
autonomous: true
requirements:
- UI-01
- UI-02
- DEV-03
must_haves:
truths:
- "The web app loads in a browser showing the full DAW skeleton layout"
- "The UI has terminal/hacker aesthetic — dark background, monospace fonts, cyan accents, no generic SaaS look"
- "All final panels are visible: DEVICES, ANIMATIONS, TIMELINE, TRANSPORT — most as labeled placeholders"
- "The DEVICES panel has an add-device form and a device list area"
- "Panel headers are uppercase and borders are dimmed cyan — matching terminal window pane style"
artifacts:
- path: "lightsync/frontend/index.html"
provides: "Full DAW skeleton HTML structure with all panels"
contains: "LIGHTSYNC"
- path: "lightsync/frontend/style.css"
provides: "Terminal aesthetic CSS — dark theme, monospace, cyan accents"
contains: "--accent"
- path: "lightsync/frontend/app.js"
provides: "WebSocket client stub, device panel interactions, API fetch helpers"
contains: "new WebSocket"
key_links:
- from: "lightsync/frontend/app.js"
to: "/ws"
via: "WebSocket connection for future position broadcasts"
pattern: "new WebSocket.*ws"
- from: "lightsync/frontend/app.js"
to: "/api/devices"
via: "fetch calls for device CRUD"
pattern: "fetch.*api/devices"
- from: "lightsync/frontend/index.html"
to: "lightsync/frontend/style.css"
via: "stylesheet link"
pattern: "style.css"
---
<objective>
Build the complete terminal-aesthetic frontend shell: HTML with full DAW skeleton layout (all panels), CSS with dark theme / monospace / cyan accents, and JavaScript with WebSocket stub and device panel interactivity.
Purpose: Establishes the visual identity and layout structure that all future phases will fill in. The user sees the final app skeleton from day one.
Output: Three files in lightsync/frontend/ — index.html, style.css, app.js — that render the full DAW interface.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-RESEARCH.md
</context>
<tasks>
<task type="auto">
<name>Task 1: Create HTML structure and CSS terminal aesthetic</name>
<files>
lightsync/frontend/index.html
lightsync/frontend/style.css
</files>
<read_first>
.planning/phases/01-foundation/01-CONTEXT.md
.planning/phases/01-foundation/01-RESEARCH.md
</read_first>
<action>
Create lightsync/frontend/ directory.
Create lightsync/frontend/index.html with this structure (per D-06 through D-10):
DOCTYPE html, lang="en", meta charset UTF-8, viewport meta, title "LIGHTSYNC".
Link to style.css, defer script app.js.
Body layout using CSS Grid (per D-06 — full DAW skeleton from Phase 1):
HEADER (per D-07):
- div.header containing:
- ASCII art or stylized text "LIGHTSYNC" in a pre or span element (use simple ASCII block letters or styled text — not an image)
- span.status-indicator with id="status" text "OFFLINE" (will toggle to "CONNECTED" via JS)
- span.show-name with id="show-name" text "No show loaded"
LEFT SIDEBAR (per D-08):
- div.sidebar containing two panels stacked vertically:
- section.panel#devices-panel:
- h2.panel-header text "DEVICES"
- div#device-list (empty, populated by JS)
- form#add-device-form with inputs: name (text), strip_type (select: sk6812, ws2801, generic), led_count (number, min=1, max=1000), ip (text), port (number, min=1, max=65535), and a submit button text "+ ADD DEVICE"
- section.panel#animations-panel:
- h2.panel-header text "ANIMATIONS"
- div.placeholder-content text "[ Phase 3+ ]"
MAIN CENTER (per D-09):
- div.main containing:
- section.panel#timeline-panel:
- h2.panel-header text "TIMELINE"
- div.placeholder-content text "[ TIMELINE -- Phase 2+ ]"
BOTTOM BAR (per D-10):
- div.transport containing:
- section.panel#transport-panel:
- h2.panel-header text "TRANSPORT"
- div.placeholder-content text "[ TRANSPORT -- Phase 2+ ]"
Create lightsync/frontend/style.css with terminal aesthetic (per D-11 through D-15):
CSS custom properties on :root:
- --bg: #0a0a0a (per D-12)
- --bg-panel: #111111
- --accent: #00ffff (per D-11)
- --accent-dim: #004444 (dimmed cyan for borders, per D-13)
- --text: #cccccc
- --text-bright: #ffffff
- --text-dim: #666666
- --font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace (per D-12)
@import url for JetBrains Mono from Google Fonts (weight 400,700).
*, *::before, *::after: box-sizing border-box, margin 0, padding 0.
html, body: height 100%, background var(--bg), color var(--text), font-family var(--font-mono), font-size 14px, overflow hidden.
Body grid layout: grid-template-areas "header header header" / "sidebar main main" / "sidebar main main" / "transport transport transport". grid-template-columns: 280px 1fr. grid-template-rows: auto 1fr auto.
.header: grid-area header, display flex, align-items center, gap 1rem, padding 8px 16px, border-bottom 1px solid var(--accent-dim), background var(--bg-panel).
.header pre (for ASCII title): color var(--accent), font-size 10px, line-height 1.1, white-space pre.
.status-indicator: font-size 11px, text-transform uppercase, padding 2px 8px, border 1px solid var(--accent-dim). When connected: color var(--accent). Default (offline): color #ff4444.
.show-name: margin-left auto, font-size 12px, color var(--text-dim).
.sidebar: grid-area sidebar, display flex, flex-direction column, gap 0, border-right 1px solid var(--accent-dim), overflow-y auto.
.main: grid-area main, display flex, flex-direction column, overflow hidden, padding 8px.
.transport: grid-area transport, border-top 1px solid var(--accent-dim).
.panel: background var(--bg-panel), border 1px solid var(--accent-dim) (per D-13), padding 0, margin 0. No border-radius (per D-15). No box-shadow (per D-15).
.panel-header: text-transform uppercase (per D-14), font-size 11px, letter-spacing 2px, color var(--accent), padding 8px 12px, border-bottom 1px solid var(--accent-dim), font-weight 700.
.placeholder-content: display flex, align-items center, justify-content center, min-height 200px, color var(--text-dim), font-size 13px, letter-spacing 1px.
#devices-panel: flex 1.
#animations-panel: flex 0 0 auto, border-top 1px solid var(--accent-dim).
#timeline-panel: flex 1, display flex, flex-direction column. The .placeholder-content inside: flex 1.
#transport-panel: padding 0.
#transport-panel .placeholder-content: min-height 48px.
#device-list: padding 8px 12px. Each .device-item: display flex, justify-content space-between, align-items center, padding 4px 0, border-bottom 1px solid var(--accent-dim), font-size 12px. .device-item .device-name: color var(--text-bright). .device-item .device-meta: color var(--text-dim), font-size 11px. .device-item .device-remove: background none, border none, color #ff4444, cursor pointer, font-family var(--font-mono), font-size 14px.
#add-device-form: padding 8px 12px, display flex, flex-direction column, gap 6px.
#add-device-form input, #add-device-form select: background var(--bg), border 1px solid var(--accent-dim), color var(--text), font-family var(--font-mono), font-size 12px, padding 4px 8px, outline none. Focus: border-color var(--accent).
#add-device-form button: background var(--accent-dim), border 1px solid var(--accent), color var(--accent), font-family var(--font-mono), font-size 12px, padding 6px, cursor pointer, text-transform uppercase, letter-spacing 1px. Hover: background var(--accent), color var(--bg).
Scrollbar styling for webkit: 6px wide, track var(--bg), thumb var(--accent-dim), thumb hover var(--accent).
</action>
<verify>
<automated>test -f /home/claude/led2/lightsync/frontend/index.html && test -f /home/claude/led2/lightsync/frontend/style.css && grep -c "LIGHTSYNC" /home/claude/led2/lightsync/frontend/index.html && grep -c "\-\-accent.*#00ffff" /home/claude/led2/lightsync/frontend/style.css && grep -c "JetBrains Mono" /home/claude/led2/lightsync/frontend/style.css</automated>
</verify>
<acceptance_criteria>
- lightsync/frontend/index.html contains "LIGHTSYNC" in header area
- lightsync/frontend/index.html contains id="devices-panel" and id="animations-panel" and id="timeline-panel" and id="transport-panel"
- lightsync/frontend/index.html contains id="add-device-form" with inputs for name, strip_type, led_count, ip, port
- lightsync/frontend/index.html contains id="device-list"
- lightsync/frontend/index.html contains "Phase 3+" in animations panel and "Phase 2+" in timeline panel and "Phase 2+" in transport panel
- lightsync/frontend/style.css contains `--accent: #00ffff` and `--bg: #0a0a0a` and `--font-mono`
- lightsync/frontend/style.css contains `text-transform: uppercase` for panel headers
- lightsync/frontend/style.css contains `border-radius` nowhere (per D-15 no rounded corners) OR only `border-radius: 0`
- lightsync/frontend/style.css contains `font-family: 'JetBrains Mono'` or references var(--font-mono) on body
- lightsync/frontend/style.css contains grid-template-areas with "header" and "sidebar" and "main" and "transport"
</acceptance_criteria>
<done>HTML renders full DAW skeleton. CSS applies terminal aesthetic with dark background, monospace font, cyan accents, flat sharp edges. All panels visible with correct labels.</done>
</task>
<task type="auto">
<name>Task 2: Create JavaScript — WebSocket client, device panel CRUD, API helpers</name>
<files>
lightsync/frontend/app.js
</files>
<read_first>
lightsync/frontend/index.html
.planning/phases/01-foundation/01-RESEARCH.md
</read_first>
<action>
Create lightsync/frontend/app.js with these modules:
1. API helper functions:
- async function apiGet(path): fetch(path), check response.ok, return response.json()
- async function apiPost(path, data): fetch(path, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(data)}), return response.json()
- async function apiDelete(path): fetch(path, {method: "DELETE"})
2. WebSocket client stub:
- let ws = null
- function connectWebSocket(): create new WebSocket(`ws://${location.host}/ws`), set onopen to update #status text to "CONNECTED" and add .connected class, set onclose to update #status text to "OFFLINE" and remove .connected class, set onmessage to parse JSON and call handleWsMessage(data), set onerror to console.error. Auto-reconnect: onclose sets setTimeout(connectWebSocket, 2000).
- function handleWsMessage(msg): switch on msg.type — for now just console.log("WS:", msg). Phase 2 will add position/state handlers.
3. Device panel logic:
- async function loadDevices(): call apiGet("/api/devices"), for each device call renderDevice(device), populate #device-list
- function renderDevice(device): create div.device-item containing: span.device-name with device.name, span.device-meta with `${device.strip_type} | ${device.led_count} LEDs | ${device.ip}:${device.port}`, button.device-remove with text "x" and onclick calling removeDevice(device.id)
- async function removeDevice(id): call apiDelete(`/api/devices/${id}`), then loadDevices() to refresh
- Form submit handler on #add-device-form: preventDefault, collect form values (name, strip_type, led_count as int, ip, port as int), call apiPost("/api/devices", data), reset form, call loadDevices()
4. Initialization:
- document.addEventListener("DOMContentLoaded", () => { connectWebSocket(); loadDevices(); })
Add .connected CSS class to style.css for the status indicator: .status-indicator.connected { color: var(--accent); border-color: var(--accent); }
</action>
<verify>
<automated>test -f /home/claude/led2/lightsync/frontend/app.js && grep -c "new WebSocket" /home/claude/led2/lightsync/frontend/app.js && grep -c "api/devices" /home/claude/led2/lightsync/frontend/app.js && grep -c "loadDevices" /home/claude/led2/lightsync/frontend/app.js && grep -c "connectWebSocket" /home/claude/led2/lightsync/frontend/app.js</automated>
</verify>
<acceptance_criteria>
- lightsync/frontend/app.js contains `new WebSocket` connecting to `/ws`
- lightsync/frontend/app.js contains `fetch` calls to `/api/devices` for GET, POST, and DELETE
- lightsync/frontend/app.js contains `function loadDevices` and `function renderDevice` and `function removeDevice`
- lightsync/frontend/app.js contains `DOMContentLoaded` listener calling `connectWebSocket()` and `loadDevices()`
- lightsync/frontend/app.js contains `function handleWsMessage` with msg.type switch/check
- lightsync/frontend/app.js contains auto-reconnect logic (setTimeout with connectWebSocket)
- lightsync/frontend/app.js contains `apiGet` and `apiPost` and `apiDelete` helper functions
</acceptance_criteria>
<done>JavaScript connects WebSocket, displays connection status, loads device list from API, supports adding and removing devices via the form. All interactions use the API helpers.</done>
</task>
</tasks>
<verification>
1. `ls lightsync/frontend/` shows index.html, style.css, app.js
2. index.html contains all four panel IDs: devices-panel, animations-panel, timeline-panel, transport-panel
3. style.css uses --accent: #00ffff and --bg: #0a0a0a
4. app.js connects to /ws and fetches from /api/devices
</verification>
<success_criteria>
- Opening the app in a browser shows: header with LIGHTSYNC title, left sidebar with DEVICES and ANIMATIONS panels, center TIMELINE placeholder, bottom TRANSPORT placeholder
- Dark background (#0a0a0a), monospace fonts, cyan accent color throughout
- No rounded corners, drop shadows, or gradients anywhere
- Device add form is functional (sends POST to /api/devices)
- WebSocket connects and shows CONNECTED status
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,254 @@
---
phase: 01-foundation
plan: 03
type: execute
wave: 2
depends_on:
- 01-01
- 01-02
files_modified:
- lightsync/api/devices.py
- lightsync/api/shows.py
- lightsync/main.py
- docker-compose.prod.yml
- Dockerfile
autonomous: true
requirements:
- INF-01
- DEV-01
- DEV-02
- DEV-03
- SHW-01
- SHW-02
- UI-01
must_haves:
truths:
- "POST /api/devices creates a device that appears in GET /api/devices and persists after restart"
- "DELETE /api/devices/{id} removes the device from the registry and from devices.json"
- "POST /api/shows creates a show file with schema_version=1 in the shows/ directory"
- "GET /api/shows/{id} returns the full show JSON including audio, devices, tracks fields"
- "Opening http://localhost:8000 serves index.html with working device CRUD through the UI"
- "The app runs inside Docker behind Traefik on lightsync.groll.cloud"
artifacts:
- path: "Dockerfile"
provides: "Python alpine container running lightsync"
contains: "python -m lightsync"
- path: "docker-compose.prod.yml"
provides: "Traefik-labeled service for lightsync.groll.cloud"
contains: "lightsync.groll.cloud"
key_links:
- from: "lightsync/frontend/app.js"
to: "lightsync/api/devices.py"
via: "fetch /api/devices — full CRUD round-trip"
pattern: "api/devices"
- from: "lightsync/api/shows.py"
to: "lightsync/store/show_store.py"
via: "ShowStore save/load calls"
pattern: "show_store"
- from: "lightsync/api/devices.py"
to: "lightsync/devices/registry.py"
via: "DeviceRegistry CRUD + persist"
pattern: "registry"
---
<objective>
Wire everything together: verify REST API endpoints work end-to-end with the frontend, add Docker deployment for groll.cloud, and run an integration smoke test confirming device CRUD and show save/load work through the full stack.
Purpose: Proves the foundation actually works as a system — not just individual modules. Deploys to production so the user can see it live.
Output: Working app at lightsync.groll.cloud with device CRUD and show save/load functional.
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/01-foundation/01-CONTEXT.md
@.planning/phases/01-foundation/01-01-SUMMARY.md
@.planning/phases/01-foundation/01-02-SUMMARY.md
<interfaces>
<!-- Key types and contracts from Plan 01 that this plan wires together -->
From lightsync/models/device.py:
```python
StripType = Literal["sk6812", "ws2801", "generic"]
class DeviceConfig(BaseModel):
id: UUID, name: str, strip_type: StripType, led_count: int, ip: str, port: int, enabled: bool
```
From lightsync/models/show.py:
```python
class ShowModel(BaseModel):
schema_version: int = 1, id: UUID, name: str, created_at: datetime, updated_at: datetime,
audio: AudioRef, devices: list[DeviceConfig], analysis: AnalysisBlock, tracks: list[TrackModel]
```
From lightsync/devices/registry.py:
```python
class DeviceRegistry:
async def load(), async def save(), def add(config), def remove(device_id) -> bool,
def get(device_id), def list_all() -> list[DeviceConfig]
```
From lightsync/store/show_store.py:
```python
class ShowStore:
def ensure_dir(), async def save(show), async def load(show_id) -> ShowModel | None, def list_ids() -> list[str]
```
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Integration test — verify API endpoints, fix any wiring issues, add Docker deployment</name>
<files>
lightsync/api/devices.py
lightsync/api/shows.py
lightsync/main.py
Dockerfile
docker-compose.prod.yml
</files>
<read_first>
lightsync/main.py
lightsync/api/devices.py
lightsync/api/shows.py
lightsync/api/ws.py
lightsync/devices/registry.py
lightsync/store/show_store.py
lightsync/frontend/app.js
lightsync/frontend/index.html
</read_first>
<action>
STEP 1 — Install and start the app:
- pip install -e ".[dev]"
- Start `python -m lightsync` in background
- Wait for server ready
STEP 2 — Smoke test device CRUD via curl:
- POST /api/devices with body {"name": "Test Strip", "strip_type": "sk6812", "led_count": 60, "ip": "192.168.1.10", "port": 21324} — expect 201 with id field
- GET /api/devices — expect array containing the created device
- DELETE /api/devices/{id} — expect 204
- GET /api/devices — expect empty array
- Stop and restart the app, POST a device, stop, restart, GET — verify device persists (DEV-02)
STEP 3 — Smoke test show CRUD via curl:
- POST /api/shows with body {"name": "Test Show"} — expect 201 with schema_version=1
- GET /api/shows — expect array with show summary
- GET /api/shows/{id} — expect full show JSON with fields: schema_version, id, name, audio, devices, tracks
- Verify JSON file exists in shows/ directory
STEP 4 — Fix any issues found during smoke test:
- If any endpoint returns unexpected status codes or missing fields, fix the route handler
- If the shows API list endpoint is too slow (loading all JSON files), add a lightweight list that returns only {id, name} without full deserialization
- Ensure the shows POST endpoint generates a UUID if the client doesn't provide one, and sets created_at/updated_at
- Ensure devices POST endpoint generates a UUID if the client doesn't provide one
STEP 5 — Verify frontend integration:
- GET / returns index.html (StaticFiles mount working)
- Verify style.css and app.js are served at /style.css and /app.js
STEP 6 — Create Dockerfile:
- FROM python:3.11-alpine
- WORKDIR /app
- COPY pyproject.toml .
- RUN pip install --no-cache-dir .
- COPY lightsync/ lightsync/
- EXPOSE 8000
- CMD ["python", "-m", "lightsync"]
- Note: Use alpine to save RAM per CLAUDE.md constraint
STEP 7 — Create docker-compose.prod.yml:
- service name: lightsync
- build: .
- container_name: lightsync
- restart: unless-stopped
- volumes: ./shows:/app/shows, ./devices.json:/app/devices.json
- networks: edge (external: true)
- labels for Traefik:
- traefik.enable=true
- traefik.http.routers.lightsync.rule=Host(`lightsync.groll.cloud`)
- traefik.http.routers.lightsync.entrypoints=websecure
- traefik.http.routers.lightsync.tls.certresolver=letsencrypt
- traefik.http.routers.lightsync.middlewares=authelia@docker
- traefik.http.services.lightsync.loadbalancer.server.port=8000
- environment: HOST=0.0.0.0, PORT=8000
STEP 8 — Deploy:
- Run ~/bin/deploy.sh /home/claude/led2
- Verify container starts successfully
STEP 9 — Update ~/start/projects.json:
- Add entry: name "LightSync", url "https://lightsync.groll.cloud", icon "zap", badge "NEW", badgeColor "green", protected true, description "LED Light Show Manager", section "project"
- Run ~/bin/deploy.sh ~/start
</action>
<verify>
<automated>cd /home/claude/led2 && pip install -e ".[dev]" 2>&1 | tail -1 && python -c "
import httpx, asyncio, json
from lightsync.main import app
async def test():
async with httpx.AsyncClient(app=app, base_url='http://test') as c:
# Device CRUD
r = await c.post('/api/devices', json={'name':'Smoke','strip_type':'sk6812','led_count':30,'ip':'10.0.0.1','port':21324})
assert r.status_code == 201, f'POST devices: {r.status_code}'
did = r.json()['id']
r = await c.get('/api/devices')
assert r.status_code == 200 and len(r.json()) >= 1, 'GET devices failed'
# Show CRUD
r = await c.post('/api/shows', json={'name':'Smoke Show'})
assert r.status_code == 201, f'POST shows: {r.status_code}'
sid = r.json()['id']
assert r.json()['schema_version'] == 1, 'Missing schema_version'
r = await c.get(f'/api/shows/{sid}')
assert r.status_code == 200, f'GET show: {r.status_code}'
body = r.json()
assert 'audio' in body and 'tracks' in body and 'devices' in body, f'Missing fields: {list(body.keys())}'
# Static files
r = await c.get('/')
assert r.status_code == 200, f'GET /: {r.status_code}'
print('ALL SMOKE TESTS PASSED')
asyncio.run(test())
"</automated>
</verify>
<acceptance_criteria>
- POST /api/devices returns 201 with valid JSON containing id, name, strip_type, led_count, ip, port
- GET /api/devices returns 200 with array of DeviceConfig objects
- DELETE /api/devices/{id} returns 204 for existing device and 404 for missing
- POST /api/shows returns 201 with JSON containing schema_version=1
- GET /api/shows returns 200 with array of show summaries
- GET /api/shows/{id} returns 200 with full ShowModel JSON including audio, devices, tracks, analysis fields
- GET / returns 200 with HTML containing "LIGHTSYNC"
- Dockerfile contains `FROM python:3.11-alpine` and `CMD ["python", "-m", "lightsync"]`
- docker-compose.prod.yml contains `lightsync.groll.cloud` and `authelia@docker` and network `edge`
- Container runs successfully via `docker compose -f docker-compose.prod.yml ps` showing status "Up"
</acceptance_criteria>
<done>All API endpoints respond correctly. Device persistence survives restart. Show files save as JSON with schema_version=1. Frontend loads from StaticFiles. App deployed to lightsync.groll.cloud behind Authelia.</done>
</task>
</tasks>
<verification>
1. httpx smoke test (in verify above) passes all assertions
2. `docker compose -f docker-compose.prod.yml ps` shows lightsync as running
3. `curl -s http://localhost:8000/api/devices` returns JSON array
4. `curl -s http://localhost:8000/` contains "LIGHTSYNC"
5. `ls shows/` shows at least one .json file after creating a show
</verification>
<success_criteria>
- Complete device CRUD works: add device via POST, see it in GET, remove via DELETE, persists across restarts
- Complete show CRUD works: create show via POST with schema_version=1, list shows, load individual show with all fields
- Frontend serves from FastAPI StaticFiles at /
- App runs in Docker on lightsync.groll.cloud behind Authelia
- All five Phase 1 success criteria from ROADMAP.md are met
</success_criteria>
<output>
After completion, create `.planning/phases/01-foundation/01-03-SUMMARY.md`
</output>