@fabrika/canvas-kernel 0.1.45 → 0.3.1

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.
Files changed (70) hide show
  1. package/README.md +33 -67
  2. package/index.ts +20 -83
  3. package/package.json +69 -72
  4. package/src/client/AnnotationProvider.tsx +218 -255
  5. package/src/client/AnnotationSidebar.tsx +393 -338
  6. package/src/client/FileIcon.tsx +105 -66
  7. package/src/client/PlanRenderer.tsx +607 -551
  8. package/src/client/RenderErrorContext.ts +7 -7
  9. package/src/client/ResponsePreview.tsx +192 -173
  10. package/src/client/annotationDom.ts +43 -97
  11. package/src/client/annotationDraft.ts +106 -96
  12. package/src/client/appContext.tsx +40 -40
  13. package/src/client/components/Callout.tsx +20 -20
  14. package/src/client/components/Checklist.tsx +27 -29
  15. package/src/client/components/Choice.tsx +178 -159
  16. package/src/client/components/CodeBlock.tsx +27 -26
  17. package/src/client/components/Diff.tsx +16 -17
  18. package/src/client/components/FilePreview.tsx +102 -74
  19. package/src/client/components/Image.tsx +41 -39
  20. package/src/client/components/Markdown.tsx +33 -32
  21. package/src/client/components/Mermaid.tsx +593 -542
  22. package/src/client/components/Note.tsx +8 -8
  23. package/src/client/components/Priority.tsx +10 -12
  24. package/src/client/components/ResponseNote.tsx +16 -15
  25. package/src/client/components/Section.tsx +28 -26
  26. package/src/client/components/Table.tsx +33 -30
  27. package/src/client/components/Task.tsx +58 -60
  28. package/src/client/components/UserInput.tsx +169 -152
  29. package/src/client/components/index.ts +16 -16
  30. package/src/client/components/renderMarkdownChildren.tsx +86 -68
  31. package/src/client/diffLines.ts +51 -39
  32. package/src/client/generateMarkdown.ts +197 -191
  33. package/src/client/hostContext.tsx +39 -34
  34. package/src/client/index.ts +44 -125
  35. package/src/client/pageGlobals.ts +25 -0
  36. package/src/client/persistenceSchemas.ts +41 -0
  37. package/src/client/preact-all.ts +4 -4
  38. package/src/client/runtime.ts +58 -111
  39. package/src/client/styles.css +1 -74
  40. package/src/engine/compiler.ts +411 -323
  41. package/src/engine/file-admission.ts +25 -0
  42. package/src/engine/paths.ts +44 -44
  43. package/src/engine/session.ts +515 -626
  44. package/src/engine/sessionSchemas.ts +71 -0
  45. package/src/engine/watcher.ts +70 -60
  46. package/src/engine/websocket.ts +134 -132
  47. package/annotate.ts +0 -94
  48. package/daemon.ts +0 -46
  49. package/src/client/AnnotationDraftFooter.tsx +0 -76
  50. package/src/client/AnnotationEditor.tsx +0 -320
  51. package/src/client/AnnotationList.tsx +0 -190
  52. package/src/client/Popover.tsx +0 -361
  53. package/src/client/SessionContext.tsx +0 -3
  54. package/src/client/annotationContext.ts +0 -130
  55. package/src/client/annotationMarkdown.ts +0 -102
  56. package/src/client/annotationTarget.ts +0 -73
  57. package/src/client/blockTarget.ts +0 -127
  58. package/src/client/highlightRange.ts +0 -262
  59. package/src/client/popoverPosition.ts +0 -46
  60. package/src/client/regionTarget.ts +0 -180
  61. package/src/client/testing/dom.ts +0 -74
  62. package/src/client/textTarget.ts +0 -31
  63. package/src/client/useRegionAnnotation.tsx +0 -188
  64. package/src/client/useTextAnnotation.tsx +0 -194
  65. package/src/client/utils.ts +0 -33
  66. package/src/engine/channel.ts +0 -278
  67. package/src/engine/http-utils.ts +0 -14
  68. package/src/engine/lifecycle.ts +0 -302
  69. package/src/engine/router.ts +0 -16
  70. package/src/engine/waiter.ts +0 -188
