@bivy/bivy 0.19.4-staging.1 → 0.19.4-staging.2

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.
@@ -21,24 +21,19 @@ iframe { width:100%; height:100%; border:0; background:var(--bg); }
21
21
  #dock { position:fixed; left:var(--space-2); right:var(--space-2); bottom:calc(var(--space-2) + env(safe-area-inset-bottom)); z-index:var(--z-sticky); display:flex; flex-direction:column; align-items:center; gap:var(--space-2); pointer-events:none; }
22
22
  #dock > * { pointer-events:auto; }
23
23
  /* The hint sits over the app; a tap on it is meant for the app underneath. */
24
- #dock > #pointing { pointer-events:none; }
24
+ #dock > #drawing, #dock > #hint { pointer-events:none; }
25
25
  nav, #draw-bar { display:flex; flex-wrap:wrap; justify-content:center; align-items:center; gap:var(--space-1); max-width:100%; padding:var(--space-1); background:var(--surface); border:thin solid var(--line); border-radius:var(--radius-xl); box-shadow:var(--shadow-lg); }
26
26
  nav .btn, #draw-bar .btn { flex-shrink:0; border-radius:var(--radius-full); }
27
- nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accent); }
28
27
  /* One row, however narrow: a bar that wraps doubles what it covers. Past the
29
28
  edge it scrolls sideways instead. */
30
29
  nav { flex-wrap:nowrap; overflow-x:auto; scrollbar-width:none; }
31
30
  nav::-webkit-scrollbar { display:none; }
32
- /* The grip hides the bar on a tap and moves it to the other edge on a drag. */
33
- #hide { color:var(--muted); cursor:grab; touch-action:none; }
34
- #dock.dragging, #dock.dragging #hide { cursor:grabbing; }
35
31
  #dock[data-edge="top"] { top:calc(var(--space-2) + env(safe-area-inset-top)); bottom:auto; flex-direction:column-reverse; }
36
32
  #show[data-edge="top"] { top:calc(var(--space-3) + env(safe-area-inset-top)); bottom:auto; }
37
33
  #name { display:flex; flex-direction:column; min-width:0; padding:0 var(--space-2); }
38
34
  #title { font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:14em; }
39
- #stamp { font-size:var(--text-xs); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
35
+ #stamp { font-size:var(--text-xs); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:14em; }
40
36
  #stamp:empty { display:none; }
41
- #lens { flex-shrink:0; }
42
37
  .panel { width:min(560px, 100%); max-height:50vh; overflow:auto; background:var(--surface); border:thin solid var(--line); border-radius:var(--radius-lg); box-shadow:var(--shadow-lg); padding:var(--space-3); box-sizing:border-box; }
43
38
  .panel h2 { font-size:var(--text-sm); margin:0 0 var(--space-2); display:flex; align-items:center; justify-content:space-between; gap:var(--space-2); }
44
39
  .panel-actions { display:flex; justify-content:flex-end; gap:var(--space-2); margin-top:var(--space-2); flex-wrap:wrap; }
@@ -62,7 +57,7 @@ nav::-webkit-scrollbar { display:none; }
62
57
  /* The note owns the dock while editing. Only its body scrolls; actions never
63
58
  cover the text field, even when the keyboard leaves little vertical room. */
64
59
  #dock { max-height:calc(100% - var(--space-4)); }
65
- #dock:has(#draft:not([hidden])) nav, #dock:has(#draft:not([hidden])) #pointing { display:none; }
60
+ #dock:has(#draft:not([hidden])) nav, #dock:has(#draft:not([hidden])) #drawing, #dock:has(#draft:not([hidden])) #menu { display:none; }
66
61
  #draft { display:flex; flex-direction:column; max-height:70dvh; overflow:hidden; padding:0; }
67
62
  #draft-body { min-height:0; overflow:auto; padding:var(--space-3); }
68
63
  #draft .panel-actions { flex-shrink:0; margin:0; padding:var(--space-2) var(--space-3); border-top:thin solid var(--line); }
@@ -79,17 +74,29 @@ nav::-webkit-scrollbar { display:none; }
79
74
  #ink.frozen { pointer-events:none; cursor:default; }
80
75
  #ink .halo { fill:none; stroke:var(--annotate-halo); stroke-width:7; stroke-linecap:round; stroke-linejoin:round; }
81
76
  #ink .mark { fill:none; stroke:var(--annotate); stroke-width:4; stroke-linecap:round; stroke-linejoin:round; }
82
- #dock > #drawing { pointer-events:none; }
77
+ /* A saved mark keeps its place while the next one is made, and wears its
78
+ number, so a note and the thing it is about stay paired. */
79
+ #ink .saved { opacity:0.75; }
80
+ #ink .pip { fill:var(--annotate); stroke:var(--annotate-halo); stroke-width:2; }
81
+ #ink .pip-text { fill:var(--annotate-halo); font:var(--weight-semibold) 13px/1 var(--font-sans); text-anchor:middle; dominant-baseline:central; }
82
+ #mark-count { flex-shrink:0; }
83
83
  /* Hints float over the app, whose page may be any colour: back them solidly. */
84
84
  #dock > .banner.inline[data-tone="accent"] { background:color-mix(in srgb, var(--accent) 14%, var(--surface)); box-shadow:var(--shadow-sm); }
85
- #dock .btn, #dock .seg-btn, #show, #down .btn { min-block-size:var(--space-7); min-inline-size:var(--space-7); font-size:var(--text-sm); }
86
- #draw-bar .segmented { flex-shrink:0; }
85
+ #dock .btn, #show, #down .btn, #dock .menu-item { min-block-size:var(--space-7); font-size:var(--text-sm); }
86
+ #dock .btn, #show { min-inline-size:var(--space-7); }
87
+ /* The menu belongs to the pill, so it sits over it. */
88
+ #menu { align-self:center; max-width:min(320px, 100%); }
89
+ #menu kbd { flex:none; padding:0 var(--space-1); border:thin solid var(--line); border-radius:var(--radius-sm); color:var(--muted); font-family:var(--font-mono); font-size:var(--text-xs); }
90
+ /* The name is a label, not a target: one pill, three buttons at most. */
91
+ #name { pointer-events:none; }
92
+ #more { position:relative; gap:var(--space-1); }
93
+ #dock:not([data-edge="top"]) #more svg { transform:rotate(180deg); }
87
94
  #compare-draw { margin-top:var(--space-2); }
88
95
  /* Marking a Compare shot: make it big enough to draw on with a thumb. */
89
96
  #compare[data-drawing] #compare-stage img { max-height:min(60vh, 560px); }
90
97
  #compare[data-drawing] #compare-slider, #compare[data-drawing] #compare-hint, #compare[data-drawing] #compare-draw { display:none; }
91
98
  .label-narrow { display:none; }
