import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 420px;
min-height: 500px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #7c3aed, #a78bfa);
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
align-items: center;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.state-badge {
font-size: 9px;
padding: 2px 6px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
background: rgba(255,255,255,0.2);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
overflow: hidden;
}
.prompt-area {
padding: 12px;
border-bottom: 1px solid var(--rs-border, #e2e8f0);
}
.prompt-input {
width: 100%;
padding: 10px 12px;
border: 2px solid var(--rs-input-border, #e2e8f0);
border-radius: 8px;
font-size: 13px;
resize: none;
outline: none;
font-family: inherit;
background: var(--rs-input-bg, #fff);
color: var(--rs-input-text, inherit);
box-sizing: border-box;
}
.prompt-input:focus {
border-color: #7c3aed;
}
.btn-row {
display: flex;
gap: 6px;
margin-top: 8px;
}
.btn {
padding: 6px 14px;
border-radius: 6px;
border: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary {
background: linear-gradient(135deg, #7c3aed, #a78bfa);
color: white;
}
.btn-primary:hover { background: linear-gradient(135deg, #6d28d9, #8b5cf6); }
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
background: var(--rs-bg-elevated, #f1f5f9);
color: var(--rs-text-primary, #475569);
}
.btn-secondary:hover { background: var(--rs-bg-hover, #e2e8f0); }
.status-area {
flex: 1;
overflow-y: auto;
padding: 12px;
font-size: 12px;
}
.step {
padding: 6px 0;
border-bottom: 1px solid var(--rs-border, #f1f5f9);
display: flex;
align-items: flex-start;
gap: 8px;
}
.step-icon {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
margin-top: 1px;
}
.step-icon.thinking { background: #dbeafe; color: #2563eb; }
.step-icon.executing { background: #fef3c7; color: #d97706; }
.step-icon.done { background: #d1fae5; color: #059669; }
.step-icon.error { background: #fee2e2; color: #dc2626; }
.step-content {
flex: 1;
line-height: 1.4;
}
.step-tool {
font-family: monospace;
background: var(--rs-bg-elevated, #f1f5f9);
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
}
.export-row {
display: flex;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--rs-border, #e2e8f0);
justify-content: center;
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 2rem;
color: #94a3b8;
text-align: center;
}
.placeholder-icon { font-size: 2rem; }
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 4px;
}
@keyframes spin { to { transform: rotate(360deg); } }
`;
declare global {
interface HTMLElementTagNameMap {
"folk-design-agent": FolkDesignAgent;
}
}
type AgentState = "idle" | "planning" | "executing" | "verifying" | "done" | "error";
export class FolkDesignAgent extends FolkShape {
static override tagName = "folk-design-agent";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#state: AgentState = "idle";
#abortController: AbortController | null = null;
#promptInput: HTMLTextAreaElement | null = null;
#statusArea: HTMLElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
#stopBtn: HTMLButtonElement | null = null;
#exportRow: HTMLElement | null = null;
#stateBadge: HTMLElement | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
📐
Enter a design brief to get started.
The agent will create a Scribus document step by step.
`;
// Replace slot container with our wrapper
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#promptInput = wrapper.querySelector(".prompt-input");
this.#statusArea = wrapper.querySelector('[data-ref="status-area"]');
this.#generateBtn = wrapper.querySelector('[data-ref="generate-btn"]');
this.#stopBtn = wrapper.querySelector('[data-ref="stop-btn"]');
this.#exportRow = wrapper.querySelector('[data-ref="export-row"]');
this.#stateBadge = wrapper.querySelector('[data-ref="state-badge"]');
// Set initial brief from attribute
const brief = this.getAttribute("brief");
if (brief && this.#promptInput) this.#promptInput.value = brief;
// Generate button
this.#generateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#generate();
});
// Stop button
this.#stopBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#stop();
});
// Enter key
this.#promptInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.#generate();
}
});
// Prevent canvas drag
this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation());
this.#statusArea?.addEventListener("pointerdown", (e) => e.stopPropagation());
// Close button
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
closeBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
#setState(state: AgentState) {
this.#state = state;
if (this.#stateBadge) this.#stateBadge.textContent = state.charAt(0).toUpperCase() + state.slice(1);
const isWorking = state !== "idle" && state !== "done" && state !== "error";
if (this.#generateBtn) {
this.#generateBtn.disabled = isWorking;
this.#generateBtn.innerHTML = isWorking
? ' Working...'
: "Generate Design";
}
if (this.#stopBtn) this.#stopBtn.style.display = isWorking ? "" : "none";
if (this.#exportRow) this.#exportRow.style.display = state === "done" ? "" : "none";
if (this.#promptInput) this.#promptInput.disabled = isWorking;
}
#addStep(icon: string, cls: string, text: string) {
if (!this.#statusArea) return;
// Remove placeholder on first step
const placeholder = this.#statusArea.querySelector(".placeholder");
if (placeholder) placeholder.remove();
const step = document.createElement("div");
step.className = "step";
step.innerHTML = `${icon}
${text}
`;
this.#statusArea.appendChild(step);
this.#statusArea.scrollTop = this.#statusArea.scrollHeight;
}
async #generate() {
const brief = this.#promptInput?.value.trim();
if (!brief || (this.#state !== "idle" && this.#state !== "done" && this.#state !== "error")) return;
// Clear previous steps
if (this.#statusArea) this.#statusArea.innerHTML = "";
this.#setState("planning");
this.#abortController = new AbortController();
try {
const space = this.closest("[data-space]")?.getAttribute("data-space") || "demo";
const res = await fetch("/api/design-agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ brief, space }),
signal: this.#abortController.signal,
});
if (!res.ok || !res.body) {
this.#addStep("!", "error", `Request failed: ${res.status}`);
this.#setState("error");
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data:")) {
try {
const data = JSON.parse(line.slice(5).trim());
this.#processEvent(data);
} catch {}
}
}
}
if (this.#state !== "error") this.#setState("done");
} catch (e: any) {
if (e.name !== "AbortError") {
this.#addStep("!", "error", `Error: ${e.message}`);
this.#setState("error");
}
}
this.#abortController = null;
}
#processEvent(data: any) {
switch (data.action) {
case "starting_scribus":
this.#addStep("~", "thinking", data.status || "Starting Scribus...");
break;
case "scribus_ready":
this.#addStep("✓", "done", "Scribus ready");
break;
case "thinking":
this.#setState("planning");
this.#addStep("~", "thinking", data.status || "Thinking...");
break;
case "executing":
this.#setState("executing");
this.#addStep("▶", "executing",
`${data.status || "Executing"}: ${data.tool}`);
break;
case "tool_result":
if (data.result?.error) {
this.#addStep("!", "error", `${data.tool} failed: ${data.result.error}`);
} else {
this.#addStep("✓", "done", `${data.tool} completed`);
}
break;
case "verifying":
this.#setState("verifying");
this.#addStep("~", "thinking", data.status || "Verifying...");
break;
case "complete":
this.#addStep("✓", "done", data.message || "Design complete");
break;
case "done":
this.#addStep("✓", "done", data.status || "Done!");
if (data.state?.frames) {
this.#addStep("✓", "done", `${data.state.frames.length} frame(s) in document`);
}
break;
case "error":
this.#addStep("!", "error", data.error || "Unknown error");
this.#setState("error");
break;
}
}
#stop() {
this.#abortController?.abort();
this.#setState("idle");
this.#addStep("!", "error", "Stopped by user");
}
}
if (!customElements.get(FolkDesignAgent.tagName)) {
customElements.define(FolkDesignAgent.tagName, FolkDesignAgent);
}