@lovek4d/kickflip 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CONTEXT.md +69 -37
  2. package/SKILL.md +19 -13
  3. package/bin/kickflip.mjs +12 -12
  4. package/bin/wait-events.mjs +2 -2
  5. package/board/agents.mjs +2 -2
  6. package/board/arch-review.mjs +4 -4
  7. package/board/bin/board.mjs +156 -83
  8. package/board/build-script.mjs +2 -1
  9. package/board/domain.mjs +9 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +9 -5
  12. package/board/onboarding.mjs +11 -0
  13. package/board/prototype-base.mjs +1 -0
  14. package/board/prototype-page.mjs +1 -0
  15. package/board/public/app-arch.js +1 -1
  16. package/board/public/app-cards.js +27 -13
  17. package/board/public/app-header.js +6 -7
  18. package/board/public/app-lanes.js +15 -38
  19. package/board/public/app-needs.js +36 -31
  20. package/board/public/app-snapshot.js +43 -70
  21. package/board/public/app-threads.js +38 -46
  22. package/board/public/app.html +9 -4
  23. package/board/public/app.js +78 -32
  24. package/board/public/hub-kit.js +62 -2
  25. package/board/public/hub.css +2 -0
  26. package/board/public/new-task.js +8 -6
  27. package/board/public/progress-review.html +46 -0
  28. package/board/public/progress-review.js +158 -0
  29. package/board/public/prototype-kit.js +109 -4
  30. package/board/public/prototype-switcher.js +123 -29
  31. package/board/public/prototype.html +57 -3
  32. package/board/public/router.js +7 -6
  33. package/board/public/shell.js +64 -24
  34. package/board/public/sidebar.css +1 -0
  35. package/board/public/sidebar.js +16 -69
  36. package/board/public/style.css +108 -57
  37. package/board/public/ui-cards.js +61 -46
  38. package/board/public/ui-enums.js +1 -1
  39. package/board/public/ui-header.js +25 -16
  40. package/board/public/ui-hub.js +12 -1
  41. package/board/public/ui-links.js +7 -7
  42. package/board/public/ui-page-header.js +19 -7
  43. package/board/public/ui-patch.js +7 -33
  44. package/board/public/ui-progress-bar.js +28 -0
  45. package/board/public/ui-questions.js +25 -17
  46. package/board/public/ui-sidebar.js +25 -23
  47. package/board/public/ui-snapshot.js +43 -52
  48. package/board/public/ui-threads.js +57 -28
  49. package/board/public/ui.js +92 -17
  50. package/board/public/view-hub-settings.js +12 -58
  51. package/board/public/view-hub.js +2 -1
  52. package/board/public/view-notes.js +27 -7
  53. package/board/public/view-questions.js +90 -68
  54. package/board/public/view-settings.js +133 -15
  55. package/board/render-check.mjs +10 -0
  56. package/board/review.mjs +1 -1
  57. package/board/server.mjs +19 -19
  58. package/board/stop.mjs +1 -0
  59. package/board/store.mjs +27 -18
  60. package/board/sync.mjs +2 -0
  61. package/board/tickets.mjs +3 -3
  62. package/board/worktree.mjs +22 -21
  63. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  64. package/hub/board-repo.mjs +1 -1
  65. package/hub/boards.mjs +4 -4
  66. package/hub/front-door.mjs +1 -1
  67. package/hub/github.mjs +3 -3
  68. package/hub/hub.mjs +11 -11
  69. package/hub/incoming.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/pr-report.mjs +2 -2
  72. package/hub/review-send.mjs +1 -1
  73. package/hub/sessions.mjs +2 -2
  74. package/hub/share.mjs +1 -1
  75. package/hub/slack.mjs +2 -2
  76. package/hub/try.mjs +7 -7
  77. package/hub/upgrade.mjs +1 -1
  78. package/hub/usage.mjs +3 -3
  79. package/hub/views.mjs +1 -1
  80. package/package.json +1 -1
  81. package/shared/card-prs.mjs +1 -1
  82. package/shared/machine-gate.mjs +2 -0
  83. package/shared/prototype-frame.js +1 -0
  84. package/shared/usage-gate.mjs +1 -1
  85. package/board/public/ui-phase-bars.js +0 -17
@@ -3,7 +3,7 @@
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' });
6
+ const BUTTON = Object.freeze({ openQuestions: 'openQuestions', done: 'done', github: 'github', merge: 'merge' });
7
7
  const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done' });
