@lovek4d/kickflip 0.4.0 → 0.5.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 (51) hide show
  1. package/CONTEXT.md +32 -16
  2. package/SKILL.md +8 -6
  3. package/bin/doctor.mjs +4 -4
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +8 -8
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/bin/board.mjs +50 -28
  8. package/board/domain.mjs +7 -7
  9. package/board/finish.mjs +4 -3
  10. package/board/headless.mjs +4 -3
  11. package/board/merge3.mjs +1 -0
  12. package/board/on-host.mjs +1 -1
  13. package/board/permissions.mjs +2 -2
  14. package/board/public/app-asks.js +6 -89
  15. package/board/public/app-cards.js +19 -10
  16. package/board/public/app-core.js +1 -20
  17. package/board/public/app-reader.js +174 -0
  18. package/board/public/app-threads.js +49 -35
  19. package/board/public/app.html +2 -0
  20. package/board/public/app.js +28 -16
  21. package/board/public/hub.css +1 -0
  22. package/board/public/new-task.js +121 -0
  23. package/board/public/shell.js +4 -0
  24. package/board/public/sidebar.css +1 -0
  25. package/board/public/style.css +91 -3
  26. package/board/public/ui-cards.js +46 -21
  27. package/board/public/ui-enums.js +1 -1
  28. package/board/public/ui-phase-bars.js +4 -4
  29. package/board/public/ui-questions.js +47 -18
  30. package/board/public/ui-sidebar.js +9 -3
  31. package/board/public/ui-threads.js +25 -12
  32. package/board/public/ui.js +42 -12
  33. package/board/public/view-frame.js +1 -0
  34. package/board/public/view-questions.js +45 -16
  35. package/board/public/view-settings.js +38 -19
  36. package/board/server.mjs +20 -18
  37. package/board/store.mjs +15 -13
  38. package/board/worktree.mjs +20 -20
  39. package/hub/boards.mjs +4 -4
  40. package/hub/front-door.mjs +20 -0
  41. package/hub/github.mjs +3 -3
  42. package/hub/hub.mjs +9 -12
  43. package/hub/new-board.mjs +2 -2
  44. package/hub/owner-checks.mjs +1 -0
  45. package/hub/readiness.mjs +2 -2
  46. package/hub/sessions.mjs +2 -2
  47. package/hub/try.mjs +7 -7
  48. package/package.json +1 -1
  49. package/shared/owner-check.mjs +3 -0
  50. package/shared/tailscale.mjs +1 -0
  51. package/shared/usage-gate.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-phase-bars.js.
2
2
  (function () {
3
- const { esc, ago, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, phaseBars: bars } = globalThis.BoardUI;
3
+ const { esc, ago, lasted, clock, cardTime, etaLine, workingLine, badge, bumpButton, cardBumpButton, editButton, withdrawButton, BUTTON, ACTION_KIND, BUILDER_KIND, PR_STATE, LINK_ARROW, phaseBars: bars } = 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
@@ -62,11 +62,11 @@
62
62
  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>');
63
63
  }
64
64
  const tryWords = (kind) => kind === 'app' ? {
65
- go: 'Build', going: 'building…', done: 'Install', failed: 'build failed',
66
- current: 'Nothing new to build: Install already has this card’s latest push.',
67
- busy: 'Building now. The badge turns back into Install when the new build is ready.',
68
- autoSub: 'The hub builds each card after each push, and main after each merge.',
69
- saved: (on) => on ? 'Saved. Each push builds its card.' : 'Saved. Cards wait for your Build.',
65
+ go: 'Serve', going: 'serving…', done: 'Install', failed: 'serve failed',
66
+ current: 'Nothing new to serve: Install already has this card’s latest push.',
67
+ busy: 'Serving now. The badge turns back into Install when the new build is ready.',
68
+ autoSub: 'The hub serves each card after each push, and main after each merge.',
69
+ saved: (on) => on ? 'Saved. Each push serves its card.' : 'Saved. Cards wait for your Serve.',
70
70
  } : {
71
71
  go: 'Serve', going: 'serving…', done: 'Visit', failed: 'serve failed',
72
72
  current: 'Nothing new to serve: Visit already shows this card’s latest push.',
@@ -137,11 +137,13 @@
137
137
  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))}${mainPull(main.pull, pulling)}</div><div class="meta">${chip}</div></div>`;
138
138
  }
139
139
  // The open card's Merge, while the board's auto-merge is off: it confirms, then sends card-merge, waiting while the
140
- // server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it
141
- // (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md).
140
+ // server records it (app-cards.js), and waits (ui.js BUSY) while the session merges it, or the board server lands a
141
+ // card with no PR, and from the tap until a board shows the card left In Review or its merge refused (ui.js pending) (docs/adr/2026-10-29-1752-auto-merge-is-a-board-setting-off-by-default.md,
142
+ // docs/adr/2026-11-04-2100-a-land-waits-for-your-merge.md).
142
143
  function mergeButton(it) {
143
144
  if (it?.action?.kind !== ACTION_KIND.merge && !it?.merging) return '';
144
- return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${it.merging ? BoardUI.BUSY : ''}>Merge</button>`;
145
+ const busy = it.merging || BoardUI.pending.has(`${it.key}:merge`);
146
+ return `<button class="${it.resolvedBy === BUTTON.merge ? 'resolves' : 'primary'}" data-confirm="merge" data-key="${esc(it.key)}"${busy ? BoardUI.BUSY : ''}>Merge</button>`;
145
147
  }
146
148
  // The open card's Implement, rightmost in its action row, on every unstarted Todo work card, its questions waiting or
147
149
  // not: it confirms, then
@@ -153,8 +155,9 @@
153
155
  if (it.kind !== 'work' || it.lane !== 'todo' || it.queued) return '';
154
156
  return `<button class="build${it.ready ? ' ready' : ''}" data-confirm="card-build" data-key="${esc(it.key)}">Implement</button>`;
155
157
  }
