@lovek4d/kickflip 0.3.1 → 0.5.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 (111) hide show
  1. package/CONTEXT.md +170 -118
  2. package/README.md +23 -14
  3. package/SKILL.md +40 -38
  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 +213 -74
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +4 -0
  13. package/board/headless.mjs +4 -0
  14. package/board/merge3.mjs +1 -0
  15. package/board/on-host.mjs +1 -1
  16. package/board/permissions.mjs +2 -0
  17. package/board/public/app-arch.js +2 -2
  18. package/board/public/app-asks.js +39 -103
  19. package/board/public/app-banner.js +2 -2
  20. package/board/public/app-cards.js +166 -73
  21. package/board/public/app-core.js +2 -23
  22. package/board/public/app-header.js +12 -5
  23. package/board/public/app-lanes.js +201 -47
  24. package/board/public/app-needs.js +2 -1
  25. package/board/public/app-reader.js +174 -0
  26. package/board/public/app-snapshot.js +2 -2
  27. package/board/public/app-threads.js +53 -42
  28. package/board/public/app.html +4 -1
  29. package/board/public/app.js +78 -96
  30. package/board/public/attach.js +39 -3
  31. package/board/public/dialogs.js +59 -14
  32. package/board/public/hub-kit.js +9 -6
  33. package/board/public/hub.css +6 -18
  34. package/board/public/linear-key.js +6 -4
  35. package/board/public/log.html +48 -0
  36. package/board/public/new-task.js +121 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +8 -7
  42. package/board/public/rules-editor.js +3 -4
  43. package/board/public/shell.js +24 -6
  44. package/board/public/sidebar.css +22 -1
  45. package/board/public/sidebar.js +64 -31
  46. package/board/public/style.css +245 -64
  47. package/board/public/toasts.js +2 -2
  48. package/board/public/ui-cards.js +205 -75
  49. package/board/public/ui-enums.js +6 -5
  50. package/board/public/ui-header.js +10 -10
  51. package/board/public/ui-hub.js +4 -4
  52. package/board/public/ui-markdown.js +1 -1
  53. package/board/public/ui-order.js +9 -6
  54. package/board/public/ui-page-header.js +10 -7
  55. package/board/public/ui-patch.js +53 -0
  56. package/board/public/ui-phase-bars.js +17 -0
  57. package/board/public/ui-prompts.js +8 -8
  58. package/board/public/ui-questions.js +61 -29
  59. package/board/public/ui-sidebar.js +54 -24
  60. package/board/public/ui-snapshot.js +20 -9
  61. package/board/public/ui-threads.js +40 -21
  62. package/board/public/ui.js +116 -18
  63. package/board/public/view-frame.js +2 -1
  64. package/board/public/view-hub-settings.js +25 -18
  65. package/board/public/view-hub.js +53 -12
  66. package/board/public/view-notes.js +1 -1
  67. package/board/public/view-questions.js +142 -62
  68. package/board/public/view-settings.js +189 -52
  69. package/board/review.mjs +1 -1
  70. package/board/server.mjs +20 -12
  71. package/board/store.mjs +15 -12
  72. package/board/tickets.mjs +3 -3
  73. package/board/worktree.mjs +21 -15
  74. package/docs/hub-session.md +4 -4
  75. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  76. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/close-board.mjs +1 -1
  80. package/hub/front-door.mjs +20 -0
  81. package/hub/github.mjs +4 -4
  82. package/hub/home.mjs +1 -1
  83. package/hub/hub.mjs +11 -14
  84. package/hub/incoming.mjs +2 -0
  85. package/hub/linear.mjs +2 -2
  86. package/hub/live.mjs +2 -2
  87. package/hub/new-board.mjs +2 -2
  88. package/hub/owner-checks.mjs +1 -0
  89. package/hub/permission-denied-hook.mjs +2 -0
  90. package/hub/pr-report.mjs +3 -1
  91. package/hub/presence.mjs +1 -1
  92. package/hub/prompts.mjs +1 -1
  93. package/hub/push.mjs +2 -2
  94. package/hub/readiness.mjs +2 -2
  95. package/hub/repos.mjs +3 -3
  96. package/hub/restart-board.mjs +1 -1
  97. package/hub/schedule.mjs +1 -1
  98. package/hub/sessions.mjs +2 -2
  99. package/hub/sw.js +1 -1
  100. package/hub/try.mjs +8 -4
  101. package/hub/usage.mjs +3 -3
  102. package/hub/views.mjs +1 -1
  103. package/package.json +5 -1
  104. package/shared/card-prs.mjs +1 -0
  105. package/shared/colors.css +5 -4
  106. package/shared/fake-gh.mjs +59 -8
  107. package/shared/owner-check.mjs +3 -0
  108. package/shared/repos.mjs +1 -1
  109. package/shared/tailscale.mjs +1 -0
  110. package/shared/usage-gate.mjs +1 -1
  111. package/board/public/view-polling.js +0 -87
