@lovek4d/kickflip 0.6.0 → 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 (41) hide show
  1. package/CONTEXT.md +15 -11
  2. package/SKILL.md +7 -7
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/install.mjs +3 -3
  5. package/board/bin/board.mjs +26 -12
  6. package/board/domain.mjs +7 -7
  7. package/board/headless.mjs +9 -9
  8. package/board/public/app-cards.js +23 -44
  9. package/board/public/app-lanes.js +4 -7
  10. package/board/public/app-threads.js +12 -8
  11. package/board/public/prototype-switcher.js +7 -2
  12. package/board/public/shell.js +4 -3
  13. package/board/public/sidebar.css +5 -0
  14. package/board/public/sidebar.js +3 -0
  15. package/board/public/style.css +11 -19
  16. package/board/public/ui-cards.js +21 -15
  17. package/board/public/ui-enums.js +2 -2
  18. package/board/public/ui-header.js +4 -6
  19. package/board/public/ui-hub.js +7 -4
  20. package/board/public/ui-order.js +1 -1
  21. package/board/public/ui-page-header.js +1 -1
  22. package/board/public/ui-sidebar.js +11 -5
  23. package/board/public/ui-snapshot.js +1 -1
  24. package/board/public/ui.js +3 -2
  25. package/board/public/view-hub.js +3 -1
  26. package/board/public/view-questions.js +1 -1
  27. package/board/server.mjs +20 -19
  28. package/board/stop.mjs +1 -1
  29. package/board/store.mjs +27 -25
  30. package/board/transcript.mjs +2 -2
  31. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  32. package/hub/front-door.mjs +1 -1
  33. package/hub/hub.mjs +1 -1
  34. package/hub/sessions.mjs +2 -2
  35. package/hub/try.mjs +7 -7
  36. package/hub/usage.mjs +3 -3
  37. package/package.json +1 -1
  38. package/shared/card-prs.mjs +1 -1
  39. package/shared/machine-gate.mjs +2 -2
  40. package/shared/tailscale.mjs +1 -1
  41. package/shared/usage-gate.mjs +1 -1
