@lovek4d/kickflip 0.7.0 → 0.7.2
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 +53 -49
- package/SKILL.md +17 -13
- package/bin/event-lines.mjs +1 -1
- package/bin/kickflip.mjs +3 -3
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +113 -105
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -8
- package/board/hub-lines.mjs +1 -0
- package/board/onboarding.mjs +3 -3
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +6 -7
- package/board/public/app-header.js +1 -1
- package/board/public/app-lanes.js +39 -62
- package/board/public/app-needs.js +16 -11
- package/board/public/app-reader.js +9 -5
- package/board/public/app.js +3 -3
- package/board/public/attach.js +18 -2
- package/board/public/dialogs.js +11 -13
- package/board/public/hub-kit.js +1 -1
- package/board/public/hub.css +0 -1
- package/board/public/linear-key.js +2 -2
- package/board/public/new-task.js +1 -1
- package/board/public/prototype-kit.js +2 -5
- package/board/public/router.js +1 -1
- package/board/public/sidebar.css +3 -3
- package/board/public/sidebar.js +2 -2
- package/board/public/style.css +43 -43
- package/board/public/ui-cards.js +25 -20
- package/board/public/ui-hub.js +1 -5
- package/board/public/ui-links.js +5 -5
- package/board/public/ui-order.js +6 -7
- package/board/public/ui-questions.js +17 -13
- package/board/public/ui-sidebar.js +1 -1
- package/board/public/ui-threads.js +29 -21
- package/board/public/ui.js +23 -6
- package/board/public/view-hub-settings.js +11 -19
- package/board/public/view-hub.js +25 -11
- package/board/public/view-notes.js +1 -1
- package/board/public/view-questions.js +18 -5
- package/board/public/view-settings.js +7 -7
- package/board/render-check.mjs +10 -10
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -19
- package/board/stop.mjs +1 -1
- package/board/store.mjs +26 -26
- package/board/tickets.mjs +3 -3
- package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
- package/hub/awake.mjs +1 -1
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/linear.mjs +1 -1
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/readiness.mjs +1 -1
- package/hub/review-send.mjs +1 -1
- package/hub/slack.mjs +1 -1
- package/hub/try.mjs +6 -7
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/usage-gate.mjs +1 -1
- package/starter/server.mjs +3 -3
package/board/public/view-hub.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The hub page, /_hub/, a view of the app shell (shell.js): a banner while pushes are held or snoozed, the Usage card with Claude model and Codex share, Needs you for the pending
|
|
2
2
|
// prompts of sessions that own no board, Health, Integrations (readiness.mjs words), each site's health and board links,
|
|
3
|
-
//
|
|
3
|
+
// then hub settings summary rows ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)). Health carries the Keep awake toggle, and Linear's and Slack's Integrations rows
|
|
4
|
+
// a button that opens their key modal ([Linear and Slack keys open in modals from their Integrations rows](docs/adr/2026-11-09-2230-linear-and-slack-keys-open-in-modals-from-their-integrations-rows.md)). A board's row can carry Restart (hub/restart-board.mjs), and
|
|
4
5
|
// the Hub row Restart hub while the hub runs older code than its files. Health comes from GET /_hub/api/hub.
|
|
5
6
|
// Its data refreshes every 30 seconds while it shows (hub-kit.js live).
|
|
6
7
|
(function () {
|
|
@@ -43,14 +44,25 @@
|
|
|
43
44
|
const mine = (() => { try { return localStorage.getItem('kickflip.push.id'); } catch { return null; } })();
|
|
44
45
|
const deviceLine = (d) => `<span class="rd-device">${esc([d.kind, d.browser, `seen ${BoardUI.ago(d.added)}`].filter(Boolean).join(' · '))}${d.id === mine ? ' · <b>this device</b>' : ''} <button type="button" class="link" data-forget-device="${esc(d.id)}">Remove</button></span>`;
|
|
45
46
|
const devices = (it) => it.devices?.length ? `<span class="links">${it.devices.map(deviceLine).join('')}</span>` : '';
|
|
46
|
-
|
|
47
|
+
// Linear and Slack carry a key button instead of a fix, in both states.
|
|
48
|
+
const keyButton = (it) => it.keyButton ? `<button type="button" class="btn quiet" data-settings-open="${esc(it.id)}" aria-haspopup="dialog">${esc(it.keyButton)}</button>` : '';
|
|
49
|
+
const missingLinks = (it) => it.fix || it.waits
|
|
50
|
+
? `<span class="links">${it.fix ? `<span class="rd-fix">${fixHtml(it.fix)}</span>` : ''}${it.waits ? `<span class="rd-waits">${esc(it.waits)}</span>` : ''}</span>` : '';
|
|
51
|
+
const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true, trail: keyButton(it),
|
|
47
52
|
sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}${devices(it)}`
|
|
48
|
-
: `<span class="st wait">not ready</span> · ${esc(it.missing)}
|
|
53
|
+
: `<span class="st wait">not ready</span> · ${esc(it.missing)}${missingLinks(it)}` })).join('');
|
|
49
54
|
return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
|
|
50
55
|
<p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
|
|
51
56
|
`;
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
// Keep awake (docs/adr/2026-11-09-1200-keep-awake-is-a-toggle-that-holds-the-mac-awake-only-on-ac-power.md): grey while off, orange while on as the machine working
|
|
60
|
+
// (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md). Unchecked posts nothing, which the hub reads as off. Off macOS it says why instead of a toggle.
|
|
61
|
+
const AWAKE_NOTE = 'Runs <code>caffeinate -s</code> on the host so it doesn’t sleep while boards work, only while it is on AC power. On battery it holds nothing. The screen can still turn off.';
|
|
62
|
+
const awakeRow = (a) => a.unsupported
|
|
63
|
+
? HubUI.row({ icon: HubUI.tile('coffee', 'var(--color-quiet)'), title: 'Keep awake', sub: esc(a.unsupported), wrap: true, attrs: ' id="keep-awake"' })
|
|
64
|
+
: `<form class="row field" id="keep-awake" method="post" action="/_hub/settings/keep-awake">${HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)')}<label class="body"><span class="text"><span class="title">Keep awake</span><span class="sub wrap">${AWAKE_NOTE}</span></span><input type="checkbox" id="awake" name="keep_awake" value="on" class="toggle" data-submit${a.on ? ' checked' : ''}></label></form>`;
|
|
65
|
+
|
|
54
66
|
// A pending prompt from a session that owns no board waits here (prompts.js).
|
|
55
67
|
function promptsHtml(list, prompts) {
|
|
56
68
|
const shown = list.filter(prompts.shows);
|
|
@@ -62,20 +74,19 @@
|
|
|
62
74
|
const restartHubButton = (busy) => `<button type="button" class="btn quiet restart-btn" id="restart-hub" data-restart-hub${busy ? BoardUI.BUSY : ''}>Restart hub</button>`;
|
|
63
75
|
|
|
64
76
|
function html(d, { flash, prompts, restarting, restartingHub }) {
|
|
65
|
-
const { row, tile
|
|
77
|
+
const { row, tile } = HubUI;
|
|
66
78
|
const usage = HubUI.usageCard(d.usage, d.codex, d.pause, d.claude);
|
|
67
79
|
// The status word is plain text, except while the hub runs older code: then it opens the Restart hub confirm and waits like that button.
|
|
68
80
|
const word = d.hub.restart
|
|
69
81
|
? `<button type="button" class="st ${esc(d.hub.tone)}" data-restart-hub${restartingHub ? BoardUI.BUSY : ''}>${esc(d.hub.word)}</button>`
|
|
70
82
|
: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span>`;
|
|
71
83
|
return `${HubUI.flash(flash)}${HubUI.pauseBanner(d.pause)}${HubUI.holdBanner(d.hold)}${usage}${promptsHtml(d.prompts || [], prompts)}<h2 class="label"${usage ? '' : ' style="margin-top:4px"'}>Health</h2>
|
|
72
|
-
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `${word} ·${esc(d.hub.detail)}
|
|
84
|
+
<div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `${word} ·${esc(d.hub.detail)}`, wrap: true,
|
|
73
85
|
trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
|
|
74
86
|
${HubUI.machineRow(d.pause.machine)}
|
|
87
|
+
${awakeRow(d.awake)}
|
|
75
88
|
${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip <route></code>.' }) : ''}</div>
|
|
76
|
-
${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, the fetch of its repo, events (its session and a watcher run, or the hub can wake it), daily wakes and reviewer links. One that just broke reads settling first.</p
|
|
77
|
-
<h2 class="label">Polling status</h2>
|
|
78
|
-
<div class="group">${nowRow(d.polling, tile('clock', d.polling.count ? 'var(--color-notice)' : 'var(--color-quiet)'))}</div>`;
|
|
89
|
+
${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembered?.length ? `<h2 class="label">Sessions without a board</h2><div class="group">${rememberedHtml(d.remembered)}</div>` : ''}<p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, the fetch of its repo, events (its session and a watcher run, or the hub can wake it), daily wakes and reviewer links. One that just broke reads settling first.</p>`;
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip <route></code>. This page refreshes every 30 seconds.<br><span class="host">${esc(d.host)}</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v${esc(d.version)}</span></footer>`;
|
|
@@ -90,7 +101,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
90
101
|
className: 'hub-view view-hub',
|
|
91
102
|
title: () => 'Hub',
|
|
92
103
|
markup: () => `${BoardUI.header({ lead: 'logo', title: 'Hub', time: 'now', logoSrc: '/_hub/kickflip.png' })}
|
|
93
|
-
<main class="has-top"><div class="hub-status"></div><section class="hub-settings"
|
|
104
|
+
<main class="has-top"><div class="hub-status"></div><section class="hub-settings">${HubSettings.markup()}</section><div class="hub-foot"></div></main>`,
|
|
94
105
|
|
|
95
106
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
96
107
|
const main = B.$('.hub-status');
|
|
@@ -168,10 +179,13 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
168
179
|
if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
|
|
169
180
|
});
|
|
170
181
|
const foot = B.$('.hub-foot');
|
|
182
|
+
// A #keep-awake link scrolls to Health's Keep awake row once it is drawn.
|
|
183
|
+
let scrollToAwake = ctx.view?.hash === '#keep-awake';
|
|
171
184
|
const paint = () => {
|
|
172
185
|
if (!last) return;
|
|
173
186
|
main.innerHTML = html(last, { flash, prompts, restarting, restartingHub });
|
|
174
187
|
foot.innerHTML = footer(last);
|
|
188
|
+
if (scrollToAwake) { scrollToAwake = false; B.$('#keep-awake')?.scrollIntoView(); }
|
|
175
189
|
};
|
|
176
190
|
const prompts = PromptAnswers.wire(B, {
|
|
177
191
|
list: () => last?.prompts || [],
|
|
@@ -180,14 +194,14 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
|
|
|
180
194
|
after: () => load(),
|
|
181
195
|
});
|
|
182
196
|
const forms = HubKit.forms(B, {
|
|
183
|
-
accepts: (action, form) =>
|
|
197
|
+
accepts: (action, form) => ['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going', '/_hub/settings/keep-awake'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
|
|
184
198
|
answer(out, action) {
|
|
185
199
|
if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
|
|
186
200
|
last = out; flash = null; paint();
|
|
187
201
|
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
202
|
: out.pause.near?.going ? `Keep going: agents start in parallel until ${BoardUI.usageClock(out.pause.near.resets_at)}.`
|
|
189
203
|
: out.pause.near ? 'Stopped: agents start one at a time again.' : 'Stopped: new work waits again.');
|
|
190
|
-
} else
|
|
204
|
+
} else { flash = null; load(); settings.B.refreshSettings?.(); }
|
|
191
205
|
ctx.sidebarChanged?.();
|
|
192
206
|
},
|
|
193
207
|
fail(message) { flash = { text: message, error: true }; paint(); },
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// An item's notes, /<route>/notes.html?item=<
|
|
1
|
+
// An item's notes, /<route>/notes.html?item=<file>/<id>, a view of the app shell (shell.js)
|
|
2
2
|
// (docs/adr/2026-10-29-1816-notes-replace-the-full-plan-and-cards-start-on-their-own.md). Back goes to the item's card.
|
|
3
3
|
// ?wave=<slug> shows a wave's goal, order and note instead, and back goes to the board.
|
|
4
4
|
(globalThis.AppViews ||= {}).notes = {
|
|
@@ -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,7 +146,7 @@
|
|
|
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();
|
|
@@ -212,7 +214,7 @@
|
|
|
212
214
|
const qs = visible();
|
|
213
215
|
const n = pending(qs).length;
|
|
214
216
|
const b = $('#send');
|
|
215
|
-
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'; }
|
|
216
218
|
const live = inForm(qs);
|
|
217
219
|
const answered = live.filter((q) => q.state !== 'open').length;
|
|
218
220
|
// docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
|
|
@@ -367,7 +369,7 @@
|
|
|
367
369
|
}
|
|
368
370
|
|
|
369
371
|
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.
|
|
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.
|
|
371
373
|
function openOnBoard(id) {
|
|
372
374
|
if (ctx.embedded) return ctx.openOnBoard(id);
|
|
373
375
|
if (!id.startsWith('item:')) return;
|
|
@@ -397,14 +399,25 @@
|
|
|
397
399
|
const sec = ev.target.closest('section.q'); if (!sec || ev.target.type !== 'radio') return;
|
|
398
400
|
const d = getDraft(sec.dataset.q); d.choice = ev.target.value; setDraft(sec.dataset.q, d); updateBar();
|
|
399
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
|
+
});
|
|
400
413
|
B.on(root, 'input', (ev) => {
|
|
401
414
|
const sec = ev.target.closest('section.q'); if (!sec || ev.target.tagName !== 'TEXTAREA') return;
|
|
402
415
|
const d = getDraft(sec.dataset.q); d.note = ev.target.value; setDraft(sec.dataset.q, d); updateBar();
|
|
403
416
|
});
|
|
404
417
|
B.on(root, 'keydown', (ev) => {
|
|
405
|
-
if (ev.
|
|
418
|
+
if (ev.target.tagName !== 'TEXTAREA') return;
|
|
406
419
|
const form = ev.target.closest('#qform');
|
|
407
|
-
if (!form) return;
|
|
420
|
+
if (!form || !BoardUI.keyedSend(ev, form)) return;
|
|
408
421
|
ev.preventDefault();
|
|
409
422
|
form.requestSubmit();
|
|
410
423
|
});
|
|
@@ -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;
|
|
@@ -290,7 +290,7 @@
|
|
|
290
290
|
const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ tickets: value }) });
|
|
291
291
|
const out = await r.json();
|
|
292
292
|
if (!r.ok) throw new Error(out.error || 'Could not save tickets.');
|
|
293
|
-
savedTickets = out.settings.tickets;
|
|
293
|
+
savedTickets = out.settings.tickets || { source: 'none' };
|
|
294
294
|
return true;
|
|
295
295
|
} catch (e) {
|
|
296
296
|
if (B.alive()) ticketsStatus.textContent = e.message || 'Could not save. Is the host awake?';
|
|
@@ -486,8 +486,8 @@
|
|
|
486
486
|
pauseChoices = d.pause_choices || [];
|
|
487
487
|
paintPause(d.paused_until);
|
|
488
488
|
if (!B.alive()) return;
|
|
489
|
-
savedTickets = d.settings?.tickets ||
|
|
490
|
-
tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets
|
|
489
|
+
savedTickets = d.settings?.tickets || { source: 'none' };
|
|
490
|
+
tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
|
|
491
491
|
loadLinear: async () => (await get('/_hub/api/linear')).body,
|
|
492
492
|
keyState: async () => (await get('/_hub/api/settings')).body.linear,
|
|
493
493
|
change(_value, _valid, { loaded, typing } = {}) {
|
|
@@ -783,7 +783,7 @@
|
|
|
783
783
|
});
|
|
784
784
|
}).catch(() => { $('#rules').innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
|
|
785
785
|
B.watch((events) => {
|
|
786
|
-
$('#rulesReplies').innerHTML = (events || []).slice(0, 3).map((e) => `<div class="reply">${e.reply ? (e.reply
|
|
786
|
+
$('#rulesReplies').innerHTML = (events || []).slice(0, 3).map((e) => `<div class="reply${e.reply?.md ? ' md' : ''}">${e.reply ? BoardUI.replyBody(e.reply) : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
|
|
787
787
|
}, { kind: 'rules' });
|
|
788
788
|
}),
|
|
789
789
|
};
|
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};
|