@lovek4d/kickflip 0.3.1 → 0.5.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 (111) hide show
  1. package/CONTEXT.md +170 -118
  2. package/README.md +23 -14
  3. package/SKILL.md +40 -38
  4. package/bin/doctor.mjs +4 -4
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +34 -26
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/arch-review.mjs +1 -1
  9. package/board/auto-review.mjs +2 -0
  10. package/board/bin/board.mjs +213 -74
  11. package/board/domain.mjs +8 -5
  12. package/board/finish.mjs +4 -0
  13. package/board/headless.mjs +4 -0
  14. package/board/merge3.mjs +1 -0
  15. package/board/on-host.mjs +1 -1
  16. package/board/permissions.mjs +2 -0
  17. package/board/public/app-arch.js +2 -2
  18. package/board/public/app-asks.js +39 -103
  19. package/board/public/app-banner.js +2 -2
  20. package/board/public/app-cards.js +166 -73
  21. package/board/public/app-core.js +2 -23
  22. package/board/public/app-header.js +12 -5
  23. package/board/public/app-lanes.js +201 -47
  24. package/board/public/app-needs.js +2 -1
  25. package/board/public/app-reader.js +174 -0
  26. package/board/public/app-snapshot.js +2 -2
  27. package/board/public/app-threads.js +53 -42
  28. package/board/public/app.html +4 -1
  29. package/board/public/app.js +78 -96
  30. package/board/public/attach.js +39 -3
  31. package/board/public/dialogs.js +59 -14
  32. package/board/public/hub-kit.js +9 -6
  33. package/board/public/hub.css +6 -18
  34. package/board/public/linear-key.js +6 -4
  35. package/board/public/log.html +48 -0
  36. package/board/public/new-task.js +121 -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 +8 -7
  42. package/board/public/rules-editor.js +3 -4
  43. package/board/public/shell.js +24 -6
  44. package/board/public/sidebar.css +22 -1
  45. package/board/public/sidebar.js +64 -31
  46. package/board/public/style.css +245 -64
  47. package/board/public/toasts.js +2 -2
  48. package/board/public/ui-cards.js +205 -75
  49. package/board/public/ui-enums.js +6 -5
  50. package/board/public/ui-header.js +10 -10
  51. package/board/public/ui-hub.js +4 -4
  52. package/board/public/ui-markdown.js +1 -1
  53. package/board/public/ui-order.js +9 -6
  54. package/board/public/ui-page-header.js +10 -7
  55. package/board/public/ui-patch.js +53 -0
  56. package/board/public/ui-phase-bars.js +17 -0
  57. package/board/public/ui-prompts.js +8 -8
  58. package/board/public/ui-questions.js +61 -29
  59. package/board/public/ui-sidebar.js +54 -24
  60. package/board/public/ui-snapshot.js +20 -9
  61. package/board/public/ui-threads.js +40 -21
  62. package/board/public/ui.js +116 -18
  63. package/board/public/view-frame.js +2 -1
  64. package/board/public/view-hub-settings.js +25 -18
  65. package/board/public/view-hub.js +53 -12
  66. package/board/public/view-notes.js +1 -1
  67. package/board/public/view-questions.js +142 -62
  68. package/board/public/view-settings.js +189 -52
  69. package/board/review.mjs +1 -1
  70. package/board/server.mjs +20 -12
  71. package/board/store.mjs +15 -12
  72. package/board/tickets.mjs +3 -3
  73. package/board/worktree.mjs +21 -15
  74. package/docs/hub-session.md +4 -4
  75. package/hooks-plugin/hooks/codex-share-auto.mjs +1 -1
  76. package/hooks-plugin/hooks/watcher-stop.mjs +2 -2
  77. package/hub/board-repo.mjs +1 -1
  78. package/hub/boards.mjs +4 -4
  79. package/hub/close-board.mjs +1 -1
  80. package/hub/front-door.mjs +20 -0
  81. package/hub/github.mjs +4 -4
  82. package/hub/home.mjs +1 -1
  83. package/hub/hub.mjs +11 -14
  84. package/hub/incoming.mjs +2 -0
  85. package/hub/linear.mjs +2 -2
  86. package/hub/live.mjs +2 -2
  87. package/hub/new-board.mjs +2 -2
  88. package/hub/owner-checks.mjs +1 -0
  89. package/hub/permission-denied-hook.mjs +2 -0
  90. package/hub/pr-report.mjs +3 -1
  91. package/hub/presence.mjs +1 -1
  92. package/hub/prompts.mjs +1 -1
  93. package/hub/push.mjs +2 -2
  94. package/hub/readiness.mjs +2 -2
  95. package/hub/repos.mjs +3 -3
  96. package/hub/restart-board.mjs +1 -1
  97. package/hub/schedule.mjs +1 -1
  98. package/hub/sessions.mjs +2 -2
  99. package/hub/sw.js +1 -1
  100. package/hub/try.mjs +8 -4
  101. package/hub/usage.mjs +3 -3
  102. package/hub/views.mjs +1 -1
  103. package/package.json +5 -1
  104. package/shared/card-prs.mjs +1 -0
  105. package/shared/colors.css +5 -4
  106. package/shared/fake-gh.mjs +59 -8
  107. package/shared/owner-check.mjs +3 -0
  108. package/shared/repos.mjs +1 -1
  109. package/shared/tailscale.mjs +1 -0
  110. package/shared/usage-gate.mjs +1 -1
  111. package/board/public/view-polling.js +0 -87
