@lovek4d/kickflip 0.6.1 → 0.7.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 (53) hide show
  1. package/CONTEXT.md +29 -17
  2. package/SKILL.md +12 -8
  3. package/bin/event-lines.mjs +1 -0
  4. package/bin/kickflip.mjs +7 -7
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -2
  7. package/board/arch-review.mjs +1 -1
  8. package/board/bin/board.mjs +46 -32
  9. package/board/domain.mjs +7 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +8 -9
  12. package/board/onboarding.mjs +1 -1
  13. package/board/prototype-page.mjs +1 -1
  14. package/board/public/app-arch.js +2 -2
  15. package/board/public/app-banner.js +1 -1
  16. package/board/public/app-cards.js +13 -4
  17. package/board/public/app-lanes.js +2 -2
  18. package/board/public/app-needs.js +107 -26
  19. package/board/public/app-reader.js +52 -42
  20. package/board/public/app-threads.js +2 -2
  21. package/board/public/app.html +1 -1
  22. package/board/public/app.js +22 -12
  23. package/board/public/progress-review.js +4 -4
  24. package/board/public/prototype-kit.js +25 -11
  25. package/board/public/prototype-switcher.js +47 -32
  26. package/board/public/prototype.html +7 -4
  27. package/board/public/router.js +34 -6
  28. package/board/public/shell.js +19 -13
  29. package/board/public/sidebar.css +2 -2
  30. package/board/public/style.css +58 -11
  31. package/board/public/ui-cards.js +44 -27
  32. package/board/public/ui-enums.js +10 -1
  33. package/board/public/ui-links.js +1 -1
  34. package/board/public/ui-order.js +8 -5
  35. package/board/public/ui-progress-bar.js +6 -7
  36. package/board/public/ui-questions.js +18 -3
  37. package/board/public/ui-sidebar.js +3 -2
  38. package/board/public/ui-threads.js +10 -12
  39. package/board/public/ui.js +4 -2
  40. package/board/public/view-notes.js +1 -1
  41. package/board/public/view-questions.js +18 -2
  42. package/board/public/view-settings.js +60 -28
  43. package/board/server.mjs +19 -20
  44. package/board/stop.mjs +1 -1
  45. package/board/store.mjs +25 -25
  46. package/board/tickets.mjs +1 -1
  47. package/board/transcript.mjs +4 -2
  48. package/hub/home.mjs +1 -1
  49. package/hub/hub.mjs +6 -6
  50. package/hub/try.mjs +6 -6
  51. package/hub/upgrade.mjs +1 -1
  52. package/package.json +1 -1
  53. package/shared/tailscale.mjs +1 -1
@@ -1,6 +1,6 @@
1
1
  // Work card renderers: the lines and chips of a card in a lane, on the board page. Needs ui.js, ui-enums.js and ui-progress-bar.js.
