changeset 228:62f9cb40d10d

[Merge] Merging conflicts
author MrJuneJune <me@mrjunejune.com>
date Sun, 02 Aug 2026 16:44:06 -0700
parents 8bb0ac8f4587 (diff) e82b80b24012 (current diff)
children 7795e3149540
files MODULE.bazel.lock
diffstat 43 files changed, 5224 insertions(+), 801 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/AGENT.md	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/MODULE.bazel	Sun Aug 02 16:44:06 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(
@@ -31,8 +63,10 @@
   sha256 = "cf0ed0a920799d576ffde4e0cae66d732bf23c2530407f26f59c7831dffe1f0e",
 )
 
+# Bring in Python support
+bazel_dep(name = "rules_python", version = "1.7.0")
+
 # Bring in pip support
-# bazel_dep(name = "rules_python", version = "1.7.0")
 # use_extension("@rules_python//python/extensions:pip.bzl", "pip")
 # pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
 
--- a/MODULE.bazel.lock	Sat Feb 28 21:04:43 2026 -0800
+++ b/MODULE.bazel.lock	Sun Aug 02 16:44:06 2026 -0700
@@ -1,5 +1,5 @@
 {
-  "lockFileVersion": 18,
+  "lockFileVersion": 28,
   "registryFileHashes": {
     "https://bcr.bazel.build/bazel_registry.json": "8a28e4aff06ee60aed2a8c281907fb8bcbf3b753c91fb5a5c57da3215d5b3497",
     "https://bcr.bazel.build/modules/abseil-cpp/20210324.2/MODULE.bazel": "7cd0312e064fde87c8d1cd79ba06c876bd23630c83466e9500321be55c96ace2",
@@ -9,7 +9,17 @@
     "https://bcr.bazel.build/modules/abseil-cpp/20230802.0/MODULE.bazel": "d253ae36a8bd9ee3c5955384096ccb6baf16a1b1e93e858370da0a3b94f77c16",
     "https://bcr.bazel.build/modules/abseil-cpp/20230802.1/MODULE.bazel": "fa92e2eb41a04df73cdabeec37107316f7e5272650f81d6cc096418fe647b915",
     "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/MODULE.bazel": "37bcdb4440fbb61df6a1c296ae01b327f19e9bb521f9b8e26ec854b6f97309ed",
-    "https://bcr.bazel.build/modules/abseil-cpp/20240116.1/source.json": "9be551b8d4e3ef76875c0d744b5d6a504a27e3ae67bc6b28f46415fd2d2957da",
+    "https://bcr.bazel.build/modules/abseil-cpp/20240116.2/MODULE.bazel": "73939767a4686cd9a520d16af5ab440071ed75cec1a876bf2fcfaf1f71987a16",
+    "https://bcr.bazel.build/modules/abseil-cpp/20250127.1/MODULE.bazel": "c4a89e7ceb9bf1e25cf84a9f830ff6b817b72874088bf5141b314726e46a57c1",
+    "https://bcr.bazel.build/modules/abseil-cpp/20250512.1/MODULE.bazel": "d209fdb6f36ffaf61c509fcc81b19e81b411a999a934a032e10cd009a0226215",
+    "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/MODULE.bazel": "51f2312901470cdab0dbdf3b88c40cd21c62a7ed58a3de45b365ddc5b11bcab2",
+    "https://bcr.bazel.build/modules/abseil-cpp/20250814.1/source.json": "cea3901d7e299da7320700abbaafe57a65d039f10d0d7ea601c4a66938ea4b0c",
+    "https://bcr.bazel.build/modules/apple_support/1.11.1/MODULE.bazel": "1843d7cd8a58369a444fc6000e7304425fba600ff641592161d9f15b179fb896",
+    "https://bcr.bazel.build/modules/apple_support/1.15.1/MODULE.bazel": "a0556fefca0b1bb2de8567b8827518f94db6a6e7e7d632b4c48dc5f865bc7c85",
+    "https://bcr.bazel.build/modules/apple_support/1.21.0/MODULE.bazel": "ac1824ed5edf17dee2fdd4927ada30c9f8c3b520be1b5fd02a5da15bc10bff3e",
+    "https://bcr.bazel.build/modules/apple_support/1.21.1/MODULE.bazel": "5809fa3efab15d1f3c3c635af6974044bac8a4919c62238cce06acee8a8c11f1",
+    "https://bcr.bazel.build/modules/apple_support/1.24.2/MODULE.bazel": "0e62471818affb9f0b26f128831d5c40b074d32e6dda5a0d3852847215a41ca4",
+    "https://bcr.bazel.build/modules/apple_support/1.24.2/source.json": "2c22c9827093250406c5568da6c54e6fdf0ef06238def3d99c71b12feb057a8d",
     "https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
     "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.2/MODULE.bazel": "30dfabbfae0139b1f0036e01c201dd4c0167da3017f0b7ef3820d78e07622989",
     "https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.2/source.json": "89d8e5d7088ae33972733099b756dae71e1647ae684ab50b26adfa853c506d01",
@@ -19,17 +29,23 @@
     "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/MODULE.bazel": "37c764292861c2f70314efa9846bb6dbb44fc0308903b3285da6528305450183",
     "https://bcr.bazel.build/modules/aspect_tools_telemetry/0.3.3/source.json": "605086bbc197743a0d360f7ddc550a1d4dfa0441bc807236e17170f636153348",
     "https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
+    "https://bcr.bazel.build/modules/bazel_features/1.10.0/MODULE.bazel": "f75e8807570484a99be90abcd52b5e1f390362c258bcb73106f4544957a48101",
     "https://bcr.bazel.build/modules/bazel_features/1.11.0/MODULE.bazel": "f9382337dd5a474c3b7d334c2f83e50b6eaedc284253334cf823044a26de03e8",
     "https://bcr.bazel.build/modules/bazel_features/1.15.0/MODULE.bazel": "d38ff6e517149dc509406aca0db3ad1efdd890a85e049585b7234d04238e2a4d",
     "https://bcr.bazel.build/modules/bazel_features/1.17.0/MODULE.bazel": "039de32d21b816b47bd42c778e0454217e9c9caac4a3cf8e15c7231ee3ddee4d",
     "https://bcr.bazel.build/modules/bazel_features/1.18.0/MODULE.bazel": "1be0ae2557ab3a72a57aeb31b29be347bcdc5d2b1eb1e70f39e3851a7e97041a",
     "https://bcr.bazel.build/modules/bazel_features/1.19.0/MODULE.bazel": "59adcdf28230d220f0067b1f435b8537dd033bfff8db21335ef9217919c7fb58",
     "https://bcr.bazel.build/modules/bazel_features/1.21.0/MODULE.bazel": "675642261665d8eea09989aa3b8afb5c37627f1be178382c320d1b46afba5e3b",
+    "https://bcr.bazel.build/modules/bazel_features/1.23.0/MODULE.bazel": "fd1ac84bc4e97a5a0816b7fd7d4d4f6d837b0047cf4cbd81652d616af3a6591a",
+    "https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
     "https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
+    "https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
     "https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
+    "https://bcr.bazel.build/modules/bazel_features/1.33.0/MODULE.bazel": "8b8dc9d2a4c88609409c3191165bccec0e4cb044cd7a72ccbe826583303459f6",
     "https://bcr.bazel.build/modules/bazel_features/1.39.0/MODULE.bazel": "28739425c1fc283c91931619749c832b555e60bcd1010b40d8441ce0a5cf726d",
-    "https://bcr.bazel.build/modules/bazel_features/1.39.0/source.json": "f63cbeb4c602098484d57001e5a07d31cb02bbccde9b5e2c9bf0b29d05283e93",
     "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
+    "https://bcr.bazel.build/modules/bazel_features/1.42.1/MODULE.bazel": "275a59b5406ff18c01739860aa70ad7ccb3cfb474579411decca11c93b951080",
+    "https://bcr.bazel.build/modules/bazel_features/1.42.1/source.json": "fcd4396b2df85f64f2b3bb436ad870793ecf39180f1d796f913cc9276d355309",
     "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
     "https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
     "https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
@@ -50,18 +66,23 @@
     "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
     "https://bcr.bazel.build/modules/buildifier_prebuilt/7.1.2/MODULE.bazel": "d18b017dddf219626ea5b04028ff0db2397655fbdaa9d9258a6443ddafb303ab",
     "https://bcr.bazel.build/modules/buildifier_prebuilt/7.1.2/source.json": "5fb3a2433f6508f4b23934eaa2986d604ae65aff95d7476d1273bf5cd434eedc",
-    "https://bcr.bazel.build/modules/buildozer/7.1.2/MODULE.bazel": "2e8dd40ede9c454042645fd8d8d0cd1527966aa5c919de86661e62953cd73d84",
-    "https://bcr.bazel.build/modules/buildozer/7.1.2/source.json": "c9028a501d2db85793a6996205c8de120944f50a0d570438fcae0457a5f9d1f8",
+    "https://bcr.bazel.build/modules/buildozer/8.5.1/MODULE.bazel": "a35d9561b3fc5b18797c330793e99e3b834a473d5fbd3d7d7634aafc9bdb6f8f",
+    "https://bcr.bazel.build/modules/buildozer/8.5.1/source.json": "e3386e6ff4529f2442800dee47ad28d3e6487f36a1f75ae39ae56c70f0cd2fbd",
     "https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
     "https://bcr.bazel.build/modules/googletest/1.11.0/MODULE.bazel": "3a83f095183f66345ca86aa13c58b59f9f94a2f81999c093d4eeaa2d262d12f4",
     "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/MODULE.bazel": "22c31a561553727960057361aa33bf20fb2e98584bc4fec007906e27053f80c6",
-    "https://bcr.bazel.build/modules/googletest/1.14.0.bcr.1/source.json": "41e9e129f80d8c8bf103a7acc337b76e54fad1214ac0a7084bf24f4cd924b8b4",
     "https://bcr.bazel.build/modules/googletest/1.14.0/MODULE.bazel": "cfbcbf3e6eac06ef9d85900f64424708cc08687d1b527f0ef65aa7517af8118f",
+    "https://bcr.bazel.build/modules/googletest/1.15.2/MODULE.bazel": "6de1edc1d26cafb0ea1a6ab3f4d4192d91a312fd2d360b63adaa213cd00b2108",
+    "https://bcr.bazel.build/modules/googletest/1.17.0/MODULE.bazel": "dbec758171594a705933a29fcf69293d2468c49ec1f2ebca65c36f504d72df46",
+    "https://bcr.bazel.build/modules/googletest/1.17.0/source.json": "38e4454b25fc30f15439c0378e57909ab1fd0a443158aa35aec685da727cd713",
     "https://bcr.bazel.build/modules/jq.bzl/0.1.0/MODULE.bazel": "2ce69b1af49952cd4121a9c3055faa679e748ce774c7f1fda9657f936cae902f",
     "https://bcr.bazel.build/modules/jq.bzl/0.1.0/source.json": "746bf13cac0860f091df5e4911d0c593971cd8796b5ad4e809b2f8e133eee3d5",
     "https://bcr.bazel.build/modules/jsoncpp/1.9.5/MODULE.bazel": "31271aedc59e815656f5736f282bb7509a97c7ecb43e927ac1a37966e0578075",
-    "https://bcr.bazel.build/modules/jsoncpp/1.9.5/source.json": "4108ee5085dd2885a341c7fab149429db457b3169b86eb081fa245eadf69169d",
+    "https://bcr.bazel.build/modules/jsoncpp/1.9.6/MODULE.bazel": "2f8d20d3b7d54143213c4dfc3d98225c42de7d666011528dc8fe91591e2e17b0",
+    "https://bcr.bazel.build/modules/jsoncpp/1.9.6/source.json": "a04756d367a2126c3541682864ecec52f92cdee80a35735a3cb249ce015ca000",
     "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902",
+    "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74",
+    "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/source.json": "f448c6e8963fdfa7eb831457df83ad63d3d6355018f6574fb017e8169deb43a9",
     "https://bcr.bazel.build/modules/openssl/3.3.1.bcr.7/MODULE.bazel": "dcb8181b28242d40ef854248f2929f3118dec18e68080e8ae3c0b7444316745b",
     "https://bcr.bazel.build/modules/openssl/3.3.1.bcr.7/source.json": "339b52ba88f2d4bd7a55822bf1c790b7c00a09e0f8d323208b9e67086f22bdb6",
     "https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
@@ -73,6 +94,7 @@
     "https://bcr.bazel.build/modules/platforms/0.0.6/MODULE.bazel": "ad6eeef431dc52aefd2d77ed20a4b353f8ebf0f4ecdd26a807d2da5aa8cd0615",
     "https://bcr.bazel.build/modules/platforms/0.0.7/MODULE.bazel": "72fd4a0ede9ee5c021f6a8dd92b503e089f46c227ba2813ff183b71616034814",
     "https://bcr.bazel.build/modules/platforms/0.0.8/MODULE.bazel": "9f142c03e348f6d263719f5074b21ef3adf0b139ee4c5133e2aa35664da9eb2d",
+    "https://bcr.bazel.build/modules/platforms/0.0.9/MODULE.bazel": "4a87a60c927b56ddd67db50c89acaa62f4ce2a1d2149ccb63ffd871d5ce29ebc",
     "https://bcr.bazel.build/modules/platforms/1.0.0/MODULE.bazel": "f05feb42b48f1b3c225e4ccf351f367be0371411a803198ec34a389fb22aa580",
     "https://bcr.bazel.build/modules/platforms/1.0.0/source.json": "f4ff1fd412e0246fd38c82328eb209130ead81d62dcd5a9e40910f867f733d96",
     "https://bcr.bazel.build/modules/protobuf/21.7/MODULE.bazel": "a5a29bb89544f9b97edce05642fac225a808b5b7be74038ea3640fae2f8e66a7",
@@ -80,19 +102,26 @@
     "https://bcr.bazel.build/modules/protobuf/27.1/MODULE.bazel": "703a7b614728bb06647f965264967a8ef1c39e09e8f167b3ca0bb1fd80449c0d",
     "https://bcr.bazel.build/modules/protobuf/29.0-rc2/MODULE.bazel": "6241d35983510143049943fc0d57937937122baf1b287862f9dc8590fc4c37df",
     "https://bcr.bazel.build/modules/protobuf/29.0-rc3/MODULE.bazel": "33c2dfa286578573afc55a7acaea3cada4122b9631007c594bf0729f41c8de92",
-    "https://bcr.bazel.build/modules/protobuf/29.0/MODULE.bazel": "319dc8bf4c679ff87e71b1ccfb5a6e90a6dbc4693501d471f48662ac46d04e4e",
-    "https://bcr.bazel.build/modules/protobuf/29.0/source.json": "b857f93c796750eef95f0d61ee378f3420d00ee1dd38627b27193aa482f4f981",
+    "https://bcr.bazel.build/modules/protobuf/29.1/MODULE.bazel": "557c3457560ff49e122ed76c0bc3397a64af9574691cb8201b4e46d4ab2ecb95",
     "https://bcr.bazel.build/modules/protobuf/3.19.0/MODULE.bazel": "6b5fbb433f760a99a22b18b6850ed5784ef0e9928a72668b66e4d7ccd47db9b0",
+    "https://bcr.bazel.build/modules/protobuf/32.1/MODULE.bazel": "89cd2866a9cb07fee9ff74c41ceace11554f32e0d849de4e23ac55515cfada4d",
+    "https://bcr.bazel.build/modules/protobuf/33.4/MODULE.bazel": "114775b816b38b6d0ca620450d6b02550c60ceedfdc8d9a229833b34a223dc42",
+    "https://bcr.bazel.build/modules/protobuf/33.4/source.json": "555f8686b4c7d6b5ba731fbea13bf656b4bfd9a7ff629c1d9d3f6e1d6155de79",
     "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/MODULE.bazel": "88af1c246226d87e65be78ed49ecd1e6f5e98648558c14ce99176da041dc378e",
-    "https://bcr.bazel.build/modules/pybind11_bazel/2.11.1/source.json": "be4789e951dd5301282729fe3d4938995dc4c1a81c2ff150afc9f1b0504c6022",
+    "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34",
+    "https://bcr.bazel.build/modules/pybind11_bazel/2.12.0/source.json": "6900fdc8a9e95866b8c0d4ad4aba4d4236317b5c1cd04c502df3f0d33afed680",
     "https://bcr.bazel.build/modules/re2/2023-09-01/MODULE.bazel": "cb3d511531b16cfc78a225a9e2136007a48cf8a677e4264baeab57fe78a80206",
-    "https://bcr.bazel.build/modules/re2/2023-09-01/source.json": "e044ce89c2883cd957a2969a43e79f7752f9656f6b20050b62f90ede21ec6eb4",
+    "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/MODULE.bazel": "b4963dda9b31080be1905ef085ecd7dd6cd47c05c79b9cdf83ade83ab2ab271a",
+    "https://bcr.bazel.build/modules/re2/2024-07-02.bcr.1/source.json": "2ff292be6ef3340325ce8a045ecc326e92cbfab47c7cbab4bd85d28971b97ac4",
+    "https://bcr.bazel.build/modules/re2/2024-07-02/MODULE.bazel": "0eadc4395959969297cbcf31a249ff457f2f1d456228c67719480205aa306daa",
     "https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8",
     "https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e",
+    "https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a",
+    "https://bcr.bazel.build/modules/rules_apple/4.1.0/MODULE.bazel": "76e10fd4a48038d3fc7c5dc6e63b7063bbf5304a2e3bd42edda6ec660eebea68",
+    "https://bcr.bazel.build/modules/rules_apple/4.1.0/source.json": "8ee81e1708756f81b343a5eb2b2f0b953f1d25c4ab3d4a68dc02754872e80715",
     "https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
     "https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002",
     "https://bcr.bazel.build/modules/rules_cc/0.0.13/MODULE.bazel": "0e8529ed7b323dad0775ff924d2ae5af7640b23553dfcd4d34344c7e7a867191",
-    "https://bcr.bazel.build/modules/rules_cc/0.0.14/MODULE.bazel": "5e343a3aac88b8d7af3b1b6d2093b55c347b8eefc2e7d1442f7a02dc8fea48ac",
     "https://bcr.bazel.build/modules/rules_cc/0.0.15/MODULE.bazel": "6704c35f7b4a72502ee81f61bf88706b54f06b3cbe5558ac17e2e14666cd5dcc",
     "https://bcr.bazel.build/modules/rules_cc/0.0.16/MODULE.bazel": "7661303b8fc1b4d7f532e54e9d6565771fea666fbdf839e0a86affcd02defe87",
     "https://bcr.bazel.build/modules/rules_cc/0.0.17/MODULE.bazel": "2ae1d8f4238ec67d7185d8861cb0a2cdf4bc608697c331b95bf990e69b62e64a",
@@ -101,39 +130,39 @@
     "https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
     "https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
     "https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
+    "https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
     "https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.0/MODULE.bazel": "b5c17f90458caae90d2ccd114c81970062946f49f355610ed89bebf954f5783c",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37",
     "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
     "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4",
-    "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.17/MODULE.bazel": "1849602c86cb60da8613d2de887f9566a6d354a6df6d7009f9d04a14402f9a84",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.17/source.json": "3832f45d145354049137c0090df04629d9c2b5493dc5c2bf46f1834040133a07",
     "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc",
+    "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642",
     "https://bcr.bazel.build/modules/rules_foreign_cc/0.14.0/MODULE.bazel": "56fb9a239503bab4183d06ba6cabb01cd73aae296ab499085b9193624a8a66e2",
     "https://bcr.bazel.build/modules/rules_foreign_cc/0.14.0/source.json": "64ccb6c4bff8afc336a24af2487b4557b8d2b13f981f2d8190983bc196b36a68",
     "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6",
     "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/MODULE.bazel": "40c97d1144356f52905566c55811f13b299453a14ac7769dfba2ac38192337a8",
-    "https://bcr.bazel.build/modules/rules_fuzzing/0.5.2/source.json": "c8b1e2c717646f1702290959a3302a178fb639d987ab61d548105019f11e527e",
     "https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
     "https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
-    "https://bcr.bazel.build/modules/rules_java/6.0.0/MODULE.bazel": "8a43b7df601a7ec1af61d79345c17b31ea1fedc6711fd4abfd013ea612978e39",
     "https://bcr.bazel.build/modules/rules_java/6.3.0/MODULE.bazel": "a97c7678c19f236a956ad260d59c86e10a463badb7eb2eda787490f4c969b963",
-    "https://bcr.bazel.build/modules/rules_java/6.4.0/MODULE.bazel": "e986a9fe25aeaa84ac17ca093ef13a4637f6107375f64667a15999f77db6c8f6",
     "https://bcr.bazel.build/modules/rules_java/6.5.2/MODULE.bazel": "1d440d262d0e08453fa0c4d8f699ba81609ed0e9a9a0f02cd10b3e7942e61e31",
     "https://bcr.bazel.build/modules/rules_java/7.10.0/MODULE.bazel": "530c3beb3067e870561739f1144329a21c851ff771cd752a49e06e3dc9c2e71a",
     "https://bcr.bazel.build/modules/rules_java/7.12.2/MODULE.bazel": "579c505165ee757a4280ef83cda0150eea193eed3bef50b1004ba88b99da6de6",
     "https://bcr.bazel.build/modules/rules_java/7.2.0/MODULE.bazel": "06c0334c9be61e6cef2c8c84a7800cef502063269a5af25ceb100b192453d4ab",
-    "https://bcr.bazel.build/modules/rules_java/7.3.2/MODULE.bazel": "50dece891cfdf1741ea230d001aa9c14398062f2b7c066470accace78e412bc2",
     "https://bcr.bazel.build/modules/rules_java/7.6.1/MODULE.bazel": "2f14b7e8a1aa2f67ae92bc69d1ec0fa8d9f827c4e17ff5e5f02e91caa3b2d0fe",
-    "https://bcr.bazel.build/modules/rules_java/8.14.0/MODULE.bazel": "717717ed40cc69994596a45aec6ea78135ea434b8402fb91b009b9151dd65615",
-    "https://bcr.bazel.build/modules/rules_java/8.14.0/source.json": "8a88c4ca9e8759da53cddc88123880565c520503321e2566b4e33d0287a3d4bc",
     "https://bcr.bazel.build/modules/rules_java/8.3.2/MODULE.bazel": "7336d5511ad5af0b8615fdc7477535a2e4e723a357b6713af439fe8cf0195017",
     "https://bcr.bazel.build/modules/rules_java/8.5.1/MODULE.bazel": "d8a9e38cc5228881f7055a6079f6f7821a073df3744d441978e7a43e20226939",
+    "https://bcr.bazel.build/modules/rules_java/8.6.1/MODULE.bazel": "f4808e2ab5b0197f094cabce9f4b006a27766beb6a9975931da07099560ca9c2",
+    "https://bcr.bazel.build/modules/rules_java/9.1.0/MODULE.bazel": "ee63f27e36a3fada80342869361182f120a9819c74320e8e65b1e04ba0cd7a9d",
+    "https://bcr.bazel.build/modules/rules_java/9.1.0/source.json": "da589573c1dee2c9ac4a568b301269a2e8191110ff0345c1a959fa7ea6c4dfd6",
     "https://bcr.bazel.build/modules/rules_jvm_external/4.4.2/MODULE.bazel": "a56b85e418c83eb1839819f0b515c431010160383306d13ec21959ac412d2fe7",
     "https://bcr.bazel.build/modules/rules_jvm_external/5.1/MODULE.bazel": "33f6f999e03183f7d088c9be518a63467dfd0be94a11d0055fe2d210f89aa909",
     "https://bcr.bazel.build/modules/rules_jvm_external/5.2/MODULE.bazel": "d9351ba35217ad0de03816ef3ed63f89d411349353077348a45348b096615036",
-    "https://bcr.bazel.build/modules/rules_jvm_external/5.3/MODULE.bazel": "bf93870767689637164657731849fb887ad086739bd5d360d90007a581d5527d",
-    "https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4",
     "https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0",
-    "https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197",
-    "https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59",
+    "https://bcr.bazel.build/modules/rules_jvm_external/6.7/MODULE.bazel": "e717beabc4d091ecb2c803c2d341b88590e9116b8bf7947915eeb33aab4f96dd",
+    "https://bcr.bazel.build/modules/rules_jvm_external/6.7/source.json": "5426f412d0a7fc6b611643376c7e4a82dec991491b9ce5cb1cfdd25fe2e92be4",
     "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3",
     "https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5",
     "https://bcr.bazel.build/modules/rules_license/0.0.3/MODULE.bazel": "627e9ab0247f7d1e05736b59dbb1b6871373de5ad31c3011880b4133cafd4bd0",
@@ -150,17 +179,22 @@
     "https://bcr.bazel.build/modules/rules_pkg/1.0.1/source.json": "bd82e5d7b9ce2d31e380dd9f50c111d678c3bdaca190cb76b0e1c71b05e1ba8a",
     "https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
     "https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
+    "https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483",
     "https://bcr.bazel.build/modules/rules_proto/6.0.2/MODULE.bazel": "ce916b775a62b90b61888052a416ccdda405212b6aaeb39522f7dc53431a5e73",
     "https://bcr.bazel.build/modules/rules_proto/7.0.2/MODULE.bazel": "bf81793bd6d2ad89a37a40693e56c61b0ee30f7a7fdbaf3eabbf5f39de47dea2",
-    "https://bcr.bazel.build/modules/rules_proto/7.0.2/source.json": "1e5e7260ae32ef4f2b52fd1d0de8d03b606a44c91b694d2f1afb1d3b28a48ce1",
+    "https://bcr.bazel.build/modules/rules_proto/7.1.0/MODULE.bazel": "002d62d9108f75bb807cd56245d45648f38275cb3a99dcd45dfb864c5d74cb96",
+    "https://bcr.bazel.build/modules/rules_proto/7.1.0/source.json": "39f89066c12c24097854e8f57ab8558929f9c8d474d34b2c00ac04630ad8940e",
     "https://bcr.bazel.build/modules/rules_python/0.10.2/MODULE.bazel": "cc82bc96f2997baa545ab3ce73f196d040ffb8756fd2d66125a530031cd90e5f",
     "https://bcr.bazel.build/modules/rules_python/0.23.1/MODULE.bazel": "49ffccf0511cb8414de28321f5fcf2a31312b47c40cc21577144b7447f2bf300",
     "https://bcr.bazel.build/modules/rules_python/0.25.0/MODULE.bazel": "72f1506841c920a1afec76975b35312410eea3aa7b63267436bfb1dd91d2d382",
     "https://bcr.bazel.build/modules/rules_python/0.28.0/MODULE.bazel": "cba2573d870babc976664a912539b320cbaa7114cd3e8f053c720171cde331ed",
     "https://bcr.bazel.build/modules/rules_python/0.31.0/MODULE.bazel": "93a43dc47ee570e6ec9f5779b2e64c1476a6ce921c48cc9a1678a91dd5f8fd58",
+    "https://bcr.bazel.build/modules/rules_python/0.33.2/MODULE.bazel": "3e036c4ad8d804a4dad897d333d8dce200d943df4827cb849840055be8d2e937",
     "https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
-    "https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7",
     "https://bcr.bazel.build/modules/rules_python/1.1.0/MODULE.bazel": "57e01abae22956eb96d891572490d20e07d983e0c065de0b2170cafe5053e788",
+    "https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
+    "https://bcr.bazel.build/modules/rules_python/1.4.1/MODULE.bazel": "8991ad45bdc25018301d6b7e1d3626afc3c8af8aaf4bc04f23d0b99c938b73a6",
+    "https://bcr.bazel.build/modules/rules_python/1.6.0/MODULE.bazel": "7e04ad8f8d5bea40451cf80b1bd8262552aa73f841415d20db96b7241bd027d8",
     "https://bcr.bazel.build/modules/rules_python/1.7.0/MODULE.bazel": "d01f995ecd137abf30238ad9ce97f8fc3ac57289c8b24bd0bf53324d937a14f8",
     "https://bcr.bazel.build/modules/rules_python/1.7.0/source.json": "028a084b65dcf8f4dc4f82f8778dbe65df133f234b316828a82e060d81bdce32",
     "https://bcr.bazel.build/modules/rules_shell/0.2.0/MODULE.bazel": "fda8a652ab3c7d8fee214de05e7a9916d8b28082234e8d2c0094505c5268ed3c",
@@ -169,14 +203,20 @@
     "https://bcr.bazel.build/modules/rules_shell/0.4.1/MODULE.bazel": "00e501db01bbf4e3e1dd1595959092c2fadf2087b2852d3f553b5370f5633592",
     "https://bcr.bazel.build/modules/rules_shell/0.6.1/MODULE.bazel": "72e76b0eea4e81611ef5452aa82b3da34caca0c8b7b5c0c9584338aa93bae26b",
     "https://bcr.bazel.build/modules/rules_shell/0.6.1/source.json": "20ec05cd5e592055e214b2da8ccb283c7f2a421ea0dc2acbf1aa792e11c03d0c",
+    "https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
+    "https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
+    "https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd",
+    "https://bcr.bazel.build/modules/rules_swift/3.1.2/MODULE.bazel": "72c8f5cf9d26427cee6c76c8e3853eb46ce6b0412a081b2b6db6e8ad56267400",
+    "https://bcr.bazel.build/modules/rules_swift/3.1.2/source.json": "e85761f3098a6faf40b8187695e3de6d97944e98abd0d8ce579cb2daf6319a66",
     "https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
     "https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
-    "https://bcr.bazel.build/modules/stardoc/0.5.6/MODULE.bazel": "c43dabc564990eeab55e25ed61c07a1aadafe9ece96a4efabb3f8bf9063b71ef",
     "https://bcr.bazel.build/modules/stardoc/0.6.2/MODULE.bazel": "7060193196395f5dd668eda046ccbeacebfd98efc77fed418dbe2b82ffaa39fd",
     "https://bcr.bazel.build/modules/stardoc/0.7.0/MODULE.bazel": "05e3d6d30c099b6770e97da986c53bd31844d7f13d41412480ea265ac9e8079c",
-    "https://bcr.bazel.build/modules/stardoc/0.7.1/MODULE.bazel": "3548faea4ee5dda5580f9af150e79d0f6aea934fc60c1cc50f4efdd9420759e7",
     "https://bcr.bazel.build/modules/stardoc/0.7.2/MODULE.bazel": "fc152419aa2ea0f51c29583fab1e8c99ddefd5b3778421845606ee628629e0e5",
     "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216",
+    "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
+    "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f",
+    "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/source.json": "5fba48bbe0ba48761f9e9f75f92876cafb5d07c0ce059cc7a8027416de94a05b",
     "https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
     "https://bcr.bazel.build/modules/tar.bzl/0.2.1/source.json": "600ac6ff61744667a439e7b814ae59c1f29632c3984fccf8000c64c9db8d7bb6",
     "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43",
@@ -190,13 +230,110 @@
   },
   "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": "YPkknXQDbryIvBRlzT1v/mQ8v4KvZhsmAmxP1pH5A78=",
+        "bzlTransitiveDigest": "HljGbKDagP11Zj2xu7uXH4csvPiytXXlNpFDe1PeuTs=",
         "usagesDigest": "BWWW2nG29mUymMGpRgfP+nxndKWaf1fCZZ33EDPaVIc=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "recordedInputs": [
+          "REPO_MAPPING:buildifier_prebuilt+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:buildifier_prebuilt+,bazel_tools bazel_tools"
+        ],
         "generatedRepoSpecs": {
           "buildifier_darwin_amd64": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_file",
@@ -314,28 +451,14 @@
               "assets_json": "[{\"arch\":\"amd64\",\"name\":\"buildifier\",\"platform\":\"darwin\",\"sha256\":\"687c49c318fb655970cf716eed3c7bfc9caeea4f2931a2fd36593c458de0c537\",\"version\":\"v7.1.2\"},{\"arch\":\"arm64\",\"name\":\"buildifier\",\"platform\":\"darwin\",\"sha256\":\"d0909b645496608fd6dfc67f95d9d3b01d90736d7b8c8ec41e802cb0b7ceae7c\",\"version\":\"v7.1.2\"},{\"arch\":\"amd64\",\"name\":\"buildifier\",\"platform\":\"linux\",\"sha256\":\"28285fe7e39ed23dc1a3a525dfcdccbc96c0034ff1d4277905d2672a71b38f13\",\"version\":\"v7.1.2\"},{\"arch\":\"arm64\",\"name\":\"buildifier\",\"platform\":\"linux\",\"sha256\":\"c22a44eee37b8927167ee6ee67573303f4e31171e7ec3a8ea021a6a660040437\",\"version\":\"v7.1.2\"},{\"arch\":\"amd64\",\"name\":\"buildifier\",\"platform\":\"windows\",\"sha256\":\"a8331515019d8d3e01baa1c76fda19e8e6e3e05532d4b0bce759bd759d0cafb7\",\"version\":\"v7.1.2\"},{\"arch\":\"amd64\",\"name\":\"buildozer\",\"platform\":\"darwin\",\"sha256\":\"90da5cf4f7db73007977a8c6bec23fa7022265978187e1da8df5edc91daf6ee1\",\"version\":\"v7.1.2\"},{\"arch\":\"arm64\",\"name\":\"buildozer\",\"platform\":\"darwin\",\"sha256\":\"bedff301bc51f04da46d2c8900c1753032ea88485af375a9f1b7bed0915558e0\",\"version\":\"v7.1.2\"},{\"arch\":\"amd64\",\"name\":\"buildozer\",\"platform\":\"linux\",\"sha256\":\"8d5c459ab21b411b8be059a8bdf59f0d3eabf9dff943d5eccb80e36e525cc09d\",\"version\":\"v7.1.2\"},{\"arch\":\"arm64\",\"name\":\"buildozer\",\"platform\":\"linux\",\"sha256\":\"a00d1790e8c92c5022d83e345d6629506836d73c23c5338d5f777589bfaed02d\",\"version\":\"v7.1.2\"},{\"arch\":\"amd64\",\"name\":\"buildozer\",\"platform\":\"windows\",\"sha256\":\"3a650e10f07787760889d7e5694924d881265ae2384499fd59ada7c39c02366e\",\"version\":\"v7.1.2\"}]"
             }
           }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "buildifier_prebuilt+",
