Skip to content
quarry Artifact [2efaa1dbd5]
Login

Artifact [2efaa1dbd5]

Artifact 2efaa1dbd5078e8f64b2dd7154d1e332b4516cf999523d2dd62e667fd43b60db:


// 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"
	"regexp"
	"slices"
	"strconv"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/highlight"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/ops"
	"craftdesign.group/quarry/internal/timing"
)

// 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)
}

// agoShort words a duration for a badge: "34m", "12h", "3d", "2y".
//
// The long form belongs in prose. In a column of numbers it is the
// thing that breaks alignment, which is the whole reason the stats are
// badges rather than a sentence.
func agoShort(s string) template.HTML {
	t, ok := parseArtifactTime(s)
	if !ok {
		return ""
	}
	d := time.Since(t)
	var out string
	switch {
	case d < time.Minute:
		out = "now"
	case d < time.Hour:
		out = fmt.Sprintf("%dm", int(d.Minutes()))
	case d < 24*time.Hour:
		out = fmt.Sprintf("%dh", int(d.Hours()))
	case d < 365*24*time.Hour:
		out = fmt.Sprintf("%dd", int(d.Hours()/24))
	default:
		out = fmt.Sprintf("%dy", int(d.Hours()/24/365))
	}
	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")),
		out))
}

// statusClass sorts a ticket status into resolved or not. Fossil's
// status vocabulary is per-repository — a ticket schema can define any
// words it likes — so this recognizes the conventional resolved words
// and treats everything else as open. Getting it wrong tints a badge;
// it never hides a ticket.
func statusClass(status string) string {
	switch strings.ToLower(strings.TrimSpace(status)) {
	case "closed", "fixed", "verified", "resolved", "deferred", "rejected", "duplicate":
		return "st-done"
	}
	return "st-open"
}

// tagStrip removes markup from a title used as a LABEL.
//
// Fossil technote and forum titles carry HTML — "<b>Design Note:</b>
// About the Irreversible Schema Change" is a real one. Escaping it is
// correct and safe, but it then reads as literal markup in a list. This
// strips the tags and the caller still escapes the result, so nothing
// is rendered as HTML: safe and readable rather than safe and ugly.
var tagRE = regexp.MustCompile(`<[^>]{0,200}>`)

func plainTitle(s string) string {
	return strings.TrimSpace(tagRE.ReplaceAllString(s, ""))
}

// compactCount bounds a count to four characters: 0, 19, 503, 1.1K,
// 26K, 1.2M.
//
// The nav's width was a function of its numbers, so a repository with
// 25,557 check-ins and 5,809 tickets rendered a visibly different nav
// from one with none — on a phone it wrapped to three lines. Exact
// figures belong on the page the tab leads to; the tab needs an order of
// magnitude.
func compactCount(n int) string {
	switch {
	case n < 0:
		return "0"
	case n < 1000:
		return strconv.Itoa(n)
	case n < 10000:
		// One decimal only where it distinguishes: 1.1K reads, 26.4K is
		// a character longer for precision nobody is using here.
		v := float64(n) / 1000
		out := strconv.FormatFloat(v, 'f', 1, 64)
		return strings.TrimSuffix(out, ".0") + "K"
	case n < 1000000:
		return strconv.Itoa(n/1000) + "K"
	case n < 10000000:
		v := float64(n) / 1000000
		out := strconv.FormatFloat(v, 'f', 1, 64)
		return strings.TrimSuffix(out, ".0") + "M"
	default:
		return strconv.Itoa(n/1000000) + "M"
	}
}

// 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 {
		// title carries the exact figure; the badge carries the shape.
		fmt.Fprintf(&b, ` <span class="n" title="%d">%s</span>`, n, compactCount(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())
}

// paletteCSS is every colour the site uses, in one place so the
// contrast tests can find them all. A colour defined anywhere else
// escapes those tests, which is how the highlight and the danger red
// came to be wrong in one of the three theme states.
const paletteCSS = `/* Gruvbox, after craftdesign.group: a warm cream rather than white and a
   soft near-black rather than pure black, because a forge is read for
   long stretches and maximum contrast is tiring rather than legible.
   --accent carries links and focus. Every pair here is contrast-checked
   against both the page ground and --code-bg, the secondary surface
   links also sit on; see TestPaletteContrast, which fails the build
   rather than letting a nicer-looking colour quietly drop below AA.

   Three states, not two. An explicit choice stamps data-theme on the
   root; the default "system" stamps nothing, so only the media query
   separates light from dark. Each set is therefore declared three times:
   once bare, once under the media query GUARDED against an explicit
   light choice, and once under the explicit dark choice — otherwise
   picking light on a dark-mode phone would do nothing. */
:root {
  --fg: #282828; --bg: #f3ecda; --muted: #665c54; --code-bg: #e6dcc0;
  --rule: #d5c4a1; --accent: #a04412;
  --mark: #f7dd9a; --add: #116329; --del: #a40e26;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --fg: #f0ece4; --bg: #1d2021; --muted: #a89984; --code-bg: #32302f;
    --rule: #504945; --accent: #d99058;
  --mark: #665418; --add: #56d364; --del: #ff7b72;
    --mark: #665418; --add: #56d364; --del: #ff7b72;
  }
}
:root[data-theme="dark"] {
  --fg: #f0ece4; --bg: #1d2021; --muted: #a89984; --code-bg: #32302f;
  --rule: #504945; --accent: #d99058;
}
/* The page paints its own ground: a transparent body borrows whatever
   the browser decided, which is the white flash this palette exists to
   avoid. */
html { background: var(--bg); }`

var base = template.Must(template.New("base").Funcs(template.FuncMap{
	"pathesc":     url.PathEscape,
	"navlink":     navlink,
	"ago":         ago,
	"agoShort":    agoShort,
	"plain":       plainTitle,
	"statusClass": statusClass,
	"scopes":      func() any { return searchScopes },
}).Parse(baseHTML))