@@ -1,11 +1,11 @@
1
- import { createContext } from "react";
1
+ import { createContext } from 'react'
2
2
 
3
3
  export interface CanvasRenderError {
4
- revision: number;
5
- filename: string;
6
- message: string;
7
- stack?: string;
8
- componentStack?: string;
4
+ revision: number
5
+ filename: string
6
+ message: string
7
+ stack?: string
8
+ componentStack?: string
9
9
  }
10
10
 
11
- export const RenderErrorContext = createContext<(error: CanvasRenderError) => void>(() => {});
11
+ export const RenderErrorContext = createContext<(error: CanvasRenderError) => void>(() => {})
@@ -1,186 +1,205 @@
1
- import React, { useEffect, useState, useRef, useMemo } from "react";
2
- import { marked } from "marked";
3
- import { useAnnotations } from "./AnnotationProvider";
4
- import { generateMarkdown, getMissingRequiredLabels } from "./generateMarkdown";
1
+ import { marked } from 'marked'
2
+ import { useEffect, useMemo, useRef, useState } from 'react'
3
+ import { useAnnotations } from './AnnotationProvider'
4
+ import { generateMarkdown, getMissingRequiredLabels } from './generateMarkdown'
5
5
 
6
6
  interface ResponsePreviewProps {
7
- open: boolean;
8
- onClose: () => void;
9
- onSubmit: (feedback: string) => void;
10
- includedRemoteIds: Set<string>;
11
- onToggleRemoteId: (id: string) => void;
7
+ open: boolean
8
+ onClose: () => void
9
+ onSubmit: (feedback: string) => void
10
+ includedRemoteIds: Set<string>
11
+ onToggleRemoteId: (id: string) => void
12
12
  }
13
13
 
