@lovek4d/kickflip 0.5.3 → 0.6.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 (85) hide show
  1. package/CONTEXT.md +69 -37
  2. package/SKILL.md +19 -13
  3. package/bin/kickflip.mjs +12 -12
  4. package/bin/wait-events.mjs +2 -2
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +156 -83
  8. package/board/build-script.mjs +2 -1
  9. package/board/domain.mjs +9 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +9 -5
  12. package/board/onboarding.mjs +11 -0
  13. package/board/prototype-base.mjs +1 -0
  14. package/board/prototype-page.mjs +1 -0
  15. package/board/public/app-arch.js +1 -1
  16. package/board/public/app-cards.js +27 -13
  17. package/board/public/app-header.js +6 -7
  18. package/board/public/app-lanes.js +15 -38
  19. package/board/public/app-needs.js +36 -31
  20. package/board/public/app-snapshot.js +43 -70
  21. package/board/public/app-threads.js +38 -46
  22. package/board/public/app.html +9 -4
  23. package/board/public/app.js +78 -32
  24. package/board/public/hub-kit.js +62 -2
  25. package/board/public/hub.css +2 -0
  26. package/board/public/new-task.js +8 -6
  27. package/board/public/progress-review.html +46 -0
  28. package/board/public/progress-review.js +158 -0
  29. package/board/public/prototype-kit.js +109 -4
  30. package/board/public/prototype-switcher.js +123 -29
  31. package/board/public/prototype.html +57 -3
  32. package/board/public/router.js +7 -6
  33. package/board/public/shell.js +64 -24
  34. package/board/public/sidebar.css +1 -0
  35. package/board/public/sidebar.js +16 -69
  36. package/board/public/style.css +108 -57
  37. package/board/public/ui-cards.js +61 -46
  38. package/board/public/ui-enums.js +1 -1
  39. package/board/public/ui-header.js +25 -16
  40. package/board/public/ui-hub.js +12 -1
  41. package/board/public/ui-links.js +7 -7
  42. package/board/public/ui-page-header.js +19 -7
  43. package/board/public/ui-patch.js +7 -33
  44. package/board/public/ui-progress-bar.js +28 -0
  45. package/board/public/ui-questions.js +25 -17
  46. package/board/public/ui-sidebar.js +25 -23
  47. package/board/public/ui-snapshot.js +43 -52
  48. package/board/public/ui-threads.js +57 -28
  49. package/board/public/ui.js +92 -17
  50. package/board/public/view-hub-settings.js +12 -58
  51. package/board/public/view-hub.js +2 -1
  52. package/board/public/view-notes.js +27 -7
  53. package/board/public/view-questions.js +90 -68
  54. package/board/public/view-settings.js +133 -15
  55. package/board/render-check.mjs +10 -0
  56. package/board/review.mjs +1 -1
  57. package/board/server.mjs +19 -19
  58. package/board/stop.mjs +1 -0
  59. package/board/store.mjs +27 -18
  60. package/board/sync.mjs +2 -0
  61. package/board/tickets.mjs +3 -3
  62. package/board/worktree.mjs +22 -21
  63. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  64. package/hub/board-repo.mjs +1 -1
  65. package/hub/boards.mjs +4 -4
  66. package/hub/front-door.mjs +1 -1
  67. package/hub/github.mjs +3 -3
  68. package/hub/hub.mjs +11 -11
  69. package/hub/incoming.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/pr-report.mjs +2 -2
  72. package/hub/review-send.mjs +1 -1
  73. package/hub/sessions.mjs +2 -2
  74. package/hub/share.mjs +1 -1
  75. package/hub/slack.mjs +2 -2
  76. package/hub/try.mjs +7 -7
  77. package/hub/upgrade.mjs +1 -1
  78. package/hub/usage.mjs +3 -3
  79. package/hub/views.mjs +1 -1
  80. package/package.json +1 -1
  81. package/shared/card-prs.mjs +1 -1
  82. package/shared/machine-gate.mjs +2 -0
  83. package/shared/prototype-frame.js +1 -0
  84. package/shared/usage-gate.mjs +1 -1
  85. package/board/public/ui-phase-bars.js +0 -17