var baseHTML = `<!doctype html>
<html lang="en"{{if .Theme}} data-theme="{{.Theme}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} — quarry</title>
<style>
` + paletteCSS + `
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(--rule); 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(--rule); }
/* Links carry the accent rather than inheriting the body colour: with
   inherit the only cue was the underline, and a page of prose and a page
   of links looked identical at a glance. */
a { color: var(--accent); }
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible,
textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
header { display: flex; gap: 1rem; align-items: baseline; flex-wrap: wrap;
         border-bottom: 1px solid var(--rule); 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); }
/* Search sits with the chrome now rather than under it. It takes the
   leftover width so it is the element that shrinks when the header is
   tight, which is right: a repository's name must stay readable and a
   search box is usable at any width. */
header .find { margin-left: auto; flex: 1 1 8rem; min-width: 0; max-width: 18rem; }
header .find input { width: 100%; }
form.theme button { padding: 0.3rem 0.45rem; line-height: 0; background: none;
  border-color: transparent; color: var(--muted); }
form.theme button:hover { border-color: var(--rule); color: var(--fg); }
form.theme .ic { opacity: 1; width: 1.05em; height: 1.05em; }
/* Scope pills reuse the nav's shape: the reader is choosing among the
   same sections, so they should not look like a different control. */
nav.scopes { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0.6rem 0 1rem; }
nav.scopes a { display: inline-block; padding: 0.15rem 0.6rem; border-radius: 999px;
  border: 1px solid var(--rule); text-decoration: none; font-size: 0.9rem; }
nav.scopes a.here { background: var(--code-bg); font-weight: 600; }
ul.hits { list-style: none; margin: 0; padding: 0; }
ul.hits li { padding: 0.5rem 0; border-bottom: 1px solid var(--code-bg); }
.snip { color: var(--muted); font-size: 0.9rem; overflow-wrap: anywhere; }
/* Fleet-wide results lead with the repository: the same wiki title
   exists in several stores, so a hit without its repo is ambiguous. */
.hit-repo { color: var(--muted); font-size: 0.85rem; }
.hit-repo::after { content: " /"; }
nav.scopes.facets a .n { color: var(--muted); font-size: 0.8rem; }
form.find.wide { max-width: none; display: flex; gap: 0.4rem; margin-bottom: 0.4rem; }
form.find.wide input { flex: 1 1 auto; min-width: 0; }
/* Chrome links are navigation, not references: colouring the whole nav
   with the accent would make the accent mean nothing. */
header .site, header .repo, nav a, .crumbs a, table.tree a { color: var(--fg); }
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; }
/* Icons are an SVG sprite (see iconSprite), referenced by <use>. They
   were CSS masks with inline data: URIs, which Chrome accepts and iOS
   Safari does not: the raw <, > and quotes make the URI invalid, the
   mask silently fails, and every icon renders as a solid block. A
   sprite has no encoding to get wrong. */
.sprite { display: none; }
.ic { width: 1em; height: 1em; vertical-align: -0.125em; fill: currentColor;
  opacity: 0.55; flex: none; }
/* 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(--rule);
  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; }
.tree-bar .dl { white-space: nowrap; }
/* A select is as wide as its widest option, so one long branch name set
   the control's width for every repository — 288px, most of a phone
   row, to show "trunk". The cap truncates the outlier instead; the full
   name is still in the open list, which is where it is read. */
.tree-bar select { max-width: 11rem; }
@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; }
  /* Without this the actions trail the crumbs mid-line and the row
     collapses into a ragged mixture of control, path and links. Giving
     them the full basis makes them break to their own line, so the row
     is always "branch and where you are" over "what you can do". */
  .tree-bar .tree-acts { margin-left: 0; flex-basis: 100%; }
  .tree-bar { gap: 0.5rem; }
}
/* Form controls inherit the browser's palette, not the page's: a white
   textarea on a dark page, and iOS Safari's blue pill for every button.
   They also default to content-box, so the width:100% on our textareas
   overflowed by exactly the padding and border. Both are the browser
   being reasonable in isolation and wrong here, so they are set once for
   every form rather than per-page. */
input, textarea, select, button {
  font: inherit; color: var(--fg); background: var(--bg);
  border: 1px solid var(--rule); border-radius: 6px;
  padding: 0.35rem 0.55rem; box-sizing: border-box; max-width: 100%;
}
textarea { display: block; width: 100%; resize: vertical; line-height: 1.5; }
button, input[type=submit] {
  -webkit-appearance: none; appearance: none;
  background: var(--code-bg); cursor: pointer; font-weight: 600;
}
button:hover, input[type=submit]:hover { border-color: var(--fg); }
::placeholder { color: var(--muted); opacity: 1; }
/* select keeps its native disclosure arrow — replacing it costs a
   hand-drawn caret that has to be redrawn for both themes. */
.readme-head { margin-top: 1.5rem; padding-bottom: 0.3rem;
  border-bottom: 1px solid var(--rule); font-size: 1rem; }
/* Each entry is one unit — a pill with its count inside — rather than a
   word and a loose number. Loose numbers made every repository's nav a
   different shape and, with a wide count, a different number of lines.
   "here" is a filled pill rather than a colour so it survives both
   themes and does not depend on hue perception; "empty" is dimmed but
   still a link. */
header nav { display: flex; flex-wrap: wrap; gap: 0.3rem; }
nav a { display: inline-flex; align-items: baseline; gap: 0.3rem;
  padding: 0.15rem 0.55rem; border-radius: 999px; text-decoration: none;
  border: 1px solid transparent; }
nav a:hover { border-color: var(--rule); }
nav a.here { font-weight: 600; background: var(--code-bg); border-color: var(--rule); }
nav a.empty { color: var(--muted); }
nav .n { color: var(--muted); font-size: 0.75rem; font-variant-numeric: tabular-nums;
  background: var(--code-bg); border-radius: 999px; padding: 0 0.35rem; }
nav a.here .n { background: var(--bg); }
.repo-sub { color: var(--muted); font-size: 0.9rem; margin: -0.6rem 0 1rem; }
.newrepo { margin: 0 0 0.8rem; }
.about { margin: -0.3rem 0 0.8rem; color: var(--fg); }
/* Red and green are the same two tokens the diff uses. They were
   literals with the dark pair behind an unguarded media query, so an
   explicit light choice on a dark-mode device drew the dark red on the
   light ground — the same defect the highlight had. */
.danger-head { color: var(--del); }
.danger { border: 1px solid var(--del); border-radius: 4px; padding: 0.2rem 0.9rem; }
.danger form + form { border-top: 1px solid var(--rule); margin-top: 0.6rem; }
/* The listing's numeric and date columns take only what they need, so
   the description and tip get the width instead of "Check-ins" wrapping
   into two lines beside a mostly-empty row. */
table.fleet th, table.fleet td { vertical-align: top; }
/* The name column takes the slack; the numeric ones take only what
   they need and never wrap. Right-aligned tabular figures line up down
   the page by construction — which a per-row grid could not do. */
table.fleet td.r-name { width: 100%; }
table.fleet th.n, table.fleet td.n { text-align: right; white-space: nowrap;
  width: 1%; padding-left: 0.9rem; font-variant-numeric: tabular-nums;
  color: var(--muted); }
table.fleet td.n.last { color: var(--fg); }
table.fleet th.n { padding-bottom: 0.2rem; }
/* The pair reads as one entry: no rule between a name and its
   description, the rule goes under the description instead. */
table.fleet tr.r-top td { padding-top: 0.5rem; border-top: 1px solid var(--code-bg); }
table.fleet tr.r-desc td { padding: 0 0 0.15rem; color: var(--muted);
  font-size: 0.9rem; border: 0; }
table.fleet tr.r-desc + tr.r-top td { border-top: 1px solid var(--code-bg); }
@media (max-width: 30rem) {
  /* A phone has room for the name and three numbers, not four. Mirror
     freshness is the most specialised of them and the first to go — the
     repository page still reports it. Tighter gutters, and a long name
     may wrap rather than push the table into a sideways scroll. */
  table.fleet th.n, table.fleet td.n { padding-left: 0.5rem; }
  table.fleet td.r-name { white-space: normal; overflow-wrap: anywhere; }
  table.fleet th.n:nth-child(4), table.fleet td.n:nth-child(4) { display: none; }
}
.ci-pass { border-color: var(--add); color: var(--add); }
.ci-fail { border-color: var(--del); color: var(--del); }
/* Ticket fields are label/value pairs, so a dl says what they are and
   escapes the global table rules that banded every label with a border.
   The label column sizes to its own widest label instead of a fixed
   10rem: fossil ticket schemas are arbitrary, so the widest label is
   whatever this repository's tracker defines. Values may be 40-char
   hashes, which must wrap rather than push the page sideways. */
dl.tkt-head {
  display: grid; grid-template-columns: minmax(0, max-content) minmax(0, 1fr);
  gap: 0.3rem 1rem; margin: 0.8rem 0;
}
dl.tkt-head dt { color: var(--muted); font-size: 0.9rem; }
dl.tkt-head dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
.tkt-id { overflow-wrap: anywhere; }
.tkt-body { margin: 0.5rem 0 1rem; }
.tkt-comment { border-top: 1px solid var(--rule); padding: 0.6rem 0; }
.tkt-comment:first-of-type { border-top: 0; }
.tl-badge { border: 1px solid var(--rule); border-radius: 3px; padding: 0 0.25rem; font-size: 0.72rem; }
.tl-pill { display: inline-block; border: 1px solid var(--rule); 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: var(--add); } pre.diff .del { color: var(--del); }
pre.diff .hunk { color: var(--muted); } pre.diff .head { font-weight: bold; }
/* Content can contain tokens no viewport fits — a hash, a URL, a wiki
   page literally named "checkin/2cbbabdf5ef624d809fbb40d2d312a29".
   Unbroken they push the whole page sideways, so a phone scrolls to
   read anything. break-word rather than anywhere: it breaks only when a
   word genuinely does not fit, leaving ordinary prose alone. <pre>
   keeps its own horizontal scroll, which is correct for code. */
main { overflow-wrap: break-word; }
/* A date is one token to a reader: breaking "2020-12-19T13:20:19" across
   two lines makes it unreadable and is what content-driven wrapping does
   to it by default. */
time { white-space: nowrap; }

/* A ticket list is read title-first. A table gave the 10-character id
   its own column and left the title the narrowest one on a phone; this
   gives the title the full width and demotes id/status/date to a meta
   line under it. Same shape at every width — no breakpoint to diverge. */
ul.tickets { list-style: none; margin: 0; padding: 0; }
ul.tickets li { padding: 0.6rem 0; border-bottom: 1px solid var(--code-bg); }
ul.tickets li:first-child { border-top: 1px solid var(--code-bg); }
.t-title { font-weight: 600; }
.t-meta { margin-top: 0.25rem; font-size: 0.85rem; color: var(--muted);
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; }
/* Open vs resolved by weight, not hue: the palette is four variables and
   a fifth for one badge would owe both themes a contrast check. */
.t-status { font-size: 0.7rem; padding: 0.05rem 0.5rem; border-radius: 999px;
  border: 1px solid var(--rule); }
.st-open { color: var(--fg); border-color: var(--fg); font-weight: 600; }
.st-done { color: var(--muted); }
main pre { overflow-wrap: normal; }
/* The highlight is a palette token like everything else. It was a pair
   of literals with the dark one behind an UNGUARDED media query, so a
   reader who chose light on a dark-mode device got the dark highlight
   under light text: a dark block with dark text on it. Tokens follow
   the three-state rule for free, which is the point of having them. */
mark { background: var(--mark); color: var(--fg); }
</style>
<style>` + highlight.CSS() + `</style>
<script type="module" src="/assets/datastar.js" defer></script>
</head>
<body>
<svg class="sprite" aria-hidden="true"><symbol id="i-commit" viewBox="0 0 16 16"><path d="M11.93 8.5a4.002 4.002 0 0 1-7.86 0H.75a.75.75 0 0 1 0-1.5h3.32a4.002 4.002 0 0 1 7.86 0h3.32a.75.75 0 0 1 0 1.5Zm-1.43-.75a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></symbol><symbol id="i-people" viewBox="0 0 16 16"><path d="M2 5.5a3.5 3.5 0 1 1 5.898 2.549 5.508 5.508 0 0 1 3.034 4.084.75.75 0 1 1-1.482.235 4 4 0 0 0-7.9 0 .75.75 0 0 1-1.482-.236A5.507 5.507 0 0 1 3.102 8.05 3.493 3.493 0 0 1 2 5.5ZM11 4a3.001 3.001 0 0 1 2.22 5.018 5.01 5.01 0 0 1 2.56 3.012.749.749 0 0 1-.885.954.752.752 0 0 1-.549-.514 3.507 3.507 0 0 0-2.522-2.372.75.75 0 0 1-.574-.73v-.352a.75.75 0 0 1 .416-.672A1.5 1.5 0 0 0 11 5.5.75.75 0 0 1 11 4Zm-5.5-.5a2 2 0 1 0-.001 3.999A2 2 0 0 0 5.5 3.5Z"/></symbol><symbol id="i-sync" viewBox="0 0 16 16"><path d="M1.705 8.005a.75.75 0 0 1 .834.656 5.5 5.5 0 0 0 9.592 2.97l-1.204-1.204a.25.25 0 0 1 .177-.427h3.646a.25.25 0 0 1 .25.25v3.646a.25.25 0 0 1-.427.177l-1.38-1.38A7.002 7.002 0 0 1 1.05 8.84a.75.75 0 0 1 .656-.834ZM8 2.5a5.487 5.487 0 0 0-4.131 1.869l1.204 1.204A.25.25 0 0 1 4.896 6H1.25A.25.25 0 0 1 1 5.75V2.104a.25.25 0 0 1 .427-.177l1.38 1.38A7.002 7.002 0 0 1 14.95 7.16a.75.75 0 0 1-1.49.178A5.5 5.5 0 0 0 8 2.5Z"/></symbol><symbol id="i-clock" viewBox="0 0 16 16"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm7-3.25v2.992l2.028.812a.75.75 0 0 1-.557 1.392l-2.5-1A.751.751 0 0 1 7 8.25v-3.5a.75.75 0 0 1 1.5 0Z"/></symbol><symbol id="i-dir" viewBox="0 0 16 16"><path d="M1.75 1A1.75 1.75 0 0 0 0 2.75v10.5C0 14.216.784 15 1.75 15h12.5A1.75 1.75 0 0 0 16 13.25v-8.5A1.75 1.75 0 0 0 14.25 3H7.5a.25.25 0 0 1-.2-.1l-.9-1.2C6.07 1.26 5.55 1 5 1H1.75Z"/></symbol><symbol id="i-file" viewBox="0 0 16 16"><path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 9 4.25V1.5Zm6.75.062V4.25c0 .138.112.25.25.25h2.688l-.011-.013-2.914-2.914-.013-.011Z"/></symbol><symbol id="i-sun" viewBox="0 0 16 16"><path d="M8 12a4 4 0 1 1 0-8 4 4 0 0 1 0 8Zm0-1.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Zm5.657-8.157a.75.75 0 0 1 0 1.061l-1.061 1.06a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734l1.06-1.06a.75.75 0 0 1 1.06 0Zm-9.193 9.193a.75.75 0 0 1 0 1.06l-1.06 1.061a.75.75 0 1 1-1.061-1.06l1.06-1.061a.75.75 0 0 1 1.061 0ZM8 0a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0V.75A.75.75 0 0 1 8 0ZM3 8a.75.75 0 0 1-.75.75H.75a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 3 8Zm13 0a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5A.75.75 0 0 1 16 8ZM8 13a.75.75 0 0 1 .75.75v1.5a.75.75 0 0 1-1.5 0v-1.5A.75.75 0 0 1 8 13ZM2.343 2.343a.75.75 0 0 1 1.061 0l1.06 1.061a.751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018l-1.06-1.06a.75.75 0 0 1 0-1.06Zm9.193 9.193a.75.75 0 0 1 1.06 0l1.061 1.06a.75.75 0 0 1-1.06 1.061l-1.061-1.06a.75.75 0 0 1 0-1.061Z"/></symbol><symbol id="i-moon" viewBox="0 0 16 16"><path d="M9.598 1.591a.749.749 0 0 1 .785-.175 7.001 7.001 0 1 1-8.967 8.967.75.75 0 0 1 .961-.96 5.5 5.5 0 0 0 7.046-7.046.75.75 0 0 1 .175-.786Zm1.616 1.945a7 7 0 0 1-7.678 7.678 5.499 5.499 0 1 0 7.678-7.678Z"/></symbol><symbol id="i-auto" viewBox="0 0 16 16"><path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Z"/><path d="M8 2.5v11a5.5 5.5 0 0 0 0-11Z"/></symbol></svg>
<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>
{{- /* The box searches what the reader is looking at. On the tickets
       page "search tickets" is almost always what is meant, and having
       to search everything and then filter is a step nobody wanted. The
       scope is a hidden field rather than a mode, so the URL says what
       was searched and the results page can widen it. */ -}}
<form class="find" action="/r/{{.Repo}}/search" method="get">
{{if .SearchKind}}<input type="hidden" name="kind" value="{{.SearchKind}}">{{end}}
<input type="search" name="q" value="{{.Query}}" placeholder="search {{.SearchScope}}" aria-label="search {{.SearchScope}}"></form>
{{end}}
{{if not .Repo}}
{{- /* Off a repository the same box searches the whole fleet, so the
       control is in one place wherever a reader happens to be. */ -}}
<form class="find" action="/search" method="get">
<input type="search" name="q" value="{{.Query}}" placeholder="search all repositories" aria-label="search all repositories"></form>
{{end}}
{{- /* A form, not a script: this codebase allows exactly one script and
       it is not this. Deciding the theme on the server also removes the
       flash a client-side switcher has to work around — the correct
       colours are in the first byte. */ -}}
<form class="theme" action="/theme" method="post">
<input type="hidden" name="to" value="{{.NextTheme}}">
<button type="submit" title="colour theme: {{.ThemeName}}" aria-label="colour theme: {{.ThemeName}}, switch to {{.NextTheme}}">
<svg class="ic" aria-hidden="true"><use href="#i-{{.ThemeIcon}}"/></svg></button></form>
{{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>{{if .Elapsed}} · <span data-volatile>rendered in {{.Elapsed}}{{if .Breakdown}} ({{.Breakdown}}){{end}}</span>{{end}}</footer>
</body>
</html>
`

