@lovek4d/kickflip 0.5.0 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/CONTEXT.md +47 -42
  2. package/README.md +1 -1
  3. package/SKILL.md +16 -16
  4. package/bin/kickflip.mjs +26 -17
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/bin/board.mjs +65 -18
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +5 -4
  11. package/board/on-host.mjs +1 -1
  12. package/board/public/app-asks.js +6 -6
  13. package/board/public/app-banner.js +7 -13
  14. package/board/public/app-cards.js +51 -27
  15. package/board/public/app-core.js +26 -8
  16. package/board/public/app-header.js +2 -1
  17. package/board/public/app-lanes.js +55 -23
  18. package/board/public/app-needs.js +129 -4
  19. package/board/public/app-reader.js +1 -1
  20. package/board/public/app-snapshot.js +6 -5
  21. package/board/public/app-threads.js +70 -59
  22. package/board/public/app.js +38 -8
  23. package/board/public/attach.js +2 -2
  24. package/board/public/dialogs.js +9 -39
  25. package/board/public/hub-kit.js +4 -2
  26. package/board/public/linear-key.js +46 -35
  27. package/board/public/new-task.js +17 -38
  28. package/board/public/prototype-kit.js +28 -3
  29. package/board/public/prototype-switcher.js +42 -107
  30. package/board/public/shell.js +7 -5
  31. package/board/public/sidebar.js +44 -5
  32. package/board/public/style.css +108 -37
  33. package/board/public/ui-cards.js +41 -30
  34. package/board/public/ui-header.js +2 -2
  35. package/board/public/ui-order.js +12 -10
  36. package/board/public/ui-questions.js +25 -38
  37. package/board/public/ui-sidebar.js +18 -12
  38. package/board/public/ui-threads.js +3 -3
  39. package/board/public/ui.js +40 -8
  40. package/board/public/view-hub-settings.js +11 -10
  41. package/board/public/view-hub.js +17 -3
  42. package/board/public/view-questions.js +142 -199
  43. package/board/public/view-settings.js +191 -72
  44. package/board/server.mjs +19 -20
  45. package/board/store.mjs +20 -15
  46. package/board/transcript.mjs +2 -0
  47. package/board/worktree.mjs +24 -20
  48. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  49. package/hooks-plugin/hooks/hooks.json +9 -0
  50. package/hub/boards.mjs +4 -4
  51. package/hub/front-door.mjs +5 -5
  52. package/hub/github.mjs +3 -3
  53. package/hub/hub.mjs +11 -11
  54. package/hub/linear.mjs +1 -2
  55. package/hub/live.mjs +2 -2
  56. package/hub/locks.mjs +1 -1
  57. package/hub/pr-report.mjs +2 -2
  58. package/hub/presence.mjs +1 -1
  59. package/hub/push.mjs +2 -2
  60. package/hub/readiness.mjs +1 -2
  61. package/hub/review-send.mjs +1 -0
  62. package/hub/secrets.mjs +2 -0
  63. package/hub/self-restart.mjs +2 -0
  64. package/hub/sessions.mjs +2 -2
  65. package/hub/share.mjs +1 -0
  66. package/hub/slack.mjs +2 -2
  67. package/hub/stream.mjs +10 -13
  68. package/hub/try.mjs +6 -6
  69. package/hub/upgrade.mjs +1 -1
  70. package/hub/usage.mjs +3 -3
  71. package/hub/views.mjs +1 -1
  72. package/package.json +2 -1
  73. package/shared/audit.mjs +4 -0
  74. package/shared/fake-gh.mjs +17 -4
  75. package/shared/service.mjs +4 -4
  76. package/shared/usage-gate.mjs +1 -1
@@ -1,16 +1,20 @@
1
1
  // Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
