@lovek4d/kickflip 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/CONTEXT.md +151 -115
  2. package/README.md +23 -14
  3. package/SKILL.md +37 -37
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +186 -69
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +3 -0
  13. package/board/headless.mjs +3 -0
  14. package/board/on-host.mjs +1 -1
  15. package/board/permissions.mjs +2 -0
  16. package/board/public/app-arch.js +2 -2
  17. package/board/public/app-asks.js +57 -38
  18. package/board/public/app-banner.js +2 -2
  19. package/board/public/app-cards.js +156 -72
  20. package/board/public/app-core.js +2 -4
  21. package/board/public/app-header.js +12 -5
  22. package/board/public/app-lanes.js +201 -47
  23. package/board/public/app-needs.js +2 -1
  24. package/board/public/app-snapshot.js +2 -2
  25. package/board/public/app-threads.js +11 -14
  26. package/board/public/app.html +2 -1
  27. package/board/public/app.js +53 -83
  28. package/board/public/attach.js +39 -3
  29. package/board/public/dialogs.js +59 -14
  30. package/board/public/hub-kit.js +9 -6
  31. package/board/public/hub.css +5 -18
  32. package/board/public/linear-key.js +6 -4
  33. package/board/public/log.html +48 -0
  34. package/board/public/prompts.js +9 -8
  35. package/board/public/prototype-kit.js +6 -6
  36. package/board/public/prototype-switcher.js +13 -5
  37. package/board/public/prototype.html +1 -0
  38. package/board/public/router.js +8 -7
  39. package/board/public/rules-editor.js +3 -4
  40. package/board/public/shell.js +20 -6
  41. package/board/public/sidebar.css +21 -1
  42. package/board/public/sidebar.js +64 -31
  43. package/board/public/style.css +155 -62
  44. package/board/public/toasts.js +2 -2
  45. package/board/public/ui-cards.js +176 -71
  46. package/board/public/ui-enums.js +5 -4
  47. package/board/public/ui-header.js +10 -10
  48. package/board/public/ui-hub.js +4 -4
  49. package/board/public/ui-markdown.js +1 -1
  50. package/board/public/ui-order.js +9 -6
  51. package/board/public/ui-page-header.js +10 -7
  52. package/board/public/ui-patch.js +53 -0
  53. package/board/public/ui-phase-bars.js +17 -0
  54. package/board/public/ui-prompts.js +8 -8
  55. package/board/public/ui-questions.js +26 -23
  56. package/board/public/ui-sidebar.js +46 -22
  57. package/board/public/ui-snapshot.js +20 -9
  58. package/board/public/ui-threads.js +17 -11
  59. package/board/public/ui.js +80 -12
  60. package/board/public/view-frame.js +1 -1
  61. package/board/public/view-hub-settings.js +25 -18
  62. package/board/public/view-hub.js +53 -12
  63. package/board/public/view-notes.js +1 -1
  64. package/board/public/view-questions.js +101 -50
  65. package/board/public/view-settings.js +167 -49
  66. package/board/review.mjs +1 -1
  67. package/board/server.mjs +18 -12
  68. package/board/store.mjs +13 -12
  69. package/board/tickets.mjs +3 -3
  70. package/board/worktree.mjs +20 -14
  71. package/docs/hub-session.md +4 -4
  72. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  73. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  74. package/hub/board-repo.mjs +1 -1
  75. package/hub/boards.mjs +4 -4
  76. package/hub/close-board.mjs +1 -1
  77. package/hub/github.mjs +4 -4
  78. package/hub/home.mjs +1 -1
  79. package/hub/hub.mjs +12 -12
  80. package/hub/incoming.mjs +2 -0
  81. package/hub/linear.mjs +2 -2
  82. package/hub/live.mjs +2 -2
  83. package/hub/new-board.mjs +2 -2
  84. package/hub/permission-denied-hook.mjs +2 -0
  85. package/hub/pr-report.mjs +3 -1
  86. package/hub/presence.mjs +1 -1
  87. package/hub/prompts.mjs +1 -1
  88. package/hub/push.mjs +2 -2
  89. package/hub/readiness.mjs +2 -2
  90. package/hub/repos.mjs +3 -3
  91. package/hub/restart-board.mjs +1 -1
  92. package/hub/schedule.mjs +1 -1
  93. package/hub/sessions.mjs +2 -2
  94. package/hub/sw.js +1 -1
  95. package/hub/try.mjs +8 -4
  96. package/hub/usage.mjs +3 -3
  97. package/hub/views.mjs +1 -1
  98. package/package.json +5 -1
  99. package/shared/card-prs.mjs +1 -0
  100. package/shared/colors.css +5 -4
  101. package/shared/fake-gh.mjs +59 -8
  102. package/shared/repos.mjs +1 -1
  103. package/shared/usage-gate.mjs +1 -1
  104. package/board/public/view-polling.js +0 -87
