@lovek4d/kickflip 0.4.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CONTEXT.md +69 -48
  2. package/README.md +1 -1
  3. package/SKILL.md +22 -20
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +27 -18
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/agents.mjs +2 -0
  9. package/board/bin/board.mjs +108 -39
  10. package/board/domain.mjs +7 -7
  11. package/board/finish.mjs +4 -3
  12. package/board/headless.mjs +5 -3
  13. package/board/merge3.mjs +1 -0
  14. package/board/on-host.mjs +1 -1
  15. package/board/permissions.mjs +2 -2
  16. package/board/public/app-asks.js +9 -92
  17. package/board/public/app-banner.js +7 -13
  18. package/board/public/app-cards.js +70 -37
  19. package/board/public/app-core.js +24 -25
  20. package/board/public/app-header.js +2 -1
  21. package/board/public/app-lanes.js +55 -23
  22. package/board/public/app-needs.js +129 -4
  23. package/board/public/app-reader.js +174 -0
  24. package/board/public/app-snapshot.js +6 -5
  25. package/board/public/app-threads.js +83 -58
  26. package/board/public/app.html +2 -0
  27. package/board/public/app.js +66 -24
  28. package/board/public/attach.js +2 -2
  29. package/board/public/dialogs.js +9 -39
  30. package/board/public/hub-kit.js +4 -2
  31. package/board/public/hub.css +1 -0
  32. package/board/public/linear-key.js +46 -35
  33. package/board/public/new-task.js +100 -0
  34. package/board/public/prototype-kit.js +28 -3
  35. package/board/public/prototype-switcher.js +42 -107
  36. package/board/public/shell.js +7 -1
  37. package/board/public/sidebar.css +1 -0
  38. package/board/public/sidebar.js +44 -5
  39. package/board/public/style.css +194 -35
  40. package/board/public/ui-cards.js +80 -44
  41. package/board/public/ui-enums.js +1 -1
  42. package/board/public/ui-header.js +2 -2
  43. package/board/public/ui-order.js +12 -10
  44. package/board/public/ui-phase-bars.js +4 -4
  45. package/board/public/ui-questions.js +63 -47
  46. package/board/public/ui-sidebar.js +27 -15
  47. package/board/public/ui-threads.js +27 -14
  48. package/board/public/ui.js +76 -14
  49. package/board/public/view-frame.js +1 -0
  50. package/board/public/view-hub-settings.js +11 -10
  51. package/board/public/view-hub.js +17 -3
  52. package/board/public/view-questions.js +174 -202
  53. package/board/public/view-settings.js +208 -70
  54. package/board/server.mjs +19 -18
  55. package/board/store.mjs +20 -13
  56. package/board/transcript.mjs +2 -0
  57. package/board/worktree.mjs +24 -20
  58. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  59. package/hooks-plugin/hooks/hooks.json +9 -0
  60. package/hub/boards.mjs +4 -4
  61. package/hub/front-door.mjs +20 -0
  62. package/hub/github.mjs +3 -3
  63. package/hub/hub.mjs +11 -14
  64. package/hub/linear.mjs +1 -2
  65. package/hub/live.mjs +2 -2
  66. package/hub/locks.mjs +1 -1
  67. package/hub/new-board.mjs +2 -2
  68. package/hub/owner-checks.mjs +1 -0
  69. package/hub/pr-report.mjs +2 -2
  70. package/hub/presence.mjs +1 -1
  71. package/hub/push.mjs +2 -2
  72. package/hub/readiness.mjs +1 -2
  73. package/hub/review-send.mjs +1 -0
  74. package/hub/secrets.mjs +2 -0
  75. package/hub/self-restart.mjs +2 -0
  76. package/hub/sessions.mjs +2 -2
  77. package/hub/share.mjs +1 -0
  78. package/hub/slack.mjs +2 -2
  79. package/hub/stream.mjs +10 -13
  80. package/hub/try.mjs +7 -7
  81. package/hub/upgrade.mjs +1 -1
  82. package/hub/usage.mjs +3 -3
  83. package/hub/views.mjs +1 -1
  84. package/package.json +2 -1
  85. package/shared/audit.mjs +4 -0
  86. package/shared/fake-gh.mjs +17 -4
  87. package/shared/owner-check.mjs +3 -0
  88. package/shared/service.mjs +4 -4
  89. package/shared/tailscale.mjs +1 -0
  90. package/shared/usage-gate.mjs +1 -1
