@kharon-labs/browser 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,9 @@ then measures it against a holdout.
8
8
  npm install @kharon-labs/browser
9
9
  ```
10
10
 
11
- On a site without a bundler, use the script-tag build at `https://kharonlabs.com/sdk/v1.js`.
11
+ On a site without a bundler, use the script-tag build at `https://kharonlabs.com/sdk/v1.js`. On a
12
+ React site (including Next.js and React Native Web), use
13
+ [`@kharon-labs/react`](https://www.npmjs.com/package/@kharon-labs/react), which is built on this package.
12
14
 
13
15
  ## Plain HTML
14
16
 
@@ -44,6 +46,7 @@ conversion("demo_booked");
44
46
  | `init({ key, apiBase?, consent?, timeoutMs?, pageId?, cookieDomain? })` | Resolves the page's slots once the DOM is ready and starts measuring if allowed. |
45
47
  | `setConsent("granted" \| "denied" \| "unknown")` | Forward your consent tool's answer. Safe to call before `init`. |
46
48
  | `conversion(goal, value?)` | Records a goal. Forms with `data-ge-goal="…"` record theirs on submit. |
49
+ | `signup()` | Records a `signup` conversion. Prefer `recordSignup` in `@kharon-labs/server`, which counts only signups that succeeded. |
47
50
  | `refresh()` | Re-scans and re-resolves, e.g. after a client-side route change. |
48
51
 
49
52
  ## Slots
@@ -56,6 +59,13 @@ conversion("demo_booked");
56
59
  - The markup's own text is the default. A field the variant doesn't set keeps it, and a slot the
57
60
  console doesn't know yet keeps all of it.
58
61
 
62
+ ## Single-page apps
63
+
64
+ The package scans the page once, when the DOM is ready. After a client-side route change that mounts
65
+ new slots, call `refresh()`. On React Native Web, a `dataSet={{ geSlot: "hero" }}` prop renders
66
+ `data-ge-slot="hero"`; React Native's types don't declare `dataSet`, so add it with a module
67
+ augmentation (or use `@kharon-labs/react`, which needs no DOM attributes).
68
+
59
69
  ## Guarantees
60
70
 
61
71
  - **Defaults always render.** If we answer late (after `timeoutMs`, 500 ms), fail or are down, the
@@ -64,6 +74,9 @@ conversion("demo_booked");
64
74
  and Switzerland, GPC honored). Until it allows storing the visitor, the package writes no cookie
65
75
  or storage and sends no analytics.
66
76
  - **Holdouts.** Held-out visitors see the defaults; their impressions record the variant withheld.
77
+ - **Acquisition.** A session's first page view records how it arrived: `utm_*` tags, which ad
78
+ network's click id was present (never the id), a Kharon campaign link, and the referring site's
79
+ host. Never the URL's path or other parameters.
67
80
 
68
81
  The visitor id is a first-party `ge_vid` cookie on your domain (one year; Safari caps script-set
69
82
  cookies at seven days, `@kharon-labs/server` avoids that). A denial deletes it.
@@ -72,3 +85,15 @@ cookies at seven days, `@kharon-labs/server` avoids that). A denial deletes it.
72
85
 
73
86
  About 4 KB gzip, no dependencies. ES2020 browsers; without `fetch` the page keeps its defaults, and
74
87
  without `IntersectionObserver` impressions aren't recorded.
88
+
89
+ ## Your privacy policy
90
+
91
+ Adding Kharon Labs sets a first-party cookie and sends visit data to us, so your
92
+ privacy policy should say so. A paragraph to adapt (not legal advice):
93
+
94
+ > We use Kharon Labs to tailor parts of this website to the organization a visit comes from and to
95
+ > measure whether that helps. Kharon Labs processes your IP address, browser details, the page you
96
+ > visit and how you arrived, and infers your organization (not you personally). With your consent
97
+ > where required, it stores a random visitor ID in a first-party cookie (`ge_vid`) and records page
98
+ > views and interactions. See https://kharonlabs.com/privacy for what Kharon Labs collects and how
99
+ > long it keeps it.
package/dist/index.cjs CHANGED
@@ -36,10 +36,69 @@ __export(index_exports, {
36
36
  refresh: () => refresh,
37
37
  reveal: () => reveal,
38
38
  scanSlots: () => scanSlots,
39
- setConsent: () => setConsent
39
+ setConsent: () => setConsent,
40
+ signup: () => signup
40
41
  });
41
42
  module.exports = __toCommonJS(index_exports);
42
43
 
