lmjtfy.git / apps / lmjtfy / src / page.js
page.jsannotatedpage.jssource288 lines · 12.8 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;

The box is a textarea that grows with the question. field-sizing: content 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));

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.

40  const bound = new Promise((done) => document.addEventListener("datastar-ready", done, { once: true }));
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();

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

/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.

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

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

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.

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

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.

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

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).

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