@lovek4d/kickflip 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CONTEXT.md +32 -16
  2. package/SKILL.md +8 -6
  3. package/bin/doctor.mjs +4 -4
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +8 -8
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/bin/board.mjs +50 -28
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -3
  10. package/board/headless.mjs +4 -3
  11. package/board/merge3.mjs +1 -0
  12. package/board/on-host.mjs +1 -1
  13. package/board/permissions.mjs +2 -2
  14. package/board/public/app-asks.js +6 -89
  15. package/board/public/app-cards.js +19 -10
  16. package/board/public/app-core.js +1 -20
  17. package/board/public/app-reader.js +174 -0
  18. package/board/public/app-threads.js +49 -35
  19. package/board/public/app.html +2 -0
  20. package/board/public/app.js +28 -16
  21. package/board/public/hub.css +1 -0
  22. package/board/public/new-task.js +121 -0
  23. package/board/public/shell.js +4 -0
  24. package/board/public/sidebar.css +1 -0
  25. package/board/public/style.css +91 -3
  26. package/board/public/ui-cards.js +46 -21
  27. package/board/public/ui-enums.js +1 -1
  28. package/board/public/ui-phase-bars.js +4 -4
  29. package/board/public/ui-questions.js +47 -18
  30. package/board/public/ui-sidebar.js +9 -3
  31. package/board/public/ui-threads.js +25 -12
  32. package/board/public/ui.js +42 -12
  33. package/board/public/view-frame.js +1 -0
  34. package/board/public/view-questions.js +45 -16
  35. package/board/public/view-settings.js +38 -19
  36. package/board/server.mjs +20 -18
  37. package/board/store.mjs +15 -13
  38. package/board/worktree.mjs +20 -20
  39. package/hub/boards.mjs +4 -4
  40. package/hub/front-door.mjs +20 -0
  41. package/hub/github.mjs +3 -3
  42. package/hub/hub.mjs +9 -12
  43. package/hub/new-board.mjs +2 -2
  44. package/hub/owner-checks.mjs +1 -0
  45. package/hub/readiness.mjs +2 -2
  46. package/hub/sessions.mjs +2 -2
  47. package/hub/try.mjs +7 -7
  48. package/package.json +1 -1
  49. package/shared/owner-check.mjs +3 -0
  50. package/shared/tailscale.mjs +1 -0
  51. package/shared/usage-gate.mjs +1 -1
@@ -7,8 +7,11 @@
7
7
  // whose options are prototype takes frames each take with ?take= (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
8
8
  // Each question's eye sends `prototype-request {key?, question, note, choice}` for that question instead of an answer,
9
9
  // with its note box as the note and its pick so far as the choice (docs/adr/2026-11-02-1822-each-question-has-its-own-prototype-eye.md).
10
- // The board server resolves the question with it, and once it is recorded the page moves on as a Send does
11
- // (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md).
10
+ // The question then reads prototype requested, locked, until its prototype round arrives, and once the request is
11
+ // recorded the page moves on as a Send does (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md,
12
+ // docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
13
+ // A topic's resolved questions fold under "resolved (N)", read-only; a resolved question's badge, once confirmed, sends
14
+ // `reask-request {key?, question}` (docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md).
12
15
  // fits: each framed take's measured size, kept across repaints and visits so a take redraws at its size at once.
