diff --git a/frontend/app.js b/frontend/app.js new file mode 100644 index 0000000..cd9bb2e --- /dev/null +++ b/frontend/app.js @@ -0,0 +1,22 @@ +// U-008: empty-shell client-side router. Hash-based (no History API / server +// catch-all needed) -- real page content is PH3 (U-022+), this just proves +// navigation swaps content without a page reload or any build step. + +const routes = { + "#/": () => "

Dashboard

placeholder -- real content is PH3 (v_dashboard, U-025)

", + "#/tasks": () => "

Tasks

placeholder -- real content is PH3 (v_task_board, U-025)

", + "#/settings": () => "

Settings

placeholder -- real content is PH3 (U-030)

", +}; + +function render() { + const hash = window.location.hash || "#/"; + const view = routes[hash] || (() => "

Not found

" + hash + "

"); + document.getElementById("app").innerHTML = view(); + + for (const a of document.querySelectorAll("#nav a")) { + a.classList.toggle("active", a.getAttribute("href") === hash); + } +} + +window.addEventListener("hashchange", render); +window.addEventListener("DOMContentLoaded", render); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b5f8f36 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + +graphlemon + + + + +
+ + + diff --git a/frontend/style.css b/frontend/style.css new file mode 100644 index 0000000..281abe5 --- /dev/null +++ b/frontend/style.css @@ -0,0 +1,7 @@ +/* U-008: minimal shell styling, nothing to design-review yet -- real look + and feel is PH3, starting from docs/wireframes/*.html. */ +body { font-family: system-ui, sans-serif; margin: 0; } +#nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #ddd; } +#nav a { text-decoration: none; color: #333; } +#nav a.active { font-weight: bold; } +#app { padding: 1rem; } diff --git a/src/backend_server.py b/src/backend_server.py index fc2cd62..c82f075 100644 --- a/src/backend_server.py +++ b/src/backend_server.py @@ -2,16 +2,30 @@ third-party deps (05 §5.7) -- this process is also where the Graph Engine scheduler will live later (05 §5.4.1), but that's a different Unit. -MVP scope here: process start/stop, and `c_pick_folder` (03-C §3.-1) as the -one real route -- macOS only for now (`osascript`); Windows/Linux native -dialogs are PH3 (U-022). +MVP scope here: process start/stop, `c_pick_folder` (03-C §3.-1) as the one +real API route -- macOS only for now (`osascript`); Windows/Linux native +dialogs are PH3 (U-022) -- and serving the U-008 frontend shell as static +files (no build step, 05 §5.7: whitelisted by exact path, not a generic +directory walker, so there's no path-traversal surface to reason about). """ import json +import os import platform import subprocess from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +_FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend") + +# U-008: exact-path whitelist, not a generic static-file server -- the +# frontend is 3 fixed files until PH3 adds real pages. +_STATIC_FILES = { + "/": ("index.html", "text/html; charset=utf-8"), + "/index.html": ("index.html", "text/html; charset=utf-8"), + "/app.js": ("app.js", "text/javascript; charset=utf-8"), + "/style.css": ("style.css", "text/css; charset=utf-8"), +} + class DialogUnavailableError(Exception): """DIALOG_UNAVAILABLE -- 03-C §3.-1: no native-dialog capability here.""" @@ -35,6 +49,20 @@ def pick_folder(): class _Handler(BaseHTTPRequestHandler): + def do_GET(self): + entry = _STATIC_FILES.get(self.path) + if entry is None: + self._send_json(404, {"error": "NOT_FOUND"}) + return + filename, content_type = entry + with open(os.path.join(_FRONTEND_DIR, filename), "rb") as f: + body = f.read() + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + def do_POST(self): if self.path == "/api/system/pick-folder": self._handle_pick_folder() diff --git a/src/test_backend_server.py b/src/test_backend_server.py index a53c63c..fb709b1 100644 --- a/src/test_backend_server.py +++ b/src/test_backend_server.py @@ -59,6 +59,59 @@ class ServerStartStopTest(unittest.TestCase): thread.join(timeout=5) +class StaticFileServingTest(unittest.TestCase): + """U-008: frontend shell served as static files. Actual client-side + routing behavior was verified in a real browser this session (nav + highlighting + content swap across all 3 routes, no page reload) -- + that's not something a stdlib-only test can exercise without a JS + engine, so this only covers what the server is responsible for.""" + + def setUp(self): + self.server = backend_server.create_server() + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + + def tearDown(self): + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + def _get(self, path): + host, port = self.server.server_address + conn = http.client.HTTPConnection(host, port, timeout=5) + conn.request("GET", path) + resp = conn.getresponse() + body = resp.read() + content_type = resp.getheader("Content-Type") + conn.close() + return resp.status, content_type, body + + def test_root_serves_index_html(self): + status, content_type, body = self._get("/") + self.assertEqual(status, 200) + self.assertIn("text/html", content_type) + self.assertIn(b"
", body) + self.assertIn(b'href="#/tasks"', body) + + def test_app_js_served_with_js_content_type(self): + status, content_type, body = self._get("/app.js") + self.assertEqual(status, 200) + self.assertIn("javascript", content_type) + self.assertIn(b"hashchange", body) + self.assertIn(b'"#/tasks"', body) + self.assertIn(b'"#/settings"', body) + + def test_style_css_served_with_css_content_type(self): + status, content_type, body = self._get("/style.css") + self.assertEqual(status, 200) + self.assertIn("text/css", content_type) + + def test_unknown_get_path_returns_404(self): + status, _, body = self._get("/nope") + self.assertEqual(status, 404) + self.assertEqual(json.loads(body)["error"], "NOT_FOUND") + + class PickFolderTest(unittest.TestCase): def test_success_returns_selected_path(self): with patch("src.backend_server.platform.system", return_value="Darwin"), \