14
14
  export function ResponsePreview({ open, onClose, onSubmit, includedRemoteIds, onToggleRemoteId }: ResponsePreviewProps) {
15
- const { annotations, generalNote, submittableResponses, feedbackEntries } = useAnnotations();
16
- const [editedText, setEditedText] = useState("");
17
- const [manuallyEdited, setManuallyEdited] = useState(false);
18
- const [editMode, setEditMode] = useState(false);
19
- const [validationError, setValidationError] = useState<string | null>(null);
20
- const textareaRef = useRef<HTMLTextAreaElement>(null);
21
- const [prevOpen, setPrevOpen] = useState(false);
22
-
23
- const remoteAnnotations = useMemo(
24
- () => annotations.filter((a) => a.source === "remote"),
25
- [annotations],
26
- );
27
-
28
- if (open && !prevOpen) {
29
- setManuallyEdited(false);
30
- setEditMode(false);
31
- setValidationError(null);
32
- }
33
- if (!open && prevOpen) {
34
- setManuallyEdited(false);
35
- setEditMode(false);
36
- setValidationError(null);
37
- }
38
- if (open !== prevOpen) {
39
- setPrevOpen(open);
40
- }
41
-
42
- const generatedText = useMemo(
43
- () => generateMarkdown(annotations, generalNote, submittableResponses, feedbackEntries, includedRemoteIds),
44
- [annotations, generalNote, submittableResponses, feedbackEntries, includedRemoteIds],
45
- );
46
- const text = manuallyEdited ? editedText : generatedText;
47
-
48
- useEffect(() => {
49
- if (open && editMode && textareaRef.current) {
50
- textareaRef.current.focus();
51
- }
52
- }, [open, editMode]);
53
-
54
- useEffect(() => {
55
- if (!open) return;
56
- const handleKeyDown = (e: KeyboardEvent) => {
57
- if (e.key === "Escape") onClose();
58
- };
59
- window.addEventListener("keydown", handleKeyDown);
60
- return () => window.removeEventListener("keydown", handleKeyDown);
61
- }, [open, onClose]);
62
-
63
- if (!open) return null;
64
-
65
- const hasContent = text.trim().length > 0;
66
-
67
- return (
68
- <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-8" onClick={onClose}>
69
- <div className="bg-bg-surface border border-border-medium rounded-xl shadow-lg w-full max-w-2xl flex flex-col" style={{ height: "80vh" }} onClick={(e) => e.stopPropagation()}>
70
- {/* Header */}
71
- <div className="flex items-center justify-between px-5 py-3 border-b border-border-subtle flex-shrink-0">
72
- <div className="flex items-center gap-3">
73
- <span className="text-[13px] font-medium text-text-primary font-body">Response preview</span>
74
- {manuallyEdited && (
75
- <button onClick={() => { setManuallyEdited(false); setEditMode(false); }} className="text-[11px] text-text-tertiary hover:text-text-secondary font-body">
76
- Reset
77
- </button>
78
- )}
79
- </div>
80
- <div className="flex items-center gap-2">
81
- {/* Mode toggle */}
82
- <div className="flex items-center bg-bg-base rounded-md p-0.5">
83
- <button
84
- onClick={() => setEditMode(false)}
85
- className={`px-2.5 py-1 rounded text-[11px] font-body font-medium transition-colors ${
86
- !editMode ? "bg-bg-elevated text-text-primary shadow-sm" : "text-text-tertiary hover:text-text-secondary"
87
- }`}
88
- >
89
- Preview
90
- </button>
91
- <button
92
- onClick={() => setEditMode(true)}
93
- className={`px-2.5 py-1 rounded text-[11px] font-body font-medium transition-colors ${
94
- editMode ? "bg-bg-elevated text-text-primary shadow-sm" : "text-text-tertiary hover:text-text-secondary"
95
- }`}
96
- >
97
- Edit
98
- </button>
99
- </div>
100
- <button onClick={onClose} className="text-text-tertiary hover:text-text-secondary w-7 h-7 flex items-center justify-center rounded-md hover:bg-border-subtle transition-colors">
101
- &#x2715;
102
- </button>
103
- </div>
104
- </div>
105
-
106
- {/* Content */}
107
- {editMode ? (
108
- <textarea
109
- ref={textareaRef}
110
- value={text}
111
- onChange={(e) => { setEditedText(e.currentTarget.value); setManuallyEdited(true); }}
112
- className="flex-1 w-full bg-bg-base p-5 text-[13px] font-mono text-text-code resize-none focus:outline-none leading-relaxed placeholder:text-text-disabled"
113
- placeholder="Your feedback will appear here..."
114
- />
115
- ) : (
116
- <div className="flex-1 overflow-y-auto p-5">
117
- <MarkdownPreview text={text} />
118
- </div>
119
- )}
120
-
121
- {/* Remote feedback summary */}
122
- {remoteAnnotations.length > 0 && (
123
- <div className="px-5 py-2 border-t border-border-subtle flex-shrink-0">
124
- <span className="text-[12px] text-text-secondary font-body">
125
- Reviewer feedback: {includedRemoteIds.size}/{remoteAnnotations.length} included
126
- </span>
127
- </div>
128
- )}
129
-
130
- {/* Actions */}
131
- {validationError && (
132
- <div className="px-5 py-2 text-[12px] text-accent-red font-body border-t border-border-subtle flex-shrink-0 flex items-center justify-between gap-2">
133
- <span>{validationError}</span>
134
- <button
135
- onClick={() => {
136
- setValidationError(null);
137
- onSubmit(text);
138
- }}
139
- className="text-[11px] text-text-tertiary hover:text-text-secondary font-body whitespace-nowrap underline"
140
- >
141
- Submit anyway
142
- </button>
143
- </div>
144
- )}
145
- <div className="flex justify-end gap-2 px-5 py-3 border-t border-border-subtle flex-shrink-0">
146
- <button
147
- onClick={onClose}
148
- className="px-4 py-2 rounded-lg text-[13px] font-body text-text-tertiary hover:text-text-secondary transition-colors"
149
- >
150
- Cancel
151
- </button>
152
- <button
153
- onClick={() => {
154
- const allMissing = getMissingRequiredLabels(submittableResponses, feedbackEntries);
155
- if (allMissing.length > 0) {
156
- setValidationError(`Please answer: ${allMissing.join(", ")}`);
157
- return;
158
- }
159
- setValidationError(null);
160
- onSubmit(text);
161
- }}
162
- disabled={!hasContent}
163
- className={`px-6 py-2 rounded-lg font-body text-[13px] font-medium transition-all ${
164
- hasContent
165
- ? "bg-btn-primary text-btn-primary-text hover:opacity-90"
166
- : "bg-bg-input text-text-disabled cursor-default"
167
- }`}
168
- >
169
- Submit feedback
170
- </button>
171
- </div>
172
- </div>
173
- </div>
174
- );
15
+ const { annotations, generalNote, submittableResponses, feedbackEntries } = useAnnotations()
16
+ const [editedText, setEditedText] = useState('')
17
+ const [manuallyEdited, setManuallyEdited] = useState(false)
18
+ const [editMode, setEditMode] = useState(false)
19
+ const [validationError, setValidationError] = useState<string | null>(null)
20
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
21
+ const [prevOpen, setPrevOpen] = useState(false)
22
+
23
+ const remoteAnnotations = useMemo(
24
+ () => annotations.filter((a) => a.source === 'remote'),
25
+ [annotations],
26
+ )
27
+
28
+ if (open && !prevOpen) {
29
+ setManuallyEdited(false)
30
+ setEditMode(false)
31
+ setValidationError(null)
32
+ }
33
+ if (!open && prevOpen) {
34
+ setManuallyEdited(false)
35
+ setEditMode(false)
36
+ setValidationError(null)
37
+ }
38
+ if (open !== prevOpen) {
39
+ setPrevOpen(open)
40
+ }
41
+
42
+ const generatedText = useMemo(
43
+ () => generateMarkdown(annotations, generalNote, submittableResponses, feedbackEntries, includedRemoteIds),
44
+ [annotations, generalNote, submittableResponses, feedbackEntries, includedRemoteIds],
45
+ )
46
+ const text = manuallyEdited ? editedText : generatedText
47
+
48
+ useEffect(() => {
49
+ if (open && editMode && textareaRef.current) {
50
+ textareaRef.current.focus()
51
+ }
52
+ }, [open, editMode])
53
+
54
+ useEffect(() => {
55
+ if (!open) return
56
+ const handleKeyDown = (e: KeyboardEvent) => {
57
+ if (e.key === 'Escape') onClose()
58
+ }
59
+ window.addEventListener('keydown', handleKeyDown)
60
+ return () => window.removeEventListener('keydown', handleKeyDown)
61
+ }, [open, onClose])
62
+
63
+ if (!open) return null
64
+
65
+ const hasContent = text.trim().length > 0
66
+
67
+ return (
68
+ <div className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm flex items-center justify-center p-8" onClick={onClose}>
69
+ <div
70
+ className="bg-bg-surface border border-border-medium rounded-xl shadow-lg w-full max-w-2xl flex flex-col"
71
+ style={{ height: '80vh' }}
72
+ onClick={(e) => e.stopPropagation()}
73
+ >
74
+ {/* Header */}
75
+ <div className="flex items-center justify-between px-5 py-3 border-b border-border-subtle flex-shrink-0">
76
+ <div className="flex items-center gap-3">
77
+ <span className="text-[13px] font-medium text-text-primary font-body">Response preview</span>
78
+ {manuallyEdited && (
79
+ <button
80
+ onClick={() => {
81
+ setManuallyEdited(false)
82
+ setEditMode(false)
83
+ }}
84
+ className="text-[11px] text-text-tertiary hover:text-text-secondary font-body"
85
+ >
86
+ Reset
87
+ </button>
88
+ )}
89
+ </div>
90
+ <div className="flex items-center gap-2">
91
+ {/* Mode toggle */}
92
+ <div className="flex items-center bg-bg-base rounded-md p-0.5">
93
+ <button
94
+ onClick={() => setEditMode(false)}
95
+ className={`px-2.5 py-1 rounded text-[11px] font-body font-medium transition-colors ${
96
+ !editMode ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
97
+ }`}
98
+ >
99
+ Preview
100
+ </button>
101
+ <button
102
+ onClick={() => setEditMode(true)}
103
+ className={`px-2.5 py-1 rounded text-[11px] font-body font-medium transition-colors ${
104
+ editMode ? 'bg-bg-elevated text-text-primary shadow-sm' : 'text-text-tertiary hover:text-text-secondary'
105
+ }`}
106
+ >
107
+ Edit
108
+ </button>
109
+ </div>
110
+ <button
111
+ onClick={onClose}
112
+ className="text-text-tertiary hover:text-text-secondary w-7 h-7 flex items-center justify-center rounded-md hover:bg-border-subtle transition-colors"
113
+ >
114
+ &#x2715;
115
+ </button>
116
+ </div>
117
+ </div>
118
+
119
+ {/* Content */}
120
+ {editMode
121
+ ? (
122
+ <textarea
123
+ ref={textareaRef}
124
+ value={text}
125
+ onChange={(e) => {
126
+ setEditedText(e.currentTarget.value)
127
+ setManuallyEdited(true)
128
+ }}
129
+ className="flex-1 w-full bg-bg-base p-5 text-[13px] font-mono text-text-code resize-none focus:outline-none leading-relaxed placeholder:text-text-disabled"
130
+ placeholder="Your feedback will appear here..."
131
+ />
132
+ )
133
+ : (
134
+ <div className="flex-1 overflow-y-auto p-5">
135
+ <MarkdownPreview text={text} />
136
+ </div>
137
+ )}
138
+
139
+ {/* Remote feedback summary */}
140
+ {remoteAnnotations.length > 0 && (
141
+ <div className="px-5 py-2 border-t border-border-subtle flex-shrink-0">
142
+ <span className="text-[12px] text-text-secondary font-body">
143
+ Reviewer feedback: {includedRemoteIds.size}/{remoteAnnotations.length} included
144
+ </span>
145
+ </div>
146
+ )}
147
+
148
+ {/* Actions */}
149
+ {validationError && (
150
+ <div className="px-5 py-2 text-[12px] text-accent-red font-body border-t border-border-subtle flex-shrink-0 flex items-center justify-between gap-2">
151
+ <span>{validationError}</span>
152
+ <button
153
+ onClick={() => {
154
+ setValidationError(null)
155
+ onSubmit(text)
156
+ }}
157
+ className="text-[11px] text-text-tertiary hover:text-text-secondary font-body whitespace-nowrap underline"
158
+ >
159
+ Submit anyway
160
+ </button>
161
+ </div>
162
+ )}
163
+ <div className="flex justify-end gap-2 px-5 py-3 border-t border-border-subtle flex-shrink-0">
164
+ <button
165
+ onClick={onClose}
166
+ className="px-4 py-2 rounded-lg text-[13px] font-body text-text-tertiary hover:text-text-secondary transition-colors"
167
+ >
168
+ Cancel
169
+ </button>
170
+ <button
171
+ onClick={() => {
172
+ const allMissing = getMissingRequiredLabels(submittableResponses, feedbackEntries)
173
+ if (allMissing.length > 0) {
174
+ setValidationError(`Please answer: ${allMissing.join(', ')}`)
175
+ return
176
+ }
177
+ setValidationError(null)
178
+ onSubmit(text)
179
+ }}
180
+ disabled={!hasContent}
181
+ className={`px-6 py-2 rounded-lg font-body text-[13px] font-medium transition-all ${
182
+ hasContent
183
+ ? 'bg-btn-primary text-btn-primary-text hover:opacity-90'
184
+ : 'bg-bg-input text-text-disabled cursor-default'
185
+ }`}
186
+ >
187
+ Submit feedback
188
+ </button>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ )
175
193
  }
