@ 261101.7
current
(() => {
	let url = document.currentScript.dataset.live;
	let es = null;
	// A page outlives its server. A stream lost for three seconds puts a
	// line over the page that says the server is gone and since when, and
	// the stream tries again each second. A server that answers again
	// sends reload when it is a new one, eg after make run or --restart,
	// and the page takes the new version; a line then says a new server
	// answered and fades. A restart's new server answers within a second
	// or two, before the gone line shows.
	let lost = null;
	const found = () => {
		clearTimeout(lost);
		lost = null;
	};
	const sync = () => {
		if (document.hidden) {
			if (es) es.close();
			es = null;
			found();
		} else if (!es && url) {
			es = new EventSource(url);
			es.onmessage = () => (searching() ? (pending = true) : update());
			es.onopen = () => {
				found();
				const gone = document.querySelector("p.conn:not(.fade)");
				if (gone) gone.remove();
			};
			es.onerror = () => {
				if (!lost) lost = setTimeout(() => say("server gone since " + new Date().toTimeString().slice(0, 8) + ", waiting for it", true), 3000);
				// The browser gives up on a stream that met an error page,
				// so a new one takes its place.
				if (es && es.readyState === EventSource.CLOSED) {
					es = null;
					setTimeout(sync, 1000);
				}
			};
		}
	};
	// say shows text in the line over the page, kept until a server
	// answers or fading.
	const say = (text, keep) => {
		const old = document.querySelector("p.conn");
		if (old) old.remove();
		const p = document.createElement("p");
		p.className = "own conn" + (keep ? "" : " fade");
		p.textContent = text;
		if (!keep) p.addEventListener("animationend", () => p.remove());
		document.body.append(p);
	};
	// Each server names itself in the stream's URL. A tab that last saw
	// another says so, whether a reload or a click brought it here.
	const serverKey = "server";
	const seeServer = () => {
		if (!url) return;
		const id = (new URL(url, location.href).searchParams.get("v") || "").split(".")[0];
		let last = null;
		try {
			last = sessionStorage.getItem(serverKey);
			sessionStorage.setItem(serverKey, id);
		} catch {}
		if (last && id && last !== id) say("new server", false);
	};

	// A highlight, not focus, marks the entry the keys act on, since the
	// search box keeps focus. A reload keeps the highlighted entry, and the
	// page Escape goes up to highlights the page it came from, so Enter
	// goes back. Entries match by path and query, without ?q=.
	const selKey = "sel";
	let sel = null;
	let pinnedFrom = null; // the current file's path, while a range pins the address
	const plain = (href) => {
		const u = new URL(href, location.href);
		const rest = u.search.slice(1).split("&").filter((s) => s && s !== "q" && !s.startsWith("q="));
		return u.pathname + (rest.length ? "?" + rest.join("&") : "");
	};
	const select = (el) => {
		if (sel) sel.classList.remove("sel");
		sel = el;
		if (!el) return;
		el.classList.add("sel");
		el.scrollIntoView({ block: "nearest" });
	};
	// hold gives the box the keyboard, except on a touch screen, where
	// focus would raise the on-screen keyboard.
	const hold = () => {
		const b = box();
		if (b && matchMedia("(pointer: fine)").matches) b.focus({ preventScroll: true });
	};
	const reload = () => {
		try {
			if (sel && sel.matches("ul.listing a")) sessionStorage.setItem(selKey, plain(sel.href));
		} catch {}
		// A box's range may have pinned the address to a version; the
		// change is in the current file, so the reload goes there.
		if (pinnedFrom) location.replace(pinnedFrom + location.search + location.hash);
		else location.reload();
	};
	document.addEventListener("DOMContentLoaded", () => {
		let key = null;
		try {
			key = sessionStorage.getItem(selKey);
			sessionStorage.removeItem(selKey);
		} catch {}
		const a = key && [...document.querySelectorAll("main ul.listing a")].find((a) => plain(a.href) === key);
		if (a) select(a);
		hold();
	});

	// A doc takes its new version in place, and each block that differs
	// from the one shown before fills with the target colour and fades.
	// Any other page, a page from a new stylesheet, or a doc that is gone
	// reloads whole.
	const blockKey = (el) => {
		// The top line counts by what it says, not whether its menu
		// was opened.
		if (el.matches("details.latest")) return el.querySelector("summary").outerHTML;
		if (!el.classList.contains("changed")) return el.outerHTML;
		const c = el.cloneNode(true);
		c.classList.remove("changed");
		if (!c.classList.length) c.removeAttribute("class");
		return c.outerHTML;
	};
	const styleOf = (doc) => {
		const l = doc.querySelector('link[rel="stylesheet"]');
		return l && l.getAttribute("href");
	};
	const update = () => {
		if (document.body.className !== "doc") return reload();
		fetch(location.href, { cache: "no-store" })
			.then((r) => (r.ok ? r.text() : Promise.reject()))
			.then((html) => {
				const next = new DOMParser().parseFromString(html, "text/html");
				const live = next.querySelector("script[data-live]");
				const main = next.querySelector("main");
				if (!live || !main || next.body.className !== "doc" || styleOf(next) !== styleOf(document)) return reload();
				const shown = new Map();
				for (const el of document.querySelectorAll("main > *")) {
					const k = blockKey(el);
					shown.set(k, (shown.get(k) || 0) + 1);
				}
				for (const el of main.children) {
					const k = blockKey(el);
					if (shown.get(k)) shown.set(k, shown.get(k) - 1);
					else el.classList.add("changed");
				}
				const x = scrollX, y = scrollY;
				const held = document.activeElement === box();
				document.title = next.title;
				document.body.replaceWith(document.adoptNode(next.body));
				scrollTo(x, y);
				sel = null;
				if (held) hold();
				backlinks();
				measureSearch();
				url = live.dataset.live;
				seeServer();
				if (es) es.close();
				es = null;
				sync();
			})
			.catch(reload);
	};
	document.addEventListener("visibilitychange", sync);
	sync();

	// The box under the header searches what is below it. A listing
	// searches its directory, names first, then lines, and the results
	// stand in for the page: its own entries first, then what lies deeper,
	// dimmed. Any other page filters itself in place: lines
	// of code, blocks of a doc, commits of a history, rows of a syntax
	// tree, each match marked.
	// The directory's results follow, dimmed, without this file. Escape or
	// an empty box brings the page back, with any update that arrived
	// meanwhile. The query rides in the URL as ?q=, so Back from a result
	// and a reload show the results again.
	let pending = false;
	let timer = null;
	let ctl = null;
	let hidden = [];
	let opened = [];
	let matches = [];
	const box = () => document.querySelector("input.search");
	const searching = () => {
		const b = box();
		return !!b && b.value.trim() !== "";
	};
	const filters = () => ["code", "doc", "log", "commit", "tree"].includes(document.body.className);
	const unfilter = () => {
		for (const el of hidden) el.hidden = false;
		hidden = [];
		for (const d of opened) d.open = false;
		opened = [];
		matches = [];
		select(null);
		for (const el of document.querySelectorAll(".filtering, p.unfound")) {
			if (el.matches("p.unfound")) el.remove();
			else el.classList.remove("filtering");
		}
		if (window.CSS && CSS.highlights) CSS.highlights.delete("search");
	};
	const showPage = () => {
		const results = document.querySelector("div.found");
		if (results) results.remove();
		unfilter();
		const main = document.querySelector("main");
		if (main) main.hidden = false;
		if (pending) {
			pending = false;
			update();
		}
	};
	// mark adds a range for each match of q in el's text, across the
	// spans that colour it. Text inside an element that matches skip
	// counts for nothing.
	const mark = (el, q, ranges, skip) => {
		const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
			acceptNode: (n) => (skip && n.parentElement.closest(skip) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT),
		});
		const nodes = [];
		let text = "";
		for (let n; (n = walk.nextNode()); ) {
			nodes.push([n, text.length]);
			text += n.data;
		}
		const low = text.toLowerCase();
		if (low.length !== text.length) return;
		const at = (i) => {
			let k = nodes.length - 1;
			while (nodes[k][1] > i) k--;
			return [nodes[k][0], i - nodes[k][1]];
		};
		for (let i = low.indexOf(q); i >= 0; i = low.indexOf(q, i + q.length)) {
			const r = new Range();
			r.setStart(...at(i));
			r.setEnd(...at(i + q.length));
			ranges.push(r);
		}
	};
	// keep reports whether el holds q, hiding it if not, and marks it if so.
	const keep = (el, q, ranges) => {
		if (!el.textContent.toLowerCase().includes(q)) {
			el.hidden = true;
			hidden.push(el);
			return false;
		}
		matches.push(el);
		mark(el, q, ranges);
		return true;
	};
	// rowText is a syntax tree row's field, kind and text, without the
	// link to its lines, whose numbers would match a query of digits. It
	// is kept per row, since a large tree has tens of thousands.
	const rowTexts = new WeakMap();
	const rowText = (row) => {
		let t = rowTexts.get(row);
		if (t === undefined) {
			t = "";
			for (const n of row.childNodes) if (n.nodeName !== "A") t += n.textContent;
			rowTexts.set(row, (t = t.toLowerCase()));
		}
		return t;
	};
	// filterTree keeps each row of a syntax tree that holds q and the folds
	// above it, opened so the row shows; unfilter closes them again. A kept
	// fold with no match below it keeps all its rows, so opening it shows
	// its subtree whole. What goes is collected in hide, only the outermost
	// of it, so a query that drops most of a large tree hides a few folds.
	// It reports whether anything in el is kept.
	const filterTree = (el, q, ranges, hide) => {
		let any = false;
		for (const c of el.children) {
			if (c.matches("summary")) continue;
			const fold = c.matches("details");
			const row = fold ? c.firstElementChild : c;
			const own = rowText(row).includes(q);
			if (own) {
				matches.push(row);
				mark(row, q, ranges, "a");
			}
			const from = hide.length;
			const below = fold && filterTree(c, q, ranges, hide);
			if (!below) hide.length = from;
			if (below && !c.open) {
				c.open = true;
				opened.push(c);
			}
			if (own || below) any = true;
			else hide.push(c);
		}
		return any;
	};
	// filter hides what in el does not hold q and reports whether anything
	// is left. Lines of code and of diffs, list items, table rows and a
	// container's children are weighed one by one; any other block stays
	// or goes whole. A file's name in a diff stays while its lines do.
	const filter = (el, q, ranges) => {
		let parts = null;
		if (el.matches("div.syntax")) {
			const hide = [];
			const any = filterTree(el, q, ranges, hide);
			for (const h of any ? hide : [el]) {
				h.hidden = true;
				hidden.push(h);
			}
			return any;
		}
		if (el.matches("div.sourceCode")) {
			const pre = el.querySelector("pre");
			pre.classList.add("filtering");
			parts = [...pre.querySelectorAll("code > span")];
			for (const a of pre.querySelectorAll("code > span[id] > a:first-child")) a.dataset.n = a.parentNode.id.slice(1);
		} else if (el.matches("pre.diff, ul, ol")) parts = [...el.children];
		else if (el.matches("table")) parts = [...el.querySelectorAll(":scope > tbody > tr")];
		else if (el.matches("main, div")) {
			let any = false;
			for (const c of el.children) if (filter(c, q, ranges)) any = true;
			for (const p of el.querySelectorAll(":scope > p.file")) {
				const next = p.nextElementSibling;
				if (p.hidden && next && next.matches("pre.diff") && !next.hidden) p.hidden = false;
			}
			if (!any) {
				el.hidden = true;
				hidden.push(el);
			}
			return any;
		} else return keep(el, q, ranges);
		let any = false;
		for (const p of parts) if (keep(p, q, ranges)) any = true;
		if (!any) {
			el.hidden = true;
			hidden.push(el);
		}
		return any;
	};
	const filterPage = (q) => {
		unfilter();
		const main = document.querySelector("main");
		const ranges = [];
		if (!filter(main, q.toLowerCase(), ranges)) {
			main.hidden = false;
			const p = document.createElement("p");
			p.className = "unfound";
			p.textContent = "nothing on this page";
			main.after(p);
		}
		if (window.Highlight && CSS.highlights) CSS.highlights.set("search", new Highlight(...ranges));
	};
	// withQuery is this page's URL holding q, or none; other parameters
	// keep their spelling, so ?log stays ?log.
	const withQuery = (q) => {
		const rest = location.search.slice(1).split("&").filter((s) => s && s !== "q" && !s.startsWith("q="));
		if (q) rest.push("q=" + encodeURIComponent(q));
		return location.pathname + (rest.length ? "?" + rest.join("&") : "") + location.hash;
	};
	const runSearch = () => {
		const b = box();
		const q = b.value.trim();
		const u = withQuery(q);
		if (u !== location.pathname + location.search + location.hash) history.replaceState(history.state, "", u);
		if (ctl) ctl.abort();
		if (!q) return showPage();
		const inPlace = filters();
		if (inPlace) filterPage(q);
		ctl = new AbortController();
		fetch(b.dataset.search + "?search=" + encodeURIComponent(q), { signal: ctl.signal })
			.then((r) => r.json())
			.then((res) => {
				if (!box() || box().value.trim() !== q) return;
				const own = (h) => inPlace && new URL(h.href, location.href).pathname === location.pathname;
				const list = (wider) => {
					const ul = document.createElement("ul");
					ul.className = "listing" + (wider ? " wider" : "");
					return ul;
				};
				// A listing shows its own entries at full strength and what
				// lies deeper dimmed below; a filtered page dims all of it.
				const near = inPlace ? null : list(false);
				const far = list(true);
				const recents = recentMatching(q);
				const shown = new Set(recents);
				const add = (h, label, text) => {
					if (own(h) || (!h.line && shown.has(new URL(h.href, location.href).pathname))) return;
					const li = document.createElement("li");
					const a = document.createElement("a");
					a.href = h.href;
					a.textContent = label;
					li.append(a);
					if (text) {
						const span = document.createElement("span");
						span.className = "line";
						span.textContent = text;
						li.append(" ", span);
					}
					(near && h.here ? near : far).append(li);
				};
				for (const h of res.names) add(h, h.path);
				for (const h of res.lines) add(h, h.path + ":" + h.line, h.text);
				const results = document.createElement("div");
				results.className = "found";
				const line = (cls, text) => {
					const p = document.createElement("p");
					p.className = cls;
					p.textContent = text;
					results.append(p);
				};
				// Pages the reader opened that match lead, from any tree.
				const recent = list(false);
				recent.classList.add("recent");
				for (const p of recents) {
					const li = document.createElement("li");
					const a = document.createElement("a");
					a.href = p;
					a.textContent = recentLabel(p);
					li.append(a);
					recent.append(li);
				}
				if (recent.children.length) {
					const p = document.createElement("p");
					p.className = "note";
					p.textContent = "recent";
					results.prepend(p, recent);
				}
				// A forge's URL leads, read on the web address; Enter opens it.
				if (res.web) {
					const web = list(false);
					web.classList.add("web");
					const li = document.createElement("li");
					const a = document.createElement("a");
					a.href = res.web.href;
					a.textContent = res.web.path;
					li.append(a);
					web.append(li);
					const p = document.createElement("p");
					p.className = "note";
					p.textContent = "online";
					results.prepend(p, web);
				}
				const any = (res.web ? 1 : 0) + recent.children.length + (near ? near.children.length : 0) + far.children.length;
				if (near && near.children.length) results.append(near);
				else if (near && any) line("unfound", "nothing in this directory itself");
				if (far.children.length) results.append(far);
				line("note", (any ? "" : "nothing found, ") + "searched " + res.files + " files" + (res.cut ? ", stopped early" : ""));
				const main = document.querySelector("main");
				const old = document.querySelector("div.found");
				if (old) old.replaceWith(results);
				else if (inPlace) (document.querySelector("p.unfound") || main).after(results);
				else main.before(results);
				if (!inPlace) main.hidden = true;
				// Typed further down a listing, the results begin above the
				// window; they come into view under the row.
				if (!inPlace && results.getBoundingClientRect().top < 0) results.scrollIntoView({ block: "start" });
			})
			.catch(() => {});
	};
	const clearSearch = () => {
		box().value = "";
		runSearch();
		settle();
	};
	// jump leaves the search for a match in the whole page: a line of code
	// by its anchor, anything else scrolled to the middle.
	// jump shows el in the full page; a match in a syntax tree, or in a
	// doc's details, has its folds opened, since clearing closes them.
	const jump = (el) => {
		clearSearch();
		for (let d = el.parentElement; d; d = d.parentElement) if (d.matches("details") && d.firstElementChild !== el) d.open = true;
		if (el.id) location.hash = el.id;
		else el.scrollIntoView({ block: "center" });
		hold();
	};
	document.addEventListener("DOMContentLoaded", () => {
		const q = new URLSearchParams(location.search).get("q");
		if (!q || !box()) return;
		box().value = q;
		runSearch();
	});
	document.addEventListener("input", (e) => {
		if (!e.target.matches("input.search")) return;
		clearTimeout(timer);
		timer = setTimeout(runSearch, 120);
		settle();
	});

	// The controls fade while none is needed: the box, copy and share, raw,
	// the stop button and a code page's views. They show while the page's
	// first line sits below the search row, since faded controls there
	// leave gaps in the header. They show while the query has a character,
	// while the mouse moves inside the column, while keyboard focus is on
	// one of them or a crumb, and after the page scrolls up, by wheel,
	// finger or key. Shown, they fade only 2 s after the last of these
	// ends, never at once. Focus in the empty box does not count, since the
	// box holds focus on every page.
	const chrome = "header, div.search, div.view";
	// While calmTimer runs, the controls stay shown.
	let calmTimer = null;
	const atTop = () => {
		const m = document.querySelector("main");
		const row = document.querySelector("div.search");
		return !m || m.getBoundingClientRect().top >= (row ? row.getBoundingClientRect().bottom : 0);
	};
	const needed = () => {
		const f = document.activeElement;
		return atTop() || searching() || (!!f && !!f.closest && !!f.closest(chrome) && !f.matches("input.search"));
	};
	const calm = () => {
		calmTimer = null;
		if (!needed()) document.documentElement.classList.add("calm");
	};
	const wake = () => {
		clearTimeout(calmTimer);
		document.documentElement.classList.remove("calm");
		calmTimer = setTimeout(calm, 2000);
	};
	// settle runs when the need may have changed: the scroll, the query, or
	// focus. A need that ends starts the 2 s unless they already run.
	const settle = () => {
		if (needed()) document.documentElement.classList.remove("calm");
		else if (!calmTimer && !document.documentElement.classList.contains("calm")) calmTimer = setTimeout(calm, 2000);
	};
	// After a scroll the browser sends a move to the mouse that rests, as
	// the page moved under it. Only a move on the screen counts, or a mouse
	// resting in the column would show the controls on every scroll.
	let mx = null;
	let my = null;
	document.addEventListener("pointermove", (e) => {
		if (e.pointerType === "touch" || (e.screenX === mx && e.screenY === my)) return;
		mx = e.screenX;
		my = e.screenY;
		const r = document.body.getBoundingClientRect();
		if (e.clientX >= r.left && e.clientX <= r.right) wake();
	});
	let lastY = scrollY;
	addEventListener("scroll", () => {
		if (scrollY < lastY) wake();
		else settle();
		lastY = scrollY;
	}, { passive: true });
	document.addEventListener("focusin", settle);
	document.addEventListener("focusout", () => setTimeout(settle));
	document.addEventListener("DOMContentLoaded", settle);

	// Below a doc, the docs that link to it. The server walks the tree for
	// them, which can take longer than the page itself, so they arrive
	// after it, and again after each update, which replaces the page.
	const backlinks = () => {
		if (document.body.className !== "doc") return;
		fetch(location.pathname + "?backlinks", { cache: "no-store" })
			.then((r) => (r.ok ? r.json() : Promise.reject()))
			.then((res) => {
				const main = document.querySelector("main");
				if (!main || document.body.className !== "doc") return;
				const old = main.querySelector(":scope > div.backlinks");
				if (old) old.remove();
				if (!res.links.length) return;
				const div = document.createElement("div");
				div.className = "backlinks own";
				const h = document.createElement("h2");
				h.className = "own";
				h.textContent = "linked from";
				const ul = document.createElement("ul");
				for (const l of res.links) {
					const li = document.createElement("li");
					const a = document.createElement("a");
					a.href = l.href;
					a.textContent = l.path;
					li.append(a);
					ul.append(li);
				}
				div.append(h, ul);
				main.append(div);
				if (searching() && filters()) filterPage(box().value.trim());
			})
			.catch(() => {});
	};

	// The pages a reader opened live in the browser's storage for this
	// origin, never on the server, so each viewer of a share sees only
	// what that viewer opened. Each entry counts its visits and keeps the
	// last; the score weighs both, as zoxide does. A reload is no visit.
	// Each entry names its tree, and a search lists only the page's own
	// tree, so one repo's reading never shows in another's. An entry
	// without a tree predates that and is dropped.
	const recentKey = "recent";
	const maxRecent = 300;
	const recentTree = () => {
		const b = box();
		return b ? b.dataset.recents : undefined;
	};
	const readRecent = () => {
		try {
			const list = JSON.parse(localStorage.getItem(recentKey));
			return Array.isArray(list) ? list.filter((e) => typeof e.r === "string") : [];
		} catch {
			return [];
		}
	};
	const score = (e, now) => {
		const age = now - e.t;
		return e.n * (age < 3600e3 ? 4 : age < 86400e3 ? 2 : age < 7 * 86400e3 ? 0.5 : 0.25);
	};
	const recentPaths = () => {
		const now = Date.now();
		const tree = recentTree();
		return readRecent()
			.filter((e) => e.r === tree)
			.sort((a, b) => score(b, now) - score(a, now))
			.map((e) => e.p);
	};
	const recentLabel = (p) => {
		try {
			return decodeURIComponent(p).replace(/^\//, "");
		} catch {
			return p;
		}
	};
	// recentMatching lists the five best pages whose path holds every word
	// of q, without this page.
	const recentMatching = (q) => {
		const words = q.toLowerCase().split(/\s+/).filter((w) => w);
		return recentPaths()
			.filter((p) => p !== location.pathname && words.every((w) => recentLabel(p).toLowerCase().includes(w)))
			.slice(0, 5);
	};
	const visit = () => {
		if (!["doc", "code", "dir"].includes(document.body.className)) return;
		const nav = performance.getEntriesByType("navigation")[0];
		if (nav && nav.type === "reload") return;
		const tree = recentTree();
		if (tree === undefined) return;
		const now = Date.now();
		const list = readRecent();
		const e = list.find((e) => e.p === location.pathname);
		if (e) {
			e.n++;
			e.t = now;
			e.r = tree;
		} else list.push({ p: location.pathname, r: tree, n: 1, t: now });
		list.sort((a, b) => score(b, now) - score(a, now));
		try {
			localStorage.setItem(recentKey, JSON.stringify(list.slice(0, maxRecent)));
		} catch {}
	};
	document.addEventListener("DOMContentLoaded", () => {
		seeServer();
		visit();
		backlinks();
	});

	// The bug button, at each page's foot, files a numbered bug in the
	// program itself, not in the tree it shows. It sends the page's URL, the
	// selected text as the note, the window's size and scroll, the browser,
	// and a snapshot of the page as it stands, scripts taken out, so the
	// snapshot shows what the reader saw without running again; the server
	// adds its own state. The button then says the number, and a field
	// beside it takes a comment: Enter adds it, Escape or leaving the field
	// drops it.
	const bugSaid = (b, word) => {
		b.dataset.label ??= b.textContent;
		b.textContent = word;
		setTimeout(() => (b.textContent = b.dataset.label), 3000);
	};
	const bugComment = (b, n) => {
		const f = document.createElement("input");
		f.placeholder = "comment on bug " + n;
		f.setAttribute("aria-label", "comment on bug " + n);
		b.before(f);
		f.focus();
		let done = false;
		const end = () => {
			if (done) return;
			done = true;
			f.remove();
		};
		f.addEventListener("keydown", (e) => {
			if (e.key === "Escape") return end();
			if (e.key !== "Enter" || !f.value.trim()) return;
			const body = new URLSearchParams({ bug: n, comment: f.value.trim() });
			end();
			fetch(b.dataset.post, { method: "POST", body }).then(
				(r) => bugSaid(b, r.ok ? "comment on bug " + n + " added" : "comment failed"),
				() => bugSaid(b, "comment failed"),
			);
		});
		f.addEventListener("blur", end);
	};
	document.addEventListener("click", (e) => {
		const b = e.target.closest("button.bug");
		if (!b) return;
		const page = document.documentElement.cloneNode(true);
		for (const s of page.querySelectorAll("script")) s.remove();
		const inputs = page.querySelectorAll("input");
		document.querySelectorAll("input").forEach((el, i) => inputs[i] && inputs[i].setAttribute("value", el.value));
		const base = document.createElement("base");
		base.href = location.href;
		page.querySelector("head").prepend(base);
		const body = new URLSearchParams({
			url: location.href,
			note: String(getSelection()).trim(),
			view: innerWidth + "x" + innerHeight + "+" + Math.round(scrollY),
			ua: navigator.userAgent,
			page: "<!doctype html>\n" + page.outerHTML,
		});
		fetch(b.dataset.post, { method: "POST", body })
			.then((r) => (r.ok ? r.text() : Promise.reject()))
			.then(
				(n) => {
					bugSaid(b, "bug " + n + " filed");
					bugComment(b, n);
				},
				() => bugSaid(b, "failed"),
			);
	});

	// The build stamp counts the seconds since the binary was built. It is
	// looked up on each tick, since a doc's update replaces it.
	setInterval(() => {
		const ago = document.querySelector("p.built[data-built] > span");
		if (ago) ago.textContent = Math.max(0, Math.floor((Date.now() - Number(ago.parentNode.dataset.built)) / 1000));
	}, 1000);

	// The box holds the keyboard, so typing searches from anywhere: a key
	// that types, or Backspace during a search, goes to the box wherever
	// focus is. Up and Down move the highlight through what can be opened,
	// a listing's entries or a search's matches and results, and Enter
	// opens it, or the first one during a search. Left and Right stay with
	// the box's caret. Escape clears the query, and with none goes up to
	// the last linked crumb. With nothing to walk, Up, Down and Space
	// scroll the page as they would outside the box; during a search Space
	// types. Shift+Tab in the box enters the header at the crumb Escape
	// goes to, so Delete lock is never one key from the box; Escape on any
	// other focused control comes back to the box.
	const openMenu = () => document.querySelector("details.latest[open]");
	const walkable = () =>
		openMenu() ? [...openMenu().querySelectorAll("ol.versions a")] : (searching() ? [...document.querySelectorAll("div.found ul.web a"), ...matches, ...document.querySelectorAll("div.found ul:not(.web) a")] : [...document.querySelectorAll("main ul.listing a")])
			.filter((el) => el.offsetParent !== null);
	const up = () => {
		const crumbs = document.querySelectorAll("header nav a");
		if (!crumbs.length) return;
		try {
			sessionStorage.setItem(selKey, plain(location.href));
		} catch {}
		location.href = crumbs[crumbs.length - 1].href;
	};
	document.addEventListener("keydown", (e) => {
		if (e.altKey || e.ctrlKey || e.metaKey || e.isComposing || e.defaultPrevented) return;
		const b = box();
		const inBox = !!b && e.target === b;
		if (!inBox && e.target.closest && e.target.closest("input, textarea, select, [contenteditable]")) return;
		if (inBox && e.key === "Tab" && e.shiftKey) {
			const crumbs = document.querySelectorAll("header nav a");
			if (!crumbs.length) return;
			e.preventDefault();
			return crumbs[crumbs.length - 1].focus();
		}
		const q = searching();
		const presses = e.target.closest && e.target.closest("button, summary");
		if (b && !inBox && ((e.key.length === 1 && (e.key !== " " || (q && !presses))) || (e.key === "Backspace" && q))) {
			b.focus({ preventScroll: true });
			b.selectionStart = b.selectionEnd = b.value.length;
			return;
		}
		const walk = walkable();
		const i = walk.indexOf(sel);
		switch (e.key) {
			case "ArrowDown":
			case "ArrowUp":
				if (walk.length) {
					if (e.key === "ArrowDown") select(walk[Math.min(i + 1, walk.length - 1)]);
					else select(walk[i < 0 ? walk.length - 1 : Math.max(i - 1, 0)]);
					if (b) b.focus({ preventScroll: true });
				} else if (inBox) scrollBy(0, e.key === "ArrowDown" ? 40 : -40);
				else return;
				break;
			case "Enter": {
				if (!inBox && e.target.closest && e.target.closest("a, button, summary")) return;
				const to = i >= 0 ? sel : q ? walk[0] : null;
				if (!to) return;
				if (to.matches("a")) location.href = to.href;
				else jump(to);
				break;
			}
			case "Escape":
				if (openMenu()) openMenu().open = false;
				else if (b && !inBox && document.activeElement !== document.body) b.focus({ preventScroll: true });
				else if (q) clearSearch();
				else up();
				break;
			case " ":
				if (!inBox || q) return;
				scrollBy(0, (e.shiftKey ? -0.85 : 0.85) * innerHeight);
				break;
			default:
				return;
		}
		e.preventDefault();
	});

	// A line's number marks the line and puts it in the address, eg #L42;
	// clicking the marked line's number again clears both. An empty
	// fragment unmarks the line, since replaceState alone leaves :target
	// as it was; the scroll position is kept, and the bare # dropped.
	document.addEventListener("click", (e) => {
		const a = e.target.closest && e.target.closest("pre.numberSource code > span > a:first-child");
		if (!a || a.getAttribute("href") !== location.hash) return;
		e.preventDefault();
		const x = scrollX, y = scrollY;
		location.hash = "";
		scrollTo(x, y);
		history.replaceState(history.state, "", location.pathname + location.search);
		hold();
	});

	// A code page's boxes are links to their lines. A click on a box,
	// where no inner box lies, marks the box and the boxes in it and puts
	// its lines in the address, eg #L482-L520; a click on the marked box
	// clears both. The box under the pointer is tinted, so it shows what a
	// click marks. Boxes take no pointer events, so the hit test is by
	// geometry, measured once against the pre, which scrolls sideways.
	// A file committed and unchanged since gets its address pinned to that
	// commit's version, eg @261024.4/main.go, so the range names these
	// lines after the file changes; the timeline marks that version with
	// data-also only when the file is clean. A range in the address marks
	// lines on any numbered page, and one box when it matches one.
	const rangeOf = (hash) => {
		const m = /^#L(\d+)-L(\d+)$/.exec(hash);
		return m && +m[1] <= +m[2] ? [+m[1], +m[2]] : null;
	};
	const cssNum = (el, v) => +el.style.getPropertyValue(v);
	let boxes = null;
	const measure = (pre) => {
		if (boxes) return boxes;
		const p = pre.getBoundingClientRect();
		boxes = [...pre.querySelectorAll("i.scope")].map((el) => {
			const r = el.getBoundingClientRect();
			const first = +el.parentElement.id.slice(1);
			const leaf = el.classList.contains("leaf");
			return {
				el, leaf, first, last: leaf ? first : first + cssNum(el, "--r") - 1,
				c: cssNum(el, "--c"), w: cssNum(el, "--w"), d: cssNum(el, "--d"),
				left: r.left - p.left, right: r.right - p.left, top: r.top - p.top, bottom: r.bottom - p.top,
			};
		});
		return boxes;
	};
	addEventListener("resize", () => (boxes = null));
	const boxAt = (pre, x, y) => {
		if (pre.classList.contains("filtering")) return (boxes = null);
		const p = pre.getBoundingClientRect();
		x -= p.left;
		y -= p.top;
		let best = null;
		for (const b of measure(pre))
			if (!b.leaf && (!best || b.d > best.d) && x >= b.left && x < b.right && y >= b.top && y < b.bottom) best = b;
		return best;
	};
	let hovered = null;
	const hover = (b) => {
		if (hovered === b) return;
		if (hovered) hovered.el.classList.remove("hover");
		hovered = b;
		if (b) b.el.classList.add("hover");
	};
	let moved = null;
	document.addEventListener("pointermove", (e) => {
		const pre = e.target.closest && e.target.closest("pre.scopes");
		if (!pre) return hover(null);
		if (moved) return (moved = e);
		moved = e;
		requestAnimationFrame(() => {
			hover(boxAt(pre, moved.clientX, moved.clientY));
			moved = null;
		});
	});
	const showRange = () => {
		for (const el of document.querySelectorAll(".marked")) el.classList.remove("marked");
		const r = rangeOf(location.hash);
		if (!r) return;
		const pre = document.querySelector("pre.scopes");
		const box = pre && measure(pre).find((b) => !b.leaf && b.first === r[0] && b.last === r[1]);
		if (box) {
			for (const b of boxes)
				if (b.first >= box.first && b.last <= box.last && b.c >= box.c && b.c + b.w <= box.c + box.w) b.el.classList.add("marked");
			return;
		}
		for (let n = r[0]; n <= r[1]; n++) {
			const line = document.getElementById("L" + n);
			if (line && line.matches("pre.numberSource code > span")) line.classList.add("marked");
		}
	};
	const pin = (hash) => {
		const d = document.querySelector("details.latest");
		if (!d || d.dataset.here || pinnedFrom) return;
		versions(d)
			.then(() => {
				const li = d.querySelector("ol.versions li[data-also]");
				if (!li || location.hash !== hash) return;
				// The pin names the lines; it does not move the reader.
				// Relative links, eg the views' ?raw, would resolve
				// against the version, so they are made absolute first.
				for (const a of document.querySelectorAll("a[href]")) {
					const h = a.getAttribute("href");
					if (!/^([a-z][a-z0-9+.-]*:|\/|#)/i.test(h)) a.setAttribute("href", a.href);
				}
				pinnedFrom = location.pathname;
				history.replaceState(history.state, "", li.dataset.also + location.search + hash);
			})
			.catch(() => {});
	};
	const markRange = (hash) => {
		const x = scrollX, y = scrollY;
		// A line marked by its number is :target, which only a new
		// fragment clears.
		if (document.querySelector(":target")) location.hash = "";
		let path = location.pathname;
		if (!hash && pinnedFrom) {
			path = pinnedFrom;
			pinnedFrom = null;
		}
		history.replaceState(history.state, "", path + location.search + hash);
		scrollTo(x, y);
		showRange();
		if (hash) pin(hash);
	};
	document.addEventListener("click", (e) => {
		const pre = e.target.closest && e.target.closest("pre.scopes");
		if (!pre || e.target.closest("a") || !getSelection().isCollapsed) return;
		const b = boxAt(pre, e.clientX, e.clientY);
		if (!b) return;
		const hash = "#L" + b.first + "-L" + b.last;
		markRange(location.hash === hash ? "" : hash);
		hold();
	});
	addEventListener("hashchange", showRange);
	document.addEventListener("DOMContentLoaded", () => {
		const r = rangeOf(location.hash);
		if (!r) return;
		showRange();
		const line = document.getElementById("L" + r[0]);
		if (line) line.scrollIntoView({ block: "start" });
	});

	// The copy button copies the page's text: what lies under the search
	// box as the browser lays it out, without the program's own lines, the
	// view links, line numbers or what a search hides. Lines marked by a
	// range in the address copy alone, and a doc copies its Markdown, from
	// ?raw. It copies through the clipboard API where the origin is
	// secure, else through a selection, which works on any origin. Its
	// glyph says how it went: a tick or a cross, then the copy glyph again.
	const pageText = () => {
		const main = document.querySelector("main");
		if (!main) return "";
		document.body.classList.add("copying");
		const text = main.innerText;
		document.body.classList.remove("copying");
		return text.replace(/\s+$/, "") + "\n";
	};
	const rangeText = () => {
		const r = rangeOf(location.hash);
		if (!r) return null;
		const lines = [];
		for (let n = r[0]; n <= r[1]; n++) {
			const line = document.getElementById("L" + n);
			if (!line || !line.matches("pre.numberSource code > span")) return null;
			lines.push(line.textContent);
		}
		return lines.join("\n") + "\n";
	};
	// A doc's Markdown is fetched when the pointer or focus reaches the
	// button, so the click writes at once: a browser lets a click write
	// the clipboard only for a moment after it. A doc that changes in
	// place is fetched again on the next approach.
	let source = null;
	const docSource = () => {
		if (!source)
			source = fetch((pinnedFrom || location.pathname) + "?raw", { cache: "no-store" })
				.then((r) => (r.ok ? r.text() : Promise.reject()))
				.catch(() => {
					source = null;
					return Promise.reject();
				});
		return source;
	};
	const approach = (e) => {
		if (document.body.className === "doc" && e.target.closest && e.target.closest("button.copy")) {
			source = null;
			docSource().catch(() => {});
		}
	};
	document.addEventListener("pointerover", approach);
	document.addEventListener("focusin", approach);
	// clip writes text to the clipboard for the button btn, whose glyph
	// then says how it went and comes back after a moment.
	const clip = (btn, text) => {
		const glyph = btn.textContent, title = btn.title;
		const done = (ok) => {
			btn.textContent = ok ? "✓" : "✗";
			btn.title = ok ? "copied" : "copy failed";
			setTimeout(() => {
				btn.textContent = glyph;
				btn.title = title;
			}, 1500);
			hold();
		};
		const bySelection = (text) => {
			const t = document.createElement("textarea");
			t.value = text;
			t.style.position = "fixed";
			t.style.opacity = "0";
			document.body.append(t);
			t.select();
			let ok = false;
			try {
				ok = document.execCommand("copy");
			} catch {}
			t.remove();
			done(ok);
		};
		const write = (text) => {
			if (navigator.clipboard && window.isSecureContext)
				navigator.clipboard.writeText(text).then(() => done(true), () => bySelection(text));
			else bySelection(text);
		};
		if (typeof text === "string") write(text);
		else text.then(write, () => done(false));
	};
	document.addEventListener("click", (e) => {
		const btn = e.target.closest && e.target.closest("button.copy");
		if (!btn) return;
		const lines = rangeText();
		if (lines !== null) return clip(btn, lines);
		if (document.body.className === "doc") return clip(btn, docSource());
		clip(btn, pageText());
	});

	// The share button copies a link to what is selected: lines selected
	// or marked on a numbered page, eg #L3-L26, pinned to the commit's
	// version when the file is committed and unchanged since, as a box's
	// link is; other selected text as a text fragment, #:~:text=, which
	// browsers scroll to and highlight; else the page itself, which on a
	// commit page may be a declaration's page. The link has the address
	// the page came from, so on a share it reaches other machines and on
	// loopback only this one. The version menu that pinning reads loads as
	// the pointer reaches the button, since a click may write the
	// clipboard only at once.
	const numbered = (node) => {
		const el = node && (node.nodeType === 1 ? node : node.parentElement);
		const line = el && el.closest("pre.numberSource code > span");
		return line && /^L\d+$/.test(line.id) ? +line.id.slice(1) : null;
	};
	const selectedLines = () => {
		const sel = getSelection();
		if (!sel || sel.isCollapsed) return null;
		const a = numbered(sel.anchorNode), b = numbered(sel.focusNode);
		if (a === null || b === null) return null;
		return a === b ? "#L" + a : "#L" + Math.min(a, b) + "-L" + Math.max(a, b);
	};
	const fragmentText = () => {
		const sel = getSelection();
		const main = document.querySelector("main");
		if (!sel || sel.isCollapsed || !main || !main.contains(sel.anchorNode)) return null;
		const words = sel.toString().trim().split(/\s+/).filter(Boolean);
		if (!words.length) return null;
		// Commas and dashes are the syntax's own, so they are escaped too.
		const enc = (ws) => encodeURIComponent(ws.join(" ")).replace(/-/g, "%2D");
		if (words.length <= 8) return "#:~:text=" + enc(words);
		return "#:~:text=" + enc(words.slice(0, 4)) + "," + enc(words.slice(-4));
	};
	const pinnedPath = () => {
		if (pinnedFrom) return location.pathname;
		const d = document.querySelector("details.latest");
		const li = d && !d.dataset.here && d.querySelector("ol.versions li[data-also]");
		return li ? li.dataset.also : location.pathname;
	};
	const withoutQuery = () => {
		const rest = location.search.slice(1).split("&").filter((s) => s && s !== "q" && !s.startsWith("q="));
		return rest.length ? "?" + rest.join("&") : "";
	};
	// On the web address the link is the forge's own, at the commit the
	// page shows, which opens in any browser; lines take the forge's form.
	const forgeLines = (hash, style) => {
		const [, a, b] = /^#L(\d+)(?:-L(\d+))?$/.exec(hash);
		if (style === "gitlab") return "#L" + a + (b ? "-" + b : "");
		if (style === "bitbucket") return "#lines-" + a + (b ? ":" + b : "");
		return hash;
	};
	const shareURL = (btn) => {
		const lines = selectedLines() || (/^#L\d+(-L\d+)?$/.test(location.hash) ? location.hash : null);
		const forge = btn.dataset.forge;
		if (forge) {
			if (lines) return forge + forgeLines(lines, btn.dataset.lines);
			return forge + (fragmentText() || "");
		}
		if (lines) return location.origin + pinnedPath() + withoutQuery() + lines;
		const text = fragmentText();
		if (text) return location.origin + location.pathname + withoutQuery() + text;
		return location.href;
	};
	const nearShare = (e) => {
		const d = document.querySelector("details.latest");
		if (d && e.target.closest && e.target.closest("button.share")) versions(d).catch(() => {});
	};
	document.addEventListener("pointerover", nearShare);
	document.addEventListener("focusin", nearShare);
	// A click on the button would collapse the selection before it is
	// read, so the button keeps the pointer from taking focus.
	document.addEventListener("mousedown", (e) => {
		if (e.target.closest && e.target.closest("button.share")) e.preventDefault();
	});
	document.addEventListener("click", (e) => {
		const btn = e.target.closest && e.target.closest("button.share");
		if (btn) clip(btn, shareURL(btn));
	});

	// The top line opens the timeline, the menu of the page's versions.
	// It loads from ?timeline when the pointer first comes over the line,
	// so it is there when it opens, or on opening. The version shown is marked and
	// highlighted, so Up and Down walk from it and Enter opens one; Escape
	// or a click outside closes it.
	// versions loads the menu once and returns the load, which a pin
	// waits for; a failed load is tried again next time.
	const loads = new WeakMap();
	const versions = (d) => {
		if (!loads.has(d))
			loads.set(
				d,
				fetch(d.dataset.timeline)
					.then((r) => (r.ok ? r.text() : Promise.reject()))
					.then((html) => {
						const got = new DOMParser().parseFromString(html, "text/html").querySelector("ol.versions");
						const slot = d.querySelector("div.versions");
						if (!got || !slot) return Promise.reject();
						slot.replaceWith(document.adoptNode(got));
						markHere(d);
					})
					.catch(() => {
						loads.delete(d);
						return Promise.reject();
					}),
			);
		return loads.get(d);
	};
	const markHere = (d) => {
		const at = d.dataset.here || location.pathname;
		const here = [...d.querySelectorAll("ol.versions a")].find(
			(a) => (a.pathname === at && !a.search) || a.parentElement.dataset.also === at,
		);
		if (!here) return;
		here.parentElement.classList.add("here");
		if (d.open) {
			select(here);
			here.parentElement.scrollIntoView({ block: "nearest" });
		}
	};
	document.addEventListener("pointerover", (e) => {
		const d = e.target.closest && e.target.closest("details.latest");
		if (d) versions(d).catch(() => {});
	});
	document.addEventListener("click", (e) => {
		const d = openMenu();
		if (d && !d.contains(e.target)) d.open = false;
	});

	// A history's fold loads its commit's diff from the commit page the
	// first time it opens. toggle does not bubble, so it is caught on the
	// way down.
	document.addEventListener("toggle", (e) => {
		const d = e.target;
		if (d.matches && d.matches("details.latest")) {
			if (!d.open) return select(null);
			versions(d).catch(() => {});
			return markHere(d);
		}
		if (!d.open || !d.dataset || !d.dataset.diff || d.dataset.loaded) return;
		d.dataset.loaded = "1";
		fetch(d.dataset.diff)
			.then((r) => r.text())
			.then((html) => {
				const got = new DOMParser().parseFromString(html, "text/html").querySelector("div.diffs");
				const slot = d.querySelector("div.diffs");
				if (got && slot) slot.replaceWith(document.adoptNode(got));
			})
			.catch(() => delete d.dataset.loaded);
	}, true);

	// The search row sticks to the window's top. Its height, in
	// --search-h, keeps a code page's view links below it and stops a
	// jump to a line or heading below it rather than under it. A doc's
	// live update brings a new body, so this runs again after it.
	const measureSearch = () => {
		const row = document.querySelector("div.search");
		document.documentElement.style.setProperty("--search-h", (row ? row.offsetHeight : 0) + "px");
	};
	addEventListener("resize", measureSearch);
	document.addEventListener("DOMContentLoaded", measureSearch);

	// A link another site sent to a repo not yet read lands on a page
	// that asks first; its read link keeps the link's fragment, eg the
	// lines it marks.
	document.addEventListener("DOMContentLoaded", () => {
		if (document.body.className !== "confirm" || !location.hash) return;
		const a = document.querySelector("main a");
		if (a) a.href = a.href.split("#")[0] + location.hash;
	});

	// A commit page shows its plain diff at once and asks for the semantic
	// one after: the declarations the commit changed above the diff, and
	// the changed tokens marked within it. A search on the page runs again
	// over the new diff.
	document.addEventListener("DOMContentLoaded", () => {
		const unread = document.querySelector("div.semantic[data-src]");
		if (!unread) return;
		fetch(unread.dataset.src)
			.then((r) => r.text())
			.then((html) => {
				const got = new DOMParser().parseFromString(html, "text/html");
				const sem = got.querySelector("div.semantic");
				const diffs = got.querySelector("div.diffs");
				const old = document.querySelector("main div.diffs");
				if (!sem || !diffs || !old) return;
				unread.replaceWith(document.adoptNode(sem));
				old.replaceWith(document.adoptNode(diffs));
				if (searching() && filters()) filterPage(box().value.trim());
			})
			.catch(() => unread.remove());
	});
})();