var fleetPage = template.Must(template.Must(base.Clone()).Parse(`{{define "fleet-body"}}
<div class="tl-controls">
<form method="get" action="/"><input type="search" name="q" value="{{.Query}}" placeholder="filter" aria-label="filter repositories">
<select name="sort" onchange="this.form.submit()" aria-label="sort">
<option value="name"{{if eq .Sort "name"}} selected{{end}}>name</option>
<option value="active"{{if eq .Sort "active"}} selected{{end}}>recently active</option>
<option value="size"{{if eq .Sort "size"}} selected{{end}}>size</option>
</select> <noscript><button type="submit">go</button></noscript></form>
<span class="tl-meta-inline">{{if .FleetTotal}}{{len .Fleet}} of {{.FleetTotal}}{{end}}</span>
{{if .IsAdmin}}<span class="tree-acts"><a href="/new">New repository</a></span>{{end}}
</div>
{{if .Fleet}}<div class="tw"><table class="fleet">
{{- /* Real columns, not a grid inside one cell. CSS grid aligns within
       ONE grid; a per-row grid gives every row its own column widths, so
       nothing lines up down the page — which was the whole point. */ -}}
<thead><tr><th scope="col">Repository</th>
<th scope="col" class="n" title="check-ins"><svg class="ic" aria-hidden="true"><use href="#i-commit"/></svg></th>
<th scope="col" class="n" title="contributors"><svg class="ic" aria-hidden="true"><use href="#i-people"/></svg></th>
<th scope="col" class="n" title="last mirrored"><svg class="ic" aria-hidden="true"><use href="#i-sync"/></svg></th>
<th scope="col" class="n" title="last activity"><svg class="ic" aria-hidden="true"><use href="#i-clock"/></svg></th></tr></thead>
<tbody>{{range .Fleet}}
{{- /* Two rows per repository: the name and its numbers, then the
       description spanning the whole width beneath. Keeping the
       description in the first column made that column as wide as the
       longest sentence, which pushed the numeric columns off a phone
       screen entirely. Spanning lets it wrap against the full page and
       leaves the name column only as wide as a name. */ -}}
<tr class="r-top"><td class="r-name"><a href="/r/{{.Name}}/timeline">{{.Name}}</a>{{if .Private}} <span class="tl-badge">private</span>{{end}}</td>
<td class="n">{{if .Indexing}}<small class="tl-meta-inline">…</small>{{else}}{{.Checkins}}{{end}}</td>
<td class="n">{{if .Contributors}}{{.Contributors}}{{end}}</td>
<td class="n">{{if .MirroredAt}}{{agoShort .MirroredAt}}{{end}}</td>
<td class="n last">{{if .LastActive}}{{agoShort .LastActive}}{{end}}</td></tr>
{{if .Description}}<tr class="r-desc"><td colspan="5">{{.Description}}</td></tr>{{end}}
{{end}}</tbody>
</table></div>{{else}}<p>{{if .Query}}No repository matches “{{.Query}}”.{{else}}No repositories are being served yet.{{if .IsAdmin}} <a href="/new">Create one</a>.{{end}}{{end}}</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>{{ago .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(--rule); padding-top:0.5rem; margin-top:0.5rem">
<p><small>{{ago .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" 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></label></p>
<p><label>Post<br><textarea name="body" rows="14" 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" 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" 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}}{{plain .Comment}}{{else}}{{ago .Date}}{{end}}</a> <small>{{ago .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 {{ago .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">{{ago .At}}</small><br>{{.Text}}</p>
{{end}}</div>{{end}}
{{define "content"}}
<style>.chatlog{max-height:60vh;overflow-y:auto;border:1px solid var(--rule);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" 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></label></p>
<p><label>Status<br><input name="status" value="Open"></label></p>
<p><label>Comment<br><textarea name="comment" rows="8"></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"}}"></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" 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}}{{ago .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}}<ul class="tickets">{{range .Tickets}}
<li><a class="t-title" href="/r/{{$.Repo}}/ticket/{{.ID}}">{{.Title}}</a>
<div class="t-meta"><span class="t-status {{statusClass .Status}}">{{.Status}}</span>
<code>{{printf "%.10s" .ID}}</code> · updated {{ago .Updated}}</div></li>
{{end}}</ul>{{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">{{ago .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">{{ago .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}}>{{.Label}}</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>
<span class="dl">download <a href="/r/{{.Repo}}/archive/{{.TreeVersion}}">tar.gz</a> <a href="/r/{{.Repo}}/archive/{{.TreeVersion}}?format=zip">zip</a></span>
{{if .IsAdmin}}<a href="/r/{{.Repo}}/settings">settings</a>{{end}}</span>
</div>
{{- /* The description belongs where the repository is, not only in the
       listing: a reader arriving from a link otherwise sees nav and a
       file tree and no statement of what the project is. */ -}}
{{if or .RepoDescription .RepoPrivate}}<p class="about">{{if .RepoPrivate}}<span class="tl-badge">private</span> {{end}}{{.RepoDescription}}</p>{{end}}
{{- /* 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}}<svg class="ic" aria-hidden="true"><use href="#i-dir"/></svg><a href="/r/{{$.Repo}}/tree/{{$.TreeVersion}}/{{.Path}}">{{.Name}}</a>
{{- else}}<svg class="ic" aria-hidden="true"><use href="#i-file"/></svg><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"}}
<dl class="tkt-head">{{range .Ticket.Header}}
<dt>{{.Name}}</dt><dd>{{.Value}}</dd>{{end}}{{range .Ticket.Extra}}
<dt>{{.Name}}</dt><dd>{{.Value}}</dd>{{end}}
<dt>created</dt><dd>{{ago .Ticket.Created}}</dd>
<dt>last change</dt><dd>{{ago .Ticket.Updated}}</dd>
<dt>ticket</dt><dd><code class="tkt-id">{{printf "%.20s" .Ticket.ID}}</code></dd>
</dl>
<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">{{ago .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}} · {{ago .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}}{{ago .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}}`))

