@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.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +289 -2
  3. package/dist/cjs/adapters/capture/capture.d.ts +112 -0
  4. package/dist/cjs/adapters/capture/capture.js +398 -0
  5. package/dist/cjs/adapters/capture/client-context.d.ts +9 -0
  6. package/dist/cjs/adapters/capture/client-context.js +41 -0
  7. package/dist/cjs/adapters/capture/reporter.d.ts +42 -0
  8. package/dist/cjs/adapters/capture/reporter.js +83 -0
  9. package/dist/cjs/adapters/capture/submit.d.ts +30 -0
  10. package/dist/cjs/adapters/capture/submit.js +32 -0
  11. package/dist/cjs/adapters/kolibri/env.d.ts +16 -0
  12. package/dist/cjs/adapters/kolibri/env.js +40 -0
  13. package/dist/cjs/adapters/kolibri/preflight.d.ts +6 -0
  14. package/dist/cjs/adapters/kolibri/preflight.js +536 -0
  15. package/dist/cjs/adapters/kolibri/target.d.ts +100 -0
  16. package/dist/cjs/adapters/kolibri/target.js +175 -0
  17. package/dist/cjs/adapters/react/reporter.d.ts +25 -0
  18. package/dist/cjs/adapters/react/reporter.js +54 -0
  19. package/dist/cjs/browser.d.ts +4 -0
  20. package/dist/cjs/browser.js +22 -0
  21. package/dist/cjs/index.d.ts +6 -0
  22. package/dist/cjs/index.js +25 -0
  23. package/dist/cjs/kernel/feedback/dispatch.d.ts +64 -0
  24. package/dist/cjs/kernel/feedback/dispatch.js +93 -0
  25. package/dist/cjs/kernel/feedback/fake-target.d.ts +58 -0
  26. package/dist/cjs/kernel/feedback/fake-target.js +87 -0
  27. package/dist/cjs/kernel/feedback/files.d.ts +6 -0
  28. package/dist/cjs/kernel/feedback/files.js +21 -0
  29. package/dist/cjs/kernel/feedback/format.d.ts +53 -0
  30. package/dist/cjs/kernel/feedback/format.js +140 -0
  31. package/dist/cjs/kernel/feedback/input.d.ts +79 -0
  32. package/dist/cjs/kernel/feedback/input.js +157 -0
  33. package/dist/cjs/kernel/feedback/messages.d.ts +76 -0
  34. package/dist/cjs/kernel/feedback/messages.js +138 -0
  35. package/dist/cjs/kernel/feedback/notice.d.ts +10 -0
  36. package/dist/cjs/kernel/feedback/notice.js +19 -0
  37. package/dist/cjs/kernel/feedback/types.d.ts +158 -0
  38. package/dist/cjs/kernel/feedback/types.js +47 -0
  39. package/dist/cjs/kolibri.d.ts +2 -0
  40. package/dist/cjs/kolibri.js +12 -0
  41. package/dist/cjs/package.json +1 -0
  42. package/dist/cjs/preflight-cli.d.ts +2 -0
  43. package/dist/cjs/preflight-cli.js +10 -0
  44. package/dist/cjs/react.d.ts +3 -0
  45. package/dist/cjs/react.js +9 -0
  46. package/dist/cjs/server.d.ts +4 -0
  47. package/dist/cjs/server.js +18 -0
  48. package/dist/cjs/wiring.d.ts +38 -0
  49. package/dist/cjs/wiring.js +72 -0
  50. package/dist/esm/adapters/capture/capture.d.ts +112 -0
  51. package/dist/esm/adapters/capture/capture.js +357 -0
  52. package/dist/esm/adapters/capture/client-context.d.ts +9 -0
  53. package/dist/esm/adapters/capture/client-context.js +38 -0
  54. package/dist/esm/adapters/capture/reporter.d.ts +42 -0
  55. package/dist/esm/adapters/capture/reporter.js +79 -0
  56. package/dist/esm/adapters/capture/submit.d.ts +30 -0
  57. package/dist/esm/adapters/capture/submit.js +28 -0
  58. package/dist/esm/adapters/kolibri/env.d.ts +16 -0
  59. package/dist/esm/adapters/kolibri/env.js +36 -0
  60. package/dist/esm/adapters/kolibri/preflight.d.ts +6 -0
  61. package/dist/esm/adapters/kolibri/preflight.js +533 -0
  62. package/dist/esm/adapters/kolibri/target.d.ts +100 -0
  63. package/dist/esm/adapters/kolibri/target.js +170 -0
  64. package/dist/esm/adapters/react/reporter.d.ts +25 -0
  65. package/dist/esm/adapters/react/reporter.js +49 -0
  66. package/dist/esm/browser.d.ts +4 -0
  67. package/dist/esm/browser.js +7 -0
  68. package/dist/esm/index.d.ts +6 -0
  69. package/dist/esm/index.js +9 -0
  70. package/dist/esm/kernel/feedback/dispatch.d.ts +64 -0
  71. package/dist/esm/kernel/feedback/dispatch.js +89 -0
  72. package/dist/esm/kernel/feedback/fake-target.d.ts +58 -0
  73. package/dist/esm/kernel/feedback/fake-target.js +82 -0
  74. package/dist/esm/kernel/feedback/files.d.ts +6 -0
  75. package/dist/esm/kernel/feedback/files.js +16 -0
  76. package/dist/esm/kernel/feedback/format.d.ts +53 -0
  77. package/dist/esm/kernel/feedback/format.js +133 -0
  78. package/dist/esm/kernel/feedback/input.d.ts +79 -0
  79. package/dist/esm/kernel/feedback/input.js +150 -0
  80. package/dist/esm/kernel/feedback/messages.d.ts +76 -0
  81. package/dist/esm/kernel/feedback/messages.js +135 -0
  82. package/dist/esm/kernel/feedback/notice.d.ts +10 -0
  83. package/dist/esm/kernel/feedback/notice.js +15 -0
  84. package/dist/esm/kernel/feedback/types.d.ts +158 -0
  85. package/dist/esm/kernel/feedback/types.js +42 -0
  86. package/dist/esm/kolibri.d.ts +2 -0
  87. package/dist/esm/kolibri.js +3 -0
  88. package/dist/esm/preflight-cli.d.ts +2 -0
  89. package/dist/esm/preflight-cli.js +8 -0
  90. package/dist/esm/react.d.ts +3 -0
  91. package/dist/esm/react.js +3 -0
  92. package/dist/esm/server.d.ts +4 -0
  93. package/dist/esm/server.js +6 -0
  94. package/dist/esm/wiring.d.ts +38 -0
  95. package/dist/esm/wiring.js +68 -0
  96. package/fixtures/preflight-screenshot.webp +0 -0
  97. 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>;