@@ -0,0 +1,53 @@
1
+ // The board's per-card patches (docs/adr/2026-11-04-0332-the-board-sends-per-card-patches.md): server.mjs makes one
2
+ // from the board a page holds to the board now, and the page applies it to the board it holds. Cards patch by key,
3
+ // questions and topics by id, each sent whole when it changed or dropped when gone, with the list's new order when
4
+ // that changed; every other part of the board comes whole when it changed.
5
+ // Needs nothing else: board/server.mjs runs this file in a vm context.
6
+ (function () {
7
+ const LISTS = { items: 'key', owner: 'id', ownerTopics: 'id' };
8
+ const OWN = new Set(['version', 'generatedAt', ...Object.keys(LISTS)]);
9
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
10
+
11
+ function listPatch(from, to, id) {
12
+ const was = new Map(from.map((r) => [r[id], r]));
13
+ const now = new Set(to.map((r) => r[id]));
14
+ const set = to.filter((r) => !was.has(r[id]) || !same(was.get(r[id]), r));
15
+ const drop = from.map((r) => r[id]).filter((k) => !now.has(k));
16
+ if (!set.length && !drop.length && same(from.map((r) => r[id]), to.map((r) => r[id]))) return null;
17
+ const placed = [...from.map((r) => r[id]).filter((k) => now.has(k)), ...to.map((r) => r[id]).filter((k) => !was.has(k))];
18
+ const order = to.map((r) => r[id]);
19
+ return { set, drop, ...(same(placed, order) ? {} : { order }) };
20
+ }
21
+
22
+ // from and to are page boards, each with its version. → {since, version, generatedAt, set, unset, lists}
23
+ function boardPatch(from, to) {
24
+ const set = {};
25
+ for (const [k, v] of Object.entries(to)) if (!OWN.has(k) && (!(k in from) || !same(from[k], v))) set[k] = v;
26
+ const unset = Object.keys(from).filter((k) => !OWN.has(k) && !(k in to));
27
+ const lists = {};
28
+ for (const [name, id] of Object.entries(LISTS)) {
29
+ const p = listPatch(from[name] || [], to[name] || [], id);
30
+ if (p) lists[name] = p;
31
+ }
32
+ return { since: from.version, version: to.version, generatedAt: to.generatedAt, set, unset, lists };
33
+ }
34
+
35
+ // → a new board, leaving board as it was, or null when the patch was made against another version.
36
+ function applyPatch(board, patch) {
37
+ if (!board || board.version !== patch.since) return null;
38
+ const next = { ...board, ...patch.set, version: patch.version, generatedAt: patch.generatedAt };
39
+ for (const k of patch.unset) delete next[k];
40
+ for (const [name, p] of Object.entries(patch.lists)) {
41
+ const id = LISTS[name];
42
+ const gone = new Set(p.drop);
43
+ const byId = new Map((board[name] || []).filter((r) => !gone.has(r[id])).map((r) => [r[id], r]));
44
+ const fresh = p.set.filter((r) => !byId.has(r[id]));
45
+ for (const r of p.set) byId.set(r[id], r);
46
+ const order = p.order || [...(board[name] || []).map((r) => r[id]).filter((k) => !gone.has(k)), ...fresh.map((r) => r[id])];
47
+ next[name] = order.map((k) => byId.get(k));
48
+ }
49
+ return next;
50
+ }
51
+
52
+ Object.assign(globalThis.BoardUI ||= {}, { boardPatch, applyPatch });
53
+ })();
@@ -0,0 +1,17 @@
1
+ // The phase bars: one bar per phase, those reached filled, a running one pulsing. An In Progress card draws the five
2
+ // build phases (ui-cards.js) and a starting board's sidebar row its three start steps (ui-sidebar.js)
3
+ // (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md). It needs nothing else, since
4
+ // hub/hub.mjs renders the shell's first sidebar with it in a vm context. style.css draws the bars.
5
+ (function () {
6
+ const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
7
+ // strip: {reached, running, dim, label, fill}, or null for no bars. reached counts the phases reached, the last of them
8
+ // running when running is set; fill, a percent, fills the next bar that far.
9
+ function phaseBars(strip, phases) {
10
+ if (!strip) return '';
11
+ const { reached, running, dim, label, fill } = strip;
12
+ const bar = (i) => (i === reached - 1 && running ? '<i class="now"></i>' : i < reached ? '<i class="done"></i>'
13
+ : i === reached && fill !== undefined ? `<i class="fill" style="--p:${fill}%"></i>` : '<i class=""></i>');
14
+ return `<span class="phase-bars${dim ? ' dim' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${phases.map((_, i) => bar(i)).join('')}</span>`;
15
+ }
16
+ Object.assign(globalThis.BoardUI ||= {}, { phaseBars });
17
+ })();
@@ -18,21 +18,21 @@
18
18
  // Whether one tap on an option answers: a single question that takes one pick.
19
19
  const oneTap = (p) => p.questions?.length === 1 && !p.questions[0].multiSelect;
20
20
 
