@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.
Files changed (76) hide show
  1. package/CONTEXT.md +60 -48
  2. package/SKILL.md +20 -14
  3. package/bin/event-lines.mjs +1 -0
  4. package/bin/kickflip.mjs +8 -8
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -2
  7. package/board/arch-review.mjs +4 -4
  8. package/board/bin/board.mjs +118 -104
  9. package/board/domain.mjs +7 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +8 -9
  12. package/board/hub-lines.mjs +1 -0
  13. package/board/onboarding.mjs +2 -2
  14. package/board/prototype-page.mjs +1 -1
  15. package/board/public/app-arch.js +3 -3
  16. package/board/public/app-banner.js +1 -1
  17. package/board/public/app-cards.js +17 -9
  18. package/board/public/app-header.js +1 -1
  19. package/board/public/app-lanes.js +39 -61
  20. package/board/public/app-needs.js +116 -30
  21. package/board/public/app-reader.js +54 -44
  22. package/board/public/app-threads.js +2 -2
  23. package/board/public/app.html +1 -1
  24. package/board/public/app.js +23 -13
  25. package/board/public/dialogs.js +1 -1
  26. package/board/public/hub-kit.js +1 -1
  27. package/board/public/hub.css +2 -0
  28. package/board/public/linear-key.js +1 -1
  29. package/board/public/new-task.js +1 -1
  30. package/board/public/progress-review.js +4 -4
  31. package/board/public/prototype-kit.js +27 -16
  32. package/board/public/prototype-switcher.js +47 -32
  33. package/board/public/prototype.html +7 -4
  34. package/board/public/router.js +35 -7
  35. package/board/public/shell.js +19 -13
  36. package/board/public/sidebar.css +4 -4
  37. package/board/public/sidebar.js +2 -2
  38. package/board/public/style.css +74 -36
  39. package/board/public/ui-cards.js +56 -37
  40. package/board/public/ui-enums.js +10 -1
  41. package/board/public/ui-hub.js +1 -5
  42. package/board/public/ui-links.js +6 -6
  43. package/board/public/ui-order.js +8 -6
  44. package/board/public/ui-progress-bar.js +6 -7
  45. package/board/public/ui-questions.js +30 -13
  46. package/board/public/ui-sidebar.js +3 -2
  47. package/board/public/ui-threads.js +20 -19
  48. package/board/public/ui.js +15 -4
  49. package/board/public/view-hub-settings.js +28 -31
  50. package/board/public/view-hub.js +7 -9
  51. package/board/public/view-notes.js +2 -2
  52. package/board/public/view-questions.js +35 -6
  53. package/board/public/view-settings.js +63 -31
  54. package/board/render-check.mjs +10 -10
  55. package/board/review.mjs +1 -1
  56. package/board/server.mjs +18 -20
  57. package/board/stop.mjs +1 -1
  58. package/board/store.mjs +26 -25
  59. package/board/tickets.mjs +1 -1
  60. package/board/transcript.mjs +4 -2
  61. package/hooks-plugin/hooks/board-data-gate.mjs +6 -6
  62. package/hub/awake.mjs +1 -1
  63. package/hub/boards.mjs +4 -4
  64. package/hub/github.mjs +3 -3
  65. package/hub/home.mjs +1 -1
  66. package/hub/hub.mjs +11 -11
  67. package/hub/incoming.mjs +2 -2
  68. package/hub/locks.mjs +1 -1
  69. package/hub/new-board.mjs +2 -2
  70. package/hub/owner-checks.mjs +1 -1
  71. package/hub/try.mjs +6 -7
  72. package/hub/upgrade.mjs +1 -1
  73. package/hub/views.mjs +1 -1
  74. package/package.json +1 -1
  75. package/shared/tailscale.mjs +1 -1
  76. 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 options drafting its answer on the tap