176
194
 
177
- const UPLOADS_PATH_RE = /!\[([^\]]*)\]\((\/tmp\/agent-canvas\/uploads\/([^)]+))\)/g;
195
+ const UPLOADS_PATH_RE = /!\[([^\]]*)\]\((\/tmp\/agent-canvas\/uploads\/([^)]+))\)/g
178
196
 
179
197
  function rewriteUploadPaths(md: string): string {
180
- return md.replace(UPLOADS_PATH_RE, (_match, alt, _fullPath, filename) => `![${alt}](/api/uploads/${filename})`);
198
+ return md.replace(UPLOADS_PATH_RE, (_match, alt, _fullPath, filename) => `![${alt}](/api/uploads/${filename})`)
181
199
  }
182
200
 
183
201
  export function MarkdownPreview({ text }: { text: string }) {
184
- const html = useMemo(() => marked.parse(rewriteUploadPaths(text), { async: false }) as string, [text]);
185
- return <div className="prose-canvas font-body text-body text-text-primary leading-relaxed" dangerouslySetInnerHTML={{ __html: html }} />;
202
+ const html = useMemo(() => marked.parse(rewriteUploadPaths(text), { async: false }) as string, [text])
203
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: Canvas modules already execute as code in this document.
204
+ return <div className="prose-canvas font-body text-body text-text-primary leading-relaxed" dangerouslySetInnerHTML={{ __html: html }} />
186
205
  }