13
16
  (globalThis.AppViews ||= {}).questions = ((fits) => ({
14
17
  className: 'view-questions',
@@ -20,7 +23,7 @@
20
23
 
21
24
  create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
22
25
  const { $, site } = B;
23
- const { esc, choiceLabel, questionCard, roundChip, takes, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
26
+ const { esc, choiceLabel, choiceOf, questionCard, roundChip, takes, linkContext, badge, topicName, cardTopics, topicRow, nextQuestion, sentNotice, fetchJSON, restartNotice } = BoardUI;
24
27
  // prototype-switcher.js reads a draft's note and clears the draft once it sends that question's answer.
25
28
  const draftKey = (id) => 'qdraft:' + id;
26
29
  const getDraft = (id) => { try { return JSON.parse(localStorage.getItem(draftKey(id)) || '{}'); } catch { return {}; } };
@@ -42,6 +45,8 @@
42
45
  let sentLine = '';
43
46
  // The footer after a prototype request: the blue spinner, then these words.
44
47
  const PROTOTYPE_REQUESTED = 'prototype requested';
48
+ // The bar's line while a suggested option is still pre-checked.
49
+ const SUGGESTED = 'suggested options are selected';
45
50
  const statusHtml = (line) => (line.startsWith(PROTOTYPE_REQUESTED) ? '<span class="try-spin" aria-hidden="true"></span>' : '') + esc(line);
46
51
  let scrollTake = null;
47
52
  const takeResources = new Map();
@@ -68,15 +73,17 @@
68
73
  }
69
74
  const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
70
75
 
71
- // Only questions changed here since their last answer, or with files attached, are sent.
76
+ // Only open questions changed here since their last answer, with files attached, or with a suggested option
77
+ // pre-checked and never answered, are sent; a resolved one is read-only.
72
78
  const filesOf = (q) => BoardAttach.files('q:' + q.id, site.root);
73
79
  const uploading = (qs) => qs.some((q) => BoardAttach.busy('q:' + q.id, site.root));
74
80
  function pending(qs) {
75
- return qs.filter((q) => {
81
+ return qs.filter((q) => q.state !== 'resolved' && q.state !== 'requested').filter((q) => {
76
82
  const d = getDraft(q.id);
77
83
  if (filesOf(q).length) return true;
78
- if (!('choice' in d) && !d.note) return false;
79
84
  const last = q.answers[q.answers.length - 1];
85
+ if (!last && q.suggested) return true;
86
+ if (!('choice' in d) && !d.note) return false;
80
87
  return !last || (d.choice ?? last.choice) !== last.choice || (d.note || '') !== (last.note || '');
81
88
  });
82
89
  }
@@ -117,7 +124,7 @@
117
124
  : `${topicBar()}${topicHead(t)}`;
118
125
  el.innerHTML = `${head}<form id="qform">
119
126
  ${live.map((q, i) => questionCard(q, { n: soloId ? 0 : i + 1, draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}
120
- ${done.length ? `<details class="done"><summary>Resolved · ${done.length}</summary>${done.map((q) => questionCard(q, { draft: getDraft(q.id), links: links(q), fit: fitOf(q) })).join('')}</details>` : ''}
127
+ ${done.length ? `<details class="done resolved-group"><summary><span class="caret" aria-hidden="true">›</span><span>resolved (${done.length})</span><span class="names">${done.map((q) => esc(q.id)).join(', ')}</span></summary>${done.map((q) => questionCard(q, { links: links(q) })).join('')}</details>` : ''}
121
128
  <div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${statusHtml(sentLine)}</span><span class="send-hint" aria-hidden="true">${/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘↵' : 'Ctrl+↵'}</span><button class="primary" type="submit" id="send">Send</button></div>
122
129
  </form>${soloId ? '' : waitingTopics() + resolvedTopics()}`;
123
130
  BoardAttach.paintAll();
@@ -138,11 +145,9 @@
138
145
  function repaintTakes(sec) {
139
146
  if (!sec.querySelector('.takes')) return;
140
147
  const q = byId(sec.dataset.q);
141
- const d = getDraft(q.id);
142
- const chosen = d.choice ?? q.answers[q.answers.length - 1]?.choice;
143
148
  const old = sec.querySelector('.takes');
144
149
  clearTakeResources(old);
145
- old.outerHTML = takes(q, chosen, links(q), fitOf(q));
150
+ old.outerHTML = takes(q, choiceOf(q, getDraft(q.id)), links(q), fitOf(q));
146
151
  sec.querySelectorAll('.take-shot').forEach(fitTake);
147
152
  }
148
153
 
@@ -257,14 +262,28 @@
257
262
  const at = leaving();
258
263
  st.textContent = '';
259
264
  const d = getDraft(q.id);
260
- const last = q.answers[q.answers.length - 1];
261
265
  let out;
262
- try { out = await BoardUI.waitOn(button, send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: d.choice ?? (last ? last.choice : null) })); }
266
+ try { out = await BoardUI.waitOn(button, send('prototype-request', { ...(key ? { key } : {}), question: q.id, note: (d.note || '').trim(), choice: choiceOf(q, d) })); }
263
267
  catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
264
268
  setDraft(q.id, {});
265
269
  await moveOnOnceLoaded(at, `Prototype requested${at.name ? ` for ${at.name}` : ''}.`, out, PROTOTYPE_REQUESTED);
266
270
  }
267
271
 
272
+ // A resolved question's badge asks Claude to ask it again from the card's current notes, once the owner confirms.
273
+ // The topic's resolved questions load again, so the question shows its re-ask under way.
274
+ async function askAgain(q, button) {
275
+ if (!(await BoardDialogs.confirm('Ask this again from today’s context?')) || !B.alive()) return;
276
+ const st = $('#qstatus');
277
+ const key = q.topic?.startsWith('item:') ? q.topic.slice(5) : null;
278
+ let out;
279
+ try { out = await BoardUI.waitOn(button, send('reask-request', { ...(key ? { key } : {}), question: q.id })); }
280
+ catch (e) { st.textContent = e.reason ? `Nothing was sent: ${e.reason}` : 'Could not send. Is the host awake?'; return; }
281
+ if (!B.alive()) return;
282
+ resolved = { topic: null, n: 0, questions: [] };
283
+ if (!land(out.board, false)) await load(false);
284
+ repaint(false);
285
+ }
286
+
268
287
  function updateBar() {
269
288
  const qs = visible();
270
289
  const n = pending(qs).length;
@@ -272,6 +291,10 @@
272
291
  if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
273
292
  const live = qs.filter((q) => q.state !== 'resolved');
274
293
  const answered = live.filter((q) => q.state !== 'open').length;
294
+ // docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
295
+ const prechecked = live.some((q) => q.suggested && !q.answers.length && choiceOf(q, getDraft(q.id)) === q.suggested.option);
296
+ const st = $('#qstatus');
297
+ if (st && !sentLine && (prechecked || st.textContent === SUGGESTED)) st.textContent = prechecked ? SUGGESTED : '';
275
298
  $('#progress').textContent = live.length > 1 ? `${answered}/${live.length} answered` : '';
276
299
  }
277
300
 
@@ -283,7 +306,7 @@
283
306
  topicId = t ? t.id : null;
284
307
  }
285
308
 
286
- const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered, t.round]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply))])]);
309
+ const sigNow = () => JSON.stringify([soloId, topicId, topics.map((t) => [t.id, t.state, t.open, t.answered, t.round]), visible().map((q) => [q.id, q.state, q.answers.length, q.answers.map((a) => Boolean(a.reply)), Boolean(q.reask)])]);
287
310
  // One load at a time: a load waiting out a board restart would otherwise overlap the next poll's. A load asked for
