@lovek4d/kickflip 0.5.3 → 0.6.1

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 (91) hide show
  1. package/CONTEXT.md +82 -46
  2. package/SKILL.md +21 -15
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +12 -12
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/agents.mjs +2 -2
  8. package/board/arch-review.mjs +4 -4
  9. package/board/bin/board.mjs +179 -92
  10. package/board/build-script.mjs +2 -1
  11. package/board/domain.mjs +9 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +9 -5
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-cards.js +40 -47
  19. package/board/public/app-header.js +6 -7
  20. package/board/public/app-lanes.js +19 -45
  21. package/board/public/app-needs.js +36 -31
  22. package/board/public/app-snapshot.js +43 -70
  23. package/board/public/app-threads.js +42 -46
  24. package/board/public/app.html +9 -4
  25. package/board/public/app.js +78 -32
  26. package/board/public/hub-kit.js +62 -2
  27. package/board/public/hub.css +2 -0
  28. package/board/public/new-task.js +8 -6
  29. package/board/public/progress-review.html +46 -0
  30. package/board/public/progress-review.js +158 -0
  31. package/board/public/prototype-kit.js +109 -4
  32. package/board/public/prototype-switcher.js +130 -31
  33. package/board/public/prototype.html +57 -3
  34. package/board/public/router.js +7 -6
  35. package/board/public/shell.js +65 -24
  36. package/board/public/sidebar.css +6 -0
  37. package/board/public/sidebar.js +19 -69
  38. package/board/public/style.css +116 -73
  39. package/board/public/ui-cards.js +78 -57
  40. package/board/public/ui-enums.js +2 -2
  41. package/board/public/ui-header.js +23 -16
  42. package/board/public/ui-hub.js +19 -5
  43. package/board/public/ui-links.js +7 -7
  44. package/board/public/ui-order.js +1 -1
  45. package/board/public/ui-page-header.js +19 -7
  46. package/board/public/ui-patch.js +7 -33
  47. package/board/public/ui-progress-bar.js +28 -0
  48. package/board/public/ui-questions.js +25 -17
  49. package/board/public/ui-sidebar.js +31 -23
  50. package/board/public/ui-snapshot.js +44 -53
  51. package/board/public/ui-threads.js +57 -28
  52. package/board/public/ui.js +94 -18
  53. package/board/public/view-hub-settings.js +12 -58
  54. package/board/public/view-hub.js +5 -2
  55. package/board/public/view-notes.js +27 -7
  56. package/board/public/view-questions.js +90 -68
  57. package/board/public/view-settings.js +133 -15
  58. package/board/render-check.mjs +10 -0
  59. package/board/review.mjs +1 -1
  60. package/board/server.mjs +20 -19
  61. package/board/stop.mjs +1 -0
  62. package/board/store.mjs +29 -18
  63. package/board/sync.mjs +2 -0
  64. package/board/tickets.mjs +3 -3
  65. package/board/transcript.mjs +2 -2
  66. package/board/worktree.mjs +22 -21
  67. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  68. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  69. package/hub/board-repo.mjs +1 -1
  70. package/hub/boards.mjs +4 -4
  71. package/hub/front-door.mjs +2 -2
  72. package/hub/github.mjs +3 -3
  73. package/hub/hub.mjs +11 -11
  74. package/hub/incoming.mjs +2 -2
  75. package/hub/owner-checks.mjs +1 -1
  76. package/hub/pr-report.mjs +2 -2
  77. package/hub/review-send.mjs +1 -1
  78. package/hub/sessions.mjs +2 -2
  79. package/hub/share.mjs +1 -1
  80. package/hub/slack.mjs +2 -2
  81. package/hub/try.mjs +6 -6
  82. package/hub/upgrade.mjs +1 -1
  83. package/hub/usage.mjs +3 -3
  84. package/hub/views.mjs +1 -1
  85. package/package.json +1 -1
  86. package/shared/card-prs.mjs +1 -1
  87. package/shared/machine-gate.mjs +2 -0
  88. package/shared/prototype-frame.js +1 -0
  89. package/shared/tailscale.mjs +1 -1
  90. package/shared/usage-gate.mjs +1 -1
  91. package/board/public/ui-phase-bars.js +0 -17
@@ -1,6 +1,6 @@
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.
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-progress-bar.js.
2
2
  (function () {
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;
3
+ const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, progressBar } = globalThis.BoardUI;
4
4
  const EXTERNAL = { target: '_blank', rel: 'noopener' };
5
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
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
@@ -11,6 +11,9 @@
11
11
  [PR_STATE.merged]: { tone: 'merged', mark: ' ✓', label: 'Merged' },
12
12
  [PR_STATE.closed]: { tone: 'danger', mark: ' ✗', label: 'Closed' },
13
13
  };
14
+ // docs/adr/2026-11-06-1547-a-cards-pr-badges-stay-once-added.md: a PR no longer open keeps its CI, Codex and reviewer
15
+ // chips at their last state, gray.
16
+ const keeps = (pr) => pr?.status === PR_STATE.merged || pr?.status === PR_STATE.closed;
14
17
  // An open PR reads green only while it waits on a reviewer alone: CI passed and no reviewer asked for changes.