@@ -1,93 +1,39 @@
1
- import type { Annotation } from "./AnnotationProvider";
2
- import type { ActiveView } from "./appContext";
3
- import { sealTarget, type SealedAnnotationTarget, type TargetAnnotation } from "./annotationTarget";
4
- import { blockTarget } from "./blockTarget";
5
- import { regionTarget } from "./regionTarget";
6
- import { textTarget } from "./textTarget";
7
- import { fileAnnotationPath } from "./utils";
8
-
9
- // The block selectors and the block snippet live with the block target now;
10
- // re-exported here because this is the path every caller already imports.
11
- export { ANNOTATABLE_SELECTOR, BLOCK_SELECTOR, getBlockSnippet } from "./blockTarget";
12
-
13
- /** Every locator strategy the kernel knows, cheapest and most specific first.
14
- * Text claims any snippet at all, so it stays last. */
15
- export const ANNOTATION_TARGETS: SealedAnnotationTarget[] = [
16
- sealTarget(regionTarget),
17
- sealTarget(blockTarget),
18
- sealTarget(textTarget),
19
- ];
20
-
21
- /**
22
- * The element a snippet names inside `root`.
23
- *
24
- * Every target is asked, not just the first one that recognises the string: a
25
- * file annotation on a line that happens to read like a block snippet must
26
- * still fall through to the target that can actually resolve it.
27
- */
28
- export function findSnippetElement(snippet: string, root: ParentNode): HTMLElement | null {
29
- for (const target of ANNOTATION_TARGETS) {
30
- const el = target.find(snippet, root);
31
- if (el) return el;
32
- }
33
- return null;
34
- }
35
-
36
- /**
37
- * Put every annotation's own decoration back after a render — inline marks,
38
- * region overlays. Each target is asked about each annotation; a snippet is not
39
- * owned exclusively, and every restore is idempotent, so asking all of them is
40
- * both correct and free.
41
- */
42
- export function restoreAnnotationTargets(root: HTMLElement, annotations: readonly TargetAnnotation[]): void {
43
- for (const ann of annotations) {
44
- if (!ann.snippet) continue;
45
- for (const target of ANNOTATION_TARGETS) {
46
- target.restore(root, ann);
47
- }
48
- }
49
- }
50
-
51
- /** The snippet as a reader should see it. Only a target that encodes something
52
- * other than readable text has anything to say; the rest already read fine. */
53
- export function describeSnippet(snippet: string): string {
54
- for (const target of ANNOTATION_TARGETS) {
55
- const described = target.describe(snippet);
56
- if (described !== null) return described;
57
- }
58
- return snippet;
59
- }
1
+ import { fileAnnotationPath, findSnippetElement } from '@fabrika/annotations'
2
+ import type { Annotation } from './AnnotationProvider'
3
+ import type { ActiveView } from './appContext'
60
4
 