92
- @media (max-width: 699px) { #lens { display:none; } #name { display:none; } .label-wide { display:none; } .label-narrow { display:inline; } nav { gap:0; } nav .btn { padding-inline:var(--space-2); } }
99
+ @media (max-width: 699px) { .wide-only { display:none; } .label-wide { display:none; } .label-narrow { display:inline; } #title { max-width:9em; } }
93
100
  /* "Made with Bivy": a quiet strip under a shared app, never over it. */
94
101
  #made-with { flex-shrink:0; display:flex; align-items:center; justify-content:center; gap:var(--space-1); min-height:var(--space-6); padding:0 var(--space-3) env(safe-area-inset-bottom); box-sizing:border-box; background:var(--surface); border-top:thin solid var(--line); color:var(--muted); font-size:var(--text-xs); text-decoration:none; }
95
102
  #made-with:hover, #made-with:focus-visible { color:var(--ink); }
@@ -124,27 +131,34 @@ body.badged #dock:not([data-edge="top"]) { bottom:calc(var(--space-2) + var(--sp
124
131
  <p class="muted" id="note-status" role="status"></p>
125
132
  <details id="draft-details"><summary>Preview context</summary><pre id="draft-context"></pre></details>
126
133
  </div>
127
- <div class="panel-actions"><button class="btn sm ghost" id="draft-cancel">Cancel</button><button class="btn sm primary" id="draft-add">Add to chat</button></div>
134
+ <div class="panel-actions"><button class="btn sm ghost" id="draft-cancel">Cancel</button><button class="btn sm" id="draft-more" type="button">Mark another</button><button class="btn sm primary" id="draft-add">Add to chat</button></div>
128
135
  </section>
129
- <p class="banner inline" data-tone="accent" id="pointing" role="status" hidden>Tap anything in the app to point at it. <span id="pointing-voice" hidden>Hold to point and speak. </span>Press Escape or Point again to stop.</p>
130
- <p class="banner inline" data-tone="accent" id="drawing" role="status" hidden>Circle or box what’s wrong, then Done. Two fingers scroll the page.</p>
131
- <div id="draw-bar" role="toolbar" aria-label="Drawing tools" hidden>
132
- <div class="segmented" role="radiogroup" aria-label="Mark with"><button class="seg-btn" type="button" role="radio" aria-checked="true" data-tool="pen">Pen</button><button class="seg-btn" type="button" role="radio" aria-checked="false" data-tool="box">Box</button></div>
136
+ <p class="banner inline" data-tone="accent" id="hint" role="status" hidden>Press and hold anything in the app to mark what’s wrong.</p>
137
+ <p class="banner inline" data-tone="accent" id="drawing" role="status" hidden>Tap what’s wrong, or circle it. Then Done. Two fingers scroll the page.</p>
138
+ <div id="draw-bar" role="toolbar" aria-label="Marking tools" tabindex="-1" hidden>
139
+ <span id="mark-count" class="badge" data-tone="accent" hidden></span>
133
140
  <button class="btn sm ghost" id="draw-undo" type="button" disabled>Undo</button>
134
141
  <button class="btn sm ghost" id="draw-clear" type="button" disabled>Clear</button>
135
- <button class="btn sm ghost" id="draw-cancel" type="button" aria-label="Stop drawing">✕</button>
142
+ <button class="btn sm ghost" id="draw-cancel" type="button" aria-label="Stop marking">✕</button>
136
143
  <button class="btn sm primary" id="draw-done" type="button" disabled>Done</button>
137
144
  </div>
145
+ <div class="menu" id="menu" role="menu" aria-label="Preview options" hidden>
146
+ <button class="menu-item" role="menuitem" id="mark" type="button" aria-keyshortcuts="c" disabled><span class="menu-item-label">Mark something</span><kbd>C</kbd></button>
147
+ <button class="menu-item" role="menuitemcheckbox" id="errors" type="button" aria-checked="false" disabled><span class="menu-item-label">Console</span></button>
148
+ <button class="menu-item" role="menuitemcheckbox" id="compare-btn" type="button" aria-checked="false" hidden><span class="menu-item-label">Compare with before</span></button>
149
+ <div class="menu-heading wide-only">Width</div>
150
+ <button class="menu-item wide-only" role="menuitemradio" data-lens="full" type="button" aria-checked="true"><span class="menu-item-label">Full</span></button>
151
+ <button class="menu-item wide-only" role="menuitemradio" data-lens="tablet" type="button" aria-checked="false"><span class="menu-item-label">Tablet</span></button>
152
+ <button class="menu-item wide-only" role="menuitemradio" data-lens="phone" type="button" aria-checked="false"><span class="menu-item-label">Phone</span></button>
153
+ <button class="menu-item separated" role="menuitem" id="reload" type="button" disabled><span class="menu-item-label">Reload</span></button>
154
+ <button class="menu-item" role="menuitem" id="edge" type="button"><span class="menu-item-label">Move to top</span></button>
155
+ <button class="menu-item" role="menuitem" id="hide" type="button"><span class="menu-item-label">Hide controls</span></button>
156
+ </div>
138
157
  <nav aria-label="Bivy preview controls">
139
- <button class="btn sm ghost" id="hide" aria-label="Hide Bivy controls" title="Tap to hide · Drag to move" aria-keyshortcuts="ArrowUp ArrowDown"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg></button>
140
- <button class="btn sm ghost" id="back" aria-label="Back to chat">‹ <span class="label-wide">Back to chat</span><span class="label-narrow">Chat</span></button>
158
+ <button class="btn sm ghost" id="back" aria-label="Back to chat">‹ <span class="label-wide">Chat</span></button>
141
159
  <span id="name"><span id="title">App preview</span><span id="stamp" class="muted" role="status"></span></span>
142
- <button class="btn sm ghost" id="point" aria-pressed="false" disabled>Point</button>
143
- <button class="btn sm ghost" id="draw" type="button" hidden>Draw</button>
144
- <button class="btn sm ghost" id="compare-btn" aria-pressed="false" hidden>Compare</button>
145
- <button class="btn sm ghost" id="errors" aria-pressed="false" aria-label="Console" disabled>Console <span class="badge" data-variant="solid" data-tone="danger" id="error-count" hidden></span></button>
146
- <div class="segmented" id="lens" role="radiogroup" aria-label="Preview width"><button class="seg-btn" role="radio" aria-selected="true" data-lens="full">Full</button><button class="seg-btn" role="radio" aria-selected="false" data-lens="tablet">Tablet</button><button class="seg-btn" role="radio" aria-selected="false" data-lens="phone">Phone</button></div>
147
- <button class="btn sm ghost" id="reload" aria-label="Reload" disabled><span class="label-wide">Reload</span><span class="label-narrow" aria-hidden="true">↻</span></button>
160
+ <button class="btn sm primary" id="update" type="button" hidden><span class="label-wide">Show new version</span><span class="label-narrow">New</span></button>
161
+ <button class="btn sm ghost" id="more" type="button" aria-haspopup="menu" aria-expanded="false" aria-label="Preview options"><span class="badge" data-variant="solid" data-tone="danger" id="error-count" hidden></span><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 9l6 6 6-6"/></svg></button>
148
162
  </nav>
149
163
  </div>
150
164
  <button class="btn sm" id="show" hidden aria-label="Show Bivy controls">Bivy</button>
@@ -161,12 +175,12 @@ const embedded=parent!==window;
161
175
  const toBivy=m=>parent.postMessage(Object.assign({source:'bivy-preview'},m),location.ancestorOrigins?.[0]||new URL(metadata.returnTo).origin);
162
176
  const storageKey='bivy-preview';
163
177
  const safePath=p=>typeof p==='string'&&p.startsWith('/')&&!p.startsWith('//')&&p.length<=2048?p:'/';
164
- const reviewerTools=ready=>{if(metadata?.reviewer)for(const id of ['point','draw','errors'])$(id).disabled=!ready;};
178
+ const reviewerTools=ready=>{if(metadata?.reviewer)for(const id of ['mark','errors'])$(id).disabled=!ready;};
165
179
  const open=(path,message)=>{status.hidden=false;status.textContent=message;resetConsole();reviewerTools(false);frame.src=metadata.origin+safePath(path);};
166
180
  function show(data,launch){
167
181
  metadata=data;
168
182
  if(data.reviewer){
169
- drawOk=data.inspect!==false;$('draw').hidden=!drawOk;back.hidden=true;
183
+ drawOk=data.inspect!==false;back.hidden=true;
170
184
  $('draft-title').textContent='Note for the app’s owner';
171
185
  $('draft-text').maxLength=1000;
172
186
  $('send-errors').textContent='Leave a note…';
@@ -181,41 +195,87 @@ function show(data,launch){
181
195
  back.title=data.returnTo?'Return to '+new URL(data.returnTo).host:'Close preview';
182
196
  frame.title=data.name;
183
197
  frame.hidden=false;
184
- // Desktop apps have no page to inspect: no Point or Console. Their viewer
198
+ // Desktop apps have no page to inspect, so no Console. Their viewer
185
199
  // (Bivy's own code) may use the clipboard; generated web apps may not.
186
200
  // (Set before navigating: the policy is fixed when the frame loads.)
187
- for(const b of [$('point'),$('errors')])b.hidden=data.inspect===false;
201
+ $('errors').hidden=data.inspect===false;
188
202
  if(data.inspect===false)frame.allow='clipboard-read; clipboard-write';
189
203
  frame.src=data.origin+(launch?'/__bivy/open#'+(embedded?'e:':'')+launch+(startPage?'~'+startPage:''):'/');
190
204
  back.hidden=Boolean(data.reviewer)||(embedded&&Boolean(data.returnTo));
191
- for(const b of [reload,$('point'),$('errors')])b.disabled=false;
205
+ for(const b of [reload,$('errors'),markBtn])b.disabled=false;
192
206
  reviewerTools(false);
193
207
  status.textContent='Loading app…';
194
- frame.onload=()=>{status.hidden=true;void loadCompare();if(revision===null){revision=-1;watch();}else if(wake)wake();};
208
+ frame.onload=()=>{
209
+ status.hidden=true;void loadCompare();
210
+ // Put them back where they were before an update they asked for.
211
+ if(restore){const at=restore;restore=null;frame.contentWindow?.postMessage(Object.assign({type:'bivy:restore'},at),metadata.origin);}
212
+ if(!watching){watching=true;latest=-1;shown=null;watch();firstHint();}else if(wake)wake();
213
+ };
195
214
  // A Bivy client framing the shell says whether it can take dictation.
196
215
  if(embedded&&data.returnTo)toBivy({type:'hello'});
197
216
  // Store navigation metadata only, never tickets or cookies.
198
217
  try{sessionStorage.setItem(storageKey,JSON.stringify(data));}catch{}
199
218
  }
200
- // Reload when an agent turn changes files, returning to the page in view.
219
+ // The marking gesture is the whole point of a preview, and a gesture nobody is
220
+ // told about is a gesture nobody uses. Say it once per device, then never again.
221
+ const hintKey='bivy-preview-hint';
222
+ function firstHint(){
223
+ // A desktop app has no page to press on: its marking is the menu's, so the
224
+ // hint would promise a gesture that isn't there.
225
+ if(metadata.controls===false||metadata.inspect===false)return;
226
+ try{if(localStorage.getItem(hintKey))return;localStorage.setItem(hintKey,'1');}catch{return;}
227
+ $('hint').hidden=false;
228
+ setTimeout(()=>{$('hint').hidden=true;},8000);
229
+ }
230
+ // An agent turn changes files: never reload the app under the person looking
231
+ // at it — a scroll position, a filled-in form or an open menu would go with it.
232
+ // "shown" is the revision the frame is displaying, "latest" the newest the node
233
+ // reports; while they differ the controls offer the new version, and taking it
234
+ // is the user's tap.
201
235
  // Until the frame has redeemed its access cookie, polls fail; back off, and
202
236
  // let the next frame load retry at once.
203
- let revision=null,retry=0,wake=null;
237
+ let shown=null,latest=null,watching=false,retry=0,wake=null,restore=null,newPath='/';
204
238
  async function watch(){
205
239
  wake=null;
206
240
  try{
207
- const r=await fetch(metadata.origin+'/__bivy/revision?after='+revision,{credentials:'include',cache:'no-store'});
241
+ const r=await fetch(metadata.origin+'/__bivy/revision?after='+latest,{credentials:'include',cache:'no-store'});
208
242
  if(!r.ok)throw Error();
209
243
  const d=await r.json();
210
- if(revision>=0&&d.revision!==revision){
211
- open(currentPath!=='/'?currentPath:d.path,'Updating…');
212
- $('stamp').textContent='Updated after the agent’s turn';
213
- // Screenshots for Compare land a few seconds after the change.
214
- for(const ms of [5000,15000])setTimeout(()=>void loadCompare(),ms);
215
- }
216
- revision=d.revision;retry=0;setTimeout(watch,0);
244
+ latest=d.revision;newPath=safePath(d.path);
245
+ // The first poll after a load describes what the frame already has.
246
+ if(shown===null)shown=latest;
247
+ waiting(latest!==shown);
248
+ retry=0;setTimeout(watch,0);
217
249
  }catch{retry=Math.min(30000,(retry||1000)*2);const t=setTimeout(watch,retry);wake=()=>{clearTimeout(t);watch();};}
218
250
  }
251
+ /** Offers the newer version, or takes the offer away once it is on screen.
252
+ * Only a change writes the stamp, which other statuses also use. */
253
+ function waiting(on){
254
+ if($('update').hidden===!on)return;
255
+ $('update').hidden=!on;
256
+ $('stamp').textContent=on?'New version ready':'';
257
+ }
258
+ /** Asks the page where it is, so the update can put it back. Without an
259
+ * inspector (a desktop app) there is nothing to ask. */
260
+ function pageState(){
261
+ if(metadata.inspect===false)return Promise.resolve(null);
262
+ return new Promise(resolve=>{
263
+ const done=d=>{clearTimeout(timer);if(stateWaiter===done)stateWaiter=null;resolve(d||null);};
264
+ const timer=setTimeout(()=>done(null),500);
265
+ stateWaiter=done;
266
+ frame.contentWindow?.postMessage({type:'bivy:draw'},metadata.origin);
267
+ });
268
+ }
269
+ let stateWaiter=null;
270
+ /** Take the version the agent just built, on the page in view. */
271
+ async function takeUpdate(){
272
+ const at=await pageState();
273
+ restore=at&&{scroll:at.scroll,elementScrolls:at.elementScrolls};
274
+ shown=latest;waiting(false);
275
+ open(currentPath!=='/'?currentPath:newPath,'Updating…');
276
+ // Screenshots for Compare land a few seconds after the change.
277
+ for(const ms of [5000,15000])setTimeout(()=>void loadCompare(),ms);
278
+ }
219
279
  // Owner drafts go to the session's composer. Reviewer drafts use only the
220
280
  // bounded notes endpoint, never the owner's command channel.
221
281
  function toChat(text){
@@ -223,14 +283,19 @@ function toChat(text){
223
283
  const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
224
284
  location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
225
285
  }
226
- let noteMark=null,pendingNote=null,noteSequence=0;
286
+ let pendingNote=null,noteSequence=0,pending=null;
227
287
  function draft(context,listen){
228
- panels(null);noteMark=null;
288
+ panels(null);
229
289
  $('draft-add').disabled=false;
230
290
  $('note-status').textContent='';
231
291
  $('draft-hint').textContent=metadata.reviewer?'Feedback only — no agent runs. Pictures are approximate; if screenshots are off, we save text and mark details.':'Review in chat before sending.';
232
292
  $('draft').hidden=false;
233
- $('draft-add').textContent=metadata.reviewer?'Send note':metadata.returnTo?'Add to chat':'Copy';
293
+ // Several marks, several notes: each keeps its own words and its own number.
294
+ // A reviewer sends one note at a time, as the notes endpoint takes them.
295
+ $('draft-more').hidden=!pending||Boolean(metadata.reviewer);
296
+ $('draft-title').textContent=metadata.reviewer?'Note for the app’s owner':pending?'Note '+pending.n:'Note for the agent';
297
+ const waiting=notes.length+(pending?1:0);
298
+ $('draft-add').textContent=metadata.reviewer?'Send note':metadata.returnTo?(waiting>1?'Add '+waiting+' notes to chat':'Add to chat'):'Copy';
234
299
  $('draft-context').textContent=context;
235
300
  $('draft-details').open=false;
236
301
  const box=$('draft-text');box.value='';
@@ -264,29 +329,71 @@ mic.onpointerdown=e=>{
264
329
  mic.onpointerup=mic.onpointercancel=()=>{if(heldAt&&Date.now()-heldAt>400)stopListening();heldAt=0;};
265
330
  mic.onclick=e=>{if(e.detail===0){if(listening)stopListening();else startListening();}};
266
331
  function fromBivy(d){
267
- if(d.type==='draw'){drawOk=d.available===true;$('draw').hidden=!drawOk;$('compare-draw').hidden=!drawOk;}
268
- else if(d.type==='voice'){voice=d.available===true;mic.hidden=!voice;$('pointing-voice').hidden=!voice;if(!voice)cancelListening();}
332
+ if(d.type==='draw'){drawOk=d.available===true;$('compare-draw').hidden=!drawOk;}
333
+ else if(d.type==='voice'){voice=d.available===true;mic.hidden=!voice;if(!voice)cancelListening();}
269
334
  else if(d.type==='transcript'){setListening(false);if(typeof d.error==='string')sayVoice(d.error.slice(0,200));else addSpoken(d.text);}
270
335
  else if(d.type==='listening'&&d.on===false&&listening){setListening(false);if(voiceStatus.textContent.startsWith('Listening'))sayVoice('');}
271
336
  }
272
- $('draft-cancel').onclick=()=>{const target=marks?$('draw'):point;cancelListening();$('draft').hidden=true;endDraw();target.focus();};
337
+ /** Saves the words for the mark in hand, and hands back the mark layer. */
338
+ function keepNote(){
339
+ if(!pending)return false;
340
+ notes.push({...pending,words:$('draft-text').value.trim().slice(0,2000)});
341
+ pending=null;
342
+ return true;
343
+ }
344
+ /** Back to marking with the notes so far still on the page. */
345
+ function resumeMarking(){
346
+ const target=draw?.target??'frame',seed=draw?.state&&{scroll:draw.scroll,viewport:draw.state.viewport,dpr:draw.state.dpr,theme:draw.state.theme,path:draw.state.path,elementScrolls:draw.state.elementScrolls,signals:draw.state.signals};
347
+ const keep=notes;
348
+ cancelListening();
349
+ $('draft').hidden=true;
350
+ draw=null;notes=[];
351
+ startDraw(target,seed);
352
+ notes=keep;
353
+ updateDrawBar();renderInk();
354
+ }
355
+ $('draft-more').onclick=()=>{if(keepNote())resumeMarking();};
356
+ // Cancelling drops the mark in hand; the notes before it are still yours.
357
+ $('draft-cancel').onclick=()=>{
358
+ cancelListening();pending=null;
359
+ $('draft').hidden=true;
360
+ if(notes.length)resumeMarking();else{endDraw();$('more').focus();}
361
+ };
362
+ /** What goes to the machine: the page's state, every stroke, and which note
363
+ * each stroke belongs to, so the picture can be numbered and each note can
364
+ * become a pin of its own. */
365
+ function markPayload(d,list){
366
+ const st=d.state,first=list[0]?.els?.[0];
367
+ return Object.assign({path:st.path,viewport:st.viewport,dpr:st.dpr,signals:st.signals,
368
+ strokes:list.flatMap(note=>note.strokes),
369
+ notes:list.map(note=>({n:note.n,words:note.words||'',selectors:note.selectors,strokes:note.strokes})),
370
+ selector:first?.selector||'',text:first?.text||''},
371
+ d.target==='frame'?{scroll:d.scroll,elementScrolls:st.elementScrolls,theme:st.theme}:{compare:compareAfter});
372
+ }
373
+ /** The notes as words: where they are, once, then one numbered entry each. */
374
+ function composeText(d,list){
375
+ return placeContext(d)+':\\n\\n'+list.map(note=>note.n+'. '+(note.words?'“'+note.words+'”\\n ':'')+note.context.replace(/\\n/g,'\\n ')).join('\\n\\n');
376
+ }
273
377
  $('draft-add').onclick=async()=>{
274
378
  cancelListening();
275
- const note=$('draft-text').value.trim();
379
+ const words=$('draft-text').value.trim();
380
+ const d=draw;
276
381
  if(metadata.reviewer){
277
- if(!note){$('note-status').textContent='Write a note before sending.';$('draft-text').focus();return;}
278
- const mark=marks||noteMark,id=++noteSequence;
382
+ if(!words){$('note-status').textContent='Write a note before sending.';$('draft-text').focus();return;}
383
+ const mark=pending&&d?markPayload(d,[{...pending,words}]):null,id=++noteSequence;
279
384
  $('draft-add').disabled=true;$('draft-cancel').disabled=true;$('draft-text').disabled=true;
280
385
  $('note-status').textContent='Sending note…';$('draft-add').textContent='Sending…';
281
386
  const timer=setTimeout(()=>finishNote({id,error:'No reply yet. Your note may have arrived; check the connection before retrying.'}),60000);
282
387
  pendingNote={id,timer};
283
- frame.contentWindow?.postMessage({type:'bivy:note',id,note:{note,context:$('draft-context').textContent,selector:mark?.selector||'',text:mark?.text||'',path:mark?.path||currentPath,viewport:mark?.viewport||{width:frame.clientWidth,height:frame.clientHeight},...(mark?{mark}:{})}},metadata.origin);
388
+ frame.contentWindow?.postMessage({type:'bivy:note',id,note:{note:words,context:$('draft-context').textContent,selector:mark?.selector||'',text:mark?.text||'',path:mark?.path||currentPath,viewport:mark?.viewport||{width:frame.clientWidth,height:frame.clientHeight},...(mark?{mark}:{})}},metadata.origin);
284
389
  return;
285
390
  }
286
- const text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
287
- // Marks go to the Bivy client, which adds a picture made on the machine.
288
- if(marks){const mark=marks;$('draft').hidden=true;endDraw();return toBivy({type:'annotation',text,mark});}
289
- if(metadata.returnTo){$('draft').hidden=true;return toChat(text);}
391
+ keepNote();
392
+ const list=notes;
393
+ const text=list.length&&d?composeText(d,list):(words?words+'\\n\\n':'')+$('draft-context').textContent;
394
+ // Marks go to the Bivy client, which adds a numbered picture from the machine.
395
+ if(list.length&&d&&embedded){const mark=markPayload(d,list);$('draft').hidden=true;endDraw();return toBivy({type:'annotation',text,mark});}
396
+ if(metadata.returnTo){$('draft').hidden=true;endDraw();return toChat(text);}
290
397
  try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
291
398
  };
292
399
  $('draft-text').addEventListener('input',()=>{if(!pendingNote)$('note-status').textContent='';});
@@ -295,10 +402,10 @@ function finishNote(d){
295
402
  clearTimeout(pendingNote.timer);pendingNote=null;
296
403
  $('draft-cancel').disabled=false;$('draft-add').disabled=false;$('draft-text').disabled=false;$('draft-add').textContent='Send note';
297
404
  if(d.error){$('note-status').textContent=String(d.error).slice(0,200);return;}
298
- $('draft').hidden=true;endDraw();noteMark=null;
405
+ $('draft').hidden=true;endDraw();
299
406
  $('stamp').textContent=d.screenshot?'Note and approximate picture sent':'Note sent without a picture';
300
407
  status.hidden=false;status.textContent=$('stamp').textContent+'. The app’s owner will see it.';
301
- point.focus();
408
+ $('more').focus();
302
409
  }
303
410
  // Console
304
411
  let entries=[];
@@ -306,48 +413,55 @@ function resetConsole(){entries=[];renderConsole();}
306
413
  function renderConsole(){
307
414
  const errors=entries.filter(e=>e.level==='error').length,count=$('error-count');
308
415
  count.hidden=!errors;count.textContent=String(errors);
309
- $('errors').setAttribute('aria-label',errors?'Console, '+errors+' error'+(errors===1?'':'s'):'Console');
416
+ // The number is shown once, on the pill, so it is visible without opening it.
417
+ $('more').setAttribute('aria-label',errors?'Preview options, '+errors+' error'+(errors===1?'':'s'):'Preview options');
310
418
  $('console-empty').hidden=entries.length>0;$('send-errors').disabled=!entries.length;
311
419
  $('entries').replaceChildren(...entries.map(e=>{const li=document.createElement('li');const tag=document.createElement('span');tag.className='badge';tag.dataset.tone=e.level==='error'?'danger':'warn';tag.textContent=e.level;const text=document.createElement('span');text.textContent=e.text;li.append(tag,text);return li;}));
312
420
  }
313
421
  $('errors').onclick=()=>panels($('console').hidden?'console':null);
314
422
  $('clear').onclick=resetConsole;
315
423
  $('send-errors').onclick=()=>draft('Console output in the app preview "'+metadata.name+'" (page '+currentPath+'):\\n'+entries.slice(-20).map(e=>'- '+e.level+': '+e.text).join('\\n'));
316
- // Point and tell
317
- const point=$('point');
318
- // Pointing again starts a new draft: panels over the app step aside.
319
- function pointing(on){if(on)panels(null);point.setAttribute('aria-pressed',String(on));$('pointing').hidden=!on;frame.contentWindow?.postMessage({type:'bivy:point',on},metadata.origin);}
320
- point.onclick=()=>pointing(point.getAttribute('aria-pressed')!=='true');
321
- function picked(d){
322
- pointing(false);
323
- if(d.cancelled)return;
324
- // A long press: the draft opens listening, and letting go stops.
325
- if(d.hold&&voice)heldAt=Date.now();
326
- const r=d.rect||{},v=d.viewport||{};
327
- const errors=entries.filter(e=>e.level==='error').slice(-5);
328
- draft('In the app preview "'+metadata.name+'" (page '+safePath(d.path)+', viewport '+v.width+'×'+v.height+'):\\n'
329
- +'Element: '+String(d.selector).slice(0,300)+(d.text?' ("'+String(d.text).slice(0,200)+'")':'')+', '+r.width+'×'+r.height+' at '+r.x+','+r.y
330
- +(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''),d.hold===true&&voice);
331
- if(metadata.reviewer){const s=pos(d.scroll);noteMark={path:safePath(d.path),viewport:v,scroll:s,theme:d.theme,selector:String(d.selector).slice(0,300),text:String(d.text||'').slice(0,200),strokes:[{tool:'box',points:[[r.x+s.x,r.y+s.y],[r.x+r.width+s.x,r.y+r.height+s.y]]}]};}
332
- }
333
- // Draw: freeze the app and mark what's wrong. Marks are kept in page
334
- // coordinates (so they stay on the content while two fingers scroll), and
335
- // the app reports what's under each one. On Done they go into the draft box
336
- // with that context; Add to chat hands them to the Bivy client, which gets a
337
- // picture from the machine. Reviewers instead submit marks with their note;
338
- // pictures stay on the machine for the owner. Compare's "after" shot can be
339
- // marked the same way by the owner.
424
+ // Marking: one gesture, two results. Pointing at an element and circling an
425
+ // area were two modes with a button each; they are one mode now, because they
426
+ // are one intention — "this, here".
427
+ //
428
+ // Tap the app reaches the app, unchanged
429
+ // Long press, lift without moving marks that element and opens the note
430
+ // (listening, when voice is available)
431
+ // Long press, then drag draws a lasso and stays in marking
432
+ // Mark (or the C key) enters marking with nothing marked yet
433
+ //
434
+ // Marks are kept in page coordinates (so they stay on the content while two
435
+ // fingers scroll), and the app reports what is under each one. On Done they go
436
+ // into the draft box with that context; Add to chat hands them to the Bivy
437
+ // client, which gets a picture from the machine. Reviewers instead submit marks
438
+ // with their note; pictures stay on the machine for the owner. Compare's "after"
439
+ // shot can be marked the same way by the owner.
440
+ const markBtn=$('mark');
441
+ markBtn.onclick=()=>{if(draw&&!draw.done)endDraw();else startDraw('frame');};
340
442
  const ink=$('ink'),inkMarks=$('ink-marks'),SVG='http://www.w3.org/2000/svg';
341
- let draw=null,marks=null,drawOk=false,nextStroke=0;
443
+ // One mark, one note, one number. Several marks used to collapse into a single
444
+ // note, so "the button is too small" and "the total is misaligned" arrived as
445
+ // one lump that could only be answered as a lump. Each mark now carries its own
446
+ // words and becomes its own pin, and the picture wears the numbers so a note and
447
+ // the thing it is about stay paired.
448
+ let draw=null,notes=[],drawOk=false,nextStroke=0,speakNext=false;
342
449
  const pos=v=>({x:Number(v?.x)||0,y:Number(v?.y)||0});
343
- function startDraw(target){
344
- endDraw();if(point.getAttribute('aria-pressed')==='true')pointing(false);
450
+ /** Applies a page's report of where it is and what state it holds. */
451
+ function applyState(current,d){
452
+ current.scroll=pos(d.scroll);
453
+ current.state={viewport:{width:Math.round(Number(d.viewport?.width))||current.state.viewport.width,height:Math.round(Number(d.viewport?.height))||current.state.viewport.height},dpr:Number(d.dpr)||devicePixelRatio,theme:d.theme==='dark'?'dark':'light',path:safePath(d.path),elementScrolls:d.elementScrolls,signals:Object.fromEntries(Object.entries(d.signals||{}).map(([k,v])=>[k,v===true]))};
454
+ }
455
+ /** "seed": the page's state, when a long press in the app brought it along. */
456
+ function startDraw(target,seed){
457
+ endDraw();
345
458
  if(target==='frame')panels(null);
459
+ $('hint').hidden=true;
346
460
  if(target==='compare'){$('compare-slider').value='0';split();$('compare').dataset.drawing='';}
347
461
  // Tools first: they change the layout the marks are measured against.
348
462
  $('drawing').hidden=false;$('draw-bar').hidden=false;document.querySelector('nav').hidden=true;
349
463
  const r=placeInk(target);
350
- draw={target,rect:r,scroll:{x:0,y:0},strokes:[],elements:{},tool:'pen',current:null,pointers:new Map(),pan:null,done:false,
464
+ draw={target,rect:r,scroll:{x:0,y:0},strokes:[],elements:{},current:null,pointers:new Map(),pan:null,done:false,
351
465
  state:{viewport:{width:Math.round(r.width),height:Math.round(r.height)},dpr:devicePixelRatio,path:currentPath,signals:{}}};
352
466
  ink.style.zIndex=target==='compare'?'calc(var(--z-sticky) + 1)':'';
353
467
  ink.classList.remove('frozen');ink.toggleAttribute('hidden',false);
@@ -356,17 +470,67 @@ function startDraw(target){
356
470
  if(target==='frame'&&metadata.inspect!==false){
357
471
  const current=draw;
358
472
  current.state.signals={unknown:true};
359
- current.waiting=d=>{current.waiting=null;current.scroll=pos(d.scroll);current.state={viewport:{width:Math.round(Number(d.viewport?.width))||current.state.viewport.width,height:Math.round(Number(d.viewport?.height))||current.state.viewport.height},dpr:Number(d.dpr)||devicePixelRatio,theme:d.theme==='dark'?'dark':'light',path:safePath(d.path),elementScrolls:d.elementScrolls,signals:Object.fromEntries(Object.entries(d.signals||{}).map(([k,v])=>[k,v===true]))};renderInk();};
360
- frame.contentWindow?.postMessage({type:'bivy:draw'},metadata.origin);
473
+ if(seed)applyState(current,seed);
474
+ else{
475
+ current.waiting=d=>{current.waiting=null;applyState(current,d);renderInk();};
476
+ frame.contentWindow?.postMessage({type:'bivy:draw'},metadata.origin);
477
+ }
361
478
  }
362
- for(const b of $('draw-bar').querySelectorAll('[data-tool]'))b.setAttribute('aria-checked',String(b.dataset.tool===draw.tool));
363
- $('draw-bar').querySelector('[aria-checked="true"]').focus();
479
+ space=draw;
480
+ $('draw-bar').focus();
364
481
  updateDrawBar();renderInk();
365
482
  }
366
- /** Lays the marking layer over what's marked; returns where that is. */
483
+ /** A long press inside the app opens marking with that element already marked;
484
+ * dragging on from it draws instead, and the app streams the points here
485
+ * because the gesture belongs to its document, not this one. */
486
+ function markFromPress(d){
487
+ startDraw('frame',d);
488
+ const el=d.element||{},r=el.rect||{};
489
+ const s={id:nextStroke++,tool:'pen',points:[[Math.round(Number(d.point?.[0])||0),Math.round(Number(d.point?.[1])||0)]],live:true};
490
+ draw.strokes.push(s);draw.live=s;
491
+ if(el.selector)draw.elements[s.id]=[{selector:String(el.selector).slice(0,300),tag:el.tag,text:String(el.text||'').slice(0,120),rect:r}];
492
+ renderInk();updateDrawBar();
493
+ }
494
+ /** The pressing finger moved: the mark becomes the path it draws. */
495
+ function markMoved(d){
496
+ const s=draw?.live;if(!s)return;
497
+ const p=[Math.round(Number(d.point?.[0])||0),Math.round(Number(d.point?.[1])||0)];
498
+ const last=s.points[s.points.length-1];
499
+ if(Math.hypot(p[0]-last[0],p[1]-last[1])<2||s.points.length>=2000)return;
500
+ s.points.push(p);renderInk();
501
+ }
502
+ /** The finger lifted. Lifted where it landed: the element is what they meant,
503
+ * so the mark becomes its box and the note opens, listening when it can. */
504
+ function markEnded(){
505
+ const s=draw?.live;if(!s)return;
506
+ draw.live=null;delete s.live;
507
+ const xs=s.points.map(p=>p[0]),ys=s.points.map(p=>p[1]);
508
+ const moved=Math.max(...xs)-Math.min(...xs)>=4||Math.max(...ys)-Math.min(...ys)>=4;
509
+ if(moved){askMarked(s);renderInk();updateDrawBar();return;}
510
+ const r=draw.elements[s.id]?.[0]?.rect;
511
+ if(r&&r.width&&r.height){s.tool='box';s.points=[[r.x,r.y],[r.x+r.width,r.y+r.height]];}
512
+ else{s.tool='box';s.points=[[xs[0]-12,ys[0]-12],[xs[0]+12,ys[0]+12]];}
513
+ renderInk();updateDrawBar();
514
+ speakNext=voice;
515
+ $('draw-done').click();
516
+ }
517
+ /** Asks the page what a mark covers, while it is still on screen. */
518
+ function askMarked(s){
519
+ if(draw.target!=='frame'||metadata.inspect===false)return;
520
+ const xs=s.points.map(p=>p[0]),ys=s.points.map(p=>p[1]);
521
+ const [x,y]=onScreen([Math.min(...xs),Math.min(...ys)]);
522
+ frame.contentWindow?.postMessage({type:'bivy:marks',id:s.id,rect:{x,y,width:Math.max(...xs)-Math.min(...xs),height:Math.max(...ys)-Math.min(...ys)}},metadata.origin);
523
+ }
524
+ /** Lays the marking layer over what's marked; returns where that is. A Compare
525
+ * shot scrolls inside its panel, and its box keeps the size it would have
526
+ * unclipped: left as is, the layer would reach past the panel and swallow the
527
+ * taps meant for the tools under it. Clipping the bottom keeps the top-left
528
+ * origin the marks are measured from. */
367
529
  function placeInk(target){
368
530
  const r=(target==='compare'?$('compare-after'):frame).getBoundingClientRect();
369
- Object.assign(ink.style,{left:r.left+'px',top:r.top+'px',width:r.width+'px',height:r.height+'px'});
531
+ const limit=target==='compare'?$('compare').getBoundingClientRect().bottom:Infinity;
532
+ const height=Math.max(0,Math.min(r.height,limit-r.top));
533
+ Object.assign(ink.style,{left:r.left+'px',top:r.top+'px',width:r.width+'px',height:height+'px'});
370
534
  inkMarks.setAttribute('viewBox','0 0 '+r.width+' '+r.height);
371
535
  return {left:r.left,top:r.top,width:r.width,height:r.height};
372
536
  }
@@ -374,22 +538,50 @@ function placeInk(target){
374
538
  // the frame are in page pixels, so they follow; the size in use is kept.)
375
539
  addEventListener('resize',()=>{if(draw&&!draw.done)draw.rect=Object.assign(placeInk(draw.target),{});});
376
540
  function endDraw(){
377
- if(!draw)return;
541
+ if(!draw&&!notes.length)return;
378
542
  delete $('compare').dataset.drawing;
379
- draw=null;marks=null;ink.toggleAttribute('hidden',true);inkMarks.replaceChildren();
380
- $('drawing').hidden=true;$('draw-bar').hidden=true;document.querySelector('nav').hidden=false;
543
+ draw=null;space=null;notes=[];pending=null;ink.toggleAttribute('hidden',true);inkMarks.replaceChildren();
544
+ $('drawing').hidden=true;$('draw-bar').hidden=true;$('mark-count').hidden=true;document.querySelector('nav').hidden=false;
381
545
  }
382
- function updateDrawBar(){const has=Boolean(draw?.strokes.length);for(const id of ['draw-undo','draw-clear','draw-done'])$(id).disabled=!has;}
383
- const onScreen=([x,y])=>draw.target==='frame'?[x-draw.scroll.x,y-draw.scroll.y]:[x,y];
546
+ function updateDrawBar(){
547
+ const has=Boolean(draw?.strokes.length);
548
+ $('draw-done').disabled=!has;
549
+ $('draw-clear').disabled=!has;
550
+ // Undo walks back through this mark's strokes, then through the marks before it.
551
+ $('draw-undo').disabled=!has&&!notes.length;
552
+ $('draw-undo').setAttribute('aria-label',has||!notes.length?'Undo':'Remove note '+notes.length);
553
+ $('mark-count').hidden=!notes.length;
554
+ $('mark-count').textContent=notes.length===1?'1 note':notes.length+' notes';
555
+ }
556
+ // Page pixels to screen pixels. Saved marks are drawn between marks, when
557
+ // there is no mark in hand, so the page's position is kept beside them.
558
+ let space=null;
559
+ const onScreen=([x,y])=>{const d=draw||space;return d&&d.target==='frame'?[x-d.scroll.x,y-d.scroll.y]:[x,y];};
384
560
  function shape(stroke){
385
561
  const pts=stroke.points.map(onScreen);
386
562
  if(stroke.tool==='box'){const [a,b]=[pts[0],pts[pts.length-1]];const el=document.createElementNS(SVG,'rect');el.setAttribute('x',Math.min(a[0],b[0]));el.setAttribute('y',Math.min(a[1],b[1]));el.setAttribute('width',Math.abs(b[0]-a[0]));el.setAttribute('height',Math.abs(b[1]-a[1]));el.setAttribute('rx','3');return el;}
387
563
  const el=document.createElementNS(SVG,'polyline');el.setAttribute('points',pts.map(p=>p.join(',')).join(' '));return el;
388
564
  }
565
+ /** The number a saved mark wears, at the top-left of what it covers. */
566
+ function pip(note){
567
+ const xs=note.strokes.flatMap(s=>s.points.map(p=>p[0])),ys=note.strokes.flatMap(s=>s.points.map(p=>p[1]));
568
+ const [x,y]=onScreen([Math.min(...xs),Math.min(...ys)]);
569
+ const g=document.createElementNS(SVG,'g');
570
+ const disc=document.createElementNS(SVG,'circle');
571
+ disc.setAttribute('cx',x);disc.setAttribute('cy',y);disc.setAttribute('r','11');disc.setAttribute('class','pip');
572
+ const text=document.createElementNS(SVG,'text');
573
+ text.setAttribute('x',x);text.setAttribute('y',y);text.setAttribute('class','pip-text');text.textContent=String(note.n);
574
+ g.append(disc,text);
575
+ return g;
576
+ }
389
577
  function renderInk(){
390
- if(!draw)return;
391
- const all=[...draw.strokes,...(draw.current?[draw.current]:[])];
392
- inkMarks.replaceChildren(...all.flatMap(s=>['halo','mark'].map(c=>{const el=shape(s);el.setAttribute('class',c);return el;})));
578
+ if(!draw&&!notes.length)return;
579
+ const paint=(s,saved)=>['halo','mark'].map(c=>{const el=shape(s);el.setAttribute('class',saved?c+' saved':c);return el;});
580
+ const done=notes.flatMap(note=>[...note.strokes.flatMap(s=>paint(s,true)),pip(note)]);
581
+ const live=draw?[...draw.strokes,...(draw.current?[draw.current]:[])].flatMap(s=>paint(s,false)):[];
582
+ // The mark being written about wears its number too, so the note on screen
583
+ // and the thing it is about are paired while the words are still being typed.
584
+ inkMarks.replaceChildren(...done,...live,...(pending&&notes.length?[pip(pending)]:[]));
393
585
  }
394
586
  function local(e){const x=e.clientX-draw.rect.left,y=e.clientY-draw.rect.top;return draw.target==='frame'?[Math.round(x+draw.scroll.x),Math.round(y+draw.scroll.y)]:[Math.round(x),Math.round(y)];}
395
587
  let scrollAsk=null;
@@ -404,15 +596,16 @@ ink.onpointerdown=e=>{
404
596
  draw.pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
405
597
  // A second finger scrolls instead: the stroke it interrupted is dropped.
406
598
  if(draw.pointers.size>=2){draw.current=null;draw.pan=middle();renderInk();return;}
407
- draw.current={id:nextStroke++,tool:draw.tool,points:[local(e)]};renderInk();
599
+ draw.current={id:nextStroke++,tool:'pen',points:[local(e)]};renderInk();
408
600
  };
409
601
  ink.onpointermove=e=>{
410
602
  if(!draw||!draw.pointers.has(e.pointerId))return;
411
603
  draw.pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
412
604
  if(draw.pan&&draw.pointers.size>=2){const m=middle();scrollPage(draw.pan.x-m.x,draw.pan.y-m.y);draw.pan=m;return;}
413
605
  const s=draw.current;if(!s)return;const p=local(e);
414
- if(s.tool==='box')s.points=[s.points[0],p];
415
- else{const last=s.points[s.points.length-1];if(Math.hypot(p[0]-last[0],p[1]-last[1])<2||s.points.length>=2000)return;s.points.push(p);}
606
+ const last=s.points[s.points.length-1];
607
+ if(Math.hypot(p[0]-last[0],p[1]-last[1])<2||s.points.length>=2000)return;
608
+ s.points.push(p);
416
609
  renderInk();
417
610
  };
418
611
  ink.onpointerup=ink.onpointercancel=e=>{
@@ -420,38 +613,51 @@ ink.onpointerup=ink.onpointercancel=e=>{
420
613
  const s=draw.current;draw.current=null;
421
614
  if(s&&e.type==='pointerup'){
422
615
  const xs=s.points.map(p=>p[0]),ys=s.points.map(p=>p[1]),w=Math.max(...xs)-Math.min(...xs),h=Math.max(...ys)-Math.min(...ys);
423
- if(w>=4||h>=4){
424
- draw.strokes.push(s);
425
- // Ask what's under it while it's on screen (viewport coordinates).
426
- if(draw.target==='frame'&&metadata.inspect!==false){const [x,y]=onScreen([Math.min(...xs),Math.min(...ys)]);frame.contentWindow?.postMessage({type:'bivy:marks',id:s.id,rect:{x,y,width:w,height:h}},metadata.origin);}
427
- }
616
+ draw.strokes.push(s);
617
+ // A drag is the path it drew; a tap means the element under it, whose box
618
+ // the page reports back (see "marked").
619
+ if(w>=4||h>=4)askMarked(s);
620
+ else if(draw.target==='frame'&&metadata.inspect!==false){s.fit=true;frame.contentWindow?.postMessage({type:'bivy:marks',id:s.id,rect:{x:onScreen(s.points[0])[0]-2,y:onScreen(s.points[0])[1]-2,width:4,height:4}},metadata.origin);}
621
+ else draw.strokes.pop();
428
622
  }
429
623
  updateDrawBar();renderInk();
430
624
  };
431
625
  ink.onwheel=e=>{if(!draw||draw.done)return;e.preventDefault();scrollPage(e.deltaX,e.deltaY);};
432
- for(const b of $('draw-bar').querySelectorAll('[data-tool]'))b.onclick=()=>{if(!draw)return;draw.tool=b.dataset.tool;for(const o of $('draw-bar').querySelectorAll('[data-tool]'))o.setAttribute('aria-checked',String(o===b));};
433
- $('draw-undo').onclick=()=>{if(!draw)return;const s=draw.strokes.pop();if(s)delete draw.elements[s.id];updateDrawBar();renderInk();};
626
+ $('draw-undo').onclick=()=>{
627
+ if(!draw)return;
628
+ // Back through this mark's strokes first, then through the notes before it.
629
+ const s=draw.strokes.pop();
630
+ if(s)delete draw.elements[s.id];else notes.pop();
631
+ updateDrawBar();renderInk();
632
+ };
434
633
  $('draw-clear').onclick=()=>{if(!draw)return;draw.strokes=[];draw.elements={};updateDrawBar();renderInk();};
435
634
  $('draw-cancel').onclick=()=>endDraw();
436
- $('draw').onclick=()=>startDraw('frame');
437
635
  $('compare-draw').onclick=()=>startDraw('compare');
438
- $('draw-done').onclick=()=>{
439
- if(!draw||!draw.strokes.length)return;
440
- const d=draw,st=d.state,seen=new Set(),els=[];
441
- for(const s of d.strokes)for(const el of d.elements[s.id]||[]){const key=String(el.selector);if(!seen.has(key)){seen.add(key);els.push(el);}}
636
+ /** What one mark names, and where it is, in words. */
637
+ function markContext(d,els){
442
638
  const bounds=s=>{const xs=s.points.map(p=>p[0]),ys=s.points.map(p=>p[1]);return s.tool+' '+Math.min(...xs)+','+Math.min(...ys)+'–'+Math.max(...xs)+','+Math.max(...ys);};
639
+ return (els.length?els.map(el=>'- '+String(el.selector).slice(0,300)+(el.text?' ("'+String(el.text).slice(0,120)+'")':'')).join('\\n')+'\\n':'')
640
+ +'Marks ('+(d.target==='compare'?'screenshot':'page')+' px): '+d.strokes.map(bounds).join('; ');
641
+ }
642
+ /** Where all the notes are, said once, above them. */
643
+ function placeContext(d){
644
+ const st=d.state;
443
645
  const where=d.target==='compare'?'On the Compare screenshot after the agent’s last change in "'+metadata.name+'"':metadata.inspect===false?'On the desktop app "'+metadata.name+'"':'In the app preview "'+metadata.name+'"';
444
646
  const scrolled=d.target==='frame'&&(d.scroll.x||d.scroll.y)?', scrolled to '+d.scroll.x+','+d.scroll.y:'';
445
- const context=where+' (page '+st.path+', viewport '+st.viewport.width+'×'+st.viewport.height+scrolled+'), marked:\\n'
446
- +(els.length?els.map(el=>'- '+String(el.selector).slice(0,300)+(el.text?' ("'+String(el.text).slice(0,120)+'")':'')).join('\\n')+'\\n':'')
447
- +'Marks ('+(d.target==='compare'?'screenshot':'page')+' px): '+d.strokes.map(bounds).join('; ');
448
- const mark=Object.assign({path:st.path,viewport:st.viewport,dpr:st.dpr,strokes:d.strokes.map(s=>({tool:s.tool,points:s.points})),signals:st.signals},
449
- d.target==='frame'?{scroll:d.scroll,elementScrolls:st.elementScrolls,theme:st.theme}:{compare:compareAfter});
450
- // The marks stay on screen, frozen, while you add your words.
647
+ return where+' (page '+st.path+', viewport '+st.viewport.width+'×'+st.viewport.height+scrolled+')';
648
+ }
649
+ $('draw-done').onclick=()=>{
650
+ if(!draw||!draw.strokes.length)return;
651
+ const d=draw,seen=new Set(),els=[];
652
+ for(const s of d.strokes)for(const el of d.elements[s.id]||[]){const key=String(el.selector);if(!seen.has(key)){seen.add(key);els.push(el);}}
653
+ pending={n:notes.length+1,strokes:d.strokes.map(s=>({tool:s.tool,points:s.points})),els,context:markContext(d,els),
654
+ selectors:els.map(el=>String(el.selector).slice(0,300)).slice(0,8)};
655
+ // The marks stay on screen, frozen, while the words for this one are added.
451
656
  d.done=true;draw=null;
452
- draft(context,false);
453
- draw=d;marks=mark;ink.classList.add('frozen');
454
- // Compare closed for the draft box: its marks go with it (they're in the context).
657
+ const speak=speakNext;speakNext=false;
658
+ draft(placeContext(d)+', note '+pending.n+':\\n'+pending.context,speak);
659
+ draw=d;ink.classList.add('frozen');renderInk();
660
+ // Compare closed for the note box: its marks go with it (they're in the context).
455
661
  if(d.target==='compare')ink.toggleAttribute('hidden',true);$('drawing').hidden=true;$('draw-bar').hidden=true;document.querySelector('nav').hidden=false;
456
662
  };
457
663
  // Compare: screenshots around the agent's last change (agent screenshots on).
@@ -460,7 +666,7 @@ async function loadCompare(){
460
666
  if(metadata.reviewer)return;
461
667
  try{const r=await fetch(metadata.origin+'/__bivy/compare',{credentials:'include',cache:'no-store'});if(!r.ok)return;shots=(await r.json()).shots||[];compareBtn.hidden=shots.length<2;}catch{}
462
668
  }
463
- function panels(open){if(listening)cancelListening();if(draw&&!(open==='compare'&&draw.target==='compare'))endDraw();for(const [id,btn] of [['console','errors'],['compare','compare-btn']]){$(id).hidden=id!==open;$(btn).setAttribute('aria-pressed',String(id===open));}$('draft').hidden=true;}
669
+ function panels(open){if(listening)cancelListening();if(draw&&!(open==='compare'&&draw.target==='compare'))endDraw();for(const [id,btn] of [['console','errors'],['compare','compare-btn']]){$(id).hidden=id!==open;$(btn).setAttribute('aria-checked',String(id===open));}$('draft').hidden=true;}
464
670
  compareBtn.onclick=async()=>{
465
671
  if(!$('compare').hidden)return panels(null);
466
672
  panels('compare');
@@ -477,33 +683,42 @@ compareBtn.onclick=async()=>{
477
683
  };
478
684
  const split=()=>{$('compare-before').style.clipPath='inset(0 '+(100-Number($('compare-slider').value))+'% 0 0)';};
479
685
  $('compare-slider').oninput=split;
480
- // The pill can cover an app's own bars: drag its grip to the other edge (kept
481
- // for next time), or tap it to collapse the pill to a corner button.
482
- const dock=$('dock'),grip=$('hide'),edgeKey='bivy-preview-edge';
686
+ // One pill, and one menu behind it: everything that used to be a button in a
687
+ // row of seven. The row scrolled sideways on a phone and covered the app; the
688
+ // pill holds what must always be visible — where you are, whether a newer
689
+ // version is waiting, whether the app is logging errors — and the menu holds
690
+ // the rest.
691
+ const dock=$('dock'),menu=$('menu'),more=$('more'),edgeKey='bivy-preview-edge';
483
692
  function edge(to,keep){
484
693
  dock.dataset.edge=to;$('show').dataset.edge=to;
694
+ $('edge').querySelector('.menu-item-label').textContent=to==='top'?'Move to bottom':'Move to top';
485
695
  if(keep)try{localStorage.setItem(edgeKey,to);}catch{}
486
696
  }
487
697
  try{edge(localStorage.getItem(edgeKey)==='top'?'top':'bottom');}catch{edge('bottom');}
488
- let drag=null,dragged=false;
489
- grip.onpointerdown=e=>{drag={y:e.clientY,moved:false};try{grip.setPointerCapture(e.pointerId);}catch{}};
490
- grip.onpointermove=e=>{
491
- if(!drag)return;const dy=e.clientY-drag.y;
492
- if(!drag.moved&&Math.abs(dy)<8)return;
493
- drag.moved=true;dock.classList.add('dragging');dock.style.transform='translateY('+dy+'px)';
494
- };
495
- grip.onpointerup=grip.onpointercancel=e=>{
496
- if(!drag)return;const {moved}=drag;drag=null;
497
- if(moved){const r=grip.getBoundingClientRect();edge(r.top+r.height/2<innerHeight/2?'top':'bottom',true);dragged=true;setTimeout(()=>{dragged=false;});}
498
- dock.classList.remove('dragging');dock.style.transform='';
499
- };
500
- grip.onkeydown=e=>{if(e.key==='ArrowUp'||e.key==='ArrowDown'){e.preventDefault();edge(e.key==='ArrowUp'?'top':'bottom',true);}};
501
- grip.onclick=()=>{if(dragged){dragged=false;return;}if(point.getAttribute('aria-pressed')==='true')pointing(false);dock.hidden=true;$('show').hidden=false;$('show').focus();};
502
- $('show').onclick=()=>{$('dock').hidden=false;$('show').hidden=true;$('hide').focus();};
503
- // Device lens (desktop widths)
504
- for(const b of $('lens').querySelectorAll('button'))b.onclick=()=>{
698
+ function openMenu(on){
699
+ menu.hidden=!on;more.setAttribute('aria-expanded',String(on));
700
+ if(on)menu.querySelector('.menu-item:not([hidden]):not(:disabled)')?.focus();
701
+ }
702
+ more.onclick=()=>openMenu(menu.hidden);
703
+ // Choosing anything closes the menu; the pill takes focus back.
704
+ menu.addEventListener('click',e=>{if(e.target.closest('.menu-item')){openMenu(false);more.focus();}});
705
+ // A tap outside, or Escape, closes it. (The app is in a frame, so a tap there
706
+ // never reaches this document: the menu also closes when the frame takes over.)
707
+ addEventListener('pointerdown',e=>{if(!menu.hidden&&!menu.contains(e.target)&&!more.contains(e.target))openMenu(false);},true);
708
+ // Focus moving into the app's frame (or away from the preview) closes it too:
709
+ // a tap inside the frame never reaches this document.
710
+ addEventListener('blur',()=>openMenu(false));
711
+ $('edge').onclick=()=>edge(dock.dataset.edge==='top'?'bottom':'top',true);
712
+ more.onkeydown=e=>{if(e.key==='ArrowUp'||e.key==='ArrowDown'){e.preventDefault();edge(e.key==='ArrowUp'?'top':'bottom',true);}};
713
+ /** Out of the way means out of the way: with the controls hidden, a long press
714
+ * belongs to the app again (a canvas, a map, anything with its own). */
715
+ const arm=on=>frame.contentWindow?.postMessage({type:'bivy:arm',on},metadata.origin);
716
+ $('hide').onclick=()=>{dock.hidden=true;$('show').hidden=false;$('show').focus();arm(false);};
717
+ $('show').onclick=()=>{dock.hidden=false;$('show').hidden=true;more.focus();arm(true);};
718
+ // Width (desktop only: a phone is already the width it is)
719
+ for(const b of menu.querySelectorAll('[data-lens]'))b.onclick=()=>{
505
720
  stage.dataset.lens=b.dataset.lens;
506
- for(const o of $('lens').querySelectorAll('button'))o.setAttribute('aria-selected',String(o===b));
721
+ for(const o of menu.querySelectorAll('[data-lens]'))o.setAttribute('aria-checked',String(o===b));
507
722
  };
508
723
  // Messages from the app origin: server state and inspector reports.
509
724
  const down=$('down'),downText=$('down-text'),ask=$('ask');
@@ -523,17 +738,35 @@ addEventListener('message',e=>{
523
738
  if(d.type==='note-sent'&&metadata.reviewer)finishNote(d);
524
739
  else if(d.type==='route'){currentPath=safePath(d.path);reviewerTools(true);}
525
740
  else if(d.type==='console'&&(d.level==='error'||d.level==='warn')){entries.push({level:d.level,text:String(d.text).slice(0,500)});if(entries.length>50)entries.shift();renderConsole();}
526
- else if(d.type==='picked')picked(d);
527
741
  else if(d.type==='release'&&heldAt){heldAt=0;stopListening();}
528
- else if(d.type==='draw-state'&&draw&&draw.waiting){draw.waiting(d);}
742
+ else if(d.type==='draw-state'){if(draw&&draw.waiting)draw.waiting(d);else if(stateWaiter)stateWaiter(d);}
529
743
  else if(d.type==='scrolled'&&draw&&draw.target==='frame'){draw.scroll=pos(d.scroll);renderInk();}
530
- else if(d.type==='marked'&&draw){draw.elements[Number(d.id)]=Array.isArray(d.elements)?d.elements.slice(0,8):[];}
744
+ else if(d.type==='marked'&&draw){
745
+ const els=Array.isArray(d.elements)?d.elements.slice(0,8):[];
746
+ draw.elements[Number(d.id)]=els;
747
+ // A tap: make the mark the box of what it landed on.
748
+ const s=draw.strokes.find(item=>item.id===Number(d.id));
749
+ if(s&&s.fit){
750
+ delete s.fit;const r=els[0]?.rect;
751
+ if(r&&r.width&&r.height)s.points=[[r.x,r.y],[r.x+r.width,r.y+r.height]];
752
+ else s.points=[[s.points[0][0]-12,s.points[0][1]-12],[s.points[0][0]+12,s.points[0][1]+12]];
753
+ s.tool='box';renderInk();updateDrawBar();
754
+ }
755
+ }
756
+ else if(d.type==='mark-start')markFromPress(d);
757
+ else if(d.type==='mark-move')markMoved(d);
758
+ else if(d.type==='mark-end')markEnded();
531
759
  }
532
760
  });
533
761
  ask.onclick=()=>toChat('The app preview "'+metadata.name+'" isn’t loading: nothing is answering on port '+downPort+'. Please find out why the server stopped, restart it, and tell me when it’s back.');
534
- addEventListener('keydown',e=>{if(e.key==='Escape'){if(listening)cancelListening();else if(!$('draft').hidden)$('draft-cancel').click();else if(draw&&!draw.done)endDraw();else if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
762
+ addEventListener('keydown',e=>{
763
+ // C marks something, for anyone not holding a finger on a phone.
764
+ if((e.key==='c'||e.key==='C')&&!e.metaKey&&!e.ctrlKey&&!e.altKey&&!/^(input|textarea|select)$/i.test(e.target?.localName||'')&&!markBtn.disabled&&!draw){e.preventDefault();startDraw('frame');return;}
765
+ if(e.key==='Escape'){if(!menu.hidden){openMenu(false);more.focus();}else if(listening)cancelListening();else if(!$('draft').hidden)$('draft-cancel').click();else if(draw&&!draw.done)endDraw();else panels(null);}});
535
766
  back.onclick=()=>{if(metadata?.returnTo){window.close();setTimeout(()=>location.replace(metadata.returnTo),100);}else{window.close();status.hidden=false;status.textContent='You can close this tab to return to Bivy.';}};
536
- reload.onclick=()=>{if(metadata)open(currentPath,'Reloading app…');};
767
+ // A reload fetches the newest bytes, so it settles any waiting version too.
768
+ reload.onclick=()=>{if(!metadata)return;shown=latest;waiting(false);open(currentPath,'Reloading app…');};
769
+ $('update').onclick=()=>{void takeUpdate();};
537
770
  (async()=>{
538
771
  if(ticket){
539
772
  const response=await fetch('/__bivy/launch',{method:'POST',headers:{'Content-Type':'text/plain'},body:ticket});