// Kepr's browser islands (§18). Every page is complete without this file; it only adds the // styled timeline tooltip, copy buttons, line-range highlighting, the Atlas panel and filter, // and the profile backdrop's glow. function timelineTips() { const dots = document.querySelectorAll(".timeline .dot"); if (dots.length === 0) return; const tip = document.createElement("div"); tip.className = "timeline-tip"; tip.hidden = true; document.body.append(tip); for (const dot of dots) { // The SVG is the no-JavaScript tooltip; lift it out so the two never show together. const title = dot.querySelector("title"); const [id, message, meta] = (title?.textContent ?? "").split("\n"); title?.remove(); dot.setAttribute("aria-label", `${id} ${message}`); dot.addEventListener("pointerenter", (event) => { tip.replaceChildren(line("tip-id", id), line("tip-msg", message), line("tip-meta", meta)); tip.hidden = false; const left = event.clientX + 14; tip.style.left = `${left + 340 > window.innerWidth ? Math.max(8, event.clientX - 354) : left}px`; tip.style.top = `${event.clientY - 10}px`; }); dot.addEventListener("pointerleave", () => { tip.hidden = true; }); } } function line(className, text) { const div = document.createElement("div"); div.className = className; div.textContent = text ?? ""; return div; } function copyButtons() { for (const button of document.querySelectorAll(".copy-btn[data-copy]")) { button.hidden = false; button.addEventListener("click", async () => { await navigator.clipboard.writeText(button.dataset.copy); button.textContent = "copied"; button.classList.add("copied"); setTimeout(() => { button.textContent = "copy"; button.classList.remove("copied"); }, 1400); }); } } // `#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 <template> after it; 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")]; const key = `kepr:atlas:${root.dataset.repo}`; const opened = new Set(remembered(key)); for (const folder of folders) { folder.open = opened.has(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]); }); } let selected = null; const show = (row) => { selected?.classList.remove("selected"); selected = row; row.classList.add("selected"); panel.replaceChildren(row.nextElementSibling.content.cloneNode(true)); }; for (const row of root.querySelectorAll(".atlas-row")) { row.addEventListener("pointerenter", (event) => { if (event.pointerType === "mouse") show(row); }); row.addEventListener("focus", () => show(row)); } 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); } }); filter.hidden = false; panel.hidden = false; } // 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 {} } // The profile's hex backdrop already breathes in CSS; this lights a few columns at a time, as // Kepr 2's canvas did. Nothing moves under reduced motion, including when it is switched on later. function hexGlow() { const columns = document.querySelectorAll(".hex-col"); if (columns.length === 0) return; const reduce = matchMedia("(prefers-reduced-motion: reduce)"); let timer = null; const update = () => { clearInterval(timer); for (const column of columns) column.classList.remove("glow"); if (reduce.matches) return; timer = setInterval(() => { const column = columns[Math.floor(Math.random() * columns.length)]; if (document.querySelectorAll(".hex-col.glow").length >= 3) return; column.classList.add("glow"); setTimeout(() => column.classList.remove("glow"), 3200); }, 1500); }; reduce.addEventListener("change", update); update(); } timelineTips(); copyButtons(); lineRanges(); atlas(); hexGlow();