Skip to content
quarry Artifact [9ec1b948f3]
Login

Artifact [9ec1b948f3]

Artifact 9ec1b948f3143e30e26699d4598edbffd40e96d603e1db1c56b0d6a5e2ff7f7b:


// 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/base64"
	"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. repo "*"
// watches every repo (the fleet page's signal).
type Notifier interface {
	Watch(repo string) (<-chan struct{}, func())
}

// ChatMessage is one rendered chat line (mirrors chat.Message without
// importing the data plane into a view type).
type ChatMessage struct {
	User string `json:"user"`
	Text string `json:"text"`
	At   string `json:"at"`
}

// ChatLive delivers a repo channel's live messages to the SSE handler.
// nil disables the live chat stream (history + form still work). The
// composition root's adapter maps the repo name to its project code.
type ChatLive interface {
	SubscribeChat(repo string) (<-chan ChatMessage, 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.
// artifactTimeLayouts are the shapes a D card's timestamp arrives in.
// Fossil writes UTC with millisecond precision and no zone; imported
// corpora also carry the second-precision form, and quarry's own OLTP
// stamps are RFC3339. All are UTC, so none carries an offset to honour.
var artifactTimeLayouts = []string{
	"2006-01-02T15:04:05.000",
	"2006-01-02T15:04:05",
	time.RFC3339,
	"2006-01-02 15:04:05",
	"2006-01-02",
}

func parseArtifactTime(s string) (time.Time, bool) {
	for _, l := range artifactTimeLayouts {
		if t, err := time.Parse(l, s); err == nil {
			return t.UTC(), true
		}
	}
	return time.Time{}, false
}

// ago renders a timestamp the way a person reads one. Recent times go
// relative ("3 hours ago"), older ones go to a plain date; the exact
// value is kept in the title attribute either way, because a relative
// time is friendlier but an absolute one is what you quote in a bug
// report.
//
// An unparseable stamp is returned VERBATIM rather than blanked or
// guessed at: it came out of a real artifact, and showing it is how the
// layout gap gets noticed.
func ago(s string) template.HTML {
	if s == "" {
		return ""
	}
	t, ok := parseArtifactTime(s)
	if !ok {
		return template.HTML(template.HTMLEscapeString(s))
	}
	return template.HTML(fmt.Sprintf(`<time datetime="%s" title="%s">%s</time>`,
		template.HTMLEscapeString(t.Format(time.RFC3339)),
		template.HTMLEscapeString(t.Format("2006-01-02 15:04:05 UTC")),
		template.HTMLEscapeString(relative(t, time.Now().UTC()))))
}

// relative words a duration at one significant unit. Fossil's forum uses
// the same shape ("2.0 hours ago", "13.4 days ago"); this rounds to
// whole units instead, because a tenth of a day is not a quantity anyone
// reasons in.
func relative(t, now time.Time) string {
	d := now.Sub(t)
	if d < 0 {
		// Clock skew between a writer and this server. "in 3 minutes" is
		// noise; the timestamp itself is the honest answer.
		return t.Format("2006-01-02 15:04")
	}
	switch {
	case d < time.Minute:
		return "just now"
	case d < time.Hour:
		return plural(int(d.Minutes()), "minute") + " ago"
	case d < 24*time.Hour:
		return plural(int(d.Hours()), "hour") + " ago"
	case d < 30*24*time.Hour:
		return plural(int(d.Hours()/24), "day") + " ago"
	default:
		// Past a month, a date is more useful than a count of days, and
		// past a year the year is the part that matters.
		if t.Year() == now.Year() {
			return t.Format("2 Jan")
		}
		return t.Format("2 Jan 2006")
	}
}

func plural(n int, unit string) string {
	if n == 1 {
		return "1 " + unit
	}
	return fmt.Sprintf("%d %ss", n, unit)
}

// navlink renders one repository-nav entry. It is a function rather
// than inline template markup because an entry carries two independent
// classes — "here" and "empty" — and expressing that inline produced
// two class attributes on the same anchor, of which a browser honours
// only the first.
//
// count is `any` so a section with nothing to count (files, ci, chat)
// can pass false and get no badge, rather than a misleading zero.
func navlink(repo, section, label, current string, hasCounts bool, count any) template.HTML {
	var class []string
	if section == current {
		class = append(class, "here")
	}
	n, counted := count.(int)
	if hasCounts && counted && n == 0 {
		// Dimmed, never hidden: a nav whose SHAPE changes per repository
		// makes a reader relearn it on every store. An honest dead end
		// costs one click; a moving nav costs orientation.
		class = append(class, "empty")
	}
	var b strings.Builder
	fmt.Fprintf(&b, `<a href="/r/%s/%s"`, url.PathEscape(repo), section)
	if len(class) > 0 {
		fmt.Fprintf(&b, ` class="%s"`, strings.Join(class, " "))
	}
	if section == current {
		b.WriteString(` aria-current="page"`)
	}
	fmt.Fprintf(&b, `>%s`, template.HTMLEscapeString(label))
	if hasCounts && counted {
		fmt.Fprintf(&b, ` <span class="n">%d</span>`, n)
	}
	b.WriteString(`</a>`)
	// Every part is escaped or generated above; section and label are
	// compile-time constants from the template.
	return template.HTML(b.String())
}

var base = template.Must(template.New("base").Funcs(template.FuncMap{
	"pathesc": url.PathEscape,
	"navlink": navlink,
	"ago":     ago,
}).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; }
/* The repository is the subject of every page under it, so it reads as
   the heading and "quarry" recedes to the crumb it is. Without this the
   two sit at equal weight and the forge name — identical on every page —
   competes with the one word that changes. */
header .repo { font-weight: bold; font-size: 1.15rem; }
header .crumb { color: var(--muted); }
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%; }
  /* Phone widths: the card's widest line is the metadata run, and its
     max-content is what pushed the table past the viewport (auto table
     layout prefers it, and .tw then scrolls sideways, hiding the
     right-aligned meta). Stacking the items drops that preference to
     the longest single item, so the table fits with the cards still
     wide. */
  .tl-metaitem { display: block; }
  .tl-card { overflow-wrap: anywhere; }
}
/* File listing. The old one spent ~52px of height and three columns on
   one short filename; fossil fits roughly 60 entries in the space that
   showed 25. Density here comes from tight rows and dropping the
   repeated per-row "history" link, not from shrinking the type. */
table.tree { table-layout: auto; }
table.tree td { padding: 0.15rem 0; border: 0; line-height: 1.45; }
table.tree tr:hover { background: var(--code-bg); }
/* Thirty underlined names in a column is a wall of rules, and the
   underline carries no information here — every name in the listing is
   a link, so marking them all marks nothing. Hover restores it. */
table.tree a, .tree-bar .crumbs a { text-decoration: none; }
table.tree a:hover, .tree-bar .crumbs a:hover { text-decoration: underline; }
td.t-name { white-space: nowrap; }
/* Sizes are only comparable when their digits line up, which means
   right-aligned and tabular figures. The column takes only what it
   needs so the name column is not pushed across the viewport. */
td.t-size { text-align: right; white-space: nowrap; width: 1%;
  padding-left: 1.5rem; color: var(--muted); font-variant-numeric: tabular-nums; }
/* Directory and file marks. Drawn in CSS rather than shipped as image
   assets: two shapes are not worth a sprite, and these inherit the text
   colour so both themes work without a second copy. */
.ic { display: inline-block; width: 0.85em; height: 0.85em; margin-right: 0.45rem;
  vertical-align: -0.05em; background: currentColor; opacity: 0.55; }
.ic-dir { -webkit-mask: var(--m-dir) center/contain no-repeat; mask: var(--m-dir) center/contain no-repeat; }
.ic-file { -webkit-mask: var(--m-file) center/contain no-repeat; mask: var(--m-file) center/contain no-repeat; }
:root {
  --m-dir: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M1.75 2h3.6c.5 0 .96.26 1.23.68L7.4 4h6.85c.97 0 1.75.78 1.75 1.75v7.5c0 .97-.78 1.75-1.75 1.75H1.75C.78 15 0 14.22 0 13.25V3.75C0 2.78.78 2 1.75 2Z"/></svg>');
  --m-file: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M2 1.75C2 .78 2.78 0 3.75 0h5.19c.46 0 .9.18 1.23.51l3.32 3.32c.33.33.51.77.51 1.23v9.19c0 .97-.78 1.75-1.75 1.75h-8.5C2.78 16 2 15.22 2 14.25Zm7.5-.19v3.19c0 .14.11.25.25.25h3.19Z"/></svg>');
}
/* Flags replace ls(1)'s trailing punctuation. "compile*" reads as part
   of the filename; a labelled chip does not. */
.flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--muted);
  border-radius: 3px; padding: 0 0.25rem; vertical-align: 0.05em; }
/* Tree chrome: one row carrying branch, crumbs and clone. */
.tree-bar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap;
  margin-bottom: 0.6rem; }
.tree-bar .crumbs { font-weight: 600; }
.tree-bar .tree-acts { margin-left: auto; display: flex; gap: 0.75rem;
  align-items: center; font-size: 0.85rem; }
.tree-bar .clone { color: var(--muted); background: var(--code-bg);
  padding: 0.1rem 0.4rem; border-radius: 4px; }
@media (max-width: 40rem) {
  /* The clone command is the first thing worth losing on a phone: it is
     long, and it is not what a reader came to the page for. */
  .tree-bar .clone { display: none; }
  .tree-bar .tree-acts { margin-left: 0; }
}
.readme-head { margin-top: 1.5rem; padding-bottom: 0.3rem;
  border-bottom: 1px solid var(--muted); font-size: 1rem; }
