@lovek4d/kickflip 0.5.0 → 0.5.3

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 (76) hide show
  1. package/CONTEXT.md +47 -42
  2. package/README.md +1 -1
  3. package/SKILL.md +16 -16
  4. package/bin/kickflip.mjs +26 -17
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/bin/board.mjs +65 -18
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +5 -4
  11. package/board/on-host.mjs +1 -1
  12. package/board/public/app-asks.js +6 -6
  13. package/board/public/app-banner.js +7 -13
  14. package/board/public/app-cards.js +51 -27
  15. package/board/public/app-core.js +26 -8
  16. package/board/public/app-header.js +2 -1
  17. package/board/public/app-lanes.js +55 -23
  18. package/board/public/app-needs.js +129 -4
  19. package/board/public/app-reader.js +1 -1
  20. package/board/public/app-snapshot.js +6 -5
  21. package/board/public/app-threads.js +70 -59
  22. package/board/public/app.js +38 -8
  23. package/board/public/attach.js +2 -2
  24. package/board/public/dialogs.js +9 -39
  25. package/board/public/hub-kit.js +4 -2
  26. package/board/public/linear-key.js +46 -35
  27. package/board/public/new-task.js +17 -38
  28. package/board/public/prototype-kit.js +28 -3
  29. package/board/public/prototype-switcher.js +42 -107
  30. package/board/public/shell.js +7 -5
  31. package/board/public/sidebar.js +44 -5
  32. package/board/public/style.css +108 -37
  33. package/board/public/ui-cards.js +41 -30
  34. package/board/public/ui-header.js +2 -2
  35. package/board/public/ui-order.js +12 -10
  36. package/board/public/ui-questions.js +25 -38
  37. package/board/public/ui-sidebar.js +18 -12
  38. package/board/public/ui-threads.js +3 -3
  39. package/board/public/ui.js +40 -8
  40. package/board/public/view-hub-settings.js +11 -10
  41. package/board/public/view-hub.js +17 -3
  42. package/board/public/view-questions.js +142 -199
  43. package/board/public/view-settings.js +191 -72
  44. package/board/server.mjs +19 -20
  45. package/board/store.mjs +20 -15
  46. package/board/transcript.mjs +2 -0
  47. package/board/worktree.mjs +24 -20
  48. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  49. package/hooks-plugin/hooks/hooks.json +9 -0
  50. package/hub/boards.mjs +4 -4
  51. package/hub/front-door.mjs +5 -5
  52. package/hub/github.mjs +3 -3
  53. package/hub/hub.mjs +11 -11
  54. package/hub/linear.mjs +1 -2
  55. package/hub/live.mjs +2 -2
  56. package/hub/locks.mjs +1 -1
  57. package/hub/pr-report.mjs +2 -2
  58. package/hub/presence.mjs +1 -1
  59. package/hub/push.mjs +2 -2
  60. package/hub/readiness.mjs +1 -2
  61. package/hub/review-send.mjs +1 -0
  62. package/hub/secrets.mjs +2 -0
  63. package/hub/self-restart.mjs +2 -0
  64. package/hub/sessions.mjs +2 -2
  65. package/hub/share.mjs +1 -0
  66. package/hub/slack.mjs +2 -2
  67. package/hub/stream.mjs +10 -13
  68. package/hub/try.mjs +6 -6
  69. package/hub/upgrade.mjs +1 -1
  70. package/hub/usage.mjs +3 -3
  71. package/hub/views.mjs +1 -1
  72. package/package.json +2 -1
  73. package/shared/audit.mjs +4 -0
  74. package/shared/fake-gh.mjs +17 -4
  75. package/shared/service.mjs +4 -4
  76. package/shared/usage-gate.mjs +1 -1
@@ -4,7 +4,8 @@
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/2026-10-29-1639-a-questions-takes-show-inline.md).
7
+ // whose url is a prototype page shows its options and a View prototypes link to its full-page viewer
8
+ // (docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md).
8
9
  // Each question's eye sends `prototype-request {key?, question, note, choice}` for that question instead of an answer,
9
10
  // 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
11
  // The question then reads prototype requested, locked, until its prototype round arrives, and once the request is
