Skip to content
quarry Artifact [7a1da0ca9d]
Login

Artifact [7a1da0ca9d]

Artifact 7a1da0ca9d4fe4f52fdfc847c166b470e5b4ef9a9757bca4ff501d408b90c55c:


// Package web is the HTML consumer of the registry (0001 §9). Pages are
// server-rendered html/template over op results — the same ops the API
// serves, so every page capability is API-visible by construction.
// Repo-scoped pages live under /r/{repo}/; the home page is the fleet.
package web

import (
	"bytes"
	"context"
	_ "embed"
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"net/url"
	"reflect"
	"strconv"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/highlight"
	"craftdesign.group/quarry/internal/ops"
)

// datastarJS is the vendored Datastar bundle (MIT; see the provenance
// ledger) — the one allowed frontend framework (0001 Principle 2),
// served self-contained. Everything works without it (Principle 3);
// it only adds live updates.
//
//go:embed assets/datastar.js
var datastarJS []byte

// Notifier is the change signal live pages subscribe to. The
// composition root supplies an events-backed implementation; web never
// touches the event plane directly. nil disables liveness.
type Notifier interface {
	Watch(repo string) (<-chan struct{}, func())
}

// base is the layout every page renders through. Both themes come from
// prefers-color-scheme; no JavaScript anywhere (0001 Principle 3 — the
// no-JS baseline is the baseline). The header carries the fleet crumb
// and, inside a repo, that repo's nav and search.
// pathesc escapes one path segment — wiki titles carry slashes in real
// stores (tcl's "(Initi/Fin)alize"), which must ride as %2F to stay one
// segment for the mux.
var base = template.Must(template.New("base").Funcs(template.FuncMap{
	"pathesc": url.PathEscape,
}).Parse(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — quarry</title>
<style>
:root { --fg: #1a1a1a; --bg: #ffffff; --muted: #6a6a6a; --code-bg: #f4f5f7; }
@media (prefers-color-scheme: dark) {
  :root { --fg: #e8e8e8; --bg: #14161a; --muted: #9a9a9a; --code-bg: #1c2026; }
}
body { color: var(--fg); background: var(--bg); font: 16px/1.5 system-ui, sans-serif;
       max-width: 60rem; margin: 0 auto; padding: 1rem; }
footer { color: var(--muted); border-top: 1px solid var(--muted); margin-top: 2rem;
         padding-top: 0.5rem; font-size: 0.85rem; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.25rem 0.75rem 0.25rem 0; }
th { border-bottom: 1px solid var(--muted); }
a { color: inherit; }
header { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap;
         border-bottom: 1px solid var(--muted); padding-bottom: 0.5rem; }
header .site { font-weight: bold; }
header form { margin-left: auto; }
.tw { overflow-x: auto; }
/* Timeline card/detail metrics adapted from fossil src/default.css
   (BSD-2-Clause; see third_party/PROVENANCE.md). The graph tiles
   seamlessly: a fixed 36px head band (node + curves) and a stretching
   tail fill each row edge-to-edge at identical pixel x positions, and
   date rows carry the active rails through. */
table.tl td { border: 0; padding: 0; vertical-align: top; }
table.tl td.tl-graph { position: relative; }
svg.tl-head { position: absolute; top: 0; left: 0; display: block; }
svg.tl-tail { position: absolute; top: 36px; left: 0; height: calc(100% - 36px); display: block; }
tr.tl-date svg.tl-tail, tr.tl-flat svg.tl-tail { top: 0; height: 100%; }
.tl-card-flat { padding: 0.45em 0.75em; font-size: 0.95em; }
.tl-meta-inline { color: var(--muted); font-size: 85%; }
table.tl td.tl-time { white-space: nowrap; width: 3.4rem; padding: 13px 0.5rem 0 0; text-align: right; }
table.tl td.tl-time a { color: var(--muted); text-decoration: none; }
table.tl td.tl-time a:hover { text-decoration: underline; }
table.tl td.tl-slot { width: 100%; padding: 0 0 5px 0.6rem; }
table.tl td.tl-datecell { padding: 6px 0 6px 0.6rem; }
.tl-card { background: var(--code-bg); border-radius: 1em; padding: 0.75em; }
.tl-title { font-weight: 550; }
.tl-p { margin: 0.35em 0 0; color: color-mix(in srgb, var(--fg) 78%, var(--muted)); }
.tl-meta { color: var(--muted); font-size: 80%; text-align: right; opacity: 0.85; margin-top: 0.5em; }
.tl-meta a { text-decoration: none; }
.tl-meta a:hover { text-decoration: underline; }
@media (max-width: 640px) {
  body { padding: 0.5rem; }
  table.tl td.tl-time { width: 2.6rem; font-size: 0.8rem; padding-top: 15px; }
  .tl-card { padding: 0.75em 0.55em; border-radius: 0.6em; }
  .tl-meta { font-size: 72%; }
}
.tl-badge { border: 1px solid var(--muted); border-radius: 3px; padding: 0 0.25rem; font-size: 0.72rem; }
.tl-pill { display: inline-block; border: 1px solid var(--muted); border-radius: 6px;
  padding: 0.05rem 0.5rem; font-weight: bold; margin: 0.55rem 0 0.25rem; }
.tl-controls { color: var(--muted); font-size: 0.9rem; display: flex; gap: 0.8rem;
  align-items: baseline; margin-bottom: 0.4rem; }
pre { overflow-x: auto; background: var(--code-bg); padding: 0.5rem; border-radius: 4px; }
pre.diff .add { color: #116329; } pre.diff .del { color: #a40e26; }
pre.diff .hunk { color: var(--muted); } pre.diff .head { font-weight: bold; }
mark { background: #fff3b8; color: inherit; }
@media (prefers-color-scheme: dark) {
  pre.diff .add { color: #56d364; } pre.diff .del { color: #ff7b72; }
  mark { background: #5c4d00; }
}
</style>
<style>` + highlight.CSS() + `</style>
<script type="module" src="/assets/datastar.js" defer></script>
</head>
<body>
<header>
<a class="site" href="/">quarry</a>
{{if .Repo}}<a href="/r/{{.Repo}}/timeline">{{.Repo}}</a>
<nav><a href="/r/{{.Repo}}/timeline">timeline</a> <a href="/r/{{.Repo}}/wiki">wiki</a> <a href="/r/{{.Repo}}/ticket">tickets</a></nav>
<form action="/r/{{.Repo}}/search" method="get"><input type="search" name="q" placeholder="search" aria-label="search"></form>
{{end}}
</header>
<main>
<h1>{{.Title}}</h1>
{{block "content" .}}{{end}}
</main>
<footer>quarry {{.Version}}</footer>
</body>
</html>
`))

var fleetPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Fleet}}<div class="tw"><table>
<thead><tr><th scope="col">Repository</th><th scope="col">Check-ins</th><th scope="col">Tip</th></tr></thead>
<tbody>{{range .Fleet}}
<tr><td><a href="/r/{{.Name}}/timeline">{{.Name}}</a></td><td>{{.Checkins}}</td><td>{{.TipDate}} — {{.TipComment}}</td></tr>
{{end}}</tbody>
</table></div>{{else}}<p>No repositories are being served. Start with <code>quarry serve --repo name=path</code>.</p>{{end}}
{{end}}`))

var wikiListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .WikiPages}}<ul>{{range .WikiPages}}
<li><a href="/r/{{$.Repo}}/wiki/{{pathesc .Title}}">{{.Title}}</a> <small>{{.Date}} by {{.User}}</small></li>
{{end}}</ul>{{else}}<p>No wiki pages.</p>{{end}}
{{end}}`))

var checkinPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p>{{.Checkin.Comment}}</p>
<p><small>{{.Checkin.Date}} by {{.Checkin.User}} on <a href="/r/{{.Repo}}/timeline?branch={{.Checkin.Branch}}">{{.Checkin.Branch}}</a> — <code>{{.Checkin.Hash}}</code></small></p>
{{if .Checkin.Parents}}<p>parent{{if gt (len .Checkin.Parents) 1}}s{{end}}:
{{range .Checkin.Parents}} <a href="/r/{{$.Repo}}/checkin/{{.}}"><code>{{printf "%.12s" .}}</code></a>{{end}}</p>{{end}}
<p><a href="/r/{{.Repo}}/checkin/{{.Checkin.Hash}}/diff">view diff</a></p>
{{if .Checkin.Files}}<div class="tw"><table>
<thead><tr><th scope="col">File</th><th scope="col">Blob</th></tr></thead>
<tbody>{{range .Checkin.Files}}
<tr><td><a href="/r/{{$.Repo}}/file/{{$.Checkin.Hash}}/{{.Name}}">{{.Name}}</a></td><td><code>{{printf "%.12s" .Blob}}</code></td></tr>
{{end}}</tbody>
</table></div>{{end}}
{{end}}`))

var ticketListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Tickets}}<div class="tw"><table>
<thead><tr><th scope="col">Ticket</th><th scope="col">Status</th><th scope="col">Title</th><th scope="col">Updated</th></tr></thead>
<tbody>{{range .Tickets}}
<tr><td><a href="/r/{{$.Repo}}/ticket/{{.ID}}"><code>{{printf "%.10s" .ID}}</code></a></td><td>{{.Status}}</td><td>{{.Title}}</td><td>{{.Updated}}</td></tr>
{{end}}</tbody>
</table></div>{{else}}<p>No tickets.</p>{{end}}
{{end}}`))

var ticketPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<table>
<tbody>{{range .Ticket.Fields}}
<tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr>
{{end}}</tbody>
</table>
<h2>History</h2>
<ul>{{range .Ticket.Changes}}
<li>{{.Date}} by {{.User}}: {{range .Fields}}{{.Name}}={{.Value}} {{end}}</li>
{{end}}</ul>
{{end}}`))

var filePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><small>at <a href="/r/{{.Repo}}/checkin/{{.File.Hash}}"><code>{{printf "%.12s" .File.Hash}}</code></a> — {{.File.Size}} bytes</small></p>
{{if .File.Binary}}<p>Binary file.</p>{{else if .FileHTML}}{{.FileHTML}}{{else}}<pre>{{.File.Content}}</pre>{{end}}
{{end}}`))

var notFoundPage = template.Must(template.Must(base.Clone()).Parse(
	`{{define "content"}}<p>Nothing lives at this address. Start from the <a href="/">repository list</a>.</p>{{end}}`))

var errorPage = template.Must(template.Must(base.Clone()).Parse(
	`{{define "content"}}<p>Something went wrong serving this page. The details are in the server log.</p>{{end}}`))

var searchPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form action="/r/{{.Repo}}/search" method="get">
<input type="search" name="q" value="{{.Query}}" placeholder="search check-ins, wiki, tickets" aria-label="search">
<button type="submit">Search</button>
</form>
{{if .Results}}<ul>{{range .Results}}
<li><code>{{.Kind}}</code> <a href="{{.URL}}">{{.Label}}</a> — <small>{{.Snippet}}</small></li>
{{end}}</ul>{{else if .Query}}<p>No results.</p>{{end}}
{{end}}`))

var diffPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><small>{{if .Diff.From}}<a href="/r/{{.Repo}}/checkin/{{.Diff.From}}"><code>{{printf "%.12s" .Diff.From}}</code></a> →{{else}}root check-in →{{end}}
<a href="/r/{{.Repo}}/checkin/{{.Diff.To}}"><code>{{printf "%.12s" .Diff.To}}</code></a></small></p>
{{if .Diff.Note}}<p><em>{{.Diff.Note}}</em></p>{{end}}
{{if .Diff.Files}}<ul>{{range .Diff.Files}}<li><code>{{.Status}}</code> {{.Name}}</li>{{end}}</ul>{{end}}
{{if .DiffLines}}<pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span>
{{end}}</pre>{{end}}
{{end}}`))

var wikiPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .WikiRev}}<p class="status"><em>Historical revision <code>{{printf "%.12s" .Wiki.Hash}}</code> ({{.Wiki.Date}}) —
<a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}">view latest</a></em></p>{{end}}
<article>{{.WikiHTML}}</article>
<footer><small>edited {{.Wiki.Date}} by {{.Wiki.User}} ·
<a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}/history">history</a></small></footer>
{{end}}`))

var wikiHistoryPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}">← {{.Wiki.Title}}</a></p>
<ul>{{$top := true}}{{range .WikiRevs}}
<li>{{.Date}} <a href="/r/{{$.Repo}}/wiki/{{pathesc $.Wiki.Title}}{{if not $top}}?rev={{printf "%.12s" .Hash}}{{end}}"><code>{{printf "%.12s" .Hash}}</code></a>
by {{.User}}{{if $top}} <small>(current)</small>{{end}}{{$top = false}}</li>{{end}}
</ul>
{{end}}`))

var timelinePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form class="tl-controls" action="/r/{{.Repo}}/timeline" method="get">
{{if .Branch}}<input type="hidden" name="branch" value="{{.Branch}}">
<span>branch <a href="/r/{{.Repo}}/timeline?n={{.Limit}}"><b>{{.Branch}}</b> ✕</a></span>{{end}}
<label>Show <select name="y">
<option value="all"{{if or (eq .Y "") (eq .Y "all")}} selected{{end}}>everything</option>
<option value="ci"{{if eq .Y "ci"}} selected{{end}}>check-ins</option>
<option value="w"{{if eq .Y "w"}} selected{{end}}>wiki</option>
<option value="t"{{if eq .Y "t"}} selected{{end}}>tickets</option>
</select></label>
<label>Max <select name="n">{{range .LimitChoices}}<option{{if eq . $.Limit}} selected{{end}}>{{.}}</option>{{end}}</select></label>
<button type="submit">Apply</button>
</form>
<div id="tl"{{if .Live}} {{.LiveAttr}}{{end}}>{{template "tl-body" .}}</div>
{{end}}
{{define "tl-body"}}
{{if .Days}}<div class="tw"><table class="tl">
<tbody>{{range .Days}}
<tr class="tl-date"><td></td><td class="tl-graph" style="min-width:{{.Width}}px">{{.Carry}}</td><td class="tl-datecell"><span class="tl-pill">{{.Date}}</span></td></tr>
{{range .Rows}}<tr{{if ne .Kind "ci"}} class="tl-flat"{{end}}>
<td class="tl-time"><a href="{{.URL}}">{{.Time}}</a></td>
<td class="tl-graph" style="min-width:{{.Width}}px">{{.Head}}{{.Tail}}</td>
<td class="tl-slot"><div class="tl-card{{if ne .Kind "ci"}} tl-card-flat{{end}}"{{with .Tint}} style="{{.}}"{{end}}>
{{if eq .Kind "ci"}}<span class="tl-title">{{.Title}}</span>
{{range .Paras}}<p class="tl-p">{{.}}</p>{{end}}
<div class="tl-meta">{{if .IsMerge}}<span class="tl-badge">Merge</span> {{end}}{{if .IsLeaf}}<span class="tl-badge">Leaf</span> {{end}}check-in: <a href="{{.URL}}"><b>{{.Short}}</b></a>
user: <b>{{.User}}</b>
branch: <a href="/r/{{$.Repo}}/timeline?branch={{.Branch}}"><b>{{.Branch}}</b></a></div>
{{else}}<span class="tl-badge">{{if eq .Kind "w"}}wiki{{else}}ticket{{end}}</span> <a href="{{.URL}}">{{.Title}}</a>
<span class="tl-meta-inline">by <b>{{.User}}</b></span>{{end}}
</div></td>
</tr>
{{end}}{{end}}</tbody>
</table></div>
{{if .Next}}<p><a href="/r/{{.Repo}}/timeline?before={{.Next}}{{if .Branch}}&branch={{.Branch}}{{end}}{{with .Y}}&y={{.}}{{end}}&n={{.Limit}}">older →</a></p>{{end}}
{{else}}<p>No check-ins.</p>{{end}}
{{end}}`))