288
311
  // while one runs shares it. → true when the board's answer landed in this view.
289
312
  let loading = null;
@@ -362,8 +385,15 @@
362
385
  if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
363
386
  const eye = ev.target.closest && ev.target.closest('[data-proto]');
364
387
  if (eye) { askPrototype(byId(eye.closest('section.q').dataset.q), eye); return; }
388
+ const reask = ev.target.closest && ev.target.closest('[data-reask]');
389
+ if (reask) { if (!reask.disabled) askAgain(byId(reask.dataset.reask), reask); return; }
365
390
  const b = ev.target.closest && ev.target.closest('[data-topic]');
366
- if (b) { soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = ''; render(); scrollTo(0, 0); }
391
+ if (b) {
392
+ soloId = null; topicId = b.dataset.topic; sentLine = ''; shown = '';
393
+ const first = topics.find((t) => t.id === topicId)?.questions[0];
394
+ if (first) history.replaceState(history.state, '', '#' + encodeURIComponent(first));
395
+ render(); scrollTo(0, 0);
396
+ }
367
397
  });
368
398
  // The full-width frame shows another take without reloading, where its page's switcher can
369
399
  // (prototype-switcher.js current.go), so the page keeps its place. → false when it can't.
@@ -417,8 +447,7 @@
417
447
  // docs/adr/2026-11-03-1519-send-records-every-ready-answer-in-one-request.md: one request, recorded all or none. Send waits meanwhile.