2
2
  (function () {
3
- const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, progressBar } = globalThis.BoardUI;
3
+ const { esc, escText, ago, lasted, clock, cardTime, etaLine, workingLine, thinkingLine, isPaused, PAUSED, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, PHASES, LINK_ARROW, progressBar } = globalThis.BoardUI;
4
4
  const EXTERNAL = { target: '_blank', rel: 'noopener' };
5
5
  // The PR badge is the one place a PR's state shows: its mark and tone, marked like the reviewer and CI chips, and its
6
6
  // title (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). A card with several PRs shows a
@@ -68,7 +68,7 @@
68
68
  function blockedWords(it) {
69
69
  const deps = it.blockedBy || [];
70
70
  const waits = it.bumpedAt && !it.held;
71
- const refs = deps.map((d, i) => `<a class="card-blocked" href="#item=${encodeURIComponent(d.key)}">${i ? '' : waits ? 'waits on ' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
71
+ const refs = deps.map((d, i) => `<a class="card-blocked" href="#selectedCard=${encodeURIComponent(d.key)}">${i ? '' : waits ? 'waits on ' : 'blocked by '}${esc(d.ref)}</a>`).join('<span class="sep">, </span>');
72
72
  const until = it.blockedUntil ? `${deps.length ? ' and until' : waits ? 'waits until' : 'blocked until'} ${it.blockedUntil}` : '';
73
73
  const tail = until + (it.blockReason && (refs || until) ? `, ${it.blockReason}` : '');
74
74
  return refs + (tail ? `<span class="card-blocked">${esc(tail)}</span>` : '');
@@ -99,7 +99,7 @@
99
99
  if (!t) return null;
100
100
  const words = tryWords(t.kind);
101
101
  if (t.state === 'running') return { tone: 'notice', html: spinner + esc(words.going), attrs: { role: 'status' } };
102
- if (t.state === 'failed') return { tone: 'danger', html: `${esc(words.failed)} ${LINK_ARROW}`, href: `#item=${encodeURIComponent(it.key)}`,
102
+ if (t.state === 'failed') return { tone: 'danger', html: `${esc(words.failed)} ${LINK_ARROW}`, href: `#selectedCard=${encodeURIComponent(it.key)}`,
103
103
  title: 'The build script stopped with an error. Tap for its log in the card’s thread.', attrs: { 'data-try-log': it.key } };
104
104
  if (t.state !== 'ready' || !t.url) return null;
105
105
  return { tone: t.stale ? 'quiet' : 'ready', html: `${esc(words.done)}${t.stale ? ' (stale)' : ''} ${LINK_ARROW}`, href: t.url,
@@ -172,23 +172,15 @@
172
172
  // Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
173
173
  // store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
174
174
  // docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
175
- const BUILD_PHASES = ['reading', 'building', 'testing', 'checking', 'landing'];
175
+ const BUILD_PHASES = PHASES.progress;
176
176
  const stepWords = (phase) => `step ${BUILD_PHASES.indexOf(phase) + 1} of ${BUILD_PHASES.length}`;
177
177
  const ALL_DONE = `all ${BUILD_PHASES.length} steps done`;
178
- // A card's progress bar (ui-progress-bar.js), or null off In Progress and on a card only a planner works on: how many
179
- // phases it reached, which of them is running, whether it dims because the card waits on something other than a builder (its
180
- // questions, a blocker or queue, a reopening) or its build is stale, and its label. An install is no build: its card keeps the phase its
181
- // build reached, undimmed
182
- // (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
183
- // A Todo card a grill or prototype runs on draws the planner's dashes instead, in Todo's grey, the grill's first and the
184
- // prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md,
185
- // docs/adr/2026-11-06-0145-every-run-takes-its-lanes-colour.md).
178
+ // A card's progress bar (ui-progress-bar.js), In Progress's phase, or null off In Progress and on a card only a planner
179
+ // works on: how many phases it reached, which of them is running, whether it dims because the card waits on something
180
+ // other than a builder (its questions, a blocker or queue, a reopening) or its build is stale, and its label. An install
181
+ // is no build: its card keeps the phase its build reached, undimmed
182
+ // (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md, docs/adr/2026-11-11-1200-every-lane-has-phases.md).
186
183
  function phaseStrip(it) {
187
- if (it.lane === 'todo' && it.builder?.planner) {
188
- const prototype = it.builder.kind === 'prototype';
189
- const done = prototype ? 2 : 1;
190
- return { count: BUILD_PHASES.length, done, now: done - 1, tone: 'in-todo', label: prototype ? 'Prototyping' : 'Grilling' };
191
- }
192
184
  if (it.lane !== 'progress' || it.builder?.planner) return null;
193
185
  const all = BUILD_PHASES.length;
194
186
  // Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
@@ -245,7 +237,7 @@
245
237
  if (it.waitsOn === 'checking') return 'Orchestrator review';
246
238
  if (it.lane === 'done') return 'done';
247
239
  if (it.grilling || it.prototyping) return 'preheating';
248
- if (it.lane === 'todo') return it.staging ? 'staging' : it.ready ? 'ready' : 'to do';
240
+ if (it.lane === 'todo') return it.phase && (it.ready || it.phase !== 'ready') ? PHASE_LOOK[it.phase][0] : 'to do';
249
241
  if (it.lane === 'progress') return it.reachedPhase ? `stopped at ${it.reachedPhase}` : 'in progress';
250
242
  return 'in review';
251
243
  }
@@ -267,17 +259,36 @@
267
259
  : `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.'}`;
268
260
  return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
269
261
  }
262
+ // docs/adr/2026-11-11-1200-every-lane-has-phases.md
263
+ // A Todo, In Review or Done card's phase as a word, toned by who acts next: grey while a machine waits, blue while
264
+ // Claude works, orange while it waits on the owner, green once it can go and purple once merged. In Progress shows its
265
+ // phase only as its progress bar, and a card In Review for an owner wait other than the merge has none. Ready reads
266
+ // only on a card that can start (domain.mjs ready), so a blocked one shows what blocks it instead.
267
+ const PHASE_LOOK = {
268
+ new: ['new', 'quiet'], grilling: ['grilling', 'info'], prototyping: ['prototyping', 'info'], staging: ['staging', 'notice'], ready: ['ready', 'success'],
269
+ draft: ['draft', 'quiet'], reviewers: ['pending review', 'notice'], merge: ['pending merge', 'notice'], stale: ['stale', 'quiet'],
270
+ merged: ['merged', 'merged'], closed: ['closed', 'quiet'],
271
+ };
272
+ function phaseWord(it) {
273
+ if (it.kind !== 'work' || it.lane === 'progress' || !it.phase || (it.phase === 'ready' && !it.ready)) return '';
274
+ const [word, tone] = PHASE_LOOK[it.phase];
275
+ return `<span class="card-phase ph-${tone}">${word}</span>`;
276
+ }
270
277
  // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed,
271
- // staging in orange or ready in green (docs/adr/2026-11-07-0700-staging-then-ready.md), stalled in red or stale in grey
272
- // (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
278
+ // bumped out by the card whose bump took its slot, or held while host recovers on a bumped ready card a busy host holds,
279
+ // both grey (docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), stalled in red or a build's stale
280
+ // in grey (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md), a stale review reading it
281
+ // once, as its phase, then the phase word (phaseWord), then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
273
282
  // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
274
283
  // docs/adr/2026-11-03-0516-a-new-card-is-ready-grilling-or-prototyping.md, docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
275
284
  const SEP = '<span class="sep"> · </span>';
276
285
  const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
277
286
  function timeLine(it, unread, bars) {
278
287
  const iso = it.sortAt;
279
- const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it), it.staging && '<span class="card-staging">staging</span>', it.ready && '<span class="card-ready">ready</span>',
280
- it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && '<span class="card-stale">stale</span>'].filter(Boolean);
288
+ const parts = [iso && `<time datetime="${esc(iso)}">${esc(cardTime(iso))}</time>`, holdWords(it), blockedWords(it), actionWords(it),
289
+ it.bumpedOut && `<a class="card-bumped-out" href="#selectedCard=${encodeURIComponent(it.bumpedOut.key)}">bumped out by ${esc(it.bumpedOut.ref)}</a>`,
290
+ it.hostHeld && '<span class="card-host-held">held while host recovers</span>',
291
+ it.stalled ? '<span class="card-stalled">stalled</span>' : it.stale && it.phase !== 'stale' && '<span class="card-stale">stale</span>', phaseWord(it)].filter(Boolean);
281
292
  const news = newWords(unread, 'message');
282
293
  const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
283
294
  const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
@@ -296,7 +307,7 @@
296
307
  + `<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>`;
297
308
  }
298
309
  // A New card's bump, edit and delete, and its line from its ask state (domain.mjs askItem;
299
- // docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is the latest bump.
310
+ // docs/adr/2026-10-29-1639-the-plus-makes-a-new-card.md). top is whether it is bumped already.
300
311
  const askActions = (it, top) => `<div class="ask-actions">${bumpButton(it, top)}${it.ask.canEdit ? editButton(it.id) : ''}${withdrawButton(it.id)}</div>`;
301
312
  // A card waiting on its owner step has the owner's Done where the bump sits: a check icon that confirms, then sends
302
313
  // card-done (app-cards.js; docs/adr/2026-11-04-0158-a-card-whose-code-landed-stays-in-review-while-an-owner-step-remains.md).
@@ -304,15 +315,21 @@
304
315
  const ownerDoneButton = (it) => `<button type="button" class="ghost icon owner-done" data-confirm="finish" data-key="${esc(it.key)}" aria-label="Done" title="${esc(`Done: ${it.ownerStep}`)}">${CHECK}</button>`;
305
316
  const cardActions = (it, top) => (it.isNew ? askActions(it, top) : it.ownerStep ? `<div class="ask-actions">${ownerDoneButton(it)}</div>`
306
317
  : it.bumpable ? `<div class="ask-actions">${cardBumpButton(it.key, Boolean(it.bumpedAt))}</div>` : '');
307
- const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && thinkingLine()].filter(Boolean).join(' · ');
318
+ // docs/adr/2026-11-07-0115-the-orchestrator-sorts-new-tasks-itself.md: a New card reads a grey "waiting", its dot
319
+ // still, until the orchestrator picks it up (the watcher's working mark), then "thinking".
320
+ const PICKUP_WAIT = '<span class="pickup-wait"><span class="working-dot" aria-hidden="true"></span>waiting</span>';
321
+ const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' ? thinkingLine() : ask.state === 'waiting' && PICKUP_WAIT].filter(Boolean).join(' · ');
308
322
  // In a wave band (app-lanes.js), step is the number of the card's phase in its wave, shown inside its id chip, and actions replace
