@sable-ai/sdk-core 0.1.0
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/README.md +55 -0
- package/dist/esm/index.js +2431 -0
- package/dist/sable.iife.js +1486 -0
- package/dist/types/browser-bridge/actions.d.ts +27 -0
- package/dist/types/browser-bridge/dom-state.d.ts +37 -0
- package/dist/types/browser-bridge/index.d.ts +19 -0
- package/dist/types/connection/index.d.ts +26 -0
- package/dist/types/events/index.d.ts +15 -0
- package/dist/types/global.d.ts +26 -0
- package/dist/types/index.d.ts +23 -0
- package/dist/types/rpc.d.ts +22 -0
- package/dist/types/runtime/clipboard.d.ts +14 -0
- package/dist/types/runtime/index.d.ts +36 -0
- package/dist/types/runtime/video-overlay.d.ts +14 -0
- package/dist/types/session/debug-panel.d.ts +29 -0
- package/dist/types/session/index.d.ts +41 -0
- package/dist/types/types/index.d.ts +131 -0
- package/dist/types/version.d.ts +7 -0
- package/dist/types/vision/frame-source.d.ts +34 -0
- package/dist/types/vision/index.d.ts +29 -0
- package/dist/types/vision/publisher.d.ts +44 -0
- package/dist/types/vision/wireframe.d.ts +22 -0
- package/package.json +61 -0
- package/src/assets/visible-dom.js.txt +764 -0
- package/src/assets/wireframe.js.txt +678 -0
- package/src/assets.d.ts +24 -0
- package/src/browser-bridge/actions.ts +161 -0
- package/src/browser-bridge/dom-state.ts +103 -0
- package/src/browser-bridge/index.ts +99 -0
- package/src/connection/index.ts +49 -0
- package/src/events/index.ts +50 -0
- package/src/global.ts +35 -0
- package/src/index.test.ts +6 -0
- package/src/index.ts +43 -0
- package/src/rpc.ts +31 -0
- package/src/runtime/clipboard.ts +47 -0
- package/src/runtime/index.ts +138 -0
- package/src/runtime/video-overlay.ts +94 -0
- package/src/session/debug-panel.ts +254 -0
- package/src/session/index.ts +375 -0
- package/src/types/index.ts +176 -0
- package/src/version.ts +8 -0
- package/src/vision/frame-source.ts +111 -0
- package/src/vision/index.ts +70 -0
- package/src/vision/publisher.ts +106 -0
- package/src/vision/wireframe.ts +43 -0
|
@@ -0,0 +1,678 @@
|
|
|
1
|
+
// wireframe.js — ultra-fast DOM wireframe renderer
|
|
2
|
+
// No dependencies needed
|
|
3
|
+
|
|
4
|
+
class Wireframe {
|
|
5
|
+
constructor(root = document.body, opts = {}) {
|
|
6
|
+
this.root = root;
|
|
7
|
+
this.scale = opts.scale || 1;
|
|
8
|
+
this.quality = opts.quality || 0.8;
|
|
9
|
+
this.maxDepth = opts.maxDepth || 30;
|
|
10
|
+
this.minSize = opts.minSize || 0; // temporarily 0 to debug
|
|
11
|
+
this.showText = opts.showText !== false;
|
|
12
|
+
this.showImages = opts.showImages !== false;
|
|
13
|
+
// When true, image elements (<img> and background-image divs) are
|
|
14
|
+
// rendered as their actual pixels using a CORS-aware fetch cache.
|
|
15
|
+
// When false (default), they render as labeled placeholder boxes.
|
|
16
|
+
// Either way they ALWAYS get a label now — the old behaviour of
|
|
17
|
+
// drawing a mystery yellow box with no hint is gone.
|
|
18
|
+
this.images = opts.images === true;
|
|
19
|
+
// Class-level cache so it persists across captures. `null` entries are
|
|
20
|
+
// negative cache (CORS failure, 404, decode error) — don't retry.
|
|
21
|
+
if (!Wireframe._imageCache) Wireframe._imageCache = new Map();
|
|
22
|
+
if (!Wireframe._imagePending) Wireframe._imagePending = new Map();
|
|
23
|
+
this.colors = {
|
|
24
|
+
bg: '#ffffff',
|
|
25
|
+
block: '#e2e8f0',
|
|
26
|
+
blockStroke: '#94a3b8',
|
|
27
|
+
text: '#334155',
|
|
28
|
+
input: '#dbeafe',
|
|
29
|
+
inputStroke: '#3b82f6',
|
|
30
|
+
button: '#bfdbfe',
|
|
31
|
+
buttonStroke: '#2563eb',
|
|
32
|
+
image: '#fde68a',
|
|
33
|
+
imageStroke: '#f59e0b',
|
|
34
|
+
imageCross: '#d97706',
|
|
35
|
+
link: '#2563eb',
|
|
36
|
+
heading: '#0f172a',
|
|
37
|
+
nav: '#e0e7ff',
|
|
38
|
+
navStroke: '#6366f1',
|
|
39
|
+
...(opts.colors || {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async capture() {
|
|
44
|
+
const t = performance.now();
|
|
45
|
+
const rootRect = this.root.getBoundingClientRect();
|
|
46
|
+
const scrollX = window.scrollX;
|
|
47
|
+
const scrollY = window.scrollY;
|
|
48
|
+
|
|
49
|
+
const canvasW = rootRect.width;
|
|
50
|
+
const canvasH = rootRect.height;
|
|
51
|
+
|
|
52
|
+
const canvas = document.createElement('canvas');
|
|
53
|
+
canvas.width = canvasW * this.scale;
|
|
54
|
+
canvas.height = canvasH * this.scale;
|
|
55
|
+
const ctx = canvas.getContext('2d');
|
|
56
|
+
ctx.scale(this.scale, this.scale);
|
|
57
|
+
|
|
58
|
+
// White background
|
|
59
|
+
ctx.fillStyle = this.colors.bg;
|
|
60
|
+
ctx.fillRect(0, 0, canvasW, canvasH);
|
|
61
|
+
|
|
62
|
+
// Two-pass: collect elements, draw boxes, then draw text on top
|
|
63
|
+
this._drawCount = 0;
|
|
64
|
+
this._skipCount = { tiny: 0, offscreen: 0, hidden: 0, depth: 0 };
|
|
65
|
+
this._elements = [];
|
|
66
|
+
this._pendingImageFetches = [];
|
|
67
|
+
this._collectElements(this.root, rootRect, 0);
|
|
68
|
+
|
|
69
|
+
// If image rendering is enabled and the first pass kicked off some
|
|
70
|
+
// fetches, give them a short window to land before drawing — that way
|
|
71
|
+
// the first captured frame already has some images instead of only
|
|
72
|
+
// placeholders. Subsequent frames hit the cache and are instant.
|
|
73
|
+
if (this.images && this._pendingImageFetches.length > 0) {
|
|
74
|
+
await Promise.race([
|
|
75
|
+
Promise.all(this._pendingImageFetches),
|
|
76
|
+
new Promise((r) => setTimeout(r, 400)),
|
|
77
|
+
]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Pass 1: draw all boxes/shapes
|
|
81
|
+
for (const item of this._elements) {
|
|
82
|
+
this._drawElementBox(ctx, item);
|
|
83
|
+
}
|
|
84
|
+
// Pass 2: find ALL text nodes directly via TreeWalker and draw them
|
|
85
|
+
this._textCount = 0;
|
|
86
|
+
this._drawAllText(ctx, rootRect);
|
|
87
|
+
|
|
88
|
+
console.log(`[wireframe] drew ${this._drawCount} elements | skipped: ${JSON.stringify(this._skipCount)} | root: ${rootRect.width.toFixed(0)}x${rootRect.height.toFixed(0)}`);
|
|
89
|
+
|
|
90
|
+
const elapsed = performance.now() - t;
|
|
91
|
+
console.log(`[wireframe] captured: ${elapsed.toFixed(0)}ms | ${canvas.width}x${canvas.height}`);
|
|
92
|
+
|
|
93
|
+
return { canvas, elapsed };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
_collectElements(el, rootRect, depth) {
|
|
97
|
+
if (depth > this.maxDepth) { this._skipCount.depth++; return; }
|
|
98
|
+
|
|
99
|
+
const children = el.children;
|
|
100
|
+
|
|
101
|
+
if (el.shadowRoot) {
|
|
102
|
+
this._collectElements(el.shadowRoot, rootRect, depth);
|
|
103
|
+
}
|
|
104
|
+
for (let i = 0; i < children.length; i++) {
|
|
105
|
+
const child = children[i];
|
|
106
|
+
const rect = child.getBoundingClientRect();
|
|
107
|
+
|
|
108
|
+
if (rect.width < this.minSize || rect.height < this.minSize) { this._skipCount.tiny++; continue; }
|
|
109
|
+
if (rect.bottom < rootRect.top || rect.top > rootRect.bottom) { this._skipCount.offscreen++; continue; }
|
|
110
|
+
if (rect.right < rootRect.left || rect.left > rootRect.right) { this._skipCount.offscreen++; continue; }
|
|
111
|
+
|
|
112
|
+
const style = window.getComputedStyle(child);
|
|
113
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') { this._skipCount.hidden++; continue; }
|
|
114
|
+
|
|
115
|
+
const x = rect.left - rootRect.left;
|
|
116
|
+
const y = rect.top - rootRect.top;
|
|
117
|
+
const w = rect.width;
|
|
118
|
+
const h = rect.height;
|
|
119
|
+
const tag = child.tagName;
|
|
120
|
+
const type = this._classifyElement(child, tag, style);
|
|
121
|
+
|
|
122
|
+
this._drawCount++;
|
|
123
|
+
|
|
124
|
+
// For image-like elements, capture a URL and a human label up front so
|
|
125
|
+
// the draw pass can either render the actual bitmap (opts.images=true
|
|
126
|
+
// + CORS-allowed fetch) or a labeled placeholder.
|
|
127
|
+
let url = null;
|
|
128
|
+
let label = null;
|
|
129
|
+
if (type === 'image' || (type === 'icon' && tag === 'IMG')) {
|
|
130
|
+
url = this._getImageUrl(child, style);
|
|
131
|
+
label = this._getImageLabel(child, url);
|
|
132
|
+
if (this.images && url) {
|
|
133
|
+
const p = this._ensureImage(url);
|
|
134
|
+
if (p && typeof p.then === 'function') {
|
|
135
|
+
this._pendingImageFetches.push(p);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
this._elements.push({ type, x, y, w, h, el: child, style, tag, url, label });
|
|
141
|
+
|
|
142
|
+
this._collectElements(child, rootRect, depth + 1);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
_classifyElement(el, tag, style) {
|
|
147
|
+
if (tag === 'VIDEO' || tag === 'CANVAS') return 'skip';
|
|
148
|
+
if (tag === 'IMG') return 'icon';
|
|
149
|
+
if (tag === 'SVG') return 'icon';
|
|
150
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return 'input';
|
|
151
|
+
if (tag === 'BUTTON') return 'button';
|
|
152
|
+
if (el.getAttribute('role') === 'button') {
|
|
153
|
+
const bg = style.backgroundColor;
|
|
154
|
+
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') return 'button';
|
|
155
|
+
}
|
|
156
|
+
if (tag === 'A') return 'link';
|
|
157
|
+
if (tag === 'NAV' || el.getAttribute('role') === 'navigation') return 'nav';
|
|
158
|
+
if (/^H[1-6]$/.test(tag)) return 'heading';
|
|
159
|
+
if (tag === 'SPAN' || tag === 'P' || tag === 'LABEL' || tag === 'LI' || tag === 'TD' || tag === 'TH') return 'text';
|
|
160
|
+
if (style.backgroundImage && style.backgroundImage !== 'none') return 'image';
|
|
161
|
+
// Check if this element has direct text content (not just from children)
|
|
162
|
+
if (this._hasDirectText(el)) return 'text';
|
|
163
|
+
return 'block';
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Extract a label for an icon — accessibility first
|
|
167
|
+
_getIconLabel(el) {
|
|
168
|
+
// Walk up to find the nearest interactive ancestor (often has the label)
|
|
169
|
+
const interactive = el.closest('[role="button"], button, a, [role="link"], [aria-label]');
|
|
170
|
+
|
|
171
|
+
// 1. aria-label (self or nearest ancestor)
|
|
172
|
+
const ariaLabel = el.getAttribute('aria-label') || (interactive && interactive.getAttribute('aria-label'));
|
|
173
|
+
if (ariaLabel) return ariaLabel;
|
|
174
|
+
|
|
175
|
+
// 2. aria-labelledby (self or ancestor)
|
|
176
|
+
const labelledBy = el.getAttribute('aria-labelledby') || (interactive && interactive.getAttribute('aria-labelledby'));
|
|
177
|
+
if (labelledBy) {
|
|
178
|
+
const ref = document.getElementById(labelledBy);
|
|
179
|
+
if (ref) return ref.textContent.trim();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 3. aria-describedby
|
|
183
|
+
const describedBy = el.getAttribute('aria-describedby') || (interactive && interactive.getAttribute('aria-describedby'));
|
|
184
|
+
if (describedBy) {
|
|
185
|
+
const ref = document.getElementById(describedBy);
|
|
186
|
+
if (ref) return ref.textContent.trim();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 4. alt / title
|
|
190
|
+
if (el.getAttribute('alt')) return el.getAttribute('alt');
|
|
191
|
+
if (el.getAttribute('title')) return el.getAttribute('title');
|
|
192
|
+
if (interactive && interactive.getAttribute('title')) return interactive.getAttribute('title');
|
|
193
|
+
|
|
194
|
+
// 5. Visible text content of parent button
|
|
195
|
+
if (interactive && interactive !== el) {
|
|
196
|
+
const text = interactive.textContent?.trim();
|
|
197
|
+
if (text && text.length < 30) return text;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return '◆';
|
|
201
|
+
|
|
202
|
+
return '◆';
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Check if element has its own text nodes (not just inherited from children)
|
|
206
|
+
_hasDirectText(el) {
|
|
207
|
+
for (const node of el.childNodes) {
|
|
208
|
+
if (node.nodeType === 3 && node.textContent.trim().length > 0) return true;
|
|
209
|
+
}
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Resolve the image URL for an <img> or a background-image element.
|
|
214
|
+
_getImageUrl(el, style) {
|
|
215
|
+
if (el.tagName === 'IMG') {
|
|
216
|
+
return el.currentSrc || el.src || null;
|
|
217
|
+
}
|
|
218
|
+
const bg = style && style.backgroundImage;
|
|
219
|
+
if (bg && bg !== 'none') {
|
|
220
|
+
// backgroundImage may contain multiple layers: url(a), linear-gradient(...)
|
|
221
|
+
const m = bg.match(/url\(["']?([^"')]+)["']?\)/);
|
|
222
|
+
if (m) return m[1];
|
|
223
|
+
}
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Derive a human-readable label for an image element. Priority:
|
|
228
|
+
// alt → aria-label → title → figcaption → nearest-ancestor aria-label
|
|
229
|
+
// → nearest heading → decoded filename → "image"
|
|
230
|
+
_getImageLabel(el, url) {
|
|
231
|
+
const get = (attr) => {
|
|
232
|
+
try { return el.getAttribute && el.getAttribute(attr); } catch { return null; }
|
|
233
|
+
};
|
|
234
|
+
if (get('alt')) return get('alt');
|
|
235
|
+
if (get('aria-label')) return get('aria-label');
|
|
236
|
+
if (get('title')) return get('title');
|
|
237
|
+
|
|
238
|
+
const fig = el.closest && el.closest('figure');
|
|
239
|
+
if (fig) {
|
|
240
|
+
const cap = fig.querySelector && fig.querySelector('figcaption');
|
|
241
|
+
if (cap && cap.textContent) {
|
|
242
|
+
const t = cap.textContent.trim();
|
|
243
|
+
if (t) return t;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const labelledAncestor = el.closest && el.closest('[aria-label]');
|
|
248
|
+
if (labelledAncestor && labelledAncestor !== el) {
|
|
249
|
+
const a = labelledAncestor.getAttribute('aria-label');
|
|
250
|
+
if (a) return a;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Notion cover images sit directly above the page H1 — use it as the
|
|
254
|
+
// label when we have nothing else, so the agent can say "your Notion
|
|
255
|
+
// page 'hello aidan' has a cover".
|
|
256
|
+
if (url) {
|
|
257
|
+
try {
|
|
258
|
+
const u = new URL(url, window.location.href);
|
|
259
|
+
const name = u.pathname.split('/').pop();
|
|
260
|
+
if (name) {
|
|
261
|
+
const decoded = decodeURIComponent(name).replace(/\.[a-z0-9]{2,5}$/i, '');
|
|
262
|
+
if (decoded && decoded.length < 60) return decoded;
|
|
263
|
+
}
|
|
264
|
+
} catch { /* ignore */ }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return 'image';
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Fetch → blob → ImageBitmap with CORS, class-level cache. Returns null
|
|
271
|
+
// (and negatively caches) on any failure. Concurrent callers for the
|
|
272
|
+
// same URL share one in-flight promise.
|
|
273
|
+
_ensureImage(url) {
|
|
274
|
+
const cache = Wireframe._imageCache;
|
|
275
|
+
const pending = Wireframe._imagePending;
|
|
276
|
+
if (cache.has(url)) return cache.get(url); // may be null
|
|
277
|
+
if (pending.has(url)) return pending.get(url);
|
|
278
|
+
|
|
279
|
+
const p = (async () => {
|
|
280
|
+
try {
|
|
281
|
+
const res = await fetch(url, { mode: 'cors', cache: 'force-cache' });
|
|
282
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
283
|
+
const blob = await res.blob();
|
|
284
|
+
const bitmap = await createImageBitmap(blob);
|
|
285
|
+
cache.set(url, bitmap);
|
|
286
|
+
// LRU bound
|
|
287
|
+
if (cache.size > 50) {
|
|
288
|
+
const firstKey = cache.keys().next().value;
|
|
289
|
+
const evicted = cache.get(firstKey);
|
|
290
|
+
if (evicted && typeof evicted.close === 'function') {
|
|
291
|
+
try { evicted.close(); } catch { /* ignore */ }
|
|
292
|
+
}
|
|
293
|
+
cache.delete(firstKey);
|
|
294
|
+
}
|
|
295
|
+
return bitmap;
|
|
296
|
+
} catch (e) {
|
|
297
|
+
cache.set(url, null); // negative cache
|
|
298
|
+
return null;
|
|
299
|
+
} finally {
|
|
300
|
+
pending.delete(url);
|
|
301
|
+
}
|
|
302
|
+
})();
|
|
303
|
+
pending.set(url, p);
|
|
304
|
+
return p;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Draw a labeled placeholder in the "yellow hatched box" style — used
|
|
308
|
+
// whenever opts.images is off or the fetch failed. The label is the key
|
|
309
|
+
// thing: the agent reads it off the frame and can reason about the
|
|
310
|
+
// image's purpose without seeing the pixels.
|
|
311
|
+
_drawImagePlaceholder(ctx, label, x, y, w, h, radius) {
|
|
312
|
+
ctx.fillStyle = this.colors.image;
|
|
313
|
+
this._roundRect(ctx, x, y, w, h, radius);
|
|
314
|
+
ctx.fill();
|
|
315
|
+
ctx.strokeStyle = this.colors.imageStroke;
|
|
316
|
+
ctx.lineWidth = 1;
|
|
317
|
+
ctx.stroke();
|
|
318
|
+
if (this.showImages && w > 20 && h > 20) {
|
|
319
|
+
ctx.strokeStyle = this.colors.imageCross;
|
|
320
|
+
ctx.lineWidth = 0.5;
|
|
321
|
+
ctx.beginPath();
|
|
322
|
+
ctx.moveTo(x + 4, y + 4);
|
|
323
|
+
ctx.lineTo(x + w - 4, y + h - 4);
|
|
324
|
+
ctx.moveTo(x + w - 4, y + 4);
|
|
325
|
+
ctx.lineTo(x + 4, y + h - 4);
|
|
326
|
+
ctx.stroke();
|
|
327
|
+
}
|
|
328
|
+
// The label band: a semi-transparent stripe across the middle of the
|
|
329
|
+
// placeholder with the text on top. Sized to remain legible at 1fps
|
|
330
|
+
// capture resolution while not dominating the frame.
|
|
331
|
+
if (label && w > 40 && h > 18) {
|
|
332
|
+
const fontSize = Math.min(Math.max(Math.floor(h * 0.18), 11), 18);
|
|
333
|
+
ctx.font = `600 ${fontSize}px -apple-system, system-ui, sans-serif`;
|
|
334
|
+
const pad = 6;
|
|
335
|
+
const bandH = fontSize + pad * 2;
|
|
336
|
+
const bandY = y + (h - bandH) / 2;
|
|
337
|
+
// Truncate label to fit
|
|
338
|
+
let display = label;
|
|
339
|
+
const maxW = w - pad * 4;
|
|
340
|
+
while (ctx.measureText(display).width > maxW && display.length > 1) {
|
|
341
|
+
display = display.slice(0, -2) + '…';
|
|
342
|
+
}
|
|
343
|
+
const textW = ctx.measureText(display).width;
|
|
344
|
+
const bandW = Math.min(textW + pad * 2, w - 8);
|
|
345
|
+
const bandX = x + (w - bandW) / 2;
|
|
346
|
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
|
|
347
|
+
ctx.fillRect(bandX, bandY, bandW, bandH);
|
|
348
|
+
ctx.fillStyle = '#ffffff';
|
|
349
|
+
ctx.textBaseline = 'middle';
|
|
350
|
+
ctx.textAlign = 'center';
|
|
351
|
+
ctx.fillText(display, x + w / 2, bandY + bandH / 2);
|
|
352
|
+
ctx.textAlign = 'start';
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Draw an actual raster image (from the cache) into the given rect.
|
|
357
|
+
// Clips to the element's rounded-rect so the image respects border-radius.
|
|
358
|
+
_drawImageBitmap(ctx, bitmap, x, y, w, h, radius) {
|
|
359
|
+
try {
|
|
360
|
+
ctx.save();
|
|
361
|
+
this._roundRect(ctx, x, y, w, h, radius);
|
|
362
|
+
ctx.clip();
|
|
363
|
+
ctx.drawImage(bitmap, x, y, w, h);
|
|
364
|
+
ctx.restore();
|
|
365
|
+
ctx.strokeStyle = this.colors.imageStroke;
|
|
366
|
+
ctx.lineWidth = 0.5;
|
|
367
|
+
this._roundRect(ctx, x, y, w, h, radius);
|
|
368
|
+
ctx.stroke();
|
|
369
|
+
return true;
|
|
370
|
+
} catch (e) {
|
|
371
|
+
try { ctx.restore(); } catch { /* ignore */ }
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
_drawElementBox(ctx, { type, x, y, w, h, el, style, url, label }) {
|
|
377
|
+
const radius = Math.min(parseFloat(style.borderRadius) || 0, w / 2, h / 2, 8);
|
|
378
|
+
|
|
379
|
+
switch (type) {
|
|
380
|
+
case 'skip':
|
|
381
|
+
return; // don't draw images/video/canvas at all
|
|
382
|
+
|
|
383
|
+
case 'image': {
|
|
384
|
+
// Try the cached bitmap first. If opts.images is off, `url` may be
|
|
385
|
+
// set but the cache won't have it → falls through to placeholder.
|
|
386
|
+
const cached = url ? Wireframe._imageCache.get(url) : null;
|
|
387
|
+
if (cached && this._drawImageBitmap(ctx, cached, x, y, w, h, radius)) {
|
|
388
|
+
break;
|
|
389
|
+
}
|
|
390
|
+
this._drawImagePlaceholder(ctx, label || 'image', x, y, w, h, radius);
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
case 'icon': {
|
|
395
|
+
// Large <img> tags are photos/avatars — render as real images if we
|
|
396
|
+
// can, with a labeled placeholder fallback. Small <img> and <svg>
|
|
397
|
+
// keep the tight "icon label" treatment.
|
|
398
|
+
const isBigImg = el && el.tagName === 'IMG' && w >= 48 && h >= 48;
|
|
399
|
+
if (isBigImg) {
|
|
400
|
+
const cached = url ? Wireframe._imageCache.get(url) : null;
|
|
401
|
+
if (cached && this._drawImageBitmap(ctx, cached, x, y, w, h, radius)) {
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
this._drawImagePlaceholder(ctx, label || 'image', x, y, w, h, radius);
|
|
405
|
+
break;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const iconLabel = label || this._getIconLabel(el);
|
|
409
|
+
|
|
410
|
+
ctx.fillStyle = '#f1f5f9';
|
|
411
|
+
this._roundRect(ctx, x, y, w, h, 3);
|
|
412
|
+
ctx.fill();
|
|
413
|
+
ctx.strokeStyle = '#94a3b8';
|
|
414
|
+
ctx.lineWidth = 0.5;
|
|
415
|
+
ctx.stroke();
|
|
416
|
+
|
|
417
|
+
if (iconLabel && w >= 10 && h >= 8) {
|
|
418
|
+
const fontSize = Math.min(Math.max(h * 0.55, 7), 11);
|
|
419
|
+
ctx.fillStyle = '#64748b';
|
|
420
|
+
ctx.font = `${fontSize}px -apple-system, sans-serif`;
|
|
421
|
+
ctx.textBaseline = 'middle';
|
|
422
|
+
ctx.textAlign = 'center';
|
|
423
|
+
const display = iconLabel.length > 6 ? iconLabel.slice(0, 5) + '…' : iconLabel;
|
|
424
|
+
ctx.fillText(display, x + w / 2, y + h / 2);
|
|
425
|
+
ctx.textAlign = 'start';
|
|
426
|
+
}
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
case 'input':
|
|
431
|
+
ctx.fillStyle = this.colors.input;
|
|
432
|
+
this._roundRect(ctx, x, y, w, h, radius);
|
|
433
|
+
ctx.fill();
|
|
434
|
+
ctx.strokeStyle = this.colors.inputStroke;
|
|
435
|
+
ctx.lineWidth = 1;
|
|
436
|
+
ctx.stroke();
|
|
437
|
+
break;
|
|
438
|
+
|
|
439
|
+
case 'button': {
|
|
440
|
+
// Use actual background color if it has one
|
|
441
|
+
const btnBg = style.backgroundColor;
|
|
442
|
+
if (btnBg && btnBg !== 'rgba(0, 0, 0, 0)' && btnBg !== 'transparent') {
|
|
443
|
+
ctx.fillStyle = btnBg;
|
|
444
|
+
} else {
|
|
445
|
+
ctx.fillStyle = this.colors.button;
|
|
446
|
+
}
|
|
447
|
+
this._roundRect(ctx, x, y, w, h, radius);
|
|
448
|
+
ctx.fill();
|
|
449
|
+
ctx.strokeStyle = this.colors.buttonStroke;
|
|
450
|
+
ctx.lineWidth = 1;
|
|
451
|
+
ctx.stroke();
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
case 'text':
|
|
456
|
+
// Text elements don't need a box — just drawn in text pass
|
|
457
|
+
break;
|
|
458
|
+
|
|
459
|
+
case 'nav':
|
|
460
|
+
ctx.fillStyle = this.colors.nav;
|
|
461
|
+
this._roundRect(ctx, x, y, w, h, radius);
|
|
462
|
+
ctx.fill();
|
|
463
|
+
ctx.strokeStyle = this.colors.navStroke;
|
|
464
|
+
ctx.lineWidth = 1;
|
|
465
|
+
ctx.stroke();
|
|
466
|
+
break;
|
|
467
|
+
|
|
468
|
+
case 'block':
|
|
469
|
+
default:
|
|
470
|
+
ctx.strokeStyle = this.colors.blockStroke;
|
|
471
|
+
ctx.lineWidth = 0.5;
|
|
472
|
+
this._roundRect(ctx, x, y, w, h, radius);
|
|
473
|
+
ctx.stroke();
|
|
474
|
+
|
|
475
|
+
const hasBg = style.backgroundColor && style.backgroundColor !== 'rgba(0, 0, 0, 0)' && style.backgroundColor !== 'transparent';
|
|
476
|
+
if (hasBg) {
|
|
477
|
+
ctx.fillStyle = this.colors.block;
|
|
478
|
+
ctx.globalAlpha = 0.3;
|
|
479
|
+
this._roundRect(ctx, x, y, w, h, radius);
|
|
480
|
+
ctx.fill();
|
|
481
|
+
ctx.globalAlpha = 1;
|
|
482
|
+
}
|
|
483
|
+
break;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Use TreeWalker to find ALL visible text nodes, regardless of nesting depth
|
|
488
|
+
_drawAllText(ctx, rootRect) {
|
|
489
|
+
if (!this.showText) return;
|
|
490
|
+
this._drawTextInRoot(ctx, rootRect, this.root);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
_drawTextInRoot(ctx, rootRect, root) {
|
|
494
|
+
// Also handle shadow DOM roots
|
|
495
|
+
const els = root.querySelectorAll('*');
|
|
496
|
+
for (const el of els) {
|
|
497
|
+
if (el.shadowRoot) {
|
|
498
|
+
this._collectElements(el.shadowRoot, rootRect, 0);
|
|
499
|
+
this._drawTextInRoot(ctx, rootRect, el.shadowRoot);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
|
|
504
|
+
acceptNode: (node) => {
|
|
505
|
+
const text = node.textContent.trim();
|
|
506
|
+
if (!text) return NodeFilter.FILTER_REJECT;
|
|
507
|
+
return NodeFilter.FILTER_ACCEPT;
|
|
508
|
+
}
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
while (walker.nextNode()) {
|
|
512
|
+
const textNode = walker.currentNode;
|
|
513
|
+
const text = textNode.textContent.trim();
|
|
514
|
+
if (!text) continue;
|
|
515
|
+
|
|
516
|
+
// Get the parent element for positioning and style
|
|
517
|
+
const parent = textNode.parentElement;
|
|
518
|
+
if (!parent) continue;
|
|
519
|
+
|
|
520
|
+
// Use Range to get exact bounding rect of the text node
|
|
521
|
+
const range = document.createRange();
|
|
522
|
+
range.selectNodeContents(textNode);
|
|
523
|
+
const rects = range.getClientRects();
|
|
524
|
+
if (rects.length === 0) continue;
|
|
525
|
+
|
|
526
|
+
const style = window.getComputedStyle(parent);
|
|
527
|
+
if (style.visibility === 'hidden' || style.opacity === '0') continue;
|
|
528
|
+
|
|
529
|
+
// Draw text at each rect (text can wrap across lines)
|
|
530
|
+
for (const rect of rects) {
|
|
531
|
+
if (rect.width < 4 || rect.height < 4) continue;
|
|
532
|
+
if (rect.bottom < rootRect.top || rect.top > rootRect.bottom) continue;
|
|
533
|
+
|
|
534
|
+
const x = rect.left - rootRect.left;
|
|
535
|
+
const y = rect.top - rootRect.top;
|
|
536
|
+
const w = rect.width;
|
|
537
|
+
const h = rect.height;
|
|
538
|
+
|
|
539
|
+
const fontSize = Math.min(Math.max(parseFloat(style.fontSize) || 11, 9), 24);
|
|
540
|
+
const bold = parseInt(style.fontWeight) >= 600;
|
|
541
|
+
const isLink = parent.closest('a') !== null;
|
|
542
|
+
const isButton = parent.closest('button, [role="button"]') !== null;
|
|
543
|
+
|
|
544
|
+
// Detect if text is on a colored background (like blue "New" button)
|
|
545
|
+
let color = this.colors.text;
|
|
546
|
+
if (isLink) {
|
|
547
|
+
color = this.colors.link;
|
|
548
|
+
} else if (isButton) {
|
|
549
|
+
// Check if parent or ancestor button has a colored bg
|
|
550
|
+
const btn = parent.closest('button, [role="button"]');
|
|
551
|
+
if (btn) {
|
|
552
|
+
const btnStyle = window.getComputedStyle(btn);
|
|
553
|
+
const bg = btnStyle.backgroundColor;
|
|
554
|
+
if (bg && bg !== 'rgba(0, 0, 0, 0)' && bg !== 'transparent') {
|
|
555
|
+
// Dark background → white text
|
|
556
|
+
const isDarkBg = this._isDarkColor(bg);
|
|
557
|
+
color = isDarkBg ? '#ffffff' : this.colors.text;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
this._drawText(ctx, text, x, y, w, h, color, fontSize, 'left', isLink, bold);
|
|
563
|
+
this._textCount++;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
console.log(`[wireframe] drew ${this._textCount} text nodes`);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Parse rgb/rgba string and check if it's dark
|
|
570
|
+
_isDarkColor(colorStr) {
|
|
571
|
+
const match = colorStr.match(/\d+/g);
|
|
572
|
+
if (!match || match.length < 3) return false;
|
|
573
|
+
const [r, g, b] = match.map(Number);
|
|
574
|
+
// Luminance formula
|
|
575
|
+
return (r * 0.299 + g * 0.587 + b * 0.114) < 128;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
_drawText(ctx, text, x, y, w, h, color, fontSize = 11, align = 'left', underline = false, bold = false) {
|
|
579
|
+
if (w < 10 || h < 8) return;
|
|
580
|
+
|
|
581
|
+
ctx.fillStyle = color;
|
|
582
|
+
ctx.font = `${bold ? 'bold ' : ''}${fontSize}px -apple-system, sans-serif`;
|
|
583
|
+
ctx.textBaseline = 'middle';
|
|
584
|
+
|
|
585
|
+
// Truncate text to fit
|
|
586
|
+
let display = text;
|
|
587
|
+
while (ctx.measureText(display).width > w && display.length > 1) {
|
|
588
|
+
display = display.slice(0, -2) + '…';
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const textY = y + h / 2;
|
|
592
|
+
let textX = x;
|
|
593
|
+
if (align === 'center') {
|
|
594
|
+
textX = x + (w - ctx.measureText(display).width) / 2;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
ctx.fillText(display, textX, textY);
|
|
598
|
+
|
|
599
|
+
if (underline) {
|
|
600
|
+
const textW = ctx.measureText(display).width;
|
|
601
|
+
ctx.beginPath();
|
|
602
|
+
ctx.moveTo(textX, textY + fontSize / 2);
|
|
603
|
+
ctx.lineTo(textX + textW, textY + fontSize / 2);
|
|
604
|
+
ctx.strokeStyle = color;
|
|
605
|
+
ctx.lineWidth = 0.5;
|
|
606
|
+
ctx.stroke();
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
_roundRect(ctx, x, y, w, h, r) {
|
|
611
|
+
ctx.beginPath();
|
|
612
|
+
ctx.moveTo(x + r, y);
|
|
613
|
+
ctx.lineTo(x + w - r, y);
|
|
614
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
615
|
+
ctx.lineTo(x + w, y + h - r);
|
|
616
|
+
ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
617
|
+
ctx.lineTo(x + r, y + h);
|
|
618
|
+
ctx.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
619
|
+
ctx.lineTo(x, y + r);
|
|
620
|
+
ctx.quadraticCurveTo(x, y, x + r, y);
|
|
621
|
+
ctx.closePath();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// --- Export ---
|
|
625
|
+
async download(filename) {
|
|
626
|
+
const { canvas } = await this.capture();
|
|
627
|
+
canvas.toBlob(b => {
|
|
628
|
+
const a = document.createElement('a');
|
|
629
|
+
a.href = URL.createObjectURL(b);
|
|
630
|
+
a.download = filename || `wireframe-${Date.now()}.png`;
|
|
631
|
+
a.click();
|
|
632
|
+
URL.revokeObjectURL(a.href);
|
|
633
|
+
}, 'image/png');
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async toDataURL() {
|
|
637
|
+
const { canvas } = await this.capture();
|
|
638
|
+
return canvas.toDataURL('image/png');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async toBlob() {
|
|
642
|
+
const { canvas } = await this.capture();
|
|
643
|
+
return new Promise(r => canvas.toBlob(r, 'image/png'));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// --- Quick API ---
|
|
648
|
+
async function wireframe(el = document.body, opts = {}) {
|
|
649
|
+
const w = new Wireframe(el, opts);
|
|
650
|
+
return w.capture();
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async function demo() {
|
|
654
|
+
const w = new Wireframe(document.body, { scale: 1 });
|
|
655
|
+
|
|
656
|
+
// Benchmark
|
|
657
|
+
const runs = 5;
|
|
658
|
+
const times = [];
|
|
659
|
+
for (let i = 0; i < runs; i++) {
|
|
660
|
+
const { elapsed } = await w.capture();
|
|
661
|
+
times.push(elapsed);
|
|
662
|
+
}
|
|
663
|
+
const avg = times.reduce((a, b) => a + b) / times.length;
|
|
664
|
+
console.log(`\n=== WIREFRAME BENCHMARK ===`);
|
|
665
|
+
console.log(`Runs: ${times.map(t => t.toFixed(0) + 'ms').join(', ')}`);
|
|
666
|
+
console.log(`Avg: ${avg.toFixed(0)}ms`);
|
|
667
|
+
|
|
668
|
+
// Download
|
|
669
|
+
await w.download();
|
|
670
|
+
|
|
671
|
+
window.__wireframe = w;
|
|
672
|
+
return w;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
console.log('Wireframe ready! No dependencies needed.');
|
|
676
|
+
console.log(' await demo() — benchmark + download');
|
|
677
|
+
console.log(' await new Wireframe().download() — quick download');
|
|
678
|
+
console.log(' await new Wireframe().toDataURL() — base64 for LLM');
|
package/src/assets.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient declarations for text-asset imports.
|
|
3
|
+
*
|
|
4
|
+
* `wireframe.js.txt` and `visible-dom.js.txt` are shipped as text assets
|
|
5
|
+
* and eval'd at runtime (see `vision/wireframe.ts` and
|
|
6
|
+
* `browser-bridge/dom-state.ts`). Bun's bundler auto-inlines any `.txt`
|
|
7
|
+
* import as a string at build time; we only need the ambient type so the
|
|
8
|
+
* editor knows the default export is `string`.
|
|
9
|
+
*
|
|
10
|
+
* We deliberately avoid the TC39 `with { type: "text" }` import-attribute
|
|
11
|
+
* form: it requires TypeScript 5.3+ and editors bundling an older `tsc`
|
|
12
|
+
* (e.g. VS Code's built-in 5.1) parse `with` as the legacy statement and
|
|
13
|
+
* report "with statements are not allowed in strict mode" (TS1101).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
declare module "*.js.txt" {
|
|
17
|
+
const text: string;
|
|
18
|
+
export default text;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
declare module "*.txt" {
|
|
22
|
+
const text: string;
|
|
23
|
+
export default text;
|
|
24
|
+
}
|