8
8
  const BUILDER_KIND = Object.freeze({ install: 'install' });
9
9
  const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
@@ -1,19 +1,27 @@
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, or busy while the Mac is busy;
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 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.
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 busy = pause?.machine?.state === 'busy';
13
+ const down = link === 'offline' || watcher === 'stopped';
14
+ const reason = link === 'offline' ? "Offline: can't reach the host"
15
+ : watcher === 'stopped' ? 'Claude not listening'
16
+ : paused ? `Paused ${BoardUI.pauseUntilWords(pause.resets_at)}`
17
+ : busy ? 'This Mac is busy: new work waits until it eases'
18
+ : link === 'reconnecting' ? 'Reconnecting…'
19
+ : watcher === 'waking' || (waking && watcher === 'asleep') ? 'Waking: the hub is resuming its session'
20
+ : watcher === 'asleep' ? 'Asleep: its session idled out. Refresh, or any tap on the page, wakes it' : '';
21
+ if (!at && !reason) return '';
22
+ 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>`;
24
+ };
17
25
  // What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
18
26
  const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
19
27
  // fresh is {at, sig}: the header's time and what the page showed then. via is 'refresh' (a ↻ tap), 'signal' (the
@@ -83,10 +91,11 @@
83
91
 
84
92
  // Which header controls take taps, and whether refresh spins. The header paints them disabled with refresh spinning;
85
93
  // 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 } = {}) => {
94
+ // 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),
95
+ // 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).
96
+ const headerControls = ({ wired = false, boardSettled = false, reloading = false } = {}) => {
88
97
  const ready = wired && boardSettled && !reloading;
89
- return { refresh: ready, settings: ready && link === 'online', spin: !boardSettled || reloading };
98
+ return { refresh: ready, settings: ready, spin: !boardSettled || reloading };
90
99
  };
91
100
 
92
101
  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"/>',
@@ -38,11 +39,21 @@
38
39
  const going = pause.state === 'going';
39
40
  const until = BoardUI.usageClock(pause.resets_at);
40
41
  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
+ // 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
+ 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>`;
41
44
  const text = going ? `Past the buffer: spending usage until ${until}`
