@lovek4d/kickflip 0.1.0 → 0.4.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 +239 -104
- package/README.md +54 -4
- package/SKILL.md +63 -42
- package/bin/doctor.mjs +4 -3
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +103 -40
- package/bin/statusline.mjs +4 -3
- package/bin/wait-events.mjs +2 -2
- package/board/arch-review.mjs +5 -0
- package/board/auto-review.mjs +2 -0
- package/board/bin/agent.mjs +1 -1
- package/board/bin/board.mjs +371 -82
- package/board/bin/builder-stop-hook.mjs +4 -3
- package/board/domain.mjs +8 -3
- package/board/finish.mjs +3 -0
- package/board/headless.mjs +3 -0
- package/board/on-host.mjs +1 -0
- package/board/permissions.mjs +2 -0
- package/board/public/app-arch.js +75 -0
- package/board/public/app-asks.js +66 -28
- package/board/public/app-banner.js +2 -2
- package/board/public/app-cards.js +354 -77
- package/board/public/app-core.js +40 -8
- package/board/public/app-header.js +33 -8
- package/board/public/app-lanes.js +238 -34
- package/board/public/app-needs.js +2 -0
- package/board/public/app-snapshot.js +45 -11
- package/board/public/app-threads.js +20 -21
- package/board/public/app.html +6 -2
- package/board/public/app.js +114 -33
- package/board/public/attach.js +39 -3
- package/board/public/dialogs.js +161 -118
- package/board/public/hub-kit.js +27 -7
- package/board/public/hub.css +26 -33
- package/board/public/linear-key.js +143 -0
- package/board/public/log.html +48 -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 +14 -10
- package/board/public/rules-editor.js +4 -5
- package/board/public/shell.js +21 -7
- package/board/public/sidebar.css +51 -11
- package/board/public/sidebar.js +124 -19
- package/board/public/style.css +244 -56
- package/board/public/toasts.js +5 -3
- package/board/public/ui-cards.js +236 -66
- package/board/public/ui-enums.js +5 -4
- package/board/public/ui-header.js +23 -14
- package/board/public/ui-hub.js +34 -16
- package/board/public/ui-links.js +18 -10
- package/board/public/ui-markdown.js +1 -1
- package/board/public/ui-order.js +21 -0
- package/board/public/ui-page-header.js +13 -8
- 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 +35 -11
- package/board/public/ui-sidebar.js +83 -21
- package/board/public/ui-snapshot.js +30 -13
- package/board/public/ui-threads.js +38 -23
- package/board/public/ui.js +96 -16
- package/board/public/view-frame.js +1 -1
- package/board/public/view-hub-settings.js +67 -44
- package/board/public/view-hub.js +105 -13
- package/board/public/view-notes.js +8 -5
- package/board/public/view-questions.js +123 -52
- package/board/public/view-settings.js +235 -62
- package/board/review.mjs +1 -0
- package/board/server.mjs +18 -7
- package/board/store.mjs +15 -6
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +20 -13
- package/docs/hub-session.md +4 -4
- package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
- package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
- package/hub/awake.mjs +1 -0
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/close-board.mjs +1 -1
- package/hub/duration.mjs +1 -0
- package/hub/github.mjs +3 -4
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +12 -10
- 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/permission-denied-hook.mjs +2 -0
- package/hub/pr-report.mjs +3 -0
- package/hub/presence.mjs +1 -1
- package/hub/prompts.mjs +1 -1
- package/hub/push.mjs +2 -2
- package/hub/readiness.mjs +2 -0
- package/hub/repos.mjs +3 -2
- package/hub/restart-board.mjs +1 -0
- package/hub/routes.mjs +1 -1
- package/hub/schedule.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/shell.mjs +2 -1
- package/hub/slack.mjs +2 -0
- package/hub/stream.mjs +12 -12
- package/hub/sw.js +2 -2
- package/hub/trust.mjs +2 -0
- package/hub/try.mjs +8 -3
- package/hub/upgrade.mjs +1 -0
- 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 +9 -6
- package/shared/fake-gh.mjs +105 -0
- package/shared/repos.mjs +1 -1
- package/shared/service.mjs +16 -16
- package/shared/skills.mjs +2 -0
- package/shared/usage-gate.mjs +1 -1
- package/starter/public/claude.js +3 -2
- package/bin/web-ts.mjs +0 -2
- package/board/public/view-polling.js +0 -87
|
@@ -4,7 +4,11 @@
|
|
|
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
|
+
// 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/2026-11-02-1822-each-question-has-its-own-prototype-eye.md).
|
|
10
|
+
// The board server resolves the question with it, and once it is recorded the page moves on as a Send does
|
|
11
|
+
// (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md).
|
|
8
12
|
// fits: each framed take's measured size, kept across repaints and visits so a take redraws at its size at once.
|
|
9
13
|
(globalThis.AppViews ||= {}).questions = ((fits) => ({
|
|
10
14
|
className: 'view-questions',
|
|
@@ -16,7 +20,7 @@
|
|
|
16
20
|
|
|
17
21
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
18
22
|
const { $, site } = B;
|
|
19
|
-
const { esc, choiceLabel, questionCard, takes, linkContext, badge, topicName, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
|
|
23
|
+
const { esc, choiceLabel, questionCard, roundChip, takes, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
|
|
20
24
|
// prototype-switcher.js reads a draft's note and clears the draft once it sends that question's answer.
|
|
21
25
|
const draftKey = (id) => 'qdraft:' + id;
|
|
22
26
|
const getDraft = (id) => { try { return JSON.parse(localStorage.getItem(draftKey(id)) || '{}'); } catch { return {}; } };
|
|
@@ -24,12 +28,21 @@
|
|
|
24
28
|
const hashId = () => { const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
|
|
25
29
|
|
|
26
30
|
let all = [];
|
|
31
|
+
// The shown topic's resolved questions, which /api/board leaves out: GET /api/questions answers them as the topic
|
|
32
|
+
// 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).
|
|
33
|
+
let resolved = { topic: null, n: 0, questions: [] };
|
|
34
|
+
// The board last landed, whose version the next load and each tap name, so an unchanged board downloads nothing
|
|
35
|
+
// and a changed one comes as a patch (ui-patch.js).
|
|
36
|
+
let board = null;
|
|
27
37
|
let items = [];
|
|
28
38
|
let topics = [];
|
|
29
39
|
let topicId = null;
|
|
30
40
|
let soloId = null;
|
|
31
41
|
let shown = '';
|
|
32
42
|
let sentLine = '';
|
|
43
|
+
// The footer after a prototype request: the blue spinner, then these words.
|
|
44
|
+
const PROTOTYPE_REQUESTED = 'prototype requested';
|
|
45
|
+
const statusHtml = (line) => (line.startsWith(PROTOTYPE_REQUESTED) ? '<span class="try-spin" aria-hidden="true"></span>' : '') + esc(line);
|
|
33
46
|
let scrollTake = null;
|
|
34
47
|
const takeResources = new Map();
|
|
35
48
|
const clearTakeResources = (within = root) => {
|
|
@@ -39,20 +52,21 @@
|
|
|
39
52
|
};
|
|
40
53
|
B.atUnmount(() => clearTakeResources());
|
|
41
54
|
|
|
42
|
-
const
|
|
55
|
+
const send = (kind, data) => site.send(kind, { ...data, ...(board ? { since: board.version } : {}) });
|
|
56
|
+
const byId = (id) => all.find((q) => q.id === id) || resolved.questions.find((q) => q.id === id);
|
|
43
57
|
const topicOf = (qid) => topics.find((t) => t.questions.includes(qid)) || null;
|
|
44
58
|
const openTopics = () => topics.filter((t) => t.state !== 'resolved');
|
|
45
59
|
const links = (q) => linkContext(items, q.topic?.startsWith('item:') ? q.topic.slice(5) : null, { base: './' });
|
|
46
60
|
const urgentOpen = () => all.find((q) => q.urgent && q.state === 'open');
|
|
47
61
|
|
|
48
|
-
// docs/adr/
|
|
62
|
+
// docs/adr/2026-10-29-1639-waiting-on-you-leads-with-what-needs-the-owner.md
|
|
49
63
|
function topicBar() {
|
|
50
64
|
const list = topicRow(topics, topicId).pills;
|
|
51
65
|
if (list.length < 2) return '';
|
|
52
66
|
return `<div class="topics" role="group" aria-label="Topics">${list.map((t) =>
|
|
53
67
|
`<button data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}">${esc(topicName(t))}<span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`).join('')}</div>`;
|
|
54
68
|
}
|
|
55
|
-
const topicHead = (t) => t ? `<div class="topichead muted small">${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</div>` : '';
|
|
69
|
+
const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
|
|
56
70
|
|
|
57
71
|
// Only questions changed here since their last answer, or with files attached, are sent.
|
|
58
72
|
const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
|
|
@@ -73,8 +87,21 @@
|
|
|
73
87
|
return t ? t.questions.map(byId).filter(Boolean) : [];
|
|
74
88
|
}
|
|
75
89
|
|
|
90
|
+
function loadResolved() {
|
|
91
|
+
const t = soloId ? null : topics.find((x) => x.id === topicId);
|
|
92
|
+
if (!t?.resolved || (resolved.topic === t.id && resolved.n === t.resolved)) return;
|
|
93
|
+
resolved = { topic: t.id, n: t.resolved, questions: [] };
|
|
94
|
+
const asked = resolved;
|
|
95
|
+
fetchJSON(site.url('api/questions?topic=' + encodeURIComponent(t.id)), { sleep: B.sleep }).then(({ body }) => {
|
|
96
|
+
if (!B.alive() || resolved !== asked) return;
|
|
97
|
+
asked.questions = body.questions || [];
|
|
98
|
+
repaint(false);
|
|
99
|
+
}, () => { if (resolved === asked) resolved = { topic: null, n: 0, questions: [] }; });
|
|
100
|
+
}
|
|
101
|
+
|
|
76
102
|
function render() {
|
|
77
103
|
clearTakeResources();
|
|
104
|
+
loadResolved();
|
|
78
105
|
const el = $('#page');
|
|
79
106
|
const qs = visible();
|
|
80
107
|
if (!qs.length) {
|
|
@@ -91,7 +118,7 @@
|
|
|
91
118
|
el.innerHTML = `${head}<form id="qform">
|
|
92
119
|
${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}
|
|
93
120
|
${done.length ? `<details class="done"><summary>Resolved · ${done.length}</summary>${done.map((q) => questionCard(q, { draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}</details>` : ''}
|
|
94
|
-
<div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${
|
|
121
|
+
<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>
|
|
95
122
|
</form>${soloId ? '' : waitingTopics() + resolvedTopics()}`;
|
|
96
123
|
BoardAttach.paintAll();
|
|
97
124
|
el.querySelectorAll('.take-shot').forEach(fitTake);
|
|
@@ -101,7 +128,7 @@
|
|
|
101
128
|
// A width sets its page's height, so a size measured at another width is measured again.
|
|
102
129
|
const fitKey = (qid, oid) => `${site.root}\n${qid}\n${oid}`;
|
|
103
130
|
const fitOf = (q) => (oid) => { const f = fits.get(fitKey(q.id, oid)); return f && f.w === window.innerWidth ? f : null; };
|
|
104
|
-
// A phone's header is a bar at the bottom (docs/adr/
|
|
131
|
+
// 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
|
|
105
132
|
// at the top covers the frame's top.
|
|
106
133
|
const frameTop = (shot) => {
|
|
107
134
|
const head = $('header.top')?.getBoundingClientRect();
|
|
@@ -125,7 +152,7 @@
|
|
|
125
152
|
// A framed take is sized to its page once it loads, since the frame can't scroll. A page that fills its frame at
|
|
126
153
|
// any height is a full-screen take and gets screenH. A full-screen take, or a grid take past 80% of the screen's
|
|
127
154
|
// height, turns its question into one full-width take under a choice row
|
|
128
|
-
// (docs/adr/
|
|
155
|
+
// (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
|
|
129
156
|
function fitTake(shot) {
|
|
130
157
|
const frame = shot.querySelector('iframe');
|
|
131
158
|
const sec = shot.closest('section.q');
|
|
@@ -208,11 +235,41 @@
|
|
|
208
235
|
`<div><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button></div>`).join('')}</details>` : '';
|
|
209
236
|
}
|
|
210
237
|
|
|
238
|
+
// The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
|
|
239
|
+
function leaving() {
|
|
240
|
+
const from = soloId ? topicOf(soloId)?.id : topicId;
|
|
241
|
+
return { from, pills: topicRow(topics, from).pills, name: topicName(topics.find((t) => t.id === from) || { title: '' }) };
|
|
242
|
+
}
|
|
243
|
+
// After a recorded tap: a load that started before it went out would read its question as still open.
|
|
244
|
+
// `footer` is a line or, for a Send, a function of the reloaded board giving the whole line.
|
|
245
|
+
// The tap's POST answers with the board as it left it, which lands here in place of a reload.
|
|
246
|
+
async function moveOnOnceLoaded({ from, pills }, what, out, footer = what) {
|
|
247
|
+
await loading;
|
|
248
|
+
if (!B.alive()) return;
|
|
249
|
+
if (!land(out.board, false)) await load(false);
|
|
250
|
+
moveOn(from, pills, what, typeof footer === 'function' ? footer() : footer, typeof footer !== 'function');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// The tapped Prototype button waits (ui.js waitOn) while the request is sent.
|
|
254
|
+
async function askPrototype(q, button) {
|
|
255
|
+
const st = $('#qstatus');
|
|
256
|
+
const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
|
|
257
|
+
const at = leaving();
|
|
258
|
+
st.textContent = '';
|
|
259
|
+
const d = getDraft(q.id);
|
|
260
|
+
const last = q.answers[q.answers.length - 1];
|
|
261
|
+
let out;
|
|
262
|
+
try { out = await BoardUI.waitOn(button, send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: d.choice ?? (last ? last.choice : null) })); }
|
|
263
|
+
catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
|
|
264
|
+
setDraft(q.id, {});
|
|
265
|
+
await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
|
|
266
|
+
}
|
|
267
|
+
|
|
211
268
|
function updateBar() {
|
|
212
269
|
const qs = visible();
|
|
213
270
|
const n = pending(qs).length;
|
|
214
271
|
const b = $('#send');
|
|
215
|
-
if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' :
|
|
272
|
+
if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
|
|
216
273
|
const live = qs.filter((q) => q.state !== 'resolved');
|
|
217
274
|
const answered = live.filter((q) => q.state !== 'open').length;
|
|
218
275
|
$('#progress').textContent = live.length > 1 ? `${answered}/${live.length} answered` : '';
|
|
@@ -226,7 +283,7 @@
|
|
|
226
283
|
topicId = t ? t.id : null;
|
|
227
284
|
}
|
|
228
285
|
|
|
229
|
-
const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply))])]);
|
|
286
|
+
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))])]);
|
|
230
287
|
// One load at a time: a load waiting out a board restart would otherwise overlap the next poll's. A load asked for
|
|
231
288
|
// while one runs shares it. → true when the board's answer landed in this view.
|
|
232
289
|
let loading = null;
|
|
@@ -235,45 +292,58 @@
|
|
|
235
292
|
async function fetchBoard(first) {
|
|
236
293
|
const page = $('#page');
|
|
237
294
|
try {
|
|
238
|
-
const { body: d } = await fetchJSON(site.url('api/board'), { onWait: () => { if (first) page.innerHTML = restartNotice(); }, sleep: B.sleep });
|
|
295
|
+
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 });
|
|
239
296
|
if (!B.alive()) return false;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
topics = d.ownerTopics || [];
|
|
243
|
-
site.syncBadge();
|
|
244
|
-
if (first) pick(hashId());
|
|
245
|
-
else if (soloId && byId(soloId)?.state !== 'open') pick('');
|
|
246
|
-
else if (!topics.some((t) => t.id === topicId)) pick('');
|
|
247
|
-
loaded = true;
|
|
248
|
-
const sig = sigNow();
|
|
249
|
-
const active = document.activeElement;
|
|
250
|
-
if (sig !== shown && !(active && active.tagName === 'TEXTAREA')) {
|
|
251
|
-
shown = sig;
|
|
252
|
-
const y = scrollY;
|
|
253
|
-
render();
|
|
254
|
-
scrollTo(0, first ? 0 : y);
|
|
255
|
-
}
|
|
297
|
+
if (d) land(d, first);
|
|
298
|
+
repaint(first);
|
|
256
299
|
return true;
|
|
257
300
|
} catch (e) {
|
|
258
301
|
if (first) page.innerHTML = `<p class="muted">Could not load the questions: ${esc(e.message)}</p>`;
|
|
259
302
|
return false;
|
|
260
303
|
}
|
|
261
304
|
}
|
|
305
|
+
// 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
|
|
306
|
+
// held, which leaves the page as it is.
|
|
307
|
+
function land(d, first) {
|
|
308
|
+
const next = d.since === undefined ? d : BoardUI.applyPatch(board, d);
|
|
309
|
+
if (!next) return false;
|
|
310
|
+
board = next;
|
|
311
|
+
all = board.owner;
|
|
312
|
+
items = board.items;
|
|
313
|
+
topics = cardTopics(board.ownerTopics, items);
|
|
314
|
+
site.syncBadge();
|
|
315
|
+
if (first) pick(hashId());
|
|
316
|
+
else if (soloId && byId(soloId)?.state !== 'open') pick('');
|
|
317
|
+
else if (!topics.some((t) => t.id === topicId)) pick('');
|
|
318
|
+
loaded = true;
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
// Draws what changed, unless the owner is typing a note.
|
|
322
|
+
function repaint(first) {
|
|
323
|
+
const sig = sigNow();
|
|
324
|
+
const active = document.activeElement;
|
|
325
|
+
if (sig !== shown && !(active && active.tagName === 'TEXTAREA')) {
|
|
326
|
+
shown = sig;
|
|
327
|
+
const y = scrollY;
|
|
328
|
+
render();
|
|
329
|
+
scrollTo(0, first ? 0 : y);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
262
332
|
|
|
263
|
-
function moveOn(from, pills, what) {
|
|
333
|
+
function moveOn(from, pills, what, footer = what, countOpen = true) {
|
|
264
334
|
soloId = null;
|
|
265
335
|
const next = nextQuestion(all, pills, { topic: from });
|
|
266
336
|
const left = topics.find((t) => t.id === from)?.open || 0;
|
|
267
337
|
if (next?.topic === from) {
|
|
268
338
|
topicId = from;
|
|
269
|
-
sentLine = `${
|
|
339
|
+
sentLine = countOpen ? `${footer.replace(/\.?$/, '.')} ${left} still open.` : footer;
|
|
270
340
|
shown = sigNow();
|
|
271
341
|
const y = scrollY;
|
|
272
342
|
render();
|
|
273
343
|
scrollTo(0, y);
|
|
274
344
|
return;
|
|
275
345
|
}
|
|
276
|
-
sentLine =
|
|
346
|
+
sentLine = footer;
|
|
277
347
|
if (!next) {
|
|
278
348
|
sentNotice.put(B.route, what);
|
|
279
349
|
if (ctx.up?.()) return;
|
|
@@ -290,6 +360,8 @@
|
|
|
290
360
|
|
|
291
361
|
B.on(root, 'click', (ev) => {
|
|
292
362
|
if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
|
|
363
|
+
const eye = ev.target.closest && ev.target.closest('[data-proto]');
|
|
364
|
+
if (eye) { askPrototype(byId(eye.closest('section.q').dataset.q), eye); return; }
|
|
293
365
|
const b = ev.target.closest && ev.target.closest('[data-topic]');
|
|
294
366
|
if (b) { soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = ''; render(); scrollTo(0, 0); }
|
|
295
367
|
});
|
|
@@ -340,28 +412,27 @@
|
|
|
340
412
|
const st = $('#qstatus');
|
|
341
413
|
if (uploading(visible())) return;
|
|
342
414
|
if (!list.length) { st.textContent = 'Pick an option or write a note first.'; return; }
|
|
343
|
-
st.textContent = '
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
} catch { st.textContent = sent ? `Sent ${sent} of ${list.length}. Is the host awake?` : 'Could not send. Is the host awake?'; }
|
|
415
|
+
st.textContent = '';
|
|
416
|
+
const at = leaving();
|
|
417
|
+
// docs/adr/2026-11-03-1519-send-records-every-ready-answer-in-one-request.md: one request, recorded all or none. Send waits meanwhile.
|
|
418
|
+
const answers = list.map((q) => {
|
|
419
|
+
const d = getDraft(q.id);
|
|
420
|
+
const last = q.answers[q.answers.length - 1];
|
|
421
|
+
const choice = d.choice ?? (last ? last.choice : null);
|
|
422
|
+
const files = filesOf(q);
|
|
423
|
+
return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) };
|
|
424
|
+
});
|
|
425
|
+
let out;
|
|
426
|
+
try { out = await BoardUI.waitOn($('#send'), send('owner-answers', { answers })); }
|
|
427
|
+
catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
|
|
428
|
+
for (const q of list) {
|
|
429
|
+
setDraft(q.id, {});
|
|
430
|
+
BoardAttach.clear('q:' + q.id, site.root);
|
|
431
|
+
}
|
|
432
|
+
await moveOnOnceLoaded(at, `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${at.name ? ` to ${at.name}` : ''}.`, out, () => {
|
|
433
|
+
const open = all.filter((q) => q.state === 'open').length;
|
|
434
|
+
return `sent ${list.length} answer${list.length === 1 ? '' : 's'}${open ? ` · ${open} still open` : ''}`;
|
|
435
|
+
});
|
|
365
436
|
});
|
|
366
437
|
// Each composer painted raises a bubbling attach-change (attach.js).
|
|
367
438
|
B.on(root, 'attach-change', updateBar);
|