@lovek4d/kickflip 0.6.1 → 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 (53) hide show
  1. package/CONTEXT.md +29 -17
  2. package/SKILL.md +12 -8
  3. package/bin/event-lines.mjs +1 -0
  4. package/bin/kickflip.mjs +7 -7
  5. package/bin/wait-events.mjs +2 -2
  6. package/board/agents.mjs +2 -2
  7. package/board/arch-review.mjs +1 -1
  8. package/board/bin/board.mjs +46 -32
  9. package/board/domain.mjs +7 -7
  10. package/board/finish.mjs +4 -4
  11. package/board/headless.mjs +8 -9
  12. package/board/onboarding.mjs +1 -1
  13. package/board/prototype-page.mjs +1 -1
  14. package/board/public/app-arch.js +2 -2
  15. package/board/public/app-banner.js +1 -1
  16. package/board/public/app-cards.js +13 -4
  17. package/board/public/app-lanes.js +2 -2
  18. package/board/public/app-needs.js +107 -26
  19. package/board/public/app-reader.js +52 -42
  20. package/board/public/app-threads.js +2 -2
  21. package/board/public/app.html +1 -1
  22. package/board/public/app.js +22 -12
  23. package/board/public/progress-review.js +4 -4
  24. package/board/public/prototype-kit.js +25 -11
  25. package/board/public/prototype-switcher.js +47 -32
  26. package/board/public/prototype.html +7 -4
  27. package/board/public/router.js +34 -6
  28. package/board/public/shell.js +19 -13
  29. package/board/public/sidebar.css +2 -2
  30. package/board/public/style.css +58 -11
  31. package/board/public/ui-cards.js +44 -27
  32. package/board/public/ui-enums.js +10 -1
  33. package/board/public/ui-links.js +1 -1
  34. package/board/public/ui-order.js +8 -5
  35. package/board/public/ui-progress-bar.js +6 -7
  36. package/board/public/ui-questions.js +18 -3
  37. package/board/public/ui-sidebar.js +3 -2
  38. package/board/public/ui-threads.js +10 -12
  39. package/board/public/ui.js +4 -2
  40. package/board/public/view-notes.js +1 -1
  41. package/board/public/view-questions.js +18 -2
  42. package/board/public/view-settings.js +60 -28
  43. package/board/server.mjs +19 -20
  44. package/board/stop.mjs +1 -1
  45. package/board/store.mjs +25 -25
  46. package/board/tickets.mjs +1 -1
  47. package/board/transcript.mjs +4 -2
  48. package/hub/home.mjs +1 -1
  49. package/hub/hub.mjs +6 -6
  50. package/hub/try.mjs +6 -6
  51. package/hub/upgrade.mjs +1 -1
  52. package/package.json +1 -1
  53. package/shared/tailscale.mjs +1 -1
@@ -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,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,7 +3,7 @@
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
9
  // notes {route, item | wave} /<route>/notes.html?item=<key> or ?wave=<lineup slug>, and plan.html, which the board
@@ -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);
@@ -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); }
@@ -84,11 +84,17 @@ button.chip { border: 0; }
84
84
  .sub.next { color: var(--color-notice); }
85
85
  .card-hold { color: var(--color-quiet); }
86
86
  .card-blocked, .card-action { color: var(--color-notice); }
87
- .card-ready { color: var(--color-success); }
88
- .card-staging { color: var(--color-notice); }
87
+ /* docs/adr/2026-11-11-1200-every-lane-has-phases.md: a card's phase word, toned by who acts next (ui-cards.js PHASE_LOOK). */
88
+ .card-phase.ph-quiet { color: var(--color-quiet); }
89
+ .card-phase.ph-info { color: var(--color-info); }
90
+ .card-phase.ph-notice { color: var(--color-notice); }
91
+ .card-phase.ph-success { color: var(--color-success); }
92
+ .card-phase.ph-merged { color: var(--color-merged); }
89
93
  /* The footer status of a stale card, and of one the orchestrator stalled (ui-cards.js timeLine). */
