@fabrika/annotations 0.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,102 @@
1
+ import { formatSnippetInContext } from './annotationContext'
2
+ import { describeSnippet } from './annotationDom'
3
+ import type { AnnotationAttachment, AnnotationContext } from './runtime'
4
+
5
+ /**
6
+ * What rendering one annotation reads off it. Every `Annotation` satisfies this,
7
+ * and so can a record that is not one: how a single annotation reads as markdown
8
+ * is the half of feedback that belongs to no particular document format.
9
+ */
10
+ export interface RenderableAnnotation {
11
+ snippet: string
12
+ note: string
13
+ /** Set when the annotation points into a file. With `context.lineStart` it
14
+ * switches the quote to numbered source lines. */
15
+ filePath?: string
16
+ context?: AnnotationContext
17
+ /** Attached images as bare paths/URLs — what the local editor writes. */
18
+ images?: string[]
19
+ /** Attached files carried on the wire — what a shared canvas sends back.
20
+ * A host may populate either field; both are rendered. */
21
+ attachments?: AnnotationAttachment[]
22
+ }
23
+
24
+ /**
25
+ * One annotation as markdown: what was annotated, quoted, then the note and any
26
+ * attachments under it. Ends on a blank line so entries stack.
27
+ *
28
+ * The caller owns everything around it — headings, grouping, ordering.
29
+ */
30
+ export function renderAnnotation(ann: RenderableAnnotation): string {
31
+ const lines: string[] = []
32
+ const snippet = ann.snippet.trim()
33
+ const ctx = ann.context
34
+ // A snippet that does not read as text — a drawn region — says what it points
35
+ // at in words instead, since quoting its encoding tells an agent nothing.
36
+ const described = describeSnippet(snippet)
37
+
38
+ if (described !== snippet) {
39
+ lines.push(`> ${described}`)
40
+ } else if (ann.filePath && ctx && ctx.lineStart != null) {
41
+ // File annotation with line numbers
42
+ const lineStart = ctx.lineStart
43
+ const lineEnd = ctx.lineEnd ?? lineStart
44
+ const snippetLines = snippet.split('\n')
45
+ const isShort = snippet.length < 30 && lineStart === lineEnd
46
+
47
+ if (isShort && (ctx.before || ctx.after)) {
48
+ // Short snippet on single line — show full line context
49
+ const expanded = formatSnippetInContext(ann)
50
+ lines.push(`> L${lineStart}: ${expanded}`)
51
+ } else if (snippetLines.length <= 6) {
52
+ for (let i = 0; i < snippetLines.length; i++) {
53
+ lines.push(`> ${lineStart + i} | ${snippetLines[i]}`)
54
+ }
55
+ } else {
56
+ for (let i = 0; i < 3; i++) {
57
+ lines.push(`> ${lineStart + i} | ${snippetLines[i]}`)
58
+ }
59
+ lines.push(`> ... (${snippetLines.length} lines)`)
60
+ for (let i = snippetLines.length - 3; i < snippetLines.length; i++) {
61
+ lines.push(`> ${lineStart + i} | ${snippetLines[i]}`)
62
+ }
63
+ }
64
+ } else {
65
+ // Plan annotations or file annotations without line info
66
+ const context = formatSnippetInContext(ann)
67
+ if (snippet.split('\n').length <= 3) {
68
+ lines.push(`> ${context.split('\n').join('\n> ')}`)
69
+ } else {
70
+ const snippetLines = snippet.split('\n')
71
+ lines.push(`> ${snippetLines[0]}`)
72
+ lines.push(`> ... (${snippetLines.length} lines)`)
73
+ lines.push(`> ${snippetLines[snippetLines.length - 1]}`)
74
+ }
75
+ }
76
+
77
+ // Comment
78
+ if (ann.note.trim()) {
79
+ lines.push('')
80
+ lines.push(ann.note.trim())
81
+ }
82
+
83
+ // Attached images. Both fields are read: the local editor writes `images`,
84
+ // a shared canvas's reviewer comes back with `attachments`, and an agent that
85
+ // sees only one of them is looking at feedback with a hole in it.
86
+ const attached = [...(ann.images ?? [])]
87
+ const seen = new Set(attached)
88
+ for (const attachment of ann.attachments ?? []) {
89
+ if (seen.has(attachment.url)) continue
90
+ seen.add(attachment.url)
91
+ attached.push(attachment.url)
92
+ }
93
+ if (attached.length) {
94
+ lines.push('')
95
+ for (const url of attached) {
96
+ lines.push(`![screenshot](${url})`)
97
+ }
98
+ }
99
+
100
+ lines.push('')
101
+ return lines.join('\n')
102
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * How a snippet names something on the page.
3
+ *
4
+ * `Annotation.snippet` is the only field that ties an annotation to a domain —
5
+ * everything else (draft persistence, sidebar, popover, notes, attachments,
6
+ * markdown, submit) works on any annotation whatever it points at. A target is
7
+ * that one tie, pulled out as a strategy: give the kernel a new target and it
8
+ * can annotate a new kind of thing without another line changing.
9
+ */
10
+
11
+ /** What a target may read off the annotation it resolves — DOM identity, not feedback. */
12
+ export interface TargetAnnotation {
13
+ id: string
14
+ snippet: string
15
+ context?: { before: string; after: string; hierarchy: string[] }
16
+ }
17
+
18
+ export interface AnnotationTarget<L extends object> {
19
+ /** Names the strategy. */
20
+ readonly kind: string
21
+ /** Read a snippet this target wrote. Null means the snippet is not its own. */
22
+ parse(snippet: string): L | null
23
+ /** Write the locator back into the snippet key. The exact inverse of `parse`. */
24
+ format(locator: L): string
25
+ /**
26
+ * The element the locator names, searched inside `root`. Targets whose
27
+ * annotation lives in DOM this target itself drew — an inline mark, an
28
+ * overlay box — return null here and are resolved by their annotation id
29
+ * instead; see findAnnotationElement.
30
+ */
31
+ find(locator: L, root: ParentNode): HTMLElement | null
32
+ /**
33
+ * Put this annotation's own decoration back into `root`, if it is not there
34
+ * already. Called after every render, for every annotation, so it must be
35
+ * idempotent and cheap on a snippet it does not own.
36
+ */
37
+ restore?(locator: L, root: HTMLElement, ann: TargetAnnotation): void
38
+ /** A one-line label a reader (or a coding agent) can act on. */
39
+ describe?(locator: L): string
40
+ }
41
+
42
+ /** An AnnotationTarget with its locator type sealed inside, so targets whose
43
+ * locators have nothing in common can share one list. */
44
+ export interface SealedAnnotationTarget {
45
+ readonly kind: string
46
+ owns(snippet: string): boolean
47
+ find(snippet: string, root: ParentNode): HTMLElement | null
48
+ restore(root: HTMLElement, ann: TargetAnnotation): void
49
+ describe(snippet: string): string | null
50
+ }
51
+
52
+ /** Close over the locator type, so parse and find can never be called with
53
+ * a locator the other one did not produce. */
54
+ export function sealTarget<L extends object>(target: AnnotationTarget<L>): SealedAnnotationTarget {
55
+ return {
56
+ kind: target.kind,
57
+ owns: (snippet) => target.parse(snippet) !== null,
58
+ find: (snippet, root) => {
59
+ const locator = target.parse(snippet)
60
+ return locator === null ? null : target.find(locator, root)
61
+ },
62
+ restore: (root, ann) => {
63
+ const locator = target.parse(ann.snippet)
64
+ if (locator === null) return
65
+ target.restore?.(locator, root, ann)
66
+ },
67
+ describe: (snippet) => {
68
+ const locator = target.parse(snippet)
69
+ if (locator === null || !target.describe) return null
70
+ return target.describe(locator)
71
+ },
72
+ }
73
+ }
@@ -0,0 +1,127 @@
1
+ import type { AnnotationTarget } from './annotationTarget'
2
+
3
+ /**
4
+ * A whole canvas block, identified by the text a reader sees in it.
5
+ *
6
+ * The hard-won invariant lives here: the selector that decides where an
7
+ * annotation may be minted and the selector the lookup scans must be one
8
+ * selector. An annotation minted on a block the lookup skips can never be
9
+ * resolved again. Both now come off one table, so they cannot drift: a kind is
10
+ * annotatable exactly when it can mint a snippet, and `getBlockSnippet` refuses
11
+ * every block outside that set.
12
+ */
13
+ interface BlockKind {
14
+ /** CSS matching this kind, as the component emits it. */
15
+ selector: string
16
+ /** The key a block of this kind is annotated under, or null when it is
17
+ * missing the attribute that identifies it. */
18
+ snippet(block: HTMLElement): string | null
19
+ }
20
+
21
+ /**
22
+ * A block's text as a reader sees it. Decorative markup — the callout icon,
23
+ * anything else marked aria-hidden — is skipped, so the glyph does not ride
24
+ * along into the sidebar label and into feedback.md.
25
+ */
26
+ function readableText(block: HTMLElement): string {
27
+ const walker = document.createTreeWalker(block, NodeFilter.SHOW_TEXT)
28
+ let text = ''
29
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
30
+ const hidden = node.parentElement?.closest("[aria-hidden='true']")
31
+ if (hidden && block.contains(hidden)) continue
32
+ text += node.textContent ?? ''
33
+ }
34
+ return text.trim()
35
+ }
36
+
37
+ /** A block named by one of its attributes, e.g. `[Item] Ship the beta`. */
38
+ function byAttribute(selector: string, prefix: string, attribute: string): BlockKind {
39
+ return {
40
+ selector,
41
+ snippet: (block) => {
42
+ const value = block.getAttribute(attribute)
43
+ return value ? `${prefix} ${value}` : null
44
+ },
45
+ }
46
+ }
47
+
48
+ /** Every block an annotation can be minted on. */
49
+ const ANNOTATABLE_KINDS: BlockKind[] = [
50
+ byAttribute("[data-md='item']", '[Item]', 'data-md-label'),
51
+ byAttribute("[data-md='section']", '[Section]', 'data-md-title'),
52
+ byAttribute("[data-md='checklist-item']", '[Checklist]', 'data-md-label'),
53
+ byAttribute("[data-md='image']", '[Image]', 'data-md-src'),
54
+ {
55
+ selector: "[data-md='callout']",
56
+ snippet: (block) => {
57
+ const type = block.getAttribute('data-md-type') || 'info'
58
+ return `[Callout:${type}] ${readableText(block).slice(0, 60) || 'Callout'}`
59
+ },
60
+ },
61
+ {
62
+ selector: "[data-md='note']",
63
+ snippet: (block) => `[Note] ${readableText(block).slice(0, 60) || 'Note'}`,
64
+ },
65
+ {
66
+ // Only rows of a table the canvas emitted: a table inside a markdown block
67
+ // is not navigable, so a row annotated there could never be reached again.
68
+ selector: "[data-md='table'] tbody tr",
69
+ snippet: (block) => {
70
+ const cells = Array.from(block.querySelectorAll('td')).map((td) => td.textContent?.trim()).filter(Boolean)
71
+ return cells.length ? `[Row] ${cells.join(' | ')}` : null
72
+ },
73
+ },
74
+ ]
75
+
76
+ /** Blocks the arrows walk but no annotation can land on — interactive controls
77
+ * carry the reader's answer, not the author's text. */
78
+ const NAVIGABLE_ONLY_SELECTORS = [
79
+ "[data-md='choice-option']",
80
+ "[data-md='multichoice-option']",
81
+ "[data-md='userinput']",
82
+ "[data-md='rangeinput']",
83
+ ]
84
+
85
+ /**
86
+ * The blocks that can carry a block annotation — a strict subset of
87
+ * BLOCK_SELECTOR, since interactive controls are navigable but not annotatable.
88
+ */
89
+ export const ANNOTATABLE_SELECTOR = ANNOTATABLE_KINDS.map((kind) => kind.selector).join(', ')
90
+
91
+ /** Every block the keyboard arrows walk through. */
92
+ export const BLOCK_SELECTOR = [
93
+ ...ANNOTATABLE_KINDS.map((kind) => kind.selector),
94
+ ...NAVIGABLE_ONLY_SELECTORS,
95
+ ].join(', ')
96
+
97
+ /** The snippet identifier for a block, or null if nothing can be annotated here. */
98
+ export function getBlockSnippet(block: HTMLElement): string | null {
99
+ for (const kind of ANNOTATABLE_KINDS) {
100
+ if (block.matches(kind.selector)) return kind.snippet(block)
101
+ }
102
+ return null
103
+ }
104
+
105
+ /** A block is its own locator: the snippet is the text that identifies it. */
106
+ export interface BlockLocator {
107
+ snippet: string
108
+ }
109
+
110
+ export const blockTarget: AnnotationTarget<BlockLocator> = {
111
+ kind: 'block',
112
+
113
+ // Every block snippet opens with its kind in brackets. Text that merely looks
114
+ // like one is not stranded by claiming it: `find` compares against snippets
115
+ // blocks actually mint, so a lookalike simply matches nothing.
116
+ parse: (snippet) => (snippet.startsWith('[') ? { snippet } : null),
117
+
118
+ format: (locator) => locator.snippet,
119
+
120
+ find: (locator, root) => {
121
+ for (const el of root.querySelectorAll(ANNOTATABLE_SELECTOR)) {
122
+ if (!(el instanceof HTMLElement)) continue
123
+ if (getBlockSnippet(el) === locator.snippet) return el
124
+ }
125
+ return null
126
+ },
127
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Wraps the current browser Range in <mark> elements with the given annotation ID.
3
+ * Works for multiline / cross-element selections.
4
+ */
5
+ export function wrapRangeWithMark(range: Range, annotationId: string): HTMLElement[] {
6
+ const marks: HTMLElement[] = []
7
+
8
+ // Collect all text nodes within the range
9
+ const textNodes = getTextNodesInRange(range)
10
+
11
+ for (const tn of textNodes) {
12
+ const nodeRange = document.createRange()
13
+ nodeRange.selectNodeContents(tn)
14
+
15
+ // Clamp to the actual selection range
16
+ if (tn === range.startContainer) {
17
+ nodeRange.setStart(tn, range.startOffset)
18
+ }
19
+ if (tn === range.endContainer) {
20
+ nodeRange.setEnd(tn, range.endOffset)
21
+ }
22
+
23
+ const text = nodeRange.toString()
24
+ if (!text || !text.trim()) continue
25
+
26
+ // Wrap this portion in a <mark>
27
+ const mark = document.createElement('mark')
28
+ mark.setAttribute('data-annotation-id', annotationId)
29
+
30
+ try {
31
+ nodeRange.surroundContents(mark)
32
+ } catch {
33
+ // surroundContents fails if range crosses element boundaries within a single text node
34
+ // (shouldn't happen for a single text node, but handle gracefully)
35
+ const fragment = nodeRange.extractContents()
36
+ mark.appendChild(fragment)
37
+ nodeRange.insertNode(mark)
38
+ }
39
+
40
+ marks.push(mark)
41
+ }
42
+
43
+ return marks
44
+ }
45
+
46
+ /**
47
+ * Whether the annotation index carries this text. Line-number gutters and text
48
+ * that already belongs to another annotation are outside it. Everything that
49
+ * records a snippet or its context is matched back against that index later, so
50
+ * all of them have to skip exactly the same runs.
51
+ */
52
+ export function isIndexedText(node: Text): boolean {
53
+ const parent = node.parentElement
54
+ if (parent?.classList.contains('select-none')) return false
55
+ if (parent?.closest('[data-annotation-id]')) return false
56
+ return true
57
+ }
58
+
59
+ function getTextNodesInRange(range: Range): Text[] {
60
+ const nodes: Text[] = []
61
+ const ancestor = range.commonAncestorContainer
62
+
63
+ if (ancestor.nodeType === Node.TEXT_NODE) {
64
+ nodes.push(ancestor as Text)
65
+ return nodes
66
+ }
67
+
68
+ const walker = document.createTreeWalker(ancestor, NodeFilter.SHOW_TEXT)
69
+ let node: Text | null
70
+ while ((node = walker.nextNode() as Text | null)) {
71
+ if (!isIndexedText(node)) continue
72
+ if (range.intersectsNode(node)) {
73
+ nodes.push(node)
74
+ }
75
+ }
76
+
77
+ return nodes
78
+ }
79
+
80
+ /** The part of a text node that falls inside the range. */
81
+ function clampToRange(node: Text, range: Range): string {
82
+ const nodeRange = document.createRange()
83
+ nodeRange.selectNodeContents(node)
84
+ if (node === range.startContainer) nodeRange.setStart(node, range.startOffset)
85
+ if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset)
86
+ return nodeRange.toString()
87
+ }
88
+
89
+ /**
90
+ * A selection's text as the annotation index will read it back. The raw
91
+ * selection is not that: it carries the gutter and any text already inside
92
+ * another annotation, none of which the index holds — so a snippet taken from
93
+ * it names something that cannot be found again.
94
+ */
95
+ export function rangeIndexText(range: Range): string {
96
+ let text = ''
97
+ for (const node of getTextNodesInRange(range)) {
98
+ text += clampToRange(node, range)
99
+ }
100
+ return text
101
+ }
102
+
103
+ /**
104
+ * Update the visual state of marks for a given annotation ID.
105
+ */
106
+ export function setMarkActive(annotationId: string, active: boolean) {
107
+ const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`)
108
+ for (const mark of marks) {
109
+ ;(mark as HTMLElement).classList.toggle('mark-active', active)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Update active state for annotation marks.
115
+ * Only touches marks for the previous and current active IDs to avoid full DOM scans.
116
+ */
117
+ export function updateAllMarkStates(activeId: string | null, prevActiveId?: string | null) {
118
+ if (prevActiveId != null && prevActiveId !== activeId) {
119
+ setMarkActive(prevActiveId, false)
120
+ }
121
+ if (activeId != null) {
122
+ setMarkActive(activeId, true)
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Rename annotation ID on existing marks (e.g. temp → real ID).
128
+ */
129
+ export function renameMarkId(oldId: string, newId: string) {
130
+ const marks = document.querySelectorAll(`[data-annotation-id="${oldId}"]`)
131
+ for (const mark of marks) {
132
+ mark.setAttribute('data-annotation-id', newId)
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Restore marks for persisted annotations by finding their snippet text in the DOM.
138
+ * Uses before/after context for disambiguation when the same text appears multiple times.
139
+ */
140
+ export function restoreMarks(
141
+ container: HTMLElement,
142
+ annotations: { id: string; snippet: string; filePath?: string; context?: { before: string; after: string; hierarchy: string[] } }[],
143
+ ) {
144
+ for (const ann of annotations) {
145
+ // A canvas can be rendered more than once (overview, compare, hidden
146
+ // source panes), so restore decisions must be scoped to this container.
147
+ if (container.querySelector(`[data-annotation-id="${ann.id}"]`)) continue
148
+
149
+ const snippet = ann.snippet
150
+ if (!snippet) continue
151
+
152
+ // Rebuild after every inserted mark. Wrapping text splits and moves text
153
+ // nodes, so a cached offset map can point at stale DOM after the first hit.
154
+ const { fullText, textNodes } = buildTextIndex(container)
155
+
156
+ // Find all occurrences of the snippet in the full text
157
+ const occurrences: number[] = []
158
+ let searchFrom = 0
159
+ while (true) {
160
+ const idx = fullText.indexOf(snippet, searchFrom)
161
+ if (idx === -1) break
162
+ occurrences.push(idx)
163
+ searchFrom = idx + 1
164
+ }
165
+
166
+ const [firstOccurrence] = occurrences
167
+ if (firstOccurrence === undefined) continue
168
+
169
+ // Pick the best occurrence using context
170
+ let bestIdx = firstOccurrence
171
+ if (occurrences.length > 1 && ann.context) {
172
+ let bestScore = -1
173
+ for (const idx of occurrences) {
174
+ let score = 0
175
+ if (ann.context.before) {
176
+ const preceding = fullText.slice(Math.max(0, idx - 80), idx)
177
+ if (preceding.includes(ann.context.before)) score += 2
178
+ else if (ann.context.before.length > 10 && preceding.includes(ann.context.before.slice(-10))) score += 1
179
+ }
180
+ if (ann.context.after) {
181
+ const following = fullText.slice(idx + snippet.length, idx + snippet.length + 80)
182
+ if (following.includes(ann.context.after)) score += 2
183
+ else if (ann.context.after.length > 10 && following.includes(ann.context.after.slice(0, 10))) score += 1
184
+ }
185
+ if (score > bestScore) {
186
+ bestScore = score
187
+ bestIdx = idx
188
+ }
189
+ }
190
+ }
191
+
192
+ // Map text offsets to DOM positions
193
+ const startOffset = bestIdx
194
+ const endOffset = bestIdx + snippet.length
195
+
196
+ const startPos = offsetToNode(textNodes, startOffset)
197
+ const endPos = offsetToNode(textNodes, endOffset)
198
+ if (!startPos || !endPos) continue
199
+
200
+ try {
201
+ const range = document.createRange()
202
+ range.setStart(startPos.node, startPos.offset)
203
+ range.setEnd(endPos.node, endPos.offset)
204
+ wrapRangeWithMark(range, ann.id)
205
+ } catch {}
206
+ }
207
+ }
208
+
209
+ function buildTextIndex(container: HTMLElement): {
210
+ fullText: string
211
+ textNodes: { node: Text; start: number }[]
212
+ } {
213
+ const textNodes: { node: Text; start: number }[] = []
214
+ let fullText = ''
215
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT)
216
+ let tn: Text | null
217
+ while ((tn = walker.nextNode() as Text | null)) {
218
+ if (!isIndexedText(tn)) continue
219
+ textNodes.push({ node: tn, start: fullText.length })
220
+ fullText += tn.textContent || ''
221
+ }
222
+ return { fullText, textNodes }
223
+ }
224
+
225
+ function offsetToNode(
226
+ textNodes: { node: Text; start: number }[],
227
+ offset: number,
228
+ ): { node: Text; offset: number } | null {
229
+ for (let i = textNodes.length - 1; i >= 0; i--) {
230
+ const entry = textNodes[i]
231
+ if (!entry || entry.start > offset) continue
232
+ const localOffset = offset - entry.start
233
+ const nodeLen = entry.node.textContent?.length ?? 0
234
+ if (localOffset <= nodeLen) {
235
+ return { node: entry.node, offset: localOffset }
236
+ }
237
+ }
238
+ return null
239
+ }
240
+
241
+ /**
242
+ * Remove the decoration for a given annotation ID, restoring the original text
243
+ * nodes an inline mark wrapped.
244
+ */
245
+ export function unwrapMarks(annotationId: string) {
246
+ const marks = document.querySelectorAll(`[data-annotation-id="${annotationId}"]`)
247
+ for (const mark of marks) {
248
+ const parent = mark.parentNode
249
+ if (!parent) continue
250
+ // Decoration that is not an inline mark — a region overlay — wraps no text
251
+ // and is simply taken out.
252
+ if (mark.tagName !== 'MARK') {
253
+ parent.removeChild(mark)
254
+ continue
255
+ }
256
+ while (mark.firstChild) {
257
+ parent.insertBefore(mark.firstChild, mark)
258
+ }
259
+ parent.removeChild(mark)
260
+ parent.normalize()
261
+ }
262
+ }
@@ -0,0 +1,46 @@
1
+ /** Width assumed when the caller does not say — the narrow popover. */
2
+ const DEFAULT_POPOVER_WIDTH = 280
3
+
4
+ /**
5
+ * Read a CSS width the popover will be styled with. Only px is a width this
6
+ * placement can reason about; anything else falls back to the default rather
7
+ * than clamping against a number that means something different.
8
+ */
9
+ export function pxWidth(css: string): number | undefined {
10
+ const match = /^(\d+(?:\.\d+)?)px$/.exec(css.trim())
11
+ return match ? Number(match[1]) : undefined
12
+ }
13
+
14
+ /**
15
+ * Calculate popover position relative to an anchor element.
16
+ * Returns absolute position within the scroll container (or body).
17
+ */
18
+ export function getPopoverPosition(
19
+ anchor: HTMLElement,
20
+ scrollContainer?: HTMLElement | null,
21
+ popoverWidth: number = DEFAULT_POPOVER_WIDTH,
22
+ ): { style: Record<string, string>; parent: HTMLElement } {
23
+ const gap = 8
24
+ const parent = scrollContainer || document.body
25
+
26
+ // Use getBoundingClientRect for both anchor and parent,
27
+ // then add scroll offsets to get absolute position within parent
28
+ const anchorRect = anchor.getBoundingClientRect()
29
+ const parentRect = parent.getBoundingClientRect()
30
+
31
+ // Both rects are viewport-relative, so the subtraction has already cancelled
32
+ // page scroll. Only a scrolling parent adds to that — the body is not what
33
+ // scrolls a document, and counting window.scrollY here counted it twice.
34
+ const scrollTop = parent === document.body ? 0 : parent.scrollTop
35
+ const top = anchorRect.bottom - parentRect.top + scrollTop + gap
36
+
37
+ // Horizontal: clamp to parent width
38
+ const left = Math.max(0, Math.min(anchorRect.left - parentRect.left, parentRect.width - popoverWidth - 12))
39
+
40
+ parent.style.position = 'relative'
41
+
42
+ return {
43
+ style: { position: 'absolute', top: `${top}px`, left: `${left}px` },
44
+ parent,
45
+ }
46
+ }