changeset 226:3fa4bf481f42

[merge] Merge hg-web into default
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 14:42:01 -0700
parents eb8b4230fdb9 (current diff) 70de0c80d093 (diff)
children 8bb0ac8f4587
files MODULE.bazel MODULE.bazel.lock hg-web/deploy.sh
diffstat 33 files changed, 3288 insertions(+), 569 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/AGENT.md	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,21 @@
+# Agent instructions
+
+## Bazel is the project interface
+
+All project execution must happen through Bazel. Use existing `bazel build`,
+`bazel test`, and `bazel run` targets instead of invoking compiled binaries,
+language runtimes, package managers, generated scripts, or multiple services
+manually.
+
+When a workflow needs several processes or commands, add a Bazel target that
+owns their startup, runfiles, supervision, shutdown, and exit status. The user
+should need one `bazel run` command.
+
+For hg-web development, run the complete local stack with:
+
+```bash
+bazel run //hg-web:dev
+```
+
+Add or update the smallest relevant Bazel target whenever code cannot be built,
+tested, run, bundled, or orchestrated through Bazel.
--- a/MODULE.bazel	Sun Aug 02 08:52:13 2026 -0700
+++ b/MODULE.bazel	Sun Aug 02 14:42:01 2026 -0700
@@ -2,6 +2,8 @@
 bazel_dep(name = "platforms", version = "1.0.0")
 bazel_dep(name = "bazel_skylib", version = "1.8.2")
 bazel_dep(name = "rules_shell", version = "0.6.1")
+bazel_dep(name = "aspect_rules_js", version = "2.9.2", dev_dependency = True)
+bazel_dep(name = "rules_nodejs", version = "6.7.3", dev_dependency = True)
 bazel_dep(name = "openssl", version = "3.3.1.bcr.7")
 bazel_dep(name = "rules_foreign_cc", version = "0.14.0")
 bazel_dep(name = "buildifier_prebuilt", version = "7.1.2", dev_dependency = True)
@@ -13,6 +15,36 @@
 )
 
 http_file = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_file")
+http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+
+http_archive(
+  name = "playwright_chromium_linux",
+  urls = ["https://playwright.azureedge.net/builds/chromium/1140/chromium-linux.zip"],
+  sha256 = "e78cda52fa7e847abcd36a48ffabda43e71e2220a3e9a398f7c9a179529570a5",
+  strip_prefix = "chrome-linux",
+  build_file_content = """
+exports_files(["chrome"])
+
+filegroup(
+    name = "chromium",
+    srcs = glob(["**"], exclude = ["BUILD.bazel"]),
+    visibility = ["//visibility:public"],
+)
+""",
+)
+
+node = use_extension("@rules_nodejs//nodejs:extensions.bzl", "node")
+node.toolchain(node_version = "20.18.0")
+use_repo(node, "nodejs")
+
+npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm")
+npm.npm_translate_lock(
+  name = "hg_web_npm",
+  data = ["//hg-web/e2e:package.json"],
+  npm_package_lock = "//hg-web/e2e:package-lock.json",
+  pnpm_lock = "//hg-web/e2e:pnpm-lock.yaml",
+)
+use_repo(npm, "hg_web_npm")
 
 # Bun
 http_file(
--- a/MODULE.bazel.lock	Sun Aug 02 08:52:13 2026 -0700
+++ b/MODULE.bazel.lock	Sun Aug 02 14:42:01 2026 -0700
@@ -230,6 +230,102 @@
   },
   "selectedYankedVersions": {},
   "moduleExtensions": {
+    "@@aspect_rules_js+//npm:extensions.bzl%pnpm": {
+      "general": {
+        "bzlTransitiveDigest": "A3wObJgLD5IcmyBKFyx9nfycX4VosrUq4UtGAHdyZ8M=",
+        "usagesDigest": "UCPGCdTkoflpzf0ShZ9hsk2zN6bbQlRvpRRZJnniDDc=",
+        "recordedInputs": [
+          "REPO_MAPPING:aspect_bazel_lib+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:aspect_bazel_lib+,bazel_tools bazel_tools",
+          "REPO_MAPPING:aspect_bazel_lib+,tar.bzl tar.bzl+",
+          "REPO_MAPPING:aspect_rules_js+,aspect_bazel_lib aspect_bazel_lib+",
+          "REPO_MAPPING:aspect_rules_js+,aspect_rules_js aspect_rules_js+",
+          "REPO_MAPPING:aspect_rules_js+,aspect_tools_telemetry_report aspect_tools_telemetry++telemetry+aspect_tools_telemetry_report",
+          "REPO_MAPPING:aspect_rules_js+,bazel_features bazel_features+",
+          "REPO_MAPPING:aspect_rules_js+,bazel_lib bazel_lib+",
+          "REPO_MAPPING:aspect_rules_js+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:aspect_rules_js+,bazel_tools bazel_tools",
+          "REPO_MAPPING:bazel_features+,bazel_features_globals bazel_features++version_extension+bazel_features_globals",
+          "REPO_MAPPING:bazel_features+,bazel_features_version bazel_features++version_extension+bazel_features_version",
+          "REPO_MAPPING:bazel_lib+,bazel_tools bazel_tools",
+          "REPO_MAPPING:tar.bzl+,aspect_bazel_lib aspect_bazel_lib+",
+          "REPO_MAPPING:tar.bzl+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:tar.bzl+,tar.bzl tar.bzl+"
+        ],
+        "generatedRepoSpecs": {
+          "pnpm": {
+            "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_rule",
+            "attributes": {
+              "package": "pnpm",
+              "version": "8.15.9",
+              "root_package": "",
+              "link_workspace": "",
+              "link_packages": {},
+              "integrity": "sha512-SZQ0ydj90aJ5Tr9FUrOyXApjOrzuW7Fee13pDzL0e1E6ypjNXP0AHDHw20VLw4BO3M1XhQHkyik6aBYWa72fgQ==",
+              "url": "",
+              "commit": "",
+              "patch_args": [
+                "-p0"
+              ],
+              "patches": [],
+              "custom_postinstall": "",
+              "npm_auth": "",
+              "npm_auth_basic": "",
+              "npm_auth_username": "",
+              "npm_auth_password": "",
+              "lifecycle_hooks": [],
+              "extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])",
+              "extract_full_archive": true,
+              "exclude_package_contents": []
+            }
+          },
+          "pnpm__links": {
+            "repoRuleId": "@@aspect_rules_js+//npm/private:npm_import.bzl%npm_import_links",
+            "attributes": {
+              "package": "pnpm",
+              "version": "8.15.9",
+              "dev": false,
+              "root_package": "",
+              "link_packages": {},
+              "deps": {},
+              "transitive_closure": {},
+              "lifecycle_build_target": false,
+              "lifecycle_hooks_env": [],
+              "lifecycle_hooks_execution_requirements": [
+                "no-sandbox"
+              ],
+              "lifecycle_hooks_use_default_shell_env": false,
+              "bins": {},
+              "package_visibility": [
+                "//visibility:public"
+              ],
+              "exclude_package_contents": []
+            }
+          }
+        }
+      }
+    },
+    "@@aspect_tools_telemetry+//:extension.bzl%telemetry": {
+      "general": {
+        "bzlTransitiveDigest": "cl5A2O84vDL6Tt+Qga8FCj1DUDGqn+e7ly5rZ+4xvcc=",
+        "usagesDigest": "TQSuPERI87Z4Alo1eOWeUR3NGo5f3txcCOd4tpsGXmw=",
+        "recordedInputs": [
+          "REPO_MAPPING:aspect_tools_telemetry+,bazel_lib bazel_lib+",
+          "REPO_MAPPING:aspect_tools_telemetry+,bazel_skylib bazel_skylib+"
+        ],
+        "generatedRepoSpecs": {
+          "aspect_tools_telemetry_report": {
+            "repoRuleId": "@@aspect_tools_telemetry+//:extension.bzl%tel_repository",
+            "attributes": {
+              "deps": {
+                "aspect_rules_js": "2.9.2",
+                "aspect_tools_telemetry": "0.3.3"
+              }
+            }
+          }
+        }
+      }
+    },
     "@@buildifier_prebuilt+//:defs.bzl%buildifier_prebuilt_deps_extension": {
       "general": {
         "bzlTransitiveDigest": "HljGbKDagP11Zj2xu7uXH4csvPiytXXlNpFDe1PeuTs=",
@@ -446,6 +542,28 @@
         }
       }
     },