@@ -1,6 +1,7 @@
1
- // A card's thread: Claude's lines, the owner's messages and their replies. Needs ui.js, ui-links.js and ui-enums.js.
1
+ // A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
2
+ // their replies. Needs ui.js, ui-links.js and ui-enums.js.
2
3
  (function () {
3
- const { esc, time, etaLine, replyBody, attachments, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, BUTTON } = globalThis.BoardUI;
4
+ const { esc, time, etaLine, replyBody, attachments, attachButton, dropZone, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
4
5
  const VIA = { slack: ' · Slack', github: ' · GitHub' };
5
6
  // A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
6
7
  function cardLink(link) {
@@ -12,10 +13,13 @@
12
13
  }
13
14
  // A message's state comes from domain.mjs threadOf.
14
15
  const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
15
- const msgFooter = (m) => ({
16
- working: () => `<div class="reply muted small">${workingLine}</div>`,
17
- waiting: () => '<div class="reply muted small">Waiting for Claude…</div>',
18
- }[stateOf(m)] || (() => ''))();
16
+ // A message's footer: the tap it came from (m.status, such as "answers submitted"), then, to its right, Claude's
17
+ // pick-up while it waits (docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
18
+ const PICK_UP = { working: workingLine, waiting: 'Waiting for Claude…' };
19
+ const msgFooter = (m) => {
20
+ const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]].filter(Boolean);
21
+ return parts.length ? `<div class="reply muted small">${parts.join('')}</div>` : '';
22
+ };
19
23
  // Each reply becomes Claude's own message at the time it was written, quoting the owner's message
20
24
  // when other messages came between them. A reply with no time follows its message.
21
25
  const withReplies = (msgs) => {
@@ -24,29 +28,35 @@
24
28
  out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
25
29
  return out.map(({ m, of }, n) => (of ? { ...m, quote: out[n - 1]?.m === of ? '' : of.text || '' } : m));
26
30
  };
27
- // A message being edited shows its edit box in place of its text; app-asks.js handles the form.
28
- const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}"><textarea rows="3">${esc(text)}</textarea>
29
- <div class="row"><span></span><span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
30
- // batched: an answer followed by another of the owner's answers in its state, which carries the batch's Waiting for Claude.
31
+ // A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
32
+ // files it adds to the message, under composer id edit:<event id>.
33
+ const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}" data-composer="edit:${esc(id)}">${dropZone(`<textarea rows="3">${esc(text)}</textarea>`)}
34
+ <div class="pending-files" data-files></div>
35
+ <div class="row">${attachButton()}<span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
36
+ // batched: an answer followed by another of the owner's answers in its state, which carries the batch's footer.
31
37
  const line = (m, links, edits, batched = false) => (m.kind === 'reply'
32
- ? `<div class="msg claude reply-msg"><div class="who">Claude · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
38
+ ? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
33
39
  : m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
34
- : m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
35
- : m.kind === 'calls' ? `<details class="msg calls"><summary>${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
36
- : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}"><span class="agent-dot" aria-hidden="true"></span>${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
40
+ : m.request ? `<div class="msg request">${statusMark(m.text)}${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
41
+ : m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${esc(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
42
+ : m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
43
+ : m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
44
+ : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
37
45
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
38
- <div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Claude' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
46
+ <div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
39
47
  <div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
48
+ ${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log →</a></div>` : ''}
40
49
  ${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
41
50
  ${m.eventId && !batched ? msgFooter(m) : ''}
42
51
  </div>`);
43
52
 
44
53
  const STEP = 3;
45
54
  const UNREAD_CAP = 10;
46
- const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link']);
55
+ const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions']);
47
56
  const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
48
- // Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount).
49
- const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
57
+ // Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
58
+ // orchestrator's answer to a builder's ask never is.
59
+ const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
50
60
  const batchedAt = (lines, i) => {
51
61
  const next = lines.slice(i + 1).find(isMessage);
52
62
  return lines[i].kind === 'owner-answer' && next?.kind === 'owner-answer' && stateOf(next) === stateOf(lines[i]);
@@ -77,14 +87,23 @@
77
87
  }
78
88
 
79
89
  // The reply form's Done sends ask-done or card-done; the board applies it before the session follows up.
80
- // docs/adr/0106-the-board-applies-the-owners-hold-resume-and-done-at-once.md
90
+ // docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md
81
91
  // Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
82
- // (docs/adr/0136-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
92
+ // (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
83
93
  function doneButton(it) {
84
94
  if (!it?.finishable) return '';
85
95
  const tone = it.resolvedBy === BUTTON.markDone ? 'resolves' : it.awaitsDone ? 'closes' : '';
86
96
  return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
87
97
  }
88
98
 
89
- Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton });
99
+ // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
100
+ // A card's open questions asked since seenAt as one blue line in its thread, at the newest one's time, linking to the
101
+ // first: it.questions from the board. → [line], or [] with none new or no seen mark.
102
+ function newQuestionsLine(questions, seenAt) {
103
+ const fresh = seenAt ? (questions?.askedAt || []).filter((t) => String(t) > seenAt) : [];
104
+ if (!fresh.length) return [];
105
+ return [{ ts: fresh.at(-1), who: 'claude', kind: 'questions', text: `${fresh.length} new question${fresh.length === 1 ? '' : 's'}`, link: `questions.html#${encodeURIComponent(questions.first)}` }];
106
+ }
107
+
108
+ Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, newQuestionsLine });
90
109
  })();
