@bivy/bivy 0.17.3-staging.24 → 0.17.3-staging.26

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.
@@ -0,0 +1,94 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright (c) 2026 Petter André Sjulstad
3
+ import { readFileSync } from "node:fs";
4
+ import { decodePng } from "./review.js";
5
+ import { encodePng } from "./rfb.js";
6
+ const MAX_STROKES = 50;
7
+ const MAX_POINTS = 10_000;
8
+ /** Validates strokes from the client (untrusted shape): bounded, finite. */
9
+ export function readStrokes(input) {
10
+ if (!Array.isArray(input) || !input.length || input.length > MAX_STROKES)
11
+ throw new Error("Draw at least one mark (at most 50).");
12
+ let total = 0;
13
+ return input.map((raw) => {
14
+ const stroke = raw;
15
+ if ((stroke?.tool !== "pen" && stroke?.tool !== "box") || !Array.isArray(stroke.points) || !stroke.points.length)
16
+ throw new Error("Invalid mark.");
17
+ total += stroke.points.length;
18
+ if (total > MAX_POINTS)
19
+ throw new Error("Too many points in the marks.");
20
+ const points = stroke.points.map((p) => {
21
+ if (!Array.isArray(p) || p.length !== 2 || !p.every((n) => typeof n === "number" && Number.isFinite(n) && Math.abs(n) < 1e6))
22
+ throw new Error("Invalid mark point.");
23
+ return [p[0], p[1]];
24
+ });
25
+ return { tool: stroke.tool, points };
26
+ });
27
+ }
28
+ /** Whether a picture retaken on the machine may not match what the user saw:
29
+ * the page kept state a fresh browser doesn't have (storage, cookies, typed
30
+ * input, an open menu or dialog, anything the user did since it loaded), or
31
+ * the retake couldn't scroll to where they were. A Compare screenshot or a
32
+ * desktop app's frame is the exact picture, so never approximate. */
33
+ export function approximate(source, signals = {}, scrolledTo = true) {
34
+ if (source === "exact")
35
+ return false;
36
+ return !scrolledTo || Object.values(signals).some(Boolean);
37
+ }
38
+ /** The annotation colours, read from the design tokens (single source). */
39
+ function tokenColor(name, fallback) {
40
+ let hex = fallback;
41
+ try {
42
+ hex = new RegExp(`--${name}:\\s*(#[0-9a-fA-F]{6})`).exec(readFileSync(new URL("./tokens.css", import.meta.url), "utf8"))?.[1] ?? fallback;
43
+ }
44
+ catch { /* fallback */ }
45
+ return [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
46
+ }
47
+ const INK = tokenColor("annotate", "#ff2d78");
48
+ const HALO = tokenColor("annotate-halo", "#ffffff");
49
+ /** Mark width in CSS pixels, and its halo on each side. */
50
+ const WIDTH = 4;
51
+ const HALO_WIDTH = 1.5;
52
+ /** Draws marks onto a PNG. `scale` maps CSS pixels to image pixels;
53
+ * `offset` (CSS px) is subtracted first — the scroll position the picture
54
+ * was taken at. Halos go under every mark first, so crossings stay clean. */
55
+ export function composite(png, strokes, map) {
56
+ const image = decodePng(png);
57
+ const { width, height, channels } = image;
58
+ const rgb = Buffer.alloc(width * height * 3);
59
+ for (let p = 0; p < width * height; p++)
60
+ image.pixels.copy(rgb, p * 3, p * channels, p * channels + 3);
61
+ const off = map.offset ?? { x: 0, y: 0 };
62
+ const toImage = ([x, y]) => [(x - off.x) * map.scale, (y - off.y) * map.scale];
63
+ const paths = strokes.map((stroke) => {
64
+ const points = stroke.points.map(toImage);
65
+ if (stroke.tool === "pen")
66
+ return points;
67
+ const [a, b] = [points[0], points[points.length - 1]];
68
+ return [a, [b[0], a[1]], b, [a[0], b[1]], a];
69
+ });
70
+ const disc = (cx, cy, r, color) => {
71
+ for (let y = Math.max(0, Math.floor(cy - r)); y <= Math.min(height - 1, Math.ceil(cy + r)); y++) {
72
+ for (let x = Math.max(0, Math.floor(cx - r)); x <= Math.min(width - 1, Math.ceil(cx + r)); x++) {
73
+ if ((x - cx) ** 2 + (y - cy) ** 2 <= r * r)
74
+ rgb.set(color, (y * width + x) * 3);
75
+ }
76
+ }
77
+ };
78
+ const trace = (path, r, color) => {
79
+ const step = Math.max(0.5, r / 2);
80
+ for (let i = 0; i < path.length; i++) {
81
+ const [x0, y0] = path[i];
82
+ const [x1, y1] = path[i + 1] ?? path[i];
83
+ const n = Math.max(1, Math.ceil(Math.hypot(x1 - x0, y1 - y0) / step));
84
+ for (let k = 0; k <= n; k++)
85
+ disc(x0 + ((x1 - x0) * k) / n, y0 + ((y1 - y0) * k) / n, r, color);
86
+ }
87
+ };
88
+ const r = (WIDTH / 2) * map.scale;
89
+ for (const path of paths)
90
+ trace(path, r + HALO_WIDTH * map.scale, HALO);
91
+ for (const path of paths)
92
+ trace(path, r, INK);
93
+ return encodePng(width, height, rgb);
94
+ }
@@ -16,7 +16,9 @@ const log=(level,text)=>post({type:'console',level,text:String(text).slice(0,500
16
16
  for(const level of ['error','warn']){const original=console[level];console[level]=function(...args){log(level,args.map(fmt).join(' '));return original.apply(this,args);};}
17
17
  addEventListener('error',e=>{if(e.message)log('error',e.message+(e.filename?' ('+e.filename.split('/').pop()+':'+e.lineno+')':''));},true);
18
18
  addEventListener('unhandledrejection',e=>log('error','Unhandled rejection: '+fmt(e.reason)));
19
- const route=()=>post({type:'route',path:location.pathname+location.search+location.hash});
19
+ // Taps and keys since this page (or route) loaded: state a retaken picture may lack.
20
+ let interacted=false;
21
+ const route=()=>{interacted=false;post({type:'route',path:location.pathname+location.search+location.hash});};
20
22
  for(const name of ['pushState','replaceState']){const original=history[name];history[name]=function(...args){const result=original.apply(this,args);route();return result;};}
21
23
  addEventListener('popstate',route);addEventListener('hashchange',route);route();
22
24
  const selector=el=>{
@@ -78,6 +80,50 @@ function startPointing(){
78
80
  document.documentElement.append(layer,box);
79
81
  addEventListener('keydown',key,true);
80
82
  }
83
+ for(const type of ['pointerdown','keydown'])addEventListener(type,e=>{if(!layer&&!(host&&e.composedPath?.().includes(host)))interacted=true;},true);
84
+ // Draw (in the shell, over this page): where the page is, what state it holds
85
+ // that a fresh browser on the machine wouldn't, and what's under each mark.
86
+ const signals=()=>{
87
+ let storage=false;try{storage=localStorage.length+sessionStorage.length>0;}catch{}
88
+ const edited=[...document.querySelectorAll('input,textarea,select')].some(el=>el.type==='checkbox'||el.type==='radio'?el.checked!==el.defaultChecked:el.localName==='select'?[...el.options].some(o=>o.selected!==o.defaultSelected):el.type!=='hidden'&&el.value!==el.defaultValue);
89
+ const open=Boolean(document.querySelector('dialog[open],details[open],[aria-expanded="true"],[aria-modal="true"]'));
90
+ return {storage,cookies:Boolean(document.cookie),interacted,open,edited};
91
+ };
92
+ const drawState=()=>({type:'draw-state',scroll:{x:Math.round(scrollX),y:Math.round(scrollY)},viewport:{width:innerWidth,height:innerHeight},dpr:devicePixelRatio,
93
+ theme:matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light',path:location.pathname+location.search+location.hash,signals:signals()});
94
+ /** Elements inside a mark (viewport rect): a grid sample, then the largest
95
+ * element under each hit that sits mostly inside the mark — circling a
96
+ * button names the button, not the bar it's in. A line through something
97
+ * falls back to what it crossed. Outermost first, at most eight. */
98
+ const marked=r=>{
99
+ const hits=new Set();
100
+ for(let i=0;i<8;i++)for(let j=0;j<8;j++){
101
+ const x=Math.min(innerWidth-1,Math.max(0,r.x+r.width*(i+0.5)/8)),y=Math.min(innerHeight-1,Math.max(0,r.y+r.height*(j+0.5)/8));
102
+ const el=document.elementsFromPoint(x,y).find(n=>n!==layer&&n!==box&&n!==host);if(el)hits.add(el);
103
+ }
104
+ const big=innerWidth*innerHeight*0.6,page=n=>n===document.body||n===document.documentElement;
105
+ // Where an element's content is: a full-width block around "Total $102"
106
+ // is judged by its text, which is what a circle goes around.
107
+ const range=document.createRange();
108
+ const rectOf=n=>{const b=n.getBoundingClientRect();if(!n.firstChild||/^(img|svg|video|canvas|input|select|textarea|button)$/.test(n.localName))return b;range.selectNodeContents(n);const c=range.getBoundingClientRect();return c.width&&c.height?c:b;};
109
+ const inside=n=>{const b=rectOf(n),a=b.width*b.height;if(!a)return 0;const w=Math.max(0,Math.min(b.right,r.x+r.width)-Math.max(b.left,r.x)),h=Math.max(0,Math.min(b.bottom,r.y+r.height)-Math.max(b.top,r.y));return w*h/a;};
110
+ const area=n=>{const b=rectOf(n);return b.width*b.height;};
111
+ const picked=new Set();
112
+ for(const hit of hits){let best=null;for(let n=hit;n&&n.nodeType===1&&!page(n);n=n.parentElement){if(inside(n)>=0.6&&area(n)<big)best=n;else if(best)break;}if(best)picked.add(best);}
113
+ if(!picked.size)for(const hit of hits)if(!page(hit)&&area(hit)<big&&(hit.innerText||'').trim())picked.add(hit);
114
+ const list=[...picked].filter(n=>![...picked].some(o=>o!==n&&o.contains(n)))
115
+ .sort((a,b)=>{const p=a.getBoundingClientRect(),q=b.getBoundingClientRect();return p.top-q.top||p.left-q.left;});
116
+ return list.slice(0,8).map(el=>{const b=el.getBoundingClientRect();return {selector:selector(el).slice(0,300),tag:el.localName,
117
+ text:(el.innerText||el.getAttribute('aria-label')||el.getAttribute('alt')||'').trim().replace(/\\s+/g,' ').slice(0,120),
118
+ rect:{x:Math.round(b.left+scrollX),y:Math.round(b.top+scrollY),width:Math.round(b.width),height:Math.round(b.height)}};});
119
+ };
120
+ addEventListener('message',e=>{
121
+ if(!framed||e.origin!==SHELL||e.source!==parent)return;
122
+ const d=e.data||{},n=v=>Number.isFinite(+v)?+v:0;
123
+ if(d.type==='bivy:draw')post(drawState());
124
+ else if(d.type==='bivy:scroll'){scrollBy({left:n(d.dx),top:n(d.dy),behavior:'instant'});post({type:'scrolled',scroll:{x:Math.round(scrollX),y:Math.round(scrollY)}});}
125
+ else if(d.type==='bivy:marks'&&d.rect)post({type:'marked',id:n(d.id),elements:marked({x:n(d.rect.x),y:n(d.rect.y),width:n(d.rect.width),height:n(d.rect.height)})});
126
+ });
81
127
  addEventListener('message',e=>{
82
128
  if(!framed||e.origin!==SHELL||e.source!==parent||e.data?.type!=='bivy:point')return;
83
129
  // Held: the layer goes when the finger lifts (see up).
@@ -22,8 +22,8 @@ iframe { width:100%; height:100%; border:0; background:var(--bg); }
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
24
  #dock > #pointing { pointer-events:none; }
25
- nav { display:flex; 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-full); box-shadow:var(--shadow-lg); }
26
- nav .btn { flex-shrink:0; border-radius:var(--radius-full); }
25
+ nav, #draw-bar { display:flex; 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-full); box-shadow:var(--shadow-lg); }
26
+ nav .btn, #draw-bar .btn { flex-shrink:0; border-radius:var(--radius-full); }
27
27
  nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accent); }
28
28
  #name { display:flex; flex-direction:column; min-width:0; padding:0 var(--space-2); }
29
29
  #title { font-size:var(--text-sm); font-weight:var(--weight-semibold); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:14em; }
@@ -55,6 +55,22 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
55
55
  #draft-hint { font-size:var(--text-xs); margin:var(--space-2) 0 var(--space-1); }
56
56
  #draft-context { margin:0; padding:var(--space-2); background:var(--surface-2); border-radius:var(--radius-md); font-family:var(--font-mono); font-size:var(--text-xs); white-space:pre-wrap; overflow-wrap:anywhere; color:var(--muted); }
57
57
  #show { position:fixed; right:var(--space-3); bottom:calc(var(--space-3) + env(safe-area-inset-bottom)); z-index:var(--z-sticky); border-radius:var(--radius-full); box-shadow:var(--shadow-lg); }
58
+ /* Draw: marks over the frozen app (or Compare's "after" shot). The layer takes
59
+ every touch, so nothing reaches the app; two fingers scroll the page. */
60
+ #ink { position:fixed; z-index:var(--z-sticky); touch-action:none; cursor:crosshair; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; }
61
+ #ink.frozen { pointer-events:none; cursor:default; }
62
+ #ink .halo { fill:none; stroke:var(--annotate-halo); stroke-width:7; stroke-linecap:round; stroke-linejoin:round; }
63
+ #ink .mark { fill:none; stroke:var(--annotate); stroke-width:4; stroke-linecap:round; stroke-linejoin:round; }
64
+ #dock > #drawing { pointer-events:none; }
65
+ /* Hints float over the app, whose page may be any colour: back them solidly. */
66
+ #dock > .banner.inline[data-tone="accent"] { background:color-mix(in srgb, var(--accent) 14%, var(--surface)); box-shadow:var(--shadow-sm); }
67
+ #draw-bar .btn { min-block-size:44px; }
68
+ #draw-bar .seg-btn { min-block-size:36px; min-inline-size:48px; }
69
+ #draw-bar .segmented { flex-shrink:0; }
70
+ #compare-draw { margin-top:var(--space-2); }
71
+ /* Marking a Compare shot: make it big enough to draw on with a thumb. */
72
+ #compare[data-drawing] #compare-stage img { max-height:min(60vh, 560px); }
73
+ #compare[data-drawing] #compare-slider, #compare[data-drawing] #compare-hint, #compare[data-drawing] #compare-draw { display:none; }
58
74
  .label-narrow { display:none; }
59
75
  @media (max-width: 699px) { #lens { display:none; } #name { display:none; } .label-wide { display:none; } .label-narrow { display:inline; } }
60
76
  [hidden] { display:none !important; }
@@ -62,6 +78,7 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
62
78
  <div class="banner" data-tone="warn" id="down" hidden><span class="banner-text" id="down-text" role="status"></span><span class="banner-actions"><button class="btn sm" id="ask" hidden>Ask agent to fix</button></span></div>
63
79
  <p id="status" role="status">Opening app…</p>
64
80
  <div id="stage" data-lens="full"><iframe id="app" title="App preview" hidden sandbox="allow-scripts allow-same-origin allow-forms allow-downloads allow-popups" referrerpolicy="no-referrer"></iframe></div>
81
+ <svg id="ink" hidden aria-hidden="true"></svg>
65
82
  <div id="dock">
66
83
  <section class="panel" id="console" hidden aria-labelledby="console-title">
67
84
  <h2><span id="console-title">Console</span><button class="btn sm ghost" id="clear">Clear</button></h2>
@@ -73,6 +90,7 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
73
90
  <h2 id="compare-title">Before and after the agent’s last change</h2>
74
91
  <div id="compare-stage"><img id="compare-after" alt="After the agent’s last change"><img id="compare-before" alt="Before the agent’s last change"><input type="range" id="compare-slider" min="0" max="100" value="50" aria-label="Show more of before or after"></div>
75
92
  <p class="muted" id="compare-hint">Left of the handle: before. Right: now. Screenshots at phone width.</p>
93
+ <button class="btn sm" id="compare-draw" type="button" hidden>Draw on “now”</button>
76
94
  </section>
77
95
  <section class="panel" id="draft" hidden aria-labelledby="draft-title">
78
96
  <h2 id="draft-title">Draft for the agent</h2>
@@ -84,15 +102,24 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
84
102
  <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>
85
103
  </section>
86
104
  <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>
105
+ <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>
106
+ <div id="draw-bar" role="toolbar" aria-label="Drawing tools" hidden>
107
+ <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>
108
+ <button class="btn sm ghost" id="draw-undo" type="button" disabled>Undo</button>
109
+ <button class="btn sm ghost" id="draw-clear" type="button" disabled>Clear</button>
110
+ <button class="btn sm ghost" id="draw-cancel" type="button" aria-label="Stop drawing">✕</button>
111
+ <button class="btn sm primary" id="draw-done" type="button" disabled>Done</button>
112
+ </div>
87
113
  <nav aria-label="Bivy preview controls">
88
114
  <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>
89
115
  <span id="name"><span id="title">App preview</span><span id="stamp" class="muted" role="status"></span></span>
90
116
  <button class="btn sm ghost" id="hide" aria-label="Hide Bivy controls" title="Hide controls">⌄</button>
91
117
  <button class="btn sm ghost" id="point" aria-pressed="false" disabled>Point</button>
118
+ <button class="btn sm ghost" id="draw" type="button" hidden>Draw</button>
92
119
  <button class="btn sm ghost" id="compare-btn" aria-pressed="false" hidden>Compare</button>
93
120
  <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>
94
121
  <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>
95
- <button class="btn sm ghost" id="reload" disabled>Reload</button>
122
+ <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>
96
123
  </nav>
97
124
  </div>
98
125
  <button class="btn sm" id="show" hidden aria-label="Show Bivy controls">Bivy</button>
@@ -192,14 +219,17 @@ mic.onpointerdown=e=>{
192
219
  mic.onpointerup=mic.onpointercancel=()=>{if(heldAt&&Date.now()-heldAt>400)stopListening();heldAt=0;};
193
220
  mic.onclick=e=>{if(e.detail===0){if(listening)stopListening();else startListening();}};
194
221
  function fromBivy(d){
195
- if(d.type==='voice'){voice=d.available===true;mic.hidden=!voice;$('pointing-voice').hidden=!voice;if(!voice)cancelListening();}
222
+ if(d.type==='draw'){drawOk=d.available===true;$('draw').hidden=!drawOk;$('compare-draw').hidden=!drawOk;}
223
+ else if(d.type==='voice'){voice=d.available===true;mic.hidden=!voice;$('pointing-voice').hidden=!voice;if(!voice)cancelListening();}
196
224
  else if(d.type==='transcript'){setListening(false);if(typeof d.error==='string')sayVoice(d.error.slice(0,200));else addSpoken(d.text);}
197
225
  else if(d.type==='listening'&&d.on===false&&listening){setListening(false);if(voiceStatus.textContent.startsWith('Listening'))sayVoice('');}
198
226
  }
199
- $('draft-cancel').onclick=()=>{cancelListening();$('draft').hidden=true;};
227
+ $('draft-cancel').onclick=()=>{cancelListening();$('draft').hidden=true;endDraw();};
200
228
  $('draft-add').onclick=async()=>{
201
229
  cancelListening();
202
230
  const note=$('draft-text').value.trim(),text=(note?note+'\\n\\n':'')+$('draft-context').textContent;
231
+ // Marks go to the Bivy client, which adds a picture made on the machine.
232
+ if(marks){const mark=marks;endDraw();return toBivy({type:'annotation',text,mark});}
203
233
  if(metadata.returnTo)return toChat(text);
204
234
  try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
205
235
  };
@@ -232,20 +262,146 @@ function picked(d){
232
262
  +'Element: '+String(d.selector).slice(0,300)+(d.text?' ("'+String(d.text).slice(0,200)+'")':'')+', '+r.width+'×'+r.height+' at '+r.x+','+r.y
233
263
  +(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''),d.hold===true&&voice);
234
264
  }
265
+ // Draw: freeze the app and mark what's wrong. Marks are kept in page
266
+ // coordinates (so they stay on the content while two fingers scroll), and
267
+ // the app reports what's under each one. On Done they go into the draft box
268
+ // with that context; Add to chat hands them to the Bivy client, which gets a
269
+ // picture from the machine. Nothing about them goes through this origin's
270
+ // network. Compare's "after" shot can be marked the same way.
271
+ const ink=$('ink'),SVG='http://www.w3.org/2000/svg';
272
+ let draw=null,marks=null,drawOk=false,nextStroke=0;
273
+ const pos=v=>({x:Number(v?.x)||0,y:Number(v?.y)||0});
274
+ function startDraw(target){
275
+ endDraw();if(point.getAttribute('aria-pressed')==='true')pointing(false);
276
+ if(target==='frame')panels(null);
277
+ if(target==='compare'){$('compare-slider').value='0';split();$('compare').dataset.drawing='';}
278
+ // Tools first: they change the layout the marks are measured against.
279
+ $('drawing').hidden=false;$('draw-bar').hidden=false;document.querySelector('nav').hidden=true;
280
+ const r=placeInk(target);
281
+ draw={target,rect:r,scroll:{x:0,y:0},strokes:[],elements:{},tool:'pen',current:null,pointers:new Map(),pan:null,done:false,
282
+ state:{viewport:{width:Math.round(r.width),height:Math.round(r.height)},dpr:devicePixelRatio,path:currentPath,signals:{}}};
283
+ ink.style.zIndex=target==='compare'?'calc(var(--z-sticky) + 1)':'';
284
+ ink.classList.remove('frozen');ink.toggleAttribute('hidden',false);
285
+ // Where the page is and what state it holds. Without an inspector (a
286
+ // desktop app, or a page whose policy kept it out) state is unknown.
287
+ if(target==='frame'&&metadata.inspect!==false){
288
+ const current=draw;
289
+ current.state.signals={unknown:true};
290
+ 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),signals:Object.fromEntries(Object.entries(d.signals||{}).map(([k,v])=>[k,v===true]))};renderInk();};
291
+ frame.contentWindow?.postMessage({type:'bivy:draw'},metadata.origin);
292
+ }
293
+ $('draw-bar').querySelector('[aria-checked="true"]').focus();
294
+ updateDrawBar();renderInk();
295
+ }
296
+ /** Lays the marking layer over what's marked; returns where that is. */
297
+ function placeInk(target){
298
+ const r=(target==='compare'?$('compare-after'):frame).getBoundingClientRect();
299
+ Object.assign(ink.style,{left:r.left+'px',top:r.top+'px',width:r.width+'px',height:r.height+'px'});
300
+ ink.setAttribute('viewBox','0 0 '+r.width+' '+r.height);
301
+ return {left:r.left,top:r.top,width:r.width,height:r.height};
302
+ }
303
+ // A rotation or a keyboard moves things: keep the layer on them. (Marks on
304
+ // the frame are in page pixels, so they follow; the size in use is kept.)
305
+ addEventListener('resize',()=>{if(draw&&!draw.done)draw.rect=Object.assign(placeInk(draw.target),{});});
306
+ function endDraw(){
307
+ if(!draw)return;
308
+ delete $('compare').dataset.drawing;
309
+ draw=null;marks=null;ink.toggleAttribute('hidden',true);ink.replaceChildren();
310
+ $('drawing').hidden=true;$('draw-bar').hidden=true;document.querySelector('nav').hidden=false;
311
+ }
312
+ function updateDrawBar(){const has=Boolean(draw?.strokes.length);for(const id of ['draw-undo','draw-clear','draw-done'])$(id).disabled=!has;}
313
+ const onScreen=([x,y])=>draw.target==='frame'?[x-draw.scroll.x,y-draw.scroll.y]:[x,y];
314
+ function shape(stroke){
315
+ const pts=stroke.points.map(onScreen);
316
+ 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;}
317
+ const el=document.createElementNS(SVG,'polyline');el.setAttribute('points',pts.map(p=>p.join(',')).join(' '));return el;
318
+ }
319
+ function renderInk(){
320
+ if(!draw)return;
321
+ const all=[...draw.strokes,...(draw.current?[draw.current]:[])];
322
+ ink.replaceChildren(...all.flatMap(s=>['halo','mark'].map(c=>{const el=shape(s);el.setAttribute('class',c);return el;})));
323
+ }
324
+ 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)];}
325
+ let scrollAsk=null;
326
+ function scrollPage(dx,dy){
327
+ if(draw?.target!=='frame'||metadata.inspect===false)return;
328
+ scrollAsk=scrollAsk?{dx:scrollAsk.dx+dx,dy:scrollAsk.dy+dy}:{dx,dy};
329
+ requestAnimationFrame(()=>{if(!scrollAsk)return;frame.contentWindow?.postMessage(Object.assign({type:'bivy:scroll'},scrollAsk),metadata.origin);scrollAsk=null;});
330
+ }
331
+ const middle=()=>{const p=[...draw.pointers.values()];return {x:(p[0].x+p[1].x)/2,y:(p[0].y+p[1].y)/2};};
332
+ ink.onpointerdown=e=>{
333
+ if(!draw||draw.done)return;e.preventDefault();try{ink.setPointerCapture(e.pointerId);}catch{}
334
+ draw.pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
335
+ // A second finger scrolls instead: the stroke it interrupted is dropped.
336
+ if(draw.pointers.size>=2){draw.current=null;draw.pan=middle();renderInk();return;}
337
+ draw.current={id:nextStroke++,tool:draw.tool,points:[local(e)]};renderInk();
338
+ };
339
+ ink.onpointermove=e=>{
340
+ if(!draw||!draw.pointers.has(e.pointerId))return;
341
+ draw.pointers.set(e.pointerId,{x:e.clientX,y:e.clientY});
342
+ 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;}
343
+ const s=draw.current;if(!s)return;const p=local(e);
344
+ if(s.tool==='box')s.points=[s.points[0],p];
345
+ 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);}
346
+ renderInk();
347
+ };
348
+ ink.onpointerup=ink.onpointercancel=e=>{
349
+ if(!draw)return;draw.pointers.delete(e.pointerId);if(draw.pointers.size<2)draw.pan=null;
350
+ const s=draw.current;draw.current=null;
351
+ if(s&&e.type==='pointerup'){
352
+ 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);
353
+ if(w>=4||h>=4){
354
+ draw.strokes.push(s);
355
+ // Ask what's under it while it's on screen (viewport coordinates).
356
+ 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);}
357
+ }
358
+ }
359
+ updateDrawBar();renderInk();
360
+ };
361
+ ink.onwheel=e=>{if(!draw||draw.done)return;e.preventDefault();scrollPage(e.deltaX,e.deltaY);};
362
+ 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));};
363
+ $('draw-undo').onclick=()=>{if(!draw)return;const s=draw.strokes.pop();if(s)delete draw.elements[s.id];updateDrawBar();renderInk();};
364
+ $('draw-clear').onclick=()=>{if(!draw)return;draw.strokes=[];draw.elements={};updateDrawBar();renderInk();};
365
+ $('draw-cancel').onclick=()=>endDraw();
366
+ $('draw').onclick=()=>startDraw('frame');
367
+ $('compare-draw').onclick=()=>startDraw('compare');
368
+ $('draw-done').onclick=()=>{
369
+ if(!draw||!draw.strokes.length)return;
370
+ const d=draw,st=d.state,seen=new Set(),els=[];
371
+ 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);}}
372
+ 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);};
373
+ 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+'"';
374
+ const scrolled=d.target==='frame'&&(d.scroll.x||d.scroll.y)?', scrolled to '+d.scroll.x+','+d.scroll.y:'';
375
+ const context=where+' (page '+st.path+', viewport '+st.viewport.width+'×'+st.viewport.height+scrolled+'), marked:\\n'
376
+ +(els.length?els.map(el=>'- '+String(el.selector).slice(0,300)+(el.text?' ("'+String(el.text).slice(0,120)+'")':'')).join('\\n')+'\\n':'')
377
+ +'Marks ('+(d.target==='compare'?'screenshot':'page')+' px): '+d.strokes.map(bounds).join('; ');
378
+ 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},
379
+ d.target==='frame'?{scroll:d.scroll,theme:st.theme}:{compare:compareAfter});
380
+ // The marks stay on screen, frozen, while you add your words.
381
+ d.done=true;draw=null;
382
+ draft(context,false);
383
+ draw=d;marks=mark;ink.classList.add('frozen');
384
+ // Compare closed for the draft box: its marks go with it (they're in the context).
385
+ if(d.target==='compare')ink.toggleAttribute('hidden',true);$('drawing').hidden=true;$('draw-bar').hidden=true;document.querySelector('nav').hidden=false;
386
+ };
235
387
  // Compare: screenshots around the agent's last change (agent screenshots on).
