@billdog.io/toolbar 0.0.0-stage → 1.0.2-beta.3
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 +35 -2
- package/dist/index.d.mts +161 -0
- package/dist/index.d.ts +161 -0
- package/dist/index.js +426 -0
- package/dist/index.mjs +392 -0
- package/package.json +26 -4
package/README.md
CHANGED
|
@@ -1,3 +1,36 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @billdog.io/toolbar
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
In-page inspection overlay for your own site: see the selector autocapture will
|
|
4
|
+
record for an element, override a feature flag locally, and read click data
|
|
5
|
+
against the live page.
|
|
6
|
+
|
|
7
|
+
## Launching
|
|
8
|
+
|
|
9
|
+
The toolbar does not start on its own. A signed-in project member launches it
|
|
10
|
+
from the dashboard, which mints a short-lived signed token and appends it to the
|
|
11
|
+
page URL as a fragment. The toolbar reads that token once, strips it from the
|
|
12
|
+
address bar, and exchanges it for a session.
|
|
13
|
+
|
|
14
|
+
Two things gate it, and both are enforced server-side:
|
|
15
|
+
|
|
16
|
+
- The token is HMAC-signed and expires in hours, so it cannot be forged or kept.
|
|
17
|
+
- The page URL must appear on the project's **authorized URLs** list. An empty
|
|
18
|
+
list means the toolbar is off — an unconfigured project has not opted in, and
|
|
19
|
+
defaulting to "any origin" would expose flags and click data wherever the
|
|
20
|
+
snippet happens to load.
|
|
21
|
+
|
|
22
|
+
Subdomain wildcards are deliberately unsupported: `*.example.com` is how a
|
|
23
|
+
feature like this becomes "any subdomain an attacker can register".
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { startToolbar } from '@billdog.io/toolbar';
|
|
29
|
+
|
|
30
|
+
// Returns null when there is no launch token in the URL, which is every
|
|
31
|
+
// ordinary page load.
|
|
32
|
+
await startToolbar({ apiHost: 'https://<project>.supabase.co/functions/v1' });
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Everything renders inside a closed shadow root, so the host page's CSS cannot
|
|
36
|
+
reach into the overlay and the overlay's cannot reach back out.
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @billdog.io/toolbar — the in-page inspection overlay.
|
|
3
|
+
*
|
|
4
|
+
* Renders on the customer's own site when a project member launches it from the
|
|
5
|
+
* dashboard: inspect an element to see the selector autocapture will record,
|
|
6
|
+
* view a click heatmap over the live page, and override a feature flag locally
|
|
7
|
+
* to check a variant without shipping a rollout.
|
|
8
|
+
*
|
|
9
|
+
* ## Launch
|
|
10
|
+
*
|
|
11
|
+
* The dashboard mints a short-lived signed token and puts it in the URL
|
|
12
|
+
* FRAGMENT. Fragments are not sent to servers on navigation, so the token does
|
|
13
|
+
* not land in the customer's access logs or in a Referer header on the way out.
|
|
14
|
+
* The toolbar reads it once and immediately strips it from the address bar, so
|
|
15
|
+
* a copied URL is not a working credential.
|
|
16
|
+
*
|
|
17
|
+
* ## Isolation
|
|
18
|
+
*
|
|
19
|
+
* Everything renders inside a shadow root. The toolbar is injected into a page
|
|
20
|
+
* whose CSS it has never seen: without a shadow root, the host's stylesheet
|
|
21
|
+
* reaches into the overlay and the overlay's reaches back out, and "the toolbar
|
|
22
|
+
* broke my site's layout" is not a defensible bug to ship.
|
|
23
|
+
*/
|
|
24
|
+
interface ToolbarOptions {
|
|
25
|
+
/** Where the toolbar API lives, e.g. https://<project>.supabase.co/functions/v1 */
|
|
26
|
+
apiHost: string;
|
|
27
|
+
/** Overrides the token read from the URL fragment. Mainly for tests. */
|
|
28
|
+
token?: string;
|
|
29
|
+
}
|
|
30
|
+
interface FeatureFlagSummary {
|
|
31
|
+
id: string;
|
|
32
|
+
key: string;
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
rollout_percentage: number | null;
|
|
35
|
+
}
|
|
36
|
+
/** One aggregated click bin, as returned by the heatmap route. */
|
|
37
|
+
interface HeatmapBin {
|
|
38
|
+
/** Fraction of viewport width, 0..1. */
|
|
39
|
+
relative_x: number;
|
|
40
|
+
/** Offset within the document, in pixels. */
|
|
41
|
+
page_y: number;
|
|
42
|
+
clicks: number;
|
|
43
|
+
users: number;
|
|
44
|
+
/** 0..1, normalised against the busiest bin on the page. */
|
|
45
|
+
intensity: number;
|
|
46
|
+
}
|
|
47
|
+
interface ToolbarSession {
|
|
48
|
+
project_id: string;
|
|
49
|
+
expires_at: number;
|
|
50
|
+
feature_flags: FeatureFlagSummary[];
|
|
51
|
+
event_definitions: Array<{
|
|
52
|
+
event_name: string;
|
|
53
|
+
display_name: string | null;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
/** Fragment key the dashboard uses to hand over the launch token. */
|
|
57
|
+
declare const TOOLBAR_FRAGMENT_KEY = "__billdog_toolbar__";
|
|
58
|
+
/** localStorage key for flag overrides, read by the SDK's flag evaluation. */
|
|
59
|
+
declare const FLAG_OVERRIDE_KEY = "billdog_flag_overrides";
|
|
60
|
+
/**
|
|
61
|
+
* Read the launch token out of the URL fragment and remove it.
|
|
62
|
+
*
|
|
63
|
+
* Stripping matters: the toolbar is opened on a page a person is likely to
|
|
64
|
+
* screenshot, share or paste into a ticket, and a URL that still carries a live
|
|
65
|
+
* credential turns any of those into a leak. Exported so the removal is
|
|
66
|
+
* testable rather than a side effect buried in start-up.
|
|
67
|
+
*/
|
|
68
|
+
declare function consumeLaunchToken(location: {
|
|
69
|
+
hash: string;
|
|
70
|
+
href: string;
|
|
71
|
+
}): {
|
|
72
|
+
token: string | null;
|
|
73
|
+
cleanedHref: string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Build the selector autocapture would record for an element.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately mirrors AutoCapture's `$elements_chain`: the point of the
|
|
79
|
+
* inspector is to show what the SDK will actually key on, so a selector that
|
|
80
|
+
* merely looks reasonable is worse than useless — someone would define an
|
|
81
|
+
* action against it and wonder why it never fires.
|
|
82
|
+
*/
|
|
83
|
+
declare function describeElement(el: Element): string;
|
|
84
|
+
/**
|
|
85
|
+
* Place a bin on the CURRENT page.
|
|
86
|
+
*
|
|
87
|
+
* The stored coordinates are deliberately device-independent — a fraction of
|
|
88
|
+
* viewport width, and an offset within the document — because the clicks were
|
|
89
|
+
* aggregated across every screen size that visited the page. Turning them back
|
|
90
|
+
* into pixels is therefore a function of THIS viewport, which is what makes the
|
|
91
|
+
* overlay line up with what the person looking at it can see.
|
|
92
|
+
*
|
|
93
|
+
* `page_y` needs no conversion: it is already a document offset, so an absolutely
|
|
94
|
+
* positioned element at that y sits where the clicks were regardless of how far
|
|
95
|
+
* the reader has scrolled.
|
|
96
|
+
*/
|
|
97
|
+
declare function binToPageCoords(bin: Pick<HeatmapBin, 'relative_x' | 'page_y'>, viewportWidth: number): {
|
|
98
|
+
x: number;
|
|
99
|
+
y: number;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* A match rule for the element someone just clicked.
|
|
103
|
+
*
|
|
104
|
+
* This is the whole "define an action by clicking" feature, and its single
|
|
105
|
+
* requirement is that the rule it proposes MATCHES WHAT AUTOCAPTURE RECORDED.
|
|
106
|
+
* A rule that merely looks plausible is worse than none: it saves cleanly,
|
|
107
|
+
* appears in every picker, and reports zero forever without erroring. So every
|
|
108
|
+
* field below is read exactly the way AutoCapture.buildElementProps reads it.
|
|
109
|
+
*
|
|
110
|
+
* Field choice, most stable first:
|
|
111
|
+
* - `id`, when present. Unique and survives restyling.
|
|
112
|
+
* - `href` for links, which is what the link MEANS rather than how it looks.
|
|
113
|
+
* - visible text otherwise, because that is what the person clicking sees.
|
|
114
|
+
*
|
|
115
|
+
* Never CSS classes. Tailwind JIT and CSS-module hashes change them between
|
|
116
|
+
* builds, so an action keyed on one keeps matching until a deploy and then
|
|
117
|
+
* silently returns zero.
|
|
118
|
+
*/
|
|
119
|
+
interface ProposedStep {
|
|
120
|
+
event_type: 'click';
|
|
121
|
+
tag_name: string;
|
|
122
|
+
attr_id?: string;
|
|
123
|
+
href?: string;
|
|
124
|
+
text?: string;
|
|
125
|
+
text_match?: 'exact';
|
|
126
|
+
url?: string;
|
|
127
|
+
url_match?: 'exact';
|
|
128
|
+
}
|
|
129
|
+
declare function proposeActionStep(el: Element, pathname?: string): ProposedStep;
|
|
130
|
+
/**
|
|
131
|
+
* Whether a proposed rule actually identifies anything.
|
|
132
|
+
*
|
|
133
|
+
* A step that is only a tag name means "every button on the site". That is a
|
|
134
|
+
* legal predicate and almost never what the author meant, so the toolbar warns
|
|
135
|
+
* rather than saving it silently.
|
|
136
|
+
*/
|
|
137
|
+
declare function isSpecificStep(step: ProposedStep): boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Colour for an intensity, 0..1.
|
|
140
|
+
*
|
|
141
|
+
* Blue through green to red, with opacity rising alongside it. Intensity is
|
|
142
|
+
* relative to the busiest bin on the page, so a quiet page still shows its
|
|
143
|
+
* hottest spot as hot rather than rendering uniformly cold.
|
|
144
|
+
*/
|
|
145
|
+
declare function intensityColor(intensity: number): string;
|
|
146
|
+
/** Locally override a flag, for this browser only. */
|
|
147
|
+
declare function setFlagOverride(key: string, value: boolean | string | null): void;
|
|
148
|
+
declare function getFlagOverrides(): Record<string, unknown>;
|
|
149
|
+
/**
|
|
150
|
+
* Start the toolbar.
|
|
151
|
+
*
|
|
152
|
+
* Returns a handle so a host can tear it down, and resolves to null when there
|
|
153
|
+
* is no launch token or the session is refused — the common case on a page load
|
|
154
|
+
* that has nothing to do with the toolbar. Failing silently is correct here:
|
|
155
|
+
* this runs on someone else's website.
|
|
156
|
+
*/
|
|
157
|
+
declare function startToolbar(options: ToolbarOptions): Promise<{
|
|
158
|
+
destroy(): void;
|
|
159
|
+
} | null>;
|
|
160
|
+
|
|
161
|
+
export { FLAG_OVERRIDE_KEY, type FeatureFlagSummary, type HeatmapBin, type ProposedStep, TOOLBAR_FRAGMENT_KEY, type ToolbarOptions, type ToolbarSession, binToPageCoords, consumeLaunchToken, describeElement, getFlagOverrides, intensityColor, isSpecificStep, proposeActionStep, setFlagOverride, startToolbar };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @billdog.io/toolbar — the in-page inspection overlay.
|
|
3
|
+
*
|
|
4
|
+
* Renders on the customer's own site when a project member launches it from the
|
|
5
|
+
* dashboard: inspect an element to see the selector autocapture will record,
|
|
6
|
+
* view a click heatmap over the live page, and override a feature flag locally
|
|
7
|
+
* to check a variant without shipping a rollout.
|
|
8
|
+
*
|
|
9
|
+
* ## Launch
|
|
10
|
+
*
|
|
11
|
+
* The dashboard mints a short-lived signed token and puts it in the URL
|
|
12
|
+
* FRAGMENT. Fragments are not sent to servers on navigation, so the token does
|
|
13
|
+
* not land in the customer's access logs or in a Referer header on the way out.
|
|
14
|
+
* The toolbar reads it once and immediately strips it from the address bar, so
|
|
15
|
+
* a copied URL is not a working credential.
|
|
16
|
+
*
|
|
17
|
+
* ## Isolation
|
|
18
|
+
*
|
|
19
|
+
* Everything renders inside a shadow root. The toolbar is injected into a page
|
|
20
|
+
* whose CSS it has never seen: without a shadow root, the host's stylesheet
|
|
21
|
+
* reaches into the overlay and the overlay's reaches back out, and "the toolbar
|
|
22
|
+
* broke my site's layout" is not a defensible bug to ship.
|
|
23
|
+
*/
|
|
24
|
+
interface ToolbarOptions {
|
|
25
|
+
/** Where the toolbar API lives, e.g. https://<project>.supabase.co/functions/v1 */
|
|
26
|
+
apiHost: string;
|
|
27
|
+
/** Overrides the token read from the URL fragment. Mainly for tests. */
|
|
28
|
+
token?: string;
|
|
29
|
+
}
|
|
30
|
+
interface FeatureFlagSummary {
|
|
31
|
+
id: string;
|
|
32
|
+
key: string;
|
|
33
|
+
enabled: boolean;
|
|
34
|
+
rollout_percentage: number | null;
|
|
35
|
+
}
|
|
36
|
+
/** One aggregated click bin, as returned by the heatmap route. */
|
|
37
|
+
interface HeatmapBin {
|
|
38
|
+
/** Fraction of viewport width, 0..1. */
|
|
39
|
+
relative_x: number;
|
|
40
|
+
/** Offset within the document, in pixels. */
|
|
41
|
+
page_y: number;
|
|
42
|
+
clicks: number;
|
|
43
|
+
users: number;
|
|
44
|
+
/** 0..1, normalised against the busiest bin on the page. */
|
|
45
|
+
intensity: number;
|
|
46
|
+
}
|
|
47
|
+
interface ToolbarSession {
|
|
48
|
+
project_id: string;
|
|
49
|
+
expires_at: number;
|
|
50
|
+
feature_flags: FeatureFlagSummary[];
|
|
51
|
+
event_definitions: Array<{
|
|
52
|
+
event_name: string;
|
|
53
|
+
display_name: string | null;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
/** Fragment key the dashboard uses to hand over the launch token. */
|
|
57
|
+
declare const TOOLBAR_FRAGMENT_KEY = "__billdog_toolbar__";
|
|
58
|
+
/** localStorage key for flag overrides, read by the SDK's flag evaluation. */
|
|
59
|
+
declare const FLAG_OVERRIDE_KEY = "billdog_flag_overrides";
|
|
60
|
+
/**
|
|
61
|
+
* Read the launch token out of the URL fragment and remove it.
|
|
62
|
+
*
|
|
63
|
+
* Stripping matters: the toolbar is opened on a page a person is likely to
|
|
64
|
+
* screenshot, share or paste into a ticket, and a URL that still carries a live
|
|
65
|
+
* credential turns any of those into a leak. Exported so the removal is
|
|
66
|
+
* testable rather than a side effect buried in start-up.
|
|
67
|
+
*/
|
|
68
|
+
declare function consumeLaunchToken(location: {
|
|
69
|
+
hash: string;
|
|
70
|
+
href: string;
|
|
71
|
+
}): {
|
|
72
|
+
token: string | null;
|
|
73
|
+
cleanedHref: string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Build the selector autocapture would record for an element.
|
|
77
|
+
*
|
|
78
|
+
* Deliberately mirrors AutoCapture's `$elements_chain`: the point of the
|
|
79
|
+
* inspector is to show what the SDK will actually key on, so a selector that
|
|
80
|
+
* merely looks reasonable is worse than useless — someone would define an
|
|
81
|
+
* action against it and wonder why it never fires.
|
|
82
|
+
*/
|
|
83
|
+
declare function describeElement(el: Element): string;
|
|
84
|
+
/**
|
|
85
|
+
* Place a bin on the CURRENT page.
|
|
86
|
+
*
|
|
87
|
+
* The stored coordinates are deliberately device-independent — a fraction of
|
|
88
|
+
* viewport width, and an offset within the document — because the clicks were
|
|
89
|
+
* aggregated across every screen size that visited the page. Turning them back
|
|
90
|
+
* into pixels is therefore a function of THIS viewport, which is what makes the
|
|
91
|
+
* overlay line up with what the person looking at it can see.
|
|
92
|
+
*
|
|
93
|
+
* `page_y` needs no conversion: it is already a document offset, so an absolutely
|
|
94
|
+
* positioned element at that y sits where the clicks were regardless of how far
|
|
95
|
+
* the reader has scrolled.
|
|
96
|
+
*/
|
|
97
|
+
declare function binToPageCoords(bin: Pick<HeatmapBin, 'relative_x' | 'page_y'>, viewportWidth: number): {
|
|
98
|
+
x: number;
|
|
99
|
+
y: number;
|
|
100
|
+
};
|
|
101
|
+
/**
|
|
102
|
+
* A match rule for the element someone just clicked.
|
|
103
|
+
*
|
|
104
|
+
* This is the whole "define an action by clicking" feature, and its single
|
|
105
|
+
* requirement is that the rule it proposes MATCHES WHAT AUTOCAPTURE RECORDED.
|
|
106
|
+
* A rule that merely looks plausible is worse than none: it saves cleanly,
|
|
107
|
+
* appears in every picker, and reports zero forever without erroring. So every
|
|
108
|
+
* field below is read exactly the way AutoCapture.buildElementProps reads it.
|
|
109
|
+
*
|
|
110
|
+
* Field choice, most stable first:
|
|
111
|
+
* - `id`, when present. Unique and survives restyling.
|
|
112
|
+
* - `href` for links, which is what the link MEANS rather than how it looks.
|
|
113
|
+
* - visible text otherwise, because that is what the person clicking sees.
|
|
114
|
+
*
|
|
115
|
+
* Never CSS classes. Tailwind JIT and CSS-module hashes change them between
|
|
116
|
+
* builds, so an action keyed on one keeps matching until a deploy and then
|
|
117
|
+
* silently returns zero.
|
|
118
|
+
*/
|
|
119
|
+
interface ProposedStep {
|
|
120
|
+
event_type: 'click';
|
|
121
|
+
tag_name: string;
|
|
122
|
+
attr_id?: string;
|
|
123
|
+
href?: string;
|
|
124
|
+
text?: string;
|
|
125
|
+
text_match?: 'exact';
|
|
126
|
+
url?: string;
|
|
127
|
+
url_match?: 'exact';
|
|
128
|
+
}
|
|
129
|
+
declare function proposeActionStep(el: Element, pathname?: string): ProposedStep;
|
|
130
|
+
/**
|
|
131
|
+
* Whether a proposed rule actually identifies anything.
|
|
132
|
+
*
|
|
133
|
+
* A step that is only a tag name means "every button on the site". That is a
|
|
134
|
+
* legal predicate and almost never what the author meant, so the toolbar warns
|
|
135
|
+
* rather than saving it silently.
|
|
136
|
+
*/
|
|
137
|
+
declare function isSpecificStep(step: ProposedStep): boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Colour for an intensity, 0..1.
|
|
140
|
+
*
|
|
141
|
+
* Blue through green to red, with opacity rising alongside it. Intensity is
|
|
142
|
+
* relative to the busiest bin on the page, so a quiet page still shows its
|
|
143
|
+
* hottest spot as hot rather than rendering uniformly cold.
|
|
144
|
+
*/
|
|
145
|
+
declare function intensityColor(intensity: number): string;
|
|
146
|
+
/** Locally override a flag, for this browser only. */
|
|
147
|
+
declare function setFlagOverride(key: string, value: boolean | string | null): void;
|
|
148
|
+
declare function getFlagOverrides(): Record<string, unknown>;
|
|
149
|
+
/**
|
|
150
|
+
* Start the toolbar.
|
|
151
|
+
*
|
|
152
|
+
* Returns a handle so a host can tear it down, and resolves to null when there
|
|
153
|
+
* is no launch token or the session is refused — the common case on a page load
|
|
154
|
+
* that has nothing to do with the toolbar. Failing silently is correct here:
|
|
155
|
+
* this runs on someone else's website.
|
|
156
|
+
*/
|
|
157
|
+
declare function startToolbar(options: ToolbarOptions): Promise<{
|
|
158
|
+
destroy(): void;
|
|
159
|
+
} | null>;
|
|
160
|
+
|
|
161
|
+
export { FLAG_OVERRIDE_KEY, type FeatureFlagSummary, type HeatmapBin, type ProposedStep, TOOLBAR_FRAGMENT_KEY, type ToolbarOptions, type ToolbarSession, binToPageCoords, consumeLaunchToken, describeElement, getFlagOverrides, intensityColor, isSpecificStep, proposeActionStep, setFlagOverride, startToolbar };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
3
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
5
|
+
var __export = (target, all) => {
|
|
6
|
+
for (var name in all)
|
|
7
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
8
|
+
};
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
18
|
+
|
|
19
|
+
// src/index.ts
|
|
20
|
+
var index_exports = {};
|
|
21
|
+
__export(index_exports, {
|
|
22
|
+
FLAG_OVERRIDE_KEY: () => FLAG_OVERRIDE_KEY,
|
|
23
|
+
TOOLBAR_FRAGMENT_KEY: () => TOOLBAR_FRAGMENT_KEY,
|
|
24
|
+
binToPageCoords: () => binToPageCoords,
|
|
25
|
+
consumeLaunchToken: () => consumeLaunchToken,
|
|
26
|
+
describeElement: () => describeElement,
|
|
27
|
+
getFlagOverrides: () => getFlagOverrides,
|
|
28
|
+
intensityColor: () => intensityColor,
|
|
29
|
+
isSpecificStep: () => isSpecificStep,
|
|
30
|
+
proposeActionStep: () => proposeActionStep,
|
|
31
|
+
setFlagOverride: () => setFlagOverride,
|
|
32
|
+
startToolbar: () => startToolbar
|
|
33
|
+
});
|
|
34
|
+
module.exports = __toCommonJS(index_exports);
|
|
35
|
+
var TOOLBAR_FRAGMENT_KEY = "__billdog_toolbar__";
|
|
36
|
+
var FLAG_OVERRIDE_KEY = "billdog_flag_overrides";
|
|
37
|
+
function consumeLaunchToken(location2) {
|
|
38
|
+
const hash = location2.hash.startsWith("#") ? location2.hash.slice(1) : location2.hash;
|
|
39
|
+
if (!hash) return { token: null, cleanedHref: location2.href };
|
|
40
|
+
const params = new URLSearchParams(hash);
|
|
41
|
+
const token = params.get(TOOLBAR_FRAGMENT_KEY);
|
|
42
|
+
if (!token) return { token: null, cleanedHref: location2.href };
|
|
43
|
+
params.delete(TOOLBAR_FRAGMENT_KEY);
|
|
44
|
+
const rest = params.toString();
|
|
45
|
+
const base = location2.href.split("#")[0];
|
|
46
|
+
return { token, cleanedHref: rest ? `${base}#${rest}` : base };
|
|
47
|
+
}
|
|
48
|
+
function describeElement(el) {
|
|
49
|
+
const parts = [];
|
|
50
|
+
let current = el;
|
|
51
|
+
let depth = 0;
|
|
52
|
+
while (current && depth < 5) {
|
|
53
|
+
let part = current.tagName.toLowerCase();
|
|
54
|
+
if (current.id) {
|
|
55
|
+
parts.unshift(`${part}#${current.id}`);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
const classes = Array.from(current.classList).filter((c) => !["bd-mask", "bd-no-capture", "ph-no-capture", "ph-sensitive"].includes(c)).slice(0, 2);
|
|
59
|
+
if (classes.length > 0) part += `.${classes.join(".")}`;
|
|
60
|
+
parts.unshift(part);
|
|
61
|
+
current = current.parentElement;
|
|
62
|
+
depth++;
|
|
63
|
+
}
|
|
64
|
+
return parts.join(" > ");
|
|
65
|
+
}
|
|
66
|
+
function binToPageCoords(bin, viewportWidth) {
|
|
67
|
+
return {
|
|
68
|
+
x: Math.round(bin.relative_x * viewportWidth),
|
|
69
|
+
y: Math.round(bin.page_y)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
var TOOLBAR_MASK_CLASSES = ["bd-mask", "ph-sensitive"];
|
|
73
|
+
var VALUE_TAGS = ["INPUT", "TEXTAREA", "SELECT"];
|
|
74
|
+
var MAX_PROPOSED_TEXT = 120;
|
|
75
|
+
function isMaskedForToolbar(el) {
|
|
76
|
+
let node = el;
|
|
77
|
+
while (node) {
|
|
78
|
+
if (node.classList && TOOLBAR_MASK_CLASSES.some((c) => node.classList.contains(c))) return true;
|
|
79
|
+
node = node.parentElement;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
function proposeActionStep(el, pathname) {
|
|
84
|
+
var _a, _b;
|
|
85
|
+
const step = {
|
|
86
|
+
// Autocapture stamps this on clicks. Without it the rule would also match
|
|
87
|
+
// `change` and `submit` on the same element.
|
|
88
|
+
event_type: "click",
|
|
89
|
+
tag_name: el.tagName ? el.tagName.toLowerCase() : ""
|
|
90
|
+
};
|
|
91
|
+
const id = (_a = el.getAttribute) == null ? void 0 : _a.call(el, "id");
|
|
92
|
+
const href = (_b = el.getAttribute) == null ? void 0 : _b.call(el, "href");
|
|
93
|
+
if (id) {
|
|
94
|
+
step.attr_id = id;
|
|
95
|
+
} else if (href) {
|
|
96
|
+
step.href = href;
|
|
97
|
+
} else if (!VALUE_TAGS.includes(el.tagName) && !isMaskedForToolbar(el)) {
|
|
98
|
+
const text = (el.textContent ?? "").trim().slice(0, 255);
|
|
99
|
+
if (text && text.length <= MAX_PROPOSED_TEXT) {
|
|
100
|
+
step.text = text;
|
|
101
|
+
step.text_match = "exact";
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (pathname) {
|
|
105
|
+
step.url = pathname;
|
|
106
|
+
step.url_match = "exact";
|
|
107
|
+
}
|
|
108
|
+
return step;
|
|
109
|
+
}
|
|
110
|
+
function isSpecificStep(step) {
|
|
111
|
+
return Boolean(step.attr_id || step.href || step.text);
|
|
112
|
+
}
|
|
113
|
+
function intensityColor(intensity) {
|
|
114
|
+
const t = Number.isFinite(intensity) ? Math.max(0, Math.min(1, intensity)) : 0;
|
|
115
|
+
const hue = Math.round(240 - t * 240);
|
|
116
|
+
const alpha = (0.25 + t * 0.45).toFixed(2);
|
|
117
|
+
return `hsla(${hue}, 90%, 50%, ${alpha})`;
|
|
118
|
+
}
|
|
119
|
+
function setFlagOverride(key, value) {
|
|
120
|
+
try {
|
|
121
|
+
const raw = localStorage.getItem(FLAG_OVERRIDE_KEY);
|
|
122
|
+
const overrides = raw ? JSON.parse(raw) : {};
|
|
123
|
+
if (value === null) delete overrides[key];
|
|
124
|
+
else overrides[key] = value;
|
|
125
|
+
localStorage.setItem(FLAG_OVERRIDE_KEY, JSON.stringify(overrides));
|
|
126
|
+
} catch {
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function getFlagOverrides() {
|
|
130
|
+
try {
|
|
131
|
+
const raw = localStorage.getItem(FLAG_OVERRIDE_KEY);
|
|
132
|
+
return raw ? JSON.parse(raw) : {};
|
|
133
|
+
} catch {
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function startToolbar(options) {
|
|
138
|
+
if (typeof window === "undefined" || typeof document === "undefined") return null;
|
|
139
|
+
const { token, cleanedHref } = options.token ? { token: options.token, cleanedHref: window.location.href } : consumeLaunchToken(window.location);
|
|
140
|
+
if (!token) return null;
|
|
141
|
+
if (cleanedHref !== window.location.href) {
|
|
142
|
+
window.history.replaceState(null, "", cleanedHref);
|
|
143
|
+
}
|
|
144
|
+
let session;
|
|
145
|
+
try {
|
|
146
|
+
const res = await fetch(`${options.apiHost.replace(/\/$/, "")}/toolbar/session`, {
|
|
147
|
+
method: "POST",
|
|
148
|
+
headers: { "Content-Type": "application/json" },
|
|
149
|
+
body: JSON.stringify({ token, url: window.location.href })
|
|
150
|
+
});
|
|
151
|
+
if (!res.ok) return null;
|
|
152
|
+
const json = await res.json();
|
|
153
|
+
session = (json == null ? void 0 : json.data) ?? json;
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
return render(session, options.apiHost, token);
|
|
158
|
+
}
|
|
159
|
+
function render(session, apiHost, token) {
|
|
160
|
+
const host = document.createElement("div");
|
|
161
|
+
host.setAttribute("data-billdog-toolbar", "");
|
|
162
|
+
host.style.cssText = "position:fixed;z-index:2147483647;bottom:16px;right:16px;";
|
|
163
|
+
const shadow = host.attachShadow({ mode: "closed" });
|
|
164
|
+
const style = document.createElement("style");
|
|
165
|
+
style.textContent = `
|
|
166
|
+
:host { all: initial; }
|
|
167
|
+
.panel {
|
|
168
|
+
font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
169
|
+
background: #12161C; color: #E7ECF1;
|
|
170
|
+
border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,.4);
|
|
171
|
+
width: 300px; overflow: hidden;
|
|
172
|
+
}
|
|
173
|
+
.head { display:flex; align-items:center; gap:8px; padding:10px 12px; background:#1D242D; }
|
|
174
|
+
.head b { font-weight:600; }
|
|
175
|
+
.body { padding: 10px 12px; max-height: 320px; overflow-y: auto; }
|
|
176
|
+
.row { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:4px 0; }
|
|
177
|
+
button { font: inherit; background:#2F6F5E; color:#fff; border:0; border-radius:5px;
|
|
178
|
+
padding:4px 9px; cursor:pointer; }
|
|
179
|
+
button.ghost { background:#2A323C; }
|
|
180
|
+
.muted { color:#8A96A3; font-size:12px; }
|
|
181
|
+
.sel { font-family: ui-monospace, monospace; font-size:11px; word-break:break-all; color:#74C3A8; }
|
|
182
|
+
`;
|
|
183
|
+
const panel = document.createElement("div");
|
|
184
|
+
panel.className = "panel";
|
|
185
|
+
panel.innerHTML = `
|
|
186
|
+
<div class="head"><b>BillDog</b><span class="muted">toolbar</span></div>
|
|
187
|
+
<div class="body">
|
|
188
|
+
<div class="row">
|
|
189
|
+
<span>Inspect elements</span>
|
|
190
|
+
<button id="bd-inspect" class="ghost">Start</button>
|
|
191
|
+
</div>
|
|
192
|
+
<div class="row">
|
|
193
|
+
<span>Click heatmap</span>
|
|
194
|
+
<button id="bd-heatmap" class="ghost">Show</button>
|
|
195
|
+
</div>
|
|
196
|
+
<div class="row"><span class="muted" id="bd-heatmap-status"></span></div>
|
|
197
|
+
<div class="row"><span class="sel" id="bd-selected"></span></div>
|
|
198
|
+
<div id="bd-action" style="display:none">
|
|
199
|
+
<div class="row"><span class="muted" id="bd-action-rule"></span></div>
|
|
200
|
+
<div class="row">
|
|
201
|
+
<input id="bd-action-name" placeholder="Name this action" maxlength="120" />
|
|
202
|
+
</div>
|
|
203
|
+
<div class="row">
|
|
204
|
+
<label class="muted"><input type="checkbox" id="bd-action-scope" /> only on this page</label>
|
|
205
|
+
<button id="bd-action-save" class="ghost">Create action</button>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="row"><span class="muted" id="bd-action-status"></span></div>
|
|
208
|
+
</div>
|
|
209
|
+
<div class="row muted">Feature flags</div>
|
|
210
|
+
<div id="bd-flags"></div>
|
|
211
|
+
</div>
|
|
212
|
+
`;
|
|
213
|
+
shadow.append(style, panel);
|
|
214
|
+
document.body.appendChild(host);
|
|
215
|
+
const overrides = getFlagOverrides();
|
|
216
|
+
const flagList = panel.querySelector("#bd-flags");
|
|
217
|
+
for (const flag of session.feature_flags.slice(0, 50)) {
|
|
218
|
+
const row = document.createElement("div");
|
|
219
|
+
row.className = "row";
|
|
220
|
+
const overridden = flag.key in overrides;
|
|
221
|
+
row.innerHTML = `<span>${escapeHtml(flag.key)}</span>`;
|
|
222
|
+
const btn = document.createElement("button");
|
|
223
|
+
btn.className = overridden ? "" : "ghost";
|
|
224
|
+
btn.textContent = overridden ? String(overrides[flag.key]) : flag.enabled ? "on" : "off";
|
|
225
|
+
btn.onclick = () => {
|
|
226
|
+
const current = getFlagOverrides()[flag.key];
|
|
227
|
+
const nextValue = current === void 0 ? true : current === true ? false : null;
|
|
228
|
+
setFlagOverride(flag.key, nextValue);
|
|
229
|
+
btn.textContent = nextValue === null ? flag.enabled ? "on" : "off" : String(nextValue);
|
|
230
|
+
btn.className = nextValue === null ? "ghost" : "";
|
|
231
|
+
};
|
|
232
|
+
row.appendChild(btn);
|
|
233
|
+
flagList.appendChild(row);
|
|
234
|
+
}
|
|
235
|
+
const selected = panel.querySelector("#bd-selected");
|
|
236
|
+
const inspectBtn = panel.querySelector("#bd-inspect");
|
|
237
|
+
let inspecting = false;
|
|
238
|
+
let lastOutlined = null;
|
|
239
|
+
const onOver = (e) => {
|
|
240
|
+
const target = e.target;
|
|
241
|
+
if (!target || host.contains(target)) return;
|
|
242
|
+
if (lastOutlined) lastOutlined.style.outline = "";
|
|
243
|
+
target.style.outline = "2px solid #74C3A8";
|
|
244
|
+
lastOutlined = target;
|
|
245
|
+
};
|
|
246
|
+
const actionBox = panel.querySelector("#bd-action");
|
|
247
|
+
const actionRule = panel.querySelector("#bd-action-rule");
|
|
248
|
+
const actionName = panel.querySelector("#bd-action-name");
|
|
249
|
+
const actionScope = panel.querySelector("#bd-action-scope");
|
|
250
|
+
const actionSave = panel.querySelector("#bd-action-save");
|
|
251
|
+
const actionStatus = panel.querySelector("#bd-action-status");
|
|
252
|
+
let pending = null;
|
|
253
|
+
const describeRule = (step) => {
|
|
254
|
+
if (step.attr_id) return `${step.tag_name}#${step.attr_id}`;
|
|
255
|
+
if (step.href) return `${step.tag_name}[href="${step.href}"]`;
|
|
256
|
+
if (step.text) return `${step.tag_name} labelled "${step.text}"`;
|
|
257
|
+
return `every <${step.tag_name}> on the site`;
|
|
258
|
+
};
|
|
259
|
+
const onClick = (e) => {
|
|
260
|
+
const target = e.target;
|
|
261
|
+
if (!target || host.contains(target)) return;
|
|
262
|
+
e.preventDefault();
|
|
263
|
+
e.stopPropagation();
|
|
264
|
+
selected.textContent = describeElement(target);
|
|
265
|
+
pending = proposeActionStep(target);
|
|
266
|
+
actionRule.textContent = isSpecificStep(pending) ? `Matches ${describeRule(pending)}` : `This would match ${describeRule(pending)} \u2014 pick something more specific.`;
|
|
267
|
+
actionStatus.textContent = "";
|
|
268
|
+
actionBox.style.display = "";
|
|
269
|
+
};
|
|
270
|
+
actionSave.onclick = async () => {
|
|
271
|
+
var _a;
|
|
272
|
+
if (!pending) return;
|
|
273
|
+
const name = actionName.value.trim();
|
|
274
|
+
if (!name) {
|
|
275
|
+
actionStatus.textContent = "Give it a name first.";
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const step = actionScope.checked ? { ...pending, url: location.pathname, url_match: "exact" } : pending;
|
|
279
|
+
actionSave.disabled = true;
|
|
280
|
+
actionStatus.textContent = "Saving...";
|
|
281
|
+
try {
|
|
282
|
+
const res = await fetch(`${apiHost.replace(/\/$/, "")}/toolbar/action`, {
|
|
283
|
+
method: "POST",
|
|
284
|
+
headers: { "Content-Type": "application/json" },
|
|
285
|
+
body: JSON.stringify({
|
|
286
|
+
token,
|
|
287
|
+
url: location.href,
|
|
288
|
+
name,
|
|
289
|
+
steps: [step]
|
|
290
|
+
})
|
|
291
|
+
});
|
|
292
|
+
const json = await res.json().catch(() => null);
|
|
293
|
+
if (!res.ok) {
|
|
294
|
+
actionStatus.textContent = ((_a = json == null ? void 0 : json.error) == null ? void 0 : _a.message) ?? "Could not save the action.";
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
actionStatus.textContent = `Created "${name}" \u2014 usable in funnels and trends, over history.`;
|
|
298
|
+
actionName.value = "";
|
|
299
|
+
pending = null;
|
|
300
|
+
actionBox.style.display = "none";
|
|
301
|
+
} catch {
|
|
302
|
+
actionStatus.textContent = "Could not reach BillDog.";
|
|
303
|
+
} finally {
|
|
304
|
+
actionSave.disabled = false;
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
const stopInspecting = () => {
|
|
308
|
+
inspecting = false;
|
|
309
|
+
inspectBtn.textContent = "Start";
|
|
310
|
+
document.removeEventListener("mouseover", onOver, true);
|
|
311
|
+
document.removeEventListener("click", onClick, true);
|
|
312
|
+
if (lastOutlined) {
|
|
313
|
+
lastOutlined.style.outline = "";
|
|
314
|
+
lastOutlined = null;
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
inspectBtn.onclick = () => {
|
|
318
|
+
if (inspecting) return stopInspecting();
|
|
319
|
+
inspecting = true;
|
|
320
|
+
inspectBtn.textContent = "Stop";
|
|
321
|
+
document.addEventListener("mouseover", onOver, true);
|
|
322
|
+
document.addEventListener("click", onClick, true);
|
|
323
|
+
};
|
|
324
|
+
const heatmapBtn = panel.querySelector("#bd-heatmap");
|
|
325
|
+
const heatmapStatus = panel.querySelector("#bd-heatmap-status");
|
|
326
|
+
let heatmapLayer = null;
|
|
327
|
+
let heatmapBins = null;
|
|
328
|
+
const paintHeatmap = (bins) => {
|
|
329
|
+
heatmapLayer == null ? void 0 : heatmapLayer.remove();
|
|
330
|
+
const layer = document.createElement("div");
|
|
331
|
+
layer.setAttribute("data-billdog-heatmap", "");
|
|
332
|
+
layer.style.cssText = [
|
|
333
|
+
"position:absolute",
|
|
334
|
+
"top:0",
|
|
335
|
+
"left:0",
|
|
336
|
+
"width:100%",
|
|
337
|
+
`height:${document.documentElement.scrollHeight}px`,
|
|
338
|
+
"z-index:2147483646",
|
|
339
|
+
// Clicks pass straight through: an overlay that swallowed them would stop
|
|
340
|
+
// the person exploring the page they came to look at.
|
|
341
|
+
"pointer-events:none"
|
|
342
|
+
].join(";");
|
|
343
|
+
const width = document.documentElement.clientWidth;
|
|
344
|
+
for (const bin of bins) {
|
|
345
|
+
const { x, y } = binToPageCoords(bin, width);
|
|
346
|
+
const dot = document.createElement("div");
|
|
347
|
+
const size = 24 + Math.round(bin.intensity * 36);
|
|
348
|
+
dot.style.cssText = [
|
|
349
|
+
"position:absolute",
|
|
350
|
+
`left:${x - size / 2}px`,
|
|
351
|
+
`top:${y - size / 2}px`,
|
|
352
|
+
`width:${size}px`,
|
|
353
|
+
`height:${size}px`,
|
|
354
|
+
"border-radius:50%",
|
|
355
|
+
`background:radial-gradient(circle, ${intensityColor(bin.intensity)} 0%, transparent 70%)`
|
|
356
|
+
].join(";");
|
|
357
|
+
dot.title = `${bin.clicks} clicks \xB7 ${bin.users} users`;
|
|
358
|
+
layer.appendChild(dot);
|
|
359
|
+
}
|
|
360
|
+
document.body.appendChild(layer);
|
|
361
|
+
heatmapLayer = layer;
|
|
362
|
+
};
|
|
363
|
+
const hideHeatmap = () => {
|
|
364
|
+
heatmapLayer == null ? void 0 : heatmapLayer.remove();
|
|
365
|
+
heatmapLayer = null;
|
|
366
|
+
heatmapBtn.textContent = "Show";
|
|
367
|
+
heatmapBtn.className = "ghost";
|
|
368
|
+
};
|
|
369
|
+
heatmapBtn.onclick = async () => {
|
|
370
|
+
if (heatmapLayer) return hideHeatmap();
|
|
371
|
+
if (!heatmapBins) {
|
|
372
|
+
heatmapStatus.textContent = "Loading\u2026";
|
|
373
|
+
try {
|
|
374
|
+
const res = await fetch(`${apiHost.replace(/\/$/, "")}/toolbar/heatmap`, {
|
|
375
|
+
method: "POST",
|
|
376
|
+
headers: { "Content-Type": "application/json" },
|
|
377
|
+
body: JSON.stringify({ token, url: window.location.href })
|
|
378
|
+
});
|
|
379
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
380
|
+
const json = await res.json();
|
|
381
|
+
const payload = (json == null ? void 0 : json.data) ?? json;
|
|
382
|
+
heatmapBins = payload.bins ?? [];
|
|
383
|
+
} catch {
|
|
384
|
+
heatmapStatus.textContent = "Could not load heatmap data.";
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
if (heatmapBins.length === 0) {
|
|
389
|
+
heatmapStatus.textContent = "No clicks recorded for this page. Heatmap capture is opt-in in project settings.";
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
heatmapStatus.textContent = `${heatmapBins.length} hotspots`;
|
|
393
|
+
paintHeatmap(heatmapBins);
|
|
394
|
+
heatmapBtn.textContent = "Hide";
|
|
395
|
+
heatmapBtn.className = "";
|
|
396
|
+
};
|
|
397
|
+
const onResize = () => {
|
|
398
|
+
if (heatmapLayer && heatmapBins) paintHeatmap(heatmapBins);
|
|
399
|
+
};
|
|
400
|
+
window.addEventListener("resize", onResize);
|
|
401
|
+
return {
|
|
402
|
+
destroy() {
|
|
403
|
+
stopInspecting();
|
|
404
|
+
hideHeatmap();
|
|
405
|
+
window.removeEventListener("resize", onResize);
|
|
406
|
+
host.remove();
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function escapeHtml(s) {
|
|
411
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
412
|
+
}
|
|
413
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
414
|
+
0 && (module.exports = {
|
|
415
|
+
FLAG_OVERRIDE_KEY,
|
|
416
|
+
TOOLBAR_FRAGMENT_KEY,
|
|
417
|
+
binToPageCoords,
|
|
418
|
+
consumeLaunchToken,
|
|
419
|
+
describeElement,
|
|
420
|
+
getFlagOverrides,
|
|
421
|
+
intensityColor,
|
|
422
|
+
isSpecificStep,
|
|
423
|
+
proposeActionStep,
|
|
424
|
+
setFlagOverride,
|
|
425
|
+
startToolbar
|
|
426
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
var TOOLBAR_FRAGMENT_KEY = "__billdog_toolbar__";
|
|
3
|
+
var FLAG_OVERRIDE_KEY = "billdog_flag_overrides";
|
|
4
|
+
function consumeLaunchToken(location2) {
|
|
5
|
+
const hash = location2.hash.startsWith("#") ? location2.hash.slice(1) : location2.hash;
|
|
6
|
+
if (!hash) return { token: null, cleanedHref: location2.href };
|
|
7
|
+
const params = new URLSearchParams(hash);
|
|
8
|
+
const token = params.get(TOOLBAR_FRAGMENT_KEY);
|
|
9
|
+
if (!token) return { token: null, cleanedHref: location2.href };
|
|
10
|
+
params.delete(TOOLBAR_FRAGMENT_KEY);
|
|
11
|
+
const rest = params.toString();
|
|
12
|
+
const base = location2.href.split("#")[0];
|
|
13
|
+
return { token, cleanedHref: rest ? `${base}#${rest}` : base };
|
|
14
|
+
}
|
|
15
|
+
function describeElement(el) {
|
|
16
|
+
const parts = [];
|
|
17
|
+
let current = el;
|
|
18
|
+
let depth = 0;
|
|
19
|
+
while (current && depth < 5) {
|
|
20
|
+
let part = current.tagName.toLowerCase();
|
|
21
|
+
if (current.id) {
|
|
22
|
+
parts.unshift(`${part}#${current.id}`);
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
const classes = Array.from(current.classList).filter((c) => !["bd-mask", "bd-no-capture", "ph-no-capture", "ph-sensitive"].includes(c)).slice(0, 2);
|
|
26
|
+
if (classes.length > 0) part += `.${classes.join(".")}`;
|
|
27
|
+
parts.unshift(part);
|
|
28
|
+
current = current.parentElement;
|
|
29
|
+
depth++;
|
|
30
|
+
}
|
|
31
|
+
return parts.join(" > ");
|
|
32
|
+
}
|
|
33
|
+
function binToPageCoords(bin, viewportWidth) {
|
|
34
|
+
return {
|
|
35
|
+
x: Math.round(bin.relative_x * viewportWidth),
|
|
36
|
+
y: Math.round(bin.page_y)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
var TOOLBAR_MASK_CLASSES = ["bd-mask", "ph-sensitive"];
|
|
40
|
+
var VALUE_TAGS = ["INPUT", "TEXTAREA", "SELECT"];
|
|
41
|
+
var MAX_PROPOSED_TEXT = 120;
|
|
42
|
+
function isMaskedForToolbar(el) {
|
|
43
|
+
let node = el;
|
|
44
|
+
while (node) {
|
|
45
|
+
if (node.classList && TOOLBAR_MASK_CLASSES.some((c) => node.classList.contains(c))) return true;
|
|
46
|
+
node = node.parentElement;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
function proposeActionStep(el, pathname) {
|
|
51
|
+
var _a, _b;
|
|
52
|
+
const step = {
|
|
53
|
+
// Autocapture stamps this on clicks. Without it the rule would also match
|
|
54
|
+
// `change` and `submit` on the same element.
|
|
55
|
+
event_type: "click",
|
|
56
|
+
tag_name: el.tagName ? el.tagName.toLowerCase() : ""
|
|
57
|
+
};
|
|
58
|
+
const id = (_a = el.getAttribute) == null ? void 0 : _a.call(el, "id");
|
|
59
|
+
const href = (_b = el.getAttribute) == null ? void 0 : _b.call(el, "href");
|
|
60
|
+
if (id) {
|
|
61
|
+
step.attr_id = id;
|
|
62
|
+
} else if (href) {
|
|
63
|
+
step.href = href;
|
|
64
|
+
} else if (!VALUE_TAGS.includes(el.tagName) && !isMaskedForToolbar(el)) {
|
|
65
|
+
const text = (el.textContent ?? "").trim().slice(0, 255);
|
|
66
|
+
if (text && text.length <= MAX_PROPOSED_TEXT) {
|
|
67
|
+
step.text = text;
|
|
68
|
+
step.text_match = "exact";
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (pathname) {
|
|
72
|
+
step.url = pathname;
|
|
73
|
+
step.url_match = "exact";
|
|
74
|
+
}
|
|
75
|
+
return step;
|
|
76
|
+
}
|
|
77
|
+
function isSpecificStep(step) {
|
|
78
|
+
return Boolean(step.attr_id || step.href || step.text);
|
|
79
|
+
}
|
|
80
|
+
function intensityColor(intensity) {
|
|
81
|
+
const t = Number.isFinite(intensity) ? Math.max(0, Math.min(1, intensity)) : 0;
|
|
82
|
+
const hue = Math.round(240 - t * 240);
|
|
83
|
+
const alpha = (0.25 + t * 0.45).toFixed(2);
|
|
84
|
+
return `hsla(${hue}, 90%, 50%, ${alpha})`;
|
|
85
|
+
}
|
|
86
|
+
function setFlagOverride(key, value) {
|
|
87
|
+
try {
|
|
88
|
+
const raw = localStorage.getItem(FLAG_OVERRIDE_KEY);
|
|
89
|
+
const overrides = raw ? JSON.parse(raw) : {};
|
|
90
|
+
if (value === null) delete overrides[key];
|
|
91
|
+
else overrides[key] = value;
|
|
92
|
+
localStorage.setItem(FLAG_OVERRIDE_KEY, JSON.stringify(overrides));
|
|
93
|
+
} catch {
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function getFlagOverrides() {
|
|
97
|
+
try {
|
|
98
|
+
const raw = localStorage.getItem(FLAG_OVERRIDE_KEY);
|
|
99
|
+
return raw ? JSON.parse(raw) : {};
|
|
100
|
+
} catch {
|
|
101
|
+
return {};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function startToolbar(options) {
|
|
105
|
+
if (typeof window === "undefined" || typeof document === "undefined") return null;
|
|
106
|
+
const { token, cleanedHref } = options.token ? { token: options.token, cleanedHref: window.location.href } : consumeLaunchToken(window.location);
|
|
107
|
+
if (!token) return null;
|
|
108
|
+
if (cleanedHref !== window.location.href) {
|
|
109
|
+
window.history.replaceState(null, "", cleanedHref);
|
|
110
|
+
}
|
|
111
|
+
let session;
|
|
112
|
+
try {
|
|
113
|
+
const res = await fetch(`${options.apiHost.replace(/\/$/, "")}/toolbar/session`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json" },
|
|
116
|
+
body: JSON.stringify({ token, url: window.location.href })
|
|
117
|
+
});
|
|
118
|
+
if (!res.ok) return null;
|
|
119
|
+
const json = await res.json();
|
|
120
|
+
session = (json == null ? void 0 : json.data) ?? json;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return render(session, options.apiHost, token);
|
|
125
|
+
}
|
|
126
|
+
function render(session, apiHost, token) {
|
|
127
|
+
const host = document.createElement("div");
|
|
128
|
+
host.setAttribute("data-billdog-toolbar", "");
|
|
129
|
+
host.style.cssText = "position:fixed;z-index:2147483647;bottom:16px;right:16px;";
|
|
130
|
+
const shadow = host.attachShadow({ mode: "closed" });
|
|
131
|
+
const style = document.createElement("style");
|
|
132
|
+
style.textContent = `
|
|
133
|
+
:host { all: initial; }
|
|
134
|
+
.panel {
|
|
135
|
+
font: 13px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
136
|
+
background: #12161C; color: #E7ECF1;
|
|
137
|
+
border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,.4);
|
|
138
|
+
width: 300px; overflow: hidden;
|
|
139
|
+
}
|
|
140
|
+
.head { display:flex; align-items:center; gap:8px; padding:10px 12px; background:#1D242D; }
|
|
141
|
+
.head b { font-weight:600; }
|
|
142
|
+
.body { padding: 10px 12px; max-height: 320px; overflow-y: auto; }
|
|
143
|
+
.row { display:flex; align-items:center; justify-content:space-between; gap:8px; padding:4px 0; }
|
|
144
|
+
button { font: inherit; background:#2F6F5E; color:#fff; border:0; border-radius:5px;
|
|
145
|
+
padding:4px 9px; cursor:pointer; }
|
|
146
|
+
button.ghost { background:#2A323C; }
|
|
147
|
+
.muted { color:#8A96A3; font-size:12px; }
|
|
148
|
+
.sel { font-family: ui-monospace, monospace; font-size:11px; word-break:break-all; color:#74C3A8; }
|
|
149
|
+
`;
|
|
150
|
+
const panel = document.createElement("div");
|
|
151
|
+
panel.className = "panel";
|
|
152
|
+
panel.innerHTML = `
|
|
153
|
+
<div class="head"><b>BillDog</b><span class="muted">toolbar</span></div>
|
|
154
|
+
<div class="body">
|
|
155
|
+
<div class="row">
|
|
156
|
+
<span>Inspect elements</span>
|
|
157
|
+
<button id="bd-inspect" class="ghost">Start</button>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="row">
|
|
160
|
+
<span>Click heatmap</span>
|
|
161
|
+
<button id="bd-heatmap" class="ghost">Show</button>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="row"><span class="muted" id="bd-heatmap-status"></span></div>
|
|
164
|
+
<div class="row"><span class="sel" id="bd-selected"></span></div>
|
|
165
|
+
<div id="bd-action" style="display:none">
|
|
166
|
+
<div class="row"><span class="muted" id="bd-action-rule"></span></div>
|
|
167
|
+
<div class="row">
|
|
168
|
+
<input id="bd-action-name" placeholder="Name this action" maxlength="120" />
|
|
169
|
+
</div>
|
|
170
|
+
<div class="row">
|
|
171
|
+
<label class="muted"><input type="checkbox" id="bd-action-scope" /> only on this page</label>
|
|
172
|
+
<button id="bd-action-save" class="ghost">Create action</button>
|
|
173
|
+
</div>
|
|
174
|
+
<div class="row"><span class="muted" id="bd-action-status"></span></div>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="row muted">Feature flags</div>
|
|
177
|
+
<div id="bd-flags"></div>
|
|
178
|
+
</div>
|
|
179
|
+
`;
|
|
180
|
+
shadow.append(style, panel);
|
|
181
|
+
document.body.appendChild(host);
|
|
182
|
+
const overrides = getFlagOverrides();
|
|
183
|
+
const flagList = panel.querySelector("#bd-flags");
|
|
184
|
+
for (const flag of session.feature_flags.slice(0, 50)) {
|
|
185
|
+
const row = document.createElement("div");
|
|
186
|
+
row.className = "row";
|
|
187
|
+
const overridden = flag.key in overrides;
|
|
188
|
+
row.innerHTML = `<span>${escapeHtml(flag.key)}</span>`;
|
|
189
|
+
const btn = document.createElement("button");
|
|
190
|
+
btn.className = overridden ? "" : "ghost";
|
|
191
|
+
btn.textContent = overridden ? String(overrides[flag.key]) : flag.enabled ? "on" : "off";
|
|
192
|
+
btn.onclick = () => {
|
|
193
|
+
const current = getFlagOverrides()[flag.key];
|
|
194
|
+
const nextValue = current === void 0 ? true : current === true ? false : null;
|
|
195
|
+
setFlagOverride(flag.key, nextValue);
|
|
196
|
+
btn.textContent = nextValue === null ? flag.enabled ? "on" : "off" : String(nextValue);
|
|
197
|
+
btn.className = nextValue === null ? "ghost" : "";
|
|
198
|
+
};
|
|
199
|
+
row.appendChild(btn);
|
|
200
|
+
flagList.appendChild(row);
|
|
201
|
+
}
|
|
202
|
+
const selected = panel.querySelector("#bd-selected");
|
|
203
|
+
const inspectBtn = panel.querySelector("#bd-inspect");
|
|
204
|
+
let inspecting = false;
|
|
205
|
+
let lastOutlined = null;
|
|
206
|
+
const onOver = (e) => {
|
|
207
|
+
const target = e.target;
|
|
208
|
+
if (!target || host.contains(target)) return;
|
|
209
|
+
if (lastOutlined) lastOutlined.style.outline = "";
|
|
210
|
+
target.style.outline = "2px solid #74C3A8";
|
|
211
|
+
lastOutlined = target;
|
|
212
|
+
};
|
|
213
|
+
const actionBox = panel.querySelector("#bd-action");
|
|
214
|
+
const actionRule = panel.querySelector("#bd-action-rule");
|
|
215
|
+
const actionName = panel.querySelector("#bd-action-name");
|
|
216
|
+
const actionScope = panel.querySelector("#bd-action-scope");
|
|
217
|
+
const actionSave = panel.querySelector("#bd-action-save");
|
|
218
|
+
const actionStatus = panel.querySelector("#bd-action-status");
|
|
219
|
+
let pending = null;
|
|
220
|
+
const describeRule = (step) => {
|
|
221
|
+
if (step.attr_id) return `${step.tag_name}#${step.attr_id}`;
|
|
222
|
+
if (step.href) return `${step.tag_name}[href="${step.href}"]`;
|
|
223
|
+
if (step.text) return `${step.tag_name} labelled "${step.text}"`;
|
|
224
|
+
return `every <${step.tag_name}> on the site`;
|
|
225
|
+
};
|
|
226
|
+
const onClick = (e) => {
|
|
227
|
+
const target = e.target;
|
|
228
|
+
if (!target || host.contains(target)) return;
|
|
229
|
+
e.preventDefault();
|
|
230
|
+
e.stopPropagation();
|
|
231
|
+
selected.textContent = describeElement(target);
|
|
232
|
+
pending = proposeActionStep(target);
|
|
233
|
+
actionRule.textContent = isSpecificStep(pending) ? `Matches ${describeRule(pending)}` : `This would match ${describeRule(pending)} \u2014 pick something more specific.`;
|
|
234
|
+
actionStatus.textContent = "";
|
|
235
|
+
actionBox.style.display = "";
|
|
236
|
+
};
|
|
237
|
+
actionSave.onclick = async () => {
|
|
238
|
+
var _a;
|
|
239
|
+
if (!pending) return;
|
|
240
|
+
const name = actionName.value.trim();
|
|
241
|
+
if (!name) {
|
|
242
|
+
actionStatus.textContent = "Give it a name first.";
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const step = actionScope.checked ? { ...pending, url: location.pathname, url_match: "exact" } : pending;
|
|
246
|
+
actionSave.disabled = true;
|
|
247
|
+
actionStatus.textContent = "Saving...";
|
|
248
|
+
try {
|
|
249
|
+
const res = await fetch(`${apiHost.replace(/\/$/, "")}/toolbar/action`, {
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { "Content-Type": "application/json" },
|
|
252
|
+
body: JSON.stringify({
|
|
253
|
+
token,
|
|
254
|
+
url: location.href,
|
|
255
|
+
name,
|
|
256
|
+
steps: [step]
|
|
257
|
+
})
|
|
258
|
+
});
|
|
259
|
+
const json = await res.json().catch(() => null);
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
actionStatus.textContent = ((_a = json == null ? void 0 : json.error) == null ? void 0 : _a.message) ?? "Could not save the action.";
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
actionStatus.textContent = `Created "${name}" \u2014 usable in funnels and trends, over history.`;
|
|
265
|
+
actionName.value = "";
|
|
266
|
+
pending = null;
|
|
267
|
+
actionBox.style.display = "none";
|
|
268
|
+
} catch {
|
|
269
|
+
actionStatus.textContent = "Could not reach BillDog.";
|
|
270
|
+
} finally {
|
|
271
|
+
actionSave.disabled = false;
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
const stopInspecting = () => {
|
|
275
|
+
inspecting = false;
|
|
276
|
+
inspectBtn.textContent = "Start";
|
|
277
|
+
document.removeEventListener("mouseover", onOver, true);
|
|
278
|
+
document.removeEventListener("click", onClick, true);
|
|
279
|
+
if (lastOutlined) {
|
|
280
|
+
lastOutlined.style.outline = "";
|
|
281
|
+
lastOutlined = null;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
inspectBtn.onclick = () => {
|
|
285
|
+
if (inspecting) return stopInspecting();
|
|
286
|
+
inspecting = true;
|
|
287
|
+
inspectBtn.textContent = "Stop";
|
|
288
|
+
document.addEventListener("mouseover", onOver, true);
|
|
289
|
+
document.addEventListener("click", onClick, true);
|
|
290
|
+
};
|
|
291
|
+
const heatmapBtn = panel.querySelector("#bd-heatmap");
|
|
292
|
+
const heatmapStatus = panel.querySelector("#bd-heatmap-status");
|
|
293
|
+
let heatmapLayer = null;
|
|
294
|
+
let heatmapBins = null;
|
|
295
|
+
const paintHeatmap = (bins) => {
|
|
296
|
+
heatmapLayer == null ? void 0 : heatmapLayer.remove();
|
|
297
|
+
const layer = document.createElement("div");
|
|
298
|
+
layer.setAttribute("data-billdog-heatmap", "");
|
|
299
|
+
layer.style.cssText = [
|
|
300
|
+
"position:absolute",
|
|
301
|
+
"top:0",
|
|
302
|
+
"left:0",
|
|
303
|
+
"width:100%",
|
|
304
|
+
`height:${document.documentElement.scrollHeight}px`,
|
|
305
|
+
"z-index:2147483646",
|
|
306
|
+
// Clicks pass straight through: an overlay that swallowed them would stop
|
|
307
|
+
// the person exploring the page they came to look at.
|
|
308
|
+
"pointer-events:none"
|
|
309
|
+
].join(";");
|
|
310
|
+
const width = document.documentElement.clientWidth;
|
|
311
|
+
for (const bin of bins) {
|
|
312
|
+
const { x, y } = binToPageCoords(bin, width);
|
|
313
|
+
const dot = document.createElement("div");
|
|
314
|
+
const size = 24 + Math.round(bin.intensity * 36);
|
|
315
|
+
dot.style.cssText = [
|
|
316
|
+
"position:absolute",
|
|
317
|
+
`left:${x - size / 2}px`,
|
|
318
|
+
`top:${y - size / 2}px`,
|
|
319
|
+
`width:${size}px`,
|
|
320
|
+
`height:${size}px`,
|
|
321
|
+
"border-radius:50%",
|
|
322
|
+
`background:radial-gradient(circle, ${intensityColor(bin.intensity)} 0%, transparent 70%)`
|
|
323
|
+
].join(";");
|
|
324
|
+
dot.title = `${bin.clicks} clicks \xB7 ${bin.users} users`;
|
|
325
|
+
layer.appendChild(dot);
|
|
326
|
+
}
|
|
327
|
+
document.body.appendChild(layer);
|
|
328
|
+
heatmapLayer = layer;
|
|
329
|
+
};
|
|
330
|
+
const hideHeatmap = () => {
|
|
331
|
+
heatmapLayer == null ? void 0 : heatmapLayer.remove();
|
|
332
|
+
heatmapLayer = null;
|
|
333
|
+
heatmapBtn.textContent = "Show";
|
|
334
|
+
heatmapBtn.className = "ghost";
|
|
335
|
+
};
|
|
336
|
+
heatmapBtn.onclick = async () => {
|
|
337
|
+
if (heatmapLayer) return hideHeatmap();
|
|
338
|
+
if (!heatmapBins) {
|
|
339
|
+
heatmapStatus.textContent = "Loading\u2026";
|
|
340
|
+
try {
|
|
341
|
+
const res = await fetch(`${apiHost.replace(/\/$/, "")}/toolbar/heatmap`, {
|
|
342
|
+
method: "POST",
|
|
343
|
+
headers: { "Content-Type": "application/json" },
|
|
344
|
+
body: JSON.stringify({ token, url: window.location.href })
|
|
345
|
+
});
|
|
346
|
+
if (!res.ok) throw new Error(String(res.status));
|
|
347
|
+
const json = await res.json();
|
|
348
|
+
const payload = (json == null ? void 0 : json.data) ?? json;
|
|
349
|
+
heatmapBins = payload.bins ?? [];
|
|
350
|
+
} catch {
|
|
351
|
+
heatmapStatus.textContent = "Could not load heatmap data.";
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
if (heatmapBins.length === 0) {
|
|
356
|
+
heatmapStatus.textContent = "No clicks recorded for this page. Heatmap capture is opt-in in project settings.";
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
heatmapStatus.textContent = `${heatmapBins.length} hotspots`;
|
|
360
|
+
paintHeatmap(heatmapBins);
|
|
361
|
+
heatmapBtn.textContent = "Hide";
|
|
362
|
+
heatmapBtn.className = "";
|
|
363
|
+
};
|
|
364
|
+
const onResize = () => {
|
|
365
|
+
if (heatmapLayer && heatmapBins) paintHeatmap(heatmapBins);
|
|
366
|
+
};
|
|
367
|
+
window.addEventListener("resize", onResize);
|
|
368
|
+
return {
|
|
369
|
+
destroy() {
|
|
370
|
+
stopInspecting();
|
|
371
|
+
hideHeatmap();
|
|
372
|
+
window.removeEventListener("resize", onResize);
|
|
373
|
+
host.remove();
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function escapeHtml(s) {
|
|
378
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
379
|
+
}
|
|
380
|
+
export {
|
|
381
|
+
FLAG_OVERRIDE_KEY,
|
|
382
|
+
TOOLBAR_FRAGMENT_KEY,
|
|
383
|
+
binToPageCoords,
|
|
384
|
+
consumeLaunchToken,
|
|
385
|
+
describeElement,
|
|
386
|
+
getFlagOverrides,
|
|
387
|
+
intensityColor,
|
|
388
|
+
isSpecificStep,
|
|
389
|
+
proposeActionStep,
|
|
390
|
+
setFlagOverride,
|
|
391
|
+
startToolbar
|
|
392
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@billdog.io/toolbar",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.2-beta.3",
|
|
4
|
+
"description": "BillDog in-page toolbar — element inspector, feature-flag overrides and heatmaps on your own site",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.mjs",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsup src/index.ts --format cjs,esm --dts",
|
|
14
|
+
"dev": "tsup src/index.ts --format cjs,esm --dts --watch",
|
|
15
|
+
"clean": "rm -rf dist"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"billdog",
|
|
19
|
+
"toolbar",
|
|
20
|
+
"heatmap",
|
|
21
|
+
"feature-flags"
|
|
22
|
+
],
|
|
23
|
+
"author": "BillDog",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
}
|
|
28
|
+
}
|