@lovek4d/kickflip 0.5.3 → 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 (85) hide show
  1. package/CONTEXT.md +69 -37
  2. package/SKILL.md +19 -13
  3. package/bin/kickflip.mjs +12 -12
  4. package/bin/wait-events.mjs +2 -2
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +156 -83
  8. package/board/build-script.mjs +2 -1
  9. package/board/domain.mjs +9 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +9 -5
  12. package/board/onboarding.mjs +11 -0
  13. package/board/prototype-base.mjs +1 -0
  14. package/board/prototype-page.mjs +1 -0
  15. package/board/public/app-arch.js +1 -1
  16. package/board/public/app-cards.js +27 -13
  17. package/board/public/app-header.js +6 -7
  18. package/board/public/app-lanes.js +15 -38
  19. package/board/public/app-needs.js +36 -31
  20. package/board/public/app-snapshot.js +43 -70
  21. package/board/public/app-threads.js +38 -46
  22. package/board/public/app.html +9 -4
  23. package/board/public/app.js +78 -32
  24. package/board/public/hub-kit.js +62 -2
  25. package/board/public/hub.css +2 -0
  26. package/board/public/new-task.js +8 -6
  27. package/board/public/progress-review.html +46 -0
  28. package/board/public/progress-review.js +158 -0
  29. package/board/public/prototype-kit.js +109 -4
  30. package/board/public/prototype-switcher.js +123 -29
  31. package/board/public/prototype.html +57 -3
  32. package/board/public/router.js +7 -6
  33. package/board/public/shell.js +64 -24
  34. package/board/public/sidebar.css +1 -0
  35. package/board/public/sidebar.js +16 -69
  36. package/board/public/style.css +108 -57
  37. package/board/public/ui-cards.js +61 -46
  38. package/board/public/ui-enums.js +1 -1
  39. package/board/public/ui-header.js +25 -16
  40. package/board/public/ui-hub.js +12 -1
  41. package/board/public/ui-links.js +7 -7
  42. package/board/public/ui-page-header.js +19 -7
  43. package/board/public/ui-patch.js +7 -33
  44. package/board/public/ui-progress-bar.js +28 -0
  45. package/board/public/ui-questions.js +25 -17
  46. package/board/public/ui-sidebar.js +25 -23
  47. package/board/public/ui-snapshot.js +43 -52
  48. package/board/public/ui-threads.js +57 -28
  49. package/board/public/ui.js +92 -17
  50. package/board/public/view-hub-settings.js +12 -58
  51. package/board/public/view-hub.js +2 -1
  52. package/board/public/view-notes.js +27 -7
  53. package/board/public/view-questions.js +90 -68
  54. package/board/public/view-settings.js +133 -15
  55. package/board/render-check.mjs +10 -0
  56. package/board/review.mjs +1 -1
  57. package/board/server.mjs +19 -19
  58. package/board/stop.mjs +1 -0
  59. package/board/store.mjs +27 -18
  60. package/board/sync.mjs +2 -0
  61. package/board/tickets.mjs +3 -3
  62. package/board/worktree.mjs +22 -21
  63. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  64. package/hub/board-repo.mjs +1 -1
  65. package/hub/boards.mjs +4 -4
  66. package/hub/front-door.mjs +1 -1
  67. package/hub/github.mjs +3 -3
  68. package/hub/hub.mjs +11 -11
  69. package/hub/incoming.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/pr-report.mjs +2 -2
  72. package/hub/review-send.mjs +1 -1
  73. package/hub/sessions.mjs +2 -2
  74. package/hub/share.mjs +1 -1
  75. package/hub/slack.mjs +2 -2
  76. package/hub/try.mjs +7 -7
  77. package/hub/upgrade.mjs +1 -1
  78. package/hub/usage.mjs +3 -3
  79. package/hub/views.mjs +1 -1
  80. package/package.json +1 -1
  81. package/shared/card-prs.mjs +1 -1
  82. package/shared/machine-gate.mjs +2 -0
  83. package/shared/prototype-frame.js +1 -0
  84. package/shared/usage-gate.mjs +1 -1
  85. package/board/public/ui-phase-bars.js +0 -17
