@lovek4d/kickflip 0.5.3 → 0.6.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 (91) hide show
  1. package/CONTEXT.md +82 -46
  2. package/SKILL.md +21 -15
  3. package/bin/doctor.mjs +5 -4
  4. package/bin/install.mjs +3 -3
  5. package/bin/kickflip.mjs +12 -12
  6. package/bin/wait-events.mjs +2 -2
  7. package/board/agents.mjs +2 -2
  8. package/board/arch-review.mjs +4 -4
  9. package/board/bin/board.mjs +179 -92
  10. package/board/build-script.mjs +2 -1
  11. package/board/domain.mjs +9 -7
  12. package/board/finish.mjs +4 -4
  13. package/board/headless.mjs +9 -5
  14. package/board/onboarding.mjs +11 -0
  15. package/board/prototype-base.mjs +1 -0
  16. package/board/prototype-page.mjs +1 -0
  17. package/board/public/app-arch.js +1 -1
  18. package/board/public/app-cards.js +40 -47
  19. package/board/public/app-header.js +6 -7
  20. package/board/public/app-lanes.js +19 -45
  21. package/board/public/app-needs.js +36 -31
  22. package/board/public/app-snapshot.js +43 -70
  23. package/board/public/app-threads.js +42 -46
  24. package/board/public/app.html +9 -4
  25. package/board/public/app.js +78 -32
  26. package/board/public/hub-kit.js +62 -2
  27. package/board/public/hub.css +2 -0
  28. package/board/public/new-task.js +8 -6
  29. package/board/public/progress-review.html +46 -0
  30. package/board/public/progress-review.js +158 -0
  31. package/board/public/prototype-kit.js +109 -4
  32. package/board/public/prototype-switcher.js +130 -31
  33. package/board/public/prototype.html +57 -3
  34. package/board/public/router.js +7 -6
  35. package/board/public/shell.js +65 -24
  36. package/board/public/sidebar.css +6 -0
  37. package/board/public/sidebar.js +19 -69
  38. package/board/public/style.css +116 -73
  39. package/board/public/ui-cards.js +78 -57
  40. package/board/public/ui-enums.js +2 -2
  41. package/board/public/ui-header.js +23 -16
  42. package/board/public/ui-hub.js +19 -5
  43. package/board/public/ui-links.js +7 -7
  44. package/board/public/ui-order.js +1 -1
  45. package/board/public/ui-page-header.js +19 -7
  46. package/board/public/ui-patch.js +7 -33
  47. package/board/public/ui-progress-bar.js +28 -0
  48. package/board/public/ui-questions.js +25 -17
  49. package/board/public/ui-sidebar.js +31 -23
  50. package/board/public/ui-snapshot.js +44 -53
  51. package/board/public/ui-threads.js +57 -28
  52. package/board/public/ui.js +94 -18
  53. package/board/public/view-hub-settings.js +12 -58
  54. package/board/public/view-hub.js +5 -2
  55. package/board/public/view-notes.js +27 -7
  56. package/board/public/view-questions.js +90 -68
  57. package/board/public/view-settings.js +133 -15
  58. package/board/render-check.mjs +10 -0
  59. package/board/review.mjs +1 -1
  60. package/board/server.mjs +20 -19
  61. package/board/stop.mjs +1 -0
  62. package/board/store.mjs +29 -18
  63. package/board/sync.mjs +2 -0
  64. package/board/tickets.mjs +3 -3
  65. package/board/transcript.mjs +2 -2
  66. package/board/worktree.mjs +22 -21
  67. package/hooks-plugin/hooks/board-data-gate.mjs +9 -6
  68. package/hooks-plugin/hooks/usage-gate.mjs +20 -3
  69. package/hub/board-repo.mjs +1 -1
  70. package/hub/boards.mjs +4 -4
  71. package/hub/front-door.mjs +2 -2
  72. package/hub/github.mjs +3 -3
  73. package/hub/hub.mjs +11 -11
  74. package/hub/incoming.mjs +2 -2
  75. package/hub/owner-checks.mjs +1 -1
  76. package/hub/pr-report.mjs +2 -2
  77. package/hub/review-send.mjs +1 -1
  78. package/hub/sessions.mjs +2 -2
  79. package/hub/share.mjs +1 -1
  80. package/hub/slack.mjs +2 -2
  81. package/hub/try.mjs +6 -6
  82. package/hub/upgrade.mjs +1 -1
  83. package/hub/usage.mjs +3 -3
  84. package/hub/views.mjs +1 -1
  85. package/package.json +1 -1
  86. package/shared/card-prs.mjs +1 -1
  87. package/shared/machine-gate.mjs +2 -0
  88. package/shared/prototype-frame.js +1 -0
  89. package/shared/tailscale.mjs +1 -1
  90. package/shared/usage-gate.mjs +1 -1
  91. package/board/public/ui-phase-bars.js +0 -17
@@ -1,11 +1,17 @@
1
- // The floating ‹ take › pill of a question's full-page prototype viewer (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
2
- // docs/adr/2026-11-05-1302-a-questions-prototypes-open-in-a-full-page-viewer.md).
1
+ // The full-bleed prototype viewer's floating controls (docs/adr/2026-10-29-1639-prototypes-come-on-request.md,
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
4
  // Load it with a relative src after claude.js, on a page whose header leads with the back arrow, then call
