@lovek4d/kickflip 0.1.0 → 0.3.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 (96) hide show
  1. package/CONTEXT.md +177 -78
  2. package/README.md +46 -5
  3. package/SKILL.md +58 -37
  4. package/bin/doctor.mjs +4 -3
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +87 -32
  7. package/bin/statusline.mjs +4 -3
  8. package/bin/wait-events.mjs +2 -2
  9. package/board/arch-review.mjs +5 -0
  10. package/board/bin/agent.mjs +1 -1
  11. package/board/bin/board.mjs +241 -69
  12. package/board/bin/builder-stop-hook.mjs +4 -3
  13. package/board/domain.mjs +5 -3
  14. package/board/on-host.mjs +1 -0
  15. package/board/public/app-arch.js +75 -0
  16. package/board/public/app-asks.js +33 -14
  17. package/board/public/app-cards.js +242 -49
  18. package/board/public/app-core.js +39 -5
  19. package/board/public/app-header.js +24 -6
  20. package/board/public/app-lanes.js +76 -26
  21. package/board/public/app-needs.js +1 -0
  22. package/board/public/app-snapshot.js +45 -11
  23. package/board/public/app-threads.js +13 -11
  24. package/board/public/app.html +4 -1
  25. package/board/public/app.js +131 -20
  26. package/board/public/dialogs.js +114 -116
  27. package/board/public/hub-kit.js +19 -2
  28. package/board/public/hub.css +24 -18
  29. package/board/public/linear-key.js +141 -0
  30. package/board/public/router.js +6 -3
  31. package/board/public/rules-editor.js +1 -1
  32. package/board/public/shell.js +1 -1
  33. package/board/public/sidebar.css +31 -11
  34. package/board/public/sidebar.js +88 -16
  35. package/board/public/style.css +141 -46
  36. package/board/public/toasts.js +6 -4
  37. package/board/public/ui-cards.js +102 -37
  38. package/board/public/ui-header.js +17 -8
  39. package/board/public/ui-hub.js +30 -12
  40. package/board/public/ui-links.js +18 -10
  41. package/board/public/ui-order.js +18 -0
  42. package/board/public/ui-page-header.js +4 -2
  43. package/board/public/ui-questions.js +37 -16
  44. package/board/public/ui-sidebar.js +49 -11
  45. package/board/public/ui-snapshot.js +15 -9
  46. package/board/public/ui-threads.js +25 -16
  47. package/board/public/ui.js +23 -11
  48. package/board/public/view-hub-settings.js +46 -30
  49. package/board/public/view-hub.js +64 -13
  50. package/board/public/view-notes.js +7 -4
  51. package/board/public/view-polling.js +3 -3
  52. package/board/public/view-questions.js +42 -22
  53. package/board/public/view-settings.js +90 -35
  54. package/board/review.mjs +1 -0
  55. package/board/server.mjs +12 -7
  56. package/board/store.mjs +14 -6
  57. package/board/tickets.mjs +3 -3
  58. package/board/worktree.mjs +14 -13
  59. package/docs/hub-session.md +3 -3
  60. package/hooks-plugin/hooks/codex-share-auto.mjs +7 -5
  61. package/hooks-plugin/hooks/watcher-stop.mjs +41 -0
  62. package/hub/awake.mjs +1 -0
  63. package/hub/board-repo.mjs +1 -1
  64. package/hub/boards.mjs +4 -4
  65. package/hub/close-board.mjs +1 -1
  66. package/hub/duration.mjs +1 -0
  67. package/hub/github.mjs +4 -5
  68. package/hub/home.mjs +1 -1
  69. package/hub/hub.mjs +12 -10
  70. package/hub/linear.mjs +2 -2
  71. package/hub/new-board.mjs +2 -2
  72. package/hub/pr-report.mjs +1 -0
  73. package/hub/push.mjs +2 -2
  74. package/hub/readiness.mjs +2 -0
  75. package/hub/repos.mjs +3 -2
  76. package/hub/restart-board.mjs +1 -0
  77. package/hub/routes.mjs +1 -1
  78. package/hub/schedule.mjs +1 -1
  79. package/hub/sessions.mjs +2 -2
  80. package/hub/shell.mjs +2 -1
  81. package/hub/slack.mjs +2 -0
  82. package/hub/stream.mjs +12 -12
  83. package/hub/sw.js +1 -1
  84. package/hub/trust.mjs +2 -0
  85. package/hub/try.mjs +4 -3
  86. package/hub/upgrade.mjs +1 -0
  87. package/hub/usage.mjs +3 -3
  88. package/hub/views.mjs +1 -1
  89. package/package.json +1 -1
  90. package/shared/colors.css +7 -5
  91. package/shared/fake-gh.mjs +54 -0
  92. package/shared/service.mjs +16 -16
  93. package/shared/skills.mjs +2 -0
  94. package/shared/usage-gate.mjs +1 -1
  95. package/starter/public/claude.js +3 -2
  96. package/bin/web-ts.mjs +0 -2
@@ -1,13 +1,13 @@
1
1
  // Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js and ui-enums.js.
