@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.
- package/CONTEXT.md +60 -48
- package/SKILL.md +20 -14
- package/bin/event-lines.mjs +1 -0
- package/bin/kickflip.mjs +8 -8
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +118 -104
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/hub-lines.mjs +1 -0
- package/board/onboarding.mjs +2 -2
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +3 -3
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +17 -9
- package/board/public/app-header.js +1 -1
- package/board/public/app-lanes.js +39 -61
- package/board/public/app-needs.js +116 -30
- package/board/public/app-reader.js +54 -44
- package/board/public/app-threads.js +2 -2
- package/board/public/app.html +1 -1
- package/board/public/app.js +23 -13
- package/board/public/dialogs.js +1 -1
- package/board/public/hub-kit.js +1 -1
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +1 -1
- package/board/public/new-task.js +1 -1
- package/board/public/progress-review.js +4 -4
- package/board/public/prototype-kit.js +27 -16
- package/board/public/prototype-switcher.js +47 -32
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +35 -7
- package/board/public/shell.js +19 -13
- package/board/public/sidebar.css +4 -4
- package/board/public/sidebar.js +2 -2
- package/board/public/style.css +74 -36
- package/board/public/ui-cards.js +56 -37
- package/board/public/ui-enums.js +10 -1
- package/board/public/ui-hub.js +1 -5
- package/board/public/ui-links.js +6 -6
- package/board/public/ui-order.js +8 -6
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +30 -13
- package/board/public/ui-sidebar.js +3 -2
- package/board/public/ui-threads.js +20 -19
- package/board/public/ui.js +15 -4
- package/board/public/view-hub-settings.js +28 -31
- package/board/public/view-hub.js +7 -9
- package/board/public/view-notes.js +2 -2
- package/board/public/view-questions.js +35 -6
- package/board/public/view-settings.js +63 -31
- package/board/render-check.mjs +10 -10
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -20
- package/board/stop.mjs +1 -1
- package/board/store.mjs +26 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
- package/hub/awake.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/github.mjs +3 -3
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/try.mjs +6 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/tailscale.mjs +1 -1
- 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))}
|
|
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
|
|
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:
|
|
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 =
|
|
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.
|
|
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
|
|
4
|
-
//
|
|
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
|
-
<
|
|
80
|
-
<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
|
|
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.
|
|
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
|
|
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
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
373
|
-
$('#
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
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
|
|
387
|
-
if (b)
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
picked.slack
|
|
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
|
});
|
package/board/render-check.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
var
|
|
2
|
-
`)[0];let
|
|
3
|
-
`)[0]||
|
|
4
|
-
`)[0]}`):
|
|
5
|
-
${
|
|
6
|
-
`)}`);return{origin:`http://127.0.0.1:${f}`,dir:
|
|
7
|
-
${
|
|
8
|
-
`)}`)}finally{await
|
|
9
|
-
${
|
|
10
|
-
`)}`)}finally{
|
|
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
|
|
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};
|