@kolibri-day/feedback 0.0.0-stage → 0.1.1

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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kolibri contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,290 @@
1
- # Temporary Holding Version
1
+ # @kolibri-day/feedback
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ In-app feedback with a screenshot of the current view, delivered to
4
+ [Kolibri](https://kolibri.day) — or to any other tracker behind the same `FeedbackTarget`
5
+ interface.
6
+
7
+ Your users click a button, see the screenshot that will be attached, write what went
8
+ wrong, and the report lands as a task in your Kolibri project: title, description, the
9
+ facts your server knows about them, what their browser reported, and the image. The
10
+ package contains everything that does not depend on your app — capture, the wire
11
+ protocol, validation, the delivery rules and the Kolibri client. Your app keeps only its
12
+ own concerns: who the reporter is, where reports are stored, which queue retries them,
13
+ and what the dialog looks like.
14
+
15
+ It was extracted from a production CRM, where it files reports into a self-hosted
16
+ Kolibri. MIT licensed, like Kolibri itself.
17
+
18
+ ## How it fits together
19
+
20
+ ```
21
+ browser app server worker / queue
22
+ ─────── ────────── ──────────────
23
+ FeedbackReporterProvider POST /api/feedback dispatchFeedback(id)
24
+ openFeedback() ── capture ─┐ readFeedbackForm(request) store.load(id)
25
+ dialog (your markup) │ parseFeedbackInput(...) target.submit(report)
26
+ submitFeedback() ──────────┴──▶ + reporter from YOUR session store.markSubmitted / recordFailure
27
+ store + enqueue(id) ────────▶ (retry on transient errors)
28
+ │
29
+ ▼
30
+ Kolibri: POST …/tasks (id = report id)
31
+ POST …/files (raw screenshot)
32
+ ```
33
+
34
+ | Entry point | Runs in | Contents |
35
+ |---|---|---|
36
+ | `@kolibri-day/feedback` | anywhere | `FeedbackReport` and `FeedbackTarget` types, `FeedbackTargetError`, the Markdown formatter, the privacy-notice helpers, `en`/`de` messages, `FakeFeedbackTarget` |
37
+ | `@kolibri-day/feedback/browser` | browser | `captureViewport`, `collectClientContext`, `submitFeedback`, `FeedbackReporterController` (framework-agnostic state) |
38
+ | `@kolibri-day/feedback/react` | React 18+ | `FeedbackReporterProvider`, `useFeedback`, `useFeedbackReporter` — state only, no markup |
39
+ | `@kolibri-day/feedback/server` | Node 20+ / any `fetch` runtime | `readFeedbackForm`, `parseFeedbackInput`, `dispatchFeedback` + `FeedbackStore`, `resolveFeedbackTarget`, `describeFeedbackTarget` |
40
+ | `@kolibri-day/feedback/kolibri` | Node 20+ / any `fetch` runtime | `KolibriFeedbackTarget`, `kolibriConfigFromEnv` |
41
+ | `kolibri-feedback-preflight` (bin) | Node 20+ | checks a live Kolibri instance before you go live |
42
+
43
+ The package ships ES modules and CommonJS with type declarations.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ npm install @kolibri-day/feedback
49
+ # for the screenshot (optional peer dependencies, loaded lazily on first capture)
50
+ npm install modern-screenshot html2canvas
51
+ ```
52
+
53
+ `react` is an optional peer dependency, needed only for `./react`.
54
+
55
+ ## Usage
56
+
57
+ ### 1. Browser: provider, triggers, dialog
58
+
59
+ ```tsx
60
+ 'use client';
61
+ import { FeedbackReporterProvider, useFeedback, useFeedbackReporter } from '@kolibri-day/feedback/react';
62
+ import { collectClientContext, submitFeedback } from '@kolibri-day/feedback/browser';
63
+ import { screenshotDefaultOn, screenshotNotice, type FeedbackTargetDescriptor } from '@kolibri-day/feedback';
64
+
65
+ export function Shell({ target, children }: { target: FeedbackTargetDescriptor; children: React.ReactNode }) {
66
+ return (
67
+ <FeedbackReporterProvider>
68
+ {children}
69
+ <ReportButton />
70
+ <ReportDialog target={target} />
71
+ </FeedbackReporterProvider>
72
+ );
73
+ }
74
+
75
+ function ReportButton() {
76
+ const { openFeedback } = useFeedback(); // stable; a no-op outside the provider
77
+ return <button data-feedback-redact onClick={openFeedback}>Feedback</button>;
78
+ }
79
+
80
+ function ReportDialog({ target }: { target: FeedbackTargetDescriptor }) {
81
+ const { open, capture, close, waitForCapture } = useFeedbackReporter();
82
+ // Render your own dialog. Its panel AND its backdrop need `data-feedback-redact`,
83
+ // because it opens while the capture is still running.
84
+ // capture.status: 'capturing' → 'ready' (capture.blob) | 'failed'
85
+ // On submit (await waitForCapture() if the reporter outran the renderer):
86
+ // await submitFeedback('/api/feedback', { kind, message, clientContext: collectClientContext(), screenshot })
87
+ // Default the screenshot checkbox to screenshotDefaultOn(target) and show screenshotNotice(target).
88
+ return null;
89
+ }
90
+ ```
91
+
92
+ **Redaction.** Anything carrying `data-feedback-redact` is left out of the screenshot:
93
+ the dialog and its backdrop, the trigger, a menu that is still animating closed, and any
94
+ data that must never appear in a ticket. Configure a different attribute with
95
+ `captureOptions: { redactAttribute }`.
96
+
97
+ **What is captured:** the visible viewport (not the full page), rendered from the DOM —
98
+ first with modern-screenshot, then with html2canvas if the first result is blank or
99
+ unreadable. The longest edge is capped at 2560 px and the image is encoded as WebP (JPEG
100
+ where the browser cannot encode WebP), stepping quality down to stay near 1 MB. The
101
+ browser data collected is URL, title, viewport, screen, DPR, user agent, language,
102
+ timezone, online status, connection type and the client clock. It never includes
103
+ storage, cookies or console output.
104
+
105
+ ### 2. Server: accept a report
106
+
107
+ ```ts
108
+ import { readFeedbackForm, parseFeedbackInput, FeedbackInputError } from '@kolibri-day/feedback/server';
109
+
110
+ export async function POST(request: Request) {
111
+ const user = await getSessionUser(request); // YOUR session — never the request body
112
+ if (!user) return Response.json({ error: 'unauthenticated' }, { status: 401 });
113
+ try {
114
+ const input = parseFeedbackInput(await readFeedbackForm(request), { kinds: ['BUG', 'IDEA', 'OTHER'] });
115
+ const id = crypto.randomUUID(); // becomes the Kolibri task id — must be a UUID
116
+ await db.transaction(async (tx) => {
117
+ await tx.insertReport({ id, ...input, reporterId: user.id, reporterName: user.name });
118
+ await tx.enqueue('submit_feedback', { id }); // same transaction: no report without a job
119
+ });
120
+ return Response.json({ ok: true, id }, { status: 201 });
121
+ } catch (e) {
122
+ if (e instanceof FeedbackInputError) return Response.json({ error: e.message }, { status: e.status });
123
+ throw e;
124
+ }
125
+ }
126
+ ```
127
+
128
+ The wire format has no identity field, so a client cannot claim to be someone else. Add
129
+ a per-reporter rate limit: the endpoint accepts uploads of several hundred KB.
130
+
131
+ ### 3. Worker: deliver it
132
+
133
+ ```ts
134
+ import { resolveFeedbackTarget, dispatchFeedback, type FeedbackStore } from '@kolibri-day/feedback/server';
135
+
136
+ const { target, warning } = resolveFeedbackTarget(process.env); // never throws
137
+ if (warning) console.warn(warning);
138
+
139
+ const store: FeedbackStore = {
140
+ async load(id) {
141
+ // → { report, status: 'pending' | 'submitted' | 'failed', externalId } | null
142
+ },
143
+ async markSubmitted(id, update) {
144
+ // status = submitted, store update.externalId / externalUrl, AND drop the screenshot bytes
145
+ },
146
+ async recordFailure(id, update) {
147
+ // lastError = update.message; persist update.externalId; status = failed if update.permanent
148
+ },
149
+ };
150
+
151
+ queue.handle('submit_feedback', ({ id }) => dispatchFeedback(id, { store, target }));
152
+ ```
153
+
154
+ `dispatchFeedback` resolves on success, when there is nothing to do, and on a
155
+ **permanent** failure (it is recorded, and retrying cannot help). It throws on a
156
+ **transient** failure, so your queue backs off and retries. After a successful delivery
157
+ your store must delete the stored screenshot: the tracker has it, and a second copy is
158
+ just more exposure.
159
+
160
+ **Without a queue** you can call `target.submit(report)` right after storing. That is
161
+ simpler, but a report is then delivered only if the tracker is reachable at that moment,
162
+ and people tend to report bugs precisely when something is down.
163
+
164
+ ## Configuration
165
+
166
+ | Variable | Where | Value |
167
+ |---|---|---|
168
+ | `FEEDBACK_TARGET` | web + worker | `kolibri` or `fake`. If unset: `fake` in development, and in production a target that fails every report loudly |
169
+ | `KOLIBRI_API_URL` | worker | Kolibri's origin, e.g. `https://kolibri.example.com`. Not an `/api` path |
170
+ | `KOLIBRI_TOKEN` | worker | a confined `kol_…` token with the `write` scope. **Secret** |
171
+ | `KOLIBRI_WORKSPACE_ID` | worker | workspace **UUID** (a slug is refused) |
172
+ | `KOLIBRI_PROJECT_ID` | worker | project **UUID** (a project key is refused) |
173
+
174
+ The web tier only needs `FEEDBACK_TARGET`. `describeFeedbackTarget()` names the
175
+ destination for the privacy notice without credentials.
176
+
177
+ ## Kolibri setup
178
+
179
+ 1. **Minimum Kolibri version.** Use an instance that scopes attachments by the project of
180
+ the task they belong to ("A private project's comments, attachments and files were the
181
+ whole workspace's"). On older instances any workspace member can fetch any screenshot,
182
+ and a private project protects nothing.
183
+ 2. **A feedback project whose audience is right.** Everyone who can see the project can
184
+ open every screenshot in it. Use a private project with a deliberate member list, or a
185
+ public one in a workspace whose members are exactly that audience. Kolibri has no UI
186
+ for project members; add them with
187
+ `POST /api/workspaces/{ws}/project-members {"project_id", "user_id", "role"}`.
188
+ 3. **A dedicated account** with role ≥ `member` in the workspace (guests cannot upload),
189
+ and also a member of the project, because the preflight reads back.
190
+ 4. **A confined write token**, minted as that account:
191
+ `POST /api/tokens {"name":"…","workspaceId":"<uuid>","confined":true,"scopes":"write","expiresInDays":90}`.
192
+ A confined token sees one workspace and cannot mint further tokens.
193
+ 5. **Run the preflight** with the variables above:
194
+
195
+ ```bash
196
+ npx kolibri-feedback-preflight # leaves the test task for review
197
+ npx kolibri-feedback-preflight --cleanup # deletes it afterwards
198
+ ```
199
+
200
+ It checks the token and its confinement, resolves workspace and project, and **prints
201
+ who can open screenshots**. It then creates a task through the real
202
+ `KolibriFeedbackTarget`, uploads a ~380 KB WebP, checks the stored size for truncation,
203
+ and fetches the file back. Exit code 0 means ready.
204
+ 6. Rotate any token that was pasted somewhere while troubleshooting.
205
+
206
+ Delivery is idempotent. The report id is sent as the task id, and Kolibri treats a
207
+ create on an existing id as an update. An identical re-upload returns the existing
208
+ attachment. A retry therefore never duplicates a ticket or a screenshot. The details are
209
+ in [`docs/setaside-feedback-api.md`](../../docs/setaside-feedback-api.md).
210
+
211
+ ## Languages
212
+
213
+ English is the default; German ships too. Pass `messages`:
214
+
215
+ ```ts
216
+ import { de, en } from '@kolibri-day/feedback';
217
+ resolveFeedbackTarget(process.env, { messages: de, kindLabels: { BUG: 'Bug', IDEA: 'Idee', OTHER: 'Sonstiges' } });
218
+ parseFeedbackInput(input, { kinds, messages: de });
219
+ screenshotNotice(target, de);
220
+
221
+ // Override single strings by spreading:
222
+ const mine = { ...en, notice: { ...en.notice, selfHosted: (label: string) => `… ${label} …` } };
223
+ ```
224
+
225
+ `messages` covers the ticket body, the privacy notice, validation errors, Kolibri errors
226
+ and configuration warnings. Your dialog's own copy remains yours. The preflight speaks
227
+ English.
228
+
229
+ ## Security and privacy notes
230
+
231
+ - **The ticket body is Markdown and is not escaped.** That is correct for Kolibri, whose
232
+ renderer escapes all input. Escaping it here would double-escape. A target that renders
233
+ raw HTML must **not** reuse `buildDescriptionMarkdown` as it is.
234
+ - **Server-derived and client-reported data are kept apart.** The ticket has one table
235
+ for what the server knows (reporter, `serverContext`, timestamps) and one, labelled as
236
+ unverified, for what the browser reported.
237
+ - **Where the screenshot goes decides the default.** For a `self_hosted` target the
238
+ screenshot is attached by default. For a `third_party` target the reporter has to opt
239
+ in, and the notice says the data leaves your infrastructure. A new target has to state
240
+ which one it is.
241
+ - **Client context is untrusted.** It is limited to scalar values, 40 keys and 2,000
242
+ characters per value, and is never parsed as anything else.
243
+
244
+ ## Development
245
+
246
+ This package lives in the Kolibri repository and follows its rules (`CLAUDE.md` at the
247
+ root): sources under `src/<ring>/<module>/`, `.ts` imports that Node 22 runs directly,
248
+ `node:test`. Unlike the other four packages it is built to JavaScript, because it runs in
249
+ other people's bundlers and Node versions.
250
+
251
+ ```
252
+ src/kernel/feedback/ the report, the target contract, formatter, messages, input, delivery rules
253
+ src/adapters/kolibri/ the Kolibri target, its env vars, the preflight
254
+ src/adapters/capture/ screenshot, browser facts, reporter state, the submit
255
+ src/adapters/react/ the React bindings
256
+ src/*.ts one entry point per published sub-path, the resolver, the CLI
257
+ ```
258
+
259
+ ```bash
260
+ npm run test:feedback # from the repository root
261
+ npm run typecheck # includes this package
262
+ npm run build -w @kolibri-day/feedback # dist/esm + dist/cjs
263
+ ```
264
+
265
+ The renderers need a real layout engine and are not unit-tested. Everything around them
266
+ is: the downscale arithmetic, the encoder's choice of format (including the Safari case
267
+ where WebP comes back as PNG), the blank-canvas check, and the reporter state machine.
268
+
269
+ ## Releasing
270
+
271
+ Releases are published from GitHub Actions with npm's trusted publishing — no npm token is
272
+ stored anywhere, and every version the workflow publishes carries a provenance statement
273
+ linking it to the commit and the run that built it (npm adds it automatically; a manual
274
+ publish from a laptop has none, which is why only the first version is published that
275
+ way).
276
+
277
+ 1. Bump `version` in `packages/feedback/package.json` and merge that to `main`.
278
+ 2. Push a tag `feedback-v<version>` on that commit, e.g. `feedback-v0.2.0`.
279
+ 3. `.github/workflows/release-feedback.yml` checks the tag against the version, runs the
280
+ package's typecheck and tests, builds, and runs `npm publish`.
281
+
282
+ One-time setup, done once by an owner of the npm organisation `kolibri-day`:
283
+
284
+ - Publish the very first version by hand (npm can only attach a trusted publisher to a
285
+ package that exists): `npm run build -w @kolibri-day/feedback`, then
286
+ `npm publish -w @kolibri-day/feedback --access public` while logged in.
287
+ - On npmjs.com, open the package → Settings → Trusted publishing, choose GitHub Actions,
288
+ and enter repository `LucaFrankfurt/AIfirstPMO` and workflow `release-feedback.yml`.
289
+ - Optionally, under Settings → Publishing access, require two-factor authentication and
290
+ disallow tokens, so the workflow is the only way a version can be published.
@@ -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>;