@fabrika/canvas-kernel 0.1.44

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 (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/annotate.ts +94 -0
  4. package/daemon.ts +46 -0
  5. package/index.ts +84 -0
  6. package/package.json +74 -0
  7. package/src/client/AnnotationDraftFooter.tsx +76 -0
  8. package/src/client/AnnotationEditor.tsx +320 -0
  9. package/src/client/AnnotationList.tsx +190 -0
  10. package/src/client/AnnotationProvider.tsx +294 -0
  11. package/src/client/AnnotationSidebar.tsx +374 -0
  12. package/src/client/FileIcon.tsx +76 -0
  13. package/src/client/PlanRenderer.tsx +587 -0
  14. package/src/client/Popover.tsx +361 -0
  15. package/src/client/RenderErrorContext.ts +11 -0
  16. package/src/client/ResponsePreview.tsx +186 -0
  17. package/src/client/SessionContext.tsx +3 -0
  18. package/src/client/annotationContext.ts +130 -0
  19. package/src/client/annotationDom.ts +115 -0
  20. package/src/client/annotationDraft.ts +130 -0
  21. package/src/client/annotationMarkdown.ts +102 -0
  22. package/src/client/annotationTarget.ts +73 -0
  23. package/src/client/appContext.tsx +61 -0
  24. package/src/client/blockTarget.ts +127 -0
  25. package/src/client/components/Callout.tsx +27 -0
  26. package/src/client/components/Checklist.tsx +37 -0
  27. package/src/client/components/Choice.tsx +178 -0
  28. package/src/client/components/CodeBlock.tsx +35 -0
  29. package/src/client/components/Diff.tsx +36 -0
  30. package/src/client/components/FilePreview.tsx +88 -0
  31. package/src/client/components/Image.tsx +48 -0
  32. package/src/client/components/Markdown.tsx +44 -0
  33. package/src/client/components/Mermaid.tsx +576 -0
  34. package/src/client/components/Note.tsx +14 -0
  35. package/src/client/components/Priority.tsx +19 -0
  36. package/src/client/components/ResponseNote.tsx +19 -0
  37. package/src/client/components/Section.tsx +33 -0
  38. package/src/client/components/Table.tsx +35 -0
  39. package/src/client/components/Task.tsx +76 -0
  40. package/src/client/components/UserInput.tsx +176 -0
  41. package/src/client/components/index.ts +18 -0
  42. package/src/client/components/renderMarkdownChildren.tsx +100 -0
  43. package/src/client/diffLines.ts +50 -0
  44. package/src/client/generateMarkdown.ts +250 -0
  45. package/src/client/highlightRange.ts +262 -0
  46. package/src/client/hostContext.tsx +55 -0
  47. package/src/client/index.ts +128 -0
  48. package/src/client/popoverPosition.ts +46 -0
  49. package/src/client/preact-all.ts +4 -0
  50. package/src/client/regionTarget.ts +180 -0
  51. package/src/client/runtime.ts +149 -0
  52. package/src/client/styles.css +492 -0
  53. package/src/client/testing/dom.ts +58 -0
  54. package/src/client/textTarget.ts +31 -0
  55. package/src/client/theme.css +66 -0
  56. package/src/client/useRegionAnnotation.tsx +188 -0
  57. package/src/client/useTextAnnotation.tsx +194 -0
  58. package/src/client/utils.ts +33 -0
  59. package/src/engine/channel.ts +278 -0
  60. package/src/engine/compiler.ts +415 -0
  61. package/src/engine/http-utils.ts +14 -0
  62. package/src/engine/lifecycle.ts +302 -0
  63. package/src/engine/paths.ts +63 -0
  64. package/src/engine/router.ts +16 -0
  65. package/src/engine/session.ts +678 -0
  66. package/src/engine/waiter.ts +188 -0
  67. package/src/engine/watcher.ts +84 -0
  68. package/src/engine/websocket.ts +159 -0
@@ -0,0 +1,250 @@
1
+ // The canvas document format: revisions, responses, required fields, sections.
2
+ // Rendering one annotation is host-agnostic and lives in ./annotationMarkdown.
3
+ import type { Annotation, PlanResponse, FeedbackEntry } from "./AnnotationProvider";
4
+ import { renderAnnotation } from "./annotationMarkdown";
5
+ import { RESPONSE_ANNOTATION_PATH } from "./utils";
6
+
7
+ /**
8
+ * Drop answers whose question is not in this revision's canvas. A draft carried
9
+ * forward by carryUnsubmittedDraft() can hold an answer to a question the new
10
+ * revision removed; submitting that would report an answer the author was never
11
+ * asked for.
12
+ *
13
+ * `seenResponseIds` must be the sticky set of controls that have mounted at any
14
+ * point in this draft, never the currently-mounted ones — see canPruneResponses.
15
+ */
16
+ export function pruneStaleResponses(
17
+ responses: Map<string, PlanResponse>,
18
+ seenResponseIds: ReadonlySet<string>,
19
+ ): Map<string, PlanResponse> {
20
+ const out = new Map<string, PlanResponse>();
21
+ for (const [id, r] of responses) {
22
+ if (seenResponseIds.has(id)) out.set(id, r);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ /**
28
+ * Whether the set of seen response ids is complete enough to prune against.
29
+ *
30
+ * An id is only evidence of absence once every canvas that could host it has
31
+ * rendered. Until then "not seen" means "not looked at yet" — the host opens
32
+ * multi-canvas sessions on the overview, where no canvas is mounted at all, so
33
+ * pruning early would throw away every answer the author gave.
34
+ */
35
+ export function canPruneResponses(
36
+ canvasFiles: readonly string[] | undefined,
37
+ renderedCanvases: ReadonlySet<string>,
38
+ ): boolean {
39
+ if (!canvasFiles || canvasFiles.length === 0) return false;
40
+ return canvasFiles.every((filename) => renderedCanvases.has(filename));
41
+ }
42
+
43
+ /**
44
+ * Generate human-readable markdown feedback from annotations and responses.
45
+ */
46
+ export function generateMarkdown(
47
+ annotations: Annotation[],
48
+ generalNote: string,
49
+ responses?: Map<string, PlanResponse>,
50
+ feedbackEntries?: Map<string, FeedbackEntry>,
51
+ includedRemoteIds?: Set<string>,
52
+ ): string {
53
+ const parts: string[] = [];
54
+ const included = annotations.filter((a) =>
55
+ a.source !== "remote" || (includedRemoteIds && includedRemoteIds.has(a.id))
56
+ );
57
+ const responseAnns = included.filter((a) => a.filePath === RESPONSE_ANNOTATION_PATH);
58
+ const canvasAnns = included.filter((a) => !a.filePath);
59
+ const fileAnns = included.filter((a) => a.filePath && a.filePath !== RESPONSE_ANNOTATION_PATH);
60
+ const resps = responses ? Array.from(responses.values()).filter((r) => hasValue(r)) : [];
61
+ const fbEntries = feedbackEntries
62
+ ? Array.from(feedbackEntries.values()).filter((e) => e.markdown.trim())
63
+ : [];
64
+
65
+ // Responses section
66
+ const hasResponseSection = resps.length > 0 || fbEntries.length > 0;
67
+ if (hasResponseSection) {
68
+ parts.push("## Responses");
69
+ parts.push("");
70
+ for (const r of resps) {
71
+ parts.push(renderResponse(r));
72
+ }
73
+ for (const entry of fbEntries) {
74
+ parts.push(entry.markdown.trim());
75
+ parts.push("");
76
+ }
77
+ }
78
+
79
+ // Response annotations (on agent response banner)
80
+ if (responseAnns.length > 0) {
81
+ parts.push("## Annotations on agent response");
82
+ parts.push("");
83
+ for (const ann of responseAnns) {
84
+ parts.push(renderAnnotation(ann));
85
+ }
86
+ }
87
+
88
+ // Canvas annotations grouped by canvas file, then hierarchy
89
+ if (canvasAnns.length > 0) {
90
+ parts.push("## Canvas annotations");
91
+ parts.push("");
92
+
93
+ // Group by canvasFile
94
+ const byCanvas = new Map<string, Annotation[]>();
95
+ for (const ann of canvasAnns) {
96
+ const key = ann.canvasFile || "";
97
+ if (!byCanvas.has(key)) byCanvas.set(key, []);
98
+ byCanvas.get(key)!.push(ann);
99
+ }
100
+
101
+ const canvasKeys = [...byCanvas.keys()].sort();
102
+ const multipleCanvases = canvasKeys.length > 1 || (canvasKeys.length === 1 && canvasKeys[0] !== "");
103
+
104
+ for (const canvasKey of canvasKeys) {
105
+ const anns = byCanvas.get(canvasKey)!;
106
+ if (multipleCanvases && canvasKey) {
107
+ parts.push(`### ${canvasKey.replace(/\.jsx$/, "")}`);
108
+ parts.push("");
109
+ }
110
+ const grouped = groupByHierarchy(anns);
111
+ parts.push(renderHierarchyGroup(grouped));
112
+ }
113
+ }
114
+
115
+ // File annotations grouped by path
116
+ if (fileAnns.length > 0) {
117
+ const groups: Record<string, Annotation[]> = {};
118
+ for (const ann of fileAnns) {
119
+ const key = ann.filePath!;
120
+ if (!groups[key]) groups[key] = [];
121
+ groups[key].push(ann);
122
+ }
123
+ parts.push("## File annotations");
124
+ parts.push("");
125
+ for (const [filePath, anns] of Object.entries(groups)) {
126
+ parts.push(`### ${filePath}`);
127
+ parts.push("");
128
+ for (const ann of anns) {
129
+ parts.push(renderAnnotation(ann));
130
+ }
131
+ }
132
+ }
133
+
134
+ // General notes
135
+ if (generalNote.trim()) {
136
+ parts.push("## General notes");
137
+ parts.push("");
138
+ parts.push(generalNote.trim());
139
+ parts.push("");
140
+ }
141
+
142
+ return parts.join("\n").trim() + "\n";
143
+ }
144
+
145
+ export function hasValue(r: PlanResponse): boolean {
146
+ if (r.note?.trim()) return true;
147
+ if (r.value === null || r.value === undefined) return false;
148
+ if (r.type === "text" && !(r.value as string).trim()) return false;
149
+ if (r.type === "checkbox" && (r.value as string[]).length === 0) return false;
150
+ return true;
151
+ }
152
+
153
+ export function getMissingRequired(responses: Map<string, PlanResponse>): PlanResponse[] {
154
+ const missing: PlanResponse[] = [];
155
+ for (const r of responses.values()) {
156
+ if (!r.required) continue;
157
+ if (r.value === null || r.value === undefined) { missing.push(r); continue; }
158
+ if (r.type === "text" && !(r.value as string).trim()) { missing.push(r); continue; }
159
+ if (r.type === "checkbox" && (r.value as string[]).length === 0) { missing.push(r); continue; }
160
+ }
161
+ return missing;
162
+ }
163
+
164
+ export function getMissingRequiredFeedback(entries: Map<string, FeedbackEntry>): FeedbackEntry[] {
165
+ const missing: FeedbackEntry[] = [];
166
+ for (const entry of entries.values()) {
167
+ if (entry.required && !entry.markdown.trim()) {
168
+ missing.push(entry);
169
+ }
170
+ }
171
+ return missing;
172
+ }
173
+
174
+ /** Returns list of missing required field labels, or empty if all valid */
175
+ export function getMissingRequiredLabels(responses: Map<string, PlanResponse>, feedbackEntries: Map<string, FeedbackEntry>): string[] {
176
+ const missingResponses = getMissingRequired(responses);
177
+ const missingFeedback = getMissingRequiredFeedback(feedbackEntries);
178
+ return [
179
+ ...missingResponses.map((r) => r.label),
180
+ ...missingFeedback.map((e) => e.label || e.id),
181
+ ];
182
+ }
183
+
184
+ function renderResponse(r: PlanResponse): string {
185
+ const lines: string[] = [];
186
+ lines.push(`**${r.label}**`);
187
+
188
+ switch (r.type) {
189
+ case "radio":
190
+ case "select":
191
+ lines.push(`Answer: ${r.value}`);
192
+ break;
193
+ case "checkbox":
194
+ for (const v of r.value as string[]) {
195
+ lines.push(`- [x] ${v}`);
196
+ }
197
+ break;
198
+ case "text":
199
+ lines.push("");
200
+ lines.push((r.value as string).trim());
201
+ break;
202
+ case "range":
203
+ lines.push(`Answer: ${r.value}`);
204
+ break;
205
+ }
206
+
207
+ if (r.note?.trim()) {
208
+ lines.push("");
209
+ lines.push(`Note: ${r.note.trim()}`);
210
+ }
211
+
212
+ lines.push("");
213
+ return lines.join("\n");
214
+ }
215
+
216
+ interface HierarchyNode {
217
+ label: string;
218
+ annotations: Annotation[];
219
+ children: Map<string, HierarchyNode>;
220
+ }
221
+
222
+ function groupByHierarchy(annotations: Annotation[]): HierarchyNode {
223
+ const root: HierarchyNode = { label: "", annotations: [], children: new Map() };
224
+ for (const ann of annotations) {
225
+ const path = ann.context?.hierarchy || [];
226
+ let current = root;
227
+ for (const segment of path) {
228
+ if (!current.children.has(segment)) {
229
+ current.children.set(segment, { label: segment, annotations: [], children: new Map() });
230
+ }
231
+ current = current.children.get(segment)!;
232
+ }
233
+ current.annotations.push(ann);
234
+ }
235
+ return root;
236
+ }
237
+
238
+ function renderHierarchyGroup(node: HierarchyNode, depth: number = 0): string {
239
+ const parts: string[] = [];
240
+ for (const ann of node.annotations) {
241
+ parts.push(renderAnnotation(ann));
242
+ }
243
+ for (const [, child] of node.children) {
244
+ const prefix = depth === 0 ? "###" : "####";
245
+ parts.push(`${prefix} ${child.label}`);
246
+ parts.push("");
247
+ parts.push(renderHierarchyGroup(child, depth + 1));
248
+ }
249
+ return parts.join("\n");
250
+ }
@@ -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,55 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ /**
4
+ * What the annotation surface needs from the host serving it.
5
+ *
6
+ * The kernel ships a local-daemon implementation, so a host that serves
7
+ * canvases straight off its own daemon needs no provider. Hosts with extra
8
+ * delivery modes (agent-canvas also serves read-only encrypted share links)
9
+ * provide their own.
10
+ */
11
+ export interface CanvasHost {
12
+ /** Session the canvas belongs to. */
13
+ sessionId: string;
14
+ /** Read-only share view — local-only affordances stay hidden. */
15
+ isShared: boolean;
16
+ /** Host can read project files (feedback bodies, file browser). */
17
+ fsAvailable: boolean;
18
+ /** Endpoint accepting annotation image uploads, or `null` for a host that
19
+ * has none — the attach affordances then stay hidden rather than posting
20
+ * into the void. Required, not optional, so no host can be silent about it. */
21
+ uploadUrl: (() => string) | null;
22
+ /** Fetch and `import()` a compiled canvas module. */
23
+ loadCanvasModule: (filename: string, revision?: number) => Promise<any>;
24
+ }
25
+
26
+ /** Local mode carries the session id as the `/s/:id` path suffix. */
27
+ function localSessionId(): string {
28
+ if (typeof window === "undefined") return "";
29
+ return window.location.pathname.replace("/s/", "");
30
+ }
31
+
32
+ export const localCanvasHost: CanvasHost = {
33
+ get sessionId() {
34
+ return localSessionId();
35
+ },
36
+ isShared: false,
37
+ fsAvailable: true,
38
+ // No endpoint by default. The only hosts that reach this fallback are the
39
+ // ones that configured nothing, which are exactly the ones least likely to
40
+ // serve an upload route — claiming one on their behalf is how every host got
41
+ // a paperclip that posts into the void. A host with uploads names its own.
42
+ uploadUrl: null,
43
+ loadCanvasModule: (filename, revision) => {
44
+ const jsName = filename.replace(/\.jsx$/, ".js");
45
+ const query = revision ? `?rev=${revision}&t=${Date.now()}` : `?t=${Date.now()}`;
46
+ const url = `/api/session/${localSessionId()}/canvas/${encodeURIComponent(jsName)}${query}`;
47
+ return import(/* @vite-ignore */ url);
48
+ },
49
+ };
50
+
51
+ export const CanvasHostContext = createContext<CanvasHost>(localCanvasHost);
52
+
53
+ export function useCanvasHost(): CanvasHost {
54
+ return useContext(CanvasHostContext);
55
+ }
@@ -0,0 +1,128 @@
1
+ // @fabrika/canvas-kernel/client — the reusable annotation surface.
2
+ //
3
+ // Everything a host needs to render a compiled canvas and collect feedback on
4
+ // it, minus the app shell (navigation, file browser, revision chrome), which
5
+ // each host builds itself.
6
+
7
+ // --- Annotation state ------------------------------------------------------
8
+ // Everything that lives in runtime.ts is re-exported through `#canvas/runtime`,
9
+ // never through a relative path. The runtime holds the React contexts and ships
10
+ // as its own bundle shared with the compiled canvas; importing it relatively
11
+ // would bundle a second copy, and the provider and its consumers would then be
12
+ // writing to and reading from different contexts.
13
+ export { AnnotationProvider } from "./AnnotationProvider";
14
+ export type { PersistedState } from "./AnnotationProvider";
15
+ export {
16
+ useAnnotations,
17
+ useFeedback,
18
+ useCanvasFile,
19
+ useResponseRegistration,
20
+ AnnotationCtx,
21
+ SessionContext,
22
+ ActiveViewCtx,
23
+ CanvasFileCtx,
24
+ } from "#canvas/runtime";
25
+ export type {
26
+ Annotation,
27
+ AnnotationAttachment,
28
+ AnnotationAuthor,
29
+ AnnotationContext,
30
+ AnnotationContextValue,
31
+ ActiveView,
32
+ FeedbackEntry,
33
+ PlanResponse,
34
+ } from "#canvas/runtime";
35
+
36
+ // --- Surface ---------------------------------------------------------------
37
+ export { AnnotationSidebar } from "./AnnotationSidebar";
38
+ // The sidebar's reusable halves. Grouping stayed with the sidebar — what an
39
+ // annotation belongs to is the host's taxonomy, so the host slices the list.
40
+ export { AnnotationList } from "./AnnotationList";
41
+ export type { AnnotationListProps } from "./AnnotationList";
42
+ export { AnnotationDraftFooter } from "./AnnotationDraftFooter";
43
+ export type { AnnotationDraftFooterProps } from "./AnnotationDraftFooter";
44
+ export { AnnotationEditor, imageToUrl } from "./AnnotationEditor";
45
+ export { PlanRenderer } from "./PlanRenderer";
46
+ export { Popover, AnnotationPopover, AnnotationCreatePopover, AnnotationEditPopover } from "./Popover";
47
+ export { ResponsePreview, MarkdownPreview } from "./ResponsePreview";
48
+ export { FileIcon } from "./FileIcon";
49
+
50
+ /** The kernel component set, as a namespace for hosts that inject it wholesale. */
51
+ export * as components from "./components";
52
+ export { useTextAnnotation } from "./useTextAnnotation";
53
+ export { useRegionAnnotation } from "./useRegionAnnotation";
54
+
55
+ // --- Host-provided context -------------------------------------------------
56
+ export { CanvasHostContext, localCanvasHost, useCanvasHost } from "./hostContext";
57
+ export type { CanvasHost } from "./hostContext";
58
+ export { ActiveViewContext, RevisionContext } from "./appContext";
59
+ export type { CanvasFileInfo, RevisionInfo } from "./appContext";
60
+ export { RenderErrorContext } from "./RenderErrorContext";
61
+ export type { CanvasRenderError } from "./RenderErrorContext";
62
+
63
+ // --- Annotation plumbing ---------------------------------------------------
64
+ export { extractContext, formatSnippetInContext } from "./annotationContext";
65
+ export {
66
+ ANNOTATION_TARGETS,
67
+ ANNOTATABLE_SELECTOR,
68
+ BLOCK_SELECTOR,
69
+ describeSnippet,
70
+ findAnnotationElement,
71
+ findSnippetElement,
72
+ getBlockSnippet,
73
+ restoreAnnotationTargets,
74
+ scrollToAnnotation,
75
+ } from "./annotationDom";
76
+
77
+ // --- Locator strategies ----------------------------------------------------
78
+ // What a snippet points at is the one thing in an annotation that is domain
79
+ // specific. A host that needs to annotate something the kernel has no target
80
+ // for writes one of these; nothing else has to change.
81
+ export { sealTarget } from "./annotationTarget";
82
+ export type { AnnotationTarget, SealedAnnotationTarget, TargetAnnotation } from "./annotationTarget";
83
+ export { blockTarget } from "./blockTarget";
84
+ export type { BlockLocator } from "./blockTarget";
85
+ export { textTarget } from "./textTarget";
86
+ export type { TextLocator } from "./textTarget";
87
+ export {
88
+ createRegionOverlay,
89
+ describeRegion,
90
+ findRegionHost,
91
+ isDrawableRegion,
92
+ regionBetween,
93
+ regionPointIn,
94
+ regionStyle,
95
+ regionTarget,
96
+ REGION_HOST_ATTR,
97
+ REGION_UNITS,
98
+ } from "./regionTarget";
99
+ export type { RegionBox, RegionLocator, RegionPoint, RegionShape } from "./regionTarget";
100
+ export {
101
+ annotationDraftKey,
102
+ carryUnsubmittedDraft,
103
+ clearPersistedDraft,
104
+ } from "./annotationDraft";
105
+ export type { AnnotationDraftPhase } from "./annotationDraft";
106
+ export {
107
+ restoreMarks,
108
+ renameMarkId,
109
+ setMarkActive,
110
+ unwrapMarks,
111
+ updateAllMarkStates,
112
+ wrapRangeWithMark,
113
+ } from "./highlightRange";
114
+ // One annotation as markdown — the half of feedback that belongs to no
115
+ // particular document format. `generateMarkdown` below is the canvas format
116
+ // built on it; a host with its own headings calls this directly.
117
+ export { renderAnnotation } from "./annotationMarkdown";
118
+ export type { RenderableAnnotation } from "./annotationMarkdown";
119
+ export {
120
+ canPruneResponses,
121
+ generateMarkdown,
122
+ getMissingRequired,
123
+ getMissingRequiredFeedback,
124
+ getMissingRequiredLabels,
125
+ hasValue,
126
+ pruneStaleResponses,
127
+ } from "./generateMarkdown";
128
+ export { autoResizeTextarea, fileAnnotationPath, generateAnnotationId, hostAcceptsUploads, RESPONSE_ANNOTATION_PATH } from "./utils";