@coframe-gtm/annotations 1.0.1

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/src/bundle.ts ADDED
@@ -0,0 +1,69 @@
1
+ /**
2
+ * IIFE bundle entry. esbuild emits a single minified IIFE that:
3
+ *
4
+ * 1. Sets `window.__annotations` to the AFS-compatible API.
5
+ * 2. Mounts the closed Shadow DOM overlay + installs the picker.
6
+ * 3. Auto-initialises with no webhook so a bare paste works.
7
+ *
8
+ * The CDP install helper (`./inject/install.ts`) appends an
9
+ * `__init({ webhookUrl, sessionId, author, mode })` call after the
10
+ * bundle source when the host wants outbound events or a specific
11
+ * starting state.
12
+ */
13
+
14
+ import { render, h } from "preact";
15
+
16
+ import { api, initSession } from "./api.js";
17
+ import { installPicker, setPickerHost } from "./picker.js";
18
+ import { mountShadow } from "./shadow.js";
19
+ import { author, mode, ready, theme, viewportTick } from "./store.js";
20
+ import { App } from "./ui/App.js";
21
+
22
+ import type { AnnotationsApi } from "./api.js";
23
+ import type { Mode, Theme } from "./types.js";
24
+
25
+ interface InitOptions {
26
+ webhookUrl?: string;
27
+ sessionId?: string;
28
+ author?: { kind: "human" | "agent"; id?: string; displayName?: string };
29
+ mode?: Mode;
30
+ theme?: Theme;
31
+ }
32
+
33
+ declare global {
34
+ interface Window {
35
+ __annotations?: AnnotationsApi & { __init: (opts?: InitOptions) => void };
36
+ }
37
+ }
38
+
39
+ let mounted = false;
40
+
41
+ function init(opts: InitOptions = {}): void {
42
+ initSession(opts);
43
+ if (opts.author) author.value = opts.author;
44
+ if (opts.mode) mode.value = opts.mode;
45
+ if (opts.theme) theme.value = opts.theme;
46
+
47
+ if (!mounted) {
48
+ mounted = true;
49
+ const { host, appRoot } = mountShadow();
50
+ setPickerHost(host);
51
+ installPicker();
52
+ render(h(App, {}), appRoot);
53
+
54
+ const bump = (): void => {
55
+ viewportTick.value++;
56
+ };
57
+ window.addEventListener("scroll", bump, { passive: true, capture: true });
58
+ window.addEventListener("resize", bump, { passive: true });
59
+ }
60
+
61
+ ready.value = true;
62
+ // eslint-disable-next-line no-console
63
+ console.info("[annotations v1] ready on", location.href);
64
+ }
65
+
66
+ const surface = Object.assign({ __init: init }, api);
67
+ (window as Window).__annotations = surface;
68
+
69
+ init();
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @vitest-environment jsdom
3
+ *
4
+ * Selector + forensic-extraction tests. Layout-dependent fields
5
+ * (bounding box, computed styles) are exercised in the browser;
6
+ * here we pin the deterministic selector / extraction logic.
7
+ */
8
+
9
+ import { beforeEach, describe, expect, it } from "vitest";
10
+
11
+ import { captureElement, cssPath, requery, shortSelector } from "./capture.js";
12
+
13
+ beforeEach(() => {
14
+ document.body.innerHTML = "";
15
+ });
16
+
17
+ describe("shortSelector", () => {
18
+ it("prefers a stable id", () => {
19
+ document.body.innerHTML = `<button id="cta" class="x y">Go</button>`;
20
+ expect(shortSelector(document.querySelector("button")!)).toBe("#cta");
21
+ });
22
+
23
+ it("prefers data-testid over class", () => {
24
+ document.body.innerHTML = `<div data-testid="hero" class="a b">x</div>`;
25
+ expect(shortSelector(document.querySelector("div")!)).toBe(
26
+ '[data-testid="hero"]',
27
+ );
28
+ });
29
+
30
+ it("falls back to tag + first stable class", () => {
31
+ document.body.innerHTML = `<a class="link primary">x</a>`;
32
+ expect(shortSelector(document.querySelector("a")!)).toBe("a.link");
33
+ });
34
+
35
+ it("ignores framework-generated ids", () => {
36
+ document.body.innerHTML = `<div id=":r1:" class="card">x</div>`;
37
+ expect(shortSelector(document.querySelector("div")!)).toBe("div.card");
38
+ });
39
+ });
40
+
41
+ describe("cssPath", () => {
42
+ it("builds a unique nth-of-type path", () => {
43
+ document.body.innerHTML = `
44
+ <main><ul><li>a</li><li class="target">b</li><li>c</li></ul></main>`;
45
+ const target = document.querySelector(".target")!;
46
+ const path = cssPath(target);
47
+ expect(path).toContain("li:nth-of-type(2)");
48
+ expect(document.querySelector(path)).toBe(target);
49
+ });
50
+
51
+ it("anchors on the nearest stable id", () => {
52
+ document.body.innerHTML = `<section id="feat"><p><span>x</span></p></section>`;
53
+ const span = document.querySelector("span")!;
54
+ const path = cssPath(span);
55
+ expect(path.startsWith("#feat")).toBe(true);
56
+ expect(document.querySelector(path)).toBe(span);
57
+ });
58
+ });
59
+
60
+ describe("requery", () => {
61
+ it("re-finds an element by its captured path", () => {
62
+ document.body.innerHTML = `<div class="box"><button class="go">Go</button></div>`;
63
+ const btn = document.querySelector("button")!;
64
+ expect(requery(cssPath(btn))).toBe(btn);
65
+ });
66
+
67
+ it("returns null for an invalid selector instead of throwing", () => {
68
+ expect(requery(">>bad")).toBeNull();
69
+ });
70
+ });
71
+
72
+ describe("captureElement", () => {
73
+ it("captures AFS context fields for an element", () => {
74
+ document.body.innerHTML = `
75
+ <article class="post"><h2 id="title" role="heading" aria-label="Hi">Hello world</h2></article>`;
76
+ const h2 = document.querySelector("h2")!;
77
+ const cap = captureElement(h2);
78
+
79
+ expect(cap.element).toBe("h2");
80
+ expect(cap.elementPath).toBe("#title");
81
+ expect(cap.url).toBe(location.href);
82
+ expect(cap.accessibility).toContain('role="heading"');
83
+ expect(cap.accessibility).toContain('aria-label="Hi"');
84
+ expect(cap.nearbyText).toContain("Hello world");
85
+ expect(cap.nearbyElements).toContain("parent: article.post");
86
+ expect(cap.boundingBox).toBeDefined();
87
+ });
88
+ });
package/src/capture.ts ADDED
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Forensic DOM extraction.
3
+ *
4
+ * Given a target element (and optionally a selected-text range or a
5
+ * multi-element set), produce the AFS 1.1 context fields the
6
+ * Forensic output formatter wants: a stable selector path, a
7
+ * canonical `fullPath`, curated computed styles, classes,
8
+ * accessibility attributes, nearby text/elements, the React
9
+ * component (when the page is React), bounding box, and fixed
10
+ * positioning.
11
+ *
12
+ * Everything here is browser-only and called at capture time, so
13
+ * `document` / `window` are assumed present. Pure string helpers are
14
+ * unit-testable; the DOM walkers are exercised in the browser.
15
+ */
16
+
17
+ import type { AddAnnotationInput } from "./api.js";
18
+ import type { BoundingBox } from "./types.js";
19
+
20
+ /** Computed-style properties worth capturing for layout reasoning. */
21
+ const FORENSIC_STYLE_PROPS = [
22
+ "display",
23
+ "position",
24
+ "width",
25
+ "height",
26
+ "margin",
27
+ "padding",
28
+ "color",
29
+ "background-color",
30
+ "background",
31
+ "font-family",
32
+ "font-size",
33
+ "font-weight",
34
+ "line-height",
35
+ "border",
36
+ "border-radius",
37
+ "box-shadow",
38
+ "flex-direction",
39
+ "justify-content",
40
+ "align-items",
41
+ "gap",
42
+ "grid-template-columns",
43
+ "z-index",
44
+ "opacity",
45
+ "text-align",
46
+ ] as const;
47
+
48
+ /** Accessibility-relevant attributes. */
49
+ const A11Y_ATTRS = [
50
+ "role",
51
+ "aria-label",
52
+ "aria-labelledby",
53
+ "aria-describedby",
54
+ "aria-hidden",
55
+ "aria-expanded",
56
+ "aria-checked",
57
+ "aria-selected",
58
+ "alt",
59
+ "title",
60
+ "tabindex",
61
+ "type",
62
+ "name",
63
+ "for",
64
+ "href",
65
+ ] as const;
66
+
67
+ export interface CapturedTarget
68
+ extends Pick<
69
+ AddAnnotationInput,
70
+ | "elementPath"
71
+ | "element"
72
+ | "x"
73
+ | "y"
74
+ | "url"
75
+ | "boundingBox"
76
+ | "reactComponents"
77
+ | "cssClasses"
78
+ | "computedStyles"
79
+ | "accessibility"
80
+ | "nearbyText"
81
+ | "selectedText"
82
+ | "isFixed"
83
+ | "isMultiSelect"
84
+ | "fullPath"
85
+ | "nearbyElements"
86
+ | "elementBoundingBoxes"
87
+ > {}
88
+
89
+ /**
90
+ * Capture the full forensic context for an element. `selectedText`
91
+ * and the multi-element set are layered in by the callers in
92
+ * `picker.ts`.
93
+ */
94
+ export function captureElement(el: Element): CapturedTarget {
95
+ const rect = el.getBoundingClientRect();
96
+ const isFixed = isFixedPositioned(el);
97
+ const scrollX = window.scrollX;
98
+ const scrollY = window.scrollY;
99
+
100
+ const box: BoundingBox = {
101
+ x: rect.left + (isFixed ? 0 : scrollX),
102
+ y: rect.top + (isFixed ? 0 : scrollY),
103
+ width: rect.width,
104
+ height: rect.height,
105
+ };
106
+
107
+ return {
108
+ element: el.tagName.toLowerCase(),
109
+ elementPath: shortSelector(el),
110
+ fullPath: cssPath(el),
111
+ x: clampPercent(((rect.left + rect.width / 2) / window.innerWidth) * 100),
112
+ y: isFixed ? rect.top : rect.top + scrollY,
113
+ url: location.href,
114
+ boundingBox: box,
115
+ isFixed,
116
+ cssClasses: classList(el),
117
+ computedStyles: computedStyles(el),
118
+ accessibility: accessibility(el),
119
+ nearbyText: nearbyText(el),
120
+ nearbyElements: nearbyElements(el),
121
+ reactComponents: reactComponent(el),
122
+ };
123
+ }
124
+
125
+ // ── Selector heuristics ────────────────────────────────────────────
126
+
127
+ /**
128
+ * Short, human-readable selector for display + as the re-query key
129
+ * used to re-anchor pins. id > data-testid > tag + first class.
130
+ */
131
+ export function shortSelector(el: Element): string {
132
+ if (el.id && isStableId(el.id)) return `#${cssEscape(el.id)}`;
133
+ const testid = el.getAttribute("data-testid");
134
+ if (testid) return `[data-testid="${testid}"]`;
135
+ const tag = el.tagName.toLowerCase();
136
+ const cls = firstStableClass(el);
137
+ return cls ? `${tag}.${cssEscape(cls)}` : tag;
138
+ }
139
+
140
+ /**
141
+ * Canonical, document-unique CSS path from `<body>` to the element,
142
+ * using `:nth-of-type` where needed for stability.
143
+ */
144
+ export function cssPath(el: Element): string {
145
+ const segments: string[] = [];
146
+ let node: Element | null = el;
147
+ while (node && node.nodeType === 1 && node.tagName.toLowerCase() !== "html") {
148
+ if (node.id && isStableId(node.id)) {
149
+ segments.unshift(`#${cssEscape(node.id)}`);
150
+ break;
151
+ }
152
+ let seg = node.tagName.toLowerCase();
153
+ const parent: Element | null = node.parentElement;
154
+ if (parent) {
155
+ const sameTag = Array.from(parent.children).filter(
156
+ (c) => c.tagName === node!.tagName,
157
+ );
158
+ if (sameTag.length > 1) {
159
+ seg += `:nth-of-type(${sameTag.indexOf(node) + 1})`;
160
+ }
161
+ }
162
+ segments.unshift(seg);
163
+ node = parent;
164
+ }
165
+ return segments.join(" > ");
166
+ }
167
+
168
+ /** Re-find an element by the selector captured earlier. Best-effort. */
169
+ export function requery(selector: string): Element | null {
170
+ if (!selector) return null;
171
+ try {
172
+ return document.querySelector(selector);
173
+ } catch {
174
+ return null;
175
+ }
176
+ }
177
+
178
+ function isStableId(id: string): boolean {
179
+ // Skip framework-generated ids (long hashes, `:r1:`, `radix-…`).
180
+ if (id.length > 40) return false;
181
+ if (/^[:]?r[a-z0-9]+[:]?$/i.test(id)) return false;
182
+ if (/^(radix|headlessui|mui|react-aria)[-:]/i.test(id)) return false;
183
+ return true;
184
+ }
185
+
186
+ function firstStableClass(el: Element): string | null {
187
+ for (const c of Array.from(el.classList)) {
188
+ // Skip hashed CSS-module / styled-components classes.
189
+ if (/^[a-z0-9_-]*[a-f0-9]{6,}$/i.test(c) && /\d/.test(c)) continue;
190
+ if (c.startsWith("cf-")) continue;
191
+ return c;
192
+ }
193
+ return el.classList[0] ?? null;
194
+ }
195
+
196
+ function cssEscape(value: string): string {
197
+ if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
198
+ return CSS.escape(value);
199
+ }
200
+ return value.replace(/([^\w-])/g, "\\$1");
201
+ }
202
+
203
+ // ── Field extractors ───────────────────────────────────────────────
204
+
205
+ function classList(el: Element): string | undefined {
206
+ const classes = Array.from(el.classList).filter((c) => !c.startsWith("cf-"));
207
+ return classes.length ? classes.join(" ") : undefined;
208
+ }
209
+
210
+ function computedStyles(el: Element): string | undefined {
211
+ const cs = window.getComputedStyle(el);
212
+ const lines: string[] = [];
213
+ for (const prop of FORENSIC_STYLE_PROPS) {
214
+ const value = cs.getPropertyValue(prop);
215
+ if (value && value !== "none" && value !== "normal" && value !== "auto") {
216
+ lines.push(`${prop}: ${value};`);
217
+ }
218
+ }
219
+ return lines.length ? lines.join("\n") : undefined;
220
+ }
221
+
222
+ function accessibility(el: Element): string | undefined {
223
+ const parts: string[] = [];
224
+ for (const attr of A11Y_ATTRS) {
225
+ const value = el.getAttribute(attr);
226
+ if (value !== null && value !== "") parts.push(`${attr}="${value}"`);
227
+ }
228
+ const text = directText(el);
229
+ if (text) parts.push(`text="${truncate(text, 60)}"`);
230
+ return parts.length ? parts.join(" ") : undefined;
231
+ }
232
+
233
+ /** Text the user can see inside the element, collapsed + truncated. */
234
+ function nearbyText(el: Element): string | undefined {
235
+ const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
236
+ if (!text) return undefined;
237
+ return truncate(text, 200);
238
+ }
239
+
240
+ /** A compact description of the element's siblings + parent. */
241
+ function nearbyElements(el: Element): string | undefined {
242
+ const parts: string[] = [];
243
+ if (el.parentElement) {
244
+ parts.push(`parent: ${describe(el.parentElement)}`);
245
+ }
246
+ const prev = el.previousElementSibling;
247
+ if (prev) parts.push(`prev: ${describe(prev)}`);
248
+ const next = el.nextElementSibling;
249
+ if (next) parts.push(`next: ${describe(next)}`);
250
+ return parts.length ? parts.join(" | ") : undefined;
251
+ }
252
+
253
+ function describe(el: Element): string {
254
+ const tag = el.tagName.toLowerCase();
255
+ const cls = firstStableClass(el);
256
+ const label = (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 30);
257
+ return `${tag}${cls ? `.${cls}` : ""}${label ? ` "${label}"` : ""}`;
258
+ }
259
+
260
+ function directText(el: Element): string {
261
+ let out = "";
262
+ for (const node of Array.from(el.childNodes)) {
263
+ if (node.nodeType === 3) out += node.textContent ?? "";
264
+ }
265
+ return out.replace(/\s+/g, " ").trim();
266
+ }
267
+
268
+ // ── React detection ────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Walk `__reactFiber$` keys to surface the nearest named component.
272
+ * The only entry React offers; sanitisation/format below is ours.
273
+ */
274
+ export function reactComponent(el: Element): string | undefined {
275
+ const fiberKey = Object.keys(el).find(
276
+ (k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"),
277
+ );
278
+ if (!fiberKey) return undefined;
279
+
280
+ let fiber = (el as unknown as Record<string, unknown>)[fiberKey] as
281
+ | ReactFiber
282
+ | undefined;
283
+ const names: string[] = [];
284
+ let props: Record<string, unknown> | undefined;
285
+ let depth = 0;
286
+
287
+ while (fiber && depth < 30) {
288
+ const type = fiber.type;
289
+ const name =
290
+ typeof type === "function"
291
+ ? (type as { displayName?: string; name?: string }).displayName ??
292
+ (type as { name?: string }).name
293
+ : undefined;
294
+ if (name && /^[A-Z]/.test(name) && !names.includes(name)) {
295
+ names.push(name);
296
+ if (!props && fiber.memoizedProps) props = fiber.memoizedProps;
297
+ if (names.length >= 3) break;
298
+ }
299
+ fiber = fiber.return;
300
+ depth++;
301
+ }
302
+
303
+ if (!names.length) return undefined;
304
+ const head = names[0];
305
+ const propStr = props ? sanitizeProps(props) : "";
306
+ return propStr ? `${head} (${propStr})` : head;
307
+ }
308
+
309
+ interface ReactFiber {
310
+ type?: unknown;
311
+ return?: ReactFiber;
312
+ memoizedProps?: Record<string, unknown>;
313
+ }
314
+
315
+ function sanitizeProps(props: Record<string, unknown>): string {
316
+ const pairs: string[] = [];
317
+ for (const [key, value] of Object.entries(props)) {
318
+ if (key === "children") continue;
319
+ if (typeof value === "string") pairs.push(`${key}=${JSON.stringify(truncate(value, 40))}`);
320
+ else if (typeof value === "number" || typeof value === "boolean") pairs.push(`${key}=${value}`);
321
+ if (pairs.length >= 5) break;
322
+ }
323
+ return pairs.join(", ");
324
+ }
325
+
326
+ // ── misc ───────────────────────────────────────────────────────────
327
+
328
+ function isFixedPositioned(el: Element): boolean {
329
+ let node: Element | null = el;
330
+ let depth = 0;
331
+ while (node && depth < 20) {
332
+ if (window.getComputedStyle(node).position === "fixed") return true;
333
+ node = node.parentElement;
334
+ depth++;
335
+ }
336
+ return false;
337
+ }
338
+
339
+ function truncate(text: string, max: number): string {
340
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
341
+ }
342
+
343
+ function clampPercent(n: number): number {
344
+ return Math.max(0, Math.min(100, Math.round(n * 10) / 10));
345
+ }
package/src/index.ts ADDED
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Public v1 surface — for Node callers (server workers, scripts,
3
+ * tests) that need types, install helpers, the bundle source, or
4
+ * the Forensic output formatter.
5
+ *
6
+ * Browser consumers don't import from here — they inject the IIFE
7
+ * bundle and use `window.__annotations`.
8
+ */
9
+
10
+ export type {
11
+ AgentationEvent,
12
+ AgentationEventType,
13
+ Annotation,
14
+ AnnotationIntent,
15
+ AnnotationKind,
16
+ AnnotationSeverity,
17
+ AnnotationStatus,
18
+ BoundingBox,
19
+ Mode,
20
+ PlacementData,
21
+ RearrangeData,
22
+ Theme,
23
+ ThreadMessage,
24
+ } from "./types.js";
25
+
26
+ export type { AnnotationsApi, AddAnnotationInput, ReplyInput } from "./api.js";
27
+
28
+ export {
29
+ installInCurrentDocument,
30
+ installOnNewDocument,
31
+ installAnnotationsOverlay,
32
+ buildAnnotationsInstallSource,
33
+ buildInitCall,
34
+ ANNOTATIONS_V1_BUNDLE_SOURCE,
35
+ } from "./inject/install.js";
36
+
37
+ export type { CdpSession, InstallOptions } from "./inject/install.js";
38
+
39
+ export {
40
+ formatAnnotation,
41
+ formatAnnotationBundle,
42
+ type FormatOptions,
43
+ type BundleOptions,
44
+ type OutputDetail,
45
+ } from "./output.js";
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Build the v1 IIFE bundle with esbuild.
3
+ *
4
+ * Invocation: `pnpm build`
5
+ *
6
+ * Output: `dist/annotations-v1.iife.js`. The build also re-emits
7
+ * `src/v1/inject/bundle-source.generated.ts` exporting the bundle
8
+ * source as a TypeScript string constant so the worker (or any Node
9
+ * caller) can import it without reading the file at runtime.
10
+ */
11
+
12
+ import { build } from "esbuild";
13
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
14
+ import { dirname, resolve } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const pkgRoot = resolve(here, "..", "..", "..");
19
+ const entryPoint = resolve(here, "..", "bundle.ts");
20
+ const outFile = resolve(pkgRoot, "dist", "annotations-v1.iife.js");
21
+ const generatedSrc = resolve(here, "bundle-source.generated.ts");
22
+
23
+ await mkdir(dirname(outFile), { recursive: true });
24
+
25
+ await build({
26
+ entryPoints: [entryPoint],
27
+ outfile: outFile,
28
+ bundle: true,
29
+ format: "iife",
30
+ minify: true,
31
+ target: "es2020",
32
+ jsx: "automatic",
33
+ jsxImportSource: "preact",
34
+ legalComments: "none",
35
+ logLevel: "info",
36
+ });
37
+
38
+ const bundleSource = await readFile(outFile, "utf8");
39
+
40
+ const generated = [
41
+ "/* eslint-disable */",
42
+ "// This file is generated by `pnpm build`.",
43
+ "// Do not edit by hand. Regenerate by running the build script.",
44
+ "export const ANNOTATIONS_V1_BUNDLE_SOURCE: string =",
45
+ JSON.stringify(bundleSource) + ";",
46
+ "",
47
+ ].join("\n");
48
+
49
+ await writeFile(generatedSrc, generated, "utf8");
50
+
51
+ const kb = (bundleSource.length / 1024).toFixed(1);
52
+ console.log(`annotations-v1.iife.js ${kb} kB`);