309
323
  // its bump with the band's ⋯ menu.
310
324
  function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
311
325
  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);
312
- // A work card always shows its ticket; New is only on an ask (askChip).
326
+ // A work card always shows its ticket; an ask, which has none, shows New or Ask there (askChip).
313
327
  const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
314
328
  const ids = it.kind === 'ask' ? askChip(it) : chip;
315
329
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
330
+ // A handled ask's asked time sits under its title, since its Ask badge holds the ticket's spot
331
+ // (docs/adr/2026-11-10-1200-an-ask-is-titled-in-place-and-wears-an-ask-badge.md).
332
+ const asked = it.kind === 'ask' && !it.isNew ? `<div class="line3 sub">asked ${esc(cardTime(it.ask.askedAt))}</div>` : '';
316
333
  const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
317
334
  : it.orchestrator && !it.builder ? `${it.rebaseFailed ? `<div class="line3 sub">${escText(it.rebaseFailed)}</div>` : ''}${orchestratorStatus(it)}`
318
335
  : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
@@ -327,12 +344,12 @@
327
344
  const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${escText(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
328
345
  : `<div class="line3 sub check">${escText(`Checks: ${it.ownerCheck.words}`)}</div>`;
329
346
  return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${escText(it.title)}</div>
330
- ${status}${check}${eta}
347
+ ${asked}${status}${check}${eta}
331
348
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
332
349
  ${timeLine(it, unread, progressBar(phaseStrip(it)))}`;
333
350
  }
334
351
  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>';
335
- const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
352
+ const askChip = (it) => (it.isNew ? badge('notice', 'New') : badge('quiet', 'Ask'));
336
353
  function planButton(items) {
337
354
  const keys = items.filter((i) => i.kind === 'work' && !i.held && !i.blocked).map((i) => i.key);
338
355
  return keys.length > 1 ? `<button type="button" class="thread-more" data-plan="${esc(keys.join(' '))}"${BoardUI.pending.has('todo:plan') ? BoardUI.BUSY : ''}>Plan waves</button>` : '';
@@ -7,5 +7,14 @@
7
7
  const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done', stalled: 'stalled' });
8
8
  const BUILDER_KIND = Object.freeze({ install: 'install' });
9
9
  const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
10
- Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE });
10
+ // Each lane's phases, in order, which a card stores beside its lane (docs/adr/2026-11-11-1200-every-lane-has-phases.md).
11
+ // In Progress's are the five build steps its progress bar draws; a review's "reviewers" and "merge" read "pending review"
12
+ // and "pending merge" on the card (ui-cards.js PHASE_LOOK).
13
+ const PHASES = Object.freeze({
14
+ todo: Object.freeze(['new', 'grilling', 'prototyping', 'staging', 'ready']),
15
+ progress: Object.freeze(['reading', 'building', 'testing', 'checking', 'landing']),
16
+ review: Object.freeze(['draft', 'reviewers', 'merge', 'stale']),
17
+ done: Object.freeze(['merged', 'closed']),
18
+ });
19
+ Object.assign(globalThis.BoardUI ||= {}, { BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, PHASES });
11
20
  })();
@@ -68,7 +68,7 @@
68
68
  return anyLink(url, url) + esc(raw.slice(url.length));
69
69
  }
70
70
 
71
- const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#item=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
71
+ const cardLink = (ref, ctx) => `<a href="${esc(`${ctx.base || ''}#selectedCard=${encodeURIComponent(record(ctx.refs, ref))}`)}">${esc(ref)}</a>`;
72
72
 
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) {
@@ -1,23 +1,26 @@
1
1
  // The board's order of its items, each lane together, which board/domain.mjs sorts the board by and the page sorts a
2
2
  // tap it draws at once by, so the card lands where the server will put it
3
- // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, sortAt, order and key.
3
+ // (docs/adr/2026-11-03-0223-each-lane-has-one-fixed-order.md). Each item carries its lane, isNew, bumpedAt, bumpedOut, sortAt, order and key.
4
4
  // Needs nothing else: board/domain.mjs runs this file in a vm context.
5
5
  (function () {
6
6
  const LANE_ORDER = { review: 0, progress: 1, todo: 2, done: 3, asks: 4 };
7
- // Todo lists bumped cards first, the latest bump on top, a bumped New ask included
8
- // (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md); then the rest oldest first by sortAt, a card with no
7
+ // Every lane but Done lists bumped cards first, the earliest bump on top, a bumped New ask included
8
+ // (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
9
+ // docs/adr/2026-11-06-1700-a-bump-marks-the-priority-through-every-lane.md), then in Todo the cards a bump bumped out,
10
+ // the earliest first, so the pull takes them next. Todo lists the rest oldest first by sortAt, a card with no
9
11
  // time last, the first added to its wave file on top, so a New ask and every new card join the bottom and the pull takes the
10
12
  // top ready card (docs/adr/2026-11-05-2345-new-todo-cards-join-the-bottom.md,
11
13
  // docs/adr/2026-11-06-0300-a-new-task-lands-at-the-bottom-of-todo-and-flashes-blue.md). Every other
12
14
  // lane lists its cards newest first by sortAt, a card with no time last, the last added to its wave file on top.
15
+ // Present times first, the earliest on top.
16
+ const earliest = (x, y) => (x ? 0 : 1) - (y ? 0 : 1) || String(x || '').localeCompare(String(y || ''));
13
17
  function laneOrder(a, b) {
14
- const bumped = (i) => i.bumpedAt || '';
15
18
  const at = (i) => String(i.sortAt || '');
16
19
  const oldestFirst = a.lane === 'todo';
17
20
  const timeless = (i) => (at(i) ? 0 : 1);
18
21
  const age = oldestFirst ? timeless(a) - timeless(b) || at(a).localeCompare(at(b)) : at(b).localeCompare(at(a));
19
22
  const added = a.lane !== 'todo' ? b.order - a.order : a.order - b.order;
20
- return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || bumped(b).localeCompare(bumped(a)) || age || added || a.key.localeCompare(b.key);
23
+ return LANE_ORDER[a.lane] - LANE_ORDER[b.lane] || earliest(a.bumpedAt, b.bumpedAt) || earliest(a.bumpedOut?.at, b.bumpedOut?.at) || age || added || a.key.localeCompare(b.key);
21
24
  }
22
25
  Object.assign(globalThis.BoardUI ||= {}, { laneOrder });
23
26
  })();
@@ -1,19 +1,18 @@
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)
1
+ // The progress bar every bar on the board draws from: an In Progress card's five build phases (ui-cards.js), and a
2
+ // starting board's three start steps (ui-sidebar.js)
3
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
4
  // renders the shell's first sidebar with it in a vm context. style.css draws the bars.
5
5
  (function () {
6
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
7
+ // {count, done, now, percent, dim, label}, or null for no bar. done counts the steps reached, filled; now is the
8
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.
9
+ // ones that far. A dim bar draws its running step filled, without the pulse.
11
10
  function progressBar(bar) {
12
11
  if (!bar) return '';
13
- const { count, done, now, percent, dim, tone, label } = bar;
12
+ const { count, done, now, percent, dim, label } = bar;
14
13
  const step = (i) => (i === now ? `<i class="${dim ? 'done' : 'now'}"></i>` : i < done ? '<i class="done"></i>'
15
14
  : 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>`;
15
+ return `<span class="phase-bars${dim ? ' dim' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${Array.from({ length: count }, (_, i) => step(i)).join('')}</span>`;
17
16
  }
18
17
  // Every pulse on the page beats on one clock: each one that starts is set to the document timeline's origin, so a
19
18
  // card's running step and its status dot blink together, and a redraw that draws a new copy never restarts the beat.
@@ -129,8 +129,8 @@
129
129
  ${answerHistory(q, links)}
130
130
  </section>`;
131
131
  }
132
- // A card's pill is its ID, or for an ask (item:ask:<id>) the time it appeared; other topics keep their title.
133
- const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : (String(t.title).match(/^([A-Z]+-\d+|[A-Z]\d+):/) || [])[1] || t.title);
132
+ // A pill reads its topic's title, a card's being its ref then its title, whichever tracker it comes from; an ask (item:ask:<id>) reads the time it appeared.
133
+ const topicName = (t) => (t.askedAt ? BoardUI.cardTime(t.askedAt) : t.title);
134
134
  // /api/board's ownerTopics, each card's topic named from its card, which the board sends in its place while the card
135
135
  // is on the board (board/server.mjs pageBoard). cards: the board's items.
136
136
  function cardTopics(topics, cards) {
@@ -174,5 +174,20 @@
174
174
  },
175
175
  };
176
176
 
177
- Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, option, answerHistory, questionCard, roundChip, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
177
+ // A prototype viewer's Round pill and its sheet, on the full page (prototype-switcher.js) and in the board's right pane
178
+ // (app-needs.js). rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds
179
+ // lists them ({n, q, url, confirm, state}). The pill names the round qid is in, out of the newest round's number; a
180
+ // card with a single round gets none.
181
+ function roundPill(rounds, qid) {
182
+ const at = (rounds || []).find((r) => r.q === qid);
183
+ if (!at || rounds.length < 2) return '';
184
+ return `<button type="button" class="ps-chip" data-ps-top="rounds">Round ${at.n} of ${Math.max(...rounds.map((r) => r.n))} · earlier</button>`;
185
+ }
186
+ const ROUND_WORD = { open: 'open', requested: 'prototype requested' };
187
+ function roundSheet({ ref, rounds, qid }) {
188
+ const rows = [...rounds].reverse().map((r) => `<button type="button" data-ps-round="${esc(r.url)}"><span>Round ${r.n}${r.confirm ? ' confirm' : ''}${r.q === qid ? ' · showing' : ''}</span><span>${ROUND_WORD[r.state] || 'answered'}</span></button>`);
189
+ return `<div class="ps-sheet" role="dialog" aria-label="Rounds"><h3>Rounds on ${esc(ref)}</h3>${rows.join('')}</div>`;
190
+ }
191
+
192
+ Object.assign(globalThis.BoardUI, { isPrototype, getDraft, setDraft, choiceLabel, choiceOf, noteOf, isSent, option, answerHistory, questionCard, roundChip, roundPill, roundSheet, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
178
193
  })();
@@ -209,7 +209,8 @@
209
209
  // (docs/adr/2026-11-06-1305-a-busy-host-shows-only-on-the-hub-row-and-the-logo-dot.md).
210
210
  const busy = hubBusy(data);
211
211
  // A board under Pause board carries its own pause (docs/adr/2026-11-05-1849-pause-board-and-pause-hub-pause-new-work-by-hand.md).
212
- const holdWord = (s) => (paused || s.pause?.state === 'paused' ? word('paused', 'paused') : '');
212
+ // A red fault on a board row keeps its word, and a down or degraded hub keeps the Hub row: red and orange beat the grey pause (AGENTS.md).
213
+ const holdWord = (s) => (paused || s.pause?.state === 'paused') && !/class="word t-down"/.test(s.sub || '') ? word('paused', 'paused') : '';
213
214
  const siteRow = (s) => {
214
215
  const sub = s.board && holdWord(s) ? holdWord(s) + (s.sub || '').replace(/<span class="dot [^"]*"[^>]*><\/span><span class="word [^"]*">[^<]*<\/span>/, '') : s.sub;
215
216
  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)) });
@@ -227,7 +228,7 @@
227
228
  : 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' })
228
229
  : '';
229
230
  const usage = sidebarUsage(data?.usage);
230
- const hub = paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
231
+ const hub = data?.hub?.tone === 'down' || data?.hub?.tone === 'wait' ? data.hub : paused ? { tone: 'paused', word: 'paused', detail: pauseUntilWords(data.pause.resets_at) } : busy ? { tone: 'wait', word: 'busy', detail: 'the host is busy' } : data?.hub;
231
232
  const n = data?.notifications || {};
232
233
  const lower = (text) => String(text || '').replace(/^\w/, (c) => c.toLowerCase());
233
234
  const notify = device === 'blocked' ? word('needs', 'Notifications blocked on this device')
@@ -3,9 +3,9 @@
3
3
  (function () {
4
4
  const { esc, escText, time, etaLine, replyBody, attachments, dropZone, sendButton, editButton, withdrawButton, thinkingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
5
5
  const VIA = { slack: ' · Slack', github: ' · GitHub' };
6
- // A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
6
+ // A link to a card on this board (#selectedCard=<key>) or on another board (/<route>/#selectedCard=<key>, an ask's move), or ''.
7
7
  function cardLink(link) {
8
- const key = /^(?:\/[\w-]+\/)?#item=([^&]+)/.exec(link)?.[1];
8
+ const key = /^(?:\/[\w-]+\/)?#(?:selectedCard|item)=([^&]+)/.exec(link)?.[1];
9
9
  if (!key) return '';
10
10
  let ask = key.startsWith('ask:');
11
11
  try { ask = decodeURIComponent(key).startsWith('ask:'); } catch {}
@@ -41,7 +41,7 @@
41
41
  : m.kind === 'visual' ? `<div class="msg link visual">${statusMark('visual')}<a href="${esc(m.link)}">${escText(m.text)} →</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
42
42
  // A request line's mark is its words' icon, or m.mark's for a line whose words vary; a Full person's names them.
43
43
  : m.request && m.card ? `<div class="msg request">${statusMark(m.mark)}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
44
- : m.request ? `<div class="msg request">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
44
+ : m.request ? `<div class="msg request${m.command === 'readied' ? ' readied' : ''}">${statusMark(m.mark || m.text)}${escText(m.text)} <span class="muted">· ${m.by ? `${esc(m.by)} · ` : ''}${esc(time(m.ts))}</span></div>`
45
45
  // docs/adr/2026-11-07-1400-a-card-stores-its-lane-and-facts-replace-statuses.md: a card's move between lanes.
46
46
  : m.kind === 'move' ? `<div class="msg request move">${statusMark('move')}${escText(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
47
47
  : m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${escText(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
@@ -52,22 +52,20 @@
52
52
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
53
53
  <div class="who">${m.who === 'love' ? (m.by ? esc(m.by) : 'You') : m.who === 'claude' ? 'Orchestrator' : esc(m.who)}${VIA[m.via] || ''}${m.moved ? ' · Moved' : ''}${m.from ? ` · Moved from ${esc(m.from)}` : ''}${m.edited ? ' · Edited' : ''} · ${esc(time(m.ts))}</div>
54
54
  <div class="body"${edits.has(m.eventId) ? ' hidden' : ''}>${m.html ? linkHtml(m.html, links) : linkText(m.text, links)}</div>${edits.has(m.eventId) ? editForm(m.eventId, edits.get(m.eventId).text) : ''}${attachments(m.files)}
55
- ${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">Open the log</a></div>` : ''}
55
+ ${m.log ? `<div class="small"><a href="log.html?file=${encodeURIComponent(m.log)}">View transcript</a></div>` : ''}
56
56
  ${m.link ? `<div class="small">${cardLink(m.link) || anyLink(m.link, m.link)}</div>` : ''}
