@weatherboard/annotation 0.1.0 → 0.2.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.
@@ -0,0 +1,185 @@
1
+ import type { AnnotationSdk, AnnotationSdkOptions, CommentCollector } from "./sdk.mjs";
2
+ import { type Arming } from "./arming.mjs";
3
+ import type { EgressOutcome } from "./transport.mjs";
4
+ /**
5
+ * The toolbar and the post-it (A-240).
6
+ *
7
+ * ===========================================================================
8
+ * WHY THIS EXISTS AT ALL
9
+ * ===========================================================================
10
+ *
11
+ * `sdk.ts` built its payload with `comment: ""` and a comment saying why: *the
12
+ * parent frame collects the human's words and merges them in verbatim*. First
13
+ * party there is no parent — see `docs/plans/first-party-annotation.md` — so a
14
+ * capture would file an issue carrying a screenshot, a `file:line` and NOTHING
15
+ * SAYING WHAT IS WRONG WITH IT. This is the surface that collects the words,
16
+ * and the surface that turns annotation mode on in the first place.
17
+ *
18
+ * The toggle is the point rather than a convenience. The reviewer navigates the
19
+ * real app with annotation OFF — clicking rows, opening dialogs, finishing a
20
+ * wizard — and switches it ON when they arrive somewhere worth commenting on.
21
+ * That is what stops click-only screens being a wall, and it is why this is a
22
+ * toolbar and not a mode the page boots into.
23
+ *
24
+ * ===========================================================================
25
+ * IT RENDERS INSIDE SOMEBODY ELSE'S PRODUCT, AND THAT DECIDES ALMOST EVERYTHING
26
+ * ===========================================================================
27
+ *
28
+ * 1. **Shadow DOM, closed to the product's CSS in both directions.** The
29
+ * product cannot restyle this and this cannot restyle the product. A
30
+ * toolbar inheriting a host's `* { box-sizing: border-box }` — or its
31
+ * `button { text-transform: uppercase }` — is a bug report about our own UI,
32
+ * filed against their product, by a tool whose entire output is bug reports.
33
+ * `:host { all: initial }` is the half people forget: a shadow root stops
34
+ * SELECTORS crossing, not INHERITANCE. `e2e/toolbar.spec.ts` proves both
35
+ * halves against a deliberately hostile fixture, and proves them the only
36
+ * way that means anything — by deleting each and watching it go red.
37
+ *
38
+ * THE ONE THING IT CANNOT DEFEND, said out loud rather than left to be
39
+ * discovered: the HOST is in the light DOM, so the product's own selectors
40
+ * reach it. `#ai-annotation-toolbar { position: static }` in a customer's
41
+ * stylesheet beats `:host`, and puts this in the flow. Nothing shadow DOM
42
+ * offers prevents that; the id is documented so that a collision is at
43
+ * least legible when somebody hits it.
44
+ *
45
+ * 2. **It must not alter what is being reviewed.** Everything is `position:
46
+ * fixed` and nothing is ever appended to the flow, so there is no layout
47
+ * shift and no scrollbar; and NOTHING IS FOCUSED ON MOUNT. The reviewer is
48
+ * here to judge pixels, and a tool that moves one of them has answered the
49
+ * question wrongly before anybody clicks anything.
50
+ *
51
+ * `position: fixed` is doing ALL of that work, and the viewport clamp in
52
+ * `position()` is doing NONE of it — see the note there, which used to
53
+ * claim otherwise.
54
+ *
55
+ * 3. **No product may be named here (A-98).** Every string in `DEFAULT_LABELS`
56
+ * is generic, and every one of them is overridable by the mounting app. The
57
+ * only text this renders that it did not author is the target's own
58
+ * `file:line`, which is the customer's source path and belongs to them.
59
+ *
60
+ * ===========================================================================
61
+ * WHY THE CSS IS A TEMPLATE STRING AND WHAT THAT COSTS
62
+ * ===========================================================================
63
+ *
64
+ * A `<style>` element inside the shadow root, whose `textContent` is the string
65
+ * below. Not inline styles: `overlay.ts` uses those and carries nine entries in
66
+ * `audit-allowance.json` for it, and that ledger may only ever get shorter.
67
+ *
68
+ * TWO AUDITS READ THIS REPOSITORY AND THEY DISAGREE ABOUT THIS FILE, which is
69
+ * worth knowing before trusting either.
70
+ *
71
+ * `packages/design-audit` — `pnpm verify`'s ratchet — applies its colour,
72
+ * space, radius and type rules to `.css` FILES ONLY: `scan.ts` classifies
73
+ * every `.ts` as `tsx`, where the only rules are `inline-style` and the
74
+ * class-name pair. It sees nothing here.
75
+ *
76
+ * **The `design-system` CI job does.** It reads CSS out of `.ts` source, and
77
+ * the ledger it judges against — at the repository root, alongside
78
+ * `audit-allowance.json` and under the same only-ever-shorter rule — already
79
+ * carries `overlay.ts` for exactly that. THIS WAS MEASURED RATHER THAN
80
+ * REASONED: the first version of this file was written believing nothing
81
+ * audited it, said so in this comment, and that job failed the pull request
82
+ * with `untokenised-colour` and `untokenised-space` against it. The check won
83
+ * and the assumption is corrected here, where the assumption was.
84
+ *
85
+ * Hence the token block at the top of `CSS`. Every colour, space and radius
86
+ * below is a `var()` into it, so there is one place to change them and the
87
+ * gate can see that there is. They are `--ai-*` and NOT the design system's
88
+ * `--color-*` / `--space-*`, and that is forced rather than chosen: this
89
+ * package has zero dependencies on purpose, so the dictionary is not
90
+ * importable — and inside a shadow root a custom property resolves against the
91
+ * PRODUCT'S, so `var(--color-fg)` would paint this toolbar with whatever the
92
+ * customer happens to mean by that name. A local, prefixed block cannot
93
+ * collide with theirs.
94
+ */
95
+ /**
96
+ * Every word this renders. Generic by default, and all of them injectable.
97
+ *
98
+ * Every field is RENDERED SOMEWHERE. A label nobody displays is an API promise
99
+ * that cannot be kept and bytes on every page of every managed product; three
100
+ * were written and deleted before this shipped for exactly that reason.
101
+ */
102
+ export interface AnnotationToolbarLabels {
103
+ /** Names the whole surface for a screen reader. */
104
+ region: string;
105
+ expand: string;
106
+ collapse: string;
107
+ start: string;
108
+ stop: string;
109
+ done: string;
110
+ comment: string;
111
+ placeholder: string;
112
+ send: string;
113
+ cancel: string;
114
+ sent: string;
115
+ issue: string;
116
+ failed: string;
117
+ retry: string;
118
+ }
119
+ export declare const DEFAULT_LABELS: AnnotationToolbarLabels;
120
+ /** The host's id. Our own node; nothing outside this package looks it up. */
121
+ export declare const TOOLBAR_ID = "ai-annotation-toolbar";
122
+ export interface AnnotationToolbarOptions {
123
+ /**
124
+ * Whether this page may offer an annotation UI at all.
125
+ *
126
+ * INJECTED, and deliberately not decided here. How arming is decided — the
127
+ * token in the URL, the cookie, the `replaceState` that strips it, the
128
+ * production kill-switch — is A-241, and a toolbar that read the URL itself
129
+ * would be a second answer to a question that must have exactly one.
130
+ *
131
+ * Unarmed, NOTHING IS APPENDED TO THE DOCUMENT. Not hidden: absent.
132
+ */
133
+ armed?: boolean;
134
+ doc?: Document;
135
+ /** Only for the viewport clamp, so a fake one is two numbers. */
136
+ win?: Pick<Window, "innerWidth" | "innerHeight">;
137
+ labels?: Partial<AnnotationToolbarLabels>;
138
+ /** *Done* — the reviewer is finished. A-241 owns what that clears. */
139
+ onDone?: () => void;
140
+ }
141
+ export interface AnnotationToolbar {
142
+ /** Is this node ours? Hand it to `AnnotationSdkOptions.ignoreElement`. */
143
+ owns: (node: unknown) => boolean;
144
+ /** Wire the toggle to a live SDK. */
145
+ attach: (sdk: AnnotationSdk) => void;
146
+ /** Hand to `AnnotationSdkOptions.collectComment`. */
147
+ collectComment: CommentCollector;
148
+ /** Hand to `AnnotationSdkOptions.onCaptureResult`. */
149
+ showResult: (outcome: EgressOutcome, retry: () => void) => void;
150
+ /** Whether the panel is open. For tests and for the mounting app. */
151
+ isExpanded: () => boolean;
152
+ destroy: () => void;
153
+ }
154
+ export declare function createAnnotationToolbar(options?: AnnotationToolbarOptions): AnnotationToolbar;
155
+ export interface MountAnnotationOptions extends AnnotationSdkOptions {
156
+ toolbar?: AnnotationToolbarOptions;
157
+ /**
158
+ * The arming decision, from `readArming()` (A-241).
159
+ *
160
+ * Passing it is how the two gates get applied without every consumer
161
+ * re-deriving them: `armed` decides whether a toolbar exists at all, and
162
+ * *Done* calls `disarm` so the cookie and the stored capture token go with it.
163
+ *
164
+ * OPTIONAL because the frame path still mounts a toolbar-less SDK and has no
165
+ * deep link to read. Supplying both this and `toolbar.armed` throws — two
166
+ * answers to a question that must have exactly one.
167
+ */
168
+ arming?: Arming;
169
+ }
170
+ export interface MountedAnnotation {
171
+ sdk: AnnotationSdk;
172
+ toolbar: AnnotationToolbar;
173
+ destroy: () => void;
174
+ }
175
+ /**
176
+ * Build the SDK and the toolbar and connect them (A-240).
177
+ *
178
+ * Three wires, and each one is a silent failure if it is missing:
179
+ * `collectComment` absent files an issue with an empty comment;
180
+ * `onCaptureResult` absent leaves a reviewer who cannot tell a sent capture
181
+ * from a lost one; `ignoreElement` absent captures our own toolbar. None of
182
+ * them throws when omitted, which is exactly why they belong in one function
183
+ * rather than in three lines of every consumer's bootstrap.
184
+ */
185
+ export declare function mountAnnotation(options: MountAnnotationOptions): MountedAnnotation;
@@ -0,0 +1,185 @@
1
+ import type { AnnotationSdk, AnnotationSdkOptions, CommentCollector } from "./sdk.js";
2
+ import { type Arming } from "./arming.js";
3
+ import type { EgressOutcome } from "./transport.js";
4
+ /**
5
+ * The toolbar and the post-it (A-240).
6
+ *
7
+ * ===========================================================================
8
+ * WHY THIS EXISTS AT ALL
9
+ * ===========================================================================
10
+ *
11
+ * `sdk.ts` built its payload with `comment: ""` and a comment saying why: *the
12
+ * parent frame collects the human's words and merges them in verbatim*. First
13
+ * party there is no parent — see `docs/plans/first-party-annotation.md` — so a
14
+ * capture would file an issue carrying a screenshot, a `file:line` and NOTHING
15
+ * SAYING WHAT IS WRONG WITH IT. This is the surface that collects the words,
16
+ * and the surface that turns annotation mode on in the first place.
17
+ *
18
+ * The toggle is the point rather than a convenience. The reviewer navigates the
19
+ * real app with annotation OFF — clicking rows, opening dialogs, finishing a
20
+ * wizard — and switches it ON when they arrive somewhere worth commenting on.
21
+ * That is what stops click-only screens being a wall, and it is why this is a
22
+ * toolbar and not a mode the page boots into.
23
+ *
24
+ * ===========================================================================
25
+ * IT RENDERS INSIDE SOMEBODY ELSE'S PRODUCT, AND THAT DECIDES ALMOST EVERYTHING
26
+ * ===========================================================================
27
+ *
28
+ * 1. **Shadow DOM, closed to the product's CSS in both directions.** The
29
+ * product cannot restyle this and this cannot restyle the product. A
30
+ * toolbar inheriting a host's `* { box-sizing: border-box }` — or its
31
+ * `button { text-transform: uppercase }` — is a bug report about our own UI,
32
+ * filed against their product, by a tool whose entire output is bug reports.
33
+ * `:host { all: initial }` is the half people forget: a shadow root stops
34
+ * SELECTORS crossing, not INHERITANCE. `e2e/toolbar.spec.ts` proves both
35
+ * halves against a deliberately hostile fixture, and proves them the only
36
+ * way that means anything — by deleting each and watching it go red.
37
+ *
38
+ * THE ONE THING IT CANNOT DEFEND, said out loud rather than left to be
39
+ * discovered: the HOST is in the light DOM, so the product's own selectors
40
+ * reach it. `#ai-annotation-toolbar { position: static }` in a customer's
41
+ * stylesheet beats `:host`, and puts this in the flow. Nothing shadow DOM
42
+ * offers prevents that; the id is documented so that a collision is at
43
+ * least legible when somebody hits it.
44
+ *
45
+ * 2. **It must not alter what is being reviewed.** Everything is `position:
46
+ * fixed` and nothing is ever appended to the flow, so there is no layout
47
+ * shift and no scrollbar; and NOTHING IS FOCUSED ON MOUNT. The reviewer is
48
+ * here to judge pixels, and a tool that moves one of them has answered the
49
+ * question wrongly before anybody clicks anything.
50
+ *
51
+ * `position: fixed` is doing ALL of that work, and the viewport clamp in
52
+ * `position()` is doing NONE of it — see the note there, which used to
53
+ * claim otherwise.
54
+ *
55
+ * 3. **No product may be named here (A-98).** Every string in `DEFAULT_LABELS`
56
+ * is generic, and every one of them is overridable by the mounting app. The
57
+ * only text this renders that it did not author is the target's own
58
+ * `file:line`, which is the customer's source path and belongs to them.
59
+ *
60
+ * ===========================================================================
61
+ * WHY THE CSS IS A TEMPLATE STRING AND WHAT THAT COSTS
62
+ * ===========================================================================
63
+ *
64
+ * A `<style>` element inside the shadow root, whose `textContent` is the string
65
+ * below. Not inline styles: `overlay.ts` uses those and carries nine entries in
66
+ * `audit-allowance.json` for it, and that ledger may only ever get shorter.
67
+ *
68
+ * TWO AUDITS READ THIS REPOSITORY AND THEY DISAGREE ABOUT THIS FILE, which is
69
+ * worth knowing before trusting either.
70
+ *
71
+ * `packages/design-audit` — `pnpm verify`'s ratchet — applies its colour,
72
+ * space, radius and type rules to `.css` FILES ONLY: `scan.ts` classifies
73
+ * every `.ts` as `tsx`, where the only rules are `inline-style` and the
74
+ * class-name pair. It sees nothing here.
75
+ *
76
+ * **The `design-system` CI job does.** It reads CSS out of `.ts` source, and
77
+ * the ledger it judges against — at the repository root, alongside
78
+ * `audit-allowance.json` and under the same only-ever-shorter rule — already
79
+ * carries `overlay.ts` for exactly that. THIS WAS MEASURED RATHER THAN
80
+ * REASONED: the first version of this file was written believing nothing
81
+ * audited it, said so in this comment, and that job failed the pull request
82
+ * with `untokenised-colour` and `untokenised-space` against it. The check won
83
+ * and the assumption is corrected here, where the assumption was.
84
+ *
85
+ * Hence the token block at the top of `CSS`. Every colour, space and radius
86
+ * below is a `var()` into it, so there is one place to change them and the
87
+ * gate can see that there is. They are `--ai-*` and NOT the design system's
88
+ * `--color-*` / `--space-*`, and that is forced rather than chosen: this
89
+ * package has zero dependencies on purpose, so the dictionary is not
90
+ * importable — and inside a shadow root a custom property resolves against the
91
+ * PRODUCT'S, so `var(--color-fg)` would paint this toolbar with whatever the
92
+ * customer happens to mean by that name. A local, prefixed block cannot
93
+ * collide with theirs.
94
+ */
95
+ /**
96
+ * Every word this renders. Generic by default, and all of them injectable.
97
+ *
98
+ * Every field is RENDERED SOMEWHERE. A label nobody displays is an API promise
99
+ * that cannot be kept and bytes on every page of every managed product; three
100
+ * were written and deleted before this shipped for exactly that reason.
101
+ */
102
+ export interface AnnotationToolbarLabels {
103
+ /** Names the whole surface for a screen reader. */
104
+ region: string;
105
+ expand: string;
106
+ collapse: string;
107
+ start: string;
108
+ stop: string;
109
+ done: string;
110
+ comment: string;
111
+ placeholder: string;
112
+ send: string;
113
+ cancel: string;
114
+ sent: string;
115
+ issue: string;
116
+ failed: string;
117
+ retry: string;
118
+ }
119
+ export declare const DEFAULT_LABELS: AnnotationToolbarLabels;
120
+ /** The host's id. Our own node; nothing outside this package looks it up. */
121
+ export declare const TOOLBAR_ID = "ai-annotation-toolbar";
122
+ export interface AnnotationToolbarOptions {
123
+ /**
124
+ * Whether this page may offer an annotation UI at all.
125
+ *
126
+ * INJECTED, and deliberately not decided here. How arming is decided — the
127
+ * token in the URL, the cookie, the `replaceState` that strips it, the
128
+ * production kill-switch — is A-241, and a toolbar that read the URL itself
129
+ * would be a second answer to a question that must have exactly one.
130
+ *
131
+ * Unarmed, NOTHING IS APPENDED TO THE DOCUMENT. Not hidden: absent.
132
+ */
133
+ armed?: boolean;
134
+ doc?: Document;
135
+ /** Only for the viewport clamp, so a fake one is two numbers. */
136
+ win?: Pick<Window, "innerWidth" | "innerHeight">;
137
+ labels?: Partial<AnnotationToolbarLabels>;
138
+ /** *Done* — the reviewer is finished. A-241 owns what that clears. */
139
+ onDone?: () => void;
140
+ }
141
+ export interface AnnotationToolbar {
142
+ /** Is this node ours? Hand it to `AnnotationSdkOptions.ignoreElement`. */
143
+ owns: (node: unknown) => boolean;
144
+ /** Wire the toggle to a live SDK. */
145
+ attach: (sdk: AnnotationSdk) => void;
146
+ /** Hand to `AnnotationSdkOptions.collectComment`. */
147
+ collectComment: CommentCollector;
148
+ /** Hand to `AnnotationSdkOptions.onCaptureResult`. */
149
+ showResult: (outcome: EgressOutcome, retry: () => void) => void;
150
+ /** Whether the panel is open. For tests and for the mounting app. */
151
+ isExpanded: () => boolean;
152
+ destroy: () => void;
153
+ }
154
+ export declare function createAnnotationToolbar(options?: AnnotationToolbarOptions): AnnotationToolbar;
155
+ export interface MountAnnotationOptions extends AnnotationSdkOptions {
156
+ toolbar?: AnnotationToolbarOptions;
157
+ /**
158
+ * The arming decision, from `readArming()` (A-241).
159
+ *
160
+ * Passing it is how the two gates get applied without every consumer
161
+ * re-deriving them: `armed` decides whether a toolbar exists at all, and
162
+ * *Done* calls `disarm` so the cookie and the stored capture token go with it.
163
+ *
164
+ * OPTIONAL because the frame path still mounts a toolbar-less SDK and has no
165
+ * deep link to read. Supplying both this and `toolbar.armed` throws — two
166
+ * answers to a question that must have exactly one.
167
+ */
168
+ arming?: Arming;
169
+ }
170
+ export interface MountedAnnotation {
171
+ sdk: AnnotationSdk;
172
+ toolbar: AnnotationToolbar;
173
+ destroy: () => void;
174
+ }
175
+ /**
176
+ * Build the SDK and the toolbar and connect them (A-240).
177
+ *
178
+ * Three wires, and each one is a silent failure if it is missing:
179
+ * `collectComment` absent files an issue with an empty comment;
180
+ * `onCaptureResult` absent leaves a reviewer who cannot tell a sent capture
181
+ * from a lost one; `ignoreElement` absent captures our own toolbar. None of
182
+ * them throws when omitted, which is exactly why they belong in one function
183
+ * rather than in three lines of every consumer's bootstrap.
184
+ */
185
+ export declare function mountAnnotation(options: MountAnnotationOptions): MountedAnnotation;
@@ -0,0 +1,218 @@
1
+ import { type FrameToParent } from "./protocol.cjs";
2
+ /**
3
+ * Egress (A-239).
4
+ *
5
+ * ===========================================================================
6
+ * WHY THIS IS A SEAM AND NOT A BRANCH IN `sdk.ts`
7
+ * ===========================================================================
8
+ *
9
+ * `sdk.ts` had exactly one way out — `win.parent.postMessage(...)` — and
10
+ * first-party there is no parent: `win.parent === win`. Every capture then
11
+ * posts a message to the page that sent it and nothing receives it. **That
12
+ * fails silently**, which is the shape to be careful of here: the overlay
13
+ * behaves correctly, the reviewer clicks, and no issue is ever filed.
14
+ *
15
+ * See `docs/plans/first-party-annotation.md`. The console stops framing the
16
+ * product and links out to it instead, so the capture has to leave the page
17
+ * over HTTP. The frame is NOT being deleted — `postMessageTransport` is
18
+ * today's behaviour, extracted unchanged, and it is still the default.
19
+ *
20
+ * ===========================================================================
21
+ * TWO PROPERTIES THAT HAD TO SURVIVE
22
+ * ===========================================================================
23
+ *
24
+ * 1. **Never a wildcard, in either direction.** `assertConcreteOrigin` still
25
+ * guards the message path, and `assertConcreteEndpoint` is its equivalent
26
+ * for HTTP. Both throw AT CONSTRUCTION rather than at first capture,
27
+ * because a misconfiguration that only surfaces when a reviewer finally
28
+ * clicks something is a misconfiguration nobody finds in review.
29
+ *
30
+ * 2. **The screenshot never costs the capture**, and neither may the network.
31
+ * `attachScreenshot` resolves within its own timeout and never rejects, so
32
+ * the payload goes out either way. An HTTP transport would reintroduce
33
+ * exactly the failure mode that was designed out — a slow network losing a
34
+ * capture — unless it carries its own timeout and its own `catch`, and
35
+ * unless a failed send is REPORTABLE rather than swallowed. A dropped
36
+ * capture that looks like a successful one is the worst outcome available
37
+ * here, so `send` rejects loudly and `createEgress` routes that to the
38
+ * caller.
39
+ */
40
+ /** Every message the page half can emit. */
41
+ export type FrameMessageKind = FrameToParent["type"];
42
+ /**
43
+ * What the far end said about a capture it accepted (A-240).
44
+ *
45
+ * Everything on it is optional and always will be. `postMessageTransport`
46
+ * cannot answer at all — `postMessage` has no reply — and the ingest may
47
+ * answer with nothing. A reviewer who is told *sent* and given no link is in a
48
+ * strictly better position than one told nothing, so an absent `issueUrl` is a
49
+ * successful send and never a failed one.
50
+ */
51
+ export interface SendReceipt {
52
+ /**
53
+ * The issue this capture became, when the far end names one.
54
+ *
55
+ * ANY VALUE HERE ENDS UP IN A THIRD PARTY'S DOM as an `href`, so the type
56
+ * alone is not the contract — see `safeIssueUrl`, which is what every value
57
+ * crossing the network boundary is put through before it gets to be one.
58
+ */
59
+ issueUrl?: string;
60
+ }
61
+ /**
62
+ * A URL from the network that is safe to put in somebody else's page (A-240).
63
+ *
64
+ * ===========================================================================
65
+ * WHY THIS IS NOT PARANOIA
66
+ * ===========================================================================
67
+ *
68
+ * `httpTransport` reads `issueUrl` out of a RESPONSE BODY and the toolbar sets
69
+ * it as an `<a href>` INSIDE THE CUSTOMER'S PAGE. `javascript:alert(1)` there
70
+ * executes in the product's origin the moment the reviewer clicks *View
71
+ * issue* — a script injection into a third party's site, shipped by us, on
72
+ * every page of every managed product.
73
+ *
74
+ * `assertConcreteEndpoint` does not cover this and was never going to:
75
+ * it constrains WHERE WE SEND, and this is WHAT COMES BACK. An ingest that is
76
+ * misconfigured, compromised, or simply behind a proxy that rewrites bodies is
77
+ * exactly the case a transport has to survive, and "our own console would
78
+ * never" is not a security boundary.
79
+ *
80
+ * It lives HERE and not in the toolbar so the guarantee travels with the
81
+ * receipt rather than with one renderer of it. The toolbar calls it too — see
82
+ * `showResult` — because it renders receipts from ANY transport, including one
83
+ * a consumer wrote, which never passes through this function.
84
+ *
85
+ * Anything not `http(s)` is dropped rather than repaired. A dropped link is a
86
+ * toast that says the capture was sent and offers no link, which is the
87
+ * outcome the receipt is already designed to survive.
88
+ */
89
+ export declare function safeIssueUrl(value: unknown): string | undefined;
90
+ export interface AnnotationTransport {
91
+ /** For diagnostics and for tests; never sent anywhere. */
92
+ readonly name: string;
93
+ /**
94
+ * May this transport carry this kind of message?
95
+ *
96
+ * The interface needs this because `annotate:hover` fires once per animation
97
+ * frame while the reviewer moves the mouse. Over `postMessage` that is a
98
+ * structured clone to a frame that is already listening; over HTTP it is one
99
+ * request per frame, which is a denial of service aimed at our own console.
100
+ *
101
+ * A transport that DROPPED such a message silently would be the same bug
102
+ * this issue exists to remove, one layer down. So declining is an explicit
103
+ * answer the SDK asks for and respects, and `send` is never called for a
104
+ * kind that was declined.
105
+ */
106
+ carries(kind: FrameMessageKind): boolean;
107
+ /**
108
+ * Deliver the message. May be synchronous.
109
+ *
110
+ * A returned promise MUST reject on failure. Resolving on a failed send is
111
+ * the one thing a transport may not do.
112
+ */
113
+ send(message: FrameToParent): void | Promise<void | SendReceipt>;
114
+ }
115
+ /** What went wrong, handed back to whoever constructed the SDK. */
116
+ export interface EgressFailure {
117
+ message: FrameToParent;
118
+ transport: string;
119
+ error: unknown;
120
+ }
121
+ export type EgressErrorHandler = (failure: EgressFailure) => void;
122
+ /**
123
+ * The parent-window transport — today's behaviour, extracted unchanged.
124
+ *
125
+ * It carries everything: the frame is a live listener on the other end of a
126
+ * cheap channel, and the handshake (`annotate:ready`) and the hover highlight
127
+ * both belong to it.
128
+ */
129
+ export declare function postMessageTransport(parentOrigin: string, win?: Pick<Window, "parent">): AnnotationTransport;
130
+ /**
131
+ * Reject anything that is not one concrete `http(s)` URL.
132
+ *
133
+ * `assertConcreteOrigin`'s argument, applied to an endpoint. A relative URL
134
+ * resolves against whatever page the SDK happens to be on — so a capture
135
+ * POSTs into the CUSTOMER'S OWN app, 404s, and is lost; and a `javascript:`
136
+ * or `data:` endpoint is a script-injection seam handed to whoever controls
137
+ * the deep link. Neither is a thing to discover at first capture.
138
+ */
139
+ export declare function assertConcreteEndpoint(endpoint: string): string;
140
+ /** Node's `fetch` and the browser's, narrowed to what we use. */
141
+ export type FetchLike = (input: string, init: {
142
+ method: string;
143
+ headers: Record<string, string>;
144
+ body: string;
145
+ credentials?: "omit";
146
+ signal?: AbortSignal;
147
+ }) => Promise<{
148
+ ok: boolean;
149
+ status: number;
150
+ /**
151
+ * Optional because `serve()`'s loopback responses are read through the
152
+ * platform `fetch`, which has it, while a stub written to assert the `init`
153
+ * need not. A transport that made the body mandatory would be asserting
154
+ * something about the ingest that the ingest does not promise.
155
+ */
156
+ json?: () => Promise<unknown>;
157
+ }>;
158
+ export interface HttpTransportOptions {
159
+ /** The console's ingest URL. Absolute, `http(s)`, never `*`. */
160
+ endpoint: string;
161
+ /**
162
+ * The capture token from the deep link — signed by the console, carried by
163
+ * the product, never validated by it. See the plan's *Authentication*
164
+ * section for why this is a second credential and not the arming one.
165
+ */
166
+ captureToken: string;
167
+ /**
168
+ * Own timeout, own catch. The default is deliberately short: a capture the
169
+ * reviewer is told failed can be retried, and a request that hangs is
170
+ * indistinguishable from one that worked.
171
+ */
172
+ timeoutMs?: number;
173
+ /** Injectable only so the tests can drive a transport with no `fetch` at all. */
174
+ fetch?: FetchLike;
175
+ }
176
+ export declare const HTTP_TRANSPORT_TIMEOUT_MS = 8000;
177
+ /**
178
+ * The first-party transport: POST the capture to the console's ingest.
179
+ *
180
+ * It carries `annotate:capture` AND NOTHING ELSE. `annotate:hover` is one
181
+ * request per animation frame, and `annotate:ready` / `annotate:cancel` are
182
+ * handshake with a parent window that does not exist first-party — sending
183
+ * them would be filing HTTP requests nobody asked for.
184
+ */
185
+ export declare function httpTransport(options: HttpTransportOptions): AnnotationTransport;
186
+ /**
187
+ * What became of one message (A-240).
188
+ *
189
+ * `createEgress` used to return `void`, which was enough while the only reader
190
+ * of a failure was `console.error`. The composer has to TELL THE REVIEWER — the
191
+ * issue link on success, a retry on failure — and it cannot do that from a
192
+ * function that answers nothing. So the outcome is returned as a value, and the
193
+ * promise NEVER REJECTS: a rejection here would be a second way for a lost
194
+ * capture to disappear, one `await`-less caller away from silence.
195
+ *
196
+ * `declined` is its own case rather than a failure, because it is not one:
197
+ * `annotate:hover` over HTTP is a kind the transport correctly refuses, and
198
+ * calling that a failed capture would put a red toast in front of a reviewer
199
+ * who did nothing wrong. It is only a problem if `annotate:capture` is what was
200
+ * declined, and that is the caller's judgement rather than this function's.
201
+ */
202
+ export type EgressOutcome = {
203
+ status: "sent";
204
+ receipt: SendReceipt;
205
+ } | {
206
+ status: "declined";
207
+ } | {
208
+ status: "failed";
209
+ error: unknown;
210
+ };
211
+ /**
212
+ * The SDK's one way out, given a transport.
213
+ *
214
+ * Pure and DOM-free on purpose, so the two things worth arguing about — that a
215
+ * declined kind is never sent, and that a failed send reaches the caller — are
216
+ * testable without a browser. `sdk.ts` holds no other egress path.
217
+ */
218
+ export declare function createEgress(transport: AnnotationTransport, onError?: EgressErrorHandler): (message: FrameToParent) => Promise<EgressOutcome>;