@@ -1,20 +1,30 @@
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, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, LINK_ARROW } = globalThis.BoardUI;
3
+ const { esc, ago, lasted, 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: 'quiet', 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
29
  const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
20
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 });
@@ -38,9 +48,9 @@
38
48
  }).join('');
39
49
  }
40
50
  const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
41
- // docs/adr/0045-the-lane-follows-who-acts-next.md
51
+ // docs/adr/2026-10-29-1639-the-lane-follows-who-acts-next.md
42
52
  // Action needed, last of the time line's orange words, linking to what the owner acts on
43
- // (docs/adr/0145-build-on-a-blocked-card-queues-it.md).
53
+ // (docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
44
54
  function actionWords(it) {
45
55
  if (!it.action) return '';
46
56
  const external = it.action.kind === ACTION_KIND.ready ? Object.entries(EXTERNAL).map(([k, v]) => ` ${k}="${esc(v)}"`).join('') : '';
@@ -52,16 +62,16 @@
52
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>');
53
63
  }
54
64
  const tryWords = (kind) => kind === 'app' ? {
55
- go: 'Build', going: 'Building…', done: 'Install', failed: 'Build failed',
56
- current: 'Nothing new to build: Install already has this card’s latest push.',
57
- busy: 'Building now. The badge turns back into Install when the new build is ready.',
58
- 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.',
59
- saved: (on) => on ? 'Saved. Each push builds its card.' : 'Saved. Cards wait for your Build.',
65
+ go: 'Serve', going: 'serving…', done: 'Install', failed: 'serve failed',
66
+ current: 'Nothing new to serve: Install already has this card’s latest push.',
67
+ busy: 'Serving now. The badge turns back into Install when the new build is ready.',
68
+ autoSub: 'The hub serves each card after each push, and main after each merge.',
69
+ saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
60
70
  } : {
61
- go: 'Serve', going: 'Serving…', done: 'Visit', failed: 'Serve failed',
71
+ go: 'Serve', going: 'serving…', done: 'Visit', failed: 'serve failed',
62
72
  current: 'Nothing new to serve: Visit already shows this card’s latest push.',
63
73
  busy: 'Serving now. The badge turns back into Visit when the new server is up.',
64
- 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.',
65
75
  saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
66
76
  };
67
77
  const spinner = '<span class="try-spin" aria-hidden="true"></span>';
@@ -96,7 +106,7 @@
96
106
  return `<${tag} class="install tone-${s.tone}"${attrs}>${s.html}</${tag}>`;
97
107
  }
98
108
  // A card's Serve / Build, only while the board's Auto-build is off and its branch has changes