418
448
  const answers = list.map((q) => {
419
449
  const d = getDraft(q.id);
420
- const last = q.answers[q.answers.length - 1];
421
- const choice = d.choice ?? (last ? last.choice : null);
450
+ const choice = choiceOf(q, d);
422
451
  const files = filesOf(q);
423
452
  return { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: (d.note || '').trim(), ...(files.length ? { files } : {}) };
424
453
  });
@@ -26,11 +26,7 @@
26
26
  <span class="muted small sub">Cards building or waiting in Orchestrator review count. Grills and cards waiting on you don’t.</span></span>
27
27
  <input type="number" class="count" id="maxImplementers" min="1" max="99" step="1" inputmode="numeric" disabled></label>
28
28
  <p class="muted small" id="maxImplementersStatus" role="status"></p>
29
- <label class="who" for="headlessBuilders"><span><span>Builders launch headless</span>
30
- <span class="muted small sub">The board launches each ready card’s builder from its notes, and the builder lands its own work. Ready needs a size and notes.</span></span>
31
- <input type="checkbox" class="toggle" id="headlessBuilders" disabled></label>
32
- <p class="muted small" id="headlessBuildersStatus" role="status"></p>
33
- <label class="who" for="autoBuild"><span><span>Auto-build cards</span>
29
+ <label class="who" for="autoBuild"><span><span>Auto serve</span>
34
30
  <span class="muted small sub" id="autoBuildSub">${BoardUI.esc(BoardUI.tryWords('web').autoSub)}</span></span>
35
31
  <input type="checkbox" class="toggle" id="autoBuild" disabled></label>
36
32
  <p class="muted small" id="autoBuildStatus" role="status"></p>
@@ -38,8 +34,12 @@
38
34
  <span class="muted small sub">Claude reviews teammates’ PRs that touch this board’s work. May use a lot of usage.</span></span>
39
35
  <input type="checkbox" class="toggle" id="autoReview" disabled></label>
40
36
  <p class="muted small" id="autoReviewStatus" role="status"></p>
37
+ <div id="prsRow" hidden><label class="who" for="prs"><span><span>PRs</span>
38
+ <span class="muted small sub">Each card opens a PR. Off, no PR opens: each card lands on main and main is pushed.</span></span>
39
+ <input type="checkbox" class="toggle" id="prs" disabled></label>
40
+ <p class="muted small" id="prsStatus" role="status"></p></div>
41
41
  <label class="who" for="autoMerge"><span><span>Auto-merge</span>
42
- <span class="muted small sub">Claude merges a PR once it is approved, green and merges cleanly.</span></span>
42
+ <span class="muted small sub" id="autoMergeSub">Claude merges a PR once it is approved, green and merges cleanly.</span></span>
43
43
  <input type="checkbox" class="toggle" id="autoMerge" disabled></label>
44
44
  <p class="muted small" id="autoMergeStatus" role="status"></p>
45
45
  <div id="skipDraftRow" hidden><label class="who" for="skipDraft"><span><span>Skip the draft</span>
@@ -58,6 +58,7 @@
58
58
  <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>
59
59
  <h2>Permissions</h2>
60
60
  <div id="permissions"><p class="muted small">Loading…</p></div>
61
+ <div id="reviewersSection" hidden>
61
62
  <h2>Reviewers</h2>
62
63
  <p class="muted small">Everyone here gets each PR link on this board once it's ready, and the weekday digest. Someone you add also gets the links of PRs already in review.</p>
63
64
  <div id="list"><p class="muted small">Loading…</p></div>
@@ -67,6 +68,7 @@
67
68
  </form>
68
69
  <div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
69
70
  <div class="replies" id="replies"></div>
71
+ </div>
70
72
  <h2>Rules</h2>
71
73
  <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>
