@coframe-gtm/annotations 1.0.2 → 1.0.4

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/ui/App.ts DELETED
@@ -1,516 +0,0 @@
1
- /**
2
- * Root Preact tree — the full annotation overlay.
3
- *
4
- * Plain h() calls (no JSX) so the package's React-flavoured tsc
5
- * config needs no per-directory override; esbuild builds the IIFE
6
- * either way. Local component state uses preact/hooks; cross-cutting
7
- * state lives in the signals from `../store.js`.
8
- *
9
- * Layers (all inside the closed Shadow DOM, fixed-positioned):
10
- * 1. hover highlight — element under the cursor in feedback mode
11
- * 2. multi-select boxes
12
- * 3. pins — one numbered marker per annotation
13
- * 4. toolbar — mode + tool switches
14
- * 5. composer popup — comment + intent/severity/kind on capture
15
- * 6. thread panel — per-pin discussion (human ↔ agent)
16
- */
17
-
18
- import { h } from "preact";
19
- import type { ComponentChild } from "preact";
20
- import { useState } from "preact/hooks";
21
-
22
- import { api } from "../api.js";
23
- import { requery } from "../capture.js";
24
- import type { CapturedTarget } from "../capture.js";
25
- import {
26
- activeThreadId,
27
- annotations,
28
- author,
29
- cursors,
30
- draft,
31
- feedbackTool,
32
- hoverBox,
33
- mode,
34
- multiSelection,
35
- theme,
36
- viewportTick,
37
- type PresenceCursor,
38
- } from "../store.js";
39
- import type {
40
- Annotation,
41
- AnnotationIntent,
42
- AnnotationKind,
43
- AnnotationSeverity,
44
- } from "../types.js";
45
- import { commitMultiSelection } from "../picker.js";
46
- import { STYLES } from "./styles.js";
47
-
48
- const SEVERITY_COLOR: Record<AnnotationSeverity, string> = {
49
- blocking: "#ef4444",
50
- important: "#f59e0b",
51
- suggestion: "#3ecf8e",
52
- };
53
- const DEFAULT_DOT = "#a78bfa";
54
-
55
- const INTENTS: AnnotationIntent[] = ["fix", "change", "question", "approve"];
56
- const SEVERITIES: AnnotationSeverity[] = ["blocking", "important", "suggestion"];
57
-
58
- export function App(): ComponentChild {
59
- const themeAttr = theme.value === "auto" ? "dark" : theme.value;
60
- // Subscribe to viewport changes so pins recompute their screen pos.
61
- void viewportTick.value;
62
-
63
- return h(
64
- "div",
65
- { class: "cf-overlay", "data-theme": themeAttr },
66
- [
67
- h("style", { key: "styles" }, STYLES),
68
- hoverBox.value && !draft.value
69
- ? h("div", {
70
- key: "hover",
71
- class: "cf-hover",
72
- style: boxStyle(hoverBox.value),
73
- })
74
- : null,
75
- ...multiSelection.value.map((el, i) => renderMultiBox(el, i)),
76
- h(
77
- "div",
78
- { key: "pins" },
79
- annotations.value.map((a, i) => renderPin(a, i + 1)),
80
- ),
81
- h(
82
- "div",
83
- { key: "cursors" },
84
- cursors.value.map((c) => renderCursor(c)),
85
- ),
86
- renderToolbar(),
87
- draft.value ? h(Composer, { key: "composer", target: draft.value }) : null,
88
- activeThreadId.value
89
- ? h(ThreadPanel, { key: "thread", id: activeThreadId.value })
90
- : null,
91
- ],
92
- );
93
- }
94
-
95
- // ── Toolbar ────────────────────────────────────────────────────────
96
-
97
- function renderToolbar(): ComponentChild {
98
- const active = mode.value === "feedback";
99
- const count = annotations.value.length;
100
- const multi = multiSelection.value.length;
101
-
102
- return h("div", { key: "toolbar", class: "cf-toolbar" }, [
103
- h("div", { key: "head", class: "cf-toolbar-head" }, [
104
- h("span", { key: "dot", class: "cf-logo-dot" }),
105
- h("span", { key: "label" }, "Annotations"),
106
- h("span", { key: "count", class: "cf-count" }, String(count)),
107
- ]),
108
- h("div", { key: "modes", class: "cf-seg" }, [
109
- segButton("View", mode.value === "view", () => {
110
- mode.value = "view";
111
- hoverBox.value = null;
112
- multiSelection.value = [];
113
- }),
114
- segButton("Comment", active, () => {
115
- mode.value = "feedback";
116
- }),
117
- ]),
118
- active
119
- ? h("div", { key: "tools", class: "cf-seg cf-tools" }, [
120
- segButton("Element", feedbackTool.value === "element", () => {
121
- feedbackTool.value = "element";
122
- multiSelection.value = [];
123
- }),
124
- segButton("Text", feedbackTool.value === "text", () => {
125
- feedbackTool.value = "text";
126
- multiSelection.value = [];
127
- }),
128
- segButton("Multi", feedbackTool.value === "multi", () => {
129
- feedbackTool.value = "multi";
130
- }),
131
- ])
132
- : null,
133
- active && feedbackTool.value === "multi" && multi
134
- ? h(
135
- "button",
136
- {
137
- key: "commit",
138
- class: "cf-btn cf-btn-primary cf-commit",
139
- onClick: commitMultiSelection,
140
- },
141
- `Comment ${multi} element${multi === 1 ? "" : "s"}`,
142
- )
143
- : null,
144
- active
145
- ? h(
146
- "div",
147
- { key: "hint", class: "cf-hint" },
148
- hintFor(feedbackTool.value),
149
- )
150
- : null,
151
- ]);
152
- }
153
-
154
- function hintFor(tool: string): string {
155
- if (tool === "text") return "Select text on the page to annotate it.";
156
- if (tool === "multi") return "Click elements to group them, then commit.";
157
- return "Click any element to comment. Esc to exit.";
158
- }
159
-
160
- function segButton(
161
- label: string,
162
- on: boolean,
163
- onClick: () => void,
164
- ): ComponentChild {
165
- return h(
166
- "button",
167
- {
168
- key: label,
169
- class: `cf-seg-btn${on ? " on" : ""}`,
170
- onClick,
171
- },
172
- label,
173
- );
174
- }
175
-
176
- // ── Pins ───────────────────────────────────────────────────────────
177
-
178
- function renderPin(a: Annotation, index: number): ComponentChild {
179
- const pos = pinPosition(a);
180
- if (!pos) return null;
181
- const color = a.severity ? SEVERITY_COLOR[a.severity] : DEFAULT_DOT;
182
- const resolved = a.status === "resolved" || a.status === "dismissed";
183
- return h(
184
- "button",
185
- {
186
- key: a.id,
187
- class: `cf-pin${resolved ? " resolved" : ""}${a.author?.kind === "agent" ? " agent" : ""}`,
188
- style: `top:${pos.top}px;left:${pos.left}px;--cf-pin:${color};`,
189
- title: firstLine(a.comment),
190
- onClick: () => {
191
- activeThreadId.value = activeThreadId.value === a.id ? null : a.id;
192
- },
193
- },
194
- a.author?.kind === "agent" ? "★" : String(index),
195
- );
196
- }
197
-
198
- /** Live screen position of a pin, re-anchored to the element. */
199
- function pinPosition(a: Annotation): { top: number; left: number } | null {
200
- const el = requery(a.fullPath ?? "") ?? requery(a.elementPath ?? "");
201
- if (el) {
202
- const rect = el.getBoundingClientRect();
203
- return { top: rect.top - 11, left: rect.left - 11 };
204
- }
205
- if (a.boundingBox) {
206
- return {
207
- top: a.boundingBox.y - (a.isFixed ? 0 : window.scrollY) - 11,
208
- left: a.boundingBox.x - (a.isFixed ? 0 : window.scrollX) - 11,
209
- };
210
- }
211
- return null;
212
- }
213
-
214
- function renderMultiBox(el: Element, i: number): ComponentChild {
215
- const rect = el.getBoundingClientRect();
216
- return h("div", {
217
- key: `multi-${i}`,
218
- class: "cf-multi-box",
219
- style: boxStyle({
220
- top: rect.top,
221
- left: rect.left,
222
- width: rect.width,
223
- height: rect.height,
224
- }),
225
- });
226
- }
227
-
228
- const CURSOR_PALETTE = ["#a78bfa", "#3ecf8e", "#f59e0b", "#38bdf8", "#f472b6"];
229
-
230
- function renderCursor(c: PresenceCursor): ComponentChild {
231
- // x is % of viewport width; y is px from document top.
232
- const left = (c.x / 100) * window.innerWidth;
233
- const top = c.y - window.scrollY;
234
- const color =
235
- c.color ??
236
- (c.kind === "agent"
237
- ? CURSOR_PALETTE[Math.abs(hashId(c.id)) % CURSOR_PALETTE.length]!
238
- : "#ffffff");
239
- return h(
240
- "div",
241
- {
242
- key: `cursor-${c.id}`,
243
- class: `cf-cursor cf-cursor-${c.kind}`,
244
- style: `top:${top}px;left:${left}px;--cf-cursor:${color};`,
245
- },
246
- [
247
- // Arrow pointer.
248
- h(
249
- "svg",
250
- {
251
- key: "arrow",
252
- width: "18",
253
- height: "18",
254
- viewBox: "0 0 18 18",
255
- class: "cf-cursor-arrow",
256
- },
257
- h("path", {
258
- d: "M2 2 L2 14 L6 10 L9 16 L11 15 L8 9 L14 9 Z",
259
- fill: color,
260
- stroke: "rgba(0,0,0,0.35)",
261
- "stroke-width": "0.75",
262
- }),
263
- ),
264
- h(
265
- "span",
266
- { key: "label", class: "cf-cursor-label" },
267
- `${c.kind === "agent" ? "🤖 " : ""}${c.label}`,
268
- ),
269
- ],
270
- );
271
- }
272
-
273
- function hashId(s: string): number {
274
- let h = 0;
275
- for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i);
276
- return h;
277
- }
278
-
279
- // ── Composer popup ─────────────────────────────────────────────────
280
-
281
- interface ComposerProps {
282
- target: CapturedTarget;
283
- }
284
-
285
- function Composer({ target }: ComposerProps): ComponentChild {
286
- const [comment, setComment] = useState("");
287
- const [intent, setIntent] = useState<AnnotationIntent>("change");
288
- const [severity, setSeverity] = useState<AnnotationSeverity>("important");
289
-
290
- const submit = (): void => {
291
- const text = comment.trim();
292
- if (!text) return;
293
- api.addAnnotation({
294
- ...target,
295
- comment: text,
296
- intent,
297
- severity,
298
- kind: "feedback" satisfies AnnotationKind,
299
- author: author.value,
300
- });
301
- draft.value = null;
302
- };
303
-
304
- const cancel = (): void => {
305
- draft.value = null;
306
- };
307
-
308
- const label = target.isMultiSelect
309
- ? `${target.elementBoundingBoxes?.length ?? 0} elements`
310
- : target.selectedText
311
- ? `"${truncate(target.selectedText, 40)}"`
312
- : `<${target.element}>`;
313
-
314
- return h("div", { class: "cf-popup cf-composer" }, [
315
- h("div", { key: "head", class: "cf-popup-head" }, [
316
- h("span", { key: "t", class: "cf-popup-target" }, label),
317
- h(
318
- "button",
319
- { key: "x", class: "cf-icon-btn", onClick: cancel, title: "Cancel" },
320
- "✕",
321
- ),
322
- ]),
323
- h("textarea", {
324
- key: "ta",
325
- class: "cf-textarea",
326
- placeholder: "Describe the change… (Markdown supported)",
327
- autofocus: true,
328
- value: comment,
329
- onInput: (e: Event) => setComment((e.target as HTMLTextAreaElement).value),
330
- onKeyDown: (e: KeyboardEvent) => {
331
- if ((e.metaKey || e.ctrlKey) && e.key === "Enter") submit();
332
- },
333
- }),
334
- h("div", { key: "intent", class: "cf-field" }, [
335
- h("span", { key: "l", class: "cf-field-label" }, "Intent"),
336
- h(
337
- "div",
338
- { key: "g", class: "cf-chipset" },
339
- INTENTS.map((it) =>
340
- chip(it, intent === it, SEVERITY_NEUTRAL, () => setIntent(it)),
341
- ),
342
- ),
343
- ]),
344
- h("div", { key: "sev", class: "cf-field" }, [
345
- h("span", { key: "l", class: "cf-field-label" }, "Severity"),
346
- h(
347
- "div",
348
- { key: "g", class: "cf-chipset" },
349
- SEVERITIES.map((s) =>
350
- chip(s, severity === s, SEVERITY_COLOR[s], () => setSeverity(s)),
351
- ),
352
- ),
353
- ]),
354
- h("div", { key: "actions", class: "cf-popup-actions" }, [
355
- h("button", { key: "c", class: "cf-btn", onClick: cancel }, "Cancel"),
356
- h(
357
- "button",
358
- {
359
- key: "s",
360
- class: "cf-btn cf-btn-primary",
361
- disabled: !comment.trim(),
362
- onClick: submit,
363
- },
364
- "Comment",
365
- ),
366
- ]),
367
- ]);
368
- }
369
-
370
- const SEVERITY_NEUTRAL = "#8b94a3";
371
-
372
- function chip(
373
- label: string,
374
- on: boolean,
375
- color: string,
376
- onClick: () => void,
377
- ): ComponentChild {
378
- return h(
379
- "button",
380
- {
381
- key: label,
382
- class: `cf-chip${on ? " on" : ""}`,
383
- style: on ? `--cf-chip:${color};` : undefined,
384
- onClick,
385
- },
386
- label,
387
- );
388
- }
389
-
390
- // ── Thread panel ───────────────────────────────────────────────────
391
-
392
- function ThreadPanel({ id }: { id: string }): ComponentChild {
393
- const [reply, setReply] = useState("");
394
- const a = annotations.value.find((x) => x.id === id);
395
- if (!a) return null;
396
-
397
- const send = (): void => {
398
- const text = reply.trim();
399
- if (!text) return;
400
- api.replyToAnnotation(id, { role: author.value.kind, content: text });
401
- setReply("");
402
- };
403
-
404
- return h("div", { class: "cf-popup cf-thread" }, [
405
- h("div", { key: "head", class: "cf-popup-head" }, [
406
- h("span", { key: "t", class: "cf-popup-target" }, `<${a.element}>`),
407
- a.severity
408
- ? h(
409
- "span",
410
- {
411
- key: "sev",
412
- class: "cf-tag",
413
- style: `--cf-tag:${SEVERITY_COLOR[a.severity]};`,
414
- },
415
- a.severity,
416
- )
417
- : null,
418
- h(
419
- "button",
420
- {
421
- key: "x",
422
- class: "cf-icon-btn",
423
- onClick: () => (activeThreadId.value = null),
424
- title: "Close",
425
- },
426
- "✕",
427
- ),
428
- ]),
429
- h("div", { key: "body", class: "cf-thread-body" }, [
430
- renderMessage(
431
- a.author?.kind ?? "human",
432
- a.author?.displayName ?? "You",
433
- a.comment,
434
- "root",
435
- ),
436
- ...(a.thread ?? []).map((m) =>
437
- renderMessage(m.role, m.role === "agent" ? "Agent" : "You", m.content, m.id),
438
- ),
439
- ]),
440
- h("div", { key: "compose", class: "cf-thread-compose" }, [
441
- h("textarea", {
442
- key: "ta",
443
- class: "cf-textarea cf-textarea-sm",
444
- placeholder: "Reply…",
445
- value: reply,
446
- onInput: (e: Event) => setReply((e.target as HTMLTextAreaElement).value),
447
- onKeyDown: (e: KeyboardEvent) => {
448
- if ((e.metaKey || e.ctrlKey) && e.key === "Enter") send();
449
- },
450
- }),
451
- h("div", { key: "row", class: "cf-thread-actions" }, [
452
- a.status !== "resolved"
453
- ? h(
454
- "button",
455
- {
456
- key: "resolve",
457
- class: "cf-btn cf-btn-ghost",
458
- onClick: () => api.resolveAnnotation(id, author.value.kind),
459
- },
460
- "Resolve",
461
- )
462
- : h(
463
- "span",
464
- { key: "resolved", class: "cf-resolved-tag" },
465
- "✓ resolved",
466
- ),
467
- h(
468
- "button",
469
- {
470
- key: "send",
471
- class: "cf-btn cf-btn-primary",
472
- disabled: !reply.trim(),
473
- onClick: send,
474
- },
475
- "Reply",
476
- ),
477
- ]),
478
- ]),
479
- ]);
480
- }
481
-
482
- function renderMessage(
483
- role: "human" | "agent",
484
- name: string,
485
- content: string,
486
- key: string,
487
- ): ComponentChild {
488
- return h("div", { key, class: `cf-msg cf-msg-${role}` }, [
489
- h("div", { key: "meta", class: "cf-msg-meta" }, [
490
- h("span", { key: "av", class: `cf-avatar cf-avatar-${role}` }, role === "agent" ? "🤖" : "🧑"),
491
- h("span", { key: "n", class: "cf-msg-name" }, name),
492
- ]),
493
- h("div", { key: "c", class: "cf-msg-body" }, content),
494
- ]);
495
- }
496
-
497
- // ── helpers ────────────────────────────────────────────────────────
498
-
499
- function boxStyle(b: {
500
- top: number;
501
- left: number;
502
- width: number;
503
- height: number;
504
- }): string {
505
- return `top:${b.top}px;left:${b.left}px;width:${b.width}px;height:${b.height}px;`;
506
- }
507
-
508
- function firstLine(markdown: string): string {
509
- const trimmed = markdown.trim();
510
- const nl = trimmed.indexOf("\n");
511
- return nl > 0 ? trimmed.slice(0, nl) : trimmed;
512
- }
513
-
514
- function truncate(text: string, max: number): string {
515
- return text.length > max ? `${text.slice(0, max - 1)}…` : text;
516
- }