repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

23.5 KB · 666 lines · JavaScript Raw History
  1const transcript = document.getElementById("transcript");
  2const form = document.getElementById("ask");
  3const input = document.getElementById("q");
  4const go = document.getElementById("go");
  5const archive = document.getElementById("archive");
  6
  7// Rebuilt rather than assigned as text, since the counts carry their own
  8// element so the number reads brighter than the word beside it.
  9const group = (n) => n.toLocaleString("en-US");
 10function paintArchive(pages, chunks) {
 11  archive.replaceChildren();
 12  const put = (tag, text) => {
 13    const el = document.createElement(tag);
 14    el.textContent = text;
 15    archive.appendChild(el);
 16  };
 17  put("b", group(pages));
 18  archive.append(" pages \u00b7 ");
 19  put("b", group(chunks));
 20  archive.append(" passages");
 21}
 22const followhint = document.getElementById("followhint");
 23const sid = document.body.dataset.session;
 24const gauge = document.getElementById("gauge");
 25const segs = document.getElementById("segs");
 26const gtext = document.getElementById("gtext");
 27const capnote = document.getElementById("capnote");
 28
 29const SEGMENTS = 8;
 30for (let i = 0; i < SEGMENTS; i++) segs.appendChild(el("span", "seg on"));
 31let turns = 0;
 32let stream = null;
 33// Following is the default after an answer, because a follow-up is the common
 34// next move. Starting fresh has to be one obvious click, not a subtle control
 35// in the corner.
 36let following = false;
 37
 38// Each pipeline step gets a human label. The step names come off the server so
 39// a new one still shows, just without a friendly name.
 40const STEPS = {
 41  followup: "Reading the previous answer",
 42  plan: "Working out what to search for",
 43  search: "Searching the web",
 44  fetch: "Reading pages",
 45  write: "Writing the answer",
 46  check: "Checking every sentence",
 47  retry: "That did not hold up, searching again",
 48  calc: "Working it out",
 49  skill: "Reading a live source",
 50  code: "Checking the code",
 51};
 52
 53// Why each step takes the time it does. A pause with no explanation reads as a
 54// hang, and two of these pauses are deliberate.
 55const WHY = {
 56  search: "Searches are spaced about a second apart so the sources keep answering.",
 57  fetch: "Each page is downloaded and stripped down to its article text.",
 58  write: "The model is running locally on the GPU.",
 59  check: "Every sentence is checked against the passage it cites, which is one model call each.",
 60  skill: "This one has a live source, so it does not need a web search.",
 61  retry: "Most of that answer was not backed by the pages found, so it is trying different searches.",
 62  code: "Every file is parsed and every package it imports is looked up in its registry.",
 63};
 64
 65// Search capacity is shown before it runs out, because hitting a wall with no
 66// warning reads as the tool being broken rather than as a limit being reached.
 67// The engine is not named: what matters to a reader is how much is left.
 68function paintBudget(b) {
 69  if (!b) return;
 70  const lit = Math.ceil((SEGMENTS * b.left) / b.max);
 71  [...segs.children].forEach((s, i) => s.classList.toggle("on", i < lit));
 72
 73  gauge.classList.toggle("low", b.questions <= 1 && !b.cooling && b.left > 0);
 74  gauge.classList.toggle("out", b.left === 0 || b.cooling);
 75
 76  if (b.cooling) gtext.textContent = `easing ${b.resetIn}s`;
 77  else if (b.left === 0) gtext.textContent = `resting ${b.resetIn}s`;
 78  else gtext.textContent = `${b.questions} question${b.questions === 1 ? "" : "s"}`;
 79
 80  if (b.cooling || b.left === 0) {
 81    capnote.textContent =
 82      "Searching is resting so the sources keep answering. Anything already read is still available, so a follow-up on this page still works.";
 83    capnote.hidden = false;
 84  } else if (b.questions <= 1) {
 85    capnote.textContent = "Close to the search allowance, so the next few questions may pause between searches.";
 86    capnote.hidden = false;
 87  } else {
 88    capnote.hidden = true;
 89  }
 90}
 91
 92async function refreshBudget() {
 93  try {
 94    paintBudget(await (await fetch("/budget")).json());
 95  } catch {}
 96}
 97setInterval(refreshBudget, 5000);
 98refreshBudget();
 99
