@lovek4d/kickflip 0.6.0 → 0.7.0

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 (68) hide show
  1. package/CONTEXT.md +42 -26
  2. package/SKILL.md +16 -12
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/event-lines.mjs +1 -0
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +7 -7
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/agents.mjs +2 -2
  9. package/board/arch-review.mjs +1 -1
  10. package/board/bin/board.mjs +70 -42
  11. package/board/domain.mjs +7 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +8 -9
  14. package/board/onboarding.mjs +1 -1
  15. package/board/prototype-page.mjs +1 -1
  16. package/board/public/app-arch.js +2 -2
  17. package/board/public/app-banner.js +1 -1
  18. package/board/public/app-cards.js +36 -48
  19. package/board/public/app-lanes.js +6 -9
  20. package/board/public/app-needs.js +107 -26
  21. package/board/public/app-reader.js +52 -42
  22. package/board/public/app-threads.js +14 -10
  23. package/board/public/app.html +1 -1
  24. package/board/public/app.js +22 -12
  25. package/board/public/progress-review.js +4 -4
  26. package/board/public/prototype-kit.js +25 -11
  27. package/board/public/prototype-switcher.js +53 -33
  28. package/board/public/prototype.html +7 -4
  29. package/board/public/router.js +34 -6
  30. package/board/public/shell.js +23 -16
  31. package/board/public/sidebar.css +7 -2
  32. package/board/public/sidebar.js +3 -0
  33. package/board/public/style.css +69 -30
  34. package/board/public/ui-cards.js +61 -38
  35. package/board/public/ui-enums.js +12 -3
  36. package/board/public/ui-header.js +4 -6
  37. package/board/public/ui-hub.js +7 -4
  38. package/board/public/ui-links.js +1 -1
  39. package/board/public/ui-order.js +9 -6
  40. package/board/public/ui-page-header.js +1 -1
  41. package/board/public/ui-progress-bar.js +6 -7
  42. package/board/public/ui-questions.js +18 -3
  43. package/board/public/ui-sidebar.js +12 -5
  44. package/board/public/ui-snapshot.js +1 -1
  45. package/board/public/ui-threads.js +10 -12
  46. package/board/public/ui.js +7 -4
  47. package/board/public/view-hub.js +3 -1
  48. package/board/public/view-notes.js +1 -1
  49. package/board/public/view-questions.js +19 -3
  50. package/board/public/view-settings.js +60 -28
  51. package/board/server.mjs +19 -19
  52. package/board/stop.mjs +1 -1
  53. package/board/store.mjs +27 -25
  54. package/board/tickets.mjs +1 -1
  55. package/board/transcript.mjs +4 -2
  56. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  57. package/hub/front-door.mjs +1 -1
  58. package/hub/home.mjs +1 -1
  59. package/hub/hub.mjs +6 -6
  60. package/hub/sessions.mjs +2 -2
  61. package/hub/try.mjs +7 -7
  62. package/hub/upgrade.mjs +1 -1
  63. package/hub/usage.mjs +3 -3
  64. package/package.json +1 -1
  65. package/shared/card-prs.mjs +1 -1
  66. package/shared/machine-gate.mjs +2 -2
  67. package/shared/tailscale.mjs +1 -1
  68. package/shared/usage-gate.mjs +1 -1
@@ -60,9 +60,10 @@
60
60
  // A composer's send buttons: the one with nth 0 presses on ⌘↵ and the one with nth 1, where there is one, on ⌥↵,
61
61
  // wherever each sits in the row (Ctrl+↵ and
62
62
  // Alt+↵ off a Mac), each with its key as a small gray hint that style.css shows only where there is a keyboard and room.
63
+ // tone is its class: primary for the nth 0 one unless given, as resolves on the Send or Reply that clears Action needed.
63
64
  const MAC = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform || globalThis.navigator?.userAgent || '');
64
65
  const SEND_KEYS = MAC ? ['⌘↵', '⌥↵'] : ['Ctrl+↵', 'Alt+↵'];
