@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.
- package/CONTEXT.md +32 -16
- package/SKILL.md +8 -6
- package/bin/doctor.mjs +4 -4
- package/bin/install.mjs +3 -3
- package/bin/kickflip.mjs +8 -8
- package/bin/wait-events.mjs +2 -2
- package/board/bin/board.mjs +50 -28
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -3
- package/board/headless.mjs +4 -3
- package/board/merge3.mjs +1 -0
- package/board/on-host.mjs +1 -1
- package/board/permissions.mjs +2 -2
- package/board/public/app-asks.js +6 -89
- package/board/public/app-cards.js +19 -10
- package/board/public/app-core.js +1 -20
- package/board/public/app-reader.js +174 -0
- package/board/public/app-threads.js +49 -35
- package/board/public/app.html +2 -0
- package/board/public/app.js +28 -16
- package/board/public/hub.css +1 -0
- package/board/public/new-task.js +121 -0
- package/board/public/shell.js +4 -0
- package/board/public/sidebar.css +1 -0
- package/board/public/style.css +91 -3
- package/board/public/ui-cards.js +46 -21
- package/board/public/ui-enums.js +1 -1
- package/board/public/ui-phase-bars.js +4 -4
- package/board/public/ui-questions.js +47 -18
- package/board/public/ui-sidebar.js +9 -3
- package/board/public/ui-threads.js +25 -12
- package/board/public/ui.js +42 -12
- package/board/public/view-frame.js +1 -0
- package/board/public/view-questions.js +45 -16
- package/board/public/view-settings.js +38 -19
- package/board/server.mjs +20 -18
- package/board/store.mjs +15 -13
- package/board/worktree.mjs +20 -20
- package/hub/boards.mjs +4 -4
- package/hub/front-door.mjs +20 -0
- package/hub/github.mjs +3 -3
- package/hub/hub.mjs +9 -12
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -0
- package/hub/readiness.mjs +2 -2
- package/hub/sessions.mjs +2 -2
- package/hub/try.mjs +7 -7
- package/package.json +1 -1
- package/shared/owner-check.mjs +3 -0
- package/shared/tailscale.mjs +1 -0
- package/shared/usage-gate.mjs +1 -1
package/board/public/ui-cards.js
CHANGED
|
@@ -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: '
|
|
66
|
-
current: 'Nothing new to
|
|
67
|
-
busy: '
|
|
68
|
-
autoSub: 'The hub
|
|
69
|
-
saved: (on) => on ? 'Saved. Each push
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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,
|
|
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.
|
|
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"
|
|
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,
|
|
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') : '');
|
package/board/public/ui-enums.js
CHANGED
|
@@ -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({
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
7
|
-
// strip: {reached, running, dim, label, fill}, or null for no bars. reached counts the phases reached, the last of
|
|
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
|
-
|
|
8
|
-
|
|
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
|
|
12
|
-
// (view-questions.js)
|
|
13
|
-
const questionEye = (q) =>
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
95
|
-
const
|
|
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,
|
|
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
|
|
103
|
-
${
|
|
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
|
|
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)}">${
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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 === '
|
|
39
|
-
: m.kind === '
|
|
40
|
-
: m.kind === '
|
|
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
|
-
|
|
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
|
})();
|
package/board/public/ui.js
CHANGED
|
@@ -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
|
|
60
|
-
// Asks Claude for throwaway prototypes of one item (docs/adr/2026-10-29-1639-prototypes-come-on-request.md)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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,
|
|
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
|
});
|