44
+ // src/engine/analytics/types.ts
45
+ var SIGNUP_GOAL = "signup";
46
+
47
+ // src/engine/analytics/touch.ts
48
+ var MAX_TOUCH_VALUE = 100;
49
+ var CLICK_ID_PARAMS = {
50
+ gclid: "google",
51
+ gbraid: "google",
52
+ wbraid: "google",
53
+ msclkid: "microsoft",
54
+ fbclid: "meta",
55
+ li_fat_id: "linkedin",
56
+ ttclid: "tiktok",
57
+ twclid: "x"
58
+ };
59
+ var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
60
+ var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
61
+ var KHARON_LINK_PARAM = "ge_co";
62
+ function touchFromUrl(url, referrer) {
63
+ const touch = {};
64
+ let page;
65
+ try {
66
+ page = new URL(url);
67
+ } catch {
68
+ return touch;
69
+ }
70
+ for (const field of UTM_FIELDS) {
71
+ const value = clean(page.searchParams.get(`utm_${field}`));
72
+ if (value) touch[field] = value;
73
+ }
74
+ for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
75
+ if (page.searchParams.get(param)) {
76
+ touch.clickId = kind;
77
+ break;
78
+ }
79
+ }
80
+ if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
81
+ const from = referrerHost(referrer);
82
+ if (from && from !== bareHost(page.hostname)) touch.referrer = from;
83
+ return touch;
84
+ }
85
+ function clean(value) {
86
+ const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
87
+ return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
88
+ }
89
+ function referrerHost(referrer) {
90
+ if (!referrer) return null;
91
+ try {
92
+ const { protocol, hostname } = new URL(referrer);
93
+ return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+ function bareHost(hostname) {
99
+ return hostname.toLowerCase().replace(/^www\./, "");
100
+ }
101
+
43
102
  // src/engine/analytics/beacon.ts
44
103
  var BEACON_VERSION = 1;
45
104
  var MAX_BEACON_EVENTS = 50;
@@ -48,7 +107,9 @@ var SLOT_ATTRIBUTES = {
48
107
  variant: "data-ge-variant",
49
108
  counterfactual: "data-ge-counterfactual",
50
109
  /** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
51
- reason: "data-ge-reason"
110
+ reason: "data-ge-reason",
111
+ /** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
112
+ rung: "data-ge-rung"
52
113
  };
53
114
 
54
115
  // packages/browser/src/tracker.ts
@@ -67,7 +128,7 @@ function startTracker({
67
128
  crossOrigin = false
68
129
  }) {
69
130
  const win = root.defaultView ?? window;
70
- const sessionId = sessionIdFor(win);
131
+ const { id: sessionId, started } = sessionFor(win);
71
132
  const queue = [];
72
133
  const seenSlots = /* @__PURE__ */ new Set();
73
134
  const push = (type, body) => {
@@ -77,7 +138,7 @@ function startTracker({
77
138
  const flush = () => {
78
139
  while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
79
140
  };
80
- push("page_view", { pageId, configVersion });
141
+ push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
81
142
  const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
82
143
  const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
83
144
  (entries) => {
@@ -143,22 +204,23 @@ function readSlot(el) {
143
204
  return {
144
205
  slotId,
145
206
  variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
146
- counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
207
+ counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
208
+ ...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
147
209
  };
148
210
  }
149
211
  function closestSlot(target) {
150
212
  const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
151
213
  return el ? readSlot(el) : null;
152
214
  }
153
- function sessionIdFor(win) {
215
+ function sessionFor(win) {
154
216
  try {
155
217
  const existing = win.sessionStorage.getItem(SESSION_KEY);
156
- if (existing) return existing;
218
+ if (existing) return { id: existing, started: false };
157
219
  const id = uuid(win);
158
220
  win.sessionStorage.setItem(SESSION_KEY, id);
159
- return id;
221
+ return { id, started: true };
160
222
  } catch {
161
- return uuid(win);
223
+ return { id: uuid(win), started: true };
162
224
  }
163
225
  }
164
226
  function uuid(win) {
@@ -204,6 +266,7 @@ function hashSlotDefaults(content) {
204
266
  var RESOLVE_PATH = "/api/v1/resolve";
205
267
  var COLLECT_V2_PATH = "/api/v1/collect";
206
268
  var REASONS = ["matched", "default", "holdout"];
269
+ var RUNGS = ["exact", "near", "segment", "default"];
207
270
  function parseResolveResponse(body) {
208
271
  if (!isRecord(body) || body.v !== RESOLVE_API_VERSION || !isRecord(body.decisions)) return null;
209
272
  if (!Number.isSafeInteger(body.configVersion) || typeof body.persist !== "boolean") return null;
@@ -216,7 +279,10 @@ function parseResolveResponse(body) {
216
279
  if (decision) decisions[id] = decision;
217
280
  }
218
281
  const m = persist && typeof body.m === "string" && body.m.length <= 1024 ? body.m : void 0;
219
- if (body.preview === true) return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist: false, preview: true, decisions };
282
+ if (body.preview === true) {
283
+ const explain = isRecord(body.explain) ? body.explain : void 0;
284
+ return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist: false, preview: true, decisions, ...explain ? { explain } : {} };
285
+ }
220
286
  return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist, vid, ...pin ? { pin } : {}, decisions, ...m ? { m } : {} };
221
287
  }
222
288
  function parseDecision(raw) {
@@ -225,10 +291,12 @@ function parseDecision(raw) {
225
291
  if (!REASONS.includes(raw.reason)) return null;
226
292
  const content = {};
227
293
  for (const [field, value] of Object.entries(raw.content)) if (typeof value === "string") content[field] = value;
294
+ const rung = RUNGS.includes(raw.rung) ? raw.rung : void 0;
228
295
  return {
229
296
  variantId: raw.variantId,
230
297
  counterfactualVariantId: raw.counterfactualVariantId,
231
298
  reason: raw.reason,
299
+ ...rung && { rung },
232
300
  content
233
301
  };
234
302
  }
@@ -274,6 +342,7 @@ function createClient(options) {
274
342
  if (response.preview) {
275
343
  previewing = true;
276
344
  stopTracking(true);
345
+ if (response.explain) win.console?.info("[kharon] debug link: why this page shows what it does", response.explain);
277
346
  return;
278
347
  }
279
348
  if (response.persist && response.vid && consent !== "denied") {
@@ -479,6 +548,8 @@ function applyDecision(el, decision, baseURI) {
479
548
  el.setAttribute(SLOT_ATTRIBUTES.variant, decision.variantId ?? "");
480
549
  el.setAttribute(SLOT_ATTRIBUTES.counterfactual, decision.counterfactualVariantId ?? "");
481
550
  el.setAttribute(SLOT_ATTRIBUTES.reason, decision.reason);
551
+ if (decision.rung) el.setAttribute(SLOT_ATTRIBUTES.rung, decision.rung);
552
+ else el.removeAttribute(SLOT_ATTRIBUTES.rung);
482
553
  }
483
554
  function reveal(root) {
484
555
  root.getElementById(ANTI_FLICKER_ID)?.remove();
@@ -574,6 +645,9 @@ function setConsent(consent) {
574
645
  function conversion(goal, value) {
575
646
  current?.conversion(goal, value);
576
647
  }
648
+ function signup() {
649
+ current?.conversion(SIGNUP_GOAL);
650
+ }
577
651
  function refresh() {
578
652
  return current?.refresh() ?? Promise.resolve();
579
653
  }
package/dist/index.d.ts CHANGED
@@ -1,9 +1,52 @@
1
- type ConsentChoice = "granted" | "denied" | "unknown";
1
+ type SignalSource = "campaign_link" | "form_email" | "known_visitor" | "reverse_ip" | "referrer";
2
+ type AttributeKey = "company.domain" | "company.name" | "company.legalName" | "company.industry"
3
+ /** NAICS sector id ("finance", "health-care"), rolled up from `company.naics` by `naicsSector`. */
4
+ | "company.sector" | "company.naics" | "company.sic" | "company.employeeCount" | "company.employeeBand" | "company.revenueBand" | "company.country" | "company.region" | "company.city" | "company.founded" | "company.ownership" | "company.ticker" | "crm.lists" | "crm.stage" | "crm.owner" | "crm.isCustomer" | "visitor.role" | "visitor.networkType" | "visitor.identified" | "visitor.country" | "intent.campaign" | "intent.utmSource" | "intent.utmMedium" | "intent.utmTerm" | "intent.referrerHost";
5
+ type AttributeValue = string | number | boolean;
6
+ type ListAttributeKey = "crm.lists";
7
+ type ScalarAttributeKey = Exclude<AttributeKey, ListAttributeKey>;
8
+ type IdentityContext = Partial<Record<ScalarAttributeKey, AttributeValue>> & Partial<Record<ListAttributeKey, string[]>>;
9
+ type Condition = ({
10
+ attr: ScalarAttributeKey;
11
+ op: "eq";
12
+ value: AttributeValue;
13
+ } | {
14
+ attr: ScalarAttributeKey;
15
+ op: "in";
16
+ value: AttributeValue[];
17
+ }
18
+ /** `startsWith` is a prefix match, for hierarchical codes: NAICS "51" covers "5112". */
19
+ | {
20
+ attr: ScalarAttributeKey;
21
+ op: "contains" | "startsWith";
22
+ value: string;
23
+ } | {
24
+ attr: ScalarAttributeKey;
25
+ op: "gte" | "lte";
26
+ value: number;
27
+ }
28
+ /** The list attribute holds this id, e.g. the account is on CRM list "hs-123". */
29
+ | {
30
+ attr: ListAttributeKey;
31
+ op: "has";
32
+ value: string;
33
+ } | {
34
+ attr: AttributeKey;
35
+ op: "exists";
36
+ }) & {
37
+ /**
38
+ * For near matching: a visitor who fails this condition never gets a near match. Exact matching
39
+ * ignores it. Conditions under `not` are always required.
40
+ */
41
+ required?: boolean;
42
+ };
2
43
  type SlotContent = Record<string, string>;
44
+ type VariantStatus = "draft" | "live" | "paused" | "retired";
3
45
  type DecisionReason = "matched" | "default" | "holdout";
46
+ type DecisionRung = "exact" | "near" | "segment" | "default";
4
47
  interface Decision {
5
48
  slotId: string;
6
- /** The variant shown, or null when default content was shown. */
49
+ /** The variant or segment row (`seg:…`) shown, or null when default content was shown. */
7
50
  variantId: string | null;
8
51
  /**
9
52
  * For holdout visitors: the variant they *would* have seen. This is the control group for that
@@ -11,9 +54,65 @@ interface Decision {
11
54
  */
12
55
  counterfactualVariantId: string | null;
13
56
  reason: DecisionReason;
57
+ rung: DecisionRung;
14
58
  /** Content with all tokens rendered. */
15
59
  content: SlotContent;
16
60
  }
61
+ type ConsentChoice = "granted" | "denied" | "unknown";
62
+ type CandidateOutcome = "wins" | "outranked" | "skipped" | "held-out";
63
+ type ExplainOutcome = Exclude<CandidateOutcome, "held-out"> | "no-match" | "not-live";
64
+ interface ConditionExplanation {
65
+ attr: AttributeKey;
66
+ op: Condition["op"];
67
+ /** The rule's value; absent for `exists`. */
68
+ value?: Extract<Condition, {
69
+ value: unknown;
70
+ }>["value"];
71
+ /** The visitor's value for `attr`, or null when they have none. */
72
+ actual: AttributeValue | string[] | null;
73
+ /** Whether this condition alone holds. Null when the attribute can't be known here (see `unavailable`). */
74
+ matched: boolean | null;
75
+ }
76
+ interface AudienceExplanation {
77
+ id: string;
78
+ name: string;
79
+ matched: boolean;
80
+ /** Some condition reads an attribute this explanation can't see, so `matched` may be wrong for real visitors. */
81
+ unknown: boolean;
82
+ /** Every leaf condition of the rule, in order. `not`, `any` and `all` are folded into `matched`. */
83
+ conditions: ConditionExplanation[];
84
+ }
85
+ interface VariantExplanation {
86
+ variantId: string;
87
+ audienceId: string;
88
+ status: VariantStatus;
89
+ /** Whether the variant's audience matches this visitor, whatever its status. */
90
+ audienceMatched: boolean;
91
+ outcome: ExplainOutcome;
92
+ /** For `skipped`: the attributes with no value and no fallback that stopped it rendering. */
93
+ missing?: string[];
94
+ }
95
+ interface SlotExplanation {
96
+ /** The variant or segment row (`seg:…`) shown, or null for default content. */
97
+ variantId: string | null;
98
+ reason: DecisionReason;
99
+ /** The step of the fallback ladder that chose the content. */
100
+ rung: DecisionRung;
101
+ /** The text each field shows. */
102
+ content: Record<string, string>;
103
+ variants: VariantExplanation[];
104
+ }
105
+ interface SiteExplanation {
106
+ company: {
107
+ domain: string;
108
+ source: SignalSource;
109
+ confidence: number;
110
+ } | null;
111
+ /** The flat attributes audiences and templates see. */
112
+ context: IdentityContext;
113
+ audiences: AudienceExplanation[];
114
+ slots: Record<string, SlotExplanation>;
115
+ }
17
116
  declare const RESOLVE_API_VERSION = 1;
18
117
  interface DeclaredSlot {
19
118
  id: string;
@@ -40,8 +139,11 @@ export interface ResolveRequest {
40
139
  /** The session's config pin from an earlier response (`"<version>.<epoch>"`), to keep its copy stable. */
41
140
  pin?: string;
42
141
  }
43
- /** What one slot shows. The same fields `<Slot>` writes as `data-ge-*` attributes. */
44
- export type ResolvedSlot = Pick<Decision, "variantId" | "counterfactualVariantId" | "reason" | "content">;
142
+ /**
143
+ * What one slot shows. The same fields `<Slot>` writes as `data-ge-*` attributes. `rung` is absent in
144
+ * answers from servers that predate the fallback ladder.
145
+ */
146
+ export type ResolvedSlot = Pick<Decision, "variantId" | "counterfactualVariantId" | "reason" | "content"> & Partial<Pick<Decision, "rung">>;
45
147
  export interface ResolveResponse {
46
148
  v: typeof RESOLVE_API_VERSION;
47
149
  /** Published config version the decisions came from; 0 when the site has never published. */
@@ -64,6 +166,11 @@ export interface ResolveResponse {
64
166
  * for a synthetic visitor. The package stores nothing, forgets nothing and doesn't measure.
65
167
  */
66
168
  preview?: true;
169
+ /**
170
+ * A debug link (docs/design/resolve-debug): why each requested slot shows what it does to this
171
+ * request, resolved from what our CDN saw. Only with `preview`; the browser SDK logs it to the console.
172
+ */
173
+ explain?: SiteExplanation;
67
174
  }
68
175
  /**
69
176
  * A short, stable fingerprint of a slot's defaults, so resolve can tell when the code and the
@@ -154,6 +261,11 @@ export declare function init(options: KharonOptions): Kharon;
154
261
  /** Same as `instance.setConsent`. Safe to call before `init`; the choice is used by the first resolve. */
155
262
  export declare function setConsent(consent: Consent): void;
156
263
  export declare function conversion(goal: string, value?: number): void;
264
+ /**
265
+ * Records a signup from the page. Prefer `recordSignup` in `@kharon-labs/server` from your signup
266
+ * handler: it counts only signups that succeeded, and ones whose browser blocks scripts.
267
+ */
268
+ export declare function signup(): void;
157
269
  export declare function refresh(): Promise<void>;
158
270
 
159
271
  export {};
package/dist/index.js CHANGED
@@ -1,3 +1,61 @@
1
+ // src/engine/analytics/types.ts
2
+ var SIGNUP_GOAL = "signup";
3
+
4
+ // src/engine/analytics/touch.ts
5
+ var MAX_TOUCH_VALUE = 100;
6
+ var CLICK_ID_PARAMS = {
7
+ gclid: "google",
8
+ gbraid: "google",
9
+ wbraid: "google",
10
+ msclkid: "microsoft",
11
+ fbclid: "meta",
12
+ li_fat_id: "linkedin",
13
+ ttclid: "tiktok",
14
+ twclid: "x"
15
+ };
16
+ var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
17
+ var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
18
+ var KHARON_LINK_PARAM = "ge_co";
19
+ function touchFromUrl(url, referrer) {
20
+ const touch = {};
21
+ let page;
22
+ try {
23
+ page = new URL(url);
24
+ } catch {
25
+ return touch;
26
+ }
27
+ for (const field of UTM_FIELDS) {
28
+ const value = clean(page.searchParams.get(`utm_${field}`));
29
+ if (value) touch[field] = value;
30
+ }
31
+ for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
32
+ if (page.searchParams.get(param)) {
33
+ touch.clickId = kind;
34
+ break;
35
+ }
36
+ }
37
+ if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
38
+ const from = referrerHost(referrer);
39
+ if (from && from !== bareHost(page.hostname)) touch.referrer = from;
40
+ return touch;
41
+ }
42
+ function clean(value) {
43
+ const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
44
+ return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
45
+ }
46
+ function referrerHost(referrer) {
47
+ if (!referrer) return null;
48
+ try {
49
+ const { protocol, hostname } = new URL(referrer);
50
+ return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+ function bareHost(hostname) {
56
+ return hostname.toLowerCase().replace(/^www\./, "");
57
+ }
58
+
1
59
  // src/engine/analytics/beacon.ts
2
60
  var BEACON_VERSION = 1;
3
61
  var MAX_BEACON_EVENTS = 50;
@@ -6,7 +64,9 @@ var SLOT_ATTRIBUTES = {
6
64
  variant: "data-ge-variant",
7
65
  counterfactual: "data-ge-counterfactual",
8
66
  /** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
9
- reason: "data-ge-reason"
67
+ reason: "data-ge-reason",
68
+ /** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
69
+ rung: "data-ge-rung"
10
70
  };
11
71
 
12
72
  // packages/browser/src/tracker.ts
@@ -25,7 +85,7 @@ function startTracker({
25
85
  crossOrigin = false
26
86
  }) {
27
87
  const win = root.defaultView ?? window;
28
- const sessionId = sessionIdFor(win);
88
+ const { id: sessionId, started } = sessionFor(win);
29
89
  const queue = [];
30
90
  const seenSlots = /* @__PURE__ */ new Set();
31
91
  const push = (type, body) => {
@@ -35,7 +95,7 @@ function startTracker({
35
95
  const flush = () => {
36
96
  while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
37
97
  };
38
- push("page_view", { pageId, configVersion });
98
+ push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
39
99
  const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
40
100
  const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
41
101
  (entries) => {
@@ -101,22 +161,23 @@ function readSlot(el) {
101
161
  return {
102
162
  slotId,
103
163
  variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
104
- counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
164
+ counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
165
+ ...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
105
166
  };
106
167
  }
107
168
  function closestSlot(target) {
108
169
  const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
109
170
  return el ? readSlot(el) : null;
110
171
  }
111
- function sessionIdFor(win) {
172
+ function sessionFor(win) {
112
173
  try {
113
174
  const existing = win.sessionStorage.getItem(SESSION_KEY);
114
- if (existing) return existing;
175
+ if (existing) return { id: existing, started: false };
115
176
  const id = uuid(win);
116
177
  win.sessionStorage.setItem(SESSION_KEY, id);
117
- return id;
178
+ return { id, started: true };
118
179
  } catch {
119
- return uuid(win);
180
+ return { id: uuid(win), started: true };
120
181
  }
121
182
  }
122
183
  function uuid(win) {
@@ -162,6 +223,7 @@ function hashSlotDefaults(content) {
162
223
  var RESOLVE_PATH = "/api/v1/resolve";
163
224
  var COLLECT_V2_PATH = "/api/v1/collect";
164
225
  var REASONS = ["matched", "default", "holdout"];
226
+ var RUNGS = ["exact", "near", "segment", "default"];
165
227
  function parseResolveResponse(body) {
166
228
  if (!isRecord(body) || body.v !== RESOLVE_API_VERSION || !isRecord(body.decisions)) return null;
167
229
  if (!Number.isSafeInteger(body.configVersion) || typeof body.persist !== "boolean") return null;
@@ -174,7 +236,10 @@ function parseResolveResponse(body) {
174
236
  if (decision) decisions[id] = decision;
175
237
  }
176
238
  const m = persist && typeof body.m === "string" && body.m.length <= 1024 ? body.m : void 0;
177
- if (body.preview === true) return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist: false, preview: true, decisions };
239
+ if (body.preview === true) {
240
+ const explain = isRecord(body.explain) ? body.explain : void 0;
241
+ return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist: false, preview: true, decisions, ...explain ? { explain } : {} };
242
+ }
178
243
  return { v: RESOLVE_API_VERSION, configVersion: body.configVersion, persist, vid, ...pin ? { pin } : {}, decisions, ...m ? { m } : {} };
179
244
  }
180
245
  function parseDecision(raw) {
@@ -183,10 +248,12 @@ function parseDecision(raw) {
183
248
  if (!REASONS.includes(raw.reason)) return null;
184
249
  const content = {};
185
250
  for (const [field, value] of Object.entries(raw.content)) if (typeof value === "string") content[field] = value;
251
+ const rung = RUNGS.includes(raw.rung) ? raw.rung : void 0;
186
252
  return {
187
253
  variantId: raw.variantId,
188
254
  counterfactualVariantId: raw.counterfactualVariantId,
189
255
  reason: raw.reason,
256
+ ...rung && { rung },
190
257
  content
191
258
  };
192
259
  }
@@ -232,6 +299,7 @@ function createClient(options) {
232
299
  if (response.preview) {
233
300
  previewing = true;
234
301
  stopTracking(true);
302
+ if (response.explain) win.console?.info("[kharon] debug link: why this page shows what it does", response.explain);
235
303
  return;
236
304
  }
237
305
  if (response.persist && response.vid && consent !== "denied") {
@@ -437,6 +505,8 @@ function applyDecision(el, decision, baseURI) {
437
505
  el.setAttribute(SLOT_ATTRIBUTES.variant, decision.variantId ?? "");
438
506
  el.setAttribute(SLOT_ATTRIBUTES.counterfactual, decision.counterfactualVariantId ?? "");
439
507
  el.setAttribute(SLOT_ATTRIBUTES.reason, decision.reason);
508
+ if (decision.rung) el.setAttribute(SLOT_ATTRIBUTES.rung, decision.rung);
509
+ else el.removeAttribute(SLOT_ATTRIBUTES.rung);
440
510
  }
441
511
  function reveal(root) {
442
512
  root.getElementById(ANTI_FLICKER_ID)?.remove();
@@ -532,6 +602,9 @@ function setConsent(consent) {
532
602
  function conversion(goal, value) {
533
603
  current?.conversion(goal, value);
534
604
  }
605
+ function signup() {
606
+ current?.conversion(SIGNUP_GOAL);
607
+ }
535
608
  function refresh() {
536
609
  return current?.refresh() ?? Promise.resolve();
537
610
  }
@@ -552,5 +625,6 @@ export {
552
625
  refresh,
553
626
  reveal,
554
627
  scanSlots,
555
- setConsent
628
+ setConsent,
629
+ signup
556
630
  };
package/dist/tracker.cjs CHANGED
@@ -27,6 +27,61 @@ __export(tracker_exports, {
27
27
  });
28
28
  module.exports = __toCommonJS(tracker_exports);
29
29
 
30
+ // src/engine/analytics/touch.ts
31
+ var MAX_TOUCH_VALUE = 100;
32
+ var CLICK_ID_PARAMS = {
33
+ gclid: "google",
34
+ gbraid: "google",
35
+ wbraid: "google",
36
+ msclkid: "microsoft",
37
+ fbclid: "meta",
38
+ li_fat_id: "linkedin",
39
+ ttclid: "tiktok",
40
+ twclid: "x"
41
+ };
42
+ var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
43
+ var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
44
+ var KHARON_LINK_PARAM = "ge_co";
45
+ function touchFromUrl(url, referrer) {
46
+ const touch = {};
47
+ let page;
48
+ try {
49
+ page = new URL(url);
50
+ } catch {
51
+ return touch;
52
+ }
53
+ for (const field of UTM_FIELDS) {
54
+ const value = clean(page.searchParams.get(`utm_${field}`));
55
+ if (value) touch[field] = value;
56
+ }
57
+ for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
58
+ if (page.searchParams.get(param)) {
59
+ touch.clickId = kind;
60
+ break;
61
+ }
62
+ }
63
+ if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
64
+ const from = referrerHost(referrer);
65
+ if (from && from !== bareHost(page.hostname)) touch.referrer = from;
66
+ return touch;
67
+ }
68
+ function clean(value) {
69
+ const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
70
+ return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
71
+ }
72
+ function referrerHost(referrer) {
73
+ if (!referrer) return null;
74
+ try {
75
+ const { protocol, hostname } = new URL(referrer);
76
+ return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+ function bareHost(hostname) {
82
+ return hostname.toLowerCase().replace(/^www\./, "");
83
+ }
84
+
30
85
  // src/engine/analytics/beacon.ts
31
86
  var BEACON_VERSION = 1;
32
87
  var MAX_BEACON_EVENTS = 50;
@@ -35,7 +90,9 @@ var SLOT_ATTRIBUTES = {
35
90
  variant: "data-ge-variant",
36
91
  counterfactual: "data-ge-counterfactual",
37
92
  /** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
38
- reason: "data-ge-reason"
93
+ reason: "data-ge-reason",
94
+ /** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
95
+ rung: "data-ge-rung"
39
96
  };
40
97
 
41
98
  // packages/browser/src/tracker.ts
@@ -54,7 +111,7 @@ function startTracker({
54
111
  crossOrigin = false
55
112
  }) {
56
113
  const win = root.defaultView ?? window;
57
- const sessionId = sessionIdFor(win);
114
+ const { id: sessionId, started } = sessionFor(win);
58
115
  const queue = [];
59
116
  const seenSlots = /* @__PURE__ */ new Set();
60
117
  const push = (type, body) => {
@@ -64,7 +121,7 @@ function startTracker({
64
121
  const flush = () => {
65
122
  while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
66
123
  };
67
- push("page_view", { pageId, configVersion });
124
+ push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
68
125
  const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
69
126
  const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
70
127
  (entries) => {
@@ -130,22 +187,23 @@ function readSlot(el) {
130
187
  return {
131
188
  slotId,
132
189
  variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
133
- counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
190
+ counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
191
+ ...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
134
192
  };
135
193
  }
136
194
  function closestSlot(target) {
137
195
  const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
138
196
  return el ? readSlot(el) : null;
139
197
  }
140
- function sessionIdFor(win) {
198
+ function sessionFor(win) {
141
199
  try {
142
200
  const existing = win.sessionStorage.getItem(SESSION_KEY);
143
- if (existing) return existing;
201
+ if (existing) return { id: existing, started: false };
144
202
  const id = uuid(win);
145
203
  win.sessionStorage.setItem(SESSION_KEY, id);
146
- return id;
204
+ return { id, started: true };
147
205
  } catch {
148
- return uuid(win);
206
+ return { id: uuid(win), started: true };
149
207
  }
150
208
  }
151
209
  function uuid(win) {
package/dist/tracker.d.ts CHANGED
@@ -1,4 +1,30 @@
1
1
  type SignalSource = "campaign_link" | "form_email" | "known_visitor" | "reverse_ip" | "referrer";
2
+ type DecisionRung = "exact" | "near" | "segment" | "default";
3
+ interface Touch$1 {
4
+ /** `utm_source`, `utm_medium`, … as tagged, trimmed to `MAX_TOUCH_VALUE` characters. */
5
+ source?: string;
6
+ medium?: string;
7
+ campaign?: string;
8
+ term?: string;
9
+ content?: string;
10
+ /** Which ad network's click id the URL carried (`gclid` → `google`). The id itself is dropped. */
11
+ clickId?: ClickIdKind;
12
+ /** The landing URL was one of our campaign links (`?ge_co=`). */
13
+ kharonLink?: true;
14
+ /** Host of an external referring page, lowercased and without `www.`. */
15
+ referrer?: string;
16
+ }
17
+ declare const CLICK_ID_PARAMS: {
18
+ readonly gclid: "google";
19
+ readonly gbraid: "google";
20
+ readonly wbraid: "google";
21
+ readonly msclkid: "microsoft";
22
+ readonly fbclid: "meta";
23
+ readonly li_fat_id: "linkedin";
24
+ readonly ttclid: "tiktok";
25
+ readonly twclid: "x";
26
+ };
27
+ type ClickIdKind = (typeof CLICK_ID_PARAMS)[keyof typeof CLICK_ID_PARAMS];
2
28
  interface BaseEvent {
3
29
  siteId: string;
4
30
  visitorId: string;
@@ -20,6 +46,11 @@ interface PageViewEvent extends BaseEvent {
20
46
  pageId: string;
21
47
  /** Site config version that was resolved, so results are attributable to what was live. */
22
48
  configVersion: number;
49
+ /**
50
+ * How the session arrived, on its first page view only (`{}` for a direct visit). Absent on later
51
+ * page views and from trackers older than signup attribution.
52
+ */
53
+ touch?: Touch$1;
23
54
  }
24
55
  interface SlotImpressionEvent extends BaseEvent {
25
56
  type: "slot_impression";
@@ -27,6 +58,8 @@ interface SlotImpressionEvent extends BaseEvent {
27
58
  variantId: string | null;
28
59
  /** Set for holdout visitors: the variant withheld from them. See Decision.counterfactualVariantId. */
29
60
  counterfactualVariantId: string | null;
61
+ /** The fallback-ladder step behind the decision (Decision.rung). Absent from older scripts and events. */
62
+ rung?: DecisionRung;
30
63
  }
31
64
  interface InteractionEvent extends BaseEvent {
32
65
  type: "interaction";
@@ -38,9 +71,11 @@ interface ConversionEvent extends BaseEvent {
38
71
  type: "conversion";
39
72
  goal: string;
40
73
  value?: number;
74
+ /** `server`: confirmed by the site's backend (`POST /api/v1/signups`), not reported by the page. */
75
+ source?: "server";
41
76
  }
42
77
  type AnalyticsEvent = PageViewEvent | SlotImpressionEvent | InteractionEvent | ConversionEvent;
43
- type ServerStamped = "siteId" | "visitorId" | "ts" | "companyDomain" | "companySource";
78
+ type ServerStamped = "siteId" | "visitorId" | "ts" | "companyDomain" | "companySource" | "source";
44
79
  type BeaconEvent = {
45
80
  [K in AnalyticsEvent["type"]]: Omit<Extract<AnalyticsEvent, {
46
81
  type: K;
package/dist/tracker.js CHANGED
@@ -1,3 +1,58 @@
1
+ // src/engine/analytics/touch.ts
2
+ var MAX_TOUCH_VALUE = 100;
3
+ var CLICK_ID_PARAMS = {
4
+ gclid: "google",
5
+ gbraid: "google",
6
+ wbraid: "google",
7
+ msclkid: "microsoft",
8
+ fbclid: "meta",
9
+ li_fat_id: "linkedin",
10
+ ttclid: "tiktok",
11
+ twclid: "x"
12
+ };
13
+ var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
14
+ var UTM_FIELDS = ["source", "medium", "campaign", "term", "content"];
15
+ var KHARON_LINK_PARAM = "ge_co";
16
+ function touchFromUrl(url, referrer) {
17
+ const touch = {};
18
+ let page;
19
+ try {
20
+ page = new URL(url);
21
+ } catch {
22
+ return touch;
23
+ }
24
+ for (const field of UTM_FIELDS) {
25
+ const value = clean(page.searchParams.get(`utm_${field}`));
26
+ if (value) touch[field] = value;
27
+ }
28
+ for (const [param, kind] of Object.entries(CLICK_ID_PARAMS)) {
29
+ if (page.searchParams.get(param)) {
30
+ touch.clickId = kind;
31
+ break;
32
+ }
33
+ }
34
+ if (page.searchParams.get(KHARON_LINK_PARAM)) touch.kharonLink = true;
35
+ const from = referrerHost(referrer);
36
+ if (from && from !== bareHost(page.hostname)) touch.referrer = from;
37
+ return touch;
38
+ }
39
+ function clean(value) {
40
+ const trimmed = value?.replace(/[\u0000-\u001f\u007f]/g, "").trim();
41
+ return trimmed ? trimmed.slice(0, MAX_TOUCH_VALUE) : null;
42
+ }
43
+ function referrerHost(referrer) {
44
+ if (!referrer) return null;
45
+ try {
46
+ const { protocol, hostname } = new URL(referrer);
47
+ return protocol === "http:" || protocol === "https:" ? bareHost(hostname) : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+ function bareHost(hostname) {
53
+ return hostname.toLowerCase().replace(/^www\./, "");
54
+ }
55
+
1
56
  // src/engine/analytics/beacon.ts
2
57
  var BEACON_VERSION = 1;
3
58
  var MAX_BEACON_EVENTS = 50;
@@ -6,7 +61,9 @@ var SLOT_ATTRIBUTES = {
6
61
  variant: "data-ge-variant",
7
62
  counterfactual: "data-ge-counterfactual",
8
63
  /** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
9
- reason: "data-ge-reason"
64
+ reason: "data-ge-reason",
65
+ /** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
66
+ rung: "data-ge-rung"
10
67
  };
11
68
 
12
69
  // packages/browser/src/tracker.ts
@@ -25,7 +82,7 @@ function startTracker({
25
82
  crossOrigin = false
26
83
  }) {
27
84
  const win = root.defaultView ?? window;
28
- const sessionId = sessionIdFor(win);
85
+ const { id: sessionId, started } = sessionFor(win);
29
86
  const queue = [];
30
87
  const seenSlots = /* @__PURE__ */ new Set();
31
88
  const push = (type, body) => {
@@ -35,7 +92,7 @@ function startTracker({
35
92
  const flush = () => {
36
93
  while (queue.length) send(win, endpoint, envelope(queue.splice(0, MAX_BEACON_EVENTS)), crossOrigin);
37
94
  };
38
- push("page_view", { pageId, configVersion });
95
+ push("page_view", started ? { pageId, configVersion, touch: touchFromUrl(win.location.href, root.referrer) } : { pageId, configVersion });
39
96
  const slots = Array.from(root.querySelectorAll(`[${SLOT_ATTRIBUTES.slot}]`));
40
97
  const observer = "IntersectionObserver" in win ? new win.IntersectionObserver(
41
98
  (entries) => {
@@ -101,22 +158,23 @@ function readSlot(el) {
101
158
  return {
102
159
  slotId,
103
160
  variantId: el.getAttribute(SLOT_ATTRIBUTES.variant) || null,
104
- counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null
161
+ counterfactualVariantId: el.getAttribute(SLOT_ATTRIBUTES.counterfactual) || null,
162
+ ...el.getAttribute(SLOT_ATTRIBUTES.rung) ? { rung: el.getAttribute(SLOT_ATTRIBUTES.rung) } : {}
105
163
  };
106
164
  }
107
165
  function closestSlot(target) {
108
166
  const el = target instanceof Element ? target.closest(`[${SLOT_ATTRIBUTES.slot}]`) : null;
109
167
  return el ? readSlot(el) : null;
110
168
  }
111
- function sessionIdFor(win) {
169
+ function sessionFor(win) {
112
170
  try {
113
171
  const existing = win.sessionStorage.getItem(SESSION_KEY);
114
- if (existing) return existing;
172
+ if (existing) return { id: existing, started: false };
115
173
  const id = uuid(win);
116
174
  win.sessionStorage.setItem(SESSION_KEY, id);
117
- return id;
175
+ return { id, started: true };
118
176
  } catch {
119
- return uuid(win);
177
+ return { id: uuid(win), started: true };
120
178
  }
121
179
  }
122
180
  function uuid(win) {
package/dist/v1.js CHANGED
@@ -1 +1 @@
1
- "use strict";(()=>{var Te=Object.defineProperty;var Ve=(e,t)=>{for(var n in t)Te(e,n,{get:t[n],enumerable:!0})};var ee={};Ve(ee,{ANTI_FLICKER_SNIPPET:()=>me,DEFAULT_API_BASE:()=>X,DEFAULT_TIMEOUT_MS:()=>U,VISITOR_COOKIE:()=>R,antiFlickerStart:()=>q,applyDecision:()=>J,conversion:()=>We,createClient:()=>F,hashSlotDefaults:()=>O,init:()=>Q,isWritable:()=>Z,pageIdFor:()=>Y,parseResolveResponse:()=>$,refresh:()=>qe,reveal:()=>B,scanSlots:()=>W,setConsent:()=>He});var d={slot:"data-ge-slot",variant:"data-ge-variant",counterfactual:"data-ge-counterfactual",reason:"data-ge-reason"};var Oe="/api/collect",D="ge_sid",w="ge_pin",ne=.5,Ce=2e3;function re({pageId:e,configVersion:t,endpoint:n=Oe,root:o=document,match:r,envelope:s=c=>({v:1,events:c,...r?{m:r}:{}}),crossOrigin:a=!1}){let c=o.defaultView??window,u=be(c),f=[],h=new Set,A=(l,p)=>{f.push({type:l,sessionId:u,...p}),f.length>=50&&m()},m=()=>{for(;f.length;)Pe(c,n,s(f.splice(0,50)),a)};A("page_view",{pageId:e,configVersion:t});let i=Array.from(o.querySelectorAll(`[${d.slot}]`)),v="IntersectionObserver"in c?new c.IntersectionObserver(l=>{for(let p of l){if(!p.isIntersecting||p.intersectionRatio<ne)continue;let g=se(p.target);v?.unobserve(p.target),!(!g||h.has(g.slotId))&&(h.add(g.slotId),A("slot_impression",g))}},{threshold:ne}):null;for(let l of i)v?.observe(l);let I=l=>p=>{let g=ke(p.target);g&&A("interaction",{slotId:g.slotId,variantId:g.variantId,action:l})},C=I("click"),_=I("copy"),N=l=>{l.target.open&&I("expand")(l)},k=l=>{let p=l.target.closest?.("form")?.getAttribute("data-ge-goal");p&&b(p)},x=()=>{o.visibilityState==="hidden"&&m()},b=(l,p)=>{A("conversion",p===void 0?{goal:l}:{goal:l,value:p}),m()};o.addEventListener("click",C,!0),o.addEventListener("copy",_,!0),o.addEventListener("toggle",N,!0),o.addEventListener("submit",k,!0),o.addEventListener("visibilitychange",x),c.addEventListener("pagehide",m);let y=c.setInterval(m,Ce);return{conversion:b,flush:m,stop({discard:l=!1}={}){l&&(f.length=0),m(),v?.disconnect(),c.clearInterval(y),o.removeEventListener("click",C,!0),o.removeEventListener("copy",_,!0),o.removeEventListener("toggle",N,!0),o.removeEventListener("submit",k,!0),o.removeEventListener("visibilitychange",x),c.removeEventListener("pagehide",m)}}}function se(e){let t=e.getAttribute(d.slot);return t?{slotId:t,variantId:e.getAttribute(d.variant)||null,counterfactualVariantId:e.getAttribute(d.counterfactual)||null}:null}function ke(e){let t=e instanceof Element?e.closest(`[${d.slot}]`):null;return t?se(t):null}function be(e){try{let t=e.sessionStorage.getItem(D);if(t)return t;let n=oe(e);return e.sessionStorage.setItem(D,n),n}catch{return oe(e)}}function oe(e){if(typeof e.crypto.randomUUID=="function")return e.crypto.randomUUID();let t=e.crypto.getRandomValues(new Uint8Array(16));t[6]=t[6]&15|64,t[8]=t[8]&63|128;let n=Array.from(t,o=>o.toString(16).padStart(2,"0")).join("");return`${n.slice(0,8)}-${n.slice(8,12)}-${n.slice(12,16)}-${n.slice(16,20)}-${n.slice(20)}`}function Pe(e,t,n,o){let r=JSON.stringify(n),s=o?"text/plain":"application/json";e.navigator.sendBeacon?.(t,new Blob([r],{type:s}))||e.fetch(t,{method:"POST",body:r,keepalive:!0,credentials:o?"omit":"same-origin",headers:{"content-type":s}}).catch(a=>console.warn("[ge] analytics beacon failed",a))}var S="ge_preview";var ie=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;function T(e){return typeof e=="string"&&ie.test(e)}function O(e){let t=JSON.stringify(Object.keys(e).sort().map(o=>[o,e[o]])),n=2166136261;for(let o=0;o<t.length;o++)n^=t.charCodeAt(o),n=Math.imul(n,16777619);return(n>>>0).toString(16).padStart(8,"0")}var ce="/api/v1/resolve",le="/api/v1/collect",Be=["matched","default","holdout"];function $(e){if(!M(e)||e.v!==1||!M(e.decisions)||!Number.isSafeInteger(e.configVersion)||typeof e.persist!="boolean")return null;let t=T(e.vid)?e.vid:void 0,n=e.persist&&t!==void 0,o=n&&typeof e.pin=="string"&&/^\d{1,9}\.\d{1,9}$/.test(e.pin)?e.pin:void 0,r={};for(let[a,c]of Object.entries(e.decisions)){let u=Le(c);u&&(r[a]=u)}let s=n&&typeof e.m=="string"&&e.m.length<=1024?e.m:void 0;return e.preview===!0?{v:1,configVersion:e.configVersion,persist:!1,preview:!0,decisions:r}:{v:1,configVersion:e.configVersion,persist:n,vid:t,...o?{pin:o}:{},decisions:r,...s?{m:s}:{}}}function Le(e){if(!M(e)||!M(e.content)||!ae(e.variantId)||!ae(e.counterfactualVariantId)||!Be.includes(e.reason))return null;let t={};for(let[n,o]of Object.entries(e.content))typeof o=="string"&&(t[n]=o);return{variantId:e.variantId,counterfactualVariantId:e.counterfactualVariantId,reason:e.reason,content:t}}function M(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function ae(e){return e===null||typeof e=="string"}var X="https://kharonlabs.com",U=500,Ne=5e3,R="ge_vid",De=365*24*60*60;function F(e){let t=e.root??document,n=t.defaultView??window,o=(e.apiBase??X).replace(/\/+$/,""),r=new Set,s=e.consent??"unknown",a=!1,c,u=null,f=!1,h=(i=!1)=>{u?.stop({discard:i}),u=null},A=()=>{a=!1,c=void 0,h(!0),Ke(t,R,e.cookieDomain);try{n.sessionStorage.removeItem(D),n.sessionStorage.removeItem(w)}catch{}},m=i=>{if(i.preview){f=!0,h(!0);return}i.persist&&i.vid&&s!=="denied"?(a=!0,c=i.m,K(t,R)!==i.vid&&$e(t,R,i.vid,e.cookieDomain),i.pin&&ue(n,w,i.pin)):(a||K(t,R))&&A()};return{get consent(){return s},async resolve(i,v=Date.now()+Ne){let I=K(t,R),C=pe(n,w),_=we(n),N={v:1,key:e.key,...T(I)?{vid:I}:{},...C?{pin:C}:{},url:_.url,referrer:t.referrer,slots:i.map(y=>({id:y.id,h:O(y.defaults)})),consent:s,gpc:n.navigator.globalPrivacyControl===!0},k=v-Date.now();if(k<=0||typeof n.fetch!="function")return null;let x=typeof AbortController=="function"?new AbortController:null,b;try{let y=n.fetch(o+ce,{method:"POST",body:JSON.stringify(N),credentials:"omit",signal:x?.signal}),l=new Promise(_e=>{b=setTimeout(()=>{x?.abort(),_e(null)},k)}),p=await Promise.race([y,l]);if(!p)return null;if(!p.ok)return console.warn(`[kharon] resolve answered ${p.status}; keeping defaults`),null;let g=$(await Promise.race([p.json(),l]));return!g||Date.now()>v?null:(g.preview&&_.token?ue(n,S,_.token):!g.preview&&_.token&&Me(n,S),m(g),g)}catch(y){return x?.signal.aborted||console.warn("[kharon] resolve failed; keeping defaults",y),null}finally{clearTimeout(b)}},adopt:m,track(i){h();let v=K(t,R);f||!a||!T(v)||(u=re({pageId:e.pageId??Y(n.location.pathname),configVersion:i,endpoint:o+le,root:t,crossOrigin:!0,envelope:I=>({v:2,key:e.key,vid:v,consent:s,events:I,...c?{m:c}:{}})}))},setConsent(i){if(i!==s){s=i,i==="denied"&&A();for(let v of r)v(i)}},onConsentChange(i){return r.add(i),()=>r.delete(i)},conversion(i,v){u?.conversion(i,v)},stop:()=>h()}}function Y(e){return e.split("/").map(n=>n.replace(/[^A-Za-z0-9_:-]/g,"")).filter(Boolean).join(".").slice(0,128)||"home"}function K(e,t){for(let n of e.cookie.split(";")){let[o,...r]=n.trim().split("=");if(o===t)try{return decodeURIComponent(r.join("="))}catch{return}}}function pe(e,t){try{return e.sessionStorage.getItem(t)}catch{return null}}function we(e){let t=e.location.href,n;try{n=new URL(t)}catch{return{url:t,token:null}}let o=n.searchParams.get(S);if(o)return{url:t,token:o};let r=pe(e,S);return r?(n.searchParams.set(S,r),{url:n.toString(),token:r}):{url:t,token:null}}function Me(e,t){try{e.sessionStorage.removeItem(t)}catch{}}function ue(e,t,n){try{e.sessionStorage.setItem(t,n)}catch{}}function de(e,t){let n=e.location?.protocol==="https:"?"; Secure":"";return`; Path=/; SameSite=Lax${t?`; Domain=${t}`:""}${n}`}function $e(e,t,n,o){e.cookie=`${t}=${encodeURIComponent(n)}; Max-Age=${De}${de(e,o)}`}function Ke(e,t,n){e.cookie=`${t}=; Max-Age=0${de(e,n)}`}var fe="data-ge-field",ge="data-ge-attr",G="kharon-af",me=`<style id="${G}">[${d.slot}]{visibility:hidden!important}</style><script>window.__kharonAf=Date.now();setTimeout(function(){var s=document.getElementById("${G}");s&&s.parentNode.removeChild(s)},500)<\/script>`,Ue=new Set(["href","src","action","formaction","poster","cite","xlink:href"]),Fe=/^(on|style$|srcdoc$|srcset$)/,H=new WeakMap,P="#text";function Ee(e,t){return t===null?(e.textContent??"").trim():e.getAttribute(t)}function Ie(e,t){let n=H.get(e);return n?.has(t??P)?n.get(t??P):Ee(e,t)}function ve(e,t,n){let o=H.get(e);o||H.set(e,o=new Map),o.has(t??P)||o.set(t??P,Ee(e,t)),t===null?e.textContent=n??"":n===null?e.removeAttribute(t):e.setAttribute(t,n)}function W(e){let t=new Map;for(let n of Se(e)){let o=n.getAttribute(d.slot);if(t.has(o))continue;let r={};for(let s of Re(n))s.field in r||(r[s.field]=Ie(s.el,s.attr)??"");t.set(o,{id:o,defaults:r})}return[...t.values()]}function ye(e,t){for(let n of Se(e)){let o=t.decisions[n.getAttribute(d.slot)];o&&J(n,o,e.baseURI)}}function J(e,t,n){for(let{el:o,field:r,attr:s}of Re(e)){let a=t.content[r];typeof a=="string"&&(s===null||Z(s,a,n))?ve(o,s,a):H.get(o)?.has(s??P)&&ve(o,s,Ie(o,s))}e.setAttribute(d.variant,t.variantId??""),e.setAttribute(d.counterfactual,t.counterfactualVariantId??""),e.setAttribute(d.reason,t.reason)}function B(e){e.getElementById(G)?.remove()}function q(e){let t=e.defaultView?.__kharonAf;return typeof t=="number"?t:void 0}function Z(e,t,n){let o=e.toLowerCase();if(Fe.test(o))return!1;if(!Ue.has(o))return!0;try{let{protocol:r}=new URL(t,n);return r==="http:"||r==="https:"}catch{return!1}}function Se(e){return Array.from(e.querySelectorAll(`[${d.slot}]`))}function Re(e){let t=[],n=[e,...Array.from(e.querySelectorAll(`[${fe}],[${ge}]`))];for(let o of n){if(o!==e&&o.closest(`[${d.slot}]`)!==e)continue;let r=o.getAttribute(fe);r&&t.push({el:o,field:r,attr:null});for(let s of(o.getAttribute(ge)??"").split(",")){let a=s.lastIndexOf(":"),c=s.slice(0,a).trim(),u=s.slice(a+1).trim();a>0&&c&&u&&t.push({el:o,field:u,attr:c})}}return t}var E=null,he;function Q(e){if(E)return console.warn("[kharon] init called twice; keeping the first instance"),E;let t=e.root??document,n=e.timeoutMs??U,o=F({...e,consent:he??e.consent}),r=async u=>{try{let f=await o.resolve(W(t),u);f&&ye(t,f),B(t),f&&o.track(f.configVersion)}catch(f){B(t),console.warn("[kharon] personalization failed; keeping defaults",f)}},a=new Promise(u=>{t.readyState!=="loading"?u():t.addEventListener("DOMContentLoaded",()=>u(),{once:!0})}).then(()=>r((q(t)??Date.now())+n));o.onConsentChange(u=>{u==="granted"&&(a=a.then(()=>r()))});let c={client:o,get ready(){return a},setConsent:o.setConsent,conversion:o.conversion,refresh(){return a=a.then(()=>r(Date.now()+n)),a},stop(){o.stop(),E===c&&(E=null)}};return E=c,c}function He(e){E?E.setConsent(e):he=e}function We(e,t){E?.conversion(e,t)}function qe(){return E?.refresh()??Promise.resolve()}var je=["granted","denied","unknown"],L=document.currentScript;window.kharon=ee;var Ae=L?.getAttribute("data-key");L&&Ae&&Q({key:Ae,apiBase:L.getAttribute("data-api")??new URL(L.src).origin,consent:je.find(e=>e===L.getAttribute("data-consent"))});})();
1
+ "use strict";(()=>{var be=Object.defineProperty;var Oe=(e,t)=>{for(var n in t)be(e,n,{get:t[n],enumerable:!0})};var ie={};Oe(ie,{ANTI_FLICKER_SNIPPET:()=>Ee,DEFAULT_API_BASE:()=>Q,DEFAULT_TIMEOUT_MS:()=>F,VISITOR_COOKIE:()=>E,antiFlickerStart:()=>j,applyDecision:()=>ne,conversion:()=>et,createClient:()=>q,hashSlotDefaults:()=>T,init:()=>re,isWritable:()=>oe,pageIdFor:()=>ee,parseResolveResponse:()=>$,refresh:()=>nt,reveal:()=>D,scanSlots:()=>W,setConsent:()=>Qe,signup:()=>tt});var G="signup";var X={gclid:"google",gbraid:"google",wbraid:"google",msclkid:"microsoft",fbclid:"meta",li_fat_id:"linkedin",ttclid:"tiktok",twclid:"x"},st=new Set(Object.values(X)),Ve=["source","medium","campaign","term","content"],Le="ge_co";function Y(e,t){let n={},o;try{o=new URL(e)}catch{return n}for(let i of Ve){let a=we(o.searchParams.get(`utm_${i}`));a&&(n[i]=a)}for(let[i,a]of Object.entries(X))if(o.searchParams.get(i)){n.clickId=a;break}o.searchParams.get(Le)&&(n.kharonLink=!0);let r=De(t);return r&&r!==se(o.hostname)&&(n.referrer=r),n}function we(e){let t=e?.replace(/[\u0000-\u001f\u007f]/g,"").trim();return t?t.slice(0,100):null}function De(e){if(!e)return null;try{let{protocol:t,hostname:n}=new URL(e);return t==="http:"||t==="https:"?se(n):null}catch{return null}}function se(e){return e.toLowerCase().replace(/^www\./,"")}var Z=1,B=50;var p={slot:"data-ge-slot",variant:"data-ge-variant",counterfactual:"data-ge-counterfactual",reason:"data-ge-reason",rung:"data-ge-rung"};var Me="/api/collect",U="ge_sid",K="ge_pin",ae=.5,Be=2e3;function le({pageId:e,configVersion:t,endpoint:n=Me,root:o=document,match:r,envelope:i=c=>({v:Z,events:c,...r?{m:r}:{}}),crossOrigin:a=!1}){let c=o.defaultView??window,{id:f,started:d}=Ke(c),m=[],b=new Set,R=(l,u)=>{m.push({type:l,sessionId:f,...u}),m.length>=B&&s()},s=()=>{for(;m.length;)$e(c,n,i(m.splice(0,B)),a)};R("page_view",d?{pageId:e,configVersion:t,touch:Y(c.location.href,o.referrer)}:{pageId:e,configVersion:t});let g=Array.from(o.querySelectorAll(`[${p.slot}]`)),h="IntersectionObserver"in c?new c.IntersectionObserver(l=>{for(let u of l){if(!u.isIntersecting||u.intersectionRatio<ae)continue;let v=ue(u.target);h?.unobserve(u.target),!(!v||b.has(v.slotId))&&(b.add(v.slotId),R("slot_impression",v))}},{threshold:ae}):null;for(let l of g)h?.observe(l);let x=l=>u=>{let v=Ue(u.target);v&&R("interaction",{slotId:v.slotId,variantId:v.variantId,action:l})},_=x("click"),N=x("copy"),O=l=>{l.target.open&&x("expand")(l)},C=l=>{let u=l.target.closest?.("form")?.getAttribute("data-ge-goal");u&&S(u)},V=()=>{o.visibilityState==="hidden"&&s()},S=(l,u)=>{R("conversion",u===void 0?{goal:l}:{goal:l,value:u}),s()};o.addEventListener("click",_,!0),o.addEventListener("copy",N,!0),o.addEventListener("toggle",O,!0),o.addEventListener("submit",C,!0),o.addEventListener("visibilitychange",V),c.addEventListener("pagehide",s);let M=c.setInterval(s,Be);return{conversion:S,flush:s,stop({discard:l=!1}={}){l&&(m.length=0),s(),h?.disconnect(),c.clearInterval(M),o.removeEventListener("click",_,!0),o.removeEventListener("copy",N,!0),o.removeEventListener("toggle",O,!0),o.removeEventListener("submit",C,!0),o.removeEventListener("visibilitychange",V),c.removeEventListener("pagehide",s)}}}function ue(e){let t=e.getAttribute(p.slot);return t?{slotId:t,variantId:e.getAttribute(p.variant)||null,counterfactualVariantId:e.getAttribute(p.counterfactual)||null,...e.getAttribute(p.rung)?{rung:e.getAttribute(p.rung)}:{}}:null}function Ue(e){let t=e instanceof Element?e.closest(`[${p.slot}]`):null;return t?ue(t):null}function Ke(e){try{let t=e.sessionStorage.getItem(U);if(t)return{id:t,started:!1};let n=ce(e);return e.sessionStorage.setItem(U,n),{id:n,started:!0}}catch{return{id:ce(e),started:!0}}}function ce(e){if(typeof e.crypto.randomUUID=="function")return e.crypto.randomUUID();let t=e.crypto.getRandomValues(new Uint8Array(16));t[6]=t[6]&15|64,t[8]=t[8]&63|128;let n=Array.from(t,o=>o.toString(16).padStart(2,"0")).join("");return`${n.slice(0,8)}-${n.slice(8,12)}-${n.slice(12,16)}-${n.slice(16,20)}-${n.slice(20)}`}function $e(e,t,n,o){let r=JSON.stringify(n),i=o?"text/plain":"application/json";e.navigator.sendBeacon?.(t,new Blob([r],{type:i}))||e.fetch(t,{method:"POST",body:r,keepalive:!0,credentials:o?"omit":"same-origin",headers:{"content-type":i}}).catch(a=>console.warn("[ge] analytics beacon failed",a))}var y="ge_preview";var pe=/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;function A(e){return typeof e=="string"&&pe.test(e)}function T(e){let t=JSON.stringify(Object.keys(e).sort().map(o=>[o,e[o]])),n=2166136261;for(let o=0;o<t.length;o++)n^=t.charCodeAt(o),n=Math.imul(n,16777619);return(n>>>0).toString(16).padStart(8,"0")}var de="/api/v1/resolve",ge="/api/v1/collect",He=["matched","default","holdout"],Fe=["exact","near","segment","default"];function $(e){if(!L(e)||e.v!==1||!L(e.decisions)||!Number.isSafeInteger(e.configVersion)||typeof e.persist!="boolean")return null;let t=A(e.vid)?e.vid:void 0,n=e.persist&&t!==void 0,o=n&&typeof e.pin=="string"&&/^\d{1,9}\.\d{1,9}$/.test(e.pin)?e.pin:void 0,r={};for(let[a,c]of Object.entries(e.decisions)){let f=qe(c);f&&(r[a]=f)}let i=n&&typeof e.m=="string"&&e.m.length<=1024?e.m:void 0;if(e.preview===!0){let a=L(e.explain)?e.explain:void 0;return{v:1,configVersion:e.configVersion,persist:!1,preview:!0,decisions:r,...a?{explain:a}:{}}}return{v:1,configVersion:e.configVersion,persist:n,vid:t,...o?{pin:o}:{},decisions:r,...i?{m:i}:{}}}function qe(e){if(!L(e)||!L(e.content)||!fe(e.variantId)||!fe(e.counterfactualVariantId)||!He.includes(e.reason))return null;let t={};for(let[o,r]of Object.entries(e.content))typeof r=="string"&&(t[o]=r);let n=Fe.includes(e.rung)?e.rung:void 0;return{variantId:e.variantId,counterfactualVariantId:e.counterfactualVariantId,reason:e.reason,...n&&{rung:n},content:t}}function L(e){return typeof e=="object"&&e!==null&&!Array.isArray(e)}function fe(e){return e===null||typeof e=="string"}var Q="https://kharonlabs.com",F=500,ze=5e3,E="ge_vid",We=365*24*60*60;function q(e){let t=e.root??document,n=t.defaultView??window,o=(e.apiBase??Q).replace(/\/+$/,""),r=new Set,i=e.consent??"unknown",a=!1,c,f=null,d=!1,m=(s=!1)=>{f?.stop({discard:s}),f=null},b=()=>{a=!1,c=void 0,m(!0),Ye(t,E,e.cookieDomain);try{n.sessionStorage.removeItem(U),n.sessionStorage.removeItem(K)}catch{}},R=s=>{if(s.preview){d=!0,m(!0),s.explain&&n.console?.info("[kharon] debug link: why this page shows what it does",s.explain);return}s.persist&&s.vid&&i!=="denied"?(a=!0,c=s.m,H(t,E)!==s.vid&&Xe(t,E,s.vid,e.cookieDomain),s.pin&&me(n,K,s.pin)):(a||H(t,E))&&b()};return{get consent(){return i},async resolve(s,g=Date.now()+ze){let h=H(t,E),x=ve(n,K),_=je(n),N={v:1,key:e.key,...A(h)?{vid:h}:{},...x?{pin:x}:{},url:_.url,referrer:t.referrer,slots:s.map(S=>({id:S.id,h:T(S.defaults)})),consent:i,gpc:n.navigator.globalPrivacyControl===!0},O=g-Date.now();if(O<=0||typeof n.fetch!="function")return null;let C=typeof AbortController=="function"?new AbortController:null,V;try{let S=n.fetch(o+de,{method:"POST",body:JSON.stringify(N),credentials:"omit",signal:C?.signal}),M=new Promise(v=>{V=setTimeout(()=>{C?.abort(),v(null)},O)}),l=await Promise.race([S,M]);if(!l)return null;if(!l.ok)return console.warn(`[kharon] resolve answered ${l.status}; keeping defaults`),null;let u=$(await Promise.race([l.json(),M]));return!u||Date.now()>g?null:(u.preview&&_.token?me(n,y,_.token):!u.preview&&_.token&&Ge(n,y),R(u),u)}catch(S){return C?.signal.aborted||console.warn("[kharon] resolve failed; keeping defaults",S),null}finally{clearTimeout(V)}},adopt:R,track(s){m();let g=H(t,E);d||!a||!A(g)||(f=le({pageId:e.pageId??ee(n.location.pathname),configVersion:s,endpoint:o+ge,root:t,crossOrigin:!0,envelope:h=>({v:2,key:e.key,vid:g,consent:i,events:h,...c?{m:c}:{}})}))},setConsent(s){if(s!==i){i=s,s==="denied"&&b();for(let g of r)g(s)}},onConsentChange(s){return r.add(s),()=>r.delete(s)},conversion(s,g){f?.conversion(s,g)},stop:()=>m()}}function ee(e){return e.split("/").map(n=>n.replace(/[^A-Za-z0-9_:-]/g,"")).filter(Boolean).join(".").slice(0,128)||"home"}function H(e,t){for(let n of e.cookie.split(";")){let[o,...r]=n.trim().split("=");if(o===t)try{return decodeURIComponent(r.join("="))}catch{return}}}function ve(e,t){try{return e.sessionStorage.getItem(t)}catch{return null}}function je(e){let t=e.location.href,n;try{n=new URL(t)}catch{return{url:t,token:null}}let o=n.searchParams.get(y);if(o)return{url:t,token:o};let r=ve(e,y);return r?(n.searchParams.set(y,r),{url:n.toString(),token:r}):{url:t,token:null}}function Ge(e,t){try{e.sessionStorage.removeItem(t)}catch{}}function me(e,t,n){try{e.sessionStorage.setItem(t,n)}catch{}}function Ie(e,t){let n=e.location?.protocol==="https:"?"; Secure":"";return`; Path=/; SameSite=Lax${t?`; Domain=${t}`:""}${n}`}function Xe(e,t,n,o){e.cookie=`${t}=${encodeURIComponent(n)}; Max-Age=${We}${Ie(e,o)}`}function Ye(e,t,n){e.cookie=`${t}=; Max-Age=0${Ie(e,n)}`}var Se="data-ge-field",he="data-ge-attr",te="kharon-af",Ee=`<style id="${te}">[${p.slot}]{visibility:hidden!important}</style><script>window.__kharonAf=Date.now();setTimeout(function(){var s=document.getElementById("${te}");s&&s.parentNode.removeChild(s)},500)<\/script>`,Ze=new Set(["href","src","action","formaction","poster","cite","xlink:href"]),Je=/^(on|style$|srcdoc$|srcset$)/,z=new WeakMap,w="#text";function Re(e,t){return t===null?(e.textContent??"").trim():e.getAttribute(t)}function _e(e,t){let n=z.get(e);return n?.has(t??w)?n.get(t??w):Re(e,t)}function ye(e,t,n){let o=z.get(e);o||z.set(e,o=new Map),o.has(t??w)||o.set(t??w,Re(e,t)),t===null?e.textContent=n??"":n===null?e.removeAttribute(t):e.setAttribute(t,n)}function W(e){let t=new Map;for(let n of ke(e)){let o=n.getAttribute(p.slot);if(t.has(o))continue;let r={};for(let i of xe(n))i.field in r||(r[i.field]=_e(i.el,i.attr)??"");t.set(o,{id:o,defaults:r})}return[...t.values()]}function Ae(e,t){for(let n of ke(e)){let o=t.decisions[n.getAttribute(p.slot)];o&&ne(n,o,e.baseURI)}}function ne(e,t,n){for(let{el:o,field:r,attr:i}of xe(e)){let a=t.content[r];typeof a=="string"&&(i===null||oe(i,a,n))?ye(o,i,a):z.get(o)?.has(i??w)&&ye(o,i,_e(o,i))}e.setAttribute(p.variant,t.variantId??""),e.setAttribute(p.counterfactual,t.counterfactualVariantId??""),e.setAttribute(p.reason,t.reason),t.rung?e.setAttribute(p.rung,t.rung):e.removeAttribute(p.rung)}function D(e){e.getElementById(te)?.remove()}function j(e){let t=e.defaultView?.__kharonAf;return typeof t=="number"?t:void 0}function oe(e,t,n){let o=e.toLowerCase();if(Je.test(o))return!1;if(!Ze.has(o))return!0;try{let{protocol:r}=new URL(t,n);return r==="http:"||r==="https:"}catch{return!1}}function ke(e){return Array.from(e.querySelectorAll(`[${p.slot}]`))}function xe(e){let t=[],n=[e,...Array.from(e.querySelectorAll(`[${Se}],[${he}]`))];for(let o of n){if(o!==e&&o.closest(`[${p.slot}]`)!==e)continue;let r=o.getAttribute(Se);r&&t.push({el:o,field:r,attr:null});for(let i of(o.getAttribute(he)??"").split(",")){let a=i.lastIndexOf(":"),c=i.slice(0,a).trim(),f=i.slice(a+1).trim();a>0&&c&&f&&t.push({el:o,field:f,attr:c})}}return t}var I=null,Ce;function re(e){if(I)return console.warn("[kharon] init called twice; keeping the first instance"),I;let t=e.root??document,n=e.timeoutMs??F,o=q({...e,consent:Ce??e.consent}),r=async f=>{try{let d=await o.resolve(W(t),f);d&&Ae(t,d),D(t),d&&o.track(d.configVersion)}catch(d){D(t),console.warn("[kharon] personalization failed; keeping defaults",d)}},a=new Promise(f=>{t.readyState!=="loading"?f():t.addEventListener("DOMContentLoaded",()=>f(),{once:!0})}).then(()=>r((j(t)??Date.now())+n));o.onConsentChange(f=>{f==="granted"&&(a=a.then(()=>r()))});let c={client:o,get ready(){return a},setConsent:o.setConsent,conversion:o.conversion,refresh(){return a=a.then(()=>r(Date.now()+n)),a},stop(){o.stop(),I===c&&(I=null)}};return I=c,c}function Qe(e){I?I.setConsent(e):Ce=e}function et(e,t){I?.conversion(e,t)}function tt(){I?.conversion(G)}function nt(){return I?.refresh()??Promise.resolve()}var ot=["granted","denied","unknown"],P=document.currentScript;window.kharon=ie;var Te=P?.getAttribute("data-key");P&&Te&&re({key:Te,apiBase:P.getAttribute("data-api")??new URL(P.src).origin,consent:ot.find(e=>e===P.getAttribute("data-consent"))});})();
package/package.json CHANGED
@@ -1,13 +1,8 @@
1
1
  {
2
2
  "name": "@kharon-labs/browser",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Personalize slots on any website with Kharon Labs: swaps in the variant for each visitor and measures lift against a holdout.",
5
5
  "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/alexarmenciu/growth-engine.git",
9
- "directory": "packages/browser"
10
- },
11
6
  "homepage": "https://kharonlabs.com",
12
7
  "type": "module",
13
8
  "sideEffects": false,