@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,100 @@
1
+ import type { FeedbackTarget, FeedbackReport, FeedbackResume, FeedbackSubmission, FeedbackTargetDescriptor, FetchLike } from '../../kernel/feedback/types.js';
2
+ import { type FormatOptions } from '../../kernel/feedback/format.js';
3
+ import { type FeedbackMessages } from '../../kernel/feedback/messages.js';
4
+ /**
5
+ * What a Kolibri target is, before it has credentials — so a web tier that holds no
6
+ * token can still tell the reporter where the screenshot goes.
7
+ *
8
+ * Self-hosted by the team that runs the app: same controller, no processor, no
9
+ * third-country transfer. It settles the EXTERNAL question only. The internal one —
10
+ * who inside the company can open a screenshot — is answered by the feedback
11
+ * project's membership, and only on a Kolibri that scopes attachments by the project
12
+ * of the task they hang off. On an older instance attachments were visible to the
13
+ * whole workspace, which is why a MINIMUM KOLIBRI VERSION is part of the setup, not
14
+ * just a private project (see the README).
15
+ */
16
+ export declare const KOLIBRI_TARGET: FeedbackTargetDescriptor;
17
+ export interface KolibriConfig {
18
+ /**
19
+ * Kolibri's origin — e.g. https://kolibri.example.com. NOT an /api path. Kolibri
20
+ * serves its API and its web app from the same origin, so the task backlink is built
21
+ * from this too.
22
+ */
23
+ apiUrl: string;
24
+ /** API token ("kol_…"), sent as `Authorization: Bearer …`. Needs the `write` scope. */
25
+ token: string;
26
+ /** Workspace UUID. A slug is refused with 403 (§A2). */
27
+ workspaceId: string;
28
+ /** UUID of the (private) feedback project. A project key is refused with 400 (§A3). */
29
+ projectId: string;
30
+ /** Per-request timeout. fetch() has none by default. */
31
+ timeoutMs?: number;
32
+ /** Language of the ticket body and of the error messages. Default: German. */
33
+ messages?: FeedbackMessages;
34
+ /** Display labels for the app's kinds in the title. */
35
+ kindLabels?: FormatOptions['kindLabels'];
36
+ }
37
+ /**
38
+ * Kolibri addresses workspaces and projects by UUID and refuses anything else — a
39
+ * slug with 403, a project key with 400 (§A2/§A3). Checking the shape up front turns
40
+ * a per-report round-trip that reads as "Kolibri rejected us" into "this setting is
41
+ * wrong".
42
+ */
43
+ export declare const UUID_PATTERN: RegExp;
44
+ /**
45
+ * Is this status worth retrying?
46
+ *
47
+ * 4xx means we sent something Kolibri will reject identically next time — a bad token,
48
+ * a project that doesn't exist, a body it won't process. Retrying burns the attempt
49
+ * budget and delays the failed report an operator needs to see. 429 and 408 are the
50
+ * exceptions inside 4xx: both explicitly mean "later".
51
+ *
52
+ * 5xx is transient. Kolibri used to answer a NOT NULL violation with 500, so a
53
+ * permanently-malformed write could masquerade as an outage; current instances answer
54
+ * those with 400, so a 5xx is what it claims to be.
55
+ */
56
+ export declare function isPermanentStatus(status: number): boolean;
57
+ export declare class KolibriFeedbackTarget implements FeedbackTarget {
58
+ readonly name: string;
59
+ readonly label: string;
60
+ readonly trustBoundary: import("../../kernel/feedback/types.js").TrustBoundary;
61
+ private readonly config;
62
+ private readonly fetchFn;
63
+ private readonly timeoutMs;
64
+ private readonly messages;
65
+ constructor(config: KolibriConfig, fetchFn?: FetchLike);
66
+ /** Base with any trailing slash removed, so path joins never double up. */
67
+ private get base();
68
+ /** The human backlink. `/t/{id}` accepts the row id or the identifier; the id is stable across a project-key rename (§F24). */
69
+ taskUrl(taskId: string): string;
70
+ private request;
71
+ /**
72
+ * POST /api/workspaces/{ws}/tasks — idempotent on the report's own id.
73
+ *
74
+ * `state_id` is deliberately absent: Kolibri fills in the project's default state
75
+ * (§A1), and naming one here would mean the app owning a workflow decision that
76
+ * belongs to whoever configures the board.
77
+ */
78
+ private createTask;
79
+ /**
80
+ * POST /api/workspaces/{ws}/files?task_id={id} — the body IS the file.
81
+ *
82
+ * Not multipart and not base64 (§C11): Kolibri takes the raw bytes with the name in
83
+ * `x-filename` and the type in `content-type`, and creates the attachment row itself
84
+ * because `?task_id=` is present. That is why there is no third call.
85
+ *
86
+ * Repeating this call is safe — Kolibri matches identical bytes against what already
87
+ * hangs on the task and hands back the existing row.
88
+ */
89
+ private uploadAttachments;
90
+ /**
91
+ * Create the task, then upload attachments.
92
+ *
93
+ * `resume` is accepted for the interface's sake but carries no information this
94
+ * target needs: the task id is the report id, so it is known before the first call
95
+ * and identical on every attempt. Re-running the create on a resume is deliberate —
96
+ * it is an upsert, it costs one request, and it repairs a task whose description
97
+ * failed to write the first time.
98
+ */
99
+ submit(report: FeedbackReport, _resume?: FeedbackResume): Promise<FeedbackSubmission>;
100
+ }
@@ -0,0 +1,175 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.KolibriFeedbackTarget = exports.UUID_PATTERN = exports.KOLIBRI_TARGET = void 0;
4
+ exports.isPermanentStatus = isPermanentStatus;
5
+ const types_ts_1 = require("../../kernel/feedback/types.js");
6
+ const format_ts_1 = require("../../kernel/feedback/format.js");
7
+ const messages_ts_1 = require("../../kernel/feedback/messages.js");
8
+ /**
9
+ * What a Kolibri target is, before it has credentials — so a web tier that holds no
10
+ * token can still tell the reporter where the screenshot goes.
11
+ *
12
+ * Self-hosted by the team that runs the app: same controller, no processor, no
13
+ * third-country transfer. It settles the EXTERNAL question only. The internal one —
14
+ * who inside the company can open a screenshot — is answered by the feedback
15
+ * project's membership, and only on a Kolibri that scopes attachments by the project
16
+ * of the task they hang off. On an older instance attachments were visible to the
17
+ * whole workspace, which is why a MINIMUM KOLIBRI VERSION is part of the setup, not
18
+ * just a private project (see the README).
19
+ */
20
+ exports.KOLIBRI_TARGET = Object.freeze({
21
+ name: 'kolibri',
22
+ label: 'Kolibri',
23
+ trustBoundary: 'self_hosted',
24
+ });
25
+ const DEFAULT_TIMEOUT_MS = 15_000;
26
+ /**
27
+ * Kolibri addresses workspaces and projects by UUID and refuses anything else — a
28
+ * slug with 403, a project key with 400 (§A2/§A3). Checking the shape up front turns
29
+ * a per-report round-trip that reads as "Kolibri rejected us" into "this setting is
30
+ * wrong".
31
+ */
32
+ exports.UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
33
+ /**
34
+ * Is this status worth retrying?
35
+ *
36
+ * 4xx means we sent something Kolibri will reject identically next time — a bad token,
37
+ * a project that doesn't exist, a body it won't process. Retrying burns the attempt
38
+ * budget and delays the failed report an operator needs to see. 429 and 408 are the
39
+ * exceptions inside 4xx: both explicitly mean "later".
40
+ *
41
+ * 5xx is transient. Kolibri used to answer a NOT NULL violation with 500, so a
42
+ * permanently-malformed write could masquerade as an outage; current instances answer
43
+ * those with 400, so a 5xx is what it claims to be.
44
+ */
45
+ function isPermanentStatus(status) {
46
+ if (status === 429 || status === 408)
47
+ return false;
48
+ return status >= 400 && status < 500;
49
+ }
50
+ /** Truncated response body for the error message. Never contains the token — that only ever travels in a header. */
51
+ async function errorBody(res) {
52
+ const text = await res.text().catch(() => '');
53
+ return text.slice(0, 500);
54
+ }
55
+ class KolibriFeedbackTarget {
56
+ name = exports.KOLIBRI_TARGET.name;
57
+ label = exports.KOLIBRI_TARGET.label;
58
+ trustBoundary = exports.KOLIBRI_TARGET.trustBoundary;
59
+ config;
60
+ fetchFn;
61
+ timeoutMs;
62
+ messages;
63
+ constructor(config, fetchFn = (input, init) => fetch(input, init)) {
64
+ this.config = config;
65
+ this.fetchFn = fetchFn;
66
+ this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
67
+ this.messages = config.messages ?? messages_ts_1.en;
68
+ }
69
+ /** Base with any trailing slash removed, so path joins never double up. */
70
+ get base() {
71
+ return this.config.apiUrl.replace(/\/+$/, '');
72
+ }
73
+ /** The human backlink. `/t/{id}` accepts the row id or the identifier; the id is stable across a project-key rename (§F24). */
74
+ taskUrl(taskId) {
75
+ return `${this.base}/t/${taskId}`;
76
+ }
77
+ async request(path, init) {
78
+ const url = `${this.base}${path}`;
79
+ try {
80
+ return await this.fetchFn(url, {
81
+ ...init,
82
+ headers: { Authorization: `Bearer ${this.config.token}`, ...(init.headers ?? {}) },
83
+ signal: AbortSignal.timeout(this.timeoutMs),
84
+ });
85
+ }
86
+ catch (e) {
87
+ // Network-level: DNS, TLS, connection refused, or our own timeout. No response
88
+ // to classify, and all of them are worth another attempt later.
89
+ throw new types_ts_1.FeedbackTargetError(this.messages.kolibri.unreachable(path, e instanceof Error ? e.message : String(e)), false);
90
+ }
91
+ }
92
+ /**
93
+ * POST /api/workspaces/{ws}/tasks — idempotent on the report's own id.
94
+ *
95
+ * `state_id` is deliberately absent: Kolibri fills in the project's default state
96
+ * (§A1), and naming one here would mean the app owning a workflow decision that
97
+ * belongs to whoever configures the board.
98
+ */
99
+ async createTask(report) {
100
+ const format = { messages: this.messages, kindLabels: this.config.kindLabels };
101
+ const res = await this.request(`/api/workspaces/${this.config.workspaceId}/tasks`, {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({
105
+ id: report.id,
106
+ project_id: this.config.projectId,
107
+ title: (0, format_ts_1.buildTitle)(report, format),
108
+ description: (0, format_ts_1.buildDescriptionMarkdown)(report, format),
109
+ }),
110
+ });
111
+ if (!res.ok) {
112
+ const body = await errorBody(res);
113
+ throw new types_ts_1.FeedbackTargetError(this.messages.kolibri.createRejected(res.status, body), isPermanentStatus(res.status), res.status);
114
+ }
115
+ }
116
+ /**
117
+ * POST /api/workspaces/{ws}/files?task_id={id} — the body IS the file.
118
+ *
119
+ * Not multipart and not base64 (§C11): Kolibri takes the raw bytes with the name in
120
+ * `x-filename` and the type in `content-type`, and creates the attachment row itself
121
+ * because `?task_id=` is present. That is why there is no third call.
122
+ *
123
+ * Repeating this call is safe — Kolibri matches identical bytes against what already
124
+ * hangs on the task and hands back the existing row.
125
+ */
126
+ async uploadAttachments(taskId, report) {
127
+ if (report.attachments.length === 0)
128
+ return 0;
129
+ let uploaded = 0;
130
+ for (const att of report.attachments) {
131
+ // Copy into a fresh ArrayBuffer before sending. Two reasons, and the second is
132
+ // the one that bites: a Uint8Array read out of a database driver may be a view
133
+ // onto a larger pooled Node Buffer, so sending the view risks sending its
134
+ // neighbours; and `Uint8Array<ArrayBufferLike>` is not a `BodyInit`, because the
135
+ // buffer behind it could be shared. The copy settles both.
136
+ const body = new ArrayBuffer(att.bytes.byteLength);
137
+ new Uint8Array(body).set(att.bytes);
138
+ const res = await this.request(`/api/workspaces/${this.config.workspaceId}/files?task_id=${encodeURIComponent(taskId)}`, {
139
+ method: 'POST',
140
+ headers: {
141
+ 'Content-Type': att.contentType,
142
+ // Kolibri decodes this and strips path separators itself; encoding is what
143
+ // keeps a non-ASCII or spaced filename from breaking the header.
144
+ 'x-filename': encodeURIComponent(att.filename),
145
+ },
146
+ body,
147
+ });
148
+ if (!res.ok) {
149
+ const detail = await errorBody(res);
150
+ throw new types_ts_1.FeedbackTargetError(this.messages.kolibri.uploadRejected(res.status, detail), isPermanentStatus(res.status), res.status,
151
+ // The task EXISTS. Carrying the id lets a durable caller persist it, which is
152
+ // belt-and-braces here rather than load-bearing: the id is the report's own,
153
+ // so a retry would find the task even without this.
154
+ taskId, this.taskUrl(taskId));
155
+ }
156
+ uploaded += 1;
157
+ }
158
+ return uploaded;
159
+ }
160
+ /**
161
+ * Create the task, then upload attachments.
162
+ *
163
+ * `resume` is accepted for the interface's sake but carries no information this
164
+ * target needs: the task id is the report id, so it is known before the first call
165
+ * and identical on every attempt. Re-running the create on a resume is deliberate —
166
+ * it is an upsert, it costs one request, and it repairs a task whose description
167
+ * failed to write the first time.
168
+ */
169
+ async submit(report, _resume) {
170
+ await this.createTask(report);
171
+ const attachmentsUploaded = await this.uploadAttachments(report.id, report);
172
+ return { externalId: report.id, externalUrl: this.taskUrl(report.id), attachmentsUploaded };
173
+ }
174
+ }
175
+ exports.KolibriFeedbackTarget = KolibriFeedbackTarget;
@@ -0,0 +1,25 @@
1
+ import { type ReactNode } from 'react';
2
+ import { type FeedbackReporterOptions, type FeedbackReporterSnapshot } from '../capture/reporter.js';
3
+ import type { CaptureResult } from '../capture/capture.js';
4
+ export interface FeedbackActions {
5
+ /** Start the capture and open the dialog. A no-op while it is already open. */
6
+ openFeedback: () => void;
7
+ }
8
+ export interface FeedbackReporterState extends FeedbackReporterSnapshot {
9
+ openFeedback: () => void;
10
+ close: () => void;
11
+ /** The settled capture — for a submit that outruns the renderer. */
12
+ waitForCapture: () => Promise<CaptureResult | null>;
13
+ }
14
+ export interface FeedbackReporterProviderProps extends FeedbackReporterOptions {
15
+ children?: ReactNode;
16
+ }
17
+ export declare function FeedbackReporterProvider({ children, capture, captureOptions }: FeedbackReporterProviderProps): import("react").JSX.Element;
18
+ /**
19
+ * For triggers. Safe to call from anywhere; a no-op outside the provider, so a
20
+ * component also rendered on a page without a reporter (a public page with nobody to
21
+ * attribute a report to) does not crash.
22
+ */
23
+ export declare function useFeedback(): FeedbackActions;
24
+ /** For the dialog: the full state plus the actions. Must be inside FeedbackReporterProvider. */
25
+ export declare function useFeedbackReporter(): FeedbackReporterState;
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ 'use client';
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.FeedbackReporterProvider = FeedbackReporterProvider;
5
+ exports.useFeedback = useFeedback;
6
+ exports.useFeedbackReporter = useFeedbackReporter;
7
+ const jsx_runtime_1 = require("react/jsx-runtime");
8
+ // React bindings for the reporter: one dialog, any number of triggers.
9
+ //
10
+ // The indirection through context is the design. Every entry point — a floating
11
+ // button, a menu item, a keyboard shortcut, a future shake-to-report — calls
12
+ // `openFeedback()` and knows nothing else; the dialog reads the state. A second copy of
13
+ // the surface would be a second copy of the rules.
14
+ //
15
+ // Two contexts, on purpose: triggers subscribe only to the stable `openFeedback`, so
16
+ // a capture finishing re-renders the dialog and not every button in the app.
17
+ //
18
+ // The markup is the app's. This package ships no dialog, because the one thing every
19
+ // app already has is its own dialog component — and a second one, styled differently,
20
+ // would be the odd one out. Remember the two attributes that keep it out of its own
21
+ // screenshot: REDACT_ATTRIBUTE on the dialog panel AND on its backdrop.
22
+ const react_1 = require("react");
23
+ const reporter_ts_1 = require("../capture/reporter.js");
24
+ const ActionsContext = (0, react_1.createContext)(null);
25
+ const ControllerContext = (0, react_1.createContext)(null);
26
+ function FeedbackReporterProvider({ children, capture, captureOptions }) {
27
+ // Created once per provider. The options are read at creation; changing them later
28
+ // would mean a different reporter, which no app has needed.
29
+ const [controller] = (0, react_1.useState)(() => new reporter_ts_1.FeedbackReporterController({ capture, captureOptions }));
30
+ const actions = (0, react_1.useMemo)(() => ({ openFeedback: controller.open }), [controller]);
31
+ return ((0, jsx_runtime_1.jsx)(ControllerContext.Provider, { value: controller, children: (0, jsx_runtime_1.jsx)(ActionsContext.Provider, { value: actions, children: children }) }));
32
+ }
33
+ const noop = { openFeedback: () => { } };
34
+ /**
35
+ * For triggers. Safe to call from anywhere; a no-op outside the provider, so a
36
+ * component also rendered on a page without a reporter (a public page with nobody to
37
+ * attribute a report to) does not crash.
38
+ */
39
+ function useFeedback() {
40
+ return (0, react_1.useContext)(ActionsContext) ?? noop;
41
+ }
42
+ /** For the dialog: the full state plus the actions. Must be inside FeedbackReporterProvider. */
43
+ function useFeedbackReporter() {
44
+ const controller = (0, react_1.useContext)(ControllerContext);
45
+ if (!controller)
46
+ throw new Error('useFeedbackReporter must be used inside <FeedbackReporterProvider>');
47
+ const snapshot = (0, react_1.useSyncExternalStore)(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
48
+ return (0, react_1.useMemo)(() => ({
49
+ ...snapshot,
50
+ openFeedback: controller.open,
51
+ close: controller.close,
52
+ waitForCapture: controller.waitForCapture,
53
+ }), [snapshot, controller]);
54
+ }
@@ -0,0 +1,4 @@
1
+ export { captureViewport, encodeWithinBudget, fitWithin, isRequestedType, REDACT_ATTRIBUTE, MAX_EDGE_PX, TARGET_MAX_BYTES, HARD_MAX_BYTES, type CaptureOptions, type CaptureResult, } from './adapters/capture/capture.js';
2
+ export { collectClientContext } from './adapters/capture/client-context.js';
3
+ export { FeedbackReporterController, type CaptureState, type FeedbackReporterSnapshot, type FeedbackReporterOptions, } from './adapters/capture/reporter.js';
4
+ export { buildFeedbackForm, submitFeedback, type FeedbackSubmitInput, type FeedbackSubmitResult, } from './adapters/capture/submit.js';
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.submitFeedback = exports.buildFeedbackForm = exports.FeedbackReporterController = exports.collectClientContext = exports.HARD_MAX_BYTES = exports.TARGET_MAX_BYTES = exports.MAX_EDGE_PX = exports.REDACT_ATTRIBUTE = exports.isRequestedType = exports.fitWithin = exports.encodeWithinBudget = exports.captureViewport = void 0;
4
+ // @kolibri-day/feedback/browser — viewport capture, browser facts, the reporter's
5
+ // state machine, and the client half of the wire protocol. Browser-only at call time;
6
+ // importing it on a server is harmless (nothing runs at module load).
7
+ var capture_ts_1 = require("./adapters/capture/capture.js");
8
+ Object.defineProperty(exports, "captureViewport", { enumerable: true, get: function () { return capture_ts_1.captureViewport; } });
9
+ Object.defineProperty(exports, "encodeWithinBudget", { enumerable: true, get: function () { return capture_ts_1.encodeWithinBudget; } });
10
+ Object.defineProperty(exports, "fitWithin", { enumerable: true, get: function () { return capture_ts_1.fitWithin; } });
11
+ Object.defineProperty(exports, "isRequestedType", { enumerable: true, get: function () { return capture_ts_1.isRequestedType; } });
12
+ Object.defineProperty(exports, "REDACT_ATTRIBUTE", { enumerable: true, get: function () { return capture_ts_1.REDACT_ATTRIBUTE; } });
13
+ Object.defineProperty(exports, "MAX_EDGE_PX", { enumerable: true, get: function () { return capture_ts_1.MAX_EDGE_PX; } });
14
+ Object.defineProperty(exports, "TARGET_MAX_BYTES", { enumerable: true, get: function () { return capture_ts_1.TARGET_MAX_BYTES; } });
15
+ Object.defineProperty(exports, "HARD_MAX_BYTES", { enumerable: true, get: function () { return capture_ts_1.HARD_MAX_BYTES; } });
16
+ var client_context_ts_1 = require("./adapters/capture/client-context.js");
17
+ Object.defineProperty(exports, "collectClientContext", { enumerable: true, get: function () { return client_context_ts_1.collectClientContext; } });
18
+ var reporter_ts_1 = require("./adapters/capture/reporter.js");
19
+ Object.defineProperty(exports, "FeedbackReporterController", { enumerable: true, get: function () { return reporter_ts_1.FeedbackReporterController; } });
20
+ var submit_ts_1 = require("./adapters/capture/submit.js");
21
+ Object.defineProperty(exports, "buildFeedbackForm", { enumerable: true, get: function () { return submit_ts_1.buildFeedbackForm; } });
22
+ Object.defineProperty(exports, "submitFeedback", { enumerable: true, get: function () { return submit_ts_1.submitFeedback; } });
@@ -0,0 +1,6 @@
1
+ export * from './kernel/feedback/types.js';
2
+ export * from './kernel/feedback/messages.js';
3
+ export * from './kernel/feedback/format.js';
4
+ export * from './kernel/feedback/notice.js';
5
+ export * from './kernel/feedback/fake-target.js';
6
+ export * from './kernel/feedback/files.js';
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ // @kolibri-day/feedback — the isomorphic core: the report shape, the target contract,
18
+ // the Markdown formatter, the privacy notice, messages, and the in-memory targets.
19
+ // No Node or DOM APIs; safe to import from server and browser code alike.
20
+ __exportStar(require("./kernel/feedback/types.js"), exports);
21
+ __exportStar(require("./kernel/feedback/messages.js"), exports);
22
+ __exportStar(require("./kernel/feedback/format.js"), exports);
23
+ __exportStar(require("./kernel/feedback/notice.js"), exports);
24
+ __exportStar(require("./kernel/feedback/fake-target.js"), exports);
25
+ __exportStar(require("./kernel/feedback/files.js"), exports);
@@ -0,0 +1,64 @@
1
+ import { type FeedbackReport, type FeedbackSubmission, type FeedbackTarget } from './types.js';
2
+ export type StoredFeedbackStatus = 'pending' | 'submitted' | 'failed';
3
+ export interface StoredFeedback {
4
+ report: FeedbackReport;
5
+ status: StoredFeedbackStatus;
6
+ /** Set when an earlier attempt created the item at the target but did not finish. */
7
+ externalId: string | null;
8
+ }
9
+ export interface FeedbackSubmittedUpdate {
10
+ targetName: string;
11
+ externalId: string;
12
+ externalUrl: string | null;
13
+ submittedAt: Date;
14
+ }
15
+ export interface FeedbackFailureUpdate {
16
+ targetName: string;
17
+ /** The error message, truncated — safe to show an operator (targets never put credentials in it). */
18
+ message: string;
19
+ /** Mark the report failed for good; no attempt will ever succeed. */
20
+ permanent: boolean;
21
+ /** The item exists at the target — persist it so the next attempt resumes instead of re-creating. */
22
+ externalId?: string;
23
+ externalUrl?: string | null;
24
+ }
25
+ /** Where the app keeps its reports. Implement it over your own database. */
26
+ export interface FeedbackStore {
27
+ load(id: string): Promise<StoredFeedback | null>;
28
+ /** Record delivery — AND drop the stored screenshot bytes (rule 3). Clears any recorded error. */
29
+ markSubmitted(id: string, update: FeedbackSubmittedUpdate): Promise<void>;
30
+ /** Record a failed attempt (rule 1 and 2). */
31
+ recordFailure(id: string, update: FeedbackFailureUpdate): Promise<void>;
32
+ }
33
+ export type DispatchResult = {
34
+ outcome: 'submitted';
35
+ submission: FeedbackSubmission;
36
+ } | {
37
+ outcome: 'missing';
38
+ } | {
39
+ outcome: 'already_submitted';
40
+ } | {
41
+ outcome: 'failed_permanently';
42
+ message: string;
43
+ };
44
+ export interface DispatchLogger {
45
+ log(message: string): void;
46
+ error(message: string): void;
47
+ }
48
+ export interface DispatchOptions {
49
+ store: FeedbackStore;
50
+ target: FeedbackTarget;
51
+ logger?: DispatchLogger;
52
+ }
53
+ /** Longest error message recorded on a report. */
54
+ export declare const MAX_ERROR_LENGTH = 1000;
55
+ /**
56
+ * Deliver one stored report.
57
+ *
58
+ * Resolves on success, on nothing-to-do, and on a PERMANENT failure (recorded — the
59
+ * failed report IS the alarm, and rethrowing would only burn the retry budget).
60
+ * Throws on a TRANSIENT failure, after recording it, so the caller's queue backs off
61
+ * and tries again. Any error that is not a FeedbackTargetError (a bug, a database
62
+ * hiccup) counts as transient: it is not known to be hopeless.
63
+ */
64
+ export declare function dispatchFeedback(id: string, options: DispatchOptions): Promise<DispatchResult>;
@@ -0,0 +1,93 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.MAX_ERROR_LENGTH = void 0;
4
+ exports.dispatchFeedback = dispatchFeedback;
5
+ // Delivering a STORED report to the configured target — the half of the design that
6
+ // makes feedback honest about failure.
7
+ //
8
+ // The recommended shape is durable: the app confirms a report as soon as it is
9
+ // stored, and delivery runs later from a job queue that provides backoff, an attempt
10
+ // budget and an operator-visible failed state. A synchronous submit in the request
11
+ // path would lose the report outright whenever the tracker is down — which is
12
+ // precisely when people are filing bug reports.
13
+ //
14
+ // The package owns the RULES; the app owns storage and the queue, behind the small
15
+ // FeedbackStore interface below. Three rules worth reading before changing anything:
16
+ //
17
+ // 1. THE EXTERNAL ID IS PERSISTED BEFORE THE ATTACHMENT UPLOAD IS RETRIED. Creating
18
+ // the item and uploading the screenshot are separate calls at most trackers; a
19
+ // failure between them must resume the upload, never create a second ticket. The
20
+ // target reports a post-create failure by carrying the id on the error, and it is
21
+ // recorded immediately.
22
+ //
23
+ // 2. PERMANENT FAILURES DO NOT RETRY. A bad token or a missing project fails
24
+ // identically on every attempt, and the delay only postpones the failed report
25
+ // someone needs to see. Transient failures are rethrown into the queue's backoff.
26
+ //
27
+ // 3. THE SCREENSHOT IS DELETED ON SUCCESS. Once the target holds the bytes, a second
28
+ // copy in the app's database is pure added exposure — markSubmitted must drop it
29
+ // (keeping only metadata such as type and size, if the app wants to show that one
30
+ // was attached).
31
+ const types_ts_1 = require("./types.js");
32
+ const defaultLogger = {
33
+ log: (m) => console.log(`[feedback] ${m}`),
34
+ error: (m) => console.error(`[feedback] ${m}`),
35
+ };
36
+ /** Longest error message recorded on a report. */
37
+ exports.MAX_ERROR_LENGTH = 1_000;
38
+ /**
39
+ * Deliver one stored report.
40
+ *
41
+ * Resolves on success, on nothing-to-do, and on a PERMANENT failure (recorded — the
42
+ * failed report IS the alarm, and rethrowing would only burn the retry budget).
43
+ * Throws on a TRANSIENT failure, after recording it, so the caller's queue backs off
44
+ * and tries again. Any error that is not a FeedbackTargetError (a bug, a database
45
+ * hiccup) counts as transient: it is not known to be hopeless.
46
+ */
47
+ async function dispatchFeedback(id, options) {
48
+ const { store, target } = options;
49
+ const logger = options.logger ?? defaultLogger;
50
+ const stored = await store.load(id);
51
+ // Gone, or already delivered: complete rather than throw. A job that can never
52
+ // succeed must not retry to exhaustion and then sit failed with nothing for an
53
+ // operator to act on.
54
+ if (!stored) {
55
+ logger.log(`report ${id} no longer exists — skipping`);
56
+ return { outcome: 'missing' };
57
+ }
58
+ if (stored.status === 'submitted') {
59
+ logger.log(`report ${id} already submitted (${stored.externalId ?? '?'}) — skipping`);
60
+ return { outcome: 'already_submitted' };
61
+ }
62
+ try {
63
+ // Resume when the item already exists (create succeeded, upload didn't).
64
+ const resume = stored.externalId ? { externalId: stored.externalId } : undefined;
65
+ const submission = await target.submit(stored.report, resume);
66
+ await store.markSubmitted(id, {
67
+ targetName: target.name,
68
+ externalId: submission.externalId,
69
+ externalUrl: submission.externalUrl,
70
+ submittedAt: new Date(),
71
+ });
72
+ logger.log(`report ${id} -> ${target.name} ${submission.externalId}`);
73
+ return { outcome: 'submitted', submission };
74
+ }
75
+ catch (e) {
76
+ const err = e instanceof types_ts_1.FeedbackTargetError ? e : null;
77
+ const message = (e instanceof Error ? e.message : String(e)).slice(0, exports.MAX_ERROR_LENGTH);
78
+ const permanent = err?.permanent ?? false;
79
+ await store.recordFailure(id, {
80
+ targetName: target.name,
81
+ message,
82
+ permanent,
83
+ // Written the moment it is known — rule 1. Without it, the next attempt creates
84
+ // a duplicate ticket at any target whose create is not idempotent.
85
+ ...(err?.externalId ? { externalId: err.externalId, externalUrl: err.externalUrl ?? null } : {}),
86
+ });
87
+ if (permanent) {
88
+ logger.error(`report ${id} permanently failed: ${message}`);
89
+ return { outcome: 'failed_permanently', message };
90
+ }
91
+ throw e;
92
+ }
93
+ }
@@ -0,0 +1,58 @@
1
+ import type { FeedbackTarget, FeedbackReport, FeedbackResume, FeedbackSubmission, TrustBoundary } from './types.js';
2
+ import { FeedbackTargetError } from './types.js';
3
+ export interface FakeSubmission {
4
+ report: FeedbackReport;
5
+ resume: FeedbackResume | undefined;
6
+ externalId: string;
7
+ }
8
+ export interface FakeFeedbackTargetOptions {
9
+ /** Origin used for the fake backlinks. */
10
+ webUrl?: string;
11
+ label?: string;
12
+ }
13
+ export declare class FakeFeedbackTarget implements FeedbackTarget {
14
+ readonly name = "fake";
15
+ readonly label: string;
16
+ /** Nothing leaves the process, so it is trivially inside the trust boundary. */
17
+ readonly trustBoundary: TrustBoundary;
18
+ /** Every submit() call, in order — including the ones that then threw. */
19
+ readonly submissions: FakeSubmission[];
20
+ /** Calls that actually created a new item (i.e. were not resumes). Duplicate-ticket detector. */
21
+ readonly creates: string[];
22
+ private seq;
23
+ private failures;
24
+ private readonly webUrl;
25
+ constructor(options?: FakeFeedbackTargetOptions);
26
+ /** Queue a failure for the NEXT submit() call (repeat to script a sequence). */
27
+ failNext(error: FeedbackTargetError): this;
28
+ /** Convenience: the two classifications, scripted. */
29
+ failNextTransient(message?: string): this;
30
+ failNextPermanent(message?: string): this;
31
+ /**
32
+ * Script the partial failure that resumability exists for: the item was created,
33
+ * the attachment upload then failed. The error carries the id, exactly as a real
34
+ * target does, so a durable caller can persist it and resume.
35
+ */
36
+ failNextAfterCreate(message?: string): this;
37
+ submit(report: FeedbackReport, resume?: FeedbackResume): Promise<FeedbackSubmission>;
38
+ }
39
+ /**
40
+ * A target that always fails permanently with a configuration message.
41
+ *
42
+ * This is what an unconfigured production deployment gets, and the choice is
43
+ * deliberate: reports are still PERSISTED and the failure is LOUD (a failed report
44
+ * naming the missing variables). The alternative — silently discarding reports, or
45
+ * refusing the button — would mean people file feedback into nothing and conclude the
46
+ * team ignored them.
47
+ *
48
+ * It is also why a missing variable does not throw at process BOOT: that would take
49
+ * every other job in the same worker down over a feedback misconfiguration.
50
+ */
51
+ export declare class UnconfiguredFeedbackTarget implements FeedbackTarget {
52
+ readonly name = "unconfigured";
53
+ readonly trustBoundary: TrustBoundary;
54
+ readonly label: string;
55
+ private readonly reason;
56
+ constructor(reason: string, label?: string);
57
+ submit(_report?: FeedbackReport, _resume?: FeedbackResume): Promise<FeedbackSubmission>;
58
+ }