@@ -14,6 +14,11 @@
14
14
  pulse: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
15
15
  close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
16
16
  plus: '<path d="M12 5v14M5 12h14"/>',
17
+ // A starting board's step icons, one per START_STEPS key. No open icon set (Lucide, Tabler) has a skateboard, so landing's
18
+ // deck with upturned ends over two wheels is drawn here.
19
+ cloning: '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 20h14"/>',
20
+ starting: '<path d="m5 7 5 5-5 5M12 18h7"/>',
21
+ landing: '<path d="M2.5 9c0 1.7 1.2 3 3 3h13c1.8 0 3-1.3 3-3"/><circle cx="7.5" cy="16.5" r="2"/><circle cx="16.5" cy="16.5" r="2"/>',
17
22
  moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
18
23
  up: '<circle cx="12" cy="12" r="9"/><path d="m8 12 4-4 4 4"/><path d="M12 16V8"/>',
19
24
  };
@@ -64,7 +69,7 @@
64
69
  const sites = data?.sites || [];
65
70
  const site = (rank) => (s) => ({ href: siteHref(s), title: String(s.title ?? s.route), board: rank === 0, rank, joined: Date.parse(s.created) });
66
71
  const rows = [...sites.filter((s) => s.board).map(site(0)), ...sites.filter((s) => !s.board).map(site(1)),
67
- ...UTILITY.map((f, i) => ({ href: f.href, title: f.title, board: false, rank: 2, joined: i }))];
72
+ ...(data?.shared ? [] : UTILITY).map((f, i) => ({ href: f.href, title: f.title, board: false, rank: 2, joined: i }))];
68
73
  rows.filter((r) => r.board).slice(0, 9).forEach((r, i) => { r.num = String(i + 1); });
69
74
  const when = (r) => (Number.isFinite(r.joined) ? r.joined : Infinity);
70
75
  const taken = new Set();
@@ -149,11 +154,12 @@
149
154
  + `<dl class="fs-facts">${fact('Repo', s.repo && esc(repoLabel(s.repo, home)))}${fact('Branch', s.branch && `<code>${esc(s.branch)}</code>`)}`
150
155
  + `${fact('Tickets', s.tickets && esc(ticketsLabel(s.tickets)))}${fact('Asked', s.askedAt && esc(askedWhen(s.askedAt, now)))}</dl>`
151
156
  + '<p class="fs-refused" role="alert" hidden></p>'
152
- + `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
157
+ + `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch && !s.joined ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
153
158
  }
154
159
 
155
160
  // A starting board's three steps (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md):
156
- // s.step is cloning, starting (the orchestrator's session) or landing (the session joining the hub), since s.stepAt.
161
+ // s.step is cloning, starting (the orchestrator's session, until it joins the hub) or landing (the joined board, until it
162
+ // listens), since s.stepAt.
157
163
  // Cloning fills its bar to git's percent; the later steps run the bars' pulse, never a fill.
158
164
  const START_STEPS = { cloning: 'cloning', starting: 'starting orchestrator', landing: 'landing it' };
159
165
  const STEP_KEYS = Object.keys(START_STEPS);
@@ -170,25 +176,28 @@
170
176
  }
171
177
 
172
178
  // The New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