61
5
  /**
62
6
  * Find the DOM element for an annotation — the decoration this kernel drew for
63
7
  * it, or failing that whatever its snippet names.
64
8
  */
65
9
  export function findAnnotationElement(ann: Annotation): HTMLElement | null {
66
- // Decoration wins over the snippet: a mark or a region box names the exact
67
- // run that was annotated, where a snippet only names something that reads the
68
- // same today.
69
- const decoration = document.querySelector(`[data-annotation-id="${ann.id}"]`);
70
- if (decoration instanceof HTMLElement) return decoration;
71
-
72
- // A snippet only identifies its target within its own canvas, and the
73
- // overview mounts every canvas at once — searching the whole document lets an
74
- // identical block in a different canvas win on document order. No container
75
- // for the canvas means it is not on screen, which is a miss, not a reason to
76
- // look elsewhere.
77
- const root = ann.canvasFile
78
- ? document.querySelector(`[data-canvas-file="${ann.canvasFile}"]`)
79
- : document;
80
- if (!root) return null;
81
-
82
- return findSnippetElement(ann.snippet, root);
10
+ // Decoration wins over the snippet: a mark or a region box names the exact
11
+ // run that was annotated, where a snippet only names something that reads the
12
+ // same today.
13
+ const decoration = document.querySelector(`[data-annotation-id="${ann.id}"]`)
14
+ if (decoration instanceof HTMLElement) return decoration
15
+
16
+ // A snippet only identifies its target within its own canvas, and the
17
+ // overview mounts every canvas at once — searching the whole document lets an
18
+ // identical block in a different canvas win on document order. No container
19
+ // for the canvas means it is not on screen, which is a miss, not a reason to
20
+ // look elsewhere.
21
+ const root = ann.canvasFile
22
+ ? document.querySelector(`[data-canvas-file="${ann.canvasFile}"]`)
23
+ : document
24
+ if (!root) return null
25
+
26
+ return findSnippetElement(ann.snippet, root)
83
27
  }
