kepr.js

36.9 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, history's scrolling,3// line-range highlighting, the Atlas panel, picker, filter and file viewer, the signed-in4// editor, the phone nav menu, and the profile's glow.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.25function timeline() {26	const svg = document.querySelector("svg.timeline");27	if (!svg) return;28	const css = getComputedStyle(document.documentElement);29	const saves = [...svg.querySelectorAll(".dot")]30		.map((dot) => {31			const [id, message, meta] = (dot.querySelector("title")?.textContent ?? "").split("\n");32			const [, color, alpha] = KINDS.find(([kind]) => kind.test(message)) ?? [33				null,34				"text-secondary",35				0.8,36			];37			const fill = css.getPropertyValue(`--${color}`).trim();38			return { href: dot.getAttribute("href"), id, message, meta, fill, alpha, t: +dot.dataset.t };39		})40		.sort((a, b) => a.t - b.t);41	if (saves.length === 0) return;42	const canvas = document.createElement("canvas");43	canvas.className = "timeline-canvas";44	canvas.setAttribute("role", "img");45	canvas.setAttribute("aria-label", svg.getAttribute("aria-label"));46	svg.replaceWith(canvas);47	const ctx = canvas.getContext("2d");48	const tip = document.createElement("div");49	tip.className = "timeline-tip";50	tip.hidden = true;51	document.body.append(tip);52	const reduce = matchMedia("(prefers-reduced-motion: reduce)");53	let [width, height, strip, radius, offset] = [0, 0, 0, 0, 0];54	// Laid out by time, a repo's bursts sat between long empty stretches the drift crawled55	// through. Each dot instead sits after the one before by the gap between them, on a log scale56	// and capped, so a busy day stays tight and a quiet month reads as a pause, not a wait.57	const spacing = (seconds) => radius + 8 + Math.min(96, 12 * Math.log2(1 + seconds / 3600));58	const resize = () => {59		width = canvas.parentElement.getBoundingClientRect().width;60		// Kepr 2's band was tall to hold two lanes; with one line, only room for the dots and label.61		height = 64;62		canvas.width = Math.round(width * devicePixelRatio);63		canvas.height = Math.round(height * devicePixelRatio);64		canvas.style.width = `${width}px`;65		canvas.style.height = `${height}px`;66		ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);67		radius = 5;68		let x = 0;69		saves.forEach((save, i) => {70			if (i > 0) x += spacing(save.t - saves[i - 1].t);71			save.x = x;72		});73		// The seam is one more capped pause, or the rest of the view when the saves are few.74		strip = Math.max(x + spacing(Number.POSITIVE_INFINITY), width);75		offset = x - width * 0.82;76	};77	// Every visible copy of each x on the looping strip.78	const onScreen = (x, margin) => {79		const shift = ((offset % strip) + strip) % strip;80		const copies = [];81		for (let sx = x - shift - strip; sx < width + margin; sx += strip) {82			if (sx > -margin) copies.push(sx);83		}84		return copies;85	};86	const hit = (event) => {87		const box = canvas.getBoundingClientRect();88		const [mx, my] = [event.clientX - box.left, event.clientY - box.top];89		if (Math.abs(my - height / 2) > radius + 7) return null;90		return (91			saves.find((save) => onScreen(save.x, 0).some((x) => Math.abs(mx - x) < radius + 7)) ?? null92		);93	};94	let [hovered, drag, dragFrom, downX, lastX, velocity, resumeAt, lastFrame] = [95		null,96		null,97		0,98		0,99		0,100		0,101		0,102		0,103	];104	const hover = (save, event) => {105		if (save !== hovered) {106			hovered = save;107			tip.hidden = save === null;108			if (save)109				tip.replaceChildren(110					line("tip-id", save.id),111					line("tip-msg", save.message),112					line("tip-meta", save.meta),113				);114		}115		if (save) {116			const left = event.clientX + 14;117			tip.style.left = `${left + 340 > innerWidth ? Math.max(8, event.clientX - 354) : left}px`;118			tip.style.top = `${event.clientY - 10}px`;119		}120		canvas.style.cursor = save ? "pointer" : drag === null ? "grab" : "grabbing";121	};122	const draw = (time) => {123		const dt = lastFrame ? Math.min((time - lastFrame) / 1000, 0.1) : 0;124		lastFrame = time;125		if (drag === null && hovered === null) {126			if (Math.abs(velocity) > 0.5) {127				offset -= velocity;128				velocity *= 0.92;129			} else if (!reduce.matches && time >= resumeAt) offset += 20 * dt;130		}131		const y = height / 2;132		ctx.clearRect(0, 0, width, height);133		ctx.lineWidth = 1;134		ctx.globalAlpha = 0.7;135		ctx.strokeStyle = css.getPropertyValue("--text-muted");136		ctx.setLineDash([4, 4]);137		ctx.beginPath();138		ctx.moveTo(0, y);139		ctx.lineTo(width, y);140		ctx.stroke();141		ctx.setLineDash([]);142		ctx.font = '10px "Martian Mono", monospace';143		ctx.textAlign = "center";144		for (const save of saves) {145			for (const x of onScreen(save.x, 10)) {146				ctx.globalAlpha = hovered === null ? save.alpha : hovered === save ? 1 : save.alpha * 0.6;147				ctx.fillStyle = save.fill;148				ctx.beginPath();149				ctx.arc(x, y, radius, 0, Math.PI * 2);150				ctx.fill();151				if (hovered === save) {152					ctx.fillStyle = css.getPropertyValue("--accent");153					ctx.fillText(save.id.slice(0, 6), x, y - radius - 6);154				}155			}156		}157		ctx.globalAlpha = 1;158		requestAnimationFrame(draw);159	};160	canvas.addEventListener("pointerdown", (event) => {161		[drag, dragFrom, downX, lastX, velocity] = [162			event.clientX,163			offset,164			event.clientX,165			event.clientX,166			0,167		];168		canvas.setPointerCapture(event.pointerId);169	});170	canvas.addEventListener("pointermove", (event) => {171		if (drag !== null) {172			offset = dragFrom - (event.clientX - drag);173			velocity = velocity * 0.6 + (event.clientX - lastX) * 0.4;174			lastX = event.clientX;175		}176		if (event.pointerType === "mouse") hover(hit(event), event);177	});178	const release = () => {179		drag = null;180		resumeAt = performance.now() + 900;181	};182	canvas.addEventListener("pointerup", (event) => {183		release();184		const save = Math.abs(event.clientX - downX) > 4 ? null : hit(event);185		if (save) location.href = save.href;186	});187	canvas.addEventListener("pointercancel", release);188	canvas.addEventListener("pointerleave", (event) => {189		if (drag === null) hover(null, event);190	});191	addEventListener("resize", resize);192	resize();193	requestAnimationFrame(draw);194}195196function line(className, text) {197	const div = document.createElement("div");198	div.className = className;199	div.textContent = text ?? "";200	return div;201}202203function copyButtons(root) {204	for (const button of root.querySelectorAll("button[data-copy]")) {205		const label = button.textContent;206		button.hidden = false;207		button.addEventListener("click", async () => {208			await navigator.clipboard.writeText(button.dataset.copy);209			button.textContent = "copied";210			button.classList.add("copied");211			setTimeout(() => {212				button.textContent = label;213				button.classList.remove("copied");214			}, 1400);215		});216	}217}218219// A Kepr page's <main>, its links made absolute so they point where they did on their own page220// (after any redirect). An error page's <main> comes back too, so a refused form shows why; null if221// nothing came back, and the caller then goes to the page itself.222async function fetchMain(url, init) {223	try {224		const response = await fetch(url, init);225		const page = new DOMParser().parseFromString(await response.text(), "text/html");226		const main = page.querySelector("main");227		for (const link of main?.querySelectorAll("a[href]:not([href^='#'])") ?? []) {228			link.href = new URL(link.getAttribute("href"), response.url || url).href;229		}230		return main;231	} catch {232		return null;233	}234}235236// A click the browser should handle itself: a new tab or window, or a download.237function modified(event) {238	return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey;239}240241// §21.9's panels open inline under the tabs, as Kepr 2's did: the panel page without its heading.242// The panel's own pages (an issue, the report form) and its forms stay inside it; a link elsewhere,243// or to a file (a download), navigates. The open tab takes the panel back to its first page, and244// closes it from there.245function panels() {246	const host = document.querySelector(".repo-panel");247	if (!host) return;248	let open = null;249	let shown = "";250	const load = async (url, init) => {251		shown = url;252		const main = await fetchMain(url, init);253		if (shown !== url) return;254		if (!main) {255			location.href = url;256			return;257		}258		main.querySelector(".breadcrumb")?.remove();259		main.querySelector(".file-page-title")?.remove();260		host.replaceChildren(...main.childNodes);261		copyButtons(host);262		host.hidden = false;263	};264	const inside = (url) => {265		const base = new URL(open.href).pathname;266		const { pathname } = new URL(url);267		return (pathname === base || pathname.startsWith(`${base}/`)) && !/\.[^/]*$/.test(pathname);268	};269	for (const tab of document.querySelectorAll(".repo-tab[data-panel]")) {270		tab.addEventListener("click", (event) => {271			if (modified(event)) return;272			event.preventDefault();273			if (open === tab && shown !== tab.href) {274				load(tab.href);275				return;276			}277			open?.classList.remove("active");278			if (open === tab) {279				open = null;280				shown = "";281				host.hidden = true;282				return;283			}284			open = tab;285			tab.classList.add("active");286			load(tab.href);287		});288	}289	host.addEventListener("click", (event) => {290		const link = event.target.closest("a[href]");291		if (!link || !open || modified(event) || link.hasAttribute("download")) return;292		if (!inside(link.href)) return;293		event.preventDefault();294		load(link.href);295	});296	host.addEventListener("submit", (event) => {297		const form = event.target;298		if (!open || !inside(form.action)) return;299		event.preventDefault();300		const method = form.method.toUpperCase();301		const data = new FormData(form);302		if (method === "GET") load(`${form.action}?${new URLSearchParams(data)}`);303		else load(form.action, { method, body: data });304	});305}306307// History and the Atlas's save picker hold one page of saves; scrolling to the end of either308// brings in the next page's rows. The pager is for pages without JavaScript.309function saveLists() {310	for (const list of document.querySelectorAll(".save-list")) {311		const observer = new IntersectionObserver(312			async ([entry]) => {313				const older = list.querySelector(".pager-older");314				if (!entry?.isIntersecting || !older) return;315				observer.disconnect();316				const main = await fetchMain(older.href);317				const next = main?.querySelector(`.save-list[data-list="${list.dataset.list}"]`);318				if (!next) return;319				next.querySelector(".pager a:not(.pager-older)")?.remove();320				list.querySelector(".pager").replaceWith(...next.childNodes);321				watch();322			},323			{ root: list },324		);325		const watch = () => {326			const older = list.querySelector(".pager-older");327			if (older) observer.observe(older);328		};329		watch();330	}331}332333// `#L42` works through CSS :target alone; a range `#L42-L50` needs marking line by line.334function lineRanges() {335	const code = document.querySelector(".code");336	if (!code) return;337	let anchor = null;338	const mark = () => {339		for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl");340		const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash);341		if (!match) return;342		const from = Number(match[1]);343		const to = Number(match[2] ?? match[1]);344		anchor = from;345		for (let n = Math.min(from, to); n <= Math.max(from, to); n++) {346			document.getElementById(`L${n}`)?.classList.add("hl");347		}348		document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" });349	};350	code.addEventListener("click", (event) => {351		const link = event.target.closest(".ln");352		if (!link || !event.shiftKey || anchor === null) return;353		event.preventDefault();354		const n = Number(link.textContent);355		history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`);356		mark();357	});358	window.addEventListener("hashchange", mark);359	mark();360}361362// The Atlas (§21.5): each row's detail panel is fetched on hover or select and kept for the page's363// life; the filter narrows by filename; which folders are open is remembered per repo in this browser.364function atlas() {365	const root = document.querySelector(".atlas");366	const filter = document.querySelector(".atlas-filter");367	if (!root || !filter) return;368	const panel = root.querySelector(".atlas-panel");369	const folders = [...root.querySelectorAll(".atlas-folder")];370	const files = [...root.querySelectorAll(".atlas-file")];371	// Its height is the closed tree's, fixed before any folder opens; opening one scrolls within.372	root.style.height = `${root.offsetHeight}px`;373	root.style.overflowY = "hidden";374	const key = `kepr:atlas:${root.dataset.repo}`;375	const opened = new Set(remembered(key));376	// `?open=<folder>` from a file page's breadcrumb unfolds that folder, without remembering it.377	const unfold = new URLSearchParams(location.search).get("open");378	const onPath = (path) => unfold !== null && `${unfold}/`.startsWith(`${path}/`);379	for (const folder of folders) {380		folder.open = opened.has(folder.dataset.path) || onPath(folder.dataset.path);381		folder.addEventListener("toggle", () => {382			if (filter.value.trim()) return;383			if (folder.open) opened.add(folder.dataset.path);384			else opened.delete(folder.dataset.path);385			remember(key, [...opened]);386		});387	}388	const panels = new Map();389	const panelFor = (path) => {390		if (!panels.has(path)) {391			const query = new URLSearchParams({ path });392			if (root.dataset.at) query.set("at", root.dataset.at);393			const text = fetch(`/${root.dataset.repo}/atlas/panel?${query}`)394				.then((response) => (response.ok ? response.text() : Promise.reject()))395				.catch(() => {396					panels.delete(path);397					return "";398				});399			panels.set(path, text);400		}401		return panels.get(path).then((text) => {402			const template = document.createElement("template");403			template.innerHTML = text;404			return template.content;405		});406	};407	let selected = null;408	const show = async (row) => {409		selected?.classList.remove("selected");410		selected = row;411		row.classList.add("selected");412		const content = await panelFor(row.dataset.path ?? `${row.parentElement.dataset.path}/`);413		if (selected === row) panel.replaceChildren(content);414	};415	for (const row of root.querySelectorAll(".atlas-row")) {416		row.addEventListener("pointerenter", (event) => {417			if (event.pointerType === "mouse") show(row);418		});419		row.addEventListener("focus", () => show(row));420	}421	// A map link in a panel jumps to its row, opening the folders above it.422	const jump = (path) => {423		const row = path.endsWith("/")424			? folders.find((folder) => `${folder.dataset.path}/` === path)?.firstElementChild425			: files.find((file) => file.dataset.path === path);426		if (!row) return false;427		for (const folder of folders) folder.open ||= path.startsWith(`${folder.dataset.path}/`);428		show(row);429		row.scrollIntoView({ block: "nearest" });430		return true;431	};432	panel.addEventListener("click", (event) => {433		const link = event.target.closest("a.atlas-jump");434		if (link && !modified(event) && jump(link.dataset.path)) event.preventDefault();435	});436	const viewer = fileViewer(root, jump, panelFor);437	for (const file of files) {438		file.addEventListener("click", (event) => {439			if (modified(event)) return;440			event.preventDefault();441			viewer(file);442		});443	}444	const target = folders.find((folder) => folder.dataset.path === unfold)?.firstElementChild;445	if (target) {446		show(target);447		target.scrollIntoView({ block: "center" });448	}449	filter.addEventListener("input", () => {450		const query = filter.value.trim().toLowerCase();451		for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query);452		for (const folder of folders) {453			folder.hidden = query !== "" && !folder.querySelector(".atlas-file:not([hidden])");454			folder.open = query !== "" || opened.has(folder.dataset.path);455		}456	});457	// One button opens every folder, or, when they all are, closes them all.458	const fold = document.querySelector(".atlas-fold-btn");459	const label = () => {460		fold.textContent = folders.every((folder) => folder.open) ? "collapse all" : "expand all";461	};462	fold.addEventListener("click", () => {463		const open = !folders.every((folder) => folder.open);464		for (const folder of folders) folder.open = open;465		label();466	});467	for (const folder of folders) folder.addEventListener("toggle", label);468	label();469	fold.hidden = folders.length === 0;470	// The filter waits behind a search button, and folds back into it when left empty.471	const search = document.querySelector(".atlas-search-btn");472	search.addEventListener("click", () => {473		search.hidden = true;474		filter.hidden = false;475		filter.focus();476	});477	filter.addEventListener("blur", () => {478		if (filter.value.trim() !== "") return;479		filter.hidden = true;480		search.hidden = false;481	});482	search.hidden = false;483	panel.hidden = false;484}485486// Kepr 2's Atlas picker: its saves arrive when it first opens, filtered as you type; a click487// outside or Escape closes it.488function atlasPicker() {489	const picker = document.querySelector(".atlas-picker");490	if (!picker) return;491	const filter = picker.querySelector(".atlas-picker-filter");492	const list = picker.querySelector(".atlas-picker-list");493	let rows = [];494	const narrow = () => {495		const query = filter.value.trim().toLowerCase();496		for (const row of rows) {497			row.hidden = query !== "" && !row.textContent.toLowerCase().includes(query);498		}499	};500	filter.addEventListener("input", narrow);501	let loading = null;502	picker.addEventListener("toggle", () => {503		if (!picker.open) return;504		filter.focus();505		if (loading) return;506		loading = fetch(list.dataset.src)507			.then((response) => (response.ok ? response.text() : Promise.reject()))508			.then((text) => {509				const template = document.createElement("template");510				template.innerHTML = text;511				list.replaceChildren(template.content);512				rows = [...list.children];513				narrow();514			})515			.catch(() => {516				loading = null;517			});518	});519	document.addEventListener("click", (event) => {520		if (!picker.contains(event.target)) picker.open = false;521	});522	picker.addEventListener("keydown", (event) => {523		if (event.key === "Escape") picker.open = false;524	});525	filter.hidden = false;526}527528// Files open in a viewer over the page, as in Kepr 2's Atlas: the file page's content with the529// row's detail panel beside it, and controls for text size, wrapping, raw, download and the530// neighbouring files. Escape closes it; the arrow keys step through the files.531function fileViewer(root, jump, panelFor) {532	let [row, size, wrap] = [null, 12, false];533	const overlay = element("div", "fv-overlay");534	overlay.tabIndex = -1;535	overlay.setAttribute("role", "dialog");536	overlay.setAttribute("aria-modal", "true");537	const close = () => {538		overlay.remove();539		row?.focus();540		row = null;541	};542	const step = (by) => {543		const rows = [...root.querySelectorAll(".atlas-file")].filter((file) => !file.hidden);544		const next = rows[rows.indexOf(row) + by];545		if (next) open(next);546	};547	const restyle = () => {548		overlay.style.setProperty("--fv-size", `${size}px`);549		overlay.classList.toggle("fv-wrap", wrap);550	};551	const open = async (file, url = file.href) => {552		row = file;553		const rows = [...root.querySelectorAll(".atlas-file")].filter((each) => !each.hidden);554		const index = rows.indexOf(file);555		const path = file.dataset.path;556		const name = path.split("/").pop();557		const raw = new URL(file.href);558		raw.pathname = raw.pathname.replace(/^(\/[^/]+)\/files\//, "$1/raw/");559		const button = (text, action, className = "fv-ctrl-btn") => {560			const b = element("button", className, text);561			b.type = "button";562			b.addEventListener("click", action);563			return b;564		};565		const link = (text, download) => {566			const a = element("a", "fv-ctrl-btn", text);567			a.href = raw.href;568			if (download) a.download = name;569			return a;570		};571		const source = element("div", "fv-source", "loading…");572		const panel = element("aside", "fv-detail");573		panelFor(path).then((content) => {574			if (row === file) panel.replaceChildren(content);575		});576		const header = element("div", "fv-header");577		const title = element("div", "fv-title");578		title.append(element("span", "fv-name", name), element("span", "fv-path mono", path));579		const controls = element("div", "fv-controls mono");580		controls.append(581			button("A−", () => {582				size = Math.max(9, size - 1);583				restyle();584			}),585			button("A+", () => {586				size = Math.min(20, size + 1);587				restyle();588			}),589			button("⇌ wrap", (event) => {590				wrap = !wrap;591				event.currentTarget.classList.toggle("active", wrap);592				restyle();593			}),594			link("raw", false),595			link("download", true),596		);597		controls.children[2].classList.toggle("active", wrap);598		const nav = element("div", "fv-nav mono");599		const back = button("←", () => step(-1), "fv-ctrl-btn");600		const forward = button("→", () => step(1), "fv-ctrl-btn");601		back.disabled = index <= 0;602		forward.disabled = index >= rows.length - 1;603		nav.append(back, element("span", "fv-pos", `${index + 1}/${rows.length}`), forward);604		header.append(button("×", close, "fv-close"), title, controls, nav);605		const body = element("div", "fv-body");606		body.append(source, panel);607		overlay.replaceChildren(header, body);608		restyle();609		if (!overlay.isConnected) document.body.append(overlay);610		overlay.focus();611		const main = await fetchMain(url);612		if (row !== file) return;613		if (!main) {614			location.href = url;615			return;616		}617		for (const heading of main.querySelectorAll(".breadcrumb, .file-page-title")) heading.remove();618		main.querySelector(".file-meta:not(:last-child)")?.remove();619		source.replaceChildren(...main.childNodes);620	};621	overlay.addEventListener("keydown", (event) => {622		if (event.key === "Escape") close();623		else if (event.key === "ArrowLeft" || event.key === "ArrowRight") {624			event.preventDefault();625			step(event.key === "ArrowLeft" ? -1 : 1);626		}627	});628	// A map link beside the code opens that file here; a folder closes the viewer on its row.629	overlay.addEventListener("click", (event) => {630		const link = event.target.closest(".fv-detail a.atlas-jump");631		if (!link || modified(event)) return;632		const path = link.dataset.path;633		const file = [...root.querySelectorAll(".atlas-file")].find((f) => f.dataset.path === path);634		event.preventDefault();635		if (file) open(file);636		else {637			close();638			jump(path);639		}640	});641	// The Markdown view's rendered/source toggle points at the file page: switch in place.642	overlay.addEventListener("click", (event) => {643		const link = event.target.closest(".fv-source a[href]");644		if (!link || !row || modified(event)) return;645		if (new URL(link.href).pathname !== new URL(row.href).pathname) return;646		event.preventDefault();647		open(row, link.href);648	});649	return open;650}651652async function navbarLogin() {653	const link = document.querySelector('.nav-links a[href^="/sign-in"]');654	if (!link || !/(^|;\s*)kepr_editor=1(?:;|$)/.test(document.cookie)) return;655	try {656		const response = await fetch("/admin", { cache: "no-store" });657		if (!response.ok) return;658		const doc = new DOMParser().parseFromString(await response.text(), "text/html");659		const token = doc.querySelector('form[action="/admin/sign-out"] input[name="form"]');660		if (!token) return;661		const form = element("form", "nav-logout");662		form.method = "post";663		form.action = "/admin/sign-out";664		const button = element("button", "", "log out");665		button.type = "submit";666		form.append(token, button);667		form.addEventListener("submit", async (event) => {668			event.preventDefault();669			button.disabled = true;670			try {671				const result = await fetch(form.action, { method: "POST", body: new FormData(form) });672				if (!result.ok) throw new Error("Log out failed. Reload and try again.");673				location.href = "/";674			} catch (error) {675				button.disabled = false;676				button.textContent = "try log out again";677				console.error(error);678			}679		});680		const admin = element("a", "", "admin");681		admin.href = "/admin";682		link.replaceWith(admin, form);683	} catch (error) {684		console.error("Could not check navbar sign-in state", error);685	}686}687688// On a phone the nav links fold behind a menu button (2026-10-01). The button only exists with689// JavaScript, so without it the links stay in the bar; the CSS hides them only on `.nav--menu`.690function navMenu() {691	const nav = document.querySelector("body > nav");692	const links = nav?.querySelector(".nav-links");693	if (!links) return;694	links.id = "nav-links";695	const button = element("button", "nav-toggle");696	button.type = "button";697	button.setAttribute("aria-label", "menu");698	button.setAttribute("aria-controls", "nav-links");699	button.setAttribute("aria-expanded", "false");700	button.append(element("span", ""), element("span", ""), element("span", ""));701	const open = () => nav.classList.contains("nav--open");702	const set = (opened) => {703		nav.classList.toggle("nav--open", opened);704		button.setAttribute("aria-expanded", String(opened));705	};706	button.addEventListener("click", () => set(!open()));707	document.addEventListener("click", (event) => {708		if (open() && !nav.contains(event.target)) set(false);709	});710	document.addEventListener("keydown", (event) => {711		if (event.key !== "Escape" || !open()) return;712		set(false);713		button.focus();714	});715	nav.classList.add("nav--menu");716	// Before the links, so tabbing from the open button reaches them next.717	links.before(button);718}719720function element(tag, className, text) {721	const node = document.createElement(tag);722	node.className = className;723	if (text !== undefined) node.textContent = text;724	return node;725}726727// Signed in, a repo page's description and details are edited in place: Markdown with a toolbar728// and a preview as the page will show it, pictures and videos picked, dropped or pasted in, and729// the one under the caret sized and placed from a row of controls. The page's HTML is the same730// for everyone; only this cookie, which says nothing but "signed in here", brings the controls.731async function repoEditor() {732	const head = document.querySelector(".repo-head");733	if (!head || !/(^|;\s*)kepr_editor=1/.test(document.cookie)) return;734	const base = `/admin/repos/${location.pathname.split("/")[1]}`;735	const response = await fetch(`${base}/text`).catch(() => null);736	if (!response?.ok) return;737	const texts = await response.json();738	const details = document.querySelector(".repo-details");739	const places = [740		["description", head.querySelector(".repo-desc"), head.querySelector(".repo-desc")],741		["details", details, details.querySelector(".repo-details-body")],742	];743	for (const [field, section, body] of places) {744		section.hidden = false;745		if (field === "description") {746			section.tabIndex = 0;747			section.setAttribute("role", "button");748			section.setAttribute("aria-label", "edit repository description");749			section.classList.add("repo-desc-editable");750			if (!section.textContent.trim()) section.textContent = "add a description";751			const activate = (event) => {752				if (event.target.closest(".editor") || body.querySelector(".editor")) return;753				event.preventDefault();754				editText(base, texts.form, field, body, texts[field]);755			};756			section.addEventListener("click", activate);757			section.addEventListener("keydown", (event) => {758				if (event.target === section && (event.key === "Enter" || event.key === " "))759					activate(event);760			});761			continue;762		}763		const button = element("button", "edit-btn mono", "edit");764		button.type = "button";765		(section.querySelector(".repo-details-head") ?? section).append(button);766		button.addEventListener("click", (event) => {767			event.preventDefault();768			if (!body.querySelector(".editor")) editText(base, texts.form, field, body, texts[field]);769		});770	}771}772773const MEDIA_MARKDOWN = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g;774const MEDIA_TYPES = "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/webm";775776function editText(base, form, field, body, text) {777	const kept = [...body.childNodes];778	if (field === "description") {779		body.removeAttribute("role");780		body.removeAttribute("tabindex");781		body.removeAttribute("aria-label");782	}783	const input = element("textarea", "editor-input mono");784	input.value = text;785	input.rows = field === "details" ? 16 : 3;786	const preview = element("div", `editor-preview${field === "details" ? " markdown" : ""}`);787	const media = element("div", "editor-media mono");788	const status = element("span", "editor-status");789	const post = (path, fields) => {790		const data = new FormData();791		data.append("form", form);792		for (const [name, value] of Object.entries(fields)) data.append(name, value);793		return fetch(`${base}/${path}`, { method: "POST", body: data });794	};795	let timer = null;796	const changed = () => {797		inspect();798		clearTimeout(timer);799		timer = setTimeout(async () => {800			const answer = await post("preview", { field, value: input.value }).catch(() => null);801			if (!answer?.ok) return;802			// Kepr's own Markdown rendering, raw HTML off: the same markup the page will carry.803			preview.innerHTML = input.value.trim() ? await answer.text() : "";804			preview.dataset.empty = input.value.trim() ? "" : "the README's words show here";805		}, 250);806	};807	const wrap = (before, after = before, fallback = "text") => {808		const [from, to] = [input.selectionStart, input.selectionEnd];809		const chosen = input.value.slice(from, to) || fallback;810		input.setRangeText(before + chosen + after, from, to);811		input.setSelectionRange(from + before.length, from + before.length + chosen.length);812		input.focus();813		changed();814	};815	const prefix = (mark) => {816		const start = input.value.lastIndexOf("\n", input.selectionStart - 1) + 1;817		input.setRangeText(mark, start, start, "end");818		input.focus();819		changed();820	};821	const upload = async (file) => {822		status.textContent = `uploading ${file.name}…`;823		const answer = await post("media", { file }).catch(() => null);824		if (!answer?.ok) {825			status.textContent = answer ? await answer.text() : "upload failed";826			return;827		}828		const { url } = await answer.json();829		const alt = file.name.replace(/\.[^.]+$/, "").replace(/[[\]]/g, "");830		input.setRangeText(831			`\n![${alt}](${url} "width=100 center")\n`,832			input.selectionStart,833			input.selectionEnd,834			"end",835		);836		status.textContent = "";837		input.focus();838		changed();839	};840	const picker = element("input", "");841	picker.type = "file";842	picker.accept = MEDIA_TYPES;843	picker.hidden = true;844	picker.addEventListener("change", () => {845		for (const file of picker.files) upload(file);846		picker.value = "";847	});848	// The picture or video under the caret, with its options, as `![alt](url "options")`.849	const underCaret = () =>850		[...input.value.matchAll(MEDIA_MARKDOWN)].find(851			(m) => input.selectionStart >= m.index && input.selectionStart <= m.index + m[0].length,852		);853	function inspect() {854		const found = field === "details" ? underCaret() : undefined;855		media.hidden = !found;856		if (!found) return;857		const words = (found[3] ?? "").split(/\s+/).filter(Boolean);858		const width = words.find((word) => word.startsWith("width="))?.slice(6) ?? "100";859		const align = words.find((word) => /^(left|center|right)$/.test(word)) ?? "center";860		const loop = words.includes("loop");861		const video = /\.(mp4|webm)$/i.test(found[2]);862		const set = (next) => {863			const options = [`width=${next.width}`, next.align, next.loop && "loop"].filter(Boolean);864			const markdown = `![${found[1]}](${found[2]} "${options.join(" ")}")`;865			const caret = input.selectionStart;866			input.setRangeText(markdown, found.index, found.index + found[0].length);867			input.setSelectionRange(caret, caret);868			changed();869		};870		const choice = (label, active, action) => {871			const b = element("button", `fv-ctrl-btn${active ? " active" : ""}`, label);872			b.type = "button";873			b.addEventListener("click", action);874			return b;875		};876		media.replaceChildren(877			element("span", "editor-label", video ? "video" : "picture"),878			...["25", "33", "50", "66", "75", "100"].map((w) =>879				choice(`${w}%`, w === width, () => set({ width: w, align, loop })),880			),881			...["left", "center", "right"].map((a) =>882				choice(a, a === align, () => set({ width, align: a, loop })),883			),884			...(video ? [choice("loop like a GIF", loop, () => set({ width, align, loop: !loop }))] : []),885		);886	}887	const save = async (value) => {888		status.textContent = "saving…";889		const answer = await post("text", { field, value }).catch(() => null);890		if (answer?.ok) location.reload();891		else status.textContent = answer ? await answer.text() : "not saved: offline?";892	};893	const cancel = () => {894		body.replaceChildren(...kept);895		if (field === "description") {896			body.tabIndex = 0;897			body.setAttribute("role", "button");898			body.setAttribute("aria-label", "edit repository description");899			body.focus();900		}901	};902	const tool = (label, title, action) => {903		const b = element("button", "fv-ctrl-btn", label);904		b.type = "button";905		b.title = title;906		b.addEventListener("click", action);907		return b;908	};909	const toolbar = element("div", "editor-toolbar mono");910	toolbar.append(911		tool("B", "bold (⌘B)", () => wrap("**")),912		tool("I", "italic (⌘I)", () => wrap("_")),913		tool("link", "link (⌘K)", () => wrap("[", "](https://)")),914		tool("code", "code", () => wrap("`")),915	);916	if (field === "details") {917		toolbar.append(918			tool("heading", "heading", () => prefix("## ")),919			tool("list", "list", () => prefix("- ")),920			tool("quote", "quote", () => prefix("> ")),921			tool("picture or video", "add a picture, GIF or video (or drop or paste one)", () =>922				picker.click(),923			),924			picker,925		);926		input.addEventListener("dragover", (event) => event.preventDefault());927		input.addEventListener("drop", (event) => {928			if (event.dataTransfer.files.length === 0) return;929			event.preventDefault();930			for (const file of event.dataTransfer.files) upload(file);931		});932		input.addEventListener("paste", (event) => {933			const files = [...event.clipboardData.files];934			if (files.length === 0) return;935			event.preventDefault();936			for (const file of files) upload(file);937		});938	}939	input.addEventListener("input", changed);940	for (const name of ["click", "keyup"]) input.addEventListener(name, inspect);941	input.addEventListener("keydown", (event) => {942		const command = event.metaKey || event.ctrlKey;943		if (event.key === "Escape") cancel();944		else if (command && event.key === "Enter") save(input.value);945		else if (command && event.key === "b") wrap("**");946		else if (command && event.key === "i") wrap("_");947		else if (command && event.key === "k") wrap("[", "](https://)");948		else return;949		event.preventDefault();950	});951	const actions = element("div", "editor-actions mono");952	actions.append(953		tool("save", "save (⌘↵)", () => save(input.value)),954		tool("cancel", "cancel (esc)", cancel),955		tool("use the README", "clear your words and show the README's again", () => save("")),956		status,957	);958	const panes = element("div", "editor-panes");959	panes.append(input, preview);960	const editor = element("div", `editor editor-${field}`);961	editor.append(toolbar, panes, media, actions);962	body.replaceChildren(editor);963	input.focus();964	changed();965}966967// Storage can be missing, refused (private windows, blocked windows) or hold anything; only a968// list of folder paths is trusted, and otherwise the tree starts closed.969function remembered(key) {970	try {971		const value = JSON.parse(localStorage.getItem(key) ?? "[]");972		return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : [];973	} catch {974		return [];975	}976}977978function remember(key, value) {979	try {980		localStorage.setItem(key, JSON.stringify(value));981	} catch {}982}983984// The profile's hex backdrop already breathes in CSS; this lights a few columns at a time, as985// Kepr 2's canvas did. Nothing moves under reduced motion, including when it is switched on later.986function hexGlow() {987	const columns = document.querySelectorAll(".hex-col");988	if (columns.length === 0) return;989	const reduce = matchMedia("(prefers-reduced-motion: reduce)");990	let timer = null;991	const update = () => {992		clearInterval(timer);993		for (const column of columns) column.classList.remove("glow");994		if (reduce.matches) return;995		timer = setInterval(() => {996			const column = columns[Math.floor(Math.random() * columns.length)];997			if (document.querySelectorAll(".hex-col.glow").length >= 3) return;998			column.classList.add("glow");999			setTimeout(() => column.classList.remove("glow"), 3200);1000		}, 1500);1001	};1002	reduce.addEventListener("change", update);1003	update();1004}10051006// A CSS duration cannot follow a track's width, so the pace would depend on the messages; set it1007// here so each row slides at its own --speed in px/s, as Kepr 2's canvas did.1008function tickerPace() {1009	for (const track of document.querySelectorAll(".ticker-track")) {1010		const speed = Number(getComputedStyle(track).getPropertyValue("--speed"));1011		if (speed > 0) track.style.animationDuration = `${track.scrollWidth / 2 / speed}s`;1012	}1013}10141015// Widths are only final once Martian Mono has loaded.1016document.fonts.ready.then(tickerPace);1017timeline();1018copyButtons(document);1019panels();1020saveLists();1021atlasPicker();1022repoEditor();1023navbarLogin();1024navMenu();1025lineRanges();1026atlas();1027hexGlow();