@lovek4d/kickflip 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CONTEXT.md +105 -68
  2. package/README.md +1 -1
  3. package/SKILL.md +27 -21
  4. package/bin/kickflip.mjs +27 -18
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -0
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +199 -79
  9. package/board/build-script.mjs +2 -1
  10. package/board/domain.mjs +9 -7
  11. package/board/finish.mjs +4 -4
  12. package/board/headless.mjs +9 -4
  13. package/board/on-host.mjs +1 -1
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-asks.js +6 -6
  19. package/board/public/app-banner.js +7 -13
  20. package/board/public/app-cards.js +75 -37
  21. package/board/public/app-core.js +26 -8
  22. package/board/public/app-header.js +8 -8
  23. package/board/public/app-lanes.js +67 -58
  24. package/board/public/app-needs.js +140 -10
  25. package/board/public/app-reader.js +1 -1
  26. package/board/public/app-snapshot.js +46 -72
  27. package/board/public/app-threads.js +78 -75
  28. package/board/public/app.html +9 -4
  29. package/board/public/app.js +114 -38
  30. package/board/public/attach.js +2 -2
  31. package/board/public/dialogs.js +9 -39
  32. package/board/public/hub-kit.js +66 -4
  33. package/board/public/hub.css +2 -0
  34. package/board/public/linear-key.js +46 -35
  35. package/board/public/new-task.js +20 -39
  36. package/board/public/progress-review.html +46 -0
  37. package/board/public/progress-review.js +158 -0
  38. package/board/public/prototype-kit.js +137 -7
  39. package/board/public/prototype-switcher.js +152 -123
  40. package/board/public/prototype.html +57 -3
  41. package/board/public/router.js +7 -6
  42. package/board/public/shell.js +68 -26
  43. package/board/public/sidebar.css +1 -0
  44. package/board/public/sidebar.js +16 -30
  45. package/board/public/style.css +205 -83
  46. package/board/public/ui-cards.js +91 -65
  47. package/board/public/ui-enums.js +1 -1
  48. package/board/public/ui-header.js +27 -18
  49. package/board/public/ui-hub.js +12 -1
  50. package/board/public/ui-links.js +7 -7
  51. package/board/public/ui-order.js +12 -10
  52. package/board/public/ui-page-header.js +19 -7
  53. package/board/public/ui-patch.js +7 -33
  54. package/board/public/ui-progress-bar.js +28 -0
  55. package/board/public/ui-questions.js +47 -52
  56. package/board/public/ui-sidebar.js +34 -26
  57. package/board/public/ui-snapshot.js +43 -52
  58. package/board/public/ui-threads.js +59 -30
  59. package/board/public/ui.js +125 -18
  60. package/board/public/view-hub-settings.js +22 -67
  61. package/board/public/view-hub.js +19 -4
  62. package/board/public/view-notes.js +27 -7
  63. package/board/public/view-questions.js +205 -240
  64. package/board/public/view-settings.js +313 -76
  65. package/board/render-check.mjs +10 -0
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +19 -20
  68. package/board/stop.mjs +1 -0
  69. package/board/store.mjs +29 -15
  70. package/board/sync.mjs +2 -0
  71. package/board/tickets.mjs +3 -3
  72. package/board/transcript.mjs +2 -0
  73. package/board/worktree.mjs +25 -20
  74. package/hooks-plugin/hooks/board-data-gate.mjs +50 -0
  75. package/hooks-plugin/hooks/hooks.json +9 -0
  76. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/front-door.mjs +5 -5
  80. package/hub/github.mjs +3 -3
  81. package/hub/hub.mjs +11 -11
  82. package/hub/incoming.mjs +2 -2
  83. package/hub/linear.mjs +1 -2
  84. package/hub/live.mjs +2 -2
  85. package/hub/locks.mjs +1 -1
  86. package/hub/owner-checks.mjs +1 -1
  87. package/hub/pr-report.mjs +2 -2
  88. package/hub/presence.mjs +1 -1
  89. package/hub/push.mjs +2 -2
  90. package/hub/readiness.mjs +1 -2
  91. package/hub/review-send.mjs +1 -0
  92. package/hub/secrets.mjs +2 -0
  93. package/hub/self-restart.mjs +2 -0
  94. package/hub/sessions.mjs +2 -2
  95. package/hub/share.mjs +1 -0
  96. package/hub/slack.mjs +2 -2
  97. package/hub/stream.mjs +10 -13
  98. package/hub/try.mjs +7 -7
  99. package/hub/upgrade.mjs +1 -1
  100. package/hub/usage.mjs +3 -3
  101. package/hub/views.mjs +1 -1
  102. package/package.json +2 -1
  103. package/shared/audit.mjs +4 -0
  104. package/shared/card-prs.mjs +1 -1
  105. package/shared/fake-gh.mjs +17 -4
  106. package/shared/machine-gate.mjs +2 -0
  107. package/shared/prototype-frame.js +1 -0
  108. package/shared/service.mjs +4 -4
  109. package/shared/usage-gate.mjs +1 -1
  110. package/board/public/ui-phase-bars.js +0 -17
