@lovek4d/kickflip 0.1.0 → 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 (120) hide show
  1. package/CONTEXT.md +239 -104
  2. package/README.md +54 -4
  3. package/SKILL.md +63 -42
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +103 -40
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/auto-review.mjs +2 -0
  11. package/board/bin/agent.mjs +1 -1
  12. package/board/bin/board.mjs +371 -82
  13. package/board/bin/builder-stop-hook.mjs +4 -3
  14. package/board/domain.mjs +8 -3
  15. package/board/finish.mjs +3 -0
  16. package/board/headless.mjs +3 -0
  17. package/board/on-host.mjs +1 -0
  18. package/board/permissions.mjs +2 -0
  19. package/board/public/app-arch.js +75 -0
  20. package/board/public/app-asks.js +66 -28
  21. package/board/public/app-banner.js +2 -2
  22. package/board/public/app-cards.js +354 -77
  23. package/board/public/app-core.js +40 -8
  24. package/board/public/app-header.js +33 -8
  25. package/board/public/app-lanes.js +238 -34
  26. package/board/public/app-needs.js +2 -0
  27. package/board/public/app-snapshot.js +45 -11
  28. package/board/public/app-threads.js +20 -21
  29. package/board/public/app.html +6 -2
  30. package/board/public/app.js +114 -33
  31. package/board/public/attach.js +39 -3
  32. package/board/public/dialogs.js +161 -118
  33. package/board/public/hub-kit.js +27 -7
  34. package/board/public/hub.css +26 -33
  35. package/board/public/linear-key.js +143 -0
  36. package/board/public/log.html +48 -0
  37. package/board/public/prompts.js +9 -8
  38. package/board/public/prototype-kit.js +6 -6
  39. package/board/public/prototype-switcher.js +13 -5
  40. package/board/public/prototype.html +1 -0
  41. package/board/public/router.js +14 -10
  42. package/board/public/rules-editor.js +4 -5
  43. package/board/public/shell.js +21 -7
  44. package/board/public/sidebar.css +51 -11
  45. package/board/public/sidebar.js +124 -19
  46. package/board/public/style.css +244 -56
  47. package/board/public/toasts.js +5 -3
  48. package/board/public/ui-cards.js +236 -66
  49. package/board/public/ui-enums.js +5 -4
  50. package/board/public/ui-header.js +23 -14
  51. package/board/public/ui-hub.js +34 -16
  52. package/board/public/ui-links.js +18 -10
  53. package/board/public/ui-markdown.js +1 -1
  54. package/board/public/ui-order.js +21 -0
  55. package/board/public/ui-page-header.js +13 -8
  56. package/board/public/ui-patch.js +53 -0
  57. package/board/public/ui-phase-bars.js +17 -0
  58. package/board/public/ui-prompts.js +8 -8
  59. package/board/public/ui-questions.js +35 -11
  60. package/board/public/ui-sidebar.js +83 -21
  61. package/board/public/ui-snapshot.js +30 -13
  62. package/board/public/ui-threads.js +38 -23
  63. package/board/public/ui.js +96 -16
  64. package/board/public/view-frame.js +1 -1
  65. package/board/public/view-hub-settings.js +67 -44
  66. package/board/public/view-hub.js +105 -13
  67. package/board/public/view-notes.js +8 -5
  68. package/board/public/view-questions.js +123 -52
  69. package/board/public/view-settings.js +235 -62
  70. package/board/review.mjs +1 -0
  71. package/board/server.mjs +18 -7
  72. package/board/store.mjs +15 -6
  73. package/board/tickets.mjs +3 -3
  74. package/board/worktree.mjs +20 -13
  75. package/docs/hub-session.md +4 -4
  76. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  77. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  78. package/hub/awake.mjs +1 -0
  79. package/hub/board-repo.mjs +1 -1
  80. package/hub/boards.mjs +4 -4
  81. package/hub/close-board.mjs +1 -1
  82. package/hub/duration.mjs +1 -0
  83. package/hub/github.mjs +3 -4
  84. package/hub/home.mjs +1 -1
  85. package/hub/hub.mjs +12 -10
  86. package/hub/incoming.mjs +2 -0
  87. package/hub/linear.mjs +2 -2
  88. package/hub/live.mjs +2 -2
  89. package/hub/new-board.mjs +2 -2
  90. package/hub/permission-denied-hook.mjs +2 -0
  91. package/hub/pr-report.mjs +3 -0
  92. package/hub/presence.mjs +1 -1
  93. package/hub/prompts.mjs +1 -1
  94. package/hub/push.mjs +2 -2
  95. package/hub/readiness.mjs +2 -0
  96. package/hub/repos.mjs +3 -2
  97. package/hub/restart-board.mjs +1 -0
  98. package/hub/routes.mjs +1 -1
  99. package/hub/schedule.mjs +1 -1
  100. package/hub/sessions.mjs +2 -2
  101. package/hub/shell.mjs +2 -1
  102. package/hub/slack.mjs +2 -0
  103. package/hub/stream.mjs +12 -12
  104. package/hub/sw.js +2 -2
  105. package/hub/trust.mjs +2 -0
  106. package/hub/try.mjs +8 -3
  107. package/hub/upgrade.mjs +1 -0
  108. package/hub/usage.mjs +3 -3
  109. package/hub/views.mjs +1 -1
  110. package/package.json +5 -1
  111. package/shared/card-prs.mjs +1 -0
  112. package/shared/colors.css +9 -6
  113. package/shared/fake-gh.mjs +105 -0
  114. package/shared/repos.mjs +1 -1
  115. package/shared/service.mjs +16 -16
  116. package/shared/skills.mjs +2 -0
  117. package/shared/usage-gate.mjs +1 -1
  118. package/starter/public/claude.js +3 -2
  119. package/bin/web-ts.mjs +0 -2
  120. package/board/public/view-polling.js +0 -87