@@ -1,73 +1,64 @@
1
- // The last board this device showed, which the board page paints dimmed until the fresh board lands, and what the
2
- // page may do meanwhile (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md). Needs ui.js.
1
+ // The board this device last held, saved in IndexedDB with its change number, which the board page paints live on open
2
+ // and catches up from (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md). Needs ui.js.
3
3
  (function () {
4
- // Characters, one board's most; app-snapshot.js keeps every board's within three of these.
5
- const SNAPSHOT_MAX = 900000;
6
4
  const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
7
5
 
8
- // What a first paint shows of board: every card as the board sent it, Done cards already slim (board/domain.mjs
9
- // slimDone), or the open lanes alone when openOnly. It keeps no version, since it isn't the board that version names,
10
- // so the first reload downloads the whole board (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
11
- const firstPaint = ({ version: _, ...board }, openOnly) => ({ ...board, items: openOnly ? board.items.filter((it) => it.lane !== 'done') : board.items });
12
-
13
- // threads maps item keys to thread messages (app.js state.threadCache). The board goes in as firstPaint, then threads of
14
- // `keep` first and the rest while they fit under max; threads of items no longer on the board are dropped. When the
15
- // board still won't fit, Done cards go too. build is the saving page's (app.html data-build). Null when open lanes alone won't fit.
16
- function packSnapshot(board, threads, { build, savedAt, keep = [], max = SNAPSHOT_MAX } = {}) {
17
- const headOf = (b) => `{"build":${JSON.stringify(build)},"savedAt":${JSON.stringify(savedAt)},"board":${JSON.stringify(b)},"threads":{`;
18
- let shown = firstPaint(board, false);
19
- let head = headOf(shown);
20
- if (head.length + 2 > max) {
21
- shown = firstPaint(board, true);
22
- head = headOf(shown);
23
- if (head.length + 2 > max) return null;
24
- }
25
- const onBoard = new Set(shown.items.map((i) => i.key));
26
- let out = head;
27
- let n = 0;
28
- for (const key of new Set([...keep, ...threads.keys()])) {
29
- if (!onBoard.has(key) || !threads.has(key)) continue;
30
- const part = `${n ? ',' : ''}${JSON.stringify(key)}:${JSON.stringify(threads.get(key))}`;
31
- if (out.length + part.length + 2 > max) continue;
32
- out += part;
33
- n++;
34
- }
35
- return `${out}}}`;
36
- }
6
+ // threads maps item keys to thread messages (app.js state.threadCache). → {build, savedAt, board, threads} with the
7
+ // threads of the cards on the board; build is the saving page's (app.html data-build).
8
+ const copyOf = (board, threads, { build, savedAt }) => {
9
+ const onBoard = new Set(board.items.map((i) => i.key));
10
+ return { build, savedAt, board, threads: Object.fromEntries([...threads].filter(([key]) => onBoard.has(key))) };
11
+ };
37
12
 
38
- // → {savedAt, board, threads: {key: messages}}, or null for anything packSnapshot didn't write under this build: code
39
- // from another build may read the board's shape differently, and a paint that throws stops the live load.
40
- function unpackSnapshot(raw, build) {
41
- let s;
42
- try { s = JSON.parse(raw); } catch { return null; }
43
- if (s?.build !== build || !Array.isArray(s.board?.items) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
13
+ // → {savedAt, board, threads}, or null for anything copyOf didn't write under this build: code from another build may
14
+ // read the board's shape differently, and a paint that throws stops the live load.
15
+ function readCopy(s, build) {
16
+ if (!s || s.build !== build || !Array.isArray(s.board?.items) || !Number.isInteger(s.board.version) || typeof s.savedAt !== 'string' || !Number.isFinite(Date.parse(s.savedAt))) return null;
44
17
  const threads = Object.fromEntries(Object.entries(s.threads && typeof s.threads === 'object' ? s.threads : {}).filter(([, m]) => Array.isArray(m)));
45
18
  return { savedAt: s.savedAt, board: s.board, threads };
46
19
  }
47
20
 
21
+ // One transaction on a store of the copies' database: 'boards', each board's copy, or 'pages', a side page's answers
22
+ // (pageCopy). fn(store) → the request whose result it resolves with. Rejects where IndexedDB is missing or refuses.
23
+ function copyStore(name, mode, fn) {
24
+ return new Promise((resolve, reject) => {
25
+ const open = indexedDB.open('kickflip-boards', 2);
26
+ open.onupgradeneeded = () => { for (const s of ['boards', 'pages']) if (!open.result.objectStoreNames.contains(s)) open.result.createObjectStore(s); };
27
+ open.onerror = () => reject(open.error);
28
+ open.onsuccess = () => {
29
+ const db = open.result;
30
+ const tx = db.transaction(name, mode);
31
+ const req = fn(tx.objectStore(name));
32
+ tx.oncomplete = () => { db.close(); resolve(req.result); };
33
+ tx.onerror = tx.onabort = () => { db.close(); reject(tx.error); };
34
+ };
35
+ });
36
+ }
37
+
38
+ // A side page's last answer from each URL it reads, Notes' and Settings', for showing while the board can't be reached
39
+ // (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md). read(url) → {savedAt, body} or null.
40
+ const pageCopy = {
41
+ save: (url, body) => copyStore('pages', 'readwrite', (store) => store.put({ savedAt: new Date().toISOString(), body }, url)).catch(() => {}),
42
+ read: (url) => copyStore('pages', 'readonly', (store) => store.get(url)).then((c) => (typeof c?.savedAt === 'string' ? c : null), () => null),
43
+ };
44
+
45
+ // The copy of the board at key, the board view's site URL: the copy build saved, or with build null any build's.
46
+ // → {savedAt, board, threads} or null.
47
+ const boardCopy = (key, build) => copyStore('boards', 'readonly', (store) => store.get(key)).then((s) => readCopy(s, build ?? s?.build), () => null);
48
+
48
49
  // The cards whose threads load right after the board, so tapping one opens it filled; open cards load with the board.
49
50
  const preloadKeys = (items, open) => {
50
51
  const opened = new Set(open);
51
52
  return items.filter((i) => ACTIVE_LANES.includes(i.lane) && !opened.has(i.key)).map((i) => i.key);
52
53
  };
53
54
 
54
- // What the board page takes taps on. view is 'snapshot' while the last visit shows, 'fresh' once a board load landed,
55
- // and null before either. blocked selects the controls that send to the host and wait for the fresh board; style.css's
56
- // INT-61 block dims the same list.
57
- const SNAPSHOT_BLOCKED = ['.actions button', 'button[data-plan]', 'button[data-bump]', 'button[data-card-bump]', 'button[data-edit]', 'button[data-withdraw]',
58
- 'button[data-check-github]', 'form[data-reply] button[type=submit]', 'form[data-reply] button[data-quick]', 'form[data-reply] button[data-proto]', 'form[data-reply] button[data-confirm]', '.prompt button'].join(', ');
59
- const liveControls = (view) => {
60
- const fresh = view !== 'snapshot';
61
- return { read: true, ask: true, cards: fresh, send: fresh, blocked: fresh ? '' : SNAPSHOT_BLOCKED };
62
- };
63
-
64
55
  // before and after map a line's id to its text; the ids of the lines that are new or read differently flash once.
65
56
  const changedLines = (before, after) => Object.keys(after).filter((id) => globalThis.BoardUI.record(before, id) !== after[id]);
66
57
 
67
- // What counts as a card's update: a new thread line, a status or Next line change, or a PR or CI change
58
+ // What counts as a card's update: a new thread line, a lane, status or Next line change, or a PR or CI change
68
59
  // (docs/adr/2026-11-02-2354-an-updated-card-takes-a-brief-tint.md). Two board items read the same exactly when it is unchanged.
69
- const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.status ?? null, it.builder?.progress ?? null, it.working ?? null, it.waitsOn ?? null,
60
+ const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.lane ?? null, it.builder?.progress ?? null, it.working ?? null, it.waitsOn ?? null,
70
61
  it.next ?? null, it.ask?.state ?? null, ...(it.prs || []).map((pr) => [pr.state, pr.isDraft, pr.reviewDecision, pr.checks?.state ?? null])]);
71
62
 
72
- Object.assign(globalThis.BoardUI, { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX });
63
+ Object.assign(globalThis.BoardUI, { copyOf, readCopy, copyStore, pageCopy, boardCopy, preloadKeys, changedLines, updateSign });
73
64
  })();