@@ -269,7 +269,6 @@ BoardView.part((B) => {
269
269
  const openPRs = (it) => (it.prs || []).filter((p) => p.state === 'OPEN');
270
270
  const CONFIRM = {
271
271
  resume: (it, ref) => `Unhold ${ref}?${it.heldUntil ? ` It's held until ${heldUntilWords(it.heldUntil)}.` : ''} It can start again once nothing blocks it.`,
272
- nudge: () => 'Nudge Claude to check this implementer?',
273
272
  // docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md
274
273
  merge: (it, ref) => (it.mergeReady ? `Merge ${ref}? The board rebases ${it.mergeReady.branch} onto ${it.mergeReady.base} as it is now, reruns the tests if moved, and lands it.`
275
274
  : openPRs(it).length > 1
@@ -301,21 +300,16 @@ BoardView.part((B) => {
301
300
  // (docs/adr/2026-11-05-1918-done-shows-only-in-in-review-beside-reply.md).
302
301
  const close = it.lane === 'todo' && it.finishable ? ask('finish', 'Close') : '';
303
302
  // A Todo card's Implement is in its thread box (app-threads.js; docs/adr/2026-11-05-0300-implement-in-the-thread-queues-the-card-for-the-orchestrator.md).
304
- return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${prototypesButton(it)}${mergeButton(it)}`)}</div>`;
303
+ return `<div class="actions" data-actions="${key}">${actionRow('workflow', `${hold}${close}${notesButton(it)}${tryButton(it)}${actionButton(it)}${mergeButton(it)}`)}</div>`;
305
304
  }
306
305
 
307
306
  // board/domain.mjs actionButtonOf picks the button and resolvedBy its colour (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
307
+ // Every prototype round is a question on its card, so this button reaches the prototypes too, Done cards included
308
+ // (docs/adr/2026-11-09-1100-needs-you-button-reaches-the-prototypes.md).
308
309
  const ACTION_BUTTONS = {
309
- [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Questions</a>`,
310
+ [BUTTON.openQuestions]: (it) => `<a${classes(it, BUTTON.openQuestions, 'button')} href="questions.html#${esc(it.questions.first)}">Needs you</a>`,
310
311
  };
311
312
  const actionButton = (it) => ACTION_BUTTONS[it.actionButton]?.(it) || '';
312
- // docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md
313
- // On any card with prototypes (board/domain.mjs prototypesOf), Done ones too, so old rounds stay a tap away: it opens
314
- // the viewer on the latest round, solid orange while a round waits on the owner's pick.
315
- const prototypesButton = (it) => {
316
- const latest = it.prototypes?.rounds.at(-1);
317
- return latest ? `<a class="button${it.prototypes.open ? ' resolves' : ''}" href="${esc(latest.url)}">Prototypes</a>` : '';
318
- };
319
313
 
320
314
  // The latest bump among New cards takes no second bump (docs/adr/2026-10-29-1639-a-pending-ask-can-be-deleted-or-bumped.md).
321
315
  const latestBump = (it) => Boolean(it.isNew && it.bumpedAt) && !state.board.items.some((i) => i.isNew && (i.bumpedAt || '') > it.bumpedAt);
@@ -476,7 +470,6 @@ BoardView.part((B) => {
476
470
  // A data-confirm button's tap: Hold's sheet, or its confirm, then its event. The thread box's Done comes here on ⌥↵ too.
477
471
  async function confirmTap(b) {
478
472
  const { key, confirm: act } = b.dataset;
479
- if (b.classList.contains('nudging')) return;
480
473
  const it = state.board.items.find((i) => i.key === key);
481
474
  if (it && act === 'hold') { openHold(it); return; }
482
475
  if (!it || !(await B.confirm(CONFIRM[act](it, it.ref)))) return;
@@ -484,40 +477,26 @@ BoardView.part((B) => {
484
477
  // server records them (docs/adr/2026-11-03-1850-a-tap-waits-for-the-server-to-record-it.md).
485
478
  const sent = { resume: ['card-hold', { key, hold: false }], merge: ['card-merge', { key }], 'card-build': ['card-build', { key }],
486
479
  finish: it.kind === 'ask' ? ['ask-done', { id: it.id }] : ['card-done', { key }] }[act];
487
- if (sent) {
488
- // Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
489
- // anchor it and scroll the page after it into Done (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
490
- const close = act === 'finish' ? () => {
491
- const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
492
- if (card) BoardUI.closeDetails(card);
493
- state.openItems.delete(key);
494
- store.set('openItems', [...state.openItems]);
495
- } : null;
496
- // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
497
- // In Review, or no longer merging there, which is its refusal.
498
- const pend = act === 'merge' ? `${key}:merge` : null;
499
- if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
500
- try {
501
- const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
502
- if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
503
- } catch (e) {
504
- if (pend) { BoardUI.pending.drop(pend); B.render(); }
505
- B.failed(e);
506
- }
507
- if (act === 'finish') B.syncSends(key);
508
- return;
509
- }
510
- // A stalled line's nudge calms it to orange with a ripple, not a spinner, until a board shows the card's status line
511
- // changed, the builder no longer stalled or the card gone (style.css .nudging).
512
- const id = `${key}:nudge`;
513
- const { progress, at } = it.builder;
514
- BoardUI.pending.start(id, (board) => { const c = board.items.find((i) => i.key === key); return !c?.builder?.stalled || c.builder.progress !== progress || c.builder.at !== at; }, B.stillWaiting);
515
- B.render();
480
+ // Done closes its card once the server answers, before the answer's board lands, or app-core.js steady would
481
+ // anchor it and scroll the page after it into Done (docs/adr/2026-11-02-0009-the-board-applies-the-owners-hold-resume-and-done-at-once.md).
482
+ const close = act === 'finish' ? () => {
483
+ const card = $(`details.item[data-key="${CSS.escape(key)}"]`);
484
+ if (card) BoardUI.closeDetails(card);
485
+ state.openItems.delete(key);
486
+ store.set('openItems', [...state.openItems]);
487
+ } : null;
488
+ // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md: Merge spins until a board shows the card left
489
+ // In Review, or no longer merging there, which is its refusal.
490
+ const pend = act === 'merge' ? `${key}:merge` : null;
491
+ if (pend) BoardUI.pending.start(pend, (board) => { const c = board.items.find((i) => i.key === key); return !c || c.lane !== 'review' || !c.merging; });
516
492
  try {
517
- const out = await B.sentHere(key, () => B.send('builder-nudge', { key }));
518
- BoardUI.pending.answered(id, out.board.generatedAt); toast('Nudge sent');
519
- } catch { BoardUI.pending.drop(id); toast('Could not send. Is the host awake?'); }
520
- B.render();
493
+ const out = await waitOn(b, B.sentHere(key, () => B.send(...sent, close)));
494
+ if (pend) { BoardUI.pending.answered(pend, out.board.generatedAt); B.render(); }
495
+ } catch (e) {
496
+ if (pend) { BoardUI.pending.drop(pend); B.render(); }
497
+ B.failed(e);
498
+ }
499
+ if (act === 'finish') B.syncSends(key);
521
500
  }
522
501
 
523
502
  Object.assign(B, { captureStrips, moveStrips, detail, card, confirmTap });
@@ -6,7 +6,6 @@ BoardView.part((B) => {
6
6
  const { esc, escText, waitOn } = BoardUI;
7
7
  const LANES = [
8
8
  { id: 'review', name: 'In Review', open: true },
9
- { id: 'stale', name: 'Stale', open: true, hideEmpty: true },
10
9
  { id: 'progress', name: 'In Progress', open: true },
11
10
  { id: 'todo', name: 'Todo', open: true },
12
11
  { id: 'done', name: 'Done', open: false, capped: true },
@@ -93,7 +92,6 @@ BoardView.part((B) => {
93
92
  return reviewsHtml(d) + LANES.map((lane) => {
94
93
  // Items arrive in each lane's fixed order (domain.mjs laneOrder).
95
94
  const items = shown.filter((i) => i.lane === lane.id);
96
- if (lane.hideEmpty && !items.length) return '';
97
95
  const expanded = laneState[lane.id] ?? lane.open;
98
96
  const open = items.filter((i) => !i.fold);
99
97
  const rows = lane.id === 'todo' ? todoRows(d, shown, open) : (lane.id === 'done' ? doneRows(d, open) : lane.capped ? cappedRows(lane.id, open) : waveGroups(lane.id, open)) + waveFolds(d, items);
@@ -108,19 +106,18 @@ BoardView.part((B) => {
108
106
  // docs/adr/2026-11-05-0215-every-lane-groups-its-cards-by-wave.md
109
107
  // Every lane but Todo groups its cards under a header per open wave (state.board.waveBands) that has cards there, in
110
108
  // band order, then the cards in no wave under No wave. A lane with no card in a wave lists its cards plainly. A header
111
- // counts its wave's cards in Todo, In Progress (Stale included), In Review and Done as x/y/k/z, from every lane, with
109
+ // counts its wave's cards in Todo, In Progress, In Review and Done as x/y/k/z, from every lane, with
112
110
  // the lane's own number bold in that lane's colour (style.css: review blue, progress orange, Todo and Done gray) and a tooltip that spells the counts out.
113
111
  // A tap on the counts opens the tooltip (countsShown, the header's lane/wave); a second tap or a tap elsewhere closes it.
114
112
  let countsShown = null;
115
113
  const COUNT_LANES = [['todo', 'in Todo'], ['progress', 'in progress'], ['review', 'in review'], ['done', 'done']];
116
114
  function waveCounts(band, laneId) {
117
- const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane === 'stale' ? 'progress' : i.lane), new Map());
115
+ const lanes = state.board.items.reduce((m, i) => m.set(i.key, i.lane), new Map());
118
116
  const n = COUNT_LANES.map(([id]) => band.keys.filter((k) => lanes.get(k) === id).length);
119
- const here = laneId === 'stale' ? 'progress' : laneId;
120
117
  const id = `${laneId}/${band.slug}`;
121
118
  const spelled = COUNT_LANES.map(([, word], at) => `${n[at]} ${word}`);
122
119
  const tip = `<span class="wb-tip" role="tooltip">${spelled.map((t) => `<div>${t}</div>`).join('')}</span>`;
123
- return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === here ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
120
+ return `<button type="button" class="wb-counts${countsShown === id ? ' open' : ''}" data-counts="${esc(id)}" aria-label="${spelled.join(', ')}">${COUNT_LANES.map(([lane], at) => `<span${lane === laneId ? ` class="wv-cur wv-${lane}"` : ''}>${n[at]}</span>`).join('<span aria-hidden="true">/</span>')}${tip}</button>`;
124
121
  }
125
122
  function waveGroups(laneId, cards) {
126
123
  const bands = state.board.waveBands || [];
@@ -142,7 +139,7 @@ BoardView.part((B) => {
142
139
  let goalShown = null;
143
140
  const bandStore = (slug) => FOLDS[`band:${slug}`].store;
144
141
  const bandOpen = (band, first) => store.get(bandStore(band.slug), band.slug === first);
145
- const LANE_WORDS = { progress: 'in progress', review: 'in review', stale: 'stale' };
142
+ const LANE_WORDS = { progress: 'in progress', review: 'in review' };
146
143
  const DOTS = '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><circle cx="5" cy="12" r="1.8"/><circle cx="12" cy="12" r="1.8"/><circle cx="19" cy="12" r="1.8"/></svg>';
147
144
 
148
145
  // Todo's wave bands and loose cards, in the order they arrive (domain.mjs laneOrder).
@@ -1,7 +1,7 @@
1
1
  // A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
2
2
  BoardView.part((B) => {
3
3
  const { $, store, state, quieted, sendDraft, links } = B;
4
- const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine } = BoardUI;
4
+ const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine, BUTTON } = BoardUI;
5
5
 
6
6
  const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more', visual: 'visual-request' };
7
7
  // A /prototype, /grill or /visual word in a message, the last one winning, and the message without it.
@@ -16,14 +16,18 @@ BoardView.part((B) => {
16
16
  // A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
17
17
  // action: builder orange once the card is ready, quiet before. An In Review card's box has Done then Reply
18
18
  // (ui-threads.js doneButton), Done in its tone. Any other card's box sends with Send, carrying no intent. A pair wraps
19
- // onto its own line under the row.
20
- const REVIEW = new Set(['review', 'stale']);
21
- const sendsOf = (item) => (item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
22
- : REVIEW.has(item?.lane) ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one');
19
+ // onto its own line under the row. Send or Reply is orange while a message clears the card's Action needed, as on a
20
+ // card the orchestrator stalled (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md).
21
+ const replyTone = (item) => (item?.resolvedBy === BUTTON.reply ? 'resolves' : undefined);
22
+ const sendsOf = (item) => [item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
23
+ : item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
23
24
  const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
24
- const sends = (kind, item) => (kind === 'one' ? `<span data-sends="one">${sendButton('Send', 0)}</span>`
25
- : kind.startsWith('review') ? pair(kind, `${doneButton(item)}${sendButton('Reply', 0, ' data-intent="reply"')}`)
26
- : pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${sendButton('Reply', 0, ' data-intent="reply"')}`));
25
+ const sends = (kind, item) => {
26
+ const reply = (label, attrs = '') => sendButton(label, 0, attrs, replyTone(item));
27
+ return kind.startsWith('one') ? `<span data-sends="${kind}">${reply('Send')}</span>`
28
+ : kind.startsWith('review') ? pair(kind, `${doneButton(item)}${reply('Reply', ' data-intent="reply"')}`)
29
+ : pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${reply('Reply', ' data-intent="reply"')}`);
30
+ };
27
31
  // The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
28
32
  function syncSends(el, key) {
29
33
  const at = el.querySelector('form [data-sends]');
@@ -122,7 +122,12 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
122
122
  .ps-switch .ps-text[hidden] { display:none; }
123
123
  .ps-picked { padding:12px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
124
124
  .ps-note { background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
125
- .ps-note[hidden] { display:none; }`;
125
+ .ps-note[hidden] { display:none; }
126
+ @media (min-width: 900px) {
127
+ .ps-pill { width:min(640px, 100%); }
128
+ .ps-switch .ps-text { width:min(640px, 100%); }
129
+ .ps-states { flex-wrap:wrap; overflow-x:visible; }
130
+ }`;
126
131
 
127
132
  const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
128
133
 
@@ -165,7 +170,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
165
170
  function paint() {
166
171
  const now = state();
167
172
  pill.innerHTML = pillHtml({ name: takes[take], pick: now, states, state: shownState });
168
- text.hidden = !(now.kind === 'pick' || now.kind === 'change');
173
+ text.hidden = !(now.kind === 'pick' || now.kind === 'change' || now.kind === 'picked');
169
174
  }
170
175
  function go(next) {
171
176
  take = next;
@@ -35,6 +35,10 @@ window.AppShell = (function () {
35
35
  channel.port1.onmessage = done;
36
36
  try { controller.postMessage({ forget: 'shell' }, [channel.port2]); } catch { done(); }
37
37
  });
38
+ // Only this reload starts at the top: iOS put the old offset back once the page had grown and left that area unpainted until a scroll
39
+ // (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md). pagehide gives ordinary reloads their restore back.
40
+ history.scrollRestoration = 'manual';
41
+ addEventListener('pagehide', () => { history.scrollRestoration = 'auto'; }, { once: true });
38
42
  setTimeout(() => location.reload(), 0);
39
43
  }
40
44
 
@@ -44,9 +48,6 @@ window.AppShell = (function () {
44
48
  }
45
49
 
46
50
  async function start() {
47
- // The shell sends every view to the top itself, so the browser's own scroll restore is off: after a reload iOS put the old
48
- // offset back once the page had grown, and left that area unpainted until a scroll (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md).
49
- history.scrollRestoration = 'manual';
50
51
  // A frame that lands on a view's URL, such as a framed page's link back to its card, shows it in the shell around it.
51
52
  if (outer()?.open(location.href, { frames: false })) return;
52
53
  // The same URL and scope as hub-kit.js's push registration, so the one registration and its push subscription stay.
@@ -41,6 +41,11 @@
41
41
  html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
42
42
  html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
43
43
  content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
44
+ /* A busy hub gets an orange dot on the logo and a broken one a red dot, in the update dot's place (red, then orange, then blue). */
45
+ html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
46
+ html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub::after, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub::after {
47
+ content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--color-notice); box-shadow: 0 0 0 2px var(--bg); }
48
+ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-sidebar > :not(.sidebar) > .top .hub::after { background: var(--color-danger); }
44
49
  .sidebar .sb-text { flex: 1; min-width: 0; }
45
50
  .sidebar .sb-title { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
46
51
  .sidebar .sb-moon { display: inline-block; margin-left: 5px; color: var(--muted); vertical-align: -1px; }
@@ -102,6 +102,9 @@
102
102
  // A repaint keeps the drawer's scroll, so a new copy doesn't move the list under a finger.
103
103
  function paint() {
104
104
  html.classList.toggle('sb-update', Boolean(update));
105
+ const dot = globalThis.BoardUI?.hubDot?.(data);
106
+ html.classList.toggle('sb-hub-busy', dot === 'busy');
107
+ html.classList.toggle('sb-hub-down', dot === 'down');
105
108
  if (!data) return;
106
109
  const scroll = aside.querySelector('.sb-scroll');
107
110
  const top = painted ? scroll?.scrollTop : undefined;
@@ -86,6 +86,9 @@ button.chip { border: 0; }
86
86
  .card-blocked, .card-action { color: var(--color-notice); }
87
87
  .card-ready { color: var(--color-success); }
88
88
  .card-staging { color: var(--color-notice); }
89
+ /* The footer status of a stale card, and of one the orchestrator stalled (ui-cards.js timeLine). */
90
+ .card-stale { color: var(--muted); }
91
+ .card-stalled { color: var(--color-danger); }
89
92
  .sub.next.idle { color: var(--muted); }
90
93
  .sub.refused { color: var(--color-danger); }
91
94
  .toast-stack { position: fixed; left: 50%; bottom: calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom));
@@ -197,7 +200,6 @@ button.link { background: none; border: 0; color: var(--accent); padding: 8px 12
197
200
  .lane-github { margin-left: auto; font-size: 12px; font-weight: 400; color: var(--muted); text-align: right; }
198
201
  .lane-github .late { color: var(--color-notice); }
199
202
  .msg.other { background: transparent; border: 1px solid var(--line); }
200
- .status.stale { border-color: var(--color-danger); background: conic-gradient(var(--color-danger) 0 75%, transparent 75% 100%); }
201
203
  .msg .reply, .msg .small, .rep, .sent { overflow-wrap: anywhere; }
202
204
  code { overflow-wrap: anywhere; }
203
205
  /* iOS Safari zooms the page when a field under 16px takes focus, which shifts the page width. */
@@ -376,20 +378,6 @@ button.attach:disabled { opacity: .35; }
376
378
  @keyframes pulse-fill { 50% { background-color: color-mix(in srgb, var(--bar-fill) 35%, transparent); } }
377
379
  /* Every pulse beats on one clock (ui-progress-bar.js onePulse), so a card's running bar and its status dot blink together. */
378
380
  @media (prefers-reduced-motion: no-preference) { .phase-bars i.now { animation: pulse-fill 1.4s ease-in-out infinite; } }
379
- /* A stale build's status line, dot included, is the needs-you red, and a tap on it nudges Claude (ui-cards.js builderStatus). */
380
- /* A status line that is a button is drawn as the div it replaces: this one reset, then only a colour per state. The font
381
- shorthand stays out so .sub's 12px wins. It sets no width: a button is its own block formatting context, so a full-width one
382
- drops below the bump icons' float instead of sitting beside it as the div's line box does (board/layout.test.mjs). */
383
- button.sub { display: block; margin-inline: 0; margin-bottom: 0; padding: 0; min-height: 0; border: 0; border-radius: 0; background: none;
384
- font-family: inherit; font-weight: inherit; letter-spacing: inherit; line-height: inherit; text-align: left; appearance: none; -webkit-appearance: none; }
385
- button.sub.agent.stalled { color: var(--color-danger); }
386
- /* A tap on it calms the line to the in-progress orange, its dot sending a ripple, until the card's next status line (app-cards.js nudge). */
387
- button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default; }
388
- .nudging .working-dot { position: relative; }
389
- .nudging .working-dot::after { content: ''; position: absolute; inset: -1px; border-radius: 50%; border: 1.5px solid currentColor; opacity: 0; }
390
- @keyframes nudge-ping { from { transform: scale(1); opacity: .9; } to { transform: scale(2.6); opacity: 0; } }
391
- @media (prefers-reduced-motion: no-preference) { .nudging .working-dot::after { animation: nudge-ping 1.1s ease-out infinite; } }
392
- .stalled .working-dot { background: currentColor; }
393
381
  .brand { display: grid; grid-template-columns: auto auto minmax(0, 1fr); column-gap: 10px; row-gap: 2px; }
394
382
  .brand .hub { grid-row: 1 / span 2; align-self: center; }
395
383
  /* The session's working line, "thinking", is the owner's blue with one font, size, weight and pulse on the card and in the thread. */
@@ -417,6 +405,9 @@ button.sub.agent.stalled.nudging { color: var(--color-notice); cursor: default;
417
405
  has picked up are grey, after the lane colours so they win. */
418
406
  .sub.agent.quiet, .sub.asked { color: var(--muted); }
419
407
  .sub.agent.waiting-gray { color: var(--muted); }
408
+ /* A stale build's status line is grey, its dot still (ui-cards.js builderStatus). */
409
+ .sub.agent.stale { color: var(--muted); }
410
+ .sub.agent.stale .working-dot { animation: none; }
420
411
  /* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
421
412
  .paused { color: var(--muted); font-weight: 600; }
422
413
  .phase-bars.in-todo { --bar-fill: var(--muted); }
@@ -521,7 +512,7 @@ button.attach[aria-pressed="true"] { color: var(--accent); background: var(--chi
521
512
  }
522
513
  @keyframes refresh-spin { to { transform: rotate(360deg); } }
523
514
  /* INT-55: the header's right group (the time then ↻, settings) sits one gap apart; each control paints disabled until app-header.js wires it */
524
- .top .nav { gap: 8px; }
515
+ .top .nav { gap: 2px; }
525
516
  .top .nav .refresh { flex-direction: row-reverse; gap: 2px; }
526
517
  .top .nav #refresh, .top .nav a.icon { display: inline-flex; align-items: center; justify-content: center; margin: 0; padding: 4px; border: 0; line-height: 1; }
527
518
  .top .nav #status { line-height: 1.1; white-space: nowrap; }
@@ -633,12 +624,12 @@ main > * { transition: opacity .25s ease; }
633
624
  .view-questions .q { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 12px; margin: 12px 0; }
634
625
  .view-questions .q.urgent { border-color: var(--color-notice); }
635
626
  /* docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md
636
- A requested question takes an accent tint and a sent or resolved one a green tint, each with its border drawn inside
637
- the card as well, so the card keeps its size. */
627
+ A requested question takes an accent tint, with its border drawn inside the card as well, so the card keeps its size.
628
+ A resolved question has no border or tint of its own; its Resolved badge marks it
629
+ (docs/adr/2026-11-06-1200-a-resolved-question-drops-its-green-border-and-tint.md). */
638
630
  .view-questions .q.pr-busy { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 9%, var(--panel)); box-shadow: inset 0 0 0 1px var(--accent); }
639
631
  .view-questions .q.pr-busy .opt { opacity: .55; cursor: default; }
640
632
  .view-questions .q.pr-busy .opt:has(input:checked) { opacity: 1; }
641
- .view-questions .q.resolved { border-color: var(--color-success); background: color-mix(in srgb, var(--color-success) 9%, var(--panel)); box-shadow: inset 0 0 0 1px var(--color-success); }
642
633
  .view-questions .q.resolved .sent-line { margin: 6px 0; font-size: 13px; color: var(--color-success); }
643
634
  .view-questions .q h3 { font-size: 15px; margin: 6px 0 4px; line-height: 1.3; }
644
635
  .view-questions .ctx { color: var(--muted); font-size: 13px; margin-bottom: 8px; }
@@ -953,6 +944,7 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
953
944
  .needs-deck { display: none; }
954
945
  /* Waiting on you is the board's pane here, so neither page shows the switch to the other. */
955
946
  .top .pages-switch { display: none; }
947
+ .top .nav a.pages-switch { display: none; }
956
948
  /* The board's + sits at the bottom right of the left pane: the pane ends at the middle of the space right of the sidebar. */
957
949
  .view-board .fab { right: calc(50% - var(--sidebar-w, 0px) / 2 + var(--fab-edge)); }
958
950
  body.has-sidebar { --sidebar-w: 272px; }
@@ -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);
@@ -194,7 +199,7 @@
194
199
  const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
195
200
  if (it.builder && it.builder.kind !== BUILDER_KIND.install) {
196
201
  const done = BUILD_PHASES.indexOf(it.builder.phase) + 1;
197
- return { count: all, done, now: done ? done - 1 : undefined, dim: asking || Boolean(it.builder.stalled || it.builder.quiet), label: `${done ? `${name(it.builder.phase)}, ${stepWords(it.builder.phase)}` : 'Starting, no step yet'}${answers}` };
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}` };
198
203
  }
199
204
  const dim = !it.builder || asking;
200
205
  if (it.reopened) return { count: all, done: all, dim, label: `Reopened, ${ALL_DONE}${answers}` };
@@ -221,16 +226,15 @@
221
226
  const words = isPaused() ? `${PAUSED} · ${esc(lasted(picked || since))}` : picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`;
222
227
  return `<div class="line3 sub agent${tone(it)}${picked && !isPaused() ? '' : ' waiting-gray'}">${words}</div>`;
223
228
  }
224
- // A stale or stopped build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
225
- // A quiet one is grey, with no dot, and one the orchestrator stopped plain, since the orchestrator already knows.
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.
226
232
  function builderStatus(it) {
227
233
  const { quiet } = it.builder;
228
- const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}${quiet ? ' quiet' : ''}`;
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' : ''}`;
229
236
  if (it.builder.stopped) return `<div class="${classes}">${esc(builderLine(it.builder))}</div>`;
230
- const words = `${quiet ? '' : DOT}${esc(builderLine(it.builder))}`;
231
- if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
232
- const nudging = BoardUI.pending.has(`${it.key}:nudge`) ? ' nudging' : '';
233
- 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>`;
237
+ return `<div class="${classes}">${quiet ? '' : DOT}${esc(builderLine(it.builder))}</div>`;
234
238
  }
235
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
236
240
  // (docs/adr/2026-11-05-0410-a-card-status-word-comes-from-a-signal-the-board-checks.md).
@@ -264,14 +268,16 @@
264
268
  return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
265
269
  }
266
270
  // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
267
- // staging in orange or ready in green (docs/adr/2026-11-07-0700-staging-then-ready.md), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
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
268
273
  // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
269
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).
270
275
  const SEP = '<span class="sep"> · </span>';
271
276
  const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
272
277
  function timeLine(it, unread, bars) {
273
278
  const iso = it.sortAt;
274
- 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>'].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);
275
281
  const news = newWords(unread, 'message');
276
282
  const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
277
283
  const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
@@ -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', done: 'done', 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,26 +1,24 @@
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). It reads that age, or busy while the Mac is busy;
4
+ // The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh). It reads that age;
5
5
  // the board's other states only move the span's colour (the arrow follows it, style.css) and its title. watcher is
6
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 Mac (pause.machine,
8
- // docs/adr/2026-11-06-0106-a-busy-mac-holds-new-work.md) turns it orange too and reads busy in place of the age.
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
9
  // link is 'online', 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it, and the icon spins).
10
10
  const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => {
11
11
  const paused = pause?.state === 'paused';
12
- const busy = pause?.machine?.state === 'busy';
13
12
  const down = link === 'offline' || watcher === 'stopped';
14
13
  const reason = link === 'offline' ? "Offline: can't reach the host"
15
14
  : watcher === 'stopped' ? 'Claude not listening'
16
15
  : paused ? `Paused ${BoardUI.pauseUntilWords(pause.resets_at)}`
17
- : busy ? 'This Mac is busy: new work waits until it eases'
18
16
  : link === 'reconnecting' ? 'Reconnecting…'
19
17
  : watcher === 'waking' || (waking && watcher === 'asleep') ? 'Waking: the hub is resuming its session'
20
18
  : watcher === 'asleep' ? 'Asleep: its session idled out. Refresh, or any tap on the page, wakes it' : '';
21
19
  if (!at && !reason) return '';
22
20
  const title = [reason, at && `Updated ${since(at)}: the last change from the host, or your last refresh`].filter(Boolean).join('. ');
23
- return `<span class="fresh${down ? ' down' : paused || busy ? ' hold' : ''}" title="${esc(title)}">${busy ? 'busy' : at ? shortSince(at) : ''}</span>`;
21
+ return `<span class="fresh${down ? ' down' : paused ? ' hold' : ''}" title="${esc(title)}">${at ? shortSince(at) : ''}</span>`;
24
22
  };
25
23
  // What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
26
24
  const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
@@ -34,14 +34,17 @@
34
34
  // hold: hub/views.mjs hubView's hold, or null for no banner. Its End form posts snooze=off, which view-hub.js sends.
35
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>` : '');
36
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).
37
39
  function pauseBanner(pause) {
38
- if (!pause || pause.state === 'normal') return '';
39
- const going = pause.state === 'going';
40
- 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);
41
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"/>';
42
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.
43
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>`;
44
- const text = going ? `Past the buffer: spending usage until ${until}`
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}`
45
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}.`;
46
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>`;
47
50
  }