@@ -71,6 +71,7 @@
71
71
  return `${HubUI.flash(flash)}${HubUI.pauseBanner(d.pause)}${HubUI.holdBanner(d.hold)}${usage}${promptsHtml(d.prompts || [], prompts)}<h2 class="label"${usage ? '' : ' style="margin-top:4px"'}>Health</h2>
72
72
  <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `${word} ·${esc(d.hub.detail)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span> ${esc(d.awake)}</span></span>` : ''}`, wrap: true,
73
73
  trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
74
+ ${HubUI.machineRow(d.pause.machine)}
74
75
  ${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip &lt;route&gt;</code>.' }) : ''}</div>
75
76
  ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, the fetch of its repo, events (its session and a watcher run, or the hub can wake it), daily wakes and reviewer links. One that just broke reads settling first.</p>
76
77
  <h2 class="label">Polling status</h2>
@@ -88,7 +89,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
88
89
  (globalThis.AppViews ||= {}).hub = {
89
90
  className: 'hub-view view-hub',
90
91
  title: () => 'Hub',
91
- markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png', settingsHref: null })}
92
+ markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png' })}
92
93
  <main class="has-top"><div class="hub-status"></div><section class="hub-settings" aria-labelledby="hubSettingsLabel"><h2 class="label" id="hubSettingsLabel">Hub settings</h2>${HubSettings.markup()}</section><div class="hub-foot"></div></main>`,
93
94
 
94
95
  create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
@@ -11,22 +11,42 @@
11
11
 
12
12
  create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, async (B) => {
13
13
  const { $, site } = B;
14
- const { esc, markdown, linkContext, fetchJSON, restartNotice } = BoardUI;
14
+ const { esc, markdown, linkContext, fetchJSON, downLanding, copyNote, pageCopy } = BoardUI;
15
15
  const key = ctx.view.item || '';
16
16
  const wave = ctx.view.wave;
17
17
  const el = $('#plan');
18
18
  const get = (url, opts = {}) => fetchJSON(url, { ...opts, sleep: B.sleep });
19
+ const paint = (body, items, note = '') => {
20
+ $('#ref').textContent = `${wave ? body.title : body.ref} notes`;
21
+ document.title = $('#ref').textContent;
22
+ el.innerHTML = note + markdown(body.markdown, linkContext(items, key, { base: './' }));
23
+ };
24
+ // While the board can't be reached, the notes' last answer here shows under its copy line, or with none the landing
25
+ // for why (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
26
+ const url = site.url(`api/notes?${wave ? `wave=${encodeURIComponent(wave)}` : `item=${encodeURIComponent(key)}`}`);
27
+ let copy = null;
28
+ let live = false;
29
+ let copied = false;
30
+ const waiting = (down) => {
31
+ copy ||= pageCopy.read(url);
32
+ copy.then((c) => {
33
+ if (!B.alive() || live || copied) return;
34
+ if (!c) { el.innerHTML = downLanding(down); return; }
35
+ copied = true;
36
+ paint(c.body, [], copyNote('the notes', c.savedAt));
37
+ });
38
+ };
19
39
  try {
20
40
  const board = get(site.url('api/board')).then((r) => r.body).catch(() => ({}));
21
- const query = wave ? `wave=${encodeURIComponent(wave)}` : `item=${encodeURIComponent(key)}`;
22
- const r = await get(site.url(`api/notes?${query}`), { onWait: () => { el.innerHTML = restartNotice(); } });
41
+ const r = await get(url, { onWait: (ms, down) => waiting(down) });
23
42
  if (!B.alive()) return;
24
43
  if (!r.ok) throw new Error(r.body.error || `notes ${r.status}`);
25
- $('#ref').textContent = `${wave ? r.body.title : r.body.ref} notes`;
26
- document.title = $('#ref').textContent;
27
- el.innerHTML = markdown(r.body.markdown, linkContext((await board).items || [], key, { base: './' }));
44
+ live = true;
45
+ paint(r.body, (await board).items || []);
46
+ pageCopy.save(url, r.body);
28
47
  } catch (e) {
29
- el.innerHTML = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`;
48
+ if (e.down) waiting(e.down);
49
+ else el.innerHTML = `<p class="muted">Could not load these notes: ${esc(e.message)}</p>`;
30
50
  }
31
51
  }),
32
52
  };
@@ -11,17 +11,24 @@
11
11
  // The question then reads prototype requested, locked, until its prototype round arrives, and once the request is
12
12
  // recorded the page moves on as a Send does (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md,
13
13
  // docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