90
94
  .card-stale { color: var(--muted); }
91
95
  .card-stalled { color: var(--color-danger); }
96
+ /* A bumped-out card's footer status, and a bumped ready card's while the host holds new work (ui-cards.js timeLine). */
97
+ .card-bumped-out, .card-host-held { color: var(--muted); }
92
98
  .sub.next.idle { color: var(--muted); }
93
99
  .sub.refused { color: var(--color-danger); }
94
100
  .toast-stack { position: fixed; left: 50%; bottom: calc(var(--app-bottom, 0px) + 12px + env(safe-area-inset-bottom));
@@ -383,10 +389,14 @@ button.attach:disabled { opacity: .35; }
383
389
  /* The session's working line, "thinking", is the owner's blue with one font, size, weight and pulse on the card and in the thread. */
384
390
  .thinking { color: var(--accent); font-size: 12px; font-weight: 600; }
385
391
  .thinking .working-dot { background: currentColor; }
392
+ /* A New card's "waiting", until the orchestrator picks it up: grey, its dot still. */
393
+ .pickup-wait { color: var(--muted); font-size: 12px; font-weight: 600; }
394
+ .pickup-wait .working-dot { background: currentColor; animation: none; }
386
395
  .msg.link { padding: 0 10px; margin: 4px 0; font-size: 13px; }
387
396
  .msg.builder-status { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--color-notice); overflow-wrap: anywhere; }
388
397
  .msg.builder-status .working-dot { animation: none; }
