@lovek4d/kickflip 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CONTEXT.md +43 -43
  2. package/SKILL.md +12 -10
  3. package/bin/event-lines.mjs +1 -1
  4. package/bin/kickflip.mjs +2 -2
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +87 -87
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +8 -8
  11. package/board/hub-lines.mjs +1 -0
  12. package/board/onboarding.mjs +1 -1
  13. package/board/public/app-arch.js +1 -1
  14. package/board/public/app-cards.js +5 -6
  15. package/board/public/app-header.js +1 -1
  16. package/board/public/app-lanes.js +37 -59
  17. package/board/public/app-needs.js +16 -11
  18. package/board/public/app-reader.js +2 -2
  19. package/board/public/app.js +2 -2
  20. package/board/public/dialogs.js +1 -1
  21. package/board/public/hub-kit.js +1 -1
  22. package/board/public/hub.css +2 -0
  23. package/board/public/linear-key.js +1 -1
  24. package/board/public/new-task.js +1 -1
  25. package/board/public/prototype-kit.js +2 -5
  26. package/board/public/router.js +1 -1
  27. package/board/public/sidebar.css +2 -2
  28. package/board/public/sidebar.js +2 -2
  29. package/board/public/style.css +20 -29
  30. package/board/public/ui-cards.js +19 -17
  31. package/board/public/ui-hub.js +1 -5
  32. package/board/public/ui-links.js +5 -5
  33. package/board/public/ui-order.js +5 -6
  34. package/board/public/ui-questions.js +12 -10
  35. package/board/public/ui-threads.js +10 -7
  36. package/board/public/ui.js +11 -2
  37. package/board/public/view-hub-settings.js +28 -31
  38. package/board/public/view-hub.js +7 -9
  39. package/board/public/view-notes.js +1 -1
  40. package/board/public/view-questions.js +18 -5
  41. package/board/public/view-settings.js +3 -3
  42. package/board/render-check.mjs +10 -10
  43. package/board/review.mjs +1 -1
  44. package/board/server.mjs +18 -19
  45. package/board/stop.mjs +1 -1
  46. package/board/store.mjs +26 -25
  47. package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
  48. package/hub/awake.mjs +1 -1
  49. package/hub/boards.mjs +4 -4
  50. package/hub/github.mjs +3 -3
  51. package/hub/hub.mjs +11 -11
  52. package/hub/incoming.mjs +2 -2
  53. package/hub/locks.mjs +1 -1
  54. package/hub/new-board.mjs +2 -2
  55. package/hub/owner-checks.mjs +1 -1
  56. package/hub/try.mjs +6 -7
  57. package/hub/views.mjs +1 -1
  58. package/package.json +1 -1
  59. package/shared/usage-gate.mjs +1 -1
@@ -1,7 +1,7 @@
1
- // HubSettings: summary rows below Hub’s health and status open centred settings modals ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)), using
1
+ // HubSettings: Keep awake, Linear and Slack sit inline at the root of the Hub page ([keep awake, Linear and Slack sit inline](docs/adr/2026-11-09-2152-keep-awake-linear-and-slack-sit-inline-on-the-hub-page.md)), then
2
+ // summary rows open centred settings modals ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)), using
2
3
  // settingsView's words from GET /_hub/api/settings. Pause hub, first, posts to /_hub/settings/hub-pause. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
3
- // hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key and Slack's token do through linear-key.js; Done waits for
4
- // a key's save to land. The rules editor saves through POST /_hub/settings/rules
4
+ // hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key and Slack's token do through linear-key.js. The rules editor saves through POST /_hub/settings/rules
5
5
  // and marks [data-dirty] while an edit is unsaved, so the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
6
6
  (function () {
7
7
  // The last data, which a return to the view paints at once.
@@ -15,9 +15,9 @@
15
15
  action: '/_hub/settings/hub-pause', name: 'pause', choices: p.choices, on: p.on, button: 'Resume' });
16
16
  const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
17
17
  choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
