@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,7 +1,7 @@
1
1
  // A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
2
2
  BoardView.part((B) => {
3
3
  const { $, store, state, toast, quieted, sendDraft, links } = B;
4
- const { esc, setHTML, threadMessages, attachButton, dropZone, protoButton, grillButton, doneButton, waitOn, newQuestionsLine } = BoardUI;
4
+ const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, boltButton, doneButton, waitOn, newQuestionsLine } = BoardUI;
5
5
 
6
6
  const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more' };
7
7
  // A /prototype or /grill word in a message, the last one winning, and the message without it.
@@ -10,31 +10,49 @@ BoardView.part((B) => {
10
10
 
11
11
  const thread = (key) => `<div class="thread" data-thread="${esc(key)}"><div class="muted small">Loading thread…</div></div>`;
12
12
 
13
- // The board, not the thread, decides the Done button, so it follows each board load even when the thread is unchanged.
13
+ // docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md,
14
+ // docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md
15
+ // A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
16
+ // action: builder orange once the card is ready, quiet before. Any other card's box sends with Send, carrying no intent.
17
+ // The pair wraps onto its own line under the row.
18
+ const sendsOf = (item) => (item?.lane !== 'todo' ? 'one' : item.ready ? 'todo-ready' : 'todo');
19
+ const sends = (kind) => (kind === 'one'
20
+ ? `<span data-sends="one">${sendButton('Send', 0)}</span>`
21
+ : `<span class="send-actions" data-sends="${kind}">${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${sendButton('Reply', 0, ' data-intent="reply"')}</span>`);
22
+ // The board, not the thread, decides the Done button and the send buttons, so they follow each board load even when
23
+ // the thread is unchanged.
14
24
  function syncDone(el, key) {
15
- const send = el.querySelector('form button[type=submit]');
16
- if (!send) return;
17
- const html = doneButton(state.board?.items.find((i) => i.key === key));
25
+ const at = el.querySelector('form [data-sends]');
26
+ if (!at) return;
27
+ const item = state.board?.items.find((i) => i.key === key);
28
+ const kind = sendsOf(item);
29
+ const todo = item?.lane === 'todo';
30
+ if (at.dataset.sends !== kind) { at.insertAdjacentHTML('afterend', sends(kind)); at.remove(); }
31
+ const tools = el.querySelector('form .tools');
32
+ const bolt = tools.querySelector('[data-bolt]');
33
+ if (todo && !bolt) tools.insertAdjacentHTML('afterbegin', boltButton(key));
34
+ else if (!todo) bolt?.remove();
35
+ const html = doneButton(item);
18
36
  const current = el.querySelector('form [data-confirm="finish"]');
19
37
  if (current?.outerHTML === html) return;
20
38
  current?.remove();
21
- if (html) send.insertAdjacentHTML('beforebegin', html);
39
+ if (html) el.querySelector('form [data-sends]').insertAdjacentHTML('beforebegin', html);
22
40
  }
23
41
 
24
42
  // Each open card's ui-threads.js threadMessages view. Opening a card marks it seen (app-core.js markSeen) before its
25
- // thread draws, so its unread messages start from its seen mark as the page loaded or as the card last closed. A
26
- // board left and opened again in this page keeps them, so its open threads show what they showed.
43
+ // thread draws, so its unread messages start from its read mark from before it opened (B.seenBefore). A board left
44
+ // and opened again in this page keeps them, so its open threads show what they showed.
27
45
  const kept = B.place?.threads;
28
- const seenBefore = new Map(kept?.seenBefore || Object.entries(B.seen));
29
46
  const moreTaps = new Map(kept?.moreTaps);
30
47
  const firstDrawn = new Map(kept?.firstDrawn);
31
- B.keep(() => ({ threads: { seenBefore: [...seenBefore], moreTaps: [...moreTaps], firstDrawn: [...firstDrawn] } }));
48
+ B.keep(() => ({ threads: { moreTaps: [...moreTaps], firstDrawn: [...firstDrawn] } }));
49
+ const seenAt = (key) => (B.seenBefore.has(key) ? B.seenBefore.get(key) : B.seen[key] || null);
32
50
  const newestTs = (msgs) => msgs.reduce((top, m) => [String(m.ts), m.reply?.at ? String(m.reply.at) : ''].reduce((a, b) => (b > a ? b : a), top), '');
33
- const viewOf = (key) => ({ seenAt: seenBefore.get(key) || null, more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
51
+ const viewOf = (key) => ({ seenAt: seenAt(key), more: moreTaps.get(key) || 0, upto: firstDrawn.get(key) || null });
34
52
  B.on(B.root, 'toggle', (ev) => {
35
53
  const el = ev.target;
36
54
  if (!el.matches?.('details.item') || el.open) return;
37
- seenBefore.set(el.dataset.key, B.seen[el.dataset.key]);
55
+ B.seenBefore.delete(el.dataset.key);
38
56
  moreTaps.delete(el.dataset.key);
39
57
  firstDrawn.delete(el.dataset.key);
40
58
  }, true);
@@ -53,7 +71,7 @@ BoardView.part((B) => {
53
71
  const edits = editsIn(key);
54
72
  if (!firstDrawn.has(key) && msgs.length) firstDrawn.set(key, newestTs(msgs));
55
73
  const view = viewOf(key);
56
- const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenBefore.get(key));
74
+ const asked = newQuestionsLine(state.board?.items.find((i) => i.key === key)?.questions, seenAt(key));
57
75
  const lines = asked.length ? [...msgs, ...asked].sort((a, b) => String(a.ts).localeCompare(String(b.ts))) : msgs;
58
76
  const sig = JSON.stringify([lines, [...edits.keys()], view]);
59
77
  if (el.dataset.sig === sig) return;
@@ -62,11 +80,9 @@ BoardView.part((B) => {
62
80
  if (!el.querySelector('.msgs')) {
63
81
  const draft = store.get('draft:' + key, '');
64
82
  el.innerHTML = `<div class="msgs"></div><form data-reply="${esc(key)}" data-composer="item:${esc(key)}">${dropZone(`<textarea rows="2" placeholder="${esc(PLACEHOLDER)}">${esc(draft)}</textarea>`)}
65
- <div class="flag-hint" data-hint aria-live="polite">${esc(HINT)}</div>
66
83
  <div class="pending-files" data-files></div>
67
- <div class="row"><span class="tools">${attachButton()}${protoButton(key)}${grillButton(key)}</span><button class="chip quick" type="button" data-quick="What's left?">What's left?</button><button class="primary" type="submit">Send</button></div></form>`;
84
+ <div class="row"><span class="tools">${protoButton(key)}${grillButton(key)}</span><button class="chip quick" type="button" data-quick="What's left?">What's left?</button>${sends('one')}</div></form>`;
68
85
  BoardAttach.paint('item:' + key, B.site.root);
69
- syncFlag(el.querySelector('form'));
70
86
  }
71
87
  syncDone(el, key);
72
88
  B.steady(() => setHTML(el.querySelector('.msgs'), threadMessages(lines, links(key), edits, view)));
@@ -74,23 +90,10 @@ BoardView.part((B) => {
74
90
  B.flashChanged?.(el);
75
91
  }
76
92
 
77
- // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
78
- // The composer's eye asks for a prototype and its fire for a grill: each is a toggle, one at a time, that Send carries
79
- // with whatever is typed, as /prototype or /grill in the text does. Nothing typed sends the bare request.
93
+ // docs/adr/2026-11-05-0430-the-eye-and-fire-send-their-request-at-once.md
94
+ // The composer's eye asks for a prototype and its fire for a grill: a tap sends the request at once, with whatever is
95
+ // typed, as /prototype or /grill in the text does on Reply. Nothing typed sends the bare request.
80
96
  const PLACEHOLDER = 'Reply in this thread… (/prototype or /grill asks for one)';
81
- const HINT = 'Tap the eye or the fire to request a prototype or a grill';
82
- const flagOf = (f) => f.dataset.flag || slashOf(f.querySelector('textarea').value).flag;
83
- function syncFlag(f) {
84
- const chosen = f.dataset.flag || null;
85
- f.querySelector('button[data-proto]')?.setAttribute('aria-pressed', String(chosen === 'prototype'));
86
- f.querySelector('button[data-grill]')?.setAttribute('aria-pressed', String(chosen === 'grill'));
87
- const hint = f.querySelector('[data-hint]');
88
- if (!hint) return;
89
- const flag = flagOf(f);
90
- const typed = slashOf(f.querySelector('textarea').value).text || BoardAttach.files(f.dataset.composer, B.site.root).length;
91
- hint.classList.toggle('on', Boolean(flag));
92
- hint.textContent = !flag ? HINT : typed ? `posts your message, with ${flag} requested in its footer` : `posts one gray line: ${flag} requested`;
93
- }
94
97
 
95
98
  // A load while one for the same thread runs shares it, so a card the page inserts open isn't fetched twice; force
96
99
  // starts a new one, after a send. maxAge skips a thread that loaded that recently. from is a load already started
@@ -122,7 +125,6 @@ BoardView.part((B) => {
122
125
  B.on(B.root, 'input', (ev) => {
123
126
  const f = ev.target.closest('form[data-reply]');
124
127
  if (!f || ev.target.tagName !== 'TEXTAREA') return;
125
- syncFlag(f);
126
128
  if (quieted('draft:' + f.dataset.reply, ev.target)) return;
127
129
  store.set('draft:' + f.dataset.reply, ev.target.value);
128
130
  });
@@ -130,44 +132,53 @@ BoardView.part((B) => {
130
132
  const b = ev.target.closest('form[data-reply] button[data-quick]');
131
133
  if (!b) return;
132
134
  const key = b.closest('form').dataset.reply;
133
- try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick }))); loadThread(key, { force: true }); }
134
- catch { toast('Could not send'); }
135
+ // Spins until the thread shows the message (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
136
+ try { await waitOn(b, BoardUI.pending.within(B.sentHere(key, () => B.send('item-message', { key, text: b.dataset.quick })).then(() => loadThread(key, { force: true })), B.stillWaiting)); }
137
+ catch (e) { B.failed(e); }
135
138
  });
136
- B.on(B.root, 'click', (ev) => {
137
- const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
139
+ // docs/adr/2026-11-05-0010-a-lightning-button-in-a-todo-threads-footer-starts-a-quick-build.md
140
+ B.on(B.root, 'click', async (ev) => {
141
+ const b = ev.target.closest('form[data-reply] button[data-bolt]');
138
142
  if (!b) return;
139
- const f = b.closest('form');
140
- const kind = b.hasAttribute('data-proto') ? 'prototype' : 'grill';
141
- if (f.dataset.flag === kind) delete f.dataset.flag; else f.dataset.flag = kind;
142
- syncFlag(f);
143
+ const key = b.dataset.bolt;
144
+ try { await waitOn(b, B.sentHere(key, () => B.send('item-message', { key, text: 'Quick build', intent: 'build' }))); loadThread(key, { force: true }); }
145
+ catch { toast('Could not send'); }
143
146
  });
144
- B.on(B.root, 'submit', async (ev) => {
145
- const f = ev.target.closest('form[data-reply]');
146
- if (!f) return;
147
- ev.preventDefault();
147
+ // Sends the composer's draft as one message, or as a request when flag names one; button spins until the thread has it.
148
+ async function post(f, flag, button) {
148
149
  const ta = f.querySelector('textarea');
149
150
  ta.blur();
150
- const flag = flagOf(f);
151
151
  const text = flag ? slashOf(ta.value).text : ta.value.trim();
152
152
  const id = f.dataset.composer;
153
153
  const files = BoardAttach.files(id, B.site.root);
154
- if ((!flag && !text && !files.length) || BoardAttach.busy(id, B.site.root)) return;
154
+ // An empty box still sends Implement, the card's build action.
155
+ const intent = flag ? null : button.dataset.intent || null;
156
+ if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
155
157
  const key = f.dataset.reply;
156
- const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}) };
157
- try {
158
- await waitOn(f.querySelector('button[type=submit]'), sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
159
- () => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data))));
158
+ const data = { key, ...(text || !flag ? { text } : {}), ...(files.length ? { files } : {}), ...(intent ? { intent } : {}) };
159
+ // Spins until the thread shows the message (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
160
+ const sending = (async () => {
161
+ await sendDraft('draft:' + key, ta, () => $(`form[data-reply="${CSS.escape(key)}"] textarea`),
162
+ () => B.sentHere(key, () => B.send(REQUEST_EVENT[flag] || 'item-message', data)));
160
163
  BoardAttach.clear(id, B.site.root);
161
- delete f.dataset.flag;
162
- syncFlag(f);
163
- loadThread(f.dataset.reply, { force: true });
164
- } catch { B.notice('Could not send. Is the host awake?'); }
164
+ await loadThread(key, { force: true });
165
+ })();
166
+ try { await waitOn(button, BoardUI.pending.within(sending, B.stillWaiting)); } catch (e) { B.failed(e); }
167
+ }
168
+ B.on(B.root, 'click', (ev) => {
169
+ const b = ev.target.closest('form[data-reply] button[data-proto], form[data-reply] button[data-grill]');
170
+ if (b) post(b.closest('form'), b.hasAttribute('data-proto') ? 'prototype' : 'grill', b);
171
+ });
172
+ B.on(B.root, 'submit', (ev) => {
173
+ const f = ev.target.closest('form[data-reply]');
174
+ if (!f) return;
175
+ ev.preventDefault();
176
+ post(f, slashOf(f.querySelector('textarea').value).flag, ev.submitter);
165
177
  });
166
- // Send waits while a composer's files upload, the ask's included (attach.js attach-change).
178
+ // A composer's send buttons wait while its files upload, the ask's included (attach.js attach-change).
167
179
  B.on(B.root, 'attach-change', (ev) => {
168
- const b = ev.target.querySelector('button[type=submit]');
169
- if (b) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
170
- if (ev.target.matches?.('form[data-reply]')) syncFlag(ev.target);
180
+ for (const b of ev.target.querySelectorAll('button[type=submit]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
181
+ if (ev.target.matches?.('form[data-reply]')) for (const b of ev.target.querySelectorAll('button[data-proto], button[data-grill]')) b.disabled = BoardAttach.busy(ev.detail.id, B.site.root);
171
182
  });
172
183
 
173
184
  Object.assign(B, {
@@ -27,7 +27,9 @@ window.BoardView = (function () {
27
27
  try {
28
28
  const old = [];
29
29
  for (let i = 0; i < localStorage.length; i++) old.push(localStorage.key(i));
30
- for (const k of old.filter((k) => /^(openItems|wave|lanes|seenThreads|dismissedAnswers|draft:ask|foldOpen:.+)$/.test(k))) {
30
+ // Read marks moved to the board (docs/adr/2026-11-05-0910-read-marks-live-on-the-board.md), so this browser's old copies go.
31
+ for (const k of old.filter((k) => /^(site:[\w-]+:)?(seenThreads|dismissedAnswers)$/.test(k))) localStorage.removeItem(k);
32
+ for (const k of old.filter((k) => /^(openItems|wave|lanes|draft:ask|foldOpen:.+)$/.test(k))) {
31
33
  if (localStorage.getItem(keyOf(k)) != null) continue;
32
34
  localStorage.setItem(keyOf(k), localStorage.getItem(k));
33
35
  localStorage.removeItem(k);
@@ -131,7 +133,7 @@ window.BoardView = (function () {
131
133
  edits: new Map(),
132
134
  githubPending: null,
133
135
  // Main's refresh icon (ui-cards.js mainPull): a tap waiting on the server, and the failed pull's reason open on touch.
134
- mainPull: { sending: false, open: false },
136
+ mainPull: { open: false },
135
137
  // The Done cards Show more paged in from the archive past the board's own (app-lanes.js), and how many older ones
136
138
  // the archive holds past them, null before the first page.
137
139
  archived: { cards: [], left: null },
@@ -187,7 +189,8 @@ window.BoardView = (function () {
187
189
  BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main, state.mainPull));
188
190
  B.renderMainLog(d.main, load);
189
191
  for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
190
- for (const it of d.items) if (!it.isNew) markSeen(it, !state.openItems.has(it.key));
192
+ B.takeMarks(d.items);
193
+ for (const it of d.items) if (state.openItems.has(it.key)) markSeen(it);
191
194
  // An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
192
195
  const strips = B.captureStrips();
193
196
  setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
@@ -229,6 +232,15 @@ window.BoardView = (function () {
229
232
  ev.preventDefault();
230
233
  readHash(); render(); scrollToFocus();
231
234
  });
235
+ // Opens and scrolls to the card key or the band slug on the board → whether the board shows it.
236
+ function focusOnBoard({ key, slug }) {
237
+ const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
238
+ if (!band && !state.board?.items.some((i) => i.key === key)) return false;
239
+ if (key) { location.hash = `#item=${encodeURIComponent(key)}`; readHash(); } else B.openBand({ slug });
240
+ render();
241
+ if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
242
+ return true;
243
+ }
232
244
  // A card missing from the last visit's board waits for the fresh one, which is final.
233
245
  function scrollToFocus(final = true) {
234
246
  if (!focusKey) return;
@@ -336,10 +348,16 @@ window.BoardView = (function () {
336
348
  const out = await B.site.send(kind, { ...data, ...(state.view === 'fresh' ? { since: state.board.version } : {}) });
337
349
  if (!B.alive()) return out;
338
350
  answered?.();
339
- land(out.board, B.boardLoad = (B.boardLoad || 0) + 1);
351
+ if (!land(out.board, B.boardLoad = (B.boardLoad || 0) + 1)) await boardAfter(out.board.generatedAt);
340
352
  return out;
341
353
  }
342
354
 
355
+ // A board that reload raced the POST left the tap's own board unlanded (land): the tap is not drawn until a board
356
+ // made after it is, so send waits for one, and a tap's spinner with it (docs/adr/2026-11-05-0200-a-tap-spins-until-the-board-shows-its-change.md).
357
+ async function boardAfter(generatedAt) {
358
+ for (let tries = 0; tries < 3 && B.alive() && !(state.view === 'fresh' && state.board.generatedAt >= generatedAt); tries++) await (reloading || loadBoard({ via: 'signal' }));
359
+ }
360
+
343
361
  // The server signals each change to what the board reads, through the shell, and the board reloads at once; the
344
362
  // 30 s poll stays as the fallback and the offline check (docs/adr/2026-10-29-1639-the-board-reloads-on-a-change-signal.md).
345
363
  let reloading = null;
@@ -356,7 +374,14 @@ window.BoardView = (function () {
356
374
 
357
375
  // The toast for a tap the server refused, with its reason, or that never reached it.
358
376
  const failed = (e) => B.toast(e?.reason || 'That didn’t go through. Is the host awake?');
359
- Object.assign(B, { render, loadBoard, send, failed, reload: signalled });
377
+ // A tap's change no board has shown in BoardUI's PENDING_LATE_MS (ui.js pending): the board reloads once and the button rests.
378
+ function stillWaiting() {
379
+ if (!B.alive()) return;
380
+ B.toast('Still waiting on the board');
381
+ signalled();
382
+ render();
383
+ }
384
+ Object.assign(B, { render, focusOnBoard, loadBoard, send, failed, stillWaiting, reload: signalled });
360
385
  const live = paintSnapshot();
361
386
  B.headerWired();
362
387
  if (live) B.linkUp();
@@ -371,15 +396,20 @@ window.BoardView = (function () {
371
396
  B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
372
397
  }
373
398
 
374
- function markup({ title = '' } = {}) {
399
+ // shared: the shell's data.shared for someone the board is shared with, view only, whose board opens with a strip saying so.
400
+ function markup({ title = '', shared = null } = {}) {
401
+ const strip = shared ? `<p class="view-only-strip"><b>View only.</b> Shared with you${shared.by ? ` by ${BoardUI.esc(shared.by)}` : ''}. You can look and open Visit links.</p>\n` : '';
375
402
  return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true, moon: true })}
403
+ <div class="split">
376
404
  <main>
377
- <section id="setup" class="setup" aria-live="polite" hidden></section>
405
+ ${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
378
406
  <section id="answers" class="answer-banner" hidden></section>
379
- <section id="waiting" class="waiting" hidden></section>
407
+ <section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
380
408
  <div id="tryMain"></div>
381
409
  <div id="lanes"></div>
382
410
  </main>
411
+ <aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
412
+ </div>
383
413
  ${BoardNewTask.markup()}
384
414
  <dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
385
415
  <div class="sheet-body">
@@ -1,6 +1,6 @@
1
1
  // docs/adr/2026-11-03-1722-every-text-box-attaches-through-attach-js.md
2
- // Files attached to a composer: any element with data-composer="<id>" holding BoardUI.attachButton(), its text box in
3
- // BoardUI.dropZone(), and a [data-files] list. Picking files, dropping them on the composer, or pasting them into it
2
+ // Files attached to a composer: any element with data-composer="<id>" holding its text box in BoardUI.dropZone(), which
3
+ // puts BoardUI.attachButton() inside the box, and a [data-files] list. Picking files, dropping them on the composer, or pasting them into it
4
4
  // uploads them at once to the api/upload of the site named by the nearest
5
5
  // [data-site] around the composer (a board view's root, app.js), else of the page's own site (ClaudeSite). Pending
6
6
  // files live in memory by site and composer id, so a page that rebuilds its markup calls paintAll() to show them
@@ -2,21 +2,18 @@
2
2
  // Load before any page script that asks (docs/adr/2026-11-03-0259-confirmations-are-the-browsers-own.md).
3
3
  (function () {
4
4
  // linear: Hub's /_hub/api/linear answer, else loadLinear() fetches it once Linear is the source. Linear is picked only
5
- // while Hub's key works; tapping it without one opens Hub's key modal over the page, or with keyState the key field
6
- // inline (linear-key.js,
5
+ // while Hub's key works; tapping it without one opens Hub's key modal over the page (linear-key.js,
7
6
  // docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A New board's "New project…" reads as
8
7
  // new_project, which the hub creates on Start; Board Settings creates it with createProject. change(value, valid,
9
8
  // {loaded, typing}) follows each change: loaded when only Linear's lists arrived, typing while a text field is being typed in.
10
9
  // GitHub issues needs repo(), the board's GitHub owner/name: without one, as on a board whose repo is another git
11
10
  // remote, it can't be picked and says why (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
12
11
  // With keyState(), Hub's /_hub/api/settings `linear`, as Board Settings passes it, the source row reads its live state
13
- // as a status word, and Linear tapped without a working key opens Hub's key field in keySlot, an element outside any
14
- // form, since the key field is a form of its own; keyBusy(on) follows each save of the key
15
- // (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
16
- function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState, keySlot, keyBusy = () => {} } = {}) {
12
+ // as a status word (docs/adr/2026-11-04-0227-every-new-card-gets-a-ticket-in-the-boards-current-source.md).
13
+ function ticketsControl(root, { value = { source: 'internal', prefix: 'INT' }, repo = () => '', title = () => '', linear = null, loadLinear, isNew = false, change = () => {}, createProject, keyState } = {}) {
17
14
  const { esc } = BoardUI;
18
15
  const draft = { prefix: 'INT', ...value };
19
- let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null, inline = null;
16
+ let creating = false, createError = '', destroyed = false, asking = false, noGithub = false, key = null;
20
17
  const works = () => Boolean(linear?.set && !linear.refused);
21
18
  const keyWorks = () => Boolean(key?.set && !key.refused);
22
19
  const keyWord = () => (keyWorks() ? 'linear key saved' : key.refused ? 'Linear refused the key' : 'no Linear key');
@@ -26,31 +23,6 @@
26
23
  const on = draft.source === 'linear' && keyWorks();
27
24
  return `<div class="tk-status${on ? ' on' : ''}" data-tk-status role="status"><span class="dot" aria-hidden="true"></span><b>${esc(statusWord())}</b></div>`;
28
25
  }
29
- // Hub's key field in keySlot. Once a key works, Linear is picked.
30
- function openInline() {
31
- if (!inline) {
32
- keySlot.innerHTML = '<p class="tk-ask">Linear needs a key. Paste one here.</p><div class="hub-view"><main></main></div>';
33
- inline = LinearKey.mount(keySlot.querySelector('main'), { linear: key, busy: keyBusy,
34
- saved: (out) => { key = out.linear; render(); },
35
- worked: async () => {
36
- closeInline();
37
- draft.source = 'linear';
38
- await load();
39
- if (destroyed) return;
40
- pickDefaults();
41
- render(); root.querySelector('[data-tk-source="linear"]').focus(); change(read(), valid());
42
- } });
43
- keySlot.hidden = false;
44
- }
45
- keySlot.querySelector('#linearKey')?.focus({ preventScroll: true });
46
- }
47
- function closeInline() {
48
- if (!inline) return;
49
- inline.destroy();
50
- inline = null;
51
- keySlot.hidden = true;
52
- keySlot.innerHTML = '';
53
- }
54
26
  async function load() {
55
27
  linear = { loading: true };
56
28
  render();
@@ -114,13 +86,12 @@
114
86
  if (!works()) {
115
87
  draft.source = before;
116
88
  render();
117
- await keyed;
118
- if (destroyed) return;
119
- if (key) { openInline(); return; }
120
89
  asking = true;
121
90
  const added = await LinearKey.open();
122
91
  asking = false;
123
92
  if (destroyed || !added) return;
93
+ if (keyState) key = await keyState().catch(() => key);
94
+ if (destroyed) return;
124
95
  draft.source = 'linear';
125
96
  await load();
126
97
  if (destroyed) return;
@@ -149,7 +120,6 @@
149
120
  noGithub = button.dataset.tkSource === 'github' && !repo();
150
121
  if (noGithub) { render(); root.querySelector('[data-tk-source="github"]').focus(); return; }
151
122
  if (button.dataset.tkSource === 'linear') { pickLinear(); return; }
152
- closeInline();
153
123
  draft.source = button.dataset.tkSource;
154
124
  render(); root.querySelector(`[data-tk-source="${draft.source}"]`).focus(); change(read(), valid());
155
125
  };
@@ -168,13 +138,13 @@
168
138
  root.addEventListener('change', input);
169
139
  render();
170
140
  if (draft.source === 'linear' && !linear) load().then(() => { if (destroyed) return; pickDefaults(); render(); change(read(), valid(), { loaded: true }); });
171
- // Without an answer the row has no status word, and Linear without a key opens Hub's key modal as on New board.
172
- const keyed = keyState ? keyState().then((state) => { if (destroyed) return; key = state; render(); }, () => {}) : null;
141
+ // Without an answer the row has no status word.
142
+ if (keyState) keyState().then((state) => { if (destroyed) return; key = state; render(); }, () => {});
173
143
  return { read, valid, render, nameChanged() {
174
144
  const field = root.querySelector('[data-tk="newName"]');
175
145
  if (field && draft.newName === undefined) field.value = title();
176
146
  },
177
- destroy() { destroyed = true; closeInline(); root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
147
+ destroy() { destroyed = true; root.removeEventListener('click', click); root.removeEventListener('input', input); root.removeEventListener('change', input); } };
178
148
  }
179
149
  function fitSheet(sheet) {
180
150
  const vv = window.visualViewport;
@@ -113,6 +113,8 @@
113
113
  const KEPT = 'kickflip.push.endpoint';
114
114
  const kept = () => { try { return localStorage.getItem(KEPT); } catch { return null; } };
115
115
  const keep = (endpoint) => { try { if (endpoint) localStorage.setItem(KEPT, endpoint); else localStorage.removeItem(KEPT); } catch { /* storage blocked here */ } };
116
+ // The id the hub gave that subscription, so the Integrations list marks this device's line (view-hub.js).
117
+ const subscribe = (sub) => post('/_hub/push/subscribe', sub.toJSON()).then(({ id }) => { try { localStorage.setItem('kickflip.push.id', id); } catch { /* storage blocked here */ } });
116
118
  const settle = (endpoint = null) => {
117
119
  const old = kept();
118
120
  if (old && old !== endpoint) post('/_hub/push/unsubscribe', { endpoint: old }).catch(() => {});
@@ -133,7 +135,7 @@
133
135
  const sub = await reg?.pushManager.getSubscription();
134
136
  settle(sub?.toJSON().endpoint);
135
137
  if (sub) {
136
- post('/_hub/push/subscribe', sub.toJSON()).catch(() => {});
138
+ subscribe(sub).catch(() => {});
137
139
  show({ title: 'This device', action: { label: 'Test', act: 'test', quiet: true } }, true);
138
140
  } else {
139
141
  show({ title: 'This device', sub: 'Off · Claude sessions tell you here when something needs you.', action: { label: 'Turn on', act: 'on' } });
@@ -148,7 +150,7 @@
148
150
  await navigator.serviceWorker.ready;
149
151
  const { key } = await (await fetch('/_hub/push/key')).json();
150
152
  const sub = (await reg.pushManager.getSubscription()) || await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) });
151
- await post('/_hub/push/subscribe', sub.toJSON());
153
+ await subscribe(sub);
152
154
  await render();
153
155
  changed();
154
156
  } catch (err) { show({ title: 'Could not turn on notifications', sub: String(err?.message || err) }); }
@@ -1,44 +1,55 @@
1
- // Hub's Linear key slot. Hub settings › Linear mounts it, and the Tickets control (dialogs.js) opens it over Board
2
- // settings or New board when Linear is tapped without a working key
3
- // (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A key saves on paste, change or Enter through
4
- // POST /_hub/settings/linear, which checks it with Linear; linear is hub/views.mjs settingsView's `linear`.
1
+ // Hub's token slots: Linear's key and Slack's user token. Hub settings › Linear and › Slack mount them, and the Tickets
2
+ // control (dialogs.js) opens Linear's over Board settings or New board when Linear is tapped without a working key
3
+ // (docs/adr/2026-11-02-1712-a-board-picks-linear-only-while-hubs-key-works.md). A token saves on paste, change or Enter
4
+ // through POST /_hub/settings/<kind>, which checks it with the service; state is hub/views.mjs settingsView's `<kind>`.
5
5
  (function () {
6
6
  const FAILED = 'Could not save. Is the host awake?';
7
7
  const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
8
- const works = (linear) => Boolean(linear?.set && !linear.refused);
8
+ const works = (state) => Boolean(state?.set && !state.refused);
9
+ const KINDS = {
10
+ linear: { field: 'key', input: 'linearKey', checking: 'Checking the key with Linear…', first: 'Paste a Linear API key', replace: 'Paste a new key to replace it', label: 'Linear API key',
11
+ note: 'Stays on the host. Boards use it to list teams and projects, create issues and move the ones that are yours. A board can pick Linear for its tickets only while this key works.',
12
+ confirm: 'Boards on Linear fall back to internal tickets, and their cards keep the Linear tickets they have. Remove the Linear key?' },
13
+ slack: { field: 'token', input: 'slackToken', checking: 'Checking the token with Slack…', first: 'Paste a Slack user token', replace: 'Paste a new token to replace it', label: 'Slack user token',
14
+ note: 'Stays on the host. The hub sends your reviewers their PR links, git_merged reactions and weekday digests as you, in your DMs with them.',
15
+ confirm: 'Reviewer links, reactions and digests stop until a token is back. Remove the Slack token?' },
16
+ };
9
17
 
10
- function slot(linear, { checking, flash }) {
18
+ function slot(kind, state, { checking, flash }) {
11
19
  const { esc, row, tile } = HubUI;
12
- const sub = checking ? `<span class="lk-checking">${SPIN}Checking the key with Linear…</span>`
13
- : `${linear.down ? `<span class="st down">${esc(linear.down)}</span> · ` : ''}${linear.keys
14
- ? esc(linear.sub).replace(esc(linear.keys.label), `<a href="${esc(linear.keys.url)}" target="_blank" rel="noopener">${esc(linear.keys.label)}</a>`)
15
- : esc(linear.sub)}`;
16
- const remove = linear.set && !checking ? '<button type="button" class="btn quiet danger" data-linear-remove>Remove</button>' : '';
17
- const placeholder = checking ? 'Checking…' : linear.set ? 'Paste a new key to replace it' : 'Paste a Linear API key';
18
- return `${HubUI.flash(flash)}<div class="group">${row({ icon: tile('key', linear.refused ? 'var(--color-danger)' : 'var(--color-quiet)'), title: esc(linear.title), sub, wrap: !works(linear), trail: remove })}`
19
- + `<form class="block lk-form"><input id="linearKey" name="key" type="password" required${checking ? ' disabled' : ''} placeholder="${placeholder}" aria-label="Linear API key" autocomplete="off" autocapitalize="off" spellcheck="false"></form></div>`
20
- + '<p class="note">Stays on the host. Boards use it to list teams and projects, create issues and move the ones that are yours. A board can pick Linear for its tickets only while this key works.</p>';
20
+ const k = KINDS[kind];
21
+ const sub = checking ? `<span class="lk-checking">${SPIN}${k.checking}</span>`
22
+ : `${state.down ? `<span class="st down">${esc(state.down)}</span> · ` : ''}${state.keys
23
+ ? esc(state.sub).replace(esc(state.keys.label), `<a href="${esc(state.keys.url)}" target="_blank" rel="noopener">${esc(state.keys.label)}</a>`)
24
+ : esc(state.sub)}`;
25
+ const remove = state.set && !checking ? `<button type="button" class="btn quiet danger" data-${kind}-remove>Remove</button>` : '';
26
+ const placeholder = checking ? 'Checking…' : state.set ? k.replace : k.first;
27
+ return `${HubUI.flash(flash)}<div class="group">${row({ icon: tile('key', state.refused ? 'var(--color-danger)' : 'var(--color-quiet)'), title: esc(state.title), sub, wrap: !works(state), trail: remove })}`
28
+ + `<form class="block lk-form"><input id="${k.input}" name="${k.field}" type="password" required${checking ? ' disabled' : ''} placeholder="${placeholder}" aria-label="${k.label}" autocomplete="off" autocapitalize="off" spellcheck="false"></form></div>`
29
+ + `<p class="note">${k.note}</p>`;
21
30
  }
22
31
 
23
- // Draws the slot into root and saves from it. busy(true) starts each save and busy(false) ends it; saved(out) gets
24
- // each answer, /_hub/api/settings's JSON, before the slot repaints; worked() follows a saved key Linear accepted.
25
- // → {busy(), settled(): a promise that resolves once no save is out, linear(), set(linear) unless busy, destroy()}
26
- function mount(root, { linear, saved = () => {}, worked = () => {}, busy = () => {} }) {
27
- let current = linear, flash = null, checking = false, pending = null, destroyed = false;
32
+ // Draws kind's slot into root and saves from it. busy(true) starts each save and busy(false) ends it; saved(out) gets
33
+ // each answer, /_hub/api/settings's JSON, before the slot repaints; worked() follows a saved token the service accepted.
34
+ // → {busy(), settled(): a promise that resolves once no save is out, state(), set(state) unless busy, destroy()}
35
+ function mount(root, { kind = 'linear', state, saved = () => {}, worked = () => {}, busy = () => {} }) {
36
+ const k = KINDS[kind];
37
+ const input = `#${k.input}`;
38
+ let current = state, flash = null, checking = false, pending = null, destroyed = false;
28
39
  const paint = () => {
29
- const typed = root.querySelector('#linearKey')?.value || '';
30
- root.innerHTML = slot(current, { checking, flash });
31
- if (!checking) root.querySelector('#linearKey').value = typed;
40
+ const typed = root.querySelector(input)?.value || '';
41
+ root.innerHTML = slot(kind, current, { checking, flash });
42
+ if (!checking) root.querySelector(input).value = typed;
32
43
  };
33
44
  function post(body) {
34
- checking = body.has('key');
45
+ checking = body.has(k.field);
35
46
  flash = null;
36
47
  pending = (async () => {
37
48
  try {
38
- const r = await fetch('/_hub/settings/linear', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
49
+ const r = await fetch(`/_hub/settings/${kind}`, { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/x-www-form-urlencoded' }, body });
39
50
  const out = await r.json().catch(() => null);
40
51
  if (destroyed) return;
41
- if (r.ok && out) { current = out.linear; saved(out); } else flash = { text: out?.error || FAILED, error: true };
52
+ if (r.ok && out) { current = out[kind]; saved(out); } else flash = { text: out?.error || FAILED, error: true };
42
53
  } catch { flash = { text: FAILED, error: true }; }
43
54
  const key = checking;
44
55
  checking = false;
@@ -52,23 +63,23 @@
52
63
  paint();
53
64
  return pending;
54
65
  }
55
- const save = (value) => { if (!pending && value.trim()) post(new URLSearchParams({ key: value.trim() })); };
66
+ const save = (value) => { if (!pending && value.trim()) post(new URLSearchParams({ [k.field]: value.trim() })); };
56
67
  const onPaste = (e) => {
57
68
  const text = e.clipboardData?.getData('text');
58
- if (!e.target.matches?.('#linearKey') || !text) return;
69
+ if (!e.target.matches?.(input) || !text) return;
59
70
  e.preventDefault();
60
71
  e.target.value = text.trim();
61
72
  save(e.target.value);
62
73
  };
63
- const onChange = (e) => { if (e.target.matches?.('#linearKey')) save(e.target.value); };
74
+ const onChange = (e) => { if (e.target.matches?.(input)) save(e.target.value); };
64
75
  const onSubmit = (e) => {
65
76
  if (!e.target.matches?.('.lk-form')) return;
66
77
  e.preventDefault();
67
- save(e.target.querySelector('#linearKey').value);
78
+ save(e.target.querySelector(input).value);
68
79
  };
69
80
  const onClick = async (e) => {
70
- if (!e.target.closest?.('[data-linear-remove]') || pending) return;
71
- if (await BoardDialogs.confirm('Boards on Linear fall back to internal tickets, and their cards keep the Linear tickets they have. Remove the Linear key?') && !destroyed) post(new URLSearchParams({ remove: '1' }));
81
+ if (!e.target.closest?.(`[data-${kind}-remove]`) || pending) return;
82
+ if (await BoardDialogs.confirm(k.confirm) && !destroyed) post(new URLSearchParams({ remove: '1' }));
72
83
  };
73
84
  const listeners = [['paste', onPaste], ['change', onChange], ['submit', onSubmit], ['click', onClick]];
74
85
  for (const [type, fn] of listeners) root.addEventListener(type, fn);
@@ -76,7 +87,7 @@
76
87
  return {
77
88
  busy: () => Boolean(pending),
78
89
  settled: () => pending || Promise.resolve(),
79
- linear: () => current,
90
+ state: () => current,
80
91
  set(next) { if (!pending) { current = next; paint(); } },
81
92
  destroy() { destroyed = true; for (const [type, fn] of listeners) root.removeEventListener(type, fn); },
82
93
  };
@@ -115,7 +126,7 @@
115
126
  dialog.close();
116
127
  wrap.remove();
117
128
  if (previous?.isConnected) previous.focus({ preventScroll: true });
118
- resolve(works(key?.linear()));
129
+ resolve(works(key?.state()));
119
130
  }
120
131
  function close() {
121
132
  if (!key?.busy()) { finish(); return; }
@@ -134,7 +145,7 @@
134
145
  wrap.querySelector('#linearKeyTitle').focus({ preventScroll: true });
135
146
  fetch('/_hub/api/settings', { cache: 'no-store', headers: { accept: 'application/json' } }).then((r) => (r.ok ? r.json() : Promise.reject())).then((out) => {
136
147
  if (finished) return;
137
- key = mount(content, { linear: out.linear, worked: finish, busy: (on) => doneBusy(wrap.querySelectorAll('[data-lk-done]'), on) });
148
+ key = mount(content, { state: out.linear, worked: finish, busy: (on) => doneBusy(wrap.querySelectorAll('[data-lk-done]'), on) });
138
149
  }).catch(() => { if (!finished) content.innerHTML = '<p class="note">Offline. Is the host awake?</p>'; });
139
150
  return result;
140
151
  }