14
- // A topic's resolved questions fold under "resolved (N)", read-only; a resolved question's badge, once confirmed, sends
15
- // `reask-request {key?, question}` (docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md).
14
+ // A question the owner has sent an answer to is resolved: read-only, green, with a Resolved badge. One that resolves
15
+ // while its topic shows stays in place until you leave that topic, by a pill, a link or the move on after the last
16
+ // Send; a topic's sent questions otherwise fold under "resolved (N)", which names only the count, above its open ones. A sent question's badge, once confirmed, sends `question-reopen {key?, question}`, and the question
17
+ // opens again with its last pick and note for a new Send. Each question is numbered by its place on its card
18
+ // (docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md,
19
+ // docs/adr/2026-11-06-1100-resolved-questions-fold-away-once-you-leave-their-topic.md).
16
20
  // A topic or question that arrives while the page shows inserts its pill or card where it belongs, and the counts
17
21
  // update, with no repaint, so the open topic, its picks, the note being typed and the scroll stay
18
22
  // (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md).
23
+ // The page shows open questions only: it ends at the Send bar, opens by itself only a topic with an open question, and
24
+ // with none shows the hub's moon and "no open questions". A link to a question still opens its topic, as a work item's
25
+ // Questions button does (docs/adr/2026-11-06-0037-the-open-questions-page-shows-open-questions-only.md).
19
26
  // markup({embedded: true}) and ctx.embedded mount it in the board's desktop Needs you pane (app-needs.js): no header,
20
27
  // and the board's URL and scroll are left alone.