-            "bazel_skylib",
-            "bazel_skylib+"
-          ],
-          [
-            "buildifier_prebuilt+",
-            "bazel_tools",
-            "bazel_tools"
-          ]
-        ]
+        }
       }
     },
     "@@emsdk+//:emscripten_cache.bzl%emscripten_cache": {
       "general": {
         "bzlTransitiveDigest": "uqDvXmpTNqW4+ie/Fk+xC3TrFrKvL+9hNtoP51Kt2oo=",
         "usagesDigest": "Jtpnpp8lxjZfLQo3Tt7L62pRRTIcHPBLoHYHYfkQrxM=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "recordedInputs": [],
         "generatedRepoSpecs": {
           "emscripten_cache": {
             "repoRuleId": "@@emsdk+//:emscripten_cache.bzl%_emscripten_cache_repository",
@@ -344,17 +467,27 @@
               "targets": []
             }
           }
-        },
-        "recordedRepoMappingEntries": []
+        }
       }
     },
     "@@emsdk+//:emscripten_deps.bzl%emscripten_deps": {
       "general": {
-        "bzlTransitiveDigest": "1X4T1VjJD8ivkANJWOhhthe3duPo3E6amm827VP4Gzk=",
+        "bzlTransitiveDigest": "+1O9FmrwgJwK/ywCKhwouNuu7NWC/hfkkzjuGp/1c9Q=",
         "usagesDigest": "f1E1WkCSGt50uwBJp/heJk2R/iAm39ixgGS93wpdywA=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "recordedInputs": [
+          "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:emsdk+,bazel_tools bazel_tools",
+          "REPO_MAPPING:emsdk+,rules_cc rules_cc+",
+          "REPO_MAPPING:protobuf+,proto_bazel_features bazel_features+",
+          "REPO_MAPPING:rules_cc+,bazel_skylib bazel_skylib+",
+          "REPO_MAPPING:rules_cc+,bazel_tools bazel_tools",
+          "REPO_MAPPING:rules_cc+,cc_compatibility_proxy rules_cc++compatibility_proxy+cc_compatibility_proxy",
+          "REPO_MAPPING:rules_cc+,com_google_protobuf protobuf+",
+          "REPO_MAPPING:rules_cc+,platforms platforms",
+          "REPO_MAPPING:rules_cc+,rules_cc rules_cc+",
+          "REPO_MAPPING:rules_cc++compatibility_proxy+cc_compatibility_proxy,rules_cc rules_cc+"
+        ],
         "generatedRepoSpecs": {
           "emscripten_bin_linux": {
             "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
@@ -406,48 +539,39 @@
               "url": "https://storage.googleapis.com/webassembly/emscripten-releases-builds/win/aaa43392544d695232b70eda706d751f18980c2a/wasm-binaries.zip"
             }
           }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "emsdk+",
-            "bazel_tools",
-            "bazel_tools"
-          ],
-          [
-            "emsdk+",
-            "rules_cc",
-            "rules_cc+"
-          ],
-          [
-            "rules_cc+",
-            "bazel_tools",
-            "bazel_tools"
-          ],
-          [
-            "rules_cc+",
-            "cc_compatibility_proxy",
-            "rules_cc++compatibility_proxy+cc_compatibility_proxy"
-          ],
-          [
-            "rules_cc+",
-            "rules_cc",
-            "rules_cc+"
-          ],
-          [
-            "rules_cc++compatibility_proxy+cc_compatibility_proxy",
-            "rules_cc",
-            "rules_cc+"
-          ]
-        ]
+        }
+      }
+    },
+    "@@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": "bzYvbsHj2ct8D8fQBqNNAJqAjmx6oxXp0japlQvDLjo=",
+        "bzlTransitiveDigest": "G54TxUUn6vxT4n5eIpNwbycPtXtiR354MSgHTcQAnHI=",
         "usagesDigest": "Eyh4mAOi6L+Nn/lY/wQBJclQrmBnWdQM+B4lZeq6azA=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "recordedInputs": [