15
18
  const waitsOnReviewer = (pr) => pr.checks?.state === 'pass' && pr.reviewDecision !== 'CHANGES_REQUESTED';
16
19
  const prChips = (it) => (it.prs || []).map(prChip).join('');
@@ -20,8 +23,9 @@
20
23
  const base = PR_LOOK[pr.status] || { tone: 'quiet', mark: '', label: '' };
21
24
  const look = pr.status === PR_STATE.open && waitsOnReviewer(pr) ? { ...base, tone: 'ready', label: 'Ready for review' } : base;
22
25
  const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
23
- const ci = pr.checks && pr.state === 'OPEN' ? badge(CI.tone, `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
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` }) : '';
26
+ const live = pr.state === 'OPEN';
27
+ const ci = pr.checks ? badge(live ? CI.tone : 'quiet', `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
28
+ const codex = pr.codexOpen ? badge(live ? 'notice' : 'quiet', `codex ${esc(pr.codexOpen)}`, { title: `${pr.codexOpen} Codex finding${pr.codexOpen === 1 ? '' : 's'} not answered yet` }) : '';
25
29
  const name = prName(pr);
26
30
  const label = look.label ? { title: look.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${look.label}` } } : { attrs: EXTERNAL };
27
31
  return `${badge(look.tone, `${esc(name)}${look.mark}`, { href: pr.url, ...label })}${ci}${codex}`;
@@ -39,12 +43,13 @@
39
43
  return days >= 1 ? ` ${days}d` : '';
40
44
  };