42
45
  : `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
46
  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
47
  }
45
48
 
49
+ // Health's Machine row: the gate's machine (shared/machine-gate.mjs), busy with its reason or normal, in words only
50
+ // (docs/adr/2026-11-06-0106-a-busy-mac-holds-new-work.md).
51
+ const machineRow = (machine) => {
52
+ const busy = machine.state === 'busy';
53
+ return row({ icon: tile('power', busy ? '#f59e0b' : '#22c55e'), title: 'Machine', wrap: true,
54
+ sub: busy ? `<span class="st wait">busy</span> · ${esc(machine.reason)}` : '<span class="st ok">normal</span>' });
55
+ };
56
+
46
57
  // builder: the word for who builds Codex's work, hub/views.mjs hubView's claude.builder.
47
58
  function bufferControl(pause, builder) {
48
59
  const at = pause.pause_at;
@@ -108,5 +119,5 @@
108
119
  return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
109
120
  }
110
121
 
111
- globalThis.HubUI = { esc, tile, CHEVRON, row, flash, holdBanner, pauseBanner, nowRow, deviceRow, usageCard };
122
+ globalThis.HubUI = { esc, TILE_PATHS, tile, CHEVRON, row, flash, holdBanner, pauseBanner, machineRow, nowRow, deviceRow, usageCard };
112
123
  })();
@@ -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;
@@ -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="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"/>');
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
  })();
@@ -1,41 +1,15 @@
1
- // The board's per-card patches (docs/adr/2026-11-04-0332-the-board-sends-per-card-patches.md): server.mjs makes one
2
- // from the board a page holds to the board now, and the page applies it to the board it holds. Cards patch by key,
3
- // questions and topics by id, each sent whole when it changed or dropped when gone, with the list's new order when
4
- // that changed; every other part of the board comes whole when it changed.
5
- // Needs nothing else: board/server.mjs runs this file in a vm context.
1
+ // Applies the board's patches on the page (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md): board/sync.mjs
2
+ // patchSince makes one from the change number the page holds, and the page applies it to the board it holds. Cards
3
+ // patch by key, questions and topics by id, each sent whole when it changed or dropped when gone, with the list's new
4
+ // order when that changed; every other part of the board comes whole when it changed. onHost rides beside the board,
5
+ // so it never moves the board's number.
6
6
  (function () {
7
7
  const LISTS = { items: 'key', owner: 'id', ownerTopics: 'id' };
8
- const OWN = new Set(['version', 'generatedAt', ...Object.keys(LISTS)]);
9
- const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
10
-
11
- function listPatch(from, to, id) {
12
- const was = new Map(from.map((r) => [r[id], r]));
13
- const now = new Set(to.map((r) => r[id]));
14
- const set = to.filter((r) => !was.has(r[id]) || !same(was.get(r[id]), r));
15
- const drop = from.map((r) => r[id]).filter((k) => !now.has(k));
16
- if (!set.length && !drop.length && same(from.map((r) => r[id]), to.map((r) => r[id]))) return null;
17
- const placed = [...from.map((r) => r[id]).filter((k) => now.has(k)), ...to.map((r) => r[id]).filter((k) => !was.has(k))];
18
- const order = to.map((r) => r[id]);
19
- return { set, drop, ...(same(placed, order) ? {} : { order }) };
20
- }
21
-
22
- // from and to are page boards, each with its version. → {since, version, generatedAt, set, unset, lists}
23
- function boardPatch(from, to) {
24
- const set = {};
25
- for (const [k, v] of Object.entries(to)) if (!OWN.has(k) && (!(k in from) || !same(from[k], v))) set[k] = v;
26
- const unset = Object.keys(from).filter((k) => !OWN.has(k) && !(k in to));
27
- const lists = {};
28
- for (const [name, id] of Object.entries(LISTS)) {
29
- const p = listPatch(from[name] || [], to[name] || [], id);
30
- if (p) lists[name] = p;
31
- }
32
- return { since: from.version, version: to.version, generatedAt: to.generatedAt, set, unset, lists };
33
- }
34
8
 
35
9
  // → a new board, leaving board as it was, or null when the patch was made against another version.
36
10
  function applyPatch(board, patch) {
37
11
  if (!board || board.version !== patch.since) return null;
38
- const next = { ...board, ...patch.set, version: patch.version, generatedAt: patch.generatedAt };
12
+ const next = { ...board, ...patch.set, version: patch.version, generatedAt: patch.generatedAt, onHost: patch.onHost };
39
13
  for (const k of patch.unset) delete next[k];
40
14
  for (const [name, p] of Object.entries(patch.lists)) {
41
15
  const id = LISTS[name];
@@ -49,5 +23,5 @@
49
23
  return next;
50
24
  }
51
25
 
52
- Object.assign(globalThis.BoardUI ||= {}, { boardPatch, applyPatch });
26
+ Object.assign(globalThis.BoardUI ||= {}, { applyPatch });
53
27
  })();
@@ -0,0 +1,28 @@
1
+ // The progress bar every bar on the board draws from: an In Progress card's five build phases and a Todo card's planner
2
+ // dashes (ui-cards.js), and a starting board's three start steps (ui-sidebar.js)
3
+ // (docs/adr/2026-11-06-0038-a-progress-bar-pulses-one-step-at-a-time.md). It needs nothing else, since hub/hub.mjs
4
+ // renders the shell's first sidebar with it in a vm context. style.css draws the bars.
5
+ (function () {
6
+ const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
7
+ // {count, done, now, percent, dim, tone, label}, or null for no bar. done counts the steps reached, filled; now is the
8
+ // index of the one step still running, which pulses, so no bar can draw two. percent fills the step after the done
9
+ // ones that far. A dim bar draws its running step filled, without the pulse. tone is a lane class, 'in-todo' drawing it in
10
+ // Todo's grey; with none it is In Progress's orange.
11
+ function progressBar(bar) {
12
+ if (!bar) return '';
13
+ const { count, done, now, percent, dim, tone, label } = bar;
14
+ const step = (i) => (i === now ? `<i class="${dim ? 'done' : 'now'}"></i>` : i < done ? '<i class="done"></i>'
15
+ : i === done && percent !== undefined ? `<i class="fill" style="--p:${percent}%"></i>` : '<i class=""></i>');
16
+ return `<span class="phase-bars${dim ? ' dim' : ''}${tone ? ` ${tone}` : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${Array.from({ length: count }, (_, i) => step(i)).join('')}</span>`;
17
+ }
18
+ // Every pulse on the page beats on one clock: each one that starts is set to the document timeline's origin, so a
19
+ // card's running step and its status dot blink together, and a redraw that draws a new copy never restarts the beat.
20
+ const PULSES = new Set(['pulse', 'pulse-fill', 'sb-pulse', 'reader-pulse']);
21
+ function onePulse(ev) {
22
+ if (!PULSES.has(ev.animationName)) return;
23
+ for (const a of ev.target.getAnimations()) if (a.animationName === ev.animationName) a.startTime = 0;
24
+ }
25
+ Object.assign(globalThis.BoardUI ||= {}, { progressBar, onePulse });
26
+ // hub/hub.mjs runs this with no document, only to render the sidebar.
27
+ globalThis.document?.addEventListener('animationstart', onePulse);
28
+ })();
@@ -1,18 +1,25 @@
1
1
  // Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