156
- // A builder reads "preheating" until its first status line, from the Implement tap or the session's dispatch on.
157
- const builderLine = (b) => [b.progress || 'preheating', b.at && ago(b.at)].filter(Boolean).join(' · ');
158
+ // A builder reads "preheating" until its first status line, from the Implement tap or the session's dispatch on, and
159
+ // ends with how long ago that line was set.
160
+ const builderLine = (b) => [b.progress || 'preheating', b.at && lasted(b.at)].filter(Boolean).join(' · ');
158
161
  // Five bars at the end of an In Progress card's time row: reached phases filled, a running one marked. The phases are
159
162
  // store.mjs PHASES (docs/adr/2026-11-03-0128-a-builder-status-line-is-a-phase-and-a-short-detail.md,
160
163
  // docs/adr/2026-11-03-1519-a-building-cards-phase-bars-end-its-time-row.md).
@@ -166,10 +169,17 @@
166
169
  // 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
167
170
  // build reached, undimmed
168
171
  // (docs/adr/2026-11-03-2024-every-in-progress-card-shows-its-phase-bars.md).
172
+ // A Todo card a grill or prototype runs on draws the planner's dashes instead, in blue, the grill's first and the
173
+ // prototype's second running (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md).
169
174
  function phaseStrip(it) {
175
+ if (it.lane === 'todo' && it.builder?.planner) {
176
+ const prototype = it.builder.kind === 'prototype';
177
+ return { reached: prototype ? 2 : 1, running: true, dim: false, plan: true, label: prototype ? 'Prototyping' : 'Grilling' };
178
+ }
170
179
  if (it.lane !== 'progress' || it.builder?.planner) return null;
171
180
  const all = BUILD_PHASES.length;
172
- if (it.waitsOn === 'checking') return { reached: all, running: false, dim: false, label: `Orchestrator review, ${ALL_DONE}` };
181
+ // Orchestrator review is the checking phase, before landing (docs/adr/2026-11-04-2130-the-hub-launches-every-agent-from-the-card.md).
182
+ if (it.waitsOn === 'checking') return { reached: BUILD_PHASES.indexOf('checking') + 1, running: true, dim: false, label: `Orchestrator review, ${stepWords('checking')}` };
173
183
  const asking = it.action?.kind === ACTION_KIND.questions || it.action?.kind === ACTION_KIND.pick;
174
184
  const answers = asking ? ', waiting on your answers' : '';
175
185
  const name = (phase) => `${phase[0].toUpperCase()}${phase.slice(1)}`;
@@ -192,9 +202,20 @@
192
202
  return has && next > prev ? 'fill' : null;
193
203
  }
194
204
  const phaseBars = (strip) => bars(strip, BUILD_PHASES);
205
+ // docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md
206
+ // Every status line on an In Progress card is the phase-bar orange, and every build or plan line on a Todo card the
207
+ // owner's blue (style.css).
208
+ const tone = (it) => (it.lane === 'progress' ? ' in-progress' : it.lane === 'todo' ? ' in-todo' : '');
209
+ const DOT = '<span class="working-dot" aria-hidden="true"></span>';
210
+ // A card idle on the orchestrator (domain.mjs orchestratorWaitOf) reads waiting for orchestrator, then orchestrator
211
+ // review with its dot once the session picks it up, each with how long ago.
212
+ function orchestratorStatus(it) {
213
+ const { since, picked } = it.orchestrator;
214
+ return `<div class="line3 sub agent${tone(it)}">${picked ? `${DOT}orchestrator review · ${esc(lasted(picked))}` : `waiting for orchestrator · ${esc(lasted(since))}`}</div>`;
215
+ }
195
216
  // A stale build's status line is the needs-you red, and a tap on it nudges Claude to check the implementer.
