@lovek4d/kickflip 0.6.1 → 0.7.1

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 +60 -48
  2. package/SKILL.md +20 -14
  3. package/bin/event-lines.mjs +1 -0
  4. package/bin/kickflip.mjs +8 -8
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -2
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +118 -104
  9. package/board/domain.mjs +7 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +8 -9
  12. package/board/hub-lines.mjs +1 -0
  13. package/board/onboarding.mjs +2 -2
  14. package/board/prototype-page.mjs +1 -1
  15. package/board/public/app-arch.js +3 -3
  16. package/board/public/app-banner.js +1 -1
  17. package/board/public/app-cards.js +17 -9
  18. package/board/public/app-header.js +1 -1
  19. package/board/public/app-lanes.js +39 -61
  20. package/board/public/app-needs.js +116 -30
  21. package/board/public/app-reader.js +54 -44
  22. package/board/public/app-threads.js +2 -2
  23. package/board/public/app.html +1 -1
  24. package/board/public/app.js +23 -13
  25. package/board/public/dialogs.js +1 -1
  26. package/board/public/hub-kit.js +1 -1
  27. package/board/public/hub.css +2 -0
  28. package/board/public/linear-key.js +1 -1
  29. package/board/public/new-task.js +1 -1
  30. package/board/public/progress-review.js +4 -4
  31. package/board/public/prototype-kit.js +27 -16
  32. package/board/public/prototype-switcher.js +47 -32
  33. package/board/public/prototype.html +7 -4
  34. package/board/public/router.js +35 -7
  35. package/board/public/shell.js +19 -13
  36. package/board/public/sidebar.css +4 -4
  37. package/board/public/sidebar.js +2 -2
  38. package/board/public/style.css +74 -36
  39. package/board/public/ui-cards.js +56 -37
  40. package/board/public/ui-enums.js +10 -1
  41. package/board/public/ui-hub.js +1 -5
  42. package/board/public/ui-links.js +6 -6
  43. package/board/public/ui-order.js +8 -6
  44. package/board/public/ui-progress-bar.js +6 -7
  45. package/board/public/ui-questions.js +30 -13
  46. package/board/public/ui-sidebar.js +3 -2
  47. package/board/public/ui-threads.js +20 -19
  48. package/board/public/ui.js +15 -4
  49. package/board/public/view-hub-settings.js +28 -31
  50. package/board/public/view-hub.js +7 -9
  51. package/board/public/view-notes.js +2 -2
  52. package/board/public/view-questions.js +35 -6
  53. package/board/public/view-settings.js +63 -31
  54. package/board/render-check.mjs +10 -10
  55. package/board/review.mjs +1 -1
  56. package/board/server.mjs +18 -20
  57. package/board/stop.mjs +1 -1
  58. package/board/store.mjs +26 -25
  59. package/board/tickets.mjs +1 -1
  60. package/board/transcript.mjs +4 -2
  61. package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
  62. package/hub/awake.mjs +1 -1
  63. package/hub/boards.mjs +4 -4
  64. package/hub/github.mjs +3 -3
  65. package/hub/home.mjs +1 -1
  66. package/hub/hub.mjs +11 -11
  67. package/hub/incoming.mjs +2 -2
  68. package/hub/locks.mjs +1 -1
  69. package/hub/new-board.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/try.mjs +6 -7
  72. package/hub/upgrade.mjs +1 -1
  73. package/hub/views.mjs +1 -1
  74. package/package.json +1 -1
  75. package/shared/tailscale.mjs +1 -1
  76. package/shared/usage-gate.mjs +1 -1
@@ -85,7 +85,7 @@
85
85
  return `<div class="topics" role="group" aria-label="Topics">${list.map((t) => pillHtml(t)).join('')}</div>`;
86
86
  }
87
87
  // An arrival (arrive) slides in with a glow and settles (style.css .arrived).
88
- const pillHtml = (t, arrived = false) => `<button class="${arrived ? 'arrived ' : ''}${t.progress ? 'progress' : ''}" data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}">${esc(topicName(t))}<span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`;
88
+ const pillHtml = (t, arrived = false) => `<button class="${arrived ? 'arrived ' : ''}${t.progress ? 'progress' : ''}" data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}"><span class="name">${esc(topicName(t))}</span><span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`;
89
89
  const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
90
90
 
91
91
  // Only open questions changed here since their last answer, with files attached, reopened with a pick, or with a
@@ -97,6 +97,8 @@
97
97
  const d = getDraft(q.id);
98
98
  if (filesOf(q).length) return true;
99
99
  const last = q.answers[q.answers.length - 1];
100
+ // A cleared pick sends only with a note.
101
+ if ('choice' in d && d.choice === null) return Boolean(d.note);
100
102
  if (!last && q.suggested) return true;
101
103
  if (q.reopened && choiceOf(q, d) !== null) return true;
102
104
  if (!('choice' in d) && !d.note) return false;
@@ -144,16 +146,18 @@
144
146
  el.innerHTML = `${head}<form id="qform">
145
147
  ${done.length ? `<details class="done resolved-group"><summary><span class="caret" aria-hidden="true">›</span><span>resolved (${done.length})</span></summary>${done.map((q) => questionCard(q, { n: numberOf(q), links: links(q) })).join('')}</details>` : ''}
146
148
  ${live.map((q) => questionCard(q, { n: numberOf(q), draft: getDraft(q.id), links: links(q) })).join('')}
147
- <div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${statusHtml(sentLine)}</span><span class="send-hint" aria-hidden="true">${/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘↵' : 'Ctrl+↵'}</span><button class="primary" type="submit" id="send">Send</button></div>
149
+ <div class="bar"><span class="muted small${sentLine ? ' sent' : ''}" id="qstatus" role="status">${statusHtml(sentLine)}</span>${BoardUI.sendButton('Send', 0, ' id="send"')}</div>
148
150
  </form>`;