72
74
  <div id="rules"><p class="muted small">Loading…</p></div>
@@ -184,10 +186,19 @@
184
186
  const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
185
187
  let repo = {}, closeRepo = () => {};
186
188
  let tickets, savedTickets, savingTickets = null, ticketsTouched = false;
187
- // Skip the draft is only for a board with pull requests, on GitHub (docs/adr/2026-11-04-1913-a-board-can-skip-the-draft.md).
189
+ // PRs shows on a board on GitHub that builds from its shared clone; a local board never opens PRs. Skip the draft and
190
+ // Reviewers show only while cards open PRs, and Auto-merge says what it merges
191
+ // (docs/adr/2026-11-04-1913-a-board-can-skip-the-draft.md, docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md).
192
+ let prsSetting = true;
193
+ const opensPRs = () => Boolean(repo.github) && !repo.local && prsSetting;
194
+ const syncPRs = () => {
195
+ $('#prsRow').hidden = !repo.github || Boolean(repo.local);
196
+ $('#skipDraftRow').hidden = $('#reviewersSection').hidden = !opensPRs();
197
+ $('#autoMergeSub').textContent = opensPRs() ? 'Claude merges a PR once it is approved, green and merges cleanly.' : 'Lands each verified card on main without waiting for your Merge.';
198
+ };
188
199
  const showRepo = () => {
189
200
  $('#boardRepoValue').textContent = repo.github || repo.remote ? `${repo.github || repo.remote} · ${repo.branch || 'Branch not recorded'}` : 'No GitHub repo';
190
- $('#skipDraftRow').hidden = !repo.github;
201
+ syncPRs();
191
202
  };
192
203
  get(repoURL).then(({ body }) => {
193
204
  if (!B.alive()) return;
@@ -348,12 +359,13 @@
348
359
  $('#autoTriage').disabled = false;
349
360
  $('#autoReview').checked = d.settings?.auto_review === true;
350
361
  $('#autoReview').disabled = false;
351
- $('#headlessBuilders').checked = d.settings?.headless_builders === true;
352
- $('#headlessBuilders').disabled = false;
353
362
  $('#idleNotification').checked = d.settings?.idle_notification === true;
354
363
  $('#idleNotification').disabled = false;
355
364
  $('#skipDraft').checked = d.settings?.skip_draft === true;
356
365
  $('#skipDraft').disabled = false;
366
+ $('#prs').checked = prsSetting = d.settings?.prs !== false;
367
+ $('#prs').disabled = false;
368
+ syncPRs();
357
369
  }).catch(() => { autoMergeStatus.textContent = ticketsStatus.textContent = 'Offline. Is the host awake?'; });
358
370
  // An Auto toggle keeps its face, disabled with a spinner beside it (ui.js waitOn), while POST api/settings saves it.
359
371
  // → the saved settings
@@ -365,14 +377,17 @@
365
377
  })());