57
57
  ${m.eventId ? msgFooter(m) : ''}
58
58
  </div>`);
59
59
 
60
60
  // One agent run (domain.mjs threadOf gives its started line, status lines and finished line the same `run`) is one
61
- // closed details line at its started line's place: the agent, running with its newest status text or finished, and
62
- // no line count, and ending with a log link beside the summary when the run has a log; expanding it shows the run's lines.
61
+ // closed details line at its first line's place: the action, then its status, finished when its newest line is a finished
62
+ // line and running otherwise, and nothing after;
63
+ // expanding it shows the run's lines and, when the run has a log, a View transcript link.
63
64
  function foldedRun(m, links, edits) {
64
- const statuses = m.folded.filter((f) => f.kind === 'status');
65
- const running = !m.folded.some((f) => f.finished);
66
- const newest = statuses.at(-1);
67
- const tail = running ? (newest ? `running · ${escText(newest.text)}` : 'running') : 'finished';
65
+ const tail = m.folded.at(-1).finished ? 'finished' : 'running';
68
66
  const log = m.folded.find((f) => f.log)?.log;
69
- const logLink = log ? ` <a class="fold-log" href="log.html?file=${encodeURIComponent(log)}">log</a>` : '';
70
- return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${running ? '' : ' class="finished"'}>${statusMark('status lines')}${escText(m.agent)} · ${tail}${logLink}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}</details>`;
67
+ const logLine = log ? `<div class="small fold-log"><a href="log.html?file=${encodeURIComponent(log)}">View transcript</a></div>` : '';
68
+ return `<details class="msg calls status-fold${m.planner ? ' planner' : ''}"><summary${tail === 'finished' ? ' class="finished"' : ''}>${statusMark('status lines')}${escText(m.agent)} · ${tail}</summary>${m.folded.map((f) => line(f, links, edits)).join('')}${logLine}</details>`;
71
69
  }