149
151
  BoardAttach.paintAll();
150
152
  updateBar();
151
153
  }
152
154
 
155
+ // A toast names a topic by its card's ref, the title up to its colon; an ask by its time.
156
+ const toastName = (t) => (t.askedAt ? topicName(t) : String(t.title).match(/^(\S+):/)?.[1] || t.title);
153
157
  // The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
154
158
  function leaving() {
155
159
  const from = soloId ? topicOf(soloId)?.id : topicId;
156
- return { from, pills: topicRow(topics, from).pills, name: topicName(topics.find((t) => t.id === from) || { title: '' }) };
160
+ return { from, pills: topicRow(topics, from).pills, name: toastName(topics.find((t) => t.id === from) || { title: '' }) };
157
161
  }
158
162
  // After a recorded tap: a load that started before it went out would read its question as still open.
159
163
  // `footer` is a line or, for a Send, a function of the reloaded board giving the whole line.
@@ -210,7 +214,7 @@
210
214
  const qs = visible();
211
215
  const n = pending(qs).length;
212
216
  const b = $('#send');
213
- if (b) { b.disabled = !n || uploading(qs); b.textContent = uploading(qs) ? 'Uploading…' : 'Send'; }
217
+ if (b) { b.disabled = !n || uploading(qs); b.childNodes[0].textContent =uploading(qs) ? 'Uploading…' : 'Send'; }
214
218
  const live = inForm(qs);
215
219
  const answered = live.filter((q) => q.state !== 'open').length;
216
220
  // docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
@@ -364,6 +368,14 @@
364
368
  scroll(0);
365
369
  }
366
370
 
371
+ let lastTap = { id: null, at: 0 };
372
+ // item:<key> is a card, wave:<slug> or file:<slug> a band. Embedded, the board is beside the pane; alone, it is the page before this one.
373
+ function openOnBoard(id) {
374
+ if (ctx.embedded) return ctx.openOnBoard(id);
375
+ if (!id.startsWith('item:')) return;
376
+ const href = new URL(`./#item=${encodeURIComponent(id.slice(5))}`, location.href).href;
377
+ if (!AppShell.open(href)) location.assign(href);
378
+ }
367
379
  B.on(root, 'click', (ev) => {
368
380
  if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
369
381
  const eye = ev.target.closest && ev.target.closest('[data-proto]');
@@ -372,9 +384,14 @@
372
384
  if (reopenBadge) { if (!reopenBadge.disabled) reopen(byId(reopenBadge.dataset.reopen), reopenBadge); return; }
373
385
  const b = ev.target.closest && ev.target.closest('[data-topic]');
374
386
  if (b) {
387
+ // Two taps on one pill within 300ms open its card on the board; a touch screen sends no dblclick.
388
+ const now = Date.now();
389
+ if (lastTap.id === b.dataset.topic && now - lastTap.at < 300) { lastTap = { id: null, at: 0 }; openOnBoard(b.dataset.topic); return; }
390
+ lastTap = { id: b.dataset.topic, at: now };
375
391
  soloId = null; setTopic(b.dataset.topic); sentLine = ''; shown = '';
376
392
  const first = topics.find((t) => t.id === topicId)?.questions[0];
377
393
  if (first) setHash(first);
394
+ ctx.onTopic?.(topicId);
378
395
  render(); scroll(0);
379
396
  }
380
397
  });
@@ -382,14 +399,25 @@
382
399
  const sec = ev.target.closest('section.q'); if (!sec || ev.target.type !== 'radio') return;
383
400
  const d = getDraft(sec.dataset.q); d.choice = ev.target.value; setDraft(sec.dataset.q, d); updateBar();
384
401
  });
402
+ // A radio can't be unchecked natively: a pointer or Space press on a checked radio is noted, and its click clears it.
403
+ let pressed = null;
404
+ const radioOf = (el) => (el.type === 'radio' ? el : el.closest?.('label.opt')?.querySelector('input[type=radio]'));
405
+ B.on(root, 'pointerdown', (ev) => { const r = radioOf(ev.target); pressed = r?.checked ? r : null; });
406
+ B.on(root, 'keydown', (ev) => { if (ev.key === ' ' && ev.target.type === 'radio') pressed = ev.target.checked ? ev.target : null; });
407
+ B.on(root, 'click', (ev) => {
408
+ const sec = ev.target.closest('section.q'); if (!sec || ev.target.type !== 'radio' || pressed !== ev.target) return;
409
+ pressed = null;
410
+ ev.target.checked = false;
411
+ const d = getDraft(sec.dataset.q); d.choice = null; setDraft(sec.dataset.q, d); updateBar();
412
+ });
385
413
  B.on(root, 'input', (ev) => {
386
414
  const sec = ev.target.closest('section.q'); if (!sec || ev.target.tagName !== 'TEXTAREA') return;
387
415
  const d = getDraft(sec.dataset.q); d.note = ev.target.value; setDraft(sec.dataset.q, d); updateBar();
388
416
  });
389
417
  B.on(root, 'keydown', (ev) => {
390
- if (ev.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing || ev.target.tagName !== 'TEXTAREA') return;
418
+ if (ev.target.tagName !== 'TEXTAREA') return;
391
419
  const form = ev.target.closest('#qform');
392
- if (!form) return;
420
+ if (!form || !BoardUI.keyedSend(ev, form)) return;
393
421
  ev.preventDefault();
394
422
  form.requestSubmit();
395
423
  });
@@ -441,6 +469,7 @@
441
469
  if (!loaded) return false;
442
470
  const topic = topics.find((t) => t.id === id);
443
471
  if (topic) { soloId = null; setTopic(topic.id); } else pick(id || '');
472
+ ctx.onTopic?.(topicId);
444
473
  sentLine = '';