@@ -1,7 +1,7 @@
1
1
  // A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
2
2
  // their replies. Needs ui.js, ui-links.js and ui-enums.js.
3
3
  (function () {
4
- const { esc, time, etaLine, replyBody, attachments, attachButton, dropZone, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
4
+ const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
5
5
  const VIA = { slack: ' · Slack', github: ' · GitHub' };
6
6
  // A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
7
7
  function cardLink(link) {
@@ -15,52 +15,79 @@
15
15
  const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
16
16
  // A message's footer: the tap it came from (m.status, such as "answers submitted"), then, to its right, Claude's
17
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…' };
18
+ const PICK_UP = { working: thinkingLine, waiting: () => 'Waiting for Claude…' };
19
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);
20
+ const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]?.()].filter(Boolean);
21
21
  return parts.length ? `<div class="reply muted small">${parts.join('')}</div>` : '';
22
22
  };
23
23
  // Each reply becomes Claude's own message at the time it was written, quoting the owner's message
24
24
  // when other messages came between them. A reply with no time follows its message.
25
25
  const withReplies = (msgs) => {
26
+ // A folded submit's answers each reply, quoting their own block.
26
27
  const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
27
- ...(m.eventId && m.reply ? [{ m: { ts: m.reply.at || m.ts, who: 'claude', kind: 'reply', reply: m.reply }, of: m, at: String(m.reply.at || m.ts), i, part: 1 }] : [])]);
28
+ ...(m.answers || [m]).filter((a) => a.eventId && a.reply).map((a) => ({ m: { ts: a.reply.at || m.ts, who: 'claude', kind: 'reply', reply: a.reply }, of: m, quoted: m.answers ? a.text : m.text, at: String(a.reply.at || m.ts), i, part: 1 }))]);
28
29
  out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
