@coframe-gtm/annotations 1.0.4 → 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.
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Toolbar — minimal, logo-first control surface.
3
+ *
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.
10
+ */
11
+
12
+ import {
13
+ activeThreadId,
14
+ annotations,
15
+ collapsed,
16
+ feedbackTool,
17
+ hoverBox,
18
+ mode,
19
+ multiSelection,
20
+ primaryAction,
21
+ toolbarPos,
22
+ } from "../store.js";
23
+ import { commitMultiSelection } from "../picker.js";
24
+ import { api } from "../api.js";
25
+ import { brandGlyph } from "./brand.js";
26
+ import { el } from "./dom.js";
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
+
91
+ export function hintFor(tool: string): string {
92
+ if (tool === "text") return "Select text on the page to annotate it.";
93
+ if (tool === "multi") return "Click elements to group them, then commit.";
94
+ return "Click any element to comment. Esc to exit.";
95
+ }
96
+
97
+ export function SegButton(label: string, on: boolean, onClick: () => void): HTMLElement {
98
+ return el("button", { class: `cf-seg-btn${on ? " on" : ""}`, onClick }, label);
99
+ }
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
+
126
+ export function renderToolbar(): HTMLElement {
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";
156
+ const multi = multiSelection.value.length;
157
+
158
+ const head = el(
159
+ "div",
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.
164
+ el(
165
+ "button",
166
+ { class: "cf-collapse", title: "Collapse to icon", onClick: () => (collapsed.value = true) },
167
+ brandGlyph(),
168
+ ),
169
+ el(
170
+ "div",
171
+ { class: "cf-seg" },
172
+ SegButton("View", mode.value === "view", () => {
173
+ mode.value = "view";
174
+ hoverBox.value = null;
175
+ multiSelection.value = [];
176
+ }),
177
+ SegButton("Comment", active, () => {
178
+ mode.value = "feedback";
179
+ }),
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,
232
+ active
233
+ ? el(
234
+ "div",
235
+ { class: "cf-seg cf-tools" },
236
+ SegButton("Element", feedbackTool.value === "element", () => {
237
+ feedbackTool.value = "element";
238
+ multiSelection.value = [];
239
+ }),
240
+ SegButton("Text", feedbackTool.value === "text", () => {
241
+ feedbackTool.value = "text";
242
+ multiSelection.value = [];
243
+ }),
244
+ SegButton("Multi", feedbackTool.value === "multi", () => {
245
+ feedbackTool.value = "multi";
246
+ }),
247
+ )
248
+ : null,
249
+ active && feedbackTool.value === "multi" && multi
250
+ ? el(
251
+ "button",
252
+ { class: "cf-btn cf-btn-primary cf-commit", onClick: commitMultiSelection },
253
+ `Comment ${multi} element${multi === 1 ? "" : "s"}`,
254
+ )
255
+ : null,
256
+ active ? el("div", { class: "cf-hint" }, hintFor(feedbackTool.value)) : null,
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;
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/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
+ }
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
+ }
@@ -0,0 +1,163 @@
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
+ primaryAction,
34
+ brandLogo,
35
+ collapsed,
36
+ theme,
37
+ toolbarPos,
38
+ viewportTick,
39
+ } from "../store.js";
40
+ import type { Signal } from "../signal.js";
41
+ import { boxStyle } from "./helpers.js";
42
+ import { renderToolbar } from "./Toolbar.js";
43
+ import { MultiBox, renderPin } from "./Pins.js";
44
+ import { renderCursor } from "./Cursors.js";
45
+ import { renderComposer } from "./Composer.js";
46
+ import { renderThreadPanel } from "./ThreadPanel.js";
47
+ import { STYLES } from "./styles.js";
48
+ import { el } from "./dom.js";
49
+
50
+ interface MountOptions {
51
+ /** When true the toolbar layer is not mounted (parent frame drives mode). */
52
+ headless?: boolean;
53
+ }
54
+
55
+ /** Wire a layer container to a set of signals; re-render on any change. */
56
+ function bindLayer(
57
+ container: HTMLElement,
58
+ deps: Signal<unknown>[],
59
+ render: () => Node | Node[] | null,
60
+ ): void {
61
+ const paint = (): void => {
62
+ container.replaceChildren();
63
+ const out = render();
64
+ if (out === null) return;
65
+ if (Array.isArray(out)) container.append(...out);
66
+ else container.append(out);
67
+ };
68
+ paint();
69
+ for (const dep of deps) dep.subscribe(paint);
70
+ }
71
+
72
+ export function mountOverlay(
73
+ appRoot: HTMLElement,
74
+ options: MountOptions = {},
75
+ ): void {
76
+ const root = el("div", { class: "cf-overlay" });
77
+
78
+ const setTheme = (): void => {
79
+ root.setAttribute("data-theme", theme.value === "auto" ? "dark" : theme.value);
80
+ };
81
+ setTheme();
82
+ theme.subscribe(setTheme);
83
+
84
+ // 1. Styles — static, once.
85
+ const style = document.createElement("style");
86
+ style.textContent = STYLES;
87
+ root.appendChild(style);
88
+
89
+ // 2. Hover highlight (hidden while composing).
90
+ const hoverLayer = el("div", { class: "cf-layer-hover" });
91
+ bindLayer(hoverLayer, [hoverBox as Signal<unknown>, draft as Signal<unknown>], () => {
92
+ const box = hoverBox.value;
93
+ if (!box || draft.value) return null;
94
+ return el("div", { class: "cf-hover", style: boxStyle(box) });
95
+ });
96
+ root.appendChild(hoverLayer);
97
+
98
+ // 3. Multi-select boxes.
99
+ const multiLayer = el("div", { class: "cf-layer-multi" });
100
+ bindLayer(
101
+ multiLayer,
102
+ [multiSelection as Signal<unknown>, viewportTick as Signal<unknown>],
103
+ () => multiSelection.value.map((element) => MultiBox(element)),
104
+ );
105
+ root.appendChild(multiLayer);
106
+
107
+ // 4. Pins.
108
+ const pinsLayer = el("div", { class: "cf-layer-pins" });
109
+ bindLayer(
110
+ pinsLayer,
111
+ [annotations as Signal<unknown>, viewportTick as Signal<unknown>],
112
+ () =>
113
+ annotations.value
114
+ .map((a, i) => renderPin(a, i + 1))
115
+ .filter((n): n is HTMLElement => n !== null),
116
+ );
117
+ root.appendChild(pinsLayer);
118
+
119
+ // 5. Cursors (multi-cursor — one labeled cursor per entry).
120
+ const cursorsLayer = el("div", { class: "cf-layer-cursors" });
121
+ bindLayer(cursorsLayer, [cursors as Signal<unknown>], () =>
122
+ cursors.value.map((c) => renderCursor(c)),
123
+ );
124
+ root.appendChild(cursorsLayer);
125
+
126
+ // 6. Toolbar — skipped entirely in headless mode.
127
+ if (!options.headless) {
128
+ const toolbarLayer = el("div", { class: "cf-layer-toolbar" });
129
+ bindLayer(
130
+ toolbarLayer,
131
+ [
132
+ mode as Signal<unknown>,
133
+ feedbackTool as Signal<unknown>,
134
+ annotations as Signal<unknown>,
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>,
140
+ ],
141
+ () => renderToolbar(),
142
+ );
143
+ root.appendChild(toolbarLayer);
144
+ }
145
+
146
+ // 7. Composer — rebuilt only when the draft opens/closes.
147
+ const composerLayer = el("div", { class: "cf-layer-composer" });
148
+ bindLayer(composerLayer, [draft as Signal<unknown>], () =>
149
+ draft.value ? renderComposer(draft.value) : null,
150
+ );
151
+ root.appendChild(composerLayer);
152
+
153
+ // 8. Thread panel.
154
+ const threadLayer = el("div", { class: "cf-layer-thread" });
155
+ bindLayer(
156
+ threadLayer,
157
+ [activeThreadId as Signal<unknown>, annotations as Signal<unknown>],
158
+ () => (activeThreadId.value ? renderThreadPanel(activeThreadId.value) : null),
159
+ );
160
+ root.appendChild(threadLayer);
161
+
162
+ appRoot.appendChild(root);
163
+ }