2
2
  (function () {
3
- const { esc, ago, time, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, LINK_ARROW } = globalThis.BoardUI;
3
+ const { esc, ago, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, LINK_ARROW } = globalThis.BoardUI;
4
4
  const EXTERNAL = { target: '_blank', rel: 'noopener' };
5
5
  function prChips(it) {
6
6
  const pr = it.pr;
7
7
  if (!pr) return '';
8
8
  const st = pr.state === 'MERGED' ? badge('ready', 'merged') : pr.state === 'CLOSED' ? badge('danger', 'closed') : '';
9
9
  // An open PR's chip ends with its state, marked like the reviewer and CI chips.
10
- const open = pr.state === 'OPEN' ? (pr.isDraft ? { tone: 'notice', mark: ' ●', label: 'Draft' } : { tone: 'ready', mark: ' ✓', label: 'Ready for review' }) : { tone: 'normal', mark: '', label: '' };
10
+ const open = pr.state === 'OPEN' ? (pr.isDraft ? { tone: 'notice', mark: ' ●', label: 'Draft' } : { tone: 'ready', mark: ' ✓', label: 'Ready for review' }) : { tone: 'quiet', mark: '', label: '' };
11
11
  const CI = { pass: { tone: 'ready', mark: '✓' }, fail: { tone: 'danger', mark: '✗' }, pending: { tone: 'notice', mark: '●' } }[pr.checks?.state] || { tone: 'quiet', mark: '?' };
12
12
  const ci = pr.checks && pr.state === 'OPEN' ? badge(CI.tone, `CI ${CI.mark}`, { title: `CI ${pr.checks.state}` }) : '';
13
13
  const repo = (pr.url.match(/github\.com\/[^/]+\/([^/]+)\//) || [])[1] || '';
@@ -16,8 +16,8 @@
16
16
  const label = open.label ? { title: open.label, attrs: { ...EXTERNAL, 'aria-label': `${name}: ${open.label}` } } : { attrs: EXTERNAL };
17
17
  return `${badge(open.tone, `${esc(name)}${open.mark}`, { href: pr.url, ...label })}${st}${ci}${codex}`;
18
18
  }
19
- const linearChips = (it) => (it.linear || []).map((l) => badge('normal', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
20
- const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge(it.ticket.source === 'internal' ? 'quiet' : 'normal', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'internal' ? ' · this board’s own ticket' : it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: it.ticket.source === 'internal' ? {} : EXTERNAL });
19
+ const linearChips = (it) => (it.linear || []).map((l) => badge('quiet', esc(l.id), { href: l.url, title: `${l.id} in Linear`, attrs: EXTERNAL })).join('');
20
+ const ticketChip = (it) => !it.ticket ? '' : !it.ticket.url ? badge('quiet', esc(it.ticket.id), { title: `${it.ticket.id} · not from this board’s ticket source` }) : badge('quiet', esc(it.ticket.id), { href: it.ticket.url, title: `${it.ticket.id}${it.ticket.source === 'internal' ? ' · this board’s own ticket' : it.ticket.source === 'github' ? ' on GitHub' : ' in Linear'}`, attrs: it.ticket.source === 'internal' ? {} : EXTERNAL });
21
21
  const REVIEW = {
22
22
  approved: { tone: 'ready', mark: '✓', label: 'approved' },
23
23
  changes: { tone: 'danger', mark: '✗', label: 'asked for changes' },
@@ -33,36 +33,44 @@
33
33
  return (it.reviewers || []).map((r) => {
34
34
  const rv = globalThis.BoardUI.record(it.review, r);
35
35
  const s = REVIEW[rv?.state || 'unsent'];
36
- return badge(s.tone, `${esc(r[0])} ${s.mark}${reviewAge(rv)}`, { title: `${r}: ${s.label}` });
36
+ const age = reviewAge(rv).trim();
37
+ return badge(s.tone, `${esc(rv?.github || r)}${age ? ` (${age})` : ''} ${s.mark}`, { title: `${r}${rv?.github ? ` (${rv.github})` : ''}: ${s.label}` });
37
38
  }).join('');
38
39
  }
39
40
  const pendingQuestions = (it) => (it.questions && it.questions.state !== 'resolved' ? it.questions.open + it.questions.answered + it.questions.replied : 0);
40
41
  // docs/adr/0045-the-lane-follows-who-acts-next.md
41
- function actionChip(it) {
42
+ // Action needed, last of the time line's orange words, linking to what the owner acts on
43
+ // (docs/adr/0145-build-on-a-blocked-card-queues-it.md).
44
+ function actionWords(it) {
42
45
  if (!it.action) return '';
43
- const external = it.action.kind === ACTION_KIND.ready ? EXTERNAL : {};
44
- return badge('notice', 'Action needed', { href: it.action.url, title: it.action.label, attrs: { ...external, 'aria-label': `Action needed: ${it.action.label}` } });
46
+ const external = it.action.kind === ACTION_KIND.ready ? Object.entries(EXTERNAL).map(([k, v]) => ` ${k}="${esc(v)}"`).join('') : '';
47
+ return `<a class="card-action" href="${esc(it.action.url)}" title="${esc(it.action.label)}" aria-label="${esc(`Action needed: ${it.action.label}`)}"${external}>Action needed</a>`;
48
+ }
49
+ // Each unfinished dep, linking to its card: "blocked by #53, #54".
50
+ function blockedWords(it) {
51
+ const deps = it.blockedBy || [];
52
+ return deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
45
53
  }
46
54
  const tryWords = (kind) => kind === 'app' ? {
47
55
  go: 'Build', going: 'Building…', done: 'Install', failed: 'Build failed',
48
- none: 'No changes to build: this card’s branch matches main.',
49
56
  current: 'Nothing new to build: Install already has this card’s latest push.',
50
57
  busy: 'Building now. The badge turns back into Install when the new build is ready.',
51
58
  autoSub: 'On: the hub builds each card after each push. Off: a card waits for your Build. Main builds again after every merge either way.',
52
59
  saved: (on) => on ? 'Saved. Each push builds its card.' : 'Saved. Cards wait for your Build.',
53
60
  } : {
54
61
  go: 'Serve', going: 'Serving…', done: 'Visit', failed: 'Serve failed',
55
- none: 'No changes to serve: this card’s branch matches main.',
56
62
  current: 'Nothing new to serve: Visit already shows this card’s latest push.',
57
63
  busy: 'Serving now. The badge turns back into Visit when the new server is up.',
58
64
  autoSub: 'On: the hub serves each card after each push. Off: a card waits for your Serve. Main serves again after every merge either way.',
59
65
  saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
60
66
  };
61
67
  const spinner = '<span class="try-spin" aria-hidden="true"></span>';
68
+ const tryURL = (run) => run.kind === 'web' ? run.port ? `https://${location.hostname}:${run.port}/` : null : run.url;
62
69
  function tryOf(it) {
63
70
  if (it.lane === 'done') return null;
64
71
  const t = it.try || (it.install ? { kind: 'app', state: 'ready', stale: it.install.older, sha: it.install.sha, url: it.install.url } : null);
65
- return it.builder?.kind === BUILDER_KIND.install ? { ...t, kind: t?.kind || 'app', state: 'running' } : t;
72
+ const run = t ? { ...t, url: tryURL(t) } : null;
73
+ return it.builder?.kind === BUILDER_KIND.install ? { ...run, kind: run?.kind || 'app', state: 'running' } : run;
66
74
  }
67
75
  function installState(it) {
68
76
  const t = tryOf(it);
@@ -87,20 +95,23 @@
87
95
  const tag = s.href ? 'a' : 'span';
88
96
  return `<${tag} class="install tone-${s.tone}"${attrs}>${s.html}</${tag}>`;
89
97
  }
98
+ // A card's Serve / Build, only while the board's Auto-build is off and its branch has changes
99
+ // (docs/adr/0124-only-the-owner-or-auto-build-starts-a-card-run.md).
90
100
  function tryButton(it) {
91
101
  const t = tryOf(it);
92
- if (it.kind !== 'work' || !t?.script) return '';
102
+ if (it.kind !== 'work' || !t?.script || t.autoBuild || !t.changes) return '';
93
103
  const words = tryWords(t.kind);
94
- const reason = !t.changes ? words.none : t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
104
+ const reason = t.state === 'running' ? words.busy : t.state === 'ready' && !t.stale ? words.current : '';
95
105
  return `<button type="button" data-try="${esc(it.key)}"${reason ? ` aria-disabled="true" title="${esc(reason)}" data-tip="${esc(reason)}"` : ''}>${t.state === 'failed' && !reason ? 'Retry' : words.go}</button>`;
96
106
  }
97
107
  const BRANCH = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
98
108
  function mainBar(main) {
99
109
  if (!main) return '';
100
110
  const words = tryWords(main.kind);
111
+ const url = tryURL(main);
101
112
  const chip = main.state === 'running' ? badge('notice', spinner + esc(words.going), { attrs: { role: 'status' } })
102
- : main.state === 'failed' ? badge('danger', esc(words.failed), { title: 'The build script stopped with an error. Its log is data/try/main.log in the board’s folder.' })
103
- : main.state === 'ready' && main.url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: main.url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
113
+ : main.state === 'failed' ? badge('danger', esc(words.failed), { button: true, attrs: { type: 'button', 'data-main-log': '', 'aria-haspopup': 'dialog' }, title: 'Read main’s build log and retry this tip.' })
114
+ : main.state === 'ready' && url ? badge('ready', `${words.done} ${LINK_ARROW}`, { href: url, attrs: main.kind === 'web' ? EXTERNAL : {} }) : '';
104
115
  return `<div class="tb-main" role="region" aria-label="Main’s version">${BRANCH}<div class="tb-main-text">${esc(main.slug)} · <strong>${esc(main.branch)}</strong>@<code>${esc(main.sha)}</code> · ${esc(ago(main.at))}</div><div class="meta">${chip}</div></div>`;
105
116
  }
106
117
  // The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, and reads "Merging…"
@@ -110,44 +121,98 @@
110
121
  const merging = sending || it.merging;
111
122
  return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${merging ? ' disabled' : ''}>${merging ? 'Merging…' : 'Merge'}</button>`;
112
123
  }
113
- const builderLine = (b) => [b.progress || 'Building', b.at && ago(b.at)].filter(Boolean).join(' · ');
124
+ // The open card's Implement, rightmost in its action row, on a card board/domain.mjs marks buildable: it confirms, then
125
+ // sends card-build, and reads "Starting…" while it is sent and the board server starts the implementer. Build and Serve
126
+ // mean the package only (docs/adr/0122-a-todo-card-that-nothing-blocks-has-build.md,
127
+ // docs/adr/0139-a-build-tap-starts-the-build-in-the-board-server.md, docs/adr/0166-a-todo-cards-button-reads-implement.md).
128
+ function buildButton(it, sending = false) {
129
+ if (!it.buildable) return '';
130
+ return `<button class="build" data-confirm="card-build" data-key="${esc(it.key)}"${sending ? ' disabled' : ''}>${sending ? 'Starting…' : 'Implement'}</button>`;
131
+ }
132
+ // A builder the Implement tap started reads "Waiting for orchestrator" until the session's builder posts its first line.
133
+ const builderLine = (b) => [b.progress || (b.waiting ? 'Waiting for orchestrator' : 'Starting'), b.at && ago(b.at)].filter(Boolean).join(' · ');
134
+ // Five bars at the end of the time row while a build runs on an In Progress card: done phases filled, the current one
135
+ // marked. The phases are store.mjs PHASES (docs/adr/0137-a-builder-status-line-is-a-phase-and-a-short-detail.md,
136
+ // docs/adr/0160-a-building-cards-phase-bars-end-its-time-row.md).
137
+ const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
138
+ function phaseBars(phase) {
139
+ const at = BUILD_PHASES.indexOf(phase);
140
+ if (at < 0) return '';
141
+ const name = `${phase[0].toUpperCase()}${phase.slice(1)}`;
142
+ return `<span class="phase-bars" role="img" aria-label="${name}, step ${at + 1} of ${BUILD_PHASES.length}">${BUILD_PHASES.map((_, i) => `<i class="${i < at ? 'done' : i === at ? 'now' : ''}"></i>`).join('')}</span>`;
143
+ }
114
144
  const stalledChip = (it) => it.lane !== 'done' && it.builder?.stalled ? badge('danger', 'Stalled', {
115
- button: true, title: 'No recent progress. Nudge Claude to check this builder',
145
+ button: true, title: 'No recent progress. Nudge Claude to check this implementer',
116
146
  attrs: { type: 'button', 'data-confirm': 'nudge', 'data-key': it.key },
117
147
  }) : '';
118
148
  const unreadCount = (othersAt, seenAt) => (seenAt ? (othersAt || []).filter((t) => String(t) > seenAt).length : 0);
119
- const unreadChip = (n) => {
120
- if (!n) return '';
121
- const label = `${n} unread message${n === 1 ? '' : 's'}`;
122
- return `<span class="chip unread" title="${label}" aria-label="${label}">${n} new</span>`;
123
- };
149
+ // When a hold ends, as a card reads it: the time today, the weekday within six days, else the date
150
+ // (docs/adr/0118-a-card-can-be-held-until-a-time.md).
151
+ function heldUntilWords(iso, now = new Date()) {
152
+ const d = new Date(iso);
153
+ const midnight = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
154
+ const days = Math.round((midnight(d) - midnight(now)) / 86400000);
155
+ if (days === 0) return clock(d);
156
+ if (days > 0 && days < 7) return `${d.toLocaleDateString([], { weekday: 'short' })} ${clock(d)}`;
157
+ return `${d.toLocaleDateString([], { month: 'numeric', day: 'numeric' })}, ${clock(d)}`;
158
+ }
159
+ function holdWords(it) {
160
+ if (!it.held) return '';
161
+ const d = it.heldUntil && new Date(it.heldUntil);
162
+ const title = !d ? 'Held until you unhold it.'
163
+ : `Held until ${d.toLocaleDateString([], { weekday: 'short', month: 'numeric', day: 'numeric' })}, ${clock(d)}. ${it.lane === 'todo' ? 'It starts on its own then, once nothing else blocks it.' : 'Claude picks it up again then.'}`;
164
+ return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
165
+ }
166
+ // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
167
+ // or ready, then a building card's phase bars, then its unread count, which app-cards.js takes away when the card opens
168
+ // (docs/adr/0154-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/0160-a-building-cards-phase-bars-end-its-time-row.md).
169
+ const SEP = '<span class="sep"> · </span>';
170
+ function timeLine(it, unread, bars) {
171
+ const iso = it.sortAt;
172
+ const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.ready && '<span class="card-ready">ready</span>'].filter(Boolean);
173
+ const count = unread ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${unread} new message${unread === 1 ? '' : 's'}</span>` : '';
174
+ const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
175
+ if (!parts.length && !count && !tail) return '';
176
+ return `<div class="line6 sub card-time">${parts.join(SEP)}${tail}${count}</div>`;
177
+ }
178
+ // The Hold sheet's picker (app-cards.js): /api/board's holdChoices, the chips hub/duration.mjs shares with Snooze and
179
+ // Keep awake, then Custom. Its .hub-view root takes hub.css's duration picker rules, which view-hub-settings.js
180
+ // durationPicker's markup also uses.
181
+ function holdPicker(key, choices) {
182
+ const chips = choices.map((c) => `<button type="button" class="btn quiet" data-hold-for="${esc(key)}" value="${esc(c.value)}" aria-label="${esc(`${c.label}, ${c.until}`)}">${esc(c.label)}</button>`).join('');
183
+ return `<div class="hub-view"><div class="dur-chips" role="group" aria-labelledby="holdTitle"><div class="dur-choices">${chips}</div>`
184
+ + `<div class="dur-custom"><span class="btn quiet" aria-hidden="true">Custom</span><input type="datetime-local" data-hold-at="${esc(key)}" required aria-label="Custom hold until a date and time"></div></div></div>`;
185
+ }
124
186
  // A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
125
187
  // docs/adr/0047-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
126
188
  const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
127
- const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, top)}</div>` : '');
128
- const ASK_STATE = { waiting: 'Waiting', working: `<span class="working">${workingLine}</span>` };
129
- const askLine = (ask) => `${ask.from ? `Moved from ${esc(ask.from)} · ` : ''}${esc(time(ask.askedAt))} · ${ask.edited ? 'Edited · ' : ''}${ASK_STATE[ask.state] || ''}`;
189
+ const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
190
+ const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && `<span class="working">${workingLine}</span>`].filter(Boolean).join(' · ');
130
191
  function workLines(it, { unread = 0, top = false } = {}) {
131
- const ids = it.kind === 'ask' ? askChip(it) : it.ticket ? ticketChip(it) : it.ticketSource === 'linear' ? linearChips(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : linearChips(it);
132
- const blocked = (it.blockedBy || []).map((d) => badge('notice', `blocked by ${esc(d.ref)}`, { href: `#item=${encodeURIComponent(d.key)}` })).join('');
133
- const ready = !blocked && !it.action && it.deps?.length && it.lane === 'todo' ? badge('ready', 'ready to start') : '';
134
- const chips = `${it.todoAge ? badge('quiet', esc(it.todoAge)) : ''}${stalledChip(it)}${unreadChip(unread)}${actionChip(it)}${it.kind === 'followup' ? badge('quiet', 'follow-up') : ''}${prChips(it)}${reviewChips(it)}${it.held ? badge('quiet', 'held') : ''}${it.risky ? badge('notice', 'risky') : ''}${it.bumpedAt ? badge('normal', 'bumped') : ''}${ready}${installChip(it)}`;
135
- const status = it.isNew && !it.action ? `<div class="line3 sub">${askLine(it.ask)}</div>`
192
+ const ticket = it.ticket ? ticketChip(it) : it.ticketSource === 'linear' ? linearChips(it) : it.internal ? badge('quiet', esc(it.ref), { title: 'Work on this site, not a product PR' }) : linearChips(it);
193
+ // A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
194
+ const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${ticket}` : ticket;
195
+ const wave = it.lineup ? badge('quiet', `${WAVE_ICON}${esc(it.lineup.title)}`, { title: `In the wave ${it.lineup.title}` }) : '';
196
+ const chips = `${stalledChip(it)}${it.followUp ? badge('quiet', 'follow-up') : ''}${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
197
+ const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
136
198
  : it.waitsOn === 'checking' ? '<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>Orchestrator review</div>'
137
- : it.builder ? `<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}</div>`
199
+ : it.builder ? `<div class="line3 sub agent${it.builder.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}</div>`
200
+ : it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
138
201
  : it.working ? `<div class="line3 sub agent">${workingLine}</div>`
139
- : it.next ? `<div class="line3 sub next">Next: ${esc(it.next)}</div>` : '';
202
+ : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
140
203
  const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
141
204
  return `${cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
142
205
  ${status}${eta}
143
206
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
144
- ${blocked ? `<div class="line5 meta blocked">${blocked}</div>` : ''}`;
207
+ ${wave ? `<div class="line5 meta">${wave}</div>` : ''}
208
+ ${timeLine(it, unread, it.lane === 'progress' && it.builder && !it.builder.planner && it.waitsOn !== 'checking' ? phaseBars(it.builder.phase) : '')}`;
145
209
  }
146
- const askChip = (it) => badge('normal', it.isNew ? 'New' : 'Task');
210
+ const WAVE_ICON = '<svg class="wave-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 9c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/><path d="M2 16c2.5-3 5-3 7.5 0s5 3 7.5 0 3.5-3 5-1.5"/></svg>';
211
+ const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
147
212
  function planButton(items) {
148
- const keys = items.filter((i) => i.kind === 'followup').map((i) => i.key);
149
- return keys.length > 1 ? `<button class="link" data-plan="${esc(keys.join(' '))}">Plan a wave from these ${keys.length} follow-ups →</button>` : '';
213
+ const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blockedBy?.length).map((i) => i.key);
214
+ return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}">Plan waves</button>` : '';
150
215
  }
151
216
 
152
- Object.assign(globalThis.BoardUI, { prChips, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionChip, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, builderLine, unreadCount, workLines, askChip, askActions, askLine, planButton });
217
+ Object.assign(globalThis.BoardUI, { prChips, linearChips, ticketChip, REVIEW, reviewAge, reviewChips, pendingQuestions, actionWords, blockedWords, tryWords, tryButton, mainBar, installChip, installButton, mergeButton, buildButton, builderLine, unreadCount, heldUntilWords, holdPicker, workLines, askChip, askActions, askLine, planButton, WAVE_ICON });
153
218
  })();
@@ -2,12 +2,17 @@
2
2
  (function () {
3
3
  const { since, esc } = globalThis.BoardUI;
4
4
  // The header's short time beside refresh: `at` is when the page last got fresh data (nextFresh), and watcher is
5
- // /api/board's checks.watcher (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md). link is 'online',
5
+ // /api/board's checks.watcher (docs/adr/0021-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/0147-boards-run-in-the-background-and-the-hub-board-in-tmux.md). link is 'online',
6
9
  // 'offline' (the last board load failed) or 'reconnecting' (a refresh tap is retrying it).
7
- const statusLine = (at, { link = 'online', watcher, pause } = {}) => (pause?.state === 'paused' ? `<span class="late">Paused until ${esc(BoardUI.usageClock(pause.resets_at))}</span>`
10
+ const statusLine = (at, { link = 'online', watcher, pause, waking = false } = {}) => (pause?.state === 'paused' ? `<span class="late">Paused until ${esc(BoardUI.usageClock(pause.resets_at))}</span>`
8
11
  : link === 'offline' ? `<span class="link-state offline" title="Offline: can't reach the host">${at ? shortSince(at) : 'offline'}</span>`
9
12
  : link === 'reconnecting' ? '<span class="link-state">Reconnecting…</span>'
10
- : watcher === 'stopped' ? '<span class="claude late" title="Claude not listening">not listening</span>'
13
+ : watcher === 'waking' || (waking && watcher === 'asleep') ? '<span class="claude asleep" title="Waking: the hub is resuming its session">waking…</span>'
14
+ : watcher === 'asleep' ? '<span class="claude asleep" title="Asleep: its session idled out. Refresh, or any tap on the page, wakes it">asleep</span>'
15
+ : watcher === 'stopped' ? '<span class="claude down" title="Claude not listening">not listening</span>'
11
16
  : at ? `<span class="fresh" title="Updated ${since(at)}: the last change from the host, or your last refresh">${shortSince(at)}</span>` : '');
12
17
  // What the page shows, for telling whether a change signal brought anything new; generatedAt changes on every load.
13
18
  const freshSig = (board, threads, openKeys) => JSON.stringify([{ ...board, generatedAt: null }, [...openKeys].sort().map((k) => [k, threads.get(k) ?? null])]);
@@ -27,11 +32,15 @@
27
32
  // when the hub doesn't list this board. A link without state, from a board server still running older code, reads from ok.
28
33
  const linkState = (l) => l.state || (l.ok ? 'ok' : 'broken');
29
34
  const brokenLinks = (links) => (links || []).filter((l) => linkState(l) === 'broken');
30
- const linksMark = (links) => {
35
+ const linksMark = (links, mark = GITHUB_MARK) => {
31
36
  const broken = brokenLinks(links).length;
32
37
  const label = `Hub links${broken ? `: ${broken} not working` : ''}`;
33
- return `<button class="ghost gh-links" type="button" data-hub-links aria-haspopup="dialog" aria-label="${label}" title="${label}">${GITHUB_MARK}${broken ? '<span class="links-dot" aria-hidden="true"></span>' : ''}</button>`;
38
+ return `<button class="ghost gh-links" type="button" data-hub-links aria-haspopup="dialog" aria-label="${label}" title="${label}">${mark}${broken ? '<span class="links-dot" aria-hidden="true"></span>' : ''}</button>`;
34
39
  };
40
+ // A board on a plain git remote has no GitHub checks, so the In Review header names its repo, host/path, instead
41
+ // (docs/adr/0127-a-board-can-work-on-any-git-remote.md).
42
+ const REMOTE_MARK = '<svg class="gh-mark" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/></svg>';
43
+ const remoteLine = (remote, { links = null } = {}) => `<span class="github remote" title="This board’s repo">${linksMark(links, REMOTE_MARK)} <span class="remote-name">${esc(remote.name)}</span></span>`;
35
44
  // The In Review header's right end; pending is whether a check-github request is waiting.
36
45
  const githubLine = (checks, { pending = false, links = null } = {}) => {
37
46
  const mark = linksMark(links);
@@ -42,7 +51,7 @@
42
51
  return `<span class="github${checks.late && !checks.idle ? ' late' : ''}${checks.idle ? ' idle' : ''}" title="${title}">${mark} ${shortSince(checks.at)}${note}</span>`;
43
52
  };
44
53
 
45
- const LINK_NAMES = { github: 'GitHub polling', events: 'Events', wakes: 'Daily wakes', push: 'Push notifications', slack: 'Reviewer links' };
54
+ const LINK_NAMES = { github: 'GitHub polling', 'repo-fetch': 'Repo fetch', events: 'Events', wakes: 'Daily wakes', slack: 'Reviewer links' };
46
55
  // The Hub links sheet's list, one row per link with a green dot, a muted one while settling, or a red one
47
56
  // (docs/adr/0040-one-board-registry-for-every-hub-feature.md).
48
57
  const LINK_DOTS = { ok: ['pass', 'Working'], settling: ['', 'Settling'], broken: ['fail', 'Not working'] };
@@ -63,7 +72,7 @@
63
72
  const step = (ok, done, waiting) => `<li><span class="dot ${ok ? 'pass' : 'pending'}" aria-hidden="true"></span>${ok ? done : waiting}</li>`;
64
73
  return `<h2>Connecting this board</h2><ul class="setup-steps">
65
74
  ${step(setup.events, 'Claude answered the hello', 'Waiting for Claude to answer the hello')}
66
- ${step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
75
+ ${setup.github === undefined ? '' : step(setup.github, 'GitHub checks reach this board', 'Waiting for the hub\'s next GitHub check')}
67
76
  </ul>`;
68
77
  };
69
78
  const checkLanded = (checks, pendingSince) => Boolean(checks?.at) && Date.parse(checks.at) >= pendingSince;
@@ -80,5 +89,5 @@
80
89
  return { refresh: ready, settings: ready && link === 'online', spin: !boardSettled || reloading };
81
90
  };
82
91
 
83
- Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
92
+ Object.assign(globalThis.BoardUI, { statusLine, freshSig, nextFresh, githubLine, remoteLine, brokenLinks, linksList, setupCard, checkLanded, githubRefresh, refreshIcon, headerControls });
84
93
  })();
@@ -3,12 +3,19 @@
3
3
  (function () {
4
4
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
5
5
  const TILE_PATHS = {
6
+ github: '<circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7"/><path d="M18 10.5c0 4-6 3-11 6"/>',
7
+ codex: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="m7 9 3 3-3 3"/><path d="M13 15h4"/>',
8
+ linear: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
9
+ slack: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
10
+ tailscale: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18"/>',
11
+ notifications: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
6
12
  bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>',
7
13
  clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
8
14
  moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
9
15
  power: '<path d="M12 2v10"/><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/>',
10
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"/>',
11
17
  snooze: '<path d="M4 10h6l-6 8h6"/><path d="M14 4h6l-6 8h6"/>',
18
+ 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"/>',
12
19
  laptop: '<rect x="4" y="5" width="16" height="11" rx="1.5"/><path d="M2 19h20"/>',
13
20
  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"/>',
14
21
  };
@@ -36,14 +43,15 @@
36
43
  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>`;
37
44
  }
38
45
 
39
- function bufferControl(pause) {
46
+ // builder: the word for who builds Codex's work, hub/views.mjs hubView's claude.builder.
47
+ function bufferControl(pause, builder) {
40
48
  const at = pause.pause_at;
41
49
  const off = at === 100;
42
- return `<div class="u-buffer"><div class="ub-row"><span class="ub-label"><span class="title">Pause work at</span><span class="ub-sub">${off ? 'No buffer' : `${100 - at}% buffer`}</span></span>`
50
+ return `<div class="block"><div class="ub-row"><span class="ub-label"><span class="title">Pause work at</span><span class="ub-sub">${off ? 'No buffer' : `${100 - at}% buffer`}</span></span>`
43
51
  + `<form class="segments ub-step" method="post" action="/_hub/settings/pause-at" role="group" aria-label="Pause work at"><button name="value" value="${at - 5}" aria-label="Pause 5% sooner"${at <= 50 ? ' disabled' : ''}>−</button>`
44
52
  + `<output class="${off ? 'off' : ''}" aria-live="polite">${off ? 'Off' : `${at}%`}</output>`
45
53
  + `<button name="value" value="${at + 5}" aria-label="Pause 5% later"${off ? ' disabled' : ''}>+</button></form></div>`
46
- + `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' : 'Claude’s new work waits when either window passes this, and resumes at that window’s reset; Codex hands its work to Opus builders.'}</p></div>`;
54
+ + `<p class="u-share-desc">${off ? 'Off: work keeps going until usage runs out.' : `Claude’s new work waits when either window passes this, and resumes at that window’s reset; Codex hands its work to ${builder} implementers.`}</p></div>`;
47
55
  }
48
56
 
49
57
  // What polling does now, the last and next check and the queue, with Send now: hub/views.mjs pollingNow's words.
@@ -53,10 +61,10 @@
53
61
  // This device's notifications (hub-kit.js device): state is {title, sub, action: {label, act, quiet, disabled}} or
54
62
  // null while unknown, which keeps the row hidden.
55
63
  function deviceRow(state, { compact = false } = {}) {
56
- if (!state) return `<div class="row" id="push" hidden>${tile('bell', '#ef4444')}</div>`;
64
+ if (!state) return `<div class="row" id="push" hidden>${tile('bell', 'var(--accent)')}</div>`;
57
65
  const a = state.action;
58
66
  const button = a ? `<button type="button" class="${a.quiet ? 'btn quiet' : 'btn'}" data-device="${esc(a.act)}"${a.disabled ? ' disabled' : ''}>${esc(a.label)}</button>` : '';
59
- return `<div class="row" id="push">${tile('bell', '#ef4444')}<span class="body"><span class="text"><span class="title">${esc(state.title)}</span>${compact ? '' : `<span class="sub wrap">${esc(state.sub)}</span>`}</span>${compact && state.title === 'This device' ? `<span class="val">${state.sub?.startsWith('On') ? 'On' : 'Off'}</span>` : ''}${button}</span></div>`;
67
+ return `<div class="row" id="push">${tile('bell', state.action?.act === 'test' ? 'var(--color-success)' : 'var(--accent)')}<span class="body"><span class="text"><span class="title">${esc(state.title)}</span>${compact ? '' : `<span class="sub wrap">${esc(state.sub)}</span>`}</span>${compact && state.title === 'This device' ? `<span class="val">${state.sub?.startsWith('On') ? 'On' : 'Off'}</span>` : ''}${button}</span></div>`;
60
68
  }
61
69
 
62
70
  // The Codex share control: codex is hub/views.mjs hubView's codex, and its form posts to /_hub/settings/codex, which
@@ -65,14 +73,24 @@
65
73
  const off = !codex.usable;
66
74
  const buttons = codex.choices.map((c) => `<button type="submit" name="value" value="${esc(c.value)}" aria-pressed="${c.on}"${off ? ' disabled' : ''}>${esc(c.label)}</button>`).join('');
67
75
  return `<div class="u-share"><form class="segments${off ? ' is-disabled' : ''}" method="post" action="/_hub/settings/codex" role="group" aria-label="Codex share"${off ? ' aria-disabled="true"' : ''}>${buttons}</form>`
68
- + `<p class="u-share-desc">${esc(codex.description)}</p>${codex.fix ? `<p class="u-share-fix"><code>${esc(codex.fix)}</code></p>` : ''}${codex.note ? `<p class="u-share-note">${esc(codex.note)}</p>` : ''}</div>`;
76
+ + `<p class="u-share-desc">${esc(codex.description)}</p>${codex.fix ? `<p class="u-share-fix"><code>${esc(codex.fix)}</code></p>` : ''}</div>`;
69
77
  }
70
78
 
71
- // data is the usage reading; codex, when given, draws Codex's block even without a reading: the Codex share control
79
+ // The Claude model control, drawn as Codex share's: claude is hub/views.mjs hubView's claude, and its form posts to
80
+ // /_hub/settings/claude-model, which view-hub.js sends.
81
+ function claudeModel(claude) {
82
+ const buttons = claude.choices.map((c) => `<button type="submit" name="value" value="${esc(c.value)}" aria-pressed="${c.on}">${esc(c.label)}</button>`).join('');
83
+ return `<div class="u-share"><form class="segments" method="post" action="/_hub/settings/claude-model" role="group" aria-label="Claude model">${buttons}</form>`
84
+ + `<p class="u-share-desc">${esc(claude.description)}</p></div>`;
85
+ }
86
+
87
+ // data is the usage reading; claude, when given, opens Claude's block with the Claude model control, even without a
88
+ // reading; codex, when given, draws Codex's block even without a reading: the Codex share control
72
89
  // first, then Codex's bars while share isn't Off.
73
- function usageCard(data, codex, pause) {
90
+ function usageCard(data, codex, pause, claude) {
74
91
  const { usageProducts, usageNames, usageClock, usageTone, usageBar, usageWords } = BoardUI;
75
92
  const products = usageProducts(data);
93
+ if (claude && !products.includes('claude')) products.unshift('claude');
76
94
  if (codex && !products.includes('codex')) products.push('codex');
77
95
  if (!products.length) return '';
78
96
  const head = (name, tag) => `<div class="u-head"><span class="title">${name}</span>${tag ? `<span class="u-asof">${esc(tag)}</span>` : ''}</div>`;
@@ -81,13 +99,13 @@
81
99
  `<div class="u-tone-${usageTone(r[key])}${pause?.pause_at < 100 ? ' ub-marked' : ''}"><div class="u-win"><span>${label}</span><span>${usageWords(r, key)}</span></div>${usageBar(r[key]).replace('</span>', `${pause?.pause_at < 100 ? `<span class="ub-mark" style="left:${pause.pause_at}%"></span>` : ''}</span>`)}</div>`).join('');
82
100
  const blocks = products.map((p) => {
83
101
  const r = data?.[p] || { at: null };
84
- if (p !== 'codex' || !codex) return `<div class="block u-block">${head(usageNames[p], asof(r))}${windows(r)}${p === 'claude' && pause ? bufferControl(pause) : ''}</div>`;
102
+ if (p === 'claude' && claude) return `<div class="block u-block">${head(usageNames.claude, asof(r))}${claudeModel(claude)}${windows(r)}</div>`;
103
+ if (p !== 'codex' || !codex) return `<div class="block u-block">${head(usageNames[p], asof(r))}${windows(r)}</div>`;
85
104
  const bars = codex.usable && codex.share !== 'off';
86
105
  return `<div class="block u-block">${head(usageNames.codex, codex.tag || (bars ? asof(r) : ''))}${codexShare(codex)}${bars ? `<div class="u-codex-bars">${windows(r)}</div>` : ''}</div>`;
87
106
  }).join('');
88
- const source = [data?.claude && 'Claude Code’s status line, which every busy session redraws', data?.codex && 'Codex’s newest session logs'].filter(Boolean).join(', and ');
89
- const note = source ? `<p class="note">From ${source}. A window whose reset time passed reads as reset.</p>` : '';
90
- return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}</div>${note}</section>`;
107
+ const buffer = pause && products.includes('claude') ? bufferControl(pause, claude?.builder ?? 'Claude') : '';
108
+ return `<section class="usage"><h2 class="label" style="margin-top:4px">Usage</h2><div class="group">${blocks}${buffer}</div></section>`;
91
109
  }
92
110
 
93
111
  globalThis.HubUI = { esc, tile, CHEVRON, row, flash, holdBanner, pauseBanner, nowRow, deviceRow, usageCard };
@@ -3,22 +3,28 @@
3
3
  // the ui-*.js files that render text.
4
4
  (function () {
5
5
  const { esc, record } = globalThis.BoardUI;
6
- // The Linear id shape store.mjs accepts for a follow-up, which an internal ref shares.
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\//;
9
+ // A GitHub ticket's ref (board/domain.mjs refOf).
10
+ const ISSUE = /^#\d+$/;
9
11
  const prUrl = (repo, n) => `https://github.com/${repo}/pull/${n}`;
10
12
 
11
- // What a renderer needs to link text shown on item `key` (null for none): each ref to its card's key, the issue url
12
- // prefix of each Linear team the board's cards link to (none on a board without a workspace), that item's repo for
13
- // a bare #n, and `base`, which a page other than the board sets to './' so a card link opens the board. A ref shaped
14
- // like a Linear id, such as INT-<n>, links from anywhere; a wave's own short id, such as B1, links only from that
15
- // wave's cards, since a plan for another wave reuses those names.
13
+ // What a renderer needs to link text shown on item `key` (null for none): each ref, and each card id that differs
14
+ // from its ref, to its card's key, the issue url prefix of each Linear team the board's cards link to (none on a
15
+ // board without a workspace), that item's repo for a bare #n that is no card's ref, and `base`, which a page other
16
+ // than the board sets to './' so a card link opens the board. A ref shaped like a Linear id, such as INT-<n>, or a
17
+ // ticket #n links from anywhere; a wave's own short id, such as B1, links only from that wave's cards, since a plan
18
+ // for another wave reuses those names.
16
19
  function linkContext(items = [], key = null, { base = '' } = {}) {
17
20
  const wave = key && !key.startsWith('ask:') ? key.slice(0, key.indexOf('/')) : null;
18
21
  const byRef = new Map();
19
22
  for (const i of items) {
20
- if (i.kind === 'ask' || !i.ref || (!LINEAR_ID.test(i.ref) && i.wave !== wave)) continue;
21
- byRef.set(i.ref, [...(byRef.get(i.ref) || []), i]);
23
+ if (i.kind === 'ask') continue;
24
+ for (const ref of new Set([i.ref, i.id])) {
25
+ if (!ref || (!LINEAR_ID.test(ref) && !ISSUE.test(ref) && i.wave !== wave)) continue;
26
+ byRef.set(ref, [...(byRef.get(ref) || []), i]);
27
+ }
22
28
  }
23
29
  const refs = Object.create(null);
24
30
  for (const [ref, list] of byRef) {
@@ -62,11 +68,13 @@
62
68
  return anyLink(url, url) + esc(raw.slice(url.length));
63
69
  }
64
70
 
71
+ const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#item=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
72
+
65
73
  // The longest hyphen-prefix of a word that is a known ref or a Linear id, then the rest as text.
66
74
  function wordLink(word, ctx) {
67
75
  for (let w = word; w; w = w.includes('-') ? w.slice(0, w.lastIndexOf('-')) : '') {
68
76
  const rest = esc(word.slice(w.length));
69
- if (record(ctx.refs, w)) return `<a href="${esc(`${ctx.base || ''}#item=${encodeURIComponent(record(ctx.refs, w))}`)}">${esc(w)}</a>${rest}`;
77
+ if (record(ctx.refs, w)) return cardLink(w, ctx) + rest;
70
78
  const team = LINEAR_ID.exec(w)?.[1];
71
79
  const prefix = team && team !== 'INT' && record(ctx.teams, team);
72
80
  if (prefix) return out(prefix + w, w) + rest;
@@ -90,7 +98,7 @@
90
98
  else if (name) {
91
99
  const repo = owner ? `${owner}/${name}` : record(ctx.repos, name);
92
100
  html += repo ? out(prUrl(repo, n), all) : esc(all);
93
- } else if (bare) html += ctx.repo && !colour(bare) ? out(prUrl(ctx.repo, bare), 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);
94
102
  else html += wordLink(word, ctx);
95
103
  }
96
104
  return html + esc(s.slice(at));
@@ -0,0 +1,18 @@
1
+ // The board's order of its items, each lane together, which board/domain.mjs sorts the board by and the page sorts a
2
+ // tap it draws at once by, so the card lands where the server will put it
3
+ // (docs/adr/0140-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
4
+ // Needs nothing else: board/domain.mjs runs this file in a vm context.
5
+ (function () {
6
+ const LANE_ORDER = { review: 0, stale: 1, progress: 2, todo: 3, done: 4, asks: 5 };
7
+ // New cards lead Todo, oldest first, so the top one is the next taken (docs/adr/0047-the-plus-makes-a-new-card.md);
8
+ // then bumped cards, the latest bump on top (docs/adr/0019-a-card-that-hasnt-started-can-be-bumped.md); then the rest
9
+ // newest first by sortAt, a card with no time last, the last added to its wave file on top.
10
+ function laneOrder(a, b) {
11
+ const bumped = (i) => i.bumpedAt || '';
12
+ const unsorted = (i) => (i.isNew ? 0 : 1);
13
+ const at = (i) => String(i.sortAt || '');
14
+ const age = a.isNew ? at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
15
+ return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || unsorted(a) - unsorted(b) || bumped(b).localeCompare(bumped(a)) || age || b.order - a.order || a.key.localeCompare(b.key);
16
+ }
17
+ Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
18
+ })();
@@ -6,6 +6,7 @@
6
6
  const ARROW = icon(24, '<path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/>');
7
7
  const REFRESH = icon(18, '<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/>');
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
+ 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"/>');
9
10
  const id = (v) => (v ? ` id="${esc(v)}"` : '');
10
11
 
11
12
  // Where the back arrow goes (docs/adr/0048-a-questions-takes-show-inline.md): 'back', one step back in history, when
@@ -41,10 +42,10 @@
41
42
  // lead is 'logo' (the kickflip, which toggles the sidebar, sidebar.js; logoHref, the hub's page, without it)
42
43
  // or 'back' (the arrow, which follows backStep, with backHref the work item or the board, beside a phone-only logo). The controls (time and refresh, gear) paint disabled with refresh spinning until the
43
44
  // page's script turns them on (docs/adr/0021-the-board-shows-github-checks-and-watcher-health.md). With controls
44
- // false, trail (HTML) takes their place. leadAttrs adds to or replaces the lead link's attributes, and level is the
45
+ // 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
45
46
  // title's heading level, for a header that isn't the page's own, such as the sidebar's (ui-sidebar.js).
46
47
  function header({ lead = 'logo', title = '', titleId, backHref = './', backLabel = 'Back to the board', backId, logoSrc = 'kickflip.png',
47
- controls = true, trail = '', time = '…', settingsHref = 'settings.html', logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
48
+ controls = true, trail = '', time = '…', bulb = false, settingsHref = 'settings.html', logoHref = '/_hub/', leadAttrs = {}, level = 1 } = {}) {
48
49
  const attrs = (base) => Object.entries({ ...base, ...leadAttrs }).map(([k, v]) => ` ${k}="${esc(v)}"`).join('');
49
50
  const logo = (cls = 'hub') => `<a class="${cls}" href="${esc(logoHref)}"${attrs({ 'aria-label': 'Boards' })}><img src="${esc(logoSrc)}" alt=""></a>`;
50
51
  const first = lead === 'back'
@@ -52,6 +53,7 @@
52
53
  : logo();
53
54
  const nav = controls ? `<nav class="nav">`
54
55
  + `<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>`
56
+ + (bulb ? `<button class="ghost icon arch-bulb" id="archBulb" type="button" aria-label="Architecture review" title="Architecture review" aria-haspopup="dialog">${BULB}</button>` : '')
55
57
  + (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>` : '')
56
58
  + `</nav>` : trail;
57
59
  const script = lead === 'back' ? `<script>(${wireBack})(${backStep});</script>` : '';