@bivy/bivy 0.17.3-staging.23 → 0.17.3-staging.25

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=>{
@@ -42,14 +44,27 @@ const hover=e=>{
42
44
  const el=under(e);if(!el)return;const r=el.getBoundingClientRect();
43
45
  Object.assign(box.style,{left:r.left+'px',top:r.top+'px',width:r.width+'px',height:r.height+'px'});
44
46
  };
45
- const pick=e=>{
46
- e.preventDefault();
47
- const el=under(e);if(!el)return;stop();
47
+ const report=(el,hold)=>{
48
48
  const r=el.getBoundingClientRect();
49
49
  onPicked({type:'picked',selector:selector(el),tag:el.localName,text:(el.innerText||el.getAttribute('aria-label')||el.getAttribute('alt')||'').trim().replace(/\\s+/g,' ').slice(0,200),
50
50
  rect:{x:Math.round(r.left),y:Math.round(r.top),width:Math.round(r.width),height:Math.round(r.height)},
51
- viewport:{width:innerWidth,height:innerHeight},path:location.pathname+location.search+location.hash});
51
+ viewport:{width:innerWidth,height:innerHeight},path:location.pathname+location.search+location.hash,hold});
52
+ };
53
+ // A long press picks at once and says so (the shell starts listening); the
54
+ // layer stays until the finger lifts, so the release never reaches the app.
55
+ let press=null,held=false;
56
+ const pick=e=>{
57
+ e.preventDefault();
58
+ if(held){held=false;stop();return;}
59
+ const el=under(e);if(!el)return;stop();report(el,false);
60
+ };
61
+ const down=e=>{
62
+ hover(e);clearTimeout(press?.timer);held=false;
63
+ const x=e.clientX,y=e.clientY;
64
+ press={x,y,timer:setTimeout(()=>{const el=under({clientX:x,clientY:y});if(!el)return;held=true;box?.remove();report(el,true);},450)};
52
65
  };
66
+ const move=e=>{hover(e);if(press&&Math.hypot(e.clientX-press.x,e.clientY-press.y)>10)clearTimeout(press.timer);};
67
+ const up=()=>{clearTimeout(press?.timer);press=null;if(held){post({type:'release'});setTimeout(()=>{if(held){held=false;stop();}},0);}};
53
68
  const key=e=>{if(e.key==='Escape'){stop();onPicked({type:'picked',cancelled:true});}};
54
69
  function startPointing(){
55
70
  stop();
@@ -60,13 +75,59 @@ function startPointing(){
60
75
  layer=document.createElement('div');
61
76
  layer.setAttribute('aria-hidden','true');
62
77
  Object.assign(layer.style,{position:'fixed',inset:0,zIndex:2147483646,cursor:'crosshair',background:'transparent',touchAction:'manipulation',userSelect:'none',webkitUserSelect:'none',webkitTouchCallout:'none'});
63
- layer.addEventListener('pointermove',hover);layer.addEventListener('pointerdown',hover);layer.addEventListener('click',pick);
78
+ layer.addEventListener('pointermove',move);layer.addEventListener('pointerdown',down);layer.addEventListener('pointerup',up);layer.addEventListener('pointercancel',up);layer.addEventListener('click',pick);
79
+ layer.addEventListener('contextmenu',e=>e.preventDefault());
64
80
  document.documentElement.append(layer,box);
65
81
  addEventListener('keydown',key,true);
66
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
+ });
67
127
  addEventListener('message',e=>{
68
128
  if(!framed||e.origin!==SHELL||e.source!==parent||e.data?.type!=='bivy:point')return;
69
- if(e.data.on)startPointing();else stop();
129
+ // Held: the layer goes when the finger lifts (see up).
130
+ if(e.data.on)startPointing();else if(!held)stop();
70
131
  });
71
132
  if(REVIEWER&&!framed){
72
133
  // A shadow root keeps the app's CSS off these controls (and ours off the app).
@@ -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; }
@@ -43,9 +43,34 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
43
43
  #entries li { display:flex; gap:var(--space-2); align-items:baseline; padding:var(--space-1) 0; border-top:thin solid var(--line); overflow-wrap:anywhere; }
44
44
  /* Keeps .field's 16px: smaller, and iOS zooms the shell on focus and stays zoomed. */
45
45
  #draft-text { width:100%; min-height:5em; box-sizing:border-box; resize:vertical; }
