@coframe-gtm/annotations 1.1.0 → 1.2.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.
@@ -13,20 +13,20 @@
13
13
  import { api } from "../api.js";
14
14
  import { activeThreadId, annotations, author } from "../store.js";
15
15
  import { SEVERITY_COLOR } from "./constants.js";
16
+ import { renderAvatar, type AvatarIdentity } from "./Avatar.js";
17
+ import { annotationBox, placeNear } from "./helpers.js";
18
+ import { brandGlyph } from "./brand.js";
16
19
  import { el } from "./dom.js";
17
20
 
18
- export function Message(
19
- role: "human" | "agent",
20
- name: string,
21
- content: string,
22
- ): HTMLElement {
21
+ export function Message(idt: AvatarIdentity, content: string): HTMLElement {
22
+ const name = idt.displayName ?? (idt.kind === "agent" ? "Agent" : "You");
23
23
  return el(
24
24
  "div",
25
- { class: `cf-msg cf-msg-${role}` },
25
+ { class: `cf-msg cf-msg-${idt.kind}` },
26
26
  el(
27
27
  "div",
28
28
  { class: "cf-msg-meta" },
29
- el("span", { class: `cf-avatar cf-avatar-${role}` }, role === "agent" ? "🤖" : "🧑"),
29
+ renderAvatar(idt),
30
30
  el("span", { class: "cf-msg-name" }, name),
31
31
  ),
32
32
  el("div", { class: "cf-msg-body" }, content),
@@ -62,12 +62,13 @@ export function renderThreadPanel(id: string): HTMLElement | null {
62
62
  sendBtn.disabled = !textarea.value.trim();
63
63
  }
64
64
 
65
- return el(
65
+ const popup = el(
66
66
  "div",
67
67
  { class: "cf-popup cf-thread" },
68
68
  el(
69
69
  "div",
70
70
  { class: "cf-popup-head" },
71
+ brandGlyph("cf-popup-brand"),
71
72
  el("span", { class: "cf-popup-target" }, `<${a.element}>`),
72
73
  a.severity
73
74
  ? el(
@@ -91,9 +92,12 @@ export function renderThreadPanel(id: string): HTMLElement | null {
91
92
  el(
92
93
  "div",
93
94
  { class: "cf-thread-body" },
94
- Message(a.author?.kind ?? "human", a.author?.displayName ?? "You", a.comment),
95
+ Message(a.author ?? { kind: "human", displayName: "You" }, a.comment),
95
96
  ...(a.thread ?? []).map((m) =>
96
- Message(m.role, m.role === "agent" ? "Agent" : "You", m.content),
97
+ Message(
98
+ { kind: m.role, displayName: m.role === "agent" ? "Agent" : "You" },
99
+ m.content,
100
+ ),
97
101
  ),
98
102
  ),
99
103
  el(
@@ -117,4 +121,12 @@ export function renderThreadPanel(id: string): HTMLElement | null {
117
121
  ),
118
122
  ),
119
123
  );
124
+
125
+ // Anchor the thread panel next to its annotated element.
126
+ const box = annotationBox(a);
127
+ if (box) placeNear(popup, box);
128
+ // Focus the reply field on open (the `autofocus` attribute doesn't fire
129
+ // on dynamically-inserted shadow-root nodes).
130
+ requestAnimationFrame(() => textarea.focus({ preventScroll: true }));
131
+ return popup;
120
132
  }
package/src/ui/Toolbar.ts CHANGED
@@ -1,51 +1,170 @@
1
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`.
2
+ * Toolbar — minimal, logo-first control surface.
5
3
  *
6
- * Vanilla DOM (no JSX). `renderToolbar()` builds the toolbar fresh from
7
- * the current signal values; the overlay re-runs it when any of the
8
- * signals the toolbar layer depends on changes.
4
+ * - Collapsed (default): a small draggable logo "FAB". Tap to expand.
5
+ * - Expanded: a tight row [grip] [logo] [View | Comment] [count]
6
+ * plus the tool row + primary action when commenting. A visible grip
7
+ * signals "draggable"; the logo collapses back to the puck.
8
+ *
9
+ * Vanilla DOM. `renderToolbar()` rebuilds from the current signal values.
9
10
  */
10
11
 
11
12
  import {
13
+ activeThreadId,
12
14
  annotations,
15
+ collapsed,
13
16
  feedbackTool,
14
17
  hoverBox,
15
18
  mode,
16
19
  multiSelection,
20
+ primaryAction,
21
+ toolbarPos,
17
22
  } from "../store.js";
18
23
  import { commitMultiSelection } from "../picker.js";
24
+ import { api } from "../api.js";
25
+ import { brandGlyph } from "./brand.js";
19
26
  import { el } from "./dom.js";
20
27
 
28
+ const EDGE = 8; // keep this many px of margin from every viewport edge
29
+ const clamp = (v: number, lo: number, hi: number): number => Math.max(lo, Math.min(v, hi));
30
+
31
+ /**
32
+ * Drag `target` by `handle`. Buttons inside the handle still click
33
+ * (they're skipped). Moves the element directly during the drag, then
34
+ * commits the final position to `toolbarPos`. If the pointer barely
35
+ * moved it's a tap → `onTap` (used to expand the collapsed FAB).
36
+ */
37
+ function attachDrag(target: HTMLElement, handle: HTMLElement, onTap?: () => void): void {
38
+ handle.addEventListener("pointerdown", (e: PointerEvent) => {
39
+ if ((e.target as HTMLElement)?.closest("button")) return; // let buttons click
40
+ e.preventDefault();
41
+ const rect = target.getBoundingClientRect();
42
+ const offX = e.clientX - rect.left;
43
+ const offY = e.clientY - rect.top;
44
+ const startX = e.clientX;
45
+ const startY = e.clientY;
46
+ let x = rect.left;
47
+ let y = rect.top;
48
+ let moved = false;
49
+ handle.setPointerCapture(e.pointerId);
50
+ target.classList.add("cf-dragging");
51
+
52
+ const move = (ev: PointerEvent): void => {
53
+ if (Math.abs(ev.clientX - startX) + Math.abs(ev.clientY - startY) > 4) moved = true;
54
+ x = clamp(ev.clientX - offX, EDGE, window.innerWidth - target.offsetWidth - EDGE);
55
+ y = clamp(ev.clientY - offY, EDGE, window.innerHeight - target.offsetHeight - EDGE);
56
+ target.style.left = `${x}px`;
57
+ target.style.top = `${y}px`;
58
+ target.style.right = "auto";
59
+ target.style.bottom = "auto";
60
+ };
61
+ const up = (): void => {
62
+ target.classList.remove("cf-dragging");
63
+ handle.removeEventListener("pointermove", move);
64
+ handle.removeEventListener("pointerup", up);
65
+ if (moved) toolbarPos.value = { x, y };
66
+ else onTap?.();
67
+ };
68
+ handle.addEventListener("pointermove", move);
69
+ handle.addEventListener("pointerup", up);
70
+ });
71
+ }
72
+
73
+ /** Scroll the annotation's element into view and open its thread. */
74
+ function jumpTo(index: number): void {
75
+ const list = annotations.value;
76
+ if (!list.length) return;
77
+ const i = ((index % list.length) + list.length) % list.length;
78
+ const a = list[i]!;
79
+ let target: Element | null = null;
80
+ try {
81
+ target =
82
+ (a.fullPath && document.querySelector(a.fullPath)) ||
83
+ (a.elementPath ? document.querySelector(a.elementPath) : null);
84
+ } catch {
85
+ /* invalid selector — fall through */
86
+ }
87
+ target?.scrollIntoView({ behavior: "smooth", block: "center" });
88
+ activeThreadId.value = a.id;
89
+ }
90
+
21
91
  export function hintFor(tool: string): string {
22
92
  if (tool === "text") return "Select text on the page to annotate it.";
23
93
  if (tool === "multi") return "Click elements to group them, then commit.";
24
94
  return "Click any element to comment. Esc to exit.";
25
95
  }
26
96
 
27
- export function SegButton(
28
- label: string,
29
- on: boolean,
30
- onClick: () => void,
31
- ): HTMLElement {
97
+ export function SegButton(label: string, on: boolean, onClick: () => void): HTMLElement {
32
98
  return el("button", { class: `cf-seg-btn${on ? " on" : ""}`, onClick }, label);
33
99
  }
34
100
 
101
+ function posStyle(): string | undefined {
102
+ const pos = toolbarPos.value;
103
+ return pos ? `left:${pos.x}px;top:${pos.y}px;right:auto;bottom:auto;` : undefined;
104
+ }
105
+
106
+ /**
107
+ * After the element is in the DOM, measure its real size and nudge it
108
+ * fully on-screen. The panel anchors top-left, so a wide expansion near
109
+ * the right/bottom edge would otherwise overflow — this pulls it back.
110
+ */
111
+ function keepOnScreen(elx: HTMLElement): void {
112
+ requestAnimationFrame(() => {
113
+ const r = elx.getBoundingClientRect();
114
+ if (!r.width || !r.height) return;
115
+ const x = clamp(r.left, EDGE, Math.max(EDGE, window.innerWidth - r.width - EDGE));
116
+ const y = clamp(r.top, EDGE, Math.max(EDGE, window.innerHeight - r.height - EDGE));
117
+ if (Math.abs(x - r.left) > 0.5 || Math.abs(y - r.top) > 0.5) {
118
+ elx.style.left = `${x}px`;
119
+ elx.style.top = `${y}px`;
120
+ elx.style.right = "auto";
121
+ elx.style.bottom = "auto";
122
+ }
123
+ });
124
+ }
125
+
35
126
  export function renderToolbar(): HTMLElement {
36
- const active = mode.value === "feedback";
37
127
  const count = annotations.value.length;
128
+
129
+ // Collapsed → a minimal draggable logo. Tap (or Enter/Space) to open.
130
+ if (collapsed.value) {
131
+ const fab = el("div", {
132
+ class: "cf-fab",
133
+ role: "button",
134
+ tabindex: "0",
135
+ title: "Open annotations · drag to move",
136
+ style: posStyle(),
137
+ });
138
+ fab.appendChild(brandGlyph());
139
+ if (count > 0) {
140
+ fab.appendChild(el("span", { class: "cf-fab-badge" }, count > 99 ? "99+" : String(count)));
141
+ }
142
+ fab.addEventListener("keydown", (e: KeyboardEvent) => {
143
+ if (e.key === "Enter" || e.key === " ") {
144
+ e.preventDefault();
145
+ collapsed.value = false;
146
+ }
147
+ });
148
+ attachDrag(fab, fab, () => {
149
+ collapsed.value = false;
150
+ });
151
+ keepOnScreen(fab);
152
+ return fab;
153
+ }
154
+
155
+ const active = mode.value === "feedback";
38
156
  const multi = multiSelection.value.length;
39
157
 
40
- return el(
158
+ const head = el(
41
159
  "div",
42
- { class: "cf-toolbar" },
160
+ { class: "cf-toolbar-head", title: "Drag to move" },
161
+ // Visible grip → "you can drag this".
162
+ el("span", { class: "cf-grip", "aria-hidden": "true" }, "⠿"),
163
+ // Logo → collapses back to the puck.
43
164
  el(
44
- "div",
45
- { class: "cf-toolbar-head" },
46
- el("span", { class: "cf-logo-dot" }),
47
- el("span", null, "Annotations"),
48
- el("span", { class: "cf-count" }, String(count)),
165
+ "button",
166
+ { class: "cf-collapse", title: "Collapse to icon", onClick: () => (collapsed.value = true) },
167
+ brandGlyph(),
49
168
  ),
50
169
  el(
51
170
  "div",
@@ -59,6 +178,57 @@ export function renderToolbar(): HTMLElement {
59
178
  mode.value = "feedback";
60
179
  }),
61
180
  ),
181
+ el("span", { class: "cf-count" }, String(count)),
182
+ // Erase board — clears every annotation.
183
+ count > 0
184
+ ? el(
185
+ "button",
186
+ {
187
+ class: "cf-icon-btn cf-clear",
188
+ title: "Clear all annotations",
189
+ onClick: () => {
190
+ api.clearAnnotations();
191
+ api.clearCursors();
192
+ },
193
+ },
194
+ "Clear",
195
+ )
196
+ : null,
197
+ );
198
+
199
+ // Suggestions navigator — count + jump to each annotation.
200
+ const list = annotations.value;
201
+ const agentCount = list.filter((a) => a.author?.kind === "agent").length;
202
+ const cur = Math.max(0, list.findIndex((a) => a.id === activeThreadId.value));
203
+ const nav =
204
+ count > 0
205
+ ? el(
206
+ "div",
207
+ { class: "cf-nav" },
208
+ el(
209
+ "button",
210
+ { class: "cf-nav-btn", title: "Previous", onClick: () => jumpTo(cur - 1) },
211
+ "‹",
212
+ ),
213
+ el(
214
+ "span",
215
+ { class: "cf-nav-label" },
216
+ agentCount > 0
217
+ ? `${agentCount} agent${agentCount === 1 ? "" : "s"} · ${cur + 1}/${count}`
218
+ : `${cur + 1} / ${count}`,
219
+ ),
220
+ el(
221
+ "button",
222
+ { class: "cf-nav-btn", title: "Next", onClick: () => jumpTo(cur + 1) },
223
+ "›",
224
+ ),
225
+ )
226
+ : null;
227
+
228
+ const toolbar = el("div", { class: "cf-toolbar", style: posStyle() });
229
+ const children: Array<Node | null> = [
230
+ head,
231
+ nav,
62
232
  active
63
233
  ? el(
64
234
  "div",
@@ -84,5 +254,17 @@ export function renderToolbar(): HTMLElement {
84
254
  )
85
255
  : null,
86
256
  active ? el("div", { class: "cf-hint" }, hintFor(feedbackTool.value)) : null,
87
- );
257
+ primaryAction.value
258
+ ? el(
259
+ "button",
260
+ { class: "cf-btn cf-btn-primary cf-launch", onClick: () => primaryAction.value?.run() },
261
+ primaryAction.value.label,
262
+ )
263
+ : null,
264
+ ];
265
+ for (const child of children) if (child) toolbar.appendChild(child);
266
+
267
+ attachDrag(toolbar, head);
268
+ keepOnScreen(toolbar);
269
+ return toolbar;
88
270
  }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Brand glyph — the host-supplied logo (the "floater" mark) or a neutral
3
+ * dot fallback. Shared by the toolbar/FAB and the popups, so the composer
4
+ * reads as the floater that grew into a box.
5
+ */
6
+
7
+ import { brandLogo } from "../store.js";
8
+ import { el } from "./dom.js";
9
+
10
+ export function brandGlyph(extraClass = ""): HTMLElement {
11
+ const span = el("span", { class: `cf-brand${extraClass ? ` ${extraClass}` : ""}` });
12
+ const logo = brandLogo.value;
13
+ if (logo) span.innerHTML = logo;
14
+ else span.appendChild(el("span", { class: "cf-logo-dot" }));
15
+ return span;
16
+ }
package/src/ui/helpers.ts CHANGED
@@ -27,6 +27,71 @@ export function truncate(text: string, max: number): string {
27
27
  return text.length > max ? `${text.slice(0, max - 1)}…` : text;
28
28
  }
29
29
 
30
+ /**
31
+ * Anchor a popup just below its target element (viewport-coords box),
32
+ * then clamp it on-screen — flipping above when it would overflow the
33
+ * bottom. Keeps the composer/thread next to what they refer to.
34
+ *
35
+ * Once placed, the popup *morphs out of the floater*: it animates from
36
+ * the launcher's position + size (the FAB / toolbar puck) to its final
37
+ * rect, so the box reads as the floater that flew over and opened.
38
+ */
39
+ export function placeNear(
40
+ popup: HTMLElement,
41
+ box: { left: number; top: number; width: number; height: number },
42
+ ): void {
43
+ popup.style.position = "fixed";
44
+ popup.style.left = `${box.left}px`;
45
+ popup.style.top = `${box.top + box.height + 10}px`;
46
+ popup.style.right = "auto";
47
+ popup.style.bottom = "auto";
48
+ requestAnimationFrame(() => {
49
+ const r = popup.getBoundingClientRect();
50
+ if (!r.width) return;
51
+ const left = Math.max(8, Math.min(box.left, window.innerWidth - r.width - 8));
52
+ let top = box.top + box.height + 10;
53
+ if (top + r.height > window.innerHeight - 8) top = box.top - r.height - 10;
54
+ top = Math.max(8, Math.min(top, window.innerHeight - r.height - 8));
55
+ popup.style.left = `${left}px`;
56
+ popup.style.top = `${top}px`;
57
+ morphFromLauncher(popup);
58
+ });
59
+ }
60
+
61
+ /**
62
+ * Spring the popup open from the floater (FAB / toolbar puck): a FLIP
63
+ * that starts the popup shrunk + translated onto the launcher, then
64
+ * animates to its real position. Falls back silently if the launcher
65
+ * can't be found or the Web Animations API is unavailable.
66
+ */
67
+ function morphFromLauncher(popup: HTMLElement): void {
68
+ if (typeof popup.animate !== "function") return;
69
+ const root = popup.closest(".cf-overlay");
70
+ const launcher = root?.querySelector<HTMLElement>(".cf-fab, .cf-toolbar");
71
+ if (!launcher) return;
72
+ const from = launcher.getBoundingClientRect();
73
+ const to = popup.getBoundingClientRect();
74
+ if (!from.width || !to.width) return;
75
+
76
+ const dx = from.left + from.width / 2 - (to.left + to.width / 2);
77
+ const dy = from.top + from.height / 2 - (to.top + to.height / 2);
78
+ const sx = Math.max(0.08, from.width / to.width);
79
+ const sy = Math.max(0.08, from.height / to.height);
80
+
81
+ // Suppress the CSS keyframe so it doesn't fight the morph.
82
+ popup.classList.add("cf-morph");
83
+ popup.style.transformOrigin = "center center";
84
+ popup.animate(
85
+ [
86
+ { transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})`, opacity: 0.25 },
87
+ { transform: "translate(0, 0) scale(1, 1)", opacity: 1 },
88
+ ],
89
+ { duration: 360, easing: "cubic-bezier(.34,1.3,.5,1)", fill: "both" },
90
+ ).addEventListener("finish", () => {
91
+ popup.style.transform = "";
92
+ });
93
+ }
94
+
30
95
  export function hashId(s: string): number {
31
96
  let h = 0;
32
97
  for (let i = 0; i < s.length; i++) h = (h << 5) - h + s.charCodeAt(i);
@@ -48,3 +113,27 @@ export function pinPosition(a: Annotation): { top: number; left: number } | null
48
113
  }
49
114
  return null;
50
115
  }
116
+
117
+ /**
118
+ * Live viewport rect of the element an annotation targets — used to
119
+ * draw the highlight outline around it. Re-anchors to the live element
120
+ * (falls back to the stored bounding box). Mirrors {@link pinPosition}.
121
+ */
122
+ export function annotationBox(
123
+ a: Annotation,
124
+ ): { top: number; left: number; width: number; height: number } | null {
125
+ const el = requery(a.fullPath ?? "") ?? requery(a.elementPath ?? "");
126
+ if (el) {
127
+ const r = el.getBoundingClientRect();
128
+ return { top: r.top, left: r.left, width: r.width, height: r.height };
129
+ }
130
+ if (a.boundingBox) {
131
+ return {
132
+ top: a.boundingBox.y - (a.isFixed ? 0 : window.scrollY),
133
+ left: a.boundingBox.x - (a.isFixed ? 0 : window.scrollX),
134
+ width: a.boundingBox.width,
135
+ height: a.boundingBox.height,
136
+ };
137
+ }
138
+ return null;
139
+ }
package/src/ui/overlay.ts CHANGED
@@ -30,7 +30,11 @@ import {
30
30
  mode,
31
31
  feedbackTool,
32
32
  multiSelection,
33
+ primaryAction,
34
+ brandLogo,
35
+ collapsed,
33
36
  theme,
37
+ toolbarPos,
34
38
  viewportTick,
35
39
  } from "../store.js";
36
40
  import type { Signal } from "../signal.js";
@@ -129,6 +133,10 @@ export function mountOverlay(
129
133
  feedbackTool as Signal<unknown>,
130
134
  annotations as Signal<unknown>,
131
135
  multiSelection as Signal<unknown>,
136
+ toolbarPos as Signal<unknown>,
137
+ primaryAction as Signal<unknown>,
138
+ collapsed as Signal<unknown>,
139
+ brandLogo as Signal<unknown>,
132
140
  ],
133
141
  () => renderToolbar(),
134
142
  );