2
2
  (function () {
3
- const { esc, time, ago, replyBody, attachments, attachButton, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
- // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it. prototype-switcher.js answerLabel matches.
3
+ const { esc, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
+ // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
5
5
  const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
6
6
  const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
7
7
  // suggested: the line under a suggested option saying where the owner chose it before, as HTML.
8
- const option = (o, { type = 'radio', name, checked = false, numbered = true, shown = false, locked = false, suggested = '' }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
8
+ const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '' }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
9
9
  <span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${suggested ? badge('ready', 'suggested').replace('class="chip', 'class="chip suggested') : ''}${o.recommended ? '<span class="rec">recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div>${suggested ? `<div class="suggested-why">${suggested}</div>` : ''}</span></label>`;
10
10
  // docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
11
11
  // A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
12
12
  // suggested, pre-checked so the topic's Send sends it.
13
13
  const choiceOf = (q, draft) => draft.choice ?? (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
14
+ // A question's unsent {choice, note} on this device, in localStorage under qdraft:<id>. view-questions.js and the
15
+ // phone's deck (app-needs.js) and the prototype viewer's Pick (prototype-switcher.js) write it.
16
+ const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
17
+ const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
14
18
  const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`You answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
15
19
 
16
20
  const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
@@ -18,7 +22,7 @@
18
22
  // requested until its prototype round arrives (view-questions.js).
19
23
  const questionEye = (q) => protoButton('q:' + q.id, { label: 'Ask for a prototype instead' });
20
24
  const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}">${dropZone(`<textarea class="note" rows="2" placeholder="Note (optional)">${esc(note || '')}</textarea>`)}
21
- <div class="note-tools">${attachButton()}${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
25
+ <div class="note-tools">${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
22
26
 
23
27
  const QUESTION_STATE = {
24
28
  open: { label: 'open', tone: 'normal' },
@@ -40,7 +44,9 @@
40
44
  const last = q.answers[q.answers.length - 1];
41
45
  return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
42
46
  }
43
- // A prototype page in the site's public/ opens in place, and its switcher picks a take (board/public/prototype-switcher.js).
47
+ // A prototype page in the site's public/ is the question's full-page viewer: it opens in place, shows one take at a
48
+ // time, and its switcher drafts a pick (board/public/prototype-switcher.js,
49
+ // docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md).
44
50
  const PROTOTYPE_PAGE = /^prototype-[\w-]+\.html(?:[?#]|$)/;
45
51
  const isPrototype = (url) => Boolean(url) && PROTOTYPE_PAGE.test(globalThis.location && url.startsWith(location.origin + '/') ? url.replace(/^[^?#]*\//, '') : url);
46
52
  const withParam = (url, key, value) => {
@@ -48,13 +54,11 @@
48
54
  return `${url.slice(0, at)}${url.slice(0, at).includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}${url.slice(at)}`;
49
55
  };
50
56
  const questionLink = (url) => !url ? ''
51
- : isPrototype(url) ? `<a class="pill proto" href="${esc(url)}">See the prototypes →</a>`
57
+ : isPrototype(url) ? `<a class="button proto-view" href="${esc(url)}">View prototypes</a>`
52
58
  : `<div class="small" style="margin-bottom:8px"><a href="${esc(url)}"${opensHere(url) ? '' : ' target="_blank" rel="noopener"'}>${esc(url.replace(/^https:\/\/(linear\.app\/[^/]+\/issue|github\.com)\//, ''))} ${LINK_ARROW}</a></div>`;
53
59
 
54
- // A question's takes show inline (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): an option with `diagram` draws
55
- // it with its `practice`, and on a question whose url is a prototype page any other option shows that page's take.
56
- const framed = (q) => isPrototype(q.url) && q.options.some((o) => !o.diagram);
57
- const inline = (q) => isPrototype(q.url) || q.options.some((o) => o.diagram);
60
+ // An option with `diagram` draws it with its `practice` in the question (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md).
61
+ const drawn = (q) => q.options.some((o) => o.diagram);
58
62
 
59
63
  // rows: [[{text, tone?, to?}]], top to bottom. A box points at the boxes of the next row listed in `to`, else at all
60
64
  // of them; tone is a status colour's name.
@@ -72,28 +76,12 @@
72
76
  }
73
77
  const practice = (lines, links) => `<div class="practice"><b>In practice</b><ul>${lines.map((l) => `<li>${linkText(l, links)}</li>`).join('')}</ul></div>`;
74
78
 
75
- const fullTakes = (q, fit) => isPrototype(q.url) && q.options.every((o) => !o.diagram && !o.practice)
76
- && (q.options.length === 1 || q.options.some((o) => fit(o.id)?.scrolls));
77
- // Grid heights decide the layout; fullH measures the chosen take at the full content width.
78
- function takeBody(q, o, links, fit, full = false) {
79
- if (o.diagram) return diagram(o.diagram) + practice(o.practice || [], links);
80
- if (!isPrototype(q.url)) return '';
81
- const size = fit(o.id);
82
- const h = full ? size?.fullH : size?.h;
83
- // Every take loads so one outside the viewport can still decide the question's layout.
84
- return `<div class="take-shot${h ? '' : ' measuring'}"${h ? ` style="height:${Math.round(h)}px"` : ''}><iframe src="${esc(withParam(q.url, 'take', o.id))}" title="${esc(o.label)}"></iframe></div>`;
85
- }
86
- function takes(q, chosen, links, fit, locked = false) {
79
+ function takes(q, chosen, links, locked = false) {
87
80
  const name = 'c-' + q.id;
88
- if (fullTakes(q, fit)) {
89
- const shown = q.options.find((o) => o.id === chosen) || q.options.find((o) => o.recommended) || q.options[0];
90
- return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown, locked, suggested: suggestedLine(q, o, links) })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
91
- }
92
- return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
81
+ return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
93
82
  }
94
- // A confirm links its one take alone, and says why it is asked.
95
- const confirmLines = (q) => !q.confirm_of ? ''
96
- : `<a class="pill proto pr-open" href="${esc(withParam(q.url, 'take', q.options[0].id))}">Open the prototype →</a>${q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : ''}`;
83
+ // A confirm says why it is asked; its View prototypes opens the viewer on its one take (?variant=).
84
+ const confirmLine = (q) => q.confirm_of && q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : '';
97
85
  // docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md
98
86
  // A resolved question, read-only: its green resolved badge asks Claude to ask it again (view-questions.js sends
99
87
  // `reask-request` once the owner confirms), how it was resolved and when it was acted on (resolved_at), its last pick
@@ -115,11 +103,10 @@
115
103
  ${q.reask ? '<div class="small muted reask-line">re-ask requested</div>' : ''}
116
104
  </section>`;
117
105
  }
118
- // An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext;
119
- // fit(option id) supplies the measured grid and full-width heights.
106
+ // An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext.
120
107
  // A question waiting on the prototype the owner asked for is locked at the pick and note the request sent until its
121
108
  // round arrives (docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
122
- function questionCard(q, { n = 0, draft = {}, links = {}, fit = () => null } = {}) {
109
+ function questionCard(q, { n = 0, draft = {}, links = {} } = {}) {
123
110
  if (q.state === 'resolved') return resolvedCard(q, links);
124
111
  const locked = q.state === 'requested';
125
112
  const chosen = locked ? q.request.choice : choiceOf(q, draft);
@@ -127,9 +114,9 @@
127
114
  <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
128
115
  <h3>${linkText(q.question, links)}</h3>
129
116
  ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
130
- ${framed(q) ? '' : questionLink(q.url)}
131
- ${confirmLines(q)}
132
- ${inline(q) ? takes(q, chosen, links, fit, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
117
+ ${questionLink(q.confirm_of && isPrototype(q.url) ? withParam(q.url, 'variant', q.options[0].id) : q.url)}
118
+ ${confirmLine(q)}
119
+ ${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
133
120
  ${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, draft.note)}
134
121
  ${answerHistory(q, links)}
135
122
  </section>`;
@@ -142,7 +129,7 @@
142
129
  const byKey = new Map(cards.map((c) => [c.key, c]));
143
130
  return topics.map((t) => {
144
131
  const c = t.title === undefined && byKey.get(t.card);
145
- return c ? { ...t, title: `${c.ref}: ${c.title}`, summary: 'Questions about this work item.', ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
132
+ return c ? { ...t, title: `${c.ref}: ${c.title}`, ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
146
133
  });
147
134
  }
148
135
  // Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
@@ -179,5 +166,5 @@
179
166
  },
180
167
  };
181
168
 
182
- Object.assign(globalThis.BoardUI, { choiceLabel, choiceOf, option, answerHistory, questionCard, roundChip, takes, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
169
+ Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, option, answerHistory, questionCard, roundChip, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
183
170
  })();
@@ -69,7 +69,7 @@
69
69
  const sites = data?.sites || [];
70
70
  const site = (rank) => (s) => ({ href: siteHref(s), title: String(s.title ?? s.route), board: rank === 0, rank, joined: Date.parse(s.created) });
71
71
  const rows = [...sites.filter((s) => s.board).map(site(0)), ...sites.filter((s) => !s.board).map(site(1)),
72
- ...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 }))];
73
73
  rows.filter((r) => r.board).slice(0, 9).forEach((r, i) => { r.num = String(i + 1); });
74
74
  const when = (r) => (Number.isFinite(r.joined) ? r.joined : Infinity);
75
75
  const taken = new Set();
@@ -176,25 +176,28 @@
176
176
  }
177
177
 
178
178
  // The New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
179
- // /_hub/sidebar.json's npm update, null | {state: available | waiting | updating | failed, version, builders, error}
180
- // (hub/upgrade.mjs; docs/adr/2026-11-03-1521-the-hub-offers-a-newer-kickflip-from-npm.md). A reload, and an asked
181
- // 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.
182
183
  function updateRow(update, upgrade) {
183
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)')}`
184
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>`;
185
186
  if (update) return button('data-app-reload', update === 'reloading', 'New version', '', 'Reload');
186
187
  if (!upgrade) return '';
187
- if (upgrade.state === 'waiting') return button('data-app-upgrade', true, `Update waits for ${upgrade.builders} implementer${upgrade.builders === 1 ? '' : 's'}`, '', 'Upgrade');
188
- if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Update available', '', 'Upgrade');
189
- if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Upgrade');
190
- 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');
191
193
  }
192
194
 
193
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
194
196
  // subscription as sidebar.js finds it, 'on' | 'off' | 'blocked' | 'install' (iOS outside the Home Screen app), or
195
197
  // null while unknown or unsupported. switching: switch mode shows, each row with its key and the hint on the Boards label.
196
198
  // update: null | 'ready' | 'reloading'.
197
- // 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.
198
201
  function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
199
202
  const sites = data?.sites || [];
200
203
  const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
@@ -231,13 +234,16 @@
231
234
  const selected = here.startsWith('_hub') ? '/_hub' : `/${here.replace(/\/$/, '')}`;
232
235
  const hint = switching ? '<span class="ks-label-hint" role="status">– press a key to switch<i class="ks-bar"></i></span>' : '';
233
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>`;
234
- return header({ lead: 'logo', title: 'Kickflip', level: 2, controls: false, trail: close, logoSrc: '/_hub/kickflip.png',
235
- 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
236
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">`
237
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('')
238
244
  + `</div>${turnOn}</section>`
239
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>` : '')
240
- + `<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}`
241
247
  + `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
242
248
  }
243
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, mark, statusMark, 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) {
@@ -32,7 +32,7 @@
32
32
  // files it adds to the message, under composer id edit:<event id>.
33
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>`)}
34
34
  <div class="pending-files" data-files></div>
35
- <div class="row">${attachButton()}<span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
35
+ <div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
36
36
  // batched: an answer followed by another of the owner's answers in its state, which carries the batch's footer.
37
37
  const line = (m, links, edits, batched = false) => (m.kind === 'reply'
38
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>`
@@ -45,7 +45,7 @@
45
45
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
46
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>
47
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)}
48
- ${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>` : ''}
49
49
  ${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
50
50
  ${m.eventId && !batched ? msgFooter(m) : ''}
51
51
  </div>`);
