changeset 195:f8f5004a920a

Merging back hg-web-tip
author MrJuneJune <me@mrjunejune.com>
date Tue, 27 Jan 2026 06:51:44 -0800
parents 14cc84ba35a0 (current diff) fb28063dc490 (diff)
children 83f16548ba41
files
diffstat 57 files changed, 4636 insertions(+), 1478 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/benchmark/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,13 @@
+# benchmark
+
+HTTP framework benchmarking tools and tests.
+
+## Contents
+
+- `bun-http-framework-benchmark/` - Forked benchmark suite for comparing HTTP frameworks
+
+## Usage
+
+```bash
+bazel build //benchmark:...
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/config/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,11 @@
+# config
+
+Shared Bazel build configurations and platform definitions.
+
+## Usage
+
+Reference in BUILD files:
+
+```starlark
+load("//config:defs.bzl", ...)
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/deita/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,33 @@
+# deita
+
+SQLite database wrapper library for C.
+
+## Features
+
+- Connection management
+- Query building and execution
+- Result handling
+
+## Files
+
+| File | Description |
+|------|-------------|
+| `deita.h` | Public API header |
+| `deita_internal.h` | Internal declarations |
+| `d_sqlite.c` | SQLite wrapper implementation |
+| `d_connection.c` | Connection handling |
+| `d_query.c` | Query execution |
+| `deita_test.c` | Unit tests |
+
+## Usage
+
+```c
+#include "deita/deita.h"
+```
+
+## Building
+
+```bash
+bazel build //deita:deita
+bazel test //deita:deita_test
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/dowa/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,43 @@
+# dowa
+
+Core utility library for C. Provides memory management, string operations, and math utilities.
+
+## Features
+
+- Arena allocator and memory utilities
+- String builder and manipulation
+- Math helpers
+- stb_ds.h for dynamic arrays and hash maps
+
+## Files
+
+| File | Description |
+|------|-------------|
+| `dowa.h` | Public API header |
+| `d_memory.c` | Memory management (arena allocator) |
+| `d_string.c` | String utilities |
+| `d_math.c` | Math helpers |
+| `stb_ds.h` | STB dynamic structures |
+| `dowa_test.c` | Unit tests |
+
+## Usage
+
+```c
+#include "dowa/dowa.h"
+
+// Arena allocator
+Arena arena = {0};
+char* str = arena_alloc(&arena, 100);
+arena_free(&arena);
+
+// String builder
+StringBuilder sb = {0};
+sb_append(&sb, "hello");
+```
+
+## Building
+
+```bash
+bazel build //dowa:dowa
+bazel test //dowa:dowa_test
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/graphics/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,8 @@
+# graphics
+
+Graphics experiments and WebGL demos.
+
+## Files
+
+- `index.html` - HTML entry point
+- `index.js` - JavaScript graphics code
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/gui_ze/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,29 @@
+# gui_ze
+
+Bazel rules for building GUI applications and web frontends.
+
+## Files
+
+| File | Description |
+|------|-------------|
+| `gui_ze.bzl` | Starlark rules for web/GUI builds |
+| `time_to_first_byte.sh` | TTFB measurement script |
+
+## Rules
+
+Provides Bazel rules for:
+- Bundling JavaScript/TypeScript with esbuild
+- Building WASM modules with Emscripten
+- Packaging web applications
+
+## Usage
+
+```starlark
+load("//gui_ze:gui_ze.bzl", "web_bundle", "wasm_cc_binary")
+
+web_bundle(
+    name = "app",
+    entry_point = "src/main.tsx",
+    ...
+)
+```
--- a/gui_ze/gui_ze.bzl	Sat Jan 24 06:37:43 2026 -0800
+++ b/gui_ze/gui_ze.bzl	Tue Jan 27 06:51:44 2026 -0800
@@ -91,6 +91,87 @@
     executable = True,
 )
 
+def _bun_bundle_impl(ctx):
+  """
+  Bundle TypeScript/JavaScript with Bun, resolving imports from bazel root.
+
+  Copies all dependencies (dereferencing symlinks) to create a flat structure
+  where imports can resolve correctly relative to the bazel workspace root.
+  """
+  out = ctx.actions.declare_file(ctx.label.name + ".js")
+
+  inputs = depset(
+    direct = [ctx.file.src],
+    transitive = [dep[DefaultInfo].files for dep in ctx.attr.deps]
+  )
+
+  # Get the source file's package directory (e.g., "hg-web" from "hg-web/src/main.tsx")
+  src_package = ctx.file.src.path.split("/")[0]
+
+  # Collect unique root directories to copy (deduped), excluding src_package (handled separately)
+  dirs_to_copy = {}
+  for f in ctx.files.deps:
+    # Find the root directory path by locating where short_path starts in full path
+    short_path_suffix = "/".join(f.short_path.split("/")[1:])
+    pos = f.path.find(short_path_suffix)
+    if pos > 0:
+      root_dir = f.path[:pos].rstrip("/")
+      # Skip src_package - it's a symlink that needs special handling
+      if not root_dir.endswith(src_package):
+        dirs_to_copy[root_dir] = True
+
+  # Build copy commands for each unique directory
+  copy_commands = ["cp -rL {dir} .".format(dir = d) for d in dirs_to_copy.keys()]
+
+  ctx.actions.run_shell(
+    inputs = inputs,
+    outputs = [out],
+    tools = [ctx.executable._bun] + [ctx.file.src] + ctx.files.deps + ctx.files.node_modules,
+    command = """
+cp -rL {src_package} {src_package}_tmp && rm -rf {src_package} && mv {src_package}_tmp {src_package}
+{copy_commands}
+export NODE_PATH=./third_party/bun/node_modules
+cp ./third_party/bun/tsconfig.json .
+{bun} build {entry} --outfile {output} --target browser 
+""".format(
+      copy_commands = "\n".join(copy_commands),
+      src_package = src_package,
+      bun = ctx.executable._bun.path,
+      entry = ctx.file.src.path,
+      output = out.path,
+    ),
+    progress_message = "Bundling %s with Bun" % ctx.file.src.short_path,
+  )
+
+  return [DefaultInfo(files = depset([out]))]
+
+bun_bundle = rule(
+  implementation = _bun_bundle_impl,
+  attrs = {
+    "src": attr.label(
+      allow_single_file = [".ts", ".tsx", ".js", ".jsx"],
+      doc = "Entry point file to bundle",
+    ),
+    "deps": attr.label_list(
+      allow_files = True,
+      doc = "Source files and other dependencies to include",
+    ),
+    "node_modules": attr.label_list(
+      allow_files = True,
+      default = [Label("//third_party/bun:bun_files")],
+      doc = "Node modules for bundling (defaults to //third_party/bun:bun_files)",
+    ),
+    "_bun": attr.label(
+      default = Label("//third_party/bun:bun"),
+      executable = True,
+      cfg = "exec",
+    ),
+  },
+  doc = "Bundle TypeScript/JavaScript using Bun with bazel root imports",
+)
+
+
+
 def _bun_build_impl(ctx):
   """
   Run bun build on the folder
@@ -111,11 +192,15 @@
     command = """
       cp -r third_party/bun/** . \
       && cp -r {src_folder}/** .  \
-      && export NODE_PATH=./node_modules && {bun_path} build {input_path} --outfile {output_path}
+      && cp -r ./bazel-out/k8-fastbuild/bin/hg-web/src/** src \
+      && ls src \
+      && pwd \
+      && export NODE_PATH=./node_modules && {bun_path} build {input_path} --outfile {output_path} --target browser
       """.format(
       bun_path = ctx.executable._bun.path,
       src_folder = ctx.attr.src_folder,
-      input_path = ctx.file.src.path.split("/")[-1],
+      # Fix this lol
+      input_path = "/".join(ctx.file.src.path.split("/")[-2:]),
       output_path = out.path,
     ),
     progress_message = "Bundling {} with Bun!\n\n".format(ctx.file.src.path),
@@ -208,11 +293,11 @@
     ctx.actions.run_shell(
       inputs = [src],
       outputs = [out],
-      command = "cp \"$1\" \"$2\"",
+      command = "cp -r \"$1\" \"$2\"",
       arguments = [src.path, out.path],
     )
     outs.append(out)
-  return [DefaultInfo(files = depset(outs))]
+  return [DefaultInfo(files = depset(outs), runfiles = ctx.runfiles(files = outs))]
 
 move_files_into_dir = rule(
   implementation = _move_files_into_dir_impl,
--- a/hg-web/BUILD	Sat Jan 24 06:37:43 2026 -0800
+++ b/hg-web/BUILD	Tue Jan 27 06:51:44 2026 -0800
@@ -1,38 +1,73 @@
 load("@rules_cc//cc:cc_binary.bzl", "cc_binary")
-load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle")
+load("//gui_ze:gui_ze.bzl", "move_files_into_dir", "bundle", "bun_bundle")
+
+# Source files
+filegroup(
+  name = "src_ts_files",
+  srcs = glob([
+    "src/**/*.ts",
+    "src/**/*.tsx",
+    "src/**/*.js",
+    "src/**/*.jsx",
+  ], allow_empty = True),
+)
 
+# Bundle TypeScript with Bun
+bun_bundle(
+  name = "page",
+  src = "src/main.tsx",
+  deps = [
+    ":src_ts_files",
+    "//markdown_converter:markdown_to_html_wasm",
+    "//third_party/highlight:js",
+  ],
+  visibility = ["//visibility:public"],
+)
+
+# Prepare compiled assets
 move_files_into_dir(
-  name = "compiled_ts",
+  name = "compiled_js",
   srcs = [
-    "//markdown_converter:markdown_to_html",
+    ":page",
+    "//markdown_converter:markdown_to_html_wasm",
+    "//third_party/highlight:js",
   ],
   dest = "src",
 )
 
+move_files_into_dir(
+  name = "public_files",
+  srcs = ["//mrjunejune:public_files"],
+  dest = "src/public",
+)
+
+move_files_into_dir(
+  name = "public_fonts_files",
+  srcs = ["//mrjunejune:public_fonts_files"],
+  dest = "src/public/fonts",
+)
+
 filegroup(
-  name = "src_files",
-  srcs = glob(["src/**"]) + [":compiled_ts"],
+  name = "all_assets",
+  srcs = glob(["src/**"]) + [":compiled_js", ":public_files", ":public_fonts_files"],
+)
+
+# Server binaries
+cc_binary(
+  name = "hg_web_server",
+  srcs = ["main.c"],
+  deps = ["//seobeo:seobeo"],
+  data = [":all_assets"],
 )
 
 cc_binary(
-  name = "hg_web_server",
+  name = "hg_web_server_debug",
   srcs = ["main.c"],
-  deps = ["//seobeo:seobeo_server"],
-  data = [":src_files"],
-  defines = ["REPO_ROOT=\\\"\"/home/mrjunejune/zenbu\"\\\""],
+  deps = ["//seobeo:seobeo"],
+  data = [":all_assets"],
 )
 
 bundle(
   name = "hg_web_server_bundle",
   binary = ":hg_web_server",
 )
-
-
-cc_binary(
-  name = "hg_web_server_debug",
-  srcs = ["main.c"],
-  deps = ["//seobeo:seobeo_tcp_server_ws_debug"],
-  data = [":src_files"],
-  defines = ["REPO_ROOT=\\\"\"/home/mrjunejune/zenbu\"\\\""],
-)
-
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,34 @@
+# 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.
+
+## Features
+
+- 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
+
+## Structure
+
+```
+hg-web/
+├── BUILD           # Bazel build configuration
+├── deploy.sh       # Deployment script
+├── main.c          # C server handling API routes
+└── src/            # Frontend source files
+```
+
+## Building
+
+```bash
+bazel build //hg-web:hg_web
+```
+
+## API Endpoints
+
+The C server (`main.c`) provides:
+
+- `GET /api/repo/list?path=` - List directory contents
+- `GET /api/repo/file?path=` - Fetch file contents
--- a/hg-web/main.c	Sat Jan 24 06:37:43 2026 -0800
+++ b/hg-web/main.c	Tue Jan 27 06:51:44 2026 -0800
@@ -11,37 +11,10 @@
 #include <netdb.h>
 
 #define HG_SERVE_HOST "127.0.0.1"
-#define HG_SERVE_PORT 4444
+#define HG_SERVE_PORT "4444"
 
 #define MAX_PATH 4096
 
-// TODO: Move this to seobeo....
-// Asked AI to create this lol, probably should learn to decode it myself..
-static void url_decode(char *dst, const char *src)
-{
-  char a, b;
-  while (*src) {
-    if ((*src == '%') &&
-        ((a = src[1]) && (b = src[2])) &&
-        (isxdigit(a) && isxdigit(b))) {
-      if (a >= 'a') a -= 'a'-'A';
-      if (a >= 'A') a -= ('A' - 10);
-      else a -= '0';
-      if (b >= 'a') b -= 'a'-'A';
-      if (b >= 'A') b -= ('A' - 10);
-      else b -= '0';
-      *dst++ = 16*a+b;
-      src+=3;
-    } else if (*src == '+') {
-      *dst++ = ' ';
-      src++;
-    } else {
-      *dst++ = *src++;
-    }
-  }
-  *dst = '\0';
-}
-
 static char* sanitize_path(const char *input_path, Dowa_Arena *arena)
 {
   if (!input_path || strlen(input_path) == 0)
@@ -57,8 +30,10 @@
 
   for (size_t i = 0; i < len; i++)
   {
-    if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/')) {
-      if (i + 1 < len && input_path[i+1] == '.') {
+    if (input_path[i] == '.' && (i == 0 || input_path[i-1] == '/'))
+    {
+      if (i + 1 < len && input_path[i+1] == '.')
+      {
         // Skip ".."
         i++;
         continue;
@@ -79,146 +54,33 @@
   return result;
 }
 
-// Helper to connect to hg serve
-static int hg_proxy_connect(void)
+Seobeo_Client_Response  *hg_proxy_request(
+  const char *method,
+  const char *path,
+  const char *req_body,
+  const char *hg_custom)
 {
-    int sock = socket(AF_INET, SOCK_STREAM, 0);
-    if (sock < 0)
-    {
-        Seobeo_Log(SEOBEO_DEBUG, "Failed to create socket\n");
-        return -1;
-    }
-
-    struct sockaddr_in server_addr;
-    memset(&server_addr, 0, sizeof(server_addr));
-    server_addr.sin_family = AF_INET;
-    server_addr.sin_port = htons(HG_SERVE_PORT);
-    inet_pton(AF_INET, HG_SERVE_HOST, &server_addr.sin_addr);
-
-    if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0)
-    {
-        Seobeo_Log(SEOBEO_DEBUG, "Failed to connect to hg serve at %s:%d\n", HG_SERVE_HOST, HG_SERVE_PORT);
-        close(sock);
-        return -1;
-    }
-
-    return sock;
-}
-
-// Generic helper to proxy a request to hg serve and get the response body
-// Returns allocated body on success, NULL on failure
-// out_status and out_content_type are optional output parameters
-// out_body_len returns the actual body length (for binary content)
-static char* hg_proxy_request(
-    const char *method,
-    const char *path,
-    const char *req_body,
-    size_t body_len,
-    char *out_status,       // should be at least 4 bytes
-    char *out_content_type, // should be at least 256 bytes
-    size_t *out_body_len,   // optional: returns actual body length
-    Dowa_Arena *arena)
-{
-    int sock = hg_proxy_connect();
-    if (sock < 0) return NULL;
-
-    // Build HTTP request
-    char http_request[MAX_PATH * 2];
-    snprintf(http_request, sizeof(http_request),
-        "%s %s HTTP/1.1\r\n"
-        "Host: %s:%d\r\n"
-        "Connection: close\r\n"
-        "Accept: application/json, text/plain, */*\r\n"
-        "Content-Length: %zu\r\n"
-        "\r\n",
-        method, path, HG_SERVE_HOST, HG_SERVE_PORT, body_len);
-
-    Seobeo_Log(SEOBEO_DEBUG, "HG Proxy request: %s %s\n", method, path);
-
-    if (send(sock, http_request, strlen(http_request), 0) < 0)
-    {
-        close(sock);
-        return NULL;
-    }
-
-    if (body_len > 0 && req_body)
-    {
-        send(sock, req_body, body_len, 0);
-    }
+  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");
 
-    // Read response
-    int buffer_size = 1024 * 1024 * 5; // 5MB
-    char *response_buf = Dowa_Arena_Allocate(arena, buffer_size);
-    size_t total_read = 0;
-    ssize_t bytes_read;
-
-    while ((bytes_read = recv(sock, response_buf + total_read, buffer_size - total_read - 1, 0)) > 0)
-    {
-        total_read += bytes_read;
-        if (total_read >= (size_t)(buffer_size - 1)) break;
-    }
-    response_buf[total_read] = '\0';
-    close(sock);
-
-    Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", total_read);
-
-    // Parse response headers - use memmem to handle binary content
-    char *headers_end = NULL;
-    for (size_t i = 0; i + 3 < total_read; i++)
-    {
-        if (response_buf[i] == '\r' && response_buf[i+1] == '\n' &&
-            response_buf[i+2] == '\r' && response_buf[i+3] == '\n')
-        {
-            headers_end = response_buf + i;
-            break;
-        }
-    }
-    if (!headers_end) return NULL;
+  if (hg_custom && hg_custom[0] != '\0')
+  {
+    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);
+  }
 
-    // Extract status
-    if (out_status && strncmp(response_buf, "HTTP/", 5) == 0)
-    {
-        char *status_start = strchr(response_buf, ' ');
-        if (status_start)
-        {
-            strncpy(out_status, status_start + 1, 3);
-            out_status[3] = '\0';
-        }
-    }
-
-    // Extract content-type
-    if (out_content_type)
-    {
-        out_content_type[0] = '\0';
-        char *ct_header = strcasestr(response_buf, "Content-Type:");
-        if (ct_header && ct_header < headers_end)
-        {
-            ct_header += 13;
-            while (*ct_header == ' ') ct_header++;
-            char *ct_end = strpbrk(ct_header, "\r\n");
-            if (ct_end)
-            {
-                size_t ct_len = ct_end - ct_header;
-                if (ct_len < 256)
-                {
-                    strncpy(out_content_type, ct_header, ct_len);
-                    out_content_type[ct_len] = '\0';
-                }
-            }
-        }
-    }
-
-    // Return body (copy to fresh allocation for clean pointer)
-    char *body = headers_end + 4;
-    size_t body_size = total_read - (body - response_buf);
-
-    if (out_body_len) *out_body_len = body_size;
-
-    char *result = Dowa_Arena_Allocate(arena, body_size + 1);
-    memcpy(result, body, body_size);
-    result[body_size] = '\0';
-
-    return result;
+  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;
 }
 
 Seobeo_Request_Entry* ApiListDirectory(Seobeo_Request_Entry *req, Dowa_Arena *arena)
@@ -229,7 +91,7 @@
   const char *rel_path = path_kv ? ((Seobeo_Request_Entry*)path_kv)->value : "";
 
   char *decoded_path = Dowa_Arena_Allocate(arena, strlen(rel_path) + 1);
-  url_decode(decoded_path, rel_path);
+  Seobeo_Url_Decode(decoded_path, rel_path);
 
   char *safe_path = sanitize_path(decoded_path, arena);
 
@@ -241,14 +103,11 @@
   else
     snprintf(hg_path, sizeof(hg_path), "/file/tip/?style=json");
 
-  char status[4] = "200";
-  char content_type[256] = "";
-  size_t body_len = 0;
-  char *hg_response = hg_proxy_request("GET", hg_path, NULL, 0, status, content_type, &body_len, arena);
+  Seobeo_Client_Response  *hg_response = hg_proxy_request("GET", hg_path, NULL, NULL);
 
-  Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%s body_len=%zu\n", status, body_len);
+  Seobeo_Log(SEOBEO_DEBUG, "ApiListDirectory: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length);
 
-  if (!hg_response || status[0] != '2')
+  if (hg_response->status_code != 200)
   {
     Seobeo_Log(SEOBEO_DEBUG, "Failed to get directory from hg serve\n");
     Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
@@ -256,11 +115,79 @@
     Dowa_HashMap_Push_Arena(resp, "body", "{\"error\":\"Failed to connect to hg serve\"}", arena);
     return resp;
   }
-  char *json = hg_response;
+
+  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", json, arena);
+  Dowa_HashMap_Push_Arena(resp, "body", temp1, arena);
+  Dowa_HashMap_Push_Arena(resp, "content-length", temp2, arena);
+  return resp;
+}
+
+Seobeo_Request_Entry* ApiGetGraph(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  Seobeo_Request_Entry *resp = 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_Log(SEOBEO_INFO, "ApiGetGraph: safe_path='%s'\n", safe_path);
+
+  if (strlen(safe_path) == 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;
+  }
+
+  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);
+
+  Seobeo_Log(SEOBEO_DEBUG, "ApiGetGraph: 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);
+
+  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;
+  }
+
+  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;
+  }
+
+
+  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;
 }
@@ -272,7 +199,7 @@
   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);
-  url_decode(decoded_path, rel_path);
+  Seobeo_Url_Decode(decoded_path, rel_path);
   char *safe_path = sanitize_path(decoded_path, arena);
 
   Seobeo_Log(SEOBEO_INFO, "ApiGetFile: safe_path='%s'\n", safe_path);
@@ -285,18 +212,16 @@
     return resp;
   }
 
-  // Build hg serve URL: /raw-file/tip/<path>
   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);
 
-  char status[4] = "200";
-  char content_type[256] = "";
-  size_t body_len = 0;
-  char *body = hg_proxy_request("GET", hg_path, NULL, 0, status, content_type, &body_len, arena);
+  Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%i body_len=%zu\n", hg_response->status_code, hg_response->body_length);
 
-  Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: status=%s body_len=%zu\n", status, body_len);
+  char status[4];
+  snprintf(status, 4, "%i", hg_response->status_code);
 
-  if (!body)
+  if (!hg_response->body)
   {
     Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
     Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
@@ -304,31 +229,24 @@
     return resp;
   }
 
-  if (status[0] != '2')
+  if (hg_response->status_code != 200)
   {
-    Seobeo_Log(SEOBEO_DEBUG, "ApiGetFile: error response: %s\n", body);
+    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);
-    // Return actual error from hg serve if available
-    Dowa_HashMap_Push_Arena(resp, "body", body_len > 0 ? body : "File not found", arena);
+    Dowa_HashMap_Push_Arena(resp, "body", hg_response->body, arena);
     return resp;
   }
 