173
- // /_hub/sidebar.json's npm update, null | {state: available | waiting | updating | failed, version, builders, error}
174
- // (hub/upgrade.mjs; docs/adr/2026-11-03-1521-the-hub-offers-a-newer-kickflip-from-npm.md). A reload, and an asked
175
- // update until it runs out, waits (ui.js BUSY): its pill keeps its blue with a spinner in place of its word.
179
+ // /_hub/sidebar.json's npm update, null | {state: available | updating | updated | restarting | failed, version, error}
180
+ // (hub/upgrade.mjs; docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md). The hub pulls a new
181
+ // version itself, so the row reads Updated to v<version> with Restart; restarting is the sidebar's own state while the hub
182
+ // restarts. A reload, a pull and a restart wait (ui.js BUSY): the pill keeps its blue with a spinner in place of its word.
176
183
  function updateRow(update, upgrade) {
177
184
  const button = (attr, busy, title, sub, go) => `<button type="button" class="sb-row sb-update-row" ${attr}${busy ? globalThis.BoardUI.BUSY : ''}>${tile('up', 'var(--accent)')}`
178
185
  + `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span><span class="sb-go">${go}</span></button>`;
179
186
  if (update) return button('data-app-reload', update === 'reloading', 'New version', '', 'Reload');
180
187
  if (!upgrade) return '';
181
- if (upgrade.state === 'waiting') return button('data-app-upgrade', true, `Update waits for ${upgrade.builders} implementer${upgrade.builders === 1 ? '' : 's'}`, '', 'Upgrade');
182
- if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Update available', '', 'Upgrade');
183
- if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Upgrade');
184
- return button('data-app-upgrade', false, 'Update available', '', 'Upgrade');
188
+ if (upgrade.state === 'updated') return button('data-app-restart', false, `Updated to v${esc(upgrade.version)}`, upgrade.error, 'Restart');
189
+ if (upgrade.state === 'restarting') return button('data-app-restart', true, `Restarting to v${esc(upgrade.version)}`, '', 'Restart');
190
+ if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Updating', '', 'Update');
191
+ if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Update');
192
+ return button('data-app-upgrade', false, 'Update available', '', 'Update');
185
193
  }
186
194
 
187
195
  // data: /_hub/sidebar.json. here: a site's route or a hub page's path without its leading slash. device: this device's push
188
196
  // subscription as sidebar.js finds it, 'on' | 'off' | 'blocked' | 'install' (iOS outside the Home Screen app), or
189
197
  // null while unknown or unsupported. switching: switch mode shows, each row with its key and the hint on the Boards label.
190
198
  // update: null | 'ready' | 'reloading'.
191
- // now: the time a starting board's seconds count to.
199
+ // now: the time a starting board's seconds count to. A shared person's data (data.shared; hub/share.mjs sharedSidebar)
200
+ // draws their boards alone: no Hub row, update or notification rows, and no New board.
192
201
  function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
193
202
  const sites = data?.sites || [];
194
203
  const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
@@ -203,7 +212,7 @@
203
212
  const pending = (data?.starting || []).map((s) => (s.failed
204
213
  ? `<button type="button" class="sb-row sb-starting sb-failed" data-starting="${esc(s.route)}" data-failed-start="${esc(s.route)}" aria-haspopup="dialog">${plus}`
205
214
  + `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span><span class="sb-sub">failed to start</span></span></button>`
206
- : `<div class="sb-row sb-starting" data-starting="${esc(s.route)}">${plus}`
215
+ : `<div class="sb-row sb-starting" data-starting="${esc(s.route)}"><span class="icon sb-plus sb-start-icon" aria-hidden="true">${svg(ICONS[s.step], 20)}</span>`
207
216
  + `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span>${startSteps(s)}<span class="sb-sub sb-step">${esc(startCaption(s, now))}</span></span></div>`)).join('');
208
217
  const newBoard = `<button type="button" class="sb-row sb-new" data-new-board aria-haspopup="dialog">${plus}<span class="sb-text"><span class="sb-title">New board</span></span></button>`;
209
218
  const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Turn on notifications', sub: 'Off on this device', cls: 'sb-turnon' })
@@ -225,13 +234,16 @@
225
234
  const selected = here.startsWith('_hub') ? '/_hub' : `/${here.replace(/\/$/, '')}`;
226
235
  const hint = switching ? '<span class="ks-label-hint" role="status">– press a key to switch<i class="ks-bar"></i></span>' : '';
227
236
  const close = `<div class="nav"><button type="button" class="sb-close" data-sidebar-close aria-label="Close boards">${svg(ICONS.close, 20)}</button></div>`;