@@ -1,23 +1,33 @@
1
- // Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js and ui-enums.js.
1
+ // Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js, ui-enums.js and ui-phase-bars.js.
2
2
  (function () {
3
- const { esc, ago, time, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, LINK_ARROW } = globalThis.BoardUI;
3
+ const { esc, ago, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, phaseBars: bars } = globalThis.BoardUI;
4
4
  const EXTERNAL = { target: '_blank', rel: 'noopener' };
5
- function prChips(it) {
6
- const pr = it.pr;
7
- if (!pr) return '';
8
- const st = pr.state === 'MERGED' ? badge('ready', 'merged') : pr.state === 'CLOSED' ? badge('danger', 'closed') : '';
9
- // An open PR's chip ends with its state, marked like the reviewer and CI chips.
10
- const open = pr.state === 'OPEN' ? (pr.isDraft ? { tone: 'notice', mark: ' ●', label: 'Draft' } : { tone: 'ready', mark: ' ✓', label: 'Ready for review' }) : { tone: 'normal', mark: '', label: '' };
5
+ // The PR badge is the one place a PR's state shows: its mark and tone, marked like the reviewer and CI chips, and its
6
+ // title (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). A card with several PRs shows a
7
+ // badge for each, in its order (docs/adr/2026-11-03-1904-a-card-holds-several-prs-across-repos.md).
8
+ const PR_LOOK = {
9
+ [PR_STATE.open]: { tone: 'quiet', mark: ' ✓', label: 'Open' },
10
+ [PR_STATE.draft]: { tone: 'notice', mark: ' ●', label: 'Draft' },
11
+ [PR_STATE.merged]: { tone: 'merged', mark: ' ✓', label: 'Merged' },
12
+ [PR_STATE.closed]: { tone: 'danger', mark: ' ✗', label: 'Closed' },
13
+ };
14
+ // An open PR reads green only while it waits on a reviewer alone: CI passed and no reviewer asked for changes.
15
+ const waitsOnReviewer = (pr) => pr.checks?.state === 'pass' && pr.reviewDecision !== 'CHANGES_REQUESTED';
16
+ const prChips = (it) => (it.prs || []).map(prChip).join('');
17
+ // A PR's short name, its repo's name and number: kickflip#6.
18
+ const prName = (pr) => `${(pr.url.match(/github\.com\/[^/]+\/([^/]+)\//) || [])[1] || ''}#${pr.number}`;
19
+ function prChip(pr) {
20
+ const base = PR_LOOK[pr.status] || { tone: 'quiet', mark: '', label: '' };
21
+ const look = pr.status === PR_STATE.open && waitsOnReviewer(pr) ? { ...base, tone: 'ready', label: 'Ready for review' } : base;
11
22
  const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
12
23
  const ci = pr.checks && pr.state === 'OPEN' ? badge(CI.tone, `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
13
- const repo = (pr.url.match(/github\.com\/[^/]+\/([^/]+)\//) || [])[1] || '';
14
24
  const codex = pr.state === 'OPEN' && pr.codexOpen ? badge('notice', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet` }) : '';
15
- const name = `${repo}#${pr.number}`;
16
- const label = open.label ? { title: open.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${open.label}` } } : { attrs: EXTERNAL };
17
- return `${badge(open.tone, `${esc(name)}${open.mark}`, { href: pr.url, ...label })}${st}${ci}${codex}`;
25
+ const name = prName(pr);
26
+ const label = look.label ? { title: look.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}` } } : { attrs: EXTERNAL };
27
+ return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
18
28
  }
19
- const linearChips = (it) => (it.linear || []).map((l) => badge('normal', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
20
- 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(it.ticket.source === 'internal' ? 'quiet' : 'normal', 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 });
29
+ const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
30
+ 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 });
21
31
  const REVIEW = {
22
32
  approved: { tone: 'ready', mark: '✓', label: 'approved' },
23
33
  changes: { tone: 'danger', mark: '✗', label: 'asked for changes' },
@@ -33,36 +43,44 @@
33
43
  return (it.reviewers || []).map((r) => {
34
44
  const rv = globalThis.BoardUI.record(it.review, r);
35
45
  const s = REVIEW[rv?.state || 'unsent'];
36
- return badge(s.tone, `${esc(r[0])} ${s.mark}${reviewAge(rv)}`, { title: `${r}: ${s.label}` });
46
+ const age = reviewAge(rv).trim();
47
+ return badge(s.tone, `${esc(rv?.github || r)}${age ? ` (${age})` : ''} ${s.mark}`, { title: `${r}${rv?.github ? ` (${rv.github})` : ''}: ${s.label}` });
37
48
  }).join('');
38
49
  }
39
50
  const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
