krz/gitbay

A CLI-first git forge.

clone: git clone https://gitbay.org/krz/gitbay.git

ab56677031290fee18323c467841a25216d45768

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-23T22:57:41Z

M5: read-only web UI

- server-rendered pages (html/template, no JS, no build step): repo
  index, tree with README rendering (goldmark, raw HTML dropped), blob
  with chroma highlighting, raw with nosniff, log and commit pages with
  signature badges, refs, tar.gz archive
- explicit route table with per-route mutating flag; Handler() builds
  the mux from it; tests assert view_only has zero mutating routes and
  that every top-level route word is a reserved username
- private and nonexistent repos both 404 on every web path
- light/dark via prefers-color-scheme; code blocks pinned light to
  match chroma's inline styles
- hook socket falls back to a hashed temp path when the server root
  exceeds the unix sun_path limit
- e2e: test git env isolated from developer gitconfig (commit.gpgsign)
 e2e/git_test.go                    |   3 +
 e2e/http_test.go                   |   2 +-
 e2e/sig_test.go                    |   8 +-
 e2e/web_test.go                    | 176 ++++++++++++++++
 go.mod                             |   3 +
 go.sum                             |  12 ++
 internal/gitutil/read.go           | 132 ++++++++++++
 internal/hookd/hookd.go            |  16 +-
 internal/httpd/routes.go           |  53 +++++
 internal/httpd/routes_test.go      |  51 +++++
 internal/httpd/smart.go            |  13 +-
 internal/httpd/web.go              | 399 +++++++++++++++++++++++++++++++++++++
 internal/store/repos.go            |  22 ++
 internal/web/static/style.css      |  53 +++++
 internal/web/templates/blob.html   |   8 +
 internal/web/templates/commit.html |  11 +
 internal/web/templates/index.html  |   8 +
 internal/web/templates/layout.html |  30 +++
 internal/web/templates/log.html    |  15 ++
 internal/web/templates/refs.html   |   8 +
 internal/web/templates/tree.html   |  14 ++
 internal/web/web.go                |  26 +++
 22 files changed, 1050 insertions(+), 13 deletions(-)

diff --git a/e2e/git_test.go b/e2e/git_test.go
index bd938bd..15fa23e 100644
--- a/e2e/git_test.go
+++ b/e2e/git_test.go
@@ -17,6 +17,9 @@ func (i *instance) gitEnv(key string) []string {
 		key, filepath.Join(i.sshDir, "known_hosts"))
 	return append(os.Environ(),
 		"GIT_SSH_COMMAND="+sshCmd,
+		// Isolate from the developer's own git config (signing, helpers).
+		"GIT_CONFIG_NOSYSTEM=1",
+		"GIT_CONFIG_GLOBAL=/dev/null",
 		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
 		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
 	)
diff --git a/e2e/http_test.go b/e2e/http_test.go
index 268dd20..2eddd13 100644
--- a/e2e/http_test.go
+++ b/e2e/http_test.go
@@ -53,7 +53,7 @@ func anonEnv() []string {
 		"GIT_TERMINAL_PROMPT=0",
 		"GIT_ASKPASS=false",
 		"GIT_CONFIG_NOSYSTEM=1",
-		"HOME=/nonexistent-forge-e2e", // no ~/.gitconfig credential helpers
+		"GIT_CONFIG_GLOBAL=/dev/null", // no ~/.gitconfig credential helpers or signing
 		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
 		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
 	)
