diff --git a/src/ropeway/server/api.py b/src/ropeway/server/api.py index d134fd3..7d408cc 100644 --- a/src/ropeway/server/api.py +++ b/src/ropeway/server/api.py @@ -136,6 +136,10 @@ def create_app(settings: Settings | None = None) -> FastAPI: allow_headers=["*"], ) + # ---- Phase 14: htmx shareable-link demo (unauthenticated, synthetic) ---- + from .htmx import router as htmx_router + app.include_router(htmx_router) + # ---- health ---- @app.get("/health") def health(): diff --git a/src/ropeway/server/htmx.py b/src/ropeway/server/htmx.py new file mode 100644 index 0000000..8cb1ca0 --- /dev/null +++ b/src/ropeway/server/htmx.py @@ -0,0 +1,227 @@ +"""htmx web client — Phase 14. + +A minimal, dependency-free server-rendered UI that drives the optimizer +through plain HTML + htmx attributes (no React, no build step, no extra +template-engine dependency). The whole point is *shareable result +links*: every run lands at ``/htmx/run/{run_id}`` and that URL can be +sent to anyone with network access to the API. + +Routes +------ +* ``GET /`` — landing page (form: corridor length, seed, + system, generations) +* ``POST /htmx/run`` — kick off a synthetic optimize, store the + result by run_id, return an HTML fragment + with the headline metrics + a permalink +* ``GET /htmx/run/{rid}`` — shareable view of a stored run + +Runs are stored in process memory; the link is valid for the lifetime +of the server. The endpoint is unauthenticated by design — it operates +on synthetic terrain only, so there is no DEM upload and no project +data to leak. The authenticated FastAPI surface (``/auth``, +``/projects``, ``/optimize/dem``) is untouched. +""" + +from __future__ import annotations + +import uuid +from dataclasses import dataclass +from typing import Optional + +from fastapi import APIRouter, Form, HTTPException +from fastapi.responses import HTMLResponse + +from ..dem import synthetic_profile +from ..multi_rope import RopewaySystemType, system_defaults +from ..optimizer import GAConfig, optimize + + +router = APIRouter(tags=["htmx"]) + + +@dataclass +class _StoredRun: + """In-memory record of one htmx-driven optimization run.""" + corridor_length_m: float + system: str + intermediate_towers: int + cable_length_m: float + min_clearance_m: float + max_tension_kn: float + max_break_over_deg: float + cost: float + feasible: bool + + +_RUNS: dict[str, _StoredRun] = {} + + +_BASE_CSS = """ +body { font-family: system-ui, sans-serif; max-width: 760px; + margin: 2.5em auto; padding: 0 1em; color: #1a1a1a; } +h1 { font-size: 1.6rem; margin-bottom: 0.2em; } +.tagline { color: #666; margin-top: 0; } +form { display: grid; grid-template-columns: 1fr 1fr; gap: 0.8em 1em; + background: #f7f7f8; padding: 1em 1.2em; border-radius: 8px; } +label { display: flex; flex-direction: column; font-size: 0.9rem; } +input, select { padding: 0.35em 0.5em; font-size: 1rem; + border: 1px solid #ccc; border-radius: 4px; } +button { grid-column: 1 / -1; padding: 0.6em 1.2em; font-size: 1rem; + background: #2563eb; color: white; border: 0; border-radius: 4px; + cursor: pointer; } +button:hover { background: #1d4ed8; } +.result { margin-top: 1.5em; padding: 1em 1.2em; background: #f0fdf4; + border-left: 4px solid #16a34a; border-radius: 4px; } +.result.infeasible { background: #fef2f2; border-left-color: #dc2626; } +.metrics { display: grid; grid-template-columns: repeat(2, 1fr); + gap: 0.4em 1em; margin: 0.8em 0; } +.metric-label { color: #666; font-size: 0.85rem; } +.metric-value { font-weight: 600; } +.permalink { font-family: ui-monospace, monospace; font-size: 0.85rem; + background: #eef; padding: 0.4em 0.6em; border-radius: 4px; + word-break: break-all; } +""" + + +_LANDING_HTML = """ + +
+ +Server-rendered optimizer demo. Synthetic terrain only; +share any run with the link it produces.
+ + + + +""" + + +def _result_fragment(rid: str, run: _StoredRun, *, base_url: str = "") -> str: + permalink = f"{base_url}/htmx/run/{rid}" + status_cls = "result" if run.feasible else "result infeasible" + status_text = "Feasible" if run.feasible else "Infeasible" + return f""" +Read-only view of a previously-completed optimization. +Run your own →
+{fragment} + + +""" + + +__all__ = ["router"] diff --git a/tests/test_server_htmx.py b/tests/test_server_htmx.py new file mode 100644 index 0000000..36c43f7 --- /dev/null +++ b/tests/test_server_htmx.py @@ -0,0 +1,84 @@ +"""Phase 14 — htmx shareable web client tests.""" + +from __future__ import annotations + +import os +import tempfile + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture() +def client(tmp_path, monkeypatch): + # Force a per-test SQLite file so the server's auth tables don't clash + # with other server tests sharing the singleton engine. + db_file = tmp_path / "htmx_test.sqlite" + monkeypatch.setenv("ROPEWAY_DATABASE_URL", f"sqlite:///{db_file}") + monkeypatch.setenv("ROPEWAY_JWT_SECRET", "test-secret-htmx") + # Reload the module so create_app picks up the env vars. + import importlib + + from ropeway.server import api as api_module + importlib.reload(api_module) + return TestClient(api_module.app) + + +def test_landing_page_returns_html_form(client): + r = client.get("/") + assert r.status_code == 200 + body = r.text + # The landing page is a self-contained htmx form. + assert "