type pageData struct {
	Title        string
	Version      string
	Repo         string // repo context; empty on fleet-level pages
	Branch       string // active timeline branch filter
	Limit        int
	LimitChoices []int
	Y            string
	Live         bool
	LiveAttr     template.HTMLAttr
	Fleet        []repoInfo
	Entries      []timelineEntry
	Days         []tlDay
	Next         string
	WikiPages    []wikiTitle
	Wiki         wikiDetail
	WikiRev      string    // non-empty when viewing a historical revision
	WikiRevs     []wikiRev // the history page's edit list
	Checkin      checkinDetail
	Tickets      []ticketSummary
	Ticket       ticketDetail
	File         fileDetail
	Diff         diffDetail
	DiffLines    []diffLine
	Query        string
	Results      []searchHit
	// WikiHTML and FileHTML are op-rendered, sanitizer-gated markup —
	// the trusted HTML injection points (0002 §2: the sanitizer is the
	// gate).
	WikiHTML template.HTML
	FileHTML template.HTML
}

type repoInfo struct {
	Name       string `json:"name"`
	Checkins   int    `json:"checkins"`
	TipDate    string `json:"tipDate"`
	TipComment string `json:"tipComment"`
}

type timelineEntry struct {
	Kind    string   `json:"kind"`
	Ref     string   `json:"ref"`
	Hash    string   `json:"hash"`
	Date    string   `json:"date"`
	User    string   `json:"user"`
	Comment string   `json:"comment"`
	Branch  string   `json:"branch"`
	IsLeaf  bool     `json:"isLeaf"`
	IsMerge bool     `json:"isMerge"`
	Parents []string `json:"parents"`
}