@@ -52,19 +52,36 @@
52
52
  }
53
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>';
54
54
  const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
55
- // A composer's text box, which attach.js outlines with Drop to attach while files are dragged over the composer.
56
- 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
+ };
57
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>`;
58
72
  // An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
59
73
  const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
60
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"/>');
61
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"/>');
62
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"/>');
63
78
  // Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
64
79
  // question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
65
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>`;
66
81
  // The thread's fire, the eye's twin for a grill.
67
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>`;
68
85
  // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
69
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
70
87
  // same place: the request's eye, fire or quick build's lightning, answers' check, new questions' plain question mark, a builder's dot.
@@ -72,7 +89,7 @@
72
89
  const MARK_ICONS = {
73
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"/>'),
74
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"/>'),
75
- 'quick build: no grill, as you asked': mini('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>'),
92
+ 'quick build: no grill': mini('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>'),
76
93
  'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
77
94
  'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
78
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'),
@@ -299,14 +316,29 @@
299
316
  // until(board) holds. start(id, until) on the tap; answered(id, generatedAt) with the generatedAt of the POST's board;
300
317
  // drop(id) when the POST is refused. Boards made before the POST answered never end it, so a reload that races the POST
301
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;
302
323
  const pendings = new Map();
303
324
  const pending = {
304
- start(id, until) { pendings.set(id, { until, after: null }); },
305
- answered(id, after) { const p = pendings.get(id); if (p) p.after = after; },
306
- drop(id) { pendings.delete(id); },
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); },
307
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
+ },
308
340
  settle(board) {
309
- for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pendings.delete(id);
341
+ for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pending.drop(id);
310
342
  },
311
343
  };
312
344
 
@@ -340,6 +372,6 @@
340
372
  globalThis.BoardUI = {
341
373
  LINK_ARROW,
342
374
  esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, RESTARTING, restartNotice,
343
- attachments, attachButton, dropZone, pendingFiles, protoButton, grillButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
375
+ attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, boltButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
344
376
  };
345
377
  })();
@@ -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();