41
45
  function reviewChips(it) {
42
- if (!it.pr || it.pr.state !== 'OPEN') return '';
46
+ const live = it.pr?.state === 'OPEN';
47
+ if (!live && !keeps(it.pr)) return '';
43
48
  return (it.reviewers || []).map((r) => {
44
49
  const rv = globalThis.BoardUI.record(it.review, r);
45
50
  const s = REVIEW[rv?.state || 'unsent'];
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}` });
51
+ const age = live ? reviewAge(rv).trim() : '';
52
+ return badge(live ? s.tone : 'quiet', `${esc(rv?.github || r)}${age ? ` (${age})` : ''} ${s.mark}`, { title: `${r}${rv?.github ? ` (${rv.github})` : ''}: ${s.label}` });
48
53
  }).join('');
49
54
  }
50
55
  const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
@@ -57,11 +62,16 @@
57
62
  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
63
  }
59
64
  // Each unfinished dep, linking to its card: "blocked by #53, #54", or "waits on #53" on a bumped card not held, which
60
- // the pull takes once nothing blocks it (docs/adr/2026-11-05-0802-todo-has-no-queue.md).
65
+ // the pull takes once nothing blocks it (docs/adr/2026-11-05-0802-todo-has-no-queue.md); then its condition, "blocked
66
+ // until <condition>" ("and until <condition>" after deps), then ", <reason>"
67
+ // (docs/adr/2026-11-05-2358-nothing-waiting-on-the-orchestrator-is-missed.md).
61
68
  function blockedWords(it) {
62
69
  const deps = it.blockedBy || [];
63
- const lead = it.bumpedAt && !it.held ? 'waits on ' : 'blocked by ';
64
- return deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : lead}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
70
+ const waits = it.bumpedAt && !it.held;
71
+ const refs = deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : waits ? 'waits on ' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
72
+ const until = it.blockedUntil ? `${deps.length ? ' and until' : waits ? 'waits until' : 'blocked until'} ${it.blockedUntil}` : '';
73
+ const tail = until + (it.blockReason && (refs || until) ? `, ${it.blockReason}` : '');
74
+ return refs + (tail ? `<span class="card-blocked">${esc(tail)}</span>` : '');
65
75
  }
66
76
  const tryWords = (kind) => kind === 'app' ? {
67
77
  go: 'Serve', going: 'serving…', done: 'Install', failed: 'serve failed',
@@ -143,7 +153,10 @@
143
153
  // server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it, or the board server lands a
144
154
  // 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,
145
155
  // docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md).
156
+ // A blocked card's Merge shows disabled, its tooltip naming what blocks it
157
+ // (docs/adr/2026-11-06-0102-a-block-past-todo-bumps-a-build-and-holds-a-land.md).
146
158
  function mergeButton(it) {
159
+ if (it?.mergeBlocked) return `<button class="primary" disabled title="${esc(it.mergeBlocked)}">Merge</button>`;
147
160
  if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
148
161
  const busy = it.merging || BoardUI.pending.has(`${it.key}:merge`);
149
162
  // A merging card has no Action needed left, so resolvedBy is null by then: merging keeps the colour resolvedBy gave it.
@@ -152,43 +165,49 @@
152
165
  // A builder reads "starting" until its first status line, from the Implement tap or the session's dispatch on, and
153
166
  // ends with how long ago that line was set. One whose process is gone leads with stopped; one whose live process
154
167
  // went quiet keeps its words and only turns grey (domain.mjs builderOf; docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
155
- const builderLine = (b) => [b.alive === false && 'stopped', b.progress || (b.alive === false ? null : 'starting'), b.at && lasted(b.at)].filter(Boolean).join(' · ');
168
+ // One the orchestrator stopped reads the stop's words and how long ago it stopped
169
+ // (docs/adr/2026-11-05-1809-the-orchestrator-stops-the-boards-own-runs.md).
170
+ const builderLine = (b) => (b.stopped ? `${b.stopped} · ${lasted(b.silentSince)}`
171
+ : [b.alive === false && 'stopped', b.progress || (b.alive === false ? null : 'starting'), b.at && lasted(b.at)].filter(Boolean).join(' · '));
156
172
  // Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
157
173
  // store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
158
174
  // docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
159
175
  const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
160
176
  const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
161
177
  const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
162
- // 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,
163
- // whether the last of them is running, whether it dims because the card waits on something other than a builder (its
178
+ // A card's progress bar (ui-progress-bar.js), or null off In Progress and on a card only a planner works on: how many
179
+ // phases it reached, which of them is running, whether it dims because the card waits on something other than a builder (its
164
180
  // 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
165
181
  // build reached, undimmed
166
182
  // (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
167
- // A Todo card a grill or prototype runs on draws the planner's dashes instead, in blue, the grill's first and the
168
- // prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md).
183
+ // A Todo card a grill or prototype runs on draws the planner's dashes instead, in Todo's grey, the grill's first and the
184
+ // prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md,
185
+ // docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md).
169
186
  function phaseStrip(it) {
170
187
  if (it.lane === 'todo' && it.builder?.planner) {
171
188
  const prototype = it.builder.kind === 'prototype';
172
- return { reached: prototype ? 2 : 1, running: true, dim: false, plan: true, label: prototype ? 'Prototyping' : 'Grilling' };
189
+ const done = prototype ? 2 : 1;
190
+ return { count: BUILD_PHASES.length, done, now: done - 1, tone: 'in-todo', label: prototype ? 'Prototyping' : 'Grilling' };
173
191
  }
174
192
  if (it.lane !== 'progress' || it.builder?.planner) return null;
175
193
  const all = BUILD_PHASES.length;
176
194
  // Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
177
- if (it.waitsOn === 'checking') return { reached: BUILD_PHASES.indexOf('checking') + 1, running: true, dim: false, label: `Orchestrator review, ${stepWords('checking')}` };
195
+ const checking = BUILD_PHASES.indexOf('checking');
196
+ if (it.waitsOn === 'checking') return { count: all, done: checking + 1, now: checking, label: `Orchestrator review, ${stepWords('checking')}` };
178
197
  const asking = it.action?.kind === ACTION_KIND.questions || it.action?.kind === ACTION_KIND.pick;
179
198
  const answers = asking ? ', waiting on your answers' : '';
180
199
  const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
181
200
  if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
182
- const reached = BUILD_PHASES.indexOf(it.builder.phase) + 1;
183
- return { reached, running: reached > 0, dim: asking || Boolean(it.builder.stalled || it.builder.quiet), label: `${reached ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
201
+ const done = BUILD_PHASES.indexOf(it.builder.phase) + 1;
202
+ return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stale || it.builder.quiet), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
184
203
  }
185
204
  const dim = !it.builder || asking;
186
- if (it.reopened) return { reached: all, running: false, dim, label: `Reopened, ${ALL_DONE}${answers}` };
187
- const reached = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
188
- const label = reached ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : 'No step yet';
189
- return { reached, running: false, dim, label: `${label}${answers}` };
205
+ if (it.reopened) return { count: all, done: all, dim, label: `Reopened, ${ALL_DONE}${answers}` };
206
+ const done = BUILD_PHASES.indexOf(it.reachedPhase) + 1;
207
+ const label = done ? `Stopped at ${it.reachedPhase}, ${stepWords(it.reachedPhase)}` : 'No step yet';
208
+ return { count: all, done, dim, label: `${label}${answers}` };
190
209
  }
191
- // What the bars do when a card's reached count goes from prev to next across redraws, null or undefined meaning no
210
+ // What the bars do when a card's done count goes from prev to next across redraws, null or undefined meaning no
192
211
  // strip (docs/adr/2026-11-03-1850-the-phase-bars-animate-as-a-build-starts-advances-and-ends.md).
193
212
  function phaseMotion(prev, next) {
194
213
  const had = typeof prev === 'number';
@@ -196,38 +215,37 @@
196
215
  if (had !== has) return has ? 'enter' : 'leave';
197
216
  return has && next > prev ? 'fill' : null;
198
217
  }