99
- // (docs/adr/0124-only-the-owner-or-auto-build-starts-a-card-run.md).
109
+ // (docs/adr/2026-11-02-1806-only-the-owner-or-auto-build-starts-a-card-run.md).
100
110
  function tryButton(it) {
101
111
  const t = tryOf(it);
102
112
  if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
@@ -105,49 +115,114 @@
105
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>`;
106
116
  }
107
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>';
108
- 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) {
109
131
  if (!main) return '';
110
132
  const words = tryWords(main.kind);
111
133
  const url = tryURL(main);
112
134
  const chip = main.state === 'running' ? badge('notice', spinner + esc(words.going), { attrs: { role: 'status' } })
113
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.' })
114
136
  : main.state === 'ready' && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
115
- 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>`;
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>`;
116
138
  }
117
- // The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, and reads "Merging…"
118
- // while it is sent or waits on the session (docs/adr/0052-auto-merge-is-a-board-setting-off-by-default.md).
119
- 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, or the board server lands a
141
+ // card with no PR, and from the tap until a board shows the card left In Review or its merge refused (ui.js pending) (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md,
142
+ // docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md).
143
+ function mergeButton(it) {
120
144
  if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
121
- const merging = sending || it.merging;
122
- return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${merging ? ' disabled' : ''}>${merging ? 'Merging…' : 'Merge'}</button>`;
123
- }
124
- // The open card's Implement, rightmost in its action row, on a card board/domain.mjs marks buildable: it confirms, then
125
- // sends card-build, and reads "Starting…" while it is sent and the board server starts the implementer. Build and Serve
126
- // mean the package only (docs/adr/0122-a-todo-card-that-nothing-blocks-has-build.md,
127
- // docs/adr/0139-a-build-tap-starts-the-build-in-the-board-server.md, docs/adr/0166-a-todo-cards-button-reads-implement.md).
128
- function buildButton(it, sending = false) {
129
- if (!it.buildable) return '';
130
- return `<button class="build" data-confirm="card-build" data-key="${esc(it.key)}"${sending ? ' disabled' : ''}>${sending ? 'Starting…' : 'Implement'}</button>`;
131
- }
132
- // A builder the Implement tap started reads "Waiting for orchestrator" until the session's builder posts its first line.
133
- const builderLine = (b) => [b.progress || (b.waiting ? 'Waiting for orchestrator' : 'Starting'), b.at && ago(b.at)].filter(Boolean).join(' · ');
134
- // Five bars at the end of the time row while a build runs on an In Progress card: done phases filled, the current one
135
- // marked. The phases are store.mjs PHASES (docs/adr/0137-a-builder-status-line-is-a-phase-and-a-short-detail.md,
136
- // docs/adr/0160-a-building-cards-phase-bars-end-its-time-row.md).
145
+ const busy = it.merging || BoardUI.pending.has(`${it.key}:merge`);
146
+ return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${busy ? BoardUI.BUSY : ''}>Merge</button>`;
147
+ }
148
+ // The open card's Implement, rightmost in its action row, on every unstarted Todo work card, its questions waiting or
149
+ // not: it confirms, then
150
+ // sends card-build, waiting while the board server starts the implementer (app-cards.js). Build and Serve
151
+ // mean the package only (docs/adr/2026-11-02-1745-a-todo-card-that-nothing-blocks-has-build.md,
152
+ // 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).
153
+ // 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).
154
+ function buildButton(it) {
155
+ if (it.kind !== 'work' || it.lane !== 'todo' || it.queued) return '';
156
+ return `<button class="build${it.ready ? ' ready' : ''}" data-confirm="card-build" data-key="${esc(it.key)}">Implement</button>`;
157
+ }
158
+ // A builder reads "preheating" until its first status line, from the Implement tap or the session's dispatch on, and
159
+ // ends with how long ago that line was set.
160
+ const builderLine = (b) => [b.progress || 'preheating', b.at && lasted(b.at)].filter(Boolean).join(' · ');
161
+ // Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
162
+ // store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
163
+ // docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
137
164
  const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
138
- function phaseBars(phase) {
139
- const at = BUILD_PHASES.indexOf(phase);
140
- if (at < 0) return '';
141
- const name = `${phase[0].toUpperCase()}${phase.slice(1)}`;
142
- return `<span class="phase-bars" role="img" aria-label="${name}, step ${at + 1} of ${BUILD_PHASES.length}">${BUILD_PHASES.map((_, i) => `<i class="${i < at ? 'done' : i === at ? 'now' : ''}"></i>`).join('')}</span>`;
143
- }
144
- const stalledChip = (it) => it.lane !== 'done' && it.builder?.stalled ? badge('danger', 'Stalled', {
145
- button: true, title: 'No recent progress. Nudge Claude to check this implementer',
146
- attrs: { type: 'button', 'data-confirm': 'nudge', 'data-key': it.key },
147
- }) : '';
165
+ const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
166
+ const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
167
+ // 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,
168
+ // whether the last of them is running, whether it dims because the card waits on something other than a builder (its
169
+ // 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
170
+ // build reached, undimmed
171
+ // (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
172
+ // A Todo card a grill or prototype runs on draws the planner's dashes instead, in blue, the grill's first and the
173
+ // prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md).
174
+ function phaseStrip(it) {
175
+ if (it.lane === 'todo' && it.builder?.planner) {
176
+ const prototype = it.builder.kind === 'prototype';
177
+ return { reached: prototype ? 2 : 1, running: true, dim: false, plan: true, label: prototype ? 'Prototyping' : 'Grilling' };
178
+ }
179
+ if (it.lane !== 'progress' || it.builder?.planner) return null;
180
+ const all = BUILD_PHASES.length;
181
+ // Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
182
+ if (it.waitsOn === 'checking') return { reached: BUILD_PHASES.indexOf('checking') + 1, running: true, dim: false, label: `Orchestrator review, ${stepWords('checking')}` };
183
+ const asking = it.action?.kind === ACTION_KIND.questions || it.action?.kind === ACTION_KIND.pick;
184
+ const answers = asking ? ', waiting on your answers' : '';
185
+ const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
186
+ if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
187
+ const reached = BUILD_PHASES.indexOf(it.builder.phase) + 1;
188
+ 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}` };
189
+ }
190
+ const dim = !it.builder || asking;
191
+ if (it.reopened) return { reached: all, running: false, dim, label: `Reopened, ${ALL_DONE}${answers}` };
192
+ const reached = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
193
+ const label = reached ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : it.queued ? 'Queued, no step yet' : 'No step yet';
194
+ return { reached, running: false, dim, label: `${label}${answers}` };
195
+ }
196
+ // What the bars do when a card's reached count goes from prev to next across redraws, null or undefined meaning no
197
+ // strip (docs/adr/2026-11-03-1850-the-phase-bars-animate-as-a-build-starts-advances-and-ends.md).
198
+ function phaseMotion(prev, next) {
199
+ const had = typeof prev === 'number';
200
+ const has = typeof next === 'number';
201
+ if (had !== has) return has ? 'enter' : 'leave';
202
+ return has && next > prev ? 'fill' : null;
203
+ }
204
+ const phaseBars = (strip) => bars(strip, BUILD_PHASES);
205
+ // docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md
206
+ // Every status line on an In Progress card is the phase-bar orange, and every build or plan line on a Todo card the
207
+ // owner's blue (style.css).
208
+ const tone = (it) => (it.lane === 'progress' ? ' in-progress' : it.lane === 'todo' ? ' in-todo' : '');
209
+ const DOT = '<span class="working-dot" aria-hidden="true"></span>';
210
+ // A card idle on the orchestrator (domain.mjs orchestratorWaitOf) reads waiting for orchestrator, then orchestrator
211
+ // review with its dot once the session picks it up, each with how long ago.
212
+ function orchestratorStatus(it) {
213
+ const { since, picked } = it.orchestrator;
214
+ return `<div class="line3 sub agent${tone(it)}">${picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`}</div>`;
215
+ }
216
+ // A stale build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
217
+ function builderStatus(it) {
218
+ const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}`;
219
+ const words = `<span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}`;
220
+ if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
221
+ 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>`;
222
+ }
148
223
  const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
149
224
  // When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
150
- // (docs/adr/0118-a-card-can-be-held-until-a-time.md).
225
+ // (docs/adr/2026-11-02-1727-a-card-can-be-held-until-a-time.md).
151
226
  function heldUntilWords(iso, now = new Date()) {
152
227
  const d = new Date(iso);
153
228
  const midnight = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
@@ -164,48 +239,65 @@
164
239
  return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
165
240
  }
166
241
  // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
167
- // or ready, then a building card's phase bars, then its unread count, which app-cards.js takes away when the card opens
168
- // (docs/adr/0154-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/0160-a-building-cards-phase-bars-end-its-time-row.md).
242
+ // or ready, then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
243
+ // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
244
+ // 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).
169
245
  const SEP = '<span class="sep"> · </span>';
246
+ const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
170
247
  function timeLine(it, unread, bars) {
171
248
  const iso = it.sortAt;
172
249
  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);
173
- const count = unread ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${unread} new message${unread === 1 ? '' : 's'}</span>` : '';
250
+ const news = newWords(unread, 'message');
251
+ const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
174
252
  const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