40
- // docs/adr/0045-the-lane-follows-who-acts-next.md
41
- function actionChip(it) {
51
+ // docs/adr/2026-10-29-1639-the-lane-follows-who-acts-next.md
52
+ // Action needed, last of the time line's orange words, linking to what the owner acts on
53
+ // (docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
54
+ function actionWords(it) {
42
55
  if (!it.action) return '';
43
- const external = it.action.kind === ACTION_KIND.ready ? EXTERNAL : {};
44
- return badge('notice', 'Action needed', { href: it.action.url, title: it.action.label, attrs: { ...external, 'aria-label': `Action needed: ${it.action.label}` } });
56
+ const external = it.action.kind === ACTION_KIND.ready ? Object.entries(EXTERNAL).map(([k, v]) => ` ${k}="${esc(v)}"`).join('') : '';
57
+ return `<a class="card-action" href="${esc(it.action.url)}" title="${esc(it.action.label)}" aria-label="${esc(`Action needed: ${it.action.label}`)}"${external}>Action needed</a>`;
58
+ }
59
+ // Each unfinished dep, linking to its card: "blocked by #53, #54".
60
+ function blockedWords(it) {
61
+ const deps = it.blockedBy || [];
62
+ return deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
45
63
  }
46
64
  const tryWords = (kind) => kind === 'app' ? {
47
- go: 'Build', going: 'Building…', done: 'Install', failed: 'Build failed',
48
- none: 'No changes to build: this card’s branch matches main.',
65
+ go: 'Build', going: 'building…', done: 'Install', failed: 'build failed',
49
66
  current: 'Nothing new to build: Install already has this card’s latest push.',
50
67
  busy: 'Building now. The badge turns back into Install when the new build is ready.',
51
- autoSub: 'On: the hub builds each card after each push. Off: a card waits for your Build. Main builds again after every merge either way.',
68
+ autoSub: 'The hub builds each card after each push, and main after each merge.',
52
69
  saved: (on) => on ? 'Saved. Each push builds its card.' : 'Saved. Cards wait for your Build.',
53
70
  } : {
54
- go: 'Serve', going: 'Serving…', done: 'Visit', failed: 'Serve failed',
55
- none: 'No changes to serve: this card’s branch matches main.',
71
+ go: 'Serve', going: 'serving…', done: 'Visit', failed: 'serve failed',
56
72
  current: 'Nothing new to serve: Visit already shows this card’s latest push.',
57
73
  busy: 'Serving now. The badge turns back into Visit when the new server is up.',
58
- autoSub: 'On: the hub serves each card after each push. Off: a card waits for your Serve. Main serves again after every merge either way.',
74
+ autoSub: 'The hub serves each card after each push, and main after each merge.',
59
75
  saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
60
76
  };
61
77
  const spinner = '<span class="try-spin" aria-hidden="true"></span>';
78
+ const tryURL = (run) => run.kind === 'web' ? run.port ? `https://${location.hostname}:${run.port}/` : null : run.url;
62
79
  function tryOf(it) {
63
80
  if (it.lane === 'done') return null;
64
81
  const t = it.try || (it.install ? { kind: 'app', state: 'ready', stale: it.install.older, sha: it.install.sha, url: it.install.url } : null);
65
- return it.builder?.kind === BUILDER_KIND.install ? { ...t, kind: t?.kind || 'app', state: 'running' } : t;
82
+ const run = t ? { ...t, url: tryURL(t) } : null;
83
+ return it.builder?.kind === BUILDER_KIND.install ? { ...run, kind: run?.kind || 'app', state: 'running' } : run;
66
84
  }
67
85
  function installState(it) {
68
86
  const t = tryOf(it);
@@ -87,67 +105,219 @@
87
105
  const tag = s.href ? 'a' : 'span';
88
106
  return `<${tag} class="install tone-${s.tone}"${attrs}>${s.html}</${tag}>`;
89
107
  }
108
+ // A card's Serve / Build, only while the board's Auto-build is off and its branch has changes
109
+ // (docs/adr/2026-11-02-1806-only-the-owner-or-auto-build-starts-a-card-run.md).
90
110
  function tryButton(it) {
91
111
  const t = tryOf(it);
92
- if (it.kind !== 'work' || !t?.script) return '';
112
+ if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
93
113
  const words = tryWords(t.kind);
94
- const reason = !t.changes ? words.none : t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
114
+ const reason = t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
95
115
  return `<button type="button" data-try="${esc(it.key)}"${reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : ''}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
96
116
  }
97
117
  const BRANCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
98
- function mainBar(main) {
118
+ // Main's refresh icon on a local board (docs/adr/2026-11-03-1914-a-local-boards-main-pulls-from-origin-on-its-own.md),
119
+ // the header's refresh icon as a ghost button right of the timestamp: it spins while a pull runs or a tap waits on the
120
+ // server, and turns red when the last pull failed, with the reason as a tooltip on hover or a popover a tap opens
121
+ // (`open`). Visit is left alone: it changes only once Main rebuilds.
122
+ function mainPull(pull, { sending = false, open = false } = {}) {
123
+ if (!pull) return '';
124
+ const busy = pull.running || sending;
125
+ const failed = !busy && pull.error;
126
+ const label = busy ? 'Pulling main' : failed ? pull.error : 'Pull main now';
127
+ const why = failed ? `<span class="main-pull-why${open ? ' open' : ''}" role="tooltip">${esc(pull.error)}</span>` : '';
128
+ return `<span class="main-pull"><button class="ghost gh-refresh${busy ? ' spinning' : ''}${failed ? ' failed' : ''}" type="button" data-main-pull aria-label="${esc(label)}"${busy ? ' aria-disabled="true"' : ''}>${BoardUI.refreshIcon(14)}</button>${why}</span>`;
129
+ }
130
+ function mainBar(main, pulling) {
99
131
  if (!main) return '';
100
132
  const words = tryWords(main.kind);
133
+ const url = tryURL(main);
101
134
  const chip = main.state === 'running' ? badge('notice', spinner + esc(words.going), { attrs: { role: 'status' } })
102
- : main.state === 'failed' ? badge('danger', esc(words.failed), { title: 'The build script stopped with an error. Its log is data/try/main.log in the board’s folder.' })
103
- : main.state === 'ready' && main.url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: main.url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
104
- return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}</div><div class="meta">${chip}</div></div>`;
135
+ : main.state === 'failed' ? badge('danger', esc(words.failed), { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' })
136
+ : main.state === 'ready' && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
137
+ return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}${mainPull(main.pull, pulling)}</div><div class="meta">${chip}</div></div>`;
105
138
  }
106
- // The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, and reads "Merging…"
107
- // while it is sent or waits on the session (docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md).
108
- function mergeButton(it, sending = false) {
139
+ // The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, waiting while the
140
+ // server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it
141
+ // (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md).
142
+ function mergeButton(it) {
109
143
  if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
110
- const merging = sending || it.merging;
111
- return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${merging ? ' disabled' : ''}>${merging ? 'Merging…' : 'Merge'}</button>`;
112
- }
113
- const builderLine = (b) => [b.progress || 'Building', b.at && ago(b.at)].filter(Boolean).join(' · ');
114
- const stalledChip = (it) => it.lane !== 'done' && it.builder?.stalled ? badge('danger', 'Stalled', {
115
- button: true, title: 'No recent progress. Nudge Claude to check this builder',
116
- attrs: { type: 'button', 'data-confirm': 'nudge', 'data-key': it.key },
117
- }) : '';
144
+ return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${it.merging ? BoardUI.BUSY : ''}>Merge</button>`;
145
+ }
146
+ // The open card's Implement, rightmost in its action row, on every unstarted Todo work card, its questions waiting or
147
+ // not: it confirms, then
148
+ // sends card-build, waiting while the board server starts the implementer (app-cards.js). Build and Serve
149
+ // mean the package only (docs/adr/2026-11-02-1745-a-todo-card-that-nothing-blocks-has-build.md,
150
+ // docs/adr/2026-11-03-0219-a-build-tap-starts-the-build-in-the-board-server.md, docs/adr/2026-11-03-1549-a-todo-cards-button-reads-implement.md).
151
+ // Implement is plain until the card is ready, then builder orange (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
152
+ function buildButton(it) {
153
+ if (it.kind !== 'work' || it.lane !== 'todo' || it.queued) return '';
154
+ return `<button class="build${it.ready ? ' ready' : ''}" data-confirm="card-build" data-key="${esc(it.key)}">Implement</button>`;
155
+ }
156
+ // A builder reads "preheating" until its first status line, from the Implement tap or the session's dispatch on.
157
+ const builderLine = (b) => [b.progress || 'preheating', b.at && ago(b.at)].filter(Boolean).join(' · ');
158
+ // Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
159
+ // store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
160
+ // docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
161
+ const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
162
+ const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
163
+ const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
164
+ // What a card's bars show, or null off In Progress and on a card only a planner works on: how many phases it reached,
165
+ // whether the last of them is running, whether it dims because the card waits on something other than a builder (its
166
+ // questions, a blocker or queue, a reopening) or its build is stale, and its label. An install is no build: its card keeps the phase its
167
+ // build reached, undimmed
168
+ // (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
169
+ function phaseStrip(it) {
170
+ if (it.lane !== 'progress' || it.builder?.planner) return null;
171
+ const all = BUILD_PHASES.length;
172
+ if (it.waitsOn === 'checking') return { reached: all, running: false, dim: false, label: `Orchestrator review, ${ALL_DONE}` };
173
+ const asking = it.action?.kind === ACTION_KIND.questions || it.action?.kind === ACTION_KIND.pick;
174
+ const answers = asking ? ', waiting on your answers' : '';
175
+ const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
176
+ if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
177
+ const reached = BUILD_PHASES.indexOf(it.builder.phase) + 1;
178
+ return { reached, running: reached > 0, dim: asking || Boolean(it.builder.stalled), label: `${reached ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Preheating, no step yet'}${answers}` };
179
+ }
180
+ const dim = !it.builder || asking;
181
+ if (it.reopened) return { reached: all, running: false, dim, label: `Reopened, ${ALL_DONE}${answers}` };
182
+ const reached = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
183
+ const label = reached ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : it.queued ? 'Queued, no step yet' : 'No step yet';
184
+ return { reached, running: false, dim, label: `${label}${answers}` };
185
+ }
186
+ // What the bars do when a card's reached count goes from prev to next across redraws, null or undefined meaning no
187
+ // strip (docs/adr/2026-11-03-1850-the-phase-bars-animate-as-a-build-starts-advances-and-ends.md).
188
+ function phaseMotion(prev, next) {
189
+ const had = typeof prev === 'number';
190
+ const has = typeof next === 'number';
191
+ if (had !== has) return has ? 'enter' : 'leave';
192
+ return has && next > prev ? 'fill' : null;
193
+ }
194
+ const phaseBars = (strip) => bars(strip, BUILD_PHASES);
195
+ // A stale build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
196
+ function builderStatus(it) {
197
+ const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}`;
198
+ const words = `<span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}`;
199
+ if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
200
+ return `<button type="button" class="${classes} stalled" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
201
+ }
118
202
  const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
