@instafy/cli 0.2.3 → 0.3.0
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.
- package/README.md +4 -0
- package/dist/bind/LICENSE +661 -0
- package/dist/bind/bin/instafy-bind.mjs +542 -0
- package/dist/bind/client/bind-client.js +1219 -0
- package/dist/bind/src/apply.mjs +293 -0
- package/dist/bind/src/bound.mjs +202 -0
- package/dist/bind/src/budget.mjs +58 -0
- package/dist/bind/src/check.mjs +176 -0
- package/dist/bind/src/config.mjs +169 -0
- package/dist/bind/src/controller.mjs +244 -0
- package/dist/bind/src/detached-env.mjs +27 -0
- package/dist/bind/src/executors/agent.mjs +336 -0
- package/dist/bind/src/executors/script.mjs +163 -0
- package/dist/bind/src/handler-kit.mjs +150 -0
- package/dist/bind/src/init.mjs +238 -0
- package/dist/bind/src/jobs.mjs +316 -0
- package/dist/bind/src/net.mjs +281 -0
- package/dist/bind/src/page-fetch.mjs +43 -0
- package/dist/bind/src/preview-host.mjs +114 -0
- package/dist/bind/src/protocol.mjs +103 -0
- package/dist/bind/src/records-schema.mjs +580 -0
- package/dist/bind/src/records.mjs +161 -0
- package/dist/bind/src/save-owner.mjs +492 -0
- package/dist/bind/src/schema-lite.mjs +212 -0
- package/dist/bind/src/server.mjs +908 -0
- package/dist/bind/src/sse.mjs +119 -0
- package/dist/bind/src/starters/blank/app.js +17 -0
- package/dist/bind/src/starters/blank/index.html +22 -0
- package/dist/bind/src/starters/checklist/app.js +171 -0
- package/dist/bind/src/starters/checklist/bound.json +29 -0
- package/dist/bind/src/starters/checklist/data.json +26 -0
- package/dist/bind/src/starters/checklist/index.html +23 -0
- package/dist/bind/src/starters/compare/app.js +316 -0
- package/dist/bind/src/starters/compare/bound.json +26 -0
- package/dist/bind/src/starters/compare/data.json +170 -0
- package/dist/bind/src/starters/compare/index.html +23 -0
- package/dist/bind/src/starters/handlers/read_offer.mjs +55 -0
- package/dist/bind/src/starters/handlers/set_check.mjs +41 -0
- package/dist/bind/src/starters/ranked/app.js +166 -0
- package/dist/bind/src/starters/ranked/bound.json +26 -0
- package/dist/bind/src/starters/ranked/data.json +142 -0
- package/dist/bind/src/starters/ranked/index.html +23 -0
- package/dist/bind/src/starters/site.js +888 -0
- package/dist/bind/src/starters/theme.css +492 -0
- package/dist/bind/src/starters/timeline/app.js +143 -0
- package/dist/bind/src/starters/timeline/data.json +41 -0
- package/dist/bind/src/starters/timeline/index.html +22 -0
- package/dist/bind/src/static.mjs +141 -0
- package/dist/bind/src/tokens.mjs +102 -0
- package/dist/bind/src/try.mjs +139 -0
- package/dist/bind/src/web.mjs +191 -0
- package/dist/cli.js +2261 -387
- package/package.json +3 -2
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Server-sent events: every connected viewer gets every event. A ring buffer (200 events, 2 MB) serves
|
|
2
|
+
// Last-Event-ID replay after a reconnect; a comment ping every 15 s keeps proxies from closing the stream.
|
|
3
|
+
//
|
|
4
|
+
// A stream also ends on its own after MAX_STREAM_MS. Whatever authenticates the viewer in front of the binder (the
|
|
5
|
+
// preview broker's forwardAuth, a proxy) decides once per request, so a stream that never ended would keep
|
|
6
|
+
// delivering after the viewer's access was revoked. EventSource reconnects by itself (retry: 2000) and the ring
|
|
7
|
+
// replays from Last-Event-ID, so a viewer sees no gap; the reconnect simply passes that check again.
|
|
8
|
+
|
|
9
|
+
export const RING_SIZE = 200;
|
|
10
|
+
export const RING_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
export const PING_MS = 15_000;
|
|
12
|
+
/** Open streams per site; more are refused with 503 so one token holder cannot pin the binder's sockets. */
|
|
13
|
+
export const MAX_CLIENTS = 50;
|
|
14
|
+
/**
|
|
15
|
+
* Open streams per source (one address behind the preview tunnel or a proxy), so one viewer cannot take every slot.
|
|
16
|
+
* Past it that source's new stream is refused like a full hub; the bind client then falls back to polling.
|
|
17
|
+
*/
|
|
18
|
+
export const MAX_PER_SOURCE = 8;
|
|
19
|
+
/** How long one stream lives before the client reconnects (and is checked again). */
|
|
20
|
+
export const MAX_STREAM_MS = 60_000;
|
|
21
|
+
|
|
22
|
+
export class EventHub {
|
|
23
|
+
constructor({
|
|
24
|
+
ringSize = RING_SIZE, ringBytes = RING_BYTES, pingMs = PING_MS, maxClients = MAX_CLIENTS, maxPerSource = MAX_PER_SOURCE,
|
|
25
|
+
maxStreamMs = MAX_STREAM_MS,
|
|
26
|
+
} = {}) {
|
|
27
|
+
this.ringSize = ringSize;
|
|
28
|
+
this.ringBytes = ringBytes;
|
|
29
|
+
this.ringUsed = 0;
|
|
30
|
+
this.maxClients = maxClients;
|
|
31
|
+
this.maxPerSource = maxPerSource;
|
|
32
|
+
this.maxStreamMs = maxStreamMs;
|
|
33
|
+
this.ring = [];
|
|
34
|
+
this.nextId = 1;
|
|
35
|
+
this.clients = new Set();
|
|
36
|
+
/** source -> open responses, oldest first */
|
|
37
|
+
this.bySource = new Map();
|
|
38
|
+
this.ping = setInterval(() => {
|
|
39
|
+
for (const res of this.clients) res.write(": ping\n\n");
|
|
40
|
+
}, pingMs);
|
|
41
|
+
this.ping.unref?.();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
get size() {
|
|
45
|
+
return this.clients.size;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Attach a response as an SSE stream. `hello` is sent first and never buffered. `source` identifies who opened it
|
|
50
|
+
* (see sourceOf in server.mjs; null = not limited per source); one source holds at most maxPerSource streams.
|
|
51
|
+
* Returns null when full.
|
|
52
|
+
*/
|
|
53
|
+
attach(req, res, { hello = null, lastEventId = null, source = null } = {}) {
|
|
54
|
+
const own = source == null ? 0 : this.bySource.get(source)?.length || 0;
|
|
55
|
+
if (this.clients.size >= this.maxClients || own >= this.maxPerSource) {
|
|
56
|
+
res.writeHead(503, { "content-type": "application/json", "retry-after": "10", "cache-control": "no-store" });
|
|
57
|
+
res.end(JSON.stringify({ ok: false, reason: "too many viewers" }));
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
res.writeHead(200, {
|
|
61
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
62
|
+
"cache-control": "no-cache, no-transform",
|
|
63
|
+
connection: "keep-alive",
|
|
64
|
+
"x-accel-buffering": "no",
|
|
65
|
+
});
|
|
66
|
+
res.write("retry: 2000\n\n");
|
|
67
|
+
if (hello) res.write(`event: ${hello.type}\ndata: ${JSON.stringify(hello)}\n\n`);
|
|
68
|
+
const last = Number(lastEventId ?? req.headers["last-event-id"]);
|
|
69
|
+
if (Number.isFinite(last) && last > 0) {
|
|
70
|
+
for (const entry of this.ring) if (entry.id > last) res.write(entry.frame);
|
|
71
|
+
}
|
|
72
|
+
this.clients.add(res);
|
|
73
|
+
if (source != null) {
|
|
74
|
+
if (!this.bySource.has(source)) this.bySource.set(source, []);
|
|
75
|
+
this.bySource.get(source).push(res);
|
|
76
|
+
}
|
|
77
|
+
const timer = this.maxStreamMs > 0 ? setTimeout(() => res.end(), this.maxStreamMs) : null;
|
|
78
|
+
timer?.unref?.();
|
|
79
|
+
let dropped = false;
|
|
80
|
+
const drop = () => {
|
|
81
|
+
if (dropped) return;
|
|
82
|
+
dropped = true;
|
|
83
|
+
if (timer) clearTimeout(timer);
|
|
84
|
+
this.clients.delete(res);
|
|
85
|
+
const list = source == null ? null : this.bySource.get(source);
|
|
86
|
+
if (list) {
|
|
87
|
+
const i = list.indexOf(res);
|
|
88
|
+
if (i >= 0) list.splice(i, 1);
|
|
89
|
+
if (list.length === 0) this.bySource.delete(source);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
req.on("close", drop);
|
|
93
|
+
res.on("close", drop);
|
|
94
|
+
res.on("finish", drop);
|
|
95
|
+
res.on("error", drop);
|
|
96
|
+
return drop;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Broadcast a host message; its `type` is the SSE event name. Returns the event id. */
|
|
100
|
+
broadcast(message) {
|
|
101
|
+
const id = this.nextId++;
|
|
102
|
+
const frame = `id: ${id}\nevent: ${message.type}\ndata: ${JSON.stringify(message)}\n\n`;
|
|
103
|
+
const bytes = Buffer.byteLength(frame);
|
|
104
|
+
this.ring.push({ id, frame, bytes });
|
|
105
|
+
this.ringUsed += bytes;
|
|
106
|
+
while (this.ring.length > 1 && (this.ring.length > this.ringSize || this.ringUsed > this.ringBytes)) {
|
|
107
|
+
this.ringUsed -= this.ring.shift().bytes;
|
|
108
|
+
}
|
|
109
|
+
for (const res of this.clients) res.write(frame);
|
|
110
|
+
return id;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
close() {
|
|
114
|
+
clearInterval(this.ping);
|
|
115
|
+
for (const res of this.clients) res.end();
|
|
116
|
+
this.clients.clear();
|
|
117
|
+
this.bySource.clear();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/* Blank starter: the theme and site.js only, for anything the other starters don't fit (a quiz, a calculator…).
|
|
2
|
+
* Build the page here with site.h (text always via textContent) and the theme's few patterns: .card, .list,
|
|
3
|
+
* .field, .seg, .btn (at most one .btn.primary), .trust for any fact, .notice, .empty. Keep theme.css and site.js.
|
|
4
|
+
* Keep view state in the query (site.state / site.setState) and give rows ids, so shared links and Studio's
|
|
5
|
+
* Present / Follow show the same view.
|
|
6
|
+
* SPDX-License-Identifier: AGPL-3.0-only */
|
|
7
|
+
(function () {
|
|
8
|
+
"use strict";
|
|
9
|
+
site.start({
|
|
10
|
+
data: null,
|
|
11
|
+
render: function () {
|
|
12
|
+
site.head({});
|
|
13
|
+
var list = document.getElementById("list");
|
|
14
|
+
list.replaceChildren(site.empty("Nothing here yet.", null, "Ask in chat to build this page."));
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
})();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
|
|
7
|
+
<meta name="theme-color" content="#141414" media="(prefers-color-scheme: dark)">
|
|
8
|
+
<title>New page</title>
|
|
9
|
+
<link rel="stylesheet" href="theme.css">
|
|
10
|
+
<!-- Restyle only when asked: override --if-* tokens in a <style> here, after theme.css. -->
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<main class="page">
|
|
14
|
+
<header class="head"><h1 class="title" id="title">New page</h1><p class="lede" id="lede"></p></header>
|
|
15
|
+
<section id="list"></section>
|
|
16
|
+
<footer class="notes" id="notes" hidden></footer>
|
|
17
|
+
</main>
|
|
18
|
+
<script src="__bind/client.js"></script>
|
|
19
|
+
<script src="site.js"></script>
|
|
20
|
+
<script src="app.js"></script>
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/* Checklist starter: packing, to-do, steps, a shared list. Ticks are shared by every viewer and device.
|
|
2
|
+
* You design the content in data.json; keep theme.css and site.js. Restyle only when asked.
|
|
3
|
+
*
|
|
4
|
+
* data.json:
|
|
5
|
+
* title, lede
|
|
6
|
+
* people?: ["Ana", "Ben"] only when each person keeps their own ticks ("each of us packs")
|
|
7
|
+
* groups: [{id, title, items: [{ref: "item:charger", text, note?, fact?}]}]
|
|
8
|
+
* fact: {"v": "Sockets are type G", "src": "…", "url": "https://…", "date": "2026-10-17"}
|
|
9
|
+
* A tick sends set_check {ref, done, who?}; the binder stores it and every page shows it. Without a binder,
|
|
10
|
+
* ticks stay on this device. Checked items stay in place (muted, struck through). Query: ?who=<name>. A per-person
|
|
11
|
+
* list shows no ticks until the viewer picks a name ("Whose list is this?" with one plain button per person, "I'm
|
|
12
|
+
* Ana"); this browser remembers the pick. After that a segmented "Ana | Ben" switches lists.
|
|
13
|
+
* Rows: id="item-<slug>", data-ref="<ref>".
|
|
14
|
+
* SPDX-License-Identifier: AGPL-3.0-only */
|
|
15
|
+
(function () {
|
|
16
|
+
"use strict";
|
|
17
|
+
var h = site.h;
|
|
18
|
+
var optimistic = {}; // "<ref>|<attr>" -> {done, id}: shown at once, until the binder answers
|
|
19
|
+
var failed = {}; // "<ref>|<attr>" -> the tick that did not save
|
|
20
|
+
var mine = {}; // intent id -> "<ref>|<attr>"
|
|
21
|
+
var known = false; // the binder (if any) has said whether it stores ticks
|
|
22
|
+
|
|
23
|
+
var bind = window.instafyBind;
|
|
24
|
+
if (bind) {
|
|
25
|
+
bind.on("done", function (m) { var k = mine[m.id]; if (!k) return; delete mine[m.id]; delete optimistic[k]; site.schedule(); });
|
|
26
|
+
bind.on("error", function (m) { var k = mine[m.id]; if (!k) return; delete mine[m.id]; failed[k] = optimistic[k] && optimistic[k].done; delete optimistic[k]; site.schedule(); });
|
|
27
|
+
bind.on("ack", function (m) {
|
|
28
|
+
if (m.status !== "rejected" || !mine[m.id]) return;
|
|
29
|
+
var k = mine[m.id];
|
|
30
|
+
delete mine[m.id];
|
|
31
|
+
failed[k] = optimistic[k] && optimistic[k].done;
|
|
32
|
+
delete optimistic[k];
|
|
33
|
+
site.schedule();
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
site.ready().then(function () { known = true; site.schedule(); });
|
|
37
|
+
|
|
38
|
+
site.start({
|
|
39
|
+
data: "data.json",
|
|
40
|
+
// a per-person list never guesses whose it is: the viewer picks (and this browser remembers it)
|
|
41
|
+
state: { who: "" },
|
|
42
|
+
render: render
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function shared() { return site.canCheck("set_check", { readable: true }); }
|
|
46
|
+
function attrFor(who) {
|
|
47
|
+
if (!who) return "done";
|
|
48
|
+
return "done_" + (who.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 40) || "x");
|
|
49
|
+
}
|
|
50
|
+
function storeKey() { return "checklist:" + location.pathname; }
|
|
51
|
+
function readLocal() {
|
|
52
|
+
try { return JSON.parse(localStorage.getItem(storeKey()) || "{}") || {}; } catch (e) { return {}; }
|
|
53
|
+
}
|
|
54
|
+
function writeLocal(all) {
|
|
55
|
+
try { localStorage.setItem(storeKey(), JSON.stringify(all)); } catch (e) { /* private window: ticks last this visit */ }
|
|
56
|
+
}
|
|
57
|
+
var memory = null; // when localStorage is blocked
|
|
58
|
+
function isDone(ref, attr) {
|
|
59
|
+
var k = ref + "|" + attr;
|
|
60
|
+
if (optimistic[k]) return optimistic[k].done;
|
|
61
|
+
if (shared()) {
|
|
62
|
+
var r = site.live(ref, attr);
|
|
63
|
+
return !!(r && r.value === true);
|
|
64
|
+
}
|
|
65
|
+
var all = memory || readLocal();
|
|
66
|
+
if (all[attr] && ref in all[attr]) return !!all[attr][ref];
|
|
67
|
+
var saved = site.live(ref, attr); // a saved copy of the page still shows the ticks it was saved with
|
|
68
|
+
return !!(saved && saved.value === true);
|
|
69
|
+
}
|
|
70
|
+
function tick(ref, attr, who, done) {
|
|
71
|
+
var k = ref + "|" + attr;
|
|
72
|
+
delete failed[k];
|
|
73
|
+
if (shared()) {
|
|
74
|
+
var params = { ref: ref, done: done, at: new Date().toISOString() };
|
|
75
|
+
if (who) params.who = who;
|
|
76
|
+
try {
|
|
77
|
+
var id = bind.intent("set_check", params, { ref: ref, ui: "check" });
|
|
78
|
+
mine[id] = k;
|
|
79
|
+
optimistic[k] = { done: done, id: id };
|
|
80
|
+
} catch (e) {
|
|
81
|
+
failed[k] = done;
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
var all = memory || readLocal();
|
|
85
|
+
all[attr] = all[attr] || {};
|
|
86
|
+
all[attr][ref] = done;
|
|
87
|
+
writeLocal(all);
|
|
88
|
+
if (readLocal()[attr] === undefined) memory = all;
|
|
89
|
+
}
|
|
90
|
+
site.schedule();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
var restored = false;
|
|
94
|
+
function render(data, st) {
|
|
95
|
+
site.head(data);
|
|
96
|
+
var people = data.people || [];
|
|
97
|
+
if (people.length && !st.who && !restored) {
|
|
98
|
+
restored = true;
|
|
99
|
+
var mine = site.recall("who");
|
|
100
|
+
if (mine && people.indexOf(mine) >= 0) return site.setState({ who: mine });
|
|
101
|
+
}
|
|
102
|
+
var who = people.length && people.indexOf(st.who) >= 0 ? st.who : "";
|
|
103
|
+
var attr = attrFor(who);
|
|
104
|
+
var groups = data.groups || [];
|
|
105
|
+
var all = [];
|
|
106
|
+
groups.forEach(function (g) { (g.items || []).forEach(function (i) { all.push(i); }); });
|
|
107
|
+
var doneCount = all.filter(function (i) { return isDone(i.ref, attr); }).length;
|
|
108
|
+
|
|
109
|
+
var bar = document.getElementById("bar");
|
|
110
|
+
bar.replaceChildren();
|
|
111
|
+
var pick = function (v) { site.remember("who", v); site.setState({ who: v }); };
|
|
112
|
+
if (people.length && !who) {
|
|
113
|
+
// nothing to tick until the viewer says whose list it is, so nobody ticks someone else's bag: a question and one
|
|
114
|
+
// plain button per person (an empty segmented control looks broken, not like a question)
|
|
115
|
+
bar.appendChild(h("p", { "class": "progress pick-q", text: "Whose list is this?" }));
|
|
116
|
+
people.forEach(function (p) {
|
|
117
|
+
bar.appendChild(h("button", { "class": "btn", text: "I'm " + p, attrs: { type: "button", "data-key": "pick:" + p }, on: { click: function () { pick(p); } } }));
|
|
118
|
+
});
|
|
119
|
+
bar.hidden = false;
|
|
120
|
+
document.getElementById("list").replaceChildren();
|
|
121
|
+
site.notes([], []); // the notes belong to a list; there is none yet
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (people.length) bar.appendChild(site.seg("who", "Whose list", people.map(function (p) { return { value: p, label: p }; }), who, pick));
|
|
125
|
+
bar.hidden = !bar.firstChild;
|
|
126
|
+
|
|
127
|
+
var list = document.getElementById("list");
|
|
128
|
+
list.replaceChildren();
|
|
129
|
+
if (!all.length) {
|
|
130
|
+
list.appendChild(site.empty("Nothing on the list yet.", null, "Ask in chat to add some."));
|
|
131
|
+
site.notes([], []);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
var top = h("div", { "class": "stack" },
|
|
135
|
+
h("div", null,
|
|
136
|
+
h("p", { "class": "progress", text: (who ? who + ": " : "") + doneCount + " of " + all.length + " done" }),
|
|
137
|
+
known && !shared() ? h("p", { "class": "cap", text: "Ticks are saved on this device only." }) : null));
|
|
138
|
+
groups.forEach(function (g) {
|
|
139
|
+
var items = g.items || [];
|
|
140
|
+
var n = items.filter(function (i) { return isDone(i.ref, attr); }).length;
|
|
141
|
+
top.appendChild(h("section", { "class": "group", attrs: { id: "group-" + site.slug(g.id || g.title) } },
|
|
142
|
+
h("div", { "class": "group-head" }, h("h2", { text: g.title }), h("span", { "class": "cap", text: n + " of " + items.length })),
|
|
143
|
+
h("ul", { "class": "list checks" }, items.map(function (i) { return row(i, attr, who); }))));
|
|
144
|
+
});
|
|
145
|
+
list.appendChild(top);
|
|
146
|
+
|
|
147
|
+
var sources = [];
|
|
148
|
+
all.forEach(function (i) { sources = sources.concat(site.sourcesOf([i.fact])); });
|
|
149
|
+
site.notes(data.unchecked, sources);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function row(item, attr, who) {
|
|
153
|
+
var k = item.ref + "|" + attr;
|
|
154
|
+
var done = isDone(item.ref, attr);
|
|
155
|
+
var input = h("input", { attrs: { type: "checkbox", checked: done, disabled: !known, "data-key": "tick:" + item.ref } });
|
|
156
|
+
input.addEventListener("change", function () { tick(item.ref, attr, who, input.checked); });
|
|
157
|
+
var attrs = site.rowAttrs(item.ref, "item");
|
|
158
|
+
var linked = !!(item.fact && site.safeUrl(item.fact.url));
|
|
159
|
+
return h("li", { "class": "item", attrs: attrs },
|
|
160
|
+
h("label", { "class": "check" }, input,
|
|
161
|
+
h("span", { "class": "text" },
|
|
162
|
+
h("span", { "class": "label-text", text: item.text }),
|
|
163
|
+
item.note ? h("span", { "class": "cap", text: item.note }) : null,
|
|
164
|
+
// a fact without a link reads like a note, in the row ("To confirm · whether pools need a swim cap")
|
|
165
|
+
item.fact && !linked ? h("span", { "class": "item-fact cap" }, site.trust(item.fact, { unknown: "To confirm" })) : null)),
|
|
166
|
+
// a fact with a link sits under the row, outside the tick target, as a quiet caption
|
|
167
|
+
item.fact && linked ? h("p", { "class": "item-fact" }, site.trust(item.fact, { unknown: "To confirm" })) : null,
|
|
168
|
+
k in failed ? h("p", { "class": "cap err" }, site.icon("error"), " Couldn't save · ",
|
|
169
|
+
h("button", { "class": "link-btn", text: "Try again", attrs: { type: "button", "data-key": "retry:" + item.ref }, on: { click: function () { tick(item.ref, attr, who, failed[k] !== false); } } })) : null);
|
|
170
|
+
}
|
|
171
|
+
})();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"title": "Checklist",
|
|
4
|
+
"intents": {
|
|
5
|
+
"set_check": {
|
|
6
|
+
"label": "Tick",
|
|
7
|
+
"describe": "Store one tick so every viewer sees the same list. Refs and people must be in data.json.",
|
|
8
|
+
"executor": "script",
|
|
9
|
+
"handler": "handlers/set_check.mjs",
|
|
10
|
+
"params": {
|
|
11
|
+
"type": "object",
|
|
12
|
+
"required": ["ref", "done"],
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"properties": {
|
|
15
|
+
"ref": { "type": "string", "maxLength": 80, "pattern": "^item:[a-z0-9-]{1,60}$" },
|
|
16
|
+
"done": { "type": "boolean" },
|
|
17
|
+
"who": { "type": "string", "maxLength": 24, "pattern": "^[A-Za-z0-9À-ÿ' -]{1,24}$" },
|
|
18
|
+
"at": { "type": "string", "format": "date-time", "maxLength": 40 }
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"target": { "records": "data/records/set_check.json" },
|
|
22
|
+
"lanes": ["local"],
|
|
23
|
+
"pending": "item",
|
|
24
|
+
"cooldown_s": 0,
|
|
25
|
+
"timeout_s": 15
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"results": { "files": ["data/records/set_check.json"], "poll_s": 5, "sse": true }
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "Before the launch",
|
|
3
|
+
"lede": "Tick things off as you go. Everyone sees the same list.",
|
|
4
|
+
"groups": [
|
|
5
|
+
{
|
|
6
|
+
"id": "prepare",
|
|
7
|
+
"title": "Prepare",
|
|
8
|
+
"items": [
|
|
9
|
+
{ "ref": "item:book-room", "text": "Book the room", "fact": { "v": "Holds 40 people", "src": "Venue page", "url": "https://example.com/venue", "date": "2026-10-17" } },
|
|
10
|
+
{ "ref": "item:invitations", "text": "Send the invitations" },
|
|
11
|
+
{ "ref": "item:name-tags", "text": "Print name tags" },
|
|
12
|
+
{ "ref": "item:parking", "text": "Tell guests where to park", "fact": { "note": "parking near the venue" } }
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "on-the-day",
|
|
17
|
+
"title": "On the day",
|
|
18
|
+
"items": [
|
|
19
|
+
{ "ref": "item:projector", "text": "Test the projector" },
|
|
20
|
+
{ "ref": "item:chairs", "text": "Set out the chairs" },
|
|
21
|
+
{ "ref": "item:photos", "text": "Take a group photo" }
|
|
22
|
+
]
|
|
23
|
+
}
|
|
24
|
+
],
|
|
25
|
+
"unchecked": []
|
|
26
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
|
|
7
|
+
<meta name="theme-color" content="#141414" media="(prefers-color-scheme: dark)">
|
|
8
|
+
<title>Checklist</title>
|
|
9
|
+
<link rel="stylesheet" href="theme.css">
|
|
10
|
+
<!-- Restyle only when asked: override --if-* tokens in a <style> here, after theme.css. -->
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<main class="page">
|
|
14
|
+
<header class="head"><h1 class="title" id="title">Checklist</h1><p class="lede" id="lede"></p></header>
|
|
15
|
+
<div class="bar" id="bar" hidden></div>
|
|
16
|
+
<section id="list"></section>
|
|
17
|
+
<footer class="notes" id="notes" hidden></footer>
|
|
18
|
+
</main>
|
|
19
|
+
<script src="__bind/client.js"></script>
|
|
20
|
+
<script src="site.js"></script>
|
|
21
|
+
<script src="app.js"></script>
|
|
22
|
+
</body>
|
|
23
|
+
</html>
|