4
- // const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, initial: 'A', render(take) {…}, above: el? })
5
- // render runs at mount and on each switch; sw.take is the current key. ←/→ switch unless a field has focus, and the
6
- // take stays in ?variant=. With ?q=<owner question id>, the pill offers the take as that question's pick: a take is
7
- // pickable when an option's id equals its key. Pick drafts it, with the note typed above the pill, in the {choice, note}
8
- // questions.html keeps for that question, and goes back to the question, whose Send sends it.
5
+ // const sw = PrototypeSwitcher.mount({ takes: { A: 'A. Name', … }, states: { empty: 'No cards', … }?, initial: 'A', render(take, state) {…}, above: el? })
6
+ // 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
+ // (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
+ // 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
11
+ // 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
+ // the note typed above the bar, in the {choice, note} questions.html keeps for that question, and goes back to the
13
+ // question, whose Send sends it. A card with more than one prototype round gets a Round pill at the top left, which
14
+ // opens a sheet of its rounds (/api/board's card prototypes.rounds, board/domain.mjs).
9
15
  (function () {
10
16
  const esc = (t) => String(t ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
11
17
 
@@ -24,15 +30,16 @@
24
30
  const shownPick = (question, drafted) => question.state === 'requested' ? question.request?.choice ?? null
25
31
  : question.state === 'resolved' ? pickedChoice(question)
26
32
  : drafted ?? pickedChoice(question) ?? question.suggested?.option ?? null;
27
- // none: no question, the take is not one of its options, or the question is locked and the take isn't its pick.
28
- // pick: nothing picked yet. picked: this take is the pick. change: another take is.
33
+ // none: no question, the take is not one of its options, or the question waits on a requested prototype and the take
34
+ // isn't its pick. closed: the question is resolved and the take isn't its pick. pick: nothing picked yet. picked: this
35
+ // take is the pick. change: another take is.
29
36
  function pickState(question, take, drafted = null) {
30
37
  if (!question) return { kind: 'none' };
31
38
  const option = (question.options || []).find((o) => o.id === take);
32
39
  const picked = shownPick(question, drafted);
33
40
  if (!option) return { kind: 'none', picked };
34
41
  if (picked === take) return { kind: 'picked', option, picked };
35
- if (locked(question)) return { kind: 'none', picked };
42
+ if (locked(question)) return { kind: question.state === 'resolved' ? 'closed' : 'none', picked };
36
43
  return picked ? { kind: 'change', option, picked } : { kind: 'pick', option };
37
44
  }
38
45
  // questions.html keeps each question's unsent {choice, note} under this key (ui-questions.js getDraft).
@@ -47,38 +54,87 @@
47
54
  if (!app?.open?.(String(url), { replace: true })) location.replace(url);
48
55
  }
49
56
 
50
- function pillHtml({ name, pick = { kind: 'none' } }) {
57
+ // The bar: the states' chips when the round has states, the ‹ name › pager, and below it on its own row the pick
58
+ // control, when there is one.
59
+ function pillHtml({ name, pick = { kind: 'none' }, states = null, state = null }) {
60
+ const chips = states ? `<div class="ps-row ps-states">${Object.entries(states).map(([key, label]) => `<button type="button" data-ps-state="${esc(key)}" aria-pressed="${key === state}">${esc(label)}</button>`).join('')}</div>` : '';
51
61
  const control = pick.kind === 'picked' ? '<span class="ps-picked">Picked</span>'
52
- : pick.kind === 'pick' || pick.kind === 'change'
53
- ? `<button type="button" class="ps-pick" data-ps="pick">${pick.kind === 'pick' ? 'Pick this' : 'Change pick'}</button>`
54
- : '';
55
- return `<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>${control}`;
62
+ : pick.kind === 'closed' ? '<span class="ps-closed">Round answered</span>'
63
+ : pick.kind === 'pick' || pick.kind === 'change' ? '<button type="button" class="ps-pick" data-ps="pick">Choose this</button>'
64
+ : '';
65
+ 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>` : ''}`;
56
66
  }
57
67
 
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
+ // 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
+ // 50px sideways, more down than across, or starting at the left edge, where the drawer's and the browser's swipes are.
84
+ const swipeStep = (start, end) => {
85
+ const dx = end.x - start.x;
86
+ return start.x < 24 || Math.abs(dx) < 50 || Math.abs(end.y - start.y) * 2 > Math.abs(dx) ? 0 : dx < 0 ? 1 : -1;
87
+ };
88
+
58
89
  const CSS = `
59
- .ps-switch { position:fixed; left:16px; right:16px; bottom:calc(var(--app-bottom, 0px) + 14px + env(safe-area-inset-bottom)); z-index:30;
90
+ body > header.top { display:none; }
91
+ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
92
+ .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
+ align-items:flex-start; pointer-events:none; font:600 13px -apple-system, system-ui, sans-serif; }
94
+ .ps-top > * { pointer-events:auto; }
95
+ .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
+ .ps-chip { border-radius:999px; padding:0 14px; min-height:40px; }
97
+ .ps-close { margin-left:auto; width:40px; height:40px; border-radius:50%; font-size:17px; line-height:1; }
98
+ .ps-sheet { position:fixed; left:0; right:0; bottom:0; z-index:32; padding:14px 16px calc(22px + env(safe-area-inset-bottom)); border-radius:18px 18px 0 0;
99
+ background:#26262c; color:#fff; box-shadow:0 -8px 30px #0009; }
100
+ .ps-sheet h3 { margin:0 0 8px; font-size:14px; }
101
+ .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
+ padding:12px 2px; font:inherit; font-weight:500; text-align:left; cursor:pointer; }
103
+ .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;
60
105
  display:flex; flex-direction:column; align-items:center; gap:6px; pointer-events:none; }
61
106
  .ps-switch > * { pointer-events:auto; max-width:100%; min-width:0; box-sizing:border-box; }
62
- .ps-switch .pill, .ps-pill { display:flex; align-items:center; gap:2px; background:#000d; border:1px solid #fff3; border-radius:999px; padding:4px;
63
- -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); font:600 13px -apple-system, system-ui, sans-serif; }
64
- .ps-switch .pill button, .ps-pill button { color:#fff; background:transparent; border:0; border-radius:999px; padding:7px 11px; font:inherit; white-space:nowrap; cursor:pointer; }
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; }
109
+ .ps-row { display:flex; align-items:center; gap:2px; min-width:0; }
110
+ .ps-pill button { color:#fff; background:transparent; border:0; border-radius:999px; padding:7px 11px; font:inherit; white-space:nowrap; cursor:pointer; }
65
111
  .ps-pill button { flex-shrink:0; min-width:44px; min-height:44px; }
66
- .ps-switch .pill button.on { background:#fff; color:#000; }
67
- .ps-name { flex:0 1 auto; padding:7px 6px; color:#fff; white-space:nowrap; min-width:0; text-align:center; overflow:hidden; text-overflow:ellipsis; }
68
- .ps-pill .ps-pick { background:#fff; color:#000; margin-left:2px; }
112
+ .ps-name { flex:1 1 auto; padding:7px 6px; color:#fff; white-space:nowrap; min-width:0; text-align:center; overflow:hidden; text-overflow:ellipsis; }
113
+ .ps-act > * { flex:1; text-align:center; }
114
+ .ps-states { overflow-x:auto; scrollbar-width:none; gap:4px; }
115
+ .ps-pill .ps-states button { background:#fff1; font-weight:500; }
116
+ .ps-pill .ps-states button[aria-pressed="true"] { background:#fff; color:#000; font-weight:700; }
117
+ .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; }
69
119
  .ps-switch .ps-text { width:min(420px, 100%); resize:none; border-radius:12px; border:1px solid #fff3; background:#000d; color:#fff;
70
- padding:7px 10px; font:400 14px -apple-system, system-ui, sans-serif; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
120
+ padding:7px 10px; font:400 16px -apple-system, system-ui, sans-serif; -webkit-backdrop-filter:blur(12px); backdrop-filter:blur(12px); }
71
121
  .ps-switch .ps-text::placeholder { color:#fffa; }
72
122
  .ps-switch .ps-text[hidden] { display:none; }
73
- .ps-picked { margin-left:2px; padding:7px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
123
+ .ps-picked { padding:12px 11px; border-radius:999px; color:#fff; background:#1f9d55; white-space:nowrap; }
74
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; }
75
- .ps-note[hidden] { display:none; }`;
125
+ .ps-note[hidden] { display:none; }
126
+ @media (min-width: 900px) {
127
+ .ps-pill { width:min(640px, 100%); }
128
+ .ps-switch .ps-text { width:min(640px, 100%); }
129
+ .ps-states { flex-wrap:wrap; overflow-x:visible; }
130
+ }`;
76
131
 
77
132
  const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
78
133
 
79
- function mount({ takes, initial, render, above = null }) {
134
+ function mount({ takes, states = null, initial, render, above = null }) {
80
135
  const keys = Object.keys(takes);
81
136
  const params = new URLSearchParams(location.search);
137
+ let shownState = states ? startTake(Object.keys(states), params.get('state')) : null;
82
138
  const qid = params.get('q');
83
139
  const site = globalThis.ClaudeSite;
84
140
  if (!document.getElementById('ps-style')) document.head.insertAdjacentHTML('beforeend', `<style id="ps-style">${CSS}</style>`);
@@ -97,20 +153,37 @@
97
153
  const pill = box.querySelector('.ps-pill');
98
154
  const noteEl = box.querySelector('.ps-note');
99
155
  const text = box.querySelector('.ps-text');
156
+ // The ✕ and, on a card with earlier rounds, the Round pill and its sheet. boardCard: /api/board's card the question is on.
157
+ const top = document.createElement('div');
158
+ top.className = 'ps-top';
159
+ document.body.append(top);
160
+ let boardCard = null;
161
+ let sheet = false;
162
+ const paintTop = () => {
163
+ const rounds = boardCard?.prototypes?.rounds;
164
+ 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 }) : ''}`;
165
+ };
166
+ paintTop();
100
167
 
101
168
  const note = (text) => { noteEl.textContent = text || ''; noteEl.hidden = !text; };
102
169
  const state = () => pickState(question, take, qid ? readDraft(qid).choice : null);
103
170
  function paint() {
104
171
  const now = state();
105
- pill.innerHTML = pillHtml({ name: takes[take], pick: now });
106
- text.hidden = !(now.kind === 'pick' || now.kind === 'change');
172
+ pill.innerHTML = pillHtml({ name: takes[take], pick: now, states, state: shownState });
173
+ text.hidden = !(now.kind === 'pick' || now.kind === 'change' || now.kind === 'picked');
107
174
  }
108
175
  function go(next) {
109
176
  take = next;
110
177
  const u = new URL(location.href); u.searchParams.set('variant', take); history.replaceState(null, '', u);
111
178
  note('');
112
179
  paint();
113
- render(take);
180
+ render(take, shownState);
181
+ }
182
+ function show(next) {
183
+ shownState = next;
184
+ const u = new URL(location.href); u.searchParams.set('state', next); history.replaceState(null, '', u);
185
+ paint();
186
+ render(take, shownState);
114
187
  }
115
188
 
116
189
  async function load() {
@@ -124,6 +197,8 @@
124
197
  || (topic && (await (await fetch(site.url('api/questions?topic=' + encodeURIComponent(topic.id)), { cache: 'no-store' })).json()).questions.find((q) => q.id === qid))
125
198
  || null;
126
199
  if (!question) note('This prototype’s question is gone, so there is nothing to pick.');
200
+ boardCard = topic && board.items.find((i) => i.questions?.topic === topic.id) || null;
201
+ paintTop();
127
202
  } catch { /* keep the last question shown */ }
128
203
  if (fromPush && pickedChoice(question)) {
129
204
  const card = /^item:(.+)$/.exec(question.topic || '')?.[1];
@@ -148,22 +223,46 @@
148
223
  });
149
224
 
150
225
  pill.addEventListener('click', (e) => {
226
+ const chip = e.target.closest('[data-ps-state]')?.dataset.psState;
227
+ if (chip) { show(chip); return; }
151
228
  const act = e.target.closest('[data-ps]')?.dataset.ps;
152
229
  if (act === 'prev') go(takeAt(keys, take, -1));
153
230
  else if (act === 'next') go(takeAt(keys, take, 1));
154
231
  else if (act === 'pick') pick();
155
232
  });
233
+ // ✕ follows the hidden header's back arrow, so it goes where back goes (router.js page tree).
234
+ top.addEventListener('click', (e) => {
235
+ const round = e.target.closest('[data-ps-round]')?.dataset.psRound;
236
+ if (round) { replaceWith(site.url(round)); return; }
237
+ const act = e.target.closest('[data-ps-top]')?.dataset.psTop;
238
+ if (act === 'close') document.querySelector('header.top a.back').click();
239
+ else if (act === 'rounds') { sheet = !sheet; paintTop(); }
240
+ });
156
241
  document.addEventListener('keydown', (e) => {
157
242
  if (typing(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
158
243
  if (e.key === 'ArrowLeft') go(takeAt(keys, take, -1));
159
244
  else if (e.key === 'ArrowRight') go(takeAt(keys, take, 1));
245
+ else if (e.key === 'Escape' && sheet) { sheet = false; paintTop(); }
160
246
  });
247
+ // A swipe that starts in a box scrolling sideways is that box's own scroll.
248
+ let touch = null;
249
+ document.addEventListener('touchstart', (e) => {
250
+ const t = e.touches[0];
251
+ const scrolls = (el) => el && el !== document.body && (el.scrollWidth > el.clientWidth && /auto|scroll/.test(getComputedStyle(el).overflowX) ? true : scrolls(el.parentElement));
252
+ touch = e.touches.length === 1 && !typing(e.target) && !e.target.closest?.('.ps-sheet') && !scrolls(e.target) ? { x: t.clientX, y: t.clientY } : null;
253
+ }, { passive: true });
254
+ document.addEventListener('touchend', (e) => {
255
+ const t = e.changedTouches[0];
256
+ const step = touch && t ? swipeStep(touch, { x: t.clientX, y: t.clientY }) : 0;
257
+ touch = null;
258
+ if (step) go(takeAt(keys, take, step));
259
+ }, { passive: true });
161
260
  document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
162
261
 
163
262
  go(take);
164
263
  load();
165
- return { takes: keys, get take() { return take; }, go };
264
+ return { takes: keys, get take() { return take; }, get state() { return shownState; }, go, show };
166
265
  }
167
266
 
168
- globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, mount };
267
+ globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, roundPill, roundSheet, swipeStep, mount };
169
268
  })();
