@lovek4d/kickflip 0.7.0 → 0.7.2

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 (68) hide show
  1. package/CONTEXT.md +53 -49
  2. package/SKILL.md +17 -13
  3. package/bin/event-lines.mjs +1 -1
  4. package/bin/kickflip.mjs +3 -3
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +113 -105
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -4
  10. package/board/headless.mjs +8 -8
  11. package/board/hub-lines.mjs +1 -0
  12. package/board/onboarding.mjs +3 -3
  13. package/board/public/app-arch.js +1 -1
  14. package/board/public/app-cards.js +6 -7
  15. package/board/public/app-header.js +1 -1
  16. package/board/public/app-lanes.js +39 -62
  17. package/board/public/app-needs.js +16 -11
  18. package/board/public/app-reader.js +9 -5
  19. package/board/public/app.js +3 -3
  20. package/board/public/attach.js +18 -2
  21. package/board/public/dialogs.js +11 -13
  22. package/board/public/hub-kit.js +1 -1
  23. package/board/public/hub.css +0 -1
  24. package/board/public/linear-key.js +2 -2
  25. package/board/public/new-task.js +1 -1
  26. package/board/public/prototype-kit.js +2 -5
  27. package/board/public/router.js +1 -1
  28. package/board/public/sidebar.css +3 -3
  29. package/board/public/sidebar.js +2 -2
  30. package/board/public/style.css +43 -43
  31. package/board/public/ui-cards.js +25 -20
  32. package/board/public/ui-hub.js +1 -5
  33. package/board/public/ui-links.js +5 -5
  34. package/board/public/ui-order.js +6 -7
  35. package/board/public/ui-questions.js +17 -13
  36. package/board/public/ui-sidebar.js +1 -1
  37. package/board/public/ui-threads.js +29 -21
  38. package/board/public/ui.js +23 -6
  39. package/board/public/view-hub-settings.js +11 -19
  40. package/board/public/view-hub.js +25 -11
  41. package/board/public/view-notes.js +1 -1
  42. package/board/public/view-questions.js +18 -5
  43. package/board/public/view-settings.js +7 -7
  44. package/board/render-check.mjs +10 -10
  45. package/board/review.mjs +1 -1
  46. package/board/server.mjs +18 -19
  47. package/board/stop.mjs +1 -1
  48. package/board/store.mjs +26 -26
  49. package/board/tickets.mjs +3 -3
  50. package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
  51. package/hub/awake.mjs +1 -1
  52. package/hub/board-repo.mjs +1 -1
  53. package/hub/boards.mjs +4 -4
  54. package/hub/github.mjs +3 -3
  55. package/hub/hub.mjs +11 -11
  56. package/hub/incoming.mjs +2 -2
  57. package/hub/linear.mjs +1 -1
  58. package/hub/locks.mjs +1 -1
  59. package/hub/new-board.mjs +2 -2
  60. package/hub/owner-checks.mjs +1 -1
  61. package/hub/readiness.mjs +1 -1
  62. package/hub/review-send.mjs +1 -1
  63. package/hub/slack.mjs +1 -1
  64. package/hub/try.mjs +6 -7
  65. package/hub/views.mjs +1 -1
  66. package/package.json +1 -1
  67. package/shared/usage-gate.mjs +1 -1
  68. package/starter/server.mjs +3 -3
@@ -31,7 +31,10 @@
31
31
  return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
32
32
  }
33
33
  const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
34
- const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'internal' ? ' · this board’s own ticket' : it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: it.ticket.source === 'internal' ? {} : EXTERNAL });
34
+ const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: EXTERNAL });
35
+ // docs/adr/2026-11-09-1500-a-task-is-a-card-with-no-ticket-keyed-t-n.md: a card with no ticket is a task, its T-<n>
36
+ // only in the badge's title.
37
+ const taskChip = (it) => badge('quiet', 'Task', { title: `${it.ref} · a task, no ticket` });
35
38
  const REVIEW = {
36
39
  approved: { tone: 'ready', mark: '✓', label: 'approved' },
37
40
  changes: { tone: 'danger', mark: '✗', label: 'asked for changes' },
@@ -177,7 +180,7 @@
177
180
  const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
178
181
  // A card's progress bar (ui-progress-bar.js), In Progress's phase, or null off In Progress and on a card only a planner
179
182
  // works on: how many phases it reached, which of them is running, whether it dims because the card waits on something
180
- // other than a builder (its questions, a blocker or queue, a reopening) or its build is stale, and its label. An install
183
+ // other than a builder (its questions, a blocker or queue, a reopening), its build is stale or paused, and its label. An install
181
184
  // is no build: its card keeps the phase its build reached, undimmed
182
185
  // (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md, docs/adr/2026-11-11-1200-every-lane-has-phases.md).
183
186
  function phaseStrip(it) {
@@ -191,7 +194,7 @@
191
194
  const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
192
195
  if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
193
196
  const done = BUILD_PHASES.indexOf(it.builder.phase) + 1;
194
- return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stale || it.builder.quiet), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
197
+ return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stale || it.builder.quiet || it.builder.pause?.paused), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
195
198
  }
196
199
  const dim = !it.builder || asking;
197
200
  if (it.reopened) return { count: all, done: all, dim, label: `Reopened, ${ALL_DONE}${answers}` };