diff --git a/e2e/sig_test.go b/e2e/sig_test.go
index 741296f..02617b1 100644
--- a/e2e/sig_test.go
+++ b/e2e/sig_test.go
@@ -67,7 +67,13 @@ type commitSpec struct {
 func buildCommits(t *testing.T, dir string, env []string, specs []commitSpec) []string {
 	t.Helper()
 	tree := strings.TrimSpace(mustGit(t, dir, env, "mktree"))
-	parent := ""
+	return buildChain(t, dir, env, tree, "", specs)
+}
+
+// buildChain constructs signed commit objects on top of parent ("" for a
+// root commit) using the given tree, and points refs/heads/main at the tip.
+func buildChain(t *testing.T, dir string, env []string, tree, parent string, specs []commitSpec) []string {
+	t.Helper()
 	base := time.Now().Add(-time.Duration(len(specs)) * time.Minute).Unix()
 	var shas []string
 	for i, spec := range specs {
diff --git a/e2e/web_test.go b/e2e/web_test.go
new file mode 100644
index 0000000..209664c
--- /dev/null
+++ b/e2e/web_test.go
@@ -0,0 +1,176 @@
+package e2e
+
+import (
+	"compress/gzip"
+	"fmt"
+	"io"
+	"net/http"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"golang.org/x/crypto/ssh"
+
+	"github.com/krazywarez/forge/internal/sig"
+)
+
+func (i *instance) get(t *testing.T, path string) (int, string) {
+	t.Helper()
+	resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d%s", i.httpPort, path))
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer resp.Body.Close()
+	body, _ := io.ReadAll(resp.Body)
+	return resp.StatusCode, string(body)
+}
+
+func TestWebUI(t *testing.T) {
+	inst := startInstance(t)
+
+	aliceKey := inst.newKey(t, "alice")
+	inst.admin(t, "admin", "user", "create", "alice",
+		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
+
+	// Public repo with real content: a README, a source file, a tag, and
+	// one SSHSIG-signed commit for the badge check.
+	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/site"); code != 0 {
+		t.Fatalf("repo create: %s", errOut)
+	}
+	work := t.TempDir()
+	env := inst.gitEnv(aliceKey)
+	mustGit(t, work, env, "clone", inst.sshURL("alice/site"), "w")
+	dir := filepath.Join(work, "w")
+	os.WriteFile(filepath.Join(dir, "README.md"), []byte("# hello site\n\nsome *markdown*\n"), 0o644)
+	os.MkdirAll(filepath.Join(dir, "src"), 0o755)
+	os.WriteFile(filepath.Join(dir, "src", "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644)
+	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
+	mustGit(t, dir, env, "add", ".")
+	mustGit(t, dir, env, "commit", "-q", "-m", "first commit")
+	mustGit(t, dir, env, "tag", "v1.0")
+	mustGit(t, dir, env, "push", "-q", "origin", "main", "v1.0")
+
+	// A signed commit on top, built with the M4 fixture helpers.
+	sshRaw, _ := os.ReadFile(aliceKey)
+	signer, err := ssh.ParsePrivateKey(sshRaw)
+	if err != nil {
+		t.Fatal(err)
+	}
+	head := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
+	buildSignedCommitOn(t, dir, env, head, "signed tip", "alice@example.test", signer)
+	mustGit(t, dir, env, "push", "-q", "origin", "main")
+
+	// Private repo must be invisible everywhere.
+	if _, _, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/secret", "--private"); code != 0 {
+		t.Fatal("create private failed")
+	}
+
+	// Index lists the public repo, not the private one.
+	status, body := inst.get(t, "/")
+	if status != 200 || !strings.Contains(body, "alice/site") {
+		t.Fatalf("index: %d\n%s", status, body)
+	}
+	if strings.Contains(body, "secret") {
+		t.Fatal("index leaks private repo")
+	}
+
+	// Repo home: tree entries plus rendered README.
+	status, body = inst.get(t, "/alice/site")
+	if status != 200 || !strings.Contains(body, "src/") || !strings.Contains(body, "README.md") {
+		t.Fatalf("repo home: %d\n%s", status, body)
+	}
+	if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") {
+		t.Fatalf("README not rendered:\n%s", body)
+	}
+
+	// Subdirectory tree and blob with highlighting.
+	status, body = inst.get(t, "/alice/site/tree/main/src")
+	if status != 200 || !strings.Contains(body, "main.go") {
+		t.Fatalf("tree src: %d", status)
+	}
+	status, body = inst.get(t, "/alice/site/blob/main/src/main.go")
+	if status != 200 || !strings.Contains(body, "package") {
+		t.Fatalf("blob: %d", status)
+	}
+
+	// Raw serves exact bytes with nosniff.
+	resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/raw/main/src/main.go", inst.httpPort))
+	if err != nil {
+		t.Fatal(err)
+	}
+	raw, _ := io.ReadAll(resp.Body)
+	resp.Body.Close()
+	if string(raw) != "package main\n\nfunc main() {}\n" {
+		t.Fatalf("raw bytes: %q", raw)
+	}
+	if resp.Header.Get("X-Content-Type-Options") != "nosniff" {
+		t.Fatal("raw missing nosniff")
+	}
+
+	// Log: both commits, with badges matching the M4 states exactly.
+	status, body = inst.get(t, "/alice/site/log")
+	if status != 200 {
+		t.Fatalf("log: %d", status)
+	}
+	if !strings.Contains(body, "badge-verified") || !strings.Contains(body, "signed tip") {
+		t.Fatalf("log missing verified badge:\n%s", body)
+	}
+	if !strings.Contains(body, "badge-unsigned") || !strings.Contains(body, "first commit") {
+		t.Fatalf("log missing unsigned badge:\n%s", body)
+	}
+
+	// Commit page for the signed tip.
+	tip := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "HEAD"))
+	status, body = inst.get(t, "/alice/site/commit/"+tip)
+	if status != 200 || !strings.Contains(body, "badge-verified") || !strings.Contains(body, "alice") {
+		t.Fatalf("commit page: %d\n%s", status, body)
+	}
+
+	// Refs page shows branch and tag.
+	status, body = inst.get(t, "/alice/site/refs")
+	if status != 200 || !strings.Contains(body, "main") || !strings.Contains(body, "v1.0") {
+		t.Fatalf("refs: %d", status)
+	}
+
+	// Archive downloads a valid gzip.
+	resp, err = http.Get(fmt.Sprintf("http://127.0.0.1:%d/alice/site/archive/main.tar.gz", inst.httpPort))
+	if err != nil {
+		t.Fatal(err)
+	}
+	gz, err := gzip.NewReader(resp.Body)
+	if err != nil {
+		t.Fatalf("archive not gzip: %v", err)
+	}
+	tarBytes, _ := io.ReadAll(gz)
+	resp.Body.Close()
+	if !strings.Contains(string(tarBytes), "README.md") {
+		t.Fatal("archive missing content")
+	}
+
+	// Private repo pages: 404, indistinguishable from nonexistent.
+	for _, p := range []string{"/alice/secret", "/alice/secret/log", "/alice/nothere"} {
+		if status, _ := inst.get(t, p); status != 404 {
+			t.Errorf("GET %s = %d, want 404", p, status)
+		}
+	}
+}
+
+// buildSignedCommitOn adds one SSHSIG-signed commit on top of parent,
+// reusing the M4 fixture machinery.
+func buildSignedCommitOn(t *testing.T, dir string, env []string, parent, subject, email string, signer ssh.Signer) {
+	t.Helper()
+	tree := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", parent+"^{tree}"))
+	specs := []commitSpec{{
+		authorEmail: email,
+		subject:     subject,
+		sign: func(p []byte) string {
+			s, err := sig.MarshalSSHSig(signer, p)
+			if err != nil {
+				t.Fatal(err)
+			}
+			return string(s)
+		},
+	}}
+	buildChain(t, dir, env, tree, parent, specs)
+}
diff --git a/go.mod b/go.mod
index ba01536..d922dc9 100644
--- a/go.mod
+++ b/go.mod
@@ -5,13 +5,16 @@ go 1.27.0
 require (
 	github.com/BurntSushi/toml v1.6.0
 	github.com/ProtonMail/go-crypto v1.4.1
+	github.com/alecthomas/chroma/v2 v2.27.0
 	github.com/spf13/cobra v1.10.2
+	github.com/yuin/goldmark v1.8.5
 	golang.org/x/crypto v0.55.0
 	modernc.org/sqlite v1.57.0
 )
 
 require (
 	github.com/cloudflare/circl v1.6.2 // indirect
+	github.com/dlclark/regexp2/v2 v2.2.1 // indirect
 	github.com/dustin/go-humanize v1.0.1 // indirect
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
diff --git a/go.sum b/go.sum
index c424a7b..2404706 100644
--- a/go.sum
+++ b/go.sum
@@ -2,9 +2,17 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk
 github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
 github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM=
 github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
+github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
+github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
+github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
+github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
+github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
+github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
 github.com/cloudflare/circl v1.6.2 h1:hL7VBpHHKzrV5WTfHCaBsgx/HGbBYlgrwvNXEVDYYsQ=
 github.com/cloudflare/circl v1.6.2/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
+github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
 github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
@@ -13,6 +21,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
+github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
@@ -26,6 +36,8 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
+github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
 go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
 golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
 golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
diff --git a/internal/gitutil/read.go b/internal/gitutil/read.go
new file mode 100644
index 0000000..5dadc63
--- /dev/null
+++ b/internal/gitutil/read.go
@@ -0,0 +1,132 @@
+package gitutil
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+type TreeEntry struct {
+	Mode string
+	Type string // blob | tree
+	SHA  string
+	Size int64 // -1 for trees
+	Name string
+}
+
+// ListTree lists one level of the tree at ref:path.
+func ListTree(dir, ref, path string) ([]TreeEntry, error) {
+	spec := ref
+	if path != "" {
+		spec = ref + ":" + path
+	}
+	cmd := exec.Command("git", "-C", dir, "ls-tree", "-l", spec)
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, fmt.Errorf("ls-tree %s: %w", spec, err)
+	}
+	var entries []TreeEntry
+	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+		if line == "" {
+			continue
+		}
+		// <mode> <type> <sha> <size>\t<name>
+		meta, name, ok := strings.Cut(line, "\t")
+		if !ok {
+			continue
+		}
+		f := strings.Fields(meta)
+		if len(f) != 4 {
+			continue
+		}
+		size := int64(-1)
+		if f[3] != "-" {
+			size, _ = strconv.ParseInt(f[3], 10, 64)
+		}
+		entries = append(entries, TreeEntry{Mode: f[0], Type: f[1], SHA: f[2], Size: size, Name: name})
+	}
+	return entries, nil
+}
+
+// ReadBlob returns the contents of ref:path, capped at limit bytes.
+func ReadBlob(dir, ref, path string, limit int64) ([]byte, error) {
+	cmd := exec.Command("git", "-C", dir, "cat-file", "blob", ref+":"+path)
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return nil, err
+	}
+	if err := cmd.Start(); err != nil {
+		return nil, err
+	}
+	data, err := io.ReadAll(io.LimitReader(stdout, limit))
+	io.Copy(io.Discard, stdout) // drain so git exits cleanly
+	if werr := cmd.Wait(); werr != nil {
+		return nil, fmt.Errorf("cat-file blob %s:%s: %w", ref, path, werr)
+	}
+	return data, err
+}
+
+// ResolveRef resolves a ref or sha to a full commit sha; errors if absent.
+func ResolveRef(dir, ref string) (string, error) {
+	cmd := exec.Command("git", "-C", dir, "rev-parse", "--verify", "--quiet", ref+"^{commit}")
+	out, err := cmd.Output()
+	if err != nil {
+		return "", fmt.Errorf("unknown ref %q", ref)
+	}
+	return strings.TrimSpace(string(out)), nil
+}
+
+type Ref struct {
+	Name string
+	SHA  string
+}
+
+// Refs lists branches or tags; kind is "heads" or "tags".
+func Refs(dir, kind string) ([]Ref, error) {
+	cmd := exec.Command("git", "-C", dir, "for-each-ref",
+		"--format=%(refname:short) %(objectname)", "refs/"+kind)
+	out, err := cmd.Output()
+	if err != nil {
+		return nil, err
+	}
+	var refs []Ref
+	for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
+		if name, sha, ok := strings.Cut(line, " "); ok {
+			refs = append(refs, Ref{Name: name, SHA: sha})
+		}
+	}
+	return refs, nil
+}
+
+// Archive streams a tar.gz of ref to w.
+func Archive(dir, ref, prefix string, w io.Writer) error {
+	cmd := exec.Command("git", "-C", dir, "archive", "--format=tar.gz", "--prefix="+prefix+"/", ref)
+	cmd.Stdout = w
+	return cmd.Run()
+}
+
+// ShowPatch returns the stat+patch text for one commit.
+func ShowPatch(dir, sha string, limit int64) (string, error) {
+	cmd := exec.Command("git", "-C", dir, "show", "--stat", "--patch", "--format=", sha)
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return "", err
+	}
+	if err := cmd.Start(); err != nil {
+		return "", err
+	}
+	data, _ := io.ReadAll(io.LimitReader(stdout, limit))
+	io.Copy(io.Discard, stdout)
+	if err := cmd.Wait(); err != nil {
+		return "", fmt.Errorf("show %s: %w", sha, err)
+	}
+	return string(data), nil
+}
+
+// IsBinary reports whether data looks like binary content.
+func IsBinary(data []byte) bool {
+	return bytes.IndexByte(data, 0) >= 0
+}
diff --git a/internal/hookd/hookd.go b/internal/hookd/hookd.go
index afc0f11..1efb95d 100644
--- a/internal/hookd/hookd.go
+++ b/internal/hookd/hookd.go
@@ -5,6 +5,7 @@
 package hookd
 
 import (
+	"crypto/sha256"
 	"encoding/json"
 	"fmt"
 	"net"
@@ -35,8 +36,19 @@ type Response struct {
 	Message string `json:"message,omitempty"`
 }
 
-// SocketPath returns the hook socket location under the server root.
-func SocketPath(root string) string { return filepath.Join(root, "hook.sock") }
+// SocketPath returns the hook socket location. It prefers the server root,
+// but unix socket paths are capped (~104 bytes on macOS, 108 on Linux), so
+// deep roots fall back to a hashed name under the system temp directory.
+// Hooks receive the chosen path via FORGE_HOOK_SOCKET, so both sides always
+// agree.
+func SocketPath(root string) string {
+	p := filepath.Join(root, "hook.sock")
+	if len(p) <= 100 {
+		return p
+	}
+	sum := sha256.Sum256([]byte(root))
+	return filepath.Join(os.TempDir(), fmt.Sprintf("forge-%x.sock", sum[:8]))
+}
 
 type Server struct {
 	st *store.Store
diff --git a/internal/httpd/routes.go b/internal/httpd/routes.go
new file mode 100644
index 0000000..9d08a57
--- /dev/null
+++ b/internal/httpd/routes.go
@@ -0,0 +1,53 @@
+package httpd
+
+import "net/http"
+
+// Route is one entry in the explicit route table. The view-only guarantee is
+// structural: Handler() consults web.mode when building the table, and the
+// route test asserts no mutating route exists in view_only mode.
+type Route struct {
+	Method  string
+	Pattern string // without method prefix
+	// Mutating marks routes that can change server state. The git transport
+	// POSTs are not mutating: upload-pack is a pure read, and the
+	// receive-pack endpoint is a static refusal that writes nothing.
+	Mutating bool
+	Handler  http.HandlerFunc
+}
+
+// Routes returns the route table for the configured web.mode.
+func (s *Server) Routes() []Route {
+	// Git smart transport (anonymous, public repos only).
+	routes := []Route{
+		{Method: "GET", Pattern: "/{owner}/{repo}/info/refs", Handler: s.infoRefs},
+		{Method: "POST", Pattern: "/{owner}/{repo}/git-upload-pack", Handler: s.uploadPack},
+		{Method: "POST", Pattern: "/{owner}/{repo}/git-receive-pack", Handler: s.receivePackRefusal},
+	}
+
+	// Web UI, read-only. These exist in every mode.
+	routes = append(routes,
+		Route{Method: "GET", Pattern: "/{$}", Handler: s.index},
+		Route{Method: "GET", Pattern: "/static/style.css", Handler: s.stylesheet},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}/raw/{ref}/{path...}", Handler: s.raw},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}/log", Handler: s.log},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}/log/{ref}", Handler: s.log},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}/commit/{sha}", Handler: s.commit},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}/refs", Handler: s.refs},
+		Route{Method: "GET", Pattern: "/{owner}/{repo}/archive/{file}", Handler: s.archive},
+	)
+
+	// Account-mode routes (login, web edits) are appended here in M8 —
+	// and only when s.cfg.Web.Mode == "accounts".
+	return routes
+}
+
+func (s *Server) Handler() http.Handler {
+	mux := http.NewServeMux()
+	for _, r := range s.Routes() {
+		mux.HandleFunc(r.Method+" "+r.Pattern, r.Handler)
+	}
+	return mux
+}
diff --git a/internal/httpd/routes_test.go b/internal/httpd/routes_test.go
new file mode 100644
index 0000000..577fc3f
--- /dev/null
+++ b/internal/httpd/routes_test.go
@@ -0,0 +1,51 @@
+package httpd
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/krazywarez/forge/internal/config"
+	"github.com/krazywarez/forge/internal/policy"
+)
+
+// TestViewOnlyHasNoMutatingRoutes is the structural guarantee from the plan:
+// under web.mode = "view_only" the route table must contain no mutating
+// route — not hidden ones, none at all.
+func TestViewOnlyHasNoMutatingRoutes(t *testing.T) {
+	cfg := config.Default()
+	cfg.Web.Mode = "view_only"
+	s := New(cfg, nil)
+
+	for _, r := range s.Routes() {
+		if r.Mutating {
+			t.Errorf("view_only route table contains mutating route %s %s", r.Method, r.Pattern)
+		}
+		// The only POSTs allowed are the git transport endpoints: a pure
+		// read (upload-pack) and a static refusal (receive-pack).
+		if r.Method != "GET" && !strings.Contains(r.Pattern, "git-upload-pack") && !strings.Contains(r.Pattern, "git-receive-pack") {
+			t.Errorf("view_only route table contains non-GET route %s %s", r.Method, r.Pattern)
+		}
+		for _, word := range []string{"login", "logout", "register", "edit", "new", "settings"} {
+			if strings.Contains(r.Pattern, "/"+word) {
+				t.Errorf("view_only route table contains account-mode pattern %s %s", r.Method, r.Pattern)
+			}
+		}
+	}
+}
+
+// TestTopLevelRouteWordsAreReserved keeps the route table and the reserved
+// username list in agreement: every literal first path segment must be an
+// unclaimable username.
+func TestTopLevelRouteWordsAreReserved(t *testing.T) {
+	s := New(config.Default(), nil)
+	for _, r := range s.Routes() {
+		seg := strings.TrimPrefix(r.Pattern, "/")
+		seg, _, _ = strings.Cut(seg, "/")
+		if seg == "" || strings.HasPrefix(seg, "{") {
+			continue // wildcard or root
+		}
+		if !policy.Reserved(seg) {
+			t.Errorf("top-level route word %q is not in the reserved username list", seg)
+		}
+	}
+}
diff --git a/internal/httpd/smart.go b/internal/httpd/smart.go
index 593b9ae..381288a 100644
--- a/internal/httpd/smart.go
+++ b/internal/httpd/smart.go
@@ -28,15 +28,10 @@ func New(cfg config.Config, st *store.Store) *Server {
 	return &Server{cfg: cfg, st: st}
 }
 
