haltija 1.12.0 → 1.12.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,96 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.12.1
4
+
5
+ A patch of fixes reported by an agent driving a real React + web-components admin app against
6
+ 1.12.0 — the kind of surface no fixture reproduces. Four of the five below are cases where haltija
7
+ answered confidently and wrongly, which is the same thread 1.12.0 was pulling on.
8
+
9
+ **Still open: [#26](https://github.com/tonioloewald/haltija/issues/26)** — tabs reportedly
10
+ disconnect permanently on webpack-dev-server (CRA) origins in 1.12.0, and rc.5 is unaffected. It is
11
+ **not fixed here**, because I could not reproduce it: a real webpack-dev-server (v5 client, `hot` +
12
+ `liveReload`) survived on both 1.12.0 and the rc.5 widget, the opposite of the reporter's A/B. The
13
+ re-injection fix below may cover it and may not. If you drive a CRA dev server, test before you rely
14
+ on this release, and please add to that issue.
15
+
16
+
17
+ ### Text selectors pick the element you could actually click — [#27](https://github.com/tonioloewald/haltija/issues/27)
18
+
19
+ Two ways the same selector chose the wrong element, both found driving a real admin app:
20
+
21
+ - **A hidden duplicate that came first won.** `click` took the first match in DOM order and left the
22
+ visibility gate to complain afterwards, so a `display:none` copy made `hj click ':text(Save
23
+ Changes)'` fail with "zero-size bounding rect" while the visible copy sat right there — and
24
+ `hj find`, which filters before choosing, returned the right one. One selector, two answers.
25
+ - **An off-canvas element was clicked silently.** The `position:absolute; left:-9999px` skip-link
26
+ idiom has a perfectly normal box (measured: 99x35 at x=-9999), so every size and style check
27
+ passed it. `click` actuated an element no human can see and reported **success** — a script then
28
+ asserts against a state it never produced. This is the worse of the two: it fails confidently.
29
+
30
+ Resolution now filters *before* choosing, and `find` and `click` share one predicate so they cannot
31
+ disagree. An off-canvas element that is the ONLY match fails loudly (`positioned off-canvas`) rather
32
+ than being clicked invisibly.
33
+
34
+ Off-canvas is measured in **page** coordinates, so content merely **below the fold** is unaffected —
35
+ visible still means *rendered*, not *on screen*. That is also why `elementFromPoint` isn't used
36
+ here despite catching this case: it rejects everything scrolled out of view, which would fail
37
+ legitimate content in a small headless viewport.
38
+
39
+ ### `hj doctor` probes requestAnimationFrame — [#28](https://github.com/tonioloewald/haltija/issues/28)
40
+
41
+ **A tab can report `visibilityState: "visible"` and still not be compositing.** Occluded windows,
42
+ offscreen windows and a sleeping display all do it. Nothing rAF-driven then renders — React's
43
+ scheduler, tosijs `queueRender`, animations, virtual scrollers — while geometry probes keep
44
+ returning real numbers, so the absence of an element stops being evidence of anything.
45
+
46
+ That doesn't merely hide information, it manufactures a plausible wrong answer. The reporter found
47
+ four routes "not mounting" on hard navigation, had a coherent mechanism (the router gates its first
48
+ mount on rAF), reproduced it four times, and nearly filed it as an application bug. Opening a second
49
+ tab fixed all four.
50
+
51
+ `hj doctor` now measures it directly and fails with `requestAnimationFrame DID NOT FIRE within 2s`.
52
+ If the probe itself can't run, that is reported as **unchecked** — never as a pass — and the probe
53
+ is bounded at 3s so a pre-flight can't hang on a socket that never answers.
54
+
55
+ ### `hj tabs` — the array is `windows`, and a `tabs` alias now exists ([#29](https://github.com/tonioloewald/haltija/issues/29))
56
+
57
+ `d['tabs']` KeyError'd because the payload key is `windows` — accurate, since the list holds popups
58
+ and iframes too, but the command is `hj tabs`, so the obvious guess failed and it looked like the
59
+ caller's bug. `tabs` is now sent as an alias of the same array. The hint no longer mixes the two
60
+ vocabularies in one sentence ("Multiple **tabs** connected. Use `?window=<id>`"), which is where the
61
+ wrong idea came from.
62
+
63
+ Popups being indistinguishable from user-opened tabs, the other half of that report, is fixed by the
64
+ popup work below: they carry `windowType: "popup"` and never take focus.
65
+
66
+ ### Popups are popups again (desktop app) — [#25](https://github.com/tonioloewald/haltija/issues/25)
67
+
68
+ A page calling `window.open(url, name, 'width=...')` now gets a genuine popup: `window.open()`
69
+ returns a real `WindowProxy`, the child has `window.opener`, and `opener.postMessage(...)` reaches
70
+ the parent.
71
+
72
+ Previously the desktop app decided by **guessing from the URL** — allow if it contained `oauth`,
73
+ `signin`, `login`, `accounts.google.com` or `/__/auth/`; deny everything else and re-open it as a
74
+ tab, which severs the opener in both directions. `window.open()` returned `null` and the child had
75
+ no `opener`. That is the shape of every OAuth popup flow: the SDK keeps the returned window to poll
76
+ `.closed` and to `.close()`, and the callback page delivers its credential via `opener.postMessage`.
77
+ With neither, a user can complete a sign-in in a window that cannot report back and the app just
78
+ waits — arguably worse than a clean block.
79
+
80
+ The heuristic failed both ways: an innocent `/login-help` page became a popup, while the common SDK
81
+ pattern of opening `about:blank` and *then* navigating matched nothing and was denied — the very
82
+ case the list existed to catch. The decision now keys on Electron's `disposition`, which is what the
83
+ page actually asked for, so there is nothing to guess.
84
+
85
+ Two things come free, because the window model already handled popups correctly: the popup registers
86
+ as `windowType: "popup"` (tellable from a user-opened tab) and it **does not steal focus**, so
87
+ untargeted commands keep going to the tab you were driving.
88
+
89
+ Ordinary `<a target="_blank">` links still open as tabs in the app's tab strip — that behaviour is
90
+ deliberate and unchanged. **Known residual:** a featureless `window.open(url, '_blank')` is
91
+ indistinguishable from a `target="_blank"` link at this layer (both report `foreground-tab`), so it
92
+ still becomes a tab and still returns `null`.
93
+
3
94
  ## 1.12.0 — trustworthy by default
4
95
 
5
96
  **Trustworthy by default.** A minor, gated on the nine-lens pre-release review: every finding it
@@ -690,28 +690,47 @@ function setupWebContentsInjection(wc) {
690
690
 
691
691
  console.log('[Haltija Desktop] Monitoring webContents:', wc.id, wc.getType())
692
692
 
693
- // Intercept window.open() calls - redirect to tabs instead of new windows
694
- // Exception: allow auth popups which need to close and callback
695
- wc.setWindowOpenHandler(({ url, frameName, features }) => {
696
- console.log('[Haltija Desktop] Intercepted window.open:', url)
697
-
698
- // Allow OAuth/auth popups - they need popup behavior to work
699
- const isAuthPopup =
700
- url.includes('accounts.google.com') ||
701
- url.includes('/__/auth/') ||
702
- url.includes('/emulator/auth') ||
703
- url.includes('firebaseapp.com/__/auth') ||
704
- url.includes('oauth') ||
705
- url.includes('signin') ||
706
- url.includes('login') ||
707
- frameName === 'firebaseAuth'
708
-
709
- if (isAuthPopup) {
710
- console.log('[Haltija Desktop] Allowing auth popup:', url)
693
+ // Intercept window.open(): a genuine POPUP stays a popup; an ordinary new-window link becomes a
694
+ // tab in our own tab strip.
695
+ //
696
+ // This used to decide by GUESSING FROM THE URL — allow if it contained `oauth`, `signin`,
697
+ // `login`, `accounts.google.com`, `/__/auth/`. Everything else was denied and re-opened as a tab,
698
+ // which severs the opener relationship in both directions: `window.open()` returns **null** and
699
+ // the child has no `window.opener`. That is the shape of every OAuth popup flow (issue #25):
700
+ // an SDK keeps the returned WindowProxy to poll `.closed` and to `.close()`, and the callback
701
+ // page delivers its credential via `opener.postMessage(...)`. With neither, the user can complete
702
+ // a sign-in in a window that cannot report back — worse than a clean block, because the app just
703
+ // waits.
704
+ //
705
+ // The heuristic failed in both directions: an innocent `/login-help` page became a popup, while
706
+ // the common SDK pattern of opening `about:blank` and *then* navigating matched nothing and was
707
+ // denied — the case the list was written to catch.
708
+ //
709
+ // `disposition` is what the page actually asked for, so there is nothing to guess (verified
710
+ // against Electron):
711
+ // window.open(url, name, 'width=420,height=320') -> 'new-window' features: "width=..."
712
+ // window.open(url, '_blank') -> 'foreground-tab' features: ""
713
+ // <a target="_blank"> -> 'foreground-tab' features: ""
714
+ //
715
+ // Allowing a real popup also fixes two things for free, because the window model already handles
716
+ // popups correctly: it registers as `windowType: "popup"` (so scripts can tell it from a
717
+ // user-opened tab) and it does NOT steal focus, since only real tabs become the untargeted
718
+ // command target.
719
+ //
720
+ // KNOWN RESIDUAL: a featureless `window.open(url, '_blank')` is indistinguishable from a
721
+ // `target="_blank"` link at this layer — both report 'foreground-tab' — so it still becomes a tab
722
+ // and still returns null. Preserving the tab UX for ordinary links is worth that; if an SDK turns
723
+ // up that opens featureless popups and needs the opener, this is the line to revisit.
724
+ wc.setWindowOpenHandler((details) => {
725
+ const { url, disposition } = details
726
+ console.log('[Haltija Desktop] Intercepted window.open:', url, `(disposition: ${disposition})`)
727
+
728
+ if (disposition === 'new-window') {
729
+ console.log('[Haltija Desktop] Genuine popup — preserving the opener relationship:', url)
711
730
  return { action: 'allow' }
712
731
  }
713
732
 
714
- // Regular links: open as new tab instead of window
733
+ // Ordinary new-window link: open as a tab instead.
715
734
  if (mainWindow && mainWindow.webContents) {
716
735
  mainWindow.webContents.send('open-url-in-tab', url)
717
736
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija-desktop",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "private": true,
5
5
  "description": "Haltija Desktop - God Mode Browser for AI Agents",
6
6
  "homepage": "https://github.com/tonioloewald/haltija",
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.12.0";
49
+ var VERSION = "1.12.1";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -624,6 +624,19 @@
624
624
  const candidates = queryAllDeep(parsed.baseSelector).filter((el) => !NON_RENDERED_TEXT.has(el.tagName) && elementTextMatches(el, parsed));
625
625
  return candidates.filter((el) => !candidates.some((other) => other !== el && containsDeep(el, other)));
626
626
  }
627
+ function isOffCanvas(el) {
628
+ const r = el.getBoundingClientRect();
629
+ return r.right + window.scrollX <= 0 || r.bottom + window.scrollY <= 0;
630
+ }
631
+ function isActionable(el) {
632
+ const cs = getComputedStyle(el);
633
+ if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0")
634
+ return false;
635
+ const r = el.getBoundingClientRect();
636
+ if (r.width <= 0 || r.height <= 0)
637
+ return false;
638
+ return !isOffCanvas(el);
639
+ }
627
640
  function resolveSelector(selector) {
628
641
  if (!TEXT_PSEUDO_RE.test(selector)) {
629
642
  return document.querySelector(selector) || queryAllDeep(selector)[0] || null;
@@ -631,7 +644,8 @@
631
644
  const parsed = parseTextSelector(selector);
632
645
  if (!parsed)
633
646
  return document.querySelector(selector);
634
- return textMatchesInDocument(parsed)[0] || null;
647
+ const matches = textMatchesInDocument(parsed);
648
+ return matches.find(isActionable) || matches[0] || null;
635
649
  }
636
650
  function resolveSelectorAll(selector) {
637
651
  if (!TEXT_PSEUDO_RE.test(selector)) {
@@ -5027,6 +5041,9 @@ ${elementSummary}${moreText}`;
5027
5041
  }
5028
5042
  this.render();
5029
5043
  }
5044
+ get isDefunct() {
5045
+ return this.killed;
5046
+ }
5030
5047
  kill() {
5031
5048
  this.killed = true;
5032
5049
  hideHighlight();
@@ -7555,6 +7572,9 @@ ${elementSummary}${moreText}`;
7555
7572
  };
7556
7573
  }
7557
7574
  getHiddenReason(el) {
7575
+ if (isOffCanvas(el)) {
7576
+ return "positioned off-canvas (e.g. left:-9999px) — rendered but not reachable by a user";
7577
+ }
7558
7578
  const rect = el.getBoundingClientRect();
7559
7579
  if (rect.width === 0 && rect.height === 0) {
7560
7580
  return "zero-size bounding rect (element not rendered or in hidden container)";
@@ -8711,6 +8731,7 @@ ${elementSummary}${moreText}`;
8711
8731
  currentTagName = TAG_NAME;
8712
8732
  window.__haltija_resolveSelector = resolveSelector;
8713
8733
  window.__haltija_resolveSelectorAll = resolveSelectorAll;
8734
+ window.__haltija_isActionable = isActionable;
8714
8735
  window.__haltija_refRegistry = refRegistry;
8715
8736
  }
8716
8737
  registerDevChannel();
@@ -8723,6 +8744,9 @@ ${elementSummary}${moreText}`;
8723
8744
  if (existingVersion !== VERSION2) {
8724
8745
  console.log(`${LOG_PREFIX} Version mismatch (${existingVersion} -> ${VERSION2}), replacing`);
8725
8746
  existing.remove();
8747
+ } else if (existing.isDefunct) {
8748
+ console.log(`${LOG_PREFIX} Existing widget is defunct (killed), replacing`);
8749
+ existing.remove();
8726
8750
  } else {
8727
8751
  return existing;
8728
8752
  }
package/bin/hj.mjs CHANGED
@@ -357,6 +357,58 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
357
357
  if (status.serverVersion && differsBeyondPatch(HJ_VERSION, status.serverVersion)) {
358
358
  notes.push(`hj ${HJ_VERSION} is driving server ${status.serverVersion} (version skew)`)
359
359
  }
360
+
361
+ // Does this tab actually PAINT? `visibilityState` answers "is this tab selected", not "is this
362
+ // tab being composited", and the two diverge for occluded windows, offscreen windows and a
363
+ // sleeping display. A starved tab renders nothing while reporting `visible`, geometry probes
364
+ // still return real numbers, and the absence of an element stops being evidence of anything.
365
+ //
366
+ // That is worse than a missing feature: it invites a plausible code-level explanation. An agent
367
+ // driving a React app found four routes "not mounting" on hard navigation, had a coherent
368
+ // mechanism (the router gates its first mount on rAF), reproduced it four times, and nearly
369
+ // filed it as an application bug. Opening a second tab fixed all four (#28). This check is here
370
+ // to convert that silent, confidently-wrong outcome into a visible one.
371
+ if (ready && tabs.length) {
372
+ const probe =
373
+ `new Promise(r => { const s = Date.now();` +
374
+ ` const t = setTimeout(() => r({ fired: false, ms: Date.now() - s }), 2000);` +
375
+ ` requestAnimationFrame(() => { clearTimeout(t); r({ fired: true, ms: Date.now() - s }) }) })`
376
+ let raf = null
377
+ try {
378
+ // BOUND IT. The probe resolves in ~2s at the latest when a browser is there to answer, so a
379
+ // longer wait means nothing is coming — and doctor is a pre-flight: a lane runs it to find
380
+ // out quickly, not to sit through another component's timeout. Without this, a connected
381
+ // socket that never answers `/eval` (a widget mid-teardown, or a test harness holding an
382
+ // open WebSocket) made `hj doctor` block for the server's full browser timeout.
383
+ const cancel = AbortSignal.timeout(3000)
384
+ const r = await fetch(`http://localhost:${port}/eval`, {
385
+ method: 'POST',
386
+ headers: { 'Content-Type': 'application/json', ...(token ? { 'X-Haltija-Token': token } : {}) },
387
+ body: JSON.stringify({ code: probe }),
388
+ signal: cancel,
389
+ })
390
+ if (r.ok) {
391
+ const j = await r.json()
392
+ if (j && j.success && j.data && typeof j.data.fired === 'boolean') raf = j.data
393
+ }
394
+ } catch {
395
+ // Fall through to the unchecked branch — never a pass.
396
+ }
397
+ if (raf === null) {
398
+ unchecked.push(
399
+ `could not run the requestAnimationFrame probe — whether this tab actually paints is ` +
400
+ `UNKNOWN. If elements seem missing, suspect a non-compositing tab before the page.`,
401
+ )
402
+ } else if (!raf.fired) {
403
+ problems.push(
404
+ `requestAnimationFrame DID NOT FIRE within 2s — this tab is not compositing, even though ` +
405
+ `it reports visibilityState "visible". Anything rAF-driven (React's scheduler, tosijs ` +
406
+ `queueRender, animations, virtual scrollers) will never render, so a missing element ` +
407
+ `is NOT evidence of an application bug. Bring a window to the front, or wake the ` +
408
+ `display, and re-run.`,
409
+ )
410
+ }
411
+ }
360
412
  // Declared origins decide WHICH tab answers. A broken declaration silently disables the
361
413
  // routing it configures, and doctor is where a CI lane finds out.
362
414
  const origins = describeOrigins(tabs)
package/bin/version.mjs CHANGED
@@ -3,4 +3,4 @@
3
3
  * ⚠️ To change the version, update package.json and run: bun run build
4
4
  */
5
5
 
6
- export const HJ_VERSION = '1.12.0'
6
+ export const HJ_VERSION = '1.12.1'
@@ -20,7 +20,7 @@
20
20
  * - Option+Tab toggles visibility (but active state always shows briefly)
21
21
  * - Localhost only by default
22
22
  */
23
- export declare const VERSION = "1.12.0";
23
+ export declare const VERSION = "1.12.1";
24
24
  export declare class DevChannel extends HTMLElement {
25
25
  static get tagName(): string;
26
26
  static elementCreator(): () => DevChannel;
@@ -195,6 +195,14 @@ export declare class DevChannel extends HTMLElement {
195
195
  private stopScreenCapture;
196
196
  private toggleMinimize;
197
197
  private togglePause;
198
+ /**
199
+ * Can this widget never talk to the server again?
200
+ *
201
+ * `killed` is set when the element is removed from the DOM or `kill()` runs, and it permanently
202
+ * stops the 3-second reconnect loop. An ordinary disconnected socket is NOT defunct — that retries
203
+ * forever and heals itself, so callers must not treat it as dead.
204
+ */
205
+ get isDefunct(): boolean;
198
206
  private kill;
199
207
  private testRecordingHandler;
200
208
  private toggleTestRecording;
@@ -1,5 +1,5 @@
1
1
  // src/version.ts
2
- var VERSION = "1.12.0";
2
+ var VERSION = "1.12.1";
3
3
 
4
4
  // src/text-selector.ts
5
5
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -577,6 +577,19 @@ function textMatchesInDocument(parsed) {
577
577
  const candidates = queryAllDeep(parsed.baseSelector).filter((el) => !NON_RENDERED_TEXT.has(el.tagName) && elementTextMatches(el, parsed));
578
578
  return candidates.filter((el) => !candidates.some((other) => other !== el && containsDeep(el, other)));
579
579
  }
580
+ function isOffCanvas(el) {
581
+ const r = el.getBoundingClientRect();
582
+ return r.right + window.scrollX <= 0 || r.bottom + window.scrollY <= 0;
583
+ }
584
+ function isActionable(el) {
585
+ const cs = getComputedStyle(el);
586
+ if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0")
587
+ return false;
588
+ const r = el.getBoundingClientRect();
589
+ if (r.width <= 0 || r.height <= 0)
590
+ return false;
591
+ return !isOffCanvas(el);
592
+ }
580
593
  function resolveSelector(selector) {
581
594
  if (!TEXT_PSEUDO_RE.test(selector)) {
582
595
  return document.querySelector(selector) || queryAllDeep(selector)[0] || null;
@@ -584,7 +597,8 @@ function resolveSelector(selector) {
584
597
  const parsed = parseTextSelector(selector);
585
598
  if (!parsed)
586
599
  return document.querySelector(selector);
587
- return textMatchesInDocument(parsed)[0] || null;
600
+ const matches = textMatchesInDocument(parsed);
601
+ return matches.find(isActionable) || matches[0] || null;
588
602
  }
589
603
  function resolveSelectorAll(selector) {
590
604
  if (!TEXT_PSEUDO_RE.test(selector)) {
@@ -4980,6 +4994,9 @@ ${elementSummary}${moreText}`;
4980
4994
  }
4981
4995
  this.render();
4982
4996
  }
4997
+ get isDefunct() {
4998
+ return this.killed;
4999
+ }
4983
5000
  kill() {
4984
5001
  this.killed = true;
4985
5002
  hideHighlight();
@@ -7508,6 +7525,9 @@ ${elementSummary}${moreText}`;
7508
7525
  };
7509
7526
  }
7510
7527
  getHiddenReason(el) {
7528
+ if (isOffCanvas(el)) {
7529
+ return "positioned off-canvas (e.g. left:-9999px) — rendered but not reachable by a user";
7530
+ }
7511
7531
  const rect = el.getBoundingClientRect();
7512
7532
  if (rect.width === 0 && rect.height === 0) {
7513
7533
  return "zero-size bounding rect (element not rendered or in hidden container)";
@@ -8664,6 +8684,7 @@ function registerDevChannel() {
8664
8684
  currentTagName = TAG_NAME;
8665
8685
  window.__haltija_resolveSelector = resolveSelector;
8666
8686
  window.__haltija_resolveSelectorAll = resolveSelectorAll;
8687
+ window.__haltija_isActionable = isActionable;
8667
8688
  window.__haltija_refRegistry = refRegistry;
8668
8689
  }
8669
8690
  registerDevChannel();
@@ -8676,6 +8697,9 @@ function inject(serverUrl2 = "wss://localhost:8700/ws/browser", options) {
8676
8697
  if (existingVersion !== VERSION2) {
8677
8698
  console.log(`${LOG_PREFIX} Version mismatch (${existingVersion} -> ${VERSION2}), replacing`);
8678
8699
  existing.remove();
8700
+ } else if (existing.isDefunct) {
8701
+ console.log(`${LOG_PREFIX} Existing widget is defunct (killed), replacing`);
8702
+ existing.remove();
8679
8703
  } else {
8680
8704
  return existing;
8681
8705
  }
package/dist/component.js CHANGED
@@ -46,7 +46,7 @@
46
46
  });
47
47
 
48
48
  // src/version.ts
49
- var VERSION = "1.12.0";
49
+ var VERSION = "1.12.1";
50
50
 
51
51
  // src/text-selector.ts
52
52
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\(/;
@@ -624,6 +624,19 @@
624
624
  const candidates = queryAllDeep(parsed.baseSelector).filter((el) => !NON_RENDERED_TEXT.has(el.tagName) && elementTextMatches(el, parsed));
625
625
  return candidates.filter((el) => !candidates.some((other) => other !== el && containsDeep(el, other)));
626
626
  }
627
+ function isOffCanvas(el) {
628
+ const r = el.getBoundingClientRect();
629
+ return r.right + window.scrollX <= 0 || r.bottom + window.scrollY <= 0;
630
+ }
631
+ function isActionable(el) {
632
+ const cs = getComputedStyle(el);
633
+ if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0")
634
+ return false;
635
+ const r = el.getBoundingClientRect();
636
+ if (r.width <= 0 || r.height <= 0)
637
+ return false;
638
+ return !isOffCanvas(el);
639
+ }
627
640
  function resolveSelector(selector) {
628
641
  if (!TEXT_PSEUDO_RE.test(selector)) {
629
642
  return document.querySelector(selector) || queryAllDeep(selector)[0] || null;
@@ -631,7 +644,8 @@
631
644
  const parsed = parseTextSelector(selector);
632
645
  if (!parsed)
633
646
  return document.querySelector(selector);
634
- return textMatchesInDocument(parsed)[0] || null;
647
+ const matches = textMatchesInDocument(parsed);
648
+ return matches.find(isActionable) || matches[0] || null;
635
649
  }
636
650
  function resolveSelectorAll(selector) {
637
651
  if (!TEXT_PSEUDO_RE.test(selector)) {
@@ -5027,6 +5041,9 @@ ${elementSummary}${moreText}`;
5027
5041
  }
5028
5042
  this.render();
5029
5043
  }
5044
+ get isDefunct() {
5045
+ return this.killed;
5046
+ }
5030
5047
  kill() {
5031
5048
  this.killed = true;
5032
5049
  hideHighlight();
@@ -7555,6 +7572,9 @@ ${elementSummary}${moreText}`;
7555
7572
  };
7556
7573
  }
7557
7574
  getHiddenReason(el) {
7575
+ if (isOffCanvas(el)) {
7576
+ return "positioned off-canvas (e.g. left:-9999px) — rendered but not reachable by a user";
7577
+ }
7558
7578
  const rect = el.getBoundingClientRect();
7559
7579
  if (rect.width === 0 && rect.height === 0) {
7560
7580
  return "zero-size bounding rect (element not rendered or in hidden container)";
@@ -8711,6 +8731,7 @@ ${elementSummary}${moreText}`;
8711
8731
  currentTagName = TAG_NAME;
8712
8732
  window.__haltija_resolveSelector = resolveSelector;
8713
8733
  window.__haltija_resolveSelectorAll = resolveSelectorAll;
8734
+ window.__haltija_isActionable = isActionable;
8714
8735
  window.__haltija_refRegistry = refRegistry;
8715
8736
  }
8716
8737
  registerDevChannel();
@@ -8723,6 +8744,9 @@ ${elementSummary}${moreText}`;
8723
8744
  if (existingVersion !== VERSION2) {
8724
8745
  console.log(`${LOG_PREFIX} Version mismatch (${existingVersion} -> ${VERSION2}), replacing`);
8725
8746
  existing.remove();
8747
+ } else if (existing.isDefunct) {
8748
+ console.log(`${LOG_PREFIX} Existing widget is defunct (killed), replacing`);
8749
+ existing.remove();
8726
8750
  } else {
8727
8751
  return existing;
8728
8752
  }
package/dist/hj.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env bun
2
- // haltija-cli:do-not-edit v1.12.0
2
+ // haltija-cli:do-not-edit v1.12.1
3
3
  import { createRequire } from "node:module";
4
4
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
5
5
 
@@ -756,7 +756,7 @@ function substituteGeneratedVars(text, seed) {
756
756
  }
757
757
 
758
758
  // bin/version.mjs
759
- var HJ_VERSION = "1.12.0";
759
+ var HJ_VERSION = "1.12.1";
760
760
 
761
761
  // bin/semver.mjs
762
762
  function parseVersion(v) {
@@ -2676,6 +2676,29 @@ async function runDoctor(port, portSource, portSourceKind, jsonOutput) {
2676
2676
  if (status.serverVersion && differsBeyondPatch(HJ_VERSION, status.serverVersion)) {
2677
2677
  notes.push(`hj ${HJ_VERSION} is driving server ${status.serverVersion} (version skew)`);
2678
2678
  }
2679
+ if (ready && tabs.length) {
2680
+ const probe = `new Promise(r => { const s = Date.now();` + ` const t = setTimeout(() => r({ fired: false, ms: Date.now() - s }), 2000);` + ` requestAnimationFrame(() => { clearTimeout(t); r({ fired: true, ms: Date.now() - s }) }) })`;
2681
+ let raf = null;
2682
+ try {
2683
+ const cancel = AbortSignal.timeout(3000);
2684
+ const r = await fetch(`http://localhost:${port}/eval`, {
2685
+ method: "POST",
2686
+ headers: { "Content-Type": "application/json", ...token ? { "X-Haltija-Token": token } : {} },
2687
+ body: JSON.stringify({ code: probe }),
2688
+ signal: cancel
2689
+ });
2690
+ if (r.ok) {
2691
+ const j = await r.json();
2692
+ if (j && j.success && j.data && typeof j.data.fired === "boolean")
2693
+ raf = j.data;
2694
+ }
2695
+ } catch {}
2696
+ if (raf === null) {
2697
+ unchecked.push(`could not run the requestAnimationFrame probe — whether this tab actually paints is ` + `UNKNOWN. If elements seem missing, suspect a non-compositing tab before the page.`);
2698
+ } else if (!raf.fired) {
2699
+ problems.push(`requestAnimationFrame DID NOT FIRE within 2s — this tab is not compositing, even though ` + `it reports visibilityState "visible". Anything rAF-driven (React's scheduler, tosijs ` + `queueRender, animations, virtual scrollers) will never render, so a missing element ` + `is NOT evidence of an application bug. Bring a window to the front, or wake the ` + `display, and re-run.`);
2700
+ }
2701
+ }
2679
2702
  const origins = describeOrigins(tabs);
2680
2703
  if (origins.problem)
2681
2704
  problems.push(origins.problem);
package/dist/index.js CHANGED
@@ -674,7 +674,7 @@ var injectorCode = `
674
674
  `;
675
675
 
676
676
  // src/version.ts
677
- var VERSION = "1.12.0";
677
+ var VERSION = "1.12.1";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -2251,7 +2251,15 @@ Deprecated: Use POST /select {"action":"clear"} instead.
2251
2251
 
2252
2252
  Returns all connected browser windows/tabs with IDs, URLs, and titles.
2253
2253
 
2254
- Response: { windows: [{ id, url, title, focused, active, hidden, windowType }], count, ready, hint }
2254
+ Response: { windows: [{ id, url, title, focused, active, hidden, windowType }], tabs, count, ready, hint }
2255
+
2256
+ The array is **\`windows\`**, not \`tabs\` \u2014 it includes popups (\`windowType: "popup"\`) and
2257
+ iframes (\`"iframe"\`), not only tabs. Because the CLI command is \`hj tabs\`, \`d['tabs']\` is the
2258
+ natural first guess and used to KeyError, so **\`tabs\` is also sent as an alias** of the same
2259
+ array. Prefer \`windows\`; \`tabs\` exists so the obvious guess works.
2260
+
2261
+ Only real tabs can be the target of an UNTARGETED command \u2014 a popup or iframe never becomes
2262
+ \`focused\`, so a page opening a popup cannot silently re-route your commands.
2255
2263
 
2256
2264
  \`active\` and \`hidden\` are exact inverses, and BOTH are sent on purpose: /status historically
2257
2265
  emitted only \`hidden\` and /windows only \`active\`, so code moving between the two silently
@@ -3441,7 +3449,7 @@ var COMPONENT_JS = `(() => {
3441
3449
  });
3442
3450
 
3443
3451
  // src/version.ts
3444
- var VERSION = "1.12.0";
3452
+ var VERSION = "1.12.1";
3445
3453
 
3446
3454
  // src/text-selector.ts
3447
3455
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -4019,6 +4027,19 @@ var COMPONENT_JS = `(() => {
4019
4027
  const candidates = queryAllDeep(parsed.baseSelector).filter((el) => !NON_RENDERED_TEXT.has(el.tagName) && elementTextMatches(el, parsed));
4020
4028
  return candidates.filter((el) => !candidates.some((other) => other !== el && containsDeep(el, other)));
4021
4029
  }
4030
+ function isOffCanvas(el) {
4031
+ const r = el.getBoundingClientRect();
4032
+ return r.right + window.scrollX <= 0 || r.bottom + window.scrollY <= 0;
4033
+ }
4034
+ function isActionable(el) {
4035
+ const cs = getComputedStyle(el);
4036
+ if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0")
4037
+ return false;
4038
+ const r = el.getBoundingClientRect();
4039
+ if (r.width <= 0 || r.height <= 0)
4040
+ return false;
4041
+ return !isOffCanvas(el);
4042
+ }
4022
4043
  function resolveSelector(selector) {
4023
4044
  if (!TEXT_PSEUDO_RE.test(selector)) {
4024
4045
  return document.querySelector(selector) || queryAllDeep(selector)[0] || null;
@@ -4026,7 +4047,8 @@ var COMPONENT_JS = `(() => {
4026
4047
  const parsed = parseTextSelector(selector);
4027
4048
  if (!parsed)
4028
4049
  return document.querySelector(selector);
4029
- return textMatchesInDocument(parsed)[0] || null;
4050
+ const matches = textMatchesInDocument(parsed);
4051
+ return matches.find(isActionable) || matches[0] || null;
4030
4052
  }
4031
4053
  function resolveSelectorAll(selector) {
4032
4054
  if (!TEXT_PSEUDO_RE.test(selector)) {
@@ -8422,6 +8444,9 @@ var COMPONENT_JS = `(() => {
8422
8444
  }
8423
8445
  this.render();
8424
8446
  }
8447
+ get isDefunct() {
8448
+ return this.killed;
8449
+ }
8425
8450
  kill() {
8426
8451
  this.killed = true;
8427
8452
  hideHighlight();
@@ -10950,6 +10975,9 @@ var COMPONENT_JS = `(() => {
10950
10975
  };
10951
10976
  }
10952
10977
  getHiddenReason(el) {
10978
+ if (isOffCanvas(el)) {
10979
+ return "positioned off-canvas (e.g. left:-9999px) \u2014 rendered but not reachable by a user";
10980
+ }
10953
10981
  const rect = el.getBoundingClientRect();
10954
10982
  if (rect.width === 0 && rect.height === 0) {
10955
10983
  return "zero-size bounding rect (element not rendered or in hidden container)";
@@ -12106,6 +12134,7 @@ var COMPONENT_JS = `(() => {
12106
12134
  currentTagName = TAG_NAME;
12107
12135
  window.__haltija_resolveSelector = resolveSelector;
12108
12136
  window.__haltija_resolveSelectorAll = resolveSelectorAll;
12137
+ window.__haltija_isActionable = isActionable;
12109
12138
  window.__haltija_refRegistry = refRegistry;
12110
12139
  }
12111
12140
  registerDevChannel();
@@ -12118,6 +12147,9 @@ var COMPONENT_JS = `(() => {
12118
12147
  if (existingVersion !== VERSION2) {
12119
12148
  console.log(\`\${LOG_PREFIX} Version mismatch (\${existingVersion} -> \${VERSION2}), replacing\`);
12120
12149
  existing.remove();
12150
+ } else if (existing.isDefunct) {
12151
+ console.log(\`\${LOG_PREFIX} Existing widget is defunct (killed), replacing\`);
12152
+ existing.remove();
12121
12153
  } else {
12122
12154
  return existing;
12123
12155
  }
@@ -14547,7 +14579,15 @@ var windows = endpoint({
14547
14579
  summary: "List connected windows",
14548
14580
  description: `Returns all connected browser windows/tabs with IDs, URLs, and titles.
14549
14581
 
14550
- Response: { windows: [{ id, url, title, focused, active, hidden, windowType }], count, ready, hint }
14582
+ Response: { windows: [{ id, url, title, focused, active, hidden, windowType }], tabs, count, ready, hint }
14583
+
14584
+ The array is **\`windows\`**, not \`tabs\` \u2014 it includes popups (\`windowType: "popup"\`) and
14585
+ iframes (\`"iframe"\`), not only tabs. Because the CLI command is \`hj tabs\`, \`d['tabs']\` is the
14586
+ natural first guess and used to KeyError, so **\`tabs\` is also sent as an alias** of the same
14587
+ array. Prefer \`windows\`; \`tabs\` exists so the obvious guess works.
14588
+
14589
+ Only real tabs can be the target of an UNTARGETED command \u2014 a popup or iframe never becomes
14590
+ \`focused\`, so a page opening a popup cannot silently re-route your commands.
14551
14591
 
14552
14592
  \`active\` and \`hidden\` are exact inverses, and BOTH are sent on purpose: /status historically
14553
14593
  emitted only \`hidden\` and /windows only \`active\`, so code moving between the two silently
@@ -16578,12 +16618,17 @@ registerHandler(find, async (body, ctx) => {
16578
16618
  // "Rendered", matching assert visible/hidden \u2014 NOT \`offsetParent === null\`, which is null for
16579
16619
  // \`html\`, \`body\` and EVERY \`position: fixed\` element. Fixed shells, modals and sticky chrome
16580
16620
  // are perfectly visible and were all being skipped.
16581
- const isVisible = (el) => {
16621
+ //
16622
+ // Prefers the WIDGET'S OWN predicate so \`find\` and \`click\` cannot disagree about which element
16623
+ // a text selector means \u2014 they did, and it also let \`find\` return an element parked off-canvas
16624
+ // at \`left:-9999px\` (#27). The inline copy below is only a fallback for a widget too old to
16625
+ // export it; keeping two rules in permanent use is what caused #24 in the first place.
16626
+ const isVisible = window.__haltija_isActionable || ((el) => {
16582
16627
  const cs = getComputedStyle(el);
16583
16628
  if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
16584
16629
  const r = el.getBoundingClientRect();
16585
16630
  return r.width > 0 && r.height > 0;
16586
- };
16631
+ });
16587
16632
 
16588
16633
  const textOf = (el) => (el.textContent || '').trim();
16589
16634
  const hits = elements.filter((el) => {
@@ -21014,9 +21059,10 @@ ${messageText}`;
21014
21059
  label: w.label
21015
21060
  }));
21016
21061
  const ready = isDrivable(windowList);
21017
- const hint = windowList.length > 1 ? "Multiple tabs connected. Use ?window=<id> to target specific tab (e.g., /tree?window=abc123)" : windowList.length === 1 ? "One tab connected. Commands automatically target it." : "No tabs connected. Inject the widget into a browser tab.";
21062
+ const hint = windowList.length > 1 ? "Multiple windows connected. Use ?window=<id> to target a specific one (e.g., /tree?window=abc123). The array is `windows` \u2014 it includes popups and iframes, not only tabs." : windowList.length === 1 ? "One window connected. Commands automatically target it." : "No windows connected. Inject the widget into a browser tab.";
21018
21063
  return Response.json({
21019
21064
  windows: windowList,
21065
+ tabs: windowList,
21020
21066
  focused: focusedWindowId,
21021
21067
  count: windowList.length,
21022
21068
  ready,
package/dist/server.js CHANGED
@@ -674,7 +674,7 @@ var injectorCode = `
674
674
  `;
675
675
 
676
676
  // src/version.ts
677
- var VERSION = "1.12.0";
677
+ var VERSION = "1.12.1";
678
678
 
679
679
  // src/embedded-assets.ts
680
680
  var APP_MD = `# Haltija App
@@ -2251,7 +2251,15 @@ Deprecated: Use POST /select {"action":"clear"} instead.
2251
2251
 
2252
2252
  Returns all connected browser windows/tabs with IDs, URLs, and titles.
2253
2253
 
2254
- Response: { windows: [{ id, url, title, focused, active, hidden, windowType }], count, ready, hint }
2254
+ Response: { windows: [{ id, url, title, focused, active, hidden, windowType }], tabs, count, ready, hint }
2255
+
2256
+ The array is **\`windows\`**, not \`tabs\` \u2014 it includes popups (\`windowType: "popup"\`) and
2257
+ iframes (\`"iframe"\`), not only tabs. Because the CLI command is \`hj tabs\`, \`d['tabs']\` is the
2258
+ natural first guess and used to KeyError, so **\`tabs\` is also sent as an alias** of the same
2259
+ array. Prefer \`windows\`; \`tabs\` exists so the obvious guess works.
2260
+
2261
+ Only real tabs can be the target of an UNTARGETED command \u2014 a popup or iframe never becomes
2262
+ \`focused\`, so a page opening a popup cannot silently re-route your commands.
2255
2263
 
2256
2264
  \`active\` and \`hidden\` are exact inverses, and BOTH are sent on purpose: /status historically
2257
2265
  emitted only \`hidden\` and /windows only \`active\`, so code moving between the two silently
@@ -3441,7 +3449,7 @@ var COMPONENT_JS = `(() => {
3441
3449
  });
3442
3450
 
3443
3451
  // src/version.ts
3444
- var VERSION = "1.12.0";
3452
+ var VERSION = "1.12.1";
3445
3453
 
3446
3454
  // src/text-selector.ts
3447
3455
  var TEXT_PSEUDO_RE = /:(?:text-is|has-text|text)\\(/;
@@ -4019,6 +4027,19 @@ var COMPONENT_JS = `(() => {
4019
4027
  const candidates = queryAllDeep(parsed.baseSelector).filter((el) => !NON_RENDERED_TEXT.has(el.tagName) && elementTextMatches(el, parsed));
4020
4028
  return candidates.filter((el) => !candidates.some((other) => other !== el && containsDeep(el, other)));
4021
4029
  }
4030
+ function isOffCanvas(el) {
4031
+ const r = el.getBoundingClientRect();
4032
+ return r.right + window.scrollX <= 0 || r.bottom + window.scrollY <= 0;
4033
+ }
4034
+ function isActionable(el) {
4035
+ const cs = getComputedStyle(el);
4036
+ if (cs.display === "none" || cs.visibility === "hidden" || cs.opacity === "0")
4037
+ return false;
4038
+ const r = el.getBoundingClientRect();
4039
+ if (r.width <= 0 || r.height <= 0)
4040
+ return false;
4041
+ return !isOffCanvas(el);
4042
+ }
4022
4043
  function resolveSelector(selector) {
4023
4044
  if (!TEXT_PSEUDO_RE.test(selector)) {
4024
4045
  return document.querySelector(selector) || queryAllDeep(selector)[0] || null;
@@ -4026,7 +4047,8 @@ var COMPONENT_JS = `(() => {
4026
4047
  const parsed = parseTextSelector(selector);
4027
4048
  if (!parsed)
4028
4049
  return document.querySelector(selector);
4029
- return textMatchesInDocument(parsed)[0] || null;
4050
+ const matches = textMatchesInDocument(parsed);
4051
+ return matches.find(isActionable) || matches[0] || null;
4030
4052
  }
4031
4053
  function resolveSelectorAll(selector) {
4032
4054
  if (!TEXT_PSEUDO_RE.test(selector)) {
@@ -8422,6 +8444,9 @@ var COMPONENT_JS = `(() => {
8422
8444
  }
8423
8445
  this.render();
8424
8446
  }
8447
+ get isDefunct() {
8448
+ return this.killed;
8449
+ }
8425
8450
  kill() {
8426
8451
  this.killed = true;
8427
8452
  hideHighlight();
@@ -10950,6 +10975,9 @@ var COMPONENT_JS = `(() => {
10950
10975
  };
10951
10976
  }
10952
10977
  getHiddenReason(el) {
10978
+ if (isOffCanvas(el)) {
10979
+ return "positioned off-canvas (e.g. left:-9999px) \u2014 rendered but not reachable by a user";
10980
+ }
10953
10981
  const rect = el.getBoundingClientRect();
10954
10982
  if (rect.width === 0 && rect.height === 0) {
10955
10983
  return "zero-size bounding rect (element not rendered or in hidden container)";
@@ -12106,6 +12134,7 @@ var COMPONENT_JS = `(() => {
12106
12134
  currentTagName = TAG_NAME;
12107
12135
  window.__haltija_resolveSelector = resolveSelector;
12108
12136
  window.__haltija_resolveSelectorAll = resolveSelectorAll;
12137
+ window.__haltija_isActionable = isActionable;
12109
12138
  window.__haltija_refRegistry = refRegistry;
12110
12139
  }
12111
12140
  registerDevChannel();
@@ -12118,6 +12147,9 @@ var COMPONENT_JS = `(() => {
12118
12147
  if (existingVersion !== VERSION2) {
12119
12148
  console.log(\`\${LOG_PREFIX} Version mismatch (\${existingVersion} -> \${VERSION2}), replacing\`);
12120
12149
  existing.remove();
12150
+ } else if (existing.isDefunct) {
12151
+ console.log(\`\${LOG_PREFIX} Existing widget is defunct (killed), replacing\`);
12152
+ existing.remove();
12121
12153
  } else {
12122
12154
  return existing;
12123
12155
  }
@@ -14547,7 +14579,15 @@ var windows = endpoint({
14547
14579
  summary: "List connected windows",
14548
14580
  description: `Returns all connected browser windows/tabs with IDs, URLs, and titles.
14549
14581
 
14550
- Response: { windows: [{ id, url, title, focused, active, hidden, windowType }], count, ready, hint }
14582
+ Response: { windows: [{ id, url, title, focused, active, hidden, windowType }], tabs, count, ready, hint }
14583
+
14584
+ The array is **\`windows\`**, not \`tabs\` \u2014 it includes popups (\`windowType: "popup"\`) and
14585
+ iframes (\`"iframe"\`), not only tabs. Because the CLI command is \`hj tabs\`, \`d['tabs']\` is the
14586
+ natural first guess and used to KeyError, so **\`tabs\` is also sent as an alias** of the same
14587
+ array. Prefer \`windows\`; \`tabs\` exists so the obvious guess works.
14588
+
14589
+ Only real tabs can be the target of an UNTARGETED command \u2014 a popup or iframe never becomes
14590
+ \`focused\`, so a page opening a popup cannot silently re-route your commands.
14551
14591
 
14552
14592
  \`active\` and \`hidden\` are exact inverses, and BOTH are sent on purpose: /status historically
14553
14593
  emitted only \`hidden\` and /windows only \`active\`, so code moving between the two silently
@@ -16578,12 +16618,17 @@ registerHandler(find, async (body, ctx) => {
16578
16618
  // "Rendered", matching assert visible/hidden \u2014 NOT \`offsetParent === null\`, which is null for
16579
16619
  // \`html\`, \`body\` and EVERY \`position: fixed\` element. Fixed shells, modals and sticky chrome
16580
16620
  // are perfectly visible and were all being skipped.
16581
- const isVisible = (el) => {
16621
+ //
16622
+ // Prefers the WIDGET'S OWN predicate so \`find\` and \`click\` cannot disagree about which element
16623
+ // a text selector means \u2014 they did, and it also let \`find\` return an element parked off-canvas
16624
+ // at \`left:-9999px\` (#27). The inline copy below is only a fallback for a widget too old to
16625
+ // export it; keeping two rules in permanent use is what caused #24 in the first place.
16626
+ const isVisible = window.__haltija_isActionable || ((el) => {
16582
16627
  const cs = getComputedStyle(el);
16583
16628
  if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
16584
16629
  const r = el.getBoundingClientRect();
16585
16630
  return r.width > 0 && r.height > 0;
16586
- };
16631
+ });
16587
16632
 
16588
16633
  const textOf = (el) => (el.textContent || '').trim();
16589
16634
  const hits = elements.filter((el) => {
@@ -21014,9 +21059,10 @@ ${messageText}`;
21014
21059
  label: w.label
21015
21060
  }));
21016
21061
  const ready = isDrivable(windowList);
21017
- const hint = windowList.length > 1 ? "Multiple tabs connected. Use ?window=<id> to target specific tab (e.g., /tree?window=abc123)" : windowList.length === 1 ? "One tab connected. Commands automatically target it." : "No tabs connected. Inject the widget into a browser tab.";
21062
+ const hint = windowList.length > 1 ? "Multiple windows connected. Use ?window=<id> to target a specific one (e.g., /tree?window=abc123). The array is `windows` \u2014 it includes popups and iframes, not only tabs." : windowList.length === 1 ? "One window connected. Commands automatically target it." : "No windows connected. Inject the widget into a browser tab.";
21018
21063
  return Response.json({
21019
21064
  windows: windowList,
21065
+ tabs: windowList,
21020
21066
  focused: focusedWindowId,
21021
21067
  count: windowList.length,
21022
21068
  ready,
package/dist/version.d.ts CHANGED
@@ -8,4 +8,4 @@
8
8
  * ⚠️ AUTO-GENERATED FROM package.json - DO NOT EDIT THIS FILE
9
9
  * ⚠️ To change the version, update package.json and run: bun run build
10
10
  */
11
- export declare const VERSION = "1.12.0";
11
+ export declare const VERSION = "1.12.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "haltija",
3
- "version": "1.12.0",
3
+ "version": "1.12.1",
4
4
  "description": "Browser control for AI agents - query DOM, click, type, run JS, watch mutations",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",