46
+ #draft-row { display:flex; align-items:flex-start; gap:var(--space-2); }
47
+ #draft-row #draft-text { flex:1; min-width:0; }
48
+ /* Hold to talk: a phone-sized target that never selects text or opens a callout. */
49
+ #mic { flex:none; inline-size:44px; block-size:44px; padding:0; border-radius:var(--radius-full); touch-action:none; user-select:none; -webkit-user-select:none; -webkit-touch-callout:none; }
50
+ #mic[aria-pressed="true"] { background:var(--accent); border-color:var(--accent); color:var(--accent-contrast); }
51
+ #voice-status { font-size:var(--text-xs); margin:var(--space-2) 0 0; }
52
+ #voice-status:empty { display:none; }
53
+ /* The panel scrolls on a phone; its actions stay in reach at the bottom. */
54
+ #draft .panel-actions { position:sticky; bottom:calc(-1 * var(--space-3)); margin:var(--space-2) calc(-1 * var(--space-3)) calc(-1 * var(--space-3)); padding:var(--space-2) var(--space-3) var(--space-3); background:var(--surface); border-top:thin solid var(--line); }
46
55
  #draft-hint { font-size:var(--text-xs); margin:var(--space-2) 0 var(--space-1); }
47
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); }
48
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; }
49
74
  .label-narrow { display:none; }