21
- // sending: true while the answer goes, which disables the line's buttons.
22
- function promptItem(p, { picks = {}, sending = false } = {}) {
23
- const off = sending ? ' disabled' : '';
21
+ // sending: while the answer goes, the tapped button: a choice ('allow', 'always', 'deny'), 'send', or an option as
22
+ // '<question index>:<label>'. It waits (ui.js BUSY) and the line's other buttons are disabled.
23
+ function promptItem(p, { picks = {}, sending = null } = {}) {
24
+ const off = (tapped) => (sending === null ? '' : sending === tapped ? BoardUI.BUSY : ' disabled');
24
25
  const where = [ago(p.at), p.cwd].filter(Boolean).map(esc).join(' · ');
25
26
  const head = `<div class="prompt-head"><b>${esc(p.questions ? 'Question' : p.tool)}</b><span class="muted small">${where}</span></div>`;
26
- const status = sending ? '<span class="muted small">Sending…</span>' : '';
27
27
  if (p.questions) {
28
28
  const questions = p.questions.map((q, i) => `<div class="prompt-q"><p>${q.header ? `<span class="chip">${esc(q.header)}</span> ` : ''}${esc(q.question)}</p>
29
- <div class="prompt-options" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" data-prompt-q="${i}" data-prompt-opt="${esc(o.label)}" aria-pressed="${(picks[i] || []).includes(o.label)}"${off}><span class="lbl">${esc(o.label)}</span>${o.description ? `<span class="det">${esc(o.description)}</span>` : ''}</button>`).join('')}</div></div>`).join('');
30
- const send = oneTap(p) ? '' : `<button type="button" class="primary" data-prompt-send${promptAnswer(p, picks) && !sending ? '' : ' disabled'}>Send</button>`;
31
- return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${questions}<div class="prompt-actions">${send}<button type="button" class="deny" data-prompt-choice="deny"${off}>Skip</button>${status}</div></li>`;
29
+ <div class="prompt-options" role="group" aria-label="${esc(q.question)}">${q.options.map((o) => `<button type="button" data-prompt-q="${i}" data-prompt-opt="${esc(o.label)}" aria-pressed="${(picks[i] || []).includes(o.label)}"${off(`${i}:${o.label}`)}><span class="lbl">${esc(o.label)}</span>${o.description ? `<span class="det">${esc(o.description)}</span>` : ''}</button>`).join('')}</div></div>`).join('');
30
+ const send = oneTap(p) ? '' : `<button type="button" class="primary" data-prompt-send${off('send') || (promptAnswer(p, picks) ? '' : ' disabled')}>Send</button>`;
31
+ return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${questions}<div class="prompt-actions">${send}<button type="button" class="deny" data-prompt-choice="deny"${off('deny')}>Skip</button></div></li>`;
32
32
  }
33
33
  const detail = p.detail ? `<pre class="prompt-detail">${esc(p.detail)}</pre>` : `<p class="prompt-what">Wants to ${esc(p.action)}</p>`;
34
34
  const rule = p.rule ? `<p class="muted small prompt-rule">Always allow adds <code>${esc(p.rule)}</code> until the session ends.</p>` : '';
35
- return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${detail}<div class="prompt-actions"><button type="button" class="primary" data-prompt-choice="allow"${off}>Allow once</button><button type="button" data-prompt-choice="always"${off}>Always allow</button><button type="button" class="deny" data-prompt-choice="deny"${off}>Deny</button>${status}</div>${rule}</li>`;
35
+ return `<li class="prompt" data-prompt="${esc(p.id)}">${head}${detail}<div class="prompt-actions"><button type="button" class="primary" data-prompt-choice="allow"${off('allow')}>Allow once</button><button type="button" data-prompt-choice="always"${off('always')}>Always allow</button><button type="button" class="deny" data-prompt-choice="deny"${off('deny')}>Deny</button></div>${rule}</li>`;
36
36
  }
37
37
 
38
38
  Object.assign(globalThis.BoardUI, { promptItem, promptAnswer, oneTap });
@@ -1,19 +1,17 @@
1
1
  // Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
2
2
  (function () {
3
- const { esc, time, replyBody, attachments, attachButton, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
3
+ const { esc, time, replyBody, attachments, attachButton, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
4
  // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it. prototype-switcher.js answerLabel matches.
5
5
  const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
6
6
  const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
7
- const option = (o, { type = 'radio', name, checked = false, numbered = true, shown = false, disabled = false }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${disabled ? ' disabled' : ''}>
7
+ const option = (o, { type = 'radio', name, checked = false, numbered = true, shown = false }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}>
8
8
  <span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${o.recommended ? '<span class="rec">Recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div></span></label>`;
9
9
 
10
10
  const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
11
- // A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone (view-questions.js);
12
- // once sent, the eye is off with a "prototype requested" badge right after it until the round arrives.
13
- const questionEye = (q) => q.state === 'resolved' ? ''
14
- : protoButton('q:' + q.id, null, { label: 'Ask for a prototype instead', disabled: q.state === 'requested' })
15
- + (q.state === 'requested' ? badge('normal', `${SPIN}prototype requested`) : '');
16
- const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}"><textarea class="note" rows="2" placeholder="Note (optional)"${q.state === 'requested' ? ' disabled' : ''}>${esc(note || '')}</textarea>
11
+ // A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone, which resolves it
12
+ // (view-questions.js); a resolved question has none.
13
+ const questionEye = (q) => q.state === 'resolved' ? '' : protoButton('q:' + q.id, null, { label: 'Ask for a prototype instead' });
14
+ const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}">${dropZone(`<textarea class="note" rows="2" placeholder="Note (optional)">${esc(note || '')}</textarea>`)}
17
15
  <div class="note-tools">${attachButton()}${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
18
16
 
19
17
  const QUESTION_STATE = {
@@ -22,10 +20,10 @@
22
20
  replied: { label: 'claude replied', tone: 'ready' },
23
21
  resolved: { label: 'resolved', tone: 'quiet' },
24
22
  };
25
- // docs/adr/0111-a-prototype-round-ends-with-a-one-take-confirm.md
23
+ // docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md
26
24
  // A topic's latest prototype round from /api/board ownerTopics: {n, state: making|back|closed, take}.
27
25
  const roundChip = (r) => !r ? ''
28
- : r.state === 'making' ? badge('notice', `${SPIN}Prototype round ${r.n}`)
26
+ : r.state === 'making' ? badge('normal', `${SPIN}Prototype round ${r.n}`)
29
27
  : r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
30
28
  : badge('normal', `Prototype round ${r.n}`);
31
29
  const RULE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>';
@@ -47,7 +45,7 @@
47
45
  : isPrototype(url) ? `<a class="pill proto" href="${esc(url)}">See the prototypes →</a>`
48
46
  : `<div class="small" style="margin-bottom:8px"><a href="${esc(url)}"${opensHere(url) ? '' : ' target="_blank" rel="noopener"'}>${esc(url.replace(/^https:\/\/(linear\.app\/[^/]+\/issue|github\.com)\//, ''))} ${LINK_ARROW}</a></div>`;
49
47
 
50
- // A question's takes show inline (docs/adr/0048-a-questions-takes-show-inline.md): an option with `diagram` draws
48
+ // A question's takes show inline (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): an option with `diagram` draws
51
49
  // it with its `practice`, and on a question whose url is a prototype page any other option shows that page's take.
52
50
  const framed = (q) => isPrototype(q.url) && q.options.some((o) => !o.diagram);
53
51
  const inline = (q) => isPrototype(q.url) || q.options.some((o) => o.diagram);
@@ -81,38 +79,43 @@
81
79
  }
