@lovek4d/kickflip 0.5.3 → 0.6.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.
- package/CONTEXT.md +69 -37
- package/SKILL.md +19 -13
- package/bin/kickflip.mjs +12 -12
- 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 +156 -83
- package/board/build-script.mjs +2 -1
- package/board/domain.mjs +9 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +9 -5
- package/board/onboarding.mjs +11 -0
- package/board/prototype-base.mjs +1 -0
- package/board/prototype-page.mjs +1 -0
- package/board/public/app-arch.js +1 -1
- package/board/public/app-cards.js +27 -13
- package/board/public/app-header.js +6 -7
- package/board/public/app-lanes.js +15 -38
- package/board/public/app-needs.js +36 -31
- package/board/public/app-snapshot.js +43 -70
- package/board/public/app-threads.js +38 -46
- package/board/public/app.html +9 -4
- package/board/public/app.js +78 -32
- package/board/public/hub-kit.js +62 -2
- package/board/public/hub.css +2 -0
- package/board/public/new-task.js +8 -6
- package/board/public/progress-review.html +46 -0
- package/board/public/progress-review.js +158 -0
- package/board/public/prototype-kit.js +109 -4
- package/board/public/prototype-switcher.js +123 -29
- package/board/public/prototype.html +57 -3
- package/board/public/router.js +7 -6
- package/board/public/shell.js +64 -24
- package/board/public/sidebar.css +1 -0
- package/board/public/sidebar.js +16 -69
- package/board/public/style.css +108 -57
- package/board/public/ui-cards.js +61 -46
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-header.js +25 -16
- package/board/public/ui-hub.js +12 -1
- package/board/public/ui-links.js +7 -7
- package/board/public/ui-page-header.js +19 -7
- package/board/public/ui-patch.js +7 -33
- package/board/public/ui-progress-bar.js +28 -0
- package/board/public/ui-questions.js +25 -17
- package/board/public/ui-sidebar.js +25 -23
- package/board/public/ui-snapshot.js +43 -52
- package/board/public/ui-threads.js +57 -28
- package/board/public/ui.js +92 -17
- package/board/public/view-hub-settings.js +12 -58
- package/board/public/view-hub.js +2 -1
- package/board/public/view-notes.js +27 -7
- package/board/public/view-questions.js +90 -68
- package/board/public/view-settings.js +133 -15
- package/board/render-check.mjs +10 -0
- package/board/review.mjs +1 -1
- package/board/server.mjs +19 -19
- package/board/stop.mjs +1 -0
- package/board/store.mjs +27 -18
- package/board/sync.mjs +2 -0
- package/board/tickets.mjs +3 -3
- package/board/worktree.mjs +22 -21
- package/hooks-plugin/hooks/usage-gate.mjs +20 -3
- package/hub/board-repo.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +1 -1
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/pr-report.mjs +2 -2
- package/hub/review-send.mjs +1 -1
- package/hub/sessions.mjs +2 -2
- package/hub/share.mjs +1 -1
- package/hub/slack.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/usage.mjs +3 -3
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/card-prs.mjs +1 -1
- package/shared/machine-gate.mjs +2 -0
- package/shared/prototype-frame.js +1 -0
- package/shared/usage-gate.mjs +1 -1
- package/board/public/ui-phase-bars.js +0 -17
|
@@ -4,16 +4,24 @@
|
|
|
4
4
|
// (GET api/reviewer-people), or a typed login, and stays off until both are chosen. 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
|
-
// lists the hub's GET /_hub/share/<route>, who
|
|
8
|
-
// through POST api/share.
|
|
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
|
|
8
|
+
// each Add, level and ✕ at once through POST api/share.
|
|
9
9
|
(globalThis.AppViews ||= {}).settings = {
|
|
10
10
|
className: 'view-settings',
|
|
11
11
|
title: () => 'Board Settings',
|
|
12
12
|
markup: (view, { siteTitle = '' } = {}) => `<dialog class="settings-modal" id="boardSettingsModal" aria-labelledby="boardSettingsTitle"><div class="settings-head"><div><h2 id="boardSettingsTitle" tabindex="-1">Board settings</h2><p class="muted small">${BoardUI.esc(siteTitle)}</p></div><button type="button" class="ghost" data-settings-dismiss aria-label="Close board settings">✕</button></div>
|
|
13
13
|
<div class="set settings-body">
|
|
14
|
+
<h2>Pause board</h2>
|
|
15
|
+
<div class="hub-view"><div class="group" id="pauseBoard"><p class="muted small block">Loading…</p></div></div>
|
|
16
|
+
<p class="muted small">Running steps finish. No new builders, planners or pulls start on this board until then, as when usage reaches the pause line.</p>
|
|
17
|
+
<p class="muted small" id="pauseBoardStatus" role="status"></p>
|
|
14
18
|
<h2>Repo</h2>
|
|
15
19
|
<button type="button" class="who repo-row" id="boardRepo" aria-haspopup="dialog" disabled><span id="boardRepoValue">Loading…</span><span class="muted small" id="boardRepoChange">Change ›</span></button>
|
|
16
20
|
<p class="muted small" id="boardRepoStatus"></p>
|
|
21
|
+
<label class="who" for="buildTarget"><span><span>Build target</span>
|
|
22
|
+
<span class="muted small sub" id="buildTargetSub">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</span></span>
|
|
23
|
+
<select id="buildTarget" disabled><option value="">Default</option></select></label>
|
|
24
|
+
<p class="muted small" id="buildTargetStatus" role="status"></p>
|
|
17
25
|
<h2>Tickets</h2>
|
|
18
26
|
<p class="muted small">Every card on this board has a ticket from here. A new card gets a new ticket unless you name one.</p>
|
|
19
27
|
<form id="ticketsForm" class="tk-section"><div id="boardTickets">Loading…</div></form>
|
|
@@ -34,7 +42,7 @@
|
|
|
34
42
|
<input type="checkbox" class="toggle" id="autoPull" disabled></label>
|
|
35
43
|
<p class="muted small" id="autoPullStatus" role="status"></p>
|
|
36
44
|
<label class="who" for="maxImplementers"><span><span>Maximum parallel implementers</span>
|
|
37
|
-
<span class="muted small sub">
|
|
45
|
+
<span class="muted small sub">Each agent that builds or runs tests counts, a card’s helpers included. Grills, read-only helpers and cards waiting in Orchestrator review or on you don’t.</span></span>
|
|
38
46
|
<input type="number" class="count" id="maxImplementers" min="1" max="99" step="1" inputmode="numeric" disabled></label>
|
|
39
47
|
<p class="muted small" id="maxImplementersStatus" role="status"></p>
|
|
40
48
|
<label class="who" for="maxPlanners"><span><span>Maximum parallel planners</span>
|
|
@@ -61,7 +69,6 @@
|
|
|
61
69
|
<span class="muted small sub">Pushes once when the board has been idle for 15 minutes, with what closed since the last one.</span></span>
|
|
62
70
|
<input type="checkbox" class="toggle" id="idleNotification" disabled></label>
|
|
63
71
|
<p class="muted small" id="idleNotificationStatus" role="status"></p>
|
|
64
|
-
<p class="muted small">The hub runs <code>.kickflip/build</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.</p>
|
|
65
72
|
<h2>Permissions</h2>
|
|
66
73
|
<div id="permissions"><p class="muted small">Loading…</p></div>
|
|
67
74
|
<div id="reviewersSection" hidden>
|
|
@@ -77,11 +84,11 @@
|
|
|
77
84
|
<div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
|
|
78
85
|
</div>
|
|
79
86
|
<h2>Sharing</h2>
|
|
80
|
-
<p class="muted small">
|
|
87
|
+
<p class="muted small">View, or Full: everything but Merge and Board settings. Nothing else on the host opens for them.</p>
|
|
81
88
|
<div id="shareList"><p class="muted small">Loading…</p></div>
|
|
82
89
|
<div id="shareRefused"></div>
|
|
83
90
|
<form class="add" id="shareAdd"><input id="shareLogin" placeholder="Add by login, like name@github" autocomplete="off" autocapitalize="off" spellcheck="false"><button type="submit">Add</button></form>
|
|
84
|
-
<div class="share-reach"><p class="muted small">They also need the host shared to them in Tailscale (<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener">Tailscale’s Machines page
|
|
91
|
+
<div class="share-reach"><p class="muted small">They also need the host shared to them in Tailscale (<a href="https://login.tailscale.com/admin/machines" target="_blank" rel="noopener">Tailscale’s Machines page ${BoardUI.LINK_ARROW}</a>). Then send them this link.</p><button type="button" id="shareCopy">Copy link</button></div>
|
|
85
92
|
<p class="muted small" id="shareStatus" role="status"></p>
|
|
86
93
|
<h2>Rules</h2>
|
|
87
94
|
<p class="muted small">Standing instructions for this board's Claude session. It sees them each time it wakes, and says what changes when you save.</p>
|
|
@@ -107,8 +114,31 @@
|
|
|
107
114
|
|
|
108
115
|
create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
|
|
109
116
|
const { $, site } = B;
|
|
110
|
-
const { esc, fetchJSON, restartNotice } = BoardUI;
|
|
111
|
-
|
|
117
|
+
const { esc, fetchJSON, restartNotice, pageCopy, copyNote, downLanding } = BoardUI;
|
|
118
|
+
// While the board can't be reached, each part reads its last answer here and the whole body turns read only under
|
|
119
|
+
// the copy line, reopening live once the board answers; a part with no copy waits out a restart, then the body shows
|
|
120
|
+
// the landing (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
|
|
121
|
+
const settingsBody = $('.settings-body');
|
|
122
|
+
let readOnly = false;
|
|
123
|
+
function fromCopy(savedAt) {
|
|
124
|
+
if (readOnly) return;
|
|
125
|
+
readOnly = true;
|
|
126
|
+
settingsBody.inert = true;
|
|
127
|
+
settingsBody.insertAdjacentHTML('afterbegin', copyNote('Settings', savedAt));
|
|
128
|
+
B.every(5000, () => { fetchJSON(site.url('api/settings'), { limit: 0 }).then(() => { if (B.alive()) ctx.reload(); }, () => {}); });
|
|
129
|
+
}
|
|
130
|
+
function down(e) {
|
|
131
|
+
if (readOnly || root.classList.contains('down')) return;
|
|
132
|
+
root.classList.add('down');
|
|
133
|
+
settingsBody.insertAdjacentHTML('afterbegin', downLanding(e.down));
|
|
134
|
+
}
|
|
135
|
+
const saved = (url, r) => { if (r.ok) pageCopy.save(url, r.body); return r; };
|
|
136
|
+
async function get(url, onWait) {
|
|
137
|
+
try { return saved(url, await fetchJSON(url, { limit: 0, sleep: B.sleep })); } catch {}
|
|
138
|
+
const c = await pageCopy.read(url);
|
|
139
|
+
if (c) { fromCopy(c.savedAt); return { ok: true, status: 200, body: c.body }; }
|
|
140
|
+
try { return saved(url, await fetchJSON(url, { onWait, sleep: B.sleep })); } catch (e) { down(e); throw e; }
|
|
141
|
+
}
|
|
112
142
|
const waitIn = (sel) => () => { $(sel).innerHTML = restartNotice(); };
|
|
113
143
|
const modal = $('#boardSettingsModal');
|
|
114
144
|
const previous = document.activeElement;
|
|
@@ -200,7 +230,8 @@
|
|
|
200
230
|
const repoURL = `/_hub/board-repo/${encodeURIComponent(B.route)}`;
|
|
201
231
|
let repo = {}, closeRepo = () => {};
|
|
202
232
|
let tickets, savedTickets, savingTickets = null, ticketsTouched = false;
|
|
203
|
-
// Landing shows on a board on GitHub that builds from its shared clone
|
|
233
|
+
// Landing shows on a board on GitHub that builds from its shared clone and on the hub board, which builds from its
|
|
234
|
+
// folder, starts on Merge and opens PRs on Draft PR or Ready PR; any other local board never opens PRs. Its choice is
|
|
204
235
|
// the board's prs and skip_draft settings read together: Draft PR, prs on and skip_draft off; Ready PR, both on;
|
|
205
236
|
// Merge, prs off. Reviewers show only while cards open PRs
|
|
206
237
|
// (docs/adr/2026-11-05-0200-landing-is-one-choice-draft-pr-ready-pr-or-merge.md).
|
|
@@ -212,9 +243,9 @@
|
|
|
212
243
|
let landing = 'draft';
|
|
213
244
|
const landingButtons = () => Object.keys(LANDING).map((value) => $(`[data-landing="${value}"]`));
|
|
214
245
|
const landingOf = (settings) => (settings?.prs === false ? 'merge' : settings?.skip_draft === true ? 'ready' : 'draft');
|
|
215
|
-
const opensPRs = () => Boolean(repo.github) && !repo.local && landing !== 'merge';
|
|
246
|
+
const opensPRs = () => Boolean(repo.github) && (!repo.local || Boolean(repo.hub)) && landing !== 'merge';
|
|
216
247
|
const syncPRs = () => {
|
|
217
|
-
$('#landingSection').hidden = !repo.github || Boolean(repo.local);
|
|
248
|
+
$('#landingSection').hidden = !repo.github || (Boolean(repo.local) && !repo.hub);
|
|
218
249
|
$('#reviewersSection').hidden = !opensPRs();
|
|
219
250
|
$('#landingSub').textContent = LANDING[landing].sub;
|
|
220
251
|
for (const button of landingButtons()) button.setAttribute('aria-pressed', String(button === $(`[data-landing="${landing}"]`)));
|
|
@@ -241,6 +272,7 @@
|
|
|
241
272
|
tickets?.render();
|
|
242
273
|
if (tickets) syncTickets();
|
|
243
274
|
showRepo();
|
|
275
|
+
loadBuildTargets();
|
|
244
276
|
$('#boardRepoStatus').textContent = 'Saved. The board’s session has been told.';
|
|
245
277
|
} });
|
|
246
278
|
});
|
|
@@ -383,7 +415,44 @@
|
|
|
383
415
|
const autoMerge = $('#autoMerge');
|
|
384
416
|
const autoMergeStatus = $('#autoMergeStatus');
|
|
385
417
|
const savedCaps = {};
|
|
418
|
+
let buildTarget = null, buildTargets = null;
|
|
419
|
+
// Pause board (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md): the hub's duration
|
|
420
|
+
// picker (hub-kit.js) with the Hold sheet's chips, Forever included, posting {pause} to api/pause at once. A Forever
|
|
421
|
+
// pause lasts until Resume.
|
|
422
|
+
const pauseBox = $('#pauseBoard');
|
|
423
|
+
const pickers = HubKit.durationPickers(B);
|
|
424
|
+
let pauseChoices = [];
|
|
425
|
+
const pausedWords = (end, now) => (end === 'forever' ? 'until you resume' : `until ${BoardUI.heldUntilWords(end, now)}`);
|
|
426
|
+
function paintPause(pausedUntil) {
|
|
427
|
+
const now = new Date();
|
|
428
|
+
const choices = pauseChoices.map((c) => ({ value: c.value, label: c.label, until: pausedWords(c.ms === null ? 'forever' : new Date(now.getTime() + c.ms), now) }));
|
|
429
|
+
pauseBox.innerHTML = HubKit.durationPicker({ id: 'pauseBoard', title: 'Pause board', icon: '', action: site.url('api/pause'), name: 'pause', button: 'Resume',
|
|
430
|
+
choices: pausedUntil ? null : choices, on: pausedUntil ? { title: `Paused ${pausedWords(pausedUntil === 'forever' ? 'forever' : new Date(pausedUntil), now)}` } : null });
|
|
431
|
+
pickers.painted();
|
|
432
|
+
}
|
|
433
|
+
B.on(pauseBox, 'submit', async (e) => {
|
|
434
|
+
e.preventDefault();
|
|
435
|
+
const form = e.target;
|
|
436
|
+
const pause = e.submitter?.value ?? form.querySelector('input[type="hidden"]')?.value;
|
|
437
|
+
const status = $('#pauseBoardStatus');
|
|
438
|
+
try {
|
|
439
|
+
const out = await BoardUI.waitOn(e.submitter || form.querySelector('input:not([type="hidden"])'), (async () => {
|
|
440
|
+
const r = await fetch(site.url('api/pause'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ pause }) });
|
|
441
|
+
const body = await r.json().catch(() => ({}));
|
|
442
|
+
if (!r.ok) throw new Error(body.error);
|
|
443
|
+
return body;
|
|
444
|
+
})(), { group: pauseBox.querySelectorAll('button') });
|
|
445
|
+
if (!B.alive()) return;
|
|
446
|
+
paintPause(out.paused_until);
|
|
447
|
+
status.textContent = out.paused_until ? 'Paused. New work on this board waits.' : 'Resumed. New work starts again.';
|
|
448
|
+
ctx.sidebarChanged?.();
|
|
449
|
+
} catch (error) {
|
|
450
|
+
if (B.alive()) status.textContent = error.message || 'Could not save. Is the host awake?';
|
|
451
|
+
}
|
|
452
|
+
});
|
|
386
453
|
get(site.url('api/settings'), waitIn('#autoMergeStatus')).then(({ body: d }) => {
|
|
454
|
+
pauseChoices = d.pause_choices || [];
|
|
455
|
+
paintPause(d.paused_until);
|
|
387
456
|
if (!B.alive()) return;
|
|
388
457
|
savedTickets = d.settings?.tickets || null;
|
|
389
458
|
tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
|
|
@@ -403,6 +472,8 @@
|
|
|
403
472
|
autoMerge.disabled = false;
|
|
404
473
|
$('#autoBuild').checked = d.settings?.auto_build === true;
|
|
405
474
|
$('#autoBuild').disabled = false;
|
|
475
|
+
buildTarget = d.settings?.build_target || '';
|
|
476
|
+
showBuildTargets();
|
|
406
477
|
$('#autoPull').checked = d.settings?.auto_pull === true;
|
|
407
478
|
$('#autoPull').disabled = false;
|
|
408
479
|
for (const [id, key] of [['maxImplementers', 'max_implementers'], ['maxPlanners', 'max_planners']]) {
|
|
@@ -446,6 +517,43 @@
|
|
|
446
517
|
}
|
|
447
518
|
});
|
|
448
519
|
|
|
520
|
+
// Build target lists the repo's .kickflip/build-* scripts at main's tip and saves a pick at once, null for the
|
|
521
|
+
// default .kickflip/build (docs/adr/2026-11-05-1823-board-settings-picks-which-build-script-a-board-runs.md).
|
|
522
|
+
// A saved target the tip no longer has stays listed, marked, so the picker shows what the hub runs.
|
|
523
|
+
const buildScriptOf = (target) => (target ? `.kickflip/build-${target}` : '.kickflip/build');
|
|
524
|
+
const buildTargetSub = (target) => `The hub runs <code>${esc(buildScriptOf(target))}</code> from each card’s branch and from main. Claude wrote it when it set up this board; if builds break, file a card.`;
|
|
525
|
+
const showBuildTargets = () => {
|
|
526
|
+
if (buildTarget === null || buildTargets === null) return;
|
|
527
|
+
const select = $('#buildTarget');
|
|
528
|
+
const names = buildTargets.includes(buildTarget) || !buildTarget ? buildTargets : [...buildTargets, buildTarget];
|
|
529
|
+
select.innerHTML = [['', 'Default'], ...names.map((t) => [t, buildTargets.includes(t) ? t : `${t} (not on main)`])]
|
|
530
|
+
.map(([value, label]) => `<option value="${esc(value)}"${value && value === buildTarget ? ' selected' : ''}>${esc(label)}</option>`).join('');
|
|
531
|
+
select.value = buildTarget;
|
|
532
|
+
select.disabled = false;
|
|
533
|
+
$('#buildTargetSub').innerHTML = buildTargetSub(buildTarget);
|
|
534
|
+
};
|
|
535
|
+
// A new repo brings its own scripts, so a repo save lists them again.
|
|
536
|
+
const loadBuildTargets = () => get(site.url('api/build-targets')).then(({ body }) => {
|
|
537
|
+
if (!B.alive()) return;
|
|
538
|
+
buildTargets = body.targets;
|
|
539
|
+
showBuildTargets();
|
|
540
|
+
}).catch(() => { $('#buildTargetStatus').textContent = 'Could not list the repo’s build scripts. Is the host awake?'; });
|
|
541
|
+
loadBuildTargets();
|
|
542
|
+
B.on($('#buildTarget'), 'change', async () => {
|
|
543
|
+
const select = $('#buildTarget');
|
|
544
|
+
try {
|
|
545
|
+
const settings = await saveToggle(select, { build_target: select.value || null });
|
|
546
|
+
if (!B.alive()) return;
|
|
547
|
+
buildTarget = settings.build_target || '';
|
|
548
|
+
showBuildTargets();
|
|
549
|
+
$('#buildTargetStatus').textContent = `Saved. The hub runs ${buildScriptOf(buildTarget)} for each card and main.`;
|
|
550
|
+
} catch (e) {
|
|
551
|
+
if (!B.alive()) return;
|
|
552
|
+
select.value = buildTarget;
|
|
553
|
+
$('#buildTargetStatus').textContent = e.message || 'Could not save. Is the host awake?';
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
449
557
|
B.on($('#autoBuild'), 'change', async () => {
|
|
450
558
|
const toggle = $('#autoBuild');
|
|
451
559
|
const want = toggle.checked;
|
|
@@ -500,7 +608,7 @@
|
|
|
500
608
|
status.textContent = e.message || 'Could not save. Is the host awake?';
|
|
501
609
|
}
|
|
502
610
|
});
|
|
503
|
-
capInput('maxImplementers', 'max_implementers', (n) => `Saved. Up to ${n === 1 ? '1
|
|
611
|
+
capInput('maxImplementers', 'max_implementers', (n) => `Saved. Up to ${n === 1 ? '1 agent builds' : `${n} agents build`} at once.`);
|
|
504
612
|
capInput('maxPlanners', 'max_planners', (n) => `Saved. Up to ${n === 1 ? '1 grill or prototype runs' : `${n} grills or prototypes run`} at once.`);
|
|
505
613
|
autoToggle('autoReview', 'auto_review', (on) => (on ? 'Saved. When idle, Claude reviews incoming PRs that touch this board’s work.' : 'Saved. Claude leaves incoming PRs alone.'));
|
|
506
614
|
autoToggle('idleNotification', 'idle_notification', (on) => (on ? 'Saved. One push once the board has been idle for 15 minutes.' : 'Saved. No push when the board goes idle.'));
|
|
@@ -527,13 +635,18 @@
|
|
|
527
635
|
// the owner reach the board (docs/adr/2026-11-04-2156-the-whole-hub-answers-only-the-owner.md). A tap waits on the server with the
|
|
528
636
|
// switch as it was and a spinner beside it, then draws the server's answer.
|
|
529
637
|
// Sharing: each row is the person's name, then their login and when; a login Tailscale sent no name with is its own.
|
|
638
|
+
// A person's row also sets them View or Full with one tap, saying what that level can do
|
|
639
|
+
// (docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md).
|
|
530
640
|
let sharing = null;
|
|
531
|
-
const
|
|
641
|
+
const CAN = { view: 'Looks only. Visit links open.', full: 'Answers, taps card buttons and adds New tasks. Merge and Board settings stay yours.' };
|
|
642
|
+
const shareRow = (title, sub, button, more = '') => `<div class="who"><span>${esc(title)}<span class="muted small sub">${sub}</span>${more}</span>${button}</div>`;
|
|
643
|
+
const shareLevel = (p) => `<span role="group" class="share-level" aria-label="${esc(p.name || p.login)}’s access">${[['view', 'View'], ['full', 'Full']].map(([level, label]) =>
|
|
644
|
+
`<button type="button" data-share-level="${level}" data-login="${esc(p.login)}" aria-pressed="${p.level === level}">${label}</button>`).join('')}</span><span class="muted small sub">${CAN[p.level]}</span>`;
|
|
532
645
|
const shareWhen = (p) => (p.now ? '<span class="share-now">viewing now</span>' : esc(p.at ? BoardUI.since(p.at) : 'not seen yet'));
|
|
533
646
|
const shareSub = (p, when) => (p.name ? `${esc(p.login)} · ${when}` : when);
|
|
534
647
|
function drawSharing() {
|
|
535
648
|
$('#shareList').innerHTML = sharing.people.length ? sharing.people.map((p) => shareRow(p.name || p.login, shareSub(p, shareWhen(p)),
|
|
536
|
-
`<button type="button" class="ghost" data-share-remove="${esc(p.login)}" aria-label="Remove ${esc(p.name || p.login)}">✕</button
|
|
649
|
+
`<button type="button" class="ghost" data-share-remove="${esc(p.login)}" aria-label="Remove ${esc(p.name || p.login)}">✕</button>`, shareLevel(p))).join('') : '<p class="muted small">Nobody yet.</p>';
|
|
537
650
|
$('#shareRefused').innerHTML = sharing.refused.length ? `<p class="muted small">Just refused. Tap to share with them:</p>${sharing.refused.map((r) => shareRow(r.name || r.login, shareSub(r, esc(BoardUI.since(r.at))),
|
|
538
651
|
`<button type="button" data-share-add="${esc(r.login)}">Add</button>`)).join('')}` : '';
|
|
539
652
|
}
|
|
@@ -557,7 +670,12 @@
|
|
|
557
670
|
}
|
|
558
671
|
}
|
|
559
672
|
loadSharing();
|
|
560
|
-
B.on($('#shareList'), 'click', (e) => {
|
|
673
|
+
B.on($('#shareList'), 'click', (e) => {
|
|
674
|
+
const b = e.target.closest?.('[data-share-remove]');
|
|
675
|
+
if (b) { saveShare({ remove: b.dataset.shareRemove }); return; }
|
|
676
|
+
const level = e.target.closest?.('[data-share-level]');
|
|
677
|
+
if (level) saveShare({ login: level.dataset.login, level: level.dataset.shareLevel });
|
|
678
|
+
});
|
|
561
679
|
B.on($('#shareRefused'), 'click', (e) => { const b = e.target.closest?.('[data-share-add]'); if (b) saveShare({ add: b.dataset.shareAdd }); });
|
|
562
680
|
B.on($('#shareAdd'), 'submit', async (e) => {
|
|
563
681
|
e.preventDefault?.();
|
|
@@ -0,0 +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};
|
package/board/review.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var d=Object.defineProperty;var
|
|
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};
|