2
2
  (function () {
3
- const { esc, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
3
+ const { esc, escText, time, ago, replyBody, attachments, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
4
  // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it.
5
5
  const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
6
6
  const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
7
7
  // suggested: the line under a suggested option saying where the owner chose it before, as HTML.
8
8
  const option = (o, { type = 'radio', name, checked = false, numbered = true, locked = false, suggested = '' }) => `<label class="opt"><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
9
- <span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${suggested ? badge('ready', 'suggested').replace('class="chip', 'class="chip suggested') : ''}${o.recommended ? '<span class="rec">recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div>${suggested ? `<div class="suggested-why">${suggested}</div>` : ''}</span></label>`;
9
+ <span><span class="lbl">${numbered ? escText(lead(o)) : ''}${escText(o.label)}</span>${suggested ? badge('ready', 'suggested').replace('class="chip', 'class="chip suggested') : ''}${o.recommended ? '<span class="rec">recommended</span>' : ''}<div class="det">${escText(o.detail || '')}</div>${suggested ? `<div class="suggested-why">${suggested}</div>` : ''}</span></label>`;
10
10
  // docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
11
11
  // A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
12
12
  // suggested, pre-checked so the topic's Send sends it.
13
13
  const choiceOf = (q, draft) => draft.choice ?? (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
14
+ // A question's unsent note: its draft, else on a reopened question the note its last answer sent, so a changed pick
15
+ // sends it again.
16
+ const noteOf = (q, draft) => draft.note ?? (q.reopened ? q.answers.at(-1)?.note || '' : '');
17
+ // docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md
18
+ // Whether the owner has resolved a question: an answer is sent, whatever Claude has done with it since, or Claude
19
+ // resolved it. A sent question draws as resolvedCard, locked, until its badge reopens it.
20
+ const isSent = (q) => q.state === 'answered' || q.state === 'replied' || q.state === 'resolved';
14
21
  // A question's unsent {choice, note} on this device, in localStorage under qdraft:<id>. view-questions.js and the
15
- // phone's deck (app-needs.js) and the prototype viewer's Pick (prototype-switcher.js) write it.
22
+ // phone's deck (app-needs.js) and the prototype viewer's Choose this (prototype-switcher.js) write it.
16
23
  const getDraft = (id) => { try { return JSON.parse(localStorage.getItem('qdraft:' + id) || '{}'); } catch { return {}; } };
17
24
  const setDraft = (id, v) => { try { localStorage.setItem('qdraft:' + id, JSON.stringify(v)); } catch {} };
18
25
  const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`You answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
@@ -26,8 +33,6 @@
26
33
 
27
34
  const QUESTION_STATE = {
28
35
  open: { label: 'open', tone: 'normal' },
29
- answered: { label: 'waiting for claude', tone: 'notice' },
30
- replied: { label: 'claude replied', tone: 'ready' },
31
36
  requested: { label: `${SPIN}prototype requested`, tone: 'normal' },
32
37
  };
33
38
  // docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md
@@ -39,8 +44,8 @@
39
44
  const RULE_ICON = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 8v4"/><path d="M12 16h.01"/></svg>';
40
45
  function answerHistory(q, links = {}) {
41
46
  if (!q.answers.length) return '';
42
- const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${esc(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
43
- ${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : a.acted ? '' : '<div class="muted small" style="margin-top:4px">Waiting for Claude…</div>'}`).join('');
47
+ const body = q.answers.map((a) => `<div class="ans"><div class="muted small">You · ${esc(time(a.ts))}</div>${escText(choiceLabel(q, a.choice))}${a.note ? `<div>${linkText(a.note, links)}</div>` : ''}${attachments(a.files)}</div>
48
+ ${a.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(a.reply), links)}</div>` : ''}`).join('');
44
49
  const last = q.answers[q.answers.length - 1];
45
50
  return q.state === 'open' || !last.reply ? body : `<details><summary>Your answer and Claude's reply</summary>${body}</details>`;
46
51
  }
@@ -82,19 +87,21 @@
82
87
  }
83
88
  // A confirm says why it is asked; its View prototypes opens the viewer on its one take (?variant=).
84
89
  const confirmLine = (q) => q.confirm_of && q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : '';
85
- // docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md
86
- // A resolved question, read-only: its green resolved badge asks Claude to ask it again (view-questions.js sends
87
- // `reask-request` once the owner confirms), how it was resolved and when it was acted on (resolved_at), its last pick
88
- // checked among locked options, and its last note and Claude's reply. reask is a re-ask under way, which locks the badge.
89
- function resolvedCard(q, links) {
90
+ // docs/adr/2026-11-05-2331-a-sent-answer-is-resolved-in-place-until-the-page-reloads.md
91
+ // A sent or resolved question, read-only in a green tint: its Resolved badge reopens it (view-questions.js sends
92
+ // `question-reopen` once the owner confirms), how Claude resolved it and when (resolved_at), its last pick checked
93
+ // among locked options, and its last note and Claude's reply. reask is a re-ask under way
94
+ // (docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md).
95
+ function resolvedCard(q, links, n) {
90
96
  const last = q.answers[q.answers.length - 1];
91
97
  const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
92
98
  const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
93
99
  const said = last && (last.note || last.reply || last.files?.length) ? `<div class="ans"><div class="muted small">You · ${esc(time(last.ts))}</div>${last.note ? `<div>${linkText(last.note, links)}</div>` : ''}${attachments(last.files)}</div>
94
100
  ${last.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(last.reply), links)}</div>` : ''}` : '';
95
101
  return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
96
- <div class="row" style="margin:0"><span class="muted small">${esc(q.source || '')}</span>${badge('ready', 'resolved', { button: true, title: 'Ask this question again', attrs: { type: 'button', 'data-reask': q.id, disabled: q.reask ? '' : null } })}</div>
102
+ <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge('ready', '✓ Resolved', { button: true, title: 'Reopen this question', attrs: { type: 'button', 'data-reopen': q.id } })}</div>
97
103
  <h3>${linkText(q.question, links)}</h3>
104
+ ${q.answers.length ? '<div class="sent-line">Sent. Tap Resolved to reopen it.</div>' : ''}
98
105
  ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
99
106
  ${questionLink(q.url)}
100
107
  ${outcome || acted ? `<div class="small muted resolved-line">${outcome}${acted}</div>` : ''}
@@ -106,18 +113,19 @@
106
113
  // An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext.
107
114
  // A question waiting on the prototype the owner asked for is locked at the pick and note the request sent until its
108
115
  // round arrives (docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
116
+ // A requested question takes the accent tint, as a sent one takes the green.
109
117
  function questionCard(q, { n = 0, draft = {}, links = {} } = {}) {
110
- if (q.state === 'resolved') return resolvedCard(q, links);
118
+ if (isSent(q)) return resolvedCard(q, links, n);
111
119
  const locked = q.state === 'requested';
112
120
  const chosen = locked ? q.request.choice : choiceOf(q, draft);
113
- return `<section class="q ${q.urgent && q.state === 'open' ? 'urgent' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
121
+ return `<section class="q${q.urgent && q.state === 'open' ? ' urgent' : ''}${locked ? ' pr-busy' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
114
122
  <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
115
123
  <h3>${linkText(q.question, links)}</h3>
116
124
  ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
117
125
  ${questionLink(q.confirm_of && isPrototype(q.url) ? withParam(q.url, 'variant', q.options[0].id) : q.url)}
118
126
  ${confirmLine(q)}
119
127
  ${drawn(q) ? takes(q, chosen, links, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
120
- ${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, draft.note)}
128
+ ${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, noteOf(q, draft))}
121
129
  ${answerHistory(q, links)}
122
130
  </section>`;
123
131
  }
@@ -166,5 +174,5 @@
166
174
  },
167
175
  };
168
176
 
169
- Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, option, answerHistory, questionCard, roundChip, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
177
+ Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, option, answerHistory, questionCard, roundChip, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
170
178
  })();