82
80
  function takes(q, chosen, links, fit) {
83
81
  const name = 'c-' + q.id;
84
- const disabled = q.state === 'requested';
85
82
  if (fullTakes(q, fit)) {
86
83
  const shown = q.options.find((o) => o.id === chosen) || q.options.find((o) => o.recommended) || q.options[0];
87
- return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown, disabled })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
84
+ return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
88
85
  }
89
- return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, disabled })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
86
+ return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
90
87
  }
91
88
  // A confirm links its one take alone, and says why it is asked.
92
89
  const confirmLines = (q) => !q.confirm_of || q.state === 'resolved' ? ''
93
90
  : `<a class="pill proto pr-open" href="${esc(withParam(q.url, 'take', q.options[0].id))}">Open the prototype →</a>${q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : ''}`;
94
91
  // An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext;
95
92
  // fit(option id) supplies the measured grid and full-width heights.
96
- // A requested question shows the note and pick its prototype was asked with, locked.
97
93
  function questionCard(q, { n = 0, draft = {}, links = {}, fit = () => null } = {}) {
98
94
  const last = q.answers[q.answers.length - 1];
99
- const busy = q.state === 'requested';
100
- const shown = busy ? q.request : draft;
101
- const chosen = shown.choice ?? (last ? last.choice : null);
102
- return `<section class="q ${q.urgent && q.state === 'open' ? 'urgent' : ''}${busy ? ' pr-busy' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
103
- <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${busy ? '' : badge(QUESTION_STATE[q.state].tone, esc(QUESTION_STATE[q.state].label))}</div>
95
+ const chosen = draft.choice ?? (last ? last.choice : null);
96
+ return `<section class="q ${q.urgent && q.state === 'open' ? 'urgent' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
97
+ <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, esc(QUESTION_STATE[q.state].label))}</div>
104
98
  <h3>${linkText(q.question, links)}</h3>
105
99
  ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
106
100
  ${framed(q) ? '' : questionLink(q.url)}
107
101
  ${confirmLines(q)}
108
102
  ${q.state === 'resolved' && q.resolved ? `<div class="small muted" style="margin-bottom:8px">Resolved: ${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'} A new answer reopens it.</div>` : ''}
109
- ${inline(q) ? takes(q, chosen, links, fit) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, disabled: busy })).join('')}
110
- ${noteComposer(q, shown.note)}
103
+ ${inline(q) ? takes(q, chosen, links, fit) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id })).join('')}
104
+ ${noteComposer(q, draft.note)}
111
105
  ${answerHistory(q, links)}
