@obsrviq/widgets 0.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Obsrviq
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 ADDED
@@ -0,0 +1,40 @@
1
+ # @obsrviq/widgets
2
+
3
+ Embeddable, white-label React components. Drop them into your product; they inherit
4
+ your theme via CSS variables and show no Obsrviq branding.
5
+
6
+ ```bash
7
+ npm install @obsrviq/widgets
8
+ ```
9
+ ```tsx
10
+ import {
11
+ ObsrviqReplay, ObsrviqInsights, ObsrviqRecommendations, ObsrviqScore, ObsrviqCopilot,
12
+ } from '@obsrviq/widgets';
13
+ import '@obsrviq/widgets/styles.css'; // one import pulls in all tokens + styles
14
+
15
+ const api = { apiBaseUrl: 'https://api.yourapp.com', apiKey: 'sk_live_…' };
16
+ ```
17
+
18
+ | Component | Props (beyond `api` + `theme`) |
19
+ |---|---|
20
+ | `<ObsrviqReplay/>` | `sessionId`, `startAtMs?`, `height?`, `onEnded?` |
21
+ | `<ObsrviqInsights/>` | `siteId`, `max?`, `onApply?`, `onDismiss?` |
22
+ | `<ObsrviqRecommendations/>` | `siteId`, `onAction?` |
23
+ | `<ObsrviqScore/>` | `siteId`, `type` (`performance`/`accessibility`/`ai-visibility`), `size?` |
24
+ | `<ObsrviqCopilot/>` | `siteId`, `placeholder?` |
25
+
26
+ Each renders a skeleton within ~1s, is keyboard- and screen-reader-accessible,
27
+ inherits the host theme, and degrades to a friendly empty state with no data.
28
+
29
+ ## Theming
30
+
31
+ Override any `--lum-*` token globally, or per-widget via the `theme` prop:
32
+
33
+ ```tsx
34
+ <ObsrviqInsights siteId="…" {...api} theme={{ '--lum-accent': '#e11d48', '--lum-radius': '14px' }} />
35
+ ```
36
+
37
+ > **Browser key exposure:** widgets given a raw `sk_` key expose it client-side.
38
+ > For customer-facing embeds, proxy through your backend or use a short-lived JWT.
39
+
40
+ Full guide: [docs/EMBEDDING.md](../../docs/EMBEDDING.md).
@@ -0,0 +1,94 @@
1
+ import * as react from 'react';
2
+ import { InsightCard, ScoreType } from '@obsrviq/types';
3
+
4
+ /** Light/dark color scheme, or an object of `--lum-*` overrides to white-label. */
5
+ type WidgetTheme = 'light' | 'dark' | Record<string, string>;
6
+ interface BaseWidgetProps {
7
+ apiBaseUrl: string;
8
+ apiKey?: string;
9
+ token?: string;
10
+ /** `'light'` or `'dark'` to pick a color scheme (default follows the page —
11
+ * dark for the standalone embed), or an object of `--lum-*` overrides. */
12
+ theme?: WidgetTheme;
13
+ }
14
+
15
+ type ClipBounds = {
16
+ fromEvent?: string;
17
+ toEvent?: string;
18
+ fromMs?: number;
19
+ toMs?: number;
20
+ fromUtc?: number;
21
+ toUtc?: number;
22
+ loop?: boolean;
23
+ };
24
+ /** What to render when the recording can't be shown (not found / load error):
25
+ * 'message' (default) shows a small note; 'hide' renders nothing so the embed
26
+ * collapses on the host page (pair with `onError` to hide your own wrapper). */
27
+ type UnavailableFallback = 'message' | 'hide';
28
+ interface ObsrviqReplayProps extends BaseWidgetProps {
29
+ /** The session to play. Optional if `userId` is given — then the session is
30
+ * resolved from the user + clip bounds (UTC window / event names). */
31
+ sessionId?: string;
32
+ /** Resolve the session from this user instead of a session id. */
33
+ userId?: string;
34
+ /** Optional site scope for `userId` resolution; omit to search the whole tenant. */
35
+ siteId?: string;
36
+ startAtMs?: number;
37
+ /** Play only a segment: bound by two custom-event names, our-clock ms, or UTC. */
38
+ clip?: ClipBounds;
39
+ /** Focus mode the player opens in ('stage' = Watch). Overrides the site default. */
40
+ defaultMode?: 'balanced' | 'stage' | 'debug';
41
+ /** Restrict the focus-mode toggle to this allowlist; a single entry hides it. */
42
+ allowedModes?: Array<'balanced' | 'stage' | 'debug'>;
43
+ /** Playback speed the player opens at (clamped to the nearest offered speed). */
44
+ defaultSpeed?: number;
45
+ /** Show the fullscreen control + `F` shortcut. Default true. */
46
+ allowFullscreen?: boolean;
47
+ /** Base for "Copy link" deep-links (this embed's URL has no session id). A
48
+ * `:id` template, or a base we append `/sessions/:id` to. Omit → console host. */
49
+ shareUrl?: string;
50
+ onEnded?: () => void;
51
+ /** Called once when the recording can't be shown — `reason` is 'not_found'
52
+ * (no such session / no match for the user) or 'error' (load/network/auth).
53
+ * Use it to hide your own container, log, retry, etc. */
54
+ onError?: (info: {
55
+ reason: 'not_found' | 'error';
56
+ message: string;
57
+ }) => void;
58
+ /** What to render when unavailable. Default 'message'. Use 'hide' to collapse. */
59
+ fallback?: UnavailableFallback;
60
+ height?: number | string;
61
+ }
62
+ /** The replay player itself, embeddable and white-label (§12). */
63
+ declare function ObsrviqReplay({ sessionId, userId, siteId, apiBaseUrl, apiKey, token, theme, startAtMs, clip, defaultMode, allowedModes, defaultSpeed, allowFullscreen, shareUrl, onEnded, onError, fallback, height, }: ObsrviqReplayProps): react.JSX.Element | null;
64
+
65
+ interface ObsrviqInsightsProps extends BaseWidgetProps {
66
+ siteId: string;
67
+ max?: number;
68
+ onApply?: (card: InsightCard) => void;
69
+ onDismiss?: (card: InsightCard) => void;
70
+ }
71
+ declare function ObsrviqInsights(props: ObsrviqInsightsProps): react.JSX.Element;
72
+
73
+ interface ObsrviqRecommendationsProps extends BaseWidgetProps {
74
+ siteId: string;
75
+ onAction?: (rec: InsightCard) => void;
76
+ }
77
+ /** A prioritized recommendation queue (impact × confidence / effort, §14). */
78
+ declare function ObsrviqRecommendations({ siteId, apiBaseUrl, apiKey, token, theme, onAction }: ObsrviqRecommendationsProps): react.JSX.Element;
79
+
80
+ interface ObsrviqScoreProps extends BaseWidgetProps {
81
+ siteId: string;
82
+ type: ScoreType;
83
+ size?: number;
84
+ }
85
+ declare function ObsrviqScore({ siteId, type, apiBaseUrl, apiKey, token, theme, size }: ObsrviqScoreProps): react.JSX.Element;
86
+
87
+ interface ObsrviqCopilotProps extends BaseWidgetProps {
88
+ siteId: string;
89
+ placeholder?: string;
90
+ }
91
+ /** Embeddable copilot — NL Q&A grounded in the tenant's data (§14). */
92
+ declare function ObsrviqCopilot({ siteId, apiBaseUrl, apiKey, token, theme, placeholder }: ObsrviqCopilotProps): react.JSX.Element;
93
+
94
+ export { type BaseWidgetProps, ObsrviqCopilot, type ObsrviqCopilotProps, ObsrviqInsights, type ObsrviqInsightsProps, ObsrviqRecommendations, type ObsrviqRecommendationsProps, ObsrviqReplay, type ObsrviqReplayProps, ObsrviqScore, type ObsrviqScoreProps };
package/dist/index.js ADDED
@@ -0,0 +1,465 @@
1
+ // src/ObsrviqReplay.tsx
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { ReplayPlayer } from "@obsrviq/player";
4
+
5
+ // src/common.tsx
6
+ import { jsx } from "react/jsx-runtime";
7
+ function ThemeScope({
8
+ theme,
9
+ className,
10
+ children
11
+ }) {
12
+ const scheme = typeof theme === "string" ? theme : void 0;
13
+ const overrides = theme && typeof theme === "object" ? theme : void 0;
14
+ return /* @__PURE__ */ jsx(
15
+ "div",
16
+ {
17
+ className: `lum-root lum lum-widget ${className ?? ""}`,
18
+ "data-lum-theme": scheme,
19
+ style: overrides,
20
+ children
21
+ }
22
+ );
23
+ }
24
+
25
+ // src/ObsrviqReplay.tsx
26
+ import { jsx as jsx2 } from "react/jsx-runtime";
27
+ function authHeaders(token, apiKey) {
28
+ if (token) return { authorization: `Bearer ${token}` };
29
+ if (apiKey) return { authorization: `Bearer ${apiKey}`, "x-obsrviq-key": apiKey };
30
+ return {};
31
+ }
32
+ function useResolvedSession(enabled, opts) {
33
+ const { apiBaseUrl, token, apiKey, userId, siteId, clip } = opts;
34
+ const [state, setState] = useState({
35
+ sessionId: null,
36
+ loading: enabled,
37
+ error: null,
38
+ notFound: false
39
+ });
40
+ const sig = JSON.stringify([userId, siteId, clip?.fromUtc, clip?.toUtc, clip?.fromEvent, clip?.toEvent]);
41
+ useEffect(() => {
42
+ if (!enabled || !userId) return;
43
+ const ctrl = new AbortController();
44
+ setState({ sessionId: null, loading: true, error: null, notFound: false });
45
+ const q = new URLSearchParams({ userId });
46
+ if (siteId) q.set("siteId", siteId);
47
+ if (clip?.fromUtc != null && clip?.toUtc != null) {
48
+ q.set("fromUtc", String(clip.fromUtc));
49
+ q.set("toUtc", String(clip.toUtc));
50
+ } else if (clip?.fromEvent) {
51
+ q.set("fromEvent", clip.fromEvent);
52
+ if (clip.toEvent) q.set("toEvent", clip.toEvent);
53
+ }
54
+ const base = apiBaseUrl.replace(/\/$/, "");
55
+ fetch(`${base}/v1/sessions/locate?${q.toString()}`, {
56
+ headers: authHeaders(token, apiKey),
57
+ signal: ctrl.signal
58
+ }).then(async (r) => {
59
+ if (!r.ok) {
60
+ const notFound = r.status === 404;
61
+ throw Object.assign(
62
+ new Error(notFound ? "No matching recording for that user." : `Lookup failed (${r.status}).`),
63
+ { notFound }
64
+ );
65
+ }
66
+ return r.json();
67
+ }).then((j) => {
68
+ if (!ctrl.signal.aborted) setState({ sessionId: j.sessionId, loading: false, error: null, notFound: false });
69
+ }).catch((e) => {
70
+ if (ctrl.signal.aborted) return;
71
+ const notFound = !!e?.notFound;
72
+ setState({
73
+ sessionId: null,
74
+ loading: false,
75
+ error: e instanceof Error ? e.message : "Lookup failed.",
76
+ notFound
77
+ });
78
+ });
79
+ return () => ctrl.abort();
80
+ }, [enabled, sig, apiBaseUrl]);
81
+ return state;
82
+ }
83
+ var msgStyle = {
84
+ display: "flex",
85
+ alignItems: "center",
86
+ justifyContent: "center",
87
+ height: "100%",
88
+ padding: 16,
89
+ textAlign: "center",
90
+ color: "var(--lum-muted)",
91
+ fontSize: 14
92
+ };
93
+ function ObsrviqReplay({
94
+ sessionId,
95
+ userId,
96
+ siteId,
97
+ apiBaseUrl,
98
+ apiKey,
99
+ token,
100
+ theme,
101
+ startAtMs,
102
+ clip,
103
+ defaultMode,
104
+ allowedModes,
105
+ defaultSpeed,
106
+ allowFullscreen,
107
+ shareUrl,
108
+ onEnded,
109
+ onError,
110
+ fallback = "message",
111
+ height = 560
112
+ }) {
113
+ const needResolve = !sessionId && !!userId;
114
+ const resolved = useResolvedSession(needResolve, { apiBaseUrl, token, apiKey, userId, siteId, clip });
115
+ const [playerError, setPlayerError] = useState(null);
116
+ const effectiveId = sessionId ?? resolved.sessionId;
117
+ useEffect(() => setPlayerError(null), [effectiveId]);
118
+ const reportedRef = useRef(null);
119
+ useEffect(() => {
120
+ let info = null;
121
+ if (!sessionId && !userId) info = { reason: "error", message: "Provide a sessionId or a userId." };
122
+ else if (resolved.error) info = { reason: resolved.notFound ? "not_found" : "error", message: resolved.error };
123
+ else if (playerError) info = playerError;
124
+ if (info) {
125
+ if (reportedRef.current !== info.message) {
126
+ reportedRef.current = info.message;
127
+ onError?.(info);
128
+ }
129
+ } else {
130
+ reportedRef.current = null;
131
+ }
132
+ }, [sessionId, userId, resolved.error, resolved.notFound, playerError]);
133
+ if (!effectiveId) {
134
+ if (fallback === "hide") return null;
135
+ const msg = !sessionId && !userId ? "Provide a sessionId or a userId to play a recording." : needResolve && resolved.loading ? "Finding the recording\u2026" : resolved.error ?? "Recording unavailable.";
136
+ return /* @__PURE__ */ jsx2(ThemeScope, { theme, className: "lum-widget--replay", children: /* @__PURE__ */ jsx2("div", { style: { height }, children: /* @__PURE__ */ jsx2("div", { style: msgStyle, children: msg }) }) });
137
+ }
138
+ if (playerError && fallback === "hide") return null;
139
+ return /* @__PURE__ */ jsx2(ThemeScope, { theme, className: "lum-widget--replay", children: /* @__PURE__ */ jsx2("div", { style: { height }, children: /* @__PURE__ */ jsx2(
140
+ ReplayPlayer,
141
+ {
142
+ sessionId: effectiveId,
143
+ apiBaseUrl,
144
+ apiKey,
145
+ token,
146
+ startAtMs,
147
+ clip,
148
+ defaultMode,
149
+ allowedModes,
150
+ defaultSpeed,
151
+ allowFullscreen,
152
+ shareUrl,
153
+ onEnded,
154
+ onError: (info) => setPlayerError(info),
155
+ embedded: true
156
+ }
157
+ ) }) });
158
+ }
159
+
160
+ // src/ObsrviqInsights.tsx
161
+ import { useEffect as useEffect2, useState as useState2 } from "react";
162
+ import { Badge, Button, EmptyState, ErrorState, Skeleton, ToastProvider, useToast } from "@obsrviq/ui";
163
+
164
+ // src/client.ts
165
+ function headers(cfg) {
166
+ const key = cfg.token || cfg.apiKey;
167
+ return key ? { authorization: `Bearer ${key}` } : {};
168
+ }
169
+ async function apiGet(cfg, path) {
170
+ const res = await fetch(`${cfg.apiBaseUrl.replace(/\/$/, "")}${path}`, { headers: headers(cfg) });
171
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
172
+ return await res.json();
173
+ }
174
+ async function apiPost(cfg, path, body) {
175
+ const res = await fetch(`${cfg.apiBaseUrl.replace(/\/$/, "")}${path}`, {
176
+ method: "POST",
177
+ headers: { ...headers(cfg), "content-type": "application/json" },
178
+ body: body ? JSON.stringify(body) : void 0
179
+ });
180
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
181
+ return await res.json();
182
+ }
183
+ async function* copilotStream(cfg, siteId, question) {
184
+ const res = await fetch(`${cfg.apiBaseUrl.replace(/\/$/, "")}/v1/copilot/ask`, {
185
+ method: "POST",
186
+ headers: { ...headers(cfg), "content-type": "application/json" },
187
+ body: JSON.stringify({ siteId, question })
188
+ });
189
+ if (!res.ok || !res.body) throw new Error(`copilot ${res.status}`);
190
+ const reader = res.body.getReader();
191
+ const decoder = new TextDecoder();
192
+ let buffer = "";
193
+ for (; ; ) {
194
+ const { done, value } = await reader.read();
195
+ if (done) break;
196
+ buffer += decoder.decode(value, { stream: true });
197
+ const lines = buffer.split("\n");
198
+ buffer = lines.pop() ?? "";
199
+ for (const line of lines) {
200
+ const t = line.trim();
201
+ if (!t.startsWith("data:")) continue;
202
+ const data = t.slice(5).trim();
203
+ if (data === "[DONE]") return;
204
+ try {
205
+ yield JSON.parse(data);
206
+ } catch {
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ // src/ObsrviqInsights.tsx
213
+ import { Fragment, jsx as jsx3, jsxs } from "react/jsx-runtime";
214
+ var SEVERITY_TONE = {
215
+ info: "info",
216
+ low: "info",
217
+ medium: "warn",
218
+ high: "error"
219
+ };
220
+ function ObsrviqInsights(props) {
221
+ return /* @__PURE__ */ jsx3(ThemeScope, { theme: props.theme, className: "lum-widget--insights", children: /* @__PURE__ */ jsx3(ToastProvider, { children: /* @__PURE__ */ jsx3(InsightsInner, { ...props }) }) });
222
+ }
223
+ function InsightsInner({ siteId, apiBaseUrl, apiKey, token, max = 5, onApply, onDismiss }) {
224
+ const cfg = { apiBaseUrl, apiKey, token };
225
+ const [cards, setCards] = useState2(null);
226
+ const [error, setError] = useState2(null);
227
+ const toast = useToast();
228
+ useEffect2(() => {
229
+ let alive = true;
230
+ apiGet(cfg, `/v1/insights?siteId=${siteId}`).then((r) => alive && setCards(r.items)).catch((e) => alive && setError(e.message));
231
+ return () => {
232
+ alive = false;
233
+ };
234
+ }, [siteId]);
235
+ const act = async (card, verb) => {
236
+ setCards((cs) => (cs ?? []).map((c) => c.id === card.id ? { ...c, status: verb === "apply" ? "applied" : "dismissed" } : c));
237
+ try {
238
+ await apiPost(cfg, `/v1/insights/${card.id}/${verb}`);
239
+ toast(verb === "apply" ? "Applied" : "Dismissed", verb === "apply" ? "ok" : "default");
240
+ (verb === "apply" ? onApply : onDismiss)?.(card);
241
+ } catch {
242
+ toast("Could not update \u2014 try again", "error");
243
+ }
244
+ };
245
+ if (error)
246
+ return /* @__PURE__ */ jsx3(ErrorState, { title: "We couldn't load insights.", children: error });
247
+ if (!cards)
248
+ return /* @__PURE__ */ jsx3("div", { className: "lum-w-cards", children: Array.from({ length: 3 }).map((_, i) => /* @__PURE__ */ jsxs("div", { className: "lum-w-card", children: [
249
+ /* @__PURE__ */ jsx3(Skeleton, { width: "60%", height: 16 }),
250
+ /* @__PURE__ */ jsx3(Skeleton, { width: "100%", height: 40 })
251
+ ] }, i)) });
252
+ const visible = cards.filter((c) => c.status !== "dismissed").slice(0, max);
253
+ if (visible.length === 0)
254
+ return /* @__PURE__ */ jsx3(EmptyState, { title: "No insights yet", children: "As soon as visitors arrive, recommendations will appear here." });
255
+ return /* @__PURE__ */ jsx3("div", { className: "lum-w-cards", children: visible.map((c) => /* @__PURE__ */ jsxs("article", { className: "lum-w-card", children: [
256
+ /* @__PURE__ */ jsxs("header", { className: "lum-w-card__head", children: [
257
+ /* @__PURE__ */ jsx3(Badge, { tone: SEVERITY_TONE[c.severity], children: c.severity }),
258
+ /* @__PURE__ */ jsx3("strong", { children: c.title })
259
+ ] }),
260
+ /* @__PURE__ */ jsx3("p", { className: "lum-w-card__what", children: c.what }),
261
+ /* @__PURE__ */ jsx3("p", { className: "lum-w-card__why lum-muted", children: c.why }),
262
+ /* @__PURE__ */ jsx3("p", { className: "lum-w-card__rec", children: c.recommendation }),
263
+ /* @__PURE__ */ jsxs("div", { className: "lum-w-card__meta", children: [
264
+ c.expectedImpact && /* @__PURE__ */ jsxs(Badge, { tone: "ok", children: [
265
+ c.expectedImpact.metric,
266
+ " ",
267
+ c.expectedImpact.lift
268
+ ] }),
269
+ /* @__PURE__ */ jsxs(Badge, { children: [
270
+ "effort: ",
271
+ c.effort
272
+ ] }),
273
+ c.evidence.length > 0 && /* @__PURE__ */ jsxs("span", { className: "lum-muted", children: [
274
+ c.evidence.length,
275
+ " sessions"
276
+ ] })
277
+ ] }),
278
+ /* @__PURE__ */ jsx3("footer", { className: "lum-w-card__actions", children: c.status === "applied" ? /* @__PURE__ */ jsx3(Badge, { tone: "ok", children: "Applied" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
279
+ /* @__PURE__ */ jsx3(Button, { variant: "primary", size: "sm", onClick: () => act(c, "apply"), children: "Apply" }),
280
+ /* @__PURE__ */ jsx3(Button, { variant: "ghost", size: "sm", onClick: () => act(c, "dismiss"), children: "Dismiss" })
281
+ ] }) })
282
+ ] }, c.id)) });
283
+ }
284
+
285
+ // src/ObsrviqRecommendations.tsx
286
+ import { useEffect as useEffect3, useState as useState3 } from "react";
287
+ import { Badge as Badge2, Button as Button2, EmptyState as EmptyState2, ErrorState as ErrorState2, Skeleton as Skeleton2 } from "@obsrviq/ui";
288
+ import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
289
+ function ObsrviqRecommendations({ siteId, apiBaseUrl, apiKey, token, theme, onAction }) {
290
+ const cfg = { apiBaseUrl, apiKey, token };
291
+ const [items, setItems] = useState3(null);
292
+ const [error, setError] = useState3(null);
293
+ useEffect3(() => {
294
+ let alive = true;
295
+ apiGet(cfg, `/v1/insights?siteId=${siteId}`).then((r) => alive && setItems(r.items)).catch((e) => alive && setError(e.message));
296
+ return () => {
297
+ alive = false;
298
+ };
299
+ }, [siteId]);
300
+ const run = async (rec) => {
301
+ setItems((xs) => (xs ?? []).filter((x) => x.id !== rec.id));
302
+ try {
303
+ await apiPost(cfg, `/v1/insights/${rec.id}/apply`);
304
+ } catch {
305
+ }
306
+ onAction?.(rec);
307
+ };
308
+ return /* @__PURE__ */ jsx4(ThemeScope, { theme, className: "lum-widget--recs", children: error ? /* @__PURE__ */ jsx4(ErrorState2, { title: "We couldn't load recommendations.", children: error }) : !items ? /* @__PURE__ */ jsx4("div", { className: "lum-w-queue", children: Array.from({ length: 3 }).map((_, i) => /* @__PURE__ */ jsx4(Skeleton2, { height: 48 }, i)) }) : items.filter((i) => i.status === "new" || i.status === "viewed").length === 0 ? /* @__PURE__ */ jsx4(EmptyState2, { title: "You're all caught up", children: "No open recommendations right now." }) : /* @__PURE__ */ jsx4("ol", { className: "lum-w-queue", children: items.filter((i) => i.status === "new" || i.status === "viewed").map((rec, i) => /* @__PURE__ */ jsxs2("li", { className: "lum-w-queue__item", children: [
309
+ /* @__PURE__ */ jsx4("span", { className: "lum-w-queue__rank lum-mono", children: i + 1 }),
310
+ /* @__PURE__ */ jsxs2("div", { className: "lum-w-queue__body", children: [
311
+ /* @__PURE__ */ jsx4("div", { className: "lum-w-queue__title", children: rec.title }),
312
+ /* @__PURE__ */ jsxs2("div", { className: "lum-w-queue__meta", children: [
313
+ /* @__PURE__ */ jsx4(Badge2, { tone: rec.severity === "high" ? "error" : rec.severity === "medium" ? "warn" : "info", children: rec.severity }),
314
+ rec.expectedImpact && /* @__PURE__ */ jsx4(Badge2, { tone: "ok", children: rec.expectedImpact.lift }),
315
+ /* @__PURE__ */ jsxs2(Badge2, { children: [
316
+ "effort: ",
317
+ rec.effort
318
+ ] })
319
+ ] })
320
+ ] }),
321
+ /* @__PURE__ */ jsx4(Button2, { size: "sm", variant: "primary", onClick: () => run(rec), children: rec.action.type === "one_click_apply" ? "Apply" : rec.action.type === "open_experiment" ? "Experiment" : "Review" })
322
+ ] }, rec.id)) }) });
323
+ }
324
+
325
+ // src/ObsrviqScore.tsx
326
+ import { useEffect as useEffect4, useState as useState4 } from "react";
327
+ import { Skeleton as Skeleton3 } from "@obsrviq/ui";
328
+ import { jsx as jsx5, jsxs as jsxs3 } from "react/jsx-runtime";
329
+ var LABEL = {
330
+ accessibility: "Accessibility",
331
+ performance: "Performance",
332
+ "ai-visibility": "AI Visibility"
333
+ };
334
+ var RATING_COLOR = {
335
+ good: "var(--lum-ok)",
336
+ "needs-improvement": "var(--lum-warn)",
337
+ poor: "var(--lum-error)"
338
+ };
339
+ function ObsrviqScore({ siteId, type, apiBaseUrl, apiKey, token, theme, size = 120 }) {
340
+ const cfg = { apiBaseUrl, apiKey, token };
341
+ const [score, setScore] = useState4(null);
342
+ useEffect4(() => {
343
+ let alive = true;
344
+ apiGet(cfg, `/v1/scores?siteId=${siteId}&type=${type}`).then((s) => alive && setScore(s)).catch(() => alive && setScore(null));
345
+ return () => {
346
+ alive = false;
347
+ };
348
+ }, [siteId, type]);
349
+ const r = size / 2 - 8;
350
+ const c = 2 * Math.PI * r;
351
+ const value = score?.value ?? 0;
352
+ const color = score ? RATING_COLOR[score.rating] : "var(--lum-muted)";
353
+ return /* @__PURE__ */ jsx5(ThemeScope, { theme, className: "lum-widget--score", children: /* @__PURE__ */ jsxs3("div", { className: "lum-w-score", style: { width: size }, children: [
354
+ !score ? /* @__PURE__ */ jsx5(Skeleton3, { width: size, height: size, radius: size / 2 }) : /* @__PURE__ */ jsxs3("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, role: "img", "aria-label": `${LABEL[type]} score ${value}`, children: [
355
+ /* @__PURE__ */ jsx5("circle", { cx: size / 2, cy: size / 2, r, fill: "none", stroke: "var(--lum-viz-grid)", strokeWidth: 9 }),
356
+ /* @__PURE__ */ jsx5(
357
+ "circle",
358
+ {
359
+ className: "lum-w-score__arc",
360
+ cx: size / 2,
361
+ cy: size / 2,
362
+ r,
363
+ fill: "none",
364
+ stroke: color,
365
+ strokeWidth: 9,
366
+ strokeLinecap: "round",
367
+ strokeDasharray: c,
368
+ strokeDashoffset: c - value / 100 * c,
369
+ transform: `rotate(-90 ${size / 2} ${size / 2})`,
370
+ style: { ["--lum-arc-c"]: c }
371
+ }
372
+ ),
373
+ /* @__PURE__ */ jsx5("text", { x: "50%", y: "48%", textAnchor: "middle", dominantBaseline: "middle", fontSize: size * 0.26, fill: "var(--lum-text)", fontWeight: "700", fontFamily: "var(--lum-font-display)", letterSpacing: "-0.02em", children: value }),
374
+ /* @__PURE__ */ jsx5("text", { x: "50%", y: "68%", textAnchor: "middle", fill: "var(--lum-muted)", fontSize: size * 0.1, children: "/ 100" })
375
+ ] }),
376
+ /* @__PURE__ */ jsx5("div", { className: "lum-w-score__label", children: LABEL[type] })
377
+ ] }) });
378
+ }
379
+
380
+ // src/ObsrviqCopilot.tsx
381
+ import { useRef as useRef2, useState as useState5 } from "react";
382
+ import { Send } from "lucide-react";
383
+ import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
384
+ function ObsrviqCopilot({ siteId, apiBaseUrl, apiKey, token, theme, placeholder = "Ask about your visitors\u2026" }) {
385
+ const cfg = { apiBaseUrl, apiKey, token };
386
+ const [input, setInput] = useState5("");
387
+ const [turns, setTurns] = useState5([]);
388
+ const [busy, setBusy] = useState5(false);
389
+ const scrollRef = useRef2(null);
390
+ const ask = async () => {
391
+ const q = input.trim();
392
+ if (!q || busy) return;
393
+ setInput("");
394
+ setBusy(true);
395
+ const idx = turns.length;
396
+ setTurns((t) => [...t, { q, a: "", evidence: [], streaming: true }]);
397
+ try {
398
+ for await (const chunk of copilotStream(cfg, siteId, q)) {
399
+ if (chunk.kind === "token") {
400
+ setTurns((t) => t.map((x, i) => i === idx ? { ...x, a: x.a + chunk.text } : x));
401
+ } else if (chunk.kind === "evidence") {
402
+ setTurns((t) => t.map((x, i) => i === idx ? { ...x, evidence: chunk.evidence.map((e) => e.ref) } : x));
403
+ } else if (chunk.kind === "error") {
404
+ setTurns((t) => t.map((x, i) => i === idx ? { ...x, a: x.a + `
405
+ [error: ${chunk.message}]` } : x));
406
+ }
407
+ scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
408
+ }
409
+ } catch (e) {
410
+ setTurns((t) => t.map((x, i) => i === idx ? { ...x, a: x.a + `
411
+ [connection error]` } : x));
412
+ } finally {
413
+ setTurns((t) => t.map((x, i) => i === idx ? { ...x, streaming: false } : x));
414
+ setBusy(false);
415
+ }
416
+ };
417
+ return /* @__PURE__ */ jsx6(ThemeScope, { theme, className: "lum-widget--copilot", children: /* @__PURE__ */ jsxs4("div", { className: "lum-w-copilot", children: [
418
+ /* @__PURE__ */ jsxs4("div", { className: "lum-w-copilot__log", ref: scrollRef, children: [
419
+ turns.length === 0 && /* @__PURE__ */ jsx6("div", { className: "lum-w-copilot__hint lum-muted", children: "Try \u201CWhere are users struggling?\u201D or \u201CWhat's causing errors?\u201D" }),
420
+ turns.map((t, i) => /* @__PURE__ */ jsxs4("div", { className: "lum-w-copilot__turn", children: [
421
+ /* @__PURE__ */ jsx6("div", { className: "lum-w-copilot__q", children: t.q }),
422
+ /* @__PURE__ */ jsxs4("div", { className: "lum-w-copilot__a", children: [
423
+ t.a,
424
+ t.streaming && /* @__PURE__ */ jsx6("span", { className: "lum-w-copilot__caret" }),
425
+ t.evidence.length > 0 && /* @__PURE__ */ jsxs4("div", { className: "lum-w-copilot__ev lum-muted", children: [
426
+ "Evidence: ",
427
+ t.evidence.length,
428
+ " session(s)"
429
+ ] })
430
+ ] })
431
+ ] }, i))
432
+ ] }),
433
+ /* @__PURE__ */ jsxs4(
434
+ "form",
435
+ {
436
+ className: "lum-w-copilot__bar",
437
+ onSubmit: (e) => {
438
+ e.preventDefault();
439
+ void ask();
440
+ },
441
+ children: [
442
+ /* @__PURE__ */ jsx6(
443
+ "input",
444
+ {
445
+ className: "lum-pl-search",
446
+ value: input,
447
+ onChange: (e) => setInput(e.target.value),
448
+ placeholder,
449
+ "aria-label": "Ask the copilot"
450
+ }
451
+ ),
452
+ /* @__PURE__ */ jsx6("button", { className: "lum-btn lum-btn--primary lum-btn--sm", type: "submit", disabled: busy, "aria-label": "Send", children: /* @__PURE__ */ jsx6(Send, { size: 15 }) })
453
+ ]
454
+ }
455
+ )
456
+ ] }) });
457
+ }
458
+ export {
459
+ ObsrviqCopilot,
460
+ ObsrviqInsights,
461
+ ObsrviqRecommendations,
462
+ ObsrviqReplay,
463
+ ObsrviqScore
464
+ };
465
+ //# sourceMappingURL=index.js.map