@@ -1,11 +1,11 @@
1
1
  // The sidebar of every site (docs/adr/2026-10-29-1639-a-sidebar-of-every-board-replaces-the-hub-home.md), drawn from the hub's
2
2
  // /_hub/sidebar.json. It needs only ui-page-header.js, loaded before it, for its head, which is the page header itself, and
3
- // ui-phase-bars.js for a starting board's steps: the app shell runs it through sidebar.js, and hub/hub.mjs renders the
3
+ // ui-progress-bar.js for a starting board's steps: the app shell runs it through sidebar.js, and hub/hub.mjs renders the
4
4
  // shell's first sidebar with all three in a vm context.
5
5
  // Its links are root-absolute, since every site and hub page sits beside this one on the hub. sidebar.css styles it.
6
6
  (function () {
7
7
  // ui.js replaces the BoardUI global when it loads after this, so the header renderer is held here.
8
- const { header, phaseBars } = globalThis.BoardUI || {};
8
+ const { header, progressBar } = globalThis.BoardUI || {};
9
9
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
10
10
  const svg = (paths, size = 17) => `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${paths}</svg>`;
11
11
  const ICONS = {
@@ -32,6 +32,9 @@
32
32
  const time = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }).replace(/\s?([AP])M/i, (_, x) => `${x.toLowerCase()}m`);
33
33
  return new Date().toDateString() === d.toDateString() ? time : `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`;
34
34
  }