112
106
  </section>`;
113
107
  }
114
108
  // A card's pill is its ID, or for an ask (item:ask:<id>) the time it appeared; other topics keep their title.
115
109
  const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : (String(t.title).match(/^([A-Z]+-\d+|[A-Z]\d+):/) || [])[1] || t.title);
110
+ // /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
111
+ // is on the board (board/server.mjs pageBoard). cards: the board's items.
112
+ function cardTopics(topics, cards) {
113
+ const byKey = new Map(cards.map((c) => [c.key, c]));
114
+ return topics.map((t) => {
115
+ const c = t.title === undefined && byKey.get(t.card);
116
+ return c ? { ...t, title: `${c.ref}: ${c.title}`, summary: 'Questions about this work item.', ...(c.kind === 'ask' ? { askedAt: c.ask.askedAt } : {}) } : t;
117
+ });
118
+ }
116
119
  // Topics from /api/board ownerTopics, in order. pills: unresolved topics with an open question, plus the
117
120
  // current topic wherever it stands; waiting: the other unresolved topics, which wait only on Claude.
118
121
  function topicRow(topics, current) {
@@ -147,5 +150,5 @@
147
150
  },
148
151
  };
149
152
 
150
- Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, roundChip, takes, topicName, topicRow, nextQuestion, sentNotice });
153
+ Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, roundChip, takes, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
151
154
  })();
@@ -1,10 +1,11 @@
1
- // The sidebar of every site (docs/adr/0050-a-sidebar-of-every-board-replaces-the-hub-home.md), drawn from the hub's
2
- // /_hub/sidebar.json. It needs only ui-page-header.js, loaded before it, for its head, which is the page header itself:
3
- // the app shell runs it through sidebar.js, and hub/hub.mjs renders the shell's first sidebar with both in a vm context.
1
+ // The sidebar of every site (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md), drawn from the hub's
2
+ // /_hub/sidebar.json. It needs only ui-page-header.js, loaded before it, for its head, which is the page header itself, and
3
+ // ui-phase-bars.js for a starting board's steps: the app shell runs it through sidebar.js, and hub/hub.mjs renders the
4
+ // shell's first sidebar with all three in a vm context.
4
5
  // Its links are root-absolute, since every site and hub page sits beside this one on the hub. sidebar.css styles it.
5
6
  (function () {
6
7
  // ui.js replaces the BoardUI global when it loads after this, so the header renderer is held here.
7
- const { header } = globalThis.BoardUI || {};
8
+ const { header, phaseBars } = globalThis.BoardUI || {};
8
9
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
9
10
  const svg = (paths, size = 17) => `<svg 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">${paths}</svg>`;
10
11
  const ICONS = {
@@ -54,7 +55,7 @@
54
55
  ];
55
56
  const siteHref = (s) => `/${encodeURIComponent(s.route)}/`;
56
57
 
57
- // Switch mode's keys (sidebar.js; docs/adr/0056-switch-mode-letters-come-from-names-in-join-order.md): each linked row the
58
+ // Switch mode's keys (sidebar.js; docs/adr/2026-10-30-1102-switch-mode-letters-come-from-names-in-join-order.md): each linked row the
58
59
  // sidebar draws, boards first for numbering, as {href, title, board, num, letter, at}. The first nine boards take num '1'–'9'.
59
60
  // Every row takes as letter the first a–z letter of its title not yet taken, at index `at` of the title's characters,
60
61
  // claiming in this order: boards by `created`, then pages by `created`, a site with none after those with one, then
@@ -81,13 +82,13 @@
81
82
  // key: the row's switchKeys entry while switch mode shows, else undefined. The tile carries a board's number, or any
82
83
  // other row's letter, and the title marks its letter; sidebar.css shows them in place of the glyph under .ks-on.
83
84
  // mark: HTML after the title, such as Hub's moon.
84
- function row({ href, icon, title, sub = '', extra = '', mark = '', on = false, cls = '', key }) {
85
+ function row({ href, icon, title, sub = '', extra = '', mark = '', ctx = '', on = false, cls = '', key }) {
85
86
  const shown = key && (key.board ? key.num : key.letter);
86
87
  const tileHtml = shown ? icon.replace(/<\/span>$/, `<span class="ks-key">${esc(shown)}</span></span>`) : icon;
87
88
  const chars = [...title];
88
89
  const titleHtml = key?.letter ? `${esc(chars.slice(0, key.at).join(''))}<span class="ks-u">${esc(chars[key.at])}</span>${esc(chars.slice(key.at + 1).join(''))}` : esc(title);
89
90
  return `<a class="sb-row${on ? ' on' : ''}${cls ? ` ${cls}` : ''}" href="${esc(href)}"${on ? ' aria-current="page"' : ''}>${tileHtml}`
90
- + `<span class="sb-text"><span class="sb-title">${titleHtml}${mark}</span>${sub ? `<span class="sb-sub">${sub}</span>` : ''}${extra}</span></a>`;
91
+ + `<span class="sb-text">${ctx ? `<span class="sb-head"><span class="sb-title">${titleHtml}${mark}</span>${ctx}</span>` : `<span class="sb-title">${titleHtml}${mark}</span>`}${sub ? `<span class="sb-sub">${sub}</span>` : ''}${extra}</span></a>`;
91
92
  }
92
93
 
93
94
  // What waits on the owner on every site but `here`: the count on the logo that opens the drawer.
@@ -121,14 +122,18 @@
121
122
  }
