Skip to content
quarry Artifact [8b10a065f3]
Login

Artifact [8b10a065f3]

Artifact 8b10a065f3db6a9e9fb4d78dff8cd4725df923675b6b0cb51adf437dcfac3cac:


// 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 (
	"runtime/debug"

	"bytes"
	"context"
	_ "embed"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"hash/fnv"
	"html/template"
	"io"
	"log"
	"net/http"
	"net/url"
	"reflect"
	"regexp"
	"slices"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/diff"
	"craftdesign.group/quarry/internal/errlog"
	"craftdesign.group/quarry/internal/highlight"
	"craftdesign.group/quarry/internal/index"
	"craftdesign.group/quarry/internal/legal"
	"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

//go:embed assets/composer.js
var composerJS []byte

// The mark, embedded like everything else quarry serves: the privacy
// page states that these pages load nothing from another host, and an
// icon fetched from a CDN would make that untrue.
//
// favicon.ico carries 16, 32 and 48 in one file so the browser's
// unprompted /favicon.ico request is answered properly rather than
// 404ing and falling back to a letter.
//
//go:embed assets/favicon.ico
var faviconICO []byte

//go:embed assets/apple-touch-icon.png
var appleTouchIcon []byte

//go:embed assets/quarry.png
var markPNG []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.
// weekday is the UTC day-of-week of a stored date — the same
// convention zit's --weekday prints (zit#111), computed from the same
// UTC timestamps, so the CLI and the forge always name the same day.
func weekday(s string) string {
	for _, layout := range []string{"2006-01-02T15:04:05.000", "2006-01-02T15:04:05",
		"2006-01-02T15:04:05.999999999", "2006-01-02"} {
		if t, err := time.Parse(layout, s); err == nil {
			return t.UTC().Format("Mon")
		}
	}
	return ""
}

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:
		// Once the day is no longer today, NAMING it adds what the
		// count alone cannot — "Thu · 3 days ago" answers "which day
		// was that" in place. The zit#111 convention, forge-wide: UTC
		// weekday, so the CLI and these pages never disagree.
		//
		// The count is CALENDAR days, not elapsed-hours/24: naming the
		// weekday exposed the rounding — a Tuesday event 55 hours before
		// a Friday page read "Tue · 2 days ago", and Tuesday is not two
		// days before Friday in any human's reckoning.
		day := func(x time.Time) time.Time {
			return time.Date(x.Year(), x.Month(), x.Day(), 0, 0, 0, 0, time.UTC)
		}
		days := int(day(now).Sub(day(t)).Hours() / 24)
		if days < 1 {
			days = 1
		}
		return t.Format("Mon") + " · " + plural(days, "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("Mon · 2 Jan")
		}
		return t.Format("Mon · 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))
}

// boardColumn is one board lane: the label as the tracker spells it,
// and the tickets currently in it.
// boardMove is one card move as the board reports it afterwards: what
// moved, where, and where it came from so undo is one post.
type boardMove struct {
	ID, Title, Field, From, To string
}

type boardColumn struct {
	Status  string
	Tickets []ticketSummary
	// Expanded: drawn at full width; otherwise a strip. Folding is the
	// reader's choice, carried in the URL (?fold=A,B) so it survives a
	// reload and can be linked; with no choice yet, empty columns fold.
	// ToggleURL is this view with this column's fold flipped — the
	// chevron in the header, both ways.
	Expanded  bool
	ToggleURL string
}

// boardFields are the ticket fields a board may be grouped by. The
// WORDS for each come from the repository's vocabulary (ticket.config:
// .quarry/tickets.json over fossil's defaults), passed in by the
// handler — the vocabulary is the project's, not this table's.
//
// The vocabulary draws lanes that hold NO tickets. A board whose
// columns are only the values already in use is a report, not a board:
// quarry's own tracker had only ever written "Open", so it drew one
// column and the move control could only move a ticket to where it
// already was. Observed values outside the vocabulary still get their
// own lanes rather than being dropped.
var boardFields = []struct {
	Key, Label string
}{
	{"status", "status"},
	{"priority", "priority"},
	{"severity", "severity"},
	{"type", "type"},
	{"subsystem", "subsystem"},
}

func boardAxis(key string) bool {
	for _, f := range boardFields {
		if f.Key == key {
			return true
		}
	}
	return false
}

// fieldOf reads one groupable field off a summary.
func fieldOf(t ticketSummary, key string) string {
	switch key {
	case "priority":
		return t.Priority
	case "severity":
		return t.Severity
	case "type":
		return t.Type
	case "subsystem":
		return t.Subsystem
	default:
		return t.Status
	}
}

// boardData fetches the tickets and lays out the board. The page and the
// move endpoint share it so a drag and a reload cannot disagree about
// what the board looks like.
func boardData(r *ops.Registry, req *http.Request, repo string, closed bool,
	group, lane string) (pageData, error) {
	vals := url.Values{"repo": {repo}, "limit": {strconv.Itoa(boardLimit)}}
	if closed {
		vals.Set("closed", "1")
	}
	out, err := invoke(req.Context(), r, "ticket.list", vals)
	if err != nil {
		return pageData{}, err
	}
	var tl struct {
		Tickets []ticketSummary `json:"tickets"`
	}
	remarshal(out, &tl)
	if !boardAxis(group) {
		group = "status"
	}
	// An explicit lane choice is honoured, including "none". Otherwise
	// pick one that actually cuts this set: a board whose lane field
	// every ticket shares spends a row of chrome saying nothing.
	switch {
	case lane == "none":
		lane = ""
	case lane == "":
		// No lanes unless asked: a board that opens with four priority
		// swimlanes nobody chose is logic the reader cannot see.
		lane = ""
	default:
		if !boardAxis(lane) {
			lane = ""
		}
	}
	vocab := ticketVocabFor(r, req, repo)
	board := buildBoard(tl.Tickets, group, lane, vocab)
	// Which columns are folded is the reader's choice, carried in the
	// URL so it survives a reload and can be linked. Before any choice
	// is made the empty columns fold, which is what keeps seven status
	// columns inside the window on the default (open) view. Each column
	// gets the URL that flips its own state, for the chevron.
	q := req.URL.Query()
	folded := map[string]bool{}
	explicit := q.Has("fold")
	for _, c := range strings.Split(q.Get("fold"), ",") {
		if c = strings.TrimSpace(c); c != "" {
			folded[strings.ToLower(c)] = true
		}
	}
	base := url.Values{"view": {"board"}, "group": {group}}
	if closed {
		base.Set("closed", "1")
	}
	if lane != "" {
		base.Set("lane", lane)
	} else {
		base.Set("lane", "none")
	}
	for li := range board {
		for ci := range board[li].Columns {
			col := &board[li].Columns[ci]
			key := strings.ToLower(col.Status)
			if explicit {
				col.Expanded = !folded[key]
			} else {
				col.Expanded = len(col.Tickets) > 0
			}
			// The fold list after flipping this column; when nothing was
			// explicit yet, the implicit folds become explicit so the
			// other columns keep their state.
			next := map[string]bool{}
			for _, l := range board[li].Columns {
				k := strings.ToLower(l.Status)
				if explicit {
					next[k] = folded[k]
				} else {
					next[k] = len(l.Tickets) == 0
				}
			}
			next[key] = col.Expanded
			var list []string
			for _, l := range board[li].Columns {
				if next[strings.ToLower(l.Status)] {
					list = append(list, l.Status)
				}
			}
			v := url.Values{}
			for k, vs := range base {
				v[k] = vs
			}
			v.Set("fold", strings.Join(list, ","))
			col.ToggleURL = "?" + v.Encode()
		}
	}
	data := pageData{Title: "tickets", Repo: repo, ShowClosed: closed,
		BoardView: true, GroupBy: group, LaneBy: lane, Vocab: vocab,
		Shown: len(tl.Tickets), Truncated: len(tl.Tickets) >= boardLimit,
		Board:        board,
		ColumnLabels: columnLabels(tl.Tickets, group, vocab),
	}
	for _, f := range boardFields {
		data.AxisFields = append(data.AxisFields,
			struct{ Key, Label string }{f.Key, f.Label})
	}
	return data, nil
}

// ticketVocabFor asks ticket.config for this repository's vocabulary.
// On ANY failure it answers with the defaults: the vocabulary shapes
// ordering and suggestions, so the wrong words are an inconvenience
// while an error page here would make a typo in .quarry/tickets.json
// take the whole tracker surface down.
func ticketVocabFor(r *ops.Registry, req *http.Request, repo string) map[string][]string {
	out, err := invoke(req.Context(), r, "ticket.config", url.Values{"repo": {repo}})
	if err != nil {
		return api.DefaultTicketVocabulary
	}
	var cfg struct {
		Vocab []struct {
			Field string   `json:"field"`
			Words []string `json:"words"`
		} `json:"vocab"`
		Problem string `json:"problem"`
	}
	remarshal(out, &cfg)
	if cfg.Problem != "" {
		log.Printf("web: %s: %s ignored: %s", repo, ".quarry/tickets.json", cfg.Problem)
	}
	if len(cfg.Vocab) == 0 {
		return api.DefaultTicketVocabulary
	}
	vocab := make(map[string][]string, len(cfg.Vocab))
	for _, f := range cfg.Vocab {
		vocab[f.Field] = f.Words
	}
	return vocab
}

// boardLimit bounds what one board draws. It is the op's own ceiling:
// a board is a working surface, not the tracker's archive, and 729 open
// tickets in seven columns is a scroll, not a plan.
const boardLimit = 200

// triageRank orders a vocabulary word by its position in the board
// field's word list; words the vocabulary does not know sort after it,
// and an unset value after those — triage you have not done yet is not
// more urgent than triage you have.
func triageRank(vocabAll map[string][]string, field, value string) int {
	vocab := vocabAll[field]
	v := strings.TrimSpace(value)
	if v == "" {
		return len(vocab) + 1
	}
	for i, w := range vocab {
		if strings.EqualFold(w, v) {
			return i
		}
	}
	return len(vocab)
}

// byTriage sorts a page of summaries priority-first, severity as the
// tiebreak, then OLDEST first. It sorts the PAGE, not the tracker: the
// list op pages by updated, so this is only offered on the first page,
// where it covers everything up to the op's limit.
//
// Recency is deliberately not in the key. A ticket's position in the
// priority view should change only when a human changes its triage —
// with updated as the tiebreak, commenting on any ticket reshuffled its
// whole tier, and a bulk edit scrambled every tier at once. Equal triage
// is instead a queue: the ticket that has waited longest is first, so
// the tail of each tier is not a race to have been touched last.
func byTriage(ts []ticketSummary, vocab map[string][]string) {
	sort.SliceStable(ts, func(i, j int) bool {
		if a, b := triageRank(vocab, "priority", ts[i].Priority), triageRank(vocab, "priority", ts[j].Priority); a != b {
			return a < b
		}
		if a, b := triageRank(vocab, "severity", ts[i].Severity), triageRank(vocab, "severity", ts[j].Severity); a != b {
			return a < b
		}
		if ts[i].Created != ts[j].Created {
			return ts[i].Created < ts[j].Created
		}
		return ts[i].ID < ts[j].ID
	})
}

// unsetLane labels the column or lane for tickets the tracker left
// blank. It is not a value any tracker writes, so it cannot collide.
const unsetLane = "unset"

// boardLane is one horizontal swimlane: a label and the same column set
// every other lane has, so the board reads as a grid.
type boardLane struct {
	Label   string
	Count   int
	Columns []boardColumn
}

// buildBoard arranges tickets into columns (the workflow, read left to
// right) crossed by swimlanes (the cut, read top to bottom).
//
// A kanban with only columns answers "what stage is this in". The lane
// is what makes it answer "and which of these actually matters" without
// filtering anything out of view — the reason a board beats a list.
//
// laneKey == "" means one implicit lane holding everything.
func buildBoard(tickets []ticketSummary, groupKey, laneKey string, vocab map[string][]string) []boardLane {
	if !boardAxis(groupKey) {
		groupKey = "status"
	}
	if groupKey == laneKey {
		laneKey = "" // crossing a field with itself yields a diagonal
	}
	// Every lane gets the SAME columns, including columns that are empty
	// in that lane: a grid whose rows have different columns is not a
	// grid, and a card cannot be dropped into a lane that isn't drawn.
	labels := columnLabels(tickets, groupKey, vocab)
	if laneKey == "" {
		return []boardLane{{Count: len(tickets), Columns: fill(tickets, groupKey, labels)}}
	}
	byLane := map[string][]ticketSummary{}
	var order []string
	for _, want := range laneValues(tickets, laneKey, vocab) {
		byLane[want] = nil
		order = append(order, want)
	}
	for _, t := range tickets {
		v := strings.TrimSpace(fieldOf(t, laneKey))
		if v == "" {
			v = unsetLane
		}
		byLane[v] = append(byLane[v], t)
	}
	out := make([]boardLane, 0, len(order))
	for _, lane := range order {
		ts := byLane[lane]
		// A lane nothing is in is noise, not an affordance: cards move
		// between COLUMNS, so an empty lane can never be a drop target.
		if len(ts) == 0 {
			continue
		}
		out = append(out, boardLane{Label: lane, Count: len(ts),
			Columns: fill(ts, groupKey, labels)})
	}
	return out
}

// laneValues orders the lane labels: vocabulary order for words the
// tracker's vocabulary knows, then the rest by size, unset last.
func laneValues(tickets []ticketSummary, key string, vocabAll map[string][]string) []string {
	vocab := vocabAll[key]
	rank, count := map[string]int{}, map[string]int{}
	label := map[string]string{}
	for i, w := range vocab {
		rank[strings.ToLower(w)] = i
	}
	for _, t := range tickets {
		v := strings.TrimSpace(fieldOf(t, key))
		if v == "" {
			v = unsetLane
		}
		k := strings.ToLower(v)
		count[k]++
		if label[k] == "" || v < label[k] {
			label[k] = v
		}
	}
	keys := make([]string, 0, len(count))
	for k := range count {
		keys = append(keys, k)
	}
	sort.Slice(keys, func(i, j int) bool {
		ui, uj := keys[i] == unsetLane, keys[j] == unsetLane
		if ui != uj {
			return uj
		}
		ri, ok1 := rank[keys[i]]
		rj, ok2 := rank[keys[j]]
		if ok1 != ok2 {
			return ok1
		}
		if ok1 && ri != rj {
			return ri < rj
		}
		if count[keys[i]] != count[keys[j]] {
			return count[keys[i]] > count[keys[j]]
		}
		return keys[i] < keys[j]
	})
	out := make([]string, len(keys))
	for i, k := range keys {
		out[i] = label[k]
	}
	return out
}

// columnLabels is the column set, in the order the board draws it.
//
// Grouping is case-folded because a tracker's vocabulary drifts: quarry's
// own store holds both "Open" and "open", which ungrouped would draw two
// columns where there is one. The label shown is the spelling that
// appears most often, so the board reads the way the tracker writes —
// except for a column that is empty, which can only be spelled the way
// the vocabulary spells it.
func columnLabels(tickets []ticketSummary, key string, vocabAll map[string][]string) []string {
	vocab := vocabAll[key]
	spellings := map[string]map[string]int{}
	rank, count := map[string]int{}, map[string]int{}
	add := func(v string) map[string]int {
		k := strings.ToLower(strings.TrimSpace(v))
		if spellings[k] == nil {
			spellings[k] = map[string]int{}
			rank[k] = len(vocab)
		}
		return spellings[k]
	}
	for i, w := range vocab {
		add(w)[w]++
		rank[strings.ToLower(w)] = i
	}
	for _, t := range tickets {
		v := strings.TrimSpace(fieldOf(t, key))
		if v == "" {
			v = unsetLane
		}
		add(v)[v]++
		count[strings.ToLower(v)]++
	}
	keys := make([]string, 0, len(spellings))
	for k := range spellings {
		keys = append(keys, k)
	}
	sort.Slice(keys, func(i, j int) bool {
		ui, uj := keys[i] == unsetLane, keys[j] == unsetLane
		if ui != uj {
			return uj
		}
		if rank[keys[i]] != rank[keys[j]] {
			return rank[keys[i]] < rank[keys[j]]
		}
		if count[keys[i]] != count[keys[j]] {
			return count[keys[i]] > count[keys[j]]
		}
		return keys[i] < keys[j]
	})
	out := make([]string, len(keys))
	for i, k := range keys {
		best := -1
		for spelling, n := range spellings[k] {
			if n > best || (n == best && spelling < out[i]) {
				out[i], best = spelling, n
			}
		}
	}
	return out
}

// fill drops tickets into a fixed column set.
func fill(tickets []ticketSummary, key string, labels []string) []boardColumn {
	at := map[string]int{}
	cols := make([]boardColumn, len(labels))
	for i, l := range labels {
		cols[i] = boardColumn{Status: l}
		at[strings.ToLower(strings.TrimSpace(l))] = i
	}
	for _, t := range tickets {
		v := strings.TrimSpace(fieldOf(t, key))
		if v == "" {
			v = unsetLane
		}
		if i, ok := at[strings.ToLower(v)]; ok {
			cols[i].Tickets = append(cols[i].Tickets, t)
		}
	}
	return cols
}

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

// initials is an avatar without an avatar store. quarry keeps no images
// for people and fetching a gravatar would send every reader's identity
// to a third party on every page, so the account button shows one or two
// letters from the name it already has.
func initials(name string) string {
	fields := strings.FieldsFunc(name, func(r rune) bool {
		return r == ' ' || r == '.' || r == '-' || r == '_' || r == '@'
	})
	switch len(fields) {
	case 0:
		return "?"
	case 1:
		r := []rune(fields[0])
		if len(r) == 1 {
			return strings.ToUpper(string(r))
		}
		return strings.ToUpper(string(r[:2]))
	default:
		return strings.ToUpper(string([]rune(fields[0])[:1]) + string([]rune(fields[1])[:1]))
	}
}

// tipcol is one icon column header. Deliberately NOT a popover: the
// Popover API is click-driven, so using it as a tooltip meant a click
// target that also carried a native title, giving two competing
// tooltips and a question-mark cursor. There is no standard hover
// tooltip primitive — popover="hint" is Chrome-only — so this does the
// thing that needs no primitive: the label is VISIBLE wherever the
// table has room for it, and the icon carries the native title where it
// does not. Nothing is hidden behind a hover a touch reader cannot do.
func tipcol(icon, label string) template.HTML {
	return template.HTML(fmt.Sprintf(
		`<th scope="col" class="n"><span class="colhead" data-tip="%s">`+
			`<svg class="ic" aria-hidden="true"><use href="#i-%s"/></svg>`+
			`<span class="collabel">%s</span></span></th>`,
		label, icon, template.HTMLEscapeString(label)))
}

// countTag renders a count compactly with its exact value in a title,
// so a column's width comes from its shape rather than its largest
// repository.
func countTag(n int) template.HTML {
	if n == 0 {
		return ""
	}
	return template.HTML(fmt.Sprintf(`<span title="%d">%s</span>`, n, compactCount(n)))
}

// 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.
// thousands renders 54108 as 54,108 — a position in a diff wants the
// exact number, not the compact one the counts use.
func thousands(n int) string {
	s := strconv.Itoa(n)
	if len(s) <= 3 {
		return s
	}
	var b strings.Builder
	for i, c := range s {
		if i > 0 && (len(s)-i)%3 == 0 {
			b.WriteByte(',')
		}
		b.WriteRune(c)
	}
	return b.String()
}

// dur renders an interval in the unit it was meant in: 3600 is "1h",
// 86400 is "24h", 90 is "90s".
func dur(seconds int) string {
	switch {
	case seconds%86400 == 0 && seconds >= 86400*2:
		return strconv.Itoa(seconds/86400) + "d"
	case seconds%3600 == 0 && seconds >= 3600:
		return strconv.Itoa(seconds/3600) + "h"
	case seconds%60 == 0 && seconds >= 60:
		return strconv.Itoa(seconds/60) + "m"
	}
	return strconv.Itoa(seconds) + "s"
}

// hostOf is the host of a URL, for a table that has no room for more.
func hostOf(u string) string {
	if p, err := url.Parse(u); err == nil && p.Host != "" {
		return p.Host
	}
	return u
}

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, aux ...int) template.HTML {
	var class []string
	if section == current {
		class = append(class, "here")
	}
	// aux is a fold TIER: tier-1 tabs fold into the "…" menu first as
	// the bar narrows, tier-3 last; untiered tabs never fold. Graduated
	// rather than all-at-once, so a landscape phone keeps six tabs and
	// hides two instead of losing five to a single breakpoint.
	if len(aux) > 0 && aux[0] > 0 && aux[0] < 9 {
		class = append(class, fmt.Sprintf("aux%d", aux[0]))
	}
	// Tier 9 never folds by width, but YIELDS its place when a drawer
	// tab surfaces as the active section on the narrowest rows.
	if len(aux) > 0 && aux[0] == 9 {
		class = append(class, "yield")
	}
	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 section == current {
		// The underline is its own element so the view transition can
		// morph IT alone: naming the whole tab morphed the text, and
		// two different-sized labels scale-zoomed into each other. Two
		// 2px bars morph as a clean sideways slide.
		b.WriteString(`<span class="ink" aria-hidden="true"></span>`)
	}
	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; --uname-l: 32%;
  --mark: #f7dd9a; --add: #116329; --del: #a40e26;
  --add-row: #dafbe1; --del-row: #ffebe9; --add-mark: #aceebb; --del-mark: #ffcecb;
  /* --surface is the neutral wash under cards and the top bar. It is a
     warm grey at 10% alpha rather than a colour per theme: one value,
     and it tints whatever ground it lands on instead of fighting it.
     --code-bg stays opaque, because code needs a solid block. */
  --surface: #a899841a;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --fg: #f0ece4; --bg: #1d2021; --muted: #bdb6ab; --code-bg: #32302f;
    --rule: #504945; --accent: #d99058; --uname-l: 70%;
  --mark: #665418; --add: #56d364; --del: #ff7b72;
  --add-row: #203326; --del-row: #3e2727; --add-mark: #22492c; --del-mark: #5f2f2d;
  --surface: #a899841a;
    --mark: #665418; --add: #56d364; --del: #ff7b72;
  --add-row: #203326; --del-row: #3e2727; --add-mark: #22492c; --del-mark: #5f2f2d;
    --surface: #a899841a;
  }
}
:root[data-theme="dark"] {
  --fg: #f0ece4; --bg: #1d2021; --muted: #bdb6ab; --code-bg: #32302f;
  --rule: #504945; --accent: #d99058; --uname-l: 70%;
}
/* 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); }
/* The rubber-band area past the top of the page painted --bg while the
   bar above it is --surface, so overscrolling showed a stripe of the
   wrong colour behind the header. Refusing the scroll chain is the
   direct fix and does not require painting html a colour that would
   then be wrong at the other end. */
html, body { overscroll-behavior: none; }
/* iOS Safari "text autosizing" inflates text inside wide scrollable
   blocks — code in a phone-width pre rendered LARGER than the prose
   around it. 100% keeps every font at its declared size; this is the
   documented opt-out, not a workaround. */