196
217
  function builderStatus(it) {
197
- const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}`;
218
+ const classes = `line3 sub agent${it.builder.planner ? ' planner' : ''}${tone(it)}`;
198
219
  const words = `<span class="working-dot" aria-hidden="true"></span>${esc(builderLine(it.builder))}`;
199
220
  if (!it.builder.stalled || it.lane === 'done') return `<div class="${classes}">${words}</div>`;
200
221
  return `<button type="button" class="${classes} stalled" data-confirm="nudge" data-key="${esc(it.key)}" title="No recent progress. Nudge Claude to check this implementer">${words}</button>`;
@@ -218,15 +239,15 @@
218
239
  return `<span class="card-hold" title="${esc(title)}">${esc(d ? `held until ${heldUntilWords(it.heldUntil)}` : 'held')}</span>`;
219
240
  }
220
241
  // A card's last line: the time its lane sorts by (domain.mjs sortAt), then its hold, what blocks it, then Action needed
221
- // or ready, then an In Progress card's phase bars, then its new questions and unread messages, questions first, which app-cards.js
242
+ // or ready, then an In Progress card's phase bars, then its unread messages, new questions counting as one, which app-cards.js
222
243
  // takes away when the card opens (docs/adr/2026-11-04-0247-a-builder-asks-the-orchestrator-mid-build.md,
223
244
  // 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).
224
245
  const SEP = '<span class="sep"> · </span>';
225
246
  const newWords = (n, word) => n && `${n} new ${word}${n === 1 ? '' : 's'}`;
226
- function timeLine(it, unread, questions, bars) {
247
+ function timeLine(it, unread, bars) {
227
248
  const iso = it.sortAt;
228
249
  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);
229
- const news = [newWords(questions, 'question'), newWords(unread, 'message')].filter(Boolean).join(' · ');
250
+ const news = newWords(unread, 'message');
230
251
  const count = news ? `<span class="card-unread">${parts.length || bars ? SEP : ''}${news}</span>` : '';
231
252
  const tail = bars ? `${parts.length ? SEP : ''}${bars}` : '';
232
253
  if (!parts.length && !count && !tail) return '';
@@ -255,24 +276,28 @@
255
276
  const askLine = (ask) => [ask.from && `Moved from ${esc(ask.from)}`, ask.edited && 'Edited', ask.state === 'working' && workingLine].filter(Boolean).join(' · ');
256
277
  // 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
257
278
  // its bump with the band's ⋯ menu.
258
- function workLines(it, { unread = 0, newQuestions = 0, top = false, step = 0, actions = null } = {}) {
279
+ function workLines(it, { unread = 0, top = false, step = 0, actions = null } = {}) {
259
280
  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);
260
281
  // A card from an ask reads New until its session sets ready, grilling or prototyping (domain.mjs unsorted).
261
282
  const chip = step ? ticket.replace(/^<[^>]*>/, (open) => `${open}<span class="wn">${step}</span>`) : ticket;
262
283
  const ids = it.kind === 'ask' ? askChip(it) : it.unsorted ? `${badge('notice', 'New')} ${chip}` : chip;
263
284
  const chips = `${prChips(it)}${reviewChips(it)}${it.risky ? badge('notice', 'risky') : ''}${installChip(it)}`;
264
285
  const status = it.isNew && !it.action ? (askLine(it.ask) && `<div class="line3 sub">${askLine(it.ask)}</div>`)
265
- : it.waitsOn === 'checking' ? '<div class="line3 sub agent"><span class="working-dot" aria-hidden="true"></span>Orchestrator review</div>'
286
+ : it.orchestrator && !it.builder ? orchestratorStatus(it)
287
+ : it.waitsOn === 'checking' ? `<div class="line3 sub agent${tone(it)}">${DOT}Orchestrator review</div>`
266
288
  : it.builder ? builderStatus(it)
267
- : it.grilling || it.prototyping ? `<div class="line3 sub agent planner"><span class="working-dot" aria-hidden="true"></span>${it.grilling ? 'preheating' : 'prototyping'}</div>`
289
+ : it.grilling || it.prototyping ? `<div class="line3 sub agent planner${tone(it)}">${DOT}${it.grilling ? 'preheating' : 'prototyping'}</div>`
268
290
  : it.refusal ? `<div class="line3 sub refused">${esc(it.refusal)}</div>`
269
291
  : it.working ? `<div class="line3 sub agent">${workingLine}</div>`
270
292
  : it.next ? `<div class="line3 sub next${it.lane === 'todo' || it.lane === 'done' || it.held || it.queued ? ' idle' : ''}">Next: ${esc(it.next)}</div>` : '';
271
293
  const eta = it.eta && it.builder ? `<div class="line3 sub eta">${esc(etaLine(it.eta))}</div>` : '';
294
+ // The owner step's code check, which closes the card when it passes (docs/adr/2026-11-04-2308-an-owner-step-can-carry-a-code-check.md).
295
+ const check = !it.ownerCheck ? '' : it.ownerCheck.broken ? `<div class="line3 sub refused">${esc(`Check can't run: ${it.ownerCheck.broken}`)}</div>`
296
+ : `<div class="line3 sub check">${esc(`Checks: ${it.ownerCheck.words}`)}</div>`;
272
297
  return `${actions ?? cardActions(it, top)}<div class="line1 title">${ids ? `${ids} ` : ''}${esc(it.title)}</div>
273
- ${status}${eta}
298
+ ${status}${check}${eta}
274
299
  ${chips ? `<div class="line2 meta">${chips}</div>` : ''}
275
- ${timeLine(it, unread, newQuestions, phaseBars(phaseStrip(it)))}`;
300
+ ${timeLine(it, unread, phaseBars(phaseStrip(it)))}`;
276
301
  }
277
302
  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>';
278
303
  const askChip = (it) => (it.isNew ? badge('notice', 'New') : '');
@@ -3,7 +3,7 @@
3
3
  // badge shows (docs/adr/2026-11-03-1843-a-merged-card-reopens-and-its-pr-badge-shows-the-prs-state.md). Each value is its name,
4
4
  // since cards carry it in the board's JSON. Needs nothing else: board/domain.mjs runs this file in a vm context (docs/adr/2026-10-30-1309-an-unstarted-card-waits-on-its-questions-in-todo.md).
