@ubio/webvision 3.1.8 → 3.2.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 ADDED
@@ -0,0 +1,173 @@
1
+ # WebVision
2
+
3
+ **Structured, ref-addressable views of the live DOM** for automation, debugging, and tooling.
4
+
5
+ WebVision walks the browser document, builds a compact **VX tree** (semantic nodes with stable refs), and can **render** it as text, **highlight** regions on the page, and **resolve** refs back to real DOM nodes. It is designed for scenarios where you need a consistent, inspectable snapshot of “what’s on screen” without shipping a full browser automation stack.
6
+
7
+ ---
8
+
9
+ ## Why use it?
10
+
11
+ - **Stable refs** — Each meaningful node gets a ref you can use to talk about “that button” or “that heading” across snapshots and tools.
12
+ - **Readable dumps** — Turn the tree into a line-oriented string (tags, ids, classes, key attrs, text) for LLMs, logs, or diffing.
13
+ - **Visual debugging** — Draw overlays on elements that correspond to VX nodes.
14
+ - **Shadow DOM** — Open shadow roots are flattened into the tree by default (see [Shadow DOM](#shadow-dom)).
15
+ - **Ships for browser and Node-oriented bundles** — ESM page bundle, IIFE global, optional Tampermonkey userscript for the **page** console.
16
+
17
+ ---
18
+
19
+ ## Install
20
+
21
+ ```bash
22
+ npm install @ubio/webvision
23
+ ```
24
+
25
+ Build artifacts (`out/page`, `build/*`) are included in the published package. Run `npm run compile` after cloning the repo if you develop from source.
26
+
27
+ ---
28
+
29
+ ## Quick start (ESM)
30
+
31
+ ```javascript
32
+ import { captureSnapshot } from '@ubio/webvision';
33
+
34
+ const tree = await captureSnapshot();
35
+ console.log(tree.render({ renderRefs: true, renderTagNames: true }));
36
+ ```
37
+
38
+ `captureSnapshot()` parses `document`, stores the latest tree and ref→DOM map on `globalThis`, and returns a **`VxTreeView`**.
39
+
40
+ ---
41
+
42
+ ## Core API
43
+
44
+ | Export | Role |
45
+ |--------|------|
46
+ | `captureSnapshot(options?)` | Parse the page; returns `VxTreeView`; updates last snapshot. |
47
+ | `getSnapshot()` | Return the last `VxTreeView` (throws if none). |
48
+ | `resolveDomNode(ref)` | Map a ref string to a DOM `Node` or `null`. |
49
+ | `renderVxNode(node, options?)` | Render a single `VxNode` subtree as text. |
50
+
51
+ ### `VxTreeView`
52
+
53
+ | Method / property | Description |
54
+ |-------------------|-------------|
55
+ | `render(options?)` | String dump of the frame’s tree (see `VxRenderOptions` in source). |
56
+ | `nodeCount` | Number of ref’d nodes in the map. |
57
+ | `highlight(options?)` | Overlay borders for refs (needs snapshot first). |
58
+ | `findNode(ref)` | Get the `VxNode` for a ref. |
59
+
60
+ ### Example: snapshot, render, highlight
61
+
62
+ ```javascript
63
+ import { captureSnapshot, resolveDomNode } from '@ubio/webvision';
64
+
65
+ const tree = await captureSnapshot();
66
+
67
+ console.log(tree.render({
68
+ renderRefs: true,
69
+ renderTagNames: true,
70
+ renderIds: true,
71
+ }));
72
+
73
+ tree.highlight({ clearOverlay: true });
74
+
75
+ const el = resolveDomNode('0abc'); // example ref from render output
76
+ ```
77
+
78
+ ### Parser options (`VxTreeOptions`)
79
+
80
+ Passed to `captureSnapshot({ ... })`:
81
+
82
+ | Option | Default | Meaning |
83
+ |--------|---------|---------|
84
+ | `flattenShadowDom` | `true` | Include **open** shadow roots after light-DOM children; set `false` for light DOM only. |
85
+ | `viewportOnly` | `false` | Drop nodes outside the viewport. |
86
+ | `probeViewport` | `false` | Extra viewport probing (see `probe.ts`). |
87
+ | `skipImages` | `false` | Omit `img` nodes when omitting. |
88
+ | `opaqueOverlays` | `false` | Try to flatten opaque overlays for parsing. |
89
+ | `unnestDivs` | `false` | Aggressive pruning of bare div/spans. |
90
+ | `frameId` / `iframeRef` | — | Multi-frame scenarios. |
91
+
92
+ ---
93
+
94
+ ## Shadow DOM
95
+
96
+ Open shadow trees are walked **after** each host’s light DOM children so the rendered tree matches a **flattened** structural view. **Closed** shadow roots cannot be accessed from script.
97
+
98
+ To restore the previous behavior (ignore shadow trees):
99
+
100
+ ```javascript
101
+ await captureSnapshot({ flattenShadowDom: false });
102
+ ```
103
+
104
+ ---
105
+
106
+ ## Package exports
107
+
108
+ | Import path | Output | Use case |
109
+ |-------------|--------|----------|
110
+ | `@ubio/webvision` | `out/page` (TypeScript build) | Types + ESM in TS projects. |
111
+ | `@ubio/webvision/page` | `build/page.mjs` | Single ESM bundle of the page module. |
112
+ | `@ubio/webvision/global` | `build/global.js` | IIFE; exposes `globalThis.WebVision` in the browser. |
113
+
114
+ Generate bundles from source:
115
+
116
+ ```bash
117
+ npm run compile:page # build/page.mjs
118
+ npm run compile:global # build/global.js + source map
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Browser console: Tampermonkey
124
+
125
+ For **`window.WebVision`** in the **page** DevTools console (not the extension isolated world), use the generated userscript.
126
+
127
+ 1. **Build** (from repo root):
128
+
129
+ ```bash
130
+ npm run compile:userscript
131
+ ```
132
+
133
+ Produces `build/global.js` and `build/webvision.user.js`.
134
+
135
+ 2. **Default workflow (hot reload)**
136
+ - Terminal A: `npm run serve:build` — serves `build/` at `http://127.0.0.1:3847`.
137
+ - Terminal B: `npm run dev:global` — rebuilds `global.js` on TS changes.
138
+ - Install **`build/webvision.user.js`** in Tampermonkey (Dashboard → install).
139
+ - Reload the tab; the script fetches `global.js` with a cache-busting query and injects it into the page.
140
+
141
+ 3. **Offline / no server** — embed the bundle when generating:
142
+
143
+ ```bash
144
+ WEBVISION_INLINE=1 npm run compile:userscript
145
+ ```
146
+
147
+ Reinstall the userscript after each rebuild (large file).
148
+
149
+ 4. **Custom URL** when building:
150
+
151
+ ```bash
152
+ WEBVISION_INJECT_URL=https://your.cdn/webvision/global.js npm run compile:userscript
153
+ ```
154
+
155
+ Requires `@grant GM.xmlHttpRequest` and matching `// @connect` for the host (generated for you for `http`/`https` URLs).
156
+
157
+ ---
158
+
159
+ ## Development
160
+
161
+ | Script | Purpose |
162
+ |--------|---------|
163
+ | `npm run compile` | Clean, `tsc`, bundle `page.mjs` + `global.js` + `webvision.user.js`. |
164
+ | `npm run dev` | Parallel `tsc -w` and esbuild watch for `page.mjs`. |
165
+ | `npm run dev:global` | Watch rebuild `build/global.js`. |
166
+ | `npm run dev:userscript` | One-shot userscript build, then `dev:global` watch. |
167
+ | `npm run lint` | ESLint. |
168
+
169
+ ---
170
+
171
+ ## License
172
+
173
+ ISC
package/build/global.js CHANGED
@@ -57,8 +57,10 @@ var WebVision = (() => {
57
57
  isHidden: () => isHidden,
58
58
  isInteractive: () => isInteractive,
59
59
  isObjectEmpty: () => isObjectEmpty,
60
+ isPopup: () => isPopup,
60
61
  isRandomIdentifier: () => isRandomIdentifier,
61
62
  isRectInViewport: () => isRectInViewport,
63
+ iterate: () => iterate,
62
64
  makeOverlaysOpaque: () => makeOverlaysOpaque,
63
65
  normalizeRef: () => normalizeRef,
64
66
  normalizeText: () => normalizeText,
@@ -71,6 +73,31 @@ var WebVision = (() => {
71
73
  truncateAttrValue: () => truncateAttrValue
72
74
  });
73
75
 
76
+ // src/page/util.ts
77
+ function isContainerNode(vxNode) {
78
+ if (vxNode.tagName === "select") {
79
+ return false;
80
+ }
81
+ const children = vxNode.children ?? [];
82
+ const hasTextChildren = children.some((child) => !child.tagName);
83
+ return children.length > 0 && !hasTextChildren;
84
+ }
85
+ function isObjectEmpty(obj) {
86
+ return !Object.values(obj).some((value) => !!value);
87
+ }
88
+ function normalizeRef(ref) {
89
+ return String(ref).toLowerCase().trim().replace(/[^a-z0-9]/gi, "");
90
+ }
91
+ function* iterate(items) {
92
+ if (!items) {
93
+ return;
94
+ }
95
+ for (let i = 0; i < items.length; i += 1) {
96
+ const item = items[i];
97
+ yield item;
98
+ }
99
+ }
100
+
74
101
  // src/page/dom.ts
75
102
  var ORIGINAL_STYLE_SYMBOL = Symbol("vx:originalStyle");
76
103
  var INTERACTIVE_TAGS = ["a", "button", "input", "textarea", "select", "label", "option", "optgroup"];
@@ -155,7 +182,15 @@ var WebVision = (() => {
155
182
  return true;
156
183
  }
157
184
  if (!hasVisibleArea(element)) {
158
- return [...element.children].every((el) => isDeepHidden(el));
185
+ if (element.children.length === 0) {
186
+ return true;
187
+ }
188
+ for (const child of iterate(element.children)) {
189
+ if (!isDeepHidden(child)) {
190
+ return false;
191
+ }
192
+ }
193
+ return true;
159
194
  }
160
195
  return false;
161
196
  }
@@ -181,6 +216,21 @@ var WebVision = (() => {
181
216
  }
182
217
  return false;
183
218
  }
219
+ function isPopup(el) {
220
+ const style = getComputedStyle(el);
221
+ const zIndex = parseInt(style.zIndex, 10);
222
+ const hasPopupPosition = style.position === "fixed" || style.position === "sticky" || style.position === "absolute" && zIndex >= 1e3;
223
+ if (!hasPopupPosition) {
224
+ return false;
225
+ }
226
+ const rect = el.getBoundingClientRect();
227
+ const area = rect.width * rect.height;
228
+ const viewportArea = window.innerWidth * window.innerHeight;
229
+ if (area <= viewportArea * 0.2) {
230
+ return false;
231
+ }
232
+ return true;
233
+ }
184
234
  function getViewportSize() {
185
235
  return {
186
236
  width: window.innerWidth,
@@ -291,11 +341,11 @@ var WebVision = (() => {
291
341
  return;
292
342
  }
293
343
  yield element;
294
- for (const child of element.children) {
344
+ for (const child of iterate(element.children)) {
295
345
  yield* traverseElements(child);
296
346
  }
297
347
  if (element.shadowRoot) {
298
- for (const child of element.shadowRoot.children) {
348
+ for (const child of iterate(element.shadowRoot.children)) {
299
349
  yield* traverseElements(child);
300
350
  }
301
351
  }
@@ -316,7 +366,7 @@ var WebVision = (() => {
316
366
  }
317
367
  for (const { x, y } of points.getAll()) {
318
368
  const element = document.elementFromPoint(x, y);
319
- if (element && !result.has(element)) {
369
+ if (element) {
320
370
  result.add(element);
321
371
  }
322
372
  }
@@ -355,22 +405,6 @@ var WebVision = (() => {
355
405
  }
356
406
  };
357
407
 
358
- // src/page/util.ts
359
- function isContainerNode(vxNode) {
360
- if (vxNode.tagName === "select") {
361
- return false;
362
- }
363
- const children = vxNode.children ?? [];
364
- const hasTextChildren = children.some((child) => !child.tagName);
365
- return children.length > 0 && !hasTextChildren;
366
- }
367
- function isObjectEmpty(obj) {
368
- return !Object.values(obj).some((value) => !!value);
369
- }
370
- function normalizeRef(ref) {
371
- return String(ref).toLowerCase().trim().replace(/[^a-z0-9]/gi, "");
372
- }
373
-
374
408
  // src/page/render.ts
375
409
  function renderVxNode(scope, options = {}) {
376
410
  const buffer = [];
@@ -388,14 +422,12 @@ var WebVision = (() => {
388
422
  return buffer.join("\n");
389
423
  }
390
424
  function renderIndentedLine(indent, vxNode, options = {}) {
391
- const diffPrefix = options.renderDiff ? vxNode.isNew ? "+ " : " " : "";
392
425
  if (!vxNode.tagName) {
393
- return [diffPrefix, indent, vxNode.textContent].filter(Boolean).join("");
426
+ return [indent, vxNode.textContent].filter(Boolean).join("");
394
427
  }
395
428
  const tagLine = renderTagLine(vxNode, options);
396
429
  const htmlStyle = options.renderStyle === "html";
397
430
  return [
398
- diffPrefix,
399
431
  indent,
400
432
  tagLine,
401
433
  htmlStyle ? "" : " ",
@@ -431,6 +463,9 @@ var WebVision = (() => {
431
463
  components.push(`[@${vxNode.ref}]`);
432
464
  }
433
465
  }
466
+ for (const meta of vxNode.meta ?? []) {
467
+ components.push(`[${meta}]`);
468
+ }
434
469
  const attrs = [];
435
470
  if (options.renderLabelAttrs) {
436
471
  for (const [attr, value] of Object.entries(vxNode.labelAttrs ?? {})) {
@@ -482,7 +517,7 @@ var WebVision = (() => {
482
517
  var VX_LAST_REFS_SYMBOL = Symbol("vx:lastRefs");
483
518
  async function captureSnapshot(options = {}) {
484
519
  const parser = new VxTreeParser(options);
485
- await parser.parse();
520
+ parser.parse();
486
521
  const domMap = parser.getDomMap();
487
522
  const vxTree = parser.getTree();
488
523
  globalThis[VX_DOM_SYMBOL] = domMap;
@@ -555,9 +590,7 @@ var WebVision = (() => {
555
590
  for (const ref of refs) {
556
591
  const el = resolveDomNode(ref);
557
592
  if (el instanceof Element) {
558
- const vxNode = this.findNode(ref);
559
- const isNew = vxNode?.isNew ?? false;
560
- const color = options.useColors ? void 0 : isNew ? "#0a0" : "#060";
593
+ const color = options.useColors ? void 0 : "#060";
561
594
  highlightEl(el, ref, color);
562
595
  }
563
596
  }
@@ -602,7 +635,7 @@ var WebVision = (() => {
602
635
  /title-/i
603
636
  // title-* attributes
604
637
  ];
605
- var VX_VALUE_ATTRS = ["value", "checked", "selected", "disabled", "readonly"];
638
+ var VX_VALUE_ATTRS = ["role", "value", "checked", "selected", "disabled", "readonly"];
606
639
  var VX_SRC_ATTRS = ["src", "href"];
607
640
  var VX_TAG_PREFERENCE = [
608
641
  "a",
@@ -721,6 +754,8 @@ var WebVision = (() => {
721
754
  const parentEl = el.matches("option, optgroup") ? el.closest("select") ?? el : el;
722
755
  const rect = parentEl.getBoundingClientRect();
723
756
  const id = el.getAttribute("id") ?? "";
757
+ const isProbeHit = this.isProbeHit(parentEl);
758
+ const isInViewport = isRectInViewport(rect, this.viewport);
724
759
  const vxNode = this.makeElementNode(el, {
725
760
  tagName: el.tagName.toLowerCase(),
726
761
  id: isRandomIdentifier(id) ? void 0 : id,
@@ -730,19 +765,23 @@ var WebVision = (() => {
730
765
  srcAttrs: this.collectSrcAttrs(el),
731
766
  hasVisibleArea: hasVisibleArea(parentEl),
732
767
  isInteractive: isInteractive(parentEl),
733
- isOutsideViewport: !isRectInViewport(rect, this.viewport),
734
- isProbeHit: this.isProbeHit(parentEl),
735
- isKept: el.matches(VX_KEEP_SELECTOR)
768
+ isOutsideViewport: !isInViewport,
769
+ isProbeHit,
770
+ isKept: el.matches(VX_KEEP_SELECTOR),
771
+ meta: [
772
+ isPopup(parentEl) ? "popup" : null,
773
+ isInViewport && !isProbeHit ? "occluded" : null
774
+ ].filter((_) => _ != null)
736
775
  });
737
776
  const children = [];
738
- for (const child of el.childNodes) {
777
+ for (const child of iterate(el.childNodes)) {
739
778
  const childNode = this.parseNode(child, vxNode);
740
779
  if (childNode != null) {
741
780
  children.push(childNode);
742
781
  }
743
782
  }
744
783
  if (this.options.flattenShadowDom !== false && el.shadowRoot) {
745
- for (const child of el.shadowRoot.childNodes) {
784
+ for (const child of iterate(el.shadowRoot.childNodes)) {
746
785
  const childNode = this.parseNode(child, vxNode);
747
786
  if (childNode != null) {
748
787
  children.push(childNode);
@@ -758,9 +797,8 @@ var WebVision = (() => {
758
797
  }
759
798
  makeElementNode(el, spec) {
760
799
  const existingRef = el[VX_NODE_REF_SYMBOL];
761
- const isNew = existingRef === void 0;
762
800
  let ref;
763
- if (isNew) {
801
+ if (existingRef === void 0) {
764
802
  const counter = this.nextCounterValue();
765
803
  ref = `${this.frameId}${counter}`;
766
804
  this.usedCounters.add(counter);
@@ -771,8 +809,7 @@ var WebVision = (() => {
771
809
  }
772
810
  const vxNode = {
773
811
  ...spec,
774
- ref,
775
- isNew
812
+ ref
776
813
  };
777
814
  this.domRefMap.set(ref, el);
778
815
  return vxNode;
@@ -867,7 +904,7 @@ var WebVision = (() => {
867
904
  }
868
905
  collectLabelAttrs(el) {
869
906
  const attrs = {};
870
- for (const attr of el.attributes) {
907
+ for (const attr of iterate(el.attributes)) {
871
908
  const attrName = attr.name;
872
909
  const value = truncateAttrValue(attr.value);
873
910
  if (!value) {
@@ -907,11 +944,11 @@ var WebVision = (() => {
907
944
  delete node[VX_NODE_REF_SYMBOL];
908
945
  delete node[VX_NODE_COUNTER_SYMBOL];
909
946
  if (node instanceof Element) {
910
- for (const child of node.children) {
947
+ for (const child of iterate(node.children)) {
911
948
  this.clearRecursive(child);
912
949
  }
913
950
  if (node.shadowRoot) {
914
- for (const child of node.shadowRoot.children) {
951
+ for (const child of iterate(node.shadowRoot.children)) {
915
952
  this.clearRecursive(child);
916
953
  }
917
954
  }
@@ -931,11 +968,11 @@ var WebVision = (() => {
931
968
  if (cnt !== void 0) {
932
969
  this.usedCounters.add(cnt);
933
970
  }
934
- for (const child of el.children ?? []) {
971
+ for (const child of iterate(el.children)) {
935
972
  this.collectDomCounters(child);
936
973
  }
937
974
  if (el.shadowRoot) {
938
- for (const child of el.shadowRoot.children) {
975
+ for (const child of iterate(el.shadowRoot.children)) {
939
976
  this.collectDomCounters(child);
940
977
  }
941
978
  }