122
123
 
123
124
  const ticketsLabel = (t) => (t.source === 'internal' ? `Internal · ${t.prefix}` : t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : `Linear · ${t.team}`);
124
- // A board's context window: its owner session's latest percent, dimmed with its age once older than ten minutes.
125
+ // A board's context window: its owner session's latest percent at the row's top right, red from 90%, dimmed once the
126
+ // reading is older than ten minutes, with that age in its tooltip.
125
127
  const CTX_STALE_MIN = 10;
128
+ const CTX_FULL_PERCENT = 90;
126
129
  function contextWords(ctx) {
127
130
  if (!ctx) return '';
131
+ const percent = Math.round(ctx.percent);
128
132
  const minutes = Math.floor((Date.now() - Date.parse(ctx.at)) / 60_000);
129
- if (!(minutes >= CTX_STALE_MIN)) return `<span class="sb-ctx">${Math.round(ctx.percent)}% ctx</span>`;
133
+ const full = percent >= CTX_FULL_PERCENT ? ' full' : '';
134
+ if (!(minutes >= CTX_STALE_MIN)) return `<span class="sb-ctx${full}" title="${percent}% of its context window">${percent}% ctx</span>`;
130
135
  const age = minutes < 60 ? `${minutes} min` : minutes < 1440 ? `${Math.floor(minutes / 60)} h` : `${Math.floor(minutes / 1440)} d`;
131
- return `<span class="sb-ctx stale">${Math.round(ctx.percent)}% ctx · ${age} ago</span>`;
136
+ return `<span class="sb-ctx${full} stale" title="${percent}% of its context window, read ${age} ago">${percent}% ctx</span>`;
132
137
  }
