annotate-kit 0.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,1043 @@
1
+ import { Component, useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type CSSProperties, type ReactNode } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { domToBlob } from 'modern-screenshot'
4
+ import {
5
+ X, Trash2, Copy, Download, Crosshair, MessageSquarePlus, Camera, ListChecks, CornerDownRight, Target,
6
+ Eraser, Check, MapPin, Square, ArrowUpRight, Circle, Spline, Undo2, Eye, EyeOff, GripVertical, Maximize2,
7
+ Lock, Globe, Settings, Pencil, Keyboard, Send, Filter, Monitor, TerminalSquare, Trash, Highlighter,
8
+ } from 'lucide-react'
9
+ import type { StorageAdapter } from './adapter'
10
+ import {
11
+ ANN_TYPES, ANN_PRIORITIES, ANN_STATUSES, OPEN_STATUSES, ANN_MODES, ANN_COLORS, annDot, annTypeLabel,
12
+ annStatusLabel, annShapeLabel,
13
+ } from './constants'
14
+ import { buildDevPrompt } from './prompt'
15
+ import { sanitizeSnapshot } from './snapshot'
16
+ import { ScreenshotEditor } from './ScreenshotEditor'
17
+ import type { Delivery } from './delivery'
18
+ import type {
19
+ Annotation, AnnType, AnnPriority, AnnStatus, AnnShape, AnnPoint, AnnVisibility, AnnMeta, AnnLog, AnnNetLog, AnnotateAccess,
20
+ } from './types'
21
+
22
+ export type AnnotateProps = {
23
+ /** Your backend binding — see `annotate-kit/supabase` and `annotate-kit/firebase`, or write your own. */
24
+ adapter: StorageAdapter
25
+ /** Current route path. Pass your router's pathname so marks are page-aware in an SPA.
26
+ * Omit to read `window.location.pathname` (updates on browser back/forward only). */
27
+ route?: string
28
+ /** Navigate to a path (used by "find on page" across routes). Default: full-page `window.location.assign`.
29
+ * Pass your router's navigate for smooth SPA jumps. */
30
+ onNavigate?: (path: string) => void
31
+ /** Override the current user (else taken from the adapter's access.me). Used for author attribution. */
32
+ user?: { email?: string; name?: string; role?: string }
33
+ /** Hook the widget's toasts into your app's notifications. Omit for a built-in mini-toast. */
34
+ onToast?: (kind: 'success' | 'error', message: string) => void
35
+ /** How often (ms) to refresh marks while the tool/panel is open. Default 15000. */
36
+ pollMs?: number
37
+ /** Capture a rolling console/JS-error buffer with each mark (secrets may appear in logs).
38
+ * Default false — opt in only if your team needs console context for bug reports. */
39
+ captureConsole?: boolean
40
+ /** Store the full URL (incl. query string + hash) instead of just origin + pathname.
41
+ * Default false — query strings often carry tokens/PII. */
42
+ captureFullUrl?: boolean
43
+ /** Capture on-screen element text (target text + surrounding labels) with each mark.
44
+ * Default true; set false for apps that display sensitive data. */
45
+ captureText?: boolean
46
+ /** Enable bare single-key tool shortcuts (P/B/A/C/D). Default true. Set false to avoid
47
+ * collisions with the host app's own single-key shortcuts. */
48
+ singleKeyShortcuts?: boolean
49
+ /** Capture a rolling buffer of failed/slow network requests (fetch + XHR) with each mark.
50
+ * Default false — URLs are trimmed + redacted, but opt in deliberately. */
51
+ captureNetwork?: boolean
52
+ /** Capture a sanitised HTML snapshot of the marked element (scripts/handlers stripped, password
53
+ * values blanked, truncated). Default false — the DOM can contain sensitive content. */
54
+ captureDomSnapshot?: boolean
55
+ /** Blur password fields and any `[data-annot-redact]` element in screenshots. Default true. */
56
+ redactScreenshots?: boolean
57
+ /** Colour theme for the widget's own panels/dock: 'light' (default), 'dark', or 'auto'
58
+ * (follows the OS via prefers-color-scheme). Does not affect the host page. */
59
+ theme?: 'light' | 'dark' | 'auto'
60
+ /** Override the brand accent colour (any valid CSS colour, e.g. '#0ea5e9'). Themes buttons, active
61
+ * tools, links, and highlights. Invalid values are ignored. */
62
+ accent?: string
63
+ /** Redact secrets/PII from captured text + console before it is stored. Return the cleaned
64
+ * string. Default masks JWTs, bearer tokens, emails, and long digit runs (cards/SSNs). */
65
+ redact?: (text: string) => string
66
+ /** Optional "send to…" targets shown in the Send panel (GitHub issue / webhook / Slack / your own).
67
+ * Off by default — see the `githubIssueDelivery` / `createWebhookDelivery` / `createSlackDelivery` helpers. */
68
+ deliveries?: Delivery[]
69
+ }
70
+
71
+ type Tool = null | 'pin' | AnnShape
72
+ const MODE_ICON: Record<string, typeof MapPin> = { pin: MapPin, rect: Square, arrow: ArrowUpRight, circle: Circle, freehand: Spline }
73
+ const cn = (...a: (string | false | null | undefined)[]): string => a.filter(Boolean).join(' ')
74
+ const errMsg = (e: unknown): string => (e instanceof Error ? e.message : String(e))
75
+ // Only ever render a URL we trust into href/src (blocks javascript:/vbscript: stored-XSS).
76
+ const safeUrl = (u?: string | null): string | undefined => (u && /^(https?:|blob:|data:image\/(png|jpe?g|gif|webp|avif))/i.test(u) ? u : undefined)
77
+ // A stored route is only safe to navigate to if it's a same-origin absolute path (not '//evil.com').
78
+ const isSafePath = (p?: string | null): p is string => !!p && /^\/(?!\/)/.test(p)
79
+ // A brand accent must be a plain CSS colour before we inject it into a <style> (prevents CSS injection).
80
+ const safeColor = (c?: string): string | undefined => (c && /^(#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla)\([\d%.,\s/]+\))$/.test(c.trim()) ? c.trim() : undefined)
81
+ // Default redactor: mask common secrets/PII in captured text + console before it is persisted.
82
+ const defaultRedact = (s: string): string => String(s ?? '')
83
+ .replace(/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}/g, '[redacted-jwt]')
84
+ .replace(/\b(bearer|token|api[_-]?key|secret|password|authorization)\b\s*[:=]?\s*[A-Za-z0-9._~+/=-]{6,}/gi, '$1 [redacted]')
85
+ .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, '[redacted-email]')
86
+ .replace(/\b(?:\d[ -]?){13,19}\b/g, '[redacted-number]')
87
+ // Trap Tab focus inside an open dialog (a11y): focus the first control, cycle Tab within, restore on close.
88
+ function useFocusTrap(active: boolean) {
89
+ const ref = useRef<HTMLDivElement | null>(null)
90
+ useEffect(() => {
91
+ if (!active) return
92
+ const el = ref.current
93
+ if (!el) return
94
+ const SEL = 'a[href],button:not([disabled]),input:not([disabled]),textarea:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'
95
+ const list = () => Array.from(el.querySelectorAll<HTMLElement>(SEL)).filter((x) => x.offsetParent !== null)
96
+ const restore = document.activeElement as HTMLElement | null
97
+ const items = list()
98
+ ;(items[0] ?? el).focus()
99
+ const onKey = (e: KeyboardEvent) => {
100
+ if (e.key !== 'Tab') return
101
+ const f = list()
102
+ if (!f.length) return
103
+ const first = f[0], last = f[f.length - 1]
104
+ if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus() }
105
+ else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus() }
106
+ }
107
+ el.addEventListener('keydown', onKey)
108
+ return () => { el.removeEventListener('keydown', onKey); restore?.focus?.() }
109
+ }, [active])
110
+ return ref
111
+ }
112
+
113
+ // If the overlay itself throws while rendering a bad row, degrade to nothing rather than crash the host.
114
+ class OverlayBoundary extends Component<{ children: ReactNode }, { failed: boolean }> {
115
+ state = { failed: false }
116
+ static getDerivedStateFromError() { return { failed: true } }
117
+ componentDidCatch(e: unknown) { console.warn('[annotate] overlay render error', e) }
118
+ render() { return this.state.failed ? null : this.props.children }
119
+ }
120
+
121
+ // ---- module-level capture: environment + a rolling console/JS-error buffer (bug-report context) ----
122
+ function captureEnv(fullUrl: boolean): AnnMeta {
123
+ const ua = navigator.userAgent
124
+ const browser = /edg/i.test(ua) ? 'Edge' : /opr|opera/i.test(ua) ? 'Opera' : /chrome/i.test(ua) ? 'Chrome' : /firefox/i.test(ua) ? 'Firefox' : /safari/i.test(ua) ? 'Safari' : 'Browser'
125
+ const os = /windows/i.test(ua) ? 'Windows' : /mac os|macintosh/i.test(ua) ? 'macOS' : /android/i.test(ua) ? 'Android' : /iphone|ipad|ios/i.test(ua) ? 'iOS' : /linux/i.test(ua) ? 'Linux' : 'OS'
126
+ const device = /mobi/i.test(ua) ? 'Mobile' : /tablet|ipad/i.test(ua) ? 'Tablet' : 'Desktop'
127
+ return { browser, os, device, viewport: `${window.innerWidth}×${window.innerHeight}`, screen: `${window.screen?.width}×${window.screen?.height}`, dpr: Math.round((window.devicePixelRatio || 1) * 100) / 100, url: fullUrl ? window.location.href : window.location.origin + window.location.pathname, ua: ua.slice(0, 200) }
128
+ }
129
+ const _logBuf: AnnLog[] = []
130
+ let _logHooked = false
131
+ // Install the console/JS-error capture — ONLY when the tool is active and the integrator opts in.
132
+ // Returns an uninstall fn that restores the original console (no permanent global monkeypatch at import).
133
+ function hookConsole(redact: (s: string) => string): () => void {
134
+ if (_logHooked || typeof window === 'undefined') return () => {}
135
+ _logHooked = true
136
+ const fmt = (args: unknown[]) => redact(args.map((a) => typeof a === 'string' ? a : a instanceof Error ? (a.stack || a.message) : (() => { try { return JSON.stringify(a) } catch { return String(a) } })()).join(' ')).slice(0, 500)
137
+ const push = (level: string, args: unknown[]) => { try { _logBuf.push({ level, text: fmt(args), at: new Date().toISOString() }); if (_logBuf.length > 50) _logBuf.shift() } catch { /* ignore */ } }
138
+ const orig = { error: console.error, warn: console.warn }
139
+ const onErr = (e: ErrorEvent) => push('error', [e.message + (e.filename ? ` @ ${e.filename}:${e.lineno}:${e.colno}` : '')])
140
+ const onRej = (e: PromiseRejectionEvent) => push('error', ['Unhandled promise rejection: ' + String(e.reason)])
141
+ const errOverride = (...a: unknown[]) => { push('error', a); orig.error(...a) }
142
+ const warnOverride = (...a: unknown[]) => { push('warn', a); orig.warn(...a) }
143
+ console.error = errOverride
144
+ console.warn = warnOverride
145
+ window.addEventListener('error', onErr)
146
+ window.addEventListener('unhandledrejection', onRej)
147
+ return () => {
148
+ _logHooked = false
149
+ _logBuf.length = 0
150
+ if (console.error === errOverride) console.error = orig.error // don't clobber a wrapper installed after us
151
+ if (console.warn === warnOverride) console.warn = orig.warn
152
+ window.removeEventListener('error', onErr)
153
+ window.removeEventListener('unhandledrejection', onRej)
154
+ }
155
+ }
156
+ const recentLogs = () => _logBuf.slice(-8)
157
+
158
+ // ---- module-level network capture (opt-in): rolling buffer of failed/slow fetch + XHR calls ----
159
+ const _netBuf: AnnNetLog[] = []
160
+ let _netHooked = false
161
+ function hookNetwork(redact: (s: string) => string): () => void {
162
+ if (_netHooked || typeof window === 'undefined' || typeof window.fetch !== 'function') return () => {}
163
+ _netHooked = true
164
+ const trim = (u: string) => { try { const x = new URL(u, location.href); return redact(x.origin + x.pathname) } catch { return redact(String(u).slice(0, 200)) } }
165
+ const record = (method: string, url: string, status: number | undefined, ms: number, ok: boolean) => {
166
+ if (ok && status !== undefined && status < 400 && ms < 2000) return // keep only errors / failures / slow calls
167
+ try { _netBuf.push({ method, url: trim(url), status, ms: Math.round(ms), ok, at: new Date().toISOString() }); if (_netBuf.length > 50) _netBuf.shift() } catch { /* ignore */ }
168
+ }
169
+ const origFetch = window.fetch
170
+ const boundFetch = origFetch.bind(window)
171
+ const wrapped = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
172
+ const start = Date.now()
173
+ const method = (init?.method || (input instanceof Request ? input.method : undefined) || 'GET').toUpperCase()
174
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as Request).url
175
+ try { const res = await boundFetch(input, init); record(method, url, res.status, Date.now() - start, res.ok); return res }
176
+ catch (e) { record(method, url, undefined, Date.now() - start, false); throw e }
177
+ }
178
+ const wrappedFetch = wrapped as typeof window.fetch
179
+ window.fetch = wrappedFetch
180
+ const XHR = window.XMLHttpRequest
181
+ const origOpen = XHR.prototype.open
182
+ const origSend = XHR.prototype.send
183
+ const openOverride = function (this: XMLHttpRequest, method: string, url: string | URL, ...rest: any[]) { // eslint-disable-line @typescript-eslint/no-explicit-any
184
+ ;(this as any).__annot = { method: String(method).toUpperCase(), url: String(url), start: 0 } // eslint-disable-line @typescript-eslint/no-explicit-any
185
+ return (origOpen as any).call(this, method, url, ...rest) // eslint-disable-line @typescript-eslint/no-explicit-any
186
+ } as typeof XHR.prototype.open
187
+ const sendOverride = function (this: XMLHttpRequest, ...args: any[]) { // eslint-disable-line @typescript-eslint/no-explicit-any
188
+ const info = (this as any).__annot // eslint-disable-line @typescript-eslint/no-explicit-any
189
+ if (info) { info.start = Date.now(); this.addEventListener('loadend', () => record(info.method, info.url, this.status || undefined, Date.now() - info.start, this.status >= 200 && this.status < 400), { once: true }) }
190
+ return (origSend as any).apply(this, args) // eslint-disable-line @typescript-eslint/no-explicit-any
191
+ } as typeof XHR.prototype.send
192
+ XHR.prototype.open = openOverride
193
+ XHR.prototype.send = sendOverride
194
+ return () => {
195
+ _netHooked = false
196
+ _netBuf.length = 0
197
+ if (window.fetch === wrappedFetch) window.fetch = origFetch // don't clobber a wrapper installed after us
198
+ if (XHR.prototype.open === openOverride) XHR.prototype.open = origOpen
199
+ if (XHR.prototype.send === sendOverride) XHR.prototype.send = origSend
200
+ }
201
+ }
202
+ const recentNet = () => _netBuf.slice(-8)
203
+
204
+ // Temporarily blur password fields + [data-annot-redact] elements while a screenshot is captured.
205
+ function maskForCapture(): () => void {
206
+ const els = Array.from(document.querySelectorAll<HTMLElement>('input[type="password" i], [data-annot-redact]'))
207
+ const prev = els.map((el) => el.style.filter)
208
+ els.forEach((el) => { el.style.filter = 'blur(10px)' })
209
+ return () => els.forEach((el, i) => { el.style.filter = prev[i] })
210
+ }
211
+
212
+ type Draft = {
213
+ kind: 'pin' | 'shape'; shape_type?: AnnShape
214
+ page_route: string; page_title: string; target_selector: string; target_text: string
215
+ pos_x: number; pos_y: number; x2?: number; y2?: number; points?: AnnPoint[]
216
+ viewport_w: number; viewport_h: number
217
+ note: string; type: AnnType; priority: AnnPriority; color: string; visibility: AnnVisibility; shot: boolean
218
+ styles?: Record<string, string> | null; el_context?: string[] | null; meta?: AnnMeta; console_logs?: AnnLog[]; network_logs?: AnnNetLog[]; dom_snapshot?: string | null
219
+ cx: number; cy: number
220
+ }
221
+ type Drawing = { shape_type: AnnShape; x: number; y: number; x2: number; y2: number; points: AnnPoint[] } | null
222
+
223
+ // ---- element identity: a resilient selector + a re-finder so a pin can be relocated on revisit ----
224
+ function cssSelector(el: Element | null): string {
225
+ if (!el || el === document.body) return 'body'
226
+ const parts: string[] = []
227
+ let node: Element | null = el
228
+ let depth = 0
229
+ while (node && node.nodeType === 1 && node !== document.body && depth < 6) {
230
+ const h = node as HTMLElement
231
+ if (h.id) { parts.unshift(`#${CSS.escape(h.id)}`); break }
232
+ const stable = h.getAttribute('data-testid') || h.getAttribute('name') || h.getAttribute('aria-label')
233
+ if (stable) { parts.unshift(`${node.tagName.toLowerCase()}[${h.getAttribute('data-testid') ? 'data-testid' : h.getAttribute('name') ? 'name' : 'aria-label'}="${CSS.escape(stable)}"]`); break }
234
+ let part = node.tagName.toLowerCase()
235
+ const cls = (node.getAttribute('class') || '').trim().split(/\s+/).filter(Boolean).slice(0, 2).map((c) => CSS.escape(c))
236
+ if (cls.length) part += '.' + cls.join('.')
237
+ const parent: Element | null = node.parentElement
238
+ if (parent) {
239
+ const sibs = Array.from(parent.children).filter((c) => c.tagName === node!.tagName)
240
+ if (sibs.length > 1) part += `:nth-of-type(${sibs.indexOf(node) + 1})`
241
+ }
242
+ parts.unshift(part)
243
+ node = node.parentElement
244
+ depth++
245
+ }
246
+ return parts.join(' > ')
247
+ }
248
+ const isVisible = (el: Element | null): el is HTMLElement => {
249
+ if (!el) return false
250
+ const r = (el as HTMLElement).getBoundingClientRect()
251
+ return r.width > 0 && r.height > 0
252
+ }
253
+ function findEl(a: { target_selector?: string; target_text?: string }): HTMLElement | null {
254
+ if (a.target_selector) { try { const el = document.querySelector(a.target_selector); if (isVisible(el)) return el as HTMLElement } catch { /* invalid selector */ } }
255
+ const txt = (a.target_text || '').trim()
256
+ if (txt && txt.length >= 2 && txt.length <= 80) {
257
+ const els = Array.from(document.querySelectorAll('button, a, h1, h2, h3, span, p, td, th, label, div'))
258
+ for (const el of els) { if ((el.textContent || '').replace(/\s+/g, ' ').trim() === txt && isVisible(el) && !el.closest('[data-annot-ui]')) return el as HTMLElement }
259
+ }
260
+ return null
261
+ }
262
+ const elLabel = (el: HTMLElement): string => {
263
+ const tag = el.tagName.toLowerCase()
264
+ const txt = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 36)
265
+ return txt ? `${tag} · “${txt}”` : tag
266
+ }
267
+ const rgbToHex = (v: string): string => {
268
+ const m = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(v || '')
269
+ if (!m) return v || ''
270
+ if (m[4] !== undefined && Number(m[4]) === 0) return 'transparent'
271
+ const h = (n: string) => Number(n).toString(16).padStart(2, '0')
272
+ return `#${h(m[1])}${h(m[2])}${h(m[3])}`.toUpperCase()
273
+ }
274
+ function captureStyles(el: HTMLElement): Record<string, string> | null {
275
+ try {
276
+ const cs = getComputedStyle(el); const r = el.getBoundingClientRect()
277
+ return { color: rgbToHex(cs.color), background: rgbToHex(cs.backgroundColor), fontSize: cs.fontSize, fontWeight: cs.fontWeight, radius: cs.borderRadius, padding: cs.padding, size: `${Math.round(r.width)}×${Math.round(r.height)}` }
278
+ } catch { return null }
279
+ }
280
+ function captureContext(el: HTMLElement): string[] {
281
+ const out: string[] = []; let node = el.parentElement; let i = 0
282
+ const own = (el.textContent || '').trim()
283
+ while (node && node.tagName !== 'BODY' && out.length < 3 && i < 12) {
284
+ if (!node.closest('[data-annot-ui]')) {
285
+ const aria = node.getAttribute('aria-label')
286
+ let lbl = aria || ''
287
+ if (!lbl) { const first = (node.textContent || '').trim().split('\n')[0].trim(); if (first.length >= 3 && first.length <= 52) lbl = first }
288
+ if (lbl && lbl !== own && !out.includes(lbl)) out.push(lbl)
289
+ }
290
+ node = node.parentElement; i++
291
+ }
292
+ return out
293
+ }
294
+
295
+ function Highlight({ el, tone = '#6366f1', label }: { el: HTMLElement | null; tone?: string; label?: string }) {
296
+ if (!el) return null
297
+ const r = el.getBoundingClientRect()
298
+ if (r.width === 0 && r.height === 0) return null
299
+ return (
300
+ <div data-annot-ui className="pointer-events-none fixed z-[9996] rounded-md" style={{ left: r.left - 3, top: r.top - 3, width: r.width + 6, height: r.height + 6, boxShadow: `0 0 0 2px ${tone}, 0 0 0 6px ${tone}22`, transition: 'all .08s' }}>
301
+ {label && <span className="absolute -top-5 left-0 whitespace-nowrap rounded bg-fg px-1.5 py-0.5 text-[10px] font-medium text-white">{label}</span>}
302
+ </div>
303
+ )
304
+ }
305
+
306
+ function ShapeSvg({ s, dash }: { s: { shape_type?: string | null; pos_x: number; pos_y: number; x2?: number | null; y2?: number | null; points?: AnnPoint[] | null; color?: string | null }; dash?: boolean }) {
307
+ const stroke = s.color || '#6366f1'; const sw = 2.5; const d = dash ? '5 4' : undefined
308
+ const x = s.pos_x, y = s.pos_y, x2 = s.x2 ?? s.pos_x, y2 = s.y2 ?? s.pos_y
309
+ if (s.shape_type === 'rect') return <rect x={Math.min(x, x2)} y={Math.min(y, y2)} width={Math.abs(x2 - x)} height={Math.abs(y2 - y)} rx="5" fill="none" stroke={stroke} strokeWidth={sw} strokeDasharray={d} />
310
+ if (s.shape_type === 'circle') return <ellipse cx={(x + x2) / 2} cy={(y + y2) / 2} rx={Math.abs(x2 - x) / 2} ry={Math.abs(y2 - y) / 2} fill="none" stroke={stroke} strokeWidth={sw} strokeDasharray={d} />
311
+ if (s.shape_type === 'arrow') {
312
+ const mid = `annarr-${Math.round(x)}-${Math.round(y)}`
313
+ return (<>
314
+ <defs><marker id={mid} markerWidth="9" markerHeight="9" refX="6" refY="3" orient="auto"><path d="M0,0 L6,3 L0,6 Z" fill={stroke} /></marker></defs>
315
+ <line x1={x} y1={y} x2={x2} y2={y2} stroke={stroke} strokeWidth={sw + 0.5} strokeDasharray={d} markerEnd={`url(#${mid})`} />
316
+ </>)
317
+ }
318
+ return <polyline points={(Array.isArray(s.points) ? s.points : []).map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke={stroke} strokeWidth={sw} strokeLinejoin="round" strokeLinecap="round" strokeDasharray={d} />
319
+ }
320
+
321
+ const chip = 'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium'
322
+
323
+ export function Annotate({ adapter, route, onNavigate, user, onToast, pollMs = 15_000, captureConsole = false, captureFullUrl = false, captureText = true, singleKeyShortcuts = true, captureNetwork = false, captureDomSnapshot = false, redactScreenshots = true, theme = 'light', accent, redact, deliveries = [] }: AnnotateProps) {
324
+ // --- route + navigation (framework-agnostic) ---
325
+ const [browserPath, setBrowserPath] = useState(() => (typeof window !== 'undefined' ? window.location.pathname : '/'))
326
+ useEffect(() => {
327
+ if (route !== undefined) return
328
+ const on = () => setBrowserPath(window.location.pathname)
329
+ window.addEventListener('popstate', on)
330
+ return () => window.removeEventListener('popstate', on)
331
+ }, [route])
332
+ const pathname = route ?? browserPath
333
+ const navigate = useCallback((p: string) => { if (onNavigate) onNavigate(p); else window.location.assign(p) }, [onNavigate])
334
+ const redactFn = useCallback((s: string) => (redact ? redact(s) : defaultRedact(s)), [redact])
335
+ const accentCss = safeColor(accent)
336
+
337
+ // --- built-in mini-toast (used only when onToast is not supplied) ---
338
+ const [toasts, setToasts] = useState<{ id: number; kind: 'success' | 'error'; msg: string }[]>([])
339
+ const toastSeq = useRef(0)
340
+ const toast = useCallback((kind: 'success' | 'error', msg: string) => {
341
+ if (onToast) { onToast(kind, msg); return }
342
+ const id = ++toastSeq.current
343
+ setToasts((t) => [...t, { id, kind, msg }])
344
+ setTimeout(() => setToasts((t) => t.filter((x) => x.id !== id)), 2600)
345
+ }, [onToast])
346
+
347
+ // --- access + identity (from the adapter) ---
348
+ const [access, setAccessData] = useState<AnnotateAccess | null>(null)
349
+ const [accessLoading, setAccessLoading] = useState(true)
350
+ const [accessBusy, setAccessBusy] = useState(false)
351
+ const refreshAccess = useCallback(async () => {
352
+ try { setAccessData(await adapter.getAccess()) } catch (e) { console.warn('[annotate] getAccess failed', e) } finally { setAccessLoading(false) }
353
+ }, [adapter])
354
+ useEffect(() => { refreshAccess() }, [refreshAccess])
355
+ const canUse = !!access?.canUse
356
+ const isAdmin = !!access?.isAdmin
357
+ const seeAll = !!access?.seeAll
358
+ const enabled = !!access?.enabled
359
+ const me = user ?? access?.me ?? {}
360
+ const meEmail = me.email || ''
361
+ const meName = me.name || me.email || 'me'
362
+
363
+ // Console/JS-error capture is installed lazily — only when the tool is usable AND opted in — and
364
+ // torn down on cleanup. No global console monkeypatch happens at import time.
365
+ useEffect(() => {
366
+ if (!canUse || !captureConsole) return
367
+ return hookConsole(redactFn)
368
+ }, [canUse, captureConsole, redactFn])
369
+
370
+ // Network capture — same lazy, opt-in, cleaned-up-on-unmount treatment as the console hook.
371
+ useEffect(() => {
372
+ if (!canUse || !captureNetwork) return
373
+ return hookNetwork(redactFn)
374
+ }, [canUse, captureNetwork, redactFn])
375
+
376
+ const [tool, setTool] = useState<Tool>(null)
377
+ const [color, setColor] = useState(ANN_COLORS[0])
378
+ const [panel, setPanel] = useState(false)
379
+ const [draft, setDraft] = useState<Draft | null>(null)
380
+ const [drawing, setDrawing] = useState<Drawing>(null)
381
+ const [active, setActive] = useState<Annotation | null>(null)
382
+ const [busy, setBusy] = useState(false)
383
+ const [tick, setTick] = useState(0)
384
+ const [flashId, setFlashId] = useState<number | null>(null)
385
+ const [hoverEl, setHoverEl] = useState<HTMLElement | null>(null)
386
+ const [filter, setFilter] = useState<'all' | AnnStatus>('all')
387
+ const [typeFilter, setTypeFilter] = useState<'' | AnnType>('')
388
+ const [prioFilter, setPrioFilter] = useState<'' | AnnPriority>('')
389
+ const [thisPageOnly, setThisPageOnly] = useState(false)
390
+ const [authorFilter, setAuthorFilter] = useState('')
391
+ const [search, setSearch] = useState('')
392
+ const [reply, setReply] = useState('')
393
+ const [peek, setPeek] = useState(false)
394
+ const [shotBusy, setShotBusy] = useState(false)
395
+ const [markupBlob, setMarkupBlob] = useState<Blob | null>(null)
396
+ const [delivering, setDelivering] = useState(false)
397
+ const [sendOpen, setSendOpen] = useState(false)
398
+ const [helpOpen, setHelpOpen] = useState(false)
399
+ const sendTrap = useFocusTrap(sendOpen)
400
+ const helpTrap = useFocusTrap(helpOpen)
401
+ const panelTrap = useFocusTrap(panel)
402
+ const [editRow, setEditRow] = useState<number | null>(null)
403
+ const [hintSeen, setHintSeen] = useState(() => { try { return localStorage.getItem('annot-hint') === '1' } catch { return true } })
404
+ const [pos, setPos] = useState<{ x: number; y: number } | null>(() => {
405
+ try {
406
+ const p = JSON.parse(localStorage.getItem('annot-pos') || 'null')
407
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) {
408
+ const mx = (typeof window !== 'undefined' ? window.innerWidth : 9999) - 64
409
+ const my = (typeof window !== 'undefined' ? window.innerHeight : 9999) - 64
410
+ return { x: Math.min(Math.max(4, p.x), mx), y: Math.min(Math.max(4, p.y), my) }
411
+ }
412
+ } catch { /* ignore */ }
413
+ return null
414
+ })
415
+
416
+ const draftElRef = useRef<HTMLElement | null>(null)
417
+ const hoverRef = useRef<HTMLElement | null>(null)
418
+ const drawRef = useRef<Drawing>(null)
419
+ const undoStackRef = useRef<number[]>([])
420
+
421
+ // --- annotations list (plain state + polling while the tool/panel is open) ---
422
+ const [all, setAll] = useState<Annotation[]>([])
423
+ const refresh = useCallback(async () => {
424
+ try { setAll(await adapter.list()) } catch (e) { console.warn('[annotate] list failed', e) }
425
+ }, [adapter])
426
+ const invalidate = refresh
427
+ useEffect(() => { if (canUse) refresh() }, [canUse, refresh])
428
+ // realtime subscription (adapters that support it) — NOT re-run on tool/panel toggles.
429
+ // Backstop is a refresh when the tab regains focus (no continuous polling while idle).
430
+ useEffect(() => {
431
+ if (!canUse || !adapter.subscribe) return
432
+ const un = adapter.subscribe(() => { refresh() })
433
+ const onVis = () => { if (document.visibilityState === 'visible') refresh() }
434
+ document.addEventListener('visibilitychange', onVis)
435
+ return () => { un(); document.removeEventListener('visibilitychange', onVis) }
436
+ }, [canUse, adapter, refresh])
437
+ // fallback poll while the tool/panel is open (only for adapters without realtime)
438
+ useEffect(() => {
439
+ if (!canUse || adapter.subscribe || !(tool || panel)) return
440
+ const id = setInterval(refresh, pollMs)
441
+ return () => clearInterval(id)
442
+ }, [canUse, tool, panel, pollMs, refresh, adapter])
443
+
444
+ const openCount = all.filter((a) => OPEN_STATUSES.includes(a.status)).length
445
+
446
+ const marksShown = !peek && (!!tool || panel || flashId != null)
447
+ const pageMarks = useMemo(() => all.filter((a) => a.page_route === pathname && (OPEN_STATUSES.includes(a.status) || a.id === flashId)), [all, pathname, flashId])
448
+ // Resolve each visible pin's element ONCE per frame (keyed on scroll/resize tick) instead of
449
+ // re-running findEl (with its querySelectorAll fallback) on every render for every mark.
450
+ const markEls = useMemo(() => {
451
+ const m = new Map<number, HTMLElement | null>()
452
+ for (const a of pageMarks) m.set(a.id, a.kind === 'shape' ? null : findEl(a))
453
+ return m
454
+ }, [pageMarks, tick, pathname]) // eslint-disable-line react-hooks/exhaustive-deps
455
+
456
+ const dismissHint = () => { setHintSeen(true); try { localStorage.setItem('annot-hint', '1') } catch { /* ignore */ } }
457
+
458
+ // ---- PIN tool ----
459
+ useEffect(() => {
460
+ if (tool !== 'pin') { if (tool === null) { setHoverEl(null); hoverRef.current = null } return }
461
+ document.body.style.cursor = 'crosshair'
462
+ const onMove = (e: MouseEvent) => {
463
+ if (draftElRef.current) return
464
+ const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
465
+ const t = el && !el.closest('[data-annot-ui]') ? el : null
466
+ if (t !== hoverRef.current) { hoverRef.current = t; setHoverEl(t) }
467
+ }
468
+ const onClick = (e: MouseEvent) => {
469
+ const t = e.target as Element
470
+ if (t.closest('[data-annot-ui]')) return
471
+ e.preventDefault(); e.stopPropagation()
472
+ const el = t as HTMLElement
473
+ draftElRef.current = el; setHoverEl(el); setActive(null)
474
+ setDraft({
475
+ kind: 'pin', page_route: pathname, page_title: document.title || pathname,
476
+ target_selector: cssSelector(el), target_text: captureText ? redactFn((el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120)) : '',
477
+ pos_x: Math.round(e.pageX), pos_y: Math.round(e.pageY), viewport_w: window.innerWidth, viewport_h: window.innerHeight,
478
+ note: '', type: 'change', priority: 'med', color, visibility: 'private', shot: false,
479
+ styles: captureStyles(el), el_context: captureText ? captureContext(el).map(redactFn) : [], meta: captureEnv(captureFullUrl), console_logs: captureConsole ? recentLogs() : [], network_logs: captureNetwork ? recentNet() : [], dom_snapshot: captureDomSnapshot ? redactFn(sanitizeSnapshot(el.outerHTML)) : null,
480
+ cx: e.clientX, cy: e.clientY,
481
+ })
482
+ }
483
+ document.addEventListener('mousemove', onMove, true)
484
+ document.addEventListener('click', onClick, true)
485
+ return () => { document.body.style.cursor = ''; document.removeEventListener('mousemove', onMove, true); document.removeEventListener('click', onClick, true) }
486
+ }, [tool, pathname, color, isAdmin, canUse, captureText, captureFullUrl, captureConsole, captureNetwork, captureDomSnapshot, redactFn])
487
+
488
+ // ---- SHAPE tools ----
489
+ useEffect(() => {
490
+ if (!tool || tool === 'pin') return
491
+ document.body.style.cursor = 'crosshair'
492
+ const prevTouch = document.body.style.touchAction
493
+ document.body.style.touchAction = 'none'
494
+ const onDown = (e: PointerEvent) => {
495
+ if ((e.target as Element).closest?.('[data-annot-ui]')) return
496
+ e.preventDefault(); e.stopPropagation()
497
+ const start: Drawing = { shape_type: tool, x: e.pageX, y: e.pageY, x2: e.pageX, y2: e.pageY, points: [{ x: e.pageX, y: e.pageY }] }
498
+ drawRef.current = start; setDrawing(start); setActive(null)
499
+ }
500
+ const onMove = (e: PointerEvent) => {
501
+ const d = drawRef.current; if (!d) return
502
+ e.preventDefault()
503
+ const next: Drawing = tool === 'freehand'
504
+ ? { ...d, x2: e.pageX, y2: e.pageY, points: (Math.hypot(e.pageX - d.points[d.points.length - 1].x, e.pageY - d.points[d.points.length - 1].y) > 3 ? [...d.points, { x: e.pageX, y: e.pageY }] : d.points) }
505
+ : { ...d, x2: e.pageX, y2: e.pageY }
506
+ drawRef.current = next; setDrawing(next)
507
+ }
508
+ const onUp = (e: PointerEvent) => {
509
+ const d = drawRef.current; drawRef.current = null; setDrawing(null)
510
+ if (!d) return
511
+ const w = Math.abs(d.x2 - d.x), h = Math.abs(d.y2 - d.y)
512
+ if (d.shape_type === 'freehand' ? d.points.length < 3 : (w < 8 && h < 8)) return
513
+ const anchor = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null
514
+ setDraft({
515
+ kind: 'shape', shape_type: d.shape_type, page_route: pathname, page_title: document.title || pathname,
516
+ target_selector: anchor && !anchor.closest('[data-annot-ui]') ? cssSelector(anchor) : '', target_text: '',
517
+ pos_x: Math.round(d.x), pos_y: Math.round(d.y), x2: Math.round(d.x2), y2: Math.round(d.y2), points: d.shape_type === 'freehand' ? d.points : undefined,
518
+ viewport_w: window.innerWidth, viewport_h: window.innerHeight,
519
+ note: '', type: 'change', priority: 'med', color, visibility: 'private', shot: false,
520
+ meta: captureEnv(captureFullUrl), console_logs: captureConsole ? recentLogs() : [], network_logs: captureNetwork ? recentNet() : [], cx: e.clientX, cy: e.clientY,
521
+ })
522
+ }
523
+ document.addEventListener('pointerdown', onDown, true)
524
+ document.addEventListener('pointermove', onMove, { capture: true, passive: false })
525
+ document.addEventListener('pointerup', onUp, true)
526
+ document.addEventListener('pointercancel', onUp, true)
527
+ return () => {
528
+ document.body.style.cursor = ''; document.body.style.touchAction = prevTouch
529
+ document.removeEventListener('pointerdown', onDown, true)
530
+ document.removeEventListener('pointermove', onMove, { capture: true } as EventListenerOptions)
531
+ document.removeEventListener('pointerup', onUp, true)
532
+ document.removeEventListener('pointercancel', onUp, true)
533
+ drawRef.current = null; setDrawing(null)
534
+ }
535
+ }, [tool, pathname, color, isAdmin, canUse, captureText, captureFullUrl, captureConsole, captureNetwork, captureDomSnapshot, redactFn])
536
+
537
+ useEffect(() => {
538
+ if (!canUse) return
539
+ let raf = 0
540
+ const on = () => { if (raf) return; raf = requestAnimationFrame(() => { raf = 0; setTick((x) => x + 1) }) }
541
+ window.addEventListener('scroll', on, true); window.addEventListener('resize', on)
542
+ return () => { if (raf) cancelAnimationFrame(raf); window.removeEventListener('scroll', on, true); window.removeEventListener('resize', on) }
543
+ }, [canUse])
544
+
545
+ useEffect(() => {
546
+ if (!canUse) return // never attach global key handlers for a viewer who can't use the tool
547
+ const h = (e: KeyboardEvent) => {
548
+ const typing = /input|textarea|select/i.test((e.target as Element)?.tagName || '') || (e.target as HTMLElement)?.isContentEditable
549
+ if (e.key === 'Escape') { if (helpOpen) setHelpOpen(false); else if (sendOpen) setSendOpen(false); else if (draft) closeDraft(); else if (panel) setPanel(false); else if (tool) setTool(null); return }
550
+ if (typing) return
551
+ const meta = e.ctrlKey || e.metaKey
552
+ if (meta && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undoLast(); return }
553
+ if (e.key === '?') { e.preventDefault(); setHelpOpen((v) => !v); return }
554
+ if (e.shiftKey && e.key.toLowerCase() === 'h') { e.preventDefault(); setPeek((v) => !v); return }
555
+ if (e.shiftKey && e.key.toLowerCase() === 'a') { e.preventDefault(); setTool((m) => (m === 'pin' ? null : 'pin')); return }
556
+ if (!singleKeyShortcuts) return // bare-letter tool hotkeys are opt-out (avoid host-app collisions)
557
+ const map: Record<string, Tool> = { p: 'pin', b: 'rect', a: 'arrow', c: 'circle', d: 'freehand' }
558
+ const t = map[e.key.toLowerCase()]
559
+ if (t && !e.shiftKey && !meta) { e.preventDefault(); setTool((cur) => (cur === t ? null : t)) }
560
+ }
561
+ document.addEventListener('keydown', h)
562
+ return () => document.removeEventListener('keydown', h)
563
+ }, [tool, draft, helpOpen, sendOpen, panel, canUse, singleKeyShortcuts]) // eslint-disable-line react-hooks/exhaustive-deps
564
+
565
+ useEffect(() => { if (active) { const f = all.find((a) => a.id === active.id); if (f && f !== active) setActive(f) } }, [all]) // eslint-disable-line react-hooks/exhaustive-deps
566
+
567
+ async function uploadShot(el: HTMLElement, visibility: AnnVisibility): Promise<string | null> {
568
+ const unmask = redactScreenshots ? maskForCapture() : () => {}
569
+ let blob: Blob | null = null
570
+ try { blob = await domToBlob(el, { backgroundColor: '#ffffff', scale: 2, quality: 0.95, style: { margin: '0' } }) }
571
+ catch (e) { console.warn('[annotate] screenshot failed', e) }
572
+ finally { unmask() }
573
+ try {
574
+ if (!blob) return null
575
+ const shot = new File([blob], 'shot.png', { type: blob.type || 'image/png' })
576
+ const { path } = await adapter.uploadScreenshot(shot, { visibility })
577
+ return path
578
+ } catch (e) { console.warn('[annotate] screenshot failed', e); return null }
579
+ }
580
+
581
+ async function captureBlob(full: boolean): Promise<Blob | null> {
582
+ await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 40)))
583
+ const target = full ? document.body : document.documentElement
584
+ const pageW = full ? Math.max(document.documentElement.scrollWidth, window.innerWidth) : window.innerWidth
585
+ const pageH = full ? Math.max(document.documentElement.scrollHeight, window.innerHeight) : window.innerHeight
586
+ let scale = Math.min(2, window.devicePixelRatio || 1)
587
+ // stay under browser canvas limits — cap total area AND each dimension (~16k px hard cap)
588
+ while (scale > 0.5 && (pageW * pageH * scale * scale > 24_000_000 || pageW * scale > 16000 || pageH * scale > 16000)) scale -= 0.5
589
+ const unmask = redactScreenshots ? maskForCapture() : () => {}
590
+ try {
591
+ return await domToBlob(target, {
592
+ backgroundColor: '#ffffff', scale,
593
+ width: full ? undefined : window.innerWidth, height: full ? undefined : window.innerHeight,
594
+ filter: (n: Node) => !(n instanceof HTMLElement && n.hasAttribute('data-annot-ui')),
595
+ })
596
+ } finally { unmask() }
597
+ }
598
+
599
+ async function startMarkup() {
600
+ if (shotBusy) return
601
+ setShotBusy(true)
602
+ try {
603
+ const blob = await captureBlob(false)
604
+ if (!blob) throw new Error('capture failed')
605
+ setMarkupBlob(blob)
606
+ } catch (e) { toast('error', errMsg(e) || 'Capture failed') } finally { setShotBusy(false) }
607
+ }
608
+
609
+ async function capturePage(full: boolean) {
610
+ if (shotBusy) return
611
+ setShotBusy(true)
612
+ try {
613
+ const blob = await captureBlob(full)
614
+ if (!blob) throw new Error('capture failed')
615
+ const canClip = !!navigator.clipboard && typeof window.ClipboardItem === 'function'
616
+ if (canClip) { try { await navigator.clipboard.write([new window.ClipboardItem({ 'image/png': blob })]); toast('success', `${full ? 'Full page' : 'Viewport'} copied — paste anywhere`); return } catch { /* download */ } }
617
+ const url = URL.createObjectURL(blob); const a = document.createElement('a')
618
+ a.href = url; a.download = `screenshot-${full ? 'fullpage' : 'viewport'}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`; a.click()
619
+ setTimeout(() => URL.revokeObjectURL(url), 500)
620
+ toast('success', 'Screenshot downloaded')
621
+ } catch (e) { toast('error', errMsg(e) || 'Screenshot failed') } finally { setShotBusy(false) }
622
+ }
623
+
624
+ const submitDraft = async () => {
625
+ if (!draft) return
626
+ if (draft.kind === 'pin' && !draft.note.trim()) return
627
+ setBusy(true)
628
+ try {
629
+ let screenshot_path: string | null = null
630
+ if (draft.shot && draftElRef.current) { screenshot_path = await uploadShot(draftElRef.current, draft.visibility); if (!screenshot_path) toast('error', 'Screenshot failed — saved without it') }
631
+ const { cx: _cx, cy: _cy, shot: _shot, ...body } = draft // eslint-disable-line @typescript-eslint/no-unused-vars
632
+ const saved = await adapter.save({ ...body, screenshot_path })
633
+ if (saved?.id) { undoStackRef.current.push(saved.id); if (undoStackRef.current.length > 30) undoStackRef.current.shift() }
634
+ invalidate(); closeDraft(); dismissHint(); toast('success', 'Saved ✓')
635
+ } catch (e) { toast('error', errMsg(e)) } finally { setBusy(false) }
636
+ }
637
+ const closeDraft = () => { setDraft(null); draftElRef.current = null; setHoverEl(null) }
638
+
639
+ const doUpdate = (patch: { id: number } & Record<string, unknown>) => adapter.update(patch as never).then(invalidate).catch((e) => toast('error', errMsg(e)))
640
+ const doDelete = (id: number) => adapter.remove(id).then(() => { invalidate(); setActive(null); toast('success', 'Deleted') }).catch((e) => toast('error', errMsg(e)))
641
+ const doAddReply = (id: number, note: string) => adapter.addReply(id, note).then(() => { invalidate(); setReply(''); toast('success', 'Reply added') }).catch((e) => toast('error', errMsg(e)))
642
+ const doClearResolved = () => adapter.clearResolved().then((r) => { invalidate(); toast('success', r?.message || 'Cleared') }).catch((e) => toast('error', errMsg(e)))
643
+ const toggleAccess = async (next: boolean) => {
644
+ setAccessBusy(true)
645
+ try { const r = await adapter.setAccess(next); await refreshAccess(); toast('success', r?.message || 'Saved') }
646
+ catch (e) { toast('error', errMsg(e)) } finally { setAccessBusy(false) }
647
+ }
648
+
649
+ const undoLast = () => { const id = undoStackRef.current.pop(); if (id) doDelete(id) }
650
+
651
+ const jumpTo = (a: Annotation) => {
652
+ const go = () => {
653
+ const el = findEl(a)
654
+ if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); setHoverEl(el) }
655
+ else window.scrollTo({ top: Math.max(0, a.pos_y - 140), behavior: 'smooth' })
656
+ setActive(a); setFlashId(a.id); setTimeout(() => setFlashId(null), 4000)
657
+ }
658
+ if (a.page_route !== pathname && isSafePath(a.page_route)) { setPanel(false); navigate(a.page_route); setTimeout(go, 400) } else go()
659
+ }
660
+
661
+ const copyPrompt = async (items: Annotation[]) => { try { await navigator.clipboard.writeText(buildDevPrompt(items)); toast('success', `Copied ${items.length} mark(s) — paste to your developer`) } catch { toast('error', 'Clipboard blocked — use Download') } }
662
+ const downloadMd = (items: Annotation[]) => {
663
+ const blob = new Blob([buildDevPrompt(items)], { type: 'text/markdown;charset=utf-8' })
664
+ const url = URL.createObjectURL(blob); const a = document.createElement('a')
665
+ a.href = url; a.download = `annotations-${new Date().toISOString().slice(0, 10)}.md`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
666
+ }
667
+ const doDeliver = async (d: Delivery) => {
668
+ if (delivering || !d.run) return
669
+ setDelivering(true)
670
+ try { await d.run(openItems); toast('success', `Sent to ${d.label}`); setSendOpen(false) }
671
+ catch (e) { toast('error', errMsg(e)) } finally { setDelivering(false) }
672
+ }
673
+
674
+ const canEdit = (a: Annotation) => seeAll || a.created_by === meEmail || a.created_by === meName
675
+ const listed = useMemo(() => {
676
+ let r = all
677
+ if (thisPageOnly) r = r.filter((a) => a.page_route === pathname)
678
+ if (filter !== 'all') r = r.filter((a) => a.status === filter)
679
+ if (typeFilter) r = r.filter((a) => a.type === typeFilter)
680
+ if (prioFilter) r = r.filter((a) => a.priority === prioFilter)
681
+ if (authorFilter) r = r.filter((a) => (a.created_by || '') === authorFilter)
682
+ const q = search.trim().toLowerCase()
683
+ if (q) r = r.filter((a) => (a.note + a.page_route + a.target_text + (a.created_by_name || '')).toLowerCase().includes(q))
684
+ return [...r].sort((a, b) => b.id - a.id)
685
+ }, [all, thisPageOnly, filter, typeFilter, prioFilter, authorFilter, search, pathname])
686
+ const grouped = useMemo(() => {
687
+ const m = new Map<string, Annotation[]>()
688
+ for (const a of listed) { const k = a.page_route; if (!m.has(k)) m.set(k, []); m.get(k)!.push(a) }
689
+ return [...m.entries()]
690
+ }, [listed])
691
+ const openItems = all.filter((a) => OPEN_STATUSES.includes(a.status))
692
+ const authors = useMemo(() => { const m = new Map<string, string>(); for (const a of all) if (a.created_by) m.set(a.created_by, a.created_by_name || a.created_by); return [...m.entries()] }, [all])
693
+ const anyFilter = filter !== 'all' || !!typeFilter || !!prioFilter || !!authorFilter || thisPageOnly || !!search.trim()
694
+
695
+ const beginDrag = (e: ReactPointerEvent, onTap?: () => void) => {
696
+ e.preventDefault()
697
+ const el = (e.currentTarget as HTMLElement); const rect = el.getBoundingClientRect()
698
+ const offX = e.clientX - rect.left, offY = e.clientY - rect.top, sx = e.clientX, sy = e.clientY
699
+ let moved = false
700
+ const onMove = (ev: PointerEvent) => { if (!moved && Math.hypot(ev.clientX - sx, ev.clientY - sy) < 5) return; moved = true; setPos({ x: Math.min(Math.max(4, ev.clientX - offX), window.innerWidth - 64), y: Math.min(Math.max(4, ev.clientY - offY), window.innerHeight - 64) }) }
701
+ const onUp = () => { document.removeEventListener('pointermove', onMove); document.removeEventListener('pointerup', onUp); if (moved) setPos((p) => { if (p) { try { localStorage.setItem('annot-pos', JSON.stringify(p)) } catch { /* ignore */ } } return p }); else onTap?.() }
702
+ document.addEventListener('pointermove', onMove); document.addEventListener('pointerup', onUp)
703
+ }
704
+
705
+ if (accessLoading || !canUse) return null
706
+
707
+ const highlightEl = draft?.kind === 'pin' ? draftElRef.current : (hoverEl || (active && active.page_route === pathname ? (markEls.get(active.id) ?? findEl(active)) : null))
708
+ const highlightTone = draft ? annDot(draft.type) : active ? annDot(active.type) : '#6366f1'
709
+ const docW = Math.max(document.documentElement.scrollWidth, window.innerWidth)
710
+ const docH = Math.max(document.documentElement.scrollHeight, window.innerHeight)
711
+ const fabStyle: CSSProperties = pos ? { position: 'fixed', left: pos.x, top: pos.y } : { position: 'fixed', right: 20, bottom: 20 }
712
+
713
+ return createPortal(
714
+ <OverlayBoundary key={pathname}>
715
+ {accentCss && <style data-annot-ui>{`[data-annot-ui],[data-annot-ui][data-annot-theme="dark"],[data-annot-theme="dark"] [data-annot-ui]{--color-brand:${accentCss};--color-brand-strong:${accentCss};--color-brand-tint:color-mix(in srgb, ${accentCss} 14%, transparent);--color-brand-hairline:color-mix(in srgb, ${accentCss} 32%, transparent);}`}</style>}
716
+ <div data-annot-ui data-annot-theme={theme}>
717
+ <Highlight el={highlightEl} tone={highlightTone} label={highlightEl ? elLabel(highlightEl) : undefined} />
718
+
719
+ {marksShown && (
720
+ <svg width={docW} height={docH} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', zIndex: 9994, pointerEvents: 'none' }}>
721
+ {pageMarks.filter((m) => m.kind === 'shape').map((s) => <ShapeSvg key={s.id} s={s} />)}
722
+ {draft?.kind === 'shape' && <ShapeSvg s={{ ...draft, color: draft.color }} dash />}
723
+ </svg>
724
+ )}
725
+ {drawing && (
726
+ <svg width={docW} height={docH} style={{ position: 'absolute', left: 0, top: 0, overflow: 'visible', zIndex: 9994, pointerEvents: 'none' }}>
727
+ <ShapeSvg s={{ shape_type: drawing.shape_type, pos_x: drawing.x, pos_y: drawing.y, x2: drawing.x2, y2: drawing.y2, points: drawing.points, color }} dash />
728
+ </svg>
729
+ )}
730
+
731
+ {marksShown && pageMarks.map((a, i) => {
732
+ const el = markEls.get(a.id) ?? null
733
+ const r = el?.getBoundingClientRect()
734
+ const left = r ? r.left : a.pos_x - window.scrollX
735
+ const top = r ? r.top : a.pos_y - window.scrollY
736
+ const lost = a.kind !== 'shape' && !el
737
+ return (
738
+ <button key={a.id} type="button" data-annot-ui
739
+ onMouseEnter={() => el && setHoverEl(el)} onMouseLeave={() => tool !== 'pin' && setHoverEl(null)}
740
+ onClick={(e) => { e.stopPropagation(); setActive(a); closeDraft() }}
741
+ title={`${a.note || annShapeLabel(a.shape_type)}${lost ? ' (element moved)' : ''}`}
742
+ className={cn('fixed z-[9997] grid h-6 w-6 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full text-[11px] font-bold text-white shadow-md ring-2 ring-white', a.id === flashId && 'animate-bounce ring-4 ring-warning', lost && 'opacity-70')}
743
+ style={{ left, top, background: a.color || annDot(a.type) }}>{i + 1}</button>
744
+ )
745
+ })}
746
+
747
+ {/* ---------------- floating tool dock ---------------- */}
748
+ <div style={fabStyle} className="z-[9998]">
749
+ {tool === null && !panel ? (
750
+ <div className="flex flex-col items-end gap-2">
751
+ {!hintSeen && (
752
+ <div data-annot-ui className="max-w-[220px] rounded-card bg-fg px-3 py-2 text-[11px] text-white shadow-e4">
753
+ <b>Feedback tool</b> — tap here, then click any element or draw to leave a note for the developer.
754
+ <button type="button" onClick={dismissHint} className="mt-1 block text-[10px] font-semibold text-brand-strong">Got it</button>
755
+ </div>
756
+ )}
757
+ <button type="button" data-annot-ui aria-label="Open feedback tool" onPointerDown={(e) => beginDrag(e, () => setTool('pin'))} title="Annotate · drag to move · Shift+A" style={{ touchAction: 'none' }}
758
+ className="relative grid h-12 w-12 place-items-center rounded-full bg-fg text-white shadow-e4 transition hover:bg-ink-hover cursor-grab active:cursor-grabbing">
759
+ <MessageSquarePlus size={20} />
760
+ {openCount > 0 && <span className="absolute -right-1 -top-1 grid h-5 min-w-5 place-items-center rounded-full bg-brand px-1 text-[10px] font-bold text-white ring-2 ring-white">{openCount}</span>}
761
+ </button>
762
+ </div>
763
+ ) : (
764
+ <div data-annot-ui className="w-[252px] overflow-hidden rounded-card border border-line bg-surface shadow-e4">
765
+ <div className="flex items-center gap-1 bg-fg px-2 py-1.5 text-white">
766
+ <button type="button" onPointerDown={(e) => beginDrag(e)} title="Drag" style={{ touchAction: 'none' }} className="cursor-grab p-1 text-fg-disabled hover:text-white active:cursor-grabbing"><GripVertical size={14} /></button>
767
+ <span className="flex-1 truncate text-[12px] font-bold">Feedback ({all.length})</span>
768
+ <button type="button" onClick={() => setPeek((v) => !v)} title={peek ? 'Show marks (Shift+H)' : 'Hide marks (Shift+H)'} className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white">{peek ? <EyeOff size={15} /> : <Eye size={15} />}</button>
769
+ <button type="button" onClick={undoLast} title="Undo (Ctrl+Z)" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white"><Undo2 size={15} /></button>
770
+ <button type="button" onClick={() => capturePage(false)} disabled={shotBusy} title="Viewport screenshot" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white disabled:opacity-40"><Camera size={15} className={shotBusy ? 'animate-pulse' : ''} /></button>
771
+ <button type="button" onClick={() => capturePage(true)} disabled={shotBusy} title="Full-page screenshot" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white disabled:opacity-40"><Maximize2 size={15} className={shotBusy ? 'animate-pulse' : ''} /></button>
772
+ <button type="button" onClick={startMarkup} disabled={shotBusy} title="Screenshot & mark up" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white disabled:opacity-40"><Highlighter size={15} className={shotBusy ? 'animate-pulse' : ''} /></button>
773
+ <button type="button" onClick={() => setHelpOpen(true)} title="Shortcuts (?)" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white"><Keyboard size={15} /></button>
774
+ <button type="button" onClick={() => { setTool(null); closeDraft() }} title="Close" className="rounded p-1 text-fg-disabled hover:bg-ink-hover hover:text-white"><X size={15} /></button>
775
+ </div>
776
+ <div className="space-y-2 p-2">
777
+ <div className="grid grid-cols-5 gap-1">
778
+ {ANN_MODES.map((m) => {
779
+ const Ico = MODE_ICON[m.key]; const on = tool === m.key
780
+ return (
781
+ <button key={m.key} type="button" title={`${m.label} (${m.kbd})`} onClick={() => setTool(on ? null : (m.key as Tool))}
782
+ className={cn('flex flex-col items-center gap-0.5 rounded-input border py-1.5 transition', on ? 'border-brand bg-brand text-white' : 'border-line text-fg-muted hover:bg-row-alt')}>
783
+ <Ico size={15} /><span className="text-[9px] font-bold">{m.label}</span>
784
+ </button>
785
+ )
786
+ })}
787
+ </div>
788
+ <div className="flex items-center gap-1.5">
789
+ {ANN_COLORS.map((c) => (<button key={c} type="button" onClick={() => setColor(c)} style={{ background: c }} title="Colour" className={cn('h-5 w-5 rounded-full border-2 transition', color === c ? 'scale-110 border-fg-secondary' : 'border-line')} />))}
790
+ {peek && <span className="ml-auto rounded bg-warning-tint px-1.5 py-0.5 text-[10px] font-bold text-warning-text ring-1 ring-warning-hairline">Peek</span>}
791
+ </div>
792
+ {tool && (<div className="flex items-center gap-1.5 rounded-input bg-brand-tint px-2 py-1 text-[11px] font-semibold text-brand-strong ring-1 ring-brand-hairline"><Crosshair size={12} /> {tool === 'pin' ? 'Hover an element & click' : 'Drag to draw'} <span className="ml-auto text-brand-strong">Esc</span></div>)}
793
+ <div className="flex gap-1.5">
794
+ <button type="button" onClick={() => setPanel(true)} className="inline-flex flex-1 items-center justify-center gap-1 rounded-input border border-line py-1.5 text-[12px] font-bold text-fg-secondary hover:bg-row-alt"><ListChecks size={14} /> Review</button>
795
+ <button type="button" onClick={() => setSendOpen(true)} disabled={!openItems.length} className="inline-flex flex-1 items-center justify-center gap-1 rounded-input bg-fg py-1.5 text-[12px] font-bold text-white hover:bg-ink-hover disabled:opacity-40"><Send size={14} /> Send</button>
796
+ </div>
797
+ </div>
798
+ </div>
799
+ )}
800
+ </div>
801
+
802
+ {/* ---------------- draft (create) popover ---------------- */}
803
+ {draft && (
804
+ <div data-annot-ui className="fixed z-[9999] w-[19rem] rounded-card border border-line bg-surface p-3.5 shadow-e4"
805
+ style={{ left: Math.min(draft.cx, window.innerWidth - 320), top: Math.min(Math.max(12, draft.cy + 14), window.innerHeight - 380) }}>
806
+ <div className="mb-2 flex items-center justify-between">
807
+ <span className="text-sm font-bold text-fg-secondary">{draft.kind === 'shape' ? `New ${annShapeLabel(draft.shape_type)} mark` : 'New annotation'}</span>
808
+ <button type="button" onClick={closeDraft} className="text-fg-muted hover:text-fg-secondary"><X size={16} /></button>
809
+ </div>
810
+ {(draft.target_text || draft.target_selector) && (
811
+ <p className="mb-1.5 flex items-center gap-1 truncate rounded-input bg-brand-tint px-2 py-1 text-[11px] font-medium text-brand-strong" title={draft.target_selector}><Target size={12} className="shrink-0" /> {draft.target_text ? `“${draft.target_text.slice(0, 38)}”` : draft.target_selector}</p>
812
+ )}
813
+ {/* captured context chip */}
814
+ <div className="mb-2 flex flex-wrap gap-1 text-[10px] text-fg-muted">
815
+ {draft.styles?.size && <span className={cn(chip, 'bg-sunken')}>{draft.styles.size}px</span>}
816
+ {draft.styles?.color && <span className={cn(chip, 'bg-sunken')}><span className="h-2 w-2 rounded-full" style={{ background: draft.styles.color }} /> text</span>}
817
+ {draft.meta && <span className={cn(chip, 'bg-sunken')}><Monitor size={10} /> {draft.meta.browser} · {draft.meta.viewport}</span>}
818
+ {!!draft.console_logs?.length && <span className={cn(chip, 'bg-danger-tint text-danger-text')}><TerminalSquare size={10} /> {draft.console_logs.length} log</span>}
819
+ {!!draft.network_logs?.length && <span className={cn(chip, 'bg-warning-tint text-warning-text')}><Globe size={10} /> {draft.network_logs.length} net</span>}
820
+ </div>
821
+ <textarea autoFocus rows={3} value={draft.note} onChange={(e) => setDraft({ ...draft, note: e.target.value })}
822
+ onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') submitDraft() }}
823
+ placeholder={draft.kind === 'shape' ? 'What about this area? (optional)' : 'What should change here?'}
824
+ className="w-full resize-none rounded-input border border-line px-3 py-2 text-sm outline-none focus:border-brand focus:ring-4 focus:ring-brand/25" />
825
+ <div className="mt-2 flex flex-wrap gap-1">
826
+ {ANN_TYPES.map((t) => (<button key={t.key} type="button" onClick={() => setDraft({ ...draft, type: t.key })} className={cn('flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1 transition', draft.type === t.key ? 'bg-fg text-white ring-fg' : 'bg-surface text-fg-muted ring-line hover:bg-row-alt')}><span className="h-1.5 w-1.5 rounded-full" style={{ background: t.dot }} />{t.label}</button>))}
827
+ </div>
828
+ <div className="mt-2 flex items-center justify-between">
829
+ <div className="flex gap-1">
830
+ {ANN_PRIORITIES.map((pr) => (<button key={pr.key} type="button" onClick={() => setDraft({ ...draft, priority: pr.key })} className={cn('rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1 transition', draft.priority === pr.key ? (pr.key === 'high' ? 'bg-danger text-white ring-danger' : pr.key === 'med' ? 'bg-warning text-white ring-warning' : 'bg-fg-disabled text-white ring-fg-disabled') : 'bg-surface text-fg-muted ring-line hover:bg-row-alt')}>{pr.label}</button>))}
831
+ </div>
832
+ <div className="flex gap-1">
833
+ <button type="button" onClick={() => setDraft({ ...draft, visibility: draft.visibility === 'private' ? 'public' : 'private' })} title={draft.visibility === 'private' ? 'Only you see this' : 'Shared'} className={cn('flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', draft.visibility === 'private' ? 'bg-surface text-fg-muted ring-line' : 'bg-success-tint text-success-text ring-success-hairline')}>{draft.visibility === 'private' ? <Lock size={11} /> : <Globe size={11} />}{draft.visibility === 'private' ? 'Private' : 'Public'}</button>
834
+ {draft.kind === 'pin' && <button type="button" onClick={() => setDraft({ ...draft, shot: !draft.shot })} title="Attach element screenshot" className={cn('flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', draft.shot ? 'bg-success-tint text-success-text ring-success-hairline' : 'bg-surface text-fg-muted ring-line')}><Camera size={11} />{draft.shot ? 'Shot' : 'No shot'}</button>}
835
+ </div>
836
+ </div>
837
+ <div className="mt-3 flex items-center justify-between">
838
+ <span className="text-[10px] text-fg-disabled">⌘/Ctrl+Enter</span>
839
+ <div className="flex gap-2">
840
+ <button type="button" onClick={closeDraft} className="rounded-input px-3 py-1.5 text-xs font-medium text-fg-muted hover:bg-sunken">Cancel</button>
841
+ <button type="button" onClick={submitDraft} disabled={busy || (draft.kind === 'pin' && !draft.note.trim())} className="inline-flex items-center gap-1 rounded-input bg-brand px-3 py-1.5 text-xs font-semibold text-white disabled:opacity-50">{busy ? 'Saving…' : <><Check size={13} /> Save</>}</button>
842
+ </div>
843
+ </div>
844
+ </div>
845
+ )}
846
+
847
+ {/* ---------------- detail popover ---------------- */}
848
+ {active && (
849
+ <div data-annot-ui className="fixed bottom-24 right-5 z-[9999] max-h-[75vh] w-[22rem] overflow-y-auto rounded-card border border-line bg-surface p-4 shadow-e4">
850
+ <div className="mb-2 flex items-center justify-between">
851
+ <span className="flex items-center gap-2 text-sm font-bold text-fg-secondary"><span className="h-2.5 w-2.5 rounded-full" style={{ background: active.color || annDot(active.type) }} /> {active.kind === 'shape' ? `${annShapeLabel(active.shape_type)} · ` : ''}{annTypeLabel(active.type)}</span>
852
+ <div className="flex items-center gap-1">
853
+ <button type="button" onClick={() => jumpTo(active)} title="Find on page" className="rounded p-1 text-fg-muted hover:bg-brand-tint hover:text-brand-strong"><Target size={15} /></button>
854
+ {canEdit(active) && <button type="button" onClick={() => doDelete(active.id)} title="Delete" className="rounded p-1 text-fg-muted hover:bg-danger-tint hover:text-danger-text"><Trash2 size={15} /></button>}
855
+ <button type="button" onClick={() => setActive(null)} className="rounded p-1 text-fg-muted hover:bg-sunken"><X size={16} /></button>
856
+ </div>
857
+ </div>
858
+ <p className="mb-1 truncate text-[11px] text-fg-muted" title={active.target_selector}>{active.page_route} · {active.target_text || active.target_selector || annShapeLabel(active.shape_type)}</p>
859
+ <p className="mb-2 flex flex-wrap items-center gap-1.5 text-[11px] text-fg-muted">
860
+ <span className="font-semibold text-fg-secondary">{active.created_by_name || active.created_by || '—'}</span>
861
+ {active.created_by_role && <span className="rounded bg-sunken px-1.5 text-[10px] font-medium text-fg-muted">{active.created_by_role}</span>}
862
+ {active.visibility === 'private' && <Lock size={11} className="text-fg-muted" />}
863
+ {active.meta && <span className={cn(chip, 'bg-sunken')}><Monitor size={10} /> {active.meta.browser} · {active.meta.os} · {active.meta.viewport}</span>}
864
+ </p>
865
+ {canEdit(active)
866
+ ? <textarea rows={2} defaultValue={active.note} key={active.id} onBlur={(e) => { if (e.target.value !== active.note) doUpdate({ id: active.id, note: e.target.value }) }} placeholder="Add a note…" className="w-full resize-none rounded-input border border-line px-3 py-2 text-sm outline-none focus:border-brand" />
867
+ : <p className="rounded-input bg-sunken px-3 py-2 text-sm text-fg-secondary">{active.note || <span className="italic text-fg-muted">(no note)</span>}</p>}
868
+ {canEdit(active) && (
869
+ <div className="mt-2 flex flex-wrap gap-1">
870
+ {ANN_STATUSES.map((st) => (<button key={st.key} type="button" onClick={() => doUpdate({ id: active.id, status: st.key })} className={cn('rounded-full px-2.5 py-1 text-[11px] font-semibold ring-1 transition', active.status === st.key ? (st.key === 'done' ? 'bg-success text-white ring-success' : st.key === 'dismissed' ? 'bg-fg-disabled text-white ring-fg-disabled' : st.key === 'in_progress' ? 'bg-warning text-white ring-warning' : 'bg-brand text-white ring-brand') : 'bg-surface text-fg-muted ring-line hover:bg-row-alt')}>{st.label}</button>))}
871
+ </div>
872
+ )}
873
+ {!!active.console_logs?.length && (
874
+ <details className="mt-2 rounded-input bg-fg p-2 text-[10px] text-white">
875
+ <summary className="cursor-pointer font-semibold text-rose-300">Console ({active.console_logs.length})</summary>
876
+ <div className="mt-1 max-h-28 space-y-0.5 overflow-y-auto font-mono">{active.console_logs.slice(-8).map((l, i) => <div key={i} className="truncate" title={l.text}><span className="text-rose-400">[{l.level}]</span> {l.text}</div>)}</div>
877
+ </details>
878
+ )}
879
+ {!!active.network_logs?.length && (
880
+ <details className="mt-2 rounded-input bg-fg p-2 text-[10px] text-white">
881
+ <summary className="cursor-pointer font-semibold text-amber-300">Network ({active.network_logs.length})</summary>
882
+ <div className="mt-1 max-h-28 space-y-0.5 overflow-y-auto font-mono">{active.network_logs.slice(-8).map((nl, i) => <div key={i} className="truncate" title={nl.url}><span className="text-amber-400">{nl.method}</span> {nl.url} → {nl.status ?? 'ERR'}</div>)}</div>
883
+ </details>
884
+ )}
885
+ {active.dom_snapshot && (
886
+ <details className="mt-2 rounded-input bg-fg p-2 text-[10px] text-white">
887
+ <summary className="cursor-pointer font-semibold text-sky-300">DOM snapshot</summary>
888
+ <pre className="mt-1 max-h-40 overflow-auto whitespace-pre-wrap font-mono text-[9px] leading-snug">{active.dom_snapshot}</pre>
889
+ </details>
890
+ )}
891
+ {safeUrl(active.screenshot_url) && <a href={safeUrl(active.screenshot_url)} target="_blank" rel="noreferrer" className="mt-2 block"><img src={safeUrl(active.screenshot_url)} alt="screenshot" className="max-h-44 w-full rounded-input border border-line object-contain" /></a>}
892
+ {(active.replies ?? []).length > 0 && (
893
+ <div className="mt-2 space-y-1 border-t border-line pt-2">{(active.replies ?? []).map((r, i) => <p key={i} className="flex items-start gap-1 text-xs text-fg-muted"><CornerDownRight size={12} className="mt-0.5 shrink-0 text-fg-disabled" /> <span><b className="text-fg-muted">{r.author || ''}</b> {r.note}</span></p>)}</div>
894
+ )}
895
+ <div className="mt-2 flex items-center gap-2">
896
+ <input value={reply} onChange={(e) => setReply(e.target.value)} placeholder="Add a follow-up…" onKeyDown={(e) => { if (e.key === 'Enter' && reply.trim()) doAddReply(active.id, reply.trim()) }} className="flex-1 rounded-input border border-line px-2.5 py-1.5 text-xs outline-none focus:border-brand" />
897
+ <button type="button" onClick={() => reply.trim() && doAddReply(active.id, reply.trim())} className="rounded-input bg-sunken p-1.5 text-fg-secondary hover:bg-row-alt"><MessageSquarePlus size={15} /></button>
898
+ </div>
899
+ </div>
900
+ )}
901
+
902
+ {/* ---------------- review drawer ---------------- */}
903
+ {panel && (
904
+ <>
905
+ <div data-annot-ui className="fixed inset-0 z-[9998] bg-fg/20" onClick={() => setPanel(false)} />
906
+ <div ref={panelTrap} tabIndex={-1} data-annot-ui role="dialog" aria-modal="true" aria-label="Annotations" className="fixed right-0 top-0 z-[9999] flex h-full w-[27rem] max-w-[94vw] flex-col border-l border-line bg-surface shadow-e4 outline-none">
907
+ <div className="flex items-center justify-between border-b border-line px-4 py-3">
908
+ <span className="flex items-center gap-2 font-bold text-fg-secondary"><ListChecks size={18} className="text-brand-strong" /> Annotations <span className="rounded-full bg-sunken px-2 text-xs text-fg-muted">{anyFilter ? `${listed.length}/${all.length}` : all.length}</span></span>
909
+ <button type="button" onClick={() => setPanel(false)} className="text-fg-muted hover:text-fg-secondary"><X size={18} /></button>
910
+ </div>
911
+ <div className="flex items-center gap-2 border-b border-line px-3 py-2">
912
+ <button type="button" onClick={() => setSendOpen(true)} disabled={!openItems.length} className="inline-flex flex-1 items-center justify-center gap-1 rounded-input bg-brand px-2.5 py-2 text-xs font-semibold text-white disabled:opacity-40"><Send size={13} /> Send to developer ({openItems.length})</button>
913
+ <button type="button" onClick={() => downloadMd(openItems)} disabled={!openItems.length} title="Download .md" className="inline-flex items-center gap-1 rounded-input border border-line px-2.5 py-2 text-xs text-fg-muted disabled:opacity-40"><Download size={13} /></button>
914
+ <button type="button" onClick={() => setHelpOpen(true)} title="Shortcuts" className="inline-flex items-center gap-1 rounded-input border border-line px-2.5 py-2 text-xs text-fg-muted"><Keyboard size={13} /></button>
915
+ </div>
916
+ {isAdmin && (
917
+ <button type="button" onClick={() => toggleAccess(!enabled)} disabled={accessBusy} className={cn('flex items-center gap-2 border-b border-line px-3 py-2 text-left text-[11.5px] font-semibold', enabled ? 'text-success-text' : 'text-fg-muted')}>
918
+ <Settings size={13} /> {enabled ? 'Visible to ALL users (testing) — click to make ADMIN-only' : 'ADMIN-only — click to enable for all users'}
919
+ </button>
920
+ )}
921
+ {/* filters */}
922
+ <div className="space-y-1.5 border-b border-line px-3 py-2">
923
+ <div className="flex flex-wrap items-center gap-1">
924
+ <Filter size={12} className="text-fg-muted" />
925
+ {(['all', ...ANN_STATUSES.map((s) => s.key)] as const).map((f) => (<button key={f} type="button" onClick={() => setFilter(f as 'all' | AnnStatus)} className={cn('rounded-full px-2 py-0.5 text-[11px] font-medium', filter === f ? 'bg-brand-tint text-brand-strong ring-1 ring-brand-hairline' : 'text-fg-muted hover:bg-sunken')}>{f === 'all' ? 'All' : annStatusLabel(f)}</button>))}
926
+ </div>
927
+ <div className="flex flex-wrap items-center gap-1">
928
+ {ANN_TYPES.map((t) => (<button key={t.key} type="button" onClick={() => setTypeFilter((v) => v === t.key ? '' : t.key)} className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium ring-1', typeFilter === t.key ? 'bg-fg text-white ring-fg' : 'bg-surface text-fg-muted ring-line hover:bg-row-alt')}><span className="h-1.5 w-1.5 rounded-full" style={{ background: t.dot }} />{t.label}</button>))}
929
+ </div>
930
+ <div className="flex flex-wrap items-center gap-1">
931
+ {ANN_PRIORITIES.map((pr) => (<button key={pr.key} type="button" onClick={() => setPrioFilter((v) => v === pr.key ? '' : pr.key)} className={cn('rounded-full px-2 py-0.5 text-[11px] font-semibold ring-1', prioFilter === pr.key ? 'bg-ink-hover text-white ring-fg-secondary' : 'bg-surface text-fg-muted ring-line hover:bg-row-alt')}>{pr.label}</button>))}
932
+ <label className="ml-1 inline-flex cursor-pointer items-center gap-1 text-[11px] font-medium text-fg-muted"><input type="checkbox" checked={thisPageOnly} onChange={(e) => setThisPageOnly(e.target.checked)} className="h-3 w-3" /> This page</label>
933
+ {seeAll && <button type="button" onClick={() => doClearResolved()} title="Delete done/dismissed" className="ml-auto inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] text-danger-text hover:bg-danger-tint"><Eraser size={11} /> Clear resolved</button>}
934
+ </div>
935
+ <div className="flex items-center gap-2">
936
+ <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search…" className="flex-1 rounded-input border border-line px-2.5 py-1.5 text-xs outline-none focus:border-brand" />
937
+ {seeAll && authors.length > 1 && (<select value={authorFilter} onChange={(e) => setAuthorFilter(e.target.value)} className="max-w-[9rem] rounded-input border border-line px-2 py-1.5 text-xs text-fg-muted"><option value="">Everyone</option>{authors.map(([id, name]) => <option key={id} value={id}>{name}</option>)}</select>)}
938
+ </div>
939
+ </div>
940
+ {/* list grouped by page */}
941
+ <div className="flex-1 overflow-y-auto p-2">
942
+ {listed.length === 0 ? (
943
+ <p className="p-6 text-center text-sm text-fg-muted">{all.length ? 'Nothing matches these filters.' : 'No annotations yet. Open the dock, pick Pin or a shape, and mark anything.'}</p>
944
+ ) : grouped.map(([page, items]) => (
945
+ <div key={page} className="mb-2">
946
+ <p className="mb-1 flex items-center gap-1 px-1 text-[10px] font-mono font-bold text-fg-muted"><span className={cn('h-1.5 w-1.5 rounded-full', page === pathname ? 'bg-success' : 'bg-fg-disabled')} /><span className="truncate">{page}</span><span className="text-fg-disabled">· {items.length}</span></p>
947
+ {items.map((a) => {
948
+ const editing = editRow === a.id
949
+ return (
950
+ <div key={a.id} onMouseEnter={() => a.page_route === pathname && a.kind !== 'shape' && setHoverEl(findEl(a))} onMouseLeave={() => setHoverEl(null)} className="mb-1.5 rounded-card border border-line hover:bg-row-alt">
951
+ <div className="flex items-start gap-2 p-2.5">
952
+ <span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full text-[10px] font-bold text-white" style={{ background: a.color || annDot(a.type) }}>{a.kind === 'shape' ? annShapeLabel(a.shape_type)[0] : annTypeLabel(a.type)[0]}</span>
953
+ <button type="button" onClick={() => jumpTo(a)} className="min-w-0 flex-1 text-left">
954
+ <span className="block truncate text-sm font-medium text-fg-secondary">{a.note || <span className="italic text-fg-muted">{annShapeLabel(a.shape_type)} mark</span>}</span>
955
+ <span className="mt-0.5 flex flex-wrap items-center gap-1 text-[10.5px] text-fg-muted">
956
+ <span className="font-semibold text-fg-muted">{a.created_by_name || a.created_by}</span>
957
+ {a.created_by_role && <span className="rounded bg-sunken px-1 text-fg-muted">{a.created_by_role}</span>}
958
+ <span className="uppercase">· {a.priority}</span>
959
+ <span className={cn('rounded px-1', a.status === 'done' ? 'bg-success-tint text-success-text' : a.status === 'dismissed' ? 'bg-sunken text-fg-muted' : a.status === 'in_progress' ? 'bg-warning-tint text-warning-text' : 'bg-brand-tint text-brand-strong')}>{annStatusLabel(a.status)}</span>
960
+ {a.visibility === 'private' && <Lock size={10} />}{a.screenshot_url && <Camera size={10} />}{!!a.console_logs?.length && <TerminalSquare size={10} className="text-danger-text" />}{(a.replies ?? []).length > 0 && <span>💬{(a.replies ?? []).length}</span>}
961
+ </span>
962
+ </button>
963
+ {/* per-row actions — no need to open the mark */}
964
+ <div className="flex shrink-0 items-center gap-0.5">
965
+ <button type="button" onClick={() => jumpTo(a)} title="Find on page" className="rounded p-1 text-fg-disabled hover:bg-brand-tint hover:text-brand-strong"><Target size={14} /></button>
966
+ {canEdit(a) && <button type="button" onClick={() => setEditRow(editing ? null : a.id)} title="Edit" className={cn('rounded p-1 hover:bg-sunken', editing ? 'bg-brand-tint text-brand-strong' : 'text-fg-disabled hover:text-fg-secondary')}><Pencil size={14} /></button>}
967
+ {canEdit(a) && a.status !== 'done' && <button type="button" onClick={() => doUpdate({ id: a.id, status: 'done' })} title="Mark done" className="rounded p-1 text-fg-disabled hover:bg-success-tint hover:text-success-text"><Check size={14} /></button>}
968
+ {canEdit(a) && <button type="button" onClick={() => doDelete(a.id)} title="Delete" className="rounded p-1 text-fg-disabled hover:bg-danger-tint hover:text-danger-text"><Trash size={14} /></button>}
969
+ </div>
970
+ </div>
971
+ {editing && canEdit(a) && (
972
+ <div className="space-y-2 border-t border-line p-2.5">
973
+ <textarea rows={2} defaultValue={a.note} onBlur={(e) => { if (e.target.value !== a.note) doUpdate({ id: a.id, note: e.target.value }) }} placeholder="Edit note…" className="w-full resize-none rounded-input border border-line px-2.5 py-1.5 text-sm outline-none focus:border-brand" />
974
+ <div className="flex flex-wrap items-center gap-1">
975
+ {ANN_STATUSES.map((st) => (<button key={st.key} type="button" onClick={() => doUpdate({ id: a.id, status: st.key })} className={cn('rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1', a.status === st.key ? (st.key === 'done' ? 'bg-success text-white ring-success' : st.key === 'dismissed' ? 'bg-fg-disabled text-white ring-fg-disabled' : st.key === 'in_progress' ? 'bg-warning text-white ring-warning' : 'bg-brand text-white ring-brand') : 'bg-surface text-fg-muted ring-line')}>{st.label}</button>))}
976
+ </div>
977
+ <div className="flex items-center gap-1.5">
978
+ <select value={a.type} onChange={(e) => doUpdate({ id: a.id, type: e.target.value })} className="flex-1 rounded-input border border-line px-2 py-1 text-[11px]">{ANN_TYPES.map((t) => <option key={t.key} value={t.key}>{t.label}</option>)}</select>
979
+ <select value={a.priority} onChange={(e) => doUpdate({ id: a.id, priority: e.target.value })} className="flex-1 rounded-input border border-line px-2 py-1 text-[11px]">{ANN_PRIORITIES.map((pr) => <option key={pr.key} value={pr.key}>{pr.label}</option>)}</select>
980
+ <button type="button" onClick={() => setEditRow(null)} className="rounded-input bg-fg px-2.5 py-1 text-[11px] font-semibold text-white">Done</button>
981
+ </div>
982
+ </div>
983
+ )}
984
+ </div>
985
+ )
986
+ })}
987
+ </div>
988
+ ))}
989
+ </div>
990
+ </div>
991
+ </>
992
+ )}
993
+
994
+ {/* ---------------- send preview modal ---------------- */}
995
+ {sendOpen && (
996
+ <div data-annot-ui className="fixed inset-0 z-[10000] grid place-items-center bg-fg/50 p-4" onClick={() => setSendOpen(false)}>
997
+ <div ref={sendTrap} tabIndex={-1} role="dialog" aria-modal="true" aria-label="Send to developer" className="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-modal bg-surface shadow-e4 outline-none" onClick={(e) => e.stopPropagation()}>
998
+ <div className="flex items-center gap-2 border-b border-line px-4 py-3">
999
+ <Send size={16} className="text-fg-secondary" /><span className="flex-1 text-sm font-bold text-fg-secondary">Send to developer — {openItems.length} open item(s)</span>
1000
+ <button type="button" onClick={() => setSendOpen(false)} className="text-fg-muted hover:text-fg-secondary"><X size={18} /></button>
1001
+ </div>
1002
+ <p className="px-4 pt-3 text-[12px] text-fg-muted">This exact markdown reaches your developer — each mark's element, note, environment, and console are included. Copy, then paste in the chat.</p>
1003
+ <pre className="m-4 flex-1 overflow-auto whitespace-pre-wrap rounded-input bg-fg p-3 font-mono text-[11.5px] leading-relaxed text-white">{buildDevPrompt(openItems) || '(no open marks)'}</pre>
1004
+ <div className="flex flex-wrap items-center justify-end gap-2 border-t border-line p-3">
1005
+ {deliveries.map((d, i) => d.href
1006
+ ? <a key={i} href={safeUrl(d.href(openItems))} target="_blank" rel="noreferrer" onClick={() => setSendOpen(false)} className="inline-flex items-center gap-1.5 rounded-input border border-line-strong px-3 py-2 text-[12.5px] font-bold text-fg-secondary hover:bg-sunken"><Send size={14} /> {d.label}</a>
1007
+ : <button key={i} type="button" disabled={delivering} onClick={() => doDeliver(d)} className="inline-flex items-center gap-1.5 rounded-input border border-line-strong px-3 py-2 text-[12.5px] font-bold text-fg-secondary hover:bg-sunken disabled:opacity-40"><Send size={14} /> {d.label}</button>)}
1008
+ <button type="button" onClick={() => downloadMd(openItems)} className="inline-flex items-center gap-1.5 rounded-input border border-line-strong px-4 py-2 text-[12.5px] font-bold text-fg-secondary hover:bg-sunken"><Download size={15} /> Download .md</button>
1009
+ <button type="button" onClick={() => { copyPrompt(openItems); setSendOpen(false) }} className="inline-flex items-center gap-1.5 rounded-input bg-fg px-4 py-2 text-[12.5px] font-bold text-white hover:bg-ink-hover"><Copy size={15} /> Copy — paste to developer</button>
1010
+ </div>
1011
+ </div>
1012
+ </div>
1013
+ )}
1014
+
1015
+ {/* ---------------- keyboard cheat-sheet ---------------- */}
1016
+ {helpOpen && (
1017
+ <div data-annot-ui className="fixed inset-0 z-[10000] grid place-items-center bg-fg/50 p-4" onClick={() => setHelpOpen(false)}>
1018
+ <div ref={helpTrap} tabIndex={-1} role="dialog" aria-modal="true" aria-label="Keyboard shortcuts" className="w-full max-w-sm rounded-modal bg-surface p-4 shadow-e4 outline-none" onClick={(e) => e.stopPropagation()}>
1019
+ <div className="mb-2 flex items-center gap-2"><Keyboard size={16} className="text-brand-strong" /><span className="flex-1 text-sm font-bold text-fg-secondary">Shortcuts</span><button type="button" onClick={() => setHelpOpen(false)} className="text-fg-muted hover:text-fg-secondary"><X size={16} /></button></div>
1020
+ <div className="grid grid-cols-2 gap-y-1.5 text-[12px] text-fg-muted">
1021
+ {[['Shift+A', 'Pin tool'], ['P / B / A / C / D', 'Pin · Box · Arrow · Circle · Draw'], ['Esc', 'Cancel / exit'], ['⌘/Ctrl+Enter', 'Save note'], ['Ctrl+Z', 'Undo last mark'], ['Shift+H', 'Hide / show marks'], ['?', 'This help']].map(([k, v]) => (
1022
+ <div key={k} className="contents"><kbd className="w-fit rounded bg-sunken px-1.5 py-0.5 font-mono text-[11px] font-semibold text-fg-secondary">{k}</kbd><span>{v}</span></div>
1023
+ ))}
1024
+ </div>
1025
+ </div>
1026
+ </div>
1027
+ )}
1028
+
1029
+ {markupBlob && <ScreenshotEditor blob={markupBlob} onToast={toast} onClose={() => setMarkupBlob(null)} />}
1030
+
1031
+ {/* ---------------- built-in mini-toast (skipped when onToast is provided) ---------------- */}
1032
+ {toasts.length > 0 && (
1033
+ <div data-annot-ui className="fixed bottom-6 left-1/2 z-[10001] flex -translate-x-1/2 flex-col items-center gap-2">
1034
+ {toasts.map((t) => (
1035
+ <div key={t.id} className={cn('rounded-input px-3 py-2 text-xs font-semibold shadow-e4 ring-1', t.kind === 'success' ? 'bg-success-tint text-success-text ring-success-hairline' : 'bg-danger-tint text-danger-text ring-danger-hairline')}>{t.msg}</div>
1036
+ ))}
1037
+ </div>
1038
+ )}
1039
+ </div>
1040
+ </OverlayBoundary>,
1041
+ document.body,
1042
+ )
1043
+ }