@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.
Files changed (85) hide show
  1. package/CONTEXT.md +69 -37
  2. package/SKILL.md +19 -13
  3. package/bin/kickflip.mjs +12 -12
  4. package/bin/wait-events.mjs +2 -2
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +156 -83
  8. package/board/build-script.mjs +2 -1
  9. package/board/domain.mjs +9 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +9 -5
  12. package/board/onboarding.mjs +11 -0
  13. package/board/prototype-base.mjs +1 -0
  14. package/board/prototype-page.mjs +1 -0
  15. package/board/public/app-arch.js +1 -1
  16. package/board/public/app-cards.js +27 -13
  17. package/board/public/app-header.js +6 -7
  18. package/board/public/app-lanes.js +15 -38
  19. package/board/public/app-needs.js +36 -31
  20. package/board/public/app-snapshot.js +43 -70
  21. package/board/public/app-threads.js +38 -46
  22. package/board/public/app.html +9 -4
  23. package/board/public/app.js +78 -32
  24. package/board/public/hub-kit.js +62 -2
  25. package/board/public/hub.css +2 -0
  26. package/board/public/new-task.js +8 -6
  27. package/board/public/progress-review.html +46 -0
  28. package/board/public/progress-review.js +158 -0
  29. package/board/public/prototype-kit.js +109 -4
  30. package/board/public/prototype-switcher.js +123 -29
  31. package/board/public/prototype.html +57 -3
  32. package/board/public/router.js +7 -6
  33. package/board/public/shell.js +64 -24
  34. package/board/public/sidebar.css +1 -0
  35. package/board/public/sidebar.js +16 -69
  36. package/board/public/style.css +108 -57
  37. package/board/public/ui-cards.js +61 -46
  38. package/board/public/ui-enums.js +1 -1
  39. package/board/public/ui-header.js +25 -16
  40. package/board/public/ui-hub.js +12 -1
  41. package/board/public/ui-links.js +7 -7
  42. package/board/public/ui-page-header.js +19 -7
  43. package/board/public/ui-patch.js +7 -33
  44. package/board/public/ui-progress-bar.js +28 -0
  45. package/board/public/ui-questions.js +25 -17
  46. package/board/public/ui-sidebar.js +25 -23
  47. package/board/public/ui-snapshot.js +43 -52
  48. package/board/public/ui-threads.js +57 -28
  49. package/board/public/ui.js +92 -17
  50. package/board/public/view-hub-settings.js +12 -58
  51. package/board/public/view-hub.js +2 -1
  52. package/board/public/view-notes.js +27 -7
  53. package/board/public/view-questions.js +90 -68
  54. package/board/public/view-settings.js +133 -15
  55. package/board/render-check.mjs +10 -0
  56. package/board/review.mjs +1 -1
  57. package/board/server.mjs +19 -19
  58. package/board/stop.mjs +1 -0
  59. package/board/store.mjs +27 -18
  60. package/board/sync.mjs +2 -0
  61. package/board/tickets.mjs +3 -3
  62. package/board/worktree.mjs +22 -21
  63. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  64. package/hub/board-repo.mjs +1 -1
  65. package/hub/boards.mjs +4 -4
  66. package/hub/front-door.mjs +1 -1
  67. package/hub/github.mjs +3 -3
  68. package/hub/hub.mjs +11 -11
  69. package/hub/incoming.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/pr-report.mjs +2 -2
  72. package/hub/review-send.mjs +1 -1
  73. package/hub/sessions.mjs +2 -2
  74. package/hub/share.mjs +1 -1
  75. package/hub/slack.mjs +2 -2
  76. package/hub/try.mjs +7 -7
  77. package/hub/upgrade.mjs +1 -1
  78. package/hub/usage.mjs +3 -3
  79. package/hub/views.mjs +1 -1
  80. package/package.json +1 -1
  81. package/shared/card-prs.mjs +1 -1
  82. package/shared/machine-gate.mjs +2 -0
  83. package/shared/prototype-frame.js +1 -0
  84. package/shared/usage-gate.mjs +1 -1
  85. 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 can look and who was just refused, and saves each Add and ✕ at once
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">Cards building or waiting in Orchestrator review count. Grills and cards waiting on you don’t.</span></span>
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">People listed here can look at this board, view only. Nothing else on the host opens for them.</p>
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 ↗</a>). Then send them this link.</p><button type="button" id="shareCopy">Copy link</button></div>
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
- const get = (url, onWait) => fetchJSON(url, { onWait, sleep: B.sleep });
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; a local board never opens PRs. Its choice is
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 card builds or waits' : `${n} cards build or wait`} in Orchestrator review at once.`);
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 shareRow = (title, sub, button) => `<div class="who"><span>${esc(title)}<span class="muted small sub">${sub}</span></span>${button}</div>`;
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>`)).join('') : '<p class="muted small">Nobody yet.</p>';
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) => { const b = e.target.closest?.('[data-share-remove]'); if (b) saveShare({ remove: b.dataset.shareRemove }); });
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 l=(t,e)=>d(t,"name",{value:e,configurable:!0});import m from"node:fs";import p from"node:path";import w from"node:os";import{routeRecord as a}from"../hub/routes.mjs";import{storedPRs as b}from"../shared/card-prs.mjs";function N(){const t=p.join(w.homedir(),".claude","slack-bridge","topics"),e=Object.create(null);let r=[];try{r=m.readdirSync(t).filter(o=>o.endsWith(".json")).sort()}catch{}for(const o of r)try{const i=JSON.parse(m.readFileSync(p.join(t,o),"utf8"));for(const[f,s]of Object.entries(i.items||{}))e[f]={...e[f],...s}}catch{}return e}l(N,"topicItems");function k(t,e,r,o={}){const i=Object.create(null),f=[e?.review_link_ts,e?.review_sent,a(t?.review_link_ts,b(e)[0]?.number),a(t?.review_link_ts,r),a(o,r)?.review_link_ts];for(const s of f)for(const[u,n]of Array.isArray(s)?s.map(c=>[c,null]):Object.entries(s||{})){if(Object.hasOwn(i,u))continue;const c=typeof n=="string"?n:n?.ts;n!==null&&(typeof c!="string"||!/^\d+(?:\.\d+)?$/.test(c))||(i[u]={ts:c||null,...typeof n?.channel=="string"?{channel:n.channel}:{}})}return i}l(k,"reviewLinks");const g=l(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 S(t,e=[]){return[...new Map([...e,...t.reviewers||[]].map(r=>[r.name,r])).values()]}l(S,"reviewersOf");export{k as reviewLinks,g as reviewTime,S as reviewersOf,N as topicItems};
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};