@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.
- package/README.md +131 -0
- package/package.json +41 -0
- package/src/PaikkoNav.tsx +92 -0
- package/src/PaikkoProvider.tsx +79 -0
- package/src/ReportButton.tsx +591 -0
- package/src/build/provenancePlugin.cjs +200 -0
- package/src/capture.ts +991 -0
- package/src/index.ts +33 -0
|
@@ -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;
|