@reticlehq/browser 3.2.0 → 3.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/actions/actions-dom.d.ts +6 -5
  2. package/dist/actions/actions-dom.js +5 -4
  3. package/dist/actions/actions-press.d.ts +3 -3
  4. package/dist/actions/actions-press.js +0 -3
  5. package/dist/actions/actions.d.ts +0 -1
  6. package/dist/actions/actions.js +17 -13
  7. package/dist/actions/check-state.d.ts +5 -0
  8. package/dist/actions/check-state.js +24 -0
  9. package/dist/actions/danger-context.d.ts +3 -2
  10. package/dist/actions/danger-context.js +2 -1
  11. package/dist/actions/value-input.d.ts +2 -1
  12. package/dist/actions/value-input.js +15 -6
  13. package/dist/connect-options.d.ts +1 -0
  14. package/dist/dom/a11y.js +43 -3
  15. package/dist/dom/addressing/source.d.ts +2 -1
  16. package/dist/dom/dom-ignore.d.ts +1 -0
  17. package/dist/dom/dom-ignore.js +13 -0
  18. package/dist/dom/query.js +6 -4
  19. package/dist/dom/realm.d.ts +6 -0
  20. package/dist/dom/realm.js +5 -0
  21. package/dist/dom/snapshot.d.ts +1 -1
  22. package/dist/dom/snapshot.js +8 -2
  23. package/dist/observers/console.js +38 -13
  24. package/dist/observers/dom.js +3 -1
  25. package/dist/observers/download.js +3 -2
  26. package/dist/observers/error-repeats.d.ts +18 -0
  27. package/dist/observers/error-repeats.js +41 -0
  28. package/dist/observers/field.d.ts +2 -0
  29. package/dist/observers/field.js +86 -0
  30. package/dist/observers/frames.js +3 -4
  31. package/dist/observers/install-all.js +2 -0
  32. package/dist/observers/net-detail/network-body.d.ts +2 -0
  33. package/dist/observers/net-detail/network-body.js +24 -7
  34. package/dist/observers/network.js +14 -1
  35. package/dist/observers/route.js +4 -4
  36. package/dist/observers/state.js +21 -8
  37. package/dist/presenter/carousel/carousel.d.ts +13 -0
  38. package/dist/presenter/carousel/carousel.js +126 -0
  39. package/dist/presenter/carousel/offer-card.d.ts +21 -0
  40. package/dist/presenter/carousel/offer-card.js +55 -0
  41. package/dist/presenter/carousel/panel-slides.d.ts +7 -0
  42. package/dist/presenter/carousel/panel-slides.js +10 -0
  43. package/dist/presenter/carousel/talk-card.d.ts +8 -0
  44. package/dist/presenter/carousel/talk-card.js +20 -0
  45. package/dist/presenter/chrome/presenter-log.d.ts +2 -1
  46. package/dist/presenter/chrome/presenter-log.js +14 -3
  47. package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -1
  48. package/dist/presenter/chrome/presenter-report-copy.js +1 -1
  49. package/dist/presenter/hud-telemetry.d.ts +3 -0
  50. package/dist/presenter/hud-telemetry.js +87 -0
  51. package/dist/presenter/presenter-account.js +1 -1
  52. package/dist/presenter/presenter-config.d.ts +5 -1
  53. package/dist/presenter/presenter-config.js +4 -1
  54. package/dist/presenter/presenter-controls.d.ts +1 -0
  55. package/dist/presenter/presenter-controls.js +12 -1
  56. package/dist/presenter/presenter-report.js +6 -6
  57. package/dist/presenter/presenter-settings-styles.js +2 -1
  58. package/dist/presenter/presenter-settings.d.ts +2 -4
  59. package/dist/presenter/presenter-settings.js +13 -4
  60. package/dist/presenter/presenter-shell-styles.js +4 -0
  61. package/dist/presenter/presenter-shell.d.ts +4 -1
  62. package/dist/presenter/presenter-shell.js +49 -11
  63. package/dist/presenter/presenter-styles.js +5 -1
  64. package/dist/presenter/presenter-workspace.d.ts +0 -7
  65. package/dist/presenter/presenter-workspace.js +7 -7
  66. package/dist/presenter/presenter.js +43 -10
  67. package/dist/presenter/tour/tour.d.ts +3 -0
  68. package/dist/presenter/tour/tour.js +5 -0
  69. package/dist/recorder/recorder.js +25 -22
  70. package/dist/registry/store-adapters.js +32 -2
  71. package/dist/registry/stores/adapters.js +8 -2
  72. package/dist/reticle-inject.d.ts +2 -0
  73. package/dist/reticle-inject.js +1337 -0
  74. package/dist/reticle.js +6 -0
  75. package/dist/review/annotator.d.ts +3 -1
  76. package/dist/review/annotator.js +37 -40
  77. package/dist/review/marks-for-agent.d.ts +9 -0
  78. package/dist/review/marks-for-agent.js +12 -0
  79. package/dist/util/captured-method.d.ts +0 -1
  80. package/dist/util/captured-method.js +1 -1
  81. package/package.json +15 -11
  82. package/dist/presenter/presenter-offer.d.ts +0 -25
  83. package/dist/presenter/presenter-offer.js +0 -87
