orchard
mirrorEvery 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
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}