@@ -12,23 +13,25 @@
12
13
  // docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
13
14
  // A topic's resolved questions fold under "resolved (N)", read-only; a resolved question's badge, once confirmed, sends
14
15
  // `reask-request {key?, question}` (docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md).
15
- // fits: each framed take's measured size, kept across repaints and visits so a take redraws at its size at once.
16
- (globalThis.AppViews ||= {}).questions = ((fits) => ({
16
+ // A topic or question that arrives while the page shows inserts its pill or card where it belongs, and the counts
17
+ // update, with no repaint, so the open topic, its picks, the note being typed and the scroll stay
18
+ // (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md).
19
+ // markup({embedded: true}) and ctx.embedded mount it in the board's desktop Needs you pane (app-needs.js): no header,
20
+ // and the board's URL and scroll are left alone.
21
+ (globalThis.AppViews ||= {}).questions = {
17
22
  className: 'view-questions',
18
23
  title: () => 'Waiting On You',
19
- markup: () => `${BoardUI.header({ lead: 'back', title: 'Waiting on you', controls: false, trail: '<span class="muted small" id="progress"></span>' })}
24
+ markup: ({ embedded = false } = {}) => `${embedded ? '' : BoardUI.header({ lead: 'back', title: 'Waiting on you', controls: false, trail: '<span class="muted small" id="progress"></span>' })}
20
25
  <main>
21
- <section id="page"></section>
26
+ ${embedded ? '<span class="muted small" id="progress" hidden></span>' : ''}<section id="page"></section>
22
27
  </main>`,
23
28
 
24
29
  create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
25
30
  const { $, site } = B;
26
- const { esc, choiceLabel, choiceOf, questionCard, roundChip, takes, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
27
- // prototype-switcher.js reads a draft's note and clears the draft once it sends that question's answer.
28
- const draftKey = (id) => 'qdraft:' + id;
29
- const getDraft = (id) => { try { return JSON.parse(localStorage.getItem(draftKey(id)) || '{}'); } catch { return {}; } };
30
- const setDraft = (id, v) => { try { localStorage.setItem(draftKey(id), JSON.stringify(v)); } catch {} };
31
- const hashId = () => { const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
31
+ const scroll = (y) => { if (!ctx.embedded) scrollTo(0, y); };
32
+ const setHash = (id) => { if (!ctx.embedded) history.replaceState(history.state, '', '#' + encodeURIComponent(id)); };
33
+ const { esc, getDraft, setDraft, choiceLabel, choiceOf, questionCard, roundChip, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
34
+ const hashId = () => { if (ctx.embedded) return ''; const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
32
35
 
33
36
  let all = [];
34
37
  // The shown topic's resolved questions, which /api/board leaves out: GET /api/questions answers them as the topic
@@ -48,14 +51,6 @@
48
51
  // The bar's line while a suggested option is still pre-checked.
49
52
  const SUGGESTED = 'suggested options are selected';
50
53
  const statusHtml = (line) => (line.startsWith(PROTOTYPE_REQUESTED) ? '<span class="try-spin" aria-hidden="true"></span>' : '') + esc(line);
51
- let scrollTake = null;
52
- const takeResources = new Map();
53
- const clearTakeResources = (within = root) => {
54
- for (const [shot, release] of takeResources) {
55
- if (!shot.isConnected || within.contains(shot)) { release(); takeResources.delete(shot); }
56
- }
57
- };
58
- B.atUnmount(() => clearTakeResources());
59
54
 
60
55
  const send = (kind, data) => site.send(kind, { ...data, ...(board ? { since: board.version } : {}) });
61
56
  const byId = (id) => all.find((q) => q.id === id) || resolved.questions.find((q) => q.id === id);
@@ -68,9 +63,10 @@
68
63
  function topicBar() {
69
64
  const list = topicRow(topics, topicId).pills;
70
65
  if (list.length < 2) return '';
71
- return `<div class="topics" role="group" aria-label="Topics">${list.map((t) =>
72
- `<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>`;
66
+ return `<div class="topics" role="group" aria-label="Topics">${list.map((t) => pillHtml(t)).join('')}</div>`;
73
67
  }
68
+ // An arrival (arrive) slides in with a glow and settles (style.css .arrived).
69
+ const pillHtml = (t, arrived = false) => `<button${arrived ? ' class="arrived"' : ''} 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>`;
74
70
  const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
75
71
 
76
72
  // Only open questions changed here since their last answer, with files attached, or with a suggested option
@@ -94,12 +90,14 @@
94
90
  return t ? t.questions.map(byId).filter(Boolean) : [];
95
91
  }
96
92
 
97
- function loadResolved() {
93
+ // → the fetch of the topic's resolved questions, or null when they are held. force asks again; the questions held stay
94
+ // drawn until the new ones land.
95
+ function loadResolved(force = false) {
98
96
  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: [] };
97
+ if (!t?.resolved || (!force && resolved.topic === t.id && resolved.n === t.resolved)) return null;
98
+ resolved = { topic: t.id, n: t.resolved, questions: resolved.topic === t.id ? resolved.questions : [] };
101
99
  const asked = resolved;
102
- fetchJSON(site.url('api/questions?topic=' + encodeURIComponent(t.id)), { sleep: B.sleep }).then(({ body }) => {
100
+ return fetchJSON(site.url('api/questions?topic=' + encodeURIComponent(t.id)), { sleep: B.sleep }).then(({ body }) => {
103
101
  if (!B.alive() || resolved !== asked) return;
104
102
  asked.questions = body.questions || [];
105
103
  repaint(false);
@@ -107,8 +105,8 @@
107
105
  }
108
106
 
109
107
  function render() {
110
- clearTakeResources();
111
108
  loadResolved();
109
+ drawn = drawnOf();
112
110
  const el = $('#page');
113
111
  const qs = visible();
114
112
  if (!qs.length) {
@@ -123,111 +121,14 @@
123
121
  ? `<div class="solo">${badge('notice', 'needs you first')}<button class="ghost" id="showAll">All questions</button></div>`
124
122
  : `${topicBar()}${topicHead(t)}`;
125
123
  el.innerHTML = `${head}<form id="qform">
126
- ${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}
124
+ ${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q) })).join('')}
127
125
  ${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
126
  <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>
129
127
  </form>${soloId ? '' : waitingTopics() + resolvedTopics()}`;
130
128
  BoardAttach.paintAll();
131
- el.querySelectorAll('.take-shot').forEach(fitTake);
132
129
  updateBar();
133
130
  }
134
131
 
135
- // A width sets its page's height, so a size measured at another width is measured again.
136
- const fitKey = (qid, oid) => `${site.root}\n${qid}\n${oid}`;
137
- const fitOf = (q) => (oid) => { const f = fits.get(fitKey(q.id, oid)); return f && f.w === window.innerWidth ? f : null; };
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
139
- // at the top covers the frame's top.
140
- const frameTop = (shot) => {
141
- const head = $('header.top')?.getBoundingClientRect();
142
- const over = head && head.top < innerHeight / 2 ? head.bottom : 0;
143
- return Math.max(0, scrollY + shot.getBoundingClientRect().top - over);
144
- };
145
- function repaintTakes(sec) {
146
- if (!sec.querySelector('.takes')) return;
147
- const q = byId(sec.dataset.q);
148
- const old = sec.querySelector('.takes');
149
- clearTakeResources(old);
150
- old.outerHTML = takes(q, choiceOf(q, getDraft(q.id)), links(q), fitOf(q));
151
- sec.querySelectorAll('.take-shot').forEach(fitTake);
152
- }
153
-
154
- // A full-screen take gets the screen between the header and the Send bar.
155
- const screenH = () => Math.max(320, innerHeight - ($('header.top')?.offsetHeight || 0) - ($('#qform .bar')?.offsetHeight || 0));
156
-
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
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
159
- // height, turns its question into one full-width take under a choice row
160
- // (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
161
- function fitTake(shot) {
162
- const frame = shot.querySelector('iframe');
163
- const sec = shot.closest('section.q');
164
- // A switch in place (inPlace) changes the take a frame shows, and so the key its size is kept under.
165
- const key = () => fitKey(sec.dataset.q, shot.closest('.take').dataset.opt);
166
- const full = Boolean(shot.closest('.takes-full'));
167
- let watcher = null;
168
- let probing = false;
169
- let raf = 0;
170
- const measure = () => {
171
- try {
172
- const doc = frame.contentDocument;
173
- // The body's own height: the document's scrollHeight never drops below the frame's.
174
- return doc?.body && doc.location.href !== 'about:blank' ? Math.ceil(doc.body.getBoundingClientRect().height) : null;
175
- } catch { return null; }
176
- };
177
- const sizeNow = () => { const f = fits.get(key()); return f?.w === window.innerWidth ? { ...f } : { w: window.innerWidth }; };
178
- const fillsFrame = (h) => Math.abs(h - frame.clientHeight) <= 1;
179
- // A page as tall as its frame is grown once: a page sized by its content keeps its height, a full-screen one
180
- // follows the frame.
181
- const probe = () => {
182
- probing = true;
183
- shot.style.height = `${frame.clientHeight + 202}px`;
184
- raf = requestAnimationFrame(() => raf = requestAnimationFrame(() => {
185
- raf = 0;
186
- probing = false;
187
- const h = measure();
188
- if (h === null || !shot.isConnected || !B.alive()) return;
189
- fits.set(key(), { ...sizeNow(), fills: fillsFrame(h) });
190
- apply();
191
- }));
192
- };
193
- const apply = () => {
194
- if (probing) return;
195
- const h = measure();
196
- if (h === null || !shot.isConnected || !B.alive()) return;
197
- const size = sizeNow();
198
- if (size.fills === undefined) {
199
- if (fillsFrame(h)) { probe(); return; }
200
- size.fills = false;
201
- }
202
- // The shot's border is 1px each side, and the frame fills the rest.
203
- const height = size.fills ? screenH() : h + 2;
204
- if (full) size.fullH = height;
205
- else { size.h = height; size.scrolls = size.fills || h > window.innerHeight * 0.8; }
206
- fits.set(key(), size);
207
- const q = byId(sec.dataset.q);
208
- if (!full && size.scrolls && q.options.every((o) => !o.diagram && !o.practice)) {
209
- repaintTakes(sec);
210
- return;
211
- }
212
- shot.style.height = `${height}px`;
213
- shot.classList.remove('measuring');
214
- if (scrollTake === shot) { scrollTo(0, frameTop(shot)); scrollTake = null; }
215
- };
216
- const off = B.on(frame, 'load', () => {
217
- if (measure() === null || !B.alive()) return;
218
- watcher?.disconnect();
219
- watcher = null;
220
- apply();
221
- // A page that grows after it loads, or narrows with the view, is measured again.
222
- if (!shot.isConnected) return;
223
- try {
224
- watcher = new frame.contentWindow.ResizeObserver(apply);
225
- watcher.observe(frame.contentDocument.body);
226
- } catch {}
227
- });
228
- takeResources.set(shot, () => { off(); watcher?.disconnect(); if (raf) cancelAnimationFrame(raf); });
229
- }
230
-
231
132
  function waitingTopics() {
232
133
  const list = topicRow(topics, topicId).waiting;
233
134
  return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
@@ -255,33 +156,44 @@
255
156
  moveOn(from, pills, what, typeof footer === 'function' ? footer() : footer, typeof footer !== 'function');
256
157
  }
257
158
 
258
- // The tapped Prototype button waits (ui.js waitOn) while the request is sent.
259
- async function askPrototype(q, button) {
260
- const st = $('#qstatus');
159
+ // A tap's change no load has drawn in BoardUI's PENDING_LATE_MS: the board loads once and the button rests.
160
+ function stillWaiting() {
161
+ if (!B.alive()) return;
162
+ B.toast('Still waiting on the board');
163
+ shown = '';
164
+ load(false);
165
+ }
166
+ // A refusal rests the button with its reason on the status line, as before.
167
+ const refused = (e) => { $('#qstatus').textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; };
168
+
169
+ // The tapped Prototype button waits (ui.js waitOn) until the page shows the request
170
+ // (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
171
+ function askPrototype(q, button) {
261
172
  const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
262
173
  const at = leaving();
263
- st.textContent = '';
174
+ $('#qstatus').textContent = '';
264
175
  const d = getDraft(q.id);
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; }
268
- setDraft(q.id, {});
269
- await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
176
+ return BoardUI.waitOn(button, BoardUI.pending.within((async () => {
177
+ let out;
178
+ try { out = await send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: choiceOf(q, d) }); } catch (e) { refused(e); return; }
179
+ setDraft(q.id, {});
180
+ await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
181
+ })(), stillWaiting));
270
182
  }
271
183
 
272
184
  // A resolved question's badge asks Claude to ask it again from the card's current notes, once the owner confirms.
273
185
  // The topic's resolved questions load again, so the question shows its re-ask under way.
274
186
  async function askAgain(q, button) {
275
187
  if (!(await BoardDialogs.confirm('Ask this again from today’s context?')) || !B.alive()) return;
276
- const st = $('#qstatus');
277
188
  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);
189
+ await BoardUI.waitOn(button, BoardUI.pending.within((async () => {
190
+ let out;
191
+ try { out = await send('reask-request', { ...(key ? { key } : {}), question: q.id }); } catch (e) { refused(e); return; }
192
+ if (!B.alive()) return;
193
+ if (!land(out.board, false)) await load(false);
194
+ await loadResolved(true);
195
+ repaint(false);
196
+ })(), stillWaiting));
285
197
  }
286
198
 
287
199
  function updateBar() {
@@ -341,17 +253,66 @@
341
253
  loaded = true;
342
254
  return true;
343
255
  }
344
- // Draws what changed, unless the owner is typing a note.
256
+ // Draws what changed: an arrival in place (arrive), else the whole page, unless the owner is typing a note.
345
257
  function repaint(first) {
346
258
  const sig = sigNow();
259
+ if (sig === shown) return;
260
+ if (!first && arrive()) { shown = sig; return; }
347
261
  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);
262
+ if (active && active.tagName === 'TEXTAREA') return;
263
+ shown = sig;
264
+ const y = scrollY;
265
+ render();
266
+ scroll(first ? 0 : y);
267
+ }
268
+
269
+ // What render last drew of the topic: its questions in order as they stood, and its resolved count.
270
+ const drawnOf = () => {
271
+ const t = topics.find((x) => x.id === topicId);
272
+ return { solo: soloId, topic: topicId, resolved: t?.resolved || 0, qs: visible().map((q) => JSON.stringify([q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply)), Boolean(q.reask)])) };
273
+ };
274
+ let drawn = null;
275
+ // A change that only adds topics, counts or open questions at the end of the shown topic is drawn in place: each
276
+ // pill keeps its node and a new one lands at its place in the row, a new question's card goes after the last open
277
+ // one, and the topic line, the folds under the form and the bar update. → false when anything shown changed.
278
+ function arrive() {
279
+ const form = $('#qform');
280
+ const now = drawnOf();
281
+ if (!form || !drawn || now.solo || drawn.solo || now.topic !== drawn.topic || now.resolved !== drawn.resolved
282
+ || drawn.qs.some((s, i) => now.qs[i] !== s)) return false;
283
+ const added = visible().slice(drawn.qs.length);
284
+ if (added.some((q) => q.state !== 'open')) return false;
285
+ const page = $('#page');
286
+ const list = topicRow(topics, topicId).pills;
287
+ const bar = page.querySelector('.topics');
288
+ if (list.length < 2) bar?.remove();
289
+ else if (!bar) page.insertAdjacentHTML('afterbegin', topicBar());
290
+ else {
291
+ const pills = list.map((t) => bar.querySelector(`button[data-topic="${CSS.escape(t.id)}"]`) || fragmentOf(pillHtml(t, true)));
292
+ for (const b of [...bar.children]) if (!pills.includes(b)) b.remove();
293
+ pills.forEach((b, i) => {
294
+ const t = list[i];
295
+ b.setAttribute('aria-pressed', String(t.id === topicId));
296
+ b.querySelector('.n').textContent = t.state === 'resolved' ? 'resolved' : String(t.open);
297
+ if (bar.children[i] !== b) bar.insertBefore(b, bar.children[i] || null);
298
+ });
353
299
  }
300
+ const head = page.querySelector('.topichead');
301
+ const headHtml = topicHead(topics.find((x) => x.id === topicId));
302
+ if (head && head.outerHTML !== headHtml) head.outerHTML = headHtml;
303
+ const live = visible().filter((q) => q.state !== 'resolved').length - added.length;
304
+ const before = form.querySelector('details.resolved-group') || form.querySelector('.bar');
305
+ added.forEach((q, i) => before.insertAdjacentHTML('beforebegin', questionCard(q, { n: live + i + 1, draft: getDraft(q.id), links: links(q) })));
306
+ const opened = new Set();
307
+ for (const d of [...page.children]) if (d.matches('details.done')) { if (d.open) opened.add(d.querySelector('summary').textContent.split(' ·')[0]); d.remove(); }
308
+ page.insertAdjacentHTML('beforeend', waitingTopics() + resolvedTopics());
309
+ for (const d of page.children) if (d.matches('details.done') && opened.has(d.querySelector('summary').textContent.split(' ·')[0])) d.open = true;
310
+ BoardAttach.paintAll();
311
+ drawn = now;
312
+ updateBar();
313
+ return true;
354
314
  }
315
+ const fragmentOf = (html) => { const box = document.createElement('div'); box.innerHTML = html; return box.firstElementChild; };
355
316
 
356
317
  function moveOn(from, pills, what, footer = what, countOpen = true) {
357
318
  soloId = null;
@@ -363,7 +324,7 @@
363
324
  shown = sigNow();
364
325
  const y = scrollY;
365
326
  render();
366
- scrollTo(0, y);
327
+ scroll(y);
367
328
  return;
368
329
  }
369
330
  sentLine = footer;
@@ -374,11 +335,11 @@
374
335
  topicId = from;
375
336
  } else {
376
337
  topicId = next.topic;
377
- history.replaceState(history.state, '', '#' + encodeURIComponent(next.id));
338
+ setHash(next.id);
378
339
  }
379
340
  shown = sigNow();
380
341
  render();
381
- scrollTo(0, 0);
342
+ scroll(0);
382
343
  }
383
344
 
384
345
  B.on(root, 'click', (ev) => {
@@ -391,38 +352,13 @@
391
352
  if (b) {
392
353
  soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = '';
393
354
  const first = topics.find((t) => t.id === topicId)?.questions[0];
394
- if (first) history.replaceState(history.state, '', '#' + encodeURIComponent(first));
395
- render(); scrollTo(0, 0);
355
+ if (first) setHash(first);
356
+ render(); scroll(0);
396
357
  }
397
358
  });
398
- // The full-width frame shows another take without reloading, where its page's switcher can
399
- // (prototype-switcher.js current.go), so the page keeps its place. → false when it can't.
400
- function inPlace(full, choice) {
401
- const take = full.querySelector('.take');
402
- const frame = take?.querySelector('iframe');
403
- let sw = null;
404
- try { sw = frame?.contentWindow?.PrototypeSwitcher?.current; } catch { sw = null; }
405
- if (!sw?.go || !sw.takes?.includes(choice)) return false;
406
- sw.go(choice);
407
- take.dataset.opt = choice;
408
- for (const label of full.querySelectorAll('.take-choices .opt')) {
409
- if (label.querySelector('input')?.value === choice) label.setAttribute('aria-current', 'true');
410
- else label.removeAttribute('aria-current');
411
- }
412
- return true;
413
- }
414
359
  B.on(root, 'change', (ev) => {
415
360
  const sec = ev.target.closest('section.q'); if (!sec || ev.target.type !== 'radio') return;
416
361
  const d = getDraft(sec.dataset.q); d.choice = ev.target.value; setDraft(sec.dataset.q, d); updateBar();
417
- const full = sec.querySelector('.takes-full');
418
- if (full && inPlace(full, d.choice)) return;
419
- if (full) {
420
- const top = frameTop(full.querySelector('.take-shot'));
421
- repaintTakes(sec);
422
- scrollTake = sec.querySelector('.take-shot');
423
- sec.querySelector(`input[value="${CSS.escape(d.choice)}"]`)?.focus({ preventScroll: true });
424
- scrollTo(0, top);
425
- }
426
362
  });
427
363
  B.on(root, 'input', (ev) => {
428
364
  const sec = ev.target.closest('section.q'); if (!sec || ev.target.tagName !== 'TEXTAREA') return;
@@ -451,41 +387,48 @@
451
387
  const files = filesOf(q);
452
388
  return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) };
453
389
  });
