@lovek4d/kickflip 0.3.1 → 0.4.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 (104) hide show
  1. package/CONTEXT.md +151 -115
  2. package/README.md +23 -14
  3. package/SKILL.md +37 -37
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +186 -69
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +3 -0
  13. package/board/headless.mjs +3 -0
  14. package/board/on-host.mjs +1 -1
  15. package/board/permissions.mjs +2 -0
  16. package/board/public/app-arch.js +2 -2
  17. package/board/public/app-asks.js +57 -38
  18. package/board/public/app-banner.js +2 -2
  19. package/board/public/app-cards.js +156 -72
  20. package/board/public/app-core.js +2 -4
  21. package/board/public/app-header.js +12 -5
  22. package/board/public/app-lanes.js +201 -47
  23. package/board/public/app-needs.js +2 -1
  24. package/board/public/app-snapshot.js +2 -2
  25. package/board/public/app-threads.js +11 -14
  26. package/board/public/app.html +2 -1
  27. package/board/public/app.js +53 -83
  28. package/board/public/attach.js +39 -3
  29. package/board/public/dialogs.js +59 -14
  30. package/board/public/hub-kit.js +9 -6
  31. package/board/public/hub.css +5 -18
  32. package/board/public/linear-key.js +6 -4
  33. package/board/public/log.html +48 -0
  34. package/board/public/prompts.js +9 -8
  35. package/board/public/prototype-kit.js +6 -6
  36. package/board/public/prototype-switcher.js +13 -5
  37. package/board/public/prototype.html +1 -0
  38. package/board/public/router.js +8 -7
  39. package/board/public/rules-editor.js +3 -4
  40. package/board/public/shell.js +20 -6
  41. package/board/public/sidebar.css +21 -1
  42. package/board/public/sidebar.js +64 -31
  43. package/board/public/style.css +155 -62
  44. package/board/public/toasts.js +2 -2
  45. package/board/public/ui-cards.js +176 -71
  46. package/board/public/ui-enums.js +5 -4
  47. package/board/public/ui-header.js +10 -10
  48. package/board/public/ui-hub.js +4 -4
  49. package/board/public/ui-markdown.js +1 -1
  50. package/board/public/ui-order.js +9 -6
  51. package/board/public/ui-page-header.js +10 -7
  52. package/board/public/ui-patch.js +53 -0
  53. package/board/public/ui-phase-bars.js +17 -0
  54. package/board/public/ui-prompts.js +8 -8
  55. package/board/public/ui-questions.js +26 -23
  56. package/board/public/ui-sidebar.js +46 -22
  57. package/board/public/ui-snapshot.js +20 -9
  58. package/board/public/ui-threads.js +17 -11
  59. package/board/public/ui.js +80 -12
  60. package/board/public/view-frame.js +1 -1
  61. package/board/public/view-hub-settings.js +25 -18
  62. package/board/public/view-hub.js +53 -12
  63. package/board/public/view-notes.js +1 -1
  64. package/board/public/view-questions.js +101 -50
  65. package/board/public/view-settings.js +167 -49
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +18 -12
  68. package/board/store.mjs +13 -12
  69. package/board/tickets.mjs +3 -3
  70. package/board/worktree.mjs +20 -14
  71. package/docs/hub-session.md +4 -4
  72. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  73. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  74. package/hub/board-repo.mjs +1 -1
  75. package/hub/boards.mjs +4 -4
  76. package/hub/close-board.mjs +1 -1
  77. package/hub/github.mjs +4 -4
  78. package/hub/home.mjs +1 -1
  79. package/hub/hub.mjs +12 -12
  80. package/hub/incoming.mjs +2 -0
  81. package/hub/linear.mjs +2 -2
  82. package/hub/live.mjs +2 -2
  83. package/hub/new-board.mjs +2 -2
  84. package/hub/permission-denied-hook.mjs +2 -0
  85. package/hub/pr-report.mjs +3 -1
  86. package/hub/presence.mjs +1 -1
  87. package/hub/prompts.mjs +1 -1
  88. package/hub/push.mjs +2 -2
  89. package/hub/readiness.mjs +2 -2
  90. package/hub/repos.mjs +3 -3
  91. package/hub/restart-board.mjs +1 -1
  92. package/hub/schedule.mjs +1 -1
  93. package/hub/sessions.mjs +2 -2
  94. package/hub/sw.js +1 -1
  95. package/hub/try.mjs +8 -4
  96. package/hub/usage.mjs +3 -3
  97. package/hub/views.mjs +1 -1
  98. package/package.json +5 -1
  99. package/shared/card-prs.mjs +1 -0
  100. package/shared/colors.css +5 -4
  101. package/shared/fake-gh.mjs +59 -8
  102. package/shared/repos.mjs +1 -1
  103. package/shared/usage-gate.mjs +1 -1
  104. package/board/public/view-polling.js +0 -87
