1// The LMGTFY part: a link with ?q= types the question into the box, then asks. 2// Everything after the submit is the server's: Datastar morphs what it streams. 3// Only the home page has the box; the code and rules pages do not. 4(() => { 5 const form = document.getElementById("ask"); 6 if (!form) return; 7 const box = document.getElementById("q"); 8 const question = form.dataset.type; 9 const calm = matchMedia("(prefers-reduced-motion: reduce)").matches; 10 11 // The box is a textarea that grows with the question. `field-sizing: content` 12 // does it where it is supported; elsewhere the height follows the text here. 13 const native = CSS.supports("field-sizing", "content"); 14 function grow() { 15 if (native) return; 16 box.style.height = "auto"; 17 box.style.height = `${box.scrollHeight + box.offsetHeight - box.clientHeight}px`; 18 } 19 box.addEventListener("input", grow); 20 form.addEventListener("submit", () => setTimeout(grow, 0)); 21 // A question is one line: Enter asks (Shift+Enter too), and a pasted line 22 // break becomes a space. Not while an IME is composing: Enter confirms there. 23 box.addEventListener("keydown", (event) => { 24 if (event.key !== "Enter" || event.isComposing) return; 25 event.preventDefault(); 26 form.requestSubmit(); 27 }); 28 box.addEventListener("paste", (event) => { 29 const text = event.clipboardData?.getData("text"); 30 if (text === undefined || !/[\r\n]/.test(text)) return; 31 event.preventDefault(); 32 box.setRangeText(text.replace(/\s*[\r\n]+\s*/g, " "), box.selectionStart, box.selectionEnd, "end"); 33 box.dispatchEvent(new Event("input", { bubbles: true })); 34 }); 35 const wait = (ms) => new Promise((done) => setTimeout(done, calm ? 0 : ms)); 36 37 // Datastar loads as a module, after this script. When it binds the box it 38 // writes its (empty) signal into it, so anything typed before then is lost. 39 // This listener is in place before the module runs, so it cannot miss the event. 40 const bound = new Promise((done) => document.addEventListener("datastar-ready", done, { once: true })); 41 42 async function type() { 43 await bound; 44 box.value = ""; 45 await wait(700); 46 for (const ch of question) { 47 box.value += ch; 48 // Datastar's bind listens for input: the signal follows the box. 49 box.dispatchEvent(new Event("input", { bubbles: true })); 50 await wait(45 + Math.random() * 70); 51 } 52 await wait(500); 53 form.requestSubmit(); 54 } 55 if (question) type(); 56 57 // What this page asked last, so the toast for it is not shown back to it. 58 form.addEventListener("submit", () => { window.lmjtfyAsked = box.value.trim(); }); 59})(); 60 61// `/live`: the archive tells every open browser how many are open and where, 62// and sends a toast when someone asks a question or clones the code. The 63// socket is a SharedWorker's (`/live.js`), one for all this browser's tabs of 64// this build; a browser without SharedWorker holds its own, per tab. Either 65// reconnects, waiting longer each time, if the socket drops. 66(() => { 67 const online = document.getElementById("online"); 68 const toasts = document.getElementById("toasts"); 69 if (!online || !toasts) return; 70 const mine = document.querySelector('meta[name="lmjtfy-build"]')?.content ?? ""; 71 function heard(text) { 72 let live; 73 try { live = JSON.parse(text); } catch { return; } 74 if (typeof live.online === "number") { 75 online.textContent = `${live.online} online`; 76 online.hidden = false; 77 } 78 if (live.closed) online.hidden = true; 79 if (typeof live.toast === "string") toast(live.toast); 80 if (typeof live.patch === "string") patch(live.patch); 81 if (typeof live.build === "string") built(live.build); 82 } 83 function shared() { 84 const worker = new SharedWorker(`/live.js?build=${encodeURIComponent(mine)}`, { name: `lmjtfy-live-${mine}` }); 85 worker.port.addEventListener("message", (event) => heard(event.data)); 86 worker.port.start(); 87 addEventListener("pagehide", () => worker.port.postMessage("bye"), { once: true }); 88 } 89 let wait = 1000; 90 function connect() { 91 // The page's build, so the archive tells only an earlier page what a 92 // deploy changed. 93 const socket = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/live?build=${encodeURIComponent(mine)}`); 94 socket.addEventListener("open", () => { wait = 1000; }); 95 socket.addEventListener("message", (event) => heard(event.data)); 96 socket.addEventListener("close", () => { 97 online.hidden = true; 98 setTimeout(connect, wait); 99 wait = Math.min(wait * 2, 60000); 100 }); 101 } 102 function start() { 103 if (typeof SharedWorker === "function") { 104 try { shared(); return; } catch { /* a browser that refuses it: hold our own */ } 105 } 106 connect(); 107 } 108 // Back from the back-forward cache: the worker forgot this tab when it 109 // said goodbye, so it joins again. 110 addEventListener("pageshow", (event) => { if (event.persisted && typeof SharedWorker === "function") shared(); }); 111 // The site was deployed since this page was rendered: a toast that stays 112 // until it is used or dismissed, once. 113 let offered = false; 114 function built(build) { 115 if (!mine || build === mine || offered) return; 116 offered = true; 117 const shown = document.createElement("div"); 118 shown.className = "toast stays"; 119 shown.innerHTML = 'A new version of the site is live. <button type="button" class="reload">Reload</button> <button type="button" class="ghost dismiss" aria-label="Dismiss">×</button>'; 120 shown.querySelector(".reload").addEventListener("click", () => location.reload()); 121 shown.querySelector(".dismiss").addEventListener("click", () => shown.remove()); 122 toasts.append(shown); 123 } 124 // Each element sent replaces this page's element with the same id, if it 125 // has one: the feeds on the home page, and whatever else is sent this way. 126 // One with `data-prepend` puts its children at the top of the element it 127 // names instead, each taking the place of its own id wherever that was, 128 // so what this page has scrolled in further down stays. 129 function patch(html) { 130 const holder = document.createElement("template"); 131 holder.innerHTML = html; 132 for (const fresh of [...holder.content.children]) { 133 const into = fresh.dataset.prepend && document.getElementById(fresh.dataset.prepend); 134 if (into) { 135 for (const line of [...fresh.children].reverse()) { 136 if (line.id) document.getElementById(line.id)?.remove(); 137 into.prepend(line); 138 } 139 } else if (fresh.id) { 140 document.getElementById(fresh.id)?.replaceWith(fresh); 141 } 142 } 143 } 144 // The markup is the server's, escaped there (view/toast.rs). 145 function toast(html) { 146 const holder = document.createElement("div"); 147 holder.innerHTML = html; 148 const shown = holder.firstElementChild; 149 if (!shown || (shown.dataset.q && shown.dataset.q === window.lmjtfyAsked)) return; 150 toasts.append(shown); 151 // The oldest passing toasts go first; one that stays is never pushed out. 152 const passing = [...toasts.children].filter((toast) => !toast.classList.contains("stays")); 153 while (passing.length > 3) passing.shift().remove(); 154 setTimeout(() => { shown.classList.add("gone"); setTimeout(() => shown.remove(), 400); }, 7000); 155 } 156 start(); 157})(); 158 159// The copy buttons, on every page: `data-copy` names the element whose link 160// or text is copied. 161(() => { 162 document.addEventListener("click", (event) => { 163 const button = event.target.closest("[data-copy]"); 164 if (!button) return; 165 const link = document.getElementById(button.dataset.copy); 166 navigator.clipboard.writeText(link.href ?? link.textContent).then( 167 () => { button.textContent = "Copied"; }, 168 () => { getSelection().selectAllChildren(link); button.textContent = "Selected, press Ctrl+C"; } 169 ); 170 }); 171})(); 172 173// The dot fields behind the page, after typesafe.ai: soft blobs put through an 174// 8x8 ordered dither, one image pixel per dot, tiled. Drawn once into CSS 175// variables rather than into elements, because the transcript is replaced 176// wholesale and a canvas inside it would be wiped with it. 177(() => { 178 const BAYER = [ 179 [0, 32, 8, 40, 2, 34, 10, 42], [48, 16, 56, 24, 50, 18, 58, 26], 180 [12, 44, 4, 36, 14, 46, 6, 38], [60, 28, 52, 20, 62, 30, 54, 22], 181 [3, 35, 11, 43, 1, 33, 9, 41], [51, 19, 59, 27, 49, 17, 57, 25], 182 [15, 47, 7, 39, 13, 45, 5, 37], [63, 31, 55, 23, 61, 29, 53, 21], 183 ]; 184 const W = 400, H = 300; 185 // A fixed seed: the same field on every load. 186 const random = (seed) => () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296); 187 188 function field(seed, count, strength, [r, g, b]) { 189 const next = random(seed); 190 const blobs = Array.from({ length: count }, () => ({ 191 x: next() * W, y: next() * H, size: 16 + next() * 30, weight: (0.5 + next() * 0.5) * strength, 192 })); 193 const canvas = document.createElement("canvas"); 194 canvas.width = W; canvas.height = H; 195 const context = canvas.getContext("2d"); 196 const image = context.createImageData(W, H); 197 for (let y = 0; y < H; y++) { 198 for (let x = 0; x < W; x++) { 199 let value = 0; 200 for (const blob of blobs) { 201 // Distances wrap, so the tile has no seam. 202 const dx = Math.min(Math.abs(x - blob.x), W - Math.abs(x - blob.x)); 203 const dy = Math.min(Math.abs(y - blob.y), H - Math.abs(y - blob.y)); 204 value += blob.weight * Math.exp(-(dx * dx + dy * dy) / (2 * blob.size * blob.size)); 205 } 206 if (value > (BAYER[y % 8][x % 8] + 0.5) / 64) { 207 const at = (y * W + x) * 4; 208 image.data[at] = r; image.data[at + 1] = g; image.data[at + 2] = b; image.data[at + 3] = 255; 209 } 210 } 211 } 212 context.putImageData(image, 0, 0); 213 return `url(${canvas.toDataURL()})`; 214 } 215 216 const root = document.documentElement.style; 217 root.setProperty("--dither-paper", field(7, 4, 0.55, [196, 196, 196])); 218 root.setProperty("--dither-pink", field(23, 5, 0.9, [30, 30, 30])); 219})(); 220 221// A diagram or picture in a document opens full size on a click: mermaid's 222// drawings and the rules' SVG are too small to read inline. Escape, the 223// close button or a click on the backdrop closes it. 224(() => { 225 let dialog; 226 function open(figure) { 227 if (!dialog) { 228 dialog = document.createElement("dialog"); 229 dialog.className = "zoom"; 230 dialog.innerHTML = '<button type="button" class="ghost close">Close</button><div class="zoomed"></div>'; 231 dialog.addEventListener("click", (event) => { 232 if (event.target === dialog || event.target.closest(".close")) dialog.close(); 233 }); 234 document.body.append(dialog); 235 } 236 const zoomed = dialog.querySelector(".zoomed"); 237 const copy = figure.cloneNode(true); 238 copy.removeAttribute("style"); 239 copy.removeAttribute("width"); 240 copy.removeAttribute("height"); 241 zoomed.replaceChildren(copy); 242 dialog.showModal(); 243 } 244 document.addEventListener("click", (event) => { 245 const figure = event.target.closest(".md pre.mermaid svg, .md img"); 246 if (figure && !event.target.closest("dialog")) open(figure); 247 }); 248})(); 249 250// What only the page knows, told to `/seen` (the archive keeps it as a 251// `read` event): how long this page was in view, how far down it was seen, 252// and the screen. It is said each time the page is put away, as the time in 253// view since it last said, so a tab left open and come back to adds up. A 254// link followed off the site is said as it is followed (`out`). 255(() => { 256 // The code host has no `/seen`, and its pages have no `#online` (nav). 257 if (!navigator.sendBeacon || !document.getElementById("online")) return; 258 let since = document.visibilityState === "visible" ? performance.now() : null; 259 let seen = 0; 260 let deepest = 0; 261 // The home page scrolls the window; a code page scrolls its pane. 262 const measure = () => { 263 const pane = document.querySelector("main.pane"); 264 const top = pane ? pane.scrollTop : scrollY; 265 const tall = pane ? pane.scrollHeight : document.documentElement.scrollHeight; 266 const high = pane ? pane.clientHeight : innerHeight; 267 if (tall > 0) deepest = Math.max(deepest, Math.min(1, (top + high) / tall)); 268 }; 269 const say = (more) => navigator.sendBeacon("/seen", JSON.stringify({ 270 path: location.pathname, query: location.search, ms: 0, scroll: deepest, 271 screen: `${screen.width}x${screen.height}`, viewport: `${innerWidth}x${innerHeight}`, ...more, 272 })); 273 addEventListener("scroll", measure, { passive: true, capture: true }); 274 document.addEventListener("visibilitychange", () => { 275 if (document.visibilityState === "visible") { since = performance.now(); return; } 276 if (since === null) return; 277 measure(); 278 seen = performance.now() - since; 279 since = null; 280 // A glance of under a second is not a reading. 281 if (seen >= 1000) say({ ms: Math.round(seen) }); 282 }); 283 document.addEventListener("click", (event) => { 284 const link = event.target.closest && event.target.closest("a[href]"); 285 if (link && link.host && link.host !== location.host && /^https?:$/.test(link.protocol)) say({ out: link.href }); 286 }, { capture: true }); 287 measure(); 288})();