@@ -228,12 +231,12 @@
228
231
  if (it.builder.stopped) return `<div class="${classes}">${esc(builderLine(it.builder))}</div>`;
229
232
  return `<div class="${classes}">${quiet ? '' : DOT}${esc(builderLine(it.builder))}</div>`;
230
233
  }
231
- // The open card's Status row: what runs on the card, else where its lane leaves it, never the wave file's raw status
234
+ // The open card's Status row: what runs on the card, else where its lane leaves it, never the item file's raw status
232
235
  // (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
233
236
  function statusWord(it) {
234
237
  const b = it.builder;
235
238
  if (b?.planner) return b.kind === 'prototype' ? 'prototyping' : 'grilling';
236
- if (b && b.kind !== BUILDER_KIND.install) return b.stopped || (b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing');
239
+ if (b && b.kind !== BUILDER_KIND.install) return b.stopped || (b.pause ? (b.pause.paused ? 'paused' : 'pausing') : b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing');
237
240
  if (it.waitsOn === 'checking') return 'Orchestrator review';
238
241
  if (it.lane === 'done') return 'done';
239
242
  if (it.grilling || it.prototyping) return 'preheating';
@@ -274,21 +277,26 @@
274
277
  const [word, tone] = PHASE_LOOK[it.phase];
275
278
  return `<span class="card-phase ph-${tone}">${word}</span>`;
276
279
  }
280
+ // docs/adr/2026-11-09-1300-a-card-pause-stops-its-builder-at-its-next-stopping-point.md
281
+ // A paused build's word and cause, which stand in for the hold or block that caused it: "pausing · blocked by #61" in
282
+ // orange while its builder runs on to its stopping point, then "paused · blocked by #61" in grey.
283
+ const pauseOf = (it) => (it.lane === 'progress' ? it.builder?.pause ?? null : null);
284
+ const pauseWords = (pause) => pause && `<span class="card-${pause.paused ? 'paused' : 'pausing'}">${pause.paused ? 'paused' : 'pausing'} · ${esc(pause.words)}</span>`;
277
285
  // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
278
- // bumped out by the card whose bump took its slot, or held while host recovers on a bumped ready card a busy host holds,
279
- // both grey (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), stalled in red or a build's stale
286
+ // held while host recovers on a bumped ready card a busy host holds, in grey
287
+ // (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), stalled in red or a build's stale
280
288
  // in grey (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), a stale review reading it
281
- // once, as its phase, then the phase word (phaseWord), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
289
+ // once, as its phase, then the phase word (phaseWord), then a paused build's words (pauseWords), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
282
290
  // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
283
291
  // docs/adr/2026-11-03-0516-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
284
292
  const SEP = '<span class="sep"> · </span>';
285
293
  const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
286
294
  function timeLine(it, unread, bars) {
287
295
  const iso = it.sortAt;
288
- const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it),
289
- it.bumpedOut && `<a class="card-bumped-out" href="#selectedCard=${encodeURIComponent(it.bumpedOut.key)}">bumped out by ${esc(it.bumpedOut.ref)}</a>`,
296
+ const pause = pauseOf(it);
297
+ const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, pause?.cause !== 'held' && holdWords(it), pause?.cause !== 'blocked' && blockedWords(it), actionWords(it),
290
298
  it.hostHeld && '<span class="card-host-held">held while host recovers</span>',
291
- it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && it.phase !== 'stale' && '<span class="card-stale">stale</span>', phaseWord(it)].filter(Boolean);
299
+ it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && it.phase !== 'stale' && '<span class="card-stale">stale</span>', phaseWord(it), pauseWords(pause)].filter(Boolean);
292
300
  const news = newWords(unread, 'message');
293
301
  const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
294
302
  const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
@@ -296,7 +304,7 @@
296
304
  return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
297
305
  }
298
306
  // The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
299
- // Keep awake, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
307
+ // Pause hub, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
300
308
  // durationPicker's markup also uses. Each chip's length (ms, null for Forever) is worded as when it would end if picked
301
309
  // at `now`, here rather than on the server, so the board's version doesn't change with the clock
302
310
  // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
@@ -319,13 +327,10 @@
319
327
  // still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
320
328
  const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
321
329
  const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