@@ -49,16 +49,18 @@
49
49
  }
50
50
  const CLIP = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>';
51
51
  const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
52
+ // A composer's text box, which attach.js outlines with Drop to attach while files are dragged over the composer.
53
+ const dropZone = (textarea) => `<div class="dz">${textarea}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
52
54
  const icon = (body, size = 18, attrs = '') => `<svg${attrs} width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
53
55
  // An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
54
56
  const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
55
57
  const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
56
58
  const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
57
59
  const ELLIPSIS = icon('<circle cx="5" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="19" cy="12" r="1" fill="currentColor"/>');
58
- // Asks Claude for throwaway prototypes of one item (docs/adr/0015-prototypes-come-on-request.md).
59
- function protoButton(id, state = null, { label = 'Ask for prototypes', disabled = false } = {}) {
60
- if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made"${disabled ? ' disabled' : ''}>${ELLIPSIS}</button>`;
61
- return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}"${disabled ? ' disabled' : ''}>${EYE}</button>`;
60
+ // Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
61
+ function protoButton(id, state = null, { label = 'Ask for prototypes' } = {}) {
62
+ if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made">${ELLIPSIS}</button>`;
63
+ return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
62
64
  }
63
65
  // latestBump is the data.id of the newest ask-bump event.
64
66
  const protoNotice = (eventId, latestBump) => (latestBump === eventId
@@ -71,15 +73,15 @@
71
73
  const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
72
74
  // A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange once bumped and disabled on the latest
73
75
  // bump. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
74
- // it is filled orange, card-unbump {key} (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md,
75
- // docs/adr/0145-build-on-a-blocked-card-queues-it.md).
76
+ // it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
77
+ // docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
76
78
  const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
77
79
  const cardBumpButton = (key, bumped) => (bumped
78
80
  ? `<button class="ghost icon bumped" data-card-bump="${esc(key)}" data-bumped aria-pressed="true" aria-label="Unbump" title="Bumped. Tap to unbump">${BUMP}</button>`
79
81
  : `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
80
- const workingLine = '<span class="working-dot" aria-hidden="true"></span>Working on it';
82
+ const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking</span>';
81
83
  // html is markup the caller has escaped; href, title and attrs values are escaped here.
82
- const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready']);
84
+ const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready', 'merged']);
83
85
  function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
84
86
  if (!BADGE_TONES.includes(tone)) throw new Error(`unknown badge tone: ${tone}`);
85
87
  const extra = Object.entries({ href, title, ...attrs }).filter(([, v]) => v != null).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
@@ -212,21 +214,87 @@
212
214
  }
213
215
  lastHtml.set(el, html);
214
216
  if (openDetails.size) for (const d of el.querySelectorAll('details:not([open])')) if (openDetails.has(detailsKey(d))) d.open = true;
217
+ for (const w of waits) { const b = waitingButton(w); if (b && !b.classList.contains(w.mark)) markWaiting(b, w); }
215
218
  return true;
216
219
  }
217
220
 
221
+ // Closes <details> d at once: its toggle event comes a task later, and a setHTML before it would reopen it.
222
+ function closeDetails(d) {
223
+ openDetails.delete(detailsKey(d));
224
+ d.open = false;
225
+ }
226
+
227
+ // A tap that saves something waits for the server (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md,
228
+ // docs/adr/2026-11-03-1915-every-pending-control-waits-in-one-style.md): waitOn(control, promise, {group}) disables
229
+ // control until promise settles and marks it tap-waiting and aria-busy. style.css button[aria-busy="true"] keeps a
230
+ // button's colours and width and shows a spinner in its text colour in place of its content, or pulses a bump icon
231
+ // orange. A toggle, select or field keeps its face, with a spinner beside it: after its caption in a .tk-field, else
232
+ // just before it. group: the other choices of the same setting, disabled (tap-blocked) with no spinner meanwhile.
233
+ // A repaint meanwhile (setHTML) patches each back to its markup, so setHTML marks it again, found by its id and data
234
+ // attributes when the repaint replaced it. Settling restores the disabled and aria-busy its latest markup had. → promise
235
+ // Work that outlives its request (a restart, an update, a merge) draws its button with BUSY in its markup until the
236
+ // state the page draws from says the work is done.
237
+ const BUSY = ' disabled aria-busy="true"';
238
+ const waits = new Set();
239
+ function markWaiting(b, w) {
240
+ w.el = b;
241
+ w.disabled = b.disabled;
242
+ w.busy = b.getAttribute('aria-busy');
243
+ b.disabled = true;
244
+ b.classList.add(w.mark);
245
+ if (w.mark !== 'tap-waiting') return;
246
+ b.setAttribute('aria-busy', 'true');
247
+ if (b.tagName === 'BUTTON') return;
248
+ w.spin?.remove();
249
+ w.spin = document.createElement('span');
250
+ w.spin.setAttribute('class', 'try-spin tap-beside');
251
+ w.spin.setAttribute('aria-hidden', 'true');
252
+ const caption = b.closest('.tk-field')?.firstElementChild;
253
+ if (caption && caption !== b) caption.appendChild(w.spin);
254
+ else b.before(w.spin);
255
+ }
256
+ const waitingButton = (w) => (w.el.isConnected && w.el.matches(w.selector) ? w.el
257
+ : w.named ? document.body.querySelector(w.selector) : null);
258
+ function waitFor(el, mark) {
259
+ const named = [...el.attributes].filter(({ name }) => name === 'id' || name.startsWith('data-'));
260
+ const w = { mark, selector: el.tagName.toLowerCase() + named.map(({ name, value }) => (value ? `[${name}="${CSS.escape(value)}"]` : `[${name}]`)).join(''), named: named.length > 0 };
261
+ markWaiting(el, w);
262
+ waits.add(w);
263
+ return w;
264
+ }
265
+ function waitOn(control, promise, { group = [] } = {}) {
266
+ const mine = [waitFor(control, 'tap-waiting'), ...[...group].filter((el) => el !== control).map((el) => waitFor(el, 'tap-blocked'))];
267
+ const done = () => {
268
+ for (const w of mine) {
269
+ waits.delete(w);
270
+ w.spin?.remove();
271
+ // setHTML keeps w.el the node it last marked; one a plain innerHTML replaced is gone with its mark.
272
+ const b = w.el;
273
+ if (!b.classList.contains(w.mark)) continue;
274
+ b.classList.remove(w.mark);
275
+ if (w.mark === 'tap-waiting') { if (w.busy === null) b.removeAttribute('aria-busy'); else b.setAttribute('aria-busy', w.busy); }
276
+ b.disabled = w.disabled;
277
+ }
278
+ };
279
+ promise.then(done, done);
280
+ return promise;
281
+ }
282
+
218
283
  // While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
219
284
  // on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
220
285
  // then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
286
+ // tag is the version of the board the page holds: an unchanged board answers 304, which resolves with body null
287
+ // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
221
288
  const RESTARTING = 'Board restarting…';
222
289
  const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
223
- async function fetchJSON(url, { onWait = () => {}, limit = 30000, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
290
+ async function fetchJSON(url, { onWait = () => {}, limit = 30000, tag = null, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
224
291
  let waited = 0;
225
292
  let delay = 500;
226
293
  for (;;) {
227
294
  let failure;
228
295
  try {
229
- const r = await get(url, { cache: 'no-store' });
296
+ const r = await get(url, { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) });
297
+ if (r.status === 304) return { ok: true, status: 304, body: null };
230
298
  const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
231
299
  if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
232
300
  failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
@@ -241,7 +309,7 @@
241
309
 
242
310
  globalThis.BoardUI = {
243
311
  LINK_ARROW,
244
- esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, fetchJSON, RESTARTING, restartNotice,
245
- attachments, attachButton, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
312
+ esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, fetchJSON, RESTARTING, restartNotice,
313
+ attachments, attachButton, dropZone, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
246
314
  };
247
315
  })();
@@ -1,5 +1,5 @@
1
1
  // Any other page on the hub as a view of the app shell (shell.js): another site, a prototype, or a board page that isn't
2
- // a view of its own, in a same-origin frame beside the board header (docs/adr/0055-one-app-for-the-whole-hub.md). The
2
+ // a view of its own, in a same-origin frame beside the board header (docs/adr/2026-10-29-2005-one-app-for-the-whole-hub.md). The
3
3
  // header's title follows the page's, and Open alone ↗ loads the page by itself (?alone=1, hub/shell.mjs pageOf). A
4
4
  // page that draws the board header itself, such as a prototype or another site's page, hides this one; its logo then
5
5
  // toggles the sidebar as this one's would, and hides while the rail shows. The page's own navigations stay in the frame
@@ -1,6 +1,6 @@
1
- // HubSettings: summary rows below Hub’s health and status open centred settings modals (ADR 0075), using
2
- // settingsView's words from GET /_hub/api/settings. Each toggle and snooze posts to /_hub/settings/notifications, and
3
- // Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key does through linear-key.js; Done waits for
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
2
+ // settingsView's words from GET /_hub/api/settings. 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 does through linear-key.js; Done waits for
4
4
  // a key's save to land. 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 () {
@@ -33,11 +33,11 @@
33
33
 
34
34
  const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
35
35
  choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
36
- // Grey while off and orange while on, as the machine working (docs/adr/0096-colour-says-whos-waiting.md).
36
+ // Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
37
37
  const awakeRow = (a) => durationPicker({ id: 'awake', title: 'Keep awake', icon: HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)'),
38
38
  action: '/_hub/settings/keep-awake', name: 'keep_awake', choices: a.choices, on: a.on, button: 'Turn off' });
39
39
 
40
- // The on/off, snooze and weekend rows (docs/adr/0032-notifications-can-be-off-snoozed-or-off-on-weekends.md).
40
+ // The on/off, snooze and weekend rows (docs/adr/2026-10-29-1639-notifications-can-be-off-snoozed-or-off-on-weekends.md).
41
41
  function notificationRows(d) {
42
42
  const { tile } = HubUI;
43
43
  const toggle = ({ icon, set, title, checked, attrs = '' }) => `<form class="row field" method="post" action="${ACTION}"><input type="hidden" name="set" value="${set}">${icon}<label class="body"><span class="text"><span class="title">${title}</span></span><input type="checkbox" name="value" value="on" class="toggle"${attrs}${checked ? ' checked' : ''}></label></form>`;
@@ -47,9 +47,20 @@
47
47
  return `${onRow}\n${snoozeRow(d)}\n${weekendRow}`;
48
48
  }
49
49
 
50
- const sections = [['notifications', 'Notifications'], ['polling', 'Polling'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
50
+ // Quiet hours (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md): start and
51
+ // end, each saved once its picker closes, the overnight hold, the muted cadence hint and what the snoozes and wake read.
52
+ function quietRows(q) {
53
+ const { row, tile } = HubUI;
54
+ const time = (name, title, value) => `<form class="row field" method="post" action="/_hub/settings/quiet"><label class="body"><span class="text title">${title}</span><input type="time" name="${name}" value="${esc(value)}" required data-autosave></label></form>`;
55
+ const holdRow = `<form class="row field" method="post" action="/_hub/settings/quiet"><input type="hidden" name="set" value="hold">${tile('moon', 'var(--accent)')}<label class="body"><span class="text"><span class="title">Hold pushes</span><span class="sub wrap">${esc(q.hold.sub)}</span></span><input type="checkbox" name="value" value="on" class="toggle" data-submit${q.hold.on ? ' checked' : ''}></label></form>`;
56
+ const reads = q.reads.map((r) => row({ title: esc(r.title), sub: `<span class="sub wrap">${esc(r.sub)}</span>` })).join('');
57
+ return `<div class="group">${time('start', 'Starts', q.start)}${time('end', 'Ends', q.end)}${holdRow}</div><p class="quiet-hint">${esc(q.hint)}</p>`
58
+ + `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
59
+ }
60
+
61
+ const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
51
62
  const sectionOf = (hash) => {
52
- const id = { push: 'notifications', 'closed-boards': 'closed' }[hash?.slice(1)] || hash?.slice(1);
63
+ const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
53
64
  return sections.some(([section]) => section === id) ? id : null;
54
65
  };
55
66
  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('')}