+    "@@pybind11_bazel+//:internal_configure.bzl%internal_configure_extension": {
+      "general": {
+        "bzlTransitiveDigest": "NRXra7941UfmNUyIxnLt82V5hULluVGL2nBsijTl4j4=",
+        "usagesDigest": "D1r3lfzMuUBFxgG8V6o0bQTLMk3GkaGOaPzw53wrwyw=",
+        "recordedInputs": [
+          "REPO_MAPPING:pybind11_bazel+,bazel_tools bazel_tools",
+          "FILE:@@pybind11_bazel+//MODULE.bazel e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34"
+        ],
+        "generatedRepoSpecs": {
+          "pybind11": {
+            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+            "attributes": {
+              "build_file": "@@pybind11_bazel+//:pybind11-BUILD.bazel",
+              "strip_prefix": "pybind11-2.12.0",
+              "urls": [
+                "https://github.com/pybind/pybind11/archive/v2.12.0.zip"
+              ]
+            }
+          }
+        }
+      }
+    },
     "@@rules_foreign_cc+//foreign_cc:extensions.bzl%tools": {
       "general": {
         "bzlTransitiveDigest": "G54TxUUn6vxT4n5eIpNwbycPtXtiR354MSgHTcQAnHI=",
@@ -879,7 +997,7 @@
     "@@rules_nodejs+//nodejs:extensions.bzl%node": {
       "general": {
         "bzlTransitiveDigest": "4pUxCNc22K4I+6+4Nxu52Hur12tFRfa1JMsN5mdDv60=",
-        "usagesDigest": "1PdljUSmnJF0C3nTMWMdndAlDqMjOzQHlOp2TAszTxk=",
+        "usagesDigest": "aOq2mNhv/K35rllf0LkQnk575K/CZNf1RTfrNe1kDkY=",
         "recordedInputs": [],
         "generatedRepoSpecs": {
           "nodejs_linux_amd64": {
--- a/hg-web/BUILD	Sun Aug 02 08:52:13 2026 -0700
+++ b/hg-web/BUILD	Sun Aug 02 14:42:01 2026 -0700
@@ -1,4 +1,5 @@
 load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
+load("@rules_shell//shell:sh_binary.bzl", "sh_binary")
 load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle", "bun_bundle")
 
 # Source files
@@ -58,6 +59,7 @@
   srcs = ["main.c"],
   deps = ["//seobeo:seobeo"],
   data = [":all_assets"],
+  visibility = ["//hg-web:__subpackages__"],
 )
 
 cc_binary(
@@ -67,6 +69,20 @@
   data = [":all_assets"],
 )
 
+sh_binary(
+  name = "dev",
+  srcs = ["dev.sh"],
+  data = [
+    ":hg_web_server",
+    "@bazel_tools//tools/bash/runfiles",
+  ],
+)
+
+test_suite(
+  name = "tests",
+  tests = ["//hg-web/e2e:app_e2e_test"],
+)
+
 bundle(
   name = "hg_web_server_bundle",
   binary = ":hg_web_server",
--- a/hg-web/README.md	Sun Aug 02 08:52:13 2026 -0700
+++ b/hg-web/README.md	Sun Aug 02 14:42:01 2026 -0700
@@ -1,34 +1,286 @@
 # hg-web
 
-A web-based Mercurial repository browser. Provides a GitHub-style interface for browsing files, viewing code with syntax highlighting, and reading markdown documentation.
+A custom Mercurial forge and repository browser. The application keeps the
+networking and HTTP stack in C with Seobeo, while the hand-drawn web interface
+is implemented in React and TypeScript.
+
+## Wiki tree
 
-## Features
+```text
+hg-web
+├── Runtime
+│   ├── hg_web_server :6970
+│   │   ├── React application and static assets
+│   │   ├── repository browsing API
+│   │   ├── commit graph API
+│   │   └── Mercurial wire-protocol proxy
+│   └── hg serve :4444
+│       ├── repository files and graph data
+│       └── Mercurial pull/push protocol
+├── Backend
+│   ├── main.c
+│   └── ../seobeo
+│       ├── s_network.c
+│       ├── s_web.c
+│       ├── s_http_client.c
+│       └── s_ssl.c
+├── Frontend
+│   └── src
+│       ├── main.tsx
+│       ├── components
+│       │   ├── app.tsx
+│       │   ├── directory-browser.tsx
+│       │   ├── graph.tsx
+│       │   ├── header.tsx
+│       │   ├── footer.tsx
+│       │   ├── theme.tsx
+│       │   └── repo-browser.tsx
+│       ├── index.html
+│       ├── index.css
+│       ├── base.css
+│       └── custom pencil, panda, and icon assets
+├── Build
+│   ├── BUILD
+│   ├── ../gui_ze/gui_ze.bzl
+│   ├── ../markdown_converter
+│   └── ../third_party/highlight
+└── Operations
+    └── deploy.sh
+```
+
+`repo-browser.tsx` is an older standalone browser implementation. The active
+entry point is `main.tsx`, which renders `app.tsx`; `app.tsx` embeds
+`directory-browser.tsx`.
+
+## Runtime topology
 
-- Browse repository files and directories
-- View code files with syntax highlighting (highlight.js)
-- Render markdown files with WASM-based converter
-- Dark/light theme support with system preference detection
-- Prefetch on hover for faster navigation
+```text
+Browser
+  |
+  | HTTPS
+  v
+nginx
+  |
+  | HTTP :6970
+  v
+hg_web_server (main.c + Seobeo)
+  |
+  | HTTP :4444
+  v
+hg serve (Zenbu Mercurial repository)
+```
+
+The C server owns the public routes. It serves the application shell and
+assets, translates browser API requests into `hg serve` requests, and streams
+Mercurial wire-protocol traffic without routing bundle data through the normal
+buffered HTTP response path.
+
+## Request flows
+
+### Application shell
+
+```text
+GET /, /directories, or /graph
+  -> GetReactHome
+  -> hg-web/src/index.html
+  -> /page.js
+  -> src/main.tsx
+  -> components/app.tsx
+```
+
+### Repository browser
 
-## Structure
+```text
+directory-browser.tsx
+  -> GET /api/repo/list?path=...
+  -> ApiListDirectory
+  -> GET hg-serve/file/tip/<path>?style=json
 
+file or README selection
+  -> GET /api/repo/file?path=...
+  -> ApiGetFile
+  -> GET hg-serve/raw-file/tip/<path>
+  -> highlight.js, markdown_converter WASM, or inline static preview
 ```
-hg-web/
-├── BUILD           # Bazel build configuration
-├── deploy.sh       # Deployment script
-├── main.c          # C server handling API routes
-└── src/            # Frontend source files
+
+Images, SVG, video, audio, and PDF files open in the forge preview modal.
+Unknown binary files retain a download link. The raw file API supplies explicit
+MIME types, `nosniff`, inline disposition for supported previews, and sandboxed
+SVG responses.
+
+### Commit graph
+
+```text
+graph.tsx
+  -> GET /api/graph/<revision>?graphtop=...&style=json
+  -> ApiGetGraph
+  -> GET hg-serve/graph/<revision>?...
+  -> React rows + custom pencil/panda canvas
+
+graph row selection
+  -> /changeset/<revision>
+  -> GET /api/changeset/<revision>
+  -> ApiGetChangeset
+  -> GET hg-serve/json-rev/<revision>
+  -> changeset metadata + rendered diff
+```
+
+### Mercurial clone, pull, and push
+
+```text
+Mercurial client
+  -> GET or POST /repo
+  -> StreamHgWireProtocol
+  -> hg serve /?cmd=...
+  -> binary-safe streamed response
 ```
 
-## Building
+## Route map
+
+| Method | Route | Owner | Purpose |
+| --- | --- | --- | --- |
+| `GET` | `/` | `GetReactHome` | Application shell |
+| `GET` | `/directories` | `GetReactHome` | Legacy application-shell route |
+| `GET` | `/directory` | `GetReactHome` | Repository browser application route |
+| `GET` | `/graph` | `GetReactHome` | Commit graph application route |
+| `GET` | `/changeset/:changeset_id` | `GetReactHome` | Changeset application route |
+| `GET` | `/api/repo/list` | `ApiListDirectory` | Directory listing JSON |
+| `GET` | `/api/repo/file` | `ApiGetFile` | Raw tracked file |
+| `GET` | `/api/repo/readme` | `ApiGetReadme` | Directory README content |
+| `GET` | `/api/graph/:graph_id` | `ApiGetGraph` | Mercurial graph JSON |
+| `GET` | `/api/changeset/:changeset_id` | `ApiGetChangeset` | Changeset metadata and diff JSON |
+| `GET`, `POST` | `/repo` | `StreamHgWireProtocol` | Mercurial wire protocol |
+
+## Frontend ownership
+
+| File | Responsibility |
+| --- | --- |
+| `src/components/app.tsx` | Client-side routes, landing page, tabs, and history |
+| `src/components/directory-browser.tsx` | Breadcrumbs, listings, prefetch, file modals, README rendering |
+| `src/components/graph.tsx` | Graph fetching, pagination, canvas edges, panda nodes |
+| `src/components/theme.tsx` | Stored light/dark preference and system-theme integration |
+| `src/components/header.tsx` | Forge identity and theme control |
+| `src/components/footer.tsx` | Shared footer |
+| `src/index.css`, `src/base.css` | Custom visual language and layout |
+
+Keep the custom assets and visual language in this package. New forge screens
+should reuse the existing CSS variables, typography, textures, and components
+instead of introducing a generic design system.
+
+## Build and local run
+
+Build the server and deployable bundle from the repository root:
 
 ```bash
-bazel build //hg-web:hg_web
+bazel build //hg-web:hg_web_server
+bazel build //hg-web:hg_web_server_bundle
+bazel test //hg-web:tests
+bazel test //markdown_converter/tests:markdown_to_html_test
+bazel test //seobeo/tests:all
+```
+
+`//hg-web:tests` creates a temporary two-commit Mercurial repository and runs
+the application in pinned Chromium through Playwright. It covers shell routes,
+files and README rendering, graph and changeset navigation, browser history,
+console and page errors, API validation, Mercurial wire protocol, Markdown
+script escaping, static assets, and backend outages. The test invokes the
+workspace Mercurial CLI with an isolated configuration and home directory.
+
+Build and run the complete local stack with one Bazel command:
+
+```bash
+bazel run //hg-web:dev
+```
+
+This starts `hg serve` on `127.0.0.1:4444` and `hg_web_server` on port `6970`.
+Stopping the Bazel target stops both child processes.
+
+The bundle contains `hg_web_server` and `hg-web/src/`, including generated
+`page.js`, markdown WASM, highlight.js, styles, and image assets.
+
+## Deployment contract
+
+The expected production layout is:
+
+```text
+nginx
+  -> hg_web_server.service
+     -> /opt/hg_web_server_bundle_active/hg_web_server
+     -> working directory: /opt/hg_web_server_bundle_active
+  -> hg serve service on 127.0.0.1:4444
 ```
 
-## API Endpoints
+`deploy.sh` builds an optimized bundle into a revisioned release directory,
+atomically repoints `/opt/hg_web_server_bundle_active`, restarts
+`hg_web_server.service`, and checks `http://127.0.0.1:6970/`. A failed restart
+or health check restores the previous release and restarts it. The service,
+release root, active path, health URL, user, and group can be overridden with
+environment variables.
+
+## Forge capability tree
+
+```text
+Zenbu Forge
+├── Repository
+│   ├── File and directory browsing            available
+│   ├── Syntax highlighting                    available
+│   ├── README rendering                       available
+│   ├── Commit graph                           available
+│   ├── Changeset detail and diff              available
+│   ├── Branches, bookmarks, and tags          planned
+│   ├── File history and blame                 planned
+│   └── Search                                 planned
+├── Collaboration
+│   ├── Authentication and authorization       planned
+│   ├── Changeset review                       planned
+│   ├── Issues                                 planned
+│   └── Releases and artifacts                 planned
+└── Automation
+    ├── Mercurial incoming/changegroup hook    planned
+    ├── Durable SQLite job queue               planned
+    ├── Isolated Bazel runner                  planned
+    ├── Live logs and job status API           planned
+    ├── Forge status and log screens           planned
+    ├── Artifact retention                     planned
+    └── Atomic deploy, health check, rollback  available
+```
+
+## Automation data flow
 
-The C server (`main.c`) provides:
+The Actions-like subsystem should extend the existing server rather than
+replace it:
+
+```text
+hg push
+  -> Mercurial hook records repository + revision
+  -> small enqueue command writes a SQLite job
+  -> runner service claims one queued job
+  -> isolated shared checkout updates to the exact revision
+  -> Bazel build and test steps stream logs
+  -> job result and artifacts are recorded
+  -> hg-web status API exposes the result
+  -> custom React screens render runs, steps, logs, and artifacts
+  -> successful protected jobs may call atomic deployment
+```
 
-- `GET /api/repo/list?path=` - List directory contents
-- `GET /api/repo/file?path=` - Fetch file contents
+Start with one runner on the same host. Keep the hook fast, never execute build
+steps inside the Mercurial request, and make the queue durable before adding
+parallel or remote runners.
+
+## Safe extension order
+
+1. Add branches, bookmarks, tags, file history, blame, and search.
+2. Add the hook, SQLite queue, single runner, and retained logs.
+3. Add run/status/log pages using the existing custom UI.
+4. Add authentication and authorization before accepting public pushes or
+   user-defined automation.
+
+## Invariants
+
+- Keep public application code on Seobeo APIs.
+- Decode and validate every path before forwarding it to Mercurial.
+- Keep `/repo` binary-safe; bundle data can contain null bytes.
+- Do not buffer large wire-protocol responses through the regular HTTP client.
+- Keep backend route names synchronized with frontend fetch and navigation code.
+- Preserve accurate status codes, content lengths, and content types.
+- Keep secrets in service-owned environment files, not in source or job logs.
--- a/hg-web/deploy.sh	Sun Aug 02 08:52:13 2026 -0700
+++ b/hg-web/deploy.sh	Sun Aug 02 14:42:01 2026 -0700
@@ -1,15 +1,82 @@
-#!/bin/bash
-# sudo groupadd zenbu_team -- already added
-# sudo useradd -r -s /usr/sbin/nologin -G zenbu_team hg_web_server
+#!/usr/bin/env bash
+set -Eeuo pipefail
+
+SERVICE_NAME="${SERVICE_NAME:-hg_web_server.service}"
+RELEASE_ROOT="${RELEASE_ROOT:-/opt/hg_web_server_releases}"
+ACTIVE_PATH="${ACTIVE_PATH:-/opt/hg_web_server_bundle_active}"
+HEALTH_URL="${HEALTH_URL:-http://127.0.0.1:6970/}"
+SERVICE_USER="${SERVICE_USER:-hg_web_server}"
+SERVICE_GROUP="${SERVICE_GROUP:-zenbu_team}"
+
+workspace="$(hg root)"
+cd "$workspace"
+
+revision="$(hg log -r . -T '{node|short}')"
+release_name="${revision}-$(date -u +%Y%m%dT%H%M%SZ)"
+release_dir="${RELEASE_ROOT}/${release_name}"
+staging_dir="${RELEASE_ROOT}/.${release_name}.tmp"
+next_link="${ACTIVE_PATH}.next"
+bundle_dir="bazel-bin/hg-web/hg_web_server_bundle"
+previous_release=""
+promoted=0
+
+health_check() {
+  for _ in $(seq 1 20); do
+    if curl --fail --silent --max-time 3 "$HEALTH_URL" >/dev/null; then
+      return 0
+    fi
+    sleep 1
+  done
+  echo "Health check failed: $HEALTH_URL" >&2
+  return 1
+}
+
+point_active_at() {
+  local target="$1"
+  sudo rm -f "$next_link"
+  sudo ln -s "$target" "$next_link"
+  sudo mv -Tf "$next_link" "$ACTIVE_PATH"
+}
+
+rollback() {
+  trap - ERR
+  if [[ "$promoted" -eq 1 && -n "$previous_release" && -d "$previous_release" ]]; then
+    echo "Deployment failed; rolling back to $previous_release" >&2
+    point_active_at "$previous_release"
+    sudo systemctl restart "$SERVICE_NAME"
+    health_check || echo "Rollback completed, but the health check still fails." >&2
+  else
+    echo "Deployment failed and no previous release is available for rollback." >&2
+  fi
+  sudo rm -rf "$staging_dir"
+  exit 1
+}
+trap rollback ERR
+
 bazel build -c opt //hg-web:hg_web_server_bundle
 
-# Create
-sudo cp -a bazel-bin/hg-web/hg_web_server_bundle /opt/hg_web_server_bundle_new
-sudo chown -R hg_web_server:zenbu_team /opt/hg_web_server_bundle_new
+sudo install -d -o root -g "$SERVICE_GROUP" -m 0755 "$RELEASE_ROOT"
+sudo rm -rf "$staging_dir"
+sudo install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0755 "$staging_dir"
+sudo cp -a "${bundle_dir}/." "$staging_dir/"
+sudo chown -R "$SERVICE_USER:$SERVICE_GROUP" "$staging_dir"
+
+sudo test -x "$staging_dir/hg_web_server"
+sudo test -f "$staging_dir/hg-web/src/index.html"
+sudo test -f "$staging_dir/hg-web/src/page.js"
+sudo mv "$staging_dir" "$release_dir"
 
-# Swap
-sudo rm -rf /opt/hg_web_server_bundle_active
-sudo mv /opt/hg_web_server_bundle_new /opt/hg_web_server_bundle_active
+if [[ -L "$ACTIVE_PATH" ]]; then
+  previous_release="$(readlink -f "$ACTIVE_PATH")"
+elif [[ -d "$ACTIVE_PATH" ]]; then
+  previous_release="${RELEASE_ROOT}/legacy-$(date -u +%Y%m%dT%H%M%SZ)"
+  sudo mv "$ACTIVE_PATH" "$previous_release"
+fi
 
-sudo systemctl restart hg_web_server.service
-echo "Deployment complete!"
+point_active_at "$release_dir"
+promoted=1
+sudo systemctl restart "$SERVICE_NAME"
+health_check
+
+trap - ERR
+echo "Deployment complete: $release_dir"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/dev.sh	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,78 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ -n "${RUNFILES_DIR:-}" ]]; then
+  source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash"
+elif [[ -n "${RUNFILES_MANIFEST_FILE:-}" ]]; then
+  runfiles_library="$(
+    grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \
+      "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2-
+  )"
+  source "$runfiles_library"
+elif [[ -d "$0.runfiles" ]]; then
+  RUNFILES_DIR="$0.runfiles"
+  export RUNFILES_DIR
+  source "$RUNFILES_DIR/bazel_tools/tools/bash/runfiles/runfiles.bash"
+elif [[ -f "$0.runfiles_manifest" ]]; then
+  RUNFILES_MANIFEST_FILE="$0.runfiles_manifest"
+  export RUNFILES_MANIFEST_FILE
+  runfiles_library="$(
+    grep -sm1 '^bazel_tools/tools/bash/runfiles/runfiles.bash ' \
+      "$RUNFILES_MANIFEST_FILE" | cut -d' ' -f2-
+  )"
+  source "$runfiles_library"
+else
+  echo "Bazel runfiles are unavailable." >&2
+  exit 1
+fi
+
+workspace="${BUILD_WORKSPACE_DIRECTORY:-}"
+if [[ -z "$workspace" || ! -d "$workspace/.hg" ]]; then
+  echo "Run this target from the Zenbu workspace." >&2
+  exit 1
+fi
+
+server="$(rlocation _main/hg-web/hg_web_server)"
+runfiles_workspace="$(dirname "$(dirname "$server")")"
+hg_pid=""
+server_pid=""
+
+cleanup() {
+  trap - EXIT INT TERM
+  for pid in "$server_pid" "$hg_pid"; do
+    if [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
+      kill "$pid" 2>/dev/null || true
+    fi
+  done
+  for pid in "$server_pid" "$hg_pid"; do
+    if [[ -n "$pid" ]]; then
+      wait "$pid" 2>/dev/null || true
+    fi
+  done
+}
+
+trap cleanup EXIT
+trap 'exit 130' INT TERM
+
+hg --repository "$workspace" serve \
+  --address 127.0.0.1 \
+  --port 4444 \
+  --accesslog - \
+  --errorlog - &
+hg_pid=$!
+
+(
+  cd "$runfiles_workspace"
+  exec "$server"
+) &
+server_pid=$!
+
+echo "hg-web: http://127.0.0.1:6970"
+echo "Mercurial wire endpoint: http://127.0.0.1:6970/repo"
+echo "Press Ctrl-C to stop both servers."
+
+set +e
+wait -n "$hg_pid" "$server_pid"
+status=$?
+set -e
+exit "$status"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/e2e/BUILD	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,31 @@
+load("@aspect_rules_js//js:defs.bzl", "js_test")
+load("@hg_web_npm//:defs.bzl", "npm_link_all_packages")
+
+exports_files([
+    "package-lock.json",
+    "package.json",
+    "pnpm-lock.yaml",
+])
+
+npm_link_all_packages(name = "node_modules")
+
+js_test(
+    name = "app_e2e_test",
+    entry_point = "app_e2e_test.js",
+    data = [
+        ":node_modules/playwright-core",
+        "//hg-web:hg_web_server",
+        "@playwright_chromium_linux//:chromium",
+        "@playwright_chromium_linux//:chrome",
+    ],
+    env = {
+        "CHROMIUM_PATH": "$(rootpath @playwright_chromium_linux//:chrome)",
+    },
+    no_copy_to_bin = ["@playwright_chromium_linux//:chromium"],
+    size = "large",
+    target_compatible_with = [
+        "@platforms//cpu:x86_64",
+        "@platforms//os:linux",
+    ],
+    timeout = "long",
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/e2e/app_e2e_test.js	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,417 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { spawn, spawnSync } = require('node:child_process');
+const { chromium } = require('playwright-core');
+
+const BASE_URL = 'http://127.0.0.1:6970';
+const RUNFILES = process.env.JS_BINARY__RUNFILES;
+const WORKSPACE = process.env.JS_BINARY__WORKSPACE;
+
+function run(command, args, options = {}) {
+  const result = spawnSync(command, args, {
+    encoding: 'utf8',
+    ...options,
+  });
+  if (result.status !== 0) {
+    throw new Error(
+      `${command} ${args.join(' ')} failed\n${result.stdout || ''}${result.stderr || ''}`,
+    );
+  }
+  return result.stdout.trim();
+}
+
+function mercurialEnvironment(home) {
+  return {
+    ...process.env,
+    HGPLAIN: '1',
+    HGRCPATH: '',
+    HOME: home,
+  };
+}
+
+function stopProcess(child) {
+  if (!child || child.exitCode !== null) return Promise.resolve();
+  child.kill('SIGTERM');
+  return new Promise(resolve => {
+    const timer = setTimeout(() => {
+      if (child.exitCode === null) child.kill('SIGKILL');
+    }, 3000);
+    child.once('exit', () => {
+      clearTimeout(timer);
+      resolve();
+    });
+  });
+}
+
+async function waitForHttp(url, child, logs) {
+  const deadline = Date.now() + 15000;
+  while (Date.now() < deadline) {
+    if (child.exitCode !== null) {
+      throw new Error(`Server exited early with ${child.exitCode}\n${logs.join('')}`);
+    }
+    try {
+      const response = await fetch(url);
+      if (response.ok) return;
+    } catch {
+      // Keep waiting for startup.
+    }
+    await new Promise(resolve => setTimeout(resolve, 100));
+  }
+  throw new Error(`Timed out waiting for ${url}\n${logs.join('')}`);
+}
+
+function createFixtureRepository(root) {
+  const options = { env: mercurialEnvironment(root) };
+  run('hg', ['init', root], options);
+  fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
+  fs.mkdirSync(path.join(root, 'src'), { recursive: true });
+  fs.writeFileSync(
+    path.join(root, 'README.md'),
+    '# Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n',
+  );
+  fs.writeFileSync(path.join(root, 'docs', 'README.md'), '# Documentation\n\nNested README.\n');
+  fs.writeFileSync(path.join(root, 'src', 'main.c'), 'int main(void) { return 0; }\n');
+  fs.writeFileSync(
+    path.join(root, 'BUILD'),
+    'cc_library(\n    name = "fixture",\n    srcs = ["src/main.c"],\n)\n',
+  );
+  fs.writeFileSync(
+    path.join(root, 'pixel.png'),
+    Buffer.from(
+      'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Wl2nWQAAAAASUVORK5CYII=',
+      'base64',
+    ),
+  );
+  fs.writeFileSync(path.join(root, 'movie.mp4'), Buffer.from('00000018667479706d703432', 'hex'));
+  fs.writeFileSync(path.join(root, 'sound.mp3'), Buffer.from('ID3'));
+  fs.writeFileSync(path.join(root, 'document.pdf'), Buffer.from('%PDF-1.4\n%%EOF\n'));
+  fs.writeFileSync(path.join(root, 'archive.bin'), Buffer.from([0, 1, 2, 3]));
+  run('hg', ['--repository', root, 'add'], options);
+  run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Initial fixture'], options);
+  const firstNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options);
+
+  fs.writeFileSync(
+    path.join(root, 'README.md'),
+    '# Updated Fixture Repository\n\n<script>window.__hgWebXss = true</script>\n\nSecond revision.\n',
+  );
+  fs.writeFileSync(path.join(root, 'new-file.txt'), 'new file\n');
+  run('hg', ['add', 'new-file.txt'], { ...options, cwd: root });
+  run('hg', ['--repository', root, 'commit', '-u', 'Test User <[email protected]>', '-m', 'Update fixture'], options);
+  const tipNode = run('hg', ['--repository', root, 'log', '-r', '.', '-T', '{node}'], options);
+  return { firstNode, tipNode };
+}
+
+async function assertJson(pathname, status = 200) {
+  const response = await fetch(`${BASE_URL}${pathname}`);
+  const body = await response.text();
+  assert.equal(response.status, status, `${pathname} status: ${body}`);
+  return JSON.parse(body);
+}
+
+async function assertPageHasNoBrowserErrors(browser, pathname, assertion) {
+  const page = await browser.newPage();
+  const errors = [];
+  page.on('pageerror', error => errors.push(`pageerror: ${error.stack || error.message}`));
+  page.on('console', message => {
+    if (message.type() === 'error') errors.push(`console: ${message.text()}`);
+  });
+  page.on('requestfailed', request => {
+    errors.push(`requestfailed: ${request.url()} ${request.failure()?.errorText || ''}`);
+  });
+  page.on('response', response => {
+    if (response.status() >= 400) {
+      errors.push(`response: ${response.status()} ${response.url()}`);
+    }
+  });
+
+  const response = await page.goto(`${BASE_URL}${pathname}`, { waitUntil: 'networkidle' });
+  assert.equal(response.status(), 200, `${pathname} document status`);
+  await assertion(page);
+  if (errors.length > 0) {
+    throw new Error(`${pathname} browser errors\n${errors.join('\n')}`);
+  }
+  await page.close();
+}
+
+async function main() {
+  assert.ok(RUNFILES, 'rules_js runfiles path is required');
+  assert.ok(WORKSPACE, 'rules_js workspace name is required');
+
+  const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'hg-web-e2e-'));
+  const appLogs = [];
+  const hgLogs = [];
+  let hgServer;
+  let appServer;
+  let browser;
+
+  try {
+    const { firstNode, tipNode } = createFixtureRepository(fixtureRoot);
+    const runfilesWorkspace = path.join(RUNFILES, WORKSPACE);
+    const serverBinary = path.join(runfilesWorkspace, 'hg-web', 'hg_web_server');
+    const chromiumPath = path.resolve(process.env.CHROMIUM_PATH);
+
+    assert.ok(fs.existsSync(serverBinary), `missing server binary: ${serverBinary}`);
+    assert.ok(fs.existsSync(chromiumPath), `missing Chromium binary: ${chromiumPath}`);
+
+    hgServer = spawn(
+      'hg',
+      [
+        '--repository', fixtureRoot,
+        'serve',
+        '--address', '127.0.0.1',
+        '--port', '4444',
+        '--accesslog', '-',
+        '--errorlog', '-',
+      ],
+      {
+        env: mercurialEnvironment(fixtureRoot),
+        stdio: ['ignore', 'pipe', 'pipe'],
+      },
+    );
+    hgServer.stdout.on('data', chunk => hgLogs.push(chunk.toString()));
+    hgServer.stderr.on('data', chunk => hgLogs.push(chunk.toString()));
+
+    appServer = spawn(serverBinary, [], {
+      cwd: runfilesWorkspace,
+      stdio: ['ignore', 'pipe', 'pipe'],
+    });
+    appServer.stdout.on('data', chunk => appLogs.push(chunk.toString()));
+    appServer.stderr.on('data', chunk => appLogs.push(chunk.toString()));
+
+    await waitForHttp(`${BASE_URL}/`, appServer, appLogs);
+    if (hgServer.exitCode !== null) {
+      throw new Error(`Mercurial server exited early with ${hgServer.exitCode}\n${hgLogs.join('')}`);
+    }
+    await waitForHttp(`${BASE_URL}/api/repo/list`, hgServer, hgLogs);
+
+    for (const pathname of ['/', '/directory', '/directory?path=docs', '/graph', `/changeset/${tipNode}`]) {
+      const response = await fetch(`${BASE_URL}${pathname}`);
+      assert.equal(response.status, 200, `${pathname} shell route`);
+      assert.match(await response.text(), /<title>Zenbu Repository<\/title>/);
+    }
+    for (const pathname of ['/page.js', '/index.css', '/base.css', '/pencil_lines.png', '/panda.png']) {
+      const response = await fetch(`${BASE_URL}${pathname}`);
+      assert.equal(response.status, 200, `${pathname} static asset`);
+      assert.ok((await response.arrayBuffer()).byteLength > 0, `${pathname} is non-empty`);
+    }
+    for (const pathname of ['/hg-web-background.jpg', '/pencil_texture.png']) {
+      assert.equal((await fetch(`${BASE_URL}${pathname}`)).status, 404);
+    }
+
+    const rootList = await assertJson('/api/repo/list');
+    assert.ok(rootList.directories.some(entry => entry.basename === 'docs'));
+    assert.ok(rootList.files.some(entry => entry.basename === 'README.md'));
+
+    const nestedList = await assertJson('/api/repo/list?path=docs');
+    assert.ok(nestedList.files.some(entry => entry.basename === 'README.md'));
+
+    const fileResponse = await fetch(`${BASE_URL}/api/repo/file?path=src%2Fmain.c`);
+    assert.equal(fileResponse.status, 200);
+    assert.match(await fileResponse.text(), /int main/);
+
+    for (const [filename, contentType, disposition] of [
+      ['pixel.png', 'image/png', 'inline'],
+      ['movie.mp4', 'video/mp4', 'inline'],
+      ['sound.mp3', 'audio/mpeg', 'inline'],
+      ['document.pdf', 'application/pdf', 'inline'],
+      ['archive.bin', 'application/octet-stream', 'attachment'],
+    ]) {
+      const response = await fetch(
+        `${BASE_URL}/api/repo/file?path=${encodeURIComponent(filename)}`,
+      );
+      assert.equal(response.status, 200, `${filename} response`);
+      assert.match(response.headers.get('content-type') || '', new RegExp(`^${contentType}`));
+      assert.equal(response.headers.get('content-disposition'), disposition);
+      assert.equal(response.headers.get('x-content-type-options'), 'nosniff');
+      assert.ok((await response.arrayBuffer()).byteLength > 0);
+    }
+    const binaryResponse = await fetch(`${BASE_URL}/api/repo/file?path=archive.bin`);
+    assert.deepEqual(
+      Buffer.from(await binaryResponse.arrayBuffer()),
+      Buffer.from([0, 1, 2, 3]),
+    );
+
+    const readmeResponse = await fetch(`${BASE_URL}/api/repo/readme?path=docs`);
+    assert.equal(readmeResponse.status, 200);
+    assert.match(await readmeResponse.text(), /Nested README/);
+    assert.equal((await fetch(`${BASE_URL}/api/repo/readme?path=src`)).status, 204);
+
+    const graph = await assertJson('/api/graph/tip?style=json');
+    assert.equal(graph.node, tipNode);
+    assert.ok(graph.changesets.length >= 2);
+
+    const changeset = await assertJson(`/api/changeset/${tipNode}`);
+    assert.equal(changeset.node, tipNode);
+    assert.equal(changeset.desc, 'Update fixture');
+    assert.ok(changeset.files.some(entry => entry.file === 'new-file.txt' && entry.status === 'added'));
+    assert.ok(changeset.diff.length > 0);
+
+    for (const pathname of [
+      '/api/repo/list?path=..%2Fetc',
+      '/api/repo/file?path=..%2FREADME.md',
+      '/api/graph/not-a-node?style=json',
+      '/api/changeset/not-a-node',
+    ]) {
+      const response = await fetch(`${BASE_URL}${pathname}`);
+      assert.equal(response.status, 400, `${pathname} rejects invalid input`);
+    }
+    assert.equal((await fetch(`${BASE_URL}/missing-route`)).status, 404);
+
+    const identify = run(
+      'hg',
+      ['identify', `${BASE_URL}/repo`],
+      { env: mercurialEnvironment(fixtureRoot) },
+    );
+    assert.match(identify, new RegExp(`^${tipNode.slice(0, 12)}`));
+
+    browser = await chromium.launch({
+      executablePath: chromiumPath,
+      headless: true,
+      args: ['--no-sandbox'],
+    });
+
+    await assertPageHasNoBrowserErrors(browser, '/', async page => {
+      await page.getByRole('heading', { name: 'Zenbu Repository' }).waitFor();
+      await page.getByText('Recent Commits').waitFor();
+      await page.getByText('Repository Files').waitFor();
+      assert.equal(await page.evaluate(() => window.__hgWebXss), undefined);
+      assert.equal(
+        await page.locator('.graph-container').evaluate(
+          element => getComputedStyle(element).backgroundImage,
+        ),
+        'none',
+      );
+      await page.locator('.theme-toggle').click();
+    });
+
+    await assertPageHasNoBrowserErrors(browser, '/directory', async page => {
+      await page.getByText('Repository Files').waitFor();
+      await page.getByRole('link', { name: 'README.md' }).first().click();
+      await page.getByText('Updated Fixture Repository').waitFor();
+      assert.equal(await page.evaluate(() => window.__hgWebXss), undefined);
+      await page.keyboard.press('Escape');
+
+      let delayedReadmeRequested = false;
+      let markReadmeRequested;
+      const readmeRequested = new Promise(resolve => {
+        markReadmeRequested = resolve;
+      });
+      await page.route(/\/api\/repo\/readme\?path=/, async route => {
+        delayedReadmeRequested = true;
+        markReadmeRequested();
+        await new Promise(resolve => setTimeout(resolve, 500));
+        await route.continue();
+      });
+      await page.getByRole('link', { name: 'docs' }).click();
+      await Promise.race([
+        readmeRequested,
+        new Promise((_, reject) => {
+          setTimeout(() => reject(new Error('Timed out waiting for delayed README request')), 5000);
+        }),
+      ]);
+      await page.getByRole('link', { name: 'root' }).click();
+      await page.getByText('Updated Fixture Repository').waitFor();
+      await page.waitForTimeout(600);
+      assert.equal(await page.getByText('Documentation').count(), 0);
+      assert.equal(delayedReadmeRequested, true);
+
+      await page.getByRole('link', { name: 'pixel.png' }).click();
+      const image = page.locator('.static-file-image');
+      await image.waitFor();
+      await image.evaluate(element => {
+        const imageElement = element;
+        if (imageElement.complete) return;
+        return new Promise((resolve, reject) => {
+          imageElement.addEventListener('load', resolve, { once: true });
+          imageElement.addEventListener('error', reject, { once: true });
+        });
+      });
+      assert.equal(await image.evaluate(element => element.naturalWidth), 1);
+      await page.getByRole('dialog', { name: 'Preview pixel.png' }).waitFor();
+      await page.keyboard.press('Escape');
+
+      await page.getByRole('link', { name: 'BUILD' }).click();
+      const buildCode = page.locator('code.language-python');
+      await buildCode.waitFor();
+      await page.getByText('cc_library').waitFor();
+      assert.match(await buildCode.textContent(), /name = "fixture"/);
+      await page.keyboard.press('Escape');
+
+      await page.getByRole('link', { name: 'src' }).click();
+      await page.getByRole('link', { name: 'main.c' }).click();
+      await page.getByText('int main(void)').waitFor();
+      await page.keyboard.press('Escape');
+    });
+
+    await assertPageHasNoBrowserErrors(browser, '/directory?path=docs', async page => {
+      await page.getByText('Documentation').waitFor();
+      await page.getByRole('link', { name: 'README.md' }).click();
+      await page.getByText('Nested README.').waitFor();
+    });
+
+    await assertPageHasNoBrowserErrors(browser, '/graph', async page => {
+      await page.getByText('Commit Graph').waitFor();
+      await page.waitForFunction(() => new URL(window.location.href).searchParams.has('tip'));
+      const graphUrl = page.url();
+      const rows = page.locator('.graph-row');
+      await rows.first().click();
+      await page.waitForURL(/\/changeset\/[0-9a-f]+$/);
+      await page.getByRole('heading', { name: 'Update fixture' }).waitFor();
+      await page.getByRole('button', { name: 'Back', exact: true }).click();
+      await page.waitForFunction(expected => window.location.href === expected, graphUrl);
+      await page.getByText('Commit Graph').waitFor();
+    });
+
+    await assertPageHasNoBrowserErrors(browser, `/changeset/${tipNode}`, async page => {
+      await page.getByRole('heading', { name: 'Update fixture' }).waitFor();
+      await page.locator('.changeset-files code').filter({ hasText: 'new-file.txt' }).waitFor();
+      await page.getByText('added', { exact: true }).waitFor();
+      await page.getByRole('region', { name: 'Changeset diff' }).waitFor();
+      await page.locator('.diff-column-headings').getByText('Before').first().waitFor();
+      await page.locator('.diff-column-headings').getByText('After').first().waitFor();
+      await page.locator('.diff-left.diff-remove').filter({ hasText: '# Fixture Repository' }).waitFor();
+      await page.locator('.diff-right.diff-add').filter({ hasText: '# Updated Fixture Repository' }).waitFor();
+      assert.equal(
+        await page.locator('.diff-left.diff-context').filter({ hasText: '<script>' }).first().textContent(),
+        '<script>window.__hgWebXss = true</script>',
+      );
+      assert.equal(
+        await page.locator('.changeset-paper').evaluate(
+          element => getComputedStyle(element).backgroundImage,
+        ),
+        'none',
+      );
+      await page.getByRole('button', { name: firstNode.slice(0, 12) }).click();
+      await page.waitForFunction(
+        expectedPath => window.location.pathname === expectedPath,
+        `/changeset/${firstNode}`,
+      );
+      await page.getByRole('heading', { name: 'Initial fixture' }).waitFor();
+      await page.getByRole('button', { name: 'Back', exact: true }).click();
+      await page.getByRole('heading', { name: 'Update fixture' }).waitFor();
+      await page.getByRole('button', { name: 'Back', exact: true }).click();
+      await page.getByText('Commit Graph').waitFor();
+    });
+
+    await assertPageHasNoBrowserErrors(browser, '/changeset/not-a-node', async page => {
+      await page.getByText('Recent Commits').waitFor();
+    });
+
+    await stopProcess(hgServer);
+    const unavailable = await fetch(`${BASE_URL}/api/repo/list`);
+    assert.equal(unavailable.status, 502);
+    assert.equal((await fetch(`${BASE_URL}/`)).status, 200);
+  } finally {
+    if (browser) await browser.close();
+    await stopProcess(appServer);
+    await stopProcess(hgServer);
+    fs.rmSync(fixtureRoot, { recursive: true, force: true });
+  }
+}
+
+main().catch(error => {
+  console.error(error.stack || error);
+  process.exitCode = 1;
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/e2e/package-lock.json	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,24 @@
+{
+  "name": "e2e",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "dependencies": {
+        "playwright-core": "1.48.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.48.2",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.48.2.tgz",
+      "integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    }
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/e2e/package.json	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,6 @@
+{
+  "private": true,
+  "dependencies": {
+    "playwright-core": "1.48.2"
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/e2e/pnpm-lock.yaml	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,18 @@
+lockfileVersion: '6.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+dependencies:
+  playwright-core:
+    specifier: 1.48.2
+    version: 1.48.2
+
+packages:
+
+  /[email protected]:
+    resolution: {integrity: sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==}
+    engines: {node: '>=18'}
+    hasBin: true
+    dev: false
--- a/hg-web/main.c	Sun Aug 02 08:52:13 2026 -0700
+++ b/hg-web/main.c	Sun Aug 02 14:42:01 2026 -0700
@@ -1,450 +1,780 @@
 #include "seobeo/seobeo.h"
 #include "dowa/dowa.h"
+
+#include <ctype.h>
+#include <errno.h>
 #include <stdio.h>
 #include <stdlib.h>
 #include <string.h>
-#include <ctype.h>
+#include <strings.h>
+#include <time.h>
 #include <unistd.h>
-#include <sys/socket.h>
-#include <netinet/in.h>
-#include <arpa/inet.h>
-#include <netdb.h>
 
 #define HG_SERVE_HOST "127.0.0.1"
 #define HG_SERVE_PORT "4444"
-
-#define MAX_PATH 4096
+#define HG_API_TIMEOUT_MS 15000
+#define HG_STREAM_IDLE_TIMEOUT_MS 60000
+#define MAX_PATH_LENGTH 4096
+#define MAX_WIRE_QUERY_LENGTH 8192
+#define MAX_WIRE_HEADER_LENGTH 8192
 
-static char* sanitize_path(const char *input_path, Dowa_Arena *arena)
+static const char *map_value_case_insensitive(Seobeo_Request_Entry *map, const char *key)
 {
-  if (!input_path || strlen(input_path) == 0)
+  if (!map || !key)
+    return NULL;
+
+  for (size_t i = 0; i < Dowa_Array_Length(map); i++)
   {
-    char *empty = Dowa_Arena_Allocate(arena, 1);
-    empty[0] = '\0';
-    return empty;
+    if (map[i].key && strcasecmp(map[i].key, key) == 0)
+      return map[i].value;
   }
+  return NULL;
+}
+
+static char *arena_string(Dowa_Arena *arena, const char *value)
+{
+  size_t length = strlen(value);
+  char *copy = Dowa_Arena_Allocate(arena, length + 1);
+  memcpy(copy, value, length + 1);
+  return copy;
+}
 
-  size_t len = strlen(input_path);
-  char *result = Dowa_Arena_Allocate(arena, len + 1);
-  size_t j = 0;
+static Seobeo_Request_Entry *text_response(
+    Dowa_Arena *arena,
+    const char *status,
+    const char *content_type,
+    const char *body)
+{
+  Seobeo_Request_Entry *response = NULL;
+  Dowa_HashMap_Push_Arena(response, "status", arena_string(arena, status), arena);
+  Dowa_HashMap_Push_Arena(
+      response, "content-type", arena_string(arena, content_type), arena);
+  Dowa_HashMap_Push_Arena(response, "body", arena_string(arena, body), arena);
+  return response;
+}
 
-  for (size_t i = 0; i < len; i++)
+static boolean decode_url_component(
+    const char *encoded,
+    Dowa_Arena *arena,
+    char **decoded_out,
+    size_t *decoded_length_out)
+{
+  if (!encoded || !decoded_out)
+    return FALSE;
+
+  size_t encoded_length = strlen(encoded);
+  if (encoded_length >= MAX_PATH_LENGTH)
+    return FALSE;
+
+  char *decoded = Dowa_Arena_Allocate(arena, encoded_length + 1);
+  size_t output_length = 0;
+  for (size_t i = 0; i < encoded_length; i++)
   {
-    if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/'))
+    unsigned char value = (unsigned char)encoded[i];
+    if (encoded[i] == '%')
     {
-      if (i + 1 < len && input_path[i+1] == '.')
-      {
-        // Skip ".."
-        i++;
-        continue;
-      }
-      // Skip "."
-      continue;
+      if (i + 2 >= encoded_length ||
+          !isxdigit((unsigned char)encoded[i + 1]) ||
+          !isxdigit((unsigned char)encoded[i + 2]))
+        return FALSE;
+
+      char hex[3] = {encoded[i + 1], encoded[i + 2], '\0'};
+      value = (unsigned char)strtoul(hex, NULL, 16);
+      i += 2;
+      if (value == '\0')
+        return FALSE;
     }
-    result[j++] = input_path[i];
+    decoded[output_length++] = (char)value;
   }
-  result[j] = '\0';
+  decoded[output_length] = '\0';
 
-  // Remove leading/trailing slashes
-  while (result[0] == '/')
-    memmove(result, result + 1, strlen(result));
-  while (j > 0 && result[j-1] == '/')
-    result[--j] = '\0';
-
-  return result;
+  *decoded_out = decoded;
+  if (decoded_length_out)
+    *decoded_length_out = output_length;
+  return TRUE;
 }
 
-Seobeo_Client_Response  *hg_proxy_request(
-  const char *method,
-  const char *path,
-  const char *req_body,
-  const char *hg_custom)
+static boolean normalize_repository_path(
+    const char *encoded_path,
+    Dowa_Arena *arena,
+    char **normalized_out)
 {
-  char full_path[MAX_PATH];
-  snprintf(full_path, MAX_PATH, "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path);
-  Seobeo_Log(SEOBEO_DEBUG, "HG Proxy PATH %s\n", full_path);
-  Seobeo_Client_Request *p_req = Seobeo_Client_Request_Create(full_path);
-  Seobeo_Client_Request_Set_Method(p_req, method);
-  Seobeo_Client_Request_Add_Header_Array(p_req, "User-Agent: Seobeo/1.0");
-  Seobeo_Client_Request_Add_Header_Array(p_req, "Accept: application/json");
+  char *decoded = NULL;
+  size_t decoded_length = 0;
+  if (!decode_url_component(encoded_path ? encoded_path : "", arena, &decoded, &decoded_length))
+    return FALSE;
 
-  if (hg_custom && hg_custom[0] != '\0')
+  size_t start = 0;
+  size_t end = decoded_length;
+  if (start < end && decoded[start] == '/')
   {
-    char buffer[1024];
-    snprintf(buffer, 1024, "x-hgarg-1: %s", hg_custom);
-    Seobeo_Client_Request_Add_Header_Array(p_req, buffer);
-    Seobeo_Log(SEOBEO_DEBUG, "HG CUSTOM %s\n", buffer);
+    start++;
+    if (start < end && decoded[start] == '/')
+      return FALSE;
+  }
+  if (end > start && decoded[end - 1] == '/')
+  {
+    if (end - 1 > start && decoded[end - 2] == '/')
+      return FALSE;
+    end--;
   }
 
-  if (req_body)
-    Seobeo_Client_Request_Set_Body(p_req, req_body, strlen(req_body));
-  Seobeo_Client_Response *p_resp = Seobeo_Client_Request_Execute(p_req);
-  Seobeo_Client_Request_Destroy(p_req);
-  return p_resp;
+  size_t segment_start = start;
+  for (size_t i = start; i <= end; i++)
+  {
+    boolean at_end = i == end;
+    unsigned char c = at_end ? '/' : (unsigned char)decoded[i];
+    if (!at_end && (iscntrl(c) || c == '\\' || c == '?' || c == '#'))
+      return FALSE;
+
+    if (c == '/')
+    {
+      size_t segment_length = i - segment_start;
+      if (segment_length == 0 && !at_end)
+        return FALSE;
+      if ((segment_length == 1 && decoded[segment_start] == '.') ||
+          (segment_length == 2 && decoded[segment_start] == '.' &&
+           decoded[segment_start + 1] == '.'))
+        return FALSE;
+      segment_start = i + 1;
+    }
+  }
+
+  size_t normalized_length = end - start;
+  char *normalized = Dowa_Arena_Allocate(arena, normalized_length + 1);
+  memcpy(normalized, decoded + start, normalized_length);
+  normalized[normalized_length] = '\0';
+  *normalized_out = normalized;
+  return TRUE;
+}
+
+static boolean validate_revision(const char *revision)
+{
+  if (!revision || revision[0] == '\0')
+    return FALSE;
+  if (strcmp(revision, "tip") == 0)
+    return TRUE;
+
+  size_t length = strlen(revision);
+  if (length > 40)
+    return FALSE;
+  for (size_t i = 0; i < length; i++)
+  {
+    if (!isxdigit((unsigned char)revision[i]))
+      return FALSE;
+  }
+  return TRUE;
 }
 
-Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+static char *encode_repository_path(const char *path, Dowa_Arena *arena)
 {
-  Seobeo_Request_Entry *resp = NULL;
-
-  void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path");
-  const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";
-
-  char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
-  Seobeo_Url_Decode(decoded_path, rel_path);
+  static const char hex[] = "0123456789ABCDEF";
+  size_t length = strlen(path);
+  char *encoded = Dowa_Arena_Allocate(arena, length * 3 + 1);
+  size_t output = 0;
+  for (size_t i = 0; i < length; i++)
+  {
+    unsigned char c = (unsigned char)path[i];
+    if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~' || c == '/')
+      encoded[output++] = (char)c;
+    else
+    {
+      encoded[output++] = '%';
+      encoded[output++] = hex[c >> 4];
+      encoded[output++] = hex[c & 0x0F];
+    }
+  }
+  encoded[output] = '\0';
+  return encoded;
+}
 
-  char *safe_path = sanitize_path(decoded_path, arena);
-
-  Seobeo_Log(SEOBEO_INFO, "ApiListDirectory: safe_path='%s'\n", safe_path);
+static boolean safe_header_value(const char *value, size_t maximum_length)
+{
+  if (!value)
+    return TRUE;
+  size_t length = strlen(value);
+  return length <= maximum_length &&
+         strchr(value, '\r') == NULL &&
+         strchr(value, '\n') == NULL;
+}
 
-  char hg_path[MAX_PATH];
-  if (strlen(safe_path) > 0)
-    snprintf(hg_path, sizeof(hg_path), "/file/tip/%s?style=json", safe_path);
-  else
-    snprintf(hg_path, sizeof(hg_path), "/file/tip/?style=json");
+static boolean extension_is(const char *extension, const char *expected)
+{
+  return extension && strcasecmp(extension, expected) == 0;
+}
+
+static const char *repository_file_content_type(
+    const char *path,
+    boolean *inline_preview,
+    boolean *sandbox_content)
+{
+  const char *extension = strrchr(path, '.');
+  *inline_preview = TRUE;
+  *sandbox_content = FALSE;
 
-  Seobeo_Client_Response  *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL);
-
-  Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length);
-
-  if (hg_response->status_code != 200)
+  if (extension_is(extension, ".png")) return "image/png";
+  if (extension_is(extension, ".jpg") ||
+      extension_is(extension, ".jpeg")) return "image/jpeg";
+  if (extension_is(extension, ".gif")) return "image/gif";
+  if (extension_is(extension, ".webp")) return "image/webp";
+  if (extension_is(extension, ".avif")) return "image/avif";
+  if (extension_is(extension, ".bmp")) return "image/bmp";
+  if (extension_is(extension, ".ico")) return "image/x-icon";
+  if (extension_is(extension, ".svg"))
   {
-    Seobeo_Log(SEOBEO_DEBUG, "Failed to get directory from hg serve\n");
-    Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to connect to hg serve\"}", arena);
-    return resp;
+    *sandbox_content = TRUE;
+    return "image/svg+xml";
   }
+  if (extension_is(extension, ".mp4") ||
+      extension_is(extension, ".m4v")) return "video/mp4";
+  if (extension_is(extension, ".webm")) return "video/webm";
+  if (extension_is(extension, ".mov")) return "video/quicktime";
+  if (extension_is(extension, ".ogv")) return "video/ogg";
+  if (extension_is(extension, ".mp3")) return "audio/mpeg";
+  if (extension_is(extension, ".wav")) return "audio/wav";
+  if (extension_is(extension, ".ogg") ||
+      extension_is(extension, ".oga")) return "audio/ogg";
+  if (extension_is(extension, ".flac")) return "audio/flac";
+  if (extension_is(extension, ".m4a")) return "audio/mp4";
+  if (extension_is(extension, ".aac")) return "audio/aac";
+  if (extension_is(extension, ".pdf")) return "application/pdf";
+  if (extension_is(extension, ".wasm")) return "application/wasm";
 
-  char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
-  char *temp2 = Dowa_Arena_Allocate(arena, 256);
-  snprintf(temp2, 256, "%zu", hg_response->body_length);
-
-  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", "application/json", arena);
-  Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
-  Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
-  return resp;
+  *inline_preview = FALSE;
+  if (extension_is(extension, ".md") ||
+      extension_is(extension, ".markdown")) return "text/markdown; charset=utf-8";
+  if (extension_is(extension, ".txt") ||
+      extension_is(extension, ".log") ||
+      extension_is(extension, ".c") ||
+      extension_is(extension, ".h") ||
+      extension_is(extension, ".cc") ||
+      extension_is(extension, ".cpp") ||
+      extension_is(extension, ".js") ||
+      extension_is(extension, ".jsx") ||
+      extension_is(extension, ".ts") ||
+      extension_is(extension, ".tsx") ||
+      extension_is(extension, ".css") ||
+      extension_is(extension, ".html") ||
+      extension_is(extension, ".htm") ||
+      extension_is(extension, ".xml") ||
+      extension_is(extension, ".json") ||
+      extension_is(extension, ".yaml") ||
+      extension_is(extension, ".yml") ||
+      extension_is(extension, ".toml") ||
+      extension_is(extension, ".sh") ||
+      extension_is(extension, ".py") ||
+      extension_is(extension, ".rs") ||
+      extension_is(extension, ".go"))
+    return "text/plain; charset=utf-8";
+  return "application/octet-stream";
 }
 
-Seobeo_Request_Entry* ApiGetGraph(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+static Seobeo_Client_Response *hg_proxy_request(
+    const char *method,
+    const char *path,
+    const char *request_body,
+    size_t request_body_length,
+    const char *hg_argument,
+    const char *accept)
 {
-  Seobeo_Request_Entry *resp = NULL;
+  char url[MAX_PATH_LENGTH];
+  int url_length = snprintf(
+      url, sizeof(url), "http://%s:%s%s", HG_SERVE_HOST, HG_SERVE_PORT, path);
+  if (url_length < 0 || (size_t)url_length >= sizeof(url))
+    return NULL;
 
-  void *path_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
-  const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";
-  Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: rel_path='%s'\n", rel_path);
-  void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id");
-  char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value;
-  Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: graph_id='%s'\n", graph_id);
-  char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
-  Seobeo_Url_Decode(decoded_path, rel_path);
-  char *safe_path = sanitize_path(decoded_path, arena);
+  Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url);
+  if (!request)
+    return NULL;
 
-  Seobeo_Log(SEOBEO_INFO, "ApiGetGraph: safe_path='%s'\n", safe_path);
+  Seobeo_Client_Request_Set_Method(request, method);
+  Seobeo_Client_Request_Add_Header_Map(request, "User-Agent", "Seobeo/1.0");
+  Seobeo_Client_Request_Add_Header_Map(
+      request, "Accept", accept ? accept : "application/json");
+  Seobeo_Client_Request_Set_Timeout_Milliseconds(request, HG_API_TIMEOUT_MS);
 
-  if (strlen(safe_path) == 0)
+  if (hg_argument && hg_argument[0] != '\0')
   {
-    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena);
-    return resp;
+    if (!safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH))
+    {
+      Seobeo_Client_Request_Destroy(request);
+      return NULL;
+    }
+    Seobeo_Client_Request_Add_Header_Map(request, "x-hgarg-1", hg_argument);
   }
 
-  char hg_path[MAX_PATH];
-  // void *graph_id_kv = Dowa_HashMap_Get_Ptr(req, ":graph_id");
-  // char *graph_id = ((Seobeo_Request_Entry*)graph_id_kv)->value;
-  snprintf(hg_path, sizeof(hg_path), "/graph/%s?%s", graph_id, safe_path);
-  Seobeo_Client_Response  *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL);
+  if (request_body && request_body_length > 0)
+    Seobeo_Client_Request_Set_Body(request, request_body, request_body_length);
+
+  Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request);
+  Seobeo_Client_Request_Destroy(request);
+  return response;
+}
 
-  Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length);
+static Seobeo_Request_Entry *forward_hg_response(
+    Seobeo_Client_Response *hg_response,
+    const char *default_content_type,
+    const char *override_content_type,
+    Dowa_Arena *arena)
+{
+  if (!hg_response)
+    return text_response(
+        arena, "502", "application/json", "{\"error\":\"Mercurial backend unavailable\"}");
 
-  char status[4];
-  snprintf(status, 4, "%i", hg_response->status_code);
+  const char *upstream_content_type =
+      map_value_case_insensitive(hg_response->headers, "Content-Type");
+  const char *upstream_or_default_content_type =
+      override_content_type
+          ? override_content_type
+          : upstream_content_type ? upstream_content_type : default_content_type;
+  if (!upstream_or_default_content_type)
+    upstream_or_default_content_type = "application/octet-stream";
+  char *content_type = arena_string(arena, upstream_or_default_content_type);
+
+  char *status = Dowa_Arena_Allocate(arena, 8);
+  snprintf(status, 8, "%d", hg_response->status_code);
 
-  if (!hg_response->body)
-  {
-    Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena);
-    return resp;
-  }
+  size_t body_length = hg_response->body ? hg_response->body_length : 0;
+  char *body = Dowa_Arena_Allocate(arena, body_length + 1);
+  if (body_length > 0)
+    memcpy(body, hg_response->body, body_length);
+  body[body_length] = '\0';
+  char *content_length = Dowa_Arena_Allocate(arena, 32);
+  snprintf(content_length, 32, "%zu", body_length);
+
+  Seobeo_Request_Entry *response = NULL;
+  Dowa_HashMap_Push_Arena(response, "status", status, arena);
+  Dowa_HashMap_Push_Arena(response, "content-type", content_type, arena);
+  Dowa_HashMap_Push_Arena(response, "body", body, arena);
+  Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena);
+  Seobeo_Client_Response_Destroy(hg_response);
+  return response;
+}
 