236
- const compareBtn=$('compare-btn');let shots=[],urls=[];
388
+ const compareBtn=$('compare-btn');let shots=[],urls=[],compareAfter=-1;
237
389
  async function loadCompare(){
238
390
  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{}
239
391
  }
240
- function panels(open){if(listening)cancelListening();for(const [id,btn] of [['console','errors'],['compare','compare-btn']]){$(id).hidden=id!==open;$(btn).setAttribute('aria-pressed',String(id===open));}$('draft').hidden=true;}
392
+ 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;}
241
393
  compareBtn.onclick=async()=>{
242
394
  if(!$('compare').hidden)return panels(null);
243
395
  panels('compare');
244
396
  for(const u of urls)URL.revokeObjectURL(u);
245
397
  const pick=[shots[shots.length-2],shots[shots.length-1]];
398
+ compareAfter=pick[1].index;
246
399
  urls=await Promise.all(pick.map(async s=>URL.createObjectURL(await (await fetch(metadata.origin+'/__bivy/compare/'+s.index,{credentials:'include'})).blob())));
247
400
  const before=$('compare-before'),after=$('compare-after');
248
401
  before.src=urls[0];after.src=urls[1];
402
+ // Drawing on "now" measures the picture, so wait until it's there.
403
+ $('compare-draw').disabled=true;
404
+ void Promise.all([before.decode(),after.decode()]).catch(()=>{}).then(()=>{$('compare-draw').disabled=false;});
249
405
  split();
250
406
  };