@@ -7,6 +7,7 @@ const PREVIEW_CHARS = 2000;
7
7
  export function installDownload(emit, opts = {}) {
8
8
  if (typeof URL.createObjectURL !== 'function')
9
9
  return () => undefined;
10
+ const ac = new AbortController();
10
11
  const objectUrl = URL.createObjectURL.bind(URL);
11
12
  const known = new Map();
12
13
  const originalCreate = captureMethod(URL, 'createObjectURL');
@@ -58,7 +59,7 @@ export function installDownload(emit, opts = {}) {
58
59
  report(anchor.getAttribute('href') ?? '', anchor.getAttribute('download') ?? undefined);
59
60
  });
60
61
  };
61
- document.addEventListener('click', onClick, true);
62
+ document.addEventListener('click', onClick, { capture: true, signal: ac.signal });
62
63
  const originalClick = captureMethod(HTMLAnchorElement.prototype, 'click');
63
64
  const patchedClick = function reticleObservedAnchorClick() {
64
65
  const pending = observeValue(() => this.hasAttribute('download')
@@ -75,7 +76,7 @@ export function installDownload(emit, opts = {}) {
75
76
  };
76
77
  HTMLAnchorElement.prototype.click = patchedClick;
77
78
  return () => {
78
- document.removeEventListener('click', onClick, true);
79
+ ac.abort();
79
80
  if (URL.createObjectURL === patchedCreate)
80
81
  URL.createObjectURL = originalCreate;
81
82
  if (HTMLAnchorElement.prototype.click === patchedClick) {
@@ -0,0 +1,18 @@
1
+ export declare const REPEAT_BURST = 3;
2
+ export declare const MAX_TRACKED_FINGERPRINTS = 256;
3
+ export interface RepeatDecision {
4
+ readonly emit: boolean;
5
+ readonly repeats?: number;
6
+ }
7
+ export interface ErrorIdentity {
8
+ readonly message: string;
9
+ readonly source?: string;
10
+ readonly line?: number;
11
+ readonly kind?: string;
12
+ readonly stack?: string;
13
+ }
14
+ export declare function fingerprintError(identity: ErrorIdentity): string;
15
+ export interface RepeatLimiter {
16
+ admit(identity: ErrorIdentity): RepeatDecision;
17
+ }
18
+ export declare function createRepeatLimiter(burst?: number, maxTracked?: number): RepeatLimiter;
@@ -0,0 +1,41 @@
1
+ import { fnv1a } from '@reticlehq/core';
2
+ export const REPEAT_BURST = 3;
3
+ export const MAX_TRACKED_FINGERPRINTS = 256;
4
+ const PASS = { emit: true };
5
+ const DROP = { emit: false };
6
+ function isCheckpoint(count) {
7
+ if (count < 10)
8
+ return false;
9
+ let n = count;
10
+ while (0 === n % 10)
11
+ n /= 10;
12
+ return 1 === n;
13
+ }
14
+ export function fingerprintError(identity) {
15
+ return fnv1a([
16
+ identity.message,
17
+ identity.source ?? '',
18
+ undefined === identity.line ? '' : String(identity.line),
19
+ identity.kind ?? '',
20
+ identity.stack ?? '',
21
+ ].join('\x00'));
22
+ }
23
+ export function createRepeatLimiter(burst = REPEAT_BURST, maxTracked = MAX_TRACKED_FINGERPRINTS) {
24
+ const counts = new Map();
25
+ return {
26
+ admit(identity) {
27
+ const key = fingerprintError(identity);
28
+ const seen = (counts.get(key) ?? 0) + 1;
29
+ counts.delete(key);
30
+ if (counts.size >= maxTracked) {
31
+ const oldest = counts.keys().next();
32
+ if (!(oldest.done ?? false))
33
+ counts.delete(oldest.value);
34
+ }
35
+ counts.set(key, seen);
36
+ if (seen <= burst)
37
+ return PASS;
38
+ return isCheckpoint(seen) ? { emit: true, repeats: seen } : DROP;
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,2 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ export declare function installField(emit: Emit): Teardown;
@@ -0,0 +1,86 @@
1
+ import { EventType, isSensitiveKey } from '@reticlehq/core';
2
+ import { getAccessibleName } from '../dom/a11y.js';
3
+ const MAX_VALUE_LEN = 160;
4
+ const BURST_MS = 150;
5
+ const PAYMENT_AUTOCOMPLETE = /^(cc-|current-password|new-password)/i;
6
+ function isField(target) {
7
+ return (target instanceof HTMLInputElement ||
8
+ target instanceof HTMLTextAreaElement ||
9
+ target instanceof HTMLSelectElement);
10
+ }
11
+ function fieldName(element) {
12
+ const testid = element.getAttribute('data-testid');
13
+ if (null !== testid && testid.length > 0)
14
+ return testid;
15
+ if (element.name.length > 0)
16
+ return element.name;
17
+ const accessible = getAccessibleName(element);
18
+ if (accessible.length > 0)
19
+ return accessible;
20
+ const id = element.getAttribute('id');
21
+ return null !== id && id.length > 0 ? id : element.tagName.toLowerCase();
22
+ }
23
+ function isSecret(element) {
24
+ if (element instanceof HTMLInputElement && 'password' === element.type)
25
+ return true;
26
+ const hint = element.getAttribute('autocomplete');
27
+ if (null !== hint && PAYMENT_AUTOCOMPLETE.test(hint))
28
+ return true;
29
+ for (const key of [
30
+ element.name,
31
+ element.getAttribute('data-testid'),
32
+ element.getAttribute('id'),
33
+ ]) {
34
+ if (null !== key && key.length > 0 && isSensitiveKey(key))
35
+ return true;
36
+ }
37
+ return false;
38
+ }
39
+ export function installField(emit) {
40
+ const ac = new AbortController();
41
+ const { signal } = ac;
42
+ const pending = new Map();
43
+ const report = (element, kind) => {
44
+ const value = element.value;
45
+ const secret = isSecret(element);
46
+ emit(EventType.FIELD_CHANGE, {
47
+ field: fieldName(element),
48
+ kind,
49
+ length: value.length,
50
+ ...(secret
51
+ ? { redacted: true }
52
+ : { value: value.length > MAX_VALUE_LEN ? `${value.slice(0, MAX_VALUE_LEN)}…` : value }),
53
+ });
54
+ };
55
+ const clear = (element) => {
56
+ const timer = pending.get(element);
57
+ if (timer !== undefined)
58
+ clearTimeout(timer);
59
+ pending.delete(element);
60
+ };
61
+ const onInput = (event) => {
62
+ const element = event.target;
63
+ if (!isField(element))
64
+ return;
65
+ clear(element);
66
+ pending.set(element, setTimeout(() => {
67
+ pending.delete(element);
68
+ report(element, 'input');
69
+ }, BURST_MS));
70
+ };
71
+ const onChange = (event) => {
72
+ const element = event.target;
73
+ if (!isField(element))
74
+ return;
75
+ clear(element);
76
+ report(element, 'change');
77
+ };
78
+ document.addEventListener('input', onInput, { signal, capture: true });
79
+ document.addEventListener('change', onChange, { signal, capture: true });
80
+ return () => {
81
+ for (const timer of pending.values())
82
+ clearTimeout(timer);
83
+ pending.clear();
84
+ ac.abort();
85
+ };
86
+ }
@@ -24,6 +24,7 @@ export function sameOriginFrameBodies(root) {
24
24
  return found;
25
25
  }
26
26
  export function observeSameOriginFrames(attach) {
27
+ const ac = new AbortController();
27
28
  const attachAll = () => {
28
29
  for (const body of sameOriginFrameBodies(document))
29
30
  attach(body);
@@ -33,8 +34,6 @@ export function observeSameOriginFrames(attach) {
33
34
  if (event.target instanceof HTMLIFrameElement)
34
35
  attachAll();
35
36
  };
36
- document.addEventListener('load', onLoad, true);
37
- return () => {
38
- document.removeEventListener('load', onLoad, true);
39
- };
37
+ document.addEventListener('load', onLoad, { capture: true, signal: ac.signal });
38
+ return () => ac.abort();
40
39
  }
@@ -2,6 +2,7 @@ import { installDom } from './dom.js';
2
2
  import { installStorage } from './storage.js';
3
3
  import { installStoreState } from './state.js';
4
4
  import { installFocus } from './focus.js';
5
+ import { installField } from './field.js';
5
6
  import { installBlindSpots } from './blind-spots.js';
6
7
  import { installDownload } from './download.js';
7
8
  import { installContextOpen } from './context-open.js';
@@ -59,6 +60,7 @@ export function installAllObservers(emit, options) {
59
60
  guard(emit, SdkSite.STORE_ADAPTER, () => installStorage(emit)),
60
61
  guard(emit, SdkSite.STORE_ADAPTER, () => installStoreState(emit)),
61
62
  guard(emit, SdkSite.DOM_OBSERVER, () => installFocus(emit)),
63
+ guard(emit, SdkSite.DOM_OBSERVER, () => installField(emit)),
62
64
  guard(emit, SdkSite.NETWORK_OBSERVER, () => installDownload(emit, { capturePreview: options.captureBodies })),
63
65
  guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)),
64
66
  guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)),
@@ -1,6 +1,8 @@
1
1
  export declare function isStreamingBody(contentType: string | null, contentLength: string | null): boolean;
2
2
  export declare function withBodyDeadline(read: Promise<string>): Promise<string | undefined>;
3
3
  export declare function isCapturableType(contentType: string | null): boolean;
4
+ export declare function setNetworkBodyMaxChars(chars: number): void;
5
+ export declare function networkBodyMaxChars(): number;
4
6
  export declare function shouldCaptureErrorBody(status: number, enabled: boolean): boolean;
5
7
  export declare function projectErrorBody(rawText: string, contentType: string | null): {
6
8
  body: string;
@@ -32,8 +32,23 @@ export function isCapturableType(contentType) {
32
32
  return false;
33
33
  return CAPTURABLE_CONTENT.test(contentType);
34
34
  }
35
- const MAX_BODY_CHARS = 8192;
36
- const MAX_BODY_SCAN_CHARS = MAX_BODY_CHARS * 2;
35
+ const DEFAULT_BODY_CHARS = 8192;
36
+ const MAX_CONFIGURABLE_BODY_CHARS = 262144;
37
+ const MIN_CONFIGURABLE_BODY_CHARS = 256;
38
+ let bodyMaxChars = DEFAULT_BODY_CHARS;
39
+ export function setNetworkBodyMaxChars(chars) {
40
+ if (!Number.isFinite(chars))
41
+ return;
42
+ bodyMaxChars = Math.min(MAX_CONFIGURABLE_BODY_CHARS, Math.max(MIN_CONFIGURABLE_BODY_CHARS, Math.floor(chars)));
43
+ }
44
+ export function networkBodyMaxChars() {
45
+ return bodyMaxChars;
46
+ }
47
+ const REDACT_SCAN_CEILING = DEFAULT_BODY_CHARS * 2;
48
+ function scanWidth(redacting) {
49
+ const generous = bodyMaxChars * 2;
50
+ return redacting ? Math.min(generous, REDACT_SCAN_CEILING) : generous;
51
+ }
37
52
  const AUTH_SCHEME_TOKEN = /\b(Bearer|Basic)\s+(?=[A-Za-z0-9._~+/=-]{16,})(?=[A-Za-z0-9._~+/=-]*[\d._~+/=-])[A-Za-z0-9._~+/=-]+/gi;
38
53
  function redactText(text) {
39
54
  return (text
@@ -51,10 +66,12 @@ export function projectErrorBody(rawText, contentType) {
51
66
  return { body: projected.body.slice(0, ERROR_BODY_CHARS), truncated: true };
52
67
  }
53
68
  export function projectBody(rawText, contentType) {
54
- const oversized = rawText.length > MAX_BODY_SCAN_CHARS;
55
- const text = oversized ? rawText.slice(0, MAX_BODY_SCAN_CHARS) : rawText;
69
+ const isJson = contentType !== null && /json|graphql/i.test(contentType);
70
+ const scanChars = scanWidth(!isJson);
71
+ const oversized = rawText.length > scanChars;
72
+ const text = oversized ? rawText.slice(0, scanChars) : rawText;
56
73
  let out;
57
- if (contentType !== null && /json|graphql/i.test(contentType)) {
74
+ if (isJson) {
58
75
  try {
59
76
  out = safeStringify(JSON.parse(text));
60
77
  }
@@ -66,6 +83,6 @@ export function projectBody(rawText, contentType) {
66
83
  out = redactText(text);
67
84
  }
68
85
  out = scrubKnownSecrets(out);
69
- const truncated = oversized || out.length > MAX_BODY_CHARS;
70
- return { body: out.length > MAX_BODY_CHARS ? out.slice(0, MAX_BODY_CHARS) : out, truncated };
86
+ const truncated = oversized || out.length > bodyMaxChars;
87
+ return { body: out.length > bodyMaxChars ? out.slice(0, bodyMaxChars) : out, truncated };
71
88
  }
@@ -1,4 +1,4 @@
1
- import { BlindSpotKind, EventType, REQUEST_SHAPE_FIELD, REQUEST_SHAPE_NONE, RETICLE_WS_PATH, StreamTransport, StreamDirection, fnv1a, } from '@reticlehq/core';
1
+ import { BlindSpotKind, EventType, NEXT_ACTION_FIELD, NEXT_ACTION_HEADER, 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';
@@ -120,6 +120,15 @@ function methodOf(input, init) {
120
120
  return input.method.toUpperCase();
121
121
  return 'GET';
122
122
  }
123
+ function nextActionOf(input, init) {
124
+ if (init !== undefined && init.headers !== undefined) {
125
+ return observeValue(() => new Headers(init.headers).get(NEXT_ACTION_HEADER)) ?? undefined;
126
+ }
127
+ if (input instanceof Request) {
128
+ return observeValue(() => input.headers.get(NEXT_ACTION_HEADER)) ?? undefined;
129
+ }
130
+ return undefined;
131
+ }
123
132
  const NON_APP_FRAME = /@reticlehq|reticle\.ts|network\.ts|transport\.ts|<anonymous>|new Promise|node:internal/i;
124
133
  const DEPENDENCY_FRAME = /node_modules|\/\.vite\/deps\//i;
125
134
  export function firstAppFrame(stack) {
@@ -191,6 +200,8 @@ export function installNetwork(emit, opts = {}) {
191
200
  const id = nextId();
192
201
  const start = performance.now();
193
202
  const method = methodOf(input, init);
203
+ const nextAction = nextActionOf(input, init);
204
+ const nextActionFields = nextAction === undefined ? {} : { [NEXT_ACTION_FIELD]: nextAction };
194
205
  const urlFields = netUrlFields(rawUrl);
195
206
  const url = urlFields.url;
196
207
  const initiatorStack = observeValue(() => initiatorFrame());
@@ -226,6 +237,7 @@ export function installNetwork(emit, opts = {}) {
226
237
  ...resourceTiming(rawUrl),
227
238
  ...netResponseMeta(res.statusText, contentType, res.headers.get('content-length')),
228
239
  ...projectRequestBody(init?.body, captureBodies),
240
+ ...nextActionFields,
229
241
  ...responseBodyFields,
230
242
  ...(reinterpret?.(url, (name) => res.headers.get(name)) ?? {}),
231
243
  });
@@ -282,6 +294,7 @@ export function installNetwork(emit, opts = {}) {
282
294
  durationMs: Math.round(performance.now() - start),
283
295
  initiator: 'fetch',
284
296
  ...initiatorFields,
297
+ ...nextActionFields,
285
298
  });
286
299
  });
287
300
  throw error;
@@ -10,6 +10,7 @@ function snapshotLocation() {
10
10
  };
11
11
  }
12
12
  export function installRoute(emit) {
13
+ const ac = new AbortController();
13
14
  const origPush = captureMethod(history, 'pushState');
14
15
  const origReplace = captureMethod(history, 'replaceState');
15
16
  const callPush = origPush.bind(history);
@@ -45,14 +46,13 @@ export function installRoute(emit) {
45
46
  const onNav = () => {
46
47
  fire(lastHref);
47
48
  };
48
- window.addEventListener('popstate', onNav);
49
- window.addEventListener('hashchange', onNav);
49
+ window.addEventListener('popstate', onNav, { signal: ac.signal });
50
+ window.addEventListener('hashchange', onNav, { signal: ac.signal });
50
51
  return () => {
51
52
  if (history.pushState === patchedPush)
52
53
  history.pushState = origPush;
53
54
  if (history.replaceState === patchedReplace)
54
55
  history.replaceState = origReplace;
55
- window.removeEventListener('popstate', onNav);
56
- window.removeEventListener('hashchange', onNav);
56
+ ac.abort();
57
57
  };
58
58
  }
@@ -19,6 +19,16 @@ export function diffState(prev, next) {
19
19
  function project(path, value) {
20
20
  return isSensitiveKey(path) ? REDACTED_VALUE : sanitizeForTransport(value);
21
21
  }
22
+ function samePresentedValue(oldValue, newValue) {
23
+ if (Object.is(oldValue, newValue))
24
+ return true;
25
+ try {
26
+ return JSON.stringify(oldValue) === JSON.stringify(newValue);
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
22
32
  function safeRead(getter) {
23
33
  try {
24
34
  return getter();
@@ -39,15 +49,18 @@ export function installStoreState(emit) {
39
49
  active.set(name, subscribe(() => {
40
50
  observeSafely(() => {
41
51
  const next = safeRead(getter);
42
- for (const change of diffState(last, next)) {
43
- emit(EventType.STATE_CHANGE, {
44
- name,
45
- path: change.path,
46
- value: project(change.path, change.new),
47
- old: project(change.path, change.old),
48
- });
49
- }
52
+ const changes = diffState(last, next);
50
53
  last = next;
54
+ for (const change of changes) {
55
+ const value = project(change.path, change.new);
56
+ const old = project(change.path, change.old);
57
+ const same = isSensitiveKey(change.path)
58
+ ? samePresentedValue(sanitizeForTransport(change.old), sanitizeForTransport(change.new))
59
+ : samePresentedValue(old, value);
60
+ if (same)
61
+ continue;
62
+ observeSafely(() => emit(EventType.STATE_CHANGE, { name, path: change.path, value, old }));
63
+ }
51
64
  });
52
65
  }));
53
66
  };
@@ -0,0 +1,13 @@
1
+ export declare const CAROUSEL_ATTR = "data-reticle-carousel";
2
+ export declare const CAROUSEL_SLIDE_ATTR = "data-reticle-carousel-slide";
3
+ export declare const CAROUSEL_DOT_ATTR = "data-reticle-carousel-dot";
4
+ export declare const CAROUSEL_CLOSE_ATTR = "data-reticle-carousel-close";
5
+ export declare const CAROUSEL_DISMISSED_KEY = "reticle.carousel.dismissed";
6
+ export declare const ROTATE_MS = 6000;
7
+ export interface Slide {
8
+ id: string;
9
+ html: string;
10
+ }
11
+ export declare function carouselHtml(slides: readonly Slide[], active: number): string;
12
+ export declare function paintCarousel(log: HTMLElement, slides: readonly Slide[], storage: Pick<Storage, 'getItem' | 'setItem'> | undefined): void;
13
+ export declare const CAROUSEL_CSS = "\n.reticle-carousel{position:relative;margin:8px 10px;padding:10px 12px 8px;border:1px solid var(--reticle-line,#2a2f3a);border-radius:10px;background:var(--reticle-surface-inset,rgba(255,255,255,.03));}\n.reticle-carousel-track{height:100px;overflow:hidden;}\n.reticle-carousel-slide .reticle-offer,.reticle-carousel-slide .reticle-talk{margin:0;padding:0;border:0;background:none;}\n.reticle-carousel-close{position:absolute;top:4px;right:6px;width:20px;height:20px;padding:0;border:0;background:none;color:inherit;opacity:.55;cursor:pointer;font-size:14px;line-height:20px;}\n.reticle-carousel-close:hover{opacity:1;}\n.reticle-carousel-dots{display:flex;justify-content:center;gap:6px;margin-top:6px;}\n.reticle-carousel-dot{width:6px;height:6px;padding:0;border:0;border-radius:999px;background:currentColor;opacity:.3;cursor:pointer;}\n.reticle-carousel-dot[aria-current=\"true\"]{opacity:.9;}\n";
@@ -0,0 +1,126 @@
1
+ export const CAROUSEL_ATTR = 'data-reticle-carousel';
2
+ export const CAROUSEL_SLIDE_ATTR = 'data-reticle-carousel-slide';
3
+ export const CAROUSEL_DOT_ATTR = 'data-reticle-carousel-dot';
4
+ export const CAROUSEL_CLOSE_ATTR = 'data-reticle-carousel-close';
5
+ export const CAROUSEL_DISMISSED_KEY = 'reticle.carousel.dismissed';
6
+ const CLOSE_LABEL = 'Close';
7
+ export const ROTATE_MS = 6000;
8
+ const rotations = new WeakMap();
9
+ function stopRotation(log) {
10
+ const timer = rotations.get(log);
11
+ if (timer !== undefined)
12
+ clearInterval(timer);
13
+ rotations.delete(log);
14
+ }
15
+ function prefersReducedMotion() {
16
+ try {
17
+ return true === globalThis.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ function show(root, index) {
24
+ root.setAttribute('data-active', String(index));
25
+ root.querySelectorAll(`[${CAROUSEL_SLIDE_ATTR}]`).forEach((slide, i) => {
26
+ slide.hidden = i !== index;
27
+ });
28
+ root.querySelectorAll(`[${CAROUSEL_DOT_ATTR}]`).forEach((dot, i) => {
29
+ dot.setAttribute('aria-current', String(i === index));
30
+ });
31
+ }
32
+ const clampIndex = (index, count) => Number.isInteger(index) && index >= 0 && index < count ? index : 0;
33
+ export function carouselHtml(slides, active) {
34
+ if (0 === slides.length)
35
+ return '';
36
+ const current = clampIndex(active, slides.length);
37
+ const track = slides
38
+ .map((slide, i) => `<div ${CAROUSEL_SLIDE_ATTR}="${slide.id}" class="reticle-carousel-slide"${i === current ? '' : ' hidden'}>${slide.html}</div>`)
39
+ .join('');
40
+ const dots = 1 === slides.length
41
+ ? ''
42
+ : `<div class="reticle-carousel-dots">${slides
43
+ .map((slide, i) => `<button type="button" ${CAROUSEL_DOT_ATTR}="${String(i)}" class="reticle-carousel-dot" aria-label="${slide.id}" aria-current="${String(i === current)}"></button>`)
44
+ .join('')}</div>`;
45
+ return `<div ${CAROUSEL_ATTR} class="reticle-carousel" role="region" aria-label="Reticle" data-active="${String(current)}">
46
+ <button type="button" ${CAROUSEL_CLOSE_ATTR} class="reticle-carousel-close" aria-label="${CLOSE_LABEL}" title="${CLOSE_LABEL}">×</button>
47
+ <div class="reticle-carousel-track">${track}</div>
48
+ ${dots}
49
+ </div>`;
50
+ }
51
+ function dismissed(storage) {
52
+ try {
53
+ return '1' === storage?.getItem(CAROUSEL_DISMISSED_KEY);
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ }
59
+ export function paintCarousel(log, slides, storage) {
60
+ const existing = log.querySelector(`:scope > [${CAROUSEL_ATTR}]`);
61
+ const active = Number(existing?.getAttribute('data-active') ?? '0');
62
+ existing?.remove();
63
+ stopRotation(log);
64
+ if (dismissed(storage))
65
+ return;
66
+ const html = carouselHtml(slides, active);
67
+ if ('' === html)
68
+ return;
69
+ log.insertAdjacentHTML('afterbegin', html);
70
+ const root = log.firstElementChild;
71
+ if (!(root instanceof HTMLElement))
72
+ return;
73
+ root.querySelector(`[${CAROUSEL_CLOSE_ATTR}]`)?.addEventListener('click', (event) => {
74
+ event.preventDefault();
75
+ event.stopPropagation();
76
+ try {
77
+ storage?.setItem(CAROUSEL_DISMISSED_KEY, '1');
78
+ }
79
+ catch {
80
+ }
81
+ stopRotation(log);
82
+ root.remove();
83
+ });
84
+ for (const dot of root.querySelectorAll(`[${CAROUSEL_DOT_ATTR}]`)) {
85
+ dot.addEventListener('click', (event) => {
86
+ event.preventDefault();
87
+ event.stopPropagation();
88
+ show(root, Number(dot.getAttribute(CAROUSEL_DOT_ATTR)));
89
+ });
90
+ }
91
+ startRotation(log, root, slides.length);
92
+ }
93
+ function startRotation(log, root, count) {
94
+ if (count < 2 || prefersReducedMotion())
95
+ return;
96
+ let paused = false;
97
+ const pause = () => {
98
+ paused = true;
99
+ };
100
+ const resume = () => {
101
+ paused = false;
102
+ };
103
+ root.addEventListener('mouseenter', pause);
104
+ root.addEventListener('mouseleave', resume);
105
+ root.addEventListener('focusin', pause);
106
+ root.addEventListener('focusout', resume);
107
+ rotations.set(log, setInterval(() => {
108
+ if (!root.isConnected) {
109
+ stopRotation(log);
110
+ return;
111
+ }
112
+ if (paused)
113
+ return;
114
+ show(root, (Number(root.getAttribute('data-active')) + 1) % count);
115
+ }, ROTATE_MS));
116
+ }
117
+ export const CAROUSEL_CSS = `
118
+ .reticle-carousel{position:relative;margin:8px 10px;padding:10px 12px 8px;border:1px solid var(--reticle-line,#2a2f3a);border-radius:10px;background:var(--reticle-surface-inset,rgba(255,255,255,.03));}
119
+ .reticle-carousel-track{height:100px;overflow:hidden;}
120
+ .reticle-carousel-slide .reticle-offer,.reticle-carousel-slide .reticle-talk{margin:0;padding:0;border:0;background:none;}
121
+ .reticle-carousel-close{position:absolute;top:4px;right:6px;width:20px;height:20px;padding:0;border:0;background:none;color:inherit;opacity:.55;cursor:pointer;font-size:14px;line-height:20px;}
122
+ .reticle-carousel-close:hover{opacity:1;}
123
+ .reticle-carousel-dots{display:flex;justify-content:center;gap:6px;margin-top:6px;}
124
+ .reticle-carousel-dot{width:6px;height:6px;padding:0;border:0;border-radius:999px;background:currentColor;opacity:.3;cursor:pointer;}
125
+ .reticle-carousel-dot[aria-current="true"]{opacity:.9;}
126
+ `;
@@ -0,0 +1,21 @@
1
+ export declare const OFFER_ATTR = "data-reticle-offer";
2
+ export declare const OFFER_CLAIM_ATTR = "data-reticle-offer-claim";
3
+ export declare const OFFER_DISMISSED_KEY = "reticle.harnessOffer.dismissed";
4
+ export declare const OFFER_TEXT: {
5
+ readonly HEADLINE: "Get Harness free for 3 months";
6
+ readonly BODY: "Explores your app and saves every journey as a free replay.";
7
+ readonly CLAIM: "Claim now";
8
+ readonly DISMISS: "Not now";
9
+ readonly claimed: (days: number) => string;
10
+ readonly ending: (days: number) => string;
11
+ };
12
+ export declare function offerDismissed(storage: Pick<Storage, 'getItem'> | undefined): boolean;
13
+ export interface OfferState {
14
+ claimed: boolean;
15
+ daysRemaining?: number | undefined;
16
+ claimUrl?: string | undefined;
17
+ eligible?: boolean | undefined;
18
+ drivable?: boolean | undefined;
19
+ }
20
+ export declare function offerHtml(offer: OfferState | undefined, dismissed: boolean): string;
21
+ 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-title{display:block;font-weight:600;font-size:12px;padding-right:18px;}\n.reticle-offer-body{margin:6px 0 8px;font-size:11px;line-height:1.45;opacity:.8;}\n.reticle-offer-claim{display:inline-block;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-claimed{margin:6px 10px;font-size:10px;opacity:.6;}\n";
@@ -0,0 +1,55 @@
1
+ export const OFFER_ATTR = 'data-reticle-offer';
2
+ export const OFFER_CLAIM_ATTR = 'data-reticle-offer-claim';
3
+ export const OFFER_DISMISSED_KEY = 'reticle.harnessOffer.dismissed';
4
+ export const OFFER_TEXT = {
5
+ HEADLINE: 'Get Harness free for 3 months',
6
+ BODY: 'Explores your app and saves every journey as a free replay.',
7
+ CLAIM: 'Claim now',
8
+ DISMISS: 'Not now',
9
+ claimed: (days) => `Harness active — ${String(days)} days left`,
10
+ ending: (days) => 1 >= days
11
+ ? 'Harness free period ends today'
12
+ : `Harness free period ends in ${String(days)} days`,
13
+ };
14
+ const ENDING_SOON_DAYS = 14;
15
+ export function offerDismissed(storage) {
16
+ try {
17
+ return '1' === storage?.getItem(OFFER_DISMISSED_KEY);
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ const escape = (text) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
24
+ export function offerHtml(offer, dismissed) {
25
+ if (offer === undefined)
26
+ return '';
27
+ if (offer.claimed) {
28
+ const days = offer.daysRemaining;
29
+ if (days === undefined || 0 > days)
30
+ return '';
31
+ const text = ENDING_SOON_DAYS >= days ? OFFER_TEXT.ending(days) : OFFER_TEXT.claimed(days);
32
+ return `<div ${OFFER_ATTR} class="reticle-offer reticle-offer-claimed">${escape(text)}</div>`;
33
+ }
34
+ if (false === offer.eligible)
35
+ return '';
36
+ if (false === offer.drivable)
37
+ return '';
38
+ if (dismissed)
39
+ return '';
40
+ const url = offer.claimUrl;
41
+ if (url === undefined || 0 === url.length)
42
+ return '';
43
+ return `<div ${OFFER_ATTR} class="reticle-offer" role="note">
44
+ <span class="reticle-offer-title">${escape(OFFER_TEXT.HEADLINE)}</span>
45
+ <p class="reticle-offer-body">${escape(OFFER_TEXT.BODY)}</p>
46
+ <a ${OFFER_CLAIM_ATTR} class="reticle-offer-claim" href="${escape(url)}" target="_blank" rel="noopener noreferrer">${escape(OFFER_TEXT.CLAIM)}</a>
47
+ </div>`;
48
+ }
49
+ export const OFFER_CSS = `
50
+ .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));}
51
+ .reticle-offer-title{display:block;font-weight:600;font-size:12px;padding-right:18px;}
52
+ .reticle-offer-body{margin:6px 0 8px;font-size:11px;line-height:1.45;opacity:.8;}
53
+ .reticle-offer-claim{display:inline-block;font-size:11px;font-weight:600;padding:4px 10px;border-radius:6px;background:var(--reticle-accent,#4f7cff);color:#fff;text-decoration:none;}
54
+ .reticle-offer-claimed{margin:6px 10px;font-size:10px;opacity:.6;}
55
+ `;
@@ -0,0 +1,7 @@
1
+ import { type OfferState } from './offer-card.js';
2
+ import type { Slide } from './carousel.js';
3
+ export declare const SLIDE_ID: {
4
+ readonly HARNESS: "harness";
5
+ readonly FOUNDERS: "founders";
6
+ };
7
+ export declare function panelSlides(offer: OfferState | undefined, offerDeclined: boolean): Slide[];
@@ -0,0 +1,10 @@
1
+ import { offerHtml } from './offer-card.js';
2
+ import { talkCardHtml } from './talk-card.js';
3
+ export const SLIDE_ID = { HARNESS: 'harness', FOUNDERS: 'founders' };
4
+ export function panelSlides(offer, offerDeclined) {
5
+ const harness = offerHtml(offer, offerDeclined);
6
+ return [
7
+ ...('' === harness ? [] : [{ id: SLIDE_ID.HARNESS, html: harness }]),
8
+ { id: SLIDE_ID.FOUNDERS, html: talkCardHtml() },
9
+ ];
10
+ }
@@ -0,0 +1,8 @@
1
+ export declare const TALK_BOOK_ATTR = "data-reticle-talk-book";
2
+ export declare const TALK_TEXT: {
3
+ readonly HEADLINE: "Talk to Founders";
4
+ readonly BODY: "Tell us where Reticle got in your way.";
5
+ readonly BOOK: "Book a call";
6
+ };
7
+ export declare function talkCardHtml(): string;
8
+ export declare const TALK_CSS = "\n.reticle-talk-title{display:block;font-weight:600;font-size:12px;padding-right:18px;}\n.reticle-talk-body{margin:6px 0 8px;font-size:11px;line-height:1.45;opacity:.8;}\n.reticle-talk-book{display:inline-block;font-size:11px;font-weight:600;padding:4px 10px;border-radius:6px;background:var(--reticle-accent,#4f7cff);color:#fff;text-decoration:none;}\n";