@kharon-labs/react 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kharon Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # @kharon-labs/react
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ React components for Kharon Labs personalization, on top of
4
+ [`@kharon-labs/browser`](https://www.npmjs.com/package/@kharon-labs/browser):
5
+ `npm install @kharon-labs/react`.
6
+
7
+ ```tsx
8
+ import { useEffect } from "react";
9
+ import { KharonProvider, Slot, useKharon } from "@kharon-labs/react";
10
+
11
+ export function App() {
12
+ return (
13
+ <KharonProvider publishableKey="ge_pk_…">
14
+ <Slot id="hero" defaults={{ headline: "Grow faster", ctaLabel: "Book a demo" }}>
15
+ {(c) => (
16
+ <>
17
+ <h1>{c.headline}</h1>
18
+ <a href="/demo">{c.ctaLabel}</a>
19
+ </>
20
+ )}
21
+ </Slot>
22
+ </KharonProvider>
23
+ );
24
+ }
25
+
26
+ function ConsentBridge() {
27
+ const { setConsent } = useKharon();
28
+ useEffect(() => myCmp.onChange((ok) => setConsent(ok ? "granted" : "denied")), [setConsent]);
29
+ return null;
30
+ }
31
+ ```
32
+
33
+ - `<Slot id defaults>` has the same shape as the hosted site's `<Slot>`. It renders `defaults` until
34
+ a decision arrives and takes only the fields you declared from the variant; one the variant
35
+ doesn't set keeps its default. Text is rendered by React, so variant content can't inject markup.
36
+ - `<KharonProvider>` takes the `@kharon-labs/browser` options (`apiBase`, `consent`, `timeoutMs`,
37
+ `pageId`, `cookieDomain`). Changing `consent` works like calling `setConsent`. After `timeoutMs`
38
+ (500 ms) an answer is dropped and the defaults stay.
39
+ - `useKharon()` gives `setConsent`, `conversion(goal, value?)` and `refresh()` (call it after a
40
+ route change that mounts new slots).
41
+
42
+ ## Server decisions (no flash)
43
+
44
+ Resolve on your server with `@kharon-labs/server` and pass the result as `decisions`. Slots render
45
+ it on the first paint, the browser skips its own resolve, and measurement starts if the server
46
+ allowed storing the visitor.
47
+
48
+ ```tsx
49
+ <KharonProvider publishableKey="ge_pk_…" decisions={decisions}>…</KharonProvider>
50
+ ```
51
+
52
+ The provider and slots are client components (`"use client"`), so they work in the Next.js App
53
+ Router. React 18 or later; adds about 1 KB gzip to the browser core.
package/dist/index.cjs ADDED
@@ -0,0 +1,161 @@
1
+ "use client";
2
+ "use strict";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // packages/react/src/index.tsx
22
+ var index_exports = {};
23
+ __export(index_exports, {
24
+ KharonProvider: () => KharonProvider,
25
+ Slot: () => Slot,
26
+ useKharon: () => useKharon
27
+ });
28
+ module.exports = __toCommonJS(index_exports);
29
+ var import_react = require("react");
30
+ var import_browser = require("@kharon-labs/browser");
31
+
32
+ // src/engine/analytics/touch.ts
33
+ var CLICK_ID_PARAMS = {
34
+ gclid: "google",
35
+ gbraid: "google",
36
+ wbraid: "google",
37
+ msclkid: "microsoft",
38
+ fbclid: "meta",
39
+ li_fat_id: "linkedin",
40
+ ttclid: "tiktok",
41
+ twclid: "x"
42
+ };
43
+ var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
44
+
45
+ // src/engine/analytics/beacon.ts
46
+ var SLOT_ATTRIBUTES = {
47
+ slot: "data-ge-slot",
48
+ variant: "data-ge-variant",
49
+ counterfactual: "data-ge-counterfactual",
50
+ /** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
51
+ reason: "data-ge-reason",
52
+ /** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
53
+ rung: "data-ge-rung"
54
+ };
55
+
56
+ // packages/react/src/index.tsx
57
+ var import_jsx_runtime = require("react/jsx-runtime");
58
+ var KharonContext = (0, import_react.createContext)(null);
59
+ var noop = () => {
60
+ };
61
+ function KharonProvider({ publishableKey, decisions: serverDecisions, children, ...options }) {
62
+ const [response, setResponse] = (0, import_react.useState)(serverDecisions ?? null);
63
+ const slots = (0, import_react.useRef)(/* @__PURE__ */ new Map());
64
+ const client = (0, import_react.useRef)(null);
65
+ const timeoutMs = options.timeoutMs ?? import_browser.DEFAULT_TIMEOUT_MS;
66
+ const initial = (0, import_react.useRef)({ publishableKey, serverDecisions, options });
67
+ const pendingConsent = (0, import_react.useRef)(void 0);
68
+ const pending = (0, import_react.useRef)(Promise.resolve());
69
+ const resolve = (0, import_react.useCallback)((deadline) => {
70
+ pending.current = pending.current.then(async () => {
71
+ const current = client.current;
72
+ if (!current) return;
73
+ const result = await current.resolve(
74
+ [...slots.current].map(([id, defaults]) => ({ id, defaults })),
75
+ deadline
76
+ );
77
+ if (result && client.current === current) setResponse(result);
78
+ (0, import_browser.reveal)(document);
79
+ });
80
+ return pending.current;
81
+ }, []);
82
+ (0, import_react.useEffect)(() => {
83
+ const { publishableKey: key, serverDecisions: decided, options: opts } = initial.current;
84
+ const created = (0, import_browser.createClient)({ ...opts, key, consent: pendingConsent.current ?? opts.consent });
85
+ client.current = created;
86
+ if (decided) {
87
+ created.adopt(decided);
88
+ (0, import_browser.reveal)(document);
89
+ } else {
90
+ void resolve(((0, import_browser.antiFlickerStart)(document) ?? Date.now()) + (opts.timeoutMs ?? import_browser.DEFAULT_TIMEOUT_MS));
91
+ }
92
+ const unsubscribe = created.onConsentChange((consent) => {
93
+ if (consent === "granted") void resolve();
94
+ });
95
+ return () => {
96
+ unsubscribe();
97
+ created.stop();
98
+ client.current = null;
99
+ };
100
+ }, [resolve]);
101
+ (0, import_react.useEffect)(() => {
102
+ if (response) client.current?.track(response.configVersion);
103
+ }, [response]);
104
+ (0, import_react.useEffect)(() => {
105
+ if (options.consent) client.current?.setConsent(options.consent);
106
+ }, [options.consent]);
107
+ const register = (0, import_react.useCallback)((id, defaults) => {
108
+ slots.current.set(id, defaults);
109
+ return () => {
110
+ if (slots.current.get(id) === defaults) slots.current.delete(id);
111
+ };
112
+ }, []);
113
+ const setConsent = (0, import_react.useCallback)((consent) => {
114
+ if (client.current) client.current.setConsent(consent);
115
+ else pendingConsent.current = consent;
116
+ }, []);
117
+ const conversion = (0, import_react.useCallback)((goal, value2) => client.current?.conversion(goal, value2), []);
118
+ const refresh = (0, import_react.useCallback)(() => resolve(Date.now() + timeoutMs), [resolve, timeoutMs]);
119
+ const value = (0, import_react.useMemo)(
120
+ () => ({
121
+ decisions: response?.decisions ?? null,
122
+ register,
123
+ setConsent,
124
+ conversion,
125
+ refresh
126
+ }),
127
+ [response, register, setConsent, conversion, refresh]
128
+ );
129
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(KharonContext.Provider, { value, children });
130
+ }
131
+ function Slot({ id, defaults, children, as = "div", className }) {
132
+ const context = (0, import_react.useContext)(KharonContext);
133
+ const register = context?.register;
134
+ const key = JSON.stringify(defaults);
135
+ const stable = (0, import_react.useMemo)(() => JSON.parse(key), [key]);
136
+ (0, import_react.useEffect)(() => register?.(id, stable) ?? noop, [register, id, stable]);
137
+ const decision = context?.decisions?.[id];
138
+ const content = { ...defaults };
139
+ if (decision) {
140
+ for (const field of Object.keys(defaults)) if (typeof decision.content[field] === "string") content[field] = decision.content[field];
141
+ }
142
+ return (0, import_react.createElement)(
143
+ as,
144
+ {
145
+ className,
146
+ [SLOT_ATTRIBUTES.slot]: id,
147
+ ...decision && {
148
+ [SLOT_ATTRIBUTES.variant]: decision.variantId ?? "",
149
+ [SLOT_ATTRIBUTES.counterfactual]: decision.counterfactualVariantId ?? "",
150
+ [SLOT_ATTRIBUTES.reason]: decision.reason,
151
+ ...decision.rung && { [SLOT_ATTRIBUTES.rung]: decision.rung }
152
+ }
153
+ },
154
+ children(content)
155
+ );
156
+ }
157
+ function useKharon() {
158
+ const context = (0, import_react.useContext)(KharonContext);
159
+ if (!context) throw new Error("useKharon must be used inside <KharonProvider>");
160
+ return context;
161
+ }
@@ -0,0 +1,54 @@
1
+ import { Consent, KharonOptions, ResolveResponse } from '@kharon-labs/browser';
2
+ import { ReactNode } from 'react';
3
+
4
+ export interface KharonProviderProps extends Omit<KharonOptions, "key" | "root"> {
5
+ /** Publishable key (`pk_…`). */
6
+ publishableKey: string;
7
+ /**
8
+ * Decisions resolved on your server with `@kharon-labs/server`. Slots render them on the first
9
+ * paint and the browser skips its own resolve.
10
+ */
11
+ decisions?: ResolveResponse | null;
12
+ children: ReactNode;
13
+ }
14
+ export interface KharonApi {
15
+ /** Forward your consent tool's answer. A grant re-resolves and starts measuring; a denial stops it. */
16
+ setConsent(consent: Consent): void;
17
+ conversion(goal: string, value?: number): void;
18
+ /** Re-resolves the mounted slots, e.g. after a route change. */
19
+ refresh(): Promise<void>;
20
+ }
21
+ /**
22
+ * Personalizes the `<Slot>`s below it. Without `decisions` the slots paint their defaults and swap
23
+ * once the browser's resolve answers (or keep them after `timeoutMs`, 500 ms).
24
+ */
25
+ export declare function KharonProvider({ publishableKey, decisions: serverDecisions, children, ...options }: KharonProviderProps): import("react").JSX.Element;
26
+ export interface SlotProps<F extends string> {
27
+ /** Slot id from the console. */
28
+ id: string;
29
+ /** Your copy, shown until (and unless) a variant replaces it. Also what the console imports. */
30
+ defaults: Record<F, string>;
31
+ /** Renders the slot's content: the variant's fields over the defaults. */
32
+ children: (content: Record<F, string>) => ReactNode;
33
+ /** Element to render. Default `div`. */
34
+ as?: "div" | "section" | "span" | "header" | "aside" | "article" | "li";
35
+ className?: string;
36
+ }
37
+ /** A personalizable region. Same shape as the hosted site's `<Slot>`, with defaults in code. */
38
+ export declare function Slot<F extends string>({ id, defaults, children, as, className }: SlotProps<F>): import("react").DetailedReactHTMLElement<{
39
+ "data-ge-rung"?: ("default" | "exact" | "near" | "segment") | undefined;
40
+ "data-ge-variant"?: string | undefined;
41
+ "data-ge-counterfactual"?: string | undefined;
42
+ "data-ge-reason"?: ("matched" | "default" | "holdout") | undefined;
43
+ className: string | undefined;
44
+ "data-ge-slot": string;
45
+ }, HTMLElement>;
46
+ /** Consent, conversions and refresh for the nearest `<KharonProvider>`. */
47
+ export declare function useKharon(): KharonApi;
48
+
49
+ export {
50
+ Consent,
51
+ ResolveResponse,
52
+ };
53
+
54
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,146 @@
1
+ "use client";
2
+
3
+ // packages/react/src/index.tsx
4
+ import { createContext, createElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
5
+ import {
6
+ DEFAULT_TIMEOUT_MS,
7
+ antiFlickerStart,
8
+ createClient,
9
+ reveal
10
+ } from "@kharon-labs/browser";
11
+
12
+ // src/engine/analytics/touch.ts
13
+ var CLICK_ID_PARAMS = {
14
+ gclid: "google",
15
+ gbraid: "google",
16
+ wbraid: "google",
17
+ msclkid: "microsoft",
18
+ fbclid: "meta",
19
+ li_fat_id: "linkedin",
20
+ ttclid: "tiktok",
21
+ twclid: "x"
22
+ };
23
+ var CLICK_ID_KINDS = new Set(Object.values(CLICK_ID_PARAMS));
24
+
25
+ // src/engine/analytics/beacon.ts
26
+ var SLOT_ATTRIBUTES = {
27
+ slot: "data-ge-slot",
28
+ variant: "data-ge-variant",
29
+ counterfactual: "data-ge-counterfactual",
30
+ /** Why the slot shows what it shows (matched, default, holdout). For debugging; not sent. */
31
+ reason: "data-ge-reason",
32
+ /** Which step of the fallback ladder chose the content (exact, near, segment, default). Sent on impressions. */
33
+ rung: "data-ge-rung"
34
+ };
35
+
36
+ // packages/react/src/index.tsx
37
+ import { jsx } from "react/jsx-runtime";
38
+ var KharonContext = createContext(null);
39
+ var noop = () => {
40
+ };
41
+ function KharonProvider({ publishableKey, decisions: serverDecisions, children, ...options }) {
42
+ const [response, setResponse] = useState(serverDecisions ?? null);
43
+ const slots = useRef(/* @__PURE__ */ new Map());
44
+ const client = useRef(null);
45
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
46
+ const initial = useRef({ publishableKey, serverDecisions, options });
47
+ const pendingConsent = useRef(void 0);
48
+ const pending = useRef(Promise.resolve());
49
+ const resolve = useCallback((deadline) => {
50
+ pending.current = pending.current.then(async () => {
51
+ const current = client.current;
52
+ if (!current) return;
53
+ const result = await current.resolve(
54
+ [...slots.current].map(([id, defaults]) => ({ id, defaults })),
55
+ deadline
56
+ );
57
+ if (result && client.current === current) setResponse(result);
58
+ reveal(document);
59
+ });
60
+ return pending.current;
61
+ }, []);
62
+ useEffect(() => {
63
+ const { publishableKey: key, serverDecisions: decided, options: opts } = initial.current;
64
+ const created = createClient({ ...opts, key, consent: pendingConsent.current ?? opts.consent });
65
+ client.current = created;
66
+ if (decided) {
67
+ created.adopt(decided);
68
+ reveal(document);
69
+ } else {
70
+ void resolve((antiFlickerStart(document) ?? Date.now()) + (opts.timeoutMs ?? DEFAULT_TIMEOUT_MS));
71
+ }
72
+ const unsubscribe = created.onConsentChange((consent) => {
73
+ if (consent === "granted") void resolve();
74
+ });
75
+ return () => {
76
+ unsubscribe();
77
+ created.stop();
78
+ client.current = null;
79
+ };
80
+ }, [resolve]);
81
+ useEffect(() => {
82
+ if (response) client.current?.track(response.configVersion);
83
+ }, [response]);
84
+ useEffect(() => {
85
+ if (options.consent) client.current?.setConsent(options.consent);
86
+ }, [options.consent]);
87
+ const register = useCallback((id, defaults) => {
88
+ slots.current.set(id, defaults);
89
+ return () => {
90
+ if (slots.current.get(id) === defaults) slots.current.delete(id);
91
+ };
92
+ }, []);
93
+ const setConsent = useCallback((consent) => {
94
+ if (client.current) client.current.setConsent(consent);
95
+ else pendingConsent.current = consent;
96
+ }, []);
97
+ const conversion = useCallback((goal, value2) => client.current?.conversion(goal, value2), []);
98
+ const refresh = useCallback(() => resolve(Date.now() + timeoutMs), [resolve, timeoutMs]);
99
+ const value = useMemo(
100
+ () => ({
101
+ decisions: response?.decisions ?? null,
102
+ register,
103
+ setConsent,
104
+ conversion,
105
+ refresh
106
+ }),
107
+ [response, register, setConsent, conversion, refresh]
108
+ );
109
+ return /* @__PURE__ */ jsx(KharonContext.Provider, { value, children });
110
+ }
111
+ function Slot({ id, defaults, children, as = "div", className }) {
112
+ const context = useContext(KharonContext);
113
+ const register = context?.register;
114
+ const key = JSON.stringify(defaults);
115
+ const stable = useMemo(() => JSON.parse(key), [key]);
116
+ useEffect(() => register?.(id, stable) ?? noop, [register, id, stable]);
117
+ const decision = context?.decisions?.[id];
118
+ const content = { ...defaults };
119
+ if (decision) {
120
+ for (const field of Object.keys(defaults)) if (typeof decision.content[field] === "string") content[field] = decision.content[field];
121
+ }
122
+ return createElement(
123
+ as,
124
+ {
125
+ className,
126
+ [SLOT_ATTRIBUTES.slot]: id,
127
+ ...decision && {
128
+ [SLOT_ATTRIBUTES.variant]: decision.variantId ?? "",
129
+ [SLOT_ATTRIBUTES.counterfactual]: decision.counterfactualVariantId ?? "",
130
+ [SLOT_ATTRIBUTES.reason]: decision.reason,
131
+ ...decision.rung && { [SLOT_ATTRIBUTES.rung]: decision.rung }
132
+ }
133
+ },
134
+ children(content)
135
+ );
136
+ }
137
+ function useKharon() {
138
+ const context = useContext(KharonContext);
139
+ if (!context) throw new Error("useKharon must be used inside <KharonProvider>");
140
+ return context;
141
+ }
142
+ export {
143
+ KharonProvider,
144
+ Slot,
145
+ useKharon
146
+ };
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
2
  "name": "@kharon-labs/react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "React components for Kharon Labs personalization: <KharonProvider> and <Slot>.",
5
+ "license": "MIT",
6
+ "homepage": "https://kharonlabs.com",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "main": "./dist/index.cjs",
10
+ "module": "./dist/index.js",
11
+ "types": "./dist/index.d.ts",
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ }
18
+ },
19
+ "files": ["dist"],
20
+ "dependencies": {
21
+ "@kharon-labs/browser": "0.2.0"
22
+ },
23
+ "peerDependencies": {
24
+ "react": ">=18"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ }
29
+ }