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