@dorsk/hmm 0.0.0-stage → 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,650 @@
1
+ <script lang="ts">
2
+ import {
3
+ Badge,
4
+ Button,
5
+ Callout,
6
+ Card,
7
+ Cluster,
8
+ CodeBlock,
9
+ Disclosure,
10
+ Heading,
11
+ IconButton,
12
+ KeyValue,
13
+ type KeyValueRow,
14
+ SegmentedControl,
15
+ Stack,
16
+ Text,
17
+ Textarea,
18
+ } from '@dorsk/tsumikit';
19
+ import { onMount, tick, untrack } from 'svelte';
20
+ import { clamp, type Options, readable, storageOf, transportOf, uid } from './core.js';
21
+ import { type Draft, emptyDraft, isEmpty, loadDraft, saveDraft } from './draft.js';
22
+ import {
23
+ type Attachment,
24
+ CATEGORIES,
25
+ type Category,
26
+ type ElementItem,
27
+ ENVELOPE_VERSION,
28
+ type Envelope,
29
+ type Item,
30
+ LIMITS,
31
+ validateEnvelope,
32
+ } from './envelope.js';
33
+ import { describeElement, labelOf } from './inspect.js';
34
+ import { LABELS, type Labels } from './labels.js';
35
+ import { elementAt } from './picker.js';
36
+ import { redactEnvelope } from './redact.js';
37
+ import { capture, type Shot } from './shot.js';
38
+ import { enqueue, flush, isRetryable } from './transport.js';
39
+ import { VERSION } from './version.js';
40
+
41
+ let {
42
+ projectKey,
43
+ endpoint,
44
+ transport,
45
+ context,
46
+ beforeSend,
47
+ launcher = true,
48
+ position = 'bottom-right',
49
+ labels,
50
+ onSent,
51
+ open = $bindable(false),
52
+ }: Options & { open?: boolean } = $props();
53
+
54
+ const L = $derived<Labels>({ ...LABELS, ...labels });
55
+ const deliver = $derived(transportOf({ projectKey, endpoint, transport }));
56
+ untrack(() => deliver);
57
+ const draftKey = $derived(`hmm:draft:${projectKey}`);
58
+ const outboxKey = $derived(`hmm:outbox:${projectKey}`);
59
+ const categories = $derived(CATEGORIES.map((value) => ({ value, label: L[value] })));
60
+
61
+ let host: HTMLElement | undefined;
62
+ let session: Storage | null = null;
63
+ let local: Storage | null = null;
64
+ let frame = 0;
65
+
66
+ let ready = $state(false);
67
+ let draft = $state<Draft>(emptyDraft());
68
+ let view = $state<'edit' | 'review' | 'done'>('edit');
69
+ let busy = $state<'' | 'capture' | 'send'>('');
70
+ let error = $state('');
71
+ let message = $state('');
72
+ let delivered = $state(true);
73
+ let pending = $state.raw<Envelope | null>(null);
74
+ let picking = $state(false);
75
+ let outline = $state.raw<DOMRect | null>(null);
76
+ let pins = $state.raw<{ n: number; x: number; y: number }[]>([]);
77
+
78
+ const empty = $derived(isEmpty(draft));
79
+ const rows = $derived<KeyValueRow[]>(
80
+ pending
81
+ ? [
82
+ {
83
+ label: L.category,
84
+ value: pending.category ? L[pending.category] : pending.kind,
85
+ },
86
+ { label: L.page, value: pending.page?.url ?? '' },
87
+ ...(pending.comment ? [{ label: L.comment, value: pending.comment }] : []),
88
+ ]
89
+ : [],
90
+ );
91
+
92
+ const titleOf = (item: Item) => (item.type === 'element' ? labelOf(item.target) : L.screenshot);
93
+ const imageOf = (attachments: Attachment[], item: Item) => {
94
+ const found = attachments.find((a) => a.id === item.attachment);
95
+ return found && `data:image/png;base64,${found.data}`;
96
+ };
97
+ const part = (selector: string) => host?.querySelector<HTMLElement>(selector);
98
+
99
+ function placePins() {
100
+ if (!open || picking || view !== 'edit') {
101
+ pins = [];
102
+ return;
103
+ }
104
+ pins = draft.items.flatMap((item, i) => {
105
+ if (item.type !== 'element' || item.url !== location.href) return [];
106
+ let target: Element | null = null;
107
+ try {
108
+ target = document.querySelector(item.target.selector);
109
+ } catch {}
110
+ const r = target?.getBoundingClientRect();
111
+ if (!r || (!r.width && !r.height)) return [];
112
+ return [{ n: i + 1, x: Math.max(0, r.left - 8), y: Math.max(0, r.top - 8) }];
113
+ });
114
+ }
115
+
116
+ function removeItem(item: Item) {
117
+ draft.items = draft.items.filter((i) => i.id !== item.id);
118
+ draft.attachments = draft.attachments.filter((a) => a.id !== item.attachment);
119
+ }
120
+
121
+ function removeImage(item: ElementItem) {
122
+ draft.attachments = draft.attachments.filter((a) => a.id !== item.attachment);
123
+ delete item.attachment;
124
+ }
125
+
126
+ function attach(shot: Shot): string {
127
+ const attachment: Attachment = { id: uid(), mime: 'image/png', ...shot };
128
+ draft.attachments.push(attachment);
129
+ return attachment.id;
130
+ }
131
+
132
+ async function focusItem(id: string) {
133
+ await tick();
134
+ part(`[data-item="${id}"]`)?.focus();
135
+ }
136
+
137
+ function beginPick() {
138
+ if (busy) return;
139
+ error = draft.items.length >= LIMITS.items ? L.full : '';
140
+ picking = !error;
141
+ }
142
+
143
+ function onMove(event: MouseEvent) {
144
+ if (!host) return;
145
+ outline =
146
+ elementAt(document, host, event.clientX, event.clientY)?.getBoundingClientRect() ?? null;
147
+ }
148
+
149
+ function onPick(event: MouseEvent) {
150
+ event.preventDefault();
151
+ event.stopPropagation();
152
+ const target = host && elementAt(document, host, event.clientX, event.clientY);
153
+ if (!target) return;
154
+ picking = false;
155
+ void addElement(target);
156
+ }
157
+
158
+ async function addElement(target: Element) {
159
+ if (!host) return;
160
+ const item: ElementItem = {
161
+ id: uid(),
162
+ type: 'element',
163
+ url: location.href,
164
+ comment: '',
165
+ target: describeElement(target),
166
+ };
167
+ busy = 'capture';
168
+ if (draft.attachments.length < LIMITS.attachments) {
169
+ try {
170
+ item.attachment = attach(await capture(window, target, host));
171
+ } catch (err) {
172
+ console.warn('[hmm] screenshot failed', err);
173
+ }
174
+ }
175
+ draft.items.push(item);
176
+ busy = '';
177
+ await focusItem(item.id);
178
+ }
179
+
180
+ async function addShot() {
181
+ if (busy || !host) return;
182
+ if (draft.items.length >= LIMITS.items || draft.attachments.length >= LIMITS.attachments) {
183
+ error = L.full;
184
+ return;
185
+ }
186
+ busy = 'capture';
187
+ error = '';
188
+ try {
189
+ const id = uid();
190
+ const attachment = attach(await capture(window, null, host));
191
+ draft.items.push({ id, type: 'screenshot', url: location.href, comment: '', attachment });
192
+ void focusItem(id);
193
+ } catch (err) {
194
+ console.warn('[hmm] screenshot failed', err);
195
+ error = L.shotFailed;
196
+ }
197
+ busy = '';
198
+ }
199
+
200
+ async function build(): Promise<Envelope | null> {
201
+ const { category, comment, items, attachments } = $state.snapshot(draft) as Draft;
202
+ const used = new Set(items.map((item) => item.attachment));
203
+ const envelope: Envelope = {
204
+ v: ENVELOPE_VERSION,
205
+ id: uid(),
206
+ kind: 'feedback',
207
+ category,
208
+ project: projectKey,
209
+ createdAt: new Date().toISOString(),
210
+ client: { name: '@dorsk/hmm', version: VERSION },
211
+ page: {
212
+ url: location.href,
213
+ title: document.title.slice(0, 300),
214
+ viewport: { width: innerWidth, height: innerHeight },
215
+ userAgent: navigator.userAgent.slice(0, 400),
216
+ language: navigator.language.slice(0, 50),
217
+ },
218
+ context: (await context?.()) ?? {},
219
+ comment: comment.trim(),
220
+ items,
221
+ attachments: attachments.filter((a) => used.has(a.id)),
222
+ };
223
+ const clean = clamp(redactEnvelope(envelope));
224
+ return beforeSend ? await beforeSend(clean) : clean;
225
+ }
226
+
227
+ async function review() {
228
+ if (busy || empty) return;
229
+ error = '';
230
+ pending = null;
231
+ try {
232
+ const envelope = await build();
233
+ if (!envelope) error = L.blocked;
234
+ else {
235
+ const check = validateEnvelope(envelope);
236
+ if (check.ok) pending = envelope;
237
+ else {
238
+ console.warn('[hmm] invalid report', check.errors);
239
+ error = L.failed;
240
+ }
241
+ }
242
+ } catch (err) {
243
+ console.warn('[hmm] could not build the report', err);
244
+ error = L.failed;
245
+ }
246
+ if (pending) view = 'review';
247
+ }
248
+
249
+ function finish(sent: boolean) {
250
+ busy = '';
251
+ pending = null;
252
+ draft = emptyDraft();
253
+ delivered = sent;
254
+ message = sent ? L.sent : L.queued;
255
+ view = 'done';
256
+ }
257
+
258
+ async function send() {
259
+ const envelope = pending;
260
+ if (!envelope || busy) return;
261
+ busy = 'send';
262
+ error = '';
263
+ try {
264
+ await deliver(envelope);
265
+ } catch (err) {
266
+ console.warn('[hmm] sending failed', err);
267
+ if (isRetryable(err) && local && enqueue(local, outboxKey, envelope)) finish(false);
268
+ else {
269
+ busy = '';
270
+ error = L.failed;
271
+ }
272
+ return;
273
+ }
274
+ finish(true);
275
+ onSent?.(envelope);
276
+ }
277
+
278
+ export function show() {
279
+ open = true;
280
+ }
281
+
282
+ export function hide() {
283
+ open = false;
284
+ void tick().then(() => part('[data-id="launcher"]')?.focus());
285
+ }
286
+
287
+ export function isOpen() {
288
+ return open;
289
+ }
290
+
291
+ function onKey(event: KeyboardEvent) {
292
+ if (event.key !== 'Escape' || !open || picking) return;
293
+ event.stopPropagation();
294
+ hide();
295
+ }
296
+
297
+ $effect(() => {
298
+ if (open) void tick().then(() => part('[data-id="comment"]')?.focus());
299
+ else
300
+ untrack(() => {
301
+ picking = false;
302
+ if (view === 'done') view = 'edit';
303
+ });
304
+ });
305
+
306
+ $effect(() => {
307
+ if (!picking) return;
308
+ const cancel = (event: KeyboardEvent) => {
309
+ if (event.key !== 'Escape') return;
310
+ event.preventDefault();
311
+ event.stopPropagation();
312
+ picking = false;
313
+ };
314
+ document.addEventListener('keydown', cancel, true);
315
+ return () => {
316
+ document.removeEventListener('keydown', cancel, true);
317
+ outline = null;
318
+ };
319
+ });
320
+
321
+ $effect(placePins);
322
+
323
+ $effect(() => {
324
+ if (ready) saveDraft(session, draftKey, $state.snapshot(draft) as Draft);
325
+ });
326
+
327
+ onMount(() => {
328
+ session = storageOf(() => sessionStorage);
329
+ local = storageOf(() => localStorage);
330
+ draft = loadDraft(session, draftKey);
331
+ ready = true;
332
+
333
+ const schedulePins = () => {
334
+ cancelAnimationFrame(frame);
335
+ frame = requestAnimationFrame(placePins);
336
+ };
337
+ const flushOutbox = () => {
338
+ if (local) void flush(local, outboxKey, deliver).catch(() => {});
339
+ };
340
+ addEventListener('scroll', schedulePins, { capture: true, passive: true });
341
+ addEventListener('resize', schedulePins);
342
+ addEventListener('online', flushOutbox);
343
+ flushOutbox();
344
+ return () => {
345
+ cancelAnimationFrame(frame);
346
+ removeEventListener('scroll', schedulePins, { capture: true });
347
+ removeEventListener('resize', schedulePins);
348
+ removeEventListener('online', flushOutbox);
349
+ };
350
+ });
351
+ </script>
352
+
353
+ {#snippet itemHead(item: Item, n: number)}
354
+ <Badge tone="accent" size="sm" numeric>{n}</Badge>
355
+ <Text tone="muted" size="sm" truncate grow data-id="item-label">{titleOf(item)}</Text>
356
+ {/snippet}
357
+
358
+ <div
359
+ bind:this={host}
360
+ data-hmm
361
+ class="hmm"
362
+ class:left={position === 'bottom-left'}
363
+ role="presentation"
364
+ onkeydown={onKey}
365
+ >
366
+ {#if launcher && !open && !picking}
367
+ <div class="corner">
368
+ <Button pill data-id="launcher" onclick={show}>{L.launcher}</Button>
369
+ </div>
370
+ {/if}
371
+
372
+ {#if open && !picking}
373
+ <div class="corner panel">
374
+ <Card
375
+ as="section"
376
+ padding="none"
377
+ surface="raised"
378
+ role="dialog"
379
+ aria-label={L.title}
380
+ data-id="panel"
381
+ style="display: flex; flex-direction: column; flex: 1; min-height: 0"
382
+ >
383
+ <header class="head">
384
+ <Heading level={2} size="sm">{L.title}</Heading>
385
+ <IconButton icon="x" label={L.close} data-id="close" onclick={hide} />
386
+ </header>
387
+
388
+ {#if view === 'review' && pending}
389
+ <div class="body" data-id="review-body">
390
+ <Text as="p" tone="muted" size="sm">{L.reviewIntro}</Text>
391
+ <KeyValue {rows} />
392
+ {#each pending.items as item, i (item.id)}
393
+ {@const image = imageOf(pending.attachments, item)}
394
+ <Card padding="sm" gap="var(--sp-2)">
395
+ <Cluster wrap={false}>{@render itemHead(item, i + 1)}</Cluster>
396
+ {#if item.comment}<Text as="p" wrap="anywhere">{item.comment}</Text>{/if}
397
+ {#if image}<img class="shot" src={image} alt={titleOf(item)} />{/if}
398
+ </Card>
399
+ {/each}
400
+ {#if Object.keys(pending.context).length > 0}
401
+ <Disclosure size="compact">
402
+ {#snippet header()}{L.context}{/snippet}
403
+ <CodeBlock
404
+ code={JSON.stringify(pending.context, null, 2)}
405
+ lang="json"
406
+ wrap
407
+ copy={false}
408
+ maxHeight="12rem"
409
+ />
410
+ </Disclosure>
411
+ {/if}
412
+ <Disclosure size="compact" data-id="raw">
413
+ {#snippet header()}{L.raw}{/snippet}
414
+ <CodeBlock code={readable(pending)} lang="json" wrap copy={false} maxHeight="12rem" />
415
+ </Disclosure>
416
+ {#if error}<Callout tone="danger" data-id="error">{error}</Callout>{/if}
417
+ </div>
418
+ <footer class="foot">
419
+ <Button
420
+ data-id="back"
421
+ disabled={!!busy}
422
+ onclick={() => {
423
+ view = 'edit';
424
+ error = '';
425
+ }}
426
+ >
427
+ {L.back}
428
+ </Button>
429
+ <Button variant="primary" data-id="send" loading={busy === 'send'} onclick={send}>
430
+ {L.send}
431
+ </Button>
432
+ </footer>
433
+ {:else if view === 'done'}
434
+ <div class="body">
435
+ <Callout tone={delivered ? 'ok' : 'warn'} data-id="message">{message}</Callout>
436
+ </div>
437
+ <footer class="foot end">
438
+ <Button variant="primary" data-id="done" onclick={hide}>{L.done}</Button>
439
+ </footer>
440
+ {:else}
441
+ <div class="body">
442
+ <SegmentedControl
443
+ label={L.category}
444
+ size="sm"
445
+ options={categories}
446
+ value={draft.category}
447
+ onchange={(value) => {
448
+ draft.category = value as Category;
449
+ }}
450
+ />
451
+ <Textarea
452
+ block
453
+ rows={3}
454
+ maxlength={4000}
455
+ data-id="comment"
456
+ aria-label={L.comment}
457
+ placeholder={L.commentPlaceholder}
458
+ bind:value={draft.comment}
459
+ />
460
+ {#each draft.items as item, i (item.id)}
461
+ {@const image = imageOf(draft.attachments, item)}
462
+ <Card padding="sm" gap="var(--sp-2)" data-id="item">
463
+ <Cluster wrap={false}>
464
+ {@render itemHead(item, i + 1)}
465
+ <IconButton
466
+ icon="x"
467
+ box="sm"
468
+ label={L.remove}
469
+ data-id="remove"
470
+ onclick={() => removeItem(item)}
471
+ />
472
+ </Cluster>
473
+ {#if image}
474
+ <Cluster wrap={false} align="start">
475
+ <img class="shot" src={image} alt={titleOf(item)} />
476
+ {#if item.type === 'element'}
477
+ <IconButton
478
+ icon="trash"
479
+ box="sm"
480
+ label={L.removeImage}
481
+ data-id="remove-image"
482
+ onclick={() => removeImage(item)}
483
+ />
484
+ {/if}
485
+ </Cluster>
486
+ {/if}
487
+ <Textarea
488
+ block
489
+ rows={2}
490
+ maxlength={4000}
491
+ data-id="item-comment"
492
+ data-item={item.id}
493
+ aria-label="{L.comment} {i + 1}"
494
+ placeholder={L.itemPlaceholder}
495
+ bind:value={item.comment}
496
+ />
497
+ </Card>
498
+ {/each}
499
+ <Cluster>
500
+ <Button size="sm" data-id="pick" disabled={!!busy} onclick={beginPick}>
501
+ {L.pick}
502
+ </Button>
503
+ <Button size="sm" data-id="shot" loading={busy === 'capture'} onclick={addShot}>
504
+ {L.shot}
505
+ </Button>
506
+ </Cluster>
507
+ {#if busy === 'capture'}
508
+ <Text as="p" tone="muted" size="sm" role="status">{L.capturing}</Text>
509
+ {/if}
510
+ {#if error}<Callout tone="danger" data-id="error">{error}</Callout>{/if}
511
+ </div>
512
+ <footer class="foot">
513
+ <Button
514
+ data-id="discard"
515
+ disabled={!!busy || empty}
516
+ onclick={() => {
517
+ draft = emptyDraft();
518
+ error = '';
519
+ }}
520
+ >
521
+ {L.discard}
522
+ </Button>
523
+ <Button variant="primary" data-id="review" disabled={!!busy || empty} onclick={review}>
524
+ {L.review}
525
+ </Button>
526
+ </footer>
527
+ {/if}
528
+ </Card>
529
+ </div>
530
+ {/if}
531
+
532
+ <div aria-hidden="true">
533
+ {#each pins as pin (pin.n)}
534
+ <div class="pin" style:transform="translate({pin.x}px, {pin.y}px)">
535
+ <Badge tone="accent" size="sm" numeric>{pin.n}</Badge>
536
+ </div>
537
+ {/each}
538
+ </div>
539
+
540
+ {#if picking}
541
+ <div class="glass" role="presentation" onmousemove={onMove} onclick={onPick}></div>
542
+ {#if outline}
543
+ <div
544
+ class="outline"
545
+ style:transform="translate({outline.left}px, {outline.top}px)"
546
+ style:width="{outline.width}px"
547
+ style:height="{outline.height}px"
548
+ ></div>
549
+ {/if}
550
+ <div class="hint" role="status">
551
+ <Card padding="sm" surface="raised">{L.pickHint}</Card>
552
+ </div>
553
+ {/if}
554
+ </div>
555
+
556
+ <style>
557
+ .hmm {
558
+ display: contents;
559
+ --z: var(--hmm-z-index, var(--z-toast, 300));
560
+ }
561
+ .corner {
562
+ position: fixed;
563
+ right: var(--sp-4);
564
+ bottom: var(--sp-4);
565
+ z-index: var(--z);
566
+ }
567
+ .left .corner {
568
+ right: auto;
569
+ left: var(--sp-4);
570
+ }
571
+ .panel {
572
+ display: flex;
573
+ flex-direction: column;
574
+ width: 24rem;
575
+ max-width: calc(100vw - 2 * var(--sp-4));
576
+ max-height: min(42rem, calc(100dvh - 2 * var(--sp-4)));
577
+ border-radius: var(--r-lg);
578
+ box-shadow: var(--shadow-lg);
579
+ }
580
+ .head,
581
+ .foot {
582
+ display: flex;
583
+ align-items: center;
584
+ justify-content: space-between;
585
+ gap: var(--sp-2);
586
+ padding: var(--sp-2) var(--sp-3);
587
+ }
588
+ .head {
589
+ border-bottom: var(--edge-width) solid var(--surface-edge);
590
+ }
591
+ .foot {
592
+ border-top: var(--edge-width) solid var(--surface-edge);
593
+ }
594
+ .foot.end {
595
+ justify-content: flex-end;
596
+ }
597
+ .body {
598
+ display: flex;
599
+ flex-direction: column;
600
+ gap: var(--sp-3);
601
+ min-height: 0;
602
+ padding: var(--sp-3);
603
+ overflow-y: auto;
604
+ }
605
+ .shot {
606
+ display: block;
607
+ align-self: flex-start;
608
+ min-width: 0;
609
+ max-width: 100%;
610
+ max-height: 10rem;
611
+ border: var(--edge-width) solid var(--surface-edge);
612
+ border-radius: var(--r-sm);
613
+ }
614
+ .pin {
615
+ position: fixed;
616
+ top: 0;
617
+ left: 0;
618
+ z-index: calc(var(--z) - 1);
619
+ border-radius: var(--r-pill);
620
+ background: var(--surface);
621
+ box-shadow: var(--shadow-md);
622
+ pointer-events: none;
623
+ }
624
+ .glass {
625
+ position: fixed;
626
+ inset: 0;
627
+ z-index: var(--z);
628
+ cursor: crosshair;
629
+ }
630
+ .outline {
631
+ position: fixed;
632
+ top: 0;
633
+ left: 0;
634
+ z-index: var(--z);
635
+ border: 2px solid var(--accent);
636
+ border-radius: var(--r-xs);
637
+ background: color-mix(in srgb, var(--accent) 15%, transparent);
638
+ pointer-events: none;
639
+ }
640
+ .hint {
641
+ position: fixed;
642
+ top: var(--sp-4);
643
+ left: 50%;
644
+ z-index: var(--z);
645
+ transform: translateX(-50%);
646
+ border-radius: var(--r-lg);
647
+ box-shadow: var(--shadow-lg);
648
+ pointer-events: none;
649
+ }
650
+ </style>
@@ -0,0 +1,11 @@
1
+ import { type Options } from './core.ts';
2
+ type $$ComponentProps = Options & {
3
+ open?: boolean;
4
+ };
5
+ declare const Hmm: import("svelte").Component<$$ComponentProps, {
6
+ show: () => void;
7
+ hide: () => void;
8
+ isOpen: () => boolean;
9
+ }, "open">;
10
+ type Hmm = ReturnType<typeof Hmm>;
11
+ export default Hmm;
package/dist/core.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { type Envelope } from './envelope.ts';
2
+ import type { Labels } from './labels.ts';
3
+ import { type Transport } from './transport.ts';
4
+ export type Options = {
5
+ /** Public identifier of the project the reports belong to. */
6
+ projectKey: string;
7
+ /** Base URL of the ingest server; reports go to `<endpoint>/v1/reports`. */
8
+ endpoint?: string;
9
+ /** Replaces the HTTP POST, for hosts that relay reports through their own backend. */
10
+ transport?: Transport;
11
+ /** Extra host data added to every report: app version, route name, install id. */
12
+ context?: () => Record<string, unknown> | Promise<Record<string, unknown>>;
13
+ /** Last chance to change a report, or to drop it by returning null. Runs before the review. */
14
+ beforeSend?: (envelope: Envelope) => Envelope | null | Promise<Envelope | null>;
15
+ /** Set to false to hide the floating button and open the panel from your own UI. */
16
+ launcher?: boolean;
17
+ position?: 'bottom-right' | 'bottom-left';
18
+ labels?: Partial<Labels>;
19
+ onSent?: (envelope: Envelope) => void;
20
+ };
21
+ export type Handle = {
22
+ open(): void;
23
+ close(): void;
24
+ destroy(): void;
25
+ readonly isOpen: boolean;
26
+ };
27
+ export declare const uid: () => string;
28
+ export declare function storageOf(get: () => Storage): Storage | null;
29
+ export declare function clamp(envelope: Envelope): Envelope;
30
+ /** Throws when the options cannot deliver a report. */
31
+ export declare function transportOf(options: Pick<Options, 'projectKey' | 'endpoint' | 'transport'>): Transport;
32
+ /** The report as shown to the user: image data replaced by its size. */
33
+ export declare const readable: (envelope: Envelope) => string;