@weatherboard/annotation 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.
- package/ADOPTING.md +360 -0
- package/LICENSE +21 -0
- package/README.md +109 -0
- package/dist/host.cjs +2 -0
- package/dist/host.cjs.map +7 -0
- package/dist/host.d.cts +60 -0
- package/dist/host.d.mts +60 -0
- package/dist/host.d.ts +60 -0
- package/dist/host.mjs +2 -0
- package/dist/host.mjs.map +7 -0
- package/dist/index.cjs +2 -0
- package/dist/index.cjs.map +7 -0
- package/dist/index.d.cts +34 -0
- package/dist/index.d.mts +34 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.mjs +2 -0
- package/dist/index.mjs.map +7 -0
- package/dist/overlay.d.cts +31 -0
- package/dist/overlay.d.mts +31 -0
- package/dist/overlay.d.ts +31 -0
- package/dist/protocol.cjs +2 -0
- package/dist/protocol.cjs.map +7 -0
- package/dist/protocol.d.cts +309 -0
- package/dist/protocol.d.mts +309 -0
- package/dist/protocol.d.ts +309 -0
- package/dist/protocol.mjs +2 -0
- package/dist/protocol.mjs.map +7 -0
- package/dist/screenshot.d.cts +182 -0
- package/dist/screenshot.d.mts +182 -0
- package/dist/screenshot.d.ts +182 -0
- package/dist/sdk.d.cts +52 -0
- package/dist/sdk.d.mts +52 -0
- package/dist/sdk.d.ts +52 -0
- package/dist/state.d.cts +62 -0
- package/dist/state.d.mts +62 -0
- package/dist/state.d.ts +62 -0
- package/dist/target.d.cts +74 -0
- package/dist/target.d.mts +74 -0
- package/dist/target.d.ts +74 -0
- package/package.json +94 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot capture for the annotation payload (A-31).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS ACTUALLY CAPTURES — READ THIS BEFORE BELIEVING THE FIELD NAME.
|
|
5
|
+
* This is not a page rasteriser. It captures the bitmap of a single
|
|
6
|
+
* `<img>`, `<canvas>` or `<video>` at or around the clicked element, and
|
|
7
|
+
* nothing else. Click a heading and the payload has no screenshot.
|
|
8
|
+
*
|
|
9
|
+
* WHY NOT html2canvas, WHICH THE SPEC SUGGESTS. `spec/02` offers html2canvas or
|
|
10
|
+
* "`element.toDataURL` via an offscreen canvas where feasible". html2canvas is a
|
|
11
|
+
* runtime dependency, and this package deliberately has none — it ships
|
|
12
|
+
* TypeScript source that two repositories compile into their own bundles, so a
|
|
13
|
+
* dependency here is a dependency, a version and a licence in both of them, for
|
|
14
|
+
* a field `spec/02` itself calls a nice-to-have ("do not block Phase 1 on
|
|
15
|
+
* screenshots; the source ref is what actually matters"). The platform gives us
|
|
16
|
+
* no way to rasterise arbitrary DOM: `SVGForeignObject` cannot load
|
|
17
|
+
* cross-origin CSS or fonts, and `getDisplayMedia` prompts the user. So we
|
|
18
|
+
* implement the honest subset rather than a convincing-looking approximation,
|
|
19
|
+
* and the spec's server-side fallback — render `page.url` at `build.gitSha`
|
|
20
|
+
* headless and crop to `target.boundingBox` — remains the route to a real
|
|
21
|
+
* picture of the page.
|
|
22
|
+
*
|
|
23
|
+
* The subset is worth having on its own: "this image is the wrong one" and
|
|
24
|
+
* "this video poster is stretched" are common reports, and for those the
|
|
25
|
+
* element's own bitmap is the entire evidence.
|
|
26
|
+
*
|
|
27
|
+
* NOTHING HERE MAY BREAK A CAPTURE. Every entry point resolves rather than
|
|
28
|
+
* rejects. A tainted canvas (any cross-origin image without CORS headers, which
|
|
29
|
+
* is most of them) makes `toDataURL` throw `SecurityError`; a detached element
|
|
30
|
+
* makes `drawImage` throw; a huge bitmap makes it slow. All of those must end
|
|
31
|
+
* with `screenshot` absent from an otherwise complete payload, never with a
|
|
32
|
+
* capture the reviewer loses.
|
|
33
|
+
*/
|
|
34
|
+
/** The canvas surface this module needs. Narrow so tests need no browser. */
|
|
35
|
+
export interface CanvasLike {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
getContext(id: "2d"): CanvasContextLike | null;
|
|
39
|
+
toDataURL(type?: string, quality?: number): string;
|
|
40
|
+
}
|
|
41
|
+
export interface CanvasContextLike {
|
|
42
|
+
drawImage(source: unknown, dx: number, dy: number, dw: number, dh: number): void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The subset of an element this module reads.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately structural rather than `instanceof HTMLImageElement`: the SDK
|
|
48
|
+
* runs inside an iframe, and an element from another document fails an
|
|
49
|
+
* `instanceof` check against this realm's constructors even when it is exactly
|
|
50
|
+
* the thing we want.
|
|
51
|
+
*/
|
|
52
|
+
export interface CaptureCandidate {
|
|
53
|
+
tagName: string;
|
|
54
|
+
parentElement?: CaptureCandidate | null;
|
|
55
|
+
querySelector?: (selector: string) => CaptureCandidate | null;
|
|
56
|
+
/** `<img>` */
|
|
57
|
+
naturalWidth?: number;
|
|
58
|
+
naturalHeight?: number;
|
|
59
|
+
complete?: boolean;
|
|
60
|
+
/** `<video>` */
|
|
61
|
+
videoWidth?: number;
|
|
62
|
+
videoHeight?: number;
|
|
63
|
+
/** `<canvas>` */
|
|
64
|
+
width?: number;
|
|
65
|
+
height?: number;
|
|
66
|
+
}
|
|
67
|
+
export type Screenshot = {
|
|
68
|
+
dataUrl: string;
|
|
69
|
+
region: "element" | "viewport";
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The byte budget for the encoded data URL.
|
|
73
|
+
*
|
|
74
|
+
* `apps/manager/src/lib/feedback/validate.ts` rejects the whole payload over
|
|
75
|
+
* `MAX_PAYLOAD_BYTES` (128KB) and names screenshots as the usual cause. 48KB
|
|
76
|
+
* leaves the rest of the payload — twenty console entries with stacks, twenty
|
|
77
|
+
* failed requests, a source chain — room to be large without the server
|
|
78
|
+
* refusing a report the reviewer has already written. A data URL is base64
|
|
79
|
+
* ASCII, so `dataUrl.length` is its byte cost in the serialised payload, near
|
|
80
|
+
* enough to compare directly.
|
|
81
|
+
*
|
|
82
|
+
* Over budget we drop the screenshot. Emitting it anyway would trade a payload
|
|
83
|
+
* with no picture for a payload the server 413s, which loses the source ref —
|
|
84
|
+
* the only part that actually matters.
|
|
85
|
+
*/
|
|
86
|
+
export declare const MAX_SCREENSHOT_BYTES: number;
|
|
87
|
+
/**
|
|
88
|
+
* Longest edge, in device-independent pixels, of the downscaled capture.
|
|
89
|
+
*
|
|
90
|
+
* A 2000px-wide hero image encodes to well over the budget at any quality worth
|
|
91
|
+
* looking at. 480px is enough to see that the wrong photo is on the card, which
|
|
92
|
+
* is the question this field answers.
|
|
93
|
+
*/
|
|
94
|
+
export declare const MAX_SCREENSHOT_EDGE = 480;
|
|
95
|
+
/**
|
|
96
|
+
* A ceiling on how long we will wait, not on how long the work takes.
|
|
97
|
+
*
|
|
98
|
+
* `toDataURL` is synchronous and cannot be interrupted; this bounds the wait so
|
|
99
|
+
* a pathological encode does not hold the capture open indefinitely, and the
|
|
100
|
+
* payload goes out without the screenshot instead. Said plainly because a
|
|
101
|
+
* timeout that cannot cancel its work is the kind of thing a reader assumes is
|
|
102
|
+
* stronger than it is.
|
|
103
|
+
*/
|
|
104
|
+
export declare const SCREENSHOT_TIMEOUT_MS = 250;
|
|
105
|
+
export interface ScreenshotOptions {
|
|
106
|
+
/**
|
|
107
|
+
* Off switch. `spec/02` treats screenshots as optional, so a host that would
|
|
108
|
+
* rather not encode bitmaps on click — or that serves images this SDK would
|
|
109
|
+
* only ever taint — sets this to `false`.
|
|
110
|
+
*/
|
|
111
|
+
enabled?: boolean;
|
|
112
|
+
maxBytes?: number;
|
|
113
|
+
maxEdge?: number;
|
|
114
|
+
timeoutMs?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Canvas factory. Injected so the tests can drive the failure paths — taint,
|
|
117
|
+
* an oversized encode, a context that is not there — none of which are
|
|
118
|
+
* reachable from a fake DOM object alone.
|
|
119
|
+
*/
|
|
120
|
+
createCanvas?: () => CanvasLike | null;
|
|
121
|
+
}
|
|
122
|
+
interface ResolvedOptions {
|
|
123
|
+
enabled: boolean;
|
|
124
|
+
maxBytes: number;
|
|
125
|
+
maxEdge: number;
|
|
126
|
+
timeoutMs: number;
|
|
127
|
+
createCanvas: () => CanvasLike | null;
|
|
128
|
+
}
|
|
129
|
+
export declare function resolveScreenshotOptions(options: ScreenshotOptions | false | undefined, doc?: Document): ResolvedOptions;
|
|
130
|
+
export declare function isMediaElement(el: CaptureCandidate | null | undefined): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* The nearest thing with a bitmap: the element itself, then a descendant, then
|
|
133
|
+
* an ancestor.
|
|
134
|
+
*
|
|
135
|
+
* Descendant before ancestor because a click lands on whatever is topmost, and
|
|
136
|
+
* on a card that is usually the overlay or the link wrapping the photo — the
|
|
137
|
+
* photo the reviewer means is inside it, not above it.
|
|
138
|
+
*/
|
|
139
|
+
export declare function findCaptureSource(el: CaptureCandidate | null, maxHops?: number): CaptureCandidate | null;
|
|
140
|
+
/**
|
|
141
|
+
* The source's own pixel dimensions, or null if it has none.
|
|
142
|
+
*
|
|
143
|
+
* A broken `<img>` reports `naturalWidth === 0`, and that is the honest answer:
|
|
144
|
+
* there is no bitmap to capture. The report still carries the source ref and
|
|
145
|
+
* the DOM path, which is what a "this image is broken" ticket needs anyway.
|
|
146
|
+
*/
|
|
147
|
+
export declare function intrinsicSize(el: CaptureCandidate): {
|
|
148
|
+
w: number;
|
|
149
|
+
h: number;
|
|
150
|
+
} | null;
|
|
151
|
+
/** Fit inside `maxEdge` without ever upscaling — enlarging invents detail. */
|
|
152
|
+
export declare function fitWithin(size: {
|
|
153
|
+
w: number;
|
|
154
|
+
h: number;
|
|
155
|
+
}, maxEdge: number): {
|
|
156
|
+
w: number;
|
|
157
|
+
h: number;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Capture synchronously, or return null. Exported for the tests, which need to
|
|
161
|
+
* assert the drop decisions without a timer in the way.
|
|
162
|
+
*/
|
|
163
|
+
export declare function captureScreenshotSync(el: CaptureCandidate | null, options: ResolvedOptions): Screenshot | null;
|
|
164
|
+
/**
|
|
165
|
+
* The entry point the SDK calls. Resolves to a screenshot or to null, within
|
|
166
|
+
* `timeoutMs`, and never rejects.
|
|
167
|
+
*/
|
|
168
|
+
export declare function captureScreenshot(el: CaptureCandidate | null, options: ResolvedOptions): Promise<Screenshot | null>;
|
|
169
|
+
/**
|
|
170
|
+
* Merge a capture into a payload, or hand the payload back untouched.
|
|
171
|
+
*
|
|
172
|
+
* The payload goes in already complete and comes out still complete. The
|
|
173
|
+
* failure mode this shape exists to make impossible is a screenshot problem
|
|
174
|
+
* costing the reviewer the source ref — so the payload is never awaited into
|
|
175
|
+
* existence, only decorated, and `screenshot` is added as a key or not at all
|
|
176
|
+
* rather than set to `undefined` (which `JSON.stringify` drops anyway, but
|
|
177
|
+
* which reads as "we tried and got nothing" to anyone inspecting the object).
|
|
178
|
+
*/
|
|
179
|
+
export declare function attachScreenshot<T extends object>(payload: T, el: CaptureCandidate | null, options: ResolvedOptions): Promise<T & {
|
|
180
|
+
screenshot?: Screenshot;
|
|
181
|
+
}>;
|
|
182
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot capture for the annotation payload (A-31).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS ACTUALLY CAPTURES — READ THIS BEFORE BELIEVING THE FIELD NAME.
|
|
5
|
+
* This is not a page rasteriser. It captures the bitmap of a single
|
|
6
|
+
* `<img>`, `<canvas>` or `<video>` at or around the clicked element, and
|
|
7
|
+
* nothing else. Click a heading and the payload has no screenshot.
|
|
8
|
+
*
|
|
9
|
+
* WHY NOT html2canvas, WHICH THE SPEC SUGGESTS. `spec/02` offers html2canvas or
|
|
10
|
+
* "`element.toDataURL` via an offscreen canvas where feasible". html2canvas is a
|
|
11
|
+
* runtime dependency, and this package deliberately has none — it ships
|
|
12
|
+
* TypeScript source that two repositories compile into their own bundles, so a
|
|
13
|
+
* dependency here is a dependency, a version and a licence in both of them, for
|
|
14
|
+
* a field `spec/02` itself calls a nice-to-have ("do not block Phase 1 on
|
|
15
|
+
* screenshots; the source ref is what actually matters"). The platform gives us
|
|
16
|
+
* no way to rasterise arbitrary DOM: `SVGForeignObject` cannot load
|
|
17
|
+
* cross-origin CSS or fonts, and `getDisplayMedia` prompts the user. So we
|
|
18
|
+
* implement the honest subset rather than a convincing-looking approximation,
|
|
19
|
+
* and the spec's server-side fallback — render `page.url` at `build.gitSha`
|
|
20
|
+
* headless and crop to `target.boundingBox` — remains the route to a real
|
|
21
|
+
* picture of the page.
|
|
22
|
+
*
|
|
23
|
+
* The subset is worth having on its own: "this image is the wrong one" and
|
|
24
|
+
* "this video poster is stretched" are common reports, and for those the
|
|
25
|
+
* element's own bitmap is the entire evidence.
|
|
26
|
+
*
|
|
27
|
+
* NOTHING HERE MAY BREAK A CAPTURE. Every entry point resolves rather than
|
|
28
|
+
* rejects. A tainted canvas (any cross-origin image without CORS headers, which
|
|
29
|
+
* is most of them) makes `toDataURL` throw `SecurityError`; a detached element
|
|
30
|
+
* makes `drawImage` throw; a huge bitmap makes it slow. All of those must end
|
|
31
|
+
* with `screenshot` absent from an otherwise complete payload, never with a
|
|
32
|
+
* capture the reviewer loses.
|
|
33
|
+
*/
|
|
34
|
+
/** The canvas surface this module needs. Narrow so tests need no browser. */
|
|
35
|
+
export interface CanvasLike {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
getContext(id: "2d"): CanvasContextLike | null;
|
|
39
|
+
toDataURL(type?: string, quality?: number): string;
|
|
40
|
+
}
|
|
41
|
+
export interface CanvasContextLike {
|
|
42
|
+
drawImage(source: unknown, dx: number, dy: number, dw: number, dh: number): void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The subset of an element this module reads.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately structural rather than `instanceof HTMLImageElement`: the SDK
|
|
48
|
+
* runs inside an iframe, and an element from another document fails an
|
|
49
|
+
* `instanceof` check against this realm's constructors even when it is exactly
|
|
50
|
+
* the thing we want.
|
|
51
|
+
*/
|
|
52
|
+
export interface CaptureCandidate {
|
|
53
|
+
tagName: string;
|
|
54
|
+
parentElement?: CaptureCandidate | null;
|
|
55
|
+
querySelector?: (selector: string) => CaptureCandidate | null;
|
|
56
|
+
/** `<img>` */
|
|
57
|
+
naturalWidth?: number;
|
|
58
|
+
naturalHeight?: number;
|
|
59
|
+
complete?: boolean;
|
|
60
|
+
/** `<video>` */
|
|
61
|
+
videoWidth?: number;
|
|
62
|
+
videoHeight?: number;
|
|
63
|
+
/** `<canvas>` */
|
|
64
|
+
width?: number;
|
|
65
|
+
height?: number;
|
|
66
|
+
}
|
|
67
|
+
export type Screenshot = {
|
|
68
|
+
dataUrl: string;
|
|
69
|
+
region: "element" | "viewport";
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The byte budget for the encoded data URL.
|
|
73
|
+
*
|
|
74
|
+
* `apps/manager/src/lib/feedback/validate.ts` rejects the whole payload over
|
|
75
|
+
* `MAX_PAYLOAD_BYTES` (128KB) and names screenshots as the usual cause. 48KB
|
|
76
|
+
* leaves the rest of the payload — twenty console entries with stacks, twenty
|
|
77
|
+
* failed requests, a source chain — room to be large without the server
|
|
78
|
+
* refusing a report the reviewer has already written. A data URL is base64
|
|
79
|
+
* ASCII, so `dataUrl.length` is its byte cost in the serialised payload, near
|
|
80
|
+
* enough to compare directly.
|
|
81
|
+
*
|
|
82
|
+
* Over budget we drop the screenshot. Emitting it anyway would trade a payload
|
|
83
|
+
* with no picture for a payload the server 413s, which loses the source ref —
|
|
84
|
+
* the only part that actually matters.
|
|
85
|
+
*/
|
|
86
|
+
export declare const MAX_SCREENSHOT_BYTES: number;
|
|
87
|
+
/**
|
|
88
|
+
* Longest edge, in device-independent pixels, of the downscaled capture.
|
|
89
|
+
*
|
|
90
|
+
* A 2000px-wide hero image encodes to well over the budget at any quality worth
|
|
91
|
+
* looking at. 480px is enough to see that the wrong photo is on the card, which
|
|
92
|
+
* is the question this field answers.
|
|
93
|
+
*/
|
|
94
|
+
export declare const MAX_SCREENSHOT_EDGE = 480;
|
|
95
|
+
/**
|
|
96
|
+
* A ceiling on how long we will wait, not on how long the work takes.
|
|
97
|
+
*
|
|
98
|
+
* `toDataURL` is synchronous and cannot be interrupted; this bounds the wait so
|
|
99
|
+
* a pathological encode does not hold the capture open indefinitely, and the
|
|
100
|
+
* payload goes out without the screenshot instead. Said plainly because a
|
|
101
|
+
* timeout that cannot cancel its work is the kind of thing a reader assumes is
|
|
102
|
+
* stronger than it is.
|
|
103
|
+
*/
|
|
104
|
+
export declare const SCREENSHOT_TIMEOUT_MS = 250;
|
|
105
|
+
export interface ScreenshotOptions {
|
|
106
|
+
/**
|
|
107
|
+
* Off switch. `spec/02` treats screenshots as optional, so a host that would
|
|
108
|
+
* rather not encode bitmaps on click — or that serves images this SDK would
|
|
109
|
+
* only ever taint — sets this to `false`.
|
|
110
|
+
*/
|
|
111
|
+
enabled?: boolean;
|
|
112
|
+
maxBytes?: number;
|
|
113
|
+
maxEdge?: number;
|
|
114
|
+
timeoutMs?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Canvas factory. Injected so the tests can drive the failure paths — taint,
|
|
117
|
+
* an oversized encode, a context that is not there — none of which are
|
|
118
|
+
* reachable from a fake DOM object alone.
|
|
119
|
+
*/
|
|
120
|
+
createCanvas?: () => CanvasLike | null;
|
|
121
|
+
}
|
|
122
|
+
interface ResolvedOptions {
|
|
123
|
+
enabled: boolean;
|
|
124
|
+
maxBytes: number;
|
|
125
|
+
maxEdge: number;
|
|
126
|
+
timeoutMs: number;
|
|
127
|
+
createCanvas: () => CanvasLike | null;
|
|
128
|
+
}
|
|
129
|
+
export declare function resolveScreenshotOptions(options: ScreenshotOptions | false | undefined, doc?: Document): ResolvedOptions;
|
|
130
|
+
export declare function isMediaElement(el: CaptureCandidate | null | undefined): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* The nearest thing with a bitmap: the element itself, then a descendant, then
|
|
133
|
+
* an ancestor.
|
|
134
|
+
*
|
|
135
|
+
* Descendant before ancestor because a click lands on whatever is topmost, and
|
|
136
|
+
* on a card that is usually the overlay or the link wrapping the photo — the
|
|
137
|
+
* photo the reviewer means is inside it, not above it.
|
|
138
|
+
*/
|
|
139
|
+
export declare function findCaptureSource(el: CaptureCandidate | null, maxHops?: number): CaptureCandidate | null;
|
|
140
|
+
/**
|
|
141
|
+
* The source's own pixel dimensions, or null if it has none.
|
|
142
|
+
*
|
|
143
|
+
* A broken `<img>` reports `naturalWidth === 0`, and that is the honest answer:
|
|
144
|
+
* there is no bitmap to capture. The report still carries the source ref and
|
|
145
|
+
* the DOM path, which is what a "this image is broken" ticket needs anyway.
|
|
146
|
+
*/
|
|
147
|
+
export declare function intrinsicSize(el: CaptureCandidate): {
|
|
148
|
+
w: number;
|
|
149
|
+
h: number;
|
|
150
|
+
} | null;
|
|
151
|
+
/** Fit inside `maxEdge` without ever upscaling — enlarging invents detail. */
|
|
152
|
+
export declare function fitWithin(size: {
|
|
153
|
+
w: number;
|
|
154
|
+
h: number;
|
|
155
|
+
}, maxEdge: number): {
|
|
156
|
+
w: number;
|
|
157
|
+
h: number;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Capture synchronously, or return null. Exported for the tests, which need to
|
|
161
|
+
* assert the drop decisions without a timer in the way.
|
|
162
|
+
*/
|
|
163
|
+
export declare function captureScreenshotSync(el: CaptureCandidate | null, options: ResolvedOptions): Screenshot | null;
|
|
164
|
+
/**
|
|
165
|
+
* The entry point the SDK calls. Resolves to a screenshot or to null, within
|
|
166
|
+
* `timeoutMs`, and never rejects.
|
|
167
|
+
*/
|
|
168
|
+
export declare function captureScreenshot(el: CaptureCandidate | null, options: ResolvedOptions): Promise<Screenshot | null>;
|
|
169
|
+
/**
|
|
170
|
+
* Merge a capture into a payload, or hand the payload back untouched.
|
|
171
|
+
*
|
|
172
|
+
* The payload goes in already complete and comes out still complete. The
|
|
173
|
+
* failure mode this shape exists to make impossible is a screenshot problem
|
|
174
|
+
* costing the reviewer the source ref — so the payload is never awaited into
|
|
175
|
+
* existence, only decorated, and `screenshot` is added as a key or not at all
|
|
176
|
+
* rather than set to `undefined` (which `JSON.stringify` drops anyway, but
|
|
177
|
+
* which reads as "we tried and got nothing" to anyone inspecting the object).
|
|
178
|
+
*/
|
|
179
|
+
export declare function attachScreenshot<T extends object>(payload: T, el: CaptureCandidate | null, options: ResolvedOptions): Promise<T & {
|
|
180
|
+
screenshot?: Screenshot;
|
|
181
|
+
}>;
|
|
182
|
+
export {};
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screenshot capture for the annotation payload (A-31).
|
|
3
|
+
*
|
|
4
|
+
* WHAT THIS ACTUALLY CAPTURES — READ THIS BEFORE BELIEVING THE FIELD NAME.
|
|
5
|
+
* This is not a page rasteriser. It captures the bitmap of a single
|
|
6
|
+
* `<img>`, `<canvas>` or `<video>` at or around the clicked element, and
|
|
7
|
+
* nothing else. Click a heading and the payload has no screenshot.
|
|
8
|
+
*
|
|
9
|
+
* WHY NOT html2canvas, WHICH THE SPEC SUGGESTS. `spec/02` offers html2canvas or
|
|
10
|
+
* "`element.toDataURL` via an offscreen canvas where feasible". html2canvas is a
|
|
11
|
+
* runtime dependency, and this package deliberately has none — it ships
|
|
12
|
+
* TypeScript source that two repositories compile into their own bundles, so a
|
|
13
|
+
* dependency here is a dependency, a version and a licence in both of them, for
|
|
14
|
+
* a field `spec/02` itself calls a nice-to-have ("do not block Phase 1 on
|
|
15
|
+
* screenshots; the source ref is what actually matters"). The platform gives us
|
|
16
|
+
* no way to rasterise arbitrary DOM: `SVGForeignObject` cannot load
|
|
17
|
+
* cross-origin CSS or fonts, and `getDisplayMedia` prompts the user. So we
|
|
18
|
+
* implement the honest subset rather than a convincing-looking approximation,
|
|
19
|
+
* and the spec's server-side fallback — render `page.url` at `build.gitSha`
|
|
20
|
+
* headless and crop to `target.boundingBox` — remains the route to a real
|
|
21
|
+
* picture of the page.
|
|
22
|
+
*
|
|
23
|
+
* The subset is worth having on its own: "this image is the wrong one" and
|
|
24
|
+
* "this video poster is stretched" are common reports, and for those the
|
|
25
|
+
* element's own bitmap is the entire evidence.
|
|
26
|
+
*
|
|
27
|
+
* NOTHING HERE MAY BREAK A CAPTURE. Every entry point resolves rather than
|
|
28
|
+
* rejects. A tainted canvas (any cross-origin image without CORS headers, which
|
|
29
|
+
* is most of them) makes `toDataURL` throw `SecurityError`; a detached element
|
|
30
|
+
* makes `drawImage` throw; a huge bitmap makes it slow. All of those must end
|
|
31
|
+
* with `screenshot` absent from an otherwise complete payload, never with a
|
|
32
|
+
* capture the reviewer loses.
|
|
33
|
+
*/
|
|
34
|
+
/** The canvas surface this module needs. Narrow so tests need no browser. */
|
|
35
|
+
export interface CanvasLike {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
getContext(id: "2d"): CanvasContextLike | null;
|
|
39
|
+
toDataURL(type?: string, quality?: number): string;
|
|
40
|
+
}
|
|
41
|
+
export interface CanvasContextLike {
|
|
42
|
+
drawImage(source: unknown, dx: number, dy: number, dw: number, dh: number): void;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The subset of an element this module reads.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately structural rather than `instanceof HTMLImageElement`: the SDK
|
|
48
|
+
* runs inside an iframe, and an element from another document fails an
|
|
49
|
+
* `instanceof` check against this realm's constructors even when it is exactly
|
|
50
|
+
* the thing we want.
|
|
51
|
+
*/
|
|
52
|
+
export interface CaptureCandidate {
|
|
53
|
+
tagName: string;
|
|
54
|
+
parentElement?: CaptureCandidate | null;
|
|
55
|
+
querySelector?: (selector: string) => CaptureCandidate | null;
|
|
56
|
+
/** `<img>` */
|
|
57
|
+
naturalWidth?: number;
|
|
58
|
+
naturalHeight?: number;
|
|
59
|
+
complete?: boolean;
|
|
60
|
+
/** `<video>` */
|
|
61
|
+
videoWidth?: number;
|
|
62
|
+
videoHeight?: number;
|
|
63
|
+
/** `<canvas>` */
|
|
64
|
+
width?: number;
|
|
65
|
+
height?: number;
|
|
66
|
+
}
|
|
67
|
+
export type Screenshot = {
|
|
68
|
+
dataUrl: string;
|
|
69
|
+
region: "element" | "viewport";
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The byte budget for the encoded data URL.
|
|
73
|
+
*
|
|
74
|
+
* `apps/manager/src/lib/feedback/validate.ts` rejects the whole payload over
|
|
75
|
+
* `MAX_PAYLOAD_BYTES` (128KB) and names screenshots as the usual cause. 48KB
|
|
76
|
+
* leaves the rest of the payload — twenty console entries with stacks, twenty
|
|
77
|
+
* failed requests, a source chain — room to be large without the server
|
|
78
|
+
* refusing a report the reviewer has already written. A data URL is base64
|
|
79
|
+
* ASCII, so `dataUrl.length` is its byte cost in the serialised payload, near
|
|
80
|
+
* enough to compare directly.
|
|
81
|
+
*
|
|
82
|
+
* Over budget we drop the screenshot. Emitting it anyway would trade a payload
|
|
83
|
+
* with no picture for a payload the server 413s, which loses the source ref —
|
|
84
|
+
* the only part that actually matters.
|
|
85
|
+
*/
|
|
86
|
+
export declare const MAX_SCREENSHOT_BYTES: number;
|
|
87
|
+
/**
|
|
88
|
+
* Longest edge, in device-independent pixels, of the downscaled capture.
|
|
89
|
+
*
|
|
90
|
+
* A 2000px-wide hero image encodes to well over the budget at any quality worth
|
|
91
|
+
* looking at. 480px is enough to see that the wrong photo is on the card, which
|
|
92
|
+
* is the question this field answers.
|
|
93
|
+
*/
|
|
94
|
+
export declare const MAX_SCREENSHOT_EDGE = 480;
|
|
95
|
+
/**
|
|
96
|
+
* A ceiling on how long we will wait, not on how long the work takes.
|
|
97
|
+
*
|
|
98
|
+
* `toDataURL` is synchronous and cannot be interrupted; this bounds the wait so
|
|
99
|
+
* a pathological encode does not hold the capture open indefinitely, and the
|
|
100
|
+
* payload goes out without the screenshot instead. Said plainly because a
|
|
101
|
+
* timeout that cannot cancel its work is the kind of thing a reader assumes is
|
|
102
|
+
* stronger than it is.
|
|
103
|
+
*/
|
|
104
|
+
export declare const SCREENSHOT_TIMEOUT_MS = 250;
|
|
105
|
+
export interface ScreenshotOptions {
|
|
106
|
+
/**
|
|
107
|
+
* Off switch. `spec/02` treats screenshots as optional, so a host that would
|
|
108
|
+
* rather not encode bitmaps on click — or that serves images this SDK would
|
|
109
|
+
* only ever taint — sets this to `false`.
|
|
110
|
+
*/
|
|
111
|
+
enabled?: boolean;
|
|
112
|
+
maxBytes?: number;
|
|
113
|
+
maxEdge?: number;
|
|
114
|
+
timeoutMs?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Canvas factory. Injected so the tests can drive the failure paths — taint,
|
|
117
|
+
* an oversized encode, a context that is not there — none of which are
|
|
118
|
+
* reachable from a fake DOM object alone.
|
|
119
|
+
*/
|
|
120
|
+
createCanvas?: () => CanvasLike | null;
|
|
121
|
+
}
|
|
122
|
+
interface ResolvedOptions {
|
|
123
|
+
enabled: boolean;
|
|
124
|
+
maxBytes: number;
|
|
125
|
+
maxEdge: number;
|
|
126
|
+
timeoutMs: number;
|
|
127
|
+
createCanvas: () => CanvasLike | null;
|
|
128
|
+
}
|
|
129
|
+
export declare function resolveScreenshotOptions(options: ScreenshotOptions | false | undefined, doc?: Document): ResolvedOptions;
|
|
130
|
+
export declare function isMediaElement(el: CaptureCandidate | null | undefined): boolean;
|
|
131
|
+
/**
|
|
132
|
+
* The nearest thing with a bitmap: the element itself, then a descendant, then
|
|
133
|
+
* an ancestor.
|
|
134
|
+
*
|
|
135
|
+
* Descendant before ancestor because a click lands on whatever is topmost, and
|
|
136
|
+
* on a card that is usually the overlay or the link wrapping the photo — the
|
|
137
|
+
* photo the reviewer means is inside it, not above it.
|
|
138
|
+
*/
|
|
139
|
+
export declare function findCaptureSource(el: CaptureCandidate | null, maxHops?: number): CaptureCandidate | null;
|
|
140
|
+
/**
|
|
141
|
+
* The source's own pixel dimensions, or null if it has none.
|
|
142
|
+
*
|
|
143
|
+
* A broken `<img>` reports `naturalWidth === 0`, and that is the honest answer:
|
|
144
|
+
* there is no bitmap to capture. The report still carries the source ref and
|
|
145
|
+
* the DOM path, which is what a "this image is broken" ticket needs anyway.
|
|
146
|
+
*/
|
|
147
|
+
export declare function intrinsicSize(el: CaptureCandidate): {
|
|
148
|
+
w: number;
|
|
149
|
+
h: number;
|
|
150
|
+
} | null;
|
|
151
|
+
/** Fit inside `maxEdge` without ever upscaling — enlarging invents detail. */
|
|
152
|
+
export declare function fitWithin(size: {
|
|
153
|
+
w: number;
|
|
154
|
+
h: number;
|
|
155
|
+
}, maxEdge: number): {
|
|
156
|
+
w: number;
|
|
157
|
+
h: number;
|
|
158
|
+
};
|
|
159
|
+
/**
|
|
160
|
+
* Capture synchronously, or return null. Exported for the tests, which need to
|
|
161
|
+
* assert the drop decisions without a timer in the way.
|
|
162
|
+
*/
|
|
163
|
+
export declare function captureScreenshotSync(el: CaptureCandidate | null, options: ResolvedOptions): Screenshot | null;
|
|
164
|
+
/**
|
|
165
|
+
* The entry point the SDK calls. Resolves to a screenshot or to null, within
|
|
166
|
+
* `timeoutMs`, and never rejects.
|
|
167
|
+
*/
|
|
168
|
+
export declare function captureScreenshot(el: CaptureCandidate | null, options: ResolvedOptions): Promise<Screenshot | null>;
|
|
169
|
+
/**
|
|
170
|
+
* Merge a capture into a payload, or hand the payload back untouched.
|
|
171
|
+
*
|
|
172
|
+
* The payload goes in already complete and comes out still complete. The
|
|
173
|
+
* failure mode this shape exists to make impossible is a screenshot problem
|
|
174
|
+
* costing the reviewer the source ref — so the payload is never awaited into
|
|
175
|
+
* existence, only decorated, and `screenshot` is added as a key or not at all
|
|
176
|
+
* rather than set to `undefined` (which `JSON.stringify` drops anyway, but
|
|
177
|
+
* which reads as "we tried and got nothing" to anyone inspecting the object).
|
|
178
|
+
*/
|
|
179
|
+
export declare function attachScreenshot<T extends object>(payload: T, el: CaptureCandidate | null, options: ResolvedOptions): Promise<T & {
|
|
180
|
+
screenshot?: Screenshot;
|
|
181
|
+
}>;
|
|
182
|
+
export {};
|
package/dist/sdk.d.cts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type AnnotationPayload, type BuildInfo } from "./protocol.cjs";
|
|
2
|
+
import { type ScreenshotOptions } from "./screenshot.cjs";
|
|
3
|
+
/**
|
|
4
|
+
* The annotation SDK (A-36).
|
|
5
|
+
*
|
|
6
|
+
* Lives in the product bundle, listens for the management app's instructions,
|
|
7
|
+
* and turns a click into a payload pinned to a code location.
|
|
8
|
+
*/
|
|
9
|
+
export interface AnnotationState {
|
|
10
|
+
consoleErrors: AnnotationPayload["state"]["consoleErrors"];
|
|
11
|
+
failedRequests: AnnotationPayload["state"]["failedRequests"];
|
|
12
|
+
featureFlags: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
export interface AnnotationSdkOptions {
|
|
15
|
+
/**
|
|
16
|
+
* The management app's origin. Required and concrete — see
|
|
17
|
+
* `assertConcreteOrigin`. Messages from anywhere else are ignored, and we
|
|
18
|
+
* never post to anywhere else.
|
|
19
|
+
*/
|
|
20
|
+
parentOrigin: string;
|
|
21
|
+
build: BuildInfo;
|
|
22
|
+
/**
|
|
23
|
+
* The matched route pattern, e.g. `/pub/[slug]`.
|
|
24
|
+
*
|
|
25
|
+
* Injected rather than derived: only the app knows its own route patterns,
|
|
26
|
+
* and `location.pathname` is the resolved URL, which is what the spec
|
|
27
|
+
* explicitly does not want — `/pub/the-eagle` and `/pub/the-crown` are the
|
|
28
|
+
* same route and should group as one.
|
|
29
|
+
*/
|
|
30
|
+
getRoute: () => string;
|
|
31
|
+
/**
|
|
32
|
+
* Page state at capture time. Injected so A-35's ring buffers can supply the
|
|
33
|
+
* real thing without this module knowing how they are collected.
|
|
34
|
+
*/
|
|
35
|
+
getState: () => AnnotationState;
|
|
36
|
+
/**
|
|
37
|
+
* Screenshot capture (A-31). Omit for the default, or pass `false` to turn it
|
|
38
|
+
* off entirely. See `screenshot.ts` for what it can and cannot capture — the
|
|
39
|
+
* short version is that it captures a media element's own bitmap and nothing
|
|
40
|
+
* else, so most clicks produce no screenshot at all, by design.
|
|
41
|
+
*/
|
|
42
|
+
screenshot?: ScreenshotOptions | false;
|
|
43
|
+
}
|
|
44
|
+
export interface AnnotationSdk {
|
|
45
|
+
enable(): void;
|
|
46
|
+
disable(): void;
|
|
47
|
+
isEnabled(): boolean;
|
|
48
|
+
destroy(): void;
|
|
49
|
+
}
|
|
50
|
+
/** State until A-35 lands its ring buffers. Empty is honest; invented is not. */
|
|
51
|
+
export declare const emptyAnnotationState: () => AnnotationState;
|
|
52
|
+
export declare function createAnnotationSdk(options: AnnotationSdkOptions): AnnotationSdk;
|
package/dist/sdk.d.mts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { type AnnotationPayload, type BuildInfo } from "./protocol.mjs";
|
|
2
|
+
import { type ScreenshotOptions } from "./screenshot.mjs";
|
|
3
|
+
/**
|
|
4
|
+
* The annotation SDK (A-36).
|
|
5
|
+
*
|
|
6
|
+
* Lives in the product bundle, listens for the management app's instructions,
|
|
7
|
+
* and turns a click into a payload pinned to a code location.
|
|
8
|
+
*/
|
|
9
|
+
export interface AnnotationState {
|
|
10
|
+
consoleErrors: AnnotationPayload["state"]["consoleErrors"];
|
|
11
|
+
failedRequests: AnnotationPayload["state"]["failedRequests"];
|
|
12
|
+
featureFlags: Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
export interface AnnotationSdkOptions {
|
|
15
|
+
/**
|
|
16
|
+
* The management app's origin. Required and concrete — see
|
|
17
|
+
* `assertConcreteOrigin`. Messages from anywhere else are ignored, and we
|
|
18
|
+
* never post to anywhere else.
|
|
19
|
+
*/
|
|
20
|
+
parentOrigin: string;
|
|
21
|
+
build: BuildInfo;
|
|
22
|
+
/**
|
|
23
|
+
* The matched route pattern, e.g. `/pub/[slug]`.
|
|
24
|
+
*
|
|
25
|
+
* Injected rather than derived: only the app knows its own route patterns,
|
|
26
|
+
* and `location.pathname` is the resolved URL, which is what the spec
|
|
27
|
+
* explicitly does not want — `/pub/the-eagle` and `/pub/the-crown` are the
|
|
28
|
+
* same route and should group as one.
|
|
29
|
+
*/
|
|
30
|
+
getRoute: () => string;
|
|
31
|
+
/**
|
|
32
|
+
* Page state at capture time. Injected so A-35's ring buffers can supply the
|
|
33
|
+
* real thing without this module knowing how they are collected.
|
|
34
|
+
*/
|
|
35
|
+
getState: () => AnnotationState;
|
|
36
|
+
/**
|
|
37
|
+
* Screenshot capture (A-31). Omit for the default, or pass `false` to turn it
|
|
38
|
+
* off entirely. See `screenshot.ts` for what it can and cannot capture — the
|
|
39
|
+
* short version is that it captures a media element's own bitmap and nothing
|
|
40
|
+
* else, so most clicks produce no screenshot at all, by design.
|
|
41
|
+
*/
|
|
42
|
+
screenshot?: ScreenshotOptions | false;
|
|
43
|
+
}
|
|
44
|
+
export interface AnnotationSdk {
|
|
45
|
+
enable(): void;
|
|
46
|
+
disable(): void;
|
|
47
|
+
isEnabled(): boolean;
|
|
48
|
+
destroy(): void;
|
|
49
|
+
}
|
|
50
|
+
/** State until A-35 lands its ring buffers. Empty is honest; invented is not. */
|
|
51
|
+
export declare const emptyAnnotationState: () => AnnotationState;
|
|
52
|
+
export declare function createAnnotationSdk(options: AnnotationSdkOptions): AnnotationSdk;
|