35
+ // A pause's end in words: until its clock, or until you resume a Forever Pause hub or Pause board
36
+ // (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md).
37
+ const pauseUntilWords = (end) => (end === 'forever' ? 'until you resume' : `until ${usageClock(end)}`);
35
38
  const usageTone = (w) => !w ? 'none' : w.state === 'stale' ? 'stale' : w.used_percentage >= 90 ? 'full' : w.used_percentage >= 75 ? 'high' : 'ok';
36
39
  const usageBar = (w) => `<span class="u-bar" aria-hidden="true"><i style="width:${w?.state === 'stale' ? 0 : Math.max(0, Math.min(100, w?.used_percentage || 0))}%"></i></span>`;
37
40
  function usageWords(reading, key) {
@@ -171,25 +174,20 @@
171
174
  function startSteps(s) {
172
175
  const at = STEP_KEYS.indexOf(s.step);
173
176
  const label = `${START_STEPS[s.step]}, step ${at + 1} of ${STEP_KEYS.length}`;
174
- const strip = s.step === 'cloning' ? { reached: 0, running: false, fill: s.percent || 0, label } : { reached: at + 1, running: true, label };
175
- return `<span class="sb-steps">${phaseBars(strip, STEP_KEYS)}</span>`;
177
+ const count = STEP_KEYS.length;
178
+ const bar = s.step === 'cloning' ? { count, done: 0, percent: s.percent || 0, label } : { count, done: at + 1, now: at, label };
179
+ return `<span class="sb-steps">${progressBar(bar)}</span>`;
176
180
  }
177
181
 
178
- // The New version row. update: the app's reload, null | 'ready' | 'reloading', which comes first. upgrade:
179
- // /_hub/sidebar.json's npm update, null | {state: available | updating | updated | restarting | failed, version, error}
180
- // (hub/upgrade.mjs; docs/adr/2026-11-05-2200-the-hub-pulls-a-new-version-itself-and-offers-restart.md). The hub pulls a new
181
- // version itself, so the row reads Updated to v<version> with Restart; restarting is the sidebar's own state while the hub
182
- // restarts. A reload, a pull and a restart wait (ui.js BUSY): the pill keeps its blue with a spinner in place of its word.
183
- function updateRow(update, upgrade) {
184
- const button = (attr, busy, title, sub, go) => `<button type="button" class="sb-row sb-update-row" ${attr}${busy ? globalThis.BoardUI.BUSY : ''}>${tile('up', 'var(--accent)')}`
185
- + `<span class="sb-text"><span class="sb-title">${title}</span>${sub ? `<span class="sb-sub">${esc(sub)}</span>` : ''}</span><span class="sb-go">${go}</span></button>`;
186
- if (update) return button('data-app-reload', update === 'reloading', 'New version', '', 'Reload');
187
- if (!upgrade) return '';
188
- if (upgrade.state === 'updated') return button('data-app-restart', false, `Updated to v${esc(upgrade.version)}`, upgrade.error, 'Restart');
189
- if (upgrade.state === 'restarting') return button('data-app-restart', true, `Restarting to v${esc(upgrade.version)}`, '', 'Restart');
190
- if (upgrade.state === 'updating') return button('data-app-upgrade', true, 'Updating', '', 'Update');
191
- if (upgrade.state === 'failed') return button('data-app-upgrade', false, 'Update failed', upgrade.error, 'Update');
192
- return button('data-app-upgrade', false, 'Update available', '', 'Update');
182
+ // The update row, only while the app's code is old: the version the hub now runs, as the Hub page's footer writes it (v0.5.1), or New version
183
+ // when no version is known (a clone whose commit moved but not its version). update: the app's reload, null | 'ready' | 'reloading'. Nothing
184
+ // else draws a row: the hub pulls a new version and restarts itself, and a pull or restart that fails shows nothing
185
+ // (docs/adr/2026-11-06-0800-the-update-row-shows-only-new-version-and-reload.md). A reload waits (ui.js BUSY): the pill keeps
186
+ // its blue with a spinner in place of its word.
187
+ function updateRow(update, version = null) {
188
+ if (!update) return '';
189
+ return `<button type="button" class="sb-row sb-update-row" data-app-reload${update === 'reloading' ? globalThis.BoardUI.BUSY : ''}>${tile('up', 'var(--accent)')}`
190
+ + `<span class="sb-text"><span class="sb-title">${version ? `v${esc(version)}` : 'New version'}</span></span><span class="sb-go">Reload</span></button>`;
193
191
  }
194
192
 
195
193
  // data: /_hub/sidebar.json. here: a site's route or a hub page's path without its leading slash. device: this device's push
@@ -202,8 +200,12 @@
202
200
  const sites = data?.sites || [];
203
201
  const keys = switching ? new Map(switchKeys(data).map((k) => [k.href, k])) : null;
204
202
  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';
205
+ // 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') : '');
205
207
  const siteRow = (s) => {
206
- const sub = paused && s.board ? word('wait', 'paused') + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
208
+ const sub = s.board && holdWord(s) ? holdWord(s) + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
207
209
  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)) });