-  if (hg_response->status_code != 200)
-  {
-    Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: error hg_response: %s\n", hg_response->body);
-    Dowa_HashMap_Push_Arena(resp, "status", status, arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena);
-    return resp;
-  }
+Seobeo_Request_Entry *ApiListDirectory(Seobeo_Request_Entry *request, Dowa_Arena *arena)
+{
+  const char *encoded_path = map_value_case_insensitive(request, "query_path");
+  char *path = NULL;
+  if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Invalid repository path\"}");
 
+  char *encoded = encode_repository_path(path, arena);
+  char hg_path[MAX_PATH_LENGTH];
+  int length = snprintf(
+      hg_path,
+      sizeof(hg_path),
+      encoded[0] ? "/file/tip/%s?style=json" : "/file/tip/?style=json",
+      encoded);
+  if (length < 0 || (size_t)length >= sizeof(hg_path))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Repository path is too long\"}");
+
+  return forward_hg_response(
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
+      "application/json",
+      NULL,
+      arena);
+}
 
-  char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
-  char *temp2 = Dowa_Arena_Allocate(arena, 256);
-  snprintf(temp2, 256, "%zu", hg_response->body_length);
+Seobeo_Request_Entry *ApiGetFile(Seobeo_Request_Entry *request, Dowa_Arena *arena)
+{
+  const char *encoded_path = map_value_case_insensitive(request, "query_path");
+  char *path = NULL;
+  if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &path) ||
+      path[0] == '\0')
+    return text_response(arena, "400", "text/plain", "A valid file path is required");
+
+  char *encoded = encode_repository_path(path, arena);
+  char hg_path[MAX_PATH_LENGTH];
+  int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded);
+  if (length < 0 || (size_t)length >= sizeof(hg_path))
+    return text_response(arena, "400", "text/plain", "File path is too long");
 
