// 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 "); 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 = '
' + '
Sovereign Canvas
' + '
Checking...
' + '' + '' + '' + '' + '' + '
' + '
' + '
' + '' + '
'; 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 = '
Prompt CMO to generate a screen. Artboards, variants, edits, prototype links, and exports will accumulate here.
'; return; } target.innerHTML = state.artboards.map((board, idx) => '
' + '
' + _esc(board.title) + '' + _esc(board.dirty ? "edited" : board.badge) + '
' + '
' + '' + '' + '' + '' + '
' + '
' + _renderSpec(board.spec) + '
' + '
' ).join(""); } function _renderSpec(spec) { if (!spec) return '
Empty spec
'; 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 '
' + _esc(text) + childHtml + '
'; } 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) => '
' + _esc(tool.name) + '
' + _esc(tool.purpose || "") + '
' ).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) => '
' + _esc(row.at) + '
' + _esc(row.message) + '
' ).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) => ({ "<": "<", ">": ">", "&": "&", '"': """, }[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(); } })();