119
- const unreadChip = (n) => {
120
- if (!n) return '';
121
- const label = `${n} unread message${n === 1 ? '' : 's'}`;
122
- return `<span class="chip unread" title="${label}" aria-label="${label}">${n} new</span>`;
123
- };
203
+ // When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
204
+ // (docs/adr/2026-11-02-1727-a-card-can-be-held-until-a-time.md).
205
+ function heldUntilWords(iso, now = new Date()) {
206
+ const d = new Date(iso);
207
+ const midnight = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
208
+ const days = Math.round((midnight(d) - midnight(now)) / 86400000);
209
+ if (days === 0) return clock(d);
210
+ if (days > 0 && days < 7) return `${d.toLocaleDateString([], { weekday: 'short' })} ${clock(d)}`;
211
+ return `${d.toLocaleDateString([], { month: 'numeric', day: 'numeric' })}, ${clock(d)}`;
212
+ }
213
+ function holdWords(it) {
214
+ if (!it.held) return '';
215
+ const d = it.heldUntil && new Date(it.heldUntil);
216
+ const title = !d ? 'Held until you unhold it.'
217
+ : `Held until ${d.toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' })}, ${clock(d)}. ${it.lane === 'todo' ? 'It starts on its own then, once nothing else blocks it.' : 'Claude picks it up again then.'}`;
218
+ return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
219
+ }
220
+ // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
221
+ // or ready, then an In Progress card's phase bars, then its new questions and unread messages, questions first, which app-cards.js
222
+ // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
223
+ // 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).
224
+ const SEP = '<span class="sep"> · </span>';
225
+ const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
226
+ function timeLine(it, unread, questions, bars) {
227
+ const iso = it.sortAt;
228
+ const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.ready && '<span class="card-ready">ready</span>'].filter(Boolean);
229
+ const news = [newWords(questions, 'question'), newWords(unread, 'message')].filter(Boolean).join(' · ');
230
+ const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
231
+ const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
232
+ if (!parts.length && !count && !tail) return '';
233
+ return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
234
+ }
235
+ // The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
236
+ // Keep awake, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
237
+ // durationPicker's markup also uses. Each chip's length (ms, null for Forever) is worded as when it would end if picked
238
+ // at `now`, here rather than on the server, so the board's version doesn't change with the clock
239
+ // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
240
+ const holdUntil = (c, now) => (c.ms === null ? 'until you unhold it' : `until ${heldUntilWords(new Date(now.getTime() + c.ms), now)}`);
241
+ function holdPicker(key, choices, now = new Date()) {
242
+ const chips = choices.map((c) => `<button type="button" class="btn quiet" data-hold-for="${esc(key)}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${holdUntil(c, now)}`)}">${esc(c.label)}</button>`).join('');
243
+ return `<div class="hub-view"><div class="dur-chips" role="group" aria-labelledby="holdTitle"><div class="dur-choices">${chips}</div>`
244
+ + `<div class="dur-custom"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-hold-at="${esc(key)}" required aria-label="Custom hold until a date and time"></div></div></div>`;
245
+ }
124
246
  // A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
