@lovek4d/kickflip 0.6.1 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +60 -48
- package/SKILL.md +20 -14
- package/bin/event-lines.mjs +1 -0
- package/bin/kickflip.mjs +8 -8
- package/bin/wait-events.mjs +2 -2
- package/board/agents.mjs +2 -2
- package/board/arch-review.mjs +4 -4
- package/board/bin/board.mjs +118 -104
- package/board/domain.mjs +7 -7
- package/board/finish.mjs +4 -4
- package/board/headless.mjs +8 -9
- package/board/hub-lines.mjs +1 -0
- package/board/onboarding.mjs +2 -2
- package/board/prototype-page.mjs +1 -1
- package/board/public/app-arch.js +3 -3
- package/board/public/app-banner.js +1 -1
- package/board/public/app-cards.js +17 -9
- package/board/public/app-header.js +1 -1
- package/board/public/app-lanes.js +39 -61
- package/board/public/app-needs.js +116 -30
- package/board/public/app-reader.js +54 -44
- package/board/public/app-threads.js +2 -2
- package/board/public/app.html +1 -1
- package/board/public/app.js +23 -13
- package/board/public/dialogs.js +1 -1
- package/board/public/hub-kit.js +1 -1
- package/board/public/hub.css +2 -0
- package/board/public/linear-key.js +1 -1
- package/board/public/new-task.js +1 -1
- package/board/public/progress-review.js +4 -4
- package/board/public/prototype-kit.js +27 -16
- package/board/public/prototype-switcher.js +47 -32
- package/board/public/prototype.html +7 -4
- package/board/public/router.js +35 -7
- package/board/public/shell.js +19 -13
- package/board/public/sidebar.css +4 -4
- package/board/public/sidebar.js +2 -2
- package/board/public/style.css +74 -36
- package/board/public/ui-cards.js +56 -37
- package/board/public/ui-enums.js +10 -1
- package/board/public/ui-hub.js +1 -5
- package/board/public/ui-links.js +6 -6
- package/board/public/ui-order.js +8 -6
- package/board/public/ui-progress-bar.js +6 -7
- package/board/public/ui-questions.js +30 -13
- package/board/public/ui-sidebar.js +3 -2
- package/board/public/ui-threads.js +20 -19
- package/board/public/ui.js +15 -4
- package/board/public/view-hub-settings.js +28 -31
- package/board/public/view-hub.js +7 -9
- package/board/public/view-notes.js +2 -2
- package/board/public/view-questions.js +35 -6
- package/board/public/view-settings.js +63 -31
- package/board/render-check.mjs +10 -10
- package/board/review.mjs +1 -1
- package/board/server.mjs +18 -20
- package/board/stop.mjs +1 -1
- package/board/store.mjs +26 -25
- package/board/tickets.mjs +1 -1
- package/board/transcript.mjs +4 -2
- package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
- package/hub/awake.mjs +1 -1
- package/hub/boards.mjs +4 -4
- package/hub/github.mjs +3 -3
- package/hub/home.mjs +1 -1
- package/hub/hub.mjs +11 -11
- package/hub/incoming.mjs +2 -2
- package/hub/locks.mjs +1 -1
- package/hub/new-board.mjs +2 -2
- package/hub/owner-checks.mjs +1 -1
- package/hub/try.mjs +6 -7
- package/hub/upgrade.mjs +1 -1
- package/hub/views.mjs +1 -1
- package/package.json +1 -1
- package/shared/tailscale.mjs +1 -1
- package/shared/usage-gate.mjs +1 -1
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
// The board view's Needs you (domain.mjs needsOf), led by the board session's pending prompts, answered in place
|
|
2
2
|
// (prompts.js). It draws twice, and the stylesheet shows one by width
|
|
3
3
|
// (docs/adr/2026-11-05-0408-needs-you-is-a-deck-on-a-phone-and-a-pane-on-a-desktop.md):
|
|
4
|
-
// - on a phone, #needsDeck at the top of the board: one thing at a time, a question's
|
|
5
|
-
//
|
|
4
|
+
// - on a phone, #needsDeck at the top of the board: one thing at a time, a question's option marked on the first tap,
|
|
5
|
+
// turning into Send “<label>”, and sent with the question's drafted note and files on a tap on it
|
|
6
|
+
// (docs/adr/2026-11-07-1212-a-deck-option-sends-on-a-second-tap.md), a prompt's buttons answering it, Prev and Next
|
|
7
|
+
// beside a dot for each thing waiting, and All open questions at its foot;
|
|
6
8
|
// - from 900px, #waiting, one line each, in the pane beside the lanes, over Waiting on you mounted there
|
|
7
9
|
// (view-questions.js, embedded) while the width lasts. Waiting on you shows the questions there, so #waiting leaves out
|
|
8
10
|
// a line that only asks questions, and with nothing else left the orange section is gone. Every questions URL lands
|
|
9
|
-
// here at that width (shell.js go, focusQuestion), and a pill
|
|
10
|
-
// (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md).
|
|
11
|
+
// here at that width (shell.js go, focusQuestion), and two taps on a pill focus its card on the board
|
|
12
|
+
// (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md). A prototype opens in the pane too, in place of
|
|
13
|
+
// Waiting on you until its ‹ Open questions (openViewer).
|
|
11
14
|
// Below 900px the header's ? opens Waiting on you, with the open-question count as its badge.
|
|
12
15
|
// Needs app-core.js.
|
|
13
16
|
BoardView.part((B) => {
|
|
14
17
|
const { $, plural, state, toast } = B;
|
|
15
|
-
const { esc, escText, promptItem, choiceLabel, isPrototype, getDraft, setDraft } = BoardUI;
|
|
18
|
+
const { esc, escText, markdown, promptItem, choiceLabel, isPrototype, getDraft, setDraft, noteOf, waitOn } = BoardUI;
|
|
16
19
|
|
|
17
20
|
const prompts = PromptAnswers.wire(B, {
|
|
18
21
|
list: () => (state.board?.needs || []).filter((line) => line.prompt).map((line) => line.prompt),
|
|
@@ -29,6 +32,7 @@ BoardView.part((B) => {
|
|
|
29
32
|
renderList(wide.matches ? needs.filter((line) => !line.questions || line.ready || line.action) : needs);
|
|
30
33
|
renderDeck(d, needs);
|
|
31
34
|
renderAsk(d);
|
|
35
|
+
if (viewer) paintViewer(d);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
38
|
// The header's ? below 900px, which opens Waiting on you, carries the open-question count as its badge.
|
|
@@ -61,6 +65,7 @@ BoardView.part((B) => {
|
|
|
61
65
|
// order; any other line as itself. → [{id, what, html}]
|
|
62
66
|
function deckOf(d, needs) {
|
|
63
67
|
const items = d.items || [];
|
|
68
|
+
const links = BoardUI.linkContext(items);
|
|
64
69
|
const owner = (d.owner || []).filter((q) => q.state === 'open');
|
|
65
70
|
const topicOf = (line) => line.topic || items.find((it) => it.key === line.key)?.questions?.topic || null;
|
|
66
71
|
return needs.flatMap((line) => {
|
|
@@ -69,7 +74,7 @@ BoardView.part((B) => {
|
|
|
69
74
|
const topic = line.questions && topicOf(line);
|
|
70
75
|
const order = (d.ownerTopics || []).find((t) => t.id === topic)?.questions || [];
|
|
71
76
|
const asks = topic ? owner.filter((q) => q.topic === topic).sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id)) : [];
|
|
72
|
-
if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q) }));
|
|
77
|
+
if (asks.length) return asks.map((q) => ({ id: `q:${q.id}`, what, html: questionHtml(q, links) }));
|
|
73
78
|
const [label, href, attrs] = line.questions ? [`Answer ${plural(line.questions.open, 'question')}`, line.questions.url, '']
|
|
74
79
|
: line.ready ? ['Ready on GitHub', line.ready, ' target="_blank" rel="noopener"']
|
|
75
80
|
: line.action ? [line.action.label, line.action.url, '']
|
|
@@ -80,11 +85,17 @@ BoardView.part((B) => {
|
|
|
80
85
|
html: `<h3>${line.key ? `<a href="${esc(line.url)}">${escText(title)}</a>` : escText(title)}</h3>${line.review ? '<p class="ctx">review held for you</p>' : ''}<div class="deck-acts"><a class="button primary" href="${esc(href)}"${attrs}>${esc(label)}</a></div>` }];
|
|
81
86
|
});
|
|
82
87
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
// The marked option, the question's drafted pick, reads Send “<label>”.
|
|
89
|
+
const questionHtml = (q, links) => {
|
|
90
|
+
const marked = getDraft(q.id).choice;
|
|
91
|
+
const optionHtml = (o) => {
|
|
92
|
+
const armed = o.id === marked;
|
|
93
|
+
const label = armed ? `Send “${esc(o.label)}”` : `${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}`;
|
|
94
|
+
return `<button type="button" class="${[o.recommended && 'rec', armed && 'armed'].filter(Boolean).join(' ')}" aria-pressed="${armed}" data-deck-pick="${esc(o.id)}"><span class="lbl">${label}</span>${o.detail ? `<div class="det">${markdown(o.detail, links)}</div>` : ''}</button>`;
|
|
95
|
+
};
|
|
96
|
+
return `<h3>${escText(q.question)}</h3>${q.context ? `<div class="ctx">${markdown(q.context, links)}</div>` : ''}
|
|
86
97
|
${isPrototype(q.url) ? `<a class="button deck-proto" href="${esc(q.url)}">View prototypes</a>` : ''}
|
|
87
|
-
<div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map(
|
|
98
|
+
<div class="deck-opts" role="group" aria-label="${esc(q.question)}">${q.options.map(optionHtml).join('')}</div>`;
|
|
88
99
|
};
|
|
89
100
|
|
|
90
101
|
// The thing on screen, by id, so an arrival or a repaint keeps it; once it is gone the deck shows what took its place.
|
|
@@ -98,46 +109,73 @@ BoardView.part((B) => {
|
|
|
98
109
|
if (!deck.length) { el.innerHTML = ''; showing = null; return; }
|
|
99
110
|
const now = deck.find((x) => x.id === showing) || deck[Math.min(Math.max(before, 0), deck.length - 1)];
|
|
100
111
|
showing = now.id;
|
|
112
|
+
const at = deck.indexOf(now);
|
|
101
113
|
const open = (d.owner || []).filter((q) => q.state === 'open').length;
|
|
102
|
-
const drafted = (d.owner || []).filter((q) => q.state === 'open' &&
|
|
114
|
+
const drafted = (d.owner || []).filter((q) => q.state === 'open' && getDraft(q.id).choice != null).length;
|
|
103
115
|
BoardUI.setHTML(el, `<div class="deck-head"><h2>Needs you</h2><span class="deck-n">${deck.length}</span></div>
|
|
104
116
|
<div class="deck-card" data-deck="${esc(now.id)}"><p class="deck-what">${escText(now.what)}</p>${now.html}</div>
|
|
105
|
-
${deck.length > 1 ? `<div class="deck-bar"><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-
|
|
117
|
+
${deck.length > 1 ? `<div class="deck-bar"><button type="button" class="ghost" data-deck-prev${at === 0 ? ' disabled' : ''}>← Prev</button><span class="deck-dots" aria-hidden="true">${deck.map((x) => `<i${x === now ? ' class="on"' : ''}></i>`).join('')}</span><button type="button" class="ghost" data-deck-next${at === deck.length - 1 ? ' disabled' : ''}>Next →</button></div>` : ''}
|
|
106
118
|
${open ? `<a class="deck-more" href="questions.html">All open questions · ${open}${drafted ? ` · ${drafted} drafted, Send there` : ''}</a>` : ''}`);
|
|
107
119
|
}
|
|
108
120
|
|
|
121
|
+
// The deck's index one step from the thing on screen; the ends hold.
|
|
122
|
+
const stepFrom = (step) => Math.min(Math.max(deck.findIndex((x) => x.id === showing) + step, 0), deck.length - 1);
|
|
109
123
|
B.on(B.root, 'click', (ev) => {
|
|
110
124
|
if (!ev.target.closest?.('#needsDeck')) return;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
showing = deck[(
|
|
125
|
+
const step = ev.target.closest('[data-deck-prev]') ? -1 : ev.target.closest('[data-deck-next]') ? 1 : 0;
|
|
126
|
+
if (step) {
|
|
127
|
+
showing = deck[stepFrom(step)].id;
|
|
114
128
|
renderNeeds(state.board);
|
|
115
129
|
return;
|
|
116
130
|
}
|
|
117
131
|
const opt = ev.target.closest('button[data-deck-pick]');
|
|
118
132
|
if (!opt) return;
|
|
119
133
|
const id = opt.closest('[data-deck]').dataset.deck.slice(2);
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
const choice = opt.dataset.deckPick;
|
|
135
|
+
const draft = getDraft(id);
|
|
136
|
+
if (draft.choice !== choice) {
|
|
137
|
+
setDraft(id, { ...draft, choice });
|
|
138
|
+
renderNeeds(state.board);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
sendAnswer((state.board.owner || []).find((q) => q.id === id), choice, opt);
|
|
124
142
|
});
|
|
125
143
|
|
|
144
|
+
// The marked option's tap sends it with the question's drafted note and files, as the questions page's Send does, and
|
|
145
|
+
// spins until the board shows the question answered, which takes it off the deck. Files still uploading hold it.
|
|
146
|
+
async function sendAnswer(q, choice, button) {
|
|
147
|
+
const composer = 'q:' + q.id;
|
|
148
|
+
if (BoardAttach.busy(composer, B.site.root)) { toast('Wait for the upload to finish'); return; }
|
|
149
|
+
const files = BoardAttach.files(composer, B.site.root);
|
|
150
|
+
const answer = { id: q.id, question: q.question, choice, label: choiceLabel(q, choice), note: noteOf(q, getDraft(q.id)).trim(), ...(files.length ? { files } : {}) };
|
|
151
|
+
const sent = () => { setDraft(q.id, {}); BoardAttach.clear(composer, B.site.root); };
|
|
152
|
+
try {
|
|
153
|
+
await waitOn(button, BoardUI.pending.within(B.send('owner-answers', { answers: [answer] }, sent), B.stillWaiting));
|
|
154
|
+
} catch (e) { B.failed(e); }
|
|
155
|
+
}
|
|
156
|
+
|
|
126
157
|
// From 900px, Waiting on you in the pane under the Needs you lines, mounted while the width lasts.
|
|
127
158
|
let questions = null;
|
|
159
|
+
// Two taps on a pane pill focus its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
|
|
160
|
+
// wave:<slug> or file:<slug> a band; any other topic, or one the board doesn't show, does nothing.
|
|
161
|
+
function openOnBoard(id) {
|
|
162
|
+
if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
|
|
163
|
+
else if (id.startsWith('wave:') || id.startsWith('file:')) B.focusOnBoard({ slug: id.slice(5) });
|
|
164
|
+
}
|
|
128
165
|
function fitPane() {
|
|
129
166
|
if (wide.matches === Boolean(questions)) return;
|
|
130
167
|
if (state.board) renderNeeds(state.board);
|
|
131
|
-
if (questions) { questions.unmount(); questions.slot.remove(); questions = null; return; }
|
|
168
|
+
if (questions) { closeViewer(); questions.unmount(); questions.slot.remove(); questions = null; return; }
|
|
132
169
|
$('#needsPane').insertAdjacentHTML('beforeend', `<div class="view-questions needs-questions">${AppViews.questions.markup({ embedded: true })}</div>`);
|
|
133
170
|
const slot = $('#needsPane .needs-questions');
|
|
134
|
-
questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true }), { slot });
|
|
171
|
+
questions = Object.assign(AppViews.questions.create(slot, B.site.root, { embedded: true, openOnBoard, onTopic: (id) => B.saveFragment({ openItemsCard: id }) }), { slot });
|
|
135
172
|
questions.mount();
|
|
136
173
|
}
|
|
137
174
|
// The shell's one rule for a questions URL from 900px (shell.js go): it shows the board and asks here for the question
|
|
138
175
|
// or topic id, or null for none. The pane opens its topic, resolved or not, and focuses its pill → false while there
|
|
139
176
|
// is no pane or it hasn't loaded, so the shell asks again.
|
|
140
177
|
function focusQuestion(id) {
|
|
178
|
+
closeViewer();
|
|
141
179
|
const topic = (state.board?.ownerTopics || []).find((t) => t.id === id || t.questions.includes(id));
|
|
142
180
|
if (!questions?.B.openTopic(topic?.id ?? id)) return false;
|
|
143
181
|
const pill = questions.slot.querySelector('.topics button[aria-pressed="true"]');
|
|
@@ -145,18 +183,66 @@ BoardView.part((B) => {
|
|
|
145
183
|
pill?.scrollIntoView({ block: 'nearest' });
|
|
146
184
|
return true;
|
|
147
185
|
}
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
186
|
+
|
|
187
|
+
// From 900px a prototype opens in the pane (docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md,
|
|
188
|
+
// shell.js go): a top bar of ‹ Open questions, ⤢ and the card's Round pill over the prototype page framed at the
|
|
189
|
+
// pane's width, which shows its footer at the bottom of the pane (prototype-switcher.js .ps-pane). ⤢ widens the pane
|
|
190
|
+
// over the lanes and back. ‹ returns to Waiting on you with the round's question focused, as Choose this does through
|
|
191
|
+
// the shell. viewer: {url, q, widened, sheet} while one is open. → false while there is no pane, so the shell asks again.
|
|
192
|
+
let viewer = null;
|
|
193
|
+
function openViewer(url) {
|
|
194
|
+
if (!questions) return false;
|
|
195
|
+
viewer = { url, q: new URL(url, location.href).searchParams.get('q'), widened: viewer?.widened ?? false, sheet: false };
|
|
196
|
+
paintViewer();
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
199
|
+
// board: the board download it shows, which renderNeeds passes before state.board holds it.
|
|
200
|
+
function paintViewer(board = state.board) {
|
|
201
|
+
const pane = $('#needsPane');
|
|
202
|
+
pane.classList.toggle('viewing', Boolean(viewer));
|
|
203
|
+
pane.classList.toggle('widened', Boolean(viewer?.widened));
|
|
204
|
+
let box = pane.querySelector('.pane-viewer');
|
|
205
|
+
if (!viewer) { box?.remove(); return; }
|
|
206
|
+
if (!box) {
|
|
207
|
+
pane.insertAdjacentHTML('beforeend', '<div class="pane-viewer"><div class="pv-top"></div><iframe class="pv-frame" title="Prototype"></iframe></div>');
|
|
208
|
+
box = pane.querySelector('.pane-viewer');
|
|
209
|
+
}
|
|
210
|
+
const card = (board?.items || []).find((it) => it.prototypes?.rounds?.some((r) => r.q === viewer.q));
|
|
211
|
+
const rounds = card?.prototypes.rounds;
|
|
212
|
+
box.querySelector('.pv-top').innerHTML = `<button type="button" class="pv-back" data-pv="back">‹ Open questions</button><span class="pv-grow"></span>
|
|
213
|
+
<button type="button" class="pv-widen" data-pv="widen" aria-pressed="${viewer.widened}" aria-label="Widen over the lanes" title="Widen over the lanes">⤢</button>${BoardUI.roundPill(rounds, viewer.q)}`;
|
|
214
|
+
box.querySelector('.ps-sheet')?.remove();
|
|
215
|
+
if (viewer.sheet && rounds) box.insertAdjacentHTML('beforeend', BoardUI.roundSheet({ ref: card.ref, rounds, qid: viewer.q }));
|
|
216
|
+
const frame = box.querySelector('iframe.pv-frame');
|
|
217
|
+
if (frame.getAttribute('src') !== viewer.url) frame.setAttribute('src', viewer.url);
|
|
218
|
+
}
|
|
219
|
+
function closeViewer() {
|
|
220
|
+
if (!viewer) return;
|
|
221
|
+
viewer = null;
|
|
222
|
+
paintViewer();
|
|
223
|
+
}
|
|
224
|
+
B.on(B.root, 'click', (ev) => {
|
|
225
|
+
if (!viewer || !ev.target.closest?.('#needsPane .pane-viewer')) return;
|
|
226
|
+
const round = ev.target.closest('[data-ps-round]')?.dataset.psRound;
|
|
227
|
+
if (round) { openViewer(round); return; }
|
|
228
|
+
const act = ev.target.closest('[data-pv], [data-ps-top]');
|
|
229
|
+
if (!act) return;
|
|
230
|
+
if (act.dataset.pv === 'back') { focusQuestion(viewer.q); return; }
|
|
231
|
+
if (act.dataset.pv === 'widen') viewer.widened = !viewer.widened;
|
|
232
|
+
else viewer.sheet = !viewer.sheet;
|
|
233
|
+
paintViewer();
|
|
156
234
|
});
|
|
235
|
+
// A push to a card's thread (#selectedCard=<key>&reply, board/store.mjs threadUrl), the switcher's for a question
|
|
236
|
+
// already picked among them, closes the viewer, so the thread it sent the owner to shows. The older #item=<key>&reply
|
|
237
|
+
// reads the same.
|
|
238
|
+
B.on(window, 'hashchange', () => {
|
|
239
|
+
const at = AppRouter.fragmentOf(location.hash);
|
|
240
|
+
if (at.selectedCard && at.reply) closeViewer();
|
|
241
|
+
});
|
|
242
|
+
|
|
157
243
|
fitPane();
|
|
158
244
|
B.on(wide, 'change', fitPane);
|
|
159
245
|
B.atUnmount(() => { questions?.unmount(); questions = null; });
|
|
160
246
|
|
|
161
|
-
Object.assign(B, { renderNeeds, focusQuestion });
|
|
247
|
+
Object.assign(B, { renderNeeds, focusQuestion, openViewer });
|
|
162
248
|
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "
|
|
2
|
-
// (ui-threads.js) and a card's or a
|
|
1
|
+
// The reader sheet (docs/adr/2026-11-04-2345-a-builders-log-and-notes-open-in-a-sheet.md): a thread's "View transcript"
|
|
2
|
+
// (ui-threads.js) and a card's or a wave's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
|
|
3
3
|
// place of log.html and notes.html, which still open on their own. It closes on its ✕, Escape, a tap on the dim area
|
|
4
4
|
// and Back, through a history entry of its own that carries the board's URL, so the router's pop keeps the board.
|
|
5
5
|
// A link in it closes it first, then opens through the shell.
|
|
6
|
-
// A log written as a stream (headless.mjs: one stream-json event per line) reads as
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// A log written as a stream (headless.mjs: one stream-json event per line) reads as the run's transcript, the whole of
|
|
7
|
+
// it as GET /api/transcript's rows: what Claude says, and each tool call as one line whose output opens on a tap, with a
|
|
8
|
+
// raw toggle for the file as it is (docs/adr/2026-11-07-1224-view-transcript-reads-the-whole-run-as-rows.md); any other
|
|
9
|
+
// log reads as its text. Every 3 seconds until the run's result arrives it reads on from where it left off, and follows
|
|
10
|
+
// new lines while read at its end.
|
|
9
11
|
BoardView.part((B) => {
|
|
10
12
|
const { $, state } = B;
|
|
11
13
|
const { esc, markdown, linkContext } = BoardUI;
|
|
@@ -19,31 +21,13 @@ BoardView.part((B) => {
|
|
|
19
21
|
let log = null;
|
|
20
22
|
let stopReading = null;
|
|
21
23
|
|
|
22
|
-
//
|
|
23
|
-
function
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
return events.some(Boolean) ? lines.map((line, i) => events[i] ?? line) : null;
|
|
24
|
+
// A row of the transcript (transcript.mjs); a call with its output opens it on a tap of its line.
|
|
25
|
+
function rowHtml(r, i) {
|
|
26
|
+
const tag = r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : '';
|
|
27
|
+
if (r.out === undefined) return `<div class="reader-row ${r.kind}">${tag}${esc(r.body)}</div>`;
|
|
28
|
+
const on = log.opened.has(i);
|
|
29
|
+
return `<div class="reader-row tool call" data-call="${i}"><span class="reader-caret">${on ? '▾' : '▸'}</span>${tag}${esc(r.body)}</div>${on ? `<div class="reader-out">${esc(r.out)}</div>` : ''}`;
|
|
29
30
|
}
|
|
30
|
-
const short = (v) => (v.length > 400 ? `${v.slice(0, 400)}…` : v);
|
|
31
|
-
const textOf = (content) => (typeof content === 'string' ? content : content.map((part) => part.text ?? '').join('\n'));
|
|
32
|
-
const callOf = (input) => input.command ?? input.file_path ?? input.pattern ?? input.url ?? input.description ?? JSON.stringify(input);
|
|
33
|
-
// A stream line's readable rows {kind, label, body}. Thinking and events other than the start, messages and the
|
|
34
|
-
// result have none; the raw view shows them.
|
|
35
|
-
function rowsOf(e) {
|
|
36
|
-
if (typeof e === 'string') return [{ kind: 'out', body: e }];
|
|
37
|
-
if (e.type === 'system') return e.subtype === 'init' ? [{ kind: 'sys', label: 'start', body: `${e.model} in ${e.cwd}` }] : [];
|
|
38
|
-
if (e.type === 'result') return [{ kind: 'text end', label: e.is_error ? 'stopped' : 'done', body: e.result ?? e.subtype }];
|
|
39
|
-
if (e.type !== 'assistant' && e.type !== 'user') return [];
|
|
40
|
-
const content = e.message.content;
|
|
41
|
-
if (typeof content === 'string') return [{ kind: 'text', body: content }];
|
|
42
|
-
return content.flatMap((c) => (c.type === 'text' ? [{ kind: 'text', body: c.text }]
|
|
43
|
-
: c.type === 'tool_use' ? [{ kind: 'tool', label: c.name.toLowerCase(), body: callOf(c.input) }]
|
|
44
|
-
: c.type === 'tool_result' ? [{ kind: 'out', body: short(textOf(c.content)) }] : []));
|
|
45
|
-
}
|
|
46
|
-
const rowHtml = (r) => `<div class="reader-row ${r.kind}">${r.label ? `<span class="reader-tag">${esc(r.label)}</span>` : ''}${esc(r.body)}</div>`;
|
|
47
31
|
|
|
48
32
|
const atEnd = () => body.scrollHeight - body.scrollTop - body.clientHeight < 40;
|
|
49
33
|
const toEnd = () => { body.scrollTop = body.scrollHeight; };
|
|
@@ -57,11 +41,11 @@ BoardView.part((B) => {
|
|
|
57
41
|
if (log?.unseen) more.textContent = `${log.unseen} new ${log.unseen === 1 ? 'line' : 'lines'} ↓`;
|
|
58
42
|
}
|
|
59
43
|
function drawLog() {
|
|
60
|
-
const {
|
|
61
|
-
body.innerHTML = !
|
|
62
|
-
: !stream ? `<pre class="reader-text">${esc(
|
|
63
|
-
: raw ? `<pre class="reader-raw">${esc(text)}</pre>`
|
|
64
|
-
:
|
|
44
|
+
const { rows, stream, raw, text } = log;
|
|
45
|
+
body.innerHTML = !rows.length ? '<p class="muted reader-empty">The log is empty so far.</p>'
|
|
46
|
+
: !stream ? `<pre class="reader-text">${esc(rows.map((r) => `${r.body}\n`).join(''))}</pre>`
|
|
47
|
+
: raw ? (text === null ? '<p class="muted">Loading…</p>' : `<pre class="reader-raw">${esc(text)}</pre>`)
|
|
48
|
+
: rows.map(rowHtml).join('');
|
|
65
49
|
drawTools();
|
|
66
50
|
drawMore();
|
|
67
51
|
}
|
|
@@ -73,24 +57,44 @@ BoardView.part((B) => {
|
|
|
73
57
|
drawMore();
|
|
74
58
|
}
|
|
75
59
|
|
|
60
|
+
// Reads the transcript on from where the last read ended; an output joins its call's line.
|
|
76
61
|
async function readLog(n) {
|
|
77
62
|
let answer;
|
|
78
63
|
try {
|
|
79
|
-
const r = await fetch(B.site.url(`api/
|
|
64
|
+
const r = await fetch(B.site.url(`api/transcript?file=${encodeURIComponent(log.file)}&from=${log.next}`), { cache: 'no-store' });
|
|
80
65
|
answer = { ok: r.ok, ...(await r.json()) };
|
|
81
66
|
} catch { answer = null; }
|
|
82
67
|
if (n !== opened) return;
|
|
83
68
|
if (answer && !answer.ok) { body.innerHTML = `<p class="muted">${esc(answer.error || 'Could not load this log.')}</p>`; return; }
|
|
84
|
-
if (answer && answer.
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
69
|
+
if (answer && (!log.read || answer.next !== log.next)) {
|
|
70
|
+
let added = 0;
|
|
71
|
+
for (const row of answer.rows) {
|
|
72
|
+
if (row.for) { log.calls.get(row.for).out = row.body; continue; }
|
|
73
|
+
if (row.id) log.calls.set(row.id, row);
|
|
74
|
+
log.rows.push(row);
|
|
75
|
+
added++;
|
|
76
|
+
}
|
|
77
|
+
Object.assign(log, { read: true, next: answer.next, stream: log.stream || answer.stream, finished: log.finished || answer.finished });
|
|
78
|
+
if (!log.following) log.unseen += added;
|
|
89
79
|
drawLog();
|
|
90
80
|
if (log.following) toEnd();
|
|
81
|
+
if (log.raw) readRaw(n);
|
|
91
82
|
}
|
|
92
83
|
if (!log.finished) stopReading = B.later(3000, () => readLog(n));
|
|
93
84
|
}
|
|
85
|
+
// The raw toggle's file as it is: GET /api/log, its last 256 KiB.
|
|
86
|
+
async function readRaw(n) {
|
|
87
|
+
let text;
|
|
88
|
+
try {
|
|
89
|
+
const r = await fetch(B.site.url(`api/log?file=${encodeURIComponent(log.file)}`), { cache: 'no-store' });
|
|
90
|
+
const answer = await r.json();
|
|
91
|
+
text = r.ok ? answer.text : answer.error;
|
|
92
|
+
} catch { text = 'Could not load this log.'; }
|
|
93
|
+
if (n !== opened || !log.raw) return;
|
|
94
|
+
log.text = text;
|
|
95
|
+
drawLog();
|
|
96
|
+
if (log.following) toEnd();
|
|
97
|
+
}
|
|
94
98
|
|
|
95
99
|
function show(title, ref) {
|
|
96
100
|
opened++;
|
|
@@ -107,8 +111,8 @@ BoardView.part((B) => {
|
|
|
107
111
|
return opened;
|
|
108
112
|
}
|
|
109
113
|
function openLog(file) {
|
|
110
|
-
const n = show(`${file.split('-')[0]}
|
|
111
|
-
log = { file,
|
|
114
|
+
const n = show(`${file.split('-')[0]} transcript`, file);
|
|
115
|
+
log = { file, read: false, next: 0, rows: [], calls: new Map(), opened: new Set(), stream: false, finished: false, raw: false, text: null, following: true, unseen: 0 };
|
|
112
116
|
readLog(n);
|
|
113
117
|
}
|
|
114
118
|
async function openNotes(search) {
|
|
@@ -144,11 +148,17 @@ BoardView.part((B) => {
|
|
|
144
148
|
else openNotes(href.slice(href.indexOf('?')));
|
|
145
149
|
});
|
|
146
150
|
B.on(sheet, 'click', (ev) => {
|
|
147
|
-
if (ev
|
|
151
|
+
if (BoardUI.onBackdrop(ev, sheet)) { close(); return; }
|
|
148
152
|
const act = ev.target.closest('[data-reader]')?.dataset.reader;
|
|
149
153
|
if (act === 'close') close();
|
|
150
|
-
else if (act === 'raw') { log.raw = !log.raw; drawLog(); follow(); }
|
|
154
|
+
else if (act === 'raw') { log.raw = !log.raw; log.text = null; drawLog(); follow(); if (log.raw) readRaw(opened); }
|
|
151
155
|
else if (act === 'end') follow();
|
|
156
|
+
const call = ev.target.closest('[data-call]');
|
|
157
|
+
if (call) {
|
|
158
|
+
const i = Number(call.dataset.call);
|
|
159
|
+
if (!log.opened.delete(i)) log.opened.add(i);
|
|
160
|
+
drawLog();
|
|
161
|
+
}
|
|
152
162
|
const a = ev.target.closest('a[href]');
|
|
153
163
|
if (!a || !plainTap(ev) || (a.target && a.target !== '_self')) return;
|
|
154
164
|
ev.preventDefault();
|
|
@@ -144,8 +144,8 @@ BoardView.part((B) => {
|
|
|
144
144
|
const text = flag ? slashOf(ta.value).text : ta.value.trim();
|
|
145
145
|
const id = f.dataset.composer;
|
|
146
146
|
const files = BoardAttach.files(id, B.site.root);
|
|
147
|
-
// An empty box still sends Implement, the card's build action: the board server
|
|
148
|
-
//
|
|
147
|
+
// An empty box still sends Implement, the card's build action: the board server stages the card for the
|
|
148
|
+
// orchestrator's review (docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md).
|
|
149
149
|
const intent = flag ? null : button.dataset.intent || null;
|
|
150
150
|
if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
|
|
151
151
|
const key = f.dataset.reply;
|
package/board/public/app.html
CHANGED
|
@@ -22,7 +22,7 @@ window.BoardBoot = (function () {
|
|
|
22
22
|
const get = (u) => { const p = fetch(base + u, { cache: 'no-store' }).then((r) => (r.ok ? r.json().catch(() => { throw unanswered(r); }) : Promise.reject(unanswered(r)))); p.catch(() => {}); return p; };
|
|
23
23
|
let open = [];
|
|
24
24
|
try { open = JSON.parse(localStorage.getItem(`site:${route}:openItems`) || '[]'); } catch {}
|
|
25
|
-
const focus =
|
|
25
|
+
const focus = /[#&](?:selectedCard|item)=([^&]+)/.exec(location.hash);
|
|
26
26
|
if (focus) open.push(decodeURIComponent(focus[1]));
|
|
27
27
|
let since = null;
|
|
28
28
|
try { const c = JSON.parse(localStorage.getItem(`site:${route}:copyVersion`) || 'null'); if (c?.build === document.documentElement.dataset.build && Number.isInteger(c.version)) since = c.version; } catch {}
|
package/board/public/app.js
CHANGED
|
@@ -173,7 +173,7 @@ window.BoardView = (function () {
|
|
|
173
173
|
};
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
// The board itself, after every part: rendering, the
|
|
176
|
+
// The board itself, after every part: rendering, the URL fragment, loading and the change signal.
|
|
177
177
|
function start(B) {
|
|
178
178
|
const { $, $$, store, state, steady, markSeen } = B;
|
|
179
179
|
const { setHTML, preloadKeys } = BoardUI;
|
|
@@ -206,31 +206,41 @@ window.BoardView = (function () {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
// #review=<PR number> opens that review in the Auto Review lane (app-lanes.js openReview), as a held review's push does.
|
|
209
|
-
// #
|
|
209
|
+
// #selectedCard=<file>/<id> (or the older #item=) opens that card, and &openItemsCard=<topic id> opens that pill in the pane; push notifications link here, with &reply to land in the thread's reply box
|
|
210
210
|
// (docs/adr/2026-10-29-1639-a-push-lands-where-the-owner-answers.md). An ask now in a work item's thread opens that item.
|
|
211
211
|
let focusKey = null;
|
|
212
212
|
let replyKey = null;
|
|
213
213
|
let hashRead = false;
|
|
214
214
|
// A board this page left goes back to where the owner left it once it first renders, unless its URL names a card.
|
|
215
|
-
const returnToPlace = () => B.restorePlace({ linked: /^#(?:item|review)=/.test(location.hash) });
|
|
215
|
+
const returnToPlace = () => B.restorePlace({ linked: /^#(?:selectedCard|item|review)=/.test(location.hash) });
|
|
216
|
+
// The pane's pill the fragment names opens once the pane has loaded; a phone has no pane, its pill is the Waiting on
|
|
217
|
+
// you page (shell.js go).
|
|
218
|
+
function openPill(id, tries = 50) {
|
|
219
|
+
if (!id || !matchMedia('(min-width: 900px)').matches || B.focusQuestion?.(id) || tries <= 0) return;
|
|
220
|
+
B.later(100, () => openPill(id, tries - 1));
|
|
221
|
+
}
|
|
216
222
|
function readHash() {
|
|
217
|
-
const review =
|
|
218
|
-
if (review && state.board) { B.openReview(
|
|
219
|
-
|
|
220
|
-
if (!
|
|
221
|
-
focusKey =
|
|
222
|
-
|
|
223
|
-
replyKey = m[2] ? focusKey : null;
|
|
223
|
+
const { selectedCard, openItemsCard, review, reply } = AppRouter.fragmentOf(location.hash);
|
|
224
|
+
if (review && state.board) { B.openReview(review); return; }
|
|
225
|
+
openPill(openItemsCard);
|
|
226
|
+
if (!selectedCard) return;
|
|
227
|
+
focusKey = BoardUI.record(state.board?.moved, selectedCard) || selectedCard;
|
|
228
|
+
replyKey = reply ? focusKey : null;
|
|
224
229
|
state.openItems.add(focusKey);
|
|
225
230
|
state.restored.delete(focusKey);
|
|
226
231
|
const it = state.board?.items.find((i) => i.key === focusKey);
|
|
227
232
|
if (it) { const s = store.get('lanes', {}); s[it.lane] = true; store.set('lanes', s); B.openBand({ key: focusKey }); }
|
|
228
233
|
if (it?.fold) store.set(B.FOLDS[it.fold].store, true);
|
|
229
234
|
}
|
|
235
|
+
// The owner's own moves rewrite the fragment in place, with no history entry and no hashchange.
|
|
236
|
+
B.saveFragment = (changes) => {
|
|
237
|
+
const hash = AppRouter.hashWith(location.hash, changes);
|
|
238
|
+
if (hash !== location.hash) history.replaceState(history.state, '', location.pathname + location.search + hash);
|
|
239
|
+
};
|
|
230
240
|
B.on(window, 'hashchange', () => { readHash(); if (state.board) { render(); scrollToFocus(); } });
|
|
231
241
|
// A card link whose hash is already the page's fires no hashchange, so its tap opens the card here.
|
|
232
242
|
B.on(B.root, 'click', (ev) => {
|
|
233
|
-
const a = ev.target.closest?.('a[href^="#item="]');
|
|
243
|
+
const a = ev.target.closest?.('a[href^="#item="], a[href^="#selectedCard="]');
|
|
234
244
|
if (!a || ev.defaultPrevented || a.getAttribute('href') !== location.hash || !state.board) return;
|
|
235
245
|
ev.preventDefault();
|
|
236
246
|
readHash(); render(); scrollToFocus();
|
|
@@ -239,9 +249,9 @@ window.BoardView = (function () {
|
|
|
239
249
|
function focusOnBoard({ key, slug }) {
|
|
240
250
|
const band = slug && state.board?.waveBands?.find((b) => b.slug === slug);
|
|
241
251
|
if (!band && !state.board?.items.some((i) => i.key === key)) return false;
|
|
242
|
-
if (key) { location.hash =
|
|
252
|
+
if (key) { location.hash = AppRouter.hashWith(location.hash, { selectedCard: key }); readHash(); } else B.openBand({ slug });
|
|
243
253
|
render();
|
|
244
|
-
if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
254
|
+
if (key) scrollToFocus(); else $(`section.wb[data-lane="todo"][data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
|
|
245
255
|
return true;
|
|
246
256
|
}
|
|
247
257
|
// A card missing from the last visit's board waits for the fresh one, which is final.
|
package/board/public/dialogs.js
CHANGED
|
@@ -348,7 +348,7 @@
|
|
|
348
348
|
} catch (e) { sending = false; fail(e); sync(); }
|
|
349
349
|
});
|
|
350
350
|
el.addEventListener('cancel', (e) => { e.preventDefault(); done(); });
|
|
351
|
-
el.addEventListener('click', (e) => { if (e
|
|
351
|
+
el.addEventListener('click', (e) => { if (BoardUI.onBackdrop(e, el)) done(); });
|
|
352
352
|
el.addEventListener('close', () => {
|
|
353
353
|
if (closed) return;
|
|
354
354
|
closed = true;
|
package/board/public/hub-kit.js
CHANGED
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
if (reset) input.value = input.defaultValue = localInput(new Date(now + 3_600_000));
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
-
// The one duration picker Snooze,
|
|
184
|
+
// The one duration picker Snooze, Pause hub and Pause board share (hub/duration.mjs): chips that post
|
|
185
185
|
// `name` to `action` at once, then Custom, whose date and time posts as `name` too. While its setting is on, `on`
|
|
186
186
|
// replaces it with when it ends and a button that posts `name`=off.
|
|
187
187
|
function durationPicker({ id, title, icon, action, name, choices, on, button }) {
|
package/board/public/hub.css
CHANGED
|
@@ -95,6 +95,8 @@
|
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
.hub-view .hub-settings { margin-top: 24px; }
|
|
98
|
+
.hub-view .hub-inline > div + div { margin-top: 16px; }
|
|
99
|
+
.hub-view .hub-inline + .label { margin-top: 24px; }
|
|
98
100
|
.hub-view .hub-setting { width: 100%; display: flex; align-items: center; gap: 12px; padding: 14px 16px; font: inherit; text-align: left; color: var(--text); cursor: pointer; border: 1px solid var(--line); border-radius: 12px; margin: 10px 0; background: var(--panel); }
|
|
99
101
|
.hub-view .hub-setting .text { flex: 1; min-width: 0; }
|
|
100
102
|
.hub-view .hub-setting .title { font-weight: 600; }
|
|
@@ -136,7 +136,7 @@
|
|
|
136
136
|
}
|
|
137
137
|
dialog.addEventListener('click', (e) => {
|
|
138
138
|
if (e.target.closest?.('[data-lk-done]')) { close(); return; }
|
|
139
|
-
if (e
|
|
139
|
+
if (!BoardUI.onBackdrop(e, dialog)) return;
|
|
140
140
|
const r = dialog.getBoundingClientRect();
|
|
141
141
|
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) close();
|
|
142
142
|
});
|
package/board/public/new-task.js
CHANGED
|
@@ -75,7 +75,7 @@ globalThis.BoardNewTask = (function () {
|
|
|
75
75
|
B.newTaskKey = newTaskKey;
|
|
76
76
|
B.on(document, 'keydown', newTaskKey);
|
|
77
77
|
B.on($('#sheetClose'), 'click', closeSheet);
|
|
78
|
-
B.on(sheet, 'click', (ev) => { if (ev
|
|
78
|
+
B.on(sheet, 'click', (ev) => { if (BoardUI.onBackdrop(ev, sheet)) closeSheet(); });
|
|
79
79
|
B.on(sheet, 'cancel', (ev) => { ev.preventDefault(); closeSheet(); });
|
|
80
80
|
// Discuss has the session grill or prototype the task at once; Implement sends the ask with quick: true, and the
|
|
81
81
|
// session marks the card ready (docs/adr/2026-11-04-0131-every-new-task-is-grilled-or-prototyped-at-once.md).
|
|
@@ -15,10 +15,10 @@
|
|
|
15
15
|
const { BUILD_PHASES, START_STEPS } = BoardUI;
|
|
16
16
|
const build = () => [...BUILD_PHASES];
|
|
17
17
|
return [
|
|
18
|
-
{ id: 'grill', name: 'Grilling', lane: 'todo', when: 'A grill planner reads a Todo card and asks its questions', steps:
|
|
19
|
-
card: { builder: { planner: true, kind: 'grill', progress: 'Grilling · the notes', phase: null, at: ago(2) } } },
|
|
20
|
-
{ id: 'prototype', name: 'Prototyping', lane: 'todo', when: 'A prototype planner draws a Todo card’s takes', steps:
|
|
21
|
-
card: { builder: { planner: true, kind: 'prototype', progress: 'Prototyping · the takes', phase: null, at: ago(2) } } },
|
|
18
|
+
{ id: 'grill', name: 'Grilling', lane: 'todo', when: 'A grill planner reads a Todo card and asks its questions', steps: [],
|
|
19
|
+
card: { phase: 'grilling', builder: { planner: true, kind: 'grill', progress: 'Grilling · the notes', phase: null, at: ago(2) } } },
|
|
20
|
+
{ id: 'prototype', name: 'Prototyping', lane: 'todo', when: 'A prototype planner draws a Todo card’s takes', steps: [],
|
|
21
|
+
card: { phase: 'prototyping', builder: { planner: true, kind: 'prototype', progress: 'Prototyping · the takes', phase: null, at: ago(2) } } },
|
|
22
22
|
{ id: 'build', name: 'Build', lane: 'progress', when: 'A builder works the card, step by step', steps: build(),
|
|
23
23
|
card: { builder: { progress: 'Testing · the suite', phase: 'testing', at: ago(2) } } },
|
|
24
24
|
{ id: 'check', name: 'Orchestrator review', lane: 'progress', when: 'The orchestrator checks the builder’s calls, on step 4', steps: build(),
|