html { -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }`

var base = template.Must(template.New("base").Funcs(template.FuncMap{
	"pathesc":  url.PathEscape,
	"queryesc": url.QueryEscape,

	"splitrows": splitRows,
	"plural": func(n int, one, many string) string {
		if n == 1 {
			return one
		}
		return many
	},
	"navlink":   navlink,
	"ago":       ago,
	"weekday":   weekday,
	"identicon": identicon,
	"spark":     sparkSVG,
	"userhue":   userHue,
	"spectrum":  spectrumSVG,
	"votectx": func(repo, root, hash string, score, yourVote int) voteCtx {
		return voteCtx{Repo: repo, Root: root, Hash: hash, Score: score, YourVote: yourVote}
	},
	"composerctx": func(repo string, rows int, placeholder string) composerCtx {
		return composerCtx{Repo: repo, Rows: rows, Placeholder: placeholder}
	},
	"descctx": func(repo string, rows int, value string) composerCtx {
		return composerCtx{Repo: repo, Rows: rows, Placeholder: "Describe the ticket (Markdown)", Name: "description", Value: value}
	},
	"remarkctx": func(repo string, rows int, placeholder string) composerCtx {
		return composerCtx{Repo: repo, Rows: rows, Placeholder: placeholder, Name: "comment", Optional: true}
	},
	"agoShort":    agoShort,
	"plain":       plainTitle,
	"statusClass": statusClass,
	"initials":    initials,
	"tipcol":      tipcol,
	"count":       countTag,
	"thousands":   thousands,
	"fieldOf":     fieldOf,
	"unsetLane":   func() string { return unsetLane },
	"dur":         dur,
	"hostOf":      hostOf,
	"add":         func(a, b int) int { return a + b },
	"commentFirst": func(s string) string {
		first, _, _ := strings.Cut(strings.TrimSpace(s), "\n")
		return commentText(first)
	},
	"sub": func(a, b int) int { return a - b },
	"docIcon": func(label string) string {
		switch strings.ToLower(label) {
		case "readme":
			return "i-book"
		case "license", "copying":
			return "i-law"
		case "security":
			return "i-shield"
		case "contributing":
			return "i-people"
		}
		return "i-file"
	},
	"docRowLabel": func(label, license string) string {
		if strings.EqualFold(label, "license") && license != "" {
			return license + " license"
		}
		return label
	},
	"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, viewport-fit=cover">
{{- /* "quarry — quarry" was what the aggregate page called itself. */ -}}
<title>{{.Title}}{{if ne .Title "quarry"}} — quarry{{end}}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
{{- /* The mark at 512px, absolute because a scraper fetches it from its
       own machine. summary, not summary_large_image: the Q is a square
       logo, and the wide card would letterbox it into a thin band. */ -}}
<meta property="og:site_name" content="quarry">
<meta property="og:type" content="website">
{{- /* "timeline" alone names no repository; a shared link has to say
       which one. The repo landing page titles itself with the repo,
       which would otherwise read "sqlite — sqlite". */ -}}
<meta property="og:title" content="{{if and .Repo (ne .Repo .Title)}}{{.Repo}} — {{end}}{{.Title}}">
<meta property="og:url" content="{{.Origin}}{{.Path}}" data-volatile="content">
{{if .OGDesc}}<meta property="og:description" content="{{.OGDesc}}">{{end}}
<meta property="og:image" content="{{.Origin}}/assets/quarry.png" data-volatile="content">
<meta property="og:image:width" content="512">
<meta property="og:image:height" content="512">
<meta property="og:image:alt" content="quarry">
<meta name="twitter:card" content="summary">
<style>
` + paletteCSS + `
body { color: var(--fg); background: var(--bg); font: 16px/1.5 system-ui, sans-serif;
       margin: 0; padding: 0; }
/* The bars span the viewport; their contents, and the page below them,
   share one column. --page is the single place that column's width is
   decided. */
:root { --page: 76rem; }
main { max-width: var(--page); margin: 0 auto; padding-left: 1rem; padding-right: 1rem; }
/* Room for the fixed footer, so the last line of a page is readable. */
/* One source for the footer's height, so the space reserved for it and
   the bar itself cannot disagree. The gap is deliberate: clearing the
   footer by a hair still reads as content running into it. */
:root { --footer-h: 2.25rem; }
body { padding-bottom: calc(var(--footer-h) + 2rem + env(safe-area-inset-bottom)); }
/* Centred rather than baseline-aligned, so the two halves sit on one
   optical line inside a bar of fixed height. */
footer { font-size: 0.78rem; }
/* One line, never two: the page reserves a fixed height for this bar, so
   a bar that wraps overlaps the content it was supposed to sit below.
   The left half truncates instead — version and render time are
   diagnostics, and they are what a narrow screen can best afford to
   lose. The links never shrink. */
footer .bar { display: flex; flex-wrap: nowrap; gap: 1rem; align-items: center;
  min-height: var(--footer-h); padding-top: 0.2rem; padding-bottom: 0.2rem; }
.foot-l { min-width: 0; display: flex; white-space: nowrap; }
/* The timing never shrinks and never truncates; the two ahead of it do,
   and drop out entirely when even truncated they would crowd it. */
.f-time { flex: none; }
.f-brand, .f-ver { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
@media (max-width: 34rem) { .f-brand { display: none; } }
@media (max-width: 26rem) { .f-ver { display: none; } }
.foot-r { flex: none; }
.foot-r { margin-left: auto; display: flex; gap: 0.9rem; white-space: nowrap; }
details > summary { cursor: pointer; padding: 0.4rem 0; }
pre.licence { max-height: 24rem; overflow: auto; font-size: 0.8rem; white-space: pre-wrap; }
/* The bars use the viewport, not the content column: forgejo puts the
   forge name at the far left and the account at the far right, which
   reads as chrome. Content stays in the column below them. */
.bar { padding-left: 1rem; padding-right: 1rem; }
.repobar .bar { max-width: var(--page); margin: 0 auto; }
main { padding-top: 1rem; }
/* Present to the document and to a screen reader, absent to the eye:
   a page still needs one h1 even when repeating it on screen would say
   nothing a reader cannot already see. */
/* Explore tabs: the same pill vocabulary as the repo nav, so the two
   navigations do not look like different systems. */
nav.tabs { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0 0 1rem; }
nav.tabs a { display: inline-flex; align-items: center; gap: 0.35rem;
  padding: 0.3rem 0.7rem; border-radius: 999px; text-decoration: none;
  border: 1px solid transparent; color: var(--fg); }
nav.tabs a:hover { border-color: var(--rule); }
nav.tabs a.here { background: var(--surface); border-color: var(--rule); font-weight: 600; }
nav.tabs .n { color: var(--muted); font-size: 0.75rem; font-variant-numeric: tabular-nums; }
ul.people { list-style: none; margin: 0; padding: 0; }
ul.people li { display: flex; align-items: center; gap: 0.6rem;
  padding: 0.5rem 0; border-bottom: 1px solid var(--code-bg); }
.avatar.sm { width: 1.75rem; height: 1.75rem; font-size: 0.65rem; margin: 0; flex: none; }
.p-main { min-width: 0; }
.p-meta { color: var(--muted); font-size: 0.85rem; }
.person-head { display: flex; align-items: center; gap: 0.9rem; margin-bottom: 1rem; }
.person-head h1 { margin: 0; }
.avatar.lg { width: 3rem; height: 3rem; font-size: 1rem; margin: 0; flex: none; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.topbar { background: var(--surface); border-bottom: 1px solid var(--rule); }
/* Three columns rather than a flex row: the search belongs in the
   middle of the bar, and centring it with auto margins fights the
   avatar's own push to the right. The side columns take equal width so
   the box is centred on the VIEWPORT, not on whatever is left after the
   forge name — which would move it every time the name changed. */
.topbar .bar { display: grid; grid-template-columns: 1fr minmax(7rem, 34rem) 1fr;
  gap: 0.75rem; align-items: center; padding-top: 0.75rem; padding-bottom: 0.75rem; }
.topbar .site { justify-self: start; }
.bar-acts { justify-self: end; display: flex; align-items: center; gap: 0.5rem; }
.topbar .find { justify-self: stretch; }
.repobar { border-bottom: 1px solid var(--rule); }
/* One row, always: the collapse exists precisely so the bar never has
   to wrap. The repository name yields (truncating with an ellipsis)
   before the tabs do — the tabs are where the reader is going. */
.repobar .bar { display: flex; gap: 0.75rem; align-items: baseline; flex-wrap: nowrap;
  padding-top: 0.6rem; padding-bottom: 0.4rem; min-width: 0; overflow: clip; }
/* Fixed and full width, like the bar at the top: the page scrolls under
   it. body carries matching bottom padding so the last line of content
   is never hidden behind it. */
footer { position: fixed; bottom: 0; left: 0; right: 0; z-index: 5;
         background: var(--surface); backdrop-filter: blur(6px);
         color: var(--muted); border-top: 1px solid var(--rule); margin: 0;
         /* .bar owns the inner padding. The bottom inset is the iPhone
            home-indicator strip: with Safari's chrome collapsed it
            overlays a bottom:0 bar, and the render time sat under it.
            viewport-fit=cover (the meta tag) is what makes the env()
            non-zero there; everywhere else it is 0 and inert. */
         padding: 0 0 env(safe-area-inset-bottom) 0; }
table { border-collapse: collapse; width: 100%; }
th, td { text-align: left; padding: 0.35rem 0.75rem 0.35rem 0; vertical-align: top; }
/* An action cell is a row of buttons, not a stack of forms. */
td.acts { display: flex; gap: 0.4rem; align-items: center; padding-top: 0.25rem; }
td.acts form { display: contents; }
/* A link dressed as the buttons beside it, for actions that are a
   page (edit) rather than a post. */
td.acts a.btn { display: inline-block; background: var(--code-bg); font-weight: 600; font: inherit; font-weight: 600;
  color: var(--fg); text-decoration: none; border: 1px solid var(--rule); border-radius: 6px;
  padding: 0.3rem 0.6rem; line-height: 1.2; }
td.acts a.btn:hover { border-color: var(--fg); }
td.acts button, td.acts a.btn { white-space: nowrap; }
/* The mirrors table: host-only upstream, one-line truncated error, and
   a kebab disclosure for the actions that are not "pull". */
.m-up a { text-decoration: none; }
.m-state .m-err { display: block; max-width: 22rem; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; color: var(--muted); }
tr.m-failing .m-state strong { color: var(--accent); }
.kebab-btn { font-weight: 700; line-height: 1.2; padding: 0.3rem 0.55rem; }
/* Without anchor positioning the row menu falls back to the account
   menu's corner; with it, it hangs off its own button. */
@supports (anchor-name: --a) {
  .menu.km { top: anchor(bottom); right: anchor(right); margin-top: 0.3rem; }
}
form.confirm { margin-top: 1rem; }
/* The rows list: GitHub's list-row shape. Wide, a row is a grid of
   main | actions with the meta line under the name; narrow, the same
   row stacks and the actions drop beneath. Problems get a left rule
   in the failure colour so they read before they are read. */
.fleet-head { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem 1rem; margin: 0.2rem 0 0.8rem; }
.fleet-sum { margin: 0; flex: 1 1 14rem; }
.fleet-bulk { display: flex; gap: 0.4rem; }
.ci-fail-text { color: var(--accent); }
ul.rows { list-style: none; margin: 0 0 1rem; padding: 0; border-top: 1px solid var(--rule); }
ul.rows > li.row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0.3rem 1rem;
  align-items: start; padding: 0.6rem 0.2rem; border-bottom: 1px solid var(--rule); }
ul.rows > li.row-problem { border-left: 3px solid var(--accent); padding-left: 0.6rem; }
.row-main { min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 0.45rem; }
.row-name { font-weight: 600; text-decoration: none; }
.row-meta { flex-basis: 100%; color: var(--muted); font-size: 0.85rem; overflow-wrap: anywhere; }
.row-meta .n { font-variant-numeric: tabular-nums; }
.row-err { flex-basis: 100%; color: var(--muted); font-size: 0.85rem; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
.row-acts { display: flex; gap: 0.4rem; align-items: center; white-space: nowrap; }
.row-acts form { display: contents; }
.row-acts button, .row-acts a.btn { font-size: 0.85rem; padding: 0.3rem 0.6rem; }
.row-acts a.btn { display: inline-block; background: var(--code-bg); font: inherit; font-size: 0.85rem; font-weight: 600;
  color: var(--fg); text-decoration: none; border: 1px solid var(--rule); border-radius: 6px; line-height: 1.2; }
.tl-badge.m-failing, .tl-badge.ci-fail { border-color: var(--accent); color: var(--accent); }
.tl-badge.m-lagging { color: var(--muted); }
details.rows-fold > summary { cursor: pointer; color: var(--muted); padding: 0.5rem 0.2rem; }
@media (max-width: 40rem) {
  ul.rows > li.row { grid-template-columns: minmax(0, 1fr); }
  .row-acts { justify-content: flex-start; }
  .fleet-bulk { width: 100%; }
  .fleet-bulk form { flex: 1; display: contents; }
  .fleet-bulk button { flex: 1; }
  /* The page must never scroll sideways; a wide table scrolls inside
     its own box. */
  main { overflow-x: clip; }
}
/* A labelled control is a column: label over input. Side-by-side put
   the trailing unit on its own line and made the row unreadable. */
form.addform { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: end;
  margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--rule); }
form.addform label { display: flex; flex-direction: column; gap: 0.2rem;
  font-size: 0.85rem; color: var(--muted); min-width: 0; }
form.addform label.grow { flex: 1 1 16rem; }
form.addform input { color: var(--fg); }
form.rowform { margin: 0.75rem 0 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 ring says "here you are", not "look at this". The accent made
   every tab stop shout in the brand colour; --muted is neutral and
   still clears WCAG 1.4.11's 3:1 against both grounds (8.2:1 dark,
   5.5:1 light) — where --rule, the obvious "lighter" choice, is 1.9:1
   and would not legally be an indicator at all. */
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible,
textarea:focus-visible { outline: 2px solid var(--muted); outline-offset: 1px; }
.topbar .site { font-weight: bold; text-decoration: none; display: inline-flex; align-items: center; }
/* The wordmark: QUARRY set in Iosevka Fixed Semibold, traced to
   outlines so no reader downloads a font for six letters. The Q's tail
   descends below the baseline by design — the viewBox keeps it. The
   phone keeps only the round mark. */
/* The box is CAP HEIGHT only — the Q's tail overflows below it, as in
   the source design, so the mark centres on the letters and the tail
   costs no layout space. */
/* Cap height equals the ring's outer diameter: the mark is 1.75rem
   over a 24-unit box whose ring spans 17.2 units (r 7.2 + half the
   2.8 stroke each side), so 1.75 x 17.2/24. */
.topbar .site .wordmark { height: 1.254rem; width: auto; display: block;
  margin-left: 0.45rem; overflow: visible; align-self: center; }
@media (max-width: 34rem) { .topbar .site .wordmark { display: none; } }
/* Sized in CSS as well as in the attributes: the attributes reserve the
   space before the image arrives (no reflow), the CSS keeps it right if
   the asset is ever replaced at another resolution. */
.topbar .site .mark { width: 1.75rem; height: 1.75rem; display: block; }
/* 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. */
.repobar .repo { font-weight: bold; font-size: 1.15rem; text-decoration: none;
  min-width: 3ch; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The posture badge: a chip beside the name, GitHub's "forked from"
   shape. failing paints its border, nothing else — the notice below
   the bar carries the words. */
.repobar .posture { display: inline-flex; align-items: center; gap: 0.3rem; flex: none;
  font-size: 0.8rem; color: var(--muted); text-decoration: none; border: 1px solid var(--rule);
  border-radius: 999px; padding: 0.1rem 0.55rem; white-space: nowrap; }
.repobar .posture:hover { color: var(--fg); border-color: var(--muted); }
.repobar .posture-failing { border-color: var(--accent); color: var(--fg); }
.repobar .posture-warn { font-size: 0.85rem; color: var(--muted); padding-top: 0; padding-bottom: 0.4rem; }
/* Narrow: the chip keeps its icon and drops its words — the bar's one
   line belongs to the nav, and the title attribute still says it all. */
@media (max-width: 48rem) { .repobar .posture .words { display: none; }
  .repobar .posture { padding: 0.2rem 0.4rem; } }
/* Explore names the posture in a word, the same chip family as
   "private": a glyph alone read as decoration. */
.fleet .r-name .tl-badge.posture-badge { margin-left: 0.35rem; 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. */
.topbar .find { min-width: 0; margin: 0; }
.topbar .find input { width: 100%; }
/* The avatar is initials, not an image: quarry has no avatar store and
   inventing one to fetch a gravatar would send every reader's identity
   to a third party. */
/* A tooltip for controls that are genuinely icon-only, after Basecoat:
   a ::after carrying content: attr(data-tip), positioned against the
   trigger, no script at all.

   Two deliberate departures from Basecoat. It shows on :focus-visible
   as well as :hover — Basecoat explicitly hides its tooltip for
   keyboard users, which makes an icon-only control unnameable without a
   mouse. And it is anchored where the browser can do it, so an
   overflow:hidden ancestor cannot clip it; the absolute fallback is
   what Basecoat does everywhere.

   What this still cannot do is touch: there is no hover on a phone.
   popover=hint with interestfor handles hover, focus AND long-press
   declaratively, but Safari does not implement it, so it cannot be the
   mechanism. That is why visible labels remain the default everywhere
   there is room, and this is reserved for controls whose meaning is
   also carried by aria-label. */
[data-tip] { position: relative; }
[data-tip]::after {
  content: attr(data-tip);
  position: absolute; z-index: 60; bottom: calc(100% + 0.35rem); left: 50%;
  translate: -50% 0.25rem;
  padding: 0.15rem 0.45rem; border: 1px solid var(--rule); border-radius: 5px;
  background: var(--bg); color: var(--fg);
  font-size: 0.75rem; font-weight: normal; white-space: nowrap;
  opacity: 0; visibility: hidden; pointer-events: none;
  transition: opacity 100ms ease, translate 100ms ease, visibility 100ms;
}
[data-tip]:hover::after {
  opacity: 1; visibility: visible; translate: -50% 0;
}
@media (prefers-reduced-motion: reduce) { [data-tip]::after { transition: none; } }
/* No anchor positioning here, on evidence: the implicit-anchor form
   resolved a column off inside table layout (Chrome, fleet headers),
   and a NAMED anchor on a class of trigger resolves every tip to the
   last trigger on the page. Absolute against the trigger is the only
   arrangement that cannot point at the wrong thing. Its cost is
   clipping under overflow ancestors, which each placement below pays
   deliberately instead of accidentally. */

/* A column header names itself when the table is wide enough to say it,
   and falls back to the icon alone when it is not. The label is real
   text, so it is readable by touch, by a screen reader and by anyone
   who does not know the glyph. */
.colhead { display: inline-flex; align-items: center; gap: 0.35rem; justify-content: flex-end; }
/* Narrow: visually hidden, NOT display:none — the label is the header's
   accessible name, and none would silence it for a screen reader. The
   tooltip carries it for sighted pointer users instead. Wide: the label
   is visible, so the tooltip would repeat it and stays off. */
.collabel { position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
  font-weight: normal; color: var(--muted); font-size: 0.8rem; }
.colhead[data-tip]::after { bottom: auto; top: calc(100% + 0.35rem);
  left: auto; right: 0; translate: 0 -0.25rem; }
.colhead[data-tip]:hover::after { translate: 0 0; }
@media (min-width: 56rem) {
  .collabel { position: static; width: auto; height: auto; overflow: visible;
    clip-path: none; }
  .colhead[data-tip]::after { display: none; }
}
.avatar { flex: none; width: 2.25rem; height: 2.25rem; padding: 0;
  border-radius: 999px; background: var(--bg); border: 1px solid var(--rule);
  font-size: 0.75rem; font-weight: 700; cursor: pointer; display: grid; place-items: center; }
.avatar:hover { border-color: var(--fg); }
.avatar .idn { width: 78%; height: 78%; border-radius: 0; }
button.avatar[popovertarget="acct"] { anchor-name: --avatar; justify-self: end; }
.menu {
  /* One inset declaration, not a shorthand plus overrides: the
     shorthand resets the sides the longhands set, which is how this
     first landed pinned to the top of the viewport. */
  position: fixed; inset: 3.25rem 1rem auto auto; margin: 0;
  min-width: 11rem; padding: 0.4rem; border: 1px solid var(--rule); border-radius: 8px;
  background: var(--bg); color: var(--fg);
  /* display is deliberately NOT set here. The UA stylesheet closes a
     popover with [popover]:not(:popover-open) { display: none }, and an
     author rule beats it — declaring display:flex on the base left the
     menu laid out and hit-testable at opacity 0, an invisible 185x111
     region swallowing clicks on every page whether or not it had ever
     been opened. It is set only in the open state below. */
  flex-direction: column; gap: 0.1rem;
}
/* Anchored to the avatar where the browser can do it, so the menu keeps
   its relationship to the button at any viewport; the fixed corner above
   is the fallback and lands in the same place at these sizes. */
@supports (anchor-name: --a) {
  .menu { position-anchor: --avatar; inset: auto; top: anchor(bottom); right: anchor(right);
    margin-top: 0.4rem; }
}
/* The menu grows out of the button rather than appearing. allow-discrete
   is what lets display participate, so the transition survives the
   popover being display:none between states. */
.menu {
  opacity: 0; transform: translateY(-0.25rem) scale(0.97); transform-origin: top right;
  transition: opacity 120ms ease, transform 120ms ease, overlay 120ms allow-discrete,
              display 120ms allow-discrete;
}
.menu:popover-open { display: flex; opacity: 1; transform: none; }
@starting-style {
  .menu:popover-open { opacity: 0; transform: translateY(-0.25rem) scale(0.97); }
}
@media (prefers-reduced-motion: reduce) { .menu { transition: none; } }
.menu a, .menu button {
  /* border-box, or "width: 100%" means 100% PLUS the padding and every
     row hangs over the menu's right edge — which reads as a clipped,
     scrolling box even when the list is one item long. */
  display: flex; align-items: center; gap: 0.4rem; width: 100%; box-sizing: border-box;
  /* A button's UA font is smaller than the page's; in a menu every
     row must read the same, whatever element it is. */
  font: inherit;
  padding: 0.35rem 0.5rem; border: 0; border-radius: 5px; background: none;
  color: var(--fg); text-decoration: none; text-align: left; font-weight: normal;
}
.menu a:hover, .menu button:hover { background: var(--surface); }
.menu form { margin: 0; }
.menu-who { padding: 0.35rem 0.5rem 0.5rem; color: var(--muted); font-size: 0.85rem;
  border-bottom: 1px solid var(--rule); margin-bottom: 0.2rem; }
/* Without the Popover API the menu renders as a plain inline row rather
   than vanishing: losing the popover is a cosmetic loss, losing the way
   to sign out is not. */
@supports not selector(:popover-open) {
  .avatar { display: none; }
  .menu { position: static; display: flex; flex-direction: row; align-items: center;
    margin-left: auto; border: 0; padding: 0; min-width: 0; gap: 0.5rem; }
  .menu a, .menu button { width: auto; padding: 0.15rem 0.4rem; }
  .menu-who { border: 0; margin: 0; padding: 0; }
}
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(--surface); 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. */
.topbar .site, .repobar .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(--surface); 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) {
  /* main carries the page's horizontal padding now, not body. This was
     "body { padding: 0.5rem }" — a shorthand, which silently reset the
     bottom reserve the fixed footer depends on and let content run
     underneath it on exactly the screens where that hurts most. */
  main { padding-left: 0.5rem; padding-right: 0.5rem; }
  .bar { padding-left: 0.5rem; padding-right: 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; }
/* GitHub's middle column: the last check-in to touch each entry, muted,
   one line, eating the slack between name and age. Its collapse is
   GitHub's too — the message yields on a phone, the age stays. The
   size column belongs to the no-data layout; GitHub's listing never
   had one, and the message is worth more. */
table.tree.hc { width: 100%; }
table.tree.hc td.t-name { white-space: nowrap; max-width: 22rem;
  overflow: hidden; text-overflow: ellipsis; padding-right: 1.25rem; }
td.t-change { color: var(--muted); max-width: 0; width: 100%;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  padding-right: 1rem; }
td.t-change a { color: var(--muted); }
td.t-change a:hover { color: var(--accent); }
td.t-age { color: var(--muted); text-align: right; white-space: nowrap; }
@media (max-width: 44rem) {
  td.t-change { display: none; }
}
table.tree td { padding: 0.15rem 0; border: 0; line-height: 1.45; }
table.tree tr:hover { background: var(--surface); }
/* 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; }
/* The icon and the name are one label, so they get a gap rather than
   sitting flush: without it the glyph reads as the first character of
   the filename. */
td.t-name { white-space: nowrap; }
td.t-name .ic { margin-right: 0.45rem; }
/* 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; }
/* A strip of repository facts, the way forgejo leads a repo page. Each
   is a link to the surface that explains it. */
.repo-stats { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; margin: 0 0 0.75rem;
  padding: 0.5rem 0.75rem; background: var(--surface); border: 1px solid var(--rule);
  border-radius: 8px; font-size: 0.9rem; }
.repo-stats a { display: inline-flex; align-items: center; gap: 0.4rem;
  color: var(--fg); text-decoration: none; }
.repo-stats a:hover { text-decoration: underline; }
.refbtn, .dlbtn { display: inline-flex; align-items: center; gap: 0.4rem; cursor: pointer; }
.refname { max-width: 12rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.caret { color: var(--muted); font-size: 0.7em; }
/* Hug the content; only long ref lists scroll, and their scrolling does
   not chain to the page behind them. */
.menu.refs, .menu.dlmenu { position: fixed; inset: auto; max-height: 60vh; overflow-y: auto;
  overscroll-behavior: contain; scrollbar-width: thin; min-width: 13rem; }
.menu.refs a.here, .menu.dlmenu a.here { background: var(--surface); font-weight: 600; }
@supports (anchor-name: --a) {
  .refbtn { anchor-name: --refbtn; }
  .menu.refs { position-anchor: --refbtn; top: anchor(bottom); left: anchor(left); margin-top: 0.3rem; }
  .dlbtn { anchor-name: --dlbtn; }
  .menu.dlmenu { position-anchor: --dlbtn; top: anchor(bottom); right: anchor(right); margin-top: 0.3rem; }
}
/* basecoat's input-group, same as the search field: ONE bordered shell
   owns the border and the radius; the segments inside are borderless
   with hairline separators. Per-segment borders put a radius at every
   seam, and the corners never quite meet. The tool choice is still a
   URL (?clone=), so it survives a reload and can be linked to. */
.clonebox { display: inline-flex; align-items: stretch; min-width: 0;
  border: 1px solid var(--rule); border-radius: 6px; background: var(--bg);
  overflow: hidden; }
.clonebox:focus-within { outline: 2px solid var(--muted); outline-offset: 1px; }
.clonepick { display: inline-flex; align-items: center; font-size: 0.8rem;
  border-right: 1px solid var(--rule); background: var(--surface); }
.clonepick a { padding: 0.2rem 0.55rem; text-decoration: none; color: var(--muted);
  align-self: stretch; display: inline-flex; align-items: center; }
.clonepick a.here { background: var(--bg); color: var(--fg); font-weight: 600; }
.cloneurl { font-family: ui-monospace, monospace; font-size: 0.8rem; min-width: 0; width: 22rem;
  border: 0; border-radius: 0; background: none; }
.cloneurl:focus-visible { outline: none; }
.copybtn { border: 0; border-left: 1px solid var(--rule); border-radius: 0;
  background: none; padding: 0 0.5rem; cursor: pointer; }
/* Tree chrome: one row carrying branch, crumbs and clone. */
.tree-bar { display: flex; gap: 0.75rem; align-items: center; flex-wrap: nowrap;
  margin-bottom: 0.6rem; min-width: 0; }
.tree-bar .crumbs { font-weight: 600; min-width: 3ch; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
.tree-bar .tree-acts { margin-left: auto; display: flex; gap: 0.75rem;
  align-items: center; font-size: 0.85rem;
  /* The clone input is the compressible member of this row: without
     min-width the actions block refuses to shrink and spills into the
     About column. */
  min-width: 0; flex: 0 1 auto; }
.tree-bar .tree-acts .clonebox { min-width: 0; flex: 0 1 auto; }
.tree-bar .tree-acts .clonepick, .tree-bar .tree-acts .dlbtn { flex: none; }

.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 not what a reader came for — and the zit/fossil picker
     goes with it: a tool choice for a command that is not on screen.
     What remains (ref, path, download) holds ONE line. */
  .tree-bar .clonebox, .tree-bar .clonepick { display: none; }
  .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; }
/* The UA draws its select caret hard against the border and ignores
   padding. Owning the indicator (a currentColor-matched chevron) is
   how every design system solves it: consistent shape, real breathing
   room, both themes. */
select { appearance: none; -webkit-appearance: none; padding-right: 1.7rem;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 0.6rem center; }
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; }
/* WebKit draws its own clear button inside a search field, in the OS
   accent colour, and nothing but its size is styleable — a blue X in a
   gruvbox page. Basecoat leaves it alone and inherits the same problem.
   It is removed here and replaced, where there is something to clear,
   by a link that resets the filter: the same colour as everything else
   and, being a link, it works with scripting off. */
input[type=search]::-webkit-search-cancel-button,
input[type=search]::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
/* basecoat's input-group: one bordered shell, borderless controls
   inside it. An add-on then sits WITHIN the field instead of floating
   beside it, which is what the loose clear link looked like. */
/* Forum: GitHub/Codeberg's comment-thread shape. Each post is a card —
   a muted header strip (author, time, hash, controls) over a padded
   body — so authorship and actions live in chrome, not in the prose. */
.f-actions { display: flex; align-items: center; gap: 0.6rem; margin: 0.75rem 0 1rem; flex-wrap: wrap; }
.f-actions form { margin: 0; }
.f-post { border: 1px solid var(--rule); border-radius: 8px; margin: 1rem 0;
  /* The author's hue as a WHISPER on the left edge — same 1px weight
     as every other border, only the color applied. */
  border-left-color: hsl(var(--uh, 0), 30%, 50%, 0.55); }
.f-post-h .idn { width: 16px; height: 16px; }
.f-spectrum { display: block; width: 100%; height: 6px; margin: 0.1rem 0 0.9rem; border-radius: 3px; }
a.uref { font-weight: 600; }
.f-post-h { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
  padding: 0.4rem 0.8rem; background: var(--surface);
  border-bottom: 1px solid var(--rule); border-radius: 8px 8px 0 0;
  font-size: 0.85rem; color: var(--muted); }
.f-post-h strong { color: var(--fg); }
.f-post-h .f-spacer { margin-left: auto; }
.f-post-b { padding: 0.9rem 1rem 0.1rem; }
.f-post-b > :first-child { margin-top: 0; }
/* The vote pill: one bordered capsule, arrow · score · arrow. The two
   forms inside are the no-script mechanism; the pill is just clothes. */
.vote-pill { display: inline-flex; align-items: center; border: 1px solid var(--rule);
  border-radius: 999px; background: var(--bg); line-height: 1.2; }
.vote-pill form { margin: 0; display: inline-flex; }
.vote-pill button { border: 0; border-radius: 999px; background: none;
  padding: 0.1rem 0.45rem; font-weight: normal; font-size: 0.85rem; }
.vote-pill button:hover { background: var(--code-bg); border-color: transparent; }
.vote-pill .vscore { padding: 0 0.1rem; font-weight: 600; color: var(--fg); font-variant-numeric: tabular-nums; }
.vote-cast { color: var(--accent); }
/* Moderator actions fold behind a no-script kebab: details/summary. */
.f-kebab { position: relative; display: inline-block; }
.f-kebab > summary { list-style: none; cursor: pointer; padding: 0 0.45rem;
  border-radius: 5px; color: var(--muted); font-weight: 600; }
.f-kebab > summary::-webkit-details-marker { display: none; }
.f-kebab > summary:hover { background: var(--code-bg); color: var(--fg); }
.f-kebab-pop { position: absolute; right: 0; top: 1.6rem; z-index: 5;
  display: flex; align-items: center; gap: 0.4rem; padding: 0.5rem;
  background: var(--bg); border: 1px solid var(--rule); border-radius: 6px;
  box-shadow: 0 4px 14px rgba(0,0,0,0.25); white-space: nowrap; }
.f-kebab-pop form { margin: 0; display: flex; align-items: center; gap: 0.4rem; }
.f-kebab-pop input { font-size: 0.85rem; }
.f-kebab-pop button { font-size: 0.85rem; }
/* The reply composer: textarea in a card, actions in a footer strip —
   the comment box every forge reader already knows. */
.f-reply { border: 1px solid var(--rule); border-radius: 8px; margin-top: 1.25rem; }
.f-reply:focus-within { border-color: var(--accent); }
.f-reply textarea { border: 0; border-radius: 8px 8px 0 0; }
.f-reply textarea:focus-visible { outline: none; }
.f-reply-foot { display: flex; justify-content: flex-end; gap: 0.5rem;
  padding: 0.45rem 0.5rem; border-top: 1px solid var(--rule);
  background: var(--surface); border-radius: 0 0 8px 8px; }
.btn-primary { background: var(--accent); color: var(--bg); border-color: var(--accent); }
.btn-primary:hover { border-color: var(--accent); filter: brightness(1.1); }
/* The markdown composer: GitHub's Write | Preview tabs over a raw
   textarea. The tab bar ships hidden and JS unhides it — without
   script the composer is simply a textarea, which is the whole
   fallback story. Preview renders through the PRODUCTION pipeline. */
.f-composer .f-tabs { display: flex; gap: 0.25rem; padding: 0.45rem 0.6rem 0;
  background: var(--surface); border-bottom: 1px solid var(--rule);
  border-radius: 8px 8px 0 0; }
.f-tab { border: 1px solid transparent; background: none; font-size: 0.85rem;
  padding: 0.3rem 0.75rem; border-radius: 6px 6px 0 0; color: var(--muted); font-weight: 600; }
.f-tab:hover { color: var(--fg); border-color: transparent; }
.f-tab.here { background: var(--bg); border-color: var(--rule); border-bottom-color: var(--bg);
  color: var(--fg); position: relative; top: 1px; }
.f-composer textarea { border: 0; border-radius: 0; }
.f-preview { padding: 0.9rem 1rem; min-height: 9rem; }
.f-preview > :first-child { margin-top: 0; }
/* Live split: raw markdown left, the render growing beside it as you
   type — the pane is fed by the production renderer, debounced. JS
   unhides it; without script the composer is a full-width textarea. */
.f-split { display: grid; grid-template-columns: 1fr; }
.f-split:has(.f-live:not([hidden])) { grid-template-columns: 1fr 1fr; }
.f-live { border-left: 1px solid var(--rule); padding: 0.9rem 1rem; overflow-y: auto;
  font-size: 0.95em; }
.f-live-pane > :first-child { margin-top: 0; }
.f-live-pane:empty::before { content: "rendered preview appears as you type"; color: var(--muted); font-size: 0.85rem; }
@media (max-width: 52rem) {
  .f-split:has(.f-live:not([hidden])) { grid-template-columns: 1fr; }
  .f-live { border-left: 0; border-top: 1px solid var(--rule); }
}
/* The poll widget as previewed (ballot-less) and the poll BUILDER —
   structured inputs in the live pane that write the fenced poll block
   back into the raw source. The markdown stays the source of truth. */
.f-pollprev, .f-poll-builder:not(:empty) { border: 1px solid var(--rule); border-radius: 6px;
  padding: 0.6rem 0.8rem; max-width: 30rem; margin: 0.5rem 0; }
.f-pollprev-o { margin: 0.35rem 0; }
.f-pollbar { height: 6px; background: var(--rule); border-radius: 3px; }
.f-poll-builder label { display: block; font-size: 0.8rem; color: var(--muted); margin-top: 0.4rem; }
.f-poll-builder input { width: 100%; margin-top: 0.15rem; }
.f-poll-builder .pb-row { display: flex; gap: 0.3rem; align-items: center; }
.f-poll-builder .pb-row button { padding: 0.2rem 0.5rem; font-size: 0.85rem; }
.f-poll-builder .pb-add { margin-top: 0.4rem; font-size: 0.85rem; }
.f-poll-builder h4 { margin: 0; font-size: 0.9rem; }
/* The slash menu (composer.js): a popover of block snippets. */
.slash-menu { position: absolute; z-index: 6; background: var(--bg);
  border: 1px solid var(--rule); border-radius: 6px; padding: 0.25rem;
  box-shadow: 0 4px 14px rgba(0,0,0,0.25); min-width: 11rem; }
.slash-menu button { display: block; width: 100%; text-align: left; border: 0;
  background: none; padding: 0.3rem 0.5rem; border-radius: 5px; font-weight: normal; font-size: 0.9rem; }
.slash-menu button:hover, .slash-menu button.sel { background: var(--surface); }
.slash-menu small { color: var(--muted); margin-left: 0.4rem; }
/* A described check-in reference: the comment excerpt beside the hash. */
.cref { color: var(--muted); font-size: 0.9em; }
/* The forum list: rows with anatomy — title, opener excerpt, author
   mark, and on the right the participant stack, count, and the
   activity sparkline. Dormant threads mute; live ones carry full ink. */
.ft-list { margin: 0.5rem 0 1rem; }
.ft-row { display: flex; align-items: center; gap: 1rem; padding: 0.55rem 0.2rem;
  border-top: 1px solid var(--rule); }
.ft-row:first-child { border-top: 0; }
/* Dormant rows fade UNIFORMLY. Muting only the title's link color read
   as two kinds of link — meaning must never ride on link color. */
.ft-row.dim { opacity: 0.62; }
.ft-row.dim:hover { opacity: 1; }
.ft-main { min-width: 0; flex: 1; }
.ft-t { font-weight: 600; }
.ft-score { font-size: 0.8rem; color: var(--accent); margin-left: 0.3rem; }
.ft-x { margin: 0.15rem 0 0; color: var(--muted); font-size: 0.9rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ft-meta { margin: 0.25rem 0 0; color: var(--muted); font-size: 0.8rem;
  display: flex; align-items: center; gap: 0.35rem; }
.ft-meta .idn { width: 14px; height: 14px; }
.ft-side { flex: none; display: flex; align-items: center; gap: 0.75rem; color: var(--muted); }
.ft-stack { display: inline-flex; }
.ft-stack .idn { width: 18px; height: 18px; border-radius: 5px;
  outline: 2px solid var(--bg); margin-left: -5px; }
.ft-stack .idn:first-child { margin-left: 0; }
.ft-count { font-size: 0.85rem; font-variant-numeric: tabular-nums; min-width: 1.4rem; text-align: right; }
.ft-spark { color: var(--accent); }
@media (max-width: 40rem) { .ft-side { display: none; } }
/* Bar actions are rounded SQUARES; the account stays a circle — a
   button is a thing you do, an avatar is who you are. Icons render at
   full size in the page's ink, not the avatar's small caption size. */
.bar-btn { flex: none; width: 2rem; height: 2rem; padding: 0;
  border-radius: 6px; background: none; border: 1px solid var(--rule);
  color: var(--muted); cursor: pointer; display: inline-flex;
  align-items: center; justify-content: center; }
/* GitHub's header grammar: rest is quiet (muted ink, hairline border),
   hover is a FILL, and keyboard focus gets a real ring — the hover
   affordance and the focus affordance are different signals. */
.bar-btn:hover { background: var(--code-bg); border-color: var(--rule); color: var(--fg); }
.bar-btn:focus-visible, .avatar:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.bar-btn .ic { width: 1rem; height: 1rem; vertical-align: 0; }
a.inboxbtn { position: relative; }
/* The unread badge: a count, not a mystery dot. It informs and holds
   still — no pulse, no animation. */
#inbox-dot { display: none; }
#inbox-dot.on { display: block; position: absolute; top: -0.35rem; right: -0.35rem;
  min-width: 1rem; height: 1rem; padding: 0 0.2rem; box-sizing: border-box;
  border-radius: 999px; background: var(--accent); color: var(--bg);
  font-size: 0.65rem; font-weight: 700; line-height: 1rem; text-align: center; }
/* A link dressed as a button, matching the button rule above — for
   "new thread"-style actions that are navigations, not submissions. */
a.btn { display: inline-block; background: var(--code-bg); color: var(--fg);
  border: 1px solid var(--rule); border-radius: 6px; padding: 0.35rem 0.55rem;
  font-weight: 600; text-decoration: none; }
a.btn:hover { border-color: var(--fg); }
.fleet-count { margin: 0 0 0.5rem; }
.input-group { display: inline-flex; align-items: center; min-width: 0;
  border: 1px solid var(--rule); border-radius: 6px; background: var(--bg); }
.input-group:focus-within { outline: 2px solid var(--muted); outline-offset: 1px; }
.input-group > input { flex: 1 1 auto; min-width: 0; border: 0; background: none;
  border-radius: 6px; }
.input-group > input:focus-visible { outline: none; }
.clearq { flex: none; color: var(--muted); text-decoration: none; font-size: 1.05rem;
  line-height: 1; padding: 0 0.5rem; }
.clearq:hover { color: var(--fg); }
/* 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. */
/* The nav is the ONLY thing in the bar allowed to shrink, and it
   shrinks by scrolling, never by pushing: the … button sits outside it
   and is always whole. This is the structural guarantee behind every
   width — a surprising repo name or count can at worst make the tabs
   swipeable, exactly GitHub's mobile behavior. */
.repobar nav { display: flex; flex-wrap: nowrap; gap: 0.9rem; margin-left: auto;
  min-width: 0; flex: 0 1 auto; overflow-x: auto; scrollbar-width: none; }
.repobar nav::-webkit-scrollbar { display: none; }
.navmore-btn { flex: none; }
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(--surface); border-color: var(--rule); }
nav a.empty { color: var(--muted); }
/* The repobar and explore navs are underline TABS, not pills: the bar
   is a row of places, and GitHub taught everyone what that looks like.
   The underline element carries a view-transition-name, so on browsers
   with cross-document view transitions (Chrome 126+, Safari 18.2+) the
   highlight glides from the old tab to the new one across the page
   load; everywhere else navigation is simply instant. */
.repobar nav a, nav.tabs a { border-radius: 0; border: 0; padding: 0.45rem 0.1rem;
  border-bottom: 2px solid transparent; background: none; color: var(--muted);
  position: relative; }
.repobar nav a:hover, nav.tabs a:hover { border-color: transparent; color: var(--fg); }
.repobar nav a.here, nav.tabs a.here { background: none; border: 0; font-weight: 600;
  color: var(--fg); border-bottom: 2px solid transparent; }
.ink { position: absolute; left: 0; right: 0; bottom: 0; height: 2px;
  background: var(--accent); border-radius: 1px; }
.repobar nav a.here .ink { view-transition-name: repo-tab; }
nav.tabs a.here .ink { view-transition-name: explore-tab; }
nav.doc-tabs a.here .ink { view-transition-name: doc-tab; }
@view-transition { navigation: auto; }
@media (prefers-reduced-motion: reduce) { @view-transition { navigation: none; } }
/* The bars are ISOLATED from the root crossfade: identical on both
   sides of a navigation, so their groups render static instead of
   flashing with the page — the fixed chrome fading in and out was most
   of what read as "weird". Everything else is kept brisk: at 0.4s a
   crossfade is an effect, at 0.15s it is a page load. */
.topbar { view-transition-name: topbar; }
.repobar { view-transition-name: repobar; }
footer { view-transition-name: sitefooter; }
::view-transition-group(*) { animation-duration: 0.15s; }
/* Only the chrome animates — the orange underline slides, the bars hold
   still. The root snapshot does NOT cross-fade: two translucent copies
   of a dark page over a dark backdrop dip in brightness mid-fade, which
   reads as a flash on every slow navigation. Content just swaps. */
::view-transition-old(root), ::view-transition-new(root) { animation: none; }
/* The collapse, GRADUATED: each tier folds at its own width and the …
   menu reveals exactly the tier the bar just hid — a landscape phone
   keeps six tabs and folds two; a portrait phone keeps three. All-at-
   once hiding read as wrong precisely there. Measured collapse needs
   script; tiers are what owning the layout costs in pure CSS. */
/* Fixed square, self-centered: sized by its own box rather than by the
   bar's baseline alignment, which squashed it once it left the nav. */
.navmore-btn { display: none; align-items: center; justify-content: center;
  width: 1.9rem; height: 1.9rem; padding: 0; align-self: center;
  border: 1px solid var(--rule); border-radius: 6px;
  background: none; color: var(--muted); cursor: pointer;
  anchor-name: --navmore; }
.navmore-btn .ic { width: 1rem; height: 1rem; }
.menu.navmore .m1, .menu.navmore .m2, .menu.navmore .m3 { display: none; }
@media (max-width: 62rem) {
  .repobar nav a.aux1:not(.here) { display: none; }
  .navmore-btn { display: inline-flex; }
  .menu.navmore .m1 { display: block; }
  /* An active folded tab stays out of the drawer — selected, visible —
     and wiki (the last tier standing) yields its place for it. */
  .repobar nav:has(a.aux1.here, a.aux2.here) a.aux3:not(.here) { display: none; }
  .repobar nav:has(a.aux3.here) a.aux2:not(.here) { display: none; }
}
@media (max-width: 52rem) {
  .repobar nav a.aux2:not(.here) { display: none; }
  .menu.navmore .m2 { display: block; }
}
@media (max-width: 44rem) {
  .repobar nav a.aux3:not(.here) { display: none; }
  .menu.navmore .m3 { display: block; }
  /* A surfaced drawer tab needs the room a core tab was using: tickets
     yields. The row must NEVER exceed the viewport — an overflowing
     bar drags the whole page sideways — and the clip below is the hard
     guarantee behind these yields. */
  .repobar nav:has(a.aux1.here, a.aux2.here, a.aux3.here) a.yield:not(.here) { display: none; }
}
/* The drawer never lists the tab that is currently OUT of it. */
.menu.navmore span:has(a.here) { display: none !important; }
.menu.navmore a { display: flex; border-radius: 6px; padding: 0.3rem 0.6rem; }
.menu.navmore a.here { background: var(--surface); border: 0; font-weight: 600; }
@supports (anchor-name: --a) {
  .menu.navmore { position-anchor: --navmore; inset: auto; top: anchor(bottom);
    right: anchor(right); margin-top: 0.3rem; }
}
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; }
/* The ticket editor: body and timeline left, metadata right; one
   column on a phone with the metadata after the body. */
.tkt-layout { display: grid; grid-template-columns: minmax(0, 1fr) 17rem; gap: 0 2.5rem; align-items: start; }
.tkt-side { font-size: 0.9rem; }
.tkt-fields { display: grid; gap: 0.65rem; margin: 0 0 1rem; }
.tkt-fields label { display: grid; gap: 0.2rem; }
.tkt-fields select, .tkt-fields input { width: 100%; box-sizing: border-box; font: inherit; }
.tkt-k { color: var(--muted); font-size: 0.8rem; text-transform: capitalize; }
.tkt-v { padding: 0.2rem 0; }
.tkt-meta { color: var(--muted); font-size: 0.82rem; border-top: 1px solid var(--rule); padding-top: 0.6rem; }
.tkt-event { border-top: 1px solid var(--rule); padding: 0.4rem 0; color: var(--muted); font-size: 0.9rem; }
.tkt-event b { color: var(--fg); font-weight: 600; }
.tkt-remark .f-reply-foot { align-items: center; flex-wrap: wrap; }
.tkt-edit { margin: 0 0 1rem; }
.tkt-edit > summary { color: var(--muted); font-size: 0.85rem; cursor: pointer; width: max-content; }
.tkt-edit[open] > summary { margin-bottom: 0.5rem; }
.tkt-retitle { margin-top: -0.5rem; }
.tkt-retitle form { display: flex; gap: 0.5rem; }
.tkt-retitle input { flex: 1; font: inherit; }
.tkt-close { display: inline-flex; align-items: center; gap: 0.35rem; color: var(--muted); font-size: 0.85rem; }
.tkt-close select { font: inherit; }
@media (max-width: 52rem) {
  .tkt-layout { grid-template-columns: minmax(0, 1fr); }
  .tkt-side { border-top: 1px solid var(--rule); padding-top: 0.75rem; margin-top: 1rem; }
}
.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; }
/* Filter controls wrap as WHOLE units: a label never separates from
   its select, a branch chip never breaks mid-name. The chip is
   GitHub's dismissible filter token — the name truncates before the ×
   detaches. */
/* GRID, not flex: one line is structural. fit-content is GitHub's
   branch-selector rule in one function — sized to its label, capped
   (12rem), and clamped to the space the other two leave, so the row
   cannot overflow whatever the branch is called and a short name gets
   a short control. */
.tl-controls { color: var(--muted); font-size: 0.9rem; display: grid;
  grid-template-columns: fit-content(12rem) max-content max-content;
  gap: 0.5rem; align-items: center; margin-bottom: 0.4rem; max-width: 100%; }
.tl-controls select { min-width: 0; max-width: 100%; }
/* The fleet bar's first grid cell is a FORM holding field and sort;
   left as a block it stacks them. Flex puts them on the one line the
   grid promised, with the New repository link beside them. */
.fleet-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.75rem; }
.fleet-bar > form { display: flex; align-items: center; gap: 0.5rem; min-width: 0; }
.tl-chip { display: inline-flex; align-items: center; gap: 0.35rem; min-width: 0;
  max-width: 100%; border: 1px solid var(--rule); border-radius: 999px;
  padding: 0.1rem 0.6rem; background: var(--surface); white-space: nowrap; }
.tl-chip b { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  max-width: min(14rem, 55vw); }
.tl-chip a { text-decoration: none; }
.repobar nav a.cog { display: inline-flex; align-self: center; padding: 0.45rem 0.1rem; }
/* Group labels are help text, shown where there is room for help. */
.tl-help { color: var(--muted); font-size: 0.8rem; }
@media (max-width: 40rem) { .tl-help { display: none; } }
pre { overflow-x: auto; background: var(--code-bg); padding: 0.5rem; border-radius: 4px; }
/* A plaintext DOCUMENT (a license, an old-school README) is prose that
   happens to be monospace — it reads straight off the page, not out of
   a code box. Fenced code inside markdown keeps the box. */
pre.plaindoc { background: none; padding: 0; border-radius: 0; }
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. */
/* Columns that scroll independently: a board's whole value is seeing
   the states side by side, so the page must not grow to the tallest
   column and make the reader scroll past one to reach another. On a
   phone the columns stack, because four 80px columns are not a board. */
/* The board never widens the page. Open columns share the row with a
   16rem floor and wrap past it — a second row beats a sideways scroll
   that hides the columns that matter. Empty columns fold to strips
   (GitHub's collapsed column), which is what keeps a seven-status
   vocabulary on one row in the common case. */
/* The board is a canvas, not prose: it is the one view that leaves the
   reading column and takes the window. The controls above it keep the
   column so they line up with every other page. A column has ONE width
   (GitHub's 350px, here 22rem) so a card is the same object wherever
   it sits; the board is what varies — columns fold, and past the
   window they wrap. The controls leave the column with it, so the
   board's left edge and its filters line up (GitHub does the same). */
main.board-page { max-width: none; }
.board { display: flex; flex-wrap: wrap; gap: 0.8rem; align-items: stretch; padding-bottom: 0.4rem; }
.board-col { flex: 0 0 22rem; min-width: 0; border: 1px solid var(--rule); border-radius: 8px;
  background: var(--surface); padding: 0.6rem; }
.board-col.folded { flex: 0 0 auto; padding: 0.5rem 0.35rem; display: flex; align-items: flex-start;
  justify-content: center; }
.board-col.folded h3 { margin: 0; writing-mode: vertical-rl; transform: rotate(180deg);
  display: flex; align-items: center; gap: 0.4rem; }
.board-col.folded .unfold { text-decoration: none; color: inherit; display: inline-flex;
  align-items: center; gap: 0.4rem; }
/* Rotated, a pill becomes an oval: the strip names the column in
   plain text, the pill's colour kept, its box dropped. */
.board-col.folded .t-status { border: 0; background: none; padding: 0; border-radius: 0;
  font-weight: 600; letter-spacing: 0.02em; }
.board-col.folded .unfold:hover .t-status { text-decoration: underline; }
/* The chevron is the one control a column has: fold on an open one,
   open on a strip. Same glyph family, same spot, both directions. */
.board-col h3 .fold { margin-left: auto; color: var(--muted); text-decoration: none; padding: 0 0.3rem;
  border-radius: 4px; font-size: 1.1rem; line-height: 1; }
.board-col h3 .fold:hover { color: var(--fg); background: var(--bg); }
.board-col.folded .chev { color: var(--muted); font-size: 1.1rem; }
/* Sticky: in a 38-card column the header is the answer to "which
   column am I in" once the top has scrolled away. */
.board-col h3 { position: sticky; top: 0; z-index: 1; background: var(--surface);
  margin: 0 0 0.5rem; padding: 0.1rem 0; font-size: 0.9rem; display: flex; align-items: center;
  gap: 0.4rem; }
.board-col h3 .n { color: var(--muted); font-size: 0.8rem; font-variant-numeric: tabular-nums; }
/* A lane is a horizontal cut across the same columns. The heading is
   sticky because the whole point of a lane is knowing which one you are
   reading while you scroll down a long one. */
.lane-h { position: sticky; top: 0; z-index: 1; margin: 1.2rem 0 0.4rem;
  font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--muted); background: var(--bg); padding: 0.3rem 0;
  display: flex; align-items: center; gap: 0.4rem; }
.lane-h .lane-name { color: var(--fg); }
.fieldrow { display: grid; gap: 0.6rem 1rem; margin: 0 0 1rem;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); }
.fieldrow label { font-size: 0.9rem; }
.fieldrow input { width: 100%; box-sizing: border-box; }
.board-axes { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap;
  margin: 0 0 0.8rem; font-size: 0.85rem; color: var(--muted); }
.board-trunc { color: var(--muted); font-size: 0.8rem; }
.board-axes select { font-size: 0.85rem; padding: 0.15rem 0.3rem; }
/* Drag: the grip and the cursor say a card moves; the card dims while
   it is in the air; the column under the pointer lights up, which is
   the answer to "where will this land". */
.board .card[draggable="true"] { cursor: grab; }
.board .card.held { opacity: 0.5; cursor: grabbing; }
.board .card .grip { float: right; color: var(--muted); font-size: 1rem; line-height: 1; margin-left: 0.4rem;
  user-select: none; }
.board-col.over { outline: 2px dashed var(--accent); outline-offset: -2px; }
/* The last move, with its undo, sits above the columns. */
.board-moved { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem;
  border: 1px solid var(--rule); border-left: 3px solid var(--accent); border-radius: 6px;
  padding: 0.4rem 0.6rem; margin: 0 0 0.8rem; font-size: 0.9rem; }
.board-moved form { display: contents; }
.board-moved button { font-size: 0.8rem; padding: 0.15rem 0.5rem; }
/* The Move menu: one button, a popover of the other columns. */
.board .card .t-meta { display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap; }
.board .card .t-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem; color: var(--muted); }
.board .card .mv-btn { margin-left: auto; font-size: 0.75rem; padding: 0.15rem 0.5rem; font-weight: 600; }
.board .card .mv-btn .caret { font-size: 0.7em; }
@supports (anchor-name: --a) {
  .menu.mv { top: anchor(bottom); right: anchor(right); margin-top: 0.3rem; min-width: 9rem; }
}
/* Triage badges. Same weight as the metadata line — they are for
   scanning a column, not for shouting. */
.t-badges { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-top: 0.35rem; }
/* The landing page's About block and document tabs. The aside reads
   as a sidebar where there is room and as a header card where there is
   not; the tab strip is the scopes idiom, already legible everywhere. */
.dash { display: grid; gap: 1.6rem; grid-template-columns: 1fr; }
@media (min-width: 64rem) {
  .dash { grid-template-columns: 18rem 1fr 1fr; align-items: start; }
}
.dash h2 { font-size: 1rem; margin: 0 0 0.6rem; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.05em; }
.dash .tl-badge { margin-right: 0.35rem; }
/* GitHub's latest-commit bar: one line, the message eats the slack and
   truncates; the hash/date/count block never wraps. On a phone the
   message yields entirely and the bar keeps author + date, exactly the
   collapse GitHub makes. */
.tip-bar { display: flex; align-items: center; gap: 0.6rem; min-width: 0;
  border: 1px solid var(--rule); border-radius: 8px; background: var(--surface);
  padding: 0.45rem 0.75rem; margin: 0 0 0.6rem; font-size: 0.9rem; }
.tip-user { font-weight: 600; flex: none; }
.tip-msg { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--muted); text-decoration: none; }
.tip-msg:hover { color: var(--accent); text-decoration: underline; }
.tip-right { flex: none; margin-left: auto; color: var(--muted); display: inline-flex;
  align-items: center; gap: 0.35rem; white-space: nowrap; }
.tip-right a { color: var(--muted); text-decoration: none; display: inline-flex;
  align-items: center; gap: 0.3rem; }
.tip-right a:hover { color: var(--accent); }
@media (max-width: 44rem) {
  .tip-bar .tip-msg, .tip-bar .tip-hash { display: none; }
}
/* On a phone the stats strip stops being a card and becomes GitHub's
   wrapping row of inline facts, closing the About flow above the
   branch bar. */
@media (max-width: 44rem) {
  .repo-stats { border: 0; background: none; padding: 0.2rem 0 0.6rem;
    border-bottom: 1px solid var(--rule); border-radius: 0; }
}
/* GitHub's About sidebar, borne on Gitea's literal frame: a real grid,
   not a float — a float squeezes the bars sharing its rows until their
   contents collide. Column 1 holds every bar, table and document;
   column 2 is the sidebar, spanning all rows. One column where there
   is no room, and the aside is FIRST in source order, so the phone
   reads description → facts → branch bar → files, exactly as GitHub
   and Forgejo order it. */
.repo-home { display: grid; grid-template-columns: minmax(0, 1fr); }
.repo-home > * { grid-column: 1; min-width: 0; }
.about-card { margin: 0.4rem 0 0.6rem; font-size: 0.9rem; }
@media (min-width: 64rem) {
  .repo-home.has-about { grid-template-columns: minmax(0, 1fr) 17rem; gap: 0 2.5rem; }
  .repo-home.has-about > .about-card { grid-column: 2; grid-row: 1 / span 40; margin: 0.4rem 0 0; }
}
.about-h { font-size: 1rem; margin: 0 0 0.5rem; }
.about-h a { color: var(--fg); text-decoration: none; }
.about-h a:hover { text-decoration: underline; }
.about-desc { margin: 0 0 0.6rem; color: var(--fg); }
.topics { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0.65rem 0 0.75rem; }
a.b-pill { color: inherit; text-decoration: none; }
a.b-pill:hover { border-color: var(--fg); color: var(--fg); }
.about-row { display: flex; align-items: center; gap: 0.5rem; margin: 0;
  padding: 0.22rem 0; color: var(--muted); text-decoration: none; min-width: 0; }
.about-row .ic { flex: none; }
.about-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
a.about-row:hover { color: var(--accent); }
a.about-row:hover span { text-decoration: underline; }
.about-link { color: var(--accent); font-weight: 600; }
.about-div { border-top: 1px solid var(--rule); margin: 0.9rem 0; }
.about-tag a { color: var(--fg); text-decoration: none; }
.about-tag a:hover { text-decoration: underline; }
.latest-pill { border: 1px solid var(--add); color: var(--add); border-radius: 999px;
  padding: 0 0.5rem; font-size: 0.75rem; line-height: 1.5; }
.about-sub { margin: 0 0 0.2rem 1.5rem; font-size: 0.85rem; color: var(--muted); }
.about-sub a { color: var(--accent); text-decoration: none; }
.about-sub a:hover { text-decoration: underline; }
.doc-tabs { display: flex; flex-wrap: wrap; gap: 0.2rem 1rem; margin: 1.4rem 0 0;
  border-bottom: 1px solid var(--rule); font-size: 0.95rem; }
nav.doc-tabs a { padding: 0.3rem 0.1rem; border: 0; border-bottom: 2px solid transparent;
  border-radius: 0; background: none; color: var(--muted); text-decoration: none; }
nav.doc-tabs a.here { color: var(--fg); background: none; border: 0;
  border-bottom: 2px solid transparent; font-weight: 600; }
nav.doc-tabs a { position: relative; }
.doc-panel { margin-top: 1rem; }
/* The structured diff: gutters own the numbers and the +/- marker so
   selection copies clean code; the mark class is the intra-line "what
   actually changed" emphasis; syntax classes come from the chroma
   stylesheet already on the page (.dtab carries .chroma). */
.dtab { border-collapse: collapse; width: 100%; font: 0.85rem/1.45 ui-monospace, monospace; }
.dtab td { padding: 0 0.5rem 0 0; vertical-align: top; }
.dnum { user-select: none; text-align: right; color: var(--muted); min-width: 3ch;
  font-variant-numeric: tabular-nums; padding-left: 0.4rem; }
.dmark { user-select: none; width: 1ch; color: var(--muted); }
tr.d-addition .dmark::before { content: "+"; }
tr.d-deletion .dmark::before { content: "-"; }
tr.d-addition, .dcell.d-addition { background: var(--add-row); }
tr.d-deletion, .dcell.d-deletion { background: var(--del-row); }
.dcode { white-space: pre-wrap; overflow-wrap: anywhere; width: 100%; }
tr.d-addition .dm, .dcell.d-addition .dm { background: var(--add-mark); border-radius: 2px; }
tr.d-deletion .dm, .dcell.d-deletion .dm { background: var(--del-mark); border-radius: 2px; }
/* Side-by-side: two half-width code cells; a side with no counterpart
   is a hatched void, fossil's idiom for "nothing here". */
.dtab.split .dcell { white-space: pre-wrap; overflow-wrap: anywhere; width: 50%;
  padding-right: 0.5rem; border-left: 1px solid var(--rule); padding-left: 0.4rem; }
.dcell.d-void { background: repeating-linear-gradient(45deg, transparent, transparent 4px,
  var(--surface) 4px, var(--surface) 8px); }
.hunkhead { color: var(--muted); font: 0.8rem/1.4 ui-monospace, monospace;
  padding: 0.35rem 0.5rem; border-top: 1px solid var(--rule); background: var(--surface); }
.filediff { margin: 1.2rem 0; border: 1px solid var(--rule); border-radius: 8px; overflow: hidden; }
/* Side-by-side halves the room each line gets, so the split view earns
   what GitHub gives it: the diff breaks out of the page column and
   takes the viewport, 2rem margins aside. 50% - 50vw walks from the
   centred column's edge to the viewport's. Only once the viewport is
   actually wider than the column — below that the breakout is a no-op
   that still moves pixels. */
@media (min-width: 80rem) {
  .filediff.wide { margin-left: calc(50% - 50vw + 2rem);
    margin-right: calc(50% - 50vw + 2rem); }
}
.filediff-head { margin: 0; padding: 0.5rem 0.75rem; font-size: 0.95rem;
  background: var(--surface); }
.diffcount { font-weight: 600; margin: 1rem 0 0.3rem; }
.diffmore { color: var(--muted); font-size: 0.9rem; margin: 1rem 0; text-align: center; min-height: 1.5rem; }
/* Segmented toggle (GitHub's "Unified | Split"): one bordered group,
   buttons joined, the current one filled. Overrides nav a's pill. */
nav.toggle { display: inline-flex; margin: 0.6rem 0 0.8rem; border: 1px solid var(--rule);
  border-radius: 6px; overflow: hidden; font-size: 0.9rem; }
nav.toggle a { border: 0; border-radius: 0; padding: 0.3rem 0.8rem; color: var(--fg);
  line-height: 1.25; }
nav.toggle a + a { border-left: 1px solid var(--rule); }
nav.toggle a:hover { background: var(--surface); }
nav.toggle a.here { background: var(--surface); font-weight: 600; }
/* The pager, shared by the people list and windowed diffs. GitHub's
   pagination shape: bordered buttons either side of a position
   readout; a disabled end keeps its place, muted, so nothing shifts
   between pages. nav a's pill rule is overridden per button. */
nav.pager { display: flex; align-items: center; justify-content: center; gap: 0.4rem;
  margin: 0.8rem 0 1rem; font-size: 0.9rem; flex-wrap: wrap; }
nav.pager a.pager-btn, nav.pager span.pager-btn { display: inline-block; padding: 0.3rem 0.8rem; border-radius: 6px;
  border: 1px solid var(--rule); text-decoration: none; color: var(--fg); line-height: 1.25;
  white-space: nowrap; }
nav.pager a.pager-btn:hover { background: var(--surface); border-color: var(--muted); }
nav.pager .pager-btn[aria-disabled="true"] { color: var(--muted); border-color: transparent;
  pointer-events: none; }
nav.pager .pager-where { color: var(--muted); padding: 0 0.6rem; font-variant-numeric: tabular-nums; }
nav.pager .pager-where b { color: var(--fg); font-weight: 600; }
/* The message leads the page but is not a headline (no forge makes it
   one — its length is untrusted). wrap-anywhere: a 2000-character
   unbroken line is real data, not a layout veto. */
/* The header names the page (GitHub's "Commit abc123"); the message
   sits below it as content in a quiet box, subject bolded — unless the
   first line is too long to be a subject, in which case it is a
   paragraph and renders like one. wrap-anywhere throughout: a
   2000-character unbroken line is real data, not a layout veto. */
.ci-h { font-size: 1.4rem; font-weight: 600; margin: 0 0 0.75rem; }
.ci-box { margin: 0 0 0.9rem; }
.ci-sub { font-weight: 600; margin: 0; overflow-wrap: anywhere; }
.ci-sub.long { font-weight: 400; }
.ci-msg { white-space: pre-wrap; overflow-wrap: anywhere;
  margin: 0.6rem 0 0; color: var(--muted); }
/* The changed-files list wears the files page's clothes: icon + name
   as one label, no underline until hover, the change kind sitting
   where the listing puts its sizes. */
.difffiles { list-style: none; padding: 0; margin: 0 0 0.8rem; }
.difffiles li { display: flex; align-items: center; gap: 0.5rem;
  padding: 0.15rem 0.2rem; line-height: 1.45; }
.difffiles li:hover { background: var(--surface); }
.difffiles a { text-decoration: none; display: inline-flex; align-items: center;
  gap: 0.45rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.difffiles a:hover { text-decoration: underline; }
.difffiles .tl-badge { margin-left: auto; }
.b-pill { font-size: 0.7rem; padding: 0.05rem 0.35rem; border-radius: 999px;
  border: 1px solid var(--rule); color: var(--muted); white-space: nowrap; }
.b-pill.b-pri { border-color: var(--accent); color: var(--accent); }
.t-badges span { font-size: 0.7rem; padding: 0.05rem 0.35rem; border-radius: 999px;
  border: 1px solid var(--rule); color: var(--muted); white-space: nowrap; }
.t-badges .b-pri { border-color: var(--accent); color: var(--accent); }
.board .card { background: var(--bg); border: 1px solid var(--rule); border-radius: 6px;
  padding: 0.5rem; margin-bottom: 0.5rem; }
.board .card .t-title { display: block; font-weight: 600; overflow-wrap: anywhere; }
.board .card .t-meta { margin-top: 0.2rem; }
/* The move control is deliberately always present, never revealed by
   script: it is how a touch or keyboard reader moves a card, and any
   drag layer is an addition on top of it. */
/* On a phone the open columns stack full-width and the folded ones
   become a row of chips above them. The earlier design kept a swiped
   row here; a page that scrolls sideways was judged worse than a board
   that reads as stacked lists, which it is on any phone-sized screen. */
@media (max-width: 40rem) {
  .board { gap: 0.5rem; }
  .board-col { flex: 1 1 100%; }
  .board-col.folded { flex: 0 0 auto; order: -1; padding: 0.3rem 0.5rem; }
  .board-col.folded h3 { writing-mode: horizontal-tb; transform: none; }
}
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; }
/* Templates wrap their tables in .tw, which scrolls. Tables that arrive
   from RENDERED CONTENT — the API doc, a wiki page, a ticket body —
   have no wrapper to put them in, and a wide one pushed the whole page
   sideways (the /docs rules column overflowed a phone by 208px). Making
   such a table its own scroll container needs no wrapper and covers
   every source of them, including markup quarry did not generate. */
main :not(.tw) > table { display: block; overflow-x: auto; max-width: 100%; }
.tw > table { display: table; }
/* 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>
{{if .Composer}}<script src="/assets/composer.js" defer></script>{{end}}
</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-branch" viewBox="0 0 16 16"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.878A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></symbol><symbol id="i-tag" viewBox="0 0 16 16"><path d="M1 7.775V2.75C1 1.784 1.784 1 2.75 1h5.025c.464 0 .91.184 1.238.513l6.25 6.25a1.75 1.75 0 0 1 0 2.474l-5.026 5.026a1.75 1.75 0 0 1-2.474 0l-6.25-6.25A1.752 1.752 0 0 1 1 7.775Zm1.5 0c0 .066.026.13.073.177l6.25 6.25a.25.25 0 0 0 .354 0l5.025-5.025a.25.25 0 0 0 0-.354l-6.25-6.25a.25.25 0 0 0-.177-.073H2.75a.25.25 0 0 0-.25.25ZM6 5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z"/></symbol><symbol id="i-link" viewBox="0 0 16 16"><path d="m7.775 3.275 1.25-1.25a3.5 3.5 0 1 1 4.95 4.95l-2.5 2.5a3.5 3.5 0 0 1-4.95 0 .751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018 1.998 1.998 0 0 0 2.83 0l2.5-2.5a2.002 2.002 0 0 0-2.83-2.83l-1.25 1.25a.751.751 0 0 1-1.042-.018.751.751 0 0 1-.018-1.042Zm-4.69 9.64a1.998 1.998 0 0 0 2.83 0l1.25-1.25a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042l-1.25 1.25a3.5 3.5 0 1 1-4.95-4.95l2.5-2.5a3.5 3.5 0 0 1 4.95 0 .751.751 0 0 1-.018 1.042.751.751 0 0 1-1.042.018 1.998 1.998 0 0 0-2.83 0l-2.5 2.5a1.998 1.998 0 0 0 0 2.83Z"/></symbol><symbol id="i-book" viewBox="0 0 16 16"><path d="M0 1.75A.75.75 0 0 1 .75 1h4.253c1.227 0 2.317.59 3 1.501A3.743 3.743 0 0 1 11.006 1h4.245a.75.75 0 0 1 .75.75v10.5a.75.75 0 0 1-.75.75h-4.507a2.25 2.25 0 0 0-1.591.659l-.622.621a.75.75 0 0 1-1.06 0l-.622-.621A2.25 2.25 0 0 0 5.258 13H.75a.75.75 0 0 1-.75-.75Zm7.251 10.324.004-5.073-.002-2.253A2.25 2.25 0 0 0 5.003 2.5H1.5v9h3.757a3.75 3.75 0 0 1 1.994.574ZM8.755 4.75l-.004 7.322a3.752 3.752 0 0 1 1.992-.572H14.5v-9h-3.495a2.25 2.25 0 0 0-2.25 2.25Z"/></symbol><symbol id="i-law" viewBox="0 0 16 16"><path d="M8.75.75V2h.985c.304 0 .603.08.867.231l1.29.736c.038.022.08.033.124.033h2.234a.75.75 0 0 1 0 1.5h-.427l2.111 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.006.005-.01.01-.045.04c-.21.176-.441.327-.686.45C14.556 10.78 13.88 11 13 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L12.178 4.5h-.162c-.305 0-.604-.079-.868-.231l-1.29-.736a.245.245 0 0 0-.124-.033H8.75V13h2.5a.75.75 0 0 1 0 1.5h-6.5a.75.75 0 0 1 0-1.5h2.5V3.5h-.984a.245.245 0 0 0-.124.033l-1.289.737c-.265.15-.564.23-.869.23h-.162l2.112 4.692a.75.75 0 0 1-.154.838l-.53-.53.529.531-.001.002-.002.002-.006.006-.016.015-.045.04c-.21.176-.441.327-.686.45C4.556 10.78 3.88 11 3 11a4.498 4.498 0 0 1-2.023-.454 3.544 3.544 0 0 1-.686-.45l-.045-.04-.016-.015-.006-.006-.004-.004v-.001a.75.75 0 0 1-.154-.838L2.178 4.5H1.75a.75.75 0 0 1 0-1.5h2.234a.249.249 0 0 0 .125-.033l1.288-.737c.265-.15.564-.23.869-.23h.984V.75a.75.75 0 0 1 1.5 0Zm2.945 8.477c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L13 6.327Zm-10 0c.285.135.718.273 1.305.273s1.02-.138 1.305-.273L3 6.327Z"/></symbol><symbol id="i-shield" viewBox="0 0 16 16"><path d="M7.467.133a1.748 1.748 0 0 1 1.066 0l5.25 1.68A1.75 1.75 0 0 1 15 3.48V7c0 1.566-.32 3.182-1.303 4.682-.983 1.498-2.585 2.813-5.032 3.855a1.697 1.697 0 0 1-1.33 0c-2.447-1.042-4.049-2.357-5.032-3.855C1.32 10.182 1 8.566 1 7V3.48a1.75 1.75 0 0 1 1.217-1.667Zm.61 1.429a.25.25 0 0 0-.153 0l-5.25 1.68a.25.25 0 0 0-.174.238V7c0 1.358.275 2.666 1.057 3.86.784 1.194 2.121 2.34 4.366 3.297a.196.196 0 0 0 .154 0c2.245-.956 3.582-2.104 4.366-3.298C13.225 9.666 13.5 8.36 13.5 7V3.48a.251.251 0 0 0-.174-.237l-5.25-1.68ZM8.75 4.75v3a.75.75 0 0 1-1.5 0v-3a.75.75 0 0 1 1.5 0ZM9 10.5a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"/></symbol><symbol id="i-copy" viewBox="0 0 16 16"><path d="M0 6.75C0 5.784.784 5 1.75 5h1.5a.75.75 0 0 1 0 1.5h-1.5a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-1.5a.75.75 0 0 1 1.5 0v1.5A1.75 1.75 0 0 1 9.25 16h-7.5A1.75 1.75 0 0 1 0 14.25Z"/><path d="M5 1.75C5 .784 5.784 0 6.75 0h7.5C15.216 0 16 .784 16 1.75v7.5A1.75 1.75 0 0 1 14.25 11h-7.5A1.75 1.75 0 0 1 5 9.25Zm1.75-.25a.25.25 0 0 0-.25.25v7.5c0 .138.112.25.25.25h7.5a.25.25 0 0 0 .25-.25v-7.5a.25.25 0 0 0-.25-.25Z"/></symbol><symbol id="i-gear" viewBox="0 0 16 16"><path d="M8 0a8.2 8.2 0 0 1 .701.031C9.444.095 9.99.645 10.16 1.29l.288 1.107c.018.066.079.158.212.224.231.114.454.243.668.386.123.082.233.09.299.071l1.103-.303c.644-.176 1.392.021 1.82.63.27.385.506.792.704 1.218.315.675.111 1.422-.364 1.891l-.814.806c-.049.048-.098.147-.088.294a6.214 6.214 0 0 1 0 .772c-.01.147.039.246.088.294l.814.806c.475.469.679 1.216.364 1.891a7.977 7.977 0 0 1-.704 1.217c-.428.61-1.176.807-1.82.63l-1.102-.302c-.067-.019-.177-.011-.3.071a5.909 5.909 0 0 1-.668.386c-.133.066-.194.158-.211.224l-.29 1.106c-.168.646-.715 1.196-1.458 1.26a8.006 8.006 0 0 1-1.402 0c-.743-.064-1.289-.614-1.458-1.26l-.289-1.106c-.018-.066-.079-.158-.212-.224a5.738 5.738 0 0 1-.668-.386c-.123-.082-.233-.09-.299-.071l-1.103.303c-.644.176-1.392-.021-1.82-.63a8.12 8.12 0 0 1-.704-1.218c-.315-.675-.111-1.422.363-1.891l.815-.806c.05-.048.098-.147.088-.294a6.214 6.214 0 0 1 0-.772c.01-.147-.038-.246-.088-.294l-.815-.806C.635 6.045.431 5.298.746 4.623a7.92 7.92 0 0 1 .704-1.217c.428-.61 1.176-.807 1.82-.63l1.102.302c.067.019.177.011.3-.071.214-.143.437-.272.668-.386.133-.066.194-.158.211-.224l.29-1.106C6.009.645 6.556.095 7.299.03 7.53.01 7.764 0 8 0Zm-.571 1.525c-.036.003-.108.036-.137.146l-.289 1.105c-.147.561-.549.967-.998 1.189-.173.086-.34.183-.5.29-.417.278-.97.423-1.529.27l-1.103-.303c-.109-.03-.175.016-.195.045-.22.312-.412.644-.573.99-.014.031-.021.11.059.19l.815.806c.411.406.562.957.53 1.456a4.709 4.709 0 0 0 0 .582c.032.499-.119 1.05-.53 1.456l-.815.806c-.081.08-.073.159-.059.19.162.346.353.677.573.989.02.03.085.076.195.046l1.102-.303c.56-.153 1.113-.008 1.53.27.161.107.328.204.501.29.447.222.85.629.997 1.189l.289 1.105c.029.109.101.143.137.146a6.6 6.6 0 0 0 1.142 0c.036-.003.108-.036.137-.146l.289-1.105c.147-.561.549-.967.998-1.189.173-.086.34-.183.5-.29.417-.278.97-.423 1.529-.27l1.103.303c.109.029.175-.016.195-.045.22-.313.411-.644.573-.99.014-.031.021-.11-.059-.19l-.815-.806c-.411-.406-.562-.957-.53-1.456a4.709 4.709 0 0 0 0-.582c-.032-.499.119-1.05.53-1.456l.815-.806c.081-.08.073-.159.059-.19a6.464 6.464 0 0 0-.573-.989c-.02-.03-.085-.076-.195-.046l-1.102.303c-.56.153-1.113.008-1.53-.27a4.44 4.44 0 0 0-.501-.29c-.447-.222-.85-.629-.997-1.189l-.289-1.105c-.029-.11-.101-.143-.137-.146a6.6 6.6 0 0 0-1.142 0ZM11 8a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM9.5 8a1.5 1.5 0 1 0-3.001.001A1.5 1.5 0 0 0 9.5 8Z"/></symbol><symbol id="i-more" viewBox="0 0 16 16"><path d="M8 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM1.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM14.5 9.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/></symbol><symbol id="i-person" viewBox="0 0 16 16"><path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></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><symbol id="i-plus" viewBox="0 0 16 16"><path d="M7.75 2a.75.75 0 0 1 .75.75V7h4.25a.75.75 0 0 1 0 1.5H8.5v4.25a.75.75 0 0 1-1.5 0V8.5H2.75a.75.75 0 0 1 0-1.5H7V2.75A.75.75 0 0 1 7.75 2Z"/></symbol><symbol id="i-inbox" viewBox="0 0 16 16"><path d="M2.8 2.06A1.75 1.75 0 0 1 4.41 1h7.18c.7 0 1.333.417 1.61 1.06l2.74 6.395c.04.093.06.194.06.295v4.5A1.75 1.75 0 0 1 14.25 15H1.75A1.75 1.75 0 0 1 0 13.25v-4.5c0-.101.02-.202.06-.295Zm1.61.44a.25.25 0 0 0-.23.152L1.887 8H4.75a.75.75 0 0 1 .6.3L6.625 10h2.75l1.275-1.7a.75.75 0 0 1 .6-.3h2.863L11.82 2.652a.25.25 0 0 0-.23-.152Zm10.09 7h-2.875l-1.275 1.7a.75.75 0 0 1-.6.3h-3.5a.75.75 0 0 1-.6-.3L4.375 9.5H1.5v3.75c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25Z"/></symbol></svg>
{{- /* Two bars, as forgejo does it: a full-width strip carrying the
       things that are the same everywhere — forge name, search, account
       — and, under it, the repository's own name and nav at content
       width. Before this the forge name, the repo name and the account
       links shared one line, so the row's shape changed with the length
       of whatever repository you were looking at. */ -}}
<header class="topbar"><div class="bar">
{{- /* The mark replaces the wordmark, but the LINK keeps its name: alt
       carries "quarry" so a screen reader, a text browser and a page
       with images off all still read the same word the wordmark said.
       An image with an empty alt here would leave the link unnamed. */ -}}
{{- /* The mark, drawn in the timeline's own language: a node (the bowl)
       with a rail leaving it (the tail), and the check-in dot at its
       centre. Inline SVG in currentColor, so one shape serves both
       themes and every size with no image asset and no theme swap. */ -}}
<a class="site" href="/" aria-label="quarry">
<svg class="mark" viewBox="0 0 24 24" role="img" aria-label="quarry">
<circle cx="11" cy="12" r="7.2" fill="none" stroke="currentColor" stroke-width="2.8"/>
<circle cx="11" cy="12" r="2.2" fill="currentColor"/>
<path d="M17.1 18.1 L19.2 20.2" stroke="currentColor" stroke-width="2.8" stroke-linecap="round"/>
</svg><svg class="wordmark" viewBox="0 -735 3000 735" aria-hidden="true"><path fill="currentColor" d="M394 187Q370 187 345.5 182.5Q321 178 299.0 167.5Q277 157 259.0 139.5Q241 122 229.5 100.0Q218 78 213.0 54.0Q208 30 208 5Q184 2 162.0 -7.0Q140 -16 121.5 -31.0Q103 -46 89.0 -65.5Q75 -85 66.5 -107.0Q58 -129 55.0 -152.5Q52 -176 52 -200V-535Q52 -562 56.5 -589.0Q61 -616 72.5 -641.0Q84 -666 102.5 -686.5Q121 -707 145.0 -720.0Q169 -733 196.0 -738.0Q223 -743 250 -743Q277 -743 304.0 -738.0Q331 -733 355.0 -720.0Q379 -707 397.5 -686.5Q416 -666 427.5 -641.0Q439 -616 443.5 -589.0Q448 -562 448 -535V-200Q448 -178 445.5 -155.5Q443 -133 435.5 -112.0Q428 -91 416.0 -72.0Q404 -53 387.0 -38.0Q370 -23 350.0 -13.0Q330 -3 308 2Q308 21 312.5 39.0Q317 57 329.0 71.0Q341 85 358.5 92.0Q376 99 394 99H420V187ZM250 -80Q272 -80 292.5 -89.5Q313 -99 325.5 -116.5Q338 -134 343.0 -156.0Q348 -178 348 -200V-535Q348 -557 343.0 -579.0Q338 -601 325.5 -618.5Q313 -636 292.5 -645.5Q272 -655 250 -655Q228 -655 207.5 -645.5Q187 -636 174.5 -618.5Q162 -601 157.0 -579.0Q152 -557 152 -535V-200Q152 -178 157.0 -156.0Q162 -134 174.5 -116.5Q187 -99 207.5 -89.5Q228 -80 250 -80Z M750 8Q723 8 696.5 2.5Q670 -3 647.0 -16.5Q624 -30 606.5 -50.5Q589 -71 578.5 -95.5Q568 -120 564.0 -146.5Q560 -173 560 -200V-735H660V-200Q660 -186 661.5 -172.0Q663 -158 667.5 -144.5Q672 -131 679.5 -118.5Q687 -106 697.5 -97.0Q708 -88 722.0 -84.0Q736 -80 750 -80Q764 -80 778.0 -84.0Q792 -88 802.5 -97.0Q813 -106 820.5 -118.5Q828 -131 832.5 -144.5Q837 -158 838.5 -172.0Q840 -186 840 -200V-735H940V-200Q940 -173 936.0 -146.5Q932 -120 921.5 -95.5Q911 -71 893.5 -50.5Q876 -30 853.0 -16.5Q830 -3 803.5 2.5Q777 8 750 8Z M1039 0 1053 -70 1192 -735H1308L1461 0H1359L1326 -172H1174L1141 0ZM1310 -260 1267 -490Q1263 -513 1258.5 -536.0Q1254 -559 1250 -582Q1246 -559 1241.5 -536.0Q1237 -513 1233 -490L1190 -260Z M1856 0 1720 -292H1660V0H1560V-735H1755Q1783 -735 1810.5 -729.0Q1838 -723 1861.5 -708.0Q1885 -693 1902.5 -671.0Q1920 -649 1930.0 -623.0Q1940 -597 1944.0 -569.0Q1948 -541 1948 -514Q1948 -481 1942.5 -448.0Q1937 -415 1921.5 -386.0Q1906 -357 1880.5 -334.5Q1855 -312 1824 -302L1964 0ZM1660 -380H1755Q1771 -380 1785.5 -385.0Q1800 -390 1811.0 -400.5Q1822 -411 1829.5 -425.0Q1837 -439 1841.0 -453.5Q1845 -468 1846.5 -483.0Q1848 -498 1848 -514Q1848 -529 1846.5 -544.0Q1845 -559 1841.0 -574.0Q1837 -589 1829.5 -602.5Q1822 -616 1811.0 -626.5Q1800 -637 1785.5 -642.0Q1771 -647 1755 -647H1660Z M2356 0 2220 -292H2160V0H2060V-735H2255Q2283 -735 2310.5 -729.0Q2338 -723 2361.5 -708.0Q2385 -693 2402.5 -671.0Q2420 -649 2430.0 -623.0Q2440 -597 2444.0 -569.0Q2448 -541 2448 -514Q2448 -481 2442.5 -448.0Q2437 -415 2421.5 -386.0Q2406 -357 2380.5 -334.5Q2355 -312 2324 -302L2464 0ZM2160 -380H2255Q2271 -380 2285.5 -385.0Q2300 -390 2311.0 -400.5Q2322 -411 2329.5 -425.0Q2337 -439 2341.0 -453.5Q2345 -468 2346.5 -483.0Q2348 -498 2348 -514Q2348 -529 2346.5 -544.0Q2345 -559 2341.0 -574.0Q2337 -589 2329.5 -602.5Q2322 -616 2311.0 -626.5Q2300 -637 2285.5 -642.0Q2271 -647 2255 -647H2160Z M2700 0V-302L2537 -735H2644L2750 -429L2856 -735H2963L2800 -302V0Z"/></svg></a>
{{if .Repo}}
<form class="find" action="/r/{{.Repo}}/search" method="get">
{{- /* The box searches what the reader is looking at. On the tickets
       page "search tickets" is almost always what is meant. The scope is
       a hidden field rather than a mode, so the URL says what was
       searched and the results page can widen it. */ -}}
{{if .SearchKind}}<input type="hidden" name="kind" value="{{.SearchKind}}">{{end}}
<input type="search" name="q" value="{{.Query}}" placeholder="search {{.SearchScope}}" aria-label="search {{.SearchScope}}" data-on-keydown__window="evt.key == '/' &amp;&amp; !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) &amp;&amp; (evt.preventDefault(), el.focus())" title="press / to search"></form>
{{else if .SearchAction}}
<form class="find" action="{{.SearchAction}}" method="get">
<input type="search" name="q" value="{{.Query}}" placeholder="filter {{.SearchScope}}" aria-label="filter {{.SearchScope}}" data-on-keydown__window="evt.key == '/' &amp;&amp; !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) &amp;&amp; (evt.preventDefault(), el.focus())" title="press / to search"></form>
{{else}}
<form class="find" action="/search" method="get">
<input type="search" name="q" value="{{.Query}}" placeholder="search all repositories" aria-label="search all repositories" data-on-keydown__window="evt.key == '/' &amp;&amp; !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName) &amp;&amp; (evt.preventDefault(), el.focus())" title="press / to search"></form>
{{end}}
{{- /* The account menu is a popover, which needs no script: the button
       opens it, Escape and an outside click close it, and the browser
       handles focus. Everything that was loose in the header lives in
       it, so the bar keeps one shape whoever is signed in. */ -}}
<div class="bar-acts">
{{if .SignedIn}}<button class="bar-btn" popovertarget="create" title="create" aria-label="create menu"><svg class="ic" aria-hidden="true"><use href="#i-plus"/></svg></button>
<div id="create" popover class="menu">
{{if .Repo}}<div class="menu-who">in {{.Repo}}</div>
<a href="/r/{{.Repo}}/forum-new">new thread</a>
<a href="/r/{{.Repo}}/ticket/new">new ticket</a>
<a href="/r/{{.Repo}}/wiki">new wiki page</a>
<a href="/r/{{.Repo}}/technote-new">new technote</a>
{{end}}{{if .IsAdmin}}<a href="/new">new repository&hellip;</a>{{end}}
</div>{{end}}
{{if and .SignedIn .HasInbox}}<a class="bar-btn inboxbtn" href="/inbox" title="inbox" aria-label="inbox" data-init="@get('/inbox.unread')"><svg class="ic" aria-hidden="true"><use href="#i-inbox"/></svg><span id="inbox-dot"></span></a>{{end}}
<button class="avatar" popovertarget="acct" title="{{if .SignedIn}}{{.SignedIn}}{{else}}account{{end}}" aria-label="account menu">{{if .SignedIn}}{{identicon .SignedIn}}{{else}}<svg class="ic" aria-hidden="true"><use href="#i-person"/></svg>{{end}}</button>
<div id="acct" popover class="menu">
{{if .SignedIn}}<div class="menu-who">{{.SignedIn}}</div>{{end}}
{{if .IsAdmin}}<a href="/fleet">fleet</a>{{end}}
{{if .HasInbox}}<a href="/inbox">inbox</a>{{end}}
<form class="theme" action="/theme" method="post">
<input type="hidden" name="to" value="{{.NextTheme}}">
<button type="submit" aria-label="colour theme: {{.ThemeName}}, switch to {{.NextTheme}}">
<svg class="ic" aria-hidden="true"><use href="#i-{{.ThemeIcon}}"/></svg> theme: {{.ThemeName}}</button></form>
{{if .SignedIn}}<form action="/logout" method="post"><button type="submit">sign out</button></form>
{{else if .CanSignIn}}<a href="/login">sign in</a>{{end}}
</div>
</div>
</div></header>
{{if .Repo}}<div class="repobar"><div class="bar">
<a class="repo" href="/r/{{.Repo}}">{{.Repo}}</a>
{{- /* Where the repository stands. A mirror says so and names its
       upstream, because "can I contribute here?" is the question a
       reader has before any other; a detached store keeps the link as
       provenance the way a fork says where it came from. */ -}}
{{if .Posture.Mirror.Tracking}}<a class="posture posture-{{.Posture.Mirror.State}}" href="{{.Posture.Mirror.URL}}" title="tracks {{.Posture.Mirror.URL}}{{if .Posture.Mirror.LastOK}}; synced {{ago .Posture.Mirror.LastOK}}{{end}}"><svg class="ic" aria-hidden="true"><use href="#i-sync"/></svg><span class="words">mirror of {{.Posture.Host}}{{if .Posture.Mirror.AcceptWrites}} <small>· accepts writes</small>{{end}}</span></a>
{{else if .Posture.DetachedFrom}}<a class="posture" href="{{.Posture.DetachedFrom}}" title="detached from {{.Posture.DetachedFrom}}; quarry is the source of truth"><svg class="ic" aria-hidden="true"><use href="#i-branch"/></svg><span class="words">forked from {{.Posture.Host}}</span></a>{{end}}
<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 "ticket" "tickets" $s $c .Counts.Tickets 9}}
{{navlink .Repo "wiki" "wiki" $s $c .Counts.WikiPages 3}}
{{navlink .Repo "forum" "forum" $s $c .Counts.ForumThreads 2}}
{{navlink .Repo "technote" "technotes" $s $c .Counts.Technotes 2}}
{{navlink .Repo "ci" "ci" $s false false 1}}
{{navlink .Repo "chat" "chat" $s false false 1}}
{{if .IsAdmin}}<a class="cog aux1{{if eq $s "settings"}} here{{end}}" href="/r/{{.Repo}}/settings" data-tip="settings" aria-label="repository settings"><svg class="ic" aria-hidden="true"><use href="#i-gear"/></svg>{{if eq $s "settings"}}<span class="ink" aria-hidden="true"></span>{{end}}</a>{{end}}
</nav>
<button type="button" class="navmore-btn" popovertarget="navmore" aria-label="more sections"><svg class="ic" aria-hidden="true"><use href="#i-more"/></svg></button>
{{- /* GitHub's collapse: at narrow widths the aux sections fold into
       this menu so the bar owns its one line instead of wrapping. The
       links are duplicated, not moved — the popover lives OUTSIDE the
       nav so the view-transition name on the visible tab stays unique,
       and a hidden duplicate is inert. */ -}}
<div id="navmore" class="menu navmore" popover>
<span class="m3">{{navlink .Repo "wiki" "wiki" $s $c .Counts.WikiPages}}</span>
<span class="m2">{{navlink .Repo "forum" "forum" $s $c .Counts.ForumThreads}}</span>
<span class="m2">{{navlink .Repo "technote" "technotes" $s $c .Counts.Technotes}}</span>
<span class="m1">{{navlink .Repo "ci" "ci" $s false false}}</span>
<span class="m1">{{navlink .Repo "chat" "chat" $s false false}}</span>
{{if .IsAdmin}}<span class="m1"><a href="/r/{{.Repo}}/settings"{{if eq $s "settings"}} class="here"{{end}}><svg class="ic" aria-hidden="true"><use href="#i-gear"/></svg> settings</a></span>{{end}}
</div>
</div>
{{- /* Staleness as a state, not a timestamp. Lagging stays quiet — a
       quiet upstream is normal. Failing is said once, in words a reader
       who has never heard of a mirror can use. */ -}}
{{if eq .Posture.Mirror.State "failing"}}<div class="bar posture-warn">upstream {{.Posture.Host}} unreachable ({{.Posture.Mirror.Failures}} {{plural .Posture.Mirror.Failures "attempt" "attempts"}}){{if .Posture.Mirror.LastOK}}; showing content as of {{ago .Posture.Mirror.LastOK}}{{else}}; nothing has been pulled yet{{end}}</div>{{end}}
</div>{{end}}
<main{{if .BoardView}} class="board-page"{{end}}>
{{if .Counts.IndexBehind}}<p class="status stale-note"><strong>Index rebuilding.</strong>
History-derived data on this page — timeline, counts, last-change columns — may end before
the repository's real tip. File listings come straight from the store and are current.</p>{{end}}
{{if .HideH1}}<h1 class="sr-only">{{.Title}}</h1>{{else}}<h1>{{.Title}}</h1>{{end}}
{{block "content" .}}{{end}}
</main>
<footer><div class="bar">
{{- /* Three parts in priority order, because the bar is one line and
       something has to go first on a narrow screen. The render time is
       the reason this footer earns its space, so it is the part that
       never shrinks; the forge name and the version collapse ahead of
       it. Truncating the whole half dropped the timing first, which is
       exactly backwards. */ -}}
<span class="foot-l"><span class="f-brand">powered by <a href="https://github.com/danmestas/zeit">zeit</a> · </span><span class="f-ver">quarry {{.Version}}{{if .Elapsed}} · {{end}}</span>{{if .Elapsed}}<span class="f-time" data-volatile>rendered in {{.Elapsed}}{{if .Breakdown}} ({{.Breakdown}}){{end}}</span>{{end}}</span>
<span class="foot-r"><a href="/licenses">licenses</a> <a href="/privacy">privacy</a> <a href="/docs">API</a></span>
</div></footer>
</body>
</html>
{{define "ticket-views"}}
{{- /* Open/all and list/board are separate axes: a reader filtering to
       open work should keep that when switching view, and vice versa.
       Both are URL state, so both survive a reload and are linkable. */ -}}
<nav class="scopes">
<a href="/r/{{.Repo}}/ticket{{if .ShowClosed}}?closed=1{{end}}"{{if not .BoardView}} class="here"{{end}}>list</a>
<a href="/r/{{.Repo}}/ticket?view=board{{if .ShowClosed}}&amp;closed=1{{end}}"{{if .BoardView}} class="here"{{end}}>board</a>
<span class="tl-meta-inline">·</span>
<a href="/r/{{.Repo}}/ticket{{if .BoardView}}?view=board{{else if not .ByPriority}}?sort=recent{{end}}"{{if not .ShowClosed}} class="here"{{end}}>open</a>
<a href="/r/{{.Repo}}/ticket?{{if .BoardView}}view=board&amp;{{else if not .ByPriority}}sort=recent&amp;{{end}}closed=1"{{if .ShowClosed}} class="here"{{end}}>all</a>
{{if not .BoardView}}<span class="tl-meta-inline">·</span>
<a href="/r/{{.Repo}}/ticket{{if .ShowClosed}}?closed=1{{end}}"{{if .ByPriority}} class="here"{{end}}>priority</a>
<a href="/r/{{.Repo}}/ticket?sort=recent{{if .ShowClosed}}&amp;closed=1{{end}}"{{if not .ByPriority}} class="here"{{end}}>recent</a>{{end}}
</nav>
{{end}}
{{define "explore-tabs"}}
{{- /* Two tabs, not four. Repositories and people are surfaces quarry
       actually has; organisations are v2 (0001 §8 puts org/ACLs there)
       and code search indexes nothing today, and a tab that opens onto
       an empty page teaches a reader the forge is broken rather than
       that the feature is unbuilt. */ -}}
<nav class="tabs">
<a href="/?view=explore"{{if not .Explore}} class="here"{{end}}><svg class="ic" aria-hidden="true"><use href="#i-dir"/></svg> repositories {{count .FleetTotal}}{{if not .Explore}}<span class="ink" aria-hidden="true"></span>{{end}}</a>
<a href="/people"{{if eq .Explore "people"}} class="here"{{end}}><svg class="ic" aria-hidden="true"><use href="#i-people"/></svg> people {{count .PeopleTotal}}{{if eq .Explore "people"}}<span class="ink" aria-hidden="true"></span>{{end}}</a>
</nav>
{{end}}
`