/* Nav state. "here" is an underline rather than a colour so it survives
   both themes and does not depend on hue perception; "empty" is dimmed
   but still a link. The count badge is deliberately quiet — it is
   orientation, not emphasis. */
nav a.here { font-weight: 600; box-shadow: inset 0 -2px 0 currentColor; }
nav a.empty { color: var(--muted); }
nav .n { color: var(--muted); font-size: 0.8rem; font-variant-numeric: tabular-nums; }
nav a.here .n { color: inherit; }
.repo-sub { color: var(--muted); font-size: 0.9rem; margin: -0.6rem 0 1rem; }
.ci-pass { border-color: #116329; color: #116329; }
.ci-fail { border-color: #a40e26; color: #a40e26; }
@media (prefers-color-scheme: dark) {
  .ci-pass { border-color: #56d364; color: #56d364; }
  .ci-fail { border-color: #ff7b72; color: #ff7b72; }
}
.tkt-title { margin-bottom: 0.3rem; }
.tkt-head th { text-align: left; color: var(--muted); font-weight: normal; width: 10rem; }
.tkt-body { margin: 0.5rem 0 1rem; }
.tkt-comment { border-top: 1px solid var(--muted); padding: 0.6rem 0; }
.tkt-comment:first-of-type { border-top: 0; }
.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="{{if .Repo}}crumb{{else}}site{{end}}" href="/">quarry</a>
{{if .Repo}}<a class="repo" href="/r/{{.Repo}}">{{.Repo}}</a>
<nav>
{{- /* Counts tell a reader where a repository's content actually IS.
       Across the corpus that varies wildly — pikchr is 1,260 forum posts
       and one ticket, tk is 5,809 tickets and one post — and without
       them every store looks alike. An empty section stays LINKED but is
       dimmed: hiding it would make the nav's shape change per repo,
       which is worse than an honest dead end. */ -}}
{{$s := .Section}}{{$c := .HasCounts}}
{{navlink .Repo "tree" "files" $s false false}}
{{navlink .Repo "timeline" "timeline" $s $c .Counts.Checkins}}
{{navlink .Repo "wiki" "wiki" $s $c .Counts.WikiPages}}
{{navlink .Repo "ticket" "tickets" $s $c .Counts.Tickets}}
{{navlink .Repo "forum" "forum" $s $c .Counts.ForumThreads}}
{{navlink .Repo "technote" "technotes" $s $c .Counts.Technotes}}
{{navlink .Repo "ci" "ci" $s false false}}
{{navlink .Repo "chat" "chat" $s false false}}
</nav>
<form action="/r/{{.Repo}}/search" method="get"><input type="search" name="q" placeholder="search" aria-label="search"></form>
{{end}}
{{if .SignedIn}}<form class="who" action="/logout" method="post">{{if $.IsAdmin}}<small><a href="/fleet">fleet</a></small> {{end}}{{if $.HasInbox}}<small><a href="/inbox">inbox</a></small> {{end}}<small class="tl-meta-inline">{{.SignedIn}}</small>
<button type="submit">sign out</button></form>{{else if .CanSignIn}}<small class="who"><a href="/login">sign in</a></small>{{end}}
</header>
<main>
{{if not .HideH1}}<h1>{{.Title}}</h1>{{end}}
{{block "content" .}}{{end}}
</main>
<footer>quarry {{.Version}} · <a href="/docs">API</a></footer>
</body>
</html>
`))

var fleetPage = template.Must(template.Must(base.Clone()).Parse(`{{define "fleet-body"}}
{{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}}{{if .Indexing}} <span class="tl-badge">indexing…</span>{{end}}</td>
<td>{{if .Indexing}}<small class="tl-meta-inline">first index in progress; counts climb as it folds</small>{{else}}{{.TipDate}} — {{.TipComment}}{{end}}</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}}
{{define "content"}}<div{{if .Live}} {{.LiveAttr}}{{end}}><div id="fleet">{{template "fleet-body" .}}</div></div>{{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}}
<form method="get" action="/r/{{.Repo}}/wiki-new"><input name="name" placeholder="new page name" required> <button type="submit">Create</button></form>
{{end}}`))

var forumListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Forum}}<ul>{{range .Forum}}
<li><a href="/r/{{$.Repo}}/forum/{{printf "%.16s" .Root}}">{{if .Title}}{{.Title}}{{else}}(untitled thread){{end}}</a>
<small>{{.Posts}} post{{if ne .Posts 1}}s{{end}} · last {{ago .Updated}}{{if .User}} · started by {{.User}}{{end}}</small></li>
{{end}}</ul>{{else}}<p>No forum threads.</p>{{end}}
<p><a href="/r/{{.Repo}}/forum-new">new thread</a></p>
{{end}}`))

var forumThreadPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><a href="/r/{{.Repo}}/forum">← forum</a></p>
{{range .ForumPosts}}<article style="border-top:1px solid var(--muted); padding-top:0.5rem; margin-top:0.5rem">
<p><small>{{.Date}} by {{.User}}{{if .Parent}} · in reply to <code>{{printf "%.12s" .Parent}}</code>{{end}} · <code>{{printf "%.12s" .Hash}}</code></small></p>
{{.HTML}}
</article>{{end}}
<form method="post" action="/r/{{.Repo}}/forum/{{.ForumRoot}}/reply" style="margin-top:1rem">
<p><textarea name="body" rows="6" style="width:100%" placeholder="Reply (Markdown)" required></textarea></p>
<p><button type="submit">Post reply</button></p>
</form>
{{end}}`))

var forumNewPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form method="post" action="/r/{{.Repo}}/forum-new">
<p><label>Title<br><input name="title" required style="width:100%"></label></p>
<p><label>Post<br><textarea name="body" rows="14" style="width:100%" placeholder="Markdown" required></textarea></label></p>
<p><button type="submit">Open thread</button> <a href="/r/{{.Repo}}/forum">cancel</a></p>
</form>
{{end}}`))

var technoteEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form method="post" action="{{if .Technote.Event}}/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}/edit{{else}}/r/{{.Repo}}/technote-new{{end}}">
<p><label>Title<br><input name="comment" style="width:100%" maxlength="200" value="{{.Technote.Comment}}"></label>
<small class="tl-meta-inline">shown in the timeline; a note without one shows its event id</small></p>
<p><label>Note<br><textarea name="body" rows="16" style="width:100%" placeholder="Markdown" required>{{.EditSource}}</textarea></label></p>
<p><button type="submit">Save</button>
<a href="{{if .Technote.Event}}/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}{{else}}/r/{{.Repo}}/technote{{end}}">cancel</a></p>
</form>
{{end}}`))

var technoteListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Technotes}}<ul>{{range .Technotes}}
<li><a href="/r/{{$.Repo}}/technote/{{printf "%.16s" .Event}}">{{if .Comment}}{{.Comment}}{{else}}{{.Date}}{{end}}</a> <small>{{.Date}} by {{.User}}</small></li>
{{end}}</ul>{{else}}<p>No technotes.</p>{{end}}
<p><a href="/r/{{.Repo}}/technote-new">new technote</a></p>
{{end}}`))

var technotePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<article>{{.WikiHTML}}</article>
<footer><small>technote {{.Technote.Date}} by {{.Technote.User}} ·
<a href="/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}/edit">edit</a></small></footer>
{{end}}`))

var chatPage = template.Must(template.Must(base.Clone()).Parse(`{{define "chat-log"}}<div id="chatlog" class="chatlog">
{{range .ChatMsgs}}<p class="chatmsg"><b>{{.User}}</b> <small class="tl-meta-inline">{{.At}}</small><br>{{.Text}}</p>
{{end}}</div>{{end}}
{{define "content"}}
<style>.chatlog{max-height:60vh;overflow-y:auto;border:1px solid var(--muted);border-radius:6px;padding:0.5rem;margin-bottom:0.5rem}
.chatmsg{margin:0.3rem 0}.chatform{display:flex;gap:0.5rem}.chatform input[name=text]{flex:1}</style>
<div{{if .Live}} {{.LiveAttr}}{{end}}>{{template "chat-log" .}}</div>
<form class="chatform" method="post" action="/r/{{.Repo}}/chat">
<input name="text" placeholder="message" maxlength="4096" required autocomplete="off">
<button type="submit">Send</button>
</form>
<p><small class="tl-meta-inline">Messages persist for 7 days; durable forum-thread archival lands with zit forum verbs.</small></p>
{{end}}`))

var docsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<article>{{.WikiHTML}}</article>
{{end}}`))

var wikiEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form method="post" action="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}/edit">
<p><textarea name="body" rows="20" style="width:100%" placeholder="Markdown">{{.EditSource}}</textarea></p>
<p><button type="submit">Save</button> <a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}">cancel</a></p>
</form>
{{end}}`))

var ticketNewPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form method="post" action="/r/{{.Repo}}/ticket/new">
<p><label>Title<br><input name="title" required style="width:100%"></label></p>
<p><label>Status<br><input name="status" value="Open"></label></p>
<p><label>Comment<br><textarea name="comment" rows="8" style="width:100%"></textarea></label></p>
<p><button type="submit">Open ticket</button></p>
</form>
{{end}}`))

var ticketEditPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form method="post" action="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit">
<p><label>Title<br><input name="title" value="{{.Ticket.Field "title"}}" style="width:100%"></label></p>
<p><label>Status<br><input name="status" value="{{.Ticket.Field "status"}}"></label></p>
<p><label>Resolution<br><input name="resolution" value="{{.Ticket.Field "resolution"}}"></label></p>
<p><label>Comment<br><textarea name="comment" rows="6" style="width:100%" placeholder="add a comment"></textarea></label></p>
<p><button type="submit">Save change</button> <a href="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}">cancel</a></p>
</form>
{{end}}`))

var checkinPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p>{{.Checkin.Comment}}</p>
<p><small>{{if .CIBadge}}{{.CIBadge}} {{end}}{{.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>{{ago .Updated}}</td></tr>
{{end}}</tbody>
</table></div>{{else}}<p>No tickets.</p>{{end}}
{{if .Next}}<p><a href="/r/{{.Repo}}/ticket?before={{.Next}}">older →</a></p>{{end}}
<p><a href="/r/{{.Repo}}/ticket/new">new ticket</a></p>
{{end}}`))

var inboxPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Error}}<p><strong>{{.Error}}</strong></p>{{end}}
{{if .Inbox}}<ul>{{range .Inbox}}
<li>{{if not .Read}}<strong>{{.Subject}}</strong>{{else}}{{.Subject}}{{end}}
<small class="tl-meta-inline">{{.CreatedAt}}</small>
{{if .Content}}<br><small>{{.Content}}</small>{{end}}</li>
{{end}}</ul>{{else if not .Error}}<p>No notifications.</p>{{end}}
{{end}}`))

var loginPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Error}}<p><strong>{{.Error}}</strong></p>{{end}}
<form method="post" action="/login">
<input type="hidden" name="next" value="{{.LoginNext}}">
<p><label>Account<br><input name="user" autocomplete="username" required autofocus></label></p>
<p><label>Password<br><input name="password" type="password" autocomplete="current-password" required></label></p>
<p><button type="submit">Sign in</button></p>
</form>
{{end}}`))

var historyPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><a href="/r/{{.Repo}}/file/{{.TreeVersion}}/{{.HistoryPath}}"><code>{{.HistoryPath}}</code></a></p>
{{if .History}}<div class="tw"><table>
<thead><tr><th scope="col">Check-in</th><th scope="col">When</th><th scope="col">Who</th><th scope="col">Comment</th></tr></thead>
<tbody>{{range .History}}
<tr><td><a href="/r/{{$.Repo}}/checkin/{{printf "%.16s" .Hash}}"><code>{{printf "%.12s" .Hash}}</code></a></td>
<td class="tl-meta-inline">{{.Date}}</td><td>{{.User}}</td><td>{{.Comment}}</td></tr>
{{end}}</tbody>
</table></div>
{{else}}<p>No recorded changes to this file — it has existed in one state for its whole life here.</p>{{end}}
{{end}}`))

var ciPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<div{{if .Live}} {{.LiveAttr}}{{end}}><div id="ci">{{template "ci-body" .}}</div></div>
{{end}}
{{define "ci-body"}}
{{if .CIRuns}}<div class="tw"><table>
<thead><tr><th scope="col">Status</th><th scope="col">Check-in</th><th scope="col">Pipeline</th><th scope="col">Started</th></tr></thead>
<tbody>{{range .CIRuns}}
<tr><td><span class="tl-badge">{{.Status}}</span></td>
<td><a href="/r/{{$.Repo}}/checkin/{{printf "%.16s" .Checkin}}"><code>{{printf "%.12s" .Checkin}}</code></a></td>
<td>{{.Pipeline}} <small class="tl-meta-inline">{{.Workflow}}</small></td>
<td class="tl-meta-inline">{{.Started}}</td></tr>
{{end}}</tbody>
</table></div>
{{else}}<p>No CI runs. A pipeline is <code>ci/*.json</code> in the repository, in dagnats' workflow format; it runs on the check-ins made after it lands.</p>{{end}}
{{end}}`))

var treePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* One line of chrome, not four. The branch picker, the crumb trail
       and the clone affordances all belong to "which tree am I looking
       at", so they share a row and the listing starts near the top. */ -}}
<div class="tree-bar">
{{if .Branches}}<form method="get" action="/r/{{.Repo}}/tree"><select name="v" onchange="this.form.submit()" aria-label="branch">{{range .Branches}}
<option value="{{.Tip}}"{{if eq .Tip $.TreeVersion}} selected{{end}}>{{.Name}} · {{printf "%.10s" .Tip}}</option>
{{end}}</select>{{if .MoreBranches}} <small class="tl-meta-inline">+{{.MoreBranches}} older</small>{{end}} <noscript><button type="submit">go</button></noscript></form>{{end}}
<span class="crumbs"><a href="/r/{{.Repo}}/tree">{{.Repo}}</a>{{range .Crumbs}} / <a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>{{end}}</span>
<span class="tree-acts"><code class="clone">zit clone {{.CloneURL}} {{.Repo}}</code>
<a href="/r/{{.Repo}}/archive/{{.TreeVersion}}">.tar.gz</a></span>
</div>
{{- /* No History column: it was one identical link per row, thirty of
       them, and the file page already links its own history. GitHub and
       Forgejo both spend that space on the last change instead, which is
       what goes here once the index carries per-file data. */ -}}
<div class="tw"><table class="tree">
<tbody>{{range .Tree}}
<tr>
<td class="t-name">{{if .Dir}}<span class="ic ic-dir" aria-hidden="true"></span><a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>
{{- else}}<span class="ic ic-file" aria-hidden="true"></span><a href="/r/{{$.Repo}}/file/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>
{{- if .Exec}} <span class="flag" title="executable">exe</span>{{end}}
{{- if .Link}} <span class="flag" title="symbolic link">link</span>{{end}}
{{- if .Binary}} <span class="flag" title="binary">bin</span>{{end}}{{end}}</td>
<td class="t-size">{{if .Dir}}{{.Files}} files{{else}}{{.Human}}{{end}}</td>
</tr>
{{end}}</tbody>
</table></div>
{{if .Readme}}<h3 class="readme-head">{{.ReadmeIn}}</h3>
<div class="tkt-body readme">{{.Readme}}</div>{{end}}
{{end}}`))

var ticketPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Ticket.Title}}<h2 class="tkt-title">{{.Ticket.Title}}</h2>{{end}}
<div class="tw"><table class="tkt-head">
<tbody>{{range .Ticket.Header}}
<tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr>
{{end}}{{range .Ticket.Extra}}
<tr><th scope="row">{{.Name}}</th><td>{{.Value}}</td></tr>
{{end}}
<tr><th scope="row">created</th><td>{{.Ticket.Created}}</td></tr>
<tr><th scope="row">last change</th><td>{{ago .Ticket.Updated}}</td></tr>
<tr><th scope="row">ticket</th><td><code>{{printf "%.20s" .Ticket.ID}}</code></td></tr>
</tbody>
</table></div>
<p><a href="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit">edit ticket</a></p>
{{if .DescriptionHTML}}<h3>Description</h3>
<div class="tkt-body">{{.DescriptionHTML}}</div>{{end}}
{{if .Attachments}}<h3>Attachments</h3>
<ul>{{range .Attachments}}
<li><a href="/r/{{$.Repo}}/attachment/{{.Src}}">{{.Name}}</a> <small class="tl-meta-inline">{{.Date}} by {{.User}}</small></li>
{{end}}</ul>{{end}}
{{if .TicketComments}}<h3>Comments ({{len .TicketComments}})</h3>
{{range .TicketComments}}<div class="tkt-comment">
<div class="tl-meta-inline">{{.Login}}{{if .DifferentUsername}} (claiming to be {{.Username}}){{end}} · {{.Date}}</div>
{{.HTML}}
</div>
{{end}}{{end}}
{{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}}`))

// fleetAdminPage is the operator's view of the fleet. The ordering is
// deliberate: failures first and loud, because the whole reason this
// page exists is that with eleven stores a wedged one is invisible —
// the fleet listing shows counts, and a store that has failed its round
// every two seconds for an hour has exactly the counts it had when it
// broke.
var fleetAdminPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<h2>Fleet</h2>
{{if .SyncResults}}<div class="status"><p><strong>Sync run.</strong></p>
<ul>{{range .SyncResults}}<li><code>{{.Repo}}</code> — {{if .Err}}<strong>failed:</strong> {{.Err}}{{else}}{{.Artifacts}} artifacts{{end}}</li>{{end}}</ul></div>{{end}}
<p>{{len .FleetHealth}} stores served{{if .Unhealthy}} — <strong>{{.Unhealthy}} failing</strong>{{else}} — all healthy{{end}}.</p>
<form method="post" action="/fleet/sync"><button type="submit">Sync all now</button></form>
<div class="tw"><table>
<thead><tr><th scope="col">Store</th><th scope="col">Check-ins</th><th scope="col">Last round</th>
<th scope="col">Artifacts</th><th scope="col">Parse fallbacks</th><th scope="col">State</th><th scope="col"></th></tr></thead>
<tbody>{{range .FleetHealth}}
<tr>
<td><a href="/r/{{.Name}}/timeline">{{.Name}}</a><br><small class="tl-meta-inline"><code>{{printf "%.12s" .Code}}</code></small></td>
<td>{{.Checkins}}</td>
<td>{{if .Health.LastOK}}{{.Health.LastOK}}<br><small class="tl-meta-inline">{{printf "%.3f" .Health.RoundSeconds}}s</small>{{else}}<em>never</em>{{end}}</td>
<td>{{.Health.Total}}{{if .Health.LastCount}} <small class="tl-meta-inline">(+{{.Health.LastCount}})</small>{{end}}</td>
<td>{{if .Health.Fallbacks}}<span class="tl-badge">{{.Health.Fallbacks}}</span>{{else}}0{{end}}</td>
<td>{{if .Health.Failures}}<strong>failing ({{.Health.Failures}} in a row)</strong><br><small class="tl-meta-inline">{{.Health.LastErrAt}} — {{.Health.LastErr}}</small>
{{else if .Indexing}}<span class="tl-badge">indexing…</span>{{else}}ok{{end}}</td>
<td><form method="post" action="/fleet/sync"><input type="hidden" name="repo" value="{{.Name}}"><button type="submit">sync</button></form></td>
</tr>
{{end}}</tbody>
</table></div>

