@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
@@ -87,7 +87,6 @@
87
87
  usage: fake.usage(),
88
88
  hub: { tone: 'ok', word: 'healthy', detail: '3 of 3 sites up' },
89
89
  sites: fake.sites(),
90
- polling: { title: 'Checks every minute while you’re here', lines: ['Next check 12:00pm · last 11:45am, 8 PRs, no change', 'No pushes queued.'], count: 0 },
91
90
  settings: [['notifications', 'Notifications', 'Pushes on · Off on weekends · This device on'], ['quiet', 'Quiet hours', '10:00pm – 8:00am · pushes held'],
92
91
  ['codex', 'Codex share', 'Auto'], ['rules', 'Rules for every board', '1 rule'], ['closed', 'Closed boards', 'No closed boards']],
93
92
  }),
@@ -105,7 +104,7 @@
105
104
 
106
105
  function hub(data = {}) {
107
106
  const d = { ...fake.hub(), header: true, ...data };
108
- const { row, tile, nowRow, CHEVRON } = HubUI;
107
+ const { row, tile, CHEVRON } = HubUI;
109
108
  const site = (s) => row({ icon: s.icon, title: esc(s.title), sub: s.status ?? '<span class="st ok">up · listening · every link passes</span>', wrap: true, trail: CHEVRON, href: `/${s.route}/` });
110
109
  const settings = d.settings.map(([id, title, sub]) => `<button type="button" class="hub-setting" data-setting="${esc(id)}" data-kit-inert aria-haspopup="dialog"><span class="text"><span class="title">${esc(title)}</span><span class="sub">${esc(sub)}</span></span>${CHEVRON}</button>`).join('');
111
110
  const usage = d.usageCard ?? HubUI.usageCard(d.usage);
@@ -114,9 +113,7 @@
114
113
  <div class="group">${row({ icon: tile('power', { ok: '#22c55e', wait: '#f59e0b' }[d.hub.tone] || '#ef4444'), title: 'Hub', sub: `<span class="st ${esc(d.hub.tone)}">${esc(d.hub.word)}</span> · ${esc(d.hub.detail)}`, wrap: true })}</div>
115
114
  <h2 class="label">Boards</h2>
116
115
  <div class="group">${d.sites.map(site).join('')}</div>
117
- <p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p>
118
- <h2 class="label">Polling status</h2>
119
- <div class="group">${nowRow(d.polling, tile('clock', '#3b82f6'))}</div></div>
116
+ <p class="note">The hub runs every site and restarts one that stops. A board's links are what the hub owes it: GitHub polling, events (its session and a watcher run, or the hub can wake it), daily wakes, pushes and reviewer links. One that just broke reads settling first.</p></div>
120
117
  <section class="hub-settings"><h2 class="label">Hub settings</h2>${settings}</section>
121
118
  <div class="hub-foot"><footer>${d.sites.length} sites. Each belongs to one Claude session, which adds it with <code>/kickflip &lt;route&gt;</code>. This page refreshes every 30 seconds.<br><span class="host">mac.example.ts.net</span><br><span class="brand"><a href="https://kickflip.sh">kickflip.sh</a> / v0.4.2</span></footer></div></main></div>`;
122
119
  }
@@ -205,25 +202,39 @@
205
202
  const stage = document.getElementById('stage');
206
203
  let at = { take: null, state: null };
207
204
  let frame = null;
205
+ // The run the frame shows: its since from api/try/prototypes.
206
+ let framed = null;
208
207
  let origin = null;
209
208
  let started = false;
210
209
  const serve = () => fetch(site.url('api/try'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ key: 'prototypes' }) }).catch(() => {});
211
210
  const post = () => frame.contentWindow.postMessage({ kickflip: 'prototype', ...at }, origin);
211
+ // 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):
212
+ // 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
213
+ // current take and state; a serve that's down shows Starting. A poll that can't reach the board leaves the stage.
212
214
  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 */ }
215
+ let run;
216
+ try { run = await (await fetch(site.url('api/try/prototypes'), { cache: 'no-store' })).json(); } catch {
217
+ if (!frame) stage.innerHTML = servedStatus({});
218
+ wait(poll, 1500);
219
+ return;
220
+ }
215
221
  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);
222
+ if (!frame || (run.since ?? null) !== framed) {
223
+ const src = servedSrc({ https: run.https, path: data.served, ...at });
224
+ origin = new URL(src).origin;
225
+ framed = run.since ?? null;
226
+ frame = document.createElement('iframe');
227
+ frame.className = 'kit-served';
228
+ frame.title = data.title;
229
+ frame.src = src;
230
+ // A switch made while the page loaded reaches it once it has.
231
+ nativeAdd.call(frame, 'load', post);
232
+ stage.replaceChildren(frame);
233
+ }
234
+ wait(poll, 3000);
225
235
  return;
226
236
  }
237
+ frame = null;
227
238
  stage.innerHTML = servedStatus(run);
228
239
  // A serve that's down is started once from here; a failed one waits for Try again.
229
240
  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,19 +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; }
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; }
125
115
  .ps-note[hidden] { display:none; }
126
116
  @media (min-width: 900px) {
117
+ .ps-switch { align-items:center; }
127
118
  .ps-pill { width:min(640px, 100%); }
128
119
  .ps-switch .ps-text { width:min(640px, 100%); }
129
120
  .ps-states { flex-wrap:wrap; overflow-x:visible; }
130
- }`;
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; }`;
131
125
 
132
126
  const typing = (el) => Boolean(el?.closest?.('input, select, textarea, [contenteditable]:not([contenteditable="false"])'));
133
127
 
@@ -138,6 +132,8 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
138
132
  const qid = params.get('q');
139
133
  const site = globalThis.ClaudeSite;
140
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');
141
137
  let take = startTake(keys, params.get('variant'), initial);
142
138
  let question = null;
143
139
  // A push opened this page (board/store.mjs pushUrl). Read once and dropped from the URL, so a reload stays on the
@@ -149,18 +145,23 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
149
145
  box.className = 'ps-switch';
150
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>';
151
147
  if (above) box.prepend(above);
148
+ box.classList.add('ps-pending');
152
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);
153
153
  const pill = box.querySelector('.ps-pill');
154
154
  const noteEl = box.querySelector('.ps-note');
155
155
  const text = box.querySelector('.ps-text');
156
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
157
  const top = document.createElement('div');
158
- top.className = 'ps-top';
158
+ top.className = 'ps-top ps-pending';
159
159
  document.body.append(top);
160
160
  let boardCard = null;
161
161
  let sheet = false;
162
162
  const paintTop = () => {
163
163
  const rounds = boardCard?.prototypes?.rounds;
164
+ const { roundPill, roundSheet } = globalThis.BoardUI;
164
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 }) : ''}`;
165
166
  };