228
- return header({ lead: 'logo', title: 'Kickflip', level: 2, controls: false, trail: close, logoSrc: '/_hub/kickflip.png',
229
- leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } })
237
+ const top = header({ lead: 'logo', title: 'Kickflip', level: 2, controls: false, trail: close, logoSrc: '/_hub/kickflip.png',
238
+ leadAttrs: { 'aria-label': 'Hide boards', title: 'Hide boards', 'data-sidebar-close': '' } });
239
+ const boardsLabel = `<div class="sb-label ks-label"><span class="ks-label-text">Boards</span>${hint}</div>`;
240
+ if (data?.shared) return `${top}<div class="sb-scroll"><section class="sb-boards" aria-label="Boards">${boardsLabel}<div class="sb-group">${boards.map(siteRow).join('')}</div></section></div>`;
241
+ return top
230
242
  + `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update, data?.upgrade)}</div><div class="sb-group">`
231
243
  + UTILITY.map((f) => row({ ...f, on: f.href.replace(/\/$/, '') === selected, sub: subs[f.href], mark: f.href === '/_hub/' ? moon : '', ...(f.href === '/_hub/' && usage ? { extra: usage, cls: 'sb-hub-usage' } : {}), key: keys?.get(f.href) })).join('')
232
244
  + `</div>${turnOn}</section>`
233
245
  + (pages.length ? `<section class="sb-pages" aria-label="Pages"><div class="sb-label">Pages</div><div class="sb-group">${pages.map(siteRow).join('')}</div></section>` : '')
234
- + `<section class="sb-boards" aria-label="Boards"><div class="sb-label ks-label"><span class="ks-label-text">Boards</span>${hint}</div>`
246
+ + `<section class="sb-boards" aria-label="Boards">${boardsLabel}`
235
247
  + `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
236
248
  }
237
249
 
@@ -1,7 +1,7 @@
1
1
  // A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
2
2
  // their replies. Needs ui.js, ui-links.js and ui-enums.js.
3
3
  (function () {
4
- const { esc, time, etaLine, replyBody, attachments, attachButton, dropZone, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, BUTTON } = globalThis.BoardUI;
4
+ const { esc, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
5
5
  const VIA = { slack: ' · Slack', github: ' · GitHub' };
6
6
  // A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
7
7
  function cardLink(link) {
@@ -13,10 +13,13 @@
13
13
  }
14
14
  // A message's state comes from domain.mjs threadOf.
15
15
  const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
16
- const msgFooter = (m) => ({
17
- working: () => `<div class="reply muted small">${workingLine}</div>`,
18
- waiting: () => '<div class="reply muted small">Waiting for Claude…</div>',
19
- }[stateOf(m)] || (() => ''))();
16
+ // A message's footer: the tap it came from (m.status, such as "answers submitted"), then, to its right, Claude's
17
+ // pick-up while it waits (docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
18
+ const PICK_UP = { working: workingLine, waiting: 'Waiting for Claude…' };
19
+ const msgFooter = (m) => {
20
+ const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]].filter(Boolean);
21
+ return parts.length ? `<div class="reply muted small">${parts.join('')}</div>` : '';
22
+ };
20
23
  // Each reply becomes Claude's own message at the time it was written, quoting the owner's message
21
24
  // when other messages came between them. A reply with no time follows its message.
22
25
  const withReplies = (msgs) => {
@@ -29,26 +32,27 @@
29
32
  // files it adds to the message, under composer id edit:<event id>.
30
33
  const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}" data-composer="edit:${esc(id)}">${dropZone(`<textarea rows="3">${esc(text)}</textarea>`)}
31
34
  <div class="pending-files" data-files></div>
32
- <div class="row">${attachButton()}<span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
33
- // batched: an answer followed by another of the owner's answers in its state, which carries the batch's Waiting for Claude.
35
+ <div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
36
+ // batched: an answer followed by another of the owner's answers in its state, which carries the batch's footer.
34
37
  const line = (m, links, edits, batched = false) => (m.kind === 'reply'
35
38
  ? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
36
39
  : m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
37
- : m.request ? `<div class="msg request">${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
38
- : m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
39
- : m.kind === 'calls' ? `<details class="msg calls"><summary>${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
40
- : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}"><span class="agent-dot" aria-hidden="true"></span>${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
40
+ : m.request ? `<div class="msg request">${statusMark(m.text)}${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
41
+ : m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${esc(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
42
+ : m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
43
+ : m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
44
+ : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
41
45
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
42
46
  <div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
43
47
  <div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
44
- ${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log →</a></div>` : ''}
48
+ ${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log</a></div>` : ''}
45
49
  ${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
46
50
  ${m.eventId && !batched ? msgFooter(m) : ''}
47
51
  </div>`);
48
52
 
49
53
  const STEP = 3;
50
54
  const UNREAD_CAP = 10;
51
- const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link']);
55
+ const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions']);
52
56
  const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
53
57
  // Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
54
58
  // orchestrator's answer to a builder's ask never is.
@@ -92,5 +96,14 @@
92
96
  return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
93
97
  }
94
98
 
95
- Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton });
99
+ // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
100
+ // A card's open questions asked since seenAt as one blue line in its thread, at the newest one's time, linking to the
101
+ // first: it.questions from the board. → [line], or [] with none new or no seen mark.
102
+ function newQuestionsLine(questions, seenAt) {
103
+ const fresh = seenAt ? (questions?.askedAt || []).filter((t) => String(t) > seenAt) : [];
104
+ if (!fresh.length) return [];
105
+ return [{ ts: fresh.at(-1), who: 'claude', kind: 'questions', text: `${fresh.length} new question${fresh.length === 1 ? '' : 's'}`, link: `questions.html#${encodeURIComponent(questions.first)}` }];
106
+ }
107
+
108
+ Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, newQuestionsLine });
96
109
  })();