<h3>Mirrors</h3>
{{if .MirrorResults}}<div class="status"><p><strong>Pull run.</strong></p>
<ul>{{range .MirrorResults}}<li><code>{{.Repo}}</code> — {{if .Err}}<strong>failed:</strong> {{.Err}}{{else if .Changed}}new artifacts{{else}}already up to date{{end}}</li>{{end}}</ul></div>{{end}}
{{if .Mirrors}}<div class="tw"><table>
<thead><tr><th scope="col">Store</th><th scope="col">Upstream</th><th scope="col">Every</th>
<th scope="col">Last pull</th><th scope="col">State</th><th scope="col"></th></tr></thead>
<tbody>{{range .Mirrors}}
<tr>
<td><a href="/r/{{.Repo}}/timeline">{{.Repo}}</a></td>
<td><small>{{.URL}}</small></td>
<td>{{.Every}}s</td>
<td>{{if .LastAt}}{{.LastAt}}{{else}}<em>never</em>{{end}}</td>
<td>{{if .Failures}}<strong>failing ({{.Failures}} in a row)</strong><br><small class="tl-meta-inline">{{.LastErr}}</small>
{{else if not .Enabled}}disabled{{else if .LastOK}}ok{{else}}<em>pending</em>{{end}}</td>
<td><form method="post" action="/fleet/mirror/run"><input type="hidden" name="repo" value="{{.Repo}}"><button type="submit">pull</button></form>
<form method="post" action="/fleet/mirror/delete"><input type="hidden" name="repo" value="{{.Repo}}"><button type="submit">forget</button></form></td>
</tr>
{{end}}</tbody>
</table></div>
<form method="post" action="/fleet/mirror/run"><button type="submit">Pull all now</button></form>
{{else}}<p>No upstreams configured. A mirrored store tracks its origin — quarry pulls, it never pushes.</p>{{end}}
<form method="post" action="/fleet/mirror">
<label>store <input name="repo" required></label>
<label>upstream <input name="url" type="url" placeholder="https://fossil-scm.org/home" required size="40"></label>
<label>every <input name="every" type="number" value="3600" min="60" size="6"> seconds</label>
<button type="submit">Add mirror</button></form>
{{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}}/edit">edit</a> ·
<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>
<option value="e"{{if eq .Y "e"}} selected{{end}}>technotes</option>
<option value="f"{{if eq .Y "f"}} selected{{end}}>forum</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{{if .Live}} {{.LiveAttr}}{{end}}><div id="tl">{{template "tl-body" .}}</div></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">{{.CI}}{{if .CI}} {{end}}{{if .IsMerge}}<span class="tl-badge">Merge</span> {{end}}{{if .IsLeaf}}<span class="tl-badge">Leaf</span> {{end}}<span class="tl-metaitem">check-in: <a href="{{.URL}}"><b>{{.Short}}</b></a></span>
<span class="tl-metaitem">user: <b>{{.User}}</b></span>
<span class="tl-metaitem">branch: <a href="/r/{{$.Repo}}/timeline?branch={{.Branch}}"><b>{{.Branch}}</b></a></span></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 carries the data-init that opens the SSE stream. It MUST
	// sit on a wrapper OUTSIDE the element the stream patches: a patch
	// morphs the target to match the fragment, and the fragment has no
	// data-init, so an attribute on the target is deleted by the first
	// patch — taking Datastar's own connection with it. The failure is
	// quiet and easy to miss, because the first patch still applies.
	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
	EditSource  string    // wiki edit form's prefilled markdown
	Checkin     checkinDetail
	Tickets     []ticketSummary
	Ticket      ticketDetail
	Attachments []attachmentRow
	// DescriptionHTML and TicketComments are pre-rendered by the op; the
	// sanitizer already ran, which is why they are template.HTML here.
	DescriptionHTML template.HTML
	TicketComments  []ticketComment
	History         []fileChange
	HistoryPath     string
	CIRuns          []ciRunRow
	CIBadge         template.HTML
	Tree            []treeRow
	TreeVersion     string
	Crumbs          []crumb
	Branches        []branchRow
	MoreBranches    int
	CloneURL        string
	Readme          template.HTML
	ReadmeIn        string
	Forum           []forumThreadRow
	ForumRoot       string // reply-form target on the thread page
	ForumPosts      []forumPostRow
	LoginNext       string // post-login redirect target (Next is the page cursor)
	SignedIn        string // the signed-in account, for the header
	Inbox           []InboxMessage
	HasInbox        bool   // an inbox is configured, so link to it
	CanSignIn       bool   // sessions are configured, so offer the link
	IsAdmin         bool   // site admin, so show the fleet link
	Error           string // form-level failure, shown above the fields
	Technotes       []technoteRow
	Technote        technoteDetail
	ChatMsgs        []ChatMessage
	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
	// FleetHealth is the admin surface's per-store ingest state, and
	// Unhealthy the count an operator scans for. SyncResults is non-nil
	// only just after a manual round.
	FleetHealth []fleetHealthRow
	Unhealthy   int
	SyncResults []fleetSyncRow
	// Mirrors is the configured upstream tracking; MirrorResults is
	// non-nil only just after a manual pull.
	Mirrors       []mirrorRow
	MirrorResults []mirrorRunRow
	// Section is the nav entry the current page belongs to, so the nav
	// can say where the reader is. Counts is the per-section census;
	// HasCounts distinguishes "no content" from "counts unavailable",
	// which otherwise both render as zero.
	Section   string
	Counts    navCounts
	HasCounts bool
	// HideH1 lets a page that already names itself suppress the layout's
	// heading. The file listing does: its crumb trail starts with the
	// repository, so an h1 above it repeats the same word and costs a
	// heading's worth of vertical space to say nothing.
	HideH1 bool
}

// navCounts mirrors the repo.counts op.
type navCounts struct {
	Checkins     int `json:"checkins"`
	WikiPages    int `json:"wikiPages"`
	Tickets      int `json:"tickets"`
	ForumThreads int `json:"forumThreads"`
	Technotes    int `json:"technotes"`
}

// sectionOf names the nav entry a path belongs to. It reads the segment
// after /r/{repo}/, so every page under a section — a ticket, a wiki
// revision, a file's history — marks its section, not just the section's
// index page.
func sectionOf(path string) string {
	rest, ok := strings.CutPrefix(path, "/r/")
	if !ok {
		return ""
	}
	_, rest, ok = strings.Cut(rest, "/")
	if !ok || rest == "" {
		return "tree" // the repository home is the file tree
	}
	seg, _, _ := strings.Cut(rest, "/")
	switch seg {
	// Pages that live under a section without sharing its path segment.
	case "file", "history", "archive", "tree":
		return "tree"
	case "checkin", "diff", "timeline":
		return "timeline"
	default:
		return seg
	}
}

// mirrorRow mirrors the mirror.list op's row.
type mirrorRow struct {
	Repo     string `json:"repo"`
	URL      string `json:"url"`
	Every    int    `json:"every"`
	Enabled  bool   `json:"enabled"`
	LastAt   string `json:"lastAt"`
	LastOK   string `json:"lastOk"`
	LastErr  string `json:"lastErr"`
	Failures int    `json:"failures"`
}

type mirrorRunRow struct {
	Repo    string `json:"repo"`
	Changed bool   `json:"changed"`
	Err     string `json:"err"`
}

// fleetHealthRow mirrors the fleet.status op's row. Durations arrive as
// seconds and are formatted for reading here rather than in the op: the
// JSON surface stays machine-shaped, the page stays human-shaped.
type fleetHealthRow struct {
	Name     string `json:"name"`
	Code     string `json:"code"`
	Checkins int    `json:"checkins"`
	Indexing bool   `json:"indexing"`
	Health   struct {
		LastOK       string  `json:"lastOk"`
		RoundSeconds float64 `json:"roundSeconds"`
		LastCount    int     `json:"lastCount"`
		Total        int     `json:"total"`
		Fallbacks    int     `json:"fallbacks"`
		LastErr      string  `json:"lastErr"`
		LastErrAt    string  `json:"lastErrAt"`
		Failures     int     `json:"failures"`
	} `json:"health"`
}

type fleetSyncRow struct {
	Repo      string `json:"repo"`
	Artifacts int    `json:"artifacts"`
	Err       string `json:"err"`
}

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

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"`
	CI      string   `json:"ci"`
}

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 forumThreadRow struct {
	Root    string `json:"root"`
	Title   string `json:"title"`
	Posts   int    `json:"posts"`
	Updated string `json:"updated"`
	User    string `json:"user"`
}

type forumPostRow struct {
	Hash   string `json:"hash"`
	Parent string `json:"parent"`
	Date   string `json:"date"`
	User   string `json:"user"`
	HTML   template.HTML
}

// attachmentRow is one live attachment on a ticket. Fossil keeps the
// content as its own artifact, which the download route serves by hash.
// CIBadge renders a check-in's verdict for a listing. Empty for a
// check-in with no verdict: a repository without CI, or a check-in made
// before its pipeline landed, should show nothing at all rather than a
// neutral badge that reads as a result.
func ciBadge(verdict string) template.HTML {
	switch verdict {
	case "ci-pass":
		return template.HTML(`<span class="tl-badge ci-pass" title="CI passed">pass</span>`)
	case "ci-fail":
		return template.HTML(`<span class="tl-badge ci-fail" title="CI failed">fail</span>`)
	}
	return ""
}

// fileChange is one check-in that touched a file.
type fileChange struct {
	Hash    string `json:"hash"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"`
}