199
- const phaseBars = (strip) => bars(strip, BUILD_PHASES);
200
- // docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md
201
- // Every status line on an In Progress card is the phase-bar orange, and every build or plan line on a Todo card the
202
- // owner's blue (style.css).
203
- const tone = (it) => (it.lane === 'progress' ? ' in-progress' : it.lane === 'todo' ? ' in-todo' : '');
218
+ // A run's status line takes its card's lane colour (style.css): grey in Todo, the phase-bar orange in In Progress, blue in
219
+ // In Review (docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md). A Done card's line keeps the plain orange.
220
+ const tone = (it) => (['todo', 'progress', 'review'].includes(it.lane) ? ` in-${it.lane}` : '');
204
221
  const DOT = '<span class="working-dot" aria-hidden="true"></span>';
205
222
  // A card idle on the orchestrator (domain.mjs orchestratorWaitOf) reads waiting for orchestrator, then orchestrator
206
223
  // review with its dot once the session picks it up, each with how long ago.
207
224
  function orchestratorStatus(it) {
208
225
  const { since, picked } = it.orchestrator;
209
- return `<div class="line3 sub agent${tone(it)}">${picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`}</div>`;
226
+ const words = isPaused() ? `${PAUSED} · ${esc(lasted(picked || since))}` : picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`;
227
+ return `<div class="line3 sub agent${tone(it)}${picked && !isPaused() ? '' : ' waiting-gray'}">${words}</div>`;
210
228
  }
211
- // A stale or stopped build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
212
- // A quiet one is grey, with no dot.
229
+ // docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md
230
+ // A stale build's status line is grey, its dot still, since the orchestrator is already told; a quiet one is grey with
231
+ // no dot, and one the orchestrator stopped plain.
213
232
  function builderStatus(it) {
214
233
  const { quiet } = it.builder;
215
- const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}${quiet ? ' quiet' : ''}`;
216
- const words = `${quiet ? '' : DOT}${esc(builderLine(it.builder))}`;
217
- if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
218
- const nudging = BoardUI.pending.has(`${it.key}:nudge`) ? ' nudging' : '';
219
- return `<button type="button" class="${classes} stalled${nudging}" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
234
+ const stale = it.builder.stale && !it.builder.stopped && it.lane !== 'done';
235
+ const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}${quiet ? ' quiet' : ''}${stale ? ' stale' : ''}`;
236
+ if (it.builder.stopped) return `<div class="${classes}">${esc(builderLine(it.builder))}</div>`;
237
+ return `<div class="${classes}">${quiet ? '' : DOT}${esc(builderLine(it.builder))}</div>`;
220
238
  }
221
239
  // The open card's Status row: what runs on the card, else where its lane leaves it, never the wave file's raw status
222
240
  // (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
223
241
  function statusWord(it) {
224
242
  const b = it.builder;
225
243
  if (b?.planner) return b.kind === 'prototype' ? 'prototyping' : 'grilling';
226
- if (b && b.kind !== BUILDER_KIND.install) return b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing';
244
+ if (b && b.kind !== BUILDER_KIND.install) return b.stopped || (b.alive === false ? 'stopped' : b.phase === 'landing' ? 'landing' : 'implementing');
227
245
  if (it.waitsOn === 'checking') return 'Orchestrator review';
228
246
  if (it.lane === 'done') return 'done';
229
247
  if (it.grilling || it.prototyping) return 'preheating';
230
- if (it.lane === 'todo') return it.ready ? 'ready' : 'to do';
248
+ if (it.lane === 'todo') return it.staging ? 'staging' : it.ready ? 'ready' : 'to do';
231
249
  if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
232
250
  return 'in review';
233
251
  }
@@ -249,15 +267,17 @@
249
267
  : `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.'}`;
250
268
  return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
251
269
  }
252
- // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
253
- // or ready, then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
270
+ // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
271
+ // staging in orange or ready in green (docs/adr/2026-11-07-0700-staging-then-ready.md), stalled in red or stale in grey
272
+ // (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
254
273
  // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
255
274
  // 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).
256
275
  const SEP = '<span class="sep"> · </span>';
257
276
  const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
258
277
  function timeLine(it, unread, bars) {
259
278
  const iso = it.sortAt;
260
- 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);
279
+ const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.staging && '<span class="card-staging">staging</span>', it.ready && '<span class="card-ready">ready</span>',
280
+ it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && '<span class="card-stale">stale</span>'].filter(Boolean);
261
281
  const news = newWords(unread, 'message');
262
282
  const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
263
283
  const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
@@ -284,36 +304,37 @@
284
304
  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>`;
285
305
  const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
286
306
  : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
287
- const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && workingLine].filter(Boolean).join(' · ');
307
+ const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && thinkingLine()].filter(Boolean).join(' · ');
288
308
  // 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
289
309
  // its bump with the band's ⋯ menu.
290
310
  function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
291
311
  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);
292
- // A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
312
+ // A work card always shows its ticket; New is only on an ask (askChip).
293
313
  const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
294
- const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${chip}` : chip;
314
+ const ids = it.kind === 'ask' ? askChip(it) : chip;
295
315
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
296
316
  const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