-func (s *Server) Handler() http.Handler {
-	mux := http.NewServeMux()
-	mux.HandleFunc("GET /{owner}/{repo}/info/refs", s.infoRefs)
-	mux.HandleFunc("POST /{owner}/{repo}/git-upload-pack", s.uploadPack)
-	// Push endpoints exist only to fail legibly.
-	mux.HandleFunc("POST /{owner}/{repo}/git-receive-pack", func(w http.ResponseWriter, r *http.Request) {
-		http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden)
-	})
-	return mux
+// receivePackRefusal exists only to fail legibly if a client POSTs without
+// reading the advertisement first.
+func (s *Server) receivePackRefusal(w http.ResponseWriter, r *http.Request) {
+	http.Error(w, s.pushRefusalMessage(r.PathValue("owner"), r.PathValue("repo")), http.StatusForbidden)
 }
 
 // publicRepo resolves owner/name and returns it only if it exists and is
diff --git a/internal/httpd/web.go b/internal/httpd/web.go
new file mode 100644
index 0000000..6bb187c
--- /dev/null
+++ b/internal/httpd/web.go
@@ -0,0 +1,399 @@
+package httpd
+
+import (
+	"bytes"
+	"fmt"
+	"html/template"
+	"net/http"
+	"path"
+	"strings"
+	"time"
+
+	"github.com/alecthomas/chroma/v2/formatters/html"
+	"github.com/alecthomas/chroma/v2/lexers"
+	"github.com/alecthomas/chroma/v2/styles"
+	"github.com/yuin/goldmark"
+
+	"github.com/krazywarez/forge/internal/control"
+	"github.com/krazywarez/forge/internal/gitutil"
+	"github.com/krazywarez/forge/internal/sig"
+	"github.com/krazywarez/forge/internal/store"
+	"github.com/krazywarez/forge/internal/web"
+)
+
+const maxRenderBytes = 1 << 20 // largest blob rendered inline
+
+func (s *Server) render(w http.ResponseWriter, page string, data any) {
+	var buf bytes.Buffer
+	if err := web.Render(&buf, page, data); err != nil {
+		http.Error(w, "template error: "+err.Error(), http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "text/html; charset=utf-8")
+	buf.WriteTo(w)
+}
+
+func (s *Server) siteName() string {
+	h := strings.TrimPrefix(strings.TrimPrefix(s.cfg.Server.SiteURL, "https://"), "http://")
+	return strings.TrimSuffix(h, "/")
+}
+
+func (s *Server) stylesheet(w http.ResponseWriter, r *http.Request) {
+	w.Header().Set("Content-Type", "text/css; charset=utf-8")
+	w.Write(web.StyleCSS)
+}
+
+func (s *Server) index(w http.ResponseWriter, r *http.Request) {
+	repos, err := s.st.ListPublicRepos()
+	if err != nil {
+		http.Error(w, "internal error", http.StatusInternalServerError)
+		return
+	}
+	s.render(w, "index.html", struct {
+		Site  string
+		Repos []store.Repo
+	}{s.siteName(), repos})
+}
+
+// repoPage is the shared context for repo-scoped pages.
+type repoPage struct {
+	Site     string
+	Repo     store.Repo
+	Ref      string
+	CloneURL string
+	Dir      string
+}
+
+// repoFor resolves the repo for a web request; false means 404 was sent.
+// The anonymous web sees public repos only — private and missing repos are
+// indistinguishable.
+func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (repoPage, bool) {
+	repo, ok := s.publicRepo(r.PathValue("owner"), r.PathValue("repo"))
+	if !ok {
+		http.NotFound(w, r)
+		return repoPage{}, false
+	}
+	if ref == "" {
+		ref = repo.DefaultBranch
+	}
+	return repoPage{
+		Site:     s.siteName(),
+		Repo:     repo,
+		Ref:      ref,
+		CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
+		Dir:      control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
+	}, true
+}
+
+type crumb struct {
+	Name string
+	URL  string
+}
+
+func crumbs(p repoPage, kind, filePath string) []crumb {
+	var cs []crumb
+	base := "/" + p.Repo.Path() + "/" + kind + "/" + p.Ref + "/"
+	acc := ""
+	for _, part := range strings.Split(filePath, "/") {
+		if part == "" {
+			continue
+		}
+		acc = path.Join(acc, part)
+		cs = append(cs, crumb{Name: part, URL: base + acc})
+	}
+	return cs
+}
+
+func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
+	p, ok := s.repoFor(w, r, "")
+	if !ok {
+		return
+	}
+	s.renderTree(w, r, p, "")
+}
+
+func (s *Server) tree(w http.ResponseWriter, r *http.Request) {
+	p, ok := s.repoFor(w, r, r.PathValue("ref"))
+	if !ok {
+		return
+	}
+	s.renderTree(w, r, p, strings.Trim(r.PathValue("path"), "/"))
+}
+
+func (s *Server) renderTree(w http.ResponseWriter, r *http.Request, p repoPage, dirPath string) {
+	if _, err := gitutil.ResolveRef(p.Dir, p.Ref); err != nil {
+		// Empty repo: render the page with no entries rather than 404.
+		s.render(w, "tree.html", struct {
+			repoPage
+			Crumbs     []crumb
+			Prefix     string
+			Entries    []gitutil.TreeEntry
+			ReadmeHTML template.HTML
+		}{repoPage: p})
+		return
+	}
+	entries, err := gitutil.ListTree(p.Dir, p.Ref, dirPath)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	prefix := ""
+	if dirPath != "" {
+		prefix = dirPath + "/"
+	}
+
+	var readmeHTML template.HTML
+	for _, e := range entries {
+		if e.Type != "blob" {
+			continue
+		}
+		lower := strings.ToLower(e.Name)
+		if lower == "readme" || lower == "readme.md" || lower == "readme.markdown" {
+			raw, err := gitutil.ReadBlob(p.Dir, p.Ref, prefix+e.Name, maxRenderBytes)
+			if err == nil {
+				var buf bytes.Buffer
+				if strings.HasSuffix(lower, ".md") || strings.HasSuffix(lower, ".markdown") {
+					// goldmark's default renderer drops raw HTML: safe.
+					if goldmark.Convert(raw, &buf) == nil {
+						readmeHTML = template.HTML(buf.String())
+					}
+				} else {
+					readmeHTML = template.HTML("<pre>" + template.HTMLEscapeString(string(raw)) + "</pre>")
+				}
+			}
+			break
+		}
+	}
+
+	s.render(w, "tree.html", struct {
+		repoPage
+		Crumbs     []crumb
+		Prefix     string
+		Entries    []gitutil.TreeEntry
+		ReadmeHTML template.HTML
+	}{p, crumbs(p, "tree", dirPath), prefix, entries, readmeHTML})
+}
+
+func (s *Server) blob(w http.ResponseWriter, r *http.Request) {
+	p, ok := s.repoFor(w, r, r.PathValue("ref"))
+	if !ok {
+		return
+	}
+	filePath := strings.Trim(r.PathValue("path"), "/")
+	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, maxRenderBytes+1)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	binary := gitutil.IsBinary(data) || len(data) > maxRenderBytes
+
+	var codeHTML template.HTML
+	if !binary {
+		codeHTML = highlight(filePath, data)
+	}
+	cs := crumbs(p, "blob", filePath)
+	base := ""
+	if len(cs) > 0 {
+		base = cs[len(cs)-1].Name
+		cs = cs[:len(cs)-1]
+	}
+	s.render(w, "blob.html", struct {
+		repoPage
+		Crumbs   []crumb
+		Base     string
+		Path     string
+		Binary   bool
+		Size     int
+		CodeHTML template.HTML
+	}{p, cs, base, filePath, binary, len(data), codeHTML})
+}
+
+func highlight(filePath string, data []byte) template.HTML {
+	lexer := lexers.Match(filePath)
+	if lexer == nil {
+		lexer = lexers.Fallback
+	}
+	style := styles.Get("friendly")
+	formatter := html.New(html.WithLineNumbers(true), html.LineNumbersInTable(false))
+	iterator, err := lexer.Tokenise(nil, string(data))
+	if err != nil {
+		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
+	}
+	var buf bytes.Buffer
+	if err := formatter.Format(&buf, style, iterator); err != nil {
+		return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
+	}
+	return template.HTML(buf.String())
+}
+
+func (s *Server) raw(w http.ResponseWriter, r *http.Request) {
+	p, ok := s.repoFor(w, r, r.PathValue("ref"))
+	if !ok {
+		return
+	}
+	filePath := strings.Trim(r.PathValue("path"), "/")
+	data, err := gitutil.ReadBlob(p.Dir, p.Ref, filePath, s.cfg.Limits.MaxBlobBytes)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	// Serve inert: never let repo content execute in the forge's origin.
+	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+	w.Header().Set("X-Content-Type-Options", "nosniff")
+	w.Write(data)
+}
+
+type sigView struct {
+	State       string
+	Signer      string
+	Fingerprint string
+}
+
+func (s *Server) sigFor(repo store.Repo, dir, sha string) (sigView, *sig.Commit) {
+	raw, err := gitutil.ReadCommit(dir, sha)
+	if err != nil {
+		return sigView{State: "unsigned"}, nil
+	}
+	parsed, err := sig.ParseCommit(raw)
+	if err != nil {
+		return sigView{State: "unsigned"}, nil
+	}
+	res, err := control.VerifyCommitCached(s.st, repo, parsed, sha)
+	if err != nil {
+		return sigView{State: "unsigned"}, parsed
+	}
+	v := sigView{State: string(res.State), Fingerprint: res.KeyFingerprint}
+	if res.SignerUserID != 0 {
+		if u, err := s.st.UserByID(res.SignerUserID); err == nil {
+			v.Signer = u.Username
+		}
+	}
+	return v, parsed
+}
+
+func (s *Server) log(w http.ResponseWriter, r *http.Request) {
+	ref := r.PathValue("ref")
+	p, ok := s.repoFor(w, r, ref)
+	if !ok {
+		return
+	}
+	const pageSize = 50
+	shas, err := gitutil.RevList(p.Dir, p.Ref, pageSize+1)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	next := ""
+	if len(shas) > pageSize {
+		next = shas[pageSize]
+		shas = shas[:pageSize]
+	}
+	type row struct {
+		SHA, ShortSHA, Subject, AuthorName, AuthorEmail, Date string
+		Sig                                                   sigView
+	}
+	var rows []row
+	for _, sha := range shas {
+		v, parsed := s.sigFor(p.Repo, p.Dir, sha)
+		rw := row{SHA: sha, ShortSHA: sha[:10], Sig: v}
+		if parsed != nil {
+			rw.Subject = parsed.Subject
+			rw.AuthorName = parsed.AuthorName
+			rw.AuthorEmail = parsed.AuthorEmail
+			rw.Date = time.Unix(parsed.AuthorUnix, 0).UTC().Format("2006-01-02")
+		}
+		rows = append(rows, rw)
+	}
+	s.render(w, "log.html", struct {
+		repoPage
+		Commits []row
+		NextSHA string
+	}{p, rows, next})
+}
+
+func (s *Server) commit(w http.ResponseWriter, r *http.Request) {
+	p, ok := s.repoFor(w, r, "")
+	if !ok {
+		return
+	}
+	sha := r.PathValue("sha")
+	full, err := gitutil.ResolveRef(p.Dir, sha)
+	if err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	v, parsed := s.sigFor(p.Repo, p.Dir, full)
+	if parsed == nil {
+		http.NotFound(w, r)
+		return
+	}
+	patch, _ := gitutil.ShowPatch(p.Dir, full, 4<<20)
+	type diffLine struct {
+		Class string
+		Text  string
+	}
+	var lines []diffLine
+	for _, l := range strings.Split(patch, "\n") {
+		class := ""
+		switch {
+		case strings.HasPrefix(l, "+++"), strings.HasPrefix(l, "---"), strings.HasPrefix(l, "diff "), strings.HasPrefix(l, "index "):
+			class = "meta"
+		case strings.HasPrefix(l, "@@"):
+			class = "hunk"
+		case strings.HasPrefix(l, "+"):
+			class = "add"
+		case strings.HasPrefix(l, "-"):
+			class = "del"
+		}
+		lines = append(lines, diffLine{class, l})
+	}
+	committerEmail := ""
+	if parsed.CommitterEmail != parsed.AuthorEmail {
+		committerEmail = parsed.CommitterEmail
+	}
+	msg := ""
+	if i := bytes.Index(parsed.Payload, []byte("\n\n")); i >= 0 {
+		msg = string(parsed.Payload[i+2:])
+	}
+	s.render(w, "commit.html", struct {
+		repoPage
+		SHA, ShortSHA, AuthorName, AuthorEmail, CommitterEmail, Date, Message string
+		Sig                                                                   sigView
+		DiffLines                                                             []diffLine
+	}{p, full, full[:10], parsed.AuthorName, parsed.AuthorEmail, committerEmail,
+		time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, lines})
+}
+
+func (s *Server) refs(w http.ResponseWriter, r *http.Request) {
+	p, ok := s.repoFor(w, r, "")
+	if !ok {
+		return
+	}
+	branches, _ := gitutil.Refs(p.Dir, "heads")
+	tags, _ := gitutil.Refs(p.Dir, "tags")
+	s.render(w, "refs.html", struct {
+		repoPage
+		Branches, Tags []gitutil.Ref
+	}{p, branches, tags})
+}
+
+func (s *Server) archive(w http.ResponseWriter, r *http.Request) {
+	p, ok := s.repoFor(w, r, "")
+	if !ok {
+		return
+	}
+	file := r.PathValue("file")
+	ref, ok := strings.CutSuffix(file, ".tar.gz")
+	if !ok {
+		http.NotFound(w, r)
+		return
+	}
+	if _, err := gitutil.ResolveRef(p.Dir, ref); err != nil {
+		http.NotFound(w, r)
+		return
+	}
+	prefix := fmt.Sprintf("%s-%s", p.Repo.Name, ref)
+	w.Header().Set("Content-Type", "application/gzip")
+	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", prefix+".tar.gz"))
+	gitutil.Archive(p.Dir, ref, prefix, w)
+}
diff --git a/internal/store/repos.go b/internal/store/repos.go
index 68d5022..84e8809 100644
--- a/internal/store/repos.go
+++ b/internal/store/repos.go
@@ -191,3 +191,25 @@ func (s *Store) RepoByID(id int64) (Repo, error) {
 	}
 	return r, nil
 }