5
5
  (function () {
6
- const BUTTON = Object.freeze({ grill: 'grill', openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
6
+ const BUTTON = Object.freeze({ openQuestions: 'openQuestions', markDone: 'markDone', github: 'github', merge: 'merge' });
7
7
  const ACTION_KIND = Object.freeze({ pick: 'pick', questions: 'questions', ready: 'ready', steps: 'steps', merge: 'merge', done: 'done' });
8
8
  const BUILDER_KIND = Object.freeze({ install: 'install' });
9
9
  const PR_STATE = Object.freeze({ open: 'open', draft: 'draft', merged: 'merged', closed: 'closed' });
@@ -4,14 +4,14 @@
4
4
  // hub/hub.mjs 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
- // strip: {reached, running, dim, label, fill}, or null for no bars. reached counts the phases reached, the last of them
8
- // running when running is set; fill, a percent, fills the next bar that far.
7
+ // strip: {reached, running, dim, label, fill, plan}, or null for no bars. reached counts the phases reached, the last of
8
+ // them running when running is set; fill, a percent, fills the next bar that far; plan draws them in the planner blue.
9
9
  function phaseBars(strip, phases) {
10
10
  if (!strip) return '';
11
- const { reached, running, dim, label, fill } = strip;
11
+ const { reached, running, dim, label, fill, plan } = strip;
12
12
  const bar = (i) => (i === reached - 1 && running ? '<i class="now"></i>' : i < reached ? '<i class="done"></i>'
13
13
  : i === reached && fill !== undefined ? `<i class="fill" style="--p:${fill}%"></i>` : '<i class=""></i>');
14
- return `<span class="phase-bars${dim ? ' dim' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${phases.map((_, i) => bar(i)).join('')}</span>`;
14
+ return `<span class="phase-bars${dim ? ' dim' : ''}${plan ? ' plan' : ''}" role="img" aria-label="${esc(label)}" title="${esc(label)}">${phases.map((_, i) => bar(i)).join('')}</span>`;
15
15
  }
16
16
  Object.assign(globalThis.BoardUI ||= {}, { phaseBars });
17
17
  })();
@@ -1,16 +1,22 @@
1
1
  // Owner questions, for Waiting on you (view-questions.js). Needs ui.js and ui-links.js.
2
2
  (function () {
3
- const { esc, time, replyBody, attachments, attachButton, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
3
+ const { esc, time, ago, replyBody, attachments, attachButton, dropZone, protoButton, linkText, linkHtml, badge, opensHere, LINK_ARROW } = globalThis.BoardUI;
4
4
  // A short id such as A or C2 leads the label; a word id such as "inline" would repeat it. prototype-switcher.js answerLabel matches.
5
5
  const lead = (o) => (/^[A-Z][0-9]?$/i.test(o.id) ? `${o.id}. ` : '');
6
6
  const choiceLabel = (q, id) => { const o = q.options.find((x) => x.id === id); return o ? `${q.multi ? '' : lead(o)}${o.label}` : id || ''; };
7
- const option = (o, { type = 'radio', name, checked = false, numbered = true, shown = false }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}>
8
- <span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${o.recommended ? '<span class="rec">Recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div></span></label>`;
7
+ // suggested: the line under a suggested option saying where the owner chose it before, as HTML.
8
+ const option = (o, { type = 'radio', name, checked = false, numbered = true, shown = false, locked = false, suggested = '' }) => `<label class="opt"${shown ? ' aria-current="true"' : ''}><input type="${type}" name="${esc(name)}" value="${esc(o.id)}" ${checked ? 'checked' : ''}${locked ? ' disabled' : ''}>
9
+ <span><span class="lbl">${numbered ? esc(lead(o)) : ''}${esc(o.label)}</span>${suggested ? badge('ready', 'suggested').replace('class="chip', 'class="chip suggested') : ''}${o.recommended ? '<span class="rec">recommended</span>' : ''}<div class="det">${esc(o.detail || '')}</div>${suggested ? `<div class="suggested-why">${suggested}</div>` : ''}</span></label>`;
10
+ // docs/adr/2026-11-04-2200-overnight-triage-suggests-your-earlier-answers.md
11
+ // A question's pick on this device: its unsent draft, else its last answer, else the option the overnight triage
12
+ // suggested, pre-checked so the topic's Send sends it.
13
+ const choiceOf = (q, draft) => draft.choice ?? (q.answers.length ? q.answers[q.answers.length - 1].choice : q.suggested?.option ?? null);
14
+ const suggestedLine = (q, o, links) => q.suggested?.option === o.id ? linkText(`You answered this on ${q.suggested.card}: "${q.suggested.answer}"`, links) : '';
9
15
 
10
16
  const SPIN = '<span class="try-spin" aria-hidden="true"></span>';
11
- // A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone, which resolves it
12
- // (view-questions.js); a resolved question has none.
13
- const questionEye = (q) => q.state === 'resolved' ? '' : protoButton('q:' + q.id, null, { label: 'Ask for a prototype instead' });
17
+ // A question's eye sends `prototype-request {key?, question, note, choice}` for that question alone, which then reads
18
+ // requested until its prototype round arrives (view-questions.js).
19
+ const questionEye = (q) => protoButton('q:' + q.id, { label: 'Ask for a prototype instead' });
14
20
  const noteComposer = (q, note) => `<div class="composer" data-composer="${esc('q:' + q.id)}">${dropZone(`<textarea class="note" rows="2" placeholder="Note (optional)">${esc(note || '')}</textarea>`)}
15
21
  <div class="note-tools">${attachButton()}${questionEye(q)}<div class="pending-files" data-files></div></div></div>`;
16
22
 
@@ -18,7 +24,7 @@
18
24
  open: { label: 'open', tone: 'normal' },
19
25
  answered: { label: 'waiting for claude', tone: 'notice' },
20
26
  replied: { label: 'claude replied', tone: 'ready' },
21
- resolved: { label: 'resolved', tone: 'quiet' },
27
+ requested: { label: `${SPIN}prototype requested`, tone: 'normal' },
22
28
  };
23
29
  // docs/adr/2026-11-02-1332-a-prototype-round-ends-with-a-one-take-confirm.md
24
30
  // A topic's latest prototype round from /api/board ownerTopics: {n, state: making|back|closed, take}.
@@ -77,31 +83,54 @@
77
83
  // Every take loads so one outside the viewport can still decide the question's layout.
78
84
  return `<div class="take-shot${h ? '' : ' measuring'}"${h ? ` style="height:${Math.round(h)}px"` : ''}><iframe src="${esc(withParam(q.url, 'take', o.id))}" title="${esc(o.label)}"></iframe></div>`;
79
85
  }
80
- function takes(q, chosen, links, fit) {
86
+ function takes(q, chosen, links, fit, locked = false) {
81
87
  const name = 'c-' + q.id;
82
88
  if (fullTakes(q, fit)) {
83
89
  const shown = q.options.find((o) => o.id === chosen) || q.options.find((o) => o.recommended) || q.options[0];
84
- return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
90
+ return `<div class="takes takes-full"><div class="take-choices" role="group" aria-label="Takes">${q.options.map((o) => option(o, { name, checked: chosen === o.id, shown: o === shown, locked, suggested: suggestedLine(q, o, links) })).join('')}</div><article class="take" data-opt="${esc(shown.id)}">${takeBody(q, shown, links, fit, true)}</article></div>`;
85
91
  }
86
- return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
92
+ return `<div class="takes">${q.options.map((o) => `<article class="take" data-opt="${esc(o.id)}">${option(o, { name, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })}${takeBody(q, o, links, fit)}</article>`).join('')}</div>`;
87
93
  }
88
94
  // A confirm links its one take alone, and says why it is asked.
89
- const confirmLines = (q) => !q.confirm_of || q.state === 'resolved' ? ''
95
+ const confirmLines = (q) => !q.confirm_of ? ''
90
96
  : `<a class="pill proto pr-open" href="${esc(withParam(q.url, 'take', q.options[0].id))}">Open the prototype →</a>${q.state === 'open' ? `<div class="pr-rule">${RULE_ICON}A pick with a note gets a one-take confirm once Claude has updated the take.</div>` : ''}`;
97
+ // docs/adr/2026-11-04-2001-a-resolved-question-is-read-only-and-can-be-asked-again.md
98
+ // A resolved question, read-only: its green resolved badge asks Claude to ask it again (view-questions.js sends
99
+ // `reask-request` once the owner confirms), how it was resolved and when it was acted on (resolved_at), its last pick
100
+ // checked among locked options, and its last note and Claude's reply. reask is a re-ask under way, which locks the badge.
101
+ function resolvedCard(q, links) {
102
+ const last = q.answers[q.answers.length - 1];
103
+ const outcome = q.resolved ? `${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'}` : '';
104
+ const acted = q.resolved_at ? ` <span class="acted" title="${esc(time(q.resolved_at))}">acted on ${esc(ago(q.resolved_at))}</span>.` : '';
105
+ const said = last && (last.note || last.reply || last.files?.length) ? `<div class="ans"><div class="muted small">You · ${esc(time(last.ts))}</div>${last.note ? `<div>${linkText(last.note, links)}</div>` : ''}${attachments(last.files)}</div>
106
+ ${last.reply ? `<div class="rep"><div class="muted small">Claude</div>${linkHtml(replyBody(last.reply), links)}</div>` : ''}` : '';
107
+ return `<section class="q resolved" id="${esc(q.id)}" data-q="${esc(q.id)}">
108
+ <div class="row" style="margin:0"><span class="muted small">${esc(q.source || '')}</span>${badge('ready', 'resolved', { button: true, title: 'Ask this question again', attrs: { type: 'button', 'data-reask': q.id, disabled: q.reask ? '' : null } })}</div>
109
+ <h3>${linkText(q.question, links)}</h3>
110
+ ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
111
+ ${questionLink(q.url)}
112
+ ${outcome || acted ? `<div class="small muted resolved-line">${outcome}${acted}</div>` : ''}
113
+ ${q.options.map((o) => option(o, { name: 'c-' + q.id, checked: last?.choice === o.id, locked: true })).join('')}
114
+ ${said}
115
+ ${q.reask ? '<div class="small muted reask-line">re-ask requested</div>' : ''}
116
+ </section>`;
117
+ }
91
118
  // An owner question from /api/board; draft is this device's unsent {choice, note}; links its ui-links.js linkContext;
92
119
  // fit(option id) supplies the measured grid and full-width heights.
120
+ // A question waiting on the prototype the owner asked for is locked at the pick and note the request sent until its
121
+ // round arrives (docs/adr/2026-11-04-2130-a-prototype-request-waits-for-its-prototype.md).
93
122
  function questionCard(q, { n = 0, draft = {}, links = {}, fit = () => null } = {}) {
94
- const last = q.answers[q.answers.length - 1];
95
- const chosen = draft.choice ?? (last ? last.choice : null);
123
+ if (q.state === 'resolved') return resolvedCard(q, links);
124
+ const locked = q.state === 'requested';
125
+ const chosen = locked ? q.request.choice : choiceOf(q, draft);
96
126
  return `<section class="q ${q.urgent && q.state === 'open' ? 'urgent' : ''}" id="${esc(q.id)}" data-q="${esc(q.id)}">
97
- <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, esc(QUESTION_STATE[q.state].label))}</div>
127
+ <div class="row" style="margin:0"><span class="muted small">${n ? n + '. ' : ''}${esc(q.source || '')}</span>${badge(QUESTION_STATE[q.state].tone, QUESTION_STATE[q.state].label)}</div>
98
128
  <h3>${linkText(q.question, links)}</h3>
99
129
  ${q.context ? `<div class="ctx">${linkText(q.context, links)}</div>` : ''}
100
130
  ${framed(q) ? '' : questionLink(q.url)}
101
131
  ${confirmLines(q)}
102
- ${q.state === 'resolved' && q.resolved ? `<div class="small muted" style="margin-bottom:8px">Resolved: ${esc(q.resolved)}${/[.!?]$/.test(q.resolved) ? '' : '.'} A new answer reopens it.</div>` : ''}
103
- ${inline(q) ? takes(q, chosen, links, fit) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id })).join('')}
104
- ${noteComposer(q, draft.note)}
132
+ ${inline(q) ? takes(q, chosen, links, fit, locked) : q.options.map((o) => option(o, { name: 'c-' + q.id, checked: chosen === o.id, locked, suggested: suggestedLine(q, o, links) })).join('')}
133
+ ${locked ? (q.request.note ? `<div class="ans"><div class="muted small">You · ${esc(time(q.request.ts))}</div><div>${linkText(q.request.note, links)}</div></div>` : '') : noteComposer(q, draft.note)}
105
134
  ${answerHistory(q, links)}
106
135
  </section>`;
107
136
  }
@@ -150,5 +179,5 @@
150
179
  },
151
180
  };
152
181
 
153
- Object.assign(globalThis.BoardUI, { choiceLabel, option, answerHistory, questionCard, roundChip, takes, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
182
+ Object.assign(globalThis.BoardUI, { choiceLabel, choiceOf, option, answerHistory, questionCard, roundChip, takes, topicName, cardTopics, topicRow, nextQuestion, sentNotice });
154
183
  })();
@@ -14,6 +14,11 @@
14
14
  pulse: '<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>',
15
15
  close: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
16
16
  plus: '<path d="M12 5v14M5 12h14"/>',
17
+ // A starting board's step icons, one per START_STEPS key. No open icon set (Lucide, Tabler) has a skateboard, so landing's
18
+ // deck with upturned ends over two wheels is drawn here.
19
+ cloning: '<path d="M12 4v11M7.5 10.5 12 15l4.5-4.5M5 20h14"/>',
20
+ starting: '<path d="m5 7 5 5-5 5M12 18h7"/>',
21
+ landing: '<path d="M2.5 9c0 1.7 1.2 3 3 3h13c1.8 0 3-1.3 3-3"/><circle cx="7.5" cy="16.5" r="2"/><circle cx="16.5" cy="16.5" r="2"/>',
17
22
  moon: '<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>',
18
23
  up: '<circle cx="12" cy="12" r="9"/><path d="m8 12 4-4 4 4"/><path d="M12 16V8"/>',
19
24
  };
@@ -149,11 +154,12 @@
149
154
  + `<dl class="fs-facts">${fact('Repo', s.repo && esc(repoLabel(s.repo, home)))}${fact('Branch', s.branch && `<code>${esc(s.branch)}</code>`)}`
150
155
  + `${fact('Tickets', s.tickets && esc(ticketsLabel(s.tickets)))}${fact('Asked', s.askedAt && esc(askedWhen(s.askedAt, now)))}</dl>`
151
156
  + '<p class="fs-refused" role="alert" hidden></p>'
152
- + `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
157
+ + `<div class="sheet-actions fs-actions"><button type="button" data-failed-dismiss>Dismiss</button>${s.branch && !s.joined ? '<button type="button" class="primary" data-failed-retry>Retry</button>' : ''}</div></div>`;
153
158
  }
154
159
 
155
160
  // A starting board's three steps (docs/adr/2026-11-04-1914-a-new-board-shows-its-start-steps-and-clone-progress.md):
156
- // s.step is cloning, starting (the orchestrator's session) or landing (the session joining the hub), since s.stepAt.
161
+ // s.step is cloning, starting (the orchestrator's session, until it joins the hub) or landing (the joined board, until it
162
+ // listens), since s.stepAt.
157
163
  // Cloning fills its bar to git's percent; the later steps run the bars' pulse, never a fill.
158
164
  const START_STEPS = { cloning: 'cloning', starting: 'starting orchestrator', landing: 'landing it' };
159
165
  const STEP_KEYS = Object.keys(START_STEPS);
@@ -203,7 +209,7 @@
203
209
  const pending = (data?.starting || []).map((s) => (s.failed
204
210
  ? `<button type="button" class="sb-row sb-starting sb-failed" data-starting="${esc(s.route)}" data-failed-start="${esc(s.route)}" aria-haspopup="dialog">${plus}`
205
211
  + `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span><span class="sb-sub">failed to start</span></span></button>`
206
- : `<div class="sb-row sb-starting" data-starting="${esc(s.route)}">${plus}`
212
+ : `<div class="sb-row sb-starting" data-starting="${esc(s.route)}"><span class="icon sb-plus sb-start-icon" aria-hidden="true">${svg(ICONS[s.step], 20)}</span>`
207
213
  + `<span class="sb-text"><span class="sb-title">${esc(s.title)}</span>${startSteps(s)}<span class="sb-sub sb-step">${esc(startCaption(s, now))}</span></span></div>`)).join('');
208
214
  const newBoard = `<button type="button" class="sb-row sb-new" data-new-board aria-haspopup="dialog">${plus}<span class="sb-text"><span class="sb-title">New board</span></span></button>`;
209
215
  const turnOn = device === 'off' ? row({ href: '/_hub/#notifications', icon: tile('bell', 'var(--accent)'), title: 'Turn on notifications', sub: 'Off on this device', cls: 'sb-turnon' })
@@ -1,7 +1,7 @@
1
1
  // A card's thread: the orchestrator's lines (who claude, named Orchestrator), its builders' asks, the owner's messages and
2
2
  // their replies. Needs ui.js, ui-links.js and ui-enums.js.
3
3
  (function () {
4
- const { esc, time, etaLine, replyBody, attachments, attachButton, dropZone, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, BUTTON } = globalThis.BoardUI;
4
+ const { esc, time, etaLine, replyBody, attachments, attachButton, dropZone, editButton, withdrawButton, workingLine, linkText, linkHtml, anyLink, mark, statusMark, BUTTON } = globalThis.BoardUI;
5
5
  const VIA = { slack: ' · Slack', github: ' · GitHub' };
6
6
  // A link to a card on this board (#item=<key>) or on another board (/<route>/#item=<key>, an ask's move), or ''.
7
7
  function cardLink(link) {
@@ -13,10 +13,13 @@
13
13
  }
14
14
  // A message's state comes from domain.mjs threadOf.
15
15
  const stateOf = (m) => m.state || (m.reply ? 'replied' : 'waiting');
16
- const msgFooter = (m) => ({
17
- working: () => `<div class="reply muted small">${workingLine}</div>`,
18
- waiting: () => '<div class="reply muted small">Waiting for Claude…</div>',
19
- }[stateOf(m)] || (() => ''))();
16
+ // A message's footer: the tap it came from (m.status, such as "answers submitted"), then, to its right, Claude's
17
+ // pick-up while it waits (docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md).
18
+ const PICK_UP = { working: workingLine, waiting: 'Waiting for Claude…' };
19
+ const msgFooter = (m) => {
20
+ const parts = [m.status && `<span class="foot-status">${statusMark(m.status)}${esc(m.status)}</span>`, PICK_UP[stateOf(m)]].filter(Boolean);
21
+ return parts.length ? `<div class="reply muted small">${parts.join('')}</div>` : '';
22
+ };
20
23
  // Each reply becomes Claude's own message at the time it was written, quoting the owner's message
21
24
  // when other messages came between them. A reply with no time follows its message.
22
25
  const withReplies = (msgs) => {
@@ -30,14 +33,15 @@
30
33
  const editForm = (id, text) => `<form class="edit-form" data-edit-form="${esc(id)}" data-composer="edit:${esc(id)}">${dropZone(`<textarea rows="3">${esc(text)}</textarea>`)}
31
34
  <div class="pending-files" data-files></div>
32
35
  <div class="row">${attachButton()}<span><button type="button" data-edit-cancel>Cancel</button> <button class="primary" type="submit">Save</button></span></div></form>`;
33
- // batched: an answer followed by another of the owner's answers in its state, which carries the batch's Waiting for Claude.
36
+ // batched: an answer followed by another of the owner's answers in its state, which carries the batch's footer.
34
37
  const line = (m, links, edits, batched = false) => (m.kind === 'reply'
35
38
  ? `<div class="msg claude reply-msg"><div class="who">Orchestrator · ${esc(time(m.ts))}</div>${m.quote ? `<blockquote class="quote">${linkText(m.quote, links)}</blockquote>` : ''}<div class="body">${linkHtml(replyBody(m.reply), links)}</div></div>`
36
39
  : m.kind === 'ask-link' ? `<div class="msg link"><a href="${esc(m.link)}">${esc(m.text)} →</a></div>`
37
- : m.request ? `<div class="msg request">${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
38
- : m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}"><span class="working-dot" aria-hidden="true"></span>${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
39
- : m.kind === 'calls' ? `<details class="msg calls"><summary>${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
40
- : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}"><span class="agent-dot" aria-hidden="true"></span>${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
40
+ : m.request ? `<div class="msg request">${statusMark(m.text)}${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
41
+ : m.kind === 'questions' ? `<div class="msg builder-status planner new-questions">${statusMark('questions')}<a href="${esc(m.link)}">${esc(m.text)}</a> <span class="muted">· ${esc(time(m.ts))}</span></div>`
42
+ : m.kind === 'status' ? `<div class="msg builder-status${m.planner ? ' planner' : ''}">${mark('<span class="working-dot"></span>')}${linkText(m.text, links)} <span class="muted">· ${esc(time(m.ts))}</span></div>`
43
+ : m.kind === 'calls' ? `<details class="msg calls"><summary>${statusMark('judgment calls')}${esc(m.text)}'s judgment calls · ${m.calls.length} · ${m.verified ? `verified ${esc(time(m.verified.at))}` : 'not verified yet'}</summary><ol>${m.calls.map((c) => `<li>${linkText(c, links)}</li>`).join('')}</ol>${m.verified?.note ? `<div class="small">Verified: ${linkText(m.verified.note, links)}</div>` : ''}</details>`
44
+ : m.kind === 'agent' ? `<div class="msg agent ${m.finished ? 'finished' : 'started'}${m.planner ? ' planner' : ''}">${mark('<span class="agent-dot"></span>')}${esc(m.text)} <span class="muted">· ${esc(time(m.ts))}${m.eta ? ` · ${esc(etaLine(m.eta))}` : ''}</span></div>` : `<div class="msg ${m.who === 'love' ? 'love' : m.who === 'claude' ? 'claude' : 'other'}"${m.eventId ? ` data-event="${esc(m.eventId)}"` : ''}>
41
45
  ${m.canDelete || m.canEdit ? `<div class="ask-actions">${m.canEdit ? editButton(m.eventId) : ''}${m.canDelete ? withdrawButton(m.eventId) : ''}</div>` : ''}
42
46
  <div class="who">${m.who === 'love' ? '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>
43
47
  <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)}
@@ -48,7 +52,7 @@
48
52
 
49
53
  const STEP = 3;
50
54
  const UNREAD_CAP = 10;
51
- const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link']);
55
+ const NOT_MESSAGE = new Set(['status', 'agent', 'calls', 'ask-link', 'questions']);
52
56
  const isMessage = (m) => !NOT_MESSAGE.has(m.kind);
53
57
  // Only Claude's and GitHub's messages are unread, as on the badge (domain.mjs othersAt, ui-cards.js unreadCount); the
54
58
  // orchestrator's answer to a builder's ask never is.
@@ -92,5 +96,14 @@
92
96
  return `<button type="button"${tone ? ` class="${tone}"` : ''} data-confirm="finish" data-key="${esc(it.key)}">Done</button>`;
93
97
  }
94
98
 
95
- Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton });
99
+ // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
100
+ // A card's open questions asked since seenAt as one blue line in its thread, at the newest one's time, linking to the
101
+ // first: it.questions from the board. → [line], or [] with none new or no seen mark.
102
+ function newQuestionsLine(questions, seenAt) {
103
+ const fresh = seenAt ? (questions?.askedAt || []).filter((t) => String(t) > seenAt) : [];
104
+ if (!fresh.length) return [];
105
+ return [{ ts: fresh.at(-1), who: 'claude', kind: 'questions', text: `${fresh.length} new question${fresh.length === 1 ? '' : 's'}`, link: `questions.html#${encodeURIComponent(questions.first)}` }];
106
+ }
107
+
108
+ Object.assign(globalThis.BoardUI, { threadMessages, withReplies, doneButton, newQuestionsLine });
96
109
  })();