322
- // In a wave band (app-lanes.js), step is the number of the card's phase in its wave, shown inside its id chip, and actions replace
323
- // its bump with the band's ⋯ menu.
324
- function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
325
- const ticket = it.ticket ? ticketChip(it) : it.ticketSource === 'linear' ? linearChips(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : linearChips(it);
326
- // A work card always shows its ticket; an ask, which has none, shows New or Ask there (askChip).
327
- const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
328
- const ids = it.kind === 'ask' ? askChip(it) : chip;
330
+ function workLines(it, { unread = 0, top = false } = {}) {
331
+ const ticket = it.ticket ? ticketChip(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : it.linear?.length ? linearChips(it) : taskChip(it);
332
+ // A work card shows its ticket, or Task when it has none; an ask shows New or Ask there (askChip).
333
+ const ids = it.kind === 'ask' ? askChip(it) : ticket;
329
334
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
330
335
  // A handled ask's asked time sits under its title, since its Ask badge holds the ticket's spot
331
336
  // (docs/adr/2026-11-10-1200-an-ask-is-titled-in-place-and-wears-an-ask-badge.md).
@@ -343,7 +348,7 @@
343
348
  // The owner step's code check, which closes the card when it passes (docs/adr/2026-11-04-2308-an-owner-step-can-carry-a-code-check.md).
344
349
  const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
345
350
  : `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
346
- return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
351
+ return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
347
352
  ${asked}${status}${check}${eta}
348
353
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
349
354
  ${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
@@ -68,10 +68,6 @@
68
68
  + `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' : `Claude’s new work waits when either window passes this, and resumes at that window’s reset; Codex hands its work to ${builder} implementers.`}</p></div>`;
69
69
  }
70
70
 
71
- // What polling does now, the last and next check and the queue, with Send now: hub/views.mjs pollingNow's words.
72
- const nowRow = (now, icon = '') => row({ icon, title: esc(now.title), sub: now.lines.map((l) => `<span class="sub wrap">${esc(l)}</span>`).join(''),
73
- trail: `<form method="post" action="/_hub/push/flush"><button class="btn quiet" ${now.count ? '' : 'disabled'}>Send now</button></form>` });
74
-
75
71
  // This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, busy}} or null
76
72
  // while unknown, which keeps the row hidden. A busy action waits (ui.js BUSY) while it turns on or tests.
77
73
  function deviceRow(state, { compact = false } = {}) {
@@ -122,5 +118,5 @@
122
118
  return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
123
119
  }
124
120
 
125
- globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow, nowRow, deviceRow, usageCard };
121
+ globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow, deviceRow, usageCard };
126
122
  })();
@@ -14,21 +14,21 @@
14
14
  // from its ref, to its card's key, the issue url prefix of each Linear team the board's cards link to (none on a
15
15
  // board without a workspace), that item's repo for a bare #n that is no card's ref, and `base`, which a page other
16
16
  // than the board sets to './' so a card link opens the board. A ref shaped like a Linear id, such as INT-<n>, or a
17
- // ticket #n links from anywhere; a wave's own short id, such as B1, links only from that wave's cards, since a plan
18
- // for another wave reuses those names.
17
+ // ticket #n links from anywhere; an item file's own short id, such as B1, links only from that file's cards, since
18
+ // another item file's plan reuses those names.
19
19
  function linkContext(items = [], key = null, { base = '' } = {}) {
20
- const wave = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
20
+ const file = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
21
21
  const byRef = new Map();
22
22
  for (const i of items) {
23
23
  if (i.kind === 'ask') continue;
24
24
  for (const ref of new Set([i.ref, i.id])) {
25
- if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.wave !== wave)) continue;
25
+ if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.file !== file)) continue;
26
26
  byRef.set(ref, [...(byRef.get(ref) || []), i]);
27
27
  }
28
28
  }
29
29
  const refs = Object.create(null);
30
30
  for (const [ref, list] of byRef) {
31
- const pick = list.length === 1 ? list[0] : list.find((i) => i.wave === wave);
31
+ const pick = list.length === 1 ? list[0] : list.find((i) => i.file === file);
32
32
  if (pick) refs[ref] = pick.key;
33
33
  }
34
34
  const teams = Object.create(null);
@@ -1,17 +1,16 @@
1
1
  // The board's order of its items, each lane together, which board/domain.mjs sorts the board by and the page sorts a
2
2
  // tap it draws at once by, so the card lands where the server will put it
3
- // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, bumpedOut, sortAt, order and key.
3
+ // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
4
4
  // Needs nothing else: board/domain.mjs runs this file in a vm context.
5
5
  (function () {
6
- const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3, asks: 4 };
6
+ const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3 };
7
7
  // Every lane but Done lists bumped cards first, the earliest bump on top, a bumped New ask included
8
8
  // (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
9
- // docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), then in Todo the cards a bump bumped out,
10
- // the earliest first, so the pull takes them next. Todo lists the rest oldest first by sortAt, a card with no
11
- // time last, the first added to its wave file on top, so a New ask and every new card join the bottom and the pull takes the
9
+ // docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md). Todo lists the rest oldest first by sortAt, a card with no
10
+ // time last, the first added to its item file on top, so a New ask and every new card join the bottom and the pull takes the
12
11
  // top ready card (docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md,
13
12
  // docs/adr/2026-11-06-0300-a-new-task-lands-at-the-bottom-of-todo-and-flashes-blue.md). Every other
14
- // lane lists its cards newest first by sortAt, a card with no time last, the last added to its wave file on top.
13
+ // lane lists its cards newest first by sortAt, a card with no time last, the last added to its item file on top.
15
14
  // Present times first, the earliest on top.
16
15
  const earliest = (x, y) => (x ? 0 : 1) - (y ? 0 : 1) || String(x || '').localeCompare(String(y || ''));
17
16
  function laneOrder(a, b) {
@@ -20,7 +19,7 @@
20
19
  const timeless = (i) => (at(i) ? 0 : 1);
21
20
  const age = oldestFirst ? timeless(a) - timeless(b) || at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
22
21
  const added = a.lane !== 'todo' ? b.order - a.order : a.order - b.order;
23
- return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || earliest(a.bumpedAt, b.bumpedAt) || earliest(a.bumpedOut?.at, b.bumpedOut?.at) || age || added || a.key.localeCompare(b.key);
22
+ return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || earliest(a.bumpedAt, b.bumpedAt) || age || added || a.key.localeCompare(b.key);
24
23
  }