-  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-  Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
-  Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
+  Seobeo_Client_Response *hg_response =
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/octet-stream");
+  if (!hg_response)
+    return forward_hg_response(NULL, "application/json", NULL, arena);
 
-  return resp;
+  boolean inline_preview = FALSE;
+  boolean sandbox_content = FALSE;
+  const char *content_type =
+      repository_file_content_type(path, &inline_preview, &sandbox_content);
+  Seobeo_Request_Entry *response = forward_hg_response(
+      hg_response, "application/octet-stream", content_type, arena);
+  Dowa_HashMap_Push_Arena(
+      response,
+      "Content-Disposition",
+      inline_preview ? "inline" : "attachment",
+      arena);
+  Dowa_HashMap_Push_Arena(
+      response, "X-Content-Type-Options", "nosniff", arena);
+  if (sandbox_content)
+    Dowa_HashMap_Push_Arena(
+        response, "Content-Security-Policy", "sandbox", arena);
+  return response;
 }
 
-Seobeo_Request_Entry* ApiGetFile(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+Seobeo_Request_Entry *ApiGetReadme(Seobeo_Request_Entry *request, Dowa_Arena *arena)
 {
-  Seobeo_Request_Entry *resp = NULL;
+  const char *encoded_path = map_value_case_insensitive(request, "query_path");
+  char *directory = NULL;
+  if (!normalize_repository_path(encoded_path ? encoded_path : "", arena, &directory))
+    return text_response(arena, "400", "text/plain", "Invalid repository path");
+
+  size_t readme_length = strlen(directory) + strlen("/README.md") + 1;
+  if (readme_length >= MAX_PATH_LENGTH)
+    return text_response(arena, "400", "text/plain", "README path is too long");
 
-  void *path_kv = Dowa_HashMap_Get_Ptr(req, "query_path");
-  const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";
-  char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
-  Seobeo_Url_Decode(decoded_path, rel_path);
-  char *safe_path = sanitize_path(decoded_path, arena);
+  char *readme_path = Dowa_Arena_Allocate(arena, readme_length);
+  snprintf(
+      readme_path,
+      readme_length,
+      directory[0] ? "%s/README.md" : "README.md",
+      directory);
+  char *encoded = encode_repository_path(readme_path, arena);
 
-  Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path);
+  char hg_path[MAX_PATH_LENGTH];
+  int length = snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", encoded);
+  if (length < 0 || (size_t)length >= sizeof(hg_path))
+    return text_response(arena, "400", "text/plain", "README path is too long");
 
-  if (strlen(safe_path) == 0)
+  Seobeo_Client_Response *hg_response =
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "text/markdown");
+  if (hg_response && hg_response->status_code == HTTP_NOT_FOUND)
   {
-    Dowa_HashMap_Push_Arena(resp, "status", "400", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", "File path required", arena);
-    return resp;
+    Seobeo_Client_Response_Destroy(hg_response);
+    return text_response(arena, "204", "text/markdown", "");
   }
-
-  char hg_path[MAX_PATH];
-  snprintf(hg_path, sizeof(hg_path), "/raw-file/tip/%s", safe_path);
-  Seobeo_Client_Response  *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL);
+  return forward_hg_response(
+      hg_response, "text/markdown", "text/markdown; charset=utf-8", arena);
+}
 
-  Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length);
-
-  char status[4];
-  snprintf(status, 4, "%i", hg_response->status_code);
+Seobeo_Request_Entry *ApiGetGraph(Seobeo_Request_Entry *request, Dowa_Arena *arena)
+{
+  const char *graph_id = map_value_case_insensitive(request, ":graph_id");
+  if (!validate_revision(graph_id))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph revision\"}");
 
-  if (!hg_response->body)
+  const char *encoded_graph_top =
+      map_value_case_insensitive(request, "query_graphtop");
+  char *graph_top = NULL;
+  if (encoded_graph_top)
   {
-    Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", "Failed to connect to hg serve", arena);
-    return resp;
+    if (!decode_url_component(encoded_graph_top, arena, &graph_top, NULL) ||
+        !validate_revision(graph_top))
+      return text_response(arena, "400", "application/json", "{\"error\":\"Invalid graph top revision\"}");
   }
 
-  if (hg_response->status_code != 200)
-  {
-    Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error hg_response: %s\n", hg_response->body);
-    Dowa_HashMap_Push_Arena(resp, "status", status, arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-    Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena);
-    return resp;
-  }
-
+  char hg_path[MAX_PATH_LENGTH];
+  int length = graph_top
+      ? snprintf(
+            hg_path,
+            sizeof(hg_path),
+            "/graph/%s?graphtop=%s&style=json",
+            graph_id,
+            graph_top)
+      : snprintf(hg_path, sizeof(hg_path), "/graph/%s?style=json", graph_id);
+  if (length < 0 || (size_t)length >= sizeof(hg_path))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Graph request is too long\"}");
 
-  char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
-  char *temp2 = Dowa_Arena_Allocate(arena, 256);
-  snprintf(temp2, 256, "%zu", hg_response->body_length);
-
-  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-  Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
-  Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
-
-  return resp;
+  return forward_hg_response(
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
+      "application/json",
+      NULL,
+      arena);
 }
 
-Seobeo_Request_Entry* ApiGetReadme(Seobeo_Request_Entry *req, Dowa_Arena *arena) {
-  return ApiGetFile(req, arena);
+Seobeo_Request_Entry *ApiGetChangeset(Seobeo_Request_Entry *request, Dowa_Arena *arena)
+{
+  const char *changeset_id = map_value_case_insensitive(request, ":changeset_id");
+  if (!validate_revision(changeset_id))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Invalid changeset revision\"}");
+
+  char hg_path[128];
+  int length = snprintf(hg_path, sizeof(hg_path), "/json-rev/%s", changeset_id);
+  if (length < 0 || (size_t)length >= sizeof(hg_path))
+    return text_response(arena, "400", "application/json", "{\"error\":\"Changeset request is too long\"}");
+
+  return forward_hg_response(
+      hg_proxy_request("GET", hg_path, NULL, 0, NULL, "application/json"),
+      "application/json",
+      NULL,
+      arena);
+}
+
+static int64_t monotonic_milliseconds(void)
+{
+  struct timespec now;
+  clock_gettime(CLOCK_MONOTONIC, &now);
+  return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
+}
+
+static size_t find_http_header_length(const uint8 *buffer, size_t length)
+{
+  if (!buffer || length < 4)
+    return 0;
+  for (size_t i = 0; i + 3 < length; i++)
+  {
+    if (buffer[i] == '\r' && buffer[i + 1] == '\n' &&
+        buffer[i + 2] == '\r' && buffer[i + 3] == '\n')
+      return i + 4;
+  }
+  return 0;
 }
 
-// Streaming handler for hg wire protocol - pipes data directly without buffering
-void StreamHgWireProtocol(Seobeo_Handle *p_client, Seobeo_Request_Entry *req, Dowa_Arena *arena)
+static void send_proxy_error(Seobeo_Handle *client, int status, const char *message)
 {
-  void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method");
-  const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET";
-
-  void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
-  const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : "";
-
-  void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
-  const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : "";
-
-  const char *hg_custom = req[7].value;
+  const char *reason = status == 504 ? "Gateway Timeout" : "Bad Gateway";
+  char response[512];
+  int length = snprintf(
+      response,
+      sizeof(response),
+      "HTTP/1.1 %d %s\r\n"
+      "Content-Type: text/plain\r\n"
+      "Content-Length: %zu\r\n"
+      "Connection: close\r\n"
+      "\r\n"
+      "%s",
+      status,
+      reason,
+      strlen(message),
+      message);
+  if (length > 0 && (size_t)length < sizeof(response))
+  {
+    Seobeo_Handle_Queue(client, (const uint8 *)response, (uint32)length);
+    Seobeo_Handle_Flush(client);
+  }
+}
 
-  Seobeo_Log(SEOBEO_DEBUG, "HG Stream Proxy: method=%s query=%s\n", method, query_string);
+static boolean parse_content_length(const char *value, size_t *length_out)
+{
+  if (!value || !length_out || value[0] == '\0')
+    return FALSE;
+  errno = 0;
+  char *end = NULL;
+  unsigned long long parsed = strtoull(value, &end, 10);
+  if (errno != 0 || !end || *end != '\0' || parsed > SIZE_MAX)
+    return FALSE;
+  *length_out = (size_t)parsed;
+  return TRUE;
+}
 
-  // THINKING: Connect to hg serve
-  // This kinda blows, but not a good way to handle it since my client API assumes it is all stored in
-  // buffer and what not.
-  Seobeo_Handle *p_upstream = Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE);
-  if (!p_upstream || p_upstream->socket < 0)
+void StreamHgWireProtocol(
+    Seobeo_Handle *client,
+    Seobeo_Request_Entry *request,
+    Dowa_Arena *arena)
+{
+  (void)arena;
+  const char *method = map_value_case_insensitive(request, "HTTP_Method");
+  const char *query = map_value_case_insensitive(request, "QueryString");
+  const char *body = map_value_case_insensitive(request, "Body");
+  const char *content_length_value =
+      map_value_case_insensitive(request, "Content-Length");
+  const char *content_type = map_value_case_insensitive(request, "Content-Type");
+  const char *hg_argument = map_value_case_insensitive(request, "x-hgarg-1");
+
+  if (!method || !query ||
+      (strcmp(method, "GET") != 0 && strcmp(method, "POST") != 0) ||
+      !safe_header_value(query, MAX_WIRE_QUERY_LENGTH) ||
+      !safe_header_value(hg_argument, MAX_WIRE_HEADER_LENGTH) ||
+      !safe_header_value(content_type, 256))
   {
-    const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 26\r\n\r\nFailed to connect upstream";
-    Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp));
-    Seobeo_Handle_Flush(p_client);
-    if (p_upstream)
-      Seobeo_Handle_Destroy(p_upstream);
+    send_proxy_error(client, 502, "Invalid Mercurial proxy request");
     return;
   }
 
-  // Create headers
-  // we only allow x-hgarg-1 and content-length
-  char request_buf[8192];
-  int req_len = snprintf(request_buf, sizeof(request_buf),
-    "%s /?%s HTTP/1.1\r\n"
-    "Host: %s:%s\r\n"
-    "User-Agent: Seobeo/1.0\r\n"
-    "Connection: close\r\n",
-    method, query_string, HG_SERVE_HOST, HG_SERVE_PORT);
+  size_t body_length = 0;
+  if (content_length_value &&
+      !parse_content_length(content_length_value, &body_length))
+  {
+    send_proxy_error(client, 502, "Invalid Mercurial request length");
+    return;
+  }
+  if (body_length > 0 && !body)
+  {
+    send_proxy_error(client, 502, "Missing Mercurial request body");
+    return;
+  }
+  if (body_length > UINT32_MAX)
+  {
+    send_proxy_error(client, 502, "Mercurial request body is too large");
+    return;
+  }
 
-  if (hg_custom && hg_custom[0] != '\0')
-    req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "x-hgarg-1: %s\r\n", hg_custom);
+  Seobeo_Handle *upstream =
+      Seobeo_Stream_Handle_Client_Create(HG_SERVE_HOST, HG_SERVE_PORT, FALSE);
+  if (!upstream || upstream->socket < 0)
+  {
+    if (upstream)
+      Seobeo_Handle_Destroy(upstream);
+    send_proxy_error(client, 502, "Mercurial backend unavailable");
+    return;
+  }
 
-  if (req_body && req_body[0] != '\0')
-    req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "Content-Length: %zu\r\n\r\n%s", strlen(req_body), req_body);
-  else
-    req_len += snprintf(request_buf + req_len, sizeof(request_buf) - req_len, "\r\n");
+  char request_header[16384];
+  int header_length = snprintf(
+      request_header,
+      sizeof(request_header),
+      "%s /?%s HTTP/1.1\r\n"
+      "Host: %s:%s\r\n"
+      "User-Agent: Seobeo/1.0\r\n"
+      "Connection: close\r\n",
+      method,
+      query,
+      HG_SERVE_HOST,
+      HG_SERVE_PORT);
+  if (header_length < 0 || (size_t)header_length >= sizeof(request_header))
+  {
+    Seobeo_Handle_Destroy(upstream);
+    send_proxy_error(client, 502, "Mercurial request headers are too large");
+    return;
+  }
 
-  Seobeo_Handle_Queue(p_upstream, (uint8*)request_buf, req_len);
-  if (Seobeo_Handle_Flush(p_upstream) < 0)
+#define APPEND_WIRE_HEADER(...) \
+  do { \
+    int appended = snprintf( \
+        request_header + header_length, \
+        sizeof(request_header) - (size_t)header_length, \
+        __VA_ARGS__); \
+    if (appended < 0 || (size_t)appended >= sizeof(request_header) - (size_t)header_length) { \
+      Seobeo_Handle_Destroy(upstream); \
+      send_proxy_error(client, 502, "Mercurial request headers are too large"); \
+      return; \
+    } \
+    header_length += appended; \
+  } while (0)
+
+  if (hg_argument && hg_argument[0] != '\0')
+    APPEND_WIRE_HEADER("x-hgarg-1: %s\r\n", hg_argument);
+  if (content_type && content_type[0] != '\0')
+    APPEND_WIRE_HEADER("Content-Type: %s\r\n", content_type);
+  if (body_length > 0)
+    APPEND_WIRE_HEADER("Content-Length: %zu\r\n", body_length);
+  APPEND_WIRE_HEADER("\r\n");
+#undef APPEND_WIRE_HEADER
+
+  if (Seobeo_Handle_Queue(
+          upstream, (const uint8 *)request_header, (uint32)header_length) != 0 ||
+      (body_length > 0 &&
+       Seobeo_Handle_Queue(upstream, (const uint8 *)body, (uint32)body_length) != 0) ||
+      Seobeo_Handle_Flush(upstream) != 0)
   {
-    const char *err_resp = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 21\r\n\r\nUpstream write failed";
-    Seobeo_Handle_Queue(p_client, (uint8*)err_resp, strlen(err_resp));
-    Seobeo_Handle_Flush(p_client);
-    Seobeo_Handle_Destroy(p_upstream);
+    Seobeo_Handle_Destroy(upstream);
+    send_proxy_error(client, 502, "Mercurial backend write failed");
     return;
   }
 
