@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.
Files changed (111) hide show
  1. package/CONTEXT.md +170 -118
  2. package/README.md +23 -14
  3. package/SKILL.md +40 -38
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +213 -74
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +4 -0
  13. package/board/headless.mjs +4 -0
  14. package/board/merge3.mjs +1 -0
  15. package/board/on-host.mjs +1 -1
  16. package/board/permissions.mjs +2 -0
  17. package/board/public/app-arch.js +2 -2
  18. package/board/public/app-asks.js +39 -103
  19. package/board/public/app-banner.js +2 -2
  20. package/board/public/app-cards.js +166 -73
  21. package/board/public/app-core.js +2 -23
  22. package/board/public/app-header.js +12 -5
  23. package/board/public/app-lanes.js +201 -47
  24. package/board/public/app-needs.js +2 -1
  25. package/board/public/app-reader.js +174 -0
  26. package/board/public/app-snapshot.js +2 -2
  27. package/board/public/app-threads.js +53 -42
  28. package/board/public/app.html +4 -1
  29. package/board/public/app.js +78 -96
  30. package/board/public/attach.js +39 -3
  31. package/board/public/dialogs.js +59 -14
  32. package/board/public/hub-kit.js +9 -6
  33. package/board/public/hub.css +6 -18
  34. package/board/public/linear-key.js +6 -4
  35. package/board/public/log.html +48 -0
  36. package/board/public/new-task.js +121 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +8 -7
  42. package/board/public/rules-editor.js +3 -4
  43. package/board/public/shell.js +24 -6
  44. package/board/public/sidebar.css +22 -1
  45. package/board/public/sidebar.js +64 -31
  46. package/board/public/style.css +245 -64
  47. package/board/public/toasts.js +2 -2
  48. package/board/public/ui-cards.js +205 -75
  49. package/board/public/ui-enums.js +6 -5
  50. package/board/public/ui-header.js +10 -10
  51. package/board/public/ui-hub.js +4 -4
  52. package/board/public/ui-markdown.js +1 -1
  53. package/board/public/ui-order.js +9 -6
  54. package/board/public/ui-page-header.js +10 -7
  55. package/board/public/ui-patch.js +53 -0
  56. package/board/public/ui-phase-bars.js +17 -0
  57. package/board/public/ui-prompts.js +8 -8
  58. package/board/public/ui-questions.js +61 -29
  59. package/board/public/ui-sidebar.js +54 -24
  60. package/board/public/ui-snapshot.js +20 -9
  61. package/board/public/ui-threads.js +40 -21
  62. package/board/public/ui.js +116 -18
  63. package/board/public/view-frame.js +2 -1
  64. package/board/public/view-hub-settings.js +25 -18
  65. package/board/public/view-hub.js +53 -12
  66. package/board/public/view-notes.js +1 -1
  67. package/board/public/view-questions.js +142 -62
  68. package/board/public/view-settings.js +189 -52
  69. package/board/review.mjs +1 -1
  70. package/board/server.mjs +20 -12
  71. package/board/store.mjs +15 -12
  72. package/board/tickets.mjs +3 -3
  73. package/board/worktree.mjs +21 -15
  74. package/docs/hub-session.md +4 -4
  75. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  76. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/close-board.mjs +1 -1
  80. package/hub/front-door.mjs +20 -0
  81. package/hub/github.mjs +4 -4
  82. package/hub/home.mjs +1 -1
  83. package/hub/hub.mjs +11 -14
  84. package/hub/incoming.mjs +2 -0
  85. package/hub/linear.mjs +2 -2
  86. package/hub/live.mjs +2 -2
  87. package/hub/new-board.mjs +2 -2
  88. package/hub/owner-checks.mjs +1 -0
  89. package/hub/permission-denied-hook.mjs +2 -0
  90. package/hub/pr-report.mjs +3 -1
  91. package/hub/presence.mjs +1 -1
  92. package/hub/prompts.mjs +1 -1
  93. package/hub/push.mjs +2 -2
  94. package/hub/readiness.mjs +2 -2
  95. package/hub/repos.mjs +3 -3
  96. package/hub/restart-board.mjs +1 -1
  97. package/hub/schedule.mjs +1 -1
  98. package/hub/sessions.mjs +2 -2
  99. package/hub/sw.js +1 -1
  100. package/hub/try.mjs +8 -4
  101. package/hub/usage.mjs +3 -3
  102. package/hub/views.mjs +1 -1
  103. package/package.json +5 -1
  104. package/shared/card-prs.mjs +1 -0
  105. package/shared/colors.css +5 -4
  106. package/shared/fake-gh.mjs +59 -8
  107. package/shared/owner-check.mjs +3 -0
  108. package/shared/repos.mjs +1 -1
  109. package/shared/tailscale.mjs +1 -0
  110. package/shared/usage-gate.mjs +1 -1
  111. 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/0048-a-questions-takes-show-inline.md).
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/0126-each-question-has-its-own-prototype-eye.md).
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 byId = (id) => all.find((q) => q.id === id);
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/0038-waiting-on-you-leads-with-what-needs-the-owner.md
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, or with files attached, are sent.
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>Resolved · ${done.length}</summary>${done.map((q) => questionCard(q, { draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}</details>` : ''}
97
- <div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${esc(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>
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/0070-phone-headers-sit-at-the-bottom.md), so only a header
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, chosen, links(q), fitOf(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/0048-a-questions-takes-show-inline.md).
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.requested ? 'prototype requested' : t.answered ? 'answered' : 'Claude replied'}</span></div>`).join('')}</details>` : '';
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
- async function askPrototype(q) {
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
- st.textContent = 'Sending…';
262
+ const at = leaving();
263
+ st.textContent = '';
218
264
  const d = getDraft(q.id);
219
- const last = q.answers[q.answers.length - 1];
220
- try { await site.send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: d.choice ?? (last ? last.choice : null) }); }
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
- sentLine = 'Prototype please.';
224
- await loading;
225
- if (await load(false)) { shown = sigNow(); render(); }
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…' : n ? `Send ${n} answer${n === 1 ? '' : 's'}` : 'Send'; }
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
- all = d.owner || [];
258
- items = d.items || [];
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 = `${what} ${left} still open, answer ${left === 1 ? 'it' : 'them'} any time.`;
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 = what;
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) { if (!eye.disabled) askPrototype(byId(eye.closest('section.q').dataset.q)); return; }
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) { soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = ''; render(); scrollTo(0, 0); }
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 = 'Sending…';
363
- const from = soloId ? topicOf(soloId)?.id : topicId;
364
- const pills = topicRow(topics, from).pills;
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 last = q.answers[q.answers.length - 1];
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
- try { await site.send('owner-answers', { answers }); }
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
- const what = `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${name ? ` to ${name}` : ''}.`;
381
- // A load that started before these answers went out would read them as still open.
382
- await loading;
383
- if (await load(false)) moveOn(from, pills, what);
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);