175
253
  if (!parts.length && !count && !tail) return '';
176
254
  return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
177
255
  }
178
256
  // The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
179
257
  // Keep awake, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
180
- // durationPicker's markup also uses.
181
- function holdPicker(key, choices) {
182
- 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}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
258
+ // durationPicker's markup also uses. Each chip's length (ms, null for Forever) is worded as when it would end if picked
259
+ // at `now`, here rather than on the server, so the board's version doesn't change with the clock
260
+ // (docs/adr/2026-11-04-0128-the-board-download-leaves-out-resolved-questions.md).
261
+ const holdUntil = (c, now) => (c.ms === null ? 'until you unhold it' : `until ${heldUntilWords(new Date(now.getTime() + c.ms), now)}`);
262
+ function holdPicker(key, choices, now = new Date()) {
263
+ 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('');
183
264
  return `<div class="hub-view"><div class="dur-chips" role="group" aria-labelledby="holdTitle"><div class="dur-choices">${chips}</div>`
184
265
  + `<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>`;
185
266
  }
186
267
  // A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
187
- // docs/adr/0047-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
268
+ // docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
188
269
  const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
189
- const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
190
- const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && `<span class="working">${workingLine}</span>`].filter(Boolean).join(' · ');
191
- function workLines(it, { unread = 0, top = false } = {}) {
270
+ // A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
271
+ // 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).
272
+ 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>';
273
+ 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>`;
274
+ const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
275
+ : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
276
+ const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && workingLine].filter(Boolean).join(' · ');
277
+ // 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
278
+ // its bump with the band's ⋯ menu.
279
+ function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
192
280
  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);
193
281
  // A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
194
- const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${ticket}` : ticket;
195
- const wave = it.lineup ? badge('quiet', `${WAVE_ICON}${esc(it.lineup.title)}`, { title: `In the wave ${it.lineup.title}` }) : '';
196
- const chips = `${stalledChip(it)}${it.followUp ? badge('quiet', 'follow-up') : ''}${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
282
+ const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
283
+ const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${chip}` : chip;
284
+ const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
197
285
  const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
198
- : it.waitsOn === 'checking' ? '<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>Orchestrator review</div>'
199
- : it.builder ? `<div class="line3 sub agent${it.builder.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}</div>`
286
+ : it.orchestrator && !it.builder ? orchestratorStatus(it)
287
+ : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
288
+ : it.builder ? builderStatus(it)
289
+ : it.grilling || it.prototyping ? `<div class="line3 sub agent planner${tone(it)}">${DOT}${it.grilling ? 'preheating' : 'prototyping'}</div>`
200
290
  : it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
201
291
  : it.working ? `<div class="line3 sub agent">${workingLine}</div>`
202
- : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
292
+ : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held || it.queued ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
203
293
  const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
204
- return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
205
- ${status}${eta}
294
+ // 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).
295
+ const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${esc(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
296
+ : `<div class="line3 sub check">${esc(`Checks: ${it.ownerCheck.words}`)}</div>`;
297
+ return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
298
+ ${status}${check}${eta}
206
299
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
207
- ${wave ? `<div class="line5 meta">${wave}</div>` : ''}
208
- ${timeLine(it, unread, it.lane === 'progress' && it.builder && !it.builder.planner && it.waitsOn !== 'checking' ? phaseBars(it.builder.phase) : '')}`;
300
+ ${timeLine(it, unread, phaseBars(phaseStrip(it)))}`;
209
301
  }
210
302
  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>';
211
303
  const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
@@ -214,5 +306,43 @@
214
306
  return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
215
307
  }
216
308
 
217
- Object.assign(globalThis.BoardUI, { prChips, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, buildButton, builderLine, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON });
309
+ // docs/adr/2026-11-03-1731-each-board-reviews-incoming-prs-that-touch-its-work.md
310
+ // 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
311
+ // posted. A tap opens the review text and the PR's links, and a review held for the owner offers Post it and Skip,
312
+ // whose tap waits while the server records it (app-lanes.js). Older posted reviews are one line each under Posted earlier.
313
+ const REVIEW_WORDS = { needs: 'needs you', reviewing: 'reviewing', posted: 'posted' };
314
+ const REVIEW_DOTS = { needs: 'rv-needs', reviewing: 'progress', posted: 'done' };
315
+ function reviewDetail(c) {
316
+ const text = c.text ? `<div class="rv-review">${c.state === 'needs' ? '<p class="muted small">Draft, not posted.</p>' : ''}${BoardUI.markdown(c.text)}</div>`
317
+ : '<p class="muted small">Nothing posted yet. Right before posting, Claude reads the PR’s reviews again and adds only new points.</p>';
318
+ 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>`;
319
+ 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>` : '';
320
+ return `<div class="rv-detail">${text}${links}${acts}</div>`;
321
+ }
322
+ 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(' ');
323
+ const reviewWhen = (c) => `${c.state === 'reviewing' ? 'started ' : ''}${ago(c.at)}`;
324
+ function reviewCard(c, open) {
325
+ return `<details class="rv-card" data-review="${c.number}"${open ? ' open' : ''}><summary><span class="status ${REVIEW_DOTS[c.state]}"></span><div class="title-wrap">
326
+ <div class="line1 title">${badge('normal', `PR #${c.number}`)} ${esc(c.title)}</div>
327
+ <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>
328
+ <div class="line2 meta">${reviewChipsOf(c)}</div></div></summary>${reviewDetail(c)}</details>`;
329
+ }
330
+ 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>
331
+ <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>`;
332
+ // reviews: the built board's reviews; open: {lane, earlier, cards: Set of open PR numbers}.
333
+ function reviewLane(reviews, { open = {} } = {}) {
334
+ if (!reviews?.shown) return '';
335
+ const { cards, earlier } = reviews;
336
+ const needs = cards.filter((c) => c.state === 'needs').length;
337
+ const shown = (n) => open.cards?.has(n);
338
+ 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>
339
+ <div class="fold-list">${earlier.map((c) => reviewLine(c, shown(c.number))).join('')}</div></details>` : '';
340
+ const waiting = reviews.waiting ? `${reviews.waiting} incoming PR${reviews.waiting === 1 ? '' : 's'} wait${reviews.waiting === 1 ? 's' : ''} until the board is idle.`
341
+ : `No incoming PR touches this board’s work yet.${reviews.at ? ` Checked ${esc(reviews.repo)} ${ago(reviews.at)}.` : ''}`;
342
+ const list = cards.length ? cards.map((c) => reviewCard(c, shown(c.number))).join('') : `<div class="empty">${waiting}</div>`;
343
+ 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>
344
+ <div class="list">${list}${fold}</div></details>`;
345
+ }
346
+
347
+ 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 });
218
348
  })();