18
- // Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
19
- const awakeRow = (a) => durationPicker({ id: 'awake', title: 'Keep awake', icon: HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)'),
20
- action: '/_hub/settings/keep-awake', name: 'keep_awake', choices: a.choices, on: a.on, button: 'Turn off' });
18
+ // A checkbox row like Push notifications, grey while off and orange while on, as the machine working
19
+ // (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md). Unchecked posts nothing, which the hub reads as off.
20
+ const awakeRow = (a) => `<form class="row field" 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><input type="checkbox" id="awake" name="keep_awake" value="on" class="toggle" data-submit${a.on ? ' checked' : ''}></label></form>`;
21
21
 
22
22
  // The on/off, snooze and weekend rows (docs/adr/2026-10-29-1639-notifications-can-be-off-snoozed-or-off-on-weekends.md).
23
23
  function notificationRows(d) {
@@ -40,12 +40,14 @@
40
40
  + `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
41
41
  }
42
42
 
43
- const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['slack', 'Slack'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
43
+ const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
44
44
  const sectionOf = (hash) => {
45
45
  const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
46
46
  return sections.some(([section]) => section === id) ? id : null;
47
47
  };
48
- const markup = () => `${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="${id}" data-settings-open="${id}" aria-haspopup="dialog"><span class="text"><span class="title">${title}</span><span class="sub" data-summary="${id}">Loading…</span></span><span class="chev" aria-hidden="true">›</span></button>`).join('')}
48
+ const markup = () => `<div class="hub-inline"><div id="keep-awake"></div><div id="linear" class="lk-inline"></div><div id="slack" class="lk-inline"></div></div>
49
+ <h2 class="label" id="hubSettingsLabel">Hub settings</h2>
50
+ ${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="${id}" data-settings-open="${id}" aria-haspopup="dialog"><span class="text"><span class="title">${title}</span><span class="sub" data-summary="${id}">Loading…</span></span><span class="chev" aria-hidden="true">›</span></button>`).join('')}
49
51
  <dialog class="settings-modal hub-modal" id="hubSettingsModal" aria-labelledby="hubSettingsTitle"><div class="settings-head"><div><h2 id="hubSettingsTitle" tabindex="-1"></h2><p class="muted small">Hub settings</p></div><button type="button" class="ghost" data-settings-dismiss aria-label="Close hub settings">✕</button></div><div class="set settings-body"><div class="hs-flash"></div><div class="setting-content"></div></div><div class="settings-footer"><button type="button" data-settings-dismiss>Done</button></div></dialog>`;
50
52
 
51
53
  globalThis.HubSettings = {
@@ -61,7 +63,7 @@
61
63
  let flash = null;
62
64
  let rules = null;
63
65
  let rulesJSON = '';
64
- let tokenSlot = null;
66
+ const tokenSlots = {};
65
67
  const pickers = HubKit.durationPickers(B);
66
68
 
67
69
  function paint() {
@@ -69,35 +71,22 @@
69
71
  const { row, tile, deviceRow } = HubUI;
70
72
  top.innerHTML = HubUI.flash(flash);
71
73
  const scrollTop = B.$('.settings-body').scrollTop;
74
+ paintInline();
72
75
  if (active === 'pause') {
73
76
  content.innerHTML = `<div class="group">${pauseRow(last.pause)}</div><p class="note">Running steps finish. No new builders, planners or pulls start on any board until then, as when usage reaches the pause line.</p>`;
74
77
  } else if (active === 'notifications') {
75
78
  content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
76
79
  } else if (active === 'quiet') {
77
80
  content.innerHTML = quietRows(last.quiet);
78
- } else if (active === 'keep-awake') {
79
- content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
80
- : `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -ims</code> on the host so it doesn’t sleep while boards work. The screen can still turn off.</p>`;
81
- } else if (active === 'linear' || active === 'slack') {
82
- if (tokenSlot) tokenSlot.set(last[active]);
83
- else {
84
- tokenSlot = LinearKey.mount(content, { kind: active, state: last[active],
85
- saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
86
- busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
87
- }
88
81
  } else if (active === 'closed') {
89
82
  content.innerHTML = `<div class="group">${(last.closed || []).length ? last.closed.map((b) => `<div class="row"><span class="body"><span class="text"><span class="title">${esc(b.title)}</span><span class="sub wrap">/${esc(b.route)}</span></span><button class="btn" type="button" data-reopen="${esc(b.route)}"${reopening === b.route ? BoardUI.BUSY : reopening ? ' disabled' : ''}>Reopen</button></span></div>`).join('') : '<p class="note">No closed boards.</p>'}</div>`;
90
83
  }
91
84
  pickers.painted();
92
85
  B.$('.settings-body').scrollTop = scrollTop;
93
- B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
94
86
  const summaries = {
95
87
  pause: last.pause.summary,
96
88
  notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
97
89
  quiet: last.quiet.summary,
98
- 'keep-awake': last.awake.summary,
99
- linear: last.linear?.summary || 'No key',
100
- slack: last.slack?.summary || 'No token',
101
90
  rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
102
91
  closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
103
92
  };
@@ -127,6 +116,19 @@
127
116
  }
128
117
  }
129
118
 
119
+ // Keep awake's row and note, and Linear's and Slack's token slots, drawn into the page itself.
120
+ function paintInline() {
121
+ B.$('#keep-awake').innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
122
+ : `<div class="group">${awakeRow(last.awake)}</div><p class="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.</p>`;
123
+ for (const kind of ['linear', 'slack']) {
124
+ if (tokenSlots[kind]) tokenSlots[kind].set(last[kind]);
125
+ else {
126
+ tokenSlots[kind] = LinearKey.mount(B.$(`#${kind}`), { kind, state: last[kind],
127
+ saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); } });
128
+ }
129
+ }
130
+ }
131
+
130
132
  // hub-kit.js device() titles its row 'This device' only where this device can take pushes.
131
133
  function deviceSummary() {
132
134
  const d = device.state('');
@@ -138,7 +140,6 @@
138
140
  function closeModal(restore = true) {
139
141
  unfitModal?.(); unfitModal = null;
140
142
  modal.close();
141
- tokenSlot?.destroy(); tokenSlot = null;
142
143
  content.innerHTML = '';
143
144
  rules = null;
144
145
  active = null;
@@ -171,16 +172,13 @@
171
172
  history.replaceState(history.state, '', settingsURL);
172
173
  return true;
173
174
  };
174
- const dismiss = () => {
175
- if (!tokenSlot?.busy()) { B.openSettings({ kind: 'hub' }); return; }
176
- tokenSlot.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
177
- };
175
+ const dismiss = () => { B.openSettings({ kind: 'hub' }); };
178
176
  B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
179
177
  B.on(root, 'click', (e) => {
180
178
  const opener = e.target.closest?.('[data-settings-open]');
181
179
  if (opener) { B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener); return; }
182
180
  if (e.target.closest?.('[data-settings-dismiss]')) { dismiss(); return; }
183
- if (e.target === modal) {
181
+ if (BoardUI.onBackdrop(e, modal)) {
184
182
  const r = modal.getBoundingClientRect();
185
183
  if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) dismiss();
186
184
  }
@@ -189,7 +187,7 @@
189
187
  if (!(await B.openSettings(AppRouter.viewOf(location.href))) && B.alive()) history.replaceState(history.state, '', settingsURL);
190
188
  });
191
189
  let reopening = null;
192
- B.atUnmount(() => closeModal(false));
190
+ B.atUnmount(() => { closeModal(false); for (const slot of Object.values(tokenSlots)) slot.destroy(); });
193
191
  const device = HubKit.device(B, { paint, changed: () => ctx.sidebarChanged?.() });
194
192
  device.ready.then(() => { if (B.alive()) paint(); });
195
193
  B.on(root, 'click', async (e) => {
@@ -216,7 +214,6 @@
216
214
  else on.checked = true;
217
215
  });
218
216
  const forms = HubKit.forms(B, {
219
- accepts: (action) => action !== '/_hub/push/flush',
220
217
  answer(out) {
221
218
  last = out;
222
219
  flash = null;
@@ -1,6 +1,6 @@
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
- // polling status, then hub settings summary rows ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)). A board's row can carry Restart (hub/restart-board.mjs), and
3
+ // then Keep awake, Linear and Slack inline, then hub settings summary rows ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)). A board's row can carry Restart (hub/restart-board.mjs), and
4
4
  // the Hub row Restart hub while the hub runs older code than its files. Health comes from GET /_hub/api/hub.
5
5
  // Its data refreshes every 30 seconds while it shows (hub-kit.js live).
6
6
  (function () {
@@ -62,20 +62,18 @@
62
62
  const restartHubButton = (busy) => `<button type="button" class="btn quiet restart-btn" id="restart-hub" data-restart-hub${busy ? BoardUI.BUSY : ''}>Restart hub</button>`;
63
63
 
64
64
  function html(d, { flash, prompts, restarting, restartingHub }) {
65
- const { row, tile, nowRow } = HubUI;
65
+ const { row, tile } = HubUI;
66
66
  const usage = HubUI.usageCard(d.usage, d.codex, d.pause, d.claude);
67
67
  // 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
68
  const word = d.hub.restart
69
69
  ? `<button type="button" class="st ${esc(d.hub.tone)}" data-restart-hub${restartingHub ? BoardUI.BUSY : ''}>${esc(d.hub.word)}</button>`
70
70
  : `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span>`;
71
71
  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)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span> ${esc(d.awake)}</span></span>` : ''}`, wrap: true,
72
+ <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `${word} ·${esc(d.hub.detail)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span></span></span>` : ''}`, wrap: true,
73
73
  trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
74
74
  ${HubUI.machineRow(d.pause.machine)}
75
75
  ${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip &lt;route&gt;</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>`;
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>`;
79
77
  }
80
78
 
81
79
  const footer = (d) => `<footer>${esc(d.sites)}. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</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 +88,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
90
88
  className: 'hub-view view-hub',
91
89
  title: () => 'Hub',
92
90
  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" aria-labelledby="hubSettingsLabel"><h2 class="label" id="hubSettingsLabel">Hub settings</h2>${HubSettings.markup()}</section><div class="hub-foot"></div></main>`,
91
+ <main class="has-top"><div class="hub-status"></div><section class="hub-settings">${HubSettings.markup()}</section><div class="hub-foot"></div></main>`,
94
92
 
95
93
  create: (root, siteRoot, ctx) => BoardView.page(root, siteRoot, (B) => {
96
94
  const main = B.$('.hub-status');
@@ -180,14 +178,14 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
180
178
  after: () => load(),
181
179
  });
182
180
  const forms = HubKit.forms(B, {
183
- accepts: (action, form) => action === '/_hub/push/flush' || ['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
181
+ accepts: (action, form) => ['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
184
182
  answer(out, action) {
185
183
  if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
186
184
  last = out; flash = null; paint();
187
185
  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
186
  : out.pause.near?.going ? `Keep going: agents start in parallel until ${BoardUI.usageClock(out.pause.near.resets_at)}.`
189
187
  : out.pause.near ? 'Stopped: agents start one at a time again.' : 'Stopped: new work waits again.');
190
- } else if (action === '/_hub/push/flush') { flash = { text: out.message, error: !out.ok }; load(); } else { flash = null; load(); settings.B.refreshSettings?.(); }
188
+ } else { flash = null; load(); settings.B.refreshSettings?.(); }
191
189
  ctx.sidebarChanged?.();
192
190
  },
193
191
  fail(message) { flash = { text: message, error: true }; paint(); },
@@ -1,4 +1,4 @@
1
- // An item's notes, /<route>/notes.html?item=<wave>/<id>, a view of the app shell (shell.js)
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><span class="send-hint" aria-hidden="true">${/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? '⌘↵' : 'Ctrl+↵'}</span><button class="primary" type="submit" id="send">Send</button></div>
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 = uploading(qs) ? 'Uploading…' : 'Send'; }
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.key !== 'Enter' || !(ev.metaKey || ev.ctrlKey) || ev.isComposing || ev.target.tagName !== 'TEXTAREA') return;
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.target === modal) {
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.wave)}/${esc(p.id)}</li>`),
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.target === closeSheet && !closing) closeSheet.close(); });
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;
@@ -1,10 +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};
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 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};
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};