297
- : it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${esc(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
317
+ : it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
298
318
  : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
299
319
  : it.builder ? builderStatus(it)
300
320
  : it.grilling || it.prototyping ? '<div class="line3 sub asked">preheating</div>'
301
- : it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
302
- : it.working ? `<div class="line3 sub agent">${workingLine}</div>`
303
- : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
304
- const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
321
+ : it.refusal ? `<div class="line3 sub refused">${escText(it.refusal)}</div>`
322
+ : it.landStopped ? `<div class="line3 sub">${escText(`${it.landStopped.words} · ${lasted(it.landStopped.at)}`)}</div>`
323
+ : it.working ? `<div class="line3 sub agent">${thinkingLine()}</div>`
324
+ : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${escText(it.next)}</div>` : '';
325
+ const eta = it.eta && it.builder ? `<div class="line3 sub eta">${escText(etaLine(it.eta))}</div>` : '';
305
326
  // 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).
306
- const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${esc(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
307
- : `<div class="line3 sub check">${esc(`Checks: ${it.ownerCheck.words}`)}</div>`;
308
- return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
327
+ const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
328
+ : `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
329
+ return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
309
330
  ${status}${check}${eta}
310
331
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
311
- ${timeLine(it, unread, phaseBars(phaseStrip(it)))}`;
332
+ ${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
312
333
  }
313
334
  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>';
314
335
  const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
315
336
  function planButton(items) {
316
- const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blockedBy?.length).map((i) => i.key);
337
+ const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blocked).map((i) => i.key);
317
338
  return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}"${BoardUI.pending.has('todo:plan') ? BoardUI.BUSY : ''}>Plan waves</button>` : '';
318
339
  }
319
340
 
@@ -334,11 +355,11 @@
334
355
  const reviewWhen = (c) => `${c.state === 'reviewing' ? 'started ' : ''}${ago(c.at)}`;
335
356
  function reviewCard(c, open) {
336
357
  return `<details class="rv-card" data-review="${c.number}"${open ? ' open' : ''}><summary><span class="status ${REVIEW_DOTS[c.state]}"></span><div class="title-wrap">
337
- <div class="line1 title">${badge('normal', `PR #${c.number}`)} ${esc(c.title)}</div>
358
+ <div class="line1 title">${badge('normal', `PR #${c.number}`)} ${escText(c.title)}</div>
338
359
  <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>
339
360
  <div class="line2 meta">${reviewChipsOf(c)}</div></div></summary>${reviewDetail(c)}</details>`;
340
361
  }
341
- 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>
362
+ const reviewLine = (c, open) => `<details class="rv-line" data-review="${c.number}"${open ? ' open' : ''}><summary><div class="title">${badge('normal', `PR #${c.number}`)} ${escText(c.title)}</div>
342
363
  <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>`;
343
364
  // reviews: the built board's reviews; open: {lane, earlier, cards: Set of open PR numbers}.
344
365
  function reviewLane(reviews, { open = {} } = {}) {
@@ -3,8 +3,8 @@
3
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
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({ openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
7
- const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done' });
6
+ const BUTTON = Object.freeze({ openQuestions: 'openQuestions', done: 'done', github: 'github', merge: 'merge', reply: 'reply' });
7
+ const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done', stalled: 'stalled' });
8
8
  const BUILDER_KIND = Object.freeze({ install: 'install' });
9
9
  const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
10
10
  Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE });
@@ -1,19 +1,25 @@
1
1
  // The board header's time and the In Review header's GitHub line, with when a refresh asks for a check. Needs ui.js.
2
2
  (function () {
3
3
  const { since, esc } = globalThis.BoardUI;
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/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',
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>`
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>'
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>'
16
- : at ? `<span class="fresh" title="Updated ${since(at)}: the last change from the host, or your last refresh">${shortSince(at)}</span>` : '');
4
+ // The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh). It reads that age;
5
+ // the board's other states only move the span's colour (the arrow follows it, style.css) and its title. watcher is
6
+ // /api/board's checks.watcher (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md): stopped
7
+ // (nothing will wake it) and an offline link turn it red, paused usage turns it orange. A busy host doesn't change it
8
+ // at all (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
9
+ // link is 'online', 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it, and the icon spins).
10
+ const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => {
11
+ const paused = pause?.state === 'paused';
12
+ const down = link === 'offline' || watcher === 'stopped';
13
+ const reason = link === 'offline' ? "Offline: can't reach the host"
14
+ : watcher === 'stopped' ? 'Claude not listening'
15
+ : paused ? `Paused ${BoardUI.pauseUntilWords(pause.resets_at)}`
16
+ : link === 'reconnecting' ? 'Reconnecting…'
17
+ : watcher === 'waking' || (waking && watcher === 'asleep') ? 'Waking: the hub is resuming its session'
18
+ : watcher === 'asleep' ? 'Asleep: its session idled out. Refresh, or any tap on the page, wakes it' : '';
19
+ if (!at && !reason) return '';
20
+ const title = [reason, at && `Updated ${since(at)}: the last change from the host, or your last refresh`].filter(Boolean).join('. ');
21
+ return `<span class="fresh${down ? ' down' : paused ? ' hold' : ''}" title="${esc(title)}">${at ? shortSince(at) : ''}</span>`;
22
+ };
17
23
  // What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
18
24
  const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
19
25
  // fresh is {at, sig}: the header's time and what the page showed then. via is 'refresh' (a ↻ tap), 'signal' (the
@@ -83,10 +89,11 @@
83
89
 
84
90
  // Which header controls take taps, and whether refresh spins. The header paints them disabled with refresh spinning;
85
91
  // 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/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md).
87
- const headerControls = ({ link = 'online', wired = false, boardSettled = false, reloading = false } = {}) => {
92
+ // 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),
93
+ // and Settings opens its read-only copy (docs/adr/2026-11-06-0135-a-board-out-of-reach-keeps-its-copy-or-shows-a-landing.md).
94
+ const headerControls = ({ wired = false, boardSettled = false, reloading = false } = {}) => {
88
95
  const ready = wired && boardSettled && !reloading;
89
- return { refresh: ready, settings: ready && link === 'online', spin: !boardSettled || reloading };
96
+ return { refresh: ready, settings: ready, spin: !boardSettled || reloading };
90
97
  };
91
98
 
92
99
  Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, remoteLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
@@ -15,6 +15,7 @@
15
15
  power: '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
16
16
  calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h18"/>',
17
17
  snooze: '<path d="M4 10h6l-6 8h6"/><path d="M14 4h6l-6 8h6"/>',
18
+ pause: '<rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/>',
18
19
  coffee: '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><path d="M6 2v2"/><path d="M10 2v2"/><path d="M14 2v2"/>',
19
20
  laptop: '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/>',
20
21
  key: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
@@ -33,16 +34,29 @@
33
34
  // hold: hub/views.mjs hubView's hold, or null for no banner. Its End form posts snooze=off, which view-hub.js sends.
34
35
  const holdBanner = (hold) => (hold ? `<div class="hold-banner"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${TILE_PATHS.moon}</svg><span>${esc(hold.text)}</span>${hold.end ? '<form method="post" action="/_hub/settings/notifications"><button class="btn quiet" name="snooze" value="off">End snooze</button></form>' : ''}</div>` : '');
35
36
 
37
+ // pause: shared/usage-gate.mjs usageGate's gate. Near the line (its near), the banner says agents start one at a time,
38
+ // with Keep going (docs/adr/2026-11-06-1249-the-pause-line-reads-every-headless-agents-usage.md).
36
39
  function pauseBanner(pause) {
37
- if (!pause || pause.state === 'normal') return '';
38
- const going = pause.state === 'going';
39
- const until = BoardUI.usageClock(pause.resets_at);
40
+ if (!pause || (pause.state === 'normal' && !pause.near)) return '';
41
+ const going = pause.state === 'going' || Boolean(pause.state === 'normal' && pause.near.going);
42
+ const until = BoardUI.usageClock(pause.state === 'normal' ? pause.near.resets_at : pause.resets_at);
40
43
  const icon = going ? '<path d="M3.34 19a10 10 0 1 1 17.32 0"/><path d="m12 14 4-4"/>' : '<circle cx="12" cy="12" r="9"/><path d="M10 9v6"/><path d="M14 9v6"/>';
41
- const text = going ? `Past the buffer: spending usage until ${until}`
44
+ // Pause hub (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md): Resume ends it, which view-hub.js posts.
45
+ if (pause.manual) return `<div class="hold-banner ub-banner ub-paused" role="status"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg><span>${esc(pause.resets_at === 'forever' ? 'Work paused: you paused the hub until you resume.' : `Work paused: you paused the hub. Resumes at ${until}.`)}</span><form method="post" action="/_hub/settings/hub-pause"><button class="btn quiet" name="pause" value="off">Resume</button></form></div>`;
46
+ const text = pause.state === 'normal' ? going ? `Approaching usage limit: agents start in parallel until ${until}` : 'Approaching usage limit, reducing parallel agents'
47
+ : going ? `Past the buffer: spending usage until ${until}`
42
48
  : `Work paused: Claude’s ${pause.window === 'five_hour' ? '5‑hour' : 'weekly'} usage is at ${Math.round(pause.used)}%. Resumes ${pause.window === 'five_hour' ? 'at ' : ''}${until}.`;
43
49
  return `<div class="hold-banner ub-banner ub-${going ? 'going' : 'paused'}" role="status"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg><span>${esc(text)}</span><form method="post" action="/_hub/settings/keep-going"><button class="btn${going ? '' : ' quiet'}" name="value" value="${going ? 'off' : 'on'}">${going ? 'Stop' : 'Keep going'}</button></form></div>`;
44
50
  }
45
51
 
52
+ // Health's Machine row: the gate's machine (shared/machine-gate.mjs), busy with its reason or normal, in words only
53
+ // (docs/adr/2026-11-06-0106-a-busy-mac-holds-new-work.md).
54
+ const machineRow = (machine) => {
55
+ const busy = machine.state === 'busy';
56
+ return row({ icon: tile('power', busy ? '#f59e0b' : '#22c55e'), title: 'Machine', wrap: true,
57
+ sub: busy ? `<span class="st wait">busy</span> · ${esc(machine.reason)}` : '<span class="st ok">normal</span>' });
58
+ };
59
+
46
60
  // builder: the word for who builds Codex's work, hub/views.mjs hubView's claude.builder.
47
61
  function bufferControl(pause, builder) {
48
62
  const at = pause.pause_at;
@@ -108,5 +122,5 @@
108
122
  return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
109
123
  }
110
124
 
111
- globalThis.HubUI = { esc, tile, CHEVRON, row, flash, holdBanner, pauseBanner, nowRow, deviceRow, usageCard };
125
+ globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow, nowRow, deviceRow, usageCard };
112
126
  })();