+
+// ListPublicRepos returns all public repositories, for the anonymous index.
+func (s *Store) ListPublicRepos() ([]Repo, error) {
+	rows, err := s.DB.Query(`
+		SELECT r.id, r.owner_kind, r.owner_id, u.username, r.name, r.visibility, r.default_branch, r.settings_json
+		FROM repos r JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
+		WHERE r.visibility = 'public' ORDER BY u.username, r.name`)
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+	var out []Repo
+	for rows.Next() {
+		var r Repo
+		var settingsJSON string
+		if err := rows.Scan(&r.ID, &r.OwnerKind, &r.OwnerID, &r.OwnerName, &r.Name, &r.Visibility, &r.DefaultBranch, &settingsJSON); err != nil {
+			return nil, err
+		}
+		out = append(out, r)
+	}
+	return out, rows.Err()
+}
diff --git a/internal/web/static/style.css b/internal/web/static/style.css
new file mode 100644
index 0000000..12f398d
--- /dev/null
+++ b/internal/web/static/style.css
@@ -0,0 +1,53 @@
+:root {
+  --bg: #ffffff; --fg: #1a1a1a; --muted: #666; --line: #ddd;
+  --link: #0550ae; --code-bg: #f6f8fa;
+  --ok: #1a7f37; --warn: #9a6700; --bad: #cf222e; --neutral: #666;
+}
+@media (prefers-color-scheme: dark) {
+  :root {
+    --bg: #0d1117; --fg: #e6edf3; --muted: #8b949e; --line: #30363d;
+    --link: #58a6ff; --code-bg: #161b22;
+    --ok: #3fb950; --warn: #d29922; --bad: #f85149; --neutral: #8b949e;
+  }
+}
+* { box-sizing: border-box; }
+body {
+  margin: 0; background: var(--bg); color: var(--fg);
+  font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+header { border-bottom: 1px solid var(--line); padding: 0.6rem 1rem; }
+a.site { font-weight: 700; }
+main { max-width: 60rem; margin: 0 auto; padding: 1rem; }
+a { color: var(--link); text-decoration: none; }
+a:hover { text-decoration: underline; }
+h1 { font-size: 1.3rem; }
+h2 { font-size: 1.1rem; }
+code, pre, .code, td.mode, td.size { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; }
+table { border-collapse: collapse; width: 100%; }
+td { padding: 0.25rem 0.6rem 0.25rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }
+td.size, td.mode { color: var(--muted); white-space: nowrap; }
+nav.tabs a { margin-right: 1rem; }
+p.clone code { background: var(--code-bg); padding: 0.15rem 0.4rem; border-radius: 4px; }
+.crumbs { color: var(--muted); }
+.readme, .code { border: 1px solid var(--line); border-radius: 6px; padding: 1rem; margin-top: 1rem; overflow-x: auto; }
+/* chroma emits inline styles for a light background; pin the block to light
+   colors in both schemes so unstyled tokens stay legible. */
+.code { background: #f8f8f8; color: #1a1a1a; }
+.code pre { margin: 0; background: transparent !important; }
+pre.message { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; }
+pre.diff { background: var(--code-bg); padding: 0.8rem; border-radius: 6px; overflow-x: auto; }
+pre.diff .add { color: var(--ok); }
+pre.diff .del { color: var(--bad); }
+pre.diff .hunk { color: var(--link); }
+pre.diff .meta { color: var(--muted); }
+.badge {
+  display: inline-block; padding: 0.05rem 0.5rem; border-radius: 10px;
+  font-size: 12px; border: 1px solid;
+}
+.badge-verified { color: var(--ok); border-color: var(--ok); }
+.badge-unsigned { color: var(--neutral); border-color: var(--line); }
+.badge-signed_unknown_key { color: var(--warn); border-color: var(--warn); }
+.badge-signed_email_mismatch { color: var(--bad); border-color: var(--bad); }
+.badge-signed_key_expired { color: var(--warn); border-color: var(--warn); }
+.badge-signed_key_revoked { color: var(--bad); border-color: var(--bad); }
+.badge-bad_signature { color: var(--bad); border-color: var(--bad); }
diff --git a/internal/web/templates/blob.html b/internal/web/templates/blob.html
new file mode 100644
index 0000000..3297f00
--- /dev/null
+++ b/internal/web/templates/blob.html
@@ -0,0 +1,8 @@
+{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
+{{define "content"}}
+{{template "repoheader" .}}
+<p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}{{.Base}}
+ · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">raw</a></p>
+{{if .Binary}}<p>binary file, {{.Size}} bytes — <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/raw/{{.Ref}}/{{.Path}}">download</a></p>
+{{else}}<div class="code">{{.CodeHTML}}</div>{{end}}
+{{end}}
diff --git a/internal/web/templates/commit.html b/internal/web/templates/commit.html
new file mode 100644
index 0000000..d403129
--- /dev/null
+++ b/internal/web/templates/commit.html
@@ -0,0 +1,11 @@
+{{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
+{{define "content"}}
+{{template "repoheader" .}}
+<h2><code>{{.SHA}}</code></h2>
+<p>{{template "sigbadge" .Sig}}</p>
+<p>author: {{.AuthorName}} &lt;{{.AuthorEmail}}&gt; · {{.Date}}
+{{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
+<pre class="message">{{.Message}}</pre>
+<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
+{{end}}</pre>
+{{end}}
diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html
new file mode 100644
index 0000000..2bc620c
--- /dev/null
+++ b/internal/web/templates/index.html
@@ -0,0 +1,8 @@
+{{define "title"}}{{.Site}}{{end}}
+{{define "content"}}
+<h1>repositories</h1>
+<table>
+{{range .Repos}}<tr><td><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}/{{.Name}}</a></td><td>{{.DefaultBranch}}</td></tr>
+{{else}}<tr><td>no public repositories</td></tr>{{end}}
+</table>
+{{end}}
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html
new file mode 100644
index 0000000..40a2fec
--- /dev/null
+++ b/internal/web/templates/layout.html
@@ -0,0 +1,30 @@
+{{define "layout"}}<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{template "title" .}}</title>
+<link rel="stylesheet" href="/static/style.css">
+</head>
+<body>
+<header>
+  <nav><a class="site" href="/">{{.Site}}</a></nav>
+</header>
+<main>
+{{template "content" .}}
+</main>
+</body>
+</html>{{end}}
+
+{{define "repoheader"}}
+<h1><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.OwnerName}}/{{.Repo.Name}}</a></h1>
+<nav class="tabs">
+  <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">files</a>
+  <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">log</a>
+  <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">refs</a>
+  <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/archive/{{.Ref}}.tar.gz">archive</a>
+</nav>
+<p class="clone">clone: <code>git clone {{.CloneURL}}</code></p>
+{{end}}
+
+{{define "sigbadge"}}<span class="badge badge-{{.State}}" title="{{.Fingerprint}}">{{.State}}{{if .Signer}} · {{.Signer}}{{end}}</span>{{end}}
diff --git a/internal/web/templates/log.html b/internal/web/templates/log.html
new file mode 100644
index 0000000..181aa03
--- /dev/null
+++ b/internal/web/templates/log.html
@@ -0,0 +1,15 @@
+{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
+{{define "content"}}
+{{template "repoheader" .}}
+<table class="log">
+{{range .Commits}}<tr>
+  <td><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code></td>
+  <td>{{.Subject}}</td>
+  <td>{{.AuthorName}} &lt;{{.AuthorEmail}}&gt;</td>
+  <td>{{.Date}}</td>
+  <td>{{template "sigbadge" .Sig}}</td>
+</tr>
+{{end}}
+</table>
+{{if .NextSHA}}<p><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log/{{.NextSHA}}">older →</a></p>{{end}}
+{{end}}
diff --git a/internal/web/templates/refs.html b/internal/web/templates/refs.html
new file mode 100644
index 0000000..79a0a77
--- /dev/null
+++ b/internal/web/templates/refs.html
@@ -0,0 +1,8 @@
+{{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
+{{define "content"}}
+{{template "repoheader" .}}
+<h2>branches</h2>
+<table>{{range .Branches}}<tr><td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td><code>{{.SHA}}</code></td></tr>{{end}}</table>
+<h2>tags</h2>
+<table>{{range .Tags}}<tr><td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td><code>{{.SHA}}</code></td></tr>{{else}}<tr><td>none</td></tr>{{end}}</table>
+{{end}}
diff --git a/internal/web/templates/tree.html b/internal/web/templates/tree.html
new file mode 100644
index 0000000..96bfe95
--- /dev/null
+++ b/internal/web/templates/tree.html
@@ -0,0 +1,14 @@
+{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
+{{define "content"}}
+{{template "repoheader" .}}
+<p class="crumbs">{{.Ref}}: {{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</p>
+<table class="tree">
+{{range .Entries}}<tr>
+  <td class="mode">{{.Mode}}</td>
+  {{if eq .Type "tree"}}<td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td><td></td>
+  {{else}}<td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td><td class="size">{{.Size}}</td>{{end}}
+</tr>
+{{else}}<tr><td>empty</td></tr>{{end}}
+</table>
+{{if .ReadmeHTML}}<section class="readme">{{.ReadmeHTML}}</section>{{end}}
+{{end}}
diff --git a/internal/web/web.go b/internal/web/web.go
new file mode 100644
index 0000000..5f21515
--- /dev/null
+++ b/internal/web/web.go
@@ -0,0 +1,26 @@
+// Package web holds the server-rendered templates and static assets for the
+// read-only UI. No JavaScript, no build step.
+package web
+
+import (
+	"embed"
+	"html/template"
+	"io"
+)
+
+//go:embed templates/*.html
+var templateFS embed.FS
+
+//go:embed static/style.css
+var StyleCSS []byte
+
+// Render executes the named page template with the shared layout.
+func Render(w io.Writer, page string, data any) error {
+	t, err := template.Must(
+		template.ParseFS(templateFS, "templates/layout.html"),
+	).ParseFS(templateFS, "templates/"+page)
+	if err != nil {
+		return err
+	}
+	return t.ExecuteTemplate(w, "layout", data)
+}