@bivy/bivy 0.17.3-staging.24 → 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.
- package/dist/apps/annotate.js +94 -0
- package/dist/apps/inspector.js +47 -1
- package/dist/apps/preview-shell.js +167 -8
- package/dist/apps/screenshot.js +8 -2
- package/dist/apps/service.js +56 -0
- package/dist/apps/styles.css +4 -0
- package/dist/apps/tokens.css +6 -0
- package/dist/controllers/app-commands.js +1 -0
- package/dist/protocol/client-command-routes.js +1 -1
- package/dist/protocol/client-command-schemas.js +5 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/apps/inspector.js
CHANGED
|
@@ -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
|
-
|
|
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==='
|
|
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()=>{
|
package/dist/apps/screenshot.js
CHANGED
|
@@ -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;
|
package/dist/apps/service.js
CHANGED
|
@@ -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);
|
package/dist/apps/styles.css
CHANGED
|
@@ -3091,6 +3091,10 @@ select.picker-search { appearance: auto; }
|
|
|
3091
3091
|
.preview-peek-stage { position: relative; block-size: 100%; }
|
|
3092
3092
|
/* Point and speak: the recording bar floats over the top of the preview while
|
|
3093
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; }
|
|
3094
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); }
|
|
3095
3099
|
.app-terminal-status { padding: var(--space-2) var(--space-4); margin: 0; color: var(--muted); font-size: var(--text-sm); }
|
|
3096
3100
|
.artifacts-group { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
package/dist/apps/tokens.css
CHANGED
|
@@ -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