type wikiTitle struct {
	Title string `json:"title"`
	Date  string `json:"date"`
	User  string `json:"user"`
}

type wikiDetail struct {
	Hash  string `json:"hash"`
	Title string `json:"title"`
	Date  string `json:"date"`
	User  string `json:"user"`
	HTML  string `json:"html"`
}

type wikiRev struct {
	Hash string `json:"hash"`
	Date string `json:"date"`
	User string `json:"user"`
}

type ticketSummary struct {
	ID      string `json:"id"`
	Title   string `json:"title"`
	Status  string `json:"status"`
	Updated string `json:"updated"`
}

type ticketField struct {
	Name  string `json:"name"`
	Value string `json:"value"`
}

type ticketDetail struct {
	ID      string        `json:"id"`
	Fields  []ticketField `json:"fields"`
	Changes []struct {
		Date   string        `json:"date"`
		User   string        `json:"user"`
		Fields []ticketField `json:"fields"`
	} `json:"changes"`
}

type checkinDetail struct {
	Hash    string   `json:"hash"`
	Branch  string   `json:"branch"`
	Date    string   `json:"date"`
	User    string   `json:"user"`
	Comment string   `json:"comment"`
	Parents []string `json:"parents"`
	Files   []struct {
		Blob string `json:"blob"`
		Name string `json:"name"`
	} `json:"files"`
}

type fileDetail struct {
	Hash    string `json:"hash"`
	Path    string `json:"path"`
	Size    int    `json:"size"`
	Binary  bool   `json:"binary"`
	Content string `json:"content"`
	HTML    string `json:"html"`
}

type diffDetail struct {
	From  string `json:"from"`
	To    string `json:"to"`
	Note  string `json:"note"`
	Files []struct {
		Status string `json:"status"`
		Name   string `json:"name"`
	} `json:"files"`
	Unified string `json:"unified"`
}

type diffLine struct {
	Class string
	Text  string
}

// classifyDiff maps unified-diff lines to display classes; the template
// escapes Text, so content stays inert.
func classifyDiff(unified string) []diffLine {
	if unified == "" {
		return nil
	}
	var out []diffLine
	for _, line := range strings.Split(strings.TrimSuffix(unified, "\n"), "\n") {
		class := "ctx"
		switch {
		case strings.HasPrefix(line, "+++"), strings.HasPrefix(line, "---"):
			class = "head"
		case strings.HasPrefix(line, "@@"):
			class = "hunk"
		case strings.HasPrefix(line, "+"):
			class = "add"
		case strings.HasPrefix(line, "-"):
			class = "del"
		}
		out = append(out, diffLine{Class: class, Text: line})
	}
	return out
}

type searchHit struct {
	Kind    string
	Label   string
	URL     string
	Snippet template.HTML
}

// hitOf turns one search result into its page link. The label is the
// human-readable name the op provides (ticket title, wiki name,
// check-in comment) with the ref as fallback; the snippet's ⟦…⟧ match
// markers become <mark> AFTER escaping, so content stays inert.
func hitOf(repo, kind, ref, label, snippet string) searchHit {
	h := searchHit{Kind: kind, Label: label}
	prefix := "/r/" + repo
	switch kind {
	case "checkin":
		h.URL = prefix + "/checkin/" + ref
		if h.Label == "" {
			h.Label = ref[:min(12, len(ref))]
		}
	case "wiki":
		h.URL = prefix + "/wiki/" + url.PathEscape(ref)
		if h.Label == "" {
			h.Label = ref
		}
	case "ticket":
		h.URL = prefix + "/ticket/" + ref
		if h.Label == "" {
			h.Label = ref[:min(10, len(ref))]
		}
	}
	esc := template.HTMLEscapeString(snippet)
	esc = strings.ReplaceAll(esc, "⟦", "<mark>")
	esc = strings.ReplaceAll(esc, "⟧", "</mark>")
	h.Snippet = template.HTML(esc)
	return h
}