50
75
  @media (max-width: 699px) { #lens { display:none; } #name { display:none; } .label-wide { display:none; } .label-narrow { display:inline; } }
51
76
  [hidden] { display:none !important; }
@@ -53,6 +78,7 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
53
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>
54
79
  <p id="status" role="status">Opening app…</p>
55
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>
56
82
  <div id="dock">
57
83
  <section class="panel" id="console" hidden aria-labelledby="console-title">
58
84
  <h2><span id="console-title">Console</span><button class="btn sm ghost" id="clear">Clear</button></h2>
@@ -64,24 +90,36 @@ nav .btn[aria-pressed="true"] { background:var(--accent-soft); color:var(--accen
64
90
  <h2 id="compare-title">Before and after the agent’s last change</h2>
65
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>
66
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>
67
94
  </section>
68
95
  <section class="panel" id="draft" hidden aria-labelledby="draft-title">
69
96
  <h2 id="draft-title">Draft for the agent</h2>
70
- <textarea class="field" id="draft-text" aria-label="What should change?" placeholder="What should change?"></textarea>
97
+ <div id="draft-row"><textarea class="field" id="draft-text" aria-label="What should change?" placeholder="What should change?"></textarea>
98
+ <button class="btn" id="mic" type="button" hidden aria-pressed="false" aria-label="Speak" title="Hold to speak, or tap to start and stop"><svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 15.5a3 3 0 003-3V6a3 3 0 10-6 0v6.5a3 3 0 003 3z"/><path d="M6 11.5v1a6 6 0 0012 0v-1M12 18.5V21M9 21h6"/></svg></button></div>
99
+ <p class="muted" id="voice-status" role="status"></p>
71
100
  <p class="muted" id="draft-hint">Sent with this context from the preview. Nothing is sent until you send it in the chat.</p>
72
101
  <pre id="draft-context"></pre>
73
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>
74
103
  </section>
75
- <p class="banner inline" data-tone="accent" id="pointing" role="status" hidden>Tap anything in the app to point at it. Press Escape or Point again to stop.</p>
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>
76
113
  <nav aria-label="Bivy preview controls">
77
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>
78
115
  <span id="name"><span id="title">App preview</span><span id="stamp" class="muted" role="status"></span></span>
79
116
  <button class="btn sm ghost" id="hide" aria-label="Hide Bivy controls" title="Hide controls">⌄</button>
80
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>
81
119
  <button class="btn sm ghost" id="compare-btn" aria-pressed="false" hidden>Compare</button>
82
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>
83
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>
84
- <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>
85
123
  </nav>
86
124
  </div>
87
125
  <button class="btn sm" id="show" hidden aria-label="Show Bivy controls">Bivy</button>
@@ -115,6 +153,8 @@ function show(data,launch){
115
153
  for(const b of [reload,$('point'),$('errors')])b.disabled=false;
116
154
  status.textContent='Loading app…';
117
155
  frame.onload=()=>{status.hidden=true;void loadCompare();if(revision===null){revision=-1;watch();}else if(wake)wake();};
156
+ // A Bivy client framing the shell says whether it can take dictation.
157
+ if(embedded&&data.returnTo)toBivy({type:'hello'});
118
158
  // Store navigation metadata only, never tickets or cookies.
119
159
  try{sessionStorage.setItem(storageKey,JSON.stringify(data));}catch{}
120
160
  }
@@ -143,16 +183,53 @@ function toChat(text){
143
183
  const to=new URL(metadata.returnTo),session=to.pathname.split('/').pop();
144
184
  location.assign(to.origin+'/share?session='+encodeURIComponent(session)+'&text='+encodeURIComponent(text));
145
185
  }
146
- function draft(context){
186
+ function draft(context,listen){
147
187
  panels(null);
148
188
  $('draft').hidden=false;
149
189
  $('draft-add').textContent=metadata.returnTo?'Add to chat':'Copy';
150
190
  $('draft-context').textContent=context;
151
- const box=$('draft-text');box.value='';box.focus();
191
+ const box=$('draft-text');box.value='';
192
+ // Speaking right away: don't raise the keyboard over the draft.
193
+ if(listen)startListening();else box.focus();
152
194
  }
153
- $('draft-cancel').onclick=()=>{$('draft').hidden=true;};
195
+ // Point and speak. The Bivy client framing this shell does the listening:
196
+ // audio never reaches this origin, and only the transcript comes back, into
197
+ // the draft box, where it stays editable. Hold to talk, or tap to start and
198
+ // tap again to stop.
199
+ const mic=$('mic'),voiceStatus=$('voice-status');
200
+ let voice=false,listening=false,heldAt=0;
201
+ const sayVoice=text=>{voiceStatus.textContent=text;};
202
+ function setListening(on){listening=on;mic.setAttribute('aria-pressed',String(on));mic.setAttribute('aria-label',on?'Stop and add what you said':'Speak');}
203
+ function startListening(){if(!voice||listening)return;setListening(true);sayVoice('Listening… let go, or tap the mic again, to add what you said.');toBivy({type:'listen',state:'start'});}
204
+ function stopListening(){if(!listening)return;sayVoice('Turning speech into text…');toBivy({type:'listen',state:'stop'});}
205
+ function cancelListening(){if(listening)toBivy({type:'listen',state:'cancel'});setListening(false);sayVoice('');}
206
+ function addSpoken(text){
207
+ const box=$('draft-text'),said=String(text||'').trim().slice(0,4000);
208
+ if(!said)return;
209
+ box.value=box.value.trim()?box.value.trimEnd()+' '+said:said;
210
+ sayVoice('Added what you said. Edit it, or Add to chat.');
211
+ }
212
+ mic.onpointerdown=e=>{
213
+ e.preventDefault();
214
+ if(listening){stopListening();heldAt=0;return;}
215
+ heldAt=Date.now();try{mic.setPointerCapture(e.pointerId);}catch{}
216
+ startListening();
217
+ };
218
+ // A short tap keeps listening until the next tap; a hold stops on release.
219
+ mic.onpointerup=mic.onpointercancel=()=>{if(heldAt&&Date.now()-heldAt>400)stopListening();heldAt=0;};
220
+ mic.onclick=e=>{if(e.detail===0){if(listening)stopListening();else startListening();}};
221
+ function fromBivy(d){
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();}
224
+ else if(d.type==='transcript'){setListening(false);if(typeof d.error==='string')sayVoice(d.error.slice(0,200));else addSpoken(d.text);}
225
+ else if(d.type==='listening'&&d.on===false&&listening){setListening(false);if(voiceStatus.textContent.startsWith('Listening'))sayVoice('');}
226
+ }
227
+ $('draft-cancel').onclick=()=>{cancelListening();$('draft').hidden=true;endDraw();};
154
228
  $('draft-add').onclick=async()=>{
229
+ cancelListening();
155
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});}
156
233
  if(metadata.returnTo)return toChat(text);
157
234
  try{await navigator.clipboard.writeText(text);$('draft-add').textContent='Copied';}catch{$('draft-text').select();}
158
235
  };
@@ -171,31 +248,160 @@ $('clear').onclick=resetConsole;
171
248
  $('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'));
172
249
  // Point and tell
173
250
  const point=$('point');