5
- // (the draft Send on the questions page sends) and a prompt's buttons answering it, Skip to the next, a dot for each thing waiting, and All open questions at its foot;
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's double-click focuses its card on the board
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
- const questionHtml = (q) => {
84
- const picked = getDraft(q.id).choice;
85
- return `<h3>${escText(q.question)}</h3>${q.context ? `<p class="ctx">${escText(q.context)}</p>` : ''}
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((o) => `<button type="button" class="${o.recommended ? 'rec' : ''}" aria-pressed="${o.id === picked}" data-deck-pick="${esc(o.id)}"><span class="lbl">${esc(choiceLabel(q, o.id))}${o.recommended ? ' <span class="rec-word">recommended</span>' : ''}</span>${o.detail ? `<small>${escText(o.detail)}</small>` : ''}</button>`).join('')}</div>`;
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' && 'choice' in getDraft(q.id)).length;
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-skip>Skip →</button></div>` : ''}
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
- if (ev.target.closest('[data-deck-skip]')) {
112
- const at = deck.findIndex((x) => x.id === showing);
113
- showing = deck[(at + 1) % deck.length].id;
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
- setDraft(id, { ...getDraft(id), choice: opt.dataset.deckPick });
121
- const at = deck.findIndex((x) => x.id === showing);
122
- showing = deck[(at + 1) % deck.length].id;
123
- renderNeeds(state.board);
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
- // A double-click on a pane pill focuses its topic on the board: item:<key> is a card (an ask is one, ask:<id>),
149
- // wave:<slug> a band; any other topic, or one the board doesn't show, does nothing.
150
- B.on(B.root, 'dblclick', (ev) => {
151
- const pill = questions && ev.target.closest?.('button[data-topic]');
152
- if (!pill || !questions.slot.contains(pill)) return;
153
- const id = pill.dataset.topic;
154
- if (id.startsWith('item:')) B.focusOnBoard({ key: id.slice(5) });
155
- else if (id.startsWith('wave:')) B.focusOnBoard({ slug: id.slice(5) });
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 "Open the log"
2
- // (ui-threads.js) and a card's or a lineup's Notes (app-cards.js, app-lanes.js) open over the board in one sheet in
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 lines, what Claude says and each
7
- // tool call, with a raw toggle for the file as it is; any other log reads as its text. It is re-read every 3 seconds
8
- // until the run's result arrives, and follows new lines while read at its end.
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
- // → the log's lines, each a stream event or, for a line that isn't one, its text; null when no line is an event.
23
- function streamOf(text) {
24
- const lines = text.split('\n');
25
- if (lines.at(-1) === '') lines.pop();
26
- const read = (line) => { try { const e = JSON.parse(line); return typeof e?.type === 'string' ? e : null; } catch { return null; } };
27
- const events = lines.map(read);
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 { text, stream, raw } = log;
61
- body.innerHTML = !text ? '<p class="muted reader-empty">The log is empty so far.</p>'
62
- : !stream ? `<pre class="reader-text">${esc(text)}</pre>`
63
- : raw ? `<pre class="reader-raw">${esc(text)}</pre>`
64
- : stream.flatMap(rowsOf).map(rowHtml).join('');
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/log?file=${encodeURIComponent(log.file)}`), { cache: 'no-store' });
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.size !== log.size) {
85
- const before = log.stream?.length ?? 0;
86
- const stream = streamOf(answer.text);
87
- Object.assign(log, { size: answer.size, text: answer.text, stream, finished: Boolean(stream?.some((e) => e.type === 'result')) });
88
- if (!log.following) log.unseen += Math.max(0, (stream?.length ?? 0) - before);
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]} log`, file);
111
- log = { file, size: -1, text: '', stream: null, raw: false, following: true, unseen: 0, finished: false };
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.target === sheet) { close(); return; }
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 marks the card ready as it is and
148
- // starts the build at once (docs/adr/2026-11-06-0021-a-button-without-a-text-box-posts-a-keyed-status-line.md).
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;
@@ -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 = /^#item=([^&]+)/.exec(location.hash);
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 {}
@@ -173,7 +173,7 @@ window.BoardView = (function () {
173
173
  };
174
174
  }
175
175
 
176
- // The board itself, after every part: rendering, the #item= hash, loading and the change signal.
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
- // #item=<wave>/<id> opens that card; push notifications link here, with &reply to land in the thread's reply box
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 = /^#review=(\d+)$/.exec(location.hash);
218
- if (review && state.board) { B.openReview(Number(review[1])); return; }
219
- const m = /^#item=([^&]+)(&reply)?$/.exec(location.hash);
220
- if (!m) return;
221
- focusKey = decodeURIComponent(m[1]);
222
- focusKey = BoardUI.record(state.board?.moved, focusKey) || focusKey;
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 = `#item=${encodeURIComponent(key)}`; readHash(); } else B.openBand({ slug });
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.
@@ -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.target === el) done(); });
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;
@@ -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, Keep awake, Pause hub and Pause board share (hub/duration.mjs): chips that post
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 }) {
@@ -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.target !== dialog) return;
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
  });
@@ -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.target === sheet) closeSheet(); });
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: build(),
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: build(),
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(),