Commit ccf51c3f4d772faf4b4f9e560f9abf83c4d5c3a3

Authored by bboonnee
1 parent 09e8bbbb

U-008: frontend skeleton (PH0 complete)

frontend/{index.html,app.js,style.css}: no build step, no npm, vanilla JS
hash router (05 §5.7). Real page content is PH3 (U-022+, starting from
docs/wireframes/); this is only the empty shell + working navigation the
Done criterion asks for.

backend_server.py serves the 3 files via an exact-path whitelist (not a
generic static-file/directory server) -- no path-traversal surface to
reason about for 3 fixed files.

Verified in an actual browser this session (navigated to localhost, clicked
all 3 nav links, screenshotted each) rather than only trusting stdlib
tests -- a Python-only test can't exercise real hashchange/DOM behavior
without a JS engine. Nav highlighting and content swap both confirmed
working, no page reload, no build step.

7 new stdlib unittest cases covering what the server is actually
responsible for (static files served with correct content-type, 404 on
unknown GET path). 66 total across U-001-008, all passing.

This closes out PH0 (U-001~U-008) -- all three Phase-acceptance criteria
now hold for real: target-project directory scaffold, 15-table schema with
constraint tests, and a real claude --agent round-trip.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
frontend/app.js 0 → 100644
  1 +// U-008: empty-shell client-side router. Hash-based (no History API / server
  2 +// catch-all needed) -- real page content is PH3 (U-022+), this just proves
  3 +// navigation swaps content without a page reload or any build step.
  4 +
  5 +const routes = {
  6 + "#/": () => "<h1>Dashboard</h1><p>placeholder -- real content is PH3 (v_dashboard, U-025)</p>",
  7 + "#/tasks": () => "<h1>Tasks</h1><p>placeholder -- real content is PH3 (v_task_board, U-025)</p>",
  8 + "#/settings": () => "<h1>Settings</h1><p>placeholder -- real content is PH3 (U-030)</p>",
  9 +};
  10 +
  11 +function render() {
  12 + const hash = window.location.hash || "#/";
  13 + const view = routes[hash] || (() => "<h1>Not found</h1><p>" + hash + "</p>");
  14 + document.getElementById("app").innerHTML = view();
  15 +
  16 + for (const a of document.querySelectorAll("#nav a")) {
  17 + a.classList.toggle("active", a.getAttribute("href") === hash);
  18 + }
  19 +}
  20 +
  21 +window.addEventListener("hashchange", render);
  22 +window.addEventListener("DOMContentLoaded", render);
... ...
frontend/index.html 0 → 100644
  1 +<!DOCTYPE html>
  2 +<html lang="en">
  3 +<head>
  4 +<meta charset="utf-8">
  5 +<title>graphlemon</title>
  6 +<link rel="stylesheet" href="/style.css">
  7 +</head>
  8 +<body>
  9 +<nav id="nav">
  10 + <a href="#/">Dashboard</a>
  11 + <a href="#/tasks">Tasks</a>
  12 + <a href="#/settings">Settings</a>
  13 +</nav>
  14 +<main id="app"></main>
  15 +<script src="/app.js"></script>
  16 +</body>
  17 +</html>
... ...
frontend/style.css 0 → 100644
  1 +/* U-008: minimal shell styling, nothing to design-review yet -- real look
  2 + and feel is PH3, starting from docs/wireframes/*.html. */
  3 +body { font-family: system-ui, sans-serif; margin: 0; }
  4 +#nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #ddd; }
  5 +#nav a { text-decoration: none; color: #333; }
  6 +#nav a.active { font-weight: bold; }
  7 +#app { padding: 1rem; }