-  // Responses 
-  while (1)
+  boolean response_started = FALSE;
+  int64_t last_progress = monotonic_milliseconds();
+  while (!response_started)
   {
-    int r = Seobeo_Handle_Read(p_upstream);
-    if (r < 0)
+    int read_result = Seobeo_Handle_Read(upstream);
+    if (read_result == -2 || read_result < 0)
     {
-      Seobeo_Handle_Destroy(p_upstream);
+      Seobeo_Handle_Destroy(upstream);
+      send_proxy_error(client, 502, "Mercurial backend closed before responding");
       return;
     }
-    if (p_upstream->read_buffer_len >= 4 &&
-        strstr((char*)p_upstream->read_buffer, "\r\n\r\n") != NULL)
+    if (read_result > 0)
+      last_progress = monotonic_milliseconds();
+
+    size_t header_size =
+        find_http_header_length(upstream->read_buffer, upstream->read_buffer_len);
+    if (header_size > 0)
+    {
+      (void)header_size;
+      if (Seobeo_Handle_Queue(
+              client, upstream->read_buffer, upstream->read_buffer_len) != 0 ||
+          Seobeo_Handle_Flush(client) != 0)
+      {
+        Seobeo_Handle_Destroy(upstream);
+        return;
+      }
+      Seobeo_Handle_Consume(upstream, upstream->read_buffer_len);
+      response_started = TRUE;
       break;
-    if (r == 0)
-      continue;
+    }
+
+    if (read_result == 0)
+    {
+      if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS)
+      {
+        Seobeo_Handle_Destroy(upstream);
+        send_proxy_error(client, 504, "Mercurial backend response timed out");
+        return;
+      }
+      usleep(1000);
+    }
   }
 
-  // TODO: Maybe make this into a separate function instead of internal function as doing this over and over again blows.
-  char *hdr_end = strstr((char*)p_upstream->read_buffer, "\r\n\r\n");
-  if (!hdr_end)
-  {
-    Seobeo_Handle_Destroy(p_upstream);
-    return;
-  }
-  size_t hdr_len = hdr_end - (char*)p_upstream->read_buffer + 4;
-  Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, hdr_len);
-  Seobeo_Handle_Flush(p_client);
-
-  // All body 
-  size_t body_in_buffer = p_upstream->read_buffer_len - hdr_len;
-  if (body_in_buffer > 0)
+  while (TRUE)
   {
-    Seobeo_Handle_Queue(p_client, p_upstream->read_buffer + hdr_len, body_in_buffer);
-    Seobeo_Handle_Flush(p_client);
-  }
-  Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len);
-  while (1)
-  {
-    int n = Seobeo_Handle_Read(p_upstream);
-    if (n > 0)
+    int read_result = Seobeo_Handle_Read(upstream);
+    if (read_result == -2)
+      break;
+    if (read_result < 0)
+      break;
+    if (read_result == 0)
     {
-      Seobeo_Handle_Queue(p_client, p_upstream->read_buffer, p_upstream->read_buffer_len);
-      Seobeo_Handle_Flush(p_client);
-      Seobeo_Handle_Consume(p_upstream, p_upstream->read_buffer_len);
+      if (monotonic_milliseconds() - last_progress >= HG_STREAM_IDLE_TIMEOUT_MS)
+      {
+        Seobeo_Log(SEOBEO_ERROR, "Mercurial response stream timed out\n");
+        break;
+      }
+      usleep(1000);
+      continue;
     }
-    else if (n == -2)
+
+    last_progress = monotonic_milliseconds();
+    if (Seobeo_Handle_Queue(
+            client, upstream->read_buffer, upstream->read_buffer_len) != 0 ||
+        Seobeo_Handle_Flush(client) != 0)
       break;
-    else if (n < 0)
-      break;
+    Seobeo_Handle_Consume(upstream, upstream->read_buffer_len);
   }
 
-  Seobeo_Handle_Destroy(p_upstream);
+  Seobeo_Handle_Destroy(upstream);
 }
 
-Seobeo_Request_Entry* ApiHgWireProtocol(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+Seobeo_Request_Entry *GetReactHome(Seobeo_Request_Entry *request, Dowa_Arena *arena)
 {
-  Seobeo_Request_Entry *resp = NULL;
-
-  void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method");
-  const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET";
-
-  void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
-  const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : "";
-
-  void *body_kv = Dowa_HashMap_Get_Ptr(req, "Body");
-  const char *req_body = body_kv ? ((Seobeo_Request_Entry*)body_kv)->value : "";
-  size_t body_len = strlen(req_body);
-
-  const char *hg_custom = req[7].value;
-  Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len);
-
-  Seobeo_Client_Response *hg_response;
+  (void)request;
+  size_t file_size = 0;
+  char *html = Seobeo_Web_LoadFile("/index.html", &file_size);
+  if (!html)
+    return text_response(arena, "500", "text/plain", "Application shell unavailable");
 
-  char hg_path[MAX_PATH];
-  snprintf(hg_path, sizeof(hg_path), "/?%s", query_string);
-
-  hg_response = hg_proxy_request(method, hg_path, req_body, hg_custom);
-
-  Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", hg_response->body_length);
-
-  Seobeo_Request_Entry *kv = Dowa_HashMap_Get_Ptr(hg_response->headers, "Content-Type");
-
-  char *status = Dowa_Arena_Allocate(arena, 5);
-  snprintf(status, 4, "%i", hg_response->status_code);
-
-  // Use binary-safe copy to handle null bytes in mercurial bundle data
-  char *temp1 = Dowa_Arena_Copy(arena, hg_response->body, hg_response->body_length);
-  char *temp2 = Dowa_Arena_Allocate(arena, 256);
-  snprintf(temp2, 256, "%zu", hg_response->body_length);
-
-  Dowa_HashMap_Push_Arena(resp, "status", status, arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", kv->value, arena);
-  Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
-  Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
-
-  return resp;
+  Seobeo_Request_Entry *response = NULL;
+  char *content_length = Dowa_Arena_Allocate(arena, 32);
+  snprintf(content_length, 32, "%zu", file_size);
+  Dowa_HashMap_Push_Arena(response, "status", "200", arena);
+  Dowa_HashMap_Push_Arena(response, "content-type", "text/html", arena);
+  Dowa_HashMap_Push_Arena(response, "body", html, arena);
+  Dowa_HashMap_Push_Arena(response, "content-length", content_length, arena);
+  return response;
 }
 
-Seobeo_Request_Entry* GetReactHome(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+int main(void)
 {
-  size_t file_size = 0;
-  char *html = Seobeo_Web_LoadFile("/index.html", &file_size);
-
-  printf("%s", html);
-  Seobeo_Request_Entry *resp = NULL; 
-  Dowa_HashMap_Push_Arena(resp, "status", "200", arena);
-  Dowa_HashMap_Push_Arena(resp, "content-type", "text/html", arena);
-  Dowa_HashMap_Push_Arena(resp, "body", html, arena);
-  return resp;
-}
-
-int main(void) {
   Seobeo_Router_Init();
 
-
   Seobeo_Router_Register("GET", "/", GetReactHome);
   Seobeo_Router_Register("GET", "/directories", GetReactHome);
+  Seobeo_Router_Register("GET", "/directory", GetReactHome);
   Seobeo_Router_Register("GET", "/graph", GetReactHome);
+  Seobeo_Router_Register("GET", "/changeset/:changeset_id", GetReactHome);
 
   Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory);
   Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile);
+  Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme);
   Seobeo_Router_Register("GET", "/api/graph/:graph_id", ApiGetGraph);
-  Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme);
+  Seobeo_Router_Register("GET", "/api/changeset/:changeset_id", ApiGetChangeset);
 
-  // Use streaming handler for hg wire protocol... 
   Seobeo_Router_Register_Stream("GET", "/repo", StreamHgWireProtocol);
   Seobeo_Router_Register_Stream("POST", "/repo", StreamHgWireProtocol);
 
   printf("Starting on Port 6970...\n");
-
-  int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1);
-
+  int result =
+      Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1);
   Seobeo_Router_Destroy();
-
   return result;
 }
--- a/hg-web/src/components/app.tsx	Sun Aug 02 08:52:13 2026 -0700
+++ b/hg-web/src/components/app.tsx	Sun Aug 02 14:42:01 2026 -0700
@@ -5,13 +5,188 @@
 import { Footer } from "hg-web/src/components/footer";
 import { ThemeProvider, useTheme } from "hg-web/src/components/theme";
 
-type Page = 'landing' | 'graph' | 'directory';
+type Page = 'landing' | 'graph' | 'directory' | 'changeset';
 
 type RouteState = {
   page: Page;
   graphCommit?: string;
   graphTip?: string;
   dirPath?: string;
+  changesetId?: string;
+  returnDepth?: number;
+}
+
+type ChangesetDetail = {
+  node: string;
+  date: [number, number];
+  desc: string;
+  branch: string;
+  bookmarks: string[];
+  tags: string[];
+  user: string;
+  parents: string[];
+  files: Array<{
+    file: string;
+    status: string;
+  }>;
+  diff: Array<{
+    blockno: number;
+    lines: Array<{ t: string; n: number; l: string }>;
+  }>;
+};
+
+type DiffLine = ChangesetDetail['diff'][number]['lines'][number];
+
+type DiffCell = {
+  lineNumber: number | null;
+  text: string;
+  kind: 'context' | 'add' | 'remove' | 'meta';
+};
+
+type SideBySideRow = {
+  left?: DiffCell;
+  right?: DiffCell;
+  range?: string;
+};
+
+function trimDiffLine(line: string): string {
+  return line.endsWith('\n') ? line.slice(0, -1) : line;
+}
+
+function contentDiffLine(line: DiffLine): string {
+  const text = trimDiffLine(line.l);
+  if ((line.t === '+' || line.t === '-' || line.t === '' || line.t === ' ') &&
+      text.startsWith(line.t || ' ')) {
+    return text.slice(1);
+  }
+  return text;
+}
+
+function buildSideBySideRows(lines: DiffLine[]): SideBySideRow[] {
+  const rows: SideBySideRow[] = [];
+  let removals: DiffCell[] = [];
+  let additions: DiffCell[] = [];
+  let oldLine: number | null = null;
+  let newLine: number | null = null;
+  let inHunk = false;
+
+  const flushChanges = () => {
+    const count = Math.max(removals.length, additions.length);
+    for (let index = 0; index < count; index++) {
+      rows.push({ left: removals[index], right: additions[index] });
+    }
+    removals = [];
+    additions = [];
+  };
+
+  for (const line of lines) {
+    if (line.t === '@') {
+      flushChanges();
+      const range = trimDiffLine(line.l);
+      const match = range.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
+      oldLine = match ? Number(match[1]) : null;
+      newLine = match ? Number(match[2]) : null;
+      inHunk = true;
+      rows.push({ range });
+      continue;
+    }
+
+    if (!inHunk && (line.t === '-' || line.t === '+')) {
+      const cell: DiffCell = {
+        lineNumber: null,
+        text: trimDiffLine(line.l),
+        kind: 'meta',
+      };
+      if (line.t === '-') removals.push(cell);
+      else additions.push(cell);
+      continue;
+    }
+
+    if (line.t === '-') {
+      removals.push({
+        lineNumber: oldLine,
+        text: contentDiffLine(line),
+        kind: 'remove',
+      });
+      if (oldLine !== null) oldLine++;
+      continue;
+    }
+
+    if (line.t === '+') {
+      additions.push({
+        lineNumber: newLine,
+        text: contentDiffLine(line),
+        kind: 'add',
+      });
+      if (newLine !== null) newLine++;
+      continue;
+    }
+
+    flushChanges();
+    const text = contentDiffLine(line);
+    rows.push({
+      left: { lineNumber: oldLine, text, kind: 'context' },
+      right: { lineNumber: newLine, text, kind: 'context' },
+    });
+    if (oldLine !== null) oldLine++;
+    if (newLine !== null) newLine++;
+  }
+
+  flushChanges();
+  return rows;
+}
+
+function diffBlockFilename(
+  block: ChangesetDetail['diff'][number],
+  fallback?: string,
+): string {
+  if (fallback) return fallback;
+  const newFileHeader = block.lines.find(
+    line => line.t === '+' && line.l.startsWith('+++ '),
+  );
+  if (!newFileHeader) return `Diff block ${block.blockno}`;
+  return trimDiffLine(newFileHeader.l).replace(/^\+\+\+ (?:b\/)?/, '').split('\t')[0];
+}
+
+function SideBySideDiff({
+  block,
+  filename,
+}: {
+  block: ChangesetDetail['diff'][number];
+  filename: string;
+}) {
+  const rows = buildSideBySideRows(block.lines);
+  return (
+    <div className="side-by-side-diff">
+      <div className="diff-file-header">{filename}</div>
+      <div className="diff-column-headings">
+        <span>Before</span>
+        <span>After</span>
+      </div>
+      <div className="diff-grid">
+        {rows.map((row, index) => (
+          row.range ? (
+            <div className="diff-range-row" key={`range-${index}`}>{row.range}</div>
+          ) : (
+            <React.Fragment key={`row-${index}`}>
+              <span className={`diff-side-number diff-${row.left?.kind || 'empty'}`}>
+                {row.left?.lineNumber ?? ''}
+              </span>
+              <code className={`diff-side-code diff-left diff-${row.left?.kind || 'empty'}`}>
+                {row.left?.text ?? ''}
+              </code>
+              <span className={`diff-side-number diff-column-divider diff-${row.right?.kind || 'empty'}`}>
+                {row.right?.lineNumber ?? ''}
+              </span>
+              <code className={`diff-side-code diff-right diff-${row.right?.kind || 'empty'}`}>
+                {row.right?.text ?? ''}
+              </code>
+            </React.Fragment>
+          )
+        ))}
+      </div>
+    </div>
+  );
 }
 
 // Icons
