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..29f0e87 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: {
@@ -614,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-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`
+
+
+
+
+
+ ▦
+ Pick a pattern and click Generate
+
+
+
+ Copy Text
+ Copy HTML
+
+
+ `;
+
+ 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 = ``;
+ 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-commitment-pool.ts b/lib/folk-commitment-pool.ts
index b4499cb..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);
}
@@ -271,6 +275,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);
@@ -317,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 ──
@@ -354,8 +369,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));
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`
+
+
+
+
+ Mode:
+
+ Gate
+ Tuner
+
+
+ Threshold:
+
+
+
+
+
0.00
+
+
+
+
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`
+
+
+
+
+
+ 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)}
+ ${btnLabel}
+
`;
+ }).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`
+
+
+
+
+ Mode:
+
+ √ Sqrt
+ log(1+x)
+ Linear
+
+
+
+
+
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`
+
+
+ `;
+
+ 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/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..8aa4d07 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";
@@ -87,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/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/rcart/mod.ts b/modules/rcart/mod.ts
index 38daf12..a1e0d63 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 {
@@ -46,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;
@@ -1777,18 +1786,24 @@ 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',
};
- // 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);
@@ -1852,16 +1867,25 @@ 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',
};
- 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);
@@ -2523,6 +2547,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",
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/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' },
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/index.ts b/server/index.ts
index 9ae3b22..32deafa 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,63 @@ 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 || "classic",
+ 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);
+ }
+ // 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);
+ }
+});
+
+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 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);
+ }
+});
+
// 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/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/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/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) ──
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;
}