+          "REPO_MAPPING:rules_foreign_cc+,bazel_tools bazel_tools",
+          "REPO_MAPPING:rules_foreign_cc+,rules_foreign_cc rules_foreign_cc+"
+        ],
         "generatedRepoSpecs": {
           "rules_foreign_cc_framework_toolchain_linux": {
             "repoRuleId": "@@rules_foreign_cc+//foreign_cc/private/framework:toolchain.bzl%framework_toolchain_repository",
@@ -810,28 +934,16 @@
               "tool": "ninja"
             }
           }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_foreign_cc+",
-            "bazel_tools",
-            "bazel_tools"
-          ],
-          [
-            "rules_foreign_cc+",
-            "rules_foreign_cc",
-            "rules_foreign_cc+"
-          ]
-        ]
+        }
       }
     },
     "@@rules_kotlin+//src/main/starlark/core/repositories:bzlmod_setup.bzl%rules_kotlin_extensions": {
       "general": {
-        "bzlTransitiveDigest": "OlvsB0HsvxbR8ZN+J9Vf00X/+WVz/Y/5Xrq2LgcVfdo=",
+        "bzlTransitiveDigest": "+Kp6j204mBZ3mxlIDDR0gBoP45BZ4jYRhRAcB8sU0qc=",
         "usagesDigest": "QI2z8ZUR+mqtbwsf2fLqYdJAkPOHdOV+tF2yVAUgRzw=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "recordedInputs": [
+          "REPO_MAPPING:rules_kotlin+,bazel_tools bazel_tools"
+        ],
         "generatedRepoSpecs": {
           "com_github_jetbrains_kotlin_git": {
             "repoRuleId": "@@rules_kotlin+//src/main/starlark/core/repositories:compiler.bzl%kotlin_compiler_git_repository",
@@ -879,23 +991,14 @@
               ]
             }
           }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_kotlin+",
