@reticlehq/browser 3.1.0 → 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.
@@ -4,6 +4,7 @@ interface ClickGeometry {
4
4
  scrolledIntoView: boolean;
5
5
  }
6
6
  export declare const NO_GEOMETRY: ClickGeometry;
7
+ export declare function mouseEventFor(el: Element, type: string, init?: MouseEventInit): MouseEvent;
7
8
  export declare function fireClickSequence(el: HTMLElement, hold?: {
8
9
  ms: number;
9
10
  sleep: (ms: number) => Promise<void>;
@@ -7,11 +7,20 @@ export const NO_GEOMETRY = {
7
7
  occludedBy: null,
8
8
  scrolledIntoView: false,
9
9
  };
10
+ export function mouseEventFor(el, type, init = {}) {
11
+ return new MouseEvent(type, { ...init, view: el.ownerDocument.defaultView });
12
+ }
13
+ function pointerEventFor(el, type, init) {
14
+ const full = { ...init, view: el.ownerDocument.defaultView };
15
+ return 'function' === typeof PointerEvent
16
+ ? new PointerEvent(type, full)
17
+ : new MouseEvent(type, full);
18
+ }
10
19
  export async function fireClickSequence(el, hold) {
11
20
  const doc = el.ownerDocument;
12
21
  const from = doc.activeElement ?? doc.body;
13
22
  firePointer(el, 'pointerdown', from);
14
- asSyntheticInput(() => el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })));
23
+ asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mousedown', { bubbles: true, cancelable: true })));
15
24
  if (el.tabIndex >= 0 && 'function' === typeof el.focus)
16
25
  el.focus();
17
26
  let heldMs = 0;
@@ -21,8 +30,8 @@ export async function fireClickSequence(el, hold) {
21
30
  heldMs = hold.now() - startedAt;
22
31
  }
23
32
  firePointer(el, 'pointerup', from);
24
- asSyntheticInput(() => el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true })));
25
- const notPrevented = asSyntheticInput(() => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })));
33
+ asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mouseup', { bubbles: true, cancelable: true })));
34
+ const notPrevented = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', { bubbles: true, cancelable: true })));
26
35
  return { prevented: !notPrevented, heldMs };
27
36
  }
28
37
  function isMeasurable(rect) {
@@ -57,24 +66,10 @@ export function clickGeometry(el) {
57
66
  return { ...hitTest(el, rect), scrolledIntoView };
58
67
  }
59
68
  export function firePointer(el, type, relatedTarget = null) {
60
- asSyntheticInput(() => {
61
- if ('function' === typeof PointerEvent) {
62
- el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
63
- }
64
- else {
65
- el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
66
- }
67
- });
69
+ asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: true, cancelable: true, relatedTarget })));
68
70
  }
69
71
  export function firePointerNonBubbling(el, type, relatedTarget = null) {
70
- asSyntheticInput(() => {
71
- if ('function' === typeof PointerEvent) {
72
- el.dispatchEvent(new PointerEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
73
- }
74
- else {
75
- el.dispatchEvent(new MouseEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
76
- }
77
- });
72
+ asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, { bubbles: false, cancelable: true, relatedTarget })));
78
73
  }
79
74
  function makeDataTransfer(data) {
80
75
  if (typeof DataTransfer !== 'function')
@@ -117,12 +112,9 @@ export async function dragElement(source, target, data) {
117
112
  button: 0,
118
113
  ...(related !== undefined ? { relatedTarget: related } : {}),
119
114
  };
120
- if ('function' === typeof PointerEvent && type.startsWith('pointer')) {
121
- el.dispatchEvent(new PointerEvent(type, { ...init, pointerId: 1, isPrimary: true }));
122
- }
123
- else {
124
- el.dispatchEvent(new MouseEvent(type, init));
125
- }
115
+ el.dispatchEvent(type.startsWith('pointer')
116
+ ? pointerEventFor(el, type, { ...init, pointerId: 1, isPrimary: true })
117
+ : mouseEventFor(el, type, init));
126
118
  };
127
119
  const sourceRect = source.getBoundingClientRect();
128
120
  const destRect = target !== null ? dest.getBoundingClientRect() : null;
@@ -190,24 +182,18 @@ export async function fireTapSequence(el, hold) {
190
182
  }
191
183
  asSyntheticInput(() => el.dispatchEvent(makeTouchEvent('touchend', [])));
192
184
  firePointerTouch(el, 'pointerup');
193
- const clicked = asSyntheticInput(() => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })));
185
+ const clicked = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', { bubbles: true, cancelable: true })));
194
186
  return { prevented: !clicked, heldMs };
195
187
  }
196
188
  function firePointerTouch(el, type) {
197
- asSyntheticInput(() => {
198
- if ('function' === typeof PointerEvent) {
199
- el.dispatchEvent(new PointerEvent(type, {
200
- bubbles: true,
201
- cancelable: true,
202
- pointerType: 'touch',
203
- isPrimary: true,
204
- width: 23,
205
- height: 23,
206
- }));
207
- return;
208
- }
209
- el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
210
- });
189
+ asSyntheticInput(() => el.dispatchEvent(pointerEventFor(el, type, {
190
+ bubbles: true,
191
+ cancelable: true,
192
+ pointerType: 'touch',
193
+ isPrimary: true,
194
+ width: 23,
195
+ height: 23,
196
+ })));
211
197
  }