125
- // docs/adr/0047-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
247
+ // docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
126
248
  const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
127
- const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, top)}</div>` : '');
128
- const ASK_STATE = { waiting: 'Waiting', working: `<span class="working">${workingLine}</span>` };
129
- const askLine = (ask) => `${ask.from ? `Moved from ${esc(ask.from)} · ` : ''}${esc(time(ask.askedAt))} · ${ask.edited ? 'Edited · ' : ''}${ASK_STATE[ask.state] || ''}`;
130
- function workLines(it, { unread = 0, top = false } = {}) {
131
- const ids = it.kind === 'ask' ? askChip(it) : 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);
132
- const blocked = (it.blockedBy || []).map((d) => badge('notice', `blocked by ${esc(d.ref)}`, { href: `#item=${encodeURIComponent(d.key)}` })).join('');
133
- const ready = !blocked && !it.action && it.deps?.length && it.lane === 'todo' ? badge('ready', 'ready to start') : '';
134
- const chips = `${it.todoAge ? badge('quiet', esc(it.todoAge)) : ''}${stalledChip(it)}${unreadChip(unread)}${actionChip(it)}${it.kind === 'followup' ? badge('quiet', 'follow-up') : ''}${prChips(it)}${reviewChips(it)}${it.held ? badge('quiet', 'held') : ''}${it.risky ? badge('notice', 'risky') : ''}${it.bumpedAt ? badge('normal', 'bumped') : ''}${ready}${installChip(it)}`;
135
- const status = it.isNew && !it.action ? `<div class="line3 sub">${askLine(it.ask)}</div>`
249
+ // A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
250
+ // card-done (app-cards.js; docs/adr/2026-11-04-0158-a-card-whose-code-landed-stays-in-review-while-an-owner-step-remains.md).
251
+ const CHECK = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5"/></svg>';
252
+ const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
253
+ const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
254
+ : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
255
+ const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && workingLine].filter(Boolean).join(' · ');
256
+ // 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
257
+ // its bump with the band's ⋯ menu.
258
+ function workLines(it, { unread = 0, newQuestions = 0, top = false, step = 0, actions = null } = {}) {
259
+ 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);
260
+ // A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
261
+ const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
262
+ const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${chip}` : chip;
263
+ const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
264
+ const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
136
265
  : it.waitsOn === 'checking' ? '<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>Orchestrator review</div>'
137
- : it.builder ? `<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}</div>`
266
+ : it.builder ? builderStatus(it)
267
+ : it.grilling || it.prototyping ? `<div class="line3 sub agent planner"><span class="working-dot" aria-hidden="true"></span>${it.grilling ? 'preheating' : 'prototyping'}</div>`
268
+ : it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
138
269
  : it.working ? `<div class="line3 sub agent">${workingLine}</div>`
139
- : it.next ? `<div class="line3 sub next">Next: ${esc(it.next)}</div>` : '';
270
+ : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held || it.queued ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
140
271
  const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
141
- return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
272
+ return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
142
273
  ${status}${eta}
143
274
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
144
- ${blocked ? `<div class="line5 meta blocked">${blocked}</div>` : ''}`;
275
+ ${timeLine(it, unread, newQuestions, phaseBars(phaseStrip(it)))}`;
145
276
  }
146
- const askChip = (it) => badge('normal', it.isNew ? 'New' : 'Task');
277
+ const WAVE_ICON = '<svg class="wave-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/><path d="M2 16c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/></svg>';
278
+ const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
147
279
  function planButton(items) {
148
- const keys = items.filter((i) => i.kind === 'followup').map((i) => i.key);
149
- return keys.length > 1 ? `<button class="link" data-plan="${esc(keys.join(' '))}">Plan a wave from these ${keys.length} follow-ups →</button>` : '';
280
+ const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blockedBy?.length).map((i) => i.key);
281
+ return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
282
+ }
283
+
284
+ // docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md
285
+ // The Auto Review lane, its needs you in the sidebar's needs-you red: one card per incoming PR (board/auto-review.mjs reviewsOf), needs you, then reviewing, then
286
+ // posted. A tap opens the review text and the PR's links, and a review held for the owner offers Post it and Skip,
287
+ // whose tap waits while the server records it (app-lanes.js). Older posted reviews are one line each under Posted earlier.
288
+ const REVIEW_WORDS = { needs: 'needs you', reviewing: 'reviewing', posted: 'posted' };
289
+ const REVIEW_DOTS = { needs: 'rv-needs', reviewing: 'progress', posted: 'done' };
290
+ function reviewDetail(c) {
291
+ const text = c.text ? `<div class="rv-review">${c.state === 'needs' ? '<p class="muted small">Draft, not posted.</p>' : ''}${BoardUI.markdown(c.text)}</div>`
292
+ : '<p class="muted small">Nothing posted yet. Right before posting, Claude reads the PR’s reviews again and adds only new points.</p>';
293
+ const links = `<div class="rv-links"><a href="${esc(c.url)}" target="_blank" rel="noopener">PR #${c.number} on GitHub ${LINK_ARROW}</a>${c.reviewUrl ? `<a href="${esc(c.reviewUrl)}" target="_blank" rel="noopener">The review on GitHub ${LINK_ARROW}</a>` : ''}</div>`;
294
+ const acts = c.state === 'needs' ? `<div class="rv-acts"><button type="button" data-review-answer="post" data-review="${c.number}">Post it</button><button type="button" class="ghost" data-review-answer="skip" data-review="${c.number}">Skip</button></div>` : '';
295
+ return `<div class="rv-detail">${text}${links}${acts}</div>`;
296
+ }
297
+ const reviewChipsOf = (c) => [badge('quiet', `from ${esc(c.author)}`), ...c.touches.map((t) => badge(c.state === 'needs' ? 'danger' : 'quiet', `touches ${esc(t.ref)}`)), c.asked ? badge('normal', 'asked you') : ''].join(' ');
298
+ const reviewWhen = (c) => `${c.state === 'reviewing' ? 'started ' : ''}${ago(c.at)}`;
299
+ function reviewCard(c, open) {
300
+ return `<details class="rv-card" data-review="${c.number}"${open ? ' open' : ''}><summary><span class="status ${REVIEW_DOTS[c.state]}"></span><div class="title-wrap">
301
+ <div class="line1 title">${badge('normal', `PR #${c.number}`)} ${esc(c.title)}</div>
302
+ <div class="line3 sub${c.state === 'reviewing' ? ' agent' : ''}">${c.state === 'reviewing' ? '<span class="working-dot" aria-hidden="true"></span>' : ''}<span class="rv-word s-${c.state}">${REVIEW_WORDS[c.state]}</span> · ${esc(reviewWhen(c))}</div>
303
+ <div class="line2 meta">${reviewChipsOf(c)}</div></div></summary>${reviewDetail(c)}</details>`;
304
+ }
305
+ const reviewLine = (c, open) => `<details class="rv-line" data-review="${c.number}"${open ? ' open' : ''}><summary><div class="title">${badge('normal', `PR #${c.number}`)} ${esc(c.title)}</div>
306
+ <div class="sub">${esc([c.author, c.state === 'posted' ? `posted ${ago(c.at)}` : 'not posted', c.open ? '' : 'closed'].filter(Boolean).join(' · '))}</div></summary>${reviewDetail(c)}</details>`;
307
+ // reviews: the built board's reviews; open: {lane, earlier, cards: Set of open PR numbers}.
308
+ function reviewLane(reviews, { open = {} } = {}) {
309
+ if (!reviews?.shown) return '';
310
+ const { cards, earlier } = reviews;
311
+ const needs = cards.filter((c) => c.state === 'needs').length;
312
+ const shown = (n) => open.cards?.has(n);
313
+ const fold = earlier.length ? `<details class="fold" data-fold="reviews-earlier"${open.earlier ? ' open' : ''}><summary><span class="lane-name">Posted earlier</span><span class="count">${earlier.length}</span></summary>
314
+ <div class="fold-list">${earlier.map((c) => reviewLine(c, shown(c.number))).join('')}</div></details>` : '';
315
+ const waiting = reviews.waiting ? `${reviews.waiting} incoming PR${reviews.waiting === 1 ? '' : 's'} wait${reviews.waiting === 1 ? 's' : ''} until the board is idle.`
316
+ : `No incoming PR touches this board’s work yet.${reviews.at ? ` Checked ${esc(reviews.repo)} ${ago(reviews.at)}.` : ''}`;
317
+ const list = cards.length ? cards.map((c) => reviewCard(c, shown(c.number))).join('') : `<div class="empty">${waiting}</div>`;
318
+ return `<details class="lane" data-lane="reviews"${open.lane === false ? '' : ' open'}><summary><span class="lane-name">Auto Review</span><span class="count">${cards.length}</span>${needs ? `<span class="lane-github"><span class="rv-late">${needs} needs you</span></span>` : ''}</summary>
319
+ <div class="list">${list}${fold}</div></details>`;
150
320
  }
151
321
 
152
- Object.assign(globalThis.BoardUI, { prChips, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionChip, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, unreadCount, workLines, askChip, askActions, askLine, planButton });
322
+ Object.assign(globalThis.BoardUI, { prChips, prName, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, buildButton, builderLine, BUILD_PHASES, phaseStrip, phaseMotion, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON, reviewLane });
153
323
  })();
@@ -1,10 +1,11 @@
1
1
  // A card's button names, which board/domain.mjs sets as its actionButton and resolvedBy, the kinds of its owner
2
- // action, and the builder kinds the page reads. Each value is its name, since cards carry it in the board's JSON.
3
- // Needs nothing else: board/domain.mjs runs
4
- // this file in a vm context (docs/adr/0059-an-unstarted-card-waits-on-its-questions-in-todo.md).
2
+ // action, the builder kinds the page reads, and the state of a card's PR, which domain.mjs sets as pr.status and its PR
3
+ // badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
4
+ // since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
5
5
  (function () {
6
6
  const BUTTON = Object.freeze({ grill: 'grill', openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
7
7
  const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done' });
8
8
  const BUILDER_KIND = Object.freeze({ install: 'install' });
9
- Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND });
9
+ const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
10
+ Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE });
10
11
  })();
@@ -2,36 +2,45 @@
2
2
  (function () {
3
3
  const { since, esc } = globalThis.BoardUI;
4
4
  // The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh), and watcher is
5
- // /api/board's checks.watcher (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md). link is 'online',
5
+ // /api/board's checks.watcher (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md): a grey asleep while
6
+ // the hub can wake the session, waking… once a wake is under way (waking: the page asked for one and the board hasn't
7
+ // said so yet), and a red not listening when nothing will wake it
8
+ // (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md). link is 'online',
6
9
  // 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it).
7
- const statusLine = (at, { link = 'online', watcher, pause } = {}) => (pause?.state === 'paused' ? `<span class="late">Paused until ${esc(BoardUI.usageClock(pause.resets_at))}</span>`
10
+ const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => (pause?.state === 'paused' ? `<span class="late">paused until ${esc(BoardUI.usageClock(pause.resets_at))}</span>`
8
11
  : link === 'offline' ? `<span class="link-state offline" title="Offline: can't reach the host">${at ? shortSince(at) : 'offline'}</span>`
9
- : link === 'reconnecting' ? '<span class="link-state">Reconnecting…</span>'
10
- : watcher === 'stopped' ? '<span class="claude late" title="Claude not listening">not listening</span>'
12
+ : link === 'reconnecting' ? '<span class="link-state">reconnecting…</span>'
13
+ : watcher === 'waking' || (waking && watcher === 'asleep') ? '<span class="claude asleep" title="Waking: the hub is resuming its session">waking…</span>'
14
+ : watcher === 'asleep' ? '<span class="claude asleep" title="Asleep: its session idled out. Refresh, or any tap on the page, wakes it">asleep</span>'
15
+ : watcher === 'stopped' ? '<span class="claude down" title="Claude not listening">not listening</span>'
11
16
  : at ? `<span class="fresh" title="Updated ${since(at)}: the last change from the host, or your last refresh">${shortSince(at)}</span>` : '');
12
17
  // What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
13
18
  const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
14
19
  // fresh is {at, sig}: the header's time and what the page showed then. via is 'refresh' (a ↻ tap), 'signal' (the
15
20
  // stream's change signal) or 'poll' (any other load). A manual refresh always moves the time, a change signal only when it brought
16
21
  // something the time hasn't counted, a poll never, and any load moves it while none has landed (sig null)
17
- // (docs/adr/0031-the-board-reloads-on-a-change-signal.md).
22
+ // (docs/adr/2026-10-29-1639-the-board-reloads-on-a-change-signal.md).
18
23
  const nextFresh = (fresh, { via, at, sig }) => (!fresh?.sig || via === 'refresh' || (via === 'signal' && sig !== fresh.sig) ? { at, sig } : fresh);
19
24
  const shortSince = (iso) => {
20
25
  const s = (Date.now() - Date.parse(iso)) / 1000;
21
26
  return s < 60 ? 'now' : s < 3600 ? Math.round(s / 60) + ' min' : s < 86400 ? Math.round(s / 3600) + ' h' : Math.round(s / 86400) + ' d';
22
27
  };
23
- // GitHub's logo guidelines forbid altering the mark, so it stays filled rather than a line icon (docs/adr/0013-icons-are-sf-symbol-style-line-icons.md).
28
+ // GitHub's logo guidelines forbid altering the mark, so it stays filled rather than a line icon (docs/adr/2026-10-29-1639-icons-are-sf-symbol-style-line-icons.md).
24
29
  const GITHUB_MARK = '<svg class="gh-mark" width="13" height="13" viewBox="0 0 16 16" fill="currentColor" role="img" aria-label="GitHub"><title>GitHub</title><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
25
30
  // The GitHub mark opens the Hub links sheet, with a red dot while any link is broken past its grace period
26
- // (docs/adr/0046-hub-links-turn-red-only-after-a-grace-period.md). links is /api/board's (hub/boards.mjs linksOf), null
31
+ // (docs/adr/2026-10-29-1639-hub-links-turn-red-only-after-a-grace-period.md). links is /api/board's (hub/boards.mjs linksOf), null
27
32
  // when the hub doesn't list this board. A link without state, from a board server still running older code, reads from ok.
28
33
  const linkState = (l) => l.state || (l.ok ? 'ok' : 'broken');
29
34
  const brokenLinks = (links) => (links || []).filter((l) => linkState(l) === 'broken');
30
- const linksMark = (links) => {
35
+ const linksMark = (links, mark = GITHUB_MARK) => {
31
36
  const broken = brokenLinks(links).length;
32
37
  const label = `Hub links${broken ? `: ${broken} not working` : ''}`;
33
- return `<button class="ghost gh-links" type="button" data-hub-links aria-haspopup="dialog" aria-label="${label}" title="${label}">${GITHUB_MARK}${broken ? '<span class="links-dot" aria-hidden="true"></span>' : ''}</button>`;
38
+ return `<button class="ghost gh-links" type="button" data-hub-links aria-haspopup="dialog" aria-label="${label}" title="${label}">${mark}${broken ? '<span class="links-dot" aria-hidden="true"></span>' : ''}</button>`;
34
39
  };
40
+ // A board on a plain git remote has no GitHub checks, so the In Review header names its repo, host/path, instead
41
+ // (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
42
+ const REMOTE_MARK = '<svg class="gh-mark" 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="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
43
+ const remoteLine = (remote, { links = null } = {}) => `<span class="github remote" title="This board’s repo">${linksMark(links, REMOTE_MARK)} <span class="remote-name">${esc(remote.name)}</span></span>`;
35
44
  // The In Review header's right end; pending is whether a check-github request is waiting.
36
45
  const githubLine = (checks, { pending = false, links = null } = {}) => {
37
46
  const mark = linksMark(links);
@@ -42,9 +51,9 @@
42
51
  return `<span class="github${checks.late && !checks.idle ? ' late' : ''}${checks.idle ? ' idle' : ''}" title="${title}">${mark} ${shortSince(checks.at)}${note}</span>`;
43
52
  };
44
53
 
45
- const LINK_NAMES = { github: 'GitHub polling', events: 'Events', wakes: 'Daily wakes', push: 'Push notifications', slack: 'Reviewer links' };
54
+ const LINK_NAMES = { github: 'GitHub polling', 'repo-fetch': 'Repo fetch', events: 'Events', wakes: 'Daily wakes', slack: 'Reviewer links' };
46
55
  // The Hub links sheet's list, one row per link with a green dot, a muted one while settling, or a red one
47
- // (docs/adr/0040-one-board-registry-for-every-hub-feature.md).
56
+ // (docs/adr/2026-10-29-1639-one-board-registry-for-every-hub-feature.md).
48
57
  const LINK_DOTS = { ok: ['pass', 'Working'], settling: ['', 'Settling'], broken: ['fail', 'Not working'] };
49
58
  const linkRow = (l) => {
50
59
  const st = linkState(l);
@@ -63,7 +72,7 @@
63
72
  const step = (ok, done, waiting) => `<li><span class="dot ${ok ? 'pass' : 'pending'}" aria-hidden="true"></span>${ok ? done : waiting}</li>`;
64
73
  return `<h2>Connecting this board</h2><ul class="setup-steps">
65
74
  ${step(setup.events, 'Claude answered the hello', 'Waiting for Claude to answer the hello')}
66
- ${step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
75
+ ${setup.github === undefined ? '' : step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
67
76
  </ul>`;
68
77
  };
69
78
  const checkLanded = (checks, pendingSince) => Boolean(checks?.at) && Date.parse(checks.at) >= pendingSince;
@@ -74,11 +83,11 @@
74
83
 
75
84
  // Which header controls take taps, and whether refresh spins. The header paints them disabled with refresh spinning;
76
85
  // both turn on together once the first board load has answered or failed.
77
- // Offline, refresh stays on so a tap can retry the load (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md).
86
+ // Offline, refresh stays on so a tap can retry the load (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md).
78
87
  const headerControls = ({ link = 'online', wired = false, boardSettled = false, reloading = false } = {}) => {
79
88
  const ready = wired && boardSettled && !reloading;
80
89
  return { refresh: ready, settings: ready && link === 'online', spin: !boardSettled || reloading };
81
90
  };
82
91
 
83
- Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
92
+ Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, remoteLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
84
93
  })();