@paikko/widget 0.1.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.
@@ -0,0 +1,591 @@
1
+ "use client";
2
+
3
+ /**
4
+ * paikko `<ReportButton>` - the entry point of the whole product.
5
+ *
6
+ * A floating button. Click it to enter "point mode": the page is overlaid, the
7
+ * cursor becomes a crosshair, and the next click on any element is captured as
8
+ * the report {@link ReportTarget} (its `data-src` provenance, owning component,
9
+ * and a re-findable CSS selector). A small form then collects a free-text
10
+ * message and a kind (bug / idea / visual).
11
+ *
12
+ * On submit it assembles a {@link ReportBundle} - the tier-1 report core plus
13
+ * every captured artifact payload inline (console, network, client state,
14
+ * storage, DOM), stamped with the configured `projectKey` (the SaaS seam) -
15
+ * validates it against the contract, and POSTs it to the configured `endpoint`
16
+ * (the backend reports URL, which may be cross-origin). The server splits the
17
+ * bundle into the persisted head + the artifact rows.
18
+ *
19
+ * The {@link Capture} controller is installed for the component's lifetime so the
20
+ * console/network ring buffers are already warm when the user reports.
21
+ */
22
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
23
+ import {
24
+ type Report,
25
+ type ReportTarget,
26
+ type ReportBundle,
27
+ ReportBundleSchema,
28
+ } from "@paikko/contract";
29
+ import {
30
+ Capture,
31
+ resolveTarget,
32
+ snapshotArtifacts,
33
+ getSessionId,
34
+ SESSION_HEADER,
35
+ PUBLISHABLE_KEY_HEADER,
36
+ type CaptureConfig,
37
+ } from "./capture";
38
+
39
+ /** Report kinds offered in the form. `kind` is free-form in the contract. */
40
+ const KINDS = ["bug", "idea", "visual"] as const;
41
+ type Kind = (typeof KINDS)[number];
42
+
43
+ type Phase = "idle" | "pointing" | "form" | "submitting" | "done" | "error";
44
+
45
+ export interface ReportButtonProps {
46
+ /** Who is filing. Defaults to "anonymous" if not supplied by the host app. */
47
+ reporter?: string;
48
+ /**
49
+ * Absolute (or same-origin) URL the bundle is POSTed to - the backend's
50
+ * reports intake, e.g. `https://api.example.com/api/reports` or
51
+ * `http://localhost:8787/api/reports`. Required: the widget is
52
+ * backend-agnostic and never assumes the backend is same-origin, so there is
53
+ * no `/api/reports` default. The POST is sent with CORS mode so it works
54
+ * cross-origin.
55
+ */
56
+ endpoint: string;
57
+ /**
58
+ * Which project/tenant this report belongs to (the SaaS seam). Stamped onto
59
+ * the {@link ReportBundle} as `projectKey` so the backend can persist it on
60
+ * the ticket. Omit (or pass `null`) for single-tenant / unkeyed deployments.
61
+ */
62
+ projectKey?: string | null;
63
+ /**
64
+ * The project's PUBLISHABLE api key (pk_...). Sent as the `x-paikko-key`
65
+ * header so a backend running with auth enforced (the default; off via PAIKKO_AUTH=disabled) accepts
66
+ * the report. Optional - omit for an unauthenticated backend. This key is
67
+ * public (it ships in the browser); never pass a secret key (sk_...).
68
+ */
69
+ apiKey?: string;
70
+ /**
71
+ * Reader for the mandated client-state store. The store seam owns the store;
72
+ * the host app passes its `getState` (or equivalent) here so capture can
73
+ * snapshot it. Omitted -> client state is captured as `{}`.
74
+ */
75
+ getClientState?: () => Record<string, unknown>;
76
+ /** Tuning for the capture buffers. Merged over capture defaults. */
77
+ captureConfig?: Partial<CaptureConfig>;
78
+ /** Called with the created ticket id (or raw response) after a successful POST. */
79
+ onReported?: (result: unknown) => void;
80
+ }
81
+
82
+ export function ReportButton({
83
+ reporter = "anonymous",
84
+ endpoint,
85
+ projectKey = null,
86
+ apiKey,
87
+ getClientState,
88
+ captureConfig,
89
+ onReported,
90
+ }: ReportButtonProps): React.JSX.Element {
91
+ const [phase, setPhase] = useState<Phase>("idle");
92
+ const [target, setTarget] = useState<ReportTarget | null>(null);
93
+ const [message, setMessage] = useState("");
94
+ const [kind, setKind] = useState<Kind>("bug");
95
+ const [errorText, setErrorText] = useState<string | null>(null);
96
+
97
+ // One capture controller for the component's lifetime. Buffers warm up the
98
+ // moment the button mounts so a report fired seconds later still has history.
99
+ const captureRef = useRef<Capture | null>(null);
100
+ if (captureRef.current === null) {
101
+ captureRef.current = new Capture({
102
+ ...captureConfig,
103
+ getClientState: getClientState ?? captureConfig?.getClientState ?? (() => ({})),
104
+ });
105
+ }
106
+
107
+ useEffect(() => {
108
+ const capture = captureRef.current;
109
+ capture?.install();
110
+ return () => capture?.uninstall();
111
+ }, []);
112
+
113
+ /* ---- point mode ---- */
114
+
115
+ const enterPointMode = useCallback(() => {
116
+ setErrorText(null);
117
+ setTarget(null);
118
+ setPhase("pointing");
119
+ }, []);
120
+
121
+ const cancel = useCallback(() => {
122
+ setPhase("idle");
123
+ setTarget(null);
124
+ setMessage("");
125
+ setErrorText(null);
126
+ }, []);
127
+
128
+ // While pointing, intercept the next click anywhere on the page (capture phase,
129
+ // so we win before the app's own handlers and can prevent the real action).
130
+ useEffect(() => {
131
+ if (phase !== "pointing") return;
132
+
133
+ const onClick = (e: MouseEvent) => {
134
+ const el = e.target as Element | null;
135
+ // Ignore clicks on paikko's own UI.
136
+ if (el && el.closest("[data-paikko-ui]")) return;
137
+ e.preventDefault();
138
+ e.stopPropagation();
139
+ setTarget(resolveTarget(el));
140
+ setPhase("form");
141
+ };
142
+
143
+ const onKey = (e: KeyboardEvent) => {
144
+ if (e.key === "Escape") cancel();
145
+ };
146
+
147
+ document.addEventListener("click", onClick, { capture: true });
148
+ document.addEventListener("keydown", onKey, true);
149
+ return () => {
150
+ document.removeEventListener("click", onClick, { capture: true });
151
+ document.removeEventListener("keydown", onKey, true);
152
+ };
153
+ }, [phase, cancel]);
154
+
155
+ /* ---- submit ---- */
156
+
157
+ const handleSubmit = useCallback(
158
+ async (e: React.FormEvent) => {
159
+ e.preventDefault();
160
+ const capture = captureRef.current;
161
+ if (!capture) return;
162
+
163
+ setPhase("submitting");
164
+ setErrorText(null);
165
+
166
+ const resolvedTarget: ReportTarget = target ?? {
167
+ selector: null,
168
+ src: null,
169
+ component: null,
170
+ };
171
+
172
+ const report: Report = {
173
+ message: message.trim(),
174
+ kind,
175
+ route:
176
+ typeof window !== "undefined"
177
+ ? window.location.pathname + window.location.search
178
+ : "",
179
+ target: resolvedTarget,
180
+ };
181
+
182
+ const artifacts = await snapshotArtifacts(capture, resolvedTarget.selector);
183
+
184
+ const bundle: ReportBundle = { reporter, projectKey, report, artifacts };
185
+
186
+ // Validate against the contract before it leaves the client. If our own
187
+ // assembly is malformed, surface it rather than POSTing garbage.
188
+ const parsed = ReportBundleSchema.safeParse(bundle);
189
+ if (!parsed.success) {
190
+ setErrorText("Report failed validation: " + parsed.error.message);
191
+ setPhase("error");
192
+ return;
193
+ }
194
+
195
+ try {
196
+ const res = await fetch(endpoint, {
197
+ method: "POST",
198
+ // The backend can live on another origin (the consumer app is a
199
+ // separate deployment), so POST in CORS mode. `credentials: "omit"`
200
+ // keeps the request simple/cookieless so a permissive backend CORS
201
+ // config (`Access-Control-Allow-Origin: *`) is enough; the report is
202
+ // authenticated by its `projectKey`, not a session cookie.
203
+ mode: "cors",
204
+ credentials: "omit",
205
+ headers: {
206
+ "content-type": "application/json",
207
+ [SESSION_HEADER]: getSessionId(),
208
+ // Publishable key for auth-enforced backends; omitted header is fine
209
+ // for an unauthenticated backend.
210
+ ...(apiKey ? { [PUBLISHABLE_KEY_HEADER]: apiKey } : null),
211
+ },
212
+ body: JSON.stringify(parsed.data),
213
+ });
214
+ if (!res.ok) {
215
+ const body = await res.text().catch(() => "");
216
+ setErrorText(`Server rejected report (${res.status}). ${body}`.trim());
217
+ setPhase("error");
218
+ return;
219
+ }
220
+ const result = await res.json().catch(() => ({}));
221
+ onReported?.(result);
222
+ setPhase("done");
223
+ // Reset form state so a subsequent report starts clean.
224
+ setMessage("");
225
+ setTarget(null);
226
+ } catch (err) {
227
+ setErrorText(err instanceof Error ? err.message : "Network error");
228
+ setPhase("error");
229
+ }
230
+ },
231
+ [apiKey, endpoint, kind, message, onReported, projectKey, reporter, target],
232
+ );
233
+
234
+ const targetLabel = useMemo(() => describeTarget(target), [target]);
235
+
236
+ /* ---- keyboard isolation ---- */
237
+
238
+ // The host app (e.g. a calculator) may install document/window-level keydown
239
+ // listeners that treat every keystroke as app input. When the user types in
240
+ // the report form those keystrokes are OURS, not the app's. We stop them from
241
+ // propagating past the form so app-level bubble listeners never see them, and
242
+ // call `stopImmediatePropagation` on the native event so even other listeners
243
+ // already attached to the same node (or capture-phase document listeners that
244
+ // re-dispatch) don't get a second crack. The widget defends its own input
245
+ // rather than trusting the host app to be polite.
246
+ const swallowKey = useCallback((e: React.KeyboardEvent) => {
247
+ e.stopPropagation();
248
+ e.nativeEvent.stopImmediatePropagation();
249
+ }, []);
250
+
251
+ /* ---- render ---- */
252
+
253
+ return (
254
+ <div data-paikko-ui="root" style={styles.root}>
255
+ {phase === "pointing" && (
256
+ <div data-paikko-ui="overlay" style={styles.overlay}>
257
+ <div style={styles.hint}>
258
+ Click the element you want to report
259
+ <button
260
+ type="button"
261
+ data-paikko-ui="cancel"
262
+ onClick={cancel}
263
+ style={styles.hintCancel}
264
+ >
265
+ Esc to cancel
266
+ </button>
267
+ </div>
268
+ </div>
269
+ )}
270
+
271
+ {(phase === "form" ||
272
+ phase === "submitting" ||
273
+ phase === "error") && (
274
+ <form
275
+ data-paikko-ui="form"
276
+ onSubmit={handleSubmit}
277
+ onKeyDown={swallowKey}
278
+ onKeyUp={swallowKey}
279
+ onKeyPress={swallowKey}
280
+ style={styles.panel}
281
+ >
282
+ <div style={styles.panelHeader}>
283
+ <strong>Report an issue</strong>
284
+ <button
285
+ type="button"
286
+ data-paikko-ui="close"
287
+ onClick={cancel}
288
+ style={styles.iconButton}
289
+ aria-label="Close"
290
+ >
291
+ ×
292
+ </button>
293
+ </div>
294
+
295
+ <div style={styles.targetRow} title={target?.src ?? undefined}>
296
+ <span style={styles.targetDot} />
297
+ {targetLabel}
298
+ </div>
299
+
300
+ <label style={styles.label}>
301
+ <span>What's wrong?</span>
302
+ <textarea
303
+ data-paikko-ui="message"
304
+ value={message}
305
+ onChange={(e) => setMessage(e.target.value)}
306
+ onKeyDown={swallowKey}
307
+ onKeyUp={swallowKey}
308
+ onKeyPress={swallowKey}
309
+ placeholder="Describe what you saw…"
310
+ rows={3}
311
+ style={styles.textarea}
312
+ autoFocus
313
+ />
314
+ </label>
315
+
316
+ <div style={styles.kindRow}>
317
+ {KINDS.map((k) => (
318
+ <button
319
+ type="button"
320
+ key={k}
321
+ data-paikko-ui={`kind-${k}`}
322
+ onClick={() => setKind(k)}
323
+ style={{
324
+ ...styles.kindButton,
325
+ ...(kind === k ? styles.kindButtonActive : null),
326
+ }}
327
+ >
328
+ {k}
329
+ </button>
330
+ ))}
331
+ </div>
332
+
333
+ {errorText && (
334
+ <div data-paikko-ui="error" style={styles.error}>
335
+ {errorText}
336
+ </div>
337
+ )}
338
+
339
+ <div style={styles.actions}>
340
+ <button
341
+ type="button"
342
+ data-paikko-ui="repoint"
343
+ onClick={enterPointMode}
344
+ style={styles.secondaryButton}
345
+ disabled={phase === "submitting"}
346
+ >
347
+ Re-pick element
348
+ </button>
349
+ <button
350
+ type="submit"
351
+ data-paikko-ui="submit"
352
+ style={styles.primaryButton}
353
+ disabled={phase === "submitting" || message.trim().length === 0}
354
+ >
355
+ {phase === "submitting" ? "Sending…" : "Send report"}
356
+ </button>
357
+ </div>
358
+ </form>
359
+ )}
360
+
361
+ {phase === "done" && (
362
+ <div data-paikko-ui="done" style={styles.panel}>
363
+ <div style={styles.panelHeader}>
364
+ <strong>Report sent</strong>
365
+ <button
366
+ type="button"
367
+ data-paikko-ui="close"
368
+ onClick={cancel}
369
+ style={styles.iconButton}
370
+ aria-label="Close"
371
+ >
372
+ ×
373
+ </button>
374
+ </div>
375
+ <div style={{ fontSize: 13, opacity: 0.8 }}>Thanks - the agent will take it from here.</div>
376
+ </div>
377
+ )}
378
+
379
+ {phase !== "form" && phase !== "submitting" && (
380
+ <button
381
+ type="button"
382
+ data-paikko-ui="fab"
383
+ onClick={phase === "pointing" ? cancel : enterPointMode}
384
+ style={{
385
+ ...styles.fab,
386
+ ...(phase === "pointing" ? styles.fabActive : null),
387
+ }}
388
+ aria-label="Report an issue"
389
+ >
390
+ {phase === "pointing" ? "Cancel" : "Report"}
391
+ </button>
392
+ )}
393
+ </div>
394
+ );
395
+ }
396
+
397
+ /* ------------------------------------------------------------------ */
398
+ /* Presentation helpers */
399
+ /* ------------------------------------------------------------------ */
400
+
401
+ function describeTarget(target: ReportTarget | null): string {
402
+ if (!target) return "No element selected";
403
+ if (target.component) return `<${target.component}>`;
404
+ if (target.selector) return target.selector;
405
+ return "Whole page";
406
+ }
407
+
408
+ const Z = 2147483000; // sit above virtually all app content
409
+
410
+ const styles: Record<string, React.CSSProperties> = {
411
+ root: {
412
+ position: "fixed",
413
+ inset: 0,
414
+ pointerEvents: "none",
415
+ zIndex: Z,
416
+ fontFamily:
417
+ "system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
418
+ },
419
+ fab: {
420
+ position: "fixed",
421
+ right: 20,
422
+ bottom: 20,
423
+ pointerEvents: "auto",
424
+ padding: "10px 16px",
425
+ borderRadius: 999,
426
+ border: "none",
427
+ background: "#111827",
428
+ color: "#fff",
429
+ fontSize: 14,
430
+ fontWeight: 600,
431
+ cursor: "pointer",
432
+ boxShadow: "0 6px 20px rgba(0,0,0,0.25)",
433
+ },
434
+ fabActive: {
435
+ background: "#dc2626",
436
+ },
437
+ overlay: {
438
+ position: "fixed",
439
+ inset: 0,
440
+ pointerEvents: "none",
441
+ background: "rgba(37,99,235,0.06)",
442
+ cursor: "crosshair",
443
+ },
444
+ hint: {
445
+ position: "fixed",
446
+ top: 16,
447
+ left: "50%",
448
+ transform: "translateX(-50%)",
449
+ pointerEvents: "auto",
450
+ background: "#111827",
451
+ color: "#fff",
452
+ padding: "8px 14px",
453
+ borderRadius: 8,
454
+ fontSize: 13,
455
+ display: "flex",
456
+ gap: 12,
457
+ alignItems: "center",
458
+ boxShadow: "0 4px 14px rgba(0,0,0,0.3)",
459
+ },
460
+ hintCancel: {
461
+ background: "transparent",
462
+ border: "1px solid rgba(255,255,255,0.4)",
463
+ color: "#fff",
464
+ borderRadius: 6,
465
+ padding: "2px 8px",
466
+ fontSize: 12,
467
+ cursor: "pointer",
468
+ },
469
+ panel: {
470
+ position: "fixed",
471
+ right: 20,
472
+ bottom: 76, // sit above the FAB (bottom:20, ~40px tall) so they stack, never overlap
473
+ width: 320,
474
+ pointerEvents: "auto",
475
+ background: "#fff",
476
+ color: "#111827",
477
+ border: "1px solid #e5e7eb",
478
+ borderRadius: 12,
479
+ padding: 16,
480
+ boxShadow: "0 12px 40px rgba(0,0,0,0.22)",
481
+ display: "flex",
482
+ flexDirection: "column",
483
+ gap: 12,
484
+ },
485
+ panelHeader: {
486
+ display: "flex",
487
+ justifyContent: "space-between",
488
+ alignItems: "center",
489
+ fontSize: 15,
490
+ },
491
+ iconButton: {
492
+ background: "transparent",
493
+ border: "none",
494
+ fontSize: 20,
495
+ lineHeight: 1,
496
+ cursor: "pointer",
497
+ color: "#6b7280",
498
+ },
499
+ targetRow: {
500
+ display: "flex",
501
+ alignItems: "center",
502
+ gap: 8,
503
+ fontSize: 12,
504
+ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
505
+ background: "#f3f4f6",
506
+ borderRadius: 6,
507
+ padding: "6px 8px",
508
+ overflow: "hidden",
509
+ whiteSpace: "nowrap",
510
+ textOverflow: "ellipsis",
511
+ },
512
+ targetDot: {
513
+ width: 8,
514
+ height: 8,
515
+ borderRadius: 999,
516
+ background: "#2563eb",
517
+ flex: "0 0 auto",
518
+ },
519
+ label: {
520
+ display: "flex",
521
+ flexDirection: "column",
522
+ gap: 6,
523
+ fontSize: 13,
524
+ fontWeight: 600,
525
+ },
526
+ textarea: {
527
+ font: "inherit",
528
+ fontWeight: 400,
529
+ fontSize: 13,
530
+ padding: 8,
531
+ border: "1px solid #d1d5db",
532
+ borderRadius: 8,
533
+ resize: "vertical",
534
+ },
535
+ kindRow: {
536
+ display: "flex",
537
+ gap: 8,
538
+ },
539
+ kindButton: {
540
+ flex: 1,
541
+ padding: "6px 0",
542
+ borderRadius: 8,
543
+ border: "1px solid #d1d5db",
544
+ background: "#fff",
545
+ color: "#374151",
546
+ fontSize: 13,
547
+ cursor: "pointer",
548
+ textTransform: "capitalize",
549
+ },
550
+ kindButtonActive: {
551
+ borderColor: "#2563eb",
552
+ background: "#eff6ff",
553
+ color: "#1d4ed8",
554
+ fontWeight: 600,
555
+ },
556
+ actions: {
557
+ display: "flex",
558
+ gap: 8,
559
+ justifyContent: "flex-end",
560
+ },
561
+ secondaryButton: {
562
+ padding: "8px 12px",
563
+ borderRadius: 8,
564
+ border: "1px solid #d1d5db",
565
+ background: "#fff",
566
+ color: "#374151",
567
+ fontSize: 13,
568
+ cursor: "pointer",
569
+ },
570
+ primaryButton: {
571
+ padding: "8px 14px",
572
+ borderRadius: 8,
573
+ border: "none",
574
+ background: "#2563eb",
575
+ color: "#fff",
576
+ fontSize: 13,
577
+ fontWeight: 600,
578
+ cursor: "pointer",
579
+ },
580
+ error: {
581
+ fontSize: 12,
582
+ color: "#b91c1c",
583
+ background: "#fef2f2",
584
+ border: "1px solid #fecaca",
585
+ borderRadius: 6,
586
+ padding: "6px 8px",
587
+ whiteSpace: "pre-wrap",
588
+ },
589
+ };
590
+
591
+ export default ReportButton;