174
- function pointing(on){point.setAttribute('aria-pressed',String(on));$('pointing').hidden=!on;frame.contentWindow?.postMessage({type:'bivy:point',on},metadata.origin);}
251
+ // Pointing again starts a new draft: panels over the app step aside.
252
+ function pointing(on){if(on)panels(null);point.setAttribute('aria-pressed',String(on));$('pointing').hidden=!on;frame.contentWindow?.postMessage({type:'bivy:point',on},metadata.origin);}
175
253
  point.onclick=()=>pointing(point.getAttribute('aria-pressed')!=='true');
176
254
  function picked(d){
177
255
  pointing(false);
178
256
  if(d.cancelled)return;
257
+ // A long press: the draft opens listening, and letting go stops.
258
+ if(d.hold&&voice)heldAt=Date.now();
179
259
  const r=d.rect||{},v=d.viewport||{};
180
260
  const errors=entries.filter(e=>e.level==='error').slice(-5);
181
261
  draft('In the app preview "'+metadata.name+'" (page '+safePath(d.path)+', viewport '+v.width+'×'+v.height+'):\\n'
182
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
183
- +(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''));
263
+ +(errors.length?'\\nRecent errors:\\n'+errors.map(e=>'- '+e.text).join('\\n'):''),d.hold===true&&voice);
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;
184
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
+ };
185
387
  // Compare: screenshots around the agent's last change (agent screenshots on).
186
- const compareBtn=$('compare-btn');let shots=[],urls=[];
388
+ const compareBtn=$('compare-btn');let shots=[],urls=[],compareAfter=-1;
187
389
  async function loadCompare(){
188
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{}
189
391
  }
190
- function panels(open){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;}
191
393
  compareBtn.onclick=async()=>{
192
394
  if(!$('compare').hidden)return panels(null);
193
395
  panels('compare');
194
396
  for(const u of urls)URL.revokeObjectURL(u);
195
397
  const pick=[shots[shots.length-2],shots[shots.length-1]];
398
+ compareAfter=pick[1].index;
196
399
  urls=await Promise.all(pick.map(async s=>URL.createObjectURL(await (await fetch(metadata.origin+'/__bivy/compare/'+s.index,{credentials:'include'})).blob())));
197
400
  const before=$('compare-before'),after=$('compare-after');
198
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;});
199
405
  split();
200
406
  };
201
407
  const split=()=>{$('compare-before').style.clipPath='inset(0 '+(100-Number($('compare-slider').value))+'% 0 0)';};
@@ -212,6 +418,8 @@ for(const b of $('lens').querySelectorAll('button'))b.onclick=()=>{
212
418
  const down=$('down'),downText=$('down-text'),ask=$('ask');
213
419
  let downPort=0;
214
420
  addEventListener('message',e=>{
421
+ // The Bivy client framing this shell (Peek): voice only.
422
+ if(embedded&&metadata?.returnTo&&e.source===parent&&e.origin===new URL(metadata.returnTo).origin){if(e.data?.source==='bivy')fromBivy(e.data);return;}
215
423
  if(!metadata||e.origin!==metadata.origin||e.source!==frame.contentWindow)return;
216
424
  const d=e.data||{};
217
425
  if(d.type==='bivy:access'&&d.state==='blocked'&&embedded&&metadata.returnTo)toBivy({type:'blocked'});
@@ -224,10 +432,14 @@ addEventListener('message',e=>{
224
432
  if(d.type==='route')currentPath=safePath(d.path);
225
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();}
226
434
  else if(d.type==='picked')picked(d);
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):[];}
227
439
  }
228
440
  });
229
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.');
230
- addEventListener('keydown',e=>{if(e.key==='Escape'){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);}});
231
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.';}};
232
444
  reload.onclick=()=>{if(metadata)open(currentPath,'Reloading app…');};
233
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. */
@@ -347,6 +350,59 @@ export class AppService {
347
350
  this.shooting = run;
348
351
  return { shots: await run };
349
352
  }