212
198
  function touchListFor(el) {
213
199
  if ('function' !== typeof Touch || 'function' !== typeof el.getBoundingClientRect)
@@ -124,7 +124,7 @@ function assertActionAllowed(el, action, args) {
124
124
  throw new Error(`potentially destructive action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
125
125
  }
126
126
  }
127
- import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, } from './actions-dom.js';
127
+ import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, mouseEventFor, } from './actions-dom.js';
128
128
  import { scrollFrom } from './scroll.js';
129
129
  function enabledOf(el) {
130
130
  return !getStates(el).includes(ElementState.DISABLED);
@@ -167,16 +167,16 @@ function asFiniteNumber(raw) {
167
167
  async function dispatchOther(el, action, args) {
168
168
  switch (action) {
169
169
  case ActionType.DBLCLICK:
170
- return !asSyntheticInput(() => el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })));
170
+ return !asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'dblclick', { bubbles: true, cancelable: true })));
171
171
  case ActionType.HOVER: {
172
172
  const doc = el.ownerDocument;
173
173
  const from = doc.activeElement ?? doc.body;
174
174
  firePointer(el, 'pointerover', from);
175
- el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: from }));
175
+ el.dispatchEvent(mouseEventFor(el, 'mouseover', { bubbles: true, relatedTarget: from }));
176
176
  firePointerNonBubbling(el, 'pointerenter', from);
177
- el.dispatchEvent(new MouseEvent('mouseenter', { relatedTarget: from }));
177
+ el.dispatchEvent(mouseEventFor(el, 'mouseenter', { relatedTarget: from }));
178
178
  firePointer(el, 'pointermove', from);
179
- const moved = el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true }));
179
+ const moved = el.dispatchEvent(mouseEventFor(el, 'mousemove', { bubbles: true, cancelable: true }));
180
180
  const holdMs = clampHold(args['holdMs']);
181
181
  if (holdMs > 0)
182
182
  await sleep(holdMs);
@@ -252,7 +252,11 @@ async function dispatchOther(el, action, args) {
252
252
  }
253
253
  if (alreadyAtCheckedState(el, action))
254
254
  return false;
255
- const event = new MouseEvent('click', { bubbles: true, cancelable: true, composed: true });
255
+ const event = mouseEventFor(el, 'click', {
256
+ bubbles: true,
257
+ cancelable: true,
258
+ composed: true,
259
+ });
256
260
  const notPrevented = asSyntheticInput(() => el.dispatchEvent(event));
257
261
  return !notPrevented || event.defaultPrevented;
258
262
  }
package/dist/dom/query.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
- import { isFrame, isHtmlElement, isInput, isSelect, isTextArea } from './realm.js';
2
+ import { isElement, isFrame, isHtmlElement, isInput, isSelect, isTextArea } from './realm.js';
3
3
  import { capturedRootOf } from './shadow-registry.js';
4
4
  import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
5
5
  import { isIgnored, isReticleOverlay } from './dom-ignore.js';
@@ -27,20 +27,35 @@ function fuzzyVisibleText(actual, expected) {
27
27
  function exactVisibleText(actual, expected) {
28
28
  return normaliseVisibleText(actual) === normaliseVisibleText(expected);
29
29
  }
30
+ const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
31
+ function isNonRendered(el) {
32
+ return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
33
+ }
30
34
  function directText(el) {
31
35
  if (isInput(el)) {
32
36
  const type = el.type.toLowerCase();
33
37
  if ('submit' === type || 'button' === type || 'reset' === type)
34
38
  return el.value;
35
39
  }
36
- const tag = el.tagName.toLowerCase();
37
- if ('script' === tag || 'style' === tag)
40
+ if (isNonRendered(el))
38
41
  return '';
39
42
  return Array.from(el.childNodes)
40
43
  .filter((node) => Node.TEXT_NODE === node.nodeType)
41
44
  .map((node) => node.textContent ?? '')
42
45
  .join('');
43
46
  }
47
+ function visibleTextOf(el, memo) {
48
+ if (isNonRendered(el) || isIgnored(el) || !isVisible(el, memo))
49
+ return '';
50
+ let text = '';
51
+ for (const node of Array.from(el.childNodes)) {
52
+ if (Node.TEXT_NODE === node.nodeType)
53
+ text += node.textContent ?? '';
54
+ else if (isElement(node))
55
+ text += visibleTextOf(node, memo);
56
+ }
57
+ return text;
58
+ }
44
59
  function semanticNameTarget(el) {
45
60
  if (isInput(el) || isTextArea(el) || isSelect(el))
46
61
  return true;
@@ -205,7 +220,8 @@ function findCandidates(query) {
205
220
  if (true === query.self) {
206
221
  if (query.scope === undefined)
207
222
  return { candidates: [], scopeMissing };
208
- if (query.text !== undefined && !fuzzyVisibleText(container.textContent ?? '', query.text)) {
223
+ if (query.text !== undefined &&
224
+ !fuzzyVisibleText(visibleTextOf(container, new Map()), query.text)) {
209
225
  return { candidates: [], scopeMissing };
210
226
  }
211
227
  return { candidates: isIgnored(container) ? [] : [container], scopeMissing };
@@ -342,10 +358,11 @@ function buildPresentRegions(query) {
342
358
  function splitTextOwner(container, wanted) {
343
359
  let best;
344
360
  let bestDepth = -1;
361
+ const memo = new Map();
345
362
  for (const el of elementsUnder(container)) {
346
363
  if (isIgnored(el))
347
364
  continue;
348
- if (!fuzzyVisibleText(el.textContent ?? '', wanted))
365
+ if (!fuzzyVisibleText(visibleTextOf(el, memo), wanted))
349
366
  continue;
350
367
  let depth = 0;
351
368
  for (let parent = el.parentElement; parent !== null; parent = parent.parentElement)
@@ -35,15 +35,23 @@ export function installDeparture(emit) {
35
35
  return;
36
36
  const href = anchor.getAttribute('href') ?? '';
37
37
  const here = document.location.href;
38
- if (!isDeparture(href, anchor.hasAttribute('download'), here))
38
+ const download = anchor.hasAttribute('download');
39
+ if (!isDeparture(href, download, here))
39
40
  return;
40
- seq += 1;
41
- emit(EventType.NET_PENDING, {
42
- id: `nav-${String(seq)}`,
43
- method: 'GET',
44
- url: new URL(href, here).toString(),
45
- initiator: NetInitiator.NAVIGATION,
46
- ...(anchor.hasAttribute('download') ? { download: true } : {}),
41
+ const url = new URL(href, here).toString();
42
+ queueMicrotask(() => {
43
+ observeSafely(() => {
44
+ if (event.defaultPrevented)
45
+ return;
46
+ seq += 1;
47
+ emit(EventType.NET_PENDING, {
48
+ id: `nav-${String(seq)}`,
49
+ method: 'GET',
50
+ url,
51
+ initiator: NetInitiator.NAVIGATION,
52
+ ...(download ? { download: true } : {}),
53
+ });
54
+ });
47
55
  });
48
56
  });
49
57
  };
@@ -1,4 +1,4 @@
1
- import { BlindSpotKind, EventType, RETICLE_WS_PATH, StreamTransport, StreamDirection, } from '@reticlehq/core';
1
+ import { BlindSpotKind, EventType, REQUEST_SHAPE_FIELD, REQUEST_SHAPE_NONE, RETICLE_WS_PATH, StreamTransport, StreamDirection, fnv1a, } from '@reticlehq/core';
2
2
  import { captureMethod } from '../patching/capture-method.js';
3
3
  import { observeSafely, observeValue } from './types.js';
4
4
  import { isCapturableType, projectBody, projectErrorBody, shouldCaptureErrorBody, withBodyDeadline, } from './net-detail/network-body.js';
@@ -31,26 +31,66 @@ function frameFields(data, captureBodies) {
31
31
  }
32
32
  return out;
33
33
  }
34
+ const ShapeToken = { NULL: '0', STRING: 's', NUMBER: 'n', BOOLEAN: 'b', OTHER: '?' };
35
+ function shapeOf(value) {
36
+ if (null === value)
37
+ return ShapeToken.NULL;
38
+ if (Array.isArray(value))
39
+ return `[${value.map(shapeOf).join(',')}]`;
40
+ if ('object' === typeof value) {
41
+ const fields = value;
42
+ return `{${Object.keys(fields)
43
+ .sort()
44
+ .map((key) => `${key}:${shapeOf(fields[key])}`)
45
+ .join(',')}}`;
46
+ }
47
+ if ('string' === typeof value)
48
+ return ShapeToken.STRING;
49
+ if ('number' === typeof value)
50
+ return ShapeToken.NUMBER;
51
+ if ('boolean' === typeof value)
52
+ return ShapeToken.BOOLEAN;
53
+ return ShapeToken.OTHER;
54
+ }
55
+ function fingerprintBody(shape, length) {
56
+ return fnv1a(`${shape}#${String(length)}`);
57
+ }
58
+ function jsonShape(text) {
59
+ try {
60
+ return shapeOf(JSON.parse(text));
61
+ }
62
+ catch {
63
+ return ShapeToken.OTHER;
64
+ }
65
+ }
34
66
  function projectRequestBody(body, captureBodies) {
35
- if (!captureBodies)
36
- return {};
37
67
  let text;
38
68
  let contentType = 'application/json';
69
+ let shape = REQUEST_SHAPE_NONE;
39
70
  if ('string' === typeof body) {
40
71
  text = body;
72
+ if (text.length > 0)
73
+ shape = fingerprintBody(jsonShape(text), text.length);
41
74
  }
42
75
  else if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
43
76
  text = body.toString();
44
77
  contentType = 'application/x-www-form-urlencoded';
78
+ if (text.length > 0) {
79
+ const fields = Object.fromEntries(new URLSearchParams(text));
80
+ shape = fingerprintBody(shapeOf(fields), text.length);
81
+ }
45
82
  }
46
83
  else if (body !== undefined && body !== null) {
47
- const shape = body.constructor?.name ?? typeof body;
48
- return { requestBodyType: shape };
84
+ const kind = body.constructor?.name ?? typeof body;
85
+ return captureBodies ? { requestBodyType: kind } : {};
49
86
  }
50
- if (text === undefined || 0 === text.length)
51
- return {};
87
+ const fields = { [REQUEST_SHAPE_FIELD]: shape };
88
+ if (!captureBodies || text === undefined || 0 === text.length)
89
+ return fields;
52
90
  const { body: out, truncated } = projectBody(text, contentType);
53
- return truncated ? { requestBody: out, requestBodyTruncated: true } : { requestBody: out };
91
+ return truncated
92
+ ? { ...fields, requestBody: out, requestBodyTruncated: true }
93
+ : { ...fields, requestBody: out };
54
94
  }
55
95
  function statusIsOk(status) {
56
96
  return status >= 200 && status < 400;
@@ -428,6 +468,7 @@ export function installNetwork(emit, opts = {}) {
428
468
  durationMs: 0,
429
469
  initiator: 'beacon',
430
470
  ...(initiatorStack === undefined ? {} : { initiatorStack }),
471
+ ...projectRequestBody(data, false),
431
472
  });
432
473
  });