@@ -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, dropZone, sendButton, 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
36
  <div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</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'
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
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
  })();
@@ -53,8 +53,9 @@
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
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.
57
- const dropZone = (textarea) => `<div class="dz">${textarea}${attachButton()}<div class="dz-hint" aria-hidden="true">Drop to attach</div></div>`;
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>`;
58
59
  // docs/adr/2026-11-04-2343-each-text-box-sends-its-intent-from-a-key.md
59
60
  // A composer's send buttons: the one with nth 0 presses on ⌘↵ and the one with nth 1, where there is one, on ⌥↵,
60
61
  // wherever each sits in the row (Ctrl+↵ and
@@ -71,27 +72,46 @@
71
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>`;
72
73
  // An SVG, not ↗: iOS Safari can draw ↗ as an emoji tile even with U+FE0E.
73
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);
74
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"/>');
75
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"/>');
76
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"/>');
77
- const BOLT = icon('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>');
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"/>';
78
81
  // Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
79
82
  // question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
80
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>`;
81
84
  // The thread's fire, the eye's twin for a grill.
82
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>`;
83
- // A Todo card's thread footer lightning: starts a quick build of the card at once (app-threads.js).
84
- const boltButton = (id) => `<button type="button" class="attach proto-ask" data-bolt="${esc(id)}" aria-label="Quick build" title="Quick build">${BOLT}</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>`;
85
89
  // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