366
378
  B.on(autoMerge, 'change', async () => {
367
379
  const want = autoMerge.checked;
368
- if (want && !(await BoardDialogs.confirm('Turn on auto-merge? Claude merges each approved PR on this board once CI is green and it merges cleanly, without asking you.'))) {
380
+ const ask = opensPRs() ? 'Turn on auto-merge? Claude merges each approved PR on this board once CI is green and it merges cleanly, without asking you.'
381
+ : 'Turn on auto-merge? Each verified card lands on main without asking you.';
382
+ if (want && !(await BoardDialogs.confirm(ask))) {
369
383
  autoMerge.checked = false;
370
384
  return;
371
385
  }
372
386
  if (!B.alive()) return;
373
387
  try {
374
388
  autoMerge.checked = (await saveToggle(autoMerge, { auto_merge: want })).auto_merge === true;
375
- autoMergeStatus.textContent = autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait for your Merge.';
389
+ autoMergeStatus.textContent = opensPRs() ? (autoMerge.checked ? 'Saved. Claude merges approved, green PRs itself.' : 'Saved. Approved, green PRs wait for your Merge.')
390
+ : autoMerge.checked ? 'Saved. Each verified card lands on main by itself.' : 'Saved. Each verified card waits for your Merge.';
376
391
  } catch (e) {
377
392
  autoMerge.checked = !want;
378
393
  autoMergeStatus.textContent = e.message || 'Could not save. Is the host awake?';
@@ -433,28 +448,32 @@
433
448
  }
434
449
  });
435
450
  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.'));
436
- autoToggle('headlessBuilders', 'headless_builders', (on) => (on ? 'Saved. The board launches each ready card’s builder itself, and the builder lands its own work.' : 'Saved. Claude briefs and launches each builder.'));
437
451
  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.'));
438
452
  autoToggle('autoTriage', 'auto_triage', (on) => (on ? 'Saved. Once a night, when cards wait on your answers, Claude triages them.' : 'Saved. Questions and Todo are left as they are.'));
439
453
  autoToggle('skipDraft', 'skip_draft', (on) => (on ? 'Saved. A builder’s draft PR is marked ready for review once CI passes.' : 'Saved. A builder’s draft PR waits for your review.'));
454
+ // PRs off needs Push main after a land, which the server checks; its refusal shows in the status line.
455
+ autoToggle('prs', 'prs', (on) => {
456
+ prsSetting = on;
457
+ syncPRs();
458
+ return on ? 'Saved. Each card opens a PR.' : 'Saved. Each card lands on main with no PR, and main is pushed.';
459
+ });
440
460
 
441
461
  // Each switch writes or removes one allow rule in the board folder's .claude/settings.local.json through POST
442
- // api/permissions, which only the host's own browser may send; elsewhere the switches are read-only
443
- // (docs/adr/2026-11-03-1921-board-settings-grant-a-boards-session-permissions.md). A tap waits on the server with the
462
+ // api/permissions (docs/adr/2026-11-03-1921-board-settings-grant-a-boards-session-permissions.md); the hub lets only
463
+ // 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
444
464
  // switch as it was and a spinner beside it, then draws the server's answer.
445
465
  const permissions = $('#permissions');
446
466
  const REFUSED = {
447
467
  denied: (command) => `Auto mode still refused this once: ${command}. Approve it under /permissions, Recently blocked.`,
448
468
  asked: (command) => `The session still asked before running ${command}, so it hasn’t picked the rule up yet. It applies once the session restarts.`,
449
469
  };
450
- const permRow = (p, owner) => `<label class="who" for="perm-${esc(p.id)}"><span><span>${esc(p.name)}</span>
470
+ const permRow = (p) => `<label class="who" for="perm-${esc(p.id)}"><span><span>${esc(p.name)}</span>
451
471
  <span class="muted small sub">${esc(p.allows)} Writes <code>${esc(p.rule)}</code></span></span>
452
- <span class="toggle-slot"><span class="try-spin toggle-spin" aria-hidden="true"></span><input type="checkbox" class="toggle" data-perm="${esc(p.id)}" id="perm-${esc(p.id)}"${p.on ? ' checked' : ''}${owner ? '' : ' disabled'}></span></label>
472
+ <span class="toggle-slot"><span class="try-spin toggle-spin" aria-hidden="true"></span><input type="checkbox" class="toggle" data-perm="${esc(p.id)}" id="perm-${esc(p.id)}"${p.on ? ' checked' : ''}></span></label>
453
473
  <p class="muted small" id="perm-${esc(p.id)}Status" role="status"></p>
454
474
  ${p.refused ? `<p class="muted small refused-note">${esc(REFUSED[p.refused.how](p.refused.command))}</p>` : ''}`;
455
475
  const drawPermissions = (d) => {
456
- permissions.innerHTML = d.permissions.map((p) => permRow(p, d.owner)).join('')
457
- + (d.owner ? '' : '<p class="muted small" id="permissionsNotOwner">Only the signed-in owner can change these.</p>');
476
+ permissions.innerHTML = d.permissions.map(permRow).join('');
458
477
  };
459
478
  get(site.url('api/permissions'), waitIn('#permissions')).then(({ ok, body }) => {
460
479
  if (!B.alive()) return;