25
24
  Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
26
25
  })();
@@ -1,16 +1,18 @@
1
1
  // Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
2
2
  (function () {
3
- const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
3
+ const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
+ // ui-markdown.js loads after this file, so markdown is looked up when a question draws.
5
+ const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
4
6
  // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
5
7
  const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
6
8
  const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
7
- // suggested: the line under a suggested option saying where the owner chose it before, as HTML.
8
- const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '' }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
9
- <span><span class="lbl">${numbered ? escText(lead(o)) : ''}${escText(o.label)}</span>${suggested ? badge('ready', 'suggested').replace('class="chip', 'class="chip suggested') : ''}${o.recommended ? '<span class="rec">recommended</span>' : ''}<div class="det">${escText(o.detail || '')}</div>${suggested ? `<div class="suggested-why">${suggested}</div>` : ''}</span></label>`;
9
+ // suggested: the orchestrator's blue line under a suggested option's label, as HTML.
10
+ const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '', links = {} }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
11
+ <span><span class="lbl">${numbered ? escText(lead(o)) : ''}${escText(o.label)}</span>${o.recommended ? '<span class="rec">recommended</span>' : ''}${suggested ? `<div class="suggested-why">Orchestrator choice: ${suggested}</div>` : ''}<div class="det">${o.detail ? markdown(o.detail, links) : ''}</div></span></label>`;
10
12
  // docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
11
13
  // A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
12
14
  // suggested, pre-checked so the topic's Send sends it.
13
- const choiceOf = (q, draft) => draft.choice ?? (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
15
+ const choiceOf = (q, draft) => 'choice' in draft ? draft.choice : (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
14
16
  // A question's unsent note: its draft, else on a reopened question the note its last answer sent, so a changed pick
15
17
  // sends it again.
16
18
  const noteOf = (q, draft) => draft.note ?? (q.reopened ? q.answers.at(-1)?.note || '' : '');
@@ -22,7 +24,7 @@
22
24
  // phone's deck (app-needs.js) and the prototype viewer's Choose this (prototype-switcher.js) write it.
23
25
  const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
24
26
  const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
25
- const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`You answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
27
+ const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(q.suggested.reason ?? `you answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
26
28
 
27
29
  const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
28
30
  // A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone, which then reads
@@ -42,10 +44,12 @@
42
44
  : r.state === 'closed' ? badge('quiet', `Round ${r.n} closed · take ${esc(r.take)}`)
43
45
  : badge('normal', `Prototype round ${r.n}`);
44
46
  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
+ // Each of an answer's replies, oldest first.
48
+ const repliesOf = (a, links) => (a.replies?.length ? a.replies : a.reply ? [a.reply] : []).map((r) => `<div class="rep${r.md ? ' md' : ''}"><div class="muted small">Claude</div>${replyBody(r, links)}</div>`).join('');
45
49
  function answerHistory(q, links = {}) {
46
50
  if (!q.answers.length) return '';
47
51
  const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${escText(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
48
- ${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : ''}`).join('');
52
+ ${repliesOf(a, links)}`).join('');
49
53
  const last = q.answers[q.answers.length - 1];
50
54
  return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
51
55
  }
@@ -83,7 +87,7 @@
83
87
 
84
88
  function takes(q, chosen, links, locked = false) {
85
89
  const name = 'c-' + q.id;
86
- return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
90
+ return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links), links })}${o.diagram ? diagram(o.diagram) + practice(o.practice || [], links) : ''}</article>`).join('')}</div>`;
87
91
  }
88
92
  // A confirm says why it is asked; its View prototypes opens the viewer on its one take (?variant=).
89
93
  const confirmLine = (q) => q.confirm_of && 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>` : '';
@@ -97,15 +101,15 @@
97
101
  const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
98
102
  const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
99
103
  const said = last && (last.note || last.reply || last.files?.length) ? `<div class="ans"><div class="muted small">You · ${esc(time(last.ts))}</div>${last.note ? `<div>${linkText(last.note, links)}</div>` : ''}${attachments(last.files)}</div>
100
- ${last.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(last.reply), links)}</div>` : ''}` : '';
104
+ ${repliesOf(last, links)}` : '';
101
105
  return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
102
106
  <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge('ready', '✓ Resolved', { button: true, title: 'Reopen this question', attrs: { type: 'button', 'data-reopen': q.id } })}</div>
103
107
  <h3>${linkText(q.question, links)}</h3>
104
108
  ${q.answers.length ? '<div class="sent-line">Sent. Tap Resolved to reopen it.</div>' : ''}
105
- ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
109
+ ${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
106
110
  ${questionLink(q.url)}
107
111
  ${outcome || acted ? `<div class="small muted resolved-line">${outcome}${acted}</div>` : ''}
108
- ${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true })).join('')}
112
+ ${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true, links })).join('')}
109
113
  ${said}
110
114
  ${q.reask ? '<div class="small muted reask-line">re-ask requested</div>' : ''}
111
115
  </section>`;