86
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
87
- // 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.
88
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"/>');
89
96
  const MARK_ICONS = {
90
- '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"/>'),
91
- '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"/>'),
92
- 'quick build: no grill': 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"/>'),
93
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"/>'),
94
113
  'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
114
+ 'status lines': mini('<path d="m9 6 6 6-6 6"/>'),
95
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'),
96
116
  };
97
117
  const mark = (inner = '') => `<span class="mark" aria-hidden="true">${inner}</span>`;
@@ -110,6 +130,12 @@
110
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>`
111
131
  : `<button class="ghost icon" data-card-bump="${esc(key)}" aria-pressed="false" aria-label="Bump to top" title="Bump to top">${BUMP}</button>`);
112
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);
113
139
  // html is markup the caller has escaped; href, title and attrs values are escaped here.
114
140
  const BADGE_TONES = Object.freeze(['quiet', 'normal', 'notice', 'danger', 'ready', 'merged']);
115
141
  function badge(tone, html, { href, title, button = false, attrs = {} } = {}) {
@@ -131,11 +157,11 @@
131
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
132
158
  // element matching opts.keep stays as it is, for markup another part paints. The focused field keeps its focus when
133
159
  // its card moves (focusSafe). setHTML skips an unchanged rebuild, and reopens each <details> the reader left open,
134
- // 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
135
161
  // parent rebuild reopens too.
136
162
  const lastHtml = new WeakMap();
137
163
  const openDetails = new Set();
138
- 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()}`;
139
165
  globalThis.document?.addEventListener('toggle', (ev) => {
140
166
  if (ev.target.tagName !== 'DETAILS') return;
141
167
  if (ev.target.open) openDetails.add(detailsKey(ev.target)); else openDetails.delete(detailsKey(ev.target));
@@ -342,11 +368,57 @@
342
368
  },
343
369
  };
344
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
+
345
415
  // While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
346
- // on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
347
- // 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.
348
419
  // tag is the version of the board the page holds: an unchanged board answers 304, which resolves with body null
349
420
  // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
