@lovek4d/kickflip 0.3.1 → 0.5.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/CONTEXT.md +170 -118
- package/README.md +23 -14
- package/SKILL.md +40 -38
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +34 -26
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +1 -1
- package/board/auto-review.mjs +2 -0
- package/board/bin/board.mjs +213 -74
- package/board/domain.mjs +8 -5
- package/board/finish.mjs +4 -0
- package/board/headless.mjs +4 -0
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +2 -2
- package/board/public/app-asks.js +39 -103
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +166 -73
- package/board/public/app-core.js +2 -23
- package/board/public/app-header.js +12 -5
- package/board/public/app-lanes.js +201 -47
- package/board/public/app-needs.js +2 -1
- package/board/public/app-reader.js +174 -0
- package/board/public/app-snapshot.js +2 -2
- package/board/public/app-threads.js +53 -42
- package/board/public/app.html +4 -1
- package/board/public/app.js +78 -96
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +59 -14
- package/board/public/hub-kit.js +9 -6
- package/board/public/hub.css +6 -18
- package/board/public/linear-key.js +6 -4
- package/board/public/log.html +48 -0
- package/board/public/new-task.js +121 -0
- package/board/public/prompts.js +9 -8
- package/board/public/prototype-kit.js +6 -6
- package/board/public/prototype-switcher.js +13 -5
- package/board/public/prototype.html +1 -0
- package/board/public/router.js +8 -7
- package/board/public/rules-editor.js +3 -4
- package/board/public/shell.js +24 -6
- package/board/public/sidebar.css +22 -1
- package/board/public/sidebar.js +64 -31
- package/board/public/style.css +245 -64
- package/board/public/toasts.js +2 -2
- package/board/public/ui-cards.js +205 -75
- package/board/public/ui-enums.js +6 -5
- package/board/public/ui-header.js +10 -10
- package/board/public/ui-hub.js +4 -4
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +9 -6
- package/board/public/ui-page-header.js +10 -7
- package/board/public/ui-patch.js +53 -0
- package/board/public/ui-phase-bars.js +17 -0
- package/board/public/ui-prompts.js +8 -8
- package/board/public/ui-questions.js +61 -29
- package/board/public/ui-sidebar.js +54 -24
- package/board/public/ui-snapshot.js +20 -9
- package/board/public/ui-threads.js +40 -21
- package/board/public/ui.js +116 -18
- package/board/public/view-frame.js +2 -1
- package/board/public/view-hub-settings.js +25 -18
- package/board/public/view-hub.js +53 -12
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +142 -62
- package/board/public/view-settings.js +189 -52
- package/board/review.mjs +1 -1
- package/board/server.mjs +20 -12
- package/board/store.mjs +15 -12
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +21 -15
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
- package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +4 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +11 -14
- package/hub/incoming.mjs +2 -0
- package/hub/linear.mjs +2 -2
- package/hub/live.mjs +2 -2
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -1
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -2
- package/hub/repos.mjs +3 -3
- package/hub/restart-board.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/sw.js +1 -1
- package/hub/try.mjs +8 -4
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +5 -1
- package/shared/card-prs.mjs +1 -0
- package/shared/colors.css +5 -4
- package/shared/fake-gh.mjs +59 -8
- package/shared/owner-check.mjs +3 -0
- package/shared/repos.mjs +1 -1
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
- package/board/public/view-polling.js +0 -87
|
@@ -4,9 +4,14 @@
|
|
|
4
4
|
// opens the topic whose pill was to its left before the Send, else to its right, and with no topic left that has an
|
|
5
5
|
// open question goes back to the board, where the Sent line shows as a toast (BoardUI.sentNotice). An open question
|
|
6
6
|
// with `urgent: true` in owner.json shows alone first, and its Send follows the same rule for its topic. A question
|
|
7
|
-
// whose options are prototype takes frames each take with ?take= (docs/adr/
|
|
7
|
+
// whose options are prototype takes frames each take with ?take= (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
|
|
8
8
|
// Each question's eye sends `prototype-request {key?, question, note, choice}` for that question instead of an answer,
|
|
9
|
-
// with its note box as the note and its pick so far as the choice (docs/adr/
|
|
9
|
+
// with its note box as the note and its pick so far as the choice (docs/adr/2026-11-02-1822-each-question-has-its-own-prototype-eye.md).
|
|
10
|
+
// The question then reads prototype requested, locked, until its prototype round arrives, and once the request is
|
|
11
|
+
// recorded the page moves on as a Send does (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md,
|
|
12
|
+
// docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
|
|
13
|
+
// A topic's resolved questions fold under "resolved (N)", read-only; a resolved question's badge, once confirmed, sends
|
|
14
|
+
// `reask-request {key?, question}` (docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md).
|
|
10
15
|
// fits: each framed take's measured size, kept across repaints and visits so a take redraws at its size at once.
|
|
11
16
|
(globalThis.AppViews ||= {}).questions = ((fits) => ({
|
|
12
17
|
className: 'view-questions',
|
|
@@ -18,7 +23,7 @@
|
|
|
18
23
|
|
|
19
24
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
20
25
|
const { $, site } = B;
|
|
21
|
-
const { esc, choiceLabel, questionCard, roundChip, takes, linkContext, badge, topicName, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
|
|
26
|
+
const { esc, choiceLabel, choiceOf, questionCard, roundChip, takes, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
|
|
22
27
|
// prototype-switcher.js reads a draft's note and clears the draft once it sends that question's answer.
|
|
23
28
|
const draftKey = (id) => 'qdraft:' + id;
|
|
24
29
|
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem(draftKey(id)) || '{}'); } catch { return {}; } };
|
|
@@ -26,12 +31,23 @@
|
|
|
26
31
|
const hashId = () => { const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
|
|
27
32
|
|
|
28
33
|
let all = [];
|
|
34
|
+
// The shown topic's resolved questions, which /api/board leaves out: GET /api/questions answers them as the topic
|
|
35
|
+
// shows, and again once its resolved count changes or after a failed load, at its next draw (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
|
|
36
|
+
let resolved = { topic: null, n: 0, questions: [] };
|
|
37
|
+
// The board last landed, whose version the next load and each tap name, so an unchanged board downloads nothing
|
|
38
|
+
// and a changed one comes as a patch (ui-patch.js).
|
|
39
|
+
let board = null;
|
|
29
40
|
let items = [];
|
|
30
41
|
let topics = [];
|
|
31
42
|
let topicId = null;
|
|
32
43
|
let soloId = null;
|
|
33
44
|
let shown = '';
|
|
34
45
|
let sentLine = '';
|
|
46
|
+
// The footer after a prototype request: the blue spinner, then these words.
|
|
47
|
+
const PROTOTYPE_REQUESTED = 'prototype requested';
|
|
48
|
+
// The bar's line while a suggested option is still pre-checked.
|
|
49
|
+
const SUGGESTED = 'suggested options are selected';
|
|
50
|
+
const statusHtml = (line) => (line.startsWith(PROTOTYPE_REQUESTED) ? '<span class="try-spin" aria-hidden="true"></span>' : '') + esc(line);
|
|
35
51
|
let scrollTake = null;
|
|
36
52
|
const takeResources = new Map();
|
|
37
53
|
const clearTakeResources = (within = root) => {
|
|
@@ -41,13 +57,14 @@
|
|
|
41
57
|
};
|
|
42
58
|
B.atUnmount(() => clearTakeResources());
|
|
43
59
|
|
|
44
|
-
const
|
|
60
|
+
const send = (kind, data) => site.send(kind, { ...data, ...(board ? { since: board.version } : {}) });
|
|
61
|
+
const byId = (id) => all.find((q) => q.id === id) || resolved.questions.find((q) => q.id === id);
|
|
45
62
|
const topicOf = (qid) => topics.find((t) => t.questions.includes(qid)) || null;
|
|
46
63
|
const openTopics = () => topics.filter((t) => t.state !== 'resolved');
|
|
47
64
|
const links = (q) => linkContext(items, q.topic?.startsWith('item:') ? q.topic.slice(5) : null, { base: './' });
|
|
48
65
|
const urgentOpen = () => all.find((q) => q.urgent && q.state === 'open');
|
|
49
66
|
|
|
50
|
-
// docs/adr/
|
|
67
|
+
// docs/adr/2026-10-29-1639-waiting-on-you-leads-with-what-needs-the-owner.md
|
|
51
68
|
function topicBar() {
|
|
52
69
|
const list = topicRow(topics, topicId).pills;
|
|
53
70
|
if (list.length < 2) return '';
|
|
@@ -56,16 +73,17 @@
|
|
|
56
73
|
}
|
|
57
74
|
const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
|
|
58
75
|
|
|
59
|
-
// Only questions changed here since their last answer,
|
|
76
|
+
// Only open questions changed here since their last answer, with files attached, or with a suggested option
|
|
77
|
+
// pre-checked and never answered, are sent; a resolved one is read-only.
|
|
60
78
|
const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
|
|
61
79
|
const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
|
|
62
80
|
function pending(qs) {
|
|
63
|
-
return qs.filter((q) => {
|
|
64
|
-
if (q.state === 'requested') return false;
|
|
81
|
+
return qs.filter((q) => q.state !== 'resolved' && q.state !== 'requested').filter((q) => {
|
|
65
82
|
const d = getDraft(q.id);
|
|
66
83
|
if (filesOf(q).length) return true;
|
|
67
|
-
if (!('choice' in d) && !d.note) return false;
|
|
68
84
|
const last = q.answers[q.answers.length - 1];
|
|
85
|
+
if (!last && q.suggested) return true;
|
|
86
|
+
if (!('choice' in d) && !d.note) return false;
|
|
69
87
|
return !last || (d.choice ?? last.choice) !== last.choice || (d.note || '') !== (last.note || '');
|
|
70
88
|
});
|
|
71
89
|
}
|
|
@@ -76,8 +94,21 @@
|
|
|
76
94
|
return t ? t.questions.map(byId).filter(Boolean) : [];
|
|
77
95
|
}
|
|
78
96
|
|
|
97
|
+
function loadResolved() {
|
|
98
|
+
const t = soloId ? null : topics.find((x) => x.id === topicId);
|
|
99
|
+
if (!t?.resolved || (resolved.topic === t.id && resolved.n === t.resolved)) return;
|
|
100
|
+
resolved = { topic: t.id, n: t.resolved, questions: [] };
|
|
101
|
+
const asked = resolved;
|
|
102
|
+
fetchJSON(site.url('api/questions?topic=' + encodeURIComponent(t.id)), { sleep: B.sleep }).then(({ body }) => {
|
|
103
|
+
if (!B.alive() || resolved !== asked) return;
|
|
104
|
+
asked.questions = body.questions || [];
|
|
105
|
+
repaint(false);
|
|
106
|
+
}, () => { if (resolved === asked) resolved = { topic: null, n: 0, questions: [] }; });
|
|
107
|
+
}
|
|
108
|
+
|
|
79
109
|
function render() {
|
|
80
110
|
clearTakeResources();
|
|
111
|
+
loadResolved();
|
|
81
112
|
const el = $('#page');
|
|
82
113
|
const qs = visible();
|
|
83
114
|
if (!qs.length) {
|
|
@@ -93,8 +124,8 @@
|
|
|
93
124
|
: `${topicBar()}${topicHead(t)}`;
|
|
94
125
|
el.innerHTML = `${head}<form id="qform">
|
|
95
126
|
${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}
|
|
96
|
-
${done.length ? `<details class="done"><summary>
|
|
97
|
-
<div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${
|
|
127
|
+
${done.length ? `<details class="done resolved-group"><summary><span class="caret" aria-hidden="true">›</span><span>resolved (${done.length})</span><span class="names">${done.map((q) => esc(q.id)).join(', ')}</span></summary>${done.map((q) => questionCard(q, { links: links(q) })).join('')}</details>` : ''}
|
|
128
|
+
<div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${statusHtml(sentLine)}</span><span class="send-hint" aria-hidden="true">${/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘↵' : 'Ctrl+↵'}</span><button class="primary" type="submit" id="send">Send</button></div>
|
|
98
129
|
</form>${soloId ? '' : waitingTopics() + resolvedTopics()}`;
|
|
99
130
|
BoardAttach.paintAll();
|
|
100
131
|
el.querySelectorAll('.take-shot').forEach(fitTake);
|
|
@@ -104,7 +135,7 @@
|
|
|
104
135
|
// A width sets its page's height, so a size measured at another width is measured again.
|
|
105
136
|
const fitKey = (qid, oid) => `${site.root}\n${qid}\n${oid}`;
|
|
106
137
|
const fitOf = (q) => (oid) => { const f = fits.get(fitKey(q.id, oid)); return f && f.w === window.innerWidth ? f : null; };
|
|
107
|
-
// A phone's header is a bar at the bottom (docs/adr/
|
|
138
|
+
// A phone's header is a bar at the bottom (docs/adr/2026-10-31-1532-phone-headers-sit-at-the-bottom.md), so only a header
|
|
108
139
|
// at the top covers the frame's top.
|
|
109
140
|
const frameTop = (shot) => {
|
|
110
141
|
const head = $('header.top')?.getBoundingClientRect();
|
|
@@ -114,11 +145,9 @@
|
|
|
114
145
|
function repaintTakes(sec) {
|
|
115
146
|
if (!sec.querySelector('.takes')) return;
|
|
116
147
|
const q = byId(sec.dataset.q);
|
|
117
|
-
const d = getDraft(q.id);
|
|
118
|
-
const chosen = d.choice ?? q.answers[q.answers.length - 1]?.choice;
|
|
119
148
|
const old = sec.querySelector('.takes');
|
|
120
149
|
clearTakeResources(old);
|
|
121
|
-
old.outerHTML = takes(q,
|
|
150
|
+
old.outerHTML = takes(q, choiceOf(q, getDraft(q.id)), links(q), fitOf(q));
|
|
122
151
|
sec.querySelectorAll('.take-shot').forEach(fitTake);
|
|
123
152
|
}
|
|
124
153
|
|
|
@@ -128,7 +157,7 @@
|
|
|
128
157
|
// A framed take is sized to its page once it loads, since the frame can't scroll. A page that fills its frame at
|
|
129
158
|
// any height is a full-screen take and gets screenH. A full-screen take, or a grid take past 80% of the screen's
|
|
130
159
|
// height, turns its question into one full-width take under a choice row
|
|
131
|
-
// (docs/adr/
|
|
160
|
+
// (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
|
|
132
161
|
function fitTake(shot) {
|
|
133
162
|
const frame = shot.querySelector('iframe');
|
|
134
163
|
const sec = shot.closest('section.q');
|
|
@@ -202,7 +231,7 @@
|
|
|
202
231
|
function waitingTopics() {
|
|
203
232
|
const list = topicRow(topics, topicId).waiting;
|
|
204
233
|
return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
|
|
205
|
-
`<div class="trow"><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button><span class="muted small">${t.
|
|
234
|
+
`<div class="trow"><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button><span class="muted small">${t.answered ? 'answered' : 'Claude replied'}</span></div>`).join('')}</details>` : '';
|
|
206
235
|
}
|
|
207
236
|
|
|
208
237
|
function resolvedTopics() {
|
|
@@ -211,27 +240,61 @@
|
|
|
211
240
|
`<div><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button></div>`).join('')}</details>` : '';
|
|
212
241
|
}
|
|
213
242
|
|
|
214
|
-
|
|
243
|
+
// The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
|
|
244
|
+
function leaving() {
|
|
245
|
+
const from = soloId ? topicOf(soloId)?.id : topicId;
|
|
246
|
+
return { from, pills: topicRow(topics, from).pills, name: topicName(topics.find((t) => t.id === from) || { title: '' }) };
|
|
247
|
+
}
|
|
248
|
+
// After a recorded tap: a load that started before it went out would read its question as still open.
|
|
249
|
+
// `footer` is a line or, for a Send, a function of the reloaded board giving the whole line.
|
|
250
|
+
// The tap's POST answers with the board as it left it, which lands here in place of a reload.
|
|
251
|
+
async function moveOnOnceLoaded({ from, pills }, what, out, footer = what) {
|
|
252
|
+
await loading;
|
|
253
|
+
if (!B.alive()) return;
|
|
254
|
+
if (!land(out.board, false)) await load(false);
|
|
255
|
+
moveOn(from, pills, what, typeof footer === 'function' ? footer() : footer, typeof footer !== 'function');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The tapped Prototype button waits (ui.js waitOn) while the request is sent.
|
|
259
|
+
async function askPrototype(q, button) {
|
|
215
260
|
const st = $('#qstatus');
|
|
216
261
|
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
217
|
-
|
|
262
|
+
const at = leaving();
|
|
263
|
+
st.textContent = '';
|
|
218
264
|
const d = getDraft(q.id);
|
|
219
|
-
|
|
220
|
-
try { await
|
|
221
|
-
catch { st.textContent = 'Could not send. Is the host awake?'; return; }
|
|
265
|
+
let out;
|
|
266
|
+
try { out = await BoardUI.waitOn(button, send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: choiceOf(q, d) })); }
|
|
267
|
+
catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
|
|
222
268
|
setDraft(q.id, {});
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
269
|
+
await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A resolved question's badge asks Claude to ask it again from the card's current notes, once the owner confirms.
|
|
273
|
+
// The topic's resolved questions load again, so the question shows its re-ask under way.
|
|
274
|
+
async function askAgain(q, button) {
|
|
275
|
+
if (!(await BoardDialogs.confirm('Ask this again from today’s context?')) || !B.alive()) return;
|
|
276
|
+
const st = $('#qstatus');
|
|
277
|
+
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
278
|
+
let out;
|
|
279
|
+
try { out = await BoardUI.waitOn(button, send('reask-request', { ...(key ? { key } : {}), question: q.id })); }
|
|
280
|
+
catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
|
|
281
|
+
if (!B.alive()) return;
|
|
282
|
+
resolved = { topic: null, n: 0, questions: [] };
|
|
283
|
+
if (!land(out.board, false)) await load(false);
|
|
284
|
+
repaint(false);
|
|
226
285
|
}
|
|
227
286
|
|
|
228
287
|
function updateBar() {
|
|
229
288
|
const qs = visible();
|
|
230
289
|
const n = pending(qs).length;
|
|
231
290
|
const b = $('#send');
|
|
232
|
-
if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' :
|
|
291
|
+
if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
|
|
233
292
|
const live = qs.filter((q) => q.state !== 'resolved');
|
|
234
293
|
const answered = live.filter((q) => q.state !== 'open').length;
|
|
294
|
+
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
295
|
+
const prechecked = live.some((q) => q.suggested && !q.answers.length && choiceOf(q, getDraft(q.id)) === q.suggested.option);
|
|
296
|
+
const st = $('#qstatus');
|
|
297
|
+
if (st && !sentLine && (prechecked || st.textContent === SUGGESTED)) st.textContent = prechecked ? SUGGESTED : '';
|
|
235
298
|
$('#progress').textContent = live.length > 1 ? `${answered}/${live.length} answered` : '';
|
|
236
299
|
}
|
|
237
300
|
|
|
@@ -243,7 +306,7 @@
|
|
|
243
306
|
topicId = t ? t.id : null;
|
|
244
307
|
}
|
|
245
308
|
|
|
246
|
-
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered, t.round]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply))])]);
|
|
309
|
+
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered, t.round]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply)), Boolean(q.reask)])]);
|
|
247
310
|
// One load at a time: a load waiting out a board restart would otherwise overlap the next poll's. A load asked for
|
|
248
311
|
// while one runs shares it. → true when the board's answer landed in this view.
|
|
249
312
|
let loading = null;
|
|
@@ -252,45 +315,58 @@
|
|
|
252
315
|
async function fetchBoard(first) {
|
|
253
316
|
const page = $('#page');
|
|
254
317
|
try {
|
|
255
|
-
const { body: d } = await fetchJSON(site.url('api/board'), { onWait: () => { if (first) page.innerHTML = restartNotice(); }, sleep: B.sleep });
|
|
318
|
+
const { body: d } = await fetchJSON(site.url('api/board' + (board ? '?since=' + encodeURIComponent(board.version) : '')), { tag: board?.version, onWait: () => { if (first) page.innerHTML = restartNotice(); }, sleep: B.sleep });
|
|
256
319
|
if (!B.alive()) return false;
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
topics = d.ownerTopics || [];
|
|
260
|
-
site.syncBadge();
|
|
261
|
-
if (first) pick(hashId());
|
|
262
|
-
else if (soloId && byId(soloId)?.state !== 'open') pick('');
|
|
263
|
-
else if (!topics.some((t) => t.id === topicId)) pick('');
|
|
264
|
-
loaded = true;
|
|
265
|
-
const sig = sigNow();
|
|
266
|
-
const active = document.activeElement;
|
|
267
|
-
if (sig !== shown && !(active && active.tagName === 'TEXTAREA')) {
|
|
268
|
-
shown = sig;
|
|
269
|
-
const y = scrollY;
|
|
270
|
-
render();
|
|
271
|
-
scrollTo(0, first ? 0 : y);
|
|
272
|
-
}
|
|
320
|
+
if (d) land(d, first);
|
|
321
|
+
repaint(first);
|
|
273
322
|
return true;
|
|
274
323
|
} catch (e) {
|
|
275
324
|
if (first) page.innerHTML = `<p class="muted">Could not load the questions: ${esc(e.message)}</p>`;
|
|
276
325
|
return false;
|
|
277
326
|
}
|
|
278
327
|
}
|
|
328
|
+
// A board, or a patch from the board held, from a load or a tap's POST. → false for a patch from a board no longer
|
|
329
|
+
// held, which leaves the page as it is.
|
|
330
|
+
function land(d, first) {
|
|
331
|
+
const next = d.since === undefined ? d : BoardUI.applyPatch(board, d);
|
|
332
|
+
if (!next) return false;
|
|
333
|
+
board = next;
|
|
334
|
+
all = board.owner;
|
|
335
|
+
items = board.items;
|
|
336
|
+
topics = cardTopics(board.ownerTopics, items);
|
|
337
|
+
site.syncBadge();
|
|
338
|
+
if (first) pick(hashId());
|
|
339
|
+
else if (soloId && byId(soloId)?.state !== 'open') pick('');
|
|
340
|
+
else if (!topics.some((t) => t.id === topicId)) pick('');
|
|
341
|
+
loaded = true;
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
344
|
+
// Draws what changed, unless the owner is typing a note.
|
|
345
|
+
function repaint(first) {
|
|
346
|
+
const sig = sigNow();
|
|
347
|
+
const active = document.activeElement;
|
|
348
|
+
if (sig !== shown && !(active && active.tagName === 'TEXTAREA')) {
|
|
349
|
+
shown = sig;
|
|
350
|
+
const y = scrollY;
|
|
351
|
+
render();
|
|
352
|
+
scrollTo(0, first ? 0 : y);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
279
355
|
|
|
280
|
-
function moveOn(from, pills, what) {
|
|
356
|
+
function moveOn(from, pills, what, footer = what, countOpen = true) {
|
|
281
357
|
soloId = null;
|
|
282
358
|
const next = nextQuestion(all, pills, { topic: from });
|
|
283
359
|
const left = topics.find((t) => t.id === from)?.open || 0;
|
|
284
360
|
if (next?.topic === from) {
|
|
285
361
|
topicId = from;
|
|
286
|
-
sentLine = `${
|
|
362
|
+
sentLine = countOpen ? `${footer.replace(/\.?$/, '.')} ${left} still open.` : footer;
|
|
287
363
|
shown = sigNow();
|
|
288
364
|
const y = scrollY;
|
|
289
365
|
render();
|
|
290
366
|
scrollTo(0, y);
|
|
291
367
|
return;
|
|
292
368
|
}
|
|
293
|
-
sentLine =
|
|
369
|
+
sentLine = footer;
|
|
294
370
|
if (!next) {
|
|
295
371
|
sentNotice.put(B.route, what);
|
|
296
372
|
if (ctx.up?.()) return;
|
|
@@ -308,9 +384,16 @@
|
|
|
308
384
|
B.on(root, 'click', (ev) => {
|
|
309
385
|
if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
|
|
310
386
|
const eye = ev.target.closest && ev.target.closest('[data-proto]');
|
|
311
|
-
if (eye) {
|
|
387
|
+
if (eye) { askPrototype(byId(eye.closest('section.q').dataset.q), eye); return; }
|
|
388
|
+
const reask = ev.target.closest && ev.target.closest('[data-reask]');
|
|
389
|
+
if (reask) { if (!reask.disabled) askAgain(byId(reask.dataset.reask), reask); return; }
|
|
312
390
|
const b = ev.target.closest && ev.target.closest('[data-topic]');
|
|
313
|
-
if (b) {
|
|
391
|
+
if (b) {
|
|
392
|
+
soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = '';
|
|
393
|
+
const first = topics.find((t) => t.id === topicId)?.questions[0];
|
|
394
|
+
if (first) history.replaceState(history.state, '', '#' + encodeURIComponent(first));
|
|
395
|
+
render(); scrollTo(0, 0);
|
|
396
|
+
}
|
|
314
397
|
});
|
|
315
398
|
// The full-width frame shows another take without reloading, where its page's switcher can
|
|
316
399
|
// (prototype-switcher.js current.go), so the page keeps its place. → false when it can't.
|
|
@@ -359,29 +442,26 @@
|
|
|
359
442
|
const st = $('#qstatus');
|
|
360
443
|
if (uploading(visible())) return;
|
|
361
444
|
if (!list.length) { st.textContent = 'Pick an option or write a note first.'; return; }
|
|
362
|
-
st.textContent = '
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
const name = topicName(topics.find((t) => t.id === from) || { title: '' });
|
|
366
|
-
// docs/adr/0163-send-records-every-ready-answer-in-one-request.md: one request, recorded all or none.
|
|
445
|
+
st.textContent = '';
|
|
446
|
+
const at = leaving();
|
|
447
|
+
// docs/adr/2026-11-03-1519-send-records-every-ready-answer-in-one-request.md: one request, recorded all or none. Send waits meanwhile.
|
|
367
448
|
const answers = list.map((q) => {
|
|
368
449
|
const d = getDraft(q.id);
|
|
369
|
-
const
|
|
370
|
-
const choice = d.choice ?? (last ? last.choice : null);
|
|
450
|
+
const choice = choiceOf(q, d);
|
|
371
451
|
const files = filesOf(q);
|
|
372
452
|
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) };
|
|
373
453
|
});
|
|
374
|
-
|
|
454
|
+
let out;
|
|
455
|
+
try { out = await BoardUI.waitOn($('#send'), send('owner-answers', { answers })); }
|
|
375
456
|
catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
|
|
376
457
|
for (const q of list) {
|
|
377
458
|
setDraft(q.id, {});
|
|
378
459
|
BoardAttach.clear('q:' + q.id, site.root);
|
|
379
460
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
else if (B.alive()) { sentLine = what; $('#qstatus').textContent = what; $('#qstatus').classList.add('sent'); }
|
|
461
|
+
await moveOnOnceLoaded(at, `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${at.name ? ` to ${at.name}` : ''}.`, out, () => {
|
|
462
|
+
const open = all.filter((q) => q.state === 'open').length;
|
|
463
|
+
return `sent ${list.length} answer${list.length === 1 ? '' : 's'}${open ? ` · ${open} still open` : ''}`;
|
|
464
|
+
});
|
|
385
465
|
});
|
|
386
466
|
// Each composer painted raises a bubbling attach-change (attach.js).
|
|
387
467
|
B.on(root, 'attach-change', updateBar);
|