208
210
  };
209
211
  const boards = sites.filter((s) => s.board);
@@ -219,7 +221,7 @@
219
221
  : 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' })
220
222
  : '';
221
223
  const usage = sidebarUsage(data?.usage);
222
- const hub = paused ? { tone: 'wait', word: 'paused', detail: `until ${usageClock(data.pause.resets_at)}` } : data?.hub;
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;
223
225
  const n = data?.notifications || {};
224
226
  const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
225
227
  const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
@@ -239,7 +241,7 @@
239
241
  const boardsLabel = `<div class="sb-label ks-label"><span class="ks-label-text">Boards</span>${hint}</div>`;
240
242
  if (data?.shared) return `${top}<div class="sb-scroll"><section class="sb-boards" aria-label="Boards">${boardsLabel}<div class="sb-group">${boards.map(siteRow).join('')}</div></section></div>`;
241
243
  return top
242
- + `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update, data?.upgrade)}</div><div class="sb-group">`
244
+ + `<div class="sb-scroll"><section class="sb-utility" aria-label="Hub"><div class="sb-update" role="status">${updateRow(update)}</div><div class="sb-group">`
243
245
  + UTILITY.map((f) => row({ ...f, on: f.href.replace(/\/$/, '') === selected, sub: subs[f.href], mark: f.href === '/_hub/' ? moon : '', ...(f.href === '/_hub/' && usage ? { extra: usage, cls: 'sb-hub-usage' } : {}), key: keys?.get(f.href) })).join('')
244
246
  + `</div>${turnOn}</section>`
245
247
  + (pages.length ? `<section class="sb-pages" aria-label="Pages"><div class="sb-label">Pages</div><div class="sb-group">${pages.map(siteRow).join('')}</div></section>` : '')
@@ -247,5 +249,5 @@
247
249
  + `<div class="sb-group">${boards.map(siteRow).join('')}${pending}${newBoard}</div></section></div>`;
248
250
  }
249
251
 
250
- Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords });
252
+ Object.assign(globalThis.BoardUI ||= {}, { sidebar, failedStart, START_STEPS, startSteps, startCaption, updateRow, othersWaiting, switchKeys, shortcuts, repoLabel, usageProducts, usageNames, usageClock, pauseUntilWords, usageTone, usageBar, usageWords });
251
253
  })();