// newRepoPage is the forge's "create a repository" form. Deliberately
// the same three questions every forge asks — name, description, where
// it comes from — and NOT a filesystem path: the server places it.
var newRepoPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<h2>New repository</h2>
{{if .Error}}<p class="status"><strong>{{.Error}}</strong></p>{{end}}
<form method="post" action="/new" class="newrepo-form">
<p><label>Name<br><input name="name" required autofocus pattern="[A-Za-z0-9][A-Za-z0-9._-]*"
  title="letters, digits, dot, dash, underscore"></label></p>
<p><label>Description <small class="tl-meta-inline">optional</small><br>
  <input name="description" size="52" maxlength="300"></label></p>
<p><label><input type="checkbox" name="private" value="true"> Private
  <small class="tl-meta-inline">— only accounts with a role on it can see that it exists</small></label></p>
<fieldset><legend>Contents</legend>
<p><label><input type="radio" name="kind" value="create" checked> Start empty</label></p>
<p class="seed"><label><input type="checkbox" name="readme" value="true" checked> Add a README</label>
<label><input type="checkbox" name="ignore" value="true"> Add a .zitignore</label>
<label>License
  <select name="license"><option value="">none</option><option value="mit">MIT</option><option value="bsd-2">BSD 2-Clause</option></select></label></p>