29
- return out.map(({ m, of }, n) => (of ? { ...m, quote: out[n - 1]?.m === of ? '' : of.text || '' } : m));
30
+ return out.map(({ m, of, quoted }, n) => (of ? { ...m, quote: out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
30
31
  };
31
32
  // A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
32
33
  // files it adds to the message, under composer id edit:<event id>.
33
34
  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
35
  <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.
37
- const line = (m, links, edits, batched = false) => (m.kind === 'reply'
36
+ <div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
37
+ const line = (m, links, edits) => (m.kind === 'reply'
38
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>`
39
- : m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
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>`
39
+ : m.kind === 'ask-link' ? `<div class="msg link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
40
+ // docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md: a visual's page opens full page.
41
+ : m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
42
+ // A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
43
+ : m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
44
+ : m.request ? `<div class="msg request">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
45
+ // docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
46
+ : m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
47
+ : m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
48
+ : m.folded ? foldedRun(m, links, edits)
42
49
  : 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)}"` : ''}>
50
+ : m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${escText(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>`
51
+ : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${escText(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)}"` : ''}>
45
52
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</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>
53
+ <div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : '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>
47
54
  <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>` : ''}
55
+ ${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log</a></div>` : ''}
49
56
  ${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
50
- ${m.eventId && !batched ? msgFooter(m) : ''}
57
+ ${m.eventId ? msgFooter(m) : ''}
51
58
  </div>`);
52
59
 
60
+ // One agent run (domain.mjs threadOf gives its started line, status lines and finished line the same `run`) is one
61
+ // closed details line at its started line's place: the agent, running with its newest status text or finished, and
62
+ // no line count, and ending with a log link beside the summary when the run has a log; expanding it shows the run's lines.
63
+ function foldedRun(m, links, edits) {
64
+ const statuses = m.folded.filter((f) => f.kind === 'status');
65
+ const running = !m.folded.some((f) => f.finished);
66
+ const newest = statuses.at(-1);
67
+ const tail = running ? (newest ? `running · ${escText(newest.text)}` : 'running') : 'finished';
68
+ const log = m.folded.find((f) => f.log)?.log;
69
+ const logLink = log ? ` <a class="fold-log" href="log.html?file=${encodeURIComponent(log)}">log</a>` : '';
70
+ return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${running ? '' : ' class="finished"'}>${statusMark('status lines')}${escText(m.agent)} · ${tail}${logLink}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}</details>`;
71
+ }
72
+ function foldStatus(lines) {
73
+ const runs = new Map();
74
+ for (const m of lines) if (m.run && (m.kind === 'status' || m.kind === 'agent')) runs.set(m.run, [...(runs.get(m.run) || []), m]);
75
+ return lines.flatMap((m) => {
76
+ const run = m.run && (m.kind === 'status' || m.kind === 'agent') ? runs.get(m.run) : null;
77
+ if (!run) return [m];
78
+ return m === run[0] ? [{ ...m, folded: run }] : [];
79
+ });
80
+ }
81
+
53
82
  const STEP = 3;
54
83
  const UNREAD_CAP = 10;
55
- const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions']);
84
+ // Every kind of system line; each leads with a mark, and a test renders each, so a new kind needs an entry in ui.js MARK_ICONS.
85
+ const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move'];
86
+ const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move']);
56
87
  const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
57
88
  // Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
58
89
  // 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;
60
- const batchedAt = (lines, i) => {
61
- const next = lines.slice(i + 1).find(isMessage);
62
- return lines[i].kind === 'owner-answer' && next?.kind === 'owner-answer' && stateOf(next) === stateOf(lines[i]);
63
- };
90
+ const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
64
91
  // The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
65
92
  function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
66
93
  const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
@@ -79,21 +106,23 @@
79
106
  // draws the open card's window, so lines after `upto` add below without hiding older ones; null draws every line.
80
107
  function threadMessages(msgs, links = {}, edits = new Map(), view = null) {
81
108
  if (!msgs.length) return '<div class="muted small">No messages here yet.</div>';
82
- const lines = withReplies(msgs);
109
+ const lines = foldStatus(withReplies(msgs));
83
110
  const { start, hidden } = view ? threadWindow(lines, view, edits) : { start: 0, hidden: 0 };
84
111
  const shown = lines.slice(start);
85
112
  if (start && shown[0].kind === 'reply' && !shown[0].quote) shown[0] = { ...shown[0], quote: lines[start - 1].text || '' };
86
- return (hidden ? moreButton(hidden) : '') + shown.map((m, i) => line(m, links, edits, batchedAt(lines, start + i))).join('');
113
+ return (hidden ? moreButton(hidden) : '') + shown.map((m) => line(m, links, edits)).join('');
87
114
  }
88
115
 
89
- // The reply form's Done sends ask-done or card-done; the board applies it before the session follows up.
90
- // docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md
116
+ // docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md
117
+ // An In Review card's Done, the second of its thread box's pair after Reply, on ⌥↵. It confirms and sends ask-done or
118
+ // card-done (app-cards.js); the board applies it before the session follows up
119
+ // (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
91
120
  // Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
92
121
  // (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
122
+ const doneTone = (it) => (it.resolvedBy === BUTTON.done ? 'resolves' : it.awaitsDone ? 'closes' : '');
93
123
  function doneButton(it) {
94
- if (!it?.finishable) return '';
95
- const tone = it.resolvedBy === BUTTON.markDone ? 'resolves' : it.awaitsDone ? 'closes' : '';
96
- return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
124
+ const tone = doneTone(it);
125
+ return sendButton('Done', 1, `${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}"`);
97
126
  }
98
127
 
99
128
  // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
@@ -105,5 +134,5 @@
105
134
  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
135
  }
107
136
 
108
- Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, newQuestionsLine });
137
+ Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, doneTone, newQuestionsLine, SYSTEM_KINDS });
109
138
  })();
@@ -52,29 +52,66 @@
52
52
  }