433
474
  return sent;
@@ -0,0 +1,25 @@
1
+ export declare const OFFER_ATTR = "data-reticle-offer";
2
+ export declare const OFFER_SLOT_ATTR = "data-reticle-offer-slot";
3
+ export declare const OFFER_CLAIM_ATTR = "data-reticle-offer-claim";
4
+ export declare const OFFER_DISMISS_ATTR = "data-reticle-offer-dismiss";
5
+ export declare const OFFER_DISMISSED_KEY = "reticle.harnessOffer.dismissed";
6
+ export declare const OFFER_TEXT: {
7
+ readonly HEADLINE: "Reticle drives your app for you";
8
+ readonly BODY: "The harness explores your app, proves what works, and saves every journey as a test that replays for free. Built on Jev — no model API key needed.";
9
+ readonly FREE: "Free for 3 months";
10
+ readonly CLAIM: "Claim now";
11
+ readonly DISMISS: "Not now";
12
+ readonly claimed: (days: number) => string;
13
+ readonly ending: (days: number) => string;
14
+ };
15
+ export declare function offerDismissed(storage: Pick<Storage, 'getItem'> | undefined): boolean;
16
+ export declare function dismissOffer(storage: Pick<Storage, 'setItem'> | undefined): void;
17
+ export interface OfferState {
18
+ claimed: boolean;
19
+ daysRemaining?: number | undefined;
20
+ claimUrl?: string | undefined;
21
+ eligible?: boolean | undefined;
22
+ }
23
+ export declare function offerHtml(offer: OfferState | undefined, dismissed: boolean): string;
24
+ export declare function paintOffer(root: ParentNode, offer: OfferState | undefined, storage: Pick<Storage, 'getItem' | 'setItem'> | undefined): void;
25
+ export declare const OFFER_CSS = "\n.reticle-offer{margin:8px 10px;padding:10px 12px;border:1px solid var(--reticle-line,#2a2f3a);border-radius:10px;background:var(--reticle-surface-inset,rgba(255,255,255,.03));}\n.reticle-offer-head{display:flex;align-items:center;justify-content:space-between;gap:8px;}\n.reticle-offer-title{font-weight:600;font-size:12px;}\n.reticle-offer-free{font-size:10px;padding:2px 6px;border-radius:999px;background:var(--reticle-accent,#4f7cff);color:#fff;white-space:nowrap;}\n.reticle-offer-body{margin:6px 0 8px;font-size:11px;line-height:1.45;opacity:.8;}\n.reticle-offer-actions{display:flex;align-items:center;gap:8px;}\n.reticle-offer-claim{font-size:11px;font-weight:600;padding:4px 10px;border-radius:6px;background:var(--reticle-accent,#4f7cff);color:#fff;text-decoration:none;}\n.reticle-offer-dismiss{font-size:11px;padding:4px 8px;border-radius:6px;background:none;border:0;color:inherit;opacity:.6;cursor:pointer;}\n.reticle-offer-dismiss:hover{opacity:1;}\n.reticle-offer-claimed{margin:6px 10px;font-size:10px;opacity:.6;}\n";
@@ -0,0 +1,87 @@
1
+ export const OFFER_ATTR = 'data-reticle-offer';
2
+ export const OFFER_SLOT_ATTR = 'data-reticle-offer-slot';
3
+ export const OFFER_CLAIM_ATTR = 'data-reticle-offer-claim';
4
+ export const OFFER_DISMISS_ATTR = 'data-reticle-offer-dismiss';
5
+ export const OFFER_DISMISSED_KEY = 'reticle.harnessOffer.dismissed';
6
+ export const OFFER_TEXT = {
7
+ HEADLINE: 'Reticle drives your app for you',
8
+ BODY: 'The harness explores your app, proves what works, and saves every journey as a test that replays for free. Built on Jev — no model API key needed.',
9
+ FREE: 'Free for 3 months',
10
+ CLAIM: 'Claim now',
11
+ DISMISS: 'Not now',
12
+ claimed: (days) => `Harness active — ${String(days)} days left`,
13
+ ending: (days) => 1 >= days
14
+ ? 'Harness free period ends today'
15
+ : `Harness free period ends in ${String(days)} days`,
16
+ };
17
+ const ENDING_SOON_DAYS = 14;
18
+ export function offerDismissed(storage) {
19
+ try {
20
+ return '1' === storage?.getItem(OFFER_DISMISSED_KEY);
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ export function dismissOffer(storage) {
27
+ try {
28
+ storage?.setItem(OFFER_DISMISSED_KEY, '1');
29
+ }
30
+ catch {
31
+ }
32
+ }
33
+ const escape = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
34
+ export function offerHtml(offer, dismissed) {
35
+ if (offer === undefined)
36
+ return '';
37
+ if (offer.claimed) {
38
+ const days = offer.daysRemaining;
39
+ if (days === undefined || 0 > days)
40
+ return '';
41
+ const text = ENDING_SOON_DAYS >= days ? OFFER_TEXT.ending(days) : OFFER_TEXT.claimed(days);
42
+ return `<div ${OFFER_ATTR} class="reticle-offer reticle-offer-claimed">${escape(text)}</div>`;
43
+ }
44
+ if (false === offer.eligible)
45
+ return '';
46
+ if (dismissed)
47
+ return '';
48
+ const url = offer.claimUrl;
49
+ if (url === undefined || 0 === url.length)
50
+ return '';
51
+ return `<div ${OFFER_ATTR} class="reticle-offer" role="note">
52
+ <div class="reticle-offer-head">
53
+ <span class="reticle-offer-title">${escape(OFFER_TEXT.HEADLINE)}</span>
54
+ <span class="reticle-offer-free">${escape(OFFER_TEXT.FREE)}</span>
55
+ </div>
56
+ <p class="reticle-offer-body">${escape(OFFER_TEXT.BODY)}</p>
57
+ <div class="reticle-offer-actions">
58
+ <a ${OFFER_CLAIM_ATTR} class="reticle-offer-claim" href="${escape(url)}" target="_blank" rel="noopener noreferrer">${escape(OFFER_TEXT.CLAIM)}</a>
59
+ <button type="button" ${OFFER_DISMISS_ATTR} class="reticle-offer-dismiss">${escape(OFFER_TEXT.DISMISS)}</button>
60
+ </div>
61
+ </div>`;
62
+ }
63
+ export function paintOffer(root, offer, storage) {
64
+ const slot = root.querySelector(`[${OFFER_SLOT_ATTR}]`);
65
+ if (!(slot instanceof HTMLElement))
66
+ return;
67
+ slot.innerHTML = offerHtml(offer, offerDismissed(storage));
68
+ const dismiss = slot.querySelector(`[${OFFER_DISMISS_ATTR}]`);
69
+ dismiss?.addEventListener('click', (event) => {
70
+ event.preventDefault();
71
+ event.stopPropagation();
72
+ dismissOffer(storage);
73
+ slot.innerHTML = '';
74
+ });
75
+ }
76
+ export const OFFER_CSS = `
77
+ .reticle-offer{margin:8px 10px;padding:10px 12px;border:1px solid var(--reticle-line,#2a2f3a);border-radius:10px;background:var(--reticle-surface-inset,rgba(255,255,255,.03));}
78
+ .reticle-offer-head{display:flex;align-items:center;justify-content:space-between;gap:8px;}
79
+ .reticle-offer-title{font-weight:600;font-size:12px;}
80
+ .reticle-offer-free{font-size:10px;padding:2px 6px;border-radius:999px;background:var(--reticle-accent,#4f7cff);color:#fff;white-space:nowrap;}
81
+ .reticle-offer-body{margin:6px 0 8px;font-size:11px;line-height:1.45;opacity:.8;}
82
+ .reticle-offer-actions{display:flex;align-items:center;gap:8px;}
83
+ .reticle-offer-claim{font-size:11px;font-weight:600;padding:4px 10px;border-radius:6px;background:var(--reticle-accent,#4f7cff);color:#fff;text-decoration:none;}
84
+ .reticle-offer-dismiss{font-size:11px;padding:4px 8px;border-radius:6px;background:none;border:0;color:inherit;opacity:.6;cursor:pointer;}
85
+ .reticle-offer-dismiss:hover{opacity:1;}
86
+ .reticle-offer-claimed{margin:6px 10px;font-size:10px;opacity:.6;}
87
+ `;
@@ -68,6 +68,10 @@ export const REPORT_CSS = `
68
68
  display:block;margin-top:2px;color:var(--reticle-c-active);opacity:.8;
69
69
  font-size:8.5px;letter-spacing:.06em;text-transform:uppercase;cursor:help;}
70
70
  [${REPORT_PANEL_ATTR}] .reticle-report-defects-wrap{margin-top:14px;}
71
+ /* The heading and its push control on one line, so the button reads as belonging to this section
72
+ rather than floating above the list. */
73
+ [${REPORT_PANEL_ATTR}] .reticle-report-defects-head{
74
+ display:flex;align-items:center;justify-content:space-between;gap:8px;}
71
75
  [${REPORT_PANEL_ATTR}] .reticle-report-defects{
72
76
  display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none;}
73
77
  /* A left rule rather than a card per row: ten cards in a 320px panel is a wall, and the rule keeps
@@ -79,7 +79,8 @@ function defects(scope, dashboardUrl) {
79
79
  const more = !linked
80
80
  ? ''
81
81
  : `<a class="reticle-report-defects-more" href="${esc(dashboardUrl)}" target="_blank" rel="noreferrer noopener">${REPORT_TEXT.DEFECTS_MORE}${scope.counts.failed > list.length ? ` (${String(scope.counts.failed)})` : ''}</a>`;
82
- return `<div class="reticle-report-defects-wrap"><span class="reticle-report-section">${REPORT_TEXT.DEFECTS}</span><ul class="reticle-report-defects">${rows}</ul>${more}</div>`;
82
+ const sync = syncButtonHtml(dashboardUrl);
83
+ return `<div class="reticle-report-defects-wrap"><div class="reticle-report-defects-head"><span class="reticle-report-section">${REPORT_TEXT.DEFECTS}</span>${sync}</div><ul class="reticle-report-defects">${rows}</ul>${more}</div>`;
83
84
  }
84
85
  export function reportBodyHtml(scope, dashboardUrl, account, projectName) {
85
86
  const c = scope.counts;
@@ -1,3 +1,4 @@
1
+ import type { HarnessConfig } from '@reticlehq/core';
1
2
  import { SETTINGS_CSS } from './presenter-settings-styles.js';
2
3
  import type { AccountState } from '@reticlehq/core';
3
4
  import { type AccountDetails } from './presenter-account.js';
@@ -41,10 +42,12 @@ export interface PresenterSettings {
41
42
  export declare function getPresenterSettings(): PresenterSettings;
42
43
  export declare function loadPresenterSettings(): PresenterSettings;
43
44
  export declare const SETTINGS_ACCOUNT_ATTR = "data-reticle-settings-account";
45
+ export declare function paintHarnessRow(root: ParentNode, config: HarnessConfig | undefined): void;
44
46
  export declare function paintSettingsAccount(root: HTMLElement, account: AccountState | undefined, dashboardUrl: string | undefined, details?: AccountDetails): void;
45
47
  export declare function settingsPanelHtml(): string;
46
48
  export interface SettingsHost {
47
49
  onHideUntilRestart?: () => void;
50
+ onHarness?: (enabled: boolean) => void;
48
51
  onSettingsChange?: (settings: PresenterSettings) => void;
49
52
  onBeforeOpen?: () => void;
50
53
  }
@@ -56,6 +59,7 @@ export declare class PresenterSettingsPanel {
56
59
  constructor(host?: SettingsHost);
57
60
  contains(node: Node): boolean;
58
61
  mount(root: HTMLElement): void;
62
+ paintHarness(config: HarnessConfig | undefined): void;
59
63
  teardown(): void;
60
64
  isOpen(): boolean;
61
65
  open(): void;
@@ -149,7 +149,29 @@ function settingsCheckRow(key, label, checked) {
149
149
  }
150
150
  export const SETTINGS_ACCOUNT_ATTR = 'data-reticle-settings-account';
151
151
  const SETTINGS_ACCOUNT_ROW_ATTR = 'data-reticle-settings-account-row';
152
+ const SETTINGS_HARNESS_ROW_ATTR = 'data-reticle-settings-harness-row';
153
+ const HARNESS_HELP = 'Let Reticle drive this app by itself to find defects. Set here or in your dashboard — both write to the same place.';
154
+ const HARNESS_LOCKED_HELP = 'Autonomous driving runs on Reticle’s model spend, so it needs a plan or the free period. Claim it from the chat panel.';
152
155
  const ACCOUNT_HELP = 'Whether this machine is signed in to a Reticle workspace. Signing in happens in your terminal.';
156
+ export function paintHarnessRow(root, config) {
157
+ const row = root.querySelector(`[${SETTINGS_HARNESS_ROW_ATTR}]`);
158
+ if (!(row instanceof HTMLElement))
159
+ return;
160
+ if (config === undefined) {
161
+ row.hidden = true;
162
+ return;
163
+ }
164
+ row.hidden = false;
165
+ const toggle = row.querySelector(`[${SETTING_KEY_ATTR}="harnessEnabled"]`);
166
+ if (!(toggle instanceof HTMLElement))
167
+ return;
168
+ const usable = config.harnessEntitled;
169
+ toggle.setAttribute('aria-checked', config.harnessEnabled && usable ? 'true' : 'false');
170
+ toggle.setAttribute('aria-disabled', usable ? 'false' : 'true');
171
+ const help = row.querySelector('[data-reticle-help]');
172
+ if (help instanceof HTMLElement)
173
+ help.title = usable ? HARNESS_HELP : HARNESS_LOCKED_HELP;
174
+ }
153
175
  export function paintSettingsAccount(root, account, dashboardUrl, details = {}) {
154
176
  const row = root.querySelector(`[${SETTINGS_ACCOUNT_ROW_ATTR}]`);
155
177
  const slot = root.querySelector(`[${SETTINGS_ACCOUNT_ATTR}]`);
@@ -165,6 +187,7 @@ export function settingsPanelHtml() {
165
187
  const outputHelp = 'How much detail is included when you copy or export the run';
166
188
  const reactHelp = 'Include React component paths in exported run state when available';
167
189
  const hideHelp = 'Hide the Reticle HUD until you reload the page';
190
+ const harnessHelp = HARNESS_HELP;
168
191
  const tallyHelp = 'Show the pass/fail score pill in the toolbar';
169
192
  const timestampsHelp = 'Show relative timestamps on each activity-log row';
170
193
  const autoChatHelp = 'On by default. Opens the agent chat by itself when a session starts and when you expand the HUD. Off leaves the toolbar bare until you ask for the chat.';
@@ -191,6 +214,7 @@ export function settingsPanelHtml() {
191
214
  ${settingsCheckRow('blockPageInteractions', 'Block page interactions', true)}
192
215
  ${settingsCheckRow('clearOnCopy', 'Clear on copy/send', false)}
193
216
  ${settingsToggleRow('hideUntilRestart', 'Hide Until Restart', hideHelp)}
217
+ ${settingsToggleRow('harnessEnabled', 'Autonomous driving', harnessHelp, `${SETTINGS_HARNESS_ROW_ATTR} hidden`)}
194
218
  ${settingsToggleRow('reduceMotion', 'Reduce motion', motionHelp)}
195
219
  <div class="reticle-settings-section">Account</div>
196
220
  <div class="reticle-settings-row" ${SETTINGS_ACCOUNT_ROW_ATTR} hidden>
@@ -286,7 +310,14 @@ export class PresenterSettingsPanel {
286
310
  if (!(toggle instanceof HTMLElement))
287
311
  return;
288
312
  const key = toggle.getAttribute(SETTING_KEY_ATTR);
289
- if ('reactComponents' === key) {
313
+ if ('harnessEnabled' === key) {
314
+ if ('true' === toggle.getAttribute('aria-disabled'))
315
+ return;
316
+ const next = 'true' !== toggle.getAttribute('aria-checked');
317
+ toggle.setAttribute('aria-checked', next ? 'true' : 'false');
318
+ this.#host.onHarness?.(next);
319
+ }
320
+ else if ('reactComponents' === key) {
290
321
  this.#update({ reactComponents: !activeSettings.reactComponents });
291
322
  }
292
323
  else if ('hideUntilRestart' === key) {
@@ -368,6 +399,12 @@ export class PresenterSettingsPanel {
368
399
  applyPresenterSettings(root, activeSettings);
369
400
  this.#host.onSettingsChange?.(activeSettings);
370
401
  }
402
+ paintHarness(config) {
403
+ const root = this.#panel;
404
+ if (root === undefined)
405
+ return;
406
+ paintHarnessRow(root, config);
407
+ }
371
408
  teardown() {
372
409
  this.#root = undefined;
373
410
  this.#panel = undefined;
@@ -83,6 +83,18 @@ export const SHELL_CSS = `
83
83
  right:auto;left:0;}
84
84
  [${OVERLAY}][${CHAT_ATTR}="1"] [${CHAT_PANEL}]{
85
85
  display:flex;pointer-events:auto;}
86
+ /**
87
+ * Idle, and not blocking the page: the card is not in use.
88
+ *
89
+ * The log fills the panel and sets pointer-events itself, so a click meant for a control sitting
90
+ * under the card landed on an empty transcript. Playwright named that div. The card passes the
91
+ * event through. The buttons stay clickable, which is the chrome a person still needs (#992).
92
+ */
93
+ [${OVERLAY}][data-reticle-mode="idle"][data-reticle-block="0"] [${CHAT_PANEL}],
94
+ [${OVERLAY}][data-reticle-mode="idle"][data-reticle-block="0"] [${CHAT_PANEL}] [data-reticle-log]{
95
+ pointer-events:none;}
96
+ [${OVERLAY}][data-reticle-mode="idle"][data-reticle-block="0"] [${CHAT_PANEL}] :is(button,a,input,select,textarea){
97
+ pointer-events:auto;}
86
98
  /**
87
99
  * Minimising the chat leaves a CAPSULE, not a hole: the same glass, the same state dot, the last
88
100
  * thing the agent did - sitting directly above the toolbar capsule and reopening the panel when
@@ -1,6 +1,8 @@
1
+ import type { HarnessConfig } from '@reticlehq/core';
1
2
  import { PresenterReport } from './presenter-report.js';
2
3
  import type { AccountState } from '@reticlehq/core';
3
4
  import { type AccountDetails } from './presenter-account.js';
5
+ import { type OfferState } from './presenter-offer.js';
4
6
  import { type SettingsHost } from './presenter-settings.js';
5
7
  interface HudShellCallbacks {
6
8
  onChatOpen?: () => void;
@@ -13,7 +15,9 @@ interface HudShellCallbacks {
13
15
  }
14
16
  export declare class HudShell {
15
17
  #private;
18
+ paintHarness(config: HarnessConfig | undefined): void;
16
19
  paintAccount(account: AccountState | undefined, dashboardUrl: string | undefined, details?: AccountDetails): void;
20
+ paintOffer(offer: OfferState | undefined): void;
17
21
  constructor(callbacks?: HudShellCallbacks);
18
22
  static dockHtml(actStripHtml: string, bannerHtml: string, logAttr: string, flowsHtml: string, footHtml: string): string;
19
23
  get report(): PresenterReport;
@@ -3,6 +3,7 @@ import { paintToolbarAccount, TOOLBAR_ACCOUNT_ATTR } from './presenter-workspace
3
3
  import { mountAccountControl } from './presenter-account.js';
4
4
  import { paintSettingsAccount } from './presenter-settings.js';
5
5
  import { CHAT_MIN_ATTR, CHAT_PILL_ATTR, REPORT_ATTR, REPORT_BTN_ATTR, ANNOTATE_BTN_ATTR, CHAT_ATTR, CHAT_TOGGLE_ATTR, CHAT_PANEL_ATTR, DOCK_ATTR, FAB_ATTR, MIN_ATTR, SETTINGS_ATTR, SETTINGS_BTN_ATTR, } from './presenter-config.js';
6
+ import { OFFER_SLOT_ATTR, paintOffer } from './presenter-offer.js';
6
7
  import { BRAND_NAME, FAB_TOGGLE_HTML, MARK_SVG } from './chrome/presenter-brand.js';
7
8
  import { settleLogAtLatest } from './chrome/presenter-log.js';
8
9
  import { installHudDragHandles, installHudPositionGuards } from './presenter-drag.js';
@@ -45,6 +46,9 @@ export class HudShell {
45
46
  #callbacks;
46
47
  #pushedAccount;
47
48
  #accountTeardown;
49
+ paintHarness(config) {
50
+ this.#settings.paintHarness(config);
51
+ }
48
52
  paintAccount(account, dashboardUrl, details = {}) {
49
53
  this.#pushedAccount = { account, dashboardUrl, details };
50
54
  if (this.#root === undefined)
@@ -52,6 +56,21 @@ export class HudShell {
52
56
  paintToolbarAccount(this.#root, account, dashboardUrl, details);
53
57
  paintSettingsAccount(this.#root, account, dashboardUrl, details);
54
58
  }
59
+ #pushedOffer;
60
+ paintOffer(offer) {
61
+ this.#pushedOffer = offer;
62
+ if (this.#root === undefined)
63
+ return;
64
+ paintOffer(this.#root, offer, this.#storage());
65
+ }
66
+ #storage() {
67
+ try {
68
+ return globalThis.localStorage;
69
+ }
70
+ catch {
71
+ return undefined;
72
+ }
73
+ }
55
74
  constructor(callbacks = {}) {
56
75
  this.#callbacks = callbacks;
57
76
  this.#settings = new PresenterSettingsPanel({
@@ -78,6 +97,7 @@ export class HudShell {
78
97
  ${actStripHtml}
79
98
  <span class="reticle-tally" data-reticle-tally hidden></span>
80
99
  ${bannerHtml}
100
+ <div ${OFFER_SLOT_ATTR}></div>
81
101
  <div class="${HUD_LOG_WELL_CLASS}"><div ${logAttr}></div></div>
82
102
  ${flowsHtml}
83
103
  ${footHtml}
@@ -228,6 +248,8 @@ export class HudShell {
228
248
  const pushed = this.#pushedAccount;
229
249
  this.paintAccount(pushed.account, pushed.dashboardUrl, pushed.details);
230
250
  }
251
+ if (this.#pushedOffer !== undefined)
252
+ this.paintOffer(this.#pushedOffer);
231
253
  }
232
254
  teardown() {
233
255
  this.#accountTeardown?.();
@@ -6,6 +6,7 @@ import { SETTINGS_CSS } from './presenter-settings-styles.js';
6
6
  import { ACCOUNT_CSS } from './presenter-account-styles.js';
7
7
  import { HUD_CHROME_CSS, HUD_LOG_WELL_CSS } from './chrome/presenter-hud-chrome.js';
8
8
  import { REPORT_CSS } from './presenter-report-styles.js';
9
+ import { OFFER_CSS } from './presenter-offer.js';
9
10
  export { Z_OVERLAY };
10
11
  export const PRESENTER_CSS = `
11
12
  [data-reticle-overlay]{position:fixed;inset:0;pointer-events:none;z-index:${String(Z_OVERLAY)};}
@@ -63,5 +64,6 @@ ${SHELL_CSS}
63
64
  ${SETTINGS_CSS}
64
65
  ${REPORT_CSS}
65
66
  ${ACCOUNT_CSS}
67
+ ${OFFER_CSS}
66
68
  ${LOG_CSS}
67
69
  ${CONTROLS_CSS}`;
@@ -93,6 +93,7 @@ export class Presenter {
93
93
  },
94
94
  onHideUntilRestart: () => this.#applyHideUntilRestart(),
95
95
  onSettingsChange: (s) => this.#onSettingsChange(s),
96
+ onHarness: (enabled) => this.#onControl?.({ kind: HumanControlKind.HARNESS, text: enabled ? 'on' : 'off' }),
96
97
  },
97
98
  });
98
99
  }
@@ -119,6 +120,8 @@ export class Presenter {
119
120
  const snapshot = parseImpactSnapshot(a['snapshot']);
120
121
  if (snapshot !== undefined) {
121
122
  this.#shell.report.setSnapshot(snapshot);
123
+ this.#shell.paintOffer(snapshot.harnessOffer);
124
+ this.#shell.paintHarness(snapshot.harnessConfig);
122
125
  this.#shell.paintAccount(snapshot.account, snapshot.dashboardUrl, {
123
126
  projectName: snapshot.projectName,
124
127
  dashboardUrl: snapshot.dashboardUrl,
@@ -111,9 +111,13 @@ export const TOUR_CSS = `
111
111
  [${TOUR_ATTR}] .reticle-tour-blocker{position:absolute;pointer-events:auto;z-index:0;}
112
112
  /* Ringed slides dim through the ring's own 9999px shadow, which leaves a hole over the thing being
113
113
  pointed at. The scrim would cover that hole -- dimming the HUD the slide is ABOUT, and washing
114
- the page twice (~80% rather than 55%). Cleared, not removed: it is also what swallows clicks
115
- meant for the app underneath, and a tour that lets you click through is not a tour. */
116
- [${TOUR_ATTR}] .reticle-tour-scrim.is-clear{background:transparent;backdrop-filter:none;}
114
+ the page twice (~80% rather than 55%).
115
+ It is cleared for the clicks too, and that half was missing. A scrim blocks only while it is
116
+ VISIBLY blocking: reported from the field on 3.1.0 by several users, one of whom removed the
117
+ plugin, because a cleared scrim went on eating every press on a page that looked lit and
118
+ reachable. An overlay that looks transparent and behaves opaque reads as the app being broken. */
119
+ [${TOUR_ATTR}] .reticle-tour-scrim.is-clear{background:transparent;backdrop-filter:none;
120
+ pointer-events:none;}
117
121
  /* Above the ring, and the z-index is the whole point.
118
122
  The ring is appended after the card, and both are positioned with an auto z-index, so the ring
119
123
  painted on top of it -- and the ring's dimming IS a 9999px shadow, which therefore fell across the
@@ -92,15 +92,27 @@ export function mountTour(deps) {
92
92
  let index = 0;
93
93
  let open = true;
94
94
  let detachKeys = () => undefined;
95
- const close = () => {
95
+ let stopWatch = () => undefined;
96
+ const remove = () => {
96
97
  if (!open)
97
98
  return;
98
99
  open = false;
99
100
  detachKeys();
100
- markSeen(deps.storage, deps.projectId);
101
+ stopWatch();
101
102
  root.remove();
102
103
  style.remove();
103
104
  };
105
+ const close = () => {
106
+ if (!open)
107
+ return;
108
+ markSeen(deps.storage, deps.projectId);
109
+ remove();
110
+ };
111
+ const yieldToDrive = () => {
112
+ if (!deps.isDriving())
113
+ return;
114
+ remove();
115
+ };
104
116
  const draw = () => {
105
117
  const slide = slides[index];
106
118
  if (undefined === slide)
@@ -229,6 +241,13 @@ export function mountTour(deps) {
229
241
  };
230
242
  draw();
231
243
  doc.body.appendChild(root);
244
+ const overlay = doc.querySelector('[data-reticle-overlay]');
245
+ if (null !== overlay) {
246
+ const observer = new MutationObserver(yieldToDrive);
247
+ observer.observe(overlay, { attributes: true, attributeFilter: ['data-reticle-mode'] });
248
+ stopWatch = () => observer.disconnect();
249
+ yieldToDrive();
250
+ }
232
251
  return {
233
252
  destroy: close,
234
253
  isOpen: () => open,
package/dist/reticle.js CHANGED
@@ -255,7 +255,9 @@ export class Reticle {
255
255
  panel.handlePush(buffered);
256
256
  this.#pendingPushes.clear();
257
257
  void import('./presenter/tour/tour.js').then((tour) => {
258
- tour.mountTour({
258
+ if (!this.#connected)
259
+ return;
260
+ const shown = tour.mountTour({
259
261
  document,
260
262
  storage: tour.safeLocalStorage(),
261
263
  projectId: reticleParamsFromSearch(window.location.search).projectId ?? window.location.origin,
@@ -272,6 +274,8 @@ export class Reticle {
272
274
  selection.addRange(range);
273
275
  },
274
276
  });
277
+ if (shown !== undefined)
278
+ this.#teardowns.push(shown.destroy);
275
279
  });
276
280
  if (this.#bridgeConnected)
277
281
  panel.sessionStart();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/browser",
3
- "version": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "description": "Reticle browser SDK — installs observers, builds semantic snapshots, executes actions, talks to the bridge.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -13,7 +13,7 @@
13
13
  "./package.json": "./package.json"
14
14
  },
15
15
  "dependencies": {
16
- "@reticlehq/core": "3.1.0"
16
+ "@reticlehq/core": "3.2.0"
17
17
  },
18
18
  "devDependencies": {
19
19
  "@iconify-json/heroicons-outline": "^1.2.1",