feat: add Mermaid diagram generator canvas tool + share panel role selector

- Add folk-mermaid-gen web component: AI-powered diagram generation via
  Ollama, client-side SVG preview via mermaid.js, animated GIF export via
  mermaid.rspace.online API
- Register in canvas tools, toolbar, and shape registry
- Add role selector dropdown to share panel invite form (backend already
  supports role parameter)
- Fix pre-existing TS errors: SankeyNode missing address field,
  SpaceMember type mismatch in WebSocket auth fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-01 14:01:52 -07:00
parent 6018a88d26
commit 7420228ce9
9 changed files with 2913 additions and 7 deletions

View File

@ -281,6 +281,26 @@ const registry: CanvasToolDefinition[] = [
},
];
// ── Mermaid Diagram Tool ──
registry.push({
declaration: {
name: "create_mermaid_diagram",
description: "Create a mermaid diagram on the canvas. Use when the user wants to create flowcharts, sequence diagrams, class diagrams, state diagrams, ER diagrams, Gantt charts, or any diagram that can be expressed in Mermaid syntax.",
parameters: {
type: "object",
properties: {
prompt: { type: "string", description: "Description of the diagram to generate (e.g. 'CI/CD pipeline with build, test, deploy stages')" },
},
required: ["prompt"],
},
},
tagName: "folk-mermaid-gen",
buildProps: (args) => ({
prompt: args.prompt,
}),
actionLabel: (args) => `Creating diagram: ${args.prompt.slice(0, 50)}${args.prompt.length > 50 ? "..." : ""}`,
});
// ── Social Media / Campaign Tools ──
registry.push(
{

693
lib/folk-mermaid-gen.ts Normal file
View File

@ -0,0 +1,693 @@
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const MERMAID_ANIMATOR_URL = "https://mermaid.rspace.online";
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: 380px;
min-height: 460px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #7c3aed, #2563eb);
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;
}
.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);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
overflow: hidden;
}
.input-area {
padding: 12px;
border-bottom: 1px solid var(--rs-border, #e2e8f0);
}
.prompt-input, .code-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);
}
.code-input {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
}
.prompt-input:focus, .code-input:focus {
border-color: #7c3aed;
}
.controls {
display: flex;
gap: 6px;
margin-top: 8px;
flex-wrap: wrap;
}
.generate-btn, .animate-btn {
flex: 1;
padding: 8px 12px;
color: white;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
min-width: 100px;
}
.generate-btn {
background: linear-gradient(135deg, #7c3aed, #2563eb);
}
.animate-btn {
background: linear-gradient(135deg, #059669, #0d9488);
}
.generate-btn:hover, .animate-btn:hover {
opacity: 0.9;
}
.generate-btn:disabled, .animate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle-btn {
padding: 6px 10px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 11px;
background: white;
cursor: pointer;
}
.toggle-btn.active {
background: #7c3aed;
color: white;
border-color: #7c3aed;
}
.controls-row {
display: flex;
gap: 6px;
margin-top: 6px;
align-items: center;
}
.controls-row select {
padding: 5px 8px;
border: 2px solid #e2e8f0;
border-radius: 6px;
font-size: 11px;
background: white;
cursor: pointer;
}
.controls-row label {
font-size: 11px;
color: #64748b;
}
.controls-row input[type="range"] {
width: 80px;
accent-color: #7c3aed;
}
.preview-area {
flex: 1;
padding: 12px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.placeholder {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #94a3b8;
text-align: center;
gap: 8px;
}
.placeholder-icon {
font-size: 48px;
opacity: 0.5;
}
.svg-preview {
width: 100%;
border-radius: 8px;
background: white;
padding: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
overflow: auto;
}
.svg-preview svg {
max-width: 100%;
height: auto;
}
.gif-preview {
width: 100%;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.history-item {
position: relative;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 8px;
}
.history-prompt {
font-size: 11px;
color: #64748b;
margin-top: 4px;
padding: 4px 8px;
background: #f1f5f9;
border-radius: 4px;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24px;
gap: 12px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #7c3aed;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error {
color: #ef4444;
padding: 12px;
background: #fef2f2;
border-radius: 6px;
font-size: 13px;
}
.download-btn {
display: inline-block;
margin-top: 4px;
padding: 4px 10px;
font-size: 11px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 4px;
cursor: pointer;
text-decoration: none;
color: inherit;
}
.download-btn:hover {
background: #e2e8f0;
}
`;
interface MermaidDiagram {
id: string;
prompt: string;
source: string;
svgHtml: string;
gifBase64?: string;
timestamp: Date;
}
declare global {
interface HTMLElementTagNameMap {
"folk-mermaid-gen": FolkMermaidGen;
}
}
export class FolkMermaidGen extends FolkShape {
static override tagName = "folk-mermaid-gen";
static override portDescriptors = [
{ name: "prompt", type: "text" as const, direction: "input" as const },
{ name: "diagram", type: "text" as const, direction: "output" as const },
];
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;
}
#diagrams: MermaidDiagram[] = [];
#isLoading = false;
#error: string | null = null;
#showCode = false;
#currentSource = "";
#animationMode: "progressive" | "template" | "sequence" = "progressive";
#animationDelay = 800;
#theme: "default" | "dark" | "forest" | "neutral" = "default";
// DOM refs
#promptInput: HTMLTextAreaElement | null = null;
#codeInput: HTMLTextAreaElement | null = null;
#previewArea: HTMLElement | null = null;
#generateBtn: HTMLButtonElement | null = null;
#animateBtn: HTMLButtonElement | null = null;
#toggleBtn: HTMLButtonElement | null = null;
#modeSelect: HTMLSelectElement | null = null;
#themeSelect: HTMLSelectElement | null = null;
#delayInput: HTMLInputElement | null = null;
#delayLabel: HTMLElement | null = null;
#promptArea: HTMLElement | null = null;
#codeArea: HTMLElement | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>🔀</span>
<span>Mermaid Diagrams</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="input-area">
<div class="prompt-section">
<textarea class="prompt-input" placeholder="Describe the diagram you want (e.g. 'CI/CD pipeline with build, test, deploy')..." rows="2"></textarea>
</div>
<div class="code-section" style="display:none">
<textarea class="code-input" placeholder="graph TD\n A[Start] --> B[End]" rows="4"></textarea>
</div>
<div class="controls">
<button class="generate-btn">Generate</button>
<button class="animate-btn" disabled>Animate GIF</button>
<button class="toggle-btn">Code</button>
</div>
<div class="controls-row">
<label>Mode:</label>
<select class="mode-select">
<option value="progressive">Progressive</option>
<option value="template">Template</option>
<option value="sequence">Sequence</option>
</select>
<label>Theme:</label>
<select class="theme-select">
<option value="default">Default</option>
<option value="dark">Dark</option>
<option value="forest">Forest</option>
<option value="neutral">Neutral</option>
</select>
<label>Delay:</label>
<input type="range" class="delay-input" min="100" max="2000" step="100" value="800" />
<span class="delay-label">800ms</span>
</div>
</div>
<div class="preview-area">
<div class="placeholder">
<span class="placeholder-icon">🔀</span>
<span>Describe a diagram or write mermaid code</span>
</div>
</div>
</div>
`;
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
// Cache DOM refs
this.#promptInput = wrapper.querySelector(".prompt-input");
this.#codeInput = wrapper.querySelector(".code-input");
this.#previewArea = wrapper.querySelector(".preview-area");
this.#generateBtn = wrapper.querySelector(".generate-btn");
this.#animateBtn = wrapper.querySelector(".animate-btn");
this.#toggleBtn = wrapper.querySelector(".toggle-btn");
this.#modeSelect = wrapper.querySelector(".mode-select");
this.#themeSelect = wrapper.querySelector(".theme-select");
this.#delayInput = wrapper.querySelector(".delay-input");
this.#delayLabel = wrapper.querySelector(".delay-label");
this.#promptArea = wrapper.querySelector(".prompt-section");
this.#codeArea = wrapper.querySelector(".code-section");
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Event listeners
this.#generateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
if (this.#showCode) {
this.#renderFromCode();
} else {
this.#generateFromPrompt();
}
});
this.#animateBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#animateGif();
});
this.#toggleBtn?.addEventListener("click", (e) => {
e.stopPropagation();
this.#showCode = !this.#showCode;
this.#updateToggle();
});
this.#modeSelect?.addEventListener("change", () => {
this.#animationMode = (this.#modeSelect?.value as any) || "progressive";
});
this.#themeSelect?.addEventListener("change", () => {
this.#theme = (this.#themeSelect?.value as any) || "default";
});
this.#delayInput?.addEventListener("input", () => {
this.#animationDelay = parseInt(this.#delayInput?.value || "800");
if (this.#delayLabel) this.#delayLabel.textContent = `${this.#animationDelay}ms`;
});
this.#promptInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
this.#generateFromPrompt();
}
});
this.#codeInput?.addEventListener("keydown", (e) => {
if (e.key === "Enter" && e.ctrlKey) {
e.preventDefault();
this.#renderFromCode();
}
});
// Prevent canvas drag
for (const el of [this.#previewArea, this.#promptInput, this.#codeInput, this.#modeSelect, this.#themeSelect, this.#delayInput]) {
el?.addEventListener("pointerdown", (e) => e.stopPropagation());
}
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
#updateToggle() {
if (this.#promptArea) this.#promptArea.style.display = this.#showCode ? "none" : "";
if (this.#codeArea) this.#codeArea.style.display = this.#showCode ? "" : "none";
if (this.#toggleBtn) {
this.#toggleBtn.textContent = this.#showCode ? "Prompt" : "Code";
this.#toggleBtn.classList.toggle("active", this.#showCode);
}
if (this.#generateBtn) {
this.#generateBtn.textContent = this.#showCode ? "Render" : "Generate";
}
// Sync code input with current source
if (this.#showCode && this.#codeInput && this.#currentSource) {
this.#codeInput.value = this.#currentSource;
}
}
async #generateFromPrompt() {
const prompt = this.#promptInput?.value.trim();
if (!prompt || this.#isLoading) return;
this.#isLoading = true;
this.#error = null;
this.#setButtonsDisabled(true);
this.#renderLoading("Generating diagram...");
try {
const response = await fetch("/api/prompt", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "llama3.1",
systemPrompt: "You are a Mermaid diagram generator. Output ONLY valid mermaid diagram code, no explanation, no markdown code fences. Start directly with the diagram type (graph, flowchart, sequenceDiagram, etc).",
messages: [{ role: "user", content: `Generate a Mermaid diagram for: ${prompt}` }],
}),
});
if (!response.ok) throw new Error(`AI generation failed: ${response.statusText}`);
const data = await response.json() as { content?: string };
let source = (data.content || "").trim();
// Strip markdown fences if present
source = source.replace(/^```(?:mermaid)?\n?/i, "").replace(/\n?```$/i, "").trim();
if (!source) throw new Error("Empty response from AI");
this.#currentSource = source;
if (this.#codeInput) this.#codeInput.value = source;
await this.#renderMermaidSvg(source, prompt);
} catch (error) {
this.#error = error instanceof Error ? error.message : "Generation failed";
this.#renderError();
} finally {
this.#isLoading = false;
this.#setButtonsDisabled(false);
}
}
async #renderFromCode() {
const source = this.#codeInput?.value.trim();
if (!source || this.#isLoading) return;
this.#currentSource = source;
this.#isLoading = true;
this.#error = null;
this.#setButtonsDisabled(true);
this.#renderLoading("Rendering diagram...");
try {
await this.#renderMermaidSvg(source, "(code edit)");
} catch (error) {
this.#error = error instanceof Error ? error.message : "Render failed";
this.#renderError();
} finally {
this.#isLoading = false;
this.#setButtonsDisabled(false);
}
}
async #renderMermaidSvg(source: string, prompt: string) {
const mermaid = (await import("mermaid")).default;
mermaid.initialize({ startOnLoad: false, theme: this.#theme });
const id = `mermaid-${Date.now()}`;
const { svg } = await mermaid.render(id, source);
const diagram: MermaidDiagram = {
id: crypto.randomUUID(),
prompt,
source,
svgHtml: svg,
timestamp: new Date(),
};
this.#diagrams.unshift(diagram);
this.#renderPreview();
if (this.#animateBtn) this.#animateBtn.disabled = false;
if (this.#promptInput) this.#promptInput.value = "";
}
async #animateGif() {
if (!this.#currentSource || this.#isLoading) return;
this.#isLoading = true;
this.#setButtonsDisabled(true);
this.#renderLoading("Animating GIF...");
try {
const response = await fetch(`${MERMAID_ANIMATOR_URL}/api/render`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: this.#currentSource,
mode: this.#animationMode,
delay: this.#animationDelay,
theme: this.#theme,
}),
});
if (!response.ok) throw new Error(`Animation failed: ${response.statusText}`);
const data = await response.json() as { gif: string; frames: number; width: number; height: number };
// Update latest history entry with GIF
if (this.#diagrams.length > 0) {
this.#diagrams[0].gifBase64 = data.gif;
}
this.#renderPreview();
} catch (error) {
this.#error = error instanceof Error ? error.message : "Animation failed";
this.#renderError();
} finally {
this.#isLoading = false;
this.#setButtonsDisabled(false);
}
}
#setButtonsDisabled(disabled: boolean) {
if (this.#generateBtn) this.#generateBtn.disabled = disabled;
if (this.#animateBtn) this.#animateBtn.disabled = disabled || !this.#currentSource;
}
#renderLoading(message: string) {
if (!this.#previewArea) return;
this.#previewArea.innerHTML = `
<div class="loading">
<div class="spinner"></div>
<span>${this.#escapeHtml(message)}</span>
</div>
`;
}
#renderError() {
if (!this.#previewArea) return;
this.#previewArea.innerHTML = `
<div class="error">${this.#escapeHtml(this.#error || "Unknown error")}</div>
${this.#diagrams.length > 0 ? this.#renderDiagramList() : '<div class="placeholder"><span class="placeholder-icon">🔀</span><span>Try again</span></div>'}
`;
}
#renderPreview() {
if (!this.#previewArea) return;
if (this.#diagrams.length === 0) {
this.#previewArea.innerHTML = `
<div class="placeholder">
<span class="placeholder-icon">🔀</span>
<span>Describe a diagram or write mermaid code</span>
</div>
`;
return;
}
this.#previewArea.innerHTML = this.#renderDiagramList();
// Wire up download buttons
this.#previewArea.querySelectorAll(".download-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
});
});
}
#renderDiagramList(): string {
return this.#diagrams.map((d) => {
const preview = d.gifBase64
? `<img class="gif-preview" src="data:image/gif;base64,${d.gifBase64}" alt="Animated diagram" />`
: `<div class="svg-preview">${d.svgHtml}</div>`;
const downloadLink = d.gifBase64
? `<a class="download-btn" href="data:image/gif;base64,${d.gifBase64}" download="mermaid-diagram.gif">Download GIF</a>`
: "";
return `
<div class="history-item">
${preview}
<div class="history-prompt">${this.#escapeHtml(d.prompt)}</div>
${downloadLink}
</div>
`;
}).join("");
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
static override fromData(data: Record<string, any>): FolkMermaidGen {
const shape = FolkShape.fromData(data) as FolkMermaidGen;
return shape;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-mermaid-gen",
diagrams: this.#diagrams.map((d) => ({
...d,
timestamp: d.timestamp.toISOString(),
})),
};
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
}
}

View File

@ -38,6 +38,7 @@ export * from "./folk-map";
// AI Integration Shapes
export * from "./folk-image-gen";
export * from "./folk-image-studio";
export * from "./folk-mermaid-gen";
export * from "./folk-video-gen";
export * from "./folk-prompt";
export * from "./folk-zine-gen";

View File

@ -1135,6 +1135,7 @@ class FolkWalletViewer extends HTMLElement {
const nodes = Array.from(nodeMap.entries()).map(([name, _idx]) => ({
name,
type: name === walletLabel ? "wallet" as const : (entries.some(tx => tx.type === "in" && ((tx.from || "Unknown").startsWith(name.slice(0, 6)))) ? "source" as const : "target" as const),
address: name,
}));
return { nodes, links: Array.from(aggregated.values()) };

2159
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -55,6 +55,7 @@
"lowlight": "^3.3.0",
"mailparser": "^3.7.2",
"marked": "^17.0.3",
"mermaid": "^11.14.0",
"nodemailer": "^6.9.0",
"pdf-lib": "^1.17.1",
"perfect-arrows": "^0.3.7",

View File

@ -3108,7 +3108,7 @@ const server = Bun.serve<WSData>({
const memberData = await memberRes.json() as { role: string; userDID: string };
// Sync to Automerge so future connections don't need the fallback
setMember(communitySlug, callerDid, memberData.role as any, (claims as any).username);
isMember = { role: memberData.role };
isMember = { did: callerDid, role: memberData.role as "admin" | "viewer" | "member" | "moderator", joinedAt: Date.now() };
}
} catch {}
}

View File

@ -52,12 +52,15 @@ export class RStackSharePanel extends HTMLElement {
async #sendInvite() {
const input = this.#shadow.getElementById("email-input") as HTMLInputElement;
const roleSelect = this.#shadow.getElementById("role-select") as HTMLSelectElement;
const status = this.#shadow.getElementById("email-status");
if (!input || !status) return;
const email = input.value.trim();
if (!email) return;
const role = roleSelect?.value || "member";
const slug = this.#spaceSlug;
if (!slug) {
status.textContent = "No space context";
@ -72,7 +75,7 @@ export class RStackSharePanel extends HTMLElement {
const res = await fetch(`/api/spaces/${slug}/invite`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, shareUrl: this.#shareUrl }),
body: JSON.stringify({ email, role, shareUrl: this.#shareUrl }),
});
if (res.ok) {
status.textContent = "Invite sent!";
@ -113,6 +116,12 @@ export class RStackSharePanel extends HTMLElement {
<label>Invite by email</label>
<div class="email-row">
<input id="email-input" type="email" placeholder="friend@example.com">
<select id="role-select" class="role-select">
<option value="member">member</option>
<option value="viewer">viewer</option>
<option value="moderator">moderator</option>
<option value="admin">admin</option>
</select>
<button id="send-btn" class="btn-blue">Send</button>
</div>
<div id="email-status" class="email-status"></div>
@ -325,6 +334,20 @@ label {
border-color: #14b8a6;
}
.role-select {
padding: 6px 8px;
border: 1px solid var(--rs-border, rgba(255,255,255,0.1));
border-radius: 6px;
font-size: 12px;
color: var(--rs-text-primary, #e2e8f0);
background: var(--rs-bg-hover, rgba(255,255,255,0.05));
cursor: pointer;
outline: none;
}
.role-select:focus {
border-color: #14b8a6;
}
.email-status {
font-size: 11px;
margin-top: 6px;

View File

@ -1161,6 +1161,7 @@
#canvas.feed-mode folk-video-chat,
#canvas.feed-mode folk-transcription,
#canvas.feed-mode folk-image-gen,
#canvas.feed-mode folk-mermaid-gen,
#canvas.feed-mode folk-video-gen,
#canvas.feed-mode folk-blender,
#canvas.feed-mode folk-freecad,
@ -1547,6 +1548,7 @@
folk-calendar,
folk-map,
folk-image-gen,
folk-mermaid-gen,
folk-video-gen,
folk-prompt,
folk-zine-gen,
@ -1591,7 +1593,7 @@
.connect-mode :is(folk-markdown, folk-wrapper, folk-slide, folk-chat,
folk-google-item, folk-piano, folk-embed, folk-image, folk-bookmark, folk-calendar, folk-map,
folk-image-gen, folk-video-gen, folk-prompt, folk-zine-gen, folk-transcription,
folk-image-gen, folk-mermaid-gen, folk-video-gen, folk-prompt, folk-zine-gen, folk-transcription,
folk-video-chat, folk-obs-note, folk-workflow-block,
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
folk-booking, folk-token-mint, folk-token-ledger,
@ -1603,7 +1605,7 @@
.connect-mode :is(folk-markdown, folk-wrapper, folk-slide, folk-chat,
folk-google-item, folk-piano, folk-embed, folk-image, folk-bookmark, folk-calendar, folk-map,
folk-image-gen, folk-video-gen, folk-prompt, folk-zine-gen, folk-transcription,
folk-image-gen, folk-mermaid-gen, folk-video-gen, folk-prompt, folk-zine-gen, folk-transcription,
folk-video-chat, folk-obs-note, folk-workflow-block,
folk-itinerary, folk-destination, folk-budget, folk-packing-list,
folk-booking, folk-token-mint, folk-token-ledger,
@ -2062,6 +2064,7 @@
<button id="new-image-gen" title="AI Image">🎨 AI Image</button>
<button id="new-video-gen" title="AI Video">🎬 AI Video</button>
<button id="new-zine-gen" title="Zine Gen">📰 Zine Gen</button>
<button id="new-mermaid-gen" title="Mermaid Diagram">🔀 Diagrams</button>
</div>
</div>
@ -2435,6 +2438,7 @@
FolkMap,
FolkImageGen,
FolkImageStudio,
FolkMermaidGen,
FolkVideoGen,
FolkPrompt,
FolkZineGen,
@ -2687,6 +2691,7 @@
FolkMap.define();
FolkImageGen.define();
FolkImageStudio.define();
FolkMermaidGen.define();
FolkVideoGen.define();
FolkPrompt.define();
FolkTranscription.define();
@ -2738,6 +2743,7 @@
shapeRegistry.register("folk-map", FolkMap);
shapeRegistry.register("folk-image-gen", FolkImageGen);
shapeRegistry.register("folk-image-studio", FolkImageStudio);
shapeRegistry.register("folk-mermaid-gen", FolkMermaidGen);
shapeRegistry.register("folk-video-gen", FolkVideoGen);
shapeRegistry.register("folk-prompt", FolkPrompt);
shapeRegistry.register("folk-zine-gen", FolkZineGen);
@ -3126,7 +3132,7 @@
const CONNECTABLE_SELECTOR = [
"folk-markdown", "folk-wrapper", "folk-slide", "folk-chat",
"folk-google-item", "folk-piano", "folk-embed", "folk-calendar",
"folk-map", "folk-image-gen", "folk-video-gen", "folk-prompt",
"folk-map", "folk-image-gen", "folk-mermaid-gen", "folk-video-gen", "folk-prompt",
"folk-transcription", "folk-video-chat", "folk-obs-note",
"folk-workflow-block", "folk-itinerary", "folk-destination",
"folk-budget", "folk-packing-list", "folk-booking",
@ -3925,6 +3931,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
"folk-calendar": { width: 320, height: 380 },
"folk-map": { width: 500, height: 400 },
"folk-image-gen": { width: 400, height: 500 },
"folk-mermaid-gen": { width: 420, height: 560 },
"folk-video-gen": { width: 450, height: 550 },
"folk-prompt": { width: 450, height: 500 },
"folk-zine-gen": { width: 500, height: 600 },
@ -4437,6 +4444,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
document.getElementById("new-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen"));
document.getElementById("new-zine-gen").addEventListener("click", () => setPendingTool("folk-zine-gen"));
document.getElementById("new-mermaid-gen").addEventListener("click", () => setPendingTool("folk-mermaid-gen"));
document.getElementById("new-prompt").addEventListener("click", () => setPendingTool("folk-prompt"));
document.getElementById("new-transcription").addEventListener("click", () => setPendingTool("folk-transcription"));
// rMeets is now handled via rApp embed (embed-meets in rAppModules array)
@ -6083,7 +6091,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
"folk-markdown": "📝", "folk-wrapper": "🗂️", "folk-slide": "🎞️",
"folk-chat": "💬", "folk-piano": "🎹", "folk-embed": "🔗",
"folk-google-item": "📎", "folk-calendar": "📅", "folk-map": "🗺️",
"folk-image-gen": "🎨", "folk-video-gen": "🎬", "folk-prompt": "🤖",
"folk-image-gen": "🎨", "folk-mermaid-gen": "🔀", "folk-video-gen": "🎬", "folk-prompt": "🤖",
"folk-transcription": "🎤", "folk-video-chat": "📹", "folk-obs-note": "📓",
"folk-workflow-block": "⚙️", "folk-itinerary": "🗓️", "folk-destination": "📍",
"folk-budget": "💰", "folk-packing-list": "🎒", "folk-booking": "✈️",
@ -7504,7 +7512,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
const TYPE_COLORS = {
"folk-markdown": "#6366f1", "folk-rapp": "#818cf8", "folk-embed": "#06b6d4",
"folk-image": "#ec4899", "folk-image-gen": "#a855f7", "folk-video-gen": "#8b5cf6",
"folk-image": "#ec4899", "folk-image-gen": "#a855f7", "folk-mermaid-gen": "#7c3aed", "folk-video-gen": "#8b5cf6",
"folk-prompt": "#10b981", "folk-bookmark": "#f97316", "folk-calendar": "#3b82f6",
"folk-chat": "#ef4444", "folk-obs-note": "#f59e0b", "folk-slide": "#14b8a6",
};