53
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>';
54
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>`;
55
+ // A composer's text box with its attach button inside at the bottom right, which attach.js outlines with Drop to
56
+ // attach while files are dragged over the composer. attach: false leaves the button out, for a composer that puts it
57
+ // on its row (app-threads.js).
58
+ const dropZone = (textarea, { attach = true } = {}) => `<div class="dz${attach ? '' : ' bare'}">${textarea}${attach ? attachButton() : ''}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
59
+ // docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md
60
+ // A composer's send buttons: the one with nth 0 presses on ⌘↵ and the one with nth 1, where there is one, on ⌥↵,
61
+ // wherever each sits in the row (Ctrl+↵ and
62
+ // Alt+↵ off a Mac), each with its key as a small gray hint that style.css shows only where there is a keyboard and room.
63
+ const MAC = /Mac|iPhone|iPad/.test(globalThis.navigator?.platform || globalThis.navigator?.userAgent || '');
64
+ const SEND_KEYS = MAC ? ['⌘↵', '⌥↵'] : ['Ctrl+↵', 'Alt+↵'];
65
+ const sendButton = (label, nth, attrs = '') => `<button type="submit"${nth === 0 ? ' class="primary"' : ''} data-nth="${nth}"${attrs}>${esc(label)}<span class="key-hint" aria-hidden="true">${SEND_KEYS[nth]}</span></button>`;
66
+ // The send button an Enter keydown in form's box presses, or null.
67
+ const keyedSend = (ev, form) => {
68
+ if (ev.key !== 'Enter' || ev.isComposing) return null;
69
+ const nth = (ev.metaKey || ev.ctrlKey) && !ev.altKey ? 0 : ev.altKey && !ev.metaKey && !ev.ctrlKey ? 1 : -1;
70
+ return nth < 0 ? null : form.querySelector(`button[type="submit"][data-nth="${nth}"]`);
71
+ };
57
72
  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>`;
58
73
  // An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
59
74
  const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
75
+ // Escaped text with each ↗ (and a trailing U+FE0E) drawn as LINK_ARROW; for owner-facing text, never an attribute.
76
+ const escText = (s) => esc(s).replace(/\u2197\uFE0E?/g, LINK_ARROW);
60
77
  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"/>');
61
78
  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"/>');
62
79
  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"/>');
80
+ const IMAGE_PATHS = '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>';
63
81
  // Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
64
82
  // question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
65
83
  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
84
  // The thread's fire, the eye's twin for a grill.
67
85
  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>`;
86
+ // The thread's image, asking for a visual: a page an agent draws to answer in the thread (app-threads.js;
87
+ // docs/adr/2026-11-05-1921-a-visual-is-a-page-an-agent-draws-for-a-thread.md).
88
+ const visualButton = (id) => `<button type="button" class="attach proto-ask" data-visual="${esc(id)}" aria-label="Ask for a visual" title="Ask for a visual">${icon(IMAGE_PATHS)}</button>`;
68
89
  // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
69
90
  // 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.
91
+ // same place: a command line's icon by its words (domain.mjs COMMANDS: the request's eye, fire or image, Implement's
92
+ // code brackets, Hold's pause, Resume's play, Done's circled check), Reply's arrow, answers' check, a reopened
93
+ // question's turn-back arrow, new questions' plain question mark, a builder's dot.
71
94
  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>`;
95
+ const LINK = mini('<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>');
72
96
  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"/>'),
97
+ '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"/>'),
98
+ '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"/>'),
99
+ 'Visual requested': mini(IMAGE_PATHS),
100
+ 'Re-ask requested': mini('<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/><path d="M3 21v-5h5"/>'),
101
+ visual: mini(IMAGE_PATHS),
102
+ 'ask-link': mini('<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'),
103
+ move: mini('<path d="M3 7h14"/><path d="m13 3 4 4-4 4"/><path d="M21 17H7"/><path d="m11 13-4 4 4 4"/>'),
104
+ 'Implementation requested': mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
105
+ Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
106
+ Resumed: mini('<path d="M7 4v16l13-8z"/>'),
107
+ 'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
108
+ 'Marked done': mini('<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>'),
109
+ Reply: mini('<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v5"/>'),
110
+ Implement: mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
76
111
  'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
112
+ 'question reopened': mini('<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>'),
77
113
  'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
114
+ 'status lines': mini('<path d="m9 6 6 6-6 6"/>'),
78
115
  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
116
  };
80
117
  const mark = (inner = '') => `<span class="mark" aria-hidden="true">${inner}</span>`;
@@ -93,6 +130,12 @@
93
130
  ? `<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>`
94
131
  : `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
95
132
  const workingLine = '<span class="thinking"><span class="working-dot" aria-hidden="true"></span>thinking</span>';