72
70
  function foldStatus(lines) {
73
71
  const runs = new Map();
@@ -103,6 +103,8 @@
103
103
  'ask-link': mini('<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>'),
104
104
  move: mini('<path d="M3 7h14"/><path d="m13 3 4 4-4 4"/><path d="M21 17H7"/><path d="m11 13-4 4 4 4"/>'),
105
105
  'Implementation requested': mini('<path d="m8 6-6 6 6 6"/><path d="m16 6 6 6-6 6"/>'),
106
+ 'Marked as staging': mini('<path d="m12 2 10 5-10 5L2 7z"/><path d="m2 12 10 5 10-5"/><path d="m2 17 10 5 10-5"/>'),
107
+ 'Marked as ready': mini('<path d="M4 22V4"/><path d="M4 4h13l-2 4 2 4H4"/>'),
106
108
  Held: mini('<path d="M9 5v14"/><path d="M15 5v14"/>'),
107
109
  Resumed: mini('<path d="M7 4v16l13-8z"/>'),
108
110
  'Related to': LINK, 'Unrelated from': LINK, 'Follow-up': LINK, 'Follow-up of': LINK,
@@ -122,8 +124,8 @@
122
124
  // Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
123
125
  const withdrawButton = (id) => `<button class="ghost icon" data-withdraw="${esc(id)}" aria-label="Delete" title="Delete">${TRASH}</button>`;
124
126
  const editButton = (id) => `<button class="ghost icon" data-edit="${esc(id)}" aria-label="Edit" title="Edit">${PENCIL}</button>`;
125
- // A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange once bumped and disabled on the latest
126
- // bump. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
127
+ // A waiting ask's bump, which app-asks.js sends as ask-bump {id}, filled orange and disabled once
128
+ // bumped. A work card's is the same icon: app-cards.js sends card-bump {key} with no confirm, and on a bumped card, where
127
129
  // it is filled orange, card-unbump {key} (docs/adr/2026-10-29-1639-a-card-that-hasnt-started-can-be-bumped.md,
128
130
  // docs/adr/2026-11-03-0301-build-on-a-blocked-card-queues-it.md).
129
131
  const bumpButton = (it, top) => `<button class="ghost icon${it.bumpedAt ? ' bumped' : ''}" data-bump="${esc(it.id)}" aria-label="Bump to top" title="Bump to top"${top ? ' disabled' : ''}>${BUMP}</button>`;
@@ -4,7 +4,7 @@
4
4
  (globalThis.AppViews ||= {}).notes = {
5
5
  className: 'view-notes',
6
6
  title: () => 'Notes',
7
- markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#item=${encodeURIComponent(item || '')}`, controls: false })}
7
+ markup: ({ item, wave }) => `${BoardUI.header({ lead: 'back', title: 'Notes', titleId: 'ref', backHref: wave ? './' : `./#selectedCard=${encodeURIComponent(item || '')}`, controls: false })}
8
8
  <main>
9
9
  <article class="plan" id="plan"><p class="muted">Loading the notes…</p></article>
10
10
  </main>`,
@@ -85,7 +85,7 @@
85
85
  return `<div class="topics" role="group" aria-label="Topics">${list.map((t) => pillHtml(t)).join('')}</div>`;
86
86
  }
87
87
  // An arrival (arrive) slides in with a glow and settles (style.css .arrived).
88
- const pillHtml = (t, arrived = false) => `<button class="${arrived ? 'arrived ' : ''}${t.progress ? 'progress' : ''}" data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}">${esc(topicName(t))}<span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`;
88
+ const pillHtml = (t, arrived = false) => `<button class="${arrived ? 'arrived ' : ''}${t.progress ? 'progress' : ''}" data-topic="${esc(t.id)}" aria-pressed="${t.id === topicId}" title="${esc(t.title)}"><span class="name">${esc(topicName(t))}</span><span class="n">${t.state === 'resolved' ? 'resolved' : t.open}</span></button>`;
89
89
  const topicHead = (t) => t ? `<div class="topichead muted small"><span>${esc(t.title)}${t.summary ? ` · ${esc(t.summary)}` : ''}</span>${roundChip(t.round)}</div>` : '';
90
90
 
91
91
  // Only open questions changed here since their last answer, with files attached, reopened with a pick, or with a
@@ -150,10 +150,12 @@
150
150
  updateBar();
151
151
  }
