@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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Run store — a durable, verifiable audit trail of "runs".
3
+ *
4
+ * A *run* is any event worth keeping forever for later verification:
5
+ * - `annotation-session` — a human/agent annotation session + its events
6
+ * - `annotation-event` — a single AFS event (created/updated/thread/…)
7
+ * - `test-run` — a validation/test execution + its outcome
8
+ *
9
+ * Transport-agnostic (the "backend" layer): this file defines the
10
+ * `RunStore` port + a deterministic in-memory implementation and a
11
+ * record-builder. Concrete adapters live in *infra*:
12
+ * - DynamoDB (structured record) + S3 (full artifact/log blob), or
13
+ * - any KV/object pair.
14
+ * Keeping the AWS SDK out of this package preserves portability into any
15
+ * host repo — infra picks the adapter.
16
+ */
17
+
18
+ export type RunKind =
19
+ | "annotation-session"
20
+ | "annotation-event"
21
+ | "test-run";
22
+
23
+ /**
24
+ * One immutable run record. `id` + `kind` + `at` are the DynamoDB
25
+ * primary/sort key material; `artifactKey` points at an S3 object
26
+ * holding the heavy payload (full event JSON, test log, screenshots).
27
+ */
28
+ export interface RunRecord {
29
+ /** Unique id (ULID/uuid). Adapter may assign if omitted upstream. */
30
+ readonly id: string;
31
+ readonly kind: RunKind;
32
+ /** ISO timestamp. */
33
+ readonly at: string;
34
+ /** Session this run belongs to, when applicable. */
35
+ readonly sessionId?: string;
36
+ /** Monotonic sequence within a session, when applicable. */
37
+ readonly sequence?: number;
38
+ /** "pass" | "fail" | "ok" | a domain status. */
39
+ readonly status?: string;
40
+ /** Small structured summary (counts, command, git sha, …). */
41
+ readonly summary?: Readonly<Record<string, unknown>>;
42
+ /** S3 key (or any object-store key) for the full artifact blob. */
43
+ readonly artifactKey?: string;
44
+ }
45
+
46
+ /** The port infra adapters implement. */
47
+ export interface RunStore {
48
+ /** Persist one run record (DynamoDB put). Idempotent on `id`. */
49
+ putRun(record: RunRecord): Promise<void>;
50
+ /**
51
+ * Persist a heavy artifact blob (S3 put) and return its key. The key
52
+ * is what goes on `RunRecord.artifactKey`.
53
+ */
54
+ putArtifact?(
55
+ key: string,
56
+ body: string,
57
+ contentType?: string,
58
+ ): Promise<string>;
59
+ /** List runs (for verification dashboards / `verify` tooling). */
60
+ listRuns(filter?: {
61
+ kind?: RunKind;
62
+ sessionId?: string;
63
+ limit?: number;
64
+ }): Promise<ReadonlyArray<RunRecord>>;
65
+ }
66
+
67
+ /** Deterministic in-memory store for tests + local dev. */
68
+ export class InMemoryRunStore implements RunStore {
69
+ private readonly runs: RunRecord[] = [];
70
+ private readonly artifacts = new Map<string, string>();
71
+
72
+ async putRun(record: RunRecord): Promise<void> {
73
+ const idx = this.runs.findIndex((r) => r.id === record.id);
74
+ if (idx >= 0) this.runs[idx] = record;
75
+ else this.runs.push(record);
76
+ }
77
+
78
+ async putArtifact(key: string, body: string): Promise<string> {
79
+ this.artifacts.set(key, body);
80
+ return key;
81
+ }
82
+
83
+ async listRuns(filter?: {
84
+ kind?: RunKind;
85
+ sessionId?: string;
86
+ limit?: number;
87
+ }): Promise<ReadonlyArray<RunRecord>> {
88
+ let out = this.runs.slice();
89
+ if (filter?.kind) out = out.filter((r) => r.kind === filter.kind);
90
+ if (filter?.sessionId) out = out.filter((r) => r.sessionId === filter.sessionId);
91
+ out.sort((a, b) => (a.at < b.at ? 1 : -1)); // newest first
92
+ if (filter?.limit !== undefined) out = out.slice(0, filter.limit);
93
+ return out;
94
+ }
95
+
96
+ /** Test helper: read back an artifact body. */
97
+ readArtifact(key: string): string | undefined {
98
+ return this.artifacts.get(key);
99
+ }
100
+ }
101
+
102
+ /** Build a well-formed RunRecord, filling `at` from an injected clock. */
103
+ export function makeRunRecord(input: {
104
+ id: string;
105
+ kind: RunKind;
106
+ at: string;
107
+ sessionId?: string;
108
+ sequence?: number;
109
+ status?: string;
110
+ summary?: Record<string, unknown>;
111
+ artifactKey?: string;
112
+ }): RunRecord {
113
+ return {
114
+ id: input.id,
115
+ kind: input.kind,
116
+ at: input.at,
117
+ ...(input.sessionId ? { sessionId: input.sessionId } : {}),
118
+ ...(input.sequence !== undefined ? { sequence: input.sequence } : {}),
119
+ ...(input.status ? { status: input.status } : {}),
120
+ ...(input.summary ? { summary: input.summary } : {}),
121
+ ...(input.artifactKey ? { artifactKey: input.artifactKey } : {}),
122
+ };
123
+ }
124
+
125
+ /**
126
+ * Convenience: record an AFS event as both a queue mutation (caller's
127
+ * concern) and a durable `annotation-event` run with the full envelope
128
+ * stashed as an S3 artifact. Adapters that lack `putArtifact` just skip
129
+ * the blob and keep the structured record.
130
+ */
131
+ export async function recordAnnotationEventRun(
132
+ store: RunStore,
133
+ envelope: { type: string; sessionId: string; sequence: number; timestamp: string; payload: unknown },
134
+ ): Promise<void> {
135
+ const id = `${envelope.sessionId}:${envelope.sequence}`;
136
+ let artifactKey: string | undefined;
137
+ if (store.putArtifact) {
138
+ artifactKey = await store.putArtifact(
139
+ `annotations/${envelope.sessionId}/${envelope.sequence}.json`,
140
+ JSON.stringify(envelope),
141
+ "application/json",
142
+ );
143
+ }
144
+ await store.putRun(
145
+ makeRunRecord({
146
+ id,
147
+ kind: "annotation-event",
148
+ at: envelope.timestamp,
149
+ sessionId: envelope.sessionId,
150
+ sequence: envelope.sequence,
151
+ status: envelope.type,
152
+ ...(artifactKey ? { artifactKey } : {}),
153
+ }),
154
+ );
155
+ }
package/src/shadow.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Closed Shadow DOM host for the v1 library.
3
+ *
4
+ * Closed mode: `host.shadowRoot` returns null to the host page,
5
+ * and `document.querySelector` can't reach into the tree. We hold
6
+ * the only reference internally so our UI is fully insulated from
7
+ * the host's JS.
8
+ *
9
+ * Reset stylesheet at the top of the tree neutralises any
10
+ * inheritable host styles (font sizes, colors, link decoration).
11
+ * Inside the shadow, our own theme.css applies cleanly.
12
+ *
13
+ * Idempotency: re-mounting on the same page is a no-op that
14
+ * returns the existing handles. CDP re-injection happens; we
15
+ * shouldn't double-mount.
16
+ */
17
+
18
+ const HOST_ID = "annotations-v1-host";
19
+
20
+ export interface ShadowMount {
21
+ /** The light-DOM host element appended to documentElement. */
22
+ host: HTMLElement;
23
+ /** The closed shadow root. We hold the only reference. */
24
+ shadow: ShadowRoot;
25
+ /** The element the Preact tree mounts into. */
26
+ appRoot: HTMLDivElement;
27
+ }
28
+
29
+ const RESET_CSS = `
30
+ :host {
31
+ all: initial;
32
+ contain: layout style;
33
+ font-family: -apple-system, "SF Pro Text", system-ui, sans-serif;
34
+ font-size: 14px;
35
+ line-height: 1.45;
36
+ color: #f4f4f4;
37
+ }
38
+ *, *::before, *::after { box-sizing: border-box; }
39
+ button { font: inherit; cursor: pointer; }
40
+ [hidden] { display: none !important; }
41
+ `;
42
+
43
+ let cachedMount: ShadowMount | null = null;
44
+
45
+ export function mountShadow(): ShadowMount {
46
+ if (cachedMount && document.contains(cachedMount.host)) return cachedMount;
47
+
48
+ // The host page may have torn down the host element (SPA route
49
+ // changes that wipe documentElement.children). If our cached
50
+ // reference is stale, fall through and re-mount cleanly.
51
+ const existingByDom = document.getElementById(HOST_ID);
52
+ if (existingByDom) existingByDom.remove();
53
+
54
+ const host = document.createElement("div");
55
+ host.id = HOST_ID;
56
+ host.style.cssText = [
57
+ "all: initial",
58
+ "position: fixed",
59
+ "inset: 0",
60
+ "pointer-events: none",
61
+ "z-index: 2147483647",
62
+ ].join(";");
63
+
64
+ const shadow = host.attachShadow({ mode: "closed" });
65
+ const reset = document.createElement("style");
66
+ reset.textContent = RESET_CSS;
67
+ shadow.appendChild(reset);
68
+
69
+ const appRoot = document.createElement("div");
70
+ appRoot.id = "cf-app";
71
+ appRoot.style.cssText = "pointer-events: auto;";
72
+ shadow.appendChild(appRoot);
73
+
74
+ document.documentElement.appendChild(host);
75
+
76
+ cachedMount = { host, shadow, appRoot };
77
+ return cachedMount;
78
+ }
79
+
80
+ export function unmountShadow(): void {
81
+ if (!cachedMount) return;
82
+ cachedMount.host.remove();
83
+ cachedMount = null;
84
+ }
package/src/store.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Preact signals for the v1 library state.
3
+ *
4
+ * Single in-memory source of truth — there's no persistence inside
5
+ * the library; the host's webhook handler is the durable log. On
6
+ * re-injection the store resets, then the host's `__init` call can
7
+ * re-hydrate via `addAnnotation` if it wants to.
8
+ */
9
+
10
+ import { signal } from "@preact/signals";
11
+ import type { CapturedTarget } from "./capture.js";
12
+ import type { Annotation, Mode, Theme } from "./types.js";
13
+
14
+ export const annotations = signal<Annotation[]>([]);
15
+ export const mode = signal<Mode>("view");
16
+ export const theme = signal<Theme>("dark");
17
+ export const webhookUrl = signal<string | null>(null);
18
+ export const sessionId = signal<string>("");
19
+ export const sequence = signal<number>(0);
20
+ export const ready = signal<boolean>(false);
21
+
22
+ /**
23
+ * Identity stamped on annotations + thread replies authored through
24
+ * the in-page UI. The host can override via `__init({ author })`.
25
+ */
26
+ export const author = signal<{
27
+ kind: "human" | "agent";
28
+ id?: string;
29
+ displayName?: string;
30
+ }>({ kind: "human", id: "human", displayName: "You" });
31
+
32
+ // ── Picker / UI interaction state (browser-only) ───────────────────
33
+
34
+ /** Sub-mode within "feedback" mode: what a click captures. */
35
+ export type FeedbackTool = "element" | "text" | "multi";
36
+
37
+ export const feedbackTool = signal<FeedbackTool>("element");
38
+
39
+ /** Live highlight box (viewport coords) drawn under the cursor. */
40
+ export const hoverBox = signal<{
41
+ top: number;
42
+ left: number;
43
+ width: number;
44
+ height: number;
45
+ } | null>(null);
46
+
47
+ /**
48
+ * A captured target awaiting a comment. When non-null the composer
49
+ * popup is open. Cleared on submit or cancel.
50
+ */
51
+ export const draft = signal<CapturedTarget | null>(null);
52
+
53
+ /** Multi-element selection accumulator (feedbackTool === "multi"). */
54
+ export const multiSelection = signal<Element[]>([]);
55
+
56
+ /** Annotation id whose thread panel is open, or null. */
57
+ export const activeThreadId = signal<string | null>(null);
58
+
59
+ /** Bump on scroll/resize to force pins to recompute screen positions. */
60
+ export const viewportTick = signal<number>(0);
61
+
62
+ /**
63
+ * Live presence cursors — one per actor (human reviewers + spun-off
64
+ * agents) collaborating on the same page. The host pushes positions via
65
+ * `setCursors`/`setCursor` (over the JS bridge); the overlay renders a
66
+ * labeled cursor for each. `x` is % of viewport width, `y` is px from
67
+ * document top — same coordinate space as annotations, so cursors and
68
+ * pins line up.
69
+ */
70
+ export interface PresenceCursor {
71
+ id: string;
72
+ label: string;
73
+ kind: "human" | "agent";
74
+ x: number;
75
+ y: number;
76
+ color?: string;
77
+ }
78
+
79
+ export const cursors = signal<PresenceCursor[]>([]);
package/src/types.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * v1 annotation data model — Annotation Format Schema (AFS) v1.1.
3
+ *
4
+ * Wire-compatible with agentation.com/schema except:
5
+ *
6
+ * - `comment` is treated as **Markdown** (not plain text) so we
7
+ * can embed inline images, code blocks, and links inside a
8
+ * single annotation. Tools that consume AFS as plaintext still
9
+ * work since Markdown degrades cleanly when not rendered.
10
+ *
11
+ * The shape is intentionally flat — no discriminated `target` union
12
+ * — so existing AFS consumers (agents, MCP clients, etc.) can
13
+ * read our annotations without a translation shim. Variant
14
+ * dimensions (text selection, multi-element, area, layout mode)
15
+ * are encoded as optional fields on the same object.
16
+ */
17
+
18
+ export type AnnotationKind = "feedback" | "placement" | "rearrange";
19
+
20
+ export type AnnotationIntent = "fix" | "change" | "question" | "approve";
21
+
22
+ export type AnnotationSeverity = "blocking" | "important" | "suggestion";
23
+
24
+ export type AnnotationStatus =
25
+ | "pending"
26
+ | "acknowledged"
27
+ | "resolved"
28
+ | "dismissed";
29
+
30
+ export type Mode = "view" | "feedback" | "layout";
31
+
32
+ export type Theme = "dark" | "light" | "auto";
33
+
34
+ export interface BoundingBox {
35
+ x: number;
36
+ y: number;
37
+ width: number;
38
+ height: number;
39
+ }
40
+
41
+ export interface PlacementData {
42
+ componentType: string;
43
+ width: number;
44
+ height: number;
45
+ scrollY: number;
46
+ text?: string;
47
+ }
48
+
49
+ export interface RearrangeData {
50
+ selector: string;
51
+ label: string;
52
+ tagName: string;
53
+ originalRect: BoundingBox;
54
+ currentRect: BoundingBox;
55
+ }
56
+
57
+ export interface ThreadMessage {
58
+ id: string;
59
+ role: "human" | "agent";
60
+ /** Markdown content. Renderers should treat as Markdown. */
61
+ content: string;
62
+ timestamp: number;
63
+ }
64
+
65
+ /**
66
+ * AFS v1.1 annotation. Field ordering mirrors the canonical spec
67
+ * so JSON diffs against agentation output stay readable.
68
+ */
69
+ export interface Annotation {
70
+ // Required
71
+ id: string;
72
+ /** Markdown — can include inline images, code blocks, links. */
73
+ comment: string;
74
+ elementPath: string;
75
+ timestamp: number;
76
+ /** Percentage of viewport width (0-100). */
77
+ x: number;
78
+ /** Pixels from document top, or viewport if `isFixed`. */
79
+ y: number;
80
+ element: string;
81
+
82
+ // Recommended
83
+ url?: string;
84
+ boundingBox?: BoundingBox;
85
+
86
+ // Optional context
87
+ reactComponents?: string;
88
+ cssClasses?: string;
89
+ computedStyles?: string;
90
+ accessibility?: string;
91
+ nearbyText?: string;
92
+ selectedText?: string;
93
+
94
+ // Browser-component fields (set by the injected library at capture time)
95
+ isFixed?: boolean;
96
+ isMultiSelect?: boolean;
97
+ fullPath?: string;
98
+ nearbyElements?: string;
99
+ /**
100
+ * For multi-element annotations: the per-element bounding boxes
101
+ * of the selected set. Always document-relative.
102
+ */
103
+ elementBoundingBoxes?: BoundingBox[];
104
+
105
+ // Feedback classification
106
+ intent?: AnnotationIntent;
107
+ severity?: AnnotationSeverity;
108
+
109
+ // Annotation kind — defaults to "feedback"
110
+ kind?: AnnotationKind;
111
+
112
+ // Layout-mode data (kind === "placement" | "rearrange")
113
+ placement?: PlacementData;
114
+ rearrange?: RearrangeData;
115
+
116
+ // Lifecycle
117
+ status?: AnnotationStatus;
118
+ resolvedAt?: string;
119
+ resolvedBy?: "human" | "agent";
120
+ thread?: ThreadMessage[];
121
+
122
+ // Authoring metadata (extension to AFS; safe additions)
123
+ author?: { kind: "human" | "agent"; id?: string; displayName?: string };
124
+ /** ISO timestamp set by the library on first emit. */
125
+ createdAt?: string;
126
+ /** ISO timestamp updated on any mutation. */
127
+ updatedAt?: string;
128
+ }
129
+
130
+ /**
131
+ * AFS event envelope. The library emits one of these per state
132
+ * change to the webhook URL. The `sequence` field is monotonic per
133
+ * session so the host can detect missed events and request replay.
134
+ */
135
+ export type AgentationEventType =
136
+ | "annotation.created"
137
+ | "annotation.updated"
138
+ | "annotation.deleted"
139
+ | "session.created"
140
+ | "session.updated"
141
+ | "session.closed"
142
+ | "thread.message"
143
+ | "action.requested"
144
+ | "presence.cursor";
145
+
146
+ export interface AgentationEvent<P = unknown> {
147
+ type: AgentationEventType;
148
+ /** ISO 8601 timestamp. */
149
+ timestamp: string;
150
+ sessionId: string;
151
+ /** Monotonic per session for ordering and replay. */
152
+ sequence: number;
153
+ payload: P;
154
+ }