133
+ // Every paused status, whatever its place, is this one class (style.css .paused); BoardUI.usagePause is set by the
134
+ // board view (app.js) to read the hub's usage pause.
135
+ const PAUSED = '<span class="paused">paused</span>';
136
+ const isPaused = () => BoardUI.usagePause?.()?.state === 'paused';
137
+ // The orchestrator's "thinking" while its usage allows, else paused.
138
+ const thinkingLine = () => (isPaused() ? PAUSED : workingLine);
96
139
  // html is markup the caller has escaped; href, title and attrs values are escaped here.
97
140
  const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready', 'merged']);
98
141
  function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
@@ -114,11 +157,11 @@
114
157
  // under el it was, so a card that moves lanes keeps its open thread. A textarea keeps what was typed in it, and an
115
158
  // element matching opts.keep stays as it is, for markup another part paints. The focused field keeps its focus when
116
159
  // its card moves (focusSafe). setHTML skips an unchanged rebuild, and reopens each <details> the reader left open,
117
- // matched by its owning [data-key] and summary text. Open state is tracked page-wide, so content filled in after a
160
+ // matched by its owning [data-key] and summary text (a card itself by its key alone, its summary text changing as it ages). Open state is tracked page-wide, so content filled in after a
118
161
  // parent rebuild reopens too.
119
162
  const lastHtml = new WeakMap();
120
163
  const openDetails = new Set();
121
- const detailsKey = (d) => `${(d.closest('[data-key]') || {}).dataset?.key || ''}|${(d.querySelector(':scope > summary')?.textContent || '').trim()}`;
164
+ const detailsKey = (d) => d.matches('[data-key]') ? d.dataset.key : `${(d.closest('[data-key]') || {}).dataset?.key || ''}|${(d.querySelector(':scope > summary')?.textContent || '').trim()}`;
122
165
  globalThis.document?.addEventListener('toggle', (ev) => {
123
166
  if (ev.target.tagName !== 'DETAILS') return;
124
167
  if (ev.target.open) openDetails.add(detailsKey(ev.target)); else openDetails.delete(detailsKey(ev.target));
@@ -299,22 +342,83 @@
299
342
  // until(board) holds. start(id, until) on the tap; answered(id, generatedAt) with the generatedAt of the POST's board;
300
343
  // drop(id) when the POST is refused. Boards made before the POST answered never end it, so a reload that races the POST
301
344
  // keeps the spinner.
345
+ // A tap whose answer carries no board (a bare fetch) calls answered(id) with no time: any board then ends it, so its
346
+ // until() must hold only for a change the tap caused. start(id, until, onLate): if no board ends it within
347
+ // PENDING_LATE_MS the entry drops and onLate() runs (the page reloads once and says so).
348
+ const PENDING_LATE_MS = 60000;
302
349
  const pendings = new Map();
303
350
  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); },
351
+ start(id, until, onLate = null) {
352
+ pending.drop(id);
353
+ const late = onLate && setTimeout(() => { pendings.delete(id); onLate(); }, PENDING_LATE_MS);
354
+ pendings.set(id, { until, after: null, late });
355
+ },
356
+ answered(id, after = '') { const p = pendings.get(id); if (p) p.after = after; },
357
+ drop(id) { clearTimeout(pendings.get(id)?.late); pendings.delete(id); },
307
358
  has: (id) => pendings.has(id),
359
+ // A tap whose change is drawn by the page's own load, not by a board (a thread, a page's redraw): resolves when work
360
+ // settles, or after PENDING_LATE_MS calls onLate() and resolves, so waitOn's spinner rests either way.
361
+ within(work, onLate) {
362
+ let timer;
363
+ const late = new Promise((resolve) => { timer = setTimeout(() => { onLate(); resolve(); }, PENDING_LATE_MS); });
364
+ return Promise.race([work, late]).finally(() => clearTimeout(timer));
365
+ },
308
366
  settle(board) {
309
- for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pendings.delete(id);
367
+ for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pending.drop(id);
310
368
  },
311
369
  };
312
370
 