-  // Use content-type from hg serve, or determine from extension
-  const char *final_content_type = content_type;
-  if (strlen(content_type) == 0 || strcmp(content_type, "application/octet-stream") == 0)
-  {
-    final_content_type = "text/plain";
-    if (strstr(safe_path, ".md")) final_content_type = "text/markdown";
-    else if (strstr(safe_path, ".html")) final_content_type = "text/html";
-    else if (strstr(safe_path, ".css")) final_content_type = "text/css";
-    else if (strstr(safe_path, ".js")) final_content_type = "application/javascript";
-    else if (strstr(safe_path, ".json")) final_content_type = "application/json";
-  }
+
+  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", final_content_type, arena);
-  Dowa_HashMap_Push_Arena(resp, "body", body, 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;
 }
@@ -337,155 +255,194 @@
   return ApiGetFile(req, arena);
 }
 
+// Streaming handler for hg wire protocol - pipes data directly without buffering
+void StreamHgWireProtocol(Seobeo_Handle *p_client, Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  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;
+
+  Seobeo_Log(SEOBEO_DEBUG, "HG Stream Proxy: method=%s query=%s\n", method, query_string);
+
+  // 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)
+  {
+    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);
+    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);
+
+  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);
+
+  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");
+
+  Seobeo_Handle_Queue(p_upstream, (uint8*)request_buf, req_len);
+  if (Seobeo_Handle_Flush(p_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);
+    return;
+  }
+
+  // Responses 
+  while (1)
+  {
+    int r = Seobeo_Handle_Read(p_upstream);
+    if (r < 0)
+    {
+      Seobeo_Handle_Destroy(p_upstream);
+      return;
+    }
+    if (p_upstream->read_buffer_len >= 4 &&
+        strstr((char*)p_upstream->read_buffer, "\r\n\r\n") != NULL)
+      break;
+    if (r == 0)
+      continue;
+  }
+
+  // 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)
+  {
+    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)
+    {
+      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);
+    }
+    else if (n == -2)
+      break;
+    else if (n < 0)
+      break;
+  }
+
+  Seobeo_Handle_Destroy(p_upstream);
+}
+
 Seobeo_Request_Entry* ApiHgWireProtocol(Seobeo_Request_Entry *req, Dowa_Arena *arena)
 {
-    Seobeo_Request_Entry *resp = NULL;
+  Seobeo_Request_Entry *resp = NULL;
 
-    // Get method
-    void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method");
-    const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET";
-
-    // Get query string
-    void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
-    const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : "";
+  void *method_kv = Dowa_HashMap_Get_Ptr(req, "HTTP_Method");
+  const char *method = method_kv ? ((Seobeo_Request_Entry*)method_kv)->value : "GET";
 
-    // Get request body for POST
-    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);
-
-    Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: method=%s query=%s body_len=%zu\n", method, query_string, body_len);
+  void *query_kv = Dowa_HashMap_Get_Ptr(req, "QueryString");
+  const char *query_string = query_kv ? ((Seobeo_Request_Entry*)query_kv)->value : "";
 
-    // Connect to hg serve
-    int sock = hg_proxy_connect();
-    if (sock < 0)
-    {
-        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;
-    }
+  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);
 
-    // Build the HTTP request to forward to hg serve
-    char http_request[MAX_PATH * 2];
-    if (strlen(query_string) > 0)
-    {
-        snprintf(http_request, sizeof(http_request),
-            "%s /?%s HTTP/1.1\r\n"
-            "Host: %s:%d\r\n"
-            "Connection: close\r\n"
-            "Content-Length: %zu\r\n"
-            "\r\n",
-            method, query_string, HG_SERVE_HOST, HG_SERVE_PORT, body_len);
-    }
-    else
-    {
-        snprintf(http_request, sizeof(http_request),
-            "%s / HTTP/1.1\r\n"
-            "Host: %s:%d\r\n"
-            "Connection: close\r\n"
-            "Content-Length: %zu\r\n"
-            "\r\n",
-            method, HG_SERVE_HOST, HG_SERVE_PORT, body_len);
-    }
+  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;
 
-    // Send HTTP request headers
-    if (send(sock, http_request, strlen(http_request), 0) < 0)
-    {
-        Seobeo_Log(SEOBEO_DEBUG, "Failed to send request to hg serve\n");
-        close(sock);
-        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 send to hg serve", arena);
-        return resp;
-    }
+  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");
 
-    // Send body if present
-    if (body_len > 0)
-    {
-        send(sock, req_body, body_len, 0);
-    }
-
-    // Read response from hg serve
-    int buffer_size = 1024 * 1024 * 5; // 5MB
-    char *response_buf = Dowa_Arena_Allocate(arena, buffer_size);
-    size_t total_read = 0;
-    ssize_t bytes_read;
+  char *status = Dowa_Arena_Allocate(arena, 5);
+  snprintf(status, 4, "%i", hg_response->status_code);
 
-    while ((bytes_read = recv(sock, response_buf + total_read, buffer_size - total_read - 1, 0)) > 0)
-    {
-        total_read += bytes_read;
-        if (total_read >= (size_t)(buffer_size - 1)) break;
-    }
-    response_buf[total_read] = '\0';
-    close(sock);
-
-    Seobeo_Log(SEOBEO_DEBUG, "HG Proxy: received %zu bytes\n", total_read);
+  // 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);
 
-    // Parse HTTP response - find headers end
-    char *headers_end = strstr(response_buf, "\r\n\r\n");
-    if (!headers_end)
-    {
-        Dowa_HashMap_Push_Arena(resp, "status", "502", arena);
-        Dowa_HashMap_Push_Arena(resp, "content-type", "text/plain", arena);
-        Dowa_HashMap_Push_Arena(resp, "body", "Invalid response from hg serve", arena);
-        return resp;
-    }
+  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);
 
-    // Extract status code from first line (e.g., "HTTP/1.1 200 OK")
-    char status_code[4] = "200";
-    if (strncmp(response_buf, "HTTP/", 5) == 0)
-    {
-        char *status_start = strchr(response_buf, ' ');
-        if (status_start)
-        {
-            strncpy(status_code, status_start + 1, 3);
-            status_code[3] = '\0';
-        }
-    }
+  return resp;
+}
 
-    // Extract content-type from headers
-    const char *content_type = "application/mercurial-0.1";
-    char *ct_header = strcasestr(response_buf, "Content-Type:");
-    if (ct_header && ct_header < headers_end)
-    {
-        ct_header += 13; // Skip "Content-Type:"
-        while (*ct_header == ' ') ct_header++;
-        char *ct_end = strpbrk(ct_header, "\r\n");
-        if (ct_end)
-        {
-            size_t ct_len = ct_end - ct_header;
-            char *ct_copy = Dowa_Arena_Allocate(arena, ct_len + 1);
-            strncpy(ct_copy, ct_header, ct_len);
-            ct_copy[ct_len] = '\0';
-            content_type = ct_copy;
-        }
-    }
+Seobeo_Request_Entry* GetReactHome(Seobeo_Request_Entry *req, Dowa_Arena *arena)
+{
+  size_t file_size = 0;
+  char *html = Seobeo_Web_LoadFile("/index.html", &file_size);
 
-    // Body starts after \r\n\r\n
-    char *body = headers_end + 4;
-
-    Dowa_HashMap_Push_Arena(resp, "status", status_code, arena);
-    Dowa_HashMap_Push_Arena(resp, "content-type", content_type, arena);
-    Dowa_HashMap_Push_Arena(resp, "body", body, arena);
-
-    return resp;
+  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", "/graph", GetReactHome);
+
   Seobeo_Router_Register("GET", "/api/repo/list", ApiListDirectory);
   Seobeo_Router_Register("GET", "/api/repo/file", ApiGetFile);
+  Seobeo_Router_Register("GET", "/api/graph/:graph_id", ApiGetGraph);
   Seobeo_Router_Register("GET", "/api/repo/readme", ApiGetReadme);
 
-  Seobeo_Router_Register("GET", "/repo", ApiHgWireProtocol);
-  Seobeo_Router_Register("POST", "/repo", ApiHgWireProtocol);
+  // 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");
-  printf("Repository: %s\n", REPO_ROOT);
 
-  int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 4);
+  int result = Seobeo_Web_Server_Start("hg-web/src", "6970", SEOBEO_MODE_EDGE, 1);
 
   Seobeo_Router_Destroy();
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,30 @@
+# src
+
+Frontend source files for the Mercurial repository browser.
+
+## Files
+
+| File | Description |
+|------|-------------|
+| `repo-browser.tsx` | Main React component for the repository browser UI |
+| `main.tsx` | React entry point, renders RepoBrowser |
+| `index.html` | HTML template |
+| `index.js` | JavaScript entry point |
+| `index.css` | highlight.js theme styles |
+| `base.css` | Base/reset styles |
+| `build.ts` | esbuild configuration for bundling |
+
+## Components (repo-browser.tsx)
+
+- `RepoBrowser` - Main app component with routing and state
+- `FileList` - Renders directory listing
+- `FileRow` - Individual file/folder row with prefetch on hover
+- `FileViewer` - Modal for viewing code files with syntax highlighting
+- `MarkdownViewerModal` - Modal for rendered markdown files
+- `ReadmeViewer` - Inline README.md display
+- `Breadcrumb` - Navigation breadcrumb
+- `GlobalStyles` - CSS-in-JS with dark/light theme support
+
+## Subdirectories
+
+- `icons/` - Static icon assets
--- a/hg-web/src/base.css	Sat Jan 24 06:37:43 2026 -0800
+++ b/hg-web/src/base.css	Tue Jan 27 06:51:44 2026 -0800
@@ -1,123 +1,169 @@
-/* --- Colors  ---*/
+/* Reset CSS: https://meyerweb.com/eric/tools/css/reset/ */
+html, body, div, span, applet, object, iframe,
+h1, h2, h3, h4, h5, h6, p, blockquote, pre,
+a, abbr, acronym, address, big, cite, code,
+del, dfn, em, img, ins, kbd, q, s, samp,
+small, strike, strong, sub, sup, tt, var,
+b, u, i, center,
+dl, dt, dd, ol, ul, li,
+fieldset, form, label, legend,
+table, caption, tbody, tfoot, thead, tr, th, td,
+article, aside, canvas, details, embed, 
+figure, figcaption, footer, header, hgroup, 
+menu, nav, output, ruby, section, summary,
+time, mark, audio, video {
+	margin: 0;
+	padding: 0;
+	border: 0;
+	font-size: 100%;
+	font: inherit;
+	vertical-align: baseline;
+}
+
+/* ===========================================
+   Base CSS - Color Variables and Basic Setup
+   =========================================== */
+
+/* Light mode (default) */
 :root {
   --bg: #ffffff;
-  --fg: #1a1a1a;
-  --border: #e0e0e0;
-  --hover: #f5f5f5;
-  --accent: #0066cc;
-  --accent-hover: #0052a3;
-  --secondary: #6c757d;
-  --success: #28a745;
-  --warning: #ffc107;
-  --danger: #dc3545;
-  --code-bg: #f6f8fa;
-  --link: #0066cc;
-  --link-hover: #0052a3;
+  --bg-subtle: #f6f8fa;
+  --bg-code: #f6f8fa;
+  --border: #d0d7de;
+  --accent: #0969da;
+  --text-primary: #1f2328;
+  --text-secondary: #656d76;
+  --hover: #f3f4f6;
+  --success: #1a7f37;
+  --danger: #cf222e;
+  --danger-bg: #ffebe9;
+  --danger-border: #ffdce0;
+  --overlay: rgba(0, 0, 0, 0.5);
+
+  /* Graph colors - light mode */
+  --graph-1: #495057;
+  --graph-2: #1971c2;
+  --graph-3: #099268;
+  --graph-4: #e67700;
+  --graph-5: #7048e8;
+  --graph-6: #c92a2a;
+  --graph-7: #c2255c;
+  --graph-node-border: #ffffff;
 }
 
