@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
@@ -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);
@@ -86,7 +88,26 @@ window.BoardView = (function () {
86
88
  }
87
89
  toasts.toast(text);
88
90
  };
89
- const B = { root, site, route, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
91
+ const store = storeFor(route);
92
+ // iOS commits a pending predictive-text word after Send clears the box, which would refill it and resave the draft.
93
+ // So Send blurs first, and input is dropped while the send runs and for a moment after it succeeds.
94
+ const quiet = new Map();
95
+ function quieted(draftKey, ta) {
96
+ const until = quiet.get(draftKey) || 0;
97
+ if (until <= Date.now()) return false;
98
+ if (until !== Infinity) ta.value = '';
99
+ return true;
100
+ }
101
+ async function sendDraft(draftKey, ta, current, send) {
102
+ ta.blur();
103
+ quiet.set(draftKey, Infinity);
104
+ try { await send(); } catch (e) { quiet.delete(draftKey); store.set(draftKey, (current() || ta).value); throw e; }
105
+ store.set(draftKey, '');
106
+ const now = current();
107
+ if (now) now.value = '';
108
+ quiet.set(draftKey, Date.now() + 1000);
109
+ }
110
+ const B = { root, site, route, store, quieted, sendDraft, toast, $, $$, on, every, later, sleep, watch, atUnmount, alive: () => alive, own: (off) => (alive ? own(off) : off()) };
90
111
  return {
91
112
  B,
92
113
  start() { alive = true; },
@@ -100,7 +121,7 @@ window.BoardView = (function () {
100
121
 
101
122
  function create(root, siteRoot, opts = {}) {
102
123
  const { B, start: begin, stop } = base(root, siteRoot);
103
- const store = storeFor(B.route);
124
+ const { store } = B;
104
125
  // board is the last /api/board; githubPending the time of a refresh's unanswered check-github request; fresh the
105
126
  // header's time, ui-header.js nextFresh's {at, sig}; edits each open edit box by its message's event id,
106
127
  // {thread, data, text}, data being what ask-edited sends besides the text.
@@ -112,7 +133,7 @@ window.BoardView = (function () {
112
133
  edits: new Map(),
113
134
  githubPending: null,
114
135
  // Main's refresh icon (ui-cards.js mainPull): a tap waiting on the server, and the failed pull's reason open on touch.
115
- mainPull: { sending: false, open: false },
136
+ mainPull: { open: false },
116
137
  // The Done cards Show more paged in from the archive past the board's own (app-lanes.js), and how many older ones
117
138
  // the archive holds past them, null before the first page.
118
139
  archived: { cards: [], left: null },
@@ -158,6 +179,7 @@ window.BoardView = (function () {
158
179
  // Renders open threads from state.threadCache; loadBoard and opening a card load them.
159
180
  function render(load = null) {
160
181
  const d = state.board;
182
+ BoardUI.pending.settle(d);
161
183
  const title = $(':scope > .top h1');
162
184
  if (d.title && title && title.textContent !== d.title) title.textContent = d.title;
163
185
  if (d.title && document.title !== d.title) document.title = d.title;
@@ -167,7 +189,8 @@ window.BoardView = (function () {
167
189
  BoardUI.setHTML($('#tryMain'), BoardUI.mainBar(d.main, state.mainPull));
168
190
  B.renderMainLog(d.main, load);
169
191
  for (const el of $$('details.item[open]')) state.openItems.add(el.dataset.key);
170
- 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);
171
194
  // An open card's thread is renderThread's (app-threads.js), so a lane repaint leaves it as it is.
172
195
  const strips = B.captureStrips();
173
196
  setHTML($('#lanes'), B.lanesHtml(d, d.items), { keep: '[data-thread]' });
@@ -209,6 +232,15 @@ window.BoardView = (function () {
209
232
  ev.preventDefault();
210
233
  readHash(); render(); scrollToFocus();
211
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
+ }
212
244
  // A card missing from the last visit's board waits for the fresh one, which is final.
213
245
  function scrollToFocus(final = true) {
214
246
  if (!focusKey) return;
@@ -316,10 +348,16 @@ window.BoardView = (function () {
316
348
  const out = await B.site.send(kind, { ...data, ...(state.view === 'fresh' ? { since: state.board.version } : {}) });
317
349
  if (!B.alive()) return out;
318
350
  answered?.();
319
- 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);
320
352
  return out;
321
353
  }
322
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
+
323
361
  // The server signals each change to what the board reads, through the shell, and the board reloads at once; the
324
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).
325
363
  let reloading = null;
@@ -336,7 +374,14 @@ window.BoardView = (function () {
336
374
 
337
375
  // The toast for a tap the server refused, with its reason, or that never reached it.
338
376
  const failed = (e) => B.toast(e?.reason || 'That didn’t go through. Is the host awake?');
339
- 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 });
340
385
  const live = paintSnapshot();
341
386
  B.headerWired();
342
387
  if (live) B.linkUp();
@@ -351,29 +396,21 @@ window.BoardView = (function () {
351
396
  B.every(30000, () => { if (!document.hidden) { loadBoard(); } });
352
397
  }
353
398
 
354
- 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` : '';
355
402
  return `${BoardUI.header({ lead: 'logo', title, logoSrc: '/_hub/kickflip.png', bulb: true, moon: true })}
403
+ <div class="split">
356
404
  <main>
357
- <section id="setup" class="setup" aria-live="polite" hidden></section>
405
+ ${strip} <section id="setup" class="setup" aria-live="polite" hidden></section>
358
406
  <section id="answers" class="answer-banner" hidden></section>
359
- <section id="waiting" class="waiting" hidden></section>
407
+ <section id="needsDeck" class="needs-deck" aria-label="Needs you" hidden></section>
360
408
  <div id="tryMain"></div>
361
409
  <div id="lanes"></div>
362
410
  </main>
363
- <button id="compose" class="fab" type="button" aria-label="New task" title="New task (press n twice)" aria-haspopup="dialog" aria-expanded="false"><svg width="35" height="35" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
364
- <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
365
- <form id="askForm" data-composer="ask">
366
- <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
367
- ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
368
- <div class="pending-files" data-files></div>
369
- <div class="tk-seg ask-seg" role="group" aria-label="Before building"><button type="button" id="askDiscuss" aria-pressed="true">Discuss</button><button type="button" id="askQuick" aria-pressed="false">Quick build</button></div>
370
- <p class="muted small ask-choice-help" id="askChoiceHelp" aria-live="polite"></p>
371
- <div class="row">
372
- <span class="muted small" id="askStatus"></span>
373
- <button type="submit" class="primary">Send</button>
374
- </div>
375
- </form>
376
- </dialog>
411
+ <aside id="needsPane" class="needs-pane" aria-label="Needs you"><section id="waiting" class="waiting" hidden></section></aside>
412
+ </div>
413
+ ${BoardNewTask.markup()}
377
414
  <dialog id="mainLogSheet" class="sheet" aria-labelledby="mainLogTitle">
378
415
  <div class="sheet-body">
379
416
  <div class="sheet-head"><h2 id="mainLogTitle"></h2><button type="button" class="ghost" id="mainLogClose" aria-label="Close">✕</button></div>
@@ -396,6 +433,11 @@ window.BoardView = (function () {
396
433
  <div id="archBody" class="arch-body"></div>
397
434
  </div>
398
435
  </dialog>
436
+ <dialog id="readerSheet" class="sheet reader-sheet" aria-labelledby="readerTitle">
437
+ <div class="reader-head"><h2 id="readerTitle"></h2><span class="reader-ref"></span><span class="reader-tools"></span><button type="button" class="ghost" data-reader="close" aria-label="Close">✕</button></div>
438
+ <div class="reader-body" tabindex="0"></div>
439
+ <button type="button" class="reader-new" data-reader="end" hidden></button>
440
+ </dialog>
399
441
  <dialog id="linksSheet" class="sheet" aria-labelledby="linksTitle">
400
442
  <div class="sheet-body">
401
443
  <div class="sheet-head"><h2 id="linksTitle">Hub links</h2><button type="button" class="ghost" id="linksClose" aria-label="Close">✕</button></div>
@@ -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) }); }
@@ -87,6 +87,7 @@
87
87
  .hub-view .remembered { padding:0 12px 10px; color:var(--muted); font-size:12px; }
88
88
  .hub-view .remembered p { margin:0; }
89
89
  .hub-view .remembered ul { margin:4px 0 0; padding-left:18px; }
90
+ .hub-view :is(.hub-modal, .sheet) code { white-space: pre-wrap; overflow-wrap: anywhere; }
90
91
  .hub-view .remembered code { white-space:pre-wrap; overflow-wrap:anywhere; }
91
92
 
92
93
  @media (max-width: 899px) {
@@ -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
  }
@@ -0,0 +1,100 @@
1
+ // The + button and the sheet that sends a new task as an ask event, on the board's main page alone: the board view's markup
2
+ // (app.js) puts BoardNewTask.markup() in its root and app-asks.js wires it with BoardNewTask.wire(B). No other page
3
+ // opts in. The ask goes through B.send, which lands the board the ask changed. B.newTaskKey(ev) is the n-twice handler, which a framed page's document also feeds.
4
+ globalThis.BoardNewTask = (function () {
5
+ const markup = () => `<button id="compose" class="fab" type="button" aria-label="New task" title="New task (press n twice)" aria-haspopup="dialog" aria-expanded="false"><svg width="35" height="35" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg></button>
6
+ <dialog id="sheet" class="sheet" aria-labelledby="sheetTitle">
7
+ <form id="askForm" data-composer="ask">
8
+ <div class="sheet-head"><h2 id="sheetTitle">New task</h2><button type="button" class="ghost" id="sheetClose" aria-label="Close">✕</button></div>
9
+ ${BoardUI.dropZone('<textarea id="askText" rows="4" placeholder="Ask a question or give me a task…" autofocus></textarea>')}
10
+ <div class="pending-files" data-files></div>
11
+ <div class="send-actions">${BoardUI.sendButton('Quick build', 1, ' data-intent="quick"')}${BoardUI.sendButton('Discuss', 0, ' data-intent="discuss"')}</div>
12
+ <p class="muted small" id="askStatus" aria-live="polite"></p>
13
+ </form>
14
+ </dialog>`;
15
+
16
+ function wire(B) {
17
+ // A shared person looks only (shell.js marks the page shared-view, and style.css hides the +), so n twice opens nothing.
18
+ if (document.documentElement.classList.contains('shared-view')) return;
19
+ const { $, store, toast, quieted, sendDraft } = B;
20
+ const { waitOn, keyedSend } = BoardUI;
21
+ const send = (kind, data) => (B.send ? B.send(kind, data) : B.site.send(kind, data));
22
+ // The ask box is a bottom sheet: a <dialog> opened from the + button. Closing keeps the draft.
23
+ const sheet = $('#sheet');
24
+ const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
25
+ // Files picked before the app shell switched away from this board are still attached, so they show again.
26
+ BoardAttach.paint('ask', B.site.root);
27
+ // ⌘↵ sends the ask as Discuss and ⌥↵ as Quick build (ui.js keyedSend).
28
+ B.on($('#askText'), 'keydown', (ev) => {
29
+ const button = keyedSend(ev, $('#askForm'));
30
+ if (!button) return;
31
+ ev.preventDefault();
32
+ $('#askForm').requestSubmit(button);
33
+ });
34
+ $('#askText').value = store.get('draft:ask', '');
35
+ B.on($('#askText'), 'input', (e) => { if (!quieted('draft:ask', e.target)) store.set('draft:ask', e.target.value); });
36
+ let stopFitting = null;
37
+ const stopFit = () => { stopFitting?.(); stopFitting = null; };
38
+ B.on(sheet, 'close', stopFit);
39
+ B.atUnmount(stopFit);
40
+ function openSheet() {
41
+ if (sheet.open) return;
42
+ stopFit();
43
+ stopFitting = BoardDialogs.fitSheet(sheet);
44
+ sheet.classList.remove('closing');
45
+ sheet.showModal();
46
+ $('#askText').focus();
47
+ $('#compose').setAttribute('aria-expanded', 'true');
48
+ }
49
+ function closeSheet() {
50
+ if (!sheet.open || sheet.classList.contains('closing')) return;
51
+ $('#compose').setAttribute('aria-expanded', 'false');
52
+ if (reduceMotion.matches) { sheet.close(); return; }
53
+ sheet.classList.add('closing');
54
+ B.later(180, () => { sheet.classList.remove('closing'); sheet.close(); });
55
+ }
56
+ B.on($('#compose'), 'click', openSheet);
57
+ // n pressed twice opens the sheet, as the + button's tooltip says, unless a field or another dialog has focus. With
58
+ // nothing focused the key goes to <body>, outside the view, so this listens on the document, and a framed page's
59
+ // own document is given to the same handler (view-frame.js). A key switch mode used (sidebar.js) comes
60
+ // defaultPrevented and doesn't count.
61
+ const TWICE_MS = 500;
62
+ let lastN = -Infinity;
63
+ const newTaskKey = (ev) => {
64
+ if (ev.defaultPrevented) { lastN = -Infinity; return; }
65
+ if (ev.key !== 'n' || ev.metaKey || ev.ctrlKey || ev.altKey || ev.repeat || ev.isComposing) return;
66
+ const field = ev.target.closest && ev.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])');
67
+ if (field || document.querySelector('dialog[open]') || B.root.classList.contains('offline')) { lastN = -Infinity; return; }
68
+ if (ev.timeStamp - lastN > TWICE_MS) { lastN = ev.timeStamp; return; }
69
+ lastN = -Infinity;
70
+ ev.preventDefault();
71
+ openSheet();
72
+ };
73
+ B.newTaskKey = newTaskKey;
74
+ B.on(document, 'keydown', newTaskKey);
75
+ B.on($('#sheetClose'), 'click', closeSheet);
76
+ B.on(sheet, 'click', (ev) => { if (ev.target === sheet) closeSheet(); });
77
+ B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
78
+ // Discuss has the session grill or prototype the task at once; Quick build sends the ask with quick: true, and the
79
+ // session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
80
+ B.on($('#askForm'), 'submit', async (ev) => {
81
+ ev.preventDefault();
82
+ $('#askText').blur();
83
+ const text = $('#askText').value.trim();
84
+ const files = BoardAttach.files('ask', B.site.root);
85
+ const button = ev.submitter;
86
+ const quick = button.dataset.intent === 'quick';
87
+ if ((!text && !files.length) || BoardAttach.busy('ask', B.site.root)) return;
88
+ $('#askStatus').textContent = '';
89
+ try {
90
+ await waitOn(button, sendDraft('draft:ask', $('#askText'), () => $('#askText'), () => send('ask', { text, ...(files.length ? { files } : {}), ...(quick ? { quick: true } : {}) })));
91
+ BoardAttach.clear('ask', B.site.root);
92
+ $('#askStatus').textContent = '';
93
+ closeSheet();
94
+ toast('Sent');
95
+ } catch { $('#askStatus').textContent = 'Could not send'; }
96
+ });
97
+ }
98
+
99
+ return { markup, wire };
100
+ })();
@@ -75,7 +75,7 @@
75
75
  <h2 class="label">Polling status</h2>
76
76
  <div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div></div>
77
77
  <section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
78
- <div class="hub-foot"><footer>${d.sites.length} 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">mac.example.ts.net</span></footer></div></main></div>`;
78
+ <div class="hub-foot"><footer>${d.sites.length} 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">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
79
79
  }
80
80
 
81
81
  const LANES = [['review', 'In Review'], ['progress', 'In Progress'], ['todo', 'Todo'], ['done', 'Done']];
@@ -127,6 +127,21 @@
127
127
 
128
128
  const kit = { sidebar, hub, cards, sheet, toast, on, fake, esc };
129
129
 
130
+ // [element, place] for each element in stage, its place the tag and class of it and each ancestor up to the stage, with
131
+ // a count telling apart boxes that share one, so a take drawn again finds the same box.
132
+ function placesIn(stage) {
133
+ const seen = new Map();
134
+ return [...stage.querySelectorAll('*')].map((el) => {
135
+ let path = '';
136
+ for (let at = el; at && at !== stage; at = at.parentElement) path = `${at.tagName}.${at.className}/${path}`;
137
+ const n = seen.get(path) || 0;
138
+ seen.set(path, n + 1);
139
+ return [el, `${path}#${n}`];
140
+ });
141
+ }
142
+ // place → [scrollTop, scrollLeft] of each box in stage scrolled away from its start.
143
+ const scrolledBoxes = (stage) => new Map(placesIn(stage).filter(([el]) => el.scrollTop || el.scrollLeft).map(([el, place]) => [place, [el.scrollTop, el.scrollLeft]]));
144
+
130
145
  function start(data) {
131
146
  const keys = data.takes.map((t) => t.key);
132
147
  const takes = Object.fromEntries(data.takes.map((t) => [t.key, t.label]));
@@ -158,13 +173,23 @@
158
173
  try { cleanup?.(); } catch (err) { console.error(err); }
159
174
  cleanup = null;
160
175
  ends.splice(0).forEach((end) => end());
161
- // A switch keeps the page where it is: the new stage holds the old one's height until the take has drawn.
176
+ // A switch keeps the page where it is: the new stage holds the old one's height until the take has drawn, and
177
+ // each box scrolled inside the take scrolls where it was once the new take has drawn the same box.
162
178
  const old = document.getElementById('stage');
163
179
  const stage = old.cloneNode(false);
164
180
  const y = window.scrollY;
181
+ const scrolled = scrolledBoxes(old);
165
182
  stage.style.minHeight = `${old.offsetHeight}px`;
166
183
  old.replaceWith(stage);
167
- const settle = () => { if (n !== shown) return; stage.style.minHeight = ''; window.scrollTo(0, y); };
184
+ const settle = () => {
185
+ if (n !== shown) return;
186
+ stage.style.minHeight = '';
187
+ for (const [el, place] of placesIn(stage)) {
188
+ const at = scrolled.get(place);
189
+ if (at) [el.scrollTop, el.scrollLeft] = at;
190
+ }
191
+ window.scrollTo(0, y);
192
+ };
168
193
  document.documentElement.dataset.take = key;
169
194
  const take = data.takes.find((t) => t.key === key);
170
195
  if (!take.src) {