389
398
  .msg.request { padding: 0 10px; margin: 4px 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
399
+ .msg.request.readied { color: var(--color-success); }
390
400
  .msg.agent { padding: 0 10px; margin: 4px 0; font-size: 12px; font-weight: 600; overflow-wrap: anywhere; }
391
401
  .msg.agent.started { color: var(--color-notice); } .msg.agent.finished { color: var(--color-success); }
392
402
  .msg.agent .muted { font-weight: 400; }
@@ -410,7 +420,6 @@ button.attach:disabled { opacity: .35; }
410
420
  .sub.agent.stale .working-dot { animation: none; }
411
421
  /* The one class every paused status takes, gray: the card and thread line, the header, the hub banner. */
412
422
  .paused { color: var(--muted); font-weight: 600; }
413
- .phase-bars.in-todo { --bar-fill: var(--muted); }
414
423
  .msg.calls { padding: 0 10px; margin: 4px 0; font-size: 12px; overflow-wrap: anywhere; }
415
424
  .msg.calls summary { cursor: pointer; color: var(--muted); list-style: none; }
416
425
  .msg.calls summary::-webkit-details-marker { display: none; }
@@ -583,7 +592,7 @@ main > * { transition: opacity .25s ease; }
583
592
  .view-settings .who.new { border-color: var(--accent); }
584
593
  .view-settings .add { display: flex; gap: 6px; margin-top: 8px; }
585
594
  .view-settings .add input { flex: 1; min-width: 0; }
586
- .view-settings .add.pick { flex-wrap: wrap; }
595
+ .view-settings .add.pick { flex-direction: column; }
587
596
  .view-settings .share-now { color: var(--ok); font-weight: 600; }
588
597
  .view-settings .share-level { display: flex; gap: 4px; margin-top: 6px; }
589
598
  .view-settings .share-level button { min-height: 32px; padding: 4px 12px; font-size: 13px; }
@@ -592,7 +601,11 @@ main > * { transition: opacity .25s ease; }
592
601
  .view-settings .share-reach { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 10px 0 4px; }
593
602
  .view-settings .share-reach p { flex: 1 1 220px; margin: 0; }
594
603
  .view-settings .share-reach button { flex: none; }
595
- .view-settings .pick-col { flex: 1 1 140px; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
604
+ .view-settings .login-row { display: flex; gap: 6px; }
605
+ .view-settings .login-row input { flex: 1; min-width: 0; }
606
+ .view-settings .chip { display: inline-flex; align-items: center; align-self: flex-start; gap: 6px; padding: 4px 6px 4px 12px; border-radius: 14px; border: 1px solid var(--accent); font-size: 14px; }
607
+ .view-settings .chip button { padding: 0 6px; min-height: 0; }
608
+ .view-settings .problem { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 9px 10px; border: 1px dashed var(--line); border-radius: 9px; }
596
609
  .view-settings .pick-list { max-height: 160px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
597
610
  .view-settings .pick-list button { text-align: left; }
598
611
  .view-settings .pick-list button[aria-pressed="true"] { border-color: var(--accent); }
@@ -650,7 +663,8 @@ main > * { transition: opacity .25s ease; }
650
663
  .view-questions .q details > summary { cursor: pointer; color: var(--muted); font-size: 13px; }
651
664
  .view-questions .topics { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 8px; margin-bottom: 4px; }
652
665
  .view-questions .topics button { min-width: 0; border: 1px solid var(--line); border-radius: 999px; padding: 4px 11px; font-size: 13px;
653
- background: var(--panel); color: var(--text); white-space: normal; text-align: left; overflow-wrap: anywhere; }
666
+ background: var(--panel); color: var(--text); display: inline-flex; max-width: 100%; text-align: left; }
667
+ .view-questions .topics .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
654
668
  .view-questions .topics button[aria-pressed="true"] { border-color: var(--accent); background: var(--accent-soft); }
655
669
  .view-questions .topics button.progress { border-color: var(--line); background: color-mix(in srgb, var(--color-notice) 12%, var(--panel)); }
656
670
  .view-questions .topics button.progress[aria-pressed="true"] { border-color: var(--accent); background: color-mix(in srgb, var(--color-notice) 12%, var(--accent-soft)); }
@@ -671,7 +685,7 @@ main > * { transition: opacity .25s ease; }
671
685
  .view-questions .q.resolved .opt:has(input:checked) .det { display: block; }
672
686
  .view-questions .q.resolved .opt:not(:has(input:checked)) { opacity: .6; }
673
687
  .view-questions .q.resolved .reask-line { margin-top: 10px; }
674
- .view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg);
688
+ .view-questions .bar { position: sticky; bottom: var(--app-bottom, 0px); padding: 10px 0 max(10px, env(safe-area-inset-bottom)); background: var(--bg); z-index: 2;
675
689
  display: flex; justify-content: space-between; align-items: center; gap: 8px; border-top: 1px solid var(--line); }
676
690
  .view-questions .bar .send-hint { margin-left: auto; }
677
691
  /* The + rides above the sticky Send bar rather than over its Send. */
@@ -748,6 +762,8 @@ main > * { transition: opacity .25s ease; }
748
762
  :root:has(body > .top, body.has-sidebar > :not(.sidebar) > .top) {
749
763
  --app-bottom: calc(49px + max(4px, env(safe-area-inset-bottom)));
750
764
  }
765
+ /* The nav under the bar already pads the safe area (--app-bottom), so the gap under Send stays the 10px above it. */
766
+ .view-questions .bar { padding-bottom: 10px; }
751
767
  body > .top, body.has-sidebar > :not(.sidebar) > .top {
752
768
  position: fixed; top: auto; bottom: 0; left: 0; right: 0; z-index: 7;
753
769
  border-bottom: 0; border-top: 1px solid var(--line); padding-top: 4px; padding-bottom: max(4px, env(safe-area-inset-bottom));
@@ -761,8 +777,14 @@ main > * { transition: opacity .25s ease; }
761
777
  .sheet.keyboard-sheet { --sheet-limit: max(80px, calc(var(--vh, 100dvh) + var(--kb, 0px) - max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px))) - max(12px, env(safe-area-inset-top)))); }