@@ -121,10 +125,10 @@
121
125
  return `<section class="q${q.urgent && q.state === 'open' ? ' urgent' : ''}${locked ? ' pr-busy' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
122
126
  <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
123
127
  <h3>${linkText(q.question, links)}</h3>
124
- ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
128
+ ${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
125
129
  ${questionLink(q.confirm_of && isPrototype(q.url) ? withParam(q.url, 'variant', q.options[0].id) : q.url)}
126
130
  ${confirmLine(q)}
127
- ${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
131
+ ${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links), links })).join('')}
128
132
  ${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, noteOf(q, draft))}
129
133
  ${answerHistory(q, links)}
130
134
  </section>`;
@@ -129,7 +129,7 @@
129
129
  + '<p class="sc-foot">Press h twice anywhere to open this. Esc to close.</p></div>';
130
130
  }
131
131
 
132
- const ticketsLabel = (t) => (t.source === 'internal' ? `Internal · ${t.prefix}` : t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : `Linear · ${t.team}`);
132
+ const ticketsLabel = (t) => (t.source === 'github' ? `GitHub issues${t.account ? ` · ${t.account}` : ''}` : t.source === 'linear' ? `Linear · ${t.team}` : 'None');
133
133
  // A board's context window: its owner session's latest percent at the row's top right, red from 90%, dimmed once the
134
134
  // reading is older than ten minutes, with that age in its tooltip.
135
135
  const CTX_STALE_MIN = 10;
@@ -1,8 +1,13 @@
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.
1
+ // A card's thread: the orchestrator's lines (who claude, named Orchestrator), the board's status lines, its builders'
2
+ // asks, the owner's messages and their replies. Needs ui.js, ui-links.js, ui-enums.js and ui-markdown.js.
3
3
  (function () {
4
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
+ // ui-markdown.js loads after this file, so markdown is looked up when a thread draws.
7
+ const markdown = (src, links) => globalThis.BoardUI.markdown(src, links);
8
+ // The orchestrator's own messages are markdown in compact styles; the owner's, a shared person's and GitHub's or
9
+ // Slack's stay as written (docs/adr/2026-11-09-1453-orchestrator-messages-replies-and-transcript-text-are-markdown.md).
10
+ const drawsMarkdown = (m) => m.who === 'claude' && !m.html;
6
11
  // A link to a card on this board (#selectedCard=<key>) or on another board (/<route>/#selectedCard=<key>, an ask's move), or ''.
7
12
  function cardLink(link) {
8
13
  const key = /^(?:\/[\w-]+\/)?#(?:selectedCard|item)=([^&]+)/.exec(link)?.[1];
@@ -25,9 +30,9 @@
25
30
  const withReplies = (msgs) => {
26
31
  // A folded submit's answers each reply, quoting their own block.
27
32
  const out = msgs.flatMap((m, i) => [{ m, at: String(m.ts), i, part: 0 },
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 }))]);
33
+ ...(m.answers || [m]).filter((a) => a.eventId && a.reply).flatMap((a) => (a.replies?.length ? a.replies : [a.reply]).map((reply, k) => ({ m: { ts: reply.at || m.ts, who: 'claude', kind: 'reply', reply }, of: m, quoted: m.answers ? a.text : m.text, later: k > 0, at: String(reply.at || m.ts), i, part: 1 })))]);
29
34
  out.sort((a, b) => a.at.localeCompare(b.at) || a.i - b.i || a.part - b.part);
