@instafy/cli 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +60 -3
  2. package/dist/bind/LICENSE +661 -0
  3. package/dist/bind/bin/instafy-bind.mjs +542 -0
  4. package/dist/bind/client/bind-client.js +1219 -0
  5. package/dist/bind/src/apply.mjs +293 -0
  6. package/dist/bind/src/bound.mjs +202 -0
  7. package/dist/bind/src/budget.mjs +58 -0
  8. package/dist/bind/src/check.mjs +176 -0
  9. package/dist/bind/src/config.mjs +180 -0
  10. package/dist/bind/src/controller.mjs +244 -0
  11. package/dist/bind/src/executors/agent.mjs +336 -0
  12. package/dist/bind/src/executors/script.mjs +163 -0
  13. package/dist/bind/src/handler-kit.mjs +150 -0
  14. package/dist/bind/src/init.mjs +238 -0
  15. package/dist/bind/src/jobs.mjs +316 -0
  16. package/dist/bind/src/net.mjs +281 -0
  17. package/dist/bind/src/page-fetch.mjs +43 -0
  18. package/dist/bind/src/preview-host.mjs +114 -0
  19. package/dist/bind/src/protocol.mjs +103 -0
  20. package/dist/bind/src/records-schema.mjs +580 -0
  21. package/dist/bind/src/records.mjs +161 -0
  22. package/dist/bind/src/save-owner.mjs +492 -0
  23. package/dist/bind/src/schema-lite.mjs +212 -0
  24. package/dist/bind/src/server.mjs +908 -0
  25. package/dist/bind/src/sse.mjs +119 -0
  26. package/dist/bind/src/starters/blank/app.js +17 -0
  27. package/dist/bind/src/starters/blank/index.html +22 -0
  28. package/dist/bind/src/starters/checklist/app.js +171 -0
  29. package/dist/bind/src/starters/checklist/bound.json +29 -0
  30. package/dist/bind/src/starters/checklist/data.json +26 -0
  31. package/dist/bind/src/starters/checklist/index.html +23 -0
  32. package/dist/bind/src/starters/compare/app.js +316 -0
  33. package/dist/bind/src/starters/compare/bound.json +26 -0
  34. package/dist/bind/src/starters/compare/data.json +170 -0
  35. package/dist/bind/src/starters/compare/index.html +23 -0
  36. package/dist/bind/src/starters/handlers/read_offer.mjs +55 -0
  37. package/dist/bind/src/starters/handlers/set_check.mjs +41 -0
  38. package/dist/bind/src/starters/ranked/app.js +166 -0
  39. package/dist/bind/src/starters/ranked/bound.json +26 -0
  40. package/dist/bind/src/starters/ranked/data.json +142 -0
  41. package/dist/bind/src/starters/ranked/index.html +23 -0
  42. package/dist/bind/src/starters/site.js +888 -0
  43. package/dist/bind/src/starters/theme.css +492 -0
  44. package/dist/bind/src/starters/timeline/app.js +143 -0
  45. package/dist/bind/src/starters/timeline/data.json +41 -0
  46. package/dist/bind/src/starters/timeline/index.html +22 -0
  47. package/dist/bind/src/static.mjs +141 -0
  48. package/dist/bind/src/tokens.mjs +102 -0
  49. package/dist/bind/src/try.mjs +139 -0
  50. package/dist/bind/src/web.mjs +191 -0
  51. package/dist/cli.js +5031 -2446
  52. package/package.json +5 -4
