@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/package.json +18 -6
- package/src/bundle.test.ts +53 -0
- package/src/bundle.ts +28 -13
- package/src/inject/bundle-source.generated.ts +1 -1
- package/src/ui/App.tsx +69 -0
- package/src/ui/Composer.tsx +138 -0
- package/src/ui/Cursors.tsx +50 -0
- package/src/ui/Pins.tsx +58 -0
- package/src/ui/ThreadPanel.tsx +124 -0
- package/src/ui/Toolbar.tsx +116 -0
- package/src/ui/constants.ts +24 -0
- package/src/ui/helpers.ts +50 -0
- package/src/ui/App.ts +0 -516
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live presence cursors — one labeled arrow per remote actor (human
|
|
3
|
+
* reviewers + spun-off agents) collaborating on the same page.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ComponentChild } from "preact";
|
|
7
|
+
|
|
8
|
+
import type { PresenceCursor } from "../store.js";
|
|
9
|
+
import { CURSOR_PALETTE } from "./constants.js";
|
|
10
|
+
import { hashId } from "./helpers.js";
|
|
11
|
+
|
|
12
|
+
interface CursorProps {
|
|
13
|
+
c: PresenceCursor;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function Cursor({ c }: CursorProps): ComponentChild {
|
|
17
|
+
// x is % of viewport width; y is px from document top.
|
|
18
|
+
const left = (c.x / 100) * window.innerWidth;
|
|
19
|
+
const top = c.y - window.scrollY;
|
|
20
|
+
const color =
|
|
21
|
+
c.color ??
|
|
22
|
+
(c.kind === "agent"
|
|
23
|
+
? CURSOR_PALETTE[Math.abs(hashId(c.id)) % CURSOR_PALETTE.length]!
|
|
24
|
+
: "#ffffff");
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
class={`cf-cursor cf-cursor-${c.kind}`}
|
|
28
|
+
style={`top:${top}px;left:${left}px;--cf-cursor:${color};`}
|
|
29
|
+
>
|
|
30
|
+
{/* Arrow pointer. */}
|
|
31
|
+
<svg
|
|
32
|
+
key="arrow"
|
|
33
|
+
width="18"
|
|
34
|
+
height="18"
|
|
35
|
+
viewBox="0 0 18 18"
|
|
36
|
+
class="cf-cursor-arrow"
|
|
37
|
+
>
|
|
38
|
+
<path
|
|
39
|
+
d="M2 2 L2 14 L6 10 L9 16 L11 15 L8 9 L14 9 Z"
|
|
40
|
+
fill={color}
|
|
41
|
+
stroke="rgba(0,0,0,0.35)"
|
|
42
|
+
stroke-width="0.75"
|
|
43
|
+
/>
|
|
44
|
+
</svg>
|
|
45
|
+
<span key="label" class="cf-cursor-label">
|
|
46
|
+
{`${c.kind === "agent" ? "🤖 " : ""}${c.label}`}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
package/src/ui/Pins.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pins + multi-select boxes.
|
|
3
|
+
*
|
|
4
|
+
* - `Pin` — one numbered marker per annotation, re-anchored to
|
|
5
|
+
* its live element each render.
|
|
6
|
+
* - `MultiBox` — a pulsing outline around each element in the
|
|
7
|
+
* current multi-selection set.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ComponentChild } from "preact";
|
|
11
|
+
|
|
12
|
+
import { activeThreadId } from "../store.js";
|
|
13
|
+
import type { Annotation } from "../types.js";
|
|
14
|
+
import { DEFAULT_DOT, SEVERITY_COLOR } from "./constants.js";
|
|
15
|
+
import { boxStyle, firstLine, pinPosition } from "./helpers.js";
|
|
16
|
+
|
|
17
|
+
interface PinProps {
|
|
18
|
+
a: Annotation;
|
|
19
|
+
index: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function Pin({ a, index }: PinProps): ComponentChild {
|
|
23
|
+
const pos = pinPosition(a);
|
|
24
|
+
if (!pos) return null;
|
|
25
|
+
const color = a.severity ? SEVERITY_COLOR[a.severity] : DEFAULT_DOT;
|
|
26
|
+
const resolved = a.status === "resolved" || a.status === "dismissed";
|
|
27
|
+
return (
|
|
28
|
+
<button
|
|
29
|
+
class={`cf-pin${resolved ? " resolved" : ""}${a.author?.kind === "agent" ? " agent" : ""}`}
|
|
30
|
+
style={`top:${pos.top}px;left:${pos.left}px;--cf-pin:${color};`}
|
|
31
|
+
title={firstLine(a.comment)}
|
|
32
|
+
onClick={() => {
|
|
33
|
+
activeThreadId.value = activeThreadId.value === a.id ? null : a.id;
|
|
34
|
+
}}
|
|
35
|
+
>
|
|
36
|
+
{a.author?.kind === "agent" ? "★" : String(index)}
|
|
37
|
+
</button>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface MultiBoxProps {
|
|
42
|
+
el: Element;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function MultiBox({ el }: MultiBoxProps): ComponentChild {
|
|
46
|
+
const rect = el.getBoundingClientRect();
|
|
47
|
+
return (
|
|
48
|
+
<div
|
|
49
|
+
class="cf-multi-box"
|
|
50
|
+
style={boxStyle({
|
|
51
|
+
top: rect.top,
|
|
52
|
+
left: rect.left,
|
|
53
|
+
width: rect.width,
|
|
54
|
+
height: rect.height,
|
|
55
|
+
})}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thread panel — the per-pin discussion (human ↔ agent). Shows the
|
|
3
|
+
* root annotation comment plus the reply thread, with a compose box
|
|
4
|
+
* and a resolve affordance. Reads the active annotation from the
|
|
5
|
+
* `../store.js` signals by id.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ComponentChild } from "preact";
|
|
9
|
+
import { useState } from "preact/hooks";
|
|
10
|
+
|
|
11
|
+
import { api } from "../api.js";
|
|
12
|
+
import { activeThreadId, annotations, author } from "../store.js";
|
|
13
|
+
import { SEVERITY_COLOR } from "./constants.js";
|
|
14
|
+
|
|
15
|
+
interface MessageProps {
|
|
16
|
+
role: "human" | "agent";
|
|
17
|
+
name: string;
|
|
18
|
+
content: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function Message({ role, name, content }: MessageProps): ComponentChild {
|
|
22
|
+
return (
|
|
23
|
+
<div class={`cf-msg cf-msg-${role}`}>
|
|
24
|
+
<div key="meta" class="cf-msg-meta">
|
|
25
|
+
<span key="av" class={`cf-avatar cf-avatar-${role}`}>
|
|
26
|
+
{role === "agent" ? "🤖" : "🧑"}
|
|
27
|
+
</span>
|
|
28
|
+
<span key="n" class="cf-msg-name">{name}</span>
|
|
29
|
+
</div>
|
|
30
|
+
<div key="c" class="cf-msg-body">{content}</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ThreadPanelProps {
|
|
36
|
+
id: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ThreadPanel({ id }: ThreadPanelProps): ComponentChild {
|
|
40
|
+
const [reply, setReply] = useState("");
|
|
41
|
+
const a = annotations.value.find((x) => x.id === id);
|
|
42
|
+
if (!a) return null;
|
|
43
|
+
|
|
44
|
+
const send = (): void => {
|
|
45
|
+
const text = reply.trim();
|
|
46
|
+
if (!text) return;
|
|
47
|
+
api.replyToAnnotation(id, { role: author.value.kind, content: text });
|
|
48
|
+
setReply("");
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div class="cf-popup cf-thread">
|
|
53
|
+
<div key="head" class="cf-popup-head">
|
|
54
|
+
<span key="t" class="cf-popup-target">{`<${a.element}>`}</span>
|
|
55
|
+
{a.severity ? (
|
|
56
|
+
<span
|
|
57
|
+
key="sev"
|
|
58
|
+
class="cf-tag"
|
|
59
|
+
style={`--cf-tag:${SEVERITY_COLOR[a.severity]};`}
|
|
60
|
+
>
|
|
61
|
+
{a.severity}
|
|
62
|
+
</span>
|
|
63
|
+
) : null}
|
|
64
|
+
<button
|
|
65
|
+
key="x"
|
|
66
|
+
class="cf-icon-btn"
|
|
67
|
+
onClick={() => (activeThreadId.value = null)}
|
|
68
|
+
title="Close"
|
|
69
|
+
>
|
|
70
|
+
✕
|
|
71
|
+
</button>
|
|
72
|
+
</div>
|
|
73
|
+
<div key="body" class="cf-thread-body">
|
|
74
|
+
<Message
|
|
75
|
+
key="root"
|
|
76
|
+
role={a.author?.kind ?? "human"}
|
|
77
|
+
name={a.author?.displayName ?? "You"}
|
|
78
|
+
content={a.comment}
|
|
79
|
+
/>
|
|
80
|
+
{(a.thread ?? []).map((m) => (
|
|
81
|
+
<Message
|
|
82
|
+
key={m.id}
|
|
83
|
+
role={m.role}
|
|
84
|
+
name={m.role === "agent" ? "Agent" : "You"}
|
|
85
|
+
content={m.content}
|
|
86
|
+
/>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
<div key="compose" class="cf-thread-compose">
|
|
90
|
+
<textarea
|
|
91
|
+
key="ta"
|
|
92
|
+
class="cf-textarea cf-textarea-sm"
|
|
93
|
+
placeholder="Reply…"
|
|
94
|
+
value={reply}
|
|
95
|
+
onInput={(e: Event) => setReply((e.target as HTMLTextAreaElement).value)}
|
|
96
|
+
onKeyDown={(e: KeyboardEvent) => {
|
|
97
|
+
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") send();
|
|
98
|
+
}}
|
|
99
|
+
/>
|
|
100
|
+
<div key="row" class="cf-thread-actions">
|
|
101
|
+
{a.status !== "resolved" ? (
|
|
102
|
+
<button
|
|
103
|
+
key="resolve"
|
|
104
|
+
class="cf-btn cf-btn-ghost"
|
|
105
|
+
onClick={() => api.resolveAnnotation(id, author.value.kind)}
|
|
106
|
+
>
|
|
107
|
+
Resolve
|
|
108
|
+
</button>
|
|
109
|
+
) : (
|
|
110
|
+
<span key="resolved" class="cf-resolved-tag">✓ resolved</span>
|
|
111
|
+
)}
|
|
112
|
+
<button
|
|
113
|
+
key="send"
|
|
114
|
+
class="cf-btn cf-btn-primary"
|
|
115
|
+
disabled={!reply.trim()}
|
|
116
|
+
onClick={send}
|
|
117
|
+
>
|
|
118
|
+
Reply
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Toolbar — mode + tool switches, annotation count, and a contextual
|
|
3
|
+
* hint. Reads cross-cutting state from the `../store.js` signals and
|
|
4
|
+
* commits multi-selections through `../picker.js`.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ComponentChild } from "preact";
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
annotations,
|
|
11
|
+
feedbackTool,
|
|
12
|
+
hoverBox,
|
|
13
|
+
mode,
|
|
14
|
+
multiSelection,
|
|
15
|
+
} from "../store.js";
|
|
16
|
+
import { commitMultiSelection } from "../picker.js";
|
|
17
|
+
|
|
18
|
+
export function hintFor(tool: string): string {
|
|
19
|
+
if (tool === "text") return "Select text on the page to annotate it.";
|
|
20
|
+
if (tool === "multi") return "Click elements to group them, then commit.";
|
|
21
|
+
return "Click any element to comment. Esc to exit.";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SegButtonProps {
|
|
25
|
+
label: string;
|
|
26
|
+
on: boolean;
|
|
27
|
+
onClick: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function SegButton({ label, on, onClick }: SegButtonProps): ComponentChild {
|
|
31
|
+
return (
|
|
32
|
+
<button class={`cf-seg-btn${on ? " on" : ""}`} onClick={onClick}>
|
|
33
|
+
{label}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function Toolbar(): ComponentChild {
|
|
39
|
+
const active = mode.value === "feedback";
|
|
40
|
+
const count = annotations.value.length;
|
|
41
|
+
const multi = multiSelection.value.length;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div key="toolbar" class="cf-toolbar">
|
|
45
|
+
<div key="head" class="cf-toolbar-head">
|
|
46
|
+
<span key="dot" class="cf-logo-dot" />
|
|
47
|
+
<span key="label">Annotations</span>
|
|
48
|
+
<span key="count" class="cf-count">{String(count)}</span>
|
|
49
|
+
</div>
|
|
50
|
+
<div key="modes" class="cf-seg">
|
|
51
|
+
<SegButton
|
|
52
|
+
key="View"
|
|
53
|
+
label="View"
|
|
54
|
+
on={mode.value === "view"}
|
|
55
|
+
onClick={() => {
|
|
56
|
+
mode.value = "view";
|
|
57
|
+
hoverBox.value = null;
|
|
58
|
+
multiSelection.value = [];
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
<SegButton
|
|
62
|
+
key="Comment"
|
|
63
|
+
label="Comment"
|
|
64
|
+
on={active}
|
|
65
|
+
onClick={() => {
|
|
66
|
+
mode.value = "feedback";
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
{active ? (
|
|
71
|
+
<div key="tools" class="cf-seg cf-tools">
|
|
72
|
+
<SegButton
|
|
73
|
+
key="Element"
|
|
74
|
+
label="Element"
|
|
75
|
+
on={feedbackTool.value === "element"}
|
|
76
|
+
onClick={() => {
|
|
77
|
+
feedbackTool.value = "element";
|
|
78
|
+
multiSelection.value = [];
|
|
79
|
+
}}
|
|
80
|
+
/>
|
|
81
|
+
<SegButton
|
|
82
|
+
key="Text"
|
|
83
|
+
label="Text"
|
|
84
|
+
on={feedbackTool.value === "text"}
|
|
85
|
+
onClick={() => {
|
|
86
|
+
feedbackTool.value = "text";
|
|
87
|
+
multiSelection.value = [];
|
|
88
|
+
}}
|
|
89
|
+
/>
|
|
90
|
+
<SegButton
|
|
91
|
+
key="Multi"
|
|
92
|
+
label="Multi"
|
|
93
|
+
on={feedbackTool.value === "multi"}
|
|
94
|
+
onClick={() => {
|
|
95
|
+
feedbackTool.value = "multi";
|
|
96
|
+
}}
|
|
97
|
+
/>
|
|
98
|
+
</div>
|
|
99
|
+
) : null}
|
|
100
|
+
{active && feedbackTool.value === "multi" && multi ? (
|
|
101
|
+
<button
|
|
102
|
+
key="commit"
|
|
103
|
+
class="cf-btn cf-btn-primary cf-commit"
|
|
104
|
+
onClick={commitMultiSelection}
|
|
105
|
+
>
|
|
106
|
+
{`Comment ${multi} element${multi === 1 ? "" : "s"}`}
|
|
107
|
+
</button>
|
|
108
|
+
) : null}
|
|
109
|
+
{active ? (
|
|
110
|
+
<div key="hint" class="cf-hint">
|
|
111
|
+
{hintFor(feedbackTool.value)}
|
|
112
|
+
</div>
|
|
113
|
+
) : null}
|
|
114
|
+
</div>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared overlay constants — colors, palettes, and the option lists
|
|
3
|
+
* the composer chips iterate over. Plain module (no JSX) so it can be
|
|
4
|
+
* imported by both the `.tsx` components and any plain `.ts` consumer.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AnnotationIntent,
|
|
9
|
+
AnnotationSeverity,
|
|
10
|
+
} from "../types.js";
|
|
11
|
+
|
|
12
|
+
export const SEVERITY_COLOR: Record<AnnotationSeverity, string> = {
|
|
13
|
+
blocking: "#ef4444",
|
|
14
|
+
important: "#f59e0b",
|
|
15
|
+
suggestion: "#3ecf8e",
|
|
16
|
+
};
|
|
17
|
+
export const DEFAULT_DOT = "#a78bfa";
|
|
18
|
+
|
|
19
|
+
export const INTENTS: AnnotationIntent[] = ["fix", "change", "question", "approve"];
|
|
20
|
+
export const SEVERITIES: AnnotationSeverity[] = ["blocking", "important", "suggestion"];
|
|
21
|
+
|
|
22
|
+
export const SEVERITY_NEUTRAL = "#8b94a3";
|
|
23
|
+
|
|
24
|
+
export const CURSOR_PALETTE = ["#a78bfa", "#3ecf8e", "#f59e0b", "#38bdf8", "#f472b6"];
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure layout/text helpers shared across the overlay components.
|
|
3
|
+
* Plain module (no JSX). `pinPosition` re-anchors a pin to its live
|
|
4
|
+
* element; `boxStyle` builds the inline-style string for highlight
|
|
5
|
+
* boxes; the rest are small string utilities.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { requery } from "../capture.js";
|
|
9
|
+
import type { Annotation } from "../types.js";
|
|
10
|
+
|
|
11
|
+
export function boxStyle(b: {
|
|
12
|
+
top: number;
|
|
13
|
+
left: number;
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}): string {
|
|
17
|
+
return `top:${b.top}px;left:${b.left}px;width:${b.width}px;height:${b.height}px;`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function firstLine(markdown: string): string {
|
|
21
|
+
const trimmed = markdown.trim();
|
|
22
|
+
const nl = trimmed.indexOf("\n");
|
|
23
|
+
return nl > 0 ? trimmed.slice(0, nl) : trimmed;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function truncate(text: string, max: number): string {
|
|
27
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function hashId(s: string): number {
|
|
31
|
+
let h = 0;
|
|
32
|
+
for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i);
|
|
33
|
+
return h;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Live screen position of a pin, re-anchored to the element. */
|
|
37
|
+
export function pinPosition(a: Annotation): { top: number; left: number } | null {
|
|
38
|
+
const el = requery(a.fullPath ?? "") ?? requery(a.elementPath ?? "");
|
|
39
|
+
if (el) {
|
|
40
|
+
const rect = el.getBoundingClientRect();
|
|
41
|
+
return { top: rect.top - 11, left: rect.left - 11 };
|
|
42
|
+
}
|
|
43
|
+
if (a.boundingBox) {
|
|
44
|
+
return {
|
|
45
|
+
top: a.boundingBox.y - (a.isFixed ? 0 : window.scrollY) - 11,
|
|
46
|
+
left: a.boundingBox.x - (a.isFixed ? 0 : window.scrollX) - 11,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|