@@ -13,6 +13,9 @@
13
13
  if (s < 86400) return Math.round(s / 3600) + 'h ago';
14
14
  return Math.round(s / 86400) + 'd ago';
15
15
  };
16
+ // How long ago a card's status was first set, as its status line ends: "just now", "14m", "3h", "2d"
17
+ // (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md).
18
+ const lasted = (iso) => ago(iso).replace(/ ago$/, '');
16
19
  const since = (iso) => {
17
20
  const s = (Date.now() - new Date(iso).getTime()) / 1000;
18
21
  return s < 60 ? 'just now' : s < 3600 ? Math.round(s / 60) + ' min ago' : s < 86400 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago';
@@ -49,23 +52,50 @@
49
52
  }
50
53
  const CLIP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
51
54
  const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
52
- // A composer's text box, which attach.js outlines with Drop to attach while files are dragged over the composer.
53
- const dropZone = (textarea) => `<div class="dz">${textarea}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
55
+ // A composer's text box with its attach button inside at the bottom right, which attach.js outlines with Drop to
56
+ // attach while files are dragged over the composer.
57
+ const dropZone = (textarea) => `<div class="dz">${textarea}${attachButton()}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
58
+ // docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md
59
+ // A composer's send buttons: the one with nth 0 presses on ⌘↵ and the one with nth 1, where there is one, on ⌥↵,
60
+ // wherever each sits in the row (Ctrl+↵ and
61
+ // Alt+↵ off a Mac), each with its key as a small gray hint that style.css shows only where there is a keyboard and room.
62
+ const MAC = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform || globalThis.navigator?.userAgent || '');
63
+ const SEND_KEYS = MAC ? ['⌘↵', '⌥↵'] : ['Ctrl+↵', 'Alt+↵'];
64
+ const sendButton = (label, nth, attrs = '') => `<button type="submit"${nth === 0 ? ' class="primary"' : ''} data-nth="${nth}"${attrs}>${esc(label)}<span class="key-hint" aria-hidden="true">${SEND_KEYS[nth]}</span></button>`;
65
+ // The send button an Enter keydown in form's box presses, or null.
66
+ const keyedSend = (ev, form) => {
67
+ if (ev.key !== 'Enter' || ev.isComposing) return null;
68
+ const nth = (ev.metaKey || ev.ctrlKey) && !ev.altKey ? 0 : ev.altKey && !ev.metaKey && !ev.ctrlKey ? 1 : -1;
69
+ return nth < 0 ? null : form.querySelector(`button[type="submit"][data-nth="${nth}"]`);
70
+ };
54
71
  const icon = (body, size = 18, attrs = '') => `<svg${attrs} width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
55
72
  // An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
56
73
  const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
57
74
  const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
58
75
  const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
59
- const ELLIPSIS = icon('<circle cx="5" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="19" cy="12" r="1" fill="currentColor"/>');
60
- // Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
61
- function protoButton(id, state = null, { label = 'Ask for prototypes' } = {}) {
62
- if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made">${ELLIPSIS}</button>`;
63
- return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
64
- }
65
- // latestBump is the data.id of the newest ask-bump event.
66
- const protoNotice = (eventId, latestBump) => (latestBump === eventId
67
- ? { text: 'Prototypes are being made.', bump: false }
68
- : { text: 'Prototypes are being made. Bump them to the top of the queue?', bump: true });
76
+ const FIRE = icon('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>');
77
+ const BOLT = icon('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>');
78
+ // Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
79
+ // question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
80
+ const protoButton = (id, { label = 'Ask for prototypes' } = {}) => `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
81
+ // The thread's fire, the eye's twin for a grill.
82
+ const grillButton = (id) => `<button type="button" class="attach proto-ask" data-grill="${esc(id)}" aria-label="Ask for a grill" title="Ask for a grill">${FIRE}</button>`;
83
+ // A Todo card's thread footer lightning: starts a quick build of the card at once (app-threads.js).
84
+ const boltButton = (id) => `<button type="button" class="attach proto-ask" data-bolt="${esc(id)}" aria-label="Quick build" title="Quick build">${BOLT}</button>`;
85
+ // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
86
+ // A thread status line's leading mark, its icon or dot centred in one fixed box so every line's text starts at the
87
+ // same place: the request's eye, fire or quick build's lightning, answers' check, new questions' plain question mark, a builder's dot.
88
+ const mini = (body, cls = '') => `<svg class="mark-icon${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
89
+ const MARK_ICONS = {
90
+ 'prototype requested': mini('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
91
+ 'grill requested': mini('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>'),
92
+ 'quick build: no grill': mini('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>'),
93
+ 'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
94
+ 'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
95
+ questions: mini('<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .9-1 1.7M12 17h.01"/>', ' big'),
96
+ };
97
+ const mark = (inner = '') => `<span class="mark" aria-hidden="true">${inner}</span>`;
98
+ const statusMark = (words) => mark(MARK_ICONS[words] || '');
69
99
  const PENCIL = icon('<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>');
70
100
  const BUMP = icon('<path d="M5 3h14"/><path d="m18 13-6-6-6 6"/><path d="M12 7v14"/>');
71
101
  // Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
@@ -280,6 +310,38 @@
280
310
  return promise;
281
311
  }
