// Kepr's browser islands (§18). Every page is complete without this file; it only adds the // ticker's pace, the drifting timeline, copy buttons, the inline panels, history's scrolling, // line-range highlighting, the Atlas panel, picker, filter and file viewer, the signed-in // editor, the phone nav menu, and the profile's visibility pause. // Kepr 2's save kinds (canvas.js), first match wins: each dot's colour and strength. const KINDS = [ [/\b(release|version|bump|tag)\b/i, "accent", 1], [/\b(fix|bug|patch|issue|crash|error|regress|revert)\b/i, "accent-dim", 0.8], [/\b(add|new|implement|feat|introduce|wire|support)\b/i, "accent", 1], [ /\b(refactor|restructure|reorganize|extract|move|rename|split|cleanup|clean up)\b/i, "text", 0.85, ], [/\b(perf|optim|speed|fast|slow|cache|memory|alloc)\b/i, "accent", 0.9], [/\b(test|spec|coverage|assert)\b/i, "text-secondary", 0.74], [/\b(doc|readme|comment|example|guide)\b/i, "text-secondary", 0.7], [/\b(polish|style|format|lint|whitespace|typo|wording|copy)\b/i, "text-secondary", 0.66], [/\b(chore|deps|update|upgrade|build|ci|config)\b/i, "text-secondary", 0.62], ]; // Kepr 2's timeline (canvas.js keprThread): the saves on a strip that drifts slowly and loops. // Hovering pauses it and shows the save, dragging scrubs, a click opens the save. Under reduced // motion it moves only when dragged, and it draws only while on screen. The saves are fetched // once the page has drawn; the SVG's plain dots stand in until then. async function timeline() { const svg = document.querySelector("svg.timeline"); if (!svg) return; const response = await fetch(svg.dataset.src); if (!response.ok) return; const repo = svg.dataset.src.split("/")[1]; const css = getComputedStyle(document.documentElement); const saves = (await response.json()) .map(({ id, t, message, meta }) => { const [, color, alpha] = KINDS.find(([kind]) => kind.test(message)) ?? [ null, "text-secondary", 0.8, ]; const fill = css.getPropertyValue(`--${color}`).trim(); return { href: `/${repo}/saves/${id}`, id, message, meta, fill, alpha, t }; }) .sort((a, b) => a.t - b.t); if (saves.length === 0) return; const canvas = document.createElement("canvas"); canvas.className = "timeline-canvas"; canvas.setAttribute("role", "img"); canvas.setAttribute("aria-label", svg.getAttribute("aria-label")); svg.replaceWith(canvas); const ctx = canvas.getContext("2d"); const tip = document.createElement("div"); tip.className = "timeline-tip"; tip.hidden = true; document.body.append(tip); const reduce = matchMedia("(prefers-reduced-motion: reduce)"); let [width, height, strip, radius, offset] = [0, 0, 0, 0, 0]; // Laid out by time, a repo's bursts sat between long empty stretches the drift crawled // through. Each dot instead sits after the one before by the gap between them, on a log scale // and capped, so a busy day stays tight and a quiet month reads as a pause, not a wait. const spacing = (seconds) => radius + 8 + Math.min(96, 12 * Math.log2(1 + seconds / 3600)); const resize = () => { width = canvas.parentElement.getBoundingClientRect().width; // Kepr 2's band was tall to hold two lanes; with one line, only room for the dots and label. height = 64; canvas.width = Math.round(width * devicePixelRatio); canvas.height = Math.round(height * devicePixelRatio); canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0); radius = 5; let x = 0; saves.forEach((save, i) => { if (i > 0) x += spacing(save.t - saves[i - 1].t); save.x = x; }); // The seam is one more capped pause, or the rest of the view when the saves are few. strip = Math.max(x + spacing(Number.POSITIVE_INFINITY), width); offset = x - width * 0.82; }; // Every visible copy of each x on the looping strip. const onScreen = (x, margin) => { const shift = ((offset % strip) + strip) % strip; const copies = []; for (let sx = x - shift - strip; sx < width + margin; sx += strip) { if (sx > -margin) copies.push(sx); } return copies; }; const hit = (event) => { const box = canvas.getBoundingClientRect(); const [mx, my] = [event.clientX - box.left, event.clientY - box.top]; if (Math.abs(my - height / 2) > radius + 7) return null; return ( saves.find((save) => onScreen(save.x, 0).some((x) => Math.abs(mx - x) < radius + 7)) ?? null ); }; let [hovered, drag, dragFrom, downX, lastX, velocity, resumeAt, lastFrame] = [ null, null, 0, 0, 0, 0, 0, 0, ]; const hover = (save, event) => { if (save !== hovered) { hovered = save; tip.hidden = save === null; if (save) tip.replaceChildren( line("tip-id", save.id), line("tip-msg", save.message), line("tip-meta", save.meta), ); } if (save) { const left = event.clientX + 14; tip.style.left = `${left + 340 > innerWidth ? Math.max(8, event.clientX - 354) : left}px`; tip.style.top = `${event.clientY - 10}px`; } canvas.style.cursor = save ? "pointer" : drag === null ? "grab" : "grabbing"; }; const draw = (time) => { const dt = lastFrame ? Math.min((time - lastFrame) / 1000, 0.1) : 0; lastFrame = time; if (drag === null && hovered === null) { if (Math.abs(velocity) > 0.5) { offset -= velocity; velocity *= 0.92; } else if (!reduce.matches && time >= resumeAt) offset += 20 * dt; } const y = height / 2; ctx.clearRect(0, 0, width, height); ctx.lineWidth = 1; ctx.globalAlpha = 0.7; ctx.strokeStyle = css.getPropertyValue("--text-muted"); ctx.setLineDash([4, 4]); ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); ctx.setLineDash([]); ctx.font = '10px "Martian Mono", monospace'; ctx.textAlign = "center"; for (const save of saves) { for (const x of onScreen(save.x, 10)) { ctx.globalAlpha = hovered === null ? save.alpha : hovered === save ? 1 : save.alpha * 0.6; ctx.fillStyle = save.fill; ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fill(); if (hovered === save) { ctx.fillStyle = css.getPropertyValue("--accent"); ctx.fillText(save.id.slice(0, 6), x, y - radius - 6); } } } ctx.globalAlpha = 1; if (visible) requestAnimationFrame(draw); else drawing = false; }; canvas.addEventListener("pointerdown", (event) => { [drag, dragFrom, downX, lastX, velocity] = [ event.clientX, offset, event.clientX, event.clientX, 0, ]; canvas.setPointerCapture(event.pointerId); }); canvas.addEventListener("pointermove", (event) => { if (drag !== null) { offset = dragFrom - (event.clientX - drag); velocity = velocity * 0.6 + (event.clientX - lastX) * 0.4; lastX = event.clientX; } if (event.pointerType === "mouse") hover(hit(event), event); }); const release = () => { drag = null; resumeAt = performance.now() + 900; }; canvas.addEventListener("pointerup", (event) => { release(); const save = Math.abs(event.clientX - downX) > 4 ? null : hit(event); if (save) location.href = save.href; }); canvas.addEventListener("pointercancel", release); canvas.addEventListener("pointerleave", (event) => { if (drag === null) hover(null, event); }); addEventListener("resize", resize); resize(); // Off screen, the loop stops; it starts again, without a jump, when the strip scrolls back. let [visible, drawing] = [true, false]; new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; if (visible && !drawing) { drawing = true; lastFrame = 0; requestAnimationFrame(draw); } }).observe(canvas); } function line(className, text) { const div = document.createElement("div"); div.className = className; div.textContent = text ?? ""; return div; } function copyButtons(root) { for (const button of root.querySelectorAll("button[data-copy]")) { const label = button.textContent; button.hidden = false; button.addEventListener("click", async () => { await navigator.clipboard.writeText(button.dataset.copy); button.textContent = "copied"; button.classList.add("copied"); setTimeout(() => { button.textContent = label; button.classList.remove("copied"); }, 1400); }); } } // A Kepr page's
, its links made absolute so they point where they did on their own page // (after any redirect). An error page's
comes back too, so a refused form shows why; null if // nothing came back, and the caller then goes to the page itself. async function fetchMain(url, init) { try { const response = await fetch(url, init); const page = new DOMParser().parseFromString(await response.text(), "text/html"); const main = page.querySelector("main"); for (const link of main?.querySelectorAll("a[href]:not([href^='#'])") ?? []) { link.href = new URL(link.getAttribute("href"), response.url || url).href; } return main; } catch { return null; } } // A click the browser should handle itself: a new tab or window, or a download. function modified(event) { return event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey; } // §21.9's panels open inline under the tabs, as Kepr 2's did: the panel page without its heading. // The panel's own pages (an issue, the report form) and its forms stay inside it; a link elsewhere, // or to a file (a download), navigates. The open tab takes the panel back to its first page, and // closes it from there. function panels() { const host = document.querySelector(".repo-panel"); if (!host) return; let open = null; let shown = ""; const load = async (url, init) => { shown = url; const main = await fetchMain(url, init); if (shown !== url) return; if (!main) { location.href = url; return; } main.querySelector(".breadcrumb")?.remove(); main.querySelector(".file-page-title")?.remove(); host.replaceChildren(...main.childNodes); copyButtons(host); host.hidden = false; }; const inside = (url) => { const base = new URL(open.href).pathname; const { pathname } = new URL(url); return (pathname === base || pathname.startsWith(`${base}/`)) && !/\.[^/]*$/.test(pathname); }; for (const tab of document.querySelectorAll(".repo-tab[data-panel]")) { tab.addEventListener("click", (event) => { if (modified(event)) return; event.preventDefault(); if (open === tab && shown !== tab.href) { load(tab.href); return; } open?.classList.remove("active"); if (open === tab) { open = null; shown = ""; host.hidden = true; return; } open = tab; tab.classList.add("active"); load(tab.href); }); } host.addEventListener("click", (event) => { const link = event.target.closest("a[href]"); if (!link || !open || modified(event) || link.hasAttribute("download")) return; if (!inside(link.href)) return; event.preventDefault(); load(link.href); }); host.addEventListener("submit", (event) => { const form = event.target; if (!open || !inside(form.action)) return; event.preventDefault(); const method = form.method.toUpperCase(); const data = new FormData(form); if (method === "GET") load(`${form.action}?${new URLSearchParams(data)}`); else load(form.action, { method, body: data }); }); } // History and the Atlas's save picker hold one page of saves; scrolling to the end of either // brings in the next page's rows. The pager is for pages without JavaScript. function saveLists() { for (const list of document.querySelectorAll(".save-list")) { const observer = new IntersectionObserver( async ([entry]) => { const older = list.querySelector(".pager-older"); if (!entry?.isIntersecting || !older) return; observer.disconnect(); const main = await fetchMain(older.href); const next = main?.querySelector(`.save-list[data-list="${list.dataset.list}"]`); if (!next) return; next.querySelector(".pager a:not(.pager-older)")?.remove(); list.querySelector(".pager").replaceWith(...next.childNodes); watch(); }, { root: list }, ); const watch = () => { const older = list.querySelector(".pager-older"); if (older) observer.observe(older); }; watch(); } } // `#L42` works through CSS :target alone; a range `#L42-L50` needs marking line by line. function lineRanges() { const code = document.querySelector(".code"); if (!code) return; let anchor = null; const mark = () => { for (const marked of code.querySelectorAll(".line.hl")) marked.classList.remove("hl"); const match = /^#L(\d+)(?:-L(\d+))?$/.exec(location.hash); if (!match) return; const from = Number(match[1]); const to = Number(match[2] ?? match[1]); anchor = from; for (let n = Math.min(from, to); n <= Math.max(from, to); n++) { document.getElementById(`L${n}`)?.classList.add("hl"); } document.getElementById(`L${Math.min(from, to)}`)?.scrollIntoView({ block: "center" }); }; code.addEventListener("click", (event) => { const link = event.target.closest(".ln"); if (!link || !event.shiftKey || anchor === null) return; event.preventDefault(); const n = Number(link.textContent); history.replaceState(null, "", `#L${Math.min(anchor, n)}-L${Math.max(anchor, n)}`); mark(); }); window.addEventListener("hashchange", mark); mark(); } // The Atlas (§21.5): each row's detail panel is fetched on hover or select and kept for the page's // life; the filter narrows by filename; which folders are open is remembered per repo in this browser. function atlas() { const root = document.querySelector(".atlas"); const filter = document.querySelector(".atlas-filter"); if (!root || !filter) return; const panel = root.querySelector(".atlas-panel"); const folders = [...root.querySelectorAll(".atlas-folder")]; const files = [...root.querySelectorAll(".atlas-file")]; // Its height is the closed tree's, fixed before any folder opens; opening one scrolls within. root.style.height = `${root.offsetHeight}px`; root.style.overflowY = "hidden"; const key = `kepr:atlas:${root.dataset.repo}`; const opened = new Set(remembered(key)); // `?open=` from a file page's breadcrumb unfolds that folder, without remembering it. const unfold = new URLSearchParams(location.search).get("open"); const onPath = (path) => unfold !== null && `${unfold}/`.startsWith(`${path}/`); for (const folder of folders) { folder.open = opened.has(folder.dataset.path) || onPath(folder.dataset.path); folder.addEventListener("toggle", () => { if (filter.value.trim()) return; if (folder.open) opened.add(folder.dataset.path); else opened.delete(folder.dataset.path); remember(key, [...opened]); }); } const panels = new Map(); const panelFor = (path) => { if (!panels.has(path)) { const query = new URLSearchParams({ path }); if (root.dataset.at) query.set("at", root.dataset.at); const text = fetch(`/${root.dataset.repo}/atlas/panel?${query}`) .then((response) => (response.ok ? response.text() : Promise.reject())) .catch(() => { panels.delete(path); return ""; }); panels.set(path, text); } return panels.get(path).then((text) => { const template = document.createElement("template"); template.innerHTML = text; return template.content; }); }; let selected = null; const show = async (row) => { selected?.classList.remove("selected"); selected = row; row.classList.add("selected"); const content = await panelFor(row.dataset.path ?? `${row.parentElement.dataset.path}/`); if (selected === row) panel.replaceChildren(content); }; for (const row of root.querySelectorAll(".atlas-row")) { row.addEventListener("pointerenter", (event) => { if (event.pointerType === "mouse") show(row); }); row.addEventListener("focus", () => show(row)); } // A map link in a panel jumps to its row, opening the folders above it. const jump = (path) => { const row = path.endsWith("/") ? folders.find((folder) => `${folder.dataset.path}/` === path)?.firstElementChild : files.find((file) => file.dataset.path === path); if (!row) return false; for (const folder of folders) folder.open ||= path.startsWith(`${folder.dataset.path}/`); show(row); row.scrollIntoView({ block: "nearest" }); return true; }; panel.addEventListener("click", (event) => { const link = event.target.closest("a.atlas-jump"); if (link && !modified(event) && jump(link.dataset.path)) event.preventDefault(); }); const viewer = fileViewer(root, jump, panelFor); for (const file of files) { file.addEventListener("click", (event) => { if (modified(event)) return; event.preventDefault(); viewer(file); }); } const target = folders.find((folder) => folder.dataset.path === unfold)?.firstElementChild; if (target) { show(target); target.scrollIntoView({ block: "center" }); } filter.addEventListener("input", () => { const query = filter.value.trim().toLowerCase(); for (const file of files) file.hidden = query !== "" && !file.dataset.name.includes(query); for (const folder of folders) { folder.hidden = query !== "" && !folder.querySelector(".atlas-file:not([hidden])"); folder.open = query !== "" || opened.has(folder.dataset.path); } }); // One button opens every folder, or, when they all are, closes them all. const fold = document.querySelector(".atlas-fold-btn"); const label = () => { fold.textContent = folders.every((folder) => folder.open) ? "collapse all" : "expand all"; }; fold.addEventListener("click", () => { const open = !folders.every((folder) => folder.open); for (const folder of folders) folder.open = open; label(); }); for (const folder of folders) folder.addEventListener("toggle", label); label(); fold.hidden = folders.length === 0; // The filter waits behind a search button, and folds back into it when left empty. const search = document.querySelector(".atlas-search-btn"); search.addEventListener("click", () => { search.hidden = true; filter.hidden = false; filter.focus(); }); filter.addEventListener("blur", () => { if (filter.value.trim() !== "") return; filter.hidden = true; search.hidden = false; }); search.hidden = false; panel.hidden = false; } // Kepr 2's Atlas picker: its saves arrive when it first opens, filtered as you type; a click // outside or Escape closes it. function atlasPicker() { const picker = document.querySelector(".atlas-picker"); if (!picker) return; const filter = picker.querySelector(".atlas-picker-filter"); const list = picker.querySelector(".atlas-picker-list"); let rows = []; const narrow = () => { const query = filter.value.trim().toLowerCase(); for (const row of rows) { row.hidden = query !== "" && !row.textContent.toLowerCase().includes(query); } }; filter.addEventListener("input", narrow); let loading = null; picker.addEventListener("toggle", () => { if (!picker.open) return; filter.focus(); if (loading) return; loading = fetch(list.dataset.src) .then((response) => (response.ok ? response.text() : Promise.reject())) .then((text) => { const template = document.createElement("template"); template.innerHTML = text; list.replaceChildren(template.content); rows = [...list.children]; narrow(); }) .catch(() => { loading = null; }); }); document.addEventListener("click", (event) => { if (!picker.contains(event.target)) picker.open = false; }); picker.addEventListener("keydown", (event) => { if (event.key === "Escape") picker.open = false; }); filter.hidden = false; } // Files open in a viewer over the page, as in Kepr 2's Atlas: the file page's content with the // row's detail panel beside it, and controls for text size, wrapping, raw, download and the // neighbouring files. Escape closes it; the arrow keys step through the files. function fileViewer(root, jump, panelFor) { let [row, size, wrap] = [null, 12, false]; const overlay = element("div", "fv-overlay"); overlay.tabIndex = -1; overlay.setAttribute("role", "dialog"); overlay.setAttribute("aria-modal", "true"); const close = () => { overlay.remove(); row?.focus(); row = null; }; const step = (by) => { const rows = [...root.querySelectorAll(".atlas-file")].filter((file) => !file.hidden); const next = rows[rows.indexOf(row) + by]; if (next) open(next); }; const restyle = () => { overlay.style.setProperty("--fv-size", `${size}px`); overlay.classList.toggle("fv-wrap", wrap); }; const open = async (file, url = file.href) => { row = file; const rows = [...root.querySelectorAll(".atlas-file")].filter((each) => !each.hidden); const index = rows.indexOf(file); const path = file.dataset.path; const name = path.split("/").pop(); const raw = new URL(file.href); raw.pathname = raw.pathname.replace(/^(\/[^/]+)\/files\//, "$1/raw/"); const button = (text, action, className = "fv-ctrl-btn") => { const b = element("button", className, text); b.type = "button"; b.addEventListener("click", action); return b; }; const link = (text, download) => { const a = element("a", "fv-ctrl-btn", text); a.href = raw.href; if (download) a.download = name; return a; }; const source = element("div", "fv-source", "loading…"); const panel = element("aside", "fv-detail"); panelFor(path).then((content) => { if (row === file) panel.replaceChildren(content); }); const header = element("div", "fv-header"); const title = element("div", "fv-title"); title.append(element("span", "fv-name", name), element("span", "fv-path mono", path)); const controls = element("div", "fv-controls mono"); controls.append( button("A−", () => { size = Math.max(9, size - 1); restyle(); }), button("A+", () => { size = Math.min(20, size + 1); restyle(); }), button("⇌ wrap", (event) => { wrap = !wrap; event.currentTarget.classList.toggle("active", wrap); restyle(); }), link("raw", false), link("download", true), ); controls.children[2].classList.toggle("active", wrap); const nav = element("div", "fv-nav mono"); const back = button("←", () => step(-1), "fv-ctrl-btn"); const forward = button("→", () => step(1), "fv-ctrl-btn"); back.disabled = index <= 0; forward.disabled = index >= rows.length - 1; nav.append(back, element("span", "fv-pos", `${index + 1}/${rows.length}`), forward); header.append(button("×", close, "fv-close"), title, controls, nav); const body = element("div", "fv-body"); body.append(source, panel); overlay.replaceChildren(header, body); restyle(); if (!overlay.isConnected) document.body.append(overlay); overlay.focus(); const main = await fetchMain(url); if (row !== file) return; if (!main) { location.href = url; return; } for (const heading of main.querySelectorAll(".breadcrumb, .file-page-title")) heading.remove(); main.querySelector(".file-meta:not(:last-child)")?.remove(); source.replaceChildren(...main.childNodes); }; overlay.addEventListener("keydown", (event) => { if (event.key === "Escape") close(); else if (event.key === "ArrowLeft" || event.key === "ArrowRight") { event.preventDefault(); step(event.key === "ArrowLeft" ? -1 : 1); } }); // A map link beside the code opens that file here; a folder closes the viewer on its row. overlay.addEventListener("click", (event) => { const link = event.target.closest(".fv-detail a.atlas-jump"); if (!link || modified(event)) return; const path = link.dataset.path; const file = [...root.querySelectorAll(".atlas-file")].find((f) => f.dataset.path === path); event.preventDefault(); if (file) open(file); else { close(); jump(path); } }); // The Markdown view's rendered/source toggle points at the file page: switch in place. overlay.addEventListener("click", (event) => { const link = event.target.closest(".fv-source a[href]"); if (!link || !row || modified(event)) return; if (new URL(link.href).pathname !== new URL(row.href).pathname) return; event.preventDefault(); open(row, link.href); }); return open; } // On a phone the nav links fold behind a menu button (2026-10-01). The button only exists with // JavaScript, so without it the links stay in the bar; the CSS hides them only on `.nav--menu`. function navMenu() { const nav = document.querySelector("body > nav"); const links = nav?.querySelector(".nav-links"); if (!links) return; links.id = "nav-links"; const button = element("button", "nav-toggle"); button.type = "button"; button.setAttribute("aria-label", "menu"); button.setAttribute("aria-controls", "nav-links"); button.setAttribute("aria-expanded", "false"); button.append(element("span", ""), element("span", ""), element("span", "")); const open = () => nav.classList.contains("nav--open"); const set = (opened) => { nav.classList.toggle("nav--open", opened); button.setAttribute("aria-expanded", String(opened)); }; button.addEventListener("click", () => set(!open())); document.addEventListener("click", (event) => { if (open() && !nav.contains(event.target)) set(false); }); document.addEventListener("keydown", (event) => { if (event.key !== "Escape" || !open()) return; set(false); button.focus(); }); nav.classList.add("nav--menu"); // Before the links, so tabbing from the open button reaches them next. links.before(button); } function element(tag, className, text) { const node = document.createElement(tag); node.className = className; if (text !== undefined) node.textContent = text; return node; } // Signed in, a repo page's description and details are edited in place: Markdown with a toolbar // and a preview as the page will show it, pictures and videos picked, dropped or pasted in, and // the one under the caret sized and placed from a row of controls. The page's HTML is the same // for everyone; only the cookie signing in sets beside the session brings the controls. async function repoEditor() { const head = document.querySelector(".repo-head"); if (!head || !/(^|;\s*)kepr_editor=[0-9a-f]{64}/.test(document.cookie)) return; const base = `/admin/repos/${location.pathname.split("/")[1]}`; const response = await fetch(`${base}/text`).catch(() => null); if (!response?.ok) return; const texts = await response.json(); const details = document.querySelector(".repo-details"); const places = [ ["description", head.querySelector(".repo-desc"), head.querySelector(".repo-desc")], ["details", details, details.querySelector(".repo-details-body")], ]; for (const [field, section, body] of places) { section.hidden = false; if (field === "description") { section.tabIndex = 0; section.setAttribute("role", "button"); section.setAttribute("aria-label", "edit repository description"); section.classList.add("repo-desc-editable"); if (!section.textContent.trim()) section.textContent = "add a description"; const activate = (event) => { if (event.target.closest(".editor") || body.querySelector(".editor")) return; event.preventDefault(); editText(base, texts.form, field, body, texts[field]); }; section.addEventListener("click", activate); section.addEventListener("keydown", (event) => { if (event.target === section && (event.key === "Enter" || event.key === " ")) activate(event); }); continue; } const button = element("button", "edit-btn mono", "edit"); button.type = "button"; (section.querySelector(".repo-details-head") ?? section).append(button); button.addEventListener("click", (event) => { event.preventDefault(); if (!body.querySelector(".editor")) editText(base, texts.form, field, body, texts[field]); }); } } const MEDIA_MARKDOWN = /!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g; const MEDIA_TYPES = "image/png,image/jpeg,image/gif,image/webp,video/mp4,video/webm"; function editText(base, form, field, body, text) { const kept = [...body.childNodes]; if (field === "description") { body.removeAttribute("role"); body.removeAttribute("tabindex"); body.removeAttribute("aria-label"); } const input = element("textarea", "editor-input mono"); input.value = text; input.rows = field === "details" ? 16 : 3; const preview = element("div", `editor-preview${field === "details" ? " markdown" : ""}`); const media = element("div", "editor-media mono"); const status = element("span", "editor-status"); const post = (path, fields) => { const data = new FormData(); data.append("form", form); for (const [name, value] of Object.entries(fields)) data.append(name, value); return fetch(`${base}/${path}`, { method: "POST", body: data }); }; let timer = null; const changed = () => { inspect(); clearTimeout(timer); timer = setTimeout(async () => { const answer = await post("preview", { field, value: input.value }).catch(() => null); if (!answer?.ok) return; // Kepr's own Markdown rendering, raw HTML off: the same markup the page will carry. preview.innerHTML = input.value.trim() ? await answer.text() : ""; preview.dataset.empty = input.value.trim() ? "" : "the README's words show here"; }, 250); }; const wrap = (before, after = before, fallback = "text") => { const [from, to] = [input.selectionStart, input.selectionEnd]; const chosen = input.value.slice(from, to) || fallback; input.setRangeText(before + chosen + after, from, to); input.setSelectionRange(from + before.length, from + before.length + chosen.length); input.focus(); changed(); }; const prefix = (mark) => { const start = input.value.lastIndexOf("\n", input.selectionStart - 1) + 1; input.setRangeText(mark, start, start, "end"); input.focus(); changed(); }; const upload = async (file) => { status.textContent = `uploading ${file.name}…`; const answer = await post("media", { file }).catch(() => null); if (!answer?.ok) { status.textContent = answer ? await answer.text() : "upload failed"; return; } const { url } = await answer.json(); const alt = file.name.replace(/\.[^.]+$/, "").replace(/[[\]]/g, ""); input.setRangeText( `\n![${alt}](${url} "width=100 center")\n`, input.selectionStart, input.selectionEnd, "end", ); status.textContent = ""; input.focus(); changed(); }; const picker = element("input", ""); picker.type = "file"; picker.accept = MEDIA_TYPES; picker.hidden = true; picker.addEventListener("change", () => { for (const file of picker.files) upload(file); picker.value = ""; }); // The picture or video under the caret, with its options, as `![alt](url "options")`. const underCaret = () => [...input.value.matchAll(MEDIA_MARKDOWN)].find( (m) => input.selectionStart >= m.index && input.selectionStart <= m.index + m[0].length, ); function inspect() { const found = field === "details" ? underCaret() : undefined; media.hidden = !found; if (!found) return; const words = (found[3] ?? "").split(/\s+/).filter(Boolean); const width = words.find((word) => word.startsWith("width="))?.slice(6) ?? "100"; const align = words.find((word) => /^(left|center|right)$/.test(word)) ?? "center"; const loop = words.includes("loop"); const video = /\.(mp4|webm)$/i.test(found[2]); const set = (next) => { const options = [`width=${next.width}`, next.align, next.loop && "loop"].filter(Boolean); const markdown = `![${found[1]}](${found[2]} "${options.join(" ")}")`; const caret = input.selectionStart; input.setRangeText(markdown, found.index, found.index + found[0].length); input.setSelectionRange(caret, caret); changed(); }; const choice = (label, active, action) => { const b = element("button", `fv-ctrl-btn${active ? " active" : ""}`, label); b.type = "button"; b.addEventListener("click", action); return b; }; media.replaceChildren( element("span", "editor-label", video ? "video" : "picture"), ...["25", "33", "50", "66", "75", "100"].map((w) => choice(`${w}%`, w === width, () => set({ width: w, align, loop })), ), ...["left", "center", "right"].map((a) => choice(a, a === align, () => set({ width, align: a, loop })), ), ...(video ? [choice("loop like a GIF", loop, () => set({ width, align, loop: !loop }))] : []), ); } const save = async (value) => { status.textContent = "saving…"; const answer = await post("text", { field, value }).catch(() => null); if (answer?.ok) location.reload(); else status.textContent = answer ? await answer.text() : "not saved: offline?"; }; const cancel = () => { body.replaceChildren(...kept); if (field === "description") { body.tabIndex = 0; body.setAttribute("role", "button"); body.setAttribute("aria-label", "edit repository description"); body.focus(); } }; const tool = (label, title, action) => { const b = element("button", "fv-ctrl-btn", label); b.type = "button"; b.title = title; b.addEventListener("click", action); return b; }; const toolbar = element("div", "editor-toolbar mono"); toolbar.append( tool("B", "bold (⌘B)", () => wrap("**")), tool("I", "italic (⌘I)", () => wrap("_")), tool("link", "link (⌘K)", () => wrap("[", "](https://)")), tool("code", "code", () => wrap("`")), ); if (field === "details") { toolbar.append( tool("heading", "heading", () => prefix("## ")), tool("list", "list", () => prefix("- ")), tool("quote", "quote", () => prefix("> ")), tool("picture or video", "add a picture, GIF or video (or drop or paste one)", () => picker.click(), ), picker, ); input.addEventListener("dragover", (event) => event.preventDefault()); input.addEventListener("drop", (event) => { if (event.dataTransfer.files.length === 0) return; event.preventDefault(); for (const file of event.dataTransfer.files) upload(file); }); input.addEventListener("paste", (event) => { const files = [...event.clipboardData.files]; if (files.length === 0) return; event.preventDefault(); for (const file of files) upload(file); }); } input.addEventListener("input", changed); for (const name of ["click", "keyup"]) input.addEventListener(name, inspect); input.addEventListener("keydown", (event) => { const command = event.metaKey || event.ctrlKey; if (event.key === "Escape") cancel(); else if (command && event.key === "Enter") save(input.value); else if (command && event.key === "b") wrap("**"); else if (command && event.key === "i") wrap("_"); else if (command && event.key === "k") wrap("[", "](https://)"); else return; event.preventDefault(); }); const actions = element("div", "editor-actions mono"); actions.append( tool("save", "save (⌘↵)", () => save(input.value)), tool("cancel", "cancel (esc)", cancel), tool("use the README", "clear your words and show the README's again", () => save("")), status, ); const panes = element("div", "editor-panes"); panes.append(input, preview); const editor = element("div", `editor editor-${field}`); editor.append(toolbar, panes, media, actions); body.replaceChildren(editor); input.focus(); changed(); } // Storage can be missing, refused (private windows, blocked windows) or hold anything; only a // list of folder paths is trusted, and otherwise the tree starts closed. function remembered(key) { try { const value = JSON.parse(localStorage.getItem(key) ?? "[]"); return Array.isArray(value) && value.every((path) => typeof path === "string") ? value : []; } catch { return []; } } function remember(key, value) { try { localStorage.setItem(key, JSON.stringify(value)); } catch {} } function hexStream() { const backdrop = document.querySelector(".hex-backdrop"); if (!backdrop) return; let visible = true; const update = () => backdrop.classList.toggle("paused", !visible || document.hidden); new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; update(); }).observe(backdrop); document.addEventListener("visibilitychange", update); update(); } // A CSS duration cannot follow a track's width, so the pace would depend on the messages; set it // here so each row slides at its own --speed in px/s, as Kepr 2's canvas did. function tickerPace() { for (const track of document.querySelectorAll(".ticker-track")) { const speed = Number(getComputedStyle(track).getPropertyValue("--speed")); if (speed > 0) track.style.animationDuration = `${track.scrollWidth / 2 / speed}s`; } } // Widths are only final once Martian Mono has loaded. document.fonts.ready.then(tickerPace); timeline() .catch((error) => console.error("kepr: the timeline could not load", error)) .finally(() => document.documentElement.classList.remove("timeline-pending")); copyButtons(document); panels(); saveLists(); atlasPicker(); repoEditor(); navMenu(); lineRanges(); atlas(); hexStream();