@@ -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
@@ -10,7 +10,7 @@
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
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
12
  // like the bulb and gear.
13
- const QUESTION = icon(18, '<path d="M8 8a4 4 0 1 1 5.5 3.7c-.9.4-1.5 1.2-1.5 2.2V15"/><path d="M12 19.5h.01"/>');
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
14
  const CHECK = icon(18, '<path d="M20 6 9 17l-5-5"/>');
15
15
  const id = (v) => (v ? ` id="${esc(v)}"` : '');
16
16
 
@@ -196,14 +196,20 @@
196
196
  // update: null | 'ready' | 'reloading'.
197
197
  // now: the time a starting board's seconds count to. A shared person's data (data.shared; hub/share.mjs sharedSidebar)
198
198
  // draws their boards alone: no Hub row, update or notification rows, and no New board.
199
+ // Whether the Hub row reads busy: the host is busy and no usage pause reads first.
200
+ const hubBusy = (data) => data?.pause?.machine?.state === 'busy' && data?.pause?.state !== 'paused';
201
+ // The logo's dot for the hub's state, like the blue update dot: 'down' (red) for a broken hub, 'busy' (orange), else ''.
202
+ const hubDot = (data) => (data?.hub?.tone === 'down' ? 'down' : hubBusy(data) ? 'busy' : '');
203
+
199
204
  function sidebar(data, { here = '', device = null, switching = false, update = null, now = Date.now() } = {}) {
200
205
  const sites = data?.sites || [];
201
206
  const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
202
207
  const paused = data?.pause?.state === 'paused';
203
- // A busy Mac holds every board's new work, and a pause reads before it (docs/adr/2026-11-06-0106-a-busy-mac-holds-new-work.md).
204
- const busy = data?.pause?.machine?.state === 'busy';
208
+ // A busy host holds every board's new work but shows only on the Hub row and the logo's dot, never on a board, and a pause reads before it
209
+ // (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
210
+ const busy = hubBusy(data);
205
211
  // A board under Pause board carries its own pause (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md).
206
- const holdWord = (s) => (paused || s.pause?.state === 'paused' ? word('paused', 'paused') : busy ? word('wait', 'busy') : '');
212
+ const holdWord = (s) => (paused || s.pause?.state === 'paused' ? word('paused', 'paused') : '');
207
213
  const siteRow = (s) => {
208
214
  const sub = s.board && holdWord(s) ? holdWord(s) + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
209
215
  return row({ href: siteHref(s), icon: s.icon, title: s.title, sub: sub ? `<span class="sb-status">${sub}</span>` : '', ctx: contextWords(s.ctx), on: s.route === here, key: keys?.get(siteHref(s)) });
@@ -221,7 +227,7 @@
221
227
  : device === 'install' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Add Claude to your Home Screen', sub: 'Notifications come only to the Home Screen app', cls: 'sb-turnon' })
222
228
  : '';
223
229
  const usage = sidebarUsage(data?.usage);
224
- const hub = paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'Mac is busy' } : data?.hub;
230
+ const hub = paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
225
231
  const n = data?.notifications || {};
226
232
  const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
227
233
  const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
@@ -249,5 +255,5 @@
249
255
  + `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
250
256
  }
251
257
 
252
- Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, START_STEPS, startSteps, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, pauseUntilWords, usageTone, usageBar, usageWords });
258
+ Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, START_STEPS, startSteps, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, pauseUntilWords, usageTone, usageBar, usageWords, hubDot });
253
259
  })();
@@ -1,7 +1,7 @@
1
1
  // The board this device last held, saved in IndexedDB with its change number, which the board page paints live on open
2
2
  // and catches up from (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md). Needs ui.js.
3
3
  (function () {
4
- const ACTIVE_LANES = ['review', 'stale', 'progress', 'todo'];
4
+ const ACTIVE_LANES = ['review', 'progress', 'todo'];
5
5
 
6
6
  // threads maps item keys to thread messages (app.js state.threadCache). → {build, savedAt, board, threads} with the
7
7
  // threads of the cards on the board; build is the saving page's (app.html data-build).