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
+
+
+
+
+ Dashboard
+ Tasks
+ Settings
+
+
+
+
+
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"), \