<p><label><input type="radio" name="kind" value="clone"> Clone an existing repository</label><br>
  <input name="from" size="52" placeholder="https://fossil-scm.org/home" aria-label="repository URL"></p>
</fieldset>
<p><button type="submit">Create repository</button></p>
</form>
{{end}}`))

// quickStartPage is what an empty repository shows instead of an empty
// timeline: a repository with no check-ins is not broken, it is waiting,
// and the reader's next move is to push to it.
var quickStartPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if or .RepoDescription .RepoPrivate}}<p class="about">{{if .RepoPrivate}}<span class="tl-badge">private</span> {{end}}{{.RepoDescription}}</p>{{end}}
<p>This repository is empty.</p>
<h3>Push an existing repository</h3>
<pre>zit remote {{.CloneURL}}
zit push</pre>
<h3>Start from scratch</h3>
<pre>zit clone {{.CloneURL}} {{.Repo}}
cd {{.Repo}}
echo "# {{.Repo}}" &gt; README.md
zit add README.md
zit commit -m "first check-in"
zit push</pre>
{{end}}`))

// settingsPage is the repository's own admin screen. Description and
// visibility for now; the destructive actions belong here too when they
// exist, kept visually apart from the settings that are reversible.
var settingsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<h2>Settings</h2>
{{if .Error}}<p class="status"><strong>{{.Error}}</strong></p>{{end}}
{{if .Saved}}<p class="status">Saved.</p>{{end}}
<form method="post" action="/r/{{.Repo}}/settings">
<p><label>Description<br>
  <input name="description" size="52" maxlength="300" value="{{.RepoDescription}}"></label></p>
<p><label><input type="checkbox" name="private" value="true"{{if .RepoPrivate}} checked{{end}}> Private
  <small class="tl-meta-inline">— only accounts with a role on it can see that it exists</small></label></p>
<p><button type="submit">Save</button></p>
</form>
<h3>Clone</h3>
<pre>zit clone {{.CloneURL}} {{.Repo}}</pre>
{{- /* Kept apart, and last. Both actions here are one click from
       something the settings above are not: irreversible. */ -}}
<h3 class="danger-head">Danger zone</h3>
<div class="danger">
<form method="post" action="/r/{{.Repo}}/settings/remove">
<p><strong>Stop serving this repository.</strong> The store on disk and its
index are left alone; registering it again brings it back.</p>
<p><button type="submit">Stop serving</button></p>
</form>
{{if .Deletable}}<form method="post" action="/r/{{.Repo}}/settings/delete">
<p><strong>Delete this repository.</strong> Its store, its index, its grants,
its sync users, its mirror and its webhooks are destroyed. There is no undo.</p>
<p><label>Type <code>{{.Repo}}</code> to confirm<br>
  <input name="confirm" required autocomplete="off"></label></p>
