@bivy/bivy 0.19.3 → 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.
- package/dist/apps/annotate.js +85 -1
- package/dist/apps/inspector.js +82 -6
- package/dist/apps/preview-shell.js +385 -152
- package/dist/apps/review.js +30 -0
- package/dist/apps/screenshot.js +16 -2
- package/dist/apps/service.js +134 -10
- package/dist/apps/styles.css +5 -0
- package/dist/apps/types.js +16 -0
- package/dist/controllers/app-commands.js +4 -0
- package/dist/server.js +32 -2
- package/dist/session/event-log.js +22 -3
- package/package.json +1 -1
|
@@ -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 > #
|
|
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])) #
|
|
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
|
-
|
|
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, #
|
|
86
|
-
#
|
|
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) {
|
|
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="
|
|
130
|
-
<p class="banner inline" data-tone="accent" id="drawing" role="status" hidden>
|
|
131
|
-
<div id="draw-bar" role="toolbar" aria-label="
|
|
132
|
-
<
|
|
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
|
|
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="
|
|
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
|
|
143
|
-
<button class="btn sm ghost" id="
|
|
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 ['
|
|
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
|
|
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
|
|
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
|
-
|
|
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,$('
|
|
205
|
+
for(const b of [reload,$('errors'),markBtn])b.disabled=false;
|
|
192
206
|
reviewerTools(false);
|
|
193
207
|
status.textContent='Loading app…';
|
|
194
|
-
frame.onload=()=>{
|
|
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
|
-
//
|
|
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
|
|
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='+
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
286
|
+
let pendingNote=null,noteSequence=0,pending=null;
|
|
227
287
|
function draft(context,listen){
|
|
228
|
-
panels(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
|
-
|
|
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;$('
|
|
268
|
-
else if(d.type==='voice'){voice=d.available===true;mic.hidden=!voice
|
|
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
|
-
|
|
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
|
|
379
|
+
const words=$('draft-text').value.trim();
|
|
380
|
+
const d=draw;
|
|
276
381
|
if(metadata.reviewer){
|
|
277
|
-
if(!
|
|
278
|
-
const mark=
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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();
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
317
|
-
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
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
|
-
|
|
344
|
-
|
|
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:{},
|
|
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
|
-
|
|
360
|
-
|
|
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
|
-
|
|
363
|
-
$('draw-bar').
|
|
479
|
+
space=draw;
|
|
480
|
+
$('draw-bar').focus();
|
|
364
481
|
updateDrawBar();renderInk();
|
|
365
482
|
}
|
|
366
|
-
/**
|
|
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
|
-
|
|
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;
|
|
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(){
|
|
383
|
-
const
|
|
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
|
|
392
|
-
|
|
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&¬es.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:
|
|
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
|
-
|
|
415
|
-
|
|
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
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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-
|
|
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
|
-
//
|
|
481
|
-
//
|
|
482
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
};
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
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
|
|
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'
|
|
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){
|
|
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=>{
|
|
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
|
|
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});
|