svrnty-hermes-webui-plugin/static/canvas.js
2026-05-28 21:44:02 -04:00

639 lines
25 KiB
JavaScript

// svrnty-canvas: Hermes-native shell for the sovereign Stitch-like design loop.
(function () {
"use strict";
if (window.__svrntyCanvasLoaded) return;
window.__svrntyCanvasLoaded = true;
const state = {
connected: false,
project: null,
artboards: [],
log: [],
eventSource: null,
eventCursor: 0,
eventPoll: null,
prototypeSourceId: null,
exportSummary: null,
lastEditPersisted: null,
lastPrototypeEdge: null,
designContext: null,
};
window.SvrntyCanvas = {
state,
refresh: _refreshStatus,
generate: _generate,
request: _request,
};
function _init() {
window.addEventListener("svrnty:panel-switch", (ev) => {
const name = ev && ev.detail && ev.detail.name;
if (name === "canvas") _openOverlay();
else _closeOverlay();
});
document.addEventListener("svrnty:canvas-generate", (ev) => _request((ev && ev.detail) || {}));
_registerSlashCommand();
const main = document.querySelector("main.main");
if (main && main.classList.contains("svrnty-showing-canvas")) _openOverlay();
}
function _registerSlashCommand() {
const install = () => {
try {
if (typeof COMMANDS === "undefined" || !Array.isArray(COMMANDS)) return false;
if (COMMANDS.some((cmd) => cmd && cmd.name === "canvas")) return true;
COMMANDS.push({
name: "canvas",
desc: "Generate on the sovereign Canvas",
arg: "prompt",
fn: (args) => {
const prompt = String(args || "").trim();
if (!prompt) {
_log("Usage: /canvas <prompt>");
return true;
}
_request({ prompt, source: "slash-command" });
return true;
},
});
return true;
} catch (_) {
return false;
}
};
if (install()) return;
let attempts = 0;
const timer = setInterval(() => {
attempts += 1;
if (install() || attempts > 40) clearInterval(timer);
}, 250);
}
function _openOverlay() {
const mount = document.querySelector("main.main") || document.body;
let overlay = document.getElementById("svrntyCanvasOverlay");
if (!overlay) {
overlay = _buildOverlay();
mount.appendChild(overlay);
} else if (overlay.parentElement !== mount && mount.tagName === "MAIN") {
mount.appendChild(overlay);
}
overlay.hidden = false;
_refreshStatus();
_connectEvents();
_startEventPolling();
_render();
}
function _closeOverlay() {
const overlay = document.getElementById("svrntyCanvasOverlay");
if (overlay) overlay.hidden = true;
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
if (state.eventPoll) {
clearInterval(state.eventPoll);
state.eventPoll = null;
}
}
function _buildOverlay() {
const root = document.createElement("div");
root.id = "svrntyCanvasOverlay";
root.className = "svrnty-canvas-overlay";
root.innerHTML =
'<div class="svrnty-canvas-toolbar">' +
'<div class="svrnty-canvas-title">Sovereign Canvas</div>' +
'<div id="svrntyCanvasStatus" class="svrnty-canvas-status">Checking...</div>' +
'<select id="svrntyCanvasBrand" class="svrnty-canvas-brand">' +
'<option value="planb">Plan B</option><option value="svrnty">Svrnty</option>' +
'</select>' +
'<input id="svrntyCanvasPrompt" class="svrnty-canvas-prompt" placeholder="Describe the screen CMO should create">' +
'<input id="svrntyCanvasVariants" class="svrnty-canvas-stepper" type="number" min="1" max="6" value="3" title="Variants">' +
'<button id="svrntyCanvasGenerate" class="svrnty-canvas-btn primary" type="button">Generate</button>' +
'<button id="svrntyCanvasRefresh" class="svrnty-canvas-btn" type="button">Refresh</button>' +
'</div>' +
'<div class="svrnty-canvas-body">' +
'<div class="svrnty-canvas-stage"><div id="svrntyCanvasArtboards" class="svrnty-canvas-artboards"></div></div>' +
'<aside class="svrnty-canvas-rail"><h3>Live loop</h3><div id="svrntyCanvasTools"></div><div id="svrntyCanvasLog" class="svrnty-canvas-log"></div></aside>' +
'</div>';
root.querySelector("#svrntyCanvasGenerate").addEventListener("click", _generate);
root.querySelector("#svrntyCanvasRefresh").addEventListener("click", _refreshStatus);
root.querySelector("#svrntyCanvasPrompt").addEventListener("keydown", (ev) => {
if (ev.key === "Enter") _generate();
});
root.querySelector("#svrntyCanvasArtboards").addEventListener("click", _handleArtboardClick);
root.querySelector("#svrntyCanvasArtboards").addEventListener("input", _handleArtboardInput);
return root;
}
function _refreshStatus() {
const brand = document.getElementById("svrntyCanvasBrand");
const brandId = brand ? brand.value : "planb";
Promise.all([
fetch("/api/canvas/status").then((r) => r.json()),
fetch("/api/canvas/tools").then((r) => r.json()),
fetch("/api/canvas/design-context?brand_id=" + encodeURIComponent(brandId)).then((r) => r.json()),
]).then(([status, tools, context]) => {
state.connected = !!(status && status.ok);
state.designContext = context || null;
_setText("svrntyCanvasStatus", state.connected ? "canva-editor online" : "canva-editor offline");
_renderTools(tools.tools || []);
if (status && status.capabilities) _log("Capabilities: " + _capabilitySummary(status.capabilities));
if (context && context.status === "ready") {
_log("BTE context ready: " + (context.source_version || context.fetched_at || context.resolved_brand_id));
} else if (context && context.deferred_ports) {
_log("Context degraded: " + context.deferred_ports.join(", "));
}
_log(state.connected ? "Canvas bridge online" : "Canvas bridge waiting for canva-editor");
if (state.connected) _ensureProject().catch((e) => _log("Project load failed: " + _message(e)));
}).catch((e) => {
state.connected = false;
_setText("svrntyCanvasStatus", "canvas bridge error");
_log("Status failed: " + _message(e));
});
}
function _connectEvents() {
if (!window.EventSource || state.eventSource) return;
try {
state.eventSource = new EventSource("/api/canvas/events");
state.eventSource.addEventListener("canvas.connected", () => _log("Live event stream connected"));
state.eventSource.onerror = () => {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
};
} catch (_) {}
}
function _startEventPolling() {
if (state.eventPoll) return;
_pollEvents();
state.eventPoll = setInterval(_pollEvents, 1200);
}
function _pollEvents() {
fetch("/api/canvas/events?format=json&since=" + encodeURIComponent(state.eventCursor))
.then((r) => r.json())
.then((body) => {
const events = body && Array.isArray(body.events) ? body.events : [];
events.forEach(_applyEvent);
if (body && body.cursor != null) state.eventCursor = Math.max(state.eventCursor, body.cursor);
})
.catch(() => {});
}
function _applyEvent(ev) {
if (!ev || !ev.type) return;
if (ev.id != null) state.eventCursor = Math.max(state.eventCursor, ev.id);
if (ev.type === "canvas.command.accepted") _log("CMO command accepted: " + (ev.prompt || "Canvas command"));
else if (ev.type === "canvas.project.ready") _log("Project ready: " + ((ev.project && ev.project.name) || "Hermes Canvas"));
else if (ev.type === "canvas.variant.started") _log("Generating variant " + ev.variant_index + "/" + ev.variants);
else if (ev.type === "canvas.screen.persisted") {
_ingestScreenEvent(ev);
_log("Artboard persisted from command");
} else if (ev.type === "canvas.command.completed") _log("Canvas command completed");
else if (ev.type === "canvas.command.failed") _log("Canvas command failed: " + (ev.error || "unknown error"));
}
function _generate() {
const input = document.getElementById("svrntyCanvasPrompt");
const prompt = input ? input.value.trim() : "";
if (!prompt) {
_log("Prompt required before generation");
return;
}
const btn = document.getElementById("svrntyCanvasGenerate");
if (btn) btn.disabled = true;
_log("CMO request queued: " + prompt);
const variantsInput = document.getElementById("svrntyCanvasVariants");
const variants = Math.max(1, Math.min(6, parseInt((variantsInput && variantsInput.value) || "1", 10)));
const brand = document.getElementById("svrntyCanvasBrand");
const brandId = brand ? brand.value : "planb";
_runCommand(prompt, variants, brandId).finally(() => {
if (btn) btn.disabled = false;
});
}
function _request(payload) {
const prompt = String((payload && payload.prompt) || "").trim();
if (!prompt) {
_log("Canvas request ignored: prompt required");
return false;
}
try {
if (typeof window.switchPanel === "function") {
window.switchPanel("canvas", { fromRailClick: true });
} else {
_openOverlay();
}
} catch (_) {
_openOverlay();
}
setTimeout(() => {
const input = document.getElementById("svrntyCanvasPrompt");
const variantsInput = document.getElementById("svrntyCanvasVariants");
const brand = document.getElementById("svrntyCanvasBrand");
if (input) input.value = prompt;
if (variantsInput && payload.variants) variantsInput.value = String(payload.variants);
if (brand && (payload.brand_id || payload.brandId)) brand.value = payload.brand_id || payload.brandId;
_log("Canvas request from " + (payload.source || "Hermes") + ": " + prompt);
_generate();
}, 0);
return true;
}
async function _runCommand(prompt, variants, brandId) {
try {
const r = await fetch("/api/canvas/command", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt, variants, brand_id: brandId }),
});
const body = await r.json();
if (!r.ok) throw new Error(body && body.error ? body.error : "HTTP " + r.status);
if (body.project) state.project = body.project;
if (body.design_context) state.designContext = body.design_context;
(body.events || []).forEach(_applyEvent);
(body.screens || []).forEach((item) => _ingestScreenEvent({
screen: item.screen,
screenSpec: item.screenSpec,
prompt: item.prompt || prompt,
variant_index: item.variant_index,
}));
_render();
} catch (e) {
_log("Command failed: " + _message(e));
}
}
async function _generateVariants(prompt, variants) {
for (let i = 0; i < variants; i++) {
const variantPrompt = variants === 1 ? prompt : prompt + "\nVariant " + (i + 1) + " of " + variants + ": choose a distinct composition.";
try {
_log("Generating variant " + (i + 1) + "/" + variants);
const r = await fetch("/api/canvas/proxy?path=/api/v1/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: variantPrompt }),
});
const body = await r.json();
if (!r.ok) throw new Error(body && body.error ? JSON.stringify(body.error) : "HTTP " + r.status);
const spec = body.screenSpec || body.screen_spec || body;
const saved = await _saveScreen(prompt, spec).catch((e) => {
_log("Generated but not persisted: " + _message(e));
return null;
});
state.artboards.unshift(_artboardFromSpec(
saved,
spec,
prompt.slice(0, 42) || "Generated screen",
variants === 1 ? "generated" : "variant " + (i + 1),
));
_log("Artboard generated");
_render();
} catch (e) {
_log("Generation failed: " + _message(e));
}
}
}
async function _ensureProject() {
if (state.project) return state.project;
const projects = await _canvasFetch("/api/v1/projects", { method: "GET" });
state.project = Array.isArray(projects) && projects.length
? projects[0]
: await _canvasFetch("/api/v1/projects", {
method: "POST",
body: { name: "Hermes Canvas" },
});
_log("Project loaded: " + (state.project.name || state.project.id || "Hermes Canvas"));
await _loadScreens();
return state.project;
}
async function _loadScreens() {
if (!state.project) return;
const projectId = state.project.id || state.project.ID || state.project.uuid;
if (!projectId) return;
const screens = await _canvasFetch("/api/v1/projects/" + encodeURIComponent(projectId) + "/screens", { method: "GET" });
state.artboards = (Array.isArray(screens) ? screens : []).map((screen, idx) =>
_artboardFromSpec(
screen,
screen.spec || screen.Spec || screen.screenSpec || {},
screen.name || screen.Name || "Screen " + (idx + 1),
"saved",
)
);
_render();
}
async function _saveScreen(prompt, spec) {
await _ensureProject();
const projectId = state.project.id || state.project.ID || state.project.uuid;
if (!projectId) throw new Error("project id missing");
return _canvasFetch("/api/v1/projects/" + encodeURIComponent(projectId) + "/screens", {
method: "POST",
body: {
name: prompt.slice(0, 42) || "Generated screen",
spec,
},
});
}
async function _canvasFetch(path, options) {
const upstreamMethod = (options && options.method) || "GET";
const bridgeMethod = upstreamMethod === "PUT" ? "POST" : upstreamMethod;
const opts = {
method: bridgeMethod,
headers: {},
};
if (options && options.body !== undefined) {
opts.headers["Content-Type"] = "application/json";
opts.body = JSON.stringify(options.body);
}
const methodQuery = upstreamMethod === "PUT" ? "&method=PUT" : "";
const r = await fetch("/api/canvas/proxy?path=" + encodeURIComponent(path) + methodQuery, opts);
const text = await r.text();
const body = text ? JSON.parse(text) : {};
if (!r.ok) throw new Error(body && body.error ? JSON.stringify(body.error) : "HTTP " + r.status);
return body;
}
function _artboardFromSpec(record, spec, title, badge) {
return {
id: record && (record.id || record.ID || record.uuid),
title,
badge: record && (record.id || record.ID || record.uuid) ? badge + " · persisted" : badge,
spec,
dirty: false,
};
}
function _ingestScreenEvent(ev) {
const screen = ev.screen || {};
const spec = ev.screenSpec || screen.spec || screen.Spec || {};
const id = screen.id || screen.ID || screen.uuid;
if (id && state.artboards.some((board) => board.id === id)) return;
state.artboards.unshift(_artboardFromSpec(
screen,
spec,
screen.name || screen.Name || (ev.prompt || "Generated screen").slice(0, 42),
ev.variant_index ? "command variant " + ev.variant_index : "command",
));
_render();
}
function _render() {
const target = document.getElementById("svrntyCanvasArtboards");
if (!target) return;
if (!state.artboards.length) {
target.innerHTML = '<div class="svrnty-canvas-empty">Prompt CMO to generate a screen. Artboards, variants, edits, prototype links, and exports will accumulate here.</div>';
return;
}
target.innerHTML = state.artboards.map((board, idx) =>
'<article class="svrnty-canvas-artboard">' +
'<div class="svrnty-canvas-artboard-head"><strong>' + _esc(board.title) + '</strong><span class="svrnty-canvas-badge">' + _esc(board.dirty ? "edited" : board.badge) + '</span></div>' +
'<div class="svrnty-canvas-artboard-actions">' +
'<button class="svrnty-canvas-mini-btn" type="button" data-canvas-action="save" data-board-index="' + idx + '">Save edits</button>' +
'<button class="svrnty-canvas-mini-btn" type="button" data-canvas-action="prototype-source" data-board-index="' + idx + '">Start</button>' +
'<button class="svrnty-canvas-mini-btn" type="button" data-canvas-action="prototype-link" data-board-index="' + idx + '">Link</button>' +
'<button class="svrnty-canvas-mini-btn" type="button" data-canvas-action="export" data-board-index="' + idx + '">Export</button>' +
'</div>' +
'<div class="svrnty-canvas-render">' + _renderSpec(board.spec) + '</div>' +
'</article>'
).join("");
}
function _renderSpec(spec) {
if (!spec) return '<div class="svrnty-canvas-node">Empty spec</div>';
const root = _rootRef(spec);
return _renderNode(root.node, 0, root.path);
}
function _renderNode(node, depth, path) {
if (!node || typeof node !== "object" || depth > 12) return "";
const type = String(node.type || node.component || "container").toLowerCase();
const field = _editableField(node);
const text = field ? _editableValue(node, field) : (node.text || node.label || node.title || node.name || (node.props && (node.props.data || node.props.label || node.props.title)) || type);
const children = node.children || node.items || [];
const childKey = Array.isArray(node.children) ? "children" : Array.isArray(node.items) ? "items" : "";
const childHtml = Array.isArray(children)
? children.map((child, idx) => _renderNode(child, depth + 1, path.concat([childKey, String(idx)]))).join("")
: "";
const editAttrs = field
? ' contenteditable="true" spellcheck="false" data-canvas-path="' + _esc(path.join(".")) + '" data-canvas-field="' + _esc(field) + '"'
: "";
return '<div class="svrnty-canvas-node ' + _esc(type) + '"' + editAttrs + '>' + _esc(text) + childHtml + '</div>';
}
function _handleArtboardInput(ev) {
const el = ev.target && ev.target.closest && ev.target.closest("[data-canvas-path]");
if (!el) return;
const boardEl = el.closest(".svrnty-canvas-artboard");
const boardIndex = Array.prototype.indexOf.call(boardEl.parentElement.children, boardEl);
const board = state.artboards[boardIndex];
if (!board) return;
const field = el.getAttribute("data-canvas-field");
const node = _nodeAtPath(board.spec, (el.getAttribute("data-canvas-path") || "").split(".").filter(Boolean));
if (!node || !field) return;
_setEditableValue(node, field, el.childNodes.length ? Array.prototype.filter.call(el.childNodes, (n) => n.nodeType === Node.TEXT_NODE).map((n) => n.nodeValue).join("").trim() || el.textContent.trim() : el.textContent.trim());
board.dirty = true;
const badge = boardEl.querySelector(".svrnty-canvas-badge");
if (badge) badge.textContent = "edited";
}
function _handleArtboardClick(ev) {
const btn = ev.target && ev.target.closest && ev.target.closest("[data-canvas-action]");
if (!btn) return;
const board = state.artboards[parseInt(btn.getAttribute("data-board-index") || "-1", 10)];
if (!board) return;
const action = btn.getAttribute("data-canvas-action");
if (action === "save") _persistBoard(board);
else if (action === "prototype-source") _setPrototypeSource(board);
else if (action === "prototype-link") _linkPrototype(board);
else if (action === "export") _exportProject();
}
async function _persistBoard(board) {
_log("Saving simple edit: " + (board.title || board.id || "artboard"));
if (!board.id) {
_log("Save skipped: artboard has no screen id");
return;
}
await _ensureProject();
const projectId = state.project && (state.project.id || state.project.ID || state.project.uuid);
if (!projectId) {
_log("Save failed: project id missing");
return;
}
try {
const saved = await _canvasFetch(
"/api/v1/projects/" + encodeURIComponent(projectId) + "/screens/" + encodeURIComponent(board.id),
{ method: "PUT", body: { spec: board.spec } },
);
board.spec = saved.spec || saved.Spec || board.spec;
board.dirty = false;
board.badge = "edited · persisted";
state.lastEditPersisted = { screenId: board.id, at: Date.now() };
_log("Simple edit persisted: " + (board.title || board.id));
_render();
} catch (e) {
_log("Save failed: " + _message(e));
}
}
function _setPrototypeSource(board) {
if (!board.id) {
_log("Prototype start needs a persisted screen");
return;
}
state.prototypeSourceId = board.id;
_log("Prototype start set: " + (board.title || board.id));
}
async function _linkPrototype(board) {
if (!state.prototypeSourceId) {
_setPrototypeSource(board);
return;
}
if (!board.id || board.id === state.prototypeSourceId) {
_log("Choose a different persisted screen to link");
return;
}
await _ensureProject();
const projectId = state.project && (state.project.id || state.project.ID || state.project.uuid);
if (!projectId) {
_log("Prototype link failed: project id missing");
return;
}
try {
const edge = await _canvasFetch(
"/api/v1/projects/" + encodeURIComponent(projectId) + "/prototype-edges",
{
method: "POST",
body: {
sourceScreenId: state.prototypeSourceId,
targetScreenId: board.id,
action: "navigate",
},
},
);
state.lastPrototypeEdge = edge;
_log("Prototype link saved: " + (edge.sourceScreenId || state.prototypeSourceId) + " -> " + (edge.targetScreenId || board.id));
state.prototypeSourceId = board.id;
} catch (e) {
_log("Prototype link failed: " + _message(e));
}
}
async function _exportProject() {
await _ensureProject();
const projectId = state.project && (state.project.id || state.project.ID || state.project.uuid);
if (!projectId) {
_log("Export failed: project id missing");
return null;
}
try {
const exported = await _canvasFetch("/api/v1/projects/" + encodeURIComponent(projectId) + "/export", { method: "GET" });
state.exportSummary = exported;
const screens = (exported.screens || []).length;
const variants = (exported.variants || []).length;
const edges = (exported.prototypeEdges || []).length;
_log("Export ready: " + screens + " screens, " + variants + " variants, " + edges + " prototype links");
return exported;
} catch (e) {
_log("Export failed: " + _message(e));
return null;
}
}
function _rootRef(spec) {
if (spec && spec.screen && spec.screen.root) return { node: spec.screen.root, path: ["screen", "root"] };
if (spec && spec.root) return { node: spec.root, path: ["root"] };
if (spec && spec.widget) return { node: spec.widget, path: ["widget"] };
return { node: spec, path: [] };
}
function _nodeAtPath(root, path) {
return path.reduce((node, part) => node && node[part], root);
}
function _editableField(node) {
if (!node || typeof node !== "object") return "";
if (Array.isArray(node.children) || Array.isArray(node.items)) return "";
const direct = ["text", "label", "title", "name"].find((field) => typeof node[field] === "string");
if (direct) return direct;
if (node.props && typeof node.props === "object") {
const prop = ["data", "label", "title", "subtitle", "description"].find((field) => typeof node.props[field] === "string");
if (prop) return "props." + prop;
}
return "";
}
function _editableValue(node, field) {
if (field.indexOf(".") === -1) return node[field];
return field.split(".").reduce((cur, key) => cur && cur[key], node);
}
function _setEditableValue(node, field, value) {
const parts = field.split(".");
const key = parts.pop();
const target = parts.reduce((cur, part) => cur && cur[part], node);
if (target && key) target[key] = value;
}
function _renderTools(tools) {
const target = document.getElementById("svrntyCanvasTools");
if (!target) return;
target.innerHTML = (tools || []).slice(0, 6).map((tool) =>
'<div class="svrnty-canvas-log-row"><strong>' + _esc(tool.name) + '</strong><br>' + _esc(tool.purpose || "") + '</div>'
).join("");
}
function _log(message) {
state.log.unshift({ message, at: new Date().toLocaleTimeString() });
state.log = state.log.slice(0, 8);
const target = document.getElementById("svrntyCanvasLog");
if (!target) return;
target.innerHTML = state.log.map((row) =>
'<div class="svrnty-canvas-log-row"><strong>' + _esc(row.at) + '</strong><br>' + _esc(row.message) + '</div>'
).join("");
}
function _setText(id, text) {
const el = document.getElementById(id);
if (el) el.textContent = text;
}
function _message(e) {
return e && e.message ? e.message : String(e);
}
function _esc(s) {
return String(s == null ? "" : s).replace(/[<>&"]/g, (c) => ({
"<": "&lt;",
">": "&gt;",
"&": "&amp;",
'"': "&quot;",
}[c]));
}
function _capabilitySummary(caps) {
const advanced = ["variants", "promptEdit", "prototypeGraph", "liveEvents"]
.filter((key) => caps[key])
.join(", ");
return advanced || "base Stitch loop";
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _init);
} else {
_init();
}
})();