@@ -2,7 +2,7 @@
2
2
  // ref opens that card, a Linear id or PR opens its page, and a bare URL becomes a link. Needs ui.js; load it before
3
3
  // the ui-*.js files that render text.
4
4
  (function () {
5
- const { esc, record } = globalThis.BoardUI;
5
+ const { esc, escText, record } = globalThis.BoardUI;
6
6
  // The Linear id shape, which an internal ref shares.
7
7
  const LINEAR_ID = /^([A-Z][A-Z0-9]*)-\d+$/;
8
8
  const LINEAR_ISSUE = /^https:\/\/linear\.app\/[^/]+\/issue\//;
@@ -73,13 +73,13 @@
73
73
  // The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
74
74
  function wordLink(word, ctx) {
75
75
  for (let w = word; w; w = w.includes('-') ? w.slice(0, w.lastIndexOf('-')) : '') {
76
- const rest = esc(word.slice(w.length));
76
+ const rest = escText(word.slice(w.length));
77
77
  if (record(ctx.refs, w)) return cardLink(w, ctx) + rest;
78
78
  const team = LINEAR_ID.exec(w)?.[1];
79
79
  const prefix = team && team !== 'INT' && record(ctx.teams, team);
80
80
  if (prefix) return out(prefix + w, w) + rest;
81
81
  }
82
- return esc(word);
82
+ return escText(word);
83
83
  }
84
84
 
85
85
  // A bare #n that reads as a hex colour stays text.
@@ -92,16 +92,16 @@
92
92
  let at = 0;
93
93
  for (const m of s.matchAll(TOKEN)) {
94
94
  const [all, url, owner, name, n, bare, word] = m;
95
- html += esc(s.slice(at, m.index));
95
+ html += escText(s.slice(at, m.index));
96
96
  at = m.index + all.length;
97
97
  if (url) html += urlLink(url);
98
98
  else if (name) {
99
99
  const repo = owner ? `${owner}/${name}` : record(ctx.repos, name);
100
- html += repo ? out(prUrl(repo, n), all) : esc(all);
101
- } else if (bare) html += record(ctx.refs, all) ? cardLink(all, ctx) : ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : esc(all);
100
+ html += repo ? out(prUrl(repo, n), all) : escText(all);
101
+ } else if (bare) html += record(ctx.refs, all) ? cardLink(all, ctx) : ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), all) : escText(all);
102
102
  else html += wordLink(word, ctx);
