llm-canvas-linux-x64 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/llm-canvas +0 -0
- package/package.json +21 -0
- package/skills/canvas-workspace/SKILL.md +320 -0
- package/workspace/app.js +727 -0
- package/workspace/comments.js +345 -0
- package/workspace/index.html +136 -0
- package/workspace/lib/highlight.min.js +1244 -0
- package/workspace/lib/hljs-github-dark.min.css +10 -0
- package/workspace/lib/mermaid.min.js +2029 -0
- package/workspace/lib/tailwind.js +83 -0
- package/workspace/lib/three.min.js +7 -0
- package/workspace/lib/turndown-plugin-gfm.min.js +165 -0
- package/workspace/lib/turndown.min.js +974 -0
- package/workspace/styles.css +454 -0
- package/workspace/theme.css +136 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// workspace/comments.js — inline anchored comments. The user selects text /
|
|
2
|
+
// an image / a structural block inside a section; a floating button opens a
|
|
3
|
+
// composer that sends a comment.created inbox event carrying an `anchor`.
|
|
4
|
+
// See docs/superpowers/specs/2026-05-16-inline-anchored-comments-design.md
|
|
5
|
+
|
|
6
|
+
const CTX_LEN = 80; // chars of prefix/suffix context captured around a selection
|
|
7
|
+
const QUOTE_MAX = 280; // max chars of captured quote text
|
|
8
|
+
const QUOTE_MAX_BYTES = 280; // server byte cap for anchor.quote
|
|
9
|
+
const CTX_MAX_BYTES = 200; // server byte cap for anchor.prefix/suffix/heading
|
|
10
|
+
const MAX_BYTES = 4096; // inbox text byte cap, mirrors INBOX_MAX_BYTES in app.js
|
|
11
|
+
const BLOCK_SELECTOR = 'table, pre, blockquote, figure, .canvas-callout, [data-mermaid-src]';
|
|
12
|
+
|
|
13
|
+
let getSlug = () => null;
|
|
14
|
+
let floatBtn, popover, popQuote, popKind, popText, popSend, popCancel, popError, popCounter;
|
|
15
|
+
let armed = null; // { sectionId, anchor } the float button / composer acts on
|
|
16
|
+
let initialised = false;
|
|
17
|
+
|
|
18
|
+
// Coalesce rapid-fire events (e.g. selectionchange during a shift-arrow drag).
|
|
19
|
+
function debounce(fn, ms) {
|
|
20
|
+
let t;
|
|
21
|
+
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// The server caps anchor strings by UTF-8 byte length; the browser must clamp
|
|
25
|
+
// by bytes too, or a multi-byte (CJK/emoji) selection is rejected on POST.
|
|
26
|
+
function clampBytesStart(str, maxBytes) {
|
|
27
|
+
const enc = new TextEncoder();
|
|
28
|
+
if (enc.encode(str).length <= maxBytes) return str;
|
|
29
|
+
let lo = 0, hi = str.length;
|
|
30
|
+
while (lo < hi) {
|
|
31
|
+
const mid = (lo + hi + 1) >> 1;
|
|
32
|
+
if (enc.encode(str.slice(0, mid)).length <= maxBytes) lo = mid; else hi = mid - 1;
|
|
33
|
+
}
|
|
34
|
+
return str.slice(0, lo);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clampBytesEnd(str, maxBytes) {
|
|
38
|
+
const enc = new TextEncoder();
|
|
39
|
+
if (enc.encode(str).length <= maxBytes) return str;
|
|
40
|
+
let lo = 0, hi = str.length;
|
|
41
|
+
while (lo < hi) {
|
|
42
|
+
const mid = (lo + hi + 1) >> 1;
|
|
43
|
+
if (enc.encode(str.slice(str.length - mid)).length <= maxBytes) lo = mid; else hi = mid - 1;
|
|
44
|
+
}
|
|
45
|
+
return str.slice(str.length - lo);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---- public API -----------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
export function initComments(context) {
|
|
51
|
+
getSlug = context.getSlug;
|
|
52
|
+
if (initialised) return; // idempotent: re-init only refreshes getSlug
|
|
53
|
+
initialised = true;
|
|
54
|
+
injectStyles();
|
|
55
|
+
buildFloatBtn();
|
|
56
|
+
buildPopover();
|
|
57
|
+
registerSource({
|
|
58
|
+
doc: document,
|
|
59
|
+
offset: () => ({ x: 0, y: 0 }),
|
|
60
|
+
sectionOf: (node) => {
|
|
61
|
+
const host = climb(node, (n) => n.classList?.contains('canvas-section-host'));
|
|
62
|
+
return host ? { id: host.dataset.sectionId, root: host } : null;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Called by app.js after a document-mode section's iframe loads. Watches the
|
|
68
|
+
// iframe document for selections/hovers and positions the button in the parent.
|
|
69
|
+
export function attachIframeComments(iframe, sectionId) {
|
|
70
|
+
const doc = iframe.contentDocument;
|
|
71
|
+
if (!doc) { console.warn('[comments] iframe contentDocument unavailable for', sectionId); return; }
|
|
72
|
+
if (doc.__canvasCommentsAttached) return; // re-render safety
|
|
73
|
+
doc.__canvasCommentsAttached = true;
|
|
74
|
+
registerSource({
|
|
75
|
+
doc,
|
|
76
|
+
offset: () => {
|
|
77
|
+
const r = iframe.getBoundingClientRect();
|
|
78
|
+
return { x: r.left, y: r.top };
|
|
79
|
+
},
|
|
80
|
+
sectionOf: () => ({ id: sectionId, root: doc.body }),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- source registration ---------------------------------------------------
|
|
85
|
+
|
|
86
|
+
// A "source" is a document we watch for selections: the main document, or a
|
|
87
|
+
// document-mode section's iframe document (registered in Task 6).
|
|
88
|
+
function registerSource(src) {
|
|
89
|
+
src.doc.addEventListener('mouseup', () => handleSelection(src));
|
|
90
|
+
src.doc.addEventListener('selectionchange', debounce(() => handleSelection(src), 150));
|
|
91
|
+
src.doc.addEventListener('scroll', hideFloatBtn, true);
|
|
92
|
+
src.doc.addEventListener('mouseover', (e) => handleHover(e, src));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function handleSelection(src) {
|
|
96
|
+
const sel = src.doc.getSelection();
|
|
97
|
+
if (!sel || sel.isCollapsed || sel.toString().trim() === '') { hideFloatBtn(); return; }
|
|
98
|
+
const range = sel.getRangeAt(0);
|
|
99
|
+
const section = src.sectionOf(range.commonAncestorContainer);
|
|
100
|
+
if (!section) { hideFloatBtn(); return; }
|
|
101
|
+
const anchor = {
|
|
102
|
+
kind: 'text',
|
|
103
|
+
quote: clampBytesStart(sel.toString().trim().slice(0, QUOTE_MAX), QUOTE_MAX_BYTES),
|
|
104
|
+
prefix: contextBefore(range, section.root),
|
|
105
|
+
suffix: contextAfter(range, section.root),
|
|
106
|
+
heading: nearestHeading(range.startContainer, section.root),
|
|
107
|
+
tag: null,
|
|
108
|
+
};
|
|
109
|
+
armFloat({ sectionId: section.id, anchor }, range.getBoundingClientRect(), src.offset());
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Show the comment button when hovering an image or a structural block.
|
|
113
|
+
function handleHover(e, src) {
|
|
114
|
+
const sel = src.doc.getSelection();
|
|
115
|
+
if (sel && !sel.isCollapsed) return; // a text selection wins
|
|
116
|
+
const el = e.target.closest('img, ' + BLOCK_SELECTOR);
|
|
117
|
+
if (!el) return;
|
|
118
|
+
const section = src.sectionOf(el);
|
|
119
|
+
if (!section) return;
|
|
120
|
+
const isImage = el.tagName === 'IMG';
|
|
121
|
+
const rawQuote = isImage
|
|
122
|
+
? (el.getAttribute('alt') || el.getAttribute('src') || '[image]')
|
|
123
|
+
: (el.getAttribute('data-mermaid-src') || el.textContent).trim().replace(/\s+/g, ' ');
|
|
124
|
+
const anchor = {
|
|
125
|
+
kind: isImage ? 'image' : 'block',
|
|
126
|
+
quote: clampBytesStart(rawQuote.slice(0, QUOTE_MAX), QUOTE_MAX_BYTES),
|
|
127
|
+
prefix: '',
|
|
128
|
+
suffix: '',
|
|
129
|
+
heading: nearestHeading(el, section.root),
|
|
130
|
+
tag: el.tagName.toLowerCase(),
|
|
131
|
+
};
|
|
132
|
+
armFloat({ sectionId: section.id, anchor }, el.getBoundingClientRect(), src.offset());
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- anchor derivation -----------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function contextBefore(range, root) {
|
|
138
|
+
const r = range.cloneRange();
|
|
139
|
+
r.selectNodeContents(root);
|
|
140
|
+
r.setEnd(range.startContainer, range.startOffset);
|
|
141
|
+
return clampBytesEnd(r.toString().slice(-CTX_LEN), CTX_MAX_BYTES);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function contextAfter(range, root) {
|
|
145
|
+
const r = range.cloneRange();
|
|
146
|
+
r.selectNodeContents(root);
|
|
147
|
+
r.setStart(range.endContainer, range.endOffset);
|
|
148
|
+
return clampBytesStart(r.toString().slice(0, CTX_LEN), CTX_MAX_BYTES);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Nearest heading at or before `node` in document order, within `root`.
|
|
152
|
+
function nearestHeading(node, root) {
|
|
153
|
+
let best = null;
|
|
154
|
+
for (const h of root.querySelectorAll('h1,h2,h3,h4,h5,h6')) {
|
|
155
|
+
if (h === node || h.contains(node) ||
|
|
156
|
+
(h.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING)) best = h;
|
|
157
|
+
else break;
|
|
158
|
+
}
|
|
159
|
+
return best ? clampBytesStart(best.textContent.trim(), CTX_MAX_BYTES) || null : null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function climb(node, test) {
|
|
163
|
+
let el = node?.nodeType === Node.ELEMENT_NODE ? node : node?.parentElement;
|
|
164
|
+
while (el) { if (test(el)) return el; el = el.parentElement; }
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---- floating button -------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
function buildFloatBtn() {
|
|
171
|
+
floatBtn = document.createElement('button');
|
|
172
|
+
floatBtn.type = 'button';
|
|
173
|
+
floatBtn.className = 'canvas-comment-fab hidden';
|
|
174
|
+
floatBtn.textContent = '💬 Comment';
|
|
175
|
+
floatBtn.addEventListener('mousedown', (e) => e.preventDefault()); // keep selection
|
|
176
|
+
floatBtn.addEventListener('click', () => { if (armed) openComposer(); });
|
|
177
|
+
document.body.appendChild(floatBtn);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// rect is viewport-relative inside the source doc; off shifts it into the
|
|
181
|
+
// parent viewport (zero for the main doc, the iframe's offset otherwise).
|
|
182
|
+
function armFloat(target, rect, off) {
|
|
183
|
+
armed = target;
|
|
184
|
+
const top = off.y + rect.top - 38;
|
|
185
|
+
floatBtn.style.left = `${Math.min(off.x + rect.left, window.innerWidth - 96)}px`;
|
|
186
|
+
floatBtn.style.top = `${top < 4 ? off.y + rect.bottom + 8 : top}px`;
|
|
187
|
+
floatBtn.classList.remove('hidden');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function hideFloatBtn() {
|
|
191
|
+
if (popover && !popover.classList.contains('hidden')) return; // composer open
|
|
192
|
+
floatBtn.classList.add('hidden');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- composer popover ------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
function buildPopover() {
|
|
198
|
+
popover = document.createElement('div');
|
|
199
|
+
popover.className = 'canvas-comment-pop hidden';
|
|
200
|
+
popover.innerHTML = `
|
|
201
|
+
<div class="canvas-comment-pop-quote"><span class="canvas-comment-pop-kind"></span><blockquote></blockquote></div>
|
|
202
|
+
<textarea class="canvas-comment-pop-text" placeholder="Describe the change or comment…"></textarea>
|
|
203
|
+
<div class="canvas-comment-pop-error hidden"></div>
|
|
204
|
+
<div class="canvas-comment-pop-actions">
|
|
205
|
+
<span class="canvas-comment-pop-counter"></span>
|
|
206
|
+
<button type="button" class="canvas-comment-pop-cancel">Cancel</button>
|
|
207
|
+
<button type="button" class="canvas-comment-pop-send">Send</button>
|
|
208
|
+
</div>`;
|
|
209
|
+
document.body.appendChild(popover);
|
|
210
|
+
popKind = popover.querySelector('.canvas-comment-pop-kind');
|
|
211
|
+
popQuote = popover.querySelector('blockquote');
|
|
212
|
+
popText = popover.querySelector('.canvas-comment-pop-text');
|
|
213
|
+
popError = popover.querySelector('.canvas-comment-pop-error');
|
|
214
|
+
popSend = popover.querySelector('.canvas-comment-pop-send');
|
|
215
|
+
popCancel = popover.querySelector('.canvas-comment-pop-cancel');
|
|
216
|
+
popCounter = popover.querySelector('.canvas-comment-pop-counter');
|
|
217
|
+
popSend.addEventListener('click', send);
|
|
218
|
+
popCancel.addEventListener('click', closePopover);
|
|
219
|
+
popText.addEventListener('input', updateComposerState);
|
|
220
|
+
popText.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePopover(); });
|
|
221
|
+
document.addEventListener('mousedown', (e) => {
|
|
222
|
+
if (!popover.classList.contains('hidden') && !popover.contains(e.target) && e.target !== floatBtn) {
|
|
223
|
+
closePopover();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function openComposer() {
|
|
229
|
+
popKind.textContent = armed.anchor.kind;
|
|
230
|
+
popQuote.textContent = armed.anchor.quote;
|
|
231
|
+
popText.value = '';
|
|
232
|
+
popError.classList.add('hidden');
|
|
233
|
+
updateComposerState();
|
|
234
|
+
const r = floatBtn.getBoundingClientRect();
|
|
235
|
+
popover.style.left = `${Math.max(4, Math.min(r.left, window.innerWidth - 340))}px`;
|
|
236
|
+
popover.style.top = `${Math.min(r.bottom + 6, window.innerHeight - 220)}px`;
|
|
237
|
+
popover.classList.remove('hidden');
|
|
238
|
+
floatBtn.classList.add('hidden');
|
|
239
|
+
popText.focus();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function updateComposerState() {
|
|
243
|
+
const n = new TextEncoder().encode(popText.value).length;
|
|
244
|
+
popCounter.textContent = `${n} / ${MAX_BYTES}`;
|
|
245
|
+
popCounter.dataset.over = n > MAX_BYTES ? 'true' : 'false';
|
|
246
|
+
popSend.disabled = n === 0 || n > MAX_BYTES;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function closePopover() {
|
|
250
|
+
popover.classList.add('hidden');
|
|
251
|
+
floatBtn.classList.add('hidden');
|
|
252
|
+
armed = null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function send() {
|
|
256
|
+
const text = popText.value;
|
|
257
|
+
const slug = getSlug();
|
|
258
|
+
if (!text.trim() || !slug || !armed) return;
|
|
259
|
+
if (new TextEncoder().encode(text).length > MAX_BYTES) {
|
|
260
|
+
popError.textContent = 'Comment is over the 4096-byte limit.';
|
|
261
|
+
popError.classList.remove('hidden');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
popSend.disabled = true;
|
|
265
|
+
popError.classList.add('hidden');
|
|
266
|
+
try {
|
|
267
|
+
const res = await fetch(`/api/sessions/${slug}/inbox`, {
|
|
268
|
+
method: 'POST',
|
|
269
|
+
headers: { 'content-type': 'application/json' },
|
|
270
|
+
body: JSON.stringify({ text, section_id: armed.sectionId, anchor: armed.anchor }),
|
|
271
|
+
});
|
|
272
|
+
if (!res.ok) {
|
|
273
|
+
let detail = `HTTP ${res.status}`;
|
|
274
|
+
try { detail = (await res.json()).message || detail; } catch {}
|
|
275
|
+
popError.textContent = `Send failed: ${detail}`;
|
|
276
|
+
popError.classList.remove('hidden');
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
closePopover(); // the inbox.updated broadcast refreshes the thread
|
|
280
|
+
} catch (err) {
|
|
281
|
+
popError.textContent = `Send failed: ${err.message}`;
|
|
282
|
+
popError.classList.remove('hidden');
|
|
283
|
+
} finally {
|
|
284
|
+
if (!popover.classList.contains('hidden')) updateComposerState();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---- styles ----------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
function injectStyles() {
|
|
291
|
+
const style = document.createElement('style');
|
|
292
|
+
style.textContent = `
|
|
293
|
+
.canvas-comment-fab {
|
|
294
|
+
position: fixed; z-index: 60; padding: 4px 10px; font-size: 12px;
|
|
295
|
+
border-radius: 6px; border: 1px solid #d4d4d8; background: #fff; color: #18181b;
|
|
296
|
+
box-shadow: 0 2px 8px rgba(0,0,0,.18); cursor: pointer;
|
|
297
|
+
}
|
|
298
|
+
.canvas-comment-pop {
|
|
299
|
+
position: fixed; z-index: 61; width: 320px; padding: 10px;
|
|
300
|
+
border-radius: 8px; border: 1px solid #d4d4d8; background: #fff; color: #18181b;
|
|
301
|
+
box-shadow: 0 8px 28px rgba(0,0,0,.22); display: flex; flex-direction: column; gap: 8px;
|
|
302
|
+
}
|
|
303
|
+
.canvas-comment-pop-kind {
|
|
304
|
+
display: inline-block; font-size: 10px; text-transform: uppercase;
|
|
305
|
+
letter-spacing: .04em; padding: 1px 6px; border-radius: 4px;
|
|
306
|
+
background: #f4f4f5; color: #52525b; margin-bottom: 4px;
|
|
307
|
+
}
|
|
308
|
+
.canvas-comment-pop blockquote {
|
|
309
|
+
margin: 0; padding: 4px 8px; border-left: 3px solid #a1a1aa;
|
|
310
|
+
font-size: 12px; color: #3f3f46; max-height: 72px; overflow: auto; white-space: pre-wrap;
|
|
311
|
+
}
|
|
312
|
+
.canvas-comment-pop-text {
|
|
313
|
+
width: 100%; min-height: 64px; resize: vertical; font: inherit; font-size: 13px;
|
|
314
|
+
padding: 6px 8px; border-radius: 6px; border: 1px solid #d4d4d8; box-sizing: border-box;
|
|
315
|
+
}
|
|
316
|
+
.canvas-comment-pop-error { font-size: 12px; color: #dc2626; }
|
|
317
|
+
.canvas-comment-pop-counter { font-size: 11px; color: #71717a; margin-right: auto; }
|
|
318
|
+
.canvas-comment-pop-counter[data-over="true"] { color: #dc2626; }
|
|
319
|
+
.canvas-comment-pop-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
320
|
+
.canvas-comment-pop-actions button {
|
|
321
|
+
font-size: 12px; padding: 4px 12px; border-radius: 6px; cursor: pointer;
|
|
322
|
+
border: 1px solid #d4d4d8; background: #fff; color: #18181b;
|
|
323
|
+
}
|
|
324
|
+
.canvas-comment-pop-send { background: #2563eb; color: #fff; border-color: #2563eb; }
|
|
325
|
+
.canvas-comment-pop-send:disabled { opacity: .5; cursor: default; }
|
|
326
|
+
.dark .canvas-comment-fab, .dark .canvas-comment-pop {
|
|
327
|
+
background: #1f1f23; color: #e4e4e7; border-color: #3f3f46;
|
|
328
|
+
}
|
|
329
|
+
.dark .canvas-comment-pop-kind { background: #27272a; color: #a1a1aa; }
|
|
330
|
+
.dark .canvas-comment-pop-counter { color: #a1a1aa; }
|
|
331
|
+
.dark .canvas-comment-pop blockquote { color: #d4d4d8; border-left-color: #52525b; }
|
|
332
|
+
.dark .canvas-comment-pop-text { background: #18181b; color: #e4e4e7; border-color: #3f3f46; }
|
|
333
|
+
.dark .canvas-comment-pop-actions button { background: #27272a; color: #e4e4e7; border-color: #3f3f46; }
|
|
334
|
+
.dark .canvas-comment-pop-send { background: #2563eb; color: #fff; border-color: #2563eb; }
|
|
335
|
+
.hidden { display: none !important; }
|
|
336
|
+
.inbox-message-anchor { margin: 4px 0; }
|
|
337
|
+
.inbox-message-anchor blockquote {
|
|
338
|
+
margin: 4px 0 0; padding: 4px 8px; border-left: 3px solid #a1a1aa;
|
|
339
|
+
font-size: 12px; color: #3f3f46; white-space: pre-wrap;
|
|
340
|
+
max-height: 80px; overflow: auto;
|
|
341
|
+
}
|
|
342
|
+
.dark .inbox-message-anchor blockquote { color: #d4d4d8; border-left-color: #52525b; }
|
|
343
|
+
`;
|
|
344
|
+
document.head.appendChild(style);
|
|
345
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en" class="h-full">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
6
|
+
<title>Canvas</title>
|
|
7
|
+
<script src="/lib/tailwind.js"></script>
|
|
8
|
+
<link rel="stylesheet" href="/lib/hljs-github-dark.min.css">
|
|
9
|
+
<link rel="stylesheet" href="/workspace/theme.css">
|
|
10
|
+
<link rel="stylesheet" href="/workspace/styles.css">
|
|
11
|
+
<script>
|
|
12
|
+
// Tailwind config — load synchronously so utility classes resolve on first paint.
|
|
13
|
+
tailwind.config = {
|
|
14
|
+
darkMode: 'class',
|
|
15
|
+
theme: {
|
|
16
|
+
extend: {
|
|
17
|
+
fontFamily: {
|
|
18
|
+
sans: ['Inter', 'system-ui', 'sans-serif'],
|
|
19
|
+
serif: ['ui-serif', 'Georgia', 'serif'],
|
|
20
|
+
mono: ['ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
</script>
|
|
26
|
+
<script>
|
|
27
|
+
// Apply the theme before first paint to avoid a flash.
|
|
28
|
+
(function () {
|
|
29
|
+
try {
|
|
30
|
+
var stored = localStorage.getItem('canvas.theme');
|
|
31
|
+
var dark = stored === 'dark' ? true
|
|
32
|
+
: stored === 'light' ? false
|
|
33
|
+
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
34
|
+
if (dark) document.documentElement.classList.add('dark');
|
|
35
|
+
} catch (e) {}
|
|
36
|
+
})();
|
|
37
|
+
</script>
|
|
38
|
+
</head>
|
|
39
|
+
<body class="h-full antialiased">
|
|
40
|
+
|
|
41
|
+
<div class="flex h-full">
|
|
42
|
+
<aside id="sidebar" class="sidebar" data-collapsed="false">
|
|
43
|
+
<button id="sidebar-spine" class="sidebar-spine" type="button" title="Expand sidebar">
|
|
44
|
+
<span class="sidebar-spine-icon" aria-hidden="true">»</span>
|
|
45
|
+
<span class="sidebar-spine-label">Canvas</span>
|
|
46
|
+
</button>
|
|
47
|
+
<div class="sidebar-body">
|
|
48
|
+
<div class="sidebar-header">
|
|
49
|
+
<div class="min-w-0">
|
|
50
|
+
<div class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400">Canvas</div>
|
|
51
|
+
<div id="connection" class="mt-1 text-xs text-slate-400 dark:text-slate-500">connecting…</div>
|
|
52
|
+
</div>
|
|
53
|
+
<button id="sidebar-collapse" class="inbox-rail-iconbtn" type="button" title="Collapse sidebar">
|
|
54
|
+
<span aria-hidden="true">‹</span>
|
|
55
|
+
</button>
|
|
56
|
+
</div>
|
|
57
|
+
<nav id="session-nav" class="p-2 space-y-1"></nav>
|
|
58
|
+
</div>
|
|
59
|
+
</aside>
|
|
60
|
+
|
|
61
|
+
<main class="flex-1 overflow-y-auto">
|
|
62
|
+
<header id="session-header" class="border-b border-slate-200 bg-white px-8 py-5 sticky top-0 z-10 flex items-start gap-4 dark:border-slate-700 dark:bg-slate-800">
|
|
63
|
+
<div class="flex-1 min-w-0">
|
|
64
|
+
<div class="text-xs uppercase tracking-wider text-slate-500 dark:text-slate-400" id="session-purpose">No session</div>
|
|
65
|
+
<h1 class="text-2xl font-semibold tracking-tight" id="session-title">Canvas</h1>
|
|
66
|
+
</div>
|
|
67
|
+
<div id="zoom-control" class="zoom-control" role="group" aria-label="Content zoom">
|
|
68
|
+
<button id="zoom-out" class="zoom-btn" type="button" title="Zoom out (Cmd/Ctrl+-)" aria-label="Zoom out">−</button>
|
|
69
|
+
<button id="zoom-reset" class="zoom-level" type="button" title="Reset zoom (Cmd/Ctrl+0)" aria-label="Reset zoom">100%</button>
|
|
70
|
+
<button id="zoom-in" class="zoom-btn" type="button" title="Zoom in (Cmd/Ctrl+=)" aria-label="Zoom in">+</button>
|
|
71
|
+
</div>
|
|
72
|
+
<button id="theme-toggle" class="theme-toggle" type="button" aria-pressed="false" title="Toggle dark mode" aria-label="Toggle dark mode">
|
|
73
|
+
<span class="theme-toggle-icon" aria-hidden="true">🌙</span>
|
|
74
|
+
</button>
|
|
75
|
+
<button id="inbox-toggle-topbar" class="inbox-toggle-topbar hidden" type="button" aria-expanded="false" aria-controls="inbox-rail" title="Messages (Cmd/Ctrl+/)">
|
|
76
|
+
<span aria-hidden="true">💬</span>
|
|
77
|
+
<span>Messages</span>
|
|
78
|
+
<span id="inbox-topbar-badge" class="inbox-badge hidden">0</span>
|
|
79
|
+
</button>
|
|
80
|
+
</header>
|
|
81
|
+
|
|
82
|
+
<div id="sections" class="px-8 py-6 space-y-8 max-w-4xl mx-auto"></div>
|
|
83
|
+
|
|
84
|
+
<div id="empty" class="px-8 py-16 max-w-2xl mx-auto text-center text-slate-500 dark:text-slate-400 hidden">
|
|
85
|
+
<p class="text-lg">No sessions yet.</p>
|
|
86
|
+
<p class="mt-2 text-sm">Ask your coding agent to write a substantial answer here. It'll create a session under <code class="px-1 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">.canvas/sessions/</code>.</p>
|
|
87
|
+
</div>
|
|
88
|
+
</main>
|
|
89
|
+
|
|
90
|
+
<aside id="inbox-rail" class="inbox-rail hidden" data-collapsed="false" data-open="false" aria-label="Inbox">
|
|
91
|
+
<button id="inbox-rail-spine" class="inbox-rail-spine" type="button" title="Expand messages">
|
|
92
|
+
<span class="inbox-spine-icon" aria-hidden="true">💬</span>
|
|
93
|
+
<span id="inbox-spine-badge" class="inbox-spine-badge hidden">0</span>
|
|
94
|
+
<span class="inbox-spine-label">Messages</span>
|
|
95
|
+
</button>
|
|
96
|
+
<div class="inbox-rail-body">
|
|
97
|
+
<header class="inbox-rail-header">
|
|
98
|
+
<span class="inbox-rail-title">Messages</span>
|
|
99
|
+
<span id="inbox-badge" class="inbox-badge hidden">0</span>
|
|
100
|
+
<span class="inbox-rail-spacer"></span>
|
|
101
|
+
<button id="inbox-collapse" class="inbox-rail-iconbtn" type="button" title="Collapse">
|
|
102
|
+
<span aria-hidden="true">›</span>
|
|
103
|
+
</button>
|
|
104
|
+
<button id="inbox-drawer-close" class="inbox-rail-iconbtn inbox-rail-iconbtn-drawer" type="button" title="Close">
|
|
105
|
+
<span aria-hidden="true">×</span>
|
|
106
|
+
</button>
|
|
107
|
+
</header>
|
|
108
|
+
<div id="inbox-thread" class="inbox-thread" role="log" aria-live="polite"></div>
|
|
109
|
+
<p id="inbox-empty" class="inbox-empty hidden">No messages yet. Leave a note for the agent.</p>
|
|
110
|
+
<div class="inbox-compose">
|
|
111
|
+
<textarea id="inbox-text" class="inbox-textarea" rows="2" placeholder="Leave a note for the agent…"></textarea>
|
|
112
|
+
<div class="inbox-compose-row">
|
|
113
|
+
<span id="inbox-error" class="inbox-error hidden" role="alert"></span>
|
|
114
|
+
<span id="inbox-counter" class="inbox-counter">0 / 4096</span>
|
|
115
|
+
<button id="inbox-send" class="inbox-send" type="button">Send</button>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
</aside>
|
|
120
|
+
<div id="inbox-backdrop" class="inbox-backdrop hidden" aria-hidden="true"></div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<script src="/lib/highlight.min.js"></script>
|
|
124
|
+
<script src="/lib/mermaid.min.js"></script>
|
|
125
|
+
<script src="/lib/turndown.min.js"></script>
|
|
126
|
+
<script src="/lib/turndown-plugin-gfm.min.js"></script>
|
|
127
|
+
<script>
|
|
128
|
+
// Mermaid 10 UMD attaches to window.mermaid directly.
|
|
129
|
+
window.mermaid.initialize({ startOnLoad: false, theme: 'neutral' });
|
|
130
|
+
window.__libs_ready = true;
|
|
131
|
+
window.dispatchEvent(new Event('libs-ready'));
|
|
132
|
+
</script>
|
|
133
|
+
<script type="module" src="/workspace/app.js"></script>
|
|
134
|
+
|
|
135
|
+
</body>
|
|
136
|
+
</html>
|