282
312
 
313
+ // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md
314
+ // A tap whose change shows only in a later board: its button draws BUSY while pending.has(id), id being
315
+ // "<card key>:<action>", whatever repaints it, until settle(board) finds a board made after the tap's answer for which
316
+ // until(board) holds. start(id, until) on the tap; answered(id, generatedAt) with the generatedAt of the POST's board;
317
+ // drop(id) when the POST is refused. Boards made before the POST answered never end it, so a reload that races the POST
318
+ // keeps the spinner.
319
+ // A tap whose answer carries no board (a bare fetch) calls answered(id) with no time: any board then ends it, so its
320
+ // until() must hold only for a change the tap caused. start(id, until, onLate): if no board ends it within
321
+ // PENDING_LATE_MS the entry drops and onLate() runs (the page reloads once and says so).
322
+ const PENDING_LATE_MS = 60000;
323
+ const pendings = new Map();
324
+ const pending = {
325
+ start(id, until, onLate = null) {
326
+ pending.drop(id);
327
+ const late = onLate && setTimeout(() => { pendings.delete(id); onLate(); }, PENDING_LATE_MS);
328
+ pendings.set(id, { until, after: null, late });
329
+ },
330
+ answered(id, after = '') { const p = pendings.get(id); if (p) p.after = after; },
331
+ drop(id) { clearTimeout(pendings.get(id)?.late); pendings.delete(id); },
332
+ has: (id) => pendings.has(id),
333
+ // A tap whose change is drawn by the page's own load, not by a board (a thread, a page's redraw): resolves when work
334
+ // settles, or after PENDING_LATE_MS calls onLate() and resolves, so waitOn's spinner rests either way.
335
+ within(work, onLate) {
336
+ let timer;
337
+ const late = new Promise((resolve) => { timer = setTimeout(() => { onLate(); resolve(); }, PENDING_LATE_MS); });
338
+ return Promise.race([work, late]).finally(() => clearTimeout(timer));
339
+ },
340
+ settle(board) {
341
+ for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pending.drop(id);
342
+ },
343
+ };
344
+
283
345
  // While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
