@lovek4d/kickflip 0.7.1 → 0.7.4

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 (62) hide show
  1. package/CONTEXT.md +25 -21
  2. package/SKILL.md +13 -9
  3. package/bin/event-lines.mjs +1 -1
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +1 -1
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/bin/board.mjs +68 -50
  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 -1
  12. package/board/onboarding.mjs +3 -3
  13. package/board/permissions.mjs +2 -2
  14. package/board/public/app-asks.js +2 -14
  15. package/board/public/app-banner.js +8 -3
  16. package/board/public/app-cards.js +7 -20
  17. package/board/public/app-core.js +2 -2
  18. package/board/public/app-lanes.js +7 -15
  19. package/board/public/app-reader.js +7 -3
  20. package/board/public/app-snapshot.js +1 -1
  21. package/board/public/app.js +1 -1
  22. package/board/public/attach.js +18 -2
  23. package/board/public/dialogs.js +10 -12
  24. package/board/public/hub.css +0 -3
  25. package/board/public/linear-key.js +1 -1
  26. package/board/public/sidebar.css +1 -1
  27. package/board/public/style.css +42 -36
  28. package/board/public/ui-cards.js +33 -27
  29. package/board/public/ui-enums.js +2 -2
  30. package/board/public/ui-links.js +0 -1
  31. package/board/public/ui-order.js +1 -1
  32. package/board/public/ui-questions.js +8 -6
  33. package/board/public/ui-sidebar.js +1 -1
  34. package/board/public/ui-threads.js +26 -22
  35. package/board/public/ui.js +15 -9
  36. package/board/public/view-hub-settings.js +25 -30
  37. package/board/public/view-hub.js +23 -5
  38. package/board/public/view-questions.js +1 -1
  39. package/board/public/view-settings.js +4 -4
  40. package/board/server.mjs +18 -18
  41. package/board/store.mjs +30 -27
  42. package/board/tickets.mjs +3 -3
  43. package/board/worktree.mjs +15 -15
  44. package/hub/board-repo.mjs +1 -1
  45. package/hub/github.mjs +3 -3
  46. package/hub/hub.mjs +11 -11
  47. package/hub/linear.mjs +1 -1
  48. package/hub/new-board.mjs +2 -2
  49. package/hub/pr-report.mjs +2 -2
  50. package/hub/readiness.mjs +1 -1
  51. package/hub/repos.mjs +3 -3
  52. package/hub/review-send.mjs +1 -1
  53. package/hub/schedule.mjs +1 -1
  54. package/hub/slack.mjs +1 -1
  55. package/hub/try.mjs +8 -6
  56. package/hub/views.mjs +1 -1
  57. package/package.json +1 -1
  58. package/shared/fake-gh.mjs +19 -4
  59. package/shared/gh.mjs +4 -0
  60. package/shared/owner-check.mjs +4 -3
  61. package/shared/service.mjs +12 -12
  62. package/starter/server.mjs +3 -3
@@ -1,7 +1,8 @@
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
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
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
1
+ // HubSettings: 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
+ // settingsView's words from GET /_hub/api/settings. Linear's key and Slack's token open the same modal from the button on their Integrations rows (view-hub.js),
3
+ // with no summary row of their own ([Linear and Slack keys open in modals from their Integrations rows](docs/adr/2026-11-09-2230-linear-and-slack-keys-open-in-modals-from-their-integrations-rows.md)). Pause hub, first, posts to /_hub/settings/hub-pause. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
4
+ // hours' times and hold to /_hub/settings/quiet, and saves at once, as Linear's key and Slack's token do through linear-key.js; Done waits for
5
+ // a key's save to land. The rules editor saves through POST /_hub/settings/rules
5
6
  // 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
7
  (function () {
7
8
  // The last data, which a return to the view paints at once.
@@ -15,10 +16,6 @@
15
16
  action: '/_hub/settings/hub-pause', name: 'pause', choices: p.choices, on: p.on, button: 'Resume' });
16
17
  const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
17
18
  choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
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
-
22
19
  // The on/off, snooze and weekend rows (docs/adr/2026-10-29-1639-notifications-can-be-off-snoozed-or-off-on-weekends.md).
23
20
  function notificationRows(d) {
24
21
  const { tile } = HubUI;
@@ -41,13 +38,13 @@
41
38
  }
42
39
 
43
40
  const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