103
103
  }
104
- return html + esc(s.slice(at));
104
+ return html + escText(s.slice(at));
105
105
  }
106
106
 
107
107
  const TAG = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w-]*)(?:[^>"']|"[^"]*"|'[^']*')*>/g;
@@ -3,7 +3,7 @@
3
3
  // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
4
4
  // Needs nothing else: board/domain.mjs runs this file in a vm context.
5
5
  (function () {
6
- const LANE_ORDER = { review: 0, stale: 1, progress: 2, todo: 3, done: 4, asks: 5 };
6
+ const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3, asks: 4 };
7
7
  // Todo lists bumped cards first, the latest bump on top, a bumped New ask included
8
8
  // (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest oldest first by sortAt, a card with no
9
9
  // time last, the first added to its wave file on top, so a New ask and every new card join the bottom and the pull takes the
@@ -8,6 +8,10 @@
8
8
  const GEAR = icon(18, '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>');
9
9
  const MOON = icon(16, '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>');
10
10
  const BULB = icon(18, '<path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/>');
11
+ // The pages switch (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md): a bare ? and a bare check, drawn
12
+ // like the bulb and gear.
13
+ const QUESTION = icon(18, '<path d="M6.5 8.5a5.5 5.5 0 1 1 8.2 4.8c-1.6.9-2.7 1.8-2.7 3.2V17"/><path d="M12 21h.01"/>');
14
+ const CHECK = icon(18, '<path d="M20 6 9 17l-5-5"/>');
11
15
  const id = (v) => (v ? ` id="${esc(v)}"` : '');
12
16
 
13
17
  // Where the back arrow goes (docs/adr/2026-10-29-1639-a-questions-takes-show-inline.md): 'back', one step back in history, when
@@ -41,13 +45,15 @@
41
45
  }
42
46
 
43
47
  // lead is 'logo' (the kickflip, which toggles the sidebar, sidebar.js; logoHref, the hub's page, without it)
44
- // or 'back' (the arrow, which follows backStep, with backHref the work item or the board, after the logo). The controls (time and refresh, gear) paint disabled with refresh spinning until the
48
+ // or 'back' (the arrow, which follows backStep, with backHref the work item or the board, after the logo). The controls (time and refresh) paint disabled with refresh spinning until the
45
49
  // page's script turns them on (docs/adr/2026-10-29-1639-the-board-shows-github-checks-and-watcher-health.md). With controls
46
- // false, trail (HTML) takes their place. bulb puts the architecture review's bulb between them (app-arch.js). leadAttrs adds to or replaces the lead link's attributes, and level is the
50
+ // false, trail (HTML) takes their place. pages is the switch between a board's two pages below 900px
51
+ // (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md): 'questions' puts the ? after the controls, with
52
+ // the open-question count in its badge (app-needs.js), and 'board' puts the check, back up to the board, after trail. leadAttrs adds to or replaces the lead link's attributes, and level is the
47
53
  // title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js). moon puts the
48
54
  // away moon right of the title, hidden until app-header.js renderStatus shows it (docs/adr/2026-11-04-0226-the-hub-checks-every-minute-while-youre-here-and-every-5-when-away.md).
49
55
  function header({ lead = 'logo', title = '', titleId, backHref = './', backLabel = 'Back to the board', backId, logoSrc = 'kickflip.png',
50
- controls = true, trail = '', time = '…', bulb = false, moon = false, settingsHref = 'settings.html', logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
56
+ controls = true, trail = '', time = '…', pages = null, moon = false, logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
51
57
  const attrs = (base) => Object.entries({ ...base, ...leadAttrs }).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
52
58
  const logo = (cls = 'hub') => `<a class="${cls}" href="${esc(logoHref)}"${attrs({ 'aria-label': 'Boards' })}><img src="${esc(logoSrc)}" alt=""></a>`;
53
59
  const first = lead === 'back'
@@ -55,14 +61,20 @@
55
61
  : logo();
56
62
  const nav = controls ? `<nav class="nav">`
57
63
  + `<span class="refresh"><button class="ghost spinning" id="refresh" type="button" disabled aria-label="Refresh" title="Refresh">${REFRESH}</button><span class="muted" id="status">${esc(time)}</span></span>`
58
- + (bulb ? `<button class="ghost icon arch-bulb" id="archBulb" type="button" aria-label="Architecture review" title="Architecture review" aria-haspopup="dialog">${BULB}</button>` : '')
59
- + (settingsHref ? `<a href="${esc(settingsHref)}" id="settingsLink" class="ghost icon" aria-label="Settings" title="Settings" aria-disabled="true" tabindex="-1" aria-haspopup="dialog">${GEAR}</a>` : '')
60
- + `</nav>` : trail;
64
+ + (pages === 'questions' ? `<a href="questions.html" id="questionsLink" class="ghost icon pages-switch" aria-label="Open questions" title="Open questions">${QUESTION}<span class="q-badge" hidden></span></a>` : '')
65
+ + `</nav>` : trail + (pages === 'board' ? `<a class="ghost icon pages-switch back" href="./" aria-label="Back to the board" title="Back to the board">${CHECK}</a>` : '');
61
66
  const script = lead === 'back' ? `<script>(${wireBack})(${backStep});</script>` : '';
62
67
  const h = `h${[1, 2, 3, 4, 5, 6].includes(level) ? level : 1}`;
63
68
  const away = moon ? `<span class="away-moon" role="img" aria-label="Away" aria-hidden="true">${MOON}</span>` : '';
64
69
  return `<header class="top"><div class="brand">${first}<${h}${id(titleId)}>${esc(title)}</${h}>${away}</div>${nav}</header>${script}`;
65
70
  }
66
71
 
67
- Object.assign(globalThis.BoardUI ||= {}, { header, backStep });
72
+ // The foot of the board, under the lanes: the architecture review's bulb (app-arch.js) and the gear, Board Settings,
73
+ // which paints disabled until app-header.js turns it on with the header's controls.
74
+ const boardFoot = () => '<div class="board-foot">'
75
+ + `<button class="ghost icon arch-bulb" id="archBulb" type="button" aria-label="Architecture review" title="Architecture review" aria-haspopup="dialog">${BULB}</button>`
76
+ + `<a href="settings.html" id="settingsLink" class="ghost icon" aria-label="Settings" title="Settings" aria-disabled="true" tabindex="-1" aria-haspopup="dialog">${GEAR}</a>`
77
+ + '</div>';
78
+
79
+ Object.assign(globalThis.BoardUI ||= {}, { header, backStep, boardFoot });
68
80
  })();