445
474
  shown = '';
446
475
  render();
@@ -1,7 +1,7 @@
1
1
  // Board Settings, a modal the app shell (shell.js) mounts over its board for /<route>/settings.html.
2
2
  // Saving reviewers sends a `reviewers` event {add: [{name, slack_user, github}], remove: [name]}; the server writes both
3
- // itself and the list redraws from its answer. Add picks a Slack member and a GitHub login from lists the server fetches
4
- // (GET api/reviewer-people), or a typed login, and stays off until both are chosen. Tickets save on each change, and Done, ✕,
3
+ // itself and the list redraws from its answer. Add searches the Slack members the server fetches (GET api/reviewer-people), then
4
+ // takes a GitHub login guessed from the name or typed, and stays off until a member and a login are there. Tickets save on each change, and Done, ✕,
5
5
  // Escape and the backdrop wait for that save to land before closing. An unsaved edit marks [data-dirty], and the shell
6
6
  // asks before closing the modal then. Sharing (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md)
7
7
  // lists the hub's GET /_hub/share/<route>, who it's shared with, each View or Full, and who was just refused, and saves
@@ -76,9 +76,9 @@
76
76
  <p class="muted small">Everyone here gets each PR link on this board once it's ready, and the weekday digest. Someone you add also gets the links of PRs already in review.</p>
77
77
  <div id="list"><p class="muted small">Loading…</p></div>
78
78
  <form class="add pick" id="addForm">
79
- <div class="pick-col"><input id="slackFilter" placeholder="Slack member" autocomplete="off"><div id="slackPeople" class="pick-list"><p class="muted small">Loading…</p></div></div>
80
- <div class="pick-col"><input id="githubFilter" placeholder="GitHub login" autocomplete="off"><div id="githubPeople" class="pick-list"><p class="muted small">Loading…</p></div></div>
81
- <button type="submit" id="addBtn" disabled>Add</button>
79
+ <input id="slackFilter" placeholder="Search Slack members" autocomplete="off">
80
+ <div id="slackPeople" class="pick-list"><p class="muted small">Loading…</p></div>
81
+ <div class="login-row" id="loginRow" hidden><input id="githubFilter" placeholder="GitHub login" autocomplete="off" autocapitalize="off" spellcheck="false"><button type="submit" id="addBtn" disabled>Add</button></div>
82
82
  </form>
83
83
  <p class="muted small" id="pickStatus" role="status"></p>
84
84
  <div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
@@ -154,7 +154,7 @@
154
154
  B.on(modal, 'cancel', (e) => { e.preventDefault(); close(); });