@@ -65,7 +76,6 @@
65
76
  let settingsURL = '/_hub/';
66
77
  let returnFocus = null;
67
78
  let unfitModal = null;
68
- let polling = null;
69
79
  let flash = null;
70
80
  let rules = null;
71
81
  let rulesJSON = '';
@@ -78,6 +88,8 @@
78
88
  const scrollTop = B.$('.settings-body').scrollTop;
79
89
  if (active === 'notifications') {
80
90
  content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}${last.presence ? row({ icon: tile('laptop', 'var(--color-quiet)'), title: esc(last.presence.title) }) : ''}</div>`;
91
+ } else if (active === 'quiet') {
92
+ content.innerHTML = quietRows(last.quiet);
81
93
  } else if (active === 'keep-awake') {
82
94
  content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
83
95
  : `<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>`;
@@ -89,7 +101,7 @@
89
101
  busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
90
102
  }
91
103
  } else if (active === 'closed') {
92
- 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 ? ' disabled' : ''}>${reopening === b.route ? 'Reopening…' : 'Reopen'}</button></span></div>`).join('') : '<p class="note">No closed boards.</p>'}</div>`;
104
+ 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>`;
93
105
  }
94
106
  const input = B.$('[data-dur-at]');
95
107
  if (input) customBounds(input, true);
