@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,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,27 @@
1
+ import React from "react";
2
+ import { renderMarkdownChildren } from "./renderMarkdownChildren";
3
+
4
+ interface CalloutProps {
5
+ type?: "info" | "warning" | "danger" | "tip";
6
+ children?: React.ReactNode;
7
+ }
8
+
9
+ const config = {
10
+ info: { bg: "bg-accent-blue-muted", icon: "i", iconColor: "text-accent-blue" },
11
+ warning: { bg: "bg-accent-amber-muted", icon: "!", iconColor: "text-accent-amber" },
12
+ danger: { bg: "bg-accent-red-muted", icon: "\u2715", iconColor: "text-accent-red" },
13
+ tip: { bg: "bg-accent-green-muted", icon: "\u2713", iconColor: "text-accent-green" },
14
+ };
15
+
16
+ export function Callout({ type = "info", children }: CalloutProps) {
17
+ const cfg = config[type];
18
+ return (
19
+ <div className={`${cfg.bg} relative rounded-lg px-5 py-4 mt-3 flex gap-3 items-start`} data-md="callout" data-md-type={type}>
20
+ {/* Decorative: the type it stands for is already in data-md-type. */}
21
+ <span aria-hidden="true" className={`${cfg.iconColor} opacity-70 text-base leading-relaxed flex-shrink-0 mt-px`}>
22
+ {cfg.icon}
23
+ </span>
24
+ <div className="text-body text-text-secondary leading-relaxed">{renderMarkdownChildren(children)}</div>
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,37 @@
1
+ import React from "react";
2
+
3
+ interface ChecklistItem {
4
+ label: string;
5
+ checked: boolean;
6
+ }
7
+
8
+ interface ChecklistProps {
9
+ items: ChecklistItem[];
10
+ }
11
+
12
+ export function Checklist({ items }: ChecklistProps) {
13
+ return (
14
+ <ul className="space-y-1 mt-3" data-md="checklist">
15
+ {items.map((item, i) => (
16
+ <li key={i} data-md="checklist-item" data-md-label={item.label} className="relative flex items-start gap-2.5 py-1">
17
+ <span className={`w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0 mt-0.5 transition-all ${
18
+ item.checked
19
+ ? "bg-accent-green"
20
+ : "border-[1.5px] border-border-hover"
21
+ }`}>
22
+ {item.checked && (
23
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
24
+ <path d="M2.5 5L4.5 7L7.5 3" style={{ stroke: "var(--color-text-inverse)" }} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
25
+ </svg>
26
+ )}
27
+ </span>
28
+ <span className={`text-body font-body ${
29
+ item.checked ? "text-text-tertiary line-through decoration-border-hover" : "text-text-secondary"
30
+ }`}>
31
+ {item.label}
32
+ </span>
33
+ </li>
34
+ ))}
35
+ </ul>
36
+ );
37
+ }
@@ -0,0 +1,178 @@
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
+ import { useAnnotations, useResponseRegistration } from "#canvas/runtime";
3
+ import { ResponseNote } from "./ResponseNote";
4
+
5
+ export type ChoiceOption = string | { value: string; label?: string };
6
+
7
+ type NormalizedOption = { value: string; label: string };
8
+
9
+ function normalizeOptions(options: ChoiceOption[]): NormalizedOption[] {
10
+ return options.map((opt) =>
11
+ typeof opt === "string"
12
+ ? { value: opt, label: opt }
13
+ : { value: opt.value, label: opt.label ?? opt.value },
14
+ );
15
+ }
16
+
17
+ /** Radio — pick one option */
18
+ interface ChoiceProps {
19
+ id: string;
20
+ label: string;
21
+ options: ChoiceOption[];
22
+ required?: boolean;
23
+ }
24
+
25
+ export function Choice({ id, label, options, required }: ChoiceProps) {
26
+ const { responses, setResponse } = useAnnotations();
27
+ useResponseRegistration(id);
28
+ const normalized = useMemo(() => normalizeOptions(options), [options]);
29
+ const current = responses.get(id);
30
+ const selected = current?.value as string | undefined;
31
+ const note = current?.note || "";
32
+ const [showNote, setShowNote] = useState(false);
33
+ const wrapperRef = useRef<HTMLDivElement>(null);
34
+
35
+ if (!responses.has(id)) {
36
+ setResponse(id, { id, type: "radio", label, value: null, options: normalized.map((o) => o.value), required });
37
+ }
38
+
39
+ const handleSelect = (value: string) => {
40
+ setResponse(id, { ...current!, value });
41
+ };
42
+
43
+ // Keyboard selection via custom event from PlanRenderer
44
+ useEffect(() => {
45
+ const el = wrapperRef.current;
46
+ if (!el) return;
47
+ const handler = (e: Event) => {
48
+ const target = (e.target as HTMLElement).closest("[data-md-label]");
49
+ const labelAttr = target?.getAttribute("data-md-label");
50
+ const match = normalized.find((o) => o.label === labelAttr);
51
+ if (match) handleSelect(match.value);
52
+ };
53
+ el.addEventListener("kb-select", handler);
54
+ return () => el.removeEventListener("kb-select", handler);
55
+ }, [normalized, handleSelect]);
56
+
57
+ const showError = current?.required && !selected;
58
+
59
+ return (
60
+ <div ref={wrapperRef} data-md="choice" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg transition-colors duration-150 hover:bg-bg-input">
61
+ <div className="flex items-baseline gap-2 mb-2">
62
+ <span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
63
+ {required && <span className="text-[10px] text-accent-red font-body">*</span>}
64
+ </div>
65
+ <div className="space-y-1">
66
+ {normalized.map(({ value, label: optLabel }) => (
67
+ <label
68
+ key={value}
69
+ data-md="choice-option"
70
+ data-md-label={optLabel}
71
+ onClick={() => handleSelect(value)}
72
+ className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
73
+ selected === value
74
+ ? "bg-highlight-selected"
75
+ : "hover:bg-bg-elevated"
76
+ }`}
77
+ >
78
+ <span className={`w-4 h-4 rounded-full border-[1.5px] flex items-center justify-center flex-shrink-0 transition-all ${
79
+ selected === value
80
+ ? "border-accent-amber bg-accent-amber"
81
+ : "border-border-strong"
82
+ }`}>
83
+ {selected === value && <span className="w-1.5 h-1.5 rounded-full bg-text-inverse" />}
84
+ </span>
85
+ <span className="text-[13px] font-body text-text-secondary">{optLabel}</span>
86
+ </label>
87
+ ))}
88
+ </div>
89
+ <ResponseNote show={showNote} note={note} onToggle={() => setShowNote(!showNote)} onChange={(n) => setResponse(id, { ...current!, note: n })} />
90
+ {showError && <p className="text-[11px] text-accent-red font-body mt-1">Please select an option.</p>}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ /** Checkbox — pick multiple options */
96
+ interface MultiChoiceProps {
97
+ id: string;
98
+ label: string;
99
+ options: ChoiceOption[];
100
+ required?: boolean;
101
+ }
102
+
103
+ export function MultiChoice({ id, label, options, required }: MultiChoiceProps) {
104
+ const { responses, setResponse } = useAnnotations();
105
+ useResponseRegistration(id);
106
+ const normalized = useMemo(() => normalizeOptions(options), [options]);
107
+ const current = responses.get(id);
108
+ const selected: string[] = (current?.value as string[]) || [];
109
+ const note = current?.note || "";
110
+ const [showNote, setShowNote] = useState(false);
111
+ const wrapperRef = useRef<HTMLDivElement>(null);
112
+
113
+ if (!responses.has(id)) {
114
+ setResponse(id, { id, type: "checkbox", label, value: [], options: normalized.map((o) => o.value), required });
115
+ }
116
+
117
+ const toggle = (value: string) => {
118
+ const next = selected.includes(value) ? selected.filter((o) => o !== value) : [...selected, value];
119
+ setResponse(id, { ...current!, value: next });
120
+ };
121
+
122
+ // Keyboard selection via custom event from PlanRenderer
123
+ useEffect(() => {
124
+ const el = wrapperRef.current;
125
+ if (!el) return;
126
+ const handler = (e: Event) => {
127
+ const target = (e.target as HTMLElement).closest("[data-md-label]");
128
+ const labelAttr = target?.getAttribute("data-md-label");
129
+ const match = normalized.find((o) => o.label === labelAttr);
130
+ if (match) toggle(match.value);
131
+ };
132
+ el.addEventListener("kb-select", handler);
133
+ return () => el.removeEventListener("kb-select", handler);
134
+ }, [normalized, toggle]);
135
+
136
+ const showError = current?.required && selected.length === 0;
137
+
138
+ return (
139
+ <div ref={wrapperRef} data-md="multichoice" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg transition-colors duration-150 hover:bg-bg-input">
140
+ <div className="flex items-baseline gap-2 mb-2">
141
+ <span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
142
+ {required && <span className="text-[10px] text-accent-red font-body">*</span>}
143
+ </div>
144
+ <div className="space-y-1">
145
+ {normalized.map(({ value, label: optLabel }) => {
146
+ const checked = selected.includes(value);
147
+ return (
148
+ <label
149
+ key={value}
150
+ data-md="multichoice-option"
151
+ data-md-label={optLabel}
152
+ onClick={() => toggle(value)}
153
+ className={`flex items-center gap-2.5 px-3 py-2 rounded-lg cursor-pointer transition-all duration-150 ${
154
+ checked ? "bg-highlight-selected" : "hover:bg-bg-elevated"
155
+ }`}
156
+ >
157
+ <span className={`w-4 h-4 rounded flex items-center justify-center flex-shrink-0 transition-all ${
158
+ checked
159
+ ? "bg-accent-amber"
160
+ : "border-[1.5px] border-border-strong"
161
+ }`}>
162
+ {checked && (
163
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none">
164
+ <path d="M2.5 5L4.5 7L7.5 3" style={{ stroke: "var(--color-text-inverse)" }} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
165
+ </svg>
166
+ )}
167
+ </span>
168
+ <span className="text-[13px] font-body text-text-secondary">{optLabel}</span>
169
+ </label>
170
+ );
171
+ })}
172
+ </div>
173
+ <ResponseNote show={showNote} note={note} onToggle={() => setShowNote(!showNote)} onChange={(n) => setResponse(id, { ...current!, note: n })} />
174
+ {showError && <p className="text-[11px] text-accent-red font-body mt-1">Please select at least one option.</p>}
175
+ </div>
176
+ );
177
+ }
178
+
@@ -0,0 +1,35 @@
1
+ import React, { useEffect, useRef } from "react";
2
+
3
+ interface CodeBlockProps {
4
+ language?: string;
5
+ children?: React.ReactNode;
6
+ }
7
+
8
+ export function CodeBlock({ language, children }: CodeBlockProps) {
9
+ const code = typeof children === "string" ? children : String(children ?? "");
10
+ const codeRef = useRef<HTMLElement>(null);
11
+
12
+ useEffect(() => {
13
+ const el = codeRef.current;
14
+ const hljs = (window as any).hljs;
15
+ if (!el || !hljs) return;
16
+ // Reset previous highlighting
17
+ el.removeAttribute("data-highlighted");
18
+ el.className = language ? `language-${language}` : "";
19
+ el.textContent = code;
20
+ hljs.highlightElement(el);
21
+ }, [code, language]);
22
+
23
+ return (
24
+ <div className="relative mt-3 group/code" data-md="codeblock" data-md-language={language || ""}>
25
+ {language && (
26
+ <span className="absolute top-2 right-3 text-tiny text-text-tertiary opacity-0 group-hover/code:opacity-60 transition-opacity duration-150 z-10 font-mono">
27
+ {language}
28
+ </span>
29
+ )}
30
+ <pre className="bg-bg-code rounded-md p-4 overflow-x-auto text-code font-mono leading-relaxed">
31
+ <code ref={codeRef} className={language ? `language-${language}` : ""}>{code}</code>
32
+ </pre>
33
+ </div>
34
+ );
35
+ }
@@ -0,0 +1,36 @@
1
+ import React from "react";
2
+ import { buildDiffLines } from "../diffLines";
3
+
4
+ interface DiffProps {
5
+ before: string;
6
+ after: string;
7
+ language?: string;
8
+ }
9
+
10
+ export function Diff({ before, after, language }: DiffProps) {
11
+ const diffLines = buildDiffLines(before, after);
12
+
13
+ return (
14
+ <div className="mt-3 bg-bg-code rounded-md overflow-hidden group/diff" data-md="diff" data-md-language={language || ""}>
15
+ {language && (
16
+ <div className="px-4 py-2 text-meta font-mono text-text-tertiary opacity-0 group-hover/diff:opacity-60 transition-opacity">
17
+ diff ({language})
18
+ </div>
19
+ )}
20
+ <pre className="px-4 pb-3 overflow-x-auto text-code font-mono">
21
+ {diffLines.map((d, i) => (
22
+ <div key={i} className={
23
+ d.type === "removed" ? "bg-accent-red-muted text-accent-red" :
24
+ d.type === "added" ? "bg-accent-green-muted text-accent-green" :
25
+ "text-text-tertiary"
26
+ }>
27
+ <span className="select-none w-6 inline-block text-center opacity-60">
28
+ {d.type === "removed" ? "-" : d.type === "added" ? "+" : " "}
29
+ </span>
30
+ {d.line}
31
+ </div>
32
+ ))}
33
+ </pre>
34
+ </div>
35
+ );
36
+ }
@@ -0,0 +1,88 @@
1
+ import React, { useEffect, useState, useContext, useRef } from "react";
2
+ import { SessionContext, ActiveViewCtx } from "#canvas/runtime";
3
+
4
+ interface FilePreviewProps {
5
+ path: string;
6
+ lines?: [number, number];
7
+ /** Injected by compiler at build time — skip runtime fetch when present */
8
+ __content?: string;
9
+ }
10
+
11
+ const EXT_TO_LANG: Record<string, string> = {
12
+ ts: "typescript", tsx: "typescript", js: "javascript", jsx: "javascript",
13
+ py: "python", rs: "rust", go: "go", rb: "ruby", java: "java",
14
+ json: "json", yaml: "yaml", yml: "yaml", md: "markdown",
15
+ css: "css", html: "html", sh: "bash", bash: "bash", toml: "toml",
16
+ sql: "sql", xml: "xml", c: "c", cpp: "cpp", h: "c",
17
+ };
18
+
19
+ export function FilePreview({ path, lines, __content }: FilePreviewProps) {
20
+ const sessionId = useContext(SessionContext);
21
+ const { setActiveView } = useContext(ActiveViewCtx);
22
+ const [content, setContent] = useState<string | null>(__content ?? null);
23
+ const [language, setLanguage] = useState(() => {
24
+ const ext = path.split(".").pop() || "";
25
+ return EXT_TO_LANG[ext] || "text";
26
+ });
27
+ const [error, setError] = useState<string | null>(null);
28
+ const [loading, setLoading] = useState(!__content);
29
+ const codeRef = useRef<HTMLElement>(null);
30
+
31
+ useEffect(() => {
32
+ if (__content !== undefined) return; // already resolved at compile time
33
+ setLoading(true);
34
+ setError(null);
35
+ fetch(`/api/file?session=${sessionId}&path=${encodeURIComponent(path)}`)
36
+ .then((r) => r.json())
37
+ .then((data: any) => {
38
+ if (data.error) {
39
+ setError(data.error);
40
+ } else {
41
+ let text = data.content;
42
+ if (lines) {
43
+ const allLines = text.split("\n");
44
+ text = allLines.slice(lines[0] - 1, lines[1]).join("\n");
45
+ }
46
+ setContent(text);
47
+ setLanguage(data.language || "text");
48
+ }
49
+ })
50
+ .catch(() => setError("Failed to fetch file"))
51
+ .finally(() => setLoading(false));
52
+ }, [sessionId, path, lines?.[0], lines?.[1], __content]);
53
+
54
+ // Highlight after content loads
55
+ useEffect(() => {
56
+ const el = codeRef.current;
57
+ const hljs = (window as any).hljs;
58
+ if (!el || !hljs || !content) return;
59
+ el.removeAttribute("data-highlighted");
60
+ el.className = language ? `language-${language}` : "";
61
+ el.textContent = content;
62
+ hljs.highlightElement(el);
63
+ }, [content, language]);
64
+
65
+ if (loading) {
66
+ return <div className="text-text-tertiary text-body mt-3 bg-bg-code rounded-md p-4">Loading {path}...</div>;
67
+ }
68
+
69
+ if (error) {
70
+ return <div className="text-accent-red text-body mt-3 bg-accent-red-muted rounded-md p-4">{path}: {error}</div>;
71
+ }
72
+
73
+ return (
74
+ <div className="mt-3 bg-bg-code rounded-md overflow-hidden" data-md="filepreview" data-md-path={path} {...(lines ? { "data-md-lines": `${lines[0]}-${lines[1]}` } : {})}>
75
+ <div className="px-4 py-2 flex items-center justify-between">
76
+ <button
77
+ className="font-mono text-meta text-text-tertiary hover:text-text-secondary hover:underline cursor-pointer text-left"
78
+ onClick={() => setActiveView({ type: "file", path })}
79
+ title={`Open ${path}`}
80
+ >{path}</button>
81
+ {lines && <span className="font-mono text-meta text-text-tertiary opacity-60">L{lines[0]}–{lines[1]}</span>}
82
+ </div>
83
+ <pre className="px-4 pb-3 overflow-x-auto text-code font-mono !bg-transparent" style={{ background: "transparent" }}>
84
+ <code ref={codeRef} className={language ? `language-${language}` : ""}>{content}</code>
85
+ </pre>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,48 @@
1
+ import React, { useContext, useState } from "react";
2
+ import { SessionContext } from "#canvas/runtime";
3
+
4
+ interface ImageProps {
5
+ src: string;
6
+ alt?: string;
7
+ caption?: string;
8
+ width?: number | string;
9
+ height?: number | string;
10
+ }
11
+
12
+ export function ImageView({ src, alt, caption, width, height }: ImageProps) {
13
+ const sessionId = useContext(SessionContext);
14
+ const [error, setError] = useState(false);
15
+
16
+ const imgSrc = `/api/image?session=${encodeURIComponent(sessionId)}&path=${encodeURIComponent(src)}`;
17
+
18
+ if (error) {
19
+ return (
20
+ <div className="mt-3 text-accent-red text-body bg-accent-red-muted rounded-md p-4">
21
+ {src}: Failed to load image
22
+ </div>
23
+ );
24
+ }
25
+
26
+ return (
27
+ <figure className="mt-3 relative" data-md="image" data-md-src={src}>
28
+ {/* The box a region annotation is measured against — the image alone, so
29
+ the caption below cannot skew the percentages. */}
30
+ <div data-annotation-image className="rounded-md overflow-hidden border border-border-subtle bg-bg-code inline-block">
31
+ <img
32
+ src={imgSrc}
33
+ alt={alt || src}
34
+ onError={() => setError(true)}
35
+ draggable={false}
36
+ className="block max-w-full h-auto"
37
+ {...(width ? { width } : {})}
38
+ {...(height ? { height } : {})}
39
+ />
40
+ </div>
41
+ {caption && (
42
+ <figcaption className="mt-1.5 text-meta text-text-tertiary font-body">
43
+ {caption}
44
+ </figcaption>
45
+ )}
46
+ </figure>
47
+ );
48
+ }
@@ -0,0 +1,44 @@
1
+ import React, { useMemo } from "react";
2
+ import { marked } from "marked";
3
+
4
+ interface MarkdownProps {
5
+ /** Inline markdown source (via children) */
6
+ children?: React.ReactNode;
7
+ /** File path (resolved at compile time) */
8
+ file?: string;
9
+ /** Injected by compiler when file prop is used */
10
+ __content?: string;
11
+ }
12
+
13
+ // Configure marked for safe rendering
14
+ marked.setOptions({
15
+ gfm: true,
16
+ breaks: false,
17
+ });
18
+
19
+ export function Markdown({ children, file, __content }: MarkdownProps) {
20
+ const source = __content ?? (typeof children === "string" ? children : String(children ?? ""));
21
+
22
+ const html = useMemo(() => {
23
+ if (!source) return "";
24
+ return marked.parse(source, { async: false }) as string;
25
+ }, [source]);
26
+
27
+ if (!source) {
28
+ return (
29
+ <div className="text-text-tertiary text-tiny mt-3">
30
+ {file ? `Could not load: ${file}` : "No markdown content"}
31
+ </div>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <div
37
+ className="mt-3 prose-canvas"
38
+ data-md="markdown"
39
+ {...(file ? { "data-md-file": file } : {})}
40
+ data-md-source={source}
41
+ dangerouslySetInnerHTML={{ __html: html }}
42
+ />
43
+ );
44
+ }