@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.
- package/ADOPTING.md +36 -0
- package/COMPATIBILITY.md +97 -0
- package/README.md +9 -3
- package/dist/arming.d.cts +142 -0
- package/dist/arming.d.mts +142 -0
- package/dist/arming.d.ts +142 -0
- package/dist/host.cjs +1 -1
- package/dist/host.cjs.map +2 -2
- package/dist/host.mjs +1 -1
- package/dist/host.mjs.map +2 -2
- package/dist/index.cjs +64 -1
- package/dist/index.cjs.map +4 -4
- package/dist/index.d.cts +5 -2
- package/dist/index.d.mts +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.mjs +64 -1
- package/dist/index.mjs.map +4 -4
- package/dist/protocol.cjs +1 -1
- package/dist/protocol.cjs.map +3 -3
- package/dist/protocol.d.cts +114 -0
- package/dist/protocol.d.mts +114 -0
- package/dist/protocol.d.ts +114 -0
- package/dist/protocol.mjs +1 -1
- package/dist/protocol.mjs.map +3 -3
- package/dist/sdk.d.cts +92 -3
- package/dist/sdk.d.mts +92 -3
- package/dist/sdk.d.ts +92 -3
- package/dist/toolbar.d.cts +185 -0
- package/dist/toolbar.d.mts +185 -0
- package/dist/toolbar.d.ts +185 -0
- package/dist/transport.d.cts +218 -0
- package/dist/transport.d.mts +218 -0
- package/dist/transport.d.ts +218 -0
- package/fixtures/README.md +35 -0
- package/fixtures/v1-minimal.json +17 -0
- package/fixtures/v1-no-source-ref.json +22 -0
- package/fixtures/v1-region-selection.json +23 -0
- package/fixtures/v1-screenshot.json +19 -0
- package/fixtures/v1-source-chain.json +24 -0
- package/package.json +11 -4
|
@@ -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>;
|