21
28
  (globalThis.AppViews ||= {}).questions = {
22
29
  className: 'view-questions',
23
30
  title: () => 'Waiting On You',
24
- markup: ({ embedded = false } = {}) => `${embedded ? '' : BoardUI.header({ lead: 'back', title: 'Waiting on you', controls: false, trail: '<span class="muted small" id="progress"></span>' })}
31
+ markup: ({ embedded = false } = {}) => `${embedded ? '' : BoardUI.header({ lead: 'logo', title: 'Waiting on you', logoSrc: '/_hub/kickflip.png', controls: false, trail: '<span class="muted small" id="progress"></span>', pages: 'board' })}
25
32
  <main>
26
33
  ${embedded ? '<span class="muted small" id="progress" hidden></span>' : ''}<section id="page"></section>
27
34
  </main>`,
@@ -30,7 +37,7 @@
30
37
  const { $, site } = B;
31
38
  const scroll = (y) => { if (!ctx.embedded) scrollTo(0, y); };
32
39
  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;
40
+ const { esc, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, questionCard, roundChip, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, downLanding } = BoardUI;
34
41
  const hashId = () => { if (ctx.embedded) return ''; const h = location.hash.slice(1); try { return decodeURIComponent(h); } catch { return h; } };
35
42
 
36
43
  let all = [];
@@ -46,6 +53,9 @@
46
53
  let soloId = null;
47
54
  let shown = '';
48
55
  let sentLine = '';
56
+ // The shown topic's questions this page has drawn unsent, which stay in place once sent or resolved until you leave
57
+ // the topic (setTopic).
58
+ const inPlace = new Set();
49
59
  // The footer after a prototype request: the blue spinner, then these words.
50
60
  const PROTOTYPE_REQUESTED = 'prototype requested';
51
61
  // The bar's line while a suggested option is still pre-checked.
@@ -57,7 +67,16 @@
57
67
  const topicOf = (qid) => topics.find((t) => t.questions.includes(qid)) || null;
58
68
  const openTopics = () => topics.filter((t) => t.state !== 'resolved');
59
69
  const links = (q) => linkContext(items, q.topic?.startsWith('item:') ? q.topic.slice(5) : null, { base: './' });
60
- const urgentOpen = () => all.find((q) => q.urgent && q.state === 'open');
70
+ // Shows a topic, or none; the topic left folds its sent questions.
71
+ const setTopic = (id) => {
72
+ if (id !== topicId) for (const qid of topics.find((t) => t.id === topicId)?.questions || []) inPlace.delete(qid);
73
+ topicId = id;
74
+ };
75
+ const urgentOpen =() => all.find((q) => q.urgent && q.state === 'open');
76
+ // A question's number on its card: its place in its topic, which keeps every question it has had.
77
+ const numberOf = (q) => (topicOf(q.id)?.questions.indexOf(q.id) ?? -1) + 1;
78
+ // The questions the form shows, in order; the rest of qs fold under resolved (N).
79
+ const inForm = (qs) => qs.filter((q) => !isSent(q) || inPlace.has(q.id));
61
80
 
62
81
  // docs/adr/2026-10-29-1639-waiting-on-you-leads-with-what-needs-the-owner.md
63
82
  function topicBar() {
@@ -66,19 +85,20 @@
66
85
  return `<div class="topics" role="group" aria-label="Topics">${list.map((t) => pillHtml(t)).join('')}</div>`;
67
86
  }
68
87
  // 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>`;
88
+ const pillHtml = (t, arrived = false) => `<button class="${arrived ? 'arrived ' : ''}${t.progress ? 'progress' : ''}" 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>`;
70
89
  const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
71
90
 
72
- // Only open questions changed here since their last answer, with files attached, or with a suggested option
73
- // pre-checked and never answered, are sent; a resolved one is read-only.
91
+ // Only open questions changed here since their last answer, with files attached, reopened with a pick, or with a
92
+ // suggested option pre-checked and never answered, are sent; a sent one is read-only.
74
93
  const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
75
94
  const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
76
95
  function pending(qs) {
77
- return qs.filter((q) => q.state !== 'resolved' && q.state !== 'requested').filter((q) => {
96
+ return qs.filter((q) => q.state === 'open').filter((q) => {
78
97
  const d = getDraft(q.id);
79
98
  if (filesOf(q).length) return true;
80
99
  const last = q.answers[q.answers.length - 1];
81
100
  if (!last && q.suggested) return true;
101
+ if (q.reopened && choiceOf(q, d) !== null) return true;
82
102
  if (!('choice' in d) && !d.note) return false;
83
103
  return !last || (d.choice ?? last.choice) !== last.choice || (d.note || '') !== (last.note || '');
84
104
  });
@@ -110,37 +130,26 @@
110
130
  const el = $('#page');
111
131
  const qs = visible();
112
132
  if (!qs.length) {
113
- el.innerHTML = '<p class="muted">Nothing is waiting on you.</p>' + waitingTopics() + resolvedTopics();
133
+ el.innerHTML = `<div class="q-empty"><svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${HubUI.TILE_PATHS.moon}</svg><p class="muted">no open questions</p></div>`;
114
134
  $('#progress').textContent = '';
115
135
  return;
116
136
  }
117
137
  const t = topics.find((x) => x.id === topicId);
118
- const live = qs.filter((q) => q.state !== 'resolved');
119
- const done = qs.filter((q) => q.state === 'resolved');
138
+ const live = inForm(qs);
139
+ const done = qs.filter((q) => !live.includes(q));
140
+ for (const q of live) inPlace.add(q.id);
120
141
  const head = soloId
121
142
  ? `<div class="solo">${badge('notice', 'needs you first')}<button class="ghost" id="showAll">All questions</button></div>`
122
143
  : `${topicBar()}${topicHead(t)}`;
123
144
  el.innerHTML = `${head}<form id="qform">
124
- ${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q) })).join('')}
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>` : ''}
145
+ ${done.length ? `<details class="done resolved-group"><summary><span class="caret" aria-hidden="true">›</span><span>resolved (${done.length})</span></summary>${done.map((q) => questionCard(q, { n: numberOf(q), links: links(q) })).join('')}</details>` : ''}
146
+ ${live.map((q) => questionCard(q, { n: numberOf(q), draft: getDraft(q.id), links: links(q) })).join('')}
126
147
  <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>
127
- </form>${soloId ? '' : waitingTopics() + resolvedTopics()}`;
148
+ </form>`;
128
149
  BoardAttach.paintAll();
129
150
  updateBar();
130
151
  }
131
152
 
132
- function waitingTopics() {
133
- const list = topicRow(topics, topicId).waiting;
134
- return list.length ? `<details class="done"><summary>Waiting on Claude · ${list.length}</summary>${list.map((t) =>
135
- `<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>` : '';
136
- }
137
-
138
- function resolvedTopics() {
139
- const done = topics.filter((t) => t.state === 'resolved' && t.id !== topicId);
140
- return done.length ? `<details class="done"><summary>Resolved topics · ${done.length}</summary>${done.map((t) =>
141
- `<div><button class="ghost" data-topic="${esc(t.id)}">${esc(t.title)}</button></div>`).join('')}</details>` : '';
142
- }
143
-
144
153
  // The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
145
154
  function leaving() {
146
155
  const from = soloId ? topicOf(soloId)?.id : topicId;
@@ -175,23 +184,24 @@
175
184
  const d = getDraft(q.id);
176
185
  return BoardUI.waitOn(button, BoardUI.pending.within((async () => {
177
186
  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; }
187
+ try { out = await send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: noteOf(q, d).trim(), choice: choiceOf(q, d) }); } catch (e) { refused(e); return; }
179
188
  setDraft(q.id, {});
180
189
  await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
181
190
  })(), stillWaiting));
182
191
  }
183
192
 
184
- // A resolved question's badge asks Claude to ask it again from the card's current notes, once the owner confirms.
185
- // The topic's resolved questions load again, so the question shows its re-ask under way.
186
- async function askAgain(q, button) {
187
- if (!(await BoardDialogs.confirm('Ask this again from today’s context?')) || !B.alive()) return;
193
+ // A sent question's Resolved badge reopens it once the owner confirms: the question opens in place with its last
194
+ // pick and note, and stays in the form until you leave the topic.
195
+ async function reopen(q, button) {
196
+ if (!(await BoardDialogs.confirm('Reopen this question?')) || !B.alive()) return;
188
197
  const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
189
198
  await BoardUI.waitOn(button, BoardUI.pending.within((async () => {
190
199
  let out;
191
- try { out = await send('reask-request', { ...(key ? { key } : {}), question: q.id }); } catch (e) { refused(e); return; }
200
+ try { out = await send('question-reopen', { ...(key ? { key } : {}), question: q.id }); } catch (e) { refused(e); return; }
192
201
  if (!B.alive()) return;
202
+ inPlace.add(q.id);
203
+ sentLine = '';
193
204
  if (!land(out.board, false)) await load(false);
194
- await loadResolved(true);
195
205
  repaint(false);
196
206
  })(), stillWaiting));
197
207
  }
@@ -201,7 +211,7 @@
201
211
  const n = pending(qs).length;
202
212
  const b = $('#send');
203
213
  if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
204
- const live = qs.filter((q) => q.state !== 'resolved');
214
+ const live = inForm(qs);
205
215
  const answered = live.filter((q) => q.state !== 'open').length;
206
216
  // docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
207
217
  const prechecked = live.some((q) => q.suggested && !q.answers.length && choiceOf(q, getDraft(q.id)) === q.suggested.option);
@@ -212,10 +222,10 @@
212
222
 
213
223
  function pick(hash) {
214
224
  const u = urgentOpen();
215
- if (u && (!hash || hash === u.id)) { soloId = u.id; topicId = topicOf(u.id)?.id ?? null; return; }
225
+ if (u && (!hash || hash === u.id)) { soloId = u.id; setTopic(topicOf(u.id)?.id ?? null); return; }
216
226
  soloId = null;
217
- const t = (hash && topicOf(hash)) || openTopics().find((x) => x.open) || openTopics()[0];
218
- topicId = t ? t.id : null;
227
+ const t = (hash && topicOf(hash)) || topics.find((x) => x.open);
228
+ setTopic(t ? t.id : null);
219
229
  }
220
230
 
221
231
  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)])]);
@@ -224,16 +234,30 @@
224
234
  let loading = null;
225
235
  let loaded = false;
226
236
  const load = (first) => (loading ||= fetchBoard(first).finally(() => { loading = null; }));
237
+ // While the board can't be reached, the page holds its sending buttons and shows the board's copy here, painted live,
238
+ // or with none and nothing landed the landing for why (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
239
+ let copy = null;
240
+ BoardUI.holdSends(B.on, root, () => root.classList.contains('offline'), () => B.toast(BoardUI.WAITING));
241
+ const out = (down) => {
242
+ root.classList.add('offline');
243
+ copy ||= BoardUI.boardCopy(site.url(''), document.documentElement.dataset.build);
244
+ copy.then((c) => {
245
+ if (!B.alive() || loaded) return;
246
+ if (!c) { $('#page').innerHTML = downLanding(down); return; }
247
+ land(c.board, true);
248
+ repaint(true);
249
+ });
250
+ };
227
251
  async function fetchBoard(first) {
228
- const page = $('#page');
229
252
  try {
230
- 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 });
253
+ const { body: d } = await fetchJSON(site.url('api/board' + (board ? '?since=' + encodeURIComponent(board.version) : '')), { tag: board?.version, onWait: (ms, down) => out(down), sleep: B.sleep });
231
254
  if (!B.alive()) return false;
255
+ root.classList.remove('offline');
232
256
  if (d) land(d, first);
233
257
  repaint(first);
234
258
  return true;
235
259
  } catch (e) {
236
- if (first) page.innerHTML = `<p class="muted">Could not load the questions: ${esc(e.message)}</p>`;
260
+ out(e.down);
237
261
  return false;
238
262
  }
239
263
  }
@@ -274,7 +298,7 @@
274
298
  let drawn = null;
275
299
  // A change that only adds topics, counts or open questions at the end of the shown topic is drawn in place: each
276
300
  // 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.
301
+ // one, and the topic line and the bar update. → false when anything shown changed.
278
302
  function arrive() {
279
303
  const form = $('#qform');
280
304
  const now = drawnOf();
@@ -300,13 +324,11 @@
300
324
  const head = page.querySelector('.topichead');
301
325
  const headHtml = topicHead(topics.find((x) => x.id === topicId));
302
326
  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;
327
+ const sendBar = form.querySelector('.bar');
328
+ for (const q of added) {
329
+ inPlace.add(q.id);
330
+ sendBar.insertAdjacentHTML('beforebegin', questionCard(q, { n: numberOf(q), draft: getDraft(q.id), links: links(q) }));
331
+ }
310
332
  BoardAttach.paintAll();
311
333
  drawn = now;
312
334
  updateBar();
@@ -319,7 +341,6 @@
319
341
  const next = nextQuestion(all, pills, { topic: from });
320
342
  const left = topics.find((t) => t.id === from)?.open || 0;
321
343
  if (next?.topic === from) {
322
- topicId = from;
323
344
  sentLine = countOpen ? `${footer.replace(/\.?$/, '.')} ${left} still open.` : footer;
324
345
  shown = sigNow();
325
346
  const y = scrollY;
@@ -332,9 +353,10 @@
332
353
  sentNotice.put(B.route, what);
333
354
  if (ctx.up?.()) return;
334
355
  sentNotice.take(B.route);
335
- topicId = from;
356
+ setTopic(null);
357
+ B.toast(what);
336
358
  } else {
337
- topicId = next.topic;
359
+ setTopic(next.topic);
338
360
  setHash(next.id);
339
361
  }
340
362
  shown = sigNow();
@@ -346,11 +368,11 @@
346
368
  if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
347
369
  const eye = ev.target.closest && ev.target.closest('[data-proto]');
348
370
  if (eye) { askPrototype(byId(eye.closest('section.q').dataset.q), eye); return; }
349
- const reask = ev.target.closest && ev.target.closest('[data-reask]');
350
- if (reask) { if (!reask.disabled) askAgain(byId(reask.dataset.reask), reask); return; }
371
+ const reopenBadge = ev.target.closest && ev.target.closest('[data-reopen]');
372
+ if (reopenBadge) { if (!reopenBadge.disabled) reopen(byId(reopenBadge.dataset.reopen), reopenBadge); return; }
351
373
  const b = ev.target.closest && ev.target.closest('[data-topic]');
352
374
  if (b) {
353
- soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = '';
375
+ soloId = null; setTopic(b.dataset.topic); sentLine = ''; shown = '';
354
376
  const first = topics.find((t) => t.id === topicId)?.questions[0];
355
377
  if (first) setHash(first);
356
378
  render(); scroll(0);
@@ -385,7 +407,7 @@
385
407
  const d = getDraft(q.id);
386
408
  const choice = choiceOf(q, d);
387
409
  const files = filesOf(q);
388
- return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) };
410
+ return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: noteOf(q, d).trim(), ...(files.length ? { files } : {}) };
389
411
  });
390
412
  // 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
413
  await BoardUI.waitOn($('#send'), BoardUI.pending.within((async () => {
@@ -403,18 +425,6 @@
403
425
  });
404
426
  // Each composer painted raises a bubbling attach-change (attach.js).
405
427
  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); }
418
428
  // A link to another question here only moves the hash.
419
429
  if (!ctx.embedded) B.on(window, 'hashchange', () => {
420
430
  if (!loaded) return;
@@ -425,6 +435,18 @@
425
435
  scroll(0);
426
436
  });
427
437
 
438
+ // In the board's pane, the shell opens the topic of a question or topic id there, a resolved one included, and with
439
+ // none or one that's gone the topic the page would open (app-needs.js focusQuestion). → false before the first load.
440
+ if (ctx.embedded) B.openTopic = (id) => {
441
+ if (!loaded) return false;
442
+ const topic = topics.find((t) => t.id === id);
443
+ if (topic) { soloId = null; setTopic(topic.id); } else pick(id || '');
444
+ sentLine = '';
445
+ shown = '';
446
+ render();
447
+ return true;
448
+ };
449
+
428
450
  load(true);
429
451
  B.every(15000, () => { if (!document.hidden) load(false); });
430
452
  B.on(document, 'visibilitychange', () => { if (!document.hidden) load(false); });