From 46c326278af79ee63b17cdb0198cb082a118238a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 14:25:48 -0700 Subject: [PATCH 01/11] feat: ASCII art canvas tool, video gen fixes, scribus/notebook sidecars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add folk-ascii-gen canvas shape with pattern/palette selectors - Add POST /api/ascii-gen proxy to ascii-art service - Register create_ascii_art in canvas tools + triage panel - Fix WAN 2.1 t2v endpoint URL (fal-ai/wan/v2.1 → fal-ai/wan-t2v) - Convert video gen to async job queue (avoids Cloudflare timeouts) - Fix Docker API Content-Type bug in sidecar-manager - Convert scribus-novnc and open-notebook to on-demand sidecars - Add ensureSidecar("scribus-novnc") to rDesign bridge proxy - Fix Hono ContextVariableMap and handleTransakMessage type errors Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 33 +-- lib/canvas-tools.ts | 36 ++++ lib/folk-ascii-gen.ts | 433 ++++++++++++++++++++++++++++++++++++++ lib/folk-video-gen.ts | 60 ++++-- lib/index.ts | 1 + lib/mi-triage-panel.ts | 1 + modules/rdesign/mod.ts | 2 + server/index.ts | 246 +++++++++++++++++++--- server/mi-media.ts | 6 +- server/sidecar-manager.ts | 24 ++- types/hono.d.ts | 1 + 11 files changed, 765 insertions(+), 78 deletions(-) create mode 100644 lib/folk-ascii-gen.ts diff --git a/docker-compose.yml b/docker-compose.yml index 4121c44..75a5d9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -318,12 +318,13 @@ services: networks: - rspace-internal - # ── Scribus noVNC (rDesign DTP workspace) ── + # ── Scribus noVNC (rDesign DTP workspace) — on-demand sidecar ── scribus-novnc: build: context: ./docker/scribus-novnc container_name: scribus-novnc - restart: unless-stopped + restart: "no" + profiles: ["sidecar"] mem_limit: 512m cpus: 1 volumes: @@ -342,22 +343,15 @@ services: timeout: 5s retries: 3 start_period: 30s - labels: - - "traefik.enable=true" - - "traefik.http.routers.scribus-novnc.rule=Host(`design.rspace.online`)" - - "traefik.http.routers.scribus-novnc.entrypoints=web" - - "traefik.http.routers.scribus-novnc.priority=150" - - "traefik.http.services.scribus-novnc.loadbalancer.server.port=6080" - - "traefik.docker.network=traefik-public" networks: - - traefik-public - rspace-internal - # ── Open Notebook (NotebookLM-like RAG service) ── + # ── Open Notebook (NotebookLM-like RAG service) — on-demand sidecar ── open-notebook: image: ghcr.io/lfnovo/open-notebook:v1-latest-single container_name: open-notebook - restart: always + restart: "no" + profiles: ["sidecar"] mem_limit: 1g cpus: 1 env_file: ./open-notebook.env @@ -365,21 +359,8 @@ services: - open-notebook-data:/app/data - open-notebook-db:/mydata networks: - - traefik-public + - rspace-internal - ai-internal - labels: - - "traefik.enable=true" - - "traefik.docker.network=traefik-public" - # Frontend UI - - "traefik.http.routers.rspace-notebook.rule=Host(`notebook.rspace.online`)" - - "traefik.http.routers.rspace-notebook.entrypoints=web" - - "traefik.http.routers.rspace-notebook.tls.certresolver=letsencrypt" - - "traefik.http.services.rspace-notebook.loadbalancer.server.port=8502" - # API endpoint (used by rNotes integration) - - "traefik.http.routers.rspace-notebook-api.rule=Host(`notebook-api.rspace.online`)" - - "traefik.http.routers.rspace-notebook-api.entrypoints=web" - - "traefik.http.routers.rspace-notebook-api.tls.certresolver=letsencrypt" - - "traefik.http.services.rspace-notebook-api.loadbalancer.server.port=5055" volumes: rspace-data: diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts index 45c4261..4efd152 100644 --- a/lib/canvas-tools.ts +++ b/lib/canvas-tools.ts @@ -470,6 +470,42 @@ registry.push( }, ); +// ── ASCII Art Tool ── +registry.push({ + declaration: { + name: "create_ascii_art", + description: "Generate ASCII art from patterns like plasma, mandelbrot, spiral, waves, nebula, kaleidoscope, aurora, lava, crystals, or fractal_tree.", + parameters: { + type: "object", + properties: { + prompt: { type: "string", description: "Pattern name or description of what to generate" }, + pattern: { + type: "string", + description: "Pattern type", + enum: ["plasma", "mandelbrot", "spiral", "waves", "nebula", "kaleidoscope", "aurora", "lava", "crystals", "fractal_tree", "random"], + }, + palette: { + type: "string", + description: "Character palette to use", + enum: ["classic", "blocks", "braille", "dots", "shades", "emoji", "cosmic", "runes", "geometric", "kanji", "hieroglyph", "alchemical"], + }, + width: { type: "number", description: "Width in characters (default 80)" }, + height: { type: "number", description: "Height in characters (default 40)" }, + }, + required: ["prompt"], + }, + }, + tagName: "folk-ascii-gen", + buildProps: (args) => ({ + prompt: args.prompt, + ...(args.pattern ? { pattern: args.pattern } : {}), + ...(args.palette ? { palette: args.palette } : {}), + ...(args.width ? { width: args.width } : {}), + ...(args.height ? { height: args.height } : {}), + }), + actionLabel: (args) => `Generating ASCII art: ${args.prompt?.slice(0, 50) || args.pattern || "random"}`, +}); + // ── Design Agent Tool ── registry.push({ declaration: { diff --git a/lib/folk-ascii-gen.ts b/lib/folk-ascii-gen.ts new file mode 100644 index 0000000..6e69051 --- /dev/null +++ b/lib/folk-ascii-gen.ts @@ -0,0 +1,433 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const PATTERNS = [ + "plasma", "mandelbrot", "spiral", "waves", "nebula", "kaleidoscope", + "aurora", "lava", "crystals", "fractal_tree", "random", +] as const; + +const PALETTES = [ + "classic", "blocks", "braille", "dots", "shades", "hires", "ultra", "dense", + "emoji", "cosmic", "mystic", "runes", "geometric", "flora", "weather", + "wingdings", "zodiac", "chess", "arrows", "music", "box", "math", + "kanji", "thai", "arabic", "devanagari", "hieroglyph", "cuneiform", + "alchemical", "dominos", "mahjong", "dingbats", "playing", "yijing", +] as const; + +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: 420px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: linear-gradient(135deg, #22c55e, #14b8a6); + 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; + } + + .prompt-area { + padding: 12px; + border-bottom: 1px solid var(--rs-border, #e2e8f0); + } + + .prompt-input { + width: 100%; + padding: 8px 10px; + border: 2px solid var(--rs-input-border, #e2e8f0); + border-radius: 6px; + 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: #14b8a6; + } + + .controls { + display: flex; + gap: 6px; + margin-top: 8px; + flex-wrap: wrap; + } + + .controls select { + padding: 5px 8px; + border: 2px solid var(--rs-input-border, #e2e8f0); + border-radius: 6px; + font-size: 11px; + background: var(--rs-input-bg, #fff); + color: var(--rs-input-text, inherit); + cursor: pointer; + } + + .size-input { + width: 52px; + padding: 5px 6px; + border: 2px solid var(--rs-input-border, #e2e8f0); + border-radius: 6px; + font-size: 11px; + background: var(--rs-input-bg, #fff); + color: var(--rs-input-text, inherit); + text-align: center; + } + + .generate-btn { + padding: 6px 14px; + background: linear-gradient(135deg, #22c55e, #14b8a6); + color: white; + border: none; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; + margin-left: auto; + } + + .generate-btn:hover { opacity: 0.9; } + .generate-btn:disabled { opacity: 0.5; cursor: not-allowed; } + + .preview-area { + flex: 1; + overflow: auto; + padding: 8px; + display: flex; + flex-direction: column; + gap: 8px; + } + + .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; } + + .ascii-output { + font-family: "Courier New", Consolas, monospace; + font-size: 10px; + line-height: 1.1; + white-space: pre; + overflow: auto; + padding: 8px; + border-radius: 6px; + background: #1e1e2e; + color: #cdd6f4; + max-height: 100%; + } + + .ascii-output.light-bg { + background: #f8fafc; + color: #1e293b; + } + + .actions-bar { + display: flex; + gap: 6px; + padding: 6px 12px; + border-top: 1px solid var(--rs-border, #e2e8f0); + justify-content: flex-end; + } + + .action-btn { + padding: 4px 10px; + border: 1px solid var(--rs-border, #e2e8f0); + border-radius: 4px; + font-size: 11px; + background: var(--rs-bg-surface, #fff); + color: var(--rs-text-primary, #1e293b); + cursor: pointer; + } + + .action-btn:hover { + background: var(--rs-bg-hover, #f1f5f9); + } + + .loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px; + gap: 12px; + } + + .spinner { + width: 28px; + height: 28px; + border: 3px solid #e2e8f0; + border-top-color: #14b8a6; + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + + @keyframes spin { to { transform: rotate(360deg); } } + + .error { + color: #ef4444; + padding: 12px; + background: #fef2f2; + border-radius: 6px; + font-size: 13px; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-ascii-gen": FolkAsciiGen; + } +} + +export class FolkAsciiGen extends FolkShape { + static override tagName = "folk-ascii-gen"; + + static override portDescriptors = [ + { name: "prompt", type: "text" as const, direction: "input" as const }, + { name: "ascii", 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; + } + + #isLoading = false; + #error: string | null = null; + #htmlOutput: string | null = null; + #textOutput: string | null = null; + #promptInput: HTMLTextAreaElement | null = null; + #patternSelect: HTMLSelectElement | null = null; + #paletteSelect: HTMLSelectElement | null = null; + #widthInput: HTMLInputElement | null = null; + #heightInput: HTMLInputElement | null = null; + #previewArea: HTMLElement | null = null; + #generateBtn: HTMLButtonElement | null = null; + #actionsBar: HTMLElement | null = null; + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + + ASCII Art + +
+ +
+
+
+
+ +
+ + + + × + + +
+
+
+
+ + Pick a pattern and click Generate +
+
+ +
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) containerDiv.replaceWith(wrapper); + + this.#promptInput = wrapper.querySelector(".prompt-input"); + this.#patternSelect = wrapper.querySelector(".pattern-select"); + this.#paletteSelect = wrapper.querySelector(".palette-select"); + this.#widthInput = wrapper.querySelector('input[title="Width"]'); + this.#heightInput = wrapper.querySelector('input[title="Height"]'); + this.#previewArea = wrapper.querySelector(".preview-area"); + this.#generateBtn = wrapper.querySelector(".generate-btn"); + this.#actionsBar = wrapper.querySelector(".actions-bar"); + + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + const copyTextBtn = wrapper.querySelector(".copy-text-btn") as HTMLButtonElement; + const copyHtmlBtn = wrapper.querySelector(".copy-html-btn") as HTMLButtonElement; + + this.#generateBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#generate(); + }); + + this.#promptInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.#generate(); + } + }); + + copyTextBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.#textOutput) navigator.clipboard.writeText(this.#textOutput); + }); + + copyHtmlBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.#htmlOutput) navigator.clipboard.writeText(this.#htmlOutput); + }); + + closeBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent canvas drag + this.#previewArea?.addEventListener("pointerdown", (e) => e.stopPropagation()); + this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); + + return root; + } + + async #generate() { + if (this.#isLoading) return; + + const pattern = this.#patternSelect?.value || "plasma"; + const prompt = this.#promptInput?.value.trim() || pattern; + const palette = this.#paletteSelect?.value || "classic"; + const width = parseInt(this.#widthInput?.value || "80") || 80; + const height = parseInt(this.#heightInput?.value || "40") || 40; + + this.#isLoading = true; + this.#error = null; + if (this.#generateBtn) this.#generateBtn.disabled = true; + this.#renderLoading(); + + try { + const res = await fetch("/api/ascii-gen", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt, pattern, palette, width, height, output_format: "html" }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + throw new Error(data.error || `HTTP ${res.status}`); + } + + const data = await res.json(); + this.#htmlOutput = data.html || null; + this.#textOutput = data.text || null; + this.#renderResult(); + + // Emit to output port + if (this.#textOutput) { + this.dispatchEvent(new CustomEvent("port-output", { + detail: { port: "ascii", value: this.#textOutput }, + bubbles: true, + })); + } + } catch (e: any) { + this.#error = e.message || "Generation failed"; + this.#renderError(); + } finally { + this.#isLoading = false; + if (this.#generateBtn) this.#generateBtn.disabled = false; + } + } + + #renderLoading() { + if (!this.#previewArea) return; + this.#previewArea.innerHTML = `
Generating...
`; + if (this.#actionsBar) this.#actionsBar.style.display = "none"; + } + + #renderError() { + if (!this.#previewArea) return; + this.#previewArea.innerHTML = `
${this.#error}
`; + if (this.#actionsBar) this.#actionsBar.style.display = "none"; + } + + #renderResult() { + if (!this.#previewArea) return; + if (this.#htmlOutput) { + this.#previewArea.innerHTML = `
${this.#htmlOutput}
`; + } else if (this.#textOutput) { + const el = document.createElement("div"); + el.className = "ascii-output"; + el.textContent = this.#textOutput; + this.#previewArea.innerHTML = ""; + this.#previewArea.appendChild(el); + } else { + this.#previewArea.innerHTML = `
No output received
`; + } + if (this.#actionsBar) this.#actionsBar.style.display = "flex"; + } +} diff --git a/lib/folk-video-gen.ts b/lib/folk-video-gen.ts index 207988b..8a62021 100644 --- a/lib/folk-video-gen.ts +++ b/lib/folk-video-gen.ts @@ -468,33 +468,59 @@ export class FolkVideoGen extends FolkShape { ? { image: this.#sourceImage, prompt, duration } : { prompt, duration }; - const response = await fetch(endpoint, { + const submitRes = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); - if (!response.ok) { - throw new Error(`Generation failed: ${response.statusText}`); + if (!submitRes.ok) { + const err = await submitRes.json().catch(() => ({})); + throw new Error(err.error || `Generation failed: ${submitRes.statusText}`); } - const result = await response.json(); + const submitData = await submitRes.json(); - const video: GeneratedVideo = { - id: crypto.randomUUID(), - prompt, - url: result.url || result.video_url, - sourceImage: this.#mode === "i2v" ? this.#sourceImage || undefined : undefined, - duration, - timestamp: new Date(), - }; + // Poll for job completion (up to 5 minutes) + const jobId = submitData.job_id; + if (!jobId) throw new Error("No job ID returned"); - this.#videos.unshift(video); - this.#renderVideos(); - this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } })); + const deadline = Date.now() + 300_000; + let elapsed = 0; - // Clear input - if (this.#promptInput) this.#promptInput.value = ""; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 3000)); + elapsed += 3; + this.#progress = Math.min(90, (elapsed / 120) * 100); + this.#renderLoading(); + + const pollRes = await fetch(`/api/video-gen/${jobId}`); + if (!pollRes.ok) continue; + const pollData = await pollRes.json(); + + if (pollData.status === "complete") { + const video: GeneratedVideo = { + id: crypto.randomUUID(), + prompt, + url: pollData.url || pollData.video_url, + sourceImage: this.#mode === "i2v" ? this.#sourceImage || undefined : undefined, + duration, + timestamp: new Date(), + }; + + this.#videos.unshift(video); + this.#renderVideos(); + this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } })); + if (this.#promptInput) this.#promptInput.value = ""; + return; + } + + if (pollData.status === "failed") { + throw new Error(pollData.error || "Video generation failed"); + } + } + + throw new Error("Video generation timed out"); } catch (error) { this.#error = error instanceof Error ? error.message : "Generation failed"; this.#renderError(); diff --git a/lib/index.ts b/lib/index.ts index 3d6f9d7..d293d0e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -51,6 +51,7 @@ export * from "./folk-drawfast"; export * from "./folk-freecad"; export * from "./folk-kicad"; export * from "./folk-design-agent"; +export * from "./folk-ascii-gen"; // Advanced Shapes export * from "./folk-video-chat"; diff --git a/lib/mi-triage-panel.ts b/lib/mi-triage-panel.ts index 35960a1..2f5ec91 100644 --- a/lib/mi-triage-panel.ts +++ b/lib/mi-triage-panel.ts @@ -39,6 +39,7 @@ const SHAPE_ICONS: Record = { "folk-freecad": { icon: "🔧", label: "CAD" }, "folk-kicad": { icon: "🔌", label: "PCB" }, "folk-design-agent": { icon: "🖨️", label: "Design" }, + "folk-ascii-gen": { icon: "▦", label: "ASCII Art" }, // Social "folk-social-post": { icon: "📣", label: "Social Post" }, "folk-social-thread": { icon: "🧵", label: "Thread" }, diff --git a/modules/rdesign/mod.ts b/modules/rdesign/mod.ts index 93020c6..c2f8e81 100644 --- a/modules/rdesign/mod.ts +++ b/modules/rdesign/mod.ts @@ -10,6 +10,7 @@ import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { designAgentRoutes } from "./design-agent-route"; +import { ensureSidecar } from "../../server/sidecar-manager"; const routes = new Hono(); @@ -25,6 +26,7 @@ routes.get("/api/health", (c) => { // Proxy bridge API calls from rspace to the Scribus container routes.all("/api/bridge/*", async (c) => { + await ensureSidecar("scribus-novnc"); const path = c.req.path.replace(/^.*\/api\/bridge/, "/api/scribus"); const bridgeSecret = process.env.SCRIBUS_BRIDGE_SECRET || ""; const headers: Record = { "Content-Type": "application/json" }; diff --git a/server/index.ts b/server/index.ts index 9ae3b22..9b91c3e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1046,6 +1046,124 @@ setInterval(() => { } }, 30 * 60 * 1000); +// ── Video generation job queue (async to avoid Cloudflare timeouts) ── + +interface VideoGenJob { + id: string; + status: "pending" | "processing" | "complete" | "failed"; + type: "t2v" | "i2v"; + prompt: string; + sourceImage?: string; + resultUrl?: string; + error?: string; + createdAt: number; + completedAt?: number; +} + +const videoGenJobs = new Map(); + +// Clean up old video jobs every 30 minutes (keep for 6h) +setInterval(() => { + const cutoff = Date.now() - 6 * 60 * 60 * 1000; + for (const [id, job] of videoGenJobs) { + if (job.createdAt < cutoff) videoGenJobs.delete(id); + } +}, 30 * 60 * 1000); + +async function processVideoGenJob(job: VideoGenJob) { + job.status = "processing"; + const falHeaders = { Authorization: `Key ${FAL_KEY}`, "Content-Type": "application/json" }; + + try { + const MODEL = job.type === "i2v" + ? "fal-ai/kling-video/v1/standard/image-to-video" + : "fal-ai/wan-t2v"; + + const body = job.type === "i2v" + ? { image_url: job.sourceImage, prompt: job.prompt || "", duration: 5, aspect_ratio: "16:9" } + : { prompt: job.prompt, num_frames: 81, resolution: "480p" }; + + // Submit to fal.ai queue + const submitRes = await fetch(`https://queue.fal.run/${MODEL}`, { + method: "POST", + headers: falHeaders, + body: JSON.stringify(body), + }); + + if (!submitRes.ok) { + const errText = await submitRes.text(); + console.error(`[video-gen] fal.ai submit error (${job.type}):`, submitRes.status, errText); + job.status = "failed"; + job.error = "Video generation failed to start"; + job.completedAt = Date.now(); + return; + } + + const { request_id } = await submitRes.json() as { request_id: string }; + + // Poll for completion (up to 5 min) + const deadline = Date.now() + 300_000; + let responseUrl = ""; + let completed = false; + + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 3000)); + const statusRes = await fetch( + `https://queue.fal.run/${MODEL}/requests/${request_id}/status`, + { headers: falHeaders }, + ); + if (!statusRes.ok) continue; + const statusData = await statusRes.json() as { status: string; response_url?: string }; + console.log(`[video-gen] Poll ${job.id}: status=${statusData.status}`); + if (statusData.response_url) responseUrl = statusData.response_url; + if (statusData.status === "COMPLETED") { completed = true; break; } + if (statusData.status === "FAILED") { + job.status = "failed"; + job.error = "Video generation failed on fal.ai"; + job.completedAt = Date.now(); + return; + } + } + + if (!completed) { + job.status = "failed"; + job.error = "Video generation timed out"; + job.completedAt = Date.now(); + return; + } + + // Fetch result + const resultUrl = responseUrl || `https://queue.fal.run/${MODEL}/requests/${request_id}`; + const resultRes = await fetch(resultUrl, { headers: falHeaders }); + if (!resultRes.ok) { + job.status = "failed"; + job.error = "Failed to retrieve video"; + job.completedAt = Date.now(); + return; + } + + const data = await resultRes.json(); + const videoUrl = data.video?.url || data.output?.url; + if (!videoUrl) { + console.error(`[video-gen] No video URL in response:`, JSON.stringify(data).slice(0, 500)); + job.status = "failed"; + job.error = "No video returned"; + job.completedAt = Date.now(); + return; + } + + job.status = "complete"; + job.resultUrl = videoUrl; + job.completedAt = Date.now(); + console.log(`[video-gen] Job ${job.id} complete: ${videoUrl}`); + } catch (e: any) { + console.error("[video-gen] error:", e.message); + job.status = "failed"; + job.error = "Video generation failed"; + job.completedAt = Date.now(); + } +} + let splatMailTransport: ReturnType | null = null; if (process.env.SMTP_PASS) { splatMailTransport = createTransport({ @@ -1496,48 +1614,67 @@ app.post("/api/image-gen/img2img", async (c) => { return c.json({ error: `Unknown provider: ${provider}` }, 400); }); -// Text-to-video via fal.ai WAN 2.1 (delegates to shared helper) +// Text-to-video via fal.ai WAN 2.1 (async job queue to avoid Cloudflare timeouts) app.post("/api/video-gen/t2v", async (c) => { + if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); + const { prompt } = await c.req.json(); if (!prompt) return c.json({ error: "prompt required" }, 400); - const { generateVideoViaFal } = await import("./mi-media"); - const result = await generateVideoViaFal(prompt); - if (!result.ok) return c.json({ error: result.error }, 502); - return c.json({ url: result.url, video_url: result.url }); + const jobId = crypto.randomUUID(); + const job: VideoGenJob = { + id: jobId, status: "pending", type: "t2v", + prompt, createdAt: Date.now(), + }; + videoGenJobs.set(jobId, job); + processVideoGenJob(job); + return c.json({ job_id: jobId, status: "pending" }); }); -// Image-to-video via fal.ai Kling +// Image-to-video via fal.ai Kling (async job queue) app.post("/api/video-gen/i2v", async (c) => { if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503); - const { image, prompt, duration } = await c.req.json(); + const { image, prompt } = await c.req.json(); if (!image) return c.json({ error: "image required" }, 400); - const res = await fetch("https://fal.run/fal-ai/kling-video/v1/standard/image-to-video", { - method: "POST", - headers: { - Authorization: `Key ${FAL_KEY}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - image_url: image, - prompt: prompt || "", - duration: duration === "5s" ? "5" : "5", - }), - }); - - if (!res.ok) { - const err = await res.text(); - console.error("[video-gen/i2v] fal.ai error:", err); - return c.json({ error: "Video generation failed" }, 502); + // Stage the source image if it's a data URL + let imageUrl = image; + if (image.startsWith("data:")) { + const url = await saveDataUrlToDisk(image, "vid-src"); + imageUrl = publicUrl(c, url); + } else if (image.startsWith("/")) { + imageUrl = publicUrl(c, image); } - const data = await res.json(); - const videoUrl = data.video?.url || data.output?.url; - if (!videoUrl) return c.json({ error: "No video returned" }, 502); + const jobId = crypto.randomUUID(); + const job: VideoGenJob = { + id: jobId, status: "pending", type: "i2v", + prompt: prompt || "", sourceImage: imageUrl, createdAt: Date.now(), + }; + videoGenJobs.set(jobId, job); + processVideoGenJob(job); + return c.json({ job_id: jobId, status: "pending" }); +}); - return c.json({ url: videoUrl, video_url: videoUrl }); +// Poll video generation job status +app.get("/api/video-gen/:jobId", async (c) => { + const jobId = c.req.param("jobId"); + const job = videoGenJobs.get(jobId); + if (!job) return c.json({ error: "Job not found" }, 404); + + const response: Record = { + job_id: job.id, status: job.status, created_at: job.createdAt, + }; + if (job.status === "complete") { + response.url = job.resultUrl; + response.video_url = job.resultUrl; + response.completed_at = job.completedAt; + } else if (job.status === "failed") { + response.error = job.error; + response.completed_at = job.completedAt; + } + return c.json(response); }); // Stage image for 3D generation (binary upload → HTTPS URL for fal.ai) @@ -1749,6 +1886,59 @@ Output ONLY the Python code, no explanations or comments outside the code.`); } }); +// ── ASCII Art Generation (proxies to ascii-art service on rspace-internal) ── +const ASCII_ART_URL = process.env.ASCII_ART_URL || "http://ascii-art:8000"; + +app.post("/api/ascii-gen", async (c) => { + const body = await c.req.json(); + const { prompt, width, height, palette, output_format } = body; + if (!prompt) return c.json({ error: "prompt required" }, 400); + + try { + const res = await fetch(`${ASCII_ART_URL}/api/pattern`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + prompt, + width: width || 80, + height: height || 40, + palette: palette || "ansi", + output_format: output_format || "html", + }), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + const err = await res.text(); + return c.json({ error: `ASCII art service error: ${err}` }, res.status as any); + } + const data = await res.json(); + return c.json(data); + } catch (e: any) { + console.error("[ascii-gen] error:", e); + return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502); + } +}); + +app.post("/api/ascii-gen/render", async (c) => { + try { + const res = await fetch(`${ASCII_ART_URL}/api/render`, { + method: "POST", + headers: { "Content-Type": c.req.header("Content-Type") || "application/json" }, + body: await c.req.arrayBuffer(), + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) { + const err = await res.text(); + return c.json({ error: `ASCII render error: ${err}` }, res.status as any); + } + const data = await res.json(); + return c.json(data); + } catch (e: any) { + console.error("[ascii-gen] render error:", e); + return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502); + } +}); + // KiCAD PCB design — MCP StreamableHTTP bridge (sidecar container) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; diff --git a/server/mi-media.ts b/server/mi-media.ts index 3b2c696..3d3e237 100644 --- a/server/mi-media.ts +++ b/server/mi-media.ts @@ -164,8 +164,8 @@ export async function generateVideoViaFal(prompt: string, source_image?: string) return { ok: true, url: videoUrl }; } - // Text-to-video via WAN 2.1 - const res = await fetch("https://fal.run/fal-ai/wan/v2.1", { + // Text-to-video via WAN 2.1 (fal.ai renamed endpoint from wan/v2.1 to wan-t2v) + const res = await fetch("https://fal.run/fal-ai/wan-t2v", { method: "POST", headers: { Authorization: `Key ${FAL_KEY}`, @@ -173,7 +173,7 @@ export async function generateVideoViaFal(prompt: string, source_image?: string) }, body: JSON.stringify({ prompt, - num_frames: 49, + num_frames: 81, resolution: "480p", }), }); diff --git a/server/sidecar-manager.ts b/server/sidecar-manager.ts index ca0da6e..3b47fa2 100644 --- a/server/sidecar-manager.ts +++ b/server/sidecar-manager.ts @@ -45,6 +45,18 @@ const SIDECARS: Record = { port: 11434, healthTimeout: 30_000, }, + "scribus-novnc": { + container: "scribus-novnc", + host: "scribus-novnc", + port: 8765, + healthTimeout: 30_000, + }, + "open-notebook": { + container: "open-notebook", + host: "open-notebook", + port: 5055, + healthTimeout: 45_000, + }, }; const lastUsed = new Map(); @@ -61,14 +73,17 @@ try { // ── Docker Engine API over Unix socket ── -function dockerApi(method: string, path: string): Promise<{ status: number; body: any }> { +function dockerApi(method: string, path: string, sendBody?: boolean): Promise<{ status: number; body: any }> { return new Promise((resolve, reject) => { + const headers: Record = {}; + // Only set Content-Type when we actually send a JSON body + if (sendBody) headers["Content-Type"] = "application/json"; const req = http.request( { socketPath: DOCKER_SOCKET, path: `/v1.43${path}`, method, - headers: { "Content-Type": "application/json" }, + headers, }, (res) => { let data = ""; @@ -100,10 +115,11 @@ async function isContainerRunning(name: string): Promise { } async function startContainer(name: string): Promise { - const { status } = await dockerApi("POST", `/containers/${name}/start`); + const { status, body } = await dockerApi("POST", `/containers/${name}/start`); // 204 = started, 304 = already running if (status !== 204 && status !== 304) { - throw new Error(`Failed to start ${name}: HTTP ${status}`); + const detail = typeof body === "object" ? JSON.stringify(body) : body; + throw new Error(`Failed to start ${name}: HTTP ${status} — ${detail}`); } } diff --git a/types/hono.d.ts b/types/hono.d.ts index fa5b728..f1c9623 100644 --- a/types/hono.d.ts +++ b/types/hono.d.ts @@ -5,6 +5,7 @@ declare module 'hono' { effectiveSpace: string; spaceRole: string; isOwner: boolean; + isSubdomain: boolean; x402Payment: string; x402Scheme: string; } From d4612d6fb8fe730c42f00aba3aa2cd4f686febe0 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 14:31:22 -0700 Subject: [PATCH 02/11] fix(ascii-gen): handle raw HTML response from ascii-art service The ascii-art service returns raw HTML, not JSON. Wrap response in {html, text} JSON envelope and strip tags for plain text version. Co-Authored-By: Claude Opus 4.6 --- server/index.ts | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/server/index.ts b/server/index.ts index 9b91c3e..32deafa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1902,7 +1902,7 @@ app.post("/api/ascii-gen", async (c) => { prompt, width: width || 80, height: height || 40, - palette: palette || "ansi", + palette: palette || "classic", output_format: output_format || "html", }), signal: AbortSignal.timeout(30_000), @@ -1911,8 +1911,11 @@ app.post("/api/ascii-gen", async (c) => { const err = await res.text(); return c.json({ error: `ASCII art service error: ${err}` }, res.status as any); } - const data = await res.json(); - return c.json(data); + // Service returns raw HTML — wrap in JSON for the client + const htmlContent = await res.text(); + // Strip HTML tags to get plain text version + const textContent = htmlContent.replace(/<[^>]*>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); + return c.json({ html: htmlContent, text: textContent }); } catch (e: any) { console.error("[ascii-gen] error:", e); return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502); @@ -1931,8 +1934,9 @@ app.post("/api/ascii-gen/render", async (c) => { const err = await res.text(); return c.json({ error: `ASCII render error: ${err}` }, res.status as any); } - const data = await res.json(); - return c.json(data); + const htmlContent = await res.text(); + const textContent = htmlContent.replace(/<[^>]*>/g, "").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&"); + return c.json({ html: htmlContent, text: textContent }); } catch (e: any) { console.error("[ascii-gen] render error:", e); return c.json({ error: `ASCII art service unavailable: ${e.message}` }, 502); From 0337797b7cdc42919c69f58930d4be0402834cee Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 14:35:45 -0700 Subject: [PATCH 03/11] fix(rtime): enable touch drag for commitment pool orbs on mobile Add touch-action:none on canvas and preventDefault on pointerdown to prevent the browser from claiming touch events for scroll/pan gestures. Co-Authored-By: Claude Opus 4.6 --- lib/folk-commitment-pool.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/folk-commitment-pool.ts b/lib/folk-commitment-pool.ts index b4499cb..915eedc 100644 --- a/lib/folk-commitment-pool.ts +++ b/lib/folk-commitment-pool.ts @@ -271,6 +271,7 @@ export class FolkCommitmentPool extends FolkShape { if (container) container.replaceWith(this.#wrapper); this.#canvas = this.#wrapper.querySelector("canvas")!; + this.#canvas.style.touchAction = "none"; // prevent browser scroll/pan on touch drag this.#ctx = this.#canvas.getContext("2d")!; this.#canvas.addEventListener("pointermove", this.#onPointerMove); @@ -354,8 +355,9 @@ export class FolkCommitmentPool extends FolkShape { const orb = this.#findOrbAt(x, y); if (!orb) return; - // Prevent FolkShape from starting a shape-move + // Prevent FolkShape from starting a shape-move + browser scroll/pan on touch e.stopPropagation(); + e.preventDefault(); this.#draggingOrb = orb; this.#ripples.push(new Ripple(orb.x, orb.y, orb.color)); From 4d06156e5f40177bf74a2811ecee9d339091f700 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 14:41:05 -0700 Subject: [PATCH 04/11] fix(rtime): scale commitment orbs relative to basket size Orb radius now uses 8-15% of basket radius with sqrt(hours) scaling instead of fixed pixel sizes (18+hours*9). Prevents orbs from being oversized on small baskets or undersized on large ones. Hover expansion also scales proportionally (15% instead of fixed 5px). Co-Authored-By: Claude Opus 4.6 --- lib/folk-commitment-pool.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/lib/folk-commitment-pool.ts b/lib/folk-commitment-pool.ts index 915eedc..5376a08 100644 --- a/lib/folk-commitment-pool.ts +++ b/lib/folk-commitment-pool.ts @@ -48,7 +48,11 @@ class Orb { constructor(c: PoolCommitment, cx: number, cy: number, r: number) { this.c = c; - this.baseRadius = 18 + c.hours * 9; + // Scale orb radius relative to basket size so they always fit + // Base: 8-15% of basket radius, scaled by sqrt(hours) to avoid giant orbs + const minR = r * 0.08; + const maxR = r * 0.15; + this.baseRadius = Math.min(maxR, minR + (maxR - minR) * (Math.sqrt(c.hours) / Math.sqrt(10))); this.radius = this.baseRadius; const a = Math.random() * Math.PI * 2; const d = Math.random() * (r - this.baseRadius - 10); @@ -84,7 +88,7 @@ class Orb { const isH = hovered === this; this.hoverT += ((isH ? 1 : 0) - this.hoverT) * 0.12; - this.radius = this.baseRadius + this.hoverT * 5; + this.radius = this.baseRadius * (1 + this.hoverT * 0.15); if (this.opacity < 1) this.opacity = Math.min(1, this.opacity + 0.025); } @@ -318,9 +322,19 @@ export class FolkCommitmentPool extends FolkShape { const emptyMsg = this.#wrapper.querySelector(".empty-msg") as HTMLElement; if (emptyMsg) emptyMsg.style.display = commitments.length === 0 ? "flex" : "none"; - // Preserve existing orbs by commitment ID + // Preserve existing orbs by commitment ID, rescale to current basket size const existing = new Map(this.#orbs.map(o => [o.c.id, o])); - this.#orbs = commitments.map(c => existing.get(c.id) || new Orb(c, cx, cy, r)); + this.#orbs = commitments.map(c => { + const old = existing.get(c.id); + if (old) { + // Rescale existing orb to current basket radius + const minR = r * 0.08; + const maxR = r * 0.15; + old.baseRadius = Math.min(maxR, minR + (maxR - minR) * (Math.sqrt(c.hours) / Math.sqrt(10))); + return old; + } + return new Orb(c, cx, cy, r); + }); } // ── Canvas coord helpers ── From 15990cc147b4e0c542a762015d75b45ad5c0ab5f Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 14:48:48 -0700 Subject: [PATCH 05/11] fix(rcart): infer Transak fiat amount from crypto amount for stablecoins When fiatAmount wasn't explicitly set on a payment request, Transak defaulted to 300 USD. Now for USDC/USDT/DAI/cUSDC, the fiat amount is inferred from the crypto amount (1:1 peg) so the widget opens with the correct payment amount. Co-Authored-By: Claude Opus 4.6 --- modules/rcart/mod.ts | 38 ++++++++++++++++++++++++-------------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 38daf12..34d46fe 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -32,6 +32,9 @@ import { import { extractProductFromUrl } from './extract'; import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak'; import { createMoonPayPaymentUrl, getMoonPayApiKey, getMoonPayEnv } from '../../shared/moonpay'; + +/** Tokens pegged 1:1 to USD — fiat amount can be inferred from crypto amount */ +const USD_STABLECOINS = ['USDC', 'USDT', 'DAI', 'cUSDC']; import QRCode from 'qrcode'; import { createTransport, type Transporter } from "nodemailer"; import { @@ -1781,14 +1784,16 @@ routes.post("/api/payments/:id/transak-session", async (c) => { hideMenu: 'true', }; - // Pre-fill fiat amount/currency so user sees the total immediately - if (p.fiatAmount) { - widgetParams.fiatAmount = p.fiatAmount; - widgetParams.defaultFiatAmount = p.fiatAmount; + // Derive fiat amount: use explicit fiatAmount, or infer from crypto amount for stablecoins + const inferredFiat = p.fiatAmount || (USD_STABLECOINS.includes(p.token) ? effectiveAmount : null); + if (inferredFiat) { + widgetParams.fiatAmount = inferredFiat; + widgetParams.defaultFiatAmount = inferredFiat; } - if (p.fiatCurrency) { - widgetParams.fiatCurrency = p.fiatCurrency; - widgetParams.defaultFiatCurrency = p.fiatCurrency; + const fiatCcy = p.fiatCurrency || (USD_STABLECOINS.includes(p.token) ? 'USD' : null); + if (fiatCcy) { + widgetParams.fiatCurrency = fiatCcy; + widgetParams.defaultFiatCurrency = fiatCcy; } const widgetUrl = await createSecureWidgetUrl(widgetParams); @@ -1855,13 +1860,18 @@ routes.post("/api/payments/:id/card-session", async (c) => { themeColor: '6366f1', hideMenu: 'true', }; - if (p.fiatAmount) { - widgetParams.fiatAmount = p.fiatAmount; - widgetParams.defaultFiatAmount = p.fiatAmount; - } - if (p.fiatCurrency) { - widgetParams.fiatCurrency = p.fiatCurrency; - widgetParams.defaultFiatCurrency = p.fiatCurrency; + // Derive fiat amount: use explicit fiatAmount, or infer from crypto amount for stablecoins + { + const inferredFiat = p.fiatAmount || (USD_STABLECOINS.includes(p.token) ? effectiveAmount : null); + if (inferredFiat) { + widgetParams.fiatAmount = inferredFiat; + widgetParams.defaultFiatAmount = inferredFiat; + } + const fiatCcy = p.fiatCurrency || (USD_STABLECOINS.includes(p.token) ? 'USD' : null); + if (fiatCcy) { + widgetParams.fiatCurrency = fiatCcy; + widgetParams.defaultFiatCurrency = fiatCcy; + } } const widgetUrl = await createSecureWidgetUrl(widgetParams); From f9dc06394c05a3b88c0333573f2fdb9062e253aa Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 14:52:28 -0700 Subject: [PATCH 06/11] fix(rcart): force Transak dark mode with colorMode param Text inputs were rendering in light mode making text invisible against the dark background. Added colorMode: 'DARK' to both Transak widget endpoints. Co-Authored-By: Claude Opus 4.6 --- modules/rcart/mod.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 34d46fe..08bc880 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -1781,6 +1781,7 @@ routes.post("/api/payments/:id/transak-session", async (c) => { partnerOrderId: `pay-${paymentId}`, email, themeColor: '6366f1', + colorMode: 'DARK', hideMenu: 'true', }; @@ -1858,6 +1859,7 @@ routes.post("/api/payments/:id/card-session", async (c) => { partnerOrderId: `pay-${paymentId}`, email, themeColor: '6366f1', + colorMode: 'DARK', hideMenu: 'true', }; // Derive fiat amount: use explicit fiatAmount, or infer from crypto amount for stablecoins From 55067729b12b86e7481378106cc35e4af2193915 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 14:58:35 -0700 Subject: [PATCH 07/11] fix(rcart): graceful server-side page for paid/expired/cancelled payments When a payment request is in a terminal state (paid, confirmed, expired, cancelled, filled), the /pay/:id route now renders a static HTML page with a clear message instead of loading the full JS component. Prevents "corrupted content error" and shows a friendly "already paid" message. Co-Authored-By: Claude Opus 4.6 --- modules/rcart/mod.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 08bc880..6467fa5 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -2535,6 +2535,49 @@ routes.get("/request", (c) => { routes.get("/pay/:id", (c) => { const space = c.req.param("space") || "demo"; const paymentId = c.req.param("id"); + + // Check payment status server-side for graceful terminal-state messages + const docId = paymentRequestDocId(space, paymentId); + const doc = _syncServer?.getDoc(docId); + if (doc) { + const p = doc.payment; + const terminalStates: Record = { + paid: { title: 'Payment Complete', msg: 'This payment request has already been paid.', icon: '✓' }, + confirmed: { title: 'Payment Confirmed', msg: 'This payment has been confirmed on-chain.', icon: '✓' }, + expired: { title: 'Payment Expired', msg: 'This payment request has expired and is no longer accepting payments.', icon: '⏲' }, + cancelled: { title: 'Payment Cancelled', msg: 'This payment request has been cancelled by the creator.', icon: '✗' }, + filled: { title: 'Payment Limit Reached', msg: 'This payment request has reached its maximum number of payments.', icon: '✓' }, + }; + const info = terminalStates[p.status]; + if (info) { + const chainNames: Record = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' }; + const explorerBase: Record = { 8453: 'https://basescan.org/tx/', 84532: 'https://sepolia.basescan.org/tx/', 1: 'https://etherscan.io/tx/' }; + const txLink = p.txHash && explorerBase[p.chainId] + ? `${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}` + : ''; + return c.html(renderShell({ + title: `${info.title} | rCart`, + moduleId: "rcart", + spaceSlug: space, + spaceVisibility: "public", + modules: getModuleInfoList(), + theme: "dark", + body: ` +
+
${info.icon}
+

${info.title}

+

${info.msg}

+ ${p.amount && p.amount !== '0' ? `
${p.amount} ${p.token}
` : ''} + ${p.fiatAmount ? `
≈ $${p.fiatAmount} ${p.fiatCurrency || 'USD'}
` : ''} + ${chainNames[p.chainId] ? `
Network: ${chainNames[p.chainId]}
` : ''} + ${txLink ? `
Tx: ${txLink}
` : ''} + ${p.paidAt ? `
Paid: ${new Date(p.paidAt).toLocaleString()}
` : ''} +
`, + styles: ``, + })); + } + } + return c.html(renderShell({ title: `Payment | rCart`, moduleId: "rcart", From 4919ca1021110b88b72756ff7a0653884f6c4c56 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 15:06:57 -0700 Subject: [PATCH 08/11] fix(rcart): use port 25 internal relay for payment emails SMTP auth credentials were stale, causing all payment confirmation emails to silently fail. Since rspace is on the mailcow Docker network, use unauthenticated relay on port 25 instead of port 587 with auth. Co-Authored-By: Claude Opus 4.6 --- modules/rcart/mod.ts | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index 6467fa5..e67a3d7 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -49,15 +49,21 @@ let _smtpTransport: Transporter | null = null; function getSmtpTransport(): Transporter | null { if (_smtpTransport) return _smtpTransport; - if (!process.env.SMTP_PASS) return null; + const host = process.env.SMTP_HOST || "mail.rmail.online"; + const isInternal = host.includes('mailcow') || host.includes('postfix'); + if (!process.env.SMTP_PASS && !isInternal) return null; + // Internal mailcow network: relay on port 25 without auth + // External: use port 587 with STARTTLS + auth _smtpTransport = createTransport({ - host: process.env.SMTP_HOST || "mail.rmail.online", - port: Number(process.env.SMTP_PORT) || 587, - secure: Number(process.env.SMTP_PORT) === 465, - auth: { - user: process.env.SMTP_USER || "noreply@rmail.online", - pass: process.env.SMTP_PASS, - }, + host, + port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587), + secure: !isInternal && Number(process.env.SMTP_PORT) === 465, + ...(isInternal ? {} : { + auth: { + user: process.env.SMTP_USER || "noreply@rmail.online", + pass: process.env.SMTP_PASS!, + }, + }), tls: { rejectUnauthorized: false }, }); return _smtpTransport; From 42546c9a63542aba3a8bdb7aa636068c5f44dc38 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 15:12:42 -0700 Subject: [PATCH 09/11] fix: use internal mailcow relay (port 25) for all SMTP transports SMTP auth on port 587 was broken across all modules due to stale credentials. Since rspace is on the mailcow Docker network, all 6 SMTP transports now use unauthenticated relay on port 25 when the host is the internal postfix container. Fixes emails for: payment receipts, space invitations, inbox approvals, agent notifications, scheduled emails, and publication sharing. Co-Authored-By: Claude Opus 4.6 --- modules/rinbox/agent-notify.ts | 8 +++++--- modules/rinbox/mod.ts | 10 ++++++---- modules/rpubs/mod.ts | 20 ++++++++++++-------- modules/rschedule/mod.ts | 20 ++++++++++++-------- server/notification-service.ts | 9 +++++---- server/spaces.ts | 28 +++++++++++++++++----------- 6 files changed, 57 insertions(+), 38 deletions(-) diff --git a/modules/rinbox/agent-notify.ts b/modules/rinbox/agent-notify.ts index dc574af..97c2b99 100644 --- a/modules/rinbox/agent-notify.ts +++ b/modules/rinbox/agent-notify.ts @@ -18,11 +18,13 @@ async function getSmtpTransport() { try { const nodemailer = await import("nodemailer"); const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport; + const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix'); _transport = createTransport({ host: SMTP_HOST, - port: SMTP_PORT, - secure: SMTP_PORT === 465, - auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, + port: isInternal ? 25 : SMTP_PORT, + secure: !isInternal && SMTP_PORT === 465, + ...(isInternal ? {} : SMTP_USER ? { auth: { user: SMTP_USER, pass: SMTP_PASS } } : {}), + tls: { rejectUnauthorized: false }, }); return _transport; } catch (e) { diff --git a/modules/rinbox/mod.ts b/modules/rinbox/mod.ts index ebf8751..c43729a 100644 --- a/modules/rinbox/mod.ts +++ b/modules/rinbox/mod.ts @@ -50,13 +50,15 @@ async function getSmtpTransport() { try { const nodemailer = await import("nodemailer"); const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport; + const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix'); _smtpTransport = createTransport({ host: SMTP_HOST, - port: SMTP_PORT, - secure: SMTP_PORT === 465, - auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, + port: isInternal ? 25 : SMTP_PORT, + secure: !isInternal && SMTP_PORT === 465, + ...(isInternal ? {} : SMTP_USER ? { auth: { user: SMTP_USER, pass: SMTP_PASS } } : {}), + tls: { rejectUnauthorized: false }, }); - console.log(`[Inbox] SMTP transport configured: ${SMTP_HOST}:${SMTP_PORT}`); + console.log(`[Inbox] SMTP transport configured: ${SMTP_HOST}:${isInternal ? 25 : SMTP_PORT}`); return _smtpTransport; } catch (e) { console.error("[Inbox] Failed to create SMTP transport:", e); diff --git a/modules/rpubs/mod.ts b/modules/rpubs/mod.ts index 117287d..f00627d 100644 --- a/modules/rpubs/mod.ts +++ b/modules/rpubs/mod.ts @@ -29,15 +29,19 @@ let _smtpTransport: Transporter | null = null; function getSmtpTransport(): Transporter | null { if (_smtpTransport) return _smtpTransport; - if (!process.env.SMTP_PASS) return null; + const host = process.env.SMTP_HOST || "mail.rmail.online"; + const isInternal = host.includes('mailcow') || host.includes('postfix'); + if (!process.env.SMTP_PASS && !isInternal) return null; _smtpTransport = createTransport({ - host: process.env.SMTP_HOST || "mail.rmail.online", - port: Number(process.env.SMTP_PORT) || 587, - secure: Number(process.env.SMTP_PORT) === 465, - auth: { - user: process.env.SMTP_USER || "noreply@rmail.online", - pass: process.env.SMTP_PASS, - }, + host, + port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587), + secure: !isInternal && Number(process.env.SMTP_PORT) === 465, + ...(isInternal ? {} : { + auth: { + user: process.env.SMTP_USER || "noreply@rmail.online", + pass: process.env.SMTP_PASS!, + }, + }), tls: { rejectUnauthorized: false }, }); return _smtpTransport; diff --git a/modules/rschedule/mod.ts b/modules/rschedule/mod.ts index c0bd8cf..45ceb48 100644 --- a/modules/rschedule/mod.ts +++ b/modules/rschedule/mod.ts @@ -52,15 +52,19 @@ let _smtpTransport: Transporter | null = null; function getSmtpTransport(): Transporter | null { if (_smtpTransport) return _smtpTransport; - if (!process.env.SMTP_PASS) return null; + const host = process.env.SMTP_HOST || "mail.rmail.online"; + const isInternal = host.includes('mailcow') || host.includes('postfix'); + if (!process.env.SMTP_PASS && !isInternal) return null; _smtpTransport = createTransport({ - host: process.env.SMTP_HOST || "mail.rmail.online", - port: Number(process.env.SMTP_PORT) || 587, - secure: Number(process.env.SMTP_PORT) === 465, - auth: { - user: process.env.SMTP_USER || "noreply@rmail.online", - pass: process.env.SMTP_PASS, - }, + host, + port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587), + secure: !isInternal && Number(process.env.SMTP_PORT) === 465, + ...(isInternal ? {} : { + auth: { + user: process.env.SMTP_USER || "noreply@rmail.online", + pass: process.env.SMTP_PASS!, + }, + }), tls: { rejectUnauthorized: false }, }); return _smtpTransport; diff --git a/server/notification-service.ts b/server/notification-service.ts index bf901a7..b9b7a77 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -43,15 +43,16 @@ let _smtpTransport: any = null; async function getSmtpTransport() { if (_smtpTransport) return _smtpTransport; - if (!SMTP_PASS) return null; + const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix'); + if (!SMTP_PASS && !isInternal) return null; try { const nodemailer = await import("nodemailer"); const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport; _smtpTransport = createTransport({ host: SMTP_HOST, - port: SMTP_PORT, - secure: SMTP_PORT === 465, - auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined, + port: isInternal ? 25 : SMTP_PORT, + secure: !isInternal && SMTP_PORT === 465, + ...(isInternal ? {} : SMTP_USER ? { auth: { user: SMTP_USER, pass: SMTP_PASS } } : {}), tls: { rejectUnauthorized: false }, }); console.log("[email] SMTP transport configured"); diff --git a/server/spaces.ts b/server/spaces.ts index 1970a9e..17c8b6e 100644 --- a/server/spaces.ts +++ b/server/spaces.ts @@ -2097,17 +2097,23 @@ spaces.post("/:slug/copy-shapes", async (c) => { let inviteTransport: Transporter | null = null; -if (process.env.SMTP_PASS) { - inviteTransport = createTransport({ - host: process.env.SMTP_HOST || "mail.rmail.online", - port: Number(process.env.SMTP_PORT) || 587, - secure: Number(process.env.SMTP_PORT) === 465, - auth: { - user: process.env.SMTP_USER || "noreply@rmail.online", - pass: process.env.SMTP_PASS, - }, - tls: { rejectUnauthorized: false }, - }); +{ + const host = process.env.SMTP_HOST || "mail.rmail.online"; + const isInternal = host.includes('mailcow') || host.includes('postfix'); + if (process.env.SMTP_PASS || isInternal) { + inviteTransport = createTransport({ + host, + port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587), + secure: !isInternal && Number(process.env.SMTP_PORT) === 465, + ...(isInternal ? {} : { + auth: { + user: process.env.SMTP_USER || "noreply@rmail.online", + pass: process.env.SMTP_PASS!, + }, + }), + tls: { rejectUnauthorized: false }, + }); + } } // ── Enhanced invite by email (with token + role) ── From 5f2b3fd8d140f1e55d5c43a80b84a7c7d222a518 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 16:04:59 -0700 Subject: [PATCH 10/11] =?UTF-8?q?fix(rcart):=20streamline=20Transak=20widg?= =?UTF-8?q?et=20=E2=80=94=20skip=20exchange=20screen,=20auto-fill=20user?= =?UTF-8?q?=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added hideExchangeScreen, isAutoFillUserData, and paymentMethod params to both Transak endpoints. This skips the initial exchange screen (we already provide all required fields) and auto-fills email for Lite KYC, reducing friction for small transactions. Co-Authored-By: Claude Opus 4.6 --- modules/rcart/mod.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/rcart/mod.ts b/modules/rcart/mod.ts index e67a3d7..a1e0d63 100644 --- a/modules/rcart/mod.ts +++ b/modules/rcart/mod.ts @@ -1786,6 +1786,9 @@ routes.post("/api/payments/:id/transak-session", async (c) => { defaultCryptoAmount: effectiveAmount, partnerOrderId: `pay-${paymentId}`, email, + isAutoFillUserData: 'true', + hideExchangeScreen: 'true', + paymentMethod: 'credit_debit_card', themeColor: '6366f1', colorMode: 'DARK', hideMenu: 'true', @@ -1864,6 +1867,9 @@ routes.post("/api/payments/:id/card-session", async (c) => { defaultCryptoAmount: effectiveAmount, partnerOrderId: `pay-${paymentId}`, email, + isAutoFillUserData: 'true', + hideExchangeScreen: 'true', + paymentMethod: 'credit_debit_card', themeColor: '6366f1', colorMode: 'DARK', hideMenu: 'true', From b3d6f2eba88eb7a70b006067eb3bc8b121afbdea Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Fri, 3 Apr 2026 17:03:39 -0700 Subject: [PATCH 11/11] feat(rgov): add quadratic, conviction, multisig & sankey GovMods Four new governance circuit shapes for delegated democracy: - folk-gov-quadratic: weight transformer (sqrt/log/linear dampening) - folk-gov-conviction: time-weighted conviction accumulator (gate/tuner modes) - folk-gov-multisig: M-of-N multiplexor gate with signer management - folk-gov-sankey: auto-discovered governance flow visualizer with animated SVG Registered in canvas-tools (4 AI tool declarations), index exports, mod.ts (shapes, tools, types, seed Circuit 3), folk-gov-project (recognizes new types), and landing page (Advanced GovMods section). Co-Authored-By: Claude Opus 4.6 --- lib/canvas-tools.ts | 90 ++++++ lib/folk-gov-conviction.ts | 606 +++++++++++++++++++++++++++++++++++++ lib/folk-gov-multisig.ts | 549 +++++++++++++++++++++++++++++++++ lib/folk-gov-project.ts | 3 + lib/folk-gov-quadratic.ts | 409 +++++++++++++++++++++++++ lib/folk-gov-sankey.ts | 512 +++++++++++++++++++++++++++++++ lib/index.ts | 4 + modules/rgov/landing.ts | 78 +++++ modules/rgov/mod.ts | 96 +++++- 9 files changed, 2346 insertions(+), 1 deletion(-) create mode 100644 lib/folk-gov-conviction.ts create mode 100644 lib/folk-gov-multisig.ts create mode 100644 lib/folk-gov-quadratic.ts create mode 100644 lib/folk-gov-sankey.ts diff --git a/lib/canvas-tools.ts b/lib/canvas-tools.ts index 4efd152..29f0e87 100644 --- a/lib/canvas-tools.ts +++ b/lib/canvas-tools.ts @@ -650,6 +650,96 @@ registry.push( }), actionLabel: (args) => `Created amendment: ${args.title}`, }, + { + declaration: { + name: "create_quadratic_transform", + description: "Create a quadratic weight transformer on the canvas. Accepts raw weights and applies sqrt/log/linear dampening — useful for reducing whale dominance in voting.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Transform title (e.g. 'Vote Weight Dampener')" }, + mode: { type: "string", description: "Transform mode", enum: ["sqrt", "log", "linear"] }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-quadratic", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + ...(args.mode ? { mode: args.mode } : {}), + }), + actionLabel: (args) => `Created quadratic transform: ${args.title}`, + }, + { + declaration: { + name: "create_conviction_gate", + description: "Create a conviction accumulator on the canvas. Accumulates time-weighted conviction from stakes. Gate mode triggers at threshold; tuner mode continuously emits score.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Gate title (e.g. 'Community Support')" }, + convictionMode: { type: "string", description: "Operating mode", enum: ["gate", "tuner"] }, + threshold: { type: "number", description: "Conviction threshold for gate mode" }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-conviction", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + ...(args.convictionMode ? { convictionMode: args.convictionMode } : {}), + ...(args.threshold != null ? { threshold: args.threshold } : {}), + }), + actionLabel: (args) => `Created conviction gate: ${args.title}`, + }, + { + declaration: { + name: "create_multisig_gate", + description: "Create an M-of-N multisig gate on the canvas. Requires M named signers before passing. Signers can sign manually or auto-populate from upstream binary gates.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Multisig title (e.g. 'Council Approval')" }, + requiredM: { type: "number", description: "Number of required signatures (M)" }, + signerNames: { type: "string", description: "Comma-separated signer names" }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-multisig", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + ...(args.requiredM != null ? { requiredM: args.requiredM } : {}), + ...(args.signerNames ? { + signers: args.signerNames.split(",").map((n: string) => ({ + name: n.trim(), signed: false, timestamp: 0, + })), + } : {}), + }), + actionLabel: (args) => `Created multisig: ${args.title}`, + }, + { + declaration: { + name: "create_sankey_visualizer", + description: "Create a governance flow Sankey visualizer on the canvas. Auto-discovers all nearby gov shapes and renders an animated flow diagram. No ports — purely visual.", + parameters: { + type: "object", + properties: { + title: { type: "string", description: "Visualizer title (e.g. 'Governance Flow')" }, + }, + required: ["title"], + }, + }, + tagName: "folk-gov-sankey", + moduleId: "rgov", + buildProps: (args) => ({ + title: args.title, + }), + actionLabel: (args) => `Created Sankey visualizer: ${args.title}`, + }, ); export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry]; diff --git a/lib/folk-gov-conviction.ts b/lib/folk-gov-conviction.ts new file mode 100644 index 0000000..26756d6 --- /dev/null +++ b/lib/folk-gov-conviction.ts @@ -0,0 +1,606 @@ +/** + * folk-gov-conviction — Conviction Accumulator + * + * Dual-mode GovMod: Gate mode accumulates conviction over time and emits + * satisfied when score >= threshold. Tuner mode continuously emits the + * current conviction score as a dynamic value for downstream wiring. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; +import { convictionScore, convictionVelocity } from "./folk-choice-conviction"; +import type { ConvictionStake } from "./folk-choice-conviction"; + +const HEADER_COLOR = "#d97706"; + +type ConvictionMode = "gate" | "tuner"; + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 240px; + min-height: 160px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .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); + } + + .body { + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 13px; + font-weight: 600; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .config-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + } + + .mode-select, .threshold-input { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 2px 6px; + outline: none; + } + + .threshold-input { + width: 60px; + text-align: right; + } + + .progress-wrap { + position: relative; + height: 20px; + background: rgba(255, 255, 255, 0.08); + border-radius: 10px; + overflow: hidden; + } + + .progress-bar { + height: 100%; + border-radius: 10px; + transition: width 0.3s, background 0.3s; + background: ${HEADER_COLOR}; + } + + .progress-bar.complete { + background: #22c55e; + box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); + } + + .progress-label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 600; + color: white; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + } + + .score-display { + font-size: 20px; + font-weight: 700; + color: ${HEADER_COLOR}; + text-align: center; + font-variant-numeric: tabular-nums; + } + + .velocity-label { + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); + text-align: center; + } + + .chart-area svg { + width: 100%; + display: block; + } + + .stakes-list { + max-height: 80px; + overflow-y: auto; + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); + } + + .stake-item { + display: flex; + justify-content: space-between; + padding: 2px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + } + + .status-label { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: center; + } + + .status-label.satisfied { + color: #22c55e; + } + + .status-label.waiting { + color: #f59e0b; + } + + .status-label.tuner { + color: ${HEADER_COLOR}; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-conviction": FolkGovConviction; + } +} + +export class FolkGovConviction extends FolkShape { + static override tagName = "folk-gov-conviction"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "stake-in", type: "json", direction: "input" }, + { name: "threshold-in", type: "number", direction: "input" }, + { name: "conviction-out", type: "json", direction: "output" }, + { name: "gate-out", type: "json", direction: "output" }, + ]; + + 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; + } + + #title = "Conviction Gate"; + #convictionMode: ConvictionMode = "gate"; + #threshold = 10; + #stakes: ConvictionStake[] = []; + #tickInterval: ReturnType | null = null; + + // DOM refs + #titleEl!: HTMLInputElement; + #modeEl!: HTMLSelectElement; + #thresholdEl!: HTMLInputElement; + #thresholdRow!: HTMLElement; + #progressWrap!: HTMLElement; + #progressBar!: HTMLElement; + #progressLabel!: HTMLElement; + #scoreDisplay!: HTMLElement; + #velocityLabel!: HTMLElement; + #chartEl!: HTMLElement; + #stakesList!: HTMLElement; + #statusEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get convictionMode() { return this.#convictionMode; } + set convictionMode(v: ConvictionMode) { + this.#convictionMode = v; + if (this.#modeEl) this.#modeEl.value = v; + this.#updateLayout(); + this.#updateVisuals(); + this.#emitPorts(); + } + + get threshold() { return this.#threshold; } + set threshold(v: number) { + this.#threshold = v; + if (this.#thresholdEl) this.#thresholdEl.value = String(v); + this.#updateVisuals(); + this.#emitPorts(); + } + + get stakes(): ConvictionStake[] { return [...this.#stakes]; } + set stakes(v: ConvictionStake[]) { + this.#stakes = v; + this.#updateVisuals(); + this.#emitPorts(); + } + + #getTotalScore(): number { + // Aggregate conviction across all stakes (single "option" = this gate) + const now = Date.now(); + let total = 0; + for (const s of this.#stakes) { + total += s.weight * Math.max(0, now - s.since) / 3600000; + } + return total; + } + + #getTotalVelocity(): number { + return this.#stakes.reduce((sum, s) => sum + s.weight, 0); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.initPorts(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ ⏳ Conviction + + + +
+
+ +
+ Mode: + + + Threshold: + + +
+
+
+
0 / 10
+
+ +
+
+
+ WAITING +
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement; + this.#thresholdEl = wrapper.querySelector(".threshold-input") as HTMLInputElement; + this.#thresholdRow = wrapper.querySelector(".threshold-row") as HTMLElement; + this.#progressWrap = wrapper.querySelector(".progress-wrap") as HTMLElement; + this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; + this.#progressLabel = wrapper.querySelector(".progress-label") as HTMLElement; + this.#scoreDisplay = wrapper.querySelector(".score-display") as HTMLElement; + this.#velocityLabel = wrapper.querySelector(".velocity-label") as HTMLElement; + this.#chartEl = wrapper.querySelector(".chart-area") as HTMLElement; + this.#stakesList = wrapper.querySelector(".stakes-list") as HTMLElement; + this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#modeEl.value = this.#convictionMode; + this.#thresholdEl.value = String(this.#threshold); + this.#updateLayout(); + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#modeEl.addEventListener("change", (e) => { + e.stopPropagation(); + this.#convictionMode = this.#modeEl.value as ConvictionMode; + this.#updateLayout(); + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#thresholdEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#threshold = parseFloat(this.#thresholdEl.value) || 0; + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, select, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Handle input ports + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "stake-in" && value && typeof value === "object") { + const v = value as any; + const stake: ConvictionStake = { + userId: v.userId || v.who || crypto.randomUUID().slice(0, 8), + userName: v.userName || v.who || "anonymous", + optionId: "gate", + weight: v.weight || v.amount || 1, + since: v.since || Date.now(), + }; + // Update existing or add + const idx = this.#stakes.findIndex(s => s.userId === stake.userId); + if (idx >= 0) { + this.#stakes[idx] = stake; + } else { + this.#stakes.push(stake); + } + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + if (name === "threshold-in" && typeof value === "number") { + this.#threshold = value; + if (this.#thresholdEl) this.#thresholdEl.value = String(value); + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + }) as EventListener); + + // Tick timer for live conviction updates + this.#tickInterval = setInterval(() => { + this.#updateVisuals(); + this.#emitPorts(); + }, 10000); + + return root; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.#tickInterval) { + clearInterval(this.#tickInterval); + this.#tickInterval = null; + } + } + + #updateLayout() { + if (!this.#thresholdRow) return; + const isGate = this.#convictionMode === "gate"; + this.#thresholdRow.style.display = isGate ? "" : "none"; + if (this.#progressWrap) this.#progressWrap.style.display = isGate ? "" : "none"; + if (this.#scoreDisplay) this.#scoreDisplay.style.display = isGate ? "none" : ""; + } + + #updateVisuals() { + const score = this.#getTotalScore(); + const velocity = this.#getTotalVelocity(); + + if (this.#convictionMode === "gate") { + // Gate mode: progress bar + const pct = this.#threshold > 0 ? Math.min(100, (score / this.#threshold) * 100) : 0; + const satisfied = score >= this.#threshold; + + if (this.#progressBar) { + this.#progressBar.style.width = `${pct}%`; + this.#progressBar.classList.toggle("complete", satisfied); + } + if (this.#progressLabel) { + this.#progressLabel.textContent = `${this.#fmtScore(score)} / ${this.#threshold}`; + } + if (this.#statusEl) { + this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING"; + this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`; + } + } else { + // Tuner mode: score display + if (this.#scoreDisplay) { + this.#scoreDisplay.textContent = this.#fmtScore(score); + } + if (this.#statusEl) { + this.#statusEl.textContent = "EMITTING"; + this.#statusEl.className = "status-label tuner"; + } + } + + if (this.#velocityLabel) { + this.#velocityLabel.textContent = `velocity: ${velocity.toFixed(1)} wt/hr`; + } + + this.#renderChart(); + this.#renderStakes(); + } + + #renderChart() { + if (!this.#chartEl || this.#stakes.length === 0) { + if (this.#chartEl) this.#chartEl.innerHTML = ""; + return; + } + + const now = Date.now(); + const W = 220; + const H = 60; + const PAD = { top: 6, right: 8, bottom: 12, left: 28 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + const earliest = Math.min(...this.#stakes.map(s => s.since)); + const timeRange = Math.max(now - earliest, 60000); + + // Sample conviction curve at 20 points + const SAMPLES = 20; + const points: { t: number; v: number }[] = []; + let maxV = 0; + for (let i = 0; i <= SAMPLES; i++) { + const t = earliest + (timeRange * i) / SAMPLES; + let v = 0; + for (const s of this.#stakes) { + if (s.since <= t) v += s.weight * Math.max(0, t - s.since) / 3600000; + } + points.push({ t, v }); + maxV = Math.max(maxV, v); + } + if (maxV === 0) maxV = 1; + + const x = (t: number) => PAD.left + ((t - earliest) / timeRange) * plotW; + const y = (v: number) => PAD.top + (1 - v / maxV) * plotH; + + let svg = ``; + + // Threshold line in gate mode + if (this.#convictionMode === "gate" && this.#threshold > 0 && this.#threshold <= maxV) { + const ty = y(this.#threshold); + svg += ``; + } + + // Area + const areaD = `M${x(points[0].t)},${y(0)} ` + + points.map(p => `L${x(p.t)},${y(p.v)}`).join(" ") + + ` L${x(points[points.length - 1].t)},${y(0)} Z`; + svg += ``; + + // Line + const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" "); + svg += ``; + + // End dot + const last = points[points.length - 1]; + svg += ``; + + // Y axis + svg += `${this.#fmtScore(maxV)}`; + svg += `0`; + + svg += ""; + this.#chartEl.innerHTML = svg; + } + + #renderStakes() { + if (!this.#stakesList) return; + const now = Date.now(); + this.#stakesList.innerHTML = this.#stakes.map(s => { + const dur = this.#fmtDuration(now - s.since); + return `
${s.userName} (wt:${s.weight})${dur}
`; + }).join(""); + } + + #emitPorts() { + const score = this.#getTotalScore(); + const velocity = this.#getTotalVelocity(); + const satisfied = this.#convictionMode === "gate" ? score >= this.#threshold : true; + + this.setPortValue("conviction-out", { + score, + velocity, + stakeCount: this.#stakes.length, + mode: this.#convictionMode, + }); + + this.setPortValue("gate-out", { + satisfied, + score, + threshold: this.#threshold, + mode: this.#convictionMode, + }); + } + + #fmtScore(v: number): string { + if (v < 1) return v.toFixed(2); + if (v < 100) return v.toFixed(1); + return Math.round(v).toString(); + } + + #fmtDuration(ms: number): string { + if (ms < 60000) return "<1m"; + if (ms < 3600000) return `${Math.floor(ms / 60000)}m`; + if (ms < 86400000) return `${Math.floor(ms / 3600000)}h`; + return `${Math.floor(ms / 86400000)}d`; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-conviction", + title: this.#title, + convictionMode: this.#convictionMode, + threshold: this.#threshold, + stakes: this.#stakes, + }; + } + + static override fromData(data: Record): FolkGovConviction { + const shape = FolkShape.fromData.call(this, data) as FolkGovConviction; + if (data.title !== undefined) shape.title = data.title; + if (data.convictionMode !== undefined) shape.convictionMode = data.convictionMode; + if (data.threshold !== undefined) shape.threshold = data.threshold; + if (data.stakes !== undefined) shape.stakes = data.stakes; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.convictionMode !== undefined && data.convictionMode !== this.#convictionMode) this.convictionMode = data.convictionMode; + if (data.threshold !== undefined && data.threshold !== this.#threshold) this.threshold = data.threshold; + if (data.stakes !== undefined && JSON.stringify(data.stakes) !== JSON.stringify(this.#stakes)) this.stakes = data.stakes; + } +} diff --git a/lib/folk-gov-multisig.ts b/lib/folk-gov-multisig.ts new file mode 100644 index 0000000..630289c --- /dev/null +++ b/lib/folk-gov-multisig.ts @@ -0,0 +1,549 @@ +/** + * folk-gov-multisig — M-of-N Multiplexor Gate + * + * Requires M of N named signers before passing. Signers can be added + * manually or auto-populated from upstream binary gates. Shows a + * multiplexor SVG diagram and progress bar. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#6366f1"; + +interface Signer { + name: string; + signed: boolean; + timestamp: number; +} + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 260px; + min-height: 180px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .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); + } + + .body { + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 13px; + font-weight: 600; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .mn-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--rs-text-primary, #e2e8f0); + font-weight: 600; + } + + .mn-input { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + padding: 2px 6px; + width: 40px; + text-align: center; + outline: none; + } + + .mux-svg { + text-align: center; + } + + .mux-svg svg { + display: block; + margin: 0 auto; + } + + .progress-wrap { + position: relative; + height: 16px; + background: rgba(255, 255, 255, 0.08); + border-radius: 8px; + overflow: hidden; + } + + .progress-bar { + height: 100%; + border-radius: 8px; + transition: width 0.3s, background 0.3s; + background: ${HEADER_COLOR}; + } + + .progress-bar.complete { + background: #22c55e; + box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); + } + + .signers-list { + display: flex; + flex-direction: column; + gap: 3px; + max-height: 120px; + overflow-y: auto; + } + + .signer-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + padding: 3px 6px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); + color: var(--rs-text-secondary, #94a3b8); + } + + .signer-item.signed { + color: #22c55e; + } + + .signer-icon { + width: 14px; + text-align: center; + font-size: 10px; + } + + .signer-name { + flex: 1; + } + + .signer-toggle { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 10px; + padding: 1px 6px; + cursor: pointer; + } + + .signer-toggle:hover { + background: rgba(255, 255, 255, 0.12); + } + + .add-signer-row { + display: flex; + gap: 4px; + } + + .add-signer-input { + flex: 1; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 3px 6px; + outline: none; + } + + .add-signer-btn { + background: ${HEADER_COLOR}; + border: none; + color: white; + border-radius: 4px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + font-weight: 600; + } + + .add-signer-btn:hover { + opacity: 0.85; + } + + .status-label { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: center; + } + + .status-label.satisfied { + color: #22c55e; + } + + .status-label.waiting { + color: #f59e0b; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-multisig": FolkGovMultisig; + } +} + +export class FolkGovMultisig extends FolkShape { + static override tagName = "folk-gov-multisig"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "signer-in", type: "json", direction: "input" }, + { name: "gate-out", type: "json", direction: "output" }, + ]; + + 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; + } + + #title = "Multisig"; + #requiredM = 2; + #signers: Signer[] = []; + + // DOM refs + #titleEl!: HTMLInputElement; + #mEl!: HTMLInputElement; + #nEl!: HTMLElement; + #muxEl!: HTMLElement; + #progressBar!: HTMLElement; + #signersList!: HTMLElement; + #addInput!: HTMLInputElement; + #statusEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get requiredM() { return this.#requiredM; } + set requiredM(v: number) { + this.#requiredM = v; + if (this.#mEl) this.#mEl.value = String(v); + this.#updateVisuals(); + this.#emitPort(); + } + + get signers(): Signer[] { return [...this.#signers]; } + set signers(v: Signer[]) { + this.#signers = v; + this.#updateVisuals(); + this.#emitPort(); + } + + get #signedCount(): number { + return this.#signers.filter(s => s.signed).length; + } + + get #isSatisfied(): boolean { + return this.#signedCount >= this.#requiredM; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.initPorts(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ 🔐 Multisig + + + +
+
+ +
+ + of + 0 + required +
+
+
+
+
+
+
+ + +
+ WAITING +
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#mEl = wrapper.querySelector(".mn-m-input") as HTMLInputElement; + this.#nEl = wrapper.querySelector(".mn-n-label") as HTMLElement; + this.#muxEl = wrapper.querySelector(".mux-svg") as HTMLElement; + this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; + this.#signersList = wrapper.querySelector(".signers-list") as HTMLElement; + this.#addInput = wrapper.querySelector(".add-signer-input") as HTMLInputElement; + this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#mEl.value = String(this.#requiredM); + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#mEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#requiredM = Math.max(1, parseInt(this.#mEl.value) || 1); + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + wrapper.querySelector(".add-signer-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + const name = this.#addInput.value.trim(); + if (!name) return; + if (this.#signers.some(s => s.name === name)) return; + this.#signers.push({ name, signed: false, timestamp: 0 }); + this.#addInput.value = ""; + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#addInput.addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") { + wrapper.querySelector(".add-signer-btn")!.dispatchEvent(new Event("click")); + } + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Handle input port + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "signer-in" && value && typeof value === "object") { + const v = value as any; + const signerName = v.signedBy || v.who || v.name || ""; + const isSatisfied = v.satisfied === true; + if (signerName && isSatisfied) { + const existing = this.#signers.find(s => s.name === signerName); + if (existing) { + existing.signed = true; + existing.timestamp = v.timestamp || Date.now(); + } else { + this.#signers.push({ name: signerName, signed: true, timestamp: v.timestamp || Date.now() }); + } + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + } + } + }) as EventListener); + + return root; + } + + #updateVisuals() { + const n = this.#signers.length; + const signed = this.#signedCount; + const satisfied = this.#isSatisfied; + const pct = n > 0 ? (signed / Math.max(this.#requiredM, 1)) * 100 : 0; + + if (this.#nEl) this.#nEl.textContent = String(n); + + if (this.#progressBar) { + this.#progressBar.style.width = `${Math.min(100, pct)}%`; + this.#progressBar.classList.toggle("complete", satisfied); + } + + if (this.#statusEl) { + this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING"; + this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`; + } + + this.#renderMux(); + this.#renderSigners(); + } + + #renderMux() { + if (!this.#muxEl) return; + const n = this.#signers.length; + if (n === 0) { + this.#muxEl.innerHTML = ""; + return; + } + + const W = 180; + const slotH = 14; + const gateW = 30; + const gateH = Math.max(20, n * slotH + 4); + const H = gateH + 16; + const gateX = W / 2 - gateW / 2; + const gateY = (H - gateH) / 2; + + let svg = ``; + + // Gate body + svg += ``; + svg += `${this.#requiredM}/${n}`; + + // Input lines (left side) + for (let i = 0; i < n; i++) { + const y = gateY + 2 + slotH * i + slotH / 2; + const signed = this.#signers[i].signed; + const color = signed ? "#22c55e" : "rgba(255,255,255,0.2)"; + svg += ``; + svg += ``; + } + + // Output line (right side) + const outY = gateY + gateH / 2; + const outColor = this.#isSatisfied ? "#22c55e" : "rgba(255,255,255,0.2)"; + svg += ``; + svg += ``; + + svg += ""; + this.#muxEl.innerHTML = svg; + } + + #renderSigners() { + if (!this.#signersList) return; + this.#signersList.innerHTML = this.#signers.map((s, i) => { + const icon = s.signed ? "✓" : "○"; + const cls = s.signed ? "signer-item signed" : "signer-item"; + const btnLabel = s.signed ? "unsign" : "sign"; + return `
+ ${icon} + ${this.#escapeHtml(s.name)} + +
`; + }).join(""); + + // Wire toggle buttons + this.#signersList.querySelectorAll(".signer-toggle").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const idx = parseInt((btn as HTMLElement).dataset.idx!); + const signer = this.#signers[idx]; + signer.signed = !signer.signed; + signer.timestamp = signer.signed ? Date.now() : 0; + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + btn.addEventListener("pointerdown", (e) => e.stopPropagation()); + }); + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #emitPort() { + this.setPortValue("gate-out", { + satisfied: this.#isSatisfied, + signed: this.#signedCount, + required: this.#requiredM, + total: this.#signers.length, + signers: this.#signers.filter(s => s.signed).map(s => s.name), + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-multisig", + title: this.#title, + requiredM: this.#requiredM, + signers: this.#signers, + }; + } + + static override fromData(data: Record): FolkGovMultisig { + const shape = FolkShape.fromData.call(this, data) as FolkGovMultisig; + if (data.title !== undefined) shape.title = data.title; + if (data.requiredM !== undefined) shape.requiredM = data.requiredM; + if (data.signers !== undefined) shape.signers = data.signers; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.requiredM !== undefined && data.requiredM !== this.#requiredM) this.requiredM = data.requiredM; + if (data.signers !== undefined && JSON.stringify(data.signers) !== JSON.stringify(this.#signers)) this.signers = data.signers; + } +} diff --git a/lib/folk-gov-project.ts b/lib/folk-gov-project.ts index 1e14966..e12ca04 100644 --- a/lib/folk-gov-project.ts +++ b/lib/folk-gov-project.ts @@ -16,6 +16,9 @@ const GOV_TAG_NAMES = new Set([ "FOLK-GOV-THRESHOLD", "FOLK-GOV-KNOB", "FOLK-GOV-AMENDMENT", + "FOLK-GOV-QUADRATIC", + "FOLK-GOV-CONVICTION", + "FOLK-GOV-MULTISIG", ]); type ProjectStatus = "draft" | "active" | "completed" | "archived"; diff --git a/lib/folk-gov-quadratic.ts b/lib/folk-gov-quadratic.ts new file mode 100644 index 0000000..81c8a2b --- /dev/null +++ b/lib/folk-gov-quadratic.ts @@ -0,0 +1,409 @@ +/** + * folk-gov-quadratic — Weight Transformer + * + * Inline weight transform GovMod. Accepts raw weight on input port, + * applies sqrt/log/linear transform, and emits effective weight on output. + * Always passes (gate-out = satisfied). Visualizes raw vs effective in a bar chart. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#14b8a6"; + +type TransformMode = "sqrt" | "log" | "linear"; + +interface WeightEntry { + who: string; + raw: number; + effective: number; +} + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 240px; + min-height: 140px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .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); + } + + .body { + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 13px; + font-weight: 600; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .mode-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + } + + .mode-select { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 2px 6px; + outline: none; + } + + .chart-area { + min-height: 60px; + } + + .chart-area svg { + width: 100%; + display: block; + } + + .entries-list { + max-height: 80px; + overflow-y: auto; + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); + } + + .entry-item { + display: flex; + justify-content: space-between; + padding: 2px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + } + + .status-label { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: center; + color: #22c55e; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-quadratic": FolkGovQuadratic; + } +} + +export class FolkGovQuadratic extends FolkShape { + static override tagName = "folk-gov-quadratic"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "weight-in", type: "json", direction: "input" }, + { name: "weight-out", type: "json", direction: "output" }, + { name: "gate-out", type: "json", direction: "output" }, + ]; + + 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; + } + + #title = "Weight Transform"; + #mode: TransformMode = "sqrt"; + #entries: WeightEntry[] = []; + + // DOM refs + #titleEl!: HTMLInputElement; + #modeEl!: HTMLSelectElement; + #chartEl!: HTMLElement; + #listEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get mode() { return this.#mode; } + set mode(v: TransformMode) { + this.#mode = v; + if (this.#modeEl) this.#modeEl.value = v; + this.#recalc(); + } + + get entries(): WeightEntry[] { return [...this.#entries]; } + set entries(v: WeightEntry[]) { + this.#entries = v; + this.#updateVisuals(); + this.#emitPorts(); + } + + #transform(raw: number): number { + if (raw <= 0) return 0; + switch (this.#mode) { + case "sqrt": return Math.sqrt(raw); + case "log": return Math.log1p(raw); + case "linear": return raw; + } + } + + #recalc() { + for (const e of this.#entries) { + e.effective = this.#transform(e.raw); + } + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.initPorts(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ √ Quadratic + + + +
+
+ +
+ Mode: + +
+
+
+ PASSTHROUGH +
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement; + this.#chartEl = wrapper.querySelector(".chart-area") as HTMLElement; + this.#listEl = wrapper.querySelector(".entries-list") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#modeEl.value = this.#mode; + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#modeEl.addEventListener("change", (e) => { + e.stopPropagation(); + this.#mode = this.#modeEl.value as TransformMode; + this.#recalc(); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, select, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Handle input port + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "weight-in" && value && typeof value === "object") { + const v = value as any; + // Accept { who, weight } or { who, raw } + const who = v.who || v.memberName || "anonymous"; + const raw = v.weight || v.raw || v.amount || 0; + // Update or add + const existing = this.#entries.find(e => e.who === who); + if (existing) { + existing.raw = raw; + existing.effective = this.#transform(raw); + } else { + this.#entries.push({ who, raw, effective: this.#transform(raw) }); + } + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + }) as EventListener); + + return root; + } + + #updateVisuals() { + this.#renderChart(); + this.#renderList(); + } + + #renderChart() { + if (!this.#chartEl) return; + if (this.#entries.length === 0) { + this.#chartEl.innerHTML = ""; + return; + } + + const W = 220; + const H = 70; + const PAD = { top: 6, right: 8, bottom: 16, left: 8 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + const maxRaw = Math.max(1, ...this.#entries.map(e => e.raw)); + const maxEff = Math.max(1, ...this.#entries.map(e => e.effective)); + const maxVal = Math.max(maxRaw, maxEff); + const barW = Math.max(6, Math.min(20, plotW / (this.#entries.length * 2.5))); + + let svg = ``; + + // Grid line + svg += ``; + + const entries = this.#entries.slice(0, 8); // max 8 bars + const groupW = plotW / entries.length; + + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + const cx = PAD.left + groupW * i + groupW / 2; + const rawH = (e.raw / maxVal) * plotH; + const effH = (e.effective / maxVal) * plotH; + + // Raw bar (dimmed) + svg += ``; + // Effective bar (teal) + svg += ``; + + // Label + const label = e.who.length > 5 ? e.who.slice(0, 5) : e.who; + svg += `${label}`; + } + + // Legend + svg += ``; + svg += `raw`; + svg += ``; + svg += `eff`; + + svg += ""; + this.#chartEl.innerHTML = svg; + } + + #renderList() { + if (!this.#listEl) return; + this.#listEl.innerHTML = this.#entries.map(e => + `
${e.who}${e.raw.toFixed(1)} → ${e.effective.toFixed(2)}
` + ).join(""); + } + + #emitPorts() { + const totalRaw = this.#entries.reduce((s, e) => s + e.raw, 0); + const totalEffective = this.#entries.reduce((s, e) => s + e.effective, 0); + + this.setPortValue("weight-out", { + totalRaw, + totalEffective, + mode: this.#mode, + entries: this.#entries.map(e => ({ who: e.who, raw: e.raw, effective: e.effective })), + }); + + // Always satisfied — this is a passthrough transform + this.setPortValue("gate-out", { + satisfied: true, + totalRaw, + totalEffective, + mode: this.#mode, + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-quadratic", + title: this.#title, + mode: this.#mode, + entries: this.#entries, + }; + } + + static override fromData(data: Record): FolkGovQuadratic { + const shape = FolkShape.fromData.call(this, data) as FolkGovQuadratic; + if (data.title !== undefined) shape.title = data.title; + if (data.mode !== undefined) shape.mode = data.mode; + if (data.entries !== undefined) shape.entries = data.entries; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.mode !== undefined && data.mode !== this.#mode) this.mode = data.mode; + if (data.entries !== undefined && JSON.stringify(data.entries) !== JSON.stringify(this.#entries)) this.entries = data.entries; + } +} diff --git a/lib/folk-gov-sankey.ts b/lib/folk-gov-sankey.ts new file mode 100644 index 0000000..ee5ea91 --- /dev/null +++ b/lib/folk-gov-sankey.ts @@ -0,0 +1,512 @@ +/** + * folk-gov-sankey — Governance Flow Visualizer + * + * Auto-discovers all connected governance shapes via arrow graph traversal, + * renders an SVG Sankey diagram with animated flow curves, tooltips, and + * a color-coded legend. Purely visual — no ports. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const HEADER_COLOR = "#7c3aed"; + +// Gov shape tag names recognized by the visualizer +const GOV_TAG_NAMES = new Set([ + "FOLK-GOV-BINARY", + "FOLK-GOV-THRESHOLD", + "FOLK-GOV-KNOB", + "FOLK-GOV-PROJECT", + "FOLK-GOV-AMENDMENT", + "FOLK-GOV-QUADRATIC", + "FOLK-GOV-CONVICTION", + "FOLK-GOV-MULTISIG", +]); + +const TYPE_COLORS: Record = { + "FOLK-GOV-BINARY": "#7c3aed", + "FOLK-GOV-THRESHOLD": "#0891b2", + "FOLK-GOV-KNOB": "#b45309", + "FOLK-GOV-PROJECT": "#1d4ed8", + "FOLK-GOV-AMENDMENT": "#be185d", + "FOLK-GOV-QUADRATIC": "#14b8a6", + "FOLK-GOV-CONVICTION": "#d97706", + "FOLK-GOV-MULTISIG": "#6366f1", +}; + +const TYPE_LABELS: Record = { + "FOLK-GOV-BINARY": "Binary", + "FOLK-GOV-THRESHOLD": "Threshold", + "FOLK-GOV-KNOB": "Knob", + "FOLK-GOV-PROJECT": "Project", + "FOLK-GOV-AMENDMENT": "Amendment", + "FOLK-GOV-QUADRATIC": "Quadratic", + "FOLK-GOV-CONVICTION": "Conviction", + "FOLK-GOV-MULTISIG": "Multisig", +}; + +interface SankeyNode { + id: string; + tagName: string; + title: string; + satisfied: boolean; + column: number; // 0 = leftmost + row: number; +} + +interface SankeyFlow { + sourceId: string; + targetId: string; +} + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 340px; + min-height: 240px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .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); + } + + .body { + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + overflow: auto; + max-height: calc(100% - 36px); + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 13px; + font-weight: 600; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .summary { + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + text-align: center; + } + + .sankey-area svg { + width: 100%; + display: block; + } + + .legend { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + } + + .legend-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 9px; + color: var(--rs-text-muted, #94a3b8); + } + + .legend-dot { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; + } + + .no-data { + font-size: 11px; + color: var(--rs-text-muted, #475569); + font-style: italic; + text-align: center; + padding: 24px 0; + } + + @keyframes flow-dash { + to { stroke-dashoffset: -20; } + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-sankey": FolkGovSankey; + } +} + +export class FolkGovSankey extends FolkShape { + static override tagName = "folk-gov-sankey"; + + 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; + } + + #title = "Governance Flow"; + #pollInterval: ReturnType | null = null; + #lastHash = ""; + + // DOM refs + #titleEl!: HTMLInputElement; + #summaryEl!: HTMLElement; + #sankeyEl!: HTMLElement; + #legendEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ 📊 Sankey + + + +
+
+ +
+
+
+
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#summaryEl = wrapper.querySelector(".summary") as HTMLElement; + this.#sankeyEl = wrapper.querySelector(".sankey-area") as HTMLElement; + this.#legendEl = wrapper.querySelector(".legend") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Poll every 3 seconds + this.#pollInterval = setInterval(() => this.#discover(), 3000); + requestAnimationFrame(() => this.#discover()); + + return root; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.#pollInterval) { + clearInterval(this.#pollInterval); + this.#pollInterval = null; + } + } + + #discover() { + const arrows = document.querySelectorAll("folk-arrow"); + const nodes = new Map(); + const flows: SankeyFlow[] = []; + + // Collect all gov shapes connected by arrows + for (const arrow of arrows) { + const a = arrow as any; + const sourceId = a.sourceId; + const targetId = a.targetId; + if (!sourceId || !targetId) continue; + + // Skip self + if (sourceId === this.id || targetId === this.id) continue; + + const sourceEl = document.getElementById(sourceId) as any; + const targetEl = document.getElementById(targetId) as any; + if (!sourceEl || !targetEl) continue; + + const srcTag = sourceEl.tagName?.toUpperCase(); + const tgtTag = targetEl.tagName?.toUpperCase(); + + const srcIsGov = GOV_TAG_NAMES.has(srcTag); + const tgtIsGov = GOV_TAG_NAMES.has(tgtTag); + + if (!srcIsGov && !tgtIsGov) continue; + + if (srcIsGov && !nodes.has(sourceId)) { + const portVal = sourceEl.getPortValue?.("gate-out"); + nodes.set(sourceId, { + id: sourceId, + tagName: srcTag, + title: sourceEl.title || srcTag, + satisfied: portVal?.satisfied === true, + column: 0, + row: 0, + }); + } + + if (tgtIsGov && !nodes.has(targetId)) { + const portVal = targetEl.getPortValue?.("gate-out") || targetEl.getPortValue?.("circuit-out"); + nodes.set(targetId, { + id: targetId, + tagName: tgtTag, + title: targetEl.title || tgtTag, + satisfied: portVal?.satisfied === true || portVal?.status === "completed", + column: 0, + row: 0, + }); + } + + if (srcIsGov && tgtIsGov) { + flows.push({ sourceId, targetId }); + } + } + + // Hash-based skip + const hash = [...nodes.keys()].sort().join(",") + "|" + + flows.map(f => `${f.sourceId}->${f.targetId}`).sort().join(",") + + "|" + [...nodes.values()].map(n => n.satisfied ? "1" : "0").join(""); + if (hash === this.#lastHash) return; + this.#lastHash = hash; + + this.#layout(nodes, flows); + this.#renderSankey(nodes, flows); + } + + #layout(nodes: Map, flows: SankeyFlow[]) { + if (nodes.size === 0) return; + + // Build adjacency for topological column assignment + const outEdges = new Map(); + const inDegree = new Map(); + for (const n of nodes.keys()) { + outEdges.set(n, []); + inDegree.set(n, 0); + } + for (const f of flows) { + if (nodes.has(f.sourceId) && nodes.has(f.targetId)) { + outEdges.get(f.sourceId)!.push(f.targetId); + inDegree.set(f.targetId, (inDegree.get(f.targetId) || 0) + 1); + } + } + + // BFS topological layering + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } + + const visited = new Set(); + while (queue.length > 0) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + + for (const next of outEdges.get(id) || []) { + const parentCol = nodes.get(id)!.column; + const node = nodes.get(next)!; + node.column = Math.max(node.column, parentCol + 1); + const newDeg = (inDegree.get(next) || 1) - 1; + inDegree.set(next, newDeg); + if (newDeg <= 0) queue.push(next); + } + } + + // Assign rows within each column + const columns = new Map(); + for (const [id, node] of nodes) { + const col = node.column; + if (!columns.has(col)) columns.set(col, []); + columns.get(col)!.push(id); + } + for (const [, ids] of columns) { + ids.forEach((id, i) => { + nodes.get(id)!.row = i; + }); + } + } + + #renderSankey(nodes: Map, flows: SankeyFlow[]) { + if (nodes.size === 0) { + if (this.#summaryEl) this.#summaryEl.textContent = ""; + if (this.#sankeyEl) this.#sankeyEl.innerHTML = `
Drop near gov shapes to visualize flows
`; + if (this.#legendEl) this.#legendEl.innerHTML = ""; + return; + } + + // Summary + if (this.#summaryEl) { + this.#summaryEl.textContent = `${nodes.size} shapes, ${flows.length} flows`; + } + + // Calculate dimensions + const maxCol = Math.max(...[...nodes.values()].map(n => n.column)); + const columns = new Map(); + for (const n of nodes.values()) { + if (!columns.has(n.column)) columns.set(n.column, []); + columns.get(n.column)!.push(n); + } + const maxRows = Math.max(...[...columns.values()].map(c => c.length)); + + const NODE_W = 80; + const NODE_H = 28; + const COL_GAP = 60; + const ROW_GAP = 12; + const PAD = 16; + + const W = PAD * 2 + (maxCol + 1) * NODE_W + maxCol * COL_GAP; + const H = PAD * 2 + maxRows * NODE_H + (maxRows - 1) * ROW_GAP; + + const nodeX = (col: number) => PAD + col * (NODE_W + COL_GAP); + const nodeY = (col: number, row: number) => { + const colNodes = columns.get(col) || []; + const totalH = colNodes.length * NODE_H + (colNodes.length - 1) * ROW_GAP; + const offsetY = (H - totalH) / 2; + return offsetY + row * (NODE_H + ROW_GAP); + }; + + let svg = ``; + + // Flows (Bezier curves) + for (const f of flows) { + const src = nodes.get(f.sourceId); + const tgt = nodes.get(f.targetId); + if (!src || !tgt) continue; + + const sx = nodeX(src.column) + NODE_W; + const sy = nodeY(src.column, src.row) + NODE_H / 2; + const tx = nodeX(tgt.column); + const ty = nodeY(tgt.column, tgt.row) + NODE_H / 2; + const cx1 = sx + (tx - sx) * 0.4; + const cx2 = tx - (tx - sx) * 0.4; + + const color = TYPE_COLORS[src.tagName] || "#94a3b8"; + + // Background curve + svg += ``; + // Animated dash curve + svg += ``; + } + + // Nodes + for (const n of nodes.values()) { + const x = nodeX(n.column); + const y = nodeY(n.column, n.row); + const color = TYPE_COLORS[n.tagName] || "#94a3b8"; + const fillOpacity = n.satisfied ? "0.25" : "0.1"; + + svg += ``; + + // Satisfied glow + if (n.satisfied) { + svg += ``; + } + + // Label (truncated) + const label = n.title.length > 12 ? n.title.slice(0, 11) + "..." : n.title; + svg += `${this.#escapeXml(label)}`; + + // Tooltip title + svg += `${this.#escapeXml(n.title)} (${TYPE_LABELS[n.tagName] || n.tagName}) - ${n.satisfied ? "Satisfied" : "Waiting"}`; + } + + svg += ""; + if (this.#sankeyEl) this.#sankeyEl.innerHTML = svg; + + // Legend + if (this.#legendEl) { + const usedTypes = new Set([...nodes.values()].map(n => n.tagName)); + this.#legendEl.innerHTML = [...usedTypes].map(t => { + const color = TYPE_COLORS[t] || "#94a3b8"; + const label = TYPE_LABELS[t] || t; + return `
${label}
`; + }).join(""); + } + } + + #escapeXml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-sankey", + title: this.#title, + }; + } + + static override fromData(data: Record): FolkGovSankey { + const shape = FolkShape.fromData.call(this, data) as FolkGovSankey; + if (data.title !== undefined) shape.title = data.title; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + } +} diff --git a/lib/index.ts b/lib/index.ts index d293d0e..8aa4d07 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -88,6 +88,10 @@ export * from "./folk-gov-threshold"; export * from "./folk-gov-knob"; export * from "./folk-gov-project"; export * from "./folk-gov-amendment"; +export * from "./folk-gov-quadratic"; +export * from "./folk-gov-conviction"; +export * from "./folk-gov-multisig"; +export * from "./folk-gov-sankey"; // Decision/Choice Shapes export * from "./folk-choice-vote"; diff --git a/modules/rgov/landing.ts b/modules/rgov/landing.ts index 79de05f..62d2973 100644 --- a/modules/rgov/landing.ts +++ b/modules/rgov/landing.ts @@ -255,6 +255,84 @@ export function renderLanding(): string { + +
+
+
+ ADVANCED GOVMODS +

+ Delegated Democracy & Flow Visualization +

+

+ Beyond simple gates: weight transformation for fair voting, time-weighted conviction, + multi-party approval, and real-time governance flow visualization. +

+
+ +
+ +
+
+
+ +
+

Quadratic Transform

+
+

+ Inline weight dampening. Raw votes pass through sqrt, log, or linear transforms — + reducing whale dominance while preserving signal. Bar chart shows raw vs effective. + Fair voting by default. +

+
+ + +
+
+
+ +
+

Conviction Accumulator

+
+

+ Time-weighted conviction scoring. Stakes accumulate conviction over hours — longer + commitment means stronger signal. Gate mode triggers at threshold; tuner mode streams live score. + Decisions that reward patience. +

+
+ + +
+
+
+ 🔐 +
+

Multisig Gate

+
+

+ M-of-N approval multiplexor. Name your signers, require 3 of 5 (or any ratio). + Multiplexor SVG shows inbound approval lines converging through a gate symbol. + Council-grade approval on the canvas. +

+
+ + +
+
+
+ 📊 +
+

Sankey Visualizer

+
+

+ Drop a Sankey shape near your circuit and it auto-discovers all connected gov shapes. + Animated Bezier flow curves, color-coded nodes, and tooltips. See your governance at a glance. + Governance you can see flowing. +

+
+
+
+
+
diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts index e0b2910..8320c08 100644 --- a/modules/rgov/mod.ts +++ b/modules/rgov/mod.ts @@ -40,6 +40,10 @@ routes.get("/", (c) => {
  • Knobs — Tunable parameters with temporal viscosity
  • Projects — Circuit aggregators showing "X of Y gates satisfied"
  • Amendments — Propose in-place circuit modifications
  • +
  • Quadratic Transform — Weight dampening (sqrt/log) for fair voting
  • +
  • Conviction Accumulator — Time-weighted conviction scoring
  • +
  • Multisig Gate — M-of-N approval multiplexor
  • +
  • Sankey Visualizer — Auto-discovered governance flow diagram
  • Open Canvas → @@ -60,6 +64,10 @@ routes.get("/api/shapes", (c) => { "folk-gov-knob", "folk-gov-project", "folk-gov-amendment", + "folk-gov-quadratic", + "folk-gov-conviction", + "folk-gov-multisig", + "folk-gov-sankey", ], }); }); @@ -71,6 +79,7 @@ function seedTemplateGov(space: string) { const govTypes = [ "folk-gov-binary", "folk-gov-threshold", "folk-gov-knob", "folk-gov-project", "folk-gov-amendment", + "folk-gov-quadratic", "folk-gov-conviction", "folk-gov-multisig", "folk-gov-sankey", ]; if (docData?.shapes) { const existing = Object.values(docData.shapes as Record) @@ -192,8 +201,85 @@ function seedTemplateGov(space: string) { }, ]; + // ── Circuit 3: "Delegated Budget Approval" ── + // Quadratic transform → Conviction gate, plus 3-of-5 Multisig → Project, plus Sankey visualizer + const quadId = `gov-quad-${now}`; + const convId = `gov-conv-${now}`; + const msigId = `gov-msig-${now}`; + const budgetProjId = `gov-budgetproj-${now}`; + const sankeyId = `gov-sankey-${now}`; + + const c3BaseY = baseY + 700; + + shapes.push( + // Quadratic weight transform + { + id: quadId, type: "folk-gov-quadratic", + x: 1600, y: c3BaseY, width: 240, height: 160, rotation: 0, + title: "Vote Weight Dampener", mode: "sqrt", + entries: [ + { who: "Whale", raw: 100, effective: 10 }, + { who: "Alice", raw: 4, effective: 2 }, + { who: "Bob", raw: 1, effective: 1 }, + ], + }, + // Conviction accumulator + { + id: convId, type: "folk-gov-conviction", + x: 1600, y: c3BaseY + 200, width: 240, height: 200, rotation: 0, + title: "Community Support", convictionMode: "gate", threshold: 5, + stakes: [ + { userId: "u1", userName: "Alice", optionId: "gate", weight: 2, since: now - 7200000 }, + { userId: "u2", userName: "Bob", optionId: "gate", weight: 1, since: now - 3600000 }, + ], + }, + // Multisig 3-of-5 + { + id: msigId, type: "folk-gov-multisig", + x: 1600, y: c3BaseY + 440, width: 260, height: 220, rotation: 0, + title: "Council Approval", requiredM: 3, + signers: [ + { name: "Alice", signed: true, timestamp: now - 86400000 }, + { name: "Bob", signed: true, timestamp: now - 43200000 }, + { name: "Carol", signed: false, timestamp: 0 }, + { name: "Dave", signed: false, timestamp: 0 }, + { name: "Eve", signed: false, timestamp: 0 }, + ], + }, + // Project aggregator + { + id: budgetProjId, type: "folk-gov-project", + x: 1960, y: c3BaseY + 180, width: 300, height: 240, rotation: 0, + title: "Delegated Budget Approval", + description: "Budget approval with quadratic dampening, time-weighted conviction, and council multisig.", + status: "active", + }, + // Sankey visualizer + { + id: sankeyId, type: "folk-gov-sankey", + x: 2320, y: c3BaseY + 100, width: 380, height: 300, rotation: 0, + title: "Governance Flow", + }, + // Arrows wiring Circuit 3 + { + id: `gov-arrow-quad-${now}`, type: "folk-arrow", + x: 0, y: 0, width: 0, height: 0, rotation: 0, + sourceId: quadId, targetId: budgetProjId, color: "#14b8a6", + }, + { + id: `gov-arrow-conv-${now}`, type: "folk-arrow", + x: 0, y: 0, width: 0, height: 0, rotation: 0, + sourceId: convId, targetId: budgetProjId, color: "#d97706", + }, + { + id: `gov-arrow-msig-${now}`, type: "folk-arrow", + x: 0, y: 0, width: 0, height: 0, rotation: 0, + sourceId: msigId, targetId: budgetProjId, color: "#6366f1", + }, + ); + addShapes(space, shapes); - console.log(`[rGov] Template seeded for "${space}": 2 circuits (8 shapes + 6 arrows)`); + console.log(`[rGov] Template seeded for "${space}": 3 circuits (13 shapes + 9 arrows)`); } // ── Module export ── @@ -213,6 +299,10 @@ export const govModule: RSpaceModule = { "folk-gov-knob", "folk-gov-project", "folk-gov-amendment", + "folk-gov-quadratic", + "folk-gov-conviction", + "folk-gov-multisig", + "folk-gov-sankey", ], canvasToolIds: [ "create_binary_gate", @@ -220,6 +310,10 @@ export const govModule: RSpaceModule = { "create_gov_knob", "create_gov_project", "create_amendment", + "create_quadratic_transform", + "create_conviction_gate", + "create_multisig_gate", + "create_sankey_visualizer", ], onboardingActions: [ { label: "Build a Circuit", icon: "⚖️", description: "Create a governance decision circuit on the canvas", type: 'create', href: '/rgov' },