30
- return out.map(({ m, of, quoted }, n) => (of ? { ...m, quote: out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
35
+ return out.map(({ m, of, quoted, later }, n) => (of ? { ...m, quote: later || out[n - 1]?.m === of && !(of.answers?.length > 1) ? '' : quoted || '' } : m));
31
36
  };
32
37
  // A message being edited shows its edit box in place of its text; app-asks.js handles the form, and attach.js the
33
38
  // files it adds to the message, under composer id edit:<event id>.
@@ -35,23 +40,26 @@
35
40
  <div class="pending-files" data-files></div>
36
41
  <div class="send-actions"><button type="button" data-edit-cancel>Cancel</button>${sendButton('Save', 0)}</div></form>`;
37
42
  const line = (m, links, edits) => (m.kind === 'reply'
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">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
43
+ ? `<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${m.reply.md ? ' md' : ''}">${replyBody(m.reply, links)}</div></div>`
44
+ : m.kind === 'ask-link' ? `<div class="msg status-line link">${statusMark('ask-link')}<a href="${esc(m.link)}">${escText(m.text)} →</a></div>`
40
45
  // 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>`
46
+ : m.kind === 'visual' ? `<div class="msg status-line link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
42
47
  // 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${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
48
+ : m.request && m.card ? `<div class="msg status-line request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
49
+ : m.request ? `<div class="msg status-line request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
50
+ // docs/adr/2026-11-09-1200-the-board-writes-status-lines-and-only-the-orchestrator-writes-messages.md: the board's
51
+ // own line, its kind's mark and words, red for a fault.
52
+ : m.kind === 'hub' ? `<div class="msg status-line request hub${m.fault ? ' fault' : ''}">${statusMark(m.mark)}${m.link ? `<a href="${esc(m.link)}">${escText(m.text)}</a>` : escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
45
53
  // 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>`
54
+ : m.kind === 'move' ? `<div class="msg status-line request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
55
+ : m.kind === 'questions' ? `<div class="msg status-line 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
56
  : m.folded ? foldedRun(m, links, edits)
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>`
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)}"` : ''}>
57
+ : m.kind === 'status' ? `<div class="msg status-line builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
58
+ : m.kind === 'calls' ? `<details class="msg status-line 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>`
59
+ : m.kind === 'agent' ? `<div class="msg status-line 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)}"` : ''}>
52
60
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
53
61
  <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>
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)}
62
+ <div class="body${drawsMarkdown(m) ? ' md' : ''}"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : drawsMarkdown(m) ? markdown(m.text, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
55
63
  ${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">View transcript</a></div>` : ''}
56
64
  ${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
57
65
  ${m.eventId ? msgFooter(m) : ''}
@@ -65,7 +73,7 @@
65
73
  const tail = m.folded.at(-1).finished ? 'finished' : 'running';
66
74
  const log = m.folded.find((f) => f.log)?.log;
67
75
  const logLine = log ? `<div class="small fold-log"><a href="log.html?file=${encodeURIComponent(log)}">View transcript</a></div>` : '';
68
- return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
76
+ return `<details class="msg status-line calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
69
77
  }
70
78
  function foldStatus(lines) {
71
79
  const runs = new Map();
@@ -80,12 +88,12 @@
80
88
  const STEP = 3;
81
89
  const UNREAD_CAP = 10;
82
90
  // 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.
83
- const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move'];
84
- const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move']);
91
+ const SYSTEM_KINDS = ['status', 'agent', 'calls', 'ask-link', 'questions', 'visual', 'command', 'move', 'hub'];
92
+ const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions', 'move', 'hub']);
85
93
  const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
86
- // Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
87
- // orchestrator's answer to a builder's ask never is.
88
- 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;
94
+ // Only Claude's and GitHub's messages and the board's fault lines are unread, as on the badge (domain.mjs othersAt,
95
+ // ui-cards.js unreadCount); the orchestrator's answer to a builder's ask never is.
96
+ const unread = (m, seenAt) => Boolean(seenAt) && (m.fault || (isMessage(m) && m.kind !== 'command' && m.kind !== 'builder-answer' && (m.who === 'claude' || m.via === 'github'))) && String(m.ts) > seenAt;
89
97
  // The window opens from the oldest unread message, at most UNREAD_CAP messages back; a message being edited always shows.
90
98
  function threadWindow(lines, { seenAt = null, more = 0, upto = null }, edits) {
91
99
  const counted = lines.flatMap((m, i) => (isMessage(m) && (!upto || String(m.ts) <= upto) ? [i] : []));
@@ -35,7 +35,10 @@
35
35
  const day = d.toLocaleDateString([], { month: 'numeric', day: 'numeric', ...(now - d > 365 * 86400000 ? { year: 'numeric' } : {}) });
36
36
  return `${day}, ${at}`;
37
37
  }
38
- const replyBody = (reply) => reply.html ?? esc(JSON.stringify(reply.json));
38
+ // A reply as HTML with its refs linked: a markdown reply drawn by ui-markdown.js, which, like ui-links.js, loads after
39
+ // this file; an older HTML one as written. Its box takes class md for a markdown reply's compact styles.
40
+ const replyBody = (reply, links) => (reply.md ? globalThis.BoardUI.markdown(reply.md, links)
41
+ : globalThis.BoardUI.linkHtml(reply.html ?? esc(JSON.stringify(reply.json)), links));
39
42
 
40
43
  // Attachments are uploads from POST /api/upload: {name, url, path, type, size}. Only uploads/ URLs become links.
41
44
  const uploadUrl = (f) => (/^uploads\/[\w.-]+$/.test(f.url || '') ? f.url : null);
@@ -107,6 +110,9 @@
107
110
  'Marked as ready': mini('<path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/>'),
108
111
  Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
109
112
  Resumed: mini('<path d="M7 4v16l13-8z"/>'),
113
+ // The board's status lines (board/hub-lines.mjs): a block's circle with a bar, a fault's warning triangle.
114
+ Blocked: mini('<circle cx="12" cy="12" r="9"/><path d="M5.6 5.6l12.8 12.8"/>'),
115
+ Fault: mini('<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/>'),
110
116
  'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
111
117
  'Marked done': mini('<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>'),
112
118
  Reply: mini('<path d="M9 14 4 9l5-5"/><path d="M4 9h10a6 6 0 0 1 6 6v5"/>'),
@@ -148,11 +154,16 @@
148
154
  return `<${tag} class="chip tone-${tone}"${extra}>${html}</${tag}>`;
149
155
  }
150
156
  // A composer's files before sending: {id, name, type, preview, status: uploading|done|error, progress (0–1), error}.
151
- const pendingFiles = (list) => list.map((p) => `<span class="pfile ${esc(p.status)}" title="${esc(p.name)}">
157
+ // An errored chip retries on click, except "Over 20 MB", which a retry can't change.
158
+ const pendingFiles = (list) => list.map((p) => {
159
+ const retry = p.status === 'error' && p.error !== 'Over 20 MB';
160
+ const title = retry ? `${p.name}: tap to retry` : p.status === 'error' ? `${p.name}: a retry can't succeed, remove it` : p.name;
161
+ return `<span class="pfile ${esc(p.status)}${retry ? ' retry' : ''}" title="${esc(title)}"${retry ? ` data-retry="${esc(p.id)}"` : ''}>
152
162
  ${isImage(p) && p.preview ? `<img src="${esc(p.preview)}" alt="${esc(p.name)}">` : `<span class="pname">${esc(p.name)}</span>`}
153
163
  ${p.status === 'uploading' ? `<span class="pbar" role="progressbar" aria-label="Uploading ${esc(p.name)}" aria-valuenow="${Math.round((p.progress || 0) * 100)}"><span style="width:${Math.max(4, Math.round((p.progress || 0) * 100))}%"></span></span>` : ''}
154
- ${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}</span>` : ''}
155
- <button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span>`).join('');
164
+ ${p.status === 'error' ? `<span class="perr">${esc(p.error || 'Upload failed')}${retry ? ' · tap to retry' : ''}</span>` : ''}
165
+ <button type="button" class="unattach" data-unattach="${esc(p.id)}" aria-label="Remove ${esc(p.name)}">✕</button></span>`;
166
+ }).join('');
156
167
 
157
168
  // Replacing innerHTML would close every <details> inside, collapsing what the reader had open, and replace the field
158
169
  // being typed in, dropping its text, cursor and focus, and on iOS the keyboard. So setHTML patches el in place: a
@@ -401,7 +412,7 @@
401
412
  const copyNote = (what, savedAt) => `<p class="note copy-note">Copy of ${esc(what)} as of ${since(savedAt)}. Read only.</p>`;
402
413
  // The buttons that send, which a board out of reach holds (style.css .offline): every one greyed and stopped, keeping its
403
414
  // label and size. Reading controls, folds, links, Edit and drafts stay live.
404
- 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]`;
415
+ 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-grill], button[data-proto], button[data-visual], button[data-plan], button[data-main-pull], button[data-arch-run], button[data-try], button[data-reopen]`;
405
416
  // While held(), a tap on one of root's sending buttons, and any form's submit there, Enter's included, goes nowhere, and onHeld
406
417
  // tells the tap it is waiting. on is the view's B.on.
407
418
  function holdSends(on, root, held, onHeld) {
@@ -447,8 +458,14 @@
447
458
  }
448
459
  }
