@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 ADDED
@@ -0,0 +1,360 @@
1
+ # Adopting annotation
2
+
3
+ Eight steps. **Every one of them fails silently**, and all five of the common
4
+ failures look identical from the console: a frame that says *waiting for the
5
+ page to announce itself*, forever, with nothing logged anywhere.
6
+
7
+ So read [Troubleshooting](#troubleshooting-by-symptom) first if something is
8
+ already not working. It is keyed by what you can see, not by what is wrong.
9
+
10
+ ---
11
+
12
+ ## What you are building
13
+
14
+ ```
15
+ reviewer's browser
16
+ ┌─────────────────────────────────────────┐
17
+ │ console ┌─────────────────┐ │
18
+ │ (the parent) ◄────┤ your app │ │
19
+ │ │ in an iframe │ │
20
+ │ createAnnotationHost└─────────────────┘ │
21
+ └─────────────────────────────────────────┘
22
+ ▲ │
23
+ │ postMessage │ data-src="src/ui/Card.tsx:42:5"
24
+ └────────────────────┘ put there at build time
25
+ ```
26
+
27
+ Three things have to be true before a single message moves:
28
+
29
+ 1. The browser must **permit the framing** — `frame-ancestors`.
30
+ 2. The page must be **armed** — a token, exchanged for a cookie.
31
+ 3. Elements must **carry source refs** — the build transform.
32
+
33
+ Miss any one and you get the same nothing.
34
+
35
+ ---
36
+
37
+ ## 1. Install
38
+
39
+ ```sh
40
+ npm i @weatherboard/annotation
41
+ npm i -D @weatherboard/annotation-next
42
+ ```
43
+
44
+ The runtime package has **zero dependencies** and ships in your app. The
45
+ transform runs only in your build.
46
+
47
+ **What breaks:** `-D` on the second one is not cosmetic. `annotation-next`
48
+ depends on a Babel parser, and npm installs a package's `dependencies` whichever
49
+ subpath you import — so putting it in `dependencies` puts a parser in your
50
+ production install, silently and forever. Nothing warns you; the app works.
51
+
52
+ ## 2. Add the build plugin
53
+
54
+ ```ts
55
+ // next.config.ts
56
+ import { withSourceRefs } from "@weatherboard/annotation-next";
57
+
58
+ export default withSourceRefs(nextConfig, {
59
+ enabled: annotationEnabled(process.env),
60
+ });
61
+ ```
62
+
63
+ **What breaks:** nothing visibly. The build succeeds, the app runs, and every
64
+ capture comes back with a box, a DOM path and **no file**. That is the single
65
+ most expensive failure here, because it looks like the feature working badly
66
+ rather than a step you skipped.
67
+
68
+ Check by opening the running app and inspecting any `<div>`. If it has no
69
+ `data-src`, this step is not working.
70
+
71
+ **Turbopack runs no webpack loaders.** `next dev --turbo` silently does nothing.
72
+
73
+ ## 3. One switch, read by two places
74
+
75
+ ```ts
76
+ // annotation-enabled.ts — imports nothing
77
+ export function annotationEnabled(env: Record<string, string | undefined>): boolean {
78
+ if (env.ANNOTATION_ENABLED === "1") return true;
79
+ if (env.ANNOTATION_ENABLED === "0") return false;
80
+ return env.VERCEL_ENV === "preview";
81
+ }
82
+ ```
83
+
84
+ Your build config and your middleware must not be able to disagree. A build with
85
+ source refs and no `frame-ancestors` is a frame that refuses to load; a build
86
+ with `frame-ancestors` and no source refs is a frame that loads and reports
87
+ feedback pointing at nothing.
88
+
89
+ Keep it in a module that **imports nothing**. Middleware runs on the edge
90
+ runtime, and pulling a build-time compiler in to read two environment variables
91
+ is the sort of import that works until it doesn't.
92
+
93
+ **Preview only.** Production is deliberately excluded: the attributes bloat every
94
+ page of HTML and publish the shape of your source tree, and there is no console
95
+ pointed at production anyway.
96
+
97
+ ## 4. Arm the session — middleware
98
+
99
+ ```ts
100
+ // middleware.ts
101
+ import { ANNOTATION_ARMED_COOKIE, readAnnotationToken } from "@weatherboard/annotation/protocol";
102
+
103
+ const presented = readAnnotationToken(request.nextUrl.searchParams);
104
+ const expected = process.env.ANNOTATION_TOKEN;
105
+
106
+ if (expected && presented && presented.length === expected.length && presented === expected) {
107
+ const overHttps = request.nextUrl.protocol === "https:";
108
+ response.cookies.set(ANNOTATION_ARMED_COOKIE, "1", {
109
+ httpOnly: false, // the client bundle is what reads it
110
+ sameSite: overHttps ? "none" : "lax",
111
+ secure: overHttps,
112
+ path: "/",
113
+ });
114
+ }
115
+ ```
116
+
117
+ Four details, each of which has cost somebody a day:
118
+
119
+ - **The cookie holds `1`, never the token.** It records that the token was
120
+ presented once, so navigation inside the frame stays armed without the secret
121
+ riding on every URL.
122
+ - **`httpOnly: false`** because the client bundle consults it. `true` and the SDK
123
+ never boots.
124
+ - **`SameSite=None`**, because the whole point is a cross-site iframe. A `Lax`
125
+ cookie is not sent in one, so the page arms on the first request and disarms on
126
+ every navigation after it.
127
+ - **…but `None` is only honoured with `Secure`**, and `Secure` is dropped over
128
+ plain http — so a local `next dev` would set a cookie the browser silently
129
+ discards. Fall back to `Lax`, which is same-origin anyway.
130
+
131
+ `readAnnotationToken` returns the **value**, not a boolean, and returns `null`
132
+ for an absent *or empty* parameter. Compare it yourself: only you hold the
133
+ secret.
134
+
135
+ **Refuse when unconfigured.** If `ANNOTATION_TOKEN` is unset, every comparison
136
+ must be `false` — including against an empty presented value. Otherwise a bare
137
+ `?ai_annotate=` arms the deployment.
138
+
139
+ ## 5. Let the console frame you — `frame-ancestors`
140
+
141
+ ```ts
142
+ const origins = (process.env.ANNOTATION_PARENT_ORIGINS ?? "")
143
+ .split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
144
+
145
+ if (annotationEnabled(process.env) && origins.length > 0) {
146
+ response.headers.set("Content-Security-Policy", `frame-ancestors ${origins.join(" ")};`);
147
+ }
148
+ ```
149
+
150
+ **Multiple CSP headers are enforced as their INTERSECTION, not their union.** If
151
+ your app already serves `frame-ancestors` anywhere — a static header in
152
+ `next.config`, another middleware — adding a second one intersects with it and
153
+ takes the existing embed down, with the old config still visibly listing an
154
+ origin that no longer works. Compute every `frame-ancestors` for your app in
155
+ **one place**.
156
+
157
+ **Never set `X-Frame-Options`.** It has no origin list — only `DENY` and
158
+ `SAMEORIGIN` — and where both headers are present, browsers have historically
159
+ honoured it over `frame-ancestors`. It silently overrules everything above.
160
+
161
+ **Validate the origins.** They are interpolated into a header, so a value
162
+ carrying a `;` ends `frame-ancestors` and starts a directive of the author's
163
+ choosing. Drop anything unrecognisable rather than escaping it: a typo should
164
+ cost one origin, not the integrity of the header.
165
+
166
+ **No default.** Your app does not know what manages it and must not. An empty
167
+ variable means same-origin only, which is the right failure: a frame the browser
168
+ refuses is visible immediately, unlike one that loads and accepts messages from
169
+ anywhere.
170
+
171
+ **What breaks:** the refusal happens in the browser, and the console never sees
172
+ it. Your server logs show a normal 200.
173
+
174
+ ## 6. Mount the SDK
175
+
176
+ ```tsx
177
+ "use client";
178
+ import { useEffect } from "react";
179
+ import { isAnnotationArmed } from "@weatherboard/annotation/protocol";
180
+
181
+ export function AnnotationMount() {
182
+ useEffect(() => {
183
+ if (!isAnnotationArmed(document.cookie)) return;
184
+ if (window.parent === window) return; // nowhere to speak
185
+
186
+ let dispose: (() => void) | null = null;
187
+ let cancelled = false;
188
+
189
+ void (async () => {
190
+ const { createAnnotationSdk, installStateCollector, defaultStateCollectorOptions } =
191
+ await import("@weatherboard/annotation");
192
+
193
+ const collector = installStateCollector(window, {
194
+ ...defaultStateCollectorOptions(),
195
+ getFeatureFlags: () => ({}),
196
+ });
197
+
198
+ const sdk = createAnnotationSdk({
199
+ parentOrigin: resolveParentOrigin(),
200
+ build: { gitSha: process.env.NEXT_PUBLIC_GIT_SHA ?? "unknown", env: "preview" },
201
+ getRoute: () => matchRoutePattern(window.location.pathname),
202
+ getState: () => collector.getState(),
203
+ });
204
+
205
+ dispose = () => { sdk.destroy(); collector.destroy(); };
206
+ if (cancelled) dispose();
207
+ })();
208
+
209
+ return () => { cancelled = true; dispose?.(); dispose = null; };
210
+ }, []);
211
+
212
+ return null;
213
+ }
214
+ ```
215
+
216
+ Put it in the **root layout**. It is on every route of a public site, so what an
217
+ ordinary visitor pays matters: a cookie read and a return. Everything else is
218
+ behind a dynamic `import()` that only an armed session reaches, so none of it is
219
+ in the bundle a normal page load parses.
220
+
221
+ **What breaks:** mounted anywhere but the root layout, annotation works on the
222
+ routes below that layout and silently does nothing everywhere else — so it looks
223
+ like an intermittent bug in the console rather than a missing mount. And a
224
+ `useEffect` that throws before `createAnnotationSdk` is reached fails the same
225
+ way as no mount at all: check the framed page's console for *your* errors before
226
+ concluding the SDK never booted.
227
+
228
+ **The collector still installs early.** The ring buffers must fill from page load
229
+ rather than from click time — by the time someone notices something is wrong, the
230
+ interesting error has already been logged and `console` no longer holds it. The
231
+ dynamic import costs a chunk fetch, so the first few milliseconds of an armed
232
+ page are genuinely unobserved. That is the trade. What you must *not* do is defer
233
+ the collector to click time, which observes nothing at all.
234
+
235
+ ## 7. `parentOrigin` is one address, not a list
236
+
237
+ `postMessage`'s second argument is **not an allowlist**. It is an address, and
238
+ the browser's guarantee that a payload — carrying this page's console errors,
239
+ failed requests and URL — is delivered nowhere else. `*` is the only way to say
240
+ "several", and `*` is exactly what would broadcast annotations to any embedder.
241
+
242
+ But `frame-ancestors` legitimately names several origins: production, plus a
243
+ preview per branch.
244
+
245
+ **Do not pick from the list.** "The first entry" is a guess, and a wrong guess is
246
+ a channel that silently never connects. Ask the browser instead:
247
+
248
+ ```ts
249
+ function resolveParentOrigin(): string | null {
250
+ const allowed = parsePublicOrigins(process.env.NEXT_PUBLIC_ANNOTATION_PARENT_ORIGINS);
251
+ const referrer = document.referrer ? new URL(document.referrer).origin : null;
252
+
253
+ if (referrer && allowed.includes(referrer)) return referrer; // observed, then validated
254
+ if (allowed.length === 1) return allowed[0]; // no ambiguity to resolve
255
+ return null; // refuse rather than guess
256
+ }
257
+ ```
258
+
259
+ `document.referrer` on a framed document is the framing document's URL, and a
260
+ console serving `Referrer-Policy: strict-origin-when-cross-origin` gives you its
261
+ origin exactly. The referrer is not trusted on its own and does not need to be:
262
+ two independent things must hold before a payload moves — the browser permitted
263
+ the framing (checked server-side against your list) *and* the origin is in the
264
+ list here. A page that lies about its referrer cannot make itself a permitted
265
+ ancestor.
266
+
267
+ **A second, `NEXT_PUBLIC_` variable.** `NEXT_PUBLIC_*` is inlined at build time,
268
+ so promoting the server-side one would make your frame-ancestors policy a
269
+ build-time constant that needs a redeploy to change. Two variables holding the
270
+ same value is the cost of the header staying a runtime decision. Pin them
271
+ together with a test.
272
+
273
+ **When it returns `null`, log and do not boot.** Posting to the wrong origin
274
+ delivers to nobody and looks exactly like a frame that never loaded.
275
+
276
+ ## 8. `getRoute` is the pattern, not the URL
277
+
278
+ ```ts
279
+ getRoute: () => matchRoutePattern(window.location.pathname),
280
+ // "/pub/[pubSlug]", never "/pub/the-eagle"
281
+ ```
282
+
283
+ `/pub/the-eagle` and `/pub/the-crown` are **one route**. Send the resolved URL
284
+ and every page is its own route, so nothing can ever say "twelve people
285
+ complained about the pub page".
286
+
287
+ Next will not tell you the matched pattern on the client, so you have to match it
288
+ yourself against a table generated from your `app/` tree. Get the precedence
289
+ right — a static segment beats `[param]`, which beats a catch-all — and test
290
+ `/best-pubs/summer` against `/best-pubs/[month]` specifically.
291
+
292
+ Return something honest and greppable when nothing matches. `"(unmatched)"` beats
293
+ a wrong pattern.
294
+
295
+ **Read it at capture time, not at mount.** Client-side navigation inside the
296
+ frame does not remount the effect, so a route captured at mount is the route the
297
+ frame opened on for the rest of the session.
298
+
299
+ ---
300
+
301
+ ## Troubleshooting, by symptom
302
+
303
+ Everything below presents as **"waiting for the page to announce itself"**. Work
304
+ down the table; the checks are ordered so that an earlier failure cannot be
305
+ mistaken for a later one.
306
+
307
+ | # | Check, in the framed page's devtools | If it fails |
308
+ |---|---|---|
309
+ | 1 | **Network tab: did the document load at all?** A `frame-ancestors` refusal shows as a blocked frame with a console message in the **parent**, and a normal 200 in your server logs. | Step 5. Check for a second CSP header intersecting yours, and for `X-Frame-Options`. |
310
+ | 2 | `document.cookie` contains `ai_annotate_armed=1` | Step 4. See below — this one has four separate causes. |
311
+ | 3 | `window.parent !== window` | You are testing top-level. The SDK's only output is a `postMessage` to a parent; an unframed page has nowhere to speak. |
312
+ | 4 | Any element has a `data-src` attribute | Step 2. The build transform is not running — or you are on Turbopack, where it silently does nothing. |
313
+ | 5 | Console shows no `[annotation] armed, but no parent origin resolved` | Step 7. Your `NEXT_PUBLIC_` origin list is missing, or the referrer is not in it. |
314
+
315
+ ### The cookie is missing (check 2), and why
316
+
317
+ Four causes, indistinguishable from the outside:
318
+
319
+ | Cause | How to tell |
320
+ |---|---|
321
+ | `ANNOTATION_TOKEN` is unset on the deployment | The comparison returns `false` for every input, by design. Check the env, not the code. |
322
+ | The presented token does not match | **Nothing is logged.** The length check fails before any comparison. Compare lengths first when debugging, never values — and never print either. |
323
+ | `httpOnly: true` | The cookie exists and `document.cookie` cannot see it. Check the Application tab, not the console. |
324
+ | `SameSite=Lax` over https in a cross-site frame | The cookie is set on the first request and dropped on every navigation after it. Symptom: it works once, then stops. |
325
+
326
+ ### The refs are wrong rather than missing
327
+
328
+ A ref that is well-formed, plausible, and points at a doc comment means you are
329
+ **losing a loader race**. Another plugin that appends to `mod.loaders` during the
330
+ build runs before the entire rule-based chain, and if it strips types and
331
+ comments first, the source we parse is not the source you wrote.
332
+
333
+ ```sh
334
+ ANNOTATION_SOURCE_REFS_DEBUG=src/ui/Card next build
335
+ ```
336
+
337
+ prints each matching module's loader chain at push time. This failure is silent
338
+ and the chain is the only thing that shows who won.
339
+
340
+ A ref that is an absolute path, or contains `node_modules`, means the repository
341
+ root was resolved wrongly — pass `rootDir` explicitly.
342
+
343
+ ### Everything checks out and still nothing
344
+
345
+ Confirm the console and the page agree on the **origin**, exactly:
346
+ `https://app.example.com` and `https://app.example.com:443` are different strings
347
+ to `postMessage`, and it fails silently when they differ.
348
+
349
+ ---
350
+
351
+ ## The shape of every failure here
352
+
353
+ Worth stating once, because it is the same shape every time and it is what makes
354
+ this integration expensive: **a rule that matches nothing reads exactly like a
355
+ rule that passes**, and a check that fails identically for several causes will be
356
+ misattributed.
357
+
358
+ Every step above is a thing that, when absent, produces no error, no warning and
359
+ no log line — only an absence. When you are debugging, prefer the check that
360
+ distinguishes causes over the check that confirms your guess.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Weatherboard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,109 @@
1
+ # @weatherboard/annotation
2
+
3
+ Click an element in a running app, get the source location that rendered it.
4
+
5
+ **Setting it up? Read [ADOPTING.md](./ADOPTING.md).** There are eight steps and
6
+ every one of them fails silently — all five common failures look identical from
7
+ the console. The guide is keyed by symptom for exactly that reason.
8
+
9
+ A reviewer opens the product, clicks the thing that is wrong, and types a
10
+ sentence. What comes back is not a screenshot and a guess — it is the file and
11
+ line that rendered that element, the ancestors that led to it, the console
12
+ errors and failed requests from that session, and the build the page came from.
13
+
14
+ Zero dependencies. It runs inside your production site, so it has no business
15
+ adding to your supply chain.
16
+
17
+ ## Install
18
+
19
+ ```sh
20
+ npm i @weatherboard/annotation
21
+ ```
22
+
23
+ ## Three entry points
24
+
25
+ They are split by **who imports them**, not by topic.
26
+
27
+ | Import | Runs where | For |
28
+ |---|---|---|
29
+ | `@weatherboard/annotation` | The browser | The page SDK — overlay, target resolution, state collection, screenshots |
30
+ | `@weatherboard/annotation/protocol` | Anywhere, no DOM | The wire names and the two readers. This is what a middleware imports |
31
+ | `@weatherboard/annotation/host` | The reviewing console | The parent half of the channel |
32
+
33
+ `/protocol` is a subpath rather than a tree-shaking promise on purpose: an edge
34
+ middleware is often bundled as CJS, and CJS does not shake. Importing the root
35
+ there would drag the overlay into a bundle that has no `document`.
36
+
37
+ ## The page half
38
+
39
+ ```ts
40
+ import { createAnnotationSdk, isAnnotationArmed, readAnnotationToken } from "@weatherboard/annotation";
41
+
42
+ if (isAnnotationArmed(document.cookie)) {
43
+ const sdk = createAnnotationSdk({
44
+ parentOrigin: "https://console.example.com",
45
+ build: { gitSha: process.env.NEXT_PUBLIC_GIT_SHA, env: "preview" },
46
+ });
47
+ sdk.start();
48
+ }
49
+ ```
50
+
51
+ **The SDK must not boot unless the page is armed.** Arming is a token in the URL
52
+ matched against a secret the app holds, set as a cookie by your middleware. The
53
+ package gives you the reader and the cookie check; deciding whether the token is
54
+ right is yours, because only you hold the secret.
55
+
56
+ ```ts
57
+ // middleware.ts
58
+ import { ANNOTATION_ARMED_COOKIE, readAnnotationToken } from "@weatherboard/annotation/protocol";
59
+
60
+ const token = readAnnotationToken(request.nextUrl.searchParams);
61
+ if (token !== undefined && token === process.env.ANNOTATION_TOKEN) {
62
+ response.cookies.set(ANNOTATION_ARMED_COOKIE, "1", { httpOnly: false, sameSite: "lax" });
63
+ }
64
+ ```
65
+
66
+ `readAnnotationToken` returns the **value**, not a boolean, and returns `null`
67
+ for an absent *or empty* parameter. An empty token compared with `===` against
68
+ your secret is a rejection; an empty token treated as present is an app armed
69
+ with the empty string, which fails later and reads like a bad secret rather than
70
+ a missing one.
71
+
72
+ ## Source refs
73
+
74
+ Target resolution reads a `data-src` attribute of the form `path/to/File.tsx:120`
75
+ off the nearest ancestor that has one. Something has to put it there:
76
+
77
+ ```sh
78
+ npm i -D @weatherboard/annotation-next
79
+ ```
80
+
81
+ ```ts
82
+ // next.config.ts
83
+ import { withSourceRefs } from "@weatherboard/annotation-next";
84
+
85
+ export default withSourceRefs(nextConfig, {
86
+ enabled: process.env.VERCEL_ENV !== "production",
87
+ });
88
+ ```
89
+
90
+ It is a separate package because npm installs a package's `dependencies`
91
+ whichever subpath you import, and the transform needs a Babel parser. Keeping
92
+ them together would put that parser in the `node_modules` of every site that
93
+ only ever wanted to read a cookie.
94
+
95
+ Nothing on a page carries `data-src` by default, and a page with none produces
96
+ captures that succeed and are worthless: a box, a DOM path, and no file. If your
97
+ captures all report no source ref, the transform is not running.
98
+
99
+ ## Compatibility
100
+
101
+ `0.x`. The wire protocol is still moving, and the version says so. The move to
102
+ 1.0 will be the statement that it has stopped — not a decision to look finished.
103
+
104
+ Two of the wire names have a legacy spelling that readers still accept. See
105
+ `protocol.ts`; they are not changing again.
106
+
107
+ ## Licence
108
+
109
+ MIT.
package/dist/host.cjs ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";var s=Object.defineProperty;var c=Object.getOwnPropertyDescriptor;var p=Object.getOwnPropertyNames;var l=Object.prototype.hasOwnProperty;var g=(e,t)=>{for(var a in t)s(e,a,{get:t[a],enumerable:!0})},f=(e,t,a,o)=>{if(t&&typeof t=="object"||typeof t=="function")for(let r of p(t))!l.call(e,r)&&r!==a&&s(e,r,{get:()=>t[r],enumerable:!(o=c(t,r))||o.enumerable});return e};var m=e=>f(s({},"__esModule",{value:!0}),e);var T={};g(T,{createAnnotationHost:()=>A,isFrameMessage:()=>d});module.exports=m(T);function u(e){if(!e||e==="*")throw new Error("[annotation] a concrete parent origin is required; '*' would broadcast annotation payloads to any embedder.");return e}var y=new Set(["annotate:ready","annotate:hover","annotate:capture","annotate:cancel"]);function d(e){if(typeof e!="object"||e===null)return!1;let t=e.type;return typeof t=="string"&&y.has(t)}function A(e){let t=u(e.frameOrigin),a=!1,o=!1,r=n=>{o||e.frame.postMessage(n,t)},i=n=>{if(n.origin===t&&n.source===e.frame&&d(n.data))switch(n.data.type){case"annotate:ready":a=!0,e.onReady({sdkVersion:n.data.sdkVersion,build:n.data.build});return;case"annotate:hover":e.onHover(n.data.target);return;case"annotate:capture":e.onCapture(n.data.payload);return;case"annotate:cancel":e.onCancel();return}};return e.hostWindow.addEventListener("message",i),{enable:()=>r({type:"annotate:enable"}),disable:()=>r({type:"annotate:disable"}),ping:()=>r({type:"annotate:ping"}),isReady:()=>a,destroy(){o=!0,a=!1,e.hostWindow.removeEventListener("message",i)}}}
2
+ //# sourceMappingURL=host.cjs.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/host.ts", "../src/protocol.ts"],
4
+ "sourcesContent": ["import {\n assertConcreteOrigin,\n type AnnotationPayload,\n type BuildInfo,\n type FrameToParent,\n type ParentToFrame,\n type TargetInfo,\n} from \"./protocol\";\n\n/**\n * The parent half of the annotation channel (A-30).\n *\n * WHY THIS LIVES IN THE SDK AND NOT IN `apps/admin`.\n *\n * The protocol is this package's domain. It already owns the message union,\n * `isParentMessage` and `assertConcreteOrigin`; a second implementation of the\n * same handshake, written host-side because that is where the UI is, is exactly\n * the shape SHARED-PACKAGES.md warns about. The two halves of a protocol\n * drifting apart is not a hypothetical \u2014 it is the normal outcome when they are\n * maintained in different packages by different changes.\n *\n * Concretely: the frame's `sdk.ts` and this file are the only two things that\n * can disagree about what `annotate:capture` means, and keeping them in one\n * package means a change to the union is a compile error in both.\n *\n * Admin gets the UI. This gets the wire.\n */\n\nexport interface AnnotationHostOptions {\n /**\n * The window being framed. Passed in rather than looked up, because the host\n * knows which iframe it means and this module must never guess.\n */\n frame: Window;\n /**\n * The frame's origin \u2014 where the product is served from. Required and\n * concrete, checked by `assertConcreteOrigin`: posting to `*` would hand an\n * annotation payload, including console errors from a real session, to\n * whatever happens to be loaded.\n */\n frameOrigin: string;\n /** The window that receives messages. Injected so tests need no real iframe. */\n hostWindow: Window;\n\n onReady(info: { sdkVersion: string; build: BuildInfo }): void;\n onHover(target: TargetInfo): void;\n onCapture(payload: AnnotationPayload): void;\n onCancel(): void;\n}\n\nexport interface AnnotationHost {\n enable(): void;\n disable(): void;\n ping(): void;\n /** True once the frame has announced itself. */\n isReady(): boolean;\n destroy(): void;\n}\n\nconst FRAME_TYPES = new Set([\n \"annotate:ready\",\n \"annotate:hover\",\n \"annotate:capture\",\n \"annotate:cancel\",\n]);\n\n/**\n * Is this one of ours?\n *\n * The mirror of `isParentMessage`, and it carries the same warning: origin is\n * checked separately and FIRST. A well-formed message from the wrong origin is\n * precisely the case this guard must not be trusted to catch \u2014 anything can\n * post `{type: \"annotate:capture\"}` to a window.\n */\nexport function isFrameMessage(data: unknown): data is FrameToParent {\n if (typeof data !== \"object\" || data === null) return false;\n const type = (data as { type?: unknown }).type;\n return typeof type === \"string\" && FRAME_TYPES.has(type);\n}\n\nexport function createAnnotationHost(options: AnnotationHostOptions): AnnotationHost {\n const frameOrigin = assertConcreteOrigin(options.frameOrigin);\n let ready = false;\n let destroyed = false;\n\n const post = (message: ParentToFrame) => {\n if (destroyed) return;\n // Targeted, never `*`. `enable` is not sensitive in itself, but a habit of\n // posting to `*` is what eventually posts something that is.\n options.frame.postMessage(message, frameOrigin);\n };\n\n const onMessage = (event: MessageEvent) => {\n /**\n * Origin first, and by exact match.\n *\n * Not `endsWith`, not a prefix: for a product served from `example.com`,\n * `https://evil-example.com` ends with nothing suspicious and\n * `https://example.com.evil.test` starts with the right characters. The browser hands us a normalised origin, so an\n * equality test is both correct and the only one that cannot be widened by\n * a hostile hostname.\n */\n if (event.origin !== frameOrigin) return;\n\n /**\n * And the source must be the frame we are talking to.\n *\n * Origin alone is not enough: any other window on the same origin \u2014 a popup\n * the product opened, another iframe \u2014 can post a well-formed message from\n * an origin that matches. This is the check that makes \"the frame said so\"\n * mean the frame.\n */\n if (event.source !== options.frame) return;\n\n if (!isFrameMessage(event.data)) return;\n\n switch (event.data.type) {\n case \"annotate:ready\":\n ready = true;\n options.onReady({ sdkVersion: event.data.sdkVersion, build: event.data.build });\n return;\n case \"annotate:hover\":\n options.onHover(event.data.target);\n return;\n case \"annotate:capture\":\n options.onCapture(event.data.payload);\n return;\n case \"annotate:cancel\":\n options.onCancel();\n return;\n }\n };\n\n options.hostWindow.addEventListener(\"message\", onMessage);\n\n return {\n enable: () => post({ type: \"annotate:enable\" }),\n disable: () => post({ type: \"annotate:disable\" }),\n ping: () => post({ type: \"annotate:ping\" }),\n isReady: () => ready,\n destroy() {\n destroyed = true;\n ready = false;\n options.hostWindow.removeEventListener(\"message\", onMessage);\n },\n };\n}\n", "/**\n * The annotation postMessage protocol (A-36).\n *\n * Mirrors `docs/contracts/types.ts`. That file is the\n * cross-component contract; this is the runtime copy the product bundle ships,\n * because the product cannot import from a docs directory. Changing a shape\n * here is a change to both \u2014 say so in the PR.\n */\n\nexport const SDK_VERSION = \"1\";\n\n/**\n * ===========================================================================\n * THE ATTRIBUTES \u2014 THE OTHER WIRE (A-186)\n * ===========================================================================\n *\n * These live in `protocol.ts`, not in `target.ts` where they are read, because\n * they are a contract between TWO PACKAGES that a consumer installs separately:\n * `@weatherboard/annotation-next` WRITES them at build time and this package\n * READS them at runtime. Two spellings of `data-src` is not a bug anybody\n * catches \u2014 the transform runs, the attributes appear, and every capture comes\n * back with no source ref at all, which is indistinguishable from the transform\n * not running.\n *\n * `annotation-next` imports these from `@weatherboard/annotation/protocol`\n * rather than declaring its own, which is why the subpath has no DOM in it: a\n * build tool importing the page SDK to learn two strings would be absurd.\n */\nexport const SRC_ATTR = \"data-src\";\nexport const NAME_ATTR = \"data-src-name\";\n\nexport interface SourceRef {\n file: string;\n line: number;\n column?: number;\n}\n\nexport interface BuildInfo {\n /** Mandatory. Feedback not pinned to a revision is ambiguous the moment someone merges. */\n gitSha: string;\n /**\n * Where this build came from.\n *\n * `\"development\"` was added in A-37, when the SDK was first mounted in a\n * real app and the two halves turned out not to agree: `resolveBuildInfo` in\n * `resolveBuildInfo` in the product's own Next config package \u2014 the module\n * that stamps the build-env variable, and the only producer of this field \u2014\n * emits\n * `production | preview | development`, mirroring `VERCEL_ENV`. This union\n * instead offered `\"staging\"`, which nothing produces: a staging deploy IS a\n * Vercel preview and reports itself as one. Widening rather than forcing the\n * mount to lie: a locally-annotated build calling itself a preview would put\n * someone's laptop in the corpus as though it were a deployment.\n */\n env: \"production\" | \"preview\" | \"staging\" | \"development\";\n branch?: string;\n /** Set when reviewing an agent's preview deploy, so feedback routes to that run's session. */\n previewRunId?: string;\n}\n\nexport interface ConsoleEntry {\n level: \"error\" | \"warn\";\n message: string;\n stack?: string;\n at: string;\n}\n\nexport interface NetworkEntry {\n url: string;\n method: string;\n status: number;\n at: string;\n}\n\nexport interface TargetInfo {\n /**\n * What the reviewer selected \u2014 one element, or an area (A-179).\n *\n * -------------------------------------------------------------------------\n * WHY THIS EXISTS RATHER THAN BEING INFERRED\n * -------------------------------------------------------------------------\n *\n * A region capture and an element capture are the same SHAPE: a bounding box,\n * a chain of source refs, a DOM path. Nothing in the values distinguishes\n * them, and the two mean opposite things about the chain \u2014 for an element it\n * is ANCESTORS, innermost first, so entry two contains entry one; for a\n * region it is SIBLINGS the rectangle happened to cross, which contain\n * nothing of each other.\n *\n * A reader that guesses will describe a region's siblings as a call-site\n * chain, which is a confident false statement about the page. `describeCapture`\n * hands that description to a model that writes acceptance criteria from it.\n *\n * -------------------------------------------------------------------------\n * WHY IT IS OPTIONAL, AND WHY ABSENT MEANS `element`\n * -------------------------------------------------------------------------\n *\n * The two halves of this protocol live in different repositories and deploy\n * independently (see the note on the legacy parameter names below). Every\n * capture in the store today, and every capture from a product bundle that\n * predates region selection, has no such field \u2014 and all of them are element\n * captures. Absent therefore has to mean `element`, or this field would\n * retroactively make the entire history ambiguous.\n *\n * Required-with-a-default would have the same effect and a worse failure: an\n * older bundle omits it, the type says it is there, and every reader believes\n * a value nobody sent.\n */\n selection?: \"element\" | \"region\";\n source?: SourceRef;\n /**\n * Every `data-src` from the clicked element up to the document root, innermost\n * first \u2014 an addition to the contract, and the reason is structural.\n *\n * Only host elements carry a ref (A-33), so `source` names the file that\n * *writes the markup*. For a composed design-system component that is\n * `MediaCard.tsx`, not the `GuideDetailCard.tsx` a human would say they\n * clicked. The chain is what lets the PM agent and the coding agent see both,\n * and pick the call site over the leaf when the feedback is about content\n * rather than the component itself.\n */\n sourceChain?: SourceRef[];\n componentName?: string;\n /** CSS selector fallback, for when no ancestor carries a ref at all. */\n domPath: string;\n boundingBox: { x: number; y: number; w: number; h: number };\n /** Truncated to 200 characters. */\n textContent?: string;\n}\n\nexport interface AnnotationPayload {\n schemaVersion: 1;\n capturedAt: string;\n build: BuildInfo;\n page: {\n url: string;\n /** The matched route pattern, not the resolved URL. */\n route: string;\n viewport: { w: number; h: number; dpr: number };\n userAgent: string;\n };\n target: TargetInfo;\n state: {\n consoleErrors: ConsoleEntry[];\n failedRequests: NetworkEntry[];\n featureFlags: Record<string, unknown>;\n };\n screenshot?: { dataUrl: string; region: \"element\" | \"viewport\" };\n /** The human's words, verbatim. The parent frame fills this in; we never rewrite it. */\n comment: string;\n}\n\nexport type ParentToFrame =\n | { type: \"annotate:enable\" }\n | { type: \"annotate:disable\" }\n | { type: \"annotate:ping\" };\n\nexport type FrameToParent =\n | { type: \"annotate:ready\"; sdkVersion: string; build: BuildInfo }\n | { type: \"annotate:hover\"; target: TargetInfo }\n | { type: \"annotate:capture\"; payload: AnnotationPayload }\n | { type: \"annotate:cancel\" };\n\nconst PARENT_TYPES = new Set([\"annotate:enable\", \"annotate:disable\", \"annotate:ping\"]);\n\n/**\n * Is this a message we recognise?\n *\n * Origin is checked separately and first \u2014 a well-formed message from the wrong\n * origin is the case this guard must not be trusted to catch.\n */\nexport function isParentMessage(data: unknown): data is ParentToFrame {\n if (typeof data !== \"object\" || data === null) return false;\n const type = (data as { type?: unknown }).type;\n return typeof type === \"string\" && PARENT_TYPES.has(type);\n}\n\n/**\n * Reject wildcards outright rather than treating them as \"any origin\".\n *\n * `postMessage(msg, \"*\")` broadcasts the payload to whatever happens to be\n * framing us, and an annotation payload carries page content, console errors and\n * failed request URLs. The spec's rule is no wildcards in either direction; this\n * makes it impossible to configure one by accident.\n */\nexport function assertConcreteOrigin(origin: string): string {\n if (!origin || origin === \"*\") {\n throw new Error(\n \"[annotation] a concrete parent origin is required; '*' would broadcast \" +\n \"annotation payloads to any embedder.\",\n );\n }\n return origin;\n}\n\n/**\n * The query parameter that arms the SDK in a managed product, and the cookie\n * that records it.\n *\n * THESE LIVE IN THE PROTOCOL BECAUSE THE TWO HALVES ARE NOW IN DIFFERENT\n * REPOSITORIES. The managed product's middleware looks for the parameter and\n * sets the cookie; the management app appends the parameter and the SDK reads\n * the cookie. While both halves were one monorepo, a test could read the other\n * side's source and assert the strings matched \u2014 that gate does not survive a\n * repository split, and a mismatch produces no error at all: the frame loads,\n * the cookie is never set, the SDK never boots, and the reviewer sees \"waiting\n * for the page to announce itself\" with nothing to go on.\n *\n * So they stop being two spellings that agree and become one exported\n * constant. A managed product that imports this package cannot drift from it.\n */\nexport const ANNOTATION_TOKEN_PARAM = \"ai_annotate\";\nexport const ANNOTATION_ARMED_COOKIE = \"ai_annotate_armed\";\n\n/**\n * The names these used to have (A-98).\n *\n * -------------------------------------------------------------------------\n * WHY THE RENAME, AND WHY IT COULD NOT JUST BE DONE\n * -------------------------------------------------------------------------\n *\n * They were `pits_annotate` and `pits_annotate_armed` \u2014 one managed product's\n * initialism, in a contract every managed product's bundle has to implement.\n * Onboarding a second product meant asking an unrelated codebase to adopt the\n * first product's name in its cookies and its query strings. That is the bug\n * A-98 is about, and it is a bug in a wire protocol rather than in a variable\n * name, which is what makes it expensive.\n *\n * The two halves live in DIFFERENT REPOSITORIES and deploy independently. A\n * clean rename is therefore a breaking change to every product bundle\n * simultaneously, and its failure mode is silence: the frame loads, the cookie\n * is never set, the SDK never boots, and the reviewer sees \"waiting for the\n * page to announce itself\" with nothing to go on. Nothing throws, nothing logs,\n * and the natural reading is that the agent broke the site.\n *\n * -------------------------------------------------------------------------\n * THE MIGRATION: READ BOTH, WRITE BOTH, THEN STOP WRITING THE OLD ONE\n * -------------------------------------------------------------------------\n *\n * 1. **Now.** Every reader accepts either name. Every writer emits BOTH, so a\n * product still running the old bundle arms on the legacy name and one\n * running the new bundle arms on the new one. Deploy order stops mattering,\n * which is the whole point \u2014 there is no window in which a reviewer sees the\n * silent failure above.\n * 2. **After every managed product's bundle is on this version** (A-102). Drop\n * the legacy names from the writers, then from the readers. Two steps, not\n * one: a reader that stops accepting the old name before the writers stop\n * emitting it re-creates the outage in the other direction.\n *\n * The legacy names are exported rather than inlined so the deletion in step 2\n * is `git grep LEGACY_` rather than an archaeology exercise.\n */\nexport const LEGACY_ANNOTATION_TOKEN_PARAM = \"pits_annotate\";\nexport const LEGACY_ANNOTATION_ARMED_COOKIE = \"pits_annotate_armed\";\n\n/**\n * ===========================================================================\n * AND THE NAMES ARE NOT CHANGING AGAIN FOR THE PUBLISHED PACKAGE (A-184)\n * ===========================================================================\n *\n * When this becomes `@weatherboard/annotation` the obvious next thought is that\n * the wire names should match the package \u2014 `weatherboard_annotate`. They\n * should not, and the reason is the migration described above rather than\n * taste.\n *\n * That migration is STILL IN FLIGHT. Readers accept both spellings; writers\n * emit both; step 2 waits on every managed product's bundle being current.\n * A third spelling introduced now does not replace the second, it joins it:\n * every reader accepts three names, every writer emits three, and the window in\n * which a product can be on any of them widens instead of closing.\n *\n * The cost is not the code. It is that each rename buys another instance of the\n * failure this file exists to prevent \u2014 the frame loads, the cookie is never\n * set, the SDK never boots, and the reviewer sees \"waiting for the page to\n * announce itself\" with nothing to go on. Twice is a migration. Three times is\n * a habit, and the third one would be paid by consumers who never asked for\n * either.\n *\n * `ai_annotate` is already product-neutral, which was the entire point of A-98.\n * A published package needs names that name no product; it does not need names\n * that name the package. Nobody reading a query string cares which npm scope\n * the library came from.\n *\n * The one real objection is collision: `ai_` is a common prefix and some other\n * tool could plausibly use it. That is survivable by construction \u2014 arming\n * requires the parameter to carry a token the product compares exactly, so a\n * coincidental `?ai_annotate=` from an unrelated tool arms nothing. A colliding\n * COOKIE cannot false-positive either; `isAnnotationArmed` matches on the\n * delimiter rather than by substring, for exactly this reason.\n *\n * If a rename is ever genuinely warranted, it happens ONCE, after step 2 has\n * landed and the reader accepts one name again \u2014 not layered on top of an\n * unfinished one.\n */\n\n/** Both spellings of the query parameter, current first. */\nexport const ANNOTATION_TOKEN_PARAMS = [\n ANNOTATION_TOKEN_PARAM,\n LEGACY_ANNOTATION_TOKEN_PARAM,\n] as const;\n\n/** Both spellings of the cookie, current first. */\nexport const ANNOTATION_ARMED_COOKIES = [\n ANNOTATION_ARMED_COOKIE,\n LEGACY_ANNOTATION_ARMED_COOKIE,\n] as const;\n\n/**\n * Whether a cookie jar carries the armed cookie, under either name.\n *\n * Delimiter-aware rather than a substring test: a cookie named\n * `not_ai_annotate_armed`, or another cookie whose VALUE contains the name,\n * satisfies `includes` \u2014 and arming an annotation channel on a coincidence is\n * not a failure anyone would think to look for.\n */\nexport function isAnnotationArmed(cookie: string): boolean {\n return cookie\n .split(\";\")\n .some((entry) => {\n const trimmed = entry.trimStart();\n return ANNOTATION_ARMED_COOKIES.some((name) => trimmed.startsWith(`${name}=`));\n });\n}\n\n/**\n * The arming token from a URL's query string, under either name.\n *\n * Current name wins when both are present. They should never disagree \u2014 the\n * management app writes both from one value \u2014 but if they ever do, the one this\n * version of the protocol owns is the one to believe.\n */\nexport function readAnnotationToken(params: URLSearchParams): string | null {\n for (const name of ANNOTATION_TOKEN_PARAMS) {\n const value = params.get(name);\n if (value !== null && value.length > 0) return value;\n }\n return null;\n}\n"],
5
+ "mappings": "yaAAA,IAAAA,EAAA,GAAAC,EAAAD,EAAA,0BAAAE,EAAA,mBAAAC,IAAA,eAAAC,EAAAJ,GCyLO,SAASK,EAAqBC,EAAwB,CAC3D,GAAI,CAACA,GAAUA,IAAW,IACxB,MAAM,IAAI,MACR,6GAEF,EAEF,OAAOA,CACT,CDtIA,IAAMC,EAAc,IAAI,IAAI,CAC1B,iBACA,iBACA,mBACA,iBACF,CAAC,EAUM,SAASC,EAAeC,EAAsC,CACnE,GAAI,OAAOA,GAAS,UAAYA,IAAS,KAAM,MAAO,GACtD,IAAMC,EAAQD,EAA4B,KAC1C,OAAO,OAAOC,GAAS,UAAYH,EAAY,IAAIG,CAAI,CACzD,CAEO,SAASC,EAAqBC,EAAgD,CACnF,IAAMC,EAAcC,EAAqBF,EAAQ,WAAW,EACxDG,EAAQ,GACRC,EAAY,GAEVC,EAAQC,GAA2B,CACnCF,GAGJJ,EAAQ,MAAM,YAAYM,EAASL,CAAW,CAChD,EAEMM,EAAaC,GAAwB,CAUzC,GAAIA,EAAM,SAAWP,GAUjBO,EAAM,SAAWR,EAAQ,OAExBJ,EAAeY,EAAM,IAAI,EAE9B,OAAQA,EAAM,KAAK,KAAM,CACvB,IAAK,iBACHL,EAAQ,GACRH,EAAQ,QAAQ,CAAE,WAAYQ,EAAM,KAAK,WAAY,MAAOA,EAAM,KAAK,KAAM,CAAC,EAC9E,OACF,IAAK,iBACHR,EAAQ,QAAQQ,EAAM,KAAK,MAAM,EACjC,OACF,IAAK,mBACHR,EAAQ,UAAUQ,EAAM,KAAK,OAAO,EACpC,OACF,IAAK,kBACHR,EAAQ,SAAS,EACjB,MACJ,CACF,EAEA,OAAAA,EAAQ,WAAW,iBAAiB,UAAWO,CAAS,EAEjD,CACL,OAAQ,IAAMF,EAAK,CAAE,KAAM,iBAAkB,CAAC,EAC9C,QAAS,IAAMA,EAAK,CAAE,KAAM,kBAAmB,CAAC,EAChD,KAAM,IAAMA,EAAK,CAAE,KAAM,eAAgB,CAAC,EAC1C,QAAS,IAAMF,EACf,SAAU,CACRC,EAAY,GACZD,EAAQ,GACRH,EAAQ,WAAW,oBAAoB,UAAWO,CAAS,CAC7D,CACF,CACF",
6
+ "names": ["host_exports", "__export", "createAnnotationHost", "isFrameMessage", "__toCommonJS", "assertConcreteOrigin", "origin", "FRAME_TYPES", "isFrameMessage", "data", "type", "createAnnotationHost", "options", "frameOrigin", "assertConcreteOrigin", "ready", "destroyed", "post", "message", "onMessage", "event"]
7
+ }
@@ -0,0 +1,60 @@
1
+ import { type AnnotationPayload, type BuildInfo, type FrameToParent, type TargetInfo } from "./protocol.cjs";
2
+ /**
3
+ * The parent half of the annotation channel (A-30).
4
+ *
5
+ * WHY THIS LIVES IN THE SDK AND NOT IN `apps/admin`.
6
+ *
7
+ * The protocol is this package's domain. It already owns the message union,
8
+ * `isParentMessage` and `assertConcreteOrigin`; a second implementation of the
9
+ * same handshake, written host-side because that is where the UI is, is exactly
10
+ * the shape SHARED-PACKAGES.md warns about. The two halves of a protocol
11
+ * drifting apart is not a hypothetical — it is the normal outcome when they are
12
+ * maintained in different packages by different changes.
13
+ *
14
+ * Concretely: the frame's `sdk.ts` and this file are the only two things that
15
+ * can disagree about what `annotate:capture` means, and keeping them in one
16
+ * package means a change to the union is a compile error in both.
17
+ *
18
+ * Admin gets the UI. This gets the wire.
19
+ */
20
+ export interface AnnotationHostOptions {
21
+ /**
22
+ * The window being framed. Passed in rather than looked up, because the host
23
+ * knows which iframe it means and this module must never guess.
24
+ */
25
+ frame: Window;
26
+ /**
27
+ * The frame's origin — where the product is served from. Required and
28
+ * concrete, checked by `assertConcreteOrigin`: posting to `*` would hand an
29
+ * annotation payload, including console errors from a real session, to
30
+ * whatever happens to be loaded.
31
+ */
32
+ frameOrigin: string;
33
+ /** The window that receives messages. Injected so tests need no real iframe. */
34
+ hostWindow: Window;
35
+ onReady(info: {
36
+ sdkVersion: string;
37
+ build: BuildInfo;
38
+ }): void;
39
+ onHover(target: TargetInfo): void;
40
+ onCapture(payload: AnnotationPayload): void;
41
+ onCancel(): void;
42
+ }
43
+ export interface AnnotationHost {
44
+ enable(): void;
45
+ disable(): void;
46
+ ping(): void;
47
+ /** True once the frame has announced itself. */
48
+ isReady(): boolean;
49
+ destroy(): void;
50
+ }
51
+ /**
52
+ * Is this one of ours?
53
+ *
54
+ * The mirror of `isParentMessage`, and it carries the same warning: origin is
55
+ * checked separately and FIRST. A well-formed message from the wrong origin is
56
+ * precisely the case this guard must not be trusted to catch — anything can
57
+ * post `{type: "annotate:capture"}` to a window.
58
+ */
59
+ export declare function isFrameMessage(data: unknown): data is FrameToParent;
60
+ export declare function createAnnotationHost(options: AnnotationHostOptions): AnnotationHost;