Spaces:
Running
Running
| (() => { | |
| document.addEventListener("DOMContentLoaded", () => { | |
| const bg = document.querySelector(".background"); | |
| // ---- Inject realistic clouds into .background ---- | |
| if (bg && !bg.querySelector(".cloud")) { | |
| const isMobile = window.matchMedia("(max-width:768px)").matches; | |
| if (!isMobile && !document.getElementById("cloud-fluff")) { | |
| const defs = document.createElementNS("http://www.w3.org/2000/svg", "svg"); | |
| defs.setAttribute("width", "0"); | |
| defs.setAttribute("height", "0"); | |
| defs.setAttribute("aria-hidden", "true"); | |
| defs.style.position = "absolute"; | |
| defs.innerHTML = | |
| '<filter id="cloud-fluff" x="-150%" y="-150%" width="400%" height="400%">' + | |
| '<feTurbulence type="fractalNoise" baseFrequency="0.012 0.02" numOctaves="4" seed="11" result="noise"/>' + | |
| '<feDisplacementMap in="SourceGraphic" in2="noise" scale="120"/>' + | |
| "</filter>"; | |
| document.body.appendChild(defs); | |
| } | |
| const cloudCount = isMobile ? 2 : 4; | |
| for (let i = 1; i <= cloudCount; i++) { | |
| const cloud = document.createElement("div"); | |
| cloud.className = "cloud cloud-" + i; | |
| cloud.setAttribute("aria-hidden", "true"); | |
| bg.appendChild(cloud); | |
| } | |
| } | |
| // ---- Grass strip along the bottom of the viewport ---- | |
| if (!document.querySelector(".grass")) { | |
| const NS = "http://www.w3.org/2000/svg"; | |
| const W = isMobile ? 1200 : 2400; | |
| const H = 70; | |
| const svg = document.createElementNS(NS, "svg"); | |
| svg.setAttribute("class", "grass"); | |
| svg.setAttribute("viewBox", "0 0 " + W + " " + H); | |
| svg.setAttribute("preserveAspectRatio", "xMidYMax slice"); | |
| svg.setAttribute("aria-hidden", "true"); | |
| // soft ground shading under the blades | |
| svg.innerHTML = | |
| '<defs><linearGradient id="grass-base" x1="0" y1="1" x2="0" y2="0">' + | |
| '<stop offset="0" stop-color="#3e7a46" stop-opacity="0.55"/>' + | |
| '<stop offset="1" stop-color="#3e7a46" stop-opacity="0"/>' + | |
| "</linearGradient></defs>" + | |
| '<rect x="0" y="' + (H - 16) + '" width="' + W + '" height="16" fill="url(#grass-base)"/>'; | |
| const isMobile = window.matchMedia("(max-width: 768px)").matches; | |
| const layers = isMobile | |
| ? [ | |
| { count: 50, hMin: 18, hMax: 32, colors: ["#4f9854"], opacity: 0.9 }, | |
| { count: 40, hMin: 24, hMax: 42, colors: ["#63ad64"], opacity: 1 }, | |
| ] | |
| : [ | |
| { count: 190, hMin: 18, hMax: 36, colors: ["#3e7a46","#457f4b","#4a8a50"], opacity: 0.8 }, | |
| { count: 160, hMin: 26, hMax: 52, colors: ["#4f9854","#57a05a","#63ad64","#6db36e"], opacity: 1 }, | |
| ]; | |
| const layerEls = []; | |
| layers.forEach((layer) => { | |
| const g = document.createElementNS(NS, "g"); | |
| g.setAttribute("opacity", layer.opacity); | |
| g.style.transformBox = "fill-box"; | |
| g.style.transformOrigin = "50% 100%"; | |
| layerEls.push(g); | |
| for (let i = 0; i < layer.count; i++) { | |
| const x = Math.random() * W; | |
| const h = layer.hMin + Math.random() * (layer.hMax - layer.hMin); | |
| const lean = (Math.random() - 0.5) * 16; | |
| const w = 3 + Math.random() * 3; | |
| const color = layer.colors[(Math.random() * layer.colors.length) | 0]; | |
| const blade = document.createElementNS(NS, "path"); | |
| blade.setAttribute( | |
| "d", | |
| "M " + x + " " + H + | |
| " Q " + (x + lean * 0.2) + " " + (H - h * 0.6) + " " + (x + lean) + " " + (H - h) + | |
| " Q " + (x + lean * 0.6 + w * 0.5) + " " + (H - h * 0.55) + " " + (x + w) + " " + H + " Z" | |
| ); | |
| blade.setAttribute("fill", color); | |
| blade.style.transformBox = "fill-box"; | |
| blade.style.transformOrigin = "50% 100%"; | |
| // stagger by x so a gust appears to travel across the lawn | |
| if (!isMobile) { | |
| blade.style.animation = | |
| "blade-sway " + (2.6 + Math.random() * 2.4).toFixed(2) + "s ease-in-out " + | |
| (-(x / W) * 3 - Math.random()).toFixed(2) + "s infinite alternate"; | |
| } | |
| g.appendChild(blade); | |
| } | |
| svg.appendChild(g); | |
| }); | |
| document.body.appendChild(svg); | |
| // ---- Scroll physics: scrolling down squashes the grass toward the | |
| // ground, scrolling up stretches it, and a damped spring makes it | |
| // boing back upright ---- | |
| if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) { | |
| let squash = 0; // offset from natural height (scaleY = 1 + squash) | |
| let squashVel = 0; | |
| let lastY = window.scrollY; | |
| let raf = null; | |
| const settle = () => { | |
| squashVel -= squash * 0.06; // spring pull toward natural height | |
| squashVel *= 0.88; // damping | |
| squash += squashVel; | |
| const s = Math.max(-0.3, Math.min(0.14, squash)); | |
| layerEls.forEach((g, i) => { | |
| // back layer reacts less than the front for depth | |
| const depth = i === 0 ? 0.55 : 1; | |
| g.style.transform = "scaleY(" + (1 + s * depth).toFixed(4) + ")"; | |
| }); | |
| if (Math.abs(squash) > 0.001 || Math.abs(squashVel) > 0.001) { | |
| raf = requestAnimationFrame(settle); | |
| } else { | |
| layerEls.forEach((g) => (g.style.transform = "")); | |
| raf = null; | |
| } | |
| }; | |
| const impulse = (v) => { | |
| squashVel += v; | |
| if (!raf) raf = requestAnimationFrame(settle); | |
| }; | |
| window.addEventListener( | |
| "scroll", | |
| () => { | |
| const y = window.scrollY; | |
| // scroll down -> stretch, scroll up -> duck | |
| impulse(Math.max(-0.08, Math.min(0.08, (y - lastY) * 0.0015))); | |
| lastY = y; | |
| }, | |
| { passive: true } | |
| ); | |
| // wheel fallback so short pages that can't scroll still react | |
| window.addEventListener( | |
| "wheel", | |
| (e) => { | |
| if (document.documentElement.scrollHeight <= window.innerHeight + 4) { | |
| impulse(Math.max(-0.05, Math.min(0.05, e.deltaY * 0.0005))); | |
| } | |
| }, | |
| { passive: true } | |
| ); | |
| } | |
| } | |
| // ---- Copy buttons on <pre> ---- | |
| document.querySelectorAll("pre").forEach((pre) => { | |
| if (pre.querySelector(".copy-btn")) return; | |
| const code = pre.querySelector("code") || pre; | |
| const btn = document.createElement("button"); | |
| btn.className = "copy-btn"; | |
| btn.textContent = "Copy"; | |
| btn.addEventListener("click", async () => { | |
| try { | |
| await navigator.clipboard.writeText(code.innerText); | |
| btn.textContent = "Copied!"; | |
| setTimeout(() => (btn.textContent = "Copy"), 1200); | |
| } catch { | |
| btn.textContent = "Error"; | |
| setTimeout(() => (btn.textContent = "Copy"), 1200); | |
| } | |
| }); | |
| pre.appendChild(btn); | |
| }); | |
| // ---- Article-only features: sidebar + collapsible code ---- | |
| const article = document.querySelector(".article"); | |
| if (!article) return; | |
| // Collect headings for TOC | |
| const headings = article.querySelectorAll("h1, h2, h3"); | |
| const codeBlocks = article.querySelectorAll("pre"); | |
| // Determine filenames from preceding h2 | |
| const codeFiles = []; | |
| codeBlocks.forEach((pre, idx) => { | |
| let name = null; | |
| let el = pre.previousElementSibling; | |
| while (el) { | |
| if (el.tagName === "H2" || el.tagName === "H3") { | |
| const text = el.textContent.trim(); | |
| if (text.match(/\.\w{1,5}$/) || text.match(/\.\w{1,5}\s/)) { | |
| name = text.replace(/\s*\(.*\)/, "").trim(); | |
| } | |
| break; | |
| } | |
| el = el.previousElementSibling; | |
| } | |
| if (!name) name = `code-block-${idx + 1}.txt`; | |
| codeFiles.push({ name, pre }); | |
| }); | |
| // Make code blocks collapsible | |
| codeBlocks.forEach((pre, idx) => { | |
| const wrapper = document.createElement("div"); | |
| wrapper.className = "code-collapsible"; | |
| const toggle = document.createElement("button"); | |
| toggle.className = "code-toggle"; | |
| const fileName = codeFiles[idx].name; | |
| toggle.innerHTML = `<span class="code-toggle-arrow">▶</span> <span class="code-toggle-name">${fileName}</span>`; | |
| toggle.setAttribute("aria-expanded", "false"); | |
| pre.parentNode.insertBefore(wrapper, pre); | |
| wrapper.appendChild(toggle); | |
| wrapper.appendChild(pre); | |
| pre.classList.add("collapsed"); | |
| toggle.addEventListener("click", () => { | |
| const expanded = pre.classList.toggle("collapsed"); | |
| toggle.setAttribute("aria-expanded", !expanded); | |
| toggle.querySelector(".code-toggle-arrow").textContent = expanded ? "▶" : "▼"; | |
| }); | |
| }); | |
| // Build sidebar | |
| const sidebar = document.createElement("nav"); | |
| sidebar.className = "article-sidebar"; | |
| // TOC section | |
| const tocTitle = document.createElement("div"); | |
| tocTitle.className = "sidebar-title"; | |
| tocTitle.textContent = "Contents"; | |
| sidebar.appendChild(tocTitle); | |
| const tocList = document.createElement("ul"); | |
| tocList.className = "sidebar-toc"; | |
| headings.forEach((h, i) => { | |
| if (!h.id) h.id = "heading-" + i; | |
| const li = document.createElement("li"); | |
| li.className = "toc-" + h.tagName.toLowerCase(); | |
| const a = document.createElement("a"); | |
| a.href = "#" + h.id; | |
| a.textContent = h.textContent; | |
| a.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| h.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| }); | |
| li.appendChild(a); | |
| tocList.appendChild(li); | |
| }); | |
| sidebar.appendChild(tocList); | |
| // Files section | |
| if (codeFiles.length > 0) { | |
| const filesTitle = document.createElement("div"); | |
| filesTitle.className = "sidebar-title"; | |
| filesTitle.textContent = "Files"; | |
| sidebar.appendChild(filesTitle); | |
| const fileList = document.createElement("ul"); | |
| fileList.className = "sidebar-files"; | |
| codeFiles.forEach(({ name, pre }) => { | |
| const li = document.createElement("li"); | |
| const btn = document.createElement("button"); | |
| btn.className = "sidebar-file-btn"; | |
| btn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg> ${name}`; | |
| btn.addEventListener("click", () => { | |
| const code = pre.querySelector("code") || pre; | |
| const text = code.innerText; | |
| const blob = new Blob([text], { type: "text/plain" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = name; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| }); | |
| li.appendChild(btn); | |
| fileList.appendChild(li); | |
| }); | |
| sidebar.appendChild(fileList); | |
| } | |
| document.body.appendChild(sidebar); | |
| // Highlight active TOC item on scroll | |
| const tocLinks = tocList.querySelectorAll("a"); | |
| const observer = new IntersectionObserver( | |
| (entries) => { | |
| entries.forEach((entry) => { | |
| if (entry.isIntersecting) { | |
| tocLinks.forEach((l) => l.classList.remove("active")); | |
| const link = tocList.querySelector(`a[href="#${entry.target.id}"]`); | |
| if (link) link.classList.add("active"); | |
| } | |
| }); | |
| }, | |
| { rootMargin: "-80px 0px -70% 0px" } | |
| ); | |
| headings.forEach((h) => observer.observe(h)); | |
| }); | |
| })(); | |