@@ -13,6 +13,9 @@
13
13
  if (s < 86400) return Math.round(s / 3600) + 'h ago';
14
14
  return Math.round(s / 86400) + 'd ago';
15
15
  };
16
+ // How long ago a card's status was first set, as its status line ends: "just now", "14m", "3h", "2d"
17
+ // (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md).
18
+ const lasted = (iso) => ago(iso).replace(/ ago$/, '');
16
19
  const since = (iso) => {
17
20
  const s = (Date.now() - new Date(iso).getTime()) / 1000;
18
21
  return s < 60 ? 'just now' : s < 3600 ? Math.round(s / 60) + ' min ago' : s < 86400 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago';
@@ -49,21 +52,33 @@
49
52
  }
50
53
  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
54
  const attachButton = () => `<label class="attach" title="Attach files"><input type="file" multiple data-attach aria-label="Attach files">${CLIP}</label>`;
55
+ // A composer's text box, which attach.js outlines with Drop to attach while files are dragged over the composer.
56
+ const dropZone = (textarea) => `<div class="dz">${textarea}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
52
57
  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
58
  // An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
54
59
  const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
55
60
  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
61
  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
- 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>`;
62
- }
63
- // latestBump is the data.id of the newest ask-bump event.
64
- const protoNotice = (eventId, latestBump) => (latestBump === eventId
65
- ? { text: 'Prototypes are being made.', bump: false }
66
- : { text: 'Prototypes are being made. Bump them to the top of the queue?', bump: true });
62
+ const FIRE = icon('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>');
63
+ // Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
64
+ // question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
65
+ const protoButton = (id, { label = 'Ask for prototypes' } = {}) => `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
66
+ // The thread's fire, the eye's twin for a grill.
67
+ const grillButton = (id) => `<button type="button" class="attach proto-ask" data-grill="${esc(id)}" aria-label="Ask for a grill" title="Ask for a grill">${FIRE}</button>`;
68
+ // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
69
+ // A thread status line's leading mark, its icon or dot centred in one fixed box so every line's text starts at the
70
+ // same place: the request's eye, fire or quick build's lightning, answers' check, new questions' plain question mark, a builder's dot.
71
+ const mini = (body, cls = '') => `<svg class="mark-icon${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
72
+ const MARK_ICONS = {
73
+ 'prototype requested': mini('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
74
+ 'grill requested': mini('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>'),
75
+ 'quick build: no grill, as you asked': mini('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>'),
76
+ 'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
77
+ 'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
78
+ questions: mini('<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .9-1 1.7M12 17h.01"/>', ' big'),
79
+ };
80
+ const mark = (inner = '') => `<span class="mark" aria-hidden="true">${inner}</span>`;
81
+ const statusMark = (words) => mark(MARK_ICONS[words] || '');
67
82
  const PENCIL = icon('<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>');
68
83
  const BUMP = icon('<path d="M5 3h14"/><path d="m18 13-6-6-6 6"/><path d="M12 7v14"/>');
69
84
  // Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
@@ -71,15 +86,15 @@
71
86
  const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
72
87
  // A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange once bumped and disabled on the latest
73
88
  // 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).
89
+ // it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
90
+ // docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
76
91
  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
92
  const cardBumpButton = (key, bumped) => (bumped
78
93
  ? `<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
94
  : `<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';
95
+ const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking</span>';
81
96
  // 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']);
97
+ const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready', 'merged']);
83
98
  function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
84
99
  if (!BADGE_TONES.includes(tone)) throw new Error(`unknown badge tone: ${tone}`);
85
100
  const extra = Object.entries({ href, title, ...attrs }).filter(([, v]) => v != null).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
@@ -212,21 +227,104 @@
212
227
  }
213
228
  lastHtml.set(el, html);
214
229
  if (openDetails.size) for (const d of el.querySelectorAll('details:not([open])')) if (openDetails.has(detailsKey(d))) d.open = true;
230
+ for (const w of waits) { const b = waitingButton(w); if (b && !b.classList.contains(w.mark)) markWaiting(b, w); }
215
231
  return true;
216
232
  }