133
138
  function askedWhen(iso, now) {
134
139
  const minutes = Math.floor((now - Date.parse(iso)) / 60_000);
@@ -147,16 +152,34 @@
147
152
  + `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
148
153
  }
149
154
 
155
+ // A starting board's three steps (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md):
156
+ // s.step is cloning, starting (the orchestrator's session) or landing (the session joining the hub), since s.stepAt.
157
+ // Cloning fills its bar to git's percent; the later steps run the bars' pulse, never a fill.
158
+ const START_STEPS = { cloning: 'cloning', starting: 'starting orchestrator', landing: 'landing it' };
159
+ const STEP_KEYS = Object.keys(START_STEPS);
160
+ // The lower row: the step, cloning with its percent, and its seconds. A host clock ahead of this device's counts from 0s.
161
+ function startCaption(s, now = Date.now()) {
162
+ const seconds = Math.max(0, Math.floor((now - Date.parse(s.stepAt)) / 1000));
163
+ return `${s.step === 'cloning' ? `cloning (${s.percent || 0}%)` : START_STEPS[s.step]} · ${seconds}s`;
164
+ }
165
+ function startSteps(s) {
166
+ const at = STEP_KEYS.indexOf(s.step);
167
+ const label = `${START_STEPS[s.step]}, step ${at + 1} of ${STEP_KEYS.length}`;
168
+ const strip = s.step === 'cloning' ? { reached: 0, running: false, fill: s.percent || 0, label } : { reached: at + 1, running: true, label };
169
+ return `<span class="sb-steps">${phaseBars(strip, STEP_KEYS)}</span>`;
170
+ }
171
+
150
172
  // The New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
151
173
  // /_hub/sidebar.json's npm update, null | {state: available | waiting | updating | failed, version, builders, error}
152
- // (hub/upgrade.mjs; docs/adr/0161-the-hub-offers-a-newer-kickflip-from-npm.md).
174
+ // (hub/upgrade.mjs; docs/adr/2026-11-03-1521-the-hub-offers-a-newer-kickflip-from-npm.md). A reload, and an asked
175
+ // update until it runs out, waits (ui.js BUSY): its pill keeps its blue with a spinner in place of its word.
153
176
  function updateRow(update, upgrade) {
154
- const button = (attr, disabled, title, sub, go) => `<button type="button" class="sb-row sb-update-row" ${attr}${disabled ? ' disabled' : ''}>${tile('up', 'var(--accent)')}`
155
- + `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span>${go ? `<span class="sb-go">${go}</span>` : ''}</button>`;
156
- if (update) return button('data-app-reload', update === 'reloading', 'New version', '', update === 'reloading' ? 'Reloading…' : 'Reload');
177
+ const button = (attr, busy, title, sub, go) => `<button type="button" class="sb-row sb-update-row" ${attr}${busy ? globalThis.BoardUI.BUSY : ''}>${tile('up', 'var(--accent)')}`
178
+ + `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span><span class="sb-go">${go}</span></button>`;
179
+ if (update) return button('data-app-reload', update === 'reloading', 'New version', '', 'Reload');
157
180
  if (!upgrade) return '';
158
- if (upgrade.state === 'waiting') return button('data-app-upgrade', true, `Update waits for ${upgrade.builders} implementer${upgrade.builders === 1 ? '' : 's'}`, '', '');
159
- if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Update available', '', 'Updating…');
181
+ if (upgrade.state === 'waiting') return button('data-app-upgrade', true, `Update waits for ${upgrade.builders} implementer${upgrade.builders === 1 ? '' : 's'}`, '', 'Upgrade');
182
+ if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Update available', '', 'Upgrade');
160
183
  if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Upgrade');
161
184
  return button('data-app-upgrade', false, 'Update available', '', 'Upgrade');
162
185
  }
@@ -165,13 +188,14 @@
165
188
  // subscription as sidebar.js finds it, 'on' | 'off' | 'blocked' | 'install' (iOS outside the Home Screen app), or
166
189
  // null while unknown or unsupported. switching: switch mode shows, each row with its key and the hint on the Boards label.
167
190
  // update: null | 'ready' | 'reloading'.
168
- function sidebar(data, { here = '', device = null, switching = false, update = null } = {}) {
191
+ // now: the time a starting board's seconds count to.
192
+ function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
169
193
  const sites = data?.sites || [];
170
194
  const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
171
195
  const paused = data?.pause?.state === 'paused';
172
196
  const siteRow = (s) => {
173
- const sub = paused && s.board ? word('wait', 'Paused') + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
174
- return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub || s.ctx ? `<span class="sb-status">${sub}${contextWords(s.ctx)}</span>` : '', on: s.route === here, key: keys?.get(siteHref(s)) });
197
+ const sub = paused && s.board ? word('wait', 'paused') + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
198
+ return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', ctx: contextWords(s.ctx), on: s.route === here, key: keys?.get(siteHref(s)) });
175
199
  };
176
200
  const boards = sites.filter((s) => s.board);
177
201
  const pages = sites.filter((s) => !s.board);
@@ -180,13 +204,13 @@
180
204
  ? `<button type="button" class="sb-row sb-starting sb-failed" data-starting="${esc(s.route)}" data-failed-start="${esc(s.route)}" aria-haspopup="dialog">${plus}`
181
205
  + `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span><span class="sb-sub">failed to start</span></span></button>`
182
206
  : `<div class="sb-row sb-starting" data-starting="${esc(s.route)}">${plus}`
183
- + `<span class="sb-text"><span class="sb-title">${s.phase === 'cloning' ? 'Cloning…' : 'Starting…'}</span><span class="sb-sub">${esc(s.title)}${s.repo ? ` · ${esc(repoLabel(s.repo, data.home))}` : ''}${s.phase === 'cloning' ? ' · Cloning from GitHub' : ' · Waiting for the board to answer'}</span></span></div>`)).join('');
207
+ + `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span>${startSteps(s)}<span class="sb-sub sb-step">${esc(startCaption(s, now))}</span></span></div>`)).join('');
184
208
  const newBoard = `<button type="button" class="sb-row sb-new" data-new-board aria-haspopup="dialog">${plus}<span class="sb-text"><span class="sb-title">New board</span></span></button>`;
185
209
  const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Turn on notifications', sub: 'Off on this device', cls: 'sb-turnon' })
186
210
  : device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
187
211
  : '';
188
212
  const usage = sidebarUsage(data?.usage);
189
- const hub = paused ? { tone: 'wait', word: 'Paused', detail: `until ${usageClock(data.pause.resets_at)}` } : data?.hub;
213
+ const hub = paused ? { tone: 'wait', word: 'paused', detail: `until ${usageClock(data.pause.resets_at)}` } : data?.hub;
190
214
  const n = data?.notifications || {};
191
215
  const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
192
216
  const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
@@ -211,5 +235,5 @@
211
235
  + `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
212
236
  }
213
237
 
214
- Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
238
+ Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
215
239
  })();
