@coframe-gtm/annotations 1.0.3 → 1.1.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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Live presence cursors — one labeled arrow per remote actor (human
3
+ * reviewers + spun-off agents) collaborating on the same page. Vanilla
4
+ * DOM; the cursors layer renders one of these per entry in
5
+ * `cursors.value` (multi-cursor).
6
+ */
7
+
8
+ import type { PresenceCursor } from "../store.js";
9
+ import { CURSOR_PALETTE } from "./constants.js";
10
+ import { hashId } from "./helpers.js";
11
+ import { el, svg } from "./dom.js";
12
+
13
+ export function renderCursor(c: PresenceCursor): HTMLElement {
14
+ // x is % of viewport width; y is px from document top.
15
+ const left = (c.x / 100) * window.innerWidth;
16
+ const top = c.y - window.scrollY;
17
+ const color =
18
+ c.color ??
19
+ (c.kind === "agent"
20
+ ? CURSOR_PALETTE[Math.abs(hashId(c.id)) % CURSOR_PALETTE.length]!
21
+ : "#ffffff");
22
+
23
+ const arrow = svg(
24
+ "svg",
25
+ {
26
+ width: "18",
27
+ height: "18",
28
+ viewBox: "0 0 18 18",
29
+ class: "cf-cursor-arrow",
30
+ },
31
+ svg("path", {
32
+ d: "M2 2 L2 14 L6 10 L9 16 L11 15 L8 9 L14 9 Z",
33
+ fill: color,
34
+ stroke: "rgba(0,0,0,0.35)",
35
+ "stroke-width": "0.75",
36
+ }),
37
+ );
38
+
39
+ return el(
40
+ "div",
41
+ {
42
+ class: `cf-cursor cf-cursor-${c.kind}`,
43
+ style: `top:${top}px;left:${left}px;--cf-cursor:${color};`,
44
+ },
45
+ arrow,
46
+ el(
47
+ "span",
48
+ { class: "cf-cursor-label" },
49
+ `${c.kind === "agent" ? "🤖 " : ""}${c.label}`,
50
+ ),
51
+ );
52
+ }
package/src/ui/Pins.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Pins + multi-select boxes (vanilla DOM).
3
+ *
4
+ * - `renderPin(a, index)` — one numbered marker per annotation,
5
+ * re-anchored to its live element. Click toggles the thread panel;
6
+ * hover shows a small floating preview card with the comment.
7
+ * - `MultiBox(el)` — a pulsing outline around an element in the
8
+ * current multi-selection set.
9
+ */
10
+
11
+ import { activeThreadId } from "../store.js";
12
+ import type { Annotation } from "../types.js";
13
+ import { DEFAULT_DOT, SEVERITY_COLOR } from "./constants.js";
14
+ import { boxStyle, firstLine, pinPosition } from "./helpers.js";
15
+ import { el } from "./dom.js";
16
+
17
+ export function renderPin(a: Annotation, index: number): HTMLElement | null {
18
+ const pos = pinPosition(a);
19
+ if (!pos) return null;
20
+ const color = a.severity ? SEVERITY_COLOR[a.severity] : DEFAULT_DOT;
21
+ const resolved = a.status === "resolved" || a.status === "dismissed";
22
+
23
+ const pin = el(
24
+ "button",
25
+ {
26
+ class: `cf-pin${resolved ? " resolved" : ""}${a.author?.kind === "agent" ? " agent" : ""}`,
27
+ style: `top:${pos.top}px;left:${pos.left}px;--cf-pin:${color};`,
28
+ title: firstLine(a.comment),
29
+ onClick: () => {
30
+ activeThreadId.value = activeThreadId.value === a.id ? null : a.id;
31
+ },
32
+ },
33
+ a.author?.kind === "agent" ? "★" : String(index),
34
+ );
35
+
36
+ // Hover-to-preview: a small floating card with the comment, shown on
37
+ // mouseenter and removed on mouseleave. The card is appended to the
38
+ // pin so it lives/dies with it; it's positioned just below-right.
39
+ let preview: HTMLElement | null = null;
40
+ pin.addEventListener("mouseenter", () => {
41
+ if (preview) return;
42
+ preview = el(
43
+ "div",
44
+ { class: "cf-pin-preview" },
45
+ firstLine(a.comment) || a.comment,
46
+ );
47
+ pin.appendChild(preview);
48
+ });
49
+ pin.addEventListener("mouseleave", () => {
50
+ preview?.remove();
51
+ preview = null;
52
+ });
53
+
54
+ return pin;
55
+ }
56
+
57
+ export function MultiBox(target: Element): HTMLElement {
58
+ const rect = target.getBoundingClientRect();
59
+ return el("div", {
60
+ class: "cf-multi-box",
61
+ style: boxStyle({
62
+ top: rect.top,
63
+ left: rect.left,
64
+ width: rect.width,
65
+ height: rect.height,
66
+ }),
67
+ });
68
+ }
@@ -0,0 +1,120 @@
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. Vanilla DOM.
6
+ *
7
+ * The reply text is read from the textarea at send time (no hook state);
8
+ * the overlay's thread layer re-renders this on annotation changes so
9
+ * new thread messages appear, which clears the in-progress reply — same
10
+ * behavior as the previous Preact `useState("")` reset after send.
11
+ */
12
+
13
+ import { api } from "../api.js";
14
+ import { activeThreadId, annotations, author } from "../store.js";
15
+ import { SEVERITY_COLOR } from "./constants.js";
16
+ import { el } from "./dom.js";
17
+
18
+ export function Message(
19
+ role: "human" | "agent",
20
+ name: string,
21
+ content: string,
22
+ ): HTMLElement {
23
+ return el(
24
+ "div",
25
+ { class: `cf-msg cf-msg-${role}` },
26
+ el(
27
+ "div",
28
+ { class: "cf-msg-meta" },
29
+ el("span", { class: `cf-avatar cf-avatar-${role}` }, role === "agent" ? "🤖" : "🧑"),
30
+ el("span", { class: "cf-msg-name" }, name),
31
+ ),
32
+ el("div", { class: "cf-msg-body" }, content),
33
+ );
34
+ }
35
+
36
+ export function renderThreadPanel(id: string): HTMLElement | null {
37
+ const a = annotations.value.find((x) => x.id === id);
38
+ if (!a) return null;
39
+
40
+ const textarea = el("textarea", {
41
+ class: "cf-textarea cf-textarea-sm",
42
+ placeholder: "Reply…",
43
+ onKeyDown: (e: KeyboardEvent) => {
44
+ if ((e.metaKey || e.ctrlKey) && e.key === "Enter") send();
45
+ },
46
+ onInput: () => syncSendState(),
47
+ }) as HTMLTextAreaElement;
48
+
49
+ const send = (): void => {
50
+ const text = textarea.value.trim();
51
+ if (!text) return;
52
+ api.replyToAnnotation(id, { role: author.value.kind, content: text });
53
+ };
54
+
55
+ const sendBtn = el(
56
+ "button",
57
+ { class: "cf-btn cf-btn-primary", disabled: true, onClick: send },
58
+ "Reply",
59
+ ) as HTMLButtonElement;
60
+
61
+ function syncSendState(): void {
62
+ sendBtn.disabled = !textarea.value.trim();
63
+ }
64
+
65
+ return el(
66
+ "div",
67
+ { class: "cf-popup cf-thread" },
68
+ el(
69
+ "div",
70
+ { class: "cf-popup-head" },
71
+ el("span", { class: "cf-popup-target" }, `<${a.element}>`),
72
+ a.severity
73
+ ? el(
74
+ "span",
75
+ { class: "cf-tag", style: `--cf-tag:${SEVERITY_COLOR[a.severity]};` },
76
+ a.severity,
77
+ )
78
+ : null,
79
+ el(
80
+ "button",
81
+ {
82
+ class: "cf-icon-btn",
83
+ onClick: () => {
84
+ activeThreadId.value = null;
85
+ },
86
+ title: "Close",
87
+ },
88
+ "✕",
89
+ ),
90
+ ),
91
+ el(
92
+ "div",
93
+ { class: "cf-thread-body" },
94
+ Message(a.author?.kind ?? "human", a.author?.displayName ?? "You", a.comment),
95
+ ...(a.thread ?? []).map((m) =>
96
+ Message(m.role, m.role === "agent" ? "Agent" : "You", m.content),
97
+ ),
98
+ ),
99
+ el(
100
+ "div",
101
+ { class: "cf-thread-compose" },
102
+ textarea,
103
+ el(
104
+ "div",
105
+ { class: "cf-thread-actions" },
106
+ a.status !== "resolved"
107
+ ? el(
108
+ "button",
109
+ {
110
+ class: "cf-btn cf-btn-ghost",
111
+ onClick: () => api.resolveAnnotation(id, author.value.kind),
112
+ },
113
+ "Resolve",
114
+ )
115
+ : el("span", { class: "cf-resolved-tag" }, "✓ resolved"),
116
+ sendBtn,
117
+ ),
118
+ ),
119
+ );
120
+ }
@@ -0,0 +1,88 @@
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
+ * 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.
9
+ */
10
+
11
+ import {
12
+ annotations,
13
+ feedbackTool,
14
+ hoverBox,
15
+ mode,
16
+ multiSelection,
17
+ } from "../store.js";
18
+ import { commitMultiSelection } from "../picker.js";
19
+ import { el } from "./dom.js";
20
+
21
+ export function hintFor(tool: string): string {
22
+ if (tool === "text") return "Select text on the page to annotate it.";
23
+ if (tool === "multi") return "Click elements to group them, then commit.";
24
+ return "Click any element to comment. Esc to exit.";
25
+ }
26
+
27
+ export function SegButton(
28
+ label: string,
29
+ on: boolean,
30
+ onClick: () => void,
31
+ ): HTMLElement {
32
+ return el("button", { class: `cf-seg-btn${on ? " on" : ""}`, onClick }, label);
33
+ }
34
+
35
+ export function renderToolbar(): HTMLElement {
36
+ const active = mode.value === "feedback";
37
+ const count = annotations.value.length;
38
+ const multi = multiSelection.value.length;
39
+
40
+ return el(
41
+ "div",
42
+ { class: "cf-toolbar" },
43
+ 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)),
49
+ ),
50
+ el(
51
+ "div",
52
+ { class: "cf-seg" },
53
+ SegButton("View", mode.value === "view", () => {
54
+ mode.value = "view";
55
+ hoverBox.value = null;
56
+ multiSelection.value = [];
57
+ }),
58
+ SegButton("Comment", active, () => {
59
+ mode.value = "feedback";
60
+ }),
61
+ ),
62
+ active
63
+ ? el(
64
+ "div",
65
+ { class: "cf-seg cf-tools" },
66
+ SegButton("Element", feedbackTool.value === "element", () => {
67
+ feedbackTool.value = "element";
68
+ multiSelection.value = [];
69
+ }),
70
+ SegButton("Text", feedbackTool.value === "text", () => {
71
+ feedbackTool.value = "text";
72
+ multiSelection.value = [];
73
+ }),
74
+ SegButton("Multi", feedbackTool.value === "multi", () => {
75
+ feedbackTool.value = "multi";
76
+ }),
77
+ )
78
+ : null,
79
+ active && feedbackTool.value === "multi" && multi
80
+ ? el(
81
+ "button",
82
+ { class: "cf-btn cf-btn-primary cf-commit", onClick: commitMultiSelection },
83
+ `Comment ${multi} element${multi === 1 ? "" : "s"}`,
84
+ )
85
+ : null,
86
+ active ? el("div", { class: "cf-hint" }, hintFor(feedbackTool.value)) : null,
87
+ );
88
+ }
@@ -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"];
package/src/ui/dom.ts ADDED
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Tiny DRY DOM-building helpers — the vanilla replacement for JSX.
3
+ *
4
+ * `el(tag, props?, ...children)` builds an HTMLElement; `svg(tag, …)`
5
+ * does the same in the SVG namespace (for the cursor arrow). Props map
6
+ * onto attributes/handlers the same way the old JSX did:
7
+ *
8
+ * - `class` → className
9
+ * - `style` → inline style string
10
+ * - `on*` (onClick…) → addEventListener (onClick→click, onInput→input,
11
+ * onKeyDown→keydown — lowercased remainder)
12
+ * - `dataset` → element.dataset entries
13
+ * - boolean attrs → present when true, omitted when false
14
+ * - everything else → setAttribute(key, String(value))
15
+ *
16
+ * Children may be strings (text nodes), Nodes, arrays (flattened), or
17
+ * null/false/undefined (skipped).
18
+ */
19
+
20
+ const SVG_NS = "http://www.w3.org/2000/svg";
21
+
22
+ export type Child = Node | string | number | null | false | undefined | Child[];
23
+
24
+ export interface Props {
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ function applyProps(node: Element, props: Props): void {
29
+ for (const [key, value] of Object.entries(props)) {
30
+ if (value === undefined || value === null) continue;
31
+
32
+ if (key === "class") {
33
+ node.setAttribute("class", String(value));
34
+ continue;
35
+ }
36
+ if (key === "style") {
37
+ node.setAttribute("style", String(value));
38
+ continue;
39
+ }
40
+ if (key === "dataset") {
41
+ const data = value as Record<string, string>;
42
+ if (node instanceof HTMLElement) {
43
+ for (const [dk, dv] of Object.entries(data)) node.dataset[dk] = dv;
44
+ }
45
+ continue;
46
+ }
47
+ if (key.startsWith("on") && typeof value === "function") {
48
+ const event = key.slice(2).toLowerCase();
49
+ node.addEventListener(event, value as EventListener);
50
+ continue;
51
+ }
52
+ if (typeof value === "boolean") {
53
+ if (value) node.setAttribute(key, "");
54
+ continue;
55
+ }
56
+ node.setAttribute(key, String(value));
57
+ }
58
+ }
59
+
60
+ function appendChildren(node: Element, children: Child[]): void {
61
+ for (const child of children) {
62
+ if (child === null || child === false || child === undefined) continue;
63
+ if (Array.isArray(child)) {
64
+ appendChildren(node, child);
65
+ continue;
66
+ }
67
+ if (child instanceof Node) {
68
+ node.appendChild(child);
69
+ continue;
70
+ }
71
+ node.appendChild(document.createTextNode(String(child)));
72
+ }
73
+ }
74
+
75
+ export function el(
76
+ tag: string,
77
+ props?: Props | null,
78
+ ...children: Child[]
79
+ ): HTMLElement {
80
+ const node = document.createElement(tag);
81
+ if (props) applyProps(node, props);
82
+ appendChildren(node, children);
83
+ return node;
84
+ }
85
+
86
+ export function svg(
87
+ tag: string,
88
+ props?: Props | null,
89
+ ...children: Child[]
90
+ ): SVGElement {
91
+ const node = document.createElementNS(SVG_NS, tag);
92
+ if (props) applyProps(node, props);
93
+ appendChildren(node, children);
94
+ return node;
95
+ }
@@ -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
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Vanilla per-layer overlay mount — the replacement for the single
3
+ * Preact <App/> tree.
4
+ *
5
+ * Each visual layer gets its OWN container and subscribes ONLY to the
6
+ * signals it depends on, re-rendering just that layer when one changes.
7
+ * This is deliberate: `hoverBox` updates on every mousemove, and a
8
+ * single global re-render would destroy an open composer (blowing away
9
+ * the textarea's focus + value) on each move. Layer isolation keeps the
10
+ * composer alive while the hover box repaints independently.
11
+ *
12
+ * Layers (all inside the closed Shadow DOM, fixed-positioned):
13
+ * 1. styles — static <style> with STYLES (once)
14
+ * 2. hover <- hoverBox, draft (hidden while composing)
15
+ * 3. multi-box <- multiSelection, viewportTick
16
+ * 4. pins <- annotations, viewportTick
17
+ * 5. cursors <- cursors (multi-cursor)
18
+ * 6. toolbar <- mode, feedbackTool, annotations, multiSelection
19
+ * (skipped entirely when init headless = true)
20
+ * 7. composer <- draft (rebuilt only when the draft opens/closes)
21
+ * 8. thread <- activeThreadId, annotations
22
+ */
23
+
24
+ import {
25
+ activeThreadId,
26
+ annotations,
27
+ cursors,
28
+ draft,
29
+ hoverBox,
30
+ mode,
31
+ feedbackTool,
32
+ multiSelection,
33
+ theme,
34
+ viewportTick,
35
+ } from "../store.js";
36
+ import type { Signal } from "../signal.js";
37
+ import { boxStyle } from "./helpers.js";
38
+ import { renderToolbar } from "./Toolbar.js";
39
+ import { MultiBox, renderPin } from "./Pins.js";
40
+ import { renderCursor } from "./Cursors.js";
41
+ import { renderComposer } from "./Composer.js";
42
+ import { renderThreadPanel } from "./ThreadPanel.js";
43
+ import { STYLES } from "./styles.js";
44
+ import { el } from "./dom.js";
45
+
46
+ interface MountOptions {
47
+ /** When true the toolbar layer is not mounted (parent frame drives mode). */
48
+ headless?: boolean;
49
+ }
50
+
51
+ /** Wire a layer container to a set of signals; re-render on any change. */
52
+ function bindLayer(
53
+ container: HTMLElement,
54
+ deps: Signal<unknown>[],
55
+ render: () => Node | Node[] | null,
56
+ ): void {
57
+ const paint = (): void => {
58
+ container.replaceChildren();
59
+ const out = render();
60
+ if (out === null) return;
61
+ if (Array.isArray(out)) container.append(...out);
62
+ else container.append(out);
63
+ };
64
+ paint();
65
+ for (const dep of deps) dep.subscribe(paint);
66
+ }
67
+
68
+ export function mountOverlay(
69
+ appRoot: HTMLElement,
70
+ options: MountOptions = {},
71
+ ): void {
72
+ const root = el("div", { class: "cf-overlay" });
73
+
74
+ const setTheme = (): void => {
75
+ root.setAttribute("data-theme", theme.value === "auto" ? "dark" : theme.value);
76
+ };
77
+ setTheme();
78
+ theme.subscribe(setTheme);
79
+
80
+ // 1. Styles — static, once.
81
+ const style = document.createElement("style");
82
+ style.textContent = STYLES;
83
+ root.appendChild(style);
84
+
85
+ // 2. Hover highlight (hidden while composing).
86
+ const hoverLayer = el("div", { class: "cf-layer-hover" });
87
+ bindLayer(hoverLayer, [hoverBox as Signal<unknown>, draft as Signal<unknown>], () => {
88
+ const box = hoverBox.value;
89
+ if (!box || draft.value) return null;
90
+ return el("div", { class: "cf-hover", style: boxStyle(box) });
91
+ });
92
+ root.appendChild(hoverLayer);
93
+
94
+ // 3. Multi-select boxes.
95
+ const multiLayer = el("div", { class: "cf-layer-multi" });
96
+ bindLayer(
97
+ multiLayer,
98
+ [multiSelection as Signal<unknown>, viewportTick as Signal<unknown>],
99
+ () => multiSelection.value.map((element) => MultiBox(element)),
100
+ );
101
+ root.appendChild(multiLayer);
102
+
103
+ // 4. Pins.
104
+ const pinsLayer = el("div", { class: "cf-layer-pins" });
105
+ bindLayer(
106
+ pinsLayer,
107
+ [annotations as Signal<unknown>, viewportTick as Signal<unknown>],
108
+ () =>
109
+ annotations.value
110
+ .map((a, i) => renderPin(a, i + 1))
111
+ .filter((n): n is HTMLElement => n !== null),
112
+ );
113
+ root.appendChild(pinsLayer);
114
+
115
+ // 5. Cursors (multi-cursor — one labeled cursor per entry).
116
+ const cursorsLayer = el("div", { class: "cf-layer-cursors" });
117
+ bindLayer(cursorsLayer, [cursors as Signal<unknown>], () =>
118
+ cursors.value.map((c) => renderCursor(c)),
119
+ );
120
+ root.appendChild(cursorsLayer);
121
+
122
+ // 6. Toolbar — skipped entirely in headless mode.
123
+ if (!options.headless) {
124
+ const toolbarLayer = el("div", { class: "cf-layer-toolbar" });
125
+ bindLayer(
126
+ toolbarLayer,
127
+ [
128
+ mode as Signal<unknown>,
129
+ feedbackTool as Signal<unknown>,
130
+ annotations as Signal<unknown>,
131
+ multiSelection as Signal<unknown>,
132
+ ],
133
+ () => renderToolbar(),
134
+ );
135
+ root.appendChild(toolbarLayer);
136
+ }
137
+
138
+ // 7. Composer — rebuilt only when the draft opens/closes.
139
+ const composerLayer = el("div", { class: "cf-layer-composer" });
140
+ bindLayer(composerLayer, [draft as Signal<unknown>], () =>
141
+ draft.value ? renderComposer(draft.value) : null,
142
+ );
143
+ root.appendChild(composerLayer);
144
+
145
+ // 8. Thread panel.
146
+ const threadLayer = el("div", { class: "cf-layer-thread" });
147
+ bindLayer(
148
+ threadLayer,
149
+ [activeThreadId as Signal<unknown>, annotations as Signal<unknown>],
150
+ () => (activeThreadId.value ? renderThreadPanel(activeThreadId.value) : null),
151
+ );
152
+ root.appendChild(threadLayer);
153
+
154
+ appRoot.appendChild(root);
155
+ }
package/src/ui/styles.ts CHANGED
@@ -108,6 +108,29 @@ export const STYLES = `
108
108
  pointer-events: none;
109
109
  }
110
110
 
111
+ /* Hover-to-preview card — shown next to a pin on mouseenter. */
112
+ .cf-pin-preview {
113
+ position: absolute;
114
+ top: 26px; left: 0;
115
+ max-width: 240px;
116
+ width: max-content;
117
+ pointer-events: none;
118
+ background: var(--cf-bg);
119
+ border: 1px solid var(--cf-border);
120
+ border-radius: 9px;
121
+ padding: 7px 10px;
122
+ font: 500 12px -apple-system, system-ui;
123
+ line-height: 1.4;
124
+ color: #f4f4f5;
125
+ white-space: normal;
126
+ overflow-wrap: anywhere;
127
+ backdrop-filter: blur(16px) saturate(140%);
128
+ box-shadow: 0 8px 28px rgba(0,0,0,0.5);
129
+ z-index: 7;
130
+ animation: cf-rise .14s cubic-bezier(.16,1,.3,1) both;
131
+ }
132
+ .cf-overlay[data-theme="light"] .cf-pin-preview { color: #18181b; }
133
+
111
134
  @keyframes cf-pin-pop {
112
135
  0% { transform: scale(0); opacity: 0; }
113
136
  60% { transform: scale(1.25); opacity: 1; }