284
346
  // on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
285
347
  // then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
@@ -309,7 +371,7 @@
309
371
 
310
372
  globalThis.BoardUI = {
311
373
  LINK_ARROW,
312
- esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, fetchJSON, RESTARTING, restartNotice,
313
- attachments, attachButton, dropZone, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
374
+ esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, RESTARTING, restartNotice,
375
+ attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, boltButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
314
376
  };
315
377
  })();
@@ -85,6 +85,7 @@
85
85
  B.on(doc, 'click', (e) => tap(e, win));
86
86
  // Switch mode (sidebar.js) answers keys and clicks in the frame as it does the shell's own.
87
87
  if (ctx.keydown) B.on(doc, 'keydown', ctx.keydown, true);
88
+ if (B.newTaskKey) B.on(doc, 'keydown', B.newTaskKey);
88
89
  if (ctx.pointerdown) B.on(doc, 'pointerdown', ctx.pointerdown, true);
89
90
  ctx.loaded(url, title);
90
91
  });
@@ -1,6 +1,6 @@
1
1
  // HubSettings: summary rows below Hub’s health and status open centred settings modals ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)), using
2
2
  // settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
3
- // hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key does through linear-key.js; Done waits for
3
+ // hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key and Slack's token do through linear-key.js; Done waits for
4
4
  // a key's save to land. The rules editor saves through POST /_hub/settings/rules
5
5
  // and marks [data-dirty] while an edit is unsaved, so the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