155
155
  B.on(modal, 'click', (e) => {
156
156
  if (e.target.closest?.('[data-settings-dismiss]')) close();
157
- else if (e.target === modal) {
157
+ else if (BoardUI.onBackdrop(e, modal)) {
158
158
  const rect = modal.getBoundingClientRect();
159
159
  if (e.clientX < rect.left || e.clientX > rect.right || e.clientY < rect.top || e.clientY > rect.bottom) close();
160
160
  }
@@ -178,7 +178,7 @@
178
178
  $('#closeBoardReason').textContent = '';
179
179
  $('#closeBoardName').textContent = d.title;
180
180
  $('#closeBoardTypeName').textContent = d.title;
181
- const warnings = [...(d.prs || []).map((p) => `<li>PR #${esc(p.pr)} (${esc(p.repo)}) — ${esc(p.wave)}/${esc(p.id)}</li>`),
181
+ const warnings = [...(d.prs || []).map((p) => `<li>PR #${esc(p.pr)} (${esc(p.repo)}) — ${esc(p.file)}/${esc(p.id)}</li>`),
182
182
  ...(d.builders || []).map((b) => `<li>${esc(b.label)} — ${esc(b.key)}</li>`)];
183
183
  $('#closeBoardWarning').innerHTML = warnings.length ? `<h3>This board still has active work</h3><ul>${warnings.join('')}</ul><p>Running implementers will be stopped with a note. PRs stay open on GitHub; this board stops polling them.</p>` : '';
184
184
  };
@@ -201,7 +201,7 @@
201
201
  B.on(typed, 'input', () => { closeConfirm.disabled = closing || typed.value !== closure?.title; });
202
202
  B.on($('#closeBoardCancel'), 'click', () => closeSheet.close());
203
203
  B.on(closeSheet, 'close', () => { unfit?.(); unfit = null; });
204
- B.on(closeSheet, 'click', (e) => { if (e.target === closeSheet && !closing) closeSheet.close(); });
204
+ B.on(closeSheet, 'click', (e) => { if (BoardUI.onBackdrop(e, closeSheet) && !closing) closeSheet.close(); });
205
205
  B.on($('#closeBoardForm'), 'submit', async (e) => {
206
206
  e.preventDefault();
207
207
  if (closing || typed.value !== closure?.title) return;
@@ -362,40 +362,72 @@
362
362
  render();
363
363
  });
364
364
 
365
- // The pickers: tap a Slack member and a GitHub login (or type one), then Add stages them for Save.
366
- let people = { slack: [], github: [] };
367
- const picked = { slack: null, github: '' };
365
+ // The picker: search the Slack members and tap one, then the GitHub login field holds a guess from the name (or what
366
+ // the person types), and Add stages them for Save. people is null while loading, 'failed' once the list can't be read.
367
+ let people = null;
368
+ const picked = { slack: null };
368
369
  const fits = (text, filter) => text.toLowerCase().includes(filter.trim().toLowerCase());
370
+ const squash = (text) => text.toLowerCase().replace(/[^a-z0-9]/g, '');
371
+ // The collaborator whose login is a plain spelling of the name: first+last, first, last, or first initial + last.
372
+ function guessLogin(name, logins) {
373
+ const words = name.toLowerCase().split(/\s+/).map(squash).filter(Boolean);
374
+ if (!words.length) return '';
375
+ const first = words[0], last = words.at(-1);
376
+ const spellings = [first + last, first, last, first[0] + last];
377
+ for (const spelling of spellings) {
378
+ const hit = logins.find((l) => squash(l) === spelling);
379
+ if (hit) return hit;
380
+ }
381
+ return '';
382
+ }
383
+ const problem = (text) => `<p class="muted small problem">${esc(text)} <button type="button" class="ghost" data-retry>Retry</button></p>`;
369
384
  function renderPick() {
370
- const slackFilter = $('#slackFilter').value, githubFilter = $('#githubFilter').value.trim();
371
385
  const taken = new Set([...current, ...add.keys()].map((n) => n.toLowerCase()));
372
- const slack = (people.slack || []).filter((p) => fits(p.name, slackFilter) && !taken.has(p.name.toLowerCase()));
373
- $('#slackPeople').innerHTML = people.slack ? slack.map((p) => `<button type="button" class="ghost" data-slack="${esc(p.id)}" data-name="${esc(p.name)}" aria-pressed="${picked.slack?.id === p.id}">${esc(p.name)}</button>`).join('') || '<p class="muted small">No one matches.</p>'
374
- : `<p class="muted small">${esc(people.slackProblem || 'Slack members are not available.')} <a href="/_hub/settings">Hub settings</a></p>`;
375
- const logins = people.github.filter((l) => fits(l, githubFilter));
376
- const typed = githubFilter && !people.github.some((l) => l.toLowerCase() === githubFilter.toLowerCase()) ? [githubFilter] : [];
377
- $('#githubPeople').innerHTML = [...typed, ...logins].map((l) => `<button type="button" class="ghost" data-github="${esc(l)}" aria-pressed="${picked.github === l}">${typed.includes(l) ? `Use ${esc(l)}` : esc(l)}</button>`).join('')
378
- + (people.githubProblem ? `<p class="muted small">${esc(people.githubProblem)}</p>` : '');
379
- $('#addBtn').disabled = !picked.slack || !picked.github;
386
+ const list = $('#slackPeople');
387
+ $('#slackFilter').hidden = Boolean(picked.slack);
388
+ $('#loginRow').hidden = !picked.slack;
389
+ if (picked.slack) {
390
+ list.innerHTML = `<span class="chip">${esc(picked.slack.name)} <button type="button" class="ghost" data-unpick aria-label="Choose someone else">✕</button></span>`;
391
+ $('#pickStatus').textContent = people?.githubProblem ? `${people.githubProblem} Type their login.` : '';
392
+ } else if (people === 'failed') list.innerHTML = problem("Couldn't load the list.");
393
+ else if (!people) list.innerHTML = '<p class="muted small">Loading…</p>';
394
+ else if (!people.slack) list.innerHTML = `<p class="muted small">${esc(people.slackProblem || 'Slack members are not available.')} <a href="/_hub/settings">Hub settings</a></p>`;
395
+ else list.innerHTML = people.slack.filter((p) => fits(p.name, $('#slackFilter').value) && !taken.has(p.name.toLowerCase()))
396
+ .map((p) => `<button type="button" class="ghost" data-slack="${esc(p.id)}" data-name="${esc(p.name)}">${esc(p.name)}</button>`).join('') || '<p class="muted small">No one matches.</p>';
397
+ $('#addBtn').disabled = !picked.slack || !$('#githubFilter').value.trim();
398
+ }
399
+ function loadPeople() {
400
+ people = null;
401
+ renderPick();
402
+ // The wait says "Board restarting…" only when the hub marked a restart; any other wait leaves Loading… until the
403
+ // list arrives or the retries give out.
404
+ const wait = (ms, down) => { if (down?.state === 'restarting') $('#slackPeople').innerHTML = restartNotice(); };
405
+ get(site.url('api/reviewer-people'), wait).then(({ ok, body }) => { if (!B.alive()) return; people = ok ? { github: [], ...body } : 'failed'; renderPick(); })
406
+ .catch(() => { if (B.alive()) { people = 'failed'; renderPick(); } });
380
407
  }
381
- get(site.url('api/reviewer-people'), waitIn('#slackPeople')).then(({ body }) => { if (!B.alive()) return; people = { github: [], ...body }; renderPick(); })
382
- .catch(() => { if (B.alive()) $('#slackPeople').innerHTML = '<p class="muted small">Offline. Is the host awake?</p>'; });
408
+ loadPeople();
383
409
  B.on($('#slackFilter'), 'input', renderPick);
384
410
  B.on($('#githubFilter'), 'input', renderPick);
385
411
  B.on($('#slackPeople'), 'click', (ev) => {
386
- const b = ev.target.closest('button[data-slack]');
387
- if (b) { picked.slack = { id: b.dataset.slack, name: b.dataset.name }; renderPick(); }
388
- });
389
- B.on($('#githubPeople'), 'click', (ev) => {
390
- const b = ev.target.closest('button[data-github]');
391
- if (b) { picked.github = b.dataset.github; renderPick(); }
412
+ const b = ev.target.closest('button');
413
+ if (!b) return;
414
+ if (b.dataset.retry !== undefined) { loadPeople(); return; }
415
+ if (b.dataset.unpick !== undefined) { picked.slack = null; $('#githubFilter').value = ''; renderPick(); return; }
416
+ if (!b.dataset.slack) return;
417
+ picked.slack = { id: b.dataset.slack, name: b.dataset.name };
418
+ $('#githubFilter').value = guessLogin(picked.slack.name, people.github);
419
+ renderPick();
420
+ $('#pickStatus').textContent = $('#githubFilter').value ? 'Guessed from their name. Change it if it is wrong.' : $('#pickStatus').textContent;
421
+ $('#githubFilter').focus?.();
392
422
  });
393
423
  B.on($('#addForm'), 'submit', (ev) => {
394
424
  ev.preventDefault();
395
- if (!picked.slack || !picked.github) return;
396
- add.set(picked.slack.name, { name: picked.slack.name, slack_user: picked.slack.id, github: picked.github });
397
- picked.slack = null; picked.github = '';
425
+ const github = $('#githubFilter').value.trim();
426
+ if (!picked.slack || !github) return;
427
+ add.set(picked.slack.name, { name: picked.slack.name, slack_user: picked.slack.id, github });
428
+ picked.slack = null;
398
429
  $('#slackFilter').value = ''; $('#githubFilter').value = '';
430
+ $('#pickStatus').textContent = '';
399
431
  renderPick();
400
432
  render();
401
433
  });
@@ -1,10 +1,10 @@
1
- var H=Object.defineProperty;var l=(n,e)=>H(n,"name",{value:e,configurable:!0});import g from"node:fs";import x from"node:fs/promises";import _ from"node:http";import E from"node:os";import h from"node:path";import{spawn as P}from"node:child_process";import{setTimeout as k}from"node:timers/promises";import{fileURLToPath as B}from"node:url";import{createInterface as R}from"node:readline";import{BoardError as w,readPrototypes as D}from"./store.mjs";import{prototypePage as F}from"./prototype-page.mjs";import{BASE_KEY as G,baseWorktreeOf as U}from"./prototype-base.mjs";import{buildScriptError as K,buildScriptOf as O}from"./build-script.mjs";import{boardAt as W}from"../hub/boards.mjs";const S=h.dirname(B(import.meta.url)),C=[390,1280],j=process.env.CHROME||"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",M={".js":"text/javascript",".css":"text/css",".html":"text/html",".json":"application/json",".png":"image/png",".svg":"image/svg+xml"},q=3e3,N=300,J=12e4,I="(document.getElementById('stage')?.innerHTML || '').trim()",Y="(document.body?.innerText || '').trim()";function V(n,e){const t=[h.join(n,"public"),h.join(S,"public"),h.join(S,"..","starter","public"),h.join(S,"..","shared")];return _.createServer(async(d,i)=>{const o=decodeURIComponent(new URL(d.url,"http://x").pathname);if(o===`/prototype-${e.topic}.html`){i.writeHead(200,{"content-type":M[".html"]}).end(await F(n,e));return}for(const u of t){const s=h.resolve(u,`.${o}`);if(s.startsWith(u+h.sep)&&g.existsSync(s)&&g.statSync(s).isFile()){i.writeHead(200,{"content-type":M[h.extname(s)]||"application/octet-stream"}).end(await x.readFile(s));return}}i.writeHead(o==="/favicon.ico"?204:404).end()})}l(V,"serve");async function z(n){if(!g.existsSync(j))throw new w(`the render check needs Chrome; none at ${j}, set CHROME to its path`);const e=P(j,["--headless=new","--remote-debugging-port=0",`--user-data-dir=${n}`,"--no-first-run","about:blank"],{stdio:"ignore",env:{...process.env,HOME:E.userInfo().homedir}}),t=h.join(n,"DevToolsActivePort");for(let a=0;a<100&&!g.existsSync(t);a++)await k(100);if(!g.existsSync(t))throw e.kill(),new w("the render check could not start Chrome");const d=g.readFileSync(t,"utf8").split(`
2
- `)[0];let i;for(let a=0;a<100&&!i;a++)i=(await fetch(`http://127.0.0.1:${d}/json`).then(c=>c.json(),()=>[])).find(c=>c.type==="page"),i||await k(100);const o=new WebSocket(i.webSocketDebuggerUrl);await new Promise((a,c)=>{o.onopen=a,o.onerror=c});let u=0;const s=new Map,p=[];return o.onmessage=({data:a})=>{const c=JSON.parse(a);c.id?s.get(c.id)?.(c):p.forEach(m=>m(c))},{cdp:l((a,c={})=>new Promise((m,$)=>{const y=++u;s.set(y,v=>v.error?$(new Error(v.error.message)):m(v.result)),o.send(JSON.stringify({id:y,method:a,params:c}))}),"cdp"),on:l(a=>(p.push(a),()=>p.splice(p.indexOf(a),1)),"on"),close:l(async()=>{o.close();const a=new Promise(c=>e.once("exit",c));e.kill(),await a},"close")}}l(z,"launch");const Q=l(n=>n.exception?.description?.split(`
3
- `)[0]||n.text,"described");async function L({cdp:n,on:e},t,d,i=I){const o=[],u=e(r=>{r.method==="Runtime.exceptionThrown"?o.push(Q(r.params.exceptionDetails)):r.method==="Runtime.consoleAPICalled"&&r.params.type==="error"?o.push(`console error: ${r.params.args.map(f=>f.value??f.description).join(" ").split(`
4
- `)[0]}`):r.method==="Log.entryAdded"&&r.params.entry.level==="error"&&r.params.entry.source!=="javascript"&&!/\/favicon\.ico$/.test(r.params.entry.url||"")&&o.push(`${r.params.entry.text}${r.params.entry.url?` ${r.params.entry.url}`:""}`)});await n("Emulation.setDeviceMetricsOverride",{width:d,height:900,deviceScaleFactor:1,mobile:d<600}),await n("Page.navigate",{url:t});const s=l(async()=>(await n("Runtime.evaluate",{expression:i,returnByValue:!0})).result.value,"stage");let p="";for(let r=0;r<q&&!p;r+=100)await k(100),p=await s();return await k(N),u(),p||o.push(i===I?"renders an empty stage":"renders an empty page"),[...new Set(o)]}l(L,"checkTake");async function X(n){const e=W(n);if(!e)throw new w(`${n} is not a board in the hub's manifest, so it has no base prototype worktree to check`);const t=U(e.route),d=h.join(t,O(e.build_target)),i=K(d);if(i)throw new w(`the base prototype worktree can't serve the round: ${i} (${d})`);const o=P(d,[G,t],{cwd:t,detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,KICKFLIP_BOARD:e.route,HOST:"127.0.0.1"}}),u=[],s=l(m=>{u.push(m),u.length>5&&u.shift()},"keep");R({input:o.stderr}).on("line",s);const p=new Promise(m=>o.once("exit",m)),r=l(async()=>{try{process.kill(-o.pid,"SIGTERM")}catch{return}if(!await Promise.race([p.then(()=>!0),k(4e3).then(()=>!1)])){try{process.kill(-o.pid,"SIGKILL")}catch{}await p}},"stop"),f=await new Promise(m=>{const $=R({input:o.stdout}),y=l(b=>{clearTimeout(v),$.close(),m(b)},"done"),v=setTimeout(()=>y(null),J);$.on("line",b=>{const T=/^port (\d{1,5})$/.exec(b);T?y(Number(T[1])):s(b)}),p.then(()=>y(null))}),a=l(()=>fetch(`http://127.0.0.1:${f}/`,{signal:AbortSignal.timeout(1e3)}).then(()=>!0,()=>!1),"answers");let c=!!f;for(let m=0;c&&!await a();m+=250)m>=6e4?c=!1:await k(250);if(!c)throw await r(),new w(`the base prototype worktree's ${O(e.build_target)} didn't serve on a port, so the round's question is not published:
5
- ${u.join(`
6
- `)}`);return{origin:`http://127.0.0.1:${f}`,dir:t,stop:r}}l(X,"serveBase");async function A(n){const e=await x.mkdtemp(h.join(E.tmpdir(),"render-check-"));let t;try{return t=await z(e),await t.cdp("Runtime.enable"),await t.cdp("Log.enable"),await n(t)}finally{await t?.close(),await x.rm(e,{recursive:!0,force:!0,maxRetries:30,retryDelay:200})}}l(A,"withChrome");async function Z(n,e){const t=await X(n);try{const d=e.takes[0].key,i=(e.states||[]).map(s=>s.key),o=[...e.takes.map(s=>[s.key,i[0]??null]),...i.slice(1).map(s=>[d,s])],u=await A(async s=>{const p=[];for(const[r,f]of o){const a=new URL(e.served,t.origin);a.searchParams.set("variant",r),f&&a.searchParams.set("state",f);for(const c of C)for(const m of await L(s,a.href,c,Y))p.push(`take ${r}${f?`, state ${f}`:""} at ${c}px: ${m}`)}return p});if(u.length)throw new w(`prototype ${e.topic} does not render, so its question is not published; fix its page in ${t.dir} and ask again:
7
- ${u.join(`
8
- `)}`)}finally{await t.stop()}}l(Z,"checkServedRender");async function he(n,e){const t=(await D(n)).find(i=>i.topic===e);if(t.served)return Z(n,t);const d=V(n,t);await new Promise(i=>d.listen(0,"127.0.0.1",i));try{const i=`http://127.0.0.1:${d.address().port}/prototype-${e}.html`,o=await A(async u=>{const s=[];for(const p of t.takes)for(const r of C)for(const f of await L(u,`${i}?variant=${p.key}`,r))s.push(`take ${p.key} at ${r}px: ${f}`);return s});if(o.length)throw new w(`prototype ${e} does not render, so its question is not published; fix the take files and ask again:
9
- ${o.join(`
10
- `)}`)}finally{d.close()}}l(he,"checkTakesRender");export{C as WIDTHS,he as checkTakesRender};
1
+ var _=Object.defineProperty;var l=(t,e)=>_(t,"name",{value:e,configurable:!0});import g from"node:fs";import $ from"node:fs/promises";import B from"node:http";import E from"node:os";import m from"node:path";import{spawn as P}from"node:child_process";import{setTimeout as k}from"node:timers/promises";import{fileURLToPath as D}from"node:url";import{createInterface as R}from"node:readline";import{BoardError as w,readPrototypes as F}from"./store.mjs";import{prototypePage as G}from"./prototype-page.mjs";import{BASE_KEY as U,baseWorktreeOf as K}from"./prototype-base.mjs";import{buildScriptError as W,buildScriptOf as O}from"./build-script.mjs";import{boardAt as q}from"../hub/boards.mjs";const S=m.dirname(D(import.meta.url)),C=[390,1280],j=process.env.CHROME||"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",M={".js":"text/javascript",".css":"text/css",".html":"text/html",".json":"application/json",".png":"image/png",".svg":"image/svg+xml"},N=3e3,J=300,Y=12e4,I="(document.getElementById('stage')?.innerHTML || '').trim()",V="(document.body?.innerText || '').trim()";function z(t,e){const o=[m.join(t,"public"),m.join(S,"public"),m.join(S,"..","starter","public"),m.join(S,"..","shared")];return B.createServer(async(u,s)=>{const r=decodeURIComponent(new URL(u.url,"http://x").pathname);if(r===`/prototype-${e.topic}.html`){s.writeHead(200,{"content-type":M[".html"]}).end(await G(t,e));return}for(const d of o){const a=m.resolve(d,`.${r}`);if(a.startsWith(d+m.sep)&&g.existsSync(a)&&g.statSync(a).isFile()){s.writeHead(200,{"content-type":M[m.extname(a)]||"application/octet-stream"}).end(await $.readFile(a));return}}s.writeHead(r==="/favicon.ico"?204:404).end()})}l(z,"serve");async function L(t){if(!g.existsSync(j))throw new w(`the render check needs Chrome; none at ${j}, set CHROME to its path`);const e=P(j,["--headless=new","--remote-debugging-port=0",`--user-data-dir=${t}`,"--no-first-run","about:blank"],{stdio:"ignore",env:{...process.env,HOME:E.userInfo().homedir}}),o=m.join(t,"DevToolsActivePort");for(let i=0;i<100&&!g.existsSync(o);i++)await k(100);if(!g.existsSync(o))throw e.kill(),new w("the render check could not start Chrome");const u=g.readFileSync(o,"utf8").split(`
2
+ `)[0];let s;for(let i=0;i<100&&!s;i++)s=(await fetch(`http://127.0.0.1:${u}/json`).then(c=>c.json(),()=>[])).find(c=>c.type==="page"),s||await k(100);if(!s)throw e.kill(),new w("the render check could not start Chrome");const r=new WebSocket(s.webSocketDebuggerUrl);try{await new Promise((i,c)=>{r.onopen=i,r.onerror=c})}catch{throw e.kill(),new w("the render check could not start Chrome")}let d=0;const a=new Map,p=[];return r.onmessage=({data:i})=>{const c=JSON.parse(i);c.id?a.get(c.id)?.(c):p.forEach(h=>h(c))},{cdp:l((i,c={})=>new Promise((h,b)=>{const y=++d;a.set(y,v=>v.error?b(new Error(v.error.message)):h(v.result)),r.send(JSON.stringify({id:y,method:i,params:c}))}),"cdp"),on:l(i=>(p.push(i),()=>p.splice(p.indexOf(i),1)),"on"),close:l(async()=>{r.close();const i=new Promise(c=>e.once("exit",c));e.kill(),await i},"close")}}l(L,"launchOnce");async function Q(t){try{return await L(t)}catch(e){if(!(e instanceof w)||!/could not start Chrome/.test(e.message))throw e;return await $.rm(t,{recursive:!0,force:!0}),await $.mkdir(t),L(t)}}l(Q,"launch");const X=l(t=>t.exception?.description?.split(`
3
+ `)[0]||t.text,"described");async function A({cdp:t,on:e},o,u,s=I){const r=[],d=e(n=>{n.method==="Runtime.exceptionThrown"?r.push(X(n.params.exceptionDetails)):n.method==="Runtime.consoleAPICalled"&&n.params.type==="error"?r.push(`console error: ${n.params.args.map(f=>f.value??f.description).join(" ").split(`
4
+ `)[0]}`):n.method==="Log.entryAdded"&&n.params.entry.level==="error"&&n.params.entry.source!=="javascript"&&!/\/favicon\.ico$/.test(n.params.entry.url||"")&&r.push(`${n.params.entry.text}${n.params.entry.url?` ${n.params.entry.url}`:""}`)});await t("Emulation.setDeviceMetricsOverride",{width:u,height:900,deviceScaleFactor:1,mobile:u<600}),await t("Page.navigate",{url:o});const a=l(async()=>(await t("Runtime.evaluate",{expression:s,returnByValue:!0})).result.value,"stage");let p="";for(let n=0;n<N&&!p;n+=100)await k(100),p=await a();return await k(J),d(),p||r.push(s===I?"renders an empty stage":"renders an empty page"),[...new Set(r)]}l(A,"checkTake");async function Z(t){const e=q(t);if(!e)throw new w(`${t} is not a board in the hub's manifest, so it has no base prototype worktree to check`);const o=K(e.route),u=m.join(o,O(e.build_target)),s=W(u);if(s)throw new w(`the base prototype worktree can't serve the round: ${s} (${u})`);const r=P(u,[U,o],{cwd:o,detached:!0,stdio:["ignore","pipe","pipe"],env:{...process.env,KICKFLIP_BOARD:e.route,HOST:"127.0.0.1"}}),d=[],a=l(h=>{d.push(h),d.length>5&&d.shift()},"keep");R({input:r.stderr}).on("line",a);const p=new Promise(h=>r.once("exit",h)),n=l(async()=>{try{process.kill(-r.pid,"SIGTERM")}catch{return}if(!await Promise.race([p.then(()=>!0),k(4e3).then(()=>!1)])){try{process.kill(-r.pid,"SIGKILL")}catch{}await p}},"stop"),f=await new Promise(h=>{const b=R({input:r.stdout}),y=l(x=>{clearTimeout(v),b.close(),h(x)},"done"),v=setTimeout(()=>y(null),Y);b.on("line",x=>{const T=/^port (\d{1,5})$/.exec(x);T?y(Number(T[1])):a(x)}),p.then(()=>y(null))}),i=l(()=>fetch(`http://127.0.0.1:${f}/`,{signal:AbortSignal.timeout(1e3)}).then(()=>!0,()=>!1),"answers");let c=!!f;for(let h=0;c&&!await i();h+=250)h>=6e4?c=!1:await k(250);if(!c)throw await n(),new w(`the base prototype worktree's ${O(e.build_target)} didn't serve on a port, so the round's question is not published:
5
+ ${d.join(`
6
+ `)}`);return{origin:`http://127.0.0.1:${f}`,dir:o,stop:n}}l(Z,"serveBase");async function H(t){const e=await $.mkdtemp(m.join(E.tmpdir(),"render-check-"));let o;try{return o=await Q(e),await o.cdp("Runtime.enable"),await o.cdp("Log.enable"),await t(o)}finally{await o?.close(),await $.rm(e,{recursive:!0,force:!0,maxRetries:30,retryDelay:200})}}l(H,"withChrome");async function ee(t,e){const o=await Z(t);try{const u=e.takes[0].key,s=(e.states||[]).map(a=>a.key),r=[...e.takes.map(a=>[a.key,s[0]??null]),...s.slice(1).map(a=>[u,a])],d=await H(async a=>{const p=[];for(const[n,f]of r){const i=new URL(e.served,o.origin);i.searchParams.set("variant",n),f&&i.searchParams.set("state",f);for(const c of C)for(const h of await A(a,i.href,c,V))p.push(`take ${n}${f?`, state ${f}`:""} at ${c}px: ${h}`)}return p});if(d.length)throw new w(`prototype ${e.topic} does not render, so its question is not published; fix its page in ${o.dir} and ask again:
7
+ ${d.join(`
8
+ `)}`)}finally{await o.stop()}}l(ee,"checkServedRender");async function we(t,e){const o=(await F(t)).find(s=>s.topic===e);if(o.served)return ee(t,o);const u=z(t,o);await new Promise(s=>u.listen(0,"127.0.0.1",s));try{const s=`http://127.0.0.1:${u.address().port}/prototype-${e}.html`,r=await H(async d=>{const a=[];for(const p of o.takes)for(const n of C)for(const f of await A(d,`${s}?variant=${p.key}`,n))a.push(`take ${p.key} at ${n}px: ${f}`);return a});if(r.length)throw new w(`prototype ${e} does not render, so its question is not published; fix the take files and ask again:
9
+ ${r.join(`
10
+ `)}`)}finally{u.close()}}l(we,"checkTakesRender");export{C as WIDTHS,we as checkTakesRender};
package/board/review.mjs CHANGED
@@ -1 +1 @@
1
- var d=Object.defineProperty;var c=(t,e)=>d(t,"name",{value:e,configurable:!0});import p from"node:fs";import b from"node:path";import w from"node:os";import{routeRecord as l}from"../hub/routes.mjs";import{prKeyOf as h,storedPRs as k}from"../shared/card-prs.mjs";function g(){const t=b.join(w.homedir(),".claude","slack-bridge","topics"),e=Object.create(null);let n=[];try{n=p.readdirSync(t).filter(o=>o.endsWith(".json")).sort()}catch{}for(const o of n)try{const s=JSON.parse(p.readFileSync(b.join(t,o),"utf8"));for(const[r,u]of Object.entries(s.items||{}))e[r]={...e[r],...u}}catch{}return e}c(g,"topicItems");function x(t={},e=[]){const n={};for(const o of e)for(const[s,r]of Object.entries(l(t,h(o))?.links||{}))r.ts&&!(Number(n[s]?.ts)<=Number(r.ts))&&(n[s]={ts:r.ts,channel:r.channel});return n}c(x,"prLinks");function S(t,e,n,o={},s={}){const r=Object.create(null),u=[s,e?.review_link_ts,e?.review_sent,l(t?.review_link_ts,k(e)[0]?.number),l(t?.review_link_ts,n),l(o,n)?.review_link_ts];for(const a of u)for(const[m,i]of Array.isArray(a)?a.map(f=>[f,null]):Object.entries(a||{})){if(Object.hasOwn(r,m))continue;const f=typeof i=="string"?i:i?.ts;i!==null&&(typeof f!="string"||!/^\d+(?:\.\d+)?$/.test(f))||(r[m]={ts:f||null,...typeof i?.channel=="string"?{channel:i.channel}:{}})}return r}c(S,"reviewLinks");const A=c(t=>{const e=t?Number(t)*1e3:NaN;return Number.isFinite(e)&&!Number.isNaN(new Date(e).getTime())?new Date(e).toISOString():null},"reviewTime");function D(t,e=[]){return[...new Map([...e,...t.reviewers||[]].map(n=>[n.name,n])).values()]}c(D,"reviewersOf");export{x as prLinks,S as reviewLinks,A as reviewTime,D as reviewersOf,g as topicItems};
1
+ var w=Object.defineProperty;var c=(t,e)=>w(t,"name",{value:e,configurable:!0});import p from"node:fs";import b from"node:path";import d from"node:os";import{routeRecord as l}from"../hub/routes.mjs";import{prKeyOf as h,storedPRs as k}from"../shared/card-prs.mjs";function g(){const t=b.join(d.homedir(),".claude","slack-bridge","topics"),e=Object.create(null);let n=[];try{n=p.readdirSync(t).filter(o=>o.endsWith(".json")).sort()}catch{}for(const o of n)try{const s=JSON.parse(p.readFileSync(b.join(t,o),"utf8"));for(const[r,a]of Object.entries(s.items||{}))e[r]={...e[r],...a}}catch{}return e}c(g,"topicItems");function x(t={},e=[]){const n={};for(const o of e)for(const[s,r]of Object.entries(l(t,h(o))?.links||{}))r.ts&&!(Number(n[s]?.ts)<=Number(r.ts))&&(n[s]={ts:r.ts,channel:r.channel});return n}c(x,"prLinks");function S(t,e,n,o={},s={}){const r=Object.create(null),a=[s,e?.review_link_ts,e?.review_sent,l(t?.review_link_ts,k(e)[0]?.number),l(t?.review_link_ts,n),l(o,n)?.review_link_ts];for(const u of a)for(const[m,i]of Array.isArray(u)?u.map(f=>[f,null]):Object.entries(u||{})){if(Object.hasOwn(r,m))continue;const f=typeof i=="string"?i:i?.ts;i!==null&&(typeof f!="string"||!/^\d+(?:\.\d+)?$/.test(f))||(r[m]={ts:f||null,...typeof i?.channel=="string"?{channel:i.channel}:{}})}return r}c(S,"reviewLinks");const A=c(t=>{const e=t?Number(t)*1e3:NaN;return Number.isFinite(e)&&!Number.isNaN(new Date(e).getTime())?new Date(e).toISOString():null},"reviewTime");function D(t,e=[]){return[...new Map([...e,...t.reviewers||[]].map(n=>[n.name,n])).values()]}c(D,"reviewersOf");export{x as prLinks,S as reviewLinks,A as reviewTime,D as reviewersOf,g as topicItems};