65
- const sendButton = (label, nth, attrs = '') => `<button type="submit"${nth === 0 ? ' class="primary"' : ''} data-nth="${nth}"${attrs}>${esc(label)}<span class="key-hint" aria-hidden="true">${SEND_KEYS[nth]}</span></button>`;
66
+ const sendButton = (label, nth, attrs = '', tone = nth === 0 ? 'primary' : '') => `<button type="submit"${tone ? ` class="${tone}"` : ''} data-nth="${nth}"${attrs}>${esc(label)}<span class="key-hint" aria-hidden="true">${SEND_KEYS[nth]}</span></button>`;
66
67
  // The send button an Enter keydown in form's box presses, or null.
67
68
  const keyedSend = (ev, form) => {
68
69
  if (ev.key !== 'Enter' || ev.isComposing) return null;
@@ -102,6 +103,8 @@
102
103
  'ask-link': mini('<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'),
103
104
  move: mini('<path d="M3 7h14"/><path d="m13 3 4 4-4 4"/><path d="M21 17H7"/><path d="m11 13-4 4 4 4"/>'),
104
105
  'Implementation requested': mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
106
+ 'Marked as staging': mini('<path d="m12 2 10 5-10 5L2 7z"/><path d="m2 12 10 5 10-5"/><path d="m2 17 10 5 10-5"/>'),
107
+ 'Marked as ready': mini('<path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/>'),
105
108
  Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
106
109
  Resumed: mini('<path d="M7 4v16l13-8z"/>'),
107
110
  'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
@@ -121,8 +124,8 @@
121
124
  // Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
122
125
  const withdrawButton = (id) => `<button class="ghost icon" data-withdraw="${esc(id)}" aria-label="Delete" title="Delete">${TRASH}</button>`;
123
126
  const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
124
- // A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange once bumped and disabled on the latest
125
- // bump. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
127
+ // A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange and disabled once
128
+ // bumped. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
126
129
  // it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
127
130
  // docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
128
131
  const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
@@ -379,7 +382,7 @@
379
382
  removed: { line: 'This board is gone', hint: 'The hub no longer lists it.', retries: false },
380
383
  access: { line: 'You no longer have access', hint: 'Ask its owner to share it again.', retries: false },
381
384
  erroring: { line: 'Board restarting', hint: 'It answers with errors. Retrying for 30 seconds.' },
382
- unreachable: { line: 'Board offline', hint: 'The hub, your connection or the Mac is down. Retrying.' },
385
+ unreachable: { line: 'Board offline', hint: 'The hub, your connection or the host is down. Retrying.' },
383
386
  };
384
387
  const RESTART_WHY = { code: 'Its code changed.', starting: 'It’s starting.', crashed: 'It stopped, and the hub is starting it again.' };
385
388
  function downOf(answer, online = navigator.onLine !== false) {
@@ -184,7 +184,9 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
184
184
  answer(out, action) {
185
185
  if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
186
186
  last = out; flash = null; paint();
187
- if (action.endsWith('/keep-going')) B.toast(out.pause.state === 'going' ? `Keep going: work runs past the buffer until ${BoardUI.usageClock(out.pause.resets_at)}.` : 'Stopped: new work waits again.');
187
+ if (action.endsWith('/keep-going')) B.toast(out.pause.state === 'going' ? `Keep going: work runs past the buffer until ${BoardUI.usageClock(out.pause.resets_at)}.`
188
+ : out.pause.near?.going ? `Keep going: agents start in parallel until ${BoardUI.usageClock(out.pause.near.resets_at)}.`
189
+ : out.pause.near ? 'Stopped: agents start one at a time again.' : 'Stopped: new work waits again.');
188
190
  } else if (action === '/_hub/push/flush') { flash = { text: out.message, error: !out.ok }; load(); } else { flash = null; load(); settings.B.refreshSettings?.(); }
189
191
  ctx.sidebarChanged?.();
190
192
  },