100// Focus is a desktop convenience and a mobile annoyance: on a phone it opens the
101// keyboard immediately and takes half the screen, and tapping the box is no
102// hardship. A coarse pointer with no hover is the honest test for that, rather
103// than a width, since a narrow desktop window still wants the focus.
104const wantsFocus = window.matchMedia("(hover: hover) and (pointer: fine)").matches;
105
106function focusInput() {
107  if (wantsFocus) input.focus({ preventScroll: true });
108}
109
110function el(tag, cls, text) {
111  const n = document.createElement(tag);
112  if (cls) n.className = cls;
113  if (text !== undefined) n.textContent = text;
114  return n;
115}
116
117// While a question is running, keep the working panel in view. Once the answer
118// lands, go to the top of that turn instead: scrolling to the bottom of the
119// page lands on the sources and passages, which means the answer itself is the
120// one thing you cannot see.
121function scrollToBottom() {
122  window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
123}
124
125function scrollToTurn(turn) {
126  // The header and the field are both pinned, so a turn scrolled to the top of
127  // the viewport would sit behind them.
128  const bar = document.querySelector(".bar").offsetHeight;
129  const askH = document.querySelector(".askwrap").offsetHeight;
130  const top = window.scrollY + turn.getBoundingClientRect().top - bar - askH - 12;
131  window.scrollTo({ top: Math.max(top, 0), behavior: "smooth" });
132}
133
134form.addEventListener("submit", (e) => {
135  e.preventDefault();
136  const q = input.value.trim();
137  if (!q || stream) return;
138  ask(q);
139});
140
141// The empty state is kept so "new" can put it back. Starting over should look
142// like arriving, not like a blank page with the examples gone until a reload.
143const introHTML = transcript.innerHTML;
144
145document.getElementById("reset").addEventListener("click", newQuestion);
146
147// The example buttons. People cannot use capabilities they do not know are
148// there, and a blank box tells them nothing. Bound by delegation so they keep
149// working after the empty state is restored.
150transcript.addEventListener("click", (e) => {
151  const b = e.target.closest(".skill");
152  if (!b || stream) return;
153  ask(b.dataset.q);
154});
155
156// newQuestion returns the page to how it looked on arrival: the blurb, the
157// examples, today's context, and an empty focused field. Both the control in
158// the header and the one under an answer do this, because they mean the same
159// thing and behaving differently was only confusing.
160async function newQuestion() {
161  if (stream) return;
162  await fetch(`/reset?sid=${sid}`, { method: "POST" });
163  following = false;
164  turns = 0;
165  transcript.innerHTML = introHTML;
166  document.title = "Ask \u00b7 search";
167  input.value = "";
168  setMode();
169  window.scrollTo({ top: 0, behavior: "smooth" });
170  focusInput();
171}
172
173// setMode keeps the input honest about what the next question will do.
174function setMode() {
175  input.placeholder = following ? "Ask a follow-up" : "Ask a question";
176  form.classList.toggle("followon", following);
177  followhint.hidden = !following;
178}
179
180// The tab title carries the question, so two open on a phone are tellable
181// apart and the browser history reads as a list of what was asked.
182function setTitle(q, state) {
183  const short = q.length > 52 ? q.slice(0, 52).trimEnd() + "\u2026" : q;
184  document.title = state ? `${state} ${short}` : `${short} \u00b7 search`;
185}
186
187const incogBtn = document.getElementById("incog");
188const incogNote = document.getElementById("incognote");
189let incognito = false;
190try {
191  incognito = localStorage.getItem("incognito") === "1";
192} catch (e) {
193  incognito = false;
194}
195
196// Incognito is a mode rather than a per-question checkbox, so it has to be
197// visible without looking for it. The whole page carries it: the toggle lights
198// up, the note under the box says what is and is not kept, and every answer
199// asked in it is tagged and dashed.
200function paintIncognito() {
201  document.body.classList.toggle("incognito", incognito);
202  if (incogBtn) incogBtn.setAttribute("aria-pressed", incognito ? "true" : "false");
203  if (incogNote) incogNote.hidden = !incognito;
204  try {
205    localStorage.setItem("incognito", incognito ? "1" : "0");
206  } catch (e) {
207    /* a private window is exactly where this would throw, and losing the
208       setting there is the safe direction to fail */
209  }
210}
211if (incogBtn) {
212  incogBtn.addEventListener("click", () => {
213    incognito = !incognito;
214    paintIncognito();
215    focusInput();
216  });
217}
218paintIncognito();
219
220function ask(q) {
221  setTitle(q, "\u25cf");
222  input.value = "";
223  go.disabled = true;
224  input.disabled = true;
225
226  // The examples are for an empty box, so they go once anything is asked.
227  const intro = transcript.querySelector(".intro .skills");
228  if (intro) intro.remove();
229
230  const turn = el("article", "turn");
231  if (incognito) turn.classList.add("incog");
232  turn.appendChild(el("div", "question", q));
233
234  const status = el("div", "status");
235  const head = el("div", "stephead");
236  const spinner = el("span", "spinner");
237  const label = el("span", "steptext", "Starting");
238  const clock = el("span", "clock", "0s");
239  head.append(spinner, label, clock);
240  status.appendChild(head);
241  const why = el("div", "why");
242  status.appendChild(why);
243  const trail = el("ul", "trail");
244  status.appendChild(trail);
245  turn.appendChild(status);
246
247  const began = Date.now();
248  const ticking = setInterval(() => {
249    clock.textContent = `${Math.round((Date.now() - began) / 1000)}s`;
250  }, 1000);
251
252  transcript.appendChild(turn);
253  scrollToTurn(turn);
254
255  stream = new EventSource(
256    `/stream?q=${encodeURIComponent(q)}&sid=${sid}${incognito ? "&incognito=1" : ""}`);
257  let lastStep = "";
258
259  // Waiting for the people ahead. One question runs at a time, and a page that
260  // sits silent for ninety seconds looks broken rather than patient.
261  stream.addEventListener("queued", (ev) => {
262    const q = JSON.parse(ev.data);
263    status.classList.add("waiting");
264    label.textContent = q.ahead === 0
265      ? "Next in line"
266      : `Waiting, ${q.ahead} question${q.ahead === 1 ? "" : "s"} ahead`;
267    why.textContent =
268      "One question runs at a time, because they share a single graphics card. This starts as soon as the one ahead finishes.";
269    trail.innerHTML = "";
270    const li = el("li", null, `position ${q.position} of ${q.total}`);
271    trail.appendChild(li);
272  });
273
274  stream.addEventListener("status", (ev) => {
275    status.classList.remove("waiting");
276    const d = JSON.parse(ev.data);
277    label.textContent = STEPS[d.step] || d.step;
278    why.textContent = WHY[d.step] || "";
279    if (d.detail) {
280      // One line per step, with later detail for the same step replacing the
281      // previous line rather than stacking a line per fetched page.
282      let li = lastStep === d.step ? trail.lastElementChild : null;
283      if (!li) {
284        li = el("li");
285        trail.appendChild(li);
286      }
287      li.textContent = d.detail;
288      lastStep = d.step;
289    }
290    // Only chase the status panel when it has been pushed out of sight.
291    const box = status.getBoundingClientRect();
292    if (box.bottom > window.innerHeight) scrollToBottom();
293  });
294
295  stream.addEventListener("answer", (ev) => {
296    const d = JSON.parse(ev.data);
297    status.remove();
298    turn.appendChild(renderAnswer(d));
299    if (d.pages !== undefined) {
300      paintArchive(d.pages, d.chunks);
301    }
302    paintBudget(d.budget);
303    finish(turn);
304  });
305
306  stream.addEventListener("failed", (ev) => {
307    const d = JSON.parse(ev.data);
308    status.remove();
309    turn.appendChild(el("p", "error", d.error));
310    finish(turn);
311  });
312
313  stream.onerror = () => {
314    if (!stream) return;
315    status.remove();
316    if (!turn.querySelector(".answer")) {
317      turn.appendChild(el("p", "error", "The connection dropped before an answer arrived."));
318    }
319    finish(turn);
320  };
321
322  function finish(turn) {
323    setTitle(q, "");
324    clearInterval(ticking);
325    if (stream) stream.close();
326    stream = null;
327    go.disabled = false;
328    input.disabled = false;
329    turns++;
330    following = true;
331    setMode();
332    focusInput();
333    scrollToTurn(turn);
334  }
335}
336
337// A code answer is pasted rather than read, so every block gets a header
338// carrying what it is and a button that copies it. The file name the model
339// wrote above the block moves into that header, since leaving it in the prose
340// says the same thing twice.
341function decorateCode(body, checks) {
342  body.querySelectorAll("pre").forEach((pre, i) => {
343    const code = pre.querySelector("code");
344    const cls = code ? [...code.classList].find((c) => c.startsWith("language-")) : null;
345    const lang = cls ? cls.slice(9) : "";
346
347    // The blocks come back in the order they were checked, so the name the
348    // server read off the block is the name for this one.
349    let name = (checks[i] && checks[i].File) || "";
350    const above = pre.previousElementSibling;
351    if (above && above.tagName === "P" && above.children.length === 1 && above.firstElementChild.tagName === "STRONG") {
352      const text = above.textContent.trim();
353      const bare = text.replace(/^(?:file|filename)\s*:\s*/i, "");
354      if (/^[\w./-]+\.[A-Za-z0-9]{1,10}$|^(Dockerfile|Makefile|docker-compose\.ya?ml|\.env)$/.test(bare)) {
355        name = bare;
356        above.remove();
357      }
358    }
359
360    const box = el("div", "codeblock");
361    pre.parentNode.insertBefore(box, pre);
362    const head = el("div", "codehead");
363    head.appendChild(el("span", "codename", name || lang || "code"));
364    if (name && lang) head.appendChild(el("span", "codelang", lang));
365    head.appendChild(el("span", "spacer"));
366
367    const copy = el("button", "copy", "copy");
368    copy.addEventListener("click", async () => {
369      const text = (code || pre).textContent;
370      try {
371        await navigator.clipboard.writeText(text);
372      } catch {
373        // Clipboard access needs a secure context, and a selection is the
374        // fallback that works everywhere: the reader still gets one keystroke.
375        const range = document.createRange();
376        range.selectNodeContents(code || pre);
377        const sel = window.getSelection();
378        sel.removeAllRanges();
379        sel.addRange(range);
380      }
381      copy.textContent = "copied";
382      copy.classList.add("done");
383      setTimeout(() => {
384        copy.textContent = "copy";
385        copy.classList.remove("done");
386      }, 1400);
387    });
388    head.appendChild(copy);
389    box.appendChild(head);
390    box.appendChild(pre);
391  });
392}
393
394// What was actually done to the code, said plainly. Parsing a file and looking
395// a package up in its registry are the two checks worth anything here, and
396// neither of them is running it, so the panel says which is which.
397function renderCodeChecks(checks, deps) {
398  if (!checks.length && !deps.length) return null;
399  const sec = el("section", "codecheck");
400  const head = el("div", "vhead");
401  head.appendChild(el("span", "label", "code"));
402  const bad = checks.filter((c) => !c.OK).length;
403  const missing = deps.filter((d) => d.Checked && !d.Found).length;
404  const parts = [];
405  if (checks.length) parts.push(`${checks.length - bad}/${checks.length} block${checks.length === 1 ? "" : "s"} read cleanly`);
406  if (deps.length) parts.push(`${deps.filter((d) => d.Found).length}/${deps.length} package${deps.length === 1 ? "" : "s"} exist`);
407  head.appendChild(el("span", bad || missing ? "score bad" : "score", parts.join(" \u00b7 ")));
408  sec.appendChild(head);
409
410  if (checks.length) {
411    const ul = el("ul", "checks");
412    checks.forEach((c) => {
413      const li = el("li", c.OK ? "ok" : "bad");
414      li.appendChild(el("span", "verdict", c.OK ? "checked" : "broken"));
415      const s = el("span", "sentence");
416      s.appendChild(el("span", "stext", `${c.File || c.Lang} \u00b7 ${c.Lines} line${c.Lines === 1 ? "" : "s"}`));
417      s.appendChild(el("span", "note", c.Note));
418      li.appendChild(s);
419      ul.appendChild(li);
420    });
421    sec.appendChild(ul);
422  }
423
424  if (deps.length) {
425    const ul = el("ul", "deps");
426    deps.forEach((d) => {
427      const li = el("li", d.Checked ? (d.Found ? "ok" : "bad") : "unknown");
428      li.appendChild(el("span", "depname", d.Name));
429      li.appendChild(el("span", "depeco", ECOS[d.Eco] || d.Eco));
430      li.appendChild(el("span", "depstate", !d.Checked ? "not reached" : d.Found ? "exists" : "no such package"));
431      ul.appendChild(li);
432    });
433    sec.appendChild(ul);
434    if (missing) {
435      sec.appendChild(el("p", "faint explain",
436        "A package that is not in its registry is one the model invented, so the install line will fail whatever else the code does."));
437    }
438  }
439  return sec;
440}
441
442const ECOS = { pypi: "PyPI", npm: "npm", go: "Go modules", docker: "Docker Hub" };
443
444function renderAnswer(d) {
445  const wrap = el("div", "answerwrap");
446
447  const meta = el("div", "meta");
448  meta.appendChild(el("span", "label", d.skill || d.shape));
449  meta.appendChild(el("span", "faint", d.elapsed));
450  if (d.retried) meta.appendChild(el("span", "flag", "retried"));
451  if (d.incognito) meta.appendChild(el("span", "flag", "incognito, not saved"));
452  if (d.standalone && d.standalone !== d.question) {
453    meta.appendChild(el("span", "chip", d.standalone));
454  }
455  (d.queries || []).forEach((q) => meta.appendChild(el("span", "chip", q)));
456  wrap.appendChild(meta);
457
458  const body = el("div", d.skill === "calculator" ? "answer prose calc" : "answer prose");
459  body.innerHTML = d.html;
460  decorateCode(body, d.checks || []);
461  wrap.appendChild(body);
462
463  (d.warnings || []).forEach((wtext) => wrap.appendChild(el("p", "warn", wtext)));
464
465  if (d.id) wrap.appendChild(renderRating(d.id));
466
467  const codePanel = renderCodeChecks(d.checks || [], d.deps || []);
468  if (codePanel) wrap.appendChild(codePanel);
469
470  const cites = d.citations || [];
471  const checked = cites.filter((c) => c.Checked);
472  const bad = checked.filter((c) => !c.Supported);
473
474  if (checked.length) {
475    const v = el("section", "validation");
476    const head = el("div", "vhead");
477    head.appendChild(el("span", "label", "verified"));
478    const pct = Math.round((100 * (checked.length - bad.length)) / checked.length);
479    const bar = el("span", bad.length ? "vbar bad" : "vbar");
480    const fill = el("span", "vfill");
481    bar.appendChild(fill);
482    head.appendChild(bar);
483    head.appendChild(el("span", bad.length ? "score bad" : "score",
484      `${checked.length - bad.length}/${checked.length} verified`));
485    v.appendChild(head);
486    requestAnimationFrame(() => { fill.style.width = `${pct}%`; });
487
488    if (bad.length) {
489      v.appendChild(el("p", "faint explain",
490        "Unsupported means the sentence was checked against the passage it cited and that passage does not state it. " +
491        "The claim may still be true, but nothing read backs it, so treat it as the model's own words rather than something taken off a source."));
492    }
493
494    const list = el("ul", "checks");
495    cites.forEach((c) => {
496      const li = el("li", c.Supported ? "ok" : "bad");
497      li.appendChild(el("span", "verdict", c.Supported ? (c.Repaired ? "re-cited" : "supported") : "unsupported"));
498      const a = el("a", "cite", `[${c.Repaired || c.PassageID}]`);
499      a.href = `#p${c.Repaired || c.PassageID}`;
500      li.appendChild(a);
501      const s = el("span", "sentence");
502      s.appendChild(el("span", "stext", c.Sentence));
503      if (c.Note) s.appendChild(el("span", "note", c.Note));
504      li.appendChild(s);
505      list.appendChild(li);
506    });
507    v.appendChild(list);
508    wrap.appendChild(v);
509  }
510
511  // What the answer named, checked. Above the sources, because this is what a
512  // reader was going to go looking for next.
513  if ((d.links || []).length) {
514    const sec = el("section", "linkset");
515    const h = el("div", "vhead");
516    h.appendChild(el("span", "label", "links"));
517    h.appendChild(el("span", "faint", "checked to be the thing named"));
518    sec.appendChild(h);
519    const ul = el("ul", "entlinks");
520    d.links.forEach((l) => {
521      const li = el("li");
522      const a = el("a", "entname", l.Name);
523      a.href = l.URL;
524      a.target = "_blank";
525      a.rel = "noreferrer noopener";
526      li.appendChild(a);
527      const sub = el("div", "faint");
528      sub.textContent = l.Host + (l.Title ? ` · ${l.Title}` : "");
529      li.appendChild(sub);
530      ul.appendChild(li);
531    });
532    sec.appendChild(ul);
533    wrap.appendChild(sec);
534  }
535
536  if ((d.sources || []).length) {
537    const sec = el("section");
538    sec.appendChild(el("span", "label", "sources"));
539    const ol = el("ol", "sources");
540    d.sources.forEach((s) => {
541      const li = el("li");
542      const a = el("a", null, s.Title || s.URL);
543      a.href = s.URL;
544      a.target = "_blank";
545      a.rel = "noreferrer noopener";
546      li.appendChild(a);
547      const sub = el("div", "faint");
548      let t = host(s.URL);
549      if (s.Published) t += ` · ${s.Published}`;
550      sub.textContent = t;
551      if (s.FromCache) {
552        sub.appendChild(document.createTextNode(" · "));
553        sub.appendChild(el("span", "cached", "cached"));
554      }
555      li.appendChild(sub);
556      ol.appendChild(li);
557    });
558    sec.appendChild(ol);
559    wrap.appendChild(sec);
560  }
561
562  if ((d.passages || []).length) {
563    const det = el("details", "passages");
564    const sum = el("summary", "label", `passages (${d.passages.length})`);
565    det.appendChild(sum);
566    det.appendChild(el("p", "faint explain",
567      "The raw text the answer was written from. Sometimes this is more useful than the answer, especially for recipes."));
568    d.passages.forEach((p) => {
569      const box = el("div", "passage");
570      box.id = `p${p.ID}`;
571      const h = el("div", "phead");
572      h.appendChild(el("span", "cite", `[${p.ID}]`));
573      h.appendChild(el("span", "faint", `source ${p.Source}`));
574      box.appendChild(h);
575      box.appendChild(el("p", null, p.Text));
576      det.appendChild(box);
577    });
578    wrap.appendChild(det);
579  }
580
581  const foot = el("div", "turnfoot");
582  const fu = el("button", "act primary", "Ask a follow-up");
583  fu.addEventListener("click", () => {
584    following = true;
585    setMode();
586    focusInput();
587    window.scrollTo({ top: 0, behavior: "smooth" });
588  });
589  const nq = el("button", "act", "New question");
590  nq.addEventListener("click", newQuestion);
591  foot.append(fu, nq);
592  wrap.appendChild(foot);
593
594  // A citation link should open the passages panel, not silently do nothing.
595  wrap.querySelectorAll("a.cite").forEach((a) => {
596    a.addEventListener("click", () => {
597      const det = wrap.querySelector("details.passages");
598      if (det) det.open = true;
599    });
600  });
601
602  return wrap;
603}
604
605const REASONS = [
606  ["wrong", "it is wrong"],
607  ["stale", "already happened"],
608  ["missed", "answered something else"],
609  ["sources", "bad sources"],
610];
611
612// A thumb on its own cannot say which step went wrong, and which step went
613// wrong is the whole reason for collecting it, so a down thumb asks once.
614function renderRating(id) {
615  const strip = el("div", "rating");
616  strip.appendChild(el("span", "label", "was this right"));
617
618  const up = el("button", "thumb up", "\u25b2 yes");
619  const down = el("button", "thumb down", "\u25bc no");
620  const why = el("div", "why-not");
621  why.hidden = true;
622
623  const said = (text) => {
624    strip.innerHTML = "";
625    strip.appendChild(el("span", "label", "thanks"));
626    strip.appendChild(el("span", "faint", text));
627  };
628
629  const send = (verdict, reason) =>
630    fetch("/rate", {
631      method: "POST",
632      headers: { "Content-Type": "application/json" },
633      body: JSON.stringify({ id, verdict, reason: reason || "" }),
634    });
635
636  up.addEventListener("click", () => {
637    send(1);
638    said("kept as a good one");
639  });
640  down.addEventListener("click", () => {
641    up.disabled = true;
642    down.disabled = true;
643    why.hidden = false;
644  });
645
646  REASONS.forEach(([key, text]) => {
647    const b = el("button", "thumb", text);
648    b.addEventListener("click", () => {
649      send(-1, key);
650      said("noted, and this one is kept to work from");
651    });
652    why.appendChild(b);
653  });
654
655  strip.append(up, down, why);
656  return strip;
657}
658
659function host(u) {
660  try {
661    return new URL(u).hostname.replace(/^www\./, "");
662  } catch {
663    return u;
664  }
665}