@@ -97,7 +109,7 @@
97
109
  B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
98
110
  const summaries = {
99
111
  notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
100
- polling: last.polling,
112
+ quiet: last.quiet.summary,
101
113
  'keep-awake': last.awake.summary,
102
114
  linear: last.linear?.summary || 'No key',
103
115
  rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
@@ -138,7 +150,6 @@
138
150
  }
139
151
 
140
152
  function closeModal(restore = true) {
141
- polling?.unmount(); polling = null;
142
153
  unfitModal?.(); unfitModal = null;
143
154
  modal.close();
144
155
  linearKey?.destroy(); linearKey = null;
@@ -168,13 +179,9 @@
168
179
  modal.showModal();
169
180
  B.$('.settings-body').scrollTop = 0;
170
181
  B.$('#hubSettingsTitle').focus({ preventScroll: true });
171
- if (id === 'polling') {
172
- polling = HubPolling.create(content, siteRoot, { ...ctx, view, changed: () => B.refreshSettings?.() });
173
- polling.mount();
174
- }
175
182
  }
176
- } else if (id === 'polling') polling?.B.selectWindow?.(view.w || null);
177
- settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {}), ...(id === 'polling' && view.w ? { w: view.w } : {}) });
183
+ }
184
+ settingsURL = AppRouter.urlOf({ kind: 'hub', ...(id ? { hash: `#${id}` } : {}) });
178
185
  history.replaceState(history.state, '', settingsURL);