217
233
 
234
+ // Closes <details> d at once: its toggle event comes a task later, and a setHTML before it would reopen it.
235
+ function closeDetails(d) {
236
+ openDetails.delete(detailsKey(d));
237
+ d.open = false;
238
+ }
239
+
240
+ // 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,
241
+ // docs/adr/2026-11-03-1915-every-pending-control-waits-in-one-style.md): waitOn(control, promise, {group}) disables
242
+ // control until promise settles and marks it tap-waiting and aria-busy. style.css button[aria-busy="true"] keeps a
243
+ // button's colours and width and shows a spinner in its text colour in place of its content, or pulses a bump icon
244
+ // orange. A toggle, select or field keeps its face, with a spinner beside it: after its caption in a .tk-field, else
245
+ // just before it. group: the other choices of the same setting, disabled (tap-blocked) with no spinner meanwhile.
246
+ // A repaint meanwhile (setHTML) patches each back to its markup, so setHTML marks it again, found by its id and data
247
+ // attributes when the repaint replaced it. Settling restores the disabled and aria-busy its latest markup had. → promise
248
+ // Work that outlives its request (a restart, an update, a merge) draws its button with BUSY in its markup until the
249
+ // state the page draws from says the work is done.
250
+ const BUSY = ' disabled aria-busy="true"';
251
+ const waits = new Set();
252
+ function markWaiting(b, w) {
253
+ w.el = b;
254
+ w.disabled = b.disabled;
255
+ w.busy = b.getAttribute('aria-busy');
256
+ b.disabled = true;
257
+ b.classList.add(w.mark);
258
+ if (w.mark !== 'tap-waiting') return;
259
+ b.setAttribute('aria-busy', 'true');
260
+ if (b.tagName === 'BUTTON') return;
261
+ w.spin?.remove();
262
+ w.spin = document.createElement('span');
263
+ w.spin.setAttribute('class', 'try-spin tap-beside');
264
+ w.spin.setAttribute('aria-hidden', 'true');
265
+ const caption = b.closest('.tk-field')?.firstElementChild;
266
+ if (caption && caption !== b) caption.appendChild(w.spin);
267
+ else b.before(w.spin);
268
+ }
269
+ const waitingButton = (w) => (w.el.isConnected && w.el.matches(w.selector) ? w.el
270
+ : w.named ? document.body.querySelector(w.selector) : null);
271
+ function waitFor(el, mark) {
272
+ const named = [...el.attributes].filter(({ name }) => name === 'id' || name.startsWith('data-'));
273
+ const w = { mark, selector: el.tagName.toLowerCase() + named.map(({ name, value }) => (value ? `[${name}="${CSS.escape(value)}"]` : `[${name}]`)).join(''), named: named.length > 0 };
274
+ markWaiting(el, w);
275
+ waits.add(w);
276
+ return w;
277
+ }
278
+ function waitOn(control, promise, { group = [] } = {}) {
279
+ const mine = [waitFor(control, 'tap-waiting'), ...[...group].filter((el) => el !== control).map((el) => waitFor(el, 'tap-blocked'))];
280
+ const done = () => {
281
+ for (const w of mine) {
282
+ waits.delete(w);
283
+ w.spin?.remove();
284
+ // setHTML keeps w.el the node it last marked; one a plain innerHTML replaced is gone with its mark.
285
+ const b = w.el;
286
+ if (!b.classList.contains(w.mark)) continue;
287
+ b.classList.remove(w.mark);
288
+ if (w.mark === 'tap-waiting') { if (w.busy === null) b.removeAttribute('aria-busy'); else b.setAttribute('aria-busy', w.busy); }
289
+ b.disabled = w.disabled;
290
+ }
291
+ };
292
+ promise.then(done, done);
293
+ return promise;
294
+ }
295
+
296
+ // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md
297
+ // A tap whose change shows only in a later board: its button draws BUSY while pending.has(id), id being
298
+ // "<card key>:<action>", whatever repaints it, until settle(board) finds a board made after the tap's answer for which
299
+ // until(board) holds. start(id, until) on the tap; answered(id, generatedAt) with the generatedAt of the POST's board;
300
+ // drop(id) when the POST is refused. Boards made before the POST answered never end it, so a reload that races the POST
301
+ // keeps the spinner.
302
+ const pendings = new Map();
303
+ const pending = {
304
+ start(id, until) { pendings.set(id, { until, after: null }); },
305
+ answered(id, after) { const p = pendings.get(id); if (p) p.after = after; },
306
+ drop(id) { pendings.delete(id); },
307
+ has: (id) => pendings.has(id),
308
+ settle(board) {
309
+ for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pendings.delete(id);
310
+ },
311
+ };
312
+
218
313
  // While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