762
778
  }
763
779
 
764
- /* Typing, a text field focused (shell.js sets :root.typing): the floating + is disabled and the page stays as it is. */
780
+ /* Typing, a text field focused (shell.js sets :root.typing): the floating + dims and stops taking taps, and the page stays as it is. */
765
781
  :root.typing .fab { opacity: .35; pointer-events: none; }
782
+ /* On a phone the + hides while typing, so it never sits over the keyboard's field. */
783
+ @media (max-width: 699px) {
784
+ :root.typing .fab { display: none; }
785
+ /* The board page bottom bar hides too; --app-bottom stays, so the page does not move. */
786
+ :root.typing body > .top, :root.typing body.has-sidebar > :not(.sidebar) > .top { display: none; }
787
+ }
766
788
 
767
789
  .settings-modal { position: fixed; inset: max(12px, env(safe-area-inset-top)) 0 calc(12px + max(var(--kb, 0px), var(--app-bottom, 0px))); margin: auto; width: min(680px, calc(100% - 24px));
768
790
  height: fit-content; max-width: none; max-height: max(120px, calc(var(--vh, 100dvh) - max(0px, calc(var(--app-bottom, 0px) - var(--kb, 0px))) - 24px - env(safe-area-inset-top)));
@@ -876,6 +898,9 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
876
898
  .reader-row.sys, .reader-row.out { color: var(--muted); font-size: 12.5px; }
877
899
  .reader-row.out { padding-left: 28px; font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
878
900
  .reader-row.tool { font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
901
+ .reader-row.call { cursor: pointer; }
902
+ .reader-caret { display: inline-block; width: 10px; margin-right: 4px; color: var(--muted); }
903
+ .reader-out { margin: 2px 14px 4px 28px; padding: 6px 8px; border-left: 2px solid var(--line); color: var(--muted); font: 11.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
879
904
  .reader-tag { display: inline-block; margin-right: 6px; padding: 0 6px; border-radius: 6px; background: var(--chip); color: var(--accent); font-size: 11px; }
880
905
  .reader-raw, .reader-text { margin: 0; padding: 2px 14px 8px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; font: 11.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
881
906
  .reader-md { padding: 4px 16px 12px; overflow-wrap: anywhere; line-height: 1.5; }
@@ -924,13 +949,14 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
924
949
  .deck-opts { display: flex; flex-direction: column; gap: 6px; }
925
950
  .deck-opts button { text-align: left; border-radius: 10px; padding: 10px 12px; font-size: 14px; }
926
951
  .deck-opts button.rec { border-color: var(--accent); }
952
+ .deck-opts button.armed { background: var(--color-notice); border-color: var(--color-notice); color: #000; font-weight: 600; }
927
953
  .deck-opts .rec-word { color: var(--accent); font-weight: 500; font-size: 12px; margin-left: 4px; }
928
954
  .deck-opts small { display: block; color: var(--muted); font-size: 12.5px; }
929
955
  .deck-prompt { margin: 0; padding: 0; list-style: none; }
930
956
  .deck-prompt li.prompt { padding: 8px 10px; background: var(--panel); border: 1px solid var(--line); border-radius: 8px; }
931
957
  .deck-acts { display: flex; flex-wrap: wrap; gap: 8px; }
932
958
  .deck-bar { display: flex; align-items: center; gap: 10px; padding: 0 14px 10px; }
933
- .deck-dots { flex: 1; display: flex; flex-wrap: wrap; gap: 5px; }
959
+ .deck-dots { flex: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: 5px; }
934
960
  .deck-dots i { width: 7px; height: 7px; border-radius: 50%; background: var(--line); }
935
961
  .deck-dots i.on { background: var(--color-notice); }
936
962
  .deck-bar button { border-radius: 999px; padding: 5px 12px; font-size: 13px; }
@@ -949,12 +975,33 @@ button[aria-busy="true"]:is([data-card-bump], [data-bump]) svg { fill: currentCo
949
975
  .view-board .fab { right: calc(50% - var(--sidebar-w, 0px) / 2 + var(--fab-edge)); }
950
976
  body.has-sidebar { --sidebar-w: 272px; }
951
977
  html.sb-collapsed body.has-sidebar { --sidebar-w: 0px; }
952
- .needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
978
+ .needs-pane { display: block; position: sticky; top: 56px; height: calc(100vh - 56px); overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain; padding: 12px 16px 0; border-left: 1px solid var(--line); }
953
979
  .needs-pane .needs-questions main { padding: 0; max-width: none; }
954
980
  .needs-pane:has(.q-empty), .needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty) { display: flex; flex-direction: column; }
955
981
  .needs-pane .needs-questions:has(.q-empty), .needs-pane .needs-questions:has(.q-empty) main, .needs-pane #page:has(.q-empty), .needs-pane .q-empty { flex: 1; min-height: 0; }
956
982
  .needs-pane .waiting { margin: 0 0 12px; }
983
+ .needs-pane * { min-width: 0; overflow-wrap: anywhere; }
984
+ .needs-pane pre, .needs-pane code { white-space: pre-wrap; }
985
+ .needs-pane :is(.deck-opts, .row, .actions, .btns) { flex-wrap: wrap; }
986
+ /* A prototype in the pane (app-needs.js openViewer): a top bar over the framed page, whose footer ends the pane; ⤢
987
+ (.widened) lays the pane over the lanes, from the sidebar's edge. */
988
+ .needs-pane.viewing { display: flex; flex-direction: column; padding: 0; overflow: hidden; }
989
+ .needs-pane.viewing > :not(.pane-viewer) { display: none; }
990
+ .needs-pane.viewing.widened { position: fixed; top: 56px; left: var(--sidebar-w, 0px); right: 0; bottom: 0; height: auto; z-index: 7; border-left: 0; background: var(--bg); }
991
+ .pane-viewer { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
992
+ .pv-top { flex: none; display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--line); background: var(--panel); }
993
+ .pv-top .pv-grow { flex: 1; }
994
+ .pv-top button { min-height: 36px; border-radius: 8px; font-weight: 600; }
995
+ .pv-top .pv-back { border: 0; background: transparent; color: var(--text); padding: 6px 8px; }
996
+ .pv-top .pv-back:hover { background: var(--line); }
997
+ .pv-top .pv-widen { width: 36px; padding: 0; font-size: 17px; }
998
+ .pv-top .pv-widen[aria-pressed="true"] { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
999
+ .pv-top .ps-chip { border-radius: 999px; padding: 0 14px; }
1000
+ .pv-frame { flex: 1; min-height: 0; width: 100%; border: 0; display: block; background: var(--panel); }
1001
+ .pane-viewer .ps-sheet { position: absolute; left: 8px; right: 8px; top: 56px; z-index: 2; padding: 12px 14px; border: 1px solid var(--line); border-radius: 12px; background: var(--panel); box-shadow: 0 8px 30px rgba(0,0,0,.25); }
1002
+ .pane-viewer .ps-sheet h3 { margin: 0 0 6px; font-size: 14px; }
1003
+ .pane-viewer .ps-sheet button { display: flex; justify-content: space-between; gap: 12px; width: 100%; border: 0; border-top: 1px solid var(--line); border-radius: 0; background: transparent; color: var(--text); padding: 10px 2px; text-align: left; }
1004
+ .pane-viewer .ps-sheet button span + span { color: var(--muted); }
957
1005
  }
958
1006
  /* The Send bar is the last thing on its page: only the phone nav under it is left clear, so its background runs to the edge. */
959
1007
  body .view-questions main { padding-bottom: var(--app-bottom, 0px); }
960
- .msg.calls.status-fold summary .fold-log { margin-left: 6px; color: var(--accent); text-decoration: underline; }