@lovek4d/kickflip 0.5.3 → 0.6.1

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 (91) hide show
  1. package/CONTEXT.md +82 -46
  2. package/SKILL.md +21 -15
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +12 -12
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/agents.mjs +2 -2
  8. package/board/arch-review.mjs +4 -4
  9. package/board/bin/board.mjs +179 -92
  10. package/board/build-script.mjs +2 -1
  11. package/board/domain.mjs +9 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +9 -5
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-cards.js +40 -47
  19. package/board/public/app-header.js +6 -7
  20. package/board/public/app-lanes.js +19 -45
  21. package/board/public/app-needs.js +36 -31
  22. package/board/public/app-snapshot.js +43 -70
  23. package/board/public/app-threads.js +42 -46
  24. package/board/public/app.html +9 -4
  25. package/board/public/app.js +78 -32
  26. package/board/public/hub-kit.js +62 -2
  27. package/board/public/hub.css +2 -0
  28. package/board/public/new-task.js +8 -6
  29. package/board/public/progress-review.html +46 -0
  30. package/board/public/progress-review.js +158 -0
  31. package/board/public/prototype-kit.js +109 -4
  32. package/board/public/prototype-switcher.js +130 -31
  33. package/board/public/prototype.html +57 -3
  34. package/board/public/router.js +7 -6
  35. package/board/public/shell.js +65 -24
  36. package/board/public/sidebar.css +6 -0
  37. package/board/public/sidebar.js +19 -69
  38. package/board/public/style.css +116 -73
  39. package/board/public/ui-cards.js +78 -57
  40. package/board/public/ui-enums.js +2 -2
  41. package/board/public/ui-header.js +23 -16
  42. package/board/public/ui-hub.js +19 -5
  43. package/board/public/ui-links.js +7 -7
  44. package/board/public/ui-order.js +1 -1
  45. package/board/public/ui-page-header.js +19 -7
  46. package/board/public/ui-patch.js +7 -33
  47. package/board/public/ui-progress-bar.js +28 -0
  48. package/board/public/ui-questions.js +25 -17
  49. package/board/public/ui-sidebar.js +31 -23
  50. package/board/public/ui-snapshot.js +44 -53
  51. package/board/public/ui-threads.js +57 -28
  52. package/board/public/ui.js +94 -18
  53. package/board/public/view-hub-settings.js +12 -58
  54. package/board/public/view-hub.js +5 -2
  55. package/board/public/view-notes.js +27 -7
  56. package/board/public/view-questions.js +90 -68
  57. package/board/public/view-settings.js +133 -15
  58. package/board/render-check.mjs +10 -0
  59. package/board/review.mjs +1 -1
  60. package/board/server.mjs +20 -19
  61. package/board/stop.mjs +1 -0
  62. package/board/store.mjs +29 -18
  63. package/board/sync.mjs +2 -0
  64. package/board/tickets.mjs +3 -3
  65. package/board/transcript.mjs +2 -2
  66. package/board/worktree.mjs +22 -21
  67. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  68. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  69. package/hub/board-repo.mjs +1 -1
  70. package/hub/boards.mjs +4 -4
  71. package/hub/front-door.mjs +2 -2
  72. package/hub/github.mjs +3 -3
  73. package/hub/hub.mjs +11 -11
  74. package/hub/incoming.mjs +2 -2
  75. package/hub/owner-checks.mjs +1 -1
  76. package/hub/pr-report.mjs +2 -2
  77. package/hub/review-send.mjs +1 -1
  78. package/hub/sessions.mjs +2 -2
  79. package/hub/share.mjs +1 -1
  80. package/hub/slack.mjs +2 -2
  81. package/hub/try.mjs +6 -6
  82. package/hub/upgrade.mjs +1 -1
  83. package/hub/usage.mjs +3 -3
  84. package/hub/views.mjs +1 -1
  85. package/package.json +1 -1
  86. package/shared/card-prs.mjs +1 -1
  87. package/shared/machine-gate.mjs +2 -0
  88. package/shared/prototype-frame.js +1 -0
  89. package/shared/tailscale.mjs +1 -1
  90. package/shared/usage-gate.mjs +1 -1
  91. package/board/public/ui-phase-bars.js +0 -17