// ciRunRow is one CI run for the display.
type ciRunRow struct {
	RunID    string `json:"runId"`
	Checkin  string `json:"checkin"`
	Workflow string `json:"workflow"`
	Pipeline string `json:"pipeline"`
	Status   string `json:"status"`
	Started  string `json:"started"`
}

// treeRow is one entry of a directory listing.
type treeRow struct {
	Name   string `json:"name"`
	Path   string `json:"path"`
	Dir    bool   `json:"dir"`
	Blob   string `json:"blob"`
	Files  int    `json:"files"`
	Size   int64  `json:"size"`
	Exec   bool   `json:"exec"`
	Link   bool   `json:"link"`
	Binary bool   `json:"binary"`
}

// Human renders a size the way a file listing should: bytes are noise
// past a few thousand, and the reader wants magnitude.
func (t treeRow) Human() string {
	const unit = 1024
	if t.Size < unit {
		return fmt.Sprintf("%d B", t.Size)
	}
	div, exp := int64(unit), 0
	for n := t.Size / unit; n >= unit; n /= unit {
		div *= unit
		exp++
	}
	return fmt.Sprintf("%.1f %cB", float64(t.Size)/float64(div), "KMGT"[exp])
}

// branchRow is one branch for the selector. Tip is carried because zit
// allows two branches to share a name, so the name alone cannot select.
type branchRow struct {
	Name string `json:"name"`
	Tip  string `json:"tip"`
}

// crumb is one path segment of the breadcrumb trail.
type crumb struct {
	Name string
	Path string
}

type attachmentRow struct {
	Name string `json:"name"`
	Src  string `json:"src"` // content artifact — the download target
	Date string `json:"date"`
	User string `json:"user"`
}

type technoteRow struct {
	Event   string `json:"event"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"`
}

type technoteDetail struct {
	Event   string `json:"event"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"`
}

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"`
	Created string        `json:"created"`
	Updated string        `json:"updated"`
	Fields  []ticketField `json:"fields"`
	Changes []struct {
		Date   string        `json:"date"`
		User   string        `json:"user"`
		Fields []ticketField `json:"fields"`
	} `json:"changes"`
}

// ticketComment is one rendered discussion entry.
type ticketComment struct {
	Date     string        `json:"date"`
	Login    string        `json:"login"`
	Username string        `json:"username"`
	HTML     template.HTML `json:"html"`
}

// DifferentUsername reports whether the typed name disagrees with the
// authenticated one. Fossil surfaces that ("claiming to be …") because
// on an open tracker the two need not match, and hiding the difference
// would attribute words to the wrong person.
func (c ticketComment) DifferentUsername() bool {
	return c.Username != "" && c.Username != c.Login
}

// Title is the ticket's headline, promoted out of the field table the
// way fossil's view does — a ticket is known by its title, not its hash.
func (t ticketDetail) Title() string { return t.Field("title") }

// Header is the ordered subset fossil shows above the description. The
// order is fossil's own (status/type, severity/priority,
// subsystem/resolution), and unset fields are dropped rather than
// rendered as blank rows.
func (t ticketDetail) Header() []ticketField {
	order := []string{"status", "type", "severity", "priority",
		"subsystem", "resolution", "assignee", "foundin", "submitter", "closer"}
	var out []ticketField
	for _, name := range order {
		if v := t.Field(name); v != "" {
			out = append(out, ticketField{Name: name, Value: v})
		}
	}
	return out
}

// Extra is everything else a repository's ticket schema carries — trackers
// define their own columns, so a fixed list would hide them.
func (t ticketDetail) Extra() []ticketField {
	known := map[string]bool{"title": true, "status": true, "type": true,
		"severity": true, "priority": true, "subsystem": true, "resolution": true,
		"assignee": true, "foundin": true, "submitter": true, "closer": true}
	var out []ticketField
	for _, f := range t.Fields {
		if !known[f.Name] && f.Value != "" {
			out = append(out, f)
		}
	}
	return out
}

// Field folds the current value of one named field (for form prefill).
func (t ticketDetail) Field(name string) string {
	for _, f := range t.Fields {
		if f.Name == name {
			return f.Value
		}
	}
	return ""
}

type checkinDetail struct {
	Hash    string   `json:"hash"`
	CI      string   `json:"ci"`
	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).
// Verify checks one repo credential pair against a required capability;
// nil leaves writes open (single-user local serving, author "anonymous").
type Verify func(repo, user, password string, need ops.Auth) bool

// Sessions is the browser half of the gate: a cookie a person can drop.
// HTTP Basic keeps working alongside it — that is what the API and every
// script uses — but a browser needs a way to log OUT, which Basic does
// not give it. A nil Sessions disables the login page and leaves Basic
// as the only path.
type Sessions interface {
	// Login issues a token for correct credentials.
	Login(user, password string) (token string, expires time.Time, ok bool)
	// Resolve names the account a token belongs to.
	Resolve(token string) (user string, ok bool)
	// Logout drops one token.
	Logout(token string)
	// Allow authorizes an ALREADY-authenticated user, so a cookie is
	// checked against the same roles a password would be.
	Allow(user, repo string, need ops.Auth) bool
}

// InboxMessage is one notification as the inbox page shows it. quarry
// does not model notifications — notify owns them (0001 §5) — so this is
// only what the page renders.
type InboxMessage struct {
	Subject   string
	Content   string
	CreatedAt string
	Read      bool
}

// Inbox reads a subscriber's notifications from the notify companion.
// nil disables the inbox page, which is the default: notifications are
// off unless --notify is configured.
type Inbox interface {
	Messages(ctx context.Context, subscriber string) ([]InboxMessage, error)
}

// WithInbox attaches the notify-backed inbox.
func WithInbox(in Inbox) Option {
	return func(o *handlerOpts) { o.inbox = in }
}

// sessionCookie is the browser's session key. Host-only, path-wide,
// unreadable to scripts, and not sent on cross-site requests — the last
// of which is what keeps a POST form from being submitted by another
// origin on the reader's behalf.
const sessionCookie = "quarry_session"

func setSession(w http.ResponseWriter, req *http.Request, token string, expires time.Time) {
	http.SetCookie(w, &http.Cookie{
		Name: sessionCookie, Value: token, Path: "/",
		Expires:  expires,
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Secure:   req.TLS != nil,
	})
}

func clearSession(w http.ResponseWriter, req *http.Request) {
	http.SetCookie(w, &http.Cookie{
		Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
		HttpOnly: true, SameSite: http.SameSiteLaxMode, Secure: req.TLS != nil,
	})
}

func cookieToken(req *http.Request) string {
	if c, err := req.Cookie(sessionCookie); err == nil {
		return c.Value
	}
	return ""
}

// gate runs the write-page auth check at Token: HTTP Basic against
// verify, the authenticated user carried to the op via context. Almost
// every gated HTML page is a write page and wants exactly this.
func gate(verify Verify, sess Sessions, repo string, w http.ResponseWriter, req *http.Request) (*http.Request, bool) {
	return gateAt(verify, sess, repo, ops.Token, w, req)
}

// gateAt is gate at an explicit capability. The fleet admin pages are
// the reason it exists: they are not scoped to one repository and they
// expose operational detail, so Token is not the right bar for them.
func gateAt(verify Verify, sess Sessions, repo string, level ops.Auth, w http.ResponseWriter, req *http.Request) (*http.Request, bool) {
	if verify == nil {
		return req, true
	}
	if sess != nil {
		if user, ok := sess.Resolve(cookieToken(req)); ok {
			if !sess.Allow(user, repo, level) {
				// Signed in, but without the capability on THIS
				// repository. A challenge would be a lie — better
				// credentials are not what is missing.
				forbid(w, user, repo, level)
				return nil, false
			}
			return req.WithContext(ops.WithUser(req.Context(), user)), true
		}
	}
	user, pass, _ := req.BasicAuth()
	if !verify(repo, user, pass, level) {
		if sess != nil && user == "" {
			// A browser with no credential gets the login page, not a
			// Basic prompt it cannot log out of.
			http.Redirect(w, req, "/login?next="+url.QueryEscape(req.URL.Path), http.StatusSeeOther)
			return nil, false
		}
		w.Header().Set("WWW-Authenticate", `Basic realm="quarry"`)
		w.WriteHeader(http.StatusUnauthorized)
		w.Write([]byte("authentication required"))
		return nil, false
	}
	return req.WithContext(ops.WithUser(req.Context(), user)), true
}

// forbid states what the signed-in user actually lacks. The fleet pages
// are not scoped to a repository, so a message naming one would be
// nonsense ("no write access to ”"); the level and the empty repo pick
// the honest wording.
// fleetHealth renders the admin page, optionally above the results of a
// sync or a pull the operator just ran.
func fleetHealth(r *ops.Registry, w http.ResponseWriter, req *http.Request,
	sync []fleetSyncRow, pulls []mirrorRunRow) {
	out, err := invoke(req.Context(), r, "fleet.status", nil)
	if err != nil {
		fail(r, w, req, err)
		return
	}
	var fs struct {
		Repos     []fleetHealthRow `json:"repos"`
		Unhealthy int              `json:"unhealthy"`
	}
	remarshal(out, &fs)
	data := pageData{
		Title: "fleet", FleetHealth: fs.Repos, Unhealthy: fs.Unhealthy,
		SyncResults: sync, MirrorResults: pulls,
	}
	// mirror.list is absent when no mirror store is configured, which is
	// a forge that mirrors nothing rather than an error — the section
	// simply renders its empty state.
	if mo, err := invoke(req.Context(), r, "mirror.list", nil); err == nil {
		var ml struct {
			Mirrors []mirrorRow `json:"mirrors"`
		}
		remarshal(mo, &ml)
		data.Mirrors = ml.Mirrors
	}
	render(r, w, req, fleetAdminPage, data)
}