41
+ // Linear and Slack open from their Integrations rows, so they have modals and no summary rows.
42
+ const keys = [['linear', 'Linear'], ['slack', 'Slack']];
44
43
  const sectionOf = (hash) => {
45
44
  const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
46
- return sections.some(([section]) => section === id) ? id : null;
45
+ return [...sections, ...keys].some(([section]) => section === id) ? id : null;
47
46
  };
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('')}
47
+ 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('')}
51
48
  <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>`;
52
49
 
53
50
  globalThis.HubSettings = {
@@ -63,7 +60,7 @@ ${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="$
63
60
  let flash = null;
64
61
  let rules = null;
65
62
  let rulesJSON = '';
66
- const tokenSlots = {};
63
+ let tokenSlot = null;
67
64
  const pickers = HubKit.durationPickers(B);
68
65
 
69
66
  function paint() {
@@ -71,13 +68,19 @@ ${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="$
71
68
  const { row, tile, deviceRow } = HubUI;
72
69
  top.innerHTML = HubUI.flash(flash);
73
70
  const scrollTop = B.$('.settings-body').scrollTop;
74
- paintInline();
75
71
  if (active === 'pause') {
76
72
  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>`;
77
73
  } else if (active === 'notifications') {
78
74
  content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
79
75
  } else if (active === 'quiet') {
80
76
  content.innerHTML = quietRows(last.quiet);
77
+ } else if (active === 'linear' || active === 'slack') {
78
+ if (tokenSlot) tokenSlot.set(last[active]);
79
+ else {
80
+ tokenSlot = LinearKey.mount(content, { kind: active, state: last[active],
81
+ saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
82
+ busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
83
+ }
81
84
  } else if (active === 'closed') {
82
85
  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>`;
83
86
  }
@@ -116,19 +119,6 @@ ${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="$
116
119
  }
117
120
  }
118
121
 
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
-
132
122
  // hub-kit.js device() titles its row 'This device' only where this device can take pushes.
133
123
  function deviceSummary() {
134
124
  const d = device.state('');
@@ -140,6 +130,7 @@ ${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="$
140
130
  function closeModal(restore = true) {
141
131
  unfitModal?.(); unfitModal = null;
142
132
  modal.close();
133
+ tokenSlot?.destroy(); tokenSlot = null;
143
134
  content.innerHTML = '';
144
135
  rules = null;
145
136
  active = null;
@@ -157,7 +148,8 @@ ${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="$
157
148
  active = id;
158
149
  if (id) {
159
150
  returnFocus = opener || B.$(`#${id}`);
160
- B.$('#hubSettingsTitle').textContent = sections.find(([section]) => section === id)[1];
151
+ B.$('#hubSettingsTitle').textContent = [...sections, ...keys].find(([section]) => section === id)[1];
152
+ B.$('.settings-head .muted').textContent = keys.some(([key]) => key === id) ? 'Integrations' : 'Hub settings';
161
153
  content.innerHTML = id === 'rules' ? '<div id="hubRules" class="rules-box"></div>' : '<p class="note">Loading…</p>';
162
154
  flash = null;
163
155
  paint();
@@ -172,7 +164,10 @@ ${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="$
172
164
  history.replaceState(history.state, '', settingsURL);
173
165
  return true;
174
166
  };
175
- const dismiss = () => { B.openSettings({ kind: 'hub' }); };
167
+ const dismiss = () => {
168
+ if (!tokenSlot?.busy()) { B.openSettings({ kind: 'hub' }); return; }
169
+ tokenSlot.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
170
+ };
176
171
  B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
177
172
  B.on(root, 'click', (e) => {
178
173
  const opener = e.target.closest?.('[data-settings-open]');
@@ -187,7 +182,7 @@ ${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="$
187
182
  if (!(await B.openSettings(AppRouter.viewOf(location.href))) && B.alive()) history.replaceState(history.state, '', settingsURL);
188
183
  });
189
184
  let reopening = null;
190
- B.atUnmount(() => { closeModal(false); for (const slot of Object.values(tokenSlots)) slot.destroy(); });
185
+ B.atUnmount(() => closeModal(false));
191
186
  const device = HubKit.device(B, { paint, changed: () => ctx.sidebarChanged?.() });
192
187
  device.ready.then(() => { if (B.alive()) paint(); });
193
188
  B.on(root, 'click', async (e) => {
@@ -1,6 +1,7 @@
1
1
  // The hub page, /_hub/, a view of the app shell (shell.js): a banner while pushes are held or snoozed, the Usage card with Claude model and Codex share, Needs you for the pending
2
2
  // prompts of sessions that own no board, Health, Integrations (readiness.mjs words), each site's health and board links,
3
- // 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
3
+ // then hub settings summary rows ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)). Health carries the Keep awake toggle, and Linear's and Slack's Integrations rows
4
+ // a button that opens their key modal ([Linear and Slack keys open in modals from their Integrations rows](docs/adr/2026-11-09-2230-linear-and-slack-keys-open-in-modals-from-their-integrations-rows.md)). A board's row can carry Restart (hub/restart-board.mjs), and
4
5
  // the Hub row Restart hub while the hub runs older code than its files. Health comes from GET /_hub/api/hub.
5
6
  // Its data refreshes every 30 seconds while it shows (hub-kit.js live).
6
7
  (function () {
@@ -43,14 +44,27 @@
43
44
  const mine = (() => { try { return localStorage.getItem('kickflip.push.id'); } catch { return null; } })();
44
45
  const deviceLine = (d) => `<span class="rd-device">${esc([d.kind, d.browser, `seen ${BoardUI.ago(d.added)}`].filter(Boolean).join(' · '))}${d.id === mine ? ' · <b>this device</b>' : ''} <button type="button" class="link" data-forget-device="${esc(d.id)}">Remove</button></span>`;
45
46
  const devices = (it) => it.devices?.length ? `<span class="links">${it.devices.map(deviceLine).join('')}</span>` : '';
46
- const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true,
47
+ // Linear and Slack carry a key button instead of a fix, in both states.
48
+ const keyButton = (it) => it.keyButton ? `<button type="button" class="btn quiet" data-settings-open="${esc(it.id)}" aria-haspopup="dialog">${esc(it.keyButton)}</button>` : '';
49
+ const missingLinks = (it) => it.fix || it.waits
50
+ ? `<span class="links">${it.fix ? `<span class="rd-fix">${fixHtml(it.fix)}</span>` : ''}${it.waits ? `<span class="rd-waits">${esc(it.waits)}</span>` : ''}</span>` : '';
51
+ // GitHub's row while its rate limit holds is red, a fault, and says when it resets
52
+ // (docs/adr/2026-11-09-1626-gh-refuses-while-githubs-hourly-limit-holds.md).
53
+ const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : it.limited ? 'var(--color-danger)' : 'var(--color-notice)'), title: esc(it.name), wrap: true, trail: keyButton(it),
47
54
  sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}${devices(it)}`
48
- : `<span class="st wait">not ready</span> · ${esc(it.missing)}<span class="links"><span class="rd-fix">${fixHtml(it.fix)}</span>${it.waits ? `<span class="rd-waits">${esc(it.waits)}</span>` : ''}</span>` })).join('');
55
+ : `${it.limited ? '<span class="st down">rate limited</span>' : '<span class="st wait">not ready</span>'} · ${esc(it.missing)}${missingLinks(it)}` })).join('');
49
56
  return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
50
57
  <p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
51
58
  `;
52
59
  }
53
60
 
61
+ // Keep awake (docs/adr/2026-11-09-1200-keep-awake-is-a-toggle-that-holds-the-mac-awake-only-on-ac-power.md): grey while off, orange while on as the machine working
62
+ // (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md). Unchecked posts nothing, which the hub reads as off. Off macOS it says why instead of a toggle.
63
+ const AWAKE_NOTE = 'Runs <code>caffeinate -s</code> on the host so it doesn’t sleep while boards work, only while it is on AC power. On battery it holds nothing. The screen can still turn off.';
64
+ const awakeRow = (a) => a.unsupported
65
+ ? HubUI.row({ icon: HubUI.tile('coffee', 'var(--color-quiet)'), title: 'Keep awake', sub: esc(a.unsupported), wrap: true, attrs: ' id="keep-awake"' })
66
+ : `<form class="row field" id="keep-awake" method="post" action="/_hub/settings/keep-awake">${HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)')}<label class="body"><span class="text"><span class="title">Keep awake</span><span class="sub wrap">${AWAKE_NOTE}</span></span><input type="checkbox" id="awake" name="keep_awake" value="on" class="toggle" data-submit${a.on ? ' checked' : ''}></label></form>`;
67
+
54
68
  // A pending prompt from a session that owns no board waits here (prompts.js).
55
69
  function promptsHtml(list, prompts) {
56
70
  const shown = list.filter(prompts.shows);
@@ -69,9 +83,10 @@
69
83
  ? `<button type="button" class="st ${esc(d.hub.tone)}" data-restart-hub${restartingHub ? BoardUI.BUSY : ''}>${esc(d.hub.word)}</button>`
70
84
  : `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span>`;
71
85
  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></span></span>` : ''}`, wrap: true,
86
+ <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `${word} ·${esc(d.hub.detail)}`, wrap: true,
73
87
  trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
74
88
  ${HubUI.machineRow(d.pause.machine)}
89
+ ${awakeRow(d.awake)}
75
90
  ${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip &lt;route&gt;</code>.' }) : ''}</div>
76
91
  ${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
92
  }
@@ -166,10 +181,13 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
166
181
  if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
167
182
  });
168
183
  const foot = B.$('.hub-foot');
184
+ // A #keep-awake link scrolls to Health's Keep awake row once it is drawn.
185
+ let scrollToAwake = ctx.view?.hash === '#keep-awake';
169
186
  const paint = () => {
170
187
  if (!last) return;
171
188
  main.innerHTML = html(last, { flash, prompts, restarting, restartingHub });
172
189
  foot.innerHTML = footer(last);
190
+ if (scrollToAwake) { scrollToAwake = false; B.$('#keep-awake')?.scrollIntoView(); }
173
191
  };
174
192
  const prompts = PromptAnswers.wire(B, {
175
193
  list: () => last?.prompts || [],
@@ -178,7 +196,7 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
178
196
  after: () => load(),
179
197
  });
180
198
  const forms = HubKit.forms(B, {
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')),
199
+ accepts: (action, form) => ['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going', '/_hub/settings/keep-awake'].includes(action) || Boolean(form?.closest?.('.hold-banner')),
182
200
  answer(out, action) {
183
201
  if (['/_hub/settings/codex', '/_hub/settings/claude-model', '/_hub/settings/pause-at', '/_hub/settings/keep-going'].includes(action)) {
184
202
  last = out; flash = null; paint();
@@ -153,7 +153,7 @@
153
153
  }
154
154
 
155
155
  // A toast names a topic by its card's ref, the title up to its colon; an ask by its time.
156
- const toastName = (t) => (t.askedAt ? topicName(t) : String(t.title).match(/^(\S+):/)?.[1] || t.title);
156
+ const toastName = (t) => String(t.title).match(/^(\S+):/)?.[1] || t.title;
157
157
  // The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
158
158
  function leaving() {
159
159
  const from = soloId ? topicOf(soloId)?.id : topicId;
@@ -290,7 +290,7 @@
290
290
  const r = await fetch(site.url('api/settings'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ tickets: value }) });
291
291
  const out = await r.json();
292
292
  if (!r.ok) throw new Error(out.error || 'Could not save tickets.');
293
- savedTickets = out.settings.tickets;
293
+ savedTickets = out.settings.tickets || { source: 'none' };
294
294
  return true;
295
295
  } catch (e) {
296
296
  if (B.alive()) ticketsStatus.textContent = e.message || 'Could not save. Is the host awake?';
@@ -486,8 +486,8 @@
486
486
  pauseChoices = d.pause_choices || [];
487
487
  paintPause(d.paused_until);
488
488
  if (!B.alive()) return;
489
- savedTickets = d.settings?.tickets || null;
490
- tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets || { source: 'internal', prefix: 'INT' }, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
489
+ savedTickets = d.settings?.tickets || { source: 'none' };
490
+ tickets = BoardDialogs.ticketsControl($('#boardTickets'), { value: savedTickets, repo: () => repo.github || '', title: () => ctx.siteTitle || '',
491
491
  loadLinear: async () => (await get('/_hub/api/linear')).body,
492
492
  keyState: async () => (await get('/_hub/api/settings')).body.linear,
493
493
  change(_value, _valid, { loaded, typing } = {}) {
@@ -783,7 +783,7 @@
783
783
  });
784
784
  }).catch(() => { $('#rules').innerHTML = '<p class="muted">Offline. Is the host awake?</p>'; });
785
785
  B.watch((events) => {
786
- $('#rulesReplies').innerHTML = (events || []).slice(0, 3).map((e) => `<div class="reply">${e.reply ? (e.reply.html ?? '') : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
786
+ $('#rulesReplies').innerHTML = (events || []).slice(0, 3).map((e) => `<div class="reply${e.reply?.md ? ' md' : ''}">${e.reply ? BoardUI.replyBody(e.reply) : '<span class="muted small">Waiting for Claude…</span>'}</div>`).join('');
787
787
  }, { kind: 'rules' });
788
788
  }),
789
789
  };