449
460
 
461
+ // Whether a click dismisses a modal: it landed on the backdrop element and the press that made it began there too, so a
462
+ // drag that starts inside (selecting text) and ends on the backdrop leaves the modal open.
463
+ let pressedOn = null;
464
+ if (typeof document !== 'undefined') document.addEventListener('pointerdown', (e) => { pressedOn = e.target; }, true);
465
+ const onBackdrop = (ev, backdrop) => ev.target === backdrop && pressedOn === backdrop;
466
+
450
467
  globalThis.BoardUI = {
451
- LINK_ARROW, escText,
468
+ LINK_ARROW, escText, onBackdrop,
452
469
  esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, WAITING, RESTARTING, restartNotice, downOf, downLanding, copyNote, SENDS, holdSends,
453
470
  attachments, attachButton, dropZone, sendButton, keyedSend, pendingFiles, protoButton, grillButton, visualButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, thinkingLine, isPaused, PAUSED, BADGE_TONES, badge,
454
471
  };
@@ -1,6 +1,7 @@
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. Pause hub, first, posts to /_hub/settings/hub-pause. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
3
- // hours' times and hold to /_hub/settings/quiet, and Keep awake to /_hub/settings/keep-awake, and saves at once, as Linear's key and Slack's token do through linear-key.js; Done waits for
1
+ // HubSettings: summary rows 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. Linear's key and Slack's token open the same modal from the button on their Integrations rows (view-hub.js),
3
+ // with no summary row of their own ([Linear and Slack keys open in modals from their Integrations rows](docs/adr/2026-11-09-2230-linear-and-slack-keys-open-in-modals-from-their-integrations-rows.md)). Pause hub, first, posts to /_hub/settings/hub-pause. Each toggle and snooze posts to /_hub/settings/notifications, Quiet
4
+ // hours' times and hold to /_hub/settings/quiet, and saves at once, as Linear's key and Slack's token do through linear-key.js; Done waits for
4
5
  // a key's save to land. The rules editor saves through POST /_hub/settings/rules
5
6
  // and marks [data-dirty] while an edit is unsaved, so the shell asks before leaving then. The data refreshes every 30 seconds while nothing is being edited.
6
7
  (function () {
@@ -15,10 +16,6 @@
15
16
  action: '/_hub/settings/hub-pause', name: 'pause', choices: p.choices, on: p.on, button: 'Resume' });
16
17
  const snoozeRow = (d) => durationPicker({ id: 'snooze', title: 'Snooze', icon: HubUI.tile('snooze', '#a855f7'), action: ACTION, name: 'snooze',
17
18
  choices: d.snooze?.choices, on: d.snoozed, button: 'End' });
18
- // Grey while off and orange while on, as the machine working (docs/adr/2026-11-01-1817-colour-says-whos-waiting.md).
19
- const awakeRow = (a) => durationPicker({ id: 'awake', title: 'Keep awake', icon: HubUI.tile('coffee', a.on ? 'var(--color-notice)' : 'var(--color-quiet)'),
20
- action: '/_hub/settings/keep-awake', name: 'keep_awake', choices: a.choices, on: a.on, button: 'Turn off' });
21
-
22
19
  // The on/off, snooze and weekend rows (docs/adr/2026-10-29-1639-notifications-can-be-off-snoozed-or-off-on-weekends.md).