@@ -40,6 +215,18 @@
 function parseRoute(): RouteState {
   const params = new URLSearchParams(window.location.search);
   const pathname = window.location.pathname;
+  const changesetMatch = pathname.match(/^\/changeset\/([^/]+)$/);
+
+  if (changesetMatch) {
+    try {
+      const changesetId = decodeURIComponent(changesetMatch[1]);
+      if (/^(?:[0-9a-f]{1,40}|tip)$/i.test(changesetId)) {
+        return { page: 'changeset', changesetId };
+      }
+    } catch {
+      return { page: 'landing' };
+    }
+  }
 
   if (pathname.startsWith('/graph') || params.has('graph')) {
     return {
@@ -70,18 +257,29 @@
     case 'directory':
       if (state.dirPath) params.set('path', state.dirPath);
       return `/directory${params.toString() ? '?' + params.toString() : ''}`;
+    case 'changeset':
+      return state.changesetId ? `/changeset/${encodeURIComponent(state.changesetId)}` : '/graph';
     default:
       return '/';
   }
 }
 
+function isRouteState(value: unknown): value is RouteState {
+  if (!value || typeof value !== 'object' || !('page' in value)) return false;
+  return ['landing', 'graph', 'directory', 'changeset'].includes(
+    String((value as { page: unknown }).page),
+  );
+}
+
 // Landing Page Component
 function LandingPage({
   onNavigateToGraph,
   onNavigateToDirectory,
+  onNavigateToChangeset,
 }: {
   onNavigateToGraph: () => void;
   onNavigateToDirectory: (path?: string) => void;
+  onNavigateToChangeset: (node: string) => void;
 }) {
   const [directories, setDirectories] = useState<any[]>([]);
   const [files, setFiles] = useState<any[]>([]);
@@ -128,9 +326,7 @@
             <Graph
               data={graphData}
               maxRows={8}
-              onCommitClick={(node) => {
-                console.log('Clicked commit:', node);
-              }}
+              onCommitClick={onNavigateToChangeset}
             />
           ) : (
             <div className="empty-state">Failed to load commits</div>
@@ -186,10 +382,12 @@
   onBack,
   initialCommit,
   initialTip,
+  onOpenChangeset,
 }: {
   onBack: () => void;
   initialCommit?: string;
   initialTip?: string;
+  onOpenChangeset: (node: string) => void;
 }) {
   const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({
     initialCommit: initialCommit || null,
@@ -209,7 +407,7 @@
   return (
     <div>
       <div className="page-header">
-        <button className="back-button" onClick={onBack}>
+        <button className="back-button" onClick={onBack} aria-label="Back">
           &larr; Back
         </button>
         <span className="page-title">Commit Graph</span>
@@ -239,14 +437,127 @@
         loading={loading}
         hasMore={hasMore}
         onLoadMore={loadMore}
-        onCommitClick={(node) => {
-          console.log('Clicked commit:', node);
-        }}
+        onCommitClick={onOpenChangeset}
       />
     </div>
   );
 }
 
+function ChangesetPage({
+  changesetId,
+  onBack,
+  onOpenChangeset,
+}: {
+  changesetId: string;
+  onBack: () => void;
+  onOpenChangeset: (node: string) => void;
+}) {
+  const [changeset, setChangeset] = useState<ChangesetDetail | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    const controller = new AbortController();
+
+    setLoading(true);
+    setError(null);
+    setChangeset(null);
+
+    fetch(`/api/changeset/${encodeURIComponent(changesetId)}`, { signal: controller.signal })
+      .then(async response => {
+        if (!response.ok) {
+          const message = await response.text();
+          throw new Error(message || `Unable to load changeset (${response.status})`);
+        }
+        return response.json() as Promise<ChangesetDetail>;
+      })
+      .then(setChangeset)
+      .catch(err => {
+        if (err.name !== 'AbortError') setError(err.message);
+      })
+      .finally(() => {
+        if (!controller.signal.aborted) setLoading(false);
+      });
+
+    return () => controller.abort();
+  }, [changesetId]);
+
+  return (
+    <div>
+      <div className="page-header">
+        <button className="back-button" onClick={onBack} aria-label="Back">
+          &larr; Back
+        </button>
+        <span className="page-title">Changeset</span>
+      </div>
+
+      {loading && <div className="loading-state">Loading changeset...</div>}
+      {error && <div className="error-message">Error: {error}</div>}
+
+      {changeset && (
+        <article className="changeset-paper">
+          <div className="changeset-heading">
+            <code>{changeset.node}</code>
+            <span className="changeset-branch">{changeset.branch}</span>
+          </div>
+          <h2>{changeset.desc}</h2>
+          <div className="changeset-meta">
+            <span>{changeset.user}</span>
+            <time dateTime={new Date(changeset.date[0] * 1000).toISOString()}>
+              {new Date(changeset.date[0] * 1000).toLocaleString()}
+            </time>
+          </div>
+
+          {(changeset.bookmarks.length > 0 || changeset.tags.length > 0) && (
+            <div className="changeset-labels">
+              {changeset.bookmarks.map(bookmark => <span key={`bookmark-${bookmark}`}>{bookmark}</span>)}
+              {changeset.tags.map(tag => <span key={`tag-${tag}`}>{tag}</span>)}
+            </div>
+          )}
+
+          {changeset.parents.length > 0 && (
+            <div className="changeset-parents">
+              <strong>Parents</strong>
+              {changeset.parents.map(parent => (
+                <button type="button" key={parent} onClick={() => onOpenChangeset(parent)}>
+                  {parent.substring(0, 12)}
+                </button>
+              ))}
+            </div>
+          )}
+
+          {changeset.files.length > 0 && (
+            <div className="changeset-files">
+              <strong>Files</strong>
+              {changeset.files.map(file => (
+                <code key={file.file}>
+                  <span className={`changeset-file-status status-${file.status}`}>
+                    {file.status}
+                  </span>
+                  {file.file}
+                </code>
+              ))}
+            </div>
+          )}
+
+          <section className="changeset-diff" aria-label="Changeset diff">
+            <h3>Diff</h3>
+            {changeset.diff.length === 0 ? (
+              <div className="empty-state">No textual changes in this changeset.</div>
+            ) : changeset.diff.map((block, index) => (
+              <SideBySideDiff
+                key={block.blockno}
+                block={block}
+                filename={diffBlockFilename(block, changeset.files[index]?.file)}
+              />
+            ))}
+          </section>
+        </article>
+      )}
+    </div>
+  );
+}
+
 // Directory Page Component
 function DirectoryPage({
   onBack,
@@ -260,7 +571,7 @@
   return (
     <div>
       <div className="page-header">
-        <button className="back-button" onClick={onBack}>
+        <button className="back-button" onClick={onBack} aria-label="Back">
           &larr; Back
         </button>
         <span className="page-title">Repository Files</span>
@@ -281,8 +592,8 @@
 
   // Handle browser back/forward
   useEffect(() => {
-    const handlePopState = () => {
-      setRoute(parseRoute());
+    const handlePopState = (event: PopStateEvent) => {
+      setRoute(isRouteState(event.state) ? event.state : parseRoute());
     };
     window.addEventListener('popstate', handlePopState);
     return () => window.removeEventListener('popstate', handlePopState);
@@ -306,6 +617,21 @@
     navigate({ page: 'directory', dirPath: path || '' });
   }, [navigate]);
 
+  const navigateToChangeset = useCallback((changesetId: string) => {
+    const returnDepth = route.page === 'changeset'
+      ? (route.returnDepth ?? 0) + 1
+      : 1;
+    navigate({ page: 'changeset', changesetId, returnDepth });
+  }, [navigate, route.page, route.returnDepth]);
+
+  const navigateBackFromChangeset = useCallback(() => {
+    if (route.returnDepth !== undefined && route.returnDepth > 0) {
+      window.history.go(-route.returnDepth);
+      return;
+    }
+    navigateToGraph();
+  }, [navigateToGraph, route.returnDepth]);
+
   const handleDirectoryPathChange = useCallback((path: string) => {
     // Update URL without full navigation
     const params = new URLSearchParams();
@@ -333,7 +659,7 @@
           Home
         </button>
         <button
-          className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`}
+          className={`nav-tab ${route.page === 'graph' || route.page === 'changeset' ? 'active' : ''}`}
           onClick={() => navigateToGraph()}
         >
           <GraphIcon />
@@ -353,6 +679,7 @@
         <LandingPage
           onNavigateToGraph={() => navigateToGraph()}
           onNavigateToDirectory={navigateToDirectory}
+          onNavigateToChangeset={navigateToChangeset}
         />
       )}
 
@@ -361,6 +688,7 @@
           onBack={navigateToLanding}
           initialCommit={route.graphCommit}
           initialTip={route.graphTip}
+          onOpenChangeset={navigateToChangeset}
         />
       )}
 
@@ -372,6 +700,14 @@
         />
       )}
 
+      {route.page === 'changeset' && route.changesetId && (
+        <ChangesetPage
+          changesetId={route.changesetId}
+          onBack={navigateBackFromChangeset}
+          onOpenChangeset={navigateToChangeset}
+        />
+      )}
+
       <Footer />
     </div>
   );
--- a/hg-web/src/components/directory-browser.tsx	Sun Aug 02 08:52:13 2026 -0700
+++ b/hg-web/src/components/directory-browser.tsx	Sun Aug 02 14:42:01 2026 -0700
@@ -19,9 +19,22 @@
   'sass', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'cfg',
   'conf', 'md', 'markdown', 'txt', 'log', 'sql', 'graphql', 'vue',
   'svelte', 'astro', 'prisma', 'dockerfile', 'makefile', 'cmake',
-  'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample'
+  'gradle', 'pom', 'lock', 'gitignore', 'env', 'example', 'sample',
+  'bzl', 'bazel'
+]);
+
+const BAZEL_FILENAMES = new Set([
+  'build', 'build.bazel', 'module.bazel', 'workspace', 'workspace.bazel'
 ]);
 
+type StaticPreviewKind = 'image' | 'video' | 'audio' | 'pdf';
+
+const IMAGE_EXTENSIONS = new Set([
+  'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'ico', 'svg'
+]);
+const VIDEO_EXTENSIONS = new Set(['mp4', 'm4v', 'webm', 'mov', 'ogv']);
+const AUDIO_EXTENSIONS = new Set(['mp3', 'wav', 'ogg', 'oga', 'flac', 'm4a', 'aac']);
+
 // Prefetch cache
 const prefetchCache = new Map<string, Promise<any>>();
 
@@ -30,6 +43,7 @@
   const basename = filename.toLowerCase();
   return CODE_EXTENSIONS.has(ext) ||
          CODE_EXTENSIONS.has(basename) ||
+         BAZEL_FILENAMES.has(basename) ||
          basename === 'dockerfile' ||
          basename === 'makefile' ||
          basename.startsWith('.');
@@ -40,6 +54,15 @@
   return ext === 'md' || ext === 'markdown';
 }
 
+function getStaticPreviewKind(filename: string): StaticPreviewKind | null {
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  if (IMAGE_EXTENSIONS.has(ext)) return 'image';
+  if (VIDEO_EXTENSIONS.has(ext)) return 'video';
+  if (AUDIO_EXTENSIONS.has(ext)) return 'audio';
+  if (ext === 'pdf') return 'pdf';
+  return null;
+}
+
 function prefetchDirectory(path: string): void {
   const cacheKey = `dir:${path}`;
   if (prefetchCache.has(cacheKey)) return;
@@ -155,6 +178,10 @@
 
   const getLanguage = () => {
     const ext = filename.split('.').pop()?.toLowerCase() || '';
+    const basename = filename.toLowerCase();
+    if (BAZEL_FILENAMES.has(basename) || ext === 'bzl' || ext === 'bazel') {
+      return 'python';
+    }
     const langMap: Record<string, string> = {
       js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
       py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
@@ -276,6 +303,81 @@
   );
 }
 
+function StaticFileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const filename = filePath.split('/').pop() || filePath;
+  const previewKind = getStaticPreviewKind(filename);
+  const fileUrl = `${API_BASE}/file?path=${encodeURIComponent(filePath)}`;
+  const [failed, setFailed] = useState(false);
+
+  useEffect(() => {
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (event.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  return (
+    <div className="file-viewer-overlay" onClick={onClose}>
+      <div
+        className="file-viewer static-file-viewer"
+        role="dialog"
+        aria-modal="true"
+        aria-label={`Preview ${filename}`}
+        onClick={(event) => event.stopPropagation()}
+      >
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <span className="file-viewer-actions">
+            <a href={fileUrl} download={filename}>Download</a>
+            <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+              <img className="icon-invert" src={ICONS.close} alt="Close" />
+            </button>
+          </span>
+        </div>
+        <div className="file-viewer-content static-file-preview">
+          {failed && <div className="error-message">Unable to preview this file.</div>}
+          {!failed && previewKind === 'image' && (
+            <img
+              className="static-file-image"
+              src={fileUrl}
+              alt={filename}
+              onError={() => setFailed(true)}
+            />
+          )}
+          {!failed && previewKind === 'video' && (
+            <video
+              className="static-file-video"
+              src={fileUrl}
+              controls
+              onError={() => setFailed(true)}
+            />
+          )}
+          {!failed && previewKind === 'audio' && (
+            <audio
+              className="static-file-audio"
+              src={fileUrl}
+              controls
+              onError={() => setFailed(true)}
+            />
+          )}
+          {!failed && previewKind === 'pdf' && (
+            <iframe
+              className="static-file-pdf"
+              src={fileUrl}
+              title={filename}
+              onError={() => setFailed(true)}
+            />
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
 /**
  * Component: FileList
  */
@@ -338,7 +440,7 @@
     e.preventDefault();
     if (isDir) {
       onNavigate(item.abspath);
-    } else if (isCodeFile(item.basename)) {
+    } else if (isCodeFile(item.basename) || getStaticPreviewKind(item.basename)) {
       onOpenFile(item.abspath);
     } else {
       window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
@@ -429,6 +531,7 @@
   const [error, setError] = useState<string | null>(null);
   const [loading, setLoading] = useState(false);
   const [viewingFile, setViewingFile] = useState<string | null>(null);
+  const requestGeneration = useRef(0);
 
   // Sync with initialPath prop
   useEffect(() => {
@@ -436,8 +539,9 @@
   }, [initialPath]);
 
   useEffect(() => {
-    fetchDirectory(currentPath);
-    fetchReadme(currentPath);
+    const generation = ++requestGeneration.current;
+    fetchDirectory(currentPath, generation);
+    fetchReadme(currentPath, generation);
   }, [currentPath]);
 
   const navigate = useCallback((path: string) => {
@@ -445,7 +549,7 @@
     onPathChange?.(path);
   }, [onPathChange]);
 
-  const fetchDirectory = async (path: string) => {
+  const fetchDirectory = async (path: string, generation: number) => {
     setLoading(true);
     setError(null);
     try {
@@ -468,26 +572,30 @@
         throw new Error(data.error);
       }
 
-      setContent({
-        files: data?.files || [],
-        directories: data?.directories || []
-      });
+      if (generation === requestGeneration.current) {
+        setContent({
+          files: data?.files || [],
+          directories: data?.directories || []
+        });
+      }
     } catch (err: any) {
       console.error('Error loading directory:', err);
-      setError(err.message);
+      if (generation === requestGeneration.current) setError(err.message);
     } finally {
-      setLoading(false);
+      if (generation === requestGeneration.current) setLoading(false);
     }
   };
 
-  const fetchReadme = async (path: string) => {
-    setReadme(null);
-    const readmePath = path ? `${path}/README.md` : 'README.md';
+  const fetchReadme = async (path: string, generation: number) => {
+    if (generation === requestGeneration.current) setReadme(null);
     try {
-      const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`);
+      const url = path
+        ? `${API_BASE}/readme?path=${encodeURIComponent(path)}`
+        : `${API_BASE}/readme`;
+      const response = await fetch(url);
       if (response.ok) {
         const text = await response.text();
-        setReadme(text);
+        if (generation === requestGeneration.current) setReadme(text || null);
       }
     } catch (err) {
       // Readme is optional
@@ -528,6 +636,8 @@
       {viewingFile && (
         isMarkdownFile(viewingFile) ? (
           <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
+        ) : getStaticPreviewKind(viewingFile) ? (
+          <StaticFileViewer filePath={viewingFile} onClose={handleCloseFile} />
         ) : (
           <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
         )
--- a/hg-web/src/components/graph.tsx	Sun Aug 02 08:52:13 2026 -0700
+++ b/hg-web/src/components/graph.tsx	Sun Aug 02 14:42:01 2026 -0700
@@ -177,17 +177,11 @@
 const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => {
   const canvasRef = useRef<HTMLCanvasElement>(null);
   const containerRef = useRef<HTMLDivElement>(null);
+  const [assetError, setAssetError] = useState<string | null>(null);
 
   const changesets = useMemo(() => 
     maxRows && data?.changesets ? data.changesets.slice(0, maxRows) : data?.changesets || [], [data, maxRows]);
 
-  let pencilPattern;
-  const img = new Image();
-  img.src = "http://localhost:6970/pencil_lines.png";
-
-  const pandaImg = new Image();
-  pandaImg.src = "http://localhost:6970/panda.png";
-
   useEffect(() => {
     const canvas = canvasRef.current;
     if (!canvas || !changesets.length) return;
@@ -195,20 +189,11 @@
     const ctx = canvas.getContext('2d');
     if (!ctx) return;
 
-    // Grab colors from CSS variables or defaults
-    const getColors = () => {
-      const s = getComputedStyle(document.documentElement);
-      return [
-        s.getPropertyValue('--graph-1').trim() || '#4dabf7',
-        s.getPropertyValue('--graph-2').trim() || '#63e6be',
-        s.getPropertyValue('--graph-3').trim() || '#ffbc42',
-        s.getPropertyValue('--graph-4').trim() || '#b197fc',
-        s.getPropertyValue('--graph-5').trim() || '#ff8787',
-        s.getPropertyValue('--graph-6').trim() || '#f06595',
-      ];
-    };
-
-    const colors = getColors();
+    let cancelled = false;
+    let pencilPattern: CanvasPattern | null = null;
+    let loadedAssets = 0;
+    const pencilImage = new Image();
+    const pandaImage = new Image();
     const dpr = window.devicePixelRatio || 1;
     const maxCol = Math.max(...changesets.map(cs => cs.col), 0);
     const canvasWidth = (maxCol + 2) * colWidth;
@@ -241,13 +226,35 @@
       // Pass 2: Draw Commit Nodes
       changesets.forEach((cs, i) => {
         const x = getX(cs.col), y = getY(i);
-        ctx.drawImage(pandaImg, x-10, y-10, 20, 20);
+        ctx.drawImage(pandaImage, x-10, y-10, 20, 20);
       });
     };
 
-    img.onload = () => {
-      pencilPattern = ctx.createPattern(img, "repeat")!;
-      renderCanvas(); 
+    const handleAssetLoad = () => {
+      loadedAssets++;
+      if (loadedAssets !== 2 || cancelled) return;
+      pencilPattern = ctx.createPattern(pencilImage, "repeat");
+      renderCanvas();
+    };
+
+    const handleAssetError = () => {
+      if (!cancelled) setAssetError('Unable to load the graph artwork.');
+    };
+
+    setAssetError(null);
+    pencilImage.onload = handleAssetLoad;
+    pencilImage.onerror = handleAssetError;
+    pandaImage.onload = handleAssetLoad;
+    pandaImage.onerror = handleAssetError;
+    pencilImage.src = "/pencil_lines.png";
+    pandaImage.src = "/panda.png";
+
+    return () => {
+      cancelled = true;
+      pencilImage.onload = null;
+      pencilImage.onerror = null;
+      pandaImage.onload = null;
+      pandaImage.onerror = null;
     };
   }, [changesets]);
 
@@ -266,45 +273,38 @@
   }, [onLoadMore, hasMore, loading]);
 
   return (
-    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', backgroundImage: 'url("/hg-web-background.jpg")', fontFamily: 'monospace' }}>
+    <div className="graph-container">
+      {assetError && <div className="error-message">{assetError}</div>}
       <div 
         ref={containerRef} 
-        style={{ display: 'flex', flex: 1, overflowY: 'auto', position: 'relative' }}
+        className="graph-wrapper"
       >
-        {/* Graph Column - Sticky to keep lines aligned with text during scroll */}
-        <div style={{ position: 'sticky', top: 0, height: 'fit-content', zIndex: 10, borderRight: '1px solid #333' }}>
+        <div className="graph-canvas-column">
           <canvas ref={canvasRef} style={{ display: 'block' }} />
         </div>
 
-        {/* Details Column */}
-        <div style={{ flex: 1 }}>
+        <div className="graph-details-column">
           {changesets.map((cs) => (
-            <div 
-              key={cs.node} 
-              style={{ 
-                height: rowHeight, 
-                display: 'flex', 
-                alignItems: 'center', 
-                padding: '0 15px', 
-                borderBottom: '1px solid #252525', 
-                cursor: 'pointer',
-                fontSize: '13px',
-                whiteSpace: 'nowrap'
-              }}
+            <button
+              type="button"
+              key={cs.node}
+              className="graph-row"
               onClick={() => onCommitClick?.(cs.node)}
-              onMouseEnter={(e) => (e.currentTarget.style.background = '#222')}
-              onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
+              aria-label={`Open changeset ${cs.node.substring(0, 12)}: ${cs.desc}`}
             >
-              <span style={{ color: '#4dabf7', width: '90px', flexShrink: 0 }}>{cs.node.substring(0, 12)}</span>
-              <span style={{ color: '#eee', flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', paddingRight: '20px' }}>{cs.desc}</span>
-              <span style={{ color: '#888', width: '150px', textAlign: 'right' }}>{cs.user.split(' <')[0]}</span>
-            </div>
+              <span className="graph-row-meta">
+                <span className="graph-hash">{cs.node.substring(0, 12)}</span>
+                <span className="graph-user">{cs.user.split(' <')[0]}</span>
+                {cs.branch && <span className="graph-branch">{cs.branch}</span>}
+              </span>
+              <span className="graph-desc">{cs.desc}</span>
+            </button>
           ))}
           <div id="infinite-scroll-sentinel" style={{ height: '50px' }} />
         </div>
       </div>
       
-      {loading && <div style={{ padding: '10px', textAlign: 'center', color: '#888', fontSize: '12px', background: '#111' }}>Loading repository history...</div>}
+      {loading && <div className="graph-loading-row">Loading repository history...</div>}
     </div>
   );
 };
Binary file hg-web/src/hg-web-background.jpg has changed
--- a/hg-web/src/index.css	Sun Aug 02 08:52:13 2026 -0700
+++ b/hg-web/src/index.css	Sun Aug 02 14:42:01 2026 -0700
@@ -270,13 +270,15 @@
   align-items: flex-start;
   max-height: 600px;
   overflow-y: auto;
+  position: relative;
 }
 
 .graph-canvas-column {
   flex-shrink: 0;
-  background: var(--bg);
   position: sticky;
   left: 0;
+  z-index: 1;
+  border-right: 1px solid var(--border);
 }
 
 .graph-details-column {
@@ -285,11 +287,16 @@
 }
 
 .graph-row {
+  width: 100%;
   height: 40px;
   display: flex;
   flex-direction: column;
   justify-content: center;
   padding: 0 12px;
+  background: transparent;
+  color: inherit;
+  text-align: left;
+  border: 0;
   border-bottom: 1px solid var(--border);
   font-size: 12px;
   cursor: pointer;
@@ -300,6 +307,12 @@
   background: var(--hover);
 }
 
+.graph-row:focus-visible {
+  outline: 2px solid var(--accent);
+  outline-offset: -2px;
+  background: var(--hover);
+}
+
 .graph-row-meta {
   display: flex;
   gap: 10px;
@@ -365,6 +378,204 @@
 }
 
 /* ===========================================
+   Changeset Detail
+   =========================================== */
+.changeset-paper {
+  background: var(--bg);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 24px;
+}
+
+.changeset-heading,
+.changeset-meta,
+.changeset-parents,
+.changeset-files,
+.changeset-labels {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.changeset-heading {
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.changeset-heading code {
+  overflow-wrap: anywhere;
+}
+
+.changeset-paper h2 {
+  margin-bottom: 8px;
+}
+
+.changeset-meta {
+  color: var(--text-secondary);
+  justify-content: space-between;
+  margin-bottom: 16px;
+}
+
+.changeset-branch,
+.changeset-labels span {
+  background: var(--bg-subtle);
+  border: 1px solid var(--border);
+  border-radius: 999px;
+  padding: 2px 8px;
+  font-size: 12px;
+}
+
+.changeset-labels,
+.changeset-parents,
+.changeset-files {
+  margin: 12px 0;
+}
+
+.changeset-parents button {
+  border: 0;
+  background: transparent;
+  color: var(--accent);
+  cursor: pointer;
+  font-family: monospace;
+}
+
+.changeset-parents button:hover {
+  text-decoration: underline;
+}
+
+.changeset-parents button:focus-visible {
+  outline: 2px solid var(--accent);
+  outline-offset: 2px;
+}
+
+.changeset-files code {
+  display: inline-flex;
+  align-items: center;
+  gap: 6px;
+  overflow-wrap: anywhere;
+}
+
+.changeset-file-status {
+  color: var(--text-secondary);
+  font-size: 10px;
+  text-transform: uppercase;
+}
+
+.changeset-file-status.status-added {
+  color: var(--success);
+}
+
+.changeset-file-status.status-removed {
+  color: var(--danger);
+}
+
+.changeset-diff {
+  margin-top: 24px;
+}
+
+.changeset-diff h3 {
+  margin-bottom: 10px;
+}
+
+.side-by-side-diff {
+  margin-bottom: 18px;
+  overflow-x: auto;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  background: var(--bg-code);
+}
+
+.diff-file-header {
+  padding: 9px 12px;
+  border-bottom: 1px solid var(--border);
+  background: var(--bg-subtle);
+  font-family: monospace;
+  font-weight: 600;
+}
+
+.diff-column-headings {
+  display: grid;
+  grid-template-columns: minmax(380px, 1fr) minmax(380px, 1fr);
+  min-width: 760px;
+  border-bottom: 1px solid var(--border);
+  color: var(--text-secondary);
+  font-size: 12px;
+  font-weight: 600;
+  text-transform: uppercase;
+}
+
+.diff-column-headings span {
+  padding: 6px 12px;
+}
+
+.diff-column-headings span + span {
+  border-left: 1px solid var(--border);
+}
+
+.diff-grid {
+  display: grid;
+  grid-template-columns: 52px minmax(328px, 1fr) 52px minmax(328px, 1fr);
+  min-width: 760px;
+  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+  font-size: 12px;
+  line-height: 1.5;
+}
+
+.diff-side-number,
+.diff-side-code {
+  min-height: 22px;
+  border-bottom: 1px solid color-mix(in srgb, var(--border) 55%, transparent);
+}
+
+.diff-side-number {
+  padding: 2px 8px;
+  color: var(--text-secondary);
+  text-align: right;
+  user-select: none;
+  border-right: 1px solid var(--border);
+}
+
+.diff-side-code {
+  display: block;
+  margin: 0;
+  padding: 2px 10px;
+  border-radius: 0;
+  background: transparent;
+  white-space: pre;
+}
+
+.diff-column-divider {
+  border-left: 1px solid var(--border);
+}
+
+.diff-add {
+  background: color-mix(in srgb, var(--success) 18%, transparent);
+}
+
+.diff-remove {
+  background: color-mix(in srgb, var(--danger) 18%, transparent);
+}
+
+.diff-empty {
+  background: color-mix(in srgb, var(--bg-subtle) 70%, transparent);
+}
+
+.diff-meta {
+  color: var(--text-secondary);
+  background: var(--bg-subtle);
+}
+
+.diff-range-row {
+  grid-column: 1 / -1;
+  padding: 4px 10px;
+  color: var(--accent);
+  background: var(--bg-subtle);
+  border-bottom: 1px solid var(--border);
+  white-space: pre;
+}
+
+/* ===========================================
    Common States
    =========================================== */
 .empty-state {
@@ -603,11 +814,53 @@
   opacity: 0.7;
 }
 
+.file-viewer-actions {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.file-viewer-actions a {
+  font-size: 13px;
+}
+
 .file-viewer-content {
   overflow: auto;
   flex: 1;
 }
 
+.static-file-viewer {
+  max-width: 1100px;
+}
+
+.static-file-preview {
+  min-height: 240px;
+  padding: 20px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: var(--bg-subtle);
+}
+
+.static-file-image,
+.static-file-video {
+  display: block;
+  max-width: 100%;
+  max-height: 75vh;
+  object-fit: contain;
+}
+
+.static-file-audio {
+  width: min(100%, 640px);
+}
+
+.static-file-pdf {
+  width: 100%;
+  height: 75vh;
+  border: 0;
+  background: #fff;
+}
+
 .file-viewer-content pre {
   margin: 0;
   padding: 16px;
Binary file hg-web/src/pencil_texture.png has changed
--- a/markdown_converter/markdown_to_html.c	Sun Aug 02 08:52:13 2026 -0700
+++ b/markdown_converter/markdown_to_html.c	Sun Aug 02 14:42:01 2026 -0700
@@ -61,14 +61,6 @@
   buf->length += len;
 }
 
-static void buffer_append_n(StringBuffer *buf, const char *str, size_t n)
-{
-  buffer_grow(buf, n);
-  memcpy(buf->data + buf->length, str, n);
-  buf->length += n;
-  buf->data[buf->length] = '\0';
-}
-
 static void buffer_append_char(StringBuffer *buf, char c)
 {
   buffer_grow(buf, 1);
@@ -76,6 +68,39 @@
   buf->data[buf->length] = '\0';
 }
 
+static void buffer_append_html_escaped_n(StringBuffer *buf, const char *text, size_t len)
+{
+  for (size_t i = 0; i < len; i++) {
+    switch (text[i]) {
+      case '&': buffer_append(buf, "&amp;"); break;
+      case '<': buffer_append(buf, "&lt;"); break;
+      case '>': buffer_append(buf, "&gt;"); break;
+      case '"': buffer_append(buf, "&quot;"); break;
+      case '\'': buffer_append(buf, "&#39;"); break;
+      default: buffer_append_char(buf, text[i]); break;
+    }
+  }
+}
+
+static int is_safe_url(const char *url, size_t len, int is_image)
+{
+  if (len == 0) return 0;
+
+  for (size_t i = 0; i < len; i++) {
+    unsigned char c = (unsigned char)url[i];
+    if (iscntrl(c) || isspace(c)) return 0;
+  }
+
+  const char *colon = memchr(url, ':', len);
+  if (!colon) return 1;
+
+  size_t scheme_len = (size_t)(colon - url);
+  if (scheme_len == 4 && strncasecmp(url, "http", scheme_len) == 0) return 1;
+  if (scheme_len == 5 && strncasecmp(url, "https", scheme_len) == 0) return 1;
+  if (!is_image && scheme_len == 6 && strncasecmp(url, "mailto", scheme_len) == 0) return 1;
+  return 0;
+}
+
 static void buffer_free(StringBuffer *buf)
 {
   if (buf) {
@@ -160,28 +185,6 @@
   return 1;
 }
 
-// Check if line starts with a specific HTML tag (e.g., "script", "style")
-static int is_html_tag(const char *line, const char *tag)
-{
-  line = skip_whitespace(line);
-  if (*line != '<') return 0;
-  line++;
-
-  // Skip optional /
-  int is_closing = 0;
-  if (*line == '/') {
-    is_closing = 1;
-    line++;
-  }
-
-  size_t tag_len = strlen(tag);
-  if (strncasecmp(line, tag, tag_len) != 0) return 0;
-
-  char next = line[tag_len];
-  // Tag must be followed by space, >, or end for closing tags
-  return next == '>' || next == ' ' || next == '\t' || next == '\n' || next == '\0';
-}
-
 // Check if line is ordered list item
 static int is_ordered_list(const char *line)
 {
@@ -359,11 +362,16 @@
         while (url_end < len && text[url_end] != ')') url_end++;
 
         if (url_end < len) {
-          buffer_append(buf, "<a href=\"");
-          buffer_append_n(buf, text + url_start, url_end - url_start);
-          buffer_append(buf, "\">");
-          buffer_append_n(buf, text + link_start, link_end - link_start);
-          buffer_append(buf, "</a>");
+          size_t url_len = url_end - url_start;
+          if (is_safe_url(text + url_start, url_len, 0)) {
+            buffer_append(buf, "<a href=\"");
+            buffer_append_html_escaped_n(buf, text + url_start, url_len);
+            buffer_append(buf, "\">");
+            process_inline(buf, text + link_start, link_end - link_start);
+            buffer_append(buf, "</a>");
+          } else {
+            process_inline(buf, text + link_start, link_end - link_start);
+          }
           i = url_end + 1;
           continue;
         }
@@ -382,11 +390,16 @@
         while (url_end < len && text[url_end] != ')') url_end++;
 
         if (url_end < len) {
-          buffer_append(buf, "<img src=\"");
-          buffer_append_n(buf, text + url_start, url_end - url_start);
-          buffer_append(buf, "\" alt=\"");
-          buffer_append_n(buf, text + alt_start, alt_end - alt_start);
-          buffer_append(buf, "\">");
+          size_t url_len = url_end - url_start;
+          if (is_safe_url(text + url_start, url_len, 1)) {
+            buffer_append(buf, "<img src=\"");
+            buffer_append_html_escaped_n(buf, text + url_start, url_len);
+            buffer_append(buf, "\" alt=\"");
+            buffer_append_html_escaped_n(buf, text + alt_start, alt_end - alt_start);
+            buffer_append(buf, "\">");
+          } else {
+            buffer_append_html_escaped_n(buf, text + alt_start, alt_end - alt_start);
+          }
           i = url_end + 1;
           continue;
         }
@@ -449,25 +462,14 @@
 
       if (end < len) {
         buffer_append(buf, "<code>");
-        buffer_append_n(buf, text + start, end - start);
+        buffer_append_html_escaped_n(buf, text + start, end - start);
         buffer_append(buf, "</code>");
         i = end + 1;
         continue;
       }
     }
 
-    // This might not be needed for now.
-    // HTML escape special characters
-    // if (text[i] == '<') {
-    //   buffer_append(buf, "&lt;");
-    // } else if (text[i] == '>') {
-    //   buffer_append(buf, "&gt;");
-    // } else if (text[i] == '&') {
-    //   buffer_append(buf, "&amp;");
-    // } else {
-    //   buffer_append_char(buf, text[i]);
-    // }
-    buffer_append_char(buf, text[i]);
+    buffer_append_html_escaped_n(buf, text + i, 1);
     i++;
   }
 }
@@ -759,48 +761,11 @@
       }
     }
 
-    // HTML block - pass through unchanged
+    // Repository markdown is untrusted. Render raw HTML as text.
     if (is_html_block_start(line)) {
-      // Check if it's a script or style tag that needs special handling
-      int is_script = is_html_tag(line, "script");
-      int is_style = is_html_tag(line, "style");
-
-      if (is_script || is_style) {
-        const char *end_tag = is_script ? "</script>" : "</style>";
-
-        // Output the opening line
-        buffer_append(buf, line);
-        buffer_append_char(buf, '\n');
-
-        free(line);
-        if (*ptr == '\n') ptr++;
-
-        // Collect content until closing tag
-        while (*ptr) {
-          line_start = ptr;
-          while (*ptr && *ptr != '\n') ptr++;
-          line_len = ptr - line_start;
-
-          line = (char *)malloc(line_len + 1);
-          if (!line) break;
-          memcpy(line, line_start, line_len);
-          line[line_len] = '\0';
-
-          buffer_append(buf, line);
-          buffer_append_char(buf, '\n');
-
-          int found_end = (strstr(line, end_tag) != NULL);
-          free(line);
-          if (*ptr == '\n') ptr++;
-
-          if (found_end) break;
-        }
-        continue;
-      }
-
-      // Regular HTML tag - just pass through the line
-      buffer_append(buf, line);
-      buffer_append_char(buf, '\n');
+      buffer_append(buf, "<p>");
+      process_inline(buf, line, line_len);
+      buffer_append(buf, "</p>");
       free(line);
       if (*ptr == '\n') ptr++;
       continue;
--- a/markdown_converter/tests/BUILD	Sun Aug 02 08:52:13 2026 -0700
+++ b/markdown_converter/tests/BUILD	Sun Aug 02 14:42:01 2026 -0700
@@ -1,3 +1,11 @@
+load("@rules_cc//cc:cc_test.bzl", "cc_test")
+
+cc_test(
+    name = "markdown_to_html_test",
+    srcs = ["markdown_to_html_test.c"],
+    deps = ["//markdown_converter:markdown_to_html_c"],
+)
+
 # load("//gui_ze:gui_ze.bzl", "bun_run", "move_files_into_dir")
 # 
 # # Test for WASM module (run with: bazel test //markdown_converter:markdown_to_html_wasm_test)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/markdown_converter/tests/markdown_to_html_test.c	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,32 @@
+#include "markdown_converter/markdown_to_html.h"
+
+#include <stdio.h>
+#include <string.h>
+
+static int failures = 0;
+
+static void expect_equal(const char *name, const char *markdown, const char *expected)
+{
+  char *actual = markdown_to_html(markdown);
+  if (!actual || strcmp(actual, expected) != 0) {
+    fprintf(stderr, "%s\nexpected: %s\nactual:   %s\n", name, expected, actual ? actual : "(null)");
+    failures++;
+  }
+  markdown_free(actual);
+}
+
+int main(void)
+{
+  expect_equal("normal link", "[link](https://example.com)",
+               "<p><a href=\"https://example.com\">link</a></p>");
+  expect_equal("escape paragraph", "a & <b>", "<p>a &amp; &lt;b&gt;</p>");
+  expect_equal("escape raw script", "<script>alert('x')</script>",
+               "<p>&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;</p>");
+  expect_equal("reject script link", "[open](javascript:alert)", "<p>open</p>");
+  expect_equal("reject data image", "![preview](data:image/svg+xml,test)", "<p>preview</p>");
+  expect_equal("escape link attribute", "[link](https://example.com/\"x)",
+               "<p><a href=\"https://example.com/&quot;x\">link</a></p>");
+  expect_equal("escape inline code", "`<script>`", "<p><code>&lt;script&gt;</code></p>");
+
+  return failures == 0 ? 0 : 1;
+}
--- a/seobeo/s_http_client.c	Sun Aug 02 08:52:13 2026 -0700
+++ b/seobeo/s_http_client.c	Sun Aug 02 14:42:01 2026 -0700
@@ -1,5 +1,46 @@
 #include "seobeo/seobeo.h"
 #include <ctype.h>
+#include <time.h>
+
+static int64_t Seobeo_Client_Monotonic_Milliseconds(void)
+{
+  struct timespec now;
+  clock_gettime(CLOCK_MONOTONIC, &now);
+  return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
+}
+
+static boolean Seobeo_Client_Read_Timed_Out(int32 timeout_ms, int64_t last_progress_ms)
+{
+  return timeout_ms > 0 &&
+         Seobeo_Client_Monotonic_Milliseconds() - last_progress_ms >= timeout_ms;
+}
+
+static size_t Seobeo_Client_Find_Header_Length(const uint8 *buffer, size_t length)
+{
+  if (!buffer || length < 4)
+    return 0;
+  for (size_t i = 0; i + 3 < length; i++)
+  {
+    if (buffer[i] == '\r' && buffer[i + 1] == '\n' &&
+        buffer[i + 2] == '\r' && buffer[i + 3] == '\n')
+      return i + 4;
+  }
+  return 0;
+}
+
+static const char *Seobeo_Client_Header_Value(
+    Seobeo_Request_Entry *headers,
+    const char *name)
+{
+  if (!headers || !name)
+    return NULL;
+  for (size_t i = 0; i < Dowa_Array_Length(headers); i++)
+  {
+    if (headers[i].key && strcasecmp(headers[i].key, name) == 0)
+      return headers[i].value;
+  }
+  return NULL;
+}
 
 static void Seobeo_Client_Parse_Url(const char *url, char **p_host, 
     char **p_port, char **p_path, boolean *p_use_tls, Dowa_Arena *p_arena)
@@ -87,6 +128,7 @@
 
   p_req->follow_redirects = FALSE;
   p_req->max_redirects = 10;
+  p_req->timeout_ms = 0;
 
   return p_req;
 }
@@ -149,6 +191,13 @@
   p_req->max_redirects = max_redirects > 0 ? max_redirects : 10;
 }
 
+void Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms)
+{
+  if (!p_req)
+    return;
+  p_req->timeout_ms = timeout_ms > 0 ? timeout_ms : 0;
+}
+
 void Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path)
 {
   if (!p_req || !path)
@@ -223,7 +272,10 @@
   return offset;
 }
 
-static Seobeo_Client_Response *Seobeo_Client_Parse_Response(Seobeo_Handle *p_handle, const char *download_path)
+static Seobeo_Client_Response *Seobeo_Client_Parse_Response(
+    Seobeo_Handle *p_handle,
+    const char *download_path,
+    int32 timeout_ms)
 {
   Seobeo_Client_Response *p_resp = malloc(sizeof(Seobeo_Client_Response));
   if (!p_resp)
@@ -238,27 +290,49 @@
     return NULL;
   }
 
+  int64_t last_progress_ms = Seobeo_Client_Monotonic_Milliseconds();
   while (TRUE)
   {
     int r = Seobeo_Handle_Read(p_handle);
-    if (r < 0)
-      return p_resp;
     if (r == -2)
       break;
+    if (r < 0)
+    {
+      Seobeo_Client_Response_Destroy(p_resp);
+      return NULL;
+    }
+    if (r > 0)
+      last_progress_ms = Seobeo_Client_Monotonic_Milliseconds();
 
-    if (p_handle->read_buffer_len >= 4 && strstr((char*)p_handle->read_buffer, "\r\n\r\n") != NULL)
+    if (Seobeo_Client_Find_Header_Length(
+            p_handle->read_buffer, p_handle->read_buffer_len) > 0)
       break;
 
     if (r == 0)
+    {
+      if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms))
+      {
+        Seobeo_Log(SEOBEO_ERROR, "HTTP response header timed out\n");
+        Seobeo_Client_Response_Destroy(p_resp);
+        return NULL;
+      }
+      usleep(1000);
       continue;
+    }
   }
 
-  char *buf = (char*)p_handle->read_buffer;
-  char *hdr_end = strstr(buf, "\r\n\r\n");
-  if (!hdr_end)
-    return p_resp;
+  size_t hdr_len = Seobeo_Client_Find_Header_Length(
+      p_handle->read_buffer, p_handle->read_buffer_len);
+  if (hdr_len == 0)
+  {
+    Seobeo_Client_Response_Destroy(p_resp);
+    return NULL;
+  }
 
-  size_t hdr_len = hdr_end - buf + 4;
+  char *buf = Dowa_Arena_Allocate(p_resp->p_arena, hdr_len + 1);
+  memcpy(buf, p_handle->read_buffer, hdr_len);
+  buf[hdr_len] = '\0';
+  char *hdr_end = buf + hdr_len - 4;
 
   char version[16];
   int status_code;
@@ -328,12 +402,12 @@
 
   Seobeo_Handle_Consume(p_handle, (uint32)hdr_len);
 
-  void *p_cl_kv = Dowa_HashMap_Get_Ptr(p_resp->headers, "Content-Length");
   size_t body_len = 0;
-  if (p_cl_kv)
+  const char *content_length = Seobeo_Client_Header_Value(
+      p_resp->headers, "Content-Length");
+  if (content_length)
   {
-    const char *content_length_str = ((Seobeo_Request_Entry*)p_cl_kv)->value;
-    body_len = atoi(content_length_str);
+    body_len = (size_t)strtoull(content_length, NULL, 10);
   }
 
   FILE *p_file = NULL;
@@ -366,15 +440,32 @@
 
         total_read += to_copy;
         Seobeo_Handle_Consume(p_handle, (uint32)to_copy);
+        last_progress_ms = Seobeo_Client_Monotonic_Milliseconds();
       }
 
       if (total_read < body_len)
       {
         int r = Seobeo_Handle_Read(p_handle);
-        if (r < 0 || r == -2)
-          break;
+        if (r == -2 || r < 0)
+        {
+          if (p_file) fclose(p_file);
+          Seobeo_Client_Response_Destroy(p_resp);
+          return NULL;
+        }
+        if (r > 0)
+          last_progress_ms = Seobeo_Client_Monotonic_Milliseconds();
         if (r == 0)
+        {
+          if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms))
+          {
+            Seobeo_Log(SEOBEO_ERROR, "HTTP response body timed out\n");
+            if (p_file) fclose(p_file);
+            Seobeo_Client_Response_Destroy(p_resp);
+            return NULL;
+          }
+          usleep(1000);
           continue;
+        }
       }
     }
 
@@ -397,9 +488,12 @@
 
     while (1)
     {
-      int n = Seobeo_Handle_Read(p_handle);
+      int n = p_handle->read_buffer_len > 0
+          ? (int)p_handle->read_buffer_len
+          : Seobeo_Handle_Read(p_handle);
       if (n > 0)
       {
+        last_progress_ms = Seobeo_Client_Monotonic_Milliseconds();
         if (download_path)
         {
           fwrite(p_handle->read_buffer, 1, p_handle->read_buffer_len, p_file);
@@ -420,9 +514,23 @@
       else if (n == -2)
         break;
       else if (n == 0)
+      {
+        if (Seobeo_Client_Read_Timed_Out(timeout_ms, last_progress_ms))
+        {
+          Seobeo_Log(SEOBEO_ERROR, "HTTP response body timed out\n");
+          if (p_file) fclose(p_file);
+          Seobeo_Client_Response_Destroy(p_resp);
+          return NULL;
+        }
+        usleep(1000);
         continue;
+      }
       else
-        break;
+      {
+        if (p_file) fclose(p_file);
+        Seobeo_Client_Response_Destroy(p_resp);
+        return NULL;
+      }
     }
 
     if (!download_path)
@@ -468,7 +576,8 @@
     return NULL;
   }
 
-  Seobeo_Client_Response *p_resp = Seobeo_Client_Parse_Response(p_handle, p_req->download_path);
+  Seobeo_Client_Response *p_resp =
+      Seobeo_Client_Parse_Response(p_handle, p_req->download_path, p_req->timeout_ms);
 
   Seobeo_Handle_Destroy(p_handle);
 
--- a/seobeo/s_network.c	Sun Aug 02 08:52:13 2026 -0700
+++ b/seobeo/s_network.c	Sun Aug 02 14:42:01 2026 -0700
@@ -1,8 +1,37 @@
 #include "seobeo/seobeo.h"
 
+static pthread_once_t g_sigpipe_once = PTHREAD_ONCE_INIT;
+
+static void Seobeo_Process_Ignore_Sigpipe(void)
+{
+  signal(SIGPIPE, SIG_IGN);
+}
+
+static void Seobeo_Socket_Disable_Sigpipe(int socket_fd)
+{
+#ifdef SO_NOSIGPIPE
+  int enabled = 1;
+  setsockopt(socket_fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled));
+#else
+  (void)socket_fd;
+#endif
+}
+
+static ssize_t Seobeo_Socket_Write(
+    int socket_fd,
+    const void *buffer,
+    size_t length)
+{
+#ifdef MSG_NOSIGNAL
+  return send(socket_fd, buffer, length, MSG_NOSIGNAL);
+#else
+  return send(socket_fd, buffer, length, 0);
+#endif
+}
 
 Seobeo_Handle *Seobeo_Stream_Handle_Server_Create(const char *host,  const char* port)
 {
+  pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe);
   Seobeo_Handle *p_handle;
   struct addrinfo hints, *server_infos, *free_server_info;
   int32 socket_fd, yes = 1;  // Need this for setsockopt 
@@ -26,6 +55,7 @@
     if((socket_fd = socket(free_server_info->ai_family,
                         free_server_info->ai_socktype, free_server_info->ai_protocol)) == -1)
     { perror("socket"); continue; }
+    Seobeo_Socket_Disable_Sigpipe(socket_fd);
 
      if (setsockopt(socket_fd, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes)) == -1)
      { perror("setsockopt SO_REUSEADDR"); continue; }