@@ -0,0 +1,888 @@
1
+ /* site.js: shared helpers for Instafy starter pages. Plain script, no build step, no dependencies.
2
+ * Copied into the site with theme.css by `instafy bind init <dir> --starter <name>`. Keep it; edit it only on request.
3
+ *
4
+ * site.start({ data: "data.json", state: (data) => ({ sort: "price", open: "" }), render(data, state) {…},
5
+ * select(ref, data) { return { open: site.slug(ref) }; } });
6
+ *
7
+ * - Query state: site.state(defaults), site.setState(patch) (history.replaceState, then render), site.onState(fn).
8
+ * Only the keys in `state` go in the query, so Studio's Present / Follow and shared links show the same view.
9
+ * - Rows: give each row id="row-<slug>" and data-ref="<kind>:<slug>" (site.rowAttrs(ref)). A "select" command from
10
+ * chat scrolls to the row and marks it; "highlight" fades the rows it names. "toast" and "reload" are the client's.
11
+ * - Facts: one shape everywhere, {"v": "8.9 oz", "src": "Maker", "url": "https://…", "date": "2026-10-17", "est": false}.
12
+ * Optional "asof": the source's own date, shown in place of "date" ("seen 5 Aug"); a price with an "asof" over 30
13
+ * days old is muted and says "may have changed", like a live price past its time.
14
+ * site.trust(fact | record, opts) draws the one trust line: "Source ↗ · seen 17 Oct", "at Store ↗ · checked 4 min
15
+ * ago", "Estimate", "Price not checked" (nobody checked it), "Not listed · Source ↗" (a fact with a source and no
16
+ * value: the source was read and does not give it). A price fact with "list": true is that source's list price,
17
+ * never a store's price now: "$900 · list price on BabyGearLab ↗ · seen 5 Aug".
18
+ * - Live checks: site.checkAll(rows) is the page's one primary button ("Check all prices", with two or more checkable
19
+ * rows). trust(value, {ref, intent, store, seed}) ends a row's caption in its one quiet action: "Check price" (only
20
+ * without "Check all prices"), "Try again" after an error, "Open store ↗" when the store can't be read. While a
21
+ * check runs the value keeps its caption and adds "Checking…"; a failed check says "Couldn't check · why · Try again"
22
+ * once. site.priceNotice(rows) says once why stores can't be read. "Check all prices" goes away while every price it
23
+ * would check is fresh, or is left only with failed rows (each has its own "Try again").
24
+ * - Links: a source in a caption is a quiet citation (muted, a faint underline); an action ("Open Amazon ↗",
25
+ * "where to check ↗", "Try again") is in the link colour.
26
+ * - Safety: every text goes through textContent; site.link() only links https: URLs.
27
+ * SPDX-License-Identifier: AGPL-3.0-only */
28
+ (function () {
29
+ "use strict";
30
+
31
+ var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
32
+ var DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
33
+ var ESTIMATE_BASIS = ["estimated", "modelled", "inferred"];
34
+ var ICONS = {
35
+ check: ["M5 13l4 4L19 7"],
36
+ info: ["M12 11.5v5", "M12 7.51l.01-.011", "M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"],
37
+ warning: ["M12 9v4", "M12 17.01l.01-.011", "M10.24 3.957 2.51 17.5A2 2 0 0 0 4.27 20.5h15.46a2 2 0 0 0 1.76-3L13.76 3.957a2 2 0 0 0-3.52 0z"],
38
+ error: ["M12 7v6", "M12 16.51l.01-.011", "M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2 2 6.477 2 12s4.477 10 10 10z"],
39
+ chevron: ["M9 6l6 6-6 6"]
40
+ };
41
+
42
+ function bind() { return window.instafyBind || null; }
43
+
44
+ // ---------- DOM ----------
45
+ /** h("li", {class, id, text, attrs: {...}, on: {click}}, ...children). Strings become text nodes; null is skipped. */
46
+ function h(tag, props) {
47
+ var el = document.createElement(tag);
48
+ props = props || {};
49
+ if (props["class"]) el.className = props["class"];
50
+ if (props.id) el.id = props.id;
51
+ if (props.text != null) el.textContent = String(props.text);
52
+ var attrs = props.attrs || {};
53
+ Object.keys(attrs).forEach(function (k) {
54
+ var v = attrs[k];
55
+ if (v === false || v == null) return;
56
+ el.setAttribute(k, v === true ? "" : String(v));
57
+ });
58
+ var on = props.on || {};
59
+ Object.keys(on).forEach(function (k) { el.addEventListener(k, on[k]); });
60
+ for (var i = 2; i < arguments.length; i++) append(el, arguments[i]);
61
+ return el;
62
+ }
63
+ /** Append children to el, skipping null and false (site.rowAction returns null when a row has no action). */
64
+ function append(el, child) {
65
+ if (child == null || child === false) return;
66
+ if (Array.isArray(child)) { child.forEach(function (c) { append(el, c); }); return; }
67
+ el.appendChild(typeof child === "object" ? child : document.createTextNode(String(child)));
68
+ }
69
+ function icon(name, cls) {
70
+ var NS = "http://www.w3.org/2000/svg";
71
+ var svg = document.createElementNS(NS, "svg");
72
+ svg.setAttribute("viewBox", "0 0 24 24");
73
+ svg.setAttribute("fill", "none");
74
+ svg.setAttribute("stroke", "currentColor");
75
+ svg.setAttribute("stroke-width", "1.5");
76
+ svg.setAttribute("stroke-linecap", "round");
77
+ svg.setAttribute("stroke-linejoin", "round");
78
+ svg.setAttribute("aria-hidden", "true");
79
+ svg.setAttribute("class", "icon" + (cls ? " " + cls : ""));
80
+ (ICONS[name] || []).forEach(function (d) {
81
+ var p = document.createElementNS(NS, "path");
82
+ p.setAttribute("d", d);
83
+ svg.appendChild(p);
84
+ });
85
+ return svg;
86
+ }
87
+ /** An https link that opens in a new tab and ends in " ↗"; anything else is plain text. */
88
+ function safeUrl(url) {
89
+ try { var u = new URL(String(url)); return u.protocol === "https:" ? u.href : null; } catch (e) { return null; }
90
+ }
91
+ function link(url, text, cls) {
92
+ var href = safeUrl(url);
93
+ if (!href) return h("span", { "class": cls || null, text: text });
94
+ // the ↗ never wraps away from its words (the space before it sits in a no-wrap span)
95
+ return h("a", { "class": cls || null, attrs: { href: href, target: "_blank", rel: "noopener noreferrer" } }, String(text), h("span", { "class": "arr", text: " ↗" }));
96
+ }
97
+ /** "babygearlab.com" for a link, "" for anything else. */
98
+ function host(url) {
99
+ var href = safeUrl(url);
100
+ return href ? new URL(href).hostname.replace(/^www\./, "") : "";
101
+ }
102
+ function slug(text) {
103
+ var s = String(text == null ? "" : text);
104
+ if (s.indexOf(":") >= 0) s = s.slice(s.indexOf(":") + 1);
105
+ return s.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "x";
106
+ }
107
+ /** id and data-ref for a row: rowAttrs("option:a") → {id: "row-a", "data-ref": "option:a"}. */
108
+ function rowAttrs(ref, prefix) {
109
+ return { id: (prefix || "row") + "-" + slug(ref), "data-ref": ref };
110
+ }
111
+
112
+ // ---------- dates and values ----------
113
+ function parseDate(iso) {
114
+ var m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || ""));
115
+ if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
116
+ var t = Date.parse(iso);
117
+ return isFinite(t) ? new Date(t) : null;
118
+ }
119
+ /** "17 Oct" (the year only when it is not this year). */
120
+ function date(iso) {
121
+ var d = parseDate(iso);
122
+ if (!d) return "";
123
+ var text = d.getDate() + " " + MONTHS[d.getMonth()];
124
+ return d.getFullYear() === new Date().getFullYear() ? text : text + " " + d.getFullYear();
125
+ }
126
+ /** "Sat 24 Oct". */
127
+ function dayDate(iso) {
128
+ var d = parseDate(iso);
129
+ return d ? DAYS[d.getDay()] + " " + date(iso) : "";
130
+ }
131
+ function ago(iso) {
132
+ var t = Date.parse(iso || "");
133
+ if (!isFinite(t)) return "";
134
+ var s = Math.max(0, (Date.now() - t) / 1000);
135
+ if (s < 60) return "just now";
136
+ if (s < 3600) return Math.round(s / 60) + " min ago";
137
+ if (s < 86400) return Math.round(s / 3600) + " h ago";
138
+ return date(new Date(t).toISOString());
139
+ }
140
+ function money(price) {
141
+ if (!price || typeof price.amount !== "number") return "";
142
+ var cur = String(price.currency || "").toUpperCase();
143
+ try {
144
+ return new Intl.NumberFormat("en", { style: "currency", currency: cur || "USD", maximumFractionDigits: price.amount % 1 ? 2 : 0 }).format(price.amount);
145
+ } catch (e) {
146
+ return (cur ? cur + " " : "") + price.amount;
147
+ }
148
+ }
149
+ function isRecord(x) { return !!(x && typeof x === "object" && x.prov && x.type); }
150
+ /** The text of a fact or record value ("" when there is none). */
151
+ function valueText(x) {
152
+ if (!x) return "";
153
+ if (isRecord(x)) {
154
+ if (x.type === "offer") return money(x.price);
155
+ if (x.value === true) return "Yes";
156
+ if (x.value === false) return "No";
157
+ return x.value == null ? "" : String(x.value);
158
+ }
159
+ return x.v == null ? "" : String(x.v);
160
+ }
161
+ /** A number to sort by: fact.n, an offer's amount, else the first number in the value text. */
162
+ function num(x) {
163
+ if (!x) return null;
164
+ if (isRecord(x) && x.type === "offer") return x.price && typeof x.price.amount === "number" ? x.price.amount : null;
165
+ if (typeof x.n === "number") return x.n;
166
+ var m = /-?\d[\d,]*(?:\.\d+)?/.exec(valueText(x));
167
+ return m ? Number(m[0].replace(/,/g, "")) : null;
168
+ }
169
+
170
+ // ---------- live values ----------
171
+ function expired(r) {
172
+ var b = bind();
173
+ if (b && b.expired) return b.expired(r);
174
+ var ttl = r && r.prov && r.prov.ttl_s;
175
+ return typeof ttl === "number" && ttl > 0 && Date.parse(r.prov.observed_at) + ttl * 1000 < Date.now();
176
+ }
177
+ var OLD_SEED_MS = 30 * 86400000;
178
+ /** A value that may have changed: a live record past its time, or a price whose source dates it ("asof") over 30
179
+ * days ago (a review's list price from August is not today's price). */
180
+ function isOld(x, price) {
181
+ if (!x) return false;
182
+ if (isRecord(x)) return expired(x);
183
+ if (!price || !x.asof) return false;
184
+ var d = parseDate(x.asof);
185
+ return !!d && Date.now() - d.getTime() > OLD_SEED_MS;
186
+ }
187
+ /** "seen 17 Oct": the source's own date when it gives one, else the day it was read. */
188
+ function seenText(x) {
189
+ var when = x && (x.asof || x.date);
190
+ return when && date(when) ? "seen " + date(when) : "";
191
+ }
192
+ function newest(list) {
193
+ return list.slice().sort(function (a, b) { return (Date.parse(b.prov.observed_at) || 0) - (Date.parse(a.prov.observed_at) || 0); })[0] || null;
194
+ }
195
+ /** The newest live record for a row: an offer with a price for "price", else the claim with that attr. */
196
+ function live(ref, attr) {
197
+ var b = bind();
198
+ if (!b || !ref) return null;
199
+ var all = b.records(ref) || [];
200
+ var list = all.filter(function (r) {
201
+ if (attr === "price" && r.type === "offer") return r.price && typeof r.price.amount === "number";
202
+ return r.type === "claim" && r.attr === attr;
203
+ });
204
+ return newest(list);
205
+ }
206
+
207
+ // ---------- the trust line ----------
208
+ var checks = { errors: new Map(), mine: new Map(), intents: {}, allShown: false, noticed: {} };
209
+ /** Only the checks drawn with trust({action}) / checkAll report errors on their rows. */
210
+ function isCheck(m) { return !m.name || !!checks.intents[m.name]; }
211
+ function capital(s) { s = String(s || ""); return s.charAt(0).toUpperCase() + s.slice(1); }
212
+ /** A note that only repeats the state ("not checked yet") says nothing. */
213
+ function usefulNote(note) {
214
+ var n = String(note || "").trim();
215
+ return n && !/^not (checked|verified)( yet)?\.?$/i.test(n) ? n.slice(0, 100) : "";
216
+ }
217
+ /** The newest live price for a row is a "could not read it" answer (a blocked or unreadable store). */
218
+ function blockedRecord(ref) {
219
+ var b = bind();
220
+ if (!b || !ref) return null;
221
+ var list = (b.records(ref) || []).filter(function (r) {
222
+ return (r.type === "offer" && r.price && typeof r.price.amount === "number") || (r.type === "claim" && r.attr === "price");
223
+ });
224
+ var top = newest(list);
225
+ return top && top.type === "claim" && top.status !== "error" && (top.status === "uncertain" || top.status === "not_found" || top.value == null) ? top : null;
226
+ }
227
+ function names() { var b = bind(); return b ? (b.names || []) : []; }
228
+ /** This row can be checked here: the binder runs the intent, the seed says the store is readable, and the last check
229
+ * did not find it blocked. */
230
+ function canCheck(intent, store, ref) {
231
+ return names().indexOf(intent) >= 0 && !!(store && store.readable) && !(ref && blockedRecord(ref));
232
+ }
233
+
234
+ /**
235
+ * The one trust line for a fact or a live record: the value, then a quiet caption.
236
+ * value · source ↗ · seen 17 Oct (a seed fact; the source's own date when it gives one)
237
+ * value at Store ↗ · checked 4 min ago (a live check; " · may have changed" once it is old)
238
+ * value · Estimate · how ↗
239
+ * Price not checked · reason · where to check ↗
240
+ * opts: label ("Price": says what was not checked), price (a price: "at <store>"), shared (the source is said once
241
+ * for the whole column: value only), and for a row that can be checked live: ref, intent, store, params. Then the
242
+ * caption ends in the row's one action: "Check price" (only when there is no "Check all prices"), "Try again" after
243
+ * an error, "Open store ↗" when the store can't be read. While a check runs the value stays and the caption says
244
+ * "Checking…".
245
+ */
246
+ function trust(x, opts) {
247
+ opts = opts || {};
248
+ var b = bind();
249
+ var el = h("span", { "class": "trust" });
250
+ var meta = [];
251
+ var add = function (part) { if (part) meta.push(part); };
252
+ var live = opts.ref && opts.intent;
253
+ if (live) checks.intents[opts.intent] = true;
254
+ var pending = !!(live && b && b.isPending && b.isPending(opts.ref, opts.intent));
255
+ var blocked = live ? blockedRecord(opts.ref) : null;
256
+ var seed = opts.seed || null;
257
+ // a blocked check keeps the seed value it had (the reason is said once, under the list or on this row)
258
+ if (blocked && isRecord(x) && x.type === "claim" && x.attr === blocked.attr && x.prov.observed_at === blocked.prov.observed_at) x = seed;
259
+ var value = valueText(x);
260
+ // "Not checked" has one meaning on every page: nobody checked it. A checklist says "To confirm" (opts.unknown),
261
+ // because there "checked" is the tick.
262
+ var notChecked = function (note, url) {
263
+ add((opts.label ? opts.label + " not checked" : (opts.unknown || "Not checked")) + (usefulNote(note) ? " · " + usefulNote(note) : ""));
264
+ if (safeUrl(url)) add(link(url, "where to check", "act"));
265
+ };
266
+ // looked for and not there: the source was read and does not give it ("Not listed · Maker ↗ · seen 16 Oct"); the
267
+ // link to the page that was read stays even when the source is named once for the column (then without the date)
268
+ var notListed = function (note, src, url, fact) {
269
+ add((opts.label ? opts.label + " not listed" : "Not listed") + (usefulNote(note) ? " · " + usefulNote(note) : ""));
270
+ if (src) add(safeUrl(url) ? link(url, src) : src);
271
+ var seen = !opts.shared && src ? seenText(fact) : "";
272
+ if (seen) add(h("span", { "class": "age", text: seen }));
273
+ };
274
+ var failed = live && !pending && checks.errors.has(opts.ref);
275
+
276
+ if (isRecord(x)) {
277
+ var src = (x.seller && x.seller.name) || x.prov.source_label || x.prov.source || "";
278
+ var url = (x.handoff && x.handoff.url) || x.prov.url;
279
+ if (x.type === "claim" && x.status === "not_found") {
280
+ value = "";
281
+ notListed(x.note, x.prov.source_label || x.prov.source || "", x.prov.url, null);
282
+ } else if (x.type === "claim" && (x.status === "uncertain" || x.value == null)) {
283
+ value = "";
284
+ notChecked(x.note);
285
+ } else if (x.type === "claim" && x.status === "error") {
286
+ value = "";
287
+ add(h("span", null, icon("error"), "Couldn't check" + (usefulNote(x.note) ? " · " + usefulNote(x.note) : "")));
288
+ } else if (ESTIMATE_BASIS.indexOf(x.basis) >= 0) {
289
+ add("Estimate");
290
+ if (safeUrl(url)) add(link(url, "how"));
291
+ } else {
292
+ add(src ? (opts.price || x.type === "offer" ? h("span", null, "at ", link(url, src)) : link(url, src)) : null);
293
+ add(h("span", { "class": "age", text: "checked " + ago(x.prov.observed_at) }));
294
+ }
295
+ if (expired(x) && value) { el.classList.add("is-old"); add("may have changed"); }
296
+ } else if (x && x.est) {
297
+ // an estimate names its basis: a source to check it against ("Estimate · Taiwan Railway ↗") or how ↗
298
+ add("Estimate");
299
+ if (x.src) add(link(x.url, x.src)); else if (safeUrl(x.url)) add(link(x.url, "how"));
300
+ } else if (x && x.src && value) {
301
+ var seedOld = isOld(x, opts.price);
302
+ if (seedOld) el.classList.add("is-old");
303
+ var atStore = opts.price && opts.store && opts.store.name && String(opts.store.name).toLowerCase() === String(x.src).toLowerCase();
304
+ // a list price ("list": true, a review site's "List price $900") says so on the row itself, even when one line
305
+ // under the list names the source: next to "Open Amazon ↗" a bare "$900" reads as Amazon's price today
306
+ var list = opts.price && !atStore && x.list;
307
+ if (!opts.shared) {
308
+ add(atStore ? h("span", null, "at ", link(x.url, x.src)) : list ? h("span", null, "list price on ", link(x.url, x.src)) : link(x.url, x.src));
309
+ var when = seenText(x);
310
+ if (when) add(h("span", { "class": "age", text: when }));
311
+ if (seedOld) add("may have changed");
312
+ } else if (list) {
313
+ add("list price");
314
+ }
315
+ } else if (x && x.src && !value) {
316
+ notListed(x.note, x.src, x.url, x);
317
+ } else {
318
+ value = "";
319
+ // a check that failed says so once ("Couldn't check · reason · Try again"), not under "Not checked" as well
320
+ if (!failed && !pending) notChecked(x && x.note, x && x.url);
321
+ }
322
+
323
+ // While a check runs the value keeps its caption (an old price stays muted and says "may have changed") and the
324
+ // caption ends in "Checking…"; with no value there is only "Checking…".
325
+ if (pending) {
326
+ if (!value) meta.length = 0;
327
+ add("Checking…");
328
+ }
329
+
330
+ // the row's one action, at the end of the caption
331
+ var action = null;
332
+ if (live && !pending && !failed) {
333
+ if (blocked && !checks.noticed[blocked.note || ""] && usefulNote(blocked.note)) add(usefulNote(blocked.note));
334
+ // opts.label says what was not checked ("Price"); the action keeps its own words ("Check price")
335
+ action = rowAction(Object.assign({}, opts, { label: null }));
336
+ add(action);
337
+ }
338
+
339
+ if (value) el.appendChild(h("span", { "class": "v", text: value }));
340
+ if (meta.length) el.appendChild(caption(meta, action, !!value));
341
+ if (failed) {
342
+ var again = canCheck(opts.intent, opts.store, opts.ref) ? retry(opts) : null;
343
+ var why = [h("span", null, icon("error"), "Couldn't check"), checks.errors.get(opts.ref)];
344
+ if (again) why.push(again);
345
+ // with no value the failure is the whole caption, on the value's line; under a value it is a line of its own
346
+ var err = caption(why, again, false);
347
+ err.classList.add("err");
348
+ el.appendChild(err);
349
+ }
350
+ return el;
351
+ }
352
+ /**
353
+ * A caption: its parts joined by " · ". The first " · " sits in .lead, which the table and fact grids hide when
354
+ * they put the caption on its own line. A short part keeps its " · " with it, and the line may break before a dot;
355
+ * site.js then checks the drawn lines (tidyCaptions): a part that starts a new line takes that line without its
356
+ * dot, so no line starts or ends in a dot. The row's action ("Open Amazon ↗", "Try again") is marked .is-act: in a
357
+ * narrow cell (table, fact grid, a phone row's sorted value) it takes its own line, without a dot in front.
358
+ */
359
+ function caption(parts, action, lead) {
360
+ var m = h("span", { "class": "meta" });
361
+ if (lead) m.appendChild(h("span", { "class": "lead", text: " · " }));
362
+ parts.forEach(function (part, i) {
363
+ var p = h("span", { "class": "part" }, part);
364
+ if (i === 0) { m.appendChild(p); return; }
365
+ var short = String(p.textContent || "").length <= 28;
366
+ var act = part === action;
367
+ if (short) m.appendChild(document.createElement("wbr"));
368
+ m.appendChild(h("span", { "class": "sp" + (short ? " keep" : "") + (act ? " is-act" : "") }, h("span", { "class": "dot", text: " · " }), p));
369
+ });
370
+ return m;
371
+ }
372
+ /** The top of the first and last drawn line of an element (null when it is not drawn). */
373
+ function lines(el) {
374
+ var r = el && el.getClientRects ? el.getClientRects() : [];
375
+ var tops = [];
376
+ for (var i = 0; i < r.length; i++) if (r[i].width > 0 || r[i].height > 0) tops.push(r[i].top);
377
+ return tops.length ? { first: tops[0], last: tops[tops.length - 1] } : null;
378
+ }
379
+ /** No caption line starts or ends in a dot: a part the line wrapped before (or right after its dot) goes on a line
380
+ * of its own without the dot, and a caption that starts under its value drops its first dot. Runs after each render
381
+ * and when the page width changes. */
382
+ function tidyCaptions() {
383
+ var metas = document.querySelectorAll(".trust .meta");
384
+ for (var i = 0; i < metas.length; i++) {
385
+ var m = metas[i];
386
+ m.classList.remove("is-wrap");
387
+ var wrapped = m.querySelectorAll(".sp.is-wrap");
388
+ for (var j = 0; j < wrapped.length; j++) wrapped[j].classList.remove("is-wrap");
389
+ }
390
+ for (var k = 0; k < metas.length; k++) {
391
+ var meta = metas[k];
392
+ var lead = meta.querySelector(":scope > .lead");
393
+ var leadAt = lines(lead);
394
+ var first = meta.querySelector(":scope > .part");
395
+ if (leadAt) {
396
+ var v = lines(meta.previousElementSibling);
397
+ var f = lines(first);
398
+ if ((v && leadAt.first > v.last + 4) || (f && f.first > leadAt.last + 4)) meta.classList.add("is-wrap");
399
+ }
400
+ var prev = first;
401
+ var sps = meta.querySelectorAll(":scope > .sp");
402
+ for (var n = 0; n < sps.length; n++) {
403
+ var sp = sps[n];
404
+ var dot = lines(sp.firstChild);
405
+ var before = lines(prev);
406
+ var part = lines(sp.lastChild);
407
+ if (dot && ((before && dot.first > before.last + 4) || (part && part.first > dot.last + 4))) sp.classList.add("is-wrap");
408
+ prev = sp.lastChild;
409
+ }
410
+ }
411
+ }
412
+ var tidyQueued = false;
413
+ function tidySoon() {
414
+ if (tidyQueued) return;
415
+ tidyQueued = true;
416
+ (window.requestAnimationFrame || setTimeout)(function () { tidyQueued = false; tidyCaptions(); });
417
+ }
418
+
419
+ // ---------- live checks ----------
420
+ function reasonText(code, reason) {
421
+ if (code === "cooldown") return "checked a moment ago, try again soon";
422
+ if (code === "rate" || code === "busy") return "too many checks, try again in a minute";
423
+ if (code === "offline" || /No binder|No host/.test(reason || "")) return "not connected";
424
+ if (code === "timeout") return "it took too long";
425
+ return String(reason || "it did not work").replace(/\s+/g, " ").slice(0, 100);
426
+ }
427
+ function send(intent, params, ref) {
428
+ var b = bind();
429
+ if (!b) return;
430
+ checks.errors.delete(ref);
431
+ try {
432
+ var id = b.intent(intent, params, { ref: ref, ui: "row" });
433
+ checks.mine.set(id, ref);
434
+ } catch (e) {
435
+ checks.errors.set(ref, "not available here");
436
+ }
437
+ schedule();
438
+ }
439
+ function actionButton(o, label, key) {
440
+ var b = bind();
441
+ var intent = o.intent || "read_offer";
442
+ return h("button", {
443
+ "class": "link-btn",
444
+ text: label,
445
+ attrs: { type: "button", "data-key": key + ":" + o.ref },
446
+ on: { click: function () { if (!b.isPending(o.ref, intent)) send(intent, o.params || { ref: o.ref }, o.ref); } }
447
+ });
448
+ }
449
+ function retry(o) { return actionButton(o, "Try again", "retry"); }
450
+ /**
451
+ * A row's one action (null when it has none): "Check price" when this row can be checked and the page has no
452
+ * "Check all prices"; "Open <store> ↗" ("Open Amazon ↗") when the store can't be read here (blocked, not readable,
453
+ * or no binder).
454
+ * trust() puts it at the end of the row's caption; call it yourself only for a row without a trust line.
455
+ */
456
+ function rowAction(o) {
457
+ o = o || {};
458
+ var intent = o.intent || "read_offer";
459
+ checks.intents[intent] = true;
460
+ var b = bind();
461
+ if (b && b.isPending && b.isPending(o.ref, intent)) return null;
462
+ if (canCheck(intent, o.store, o.ref)) return checks.allShown ? null : actionButton(o, o.label || "Check price", "check");
463
+ if (o.store && safeUrl(o.store.url) && (!b || readyDone)) return link(o.store.url, "Open " + (o.store.name || "store"), "act");
464
+ return null;
465
+ }
466
+ /** Kept for pages written against site.js 1.0: the same as rowAction. */
467
+ function checkButton(o) { return rowAction(o); }
468
+ /**
469
+ * The page's one primary button: checks every row that can be checked. Shown only when two or more can be; then rows
470
+ * have no "Check price" of their own. It goes away while every one of those rows has a fresh live price (it comes
471
+ * back once one may have changed). Call it before drawing the rows.
472
+ */
473
+ function checkAll(rows, o) {
474
+ o = o || {};
475
+ var intent = o.intent || "read_offer";
476
+ var b = bind();
477
+ checks.intents[intent] = true;
478
+ var ok = (rows || []).filter(function (r) { return canCheck(intent, r.store, r.ref); });
479
+ var busy = ok.some(function (r) { return b.isPending(r.ref, intent); });
480
+ checks.allShown = ok.length >= 2 || (busy && checks.allShown);
481
+ if (!checks.allShown) return null;
482
+ var isFresh = function (r) { var x = live(r.ref, o.attr || "price"); return !!x && !expired(x) && !checks.errors.has(r.ref); };
483
+ if (!busy && ok.every(isFresh)) return null;
484
+ // a press checks only what is not fresh: a price checked a minute ago is not asked again (and never shows a
485
+ // "checked a moment ago" error on a row that is fine)
486
+ var todo = ok.filter(function (r) { return !isFresh(r); });
487
+ // what is left are checks that failed: each row says why and has its own "Try again", so no second way to retry
488
+ if (!busy && todo.every(function (r) { return checks.errors.has(r.ref); })) return null;
489
+ return h("button", {
490
+ "class": "btn primary",
491
+ text: busy ? "Checking…" : (o.label || "Check all prices"),
492
+ attrs: { type: "button", "data-key": "check-all", "aria-busy": busy ? "true" : false },
493
+ on: { click: function () {
494
+ todo.forEach(function (r) { if (!b.isPending(r.ref, intent)) send(intent, r.params || { ref: r.ref }, r.ref); });
495
+ } }
496
+ });
497
+ }
498
+ /** Any row's live check still running (rows keep their order until every check has finished). */
499
+ function anyPending(rows, intent) {
500
+ var b = bind();
501
+ if (!b || !b.isPending) return false;
502
+ return (rows || []).some(function (r) { return b.isPending(r.ref, intent || "read_offer"); });
503
+ }
504
+ /**
505
+ * One neutral notice under the list when stores can't be read, so the reason is said once and not on every row.
506
+ * A reason shared by two or more rows goes here; a reason only one row has stays on that row. Call it before the
507
+ * rows and append what it returns after them.
508
+ */
509
+ function priceNotice(rows, o) {
510
+ o = o || {};
511
+ checks.noticed = {};
512
+ var priced = (rows || []).filter(function (r) { return r.store; });
513
+ if (!priced.length) return null;
514
+ var by = {};
515
+ priced.forEach(function (r) {
516
+ var rec = blockedRecord(r.ref);
517
+ if (rec && usefulNote(rec.note)) (by[rec.note] = by[rec.note] || []).push(r.ref);
518
+ });
519
+ var shared = Object.keys(by).filter(function (k) { return by[k].length >= 2; });
520
+ shared.forEach(function (k) { checks.noticed[k] = true; });
521
+ var seedBlocked = priced.every(function (r) { return !(r.store && r.store.readable); });
522
+ var tail = " Open a store to see today's price.";
523
+ if (shared.length === 1) return notice("info", capital(usefulNote(shared[0])) + "." + tail);
524
+ if (shared.length > 1) return notice("info", "Some stores can't be checked automatically." + tail);
525
+ if (seedBlocked) return notice("info", "These stores don't allow price checks from this page." + tail);
526
+ return null;
527
+ }
528
+
529
+ /** Every priced row's store refuses checks from the start ("readable": false): the page offers no check at all. */
530
+ function storesBlocked(rows) {
531
+ var priced = (rows || []).filter(function (r) { return r.store; });
532
+ return priced.length > 0 && priced.every(function (r) { return !(r.store && r.store.readable); });
533
+ }
534
+
535
+ // ---------- query state ----------
536
+ var defaults = {};
537
+ var renderers = [];
538
+ function state(defs) {
539
+ if (defs) defaults = Object.assign({}, defs);
540
+ var q = new URLSearchParams(location.search);
541
+ var out = {};
542
+ Object.keys(defaults).forEach(function (k) {
543
+ var v = q.get(k);
544
+ out[k] = v == null ? defaults[k] : v;
545
+ });
546
+ return out;
547
+ }
548
+ function setState(next) {
549
+ var q = new URLSearchParams(location.search);
550
+ var cur = state();
551
+ Object.keys(next || {}).forEach(function (k) {
552
+ if (!(k in defaults)) return;
553
+ cur[k] = next[k] == null ? defaults[k] : String(next[k]);
554
+ });
555
+ Object.keys(defaults).forEach(function (k) {
556
+ if (cur[k] === "" + defaults[k] || cur[k] === defaults[k]) q.delete(k);
557
+ else q.set(k, cur[k]);
558
+ });
559
+ var search = q.toString();
560
+ try { history.replaceState(history.state, "", location.pathname + (search ? "?" + search : "") + location.hash); } catch (e) { /* sandboxed */ }
561
+ render();
562
+ }
563
+ function onState(fn) { renderers.push(fn); render(); }
564
+
565
+ // ---------- rendering, guarded ----------
566
+ var queued = false;
567
+ var failedPage = false;
568
+ var selectedRef = null;
569
+ function schedule() {
570
+ if (queued) return;
571
+ queued = true;
572
+ Promise.resolve().then(function () { queued = false; render(); });
573
+ }
574
+ function render() {
575
+ if (failedPage) return;
576
+ var active = document.activeElement;
577
+ var key = active && active.getAttribute && (active.getAttribute("data-key") || (active.id ? "#" + active.id : null));
578
+ try {
579
+ renderers.forEach(function (fn) { fn(); });
580
+ } catch (e) {
581
+ console.error("[site] render failed", e);
582
+ return fail();
583
+ }
584
+ markSelected();
585
+ syncThemeColor();
586
+ tidySoon();
587
+ if (key && document.activeElement !== active) {
588
+ var again = key.charAt(0) === "#" ? document.getElementById(key.slice(1)) : document.querySelector('[data-key="' + cssEscape(key) + '"]');
589
+ if (again && again.focus) again.focus({ preventScroll: true });
590
+ }
591
+ }
592
+ function cssEscape(s) { return window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/["\\]/g, "\\$&"); }
593
+ function fail() {
594
+ failedPage = true;
595
+ var main = document.querySelector("main") || document.body;
596
+ // the page keeps its title (the person still sees which page this is), and Reload looks like a button
597
+ var top = main.querySelector(".head");
598
+ main.replaceChildren();
599
+ if (top) main.appendChild(top);
600
+ main.appendChild(notice("error", "This page couldn't load its data. Reload to try again.",
601
+ h("button", { "class": "btn", text: "Reload", attrs: { type: "button" }, on: { click: function () { location.reload(); } } })));
602
+ }
603
+ function notice(tone, text, action) {
604
+ return h("div", { "class": "notice" + (tone === "error" ? " is-error" : tone === "warn" ? " is-warn" : ""), attrs: { role: tone === "error" ? "alert" : null } },
605
+ icon(tone === "error" ? "error" : tone === "warn" ? "warning" : "info"), h("p", { "class": "notice-text", text: text }), action || null);
606
+ }
607
+ /** An empty list: "No options yet." plus, only in Studio (where the owner can ask), a hint such as "Ask in chat to add
608
+ * some." A friend who opened a shared link has no chat to ask in. */
609
+ function empty(text, action, hint) {
610
+ return h("div", { "class": "empty" }, h("p", null, text, hint ? h("span", { "class": "in-studio", text: " " + hint }) : null), action || null);
611
+ }
612
+ function skeleton(rows, text) {
613
+ var list = h("div", { "class": "skel-rows", attrs: { "aria-hidden": "true" } });
614
+ for (var i = 0; i < (rows || 3); i++) list.appendChild(h("div", { "class": "card" }, h("span", { "class": "skel w1" }), h("span", { "class": "skel w2" })));
615
+ return h("div", { "class": "stack" }, list, h("p", { "class": "cap", text: text || "Loading…" }));
616
+ }
617
+ var toastTimer = null;
618
+ function toast(text) {
619
+ var el = document.querySelector(".toast") || h("div", { "class": "toast", attrs: { role: "status" } });
620
+ el.textContent = String(text || "").slice(0, 120);
621
+ document.body.appendChild(el);
622
+ clearTimeout(toastTimer);
623
+ toastTimer = setTimeout(function () { el.remove(); }, 2500);
624
+ }
625
+
626
+ /** Fetch a JSON file of the site. While it loads, #list shows skeleton rows; a failure shows the error notice. */
627
+ function load(url) {
628
+ var list = document.getElementById("list");
629
+ if (list && !list.firstChild) list.appendChild(skeleton(3));
630
+ return fetch(url, { cache: "no-store" })
631
+ .then(function (r) { if (!r.ok) throw new Error(url + ": HTTP " + r.status); return r.json(); })
632
+ .catch(function (e) { console.error("[site] load failed", e); fail(); throw e; });
633
+ }
634
+ var readyDone = !bind();
635
+ /** Resolves when the binder (if any) has said which checks it runs. Without __bind/client.js: at once. */
636
+ function ready() {
637
+ var b = bind();
638
+ if (!b) { readyDone = true; return Promise.resolve({ connected: false, names: [] }); }
639
+ var timeout = new Promise(function (resolve) { setTimeout(function () { resolve({ connected: false, names: [] }); }, 4000); });
640
+ return Promise.race([b.ready, timeout]).then(function (info) { readyDone = true; return info; });
641
+ }
642
+
643
+ // ---------- header, notes ----------
644
+ function head(data) {
645
+ var title = document.getElementById("title") || document.querySelector("h1.title");
646
+ var lede = document.getElementById("lede") || document.querySelector("p.lede");
647
+ if (data && data.title) { document.title = data.title; if (title) title.textContent = data.title; }
648
+ if (lede) {
649
+ lede.replaceChildren();
650
+ if (data && data.lede) lede.appendChild(document.createTextNode(data.lede));
651
+ if (data && data.map && safeUrl(data.map)) { lede.appendChild(document.createTextNode(" ")); lede.appendChild(link(data.map, "Map")); }
652
+ lede.hidden = !lede.firstChild;
653
+ }
654
+ // how the list is ordered ("Ranked by …") belongs with the answer, before any button
655
+ var headEl = (lede && lede.parentNode) || (title && title.parentNode);
656
+ if (headEl) {
657
+ var basis = headEl.querySelector("p.basis");
658
+ if (data && data.basis) {
659
+ if (!basis) { basis = h("p", { "class": "cap basis" }); headEl.appendChild(basis); }
660
+ basis.textContent = data.basis;
661
+ } else if (basis) basis.remove();
662
+ }
663
+ }
664
+ /** The footer: "Still to confirm" (each thing once, plain text) and "Sources: A ↗, B ↗" (one link per site). */
665
+ function notes(unchecked, sources) {
666
+ var el = document.getElementById("notes");
667
+ if (!el) return;
668
+ el.replaceChildren();
669
+ var list = (unchecked || []).filter(Boolean);
670
+ if (list.length) {
671
+ // "Still to confirm", not "Not checked": a checklist's boxes are "checked" too, and a value's own caption says
672
+ // "Not checked"
673
+ el.appendChild(h("h2", { text: "Still to confirm" }));
674
+ el.appendChild(h("ul", { "class": "cap" }, list.map(function (t) { return h("li", { text: t }); })));
675
+ }
676
+ var seen = {};
677
+ var links = [];
678
+ (sources || []).forEach(function (s) {
679
+ if (!s || !s.name) return;
680
+ var key = String(s.name).toLowerCase();
681
+ var site = host(s.url);
682
+ if (seen[key] || (site && seen["@" + site])) return;
683
+ seen[key] = 1;
684
+ if (site) seen["@" + site] = 1;
685
+ links.push(s);
686
+ });
687
+ if (links.length) {
688
+ var p = h("p", { "class": "cap" }, "Sources: ");
689
+ links.forEach(function (s, i) { if (i) p.appendChild(document.createTextNode(", ")); p.appendChild(link(s.url, s.name)); });
690
+ el.appendChild(p);
691
+ }
692
+ el.hidden = !el.firstChild;
693
+ }
694
+ /** Every {src, url} in a list of facts, for notes(). */
695
+ function sourcesOf(facts) {
696
+ var out = [];
697
+ (facts || []).forEach(function (f) { if (f && f.src && !f.est) out.push({ name: f.src, url: f.url }); });
698
+ return out;
699
+ }
700
+
701
+ // ---------- segmented control, select ----------
702
+ /** One single choice: native radios (arrow keys work). options: [{value, label}] (at most 4; more → field select). */
703
+ function seg(name, label, options, value, onChange) {
704
+ if (options.length > 4) return selectField(name, label, options, value, onChange);
705
+ var group = h("div", { "class": "seg", attrs: { role: "radiogroup", "aria-label": label } });
706
+ options.forEach(function (o) {
707
+ var input = h("input", { attrs: { type: "radio", name: name, value: o.value, checked: String(o.value) === String(value), "data-key": "seg:" + name + ":" + o.value } });
708
+ input.addEventListener("change", function () { if (input.checked) onChange(o.value); });
709
+ group.appendChild(h("label", null, input, h("span", { text: o.label })));
710
+ });
711
+ return group;
712
+ }
713
+ function selectField(name, label, options, value, onChange) {
714
+ var id = "f-" + name;
715
+ var sel = h("select", { id: id, attrs: { name: name } }, options.map(function (o) {
716
+ return h("option", { text: o.label, attrs: { value: o.value, selected: String(o.value) === String(value) } });
717
+ }));
718
+ sel.addEventListener("change", function () { onChange(sel.value); });
719
+ return h("div", { "class": "field inline" }, h("label", { text: label, attrs: { "for": id } }), sel);
720
+ }
721
+
722
+ // ---------- select / highlight commands ----------
723
+ var hooks = { select: null };
724
+ function rowFor(ref) {
725
+ var all = document.querySelectorAll("[data-ref]");
726
+ for (var i = 0; i < all.length; i++) if (all[i].getAttribute("data-ref") === ref) return all[i];
727
+ return null;
728
+ }
729
+ var marked = {}; // ref -> until (highlight without motion)
730
+ function markSelected() {
731
+ var lit = document.querySelectorAll(".is-highlighted");
732
+ for (var j = 0; j < lit.length; j++) lit[j].classList.remove("is-highlighted");
733
+ Object.keys(marked).forEach(function (ref) {
734
+ if (marked[ref] < Date.now()) { delete marked[ref]; return; }
735
+ var r = rowFor(ref);
736
+ if (r) r.classList.add("is-highlighted");
737
+ });
738
+ var all = document.querySelectorAll(".is-selected");
739
+ for (var i = 0; i < all.length; i++) all[i].classList.remove("is-selected");
740
+ var row = selectedRef ? rowFor(selectedRef) : openWithoutDetails();
741
+ if (!row) return;
742
+ // the tint marks it; no check mark, which would read as "recommended" or "verified"
743
+ row.classList.add("is-selected");
744
+ }
745
+ /** ?open=<slug> on a row that has no details to open: the row is marked instead, so the link still shows something. */
746
+ function openWithoutDetails() {
747
+ var open = "open" in defaults ? state().open : "";
748
+ var row = open && document.getElementById("row-" + open);
749
+ if (!row) return null;
750
+ var next = row.nextElementSibling;
751
+ return row.querySelector(".more") || (next && next.classList.contains("more-row")) ? null : row;
752
+ }
753
+ function select(ref) {
754
+ selectedRef = String(ref || "") || null;
755
+ var patch = hooks.select && selectedRef ? hooks.select(selectedRef) : null;
756
+ if (patch) setState(patch); else render();
757
+ var row = selectedRef && rowFor(selectedRef);
758
+ if (row) row.scrollIntoView({ block: "center", behavior: reducedMotion() ? "auto" : "smooth" });
759
+ }
760
+ function reducedMotion() { return !!(window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches); }
761
+ function highlight(refs, ms) {
762
+ var dur = Math.min(Number(ms) || 1600, 10000);
763
+ (Array.isArray(refs) ? refs : [refs]).forEach(function (ref) {
764
+ var row = rowFor(String(ref));
765
+ if (!row) return;
766
+ if (reducedMotion() || !row.animate) {
767
+ // no fade, but the rows chat pointed at are still marked for a while (kept across redraws)
768
+ marked[String(ref)] = Date.now() + dur;
769
+ row.classList.add("is-highlighted");
770
+ setTimeout(render, dur + 20);
771
+ return;
772
+ }
773
+ var to = getComputedStyle(row).backgroundColor;
774
+ var from = getComputedStyle(document.documentElement).getPropertyValue("--if-selected").trim() || "#F6F3EF";
775
+ row.animate([{ backgroundColor: from }, { backgroundColor: to }], { duration: dur, easing: "ease-out" });
776
+ });
777
+ }
778
+
779
+ // ---------- per-viewer conveniences (this browser only; may be empty or blocked) ----------
780
+ function remember(key, value) {
781
+ try { localStorage.setItem("site:" + location.pathname + ":" + key, String(value)); } catch (e) { /* private window */ }
782
+ }
783
+ function recall(key) {
784
+ try { return localStorage.getItem("site:" + location.pathname + ":" + key); } catch (e) { return null; }
785
+ }
786
+
787
+ // ---------- theme-color follows the page theme ----------
788
+ function syncThemeColor() {
789
+ var root = document.documentElement;
790
+ var t = root.getAttribute("data-theme");
791
+ var dark = t === "dark" || (t !== "light" && window.matchMedia && matchMedia("(prefers-color-scheme: dark)").matches);
792
+ var metas = document.querySelectorAll('meta[name="theme-color"]');
793
+ for (var i = 0; i < metas.length; i++) {
794
+ if (t) metas[i].setAttribute("content", dark ? "#141414" : "#FFFFFF");
795
+ }
796
+ }
797
+
798
+ // ---------- one quiet status line for screen readers (rows are redrawn, so they are not live regions) ----------
799
+ var statusEl = null;
800
+ function announce(text) {
801
+ if (!statusEl) {
802
+ statusEl = h("p", { "class": "sr-only", attrs: { role: "status" } });
803
+ document.body.appendChild(statusEl);
804
+ }
805
+ statusEl.textContent = text;
806
+ }
807
+
808
+ // ---------- wiring ----------
809
+ function wire() {
810
+ window.addEventListener("popstate", render);
811
+ // captions are tidied against the drawn lines: again when the width or the font changes
812
+ window.addEventListener("resize", tidySoon);
813
+ if (document.fonts && document.fonts.ready) document.fonts.ready.then(tidySoon);
814
+ try { new MutationObserver(syncThemeColor).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] }); } catch (e) { /* old browsers */ }
815
+ var b = bind();
816
+ if (!b) return;
817
+ b.on("records", schedule);
818
+ b.on("pending", schedule);
819
+ // An error listener also keeps the client's generic "did not work" toast away: the row says it instead.
820
+ b.on("error", function (m) {
821
+ if (!isCheck(m)) return;
822
+ var ref = m.ref || checks.mine.get(m.id);
823
+ checks.mine.delete(m.id);
824
+ if (!ref) return;
825
+ if (!(m.error && m.error.code === "cancelled")) {
826
+ checks.errors.set(ref, reasonText(m.error && m.error.code, m.error && m.error.message));
827
+ announce("Couldn't check · " + checks.errors.get(ref));
828
+ }
829
+ schedule();
830
+ });
831
+ b.on("ack", function (m) {
832
+ if (m.status !== "rejected" || !checks.mine.has(m.id)) return;
833
+ var ref = checks.mine.get(m.id);
834
+ checks.mine.delete(m.id);
835
+ checks.errors.set(ref, reasonText(m.reason, m.reason));
836
+ announce("Couldn't check · " + checks.errors.get(ref));
837
+ schedule();
838
+ });
839
+ b.on("done", function (m) {
840
+ if (!isCheck(m)) return;
841
+ var ref = m.ref || checks.mine.get(m.id);
842
+ if (checks.mine.has(m.id)) announce("Checked");
843
+ checks.mine.delete(m.id);
844
+ if (ref) checks.errors.delete(ref);
845
+ schedule();
846
+ });
847
+ b.on("command", function (m) {
848
+ var c = m.command || {};
849
+ var args = c.args || {};
850
+ if (c.name === "select") select(args.ref);
851
+ if (c.name === "highlight") highlight(args.refs || args.ref, args.ms);
852
+ });
853
+ ready().then(schedule);
854
+ setInterval(schedule, 60000); // ages ("4 min ago") stay current
855
+ }
856
+
857
+ /**
858
+ * Start a page: load the data, keep `state` keys in the query, render now and on every change (query, live values,
859
+ * checks, Studio's view). render(data, state) builds the page; select(ref, data) returns a state patch (optional).
860
+ */
861
+ function start(o) {
862
+ var dataUrl = o.data === undefined ? "data.json" : o.data;
863
+ var got = dataUrl ? load(dataUrl) : Promise.resolve({});
864
+ return got.then(function (data) {
865
+ state(typeof o.state === "function" ? o.state(data) : (o.state || {}));
866
+ if (o.select) hooks.select = function (ref) { return o.select(ref, data); };
867
+ var list = document.getElementById("list");
868
+ if (list) list.replaceChildren();
869
+ onState(function () { o.render(data, state()); });
870
+ return data;
871
+ }, function () { /* fail() already showed the notice */ });
872
+ }
873
+
874
+ window.site = {
875
+ version: "1.4.0",
876
+ h: h, append: append, icon: icon, link: link, slug: slug, rowAttrs: rowAttrs, safeUrl: safeUrl,
877
+ date: date, dayDate: dayDate, ago: ago, money: money, num: num, valueText: valueText, host: host, seen: seenText,
878
+ old: isOld, expired: expired,
879
+ live: live, trust: trust, rowAction: rowAction, checkButton: checkButton, checkAll: checkAll, canCheck: canCheck,
880
+ anyPending: anyPending, priceNotice: priceNotice, storesBlocked: storesBlocked, blocked: blockedRecord, tidy: tidyCaptions, remember: remember, recall: recall,
881
+ state: state, setState: setState, onState: onState, render: render, schedule: schedule,
882
+ load: load, ready: ready, start: start, fail: fail,
883
+ head: head, notes: notes, sourcesOf: sourcesOf, notice: notice, empty: empty, skeleton: skeleton, toast: toast,
884
+ seg: seg, selectField: selectField, select: select, highlight: highlight, announce: announce,
885
+ get selected() { return selectedRef; }
886
+ };
887
+ wire();
888
+ })();