<p><button type="submit">Delete forever</button></p>
</form>{{else}}<p class="tl-meta-inline">This repository's store was not created by quarry,
so it cannot be deleted here — stop serving it and remove the directory yourself.</p>{{end}}
</div>
{{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 busyPage = template.Must(template.Must(base.Clone()).Parse(
	`{{define "content"}}<p>The server is at capacity for this kind of request. Nothing is wrong —
	this is the limit doing its job — and the same request should succeed shortly.</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 globalSearchPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form class="find wide" action="/search" method="get">
{{if .SearchKind}}<input type="hidden" name="kind" value="{{.SearchKind}}">{{end}}
<input type="search" name="q" value="{{.Query}}" placeholder="search all repositories" aria-label="search all repositories">
<button type="submit">Search</button>
</form>
{{if .Query}}<nav class="scopes">{{range scopes}}
<a href="/search?q={{$.Query}}{{if .Kind}}&amp;kind={{.Kind}}{{end}}"{{if eq .Kind $.SearchKind}} class="here"{{end}}>{{.Label}}</a>
{{end}}</nav>
<p class="tl-meta-inline">{{len .Results}} result{{if ne (len .Results) 1}}s{{end}} across {{.Searched}} repositor{{if eq .Searched 1}}y{{else}}ies{{end}}</p>
{{- /* One repository can fill a page of results honestly — fossil really
       does have the most to say about memory leaks. The facet is how a
       reader gets to the others without retyping the query. */ -}}
{{if .ByRepo}}<nav class="scopes facets">
<a href="/search?q={{.Query}}{{if .SearchKind}}&amp;kind={{.SearchKind}}{{end}}"{{if not .RepoFilter}} class="here"{{end}}>all repositories</a>
{{range .ByRepo}}<a href="/search?q={{$.Query}}{{if $.SearchKind}}&amp;kind={{$.SearchKind}}{{end}}&amp;repo={{.Repo}}"{{if eq .Repo $.RepoFilter}} class="here"{{end}}>{{.Repo}} <span class="n">{{.Count}}</span></a>
{{end}}</nav>{{end}}{{end}}
{{if .Results}}<ul class="hits">{{range .Results}}
<li><a class="hit-repo" href="/r/{{.Repo}}">{{.Repo}}</a>
<a href="{{.URL}}">{{.Label}}</a> <small class="tl-meta-inline">{{.Kind}}</small>
<div class="snip">{{.Snippet}}</div></li>
{{end}}</ul>{{else if .Query}}<p>Nothing in {{.SearchScope}} matches “{{.Query}}”.</p>{{end}}
{{end}}`))

var searchPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<form class="find" action="/r/{{.Repo}}/search" method="get">
{{if .SearchKind}}<input type="hidden" name="kind" value="{{.SearchKind}}">{{end}}
<input type="search" name="q" value="{{.Query}}" placeholder="search {{.SearchScope}}" aria-label="search {{.SearchScope}}">
<button type="submit">Search</button>
</form>
{{- /* The scope the header chose is shown and changeable here. A reader
       who searched from the tickets page and found nothing needs to widen
       without retyping, which is one click rather than a new query. */ -}}
{{if .Query}}<nav class="scopes">{{range scopes}}
<a href="/r/{{$.Repo}}/search?q={{$.Query}}{{if .Kind}}&amp;kind={{.Kind}}{{end}}"{{if eq .Kind $.SearchKind}} class="here"{{end}}>{{.Label}}</a>
{{end}}</nav>{{end}}
{{if .Results}}<ul class="hits">{{range .Results}}
<li><a href="{{.URL}}">{{.Label}}</a> <small class="tl-meta-inline">{{.Kind}}</small>
<div class="snip">{{.Snippet}}</div></li>
{{end}}</ul>{{else if .Query}}<p>Nothing in {{.SearchScope}} matches “{{.Query}}”.</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 {{ago .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>{{ago .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
	FleetTotal int
	// Searched is how many repositories a fleet-wide search consulted;
	// ByRepo is the per-repository facet and RepoFilter the one the
	// reader has narrowed to.
	Searched    int
	ByRepo      []repoFacet
	RepoFilter  string
	Sort        string
	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
	// SearchKind and SearchScope are derived from Section by
	// searchContext, so no handler has to remember to set them.
	SearchKind  string
	SearchScope string
	// Theme is the reader's explicit colour choice, empty when they
	// have made none and the system decides. NextTheme is what the
	// switcher will move to; ThemeIcon and ThemeName describe the
	// current state.
	Theme     string
	NextTheme string
	ThemeIcon string
	ThemeName string
	Counts    navCounts
	HasCounts bool
	// RepoDescription and RepoPrivate are this repository's settings,
	// for the settings form and the About panel.
	RepoDescription string
	RepoPrivate     bool
	Saved           bool
	// Deletable is whether quarry created this repository's store and
	// may therefore destroy it.
	Deletable bool
	// Elapsed is how long this request took to render, reported the way
	// fossil reports it ("This page was generated in about 0.003s").
	// Publishing it is what made the timeline regression measurable
	// against fossil at all, so it stays on the page rather than in a
	// header only a developer would look at.
	Elapsed string
	// Breakdown says which layer the time went to ("zit 147ms, sql 3ms").
	// One number tells a reader the page is slow; it does not say whether
	// to look at the store, the index, or the template.
	Breakdown string
	// 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"`
	Description  string `json:"description"`
	Private      bool   `json:"private"`
	LastActive   string `json:"lastActive"`
	Deletable    bool   `json:"deletable"`
	Contributors int    `json:"contributors"`
	MirroredAt   string `json:"mirroredAt"`
	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])
}

// labelBranches fills each row's selector label. zit allows two
// branches to share a name, so a name is not always enough to tell the
// options apart — but it almost always is, and appending a hash to every
// one made the control as wide as its widest hash for no gain. The hash
// appears only on the names that actually repeat.
func labelBranches(rows []branchRow) {
	seen := map[string]int{}
	for _, b := range rows {
		seen[b.Name]++
	}
	for i, b := range rows {
		rows[i].Label = b.Name
		if seen[b.Name] > 1 {
			rows[i].Label = b.Name + " · " + b.Tip[:min(10, len(b.Tip))]
		}
	}
}

// localRef is the Referer reduced to a path on this origin, or "/". A
// Referer is attacker-controllable, so only its path and query survive;
// anything naming another host, or a protocol-relative "//evil" that a
// browser would read as one, becomes the home page.
func localRef(req *http.Request) string {
	ref := req.Referer()
	if ref == "" {
		return "/"
	}
	u, err := url.Parse(ref)
	if err != nil || u.Path == "" || !strings.HasPrefix(u.Path, "/") ||
		strings.HasPrefix(u.Path, "//") {
		return "/"
	}
	out := u.Path
	if u.RawQuery != "" {
		out += "?" + u.RawQuery
	}
	return out
}

// themeCookie holds an explicit colour choice. Absent means "follow the
// system", which is the default and stores nothing — a reader who never
// touches the control is never given a cookie.
const themeCookie = "theme"

// themeCycle is the order the switcher moves through. Three states, not
// two: with only light and dark, a reader who wanted "follow my phone"
// back would have no way to ask for it.
var themeCycle = []string{"", "light", "dark"}

// themeState describes one point in the cycle for the template.
func themeState(theme string) (next, icon, name string) {
	i := slices.Index(themeCycle, theme)
	if i < 0 {
		i = 0
	}
	next = themeCycle[(i+1)%len(themeCycle)]
	switch theme {
	case "light":
		return next, "sun", "light"
	case "dark":
		return next, "moon", "dark"
	}
	return next, "auto", "system"
}

// searchScopeName is searchContext's inverse: the word for a kind, used
// where the kind is already known and the section is not.
func searchScopeName(kind string) (string, string) {
	for _, section := range []string{"timeline", "wiki", "ticket", "forum", "technote"} {
		if k, scope := searchContext(section); k == kind && kind != "" {
			return k, scope
		}
	}
	return "", "everything"
}

// searchScopes are the choices offered on the results page, in the
// order the nav uses so the two surfaces agree.
var searchScopes = []struct{ Kind, Label string }{
	{"", "everything"}, {"checkin", "check-ins"}, {"wiki", "wiki"},
	{"ticket", "tickets"}, {"forum", "forum"}, {"technote", "technotes"},
}

// searchContext maps a nav section to the search kind it means and the
// word for it. A section with nothing indexed — files, ci, chat — falls
// through to searching everything rather than to an empty scope that
// would silently find nothing.
func searchContext(section string) (kind, scope string) {
	switch section {
	case "timeline":
		return "checkin", "check-ins"
	case "wiki":
		return "wiki", "wiki"
	case "ticket":
		return "ticket", "tickets"
	case "forum":
		return "forum", "forum"
	case "technote":
		return "technote", "technotes"
	}
	return "", "everything"
}

// 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"`
	// Label is what the selector shows. Usually just the name: the hash
	// was in every option, which made the control as wide as its widest
	// hash on every repository and told a reader nothing they chose by.
	// It comes back only for a name two branches share, which is the
	// one case where the name alone does not identify the choice.
	Label string `json:"-"`
}

// 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
}

// repoFacet is one repository's share of a fleet-wide result set.
type repoFacet struct {
	Repo  string
	Count int
}

type searchHit struct {
	// Repo is set only for fleet-wide results, where a hit is
	// meaningless without saying which repository it came from.
	Repo    string
	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) api.Decision

// 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()
	switch verify(repo, user, pass, level) {
	case api.Allowed:
	case api.Insufficient:
		// Authenticated and short of the capability: a challenge would
		// invite the reader to type the same password again.
		forbid(w, user, repo, level)
		return nil, false
	default:
		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.
// isEmptyRepo reports whether a registered repository simply has no
// check-ins yet, as opposed to not existing.
func isEmptyRepo(r *ops.Registry, req *http.Request, repo string) bool {
	out, err := invoke(req.Context(), r, "repo.counts", url.Values{"repo": {repo}})
	if err != nil {
		return false // unknown repository: a real not-found
	}
	var c struct {
		Checkins int `json:"checkins"`
	}
	remarshal(out, &c)
	return c.Checkins == 0
}

// deletable reports whether quarry created this repository's store, so
// the settings page can offer deletion only where it would work rather
// than offering it and failing.
func deletable(r *ops.Registry, req *http.Request, repo string) bool {
	out, err := invoke(req.Context(), r, "repo.list", nil)
	if err != nil {
		return false
	}
	var rl struct {
		Repos []repoInfo `json:"repos"`
	}
	remarshal(out, &rl)
	for _, x := range rl.Repos {
		if x.Name == repo {
			return x.Deletable
		}
	}
	return false
}

// 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)
		})
	}
	mux.HandleFunc("POST /theme", func(w http.ResponseWriter, req *http.Request) {
		to := req.FormValue("to")
		if !slices.Contains(themeCycle, to) {
			http.Error(w, "unknown theme", http.StatusBadRequest)
			return
		}
		c := &http.Cookie{Name: themeCookie, Value: to, Path: "/",
			HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 400 * 24 * 60 * 60}
		if to == "" {
			// "Follow the system" is the absence of a choice, so it
			// removes the cookie rather than storing a third value.
			c.MaxAge, c.Expires = -1, time.Unix(0, 0)
		}
		http.SetCookie(w, c)
		// Back to the page the reader was on. Only a same-site path is
		// honoured: an open redirect here would let any page bounce a
		// reader off this origin through a control they expect to be
		// inert.
		http.Redirect(w, req, localRef(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) {
		q, sortBy := req.URL.Query().Get("q"), req.URL.Query().Get("sort")
		if sortBy == "" {
			sortBy = "name"
		}
		out, err := invoke(req.Context(), r, "repo.list",
			url.Values{"q": {q}, "sort": {sortBy}, "limit": {"100"}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fl struct {
			Repos []repoInfo `json:"repos"`
			Total int        `json:"total"`
		}
		remarshal(out, &fl)
		data := pageData{Title: "quarry", Fleet: fl.Repos, FleetTotal: fl.Total,
			Query: q, Sort: sortBy}
		if n != nil {
			data.Live = true
			// The stream has to carry the reader's view, not just the
			// URL. A repaint re-renders the whole listing, so a stream
			// that does not know the filter replaces what the reader
			// asked for with the default a second after it appears —
			// which reads as the filter not working at all.
			live := url.Values{}
			if q != "" {
				live.Set("q", q)
			}
			// The default sort stays out of the URL: including it would
			// make every plain home page hook a parameterised stream
			// for no difference in what it shows.
			if sortBy != "" && sortBy != "name" {
				live.Set("sort", sortBy)
			}
			sse := "/fleet.sse"
			if len(live) > 0 {
				sse += "?" + live.Encode()
			}
			data.LiveAttr = template.HTMLAttr(`data-init="@get('` + template.JSEscapeString(sse) + `')"`)
		}
		render(r, w, req, fleetPage, data)
	})
	settingsFor := func(req *http.Request, repo string) (desc string, private bool) {
		out, err := invoke(req.Context(), r, "repo.get", url.Values{"repo": {repo}})
		if err != nil {
			return "", false
		}
		var info repoInfo
		remarshal(out, &info)
		return info.Description, info.Private
	}
	mux.HandleFunc("GET /r/{repo}/settings", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
		if !ok {
			return
		}
		desc, private := settingsFor(req, repo)
		render(r, w, req, settingsPage, pageData{
			Title: repo, Repo: repo, RepoDescription: desc, RepoPrivate: private,
			CloneURL: cloneURL(req, repo), Deletable: deletable(r, req, repo),
		})
	})
	mux.HandleFunc("POST /r/{repo}/settings/remove", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "repo.remove", url.Values{"name": {repo}}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/", http.StatusSeeOther)
	})
	mux.HandleFunc("POST /r/{repo}/settings/delete", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
		if !ok {
			return
		}
		_, err := invoke(req.Context(), r, "repo.delete",
			url.Values{"name": {repo}, "confirm": {req.FormValue("confirm")}})
		if err != nil {
			desc, private := settingsFor(req, repo)
			renderStatus(r, w, req, settingsPage, pageData{
				Title: repo, Repo: repo, RepoDescription: desc, RepoPrivate: private,
				CloneURL: cloneURL(req, repo), Deletable: deletable(r, req, repo),
				Error: err.Error(),
			}, http.StatusBadRequest)
			return
		}
		http.Redirect(w, req, "/", http.StatusSeeOther)
	})
	mux.HandleFunc("POST /r/{repo}/settings", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
		if !ok {
			return
		}
		form := url.Values{"name": {repo}, "description": {req.FormValue("description")}}
		if req.FormValue("private") == "true" {
			form.Set("private", "true")
		}
		data := pageData{Title: repo, Repo: repo, CloneURL: cloneURL(req, repo),
			RepoDescription: req.FormValue("description"),
			RepoPrivate:     req.FormValue("private") == "true"}
		if _, err := invoke(req.Context(), r, "repo.update", form); err != nil {
			// The form comes back holding what the reader typed, with
			// the reason — not an error page that discards it.
			data.Error = err.Error()
			renderStatus(r, w, req, settingsPage, data, http.StatusBadRequest)
			return
		}
		data.Saved = true
		render(r, w, req, settingsPage, data)
	})
	mux.HandleFunc("GET /new", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
		if !ok {
			return
		}
		render(r, w, req, newRepoPage, pageData{Title: "new repository"})
	})
	mux.HandleFunc("POST /new", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
		if !ok {
			return
		}
		form := url.Values{
			"name":        {req.FormValue("name")},
			"description": {req.FormValue("description")},
		}
		if req.FormValue("private") == "true" {
			form.Set("private", "true")
		}
		if req.FormValue("kind") == "clone" {
			form.Set("clone", req.FormValue("from"))
		} else {
			form.Set("create", "true")
			// Seeding applies to a new repository only; a clone arrives
			// with its own history and the op refuses the combination.
			if req.FormValue("readme") == "true" {
				form.Set("readme", "true")
			}
			if req.FormValue("ignore") == "true" {
				form.Set("ignore", "true")
			}
			form.Set("license", req.FormValue("license"))
		}
		out, err := invoke(req.Context(), r, "repo.add", form)
		if err != nil {
			// A rejected form comes BACK as a form with the reason on it,
			// not as an error page: the reader has a correction to make,
			// and an error page throws their input away.
			renderStatus(r, w, req, newRepoPage,
				pageData{Title: "new repository", Error: err.Error()}, http.StatusBadRequest)
			return
		}
		var added struct {
			Name string `json:"name"`
		}
		remarshal(out, &added)
		http.Redirect(w, req, "/r/"+added.Name, http.StatusSeeOther)
	})
	// 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 {
			// A repository with no check-ins has an unborn trunk, so the
			// listing legitimately finds nothing — and answering 404
			// tells its owner the repository does not exist, moments
			// after they created it. Distinguish the two by asking the
			// index whether this repository is merely empty.
			if isEmptyRepo(r, req, repo) {
				desc, private := settingsFor(req, repo)
				render(r, w, req, quickStartPage, pageData{
					Title: repo, Repo: repo, CloneURL: cloneURL(req, repo),
					RepoDescription: desc, RepoPrivate: private,
				})
				return
			}
			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
						}
					}
				}
			}
			labelBranches(data.Branches)
		}
		// An empty repository is not a broken one — it is waiting for a
		// first push. Showing a bare file table says nothing about what
		// to do next, which is the only thing its reader wants to know.
		data.RepoDescription, data.RepoPrivate = settingsFor(req, repo)
		if len(data.Tree) == 0 && len(data.Crumbs) == 0 {
			render(r, w, req, quickStartPage, pageData{
				Title: repo, Repo: repo, CloneURL: cloneURL(req, repo),
				RepoDescription: data.RepoDescription, RepoPrivate: data.RepoPrivate,
			})
			return
		}
		// 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 /search", func(w http.ResponseWriter, req *http.Request) {
		q := req.URL.Query().Get("q")
		kind := req.URL.Query().Get("kind")
		if kind != "" && !slices.Contains(index.SearchKinds, kind) {
			fail(r, w, req, fmt.Errorf("no such search scope %q: %w", kind, ops.ErrNotFound))
			return
		}
		_, scope := searchScopeName(kind)
		only := req.URL.Query().Get("repo")
		data := pageData{Title: "search", Query: q, SearchKind: kind, SearchScope: scope,
			RepoFilter: only}
		if q != "" {
			vals := url.Values{"q": {q}}
			if kind != "" {
				vals.Set("kind", kind)
			}
			if only != "" {
				vals.Set("repo", only)
			}
			out, err := invoke(req.Context(), r, "search.all", vals)
			if err != nil {
				fail(r, w, req, err)
				return
			}
			var sr struct {
				Results []struct {
					Repo    string `json:"repo"`
					Kind    string `json:"kind"`
					Ref     string `json:"ref"`
					Label   string `json:"label"`
					Snippet string `json:"snippet"`
				} `json:"results"`
				Searched int `json:"searched"`
				ByRepo   []struct {
					Repo  string `json:"repo"`
					Count int    `json:"count"`
				} `json:"by_repo"`
			}
			remarshal(out, &sr)
			data.Searched = sr.Searched
			for _, b := range sr.ByRepo {
				data.ByRepo = append(data.ByRepo, repoFacet{Repo: b.Repo, Count: b.Count})
			}
			for _, res := range sr.Results {
				h := hitOf(res.Repo, res.Kind, res.Ref, res.Label, res.Snippet)
				h.Repo = res.Repo
				data.Results = append(data.Results, h)
			}
		}
		render(r, w, req, globalSearchPage, data)
	})
	mux.HandleFunc("GET /r/{repo}/search", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		q := req.URL.Query().Get("q")
		kind := req.URL.Query().Get("kind")
		if kind != "" && !slices.Contains(index.SearchKinds, kind) {
			// An unknown scope is a bad URL, not an empty result: saying
			// "no results" would blame the query for a typo in the link.
			fail(r, w, req, fmt.Errorf("no such search scope %q: %w", kind, ops.ErrNotFound))
			return
		}
		_, scope := searchScopeName(kind)
		data := pageData{Title: "search", Repo: repo, Query: q,
			SearchKind: kind, SearchScope: scope}
		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")
		format := req.URL.Query().Get("format")
		out, err := invoke(req.Context(), r, "archive.get", url.Values{
			"repo": {repo}, "version": {req.PathValue("version")}, "format": {format},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var ad struct {
			Name    string `json:"name"`
			Version string `json:"version"`
			Content string `json:"content"`
		}
		remarshal(out, &ad)
		raw, err := base64.StdEncoding.DecodeString(ad.Content)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		ctype := "application/gzip"
		if strings.HasSuffix(ad.Name, ".zip") {
			ctype = "application/zip"
		}
		// The archive is a pure function of a resolved check-in and zit
		// guarantees the bytes are reproducible, so this entity can
		// never change and the ETag is the check-in itself. A reader who
		// already has it gets a 304 and downloads nothing — which is
		// worth more than any server-side cache, because the bytes never
		// leave the machine at all.
		etag := `"` + ad.Version + "-" + format + `"`
		w.Header().Set("ETag", etag)
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
		if match := req.Header.Get("If-None-Match"); match == etag {
			w.WriteHeader(http.StatusNotModified)
			return
		}
		w.Header().Set("Content-Type", ctype)
		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 withTimer(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) {
	// Only the listing's own parameters are forwarded; anything else in
	// the query string is a caller's invention and must not reach an op.
	view := url.Values{}
	for _, k := range []string{"q", "sort"} {
		if v := req.URL.Query().Get(k); v != "" {
			view.Set(k, v)
		}
	}
	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, view)
		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, view url.Values) (string, error) {
	out, err := invoke(context.Background(), r, "repo.list", view)
	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,
		Query: view.Get("q"), Sort: view.Get("sort")}); 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
	if c, err := req.Cookie(themeCookie); err == nil && (c.Value == "light" || c.Value == "dark") {
		data.Theme = c.Value
	}
	data.NextTheme, data.ThemeIcon, data.ThemeName = themeState(data.Theme)
	if start, ok := req.Context().Value(ctxStart{}).(time.Time); ok {
		data.Elapsed = timing.Format(time.Since(start))
	}
	// Read the breakdown LAST: the ops this page invoked have all run by
	// now, so the accumulator is complete.
	data.Breakdown = timing.From(req.Context()).Breakdown()
	// 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)
		// Derived in the one place every page passes through, so a new page
		// gets a correctly scoped search box without knowing it exists. A
		// handler that already set a scope keeps it: the results page holds
		// the scope the reader is actually searching in, which is not the
		// one its own section would imply.
		if data.SearchScope == "" {
			data.SearchKind, data.SearchScope = searchContext(data.Section)
		}
		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).