func forbid(w http.ResponseWriter, user, repo string, level ops.Auth) {
	w.WriteHeader(http.StatusForbidden)
	switch {
	case level >= ops.Admin && repo == "":
		fmt.Fprintf(w, "%s is not a site admin", user)
	case level >= ops.Admin:
		fmt.Fprintf(w, "%s has no admin access to %s", user, repo)
	default:
		fmt.Fprintf(w, "%s has no write access to %s", user, repo)
	}
}

// WithSessions attaches the cookie plane to a handler built by Handler.
// Kept separate from Handler's signature so a caller that only needs the
// Basic gate — every test, and single-user local serving — is unchanged.
func WithSessions(s Sessions) func(*handlerOpts) {
	return func(o *handlerOpts) { o.sessions = s }
}

type handlerOpts struct {
	sessions Sessions
	inbox    Inbox
}

// Option configures the handler beyond its required planes.
type Option func(*handlerOpts)

func Handler(r *ops.Registry, n Notifier, live ChatLive, v ...Verify) http.Handler {
	return HandlerWith(r, n, live, nil, v...)
}

// HandlerWith is Handler plus options; Handler is the common case.
func HandlerWith(r *ops.Registry, n Notifier, live ChatLive, opts []Option, v ...Verify) http.Handler {
	var verify Verify
	if len(v) > 0 {
		verify = v[0]
	}
	o := &handlerOpts{}
	for _, fn := range opts {
		fn(o)
	}
	sessions := o.sessions
	inbox := o.inbox
	mux := http.NewServeMux()
	if sessions != nil {
		mux.HandleFunc("GET /login", func(w http.ResponseWriter, req *http.Request) {
			render(r, w, req, loginPage, pageData{
				Title: "sign in", LoginNext: safeNext(req.URL.Query().Get("next")),
			})
		})
		mux.HandleFunc("POST /login", func(w http.ResponseWriter, req *http.Request) {
			user, password := req.FormValue("user"), req.FormValue("password")
			next := safeNext(req.FormValue("next"))
			token, expires, ok := sessions.Login(user, password)
			if !ok {
				// One message for a bad name and a bad password: naming
				// which was wrong tells an attacker which accounts exist.
				w.WriteHeader(http.StatusUnauthorized)
				render(r, w, req, loginPage, pageData{
					Title: "sign in", LoginNext: next,
					Error: "That account and password do not match.",
				})
				return
			}
			setSession(w, req, token, expires)
			http.Redirect(w, req, next, http.StatusSeeOther)
		})
		mux.HandleFunc("POST /logout", func(w http.ResponseWriter, req *http.Request) {
			sessions.Logout(cookieToken(req))
			clearSession(w, req)
			http.Redirect(w, req, "/", http.StatusSeeOther)
		})
	}
	if inbox != nil && sessions != nil {
		mux.HandleFunc("GET /inbox", func(w http.ResponseWriter, req *http.Request) {
			user, ok := sessions.Resolve(cookieToken(req))
			if !ok {
				http.Redirect(w, req, "/login?next=%2Finbox", http.StatusSeeOther)
				return
			}
			data := pageData{Title: "inbox"}
			msgs, err := inbox.Messages(req.Context(), user)
			if err != nil {
				// A notify outage must not look like "you have nothing";
				// an empty list and a failure are different answers.
				log.Printf("web: inbox for %s: %v", user, err)
				data.Error = "Notifications are unavailable right now."
			}
			data.Inbox = msgs
			render(r, w, req, inboxPage, data)
		})
	}
	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)
		data := pageData{Title: "quarry", Fleet: fl.Repos}
		if n != nil {
			data.Live = true
			data.LiveAttr = template.HTMLAttr(`data-init="@get('/fleet.sse')"`)
		}
		render(r, w, req, fleetPage, data)
	})
	// The fleet admin pages are gated at Admin with an EMPTY repo: they
	// are not scoped to a store, and a site admin is the only role for
	// which "every store's health" is a meaningful question.
	mux.HandleFunc("GET /fleet", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
		if !ok {
			return
		}
		fleetHealth(r, w, req, nil, nil)
	})
	mux.HandleFunc("POST /fleet/sync", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
		if !ok {
			return
		}
		out, err := invoke(req.Context(), r, "fleet.sync",
			url.Values{"repo": {req.FormValue("repo")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fs struct {
			Results []fleetSyncRow `json:"results"`
		}
		remarshal(out, &fs)
		// Rendered in place rather than redirected: the results ARE the
		// answer to pressing the button, and a redirect would drop them.
		fleetHealth(r, w, req, fs.Results, nil)
	})
	mux.HandleFunc("POST /fleet/mirror", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "mirror.set", url.Values{
			"repo": {req.FormValue("repo")}, "url": {req.FormValue("url")},
			"every": {req.FormValue("every")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/fleet", http.StatusSeeOther)
	})
	mux.HandleFunc("POST /fleet/mirror/delete", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "mirror.delete",
			url.Values{"repo": {req.FormValue("repo")}}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/fleet", http.StatusSeeOther)
	})
	mux.HandleFunc("POST /fleet/mirror/run", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
		if !ok {
			return
		}
		out, err := invoke(req.Context(), r, "mirror.run",
			url.Values{"repo": {req.FormValue("repo")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var mr struct {
			Results []mirrorRunRow `json:"results"`
		}
		remarshal(out, &mr)
		fleetHealth(r, w, req, nil, mr.Results)
	})
	mux.HandleFunc("GET /docs", func(w http.ResponseWriter, req *http.Request) {
		out, err := invoke(req.Context(), r, "meta.docs", nil)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var d struct {
			HTML string `json:"html"`
		}
		remarshal(out, &d)
		render(r, w, req, docsPage, pageData{
			Title: "API", WikiHTML: template.HTML(d.HTML),
		})
	})
	mux.HandleFunc("GET /fleet.sse", func(w http.ResponseWriter, req *http.Request) {
		if n == nil {
			http.NotFound(w, req)
			return
		}
		liveFleet(r, n, w, req)
	})
	// 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)
	})
	// The repository's front door. A forge lands on code — fossil's own
	// default is the timeline, and departing from it is deliberate: a
	// repository with no file view has no front door at all, and every
	// GitHub habit misses.
	treeHandler := func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		version, path := req.PathValue("version"), req.PathValue("path")
		if v := req.URL.Query().Get("v"); v != "" {
			// The branch selector posts here; redirect so the chosen
			// version lives in the path and the page is linkable.
			http.Redirect(w, req, "/r/"+repo+"/tree/"+url.PathEscape(v)+"/", http.StatusSeeOther)
			return
		}
		if version == "" {
			version = "trunk"
		}
		out, err := invoke(req.Context(), r, "tree.list", url.Values{
			"repo": {repo}, "version": {version}, "path": {path},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tl struct {
			Version  string    `json:"version"`
			Path     string    `json:"path"`
			Entries  []treeRow `json:"entries"`
			Readme   string    `json:"readme"`
			ReadmeIn string    `json:"readmeIn"`
		}
		remarshal(out, &tl)

		data := pageData{
			Title: repo, Repo: repo, Tree: tl.Entries, TreeVersion: version,
			Readme: template.HTML(tl.Readme), ReadmeIn: tl.ReadmeIn,
			CloneURL: cloneURL(req, repo),
		}
		if tl.Path != "" {
			var acc string
			for _, seg := range strings.Split(tl.Path, "/") {
				acc = strings.TrimPrefix(acc+"/"+seg, "/")
				data.Crumbs = append(data.Crumbs, crumb{Name: seg, Path: acc})
			}
		}
		if bo, err := invoke(req.Context(), r, "branch.list", url.Values{"repo": {repo}}); err == nil {
			var bl struct {
				Branches []branchRow `json:"branches"`
			}
			remarshal(bo, &bl)
			// tcl has 1,855 branches and sqlite 1,747. Rendering every
			// one is both an unusable control and 90% of the page's
			// weight — 239KB of <option>. Show the recently active ones,
			// which branch.list already returns first.
			const shown = 25
			data.Branches = bl.Branches
			if len(bl.Branches) > shown {
				data.Branches = bl.Branches[:shown]
				data.MoreBranches = len(bl.Branches) - shown
				// The branch being VIEWED must appear even when it is
				// not recently active, or the selector silently shows
				// the wrong thing for anyone browsing older work.
				found := false
				for _, b := range data.Branches {
					if b.Tip == version {
						found = true
						break
					}
				}
				if !found {
					for _, b := range bl.Branches {
						if b.Tip == version {
							data.Branches = append([]branchRow{b}, data.Branches...)
							break
						}
					}
				}
			}
		}
		// The crumb trail is this page's heading.
		data.HideH1 = true
		render(r, w, req, treePage, data)
	}
	mux.HandleFunc("GET /r/{repo}", treeHandler)
	mux.HandleFunc("GET /r/{repo}/tree", treeHandler)
	mux.HandleFunc("GET /r/{repo}/tree/{version}/{path...}", treeHandler)
	mux.HandleFunc("GET /r/{repo}/history/{version}/{path...}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		version, path := req.PathValue("version"), req.PathValue("path")
		out, err := invoke(req.Context(), r, "file.history", url.Values{
			"repo": {repo}, "version": {version}, "path": {path},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fh struct {
			Changes []fileChange `json:"changes"`
		}
		remarshal(out, &fh)
		render(r, w, req, historyPage, pageData{
			Title: path, Repo: repo, TreeVersion: version,
			HistoryPath: path, History: fh.Changes,
		})
	})
	mux.HandleFunc("GET /r/{repo}/ci", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "ci.list", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var cl struct {
			Runs []ciRunRow `json:"runs"`
		}
		remarshal(out, &cl)
		data := pageData{Title: "ci", Repo: repo, CIRuns: cl.Runs}
		if n != nil {
			// A run's status changes while nobody is clicking, so the
			// page follows it. data-init sits on a WRAPPER, outside the
			// element the stream patches — a patch morphs its target to
			// match the fragment, and the fragment has no data-init.
			data.Live = true
			data.LiveAttr = template.HTMLAttr(`data-init="@get('/r/` + repo + `/ci.sse')"`)
		}
		render(r, w, req, ciPage, data)
	})
	mux.HandleFunc("GET /r/{repo}/ci.sse", func(w http.ResponseWriter, req *http.Request) {
		if n == nil {
			http.NotFound(w, req)
			return
		}
		repo := req.PathValue("repo")
		liveCI(r, repo, w, req)
	})
	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}/chat", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "chat.history", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var ch struct {
			Messages []ChatMessage `json:"messages"`
		}
		remarshal(out, &ch)
		data := pageData{Title: "chat", Repo: repo, ChatMsgs: ch.Messages}
		if live != nil {
			data.Live = true
			data.LiveAttr = template.HTMLAttr(`data-init="@get('/r/` + repo + `/chat.sse')"`)
		}
		render(r, w, req, chatPage, data)
	})
	mux.HandleFunc("POST /r/{repo}/chat", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "chat.post",
			url.Values{"repo": {repo}, "text": {req.FormValue("text")}}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/chat", http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/chat.sse", func(w http.ResponseWriter, req *http.Request) {
		if live == nil {
			http.NotFound(w, req)
			return
		}
		liveChat(live, req.PathValue("repo"), w, req)
	})
	mux.HandleFunc("GET /r/{repo}/forum", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "forum.list", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fl struct {
			Threads []forumThreadRow `json:"threads"`
		}
		remarshal(out, &fl)
		render(r, w, req, forumListPage, pageData{Title: "forum", Repo: repo, Forum: fl.Threads})
	})
	mux.HandleFunc("GET /r/{repo}/forum-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		render(r, w, req, forumNewPage, pageData{Title: "new thread", Repo: repo})
	})
	mux.HandleFunc("POST /r/{repo}/forum-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		out, err := invoke(req.Context(), r, "forum.post", url.Values{
			"repo": {repo}, "title": {req.FormValue("title")}, "body": {req.FormValue("body")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fp struct {
			Hash string `json:"hash"`
		}
		remarshal(out, &fp)
		http.Redirect(w, req, "/r/"+repo+"/forum/"+fp.Hash[:min(16, len(fp.Hash))], http.StatusSeeOther)
	})
	mux.HandleFunc("POST /r/{repo}/forum/{root}/reply", func(w http.ResponseWriter, req *http.Request) {
		repo, root := req.PathValue("repo"), req.PathValue("root")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "forum.reply", url.Values{
			"repo": {repo}, "root": {root}, "body": {req.FormValue("body")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/forum/"+root, http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/technote-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		render(r, w, req, technoteEditPage, pageData{Title: "new technote", Repo: repo})
	})
	mux.HandleFunc("POST /r/{repo}/technote-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		out, err := invoke(req.Context(), r, "technote.add", url.Values{
			"repo": {repo}, "body": {req.FormValue("body")},
			"comment": {req.FormValue("comment")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var ta struct {
			Event string `json:"event"`
		}
		remarshal(out, &ta)
		http.Redirect(w, req, "/r/"+repo+"/technote/"+ta.Event[:min(16, len(ta.Event))], http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/technote/{event}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, event := req.PathValue("repo"), req.PathValue("event")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		out, err := invoke(req.Context(), r, "technote.get",
			url.Values{"repo": {repo}, "event": {event}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tg struct {
			Event   string `json:"event"`
			Body    string `json:"body"`
			Comment string `json:"comment"`
		}
		remarshal(out, &tg)
		render(r, w, req, technoteEditPage, pageData{
			Title: "edit technote", Repo: repo,
			Technote:   technoteDetail{Event: tg.Event, Comment: tg.Comment},
			EditSource: tg.Body,
		})
	})
	mux.HandleFunc("POST /r/{repo}/technote/{event}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, event := req.PathValue("repo"), req.PathValue("event")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "technote.add", url.Values{
			"repo": {repo}, "body": {req.FormValue("body")}, "event": {event},
			"comment": {req.FormValue("comment")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/technote/"+event, http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/forum/{root}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "forum.get",
			url.Values{"repo": {repo}, "root": {req.PathValue("root")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fg struct {
			Title string         `json:"title"`
			Posts []forumPostRow `json:"posts"`
		}
		remarshal(out, &fg)
		title := fg.Title
		if title == "" {
			title = "thread"
		}
		render(r, w, req, forumThreadPage, pageData{
			Title: title, Repo: repo, ForumPosts: fg.Posts,
			ForumRoot: req.PathValue("root"),
		})
	})
	mux.HandleFunc("GET /r/{repo}/technote", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "technote.list", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tl struct {
			Notes []technoteRow `json:"notes"`
		}
		remarshal(out, &tl)
		render(r, w, req, technoteListPage, pageData{Title: "technotes", Repo: repo, Technotes: tl.Notes})
	})
	mux.HandleFunc("GET /r/{repo}/technote/{event}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "technote.get",
			url.Values{"repo": {repo}, "event": {req.PathValue("event")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tg struct {
			Event string `json:"event"`
			Date  string `json:"date"`
			User  string `json:"user"`
			HTML  string `json:"html"`
		}
		remarshal(out, &tg)
		render(r, w, req, technotePage, pageData{
			Title: "technote " + tg.Date, Repo: repo,
			Technote: technoteDetail{Event: tg.Event, Date: tg.Date, User: tg.User},
			WikiHTML: template.HTML(tg.HTML),
		})
	})
	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}, "before": {req.URL.Query().Get("before")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tl struct {
			Tickets []ticketSummary `json:"tickets"`
			Next    string          `json:"next"`
		}
		remarshal(out, &tl)
		render(r, w, req, ticketListPage, pageData{
			Title: "tickets", Repo: repo, Tickets: tl.Tickets, Next: tl.Next,
		})
	})
	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"`
			Attachments     []attachmentRow `json:"attachments"`
			DescriptionHTML string          `json:"descriptionHtml"`
			Comments        []ticketComment `json:"comments"`
		}
		remarshal(out, &td)
		// A ticket is known by its title, not its hash; fall back to the
		// id only when the tracker recorded no title.
		title := td.Ticket.Title()
		if title == "" {
			title = "ticket " + td.Ticket.ID[:10]
		}
		render(r, w, req, ticketPage, pageData{
			Title: title, Repo: repo, Ticket: td.Ticket,
			Attachments:     td.Attachments,
			DescriptionHTML: template.HTML(td.DescriptionHTML),
			TicketComments:  td.Comments,
		})
	})
	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),
		})
	})
	// Archive download. Same posture as an attachment: a download, never
	// an inline render, with the stored content type discarded — a
	// tarball is bytes a reader saves, not a document a browser should
	// try to interpret.
	mux.HandleFunc("GET /r/{repo}/archive/{version}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "archive.get", url.Values{
			"repo": {repo}, "version": {req.PathValue("version")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var ad struct {
			Name    string `json:"name"`
			Content string `json:"content"`
		}
		remarshal(out, &ad)
		raw, err := base64.StdEncoding.DecodeString(ad.Content)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		w.Header().Set("Content-Type", "application/gzip")
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(ad.Name))
		w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
		w.Write(raw)
	})
	// Attachment download. This is the only route that hands a reader
	// bytes somebody else uploaded, so it is deliberately hostile to its
	// own payload: always a download, never an inline render; the stored
	// content type is discarded in favour of octet-stream; nosniff stops
	// the browser guessing its way back to HTML; and the CSP denies
	// everything in case a viewer renders it anyway.
	mux.HandleFunc("GET /r/{repo}/attachment/{hash}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "attachment.get", url.Values{
			"repo": {repo}, "hash": {req.PathValue("hash")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var ad struct {
			Name    string `json:"name"`
			Content string `json:"content"`
		}
		remarshal(out, &ad)
		raw, err := base64.StdEncoding.DecodeString(ad.Content)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		w.Header().Set("Content-Type", "application/octet-stream")
		w.Header().Set("X-Content-Type-Options", "nosniff")
		w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
		w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(ad.Name))
		w.Header().Set("Content-Length", strconv.Itoa(len(raw)))
		w.Write(raw)
	})
	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,
			CIBadge: ciBadge(cd.CI),
		})
	})
	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}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, name := req.PathValue("repo"), req.PathValue("name")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		source := ""
		if out, err := invoke(req.Context(), r, "wiki.get",
			url.Values{"repo": {repo}, "name": {name}}); err == nil {
			var wd struct {
				Source string `json:"source"`
			}
			remarshal(out, &wd)
			source = wd.Source
		}
		render(r, w, req, wikiEditPage, pageData{
			Title: "edit " + name, Repo: repo,
			Wiki: wikiDetail{Title: name}, EditSource: source,
		})
	})
	mux.HandleFunc("POST /r/{repo}/wiki/{name}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, name := req.PathValue("repo"), req.PathValue("name")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "wiki.save", url.Values{
			"repo": {repo}, "name": {name},
			"body": {req.FormValue("body")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/wiki/"+url.PathEscape(name), http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/wiki-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		name := req.URL.Query().Get("name")
		if name == "" {
			http.Redirect(w, req, "/r/"+repo+"/wiki", http.StatusSeeOther)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/wiki/"+url.PathEscape(name)+"/edit", http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/ticket/new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		render(r, w, req, ticketNewPage, pageData{Title: "new ticket", Repo: repo})
	})
	mux.HandleFunc("POST /r/{repo}/ticket/new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		out, err := invoke(req.Context(), r, "ticket.new", url.Values{
			"repo": {repo}, "title": {req.FormValue("title")},
			"status": {req.FormValue("status")}, "comment": {req.FormValue("comment")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tn struct {
			ID string `json:"id"`
		}
		remarshal(out, &tn)
		http.Redirect(w, req, "/r/"+repo+"/ticket/"+tn.ID, http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/ticket/{id}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, id := req.PathValue("repo"), req.PathValue("id")
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		out, err := invoke(req.Context(), r, "ticket.get",
			url.Values{"repo": {repo}, "id": {id}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tg struct {
			Ticket ticketDetail `json:"ticket"`
		}
		remarshal(out, &tg)
		render(r, w, req, ticketEditPage, pageData{
			Title: "edit ticket", Repo: repo, Ticket: tg.Ticket,
		})
	})
	mux.HandleFunc("POST /r/{repo}/ticket/{id}/edit", func(w http.ResponseWriter, req *http.Request) {
		repo, id := req.PathValue("repo"), req.PathValue("id")
		req, ok := gate(verify, sessions, repo, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "ticket.set", url.Values{
			"repo": {repo}, "id": {id},
			"title": {req.FormValue("title")}, "status": {req.FormValue("status")},
			"resolution": {req.FormValue("resolution")}, "comment": {req.FormValue("comment")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/ticket/"+id, http.StatusSeeOther)
	})
	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,
		})
	})
	// Identity is resolved ONCE per request and carried in the context,
	// so every page header can show who is signed in without each route
	// knowing about the session plane.
	return withIdentity(sessions, inbox != nil, 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")
	streamPatches(n, repo, w, req, func() (string, error) {
		return timelineFragment(r, repo, req.URL.Query())
	})
}

// liveFleet does the same for the home page: any repo's change re-renders
// the fleet table.
func liveFleet(r *ops.Registry, n Notifier, w http.ResponseWriter, req *http.Request) {
	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("*")
	defer cancel()
	// A first index emits events continuously, so the event tick alone
	// would show progress — but the "indexing" flag clears AFTER the
	// last event, so the badge would stick until something else
	// happened. A slow poll alongside the events closes that gap, and
	// sending only on change keeps an idle fleet silent.
	poll := time.NewTicker(2 * time.Second)
	defer poll.Stop()
	heartbeat := time.NewTicker(20 * time.Second)
	defer heartbeat.Stop()

	last := ""
	repaint := func() bool {
		frag, err := fleetFragment(r)
		if err != nil {
			log.Printf("web: live fleet: %v", err)
			return true
		}
		if frag == last {
			return true // nothing changed; stay quiet
		}
		last = frag
		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()
		return true
	}
	repaint()
	for {
		select {
		case <-req.Context().Done():
			return
		case <-heartbeat.C:
			fmt.Fprint(w, ": ping\n\n")
			fl.Flush()
		case <-poll.C:
			repaint()
		case _, open := <-ticks:
			if !open {
				return
			}
			time.Sleep(200 * time.Millisecond) // debounce a burst
			select {
			case <-ticks:
			default:
			}
			repaint()
		}
	}
}

// streamPatches is the SSE loop both live pages share: subscribe to one
// repo's changes ("*" = the whole fleet), debounce bursts, and send each
// repaint as a Datastar element patch that morphs in place.
// liveChat streams new chat messages as Datastar append patches into
// #chatlog — each message an appended paragraph, so the log grows
// without re-rendering history. Without JS the page still shows the
// on-load window and posts via the plain form (Principle 3).
func liveChat(live ChatLive, repo string, w http.ResponseWriter, req *http.Request) {
	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()

	msgs, cancel := live.SubscribeChat(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 m, open := <-msgs:
			if !open {
				return
			}
			el := `<p class="chatmsg"><b>` + template.HTMLEscapeString(m.User) +
				`</b> <small class="tl-meta-inline">` + template.HTMLEscapeString(m.At) +
				`</small><br>` + template.HTMLEscapeString(m.Text) + `</p>`
			fmt.Fprint(w, "event: datastar-patch-elements\n")
			fmt.Fprint(w, "data: mode append\n")
			fmt.Fprint(w, "data: selector #chatlog\n")
			fmt.Fprintf(w, "data: elements %s\n\n", el)
			fl.Flush()
		}
	}
}

func streamPatches(n Notifier, repo string, w http.ResponseWriter, req *http.Request, fragment func() (string, error)) {
	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 := fragment()
			if err != nil {
				log.Printf("web: live %s: %v", repo, err)
				continue
			}
			// Datastar v1 element patch: morphs the fragment 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()
		}
	}
}

