@menukfernandoo/canvas-flow 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.
@@ -0,0 +1,795 @@
1
+ /* global EventSource, document, location, window */
2
+
3
+ const sessionDataElement = document.getElementById("canvasflow-session");
4
+ const sessionData = JSON.parse(sessionDataElement?.textContent || "{}");
5
+ const key = String(sessionData.key || "");
6
+ const filePath = String(sessionData.file || "");
7
+ const queueStorageKey = "canvas-flow:queued:" + key;
8
+ const internalQueueKeyField = "_canvasflowQueueKey";
9
+ const initialChat = Array.isArray(sessionData.initialChat) ? sessionData.initialChat : [];
10
+ const MODE_TOGGLE_HOTKEY_KEY = String(sessionData.modeToggleHotkeyKey || "").toLowerCase();
11
+
12
+ function isModeToggleHotkeyEvent(event) {
13
+ if (event.shiftKey || event.altKey) return false;
14
+ return Boolean(event.metaKey || event.ctrlKey) && String(event.key || "").toLowerCase() === MODE_TOGGLE_HOTKEY_KEY;
15
+ }
16
+
17
+ const frame = /** @type {HTMLIFrameElement} */ (document.getElementById("artifact"));
18
+ const annotationPills = /** @type {HTMLDivElement} */ (document.getElementById("annotationPills"));
19
+ const chatLog = /** @type {HTMLDivElement} */ (document.getElementById("chatLog"));
20
+ const chatInput = /** @type {HTMLTextAreaElement} */ (document.getElementById("chatInput"));
21
+ const sendButton = /** @type {HTMLButtonElement} */ (document.getElementById("send"));
22
+ const sendCaret = /** @type {HTMLButtonElement} */ (document.getElementById("sendCaret"));
23
+ const sendActions = /** @type {HTMLDivElement} */ (document.getElementById("sendActions"));
24
+ const sendMenu = /** @type {HTMLDivElement} */ (document.getElementById("sendMenu"));
25
+ const sendFromMenuButton = /** @type {HTMLButtonElement} */ (document.getElementById("sendFromMenu"));
26
+ const sendAndEndButton = /** @type {HTMLButtonElement} */ (document.getElementById("sendAndEnd"));
27
+ const annotationSwitch = /** @type {HTMLButtonElement} */ (document.getElementById("annotation"));
28
+ const moreWrap = /** @type {HTMLDivElement} */ (document.getElementById("moreWrap"));
29
+ const moreButton = /** @type {HTMLButtonElement} */ (document.getElementById("moreButton"));
30
+ const moreMenu = /** @type {HTMLDivElement} */ (document.getElementById("moreMenu"));
31
+ const reloadArtifactButton = /** @type {HTMLButtonElement} */ (document.getElementById("reloadArtifact"));
32
+ const copySnapshotButton = /** @type {HTMLButtonElement} */ (document.getElementById("copySnapshot"));
33
+ const exportArtifactButton = /** @type {HTMLButtonElement} */ (document.getElementById("exportArtifact"));
34
+ const shareArtifactButton = /** @type {HTMLButtonElement} */ (document.getElementById("shareArtifact"));
35
+ const shareDialog = /** @type {HTMLDivElement} */ (document.getElementById("shareDialog"));
36
+ const shareForm = /** @type {HTMLFormElement} */ (document.getElementById("shareForm"));
37
+ const shareCloseButton = /** @type {HTMLButtonElement} */ (document.getElementById("shareClose"));
38
+ const shareCancelButton = /** @type {HTMLButtonElement} */ (document.getElementById("shareCancel"));
39
+ const sharePublishButton = /** @type {HTMLButtonElement} */ (document.getElementById("sharePublish"));
40
+ const sharePasswordInput = /** @type {HTMLInputElement} */ (document.getElementById("sharePassword"));
41
+ const shareStatus = /** @type {HTMLDivElement} */ (document.getElementById("shareStatus"));
42
+ const shareResult = /** @type {HTMLDivElement} */ (document.getElementById("shareResult"));
43
+ const shareUrlInput = /** @type {HTMLInputElement} */ (document.getElementById("shareUrl"));
44
+ const shareUpdateKeyInput = /** @type {HTMLInputElement} */ (document.getElementById("shareUpdateKey"));
45
+ const copyShareUrlButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyShareUrl"));
46
+ const copyUpdateKeyButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyUpdateKey"));
47
+ const endButton = /** @type {HTMLButtonElement} */ (document.getElementById("end"));
48
+ const copyPathButton = /** @type {HTMLButtonElement} */ (document.getElementById("copyPath"));
49
+ const copyHint = /** @type {HTMLSpanElement} */ (document.getElementById("copyHint"));
50
+ const copyHintText = /** @type {HTMLSpanElement} */ (document.getElementById("copyHintText"));
51
+ const presenceBanner = /** @type {HTMLDivElement} */ (document.getElementById("presenceBanner"));
52
+ const endedOverlay = /** @type {HTMLDivElement} */ (document.getElementById("endedOverlay"));
53
+ const layoutGateOverlay = /** @type {HTMLDivElement} */ (document.getElementById("layoutGateOverlay"));
54
+ const layoutGateTitle = /** @type {HTMLDivElement} */ (document.getElementById("layoutGateTitle"));
55
+ const layoutGateCopy = /** @type {HTMLParagraphElement} */ (document.getElementById("layoutGateCopy"));
56
+ const layoutGateAction = /** @type {HTMLButtonElement} */ (document.getElementById("layoutGateAction"));
57
+ const layoutIssueBanner = /** @type {HTMLDivElement} */ (document.getElementById("layoutIssueBanner"));
58
+ const sendHint = /** @type {HTMLSpanElement} */ (document.getElementById("sendHint"));
59
+ const artifactSrc = frame.dataset.artifactSrc || frame.getAttribute?.("data-artifact-src") || frame.src || "";
60
+
61
+ const queued = loadQueuedPrompts();
62
+ let annotation = true;
63
+ let ended = false;
64
+ let agentPresence = "waiting";
65
+ let pendingSnapshot = "";
66
+ const layoutGateEnabled = sessionData.layoutGateEnabled !== false;
67
+ const configuredLayoutGateMaxHoldMs = Number(sessionData.layoutGateMaxHoldMs);
68
+ const layoutGateMaxHoldMs =
69
+ Number.isFinite(configuredLayoutGateMaxHoldMs) && configuredLayoutGateMaxHoldMs > 0
70
+ ? Math.min(configuredLayoutGateMaxHoldMs, 60_000)
71
+ : 12_000;
72
+ let layoutGateVisible = false;
73
+ let layoutGateArmed = false;
74
+ let layoutGateManuallyBypassed = !layoutGateEnabled;
75
+ let layoutGateCycle = 0;
76
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
77
+ let layoutGateTimer;
78
+ const snapshotRequests = [];
79
+ let endAfterSubmit = false;
80
+ let workingBubble = null;
81
+ let submitQueuedPromise = null;
82
+ let submitQueuedAgain = false;
83
+ let lastScroll = { x: 0, y: 0 };
84
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
85
+ let copyHintTimer;
86
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
87
+ let sendHintTimer;
88
+
89
+ function escapeHtml(value) {
90
+ return String(value).replace(
91
+ /[&<>"']/g,
92
+ (char) =>
93
+ ({
94
+ "&": "&amp;",
95
+ "<": "&lt;",
96
+ ">": "&gt;",
97
+ '"': "&quot;",
98
+ "'": "&#39;",
99
+ })[char],
100
+ );
101
+ }
102
+
103
+ function loadQueuedPrompts() {
104
+ try {
105
+ const parsed = JSON.parse(sessionStorage.getItem(queueStorageKey) || "[]");
106
+ return Array.isArray(parsed) ? parsed.filter((prompt) => prompt && typeof prompt === "object") : [];
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ function persistQueuedPrompts() {
113
+ try {
114
+ if (queued.length) {
115
+ sessionStorage.setItem(queueStorageKey, JSON.stringify(queued));
116
+ } else {
117
+ sessionStorage.removeItem(queueStorageKey);
118
+ }
119
+ } catch {
120
+ // The in-memory queue still works if browser storage is unavailable.
121
+ }
122
+ }
123
+
124
+ function render() {
125
+ annotationPills.innerHTML = queued
126
+ .map(
127
+ (prompt, index) =>
128
+ '<div class="pill-wrap"><div class="pill"><span class="pill-preview">' +
129
+ escapeHtml(prompt.prompt) +
130
+ '</span><button class="pill-close" type="button" aria-label="Remove queued prompt" data-index="' +
131
+ index +
132
+ '"><svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false"><path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></button></div><div class="pill-tooltip">' +
133
+ (prompt.selector
134
+ ? '<div class="tooltip-label">Target</div><div class="pill-tooltip-target">' +
135
+ escapeHtml(prompt.selector) +
136
+ "</div>"
137
+ : "") +
138
+ '<div class="tooltip-label">Prompt</div><div class="pill-tooltip-prompt">' +
139
+ escapeHtml(prompt.prompt) +
140
+ "</div></div></div>",
141
+ )
142
+ .join("");
143
+
144
+ for (const button of annotationPills.querySelectorAll(".pill-close")) {
145
+ const closeButton = /** @type {HTMLButtonElement} */ (button);
146
+ closeButton.addEventListener("click", (event) => removeQueuedPrompt(Number(closeButton.dataset.index), event));
147
+ }
148
+ updateSendState();
149
+ }
150
+
151
+ function updateSendState() {
152
+ sendButton.disabled = ended || agentPresence === "working";
153
+ sendCaret.disabled = ended || agentPresence === "working";
154
+ sendFromMenuButton.disabled = sendButton.disabled;
155
+ }
156
+
157
+ function showSendHint() {
158
+ sendHint.hidden = false;
159
+ clearTimeout(sendHintTimer);
160
+ sendHintTimer = setTimeout(() => {
161
+ sendHint.hidden = true;
162
+ }, 2600);
163
+ chatInput.focus();
164
+ }
165
+
166
+ function hideSendHint() {
167
+ clearTimeout(sendHintTimer);
168
+ sendHint.hidden = true;
169
+ }
170
+
171
+ function setMenuOpen(button, menu, open) {
172
+ menu.hidden = !open;
173
+ button.setAttribute("aria-expanded", String(open));
174
+ }
175
+
176
+ function closeMenus() {
177
+ setMenuOpen(moreButton, moreMenu, false);
178
+ setMenuOpen(sendCaret, sendMenu, false);
179
+ }
180
+
181
+ function toggleMenu(button, menu) {
182
+ const open = menu.hidden;
183
+ closeMenus();
184
+ setMenuOpen(button, menu, open);
185
+ }
186
+
187
+ async function copyText(text) {
188
+ try {
189
+ if (navigator.clipboard && navigator.clipboard.writeText) {
190
+ await navigator.clipboard.writeText(text);
191
+ return true;
192
+ }
193
+ } catch {
194
+ // Fall through to the textarea-based fallback below.
195
+ }
196
+ const helper = document.createElement("textarea");
197
+ helper.value = text;
198
+ helper.style.position = "fixed";
199
+ helper.style.opacity = "0";
200
+ document.body.appendChild(helper);
201
+ helper.select();
202
+ document.execCommand("copy");
203
+ helper.remove();
204
+ return true;
205
+ }
206
+
207
+ function addChat(role, text) {
208
+ if (!text) return;
209
+
210
+ const el = document.createElement("div");
211
+ el.className = "bubble " + role;
212
+ el.innerHTML = "<small>" + (role === "agent" ? "Agent" : "You") + "</small><div>" + escapeHtml(text) + "</div>";
213
+ chatLog.appendChild(el);
214
+ chatLog.scrollTop = chatLog.scrollHeight;
215
+ }
216
+
217
+ function syncChat(chat) {
218
+ for (const el of [...chatLog.querySelectorAll(".bubble.user,.bubble.agent:not(.agent-working)")]) {
219
+ el.remove();
220
+ }
221
+
222
+ for (const item of chat) addChat(item.role, item.text);
223
+ if (workingBubble) chatLog.appendChild(workingBubble);
224
+ chatLog.scrollTop = chatLog.scrollHeight;
225
+ }
226
+
227
+ function setAgentPresence(state) {
228
+ agentPresence = state === "listening" || state === "working" ? state : "waiting";
229
+ updateSendState();
230
+ if (presenceBanner) presenceBanner.hidden = ended || agentPresence !== "waiting";
231
+
232
+ if (agentPresence !== "working") {
233
+ if (workingBubble) workingBubble.remove();
234
+ workingBubble = null;
235
+ return;
236
+ }
237
+
238
+ if (!workingBubble) {
239
+ workingBubble = document.createElement("div");
240
+ workingBubble.className = "bubble agent agent-working";
241
+ workingBubble.innerHTML = '<span class="spinner"></span><span>Working...</span>';
242
+ chatLog.appendChild(workingBubble);
243
+ }
244
+ chatLog.scrollTop = chatLog.scrollHeight;
245
+ }
246
+
247
+ function removeQueuedPrompt(index, event) {
248
+ if (event) event.stopPropagation();
249
+ queued.splice(index, 1);
250
+ persistQueuedPrompts();
251
+ render();
252
+ }
253
+
254
+ function promptQueueKey(prompt) {
255
+ return prompt && typeof prompt[internalQueueKeyField] === "string" ? prompt[internalQueueKeyField].trim() : "";
256
+ }
257
+
258
+ function enqueuePrompt(prompt) {
259
+ if (!prompt || typeof prompt !== "object") return;
260
+
261
+ const queueKey = promptQueueKey(prompt);
262
+ if (queueKey) {
263
+ const index = queued.findIndex((item) => promptQueueKey(item) === queueKey);
264
+ if (index !== -1) {
265
+ queued[index] = prompt;
266
+ } else {
267
+ queued.push(prompt);
268
+ }
269
+ } else {
270
+ queued.push(prompt);
271
+ }
272
+
273
+ persistQueuedPrompts();
274
+ render();
275
+ }
276
+
277
+ function stripInternalPromptFields(prompt) {
278
+ if (!prompt || typeof prompt !== "object") return prompt;
279
+ const clean = { ...prompt };
280
+ delete clean[internalQueueKeyField];
281
+ return clean;
282
+ }
283
+
284
+ function postToFrame(message) {
285
+ if (frame.contentWindow) frame.contentWindow.postMessage(message, "*");
286
+ }
287
+
288
+ function requestSnapshot(action) {
289
+ snapshotRequests.push(action);
290
+ postToFrame({ type: "canvasflow:requestSnapshot" });
291
+ }
292
+
293
+ function sendQueued(endAfter) {
294
+ if (ended || agentPresence === "working") return;
295
+ closeMenus();
296
+
297
+ const text = chatInput.value.trim();
298
+ if (text) {
299
+ queued.push({ uid: "", prompt: text, selector: "", tag: "message", text: "Freeform message" });
300
+ persistQueuedPrompts();
301
+ addChat("user", text);
302
+ chatInput.value = "";
303
+ render();
304
+ }
305
+ if (!queued.length) {
306
+ if (endAfter) {
307
+ endSession();
308
+ } else {
309
+ showSendHint();
310
+ }
311
+ return;
312
+ }
313
+ hideSendHint();
314
+
315
+ if (endAfter) endAfterSubmit = true;
316
+ requestSnapshot("submit");
317
+ }
318
+
319
+ async function submitQueued() {
320
+ if (submitQueuedPromise) {
321
+ submitQueuedAgain = true;
322
+ return submitQueuedPromise;
323
+ }
324
+
325
+ let succeeded = false;
326
+ submitQueuedPromise = submitQueuedOnce();
327
+ try {
328
+ const result = await submitQueuedPromise;
329
+ succeeded = true;
330
+ return result;
331
+ } finally {
332
+ submitQueuedPromise = null;
333
+ const shouldSubmitAgain = submitQueuedAgain;
334
+ submitQueuedAgain = false;
335
+ if (!succeeded) {
336
+ endAfterSubmit = false;
337
+ } else if (!ended && shouldSubmitAgain) {
338
+ if (queued.length) {
339
+ submitQueued();
340
+ } else if (endAfterSubmit) {
341
+ endAfterSubmit = false;
342
+ endSession();
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ async function submitQueuedOnce() {
349
+ const prompts = queued.slice();
350
+ const shouldEndSession = endAfterSubmit;
351
+ const body = { prompts: prompts.map(stripInternalPromptFields), domSnapshot: pendingSnapshot };
352
+ if (shouldEndSession) body.endSession = true;
353
+ const response = await fetch("/api/" + key + "/prompts", {
354
+ method: "POST",
355
+ headers: { "content-type": "application/json" },
356
+ body: JSON.stringify(body),
357
+ });
358
+ if (!response.ok) throw new Error("failed to submit queued prompts");
359
+ for (const prompt of prompts) {
360
+ const index = queued.indexOf(prompt);
361
+ if (index !== -1) queued.splice(index, 1);
362
+ }
363
+ persistQueuedPrompts();
364
+ render();
365
+ if (shouldEndSession) {
366
+ endAfterSubmit = false;
367
+ markSessionEnded();
368
+ return;
369
+ }
370
+ if (agentPresence === "listening") setAgentPresence("working");
371
+ }
372
+
373
+ function normalizeLayoutWarningsPayload(value) {
374
+ return Array.isArray(value) ? value.filter((item) => item && typeof item === "object") : [];
375
+ }
376
+
377
+ function isErrorLayoutWarning(warning) {
378
+ return String(warning?.severity || "").toLowerCase() === "error";
379
+ }
380
+
381
+ function setLayoutIssueBanner(visible, text = "This surface may have layout issues. Your agent has been notified.") {
382
+ if (!layoutIssueBanner) return;
383
+ layoutIssueBanner.textContent = text;
384
+ layoutIssueBanner.hidden = !visible;
385
+ }
386
+
387
+ function clearLayoutGateTimer() {
388
+ if (layoutGateTimer) clearTimeout(layoutGateTimer);
389
+ layoutGateTimer = undefined;
390
+ }
391
+
392
+ function setLayoutGateCard(state) {
393
+ if (!layoutGateTitle || !layoutGateCopy) return;
394
+
395
+ if (state === "held") {
396
+ layoutGateTitle.innerHTML = "Fixing a layout issue...";
397
+ layoutGateCopy.textContent =
398
+ "The real browser found overflow or overlapping content. Your agent has been notified and this will reveal after the next clean reload.";
399
+ return;
400
+ }
401
+
402
+ layoutGateTitle.innerHTML = "Checking layout.<br>One moment.";
403
+ layoutGateCopy.textContent = "CanvasFlow is waiting for fonts and final geometry before revealing this artifact.";
404
+ }
405
+
406
+ function setLayoutGateActive(active) {
407
+ layoutGateVisible = active;
408
+ if (layoutGateOverlay) layoutGateOverlay.hidden = !active;
409
+ document.body?.classList?.toggle("layout-gate-active", active);
410
+ }
411
+
412
+ function revealLayoutGate({ showBanner = false, bannerText = undefined } = {}) {
413
+ clearLayoutGateTimer();
414
+ layoutGateArmed = false;
415
+ setLayoutGateActive(false);
416
+ setLayoutIssueBanner(showBanner, bannerText);
417
+ }
418
+
419
+ function forceRevealLayoutGate(reason) {
420
+ if (!layoutGateEnabled || ended) return;
421
+ if (reason === "manual") layoutGateManuallyBypassed = true;
422
+ const bannerText =
423
+ reason === "timeout"
424
+ ? "This surface may have layout issues. CanvasFlow revealed it after the safety timeout so review is never blocked."
425
+ : "This surface may have layout issues. You chose to show it before the layout check passed.";
426
+ revealLayoutGate({ showBanner: true, bannerText });
427
+ }
428
+
429
+ function startLayoutGateCycle() {
430
+ if (!layoutGateEnabled || layoutGateManuallyBypassed || ended) return;
431
+
432
+ layoutGateCycle += 1;
433
+ layoutGateArmed = true;
434
+ setLayoutIssueBanner(false);
435
+ setLayoutGateCard("checking");
436
+ setLayoutGateActive(true);
437
+ clearLayoutGateTimer();
438
+
439
+ const cycle = layoutGateCycle;
440
+ layoutGateTimer = setTimeout(() => {
441
+ if (cycle !== layoutGateCycle || !layoutGateVisible || ended) return;
442
+ forceRevealLayoutGate("timeout");
443
+ }, layoutGateMaxHoldMs);
444
+ layoutGateTimer?.unref?.();
445
+ }
446
+
447
+ function handleLayoutWarningsForGate(layoutWarnings) {
448
+ const warnings = normalizeLayoutWarningsPayload(layoutWarnings);
449
+ const hasErrors = warnings.some(isErrorLayoutWarning);
450
+
451
+ if (!layoutGateEnabled) return;
452
+
453
+ if (layoutGateManuallyBypassed) {
454
+ setLayoutIssueBanner(hasErrors);
455
+ return;
456
+ }
457
+
458
+ if (!layoutGateArmed && !layoutGateVisible) return;
459
+
460
+ if (!hasErrors) {
461
+ revealLayoutGate();
462
+ return;
463
+ }
464
+
465
+ setLayoutGateCard("held");
466
+ setLayoutGateActive(true);
467
+ }
468
+
469
+ function initializeLayoutGate() {
470
+ if (!layoutGateEnabled) {
471
+ setLayoutGateActive(false);
472
+ setLayoutIssueBanner(false);
473
+ return;
474
+ }
475
+
476
+ if (layoutGateAction) layoutGateAction.onclick = () => forceRevealLayoutGate("manual");
477
+ startLayoutGateCycle();
478
+ }
479
+
480
+ async function submitLayoutWarnings(layoutWarnings) {
481
+ const response = await fetch("/api/" + key + "/layout-warnings", {
482
+ method: "POST",
483
+ headers: { "content-type": "application/json" },
484
+ body: JSON.stringify({ layout_warnings: normalizeLayoutWarningsPayload(layoutWarnings) }),
485
+ });
486
+ if (!response.ok) throw new Error("failed to submit layout warnings");
487
+ }
488
+
489
+ async function endSession() {
490
+ if (ended) return;
491
+ const response = await fetch("/api/" + key + "/end", { method: "POST" });
492
+ if (!response.ok) throw new Error("failed to end session");
493
+ markSessionEnded();
494
+ }
495
+
496
+ function markSessionEnded() {
497
+ if (ended) return;
498
+ ended = true;
499
+ closeMenus();
500
+ annotationSwitch.disabled = true;
501
+ moreButton.disabled = true;
502
+ chatInput.disabled = true;
503
+ updateSendState();
504
+ if (presenceBanner) presenceBanner.hidden = true;
505
+ layoutGateManuallyBypassed = true;
506
+ revealLayoutGate();
507
+ postToFrame({ type: "canvasflow:setAnnotationMode", enabled: false });
508
+ endedOverlay.hidden = false;
509
+ }
510
+
511
+ function copyFilePath() {
512
+ copyText(filePath);
513
+ copyHint.classList.add("copied");
514
+ copyHintText.textContent = "Copied";
515
+ clearTimeout(copyHintTimer);
516
+ copyHintTimer = setTimeout(() => {
517
+ copyHint.classList.remove("copied");
518
+ copyHintText.textContent = "Copy";
519
+ }, 1600);
520
+ }
521
+
522
+ function copyDomSnapshot() {
523
+ closeMenus();
524
+ requestSnapshot("copy");
525
+ }
526
+
527
+ function exportFileName() {
528
+ const base = (filePath.split(/[\\/]/).pop() || "artifact.html").replace(/\.html?$/i, "");
529
+ return (base || "artifact") + ".export.html";
530
+ }
531
+
532
+ function setExportLabel(text) {
533
+ const label = exportArtifactButton.querySelector("span");
534
+ if (label) label.textContent = text;
535
+ }
536
+
537
+ function unresolvedAssetText(count) {
538
+ return count === 1 ? "1 unresolved asset" : `${count} unresolved assets`;
539
+ }
540
+
541
+ function noticeText(count) {
542
+ return count === 1 ? "1 notice" : `${count} notices`;
543
+ }
544
+
545
+ function exportWarningText(unresolvedCount, noticeCount) {
546
+ if (unresolvedCount > 0 && noticeCount > 0) {
547
+ return `${unresolvedAssetText(unresolvedCount)} and ${noticeText(noticeCount)}`;
548
+ }
549
+ if (unresolvedCount > 0) return unresolvedAssetText(unresolvedCount);
550
+ return noticeText(noticeCount);
551
+ }
552
+
553
+ async function exportArtifact() {
554
+ // The bundle inlines local assets server-side, so it can take a moment - keep the menu open
555
+ // and narrate progress in place instead of closing it and leaving the user with no feedback.
556
+ exportArtifactButton.disabled = true;
557
+ setExportLabel("Exporting...");
558
+ try {
559
+ const response = await fetch("/api/" + key + "/export");
560
+ if (!response.ok) throw new Error("export failed");
561
+ const warningCount = Number(response.headers.get("x-canvasflow-export-warning-count") || "0");
562
+ const noticeCount = Number(response.headers.get("x-canvasflow-export-notice-count") || "0");
563
+ const blob = await response.blob();
564
+ const url = URL.createObjectURL(blob);
565
+ const link = document.createElement("a");
566
+ link.href = url;
567
+ link.download = exportFileName();
568
+ document.body.appendChild(link);
569
+ link.click();
570
+ link.remove();
571
+ setTimeout(() => URL.revokeObjectURL(url), 2000);
572
+ if (warningCount > 0 || noticeCount > 0) {
573
+ setExportLabel(`Exported with ${exportWarningText(warningCount, noticeCount)}`);
574
+ } else {
575
+ setExportLabel("Export standalone HTML");
576
+ closeMenus();
577
+ }
578
+ } catch {
579
+ setExportLabel("Export failed - retry");
580
+ } finally {
581
+ exportArtifactButton.disabled = false;
582
+ }
583
+ }
584
+
585
+ function openShareDialog() {
586
+ closeMenus();
587
+ shareDialog.hidden = false;
588
+ shareStatus.textContent = "";
589
+ shareStatus.classList.remove("error");
590
+ shareResult.hidden = true;
591
+ sharePasswordInput.value = "";
592
+ sharePasswordInput.focus();
593
+ }
594
+
595
+ function closeShareDialog() {
596
+ shareDialog.hidden = true;
597
+ }
598
+
599
+ async function copyToButton(value, button, label) {
600
+ await copyText(value);
601
+ button.textContent = "Copied";
602
+ setTimeout(() => {
603
+ button.textContent = label;
604
+ }, 1200);
605
+ }
606
+
607
+ async function publishShare(event) {
608
+ event.preventDefault();
609
+ sharePublishButton.disabled = true;
610
+ shareStatus.classList.remove("error");
611
+ shareStatus.textContent = "Publishing to ht-ml.app...";
612
+ shareResult.hidden = true;
613
+ const password = sharePasswordInput.value.trim();
614
+ const passwordProtected = Boolean(password);
615
+ try {
616
+ const response = await fetch("/api/" + key + "/share", {
617
+ method: "POST",
618
+ headers: { "content-type": "application/json" },
619
+ body: JSON.stringify(password ? { password } : {}),
620
+ });
621
+ const data = await response.json();
622
+ if (!response.ok) throw new Error(data.error || "publish failed");
623
+ shareUrlInput.value = data.url || "";
624
+ shareUpdateKeyInput.value = data.update_key || "";
625
+ const unresolvedAssets = Array.isArray(data.unresolved_local_assets) ? data.unresolved_local_assets : [];
626
+ const notices = Array.isArray(data.notices) ? data.notices : [];
627
+ const warningCount = unresolvedAssets.length;
628
+ const noticeCount = notices.length;
629
+ const noticeSummary = noticeCount ? noticeText(noticeCount) : "";
630
+ shareStatus.textContent =
631
+ warningCount > 0
632
+ ? `Published with ${warningCount === 1 ? "1 unresolved local asset" : `${warningCount} unresolved local assets`}${noticeSummary ? ` and ${noticeSummary}` : ""}.${passwordProtected ? " This page is PASSWORD-PROTECTED; viewers also need the password." : ""}`
633
+ : noticeCount > 0
634
+ ? `Published with ${noticeSummary}.${passwordProtected ? " This page is PASSWORD-PROTECTED; viewers also need the password." : ""}`
635
+ : passwordProtected
636
+ ? "Published. This page is PASSWORD-PROTECTED; viewers also need the password."
637
+ : "Published. Anyone with the link can view this page.";
638
+ shareResult.hidden = false;
639
+ shareUrlInput.focus();
640
+ shareUrlInput.select();
641
+ } catch (error) {
642
+ shareStatus.classList.add("error");
643
+ shareStatus.textContent = error instanceof Error ? error.message : String(error);
644
+ } finally {
645
+ sharePublishButton.disabled = false;
646
+ }
647
+ }
648
+
649
+ function resetFrame() {
650
+ startLayoutGateCycle();
651
+ // The iframe is sandboxed, so reload by resetting the iframe URL from chrome.
652
+ frame.src = artifactSrc || frame.src;
653
+ }
654
+
655
+ function loadFrame() {
656
+ if (artifactSrc) frame.src = artifactSrc;
657
+ }
658
+
659
+ function reloadArtifact() {
660
+ closeMenus();
661
+ resetFrame();
662
+ }
663
+
664
+ async function reloadAfterServerRestart() {
665
+ let sawOutage = false;
666
+ const deadline = Date.now() + 5000;
667
+
668
+ while (Date.now() < deadline) {
669
+ try {
670
+ const res = await fetch("/health", { cache: "no-store" });
671
+ if (sawOutage && res.ok) {
672
+ location.reload();
673
+ return;
674
+ }
675
+ } catch {
676
+ sawOutage = true;
677
+ }
678
+
679
+ await new Promise((resolve) => setTimeout(resolve, 100));
680
+ }
681
+
682
+ location.reload();
683
+ }
684
+
685
+ window.addEventListener("message", (event) => {
686
+ if (event.source !== frame.contentWindow) return;
687
+
688
+ const msg = event.data || {};
689
+ if (msg.type === "canvasflow:queuePrompt") {
690
+ enqueuePrompt(msg.prompt);
691
+ }
692
+ if (msg.type === "canvasflow:snapshot") {
693
+ const snapshotAction = snapshotRequests.shift() || "submit";
694
+ if (snapshotAction === "copy") {
695
+ copyText(msg.snapshot || "");
696
+ } else {
697
+ pendingSnapshot = msg.snapshot || "";
698
+ submitQueued();
699
+ }
700
+ }
701
+ if (msg.type === "canvasflow:scroll") {
702
+ lastScroll = { x: Number(msg.x) || 0, y: Number(msg.y) || 0 };
703
+ }
704
+ if (msg.type === "canvasflow:layoutWarnings") {
705
+ handleLayoutWarningsForGate(msg.layout_warnings);
706
+ submitLayoutWarnings(msg.layout_warnings).catch(() => {});
707
+ }
708
+ if (msg.type === "canvasflow:sendQueuedPrompts") sendQueued();
709
+ if (msg.type === "canvasflow:endSession") endSession();
710
+ if (msg.type === "canvasflow:toggleAnnotationMode") toggleAnnotationMode();
711
+ });
712
+
713
+ loadFrame();
714
+
715
+ function toggleAnnotationMode() {
716
+ if (ended) return;
717
+ annotation = !annotation;
718
+ annotationSwitch.setAttribute("aria-pressed", String(annotation));
719
+ postToFrame({ type: "canvasflow:setAnnotationMode", enabled: annotation });
720
+ }
721
+
722
+ annotationSwitch.onclick = toggleAnnotationMode;
723
+
724
+ sendButton.onclick = () => sendQueued(false);
725
+ sendFromMenuButton.onclick = () => sendQueued(false);
726
+ sendAndEndButton.onclick = () => sendQueued(true);
727
+ sendCaret.onclick = () => toggleMenu(sendCaret, sendMenu);
728
+ moreButton.onclick = () => toggleMenu(moreButton, moreMenu);
729
+ chatInput.addEventListener("keydown", (event) => {
730
+ if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
731
+ event.preventDefault();
732
+ sendQueued(false);
733
+ }
734
+ });
735
+ chatInput.addEventListener("input", hideSendHint);
736
+ copyPathButton.onclick = copyFilePath;
737
+ reloadArtifactButton.onclick = reloadArtifact;
738
+ copySnapshotButton.onclick = copyDomSnapshot;
739
+ exportArtifactButton.onclick = exportArtifact;
740
+ shareArtifactButton.onclick = openShareDialog;
741
+ shareCloseButton.onclick = closeShareDialog;
742
+ shareCancelButton.onclick = closeShareDialog;
743
+ shareForm.addEventListener("submit", publishShare);
744
+ shareDialog.addEventListener("click", (event) => {
745
+ if (event.target === shareDialog) closeShareDialog();
746
+ });
747
+ copyShareUrlButton.onclick = () => copyToButton(shareUrlInput.value, copyShareUrlButton, "Copy URL");
748
+ copyUpdateKeyButton.onclick = () => copyToButton(shareUpdateKeyInput.value, copyUpdateKeyButton, "Copy key");
749
+ endButton.onclick = () => {
750
+ closeMenus();
751
+ endSession();
752
+ };
753
+ document.addEventListener("mousedown", (event) => {
754
+ const target = /** @type {Node} */ (event.target);
755
+ if (!moreMenu.hidden && !moreWrap.contains(target)) setMenuOpen(moreButton, moreMenu, false);
756
+ if (!sendMenu.hidden && !sendActions.contains(target)) setMenuOpen(sendCaret, sendMenu, false);
757
+ });
758
+ document.addEventListener("keydown", (event) => {
759
+ if (event.key === "Escape") {
760
+ if (!shareDialog.hidden) {
761
+ closeShareDialog();
762
+ } else {
763
+ closeMenus();
764
+ }
765
+ }
766
+ });
767
+ // Capture phase so the mode hotkey fires no matter where focus is in the chrome - including
768
+ // mid-keystroke in chatInput or an annotation-card textarea - without disturbing normal typing.
769
+ document.addEventListener(
770
+ "keydown",
771
+ (event) => {
772
+ if (!isModeToggleHotkeyEvent(event)) return;
773
+ event.preventDefault();
774
+ toggleAnnotationMode();
775
+ },
776
+ true,
777
+ );
778
+ frame.addEventListener("load", () => {
779
+ postToFrame({ type: "canvasflow:setAnnotationMode", enabled: annotation && !ended });
780
+ // Replay the pre-reload scroll position so hot reloads don't jump the artifact to the top.
781
+ postToFrame({ type: "canvasflow:restoreScroll", x: lastScroll.x, y: lastScroll.y });
782
+ });
783
+
784
+ initializeLayoutGate();
785
+
786
+ const events = new EventSource("/events/" + key);
787
+ events.addEventListener("reload", () => resetFrame());
788
+ events.addEventListener("chrome-reload", () => reloadAfterServerRestart());
789
+ events.addEventListener("agent-reply", (event) => addChat("agent", JSON.parse(event.data).text));
790
+ events.addEventListener("chat-sync", (event) => syncChat(JSON.parse(event.data).chat || []));
791
+ events.addEventListener("agent-presence", (event) => setAgentPresence(JSON.parse(event.data).state));
792
+
793
+ render();
794
+ initialChat.forEach((item) => addChat(item.role, item.text));
795
+ setAgentPresence("waiting");