@@ -1,17 +1,28 @@
1
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/0028-the-board-opens-on-the-last-visit.md). Needs ui.js.
2
+ // page may do meanwhile (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md). Needs ui.js.
3
3
  (function () {
4
4
  // Characters, one board's most; app-snapshot.js keeps every board's within three of these.
5
5
  const SNAPSHOT_MAX = 900000;
6
6
  const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
7
7
 
8
- // threads maps item keys to thread messages (app.js state.threadCache). Threads of `keep` go in first, then the
9
- // rest while they fit under max; threads of items no longer on the board are dropped. build is the saving page's
10
- // (app.html data-build). Null when the board alone won't fit.
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.
11
16
  function packSnapshot(board, threads, { build, savedAt, keep = [], max = SNAPSHOT_MAX } = {}) {
12
- const head = `{"build":${JSON.stringify(build)},"savedAt":${JSON.stringify(savedAt)},"board":${JSON.stringify(board)},"threads":{`;
13
- if (head.length + 2 > max) return null;
14
- const onBoard = new Set(board.items.map((i) => i.key));
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));
15
26
  let out = head;
16
27
  let n = 0;
17
28
  for (const key of new Set([...keep, ...threads.keys()])) {
@@ -54,9 +65,9 @@
54
65
  const changedLines = (before, after) => Object.keys(after).filter((id) => globalThis.BoardUI.record(before, id) !== after[id]);
55
66
 
56
67
  // What counts as a card's update: a new thread line, a status or Next line change, or a PR or CI change
57
- // (docs/adr/0135-an-updated-card-takes-a-brief-tint.md). Two board items read the same exactly when it is unchanged.
68
+ // (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.
58
69
  const updateSign = (it) => JSON.stringify([it.threadCount ?? 0, it.status ?? null, it.builder?.progress ?? null, it.working ?? null, it.waitsOn ?? null,
59
- it.next ?? null, it.ask?.state ?? null, it.pr ? [it.pr.state, it.pr.isDraft, it.pr.reviewDecision, it.pr.checks?.state ?? null] : null]);
70
+ it.next ?? null, it.ask?.state ?? null, ...(it.prs || []).map((pr) => [pr.state, pr.isDraft, pr.reviewDecision, pr.checks?.state ?? null])]);
60
71
 
61
72
  Object.assign(globalThis.BoardUI, { packSnapshot, unpackSnapshot, preloadKeys, liveControls, changedLines, updateSign, SNAPSHOT_MAX });
62
73
  })();
@@ -1,6 +1,7 @@
1
- // A card's thread: Claude's lines, the owner's messages and their replies. Needs ui.js, ui-links.js and ui-enums.js.
1
+ // A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
2
+ // their replies. Needs ui.js, ui-links.js and ui-enums.js.
2
3
  (function () {
3
- const { esc, time, etaLine, replyBody, attachments, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, BUTTON } = globalThis.BoardUI;
4
+ const { esc, time, etaLine, replyBody, attachments, attachButton, dropZone, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, BUTTON } = globalThis.BoardUI;
4
5
  const VIA = { slack: ' · Slack', github: ' · GitHub' };
5
6
  // A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
6
7
  function cardLink(link) {
@@ -24,19 +25,23 @@
24
25
  out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
25
26
  return out.map(({ m, of }, n) => (of ? { ...m, quote: out[n - 1]?.m === of ? '' : of.text || '' } : m));
26
27
  };
27
- // A message being edited shows its edit box in place of its text; app-asks.js handles the form.
28
- const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}"><textarea rows="3">${esc(text)}</textarea>
29
- <div class="row"><span></span><span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
28
+ // A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
29
+ // files it adds to the message, under composer id edit:<event id>.
30
+ 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>`)}
31
+ <div class="pending-files" data-files></div>
32
+ <div class="row">${attachButton()}<span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
30
33
  // batched: an answer followed by another of the owner's answers in its state, which carries the batch's Waiting for Claude.
31
34
  const line = (m, links, edits, batched = false) => (m.kind === 'reply'
32
- ? `<div class="msg claude reply-msg"><div class="who">Claude · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
35
+ ? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
33
36
  : m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
37
+ : m.request ? `<div class="msg request">${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
34
38
  : m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
35
39
  : m.kind === 'calls' ? `<details class="msg calls"><summary>${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
36
40
  : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}"><span class="agent-dot" aria-hidden="true"></span>${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
37
41
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
38
- <div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Claude' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
42
+ <div class="who">${m.who === 'love' ? 'You' : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
39
43
  <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)}
44
+ ${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log →</a></div>` : ''}
40
45
  ${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
41
46
  ${m.eventId && !batched ? msgFooter(m) : ''}
42
47
  </div>`);
@@ -45,8 +50,9 @@
45
50
  const UNREAD_CAP = 10;
46
51
  const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link']);
47
52
  const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
48
- // Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount).
49
- const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
53
+ // Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
54
+ // orchestrator's answer to a builder's ask never is.
55
+ const unread = (m, seenAt) => Boolean(seenAt) && isMessage(m) && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github') && String(m.ts) > seenAt;
50
56
  const batchedAt = (lines, i) => {
51
57
  const next = lines.slice(i + 1).find(isMessage);
52
58
  return lines[i].kind === 'owner-answer' && next?.kind === 'owner-answer' && stateOf(next) === stateOf(lines[i]);
@@ -77,9 +83,9 @@
77
83
  }
78
84
 
79
85
  // The reply form's Done sends ask-done or card-done; the board applies it before the session follows up.
80
- // docs/adr/0106-the-board-applies-the-owners-hold-resume-and-done-at-once.md
86
+ // docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md
81
87
  // Orange while it clears an ask's Action needed, primary while a reopened card waits only on it
82
- // (docs/adr/0136-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
88
+ // (docs/adr/2026-11-03-0137-a-card-waiting-only-to-be-closed-has-no-action-needed.md).
83
89
  function doneButton(it) {
84
90
  if (!it?.finishable) return '';
85
91
  const tone = it.resolvedBy === BUTTON.markDone ? 'resolves' : it.awaitsDone ? 'closes' : '';