// fleetFragment renders the #fleet body through repo.list — shared by
// the page render and the live patches.
func fleetFragment(r *ops.Registry) (string, error) {
	out, err := invoke(context.Background(), r, "repo.list", nil)
	if err != nil {
		return "", err
	}
	var fl struct {
		Repos []repoInfo `json:"repos"`
	}
	remarshal(out, &fl)
	var buf bytes.Buffer
	buf.WriteString(`<div id="fleet">`)
	if err := fleetPage.ExecuteTemplate(&buf, "fleet-body", pageData{Fleet: fl.Repos}); err != nil {
		return "", err
	}
	buf.WriteString(`</div>`)
	return buf.String(), nil
}

// 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) {
	if id, ok := req.Context().Value(ctxIdentity{}).(identity); ok {
		data.SignedIn, data.CanSignIn, data.HasInbox = id.user, id.canSignIn, id.hasInbox
		data.IsAdmin = id.isAdmin
	}
	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
	// The nav belongs to the layout, not to any one handler, so its
	// counts and its current-section marker are filled in here rather
	// than in each of the thirty-odd places that render a repo page.
	// A counts failure is NOT fatal: the nav degrades to bare labels,
	// which is what it showed before counts existed.
	if data.Repo != "" {
		data.Section = sectionOf(req.URL.Path)
		if co, err := invoke(req.Context(), r, "repo.counts",
			url.Values{"repo": {data.Repo}}); err == nil {
			remarshal(co, &data.Counts)
			data.HasCounts = true
		} else {
			log.Printf("web: counts for %s: %v", data.Repo, err)
		}
	}
	// 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)
	}
}