var fleetPage = template.Must(template.Must(base.Clone()).Parse(fleetSource))

const fleetSource = `{{define "fleet-controls"}}
<div class="tl-controls fleet-bar">
{{- /* Live where the one allowed script runs, a plain GET form where
       it does not. The debounced @get uses Datastar's form content
       type, so it sends exactly the q= and sort= this form would submit
       on Enter — one request shape, one server path, and the listing
       follows the keystrokes without a click.

       The shell is basecoat's input-group: the border lives on the
       wrapper and the controls inside are bare, so the clear button
       reads as part of the field rather than as something beside it. */ -}}
<form method="get" action="/"
  {{- /* Live-at-load WITHOUT the stale-filter trap. The stream
       (/fleet.rev) carries only an invalidation signal — "the fleet is
       at revision N" — so it cannot repaint anything and cannot go
       stale. The effect fires on every revision bump and re-requests
       the listing as a ONE-SHOT (/fleet.frag) with the form content
       type, which serializes THIS form — so the refresh always carries
       whatever the filter says at that moment. The && guard keeps the
       effect's initial run (rev still 0) from re-fetching the page the
       server just rendered. */ -}}
{{if $.Live}} data-signals="{fleetRev: 0}" data-init="@get('/fleet.rev')" data-effect="$fleetRev && @get('/fleet.frag', {contentType: 'form'})"{{end}}>
<span class="input-group">
<input type="search" name="q" value="{{.Query}}" placeholder="filter" aria-label="filter repositories"
{{if $.Live}}data-on:input__debounce.300ms="@get('/fleet.frag', {contentType: 'form'})"{{end}}>
{{if .Query}}<a class="clearq" href="/?view=explore{{if .Sort}}&amp;sort={{.Sort}}{{end}}" aria-label="clear the filter">&times;</a>{{end}}
</span>
<select name="kind" aria-label="kind" data-on:change="@get('/fleet.frag', {contentType: 'form'})" onchange="this.form.submit()">
<option value=""{{if eq .Kind ""}} selected{{end}}>all</option>
<option value="mirror"{{if eq .Kind "mirror"}} selected{{end}}>mirrors</option>
<option value="native"{{if eq .Kind "native"}} selected{{end}}>native</option>
</select>
<select name="sort" aria-label="sort" data-on:change="@get('/fleet.frag', {contentType: 'form'})" onchange="this.form.submit()">
<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>
{{if .IsAdmin}}<span class="tree-acts"><a href="/new">New repository</a></span>{{end}}
</div>
{{end}}
{{define "fleet-body"}}
{{- /* The count belongs with the listing, not with the controls: it
       DESCRIBES the rows, so it has to be repainted with them or it
       goes stale the moment the filter changes.

       Filtered, the useful denominator is the fleet rather than the
       match count — "1 of 1" is true and says nothing. Unfiltered there
       is nothing to compare against, so it is just the total. */ -}}
<p class="tl-meta-inline fleet-count">{{if or .Query .Kind}}{{len .Fleet}} of {{.Served}} repositories{{else if .FleetTotal}}{{.FleetTotal}} repositories{{end}}</p>

{{if .Fleet}}{{- /* Below the width where a header carries its own words the
       icons stand alone: the sr-only label names each for a screen
       reader and the tooltip for a pointer. A legend used to restate
       all four in a row above the header — which read as a duplicate
       of the header itself and said nothing the tooltips do not. Touch
       readers at narrow widths are the honest gap; the numbers' shapes
       (counts, durations) carry most of the meaning there. */ -}}
<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. */ -}}
{{- /* An icon with only a title tells a touch reader nothing: there is
       no hover, so the label is unreachable. Each header carries a
       VISIBLE label wherever the table has room for one, and the legend
       above names them where it does not. */ -}}
<thead><tr><th scope="col">Repository</th>
{{tipcol "commit" "check-ins"}}{{tipcol "people" "contributors"}}
{{tipcol "sync" "last mirrored"}}{{tipcol "clock" "last activity"}}</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. */ -}}
{{- /* A repository's name opens the repository itself, which is its
       files. The timeline is one of its sections, reachable from the
       nav; landing there made every arrival start with what changed
       rather than with what the project IS. */ -}}
<tr class="r-top"><td class="r-name"><a href="/r/{{.Name}}">{{.Name}}</a>{{if .Mirror.Tracking}} <span class="tl-badge posture-badge" title="mirror of {{.Mirror.URL}}{{if eq .Mirror.State "failing"}} — failing{{end}}">mirror</span>{{else if .DetachedFrom}} <span class="tl-badge posture-badge" title="forked from {{.DetachedFrom}}">fork</span>{{end}}{{if .Private}} <span class="tl-badge">private</span>{{end}}</td>
{{- /* Compact, as the nav already does: tcl's 25,557 set this column's
       width for every row, and nobody reads a check-in count to five
       significant figures. The exact figure stays in the title. */ -}}
<td class="n">{{if .Indexing}}<small class="tl-meta-inline">…</small>{{else}}{{count .Checkins}}{{end}}</td>
<td class="n">{{if .Contributors}}{{count .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}}
{{- /* The controls sit OUTSIDE #fleet on purpose. Everything inside is
       replaced on every repaint, so a filter box in there would be torn
       out from under the reader mid-keystroke and would re-run its own
       data-init on each morph. Out here the input is a stable element:
       it owns the stream, and typing re-opens it rather than racing a
       second one. */ -}}
{{define "content"}}{{template "explore-tabs" .}}{{template "fleet-controls" .}}
<div><div id="fleet">{{template "fleet-body" .}}</div></div>{{end}}`

var licensesPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<h2>quarry</h2>
<p>quarry — the forge software serving this site — is BSD 2-Clause.</p>
<pre class="licence">{{.QuarryLicence}}</pre>

<h2>The repositories served here</h2>
<p>Each repository quarry serves carries its own licence, chosen by the
people who wrote it. quarry makes no claim over any of it: a store is
hosted content, not part of this software. Look in the repository itself
— conventionally a <code>LICENSE</code>, <code>LICENCE</code> or
<code>COPYING</code> file at the root of its tree — for the terms that
actually apply to that project.</p>

<h2>Third-party components</h2>
<p>quarry links the modules below. They are listed because their licences
require reproducing them, not because quarry claims anything about them.
The list is generated from the build graph, so it cannot quietly fall
behind what this binary contains.</p>
<details><summary>{{len .Notices}} modules</summary>
{{range .Notices}}
<h3 id="{{.Module}}">{{.Module}}</h3>
<p class="tl-meta-inline">{{.Version}} · {{.License}}</p>
<pre class="licence">{{.Text}}</pre>
{{end}}
</details>
{{end}}`))

var privacyPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p>This page describes what this quarry instance stores. It describes the
software's behaviour, not a legal commitment by whoever runs it.</p>

<h2>What is public by construction</h2>
<p>A repository quarry serves is a set of artifacts, and every artifact
records who wrote it and when. Names, e-mail addresses and dates inside
check-ins, wiki edits, tickets, forum posts and technotes are part of the
repository's content: quarry displays them, indexes them for search, and
serves them over the sync protocol. It cannot remove them without
rewriting history, which it will not do. If a repository is public, so is
everything in it.</p>

<h2>What quarry itself keeps</h2>
<ul>
<li><strong>Accounts</strong> — a login name, a password hash (argon2id;
the password itself is never stored), whether the account is a site
admin, and its creation date. Per-repository grants are stored alongside.</li>
<li><strong>Sessions</strong> — a random token in a cookie, with an
expiry. Signing out deletes it.</li>
<li><strong>Sync credentials</strong> — a shared secret per repository
user, as the sync protocol defines it. Never a plaintext password.</li>
<li><strong>Your colour theme</strong> — a cookie holding
"light" or "dark", set only if you choose one. Choosing "system" removes
it. It is not used to identify you.</li>
</ul>

<h2>What quarry does not do</h2>
<ul>
<li>No third-party analytics, advertising, or trackers. The pages load
nothing from another host — there is no external stylesheet, font, image
or script anywhere in this interface.</li>
<li>No avatar fetching. The circle in the corner is your initials drawn
locally, not an image requested from a third party using your address.</li>
<li>No access log of readers is kept by quarry itself. The server it runs
behind may keep its own; that is outside this software.</li>
</ul>

<h2>Deletion</h2>
<p>Deleting an account removes it, its sessions and its grants. It does
not remove artifacts that account authored, because those are repository
content and belong to the history rather than to the forge.</p>
{{end}}`))

var peoplePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{template "explore-tabs" .}}
<div class="tl-controls">
<form method="get" action="/people"><input type="search" name="q" value="{{.Query}}" placeholder="filter" aria-label="filter people">
<noscript><button type="submit">go</button></noscript></form>
</div>
{{if .People}}<ul class="people">{{range .People}}
<li><span class="avatar" aria-hidden="true">{{identicon .User}}</span>
<div class="p-main"><a href="/people/{{pathesc .User}}">{{.User}}</a>
<div class="p-meta">{{count .Events}} contribution{{if ne .Events 1}}s{{end}} in {{len .Repos}} repositor{{if eq (len .Repos) 1}}y{{else}}ies{{end}} · last {{ago .Last}}</div></div></li>
{{end}}</ul>
<nav class="pager" aria-label="people pages">
{{if .PrevPage}}<a class="pager-btn" href="/people?page={{.PrevPage}}{{if .Query}}&amp;q={{.Query}}{{end}}">&lsaquo; Newer</a>{{else}}<span class="pager-btn" aria-disabled="true">&lsaquo; Newer</span>{{end}}
<span class="pager-where"><b>{{len .People}}</b> of {{thousands .PeopleTotal}}</span>
{{if .NextPage}}<a class="pager-btn" href="/people?page={{.NextPage}}{{if .Query}}&amp;q={{.Query}}{{end}}">Older &rsaquo;</a>{{else}}<span class="pager-btn" aria-disabled="true">Older &rsaquo;</span>{{end}}
</nav>
{{else}}<p>{{if .Query}}Nobody matches “{{.Query}}”.{{else}}No contributions have been indexed yet.{{end}}</p>{{end}}
{{end}}`))

var personPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<div class="person-head">
<span class="avatar" aria-hidden="true">{{identicon .Title}}</span>
<div><h1>{{.Title}}</h1>
<p class="p-meta">{{count .Person.Events}} contribution{{if ne .Person.Events 1}}s{{end}} ·
first {{ago .Person.First}} · last {{ago .Person.Last}}</p></div>
</div>
<h2>Repositories</h2>
<ul class="hits">{{range .Person.Repos}}
<li><a href="/r/{{.}}">{{.}}</a></li>
{{end}}</ul>
{{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}}
{{if .Posture.ReadOnly}}<p><a href="{{.Posture.Upstream "wikinew"}}">new page on {{.Posture.Host}} &rarr;</a></p>{{else}}<form method="get" action="/r/{{.Repo}}/wiki-new"><input name="name" placeholder="new page name" required> <button type="submit">Create</button></form>{{end}}
{{end}}`))

var forumQueuePage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><a href="/r/{{.Repo}}/forum">← forum</a> · <a href="/r/{{.Repo}}/forum/modlog">moderation log</a></p>
<form method="post" action="/r/{{.Repo}}/forum/queue"><button name="toggle" value="{{if .ModOn}}off{{else}}on{{end}}">turn moderation {{if .ModOn}}off{{else}}on{{end}}</button>
<small>{{if .ModOn}}new posts from accounts without a role are held here{{else}}everything posts directly{{end}}</small></form>
{{if .Queue}}{{range .Queue}}<article style="border-top:1px solid var(--rule); padding-top:0.5rem; margin-top:0.5rem">
<p><small>#{{.ID}} · {{.Kind}} by <strong>{{.Author}}</strong> · {{ago .At}}{{if .AIAction}} · hook: <strong>{{.AIAction}}</strong> ({{printf "%.2f" .AIScore}}){{if .AIReason}} — {{.AIReason}}{{end}}{{end}}</small></p>
{{if .Title}}<p><strong>{{.Title}}</strong></p>{{end}}
<pre style="white-space:pre-wrap">{{.Body}}</pre>
<form method="post" action="/r/{{$.Repo}}/forum/queue">
<input type="hidden" name="id" value="{{.ID}}">
<button name="action" value="approve" class="btn-primary">Approve</button>
<input name="reason" placeholder="reason (required to reject)">
<button name="action" value="reject">Reject</button>
</form>
</article>{{end}}{{else}}<p>The queue is empty.</p>{{end}}
{{end}}`))

var forumModlogPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
<p><a href="/r/{{.Repo}}/forum">← forum</a></p>
<p><small>Every moderation action — human and automated — is recorded here permanently. There is no way to edit or remove an entry.</small></p>
{{if .ModLog}}<ul>{{range .ModLog}}
<li><small>{{ago .At}}</small> <strong>{{.Actor}}</strong> {{.Action}} <code>{{.Target}}</code>{{if .Reason}} — {{.Reason}}{{end}}</li>
{{end}}</ul>{{else}}<p>No moderation actions.</p>{{end}}
{{end}}`))

var forumListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Flash}}<p class="status">{{.Flash}}</p>{{end}}
{{range .ForumPinned}}<p class="f-pinned" style="border:1px solid var(--rule); border-radius:6px; padding:0.4rem 0.6rem"><span class="b-pill">announcement</span> <a href="/r/{{$.Repo}}/forum/{{printf "%.16s" .Root}}"><strong>{{if .Title}}{{.Title}}{{else}}(untitled){{end}}</strong></a> <small>{{.Posts}} post{{if ne .Posts 1}}s{{end}} · last {{ago .Updated}}</small></p>
{{end}}<p><small>{{if eq .Sort "hot"}}<a href="/r/{{.Repo}}/forum">recent</a> · <strong>hot</strong>{{else}}<strong>recent</strong> · <a href="/r/{{.Repo}}/forum?sort=hot">hot</a>{{end}}</small></p>
{{if .Forum}}<div class="ft-list">{{range .Forum}}
<div class="ft-row{{if .Dormant}} dim{{end}}">
<div class="ft-main">
<div class="ft-t">{{if .HasPoll}}<span class="b-pill">poll</span> {{end}}<a href="/r/{{$.Repo}}/forum/{{printf "%.16s" .Root}}">{{if .Title}}{{.Title}}{{else}}(untitled thread){{end}}</a>{{if .Score}} <span class="ft-score" title="net votes" data-score="{{.Score}}">▲ {{.Score}}</span>{{end}}</div>
{{if .Excerpt}}<p class="ft-x">{{.Excerpt}}</p>{{end}}
<p class="ft-meta">{{identicon .User}} <strong>{{.User}}</strong> · started {{ago .Created}} · last reply {{ago .Updated}}</p>
</div>
<div class="ft-side">
<span class="ft-stack" title="{{len .Participants}} participant{{if ne (len .Participants) 1}}s{{end}}">{{range .Participants}}{{identicon .}}{{end}}</span>
<span class="ft-count" title="{{.Posts}} post{{if ne .Posts 1}}s{{end}}">{{.Posts}}</span>
{{spark .Spark}}
</div>
</div>
{{end}}</div>{{else}}<p>No forum threads.</p>{{end}}
{{if eq .Sort "hot"}}<p class="hot-formula"><small>ranked by (score + ½·replies) ÷ (age_hours + 2)<sup>1.8</sup>; threads quiet for 30 days follow in recency order</small></p>{{end}}
<div class="f-actions">{{if .Posture.ReadOnly}}<a class="btn" href="{{.Posture.Upstream "forumnew"}}">New thread on {{.Posture.Host}} &rarr;</a>{{else}}<a class="btn" href="/r/{{.Repo}}/forum-new">New thread</a>{{if .CanWrite}}<a class="btn" href="/r/{{.Repo}}/announce-new">New announcement</a><a href="/r/{{.Repo}}/forum/queue">queue</a>{{end}}{{end}}<small><a href="/r/{{.Repo}}/forum/modlog">moderation log</a></small></div>
{{end}}`))

var forumAnnouncePage = template.Must(template.Must(base.Clone()).Parse(composerSource + `{{define "content"}}
<p><small>Announcements pin above the forum and surface on the repository home. Anyone can reply; only maintainers start one.</small></p>
<form method="post" action="/r/{{.Repo}}/announce-new">
<p><label>Title<br><input name="title" required></label></p>
<div class="f-reply">{{template "composer" (composerctx .Repo 14 "Markdown — type / for blocks")}}
<div class="f-reply-foot"><a href="/r/{{.Repo}}/forum">cancel</a> <button type="submit" class="btn-primary">Announce</button></div></div>
</form>
{{end}}`))

// voteWidgetSource is the pair of arrows and the score. The FORMS are
// the mechanism (no-JS: POST + redirect, back where you were); datastar
// layers @post over submit, and the vote handler answers a datastar
// request with an SSE patch of exactly this span — no page flash. One
// form per arrow so the direction rides a hidden input: fetch-built
// form data never carries the clicked button.
var voteWidgetSource = `{{define "vote-widget"}}<span class="vote-pill" id="vote-{{printf "%.12s" .Hash}}" data-score="{{.Score}}"><form method="post" action="/r/{{.Repo}}/forum/{{.Root}}/vote" data-on-submit="@post('/r/{{.Repo}}/forum/{{.Root}}/vote', {contentType: 'form'})"><input type="hidden" name="post" value="{{.Hash}}"><input type="hidden" name="dir" value="{{if eq .YourVote 1}}none{{else}}up{{end}}"><button title="{{if eq .YourVote 1}}retract upvote{{else}}upvote{{end}}"{{if eq .YourVote 1}} class="vote-cast"{{end}}>▲</button></form><span class="vscore">{{.Score}}</span><form method="post" action="/r/{{.Repo}}/forum/{{.Root}}/vote" data-on-submit="@post('/r/{{.Repo}}/forum/{{.Root}}/vote', {contentType: 'form'})"><input type="hidden" name="post" value="{{.Hash}}"><input type="hidden" name="dir" value="{{if eq .YourVote -1}}none{{else}}down{{end}}"><button title="{{if eq .YourVote -1}}retract downvote{{else}}downvote{{end}}"{{if eq .YourVote -1}} class="vote-cast"{{end}}>▼</button></form></span>{{end}}`

// voteCtx feeds vote-widget: one post's vote state on one thread page.
type voteCtx struct {
	Repo, Root, Hash string
	Score, YourVote  int
}

// composerSource is the markdown composer: Write | Preview tabs over a
// raw textarea (raw text is never more than one click away — it IS the
// Write tab). Without JS the tab bar stays hidden and the composer is a
// plain textarea. Preview posts the draft to markup-preview, which runs
// the PRODUCTION render pipeline and patches the pane — the preview
// cannot drift from what a reader will see.
var composerSource = `{{define "composer"}}<div class="f-composer" data-signals="{mdprev: false}" data-live="/r/{{.Repo}}/markup-preview?plain=1">
<div class="f-tabs" hidden data-attr:hidden="false">
<button type="button" data-on-click="$mdprev = false" data-attr:class="$mdprev ? 'f-tab' : 'f-tab here'" class="f-tab here">Write</button>
<button type="button" data-on-click="$mdprev = true; @post('/r/{{.Repo}}/markup-preview', {contentType: 'form'})" data-attr:class="$mdprev ? 'f-tab here' : 'f-tab'" class="f-tab">Preview</button>
</div>
<div class="f-split" data-show="!$mdprev">
<textarea name="{{.FieldName}}" rows="{{.Rows}}" placeholder="{{.Placeholder}}"{{if not .Optional}} required{{end}} data-slash>{{.Value}}</textarea>
<div class="f-live" hidden>
<div class="f-poll-builder"></div>
<div class="f-live-pane"></div>
</div>
</div>
<div id="mdpreview" class="f-preview" hidden data-show="$mdprev"></div>
</div>{{end}}`

// composerCtx feeds the composer define.
type composerCtx struct {
	Repo, Placeholder, Value string
	Rows                     int
	Name                     string // form field; "" means body
	Optional                 bool   // the text may be empty (a status change alone)
}

// FieldName is the textarea's name; the forum's reply is "body", a
// ticket's remark is "comment".
func (c composerCtx) FieldName() string {
	if c.Name == "" {
		return "body"
	}
	return c.Name
}

var forumThreadPage = template.Must(template.Must(base.Clone()).Parse(voteWidgetSource + composerSource + `{{define "content"}}
<div class="f-actions"><a href="/r/{{.Repo}}/forum">← forum</a>{{if .CanWrite}}
<form method="post" action="/r/{{.Repo}}/forum/{{.ForumRoot}}/subscribe" style="margin-left:auto">
{{if eq .YourSub "sub"}}<button name="state" value="unsub" title="stop notifications for this thread">Unsubscribe</button>{{else}}<button name="state" value="sub" title="notify me about replies in this thread">Subscribe</button>{{end}}
</form>{{end}}</div>
{{if .Flash}}<p class="status">{{.Flash}}</p>{{end}}
{{spectrum .ForumPosts}}
{{$op := ""}}{{if .ForumPosts}}{{$op = (index .ForumPosts 0).User}}{{end}}
{{range .ForumPosts}}<article class="f-post" id="p-{{printf "%.12s" .Hash}}" style="--uh: {{userhue .User}}">
<div class="f-post-h">{{identicon .User}}<strong>{{.User}}</strong>{{if and $op (eq .User $op)}} <span class="b-pill" title="started this thread">opener</span>{{end}} {{ago .Date}}{{if .Parent}} · replying to <code>{{printf "%.12s" .Parent}}</code>{{end}}<span class="f-spacer"></span><code>{{printf "%.12s" .Hash}}</code>
{{- if $.CanWrite}} {{template "vote-widget" (votectx $.Repo $.ForumRoot .Hash .Score .YourVote)}}{{else if .Score}} <span class="vote-pill" data-score="{{.Score}}"><span class="vscore" style="padding:0.1rem 0.5rem">▲ {{.Score}}</span></span>{{end}}
{{- if $.CanMod}} <details class="f-kebab"><summary aria-label="moderate">···</summary><div class="f-kebab-pop">
<form method="post" action="/r/{{$.Repo}}/forum/{{$.ForumRoot}}/hide">
<input type="hidden" name="post" value="{{.Hash}}">
{{if .Hidden}}<button name="action" value="unhide">Unhide</button>{{else}}<input name="reason" placeholder="reason (public)" required> <button name="action" value="hide">Hide</button>{{end}}
</form></div></details>{{end}}</div>
<div class="f-post-b">
{{if .Hidden}}<details class="f-hidden"><summary><small>hidden by a moderator — {{.HiddenReason}}</small></summary>{{.HTML}}</details>{{else}}{{.HTML}}{{end}}
{{$p := .}}{{if .Poll.Present}}{{with .Poll}}<div style="border:1px solid var(--rule); border-radius:6px; padding:0.6rem 0.8rem; max-width:30rem; margin:0.5rem 0">
<p style="margin:0 0 0.4rem"><strong>{{.Question}}</strong> <small>{{.Total}} {{plural .Total "ballot" "ballots"}}{{if .Closed}} · closed{{else if .Closes}} · closes {{.Closes}}{{end}}</small></p>
{{range $i, $o := .Options}}<div style="margin:0.35rem 0">
<div><small>{{if and $.CanWrite (not $p.Poll.Closed)}}<form method="post" action="/r/{{$.Repo}}/forum/{{$.ForumRoot}}/poll" style="display:inline"><input type="hidden" name="post" value="{{$p.Hash}}"><button name="choice" value="{{$i}}" title="{{if eq $p.Poll.YourChoice $i}}your ballot{{else}}vote {{$o}}{{end}}" style="background:none;border:none;cursor:pointer;padding:0 0.15rem{{if eq $p.Poll.YourChoice $i}};color:var(--accent){{end}}">{{if eq $p.Poll.YourChoice $i}}◉{{else}}○{{end}}</button></form>{{end}}{{$o}} · {{index $p.Poll.Counts $i}}</small></div>
<div style="height:6px; background:var(--rule); border-radius:3px"><div style="height:6px; width:{{index $p.Poll.Percents $i}}%; background:var(--accent); border-radius:3px"></div></div>
</div>{{end}}
</div>{{end}}{{end}}
</div>
</article>{{end}}
{{if .Posture.ReadOnly}}<p style="margin-top:1rem"><a href="{{.Posture.Upstream "forum"}}">reply on {{.Posture.Host}} &rarr;</a></p>{{else}}<form class="f-reply" method="post" action="/r/{{.Repo}}/forum/{{.ForumRoot}}/reply">
{{template "composer" (composerctx .Repo 6 "Write a reply (Markdown — type / for blocks)")}}
<div class="f-reply-foot"><button type="submit" class="btn-primary">Post reply</button></div>
</form>{{end}}
{{end}}`))

var forumNewPage = template.Must(template.Must(base.Clone()).Parse(composerSource + `{{define "content"}}
<form method="post" action="/r/{{.Repo}}/forum-new">
<p><label>Title<br><input name="title" required></label></p>
<div class="f-reply">{{template "composer" (composerctx .Repo 14 "Markdown — type / for blocks")}}
<div class="f-reply-foot"><a href="/r/{{.Repo}}/forum">cancel</a> <button type="submit" class="btn-primary">Open thread</button></div></div>
</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>{{if .Posture.ReadOnly}}<a href="{{.Posture.Upstream "technoteedit"}}">new technote on {{.Posture.Host}} &rarr;</a>{{else}}<a href="/r/{{.Repo}}/technote-new">new technote</a>{{end}}</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}} ·
{{if .Posture.ReadOnly}}<a href="{{.Posture.Upstream "technoteedit"}}?name={{.Technote.Event}}">edit on {{.Posture.Host}} &rarr;</a>{{else}}<a href="/r/{{.Repo}}/technote/{{printf "%.16s" .Technote.Event}}/edit">edit</a>{{end}}</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}}`))

// ticketVocab renders the repository's vocabulary as datalists —
// shared by the new and edit forms, which are separate template trees;
// a define in one is invisible to the other.
const ticketVocab = `{{define "ticket-vocab"}}
{{- /* The repository's own words (ticket.config), offered as
       SUGGESTIONS: a datalist completes, a select would refuse a word
       the tracker already uses. */ -}}
{{range $field, $words := .Vocab}}{{if $words}}<datalist id="v-{{$field}}">{{range $words}}<option value="{{.}}">{{end}}</datalist>
{{end}}{{end}}{{end}}`

// The triage fields are `input list=` rather than `select`: the
// vocabulary below is fossil's DEFAULT, and a tracker may use any words
// it likes. A select would refuse a word the tracker already writes,
// which would leave quarry unable to edit trackers it can read.
var ticketNewPage = template.Must(template.Must(base.Clone()).Parse(ticketVocab + `{{define "content"}}
<form method="post" action="/r/{{.Repo}}/ticket/new">
<p><label>Title<br><input name="title" required value="{{.Prefill.Title}}"></label></p>
<div class="fieldrow">
<label>Status<br><input name="status" value="Open" list="v-status"></label>
<label>Type<br><input name="type" list="v-type" value="{{.Prefill.Type}}"></label>
<label>Severity<br><input name="severity" list="v-severity" value="{{.Prefill.Severity}}"></label>
<label>Priority<br><input name="priority" list="v-priority" value="{{.Prefill.Priority}}"></label>
<label>Subsystem<br><input name="subsystem" value="{{.Prefill.Subsystem}}"></label>
</div>
<p><label>Comment<br><textarea name="comment" rows="8">{{.Prefill.Comment}}</textarea></label></p>
<p><button type="submit">Open ticket</button></p>
</form>
{{template "ticket-vocab" .}}
{{end}}
`))

var ticketEditPage = template.Must(template.Must(base.Clone()).Parse(ticketVocab + `{{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>
<div class="fieldrow">
<label>Status<br><input name="status" value="{{.Ticket.Field "status"}}" list="v-status"></label>
<label>Resolution<br><input name="resolution" value="{{.Ticket.Field "resolution"}}" list="v-resolution"></label>
<label>Type<br><input name="type" value="{{.Ticket.Field "type"}}" list="v-type"></label>
<label>Severity<br><input name="severity" value="{{.Ticket.Field "severity"}}" list="v-severity"></label>
<label>Priority<br><input name="priority" value="{{.Ticket.Field "priority"}}" list="v-priority"></label>
<label>Subsystem<br><input name="subsystem" value="{{.Ticket.Field "subsystem"}}"></label>
</div>
<p><label>Comment<br><textarea name="comment" rows="6" placeholder="add a comment"></textarea></label></p>
{{template "ticket-vocab" .}}
<p><button type="submit">Save change</button> <a href="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}">cancel</a></p>
</form>
{{end}}`))

// commentText flattens fossil-wiki link syntax for places that want
// the comment as TEXT — the h1, the <title>. "[target|label]" reads as
// its label, "[target]" as its target; fossil renders these as links
// where there is room for markup, but a heading full of pipe syntax
// reads as a rendering bug.
var fossilCommentLink = regexp.MustCompile(`\[([^\]|]*)\|([^\]]*)\]|\[([^\]]*)\]`)

func commentText(s string) string {
	return fossilCommentLink.ReplaceAllStringFunc(s, func(m string) string {
		sub := fossilCommentLink.FindStringSubmatch(m)
		if sub[2] != "" {
			return sub[2]
		}
		return sub[3]
	})
}

var hexTarget = regexp.MustCompile(`^[0-9a-f]{6,64}$`)

// commentHTML renders a comment the way fossil renders it: escaped
// text with the bracket syntax turned into real links. A hex target
// goes to /info, which resolves it to a check-in, ticket, wiki edit,
// technote or forum post at click time — the renderer cannot know the
// kind, and fossil's own comments reference tickets as often as
// check-ins. An http(s) target leaves the site; anything else stays
// literal text, brackets and all. (A "[0]" is a footnote, not a page.)
func commentHTML(repo, s string) template.HTML {
	var b strings.Builder
	last := 0
	for _, m := range fossilCommentLink.FindAllStringSubmatchIndex(s, -1) {
		b.WriteString(template.HTMLEscapeString(s[last:m[0]]))
		last = m[1]
		target, label := "", ""
		if m[2] >= 0 {
			target, label = s[m[2]:m[3]], s[m[4]:m[5]]
		} else {
			target = s[m[6]:m[7]]
			label = target
		}
		href := ""
		switch {
		case hexTarget.MatchString(strings.ToLower(target)):
			href = "/r/" + url.PathEscape(repo) + "/info/" + url.PathEscape(target)
		case strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://"):
			href = target
		}
		if href == "" || label == "" {
			b.WriteString(template.HTMLEscapeString(s[m[0]:m[1]]))
			continue
		}
		b.WriteString(`<a href="` + template.HTMLEscapeString(href) + `">` +
			template.HTMLEscapeString(label) + `</a>`)
	}
	b.WriteString(template.HTMLEscapeString(s[last:]))
	return template.HTML(b.String())
}

var checkinPage = template.Must(template.Must(base.Clone()).Parse(diffBodySource + `{{define "content"}}
{{- /* GitHub's shape: the page is NAMED by its hash and the message
       is CONTENT — a quiet box at body size, subject bolded, the rest
       verbatim with its line breaks, bracket links live as fossil
       renders them. No forge headlines the message, because none can
       trust its length: fossil comments have no subject-line
       convention and 2% of first lines in real repositories run past
       200 characters. The full manifest lives in the tree browser —
       this page answers "what changed". */ -}}
<div class="ci-h">check-in <code title="{{.Checkin.Hash}}">{{printf "%.12s" .Checkin.Hash}}</code></div>
<div class="ci-box">
<p class="ci-sub{{if .CheckinLongLead}} long{{end}}">{{.CheckinLead}}</p>
{{if .CheckinBody}}<div class="ci-msg">{{.CheckinBody}}</div>{{end}}
</div>
<div class="repo-stats">
<span>{{if .CIBadge}}{{.CIBadge}} {{end}}{{ago .Checkin.Date}} by {{.Checkin.User}}</span>
<a href="/r/{{.Repo}}/timeline?branch={{.Checkin.Branch}}"><svg class="ic" aria-hidden="true"><use href="#i-branch"/></svg> {{.Checkin.Branch}}</a>
{{range $i, $p := .Checkin.Parents}}<a href="/r/{{$.Repo}}/checkin/{{$p}}" title="{{$p}}">{{if $i}}merged from{{else}}parent{{end}}</a>{{end}}
<a href="/r/{{.Repo}}/tree/{{.Checkin.Hash}}/"><svg class="ic" aria-hidden="true"><use href="#i-dir"/></svg> browse files</a>
</div>
{{if .Checkin.DiscussedIn}}<p><small>discussed in {{range $i, $t := .Checkin.DiscussedIn}}{{if $i}} · {{end}}<a href="/r/{{$.Repo}}/forum/{{printf "%.16s" $t.Root}}">{{if $t.Title}}{{$t.Title}}{{else}}(untitled thread){{end}}</a>{{end}}</small></p>{{end}}
{{template "diff-body" .}}
{{end}}`))

var ticketBoardPage = template.Must(template.Must(base.Clone()).Parse(boardSource))

const boardSource = `{{define "content"}}
{{template "ticket-views" .}}
{{- /* The board is a grid of server-grouped columns crossed by
       swimlanes. Everything works with no script at all: a card is moved
       with the select beneath it, which submits like any form.

       That select is the MECHANISM, not a fallback. Dragging is layered
       over it for pointer users and picking-up is layered over it for
       everyone else; hiding the select once script loads would leave a
       board that keyboard and assistive-technology readers cannot
       operate. All three do the same POST. */ -}}
<form class="board-axes" method="get" action="/r/{{.Repo}}/ticket">
<input type="hidden" name="view" value="board">
{{if .ShowClosed}}<input type="hidden" name="closed" value="1">{{end}}
<label>columns <select name="group" onchange="this.form.submit()">
{{range .AxisFields}}<option value="{{.Key}}"{{if eq .Key $.GroupBy}} selected{{end}}>{{.Label}}</option>{{end}}
</select></label>
<label>lanes <select name="lane" onchange="this.form.submit()">
<option value="none"{{if eq .LaneBy ""}} selected{{end}}>none</option>
{{range .AxisFields}}{{if ne .Key $.GroupBy}}<option value="{{.Key}}"{{if eq .Key $.LaneBy}} selected{{end}}>{{.Label}}</option>{{end}}{{end}}
</select></label>
<noscript><button type="submit">apply</button></noscript>
{{if .Truncated}}<span class="board-trunc">showing the {{count .Shown}} most recently updated</span>{{end}}
</form>
<div id="board" data-signals="{held: '', over: ''}">{{template "board-body" .}}</div>
{{end}}

{{define "board-body"}}
{{if .Board}}
{{- /* Feedback for the last move, with its undo: the one thing a reader
       needs after a drop is to know it landed, and a way back. A live
       region so it is announced; a form so it works without script. */ -}}
{{with .Moved}}<div class="board-moved" role="status">Moved <strong>{{.Title}}</strong>{{if .From}} from {{.From}}{{end}} to <strong>{{.To}}</strong>
<form method="post" action="/r/{{$.Repo}}/ticket/{{.ID}}/move?to={{if .From}}{{.From}}{{else}}{{unsetLane}}{{end}}&amp;field={{.Field}}&amp;group={{$.GroupBy}}&amp;lane={{if $.LaneBy}}{{$.LaneBy}}{{else}}none{{end}}{{if $.ShowClosed}}&amp;closed=1{{end}}" data-on:submit__prevent="@post(evt.target.action)"><button type="submit">undo</button></form></div>{{end}}
{{range .Board}}
{{if $.LaneBy}}<h2 class="lane-h"><span class="lane-name">{{.Label}}</span> <span class="n">{{count .Count}}</span></h2>{{end}}
<div class="board">{{range .Columns}}
{{- /* The column is the drop target. While a card is over it the
       column says so (data-class:over), which is the signifier a drag
       needs: where this will land. */ -}}
<section class="board-col{{if not .Expanded}} folded{{end}}"
  data-class:over="$over === '{{.Status}}'"
  data-on:dragover__prevent="$over = '{{.Status}}'"
  data-on:dragleave="$over === '{{.Status}}' &amp;&amp; ($over = '')"
  data-on:drop__prevent="$over = ''; $held &amp;&amp; (@post('/r/{{$.Repo}}/ticket/' + $held + '/move?to={{.Status}}&amp;field={{$.GroupBy}}&amp;group={{$.GroupBy}}&amp;lane={{if $.LaneBy}}{{$.LaneBy}}{{else}}none{{end}}{{if $.ShowClosed}}&amp;closed=1{{end}}'), $held = '')">
{{- /* A folded column is GitHub's collapsed column: the name and its
       count on a narrow strip, still a drop target. The name links to
       the same view with this column opened. */ -}}
{{if not .Expanded}}<h3><a class="unfold" href="{{.ToggleURL}}" title="open the {{.Status}} column"><span class="t-status {{statusClass .Status}}">{{.Status}}</span> <span class="n">{{len .Tickets}}</span> <span class="chev" aria-hidden="true">&rsaquo;</span></a></h3>
{{else}}<h3><span class="t-status {{statusClass .Status}}">{{.Status}}</span> <span class="n">{{count (len .Tickets)}}</span> <a class="fold" href="{{.ToggleURL}}" aria-label="fold the {{.Status}} column" title="fold"><span class="chev" aria-hidden="true">&lsaquo;</span></a></h3>{{end}}
{{range .Tickets}}
{{- /* Two ways to move a card, one per kind of input: drag it (the
       grip says so, the cursor says so), or open its Move menu — a
       native popover of forms, so it works by keyboard, by touch, and
       with scripting off. Nothing is a mode. */ -}}
<article class="card" data-ticket="{{.ID}}"{{if $.CanWrite}} draggable="true"
  data-on:dragstart="$held = '{{printf "%.10s" .ID}}'"
  data-on:dragend="$held = ''; $over = ''"
  data-class:held="$held === '{{printf "%.10s" .ID}}'"{{end}}>
{{if $.CanWrite}}<span class="grip" aria-hidden="true" title="drag to move">&#x2807;</span>{{end}}
<a class="t-title" href="/r/{{$.Repo}}/ticket/{{.ID}}">{{.Title}}</a>
<div class="t-badges">
{{- /* A lane already names its field for every card in it; repeating
       the badge on each card is noise. */ -}}
{{if and .Priority (ne $.LaneBy "priority")}}<span class="b-pri" title="priority">{{.Priority}}</span>{{end}}
{{if and .Severity (ne $.LaneBy "severity")}}<span class="b-sev" title="severity">{{.Severity}}</span>{{end}}
{{if and .Type (ne $.LaneBy "type")}}<span class="b-type" title="type">{{.Type}}</span>{{end}}
{{if and .Subsystem (ne $.LaneBy "subsystem")}}<span class="b-sub" title="subsystem">{{.Subsystem}}</span>{{end}}
</div>
<div class="t-meta"><span class="t-id" title="{{.ID}}">#{{printf "%.8s" .ID}}</span> · {{ago .Updated}}{{if $.CanWrite}}
{{$id := printf "%.10s" .ID}}{{$here := .}}<button type="button" class="mv-btn" popovertarget="mv-{{$id}}" style="anchor-name: --mv-{{$id}}" aria-label="move {{.Title}}">Move <span class="caret" aria-hidden="true">&#9662;</span></button>
<div id="mv-{{$id}}" popover class="menu mv" style="position-anchor: --mv-{{$id}}">
{{range $.ColumnLabels}}{{if ne . (fieldOf $here $.GroupBy)}}<form method="post" action="/r/{{$.Repo}}/ticket/{{$id}}/move?to={{.}}&amp;field={{$.GroupBy}}&amp;group={{$.GroupBy}}&amp;lane={{if $.LaneBy}}{{$.LaneBy}}{{else}}none{{end}}{{if $.ShowClosed}}&amp;closed=1{{end}}" data-on:submit__prevent="@post(evt.target.action)"><button type="submit">{{.}}</button></form>{{end}}{{end}}
</div>{{end}}</div>
</article>
{{end}}
</section>
{{end}}</div>
{{end}}
{{else}}<p>{{if .ShowClosed}}No tickets.{{else}}No open tickets. <a href="/r/{{.Repo}}/ticket?closed=1&amp;view=board">See all</a>.{{end}}</p>{{end}}
{{end}}`

var ticketListPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* Open by default, closed on request. Half of every tracker in this
       corpus is finished work, so showing everything makes the live
       tickets the minority of the page. */ -}}
{{template "ticket-views" .}}
{{if .Truncated}}<p class="board-trunc">priority order covers the {{count .Shown}} most recently updated; the rest are on the recent pages</p>{{end}}
{{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>
{{if .Priority}}<span class="b-pill b-pri" title="priority">{{.Priority}}</span>{{end}}
{{if .Severity}}<span class="b-pill" title="severity">{{.Severity}}</span>{{end}}
<code>{{printf "%.10s" .ID}}</code> · updated {{ago .Updated}}</div></li>
{{end}}</ul>{{else}}<p>{{if .ShowClosed}}No tickets.{{else}}No open tickets. <a href="/r/{{.Repo}}/ticket?closed=1">See all</a>.{{end}}</p>{{end}}
{{if .Next}}<p><a href="/r/{{.Repo}}/ticket?before={{.Next}}{{if .ShowClosed}}&amp;closed=1{{end}}">older →</a></p>{{end}}
<p>{{if .Posture.ReadOnly}}<a href="{{.Posture.Upstream "tktnew"}}">new ticket on {{.Posture.Host}} &rarr;</a>{{else}}<a href="/r/{{.Repo}}/ticket/new">new ticket</a>{{end}}</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}}`))

const docPanelSource = `{{define "doc-panel"}}
{{- /* Switching tabs is a MORPH, not a navigation: a reload threw away
       the reader's scroll position mid-README. The href stays as the
       no-script path — a plain load of the same view — and the click
       handler fetches the one-shot fragment instead. */ -}}
{{with .About}}<nav class="doc-tabs">
{{$sel := .DocPath}}{{range .Docs}}<a href="/r/{{$.Repo}}?doc={{pathesc .Path}}"{{if eq .Path $sel}} class="here"{{end}} data-on:click__prevent="@get('/r/{{$.Repo}}/doc.frag?doc={{pathesc .Path}}')">{{.Label}}{{if eq .Path $sel}}<span class="ink" aria-hidden="true"></span>{{end}}</a>{{end}}
</nav>
<div class="tkt-body readme doc-panel">{{$.DocHTML}}</div>{{end}}
{{end}}`

var dashboardPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{with .Dash}}
<div class="dash">
<section class="dash-repos">
<h2>Your repositories</h2>
{{if .Repos}}<ul class="tickets">{{range .Repos}}
<li><a class="t-title" href="/r/{{pathesc .Name}}">{{.Name}}</a>
<div class="t-meta">{{if .Description}}{{.Description}} · {{end}}{{if .LastActivity}}active {{ago .LastActivity}}{{end}}</div></li>
{{end}}</ul>{{else}}<p>No repositories granted to this account yet.</p>{{end}}
<p><a href="/?view=explore">explore all {{if $.FleetTotal}}{{$.FleetTotal}} {{end}}repositories &rarr;</a></p>
</section>
<section class="dash-feed">
<h2>Activity</h2>
{{if .Feed}}<ul class="tickets">{{range .Feed}}
<li>{{template "dash-event" .}}</li>
{{end}}</ul>{{else}}<p>Nothing yet — activity in your repositories lands here.</p>{{end}}
</section>
<section class="dash-mine">
<h2>Your recent work</h2>
{{if .Mine}}<ul class="tickets">{{range .Mine}}
<li>{{template "dash-event" .}}</li>
{{end}}</ul>{{else}}<p>Nothing recorded under your login name yet.</p>{{end}}
</section>
</div>
{{end}}
{{end}}

{{define "dash-event"}}
{{- /* ci and tickets and wiki link straight to their object; technote
       and forum link to their section — their refs are not stable page
       paths yet. */ -}}
<span class="tl-badge">{{if eq .Kind "ci"}}check-in{{else if eq .Kind "t"}}ticket{{else if eq .Kind "w"}}wiki{{else if eq .Kind "e"}}technote{{else if eq .Kind "f"}}forum{{else}}{{.Kind}}{{end}}</span>
<a class="t-title" href="/r/{{pathesc .Repo}}{{if eq .Kind "ci"}}/checkin/{{printf "%.16s" .Hash}}{{else if eq .Kind "t"}}/ticket/{{printf "%.10s" .Ref}}{{else if eq .Kind "w"}}/wiki/{{pathesc .Ref}}{{else if eq .Kind "e"}}/technote{{else if eq .Kind "f"}}/forum{{else}}{{end}}">
{{- if .Comment}}{{.Comment}}{{else}}{{.Ref}}{{end}}</a>
<div class="t-meta"><a href="/r/{{pathesc .Repo}}">{{.Repo}}</a> · {{.User}} · {{ago .Date}}</div>
{{end}}`))

// splitRow is one side-by-side row: a deletion paired with the
// addition that replaced it, a context line on both sides, or one side
// alone. Pairing is the same run-alignment the word-diff uses, so the
// marks land opposite each other.
type splitRow struct {
	Left, Right *diff.Line
}

func splitRows(h diff.Hunk) []splitRow {
	var out []splitRow
	i := 0
	for i < len(h.Lines) {
		l := h.Lines[i]
		switch l.Kind {
		case "context":
			out = append(out, splitRow{&h.Lines[i], &h.Lines[i]})
			i++
		case "deletion":
			ds := i
			for i < len(h.Lines) && h.Lines[i].Kind == "deletion" {
				i++
			}
			as := i
			for i < len(h.Lines) && h.Lines[i].Kind == "addition" {
				i++
			}
			dn, an := as-ds, i-as
			for k := 0; k < max(dn, an); k++ {
				var row splitRow
				if k < dn {
					row.Left = &h.Lines[ds+k]
				}
				if k < an {
					row.Right = &h.Lines[as+k]
				}
				out = append(out, row)
			}
		default: // addition run with no deletions before it
			out = append(out, splitRow{nil, &h.Lines[i]})
			i++
		}
	}
	return out
}

var branchesPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* fossil's /brlist split: the open branches are the living work
       and lead; the closed ones are history and follow, marked. */ -}}
{{if .Branches}}<div class="tw"><table>
<thead><tr><th scope="col">Branch</th><th scope="col">Updated</th><th scope="col">Tip</th></tr></thead>
<tbody>{{range .Branches}}{{if not .Closed}}
<tr><td><a href="/r/{{$.Repo}}/tree/{{pathesc .Tip}}/">{{.Name}}</a></td>
<td class="tl-meta-inline">{{ago .Date}}</td>
<td><a href="/r/{{$.Repo}}/checkin/{{printf "%.16s" .Tip}}"><code>{{printf "%.12s" .Tip}}</code></a></td></tr>
{{end}}{{end}}</tbody></table></div>
{{if .ClosedCount}}<h3>Closed <span class="tl-badge">{{count .ClosedCount}}</span></h3>
<div class="tw"><table>
<tbody>{{range .Branches}}{{if .Closed}}
<tr><td><a href="/r/{{$.Repo}}/tree/{{pathesc .Tip}}/">{{.Name}}</a> <span class="tl-badge">closed</span></td>
<td class="tl-meta-inline">{{ago .Date}}</td>
<td><a href="/r/{{$.Repo}}/checkin/{{printf "%.16s" .Tip}}"><code>{{printf "%.12s" .Tip}}</code></a></td></tr>
{{end}}{{end}}</tbody></table></div>{{end}}
{{else}}<p>No branches yet.</p>{{end}}
{{end}}`))

var tagsPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{if .Tags}}<div class="tw"><table>
<thead><tr><th scope="col">Tag</th><th scope="col">When</th><th scope="col">Check-in</th></tr></thead>
<tbody>{{range .Tags}}
<tr><td><svg class="ic" aria-hidden="true"><use href="#i-tag"/></svg> {{.Name}}</td>
<td class="tl-meta-inline">{{if .Date}}{{ago .Date}}{{end}}</td>
<td><a href="/r/{{$.Repo}}/checkin/{{printf "%.16s" .Tip}}"><code>{{printf "%.12s" .Tip}}</code></a></td></tr>
{{end}}</tbody></table></div>
{{else}}<p>No tags yet. A release tag names a check-in: <code>zit tag &lt;name&gt; &lt;check-in&gt;</code>.</p>{{end}}
{{end}}`))

var treePage = template.Must(template.Must(base.Clone()).Parse(docPanelSource + `{{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="repo-home{{if .About}} has-about{{end}}">
{{if .About}}{{with .About}}
{{- /* The document tabs: README | Contributing | License | Security by
       root-level convention, or the .quarry/repo.json docs list when
       the project curates. Tab choice is URL state (?doc=), so it is
       linkable, back-button-correct and needs no script. The aside
       precedes the file table in source order for the same reason it
       does on GitHub and Forgejo: floated, it rides the right edge
       from the top of the listing; stacked on a phone, "what is this"
       comes before the files. */ -}}
<aside class="about-card">
<h2 class="about-h">About</h2>
{{if .Description}}<p class="about-desc">{{.Description}}</p>{{end}}
{{if .Homepage}}<a class="about-row about-link" href="{{.Homepage}}" rel="nofollow"><svg class="ic" aria-hidden="true"><use href="#i-link"/></svg> <span>{{.Homepage}}</span></a>{{end}}
{{if .Topics}}<p class="topics">{{range .Topics}}<a class="b-pill" href="/?q={{queryesc .}}" title="repositories tagged {{.}}">{{.}}</a>{{end}}</p>{{end}}
{{range .Docs}}<a class="about-row" href="?doc={{pathesc .Path}}#docs"><svg class="ic" aria-hidden="true"><use href="#{{docIcon .Label}}"/></svg> <span>{{docRowLabel .Label $.About.LicenseName}}</span></a>
{{end -}}
{{if .Contributors}}<a class="about-row" href="/people"><svg class="ic" aria-hidden="true"><use href="#i-people"/></svg> <span><strong>{{count .Contributors}}</strong> {{plural .Contributors "contributor" "contributors"}}</span></a>{{end}}
{{if .LatestTag}}<div class="about-div"></div>
<h2 class="about-h"><a href="/r/{{$.Repo}}/tag">Tags</a> <span class="tl-badge">{{count .TagCount}}</span></h2>
<p class="about-row about-tag"><svg class="ic" aria-hidden="true"><use href="#i-tag"/></svg> <a href="/r/{{$.Repo}}/checkin/{{.LatestTagTip}}" title="{{.LatestTagTip}}"><strong>{{.LatestTag}}</strong></a> <span class="latest-pill">Latest</span></p>
<p class="about-sub">{{ago .LatestTagDate}}</p>
{{if gt .TagCount 1}}<p class="about-sub"><a href="/r/{{$.Repo}}/tag">+ {{count (sub .TagCount 1)}} tags</a></p>{{end}}
{{end}}
{{if .AnnounceRoot}}<div class="about-div"></div>
<h2 class="about-h"><a href="/r/{{$.Repo}}/forum">Announcement</a></h2>
<p class="about-row"><a href="/r/{{$.Repo}}/forum/{{printf "%.16s" .AnnounceRoot}}"><strong>{{if .AnnounceTitle}}{{.AnnounceTitle}}{{else}}(untitled){{end}}</strong></a></p>
<p class="about-sub">{{ago .AnnounceDate}}</p>
{{end}}
</aside>
{{end}}{{end}}
{{- /* Forgejo's shape: a strip of repository facts, then a row with the
       ref switcher on the left and clone/download on the right. The
       facts were previously nowhere and the switcher was a bare select
       whose width was set by its longest branch name. */ -}}
<div class="repo-stats">
<a href="/r/{{.Repo}}/timeline"><svg class="ic" aria-hidden="true"><use href="#i-commit"/></svg> {{count .Counts.Checkins}} {{plural .Counts.Checkins "check-in" "check-ins"}}</a>
<a href="/r/{{.Repo}}/branch"><svg class="ic" aria-hidden="true"><use href="#i-branch"/></svg> {{count .BranchTotal}} {{plural .BranchTotal "branch" "branches"}}</a>
{{if .Counts.Tags}}<a href="/r/{{.Repo}}/tag"><svg class="ic" aria-hidden="true"><use href="#i-tag"/></svg> {{count .Counts.Tags}} {{plural .Counts.Tags "tag" "tags"}}</a>{{end}}
<a href="/people"><svg class="ic" aria-hidden="true"><use href="#i-people"/></svg> {{count .Counts.Contributors}} {{plural .Counts.Contributors "contributor" "contributors"}}</a>
</div>
<div class="tree-bar">
{{if .Branches}}
{{- /* A popover, not a select: a select is as wide as its widest option
       and cannot show branches and tags as separate lists. This shows
       both, and stays one button wide whatever the names are. */ -}}
<div class="refpick">
<button type="button" class="refbtn" popovertarget="refs" aria-label="switch branch or tag">
<svg class="ic" aria-hidden="true"><use href="#i-branch"/></svg> <span class="refname">{{.RefName}}</span> <span class="caret" aria-hidden="true">▾</span></button>
<div id="refs" popover class="menu refs">
<div class="menu-who">branches{{if .MoreBranches}} · {{.MoreBranches}} older not shown{{end}}</div>
{{range .Branches}}<a href="/r/{{$.Repo}}/tree/{{.Tip}}"{{if eq .Tip $.TreeVersion}} class="here"{{end}}>{{.Label}}</a>{{end}}
{{if .Tags}}<div class="menu-who">tags</div>
{{range .Tags}}<a href="/r/{{$.Repo}}/tree/{{.Tip}}"{{if eq .Tip $.TreeVersion}} class="here"{{end}}>{{.Name}}</a>{{end}}{{end}}
</div>
</div>{{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">
{{- /* The clone URL is selectable text with a copy button beside it, and
       the archives sit behind one "download" rather than two loose
       links, which is what stopped this row fitting a phone. */ -}}
{{- /* zit and fossil share the sync protocol, so a store that never
       emitted a sub-manifest can be cloned by either. Offering the
       choice saves a reader who already has fossil from installing
       anything; it is withheld where it would not work, because a
       command that fails is worse than one never offered. */ -}}
<span class="clonebox">{{if .FossilClone}}<span class="clonepick">
<a href="?clone=zit" class="{{if ne .CloneTool "fossil"}}here{{end}}">zit</a>
<a href="?clone=fossil" class="{{if eq .CloneTool "fossil"}}here{{end}}">fossil</a>
</span>{{end}}<input class="cloneurl" type="text" readonly value="{{.CloneTool}} clone {{.CloneURL}} {{.Repo}}" aria-label="clone command" onfocus="this.select()">
<button type="button" class="copybtn" data-tip="select the clone command" aria-label="select the clone command" onclick="this.previousElementSibling.select()"><svg class="ic" aria-hidden="true"><use href="#i-copy"/></svg></button></span>
<button type="button" class="dlbtn" popovertarget="dl">download <span class="caret" aria-hidden="true">▾</span></button>
<div id="dl" popover class="menu dlmenu">
<a href="/r/{{.Repo}}/archive/{{.TreeVersion}}">tar.gz</a>
<a href="/r/{{.Repo}}/archive/{{.TreeVersion}}?format=zip">zip</a>
</div>
</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 and (or .RepoDescription .RepoPrivate) (not .About)}}<p class="about">{{if .RepoPrivate}}<span class="tl-badge">private</span> {{end}}{{.RepoDescription}}</p>{{end}}
{{if .Tip}}
{{- /* GitHub's latest-commit bar: who last touched this tree, what they
       said, and the door to the full history — the file table's header
       row in spirit. Root listing only until the index carries
       per-path change data. */ -}}
<div class="tip-bar">
<span class="tip-user">{{.Tip.User}}</span>
<a class="tip-msg" href="/r/{{.Repo}}/checkin/{{.Tip.Hash}}">{{commentFirst .Tip.Comment}}</a>
<span class="tip-right"><span class="tip-hash"><code>{{printf "%.10s" .Tip.Hash}}</code> ·</span> {{ago .Tip.Date}} · <a href="/r/{{.Repo}}/timeline{{if .RefName}}?branch={{.RefName}}{{end}}"><svg class="ic" aria-hidden="true"><use href="#i-clock"/></svg> {{count .Counts.Checkins}} {{plural .Counts.Checkins "check-in" "check-ins"}}</a></span>
</div>
{{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{{if .TreeChanges}} hc{{end}}">
<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>
{{if $.TreeChanges}}<td class="t-change">{{if .LastHash}}<a href="/r/{{$.Repo}}/checkin/{{.LastHash}}">{{commentFirst .LastComment}}</a>{{end}}</td>
<td class="t-age">{{if .LastDate}}{{ago .LastDate}}{{end}}</td>
{{else}}<td class="t-size">{{if .Dir}}{{.Files}} files{{else}}{{.Human}}{{end}}</td>{{end}}
</tr>
{{end}}</tbody>
</table></div>
{{if .Readme}}<h3 class="readme-head">{{.ReadmeIn}}</h3>
<div class="tkt-body readme">{{.Readme}}</div>{{end}}
{{if .About}}{{with .About}}{{if .Docs}}<div id="docs">{{template "doc-panel" $}}</div>{{end}}{{end}}{{end}}
</div>
{{end}}`))

var ticketPage = template.Must(template.Must(base.Clone()).Parse(composerSource + ticketSource))

// The ticket page is the editor. Fossil keeps a separate tktedit page
// with every field as a select; GitHub has no edit page at all — the
// issue page carries its metadata in a sidebar, its composer at the
// foot of the timeline, and each field change in the timeline as an
// event. This is the GitHub shape over fossil's schema: a sidebar form
// of selects (one Update button without script, save-on-change with
// it), a remark composer whose second button closes or reopens, and
// the artifact's own J cards rendered as "status Open → Review" lines.
const ticketSource = `{{- /* One select per triage field. The form is the no-script
       path: change several, press Update. With script each select
       posts the form on change and the Update button hides — the
       sidebar and timeline come back as a patch. */ -}}
{{define "tkt-side"}}{{if and .CanWrite (not .Posture.ReadOnly)}}<form method="post" action="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit" class="tkt-fields" data-on:submit__prevent="@post('/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit', {contentType: 'form'})">
{{range .TicketFields}}{{$cur := .Value}}{{if or .Options (ne .Name "resolution")}}<label><span class="tkt-k">{{.Name}}</span>
{{if and (eq .Name "resolution") (not $.Ticket.Closed)}}<span class="tkt-v">{{if .Value}}{{.Value}}{{else}}—{{end}}</span>{{else if .Options}}<select name="{{.Name}}" data-on:change="@post('/r/{{$.Repo}}/ticket/{{printf "%.10s" $.Ticket.ID}}/edit', {contentType: 'form'})">{{if not .Value}}<option value="" selected>—</option>{{end}}{{range .Options}}<option{{if eq . $cur}} selected{{end}}>{{.}}</option>{{end}}</select>{{else}}<input name="{{.Name}}" value="{{.Value}}" data-on:change="@post('/r/{{$.Repo}}/ticket/{{printf "%.10s" $.Ticket.ID}}/edit', {contentType: 'form'})">{{end}}</label>
{{end}}{{end}}<button type="submit" data-attr:hidden="true">Update</button>
</form>{{else}}<dl class="tkt-head">{{range .Ticket.Header}}
<dt>{{.Name}}</dt><dd>{{.Value}}</dd>{{end}}</dl>{{end}}
<dl class="tkt-head tkt-meta">{{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>
{{if .Posture.ReadOnly}}<p><a href="{{.Posture.Upstream "tktedit"}}?name={{.Ticket.ID}}">edit on {{.Posture.Host}} &rarr;</a></p>{{end}}{{end}}
{{- /* Remarks and field moves, oldest first, one block per artifact. */ -}}
{{define "tkt-timeline"}}{{range .Timeline}}<div class="{{if .HTML}}tkt-comment{{else}}tkt-event{{end}}">
<div class="tl-meta-inline">{{.Login}}{{if .DifferentUsername}} (claiming to be {{.Username}}){{end}}{{with .Summary}} {{.}}{{end}} · {{ago .Date}}</div>
{{.HTML}}
</div>
{{end}}{{end}}
{{define "content"}}
{{- /* Title and description edit where they are read: a details
       fold holding the form, so it works without script and the page
       never leaves. The layout already drew the title as its h1; the
       fold's summary is the pencil beside it. */ -}}
{{if and .CanWrite (not .Posture.ReadOnly)}}<details class="tkt-edit tkt-retitle"><summary aria-label="edit title">&#9998; title</summary>
<form method="post" action="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit"><input name="title" value="{{.Ticket.Title}}" maxlength="200" required aria-label="title"> <button type="submit">Save</button></form></details>{{end}}
<div class="tkt-layout">
<div class="tkt-main">
{{if .DescriptionHTML}}<div class="tkt-body">{{.DescriptionHTML}}</div>{{end}}
{{if and .CanWrite (not .Posture.ReadOnly)}}<details class="tkt-edit"><summary>&#9998; {{if .DescriptionHTML}}edit description{{else}}add a description{{end}}</summary>
<form class="f-reply" method="post" action="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit">
{{template "composer" (descctx .Repo 12 .Ticket.Description)}}
<div class="f-reply-foot"><button type="submit" class="btn-primary">Save description</button></div></form></details>{{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}}
<div id="tkt-timeline">{{template "tkt-timeline" .}}</div>
{{- /* The composer: Comment, or the same remark with a status move.
       Closing asks for the resolution right there — the only moment
       it is required, and the only place it is offered while open. */ -}}
{{if and .CanWrite (not .Posture.ReadOnly)}}<form class="f-reply tkt-remark" method="post" action="/r/{{.Repo}}/ticket/{{printf "%.10s" .Ticket.ID}}/edit">
{{template "composer" (remarkctx .Repo 6 "Leave a comment (Markdown — type / for blocks)")}}
<div class="f-reply-foot">{{if .Ticket.Closed}}<button type="submit" name="status" value="Open">Reopen</button>{{else}}<label class="tkt-close">with <select name="resolution">{{range .TicketFields}}{{if eq .Name "resolution"}}{{range .Options}}<option{{if eq . "Fixed"}} selected{{end}}>{{.}}</option>{{end}}{{end}}{{end}}</select></label><button type="submit" name="status" value="Closed">Comment &amp; close</button>{{end}} <button type="submit" class="btn-primary">Comment</button></div>
</form>{{end}}
</div>
<aside class="tkt-side" id="tkt-side">{{template "tkt-side" .}}</aside>
</div>
{{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(fleetAdminSource))

const fleetAdminSource = `{{- /* One row per store, ingest and mirror together, because they are
       one store: the old page had two tables keyed by the same name and
       the reader scrolled twice to learn one thing. Problems lead; when
       there are any, the healthy rows fold behind a disclosure so a
       phone shows the one row that matters and a count. Counts that
       are zero do not render — a column of zeros says nothing — and
       the ones that are not are chips the eye can find. The row is a
       grid on a wide screen and a stack on a narrow one; same markup. */ -}}
{{define "fleet-row"}}<li class="row{{if .Problem}} row-problem{{end}}">
<div class="row-main">
<a class="row-name" href="/r/{{.Name}}">{{.Name}}</a>
{{$st := .IngestState}}{{if ne $st "ok"}}<span class="tl-badge{{if or (eq $st "failing") (eq $st "stalled") (eq $st "gaps")}} ci-fail{{end}}" title="{{if .Health.Stalled}}ingest has stopped retrying; next attempt {{.Health.RetryAt}}{{else if .Health.LastErr}}{{.Health.LastErr}}{{else if .Health.Gaps}}{{.Health.Gaps}} artifacts missing from the index{{end}}">{{if eq $st "gaps"}}{{.Health.Gaps}} gaps{{else}}{{$st}}{{end}}</span>{{end}}
{{with .Mirror}}{{if .Enabled}}<span class="tl-badge m-{{.State}}" title="mirror of {{.URL}}">{{if eq .State "failing"}}failing ×{{.Failures}}{{else}}{{.State}}{{end}}</span>{{if .AcceptWrites}}<span class="tl-badge" title="local writes accepted">writes</span>{{end}}{{else}}<span class="tl-badge" title="forked from {{.DetachedFrom}}">fork</span>{{end}}{{end}}
{{if .Health.Fallbacks}}<span class="tl-badge" title="artifacts parsed by the fallback parser">{{.Health.Fallbacks}} fallbacks</span>{{end}}
<div class="row-meta">
{{with .Mirror}}{{if .Enabled}}<a href="{{.URL}}" title="{{.URL}}">{{hostOf .URL}}</a> · every {{dur .Every}} · pulled {{if .LastAt}}{{ago .LastAt}}{{else}}never{{end}} · {{else}}<a href="{{.DetachedFrom}}">{{hostOf .DetachedFrom}}</a> · {{end}}{{end}}{{thousands .Checkins}} check-ins · indexed {{if .Health.LastOK}}{{ago .Health.LastOK}} <span class="n">({{printf "%.2f" .Health.RoundSeconds}}s)</span>{{else}}never{{end}} · <code>{{printf "%.10s" .Code}}</code>
</div>
{{if .Health.LastErr}}<div class="row-err" title="{{.Health.LastErr}}">ingest: {{.Health.LastErr}}</div>{{end}}
{{with .Mirror}}{{if and .Enabled .LastErr}}<div class="row-err" title="{{.LastErr}}">pull: {{.LastErr}}</div>{{end}}{{end}}
</div>
<div class="row-acts">
<form method="post" action="/fleet/sync"><input type="hidden" name="repo" value="{{.Name}}"><button type="submit">sync</button></form>
{{with .Mirror}}{{if .Enabled}}<form method="post" action="/fleet/mirror/run"><input type="hidden" name="repo" value="{{.Repo}}"><button type="submit">pull</button></form>
{{- /* A native popover, like the account menu: the browser owns
       light-dismiss, Esc and the top layer, and the items share the
       .menu rules so they size the same. Anchored to its own button;
       the anchor name is the row's index, since store names can hold
       characters a CSS ident cannot. */ -}}
<button type="button" class="kebab-btn" popovertarget="km{{$.Idx}}" style="anchor-name: --km{{$.Idx}}" aria-label="more actions for {{.Repo}}">&#8943;</button>
<div id="km{{$.Idx}}" popover class="menu km" style="position-anchor: --km{{$.Idx}}">
<a href="/fleet?edit={{.Repo}}#mirror-form">edit mirror</a>
<form method="post" action="/fleet/mirror/writes"><input type="hidden" name="repo" value="{{.Repo}}"><input type="hidden" name="accept" value="{{if .AcceptWrites}}false{{else}}true{{end}}"><button type="submit">{{if .AcceptWrites}}refuse local writes{{else}}allow local writes{{end}}</button></form>
<a href="/fleet?detach={{.Repo}}">detach&hellip;</a>
</div>
{{else}}<a class="btn" href="/fleet?reattach={{.Repo}}">re-attach&hellip;</a>{{end}}{{end}}
</div>
</li>{{end}}
{{define "content"}}
{{- /* The layout already renders the page title as its h1; a second
       heading saying the same word twice is what "fleet Fleet" was. */ -}}
{{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}}
{{if .ServerErrors}}
{{- /* The error journal: everything that answered "internal error" to
       a reader since the server started, deduplicated by message. This
       panel is what makes that answer a promise someone collects on —
       before it, 500s were writes to a log nobody read, and the fleet
       said "ok" while the web layer burned. */ -}}
<h2>Server errors</h2>
<div class="tw"><table>
<thead><tr><th scope="col">Last seen</th><th scope="col">Count</th><th scope="col">Where</th><th scope="col">Error</th></tr></thead>
<tbody>{{range .ServerErrors}}
<tr><td class="tl-meta-inline">{{ago .Last}}</td>
<td>{{.Count}}</td>
<td><span class="tl-badge">{{.Surface}}</span> <code>{{.Path}}</code></td>
<td>{{.Message}}{{if $.TrackerRepo}} · <a href="/r/{{pathesc $.TrackerRepo}}/ticket/new?title={{queryesc .Message}}&amp;type=Code_Defect&amp;subsystem={{queryesc .Surface}}&amp;comment={{queryesc (printf "Journalled server error.\n\n    surface: %s\n    path:    %s\n    count:   %d\n    last:    %s" .Surface .Path .Count .Last)}}">file ticket</a>{{end}}</td></tr>
{{end}}</tbody></table></div>
{{end}}
<div class="fleet-head">
<p class="fleet-sum">{{len .FleetRows}} {{plural (len .FleetRows) "store" "stores"}}{{with .MirrorCounts}} · {{index . "all"}} {{plural (index . "all") "mirror" "mirrors"}}{{end}}{{if $.FleetProblems}} · <strong class="ci-fail-text">{{$.FleetProblems}} {{plural $.FleetProblems "problem" "problems"}}</strong>{{else}} · all healthy{{end}}{{if .ErrorTotal}} · <span class="tl-badge ci-fail">{{.ErrorTotal}} server {{plural .ErrorTotal "error" "errors"}}</span>{{end}}</p>
<div class="fleet-bulk"><form method="post" action="/fleet/sync"><button type="submit">Sync all</button></form>
{{if .Mirrors}}<form method="post" action="/fleet/mirror/run"><button type="submit">Pull all</button></form>{{end}}</div>
</div>
{{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 .FleetRows}}
{{if .FleetProblems}}<ul class="rows">{{range .FleetRows}}{{if .Problem}}{{template "fleet-row" .}}{{end}}{{end}}</ul>
{{$healthy := 0}}{{range .FleetRows}}{{if not .Problem}}{{$healthy = 1}}{{end}}{{end}}
{{if $healthy}}<details class="rows-fold"><summary>{{len .FleetRows}} stores in all — show the healthy ones</summary>
<ul class="rows">{{range .FleetRows}}{{if not .Problem}}{{template "fleet-row" .}}{{end}}{{end}}</ul></details>{{end}}
{{else}}<ul class="rows">{{range .FleetRows}}{{template "fleet-row" .}}{{end}}</ul>{{end}}
{{else}}<p>Nothing matches.</p>{{end}}

<h3 id="mirror-form-head">Add or update a mirror</h3>
{{- /* Each control keeps its label above it rather than beside it: at
       "every 3600 seconds" the trailing word wrapped onto its own line
       and the row read as three broken sentences. */ -}}
{{- /* One form adds and edits: the store name is the key, so saving
       a name that exists updates that row. Said on the button, because
       "Add" alone hid that a repair was possible. */ -}}
<form method="post" action="/fleet/mirror" class="addform" id="mirror-form">
<label>store<input name="repo" required{{with .MirrorEdit}} value="{{.Repo}}"{{end}}></label>
<label class="grow">upstream<input name="url" type="url" placeholder="https://fossil-scm.org/home" required{{with .MirrorEdit}} value="{{if .Enabled}}{{.URL}}{{else}}{{.DetachedFrom}}{{end}}"{{end}}></label>
<label>every (seconds)<input name="every" type="number" value="{{with .MirrorEdit}}{{if .Every}}{{.Every}}{{else}}3600{{end}}{{else}}3600{{end}}" min="60"></label>
<button type="submit">{{if .MirrorEdit}}Save mirror{{else}}Add or update mirror{{end}}</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.
// mirrorDetachPage is the acknowledgement before a posture change that
// cannot be undone by a click: detaching makes the store a fork. The
// checkbox is required, which is what an acknowledgement is.
var mirrorDetachPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{with .MirrorEdit}}
<p class="tl-meta-inline"><a href="/fleet">&larr; fleet</a></p>
<p><strong>{{.Repo}}</strong> tracks <a href="{{.URL}}">{{.URL}}</a>. Detaching makes it a <strong>fork</strong>:</p>
<ul>
<li>quarry becomes the source of truth; scheduled pulls stop.</li>
<li>pushes, tickets, wiki and forum writes are accepted here from now on.</li>
<li>the upstream is kept as provenance — the repository will say "forked from {{hostOf .URL}}".</li>
<li>re-attaching later merges whatever the upstream did in the meantime on top of local history.</li>
</ul>
<form method="post" action="/fleet/mirror/detach" class="confirm">
<input type="hidden" name="repo" value="{{.Repo}}">
<p><label><input type="checkbox" name="ack" value="fork" required> I understand {{.Repo}} becomes a fork of {{hostOf .URL}}</label></p>
<p><button type="submit">Detach {{.Repo}}</button> <a href="/fleet">cancel</a></p>
</form>
{{end}}{{end}}`))

// mirrorReattachPage: one click to resume tracking, with the one thing
// that can go wrong said first. Drift is not measured yet (see the
// divergence ticket); the warning is the honest placeholder.
var mirrorReattachPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{with .MirrorEdit}}
<p class="tl-meta-inline"><a href="/fleet">&larr; fleet</a></p>
<p><strong>{{.Repo}}</strong> is a fork of <a href="{{.DetachedFrom}}">{{.DetachedFrom}}</a>. Re-attaching resumes tracking it:</p>
<ul>
<li>scheduled pulls resume every {{dur .Every}}; the next pull merges the upstream's history on top of whatever was committed here since the detach.</li>
<li>local writes return to <strong>refused</strong>; allow them again on the fleet page if this is meant to stay a fork that follows upstream.</li>
<li>if local history has diverged from the upstream, both lines stay in the store as separate branches — nothing is lost, but the timeline will show both.</li>
</ul>
<form method="post" action="/fleet/mirror" class="confirm">
<input type="hidden" name="repo" value="{{.Repo}}">
<input type="hidden" name="url" value="{{.DetachedFrom}}">
<input type="hidden" name="every" value="{{if .Every}}{{.Every}}{{else}}3600{{end}}">
<p><button type="submit">Re-attach {{.Repo}} to {{hostOf .DetachedFrom}}</button> <a href="/fleet">cancel</a></p>
</form>
{{end}}{{end}}`))

var newRepoPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{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>
{{- /* The posture is chosen here, once, on purpose. A clone used to
       record a mirror silently; now the form says what each choice
       means for who owns the history afterwards. */ -}}
<p><label><input type="radio" name="kind" value="own"> Import and own</label>
  <small class="tl-meta-inline">— clone once; quarry becomes the source of truth</small></p>
<p><label><input type="radio" name="kind" value="clone"> Mirror</label>
  <small class="tl-meta-inline">— clone and keep tracking the upstream; writes are refused here</small></p>
<p><input name="from" size="52" placeholder="https://fossil-scm.org/home" aria-label="repository URL">
  <label>pull every <input name="every" type="number" value="3600" min="60" size="6" aria-label="pull interval in seconds"> seconds</label></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"}}

{{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>Homepage <small class="tl-meta-inline">— shown on the About panel; http(s) only</small><br>
  <input name="homepage" type="url" size="52" maxlength="300" placeholder="https://" value="{{.RepoHomepage}}"></label></p>
<p><label>Topics <small class="tl-meta-inline">— space-separated, at most 20</small><br>
  <input name="topics" size="52" maxlength="600" value="{{.RepoTopics}}"></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"}}
{{- /* A 404 keeps its footing: inside a known repository the repobar
       stays (set by fail), so the reader is one click from every
       surface that DOES exist rather than dumped back at the door.
       The path is echoed so a typo is findable by eye. */ -}}
<p class="status"><strong>404</strong> — nothing lives at <code>{{.MissPath}}</code>.</p>
{{if .Repo}}<p>The repository exists; this page within it does not. The tabs above are all real.</p>
{{else}}<p>Start from the <a href="/">repository list</a>.</p>{{end}}
{{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 forbiddenPage = template.Must(template.Must(base.Clone()).Parse(
	`{{define "content"}}<p>{{.Error}}</p>{{end}}`))

var globalSearchPage = template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}
{{- /* No second box: the header carries one on every page including
       this one, and two inputs for the same query invite a reader to
       wonder which is authoritative. */ -}}
{{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"}}
{{- /* 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}}`))

// diffBodySource is the structured diff shared by the check-in page
// (fossil's /info shape: metadata, then the change inline) and the
// standalone /diff deep link. One template, two doors.
const diffBodySource = `{{define "dtoks"}}{{range .Tokens}}{{if or .Class .Mark}}<span class="{{if .Class}}{{.Class}}{{end}}{{if and .Class .Mark}} {{end}}{{if .Mark}}dm{{end}}">{{.Text}}</span>{{else}}{{.Text}}{{end}}{{end}}{{end}}
{{define "diff-body"}}
{{if .Diff.Note}}<p><em>{{.Diff.Note}}</em></p>{{end}}
{{if .Diff.Structured}}
{{if .Diff.Offset}}<p class="diffcount">{{if .Diff.File}}<code>{{.Diff.File}}</code>{{else}}{{len .Diff.Structured}} {{plural (len .Diff.Structured) "file" "files"}} changed{{end}} <small>— continued</small></p>
{{else}}<p class="diffcount">{{len .Diff.Structured}} {{plural (len .Diff.Structured) "file" "files"}} changed{{if .Diff.OmittedLines}} <small hidden data-attr:hidden="false">— a large change; the rest loads as you scroll</small>{{end}}</p>
<ul class="difffiles">{{range .Diff.Structured}}
<li><a href="#f-{{pathesc .Path}}"><svg class="ic" aria-hidden="true"><use href="#i-file"/></svg> {{.Path}}</a><span class="tl-badge">{{.Change}}</span></li>{{end}}</ul>
{{end}}
<nav class="toggle" aria-label="diff view">
<a href="?{{if .Diff.File}}file={{.Diff.File}}{{end}}"{{if not .DiffSplit}} class="here" aria-current="true"{{end}}>unified</a>
<a href="?view=split{{if .Diff.File}}&amp;file={{.Diff.File}}{{end}}"{{if .DiffSplit}} class="here" aria-current="true"{{end}}>side-by-side</a>
</nav>
{{if .Diff.Offset}}{{template "diff-pager" .}}{{else if .Diff.OmittedLines}}<noscript>{{template "diff-pager" .}}</noscript>{{end}}
{{template "diff-more" .}}
{{else if .Diff.Files}}<ul class="difffiles">{{range .Diff.Files}}
<li><a href="/r/{{$.Repo}}/checkin/{{$.Diff.To}}/diff?file={{.Name}}"><svg class="ic" aria-hidden="true"><use href="#i-file"/></svg> {{.Name}}</a><span class="tl-badge">{{.Status}}</span></li>{{end}}</ul>{{end}}
{{end}}
{{- /* One window of file sections, then the sentinel that fetches the
       next window when it scrolls into view. The sentinel is ALSO a
       link: with scripting off the same URL renders the next window as
       a page, so the whole diff stays reachable either way. The
       fragment response replaces #diff-more with this very template,
       which is how the chain continues. */ -}}
{{define "diff-more"}}{{range .Diff.Structured}}{{if .Hunks}}
<section class="filediff{{if $.DiffSplit}} wide{{end}}"{{if .Before}}{{else}} id="f-{{pathesc .Path}}"{{end}}>
<h3 class="filediff-head"><span class="tl-badge">{{.Change}}</span> <code>{{.Path}}</code>{{if .PrevPath}} <small>(was {{.PrevPath}})</small>{{end}}{{if .Before}} <small>(continued)</small>{{end}}</h3>
{{range .Hunks}}
<div class="hunkhead"><span class="hunkfold">&#8943;</span>{{if .Header}} {{.Header}}{{end}}</div>
{{if $.DiffSplit}}
<div class="tw"><table class="dtab chroma split"><tbody>
{{range splitrows .}}<tr>
{{if .Left}}<td class="dnum">{{.Left.OldNum}}</td><td class="dcell d-{{.Left.Kind}}">{{template "dtoks" .Left}}</td>
{{else}}<td class="dnum"></td><td class="dcell d-void"></td>{{end}}
{{if .Right}}<td class="dnum">{{.Right.NewNum}}</td><td class="dcell d-{{.Right.Kind}}">{{template "dtoks" .Right}}</td>
{{else}}<td class="dnum"></td><td class="dcell d-void"></td>{{end}}
</tr>
{{end}}</tbody></table></div>
{{else}}
<div class="tw"><table class="dtab chroma"><tbody>
{{range .Lines}}<tr class="d-{{.Kind}}">
<td class="dnum">{{if .OldNum}}{{.OldNum}}{{end}}</td>
<td class="dnum">{{if .NewNum}}{{.NewNum}}{{end}}</td>
<td class="dmark"></td>
<td class="dcode">{{template "dtoks" .}}</td>
</tr>
{{end}}</tbody></table></div>
{{end}}
{{end}}
</section>
{{end}}{{end}}
{{with .Diff.More true}}<div id="diff-more" class="diffmore" data-on-intersect__once="@get('{{.}}')"><noscript>{{template "diff-pager" $}}</noscript><span aria-hidden="true" hidden data-attr:hidden="false">&#8943;</span></div>{{else}}{{if .Diff.Offset}}<noscript>{{template "diff-pager" .}}</noscript>{{end}}{{end}}
{{end}}
{{- /* The pager: where this window sits in the whole diff, and the
       way to the one before, the one after, and the start. The paged
       reading (no script) lives on it; with script it only appears on
       a window reached by link, as a bearing. Disabled ends stay in
       the row so the buttons never shift. */ -}}
{{define "diff-pager"}}<nav class="pager" aria-label="diff pages">
<a class="pager-btn" href="{{.Diff.Start}}"{{if not .Diff.Offset}} aria-disabled="true"{{end}}>&laquo; Start</a>
{{with .Diff.Back}}<a class="pager-btn" href="{{.}}">&lsaquo; Previous</a>{{else}}<span class="pager-btn" aria-disabled="true">&lsaquo; Previous</span>{{end}}
<span class="pager-where">Lines <b>{{thousands (add .Diff.Offset 1)}}&ndash;{{thousands .Diff.NextOffset}}</b> of {{thousands .Diff.TotalLines}}</span>
{{with .Diff.More false}}<a class="pager-btn" href="{{.}}">Next &rsaquo;</a>{{else}}<span class="pager-btn" aria-disabled="true">Next &rsaquo;</span>{{end}}
{{with .Diff.Last}}<a class="pager-btn" href="{{.}}">Last &raquo;</a>{{else}}<span class="pager-btn" aria-disabled="true">Last &raquo;</span>{{end}}
</nav>{{end}}`

var diffPage = template.Must(template.Must(base.Clone()).Parse(diffBodySource + `{{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>
{{template "diff-body" .}}
{{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}}
{{- /* Fossil deletes a page by writing an empty edit. The tombstone
       says so rather than presenting a blank page as content, and the
       history stays one click away — the record survives deletion. */ -}}
{{if .Wiki.Deleted}}<p class="status"><em>This page was deleted {{ago .Wiki.Date}} by {{.Wiki.User}}.
Its <a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}/history">history</a> remains readable.</em></p>
{{else}}<article>{{.WikiHTML}}</article>{{end}}
<footer><small>edited {{ago .Wiki.Date}} by {{.Wiki.User}} ·
{{if .Posture.ReadOnly}}<a href="{{.Posture.Upstream "wikiedit"}}?name={{.Wiki.Title}}">edit on {{.Posture.Host}} &rarr;</a>{{else}}<a href="/r/{{.Repo}}/wiki/{{pathesc .Wiki.Title}}/edit">edit</a>{{end}} ·
<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(timelineSource))

const timelineSource = `{{define "content"}}
{{- /* Three selects, one row, whatever the content: a select shows
       its choice, truncates its own text natively, and submits on
       change (the button exists only where script does not). Pills
       spent three phone rows saying the same thing. */ -}}
<form class="tl-controls" action="/r/{{.Repo}}/timeline" method="get">
<select name="branch" aria-label="branch" onchange="this.form.submit()">
<option value="">all branches</option>
{{range .Branches}}<option value="{{.Name}}"{{if eq .Name $.Branch}} selected{{end}}>{{.Name}}</option>{{end}}
</select>
<select name="y" aria-label="show" onchange="this.form.submit()">
<option value="ci"{{if eq .Y "ci"}} selected{{end}}>check-ins</option>
<option value="all"{{if eq .Y "all"}} selected{{end}}>everything</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>
<select name="n" aria-label="how many" onchange="this.form.submit()">
{{range .LimitChoices}}<option{{if eq . $.Limit}} selected{{end}}>{{.}}</option>{{end}}
</select>
<noscript><button type="submit">Apply</button></noscript>
</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">{{with weekday .Date}}{{.}} · {{end}}{{.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 if eq .Kind "e"}}technote{{else if eq .Kind "f"}}forum{{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
	Origin       string // scheme://host we were reached by; for absolute URLs
	Path         string // the request path, for og:url
	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
	// Served is the fleet size before a filter, so a filtered listing
	// can say how many of the whole it matched — "1 of 1" is true and
	// tells a reader nothing.
	Served int
	// Notices is the third-party licence inventory; QuarryLicence is
	// this software's own.
	Notices       []legal.Notice
	QuarryLicence string
	// Explore names the aggregate tab in view; People and PeopleTotal
	// back the people one.
	Explore    string
	Person     personRow
	ShowClosed bool
	BoardView  bool
	// CanWrite decides whether the board draws its move control. It is
	// write CAPABILITY, not session state — see mayWrite.
	CanWrite bool
	Board    []boardLane
	// The board's two axes and the choices offered for them, so the
	// controls can be rendered without the template knowing the
	// vocabulary.
	GroupBy    string
	LaneBy     string
	AxisFields []struct{ Key, Label string }
	// The column set, so the per-card move select offers every column
	// the board draws rather than only the ones already occupied — the
	// bug that made the board useless: one column meant the only move
	// available was to where the ticket already was.
	ColumnLabels []string
	// The board reads a bounded page of the tracker. Saying so is the
	// difference between "these are the tickets" and "these are some".
	Shown      int
	Truncated  bool
	ByPriority bool
	// MissPath is the URL a 404 echoes back, so a typo is findable.
	MissPath string
	// TrackerRepo names the forge's own tracker for /fleet's file-ticket
	// buttons; empty draws none.
	TrackerRepo string
	// DiffSplit selects the side-by-side diff view (?view=split).
	DiffSplit bool
	// CheckinLead is the comment's first line, linkified; CheckinBody
	// is the rest of the comment, linkified, line breaks preserved.
	// CheckinLongLead marks a first line too long to be a subject line —
	// it wears near-body styling instead of the bold lead.
	CheckinLead     template.HTML
	CheckinBody     template.HTML
	CheckinLongLead bool
	// Prefill seeds the new-ticket form from query parameters, so a
	// surface that knows about a problem (the fleet's error journal)
	// can deep-link a half-written ticket. Values pass through
	// template escaping like any other text.
	Prefill ticketPrefill
	// Dash is the signed-in landing page's aggregate (me.feed).
	Dash *dashData
	// ServerErrors is /fleet's error journal panel.
	ServerErrors []errlogRow
	ErrorTotal   int
	// About is the repo landing page's sidebar aggregate (repo.about);
	// nil everywhere else. DocHTML is the selected tab, op-rendered and
	// sanitizer-gated like WikiHTML.
	About   *aboutData
	DocHTML template.HTML
	// Vocab is the repository's ticket vocabulary (ticket.config), for
	// form datalists and anything else that offers the words back.
	Vocab       map[string][]string
	Page        int
	NextPage    int
	PrevPage    int
	People      []personRow
	PeopleTotal 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
	Timeline        []ticketEvent // comments and field changes, oldest first
	TicketFields    []ticketControl
	History         []fileChange
	HistoryPath     string
	CIRuns          []ciRunRow
	CIBadge         template.HTML
	Tree            []treeRow
	TreeVersion     string
	Crumbs          []crumb
	Branches        []branchRow
	ClosedCount     int
	MoreBranches    int
	// BranchTotal and TagTotal back the repository stats strip; Tags
	// and RefName back the ref switcher.
	BranchTotal int
	TagTotal    int
	Tags        []tagRow
	RefName     string
	// FossilClone offers the fossil clone command alongside zit;
	// CloneTool is the one currently shown.
	FossilClone bool
	CloneTool   string
	CloneURL    string
	Readme      template.HTML
	ReadmeIn    string
	Forum       []forumThreadRow
	ForumPinned []forumThreadRow
	ForumRoot   string // reply-form target on the thread page
	Flash       string // one-render notice ("post held for moderation")
	OGDesc      string // og:description for link unfurls
	Composer    bool   // page carries a markdown composer: load its script
	YourSub     string // the viewer's thread-subscription standing
	CanMod      bool   // moderation controls follow Admin
	ModOn       bool
	Queue       []stagedRow
	ModLog      []modRow
	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
	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
	// FleetRows is the joined, problems-first listing; FleetProblems
	// how many lead it. MirrorCounts summarises the mirrors by state.
	FleetRows     []fleetRow
	FleetProblems int
	MirrorCounts  map[string]int
	SyncResults   []fleetSyncRow
	// Mirrors is the configured upstream tracking; MirrorResults is
	// non-nil only just after a manual pull.
	Mirrors       []mirrorRow
	MirrorResults []mirrorRunRow
	// MirrorEdit prefills the add form with one row, for editing in
	// place; set from ?edit=<repo> on the fleet page.
	MirrorEdit *mirrorRow
	// Posture is the repo's mirror standing, set for every repo page.
	Posture posture
	// Kind is the explore listing's posture filter: "", mirror, native.
	Kind string
	// Moved is the board's last move, for the status line and its undo.
	Moved *boardMove
	// SearchAction, when set on a fleet-level page, points the header
	// box at that page as a filter instead of at global search.
	SearchAction string
	// 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
	RepoHomepage    string
	RepoTopics      string
	Tip             *timelineEntry
	// TreeChanges: the listing has last-change data, so the table shows
	// GitHub's message + age columns instead of sizes.
	TreeChanges bool
	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 {
	IndexBehind  bool `json:"indexBehind"`
	Checkins     int  `json:"checkins"`
	WikiPages    int  `json:"wikiPages"`
	Tickets      int  `json:"tickets"`
	ForumThreads int  `json:"forumThreads"`
	Technotes    int  `json:"technotes"`
	Contributors int  `json:"contributors"`
	Tags         int  `json:"tags"`
}

// 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"`
	AcceptWrites bool   `json:"acceptWrites"`
	DetachedFrom string `json:"detachedFrom"`
	State        string `json:"state"`
}

// posture is what every repo page knows about where the repository
// stands: tracking an upstream (and how freshly), detached from one,
// or native. It comes from repo.get, so the page and the API agree.
type posture struct {
	Mirror struct {
		Tracking     bool   `json:"tracking"`
		URL          string `json:"url"`
		State        string `json:"state"`
		LastOK       string `json:"lastOk"`
		LastAt       string `json:"lastAt"`
		Failures     int    `json:"failures"`
		AcceptWrites bool   `json:"acceptWrites"`
	} `json:"mirror"`
	DetachedFrom string `json:"detachedFrom"`
}

// ReadOnly: the store refuses local writes, so the page offers the
// upstream's write surfaces instead of its own.
func (p posture) ReadOnly() bool { return p.Mirror.Tracking && !p.Mirror.AcceptWrites }

// Host is the upstream's host, which is how a badge names it.
func (p posture) Host() string {
	u := p.Mirror.URL
	if u == "" {
		u = p.DetachedFrom
	}
	if parsed, err := url.Parse(u); err == nil && parsed.Host != "" {
		return parsed.Host
	}
	return u
}

// Upstream joins a Fossil page path onto the upstream URL: the write
// surfaces over there have predictable names.
func (p posture) Upstream(path string) string {
	return strings.TrimSuffix(p.Mirror.URL, "/") + "/" + strings.TrimPrefix(path, "/")
}

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"`
		Gaps         int     `json:"gaps"`
		Stalled      bool    `json:"stalled"`
		RetryAt      string  `json:"retryAt"`
	} `json:"health"`
}

// fleetRow is one store as the fleet page shows it: ingest health and
// mirror posture on one line, because they are one store. Problem is
// what sorts it to the top and keeps it out of the collapsed group.
type fleetRow struct {
	fleetHealthRow
	Mirror  *mirrorRow
	Problem bool
	Idx     int // position in the listing; names the row's popover and its anchor
}

// IngestState is the one-word verdict for the index side.
func (f fleetRow) IngestState() string {
	switch {
	case f.Health.Stalled:
		return "stalled"
	case f.Health.Failures > 0 || f.Health.LastErr != "":
		return "failing"
	case f.Indexing:
		return "indexing"
	case f.Health.Gaps > 0:
		return "gaps"
	}
	return "ok"
}

// fleetRows joins the two lists by name, marks problems, and orders
// the problems first. The healthy majority reads as a single block.
func fleetRows(health []fleetHealthRow, mirrors []mirrorRow) (rows []fleetRow, problems int) {
	byName := map[string]*mirrorRow{}
	for i := range mirrors {
		byName[mirrors[i].Repo] = &mirrors[i]
	}
	for _, h := range health {
		row := fleetRow{fleetHealthRow: h, Mirror: byName[h.Name]}
		st := row.IngestState()
		row.Problem = st == "stalled" || st == "failing" || st == "gaps" ||
			(row.Mirror != nil && row.Mirror.State == "failing")
		if row.Problem {
			problems++
		}
		rows = append(rows, row)
	}
	sort.SliceStable(rows, func(i, j int) bool { return rows[i].Problem && !rows[j].Problem })
	for i := range rows {
		rows[i].Idx = i
	}
	return rows, problems
}

// errlogRow mirrors errlog.Entry through the op boundary.
type errlogRow struct {
	Message string `json:"message"`
	Surface string `json:"surface"`
	Path    string `json:"path"`
	Count   int    `json:"count"`
	First   string `json:"first"`
	Last    string `json:"last"`
}

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"`
	Mirror      struct {
		Tracking bool   `json:"tracking"`
		URL      string `json:"url"`
		State    string `json:"state"`
	} `json:"mirror"`
	DetachedFrom string `json:"detachedFrom"`
	Homepage     string `json:"homepage"`
	Topics       string `json:"topics"`
	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"`
}