166
167
  paintTop();
@@ -186,7 +187,20 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
186
187
  render(take, shownState);
187
188
  }
188
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);
189
200
  async function load() {
201
+ try { await fetchQuestion(); } finally { reveal(); }
202
+ }
203
+ async function fetchQuestion() {
190
204
  if (!qid || !site) return;
191
205
  try {
192
206
  const board = await (await fetch(site.url('api/board'), { cache: 'no-store' })).json();
@@ -202,7 +216,7 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
202
216
  } catch { /* keep the last question shown */ }
203
217
  if (fromPush && pickedChoice(question)) {
204
218
  const card = /^item:(.+)$/.exec(question.topic || '')?.[1];
205
- replaceWith(site.url(card ? `#item=${encodeURIComponent(card)}&reply` : 'questions.html'));
219
+ replaceWith(site.url(card ? `#selectedCard=${encodeURIComponent(card)}&reply` : 'questions.html'));
206
220
  return;
207
221
  }
208
222
  if (question && !text.value) text.value = readDraft(qid).note || '';
@@ -260,9 +274,10 @@ main.proto-main { padding-top:calc(60px + env(safe-area-inset-top)); }
260
274
  document.addEventListener('visibilitychange', () => { if (!document.hidden) load(); });
261
275
 
262
276
  go(take);
277
+ measure();
263
278
  load();
264
279
  return { takes: keys, get take() { return take; }, get state() { return shownState; }, go, show };
265
280
  }
266
281
 
267
- globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, roundPill, roundSheet, swipeStep, mount };
282
+ globalThis.PrototypeSwitcher = { takeAt, startTake, pickedChoice, pickState, pillHtml, swipeStep, mount };
268
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); }
@@ -3,10 +3,10 @@
3
3
  // ui.test.mjs runs them in node:vm. listen() catches clicks on links to a view the shell mounts, and back and forward.
4
4
  // A view is {kind, …}:
5
5
  // start `/`, which the shell turns into the last board
