@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.
Files changed (46) hide show
  1. package/README.md +55 -0
  2. package/dist/esm/index.js +2431 -0
  3. package/dist/sable.iife.js +1486 -0
  4. package/dist/types/browser-bridge/actions.d.ts +27 -0
  5. package/dist/types/browser-bridge/dom-state.d.ts +37 -0
  6. package/dist/types/browser-bridge/index.d.ts +19 -0
  7. package/dist/types/connection/index.d.ts +26 -0
  8. package/dist/types/events/index.d.ts +15 -0
  9. package/dist/types/global.d.ts +26 -0
  10. package/dist/types/index.d.ts +23 -0
  11. package/dist/types/rpc.d.ts +22 -0
  12. package/dist/types/runtime/clipboard.d.ts +14 -0
  13. package/dist/types/runtime/index.d.ts +36 -0
  14. package/dist/types/runtime/video-overlay.d.ts +14 -0
  15. package/dist/types/session/debug-panel.d.ts +29 -0
  16. package/dist/types/session/index.d.ts +41 -0
  17. package/dist/types/types/index.d.ts +131 -0
  18. package/dist/types/version.d.ts +7 -0
  19. package/dist/types/vision/frame-source.d.ts +34 -0
  20. package/dist/types/vision/index.d.ts +29 -0
  21. package/dist/types/vision/publisher.d.ts +44 -0
  22. package/dist/types/vision/wireframe.d.ts +22 -0
  23. package/package.json +61 -0
  24. package/src/assets/visible-dom.js.txt +764 -0
  25. package/src/assets/wireframe.js.txt +678 -0
  26. package/src/assets.d.ts +24 -0
  27. package/src/browser-bridge/actions.ts +161 -0
  28. package/src/browser-bridge/dom-state.ts +103 -0
  29. package/src/browser-bridge/index.ts +99 -0
  30. package/src/connection/index.ts +49 -0
  31. package/src/events/index.ts +50 -0
  32. package/src/global.ts +35 -0
  33. package/src/index.test.ts +6 -0
  34. package/src/index.ts +43 -0
  35. package/src/rpc.ts +31 -0
  36. package/src/runtime/clipboard.ts +47 -0
  37. package/src/runtime/index.ts +138 -0
  38. package/src/runtime/video-overlay.ts +94 -0
  39. package/src/session/debug-panel.ts +254 -0
  40. package/src/session/index.ts +375 -0
  41. package/src/types/index.ts +176 -0
  42. package/src/version.ts +8 -0
  43. package/src/vision/frame-source.ts +111 -0
  44. package/src/vision/index.ts +70 -0
  45. package/src/vision/publisher.ts +106 -0
  46. 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');
@@ -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
+ }