diff --git a/.env b/.env
new file mode 100644
index 0000000..5cee167
--- /dev/null
+++ b/.env
@@ -0,0 +1,5 @@
+COMPOSE_PROJECT_NAME=birdcage-docs
+APP_ENV=prod
+APP_PORT=80
+PUBLIC_DOMAIN=birdcage.warehack.ing
+VITE_HMR_HOST=birdcage.warehack.ing
diff --git a/Caddyfile b/Caddyfile
new file mode 100644
index 0000000..49b66df
--- /dev/null
+++ b/Caddyfile
@@ -0,0 +1,11 @@
+:80 {
+ root * /srv
+ encode gzip
+
+ handle /health {
+ respond "ok" 200
+ }
+
+ try_files {path} {path}/index.html {path}/ /index.html
+ file_server
+}
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..d899cbd
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,25 @@
+# ── base: shared dependency install ──────────────────────────────
+FROM node:20-slim AS base
+WORKDIR /app
+ENV ASTRO_TELEMETRY_DISABLED=1
+COPY package.json package-lock.json ./
+RUN npm ci
+COPY . .
+
+# ── dev: Astro dev server with HMR ─────────────────────────────
+FROM base AS dev
+ENV HOST=0.0.0.0
+EXPOSE 4321
+CMD ["npx", "astro", "dev", "--host", "0.0.0.0", "--port", "4321"]
+
+# ── build: static site generation ───────────────────────────────
+FROM base AS build
+RUN npx astro build
+
+# ── prod: Caddy serving static files ───────────────────────────
+FROM caddy:2-alpine AS prod
+COPY Caddyfile /etc/caddy/Caddyfile
+COPY --from=build /app/dist /srv
+EXPOSE 80
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD wget -qO- http://127.0.0.1:80/health || exit 1
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..bb64530
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,27 @@
+.PHONY: up down logs rebuild dev prod clean
+
+up:
+ docker compose up -d --build
+
+down:
+ docker compose down
+
+logs:
+ docker compose logs -f
+
+rebuild:
+ docker compose down
+ docker compose up -d --build
+
+dev:
+ @sed -i 's/^APP_ENV=.*/APP_ENV=dev/' .env
+ @sed -i 's/^APP_PORT=.*/APP_PORT=4321/' .env
+ $(MAKE) rebuild
+
+prod:
+ @sed -i 's/^APP_ENV=.*/APP_ENV=prod/' .env
+ @sed -i 's/^APP_PORT=.*/APP_PORT=80/' .env
+ $(MAKE) rebuild
+
+clean:
+ docker compose down --rmi local -v
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..b5f999f
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,31 @@
+services:
+ docs:
+ build:
+ context: .
+ target: ${APP_ENV:-dev}
+ container_name: birdcage-docs
+ restart: unless-stopped
+ environment:
+ - PUBLIC_DOMAIN=${PUBLIC_DOMAIN}
+ - VITE_HMR_HOST=${VITE_HMR_HOST}
+ networks:
+ - caddy
+ labels:
+ caddy: ${PUBLIC_DOMAIN}
+ caddy.reverse_proxy: "{{upstreams ${APP_PORT:-4321}}}"
+ caddy.reverse_proxy.flush_interval: "-1"
+ caddy.reverse_proxy.transport: http
+ caddy.reverse_proxy.transport.read_timeout: "0"
+ caddy.reverse_proxy.transport.write_timeout: "0"
+ caddy.reverse_proxy.transport.keepalive: 5m
+ caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
+ caddy.reverse_proxy.stream_timeout: 24h
+ caddy.reverse_proxy.stream_close_delay: 5s
+ volumes:
+ - ./src:/app/src
+ - ./public:/app/public
+ - ./astro.config.mjs:/app/astro.config.mjs
+
+networks:
+ caddy:
+ external: true
diff --git a/public/screenshots/tui-console.png b/public/screenshots/tui-console.png
new file mode 100644
index 0000000..37302a1
Binary files /dev/null and b/public/screenshots/tui-console.png differ
diff --git a/public/screenshots/tui-position.png b/public/screenshots/tui-position.png
new file mode 100644
index 0000000..add2f0f
Binary files /dev/null and b/public/screenshots/tui-position.png differ
diff --git a/public/screenshots/tui-signal.png b/public/screenshots/tui-signal.png
new file mode 100644
index 0000000..efdfea1
Binary files /dev/null and b/public/screenshots/tui-signal.png differ
diff --git a/public/screenshots/tui-system.png b/public/screenshots/tui-system.png
new file mode 100644
index 0000000..f637e48
Binary files /dev/null and b/public/screenshots/tui-system.png differ
diff --git a/src/content/docs/guides/tui.mdx b/src/content/docs/guides/tui.mdx
new file mode 100644
index 0000000..0f9b8b9
--- /dev/null
+++ b/src/content/docs/guides/tui.mdx
@@ -0,0 +1,176 @@
+---
+title: "The Birdcage TUI"
+description: A five-screen terminal interface for controlling Winegard satellite dishes — from a $75 RV salvage find to something that looks like it belongs in a ground station.
+sidebar:
+ order: 8
+ badge:
+ text: New
+ variant: tip
+---
+
+import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components';
+
+Mission control for less than dinner for two.
+
+Somewhere inside every Winegard Carryout G2 is a 2013-vintage NXP Cortex-M4 running firmware 02.02.48,
+driving two Allegro A3981 stepper motors and a Broadcom BCM4515 DVB-S2 tuner through 12 submenus
+and over 100 undocumented commands. In 2026, it takes commands from a Python TUI built on
+[Textual](https://textual.textualize.io/) and doesn't seem to mind.
+
+Gabe Emerson ([KL1FI](https://github.com/saveitforparts)) went first — publishing control scripts
+for five different Winegard variants, proving that a $50–200 RV salvage yard dish could replace
+a $500–2000 commercial amateur radio rotator. Chris Davidson ([cdavidson0522](https://github.com/cdavidson0522/winegard-sky-scan))
+figured out the G2's RS-422 wiring and discovered `azscanwxp` — the firmware's built-in radio telescope mode.
+Birdcage is what happens when you take all of that and give it a proper interface.
+
+## Quick Start
+
+
+1. **Clone and install** (from the `tui/` directory inside the Birdcage repo):
+
+ ```bash
+ cd tui/
+ uv sync
+ ```
+
+2. **Run in demo mode** (no hardware required):
+
+ ```bash
+ uv run birdcage-tui --demo
+ ```
+
+3. **Connect to a real dish:**
+
+ ```bash
+ uv run birdcage-tui --port /dev/ttyUSB0 --firmware g2
+ ```
+
+
+
+
+## Five Screens
+
+Navigate between screens with **F1**–**F5** keys or click the sidebar buttons.
+The device status bar at the bottom persists across all screens — connection state,
+serial port, firmware version, and current menu prompt are always visible.
+
+### F1 — Position
+
+
+
+The position screen is where you point the dish. A compass rose shows current azimuth
+with a bearing indicator, while AZ and EL sparklines track movement history over time.
+The numeric readout at top shows position to hundredths of a degree — the G2's stepper
+resolution is 0.009° azimuth (40,000 steps/rev) and 0.014° elevation (24,960 steps/rev).
+
+The AZ and EL sparklines give you immediate visual feedback: flat lines mean the dish
+is parked, slopes mean it's slewing, and the amplitude of the noise floor after arrival
+tells you how much stepper backlash you're dealing with.
+
+### F2 — Signal
+
+
+
+Signal strength from two sources: the BCM4515 DVB tuner's RSSI (bounded, averaged)
+and the raw ADC reading (single-shot). The gauge uses sub-character Unicode block elements
+(▏▎▍▌▋▊▉█) for smooth visual resolution. Below the gauge, DVB RSSI and ADC RSSI
+sparklines show signal trends over time — useful for peaking during manual dish adjustment.
+
+The sample counter and iteration display at bottom track measurement throughput. On a
+live dish with the LNA enabled (`lnbdc odu`), you'll see the noise floor sit around
+RSSI 500 (ADC) or 230–490 (DVB, polarity-dependent).
+
+### F3 — Scan
+
+The scan screen wraps the firmware's `azscanwxp` command — Davidson's radio telescope
+mode. Define an AZ×EL grid, set the step resolution, and watch the sky heatmap fill
+in with RSSI-colored cells as the dish sweeps. Results export to CSV for post-processing
+into proper sky maps.
+
+
+
+### F4 — System
+
+
+
+The system dashboard. Top row shows the firmware identity banner: version 02.02.48,
+K60 MCU at 96 MHz, antenna ID "12-IN G2". Below that, two side-by-side panels:
+
+- **A3981 Diagnostics** — fault pin status for both stepper drivers (AZ/EL DIAG: OK or FAULT),
+ plus step size mode (AUTO means the driver handles microstepping transitions automatically).
+- **Motor Dynamics** — max velocity and acceleration for each axis. The G2 defaults to
+ 65.0°/s azimuth, 45.0°/s elevation, with 400.0°/s² acceleration — fast enough to track
+ LEO satellites at medium altitudes.
+
+The NVS Table at the bottom is a scrollable browser of all 134 non-volatile storage values.
+Current, saved, and default columns let you see what's been modified. The "Refresh NVS"
+and "Export NVS JSON" buttons at the bottom do what you'd expect.
+
+### F5 — Console
+
+
+
+Raw firmware console access with guardrails. Type commands directly to the dish's serial
+interface, see responses with color-coded prompt tracking (`TRK>`, `MOT>`, `DVB>`, `NVS>`,
+etc.). Command history with up/down arrows.
+
+The safety gates matter here: the console warns before sending `q` at root level (which
+kills the firmware shell — requires power cycle to recover), before `reboot`, and before
+NVS writes. The firmware doesn't have an "are you sure?" prompt. Birdcage does.
+
+## Demo Mode
+
+Pass `--demo` and Birdcage substitutes a `DemoDevice` for the serial bridge. The simulator models:
+
+- **Motor physics** — position changes at ~10°/s with configurable settling noise
+ (±0.05° random perturbation to simulate stepper backlash)
+- **RSSI signal** — Gaussian model centered on a target position, so signal strength
+ increases as you point closer to the simulated source
+- **All 12 submenus** — `TRK>`, `MOT>`, `DVB>`, `NVS>`, `A3981>`, `ADC>`, `OS>`, `STEP>`,
+ `PEAK>`, `EE>`, `GPIO>`, `LATLON>`, `DIPSWITCH>`. Menu navigation with `mot`, `dvb`, `nvs`,
+ etc. and `q` to go back all work correctly
+- **Full NVS dump** — the complete 134-entry table from firmware 02.02.48, captured from a
+ live unit on 2026-02-12
+
+Every screen, every widget, every button works in demo mode. It's the same code path —
+the only difference is what's on the other end of the bridge.
+
+## The Story
+
+The hardware was never the hard part. RV satellite dishes show up at salvage yards for
+$50–200 because the RV got totaled or the owner switched to Starlink. The mechanicals are
+built to survive highway speeds and hailstorms. The motors are Allegro A3981 stepper drivers
+with 1/16 microstepping — more precise than most amateur radio rotators at ten times the price.
+
+The gap was always software.
+
+Gabe Emerson (KL1FI) bridged it first. Five repositories for five Winegard variants — the
+Trav'ler (HAL 0.0.00 and HAL 2.05), the Trav'ler Pro, the original Carryout, and the
+Carryout G2. Python scripts that send `a 0 180` to point azimuth south and `a 1 45` to tilt
+elevation to 45°. A rotctld bridge so Gpredict could drive the dish. Proof that the idea
+worked.
+
+Chris Davidson took the G2 further — mapped the RS-422 wiring (four wires, not two, and
+polarity matters or you get garbled data at the correct baud rate), discovered the `azscanwxp`
+command buried in the motor submenu, and turned a TV satellite dish into an RF imager.
+
+Birdcage picks up where they left off. We reverse-engineered over 100 commands across
+12 firmware submenus using automated probing and interactive exploration. We documented
+the full NVS table (134 entries), the GPIO pin map, the SPI bus layout (4 MHz to the motor
+drivers, 6.857 MHz to the DVB tuner), the DiSEqC 2.x interface, and the boot sequence from
+bootloader through motor calibration to prompt.
+
+The TUI is what ties it together. Not because a terminal interface is fashionable, but because
+when you're on a roof with a laptop and a USB-to-RS422 adapter, you want something that runs
+over SSH and shows you everything at once.
+
+A 2013 microcontroller. A 2026 terminal. A dish that costs less than the cable to connect it.
+
+That's the project.