// Handler mounts every HTML page over the registry. n may be nil (no
// live updates; pages are unchanged otherwise).
func Handler(r *ops.Registry, n Notifier) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /assets/datastar.js", func(w http.ResponseWriter, _ *http.Request) {
		w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
		w.Header().Set("Cache-Control", "public, max-age=86400")
		w.Write(datastarJS)
	})
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, req *http.Request) {
		out, err := invoke(req.Context(), r, "repo.list", nil)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fl struct {
			Repos []repoInfo `json:"repos"`
		}
		remarshal(out, &fl)
		render(r, w, req, fleetPage, pageData{Title: "quarry", Fleet: fl.Repos})
	})
	// Everything unmatched gets the themed 404, never Go's bare
	// plain-text default.
	mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
		fail(r, w, req, ops.ErrNotFound)
	})
	mux.HandleFunc("GET /r/{repo}/timeline", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		vals := req.URL.Query()
		vals.Set("repo", repo)
		// The page's Max control uses fossil's n= name; the op's field
		// is limit.
		limit := 50
		if n := vals.Get("n"); n != "" {
			vals.Set("limit", n)
			if v, err := strconv.Atoi(n); err == nil && v > 0 && v <= 200 {
				limit = v
			}
		}
		out, err := invoke(req.Context(), r, "timeline.list", vals)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tl struct {
			Entries []timelineEntry `json:"entries"`
			Next    string          `json:"next"`
		}
		remarshal(out, &tl)
		data := pageData{
			Title: "timeline", Repo: repo, Days: buildTimeline(repo, tl.Entries), Next: tl.Next,
			Branch: req.URL.Query().Get("branch"), Y: req.URL.Query().Get("y"),
			Limit:  limit, LimitChoices: []int{20, 50, 100, 200},
		}
		// Live updates enhance the first page only; history pages are
		// static by nature. Without the notifier (or JS) the page is
		// simply itself — Principle 3's refresh-fallback.
		if n != nil && req.URL.Query().Get("before") == "" {
			data.Live = true
			// Built as a trusted attribute: html/template would JS-escape
			// data-on-* values (slashes become \/).
			data.LiveAttr = template.HTMLAttr(
				`data-init="@get('/r/` + repo + `/timeline.sse?` +
					template.HTMLEscapeString(req.URL.RawQuery) + `')"`)
		}
		render(r, w, req, timelinePage, data)
	})
	mux.HandleFunc("GET /r/{repo}/timeline.sse", func(w http.ResponseWriter, req *http.Request) {
		if n == nil {
			http.NotFound(w, req)
			return
		}
		liveTimeline(r, n, w, req)
	})
	mux.HandleFunc("GET /r/{repo}/wiki", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "wiki.list", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var wl struct {
			Pages []wikiTitle `json:"pages"`
		}
		remarshal(out, &wl)
		render(r, w, req, wikiListPage, pageData{Title: "wiki", Repo: repo, WikiPages: wl.Pages})
	})
	mux.HandleFunc("GET /r/{repo}/ticket", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "ticket.list", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tl struct {
			Tickets []ticketSummary `json:"tickets"`
		}
		remarshal(out, &tl)
		render(r, w, req, ticketListPage, pageData{Title: "tickets", Repo: repo, Tickets: tl.Tickets})
	})
	mux.HandleFunc("GET /r/{repo}/ticket/{id}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "ticket.get",
			url.Values{"repo": {repo}, "id": {req.PathValue("id")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var td struct {
			Ticket ticketDetail `json:"ticket"`
		}
		remarshal(out, &td)
		render(r, w, req, ticketPage, pageData{
			Title: "ticket " + td.Ticket.ID[:10], Repo: repo, Ticket: td.Ticket,
		})
	})
	mux.HandleFunc("GET /r/{repo}/search", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		q := req.URL.Query().Get("q")
		data := pageData{Title: "search", Repo: repo, Query: q}
		if q != "" {
			vals := req.URL.Query()
			vals.Set("repo", repo)
			out, err := invoke(req.Context(), r, "search.query", vals)
			if err != nil {
				fail(r, w, req, err)
				return
			}
			var sr struct {
				Results []struct {
					Kind    string `json:"kind"`
					Ref     string `json:"ref"`
					Label   string `json:"label"`
					Snippet string `json:"snippet"`
				} `json:"results"`
			}
			remarshal(out, &sr)
			for _, res := range sr.Results {
				data.Results = append(data.Results, hitOf(repo, res.Kind, res.Ref, res.Label, res.Snippet))
			}
		}
		render(r, w, req, searchPage, data)
	})
	mux.HandleFunc("GET /r/{repo}/checkin/{hash}/diff", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "checkin.diff",
			url.Values{"repo": {repo}, "hash": {req.PathValue("hash")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var dd diffDetail
		remarshal(out, &dd)
		render(r, w, req, diffPage, pageData{
			Title: "diff " + dd.To[:12], Repo: repo, Diff: dd, DiffLines: classifyDiff(dd.Unified),
		})
	})
	mux.HandleFunc("GET /r/{repo}/file/{hash}/{path...}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "file.get", url.Values{
			"repo": {repo},
			"hash": {req.PathValue("hash")},
			"path": {req.PathValue("path")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fd fileDetail
		remarshal(out, &fd)
		render(r, w, req, filePage, pageData{
			Title: fd.Path, Repo: repo, File: fd, FileHTML: template.HTML(fd.HTML),
		})
	})
	mux.HandleFunc("GET /r/{repo}/checkin/{hash}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "checkin.get",
			url.Values{"repo": {repo}, "hash": {req.PathValue("hash")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var cd checkinDetail
		remarshal(out, &cd)
		render(r, w, req, checkinPage, pageData{
			Title: "check-in " + cd.Hash[:12], Repo: repo, Checkin: cd,
		})
	})
	mux.HandleFunc("GET /r/{repo}/wiki/{name}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		rev := req.URL.Query().Get("rev")
		out, err := invoke(req.Context(), r, "wiki.get",
			url.Values{"repo": {repo}, "name": {req.PathValue("name")}, "rev": {rev}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var wd wikiDetail
		remarshal(out, &wd)
		render(r, w, req, wikiPage, pageData{
			Title: wd.Title, Repo: repo, Wiki: wd, WikiRev: rev,
			WikiHTML: template.HTML(wd.HTML),
		})
	})
	mux.HandleFunc("GET /r/{repo}/wiki/{name}/history", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		name := req.PathValue("name")
		out, err := invoke(req.Context(), r, "wiki.history",
			url.Values{"repo": {repo}, "name": {name}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var wh struct {
			Title string    `json:"title"`
			Edits []wikiRev `json:"edits"`
		}
		remarshal(out, &wh)
		render(r, w, req, wikiHistoryPage, pageData{
			Title: wh.Title + " history", Repo: repo,
			Wiki: wikiDetail{Title: wh.Title}, WikiRevs: wh.Edits,
		})
	})
	return mux
}

// liveTimeline streams Datastar element patches: on every change tick
// (debounced), the timeline body re-renders through the same op path
// the page uses and morphs into #tl.
func liveTimeline(r *ops.Registry, n Notifier, w http.ResponseWriter, req *http.Request) {
	repo := req.PathValue("repo")
	fl, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "streaming unsupported", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-store")
	fmt.Fprint(w, ": live\n\n")
	fl.Flush()

	ticks, cancel := n.Watch(repo)
	defer cancel()
	heartbeat := time.NewTicker(20 * time.Second)
	defer heartbeat.Stop()
	for {
		select {
		case <-req.Context().Done():
			return
		case <-heartbeat.C:
			fmt.Fprint(w, ": ping\n\n")
			fl.Flush()
		case _, open := <-ticks:
			if !open {
				return
			}
			// Debounce: a burst of artifacts is one repaint.
			time.Sleep(200 * time.Millisecond)
			select {
			case <-ticks:
			default:
			}
			frag, err := timelineFragment(r, repo, req.URL.Query())
			if err != nil {
				log.Printf("web: live timeline %s: %v", repo, err)
				continue
			}
			// Datastar v1 element patch: morphs #tl in place.
			fmt.Fprint(w, "event: datastar-patch-elements\n")
			for _, line := range strings.Split(frag, "\n") {
				fmt.Fprintf(w, "data: elements %s\n", line)
			}
			fmt.Fprint(w, "\n")
			fl.Flush()
		}
	}
}

// timelineFragment renders the #tl body for the given query — shared by
// the page render and the live patches, so both always agree.
func timelineFragment(r *ops.Registry, repo string, q url.Values) (string, error) {
	vals := url.Values{}
	for k, v := range q {
		vals[k] = v
	}
	vals.Set("repo", repo)
	if nn := vals.Get("n"); nn != "" {
		vals.Set("limit", nn)
	}
	out, err := invoke(context.Background(), r, "timeline.list", vals)
	if err != nil {
		return "", err
	}
	var tl struct {
		Entries []timelineEntry `json:"entries"`
		Next    string          `json:"next"`
	}
	remarshal(out, &tl)
	limit := 50
	if v, err := strconv.Atoi(vals.Get("limit")); err == nil && v > 0 {
		limit = v
	}
	data := pageData{
		Repo: repo, Days: buildTimeline(repo, tl.Entries), Next: tl.Next,
		Branch: q.Get("branch"), Y: q.Get("y"), Limit: limit,
	}
	var buf bytes.Buffer
	buf.WriteString(`<div id="tl">`)
	if err := timelinePage.ExecuteTemplate(&buf, "tl-body", data); err != nil {
		return "", err
	}
	buf.WriteString(`</div>`)
	return buf.String(), nil
}

// render executes a page template with the version footer served by the
// meta.version op — every page reports it (0001 Principle 1).
func render(r *ops.Registry, w http.ResponseWriter, req *http.Request,
	page *template.Template, data pageData) {
	renderStatus(r, w, req, page, data, http.StatusOK)
}

func renderStatus(r *ops.Registry, w http.ResponseWriter, req *http.Request,
	page *template.Template, data pageData, status int) {
	out, err := invoke(req.Context(), r, "meta.version", nil)
	if err != nil {
		http.Error(w, "version op failed", http.StatusInternalServerError)
		return
	}
	var v struct {
		Version string `json:"version"`
	}
	remarshal(out, &v)
	data.Version = v.Version
	// Render to a buffer first: a template error mid-stream would
	// otherwise emit a silently truncated page (a bug class this exact
	// code once shipped) — buffered, it becomes a loud, logged 500.
	var buf bytes.Buffer
	if err := page.Execute(&buf, data); err != nil {
		log.Printf("web: template %s: %v", req.URL.Path, err)
		http.Error(w, "template error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	w.Write(buf.Bytes())
}

// fail renders the themed error page for an op failure: not-found gets
// the 404 page, everything else logs and gets the 500 page — never
// Go's bare plain-text defaults (every page keeps theme, nav, version).
func fail(r *ops.Registry, w http.ResponseWriter, req *http.Request, err error) {
	if errors.Is(err, ops.ErrNotFound) {
		renderStatus(r, w, req, notFoundPage, pageData{Title: "not found"}, http.StatusNotFound)
		return
	}
	log.Printf("web: %s: %v", req.URL.Path, err)
	renderStatus(r, w, req, errorPage, pageData{Title: "error"}, http.StatusInternalServerError)
}

// invoke runs an op through its descriptor with form-shaped input — the
// same decode path the API router uses, so HTML pages and API calls hit
// identical op semantics. Results come back as opaque values; pages pick
// the fields they render via remarshal.
func invoke(ctx context.Context, r *ops.Registry, name string, form url.Values) (any, error) {
	d, ok := r.Lookup(name)
	if !ok {
		return nil, fmt.Errorf("web: unknown op %q", name)
	}
	inPtr := d.NewIn()
	if err := ops.DecodeValues(form, inPtr); err != nil {
		return nil, err
	}
	in := reflect.ValueOf(inPtr).Elem().Interface()
	if err := ops.Validate(in); err != nil {
		return nil, err
	}
	return r.Invoke(ctx, name, in)
}

// remarshal projects an op result onto the fields a page renders — the
// JSON round-trip keeps web coupled to op output *shapes* (the API
// contract) rather than to concrete Go types in other packages.
func remarshal(from any, to any) {
	b, err := json.Marshal(from)
	if err == nil {
		json.Unmarshal(b, to)
	}
}