// 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 glow. // 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. function timeline() { const svg = document.querySelector("svg.timeline"); if (!svg) return; const css = getComputedStyle(document.documentElement); const saves = [...svg.querySelectorAll(".dot")] .map((dot) => { const [id, message, meta] = (dot.querySelector("title")?.textContent ?? "").split("\n"); const [, color, alpha] = KINDS.find(([kind]) => kind.test(message)) ?? [ null, "text-secondary", 0.8, ]; const fill = css.getPropertyValue(`--${color}`).trim(); return { href: dot.getAttribute("href"), id, message, meta, fill, alpha, t: +dot.dataset.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; requestAnimationFrame(draw); }; 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(); requestAnimationFrame(draw); } 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 waits in the