353
+ /** Draw on the preview: the user's marks on a picture of what they saw.
354
+ * The picture is the Compare screenshot they drew on, a desktop app's
355
+ * current frame, or a web page retaken at their viewport, pixel ratio and
356
+ * scroll — "approximate" when the page may hold state a fresh browser
357
+ * doesn't. Returned to the client (over the session channel) as a PNG. */
358
+ async annotate(sessionId, input) {
359
+ const entry = this.registry.requireView(sessionId, input.appId, input.viewId);
360
+ if (entry.view.kind !== "web")
361
+ throw new Error("Only previews can be drawn on.");
362
+ const strokes = readStrokes(input.strokes);
363
+ const whole = (n, min, max) => typeof n === "number" && Number.isFinite(n) && n >= min && n <= max;
364
+ // Compare's shot is drawn on at its on-screen size, which can be small.
365
+ if (!whole(input.viewport?.width, 40, 4000) || !whole(input.viewport?.height, 40, 8000))
366
+ throw new Error("Invalid viewport.");
367
+ const viewport = { width: Math.round(input.viewport.width), height: Math.round(input.viewport.height) };
368
+ const page = typeof input.path === "string" && input.path.startsWith("/") && !input.path.startsWith("//") && input.path.length <= 2048 ? input.path : entry.lastPath ?? "/";
369
+ const scroll = { x: whole(input.scroll?.x, 0, 1e6) ? input.scroll.x : 0, y: whole(input.scroll?.y, 0, 1e6) ? input.scroll.y : 0 };
370
+ const name = `${entry.app.name} ${page === "/" ? "" : page} marked.png`.replace(/[^\w .()-]+/g, "-").replace(/\s+/g, " ").trim();
371
+ const finish = (base, offset, approx) => {
372
+ const png = composite(base, strokes, { scale: pngSize(base).width / viewport.width, offset });
373
+ return { image: { data: png.toString("base64"), mimeType: "image/png", name, ...pngSize(png) }, approximate: approx };
374
+ };
375
+ // Compare: the exact frame they drew on, in its own coordinates.
376
+ if (input.compare !== undefined) {
377
+ const shot = Number.isInteger(input.compare) ? entry.shots?.[input.compare] : undefined;
378
+ if (!shot)
379
+ throw new Error("That Compare screenshot is no longer kept. Open Compare again.");
380
+ return finish(shot.png, { x: 0, y: 0 }, approximate("exact"));
381
+ }
382
+ if (!this.screenshots.enabled())
383
+ return { approximate: false, screenshotsOff: true };
384
+ // A desktop app: its display's current frame is exactly what they saw.
385
+ if (entry.target.kind === "display") {
386
+ await this.windowShown(entry).catch(() => { });
387
+ if (!entry.display)
388
+ throw new Error("The app isn't running, so there's nothing to draw on.");
389
+ const frame = await captureFrame(entry.display);
390
+ return finish(encodePng(frame.width, frame.height, frame.rgb), { x: 0, y: 0 }, approximate("exact"));
391
+ }
392
+ const outDir = path.join(os.tmpdir(), "bivy-shots", "annotate", entry.view.id);
393
+ const dpr = whole(input.dpr, 1, 3) ? Math.round(input.dpr * 4) / 4 : 2;
394
+ 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));
395
+ this.shooting = run;
396
+ const [shot] = await run;
397
+ if (!shot)
398
+ throw new Error("Couldn't take a picture of the page.");
399
+ const base = fs.readFileSync(shot.file);
400
+ fs.rmSync(outDir, { recursive: true, force: true });
401
+ const landed = shot.scroll ?? scroll;
402
+ const scrolledTo = Math.abs(landed.x - scroll.x) <= 2 && Math.abs(landed.y - scroll.y) <= 2;
403
+ // Marks stay on the content they were drawn on, wherever the retake scrolled.
404
+ return finish(base, landed, approximate("retake", input.signals, scrolledTo));
405
+ }
350
406
  /** The managed server's output, as an attachable terminal (starts it if needed). */
351
407
  async logs(sessionId, appId, viewId) {
352
408
  const entry = this.registry.requireView(sessionId, appId, viewId);
@@ -3088,6 +3088,14 @@ 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
+ .preview-peek-stage { position: relative; block-size: 100%; }
3092
+ /* Point and speak: the recording bar floats over the top of the preview while
3093
+ the finger stays on the shell's mic, so nothing under it moves. */
3094
+ /* Draw: making the marked-up picture, or the choice when screenshots are off. */
3095
+ .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); }
3096
+ .preview-marking .banner { margin: 0; }
3097
+ .preview-marking .banner-actions { flex-wrap: wrap; }
3098
+ .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); }
3091
3099
  .app-terminal-status { padding: var(--space-2) var(--space-4); margin: 0; color: var(--muted); font-size: var(--text-sm); }
3092
3100
  .artifacts-group { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
3093
3101
  .artifacts-group:last-child { margin-bottom: 0; }
@@ -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.23",
3
+ "version": "0.17.3-staging.25",
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.",