-.dark {
+/* Dark mode - applied when html has .dark class */
+:root.dark {
   --bg: #0d1117;
-  --fg: #c9d1d9;
+  --bg-subtle: #161b22;
+  --bg-code: #161b22;
   --border: #30363d;
-  --hover: #161b22;
   --accent: #58a6ff;
-  --accent-hover: #79c0ff;
-  --secondary: #8b949e;
-  --success: #3fb950;
-  --warning: #d29922;
+  --text-primary: #e6edf3;
+  --text-secondary: #8b949e;
+  --hover: #1c2128;
+  --success: #238636;
   --danger: #f85149;
-  --code-bg: #161b22;
-  --link: #58a6ff;
-  --link-hover: #79c0ff;
+  --danger-bg: #f8514926;
+  --danger-border: #f8514966;
+  --overlay: rgba(0, 0, 0, 0.7);
+
+  /* Graph colors - dark mode */
+  --graph-1: #868e96;
+  --graph-2: #4dabf7;
+  --graph-3: #63e6be;
+  --graph-4: #ffbc42;
+  --graph-5: #b197fc;
+  --graph-6: #ff8787;
+  --graph-7: #f06595;
+  --graph-node-border: #1a1a1a;
 }
 
+/* System preference fallback (when no explicit class is set) */
 @media (prefers-color-scheme: dark) {
-  :root:not(.light-mode) {
+  :root:not(.light) {
     --bg: #0d1117;
-    --fg: #c9d1d9;
+    --bg-subtle: #161b22;
+    --bg-code: #161b22;
     --border: #30363d;
-    --hover: #161b22;
     --accent: #58a6ff;
-    --accent-hover: #79c0ff;
-    --secondary: #8b949e;
-    --success: #3fb950;
-    --warning: #d29922;
+    --text-primary: #e6edf3;
+    --text-secondary: #8b949e;
+    --hover: #1c2128;
+    --success: #238636;
     --danger: #f85149;
-    --code-bg: #161b22;
-    --link: #58a6ff;
-    --link-hover: #79c0ff;
+    --danger-bg: #f8514926;
+    --danger-border: #f8514966;
+    --overlay: rgba(0, 0, 0, 0.7);
+
+    --graph-1: #868e96;
+    --graph-2: #4dabf7;
+    --graph-3: #63e6be;
+    --graph-4: #ffbc42;
+    --graph-5: #b197fc;
+    --graph-6: #ff8787;
+    --graph-7: #f06595;
+    --graph-node-border: #1a1a1a;
   }
 }
 
-/* --- Reset and Base Styles --- */
+/* Fonts */
+@font-face {
+  font-family: "Roboto";
+  src: url("/public/fonts/Roboto-Regular.ttf");
+}
+@font-face {
+  font-family: "Roboto Light";
+  src: url("/public/fonts/Roboto-Thin.ttf");
+}
+@font-face {
+  font-family: "More Thin";
+  src: url("/public/fonts/more-sugar.thin.otf");
+}
+@font-face {
+  font-family: "More";
+  src: url("/public/fonts/more-sugar.regular.otf");
+}
+
+button {
+  font-family: "More Thin", sans-serif;
+}
+
+/* Reset and Base */
 * {
   margin: 0;
   padding: 0;
   box-sizing: border-box;
 }
 
-html {
-  background: var(--bg);
-  color: var(--fg);
-}
-
-body {
-  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans', Helvetica, Arial, sans-serif;
-  line-height: 1.6;
+html, body {
   background: var(--bg);
-  color: var(--fg);
-  font-size: 16px;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
-}
-
-main {
-  max-width: 1200px;
-  margin: 0 auto;
-  padding: 2rem;
+  color: var(--text-primary);
+  font-family: "More Thin", sans-serif;
+  line-height: 1.6;
+  transition: background 0.2s, color 0.2s;
 }
 
 a {
-  color: var(--link);
+  color: var(--accent);
   text-decoration: none;
 }
 
 a:hover {
-  color: var(--link-hover);
   text-decoration: underline;
 }
 
-h1, h2, h3, h4, h5, h6 {
-  margin-bottom: 1rem;
-  font-weight: 600;
-  line-height: 1.25;
-}
-
-h1 { font-size: 2rem; }
-h2 { font-size: 1.75rem; }
-h3 { font-size: 1.5rem; }
-h4 { font-size: 1.25rem; }
-h5 { font-size: 1.1rem; }
-h6 { font-size: 1rem; }
-
-p {
-  margin-bottom: 1rem;
-}
-
 code {
-  background: var(--code-bg);
+  background: var(--bg-code);
   padding: 0.2em 0.4em;
   border-radius: 3px;
-  font-family: 'Monaco', 'Courier New', monospace;
   font-size: 0.9em;
 }
 
 pre {
-  background: var(--code-bg);
+  background: var(--bg-code);
   padding: 1rem;
   border-radius: 6px;
   overflow-x: auto;
-  margin-bottom: 1rem;
 }
 
 pre code {
@@ -125,17 +171,18 @@
   padding: 0;
 }
 
-/* Mobile responsive */
-@media (max-width: 768px) {
-  body {
-    font-size: 14px;
-  }
+/* Icon invert for dark mode */
+:root.dark .icon-invert {
+  filter: invert(0.8);
+}
 
-  main {
-    padding: 1rem;
-  }
+:root.light .icon-invert,
+:root:not(.dark):not(.light) .icon-invert {
+  filter: none;
+}
 
-  h1 { font-size: 1.75rem; }
-  h2 { font-size: 1.5rem; }
-  h3 { font-size: 1.25rem; }
+@media (prefers-color-scheme: dark) {
+  :root:not(.light) .icon-invert {
+    filter: invert(0.8);
+  }
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/components/app.tsx	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,389 @@
+import React, { useState, useEffect, useCallback } from 'react';
+import { Graph, useGraphData } from "hg-web/src/components/graph";
+import { DirectoryBrowser } from "hg-web/src/components/directory-browser";
+import { Header } from "hg-web/src/components/header";
+import { Footer } from "hg-web/src/components/footer";
+import { ThemeProvider, useTheme } from "hg-web/src/components/theme";
+
+type Page = 'landing' | 'graph' | 'directory';
+
+type RouteState = {
+  page: Page;
+  graphCommit?: string;
+  graphTip?: string;
+  dirPath?: string;
+}
+
+// Icons
+const ICONS = {
+  folder: "/icons/folder.png",
+};
+
+const GraphIcon = () => (
+  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <circle cx="6" cy="6" r="3"/>
+    <circle cx="6" cy="18" r="3"/>
+    <circle cx="18" cy="12" r="3"/>
+    <line x1="6" y1="9" x2="6" y2="15"/>
+    <path d="M8.5 7.5L15.5 11"/>
+  </svg>
+);
+
+const FolderIcon = () => (
+  <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" stroke="none">
+    <path d="M10 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2h-8l-2-2z"/>
+  </svg>
+);
+
+const API_BASE = '/api/repo';
+
+function parseRoute(): RouteState {
+  const params = new URLSearchParams(window.location.search);
+  const pathname = window.location.pathname;
+
+  if (pathname.startsWith('/graph') || params.has('graph')) {
+    return {
+      page: 'graph',
+      graphCommit: params.get('commit') || undefined,
+      graphTip: params.get('tip') || undefined,
+    };
+  }
+
+  if (pathname.startsWith('/directory') || params.has('path')) {
+    return {
+      page: 'directory',
+      dirPath: params.get('path') || '',
+    };
+  }
+
+  return { page: 'landing' };
+}
+
+function buildUrl(state: RouteState): string {
+  const params = new URLSearchParams();
+
+  switch (state.page) {
+    case 'graph':
+      if (state.graphCommit) params.set('commit', state.graphCommit);
+      if (state.graphTip) params.set('tip', state.graphTip);
+      return `/graph${params.toString() ? '?' + params.toString() : ''}`;
+    case 'directory':
+      if (state.dirPath) params.set('path', state.dirPath);
+      return `/directory${params.toString() ? '?' + params.toString() : ''}`;
+    default:
+      return '/';
+  }
+}
+
+// Landing Page Component
+function LandingPage({
+  onNavigateToGraph,
+  onNavigateToDirectory,
+}: {
+  onNavigateToGraph: () => void;
+  onNavigateToDirectory: (path?: string) => void;
+}) {
+  const [directories, setDirectories] = useState<any[]>([]);
+  const [files, setFiles] = useState<any[]>([]);
+  const [dirLoading, setDirLoading] = useState(true);
+
+  const { data: graphData, loading: graphLoading } = useGraphData();
+
+  useEffect(() => {
+    fetch(`${API_BASE}/list`)
+      .then(r => r.json())
+      .then(data => {
+        setDirectories(data.directories || []);
+        setFiles(data.files || []);
+        setDirLoading(false);
+      })
+      .catch(() => setDirLoading(false));
+  }, []);
+
+  const previewItems = [
+    ...directories.slice(0, 6),
+    ...files.slice(0, Math.max(0, 6 - directories.length))
+  ].slice(0, 6);
+
+  return (
+    <div className="landing-grid">
+      {/* Graph Preview */}
+      <div className="landing-section">
+        <div className="landing-section-header">
+          <span className="landing-section-title">
+            <GraphIcon />
+            Recent Commits
+          </span>
+          <a href="/graph" className="landing-section-link" onClick={(e) => {
+            e.preventDefault();
+            onNavigateToGraph();
+          }}>
+            View all
+          </a>
+        </div>
+        <div className="landing-section-content">
+          {graphLoading ? (
+            <div className="loading-state">Loading commits...</div>
+          ) : graphData ? (
+            <Graph
+              data={graphData}
+              maxRows={8}
+              onCommitClick={(node) => {
+                console.log('Clicked commit:', node);
+              }}
+            />
+          ) : (
+            <div className="empty-state">Failed to load commits</div>
+          )}
+        </div>
+      </div>
+
+      {/* Directory Preview */}
+      <div className="landing-section">
+        <div className="landing-section-header">
+          <span className="landing-section-title">
+            <FolderIcon />
+            Repository Files
+          </span>
+          <a href="/directory" className="landing-section-link" onClick={(e) => {
+            e.preventDefault();
+            onNavigateToDirectory();
+          }}>
+            Browse all
+          </a>
+        </div>
+        <div className="landing-section-content">
+          {dirLoading ? (
+            <div className="loading-state">Loading files...</div>
+          ) : previewItems.length > 0 ? (
+            previewItems.map((item) => (
+              <div
+                key={item.abspath}
+                className="dir-item"
+                onClick={() => onNavigateToDirectory(item.abspath)}
+              >
+                <span className="dir-item-icon">
+                  <img
+                    className="icon-invert"
+                    src={directories.includes(item) ? ICONS.folder : "/icons/file.svg"}
+                    alt=""
+                  />
+                </span>
+                <span className="dir-item-name">{item.basename}</span>
+              </div>
+            ))
+          ) : (
+            <div className="empty-state">No files found</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+// Graph Page Component
+function GraphPage({
+  onBack,
+  initialCommit,
+  initialTip,
+}: {
+  onBack: () => void;
+  initialCommit?: string;
+  initialTip?: string;
+}) {
+  const { data, loading, error, loadMore, hasMore, tip, currentCommit } = useGraphData({
+    initialCommit: initialCommit || null,
+    graphTop: initialTip || null,
+  });
+
+  useEffect(() => {
+    if (tip && currentCommit) {
+      const params = new URLSearchParams();
+      params.set('commit', currentCommit);
+      params.set('tip', tip);
+      const newUrl = `/graph?${params.toString()}`;
+      window.history.replaceState({ page: 'graph', graphCommit: currentCommit, graphTip: tip }, '', newUrl);
+    }
+  }, [currentCommit, tip]);
+
+  return (
+    <div>
+      <div className="page-header">
+        <button className="back-button" onClick={onBack}>
+          &larr; Back
+        </button>
+        <span className="page-title">Commit Graph</span>
+      </div>
+
+      {tip && (
+        <div className="graph-params">
+          <span className="graph-param">
+            <span className="graph-param-label">Tip:</span>
+            <span className="graph-param-value">{tip.substring(0, 12)}</span>
+          </span>
+          {currentCommit && currentCommit !== tip && (
+            <span className="graph-param">
+              <span className="graph-param-label">Current:</span>
+              <span className="graph-param-value">{currentCommit.substring(0, 12)}</span>
+            </span>
+          )}
+        </div>
+      )}
+
+      {error && (
+        <div className="error-message">Error: {error}</div>
+      )}
+
+      <Graph
+        data={data}
+        loading={loading}
+        hasMore={hasMore}
+        onLoadMore={loadMore}
+        onCommitClick={(node) => {
+          console.log('Clicked commit:', node);
+        }}
+      />
+    </div>
+  );
+}
+
+// Directory Page Component
+function DirectoryPage({
+  onBack,
+  initialPath,
+  onPathChange,
+}: {
+  onBack: () => void;
+  initialPath?: string;
+  onPathChange: (path: string) => void;
+}) {
+  return (
+    <div>
+      <div className="page-header">
+        <button className="back-button" onClick={onBack}>
+          &larr; Back
+        </button>
+        <span className="page-title">Repository Files</span>
+      </div>
+
+      <DirectoryBrowser
+        initialPath={initialPath}
+        onPathChange={onPathChange}
+      />
+    </div>
+  );
+}
+
+// Main App Content (uses theme context)
+function AppContent() {
+  const [route, setRoute] = useState<RouteState>(parseRoute);
+  const { isDark, toggleTheme } = useTheme();
+
+  // Handle browser back/forward
+  useEffect(() => {
+    const handlePopState = () => {
+      setRoute(parseRoute());
+    };
+    window.addEventListener('popstate', handlePopState);
+    return () => window.removeEventListener('popstate', handlePopState);
+  }, []);
+
+  const navigate = useCallback((newRoute: RouteState) => {
+    const url = buildUrl(newRoute);
+    window.history.pushState(newRoute, '', url);
+    setRoute(newRoute);
+  }, []);
+
+  const navigateToLanding = useCallback(() => {
+    navigate({ page: 'landing' });
+  }, [navigate]);
+
+  const navigateToGraph = useCallback((commit?: string, tip?: string) => {
+    navigate({ page: 'graph', graphCommit: commit, graphTip: tip });
+  }, [navigate]);
+
+  const navigateToDirectory = useCallback((path?: string) => {
+    navigate({ page: 'directory', dirPath: path || '' });
+  }, [navigate]);
+
+  const handleDirectoryPathChange = useCallback((path: string) => {
+    // Update URL without full navigation
+    const params = new URLSearchParams();
+    if (path) params.set('path', path);
+    const newUrl = `/directory${params.toString() ? '?' + params.toString() : ''}`;
+    window.history.replaceState({ page: 'directory', dirPath: path }, '', newUrl);
+    setRoute(prev => ({ ...prev, dirPath: path }));
+  }, []);
+
+  return (
+    <div className="app-container">
+      <Header
+        title="Zenbu Repository"
+        showThemeToggle={true}
+        isDark={isDark}
+        onToggleTheme={toggleTheme}
+      />
+
+      {/* Navigation Tabs */}
+      <div className="nav-tabs">
+        <button
+          className={`nav-tab ${route.page === 'landing' ? 'active' : ''}`}
+          onClick={navigateToLanding}
+        >
+          Home
+        </button>
+        <button
+          className={`nav-tab ${route.page === 'graph' ? 'active' : ''}`}
+          onClick={() => navigateToGraph()}
+        >
+          <GraphIcon />
+          Graph
+        </button>
+        <button
+          className={`nav-tab ${route.page === 'directory' ? 'active' : ''}`}
+          onClick={() => navigateToDirectory()}
+        >
+          <FolderIcon />
+          Files
+        </button>
+      </div>
+
+      {/* Page Content */}
+      {route.page === 'landing' && (
+        <LandingPage
+          onNavigateToGraph={() => navigateToGraph()}
+          onNavigateToDirectory={navigateToDirectory}
+        />
+      )}
+
+      {route.page === 'graph' && (
+        <GraphPage
+          onBack={navigateToLanding}
+          initialCommit={route.graphCommit}
+          initialTip={route.graphTip}
+        />
+      )}
+
+      {route.page === 'directory' && (
+        <DirectoryPage
+          onBack={navigateToLanding}
+          initialPath={route.dirPath}
+          onPathChange={handleDirectoryPathChange}
+        />
+      )}
+
+      <Footer />
+    </div>
+  );
+}
+
+// App wrapper with ThemeProvider
+function App() {
+  return (
+    <ThemeProvider>
+      <AppContent />
+    </ThemeProvider>
+  );
+}
+
+export { App };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/components/directory-browser.tsx	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,539 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js';
+import hljs from 'third_party/highlight/highlight.min.js';
+
+// --- ICONS (served as static files) ---
+const ICONS = {
+  folder: "/icons/folder.png",
+  file: "/icons/file.svg",
+  close: "/icons/close.png"
+};
+
+const API_BASE = '/api/repo';
+
+// File extensions that should be displayed as code
+const CODE_EXTENSIONS = new Set([
+  'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp',
+  'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash',
+  'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss',
+  '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'
+]);
+
+// Prefetch cache
+const prefetchCache = new Map<string, Promise<any>>();
+
+function isCodeFile(filename: string): boolean {
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  const basename = filename.toLowerCase();
+  return CODE_EXTENSIONS.has(ext) ||
+         CODE_EXTENSIONS.has(basename) ||
+         basename === 'dockerfile' ||
+         basename === 'makefile' ||
+         basename.startsWith('.');
+}
+
+function isMarkdownFile(filename: string): boolean {
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  return ext === 'md' || ext === 'markdown';
+}
+
+function prefetchDirectory(path: string): void {
+  const cacheKey = `dir:${path}`;
+  if (prefetchCache.has(cacheKey)) return;
+
+  const url = path
+    ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
+    : `${API_BASE}/list`;
+
+  prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null));
+}
+
+function prefetchFile(path: string): void {
+  const cacheKey = `file:${path}`;
+  if (prefetchCache.has(cacheKey)) return;
+
+  prefetchCache.set(cacheKey,
+    fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`)
+      .then(r => r.ok ? r.text() : null)
+      .catch(() => null)
+  );
+}
+
+async function getCachedFile(path: string): Promise<string | null> {
+  const cacheKey = `file:${path}`;
+  if (prefetchCache.has(cacheKey)) {
+    return prefetchCache.get(cacheKey);
+  }
+  prefetchFile(path);
+  return prefetchCache.get(cacheKey)!;
+}
+
+/**
+ * Component: Breadcrumb
+ */
+function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) {
+  if (!currentPath) {
+    return (
+      <nav className="breadcrumb">
+        <span className="nav-item active">root</span>
+      </nav>
+    );
+  }
+
+  const parts = currentPath.split('/').filter(p => p);
+  const crumbs = parts.map((part, index) => ({
+    name: part,
+    fullPath: parts.slice(0, index + 1).join('/')
+  }));
+
+  return (
+    <nav className="breadcrumb">
+      <a
+        href="#"
+        onClick={(e) => { e.preventDefault(); onNavigate(''); }}
+        title="Go to Root"
+      >
+        root
+      </a>
+      {crumbs.map((crumb, index) => {
+        const isLast = index === crumbs.length - 1;
+        return (
+          <React.Fragment key={crumb.fullPath}>
+            <span className="separator">/</span>
+            {isLast ? (
+              <span className="nav-item active">{crumb.name}</span>
+            ) : (
+              <a
+                href="#"
+                onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }}
+              >
+                {crumb.name}
+              </a>
+            )}
+          </React.Fragment>
+        );
+      })}
+    </nav>
+  );
+}
+
+/**
+ * Component: FileViewer
+ * Shows file content inline with syntax highlighting
+ */
+function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const [content, setContent] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const codeRef = useRef<HTMLElement>(null);
+
+  const filename = filePath.split('/').pop() || filePath;
+
+  useEffect(() => {
+    setLoading(true);
+    getCachedFile(filePath).then((text) => {
+      setContent(text);
+      setLoading(false);
+    });
+  }, [filePath]);
+
+  useEffect(() => {
+    if (content && codeRef.current) {
+      hljs.highlightElement(codeRef.current);
+    }
+  }, [content]);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  const getLanguage = () => {
+    const ext = filename.split('.').pop()?.toLowerCase() || '';
+    const langMap: Record<string, string> = {
+      js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
+      py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
+      c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
+      sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
+      json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
+      html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss',
+      sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml',
+      dockerfile: 'dockerfile', makefile: 'makefile'
+    };
+    return langMap[ext] || 'plaintext';
+  };
+
+  const addLineNumbers = (text: string) => {
+    const lines = text.split('\n');
+    return lines.map((_, i) => i + 1).join('\n');
+  };
+
+  return (
+    <div className="file-viewer-overlay" onClick={onClose}>
+      <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+            <img className="icon-invert" src={ICONS.close} alt="Close" />
+          </button>
+        </div>
+        <div className="file-viewer-content">
+          {loading ? (
+            <div className="file-viewer-loading">Loading...</div>
+          ) : content ? (
+            <pre style={{ display: 'flex' }}>
+              <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span>
+              <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code>
+            </pre>
+          ) : (
+            <div className="file-viewer-loading">Unable to load file</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Component: MarkdownViewerModal
+ */
+function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const [content, setContent] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const contentRef = useRef<HTMLDivElement>(null);
+  const moduleRef = useRef<any>(null);
+  const [wasmReady, setWasmReady] = useState(false);
+
+  const filename = filePath.split('/').pop() || filePath;
+
+  useEffect(() => {
+    createMarkdownModule().then((Module: any) => {
+      moduleRef.current = Module;
+      setWasmReady(true);
+    });
+  }, []);
+
+  useEffect(() => {
+    setLoading(true);
+    getCachedFile(filePath).then((text) => {
+      setContent(text);
+      setLoading(false);
+    });
+  }, [filePath]);
+
+  useEffect(() => {
+    if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
+
+    const Module = moduleRef.current;
+    const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
+    const markdownFree = Module.cwrap('markdown_free', null, ['number']);
+
+    const ptr = markdownToHtmlPtr(content);
+    const html = Module.UTF8ToString(ptr);
+    markdownFree(ptr);
+    contentRef.current.innerHTML = html;
+  }, [content, wasmReady]);
+
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.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" onClick={(e) => e.stopPropagation()}>
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+            <img className="icon-invert" src={ICONS.close} alt="Close" />
+          </button>
+        </div>
+        <div className="file-viewer-content">
+          {loading || !wasmReady ? (
+            <div className="file-viewer-loading">Loading...</div>
+          ) : content ? (
+            <div className="readme-content" ref={contentRef} />
+          ) : (
+            <div className="file-viewer-loading">Unable to load file</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Component: FileList
+ */
+function FileList({ directories, files, onNavigate, onOpenFile }: {
+  directories: any[];
+  files: any[];
+  onNavigate: (path: string) => void;
+  onOpenFile: (path: string) => void;
+}) {
+  const isEmpty = directories.length === 0 && files.length === 0;
+
+  if (isEmpty) {
+    return (
+      <div className="file-list-container">
+        <div className="empty-state">This directory is empty.</div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="file-list-container">
+      <div className="file-header">Files</div>
+      <div id="fileListBody">
+        {directories.map((dir) => (
+          <FileRow
+            key={dir.abspath}
+            item={dir}
+            iconUrl={ICONS.folder}
+            isDir={true}
+            onNavigate={onNavigate}
+            onOpenFile={onOpenFile}
+          />
+        ))}
+        {files.map((file) => (
+          <FileRow
+            key={file.abspath}
+            item={file}
+            iconUrl={ICONS.file}
+            isDir={false}
+            onNavigate={onNavigate}
+            onOpenFile={onOpenFile}
+          />
+        ))}
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Component: FileRow
+ */
+function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: {
+  item: { abspath: string; basename: string };
+  iconUrl: string;
+  isDir: boolean;
+  onNavigate: (path: string) => void;
+  onOpenFile: (path: string) => void;
+}) {
+  const handleClick = (e: React.MouseEvent) => {
+    e.preventDefault();
+    if (isDir) {
+      onNavigate(item.abspath);
+    } else if (isCodeFile(item.basename)) {
+      onOpenFile(item.abspath);
+    } else {
+      window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
+    }
+  };
+
+  const handleMouseEnter = () => {
+    if (isDir) {
+      prefetchDirectory(item.abspath);
+    } else if (isCodeFile(item.basename)) {
+      prefetchFile(item.abspath);
+    }
+  };
+
+  const href = isDir
+    ? `#`
+    : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`;
+
+  return (
+    <div className="file-row" onMouseEnter={handleMouseEnter}>
+      <span className="icon">
+        <img className="icon-invert" src={iconUrl} alt={isDir ? "Directory" : "File"} />
+      </span>
+      <span className="name">
+        <a href={href} onClick={handleClick}>
+          {item.basename}
+        </a>
+      </span>
+    </div>
+  );
+}
+
+/**
+ * Component: ReadmeViewer
+ */
+function ReadmeViewer({ content }: { content: string | null }) {
+  const contentRef = useRef<HTMLDivElement>(null);
+  const moduleRef = useRef<any>(null);
+  const [wasmReady, setWasmReady] = useState(false);
+
+  useEffect(() => {
+    createMarkdownModule().then((Module: any) => {
+      moduleRef.current = Module;
+      setWasmReady(true);
+    });
+  }, []);
+
+  useEffect(() => {
+    if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
+
+    const Module = moduleRef.current;
+    const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
+    const markdownFree = Module.cwrap('markdown_free', null, ['number']);
+
+    const ptr = markdownToHtmlPtr(content);
+    const html = Module.UTF8ToString(ptr);
+    markdownFree(ptr);
+    contentRef.current.innerHTML = html;
+  }, [content, wasmReady]);
+
+  if (!content) return null;
+
+  return (
+    <div className="readme-section">
+      <div className="readme-header">
+        <img className="icon-invert" src={ICONS.file} width="16" alt="" style={{ opacity: 0.5 }} />
+        README.md
+      </div>
+      <div className="readme-content" ref={contentRef}>
+        {!wasmReady && 'Loading...'}
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Directory Browser Component (no header/footer - for embedding in app)
+ */
+interface DirectoryBrowserProps {
+  initialPath?: string;
+  onPathChange?: (path: string) => void;
+}
+
+function DirectoryBrowser({ initialPath = '', onPathChange }: DirectoryBrowserProps) {
+  const [currentPath, setCurrentPath] = useState(initialPath);
+  const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] });
+  const [readme, setReadme] = useState<string | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [viewingFile, setViewingFile] = useState<string | null>(null);
+
+  // Sync with initialPath prop
+  useEffect(() => {
+    setCurrentPath(initialPath);
+  }, [initialPath]);
+
+  useEffect(() => {
+    fetchDirectory(currentPath);
+    fetchReadme(currentPath);
+  }, [currentPath]);
+
+  const navigate = useCallback((path: string) => {
+    setCurrentPath(path);
+    onPathChange?.(path);
+  }, [onPathChange]);
+
+  const fetchDirectory = async (path: string) => {
+    setLoading(true);
+    setError(null);
+    try {
+      const cacheKey = `dir:${path}`;
+      let data;
+      if (prefetchCache.has(cacheKey)) {
+        data = await prefetchCache.get(cacheKey);
+        prefetchCache.delete(cacheKey);
+      } else {
+        const url = path
+          ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
+          : `${API_BASE}/list`;
+        const response = await fetch(url);
+        if (response.ok) {
+          data = await response.json();
+        }
+      }
+
+      if (data?.error) {
+        throw new Error(data.error);
+      }
+
+      setContent({
+        files: data?.files || [],
+        directories: data?.directories || []
+      });
+    } catch (err: any) {
+      console.error('Error loading directory:', err);
+      setError(err.message);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const fetchReadme = async (path: string) => {
+    setReadme(null);
+    const readmePath = path ? `${path}/README.md` : 'README.md';
+    try {
+      const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`);
+      if (response.ok) {
+        const text = await response.text();
+        setReadme(text);
+      }
+    } catch (err) {
+      // Readme is optional
+    }
+  };
+
+  const handleOpenFile = useCallback((path: string) => {
+    setViewingFile(path);
+  }, []);
+
+  const handleCloseFile = useCallback(() => {
+    setViewingFile(null);
+  }, []);
+
+  return (
+    <>
+      <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
+
+      {error && <div className="error-message">Error: {error}</div>}
+
+      {loading ? (
+        <div className="file-list-container">
+          <div className="loading-state">Loading files...</div>
+        </div>
+      ) : (
+        <>
+          <FileList
+            directories={content.directories}
+            files={content.files}
+            onNavigate={navigate}
+            onOpenFile={handleOpenFile}
+          />
+          <ReadmeViewer content={readme} />
+        </>
+      )}
+
+      {/* File Viewer Modal */}
+      {viewingFile && (
+        isMarkdownFile(viewingFile) ? (
+          <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
+        ) : (
+          <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
+        )
+      )}
+    </>
+  );
+}
+
+export { DirectoryBrowser };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/components/footer.tsx	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,31 @@
+import React from 'react';
+
+interface FooterProps {
+  showCloneUrl?: boolean;
+  cloneUrl?: string;
+}
+
+function Footer({
+  showCloneUrl = false,
+  cloneUrl = "hg clone http://zenbu.babocoder.com/repo",
+}: FooterProps) {
+  const currentYear = new Date().getFullYear();
+
+  return (
+    <footer className="footer">
+      {showCloneUrl && (
+        <div className="clone-box">
+          <div className="clone-box-inner">
+            <span className="clone-label">Clone HTTPS</span>
+            <code className="clone-url">{cloneUrl}</code>
+          </div>
+        </div>
+      )}
+      <div className="footer-content">
+        <span className="footer-text">&copy; 2026 June Park</span>
+      </div>
+    </footer>
+  );
+}
+
+export { Footer };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/components/graph.tsx	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,313 @@
+import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
+
+// Configuration constants for the layout
+const rowHeight = 40;
+const colWidth = 20;
+const nodeRadius = 4.5;
+
+// --- Interfaces ---
+
+interface Changeset {
+  node: string;
+  date: [number, number];
+  desc: string;
+  branch: string;
+  bookmarks: string[];
+  tags: string[];
+  user: string;
+  phase: string;
+  col: number;
+  row: number;
+  color: number;
+  edges: Array<{
+    bcolor: string;
+    col: number;
+    color: number;
+    nextcol: number;
+    width: number;
+  }>;
+  parents: string[];
+}
+
+interface GraphData {
+  node: string;
+  changeset_count: number;
+  changesets: Changeset[];
+}
+
+interface UseGraphDataOptions {
+  initialCommit?: string | null;
+  graphTop?: string | null;
+}
+
+interface UseGraphDataResult {
+  data: GraphData | null;
+  loading: boolean;
+  error: string | null;
+  loadMore: () => void;
+  hasMore: boolean;
+  tip: string | null;
+  currentCommit: string | null;
+}
+
+// --- Hook Logic ---
+
+function useGraphData({ initialCommit = null, graphTop = null }: UseGraphDataOptions = {}): UseGraphDataResult {
+  const [data, setData] = useState<GraphData | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [tip, setTip] = useState<string | null>(graphTop);
+  const [currentCommit, setCurrentCommit] = useState<string | null>(initialCommit);
+  const [hasMore, setHasMore] = useState(true);
+
+  const fetchData = useCallback(async (commit: string | null, tipNode: string | null, append: boolean = false) => {
+    if (loading) return;
+    setLoading(true);
+    setError(null);
+
+    try {
+      const url = !commit 
+        ? `/api/graph/tip?style=json` 
+        : `/api/graph/${commit}?graphtop=${tipNode}&style=json`;
+
+      const response = await fetch(url);
+      if (!response.ok) throw new Error(`Fetch failed: ${response.status}`);
+      
+      const result: GraphData = await response.json();
+
+      setData(prev => {
+        if (append && prev) {
+          const existingNodes = new Set(prev.changesets.map(cs => cs.node));
+          const newChangesets = result.changesets.filter(cs => !existingNodes.has(cs.node));
+          
+          // Re-index rows to ensure they increment correctly for the canvas height
+          const startRow = prev.changesets.length;
+          const reindexed = newChangesets.map((cs, idx) => ({
+            ...cs,
+            row: startRow + idx
+          }));
+
+          return {
+            ...result,
+            changesets: [...prev.changesets, ...reindexed]
+          };
+        }
+        return result;
+      });
+
+      if (!tip && !append) setTip(result.node);
+
+      if (result.changesets.length > 0) {
+        const lastNode = result.changesets[result.changesets.length - 1].node;
+        setCurrentCommit(lastNode);
+        setHasMore(result.changesets.length >= 30);
+      } else {
+        setHasMore(false);
+      }
+    } catch (err: any) {
+      setError(err.message);
+    } finally {
+      setLoading(false);
+    }
+  }, [tip, loading]);
+
+  useEffect(() => {
+    fetchData(initialCommit, graphTop, false);
+  }, [initialCommit, graphTop]);
+
+  const loadMore = useCallback(() => {
+    if (!loading && hasMore && currentCommit && tip) {
+      fetchData(currentCommit, tip, true);
+    }
+  }, [loading, hasMore, currentCommit, tip, fetchData]);
+
+  return { data, loading, error, loadMore, hasMore, tip, currentCommit };
+}
+
+// --- Pencil Rendering Logic ---
+
+const drawPencilLine = (
+  ctx: CanvasRenderingContext2D,
+  x1: number, y1: number,
+  x2: number, y2: number,
+  texture: CanvasPattern | null, // Ensure type safety
+  isCurve: boolean = false
+) => {
+  const strokes = 3;
+  ctx.save();
+
+  for (let s = 0; s < strokes; s++) {
+    ctx.beginPath();
+    ctx.strokeStyle = texture;
+    ctx.globalAlpha = 0.2 + (s * 0.2); 
+    ctx.lineWidth = 1.5 - (s * 0.2); // Pencil lines are usually thinner
+
+    // 2. Realistic Jitter: Actually return a random small number
+    const jitter = () => (Math.random() - 0.5) * 1.5;
+
+    ctx.moveTo(x1 + jitter(), y1 + jitter());
+
+    if (isCurve) {
+      const cpY = y1 + (y2 - y1) / 2;
+      ctx.bezierCurveTo(
+        x1 + jitter(), cpY + jitter(), 
+        x2 + jitter(), cpY + jitter(), 
+        x2 + jitter(), y2 + jitter()
+      );
+    } else {
+      ctx.lineTo(x2 + jitter(), y2 + jitter());
+    }
+    
+    ctx.stroke();
+  }
+  ctx.restore();
+}
+
+// --- Main Component ---
+
+interface GraphProps {
+  data: GraphData | null;
+  loading?: boolean;
+  hasMore?: boolean;
+  onLoadMore?: () => void;
+  onCommitClick?: (node: string) => void;
+  maxRows?: number;
+}
+
+const Graph = ({ data, loading, hasMore, onLoadMore, onCommitClick, maxRows }: GraphProps) => {
+  const canvasRef = useRef<HTMLCanvasElement>(null);
+  const containerRef = useRef<HTMLDivElement>(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;
+
+    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();
+    const dpr = window.devicePixelRatio || 1;
+    const maxCol = Math.max(...changesets.map(cs => cs.col), 0);
+    const canvasWidth = (maxCol + 2) * colWidth;
+    
+    // Scale for high-DPI screens
+    canvas.width = canvasWidth * dpr;
+    canvas.height = changesets.length * rowHeight * dpr;
+    canvas.style.width = `${canvasWidth}px`;
+    canvas.style.height = `${changesets.length * rowHeight}px`;
+    ctx.scale(dpr, dpr);
+    ctx.clearRect(0, 0, canvasWidth, changesets.length * rowHeight);
+
+    const getX = (col: number) => (col + 1) * colWidth;
+    const getY = (row: number) => (row * rowHeight) + (rowHeight / 2);
+
+    const renderCanvas = () => {
+      if (!pencilPattern) return; // Don't draw if the pattern isn't ready
+    
+      // Pass 1: Draw Connecting Edges
+      changesets.forEach((cs, i) => {
+        if (!cs.edges) return;
+        cs.edges.forEach(edge => {
+          const sX = getX(edge.col), sY = getY(i);
+          const eX = getX(edge.nextcol), eY = getY(i + 1);
+          
+          drawPencilLine(ctx, sX, sY, eX, eY, pencilPattern, edge.col !== edge.nextcol);
+        });
+      });
+
+      // 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);
+      });
+    };
+
+    img.onload = () => {
+      pencilPattern = ctx.createPattern(img, "repeat")!;
+      renderCanvas(); 
+    };
+  }, [changesets]);
+
+  // Handle Infinite Scroll via Intersection Observer
+  useEffect(() => {
+    if (!onLoadMore || !hasMore) return;
+    const observer = new IntersectionObserver((entries) => {
+      if (entries[0].isIntersecting && !loading) {
+        onLoadMore();
+      }
+    }, { threshold: 0.1 });
+
+    const sentinel = document.getElementById('infinite-scroll-sentinel');
+    if (sentinel) observer.observe(sentinel);
+    return () => observer.disconnect();
+  }, [onLoadMore, hasMore, loading]);
+
+  return (
+    <div style={{ display: 'flex', flexDirection: 'column', height: '100%', backgroundImage: 'url("/hg-web-background.jpg")', fontFamily: 'monospace' }}>
+      <div 
+        ref={containerRef} 
+        style={{ display: 'flex', flex: 1, overflowY: 'auto', position: 'relative' }}
+      >
+        {/* 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' }}>
+          <canvas ref={canvasRef} style={{ display: 'block' }} />
+        </div>
+
+        {/* Details Column */}
+        <div style={{ flex: 1 }}>
+          {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'
+              }}
+              onClick={() => onCommitClick?.(cs.node)}
+              onMouseEnter={(e) => (e.currentTarget.style.background = '#222')}
+              onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
+            >
+              <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>
+          ))}
+          <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>}
+    </div>
+  );
+};
+
+export { Graph, useGraphData };
+export type { GraphData, Changeset, UseGraphDataResult };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/components/header.tsx	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,60 @@
+import React from 'react';
+
+// Icons
+const ICONS = {
+  repo: "/public/epi_all_colors.svg",
+};
+
+const SunIcon = () => (
+  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <circle cx="12" cy="12" r="5"/>
+    <line x1="12" y1="1" x2="12" y2="3"/>
+    <line x1="12" y1="21" x2="12" y2="23"/>
+    <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
+    <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
+    <line x1="1" y1="12" x2="3" y2="12"/>
+    <line x1="21" y1="12" x2="23" y2="12"/>
+    <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
+    <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
+  </svg>
+);
+
+const MoonIcon = () => (
+  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
+    <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
+  </svg>
+);
+
+interface HeaderProps {
+  title?: string;
+  subtitle?: string;
+  showThemeToggle?: boolean;
+  isDark?: boolean;
+  onToggleTheme?: () => void;
+}
+
+function Header({
+  title = "Zenbu Repository",
+  subtitle,
+  showThemeToggle = true,
+  isDark = false,
+  onToggleTheme,
+}: HeaderProps) {
+  return (
+    <header className="header">
+      <img src={ICONS.repo} alt="Zenbu" className="header-icon" />
+      <div className="header-content">
+        <h1><a href="/">{title}</a></h1>
+        {subtitle && <p className="header-subtitle">{subtitle}</p>}
+      </div>
+      {showThemeToggle && onToggleTheme && (
+        <button className="theme-toggle" onClick={onToggleTheme} title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}>
+          {isDark ? <SunIcon /> : <MoonIcon />}
+          <span>{isDark ? 'Light' : 'Dark'}</span>
+        </button>
+      )}
+    </header>
+  );
+}
+
+export { Header };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/components/repo-browser.tsx	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,584 @@
+import React, { useState, useEffect, useRef, useCallback } from 'react';
+import createMarkdownModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js';
+import hljs from 'third_party/highlight/highlight.min.js';
+import { Header } from "hg-web/src/components/header";
+import { Footer } from "hg-web/src/components/footer";
+import { ThemeProvider, useTheme } from "hg-web/src/components/theme";
+
+// --- ICONS (served as static files) ---
+const ICONS = {
+  folder: "/icons/folder.png",
+  file: "/icons/file.svg",
+  close: "/icons/close.png"
+};
+
+const API_BASE = '/api/repo';
+
+// File extensions that should be displayed as code
+const CODE_EXTENSIONS = new Set([
+  'js', 'jsx', 'ts', 'tsx', 'py', 'rb', 'java', 'c', 'cpp', 'h', 'hpp',
+  'cs', 'go', 'rs', 'swift', 'kt', 'scala', 'php', 'pl', 'sh', 'bash',
+  'zsh', 'fish', 'ps1', 'bat', 'cmd', 'html', 'htm', 'css', 'scss',
+  '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'
+]);
+
+// Prefetch cache
+const prefetchCache = new Map<string, Promise<any>>();
+
+function isCodeFile(filename: string): boolean {
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  const basename = filename.toLowerCase();
+  return CODE_EXTENSIONS.has(ext) ||
+         CODE_EXTENSIONS.has(basename) ||
+         basename === 'dockerfile' ||
+         basename === 'makefile' ||
+         basename.startsWith('.');
+}
+
+function isMarkdownFile(filename: string): boolean {
+  const ext = filename.split('.').pop()?.toLowerCase() || '';
+  return ext === 'md' || ext === 'markdown';
+}
+
+function prefetchDirectory(path: string): void {
+  const cacheKey = `dir:${path}`;
+  if (prefetchCache.has(cacheKey)) return;
+
+  const url = path
+    ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
+    : `${API_BASE}/list`;
+
+  prefetchCache.set(cacheKey, fetch(url).then(r => r.json()).catch(() => null));
+}
+
+function prefetchFile(path: string): void {
+  const cacheKey = `file:${path}`;
+  if (prefetchCache.has(cacheKey)) return;
+
+  prefetchCache.set(cacheKey,
+    fetch(`${API_BASE}/file?path=${encodeURIComponent(path)}`)
+      .then(r => r.ok ? r.text() : null)
+      .catch(() => null)
+  );
+}
+
+async function getCachedFile(path: string): Promise<string | null> {
+  const cacheKey = `file:${path}`;
+  if (prefetchCache.has(cacheKey)) {
+    return prefetchCache.get(cacheKey);
+  }
+  prefetchFile(path);
+  return prefetchCache.get(cacheKey)!;
+}
+
+/**
+ * Component: Breadcrumb
+ */
+function Breadcrumb({ currentPath, onNavigate }: { currentPath: string; onNavigate: (path: string) => void }) {
+  if (!currentPath) {
+    return (
+      <nav className="breadcrumb">
+        <span className="nav-item active">root</span>
+      </nav>
+    );
+  }
+
+  const parts = currentPath.split('/').filter(p => p);
+  const crumbs = parts.map((part, index) => ({
+    name: part,
+    fullPath: parts.slice(0, index + 1).join('/')
+  }));
+
+  return (
+    <nav className="breadcrumb">
+      <a
+        href="/"
+        onClick={(e) => { e.preventDefault(); onNavigate(''); }}
+        title="Go to Root"
+      >
+        root
+      </a>
+      {crumbs.map((crumb, index) => {
+        const isLast = index === crumbs.length - 1;
+        return (
+          <React.Fragment key={crumb.fullPath}>
+            <span className="separator">/</span>
+            {isLast ? (
+              <span className="nav-item active">{crumb.name}</span>
+            ) : (
+              <a
+                href={`?path=${encodeURIComponent(crumb.fullPath)}`}
+                onClick={(e) => { e.preventDefault(); onNavigate(crumb.fullPath); }}
+              >
+                {crumb.name}
+              </a>
+            )}
+          </React.Fragment>
+        );
+      })}
+    </nav>
+  );
+}
+
+/**
+ * Component: FileViewer
+ * Shows file content inline with syntax highlighting
+ */
+function FileViewer({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const [content, setContent] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const codeRef = useRef<HTMLElement>(null);
+
+  const filename = filePath.split('/').pop() || filePath;
+
+  useEffect(() => {
+    setLoading(true);
+    getCachedFile(filePath).then((text) => {
+      setContent(text);
+      setLoading(false);
+    });
+  }, [filePath]);
+
+  useEffect(() => {
+    if (content && codeRef.current) {
+      hljs.highlightElement(codeRef.current);
+    }
+  }, [content]);
+
+  // Close on escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', handleKeyDown);
+    return () => window.removeEventListener('keydown', handleKeyDown);
+  }, [onClose]);
+
+  // Get language from file extension for highlight.js
+  const getLanguage = () => {
+    const ext = filename.split('.').pop()?.toLowerCase() || '';
+    const langMap: Record<string, string> = {
+      js: 'javascript', jsx: 'javascript', ts: 'typescript', tsx: 'typescript',
+      py: 'python', rb: 'ruby', rs: 'rust', go: 'go', java: 'java',
+      c: 'c', cpp: 'cpp', h: 'c', hpp: 'cpp', cs: 'csharp',
+      sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
+      json: 'json', yaml: 'yaml', yml: 'yaml', toml: 'toml',
+      html: 'html', htm: 'html', css: 'css', scss: 'scss', sass: 'scss',
+      sql: 'sql', md: 'markdown', markdown: 'markdown', xml: 'xml',
+      dockerfile: 'dockerfile', makefile: 'makefile'
+    };
+    return langMap[ext] || 'plaintext';
+  };
+
+  const addLineNumbers = (text: string) => {
+    const lines = text.split('\n');
+    return lines.map((_, i) => i + 1).join('\n');
+  };
+
+  return (
+    <div className="file-viewer-overlay" onClick={onClose}>
+      <div className="file-viewer" onClick={(e) => e.stopPropagation()}>
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+            <img className="icon-invert" src={ICONS.close} alt="Close" />
+          </button>
+        </div>
+        <div className="file-viewer-content">
+          {loading ? (
+            <div className="file-viewer-loading">Loading...</div>
+          ) : content ? (
+            <pre style={{ display: 'flex' }}>
+              <span className="file-viewer-line-numbers">{addLineNumbers(content)}</span>
+              <code ref={codeRef} className={`language-${getLanguage()}`}>{content}</code>
+            </pre>
+          ) : (
+            <div className="file-viewer-loading">Unable to load file</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Component: MarkdownViewerModal
+ * Shows markdown content rendered in a modal
+ */
+function MarkdownViewerModal({ filePath, onClose }: { filePath: string; onClose: () => void }) {
+  const [content, setContent] = useState<string | null>(null);
+  const [loading, setLoading] = useState(true);
+  const contentRef = useRef<HTMLDivElement>(null);
+  const moduleRef = useRef<any>(null);
+  const [wasmReady, setWasmReady] = useState(false);
+
+  const filename = filePath.split('/').pop() || filePath;
+
+  useEffect(() => {
+    createMarkdownModule().then((Module: any) => {
+      moduleRef.current = Module;
+      setWasmReady(true);
+    });
+  }, []);
+
+  useEffect(() => {
+    setLoading(true);
+    getCachedFile(filePath).then((text) => {
+      setContent(text);
+      setLoading(false);
+    });
+  }, [filePath]);
+
+  useEffect(() => {
+    if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
+
+    const Module = moduleRef.current;
+    const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
+    const markdownFree = Module.cwrap('markdown_free', null, ['number']);
+
+    const ptr = markdownToHtmlPtr(content);
+    const html = Module.UTF8ToString(ptr);
+    markdownFree(ptr);
+    contentRef.current.innerHTML = html;
+  }, [content, wasmReady]);
+
+  // Close on escape key
+  useEffect(() => {
+    const handleKeyDown = (e: KeyboardEvent) => {
+      if (e.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" onClick={(e) => e.stopPropagation()}>
+        <div className="file-viewer-header">
+          <span className="file-viewer-title">
+            <img src={ICONS.file} alt="" style={{ width: 16, height: 16 }} />
+            {filename}
+          </span>
+          <button className="file-viewer-close" onClick={onClose} title="Close (Esc)">
+            <img className="icon-invert" src={ICONS.close} alt="Close" />
+          </button>
+        </div>
+        <div className="file-viewer-content">
+          {loading || !wasmReady ? (
+            <div className="file-viewer-loading">Loading...</div>
+          ) : content ? (
+            <div className="readme-content" ref={contentRef} />
+          ) : (
+            <div className="file-viewer-loading">Unable to load file</div>
+          )}
+        </div>
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Component: FileList
+ */
+function FileList({ directories, files, onNavigate, onOpenFile }: {
+  directories: any[];
+  files: any[];
+  onNavigate: (path: string) => void;
+  onOpenFile: (path: string) => void;
+}) {
+  const isEmpty = directories.length === 0 && files.length === 0;
+
+  if (isEmpty) {
+    return (
+      <div className="file-list-container">
+        <div className="empty-state">This directory is empty.</div>
+      </div>
+    );
+  }
+
+  return (
+    <div className="file-list-container">
+      <div className="file-header">Files</div>
+
+      <div id="fileListBody">
+        {directories.map((dir) => (
+          <FileRow
+            key={dir.abspath}
+            item={dir}
+            iconUrl={ICONS.folder}
+            isDir={true}
+            onNavigate={onNavigate}
+            onOpenFile={onOpenFile}
+          />
+        ))}
+
+        {files.map((file) => (
+          <FileRow
+            key={file.abspath}
+            item={file}
+            iconUrl={ICONS.file}
+            isDir={false}
+            onNavigate={onNavigate}
+            onOpenFile={onOpenFile}
+          />
+        ))}
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Component: FileRow
+ */
+function FileRow({ item, iconUrl, isDir, onNavigate, onOpenFile }: {
+  item: { abspath: string; basename: string };
+  iconUrl: string;
+  isDir: boolean;
+  onNavigate: (path: string) => void;
+  onOpenFile: (path: string) => void;
+}) {
+  const handleClick = (e: React.MouseEvent) => {
+    e.preventDefault();
+    if (isDir) {
+      onNavigate(item.abspath);
+    } else if (isCodeFile(item.basename)) {
+      onOpenFile(item.abspath);
+    } else {
+      window.open(`/api/repo/file?path=${encodeURIComponent(item.abspath)}`, '_blank');
+    }
+  };
+
+  const handleMouseEnter = () => {
+    if (isDir) {
+      prefetchDirectory(item.abspath);
+    } else if (isCodeFile(item.basename)) {
+      prefetchFile(item.abspath);
+    }
+  };
+
+  const href = isDir
+    ? `?path=${encodeURIComponent(item.abspath)}`
+    : `/api/repo/file?path=${encodeURIComponent(item.abspath)}`;
+
+  return (
+    <div className="file-row" onMouseEnter={handleMouseEnter}>
+      <span className="icon">
+        <img className="icon-invert" src={iconUrl} alt={isDir ? "Directory" : "File"} />
+      </span>
+      <span className="name">
+        <a href={href} onClick={handleClick}>
+          {item.basename}
+        </a>
+      </span>
+    </div>
+  );
+}
+
+/**
+ * Component: ReadmeViewer
+ */
+function ReadmeViewer({ content }: { content: string | null }) {
+  const contentRef = useRef<HTMLDivElement>(null);
+  const moduleRef = useRef<any>(null);
+  const [wasmReady, setWasmReady] = useState(false);
+
+  useEffect(() => {
+    createMarkdownModule().then((Module: any) => {
+      moduleRef.current = Module;
+      setWasmReady(true);
+    });
+  }, []);
+
+  useEffect(() => {
+    if (!content || !wasmReady || !contentRef.current || !moduleRef.current) return;
+
+    const Module = moduleRef.current;
+    const markdownToHtmlPtr = Module.cwrap('markdown_to_html', 'number', ['string']);
+    const markdownFree = Module.cwrap('markdown_free', null, ['number']);
+
+    const ptr = markdownToHtmlPtr(content);
+    const html = Module.UTF8ToString(ptr);
+    markdownFree(ptr);
+    contentRef.current.innerHTML = html;
+  }, [content, wasmReady]);
+
+  if (!content) return null;
+
+  return (
+    <div className="readme-section">
+      <div className="readme-header">
+        <img className="icon-invert" src={ICONS.file} width="16" alt="" style={{ opacity: 0.5 }} />
+        README.md
+      </div>
+      <div className="readme-content" ref={contentRef}>
+        {!wasmReady && 'Loading...'}
+      </div>
+    </div>
+  );
+}
+
+/**
+ * Repository Browser Content (uses theme context)
+ */
+function RepoBrowserContent() {
+  const [currentPath, setCurrentPath] = useState(getCurrentPath());
+  const [content, setContent] = useState<{ files: any[]; directories: any[] }>({ files: [], directories: [] });
+  const [readme, setReadme] = useState<string | null>(null);
+  const [error, setError] = useState<string | null>(null);
+  const [loading, setLoading] = useState(false);
+  const [viewingFile, setViewingFile] = useState<string | null>(null);
+
+  const { isDark, toggleTheme } = useTheme();
+
+  function getCurrentPath() {
+    const params = new URLSearchParams(window.location.search);
+    return params.get('path') || '';
+  }
+
+  useEffect(() => {
+    const handlePopState = () => setCurrentPath(getCurrentPath());
+    window.addEventListener('popstate', handlePopState);
+    return () => window.removeEventListener('popstate', handlePopState);
+  }, []);
+
+  useEffect(() => {
+    fetchDirectory(currentPath);
+    fetchReadme(currentPath);
+  }, [currentPath]);
+
+  const navigate = (path: string) => {
+    const newUrl = path ? `?path=${encodeURIComponent(path)}` : '/';
+    window.history.pushState({ path }, '', newUrl);
+    setCurrentPath(path);
+  };
+
+  const fetchDirectory = async (path: string) => {
+    setLoading(true);
+    setError(null);
+    try {
+      const cacheKey = `dir:${path}`;
+      let data;
+      if (prefetchCache.has(cacheKey)) {
+        data = await prefetchCache.get(cacheKey);
+        prefetchCache.delete(cacheKey);
+      } else {
+        const url = path
+          ? `${API_BASE}/list?path=${encodeURIComponent(path)}`
+          : `${API_BASE}/list`;
+        const response = await fetch(url);
+        if (response.ok) {
+          data = await response.json();
+        }
+      }
+
+      if (data?.error) {
+        throw new Error(data.error);
+      }
+
+      setContent({
+        files: data?.files || [],
+        directories: data?.directories || []
+      });
+    } catch (err: any) {
+      console.error('Error loading directory:', err);
+      setError(err.message);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const fetchReadme = async (path: string) => {
+    setReadme(null);
+    const readmePath = path ? `${path}/README.md` : 'README.md';
+    try {
+      const response = await fetch(`${API_BASE}/file?path=${encodeURIComponent(readmePath)}`);
+      if (response.ok) {
+        const text = await response.text();
+        setReadme(text);
+      }
+    } catch (err) {
+      // Readme is optional, ignore errors
+    }
+  };
+
+  const handleOpenFile = useCallback((path: string) => {
+    setViewingFile(path);
+  }, []);
+
+  const handleCloseFile = useCallback(() => {
+    setViewingFile(null);
+  }, []);
+
+  return (
+    <>
+      <div className="repo-container">
+        <Header
+          title="Zenbu Repository"
+          subtitle="Browse and manage the mercurial codebase"
+          showThemeToggle={true}
+          isDark={isDark}
+          onToggleTheme={toggleTheme}
+        />
+
+        {/* Clone Bar */}
+        <div className="clone-box">
+          <div className="clone-box-inner">
+            <span className="clone-label">Clone HTTPS</span>
+            <code className="clone-url">hg clone http://zenbu.babocoder.com/repo</code>
+          </div>
+        </div>
+
+        {/* Navigation & Content */}
+        <Breadcrumb currentPath={currentPath} onNavigate={navigate} />
+
+        {error && <div className="error-message">Error: {error}</div>}
+
+        {loading ? (
+          <div className="file-list-container">
+            <div className="loading-state">Loading files...</div>
+          </div>
+        ) : (
+          <>
+            <FileList
+              directories={content.directories}
+              files={content.files}
+              onNavigate={navigate}
+              onOpenFile={handleOpenFile}
+            />
+            <ReadmeViewer content={readme} />
+          </>
+        )}
+
+        <Footer />
+      </div>
+
+      {/* File Viewer Modal */}
+      {viewingFile && (
+        isMarkdownFile(viewingFile) ? (
+          <MarkdownViewerModal filePath={viewingFile} onClose={handleCloseFile} />
+        ) : (
+          <FileViewer filePath={viewingFile} onClose={handleCloseFile} />
+        )
+      )}
+    </>
+  );
+}
+
+/**
+ * Main Application Component with ThemeProvider
+ */
+function RepoBrowser() {
+  return (
+    <ThemeProvider>
+      <RepoBrowserContent />
+    </ThemeProvider>
+  );
+}
+
+export { RepoBrowser };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/components/theme.tsx	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,72 @@
+import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
+
+interface ThemeContextType {
+  isDark: boolean;
+  toggleTheme: () => void;
+}
+
+const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
+
+// Apply theme class to document root
+function applyTheme(isDark: boolean) {
+  const root = document.documentElement;
+  if (isDark) {
+    root.classList.add('dark');
+    root.classList.remove('light');
+  } else {
+    root.classList.add('light');
+    root.classList.remove('dark');
+  }
+}
+
+interface ThemeProviderProps {
+  children: ReactNode;
+}
+
+function ThemeProvider({ children }: ThemeProviderProps) {
+  const [isDark, setIsDark] = useState(() => {
+    const saved = localStorage.getItem('theme');
+    if (saved) return saved === 'dark';
+    return window.matchMedia('(prefers-color-scheme: dark)').matches;
+  });
+
+  // Apply theme on mount and change
+  useEffect(() => {
+    applyTheme(isDark);
+    localStorage.setItem('theme', isDark ? 'dark' : 'light');
+  }, [isDark]);
+
+  // Listen for system theme changes
+  useEffect(() => {
+    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
+    const handleChange = (e: MediaQueryListEvent) => {
+      // Only apply if no explicit preference is saved
+      if (!localStorage.getItem('theme')) {
+        setIsDark(e.matches);
+      }
+    };
+
+    mediaQuery.addEventListener('change', handleChange);
+    return () => mediaQuery.removeEventListener('change', handleChange);
+  }, []);
+
+  const toggleTheme = useCallback(() => {
+    setIsDark(prev => !prev);
+  }, []);
+
+  return (
+    <ThemeContext.Provider value={{ isDark, toggleTheme }}>
+      {children}
+    </ThemeContext.Provider>
+  );
+}
+
+function useTheme(): ThemeContextType {
+  const context = useContext(ThemeContext);
+  if (context === undefined) {
+    throw new Error('useTheme must be used within a ThemeProvider');
+  }
+  return context;
+}
+
+export { ThemeProvider, useTheme };
Binary file hg-web/src/hg-web-background.jpg has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/icons/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,29 @@
+# icons
+
+Static icon assets for the repository browser UI.
+
+## Files
+
+| File | Description | Source |
+|------|-------------|--------|
+| `folder.png` | Directory/folder icon | Flaticon |
+| `file.svg` | Generic file icon | VSCode Material Icon Theme |
+| `home.png` | Home navigation icon | Flaticon |
+| `repo.svg` | Repository icon (git branch style) | Custom |
+| `close.png` | Modal close button icon | Flaticon |
+
+## Usage
+
+Icons are served as static files at `/icons/` and referenced in `repo-browser.tsx`:
+
+```typescript
+const ICONS = {
+  folder: "/icons/folder.png",
+  file: "/icons/file.svg",
+  home: "/icons/home.png",
+  repo: "/icons/repo.svg",
+  close: "/icons/close.png"
+};
+```
+
+Add to Bazel filegroup for serving.
Binary file hg-web/src/icons/close.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/icons/file.svg	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,1 @@
+<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/><path fill="#42a5f5" d="M8 16h8v2H8zm0-4h8v2H8zm6-10H6c-1.1 0-2 .9-2 2v16c0 1.1.89 2 1.99 2H18c1.1 0 2-.9 2-2V8zm4 18H6V4h7v5h5z"/></svg>
\ No newline at end of file
Binary file hg-web/src/icons/folder.png has changed
Binary file hg-web/src/icons/home.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/icons/repo.svg	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,4 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+  <path d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
+  <path d="M9 18c-4.51 2-5-2-7-2"/>
+</svg>
--- a/hg-web/src/index.css	Sat Jan 24 06:37:43 2026 -0800
+++ b/hg-web/src/index.css	Tue Jan 27 06:51:44 2026 -0800
@@ -1,179 +1,751 @@
+/* ===========================================
+   Component Styles
+   Import base.css before this file
+   =========================================== */
+
+/* ===========================================
+   App Layout
+   =========================================== */
+.app-container {
+  max-width: 1200px;
+  margin: 0 auto;
+  padding: 40px 20px;
+}
+
+/* ===========================================
+   Header
+   =========================================== */
 .header {
-  border-bottom: 1px solid var(--border);
-  padding-bottom: 1rem;
-  margin-bottom: 2rem;
+  display: flex;
+  align-items: center;
+  margin-bottom: 24px;
+  gap: 15px;
+}
+
+.header-icon {
+  width: 32px;
+  height: 32px;
+  opacity: 0.8;
+  cursor: pointer;
 }
 
 .header h1 {
-  margin-bottom: 0.5rem;
+  margin: 0;
+  font-size: 24px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+
+.header h1 a {
+  color: inherit;
+  text-decoration: none;
+}
+
+.header-subtitle {
+  color: var(--text-secondary);
+  margin: 0;
+  font-size: 14px;
 }
 
-.header .description {
-  color: var(--secondary);
-  font-size: 0.95rem;
+/* ===========================================
+   Navigation Tabs
+   =========================================== */
+.nav-tabs {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 24px;
+  border-bottom: 1px solid var(--border);
+  padding-bottom: 8px;
 }
 
-.clone-info {
-  background: var(--code-bg);
-  border: 1px solid var(--border);
+.nav-tab {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 16px;
   border-radius: 6px;
-  padding: 1rem;
-  margin-bottom: 2rem;
+  background: transparent;
+  border: 1px solid transparent;
+  color: var(--text-secondary);
+  cursor: pointer;
+  font-size: 14px;
+  transition: all 0.2s;
+}
+
+.nav-tab:hover {
+  background: var(--bg-subtle);
+  color: var(--text-primary);
+}
+
+.nav-tab.active {
+  background: var(--bg-subtle);
+  border-color: var(--border);
+  color: var(--text-primary);
+  font-weight: 500;
 }
 
-.clone-info code {
-  background: none;
-  color: var(--fg);
-  font-size: 0.95rem;
+.nav-tab svg {
+  color: var(--text-secondary);
+}
+
+/* ===========================================
+   Landing Page
+   =========================================== */
+.landing-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 24px;
+}
+
+@media (max-width: 768px) {
+  .landing-grid {
+    grid-template-columns: 1fr;
+  }
+}
+
+.landing-section {
+  background: var(--bg);
+  border: 1px solid var(--border);
+  border-radius: 8px;
+  overflow: hidden;
 }
 
-.breadcrumb {
-  margin-bottom: 1.5rem;
-  font-size: 0.95rem;
+.landing-section-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 12px 16px;
+  background: var(--bg-subtle);
+  border-bottom: 1px solid var(--border);
 }
 
-.breadcrumb a {
-  color: var(--link);
+.landing-section-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-weight: 600;
+  font-size: 14px;
+  color: var(--text-primary);
 }
 
-.breadcrumb a:hover {
+.landing-section-title svg {
+  color: var(--text-secondary);
+}
+
+.landing-section-link {
+  font-size: 12px;
+  color: var(--accent);
+  text-decoration: none;
+  padding: 4px 8px;
+  border-radius: 4px;
+  transition: background 0.2s;
+}
+
+.landing-section-link:hover {
+  background: var(--bg-subtle);
   text-decoration: underline;
 }
 
-.breadcrumb span {
-  color: var(--secondary);
-  margin: 0 0.5rem;
+.landing-section-content {
+  padding: 0;
+}
+
+/* ===========================================
+   Directory Items (Landing Preview)
+   =========================================== */
+.dir-item {
+  display: flex;
+  align-items: center;
+  padding: 10px 16px;
+  border-bottom: 1px solid var(--border);
+  font-size: 14px;
+  cursor: pointer;
+  transition: background 0.1s;
+}
+
+.dir-item:last-child {
+  border-bottom: none;
+}
+
+.dir-item:hover {
+  background: var(--hover);
+}
+
+.dir-item-icon {
+  margin-right: 12px;
+  opacity: 0.7;
+}
+
+.dir-item-icon img {
+  width: 18px;
+  height: 18px;
+}
+
+.dir-item-name {
+  color: var(--text-primary);
+}
+
+.dir-item-name:hover {
+  color: var(--accent);
+}
+
+/* ===========================================
+   Page Header (Graph Page)
+   =========================================== */
+.page-header {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 16px;
 }
 
-.file-list {
+.back-button {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 6px 12px;
+  background: var(--bg-subtle);
   border: 1px solid var(--border);
   border-radius: 6px;
+  color: var(--text-secondary);
+  cursor: pointer;
+  font-size: 13px;
+  transition: all 0.2s;
+}
+
+.back-button:hover {
+  background: var(--hover);
+  color: var(--text-primary);
+}
+
+.page-title {
+  font-size: 18px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+
+/* ===========================================
+   Graph Params
+   =========================================== */
+.graph-params {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 16px;
+  font-size: 12px;
+  color: var(--text-secondary);
+}
+
+.graph-param {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 8px;
+  background: var(--bg-subtle);
+  border-radius: 4px;
+}
+
+.graph-param-label {
+  font-weight: 500;
+}
+
+.graph-param-value {
+  font-family: monospace;
+  color: var(--accent);
+}
+
+/* ===========================================
+   Graph Component
+   =========================================== */
+.graph-container {
+  background: var(--bg);
+  color: var(--text-primary);
+  font-family: "More Thin", sans-serif;
+  border-radius: 6px;
+  border: 1px solid var(--border);
   overflow: hidden;
 }
 
-.file-item {
+.graph-wrapper {
   display: flex;
-  align-items: center;
-  padding: 0.75rem 1rem;
-  border-bottom: 1px solid var(--border);
-  transition: background-color 0.2s;
+  align-items: flex-start;
+  max-height: 600px;
+  overflow-y: auto;
+}
+
+.graph-canvas-column {
+  flex-shrink: 0;
+  background: var(--bg);
+  position: sticky;
+  left: 0;
 }
 
-.file-item:last-child {
-  border-bottom: none;
+.graph-details-column {
+  flex-grow: 1;
+  overflow-x: hidden;
 }
 
-.file-item:hover {
+.graph-row {
+  height: 40px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  padding: 0 12px;
+  border-bottom: 1px solid var(--border);
+  font-size: 12px;
+  cursor: pointer;
+  transition: background-color 0.1s;
+}
+
+.graph-row:hover {
   background: var(--hover);
 }
 
-.file-item .icon {
-  margin-right: 0.75rem;
-  font-size: 1.2rem;
-  width: 20px;
-  text-align: center;
+.graph-row-meta {
+  display: flex;
+  gap: 10px;
+  margin-bottom: 2px;
+  align-items: center;
+}
+
+.graph-hash {
+  color: var(--accent);
+  font-family: monospace;
+}
+
+.graph-user {
+  color: var(--text-secondary);
+  font-weight: 500;
+}
+
+.graph-branch {
+  color: var(--text-secondary);
+  font-size: 10px;
+  background: var(--bg-subtle);
+  padding: 1px 6px;
+  border-radius: 3px;
+}
+
+.graph-desc {
+  color: var(--text-primary);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.graph-badge-tip {
+  background: var(--success);
+  color: #fff;
+  padding: 0 4px;
+  border-radius: 2px;
+  font-size: 10px;
+  font-weight: bold;
+  flex-shrink: 0;
+}
+
+.graph-badge-tag {
+  background: var(--accent);
+  color: #fff;
+  padding: 0 4px;
+  border-radius: 2px;
+  font-size: 10px;
+  font-weight: bold;
+  flex-shrink: 0;
 }
 
-.file-item .name {
-  flex: 1;
-  font-family: 'Monaco', 'Courier New', monospace;
-  font-size: 0.9rem;
+.graph-loading-row {
+  height: 40px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: var(--text-secondary);
+  font-size: 12px;
+}
+
+/* ===========================================
+   Common States
+   =========================================== */
+.empty-state {
+  padding: 40px;
+  text-align: center;
+  color: var(--text-secondary);
+}
+
+.loading-state {
+  padding: 40px;
+  text-align: center;
+  color: var(--text-secondary);
 }
 
-.file-item .name a {
-  color: var(--fg);
+.error-message {
+  padding: 15px;
+  border: 1px solid var(--danger-border);
+  background: var(--danger-bg);
+  color: var(--danger);
+  border-radius: 6px;
+  margin-bottom: 20px;
+}
+
+/* ===========================================
+   Repository Browser
+   =========================================== */
+.repo-container {
+  font-family: "More Thin", sans-serif;
+  max-width: 980px;
+  margin: 40px auto;
+  color: var(--text-primary);
+  padding: 0 20px;
+}
+
+/* Clone Box */
+.clone-box {
+  background: var(--bg-subtle);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 12px 16px;
+  margin-bottom: 24px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
 }
 
-.file-item .name a:hover {
-  color: var(--link);
+.clone-label {
+  font-weight: 600;
+  font-size: 13px;
+  margin-right: 10px;
+  color: var(--text-primary);
+}
+
+.clone-url {
+  font-family: "More Thin", sans-serif;
+  background: var(--bg);
+  border: 1px solid var(--border);
+  padding: 4px 8px;
+  border-radius: 4px;
+  font-size: 12px;
+  color: var(--text-secondary);
+  flex-grow: 1;
+}
+
+/* Breadcrumb */
+.breadcrumb {
+  display: flex;
+  align-items: center;
+  font-size: 14px;
+  margin-bottom: 16px;
+  color: var(--text-secondary);
+  padding: 8px 0;
 }
 
-.file-item.directory .icon {
+.breadcrumb a {
   color: var(--accent);
+  text-decoration: none;
+  border-radius: 4px;
+  padding: 2px 6px;
+}
+
+.breadcrumb a:hover {
+  background: var(--bg-subtle);
+  text-decoration: underline;
+}
+
+.breadcrumb .separator {
+  margin: 0 4px;
+  color: var(--text-secondary);
+  opacity: 0.5;
+}
+
+.breadcrumb .nav-item.active {
+  font-weight: 600;
+  color: var(--text-primary);
+  padding: 2px 6px;
+}
+
+/* File List */
+.file-list-container {
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  overflow: hidden;
+  background: var(--bg);
 }
 
-.file-item.file .icon {
-  color: var(--secondary);
+.file-header {
+  background: var(--bg-subtle);
+  border-bottom: 1px solid var(--border);
+  padding: 12px 16px;
+  font-size: 13px;
+  font-weight: 600;
+  color: var(--text-secondary);
+}
+
+.file-row {
+  display: flex;
+  align-items: center;
+  padding: 10px 16px;
+  border-bottom: 1px solid var(--border);
+  transition: background 0.1s;
+}
+
+.file-row:last-child {
+  border-bottom: none;
+}
+
+.file-row:hover {
+  background: var(--hover);
 }
 
-.readme-section {
-  margin-top: 2rem;
-  padding-top: 2rem;
-  border-top: 1px solid var(--border);
+.file-row .icon img {
+  width: 20px;
+  height: 20px;
+  vertical-align: middle;
+  margin-right: 12px;
+}
+
+.file-row .name a {
+  color: var(--text-primary);
+  text-decoration: none;
+  font-size: 14px;
 }
 
-.readme-section h2 {
-  margin-bottom: 1rem;
-  font-size: 1.5rem;
+.file-row .name a:hover {
+  color: var(--accent);
+  text-decoration: underline;
+}
+
+/* Readme */
+.readme-section {
+  margin-top: 32px;
+  border: 1px solid var(--border);
+  border-radius: 6px;
+}
+
+.readme-header {
+  background: var(--bg-subtle);
+  padding: 10px 16px;
+  font-size: 12px;
+  font-weight: 600;
+  border-bottom: 1px solid var(--border);
+  display: flex;
+  align-items: center;
+  gap: 8px;
 }
 
 .readme-content {
+  padding: 32px;
+  background: var(--bg);
+  overflow-x: auto;
+  color: var(--text-primary);
+}
+
+/* File Viewer Modal */
+.file-viewer-overlay {
+  position: fixed;
+  inset: 0;
+  background: var(--overlay);
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  z-index: 1000;
+  padding: 20px;
+}
+
+.file-viewer {
+  background: var(--bg);
   border: 1px solid var(--border);
   border-radius: 6px;
-  padding: 1.5rem;
-  background: var(--code-bg);
+  width: 100%;
+  max-width: 900px;
+  max-height: 90vh;
+  display: flex;
+  flex-direction: column;
+  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
+}
+
+.file-viewer-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 12px 16px;
+  background: var(--bg-subtle);
+  border-bottom: 1px solid var(--border);
+  border-radius: 6px 6px 0 0;
+}
+
+.file-viewer-title {
+  font-weight: 600;
+  font-size: 14px;
+  color: var(--text-primary);
+  display: flex;
+  align-items: center;
+  gap: 8px;
 }
 
-.readme-content h1 { font-size: 1.75rem; margin-top: 1.5rem; }
-.readme-content h2 { font-size: 1.5rem; margin-top: 1.25rem; }
-.readme-content h3 { font-size: 1.25rem; margin-top: 1rem; }
+.file-viewer-close {
+  background: transparent;
+  border: none;
+  cursor: pointer;
+  padding: 4px;
+  border-radius: 4px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
 
-.readme-content h1:first-child,
-.readme-content h2:first-child,
-.readme-content h3:first-child {
-  margin-top: 0;
+.file-viewer-close:hover {
+  background: var(--hover);
+}
+
+.file-viewer-close img {
+  width: 16px;
+  height: 16px;
+  opacity: 0.7;
+}
+
+.file-viewer-content {
+  overflow: auto;
+  flex: 1;
 }
 
-.readme-content ul,
-.readme-content ol {
-  margin-left: 2rem;
-  margin-bottom: 1rem;
+.file-viewer-content pre {
+  margin: 0;
+  padding: 16px;
+  background: var(--bg-code);
+  font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace;
+  font-size: 13px;
+  line-height: 1.5;
+  overflow-x: auto;
+}
+
+.file-viewer-content code {
+  background: transparent;
+  padding: 0;
 }
 
-.readme-content li {
-  margin-bottom: 0.5rem;
+.file-viewer-loading {
+  padding: 40px;
+  text-align: center;
+  color: var(--text-secondary);
 }
 
-.readme-content img {
-  max-width: 100%;
-  height: auto;
-  border-radius: 6px;
+.file-viewer-line-numbers {
+  display: inline-block;
+  user-select: none;
+  text-align: right;
+  padding-right: 16px;
+  margin-right: 16px;
+  border-right: 1px solid var(--border);
+  color: var(--text-secondary);
+  opacity: 0.5;
 }
 
-.empty-state {
-  text-align: center;
-  padding: 3rem 1rem;
-  color: var(--secondary);
+/* Theme Toggle Button */
+.theme-toggle {
+  margin-left: auto;
+  background: var(--bg-subtle);
+  border: 1px solid var(--border);
+  border-radius: 6px;
+  padding: 8px 12px;
+  cursor: pointer;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  color: var(--text-secondary);
+  font-size: 13px;
+  transition: all 0.2s;
 }
 
-.error-message {
-  background: var(--danger);
-  color: white;
-  padding: 1rem;
-  border-radius: 6px;
-  margin-bottom: 1rem;
+.theme-toggle:hover {
+  background: var(--hover);
+  color: var(--text-primary);
+}
+
+.theme-toggle svg {
+  flex-shrink: 0;
+}
+
+/* Description */
+.description {
+  color: var(--text-secondary);
+  margin: 0;
+  font-size: 14px;
 }
 
-/* Mobile responsive */
+/* ===========================================
+   Mobile Responsive
+   =========================================== */
 @media (max-width: 768px) {
-  main {
-    padding: 1rem;
+  .app-container {
+    padding: 20px 15px;
   }
 
-  .file-item {
-    padding: 0.5rem 0.75rem;
+  .repo-container {
+    padding: 0 15px;
+  }
+
+  .file-row {
+    padding: 8px 12px;
   }
 
-  .file-item .name {
-    font-size: 0.85rem;
-  }
-
-  .clone-info {
-    padding: 0.75rem;
-    overflow-x: auto;
-  }
-
-  .readme-content {
-    padding: 1rem;
+  .clone-box {
+    padding: 10px 12px;
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 8px;
   }
 }
+
+/* ===========================================
+   Footer
+   =========================================== */
+.footer {
+  margin-top: 48px;
+  padding-top: 24px;
+  border-top: 1px solid var(--border);
+}
+
+.footer-content {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 8px;
+  padding: 16px 0;
+  color: var(--text-secondary);
+  font-size: 13px;
+}
+
+.footer-separator {
+  opacity: 0.5;
+}
+
+.footer-text {
+  color: var(--text-secondary);
+}
+
+.clone-box-inner {
+  display: flex;
+  align-items: center;
+  width: 100%;
+}
+
+/* Header content wrapper */
+.header-content {
+  flex: 1;
+}
+
+.header-content h1 {
+  margin: 0;
+  font-size: 24px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+
+.header-content h1 a {
+  color: inherit;
+  text-decoration: none;
+}
+
+.header-content h1 a:hover {
+  text-decoration: none;
+}
--- a/hg-web/src/index.html	Sat Jan 24 06:37:43 2026 -0800
+++ b/hg-web/src/index.html	Tue Jan 27 06:51:44 2026 -0800
@@ -1,39 +1,23 @@
 <!DOCTYPE html>
 <html lang="en">
-<head>
+  <head>
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <title>Zenbu Repository</title>
+    <link rel="stylesheet" href="/index.css">
     <link rel="stylesheet" href="/base.css">
-    <link rel="stylesheet" href="/index.css">
-</head>
-<body>
-    <main>
-        <div class="header">
-            <h1>Zenbu Repository</h1>
-            <p class="description">Browse and clone this mercurial repository</p>
-        </div>
 
-        <div class="clone-info">
-            <strong>Clone this repository:</strong><br>
-            <code>hg clone http://zenbu.babocoder.com</code>
-        </div>
-
-        <div class="breadcrumb" id="breadcrumb"></div>
+    <link rel="stylesheet" href="/a11y-dark.min.css"  media="(prefers-color-scheme: dark)">
+    <link rel="stylesheet" href="/a11y-light.min.css" media="(prefers-color-scheme: light)">
 
-        <div class="file-list" id="fileList"></div>
-
-        <div class="readme-section" id="readmeSection" style="display: none;">
-            <h2>README</h2>
-            <div class="readme-content" id="readmeContent"></div>
-        </div>
-
-        <div class="empty-state" id="emptyState" style="display: none;">
-            <p>No files found in this directory</p>
-        </div>
+    <link rel="preload" href="/public/fonts/more-sugar.regular.otf" as="font" type="font/otf" crossorigin>
+    <link rel="icon" type="image/svg+xml" href="/public/epi_all_colors.svg">
+  </head>
+  <body>
+    <main>
+      <div id="root"></div>
     </main>
-
-    <script src="/markdown_to_html.js"></script>
-    <script src="/index.js"></script>
-</body>
+    <script type="module" src="/page.js"></script>
+    <script src="/highlight.min.js"></script>
+  </body>
 </html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hg-web/src/main.tsx	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,6 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { App } from "hg-web/src/components/app";
+
+const root = ReactDOM.createRoot(document.getElementById('root')!);
+root.render(<App />);
Binary file hg-web/src/panda.png has changed
Binary file hg-web/src/pencil_lines.png has changed
Binary file hg-web/src/pencil_texture.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/load_test/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,7 @@
+# load_test
+
+Load testing and performance measurement scripts.
+
+## Files
+
+- `main.py` - Python load testing script
--- a/markdown_converter/BUILD	Sat Jan 24 06:37:43 2026 -0800
+++ b/markdown_converter/BUILD	Tue Jan 27 06:51:44 2026 -0800
@@ -38,6 +38,9 @@
     "-sALLOW_MEMORY_GROWTH",  # Allow memory to grow dynamically
     "-sEXPORTED_FUNCTIONS=['_markdown_to_html','_markdown_free','_markdown_get_length','_wasm_alloc','_wasm_free']",
     "-sEXPORTED_RUNTIME_METHODS=['cwrap','ccall','UTF8ToString','stringToUTF8','lengthBytesUTF8']",
+    "-sMODULARIZE",  # Output as factory function
+    "-sEXPORT_ES6",  # Use ES6 module exports
+    "-sENVIRONMENT=web",  # Browser-only code (no Node.js builtins)
   ],
   tags=["manual"],
 )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/markdown_converter/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,52 @@
+# markdown_converter
+
+Markdown to HTML converter written in C, with WASM support for browser use.
+
+## Features
+
+- Headings, paragraphs, lists
+- Code blocks with syntax highlighting
+- Links and images
+- Bold, italic, inline code
+- Blockquotes
+
+## Files
+
+| File | Description |
+|------|-------------|
+| `markdown_to_html.h` | Public API header |
+| `markdown_to_html.c` | C implementation |
+| `markdown_to_html.js` | JavaScript wrapper for WASM |
+| `markdown_to_html.css` | Default styles |
+| `tests/` | Test cases |
+
+## Usage (C)
+
+```c
+#include "markdown_converter/markdown_to_html.h"
+
+char* html = markdown_to_html(markdown_string);
+// use html...
+markdown_free(html);
+```
+
+## Usage (WASM/JavaScript)
+
+```javascript
+import createModule from 'markdown_converter/markdown_to_html_wasm/markdown_to_html_bin.js';
+
+const Module = await createModule();
+const toHtml = Module.cwrap('markdown_to_html', 'number', ['string']);
+const free = Module.cwrap('markdown_free', null, ['number']);
+
+const ptr = toHtml(markdownString);
+const html = Module.UTF8ToString(ptr);
+free(ptr);
+```
+
+## Building
+
+```bash
+bazel build //markdown_converter:markdown_to_html
+bazel build //markdown_converter:markdown_to_html_wasm  # WASM version
+```
--- a/markdown_converter/markdown_to_html.c	Sat Jan 24 06:37:43 2026 -0800
+++ b/markdown_converter/markdown_to_html.c	Tue Jan 27 06:51:44 2026 -0800
@@ -84,6 +84,9 @@
   }
 }
 
+// Forward declaration
+static void process_inline(StringBuffer *buf, const char *text, size_t len);
+
 // Check if line starts with pattern (after trimming whitespace)
 static int starts_with(const char *line, const char *pattern)
 {
@@ -187,6 +190,157 @@
   return *line == '.' && line[1] == ' ';
 }
 
+// Check if line could be a table row (contains |)
+static int is_table_row(const char *line)
+{
+  line = skip_whitespace(line);
+  // Must contain at least one |
+  return strchr(line, '|') != NULL;
+}
+
+// Check if line is a table separator (|---|---|)
+static int is_table_separator(const char *line)
+{
+  line = skip_whitespace(line);
+  int has_dash = 0;
+  int has_pipe = 0;
+
+  while (*line) {
+    char c = *line;
+    if (c == '|') has_pipe = 1;
+    else if (c == '-') has_dash = 1;
+    else if (c == ':') ; // alignment marker, allowed
+    else if (isspace((unsigned char)c)) ; // whitespace allowed
+    else return 0; // invalid character for separator
+    line++;
+  }
+
+  return has_dash && has_pipe;
+}
+
+// Parse alignment from separator cell (e.g., ":---:", "---:", ":---")
+// Returns: 0 = left (default), 1 = center, 2 = right
+static int parse_alignment(const char *cell, size_t len)
+{
+  // Trim whitespace
+  while (len > 0 && isspace((unsigned char)*cell)) { cell++; len--; }
+  while (len > 0 && isspace((unsigned char)cell[len-1])) { len--; }
+
+  if (len == 0) return 0;
+
+  int left_colon = (cell[0] == ':');
+  int right_colon = (len > 0 && cell[len-1] == ':');
+
+  if (left_colon && right_colon) return 1; // center
+  if (right_colon) return 2; // right
+  return 0; // left (default)
+}
+
+// Count columns in a table row
+static int count_table_columns(const char *line)
+{
+  int count = 0;
+  int in_cell = 0;
+  line = skip_whitespace(line);
+
+  // Skip leading |
+  if (*line == '|') line++;
+
+  while (*line) {
+    if (*line == '|') {
+      count++;
+      in_cell = 0;
+    } else if (!isspace((unsigned char)*line)) {
+      in_cell = 1;
+    }
+    line++;
+  }
+
+  // Count last cell if there was content after last |
+  if (in_cell) count++;
+
+  return count > 0 ? count : 1;
+}
+
+// Parse table cells and call callback for each
+typedef void (*cell_callback)(StringBuffer *buf, const char *cell, size_t len, int align, int is_header);
+
+static void parse_table_row(StringBuffer *buf, const char *line, int *alignments, int num_cols, int is_header, cell_callback cb)
+{
+  line = skip_whitespace(line);
+
+  // Skip leading |
+  if (*line == '|') line++;
+
+  int col = 0;
+  const char *cell_start = line;
+
+  while (*line && col < num_cols) {
+    if (*line == '|' || *(line + 1) == '\0') {
+      // End of cell
+      size_t cell_len = line - cell_start;
+      if (*line != '|') cell_len++; // include last char if no trailing |
+
+      // Trim whitespace from cell
+      while (cell_len > 0 && isspace((unsigned char)*cell_start)) { cell_start++; cell_len--; }
+      while (cell_len > 0 && isspace((unsigned char)cell_start[cell_len-1])) { cell_len--; }
+
+      int align = (alignments && col < num_cols) ? alignments[col] : 0;
+      cb(buf, cell_start, cell_len, align, is_header);
+
+      col++;
+      cell_start = line + 1;
+    }
+    line++;
+  }
+
+  // Fill remaining columns with empty cells
+  while (col < num_cols) {
+    cb(buf, "", 0, alignments ? alignments[col] : 0, is_header);
+    col++;
+  }
+}
+
+static void emit_table_cell(StringBuffer *buf, const char *cell, size_t len, int align, int is_header)
+{
+  const char *tag = is_header ? "th" : "td";
+  const char *align_attr = "";
+
+  if (align == 1) align_attr = " style=\"text-align:center\"";
+  else if (align == 2) align_attr = " style=\"text-align:right\"";
+
+  buffer_append(buf, "<");
+  buffer_append(buf, tag);
+  buffer_append(buf, align_attr);
+  buffer_append(buf, ">");
+  process_inline(buf, cell, len);
+  buffer_append(buf, "</");
+  buffer_append(buf, tag);
+  buffer_append(buf, ">");
+}
+
+// Parse alignments from separator row
+static void parse_alignments(const char *line, int *alignments, int num_cols)
+{
+  line = skip_whitespace(line);
+  if (*line == '|') line++;
+
+  int col = 0;
+  const char *cell_start = line;
+
+  while (*line && col < num_cols) {
+    if (*line == '|' || *(line + 1) == '\0') {
+      size_t cell_len = line - cell_start;
+      if (*line != '|') cell_len++;
+
+      alignments[col] = parse_alignment(cell_start, cell_len);
+      col++;
+      cell_start = line + 1;
+    }
+    line++;
+  }
+}
+
 // Process inline markdown (bold, italic, code, links, strikethrough)
 static void process_inline(StringBuffer *buf, const char *text, size_t len)
 {
@@ -532,6 +686,79 @@
       continue;
     }
 
+    // Table: | col1 | col2 | followed by |---|---|
+    if (is_table_row(line)) {
+      // Peek at next line to see if it's a separator
+      const char *peek_ptr = ptr;
+      if (*peek_ptr == '\n') peek_ptr++;
+
+      const char *next_line_start = peek_ptr;
+      while (*peek_ptr && *peek_ptr != '\n') peek_ptr++;
+      size_t next_line_len = peek_ptr - next_line_start;
+
+      char *next_line = (char *)malloc(next_line_len + 1);
+      if (next_line) {
+        memcpy(next_line, next_line_start, next_line_len);
+        next_line[next_line_len] = '\0';
+
+        if (is_table_separator(next_line)) {
+          // It's a table!
+          int num_cols = count_table_columns(line);
+          int *alignments = (int *)calloc(num_cols, sizeof(int));
+
+          buffer_append(buf, "<table>");
+
+          // Header row
+          buffer_append(buf, "<thead><tr>");
+          parse_table_row(buf, line, NULL, num_cols, 1, emit_table_cell);
+          buffer_append(buf, "</tr></thead>");
+
+          free(line);
+          if (*ptr == '\n') ptr++;
+
+          // Parse alignments from separator
+          parse_alignments(next_line, alignments, num_cols);
+          free(next_line);
+
+          // Skip separator line
+          ptr = peek_ptr;
+          if (*ptr == '\n') ptr++;
+
+          // Body rows
+          buffer_append(buf, "<tbody>");
+
+          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';
+
+            if (!is_table_row(line) || is_empty_line(line)) {
+              ptr = line_start;
+              free(line);
+              break;
+            }
+
+            buffer_append(buf, "<tr>");
+            parse_table_row(buf, line, alignments, num_cols, 0, emit_table_cell);
+            buffer_append(buf, "</tr>");
+
+            free(line);
+            if (*ptr == '\n') ptr++;
+          }
+
+          buffer_append(buf, "</tbody></table>");
+          free(alignments);
+          continue;
+        }
+        free(next_line);
+      }
+    }
+
     // HTML block - pass through unchanged
     if (is_html_block_start(line)) {
       // Check if it's a script or style tag that needs special handling
@@ -607,6 +834,7 @@
           is_horizontal_rule(line) ||
           is_unordered_list(line) ||
           is_ordered_list(line) ||
+          is_table_row(line) ||
           is_html_block_start(line)) {
         ptr = line_start;
         free(line);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/markdown_converter/markdown_to_html.css	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,133 @@
+/* markdown panel */
+#markdown {
+  flex: 1;
+  padding: 15px;
+  border: 1px solid rgba(var(--gray), 0.3);
+  border-radius: 4px;
+  background: var(--white);
+  color: rgb(var(--gray-dark));
+  overflow-y: auto;
+  font-size: 15px;
+}
+
+/* Markdown markdown styles */
+#markdown h1, #markdown h2, #markdown h3, #markdown h4, #markdown h5, #markdown h6 {
+  margin: 1em 0 0.5em 0;
+  color: rgb(var(--gray-dark));
+  line-height: 1.3;
+}
+
+#markdown h1:first-child, #markdown h2:first-child, #markdown h3:first-child {
+  margin-top: 0;
+}
+
+#markdown h1 { font-size: 1.8em; }
+#markdown h2 { font-size: 1.5em; }
+#markdown h3 { font-size: 1.25em; }
+#markdown h4 { font-size: 1.1em; }
+
+#markdown p {
+  margin: 0.8em 0;
+}
+
+#markdown ul, #markdown ol {
+  margin: 0.8em 0;
+  padding-left: 25px;
+}
+
+#markdown li {
+  margin: 0.3em 0;
+}
+
+#markdown code {
+  background: rgb(var(--gray-light));
+  padding: 2px 6px;
+  border-radius: 3px;
+  font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
+  font-size: 0.9em;
+  color: var(--accent);
+}
+
+#markdown pre {
+  background: var(--black);
+  color: var(--white);
+  padding: 15px;
+  border-radius: 6px;
+  overflow-x: auto;
+  margin: 1em 0;
+  line-height: 1.4;
+}
+
+#markdown pre code {
+  background: none;
+  color: inherit;
+  padding: 0;
+}
+
+#markdown blockquote {
+  border-left: 4px solid var(--accent);
+  padding: 10px 15px;
+  margin: 1em 0;
+  background: rgb(var(--gray-light));
+  color: rgb(var(--gray));
+  font-style: italic;
+}
+
+#markdown a {
+  color: var(--accent);
+  text-decoration: none;
+}
+
+#markdown a:hover {
+  text-decoration: underline;
+}
+
+#markdown hr {
+  border: none;
+  border-top: 1px solid rgb(var(--gray-light));
+  margin: 1.5em 0;
+}
+
+#markdown img {
+  max-width: 100%;
+  height: auto;
+  border-radius: 4px;
+}
+
+#markdown strong {
+  font-weight: 600;
+}
+
+#markdown em {
+  font-style: italic;
+}
+
+#markdown del {
+  text-decoration: line-through;
+  color: rgb(var(--gray));
+}
+
+/* Mobile */
+@media (max-width: 900px) {
+  body {
+    padding: 15px;
+  }
+
+  .container {
+    grid-template-columns: 1fr;
+    height: auto;
+    gap: 15px;
+  }
+
+  .panel {
+    min-height: 350px;
+  }
+
+  textarea {
+    min-height: 300px;
+  }
+
+  #markdown {
+    min-height: 300px;
+  }
+}
--- a/markdown_converter/wasm/BUILD	Sat Jan 24 06:37:43 2026 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,22 +0,0 @@
-# load("@rules_cc//cc:defs.bzl", "cc_library")
-# load("@emsdk//emscripten_toolchain:wasm_rules.bzl", "wasm_cc_binary")
-# 
-# package(default_visibility = ["//visibility:public"])
-# 
-# cc_library(
-#   name = "hello-world",
-#   srcs = ["markdown_to_html_wasm.c"],
-# )
-# 
-# wasm_cc_binary(
-#   name = "markdown_to_html_wasm",
-#   cc_target = ":hello-world",
-# )
-# 
-# # JS to link wasm with 
-# filegroup(
-#   name = "markdown_to_html_wasm_js",
-#   srcs = glob([
-#       "**/*.js",
-#   ], allow_empty=True),
-# )
--- a/markdown_converter/wasm/markdown_to_html_wasm.c	Sat Jan 24 06:37:43 2026 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,698 +0,0 @@
-/**
- * Markdown to HTML Converter - Standalone WASM Implementation
- * No libc dependencies - can be compiled with: clang --target=wasm32
- */
-
-#define WASM_EXPORT __attribute__((visibility("default")))
-
-typedef unsigned long size_t;
-typedef int int32_t;
-
-// Simple bump allocator for WASM
-#define HEAP_SIZE (1024 * 1024)  // 1MB heap
-static char heap[HEAP_SIZE];
-static size_t heap_offset = 0;
-
-WASM_EXPORT void *malloc(size_t size)
-{
-  // Align to 8 bytes
-  size_t aligned_offset = (heap_offset + 7) & ~7;
-  if (aligned_offset + size > HEAP_SIZE) return 0;
-
-  void *ptr = &heap[aligned_offset];
-  heap_offset = aligned_offset + size;
-  return ptr;
-}
-
-WASM_EXPORT void free(void *ptr)
-{
-  // Simple bump allocator - no actual free
-  (void)ptr;
-}
-
-WASM_EXPORT void heap_reset(void)
-{
-  heap_offset = 0;
-}
-
-// String functions
-static size_t strlen(const char *s)
-{
-  size_t len = 0;
-  while (s[len]) len++;
-  return len;
-}
-
-static void *memcpy(void *dest, const void *src, size_t n)
-{
-  char *d = (char *)dest;
-  const char *s = (const char *)src;
-  while (n--) *d++ = *s++;
-  return dest;
-}
-
-static int isspace_c(int c)
-{
-  return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v';
-}
-
-static int isdigit_c(int c)
-{
-  return c >= '0' && c <= '9';
-}
-
-// String buffer for building HTML output
-typedef struct {
-  char  *data;
-  size_t length;
-  size_t capacity;
-} StringBuffer;
-
-static StringBuffer *buffer_create(size_t initial_capacity)
-{
-  StringBuffer *buf = (StringBuffer *)malloc(sizeof(StringBuffer));
-  if (!buf) return 0;
-
-  buf->data = (char *)malloc(initial_capacity);
-  if (!buf->data) return 0;
-
-  buf->data[0] = '\0';
-  buf->length = 0;
-  buf->capacity = initial_capacity;
-  return buf;
-}
-
-static void buffer_grow(StringBuffer *buf, size_t needed)
-{
-  if (buf->length + needed + 1 > buf->capacity) {
-    size_t new_capacity = buf->capacity * 2;
-    while (new_capacity < buf->length + needed + 1)
-      new_capacity *= 2;
-
-    char *new_data = (char *)malloc(new_capacity);
-    if (new_data) {
-      memcpy(new_data, buf->data, buf->length + 1);
-      buf->data = new_data;
-      buf->capacity = new_capacity;
-    }
-  }
-}
-
-static void buffer_append(StringBuffer *buf, const char *str)
-{
-  size_t len = strlen(str);
-  buffer_grow(buf, len);
-  memcpy(buf->data + buf->length, str, len + 1);
-  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);
-  buf->data[buf->length++] = c;
-  buf->data[buf->length] = '\0';
-}
-
-// Check if line starts with pattern (after trimming whitespace)
-static int starts_with(const char *line, const char *pattern)
-{
-  while (*line && isspace_c(*line)) line++;
-  size_t plen = strlen(pattern);
-  for (size_t i = 0; i < plen; i++) {
-    if (line[i] != pattern[i]) return 0;
-  }
-  return 1;
-}
-
-// Count leading # characters
-static int count_heading_level(const char *line)
-{
-  int count = 0;
-  while (*line && isspace_c(*line)) line++;
-  while (line[count] == '#' && count < 6) count++;
-  if (count > 0 && line[count] == ' ') return count;
-  return 0;
-}
-
-// Skip whitespace
-static const char *skip_whitespace(const char *str)
-{
-  while (*str && isspace_c(*str)) str++;
-  return str;
-}
-
-// Check if line is empty
-static int is_empty_line(const char *line)
-{
-  while (*line) {
-    if (!isspace_c(*line)) return 0;
-    line++;
-  }
-  return 1;
-}
-
-// Check if line is horizontal rule
-static int is_horizontal_rule(const char *line)
-{
-  line = skip_whitespace(line);
-  char first = *line;
-  if (first != '-' && first != '*' && first != '_') return 0;
-
-  int count = 0;
-  while (*line) {
-    if (*line == first) count++;
-    else if (!isspace_c(*line)) return 0;
-    line++;
-  }
-  return count >= 3;
-}
-
-// Check if line is unordered list item
-static int is_unordered_list(const char *line)
-{
-  line = skip_whitespace(line);
-  return (*line == '-' || *line == '*' || *line == '+') && line[1] == ' ';
-}
-
-// Check if line is ordered list item
-static int is_ordered_list(const char *line)
-{
-  line = skip_whitespace(line);
-  while (*line && isdigit_c(*line)) line++;
-  return *line == '.' && line[1] == ' ';
-}
-
-// Check if line is a table row (starts with |)
-static int is_table_row(const char *line)
-{
-  line = skip_whitespace(line);
-  return *line == '|';
-}
-
-// Check if line is a table separator row (| --- | --- |)
-static int is_table_separator(const char *line)
-{
-  line = skip_whitespace(line);
-  if (*line != '|') return 0;
-  line++;
-
-  int has_dash = 0;
-  while (*line) {
-    if (*line == '-') has_dash = 1;
-    else if (*line == '|' || *line == ':' || isspace_c(*line)) { /* ok */ }
-    else return 0;
-    line++;
-  }
-  return has_dash;
-}
-
-// Forward declaration for process_inline
-static void process_inline(StringBuffer *buf, const char *text, size_t len);
-
-// Parse table cells from a row and append to buffer
-static void parse_table_row(StringBuffer *buf, const char *line, int is_header)
-{
-  const char *cell_tag = is_header ? "th" : "td";
-
-  buffer_append(buf, "<tr>");
-
-  line = skip_whitespace(line);
-  if (*line == '|') line++; // Skip leading |
-
-  while (*line) {
-    // Skip whitespace before cell content
-    while (*line && isspace_c(*line)) line++;
-
-    // Find cell end (next | or end of line)
-    const char *cell_start = line;
-    while (*line && *line != '|') line++;
-
-    // Trim trailing whitespace from cell
-    const char *cell_end = line;
-    while (cell_end > cell_start && isspace_c(*(cell_end - 1))) cell_end--;
-
-    size_t cell_len = cell_end - cell_start;
-
-    // Only output cell if we have content or more cells coming
-    if (cell_len > 0 || *line == '|') {
-      buffer_append(buf, "<");
-      buffer_append(buf, cell_tag);
-      buffer_append(buf, ">");
-      if (cell_len > 0) {
-        process_inline(buf, cell_start, cell_len);
-      }
-      buffer_append(buf, "</");
-      buffer_append(buf, cell_tag);
-      buffer_append(buf, ">");
-    }
-
-    if (*line == '|') line++; // Skip |
-
-    // Check if this was the trailing |
-    const char *rest = line;
-    while (*rest && isspace_c(*rest)) rest++;
-    if (!*rest) break; // End of line after trailing |
-  }
-
-  buffer_append(buf, "</tr>");
-}
-
-// Process inline markdown
-static void process_inline(StringBuffer *buf, const char *text, size_t len)
-{
-  size_t i = 0;
-
-  while (i < len) {
-    // Links: [text](url)
-    if (text[i] == '[') {
-      size_t link_start = i + 1;
-      size_t link_end = link_start;
-      while (link_end < len && text[link_end] != ']') link_end++;
-
-      if (link_end < len && link_end + 1 < len && text[link_end + 1] == '(') {
-        size_t url_start = link_end + 2;
-        size_t url_end = url_start;
-        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>");
-          i = url_end + 1;
-          continue;
-        }
-      }
-    }
-
-    // Images: ![alt](url)
-    if (text[i] == '!' && i + 1 < len && text[i + 1] == '[') {
-      size_t alt_start = i + 2;
-      size_t alt_end = alt_start;
-      while (alt_end < len && text[alt_end] != ']') alt_end++;
-
-      if (alt_end < len && alt_end + 1 < len && text[alt_end + 1] == '(') {
-        size_t url_start = alt_end + 2;
-        size_t url_end = url_start;
-        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, "\">");
-          i = url_end + 1;
-          continue;
-        }
-      }
-    }
-
-    // Bold: **text** or __text__
-    if ((text[i] == '*' && i + 1 < len && text[i + 1] == '*') ||
-        (text[i] == '_' && i + 1 < len && text[i + 1] == '_')) {
-      char marker = text[i];
-      size_t start = i + 2;
-      size_t end = start;
-      while (end + 1 < len && !(text[end] == marker && text[end + 1] == marker)) end++;
-
-      if (end + 1 < len) {
-        buffer_append(buf, "<strong>");
-        process_inline(buf, text + start, end - start);
-        buffer_append(buf, "</strong>");
-        i = end + 2;
-        continue;
-      }
-    }
-
-    // Strikethrough: ~~text~~
-    if (text[i] == '~' && i + 1 < len && text[i + 1] == '~') {
-      size_t start = i + 2;
-      size_t end = start;
-      while (end + 1 < len && !(text[end] == '~' && text[end + 1] == '~')) end++;
-
-      if (end + 1 < len) {
-        buffer_append(buf, "<del>");
-        process_inline(buf, text + start, end - start);
-        buffer_append(buf, "</del>");
-        i = end + 2;
-        continue;
-      }
-    }
-
-    // Italic: *text* or _text_
-    if ((text[i] == '*' || text[i] == '_') && i + 1 < len && !isspace_c(text[i + 1])) {
-      char marker = text[i];
-      size_t start = i + 1;
-      size_t end = start;
-      while (end < len && text[end] != marker) end++;
-
-      if (end < len && end > start) {
-        buffer_append(buf, "<em>");
-        process_inline(buf, text + start, end - start);
-        buffer_append(buf, "</em>");
-        i = end + 1;
-        continue;
-      }
-    }
-
-    // Inline code: `code`
-    if (text[i] == '`') {
-      size_t start = i + 1;
-      size_t end = start;
-      while (end < len && text[end] != '`') end++;
-
-      if (end < len) {
-        buffer_append(buf, "<code>");
-        buffer_append_n(buf, text + start, end - start);
-        buffer_append(buf, "</code>");
-        i = end + 1;
-        continue;
-      }
-    }
-
-    // HTML escape
-    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]);
-    }
-    i++;
-  }
-}
-
-// Append heading tag
-static void append_heading_tag(StringBuffer *buf, int level, int closing)
-{
-  buffer_append_char(buf, '<');
-  if (closing) buffer_append_char(buf, '/');
-  buffer_append_char(buf, 'h');
-  buffer_append_char(buf, '0' + level);
-  buffer_append_char(buf, '>');
-}
-
-// Convert markdown to HTML
-WASM_EXPORT char *markdown_to_html(const char *markdown)
-{
-  if (!markdown) return 0;
-
-  StringBuffer *buf = buffer_create(4096);
-  if (!buf) return 0;
-
-  const char *ptr = markdown;
-  const char *line_start;
-
-  while (*ptr) {
-    line_start = ptr;
-
-    // Find end of line
-    while (*ptr && *ptr != '\n') ptr++;
-    size_t line_len = ptr - line_start;
-
-    // Create line copy
-    char *line = (char *)malloc(line_len + 1);
-    if (!line) return buf->data;
-    memcpy(line, line_start, line_len);
-    line[line_len] = '\0';
-
-    // Skip empty lines
-    if (is_empty_line(line)) {
-      if (*ptr == '\n') ptr++;
-      continue;
-    }
-
-    // Headings
-    int heading_level = count_heading_level(line);
-    if (heading_level > 0) {
-      const char *content = skip_whitespace(line);
-      while (*content == '#') content++;
-      content = skip_whitespace(content);
-
-      append_heading_tag(buf, heading_level, 0);
-      process_inline(buf, content, strlen(content));
-      append_heading_tag(buf, heading_level, 1);
-
-      if (*ptr == '\n') ptr++;
-      continue;
-    }
-
-    // Code block
-    if (starts_with(line, "```")) {
-      buffer_append(buf, "<pre><code>");
-      if (*ptr == '\n') ptr++;
-
-      while (*ptr) {
-        line_start = ptr;
-        while (*ptr && *ptr != '\n') ptr++;
-        line_len = ptr - line_start;
-
-        char *code_line = (char *)malloc(line_len + 1);
-        if (!code_line) break;
-        memcpy(code_line, line_start, line_len);
-        code_line[line_len] = '\0';
-
-        if (starts_with(code_line, "```")) {
-          if (*ptr == '\n') ptr++;
-          break;
-        }
-
-        for (size_t i = 0; i < line_len; i++) {
-          if (code_line[i] == '<') buffer_append(buf, "&lt;");
-          else if (code_line[i] == '>') buffer_append(buf, "&gt;");
-          else if (code_line[i] == '&') buffer_append(buf, "&amp;");
-          else buffer_append_char(buf, code_line[i]);
-        }
-        buffer_append_char(buf, '\n');
-
-        if (*ptr == '\n') ptr++;
-      }
-
-      buffer_append(buf, "</code></pre>");
-      continue;
-    }
-
-    // Blockquote
-    if (starts_with(line, ">")) {
-      buffer_append(buf, "<blockquote>");
-
-      while (1) {
-        const char *content = skip_whitespace(line);
-        if (*content == '>') content++;
-        content = skip_whitespace(content);
-        process_inline(buf, content, strlen(content));
-        buffer_append_char(buf, ' ');
-
-        if (*ptr == '\n') ptr++;
-        if (!*ptr) break;
-
-        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';
-
-        if (!starts_with(line, ">")) {
-          ptr = line_start;
-          break;
-        }
-      }
-
-      buffer_append(buf, "</blockquote>");
-      continue;
-    }
-
-    // Horizontal rule
-    if (is_horizontal_rule(line)) {
-      buffer_append(buf, "<hr>");
-      if (*ptr == '\n') ptr++;
-      continue;
-    }
-
-    // Unordered list
-    if (is_unordered_list(line)) {
-      buffer_append(buf, "<ul>");
-
-      while (1) {
-        const char *content = skip_whitespace(line);
-        content += 2;
-
-        buffer_append(buf, "<li>");
-        process_inline(buf, content, strlen(content));
-        buffer_append(buf, "</li>");
-
-        if (*ptr == '\n') ptr++;
-        if (!*ptr) break;
-
-        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';
-
-        if (!is_unordered_list(line)) {
-          ptr = line_start;
-          break;
-        }
-      }
-
-      buffer_append(buf, "</ul>");
-      continue;
-    }
-
-    // Ordered list
-    if (is_ordered_list(line)) {
-      buffer_append(buf, "<ol>");
-
-      while (1) {
-        const char *content = skip_whitespace(line);
-        while (*content && isdigit_c(*content)) content++;
-        if (*content == '.') content++;
-        content = skip_whitespace(content);
-
-        buffer_append(buf, "<li>");
-        process_inline(buf, content, strlen(content));
-        buffer_append(buf, "</li>");
-
-        if (*ptr == '\n') ptr++;
-        if (!*ptr) break;
-
-        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';
-
-        if (!is_ordered_list(line)) {
-          ptr = line_start;
-          break;
-        }
-      }
-
-      buffer_append(buf, "</ol>");
-      continue;
-    }
-
-    // Table
-    if (is_table_row(line)) {
-      // Check if next line is a separator (to confirm this is a table)
-      const char *peek_ptr = ptr;
-      if (*peek_ptr == '\n') peek_ptr++;
-
-      const char *next_line_start = peek_ptr;
-      while (*peek_ptr && *peek_ptr != '\n') peek_ptr++;
-      size_t next_line_len = peek_ptr - next_line_start;
-
-      char *next_line = (char *)malloc(next_line_len + 1);
-      if (next_line) {
-        memcpy(next_line, next_line_start, next_line_len);
-        next_line[next_line_len] = '\0';
-
-        if (is_table_separator(next_line)) {
-          // It's a valid table
-          buffer_append(buf, "<table>");
-
-          // Header row
-          buffer_append(buf, "<thead>");
-          parse_table_row(buf, line, 1);
-          buffer_append(buf, "</thead>");
-
-          // Skip to after separator
-          if (*ptr == '\n') ptr++;
-          ptr = peek_ptr;
-          if (*ptr == '\n') ptr++;
-
-          // Body rows
-          buffer_append(buf, "<tbody>");
-          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';
-
-            if (!is_table_row(line) || is_empty_line(line)) {
-              ptr = line_start;
-              break;
-            }
-
-            parse_table_row(buf, line, 0);
-            if (*ptr == '\n') ptr++;
-          }
-          buffer_append(buf, "</tbody>");
-
-          buffer_append(buf, "</table>");
-          continue;
-        }
-      }
-    }
-
-    // Paragraph
-    buffer_append(buf, "<p>");
-
-    while (1) {
-      const char *content = skip_whitespace(line);
-      process_inline(buf, content, strlen(content));
-
-      if (*ptr == '\n') ptr++;
-      if (!*ptr) break;
-
-      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';
-
-      if (is_empty_line(line) ||
-          count_heading_level(line) > 0 ||
-          starts_with(line, "```") ||
-          starts_with(line, ">") ||
-          is_horizontal_rule(line) ||
-          is_unordered_list(line) ||
-          is_ordered_list(line) ||
-          is_table_row(line)) {
-        ptr = line_start;
-        break;
-      }
-
-      buffer_append_char(buf, ' ');
-    }
-
-    buffer_append(buf, "</p>");
-  }
-
-  return buf->data;
-}
-
-// Get string length (for JS interop)
-WASM_EXPORT size_t markdown_strlen(const char *str)
-{
-  return str ? strlen(str) : 0;
-}
--- a/markdown_converter/wasm/markdown_to_html_wasm.js	Sat Jan 24 06:37:43 2026 -0800
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,143 +0,0 @@
-/**
- * Markdown to HTML Converter - WASM FFI Wrapper
- * Loads the C implementation via WebAssembly and provides JavaScript API
- *
- * Build the WASM with: bazel build //markdown_converter:markdown_to_html_wasm
- */
-
-class MarkdownConverterWasm {
-  constructor() {
-    this.wasm = undefined;
-    this.ready = false;
-  }
-
-  /**
-   * Initialize the WASM module
-   * @param {string} wasmPath - Path to the .wasm file
-   * @returns {Promise<void>}
-   */
-  async init(wasmPath = '/markdown_to_html_wasm.wasm') {
-    if (this.ready) return;
-
-    try {
-      this.wasm = await WebAssembly.instantiateStreaming(fetch(wasmPath), {
-        env: {}
-      });
-      this.ready = true;
-    } catch (err) {
-      console.error('Failed to load markdown WASM module:', err);
-      throw err;
-    }
-  }
-
-  /**
-   * Write string to WASM memory
-   * @private
-   */
-  _writeString(str) {
-    const encoder = new TextEncoder();
-    const bytes = encoder.encode(str + '\0');
-    const ptr = this.wasm.instance.exports.malloc(bytes.length);
-    const memory = new Uint8Array(this.wasm.instance.exports.memory.buffer);
-    memory.set(bytes, ptr);
-    return ptr;
-  }
-
-  /**
-   * Read string from WASM memory
-   * @private
-   */
-  _readString(ptr) {
-    const memory = new Uint8Array(this.wasm.instance.exports.memory.buffer);
-    let end = ptr;
-    while (memory[end] !== 0) end++;
-    const bytes = memory.slice(ptr, end);
-    const decoder = new TextDecoder();
-    return decoder.decode(bytes);
-  }
-
-  /**
-   * Reset the WASM heap (call between conversions to reclaim memory)
-   */
-  resetHeap() {
-    if (this.ready) {
-      this.wasm.instance.exports.heap_reset();
-    }
-  }
-
-  /**
-   * Convert markdown string to HTML string using WASM
-   * @param {string} markdown - The markdown text to convert
-   * @returns {string} The converted HTML
-   */
-  convert(markdown) {
-    if (!this.ready) {
-      throw new Error('WASM module not initialized. Call init() first.');
-    }
-
-    // Reset heap before each conversion to reclaim memory
-    this.resetHeap();
-
-    // Write markdown to WASM memory
-    const inputPtr = this._writeString(markdown);
-
-    // Call the C function
-    const outputPtr = this.wasm.instance.exports.markdown_to_html(inputPtr);
-
-    // Read the result
-    const html = this._readString(outputPtr);
-
-    return html;
-  }
-
-  /**
-   * Convert markdown to DOM elements (compatible with original API)
-   * @param {string} markdown - The markdown text to convert
-   * @returns {Array<HTMLElement>} Array of DOM elements
-   */
-  convertToElements(markdown) {
-    const html = this.convert(markdown);
-    const template = document.createElement('template');
-    template.innerHTML = html;
-    return Array.from(template.content.children);
-  }
-}
-
-// Singleton instance
-const markdownWasm = new MarkdownConverterWasm();
-
-/**
- * Convert markdown to DOM elements (WASM version)
- * Compatible with original markdownConverter() API
- * @param {string} value - Markdown string
- * @returns {Promise<Array<HTMLElement>>} Array of DOM elements
- */
-async function markdownConverterWasm(value) {
-  if (!markdownWasm.ready) {
-    await markdownWasm.init();
-  }
-  return markdownWasm.convertToElements(value);
-}
-
-/**
- * Render markdown to a container element (WASM version)
- * Compatible with original renderMarkdown() API
- * @param {HTMLElement} container - Container element
- * @param {string} markdown - Markdown string
- */
-async function renderMarkdownWasm(container, markdown) {
-  if (!markdownWasm.ready) {
-    await markdownWasm.init();
-  }
-  container.innerHTML = markdownWasm.convert(markdown);
-}
-
-// Export for use in other files
-if (typeof module !== 'undefined' && module.exports) {
-  module.exports = {
-    MarkdownConverterWasm,
-    markdownWasm,
-    markdownConverterWasm,
-    renderMarkdownWasm
-  };
-}
--- a/mrjunejune/BUILD	Sat Jan 24 06:37:43 2026 -0800
+++ b/mrjunejune/BUILD	Tue Jan 27 06:51:44 2026 -0800
@@ -27,6 +27,18 @@
   ],
   dest = "src/public/highlight",
 )
+
+filegroup(
+  name = "public_files",
+  srcs = glob(["src/public/*"]),
+  visibility = ["//visibility:public"],
+)
+
+filegroup(
+  name = "public_fonts_files",
+  srcs = glob(["src/public/fonts/*"]),
+  visibility = ["//visibility:public"],
+)
     
 filegroup(
   name = "src_files",
Binary file mrjunejune/src/public/epi-photos/.DS_Store has changed
Binary file mrjunejune/src/public/epi-photos/webp/.DS_Store has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/npc/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,13 @@
+# npc
+
+NPC/character system application.
+
+## Files
+
+- `main.c` - Main application code
+
+## Building
+
+```bash
+bazel build //npc:npc
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/postdog/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,30 @@
+# postdog
+
+GUI application built with raylib. HTTP client with a graphical interface (like Postman).
+
+## Features
+
+- HTTP request builder
+- Response viewer
+- Request history
+
+## Files
+
+| File | Description |
+|------|-------------|
+| `main.c` | Main application |
+| `gui_window_file_dialog.h` | File dialog implementation |
+| `Roboto-Regular.ttf` | UI font |
+| `epi_all_colors.png` | Logo |
+| `history/` | Saved request history |
+
+## Building
+
+```bash
+bazel build //postdog:postdog
+bazel run //postdog:postdog
+```
+
+## Dependencies
+
+- raylib (via //third_party/raylib)
--- a/react_games/public/base.css	Sat Jan 24 06:37:43 2026 -0800
+++ b/react_games/public/base.css	Tue Jan 27 06:51:44 2026 -0800
@@ -5,7 +5,6 @@
 }
 
 body {
-  font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
   background: linear-gradient(135deg, #667eea 10%, #764ba2 100%);
   background-attachment: fixed;
   background-repeat: no-repeat;
--- a/seobeo/BUILD	Sat Jan 24 06:37:43 2026 -0800
+++ b/seobeo/BUILD	Tue Jan 27 06:51:44 2026 -0800
@@ -308,7 +308,7 @@
     "//dowa:dowa",
     "@openssl//:ssl",
   ],
-  defines = ["SEOBEO_WEBSOCKET_SERVER"],
+  defines = ["SEOBEO_WEBSOCKET_SERVER", "SEOBEO_ENABLE_DEBUG"],
   target_compatible_with = [
     "@platforms//os:osx",
   ],
@@ -334,7 +334,7 @@
     "//dowa:dowa",
     "@openssl//:ssl",
   ],
-  defines = ["SEOBEO_WEBSOCKET_SERVER"],
+  defines = ["SEOBEO_WEBSOCKET_SERVER", "SEOBEO_ENABLE_DEBUG"],
   target_compatible_with = [
     "@platforms//os:linux",
   ],
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/seobeo/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,49 @@
+# seobeo
+
+HTTP client and networking library for C.
+
+## Features
+
+- HTTP/HTTPS client
+- SSL/TLS support
+- Async networking with libuv
+- Snapshot testing utilities
+
+## Files
+
+| File | Description |
+|------|-------------|
+| `seobeo.h` | Public API header |
+| `seobeo_internal.h` | Internal declarations |
+| `s_http_client.c` | HTTP client implementation |
+| `s_network.c` | Network utilities |
+| `s_ssl.c` | SSL/TLS handling |
+| `s_logging.c` | Logging utilities |
+| `snapshot_creator.c/h` | Snapshot testing |
+| `docs/` | Documentation |
+| `examples/` | Usage examples |
+| `tests/` | Unit tests |
+| `os/` | OS-specific code |
+
+## Usage
+
+```c
+#include "seobeo/seobeo.h"
+
+// Make HTTP request
+HttpResponse* resp = http_get("https://example.com");
+// handle response...
+http_response_free(resp);
+```
+
+## Building
+
+```bash
+bazel build //seobeo:seobeo
+bazel test //seobeo:seobeo_test
+```
+
+## Dependencies
+
+- libuv (via //third_party/libuv)
+- OpenSSL
--- a/seobeo/s_http_client.c	Sat Jan 24 06:37:43 2026 -0800
+++ b/seobeo/s_http_client.c	Tue Jan 27 06:51:44 2026 -0800
@@ -69,7 +69,7 @@
 
   memset(p_req, 0, sizeof(Seobeo_Client_Request));
 
-  p_req->p_arena = Dowa_Arena_Create(1024 * 1024);
+  p_req->p_arena = Dowa_Arena_Create(1024 * 1024 * 5);
   if (!p_req->p_arena)
   {
     free(p_req);
@@ -231,7 +231,7 @@
 
   memset(p_resp, 0, sizeof(Seobeo_Client_Response));
 
-  p_resp->p_arena = Dowa_Arena_Create(1024 * 1024 * 5); // 5 MB 
+  p_resp->p_arena = Dowa_Arena_Create(1024 * 1024 * 10); // 10 MB 
   if (!p_resp->p_arena)
   {
     free(p_resp);
@@ -391,7 +391,7 @@
   }
   else
   {
-    size_t cap = 1024 * 1024 * 3;
+    size_t cap = 1024 * 1024 * 5;
     size_t used = 0;
     char *body = download_path ? NULL : Dowa_Arena_Allocate(p_resp->p_arena, cap);
 
@@ -414,7 +414,6 @@
           }
           memcpy(body + used, p_handle->read_buffer, p_handle->read_buffer_len);
           used += p_handle->read_buffer_len;
-          Seobeo_Log(SEOBEO_DEBUG, "Copied %zu bytes, total %zu/%zu\n", used, used + p_handle->read_buffer_len, body_len);
         }
         Seobeo_Handle_Consume(p_handle, (uint32)p_handle->read_buffer_len);
       }
--- a/seobeo/s_web.c	Sat Jan 24 06:37:43 2026 -0800
+++ b/seobeo/s_web.c	Tue Jan 27 06:51:44 2026 -0800
@@ -151,6 +151,11 @@
   void *p_conn_kv = Dowa_HashMap_Get_Ptr(p_req_map, "Connection");
   const char *conn_header = p_conn_kv ? ((Seobeo_Request_Entry*)p_conn_kv)->value : NULL;
 
+  void *p_real_ip_kv = Dowa_HashMap_Get_Ptr(p_req_map, "X-Real-IP");
+  const char *real_ip = p_real_ip_kv ? ((Seobeo_Request_Entry*)p_real_ip_kv)->value : NULL;
+  if (!real_ip)
+    real_ip = p_cli_handle->host;
+
   if (conn_header)
   {
     if (connection_header_contains(conn_header, "close"))
@@ -185,6 +190,7 @@
 
   // --- Check for WebSocket upgrade request ---
   #ifdef SEOBEO_WEBSOCKET_SERVER
+  Seobeo_Log(SEOBEO_DEBUG, "Web socket path \n");
   if (Seobeo_WebSocket_Server_Handle_Upgrade(p_cli_handle, p_req_map, path))
   {
     Seobeo_Log(SEOBEO_INFO, "WebSocket connection established\n");
@@ -194,7 +200,15 @@
   }
   #endif
 
-  // --- Try to match API route first ---
+  // --- Try to match streaming route first ---
+  Seobeo_Stream_Handler stream_handler = Seobeo_Router_Find_Stream_Handler(method, path, &p_req_map, p_request_arena);
+  if (stream_handler != NULL)
+  {
+    stream_handler(p_cli_handle, p_req_map, p_response_arena);
+    goto clean_up_arenas;
+  }
+
+  // --- Try to match API route ---
   Seobeo_Route_Handler handler = Seobeo_Router_Find_Handler(method, path, &p_req_map, p_request_arena);
   if (handler != NULL)
   {
@@ -333,7 +347,10 @@
       break;
 
     if (r == 0)
-      return 1;     // EAGAIN, try again later TODO: Add this as part of Handle struct.
+    {
+      Seobeo_Log(SEOBEO_INFO, "Waiting?\n");
+      continue;     // EAGAIN, try again later TODO: Add this as part of Handle struct.
+    }
   }
 
   // "METHOD SP PATH SP VERSION CRLF"
@@ -448,7 +465,6 @@
     char *next = strstr(line, "\r\n");
     if (!next) break;
 
-    // split at colon
     char *colon = memchr(line, ':', next - line);
     if (colon)
     {
@@ -472,7 +488,6 @@
       memcpy(val, val_start, value_len);
       val[value_len] = '\0';
 
-      // Both key and value are arena-allocated, hashmap will use them
       Dowa_HashMap_Push_Arena(*pp_map, key, val, p_arena);
     }
 
@@ -490,7 +505,6 @@
 
     Seobeo_Log(SEOBEO_DEBUG, "Content-Length=%zu, reading body in chunks...\n", body_len);
 
-    // Allocate buffer for entire body
     char *body = Dowa_Arena_Allocate(p_arena, body_len + 1);
     if (!body)
     {
@@ -606,6 +620,7 @@
   char *method; // "GET", "POST", "PUT", "DELETE"
   char *path_pattern; // "/v1/users/:id/posts/:post_id"
   Seobeo_Route_Handler handler;
+  Seobeo_Stream_Handler stream_handler; // For streaming responses
 
   // Pre-parsed path segments for efficient matching
   char **path_segments; // ["v1", "users", ":id", "posts", ":post_id"]
@@ -627,6 +642,25 @@
   route.method = strdup(method);
   route.path_pattern = strdup(path_pattern);
   route.handler = handler;
+  route.stream_handler = NULL;
+  route.path_segments = Dowa_String_Split(path_pattern, "/", strlen(path_pattern), 1, NULL);
+  route.segment_count = Dowa_Array_Length(route.path_segments);
+  route.is_param = (boolean*)malloc(sizeof(boolean) * route.segment_count);
+
+  for (size_t i = 0; i < route.segment_count; i++)
+    route.is_param[i] = (route.path_segments[i][0] == ':');
+
+  Dowa_Array_Push(g_routes, route);
+}
+
+void Seobeo_Router_Register_Stream(const char *method, const char *path_pattern, Seobeo_Stream_Handler handler)
+{
+  Seobeo_Route route = {0};
+
+  route.method = strdup(method);
+  route.path_pattern = strdup(path_pattern);
+  route.handler = NULL;
+  route.stream_handler = handler;
   route.path_segments = Dowa_String_Split(path_pattern, "/", strlen(path_pattern), 1, NULL);
   route.segment_count = Dowa_Array_Length(route.path_segments);
   route.is_param = (boolean*)malloc(sizeof(boolean) * route.segment_count);
@@ -709,6 +743,29 @@
   return NULL;
 }
 
+Seobeo_Stream_Handler Seobeo_Router_Find_Stream_Handler(
+  const char *method,
+  const char *path,
+  Seobeo_Request_Entry **pp_request_map,
+  Dowa_Arena *p_arena)
+{
+  if (g_routes == NULL || method == NULL || path == NULL)
+    return NULL;
+
+  size_t route_count = Dowa_Array_Length(g_routes);
+  for (size_t i = 0; i < route_count; i++)
+  {
+    Seobeo_Route *route = &g_routes[i];
+    if (strcmp(route->method, method) != 0)
+      continue;
+
+    if (route->stream_handler && match_route_and_extract(route, path, pp_request_map, p_arena))
+      return route->stream_handler;
+  }
+
+  return NULL;
+}
+
 void Seobeo_Router_Send_Response(
     Seobeo_Handle *p_handle,
     Seobeo_Request_Entry *p_response_map,
@@ -744,24 +801,23 @@
   const char *body = "";
   void *p_body_kv = Dowa_HashMap_Get_Ptr(p_response_map, "body");
   if (p_body_kv)
-  {
     body = ((Seobeo_Request_Entry*)p_body_kv)->value;
-  }
 
   const char *content_type = "text/html";
   void *p_content_type_kv = Dowa_HashMap_Get_Ptr(p_response_map, "content-type");
   if (p_content_type_kv)
-  {
     content_type = ((Seobeo_Request_Entry*)p_content_type_kv)->value;
-  }
 
-  size_t body_length = strlen(body);
+  // TODO: Update this to be integer
+  size_t body_length;
   void *p_content_length_kv = Dowa_HashMap_Get_Ptr(p_response_map, "content-length");
   if (p_content_length_kv)
   {
     const char *content_length_str = ((Seobeo_Request_Entry*)p_content_length_kv)->value;
     body_length = atoi(content_length_str);
   }
+  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);
@@ -782,6 +838,8 @@
     free(temp);
   }
 
+  printf("hEADER %s\n", header);
+
   Seobeo_Handle_Queue(p_handle, (uint8_t*)header, strlen(header));
   Seobeo_Handle_Queue(p_handle, (uint8_t*)body, body_length);
   Seobeo_Handle_Flush(p_handle);
@@ -804,3 +862,44 @@
   Dowa_Array_Free(g_routes);
   g_routes = NULL;
 }
+
+// Written by AI. I don't know what it does.
+void Seobeo_Url_Decode(char *dst, const char *src)
+{
+  char a, b;
+  while (*src) {
+    /* Check if we have a % followed by two valid hex characters */
+    if (*src == '%' && src[1] && src[2]) {
+      a = src[1];
+      b = src[2];
+
+      /* Manual isxdigit check and conversion for 'a' */
+      int a_val = -1;
+      if (a >= '0' && a <= '9')    a_val = a - '0';
+      else if (a >= 'a' && a <= 'f') a_val = a - 'a' + 10;
+      else if (a >= 'A' && a <= 'F') a_val = a - 'A' + 10;
+
+      /* Manual isxdigit check and conversion for 'b' */
+      int b_val = -1;
+      if (b >= '0' && b <= '9')    b_val = b - '0';
+      else if (b >= 'a' && b <= 'f') b_val = b - 'a' + 10;
+      else if (b >= 'A' && b <= 'F') b_val = b - 'A' + 10;
+
+      /* If both were valid hex, combine them */
+      if (a_val != -1 && b_val != -1) {
+        *dst++ = (char)((a_val << 4) | b_val);
+        src += 3;
+        continue;
+      }
+    }
+
+    /* Handle '+' as space, otherwise copy character literally */
+    if (*src == '+') {
+      *dst++ = ' ';
+    } else {
+      *dst++ = *src;
+    }
+    src++;
+  }
+  *dst = '\0';
+}
--- a/seobeo/seobeo.h	Sat Jan 24 06:37:43 2026 -0800
+++ b/seobeo/seobeo.h	Tue Jan 27 06:51:44 2026 -0800
@@ -91,6 +91,9 @@
 /* Destroy response and free all resources. */
 extern void                    Seobeo_Client_Response_Destroy(Seobeo_Client_Response *p_resp);
 
+// --- HTTP Web related helper functions --- //
+extern void    Seobeo_Url_Decode(char *dst, const char *src);
+
 /**
  * WebSocket Client API
  * ------
@@ -324,18 +327,22 @@
 extern void                               Seobeo_WebSocket_Server_Connection_Close(Seobeo_WebSocket_Server_Connection *p_conn, uint16 code, const char *reason);
 
 /* Initialize the router system (called automatically by Seobeo_Web_Server_Start) */
-extern void           Seobeo_Router_Init();
+extern void                  Seobeo_Router_Init();
 /* Register an API route handler. Call before starting server. */
-extern void           Seobeo_Router_Register(const char *method, const char *path_pattern, Seobeo_Route_Handler handler);
+extern void                  Seobeo_Router_Register(const char *method, const char *path_pattern, Seobeo_Route_Handler handler);
+/* Register a streaming route handler. Handler receives client handle for direct streaming. */
+extern void                  Seobeo_Router_Register_Stream(const char *method, const char *path_pattern, Seobeo_Stream_Handler handler);
 /* Clean up router resources */
-extern void           Seobeo_Router_Destroy();
+extern void                  Seobeo_Router_Destroy();
 /* Find matching route handler (internal use) */
-extern Seobeo_Route_Handler Seobeo_Router_Find_Handler(const char *method, const char *path, Seobeo_Request_Entry **pp_request_map, Dowa_Arena *p_arena);
+extern Seobeo_Route_Handler  Seobeo_Router_Find_Handler(const char *method, const char *path, Seobeo_Request_Entry **pp_request_map, Dowa_Arena *p_arena);
 /* Send HTTP response from response map (internal use) */
-extern void           Seobeo_Router_Send_Response(Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, Dowa_Arena *p_arena);
+extern void                  Seobeo_Router_Send_Response(Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, Dowa_Arena *p_arena);
 /* Send HTTP response with keep-alive option */
-extern void           Seobeo_Router_Send_Response_KeepAlive(Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, Dowa_Arena *p_arena, boolean keep_alive);
-extern char          *Seobeo_Web_LoadFile(const char *file_path, size_t *p_file_size);
+extern void                  Seobeo_Router_Send_Response_KeepAlive(Seobeo_Handle *p_handle, Seobeo_Request_Entry *p_response_map, Dowa_Arena *p_arena, boolean keep_alive);
+extern char                 *Seobeo_Web_LoadFile(const char *file_path, size_t *p_file_size);
+/* Being a proxy and keeping the client open */
+extern Seobeo_Stream_Handler Seobeo_Router_Find_Stream_Handler(const char *method, const char *path, Seobeo_Request_Entry **pp_request_map, Dowa_Arena *p_arena);
 
 // --- Helper functions --- //
 /* Destroy handle. It will handle all NULL poointers. */
--- a/seobeo/seobeo_internal.h	Sat Jan 24 06:37:43 2026 -0800
+++ b/seobeo/seobeo_internal.h	Tue Jan 27 06:51:44 2026 -0800
@@ -87,6 +87,13 @@
     Dowa_Arena *p_arena
 );
 
+// Streaming handler - gets direct access to client handle for streaming responses
+typedef void (*Seobeo_Stream_Handler)(
+    Seobeo_Handle *p_client_handle,
+    Seobeo_Request_Entry *p_request_map,
+    Dowa_Arena *p_arena
+);
+
 // --- Parse Header into Dowa Map ---//
 extern int            Seobeo_Web_Header_Parse(Seobeo_Handle *p_handle, Seobeo_Request_Entry **pp_map, Dowa_Arena *p_arena);
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sori/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,13 @@
+# sori
+
+Audio library for C.
+
+## Files
+
+- `main.c` - Audio implementation
+
+## Building
+
+```bash
+bazel build //sori:sori
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/README.md	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,26 @@
+# third_party
+
+External dependencies and libraries.
+
+## Contents
+
+| Directory | Description |
+|-----------|-------------|
+| `bun/` | Bun JavaScript runtime |
+| `emsdk/` | Emscripten SDK for WASM compilation |
+| `highlight/` | highlight.js for syntax highlighting |
+| `libuv/` | Async I/O library |
+| `luajit/` | LuaJIT interpreter |
+| `raylib/` | Game/GUI library |
+| `sqlite3/` | SQLite database |
+| `wrk/` | HTTP benchmarking tool |
+
+## Usage
+
+Reference in BUILD files:
+
+```starlark
+deps = ["//third_party/sqlite3:sqlite3"]
+deps = ["//third_party/raylib:raylib"]
+deps = ["//third_party/libuv:libuv"]
+```
--- a/third_party/bun/tsconfig.json	Sat Jan 24 06:37:43 2026 -0800
+++ b/third_party/bun/tsconfig.json	Tue Jan 27 06:51:44 2026 -0800
@@ -1,29 +1,8 @@
 {
   "compilerOptions": {
-    // Environment setup & latest features
-    "lib": ["ESNext"],
-    "target": "ESNext",
-    "module": "Preserve",
-    "moduleDetection": "force",
-    "jsx": "react-jsx",
-    "allowJs": true,
-
-    // Bundler mode
-    "moduleResolution": "bundler",
-    "allowImportingTsExtensions": true,
-    "verbatimModuleSyntax": true,
-    "noEmit": true,
-
-    // Best practices
-    "strict": true,
-    "skipLibCheck": true,
-    "noFallthroughCasesInSwitch": true,
-    "noUncheckedIndexedAccess": true,
-    "noImplicitOverride": true,
-
-    // Some stricter flags (disabled by default)
-    "noUnusedLocals": false,
-    "noUnusedParameters": false,
-    "noPropertyAccessFromIndexSignature": false
+    "baseUrl": ".",
+    "paths": {
+      "*": ["*"]
+    }
   }
 }
--- a/third_party/highlight/highlight.js	Sat Jan 24 06:37:43 2026 -0800
+++ b/third_party/highlight/highlight.js	Tue Jan 27 06:51:44 2026 -0800
@@ -8163,4 +8163,429 @@
 })();
 
     hljs.registerLanguage('x86asm', hljsGrammar);
-  })();
\ No newline at end of file
+  })();
+  (function(){
+    var hljsGrammar = (function () {
+  'use strict';
+
+
+  /*
+  Language: Shell Session
+  Requires: bash.js
+  Author: TSUYUSATO Kitsune <[email protected]>
+  Category: common
+  Audit: 2020
+  */
+/*
+Language: Bash
+Author: vah <[email protected]>
+Contributrors: Benjamin Pannell <[email protected]>
+Website: https://www.gnu.org/software/bash/
+Category: common, scripting
+*/
+
+/** @type LanguageFn */
+  function bash(hljs) {
+    const regex = hljs.regex;
+    const VAR = {};
+    const BRACED_VAR = {
+      begin: /\$\{/,
+      end: /\}/,
+      contains: [
+        "self",
+        {
+          begin: /:-/,
+          contains: [ VAR ]
+        } // default values
+      ]
+    };
+    Object.assign(VAR, {
+      className: 'variable',
+      variants: [
+        { begin: regex.concat(/\$[\w\d#@][\w\d_]*/,
+          // negative look-ahead tries to avoid matching patterns that are not
+          // Perl at all like $ident$, @ident@, etc.
+          `(?![\\w\\d])(?![$])`) },
+        BRACED_VAR
+      ]
+    });
+  
+    const SUBST = {
+      className: 'subst',
+      begin: /\$\(/,
+      end: /\)/,
+      contains: [ hljs.BACKSLASH_ESCAPE ]
+    };
+    const COMMENT = hljs.inherit(
+      hljs.COMMENT(),
+      {
+        match: [
+          /(^|\s)/,
+          /#.*$/
+        ],
+        scope: {
+          2: 'comment'
+        }
+      }
+    );
+    const HERE_DOC = {
+      begin: /<<-?\s*(?=\w+)/,
+      starts: { contains: [
+        hljs.END_SAME_AS_BEGIN({
+          begin: /(\w+)/,
+          end: /(\w+)/,
+          className: 'string'
+        })
+      ] }
+    };
+    const QUOTE_STRING = {
+      className: 'string',
+      begin: /"/,
+      end: /"/,
+      contains: [
+        hljs.BACKSLASH_ESCAPE,
+        VAR,
+        SUBST
+      ]
+    };
+    SUBST.contains.push(QUOTE_STRING);
+    const ESCAPED_QUOTE = {
+      match: /\\"/
+    };
+    const APOS_STRING = {
+      className: 'string',
+      begin: /'/,
+      end: /'/
+    };
+    const ESCAPED_APOS = {
+      match: /\\'/
+    };
+    const ARITHMETIC = {
+      begin: /\$?\(\(/,
+      end: /\)\)/,
+      contains: [
+        {
+          begin: /\d+#[0-9a-f]+/,
+          className: "number"
+        },
+        hljs.NUMBER_MODE,
+        VAR
+      ]
+    };
+    const SH_LIKE_SHELLS = [
+      "fish",
+      "bash",
+      "zsh",
+      "sh",
+      "csh",
+      "ksh",
+      "tcsh",
+      "dash",
+      "scsh",
+    ];
+    const KNOWN_SHEBANG = hljs.SHEBANG({
+      binary: `(${SH_LIKE_SHELLS.join("|")})`,
+      relevance: 10
+    });
+    const FUNCTION = {
+      className: 'function',
+      begin: /\w[\w\d_]*\s*\(\s*\)\s*\{/,
+      returnBegin: true,
+      contains: [ hljs.inherit(hljs.TITLE_MODE, { begin: /\w[\w\d_]*/ }) ],
+      relevance: 0
+    };
+  
+    const KEYWORDS = [
+      "if",
+      "then",
+      "else",
+      "elif",
+      "fi",
+      "time",
+      "for",
+      "while",
+      "until",
+      "in",
+      "do",
+      "done",
+      "case",
+      "esac",
+      "coproc",
+      "function",
+      "select"
+    ];
+  
+    const LITERALS = [
+      "true",
+      "false"
+    ];
+  
+    // to consume paths to prevent keyword matches inside them
+    const PATH_MODE = { match: /(\/[a-z._-]+)+/ };
+  
+    // http://www.gnu.org/software/bash/manual/html_node/Shell-Builtin-Commands.html
+    const SHELL_BUILT_INS = [
+      "break",
+      "cd",
+      "continue",
+      "eval",
+      "exec",
+      "exit",
+      "export",
+      "getopts",
+      "hash",
+      "pwd",
+      "readonly",
+      "return",
+      "shift",
+      "test",
+      "times",
+      "trap",
+      "umask",
+      "unset"
+    ];
+  
+    const BASH_BUILT_INS = [
+      "alias",
+      "bind",
+      "builtin",
+      "caller",
+      "command",
+      "declare",
+      "echo",
+      "enable",
+      "help",
+      "let",
+      "local",
+      "logout",
+      "mapfile",
+      "printf",
+      "read",
+      "readarray",
+      "source",
+      "sudo",
+      "type",
+      "typeset",
+      "ulimit",
+      "unalias"
+    ];
+  
+    const ZSH_BUILT_INS = [
+      "autoload",
+      "bg",
+      "bindkey",
+      "bye",
+      "cap",
+      "chdir",
+      "clone",
+      "comparguments",
+      "compcall",
+      "compctl",
+      "compdescribe",
+      "compfiles",
+      "compgroups",
+      "compquote",
+      "comptags",
+      "comptry",
+      "compvalues",
+      "dirs",
+      "disable",
+      "disown",
+      "echotc",
+      "echoti",
+      "emulate",
+      "fc",
+      "fg",
+      "float",
+      "functions",
+      "getcap",
+      "getln",
+      "history",
+      "integer",
+      "jobs",
+      "kill",
+      "limit",
+      "log",
+      "noglob",
+      "popd",
+      "print",
+      "pushd",
+      "pushln",
+      "rehash",
+      "sched",
+      "setcap",
+      "setopt",
+      "stat",
+      "suspend",
+      "ttyctl",
+      "unfunction",
+      "unhash",
+      "unlimit",
+      "unsetopt",
+      "vared",
+      "wait",
+      "whence",
+      "where",
+      "which",
+      "zcompile",
+      "zformat",
+      "zftp",
+      "zle",
+      "zmodload",
+      "zparseopts",
+      "zprof",
+      "zpty",
+      "zregexparse",
+      "zsocket",
+      "zstyle",
+      "ztcp"
+    ];
+  
+    const GNU_CORE_UTILS = [
+      "chcon",
+      "chgrp",
+      "chown",
+      "chmod",
+      "cp",
+      "dd",
+      "df",
+      "dir",
+      "dircolors",
+      "ln",
+      "ls",
+      "mkdir",
+      "mkfifo",
+      "mknod",
+      "mktemp",
+      "mv",
+      "realpath",
+      "rm",
+      "rmdir",
+      "shred",
+      "sync",
+      "touch",
+      "truncate",
+      "vdir",
+      "b2sum",
+      "base32",
+      "base64",
+      "cat",
+      "cksum",
+      "comm",
+      "csplit",
+      "cut",
+      "expand",
+      "fmt",
+      "fold",
+      "head",
+      "join",
+      "md5sum",
+      "nl",
+      "numfmt",
+      "od",
+      "paste",
+      "ptx",
+      "pr",
+      "sha1sum",
+      "sha224sum",
+      "sha256sum",
+      "sha384sum",
+      "sha512sum",
+      "shuf",
+      "sort",
+      "split",
+      "sum",
+      "tac",
+      "tail",
+      "tr",
+      "tsort",
+      "unexpand",
+      "uniq",
+      "wc",
+      "arch",
+      "basename",
+      "chroot",
+      "date",
+      "dirname",
+      "du",
+      "echo",
+      "env",
+      "expr",
+      "factor",
+      // "false", // keyword literal already
+      "groups",
+      "hostid",
+      "id",
+      "link",
+      "logname",
+      "nice",
+      "nohup",
+      "nproc",
+      "pathchk",
+      "pinky",
+      "printenv",
+      "printf",
+      "pwd",
+      "readlink",
+      "runcon",
+      "seq",
+      "sleep",
+      "stat",
+      "stdbuf",
+      "stty",
+      "tee",
+      "test",
+      "timeout",
+      // "true", // keyword literal already
+      "tty",
+      "uname",
+      "unlink",
+      "uptime",
+      "users",
+      "who",
+      "whoami",
+      "yes"
+    ];
+  
+    return {
+      name: 'Bash',
+      aliases: [
+        'sh',
+        'zsh'
+      ],
+      keywords: {
+        $pattern: /\b[a-z][a-z0-9._-]+\b/,
+        keyword: KEYWORDS,
+        literal: LITERALS,
+        built_in: [
+          ...SHELL_BUILT_INS,
+          ...BASH_BUILT_INS,
+          // Shell modifiers
+          "set",
+          "shopt",
+          ...ZSH_BUILT_INS,
+          ...GNU_CORE_UTILS
+        ]
+      },
+      contains: [
+        KNOWN_SHEBANG, // to catch known shells and boost relevancy
+        hljs.SHEBANG(), // to catch unknown shells but still highlight the shebang
+        FUNCTION,
+        ARITHMETIC,
+        COMMENT,
+        HERE_DOC,
+        PATH_MODE,
+        QUOTE_STRING,
+        ESCAPED_QUOTE,
+        APOS_STRING,
+        ESCAPED_APOS,
+        VAR
+      ]
+    };
+  }
+}
+  return bash;
+})();
+
+    hljs.registerLanguage('bash', hljsGrammar);
+  })();
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/third_party/highlight/languages/shell.js	Tue Jan 27 06:51:44 2026 -0800
@@ -0,0 +1,31 @@
+/*
+Language: Shell Session
+Requires: bash.js
+Author: TSUYUSATO Kitsune <[email protected]>
+Category: common
+Audit: 2020
+*/
+
+/** @type LanguageFn */
+export default function(hljs) {
+  return {
+    name: 'Shell Session',
+    aliases: [
+      'console',
+      'shellsession'
+    ],
+    contains: [
+      {
+        className: 'meta.prompt',
+        // We cannot add \s (spaces) in the regular expression otherwise it will be too broad and produce unexpected result.
+        // For instance, in the following example, it would match "echo /path/to/home >" as a prompt:
+        // echo /path/to/home > t.exe
+        begin: /^\s{0,3}[/~\w\d[\]()@-]*[>%$#][ ]?/,
+        starts: {
+          end: /[^\\](?=\s*$)/,
+          subLanguage: 'bash'
+        }
+      }
+    ]
+  };
+}