// aboutData mirrors repo.about's aggregate for the landing page.
type aboutData struct {
	Description string   `json:"description"`
	Homepage    string   `json:"homepage"`
	Topics      []string `json:"topics"`
	Docs        []struct {
		Path  string `json:"path"`
		Label string `json:"label"`
	} `json:"docs"`
	DocPath       string `json:"docPath"`
	DocLabel      string `json:"docLabel"`
	DocHTML       string `json:"docHtml"`
	LatestTag     string `json:"latestTag"`
	LatestTagTip  string `json:"latestTagTip"`
	LatestTagDate string `json:"latestTagDate"`
	TagCount      int    `json:"tagCount"`
	Contributors  int    `json:"contributors"`
	LicenseName   string `json:"licenseName"`
	AnnounceRoot  string `json:"announceRoot"`
	AnnounceTitle string `json:"announceTitle"`
	AnnounceDate  string `json:"announceDate"`
}

// dashData mirrors me.feed for the signed-in landing page.
type dashData struct {
	User  string `json:"user"`
	Repos []struct {
		Name         string `json:"name"`
		Description  string `json:"description"`
		LastActivity string `json:"lastActivity"`
	} `json:"repos"`
	Feed []dashEvent `json:"feed"`
	Mine []dashEvent `json:"mine"`
}

type dashEvent struct {
	Repo    string `json:"repo"`
	Kind    string `json:"kind"`
	Ref     string `json:"ref"`
	Hash    string `json:"hash"`
	Date    string `json:"date"`
	User    string `json:"user"`
	Comment string `json:"comment"`
}

type ticketPrefill struct {
	Title, Comment, Type, Severity, Priority, Subsystem string
}

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

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"`
	Created      string   `json:"created"`
	Updated      string   `json:"updated"`
	User         string   `json:"user"`
	Score        int      `json:"score"`
	Excerpt      string   `json:"excerpt"`
	HasPoll      bool     `json:"hasPoll"`
	Participants []string `json:"participants"`
	Spark        []int    `json:"spark"`
	Dormant      bool     `json:"dormant"`
}

type forumPostRow struct {
	Hash         string `json:"hash"`
	Parent       string `json:"parent"`
	Date         string `json:"date"`
	User         string `json:"user"`
	Score        int    `json:"score"`
	YourVote     int    `json:"yourVote"`
	Hidden       bool   `json:"hidden"`
	HiddenReason string `json:"hiddenReason"`
	HTML         template.HTML
	Poll         struct {
		Present    bool     `json:"present"`
		Question   string   `json:"question"`
		Options    []string `json:"options"`
		Counts     []int    `json:"counts"`
		Percents   []int    `json:"percents"`
		Total      int      `json:"total"`
		YourChoice int      `json:"yourChoice"`
		Closed     bool     `json:"closed"`
		Closes     string   `json:"closes"`
	} `json:"poll"`
}

// identicon draws a deterministic identity mark from a login name: a
// 5x5 horizontally-mirrored pixel grid, hued by the name's hash — the
// same idea as GitHub's identicons. Inline SVG, so it costs no request
// and no script; the same name renders the same mark everywhere.
// userHue is one login's hue, shared by the identicon, the hued name,
// the post edge and the spectrum ribbon — one color IS one person,
// everywhere.
func userHue(name string) int {
	h := fnv.New64a()
	h.Write([]byte(name))
	return int(h.Sum64() % 360)
}

// spectrumSVG is a thread's fingerprint: one band per post, in the
// author's hue, in posting order. A two-person volley reads striped; a
// question with one long answer reads as a field with an interruption.
func spectrumSVG(posts []forumPostRow) template.HTML {
	if len(posts) < 2 {
		return ""
	}
	// One voice makes a solid bar that says nothing and reads as an
	// error stripe; the ribbon draws only when there is a conversation
	// to fingerprint.
	voices := map[string]bool{}
	for _, p := range posts {
		voices[p.User] = true
	}
	if len(voices) < 2 {
		return ""
	}
	var b strings.Builder
	n := len(posts)
	// One continuous bar, constant height: contiguous segments, no
	// gaps — presence comes from height and the muted edge recipe, and
	// only the rounded OUTER corners say "designed element".
	fmt.Fprintf(&b, `<svg class="f-spectrum" viewBox="0 0 100 6" preserveAspectRatio="none" aria-hidden="true">`)
	seg := 100 / float64(n)
	for i, p := range posts {
		fmt.Fprintf(&b, `<rect x="%.2f" y="0" width="%.2f" height="6" fill="hsl(%d, 30%%, 50%%)" fill-opacity="0.6"><title>%s</title></rect>`,
			float64(i)*seg, seg+0.05, userHue(p.User), template.HTMLEscapeString(p.User))
	}
	b.WriteString(`</svg>`)
	return template.HTML(b.String())
}

var identiconMemo sync.Map // name -> template.HTML; marks are immutable

func identicon(name string) template.HTML {
	if v, ok := identiconMemo.Load(name); ok {
		return v.(template.HTML)
	}
	h := fnv.New64a()
	h.Write([]byte(name))
	v := h.Sum64()
	hue := v % 360
	cells := v >> 9   // 15 bits: which of the 5x3 half-grid cells are on
	shades := v >> 24 // 15 more: which on-cells take the deeper shade
	if cells&0x7FFF == 0 {
		cells = 0x1084 // a degenerate hash still gets a face: center column
	}
	var b strings.Builder
	// A wash of the mark's own hue behind the grid gives it a plate to
	// sit on; two lightness steps of one hue give it depth. One hue per
	// person keeps the mark recognisable at a glance — the color IS the
	// identity as much as the pattern.
	fmt.Fprintf(&b, `<svg class="idn" viewBox="-0.6 -0.6 6.2 6.2" aria-hidden="true">`)
	fmt.Fprintf(&b, `<rect x="-0.6" y="-0.6" width="6.2" height="6.2" rx="1" fill="hsl(%d, 45%%, 50%%)" fill-opacity="0.14"/>`, hue)
	deep := fmt.Sprintf("hsl(%d, 62%%, 40%%)", hue)
	lit := fmt.Sprintf("hsl(%d, 60%%, 56%%)", hue)
	cell := func(x, y int, fill string) {
		fmt.Fprintf(&b, `<rect x="%d" y="%d" width="1" height="1" rx="0.22" fill="%s"/>`, x, y, fill)
	}
	for row := 0; row < 5; row++ {
		for col := 0; col < 3; col++ {
			i := uint(row*3 + col)
			if cells>>i&1 == 1 {
				fill := lit
				if shades>>i&1 == 1 {
					fill = deep
				}
				cell(col, row, fill)
				if col < 2 {
					cell(4-col, row, fill)
				}
			}
		}
	}
	b.WriteString(`</svg>`)
	out := template.HTML(b.String())
	identiconMemo.Store(name, out)
	return out
}

// sparkSVG draws a thread's activity levels (0-4) as a tiny bar-run —
// conversation SHAPE at a glance: a flare-and-die support question and
// a slow-burn RFC stop looking alike.
func sparkSVG(levels []int) template.HTML {
	if len(levels) == 0 {
		return ""
	}
	var b strings.Builder
	w := len(levels) * 3
	fmt.Fprintf(&b, `<svg class="ft-spark" viewBox="0 0 %d 10" width="%d" height="10" aria-hidden="true">`, w, w)
	for i, l := range levels {
		h := l * 2
		if h == 0 {
			h = 1
		}
		fmt.Fprintf(&b, `<rect x="%d" y="%d" width="2" height="%d" rx="0.5" fill="currentColor" fill-opacity="%s"/>`,
			i*3, 10-h, h, map[bool]string{true: "0.9", false: "0.25"}[l > 0])
	}
	b.WriteString(`</svg>`)
	return template.HTML(b.String())
}

// stagedRow is one held submission in the moderation queue.
type stagedRow struct {
	ID       int64   `json:"id"`
	Kind     string  `json:"kind"`
	Title    string  `json:"title"`
	Body     string  `json:"body"`
	Author   string  `json:"author"`
	At       string  `json:"at"`
	AIAction string  `json:"aiAction"`
	AIScore  float64 `json:"aiScore"`
	AIReason string  `json:"aiReason"`
}

// modRow is one public moderation-log line.
type modRow struct {
	At     string `json:"at"`
	Actor  string `json:"actor"`
	Action string `json:"action"`
	Target string `json:"target"`
	Reason string `json:"reason"`
}

// 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"`
	LastHash    string `json:"lastHash"`
	LastDate    string `json:"lastDate"`
	LastComment string `json:"lastComment"`
}

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

// openBranches drops closed branches from a selector — a hundred
// finished work branches bury the four live ones — but never the one
// the reader is currently on: a closed branch is still browsable, and
// a selector that cannot show its own state is lying.
func openBranches(rows []branchRow, current string) []branchRow {
	out := rows[:0:0]
	for _, b := range rows {
		if !b.Closed || b.Name == current || b.Tip == current {
			out = append(out, b)
		}
	}
	return out
}

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

// matchesRepo is the fleet page's filter: a case-insensitive substring
// of the store name or, for a mirror, its upstream.
func matchesRepo(q string, fields ...string) bool {
	if q == "" {
		return true
	}
	q = strings.ToLower(q)
	for _, f := range fields {
		if strings.Contains(strings.ToLower(f), q) {
			return true
		}
	}
	return false
}

// tagRow is one release tag for the ref switcher.
type tagRow struct {
	Name string `json:"name"`
	Tip  string `json:"tip"`
	Date string `json:"date"`
}

// 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"`
	Date   string `json:"date"`
	Closed bool   `json:"closed"`
	// 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"`
	Created    string `json:"created,omitempty"`
	Type       string `json:"type,omitempty"`
	Severity   string `json:"severity,omitempty"`
	Priority   string `json:"priority,omitempty"`
	Subsystem  string `json:"subsystem,omitempty"`
	Resolution string `json:"resolution,omitempty"`
}

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"`
	Description string        `json:"description"`
	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
}

// privateFields are the ticket fields fossil does NOT show publicly.
// Its own new-ticket template labels private_contact "Not publicly
// visible" and gates it behind capability "e" (view private email
// addresses) with enable_output [hascap e]. quarry rendered it to
// everyone: across this corpus that is 2,650 values, of which some in
// fossil's own tracker are real e-mail addresses. Reproducing a
// tracker's content faithfully must not mean publishing what the
// tracker deliberately withheld.
//
// quarry has no equivalent capability yet, so these are dropped
// outright rather than gated. Dropping is the safe direction: an admin
// who needs the address can read the artifact, and nobody else can.
var privateFields = map[string]bool{"private_contact": true, "contact": true}

// 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] && !privateFields[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 ""
}

// Closed reports whether the ticket is in a terminal status. Fossil's
// default vocabulary closes on "Closed" and "Fixed"; a tracker with
// other words still gets a Comment & close button that writes Closed.
func (t ticketDetail) Closed() bool {
	switch strings.ToLower(t.Field("status")) {
	case "closed", "fixed":
		return true
	}
	return false
}

// ticketEvent is one entry of the ticket's timeline: a remark, a set of
// field changes, or both — fossil lets one artifact carry both, and a
// "closed with comment" is exactly that.
type ticketEvent struct {
	Date        string
	Login       string
	Username    string
	HTML        template.HTML
	Deltas      []fieldDelta
	Redescribed bool // the change rewrote the description
}

// fieldDelta is one field's move, for the "status Open → Review" line.
type fieldDelta struct {
	Name, From, To string
}

func (e ticketEvent) DifferentUsername() bool {
	return e.Username != "" && e.Username != e.Login
}

// Summary is the event's verb phrase: "edited the description, retitled
// <b>Short</b> and changed status <b>Open → Review</b>". Empty for a
// plain remark.
func (e ticketEvent) Summary() template.HTML {
	esc := template.HTMLEscapeString
	var parts, moves []string
	if e.Redescribed {
		parts = append(parts, "edited the description")
	}
	for _, d := range e.Deltas {
		if d.Name == "title" {
			parts = append(parts, "retitled <b>"+esc(d.To)+"</b>")
			continue
		}
		m := esc(d.Name) + " <b>"
		if d.From != "" {
			m += esc(d.From) + " → "
		}
		if d.To != "" {
			m += esc(d.To)
		} else {
			m += "—"
		}
		moves = append(moves, m+"</b>")
	}
	if len(moves) > 0 {
		parts = append(parts, "changed "+strings.Join(moves, ", "))
	}
	switch len(parts) {
	case 0:
		return ""
	case 1:
		return template.HTML(parts[0])
	}
	return template.HTML(strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1])
}

// timelineNoise is what a change artifact carries that is not a field
// the reader edits: the remark itself and the two mimetype columns.
var timelineNoise = map[string]bool{"icomment": true, "mimetype": true,
	"comment": true, "cmimetype": true}

// ticketTimeline merges the rendered remarks with the field changes the
// artifacts record, oldest first. The first change is the ticket's
// birth — its fields are the description, not a move — so it only
// contributes its remark. Later changes walk forward from that state
// so each event knows where a field came from. A remark and the field
// changes written in the same artifact share a date, which is how they
// fold into one event.
func ticketTimeline(t ticketDetail, comments []ticketComment) []ticketEvent {
	byDate := map[string]*ticketEvent{}
	var out []*ticketEvent
	at := func(date, login string) *ticketEvent {
		if e := byDate[date]; e != nil {
			return e
		}
		e := &ticketEvent{Date: date, Login: login}
		byDate[date] = e
		out = append(out, e)
		return e
	}
	state := map[string]string{}
	for i, c := range t.Changes {
		if i == 0 {
			for _, f := range c.Fields {
				state[f.Name] = f.Value
			}
			continue
		}
		var deltas []fieldDelta
		redescribed := false
		for _, f := range c.Fields {
			if f.Name == "comment" {
				redescribed = true
			}
			if timelineNoise[f.Name] || privateFields[f.Name] {
				continue
			}
			if state[f.Name] == f.Value {
				continue
			}
			deltas = append(deltas, fieldDelta{Name: f.Name, From: state[f.Name], To: f.Value})
			state[f.Name] = f.Value
		}
		if len(deltas) > 0 || redescribed {
			e := at(c.Date, c.User)
			e.Deltas = append(e.Deltas, deltas...)
			e.Redescribed = e.Redescribed || redescribed
		}
	}
	for _, c := range comments {
		e := at(c.Date, c.Login)
		e.Username, e.HTML = c.Username, c.HTML
		if e.Login == "" {
			e.Login = c.Login
		}
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].Date < out[j].Date })
	res := make([]ticketEvent, len(out))
	for i, e := range out {
		res[i] = *e
	}
	return res
}

// ticketControl is one editable field in the sidebar: the words the
// tracker uses for it, with the current value kept even when the
// vocabulary does not list it — a select that refused a word the
// tracker already writes would make the ticket uneditable.
type ticketControl struct {
	Name    string
	Value   string
	Options []string // empty: free text
}

// ticketControls builds the sidebar from fossil's triage fields in the
// order its view shows them, taking each field's words from the
// repository's vocabulary.
func ticketControls(t ticketDetail, vocab map[string][]string) []ticketControl {
	var out []ticketControl
	for _, name := range []string{"status", "type", "severity", "priority", "subsystem", "resolution"} {
		c := ticketControl{Name: name, Value: t.Field(name)}
		if words := vocab[name]; len(words) > 0 {
			c.Options = append([]string(nil), words...)
			if c.Value != "" && !slices.Contains(c.Options, c.Value) {
				c.Options = append([]string{c.Value}, c.Options...)
			}
		}
		out = append(out, c)
	}
	return out
}

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"`
	DiscussedIn []forumThreadRow `json:"discussedIn"`
}

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"`
	Structured   []diff.FileDiff `json:"structured"`
	OmittedLines int             `json:"omittedLines"`
	NextOffset   int             `json:"nextOffset"`
	TotalLines   int             `json:"totalLines"`
	LastOffset   int             `json:"lastOffset"`
	// Page-side: the file the view is narrowed to, the offset this
	// window began at, and the view mode — what the next-chunk URL
	// has to repeat.
	Repo   string `json:"-"`
	File   string `json:"-"`
	Offset int    `json:"offset"` // snapped by the op to the hunk it starts on
	Prev   int    `json:"-"`      // offset of the window before this one; -1 when unknown
	Split  bool   `json:"-"`
}

// Back is the URL of the window this one continued from, for the
// paged (no-script) reading: the continue link carries its own offset
// as prev, so one step back is always known. "" on the first window.
func (d diffDetail) Back() string {
	if d.Offset == 0 || d.Prev < 0 {
		return ""
	}
	return "?" + d.query(d.Prev, -1).Encode()
}

// Last is the URL of the final window, "" when this is it.
func (d diffDetail) Last() string {
	if d.LastOffset <= d.Offset {
		return ""
	}
	return "?" + d.query(d.LastOffset, -1).Encode()
}

// Start is the URL of the first window, same view.
func (d diffDetail) Start() string {
	return "?" + d.query(0, -1).Encode()
}

func (d diffDetail) query(after, prev int) url.Values {
	v := url.Values{}
	if after > 0 {
		v.Set("after", strconv.Itoa(after))
	}
	if prev >= 0 {
		v.Set("prev", strconv.Itoa(prev))
	}
	if d.File != "" {
		v.Set("file", d.File)
	}
	if d.Split {
		v.Set("view", "split")
	}
	return v
}

// More is the URL of the window after this one, or "" at the end.
func (d diffDetail) More(frag bool) string {
	if d.OmittedLines == 0 {
		return ""
	}
	v := d.query(d.NextOffset, d.Offset)
	if frag {
		v.Del("prev")
		// Absolute: the check-in page and its /diff deep link share
		// this template, and a relative URL would resolve differently
		// from each.
		return "/r/" + d.Repo + "/checkin/" + d.To + "/diff.frag?" + v.Encode()
	}
	return "?" + v.Encode()
}

// Paged reports whether the diff spans more than this window, which
// is when the pager is worth drawing.
func (d diffDetail) Paged() bool { return d.Offset > 0 || d.OmittedLines > 0 }

// Continued reports whether this window opens mid-file.
func (d diffDetail) Continued() bool {
	return len(d.Structured) > 0 && d.Structured[0].Before > 0 && len(d.Structured[0].Hunks) > 0
}

// diffWindow loads one window of a check-in's diff as the page shows
// it: ?file= narrows, ?after= continues, ?view=split widens.
func diffWindow(r *ops.Registry, req *http.Request, repo, hash string) (diffDetail, error) {
	q := req.URL.Query()
	after, _ := strconv.Atoi(q.Get("after"))
	out, err := invoke(req.Context(), r, "checkin.diff", url.Values{
		"repo": {repo}, "hash": {hash}, "file": {q.Get("file")},
		"offset": {strconv.Itoa(max(after, 0))}, "maxLines": {diffBudget}})
	if err != nil {
		return diffDetail{}, err
	}
	var dd diffDetail
	remarshal(out, &dd)
	dd.Repo, dd.File, dd.Split = repo, q.Get("file"), q.Get("view") == "split"
	dd.Prev = -1
	if p, err := strconv.Atoi(q.Get("prev")); err == nil && p >= 0 && p < dd.Offset {
		dd.Prev = p
	}
	return dd, nil
}

// diffBudget is the most diff lines a page renders. Past it, files
// show a count and a link to their own diff. ~3,000 keeps the whole
// page — diff, highlight, render — inside the response budget, and is
// already far more than anyone reads in one scroll.
const diffBudget = "3000"

// personRow is one contributor on the people tab.
type personRow struct {
	User   string   `json:"user"`
	Events int      `json:"events"`
	First  string   `json:"first"`
	Last   string   `json:"last"`
	Repos  []string `json:"repos"`
}

// 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.
// mayWrite answers whether this caller could write to the repository,
// WITHOUT challenging or refusing. gate answers the same question but
// as a decision that writes a response, which a page cannot use to
// decide whether to draw a control.
//
// It exists because the board's move control must follow write
// CAPABILITY, not session state: a caller authenticating with Basic
// auth can write and is not "signed in", and gating the control on
// SignedIn hid the only mechanism a no-script reader has for moving a
// card. Getting this wrong in the other direction is worse — a control
// shown to somebody who cannot use it — so it mirrors gateAt's decision
// rather than approximating it.
func mayWrite(verify Verify, sess Sessions, repo string, req *http.Request) bool {
	return mayAt(verify, sess, repo, ops.Token, req)
}

// mayAt is mayWrite at an explicit capability — the announcement
// control follows Admin the way the board's move control follows Token.
func mayAt(verify Verify, sess Sessions, repo string, level ops.Auth, req *http.Request) bool {
	if verify == nil {
		return true
	}
	if sess != nil {
		if user, ok := sess.Resolve(cookieToken(req)); ok {
			return sess.Allow(user, repo, level)
		}
	}
	user, pass, _ := req.BasicAuth()
	return verify(repo, user, pass, level) == api.Allowed
}

// viewer resolves who is LOOKING at a page, without ever challenging:
// session identity first, then Basic credentials — mayWrite's order.
// "" is anonymous. It personalizes reads (the lit arrow on a vote the
// viewer already cast); it grants nothing.
func viewer(verify Verify, sess Sessions, repo string, req *http.Request) string {
	if sess != nil {
		if user, ok := sess.Resolve(cookieToken(req)); ok {
			return user
		}
	}
	if user, pass, ok := req.BasicAuth(); ok && verify != nil &&
		verify(repo, user, pass, ops.Token) == api.Allowed {
		return user
	}
	return ""
}

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.
// mirrorGate refuses a write PAGE on a read-only mirror the way the
// ops refuse the write itself: the page would only collect input the
// op is going to reject. The refusal names the upstream.
func mirrorGate(r *ops.Registry, w http.ResponseWriter, req *http.Request, repo string) bool {
	info, err := invoke(req.Context(), r, "repo.get", url.Values{"repo": {repo}})
	if err != nil {
		return true // an unknown repo fails later, with its own message
	}
	var p posture
	remarshal(info, &p)
	if !p.ReadOnly() {
		return true
	}
	fail(r, w, req, fmt.Errorf("%w: %s mirrors %s and does not accept local writes", ops.ErrForbidden, repo, p.Mirror.URL))
	return false
}

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, tracker string) {
	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"`
		Errors     []errlogRow      `json:"errors"`
		ErrorTotal int              `json:"errorTotal"`
	}
	remarshal(out, &fs)
	// The header box filters THIS page: on the fleet page "search"
	// means "find the store", and both tables narrow to the match.
	q := req.URL.Query().Get("q")
	if q != "" {
		kept := fs.Repos[:0]
		for _, row := range fs.Repos {
			if matchesRepo(q, row.Name) {
				kept = append(kept, row)
			}
		}
		fs.Repos = kept
	}
	data := pageData{
		Title: "fleet", FleetHealth: fs.Repos, Unhealthy: fs.Unhealthy,
		SyncResults: sync, MirrorResults: pulls,
		ServerErrors: fs.Errors, ErrorTotal: fs.ErrorTotal,
		TrackerRepo: tracker, Query: q, SearchScope: "fleet", SearchAction: "/fleet",
	}
	// 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.MirrorCounts = map[string]int{}
		for _, m := range ml.Mirrors {
			if m.Enabled {
				data.MirrorCounts[m.State]++
				data.MirrorCounts["all"]++
			}
			if matchesRepo(q, m.Repo, m.URL, m.DetachedFrom) {
				data.Mirrors = append(data.Mirrors, m)
			}
		}
		if edit := req.URL.Query().Get("edit"); edit != "" {
			for i := range ml.Mirrors {
				if ml.Mirrors[i].Repo == edit {
					data.MirrorEdit = &ml.Mirrors[i]
				}
			}
		}
		// The confirmation pages for the two posture changes.
		if name := req.URL.Query().Get("detach"); name != "" {
			for i := range ml.Mirrors {
				if ml.Mirrors[i].Repo == name && ml.Mirrors[i].Enabled {
					data.MirrorEdit = &ml.Mirrors[i]
					data.Title = "detach " + name
					render(r, w, req, mirrorDetachPage, data)
					return
				}
			}
		}
		if name := req.URL.Query().Get("reattach"); name != "" {
			for i := range ml.Mirrors {
				if ml.Mirrors[i].Repo == name && !ml.Mirrors[i].Enabled {
					data.MirrorEdit = &ml.Mirrors[i]
					data.Title = "re-attach " + name
					render(r, w, req, mirrorReattachPage, data)
					return
				}
			}
		}
	}
	data.FleetRows, data.FleetProblems = fleetRows(data.FleetHealth, data.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
	// tracker names the repository whose ticket tracker is the forge's
	// own — where the fleet's "file ticket" buttons lead. Empty means
	// no such repo is designated and the buttons are not drawn.
	tracker string
}

// WithTracker designates the forge's own issue tracker.
func WithTracker(repo string) Option {
	return func(o *handlerOpts) { o.tracker = repo }
}