@@ -4,7 +4,7 @@
4
4
  (globalThis.AppViews ||= {}).notes = {
5
5
  className: 'view-notes',
6
6
  title: () => 'Notes',
7
- markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#item=${encodeURIComponent(item || '')}`, controls: false })}
7
+ markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#selectedCard=${encodeURIComponent(item || '')}`, controls: false })}
8
8
  <main>
9
9
  <article class="plan" id="plan"><p class="muted">Loading the notes…</p></article>
10
10
  </main>`,
@@ -11,7 +11,7 @@
11
11
  // The question then reads prototype requested, locked, until its prototype round arrives, and once the request is
12
12
  // recorded the page moves on as a Send does (docs/adr/2026-11-03-1803-a-question-asked-for-a-prototype-is-resolved.md,
13
13
  // docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
14
- // A question the owner has sent an answer to is resolved: read-only, green, with a Resolved badge. One that resolves
14
+ // A question the owner has sent an answer to is resolved: read-only, with a Resolved badge. One that resolves
15
15
  // while its topic shows stays in place until you leave that topic, by a pill, a link or the move on after the last
16
16
  // Send; a topic's sent questions otherwise fold under "resolved (N)", which names only the count, above its open ones. A sent question's badge, once confirmed, sends `question-reopen {key?, question}`, and the question
17
17
  // opens again with its last pick and note for a new Send. Each question is numbered by its place on its card
@@ -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
@@ -150,10 +150,12 @@
150
150
  updateBar();
151
151
  }
152
152
 
153
+ // A toast names a topic by its card's ref, the title up to its colon; an ask by its time.
154
+ const toastName = (t) => (t.askedAt ? topicName(t) : String(t.title).match(/^(\S+):/)?.[1] || t.title);
153
155
  // The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
154
156
  function leaving() {
155
157
  const from = soloId ? topicOf(soloId)?.id : topicId;
156
- return { from, pills: topicRow(topics, from).pills, name: topicName(topics.find((t) => t.id === from) || { title: '' }) };
158
+ return { from, pills: topicRow(topics, from).pills, name: toastName(topics.find((t) => t.id === from) || { title: '' }) };
157
159
  }
158
160
  // After a recorded tap: a load that started before it went out would read its question as still open.
159
161
  // `footer` is a line or, for a Send, a function of the reloaded board giving the whole line.
@@ -364,6 +366,14 @@
364
366
  scroll(0);
365
367
  }
366
368
 
369
+ let lastTap = { id: null, at: 0 };
370
+ // item:<key> is a card, wave:<slug> a band. Embedded, the board is beside the pane; alone, it is the page before this one.
371
+ function openOnBoard(id) {
372
+ if (ctx.embedded) return ctx.openOnBoard(id);
373
+ if (!id.startsWith('item:')) return;
374
+ const href = new URL(`./#item=${encodeURIComponent(id.slice(5))}`, location.href).href;
375
+ if (!AppShell.open(href)) location.assign(href);
376
+ }
367
377
  B.on(root, 'click', (ev) => {
368
378
  if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
369
379
  const eye = ev.target.closest && ev.target.closest('[data-proto]');
@@ -372,9 +382,14 @@
372
382
  if (reopenBadge) { if (!reopenBadge.disabled) reopen(byId(reopenBadge.dataset.reopen), reopenBadge); return; }
373
383
  const b = ev.target.closest && ev.target.closest('[data-topic]');
374
384
  if (b) {
385
+ // Two taps on one pill within 300ms open its card on the board; a touch screen sends no dblclick.
386
+ const now = Date.now();
387
+ if (lastTap.id === b.dataset.topic && now - lastTap.at < 300) { lastTap = { id: null, at: 0 }; openOnBoard(b.dataset.topic); return; }
388
+ lastTap = { id: b.dataset.topic, at: now };
375
389
  soloId = null; setTopic(b.dataset.topic); sentLine = ''; shown = '';
376
390
  const first = topics.find((t) => t.id === topicId)?.questions[0];
377
391
  if (first) setHash(first);
392
+ ctx.onTopic?.(topicId);
378
393
  render(); scroll(0);
379
394
  }
380
395
  });
@@ -441,6 +456,7 @@
441
456
  if (!loaded) return false;
442
457
  const topic = topics.find((t) => t.id === id);
443
458
  if (topic) { soloId = null; setTopic(topic.id); } else pick(id || '');
459
+ ctx.onTopic?.(topicId);
444
460
  sentLine = '';
445
461
  shown = '';
446
462
  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>
@@ -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
  });