diff --git a/docs/.dockerignore b/docs/.dockerignore
new file mode 100644
index 0000000..97aa7fe
--- /dev/null
+++ b/docs/.dockerignore
@@ -0,0 +1,10 @@
+node_modules
+dist
+.astro
+.git
+.gitignore
+.env
+.env.*
+!.env.example
+*.log
+.DS_Store
diff --git a/docs/.env.example b/docs/.env.example
new file mode 100644
index 0000000..7b11923
--- /dev/null
+++ b/docs/.env.example
@@ -0,0 +1,18 @@
+# mcaxl docs — local environment.
+#
+# Copy to .env and adjust. `.env` is gitignored.
+
+# Distinguishes this compose project from others on the host.
+COMPOSE_PROJECT=mcaxl-docs
+
+# Public hostnames (resolved via the wildcard DNS for *.mcp.l.supported.systems
+# that already points at the host's edge caddy-docker-proxy).
+# DOMAIN → prod static build
+# DEV_DOMAIN → dev server, hot-reload
+DOMAIN=mcaxl-docs.mcp.l.supported.systems
+DEV_DOMAIN=dev-mcaxl-docs.mcp.l.supported.systems
+
+# Mode switch — drives which compose profile is active.
+# dev → astro dev server + HMR (hot reload) at DEV_DOMAIN
+# prod → static build served by in-container Caddy at DOMAIN
+COMPOSE_PROFILES=dev
diff --git a/docs/Caddyfile b/docs/Caddyfile
new file mode 100644
index 0000000..2f5f1a7
--- /dev/null
+++ b/docs/Caddyfile
@@ -0,0 +1,31 @@
+# Internal Caddy used by the prod container to serve the static site.
+# caddy-docker-proxy terminates TLS at the edge, forwards to :80 here.
+
+:80 {
+ root * /srv
+ file_server
+ encode gzip zstd
+
+ # Long-cache everything Astro puts under /_astro/ (hashed filenames)
+ @immutable path /_astro/*
+ header @immutable Cache-Control "public, max-age=31536000, immutable"
+
+ # Short-cache HTML so content updates appear quickly after redeploy.
+ @html path_regexp htmlrx \.(html)?$
+ header @html Cache-Control "public, max-age=60, must-revalidate"
+
+ # Basic security headers
+ header {
+ X-Content-Type-Options nosniff
+ Referrer-Policy strict-origin-when-cross-origin
+ X-Frame-Options SAMEORIGIN
+ -Server
+ }
+
+ # Render 404 for missing pages
+ handle_errors {
+ @404 expression {http.error.status_code} == 404
+ rewrite @404 /404.html
+ file_server
+ }
+}
diff --git a/docs/Dockerfile b/docs/Dockerfile
new file mode 100644
index 0000000..5100086
--- /dev/null
+++ b/docs/Dockerfile
@@ -0,0 +1,62 @@
+# mcaxl docs — multi-stage build.
+#
+# Stages:
+# deps — install node_modules once, cached by package.json
+# dev — runs `astro dev` with the source bind-mounted (compose `dev` profile)
+# build — produces the static site in /app/dist
+# prod — serves /app/dist with Caddy (static file server, no Node runtime)
+
+# ---------- Stage: deps --------------------------------------------------
+FROM mirror.gcr.io/library/node:22-alpine AS deps
+
+ENV ASTRO_TELEMETRY_DISABLED=1 \
+ NODE_ENV=development
+
+WORKDIR /app
+
+COPY package.json package-lock.json* ./
+RUN --mount=type=cache,target=/root/.npm \
+ npm install --no-audit --no-fund
+
+# ---------- Stage: dev ---------------------------------------------------
+FROM mirror.gcr.io/library/node:22-alpine AS dev
+
+ENV ASTRO_TELEMETRY_DISABLED=1 \
+ NODE_ENV=development \
+ HOST=0.0.0.0
+
+WORKDIR /app
+
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+EXPOSE 4321
+CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+
+# ---------- Stage: build -------------------------------------------------
+FROM mirror.gcr.io/library/node:22-alpine AS build
+
+ENV ASTRO_TELEMETRY_DISABLED=1 \
+ NODE_ENV=production
+
+WORKDIR /app
+
+COPY --from=deps /app/node_modules ./node_modules
+COPY . .
+
+# DOMAIN must be set at build time so Astro bakes the canonical site URL
+# (sitemaps, og: tags, etc.) into the static output.
+ARG DOMAIN
+ENV DOMAIN=${DOMAIN}
+
+RUN npm run build
+
+# ---------- Stage: prod --------------------------------------------------
+# Static site served by Caddy. No Node in the final image.
+FROM mirror.gcr.io/library/caddy:2-alpine AS prod
+
+COPY --from=build /app/dist /srv
+COPY Caddyfile /etc/caddy/Caddyfile
+
+EXPOSE 80
+CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"]
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..c347914
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,57 @@
+# mcaxl docs — compose wrapper.
+#
+# Mode (dev/prod) is driven by COMPOSE_PROFILES in .env. Flip it there.
+# `docker compose` honors COMPOSE_PROJECT and COMPOSE_PROFILES automatically.
+
+COMPOSE ?= docker compose
+
+.PHONY: help up down restart logs shell ps build clean prod dev net pull-prod pull-dev
+
+help: ## Show this help
+ @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-10s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
+
+up: ## Start the active profile (set COMPOSE_PROFILES in .env)
+ $(COMPOSE) up -d --build
+
+dev: ## Start dev explicitly (hot reload at https://$${DEV_DOMAIN})
+ COMPOSE_PROFILES=dev $(COMPOSE) up -d --build
+
+prod: ## Start prod explicitly (static site at https://$${DOMAIN})
+ COMPOSE_PROFILES=prod $(COMPOSE) up -d --build
+
+down: ## Stop and remove all containers for this project
+ COMPOSE_PROFILES=dev $(COMPOSE) down
+ COMPOSE_PROFILES=prod $(COMPOSE) down
+
+restart: ## Restart the active profile
+ $(COMPOSE) restart
+
+logs: ## Tail logs for active services
+ $(COMPOSE) logs -f --tail=200
+
+shell: ## Shell into the dev container
+ $(COMPOSE) exec docs-dev sh
+
+ps: ## Show running containers for this project
+ $(COMPOSE) ps
+
+build: ## Build images for both profiles
+ COMPOSE_PROFILES=dev,prod $(COMPOSE) build
+
+clean: ## Remove containers, images, and volumes for this project
+ COMPOSE_PROFILES=dev,prod $(COMPOSE) down --rmi local -v
+
+net: ## Create the external `caddy` network (one-time, host-wide)
+ docker network inspect caddy >/dev/null 2>&1 || docker network create caddy
+
+pull-prod: ## Deploy: git pull, rebuild, and restart the prod container
+ git pull --ff-only
+ COMPOSE_PROFILES=prod $(COMPOSE) up -d --build docs-prod
+ @echo
+ @COMPOSE_PROFILES=prod $(COMPOSE) ps docs-prod
+
+pull-dev: ## Deploy: git pull, rebuild, and restart the dev container
+ git pull --ff-only
+ COMPOSE_PROFILES=dev $(COMPOSE) up -d --build docs-dev
+ @echo
+ @COMPOSE_PROFILES=dev $(COMPOSE) ps docs-dev
diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index 541b781..d879ef7 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -52,6 +52,7 @@ export default defineConfig({
src: './src/assets/logo.svg',
replacesTitle: false,
},
+ favicon: '/favicon.svg',
customCss: ['./src/styles/custom.css'],
// Per-page action buttons: "Copy Markdown" + "View in Markdown"
diff --git a/docs/docker-compose.yml b/docs/docker-compose.yml
new file mode 100644
index 0000000..40b34fe
--- /dev/null
+++ b/docs/docker-compose.yml
@@ -0,0 +1,71 @@
+# mcaxl docs — dev/prod via compose profiles.
+#
+# Mode is driven by COMPOSE_PROFILES in .env:
+# COMPOSE_PROFILES=dev → hot-reload astro dev server at https://${DEV_DOMAIN}
+# COMPOSE_PROFILES=prod → static site via in-container Caddy at https://${DOMAIN}
+#
+# Both profiles sit behind the host's external caddy-docker-proxy on the
+# `caddy` network. mcaxl is a public OSS project; no basic auth.
+#
+# Prerequisite: `docker network create caddy` (one-time, shared with the
+# edge caddy-docker-proxy instance).
+
+services:
+ docs-dev:
+ profiles: ["dev"]
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: dev
+ image: ${COMPOSE_PROJECT}-dev
+ container_name: ${COMPOSE_PROJECT}-dev
+ restart: unless-stopped
+ environment:
+ ASTRO_TELEMETRY_DISABLED: "1"
+ NODE_ENV: development
+ DOMAIN: ${DEV_DOMAIN} # Vite HMR binds wss://${DEV_DOMAIN}:443
+ volumes:
+ - .:/app
+ - /app/node_modules
+ - /app/.astro
+ networks:
+ - caddy
+ labels:
+ caddy: ${DEV_DOMAIN}
+ caddy.reverse_proxy: "{{upstreams 4321}}"
+
+ # ── HMR-through-Caddy streaming tweaks ──
+ # Vite HMR doesn't send app-level pings; Caddy closes idle WebSockets
+ # after ~10-15s without these. Refs in ~/.claude/CLAUDE.md.
+ 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"
+
+ caddy.encode: "gzip zstd"
+
+ docs-prod:
+ profiles: ["prod"]
+ build:
+ context: .
+ dockerfile: Dockerfile
+ target: prod
+ args:
+ DOMAIN: ${DOMAIN}
+ image: ${COMPOSE_PROJECT}-prod
+ container_name: ${COMPOSE_PROJECT}-prod
+ restart: unless-stopped
+ networks:
+ - caddy
+ labels:
+ caddy: ${DOMAIN}
+ caddy.reverse_proxy: "{{upstreams 80}}"
+ caddy.encode: "gzip zstd"
+
+networks:
+ caddy:
+ external: true
diff --git a/docs/public/favicon.svg b/docs/public/favicon.svg
new file mode 100644
index 0000000..88c73c1
--- /dev/null
+++ b/docs/public/favicon.svg
@@ -0,0 +1,8 @@
+
+
diff --git a/docs/src/assets/logo.svg b/docs/src/assets/logo.svg
index d46845b..d89aa25 100644
--- a/docs/src/assets/logo.svg
+++ b/docs/src/assets/logo.svg
@@ -1,6 +1,16 @@
-