6
6
  (function () {
@@ -58,7 +58,7 @@
58
58
  + `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
59
59
  }
60
60
 
61
- const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
61
+ const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['slack', 'Slack'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
62
62
  const sectionOf = (hash) => {
63
63
  const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
64
64
  return sections.some(([section]) => section === id) ? id : null;
@@ -79,7 +79,7 @@
79
79
  let flash = null;
80
80
  let rules = null;
81
81
  let rulesJSON = '';
82
- let linearKey = null;
82
+ let tokenSlot = null;
83
83
 
84
84
  function paint() {
85
85
  if (!last) return;
@@ -87,16 +87,16 @@
87
87
  top.innerHTML = HubUI.flash(flash);
88
88
  const scrollTop = B.$('.settings-body').scrollTop;
89
89
  if (active === 'notifications') {
90
- content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}${last.presence ? row({ icon: tile('laptop', 'var(--color-quiet)'), title: esc(last.presence.title) }) : ''}</div>`;
90
+ content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
91
91
  } else if (active === 'quiet') {
92
92
  content.innerHTML = quietRows(last.quiet);
93
93
  } else if (active === 'keep-awake') {
94
94
  content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
95
95
  : `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -ims</code> on the host so it doesn’t sleep while boards work. The screen can still turn off.</p>`;
96
- } else if (active === 'linear') {
97
- if (linearKey) linearKey.set(last.linear);
96
+ } else if (active === 'linear' || active === 'slack') {
97
+ if (tokenSlot) tokenSlot.set(last[active]);
98
98
  else {
99
- linearKey = LinearKey.mount(content, { linear: last.linear,
99
+ tokenSlot = LinearKey.mount(content, { kind: active, state: last[active],
100
100
  saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
101
101
  busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
102
102
  }
@@ -112,6 +112,7 @@
112
112
  quiet: last.quiet.summary,
113
113
  'keep-awake': last.awake.summary,
114
114
  linear: last.linear?.summary || 'No key',
115
+ slack: last.slack?.summary || 'No token',
115
116
  rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
116
117
  closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
117
118
  };
@@ -152,7 +153,7 @@
152
153
  function closeModal(restore = true) {
153
154
  unfitModal?.(); unfitModal = null;
154
155
  modal.close();
155
- linearKey?.destroy(); linearKey = null;
156
+ tokenSlot?.destroy(); tokenSlot = null;
156
157
  content.innerHTML = '';
157
158
  rules = null;
158
159
  active = null;
@@ -186,8 +187,8 @@
186
187
  return true;
187
188
  };
188
189
  const dismiss = () => {
189
- if (!linearKey?.busy()) { B.openSettings({ kind: 'hub' }); return; }
190
- linearKey.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
190
+ if (!tokenSlot?.busy()) { B.openSettings({ kind: 'hub' }); return; }
191
+ tokenSlot.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
191
192
  };
192
193
  B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
193
194
  B.on(root, 'click', (e) => {
@@ -39,8 +39,12 @@
39
39
  const { row, tile } = HubUI;
40
40
  const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
41
41
  ? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
42
+ // One line per device with notifications on: kind, browser, last seen, and a Remove that forgets it.
43
+ const mine = (() => { try { return localStorage.getItem('kickflip.push.id'); } catch { return null; } })();
44
+ const deviceLine = (d) => `<span class="rd-device">${esc([d.kind, d.browser, `seen ${BoardUI.ago(d.added)}`].filter(Boolean).join(' · '))}${d.id === mine ? ' · <b>this device</b>' : ''} <button type="button" class="link" data-forget-device="${esc(d.id)}">Remove</button></span>`;
45
+ const devices = (it) => it.devices?.length ? `<span class="links">${it.devices.map(deviceLine).join('')}</span>` : '';
42
46
  const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true,
43
- sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}`
47
+ sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}${devices(it)}`
44
48
  : `<span class="st wait">not ready</span> · ${esc(it.missing)}<span class="links"><span class="rd-fix">${fixHtml(it.fix)}</span>${it.waits ? `<span class="rd-waits">${esc(it.waits)}</span>` : ''}</span>` })).join('');
45
49
  return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
46
50
  <p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
@@ -73,7 +77,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
73
77
  <div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
74
78
  }
75
79
 
76
- const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span></footer>`;
80
+ const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v${esc(d.version)}</span></footer>`;
77
81
 
78
82
  // iOS shows an app badge only in a Home Screen app with notifications allowed; elsewhere this does nothing.
79
83
  function badge(n) {
@@ -109,8 +113,11 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
109
113
  if (!r.ok || !out) throw new Error();
110
114
  if (B.alive()) B.toast(`${out.title} is back up, and its session is listening.`);
111
115
  } catch { flash = { text: out?.error || `Could not restart ${title}. Is the host awake?`, error: true }; }
116
+ // The row keeps its Restart busy until the hub's data shows the board's new word, and only a refusal loads after
117
+ // (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
118
+ if (!flash && B.alive()) await BoardUI.pending.within(load(), () => { B.toast('Still waiting on the board'); load(); });
112
119
  restarting.delete(route);
113
- if (B.alive()) { paint(); load(); }
120
+ if (B.alive()) { paint(); if (flash) load(); }
114
121
  }
115
122
  // Restart hub asks the host to restart the hub service (POST /_hub/restart-hub), then waits for the hub to answer
116
123
  // with other code than it ran (its x-hub-code) and reloads the app; past HUB_WAIT tries it says how to restart by hand.
@@ -145,7 +152,14 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
145
152
  flash = { text: `The hub hasn’t come back. On the host, run ${out.command}`, error: true };
146
153
  if (B.alive()) paint();
147
154
  }
155
+ async function forgetDevice(id) {
156
+ const out = await fetch('/_hub/push/forget', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }).then((r) => r.ok, () => false);
157
+ if (!out) { flash = { text: 'Could not remove that device. Is the host awake?', error: true }; paint(); return; }
158
+ load();
159
+ }
148
160
  B.on(main, 'click', (e) => {
161
+ const forgetter = e.target.closest?.('[data-forget-device]');
162
+ if (forgetter) forgetDevice(forgetter.dataset.forgetDevice);
149
163
  const opener = e.target.closest?.('[data-settings-open]');
150
164
  if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
151
165
  if (e.target.closest?.('[data-restart-hub]')) restartHub();