152
152
 
153
+ // A toast names a topic by its card's ref, the title up to its colon; an ask by its time.
154
+ const toastName = (t) => (t.askedAt ? topicName(t) : String(t.title).match(/^(\S+):/)?.[1] || t.title);
153
155
  // The topic a tap on this page acts for, the pills before it and its name, which moveOn reads once it is recorded.
154
156
  function leaving() {
155
157
  const from = soloId ? topicOf(soloId)?.id : topicId;
156
- return { from, pills: topicRow(topics, from).pills, name: topicName(topics.find((t) => t.id === from) || { title: '' }) };
158
+ return { from, pills: topicRow(topics, from).pills, name: toastName(topics.find((t) => t.id === from) || { title: '' }) };
157
159
  }
158
160
  // After a recorded tap: a load that started before it went out would read its question as still open.
159
161
  // `footer` is a line or, for a Send, a function of the reloaded board giving the whole line.
@@ -364,6 +366,14 @@
364
366
  scroll(0);
365
367
  }
366
368
 
369
+ let lastTap = { id: null, at: 0 };
370
+ // item:<key> is a card, wave:<slug> a band. Embedded, the board is beside the pane; alone, it is the page before this one.
371
+ function openOnBoard(id) {
372
+ if (ctx.embedded) return ctx.openOnBoard(id);
373
+ if (!id.startsWith('item:')) return;
374
+ const href = new URL(`./#item=${encodeURIComponent(id.slice(5))}`, location.href).href;
375
+ if (!AppShell.open(href)) location.assign(href);
376
+ }
367
377
  B.on(root, 'click', (ev) => {
368
378
  if (ev.target.id === 'showAll') { soloId = null; sentLine = ''; shown = ''; render(); return; }
369
379
  const eye = ev.target.closest && ev.target.closest('[data-proto]');
@@ -372,9 +382,14 @@
372
382
  if (reopenBadge) { if (!reopenBadge.disabled) reopen(byId(reopenBadge.dataset.reopen), reopenBadge); return; }
373
383
  const b = ev.target.closest && ev.target.closest('[data-topic]');
374
384
  if (b) {
385
+ // Two taps on one pill within 300ms open its card on the board; a touch screen sends no dblclick.
386
+ const now = Date.now();
387
+ if (lastTap.id === b.dataset.topic && now - lastTap.at < 300) { lastTap = { id: null, at: 0 }; openOnBoard(b.dataset.topic); return; }
388
+ lastTap = { id: b.dataset.topic, at: now };
375
389
  soloId = null; setTopic(b.dataset.topic); sentLine = ''; shown = '';
376
390
  const first = topics.find((t) => t.id === topicId)?.questions[0];
377
391
  if (first) setHash(first);
392
+ ctx.onTopic?.(topicId);
378
393
  render(); scroll(0);
379
394
  }
380
395
  });