454
- let out;
455
- try { out = await BoardUI.waitOn($('#send'), send('owner-answers', { answers })); }
456
- catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
457
- for (const q of list) {
458
- setDraft(q.id, {});
459
- BoardAttach.clear('q:' + q.id, site.root);
460
- }
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
- });
390
+ // Send waits until the page has moved on (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
391
+ await BoardUI.waitOn($('#send'), BoardUI.pending.within((async () => {
392
+ let out;
393
+ try { out = await send('owner-answers', { answers }); } catch (e) { refused(e); return; }
394
+ for (const q of list) {
395
+ setDraft(q.id, {});
396
+ BoardAttach.clear('q:' + q.id, site.root);
397
+ }
398
+ await moveOnOnceLoaded(at, `Sent ${list.length} answer${list.length === 1 ? '' : 's'}${at.name ? ` to ${at.name}` : ''}.`, out, () => {
399
+ const open = all.filter((q) => q.state === 'open').length;
400
+ return `sent ${list.length} answer${list.length === 1 ? '' : 's'}${open ? ` · ${open} still open` : ''}`;
401
+ });
402
+ })(), stillWaiting));
465
403
  });
466
404
  // Each composer painted raises a bubbling attach-change (attach.js).
467
405
  B.on(root, 'attach-change', updateBar);
