lmjtfy.git / apps / lmjtfy / src / page.js
page.jsannotatedpage.jssource262 lines · 11.6 KB · raw
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})();