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