@@ -441,6 +456,7 @@
441
456
  if (!loaded) return false;
442
457
  const topic = topics.find((t) => t.id === id);
443
458
  if (topic) { soloId = null; setTopic(topic.id); } else pick(id || '');
459
+ ctx.onTopic?.(topicId);
444
460
  sentLine = '';
445
461
  shown = '';
446
462
  render();
@@ -1,7 +1,7 @@
1
1
  // Board Settings, a modal the app shell (shell.js) mounts over its board for /<route>/settings.html.
2
2
  // Saving reviewers sends a `reviewers` event {add: [{name, slack_user, github}], remove: [name]}; the server writes both
3
- // itself and the list redraws from its answer. Add picks a Slack member and a GitHub login from lists the server fetches
4
- // (GET api/reviewer-people), or a typed login, and stays off until both are chosen. Tickets save on each change, and Done, ✕,
3
+ // itself and the list redraws from its answer. Add searches the Slack members the server fetches (GET api/reviewer-people), then
4
+ // takes a GitHub login guessed from the name or typed, and stays off until a member and a login are there. Tickets save on each change, and Done, ✕,
5
5
  // Escape and the backdrop wait for that save to land before closing. An unsaved edit marks [data-dirty], and the shell
6
6
  // asks before closing the modal then. Sharing (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md)
7
7
  // lists the hub's GET /_hub/share/<route>, who it's shared with, each View or Full, and who was just refused, and saves
@@ -76,9 +76,9 @@
76
76
  <p class="muted small">Everyone here gets each PR link on this board once it's ready, and the weekday digest. Someone you add also gets the links of PRs already in review.</p>
77
77
  <div id="list"><p class="muted small">Loading…</p></div>
78
78
  <form class="add pick" id="addForm">
79
- <div class="pick-col"><input id="slackFilter" placeholder="Slack member" autocomplete="off"><div id="slackPeople" class="pick-list"><p class="muted small">Loading…</p></div></div>
80
- <div class="pick-col"><input id="githubFilter" placeholder="GitHub login" autocomplete="off"><div id="githubPeople" class="pick-list"><p class="muted small">Loading…</p></div></div>
81
- <button type="submit" id="addBtn" disabled>Add</button>
79
+ <input id="slackFilter" placeholder="Search Slack members" autocomplete="off">
80
+ <div id="slackPeople" class="pick-list"><p class="muted small">Loading…</p></div>
81
+ <div class="login-row" id="loginRow" hidden><input id="githubFilter" placeholder="GitHub login" autocomplete="off" autocapitalize="off" spellcheck="false"><button type="submit" id="addBtn" disabled>Add</button></div>
82
82
  </form>
83
83
  <p class="muted small" id="pickStatus" role="status"></p>
84
84
  <div class="row" style="margin-top:12px"><span class="muted small" id="saveStatus"></span><button class="primary" id="save" disabled>Save</button></div>
@@ -362,40 +362,72 @@
362
362
  render();
363
363
  });
364
364
 
365
- // The pickers: tap a Slack member and a GitHub login (or type one), then Add stages them for Save.
366
- let people = { slack: [], github: [] };
367
- const picked = { slack: null, github: '' };
365
+ // The picker: search the Slack members and tap one, then the GitHub login field holds a guess from the name (or what
366
+ // the person types), and Add stages them for Save. people is null while loading, 'failed' once the list can't be read.
367
+ let people = null;
368
+ const picked = { slack: null };
368
369
  const fits = (text, filter) => text.toLowerCase().includes(filter.trim().toLowerCase());