23
20
  function notificationRows(d) {
24
21
  const { tile } = HubUI;
@@ -40,10 +37,12 @@
40
37
  + `<h2 class="label">Snoozes and wakes read</h2><div class="group">${reads}</div>`;
41
38
  }
42
39
 
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']];
40
+ const sections = [['pause', 'Pause hub'], ['notifications', 'Notifications'], ['quiet', 'Quiet hours'], ['rules', 'Rules for every board'], ['closed', 'Closed boards']];
41
+ // Linear and Slack open from their Integrations rows, so they have modals and no summary rows.
42
+ const keys = [['linear', 'Linear'], ['slack', 'Slack']];
44
43
  const sectionOf = (hash) => {
45
44
  const id = { push: 'notifications', 'closed-boards': 'closed', polling: 'quiet' }[hash?.slice(1)] || hash?.slice(1);
46
- return sections.some(([section]) => section === id) ? id : null;
45
+ return [...sections, ...keys].some(([section]) => section === id) ? id : null;
47
46
  };
48
47
  const markup = () => `${sections.map(([id, title]) => `<button type="button" class="hub-setting" id="${id}" data-settings-open="${id}" aria-haspopup="dialog"><span class="text"><span class="title">${title}</span><span class="sub" data-summary="${id}">Loading…</span></span><span class="chev" aria-hidden="true">›</span></button>`).join('')}
49
48
  <dialog class="settings-modal hub-modal" id="hubSettingsModal" aria-labelledby="hubSettingsTitle"><div class="settings-head"><div><h2 id="hubSettingsTitle" tabindex="-1"></h2><p class="muted small">Hub settings</p></div><button type="button" class="ghost" data-settings-dismiss aria-label="Close hub settings">✕</button></div><div class="set settings-body"><div class="hs-flash"></div><div class="setting-content"></div></div><div class="settings-footer"><button type="button" data-settings-dismiss>Done</button></div></dialog>`;
@@ -75,9 +74,6 @@
75
74
  content.innerHTML = `<div class="group">${deviceRow(device.state(last.device), { compact: true })}${notificationRows(last)}</div>`;
76
75
  } else if (active === 'quiet') {
77
76
  content.innerHTML = quietRows(last.quiet);
78
- } else if (active === 'keep-awake') {
79
- content.innerHTML = last.awake.unsupported ? `<p class="note">${esc(last.awake.unsupported)}.</p>`
80
- : `<div class="group">${awakeRow(last.awake)}</div><p class="note">Runs <code>caffeinate -ims</code> on the host so it doesn’t sleep while boards work. The screen can still turn off.</p>`;
81
77
  } else if (active === 'linear' || active === 'slack') {
82
78
  if (tokenSlot) tokenSlot.set(last[active]);
83
79
  else {
@@ -90,14 +86,10 @@
90
86
  }
91
87
  pickers.painted();
92
88
  B.$('.settings-body').scrollTop = scrollTop;
93
- B.$('#keep-awake').disabled = Boolean(last.awake.unsupported);
94
89
  const summaries = {
95
90
  pause: last.pause.summary,
96
91
  notifications: [last.on.on ? 'Pushes on' : 'Pushes off', last.snoozed?.summary, last.weekends.on ? 'Off on weekends' : '', deviceSummary()].filter(Boolean).join(' · '),
97
92
  quiet: last.quiet.summary,
98
- 'keep-awake': last.awake.summary,
99
- linear: last.linear?.summary || 'No key',
100
- slack: last.slack?.summary || 'No token',
101
93
  rules: last.rules.length ? `${last.rules.length} rule${last.rules.length === 1 ? '' : 's'}` : 'None yet',
102
94
  closed: last.closed?.length ? `${last.closed.length} closed` : 'No closed boards',
103
95
  };
@@ -156,7 +148,8 @@
156
148
  active = id;
157
149
  if (id) {
158
150
  returnFocus = opener || B.$(`#${id}`);
159
- B.$('#hubSettingsTitle').textContent = sections.find(([section]) => section === id)[1];
151
+ B.$('#hubSettingsTitle').textContent = [...sections, ...keys].find(([section]) => section === id)[1];
152
+ B.$('.settings-head .muted').textContent = keys.some(([key]) => key === id) ? 'Integrations' : 'Hub settings';
160
153
  content.innerHTML = id === 'rules' ? '<div id="hubRules" class="rules-box"></div>' : '<p class="note">Loading…</p>';
161
154
  flash = null;
162
155
  paint();
@@ -180,7 +173,7 @@
180
173
  const opener = e.target.closest?.('[data-settings-open]');
181
174
  if (opener) { B.openSettings({ hash: `#${opener.dataset.settingsOpen}` }, opener); return; }
182
175
  if (e.target.closest?.('[data-settings-dismiss]')) { dismiss(); return; }
183
- if (e.target === modal) {
176
+ if (BoardUI.onBackdrop(e, modal)) {
184
177
  const r = modal.getBoundingClientRect();
185
178
  if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) dismiss();
186
179
  }
@@ -216,7 +209,6 @@
216
209
  else on.checked = true;
217
210
  });
218
211
  const forms = HubKit.forms(B, {
219
- accepts: (action) => action !== '/_hub/push/flush',
220
212
  answer(out) {
221
213
  last = out;
222
214
  flash = null;