type ctxIdentity struct{}

type identity struct {
	user      string
	canSignIn bool
	hasInbox  bool
	// isAdmin drives the fleet link in the header. It is a DISPLAY
	// decision only — /fleet gates itself, so a wrong answer here hides
	// or shows a link, never grants access.
	isAdmin bool
}

func withIdentity(sessions Sessions, hasInbox bool, next http.Handler) http.Handler {
	if sessions == nil {
		return next
	}
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		id := identity{canSignIn: true, hasInbox: hasInbox}
		if user, ok := sessions.Resolve(cookieToken(req)); ok {
			id.user = user
			// Empty repo at Admin is the site-admin question — the same
			// one /fleet asks.
			id.isAdmin = sessions.Allow(user, "", ops.Admin)
		}
		next.ServeHTTP(w, req.WithContext(context.WithValue(req.Context(), ctxIdentity{}, id)))
	})
}

// safeNext keeps the post-login redirect on this site. An attacker who
// can choose ?next= can otherwise land a freshly-signed-in reader on
// their own page, which is how a login form becomes an open redirect.
// Only a rooted, single-slash path is allowed; anything else goes home.
func safeNext(next string) string {
	if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
		return "/"
	}
	if strings.Contains(next, "\n") || strings.Contains(next, "\r") {
		return "/"
	}
	return next
}

// cloneURL is the address a reader clones from: quarry's own sync
// fronting for this repository (0001 §7), on whatever host they reached
// us by — a hardcoded base would be wrong for every deployment but one.
func cloneURL(req *http.Request, repo string) string {
	scheme := "http"
	if req.TLS != nil {
		scheme = "https"
	}
	if p := req.Header.Get("X-Forwarded-Proto"); p != "" {
		scheme = p
	}
	return scheme + "://" + req.Host + "/r/" + repo
}

// ciFragment renders the CI table for a repo — shared by the page and
// the stream, so a patch cannot drift from what a reload would draw.
func ciFragment(r *ops.Registry, repo string) (string, error) {
	out, err := invoke(context.Background(), r, "ci.list", url.Values{"repo": {repo}})
	if err != nil {
		return "", err
	}
	var cl struct {
		Runs []ciRunRow `json:"runs"`
	}
	remarshal(out, &cl)
	var buf bytes.Buffer
	if err := ciPage.ExecuteTemplate(&buf, "ci-body",
		pageData{Repo: repo, CIRuns: cl.Runs}); err != nil {
		return "", err
	}
	return `<div id="ci">` + buf.String() + `</div>`, nil
}

// liveCI streams the CI table. Unlike the timeline, a CI page changes
// without any artifact arriving — a run finishes inside dagnats — so
// this polls rather than waiting on the event plane, and sends only
// when the rendered fragment actually differs.
func liveCI(r *ops.Registry, repo string, w http.ResponseWriter, req *http.Request) {
	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()

	poll := time.NewTicker(3 * time.Second)
	defer poll.Stop()
	heartbeat := time.NewTicker(20 * time.Second)
	defer heartbeat.Stop()
	last := ""
	for {
		select {
		case <-req.Context().Done():
			return
		case <-heartbeat.C:
			fmt.Fprint(w, ": ping\n\n")
			fl.Flush()
		case <-poll.C:
			frag, err := ciFragment(r, repo)
			if err != nil || frag == last {
				continue
			}
			last = frag
			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()
		}
	}
}