6
- // board {route, hash} /<route>/, hash such as #item=<key>&reply
6
+ // board {route, hash} /<route>/, hash such as #selectedCard=<key>&openItemsCard=<topic id>, or the older #item=<key>&reply
7
7
  // settings {route} /<route>/settings.html, a request to open the modal over that board
8
8
  // questions {route, id} /<route>/questions.html#<id>
9
- // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
9
+ // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<wave slug>, and plan.html, which the board
10
10
  // redirects there
11
11
  // hub {hash} /_hub/ with a settings modal named by hash; legacy /_hub/settings opens the same modals
12
12
  // ([hub settings open as modals](docs/adr/2026-11-01-1221-hub-settings-open-as-modals.md)),
@@ -45,6 +45,7 @@ globalThis.AppRouter = (function () {
45
45
  if (page === 'questions.html') {
46
46
  let id = null;
47
47
  try { id = u.hash.length > 1 ? decodeURIComponent(u.hash.slice(1)) : null; } catch { id = u.hash.slice(1); }
48
+ if (id?.startsWith('openItemsCard=')) id = id.slice('openItemsCard='.length);
48
49
  return { kind: 'questions', route, id };
49
50
  }
50
51
  if (page === 'notes.html' || page === 'plan.html') {
@@ -80,10 +81,10 @@ globalThis.AppRouter = (function () {
80
81
 
81
82
  // The page tree (docs/adr/2026-10-30-1125-back-follows-the-page-tree.md): a board, its Waiting on you, and a prototype page tied
82
83
  // to one of its questions by ?q=, framed. levelOf → 0, 1 or 2 down the tree, or null for a view outside it.
84
+ // A board's prototype page, which from 900px opens in the board's right pane (shell.js go).
85
+ const isPrototype = (view, boards) => view?.kind === 'frame' && boards.has(view.route) && /^\/[^/]+\/prototype-[\w-]+\.html$/.test(new URL(view.url, 'http://hub').pathname);
83
86
  function questionOf(view, boards) {
84
- if (view?.kind !== 'frame' || !boards.has(view.route)) return null;
85
- const u = new URL(view.url, 'http://hub');
86
- return /^\/[^/]+\/prototype-[\w-]+\.html$/.test(u.pathname) ? u.searchParams.get('q') || null : null;
87
+ return isPrototype(view, boards) ? new URL(view.url, 'http://hub').searchParams.get('q') || null : null;
87
88
  }
88
89
  // wide: from 900px Waiting on you is the board's pane, so a prototype's parent is the board itself.
89
90
  function levelOf(view, { boards = new Set(), wide = false } = {}) {
@@ -96,7 +97,7 @@ globalThis.AppRouter = (function () {
96
97
  function parentsOf(view, { boards = new Set(), card = null, wide = false } = {}) {
97
98
  const q = questionOf(view, boards);
98
99
  if (!q && view?.kind !== 'questions') return [];
99
- const board = { kind: 'board', route: view.route, hash: card ? `#item=${encodeURIComponent(card)}` : '' };
100
+ const board = { kind: 'board', route: view.route, hash: card ? `#selectedCard=${encodeURIComponent(card)}` : '' };
100
101
  return q && !wide ? [board, { kind: 'questions', route: view.route, id: q }] : [board];
101
102
  }
102
103
 
@@ -224,5 +225,32 @@ globalThis.AppRouter = (function () {
224
225
  return { push: (href, from) => write('push', href, from), replace: (href) => write('replace', href), up };
225
226
  }
226
227
 
227
- return { viewOf, urlOf, mounted, entryOf, listen };
228
+ // A board's fragment is &-joined keys: selectedCard=<key> (the focused card), openItemsCard=<topic id> (the selected
229
+ // open-items pill), review=<PR number>, and a bare reply. The older #item=<key> reads as selectedCard.
230
+ // → {selectedCard, openItemsCard, review, reply}, each null or false when absent.
231
+ function fragmentOf(hash) {
232
+ const out = { selectedCard: null, openItemsCard: null, review: null, reply: false };
233
+ const decode = (v) => { try { return decodeURIComponent(v); } catch { return v; } };
234
+ for (const part of String(hash).replace(/^#/, '').split('&')) {
235
+ const eq = part.indexOf('=');
236
+ const [k, v] = eq < 0 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
237
+ if (k === 'selectedCard' || k === 'item') out.selectedCard = decode(v) || null;
238
+ else if (k === 'openItemsCard') out.openItemsCard = decode(v) || null;
239
+ else if (k === 'review' && /^\d+$/.test(v)) out.review = Number(v);
240
+ else if (k === 'reply') out.reply = true;
241
+ }
242
+ return out;
243
+ }
244
+ // hash with the named keys of changes replaced (null drops one) → the new fragment, '' for none. A #review= link is
245
+ // alone in its fragment, so a change to a card or pill replaces it; reply is spent once the card is shown.
246
+ function hashWith(hash, changes) {
247
+ const now = fragmentOf(hash);
248
+ const next = { selectedCard: now.selectedCard, openItemsCard: now.openItemsCard, ...changes };
249
+ const parts = [];
250
+ if (next.selectedCard) parts.push(`selectedCard=${encodeURIComponent(next.selectedCard)}`);
251
+ if (next.openItemsCard) parts.push(`openItemsCard=${encodeURIComponent(next.openItemsCard)}`);
252
+ return parts.length ? '#' + parts.join('&') : '';
253
+ }
254
+
255
+ return { viewOf, urlOf, mounted, entryOf, isPrototype, listen, fragmentOf, hashWith };
228
256
  })();
@@ -246,24 +246,30 @@ window.AppShell = (function () {
246
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
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
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);
249
+ // or null for none). A board's prototype page does the same and opens in the pane
250
+ // (docs/adr/2026-11-07-2030-on-desktop-the-prototype-viewer-opens-in-the-right-pane.md).
251
+ const inPane = (view) => wide.matches && (view?.kind === 'questions' || AppRouter.isPrototype(view, boards()));
252
+ // ask(B) → true once the board's pane did what the view asks. A board just shown takes a moment to fill its pane, so
253
+ // the pane is asked again until it has.
254
+ const paneAsk = (view, href) => (view.kind === 'questions' ? (B) => B.focusQuestion?.(view.id) : (B) => B.openViewer?.(href));
255
+ function askPane(ask, tries = 50) {
256
+ if (current?.view.kind !== 'board' || ask(current.v.B) || tries <= 0) return;
257
+ setTimeout(() => askPane(ask, tries - 1), 100);
253
258
  }
254
259
  function go(view, href, how, from) {
255
260
  if (!AppRouter.mounted(view)) return false;
256
- if (view.kind === 'questions' && wide.matches) {
261
+ if (inPane(view)) {
262
+ const ask = paneAsk(view, href);
257
263
  const board = { kind: 'board', route: view.route, hash: '' };
258
264
  const boardHref = new URL(AppRouter.urlOf(board), location.href).href;
259
265
  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.
266
+ // On its own board it only asks the pane, so the URL, history and an open card stay as they are.
261
267
  else if (current?.view.kind === 'board' && current.route === view.route && !settings) {
262
- focusPane(view.id);
268
+ askPane(ask);
263
269
  sidebar.close();
264
270
  return true;
265
271
  }
266
- return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) focusPane(view.id); return shown; });
272
+ return Promise.resolve(go(board, boardHref, how, from)).then((shown) => { if (shown) askPane(ask); return shown; });
267
273
  }
268
274
  if (view.kind === 'settings') {
269
275
  if (current?.view.kind === 'board' && current.route === view.route) {
@@ -317,15 +323,15 @@ window.AppShell = (function () {
317
323
  if (!AppRouter.mounted(first)) { location.replace('/_hub/'); return; }
318
324
 
319
325
  let pane = null;
320
- if (first.kind === 'questions' && wide.matches) {
321
- pane = { id: first.id };
326
+ if (inPane(first)) {
327
+ pane = paneAsk(first, location.href);
322
328
  first = { kind: 'board', route: first.route, hash: '' };
323
329
  history.replaceState(history.state, '', AppRouter.urlOf(first));
324
330
  }
325
331
  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.
332
+ // Widening past 900px on Waiting on you or a prototype swaps to the board with it open in the pane.
327
333
  wide.addEventListener('change', () => {
328
- if (wide.matches && current?.view.kind === 'questions') go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
334
+ if (current && inPane(current.view)) go(AppRouter.viewOf(location.href, { boards: boards() }), location.href, 'replace');
329
335
  });
330
336
  opener = (url, { replace = false, frames = true } = {}) => {
331
337
  const view = AppRouter.viewOf(url, { boards: boards() });
@@ -342,7 +348,7 @@ window.AppShell = (function () {
342
348
  navigator.serviceWorker?.startMessages?.();
343
349
 
344
350
  show(first);
345
- if (pane) focusPane(pane.id);
351
+ if (pane) askPane(pane);
346
352
  openStream();
347
353
  addEventListener('visibilitychange', () => { if (!document.hidden) openStream(); });
348
354
  addEventListener('pageshow', openStream);
@@ -150,11 +150,11 @@ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-
150
150
  @starting-style { .sidebar.ks-on .ks-label-hint { opacity: 0; } }
151
151
  /* The label is the bar's containing block: left 0 is the BOARDS text's edge, and right -8px crosses the label's 8px
152
152
  side margin (.sb-label) to the rows' right edge. The bar sits in the label's 4px bottom margin. */
153
- .sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--accent);
153
+ .sidebar .ks-bar { position: absolute; left: 0; right: -8px; top: 100%; height: 2px; margin-top: 1px; background: var(--color-notice);
154
154
  transform-origin: 0 50%; animation: ks-drain linear forwards; }
155
155
  @keyframes ks-drain { to { transform: scaleX(0); } }
156
156
  .sidebar .ks-u { transition: color 200ms linear; }
157
- .sidebar.ks-on .ks-u { color: var(--accent); transition: color 150ms linear; }
157
+ .sidebar.ks-on .ks-u { color: var(--color-notice); transition: color 150ms linear; }
158
158
  @starting-style { .sidebar.ks-on .ks-u { color: inherit; } }
159
159
  @media (prefers-reduced-motion: reduce) {
160
160
  .sidebar .ks-bar { animation: none; }
@@ -173,12 +173,12 @@ html.sb-hub-down body.has-sidebar > .top .hub::after, html.sb-hub-down body.has-
173
173
  .sidebar .sb-ctx.stale { opacity: .6; }
174
174
  .sidebar .lcs { order: -1; flex: 1 1 100%; min-width: 0; display: flex; flex-wrap: wrap; gap: 4px; }
175
175
  .sidebar .lc { flex: none; font-size: 11px; font-weight: 600; border-radius: 6px; padding: 0 6px; line-height: 18px; color: var(--tone); background: color-mix(in srgb, var(--tone) 14%, transparent); }
176
- .sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
176
+ .sidebar .lc-needs { --tone: var(--color-danger); } .sidebar .lc-review { --tone: var(--accent); } .sidebar .lc-progress { --tone: var(--color-notice); } .sidebar .lc-todo { --tone: var(--muted); }
177
177
  .sidebar .dot { flex: none; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--tone); }
178
178
  @keyframes sb-pulse { 50% { opacity: .35; } }
179
179
  @media (prefers-reduced-motion: no-preference) { .sidebar .dot.pulse { animation: sb-pulse 1.4s ease-in-out infinite; } }
180
180
  .sidebar .word { flex: none; font-weight: 600; color: var(--tone); }
181
- .sidebar .t-down, .sidebar .t-needs { --tone: var(--color-danger); }
181
+ .sidebar .t-down { --tone: var(--color-danger); }
182
182
  .sidebar .t-starting { --tone: var(--color-notice); }
183
183
  .sidebar .t-stopped, .sidebar .t-asleep { --tone: var(--muted); }
184
184
  .sidebar .t-working { --tone: var(--color-notice); }
@@ -236,7 +236,7 @@
236
236
  }
237
237
  el.addEventListener('click', (e) => {
238
238
  if (sending) return;
239
- if (e.target === el || e.target.closest('[data-failed-close]')) { done(); return; }
239
+ if (BoardUI.onBackdrop(e, el) || e.target.closest('[data-failed-close]')) { done(); return; }
240
240
  const dismiss = e.target.closest('[data-failed-dismiss]');
241
241
  const retry = e.target.closest('[data-failed-retry]');
242
242
  if (dismiss) {
@@ -309,7 +309,7 @@
309
309
  el.innerHTML = shortcuts();
310
310
  el.addEventListener('cancel', (e) => { e.preventDefault(); closeHelp(); });
311
311
  el.addEventListener('close', closeHelp);
312
- el.addEventListener('click', (e) => { if (e.target === el) closeHelp(); });
312
+ el.addEventListener('click', (e) => { if (BoardUI.onBackdrop(e, el)) closeHelp(); });
313
313
  help = { el, previous: document.activeElement };
314
314
  document.body.appendChild(el);
315
315
  el.showModal();