lmjtfy.git / apps / lmjtfy / src / page.js
page.jsannotatedpage.jssource262 lines · 11.6 KB · raw

The LMGTFY part: a link with ?q= types the question into the box, then asks. Everything after the submit is the server's: Datastar morphs what it streams. 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));

Datastar loads as a module, after this script. When it binds the box it writes its (empty) signal into it, so anything typed before then is lost. 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 }));
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();

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})();

/live: the archive tells every open browser how many are open and where, and sends a toast when someone asks a question or clones the code. The socket is a SharedWorker's (/live.js), one for all this browser's tabs of this build; a browser without SharedWorker holds its own, per tab. Either 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})();

The copy buttons, on every page: data-copy names the element whose link 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})();

The dot fields behind the page, after typesafe.ai: soft blobs put through an 8x8 ordered dither, one image pixel per dot, tiled. Drawn once into CSS variables rather than into elements, because the transcript is replaced 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})();

A diagram or picture in a document opens full size on a click: mermaid's drawings and the rules' SVG are too small to read inline. Escape, the 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})();

What only the page knows, told to /seen (the archive keeps it as a read event): how long this page was in view, how far down it was seen, and the screen. It is said each time the page is put away, as the time in view since it last said, so a tab left open and come back to adds up. A 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})();