251
407
  const split=()=>{$('compare-before').style.clipPath='inset(0 '+(100-Number($('compare-slider').value))+'% 0 0)';};
@@ -277,10 +433,13 @@ addEventListener('message',e=>{
277
433
  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();}
278
434
  else if(d.type==='picked')picked(d);
279
435
  else if(d.type==='release'&&heldAt){heldAt=0;stopListening();}
436
+ else if(d.type==='draw-state'&&draw&&draw.waiting){draw.waiting(d);}
437
+ else if(d.type==='scrolled'&&draw&&draw.target==='frame'){draw.scroll=pos(d.scroll);renderInk();}
438
+ else if(d.type==='marked'&&draw){draw.elements[Number(d.id)]=Array.isArray(d.elements)?d.elements.slice(0,8):[];}
280
439
  }
281
440
  });
282
441
  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.');
283
- addEventListener('keydown',e=>{if(e.key==='Escape'){if(listening)cancelListening();else if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
442
+ addEventListener('keydown',e=>{if(e.key==='Escape'){if(listening)cancelListening();else if(draw&&!draw.done)endDraw();else if(point.getAttribute('aria-pressed')==='true')pointing(false);else panels(null);}});
284
443
  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.';}};
285
444
  reload.onclick=()=>{if(metadata)open(currentPath,'Reloading app…');};
286
445
  (async()=>{
@@ -160,16 +160,22 @@ export async function takeShots(views, request, outDir, chrome = findChrome()) {
160
160
  for (const width of request.widths)
161
161
  for (const theme of request.themes) {
162
162
  const mobile = width < 600;
163
- await cdp.send("Emulation.setDeviceMetricsOverride", { width, height: mobile ? 844 : 800, deviceScaleFactor: mobile ? 2 : 1, mobile }, sessionId);
163
+ await cdp.send("Emulation.setDeviceMetricsOverride", { width, height: request.height ?? (mobile ? 844 : 800), deviceScaleFactor: request.scale ?? (mobile ? 2 : 1), mobile }, sessionId);
164
164
  await cdp.send("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: theme }] }, sessionId);
165
165
  const loaded = cdp.once("Page.loadEventFired", sessionId, 15_000);
166
166
  await cdp.send("Page.navigate", { url: origin + request.path }, sessionId);
167
167
  await loaded;
168
168
  await new Promise((r) => setTimeout(r, 400)); // let fonts and first effects settle
169
+ let scroll;
170
+ if (request.scroll) {
171
+ const { result } = await cdp.send("Runtime.evaluate", { expression: `scrollTo(${Number(request.scroll.x) || 0}, ${Number(request.scroll.y) || 0}); [scrollX, scrollY]`, returnByValue: true }, sessionId);
172
+ scroll = { x: Number(result?.value?.[0]) || 0, y: Number(result?.value?.[1]) || 0 };
173
+ await new Promise((r) => setTimeout(r, 150));
174
+ }
169
175
  const { data } = await cdp.send("Page.captureScreenshot", { format: "png" }, sessionId);
170
176
  const file = path.join(outDir, `${entry.view.id.slice(0, 8)}-${width}-${theme}.png`);
171
177
  fs.writeFileSync(file, Buffer.from(data, "base64"));
172
- shots.push({ viewId: entry.view.id, view: entry.view.name, width, theme, file });
178
+ shots.push({ viewId: entry.view.id, view: entry.view.name, width, theme, file, ...(scroll ? { scroll } : {}) });
173
179
  }
174
180
  }
175
181
  return shots;
@@ -5,6 +5,9 @@ import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { scanListeners } from "./listeners.js";
7
7
  import { takeShots } from "./screenshot.js";
8
+ import { approximate, composite, readStrokes } from "./annotate.js";
9
+ import { captureFrame, encodePng } from "./rfb.js";
10
+ import { pngSize } from "./review.js";
8
11
  const NO_REVIEWS = { publish: (review) => review, expire: () => { } };
9
12
  const NO_DISPLAYS = { unavailable: () => "Desktop app views aren't available on this machine.", ensure: () => Promise.reject(new Error("Desktop app views aren't available on this machine.")), stop: () => { } };
10
13
  /** How long a screenshot waits for a desktop app's first window. */
@@ -62,6 +65,8 @@ export class AppService {
62
65
  view.notes = structuredClone(entry.notes);
63
66
  if (entry?.stats)
64
67
  view.stats = structuredClone(entry.stats);
68
+ if (entry?.lastPath)
69
+ view.lastPath = entry.lastPath;
65
70
  }
66
71
  return { apps, previewAvailable: available };
67
72
  }
@@ -347,6 +352,59 @@ export class AppService {
347
352
  this.shooting = run;
348
353
  return { shots: await run };
349
354
  }
355
+ /** Draw on the preview: the user's marks on a picture of what they saw.
356
+ * The picture is the Compare screenshot they drew on, a desktop app's
357
+ * current frame, or a web page retaken at their viewport, pixel ratio and
358
+ * scroll — "approximate" when the page may hold state a fresh browser
359
+ * doesn't. Returned to the client (over the session channel) as a PNG. */
360
+ async annotate(sessionId, input) {
361
+ const entry = this.registry.requireView(sessionId, input.appId, input.viewId);
362
+ if (entry.view.kind !== "web")
363
+ throw new Error("Only previews can be drawn on.");
364
+ const strokes = readStrokes(input.strokes);
365
+ const whole = (n, min, max) => typeof n === "number" && Number.isFinite(n) && n >= min && n <= max;
366
+ // Compare's shot is drawn on at its on-screen size, which can be small.
367
+ if (!whole(input.viewport?.width, 40, 4000) || !whole(input.viewport?.height, 40, 8000))
368
+ throw new Error("Invalid viewport.");
369
+ const viewport = { width: Math.round(input.viewport.width), height: Math.round(input.viewport.height) };
370
+ const page = typeof input.path === "string" && input.path.startsWith("/") && !input.path.startsWith("//") && input.path.length <= 2048 ? input.path : entry.lastPath ?? "/";
371
+ const scroll = { x: whole(input.scroll?.x, 0, 1e6) ? input.scroll.x : 0, y: whole(input.scroll?.y, 0, 1e6) ? input.scroll.y : 0 };
372
+ const name = `${entry.app.name} ${page === "/" ? "" : page} marked.png`.replace(/[^\w .()-]+/g, "-").replace(/\s+/g, " ").trim();
373
+ const finish = (base, offset, approx) => {
374
+ const png = composite(base, strokes, { scale: pngSize(base).width / viewport.width, offset });
375
+ return { image: { data: png.toString("base64"), mimeType: "image/png", name, ...pngSize(png) }, approximate: approx };
376
+ };
377
+ // Compare: the exact frame they drew on, in its own coordinates.
378
+ if (input.compare !== undefined) {
379
+ const shot = Number.isInteger(input.compare) ? entry.shots?.[input.compare] : undefined;
380
+ if (!shot)
381
+ throw new Error("That Compare screenshot is no longer kept. Open Compare again.");
382
+ return finish(shot.png, { x: 0, y: 0 }, approximate("exact"));
383
+ }
384
+ if (!this.screenshots.enabled())
385
+ return { approximate: false, screenshotsOff: true };
386
+ // A desktop app: its display's current frame is exactly what they saw.
387
+ if (entry.target.kind === "display") {
388
+ await this.windowShown(entry).catch(() => { });
389
+ if (!entry.display)
390
+ throw new Error("The app isn't running, so there's nothing to draw on.");
391
+ const frame = await captureFrame(entry.display);
392
+ return finish(encodePng(frame.width, frame.height, frame.rgb), { x: 0, y: 0 }, approximate("exact"));
393
+ }
394
+ const outDir = path.join(os.tmpdir(), "bivy-shots", "annotate", entry.view.id);
395
+ const dpr = whole(input.dpr, 1, 3) ? Math.round(input.dpr * 4) / 4 : 2;
396
+ const run = this.shooting.catch(() => { }).then(() => this.screenshots.take([entry], { widths: [viewport.width], themes: [input.theme === "dark" ? "dark" : "light"], path: page, height: viewport.height, scale: dpr, scroll }, outDir));
397
+ this.shooting = run;
398
+ const [shot] = await run;
399
+ if (!shot)
400
+ throw new Error("Couldn't take a picture of the page.");
401
+ const base = fs.readFileSync(shot.file);
402
+ fs.rmSync(outDir, { recursive: true, force: true });
403
+ const landed = shot.scroll ?? scroll;
404
+ const scrolledTo = Math.abs(landed.x - scroll.x) <= 2 && Math.abs(landed.y - scroll.y) <= 2;
405
+ // Marks stay on the content they were drawn on, wherever the retake scrolled.
406
+ return finish(base, landed, approximate("retake", input.signals, scrolledTo));
407
+ }
350
408
  /** The managed server's output, as an attachable terminal (starts it if needed). */
351
409
  async logs(sessionId, appId, viewId) {
352
410
  const entry = this.registry.requireView(sessionId, appId, viewId);
@@ -3088,9 +3088,23 @@ select.picker-search { appearance: auto; }
3088
3088
  .apps-foot { margin: 0; padding: 0 var(--space-1); color: var(--muted); font-size: var(--text-xs); line-height: var(--lh-xs); }
3089
3089
  /* Peek: an app preview framed in a drawer over the chat (PreviewPeek.tsx). */
3090
3090
  .preview-peek { display: block; inline-size: 100%; block-size: 100%; border: 0; border-radius: var(--radius-md); background: var(--bg); }
3091
+ /* Share-sheet landing (ShareDestinationSheet.tsx): what was shared, then one
3092
+ row per destination — AppRow, with the session's app preview when it has one. */
3093
+ .share-sheet { display: flex; flex-direction: column; gap: var(--space-3); }
3094
+ .share-shared { display: flex; flex-direction: column; gap: var(--space-2); margin: 0; }
3095
+ .share-thumbs { display: flex; gap: var(--space-2); overflow-x: auto; }
3096
+ .share-thumb { flex: none; block-size: 96px; inline-size: auto; max-inline-size: 160px; object-fit: cover; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-2); }
3097
+ .share-dests { display: flex; flex-direction: column; gap: var(--space-1); }
3098
+ .share-dest { display: block; inline-size: 100%; padding: 0; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface); color: inherit; font: inherit; text-align: start; cursor: pointer; }
3099
+ .share-dest:hover { background: var(--surface-2); }
3100
+ .share-dest .app-row { min-block-size: 44px; }
3091
3101
  .preview-peek-stage { position: relative; block-size: 100%; }
3092
3102
  /* Point and speak: the recording bar floats over the top of the preview while
3093
3103
  the finger stays on the shell's mic, so nothing under it moves. */
3104
+ /* Draw: making the marked-up picture, or the choice when screenshots are off. */
3105
+ .preview-marking { position: absolute; inset-inline: var(--space-2); bottom: var(--space-2); z-index: var(--z-sticky); box-shadow: var(--shadow-lg); border-radius: var(--radius-md); background: var(--surface); }
3106
+ .preview-marking .banner { margin: 0; }
3107
+ .preview-marking .banner-actions { flex-wrap: wrap; }
3094
3108
  .preview-voice { position: absolute; inset-inline: var(--space-2); top: var(--space-2); z-index: var(--z-sticky); padding: var(--space-1) var(--space-2); border: 1px solid var(--line-strong); border-radius: var(--radius-full); background: var(--surface); box-shadow: var(--shadow-lg); }
3095
3109
  .app-terminal-status { padding: var(--space-2) var(--space-4); margin: 0; color: var(--muted); font-size: var(--text-sm); }
3096
3110
  .artifacts-group { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
@@ -62,6 +62,12 @@
62
62
  --s-github: #24292f; --s-slack: #7c2d92; --s-linear: #5e6ad2; --s-schedule: #0e7490;
63
63
  --s-webhook: #6d28d9; --s-manual: #b45309; --s-cli: #3f6212; --s-app: #64748b;
64
64
 
65
+ /* ---- Annotation: marks drawn over an app preview. The same in both themes
66
+ (the app underneath has its own), vivid and rare in real UIs, with a white
67
+ halo so it reads on light and dark pages. The node composites it too. ---- */
68
+ --annotate: #ff2d78;
69
+ --annotate-halo: #ffffff;
70
+
65
71
  /* ---- Terminal text selection ---- */
66
72
  --term-selection: rgba(63, 99, 119, 0.30);
67
73
  --term-selection-inactive: rgba(63, 99, 119, 0.20);
@@ -26,6 +26,7 @@ export function createAppCommands(service, workspaceFor, published = () => { })
26
26
  "apps.showMe": (msg) => service.present(String(msg.sessionId), { target: typeof msg.appId === "string" ? msg.appId : undefined, trigger: "asked" }),
27
27
  "apps.mute": (msg) => service.mute(String(msg.sessionId)),
28
28
  "apps.reviewMode": (msg) => service.setReviewMode(String(msg.sessionId), String(msg.appId), msg.mode),
29
+ "apps.annotate": (msg) => service.annotate(String(msg.sessionId), msg),
29
30
  "apps.clearNotes": (msg) => service.clearNotes(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
30
31
  "apps.logs": (msg) => service.logs(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
31
32
  "apps.share": (msg) => service.share(String(msg.sessionId), String(msg.appId), String(msg.viewId)),
@@ -3,7 +3,7 @@
3
3
  /** Direct-mode adapters for operations whose canonical implementation is the
4
4
  * client command registry. Relay and HTTP now differ only in framing. */
5
5
  export const CLIENT_COMMAND_ROUTES = [
6
- ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "present", "showMe", "mute", "reviewMode", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
6
+ ...["list", "publish", "offers", "adopt", "open", "logs", "shot", "present", "showMe", "mute", "reviewMode", "annotate", "clearNotes", "share", "revoke", "remove"].map((action) => ({ method: "post", path: `/api/apps/${action}`, kind: `apps.${action}` })),
7
7
  { method: "post", path: "/api/artifacts/list", kind: "artifacts.list" },
8
8
  { method: "post", path: "/api/auth/credentials/native-preview", kind: "credentials.native.preview" },
9
9
  { method: "post", path: "/api/auth/credentials/native-import", kind: "credentials.native.import" },
@@ -19,6 +19,11 @@ export const CLIENT_COMMAND_SCHEMAS = {
19
19
  "apps.showMe": Type.Object({ ...request, ...session, appId: Type.Optional(Type.String()) }),
20
20
  "apps.mute": Type.Object({ ...request, ...session }),
21
21
  "apps.reviewMode": Type.Object({ ...request, ...session, appId: Type.String(), mode: Type.Union([Type.Literal("ready"), Type.Literal("every"), Type.Literal("off")]) }),
22
+ "apps.annotate": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String(), path: Type.Optional(Type.String({ maxLength: 2048 })),
23
+ viewport: Type.Object({ width: Type.Number(), height: Type.Number() }), scroll: Type.Optional(Type.Object({ x: Type.Number(), y: Type.Number() })),
24
+ dpr: Type.Optional(Type.Number()), theme: Type.Optional(Type.Union([Type.Literal("light"), Type.Literal("dark")])), compare: Type.Optional(Type.Integer({ minimum: 0 })),
25
+ strokes: Type.Array(Type.Object({ tool: Type.Union([Type.Literal("pen"), Type.Literal("box")]), points: Type.Array(Type.Array(Type.Number(), { minItems: 2, maxItems: 2 }), { maxItems: 10000 }) }), { minItems: 1, maxItems: 50 }),
26
+ signals: Type.Optional(Type.Record(Type.String(), Type.Boolean())) }),
22
27
  "apps.clearNotes": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
23
28
  "apps.logs": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
24
29
  "apps.share": Type.Object({ ...request, ...session, appId: Type.String(), viewId: Type.String() }),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bivy/bivy",
3
- "version": "0.17.3-staging.24",
3
+ "version": "0.17.3-staging.26",
4
4
  "type": "module",
5
5
  "license": "AGPL-3.0-only",
6
6
  "description": "Run coding agents on machines you own. Open-source, self-hostable agent workspace.",