// 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.
// recovered wraps the whole handler tree: a panic becomes a recorded
// journal entry and a clean 500 instead of a dropped connection and a
// stack trace nobody sees.
func recovered(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
		defer func() {
			if p := recover(); p != nil {
				err := fmt.Errorf("panic: %v", p)
				log.Printf("web: %s: %v\n%s", req.URL.Path, err, debug.Stack())
				errlog.Default.Report("panic", req.URL.Path, err)
				// Headers may already be gone; best effort.
				http.Error(w, "internal error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, req)
	})
}

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("GET /people", func(w http.ResponseWriter, req *http.Request) {
		q := req.URL.Query().Get("q")
		// A page, not the fleet: at any real size this list is longer
		// than anyone scrolls, and rendering all of it helps nobody.
		const perPage = 50
		page, _ := strconv.Atoi(req.URL.Query().Get("page"))
		if page < 1 {
			page = 1
		}
		vals := url.Values{
			"limit":  {strconv.Itoa(perPage)},
			"offset": {strconv.Itoa((page - 1) * perPage)},
		}
		if q != "" {
			vals.Set("q", q)
		}
		out, err := invoke(req.Context(), r, "people.list", vals)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var pl struct {
			People []personRow `json:"people"`
			Total  int         `json:"total"`
		}
		remarshal(out, &pl)
		data := pageData{Title: "people", HideH1: true, Explore: "people",
			Query: q, People: pl.People, PeopleTotal: pl.Total, Page: page}
		if page*perPage < pl.Total {
			data.NextPage = page + 1
		}
		if page > 1 {
			data.PrevPage = page - 1
		}
		// The other tab's count comes from its own op, so the pair of
		// numbers on the tabs always describes the same moment.
		if fo, err := invoke(req.Context(), r, "repo.list", nil); err == nil {
			var fl struct {
				Total int `json:"total"`
			}
			remarshal(fo, &fl)
			data.FleetTotal = fl.Total
		}
		render(r, w, req, peoplePage, data)
	})
	mux.HandleFunc("GET /people/{user}", func(w http.ResponseWriter, req *http.Request) {
		want := req.PathValue("user")
		out, err := invoke(req.Context(), r, "people.list",
			url.Values{"q": {want}, "limit": {"500"}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var pl struct {
			People []personRow `json:"people"`
		}
		remarshal(out, &pl)
		// q is a substring filter, so an exact match must be picked out
		// of it: "dan" must not open "dan.mestas".
		for _, p := range pl.People {
			if p.User == want {
				render(r, w, req, personPage, pageData{Title: p.User, HideH1: true, Person: p})
				return
			}
		}
		fail(r, w, req, fmt.Errorf("no contributor %q: %w", want, ops.ErrNotFound))
	})
	mux.HandleFunc("GET /licenses", func(w http.ResponseWriter, req *http.Request) {
		notices, err := legal.Notices()
		if err != nil {
			fail(r, w, req, err)
			return
		}
		render(r, w, req, licensesPage, pageData{Title: "licenses",
			Notices: notices, QuarryLicence: legal.Quarry})
	})
	mux.HandleFunc("GET /privacy", func(w http.ResponseWriter, req *http.Request) {
		render(r, w, req, privacyPage, pageData{Title: "privacy"})
	})
	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 /inbox.unread", func(w http.ResponseWriter, req *http.Request) {
			// The bar's dot: fired lazily by the page after render, so
			// no page ever waits on notify to draw. Anonymous or outage
			// answers "no dot" — a decoration must never 500 a page.
			w.Header().Set("Content-Type", "text/event-stream")
			user, ok := sessions.Resolve(cookieToken(req))
			if !ok {
				return
			}
			msgs, err := inbox.Messages(req.Context(), user)
			if err != nil {
				return
			}
			unread := 0
			for _, m := range msgs {
				if !m.Read {
					unread++
				}
			}
			if unread == 0 {
				return
			}
			label := strconv.Itoa(unread)
			if unread > 9 {
				label = "9+"
			}
			fmt.Fprint(w, "event: datastar-patch-elements\n")
			fmt.Fprintf(w, "data: elements <span id=\"inbox-dot\" class=\"on\" title=\"%d unread\">%s</span>\n\n", unread, label)
		})
	}
	// The mark. Immutable per build and small, so it is cached hard —
	// a favicon refetched on every page load is the most pointless
	// request a site can make.
	icon := func(ctype string, body []byte) http.HandlerFunc {
		return func(w http.ResponseWriter, _ *http.Request) {
			w.Header().Set("Content-Type", ctype)
			w.Header().Set("Cache-Control", "public, max-age=604800")
			w.Write(body)
		}
	}
	mux.HandleFunc("GET /favicon.ico", icon("image/x-icon", faviconICO))
	mux.HandleFunc("GET /apple-touch-icon.png", icon("image/png", appleTouchIcon))
	mux.HandleFunc("GET /assets/quarry.png", icon("image/png", markPNG))
	mux.HandleFunc("GET /assets/composer.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(composerJS)
	})
	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) {
		// A signed-in reader's landing page is their dashboard —
		// GitHub's rule. The fleet stays one query param away
		// (?view=explore) and IS the page for everyone anonymous. A
		// filter or sort in the URL means the reader is exploring, so
		// those stay on the fleet too.
		if id, ok := req.Context().Value(ctxIdentity{}).(identity); ok && id.user != "" &&
			req.URL.Query().Get("view") != "explore" && req.URL.Query().Get("q") == "" &&
			req.URL.Query().Get("sort") == "" {
			mf, err := invoke(ops.WithUser(req.Context(), id.user), r, "me.feed", url.Values{})
			if err == nil {
				var out dashData
				remarshal(mf, &out)
				data := pageData{Title: "home", HideH1: true, Dash: &out}
				// The explore link names the fleet's size, so "where are
				// the rest of the repositories" answers itself.
				if ro, rerr := invoke(req.Context(), r, "repo.list", url.Values{"limit": {"1"}}); rerr == nil {
					var rl struct {
						Total int `json:"total"`
					}
					remarshal(ro, &rl)
					data.FleetTotal = rl.Total
				}
				render(r, w, req, dashboardPage, data)
				return
			}
			// No grants, no accounts plane, any failure: the fleet is
			// the honest fallback, never an error page for "/".
			log.Printf("web: dashboard for %s: %v", id.user, err)
		}
		q, sortBy, kind := req.URL.Query().Get("q"), req.URL.Query().Get("sort"), req.URL.Query().Get("kind")
		if sortBy == "" {
			sortBy = "name"
		}
		out, err := invoke(req.Context(), r, "repo.list",
			url.Values{"q": {q}, "sort": {sortBy}, "kind": {kind}, "limit": {"100"}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fl struct {
			Repos  []repoInfo `json:"repos"`
			Total  int        `json:"total"`
			Served int        `json:"served"`
		}
		remarshal(out, &fl)
		// The forge names itself in the top bar on every page, so the
		// aggregate page repeating it as a heading says nothing. The
		// heading stays in the document for structure and screen
		// readers; it is the visible duplicate that goes.
		peopleTotal := 0
		if po, err := invoke(req.Context(), r, "people.list", url.Values{"limit": {"1"}}); err == nil {
			var pl struct {
				Total int `json:"total"`
			}
			remarshal(po, &pl)
			peopleTotal = pl.Total
		}
		data := pageData{Title: "repositories", HideH1: true, PeopleTotal: peopleTotal,
			Fleet: fl.Repos, FleetTotal: fl.Total, Served: fl.Served,
			Query: q, Sort: sortBy, Kind: kind}
		if n != nil {
			data.Live = true
		}
		render(r, w, req, fleetPage, data)
	})
	settingsFor := func(req *http.Request, repo string) (info repoInfo) {
		out, err := invoke(req.Context(), r, "repo.get", url.Values{"repo": {repo}})
		if err != nil {
			return repoInfo{}
		}
		remarshal(out, &info)
		return info
	}
	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
		}
		info := settingsFor(req, repo)
		render(r, w, req, settingsPage, pageData{
			Title: "settings", HideH1: true, Repo: repo,
			RepoDescription: info.Description, RepoPrivate: info.Private,
			RepoHomepage: info.Homepage, RepoTopics: info.Topics,
			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 {
			info := settingsFor(req, repo)
			renderStatus(r, w, req, settingsPage, pageData{
				Title: repo, Repo: repo, RepoDescription: info.Description, RepoPrivate: info.Private,
				RepoHomepage: info.Homepage, RepoTopics: info.Topics,
				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")},
			"homepage": {req.FormValue("homepage")}, "topics": {req.FormValue("topics")}}
		if req.FormValue("private") == "true" {
			form.Set("private", "true")
		}
		data := pageData{Title: repo, Repo: repo, CloneURL: cloneURL(req, repo),
			RepoDescription: req.FormValue("description"),
			RepoHomepage:    req.FormValue("homepage"),
			RepoTopics:      req.FormValue("topics"),
			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")
		}
		switch req.FormValue("kind") {
		case "clone":
			form.Set("clone", req.FormValue("from"))
			if every := req.FormValue("every"); every != "" {
				form.Set("mirrorEvery", every)
			}
		case "own":
			form.Set("clone", req.FormValue("from"))
			form.Set("noMirror", "true")
		default:
			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, o.tracker)
	})
	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, o.tracker)
	})
	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/detach", func(w http.ResponseWriter, req *http.Request) {
		req, ok := gateAt(verify, sessions, "", ops.Admin, w, req)
		if !ok {
			return
		}
		// No acknowledgement, no detach: the button on the table row
		// leads to the confirmation page, never straight here.
		if req.FormValue("ack") != "fork" {
			http.Redirect(w, req, "/fleet?detach="+url.QueryEscape(req.FormValue("repo")), http.StatusSeeOther)
			return
		}
		if _, err := invoke(req.Context(), r, "mirror.detach",
			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/writes", 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.writes", url.Values{
			"repo": {req.FormValue("repo")}, "accept": {req.FormValue("accept")}}); 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, o.tracker)
	})
	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.rev", func(w http.ResponseWriter, req *http.Request) {
		if n == nil {
			http.Error(w, "no live plane", http.StatusNotFound)
			return
		}
		fleetRevStream(r, n, w, req)
	})
	mux.HandleFunc("GET /fleet.frag", func(w http.ResponseWriter, req *http.Request) {
		fleetFrag(r, 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
		}
		// An empty version stays empty: the op substitutes the
		// repository's DEFAULT branch, which is not always literally
		// "trunk", and reports the name it used in Ref.
		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) {
				info := settingsFor(req, repo)
				render(r, w, req, quickStartPage, pageData{
					Title: repo, Repo: repo, CloneURL: cloneURL(req, repo),
					RepoDescription: info.Description, RepoPrivate: info.Private,
				})
				return
			}
			fail(r, w, req, err)
			return
		}
		var tl struct {
			Version  string    `json:"version"`
			Ref      string    `json:"ref"`
			Path     string    `json:"path"`
			Entries  []treeRow `json:"entries"`
			Readme   string    `json:"readme"`
			ReadmeIn string    `json:"readmeIn"`
		}
		remarshal(out, &tl)
		if version == "" && tl.Ref != "" {
			version = tl.Ref
		}

		data := pageData{
			Title: repo, Repo: repo, Tree: tl.Entries, TreeVersion: version,
			Readme: template.HTML(tl.Readme), ReadmeIn: tl.ReadmeIn,
			CloneURL: cloneURL(req, repo),
		}
		for _, e := range tl.Entries {
			if e.LastHash != "" {
				data.TreeChanges = true
				break
			}
		}
		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 tl.Path == "" {
			// GitHub's bar names the branch's tip; a hash version has no
			// branch timeline, and the empty answer simply hides the bar.
			if to, terr := invoke(req.Context(), r, "timeline.list", url.Values{
				"repo": {repo}, "limit": {"1"}, "y": {"ci"}, "branch": {version}}); terr == nil {
				var tt struct {
					Entries []timelineEntry `json:"entries"`
				}
				remarshal(to, &tt)
				// Only when the index's newest IS the tree being shown:
				// while an index rebuild lags the store, the timeline's
				// "newest" is years behind the listing zit just served,
				// and a bar contradicting the files below it is worse
				// than no bar.
				if len(tt.Entries) == 1 && tt.Entries[0].Hash == tl.Version {
					data.Tip = &tt.Entries[0]
				}
			}
		}
		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)
			// The strip's count keeps ALL branches — the number a reader
			// compares with the branch page — while the selector below
			// lists only the open ones.
			data.BranchTotal = len(bl.Branches)
			bl.Branches = openBranches(bl.Branches, version)
			// 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)
			// The button says which ref is in view. Falling back to the
			// short hash rather than to "trunk" keeps it honest when a
			// reader is on a detached check-in.
			data.RefName = version[:min(12, len(version))]
			for _, b := range data.Branches {
				if b.Tip == version {
					data.RefName = b.Name
					break
				}
			}
		}
		if to, err := invoke(req.Context(), r, "tag.list",
			url.Values{"repo": {repo}, "limit": {"25"}}); err == nil {
			var tl struct {
				Tags  []tagRow `json:"tags"`
				Count int      `json:"count"`
			}
			remarshal(to, &tl)
			data.Tags = tl.Tags
			data.TagTotal = tl.Count
			for _, t := range tl.Tags {
				if t.Tip == version {
					data.RefName = t.Name
					break
				}
			}
		}
		// 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.
		sinfo := settingsFor(req, repo)
		data.RepoDescription, data.RepoPrivate = sinfo.Description, sinfo.Private
		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 clone tool is a URL parameter so the choice survives a
		// reload and can be linked to. fossil is offered only where the
		// store can actually be cloned by it.
		data.CloneTool = "zit"
		if info, err := invoke(req.Context(), r, "repo.get", url.Values{"repo": {repo}}); err == nil {
			var ri struct {
				FossilClone bool `json:"fossilClone"`
			}
			remarshal(info, &ri)
			data.FossilClone = ri.FossilClone
			if ri.FossilClone && req.URL.Query().Get("clone") == "fossil" {
				data.CloneTool = "fossil"
			}
		}
		// The repository ROOT carries the About sidebar whatever ref is
		// showing — switching branches must not make the repo forget
		// what it is. The document TABS are default-ref only, though:
		// repo.about renders default-branch docs, and presenting them
		// under a branch's listing would show the wrong branch's README
		// as if it were this one's. On a branch root the aside sheds its
		// doc rows and the branch's own README renders below, plainly.
		if tl.Path == "" {
			atDefault := req.PathValue("version") == ""
			vals := url.Values{"repo": {repo}}
			if d := req.URL.Query().Get("doc"); d != "" && atDefault {
				vals.Set("doc", d)
			}
			if ao, err := invoke(req.Context(), r, "repo.about", vals); err == nil {
				var about aboutData
				remarshal(ao, &about)
				data.About = &about
				if atDefault {
					data.DocHTML = template.HTML(about.DocHTML)
					// The tab panel replaces the plain README block:
					// README is the first tab, and rendering it twice
					// says nothing.
					data.Readme, data.ReadmeIn = "", ""
				} else {
					about.Docs, about.DocPath, about.DocLabel, about.DocHTML = nil, "", "", ""
				}
			} else if !errors.Is(err, ops.ErrNotFound) {
				log.Printf("web: about %s: %v", repo, err)
			} else if atDefault {
				// ?doc= named a tab that does not exist.
				fail(r, w, req, err)
				return
			}
		}
		// The crumb trail is this page's heading.
		data.HideH1 = true
		render(r, w, req, treePage, data)
	}
	mux.HandleFunc("GET /r/{repo}/doc.frag", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		vals := url.Values{"repo": {repo}}
		if d := req.URL.Query().Get("doc"); d != "" {
			vals.Set("doc", d)
		}
		ao, err := invoke(req.Context(), r, "repo.about", vals)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var about aboutData
		remarshal(ao, &about)
		data := pageData{Repo: repo, About: &about, DocHTML: template.HTML(about.DocHTML)}
		var buf bytes.Buffer
		buf.WriteString(`<div id="docs">`)
		if err := treePage.ExecuteTemplate(&buf, "doc-panel", data); err != nil {
			http.Error(w, "panel render failed", http.StatusInternalServerError)
			return
		}
		buf.WriteString(`</div>`)
		w.Header().Set("Content-Type", "text/event-stream")
		fmt.Fprint(w, "event: datastar-patch-elements\n")
		for _, line := range strings.Split(buf.String(), "\n") {
			fmt.Fprintf(w, "data: elements %s\n", line)
		}
		fmt.Fprint(w, "\n")
	})
	mux.HandleFunc("GET /r/{repo}/branch", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "branch.list", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var bl struct {
			Branches []branchRow `json:"branches"`
		}
		remarshal(out, &bl)
		closed := 0
		for _, b := range bl.Branches {
			if b.Closed {
				closed++
			}
		}
		render(r, w, req, branchesPage, pageData{Title: "branches", Repo: repo,
			Branches: bl.Branches, ClosedCount: closed})
	})
	mux.HandleFunc("GET /r/{repo}/tag", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "tag.list",
			url.Values{"repo": {repo}, "limit": {"200"}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tl struct {
			Tags []tagRow `json:"tags"`
		}
		remarshal(out, &tl)
		render(r, w, req, tagsPage, pageData{Title: "tags", Repo: repo, Tags: tl.Tags})
	})
	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)
		// Check-ins are the default view: a code timeline is what a
		// reader comes for, and a busy wiki or tracker was drowning it.
		// "everything" is one pill away and explicit in the URL (y=all).
		if vals.Get("y") == "" {
			vals.Set("y", "ci")
		}
		// 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"`
			IndexBehind bool            `json:"indexBehind"`
		}
		remarshal(out, &tl)
		data := pageData{
			Title: "timeline", Repo: repo, Days: buildTimeline(repo, tl.Entries), Next: tl.Next,
			Branch: req.URL.Query().Get("branch"), Y: vals.Get("y"),
			Limit: limit, LimitChoices: []int{20, 50, 100, 200},
		}
		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)
			bl.Branches = openBranches(bl.Branches, data.Branch)
			const shown = 25
			data.Branches = bl.Branches
			if len(bl.Branches) > shown {
				data.Branches = bl.Branches[:shown]
				found := false
				for _, b := range data.Branches {
					if b.Name == data.Branch {
						found = true
						break
					}
				}
				if !found && data.Branch != "" {
					for _, b := range bl.Branches {
						if b.Name == data.Branch {
							data.Branches = append([]branchRow{b}, data.Branches...)
							break
						}
					}
				}
			}
		}
		// 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")
		sort := req.URL.Query().Get("sort")
		out, err := invoke(req.Context(), r, "forum.list", url.Values{"repo": {repo}, "sort": {sort}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fl struct {
			Threads       []forumThreadRow `json:"threads"`
			Announcements []forumThreadRow `json:"announcements"`
		}
		remarshal(out, &fl)
		flash := ""
		if req.URL.Query().Get("staged") != "" {
			flash = "Your post is held for moderation and will appear once a moderator approves it."
		}
		render(r, w, req, forumListPage, pageData{Title: "forum", Repo: repo,
			Forum: fl.Threads, ForumPinned: fl.Announcements, Sort: sort, Flash: flash,
			// On THIS page CanWrite carries the announce capability: the
			// "new announcement" control follows Admin.
			CanWrite: mayAt(verify, sessions, repo, ops.Admin, req)})
	})
	mux.HandleFunc("GET /r/{repo}/forum/queue", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
		if !ok {
			return
		}
		out, err := invoke(req.Context(), r, "forum.queue", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var q struct {
			Entries []stagedRow `json:"entries"`
			Enabled bool        `json:"enabled"`
		}
		remarshal(out, &q)
		render(r, w, req, forumQueuePage, pageData{Title: "moderation queue", Repo: repo,
			Queue: q.Entries, ModOn: q.Enabled})
	})
	mux.HandleFunc("POST /r/{repo}/forum/queue", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
		if !ok {
			return
		}
		var err error
		if t := req.FormValue("toggle"); t != "" {
			_, err = invoke(req.Context(), r, "forum.moderation",
				url.Values{"repo": {repo}, "enabled": {t}})
		} else {
			_, err = invoke(req.Context(), r, "forum.moderate", url.Values{
				"repo": {repo}, "id": {req.FormValue("id")},
				"action": {req.FormValue("action")}, "reason": {req.FormValue("reason")},
			})
		}
		if err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/forum/queue", http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/forum/modlog", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "forum.modlog", url.Values{"repo": {repo}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var ml struct {
			Entries []modRow `json:"entries"`
		}
		remarshal(out, &ml)
		render(r, w, req, forumModlogPage, pageData{Title: "moderation log", Repo: repo, ModLog: ml.Entries})
	})
	mux.HandleFunc("POST /r/{repo}/markup-preview", 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, "markup.preview", url.Values{
			"repo": {repo}, "body": {req.FormValue("body")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var mp struct {
			HTML string `json:"html"`
		}
		remarshal(out, &mp)
		// plain=1 is composer.js's live pane: a bare sanitized fragment,
		// no SSE wrapping — innerHTML on the client side.
		if req.URL.Query().Get("plain") == "1" {
			w.Header().Set("Content-Type", "text/html; charset=utf-8")
			io.WriteString(w, mp.HTML)
			return
		}
		// Patch the pane. The patched element drops the `hidden` attr the
		// initial no-JS markup carries — from here on data-show governs.
		w.Header().Set("Content-Type", "text/event-stream")
		fmt.Fprint(w, "event: datastar-patch-elements\n")
		frag := `<div id="mdpreview" class="f-preview" data-show="$mdprev">` + mp.HTML + `</div>`
		for _, line := range strings.Split(frag, "\n") {
			fmt.Fprintf(w, "data: elements %s\n", line)
		}
		fmt.Fprint(w, "\n")
	})
	mux.HandleFunc("POST /r/{repo}/forum/{root}/subscribe", 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.subscribe", url.Values{
			"repo": {repo}, "root": {root}, "state": {req.FormValue("state")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/forum/"+root, http.StatusSeeOther)
	})
	mux.HandleFunc("POST /r/{repo}/forum/{root}/hide", func(w http.ResponseWriter, req *http.Request) {
		repo, root := req.PathValue("repo"), req.PathValue("root")
		req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
		if !ok {
			return
		}
		if _, err := invoke(req.Context(), r, "forum.hide", url.Values{
			"repo": {repo}, "post": {req.FormValue("post")},
			"action": {req.FormValue("action")}, "reason": {req.FormValue("reason")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/forum/"+root, http.StatusSeeOther)
	})
	mux.HandleFunc("GET /r/{repo}/announce-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		if _, ok := gateAt(verify, sessions, repo, ops.Admin, w, req); !ok {
			return
		}
		render(r, w, req, forumAnnouncePage, pageData{Title: "new announcement", Repo: repo, Composer: true})
	})
	mux.HandleFunc("POST /r/{repo}/announce-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		req, ok := gateAt(verify, sessions, repo, ops.Admin, w, req)
		if !ok {
			return
		}
		out, err := invoke(req.Context(), r, "forum.announce", 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("GET /r/{repo}/forum-new", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		if !mirrorGate(r, w, req, repo) {
			return
		}
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		render(r, w, req, forumNewPage, pageData{Title: "new thread", Repo: repo, Composer: true})
	})
	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"`
			Staged bool   `json:"staged"`
		}
		remarshal(out, &fp)
		if fp.Staged {
			http.Redirect(w, req, "/r/"+repo+"/forum?staged=1", http.StatusSeeOther)
			return
		}
		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
		}
		out, err := invoke(req.Context(), r, "forum.reply", url.Values{
			"repo": {repo}, "root": {root}, "body": {req.FormValue("body")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var fr struct {
			Staged bool `json:"staged"`
		}
		remarshal(out, &fr)
		if fr.Staged {
			http.Redirect(w, req, "/r/"+repo+"/forum/"+root+"?staged=1", http.StatusSeeOther)
			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 !mirrorGate(r, w, req, repo) {
			return
		}
		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 !mirrorGate(r, w, req, repo) {
			return
		}
		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")
		// The viewer's identity rides into forum.get so the page can
		// light the arrow they already cast (YourVote).
		ctx := req.Context()
		if u := viewer(verify, sessions, repo, req); u != "" {
			ctx = ops.WithUser(ctx, u)
		}
		out, err := invoke(ctx, 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"`
			Excerpt string         `json:"excerpt"`
			Posts   []forumPostRow `json:"posts"`
			YourSub string         `json:"yourSub"`
		}
		remarshal(out, &fg)
		title := fg.Title
		if title == "" {
			title = "thread"
		}
		flash := ""
		if req.URL.Query().Get("staged") != "" {
			flash = "Your reply is held for moderation and will appear once a moderator approves it."
		}
		render(r, w, req, forumThreadPage, pageData{
			Title: title, Repo: repo, ForumPosts: fg.Posts, YourSub: fg.YourSub,
			OGDesc:    fg.Excerpt,
			ForumRoot: req.PathValue("root"), Flash: flash, Composer: true,
			// Same rule as the board's move control: the arrows follow
			// write CAPABILITY, not session state.
			CanWrite: mayWrite(verify, sessions, repo, req),
			CanMod:   mayAt(verify, sessions, repo, ops.Admin, req),
		})
	})
	mux.HandleFunc("POST /r/{repo}/forum/{root}/vote", 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
		}
		out, err := invoke(req.Context(), r, "forum.vote", url.Values{
			"repo": {repo}, "post": {req.FormValue("post")}, "dir": {req.FormValue("dir")},
		})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		// A datastar submit gets the widget patched in place — the
		// score and arrows update with no page flash. A plain form
		// submit gets the redirect it always did (Principle 3).
		if req.Header.Get("datastar-request") != "true" {
			http.Redirect(w, req, "/r/"+repo+"/forum/"+root, http.StatusSeeOther)
			return
		}
		var fv struct {
			Post  string `json:"post"`
			Score int    `json:"score"`
		}
		remarshal(out, &fv)
		yourVote := map[string]int{"up": 1, "down": -1}[req.FormValue("dir")]
		var buf bytes.Buffer
		if err := forumThreadPage.ExecuteTemplate(&buf, "vote-widget",
			voteCtx{Repo: repo, Root: root, Hash: fv.Post, Score: fv.Score, YourVote: yourVote}); err != nil {
			http.Error(w, "widget render failed", http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "text/event-stream")
		fmt.Fprint(w, "event: datastar-patch-elements\n")
		for _, line := range strings.Split(buf.String(), "\n") {
			fmt.Fprintf(w, "data: elements %s\n", line)
		}
		fmt.Fprint(w, "\n")
	})
	mux.HandleFunc("POST /r/{repo}/forum/{root}/poll", 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.poll", url.Values{
			"repo": {repo}, "post": {req.FormValue("post")}, "choice": {req.FormValue("choice")},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		http.Redirect(w, req, "/r/"+repo+"/forum/"+root, http.StatusSeeOther)
	})
	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")
		closed := req.URL.Query().Get("closed") != ""
		byPriority := req.URL.Query().Get("sort") != "recent"
		vals := url.Values{"repo": {repo}, "before": {req.URL.Query().Get("before")}}
		if closed {
			vals.Set("closed", "1")
		}
		if byPriority {
			// Priority order cannot ride the updated-cursor pagination,
			// so it takes one op-limit page and says when that is not
			// everything — the same honesty as the board.
			vals.Del("before")
			vals.Set("limit", strconv.Itoa(boardLimit))
		}
		out, err := invoke(req.Context(), r, "ticket.list", vals)
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var tl struct {
			Tickets []ticketSummary `json:"tickets"`
			Next    string          `json:"next"`
		}
		remarshal(out, &tl)
		if byPriority {
			byTriage(tl.Tickets, ticketVocabFor(r, req, repo))
			tl.Next = ""
		}
		data := pageData{Title: "tickets", Repo: repo, Tickets: tl.Tickets,
			Next: tl.Next, ShowClosed: closed, ByPriority: byPriority,
			Shown: len(tl.Tickets), Truncated: byPriority && len(tl.Tickets) >= boardLimit}
		if req.URL.Query().Get("view") == "board" {
			q := req.URL.Query()
			bd, err := boardData(r, req, repo, closed, q.Get("group"), q.Get("lane"))
			if id := q.Get("moved"); id != "" && err == nil {
				mv := &boardMove{ID: id, Field: bd.GroupBy, From: q.Get("from"), To: q.Get("to")}
				for _, lane := range bd.Board {
					for _, col := range lane.Columns {
						for _, t := range col.Tickets {
							if strings.HasPrefix(t.ID, id) {
								mv.Title = t.Title
							}
						}
					}
				}
				bd.Moved = mv
			}
			if err != nil {
				fail(r, w, req, err)
				return
			}
			bd.CanWrite = mayWrite(verify, sessions, repo, req)
			render(r, w, req, ticketBoardPage, bd)
			return
		}
		render(r, w, req, ticketListPage, data)
	})
	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]
		}
		vocab := ticketVocabFor(r, req, repo)
		render(r, w, req, ticketPage, pageData{
			Title: title, Repo: repo, Ticket: td.Ticket,
			Attachments:     td.Attachments,
			DescriptionHTML: template.HTML(td.DescriptionHTML),
			TicketComments:  td.Comments,
			Timeline:        ticketTimeline(td.Ticket, td.Comments),
			TicketFields:    ticketControls(td.Ticket, vocab),
			Vocab:           vocab,
			CanWrite:        mayWrite(verify, sessions, repo, req),
			Composer:        true,
		})
	})
	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")
		dd, err := diffWindow(r, req, repo, req.PathValue("hash"))
		if err != nil {
			fail(r, w, req, err)
			return
		}
		title := "diff " + dd.To[:12]
		if dd.File != "" {
			title = dd.File + " — " + title
		}
		render(r, w, req, diffPage, pageData{Title: title, Repo: repo, Diff: dd, DiffSplit: dd.Split})
	})
	// The next window of a diff, for the sentinel at the end of the
	// last one: the same sections the page renders, replacing the
	// sentinel, followed by a fresh sentinel (or none, at the end).
	mux.HandleFunc("GET /r/{repo}/checkin/{hash}/diff.frag", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		dd, err := diffWindow(r, req, repo, req.PathValue("hash"))
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var buf strings.Builder
		if err := diffPage.ExecuteTemplate(&buf, "diff-more", pageData{Repo: repo, Diff: dd, DiffSplit: dd.Split}); err != nil {
			http.Error(w, "diff render failed", http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-store")
		fmt.Fprint(w, "event: datastar-patch-elements\ndata: mode replace\ndata: selector #diff-more\n")
		for _, line := range strings.Split(buf.String(), "\n") {
			fmt.Fprintf(w, "data: elements %s\n", line)
		}
		fmt.Fprint(w, "\n")
	})
	// 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),
		})
	})
	// The /info dispatch, as fossil has it: a bare artifact prefix
	// resolves to whatever kind of page owns it. Comment bracket links
	// land here, because "[4bd630]" in a real comment is as often a
	// ticket as a check-in.
	mux.HandleFunc("GET /r/{repo}/info/{prefix}", func(w http.ResponseWriter, req *http.Request) {
		repo := req.PathValue("repo")
		out, err := invoke(req.Context(), r, "artifact.resolve",
			url.Values{"repo": {repo}, "prefix": {req.PathValue("prefix")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var res struct{ Kind, Ref string }
		remarshal(out, &res)
		page := ""
		switch res.Kind {
		case "ci":
			page = "/checkin/" + url.PathEscape(res.Ref)
		case "t":
			page = "/ticket/" + url.PathEscape(res.Ref)
		case "w":
			page = "/wiki/" + url.PathEscape(res.Ref)
		case "e":
			page = "/technote/" + url.PathEscape(res.Ref)
		case "f":
			page = "/forum/" + url.PathEscape(res.Ref)
		default:
			fail(r, w, req, ops.ErrNotFound)
			return
		}
		http.Redirect(w, req, "/r/"+url.PathEscape(repo)+page, http.StatusFound)
	})
	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")}, "files": {"0"}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var cd checkinDetail
		remarshal(out, &cd)
		// The message IS the page's name — fossil's /info leads with the
		// comment, and a reader recognises "fix shift key firing twice"
		// where they will never recognise twelve hex digits.
		first, body, _ := strings.Cut(strings.TrimSpace(cd.Comment), "\n")
		title := commentText(first)
		if title == "" {
			title = "check-in " + cd.Hash[:12]
		}
		data := pageData{Title: title, HideH1: true, Repo: repo, Checkin: cd,
			CheckinLead: commentHTML(repo, first),
			CheckinBody: commentHTML(repo, strings.TrimSpace(body)),
			// 100 characters covers 90% of first lines in the ingested
			// corpus; past it this is a paragraph, and a paragraph in
			// bold display type is a wall.
			CheckinLongLead: len([]rune(title)) > 100,
			CIBadge:         ciBadge(cd.CI),
			DiffSplit:       req.URL.Query().Get("view") == "split"}
		if dd, derr := diffWindow(r, req, repo, req.PathValue("hash")); derr == nil {
			data.Diff, data.DiffSplit = dd, dd.Split
		}
		render(r, w, req, checkinPage, data)
	})
	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 !mirrorGate(r, w, req, repo) {
			return
		}
		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")
		if !mirrorGate(r, w, req, repo) {
			return
		}
		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 !mirrorGate(r, w, req, repo) {
			return
		}
		if _, ok := gate(verify, sessions, repo, w, req); !ok {
			return
		}
		q := req.URL.Query()
		render(r, w, req, ticketNewPage, pageData{Title: "new ticket", Repo: repo,
			Vocab: ticketVocabFor(r, req, repo),
			Prefill: ticketPrefill{Title: q.Get("title"), Comment: q.Get("comment"),
				Type: q.Get("type"), Severity: q.Get("severity"),
				Priority: q.Get("priority"), Subsystem: q.Get("subsystem")}})
	})
	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 !mirrorGate(r, w, req, repo) {
			return
		}
		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,
			Vocab: ticketVocabFor(r, req, repo),
		})
	})
	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
		}
		// Only what moved is written. The sidebar posts every field on
		// each change; sending them all would record a J card per field
		// per save, and the timeline would read "changed status Open →
		// Open, priority High → High" after every click.
		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)
		vals := url.Values{"repo": {repo}, "id": {id}}
		if c := strings.TrimSpace(req.FormValue("comment")); c != "" {
			vals.Set("comment", c)
		}
		for _, f := range []string{"title", "status", "resolution", "type", "severity", "priority", "subsystem"} {
			if _, sent := req.Form[f]; !sent {
				continue
			}
			if v := req.FormValue(f); v != "" && v != tg.Ticket.Field(f) {
				vals.Set(f, v)
			}
		}
		if v := req.FormValue("description"); v != "" && v != tg.Ticket.Description {
			vals.Set("description", v)
		}
		if len(vals) > 2 {
			if _, err := invoke(req.Context(), r, "ticket.set", vals); err != nil {
				fail(r, w, req, err)
				return
			}
		}
		if req.Header.Get("Datastar-Request") == "" {
			http.Redirect(w, req, "/r/"+repo+"/ticket/"+id, http.StatusSeeOther)
			return
		}
		// Script saved a select: patch the sidebar and the timeline.
		out, err = invoke(req.Context(), r, "ticket.get", url.Values{"repo": {repo}, "id": {id}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var td struct {
			Ticket   ticketDetail    `json:"ticket"`
			Comments []ticketComment `json:"comments"`
		}
		remarshal(out, &td)
		vocab := ticketVocabFor(r, req, repo)
		data := pageData{Repo: repo, Ticket: td.Ticket, CanWrite: true,
			Timeline: ticketTimeline(td.Ticket, td.Comments), TicketFields: ticketControls(td.Ticket, vocab), Vocab: vocab}
		w.Header().Set("Content-Type", "text/event-stream")
		for _, part := range []struct{ sel, tpl string }{{"#tkt-timeline", "tkt-timeline"}, {"#tkt-side", "tkt-side"}} {
			var buf bytes.Buffer
			if err := ticketPage.ExecuteTemplate(&buf, part.tpl, data); err != nil {
				http.Error(w, "ticket render failed", http.StatusInternalServerError)
				return
			}
			fmt.Fprint(w, "event: datastar-patch-elements\n")
			fmt.Fprintf(w, "data: selector %s\n", part.sel)
			fmt.Fprint(w, "data: mode inner\n")
			for _, line := range strings.Split(buf.String(), "\n") {
				fmt.Fprintf(w, "data: elements %s\n", line)
			}
			fmt.Fprint(w, "\n")
		}
	})
	// POST .../move is the board's write: one field, one value, and a
	// repainted board in reply. The per-card select posts to /edit and
	// gets a redirect; this exists because a fetch cannot follow one.
	//
	// The field is checked against the board's own axes. Without that
	// check the query string would choose which ticket column to write,
	// which is a different feature than "move a card".
	mux.HandleFunc("POST /r/{repo}/ticket/{id}/move", 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
		}
		q := req.URL.Query()
		field, to := q.Get("field"), q.Get("to")
		if !boardAxis(field) {
			http.Error(w, "not a board field", http.StatusBadRequest)
			return
		}
		// The blank lane is a rendering of absence, not a value to write.
		if to == unsetLane {
			to = ""
		}
		// Where it came from, for the status line and its undo.
		move := boardMove{ID: id, Field: field, To: to}
		if tg, err := invoke(req.Context(), r, "ticket.get", url.Values{"repo": {repo}, "id": {id}}); err == nil {
			var cur struct {
				Ticket struct {
					Fields []struct {
						Name, Value string
					} `json:"fields"`
				} `json:"ticket"`
			}
			remarshal(tg, &cur)
			for _, f := range cur.Ticket.Fields {
				switch {
				case strings.EqualFold(f.Name, field):
					move.From = f.Value
				case strings.EqualFold(f.Name, "title"):
					move.Title = f.Value
				}
			}
		}
		if _, err := invoke(req.Context(), r, "ticket.set", url.Values{
			"repo": {repo}, "id": {id}, field: {to},
		}); err != nil {
			fail(r, w, req, err)
			return
		}
		// A plain form post (the Move menu with scripting off, or undo)
		// lands back on the board; Datastar gets the fragment.
		if req.Header.Get("Datastar-Request") == "" {
			back := url.Values{"view": {"board"}, "group": {q.Get("group")}, "lane": {q.Get("lane")},
				"moved": {id}, "to": {to}, "from": {move.From}}
			if q.Get("closed") == "1" {
				back.Set("closed", "1")
			}
			http.Redirect(w, req, "/r/"+repo+"/ticket?"+back.Encode(), http.StatusSeeOther)
			return
		}
		data, err := boardData(r, req, repo, q.Get("closed") == "1", q.Get("group"), q.Get("lane"))
		if err != nil {
			fail(r, w, req, err)
			return
		}
		data.CanWrite = true
		data.Moved = &move
		var buf bytes.Buffer
		if err := ticketBoardPage.ExecuteTemplate(&buf, "board-body", data); err != nil {
			http.Error(w, "board render failed", http.StatusInternalServerError)
			return
		}
		w.Header().Set("Content-Type", "text/event-stream")
		fmt.Fprint(w, "event: datastar-patch-elements\n")
		fmt.Fprint(w, "data: selector #board\n")
		fmt.Fprint(w, "data: mode inner\n")
		for _, line := range strings.Split(buf.String(), "\n") {
			fmt.Fprintf(w, "data: elements %s\n", line)
		}
		fmt.Fprint(w, "\n")
	})
	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 recovered(withTimer(withIdentity(sessions, inbox != nil, mux)))
}

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

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

// fleetRevStream is the landing page's live signal: it says THAT the
// fleet changed, never WHAT it looks like. The old stream carried
// rendered rows, so it also carried the filter it was opened with —
// opened at load that filter was empty, and a few seconds after the
// reader typed, the background repaint replaced their filtered rows
// with everything (and Datastar's retry resurrected the stale stream
// after any attempt to cancel it). A revision number cannot be stale
// by construction: the client re-requests the listing itself, sending
// whatever its form says NOW.
func fleetRevStream(r *ops.Registry, n Notifier, w http.ResponseWriter, req *http.Request) {
	fl, ok := w.(http.Flusher)
	if !ok {
		http.Error(w, "streaming unsupported", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-store")
	fmt.Fprint(w, ": live\n\n")
	fl.Flush()

	ticks, cancel := n.Watch("*")
	defer cancel()
	// The indexing badge clears AFTER a store's last event, so ticks
	// alone would leave it stuck until the next unrelated change. The
	// poll watches a cheap fingerprint — which repositories are still
	// indexing — and bumps only when it changes; an idle fleet costs
	// one op invoke per poll and sends nothing.
	poll := time.NewTicker(2 * time.Second)
	defer poll.Stop()
	heartbeat := time.NewTicker(20 * time.Second)
	defer heartbeat.Stop()

	rev := 0
	bump := func() {
		rev++
		fmt.Fprint(w, "event: datastar-patch-signals\n")
		fmt.Fprintf(w, "data: signals {fleetRev: %d}\n\n", rev)
		fl.Flush()
	}
	fingerprint := func() string {
		out, err := invoke(req.Context(), r, "repo.list", url.Values{})
		if err != nil {
			return ""
		}
		var fl2 struct {
			Repos []struct {
				Name     string `json:"name"`
				Indexing bool   `json:"indexing"`
			} `json:"repos"`
		}
		remarshal(out, &fl2)
		var b strings.Builder
		for _, rp := range fl2.Repos {
			if rp.Indexing {
				b.WriteString(rp.Name)
				b.WriteByte(' ')
			}
		}
		return b.String()
	}
	lastFP := fingerprint()
	for {
		select {
		case <-req.Context().Done():
			return
		case <-heartbeat.C:
			fmt.Fprint(w, ": ping\n\n")
			fl.Flush()
		case <-poll.C:
			if fp := fingerprint(); fp != lastFP {
				lastFP = fp
				bump()
			}
		case _, open := <-ticks:
			if !open {
				return
			}
			bump()
		}
	}
}

// fleetFrag renders the #fleet region ONCE, with whatever filter the
// request carries, and closes. Datastar's form content type serializes
// the surrounding form onto the GET, so the scripted refresh and the
// no-script submit send the same parameters — and a one-shot response
// holds no connection that could go stale.
func fleetFrag(r *ops.Registry, w http.ResponseWriter, req *http.Request) {
	view := url.Values{}
	for _, k := range []string{"q", "sort", "kind"} {
		if v := req.URL.Query().Get(k); v != "" {
			view.Set(k, v)
		}
	}
	admin := false
	if id, ok := req.Context().Value(ctxIdentity{}).(identity); ok {
		admin = id.isAdmin
	}
	frag, err := fleetFragment(r, view, admin)
	if err != nil {
		http.Error(w, "fleet render failed", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-store")
	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")
}

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.
// fleetFragment re-renders the morphed region. It must be given
// EVERYTHING the full page gave the same template: the first repaint
// arrives immediately on connect and replaces the server-rendered
// listing, so any field the fragment omits appears on load and then
// vanishes a moment later. That is what dropped "23 of 23" and the
// New repository link, and previously the filter and sort.
//
// admin is per-connection rather than global: the fragment decides
// whether to draw an admin-only control, so it has to be the identity
// of the reader this stream belongs to.
func fleetFragment(r *ops.Registry, view url.Values, admin bool) (string, error) {
	out, err := invoke(context.Background(), r, "repo.list", view)
	if err != nil {
		return "", err
	}
	var fl struct {
		Repos  []repoInfo `json:"repos"`
		Total  int        `json:"total"`
		Served int        `json:"served"`
	}
	remarshal(out, &fl)
	var buf bytes.Buffer
	buf.WriteString(`<div id="fleet">`)
	if err := fleetPage.ExecuteTemplate(&buf, "fleet-body", pageData{Fleet: fl.Repos,
		FleetTotal: fl.Total, Served: fl.Served, IsAdmin: admin,
		Query: view.Get("q"), Sort: view.Get("sort"), Kind: view.Get("kind")}); 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)
	// A card scraper fetches the image from another machine, so og:image
	// cannot be the site-relative path the favicon links use.
	data.Origin, data.Path = origin(req), req.URL.Path
	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)
		// The repobar's active tab already names these pages; an h1
		// repeating the word underneath it is chrome saying the same
		// thing twice. The h1 goes sr-only (HideH1's mechanism), so the
		// page keeps its accessible name. Detail pages — a ticket's
		// title, a wiki page's name — keep their visible h1: those say
		// something the tab does not.
		if !data.HideH1 {
			switch data.Title {
			case map[string]string{
				"tree": "files", "timeline": "timeline", "ticket": "tickets",
				"wiki": "wiki", "forum": "forum", "technote": "technotes",
				"ci": "ci", "chat": "chat",
			}[data.Section]:
				data.HideH1 = data.Title != ""
			}
		}
		// 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)
		}
		if info, err := invoke(req.Context(), r, "repo.get", url.Values{"repo": {data.Repo}}); err == nil {
			remarshal(info, &data.Posture)
		}
	}
	// 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):
		data := pageData{Title: "not found", MissPath: req.URL.Path}
		// Keep the repo chrome when the repository itself is real — the
		// existence check is one op, paid only on the error path. When
		// the repo is what is missing (or private: indistinguishable by
		// design), the page falls back to the fleet-level frame.
		if repo := req.PathValue("repo"); repo != "" {
			if _, cerr := invoke(req.Context(), r, "repo.counts",
				url.Values{"repo": {repo}}); cerr == nil {
				data.Repo = repo
			}
		}
		renderStatus(r, w, req, notFoundPage, data, http.StatusNotFound)
	case errors.Is(err, ops.ErrInvalid):
		renderStatus(r, w, req, errorPage, pageData{Title: "bad request"}, http.StatusBadRequest)
	case errors.Is(err, ops.ErrForbidden):
		// The message is the content here: it says where the write
		// belongs, which is the one thing the reader needs next.
		renderStatus(r, w, req, forbiddenPage, pageData{Title: "not accepted here", Error: err.Error()}, http.StatusForbidden)
	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)
		errlog.Default.Report("web", 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 {
	return origin(req) + "/r/" + repo
}

// origin is the scheme://host a reader reached us by. Everything that
// must be absolute — a clone address, an og:image a scraper fetches from
// another machine — is built from this, because a hardcoded base would
// be wrong for every deployment but one.
func origin(req *http.Request) string {
	scheme := "http"
	if req.TLS != nil {
		scheme = "https"
	}
	if p := req.Header.Get("X-Forwarded-Proto"); p != "" {
		scheme = p
	}
	return scheme + "://" + req.Host
}

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