639 lines
25 KiB
JavaScript
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) => ({
|
|
"<": "<",
|
|
">": ">",
|
|
"&": "&",
|
|
'"': """,
|
|
}[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();
|
|
}
|
|
})();
|