@lovek4d/kickflip 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CONTEXT.md +42 -26
  2. package/SKILL.md +16 -12
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/event-lines.mjs +1 -0
  5. package/bin/install.mjs +3 -3
  6. package/bin/kickflip.mjs +7 -7
  7. package/bin/wait-events.mjs +2 -2
  8. package/board/agents.mjs +2 -2
  9. package/board/arch-review.mjs +1 -1
  10. package/board/bin/board.mjs +70 -42
  11. package/board/domain.mjs +7 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +8 -9
  14. package/board/onboarding.mjs +1 -1
  15. package/board/prototype-page.mjs +1 -1
  16. package/board/public/app-arch.js +2 -2
  17. package/board/public/app-banner.js +1 -1
  18. package/board/public/app-cards.js +36 -48
  19. package/board/public/app-lanes.js +6 -9
  20. package/board/public/app-needs.js +107 -26
  21. package/board/public/app-reader.js +52 -42
  22. package/board/public/app-threads.js +14 -10
  23. package/board/public/app.html +1 -1
  24. package/board/public/app.js +22 -12
  25. package/board/public/progress-review.js +4 -4
  26. package/board/public/prototype-kit.js +25 -11
  27. package/board/public/prototype-switcher.js +53 -33
  28. package/board/public/prototype.html +7 -4
  29. package/board/public/router.js +34 -6
  30. package/board/public/shell.js +23 -16
  31. package/board/public/sidebar.css +7 -2
  32. package/board/public/sidebar.js +3 -0
  33. package/board/public/style.css +69 -30
  34. package/board/public/ui-cards.js +61 -38
  35. package/board/public/ui-enums.js +12 -3
  36. package/board/public/ui-header.js +4 -6
  37. package/board/public/ui-hub.js +7 -4
  38. package/board/public/ui-links.js +1 -1
  39. package/board/public/ui-order.js +9 -6
  40. package/board/public/ui-page-header.js +1 -1
  41. package/board/public/ui-progress-bar.js +6 -7
  42. package/board/public/ui-questions.js +18 -3
  43. package/board/public/ui-sidebar.js +12 -5
  44. package/board/public/ui-snapshot.js +1 -1
  45. package/board/public/ui-threads.js +10 -12
  46. package/board/public/ui.js +7 -4
  47. package/board/public/view-hub.js +3 -1
  48. package/board/public/view-notes.js +1 -1
  49. package/board/public/view-questions.js +19 -3
  50. package/board/public/view-settings.js +60 -28
  51. package/board/server.mjs +19 -19
  52. package/board/stop.mjs +1 -1
  53. package/board/store.mjs +27 -25
  54. package/board/tickets.mjs +1 -1
  55. package/board/transcript.mjs +4 -2
  56. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  57. package/hub/front-door.mjs +1 -1
  58. package/hub/home.mjs +1 -1
  59. package/hub/hub.mjs +6 -6
  60. package/hub/sessions.mjs +2 -2
  61. package/hub/try.mjs +7 -7
  62. package/hub/upgrade.mjs +1 -1
  63. package/hub/usage.mjs +3 -3
  64. package/package.json +1 -1
  65. package/shared/card-prs.mjs +1 -1
  66. package/shared/machine-gate.mjs +2 -2
  67. package/shared/tailscale.mjs +1 -1
  68. package/shared/usage-gate.mjs +1 -1