@@ -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
- const BUTTON = Object.freeze({ grill: 'grill', openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
6
+ const BUTTON = Object.freeze({ 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,14 +2,14 @@
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): a grey asleep while
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
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
7
  // said so yet), and a red not listening when nothing will wake it
8
- // (docs/adr/0147-boards-run-in-the-background-and-the-hub-board-in-tmux.md). link is 'online',
8
+ // (docs/adr/2026-11-03-0313-boards-run-in-the-background-and-the-hub-board-in-tmux.md). link is 'online',
9
9
  // 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it).
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>`
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>`
11
11
  : link === 'offline' ? `<span class="link-state offline" title="Offline: can't reach the host">${at ? shortSince(at) : 'offline'}</span>`
12
- : link === 'reconnecting' ? '<span class="link-state">Reconnecting…</span>'
12
+ : link === 'reconnecting' ? '<span class="link-state">reconnecting…</span>'
13
13
  : watcher === 'waking' || (waking && watcher === 'asleep') ? '<span class="claude asleep" title="Waking: the hub is resuming its session">waking…</span>'
14
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
15
  : watcher === 'stopped' ? '<span class="claude down" title="Claude not listening">not listening</span>'
@@ -19,16 +19,16 @@
19
19
  // fresh is {at, sig}: the header's time and what the page showed then. via is 'refresh' (a ↻ tap), 'signal' (the
20
20
  // stream's change signal) or 'poll' (any other load). A manual refresh always moves the time, a change signal only when it brought
21
21
  // something the time hasn't counted, a poll never, and any load moves it while none has landed (sig null)
22
- // (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).
23
23
  const nextFresh = (fresh, { via, at, sig }) => (!fresh?.sig || via === 'refresh' || (via === 'signal' && sig !== fresh.sig) ? { at, sig } : fresh);
24
24
  const shortSince = (iso) => {
25
25
  const s = (Date.now() - Date.parse(iso)) / 1000;
26
26
  return s < 60 ? 'now' : s < 3600 ? Math.round(s / 60) + ' min' : s < 86400 ? Math.round(s / 3600) + ' h' : Math.round(s / 86400) + ' d';
27
27
  };
28
- // 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).
29
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>';
30
30
  // The GitHub mark opens the Hub links sheet, with a red dot while any link is broken past its grace period
31
- // (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
32
32
  // when the hub doesn't list this board. A link without state, from a board server still running older code, reads from ok.
33
33
  const linkState = (l) => l.state || (l.ok ? 'ok' : 'broken');
34
34
  const brokenLinks = (links) => (links || []).filter((l) => linkState(l) === 'broken');
@@ -38,7 +38,7 @@
38
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>`;
39
39
  };
40
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/0127-a-board-can-work-on-any-git-remote.md).
41
+ // (docs/adr/2026-11-02-1850-a-board-can-work-on-any-git-remote.md).
42
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
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>`;
44
44
  // The In Review header's right end; pending is whether a check-github request is waiting.
@@ -53,7 +53,7 @@
53
53
 
54
54
  const LINK_NAMES = { github: 'GitHub polling', 'repo-fetch': 'Repo fetch', events: 'Events', wakes: 'Daily wakes', slack: 'Reviewer links' };
55
55
  // The Hub links sheet's list, one row per link with a green dot, a muted one while settling, or a red one
56
- // (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).
57
57
  const LINK_DOTS = { ok: ['pass', 'Working'], settling: ['', 'Settling'], broken: ['fail', 'Not working'] };
58
58
  const linkRow = (l) => {
59
59
  const st = linkState(l);
@@ -83,7 +83,7 @@
83
83
 
84
84
  // Which header controls take taps, and whether refresh spins. The header paints them disabled with refresh spinning;
85
85
  // both turn on together once the first board load has answered or failed.
86
- // 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).
87
87
  const headerControls = ({ link = 'online', wired = false, boardSettled = false, reloading = false } = {}) => {
88
88
  const ready = wired && boardSettled && !reloading;
89
89
  return { refresh: ready, settings: ready && link === 'online', spin: !boardSettled || reloading };
@@ -1,4 +1,4 @@
1
- // The rows and tiles of the hub page and settings modals (view-hub.js, view-hub-settings.js, view-polling.js), which hub.css styles
1
+ // The rows and tiles of the hub page and settings modals (view-hub.js, view-hub-settings.js), which hub.css styles
2
2
  // under .hub-view. Pure: each lays out words hub/views.mjs wrote, and title and sub are HTML.
3
3
  (function () {
4
4
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -58,12 +58,12 @@
58
58
  const nowRow = (now, icon = '') => row({ icon, title: esc(now.title), sub: now.lines.map((l) => `<span class="sub wrap">${esc(l)}</span>`).join(''),
59
59
  trail: `<form method="post" action="/_hub/push/flush"><button class="btn quiet" ${now.count ? '' : 'disabled'}>Send now</button></form>` });
60
60
 
61
- // This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, disabled}} or
62
- // null while unknown, which keeps the row hidden.
61
+ // This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, busy}} or null
62
+ // while unknown, which keeps the row hidden. A busy action waits (ui.js BUSY) while it turns on or tests.
63
63
  function deviceRow(state, { compact = false } = {}) {
64
64
  if (!state) return `<div class="row" id="push" hidden>${tile('bell', 'var(--accent)')}</div>`;
65
65
  const a = state.action;
66
- const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.disabled ? ' disabled' : ''}>${esc(a.label)}</button>` : '';
66
+ const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.busy ? BoardUI.BUSY : ''}>${esc(a.label)}</button>` : '';
67
67
  return `<div class="row" id="push">${tile('bell', state.action?.act === 'test' ? 'var(--color-success)' : 'var(--accent)')}<span class="body"><span class="text"><span class="title">${esc(state.title)}</span>${compact ? '' : `<span class="sub wrap">${esc(state.sub)}</span>`}</span>${compact && state.title === 'This device' ? `<span class="val">${state.sub?.startsWith('On') ? 'On' : 'Off'}</span>` : ''}${button}</span></div>`;
68
68
  }
69
69
 
@@ -1,4 +1,4 @@
1
- // A small Markdown renderer for Notes (view-notes.js; docs/adr/0024-a-full-plan-opens-as-a-page-on-the-site.md). Needs ui.js and ui-links.js.
1
+ // A small Markdown renderer for Notes (view-notes.js; docs/adr/2026-10-29-1639-a-full-plan-opens-as-a-page-on-the-site.md). Needs ui.js and ui-links.js.
2
2
  (function () {
3
3
  const { esc, linkHtml } = globalThis.BoardUI;
4
4
  // Raw HTML is escaped first, so a link's href can only break out through its scheme.
@@ -1,18 +1,21 @@
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/0140-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
3
+ // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, queued, bumpedAt, sortAt, order and key.
4
4
  // Needs nothing else: board/domain.mjs runs this file in a vm context.
5
5
  (function () {
6
6
  const LANE_ORDER = { review: 0, stale: 1, progress: 2, todo: 3, done: 4, asks: 5 };
7
- // New cards lead Todo, oldest first, so the top one is the next taken (docs/adr/0047-the-plus-makes-a-new-card.md);
8
- // then bumped cards, the latest bump on top (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md); then the rest
9
- // newest first by sortAt, a card with no time last, the last added to its wave file on top.
7
+ // New cards lead Todo, oldest first, so the top one is the next taken (docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md);
8
+ // then bumped cards, the latest bump on top (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest
9
+ // newest first by sortAt, a card with no time last, the last added to its wave file on top. Todo's Queued cards
10
+ // lead it, bumped first and then oldest first, so the top one is the next to build
11
+ // (docs/adr/2026-11-04-0150-queued-sits-at-the-top-of-todo.md).
10
12
  function laneOrder(a, b) {
11
13
  const bumped = (i) => i.bumpedAt || '';
12
14
  const unsorted = (i) => (i.isNew ? 0 : 1);
15
+ const queued = (i) => (i.queued ? 1 : 0);
13
16
  const at = (i) => String(i.sortAt || '');
14
- const age = a.isNew ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
15
- return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
17
+ const age = a.isNew || a.queued ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
18
+ return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || queued(b) - queued(a) || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
16
19
  }
17
20
  Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
18
21
  })();