371
+ // Why a board request failed, from what the hub marks on its answer (hub/hub.mjs sendDown, hub/front-door.mjs refuse)
372
+ // (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md). answer is the response, or null when
373
+ // nothing answered or the request timed out; online is navigator.onLine. → {state, line, hint, retries}: the landing's
374
+ // two lines, and whether the board can come back by itself, so the page keeps retrying.
375
+ const BACK = 'This page comes back by itself.';
376
+ const DOWN = {
377
+ restarting: { line: 'Board restarting' },
378
+ stopped: { line: 'Board stopped', hint: 'Nothing answers on its port. Its Claude session has to start it.' },
379
+ removed: { line: 'This board is gone', hint: 'The hub no longer lists it.', retries: false },
380
+ access: { line: 'You no longer have access', hint: 'Ask its owner to share it again.', retries: false },
381
+ erroring: { line: 'Board restarting', hint: 'It answers with errors. Retrying for 30 seconds.' },
382
+ unreachable: { line: 'Board offline', hint: 'The hub, your connection or the Mac is down. Retrying.' },
383
+ };
384
+ const RESTART_WHY = { code: 'Its code changed.', starting: 'It’s starting.', crashed: 'It stopped, and the hub is starting it again.' };
385
+ function downOf(answer, online = navigator.onLine !== false) {
386
+ const why = online && answer ? answer.headers.get('x-kickflip-down') : null;
387
+ const state = !online || !answer ? 'unreachable'
388
+ : RESTART_WHY[why] ? 'restarting'
389
+ : DOWN[why] ? why
390
+ : answer.headers.get('x-app-shell') ? 'access'
391
+ : why || answer.headers.get('x-kickflip-hub') ? 'erroring' : 'unreachable';
392
+ return { state, retries: true, ...DOWN[state], ...(state === 'restarting' ? { hint: `${RESTART_WHY[why]} ${BACK}` } : {}) };
393
+ }
394
+ // A view's place when it has no copy to show: the kickflip mark and down's two lines, as the no-access page has them.
395
+ const downLanding = (down) => `<div class="down-landing" data-down="${down.state}"><img class="down-landing-mark" src="/_hub/kickflip.png" width="70" height="60" alt="">
396
+ <p class="down-landing-line">${esc(down.line)}</p><p class="down-landing-hint">${esc(down.hint)}</p></div>`;
397
+ // The line over a side page painted from its copy while the board can't be reached.
398
+ const copyNote = (what, savedAt) => `<p class="note copy-note">Copy of ${esc(what)} as of ${since(savedAt)}. Read only.</p>`;
399
+ // The buttons that send, which a board out of reach holds (style.css .offline): every one greyed and stopped, keeping its
400
+ // label and size. Reading controls, folds, links, Edit and drafts stay live.
401
+ const SENDS = `form button[type="submit"], .actions button, button[data-confirm], button[data-deck-pick], button[data-review-answer], button[data-prompt-send], button[data-prompt-choice], button[data-quick], button[data-card-bump], button[data-bump], button[data-withdraw], button[data-band-move], button[data-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]`;
402
+ // While held(), a tap on one of root's sending buttons, and any form's submit there, Enter's included, goes nowhere, and onHeld
403
+ // tells the tap it is waiting. on is the view's B.on.
404
+ function holdSends(on, root, held, onHeld) {
405
+ const stop = (ev) => {
406
+ if (!held() || (ev.type === 'click' && !ev.target.closest?.(SENDS))) return;
407
+ ev.preventDefault();
408
+ ev.stopImmediatePropagation();
409
+ onHeld();
410
+ };
411
+ on(root, 'click', stop, true);
412
+ on(root, 'submit', stop, true);
413
+ }
414
+
313
415
  // While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
314
- // on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
315
- // then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
416
+ // on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs, down) and retries with a growing backoff,
417
+ // then throws once about limit ms have passed, the error carrying down (downOf). Any other answer resolves
418
+ // {ok, status, body}, a 404's {error} included.
316
419
  // tag is the version of the board the page holds: an unchanged board answers 304, which resolves with body null
317
420
  // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
421
+ const WAITING = 'Waiting for the board to reconnect';
318
422
  const RESTARTING = 'Board restarting…';
319
423
  const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