84
28
 
85
29
  /** Flash/pulse the element briefly — blinks bg via CSS animation */
86
30
  function flashElement(el: HTMLElement) {
87
- el.classList.remove("ann-flash");
88
- void el.offsetWidth; // force reflow to restart
89
- el.classList.add("ann-flash");
90
- el.addEventListener("animationend", () => { el.classList.remove("ann-flash"); }, { once: true });
31
+ el.classList.remove('ann-flash')
32
+ void el.offsetWidth // force reflow to restart
33
+ el.classList.add('ann-flash')
34
+ el.addEventListener('animationend', () => {
35
+ el.classList.remove('ann-flash')
36
+ }, { once: true })
91
37
  }
92
38
 
93
39
  /**
@@ -95,21 +41,21 @@ function flashElement(el: HTMLElement) {
95
41
  * Handles file annotations by switching view first.
96
42
  */
97
43
  export function scrollToAnnotation(ann: Annotation, setActiveView: (view: ActiveView) => void) {
98
- const filePath = fileAnnotationPath(ann);
99
- if (filePath) {
100
- setActiveView({ type: "file", path: filePath });
101
- setTimeout(() => {
102
- const el = findAnnotationElement(ann);
103
- if (el) {
104
- el.scrollIntoView({ behavior: "smooth", block: "center" });
105
- flashElement(el);
106
- }
107
- }, 150);
108
- } else {
109
- const el = findAnnotationElement(ann);
110
- if (el) {
111
- el.scrollIntoView({ behavior: "smooth", block: "center" });
112
- flashElement(el);
113
- }
114
- }
44
+ const filePath = fileAnnotationPath(ann)
45
+ if (filePath) {
46
+ setActiveView({ type: 'file', path: filePath })
47
+ setTimeout(() => {
48
+ const el = findAnnotationElement(ann)
49
+ if (el) {
50
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
51
+ flashElement(el)
52
+ }
53
+ }, 150)
54
+ } else {
55
+ const el = findAnnotationElement(ann)
56
+ if (el) {
57
+ el.scrollIntoView({ behavior: 'smooth', block: 'center' })
58
+ flashElement(el)
59
+ }
60
+ }
115
61
  }