kepr.js

39.4 KB · raw

1// Kepr's browser islands (§18). Every page is complete without this file; it only adds the2// ticker's pace, the drifting timeline, copy buttons, the inline panels, issue replies and3// attachments, history's scrolling, line-range highlighting, the Atlas panel, picker, filter and4// file viewer, the signed-in editor, the phone nav menu, and the profile's visibility pause.56// Kepr 2's save kinds (canvas.js), first match wins: each dot's colour and strength.7const KINDS = [8	[/\b(release|version|bump|tag)\b/i, "accent", 1],9	[/\b(fix|bug|patch|issue|crash|error|regress|revert)\b/i, "accent-dim", 0.8],10	[/\b(add|new|implement|feat|introduce|wire|support)\b/i, "accent", 1],11	[12		/\b(refactor|restructure|reorganize|extract|move|rename|split|cleanup|clean up)\b/i,13		"text",14		0.85,15	],16	[/\b(perf|optim|speed|fast|slow|cache|memory|alloc)\b/i, "accent", 0.9],17	[/\b(test|spec|coverage|assert)\b/i, "text-secondary", 0.74],18	[/\b(doc|readme|comment|example|guide)\b/i, "text-secondary", 0.7],19	[/\b(polish|style|format|lint|whitespace|typo|wording|copy)\b/i, "text-secondary", 0.66],20	[/\b(chore|deps|update|upgrade|build|ci|config)\b/i, "text-secondary", 0.62],21];22// Kepr 2's timeline (canvas.js keprThread): the saves on a strip that drifts slowly and loops.23// Hovering pauses it and shows the save, dragging scrubs, a click opens the save. Under reduced24// motion it moves only when dragged, and it draws only while on screen. The saves are fetched25// once the page has drawn; the SVG's plain dots stand in until then.26async function timeline() {27	const svg = document.querySelector("svg.timeline");28	if (!svg) return;29	const response = await fetch(svg.dataset.src);30	if (!response.ok) return;31	const repo = svg.dataset.src.split("/")[1];32	const css = getComputedStyle(document.documentElement);33	const saves = (await response.json())34		.map(({ id, t, message, meta }) => {35			const [, color, alpha] = KINDS.find(([kind]) => kind.test(message)) ?? [36				null,37				"text-secondary",38				0.8,39			];40			const fill = css.getPropertyValue(`--${color}`).trim();41			return { href: `/${repo}/saves/${id}`, id, message, meta, fill, alpha, t };42		})43		.sort((a, b) => a.t - b.t);44	if (saves.length === 0) return;45	const canvas = document.createElement("canvas");46	canvas.className = "timeline-canvas";47	canvas.setAttribute("role", "img");48	canvas.setAttribute("aria-label", svg.getAttribute("aria-label"));49	svg.replaceWith(canvas);50	const ctx = canvas.getContext("2d");51	const tip = document.createElement("div");52	tip.className = "timeline-tip";53	tip.hidden = true;54	document.body.append(tip);55	const reduce = matchMedia("(prefers-reduced-motion: reduce)");56	let [width, height, strip, radius, offset] = [0, 0, 0, 0, 0];57	// Laid out by time, a repo's bursts sat between long empty stretches the drift crawled58	// through. Each dot instead sits after the one before by the gap between them, on a log scale59	// and capped, so a busy day stays tight and a quiet month reads as a pause, not a wait.60	const spacing = (seconds) => radius + 8 + Math.min(96, 12 * Math.log2(1 + seconds / 3600));61	const resize = () => {62		width = canvas.parentElement.getBoundingClientRect().width;63		// Kepr 2's band was tall to hold two lanes; with one line, only room for the dots and label.64		height = 64;65		canvas.width = Math.round(width * devicePixelRatio);66		canvas.height = Math.round(height * devicePixelRatio);67		canvas.style.width = `${width}px`;68		canvas.style.height = `${height}px`;69		ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);70		radius = 5;71		let x = 0;72		saves.forEach((save, i) => {73			if (i > 0) x += spacing(save.t - saves[i - 1].t);74			save.x = x;75		});76		// The seam is one more capped pause, or the rest of the view when the saves are few.77		strip = Math.max(x + spacing(Number.POSITIVE_INFINITY), width);78		offset = x - width * 0.82;79	};80	// Every visible copy of each x on the looping strip.81	const onScreen = (x, margin) => {82		const shift = ((offset % strip) + strip) % strip;83		const copies = [];84		for (let sx = x - shift - strip; sx < width + margin; sx += strip) {85			if (sx > -margin) copies.push(sx);86		}87		return copies;88	};89	const hit = (event) => {90		const box = canvas.getBoundingClientRect();91		const [mx, my] = [event.clientX - box.left, event.clientY - box.top];92		if (Math.abs(my - height / 2) > radius + 7) return null;93		return (94			saves.find((save) => onScreen(save.x, 0).some((x) => Math.abs(mx - x) < radius + 7)) ?? null95		);96	};97	let [hovered, drag, dragFrom, downX, lastX, velocity, resumeAt, lastFrame] = [98		null,99		null,100		0,101		0,102		0,103		0,104		0,105		0,106	];107	const hover = (save, event) => {108		if (save !== hovered) {109			hovered = save;110			tip.hidden = save === null;111			if (save)112				tip.replaceChildren(113					line("tip-id", save.id),114					line("tip-msg", save.message),115					line("tip-meta", save.meta),116				);117		}118		if (save) {119			const left = event.clientX + 14;120			tip.style.left = `${left + 340 > innerWidth ? Math.max(8, event.clientX - 354) : left}px`;121			tip.style.top = `${event.clientY - 10}px`;122		}123		canvas.style.cursor = save ? "pointer" : drag === null ? "grab" : "grabbing";124	};125	const draw = (time) => {126		const dt = lastFrame ? Math.min((time - lastFrame) / 1000, 0.1) : 0;127		lastFrame = time;128		if (drag === null && hovered === null) {129			if (Math.abs(velocity) > 0.5) {130				offset -= velocity;131				velocity *= 0.92;132			} else if (!reduce.matches && time >= resumeAt) offset += 20 * dt;133		}134		const y = height / 2;135		ctx.clearRect(0, 0, width, height);136		ctx.lineWidth = 1;137		ctx.globalAlpha = 0.7;138		ctx.strokeStyle = css.getPropertyValue("--text-muted");139		ctx.setLineDash([4, 4]);140		ctx.beginPath();141		ctx.moveTo(0, y);142		ctx.lineTo(width, y);143		ctx.stroke();144		ctx.setLineDash([]);145		ctx.font = '10px "Martian Mono", monospace';146		ctx.textAlign = "center";147		for (const save of saves) {148			for (const x of onScreen(save.x, 10)) {149				ctx.globalAlpha = hovered === null ? save.alpha : hovered === save ? 1 : save.alpha * 0.6;150				ctx.fillStyle = save.fill;151				ctx.beginPath();152				ctx.arc(x, y, radius, 0, Math.PI * 2);153				ctx.fill();154				if (hovered === save) {155					ctx.fillStyle = css.getPropertyValue("--accent");156					ctx.fillText(save.id.slice(0, 6), x, y - radius - 6);157				}158			}159		}160		ctx.globalAlpha = 1;161		if (visible) requestAnimationFrame(draw);162		else drawing = false;163	};164	canvas.addEventListener("pointerdown", (event) => {165		[drag, dragFrom, downX, lastX, velocity] = [166			event.clientX,167			offset,168			event.clientX,169			event.clientX,170			0,171		];172		canvas.setPointerCapture(event.pointerId);173	});174	canvas.addEventListener("pointermove", (event) => {175		if (drag !== null) {176			offset = dragFrom - (event.clientX - drag);177			velocity = velocity * 0.6 + (event.clientX - lastX) * 0.4;178			lastX = event.clientX;179		}180		if (event.pointerType === "mouse") hover(hit(event), event);181	});182	const release = () => {183		drag = null;184		resumeAt = performance.now() + 900;185	};186	canvas.addEventListener("pointerup", (event) => {187		release();188		const save = Math.abs(event.clientX - downX) > 4 ? null : hit(event);189		if (save) location.href = save.href;190	});191	canvas.addEventListener("pointercancel", release);192	canvas.addEventListener("pointerleave", (event) => {193		if (drag === null) hover(null, event);194	});195	addEventListener("resize", resize);196	resize();197	// Off screen, the loop stops; it starts again, without a jump, when the strip scrolls back.198	let [visible, drawing] = [true, false];199	new IntersectionObserver(([entry]) => {200		visible = entry.isIntersecting;201		if (visible && !drawing) {202			drawing = true;203			lastFrame = 0;204			requestAnimationFrame(draw);205		}206	}).observe(canvas);207}208209function line(className, text) {210	const div = document.createElement("div");211	div.className = className;212	div.textContent = text ?? "";213	return div;214}215216function copyButtons(root) {217	for (const button of root.querySelectorAll("button[data-copy]")) {218		const label = button.textContent;219		button.hidden = false;220		button.addEventListener("click", async () => {221			await navigator.clipboard.writeText(button.dataset.copy);222			button.textContent = "copied";223			button.classList.add("copied");224			setTimeout(() => {225				button.textContent = label;226				button.classList.remove("copied");227			}, 1400);228		});229	}230}231232// A Kepr page's <main>, its links made absolute so they point where they did on their own page233// (after any redirect). An error page's <main> comes back too, so a refused form shows why; null if234// nothing came back, and the caller then goes to the page itself.235async function fetchMain(url, init) {236	try {237		const response = await fetch(url, init);238		const page = new DOMParser().parseFromString(await response.text(), "text/html");239		const main = page.querySelector("main");240		for (const link of main?.querySelectorAll("a[href]:not([href^='#'])") ?? []) {241			link.href = new URL(link.getAttribute("href"), response.url || url).href;242		}243		return main;244	} catch {245		return null;246	}247}248249// A click the browser should handle itself: a new tab or window, or a download.250function modified(event) {251	return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;252}253254// §21.9's panels omit the root heading; nested pages need their own title and way back.255// The panel's own pages (an issue, the report form) and its forms stay inside it; a link elsewhere,256// or to a file (a download), navigates. The open tab takes the panel back to its first page, and257// closes it from there.258function panels() {259	const host = document.querySelector(".repo-panel");260	if (!host) return;261	let open = null;262	let shown = "";263	const load = async (url, init) => {264		shown = url;265		const main = await fetchMain(url, init);266		if (shown !== url) return;267		if (!main) {268			location.href = url;269			return;270		}271		if (new URL(url).pathname === new URL(open.href).pathname) {272			main.querySelector(".breadcrumb")?.remove();273			main.querySelector(".file-page-title")?.remove();274		}275		host.replaceChildren(...main.childNodes);276		copyButtons(host);277		host.hidden = false;278	};279	const inside = (url) => {280		const base = new URL(open.href).pathname;281		const { pathname } = new URL(url);282		return (pathname === base || pathname.startsWith(`${base}/`)) && !/\.[^/]*$/.test(pathname);283	};284	for (const tab of document.querySelectorAll(".repo-tab[data-panel]")) {285		tab.addEventListener("click", (event) => {286			if (modified(event)) return;287			event.preventDefault();288			if (open === tab && shown !== tab.href) {289				load(tab.href);290				return;291			}292			open?.classList.remove("active");293			if (open === tab) {294				open = null;295				shown = "";296				host.hidden = true;297				return;298			}299			open = tab;300			tab.classList.add("active");301			load(tab.href);302		});303	}304	host.addEventListener("click", (event) => {305		const link = event.target.closest("a[href]");306		if (!link || !open || modified(event) || link.hasAttribute("download")) return;307		if (!inside(link.href)) return;308		event.preventDefault();309		load(link.href);310	});311	host.addEventListener("submit", (event) => {312		const form = event.target;313		if (!open || !inside(form.action)) return;314		event.preventDefault();315		const method = form.method.toUpperCase();316		const data = new FormData(form);317		if (method === "GET") load(`${form.action}?${new URLSearchParams(data)}`);318		else load(form.action, { method, body: data });319	});320}321322// Issue threads (§23): reply and quote fill the composer in place, and quote with text selected in323// a post quotes just that. With nothing selected, quote follows its link, which quotes the post's324// own Markdown. Listening on the document, before the panel's own handler, reaches threads opened325// in a repo's panel too.326function issueReplies() {327	let picked = "";328	// A click collapses the selection, so it is read as the pointer goes down.329	document.addEventListener(330		"pointerdown",331		(event) => {332			const post = event.target.closest?.(".issue-post-actions")?.closest(".issue-post");333			const selection = getSelection();334			picked = post?.contains(selection.anchorNode) ? selection.toString().trim() : "";335		},336		true,337	);338	document.addEventListener(339		"click",340		(event) => {341			const link = event.target.closest(".issue-post-actions a, .issue-composer-to .issues-cancel");342			const form = document.getElementById("reply");343			if (!link || !form || modified(event)) return;344			if (link.dataset.action === "quote" && !picked) return;345			event.preventDefault();346			event.stopPropagation();347			const post = link.closest(".issue-post");348			const to = form.querySelector(".issue-composer-to");349			form.elements.reply_to.value = post?.dataset.entry ?? "";350			to.hidden = !post?.dataset.entry;351			if (post) {352				const name = to.querySelector(".issue-composer-name");353				name.textContent = post.dataset.author;354				name.href = `#${post.id}`;355			}356			const body = form.elements.body;357			if (link.dataset.action === "quote") {358				const quote = `${picked.replace(/^/gm, "> ")}\n\n`;359				body.value = body.value.trim() ? `${body.value.trimEnd()}\n\n${quote}` : quote;360				picked = "";361			}362			body.focus();363			body.scrollIntoView({ block: "center" });364		},365		true,366	);367}368369// Pictures pasted or dropped into an issue's form join its attachments (§23), and each chosen file370// is listed under the field, a picture with its thumbnail, before anything is sent.371function issueAttachments() {372	const fileInput = (target) =>373		target.closest?.("form")?.querySelector("input[type=file][name=files]") ?? null;374	const add = (input, files) => {375		const all = new DataTransfer();376		for (const file of [...input.files, ...files]) all.items.add(file);377		input.files = all.files;378		input.dispatchEvent(new Event("change", { bubbles: true }));379	};380	document.addEventListener("paste", (event) => {381		const input = fileInput(event.target);382		const files = [...(event.clipboardData?.files ?? [])];383		if (!input || files.length === 0) return;384		event.preventDefault();385		add(input, files);386	});387	document.addEventListener("dragover", (event) => {388		if (fileInput(event.target) && event.dataTransfer?.types.includes("Files")) {389			event.preventDefault();390		}391	});392	document.addEventListener("drop", (event) => {393		const input = fileInput(event.target);394		if (!input || !event.dataTransfer?.files.length) return;395		event.preventDefault();396		add(input, event.dataTransfer.files);397	});398	document.addEventListener("change", (event) => {399		const input = event.target;400		const list = input.closest?.("form")?.querySelector(".issue-attached");401		if (input.name !== "files" || !list) return;402		for (const image of list.querySelectorAll("img")) URL.revokeObjectURL(image.src);403		list.replaceChildren(404			...[...input.files].map((file) => {405				const item = element("li", "", file.name);406				if (file.type.startsWith("image/")) {407					const image = element("img", "");408					image.src = URL.createObjectURL(file);409					image.alt = "";410					item.prepend(image);411				}412				return item;413			}),414		);415	});416}417418// History and the Atlas's save picker hold one page of saves; scrolling to the end of either419// brings in the next page's rows. The pager is for pages without JavaScript.420function saveLists() {421	for (const list of document.querySelectorAll(".save-list")) {422		const observer = new IntersectionObserver(423			async ([entry]) => {424				const older = list.querySelector(".pager-older");425				if (!entry?.isIntersecting || !older) return;426				observer.disconnect();427				const main = await fetchMain(older.href);428				const next = main?.querySelector(`.save-list[data-list="${list.dataset.list}"]`);429				if (!next) return;430				next.querySelector(".pager a:not(.pager-older)")?.remove();431				list.querySelector(".pager").replaceWith(...next.childNodes);432				watch();433			},434			{ root: list },435		);436		const watch = () => {437			const older = list.querySelector(".pager-older");438			if (older) observer.observe(older);439		};440		watch();441	}442}443444// `#L42` works through CSS :target alone; a range `#L42-L50` needs marking line by line.445function lineRanges() {446	const code = document.querySelector(".code");447	if (!code) return;448	let anchor = null;449	const mark = () => {450		for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl");451		const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);452		if (!match) return;453		const from = Number(match[1]);454		const to = Number(match[2] ?? match[1]);455		anchor = from;456		for (let n = Math.min(from, to); n <= Math.max(from, to); n++) {457			document.getElementById(`L${n}`)?.classList.add("hl");458		}459		document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" });460	};461	code.addEventListener("click", (event) => {462		const link = event.target.closest(".ln");463		if (!link || !event.shiftKey || anchor === null) return;464		event.preventDefault();465		const n = Number(link.textContent);466		history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`);467		mark();468	});469	window.addEventListener("hashchange", mark);470	mark();471}472473// The Atlas (§21.5): each row's detail panel is fetched on hover or select and kept for the page's474// life; the filter narrows by filename; which folders are open is remembered per repo in this browser.475function atlas() {476	const root = document.querySelector(".atlas");477	const filter = document.querySelector(".atlas-filter");478	if (!root || !filter) return;479	const panel = root.querySelector(".atlas-panel");480	const folders = [...root.querySelectorAll(".atlas-folder")];481	const files = [...root.querySelectorAll(".atlas-file")];482	// Its height is the closed tree's, fixed before any folder opens; opening one scrolls within.483	root.style.height = `${root.offsetHeight}px`;484	root.style.overflowY = "hidden";485	const key = `kepr:atlas:${root.dataset.repo}`;486	const opened = new Set(remembered(key));487	// `?open=<folder>` from a file page's breadcrumb unfolds that folder, without remembering it.488	const unfold = new URLSearchParams(location.search).get("open");489	const onPath = (path) => unfold !== null && `${unfold}/`.startsWith(`${path}/`);490	for (const folder of folders) {491		folder.open = opened.has(folder.dataset.path) || onPath(folder.dataset.path);492		folder.addEventListener("toggle", () => {493			if (filter.value.trim()) return;494			if (folder.open) opened.add(folder.dataset.path);495			else opened.delete(folder.dataset.path);496			remember(key, [...opened]);497		});498	}499	const panels = new Map();500	const panelFor = (path) => {501		if (!panels.has(path)) {502			const query = new URLSearchParams({ path });503			if (root.dataset.at) query.set("at", root.dataset.at);504			const text = fetch(`/${root.dataset.repo}/atlas/panel?${query}`)505				.then((response) => (response.ok ? response.text() : Promise.reject()))506				.catch(() => {507					panels.delete(path);508					return "";509				});510			panels.set(path, text);511		}512		return panels.get(path).then((text) => {513			const template = document.createElement("template");514			template.innerHTML = text;515			return template.content;516		});517	};518	let selected = null;519	const show = async (row) => {520		selected?.classList.remove("selected");521		selected = row;522		row.classList.add("selected");523		const content = await panelFor(row.dataset.path ?? `${row.parentElement.dataset.path}/`);524		if (selected === row) panel.replaceChildren(content);525	};526	for (const row of root.querySelectorAll(".atlas-row")) {527		row.addEventListener("pointerenter", (event) => {528			if (event.pointerType === "mouse") show(row);529		});530		row.addEventListener("focus", () => show(row));531	}532	// A map link in a panel jumps to its row, opening the folders above it.533	const jump = (path) => {534		const row = path.endsWith("/")535			? folders.find((folder) => `${folder.dataset.path}/` === path)?.firstElementChild536			: files.find((file) => file.dataset.path === path);537		if (!row) return false;538		for (const folder of folders) folder.open ||= path.startsWith(`${folder.dataset.path}/`);539		show(row);540		row.scrollIntoView({ block: "nearest" });541		return true;542	};543	panel.addEventListener("click", (event) => {544		const link = event.target.closest("a.atlas-jump");545		if (link && !modified(event) && jump(link.dataset.path)) event.preventDefault();546	});547	const viewer = fileViewer(root, jump, panelFor);548	for (const file of files) {549		file.addEventListener("click", (event) => {550			if (modified(event)) return;551			event.preventDefault();552			viewer(file);553		});554	}555	const target = folders.find((folder) => folder.dataset.path === unfold)?.firstElementChild;556	if (target) {557		show(target);558		target.scrollIntoView({ block: "center" });559	}560	filter.addEventListener("input", () => {561		const query = filter.value.trim().toLowerCase();562		for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query);563		for (const folder of folders) {564			folder.hidden = query !== "" && !folder.querySelector(".atlas-file:not([hidden])");565			folder.open = query !== "" || opened.has(folder.dataset.path);566		}567	});568	// One button opens every folder, or, when they all are, closes them all.569	const fold = document.querySelector(".atlas-fold-btn");570	const label = () => {571		fold.textContent = folders.every((folder) => folder.open) ? "collapse all" : "expand all";572	};573	fold.addEventListener("click", () => {574		const open = !folders.every((folder) => folder.open);575		for (const folder of folders) folder.open = open;576		label();577	});578	for (const folder of folders) folder.addEventListener("toggle", label);579	label();580	fold.hidden = folders.length === 0;581	// The filter waits behind a search button, and folds back into it when left empty.582	const search = document.querySelector(".atlas-search-btn");583	search.addEventListener("click", () => {584		search.hidden = true;585		filter.hidden = false;586		filter.focus();587	});588	filter.addEventListener("blur", () => {589		if (filter.value.trim() !== "") return;590		filter.hidden = true;591		search.hidden = false;592	});593	search.hidden = false;594	panel.hidden = false;595}596597// Kepr 2's Atlas picker: its saves arrive when it first opens, filtered as you type; a click598// outside or Escape closes it.599function atlasPicker() {600	const picker = document.querySelector(".atlas-picker");601	if (!picker) return;602	const filter = picker.querySelector(".atlas-picker-filter");603	const list = picker.querySelector(".atlas-picker-list");604	let rows = [];605	const narrow = () => {606		const query = filter.value.trim().toLowerCase();607		for (const row of rows) {608			row.hidden = query !== "" && !row.textContent.toLowerCase().includes(query);609		}610	};611	filter.addEventListener("input", narrow);612	let loading = null;613	picker.addEventListener("toggle", () => {614		if (!picker.open) return;615		filter.focus();616		if (loading) return;617		loading = fetch(list.dataset.src)618			.then((response) => (response.ok ? response.text() : Promise.reject()))619			.then((text) => {620				const template = document.createElement("template");621				template.innerHTML = text;622				list.replaceChildren(template.content);623				rows = [...list.children];624				narrow();625			})626			.catch(() => {627				loading = null;628			});629	});630	document.addEventListener("click", (event) => {631		if (!picker.contains(event.target)) picker.open = false;632	});633	picker.addEventListener("keydown", (event) => {634		if (event.key === "Escape") picker.open = false;635	});636	filter.hidden = false;637}638639// Files open in a viewer over the page, as in Kepr 2's Atlas: the file page's content with the640// row's detail panel beside it, and controls for text size, wrapping, raw, download and the641// neighbouring files. Escape closes it; the arrow keys step through the files.642function fileViewer(root, jump, panelFor) {643	let [row, size, wrap] = [null, 12, false];644	const overlay = element("div", "fv-overlay");645	overlay.tabIndex = -1;646	overlay.setAttribute("role", "dialog");647	overlay.setAttribute("aria-modal", "true");648	const close = () => {649		overlay.remove();650		row?.focus();651		row = null;652	};653	const step = (by) => {654		const rows = [...root.querySelectorAll(".atlas-file")].filter((file) => !file.hidden);655		const next = rows[rows.indexOf(row) + by];656		if (next) open(next);657	};658	const restyle = () => {659		overlay.style.setProperty("--fv-size", `${size}px`);660		overlay.classList.toggle("fv-wrap", wrap);661	};662	const open = async (file, url = file.href) => {663		row = file;664		const rows = [...root.querySelectorAll(".atlas-file")].filter((each) => !each.hidden);665		const index = rows.indexOf(file);666		const path = file.dataset.path;667		const name = path.split("/").pop();668		const raw = new URL(file.href);669		raw.pathname = raw.pathname.replace(/^(\/[^/]+)\/files\//, "$1/raw/");670		const button = (text, action, className = "fv-ctrl-btn") => {671			const b = element("button", className, text);672			b.type = "button";673			b.addEventListener("click", action);674			return b;675		};676		const link = (text, download) => {677			const a = element("a", "fv-ctrl-btn", text);678			a.href = raw.href;679			if (download) a.download = name;680			return a;681		};682		const source = element("div", "fv-source", "loading…");683		const panel = element("aside", "fv-detail");684		panelFor(path).then((content) => {685			if (row === file) panel.replaceChildren(content);686		});687		const header = element("div", "fv-header");688		const title = element("div", "fv-title");689		title.append(element("span", "fv-name", name), element("span", "fv-path mono", path));690		const controls = element("div", "fv-controls mono");691		controls.append(692			button("A−", () => {693				size = Math.max(9, size - 1);694				restyle();695			}),696			button("A+", () => {697				size = Math.min(20, size + 1);698				restyle();699			}),700			button("⇌ wrap", (event) => {701				wrap = !wrap;702				event.currentTarget.classList.toggle("active", wrap);703				restyle();704			}),705			link("raw", false),706			link("download", true),707		);708		controls.children[2].classList.toggle("active", wrap);709		const nav = element("div", "fv-nav mono");710		const back = button("←", () => step(-1), "fv-ctrl-btn");711		const forward = button("→", () => step(1), "fv-ctrl-btn");712		back.disabled = index <= 0;713		forward.disabled = index >= rows.length - 1;714		nav.append(back, element("span", "fv-pos", `${index + 1}/${rows.length}`), forward);715		header.append(button("×", close, "fv-close"), title, controls, nav);716		const body = element("div", "fv-body");717		body.append(source, panel);718		overlay.replaceChildren(header, body);719		restyle();720		if (!overlay.isConnected) document.body.append(overlay);721		overlay.focus();722		const main = await fetchMain(url);723		if (row !== file) return;724		if (!main) {725			location.href = url;726			return;727		}728		for (const heading of main.querySelectorAll(".breadcrumb, .file-page-title")) heading.remove();729		main.querySelector(".file-meta:not(:last-child)")?.remove();730		source.replaceChildren(...main.childNodes);731	};732	overlay.addEventListener("keydown", (event) => {733		if (event.key === "Escape") close();734		else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {735			event.preventDefault();736			step(event.key === "ArrowLeft" ? -1 : 1);737		}738	});739	// A map link beside the code opens that file here; a folder closes the viewer on its row.740	overlay.addEventListener("click", (event) => {741		const link = event.target.closest(".fv-detail a.atlas-jump");742		if (!link || modified(event)) return;743		const path = link.dataset.path;744		const file = [...root.querySelectorAll(".atlas-file")].find((f) => f.dataset.path === path);745		event.preventDefault();746		if (file) open(file);747		else {748			close();749			jump(path);750		}751	});752	// The Markdown view's rendered/source toggle points at the file page: switch in place.753	overlay.addEventListener("click", (event) => {754		const link = event.target.closest(".fv-source a[href]");755		if (!link || !row || modified(event)) return;756		if (new URL(link.href).pathname !== new URL(row.href).pathname) return;757		event.preventDefault();758		open(row, link.href);759	});760	return open;761}762763// On a phone the nav links fold behind a menu button (2026-10-01). The button only exists with764// JavaScript, so without it the links stay in the bar; the CSS hides them only on `.nav--menu`.765function navMenu() {766	const nav = document.querySelector("body > nav");767	const links = nav?.querySelector(".nav-links");768	if (!links) return;769	links.id = "nav-links";770	const button = element("button", "nav-toggle");771	button.type = "button";772	button.setAttribute("aria-label", "menu");773	button.setAttribute("aria-controls", "nav-links");774	button.setAttribute("aria-expanded", "false");775	button.append(element("span", ""), element("span", ""), element("span", ""));776	const open = () => nav.classList.contains("nav--open");777	const set = (opened) => {778		nav.classList.toggle("nav--open", opened);779		button.setAttribute("aria-expanded", String(opened));780	};781	button.addEventListener("click", () => set(!open()));782	document.addEventListener("click", (event) => {783		if (open() && !nav.contains(event.target)) set(false);784	});785	document.addEventListener("keydown", (event) => {786		if (event.key !== "Escape" || !open()) return;787		set(false);788		button.focus();789	});790	nav.classList.add("nav--menu");791	// Before the links, so tabbing from the open button reaches them next.792	links.before(button);793}794function element(tag, className, text) {795	const node = document.createElement(tag);796	node.className = className;797	if (text !== undefined) node.textContent = text;798	return node;799}800801// Signed in, a repo page's description and details are edited in place: Markdown with a toolbar802// and a preview as the page will show it, pictures and videos picked, dropped or pasted in, and803// the one under the caret sized and placed from a row of controls. The page's HTML is the same804// for everyone; only the cookie signing in sets beside the session brings the controls.805async function repoEditor() {806	const head = document.querySelector(".repo-head");807	if (!head || !/(^|;\s*)kepr_editor=[0-9a-f]{64}/.test(document.cookie)) return;808	const base = `/admin/repos/${location.pathname.split("/")[1]}`;809	const response = await fetch(`${base}/text`).catch(() => null);810	if (!response?.ok) return;811	const texts = await response.json();812	const details = document.querySelector(".repo-details");813	const places = [814		["description", head.querySelector(".repo-desc"), head.querySelector(".repo-desc")],815		["details", details, details.querySelector(".repo-details-body")],816	];817	for (const [field, section, body] of places) {818		section.hidden = false;819		if (field === "description") {820			section.tabIndex = 0;821			section.setAttribute("role", "button");822			section.setAttribute("aria-label", "edit repository description");823			section.classList.add("repo-desc-editable");824			if (!section.textContent.trim()) section.textContent = "add a description";825			const activate = (event) => {826				if (event.target.closest(".editor") || body.querySelector(".editor")) return;827				event.preventDefault();828				editText(base, texts.form, field, body, texts[field]);829			};830			section.addEventListener("click", activate);831			section.addEventListener("keydown", (event) => {832				if (event.target === section && (event.key === "Enter" || event.key === " "))833					activate(event);834			});835			continue;836		}837		const button = element("button", "edit-btn mono", "edit");838		button.type = "button";839		(section.querySelector(".repo-details-head") ?? section).append(button);840		button.addEventListener("click", (event) => {841			event.preventDefault();842			if (!body.querySelector(".editor")) editText(base, texts.form, field, body, texts[field]);843		});844	}845}846847const MEDIA_MARKDOWN = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g;848const MEDIA_TYPES = "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/webm";849850function editText(base, form, field, body, text) {851	const kept = [...body.childNodes];852	if (field === "description") {853		body.removeAttribute("role");854		body.removeAttribute("tabindex");855		body.removeAttribute("aria-label");856	}857	const input = element("textarea", "editor-input mono");858	input.value = text;859	input.rows = field === "details" ? 16 : 3;860	const preview = element("div", `editor-preview${field === "details" ? " markdown" : ""}`);861	const media = element("div", "editor-media mono");862	const status = element("span", "editor-status");863	const post = (path, fields) => {864		const data = new FormData();865		data.append("form", form);866		for (const [name, value] of Object.entries(fields)) data.append(name, value);867		return fetch(`${base}/${path}`, { method: "POST", body: data });868	};869	let timer = null;870	const changed = () => {871		inspect();872		clearTimeout(timer);873		timer = setTimeout(async () => {874			const answer = await post("preview", { field, value: input.value }).catch(() => null);875			if (!answer?.ok) return;876			// Kepr's own Markdown rendering, raw HTML off: the same markup the page will carry.877			preview.innerHTML = input.value.trim() ? await answer.text() : "";878			preview.dataset.empty = input.value.trim() ? "" : "the README's words show here";879		}, 250);880	};881	const wrap = (before, after = before, fallback = "text") => {882		const [from, to] = [input.selectionStart, input.selectionEnd];883		const chosen = input.value.slice(from, to) || fallback;884		input.setRangeText(before + chosen + after, from, to);885		input.setSelectionRange(from + before.length, from + before.length + chosen.length);886		input.focus();887		changed();888	};889	const prefix = (mark) => {890		const start = input.value.lastIndexOf("\n", input.selectionStart - 1) + 1;891		input.setRangeText(mark, start, start, "end");892		input.focus();893		changed();894	};895	const upload = async (file) => {896		status.textContent = `uploading ${file.name}…`;897		const answer = await post("media", { file }).catch(() => null);898		if (!answer?.ok) {899			status.textContent = answer ? await answer.text() : "upload failed";900			return;901		}902		const { url } = await answer.json();903		const alt = file.name.replace(/\.[^.]+$/, "").replace(/[[\]]/g, "");904		input.setRangeText(905			`\n![${alt}](${url} "width=100 center")\n`,906			input.selectionStart,907			input.selectionEnd,908			"end",909		);910		status.textContent = "";911		input.focus();912		changed();913	};914	const picker = element("input", "");915	picker.type = "file";916	picker.accept = MEDIA_TYPES;917	picker.hidden = true;918	picker.addEventListener("change", () => {919		for (const file of picker.files) upload(file);920		picker.value = "";921	});922	// The picture or video under the caret, with its options, as `![alt](url "options")`.923	const underCaret = () =>924		[...input.value.matchAll(MEDIA_MARKDOWN)].find(925			(m) => input.selectionStart >= m.index && input.selectionStart <= m.index + m[0].length,926		);927	function inspect() {928		const found = field === "details" ? underCaret() : undefined;929		media.hidden = !found;930		if (!found) return;931		const words = (found[3] ?? "").split(/\s+/).filter(Boolean);932		const width = words.find((word) => word.startsWith("width="))?.slice(6) ?? "100";933		const align = words.find((word) => /^(left|center|right)$/.test(word)) ?? "center";934		const loop = words.includes("loop");935		const video = /\.(mp4|webm)$/i.test(found[2]);936		const set = (next) => {937			const options = [`width=${next.width}`, next.align, next.loop && "loop"].filter(Boolean);938			const markdown = `![${found[1]}](${found[2]} "${options.join(" ")}")`;939			const caret = input.selectionStart;940			input.setRangeText(markdown, found.index, found.index + found[0].length);941			input.setSelectionRange(caret, caret);942			changed();943		};944		const choice = (label, active, action) => {945			const b = element("button", `fv-ctrl-btn${active ? " active" : ""}`, label);946			b.type = "button";947			b.addEventListener("click", action);948			return b;949		};950		media.replaceChildren(951			element("span", "editor-label", video ? "video" : "picture"),952			...["25", "33", "50", "66", "75", "100"].map((w) =>953				choice(`${w}%`, w === width, () => set({ width: w, align, loop })),954			),955			...["left", "center", "right"].map((a) =>956				choice(a, a === align, () => set({ width, align: a, loop })),957			),958			...(video ? [choice("loop like a GIF", loop, () => set({ width, align, loop: !loop }))] : []),959		);960	}961	const save = async (value) => {962		status.textContent = "saving…";963		const answer = await post("text", { field, value }).catch(() => null);964		if (answer?.ok) location.reload();965		else status.textContent = answer ? await answer.text() : "not saved: offline?";966	};967	const cancel = () => {968		body.replaceChildren(...kept);969		if (field === "description") {970			body.tabIndex = 0;971			body.setAttribute("role", "button");972			body.setAttribute("aria-label", "edit repository description");973			body.focus();974		}975	};976	const tool = (label, title, action) => {977		const b = element("button", "fv-ctrl-btn", label);978		b.type = "button";979		b.title = title;980		b.addEventListener("click", action);981		return b;982	};983	const toolbar = element("div", "editor-toolbar mono");984	toolbar.append(985		tool("B", "bold (⌘B)", () => wrap("**")),986		tool("I", "italic (⌘I)", () => wrap("_")),987		tool("link", "link (⌘K)", () => wrap("[", "](https://)")),988		tool("code", "code", () => wrap("`")),989	);990	if (field === "details") {991		toolbar.append(992			tool("heading", "heading", () => prefix("## ")),993			tool("list", "list", () => prefix("- ")),994			tool("quote", "quote", () => prefix("> ")),995			tool("picture or video", "add a picture, GIF or video (or drop or paste one)", () =>996				picker.click(),997			),998			picker,999		);1000		input.addEventListener("dragover", (event) => event.preventDefault());1001		input.addEventListener("drop", (event) => {1002			if (event.dataTransfer.files.length === 0) return;1003			event.preventDefault();1004			for (const file of event.dataTransfer.files) upload(file);1005		});1006		input.addEventListener("paste", (event) => {1007			const files = [...event.clipboardData.files];1008			if (files.length === 0) return;1009			event.preventDefault();1010			for (const file of files) upload(file);1011		});1012	}1013	input.addEventListener("input", changed);1014	for (const name of ["click", "keyup"]) input.addEventListener(name, inspect);1015	input.addEventListener("keydown", (event) => {1016		const command = event.metaKey || event.ctrlKey;1017		if (event.key === "Escape") cancel();1018		else if (command && event.key === "Enter") save(input.value);1019		else if (command && event.key === "b") wrap("**");1020		else if (command && event.key === "i") wrap("_");1021		else if (command && event.key === "k") wrap("[", "](https://)");1022		else return;1023		event.preventDefault();1024	});1025	const actions = element("div", "editor-actions mono");1026	actions.append(1027		tool("save", "save (⌘↵)", () => save(input.value)),1028		tool("cancel", "cancel (esc)", cancel),1029		tool("use the README", "clear your words and show the README's again", () => save("")),1030		status,1031	);1032	const panes = element("div", "editor-panes");1033	panes.append(input, preview);1034	const editor = element("div", `editor editor-${field}`);1035	editor.append(toolbar, panes, media, actions);1036	body.replaceChildren(editor);1037	input.focus();1038	changed();1039}10401041// Storage can be missing, refused (private windows, blocked windows) or hold anything; only a1042// list of folder paths is trusted, and otherwise the tree starts closed.1043function remembered(key) {1044	try {1045		const value = JSON.parse(localStorage.getItem(key) ?? "[]");1046		return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : [];1047	} catch {1048		return [];1049	}1050}10511052function remember(key, value) {1053	try {1054		localStorage.setItem(key, JSON.stringify(value));1055	} catch {}1056}10571058function hexStream() {1059	const backdrop = document.querySelector(".hex-backdrop");1060	if (!backdrop) return;1061	let visible = true;1062	const update = () => backdrop.classList.toggle("paused", !visible || document.hidden);1063	new IntersectionObserver(([entry]) => {1064		visible = entry.isIntersecting;1065		update();1066	}).observe(backdrop);1067	document.addEventListener("visibilitychange", update);1068	update();1069}10701071// A CSS duration cannot follow a track's width, so the pace would depend on the messages; set it1072// here so each row slides at its own --speed in px/s, as Kepr 2's canvas did.1073function tickerPace() {1074	for (const track of document.querySelectorAll(".ticker-track")) {1075		const speed = Number(getComputedStyle(track).getPropertyValue("--speed"));1076		if (speed > 0) track.style.animationDuration = `${track.scrollWidth / 2 / speed}s`;1077	}1078}10791080// Widths are only final once Martian Mono has loaded.1081document.fonts.ready.then(tickerPace);1082timeline()1083	.catch((error) => console.error("kepr: the timeline could not load", error))1084	.finally(() => document.documentElement.classList.remove("timeline-pending"));1085copyButtons(document);1086panels();1087issueReplies();1088issueAttachments();1089saveLists();1090atlasPicker();1091repoEditor();1092navMenu();1093lineRanges();1094atlas();1095hexStream();