@@ -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, 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); });
@@ -4,16 +4,24 @@
4
4
  // (GET api/reviewer-people), or a typed login, and stays off until both are chosen. Tickets save on each change, and Done, ✕,
5
5
  // Escape and the backdrop wait for that save to land before closing. An unsaved edit marks [data-dirty], and the shell
6
6
  // asks before closing the modal then. Sharing (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md)
7
- // lists the hub's GET /_hub/share/<route>, who can look and who was just refused, and saves each Add and ✕ at once
8
- // through POST api/share.
7
+ // lists the hub's GET /_hub/share/<route>, who it's shared with, each View or Full, and who was just refused, and saves
8
+ // each Add, level and ✕ at once through POST api/share.
9
9
  (globalThis.AppViews ||= {}).settings = {
10
10
  className: 'view-settings',
11
11
  title: () => 'Board Settings',
12
12
  markup: (view, { siteTitle = '' } = {}) => `<dialog class="settings-modal" id="boardSettingsModal" aria-labelledby="boardSettingsTitle"><div class="settings-head"><div><h2 id="boardSettingsTitle" tabindex="-1">Board settings</h2><p class="muted small">${BoardUI.esc(siteTitle)}</p></div><button type="button" class="ghost" data-settings-dismiss aria-label="Close board settings">✕</button></div>
13
13
  <div class="set settings-body">
14
+ <h2>Pause board</h2>
15
+ <div class="hub-view"><div class="group" id="pauseBoard"><p class="muted small block">Loading…</p></div></div>
16
+ <p class="muted small">Running steps finish. No new builders, planners or pulls start on this board until then, as when usage reaches the pause line.</p>
17
+ <p class="muted small" id="pauseBoardStatus" role="status"></p>
14
18
  <h2>Repo</h2>
15
19
  <button type="button" class="who repo-row" id="boardRepo" aria-haspopup="dialog" disabled><span id="boardRepoValue">Loading…</span><span class="muted small" id="boardRepoChange">Change ›</span></button>
16
20
  <p class="muted small" id="boardRepoStatus"></p>
21
+ <label class="who" for="buildTarget"><span><span>Build target</span>
22
+ <span class="muted small sub" id="buildTargetSub">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</span></span>
23
+ <select id="buildTarget" disabled><option value="">Default</option></select></label>
24
+ <p class="muted small" id="buildTargetStatus" role="status"></p>
17
25
  <h2>Tickets</h2>
18
26
  <p class="muted small">Every card on this board has a ticket from here. A new card gets a new ticket unless you name one.</p>
19
27
  <form id="ticketsForm" class="tk-section"><div id="boardTickets">Loading…</div></form>
@@ -34,7 +42,7 @@
34
42
  <input type="checkbox" class="toggle" id="autoPull" disabled></label>
35
43
  <p class="muted small" id="autoPullStatus" role="status"></p>
36
44
  <label class="who" for="maxImplementers"><span><span>Maximum parallel implementers</span>
37
- <span class="muted small sub">Cards building or waiting in Orchestrator review count. Grills and cards waiting on you don’t.</span></span>
45
+ <span class="muted small sub">Each agent that builds or runs tests counts, a card’s helpers included. Grills, read-only helpers and cards waiting in Orchestrator review or on you don’t.</span></span>
38
46
  <input type="number" class="count" id="maxImplementers" min="1" max="99" step="1" inputmode="numeric" disabled></label>
39
47
  <p class="muted small" id="maxImplementersStatus" role="status"></p>
40
48
  <label class="who" for="maxPlanners"><span><span>Maximum parallel planners</span>
@@ -61,7 +69,6 @@
61
69
  <span class="muted small sub">Pushes once when the board has been idle for 15 minutes, with what closed since the last one.</span></span>
62
70
  <input type="checkbox" class="toggle" id="idleNotification" disabled></label>
63
71
  <p class="muted small" id="idleNotificationStatus" role="status"></p>
64
- <p class="muted small">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</p>
65
72
  <h2>Permissions</h2>
66
73
  <div id="permissions"><p class="muted small">Loading…</p></div>
67
74
  <div id="reviewersSection" hidden>
@@ -77,11 +84,11 @@
77
84
  <div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
78
85
  </div>
79
86
  <h2>Sharing</h2>
80
- <p class="muted small">People listed here can look at this board, view only. Nothing else on the host opens for them.</p>
87
+ <p class="muted small">View, or Full: everything but Merge and Board settings. Nothing else on the host opens for them.</p>
81
88
  <div id="shareList"><p class="muted small">Loading…</p></div>
82
89
  <div id="shareRefused"></div>
83
90
  <form class="add" id="shareAdd"><input id="shareLogin" placeholder="Add by login, like name@github" autocomplete="off" autocapitalize="off" spellcheck="false"><button type="submit">Add</button></form>
84
- <div class="share-reach"><p class="muted small">They also need the host shared to them in Tailscale (<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener">Tailscale’s Machines page ↗</a>). Then send them this link.</p><button type="button" id="shareCopy">Copy link</button></div>
91
+ <div class="share-reach"><p class="muted small">They also need the host shared to them in Tailscale (<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener">Tailscale’s Machines page ${BoardUI.LINK_ARROW}</a>). Then send them this link.</p><button type="button" id="shareCopy">Copy link</button></div>
85
92
  <p class="muted small" id="shareStatus" role="status"></p>
86
93
  <h2>Rules</h2>
87
94
  <p class="muted small">Standing instructions for this board's Claude session. It sees them each time it wakes, and says what changes when you save.</p>
@@ -107,8 +114,31 @@
107
114
 
108
115
  create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
109
116
  const { $, site } = B;
110
- const { esc, fetchJSON, restartNotice } = BoardUI;
111
- const get = (url, onWait) => fetchJSON(url, { onWait, sleep: B.sleep });
117
+ const { esc, fetchJSON, restartNotice, pageCopy, copyNote, downLanding } = BoardUI;
118
+ // While the board can't be reached, each part reads its last answer here and the whole body turns read only under
119
+ // the copy line, reopening live once the board answers; a part with no copy waits out a restart, then the body shows
120
+ // the landing (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
121
+ const settingsBody = $('.settings-body');
122
+ let readOnly = false;
123
+ function fromCopy(savedAt) {
124
+ if (readOnly) return;
125
+ readOnly = true;
126
+ settingsBody.inert = true;
127
+ settingsBody.insertAdjacentHTML('afterbegin', copyNote('Settings', savedAt));
128
+ B.every(5000, () => { fetchJSON(site.url('api/settings'), { limit: 0 }).then(() => { if (B.alive()) ctx.reload(); }, () => {}); });
129
+ }
130
+ function down(e) {
131
+ if (readOnly || root.classList.contains('down')) return;
132
+ root.classList.add('down');
133
+ settingsBody.insertAdjacentHTML('afterbegin', downLanding(e.down));
134
+ }
135
+ const saved = (url, r) => { if (r.ok) pageCopy.save(url, r.body); return r; };
136
+ async function get(url, onWait) {
137
+ try { return saved(url, await fetchJSON(url, { limit: 0, sleep: B.sleep })); } catch {}
138
+ const c = await pageCopy.read(url);
139
+ if (c) { fromCopy(c.savedAt); return { ok: true, status: 200, body: c.body }; }
140
+ try { return saved(url, await fetchJSON(url, { onWait, sleep: B.sleep })); } catch (e) { down(e); throw e; }
141
+ }
112
142
  const waitIn = (sel) => () => { $(sel).innerHTML = restartNotice(); };
113
143
  const modal = $('#boardSettingsModal');
114
144
  const previous = document.activeElement;
@@ -200,7 +230,8 @@
200
230
  const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
201
231
  let repo = {}, closeRepo = () => {};
202
232
  let tickets, savedTickets, savingTickets = null, ticketsTouched = false;
203
- // Landing shows on a board on GitHub that builds from its shared clone; a local board never opens PRs. Its choice is
233
+ // Landing shows on a board on GitHub that builds from its shared clone and on the hub board, which builds from its
234
+ // folder, starts on Merge and opens PRs on Draft PR or Ready PR; any other local board never opens PRs. Its choice is
204
235
  // the board's prs and skip_draft settings read together: Draft PR, prs on and skip_draft off; Ready PR, both on;
205
236
  // Merge, prs off. Reviewers show only while cards open PRs
206
237
  // (docs/adr/2026-11-05-0200-landing-is-one-choice-draft-pr-ready-pr-or-merge.md).
@@ -212,9 +243,9 @@
212
243
  let landing = 'draft';
213
244
  const landingButtons = () => Object.keys(LANDING).map((value) => $(`[data-landing="${value}"]`));
214
245
  const landingOf = (settings) => (settings?.prs === false ? 'merge' : settings?.skip_draft === true ? 'ready' : 'draft');
215
- const opensPRs = () => Boolean(repo.github) && !repo.local && landing !== 'merge';
246
+ const opensPRs = () => Boolean(repo.github) && (!repo.local || Boolean(repo.hub)) && landing !== 'merge';
216
247
  const syncPRs = () => {
217
- $('#landingSection').hidden = !repo.github || Boolean(repo.local);
248
+ $('#landingSection').hidden = !repo.github || (Boolean(repo.local) && !repo.hub);
218
249
  $('#reviewersSection').hidden = !opensPRs();
219
250
  $('#landingSub').textContent = LANDING[landing].sub;
220
251
  for (const button of landingButtons()) button.setAttribute('aria-pressed', String(button === $(`[data-landing="${landing}"]`)));
@@ -241,6 +272,7 @@
241
272
  tickets?.render();
242
273
  if (tickets) syncTickets();
243
274
  showRepo();
275
+ loadBuildTargets();
244
276
  $('#boardRepoStatus').textContent = 'Saved. The board’s session has been told.';
245
277
  } });
246
278
  });
@@ -383,7 +415,44 @@
383
415
  const autoMerge = $('#autoMerge');
384
416
  const autoMergeStatus = $('#autoMergeStatus');
385
417
  const savedCaps = {};
418
+ let buildTarget = null, buildTargets = null;
419
+ // Pause board (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md): the hub's duration
420
+ // picker (hub-kit.js) with the Hold sheet's chips, Forever included, posting {pause} to api/pause at once. A Forever
421
+ // pause lasts until Resume.
422
+ const pauseBox = $('#pauseBoard');
423
+ const pickers = HubKit.durationPickers(B);
424
+ let pauseChoices = [];
425
+ const pausedWords = (end, now) => (end === 'forever' ? 'until you resume' : `until ${BoardUI.heldUntilWords(end, now)}`);
426
+ function paintPause(pausedUntil) {
427
+ const now = new Date();
428
+ const choices = pauseChoices.map((c) => ({ value: c.value, label: c.label, until: pausedWords(c.ms === null ? 'forever' : new Date(now.getTime() + c.ms), now) }));
429
+ pauseBox.innerHTML = HubKit.durationPicker({ id: 'pauseBoard', title: 'Pause board', icon: '', action: site.url('api/pause'), name: 'pause', button: 'Resume',
430
+ choices: pausedUntil ? null : choices, on: pausedUntil ? { title: `Paused ${pausedWords(pausedUntil === 'forever' ? 'forever' : new Date(pausedUntil), now)}` } : null });
431
+ pickers.painted();
432
+ }
433
+ B.on(pauseBox, 'submit', async (e) => {
434
+ e.preventDefault();
435
+ const form = e.target;
436
+ const pause = e.submitter?.value ?? form.querySelector('input[type="hidden"]')?.value;
437
+ const status = $('#pauseBoardStatus');
438
+ try {
439
+ const out = await BoardUI.waitOn(e.submitter || form.querySelector('input:not([type="hidden"])'), (async () => {
440
+ const r = await fetch(site.url('api/pause'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pause }) });
441
+ const body = await r.json().catch(() => ({}));
442
+ if (!r.ok) throw new Error(body.error);
443
+ return body;
444
+ })(), { group: pauseBox.querySelectorAll('button') });
445
+ if (!B.alive()) return;
446
+ paintPause(out.paused_until);
447
+ status.textContent = out.paused_until ? 'Paused. New work on this board waits.' : 'Resumed. New work starts again.';
448
+ ctx.sidebarChanged?.();
449
+ } catch (error) {
450
+ if (B.alive()) status.textContent = error.message || 'Could not save. Is the host awake?';
451
+ }
452
+ });
386
453
  get(site.url('api/settings'), waitIn('#autoMergeStatus')).then(({ body: d }) => {
454
+ pauseChoices = d.pause_choices || [];
455
+ paintPause(d.paused_until);
387
456
  if (!B.alive()) return;
388
457
  savedTickets = d.settings?.tickets || null;
389
458
  tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
@@ -403,6 +472,8 @@
403
472
  autoMerge.disabled = false;
404
473
  $('#autoBuild').checked = d.settings?.auto_build === true;
405
474
  $('#autoBuild').disabled = false;
475
+ buildTarget = d.settings?.build_target || '';
476
+ showBuildTargets();
406
477
  $('#autoPull').checked = d.settings?.auto_pull === true;
407
478
  $('#autoPull').disabled = false;
408
479
  for (const [id, key] of [['maxImplementers', 'max_implementers'], ['maxPlanners', 'max_planners']]) {
@@ -446,6 +517,43 @@
446
517
  }
447
518
  });
448
519
 
520
+ // Build target lists the repo's .kickflip/build-* scripts at main's tip and saves a pick at once, null for the
521
+ // default .kickflip/build (docs/adr/2026-11-05-1823-board-settings-picks-which-build-script-a-board-runs.md).
522
+ // A saved target the tip no longer has stays listed, marked, so the picker shows what the hub runs.
523
+ const buildScriptOf = (target) => (target ? `.kickflip/build-${target}` : '.kickflip/build');
524
+ const buildTargetSub = (target) => `The hub runs <code>${esc(buildScriptOf(target))}</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.`;
525
+ const showBuildTargets = () => {
526
+ if (buildTarget === null || buildTargets === null) return;
527
+ const select = $('#buildTarget');
528
+ const names = buildTargets.includes(buildTarget) || !buildTarget ? buildTargets : [...buildTargets, buildTarget];
529
+ select.innerHTML = [['', 'Default'], ...names.map((t) => [t, buildTargets.includes(t) ? t : `${t} (not on main)`])]
530
+ .map(([value, label]) => `<option value="${esc(value)}"${value && value === buildTarget ? ' selected' : ''}>${esc(label)}</option>`).join('');
531
+ select.value = buildTarget;
532
+ select.disabled = false;
533
+ $('#buildTargetSub').innerHTML = buildTargetSub(buildTarget);
534
+ };
535
+ // A new repo brings its own scripts, so a repo save lists them again.
536
+ const loadBuildTargets = () => get(site.url('api/build-targets')).then(({ body }) => {
537
+ if (!B.alive()) return;
538
+ buildTargets = body.targets;
539
+ showBuildTargets();
540
+ }).catch(() => { $('#buildTargetStatus').textContent = 'Could not list the repo’s build scripts. Is the host awake?'; });
541
+ loadBuildTargets();
542
+ B.on($('#buildTarget'), 'change', async () => {
543
+ const select = $('#buildTarget');
544
+ try {
545
+ const settings = await saveToggle(select, { build_target: select.value || null });
546
+ if (!B.alive()) return;
547
+ buildTarget = settings.build_target || '';
548
+ showBuildTargets();
549
+ $('#buildTargetStatus').textContent = `Saved. The hub runs ${buildScriptOf(buildTarget)} for each card and main.`;
550
+ } catch (e) {
551
+ if (!B.alive()) return;
552
+ select.value = buildTarget;
553
+ $('#buildTargetStatus').textContent = e.message || 'Could not save. Is the host awake?';
554
+ }
555
+ });
556
+
449
557
  B.on($('#autoBuild'), 'change', async () => {
450
558
  const toggle = $('#autoBuild');
451
559
  const want = toggle.checked;
@@ -500,7 +608,7 @@
500
608
  status.textContent = e.message || 'Could not save. Is the host awake?';
501
609
  }
502
610
  });
503
- capInput('maxImplementers', 'max_implementers', (n) => `Saved. Up to ${n === 1 ? '1 card builds or waits' : `${n} cards build or wait`} in Orchestrator review at once.`);
611
+ capInput('maxImplementers', 'max_implementers', (n) => `Saved. Up to ${n === 1 ? '1 agent builds' : `${n} agents build`} at once.`);
504
612
  capInput('maxPlanners', 'max_planners', (n) => `Saved. Up to ${n === 1 ? '1 grill or prototype runs' : `${n} grills or prototypes run`} at once.`);
505
613
  autoToggle('autoReview', 'auto_review', (on) => (on ? 'Saved. When idle, Claude reviews incoming PRs that touch this board’s work.' : 'Saved. Claude leaves incoming PRs alone.'));
506
614
  autoToggle('idleNotification', 'idle_notification', (on) => (on ? 'Saved. One push once the board has been idle for 15 minutes.' : 'Saved. No push when the board goes idle.'));
@@ -527,13 +635,18 @@
527
635
  // the owner reach the board (docs/adr/2026-11-04-2156-the-whole-hub-answers-only-the-owner.md). A tap waits on the server with the
528
636
  // switch as it was and a spinner beside it, then draws the server's answer.
529
637
  // Sharing: each row is the person's name, then their login and when; a login Tailscale sent no name with is its own.
638
+ // A person's row also sets them View or Full with one tap, saying what that level can do
639
+ // (docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md).
530
640
  let sharing = null;
531
- const shareRow = (title, sub, button) => `<div class="who"><span>${esc(title)}<span class="muted small sub">${sub}</span></span>${button}</div>`;
641
+ const CAN = { view: 'Looks only. Visit links open.', full: 'Answers, taps card buttons and adds New tasks. Merge and Board settings stay yours.' };
642
+ const shareRow = (title, sub, button, more = '') => `<div class="who"><span>${esc(title)}<span class="muted small sub">${sub}</span>${more}</span>${button}</div>`;
643
+ const shareLevel = (p) => `<span role="group" class="share-level" aria-label="${esc(p.name || p.login)}’s access">${[['view', 'View'], ['full', 'Full']].map(([level, label]) =>
644
+ `<button type="button" data-share-level="${level}" data-login="${esc(p.login)}" aria-pressed="${p.level === level}">${label}</button>`).join('')}</span><span class="muted small sub">${CAN[p.level]}</span>`;
532
645
  const shareWhen = (p) => (p.now ? '<span class="share-now">viewing now</span>' : esc(p.at ? BoardUI.since(p.at) : 'not seen yet'));
533
646
  const shareSub = (p, when) => (p.name ? `${esc(p.login)} · ${when}` : when);
534
647
  function drawSharing() {
535
648
  $('#shareList').innerHTML = sharing.people.length ? sharing.people.map((p) => shareRow(p.name || p.login, shareSub(p, shareWhen(p)),
536
- `<button type="button" class="ghost" data-share-remove="${esc(p.login)}" aria-label="Remove ${esc(p.name || p.login)}">✕</button>`)).join('') : '<p class="muted small">Nobody yet.</p>';
649
+ `<button type="button" class="ghost" data-share-remove="${esc(p.login)}" aria-label="Remove ${esc(p.name || p.login)}">✕</button>`, shareLevel(p))).join('') : '<p class="muted small">Nobody yet.</p>';
537
650
  $('#shareRefused').innerHTML = sharing.refused.length ? `<p class="muted small">Just refused. Tap to share with them:</p>${sharing.refused.map((r) => shareRow(r.name || r.login, shareSub(r, esc(BoardUI.since(r.at))),
538
651
  `<button type="button" data-share-add="${esc(r.login)}">Add</button>`)).join('')}` : '';
539
652
  }
@@ -557,7 +670,12 @@
557
670
  }
558
671
  }
559
672
  loadSharing();
560
- B.on($('#shareList'), 'click', (e) => { const b = e.target.closest?.('[data-share-remove]'); if (b) saveShare({ remove: b.dataset.shareRemove }); });
673
+ B.on($('#shareList'), 'click', (e) => {
674
+ const b = e.target.closest?.('[data-share-remove]');
675
+ if (b) { saveShare({ remove: b.dataset.shareRemove }); return; }
676
+ const level = e.target.closest?.('[data-share-level]');
677
+ if (level) saveShare({ login: level.dataset.login, level: level.dataset.shareLevel });
678
+ });
561
679
  B.on($('#shareRefused'), 'click', (e) => { const b = e.target.closest?.('[data-share-add]'); if (b) saveShare({ add: b.dataset.shareAdd }); });
562
680
  B.on($('#shareAdd'), 'submit', async (e) => {
563
681
  e.preventDefault?.();