@@ -13,6 +13,9 @@
13
13
  if (s < 86400) return Math.round(s / 3600) + 'h ago';
14
14
  return Math.round(s / 86400) + 'd ago';
15
15
  };
16
+ // How long ago a card's status was first set, as its status line ends: "just now", "14m", "3h", "2d"
17
+ // (docs/adr/2026-11-04-2300-each-lane-is-a-queue-for-the-orchestrator.md).
18
+ const lasted = (iso) => ago(iso).replace(/ ago$/, '');
16
19
  const since = (iso) => {
17
20
  const s = (Date.now() - new Date(iso).getTime()) / 1000;
18
21
  return s < 60 ? 'just now' : s < 3600 ? Math.round(s / 60) + ' min ago' : s < 86400 ? Math.round(s / 3600) + ' h ago' : Math.round(s / 86400) + ' d ago';
@@ -56,16 +59,26 @@
56
59
  const LINK_ARROW = icon('<path d="M7 17 17 7"/><path d="M7 7h10v10"/>', '1em', ' class="link-arrow" style="vertical-align:-0.125em"');
57
60
  const EYE = icon('<path d="M2.06 12.35a1 1 0 0 1 0-.7 10.75 10.75 0 0 1 19.88 0 1 1 0 0 1 0 .7 10.75 10.75 0 0 1-19.88 0"/><circle cx="12" cy="12" r="3"/>');
58
61
  const TRASH = icon('<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>');
59
- const ELLIPSIS = icon('<circle cx="5" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="19" cy="12" r="1" fill="currentColor"/>');
60
- // Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md).
61
- function protoButton(id, state = null, { label = 'Ask for prototypes' } = {}) {
62
- if (state?.pending) return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" data-proto-pending="${esc(state.pending)}" aria-label="Prototypes are being made" title="Prototypes are being made">${ELLIPSIS}</button>`;
63
- return `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
64
- }
65
- // latestBump is the data.id of the newest ask-bump event.
66
- const protoNotice = (eventId, latestBump) => (latestBump === eventId
67
- ? { text: 'Prototypes are being made.', bump: false }
68
- : { text: 'Prototypes are being made. Bump them to the top of the queue?', bump: true });
62
+ const FIRE = icon('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>');
63
+ // Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md): a
64
+ // question's eye sends at once; a thread's eye is a toggle that Send carries (app-threads.js).
65
+ const protoButton = (id, { label = 'Ask for prototypes' } = {}) => `<button type="button" class="attach proto-ask" data-proto="${esc(id)}" aria-label="${esc(label)}" title="${esc(label)}">${EYE}</button>`;
66
+ // The thread's fire, the eye's twin for a grill.
67
+ const grillButton = (id) => `<button type="button" class="attach proto-ask" data-grill="${esc(id)}" aria-label="Ask for a grill" title="Ask for a grill">${FIRE}</button>`;
68
+ // docs/adr/2026-11-04-2130-a-button-tap-is-a-status-line-or-a-message-footer.md
69
+ // A thread status line's leading mark, its icon or dot centred in one fixed box so every line's text starts at the
70
+ // same place: the request's eye, fire or quick build's lightning, answers' check, new questions' plain question mark, a builder's dot.
71
+ const mini = (body, cls = '') => `<svg class="mark-icon${cls}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
72
+ const MARK_ICONS = {
73
+ 'prototype requested': mini('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
74
+ 'grill requested': mini('<path d="M12 3c1 3 5 5 5 10a5 5 0 0 1-10 0c0-2 1-3 2-4 0 2 1 3 2 3 0-3-1-6 1-9z"/>'),
75
+ 'quick build: no grill, as you asked': mini('<path d="M13 2 4 14h7l-1 8 9-12h-7z"/>'),
76
+ 'answers submitted': mini('<path d="M20 6 9 17l-5-5"/>'),
77
+ 'judgment calls': mini('<path d="m9 6 6 6-6 6"/>'),
78
+ questions: mini('<path d="M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.7.4-1 .9-1 1.7M12 17h.01"/>', ' big'),
79
+ };
80
+ const mark = (inner = '') => `<span class="mark" aria-hidden="true">${inner}</span>`;
81
+ const statusMark = (words) => mark(MARK_ICONS[words] || '');
69
82
  const PENCIL = icon('<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>');
70
83
  const BUMP = icon('<path d="M5 3h14"/><path d="m18 13-6-6-6 6"/><path d="M12 7v14"/>');
71
84
  // Deletes or edits a pending ask or thread message; app-asks.js handles [data-withdraw] and [data-edit] on one path.
@@ -280,6 +293,23 @@
280
293
  return promise;
281
294
  }
282
295
 
296
+ // docs/adr/2026-11-04-2320-a-merge-tap-waits-its-turn-to-land.md
297
+ // A tap whose change shows only in a later board: its button draws BUSY while pending.has(id), id being
298
+ // "<card key>:<action>", whatever repaints it, until settle(board) finds a board made after the tap's answer for which
299
+ // until(board) holds. start(id, until) on the tap; answered(id, generatedAt) with the generatedAt of the POST's board;
300
+ // drop(id) when the POST is refused. Boards made before the POST answered never end it, so a reload that races the POST
301
+ // keeps the spinner.
302
+ const pendings = new Map();
303
+ const pending = {
304
+ start(id, until) { pendings.set(id, { until, after: null }); },
305
+ answered(id, after) { const p = pendings.get(id); if (p) p.after = after; },
306
+ drop(id) { pendings.delete(id); },
307
+ has: (id) => pendings.has(id),
308
+ settle(board) {
309
+ for (const [id, p] of pendings) if (p.after !== null && board.generatedAt >= p.after && p.until(board)) pendings.delete(id);
310
+ },
311
+ };
312
+
283
313
  // While a board restarts, the hub answers its own 502 page, and a request can fail outright. fetchJSON waits that out:
284
314
  // on a network error, a 5xx or a body that isn't JSON it calls onWait(waitedMs) and retries with a growing backoff,
285
315
  // then throws once about limit ms have passed. Any other answer resolves {ok, status, body}, a 404's {error} included.
@@ -309,7 +339,7 @@
309
339
 
310
340
  globalThis.BoardUI = {
311
341
  LINK_ARROW,
312
- esc, record, time, ago, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, fetchJSON, RESTARTING, restartNotice,
313
- attachments, attachButton, dropZone, pendingFiles, protoButton, protoNotice, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
342
+ esc, record, time, ago, lasted, since, clock, etaLine, cardTime, replyBody, setHTML, closeDetails, BUSY, waitOn, pending, fetchJSON, RESTARTING, restartNotice,
343
+ attachments, attachButton, dropZone, pendingFiles, protoButton, grillButton, mark, statusMark, MARK_ICONS, withdrawButton, editButton, bumpButton, cardBumpButton, workingLine, BADGE_TONES, badge,
314
344
  };
315
345
  })();
@@ -85,6 +85,7 @@
85
85
  B.on(doc, 'click', (e) => tap(e, win));
86
86
  // Switch mode (sidebar.js) answers keys and clicks in the frame as it does the shell's own.
87
87
  if (ctx.keydown) B.on(doc, 'keydown', ctx.keydown, true);
88
+ if (B.newTaskKey) B.on(doc, 'keydown', B.newTaskKey);
88
89
  if (ctx.pointerdown) B.on(doc, 'pointerdown', ctx.pointerdown, true);
89
90
  ctx.loaded(url, title);
90
91
  });