421
+ const WAITING = 'Waiting for the board to reconnect';
350
422
  const RESTARTING = 'Board restarting…';
351
423
  const restartNotice = () => `<span class="muted small restarting">${RESTARTING}</span>`;
352
424
  async function fetchJSON(url, { onWait = () => {}, limit = 30000, tag = null, fetch: get = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
@@ -354,15 +426,18 @@
354
426
  let delay = 500;
355
427
  for (;;) {
356
428
  let failure;
429
+ let answer = null;
357
430
  try {
358
431
  const r = await get(url, { cache: 'no-store', ...(tag ? { headers: { 'if-none-match': `"${tag}"` } } : {}) });
359
432
  if (r.status === 304) return { ok: true, status: 304, body: null };
360
433
  const body = await r.text().then((t) => JSON.parse(t)).catch(() => undefined);
361
434
  if (body !== undefined && r.status < 500) return { ok: r.ok, status: r.status, body };
435
+ answer = r;
362
436
  failure = new Error(r.status >= 500 ? `the board answered ${r.status}` : 'the board did not answer with JSON');
363
437
  } catch { failure = new Error('Offline. Is the host awake?'); }
438
+ failure.down = downOf(answer);
364
439
  if (waited >= limit) throw failure;
365
- onWait(waited);
440
+ onWait(waited, failure.down);
366
441
  await sleep(delay);
367
442
  waited += delay;
368
443
  delay = Math.min(delay * 2, 4000);
@@ -370,8 +445,8 @@
370
445
  }
371
446
 
372
447
  globalThis.BoardUI = {
373
- LINK_ARROW,
374
- esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, RESTARTING, restartNotice,
375
- attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, boltButton, 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,
376
451
  };
377
452
  })();
@@ -1,5 +1,5 @@
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
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
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.
@@ -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'], ['slack', 'Slack'], ['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;
@@ -80,13 +62,16 @@
80
62
  let rules = null;
81
63
  let rulesJSON = '';
82
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') {
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') {
90
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);
@@ -103,11 +88,11 @@
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,
@@ -207,37 +192,6 @@
207
192
  B.atUnmount(() => closeModal(false));
208
193
  const device = HubKit.device(B, { paint, changed: () => ctx.sidebarChanged?.() });
209
194
  device.ready.then(() => { if (B.alive()) paint(); });
210
- let openPicker = null;
211
- B.on(root, 'click', (e) => {
212
- const input = e.target.closest?.('[data-dur-at]');
213
- if (!input) return;
214
- openPicker = input;
215
- customBounds(input, input.value === input.defaultValue);
216
- try { input.showPicker(); } catch { /* iOS opens its wheel on the tap itself. */ }
217
- });
218
- // A chip's pointerdown comes before Custom's focusout; without this reset Custom posts first and the chip's post is dropped.
219
- B.on(root, 'pointerdown', (e) => {
220
- const input = e.target.closest?.('.dur-choices button') && B.$('[data-dur-at]');
221
- if (!input) return;
222
- openPicker = null;
223
- input.value = input.defaultValue;
224
- });
225
- // Closing an opened picker accepts its suggested time; an unchanged field reached by Tab does nothing.
226
- const saveCustom = (input) => {
227
- if (!input.value || (input !== openPicker && input.value === input.defaultValue) || !input.checkValidity()) return;
228
- const at = new Date(input.value);
229
- if (Number.isNaN(at.getTime())) return;
230
- input.form.querySelector('input[type="hidden"]').value = at.toISOString();
231
- input.form.requestSubmit();
232
- openPicker = null;
233
- input.defaultValue = input.value;
234
- };
235
- B.on(root, 'change', (e) => {
236
- if (e.target.matches?.('[data-dur-at]') && document.activeElement !== e.target) saveCustom(e.target);
237
- });
238
- B.on(root, 'focusout', (e) => {
239
- if (e.target.matches?.('[data-dur-at]')) saveCustom(e.target);
240
- });
241
195
  B.on(root, 'click', async (e) => {
242
196
  const button = e.target.closest?.('[data-reopen]');
243
197
  if (!button || reopening) return;