406
+ // While a text field is focused and the keyboard is up, the bar docks on the keyboard (style.css .kb-up) from
407
+ // visualViewport, which iOS reports apart from the layout viewport the sticky bar follows.
408
+ const vv = window.visualViewport;
409
+ let typing = false;
410
+ const dock = () => {
411
+ const kb = vv ? Math.max(0, Math.round(window.innerHeight - vv.height - vv.offsetTop)) : 0;
412
+ root.style.setProperty('--kb', kb + 'px');
413
+ root.classList.toggle('kb-up', typing && kb > 0);
414
+ };
415
+ B.on(root, 'focusin', (ev) => { typing = ev.target.matches?.('textarea, input[type="text"], input:not([type])') ?? false; dock(); });
416
+ B.on(root, 'focusout', () => { typing = false; dock(); });
417
+ if (vv) { B.on(vv, 'resize', dock); B.on(vv, 'scroll', dock); }
468
418
  // A link to another question here only moves the hash.
469
- B.on(window, 'hashchange', () => {
419
+ if (!ctx.embedded) B.on(window, 'hashchange', () => {
470
420
  if (!loaded) return;
471
421
  pick(hashId());
472
422
  sentLine = '';
473
423
  shown = '';
474
424
  render();
475
- scrollTo(0, 0);
425
+ scroll(0);
476
426
  });
477
427
 
478
428
  load(true);
479
429
  B.every(15000, () => { if (!document.hidden) load(false); });
480
430
  B.on(document, 'visibilitychange', () => { if (!document.hidden) load(false); });
481
- // An answer sent from a standalone prototype is reloaded when this page returns from the browser cache.
431
+ // A pick drafted on a standalone prototype page shows when this page returns from the browser cache.
482
432
  B.on(window, 'pageshow', (ev) => { if (ev.persisted) { shown = ''; load(false); } });
483
- // A new width needs grid measurements before choosing either layout again.
484
- let width = window.innerWidth;
485
- B.on(window, 'resize', () => {
486
- if (window.innerWidth === width) return;
487
- width = window.innerWidth;
488
- root.querySelectorAll('section.q').forEach(repaintTakes);
489
- });
490
433
  }),
491
- }))(new Map());
434
+ };