@@ -10,7 +10,7 @@
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 take pill and its note. */
13
+ take gets). main keeps room below the stage for the viewer's bar and its note. */
14
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)); }
15
15
  /* kit.hub() is a whole page with its own gutter and header, so it goes edge to edge. */
16
16
  main.proto-main:has(> #stage > .kit-hub) { padding-top: 0; }
@@ -20,11 +20,28 @@
20
20
  #stage:has(> .kit-hub) { padding: 0; }
21
21
  #stage .top { position: static; }
22
22
  #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
+ #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); }
26
+ .kit-served-failed pre { margin: 8px 0; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
23
27
  .kit-missing { margin: 0; padding: 16px; border: 1px dashed var(--line); border-radius: 12px; color: var(--muted); overflow-wrap: anywhere; }
24
28
  #protoToast { position: fixed; z-index: 40; left: 50%; top: calc(64px + env(safe-area-inset-top)); transform: translateX(-50%); max-width: calc(100vw - 32px); }
25
29
  #protoToast[hidden] { display: none; }
26
30
  @media (max-width: 899px) { #protoToast { top: max(12px, env(safe-area-inset-top)); } }
27
31
 
32
+ /* A take's own boxes are capped to the stage at phone width, so a fixed-width panel never cuts off. prototype-kit.js
33
+ (phoneFit) marks what the take lays side by side with data-stack, which stacks in one column whether the take used
34
+ grid, flex (wrapping or not) or inline-block, and a frame 600px or wider with data-wide: it keeps its own layout
35
+ and scrolls sideways inside its data-scroll box. */
36
+ @media (max-width: 599px) {
37
+ #stage :where(div, section, article, aside, nav, header, footer, main, form, ul, ol, table, figure):not([data-wide]) { max-width: 100%; min-width: 0; }
38
+ #stage [data-scroll] { overflow-x: auto; max-width: 100%; }
39
+ #stage [data-wide] { max-width: none; flex-shrink: 0; }
40
+ #stage [data-stack] { display: flex !important; flex-direction: column !important; flex-wrap: nowrap !important; grid-template-columns: none !important; align-items: stretch !important; }
41
+ #stage [data-stack] > * { width: auto !important; flex: 0 0 auto !important; grid-column: auto !important; float: none !important; }
42
+ }
43
+ /* iOS Safari zooms the page when it focuses a field under 16px. */
44
+ #stage :where(input, select, textarea) { font-size: 16px; }
28
45
  #stage .kit-sidebar.sidebar { position: relative; inset: auto; z-index: auto; transform: none; visibility: visible; transition: none;
29
46
  width: min(320px, 100%); height: 640px; margin: 0 auto; padding: 0 8px; border: 1px solid var(--line); border-radius: 14px; }
30
47
  #stage .kit-sidebar.sidebar:not(.kit-head) > header.top { display: none; }
@@ -40,15 +57,52 @@
40
57
  <main class="proto-main"><div id="stage"></div></main>
41
58
  <div class="toast" id="protoToast" role="status" hidden></div>
42
59
  <script id="prototypeData" type="application/json">{{prototypeData}}</script>
43
- <script src="claude.js"></script>
44
60
  <script src="ui.js"></script>
45
61
  <script src="ui-links.js"></script>
46
62
  <script src="ui-enums.js"></script>
47
- <script src="ui-phase-bars.js"></script>
63
+ <script src="ui-progress-bar.js"></script>
64
+ <script src="ui-order.js"></script>
65
+ <script src="ui-patch.js"></script>
66
+ <script src="ui-threads.js"></script>
48
67
  <script src="ui-cards.js"></script>
68
+ <script src="ui-header.js"></script>
69
+ <script src="ui-snapshot.js"></script>
49
70
  <script src="ui-page-header.js"></script>
50
71
  <script src="ui-sidebar.js"></script>
72
+ <script src="ui-questions.js"></script>
73
+ <script src="ui-prompts.js"></script>
74
+ <script src="ui-markdown.js"></script>
51
75
  <script src="ui-hub.js"></script>
76
+ <script src="toasts.js"></script>
77
+ <script src="dialogs.js"></script>
78
+ <script src="prompts.js"></script>
79
+ <script src="rules-editor.js"></script>
80
+ <script src="claude.js"></script>
81
+ <script src="attach.js"></script>
82
+ <script src="hub-kit.js"></script>
83
+ <script src="linear-key.js"></script>
84
+ <script src="sidebar.js"></script>
85
+ <script src="app.js"></script>
86
+ <script src="new-task.js"></script>
87
+ <script src="app-core.js"></script>
88
+ <script src="app-threads.js"></script>
89
+ <script src="app-cards.js"></script>
90
+ <script src="app-lanes.js"></script>
91
+ <script src="app-asks.js"></script>
92
+ <script src="app-needs.js"></script>
93
+ <script src="app-header.js"></script>
94
+ <script src="app-arch.js"></script>
95
+ <script src="app-reader.js"></script>
96
+ <script src="app-banner.js"></script>
97
+ <script src="app-snapshot.js"></script>
98
+ <script src="view-settings.js"></script>
99
+ <script src="view-questions.js"></script>
100
+ <script src="view-notes.js"></script>
101
+ <script src="view-hub.js"></script>
102
+ <script src="view-hub-settings.js"></script>
103
+ <script src="view-frame.js"></script>
104
+ <script src="router.js"></script>
105
+ <script src="shell.js"></script>
52
106
  <script src="prototype-switcher.js"></script>
53
107
  <script src="prototype-kit.js"></script>
54
108
  <script>PrototypeKit.start(JSON.parse(document.getElementById('prototypeData').textContent));</script>
@@ -85,18 +85,19 @@ globalThis.AppRouter = (function () {
85
85
  const u = new URL(view.url, 'http://hub');
86
86
  return /^\/[^/]+\/prototype-[\w-]+\.html$/.test(u.pathname) ? u.searchParams.get('q') || null : null;
87
87
  }
88
- function levelOf(view, { boards = new Set() } = {}) {
88
+ // wide: from 900px Waiting on you is the board's pane, so a prototype's parent is the board itself.
89
+ function levelOf(view, { boards = new Set(), wide = false } = {}) {
89
90
  if (view?.kind === 'board') return 0;
90
91
  if (view?.kind === 'questions') return 1;
91
- return questionOf(view, boards) ? 2 : null;
92
+ return questionOf(view, boards) ? (wide ? 1 : 2) : null;
92
93
  }
93
94
  // → the views above view in the tree, its board first. card: the card whose thread the owner left the board from,
94
95
  // which the board keeps open.
95
- function parentsOf(view, { boards = new Set(), card = null } = {}) {
96
+ function parentsOf(view, { boards = new Set(), card = null, wide = false } = {}) {
96
97
  const q = questionOf(view, boards);
97
98
  if (!q && view?.kind !== 'questions') return [];
98
99
  const board = { kind: 'board', route: view.route, hash: card ? `#item=${encodeURIComponent(card)}` : '' };
99
- return q ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
100
+ return q && !wide ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
100
101
  }
101
102
 
102
103
  // Each history entry carries {idx}, its distance from the shell's first entry, pushed when a push opened it, and on a
@@ -109,8 +110,8 @@ globalThis.AppRouter = (function () {
109
110
  // boards() → the board routes now.
110
111
  // → {push(href, from), replace(href), up()}: push and replace write an entry for a view go is showing; replace answers
111
112
  // 'up' when it went back to a parent instead, which then takes href, and go shows it on that pop.
112
- function listen({ boards, go }) {
113
- const b = () => ({ boards: boards() });
113
+ function listen({ boards, go, wide = () => false }) {
114
+ const b = () => ({ boards: boards(), wide: wide() });
114
115
  const mark = (pushed) => (pushed ? { pushed: true } : {});
115
116
  let idx = Number.isInteger(history.state?.idx) ? history.state.idx : 0;
116
117
  let landing = null;
@@ -6,8 +6,8 @@
6
6
  // view in place through router.js; a link to anything that isn't a page loads it. Leaving a view whose root holds
7
7
  // [data-dirty], an unsaved edit, asks first. The shell holds the hub's one stream, /_hub/stream
8
8
  // (hub/stream.mjs): `change {route}` signals the open board, and `sidebar` repaints the sidebar. A board the shell
9
- // leaves is unmounted; one left fresh whose stream sent no change since paints live when it comes back, and any other
10
- // paints dimmed from its last visit (docs/adr/2026-10-29-1639-the-board-opens-on-the-last-visit.md).
9
+ // leaves is unmounted, and paints live from its saved copy when it comes back
10
+ // (docs/adr/2026-11-05-1819-the-board-syncs-by-change-number.md).
11
11
  // A push tap reaches it from hub/sw.js as {open: url}, and a page framed inside it hands it a view's URL through
12
12
  // AppShell.open(url, {replace, frames}) → true when the shell shows it in place; frames false refuses a frame view.
13
13
  // AppShell.reload() reloads the app past the shell the service worker keeps.
@@ -35,6 +35,10 @@ window.AppShell = (function () {
35
35
  channel.port1.onmessage = done;
36
36
  try { controller.postMessage({ forget: 'shell' }, [channel.port2]); } catch { done(); }
37
37
  });
38
+ // Only this reload starts at the top: iOS put the old offset back once the page had grown and left that area unpainted until a scroll
39
+ // (docs/adr/2026-11-06-1100-the-shell-turns-off-scroll-restoration.md). pagehide gives ordinary reloads their restore back.
40
+ history.scrollRestoration = 'manual';
41
+ addEventListener('pagehide', () => { history.scrollRestoration = 'auto'; }, { once: true });
38
42
  setTimeout(() => location.reload(), 0);
39
43
  }
40
44
 
@@ -57,10 +61,20 @@ window.AppShell = (function () {
57
61
  // A stored sidebar's code says nothing about the shell that booted, so it adopts the next live hash.
58
62
  bootCode = data?.code || null;
59
63
  data ||= read(DATA_KEY);
60
- // A board shared with someone else, view only (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
61
- // the hub sends them data.shared, and style.css hides what writes under .shared-view.
64
+ // A board shared with someone else (docs/adr/2026-11-05-2215-a-board-is-shared-view-only-with-a-tailscale-login.md):
65
+ // the hub sends them data.shared, and style.css hides what writes under .shared-view. On a board they have Full on
66
+ // (data.shared.full; docs/adr/2026-11-05-1912-a-shared-person-is-view-or-full.md) the page is .shared-full too,
67
+ // which shows the composers and card buttons again; levelHere follows the route shown and each sidebar event.
62
68
  const shared = data?.shared || null;
63
69
  document.documentElement.classList.toggle('shared-view', Boolean(shared));
70
+ const sharedHere = (route) => ({ by: data?.shared?.by ?? null, full: Boolean(data?.shared?.full?.includes(route)) });
71
+ function levelHere() {
72
+ if (!shared || !current) return;
73
+ const here = sharedHere(current.route);
74
+ document.documentElement.classList.toggle('shared-full', here.full);
75
+ const strip = current.root.querySelector('.view-only-strip');
76
+ if (strip) strip.innerHTML = BoardView.sharedStrip(here);
77
+ }
64
78
  sidebar = Sidebar.mount({ data, poll: false, reload, open: (href) => { if (!open(href)) location.assign(href); } });
65
79
  const boards = () => new Set((data?.sites || []).filter((s) => s.board).map((s) => s.route));
66
80
  const titleOf = (route) => data?.sites?.find((s) => s.route === route)?.title || route;
@@ -72,13 +86,8 @@ window.AppShell = (function () {
72
86
  listeners.get(route).add(fn);
73
87
  return () => listeners.get(route)?.delete(fn);
74
88
  };
75
- // route → {fresh, generatedAt} for each board left fresh with no change since. Trusted only while the stream is up:
76
- // a dropped stream may have missed a change, so its return forgets them all.
77
- const left = new Map();
78
- let streamUp = false;
79
89
  let current = null;
80
90
  function changed(route) {
81
- left.delete(route);
82
91
  for (const fn of listeners.get(route) || []) fn();
83
92
  }
84
93
 
@@ -94,8 +103,7 @@ window.AppShell = (function () {
94
103
  const cur = es = new EventSource('/_hub/stream');
95
104
  cur.addEventListener('open', () => {
96
105
  tries = 0;
97
- streamUp = true;
98
- if (opened) { left.clear(); if (current) changed(current.route); }
106
+ if (opened && current) changed(current.route);
99
107
  opened = true;
100
108
  });
101
109
  cur.addEventListener('change', (e) => {
@@ -107,10 +115,10 @@ window.AppShell = (function () {
107
115
  try { data = JSON.parse(e.data); } catch { return; }
108
116
  codeSeen(data.code);
109
117
  sidebar.setData(data);
118
+ levelHere();
110
119
  current?.v?.B?.renderStatus?.();
111
120
  });
112
121
  cur.addEventListener('error', () => {
113
- streamUp = false;
114
122
  if (cur.readyState !== EventSource.CLOSED || es !== cur) return;
115
123
  retry = setTimeout(openStream, Math.min(30000, 1000 * 2 ** tries++));
116
124
  });
@@ -131,6 +139,14 @@ window.AppShell = (function () {
131
139
  };
132
140
  for (const type of ['pointerdown', 'keydown', 'scroll']) addEventListener(type, beat, { capture: true, passive: true });
133
141
 
142
+ // docs/adr/2026-11-05-1834-while-typing-the-header-is-a-strip-and-the-send-bar-sits-on-the-keyboard.md
143
+ // While a text field has focus :root.typing is set and style.css disables the floating + (New task); nothing else
144
+ // on the page moves.
145
+ document.addEventListener('focusin', (ev) => {
146
+ document.documentElement.classList.toggle('typing', ev.target.matches?.('textarea, input[type="text"], input:not([type])') ?? false);
147
+ });
148
+ document.addEventListener('focusout', () => document.documentElement.classList.remove('typing'));
149
+
134
150
  // A view that changed what the sidebar's foot rows say, such as notifications, asks for a fresh copy
135
151
  // rather than waiting for the stream's next one.
136
152
  async function refreshSidebar() {
@@ -179,29 +195,23 @@ window.AppShell = (function () {
179
195
  const board = view.kind === 'board';
180
196
  const hub = route === undefined;
181
197
  const siteTitle = hub ? 'Hub' : titleOf(route);
182
- if (current) {
183
- const st = current.v.B.state;
184
- const keep = streamUp && st?.view === 'fresh' && st.board ? { fresh: st.fresh, generatedAt: st.board.generatedAt } : null;
185
- current.v.unmount();
186
- if (keep) left.set(current.route, keep);
187
- }
198
+ if (current) current.v.unmount();
188
199
  const page = board ? null : AppViews[view.kind];
189
200
  const root = document.createElement('div');
190
201
  root.className = board ? 'view-board' : page.className;
191
- root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared }) : page.markup(view, { siteTitle });
202
+ root.innerHTML = board ? BoardView.markup({ title: siteTitle, shared: shared && sharedHere(route) }) : page.markup(view, { siteTitle });
192
203
  if (current) current.root.replaceWith(root); else scrim.after(root);
193
204
  const siteRoot = hub ? '/_hub' : '/' + encodeURIComponent(route);
194
205
  let v;
195
206
  if (board) {
196
- const live = streamUp ? left.get(route) || null : null;
197
- left.delete(route);
198
- v = BoardView.create(root, siteRoot, { signal: signalFor(route), live, pause: () => data?.pause, away: () => data?.away });
207
+ v = BoardView.create(root, siteRoot, { signal: signalFor(route), pause: () => data?.sites?.find((s) => s.route === route)?.pause || data?.pause, away: () => data?.away });
199
208
  } else {
200
209
  v = page.create(root, siteRoot, { view, siteTitle, boards, open: openHere, toggleSidebar: sidebar.toggle, keydown: sidebar.keydown, pointerdown: sidebar.pointerdown,
201
210
  reload: () => go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'reload'), closeSidebar: sidebar.close, up: () => router.up(),
202
211
  loaded: (url, title) => { if (current?.v === v) framed(url, title); }, sidebarChanged: refreshSidebar });
203
212
  }
204
213
  current = { view, route, root, v };
214
+ levelHere();
205
215
  document.title = board ? siteTitle : page.title(view, { siteTitle });
206
216
  if (boards().has(route)) { try { localStorage.setItem(LAST_KEY, route); } catch { /* no storage: `/` opens the start board */ } }
207
217
  sidebar.setHere(hub ? new URL(AppRouter.urlOf(view), location.href).pathname.slice(1) : route);
@@ -232,8 +242,29 @@ window.AppShell = (function () {
232
242
  // go(view, href, how, from): router.js's, with how 'replace' for a framed page's hand-off and 'reload' for a view's
233
243
  // own refresh. → true, or a promise of whether the view switched once the owner answers about an unsaved edit.
234
244
  let router = null;
245
+ const wide = matchMedia('(min-width: 900px)');
246
+ // Board and Waiting on you are a board's two pages (docs/adr/2026-11-07-1300-board-and-open-questions-are-two-pages.md):
247
+ // from 900px the board shows Waiting on you in its pane, so every view of the questions page, a link, a push, a
248
+ // hand-off, a history entry or a cold load, shows the board at its own URL and opens the question's topic there (id,
249
+ // or null for none). A board just shown takes a moment to fill its pane, so the pane is asked again until it has.
250
+ function focusPane(id, tries = 50) {
251
+ if (current?.view.kind !== 'board' || current.v.B.focusQuestion?.(id) || tries <= 0) return;
252
+ setTimeout(() => focusPane(id, tries - 1), 100);
253
+ }
235
254
  function go(view, href, how, from) {
236
255
  if (!AppRouter.mounted(view)) return false;
256
+ if (view.kind === 'questions' && wide.matches) {
257
+ const board = { kind: 'board', route: view.route, hash: '' };
258
+ const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
259
+ if (how === 'pop') history.replaceState(history.state, '', boardHref);
260
+ // On its own board it only opens the topic, so the URL, history and an open card stay as they are.
261
+ else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
262
+ focusPane(view.id);
263
+ sidebar.close();
264
+ return true;
265
+ }
266
+ return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) focusPane(view.id); return shown; });
267
+ }
237
268
  if (view.kind === 'settings') {
238
269
  if (current?.view.kind === 'board' && current.route === view.route) {
239
270
  const here = AppRouter.viewOf(location.href, { boards: boards() });
@@ -285,7 +316,17 @@ window.AppShell = (function () {
285
316
  }
286
317
  if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
287
318
 
288
- router = AppRouter.listen({ boards, go });
319
+ let pane = null;
320
+ if (first.kind === 'questions' && wide.matches) {
321
+ pane = { id: first.id };
322
+ first = { kind: 'board', route: first.route, hash: '' };
323
+ history.replaceState(history.state, '', AppRouter.urlOf(first));
324
+ }
325
+ router = AppRouter.listen({ boards, go, wide: () => wide.matches });
326
+ // Widening past 900px on Waiting on you swaps to the board with that topic open in the pane.
327
+ wide.addEventListener('change', () => {
328
+ if (wide.matches && current?.view.kind === 'questions') go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
329
+ });
289
330
  opener = (url, { replace = false, frames = true } = {}) => {
290
331
  const view = AppRouter.viewOf(url, { boards: boards() });
291
332
  if (!AppRouter.mounted(view) || (!frames && view.kind === 'frame')) return false;
@@ -301,6 +342,7 @@ window.AppShell = (function () {
301
342
  navigator.serviceWorker?.startMessages?.();
302
343
 
303
344
  show(first);
345
+ if (pane) focusPane(pane.id);
304
346
  openStream();
305
347
  addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
306
348
  addEventListener('pageshow', openStream);
@@ -309,7 +351,6 @@ window.AppShell = (function () {
309
351
  addEventListener('pagehide', (e) => {
310
352
  if (!e.persisted || !es) return;
311
353
  clearTimeout(retry);
312
- streamUp = false;
313
354
  es.close();
314
355
  });
315
356
  }
@@ -41,6 +41,11 @@
41
41
  html.sb-update body.has-sidebar > .top .hub, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
42
42
  html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-sidebar > :not(.sidebar) > .top .hub::after {
43
43
  content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 2px var(--bg); }
44
+ /* A busy hub gets an orange dot on the logo and a broken one a red dot, in the update dot's place (red, then orange, then blue). */
45
+ html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub { position: relative; }
46
+ html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > .top .hub::after, html:is(.sb-hub-busy, .sb-hub-down) body.has-sidebar > :not(.sidebar) > .top .hub::after {
47
+ content: ''; position: absolute; width: 10px; height: 10px; top: 3px; right: 3px; border-radius: 50%; background: var(--color-notice); box-shadow: 0 0 0 2px var(--bg); }
48
+ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-sidebar > :not(.sidebar) > .top .hub::after { background: var(--color-danger); }
44
49
  .sidebar .sb-text { flex: 1; min-width: 0; }
45
50
  .sidebar .sb-title { display: block; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
46
51
  .sidebar .sb-moon { display: inline-block; margin-left: 5px; color: var(--muted); vertical-align: -1px; }
@@ -180,6 +185,7 @@ html.sb-update body.has-sidebar > .top .hub::after, html.sb-update body.has-side
180
185
  .sidebar .t-building { --tone: var(--color-notice); }
181
186
  .sidebar .t-ok { --tone: var(--color-success); }
182
187
  .sidebar .t-wait { --tone: var(--color-notice); }
188
+ .sidebar .t-paused { --tone: var(--muted); }
183
189
 
184
190
  @media (min-width: 900px) {
185
191
  body.has-sidebar { padding-left: 272px; }