320
424
  async function fetchJSON(url, { onWait = () => {}, limit = 30000, tag = null, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
@@ -322,15 +426,18 @@
322
426
  let delay = 500;
323
427
  for (;;) {
324
428
  let failure;
429
+ let answer = null;
325
430
  try {
326
431
  const r = await get(url, { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) });
327
432
  if (r.status === 304) return { ok: true, status: 304, body: null };
328
433
  const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
329
434
  if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
435
+ answer = r;
330
436
  failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
331
437
  } catch { failure = new Error('Offline. Is the host awake?'); }
438
+ failure.down = downOf(answer);
332
439
  if (waited >= limit) throw failure;
333
- onWait(waited);
440
+ onWait(waited, failure.down);
334
441
  await sleep(delay);
335
442
  waited += delay;
336
443
  delay = Math.min(delay * 2, 4000);
@@ -338,8 +445,8 @@
338
445
  }
339
446
 
340
447
  globalThis.BoardUI = {
341
- LINK_ARROW,
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,
448
+ LINK_ARROW, escText,
449
+ esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, WAITING, RESTARTING, restartNotice, downOf, downLanding, copyNote, SENDS, holdSends,
450
+ attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
344
451
  };
345
452
  })();
@@ -1,6 +1,6 @@
1
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
2
+ // settingsView's words from GET /_hub/api/settings. Pause hub, first, posts to /_hub/settings/hub-pause. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
3
+ // hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key and Slack's token do through linear-key.js; Done waits for
4
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 () {
@@ -9,28 +9,10 @@
9
9
  const { esc } = HubUI;
10
10
  const ACTION = '/_hub/settings/notifications';
11
11
 
12
- // datetime-local speaks the device's clock; the picker's hidden field carries its UTC ISO value to the hub.
13
- const localInput = (d) => {
14
- const pad = (n) => String(n).padStart(2, '0');
15
- return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
16
- };
17
- function customBounds(input, reset = false) {
18
- const now = Date.now();
19
- input.min = localInput(new Date(now + 60_000));
20
- input.max = localInput(new Date(now + 30 * 86_400_000));
21
- if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
22
- }
23
-
24
- // The one duration picker Snooze and Keep awake share (hub/duration.mjs): chips that post `name` to `action` at once,
25
- // then Custom, whose date and time posts as `name` too. While its setting is on, `on` replaces it with when it ends
26
- // and a button that posts `name`=off.
27
- function durationPicker({ id, title, icon, action, name, choices, on, button }) {
28
- if (on) return `<div class="row dur-on">${icon}<span class="body"><span class="text title">${esc(on.title)}</span><form method="post" action="${action}"><button class="btn quiet" name="${name}" value="off">${button}</button></form></span></div>`;
29
- if (!choices) return '';
30
- const chips = choices.map((c) => `<button class="btn quiet" name="${name}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
31
- return `<div class="block" role="group" aria-labelledby="${id}Title"><div class="dur-head">${icon}<span class="title" id="${id}Title">${title}</span></div><div class="dur-chips"><form class="dur-choices" method="post" action="${action}">${chips}</form><form class="dur-custom" method="post" action="${action}"><input type="hidden" name="${name}"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-dur-at required aria-label="Custom ${title.toLowerCase()} until a date and time"></form></div></div>`;
32
- }
33
-
12
+ const { durationPicker } = HubKit;
13
+ // Pause hub (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md), gray as every paused status.
14
+ const pauseRow = (p) => durationPicker({ id: 'pauseHub', title: 'Pause hub', icon: HubUI.tile('pause', 'var(--color-quiet)'),
15
+ action: '/_hub/settings/hub-pause', name: 'pause', choices: p.choices, on: p.on, button: 'Resume' });
34
16
  const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
35
17
  choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
36
18
  // Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
@@ -58,7 +40,7 @@
58
40
  + `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
59
41
  }
60
42
 
61
- const sections = [['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
43
+ const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['keep-awake', 'Keep awake'], ['linear', 'Linear'], ['slack', 'Slack'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
62
44
  const sectionOf = (hash) => {
63
45
  const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
64
46
  return sections.some(([section]) => section === id) ? id : null;
@@ -79,39 +61,43 @@
79
61
  let flash = null;
80
62
  let rules = null;
81
63
  let rulesJSON = '';
82
- let linearKey = null;
64
+ let tokenSlot = null;
65
+ const pickers = HubKit.durationPickers(B);
83
66
 
84
67
  function paint() {
85
68
  if (!last) return;
86
69
  const { row, tile, deviceRow } = HubUI;
87
70
  top.innerHTML = HubUI.flash(flash);
88
71
  const scrollTop = B.$('.settings-body').scrollTop;
89
- if (active === 'notifications') {
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>`;
72
+ if (active === 'pause') {
73
+ content.innerHTML = `<div class="group">${pauseRow(last.pause)}</div><p class="note">Running steps finish. No new builders, planners or pulls start on any board until then, as when usage reaches the pause line.</p>`;
74
+ } else if (active === 'notifications') {
75
+ content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
91
76
  } else if (active === 'quiet') {
92
77
  content.innerHTML = quietRows(last.quiet);
93
78
  } else if (active === 'keep-awake') {
94
79
  content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
95
80
  : `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -ims</code> on the host so it doesn’t sleep while boards work. The screen can still turn off.</p>`;
96
- } else if (active === 'linear') {
97
- if (linearKey) linearKey.set(last.linear);
81
+ } else if (active === 'linear' || active === 'slack') {
82
+ if (tokenSlot) tokenSlot.set(last[active]);
98
83
  else {
99
- linearKey = LinearKey.mount(content, { linear: last.linear,
84
+ tokenSlot = LinearKey.mount(content, { kind: active, state: last[active],
100
85
  saved(out) { last = out; flash = null; paint(); ctx.sidebarChanged?.(); },
101
86
  busy: (on) => LinearKey.doneBusy(modal.querySelectorAll('[data-settings-dismiss]'), on) });
102
87
  }
103
88
  } else if (active === 'closed') {
104
89
  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>`;
105
90
  }
106
- const input = B.$('[data-dur-at]');
107
- if (input) customBounds(input, true);
91
+ pickers.painted();
108
92
  B.$('.settings-body').scrollTop = scrollTop;
109
93
  B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
110
94
  const summaries = {
95
+ pause: last.pause.summary,
111
96
  notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
112
97
  quiet: last.quiet.summary,
113
98
  'keep-awake': last.awake.summary,
114
99
  linear: last.linear?.summary || 'No key',
100
+ slack: last.slack?.summary || 'No token',
115
101
  rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
116
102
  closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
117
103
  };
@@ -152,7 +138,7 @@
152
138
  function closeModal(restore = true) {
153
139
  unfitModal?.(); unfitModal = null;
154
140
  modal.close();
155
- linearKey?.destroy(); linearKey = null;
141
+ tokenSlot?.destroy(); tokenSlot = null;
156
142
  content.innerHTML = '';
157
143
  rules = null;
158
144
  active = null;
@@ -186,8 +172,8 @@
186
172
  return true;
187
173
  };
188
174
  const dismiss = () => {
189
- if (!linearKey?.busy()) { B.openSettings({ kind: 'hub' }); return; }
190
- linearKey.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
175
+ if (!tokenSlot?.busy()) { B.openSettings({ kind: 'hub' }); return; }
176
+ tokenSlot.settled().then(() => { if (B.alive()) B.openSettings({ kind: 'hub' }); });
191
177
  };
192
178
  B.on(modal, 'cancel', (e) => { e.preventDefault(); dismiss(); });
193
179
  B.on(root, 'click', (e) => {
@@ -206,37 +192,6 @@
206
192
  B.atUnmount(() => closeModal(false));
207
193
  const device = HubKit.device(B, { paint, changed: () => ctx.sidebarChanged?.() });
208
194
  device.ready.then(() => { if (B.alive()) paint(); });
209
- let openPicker = null;
210
- B.on(root, 'click', (e) => {
211
- const input = e.target.closest?.('[data-dur-at]');
212
- if (!input) return;
213
- openPicker = input;
214
- customBounds(input, input.value === input.defaultValue);
215
- try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
216
- });
217
- // A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
218
- B.on(root, 'pointerdown', (e) => {
219
- const input = e.target.closest?.('.dur-choices button') && B.$('[data-dur-at]');
220
- if (!input) return;
221
- openPicker = null;
222
- input.value = input.defaultValue;
223
- });
224
- // Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
225
- const saveCustom = (input) => {
226
- if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
227
- const at = new Date(input.value);
228
- if (Number.isNaN(at.getTime())) return;
229
- input.form.querySelector('input[type="hidden"]').value = at.toISOString();
230
- input.form.requestSubmit();
231
- openPicker = null;
232
- input.defaultValue = input.value;
233
- };
234
- B.on(root, 'change', (e) => {
235
- if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
236
- });
237
- B.on(root, 'focusout', (e) => {
238
- if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
239
- });
240
195
  B.on(root, 'click', async (e) => {
241
196
  const button = e.target.closest?.('[data-reopen]');
242
197
  if (!button || reopening) return;