@@ -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"
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
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
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) {
@@ -147,8 +151,14 @@ BoardView.part((B) => {
147
151
  if (ev.target === 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();
@@ -1,7 +1,7 @@
1
1
  // A card's thread on the board view: loading and rendering it, and its reply box. Needs app-core.js.
2
2
  BoardView.part((B) => {
3
3
  const { $, store, state, quieted, sendDraft, links } = B;
4
- const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine } = BoardUI;
4
+ const { esc, setHTML, threadMessages, dropZone, sendButton, protoButton, grillButton, visualButton, attachButton, doneButton, doneTone, waitOn, newQuestionsLine, BUTTON } = BoardUI;
5
5
 
6
6
  const REQUEST_EVENT = { prototype: 'prototype-request', grill: 'grill-more', visual: 'visual-request' };
7
7
  // A /prototype, /grill or /visual word in a message, the last one winning, and the message without it.
@@ -16,14 +16,18 @@ BoardView.part((B) => {
16
16
  // A Todo card's reply box sends with Reply (intent: 'reply') or Implement (intent: 'build'), which is the card's build
17
17
  // action: builder orange once the card is ready, quiet before. An In Review card's box has Done then Reply
18
18
  // (ui-threads.js doneButton), Done in its tone. Any other card's box sends with Send, carrying no intent. A pair wraps
19
- // onto its own line under the row.
20
- const REVIEW = new Set(['review', 'stale']);
21
- const sendsOf = (item) => (item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
22
- : REVIEW.has(item?.lane) ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one');
19
+ // onto its own line under the row. Send or Reply is orange while a message clears the card's Action needed, as on a
20
+ // card the orchestrator stalled (docs/adr/2026-11-06-1252-stale-is-automatic-stalled-is-the-orchestrators-flag.md).
21
+ const replyTone = (item) => (item?.resolvedBy === BUTTON.reply ? 'resolves' : undefined);
22
+ const sendsOf = (item) => [item?.lane === 'todo' ? (item.ready ? 'todo-ready' : 'todo')
23
+ : item?.lane === 'review' ? ['review', doneTone(item)].filter(Boolean).join(' ') : 'one', replyTone(item) && 'reply-resolves'].filter(Boolean).join(' ');
23
24
  const pair = (kind, html) => `<span class="send-actions" data-sends="${kind}">${html}</span>`;
24
- const sends = (kind, item) => (kind === 'one' ? `<span data-sends="one">${sendButton('Send', 0)}</span>`
25
- : kind.startsWith('review') ? pair(kind, `${doneButton(item)}${sendButton('Reply', 0, ' data-intent="reply"')}`)
26
- : pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${sendButton('Reply', 0, ' data-intent="reply"')}`));
25
+ const sends = (kind, item) => {
26
+ const reply = (label, attrs = '') => sendButton(label, 0, attrs, replyTone(item));
27
+ return kind.startsWith('one') ? `<span data-sends="${kind}">${reply('Send')}</span>`
28
+ : kind.startsWith('review') ? pair(kind, `${doneButton(item)}${reply('Reply', ' data-intent="reply"')}`)
29
+ : pair(kind, `${sendButton('Implement', 1, ` class="build${kind === 'todo-ready' ? ' ready' : ''}" data-intent="build"`)}${reply('Reply', ' data-intent="reply"')}`);
30
+ };
27
31
  // The board, not the thread, decides the send buttons, so they follow each board load even when the thread is unchanged.
28
32
  function syncSends(el, key) {
29
33
  const at = el.querySelector('form [data-sends]');
@@ -140,8 +144,8 @@ BoardView.part((B) => {
140
144
  const text = flag ? slashOf(ta.value).text : ta.value.trim();
141
145
  const id = f.dataset.composer;
142
146
  const files = BoardAttach.files(id, B.site.root);
143
- // An empty box still sends Implement, the card's build action: the board server marks the card ready as it is and
144
- // 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).
145
149
  const intent = flag ? null : button.dataset.intent || null;
146
150
  if ((!flag && !text && !files.length && intent !== 'build') || BoardAttach.busy(id, B.site.root)) return;
147
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=<wave>/<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,7 +249,7 @@ 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
254
  if (key) scrollToFocus(); else $(`section.wb[data-band="${CSS.escape(slug)}"]`)?.scrollIntoView({ block: 'start' });
245
255
  return true;
@@ -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(),
@@ -205,25 +205,39 @@
205
205
  const stage = document.getElementById('stage');
206
206
  let at = { take: null, state: null };
207
207
  let frame = null;
208
+ // The run the frame shows: its since from api/try/prototypes.
209
+ let framed = null;
208
210
  let origin = null;
209
211
  let started = false;
210
212
  const serve = () => fetch(site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'prototypes' }) }).catch(() => {});
211
213
  const post = () => frame.contentWindow.postMessage({ kickflip: 'prototype', ...at }, origin);
214
+ // Polls the serve while the page is open (docs/adr/2026-11-07-1231-a-base-move-restarts-the-prototype-serve-and-a-demo-hub-never-restarts-itself.md):
215
+ // a ready run the frame doesn't show, the first or a new one after a restart or a base move, gets a new frame at the
216
+ // current take and state; a serve that's down shows Starting. A poll that can't reach the board leaves the stage.
212
217
  async function poll() {
213
- let run = {};
214
- try { run = await (await fetch(site.url('api/try/prototypes'), { cache: 'no-store' })).json(); } catch { /* the board is out of reach; ask again */ }
218
+ let run;
219
+ try { run = await (await fetch(site.url('api/try/prototypes'), { cache: 'no-store' })).json(); } catch {
220
+ if (!frame) stage.innerHTML = servedStatus({});
221
+ wait(poll, 1500);
222
+ return;
223
+ }
215
224
  if (run.state === 'ready' && run.https) {
216
- const src = servedSrc({ https: run.https, path: data.served, ...at });
217
- origin = new URL(src).origin;
218
- frame = document.createElement('iframe');
219
- frame.className = 'kit-served';
220
- frame.title = data.title;
221
- frame.src = src;
222
- // A switch made while the page loaded reaches it once it has.
223
- nativeAdd.call(frame, 'load', post);
224
- stage.replaceChildren(frame);
225
+ if (!frame || (run.since ?? null) !== framed) {
226
+ const src = servedSrc({ https: run.https, path: data.served, ...at });
227
+ origin = new URL(src).origin;
228
+ framed = run.since ?? null;
229
+ frame = document.createElement('iframe');
230
+ frame.className = 'kit-served';
231
+ frame.title = data.title;
232
+ frame.src = src;
233
+ // A switch made while the page loaded reaches it once it has.
234
+ nativeAdd.call(frame, 'load', post);
235
+ stage.replaceChildren(frame);
236
+ }
237
+ wait(poll, 3000);
225
238
  return;
226
239
  }
240
+ frame = null;
227
241
  stage.innerHTML = servedStatus(run);
228
242
  // A serve that's down is started once from here; a failed one waits for Try again.
229
243
  if (!run.state && !started) { started = true; serve(); }
@@ -1,13 +1,15 @@
1
- // The full-bleed prototype viewer's floating controls (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
1
+ // The full-bleed prototype viewer's controls (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
2
2
  // docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md,
3
- // docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md).
3
+ // docs/adr/2026-11-07-0300-prototypes-have-their-own-button-and-viewer.md,
4
+ // docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md).
4
5
  // Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
5
6
  // const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, states: { empty: 'No cards', … }?, initial: 'A', render(take, state) {…}, above: el? })
6
7
  // render runs at mount and on each switch of take or state; sw.take is the current key. A served round's named states
7
8
  // (docs/adr/2026-11-06-1500-a-prototype-round-is-a-page-in-the-boards-base-worktree.md) are a row of chips above the
8
9
  // pager, the state kept in ?state=, else the first. The page's header is hidden: a ✕ at the top
9
- // right goes back as its back arrow does, and the bottom bar holds the ‹ take › pager. ←/→ and a sideways swipe switch
10
- // unless a field has focus, and the take stays in ?variant=. With ?q=<owner question id>, the bar's second row offers
10
+ // right goes back as its back arrow does, and the footer, full width on the bottom edge at every width, holds the
11
+ // ‹ take › pager. Framed in the board's right pane, the pane's top bar takes the ✕'s and the Round pill's place. ←/→
12
+ // and a sideways swipe switch unless a field has focus, and the take stays in ?variant=. With ?q=<owner question id>, the bar's second row offers
11
13
  // the take as that question's pick: a take is pickable when an option's id equals its key. Choose this drafts it, with
12
14
  // the note typed above the bar, in the {choice, note} questions.html keeps for that question, and goes back to the
13
15
  // question, whose Send sends it. A card with more than one prototype round gets a Round pill at the top left, which
@@ -65,20 +67,6 @@
65
67
  return `${chips}<div class="ps-row"><button type="button" data-ps="prev" aria-label="Previous take">‹</button><span class="ps-name" title="${esc(name)}">${esc(name)}</span><button type="button" data-ps="next" aria-label="Next take">›</button></div>${control ? `<div class="ps-row ps-act">${control}</div>` : ''}`;
66
68
  }
67
69
 
68
- // rounds: the card's prototype questions, oldest round first, as /api/board's card prototypes.rounds lists them
69
- // ({n, q, url, confirm, state}). The pill names the round qid is in, out of the newest round's number; a card with a
70
- // single round gets none.
71
- function roundPill(rounds, qid) {
72
- const at = (rounds || []).find((r) => r.q === qid);
73
- if (!at || rounds.length < 2) return '';
74
- return `<button type="button" class="ps-chip" data-ps-top="rounds">Round ${at.n} of ${Math.max(...rounds.map((r) => r.n))} · earlier</button>`;
75
- }
76
- const ROUND_WORD = { open: 'open', requested: 'prototype requested' };
77
- function roundSheet({ ref, rounds, qid }) {
78
- const rows = [...rounds].reverse().map((r) => `<button type="button" data-ps-round="${esc(r.url)}"><span>Round ${r.n}${r.confirm ? ' confirm' : ''}${r.q === qid ? ' · showing' : ''}</span><span>${ROUND_WORD[r.state] || 'answered'}</span></button>`);
79
- return `<div class="ps-sheet" role="dialog" aria-label="Rounds"><h3>Rounds on ${esc(ref)}</h3>${rows.join('')}</div>`;
80
- }
81
-
82
70
  // A touch from start to end: 1 for a swipe left (the next take), -1 for a swipe right, 0 for anything else: under
83
71
  // 50px sideways, more down than across, or starting at the left edge, where the drawer's and the browser's swipes are.
84
72
  const swipeStep = (start, end) => {
@@ -88,10 +76,13 @@
88
76
 
89
77
  const CSS = `
90
78
  body > header.top { display:none; }
79
+ /* style.css sets --app-bottom on a phone for the bar the header becomes; hidden here, it leaves nothing to clear. */
80
+ :root { --app-bottom:0px !important; }
91
81
  main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
92
82
  .ps-top { position:fixed; left:10px; right:10px; top:calc(10px + env(safe-area-inset-top)); z-index:31; display:flex; justify-content:space-between;
93
83
  align-items:flex-start; pointer-events:none; font:600 13px -apple-system, system-ui, sans-serif; }
94
84
  .ps-top > * { pointer-events:auto; }
85
+ .ps-pending { visibility:hidden; }
95
86
  .ps-chip, .ps-close { color:#fff; background:#000a; border:1px solid #fff4; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:inherit; cursor:pointer; }
96
87
  .ps-chip { border-radius:999px; padding:0 14px; min-height:40px; }
97
88
  .ps-close { margin-left:auto; width:40px; height:40px; border-radius:50%; font-size:17px; line-height:1; }
@@ -101,11 +92,10 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
101
92
  .ps-sheet button { display:flex; justify-content:space-between; gap:12px; width:100%; border:0; border-top:1px solid #fff2; background:transparent; color:#fff;
102
93
  padding:12px 2px; font:inherit; font-weight:500; text-align:left; cursor:pointer; }
103
94
  .ps-sheet button span + span { opacity:.7; }
104
- .ps-switch { position:fixed; left:10px; right:10px; bottom:calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom)); z-index:30;
105
- display:flex; flex-direction:column; align-items:center; gap:6px; pointer-events:none; }
106
- .ps-switch > * { pointer-events:auto; max-width:100%; min-width:0; box-sizing:border-box; }
107
- .ps-pill { display:flex; flex-direction:column; gap:6px; width:min(460px, 100%); background:#202026eb; border:1px solid #fff3; border-radius:18px; padding:6px;
108
- box-shadow:0 6px 24px #0008; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:600 13px -apple-system, system-ui, sans-serif; }
95
+ .ps-switch { position:fixed; left:0; right:0; bottom:var(--app-bottom, 0px); z-index:30; display:flex; flex-direction:column; align-items:stretch;
96
+ background:#202026; border-top:1px solid #fff3; padding:6px 6px calc(6px + env(safe-area-inset-bottom)); box-sizing:border-box; }
97
+ .ps-switch > * { min-width:0; box-sizing:border-box; }
98
+ .ps-pill { display:flex; flex-direction:column; gap:6px; border-radius:0; font:600 13px -apple-system, system-ui, sans-serif; }
109
99
  .ps-row { display:flex; align-items:center; gap:2px; min-width:0; }
110
100
  .ps-pill button { color:#fff; background:transparent; border:0; border-radius:999px; padding:7px 11px; font:inherit; white-space:nowrap; cursor:pointer; }
111
101
  .ps-pill button { flex-shrink:0; min-width:44px; min-height:44px; }
@@ -115,14 +105,23 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
115
105
  .ps-pill .ps-states button { background:#fff1; font-weight:500; }
116
106
  .ps-pill .ps-states button[aria-pressed="true"] { background:#fff; color:#000; font-weight:700; }
117
107
  .ps-pill .ps-pick { background:var(--accent, #fff); color:var(--on-accent, #000); font-weight:700; }
118
- .ps-closed { padding:12px 11px; border-radius:999px; color:#fffa; background:#fff1; white-space:nowrap; }
119
- .ps-switch .ps-text { width:min(420px, 100%); resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
120
- padding:7px 10px; font:400 16px -apple-system, system-ui, sans-serif; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
108
+ .ps-closed { box-sizing:border-box; min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 11px; border-radius:999px; color:#fffa; background:#fff1; white-space:nowrap; }
109
+ .ps-switch .ps-text { margin-bottom:6px; resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
110
+ padding:7px 10px; font:400 16px -apple-system, system-ui, sans-serif; }
121
111
  .ps-switch .ps-text::placeholder { color:#fffa; }
122
112
  .ps-switch .ps-text[hidden] { display:none; }
123
- .ps-picked { padding:12px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
124
- .ps-note { background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
125
- .ps-note[hidden] { display:none; }`;
113
+ .ps-picked { box-sizing:border-box; min-height:44px; display:flex; align-items:center; justify-content:center; padding:0 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
114
+ .ps-note { margin-bottom:6px; background:#000d; color:#fff; border-radius:8px; padding:5px 10px; font:500 12px -apple-system, system-ui, sans-serif; text-align:center; }
115
+ .ps-note[hidden] { display:none; }
116
+ @media (min-width: 900px) {
117
+ .ps-switch { align-items:center; }
118
+ .ps-pill { width:min(640px, 100%); }
119
+ .ps-switch .ps-text { width:min(640px, 100%); }
120
+ .ps-states { flex-wrap:wrap; overflow-x:visible; }
121
+ }
122
+ .ps-pane main.proto-main { padding-top:0; }
123
+ .ps-pane .ps-top { display:none; }
124
+ .ps-pane .ps-states { flex-wrap:wrap; overflow-x:visible; justify-content:center; }`;
126
125
 
127
126
  const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
128
127
 
@@ -133,6 +132,8 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
133
132
  const qid = params.get('q');
134
133
  const site = globalThis.ClaudeSite;
135
134
  if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
135
+ // Framed in the board's right pane (app-needs.js openViewer), whose top bar holds the way back and the Round pill.
136
+ if (window.frameElement?.closest('.needs-pane')) document.documentElement.classList.add('ps-pane');
136
137
  let take = startTake(keys, params.get('variant'), initial);
137
138
  let question = null;
138
139
  // A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
@@ -144,18 +145,23 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
144
145
  box.className = 'ps-switch';
145
146
  box.innerHTML = '<div class="ps-note" role="status" hidden></div><textarea class="ps-text" rows="2" placeholder="Note with your pick (optional)" aria-label="Note with your pick" hidden></textarea><div class="ps-pill"></div>';
146
147
  if (above) box.prepend(above);
148
+ box.classList.add('ps-pending');
147
149
  document.body.append(box);
150
+ // The footer's height, so the page above it ends where it starts (prototype.html reads --ps-footer-h).
151
+ const measure = () => document.documentElement.style.setProperty('--ps-footer-h', `${box.offsetHeight}px`);
152
+ new ResizeObserver(measure).observe(box);
148
153
  const pill = box.querySelector('.ps-pill');
149
154
  const noteEl = box.querySelector('.ps-note');
150
155
  const text = box.querySelector('.ps-text');
151
156
  // The ✕ and, on a card with earlier rounds, the Round pill and its sheet. boardCard: /api/board's card the question is on.
152
157
  const top = document.createElement('div');
153
- top.className = 'ps-top';
158
+ top.className = 'ps-top ps-pending';
154
159
  document.body.append(top);
155
160
  let boardCard = null;
156
161
  let sheet = false;
157
162
  const paintTop = () => {
158
163
  const rounds = boardCard?.prototypes?.rounds;
164
+ const { roundPill, roundSheet } = globalThis.BoardUI;
159
165
  top.innerHTML = `${roundPill(rounds, qid)}<button type="button" class="ps-close" data-ps-top="close" aria-label="Close">✕</button>${sheet && rounds ? roundSheet({ ref: boardCard.ref, rounds, qid }) : ''}`;
160
166
  };
161
167
  paintTop();
@@ -165,7 +171,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
165
171
  function paint() {
166
172
  const now = state();
167
173
  pill.innerHTML = pillHtml({ name: takes[take], pick: now, states, state: shownState });
168
- text.hidden = !(now.kind === 'pick' || now.kind === 'change');
174
+ text.hidden = !(now.kind === 'pick' || now.kind === 'change' || now.kind === 'picked');
169
175
  }
170
176
  function go(next) {
171
177
  take = next;
@@ -181,7 +187,20 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
181
187
  render(take, shownState);
182
188
  }
183
189
 
190
+ // The footer and the Round pill stay hidden until the first load settles (or 1.5s pass), so they appear complete in one paint.
191
+ let revealed = false;
192
+ const reveal = () => {
193
+ if (revealed) return;
194
+ revealed = true;
195
+ measure();
196
+ box.classList.remove('ps-pending');
197
+ top.classList.remove('ps-pending');
198
+ };
199
+ setTimeout(reveal, 1500);
184
200
  async function load() {
201
+ try { await fetchQuestion(); } finally { reveal(); }
202
+ }
203
+ async function fetchQuestion() {
185
204
  if (!qid || !site) return;
186
205
  try {
187
206
  const board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
@@ -197,7 +216,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
197
216
  } catch { /* keep the last question shown */ }
198
217
  if (fromPush && pickedChoice(question)) {
199
218
  const card = /^item:(.+)$/.exec(question.topic || '')?.[1];
200
- replaceWith(site.url(card ? `#item=${encodeURIComponent(card)}&reply` : 'questions.html'));
219
+ replaceWith(site.url(card ? `#selectedCard=${encodeURIComponent(card)}&reply` : 'questions.html'));
201
220
  return;
202
221
  }
203
222
  if (question && !text.value) text.value = readDraft(qid).note || '';
@@ -255,9 +274,10 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
255
274
  document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
256
275
 
257
276
  go(take);
277
+ measure();
258
278
  load();
259
279
  return { takes: keys, get take() { return take; }, get state() { return shownState; }, go, show };
260
280
  }
261
281
 
262
- globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, roundPill, roundSheet, swipeStep, mount };
282
+ globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, swipeStep, mount };
263
283
  })();
@@ -10,8 +10,9 @@
10
10
  <link rel="stylesheet" href="sidebar.css">
11
11
  <style>
12
12
  /* The one page every recorded prototype is (board/server.mjs fills the {{…}} slots; prototype-kit.js documents what a
13
- take gets). main keeps room below the stage for the viewer's bar and its note. */
14
- main.proto-main { max-width: none; padding-left: 0; padding-right: 0; padding-bottom: calc(150px + var(--app-bottom, 0px) + env(safe-area-inset-bottom)); }
13
+ take gets). main keeps room below the stage for the viewer's footer, at its measured height (prototype-switcher.js),
14
+ which already holds the bottom inset. */
15
+ main.proto-main { max-width: none; padding-left: 0; padding-right: 0; padding-bottom: calc(var(--ps-footer-h, 150px) + var(--app-bottom, 0px)); }
15
16
  /* kit.hub() is a whole page with its own gutter and header, so it goes edge to edge. */
16
17
  main.proto-main:has(> #stage > .kit-hub) { padding-top: 0; }
17
18
  /* contain and the transform make the stage the containing block of everything in it, position: fixed included, and
@@ -20,9 +21,11 @@
20
21
  #stage:has(> .kit-hub) { padding: 0; }
21
22
  #stage .top { position: static; }
22
23
  #stage .hub-view main { padding-bottom: 16px; }
23
- /* A served round's page, framed from the board's base prototype worktree, edge to edge above the viewer's bar. */
24
+ /* A served round's page, framed from the board's base prototype worktree, edge to edge from under the ✕ row to the
25
+ viewer's footer; in the board's right pane (.ps-pane) there is no ✕ row, so it starts at the top. */
24
26
  #stage:has(> .kit-served) { max-width: none; padding: 0; }
25
- .kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh - 210px - env(safe-area-inset-top) - env(safe-area-inset-bottom)); border: 0; background: var(--panel); }
27
+ .kit-served { display: block; width: 100%; min-height: 320px; height: calc(100dvh - 60px - var(--ps-footer-h, 150px) - var(--app-bottom, 0px) - env(safe-area-inset-top)); border: 0; background: var(--panel); }
28
+ .ps-pane .kit-served { height: calc(100dvh - var(--ps-footer-h, 150px)); }
26
29
  .kit-served-failed pre { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
27
30
  .kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
28
31
  #protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }