agent-canvas 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agent-canvas.js +14 -0
- package/bin/agent-canvas.ts +377 -0
- package/daemon/build.ts +185 -0
- package/daemon/client/AnnotationProvider.tsx +185 -0
- package/daemon/client/AnnotationSidebar.tsx +314 -0
- package/daemon/client/App.tsx +611 -0
- package/daemon/client/CompareView.tsx +333 -0
- package/daemon/client/FileBrowser.tsx +188 -0
- package/daemon/client/FileIcon.tsx +76 -0
- package/daemon/client/FileViewer.tsx +207 -0
- package/daemon/client/PlanRenderer.tsx +665 -0
- package/daemon/client/Popover.tsx +285 -0
- package/daemon/client/ResponsePreview.tsx +260 -0
- package/daemon/client/RevisionSelect.tsx +97 -0
- package/daemon/client/SessionContext.tsx +3 -0
- package/daemon/client/SessionSwitcher.tsx +72 -0
- package/daemon/client/annotationContext.ts +114 -0
- package/daemon/client/components/Callout.tsx +25 -0
- package/daemon/client/components/Checklist.tsx +37 -0
- package/daemon/client/components/Choice.tsx +164 -0
- package/daemon/client/components/CodeBlock.tsx +35 -0
- package/daemon/client/components/Diff.tsx +65 -0
- package/daemon/client/components/FilePreview.tsx +88 -0
- package/daemon/client/components/Image.tsx +45 -0
- package/daemon/client/components/Mermaid.tsx +277 -0
- package/daemon/client/components/Note.tsx +13 -0
- package/daemon/client/components/Priority.tsx +19 -0
- package/daemon/client/components/ResponseNote.tsx +24 -0
- package/daemon/client/components/Section.tsx +32 -0
- package/daemon/client/components/Table.tsx +35 -0
- package/daemon/client/components/Task.tsx +75 -0
- package/daemon/client/components/UserInput.tsx +180 -0
- package/daemon/client/components/index.ts +15 -0
- package/daemon/client/domDiff.ts +336 -0
- package/daemon/client/exportMarkdown.ts +267 -0
- package/daemon/client/generateMarkdown.ts +194 -0
- package/daemon/client/highlightRange.ts +233 -0
- package/daemon/client/index.html +26 -0
- package/daemon/client/lcs.ts +19 -0
- package/daemon/client/popoverPosition.ts +31 -0
- package/daemon/client/runtime.ts +89 -0
- package/daemon/client/styles.css +306 -0
- package/daemon/client/theme.css +66 -0
- package/daemon/client/unifiedDiff.ts +604 -0
- package/daemon/dist/client.css +2 -0
- package/daemon/dist/client.js +68 -0
- package/daemon/dist/components.js +5 -0
- package/daemon/dist/index.html +108 -0
- package/daemon/dist/jsx-dev-runtime-shim.js +17 -0
- package/daemon/dist/jsx-runtime-shim.js +17 -0
- package/daemon/dist/react-dom-client-shim.js +4 -0
- package/daemon/dist/react-dom-shim.js +4 -0
- package/daemon/dist/react-shim.js +4 -0
- package/daemon/dist/runtime.js +1 -0
- package/daemon/dist/theme.css +66 -0
- package/daemon/src/compiler.ts +92 -0
- package/daemon/src/paths.ts +22 -0
- package/daemon/src/server.ts +448 -0
- package/daemon/src/session.ts +274 -0
- package/daemon/src/watcher.ts +59 -0
- package/hooks/session-start.sh +11 -0
- package/package.json +39 -0
- package/skills/canvas/SKILL.md +174 -0
- package/skills/canvas/components.md +349 -0
- package/skills/canvas/flows.md +406 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState, useCallback } from "react";
|
|
2
|
+
import { useAnnotations, Annotation } from "#canvas/runtime";
|
|
3
|
+
import { AnnotationCreatePopover } from "../Popover";
|
|
4
|
+
|
|
5
|
+
interface MermaidProps {
|
|
6
|
+
children?: React.ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface NodePopoverState {
|
|
10
|
+
anchorEl: HTMLElement;
|
|
11
|
+
snippet: string;
|
|
12
|
+
prefix: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function Mermaid({ children }: MermaidProps) {
|
|
16
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
17
|
+
const [error, setError] = useState<string | null>(null);
|
|
18
|
+
const source = typeof children === "string" ? children : String(children ?? "");
|
|
19
|
+
const { annotations, addAnnotationWithId, activeAnnotationId, setActiveAnnotationId } = useAnnotations();
|
|
20
|
+
const [nodePopover, setNodePopover] = useState<NodePopoverState | null>(null);
|
|
21
|
+
|
|
22
|
+
const annotationsRef = useRef(annotations);
|
|
23
|
+
annotationsRef.current = annotations;
|
|
24
|
+
|
|
25
|
+
const handleNodeClick = useCallback((anchorEl: HTMLElement, snippet: string, prefix: string) => {
|
|
26
|
+
const diagramSnippet = `[Diagram ${prefix.toLowerCase()}] ${snippet}`;
|
|
27
|
+
const existing = annotationsRef.current.find((a) => a.snippet === diagramSnippet);
|
|
28
|
+
if (existing) {
|
|
29
|
+
setActiveAnnotationId(existing.id);
|
|
30
|
+
} else {
|
|
31
|
+
setNodePopover({ anchorEl, snippet, prefix });
|
|
32
|
+
}
|
|
33
|
+
}, [setActiveAnnotationId]);
|
|
34
|
+
|
|
35
|
+
const handleNodeClickRef = useRef(handleNodeClick);
|
|
36
|
+
handleNodeClickRef.current = handleNodeClick;
|
|
37
|
+
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
let cancelled = false;
|
|
40
|
+
const render = async () => {
|
|
41
|
+
try {
|
|
42
|
+
const mermaid = (window as any).mermaid;
|
|
43
|
+
if (!mermaid) { setError("Mermaid library not loaded"); return; }
|
|
44
|
+
const theme = document.documentElement.dataset.theme === "light" ? "default" : "dark";
|
|
45
|
+
mermaid.initialize({ startOnLoad: false, theme });
|
|
46
|
+
const id = `mermaid-${Math.random().toString(36).slice(2)}`;
|
|
47
|
+
const { svg } = await mermaid.render(id, source.trim());
|
|
48
|
+
if (!cancelled && containerRef.current) {
|
|
49
|
+
containerRef.current.innerHTML = svg;
|
|
50
|
+
setupNodeAnnotations(containerRef.current, (el, snippet, prefix) => handleNodeClickRef.current(el, snippet, prefix));
|
|
51
|
+
highlightAnnotatedNodes(containerRef.current, annotationsRef.current);
|
|
52
|
+
}
|
|
53
|
+
setError(null);
|
|
54
|
+
} catch (e: any) {
|
|
55
|
+
if (!cancelled) setError(e.message || "Invalid diagram");
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
render();
|
|
59
|
+
|
|
60
|
+
// Re-render on theme change
|
|
61
|
+
const observer = new MutationObserver((mutations) => {
|
|
62
|
+
for (const m of mutations) {
|
|
63
|
+
if (m.attributeName === "data-theme") render();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
|
67
|
+
|
|
68
|
+
return () => { cancelled = true; observer.disconnect(); };
|
|
69
|
+
}, [source]);
|
|
70
|
+
|
|
71
|
+
// Update visual highlights when annotations change
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (containerRef.current) {
|
|
74
|
+
highlightAnnotatedNodes(containerRef.current, annotations, activeAnnotationId);
|
|
75
|
+
}
|
|
76
|
+
}, [annotations, activeAnnotationId]);
|
|
77
|
+
|
|
78
|
+
if (error) {
|
|
79
|
+
return (
|
|
80
|
+
<div className="bg-accent-red-muted rounded-md p-4 mt-3 text-body text-accent-red">
|
|
81
|
+
Mermaid error: {error}
|
|
82
|
+
<pre className="mt-2 text-tiny text-text-tertiary whitespace-pre-wrap">{source}</pre>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const scrollContainer = containerRef.current?.closest("#plan-scroll-container") as HTMLElement | null;
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<>
|
|
91
|
+
<div ref={containerRef} className="mt-3 flex justify-center overflow-x-auto mermaid-container" data-md="mermaid" data-md-source={source.trim()} />
|
|
92
|
+
{nodePopover && (
|
|
93
|
+
<AnnotationCreatePopover
|
|
94
|
+
anchorEl={nodePopover.anchorEl}
|
|
95
|
+
scrollContainer={scrollContainer}
|
|
96
|
+
snippet={nodePopover.snippet}
|
|
97
|
+
header={
|
|
98
|
+
<div style={{ fontSize: "12px", color: "var(--color-text-tertiary)", marginBottom: "8px", lineHeight: "1.4", display: "flex", alignItems: "center", gap: "6px" }}>
|
|
99
|
+
<span style={{ width: "8px", height: "8px", borderRadius: "50%", background: "var(--color-accent-amber)", flexShrink: 0 }} />
|
|
100
|
+
{nodePopover.snippet}
|
|
101
|
+
</div>
|
|
102
|
+
}
|
|
103
|
+
onAdd={(note) => {
|
|
104
|
+
const id = `ann-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
|
|
105
|
+
addAnnotationWithId(id, `[Diagram ${nodePopover.prefix.toLowerCase()}] ${nodePopover.snippet}`, note);
|
|
106
|
+
setNodePopover(null);
|
|
107
|
+
}}
|
|
108
|
+
onCancel={() => setNodePopover(null)}
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
111
|
+
</>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function setupNodeAnnotations(
|
|
116
|
+
container: HTMLElement,
|
|
117
|
+
showPopover: (anchorEl: HTMLElement, snippet: string, prefix: string) => void,
|
|
118
|
+
) {
|
|
119
|
+
const makeClickable = (el: SVGElement, getSnippet: () => string, prefix: string) => {
|
|
120
|
+
el.style.cursor = "pointer";
|
|
121
|
+
el.addEventListener("mouseenter", () => { el.style.filter = "brightness(1.2)"; });
|
|
122
|
+
el.addEventListener("mouseleave", () => { el.style.filter = ""; });
|
|
123
|
+
el.addEventListener("click", (e) => {
|
|
124
|
+
e.stopPropagation();
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
showPopover(el as unknown as HTMLElement, getSnippet(), prefix);
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
// Nodes
|
|
131
|
+
for (const node of container.querySelectorAll(".node")) {
|
|
132
|
+
makeClickable(node as SVGElement, () => {
|
|
133
|
+
const labelEl = node.querySelector(".nodeLabel") || node.querySelector("foreignObject span") || node.querySelector("text");
|
|
134
|
+
return labelEl?.textContent?.trim() || "Node";
|
|
135
|
+
}, "Node");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Edge paths (connections)
|
|
139
|
+
for (const edge of container.querySelectorAll(".edgePath")) {
|
|
140
|
+
const el = edge as SVGElement;
|
|
141
|
+
// Widen hit area for thin lines
|
|
142
|
+
const path = el.querySelector("path");
|
|
143
|
+
if (path) {
|
|
144
|
+
path.style.strokeWidth = Math.max(parseFloat(path.style.strokeWidth || "1"), 8) + "px";
|
|
145
|
+
path.style.strokeOpacity = "0";
|
|
146
|
+
const clone = path.cloneNode(true) as SVGPathElement;
|
|
147
|
+
clone.style.strokeWidth = "";
|
|
148
|
+
clone.style.strokeOpacity = "";
|
|
149
|
+
el.insertBefore(clone, path);
|
|
150
|
+
}
|
|
151
|
+
// Find the label for this edge (sibling edgeLabel)
|
|
152
|
+
const edgeId = el.id || "";
|
|
153
|
+
makeClickable(el, () => {
|
|
154
|
+
// Try to find matching edge label
|
|
155
|
+
const labels = container.querySelectorAll(".edgeLabel");
|
|
156
|
+
for (const label of labels) {
|
|
157
|
+
if (label.id && edgeId && label.id.replace("label", "").includes(edgeId.replace("L-", "").replace("edge", ""))) {
|
|
158
|
+
const text = label.textContent?.trim();
|
|
159
|
+
if (text) return text;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Fallback: find nearest edge label by position
|
|
163
|
+
return "Connection";
|
|
164
|
+
}, "Edge");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Edge labels
|
|
168
|
+
for (const label of container.querySelectorAll(".edgeLabel")) {
|
|
169
|
+
const text = label.textContent?.trim();
|
|
170
|
+
if (!text) continue;
|
|
171
|
+
makeClickable(label as SVGElement, () => text, "Edge");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Sequence diagram: actors (participants)
|
|
175
|
+
for (const actor of container.querySelectorAll(".actor")) {
|
|
176
|
+
const textEl = actor.closest("g")?.querySelector("text") || actor.parentElement?.querySelector("text");
|
|
177
|
+
if (!textEl) continue;
|
|
178
|
+
// The actor rect and text are siblings in a <g> — make the whole <g> clickable
|
|
179
|
+
const group = actor.closest("g") as SVGElement | null;
|
|
180
|
+
if (!group || group.hasAttribute("data-clickable")) continue;
|
|
181
|
+
group.setAttribute("data-clickable", "true");
|
|
182
|
+
makeClickable(group, () => textEl.textContent?.trim() || "Participant", "Node");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Sequence diagram: message labels
|
|
186
|
+
for (const msgText of container.querySelectorAll(".messageText")) {
|
|
187
|
+
const text = msgText.textContent?.trim();
|
|
188
|
+
if (!text) continue;
|
|
189
|
+
makeClickable(msgText as SVGElement, () => text, "Edge");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function highlightAnnotatedNodes(container: HTMLElement, annotations: Annotation[], activeId?: string | null) {
|
|
194
|
+
// Collect annotated diagram snippets
|
|
195
|
+
const annotatedSnippets = new Map<string, string>();
|
|
196
|
+
for (const ann of annotations) {
|
|
197
|
+
const match = ann.snippet.match(/^\[Diagram (?:node|edge)\] (.+)$/);
|
|
198
|
+
if (match) annotatedSnippets.set(match[1], ann.id);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Reset all node outlines
|
|
202
|
+
for (const el of container.querySelectorAll("[data-annotated]")) {
|
|
203
|
+
(el as SVGElement).style.outline = "";
|
|
204
|
+
(el as SVGElement).style.outlineOffset = "";
|
|
205
|
+
(el as SVGElement).style.borderRadius = "";
|
|
206
|
+
el.removeAttribute("data-annotated");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (annotatedSnippets.size === 0) return;
|
|
210
|
+
|
|
211
|
+
// Highlight nodes
|
|
212
|
+
for (const node of container.querySelectorAll(".node")) {
|
|
213
|
+
const labelEl = node.querySelector(".nodeLabel") || node.querySelector("foreignObject span") || node.querySelector("text");
|
|
214
|
+
const text = labelEl?.textContent?.trim();
|
|
215
|
+
if (!text || !annotatedSnippets.has(text)) continue;
|
|
216
|
+
const annId = annotatedSnippets.get(text)!;
|
|
217
|
+
const el = node as SVGElement;
|
|
218
|
+
el.setAttribute("data-annotated", annId);
|
|
219
|
+
const isActive = annId === activeId;
|
|
220
|
+
el.style.outline = isActive
|
|
221
|
+
? "2px solid var(--color-highlight-border)"
|
|
222
|
+
: "2px solid var(--color-highlight-annotation)";
|
|
223
|
+
el.style.outlineOffset = "2px";
|
|
224
|
+
el.style.borderRadius = "4px";
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Highlight edge labels
|
|
228
|
+
for (const label of container.querySelectorAll(".edgeLabel")) {
|
|
229
|
+
const text = label.textContent?.trim();
|
|
230
|
+
if (!text || !annotatedSnippets.has(text)) continue;
|
|
231
|
+
const annId = annotatedSnippets.get(text)!;
|
|
232
|
+
const el = label as SVGElement;
|
|
233
|
+
el.setAttribute("data-annotated", annId);
|
|
234
|
+
const isActive = annId === activeId;
|
|
235
|
+
el.style.outline = isActive
|
|
236
|
+
? "2px solid var(--color-highlight-border)"
|
|
237
|
+
: "2px solid var(--color-highlight-annotation)";
|
|
238
|
+
el.style.outlineOffset = "2px";
|
|
239
|
+
el.style.borderRadius = "4px";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Highlight sequence diagram actors
|
|
243
|
+
for (const actor of container.querySelectorAll(".actor")) {
|
|
244
|
+
const group = actor.closest("g") as SVGElement | null;
|
|
245
|
+
if (!group) continue;
|
|
246
|
+
const textEl = group.querySelector("text");
|
|
247
|
+
const text = textEl?.textContent?.trim();
|
|
248
|
+
if (!text || !annotatedSnippets.has(text)) continue;
|
|
249
|
+
if (group.hasAttribute("data-annotated")) continue;
|
|
250
|
+
const annId = annotatedSnippets.get(text)!;
|
|
251
|
+
group.setAttribute("data-annotated", annId);
|
|
252
|
+
const isActive = annId === activeId;
|
|
253
|
+
const rect = group.querySelector("rect");
|
|
254
|
+
if (rect) {
|
|
255
|
+
rect.style.outline = isActive
|
|
256
|
+
? "2px solid var(--color-highlight-border)"
|
|
257
|
+
: "2px solid var(--color-highlight-annotation)";
|
|
258
|
+
rect.style.outlineOffset = "2px";
|
|
259
|
+
rect.style.borderRadius = "4px";
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Highlight sequence diagram message labels
|
|
264
|
+
for (const msgText of container.querySelectorAll(".messageText")) {
|
|
265
|
+
const text = msgText.textContent?.trim();
|
|
266
|
+
if (!text || !annotatedSnippets.has(text)) continue;
|
|
267
|
+
const annId = annotatedSnippets.get(text)!;
|
|
268
|
+
const el = msgText as SVGElement;
|
|
269
|
+
el.setAttribute("data-annotated", annId);
|
|
270
|
+
const isActive = annId === activeId;
|
|
271
|
+
el.style.outline = isActive
|
|
272
|
+
? "2px solid var(--color-highlight-border)"
|
|
273
|
+
: "2px solid var(--color-highlight-annotation)";
|
|
274
|
+
el.style.outlineOffset = "2px";
|
|
275
|
+
el.style.borderRadius = "4px";
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface NoteProps {
|
|
4
|
+
children?: React.ReactNode;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function Note({ children }: NoteProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="relative bg-bg-elevated rounded-lg px-5 py-4 mt-3 text-body text-text-secondary italic leading-relaxed" data-md="note">
|
|
10
|
+
{children}
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface PriorityProps {
|
|
4
|
+
level: "high" | "medium" | "low";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const config = {
|
|
8
|
+
high: "text-accent-red bg-accent-red-muted",
|
|
9
|
+
medium: "text-accent-amber bg-accent-amber-muted",
|
|
10
|
+
low: "text-text-tertiary bg-border-subtle",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function Priority({ level }: PriorityProps) {
|
|
14
|
+
return (
|
|
15
|
+
<span className={`inline text-tiny font-medium font-body ml-2 px-1.5 py-0.5 rounded ${config[level]}`} data-md="priority" data-md-level={level}>
|
|
16
|
+
{level}
|
|
17
|
+
</span>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/** Seamless note input for response components */
|
|
4
|
+
export function ResponseNote({ note, onChange }: { show?: boolean; note: string; onToggle?: () => void; onChange: (n: string) => void }) {
|
|
5
|
+
return (
|
|
6
|
+
<div className="mt-1.5">
|
|
7
|
+
<textarea
|
|
8
|
+
value={note}
|
|
9
|
+
onChange={(e) => onChange(e.target.value)}
|
|
10
|
+
className="w-full bg-transparent text-[12px] font-body text-text-primary resize-none focus:outline-none border-none placeholder:text-text-disabled transition-colors min-h-[20px] p-0 leading-relaxed"
|
|
11
|
+
placeholder="Add a note..."
|
|
12
|
+
rows={1}
|
|
13
|
+
onInput={(e) => {
|
|
14
|
+
const t = e.target as HTMLTextAreaElement;
|
|
15
|
+
t.style.height = "auto";
|
|
16
|
+
t.style.height = t.scrollHeight + "px";
|
|
17
|
+
}}
|
|
18
|
+
ref={(el) => {
|
|
19
|
+
if (el) { el.style.height = "auto"; el.style.height = el.scrollHeight + "px"; }
|
|
20
|
+
}}
|
|
21
|
+
/>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React, { useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface SectionProps {
|
|
4
|
+
title: string;
|
|
5
|
+
children?: React.ReactNode;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Section({ title, children }: SectionProps) {
|
|
9
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
10
|
+
|
|
11
|
+
return (
|
|
12
|
+
<div className="relative mb-8 p-4 group/section" data-md="section" data-md-title={title}>
|
|
13
|
+
<div className="relative mb-2">
|
|
14
|
+
<button
|
|
15
|
+
onClick={() => setCollapsed(!collapsed)}
|
|
16
|
+
className={`absolute -left-8 top-1/2 -translate-y-1/2 text-text-tertiary opacity-0 group-hover/section:opacity-50 hover:!opacity-100 transition-all duration-150 p-0.5 ${collapsed ? "-rotate-90" : ""}`}
|
|
17
|
+
>
|
|
18
|
+
<svg width="14" height="14" viewBox="0 0 12 12" fill="none">
|
|
19
|
+
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
|
|
20
|
+
</svg>
|
|
21
|
+
</button>
|
|
22
|
+
<h2
|
|
23
|
+
className="font-heading text-section text-text-primary"
|
|
24
|
+
style={{ fontWeight: 400 }}
|
|
25
|
+
>
|
|
26
|
+
{title}
|
|
27
|
+
</h2>
|
|
28
|
+
</div>
|
|
29
|
+
<div className={collapsed ? "mt-6 hidden" : "mt-6"}>{children}</div>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface TableProps {
|
|
4
|
+
headers: string[];
|
|
5
|
+
rows: string[][];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function Table({ headers, rows }: TableProps) {
|
|
9
|
+
return (
|
|
10
|
+
<div className="overflow-x-auto mt-3" data-md="table">
|
|
11
|
+
<table className="w-full text-body">
|
|
12
|
+
<thead>
|
|
13
|
+
<tr className="border-b border-border-subtle">
|
|
14
|
+
{headers.map((h, i) => (
|
|
15
|
+
<th key={i} className="text-left px-4 py-2.5 text-meta font-medium uppercase tracking-wider text-text-tertiary font-body">
|
|
16
|
+
{h}
|
|
17
|
+
</th>
|
|
18
|
+
))}
|
|
19
|
+
</tr>
|
|
20
|
+
</thead>
|
|
21
|
+
<tbody>
|
|
22
|
+
{rows.map((row, ri) => (
|
|
23
|
+
<tr key={ri} className={`relative border-b border-border-subtle last:border-b-0 transition-colors hover:bg-bg-surface/50 ${ri % 2 === 1 ? "bg-bg-surface/30" : ""}`}>
|
|
24
|
+
{row.map((cell, ci) => (
|
|
25
|
+
<td key={ci} className="px-4 py-2.5 text-text-secondary font-body">
|
|
26
|
+
{cell}
|
|
27
|
+
</td>
|
|
28
|
+
))}
|
|
29
|
+
</tr>
|
|
30
|
+
))}
|
|
31
|
+
</tbody>
|
|
32
|
+
</table>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
type Status = "todo" | "done" | "blocked" | "in-progress";
|
|
4
|
+
type BadgeVariant = "info" | "success" | "warning" | "danger" | "neutral";
|
|
5
|
+
|
|
6
|
+
interface ItemProps {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
status?: Status;
|
|
10
|
+
badge?: string;
|
|
11
|
+
badgeVariant?: BadgeVariant;
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const dotColors: Record<Status, string> = {
|
|
16
|
+
todo: "bg-border-hover",
|
|
17
|
+
done: "bg-accent-green",
|
|
18
|
+
blocked: "bg-accent-red",
|
|
19
|
+
"in-progress": "bg-accent-blue",
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const statusLabels: Record<Status, string> = {
|
|
23
|
+
todo: "Todo",
|
|
24
|
+
done: "Done",
|
|
25
|
+
blocked: "Blocked",
|
|
26
|
+
"in-progress": "In progress",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const badgeStyles: Record<BadgeVariant, string> = {
|
|
30
|
+
info: "bg-accent-blue-muted text-accent-blue",
|
|
31
|
+
success: "bg-accent-green-muted text-accent-green",
|
|
32
|
+
warning: "bg-accent-amber-muted text-accent-amber",
|
|
33
|
+
danger: "bg-accent-red-muted text-accent-red",
|
|
34
|
+
neutral: "bg-border-subtle text-text-tertiary",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function Item({ id, label, status, badge, badgeVariant = "neutral", children }: ItemProps) {
|
|
38
|
+
return (
|
|
39
|
+
<div
|
|
40
|
+
className="group/task relative -mx-4 px-4 py-3 rounded-md transition-colors duration-150 hover:bg-bg-input"
|
|
41
|
+
data-task-id={id}
|
|
42
|
+
data-md="item"
|
|
43
|
+
data-md-label={label}
|
|
44
|
+
{...(status ? { "data-md-status": status } : {})}
|
|
45
|
+
{...(badge ? { "data-md-badge": badge } : {})}
|
|
46
|
+
>
|
|
47
|
+
<div className="flex items-start gap-3">
|
|
48
|
+
{status && <span className={`w-2 h-2 rounded-full mt-[7px] flex-shrink-0 ${dotColors[status]}`} />}
|
|
49
|
+
<div className="flex-1 min-w-0">
|
|
50
|
+
<div className="flex items-baseline gap-2">
|
|
51
|
+
<span className="font-body text-task-label font-semibold text-text-primary">{label}</span>
|
|
52
|
+
{status && (
|
|
53
|
+
<span className="font-body text-tiny uppercase tracking-wider text-text-tertiary opacity-0 group-hover/task:opacity-100 transition-opacity duration-150">
|
|
54
|
+
{statusLabels[status]}
|
|
55
|
+
</span>
|
|
56
|
+
)}
|
|
57
|
+
{badge && (
|
|
58
|
+
<span className={`font-body text-tiny font-medium px-1.5 py-px rounded-full ${badgeStyles[badgeVariant]}`}>
|
|
59
|
+
{badge}
|
|
60
|
+
</span>
|
|
61
|
+
)}
|
|
62
|
+
</div>
|
|
63
|
+
{children && (
|
|
64
|
+
<div className="text-body text-text-secondary mt-1 leading-relaxed">{children}</div>
|
|
65
|
+
)}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @deprecated Use <Item> instead */
|
|
73
|
+
export function Task(props: ItemProps) {
|
|
74
|
+
return <Item {...props} />;
|
|
75
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useAnnotations } from "#canvas/runtime";
|
|
3
|
+
import { ResponseNote } from "./ResponseNote";
|
|
4
|
+
|
|
5
|
+
/** Free text input from user */
|
|
6
|
+
interface UserInputProps {
|
|
7
|
+
id: string;
|
|
8
|
+
label: string;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
multiline?: boolean;
|
|
11
|
+
required?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function UserInput({ id, label, placeholder, multiline, required }: UserInputProps) {
|
|
15
|
+
const { responses, setResponse } = useAnnotations();
|
|
16
|
+
const current = responses.get(id);
|
|
17
|
+
const value = (current?.value as string) ?? "";
|
|
18
|
+
const note = current?.note || "";
|
|
19
|
+
const [showNote, setShowNote] = useState(false);
|
|
20
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!responses.has(id)) {
|
|
24
|
+
setResponse(id, { id, type: "text", label, value: "", required });
|
|
25
|
+
}
|
|
26
|
+
}, [id]);
|
|
27
|
+
|
|
28
|
+
const handleChange = (v: string) => {
|
|
29
|
+
setResponse(id, { ...current!, value: v });
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const autoResize = (el: HTMLTextAreaElement) => {
|
|
33
|
+
el.style.height = "auto";
|
|
34
|
+
el.style.height = Math.max(multiline ? 60 : 32, el.scrollHeight) + "px";
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const showError = current?.required && !value.trim();
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div data-md="userinput" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg transition-colors duration-150 hover:bg-bg-input">
|
|
41
|
+
<div className="flex items-baseline gap-2 mb-2">
|
|
42
|
+
<span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
|
|
43
|
+
{required && <span className="text-[10px] text-accent-red font-body">*</span>}
|
|
44
|
+
</div>
|
|
45
|
+
<textarea
|
|
46
|
+
ref={ref}
|
|
47
|
+
value={value}
|
|
48
|
+
onChange={(e) => { handleChange(e.target.value); autoResize(e.target); }}
|
|
49
|
+
placeholder={placeholder || "Type your response..."}
|
|
50
|
+
rows={multiline ? 3 : 1}
|
|
51
|
+
className="w-full bg-bg-input text-[13px] font-body text-text-primary rounded-lg px-3 py-2 resize-none focus:outline-none border border-border-subtle focus:border-border-hover placeholder:text-text-tertiary transition-colors"
|
|
52
|
+
onInput={(e) => autoResize(e.target as HTMLTextAreaElement)}
|
|
53
|
+
onFocus={(e) => autoResize(e.target)}
|
|
54
|
+
/>
|
|
55
|
+
<ResponseNote show={showNote} note={note} onToggle={() => setShowNote(!showNote)} onChange={(n) => setResponse(id, { ...current!, note: n })} />
|
|
56
|
+
{showError && <p className="text-[11px] text-accent-red font-body mt-1">This field is required.</p>}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Numeric range / slider */
|
|
62
|
+
interface RangeInputProps {
|
|
63
|
+
id: string;
|
|
64
|
+
label: string;
|
|
65
|
+
min?: number;
|
|
66
|
+
max?: number;
|
|
67
|
+
step?: number;
|
|
68
|
+
required?: boolean;
|
|
69
|
+
minLabel?: string;
|
|
70
|
+
maxLabel?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function RangeInput({ id, label, min = 1, max = 10, step = 1, required, minLabel, maxLabel }: RangeInputProps) {
|
|
74
|
+
const { responses, setResponse } = useAnnotations();
|
|
75
|
+
const current = responses.get(id);
|
|
76
|
+
const value = current?.value as number | null;
|
|
77
|
+
const note = current?.note || "";
|
|
78
|
+
const [showNote, setShowNote] = useState(false);
|
|
79
|
+
|
|
80
|
+
useEffect(() => {
|
|
81
|
+
if (!responses.has(id)) {
|
|
82
|
+
setResponse(id, { id, type: "range", label, value: null, required });
|
|
83
|
+
}
|
|
84
|
+
}, [id]);
|
|
85
|
+
|
|
86
|
+
const handleChange = (v: number) => {
|
|
87
|
+
setResponse(id, { ...current!, value: v });
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const hasValue = value !== null && value !== undefined;
|
|
91
|
+
const displayValue = hasValue ? value : Math.round((min + max) / 2);
|
|
92
|
+
const pct = ((displayValue - min) / (max - min)) * 100;
|
|
93
|
+
const stepCount = Math.round((max - min) / step);
|
|
94
|
+
const showDots = stepCount <= 20;
|
|
95
|
+
|
|
96
|
+
const showError = current?.required && !hasValue;
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div data-md="rangeinput" data-md-label={label} className="-mx-4 px-4 py-3 my-1 rounded-lg transition-colors duration-150 hover:bg-bg-input">
|
|
100
|
+
<div className="flex items-baseline justify-between mb-1">
|
|
101
|
+
<div className="flex items-baseline gap-2">
|
|
102
|
+
<span className="text-[13px] font-body font-medium text-text-primary">{label}</span>
|
|
103
|
+
{required && <span className="text-[10px] text-accent-red font-body">*</span>}
|
|
104
|
+
</div>
|
|
105
|
+
<span className="flex items-center gap-1.5">
|
|
106
|
+
<span className={`text-[13px] font-mono ${hasValue ? "text-accent-amber" : "text-text-disabled"}`}>
|
|
107
|
+
{hasValue ? value : "—"}
|
|
108
|
+
</span>
|
|
109
|
+
{hasValue && (
|
|
110
|
+
<button
|
|
111
|
+
onClick={() => setResponse(id, { ...current!, value: null })}
|
|
112
|
+
className="text-text-disabled hover:text-text-tertiary transition-colors text-[10px] leading-none"
|
|
113
|
+
title="Clear"
|
|
114
|
+
>
|
|
115
|
+
✕
|
|
116
|
+
</button>
|
|
117
|
+
)}
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="relative h-6 flex items-center">
|
|
121
|
+
{/* Track background */}
|
|
122
|
+
<div className="absolute left-0 right-0 h-1.5 rounded-full bg-border-medium">
|
|
123
|
+
{hasValue && <div className="h-full rounded-full bg-accent-amber transition-all duration-150" style={{ width: `${pct}%` }} />}
|
|
124
|
+
</div>
|
|
125
|
+
{/* Discrete step dots */}
|
|
126
|
+
{showDots && Array.from({ length: stepCount + 1 }, (_, i) => {
|
|
127
|
+
const dotVal = min + i * step;
|
|
128
|
+
const dotPct = ((dotVal - min) / (max - min)) * 100;
|
|
129
|
+
const isSelected = hasValue && dotVal <= value!;
|
|
130
|
+
const isActive = hasValue && dotVal === value;
|
|
131
|
+
return (
|
|
132
|
+
<div
|
|
133
|
+
key={i}
|
|
134
|
+
className={`absolute rounded-full pointer-events-none transition-all duration-150 ${
|
|
135
|
+
isActive ? "w-2.5 h-2.5 bg-accent-amber" : isSelected ? "w-1.5 h-1.5 bg-accent-amber" : "w-1.5 h-1.5 bg-border-hover"
|
|
136
|
+
}`}
|
|
137
|
+
style={{ left: `${dotPct}%`, transform: "translate(-50%, -50%)", top: "50%" }}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
})}
|
|
141
|
+
{/* Thumb indicator */}
|
|
142
|
+
{hasValue ? (
|
|
143
|
+
<div className="absolute h-5 w-5 rounded-full bg-accent-amber shadow-md pointer-events-none transition-all duration-150" style={{ left: `calc(${pct}% - 10px)`, borderWidth: "2px", borderStyle: "solid", borderColor: "var(--color-bg-base)" }} />
|
|
144
|
+
) : (
|
|
145
|
+
<div className="absolute h-4 w-4 rounded-full bg-border-hover pointer-events-none opacity-50" style={{ left: "calc(50% - 8px)", borderWidth: "2px", borderStyle: "solid", borderColor: "var(--color-bg-base)" }} />
|
|
146
|
+
)}
|
|
147
|
+
<input
|
|
148
|
+
type="range"
|
|
149
|
+
min={min}
|
|
150
|
+
max={max}
|
|
151
|
+
step={step}
|
|
152
|
+
value={displayValue}
|
|
153
|
+
onChange={(e) => handleChange(Number(e.target.value))}
|
|
154
|
+
className="absolute inset-0 w-full opacity-0 cursor-pointer"
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
{/* Labels: show step values if few enough, otherwise just min/max */}
|
|
158
|
+
{showDots && stepCount <= 10 ? (
|
|
159
|
+
<div className="flex justify-between -mt-0.5 px-0">
|
|
160
|
+
{Array.from({ length: stepCount + 1 }, (_, i) => {
|
|
161
|
+
const dotVal = min + i * step;
|
|
162
|
+
const isActive = hasValue && dotVal === value;
|
|
163
|
+
return (
|
|
164
|
+
<span key={i} className={`text-[10px] font-mono transition-colors ${isActive ? "text-accent-amber font-medium" : "text-text-disabled"}`} style={{ minWidth: "1ch", textAlign: "center" }}>
|
|
165
|
+
{dotVal}
|
|
166
|
+
</span>
|
|
167
|
+
);
|
|
168
|
+
})}
|
|
169
|
+
</div>
|
|
170
|
+
) : (
|
|
171
|
+
<div className="flex justify-between -mt-0.5">
|
|
172
|
+
<span className="text-[11px] text-text-tertiary font-body">{minLabel || min}</span>
|
|
173
|
+
<span className="text-[11px] text-text-tertiary font-body">{maxLabel || max}</span>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
<ResponseNote show={showNote} note={note} onToggle={() => setShowNote(!showNote)} onChange={(n) => setResponse(id, { ...current!, note: n })} />
|
|
177
|
+
{showError && <p className="text-[11px] text-accent-red font-body mt-1">Please select a value.</p>}
|
|
178
|
+
</div>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { Section } from "./Section";
|
|
2
|
+
export { Item, Task } from "./Task";
|
|
3
|
+
export { CodeBlock } from "./CodeBlock";
|
|
4
|
+
export { Callout } from "./Callout";
|
|
5
|
+
export { Note } from "./Note";
|
|
6
|
+
export { Table } from "./Table";
|
|
7
|
+
export { Checklist } from "./Checklist";
|
|
8
|
+
export { Priority } from "./Priority";
|
|
9
|
+
export { FilePreview } from "./FilePreview";
|
|
10
|
+
export { Mermaid } from "./Mermaid";
|
|
11
|
+
export { Diff } from "./Diff";
|
|
12
|
+
export { Choice, MultiChoice } from "./Choice";
|
|
13
|
+
export { UserInput, RangeInput } from "./UserInput";
|
|
14
|
+
export { ImageView } from "./Image";
|
|
15
|
+
export { useFeedback } from "#canvas/runtime";
|