@@ -81,6 +111,7 @@
 
 Seobeo_Handle *Seobeo_Stream_Handle_Client_Create(const char *host,  const char* port, boolean use_tls)
 {
+  pthread_once(&g_sigpipe_once, Seobeo_Process_Ignore_Sigpipe);
   Seobeo_Handle *p_handle;
   p_handle = malloc(sizeof(*p_handle));
 
@@ -98,6 +129,7 @@
   if((socket_fd = socket(server_infos->ai_family,
                       server_infos->ai_socktype, server_infos->ai_protocol)) == -1)
   { perror("socket"); return NULL; }
+  Seobeo_Socket_Disable_Sigpipe(socket_fd);
 
   if (connect(socket_fd, server_infos->ai_addr, server_infos->ai_addrlen) != 0)
   { perror("connect"); return NULL; }
@@ -154,6 +186,7 @@
       Seobeo_Get_IP4_Or_IP6((struct sockaddr *)&addr),
       client_inet_addr, sizeof client_inet_addr);
   if (client_fd == -1) return NULL;
+  Seobeo_Socket_Disable_Sigpipe(client_fd);
 
   // Set non blocking...
   int flags = fcntl(client_fd, F_GETFL, 0);
@@ -236,7 +269,7 @@
     }else
     {
       Seobeo_Log(SEOBEO_DEBUG, "Flushing socket: %d\n", p_handle->socket);
-      ssize_t n = write(
+      ssize_t n = Seobeo_Socket_Write(
         p_handle->socket,
         p_handle->write_buffer + sent,
         total - sent
@@ -275,9 +308,10 @@
       }
       else
       {
-        n = write(p_handle->socket,
-                  data + offset,
-                  data_size - offset);
+        n = Seobeo_Socket_Write(
+            p_handle->socket,
+            data + offset,
+            data_size - offset);
       }
 
       if (n==0)
--- a/seobeo/s_web.c	Sun Aug 02 08:52:13 2026 -0700
+++ b/seobeo/s_web.c	Sun Aug 02 14:42:01 2026 -0700
@@ -51,6 +51,7 @@
   {
     case HTTP_OK: status_text = "OK"; break;
     case HTTP_CREATED: status_text = "Created"; break;
+    case HTTP_NO_CONTENT: status_text = "No Content"; break;
     case HTTP_MOVED_PERMANENTLY: status_text = "Moved Permanently"; break;
     case HTTP_FOUND: status_text = "Found"; break;
     case HTTP_BAD_REQUEST: status_text = "Bad Request"; break;
@@ -58,11 +59,13 @@
     case HTTP_FORBIDDEN: status_text = "Forbidden"; break;
     case HTTP_NOT_FOUND: status_text = "Not Found"; break;
     case HTTP_INTERNAL_ERROR: status_text = "Internal Server Error"; break;
+    case 502: status_text = "Bad Gateway"; break;
+    case 504: status_text = "Gateway Timeout"; break;
     default: status_text = "Unknown"; break;
   }
 
-  sprintf(
-    buffer,
+  snprintf(
+    (char*)buffer, 1024,
     "HTTP/1.1 %d %s\r\n"
     "Content-Type: %s\r\n"
     "Content-Length: %d\r\n"
@@ -774,6 +777,11 @@
   Seobeo_Router_Send_Response_KeepAlive(p_handle, p_response_map, p_arena, FALSE);
 }
 
+static boolean Seobeo_Response_Header_Value_Is_Safe(const char *value)
+{
+  return value && strchr(value, '\r') == NULL && strchr(value, '\n') == NULL;
+}
+
 void Seobeo_Router_Send_Response_KeepAlive(
     Seobeo_Handle *p_handle,
     Seobeo_Request_Entry *p_response_map,
@@ -819,26 +827,63 @@
   else
     body_length = strlen(body);
 
-  char *header = Dowa_Arena_Allocate(p_arena, 4096);
-  Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive);
+  size_t header_capacity = 1024;
   for (int i = 0; i < Dowa_Array_Length(p_response_map); i++)
   {
+    const char *key = p_response_map[i].key;
+    const char *value = p_response_map[i].value;
     if (
-      strstr(p_response_map[i].key, "status") ||
-      strstr(p_response_map[i].key, "body") ||
-      strstr(p_response_map[i].key, "content-type") ||
-      strstr(p_response_map[i].key, "content-length")
+      strcasecmp(key, "status") == 0 ||
+      strcasecmp(key, "body") == 0 ||
+      strcasecmp(key, "content-type") == 0 ||
+      strcasecmp(key, "content-length") == 0
+    )
+      continue;
+    if (Seobeo_Response_Header_Value_Is_Safe(key) &&
+        Seobeo_Response_Header_Value_Is_Safe(value))
+      header_capacity += strlen(key) + strlen(value) + 4;
+  }
+
+  char *header = Dowa_Arena_Allocate(p_arena, header_capacity);
+  Seobeo_Web_Header_Generate_KeepAlive(header, status, content_type, body_length, keep_alive);
+  size_t header_length = strlen(header);
+  if (header_length < 2)
+    return;
+  header_length -= 2;
+
+  for (int i = 0; i < Dowa_Array_Length(p_response_map); i++)
+  {
+    const char *key = p_response_map[i].key;
+    const char *value = p_response_map[i].value;
+    if (
+      strcasecmp(key, "status") == 0 ||
+      strcasecmp(key, "body") == 0 ||
+      strcasecmp(key, "content-type") == 0 ||
+      strcasecmp(key, "content-length") == 0
     )
       continue;
 
-    int32 current_header_len = strlen(header);
-    char *temp = malloc(sizeof(char) * 1024);
-    sprintf(temp, "%s: %s\r\n\r\n", p_response_map[i].key, p_response_map[i].value);
-    memcpy(&header[current_header_len - 2 /* \r\n */], temp, strlen(temp));
-    free(temp);
+    if (!Seobeo_Response_Header_Value_Is_Safe(key) ||
+        !Seobeo_Response_Header_Value_Is_Safe(value))
+    {
+      Seobeo_Log(SEOBEO_WARNING, "Skipping unsafe response header\n");
+      continue;
+    }
+
+    int written = snprintf(
+        header + header_length,
+        header_capacity - header_length,
+        "%s: %s\r\n",
+        key,
+        value);
+    if (written < 0 || (size_t)written >= header_capacity - header_length)
+    {
+      Seobeo_Log(SEOBEO_ERROR, "Response header exceeded allocated capacity\n");
+      return;
+    }
+    header_length += (size_t)written;
   }
-
-  printf("hEADER %s\n", header);
+  memcpy(header + header_length, "\r\n", 3);
 
   Seobeo_Handle_Queue(p_handle, (uint8_t*)header, strlen(header));
   Seobeo_Handle_Queue(p_handle, (uint8_t*)body, body_length);
--- a/seobeo/seobeo.h	Sun Aug 02 08:52:13 2026 -0700
+++ b/seobeo/seobeo.h	Sun Aug 02 14:42:01 2026 -0700
@@ -30,6 +30,7 @@
 // HTTP STATUS CODE
 #define HTTP_OK 200
 #define HTTP_CREATED 201
+#define HTTP_NO_CONTENT 204
 #define HTTP_MOVED_PERMANENTLY 301
 #define HTTP_FOUND 302
 #define HTTP_BAD_REQUEST 400
@@ -82,6 +83,8 @@
 extern void                    Seobeo_Client_Request_Set_Body(Seobeo_Client_Request *p_req, const char *body, size_t length);
 /* Enable/disable following redirects with max redirect count. Default is FALSE with 10 max. */
 extern void                    Seobeo_Client_Request_Set_Follow_Redirects(Seobeo_Client_Request *p_req, boolean follow, int32 max_redirects);
+/* Set the maximum idle time for HTTP response reads. Zero disables the timeout. */
+extern void                    Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms);
 /* Set download path to save response body to file instead of memory. */
 extern void                    Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path);
 /* Execute the HTTP request and return response. */
--- a/seobeo/seobeo_internal.h	Sun Aug 02 08:52:13 2026 -0700
+++ b/seobeo/seobeo_internal.h	Sun Aug 02 14:42:01 2026 -0700
@@ -131,6 +131,7 @@
 
   boolean  follow_redirects;
   int32    max_redirects;
+  int32    timeout_ms;
 
   char    *download_path;
 
@@ -158,6 +159,7 @@
 extern void                    Seobeo_Client_Request_Add_Header_Array(Seobeo_Client_Request *p_req, const char *header);
 extern void                    Seobeo_Client_Request_Set_Body(Seobeo_Client_Request *p_req, const char *body, size_t length);
 extern void                    Seobeo_Client_Request_Set_Follow_Redirects(Seobeo_Client_Request *p_req, boolean follow, int32 max_redirects);
+extern void                    Seobeo_Client_Request_Set_Timeout_Milliseconds(Seobeo_Client_Request *p_req, int32 timeout_ms);
 extern void                    Seobeo_Client_Request_Set_Download_Path(Seobeo_Client_Request *p_req, const char *path);
 extern Seobeo_Client_Response *Seobeo_Client_Request_Execute(Seobeo_Client_Request *p_req);
 extern void                    Seobeo_Client_Request_Destroy(Seobeo_Client_Request *p_req);
--- a/seobeo/tests/BUILD	Sun Aug 02 08:52:13 2026 -0700
+++ b/seobeo/tests/BUILD	Sun Aug 02 14:42:01 2026 -0700
@@ -28,3 +28,48 @@
   args = ["$(location //seobeo/examples:websocket_server_example)"],
   visibility = ["//visibility:public"],
 )
+
+cc_test(
+  name = "seobeo_response_test",
+  srcs = ["seobeo_response_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
+
+cc_test(
+  name = "seobeo_sigpipe_test",
+  srcs = ["seobeo_sigpipe_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
+
+cc_test(
+  name = "seobeo_http_timeout_test",
+  srcs = ["seobeo_http_timeout_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
+
+cc_test(
+  name = "seobeo_http_framing_test",
+  srcs = ["seobeo_http_framing_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
+
+cc_test(
+  name = "seobeo_http_content_length_test",
+  srcs = ["seobeo_http_content_length_test.c"],
+  deps = ["//seobeo:seobeo"],
+  size = "small",
+  timeout = "short",
+  visibility = ["//visibility:public"],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_http_content_length_test.c	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,91 @@
+#include "seobeo/seobeo.h"
+
+#include <arpa/inet.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+
+typedef struct {
+  int listener;
+} Content_Length_Server;
+
+static int64_t monotonic_milliseconds(void)
+{
+  struct timespec now;
+  clock_gettime(CLOCK_MONOTONIC, &now);
+  return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
+}
+
+static void *serve_lowercase_content_length(void *argument)
+{
+  Content_Length_Server *server = argument;
+  int client = accept(server->listener, NULL, NULL);
+  if (client >= 0) {
+    char request[1024];
+    (void)read(client, request, sizeof(request));
+    const char response[] =
+        "HTTP/1.1 200 OK\r\n"
+        "content-length: 4\r\n"
+        "Connection: keep-alive\r\n"
+        "\r\n"
+        "done";
+    (void)write(client, response, sizeof(response) - 1);
+    usleep(500000);
+    close(client);
+  }
+  return NULL;
+}
+
+int main(void)
+{
+  int listener = socket(AF_INET, SOCK_STREAM, 0);
+  if (listener < 0)
+    return 1;
+
+  struct sockaddr_in address = {
+    .sin_family = AF_INET,
+    .sin_addr.s_addr = htonl(INADDR_LOOPBACK),
+    .sin_port = 0,
+  };
+  if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 ||
+      listen(listener, 1) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  socklen_t address_length = sizeof(address);
+  if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  Content_Length_Server server = {.listener = listener};
+  pthread_t thread;
+  if (pthread_create(&thread, NULL, serve_lowercase_content_length, &server) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  char url[128];
+  snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port));
+  Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url);
+  Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000);
+  int64_t started = monotonic_milliseconds();
+  Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request);
+  int64_t elapsed = monotonic_milliseconds() - started;
+
+  int failed = !response ||
+               response->body_length != 4 ||
+               memcmp(response->body, "done", 4) != 0 ||
+               elapsed > 300;
+  if (failed)
+    fprintf(stderr, "Case-insensitive Content-Length was not honored (%lldms)\n",
+            (long long)elapsed);
+
+  Seobeo_Client_Response_Destroy(response);
+  Seobeo_Client_Request_Destroy(request);
+  pthread_join(thread, NULL);
+  close(listener);
+  return failed;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_http_framing_test.c	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,79 @@
+#include "seobeo/seobeo.h"
+
+#include <arpa/inet.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+
+typedef struct {
+  int listener;
+} Framing_Server;
+
+static void *serve_close_delimited_response(void *argument)
+{
+  Framing_Server *server = argument;
+  int client = accept(server->listener, NULL, NULL);
+  if (client >= 0) {
+    char request[1024];
+    (void)read(client, request, sizeof(request));
+    const char response[] =
+        "HTTP/1.1 200 OK\r\n"
+        "Content-Type: text/plain\r\n"
+        "Connection: close\r\n"
+        "\r\n"
+        "buffered-body";
+    (void)write(client, response, sizeof(response) - 1);
+    close(client);
+  }
+  return NULL;
+}
+
+int main(void)
+{
+  int listener = socket(AF_INET, SOCK_STREAM, 0);
+  if (listener < 0)
+    return 1;
+
+  struct sockaddr_in address = {
+    .sin_family = AF_INET,
+    .sin_addr.s_addr = htonl(INADDR_LOOPBACK),
+    .sin_port = 0,
+  };
+  if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 ||
+      listen(listener, 1) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  socklen_t address_length = sizeof(address);
+  if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  Framing_Server server = {.listener = listener};
+  pthread_t thread;
+  if (pthread_create(&thread, NULL, serve_close_delimited_response, &server) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  char url[128];
+  snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port));
+  Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url);
+  Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 1000);
+  Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request);
+
+  int failed = !response ||
+               response->status_code != 200 ||
+               response->body_length != strlen("buffered-body") ||
+               memcmp(response->body, "buffered-body", strlen("buffered-body")) != 0;
+  if (failed)
+    fprintf(stderr, "Close-delimited response body was not preserved\n");
+
+  Seobeo_Client_Response_Destroy(response);
+  Seobeo_Client_Request_Destroy(request);
+  pthread_join(thread, NULL);
+  close(listener);
+  return failed;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_http_timeout_test.c	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,82 @@
+#include "seobeo/seobeo.h"
+
+#include <arpa/inet.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+
+typedef struct {
+  int listener;
+} Timeout_Server;
+
+static int64_t monotonic_milliseconds(void)
+{
+  struct timespec now;
+  clock_gettime(CLOCK_MONOTONIC, &now);
+  return (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
+}
+
+static void *serve_stalled_response(void *argument)
+{
+  Timeout_Server *server = argument;
+  int client = accept(server->listener, NULL, NULL);
+  if (client >= 0) {
+    char request[1024];
+    (void)read(client, request, sizeof(request));
+    usleep(250000);
+    close(client);
+  }
+  return NULL;
+}
+
+int main(void)
+{
+  int listener = socket(AF_INET, SOCK_STREAM, 0);
+  if (listener < 0)
+    return 1;
+
+  struct sockaddr_in address = {
+    .sin_family = AF_INET,
+    .sin_addr.s_addr = htonl(INADDR_LOOPBACK),
+    .sin_port = 0,
+  };
+  if (bind(listener, (struct sockaddr *)&address, sizeof(address)) != 0 ||
+      listen(listener, 1) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  socklen_t address_length = sizeof(address);
+  if (getsockname(listener, (struct sockaddr *)&address, &address_length) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  Timeout_Server server = {.listener = listener};
+  pthread_t thread;
+  if (pthread_create(&thread, NULL, serve_stalled_response, &server) != 0) {
+    close(listener);
+    return 1;
+  }
+
+  char url[128];
+  snprintf(url, sizeof(url), "http://127.0.0.1:%u/", ntohs(address.sin_port));
+  Seobeo_Client_Request *request = Seobeo_Client_Request_Create(url);
+  Seobeo_Client_Request_Set_Timeout_Milliseconds(request, 50);
+
+  int64_t started = monotonic_milliseconds();
+  Seobeo_Client_Response *response = Seobeo_Client_Request_Execute(request);
+  int64_t elapsed = monotonic_milliseconds() - started;
+
+  int failed = response != NULL || elapsed < 40 || elapsed > 2000;
+  if (failed)
+    fprintf(stderr, "Expected a bounded timeout; response=%p elapsed=%lldms\n",
+            (void *)response, (long long)elapsed);
+
+  Seobeo_Client_Response_Destroy(response);
+  Seobeo_Client_Request_Destroy(request);
+  pthread_join(thread, NULL);
+  close(listener);
+  return failed;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_response_test.c	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,79 @@
+#include "seobeo/seobeo.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+
+int main(void)
+{
+  int sockets[2];
+  if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0)
+    return 1;
+
+  Seobeo_Handle handle = {0};
+  handle.socket = sockets[0];
+  handle.write_buffer_capacity = 4096;
+  handle.write_buffer = malloc(handle.write_buffer_capacity);
+
+  Dowa_Arena *arena = Dowa_Arena_Create(4096);
+  Seobeo_Request_Entry *response = NULL;
+  Dowa_HashMap_Push_Arena(response, "status", "200", arena);
+  Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena);
+  Dowa_HashMap_Push_Arena(response, "body", "hello", arena);
+  Dowa_HashMap_Push_Arena(response, "X-Test", "present", arena);
+  Dowa_HashMap_Push_Arena(response, "X-Unsafe", "bad\r\nInjected: yes", arena);
+
+  Seobeo_Router_Send_Response(&handle, response, arena);
+
+  char received[4096] = {0};
+  ssize_t length = read(sockets[1], received, sizeof(received) - 1);
+  int failed = 0;
+  if (length <= 0)
+    failed = 1;
+  if (!strstr(received, "Content-Length: 5\r\n"))
+    failed = 1;
+  if (!strstr(received, "X-Test: present\r\n\r\nhello"))
+    failed = 1;
+  if (strstr(received, "Injected: yes"))
+    failed = 1;
+
+  if (failed)
+    fprintf(stderr, "Unexpected response:\n%s\n", received);
+
+  close(sockets[0]);
+  close(sockets[1]);
+  free(handle.write_buffer);
+  Dowa_Arena_Free(arena);
+
+  if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0)
+    return 1;
+
+  memset(&handle, 0, sizeof(handle));
+  handle.socket = sockets[0];
+  handle.write_buffer_capacity = 4096;
+  handle.write_buffer = malloc(handle.write_buffer_capacity);
+  arena = Dowa_Arena_Create(4096);
+  response = NULL;
+  Dowa_HashMap_Push_Arena(response, "status", "204", arena);
+  Dowa_HashMap_Push_Arena(response, "content-type", "text/plain", arena);
+  Dowa_HashMap_Push_Arena(response, "body", "", arena);
+  Seobeo_Router_Send_Response(&handle, response, arena);
+
+  memset(received, 0, sizeof(received));
+  length = read(sockets[1], received, sizeof(received) - 1);
+  if (length <= 0 ||
+      !strstr(received, "HTTP/1.1 204 No Content\r\n") ||
+      !strstr(received, "Content-Length: 0\r\n") ||
+      !strstr(received, "\r\n\r\n"))
+    failed = 1;
+
+  if (failed)
+    fprintf(stderr, "Unexpected no-content response:\n%s\n", received);
+
+  close(sockets[0]);
+  close(sockets[1]);
+  free(handle.write_buffer);
+  Dowa_Arena_Free(arena);
+  return failed;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/tests/seobeo_sigpipe_test.c	Sun Aug 02 14:42:01 2026 -0700
@@ -0,0 +1,36 @@
+#include "seobeo/seobeo.h"
+
+#include <signal.h>
+#include <stdlib.h>
+#include <sys/socket.h>
+
+int main(void)
+{
+  Seobeo_Handle *server =
+      Seobeo_Stream_Handle_Server_Create("127.0.0.1", "0");
+  if (!server)
+    return 1;
+
+  raise(SIGPIPE);
+  Seobeo_Handle_Destroy(server);
+
+  int sockets[2];
+  if (socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) != 0)
+    return 1;
+
+  Seobeo_Handle handle = {0};
+  handle.socket = sockets[0];
+  handle.write_buffer_capacity = 64;
+  handle.write_buffer = malloc(handle.write_buffer_capacity);
+  if (!handle.write_buffer)
+    return 1;
+
+  close(sockets[1]);
+  if (Seobeo_Handle_Queue(&handle, (const uint8 *)"closed", 6) != 0)
+    return 1;
+
+  int result = Seobeo_Handle_Flush(&handle);
+  close(sockets[0]);
+  free(handle.write_buffer);
+  return result < 0 ? 0 : 1;
+}