-            "bazel_tools",
-            "bazel_tools"
-          ]
-        ]
+        }
       }
     },
     "@@rules_nodejs+//nodejs:extensions.bzl%node": {
       "general": {
         "bzlTransitiveDigest": "4pUxCNc22K4I+6+4Nxu52Hur12tFRfa1JMsN5mdDv60=",
-        "usagesDigest": "1PdljUSmnJF0C3nTMWMdndAlDqMjOzQHlOp2TAszTxk=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "usagesDigest": "aOq2mNhv/K35rllf0LkQnk575K/CZNf1RTfrNe1kDkY=",
+        "recordedInputs": [],
         "generatedRepoSpecs": {
           "nodejs_linux_amd64": {
             "repoRuleId": "@@rules_nodejs+//nodejs:repositories.bzl%_nodejs_repositories",
@@ -1019,17 +1122,31 @@
               "user_node_repository_name": "nodejs"
             }
           }
-        },
-        "recordedRepoMappingEntries": []
+        }
       }
     },
     "@@rules_python+//python/extensions:config.bzl%config": {
       "general": {
-        "bzlTransitiveDigest": "xaCns8Qt+8bJqVLy8r6nc/eL2AjEIX/vOdjqoh5xYac=",
+        "bzlTransitiveDigest": "dzD8Q2YmrP3fz8saWLHPmlwPLO91ImtTmP/c9JKTStM=",
         "usagesDigest": "ZVSXMAGpD+xzVNPuvF1IoLBkty7TROO0+akMapt1pAg=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "recordedInputs": [
+          "REPO_MAPPING:rules_python+,bazel_tools bazel_tools",
+          "REPO_MAPPING:rules_python+,pypi__build rules_python++config+pypi__build",
+          "REPO_MAPPING:rules_python+,pypi__click rules_python++config+pypi__click",
+          "REPO_MAPPING:rules_python+,pypi__colorama rules_python++config+pypi__colorama",
+          "REPO_MAPPING:rules_python+,pypi__importlib_metadata rules_python++config+pypi__importlib_metadata",
+          "REPO_MAPPING:rules_python+,pypi__installer rules_python++config+pypi__installer",
+          "REPO_MAPPING:rules_python+,pypi__more_itertools rules_python++config+pypi__more_itertools",
+          "REPO_MAPPING:rules_python+,pypi__packaging rules_python++config+pypi__packaging",
+          "REPO_MAPPING:rules_python+,pypi__pep517 rules_python++config+pypi__pep517",
+          "REPO_MAPPING:rules_python+,pypi__pip rules_python++config+pypi__pip",
+          "REPO_MAPPING:rules_python+,pypi__pip_tools rules_python++config+pypi__pip_tools",
+          "REPO_MAPPING:rules_python+,pypi__pyproject_hooks rules_python++config+pypi__pyproject_hooks",
+          "REPO_MAPPING:rules_python+,pypi__setuptools rules_python++config+pypi__setuptools",
+          "REPO_MAPPING:rules_python+,pypi__tomli rules_python++config+pypi__tomli",
+          "REPO_MAPPING:rules_python+,pypi__wheel rules_python++config+pypi__wheel",
+          "REPO_MAPPING:rules_python+,pypi__zipp rules_python++config+pypi__zipp"
+        ],
         "generatedRepoSpecs": {
           "rules_python_internal": {
             "repoRuleId": "@@rules_python+//python/private:internal_config_repo.bzl%internal_config_repo",
@@ -1173,98 +1290,17 @@
               "build_file_content": "package(default_visibility = [\"//visibility:public\"])\n\nload(\"@rules_python//python:py_library.bzl\", \"py_library\")\n\npy_library(\n    name = \"lib\",\n    srcs = glob([\"**/*.py\"]),\n    data = glob([\"**/*\"], exclude=[\n        # These entries include those put into user-installed dependencies by\n        # data_exclude to avoid non-determinism.\n        \"**/*.py\",\n        \"**/*.pyc\",\n        \"**/*.pyc.*\",  # During pyc creation, temp files named *.pyc.NNN are created\n        \"**/*.dist-info/RECORD\",\n        \"BUILD\",\n        \"WORKSPACE\",\n    ]),\n    # This makes this directory a top-level in the python import\n    # search path for anything that depends on this.\n    imports = [\".\"],\n)\n"
             }
           }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_python+",
-            "bazel_tools",
-            "bazel_tools"
-          ],
-          [
-            "rules_python+",
-            "pypi__build",
-            "rules_python++config+pypi__build"
-          ],
-          [
-            "rules_python+",
-            "pypi__click",
-            "rules_python++config+pypi__click"
-          ],
-          [
-            "rules_python+",
-            "pypi__colorama",
-            "rules_python++config+pypi__colorama"
-          ],
-          [
-            "rules_python+",
-            "pypi__importlib_metadata",
-            "rules_python++config+pypi__importlib_metadata"
-          ],
-          [
-            "rules_python+",
-            "pypi__installer",
-            "rules_python++config+pypi__installer"
-          ],
-          [
-            "rules_python+",
-            "pypi__more_itertools",
-            "rules_python++config+pypi__more_itertools"
-          ],
-          [
-            "rules_python+",
-            "pypi__packaging",
-            "rules_python++config+pypi__packaging"
-          ],
-          [
-            "rules_python+",
-            "pypi__pep517",
-            "rules_python++config+pypi__pep517"
-          ],
-          [
-            "rules_python+",
-            "pypi__pip",
-            "rules_python++config+pypi__pip"
-          ],
-          [
-            "rules_python+",
-            "pypi__pip_tools",
-            "rules_python++config+pypi__pip_tools"
-          ],
-          [
-            "rules_python+",
-            "pypi__pyproject_hooks",
-            "rules_python++config+pypi__pyproject_hooks"
-          ],
-          [
-            "rules_python+",
-            "pypi__setuptools",
-            "rules_python++config+pypi__setuptools"
-          ],
-          [
-            "rules_python+",
-            "pypi__tomli",
-            "rules_python++config+pypi__tomli"
-          ],
-          [
-            "rules_python+",
-            "pypi__wheel",
-            "rules_python++config+pypi__wheel"
-          ],
-          [
-            "rules_python+",
-            "pypi__zipp",
-            "rules_python++config+pypi__zipp"
-          ]
-        ]
+        }
       }
     },
     "@@rules_python+//python/uv:uv.bzl%uv": {
       "general": {
-        "bzlTransitiveDigest": "N8SCcKcL6KnzBLApxvY2jR9vhXjA2VCBZMLZfY3sDRA=",
+        "bzlTransitiveDigest": "ijW9KS7qsIY+yBVvJ+Nr1mzwQox09j13DnE3iIwaeTM=",
         "usagesDigest": "H8dQoNZcoqP+Mu0tHZTi4KHATzvNkM5ePuEqoQdklIU=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "recordedInputs": [
+          "REPO_MAPPING:rules_python+,bazel_tools bazel_tools",
+          "REPO_MAPPING:rules_python+,platforms platforms"
+        ],
         "generatedRepoSpecs": {
           "uv": {
             "repoRuleId": "@@rules_python+//python/uv/private:uv_toolchains_repo.bzl%uv_toolchains_repo",
@@ -1284,28 +1320,14 @@
               "toolchain_target_settings": {}
             }
           }
-        },
-        "recordedRepoMappingEntries": [
-          [
-            "rules_python+",
-            "bazel_tools",
-            "bazel_tools"
-          ],
-          [
-            "rules_python+",
-            "platforms",
-            "platforms"
-          ]
-        ]
+        }
       }
     },
     "@@yq.bzl+//yq:extensions.bzl%yq": {
       "general": {
         "bzlTransitiveDigest": "61Uz+o5PnlY0jJfPZEUNqsKxnM/UCLeWsn5VVCc8u5Y=",
         "usagesDigest": "1LyF9N6pw6Si4IXKugZfKYnx8CNnZniv05B2ubvTocg=",
-        "recordedFileInputs": {},
-        "recordedDirentsInputs": {},
-        "envVariables": {},
+        "recordedInputs": [],
         "generatedRepoSpecs": {
           "yq_darwin_amd64": {
             "repoRuleId": "@@yq.bzl+//yq/toolchain:platforms.bzl%yq_platform_repo",
@@ -1369,9 +1391,10 @@
               "user_repository_name": "yq"
             }
           }
-        },
-        "recordedRepoMappingEntries": []
+        }
       }
     }
-  }
+  },
+  "facts": {},
+  "factsVersions": {}
 }
--- a/hg-web/BUILD	Sat Feb 28 21:04:43 2026 -0800
+++ b/hg-web/BUILD	Sun Aug 02 16:44:06 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,7 +69,30 @@
   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",
 )
+
+sh_binary(
+  name = "deploy",
+  srcs = ["deploy.sh"],
+  data = [
+    ":hg_web_server_bundle",
+    "@bazel_tools//tools/bash/runfiles",
+  ],
+)
--- a/hg-web/README.md	Sat Feb 28 21:04:43 2026 -0800
+++ b/hg-web/README.md	Sun Aug 02 16:44:06 2026 -0700
@@ -1,34 +1,305 @@
 # 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.
+
+## Publishing
+
+Push committed Mercurial changes to the configured server:
+
+```bash
+hg push
+```
+
+Then update and deploy on the server:
+
+```bash
+ssh -t [email protected] \
+  'cd ~/zenbu && hg update default && bazel run //hg-web:deploy'
+```
+
+The SSH key may prompt for its passphrase. `//hg-web:deploy` builds the release
+bundle through Bazel before running the atomic promotion, health check, and
+rollback workflow.
+
+## Forge capability tree
 