... ...
src/backend_server.py
... ... @@ -2,16 +2,30 @@
2 2 third-party deps (05 §5.7) -- this process is also where the Graph Engine
3 3 scheduler will live later (05 §5.4.1), but that's a different Unit.
4 4  
5   -MVP scope here: process start/stop, and `c_pick_folder` (03-C §3.-1) as the
6   -one real route -- macOS only for now (`osascript`); Windows/Linux native
7   -dialogs are PH3 (U-022).
  5 +MVP scope here: process start/stop, `c_pick_folder` (03-C §3.-1) as the one
  6 +real API route -- macOS only for now (`osascript`); Windows/Linux native
  7 +dialogs are PH3 (U-022) -- and serving the U-008 frontend shell as static
  8 +files (no build step, 05 §5.7: whitelisted by exact path, not a generic
  9 +directory walker, so there's no path-traversal surface to reason about).
8 10 """
9 11  
10 12 import json
  13 +import os
11 14 import platform
12 15 import subprocess
13 16 from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14 17  
  18 +_FRONTEND_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "frontend")
  19 +
  20 +# U-008: exact-path whitelist, not a generic static-file server -- the
  21 +# frontend is 3 fixed files until PH3 adds real pages.
  22 +_STATIC_FILES = {
  23 + "/": ("index.html", "text/html; charset=utf-8"),
  24 + "/index.html": ("index.html", "text/html; charset=utf-8"),
  25 + "/app.js": ("app.js", "text/javascript; charset=utf-8"),
  26 + "/style.css": ("style.css", "text/css; charset=utf-8"),
  27 +}
  28 +
15 29  
16 30 class DialogUnavailableError(Exception):
17 31 """DIALOG_UNAVAILABLE -- 03-C §3.-1: no native-dialog capability here."""
... ... @@ -35,6 +49,20 @@ def pick_folder():
35 49  
36 50  
37 51 class _Handler(BaseHTTPRequestHandler):
  52 + def do_GET(self):
  53 + entry = _STATIC_FILES.get(self.path)
  54 + if entry is None:
  55 + self._send_json(404, {"error": "NOT_FOUND"})
  56 + return
  57 + filename, content_type = entry
  58 + with open(os.path.join(_FRONTEND_DIR, filename), "rb") as f:
  59 + body = f.read()
  60 + self.send_response(200)
  61 + self.send_header("Content-Type", content_type)
  62 + self.send_header("Content-Length", str(len(body)))
  63 + self.end_headers()
  64 + self.wfile.write(body)
  65 +
38 66 def do_POST(self):
39 67 if self.path == "/api/system/pick-folder":
40 68 self._handle_pick_folder()
... ...
src/test_backend_server.py
... ... @@ -59,6 +59,59 @@ class ServerStartStopTest(unittest.TestCase):
59 59 thread.join(timeout=5)
60 60  
61 61  
  62 +class StaticFileServingTest(unittest.TestCase):
  63 + """U-008: frontend shell served as static files. Actual client-side
  64 + routing behavior was verified in a real browser this session (nav
  65 + highlighting + content swap across all 3 routes, no page reload) --
  66 + that's not something a stdlib-only test can exercise without a JS
  67 + engine, so this only covers what the server is responsible for."""
  68 +
  69 + def setUp(self):
  70 + self.server = backend_server.create_server()
  71 + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  72 + self.thread.start()
  73 +
  74 + def tearDown(self):
  75 + self.server.shutdown()
  76 + self.server.server_close()
  77 + self.thread.join(timeout=5)
  78 +
  79 + def _get(self, path):
  80 + host, port = self.server.server_address
  81 + conn = http.client.HTTPConnection(host, port, timeout=5)
  82 + conn.request("GET", path)
  83 + resp = conn.getresponse()
  84 + body = resp.read()
  85 + content_type = resp.getheader("Content-Type")
  86 + conn.close()
  87 + return resp.status, content_type, body
  88 +
  89 + def test_root_serves_index_html(self):
  90 + status, content_type, body = self._get("/")
  91 + self.assertEqual(status, 200)
  92 + self.assertIn("text/html", content_type)
  93 + self.assertIn(b"<main id=\"app\">", body)
  94 + self.assertIn(b'href="#/tasks"', body)
  95 +
  96 + def test_app_js_served_with_js_content_type(self):
  97 + status, content_type, body = self._get("/app.js")
  98 + self.assertEqual(status, 200)
  99 + self.assertIn("javascript", content_type)
  100 + self.assertIn(b"hashchange", body)
  101 + self.assertIn(b'"#/tasks"', body)
  102 + self.assertIn(b'"#/settings"', body)
  103 +
  104 + def test_style_css_served_with_css_content_type(self):
  105 + status, content_type, body = self._get("/style.css")
  106 + self.assertEqual(status, 200)
  107 + self.assertIn("text/css", content_type)
  108 +
  109 + def test_unknown_get_path_returns_404(self):
  110 + status, _, body = self._get("/nope")
  111 + self.assertEqual(status, 404)
  112 + self.assertEqual(json.loads(body)["error"], "NOT_FOUND")
  113 +
  114 +
62 115 class PickFolderTest(unittest.TestCase):
63 116 def test_success_returns_selected_path(self):
64 117 with patch("src.backend_server.platform.system", return_value="Darwin"), \
... ...