179
186
  return true;
180
187
  };
@@ -254,7 +261,7 @@
254
261
  else on.checked = true;
255
262
  });
256
263
  const forms = HubKit.forms(B, {
257
- accepts: (action) => action !== '/_hub/push/flush' && action !== '/_hub/settings/polling',
264
+ accepts: (action) => action !== '/_hub/push/flush',
258
265
  answer(out) {
259
266
  last = out;
260
267
  flash = null;
@@ -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
- // polling status, then hub settings summary rows (ADR 0075). A board's row can carry Restart (hub/restart-board.mjs). Health comes from GET /_hub/api/hub.
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
4
+ // the Hub row Restart hub while the hub runs older code than its files. Health comes from GET /_hub/api/hub.
4
5
  // Its data refreshes every 30 seconds while it shows (hub-kit.js live).
5
6
  (function () {
6
7
  // The last data, which a return to the view paints at once.
@@ -11,14 +12,14 @@
11
12
  return sessions.map((s) => `<div class="remembered"><p>Always allow · session <code>${esc(s.session)}</code></p><ul>${s.rules.map((rule) => `<li><code>${esc(rule)}</code></li>`).join('')}</ul></div>`).join('');
12
13
  }
13
14
 
14
- const RESTARTING = '<span class="st wait">Restarting</span> · stopping its session, then starting it again';
15
+ const RESTARTING = '<span class="st wait">restarting</span> · stopping its session, then starting it again';
15
16
 
16
17
  // A board with Restart (hub/restart-board.mjs restartable) keeps its link and chevron around the button, since a
17
- // button can't sit inside a link.
18
+ // button can't sit inside a link. Restart waits (ui.js BUSY) until its POST answers, once the board is back up.
18
19
  function restartRow(s, sub, busy) {
19
20
  const href = esc(s.href);
20
21
  return `<div class="row site-row"><a class="site-link" href="${href}">${s.icon}<span class="text"><span class="title">${esc(s.title)}</span><span class="sub wrap">${sub}</span></span></a>`
21
- + `<button type="button" class="btn quiet restart-btn" data-restart="${esc(s.route)}" data-title="${esc(s.title)}" aria-label="Restart ${esc(s.title)}"${busy ? ' disabled aria-busy="true"' : ''}>${busy ? '<span class="try-spin" aria-hidden="true"></span>Restarting…' : 'Restart'}</button>`
22
+ + `<button type="button" class="btn quiet restart-btn" data-restart="${esc(s.route)}" data-title="${esc(s.title)}" aria-label="Restart ${esc(s.title)}"${busy ? BoardUI.BUSY : ''}>Restart</button>`
22
23
  + `<a class="chev" href="${href}" tabindex="-1" aria-hidden="true">›</a></div>`;
23
24
  }
24
25
 
@@ -39,8 +40,8 @@
39
40
  const fixHtml = (fix) => fix.command ? `<code>${esc(fix.command)}</code>` : fix.settings
40
41
  ? `${esc(fix.text)} <button type="button" class="link" data-settings-open="${esc(fix.settings)}">${esc(fix.label)}</button>.` : esc(fix.text);
41
42
  const rows = integrations.map((it) => row({ icon: tile(it.id, it.ready ? 'var(--color-quiet)' : 'var(--color-notice)'), title: esc(it.name), wrap: true,
42
- sub: it.ready ? `<span class="st ok">Ready</span> · ${esc(it.detail)}`
43
- : `<span class="st wait">Not ready</span> · ${esc(it.missing)}<span class="links"><span class="rd-fix">${fixHtml(it.fix)}</span><span class="rd-waits">${esc(it.waits)}</span></span>` })).join('');
43
+ sub: it.ready ? `<span class="st ok">ready</span> · ${esc(it.detail)}`
44
+ : `<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('');
44
45
  return `<h2 class="label" id="integrations">Integrations</h2><div class="group">${rows}</div>
45
46
  <p class="note">A feature that needs an integration stays off until it’s ready, and says why.</p>
46
47
  `;
@@ -52,11 +53,20 @@
52
53
  return shown.length ? `<section class="waiting"><h2>Needs you · ${shown.length}</h2><ul>${shown.map((p) => BoardUI.promptItem(p, prompts.state(p))).join('')}</ul></section>` : '';
53
54
  }
54
55
 
55
- function html(d, { flash, prompts, restarting }) {
56
+ // Restart hub, while the hub runs older code than its files (hub/views.mjs hubView's hub.restart). It waits (ui.js BUSY)
57
+ // from the tap until the hub answers with its new code and the page reloads.
58
+ const restartHubButton = (busy) => `<button type="button" class="btn quiet restart-btn" id="restart-hub" data-restart-hub${busy ? BoardUI.BUSY : ''}>Restart hub</button>`;
59
+
60
+ function html(d, { flash, prompts, restarting, restartingHub }) {
56
61
  const { row, tile, nowRow } = HubUI;
57
62
  const usage = HubUI.usageCard(d.usage, d.codex, d.pause, d.claude);
63
+ // 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.
64
+ const word = d.hub.restart
65
+ ? `<button type="button" class="st ${esc(d.hub.tone)}" data-restart-hub${restartingHub ? BoardUI.BUSY : ''}>${esc(d.hub.word)}</button>`
66
+ : `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span>`;
58
67
  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>
59
- <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<button type="button" class="st ${esc(d.hub.tone)}" data-jump="integrations">${esc(d.hub.word)}</button> ·${esc(d.hub.detail)}${d.awake ? `<span class="links"><span><span class="awake-on">Keeping awake</span> ${esc(d.awake)}</span></span>` : ''}`, wrap: true })}
68
+ <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,
69
+ trail: d.hub.restart ? restartHubButton(restartingHub) : '' })}
60
70
  ${d.empty ? row({ title: 'No sites yet', sub: 'In a Claude session, run <code>/kickflip &lt;route&gt;</code>.' }) : ''}</div>
