@kolibri-day/feedback 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.
- package/LICENSE +21 -0
- package/README.md +289 -2
- package/dist/cjs/adapters/capture/capture.d.ts +112 -0
- package/dist/cjs/adapters/capture/capture.js +398 -0
- package/dist/cjs/adapters/capture/client-context.d.ts +9 -0
- package/dist/cjs/adapters/capture/client-context.js +41 -0
- package/dist/cjs/adapters/capture/reporter.d.ts +42 -0
- package/dist/cjs/adapters/capture/reporter.js +83 -0
- package/dist/cjs/adapters/capture/submit.d.ts +30 -0
- package/dist/cjs/adapters/capture/submit.js +32 -0
- package/dist/cjs/adapters/kolibri/env.d.ts +16 -0
- package/dist/cjs/adapters/kolibri/env.js +40 -0
- package/dist/cjs/adapters/kolibri/preflight.d.ts +6 -0
- package/dist/cjs/adapters/kolibri/preflight.js +536 -0
- package/dist/cjs/adapters/kolibri/target.d.ts +100 -0
- package/dist/cjs/adapters/kolibri/target.js +175 -0
- package/dist/cjs/adapters/react/reporter.d.ts +25 -0
- package/dist/cjs/adapters/react/reporter.js +54 -0
- package/dist/cjs/browser.d.ts +4 -0
- package/dist/cjs/browser.js +22 -0
- package/dist/cjs/index.d.ts +6 -0
- package/dist/cjs/index.js +25 -0
- package/dist/cjs/kernel/feedback/dispatch.d.ts +64 -0
- package/dist/cjs/kernel/feedback/dispatch.js +93 -0
- package/dist/cjs/kernel/feedback/fake-target.d.ts +58 -0
- package/dist/cjs/kernel/feedback/fake-target.js +87 -0
- package/dist/cjs/kernel/feedback/files.d.ts +6 -0
- package/dist/cjs/kernel/feedback/files.js +21 -0
- package/dist/cjs/kernel/feedback/format.d.ts +53 -0
- package/dist/cjs/kernel/feedback/format.js +140 -0
- package/dist/cjs/kernel/feedback/input.d.ts +79 -0
- package/dist/cjs/kernel/feedback/input.js +157 -0
- package/dist/cjs/kernel/feedback/messages.d.ts +76 -0
- package/dist/cjs/kernel/feedback/messages.js +138 -0
- package/dist/cjs/kernel/feedback/notice.d.ts +10 -0
- package/dist/cjs/kernel/feedback/notice.js +19 -0
- package/dist/cjs/kernel/feedback/types.d.ts +158 -0
- package/dist/cjs/kernel/feedback/types.js +47 -0
- package/dist/cjs/kolibri.d.ts +2 -0
- package/dist/cjs/kolibri.js +12 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/preflight-cli.d.ts +2 -0
- package/dist/cjs/preflight-cli.js +10 -0
- package/dist/cjs/react.d.ts +3 -0
- package/dist/cjs/react.js +9 -0
- package/dist/cjs/server.d.ts +4 -0
- package/dist/cjs/server.js +18 -0
- package/dist/cjs/wiring.d.ts +38 -0
- package/dist/cjs/wiring.js +72 -0
- package/dist/esm/adapters/capture/capture.d.ts +112 -0
- package/dist/esm/adapters/capture/capture.js +357 -0
- package/dist/esm/adapters/capture/client-context.d.ts +9 -0
- package/dist/esm/adapters/capture/client-context.js +38 -0
- package/dist/esm/adapters/capture/reporter.d.ts +42 -0
- package/dist/esm/adapters/capture/reporter.js +79 -0
- package/dist/esm/adapters/capture/submit.d.ts +30 -0
- package/dist/esm/adapters/capture/submit.js +28 -0
- package/dist/esm/adapters/kolibri/env.d.ts +16 -0
- package/dist/esm/adapters/kolibri/env.js +36 -0
- package/dist/esm/adapters/kolibri/preflight.d.ts +6 -0
- package/dist/esm/adapters/kolibri/preflight.js +533 -0
- package/dist/esm/adapters/kolibri/target.d.ts +100 -0
- package/dist/esm/adapters/kolibri/target.js +170 -0
- package/dist/esm/adapters/react/reporter.d.ts +25 -0
- package/dist/esm/adapters/react/reporter.js +49 -0
- package/dist/esm/browser.d.ts +4 -0
- package/dist/esm/browser.js +7 -0
- package/dist/esm/index.d.ts +6 -0
- package/dist/esm/index.js +9 -0
- package/dist/esm/kernel/feedback/dispatch.d.ts +64 -0
- package/dist/esm/kernel/feedback/dispatch.js +89 -0
- package/dist/esm/kernel/feedback/fake-target.d.ts +58 -0
- package/dist/esm/kernel/feedback/fake-target.js +82 -0
- package/dist/esm/kernel/feedback/files.d.ts +6 -0
- package/dist/esm/kernel/feedback/files.js +16 -0
- package/dist/esm/kernel/feedback/format.d.ts +53 -0
- package/dist/esm/kernel/feedback/format.js +133 -0
- package/dist/esm/kernel/feedback/input.d.ts +79 -0
- package/dist/esm/kernel/feedback/input.js +150 -0
- package/dist/esm/kernel/feedback/messages.d.ts +76 -0
- package/dist/esm/kernel/feedback/messages.js +135 -0
- package/dist/esm/kernel/feedback/notice.d.ts +10 -0
- package/dist/esm/kernel/feedback/notice.js +15 -0
- package/dist/esm/kernel/feedback/types.d.ts +158 -0
- package/dist/esm/kernel/feedback/types.js +42 -0
- package/dist/esm/kolibri.d.ts +2 -0
- package/dist/esm/kolibri.js +3 -0
- package/dist/esm/preflight-cli.d.ts +2 -0
- package/dist/esm/preflight-cli.js +8 -0
- package/dist/esm/react.d.ts +3 -0
- package/dist/esm/react.js +3 -0
- package/dist/esm/server.d.ts +4 -0
- package/dist/esm/server.js +6 -0
- package/dist/esm/wiring.d.ts +38 -0
- package/dist/esm/wiring.js +68 -0
- package/fixtures/preflight-screenshot.webp +0 -0
- package/package.json +82 -4
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Longest edge of an uploaded screenshot, in pixels.
|
|
3
|
+
*
|
|
4
|
+
* DO NOT LOWER THIS BACK toward ~1600 by reasoning about photo file sizes — that was
|
|
5
|
+
* the original mistake and it produced a real, reported defect.
|
|
6
|
+
*
|
|
7
|
+
* This started at 1600, sized against "a full-page PNG can be 2-6 MB". That is true of
|
|
8
|
+
* PHOTOGRAPHIC content and false of a UI screenshot in WebP: measured on a
|
|
9
|
+
* data-table page, the full-resolution 2560x1440 capture encodes to ~30 KB — against
|
|
10
|
+
* a 1 MB target. So the old cap bought nothing and cost legibility: a 2560-wide
|
|
11
|
+
* desktop was downscaled by 0.625, turning 12px badge text into 7.5px and smearing
|
|
12
|
+
* white-on-near-black labels into unreadable dark blocks.
|
|
13
|
+
*
|
|
14
|
+
* That was confirmed to be the RESIZE, not the encoder: the same downscale applied to
|
|
15
|
+
* a pixel-perfect browser screenshot smears identically, and lossless PNG at 1600
|
|
16
|
+
* smears just as badly. Only stopping the downscale fixes it.
|
|
17
|
+
*
|
|
18
|
+
* 2560 rather than unlimited: it still bounds an ultrawide or scaled-up display,
|
|
19
|
+
* where a capture genuinely can get large. Everything below it is now 1:1.
|
|
20
|
+
*/
|
|
21
|
+
export declare const MAX_EDGE_PX = 2560;
|
|
22
|
+
/** Target ceiling for the encoded blob; the encoder steps quality down to reach it. */
|
|
23
|
+
export declare const TARGET_MAX_BYTES = 1000000;
|
|
24
|
+
/** Absolute client-side refusal (the server independently enforces its own cap). */
|
|
25
|
+
export declare const HARD_MAX_BYTES = 2000000;
|
|
26
|
+
/**
|
|
27
|
+
* THE EXCLUSION CONTRACT: anything carrying this attribute is dropped from the capture
|
|
28
|
+
* by both renderers — the report dialog and its backdrop, the trigger button, a menu
|
|
29
|
+
* mid-animation, and any app data that must never appear in a screenshot.
|
|
30
|
+
*/
|
|
31
|
+
export declare const REDACT_ATTRIBUTE = "data-feedback-redact";
|
|
32
|
+
export interface CaptureOptions {
|
|
33
|
+
/** Attribute that excludes a subtree from the capture. Default: `data-feedback-redact`. */
|
|
34
|
+
redactAttribute?: string;
|
|
35
|
+
/** Longest edge in pixels. Default: MAX_EDGE_PX — read its comment before lowering it. */
|
|
36
|
+
maxEdge?: number;
|
|
37
|
+
/** Encoded size the quality ladder aims for. Default: TARGET_MAX_BYTES. */
|
|
38
|
+
targetBytes?: number;
|
|
39
|
+
/** Larger results are discarded (the report goes without a screenshot). Default: HARD_MAX_BYTES. */
|
|
40
|
+
hardMaxBytes?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface CaptureResult {
|
|
43
|
+
blob: Blob;
|
|
44
|
+
width: number;
|
|
45
|
+
height: number;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Target dimensions after downscaling. Pure — this is the arithmetic the tests pin
|
|
49
|
+
* down, separated from any canvas.
|
|
50
|
+
*
|
|
51
|
+
* Never upscales: a small phone viewport is already the size it is, and enlarging it
|
|
52
|
+
* would inflate the payload while adding exactly zero detail.
|
|
53
|
+
*/
|
|
54
|
+
export declare function fitWithin(width: number, height: number, maxEdge?: number): {
|
|
55
|
+
width: number;
|
|
56
|
+
height: number;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Did the canvas actually encode the type we asked for?
|
|
60
|
+
*
|
|
61
|
+
* A canvas asked for a type it cannot encode does not fail — it silently returns PNG
|
|
62
|
+
* instead. So the requested type is not evidence of anything; `blob.type` is. An
|
|
63
|
+
* empty type (very old engines) is taken at its word.
|
|
64
|
+
*/
|
|
65
|
+
export declare function isRequestedType(produced: string, requested: string): boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Encode, stepping quality down until the blob fits `targetBytes`.
|
|
68
|
+
*
|
|
69
|
+
* Formats in order of preference: WebP (visibly better than JPEG on UI text at the
|
|
70
|
+
* same size), then JPEG. A format counts as supported only if the browser actually
|
|
71
|
+
* produced it (isRequestedType): a browser that cannot encode WebP hands back a PNG
|
|
72
|
+
* rather than null, and taking that PNG as "WebP done" used to skip JPEG entirely —
|
|
73
|
+
* leaving such browsers with an uncompressible PNG and a quality ladder that did
|
|
74
|
+
* nothing. A substituted PNG is kept only as the last resort, when neither lossy
|
|
75
|
+
* format is available.
|
|
76
|
+
*
|
|
77
|
+
* Terminates unconditionally: the ladder is finite, and the smallest blob of the
|
|
78
|
+
* supported format is returned whether or not it met the target — the caller (and the
|
|
79
|
+
* server) enforce the hard cap.
|
|
80
|
+
*/
|
|
81
|
+
export declare function encodeWithinBudget(canvas: HTMLCanvasElement, targetBytes?: number): Promise<Blob | null>;
|
|
82
|
+
/**
|
|
83
|
+
* A render is "usable" if it has real pixels. foreignObject failures are quiet — a
|
|
84
|
+
* browser that chokes on the serialized SVG hands back a blank or zero-sized canvas
|
|
85
|
+
* rather than throwing — so a try/catch alone would let a white rectangle through and
|
|
86
|
+
* call it a screenshot. This is what makes the fallback trigger on the failure mode
|
|
87
|
+
* that actually happens.
|
|
88
|
+
*/
|
|
89
|
+
export declare function looksUsable(canvas: HTMLCanvasElement): boolean;
|
|
90
|
+
/**
|
|
91
|
+
* Capture the CURRENT VIEWPORT.
|
|
92
|
+
*
|
|
93
|
+
* Viewport, not full page, on purpose: what the reporter is looking at is what they
|
|
94
|
+
* are reporting, a full-page render multiplies the payload, and sticky/fixed elements
|
|
95
|
+
* render badly when the capture is taller than the window.
|
|
96
|
+
*
|
|
97
|
+
* THE EXCLUSION CONTRACT: anything that must not appear in the shot carries
|
|
98
|
+
* REDACT_ATTRIBUTE — both renderers drop those subtrees. That attribute, NOT
|
|
99
|
+
* call ordering, is what keeps the report dialog out of the screenshot: the dialog
|
|
100
|
+
* (panel and overlay both marked) opens in the same tick as the capture starts,
|
|
101
|
+
* because a full-page render measures in SECONDS (~1.5-4.5s at ~2000 elements) and
|
|
102
|
+
* holding the dialog for it made the button read as frozen. Two shipped mistakes
|
|
103
|
+
* inform this design: v1 opened the dialog unredacted before capturing (the form was
|
|
104
|
+
* in every screenshot); v2 awaited the capture before opening (correct pixels, 5-8s
|
|
105
|
+
* of dead air). Anything new that must never be captured — a future shake-to-report
|
|
106
|
+
* toast, a debug panel — gets the attribute, not a sequencing rule.
|
|
107
|
+
*
|
|
108
|
+
* Returns null on any failure. A missing screenshot degrades the report; a thrown
|
|
109
|
+
* error would lose it entirely, and the reporter's text is the part that cannot be
|
|
110
|
+
* re-derived.
|
|
111
|
+
*/
|
|
112
|
+
export declare function captureViewport(options?: CaptureOptions): Promise<CaptureResult | null>;
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// Client-side screenshot capture of the CURRENT VIEWPORT.
|
|
2
|
+
//
|
|
3
|
+
// TWO RENDERERS, TRIED IN ORDER — not a platform split.
|
|
4
|
+
// 1. modern-screenshot (foreignObject): serializes the DOM into an SVG and lets the
|
|
5
|
+
// BROWSER'S OWN ENGINE draw it. Measured against Chromium's real screenshot of
|
|
6
|
+
// the same page it scores a mean absolute error of 0.51 with 0.52% of pixels
|
|
7
|
+
// materially off, versus html2canvas's 2.48 / 2.05% — roughly 5x closer. It also
|
|
8
|
+
// gets natively right the things html2canvas fabricates: a closed <details> stays
|
|
9
|
+
// closed, <summary> keeps its ▶ instead of a bogus "1.", native <select> arrows
|
|
10
|
+
// and checkboxes render as themselves.
|
|
11
|
+
// 2. html2canvas: the fallback, kept because foreignObject has a history of Safari
|
|
12
|
+
// bugs that CANNOT be tested from a headless environment, and iPhones are exactly
|
|
13
|
+
// what people report bugs from. Selection is by OUTCOME, not by sniffing the platform: if the
|
|
14
|
+
// first renderer throws or returns something unusable, we quietly use the second.
|
|
15
|
+
// A user-agent test would guess; this observes.
|
|
16
|
+
//
|
|
17
|
+
// WHY NOT getDisplayMedia (re-evaluated after repeated fidelity complaints, and still
|
|
18
|
+
// the answer): it is genuinely pixel-exact, and that is a real advantage. But it is
|
|
19
|
+
// UNSUPPORTED ON iOS SAFARI — undefined, not degraded — so it can never be the only
|
|
20
|
+
// mechanism for this userbase; it prompts on every single report; and it can make the
|
|
21
|
+
// PRIVACY POSTURE WORSE rather than better, because `preferCurrentTab` is only a hint
|
|
22
|
+
// and one mis-click shares an entire desktop (mail, chat) into a shared tracker
|
|
23
|
+
// project. A DOM render is bounded to this app's own markup by construction. If
|
|
24
|
+
// pixel-exactness is still wanted, the next step is an OPT-IN "take a real
|
|
25
|
+
// screenshot" button on desktop, leaving this silent path as the default everywhere.
|
|
26
|
+
//
|
|
27
|
+
// Remaining honest limits of any DOM render: cross-origin images cannot be inlined, so
|
|
28
|
+
// a map view still has holes where third-party tiles are (the surrounding chrome
|
|
29
|
+
// captures fine, which is usually where the bug is).
|
|
30
|
+
//
|
|
31
|
+
// Both renderers are DYNAMICALLY IMPORTED at capture time, so neither enters the main
|
|
32
|
+
// bundle — people on mobile data do not pay for a feature they have not opened. They
|
|
33
|
+
// are optional peer dependencies of this package: install `modern-screenshot` and
|
|
34
|
+
// `html2canvas` in the app that captures.
|
|
35
|
+
/**
|
|
36
|
+
* Longest edge of an uploaded screenshot, in pixels.
|
|
37
|
+
*
|
|
38
|
+
* DO NOT LOWER THIS BACK toward ~1600 by reasoning about photo file sizes — that was
|
|
39
|
+
* the original mistake and it produced a real, reported defect.
|
|
40
|
+
*
|
|
41
|
+
* This started at 1600, sized against "a full-page PNG can be 2-6 MB". That is true of
|
|
42
|
+
* PHOTOGRAPHIC content and false of a UI screenshot in WebP: measured on a
|
|
43
|
+
* data-table page, the full-resolution 2560x1440 capture encodes to ~30 KB — against
|
|
44
|
+
* a 1 MB target. So the old cap bought nothing and cost legibility: a 2560-wide
|
|
45
|
+
* desktop was downscaled by 0.625, turning 12px badge text into 7.5px and smearing
|
|
46
|
+
* white-on-near-black labels into unreadable dark blocks.
|
|
47
|
+
*
|
|
48
|
+
* That was confirmed to be the RESIZE, not the encoder: the same downscale applied to
|
|
49
|
+
* a pixel-perfect browser screenshot smears identically, and lossless PNG at 1600
|
|
50
|
+
* smears just as badly. Only stopping the downscale fixes it.
|
|
51
|
+
*
|
|
52
|
+
* 2560 rather than unlimited: it still bounds an ultrawide or scaled-up display,
|
|
53
|
+
* where a capture genuinely can get large. Everything below it is now 1:1.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_EDGE_PX = 2560;
|
|
56
|
+
/** Target ceiling for the encoded blob; the encoder steps quality down to reach it. */
|
|
57
|
+
export const TARGET_MAX_BYTES = 1_000_000;
|
|
58
|
+
/** Absolute client-side refusal (the server independently enforces its own cap). */
|
|
59
|
+
export const HARD_MAX_BYTES = 2_000_000;
|
|
60
|
+
/**
|
|
61
|
+
* THE EXCLUSION CONTRACT: anything carrying this attribute is dropped from the capture
|
|
62
|
+
* by both renderers — the report dialog and its backdrop, the trigger button, a menu
|
|
63
|
+
* mid-animation, and any app data that must never appear in a screenshot.
|
|
64
|
+
*/
|
|
65
|
+
export const REDACT_ATTRIBUTE = 'data-feedback-redact';
|
|
66
|
+
/**
|
|
67
|
+
* Encoder quality ladder. 0.92 is the working value — fine light-on-dark text is the
|
|
68
|
+
* first thing to go, and it survives cleanly there. The lower rungs are a BACKSTOP for
|
|
69
|
+
* an unexpectedly heavy page (an image-rich view), not the normal path; a typical UI
|
|
70
|
+
* screenshot never leaves the first rung.
|
|
71
|
+
*/
|
|
72
|
+
const QUALITY_STEPS = [0.92, 0.8, 0.65];
|
|
73
|
+
/**
|
|
74
|
+
* Target dimensions after downscaling. Pure — this is the arithmetic the tests pin
|
|
75
|
+
* down, separated from any canvas.
|
|
76
|
+
*
|
|
77
|
+
* Never upscales: a small phone viewport is already the size it is, and enlarging it
|
|
78
|
+
* would inflate the payload while adding exactly zero detail.
|
|
79
|
+
*/
|
|
80
|
+
export function fitWithin(width, height, maxEdge = MAX_EDGE_PX) {
|
|
81
|
+
const longest = Math.max(width, height);
|
|
82
|
+
if (longest <= maxEdge || longest === 0)
|
|
83
|
+
return { width, height };
|
|
84
|
+
const factor = maxEdge / longest;
|
|
85
|
+
// Floor + a floor of 1: rounding could land a hair over maxEdge, and a zero
|
|
86
|
+
// dimension would make the canvas throw.
|
|
87
|
+
return { width: Math.max(1, Math.floor(width * factor)), height: Math.max(1, Math.floor(height * factor)) };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Did the canvas actually encode the type we asked for?
|
|
91
|
+
*
|
|
92
|
+
* A canvas asked for a type it cannot encode does not fail — it silently returns PNG
|
|
93
|
+
* instead. So the requested type is not evidence of anything; `blob.type` is. An
|
|
94
|
+
* empty type (very old engines) is taken at its word.
|
|
95
|
+
*/
|
|
96
|
+
export function isRequestedType(produced, requested) {
|
|
97
|
+
return produced === '' || produced === requested;
|
|
98
|
+
}
|
|
99
|
+
/** Draw `source` into a downscaled canvas. Separated so capture() stays readable. */
|
|
100
|
+
function downscale(source, maxEdge) {
|
|
101
|
+
const { width, height } = fitWithin(source.width, source.height, maxEdge);
|
|
102
|
+
if (width === source.width && height === source.height)
|
|
103
|
+
return source;
|
|
104
|
+
const target = document.createElement('canvas');
|
|
105
|
+
target.width = width;
|
|
106
|
+
target.height = height;
|
|
107
|
+
const ctx = target.getContext('2d');
|
|
108
|
+
if (!ctx)
|
|
109
|
+
return source;
|
|
110
|
+
// Smooth downsampling; the default is already 'low' in some engines and text
|
|
111
|
+
// suffers noticeably at these ratios.
|
|
112
|
+
ctx.imageSmoothingEnabled = true;
|
|
113
|
+
ctx.imageSmoothingQuality = 'high';
|
|
114
|
+
ctx.drawImage(source, 0, 0, width, height);
|
|
115
|
+
return target;
|
|
116
|
+
}
|
|
117
|
+
function toBlob(canvas, type, quality) {
|
|
118
|
+
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Encode, stepping quality down until the blob fits `targetBytes`.
|
|
122
|
+
*
|
|
123
|
+
* Formats in order of preference: WebP (visibly better than JPEG on UI text at the
|
|
124
|
+
* same size), then JPEG. A format counts as supported only if the browser actually
|
|
125
|
+
* produced it (isRequestedType): a browser that cannot encode WebP hands back a PNG
|
|
126
|
+
* rather than null, and taking that PNG as "WebP done" used to skip JPEG entirely —
|
|
127
|
+
* leaving such browsers with an uncompressible PNG and a quality ladder that did
|
|
128
|
+
* nothing. A substituted PNG is kept only as the last resort, when neither lossy
|
|
129
|
+
* format is available.
|
|
130
|
+
*
|
|
131
|
+
* Terminates unconditionally: the ladder is finite, and the smallest blob of the
|
|
132
|
+
* supported format is returned whether or not it met the target — the caller (and the
|
|
133
|
+
* server) enforce the hard cap.
|
|
134
|
+
*/
|
|
135
|
+
export async function encodeWithinBudget(canvas, targetBytes = TARGET_MAX_BYTES) {
|
|
136
|
+
let substitute = null;
|
|
137
|
+
for (const type of ['image/webp', 'image/jpeg']) {
|
|
138
|
+
let smallest = null;
|
|
139
|
+
for (const quality of QUALITY_STEPS) {
|
|
140
|
+
const blob = await toBlob(canvas, type, quality);
|
|
141
|
+
if (!blob)
|
|
142
|
+
break; // cannot encode this type at all
|
|
143
|
+
if (!isRequestedType(blob.type, type)) {
|
|
144
|
+
substitute ??= blob; // unsupported — the browser fell back to PNG
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
if (blob.size <= targetBytes)
|
|
148
|
+
return blob;
|
|
149
|
+
if (!smallest || blob.size < smallest.size)
|
|
150
|
+
smallest = blob;
|
|
151
|
+
}
|
|
152
|
+
if (smallest)
|
|
153
|
+
return smallest; // supported, just heavy: the best this format can do
|
|
154
|
+
}
|
|
155
|
+
return substitute;
|
|
156
|
+
}
|
|
157
|
+
/** Should this element be kept out of the capture entirely? */
|
|
158
|
+
function isRedacted(el, attribute) {
|
|
159
|
+
return el.nodeType === 1 && el.hasAttribute?.(attribute) === true;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* A render is "usable" if it has real pixels. foreignObject failures are quiet — a
|
|
163
|
+
* browser that chokes on the serialized SVG hands back a blank or zero-sized canvas
|
|
164
|
+
* rather than throwing — so a try/catch alone would let a white rectangle through and
|
|
165
|
+
* call it a screenshot. This is what makes the fallback trigger on the failure mode
|
|
166
|
+
* that actually happens.
|
|
167
|
+
*/
|
|
168
|
+
export function looksUsable(canvas) {
|
|
169
|
+
if (canvas.width < 2 || canvas.height < 2)
|
|
170
|
+
return false;
|
|
171
|
+
try {
|
|
172
|
+
const ctx = canvas.getContext('2d');
|
|
173
|
+
if (!ctx)
|
|
174
|
+
return false;
|
|
175
|
+
// Sample a coarse grid rather than every pixel: enough to tell "drew something"
|
|
176
|
+
// from "drew nothing", cheap enough to run on every capture.
|
|
177
|
+
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
178
|
+
const step = Math.max(4, Math.floor((canvas.width * canvas.height) / 5000)) * 4;
|
|
179
|
+
let first = -1;
|
|
180
|
+
for (let i = 0; i < data.length; i += step) {
|
|
181
|
+
const lum = (data[i] ?? 0) + (data[i + 1] ?? 0) + (data[i + 2] ?? 0);
|
|
182
|
+
if (first === -1)
|
|
183
|
+
first = lum;
|
|
184
|
+
else if (Math.abs(lum - first) > 12)
|
|
185
|
+
return true; // some variation ⇒ real content
|
|
186
|
+
}
|
|
187
|
+
return false; // uniform ⇒ blank
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Tainted canvas (a cross-origin image got in). Unreadable here, and unencodable
|
|
191
|
+
// later — treat as unusable so the other renderer gets its turn.
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Preferred renderer: modern-screenshot, via foreignObject.
|
|
197
|
+
*
|
|
198
|
+
* The browser draws it, so UA behaviour (a closed `<details>`, `<summary>`'s marker,
|
|
199
|
+
* native form controls) is correct for free — no clone patching required.
|
|
200
|
+
*
|
|
201
|
+
* Scroll handling differs from html2canvas, which takes x/y/scroll options: here the
|
|
202
|
+
* clone is translated by the scroll offset and the output box fixed to the viewport,
|
|
203
|
+
* which crops to the visible region.
|
|
204
|
+
*/
|
|
205
|
+
async function renderWithModernScreenshot(attribute) {
|
|
206
|
+
const { domToCanvas } = await import('modern-screenshot');
|
|
207
|
+
const canvas = await domToCanvas(document.documentElement, {
|
|
208
|
+
width: window.innerWidth,
|
|
209
|
+
height: window.innerHeight,
|
|
210
|
+
backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff',
|
|
211
|
+
scale: 1,
|
|
212
|
+
style: {
|
|
213
|
+
transform: `translate(${-window.scrollX}px, ${-window.scrollY}px)`,
|
|
214
|
+
transformOrigin: 'top left',
|
|
215
|
+
},
|
|
216
|
+
// Same redaction contract as the html2canvas path — `filter` returns false to drop
|
|
217
|
+
// a node. Keeping both honour one attribute is what stops the two renderers from
|
|
218
|
+
// disagreeing about what is sensitive.
|
|
219
|
+
filter: (node) => !(node instanceof Element && isRedacted(node, attribute)),
|
|
220
|
+
});
|
|
221
|
+
return canvas ?? null;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* html2canvas ships CommonJS with an ES-module build beside it. A bundler picks the
|
|
225
|
+
* ES build, where the function IS the default export; Node's own ESM-to-CJS interop
|
|
226
|
+
* wraps `module.exports` once more, so there it sits at `.default.default`. Accept
|
|
227
|
+
* both rather than depend on which one the consuming app's tooling chose.
|
|
228
|
+
*/
|
|
229
|
+
async function loadHtml2Canvas() {
|
|
230
|
+
const mod = (await import('html2canvas'));
|
|
231
|
+
return typeof mod.default === 'function' ? mod.default : mod.default.default;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Fallback renderer: html2canvas.
|
|
235
|
+
*
|
|
236
|
+
* Needs the clone patched (see neutralizeCollapsedDetails) because it re-implements
|
|
237
|
+
* layout from computed styles. That workaround stays here, and ONLY here — deleting it
|
|
238
|
+
* along with the switch to modern-screenshot would quietly reintroduce the original
|
|
239
|
+
* over-painted-filter-panel bug for anyone whose browser lands on this path.
|
|
240
|
+
*/
|
|
241
|
+
async function renderWithHtml2Canvas(attribute) {
|
|
242
|
+
const html2canvas = await loadHtml2Canvas();
|
|
243
|
+
return html2canvas(document.body, {
|
|
244
|
+
// Anything the app marks as sensitive never enters the render — the permanent
|
|
245
|
+
// hook for excluding e.g. a salary column from every screenshot. The trigger
|
|
246
|
+
// button and the report dialog carry the same attribute.
|
|
247
|
+
ignoreElements: (el) => isRedacted(el, attribute),
|
|
248
|
+
onclone: neutralizeCollapsedDetails,
|
|
249
|
+
// CSS pixels, not devicePixelRatio. (Sharpening mobile captures by rendering at
|
|
250
|
+
// DPR is a possible enhancement, deliberately not done here.)
|
|
251
|
+
scale: 1,
|
|
252
|
+
useCORS: true,
|
|
253
|
+
// Cross-origin images that can't be read taint the canvas and make toBlob throw;
|
|
254
|
+
// dropping them costs a blank rectangle and saves the whole capture.
|
|
255
|
+
allowTaint: false,
|
|
256
|
+
logging: false,
|
|
257
|
+
backgroundColor: getComputedStyle(document.body).backgroundColor || '#ffffff',
|
|
258
|
+
// The visible window only.
|
|
259
|
+
x: window.scrollX,
|
|
260
|
+
y: window.scrollY,
|
|
261
|
+
width: window.innerWidth,
|
|
262
|
+
height: window.innerHeight,
|
|
263
|
+
windowWidth: document.documentElement.clientWidth,
|
|
264
|
+
windowHeight: document.documentElement.clientHeight,
|
|
265
|
+
scrollX: 0,
|
|
266
|
+
scrollY: 0,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Make a COLLAPSED `<details>` actually look collapsed in the capture.
|
|
271
|
+
*
|
|
272
|
+
* html2canvas walks the DOM and paints anything whose COMPUTED STYLE says it is
|
|
273
|
+
* visible. But a closed `<details>` is collapsed by the user agent — the content lives
|
|
274
|
+
* in a slot the browser simply doesn't paint — and that is not expressed in CSS at
|
|
275
|
+
* all. Measured on a closed panel: `display: flex`, `visibility: visible`,
|
|
276
|
+
* `content-visibility: visible`, and a full 950x82 bounding rect. So html2canvas has
|
|
277
|
+
* every reason to draw it, and does — over whatever follows.
|
|
278
|
+
*
|
|
279
|
+
* That produced a reported defect: a closed "more filters" panel was painted on top of
|
|
280
|
+
* the table header, making the screenshot actively misleading (it showed filter fields
|
|
281
|
+
* the reporter never had open).
|
|
282
|
+
*
|
|
283
|
+
* The `<summary>` line is the same root cause wearing a different hat: `summary` is
|
|
284
|
+
* `display: list-item`, and html2canvas renders that marker as an ORDERED-LIST NUMBER,
|
|
285
|
+
* so a stray "1." appeared beside the panel's title. Forcing `display: block` drops the
|
|
286
|
+
* marker box. The cost is losing the ▶ disclosure triangle, which html2canvas cannot
|
|
287
|
+
* draw anyway — a missing triangle beats a fabricated "1.".
|
|
288
|
+
*
|
|
289
|
+
* Mutates the CLONE html2canvas renders from, never the live document.
|
|
290
|
+
*/
|
|
291
|
+
function neutralizeCollapsedDetails(doc) {
|
|
292
|
+
doc.querySelectorAll('details:not([open])').forEach((details) => {
|
|
293
|
+
for (const child of Array.from(details.children)) {
|
|
294
|
+
if (child.tagName !== 'SUMMARY')
|
|
295
|
+
child.style.display = 'none';
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
doc.querySelectorAll('summary').forEach((summary) => {
|
|
299
|
+
summary.style.display = 'block';
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Capture the CURRENT VIEWPORT.
|
|
304
|
+
*
|
|
305
|
+
* Viewport, not full page, on purpose: what the reporter is looking at is what they
|
|
306
|
+
* are reporting, a full-page render multiplies the payload, and sticky/fixed elements
|
|
307
|
+
* render badly when the capture is taller than the window.
|
|
308
|
+
*
|
|
309
|
+
* THE EXCLUSION CONTRACT: anything that must not appear in the shot carries
|
|
310
|
+
* REDACT_ATTRIBUTE — both renderers drop those subtrees. That attribute, NOT
|
|
311
|
+
* call ordering, is what keeps the report dialog out of the screenshot: the dialog
|
|
312
|
+
* (panel and overlay both marked) opens in the same tick as the capture starts,
|
|
313
|
+
* because a full-page render measures in SECONDS (~1.5-4.5s at ~2000 elements) and
|
|
314
|
+
* holding the dialog for it made the button read as frozen. Two shipped mistakes
|
|
315
|
+
* inform this design: v1 opened the dialog unredacted before capturing (the form was
|
|
316
|
+
* in every screenshot); v2 awaited the capture before opening (correct pixels, 5-8s
|
|
317
|
+
* of dead air). Anything new that must never be captured — a future shake-to-report
|
|
318
|
+
* toast, a debug panel — gets the attribute, not a sequencing rule.
|
|
319
|
+
*
|
|
320
|
+
* Returns null on any failure. A missing screenshot degrades the report; a thrown
|
|
321
|
+
* error would lose it entirely, and the reporter's text is the part that cannot be
|
|
322
|
+
* re-derived.
|
|
323
|
+
*/
|
|
324
|
+
export async function captureViewport(options = {}) {
|
|
325
|
+
if (typeof window === 'undefined' || typeof document === 'undefined')
|
|
326
|
+
return null;
|
|
327
|
+
const attribute = options.redactAttribute ?? REDACT_ATTRIBUTE;
|
|
328
|
+
// Preferred first, fallback second — chosen by OUTCOME (threw, or produced something
|
|
329
|
+
// blank/tainted), never by sniffing the user agent.
|
|
330
|
+
let rendered = null;
|
|
331
|
+
for (const render of [renderWithModernScreenshot, renderWithHtml2Canvas]) {
|
|
332
|
+
try {
|
|
333
|
+
const candidate = await render(attribute);
|
|
334
|
+
if (candidate && looksUsable(candidate)) {
|
|
335
|
+
rendered = candidate;
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// Try the next renderer. Both failing is handled below.
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (!rendered)
|
|
344
|
+
return null;
|
|
345
|
+
try {
|
|
346
|
+
const canvas = downscale(rendered, options.maxEdge ?? MAX_EDGE_PX);
|
|
347
|
+
const blob = await encodeWithinBudget(canvas, options.targetBytes ?? TARGET_MAX_BYTES);
|
|
348
|
+
if (!blob || blob.size > (options.hardMaxBytes ?? HARD_MAX_BYTES))
|
|
349
|
+
return null;
|
|
350
|
+
return { blob, width: canvas.width, height: canvas.height };
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
// Encoding failed (out of memory on a huge page). The report still goes, without
|
|
354
|
+
// a screenshot — the reporter's text is what cannot be re-derived.
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { FeedbackClientContext } from '../../kernel/feedback/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Collect the current view's context.
|
|
4
|
+
*
|
|
5
|
+
* `win` is injectable so this is testable in node without a DOM — the alternative
|
|
6
|
+
* (a jsdom environment for one function) would pull a second test environment into a
|
|
7
|
+
* suite that is deliberately node-only.
|
|
8
|
+
*/
|
|
9
|
+
export declare function collectClientContext(win?: Window & typeof globalThis): FeedbackClientContext;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** Anything that can throw here yields `undefined`, never an exception. */
|
|
2
|
+
function safe(read) {
|
|
3
|
+
try {
|
|
4
|
+
return read();
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
return undefined;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Collect the current view's context.
|
|
12
|
+
*
|
|
13
|
+
* `win` is injectable so this is testable in node without a DOM — the alternative
|
|
14
|
+
* (a jsdom environment for one function) would pull a second test environment into a
|
|
15
|
+
* suite that is deliberately node-only.
|
|
16
|
+
*/
|
|
17
|
+
export function collectClientContext(win = globalThis) {
|
|
18
|
+
const nav = win.navigator;
|
|
19
|
+
const doc = win.document;
|
|
20
|
+
return {
|
|
21
|
+
url: safe(() => win.location?.href) ?? '',
|
|
22
|
+
title: safe(() => doc?.title) ?? '',
|
|
23
|
+
viewport: safe(() => `${win.innerWidth}x${win.innerHeight}`) ?? '',
|
|
24
|
+
screen: safe(() => (win.screen ? `${win.screen.width}x${win.screen.height}` : '')) ?? '',
|
|
25
|
+
devicePixelRatio: safe(() => win.devicePixelRatio) ?? 1,
|
|
26
|
+
userAgent: safe(() => nav?.userAgent) ?? '',
|
|
27
|
+
language: safe(() => nav?.language) ?? '',
|
|
28
|
+
// Intl is present everywhere this app runs, but a locked-down browser can still
|
|
29
|
+
// make resolvedOptions throw; the timezone is a nice-to-have, not a reason to fail.
|
|
30
|
+
timezone: safe(() => Intl.DateTimeFormat().resolvedOptions().timeZone) ?? '',
|
|
31
|
+
online: safe(() => nav?.onLine) ?? true,
|
|
32
|
+
// People work on mobile data; "it only breaks on 3g" is a real bug class.
|
|
33
|
+
connection: safe(() => nav?.connection?.effectiveType) ?? null,
|
|
34
|
+
// The CLIENT clock, deliberately alongside the server's own timestamp. Comparing
|
|
35
|
+
// the two is free and explains a whole family of date/timezone bugs.
|
|
36
|
+
clientTime: safe(() => new Date().toISOString()) ?? '',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type CaptureOptions, type CaptureResult } from './capture.js';
|
|
2
|
+
export type CaptureState = {
|
|
3
|
+
status: 'idle';
|
|
4
|
+
} | {
|
|
5
|
+
status: 'capturing';
|
|
6
|
+
} | {
|
|
7
|
+
status: 'failed';
|
|
8
|
+
} | ({
|
|
9
|
+
status: 'ready';
|
|
10
|
+
} & CaptureResult);
|
|
11
|
+
export interface FeedbackReporterSnapshot {
|
|
12
|
+
open: boolean;
|
|
13
|
+
capture: CaptureState;
|
|
14
|
+
}
|
|
15
|
+
export interface FeedbackReporterOptions {
|
|
16
|
+
/** Replace the capture (tests, or an app with its own renderer). Default: captureViewport(captureOptions). */
|
|
17
|
+
capture?: () => Promise<CaptureResult | null>;
|
|
18
|
+
captureOptions?: CaptureOptions;
|
|
19
|
+
}
|
|
20
|
+
export declare class FeedbackReporterController {
|
|
21
|
+
private snapshot;
|
|
22
|
+
private readonly listeners;
|
|
23
|
+
/** The in-flight capture, for a submit that arrives before it resolves. */
|
|
24
|
+
private pending;
|
|
25
|
+
/** Generation stamp, so a capture orphaned by close() can never write stale state. */
|
|
26
|
+
private generation;
|
|
27
|
+
private readonly capture;
|
|
28
|
+
constructor(options?: FeedbackReporterOptions);
|
|
29
|
+
/** The current state. The same object until something changes — safe for useSyncExternalStore. */
|
|
30
|
+
getSnapshot: () => FeedbackReporterSnapshot;
|
|
31
|
+
subscribe: (listener: () => void) => (() => void);
|
|
32
|
+
private set;
|
|
33
|
+
/** Start the capture and open, in the same tick. A no-op while already open. */
|
|
34
|
+
open: () => void;
|
|
35
|
+
/**
|
|
36
|
+
* Close and forget. The render itself cannot be aborted, but its result is
|
|
37
|
+
* discarded — no reason to hold an image for a report that was cancelled.
|
|
38
|
+
*/
|
|
39
|
+
close: () => void;
|
|
40
|
+
/** The settled capture — immediately when done, or after an in-flight one finishes. */
|
|
41
|
+
waitForCapture: () => Promise<CaptureResult | null>;
|
|
42
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// The reporter's state machine — open/close and the screenshot that runs alongside —
|
|
2
|
+
// without any UI framework. `@kolibri-day/feedback/react` binds it to React; a Vue or
|
|
3
|
+
// vanilla app subscribes to it directly.
|
|
4
|
+
//
|
|
5
|
+
// THE DIALOG OPENS IMMEDIATELY; THE CAPTURE RESOLVES INTO ITS PREVIEW. This sequencing
|
|
6
|
+
// is the third iteration, and the constraint it finally gets right is about PIXELS,
|
|
7
|
+
// not order. v1 opened the dialog before capturing — the form was in every screenshot.
|
|
8
|
+
// v2 held the dialog until the capture finished — correct pixels, but on a real page a
|
|
9
|
+
// DOM render takes seconds (measured ~4.5s for modern-screenshot and ~1.7s for
|
|
10
|
+
// html2canvas at ~2000 elements), and a button that does nothing for seconds reads as
|
|
11
|
+
// broken. No renderer choice fixes that; the render cost has to come OFF the
|
|
12
|
+
// click-to-dialog path.
|
|
13
|
+
//
|
|
14
|
+
// So: the capture starts at open(), the dialog opens in the same tick, and the dialog
|
|
15
|
+
// stays out of the shot because its panel AND its backdrop carry REDACT_ATTRIBUTE.
|
|
16
|
+
// The preview shows "capturing…" until the frame lands; a submit that outruns it
|
|
17
|
+
// awaits waitForCapture().
|
|
18
|
+
//
|
|
19
|
+
// Known minor artifact, accepted: a modal that locks page scroll removes the
|
|
20
|
+
// scrollbar, so on classic-scrollbar systems the capture can sit ~15px off from what
|
|
21
|
+
// was on screen. A shifted-by-a-scrollbar screenshot is a fair trade for an instant
|
|
22
|
+
// dialog.
|
|
23
|
+
import { captureViewport } from "./capture.js";
|
|
24
|
+
const IDLE = Object.freeze({
|
|
25
|
+
open: false,
|
|
26
|
+
capture: Object.freeze({ status: 'idle' }),
|
|
27
|
+
});
|
|
28
|
+
export class FeedbackReporterController {
|
|
29
|
+
snapshot = IDLE;
|
|
30
|
+
listeners = new Set();
|
|
31
|
+
/** The in-flight capture, for a submit that arrives before it resolves. */
|
|
32
|
+
pending = null;
|
|
33
|
+
/** Generation stamp, so a capture orphaned by close() can never write stale state. */
|
|
34
|
+
generation = 0;
|
|
35
|
+
capture;
|
|
36
|
+
constructor(options = {}) {
|
|
37
|
+
this.capture = options.capture ?? (() => captureViewport(options.captureOptions));
|
|
38
|
+
}
|
|
39
|
+
/** The current state. The same object until something changes — safe for useSyncExternalStore. */
|
|
40
|
+
getSnapshot = () => this.snapshot;
|
|
41
|
+
subscribe = (listener) => {
|
|
42
|
+
this.listeners.add(listener);
|
|
43
|
+
return () => this.listeners.delete(listener);
|
|
44
|
+
};
|
|
45
|
+
set(next) {
|
|
46
|
+
this.snapshot = next;
|
|
47
|
+
for (const listener of this.listeners)
|
|
48
|
+
listener();
|
|
49
|
+
}
|
|
50
|
+
/** Start the capture and open, in the same tick. A no-op while already open. */
|
|
51
|
+
open = () => {
|
|
52
|
+
if (this.snapshot.open)
|
|
53
|
+
return;
|
|
54
|
+
const generation = ++this.generation;
|
|
55
|
+
// A capture that throws (it should not — captureViewport returns null on failure)
|
|
56
|
+
// still settles as "no screenshot" rather than an unhandled rejection.
|
|
57
|
+
const pending = this.capture().catch(() => null);
|
|
58
|
+
this.pending = pending;
|
|
59
|
+
this.set({ open: true, capture: { status: 'capturing' } });
|
|
60
|
+
void pending.then((result) => {
|
|
61
|
+
if (this.generation !== generation)
|
|
62
|
+
return; // closed meanwhile — stale frame
|
|
63
|
+
this.set({ open: true, capture: result ? { status: 'ready', ...result } : { status: 'failed' } });
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Close and forget. The render itself cannot be aborted, but its result is
|
|
68
|
+
* discarded — no reason to hold an image for a report that was cancelled.
|
|
69
|
+
*/
|
|
70
|
+
close = () => {
|
|
71
|
+
this.generation++;
|
|
72
|
+
this.pending = null;
|
|
73
|
+
this.set(IDLE);
|
|
74
|
+
};
|
|
75
|
+
/** The settled capture — immediately when done, or after an in-flight one finishes. */
|
|
76
|
+
waitForCapture = async () => {
|
|
77
|
+
return this.pending ? await this.pending : null;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { FeedbackClientContext } from '../../kernel/feedback/types.js';
|
|
2
|
+
export interface FeedbackSubmitInput {
|
|
3
|
+
kind: string;
|
|
4
|
+
message: string;
|
|
5
|
+
clientContext?: FeedbackClientContext;
|
|
6
|
+
/** The capture's blob, or null/undefined for a text-only report. */
|
|
7
|
+
screenshot?: Blob | null;
|
|
8
|
+
}
|
|
9
|
+
/** Build the multipart body. Identity is deliberately not a field — the server takes it from the session. */
|
|
10
|
+
export declare function buildFeedbackForm(input: FeedbackSubmitInput): FormData;
|
|
11
|
+
export type FeedbackSubmitResult = {
|
|
12
|
+
ok: true;
|
|
13
|
+
id: string | null;
|
|
14
|
+
} | {
|
|
15
|
+
ok: false;
|
|
16
|
+
status: number;
|
|
17
|
+
/** The server's message for the reporter, when it sent one (`{ "error": "…" }`). */
|
|
18
|
+
error: string | null;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* POST the report. Resolves with the server's verdict; REJECTS only when the request
|
|
22
|
+
* never got an answer (offline, DNS, CORS) — the caller words that case differently
|
|
23
|
+
* ("network error, try again") from a refusal.
|
|
24
|
+
*
|
|
25
|
+
* Success means the server ACCEPTED the report — with the durable pipeline, that it is
|
|
26
|
+
* stored, not that it reached the tracker. Word the confirmation accordingly.
|
|
27
|
+
*/
|
|
28
|
+
export declare function submitFeedback(endpoint: string, input: FeedbackSubmitInput, options?: {
|
|
29
|
+
fetch?: typeof fetch;
|
|
30
|
+
}): Promise<FeedbackSubmitResult>;
|