// fail renders an op error, mapping the op layer's error kinds the way
// the API router already does. Only ErrNotFound was mapped, so every
// other kind rendered as 500 — and a 500 tells a client the request
// failed, not that it should try again. Two paths depended on the
// distinction and lost it: a single-writer lease refusal (0001 §3),
// which is explicitly retryable, and a shed from a saturated work lane,
// which is retryable by construction.
func fail(r *ops.Registry, w http.ResponseWriter, req *http.Request, err error) {
	switch {
	case errors.Is(err, ops.ErrNotFound):
		renderStatus(r, w, req, notFoundPage, pageData{Title: "not found"}, http.StatusNotFound)
	case errors.Is(err, ops.ErrInvalid):
		renderStatus(r, w, req, errorPage, pageData{Title: "bad request"}, http.StatusBadRequest)
	case errors.Is(err, ops.ErrUnavailable):
		// Retry-After makes the refusal actionable rather than merely
		// polite: a client that backs off is what keeps a shed from
		// becoming a retry storm.
		w.Header().Set("Retry-After", "5")
		renderStatus(r, w, req, busyPage, pageData{Title: "busy"}, http.StatusServiceUnavailable)
	default:
		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 ctxStart struct{}

// withTimer stamps request start so the layout can report render time.
// Outermost of the middlewares, so the number covers everything the
// server does for the request, not just the part after auth.
func withTimer(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		ctx, _ := timing.With(req.Context())
		next.ServeHTTP(w, req.WithContext(
			context.WithValue(ctx, ctxStart{}, time.Now())))
	})
}

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()
		}
	}
}