61
71
  ${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>
62
72
  <h2 class="label">Polling status</h2>
@@ -102,19 +112,50 @@ ${integrationsHtml(d.integrations)}${sitesHtml(d.groups, restarting)}${d.remembe
102
112
  restarting.delete(route);
103
113
  if (B.alive()) { paint(); load(); }
104
114
  }
115
+ // Restart hub asks the host to restart the hub service (POST /_hub/restart-hub), then waits for the hub to answer
116
+ // with other code than it ran (its x-hub-code) and reloads the app; past HUB_WAIT tries it says how to restart by hand.
117
+ let restartingHub = false;
118
+ const HUB_WAIT = 120;
119
+ async function restartHub() {
120
+ if (restartingHub || !(await BoardDialogs.confirm('This restarts the hub on the host so it runs the code in its files. Its pages stop answering until it’s back, then this page reloads. Restart the hub?'))) return;
121
+ restartingHub = true;
122
+ flash = null;
123
+ paint();
124
+ let out = null, before = null;
125
+ try {
126
+ const r = await fetch('/_hub/restart-hub', { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: '{}' });
127
+ out = await r.json().catch(() => null);
128
+ if (!r.ok || !out) throw new Error();
129
+ before = r.headers.get('x-hub-code');
130
+ } catch {
131
+ restartingHub = false;
132
+ flash = { text: out?.error || 'Could not restart the hub. Is the host awake?', error: true };
133
+ if (B.alive()) paint();
134
+ return;
135
+ }
136
+ for (let i = 0; i < HUB_WAIT && B.alive(); i++) {
137
+ await new Promise((resolve) => setTimeout(resolve, 1000));
138
+ try {
139
+ const r = await fetch('/_hub/api/hub', { cache: 'no-store', headers: { accept: 'application/json' } });
140
+ const code = r.headers.get('x-hub-code');
141
+ if (r.ok && code && code !== before) return AppShell.reload();
142
+ } catch { /* the hub is down while it restarts */ }
143
+ }
144
+ restartingHub = false;
145
+ flash = { text: `The hub hasn’t come back. On the host, run ${out.command}`, error: true };
146
+ if (B.alive()) paint();
147
+ }
105
148
  B.on(main, 'click', (e) => {
106
149
  const opener = e.target.closest?.('[data-settings-open]');
107
150
  if (opener) B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener);
108
- // The Hub's status word jumps to what's behind it: Integrations, then each board's links below them.
109
- const jump = e.target.closest?.('[data-jump]');
110
- if (jump) B.$(`#${jump.dataset.jump}`).scrollIntoView({ behavior: 'smooth', block: 'start' });
151
+ if (e.target.closest?.('[data-restart-hub]')) restartHub();
111
152
  const restarter = e.target.closest?.('[data-restart]');
112
153
  if (restarter) restart(restarter.dataset.restart, restarter.dataset.title);
113
154
  });
114
155
  const foot = B.$('.hub-foot');
115
156
  const paint = () => {
116
157
  if (!last) return;
117
- main.innerHTML = html(last, { flash, prompts, restarting });
158
+ main.innerHTML = html(last, { flash, prompts, restarting, restartingHub });
118
159
  foot.innerHTML = footer(last);
119
160
  };
120
161
  const prompts = PromptAnswers.wire(B, {
@@ -1,5 +1,5 @@
1
1
  // An item's notes, /<route>/notes.html?item=<wave>/<id>, a view of the app shell (shell.js)
2
- // (docs/adr/0053-notes-replace-the-full-plan-and-cards-start-on-their-own.md). Back goes to the item's card.
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 = {
5
5
  className: 'view-notes',