219
314
  // on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
220
315
  // then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
316
+ // tag is the version of the board the page holds: an unchanged board answers 304, which resolves with body null
317
+ // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
221
318
  const RESTARTING = 'Board restarting…';
222
319
  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)) } = {}) {
320
+ async function fetchJSON(url, { onWait = () => {}, limit = 30000, tag = null, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
224
321
  let waited = 0;
225
322
  let delay = 500;
226
323
  for (;;) {
227
324
  let failure;
228
325
  try {
229
- const r = await get(url, { cache: 'no-store' });
326
+ const r = await get(url, { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) });
327
+ if (r.status === 304) return { ok: true, status: 304, body: null };
230
328
  const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
231
329
  if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
232
330
  failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
@@ -241,7 +339,7 @@
241
339
 
242
340
  globalThis.BoardUI = {
243
341
  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,
342
+ esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, RESTARTING, restartNotice,
343
+ attachments, attachButton, dropZone, pendingFiles, protoButton, grillButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
246
344
  };
247
345
  })();
@@ -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
@@ -85,6 +85,7 @@
85
85
  B.on(doc, 'click', (e) => tap(e, win));
86
86
  // Switch mode (sidebar.js) answers keys and clicks in the frame as it does the shell's own.
87
87
  if (ctx.keydown) B.on(doc, 'keydown', ctx.keydown, true);
88
+ if (B.newTaskKey) B.on(doc, 'keydown', B.newTaskKey);
88
89
  if (ctx.pointerdown) B.on(doc, 'pointerdown', ctx.pointerdown, true);
89
90
  ctx.loaded(url, title);
90
91
  });
@@ -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',