370
+ const squash = (text) => text.toLowerCase().replace(/[^a-z0-9]/g, '');
371
+ // The collaborator whose login is a plain spelling of the name: first+last, first, last, or first initial + last.
372
+ function guessLogin(name, logins) {
373
+ const words = name.toLowerCase().split(/\s+/).map(squash).filter(Boolean);
374
+ if (!words.length) return '';
375
+ const first = words[0], last = words.at(-1);
376
+ const spellings = [first + last, first, last, first[0] + last];
377
+ for (const spelling of spellings) {
378
+ const hit = logins.find((l) => squash(l) === spelling);
379
+ if (hit) return hit;
380
+ }
381
+ return '';
382
+ }
383
+ const problem = (text) => `<p class="muted small problem">${esc(text)} <button type="button" class="ghost" data-retry>Retry</button></p>`;
369
384
  function renderPick() {
370
- const slackFilter = $('#slackFilter').value, githubFilter = $('#githubFilter').value.trim();
371
385
  const taken = new Set([...current, ...add.keys()].map((n) => n.toLowerCase()));
372
- const slack = (people.slack || []).filter((p) => fits(p.name, slackFilter) && !taken.has(p.name.toLowerCase()));
373
- $('#slackPeople').innerHTML = people.slack ? slack.map((p) => `<button type="button" class="ghost" data-slack="${esc(p.id)}" data-name="${esc(p.name)}" aria-pressed="${picked.slack?.id === p.id}">${esc(p.name)}</button>`).join('') || '<p class="muted small">No one matches.</p>'
374
- : `<p class="muted small">${esc(people.slackProblem || 'Slack members are not available.')} <a href="/_hub/settings">Hub settings</a></p>`;
375
- const logins = people.github.filter((l) => fits(l, githubFilter));
376
- const typed = githubFilter && !people.github.some((l) => l.toLowerCase() === githubFilter.toLowerCase()) ? [githubFilter] : [];
377
- $('#githubPeople').innerHTML = [...typed, ...logins].map((l) => `<button type="button" class="ghost" data-github="${esc(l)}" aria-pressed="${picked.github === l}">${typed.includes(l) ? `Use ${esc(l)}` : esc(l)}</button>`).join('')
378
- + (people.githubProblem ? `<p class="muted small">${esc(people.githubProblem)}</p>` : '');
379
- $('#addBtn').disabled = !picked.slack || !picked.github;
386
+ const list = $('#slackPeople');
387
+ $('#slackFilter').hidden = Boolean(picked.slack);
388
+ $('#loginRow').hidden = !picked.slack;
389
+ if (picked.slack) {
390
+ list.innerHTML = `<span class="chip">${esc(picked.slack.name)} <button type="button" class="ghost" data-unpick aria-label="Choose someone else">✕</button></span>`;
391
+ $('#pickStatus').textContent = people?.githubProblem ? `${people.githubProblem} Type their login.` : '';
392
+ } else if (people === 'failed') list.innerHTML = problem("Couldn't load the list.");
393
+ else if (!people) list.innerHTML = '<p class="muted small">Loading…</p>';
394
+ else if (!people.slack) list.innerHTML = `<p class="muted small">${esc(people.slackProblem || 'Slack members are not available.')} <a href="/_hub/settings">Hub settings</a></p>`;
395
+ else list.innerHTML = people.slack.filter((p) => fits(p.name, $('#slackFilter').value) && !taken.has(p.name.toLowerCase()))
396
+ .map((p) => `<button type="button" class="ghost" data-slack="${esc(p.id)}" data-name="${esc(p.name)}">${esc(p.name)}</button>`).join('') || '<p class="muted small">No one matches.</p>';
397
+ $('#addBtn').disabled = !picked.slack || !$('#githubFilter').value.trim();
398
+ }
399
+ function loadPeople() {
400
+ people = null;
401
+ renderPick();
402
+ // The wait says "Board restarting…" only when the hub marked a restart; any other wait leaves Loading… until the
403
+ // list arrives or the retries give out.
404
+ const wait = (ms, down) => { if (down?.state === 'restarting') $('#slackPeople').innerHTML = restartNotice(); };
405
+ get(site.url('api/reviewer-people'), wait).then(({ ok, body }) => { if (!B.alive()) return; people = ok ? { github: [], ...body } : 'failed'; renderPick(); })
406
+ .catch(() => { if (B.alive()) { people = 'failed'; renderPick(); } });
380
407
  }
381
- get(site.url('api/reviewer-people'), waitIn('#slackPeople')).then(({ body }) => { if (!B.alive()) return; people = { github: [], ...body }; renderPick(); })
382
- .catch(() => { if (B.alive()) $('#slackPeople').innerHTML = '<p class="muted small">Offline. Is the host awake?</p>'; });
408
+ loadPeople();
383
409
  B.on($('#slackFilter'), 'input', renderPick);
384
410
  B.on($('#githubFilter'), 'input', renderPick);
385
411
  B.on($('#slackPeople'), 'click', (ev) => {
386
- const b = ev.target.closest('button[data-slack]');
387
- if (b) { picked.slack = { id: b.dataset.slack, name: b.dataset.name }; renderPick(); }
388
- });
389
- B.on($('#githubPeople'), 'click', (ev) => {
390
- const b = ev.target.closest('button[data-github]');
391
- if (b) { picked.github = b.dataset.github; renderPick(); }
412
+ const b = ev.target.closest('button');
413
+ if (!b) return;
414
+ if (b.dataset.retry !== undefined) { loadPeople(); return; }
415
+ if (b.dataset.unpick !== undefined) { picked.slack = null; $('#githubFilter').value = ''; renderPick(); return; }
416
+ if (!b.dataset.slack) return;
417
+ picked.slack = { id: b.dataset.slack, name: b.dataset.name };
418
+ $('#githubFilter').value = guessLogin(picked.slack.name, people.github);
419
+ renderPick();
420
+ $('#pickStatus').textContent = $('#githubFilter').value ? 'Guessed from their name. Change it if it is wrong.' : $('#pickStatus').textContent;
421
+ $('#githubFilter').focus?.();
392
422
  });
393
423
  B.on($('#addForm'), 'submit', (ev) => {
394
424
  ev.preventDefault();
395
- if (!picked.slack || !picked.github) return;
396
- add.set(picked.slack.name, { name: picked.slack.name, slack_user: picked.slack.id, github: picked.github });
397
- picked.slack = null; picked.github = '';
425
+ const github = $('#githubFilter').value.trim();
426
+ if (!picked.slack || !github) return;
427
+ add.set(picked.slack.name, { name: picked.slack.name, slack_user: picked.slack.id, github });
428
+ picked.slack = null;
398
429
  $('#slackFilter').value = ''; $('#githubFilter').value = '';
430
+ $('#pickStatus').textContent = '';
399
431
  renderPick();
400
432
  render();
401
433
  });