-The C server (`main.c`) provides:
+```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 Actions-like subsystem should extend the existing server rather than
+replace it:
 
-- `GET /api/repo/list?path=` - List directory contents
-- `GET /api/repo/file?path=` - Fetch file contents
+```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
+```
+
+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	Sat Feb 28 21:04:43 2026 -0800
+++ b/hg-web/deploy.sh	Sun Aug 02 16:44:06 2026 -0700
@@ -1,15 +1,110 @@
-#!/bin/bash
-# sudo groupadd zenbu_team -- already added
-# sudo useradd -r -s /usr/sbin/nologin -G zenbu_team hg_web_server
-bazel build -c opt //hg-web:hg_web_server_bundle
+#!/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="${BUILD_WORKSPACE_DIRECTORY:-$(hg root)}"
+cd "$workspace"
+
+if [[ -n "${BUILD_WORKSPACE_DIRECTORY:-}" ]]; then
+  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
+  bundle_dir="$(rlocation _main/hg-web/hg_web_server_bundle)"
+else
+  bazel build -c opt //hg-web:hg_web_server_bundle
+  bundle_dir="bazel-bin/hg-web/hg_web_server_bundle"
+fi
+
+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"
+previous_release=""
+promoted=0
 
-# 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
+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"
+}
 
-# Swap
-sudo rm -rf /opt/hg_web_server_bundle_active
-sudo mv /opt/hg_web_server_bundle_new /opt/hg_web_server_bundle_active
+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
 
-sudo systemctl restart hg_web_server.service
-echo "Deployment complete!"
+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"
+
+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
+
+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 16:44:06 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 16:44:06 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 16:44:06 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 16:44:06 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 16:44:06 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 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/hg-web/main.c	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/hg-web/src/components/app.tsx	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/hg-web/src/components/directory-browser.tsx	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/hg-web/src/components/graph.tsx	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/hg-web/src/index.css	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/markdown_converter/markdown_to_html.c	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/markdown_converter/tests/BUILD	Sun Aug 02 16:44:06 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 16:44:06 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;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schwab_trader/BUILD	Sun Aug 02 16:44:06 2026 -0700
@@ -0,0 +1,66 @@
+load("@rules_python//python:py_binary.bzl", "py_binary")
+load("@rules_python//python:py_library.bzl", "py_library")
+load("@rules_python//python:py_test.bzl", "py_test")
+
+py_library(
+    name = "schwab_client",
+    srcs = [
+        "__init__.py",
+        "schwab_client.py",
+    ],
+    visibility = ["//visibility:public"],
+)
+
+py_library(
+    name = "schwab_dashboard_lib",
+    srcs = [
+        "__init__.py",
+        "dashboard.py",
+    ],
+    deps = [":schwab_client"],
+)
+
+py_library(
+    name = "schwab_dashboard_server_lib",
+    srcs = [
+        "__init__.py",
+        "dashboard_server.py",
+    ],
+    deps = [
+        ":schwab_client",
+        ":schwab_dashboard_lib",
+    ],
+)
+
+py_binary(
+    name = "schwab_cli",
+    srcs = ["schwab_cli.py"],
+    main = "schwab_cli.py",
+    deps = [":schwab_client"],
+)
+
+py_binary(
+    name = "schwab_dashboard",
+    srcs = ["dashboard_server.py"],
+    main = "dashboard_server.py",
+    deps = [
+        ":schwab_client",
+        ":schwab_dashboard_lib",
+    ],
+)
+
+py_test(
+    name = "schwab_client_test",
+    srcs = ["schwab_client_test.py"],
+    deps = [":schwab_client"],
+)
+
+py_test(
+    name = "dashboard_test",
+    srcs = ["dashboard_test.py"],
+    deps = [
+        ":schwab_client",
+        ":schwab_dashboard_lib",
+        ":schwab_dashboard_server_lib",
+    ],
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schwab_trader/README.md	Sun Aug 02 16:44:06 2026 -0700
@@ -0,0 +1,160 @@
+# Schwab Trader
+
+Small Bazel-built helper for the Schwab Trader API. This project is only plumbing: it helps authenticate, inspect accounts, build explicit user-specified stock orders, and submit them only when a live-trade confirmation flag is present.
+
+It does not recommend trades, choose symbols, allocate portfolio risk, or automate a strategy.
+
+## Can Schwab accounts be traded by API?
+
+Yes. Schwab provides the Trader API through the Schwab Developer Portal. The flow is OAuth 2.0:
+
+1. Create an app in the Schwab Developer Portal.
+2. Get an app key and app secret.
+3. Register a redirect URI.
+4. Open the OAuth authorization URL and log in through Schwab.
+5. Exchange the returned `code` for an access token and refresh token.
+6. Use the access token against `https://api.schwabapi.com/trader/v1`.
+
+Do not use username/password scraping or browser automation. The supported path is OAuth tokens.
+
+Useful endpoints this project targets:
+
+- `GET https://api.schwabapi.com/trader/v1/accounts/accountNumbers`
+- `GET https://api.schwabapi.com/trader/v1/accounts`
+- `GET https://api.schwabapi.com/trader/v1/accounts/{accountHash}`
+- `POST https://api.schwabapi.com/trader/v1/accounts/{accountHash}/orders`
+
+Schwab uses account hashes for trading API calls. Fetch them with `account-numbers` before placing any order.
+
+## Build and test
+
+From the repo root:
+
+```bash
+bazel build //schwab_trader:schwab_cli
+bazel test //schwab_trader:schwab_client_test
+bazel build //schwab_trader:schwab_dashboard
+bazel test //schwab_trader:dashboard_test
+```
+
+## Configuration
+
+Set these environment variables:
+
+```bash
+export SCHWAB_APP_KEY="your-schwab-app-key"
+export SCHWAB_APP_SECRET="your-schwab-app-secret"
+export SCHWAB_REDIRECT_URI="https://127.0.0.1"
+export SCHWAB_TOKEN_FILE="$HOME/.config/zenbu/schwab_tokens.json"
+```
+
+`SCHWAB_TOKEN_FILE` is optional and defaults to `~/.config/zenbu/schwab_tokens.json`. Token files are written with `0600` permissions.
+
+## OAuth bootstrap
+
+Print the Schwab login URL:
+
+```bash
+bazel run //schwab_trader:schwab_cli -- auth-url
+```
+
+Open it, log in through Schwab, authorize the app, then copy the full callback URL or just its `code` parameter:
+
+```bash
+bazel run //schwab_trader:schwab_cli -- token --code 'https://127.0.0.1/?code=...'
+```
+
+Refresh later:
+
+```bash
+bazel run //schwab_trader:schwab_cli -- refresh
+```
+
+## Account discovery
+
+```bash
+bazel run //schwab_trader:schwab_cli -- account-numbers
+bazel run //schwab_trader:schwab_cli -- accounts --positions
+```
+
+## Order dry run
+
+Build a stock order payload without sending it:
+
+```bash
+bazel run //schwab_trader:schwab_cli -- build-equity-order \
+  --action BUY \
+  --symbol AAPL \
+  --quantity 1 \
+  --order-type MARKET
+```
+
+`place-equity-order` is also dry-run by default:
+
+```bash
+bazel run //schwab_trader:schwab_cli -- place-equity-order \
+  --account-hash "$SCHWAB_ACCOUNT_HASH" \
+  --action SELL \
+  --symbol AAPL \
+  --quantity 1 \
+  --order-type LIMIT \
+  --price 250.00
+```
+
+To actually submit an order, both safety flags are required:
+
+```bash
+bazel run //schwab_trader:schwab_cli -- place-equity-order \
+  --account-hash "$SCHWAB_ACCOUNT_HASH" \
+  --action BUY \
+  --symbol AAPL \
+  --quantity 1 \
+  --order-type MARKET \
+  --live \
+  --confirm-live-trade
+```
+
+Use live trading only after checking the generated JSON, account hash, symbol, quantity, order type, and Schwab API permissions.
+
+## Local sentiment dashboard
+
+Run the local dashboard:
+
+```bash
+bazel run //schwab_trader:schwab_dashboard
+```
+
+Then open:
+
+```text
+http://127.0.0.1:8765
+```
+
+The dashboard is intentionally local-first and read/paper-trade oriented. It has no live-trade endpoint. It shows:
+
+- Schwab environment/token status without exposing token values
+- risk settings such as profit target, stop loss, confidence threshold, and max paper-trade dollars
+- manually added social evidence from Reddit/X/news/etc.
+- deterministic sentiment signals and confidence
+- paper trades with simple risk rejection
+- audit events
+
+The dashboard stores state in:
+
+```text
+~/.local/share/zenbu/schwab_trader/dashboard.db
+```
+
+Override it when testing:
+
+```bash
+bazel run //schwab_trader:schwab_dashboard -- --db /tmp/schwab_dashboard.db
+```
+
+Social evidence can be added through the page or API:
+
+```bash
+curl -X POST http://127.0.0.1:8765/api/evidence \
+  -H 'Content-Type: application/json' \
+  -d '{"source":"reddit","symbol":"AAPL","text":"$AAPL bullish strong growth","engagement":42}'
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schwab_trader/__init__.py	Sun Aug 02 16:44:06 2026 -0700
@@ -0,0 +1,2 @@
+"""Schwab Trader API helpers for Zenbu."""
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schwab_trader/dashboard.py	Sun Aug 02 16:44:06 2026 -0700
@@ -0,0 +1,598 @@
+from __future__ import annotations
+
+import json
+import os
+import re
+import sqlite3
+import time
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from schwab_trader.schwab_client import DEFAULT_TOKEN_FILE, SchwabConfig, load_tokens
+
+
+DEFAULT_DASHBOARD_DB = "~/.local/share/zenbu/schwab_trader/dashboard.db"
+
+DEFAULT_SETTINGS: dict[str, Any] = {
+    "profit_target_pct": 3.0,
+    "stop_loss_pct": 2.0,
+    "max_trade_dollars": 500.0,
+    "max_account_pct": 2.0,
+    "max_open_positions": 3,
+    "min_confidence": 0.60,
+    "min_evidence_count": 3,
+    "min_source_count": 2,
+    "cooldown_minutes": 60,
+    "market_hours_only": True,
+    "max_daily_loss_dollars": 250.0,
+    "day_trade_limit": 3,
+    "live_trading_enabled": False,
+    "require_manual_confirmation": True,
+    "llm_enabled": False,
+    "allowlist": [],
+    "blocklist": [],
+}
+
+POSITIVE_WORDS = {
+    "beat",
+    "beats",
+    "bull",
+    "bullish",
+    "buy",
+    "calls",
+    "growth",
+    "hype",
+    "moon",
+    "mooning",
+    "profit",
+    "rally",
+    "strong",
+    "surge",
+    "up",
+    "winner",
+}
+
+NEGATIVE_WORDS = {
+    "bear",
+    "bearish",
+    "crash",
+    "dump",
+    "fall",
+    "falling",
+    "fraud",
+    "lawsuit",
+    "loss",
+    "miss",
+    "puts",
+    "risk",
+    "sell",
+    "short",
+    "weak",
+}
+
+SYMBOL_RE = re.compile(r"(?<![A-Z0-9])\$?([A-Z]{1,5})(?![A-Z0-9])")
+COMMON_WORDS = {
+    "A",
+    "AI",
+    "AM",
+    "API",
+    "CEO",
+    "CFO",
+    "DD",
+    "ETF",
+    "GDP",
+    "IPO",
+    "IRS",
+    "LLM",
+    "PDT",
+    "SEC",
+    "USA",
+    "USD",
+}
+
+
+@dataclass(frozen=True)
+class EvidenceInput:
+    source: str
+    text: str
+    symbol: str | None = None
+    url: str | None = None
+    engagement: float = 0.0
+    raw: dict[str, Any] | None = None
+
+
+class DashboardStore:
+    def __init__(self, db_path: Path | str | None = None) -> None:
+        if db_path is None:
+            db_path = os.environ.get("SCHWAB_DASHBOARD_DB", DEFAULT_DASHBOARD_DB)
+        self.db_path = Path(db_path).expanduser()
+        self.db_path.parent.mkdir(parents=True, exist_ok=True)
+        self._init_db()
+
+    def get_status(self) -> dict[str, Any]:
+        token_file = Path(os.environ.get("SCHWAB_TOKEN_FILE", DEFAULT_TOKEN_FILE)).expanduser()
+        token_status: dict[str, Any] = {
+            "path": str(token_file),
+            "exists": token_file.exists(),
+            "access_token_present": False,
+            "refresh_token_present": False,
+            "saved_at": None,
+            "age_seconds": None,
+        }
+        if token_file.exists():
+            try:
+                tokens = load_tokens(token_file)
+                saved_at = tokens.get("saved_at")
+                token_status.update(
+                    {
+                        "access_token_present": bool(tokens.get("access_token")),
+                        "refresh_token_present": bool(tokens.get("refresh_token")),
+                        "saved_at": saved_at,
+                        "age_seconds": int(time.time()) - int(saved_at) if saved_at else None,
+                    }
+                )
+            except (OSError, ValueError, TypeError) as error:
+                token_status["error"] = str(error)
+
+        env_status = {
+            "SCHWAB_APP_KEY": bool(os.environ.get("SCHWAB_APP_KEY")),
+            "SCHWAB_APP_SECRET": bool(os.environ.get("SCHWAB_APP_SECRET")),
+            "SCHWAB_REDIRECT_URI": bool(os.environ.get("SCHWAB_REDIRECT_URI")),
+        }
+
+        return {
+            "service": "schwab-dashboard",
+            "database": str(self.db_path),
+            "env": env_status,
+            "tokens": token_status,
+            "live_trading_enabled": False,
+            "live_trading_note": "Dashboard has no live-trade endpoint; use CLI dry-run/manual confirmation flow.",
+            "counts": self.get_counts(),
+        }
+
+    def get_counts(self) -> dict[str, int]:
+        with self._connect() as conn:
+            return {
+                "evidence": self._count(conn, "evidence"),
+                "signals": self._count(conn, "signals"),
+                "paper_trades": self._count(conn, "paper_trades"),
+                "audit_events": self._count(conn, "audit"),
+            }
+
+    def get_settings(self) -> dict[str, Any]:
+        settings = dict(DEFAULT_SETTINGS)
+        with self._connect() as conn:
+            for row in conn.execute("SELECT key, value_json FROM settings"):
+                settings[row["key"]] = json.loads(row["value_json"])
+        return settings
+
+    def update_settings(self, updates: dict[str, Any]) -> dict[str, Any]:
+        allowed = set(DEFAULT_SETTINGS)
+        unknown = sorted(set(updates) - allowed)
+        if unknown:
+            raise ValueError("Unknown settings: " + ", ".join(unknown))
+
+        current = self.get_settings()
+        current.update(updates)
+        self._validate_settings(current)
+
+        with self._connect() as conn:
+            for key, value in current.items():
+                conn.execute(
+                    """
+                    INSERT INTO settings(key, value_json)
+                    VALUES (?, ?)
+                    ON CONFLICT(key) DO UPDATE SET value_json = excluded.value_json
+                    """,
+                    (key, json.dumps(value, sort_keys=True)),
+                )
+            conn.commit()
+        self.add_audit("settings.updated", "Dashboard settings updated", updates)
+        return current
+
+    def add_evidence(self, item: EvidenceInput) -> dict[str, Any]:
+        source = item.source.strip().lower()
+        text = item.text.strip()
+        if not source:
+            raise ValueError("source is required")
+        if not text:
+            raise ValueError("text is required")
+
+        symbol = normalize_symbol(item.symbol) if item.symbol else extract_symbol(text)
+        if not symbol:
+            raise ValueError("symbol is required or must be detectable as a ticker in text")
+
+        sentiment_score = score_sentiment(text)
+        created_at = int(time.time())
+
+        with self._connect() as conn:
+            cursor = conn.execute(
+                """
+                INSERT INTO evidence(
+                  source, symbol, url, text, engagement, sentiment_score, raw_json, created_at
+                )
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+                """,
+                (
+                    source,
+                    symbol,
+                    item.url,
+                    text,
+                    float(item.engagement),
+                    sentiment_score,
+                    json.dumps(item.raw or {}, sort_keys=True),
+                    created_at,
+                ),
+            )
+            evidence_id = int(cursor.lastrowid)
+            conn.commit()
+
+        signal = self.recompute_signal(symbol)
+        self.add_audit(
+            "evidence.added",
+            f"Added {source} evidence for {symbol}",
+            {"evidence_id": evidence_id, "symbol": symbol, "signal": signal},
+        )
+        return {"id": evidence_id, "symbol": symbol, "sentiment_score": sentiment_score, "signal": signal}
+
+    def list_evidence(self, limit: int = 100) -> list[dict[str, Any]]:
+        with self._connect() as conn:
+            rows = conn.execute(
+                """
+                SELECT id, source, symbol, url, text, engagement, sentiment_score, created_at
+                FROM evidence
+                ORDER BY id DESC
+                LIMIT ?
+                """,
+                (limit,),
+            ).fetchall()
+        return [dict(row) for row in rows]
+
+    def recompute_signal(self, symbol: str) -> dict[str, Any]:
+        symbol = normalize_symbol(symbol)
+        settings = self.get_settings()
+        with self._connect() as conn:
+            rows = conn.execute(
+                """
+                SELECT source, sentiment_score, engagement, created_at
+                FROM evidence
+                WHERE symbol = ?
+                ORDER BY id DESC
+                LIMIT 100
+                """,
+                (symbol,),
+            ).fetchall()
+
+            evidence_count = len(rows)
+            source_count = len({row["source"] for row in rows})
+            weighted_total = 0.0
+            weight_sum = 0.0
+            for row in rows:
+                engagement_weight = min(5.0, 1.0 + max(0.0, float(row["engagement"])) / 100.0)
+                weighted_total += float(row["sentiment_score"]) * engagement_weight
+                weight_sum += engagement_weight
+            sentiment_score = weighted_total / weight_sum if weight_sum else 0.0
+            confidence = compute_confidence(sentiment_score, evidence_count, source_count)
+            action = decide_signal_action(symbol, sentiment_score, confidence, evidence_count, source_count, settings)
+            summary = summarize_signal(symbol, sentiment_score, confidence, evidence_count, source_count, action)
+            updated_at = int(time.time())
+
+            conn.execute(
+                """
+                INSERT INTO signals(
+                  symbol, sentiment_score, confidence, evidence_count, source_count,
+                  action, summary, updated_at
+                )
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+                ON CONFLICT(symbol) DO UPDATE SET
+                  sentiment_score = excluded.sentiment_score,
+                  confidence = excluded.confidence,
+                  evidence_count = excluded.evidence_count,
+                  source_count = excluded.source_count,
+                  action = excluded.action,
+                  summary = excluded.summary,
+                  updated_at = excluded.updated_at
+                """,
+                (
+                    symbol,
+                    sentiment_score,
+                    confidence,
+                    evidence_count,
+                    source_count,
+                    action,
+                    summary,
+                    updated_at,
+                ),
+            )
+            conn.commit()
+
+        return {
+            "symbol": symbol,
+            "sentiment_score": round(sentiment_score, 4),
+            "confidence": round(confidence, 4),
+            "evidence_count": evidence_count,
+            "source_count": source_count,
+            "action": action,
+            "summary": summary,
+            "updated_at": updated_at,
+        }
+
+    def list_signals(self) -> list[dict[str, Any]]:
+        with self._connect() as conn:
+            rows = conn.execute(
+                """
+                SELECT symbol, sentiment_score, confidence, evidence_count, source_count,
+                       action, summary, updated_at
+                FROM signals
+                ORDER BY confidence DESC, updated_at DESC
+                """
+            ).fetchall()
+        return [dict(row) for row in rows]
+
+    def add_paper_trade(self, payload: dict[str, Any]) -> dict[str, Any]:
+        symbol = normalize_symbol(str(payload.get("symbol", "")))
+        action = str(payload.get("action", "")).upper()
+        quantity = float(payload.get("quantity", 0))
+        price = float(payload.get("price", 0))
+        reason = str(payload.get("reason", "manual paper trade")).strip()
+
+        if action not in {"BUY", "SELL"}:
+            raise ValueError("action must be BUY or SELL")
+        if not symbol:
+            raise ValueError("symbol is required")
+        if quantity <= 0:
+            raise ValueError("quantity must be greater than zero")
+        if price <= 0:
+            raise ValueError("price must be greater than zero")
+
+        settings = self.get_settings()
+        notional = quantity * price
+        status = "accepted"
+        if notional > float(settings["max_trade_dollars"]):
+            status = "rejected_max_trade_dollars"
+        elif symbol in {normalize_symbol(s) for s in settings["blocklist"]}:
+            status = "rejected_blocklist"
+
+        created_at = int(time.time())
+        with self._connect() as conn:
+            cursor = conn.execute(
+                """
+                INSERT INTO paper_trades(symbol, action, quantity, price, notional, status, reason, created_at)
+                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+                """,
+                (symbol, action, quantity, price, notional, status, reason, created_at),
+            )
+            trade_id = int(cursor.lastrowid)
+            conn.commit()
+
+        result = {
+            "id": trade_id,
+            "symbol": symbol,
+            "action": action,
+            "quantity": quantity,
+            "price": price,
+            "notional": notional,
+            "status": status,
+            "reason": reason,
+            "created_at": created_at,
+        }
+        self.add_audit("paper_trade.created", f"Paper trade {status}: {action} {quantity} {symbol}", result)
+        return result
+
+    def list_paper_trades(self, limit: int = 100) -> list[dict[str, Any]]:
+        with self._connect() as conn:
+            rows = conn.execute(
+                """
+                SELECT id, symbol, action, quantity, price, notional, status, reason, created_at
+                FROM paper_trades
+                ORDER BY id DESC
+                LIMIT ?
+                """,
+                (limit,),
+            ).fetchall()
+        return [dict(row) for row in rows]
+
+    def add_audit(self, event_type: str, message: str, payload: dict[str, Any] | None = None) -> None:
+        with self._connect() as conn:
+            conn.execute(
+                """
+                INSERT INTO audit(event_type, message, payload_json, created_at)
+                VALUES (?, ?, ?, ?)
+                """,
+                (event_type, message, json.dumps(payload or {}, sort_keys=True), int(time.time())),
+            )
+            conn.commit()
+
+    def list_audit(self, limit: int = 100) -> list[dict[str, Any]]:
+        with self._connect() as conn:
+            rows = conn.execute(
+                """
+                SELECT id, event_type, message, payload_json, created_at
+                FROM audit
+                ORDER BY id DESC
+                LIMIT ?
+                """,
+                (limit,),
+            ).fetchall()
+        events = []
+        for row in rows:
+            event = dict(row)
+            event["payload"] = json.loads(event.pop("payload_json"))
+            events.append(event)
+        return events
+
+    def _init_db(self) -> None:
+        with self._connect() as conn:
+            conn.executescript(
+                """
+                CREATE TABLE IF NOT EXISTS settings (
+                  key TEXT PRIMARY KEY,
+                  value_json TEXT NOT NULL
+                );
+
+                CREATE TABLE IF NOT EXISTS evidence (
+                  id INTEGER PRIMARY KEY AUTOINCREMENT,
+                  source TEXT NOT NULL,
+                  symbol TEXT NOT NULL,
+                  url TEXT,
+                  text TEXT NOT NULL,
+                  engagement REAL NOT NULL DEFAULT 0,
+                  sentiment_score REAL NOT NULL,
+                  raw_json TEXT NOT NULL DEFAULT '{}',
+                  created_at INTEGER NOT NULL
+                );
+
+                CREATE INDEX IF NOT EXISTS idx_evidence_symbol_created
+                ON evidence(symbol, created_at);
+
+                CREATE TABLE IF NOT EXISTS signals (
+                  symbol TEXT PRIMARY KEY,
+                  sentiment_score REAL NOT NULL,
+                  confidence REAL NOT NULL,
+                  evidence_count INTEGER NOT NULL,
+                  source_count INTEGER NOT NULL,
+                  action TEXT NOT NULL,
+                  summary TEXT NOT NULL,
+                  updated_at INTEGER NOT NULL
+                );
+
+                CREATE TABLE IF NOT EXISTS paper_trades (
+                  id INTEGER PRIMARY KEY AUTOINCREMENT,
+                  symbol TEXT NOT NULL,
+                  action TEXT NOT NULL,
+                  quantity REAL NOT NULL,
+                  price REAL NOT NULL,
+                  notional REAL NOT NULL,
+                  status TEXT NOT NULL,
+                  reason TEXT NOT NULL,
+                  created_at INTEGER NOT NULL
+                );
+
+                CREATE TABLE IF NOT EXISTS audit (
+                  id INTEGER PRIMARY KEY AUTOINCREMENT,
+                  event_type TEXT NOT NULL,
+                  message TEXT NOT NULL,
+                  payload_json TEXT NOT NULL,
+                  created_at INTEGER NOT NULL
+                );
+                """
+            )
+            for key, value in DEFAULT_SETTINGS.items():
+                conn.execute(
+                    "INSERT OR IGNORE INTO settings(key, value_json) VALUES (?, ?)",
+                    (key, json.dumps(value, sort_keys=True)),
+                )
+            conn.commit()
+
+    def _connect(self) -> sqlite3.Connection:
+        conn = sqlite3.connect(self.db_path)
+        conn.row_factory = sqlite3.Row
+        return conn
+
+    @staticmethod
+    def _count(conn: sqlite3.Connection, table: str) -> int:
+        return int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
+
+    @staticmethod
+    def _validate_settings(settings: dict[str, Any]) -> None:
+        positive_numbers = [
+            "profit_target_pct",
+            "stop_loss_pct",
+            "max_trade_dollars",
+            "max_account_pct",
+            "max_daily_loss_dollars",
+        ]
+        for key in positive_numbers:
+            if float(settings[key]) <= 0:
+                raise ValueError(f"{key} must be greater than zero")
+        if not 0 <= float(settings["min_confidence"]) <= 1:
+            raise ValueError("min_confidence must be between 0 and 1")
+        if int(settings["min_evidence_count"]) < 1:
+            raise ValueError("min_evidence_count must be at least 1")
+        if int(settings["min_source_count"]) < 1:
+            raise ValueError("min_source_count must be at least 1")
+        if bool(settings["live_trading_enabled"]):
+            raise ValueError("live_trading_enabled cannot be enabled from the dashboard")
+
+
+def normalize_symbol(value: str) -> str:
+    symbol = value.strip().upper().lstrip("$")
+    if not re.fullmatch(r"[A-Z]{1,5}", symbol):
+        raise ValueError("symbol must be 1-5 letters")
+    return symbol
+
+
+def extract_symbol(text: str) -> str | None:
+    for match in SYMBOL_RE.finditer(text.upper()):
+        symbol = match.group(1)
+        if symbol not in COMMON_WORDS:
+            return symbol
+    return None
+
+
+def score_sentiment(text: str) -> float:
+    words = re.findall(r"[a-zA-Z']+", text.lower())
+    positive = sum(1 for word in words if word in POSITIVE_WORDS)
+    negative = sum(1 for word in words if word in NEGATIVE_WORDS)
+    total = positive + negative
+    if total == 0:
+        return 0.0
+    return max(-1.0, min(1.0, (positive - negative) / total))
+
+
+def compute_confidence(sentiment_score: float, evidence_count: int, source_count: int) -> float:
+    evidence_component = min(0.35, evidence_count * 0.07)
+    source_component = min(0.25, source_count * 0.10)
+    sentiment_component = min(0.20, abs(sentiment_score) * 0.20)
+    return min(0.95, 0.20 + evidence_component + source_component + sentiment_component)
+
+
+def decide_signal_action(
+    symbol: str,
+    sentiment_score: float,
+    confidence: float,
+    evidence_count: int,
+    source_count: int,
+    settings: dict[str, Any],
+) -> str:
+    blocklist = {normalize_symbol(item) for item in settings.get("blocklist", [])}
+    allowlist = {normalize_symbol(item) for item in settings.get("allowlist", [])}
+    if symbol in blocklist:
+        return "NO_TRADE_BLOCKED"
+    if allowlist and symbol not in allowlist:
+        return "NO_TRADE_NOT_ALLOWLISTED"
+    if evidence_count < int(settings["min_evidence_count"]):
+        return "NO_TRADE_NEEDS_EVIDENCE"
+    if source_count < int(settings["min_source_count"]):
+        return "NO_TRADE_NEEDS_SOURCE_DIVERSITY"
+    if confidence < float(settings["min_confidence"]):
+        return "NO_TRADE_LOW_CONFIDENCE"
+    if sentiment_score >= 0.25:
+        return "CONSIDER_BUY"
+    if sentiment_score <= -0.25:
+        return "CONSIDER_SELL"
+    return "WATCH"
+
+
+def summarize_signal(
+    symbol: str,
+    sentiment_score: float,
+    confidence: float,
+    evidence_count: int,
+    source_count: int,
+    action: str,
+) -> str:
+    direction = "positive" if sentiment_score > 0 else "negative" if sentiment_score < 0 else "neutral"
+    return (
+        f"{symbol} has {direction} social sentiment from {evidence_count} evidence item(s) "
+        f"across {source_count} source(s). Confidence is {confidence:.0%}. Action: {action}."
+    )
+
+
+def get_config_if_available() -> SchwabConfig | None:
+    try:
+        return SchwabConfig.from_env()
+    except Exception:
+        return None
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schwab_trader/dashboard_server.py	Sun Aug 02 16:44:06 2026 -0700
@@ -0,0 +1,331 @@
+from __future__ import annotations
+
+import argparse
+import json
+from http import HTTPStatus
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from typing import Any
+from urllib.parse import parse_qs, urlparse
+
+from schwab_trader.dashboard import DashboardStore, EvidenceInput, get_config_if_available
+from schwab_trader.schwab_client import SchwabError, get_account, get_account_numbers, get_accounts, load_tokens
+
+
+INDEX_HTML = """<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Schwab Sentiment Dashboard</title>
+  <style>
+    :root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; background: #0b1020; color: #eef2ff; }
+    body { margin: 0; }
+    header { padding: 24px; background: linear-gradient(135deg, #172554, #0f172a); border-bottom: 1px solid #334155; }
+    h1 { margin: 0 0 8px; font-size: 28px; }
+    main { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); padding: 16px; }
+    section { background: #111827; border: 1px solid #334155; border-radius: 14px; padding: 16px; box-shadow: 0 10px 25px #0004; }
+    h2 { margin-top: 0; font-size: 18px; }
+    label { display: block; margin: 10px 0 4px; color: #cbd5e1; }
+    input, textarea, select, button { width: 100%; box-sizing: border-box; border-radius: 8px; border: 1px solid #475569; background: #020617; color: #f8fafc; padding: 10px; }
+    button { margin-top: 12px; background: #2563eb; border: 0; font-weight: 700; cursor: pointer; }
+    button.secondary { background: #334155; }
+    pre { overflow: auto; white-space: pre-wrap; word-break: break-word; background: #020617; padding: 12px; border-radius: 8px; }
+    table { width: 100%; border-collapse: collapse; font-size: 13px; }
+    th, td { border-bottom: 1px solid #334155; padding: 8px; text-align: left; vertical-align: top; }
+    .ok { color: #86efac; }
+    .warn { color: #fbbf24; }
+    .bad { color: #fca5a5; }
+    .span2 { grid-column: 1 / -1; }
+  </style>
+</head>
+<body>
+  <header>
+    <h1>Schwab Sentiment Dashboard</h1>
+    <div>This is local-first and read/paper-trade focused. There is no live-trade endpoint in this dashboard.</div>
+  </header>
+  <main>
+    <section>
+      <h2>Status</h2>
+      <button onclick="loadAll()">Refresh</button>
+      <pre id="status">Loading...</pre>
+    </section>
+
+    <section>
+      <h2>Settings</h2>
+      <label>Profit target %</label><input id="profit_target_pct" type="number" step="0.1">
+      <label>Stop loss %</label><input id="stop_loss_pct" type="number" step="0.1">
+      <label>Max dollars per paper trade</label><input id="max_trade_dollars" type="number" step="1">
+      <label>Minimum confidence</label><input id="min_confidence" type="number" step="0.01" min="0" max="1">
+      <button onclick="saveSettings()">Save Settings</button>
+      <pre id="settingsResult"></pre>
+    </section>
+
+    <section>
+      <h2>Add Social Evidence</h2>
+      <label>Source</label><input id="source" placeholder="reddit">
+      <label>Symbol</label><input id="symbol" placeholder="AAPL">
+      <label>URL</label><input id="url" placeholder="https://...">
+      <label>Engagement</label><input id="engagement" type="number" value="0">
+      <label>Text</label><textarea id="text" rows="5" placeholder="$AAPL looks bullish..."></textarea>
+      <button onclick="addEvidence()">Add Evidence</button>
+      <pre id="evidenceResult"></pre>
+    </section>
+
+    <section>
+      <h2>Paper Trade</h2>
+      <label>Action</label><select id="paperAction"><option>BUY</option><option>SELL</option></select>
+      <label>Symbol</label><input id="paperSymbol" placeholder="AAPL">
+      <label>Quantity</label><input id="paperQuantity" type="number" step="0.01" value="1">
+      <label>Price</label><input id="paperPrice" type="number" step="0.01">
+      <label>Reason</label><input id="paperReason" placeholder="manual paper trade">
+      <button onclick="addPaperTrade()">Create Paper Trade</button>
+      <pre id="paperResult"></pre>
+    </section>
+
+    <section class="span2">
+      <h2>Signals</h2>
+      <div id="signals"></div>
+    </section>
+
+    <section>
+      <h2>Recent Evidence</h2>
+      <div id="evidence"></div>
+    </section>
+
+    <section>
+      <h2>Paper Trades</h2>
+      <div id="paperTrades"></div>
+    </section>
+
+    <section class="span2">
+      <h2>Audit Log</h2>
+      <div id="audit"></div>
+    </section>
+  </main>
+  <script>
+    async function api(path, options = {}) {
+      const response = await fetch(path, {
+        headers: {'Content-Type': 'application/json'},
+        ...options
+      });
+      const body = await response.json();
+      if (!response.ok) throw new Error(body.error || response.statusText);
+      return body;
+    }
+
+    function json(id, value) {
+      document.getElementById(id).textContent = JSON.stringify(value, null, 2);
+    }
+
+    function table(rows, cols) {
+      if (!rows.length) return '<p class="warn">No data yet.</p>';
+      return '<table><thead><tr>' + cols.map(c => `<th>${c}</th>`).join('') +
+        '</tr></thead><tbody>' + rows.map(row => '<tr>' + cols.map(c => `<td>${row[c] ?? ''}</td>`).join('') + '</tr>').join('') + '</tbody></table>';
+    }
+
+    async function loadAll() {
+      const [status, settings, signals, evidence, paperTrades, audit] = await Promise.all([
+        api('/api/status'), api('/api/settings'), api('/api/signals'),
+        api('/api/evidence'), api('/api/paper-trades'), api('/api/audit')
+      ]);
+      json('status', status);
+      for (const key of ['profit_target_pct', 'stop_loss_pct', 'max_trade_dollars', 'min_confidence']) {
+        document.getElementById(key).value = settings[key];
+      }
+      document.getElementById('signals').innerHTML = table(signals, ['symbol', 'action', 'sentiment_score', 'confidence', 'evidence_count', 'source_count', 'summary']);
+      document.getElementById('evidence').innerHTML = table(evidence, ['id', 'source', 'symbol', 'sentiment_score', 'engagement', 'text']);
+      document.getElementById('paperTrades').innerHTML = table(paperTrades, ['id', 'symbol', 'action', 'quantity', 'price', 'notional', 'status', 'reason']);
+      document.getElementById('audit').innerHTML = table(audit, ['id', 'event_type', 'message', 'created_at']);
+    }
+
+    async function saveSettings() {
+      try {
+        const body = {};
+        for (const key of ['profit_target_pct', 'stop_loss_pct', 'max_trade_dollars', 'min_confidence']) {
+          body[key] = Number(document.getElementById(key).value);
+        }
+        json('settingsResult', await api('/api/settings', {method: 'POST', body: JSON.stringify(body)}));
+        await loadAll();
+      } catch (error) { json('settingsResult', {error: error.message}); }
+    }
+
+    async function addEvidence() {
+      try {
+        const body = {
+          source: document.getElementById('source').value,
+          symbol: document.getElementById('symbol').value,
+          url: document.getElementById('url').value,
+          engagement: Number(document.getElementById('engagement').value),
+          text: document.getElementById('text').value
+        };
+        json('evidenceResult', await api('/api/evidence', {method: 'POST', body: JSON.stringify(body)}));
+        await loadAll();
+      } catch (error) { json('evidenceResult', {error: error.message}); }
+    }
+
+    async function addPaperTrade() {
+      try {
+        const body = {
+          action: document.getElementById('paperAction').value,
+          symbol: document.getElementById('paperSymbol').value,
+          quantity: Number(document.getElementById('paperQuantity').value),
+          price: Number(document.getElementById('paperPrice').value),
+          reason: document.getElementById('paperReason').value
+        };
+        json('paperResult', await api('/api/paper-trades', {method: 'POST', body: JSON.stringify(body)}));
+        await loadAll();
+      } catch (error) { json('paperResult', {error: error.message}); }
+    }
+
+    loadAll();
+  </script>
+</body>
+</html>
+"""
+
+
+def create_handler(store: DashboardStore) -> type[BaseHTTPRequestHandler]:
+    class DashboardHandler(BaseHTTPRequestHandler):
+        server_version = "SchwabDashboard/0.1"
+
+        def do_GET(self) -> None:
+            try:
+                parsed = urlparse(self.path)
+                query = parse_qs(parsed.query)
+                if parsed.path == "/":
+                    self._send_html(INDEX_HTML)
+                elif parsed.path == "/api/status":
+                    self._send_json(store.get_status())
+                elif parsed.path == "/api/settings":
+                    self._send_json(store.get_settings())
+                elif parsed.path == "/api/evidence":
+                    self._send_json(store.list_evidence())
+                elif parsed.path == "/api/signals":
+                    self._send_json(store.list_signals())
+                elif parsed.path == "/api/paper-trades":
+                    self._send_json(store.list_paper_trades())
+                elif parsed.path == "/api/audit":
+                    self._send_json(store.list_audit())
+                elif parsed.path == "/api/schwab/account-numbers":
+                    self._send_json(_load_schwab_account_numbers())
+                elif parsed.path == "/api/schwab/accounts":
+                    fields = "positions" if query.get("positions") == ["1"] else None
+                    self._send_json(_load_schwab_accounts(fields))
+                elif parsed.path == "/api/schwab/account":
+                    account_hash = query.get("account_hash", [""])[0]
+                    fields = "positions" if query.get("positions") == ["1"] else None
+                    self._send_json(_load_schwab_account(account_hash, fields))
+                else:
+                    self._send_error(HTTPStatus.NOT_FOUND, "Not found")
+            except (ValueError, SchwabError) as error:
+                self._send_error(HTTPStatus.BAD_REQUEST, str(error))
+
+        def do_POST(self) -> None:
+            try:
+                parsed = urlparse(self.path)
+                payload = self._read_json()
+                if parsed.path == "/api/settings":
+                    self._send_json(store.update_settings(payload))
+                elif parsed.path == "/api/evidence":
+                    result = store.add_evidence(
+                        EvidenceInput(
+                            source=str(payload.get("source", "")),
+                            symbol=str(payload["symbol"]) if payload.get("symbol") else None,
+                            url=str(payload["url"]) if payload.get("url") else None,
+                            text=str(payload.get("text", "")),
+                            engagement=float(payload.get("engagement", 0)),
+                            raw=payload.get("raw") if isinstance(payload.get("raw"), dict) else None,
+                        )
+                    )
+                    self._send_json(result, HTTPStatus.CREATED)
+                elif parsed.path == "/api/paper-trades":
+                    self._send_json(store.add_paper_trade(payload), HTTPStatus.CREATED)
+                else:
+                    self._send_error(HTTPStatus.NOT_FOUND, "Not found")
+            except (ValueError, KeyError, SchwabError) as error:
+                self._send_error(HTTPStatus.BAD_REQUEST, str(error))
+
+        def log_message(self, format: str, *args: Any) -> None:
+            print(f"[dashboard] {self.address_string()} - {format % args}")
+
+        def _read_json(self) -> dict[str, Any]:
+            length = int(self.headers.get("Content-Length", "0"))
+            if length <= 0:
+                return {}
+            raw = self.rfile.read(length).decode("utf-8")
+            value = json.loads(raw)
+            if not isinstance(value, dict):
+                raise ValueError("JSON body must be an object")
+            return value
+
+        def _send_html(self, body: str) -> None:
+            data = body.encode("utf-8")
+            self.send_response(HTTPStatus.OK)
+            self.send_header("Content-Type", "text/html; charset=utf-8")
+            self.send_header("Content-Length", str(len(data)))
+            self.end_headers()
+            self.wfile.write(data)
+
+        def _send_json(self, body: Any, status: HTTPStatus = HTTPStatus.OK) -> None:
+            data = json.dumps(body, indent=2, sort_keys=True).encode("utf-8")
+            self.send_response(status)
+            self.send_header("Content-Type", "application/json; charset=utf-8")
+            self.send_header("Content-Length", str(len(data)))
+            self.end_headers()
+            self.wfile.write(data)
+
+        def _send_error(self, status: HTTPStatus, message: str) -> None:
+            self._send_json({"error": message}, status)
+
+    return DashboardHandler
+
+
+def _load_schwab_account_numbers() -> Any:
+    config, access_token = _load_schwab_access_token()
+    return get_account_numbers(access_token).body
+
+
+def _load_schwab_accounts(fields: str | None) -> Any:
+    config, access_token = _load_schwab_access_token()
+    return get_accounts(access_token, fields).body
+
+
+def _load_schwab_account(account_hash: str, fields: str | None) -> Any:
+    if not account_hash:
+        raise ValueError("account_hash is required")
+    config, access_token = _load_schwab_access_token()
+    return get_account(access_token, account_hash, fields).body
+
+
+def _load_schwab_access_token() -> tuple[Any, str]:
+    config = get_config_if_available()
+    if config is None:
+        raise SchwabError("Schwab environment is not configured")
+    tokens = load_tokens(config.token_file)
+    access_token = tokens.get("access_token")
+    if not access_token:
+        raise SchwabError(f"No access_token in {config.token_file}")
+    return config, access_token
+
+
+def run(host: str, port: int, db_path: str | None = None) -> None:
+    store = DashboardStore(db_path)
+    server = ThreadingHTTPServer((host, port), create_handler(store))
+    print(f"Schwab dashboard listening on http://{host}:{port}")
+    print("Live trading is disabled in this dashboard.")
+    server.serve_forever()
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="Run the local Schwab sentiment dashboard")
+    parser.add_argument("--host", default="127.0.0.1")
+    parser.add_argument("--port", default=8765, type=int)
+    parser.add_argument("--db", help="SQLite dashboard DB path")
+    args = parser.parse_args()
+    run(args.host, args.port, args.db)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schwab_trader/dashboard_test.py	Sun Aug 02 16:44:06 2026 -0700
@@ -0,0 +1,61 @@
+import json
+import tempfile
+import threading
+import unittest
+import urllib.request
+from http.server import ThreadingHTTPServer
+from pathlib import Path
+
+from schwab_trader.dashboard import DashboardStore, EvidenceInput, score_sentiment
+from schwab_trader.dashboard_server import create_handler
+
+
+class DashboardStoreTest(unittest.TestCase):
+    def test_sentiment_scoring(self):
+        self.assertGreater(score_sentiment("$AAPL bullish strong growth"), 0)
+        self.assertLess(score_sentiment("$AAPL bearish weak sell"), 0)
+
+    def test_evidence_recomputes_signal(self):
+        with tempfile.TemporaryDirectory() as temp_dir:
+            store = DashboardStore(Path(temp_dir) / "dashboard.db")
+            store.update_settings({"min_evidence_count": 2, "min_source_count": 2, "min_confidence": 0.3})
+
+            store.add_evidence(EvidenceInput(source="reddit", symbol="AAPL", text="$AAPL bullish strong", engagement=20))
+            result = store.add_evidence(EvidenceInput(source="x", symbol="AAPL", text="$AAPL growth surge", engagement=50))
+
+            self.assertEqual("AAPL", result["symbol"])
+            signals = store.list_signals()
+            self.assertEqual(1, len(signals))
+            self.assertEqual("CONSIDER_BUY", signals[0]["action"])
+
+    def test_paper_trade_rejects_above_max_trade_dollars(self):
+        with tempfile.TemporaryDirectory() as temp_dir:
+            store = DashboardStore(Path(temp_dir) / "dashboard.db")
+            store.update_settings({"max_trade_dollars": 100})
+
+            trade = store.add_paper_trade(
+                {"symbol": "AAPL", "action": "BUY", "quantity": 2, "price": 75, "reason": "test"}
+            )
+
+            self.assertEqual("rejected_max_trade_dollars", trade["status"])
+
+    def test_dashboard_http_status(self):
+        with tempfile.TemporaryDirectory() as temp_dir:
+            store = DashboardStore(Path(temp_dir) / "dashboard.db")
+            server = ThreadingHTTPServer(("127.0.0.1", 0), create_handler(store))
+            thread = threading.Thread(target=server.serve_forever, daemon=True)
+            thread.start()
+            try:
+                url = f"http://127.0.0.1:{server.server_port}/api/status"
+                with urllib.request.urlopen(url, timeout=5) as response:
+                    body = json.loads(response.read().decode("utf-8"))
+                self.assertEqual("schwab-dashboard", body["service"])
+                self.assertFalse(body["live_trading_enabled"])
+            finally:
+                server.shutdown()
+                thread.join(timeout=5)
+
+
+if __name__ == "__main__":
+    unittest.main()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schwab_trader/env.example	Sun Aug 02 16:44:06 2026 -0700
@@ -0,0 +1,10 @@
+# Copy this to your shell profile or an untracked local env file.
+# Never commit real Schwab credentials or OAuth tokens.
+
+export SCHWAB_APP_KEY="your-schwab-app-key"
+export SCHWAB_APP_SECRET="your-schwab-app-secret"
+export SCHWAB_REDIRECT_URI="https://127.0.0.1"
+
+# Optional. Defaults to ~/.config/zenbu/schwab_tokens.json.
+export SCHWAB_TOKEN_FILE="$HOME/.config/zenbu/schwab_tokens.json"
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schwab_trader/schwab_cli.py	Sun Aug 02 16:44:06 2026 -0700
@@ -0,0 +1,171 @@
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+
+from schwab_trader.schwab_client import (
+    SchwabConfig,
+    SchwabError,
+    build_authorization_url,
+    build_equity_order,
+    exchange_code_for_tokens,
+    get_account,
+    get_account_numbers,
+    get_accounts,
+    load_tokens,
+    place_order,
+    refresh_tokens,
+    save_tokens,
+)
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(
+        description="Safe Schwab Trader API helper. This tool never chooses trades for you.",
+    )
+    subparsers = parser.add_subparsers(dest="command", required=True)
+
+    auth_url = subparsers.add_parser("auth-url", help="Print the Schwab OAuth authorization URL")
+    auth_url.add_argument("--state", help="Optional OAuth state value")
+
+    token = subparsers.add_parser("token", help="Exchange an authorization code/callback URL for tokens")
+    token.add_argument("--code", required=True, help="Authorization code or full callback URL")
+
+    subparsers.add_parser("refresh", help="Refresh and save tokens")
+
+    subparsers.add_parser("account-numbers", help="Print Schwab account-number/account-hash mapping")
+
+    accounts = subparsers.add_parser("accounts", help="Print account data")
+    accounts.add_argument("--positions", action="store_true", help="Include positions if API permissions allow it")
+
+    account = subparsers.add_parser("account", help="Print one account by account hash")
+    account.add_argument("--account-hash", required=True)
+    account.add_argument("--positions", action="store_true", help="Include positions if API permissions allow it")
+
+    build_order = subparsers.add_parser("build-equity-order", help="Build and print a stock order JSON payload")
+    _add_order_args(build_order)
+
+    place_equity = subparsers.add_parser(
+        "place-equity-order",
+        help="Place a user-specified stock order; defaults to dry-run output only",
+    )
+    _add_order_args(place_equity)
+    place_equity.add_argument("--account-hash", required=True)
+    place_equity.add_argument("--live", action="store_true", help="Actually submit the order to Schwab")
+    place_equity.add_argument(
+        "--confirm-live-trade",
+        action="store_true",
+        help="Required with --live to reduce accidental orders",
+    )
+
+    args = parser.parse_args(argv)
+
+    try:
+        if args.command == "auth-url":
+            config = SchwabConfig.from_env()
+            print(build_authorization_url(config.app_key, config.redirect_uri, args.state))
+            return 0
+
+        if args.command == "token":
+            config = SchwabConfig.from_env()
+            tokens = exchange_code_for_tokens(config, args.code)
+            save_tokens(config.token_file, tokens)
+            print(f"Saved tokens to {config.token_file}")
+            return 0
+
+        if args.command == "refresh":
+            config = SchwabConfig.from_env()
+            tokens = refresh_tokens(config)
+            save_tokens(config.token_file, tokens)
+            print(f"Refreshed tokens in {config.token_file}")
+            return 0
+
+        if args.command == "account-numbers":
+            config = SchwabConfig.from_env()
+            access_token = _load_access_token(config)
+            _print_json(get_account_numbers(access_token).body)
+            return 0
+
+        if args.command == "accounts":
+            config = SchwabConfig.from_env()
+            access_token = _load_access_token(config)
+            fields = "positions" if args.positions else None
+            _print_json(get_accounts(access_token, fields).body)
+            return 0
+
+        if args.command == "account":
+            config = SchwabConfig.from_env()
+            access_token = _load_access_token(config)
+            fields = "positions" if args.positions else None
+            _print_json(get_account(access_token, args.account_hash, fields).body)
+            return 0
+
+        if args.command == "build-equity-order":
+            order = _build_order_from_args(args)
+            _print_json(order)
+            return 0
+
+        if args.command == "place-equity-order":
+            order = _build_order_from_args(args)
+            if not args.live:
+                print("DRY RUN: order was not sent. Add --live --confirm-live-trade to submit.")
+                _print_json(order)
+                return 0
+            if not args.confirm_live_trade:
+                raise SchwabError("--live requires --confirm-live-trade")
+
+            config = SchwabConfig.from_env()
+            access_token = _load_access_token(config)
+            response = place_order(access_token, args.account_hash, order)
+            print(f"Schwab order response status: {response.status}")
+            location = response.headers.get("Location")
+            if location:
+                print(f"Order location: {location}")
+            if response.body is not None:
+                _print_json(response.body)
+            return 0
+
+        raise SchwabError(f"Unknown command: {args.command}")
+    except SchwabError as error:
+        print(f"error: {error}", file=sys.stderr)
+        return 1
+
+
+def _add_order_args(parser: argparse.ArgumentParser) -> None:
+    parser.add_argument("--action", required=True, choices=["BUY", "SELL", "buy", "sell"])
+    parser.add_argument("--symbol", required=True)
+    parser.add_argument("--quantity", required=True, type=float)
+    parser.add_argument("--order-type", default="MARKET", choices=["MARKET", "LIMIT", "market", "limit"])
+    parser.add_argument("--price", type=float, help="Required for LIMIT orders; invalid for MARKET orders")
+    parser.add_argument("--duration", default="DAY")
+    parser.add_argument("--session", default="NORMAL")
+
+
+def _build_order_from_args(args: argparse.Namespace) -> dict:
+    return build_equity_order(
+        action=args.action,
+        symbol=args.symbol,
+        quantity=args.quantity,
+        order_type=args.order_type,
+        price=args.price,
+        duration=args.duration,
+        session=args.session,
+    )
+
+
+def _load_access_token(config: SchwabConfig) -> str:
+    tokens = load_tokens(config.token_file)
+    access_token = tokens.get("access_token")
+    if not access_token:
+        raise SchwabError(f"No access_token in {config.token_file}")
+    return access_token
+
+
+def _print_json(value: object) -> None:
+    print(json.dumps(value, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schwab_trader/schwab_client.py	Sun Aug 02 16:44:06 2026 -0700
@@ -0,0 +1,275 @@
+from __future__ import annotations
+
+import base64
+import json
+import os
+import stat
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+
+AUTH_URL = "https://api.schwabapi.com/v1/oauth/authorize"
+TOKEN_URL = "https://api.schwabapi.com/v1/oauth/token"
+TRADER_BASE_URL = "https://api.schwabapi.com/trader/v1"
+DEFAULT_TOKEN_FILE = "~/.config/zenbu/schwab_tokens.json"
+
+
+class SchwabError(RuntimeError):
+    pass
+
+
+@dataclass(frozen=True)
+class SchwabConfig:
+    app_key: str
+    app_secret: str
+    redirect_uri: str
+    token_file: Path
+
+    @classmethod
+    def from_env(cls) -> "SchwabConfig":
+        app_key = os.environ.get("SCHWAB_APP_KEY", "").strip()
+        app_secret = os.environ.get("SCHWAB_APP_SECRET", "").strip()
+        redirect_uri = os.environ.get("SCHWAB_REDIRECT_URI", "").strip()
+        token_file = Path(os.environ.get("SCHWAB_TOKEN_FILE", DEFAULT_TOKEN_FILE)).expanduser()
+
+        missing = [
+            name
+            for name, value in (
+                ("SCHWAB_APP_KEY", app_key),
+                ("SCHWAB_APP_SECRET", app_secret),
+                ("SCHWAB_REDIRECT_URI", redirect_uri),
+            )
+            if not value
+        ]
+        if missing:
+            raise SchwabError("Missing required environment variables: " + ", ".join(missing))
+
+        return cls(
+            app_key=app_key,
+            app_secret=app_secret,
+            redirect_uri=redirect_uri,
+            token_file=token_file,
+        )
+
+
+@dataclass(frozen=True)
+class ApiResponse:
+    status: int
+    headers: dict[str, str]
+    body: Any
+    raw_body: str
+
+
+def build_authorization_url(app_key: str, redirect_uri: str, state: str | None = None) -> str:
+    params = {
+        "response_type": "code",
+        "client_id": app_key,
+        "redirect_uri": redirect_uri,
+    }
+    if state:
+        params["state"] = state
+    return AUTH_URL + "?" + urllib.parse.urlencode(params)
+
+
+def extract_authorization_code(code_or_url: str) -> str:
+    value = code_or_url.strip()
+    if not value:
+        raise SchwabError("Authorization code is empty")
+
+    parsed = urllib.parse.urlparse(value)
+    if parsed.scheme and parsed.netloc:
+        query = urllib.parse.parse_qs(parsed.query)
+        codes = query.get("code")
+        if not codes or not codes[0]:
+            raise SchwabError("No code= parameter found in callback URL")
+        return codes[0]
+
+    return urllib.parse.unquote(value)
+
+
+def exchange_code_for_tokens(config: SchwabConfig, code_or_url: str) -> dict[str, Any]:
+    code = extract_authorization_code(code_or_url)
+    return _token_request(
+        config,
+        {
+            "grant_type": "authorization_code",
+            "code": code,
+            "redirect_uri": config.redirect_uri,
+        },
+    )
+
+
+def refresh_tokens(config: SchwabConfig, refresh_token: str | None = None) -> dict[str, Any]:
+    token_value = refresh_token
+    if token_value is None:
+        existing = load_tokens(config.token_file)
+        token_value = existing.get("refresh_token")
+    if not token_value:
+        raise SchwabError("No refresh token available")
+
+    return _token_request(
+        config,
+        {
+            "grant_type": "refresh_token",
+            "refresh_token": token_value,
+        },
+    )
+
+
+def save_tokens(path: Path, tokens: dict[str, Any]) -> None:
+    payload = dict(tokens)
+    payload["saved_at"] = int(time.time())
+    path.parent.mkdir(parents=True, exist_ok=True)
+    path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
+    path.chmod(stat.S_IRUSR | stat.S_IWUSR)
+
+
+def load_tokens(path: Path) -> dict[str, Any]:
+    if not path.exists():
+        raise SchwabError(f"Token file does not exist: {path}")
+    return json.loads(path.read_text(encoding="utf-8"))
+
+
+def get_account_numbers(access_token: str) -> ApiResponse:
+    return _api_request("GET", "/accounts/accountNumbers", access_token)
+
+
+def get_accounts(access_token: str, fields: str | None = None) -> ApiResponse:
+    query = ""
+    if fields:
+        query = "?" + urllib.parse.urlencode({"fields": fields})
+    return _api_request("GET", "/accounts" + query, access_token)
+
+
+def get_account(access_token: str, account_hash: str, fields: str | None = None) -> ApiResponse:
+    query = ""
+    if fields:
+        query = "?" + urllib.parse.urlencode({"fields": fields})
+    return _api_request("GET", f"/accounts/{urllib.parse.quote(account_hash)}{query}", access_token)
+
+
+def build_equity_order(
+    action: str,
+    symbol: str,
+    quantity: float,
+    order_type: str = "MARKET",
+    price: float | None = None,
+    duration: str = "DAY",
+    session: str = "NORMAL",
+) -> dict[str, Any]:
+    normalized_action = action.upper()
+    normalized_symbol = symbol.upper()
+    normalized_order_type = order_type.upper()
+    normalized_duration = duration.upper()
+    normalized_session = session.upper()
+
+    if normalized_action not in {"BUY", "SELL"}:
+        raise SchwabError("action must be BUY or SELL")
+    if not normalized_symbol:
+        raise SchwabError("symbol is required")
+    if quantity <= 0:
+        raise SchwabError("quantity must be greater than zero")
+    if normalized_order_type not in {"MARKET", "LIMIT"}:
+        raise SchwabError("order_type must be MARKET or LIMIT")
+    if normalized_order_type == "LIMIT" and price is None:
+        raise SchwabError("LIMIT orders require --price")
+    if normalized_order_type == "MARKET" and price is not None:
+        raise SchwabError("MARKET orders cannot include --price")
+
+    order: dict[str, Any] = {
+        "orderType": normalized_order_type,
+        "session": normalized_session,
+        "duration": normalized_duration,
+        "orderStrategyType": "SINGLE",
+        "orderLegCollection": [
+            {
+                "instruction": normalized_action,
+                "quantity": quantity,
+                "instrument": {
+                    "symbol": normalized_symbol,
+                    "assetType": "EQUITY",
+                },
+            }
+        ],
+    }
+    if price is not None:
+        order["price"] = f"{price:.2f}"
+    return order
+
+
+def place_order(access_token: str, account_hash: str, order: dict[str, Any]) -> ApiResponse:
+    path = f"/accounts/{urllib.parse.quote(account_hash)}/orders"
+    return _api_request("POST", path, access_token, order)
+
+
+def _token_request(config: SchwabConfig, form: dict[str, str]) -> dict[str, Any]:
+    credentials = f"{config.app_key}:{config.app_secret}".encode("utf-8")
+    headers = {
+        "Authorization": "Basic " + base64.b64encode(credentials).decode("ascii"),
+        "Content-Type": "application/x-www-form-urlencoded",
+        "Accept": "application/json",
+    }
+    data = urllib.parse.urlencode(form).encode("utf-8")
+    request = urllib.request.Request(TOKEN_URL, data=data, headers=headers, method="POST")
+    response = _open_request(request)
+    if not isinstance(response.body, dict):
+        raise SchwabError("Token endpoint did not return a JSON object")
+    return response.body
+
+
+def _api_request(
+    method: str,
+    path: str,
+    access_token: str,
+    body: dict[str, Any] | None = None,
+) -> ApiResponse:
+    headers = {
+        "Authorization": f"Bearer {access_token}",
+        "Accept": "application/json",
+    }
+    data = None
+    if body is not None:
+        data = json.dumps(body).encode("utf-8")
+        headers["Content-Type"] = "application/json"
+
+    request = urllib.request.Request(
+        TRADER_BASE_URL + path,
+        data=data,
+        headers=headers,
+        method=method,
+    )
+    return _open_request(request)
+
+
+def _open_request(request: urllib.request.Request) -> ApiResponse:
+    try:
+        with urllib.request.urlopen(request, timeout=30) as response:
+            raw = response.read().decode("utf-8")
+            return ApiResponse(
+                status=response.status,
+                headers=dict(response.headers.items()),
+                body=_parse_json_or_text(raw),
+                raw_body=raw,
+            )
+    except urllib.error.HTTPError as error:
+        raw = error.read().decode("utf-8", errors="replace")
+        raise SchwabError(
+            f"Schwab API request failed with HTTP {error.code}: {raw or error.reason}"
+        ) from error
+    except urllib.error.URLError as error:
+        raise SchwabError(f"Schwab API request failed: {error.reason}") from error
+
+
+def _parse_json_or_text(raw: str) -> Any:
+    if not raw:
+        return None
+    try:
+        return json.loads(raw)
+    except json.JSONDecodeError:
+        return raw
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schwab_trader/schwab_client_test.py	Sun Aug 02 16:44:06 2026 -0700
@@ -0,0 +1,67 @@
+import os
+import tempfile
+import unittest
+from pathlib import Path
+from urllib.parse import parse_qs, urlparse
+
+from schwab_trader.schwab_client import (
+    SchwabConfig,
+    SchwabError,
+    build_authorization_url,
+    build_equity_order,
+    extract_authorization_code,
+    save_tokens,
+)
+
+
+class SchwabClientTest(unittest.TestCase):
+    def test_authorization_url_contains_oauth_params(self):
+        url = build_authorization_url("app-key", "https://127.0.0.1/callback", "state-1")
+        parsed = urlparse(url)
+        params = parse_qs(parsed.query)
+
+        self.assertEqual("https", parsed.scheme)
+        self.assertEqual("api.schwabapi.com", parsed.netloc)
+        self.assertEqual(["code"], params["response_type"])
+        self.assertEqual(["app-key"], params["client_id"])
+        self.assertEqual(["https://127.0.0.1/callback"], params["redirect_uri"])
+        self.assertEqual(["state-1"], params["state"])
+
+    def test_extract_authorization_code_from_callback_url(self):
+        code = extract_authorization_code("https://127.0.0.1/callback?code=abc%40123&state=x")
+        self.assertEqual("abc@123", code)
+
+    def test_build_market_equity_order(self):
+        order = build_equity_order("buy", "aapl", 1)
+
+        self.assertEqual("MARKET", order["orderType"])
+        self.assertEqual("BUY", order["orderLegCollection"][0]["instruction"])
+        self.assertEqual("AAPL", order["orderLegCollection"][0]["instrument"]["symbol"])
+        self.assertNotIn("price", order)
+
+    def test_limit_order_requires_price(self):
+        with self.assertRaises(SchwabError):
+            build_equity_order("SELL", "MSFT", 2, order_type="LIMIT")
+
+    def test_save_tokens_uses_owner_only_permissions(self):
+        with tempfile.TemporaryDirectory() as temp_dir:
+            token_file = Path(temp_dir) / "tokens.json"
+            save_tokens(token_file, {"access_token": "x"})
+
+            self.assertEqual(0o600, token_file.stat().st_mode & 0o777)
+
+    def test_config_from_env_requires_credentials(self):
+        old_env = os.environ.copy()
+        try:
+            for key in ("SCHWAB_APP_KEY", "SCHWAB_APP_SECRET", "SCHWAB_REDIRECT_URI"):
+                os.environ.pop(key, None)
+            with self.assertRaises(SchwabError):
+                SchwabConfig.from_env()
+        finally:
+            os.environ.clear()
+            os.environ.update(old_env)
+
+
+if __name__ == "__main__":
+    unittest.main()
+
--- a/seobeo/s_http_client.c	Sat Feb 28 21:04:43 2026 -0800
+++ b/seobeo/s_http_client.c	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/seobeo/s_network.c	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/seobeo/s_web.c	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/seobeo/seobeo.h	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/seobeo/seobeo_internal.h	Sun Aug 02 16:44:06 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	Sat Feb 28 21:04:43 2026 -0800
+++ b/seobeo/tests/BUILD	Sun Aug 02 16:44:06 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 16:44:06 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 16:44:06 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 16:44:06 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 16:44:06 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 16:44:06 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;
+}