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` +
+ + + ASCII Art + +
+ +
+
+
+
+ +
+ + + + × + + +
+
+
+
+ + Pick a pattern and click Generate +
+
+ +
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) containerDiv.replaceWith(wrapper); + + this.#promptInput = wrapper.querySelector(".prompt-input"); + this.#patternSelect = wrapper.querySelector(".pattern-select"); + this.#paletteSelect = wrapper.querySelector(".palette-select"); + this.#widthInput = wrapper.querySelector('input[title="Width"]'); + this.#heightInput = wrapper.querySelector('input[title="Height"]'); + this.#previewArea = wrapper.querySelector(".preview-area"); + this.#generateBtn = wrapper.querySelector(".generate-btn"); + this.#actionsBar = wrapper.querySelector(".actions-bar"); + + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + const copyTextBtn = wrapper.querySelector(".copy-text-btn") as HTMLButtonElement; + const copyHtmlBtn = wrapper.querySelector(".copy-html-btn") as HTMLButtonElement; + + this.#generateBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#generate(); + }); + + this.#promptInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + this.#generate(); + } + }); + + copyTextBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.#textOutput) navigator.clipboard.writeText(this.#textOutput); + }); + + copyHtmlBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.#htmlOutput) navigator.clipboard.writeText(this.#htmlOutput); + }); + + closeBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent canvas drag + this.#previewArea?.addEventListener("pointerdown", (e) => e.stopPropagation()); + this.#promptInput?.addEventListener("pointerdown", (e) => e.stopPropagation()); + + return root; + } + + async #generate() { + if (this.#isLoading) return; + + const pattern = this.#patternSelect?.value || "plasma"; + const prompt = this.#promptInput?.value.trim() || pattern; + const palette = this.#paletteSelect?.value || "classic"; + const width = parseInt(this.#widthInput?.value || "80") || 80; + const height = parseInt(this.#heightInput?.value || "40") || 40; + + this.#isLoading = true; + this.#error = null; + if (this.#generateBtn) this.#generateBtn.disabled = true; + this.#renderLoading(); + + try { + const res = await fetch("/api/ascii-gen", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt, pattern, palette, width, height, output_format: "html" }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({ error: `HTTP ${res.status}` })); + throw new Error(data.error || `HTTP ${res.status}`); + } + + const data = await res.json(); + this.#htmlOutput = data.html || null; + this.#textOutput = data.text || null; + this.#renderResult(); + + // Emit to output port + if (this.#textOutput) { + this.dispatchEvent(new CustomEvent("port-output", { + detail: { port: "ascii", value: this.#textOutput }, + bubbles: true, + })); + } + } catch (e: any) { + this.#error = e.message || "Generation failed"; + this.#renderError(); + } finally { + this.#isLoading = false; + if (this.#generateBtn) this.#generateBtn.disabled = false; + } + } + + #renderLoading() { + if (!this.#previewArea) return; + this.#previewArea.innerHTML = `
Generating...
`; + if (this.#actionsBar) this.#actionsBar.style.display = "none"; + } + + #renderError() { + if (!this.#previewArea) return; + this.#previewArea.innerHTML = `
${this.#error}
`; + if (this.#actionsBar) this.#actionsBar.style.display = "none"; + } + + #renderResult() { + if (!this.#previewArea) return; + if (this.#htmlOutput) { + this.#previewArea.innerHTML = `
${this.#htmlOutput}
`; + } else if (this.#textOutput) { + const el = document.createElement("div"); + el.className = "ascii-output"; + el.textContent = this.#textOutput; + this.#previewArea.innerHTML = ""; + this.#previewArea.appendChild(el); + } else { + this.#previewArea.innerHTML = `
No output received
`; + } + if (this.#actionsBar) this.#actionsBar.style.display = "flex"; + } +} diff --git a/lib/folk-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` +
+ ⏳ Conviction + + + +
+
+ +
+ Mode: + + + Threshold: + + +
+
+
+
0 / 10
+
+ +
+
+
+ WAITING +
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement; + this.#thresholdEl = wrapper.querySelector(".threshold-input") as HTMLInputElement; + this.#thresholdRow = wrapper.querySelector(".threshold-row") as HTMLElement; + this.#progressWrap = wrapper.querySelector(".progress-wrap") as HTMLElement; + this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; + this.#progressLabel = wrapper.querySelector(".progress-label") as HTMLElement; + this.#scoreDisplay = wrapper.querySelector(".score-display") as HTMLElement; + this.#velocityLabel = wrapper.querySelector(".velocity-label") as HTMLElement; + this.#chartEl = wrapper.querySelector(".chart-area") as HTMLElement; + this.#stakesList = wrapper.querySelector(".stakes-list") as HTMLElement; + this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#modeEl.value = this.#convictionMode; + this.#thresholdEl.value = String(this.#threshold); + this.#updateLayout(); + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#modeEl.addEventListener("change", (e) => { + e.stopPropagation(); + this.#convictionMode = this.#modeEl.value as ConvictionMode; + this.#updateLayout(); + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#thresholdEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#threshold = parseFloat(this.#thresholdEl.value) || 0; + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, select, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Handle input ports + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "stake-in" && value && typeof value === "object") { + const v = value as any; + const stake: ConvictionStake = { + userId: v.userId || v.who || crypto.randomUUID().slice(0, 8), + userName: v.userName || v.who || "anonymous", + optionId: "gate", + weight: v.weight || v.amount || 1, + since: v.since || Date.now(), + }; + // Update existing or add + const idx = this.#stakes.findIndex(s => s.userId === stake.userId); + if (idx >= 0) { + this.#stakes[idx] = stake; + } else { + this.#stakes.push(stake); + } + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + if (name === "threshold-in" && typeof value === "number") { + this.#threshold = value; + if (this.#thresholdEl) this.#thresholdEl.value = String(value); + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + }) as EventListener); + + // Tick timer for live conviction updates + this.#tickInterval = setInterval(() => { + this.#updateVisuals(); + this.#emitPorts(); + }, 10000); + + return root; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.#tickInterval) { + clearInterval(this.#tickInterval); + this.#tickInterval = null; + } + } + + #updateLayout() { + if (!this.#thresholdRow) return; + const isGate = this.#convictionMode === "gate"; + this.#thresholdRow.style.display = isGate ? "" : "none"; + if (this.#progressWrap) this.#progressWrap.style.display = isGate ? "" : "none"; + if (this.#scoreDisplay) this.#scoreDisplay.style.display = isGate ? "none" : ""; + } + + #updateVisuals() { + const score = this.#getTotalScore(); + const velocity = this.#getTotalVelocity(); + + if (this.#convictionMode === "gate") { + // Gate mode: progress bar + const pct = this.#threshold > 0 ? Math.min(100, (score / this.#threshold) * 100) : 0; + const satisfied = score >= this.#threshold; + + if (this.#progressBar) { + this.#progressBar.style.width = `${pct}%`; + this.#progressBar.classList.toggle("complete", satisfied); + } + if (this.#progressLabel) { + this.#progressLabel.textContent = `${this.#fmtScore(score)} / ${this.#threshold}`; + } + if (this.#statusEl) { + this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING"; + this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`; + } + } else { + // Tuner mode: score display + if (this.#scoreDisplay) { + this.#scoreDisplay.textContent = this.#fmtScore(score); + } + if (this.#statusEl) { + this.#statusEl.textContent = "EMITTING"; + this.#statusEl.className = "status-label tuner"; + } + } + + if (this.#velocityLabel) { + this.#velocityLabel.textContent = `velocity: ${velocity.toFixed(1)} wt/hr`; + } + + this.#renderChart(); + this.#renderStakes(); + } + + #renderChart() { + if (!this.#chartEl || this.#stakes.length === 0) { + if (this.#chartEl) this.#chartEl.innerHTML = ""; + return; + } + + const now = Date.now(); + const W = 220; + const H = 60; + const PAD = { top: 6, right: 8, bottom: 12, left: 28 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + const earliest = Math.min(...this.#stakes.map(s => s.since)); + const timeRange = Math.max(now - earliest, 60000); + + // Sample conviction curve at 20 points + const SAMPLES = 20; + const points: { t: number; v: number }[] = []; + let maxV = 0; + for (let i = 0; i <= SAMPLES; i++) { + const t = earliest + (timeRange * i) / SAMPLES; + let v = 0; + for (const s of this.#stakes) { + if (s.since <= t) v += s.weight * Math.max(0, t - s.since) / 3600000; + } + points.push({ t, v }); + maxV = Math.max(maxV, v); + } + if (maxV === 0) maxV = 1; + + const x = (t: number) => PAD.left + ((t - earliest) / timeRange) * plotW; + const y = (v: number) => PAD.top + (1 - v / maxV) * plotH; + + let svg = ``; + + // Threshold line in gate mode + if (this.#convictionMode === "gate" && this.#threshold > 0 && this.#threshold <= maxV) { + const ty = y(this.#threshold); + svg += ``; + } + + // Area + const areaD = `M${x(points[0].t)},${y(0)} ` + + points.map(p => `L${x(p.t)},${y(p.v)}`).join(" ") + + ` L${x(points[points.length - 1].t)},${y(0)} Z`; + svg += ``; + + // Line + const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" "); + svg += ``; + + // End dot + const last = points[points.length - 1]; + svg += ``; + + // Y axis + svg += `${this.#fmtScore(maxV)}`; + svg += `0`; + + svg += ""; + this.#chartEl.innerHTML = svg; + } + + #renderStakes() { + if (!this.#stakesList) return; + const now = Date.now(); + this.#stakesList.innerHTML = this.#stakes.map(s => { + const dur = this.#fmtDuration(now - s.since); + return `
${s.userName} (wt:${s.weight})${dur}
`; + }).join(""); + } + + #emitPorts() { + const score = this.#getTotalScore(); + const velocity = this.#getTotalVelocity(); + const satisfied = this.#convictionMode === "gate" ? score >= this.#threshold : true; + + this.setPortValue("conviction-out", { + score, + velocity, + stakeCount: this.#stakes.length, + mode: this.#convictionMode, + }); + + this.setPortValue("gate-out", { + satisfied, + score, + threshold: this.#threshold, + mode: this.#convictionMode, + }); + } + + #fmtScore(v: number): string { + if (v < 1) return v.toFixed(2); + if (v < 100) return v.toFixed(1); + return Math.round(v).toString(); + } + + #fmtDuration(ms: number): string { + if (ms < 60000) return "<1m"; + if (ms < 3600000) return `${Math.floor(ms / 60000)}m`; + if (ms < 86400000) return `${Math.floor(ms / 3600000)}h`; + return `${Math.floor(ms / 86400000)}d`; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-conviction", + title: this.#title, + convictionMode: this.#convictionMode, + threshold: this.#threshold, + stakes: this.#stakes, + }; + } + + static override fromData(data: Record): FolkGovConviction { + const shape = FolkShape.fromData.call(this, data) as FolkGovConviction; + if (data.title !== undefined) shape.title = data.title; + if (data.convictionMode !== undefined) shape.convictionMode = data.convictionMode; + if (data.threshold !== undefined) shape.threshold = data.threshold; + if (data.stakes !== undefined) shape.stakes = data.stakes; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.convictionMode !== undefined && data.convictionMode !== this.#convictionMode) this.convictionMode = data.convictionMode; + if (data.threshold !== undefined && data.threshold !== this.#threshold) this.threshold = data.threshold; + if (data.stakes !== undefined && JSON.stringify(data.stakes) !== JSON.stringify(this.#stakes)) this.stakes = data.stakes; + } +} diff --git a/lib/folk-gov-multisig.ts b/lib/folk-gov-multisig.ts new file mode 100644 index 0000000..630289c --- /dev/null +++ b/lib/folk-gov-multisig.ts @@ -0,0 +1,549 @@ +/** + * folk-gov-multisig — M-of-N Multiplexor Gate + * + * Requires M of N named signers before passing. Signers can be added + * manually or auto-populated from upstream binary gates. Shows a + * multiplexor SVG diagram and progress bar. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#6366f1"; + +interface Signer { + name: string; + signed: boolean; + timestamp: number; +} + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 260px; + min-height: 180px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .body { + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 13px; + font-weight: 600; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .mn-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--rs-text-primary, #e2e8f0); + font-weight: 600; + } + + .mn-input { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 12px; + padding: 2px 6px; + width: 40px; + text-align: center; + outline: none; + } + + .mux-svg { + text-align: center; + } + + .mux-svg svg { + display: block; + margin: 0 auto; + } + + .progress-wrap { + position: relative; + height: 16px; + background: rgba(255, 255, 255, 0.08); + border-radius: 8px; + overflow: hidden; + } + + .progress-bar { + height: 100%; + border-radius: 8px; + transition: width 0.3s, background 0.3s; + background: ${HEADER_COLOR}; + } + + .progress-bar.complete { + background: #22c55e; + box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); + } + + .signers-list { + display: flex; + flex-direction: column; + gap: 3px; + max-height: 120px; + overflow-y: auto; + } + + .signer-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + padding: 3px 6px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.03); + color: var(--rs-text-secondary, #94a3b8); + } + + .signer-item.signed { + color: #22c55e; + } + + .signer-icon { + width: 14px; + text-align: center; + font-size: 10px; + } + + .signer-name { + flex: 1; + } + + .signer-toggle { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 10px; + padding: 1px 6px; + cursor: pointer; + } + + .signer-toggle:hover { + background: rgba(255, 255, 255, 0.12); + } + + .add-signer-row { + display: flex; + gap: 4px; + } + + .add-signer-input { + flex: 1; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 3px 6px; + outline: none; + } + + .add-signer-btn { + background: ${HEADER_COLOR}; + border: none; + color: white; + border-radius: 4px; + padding: 3px 8px; + font-size: 11px; + cursor: pointer; + font-weight: 600; + } + + .add-signer-btn:hover { + opacity: 0.85; + } + + .status-label { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: center; + } + + .status-label.satisfied { + color: #22c55e; + } + + .status-label.waiting { + color: #f59e0b; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-multisig": FolkGovMultisig; + } +} + +export class FolkGovMultisig extends FolkShape { + static override tagName = "folk-gov-multisig"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "signer-in", type: "json", direction: "input" }, + { name: "gate-out", type: "json", direction: "output" }, + ]; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #title = "Multisig"; + #requiredM = 2; + #signers: Signer[] = []; + + // DOM refs + #titleEl!: HTMLInputElement; + #mEl!: HTMLInputElement; + #nEl!: HTMLElement; + #muxEl!: HTMLElement; + #progressBar!: HTMLElement; + #signersList!: HTMLElement; + #addInput!: HTMLInputElement; + #statusEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get requiredM() { return this.#requiredM; } + set requiredM(v: number) { + this.#requiredM = v; + if (this.#mEl) this.#mEl.value = String(v); + this.#updateVisuals(); + this.#emitPort(); + } + + get signers(): Signer[] { return [...this.#signers]; } + set signers(v: Signer[]) { + this.#signers = v; + this.#updateVisuals(); + this.#emitPort(); + } + + get #signedCount(): number { + return this.#signers.filter(s => s.signed).length; + } + + get #isSatisfied(): boolean { + return this.#signedCount >= this.#requiredM; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.initPorts(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ 🔐 Multisig + + + +
+
+ +
+ + of + 0 + required +
+
+
+
+
+
+
+ + +
+ WAITING +
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#mEl = wrapper.querySelector(".mn-m-input") as HTMLInputElement; + this.#nEl = wrapper.querySelector(".mn-n-label") as HTMLElement; + this.#muxEl = wrapper.querySelector(".mux-svg") as HTMLElement; + this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; + this.#signersList = wrapper.querySelector(".signers-list") as HTMLElement; + this.#addInput = wrapper.querySelector(".add-signer-input") as HTMLInputElement; + this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#mEl.value = String(this.#requiredM); + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#mEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#requiredM = Math.max(1, parseInt(this.#mEl.value) || 1); + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + wrapper.querySelector(".add-signer-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + const name = this.#addInput.value.trim(); + if (!name) return; + if (this.#signers.some(s => s.name === name)) return; + this.#signers.push({ name, signed: false, timestamp: 0 }); + this.#addInput.value = ""; + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#addInput.addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Enter") { + wrapper.querySelector(".add-signer-btn")!.dispatchEvent(new Event("click")); + } + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Handle input port + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "signer-in" && value && typeof value === "object") { + const v = value as any; + const signerName = v.signedBy || v.who || v.name || ""; + const isSatisfied = v.satisfied === true; + if (signerName && isSatisfied) { + const existing = this.#signers.find(s => s.name === signerName); + if (existing) { + existing.signed = true; + existing.timestamp = v.timestamp || Date.now(); + } else { + this.#signers.push({ name: signerName, signed: true, timestamp: v.timestamp || Date.now() }); + } + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + } + } + }) as EventListener); + + return root; + } + + #updateVisuals() { + const n = this.#signers.length; + const signed = this.#signedCount; + const satisfied = this.#isSatisfied; + const pct = n > 0 ? (signed / Math.max(this.#requiredM, 1)) * 100 : 0; + + if (this.#nEl) this.#nEl.textContent = String(n); + + if (this.#progressBar) { + this.#progressBar.style.width = `${Math.min(100, pct)}%`; + this.#progressBar.classList.toggle("complete", satisfied); + } + + if (this.#statusEl) { + this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING"; + this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`; + } + + this.#renderMux(); + this.#renderSigners(); + } + + #renderMux() { + if (!this.#muxEl) return; + const n = this.#signers.length; + if (n === 0) { + this.#muxEl.innerHTML = ""; + return; + } + + const W = 180; + const slotH = 14; + const gateW = 30; + const gateH = Math.max(20, n * slotH + 4); + const H = gateH + 16; + const gateX = W / 2 - gateW / 2; + const gateY = (H - gateH) / 2; + + let svg = ``; + + // Gate body + svg += ``; + svg += `${this.#requiredM}/${n}`; + + // Input lines (left side) + for (let i = 0; i < n; i++) { + const y = gateY + 2 + slotH * i + slotH / 2; + const signed = this.#signers[i].signed; + const color = signed ? "#22c55e" : "rgba(255,255,255,0.2)"; + svg += ``; + svg += ``; + } + + // Output line (right side) + const outY = gateY + gateH / 2; + const outColor = this.#isSatisfied ? "#22c55e" : "rgba(255,255,255,0.2)"; + svg += ``; + svg += ``; + + svg += ""; + this.#muxEl.innerHTML = svg; + } + + #renderSigners() { + if (!this.#signersList) return; + this.#signersList.innerHTML = this.#signers.map((s, i) => { + const icon = s.signed ? "✓" : "○"; + const cls = s.signed ? "signer-item signed" : "signer-item"; + const btnLabel = s.signed ? "unsign" : "sign"; + return `
+ ${icon} + ${this.#escapeHtml(s.name)} + +
`; + }).join(""); + + // Wire toggle buttons + this.#signersList.querySelectorAll(".signer-toggle").forEach((btn) => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const idx = parseInt((btn as HTMLElement).dataset.idx!); + const signer = this.#signers[idx]; + signer.signed = !signer.signed; + signer.timestamp = signer.signed ? Date.now() : 0; + this.#updateVisuals(); + this.#emitPort(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + btn.addEventListener("pointerdown", (e) => e.stopPropagation()); + }); + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #emitPort() { + this.setPortValue("gate-out", { + satisfied: this.#isSatisfied, + signed: this.#signedCount, + required: this.#requiredM, + total: this.#signers.length, + signers: this.#signers.filter(s => s.signed).map(s => s.name), + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-multisig", + title: this.#title, + requiredM: this.#requiredM, + signers: this.#signers, + }; + } + + static override fromData(data: Record): FolkGovMultisig { + const shape = FolkShape.fromData.call(this, data) as FolkGovMultisig; + if (data.title !== undefined) shape.title = data.title; + if (data.requiredM !== undefined) shape.requiredM = data.requiredM; + if (data.signers !== undefined) shape.signers = data.signers; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.requiredM !== undefined && data.requiredM !== this.#requiredM) this.requiredM = data.requiredM; + if (data.signers !== undefined && JSON.stringify(data.signers) !== JSON.stringify(this.#signers)) this.signers = data.signers; + } +} diff --git a/lib/folk-gov-project.ts b/lib/folk-gov-project.ts index 1e14966..e12ca04 100644 --- a/lib/folk-gov-project.ts +++ b/lib/folk-gov-project.ts @@ -16,6 +16,9 @@ const GOV_TAG_NAMES = new Set([ "FOLK-GOV-THRESHOLD", "FOLK-GOV-KNOB", "FOLK-GOV-AMENDMENT", + "FOLK-GOV-QUADRATIC", + "FOLK-GOV-CONVICTION", + "FOLK-GOV-MULTISIG", ]); type ProjectStatus = "draft" | "active" | "completed" | "archived"; diff --git a/lib/folk-gov-quadratic.ts b/lib/folk-gov-quadratic.ts new file mode 100644 index 0000000..81c8a2b --- /dev/null +++ b/lib/folk-gov-quadratic.ts @@ -0,0 +1,409 @@ +/** + * folk-gov-quadratic — Weight Transformer + * + * Inline weight transform GovMod. Accepts raw weight on input port, + * applies sqrt/log/linear transform, and emits effective weight on output. + * Always passes (gate-out = satisfied). Visualizes raw vs effective in a bar chart. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; + +const HEADER_COLOR = "#14b8a6"; + +type TransformMode = "sqrt" | "log" | "linear"; + +interface WeightEntry { + who: string; + raw: number; + effective: number; +} + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 240px; + min-height: 140px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .body { + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 13px; + font-weight: 600; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .mode-row { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + } + + .mode-select { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + color: var(--rs-text-primary, #e2e8f0); + font-size: 11px; + padding: 2px 6px; + outline: none; + } + + .chart-area { + min-height: 60px; + } + + .chart-area svg { + width: 100%; + display: block; + } + + .entries-list { + max-height: 80px; + overflow-y: auto; + font-size: 10px; + color: var(--rs-text-muted, #94a3b8); + } + + .entry-item { + display: flex; + justify-content: space-between; + padding: 2px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + } + + .status-label { + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + text-align: center; + color: #22c55e; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-quadratic": FolkGovQuadratic; + } +} + +export class FolkGovQuadratic extends FolkShape { + static override tagName = "folk-gov-quadratic"; + + static override portDescriptors: PortDescriptor[] = [ + { name: "weight-in", type: "json", direction: "input" }, + { name: "weight-out", type: "json", direction: "output" }, + { name: "gate-out", type: "json", direction: "output" }, + ]; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #title = "Weight Transform"; + #mode: TransformMode = "sqrt"; + #entries: WeightEntry[] = []; + + // DOM refs + #titleEl!: HTMLInputElement; + #modeEl!: HTMLSelectElement; + #chartEl!: HTMLElement; + #listEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + get mode() { return this.#mode; } + set mode(v: TransformMode) { + this.#mode = v; + if (this.#modeEl) this.#modeEl.value = v; + this.#recalc(); + } + + get entries(): WeightEntry[] { return [...this.#entries]; } + set entries(v: WeightEntry[]) { + this.#entries = v; + this.#updateVisuals(); + this.#emitPorts(); + } + + #transform(raw: number): number { + if (raw <= 0) return 0; + switch (this.#mode) { + case "sqrt": return Math.sqrt(raw); + case "log": return Math.log1p(raw); + case "linear": return raw; + } + } + + #recalc() { + for (const e of this.#entries) { + e.effective = this.#transform(e.raw); + } + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + this.initPorts(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ √ Quadratic + + + +
+
+ +
+ Mode: + +
+
+
+ PASSTHROUGH +
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement; + this.#chartEl = wrapper.querySelector(".chart-area") as HTMLElement; + this.#listEl = wrapper.querySelector(".entries-list") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + this.#modeEl.value = this.#mode; + this.#updateVisuals(); + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#modeEl.addEventListener("change", (e) => { + e.stopPropagation(); + this.#mode = this.#modeEl.value as TransformMode; + this.#recalc(); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, select, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Handle input port + this.addEventListener("port-value-changed", ((e: CustomEvent) => { + const { name, value } = e.detail; + if (name === "weight-in" && value && typeof value === "object") { + const v = value as any; + // Accept { who, weight } or { who, raw } + const who = v.who || v.memberName || "anonymous"; + const raw = v.weight || v.raw || v.amount || 0; + // Update or add + const existing = this.#entries.find(e => e.who === who); + if (existing) { + existing.raw = raw; + existing.effective = this.#transform(raw); + } else { + this.#entries.push({ who, raw, effective: this.#transform(raw) }); + } + this.#updateVisuals(); + this.#emitPorts(); + this.dispatchEvent(new CustomEvent("content-change")); + } + }) as EventListener); + + return root; + } + + #updateVisuals() { + this.#renderChart(); + this.#renderList(); + } + + #renderChart() { + if (!this.#chartEl) return; + if (this.#entries.length === 0) { + this.#chartEl.innerHTML = ""; + return; + } + + const W = 220; + const H = 70; + const PAD = { top: 6, right: 8, bottom: 16, left: 8 }; + const plotW = W - PAD.left - PAD.right; + const plotH = H - PAD.top - PAD.bottom; + + const maxRaw = Math.max(1, ...this.#entries.map(e => e.raw)); + const maxEff = Math.max(1, ...this.#entries.map(e => e.effective)); + const maxVal = Math.max(maxRaw, maxEff); + const barW = Math.max(6, Math.min(20, plotW / (this.#entries.length * 2.5))); + + let svg = ``; + + // Grid line + svg += ``; + + const entries = this.#entries.slice(0, 8); // max 8 bars + const groupW = plotW / entries.length; + + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + const cx = PAD.left + groupW * i + groupW / 2; + const rawH = (e.raw / maxVal) * plotH; + const effH = (e.effective / maxVal) * plotH; + + // Raw bar (dimmed) + svg += ``; + // Effective bar (teal) + svg += ``; + + // Label + const label = e.who.length > 5 ? e.who.slice(0, 5) : e.who; + svg += `${label}`; + } + + // Legend + svg += ``; + svg += `raw`; + svg += ``; + svg += `eff`; + + svg += ""; + this.#chartEl.innerHTML = svg; + } + + #renderList() { + if (!this.#listEl) return; + this.#listEl.innerHTML = this.#entries.map(e => + `
${e.who}${e.raw.toFixed(1)} → ${e.effective.toFixed(2)}
` + ).join(""); + } + + #emitPorts() { + const totalRaw = this.#entries.reduce((s, e) => s + e.raw, 0); + const totalEffective = this.#entries.reduce((s, e) => s + e.effective, 0); + + this.setPortValue("weight-out", { + totalRaw, + totalEffective, + mode: this.#mode, + entries: this.#entries.map(e => ({ who: e.who, raw: e.raw, effective: e.effective })), + }); + + // Always satisfied — this is a passthrough transform + this.setPortValue("gate-out", { + satisfied: true, + totalRaw, + totalEffective, + mode: this.#mode, + }); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-quadratic", + title: this.#title, + mode: this.#mode, + entries: this.#entries, + }; + } + + static override fromData(data: Record): FolkGovQuadratic { + const shape = FolkShape.fromData.call(this, data) as FolkGovQuadratic; + if (data.title !== undefined) shape.title = data.title; + if (data.mode !== undefined) shape.mode = data.mode; + if (data.entries !== undefined) shape.entries = data.entries; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + if (data.mode !== undefined && data.mode !== this.#mode) this.mode = data.mode; + if (data.entries !== undefined && JSON.stringify(data.entries) !== JSON.stringify(this.#entries)) this.entries = data.entries; + } +} diff --git a/lib/folk-gov-sankey.ts b/lib/folk-gov-sankey.ts new file mode 100644 index 0000000..ee5ea91 --- /dev/null +++ b/lib/folk-gov-sankey.ts @@ -0,0 +1,512 @@ +/** + * folk-gov-sankey — Governance Flow Visualizer + * + * Auto-discovers all connected governance shapes via arrow graph traversal, + * renders an SVG Sankey diagram with animated flow curves, tooltips, and + * a color-coded legend. Purely visual — no ports. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const HEADER_COLOR = "#7c3aed"; + +// Gov shape tag names recognized by the visualizer +const GOV_TAG_NAMES = new Set([ + "FOLK-GOV-BINARY", + "FOLK-GOV-THRESHOLD", + "FOLK-GOV-KNOB", + "FOLK-GOV-PROJECT", + "FOLK-GOV-AMENDMENT", + "FOLK-GOV-QUADRATIC", + "FOLK-GOV-CONVICTION", + "FOLK-GOV-MULTISIG", +]); + +const TYPE_COLORS: Record = { + "FOLK-GOV-BINARY": "#7c3aed", + "FOLK-GOV-THRESHOLD": "#0891b2", + "FOLK-GOV-KNOB": "#b45309", + "FOLK-GOV-PROJECT": "#1d4ed8", + "FOLK-GOV-AMENDMENT": "#be185d", + "FOLK-GOV-QUADRATIC": "#14b8a6", + "FOLK-GOV-CONVICTION": "#d97706", + "FOLK-GOV-MULTISIG": "#6366f1", +}; + +const TYPE_LABELS: Record = { + "FOLK-GOV-BINARY": "Binary", + "FOLK-GOV-THRESHOLD": "Threshold", + "FOLK-GOV-KNOB": "Knob", + "FOLK-GOV-PROJECT": "Project", + "FOLK-GOV-AMENDMENT": "Amendment", + "FOLK-GOV-QUADRATIC": "Quadratic", + "FOLK-GOV-CONVICTION": "Conviction", + "FOLK-GOV-MULTISIG": "Multisig", +}; + +interface SankeyNode { + id: string; + tagName: string; + title: string; + satisfied: boolean; + column: number; // 0 = leftmost + row: number; +} + +interface SankeyFlow { + sourceId: string; + targetId: string; +} + +const styles = css` + :host { + background: var(--rs-bg-surface, #1e293b); + border-radius: 10px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); + min-width: 340px; + min-height: 240px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: ${HEADER_COLOR}; + color: white; + font-size: 12px; + font-weight: 600; + cursor: move; + border-radius: 10px 10px 0 0; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 14px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .body { + display: flex; + flex-direction: column; + padding: 12px; + gap: 8px; + overflow: auto; + max-height: calc(100% - 36px); + } + + .title-input { + background: transparent; + border: none; + color: var(--rs-text-primary, #e2e8f0); + font-size: 13px; + font-weight: 600; + width: 100%; + outline: none; + } + + .title-input::placeholder { + color: var(--rs-text-muted, #64748b); + } + + .summary { + font-size: 11px; + color: var(--rs-text-muted, #94a3b8); + text-align: center; + } + + .sankey-area svg { + width: 100%; + display: block; + } + + .legend { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; + } + + .legend-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 9px; + color: var(--rs-text-muted, #94a3b8); + } + + .legend-dot { + width: 8px; + height: 8px; + border-radius: 2px; + flex-shrink: 0; + } + + .no-data { + font-size: 11px; + color: var(--rs-text-muted, #475569); + font-style: italic; + text-align: center; + padding: 24px 0; + } + + @keyframes flow-dash { + to { stroke-dashoffset: -20; } + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-gov-sankey": FolkGovSankey; + } +} + +export class FolkGovSankey extends FolkShape { + static override tagName = "folk-gov-sankey"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + #title = "Governance Flow"; + #pollInterval: ReturnType | null = null; + #lastHash = ""; + + // DOM refs + #titleEl!: HTMLInputElement; + #summaryEl!: HTMLElement; + #sankeyEl!: HTMLElement; + #legendEl!: HTMLElement; + + get title() { return this.#title; } + set title(v: string) { + this.#title = v; + if (this.#titleEl) this.#titleEl.value = v; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; + wrapper.innerHTML = html` +
+ 📊 Sankey + + + +
+
+ +
+
+
+
+ `; + + const slot = root.querySelector("slot"); + const container = slot?.parentElement as HTMLElement; + if (container) container.replaceWith(wrapper); + + // Cache refs + this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; + this.#summaryEl = wrapper.querySelector(".summary") as HTMLElement; + this.#sankeyEl = wrapper.querySelector(".sankey-area") as HTMLElement; + this.#legendEl = wrapper.querySelector(".legend") as HTMLElement; + + // Set initial values + this.#titleEl.value = this.#title; + + // Wire events + this.#titleEl.addEventListener("input", (e) => { + e.stopPropagation(); + this.#title = this.#titleEl.value; + this.dispatchEvent(new CustomEvent("content-change")); + }); + + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Prevent drag on inputs + for (const el of wrapper.querySelectorAll("input, button")) { + el.addEventListener("pointerdown", (e) => e.stopPropagation()); + } + + // Poll every 3 seconds + this.#pollInterval = setInterval(() => this.#discover(), 3000); + requestAnimationFrame(() => this.#discover()); + + return root; + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.#pollInterval) { + clearInterval(this.#pollInterval); + this.#pollInterval = null; + } + } + + #discover() { + const arrows = document.querySelectorAll("folk-arrow"); + const nodes = new Map(); + const flows: SankeyFlow[] = []; + + // Collect all gov shapes connected by arrows + for (const arrow of arrows) { + const a = arrow as any; + const sourceId = a.sourceId; + const targetId = a.targetId; + if (!sourceId || !targetId) continue; + + // Skip self + if (sourceId === this.id || targetId === this.id) continue; + + const sourceEl = document.getElementById(sourceId) as any; + const targetEl = document.getElementById(targetId) as any; + if (!sourceEl || !targetEl) continue; + + const srcTag = sourceEl.tagName?.toUpperCase(); + const tgtTag = targetEl.tagName?.toUpperCase(); + + const srcIsGov = GOV_TAG_NAMES.has(srcTag); + const tgtIsGov = GOV_TAG_NAMES.has(tgtTag); + + if (!srcIsGov && !tgtIsGov) continue; + + if (srcIsGov && !nodes.has(sourceId)) { + const portVal = sourceEl.getPortValue?.("gate-out"); + nodes.set(sourceId, { + id: sourceId, + tagName: srcTag, + title: sourceEl.title || srcTag, + satisfied: portVal?.satisfied === true, + column: 0, + row: 0, + }); + } + + if (tgtIsGov && !nodes.has(targetId)) { + const portVal = targetEl.getPortValue?.("gate-out") || targetEl.getPortValue?.("circuit-out"); + nodes.set(targetId, { + id: targetId, + tagName: tgtTag, + title: targetEl.title || tgtTag, + satisfied: portVal?.satisfied === true || portVal?.status === "completed", + column: 0, + row: 0, + }); + } + + if (srcIsGov && tgtIsGov) { + flows.push({ sourceId, targetId }); + } + } + + // Hash-based skip + const hash = [...nodes.keys()].sort().join(",") + "|" + + flows.map(f => `${f.sourceId}->${f.targetId}`).sort().join(",") + + "|" + [...nodes.values()].map(n => n.satisfied ? "1" : "0").join(""); + if (hash === this.#lastHash) return; + this.#lastHash = hash; + + this.#layout(nodes, flows); + this.#renderSankey(nodes, flows); + } + + #layout(nodes: Map, flows: SankeyFlow[]) { + if (nodes.size === 0) return; + + // Build adjacency for topological column assignment + const outEdges = new Map(); + const inDegree = new Map(); + for (const n of nodes.keys()) { + outEdges.set(n, []); + inDegree.set(n, 0); + } + for (const f of flows) { + if (nodes.has(f.sourceId) && nodes.has(f.targetId)) { + outEdges.get(f.sourceId)!.push(f.targetId); + inDegree.set(f.targetId, (inDegree.get(f.targetId) || 0) + 1); + } + } + + // BFS topological layering + const queue: string[] = []; + for (const [id, deg] of inDegree) { + if (deg === 0) queue.push(id); + } + + const visited = new Set(); + while (queue.length > 0) { + const id = queue.shift()!; + if (visited.has(id)) continue; + visited.add(id); + + for (const next of outEdges.get(id) || []) { + const parentCol = nodes.get(id)!.column; + const node = nodes.get(next)!; + node.column = Math.max(node.column, parentCol + 1); + const newDeg = (inDegree.get(next) || 1) - 1; + inDegree.set(next, newDeg); + if (newDeg <= 0) queue.push(next); + } + } + + // Assign rows within each column + const columns = new Map(); + for (const [id, node] of nodes) { + const col = node.column; + if (!columns.has(col)) columns.set(col, []); + columns.get(col)!.push(id); + } + for (const [, ids] of columns) { + ids.forEach((id, i) => { + nodes.get(id)!.row = i; + }); + } + } + + #renderSankey(nodes: Map, flows: SankeyFlow[]) { + if (nodes.size === 0) { + if (this.#summaryEl) this.#summaryEl.textContent = ""; + if (this.#sankeyEl) this.#sankeyEl.innerHTML = `
Drop near gov shapes to visualize flows
`; + if (this.#legendEl) this.#legendEl.innerHTML = ""; + return; + } + + // Summary + if (this.#summaryEl) { + this.#summaryEl.textContent = `${nodes.size} shapes, ${flows.length} flows`; + } + + // Calculate dimensions + const maxCol = Math.max(...[...nodes.values()].map(n => n.column)); + const columns = new Map(); + for (const n of nodes.values()) { + if (!columns.has(n.column)) columns.set(n.column, []); + columns.get(n.column)!.push(n); + } + const maxRows = Math.max(...[...columns.values()].map(c => c.length)); + + const NODE_W = 80; + const NODE_H = 28; + const COL_GAP = 60; + const ROW_GAP = 12; + const PAD = 16; + + const W = PAD * 2 + (maxCol + 1) * NODE_W + maxCol * COL_GAP; + const H = PAD * 2 + maxRows * NODE_H + (maxRows - 1) * ROW_GAP; + + const nodeX = (col: number) => PAD + col * (NODE_W + COL_GAP); + const nodeY = (col: number, row: number) => { + const colNodes = columns.get(col) || []; + const totalH = colNodes.length * NODE_H + (colNodes.length - 1) * ROW_GAP; + const offsetY = (H - totalH) / 2; + return offsetY + row * (NODE_H + ROW_GAP); + }; + + let svg = ``; + + // Flows (Bezier curves) + for (const f of flows) { + const src = nodes.get(f.sourceId); + const tgt = nodes.get(f.targetId); + if (!src || !tgt) continue; + + const sx = nodeX(src.column) + NODE_W; + const sy = nodeY(src.column, src.row) + NODE_H / 2; + const tx = nodeX(tgt.column); + const ty = nodeY(tgt.column, tgt.row) + NODE_H / 2; + const cx1 = sx + (tx - sx) * 0.4; + const cx2 = tx - (tx - sx) * 0.4; + + const color = TYPE_COLORS[src.tagName] || "#94a3b8"; + + // Background curve + svg += ``; + // Animated dash curve + svg += ``; + } + + // Nodes + for (const n of nodes.values()) { + const x = nodeX(n.column); + const y = nodeY(n.column, n.row); + const color = TYPE_COLORS[n.tagName] || "#94a3b8"; + const fillOpacity = n.satisfied ? "0.25" : "0.1"; + + svg += ``; + + // Satisfied glow + if (n.satisfied) { + svg += ``; + } + + // Label (truncated) + const label = n.title.length > 12 ? n.title.slice(0, 11) + "..." : n.title; + svg += `${this.#escapeXml(label)}`; + + // Tooltip title + svg += `${this.#escapeXml(n.title)} (${TYPE_LABELS[n.tagName] || n.tagName}) - ${n.satisfied ? "Satisfied" : "Waiting"}`; + } + + svg += ""; + if (this.#sankeyEl) this.#sankeyEl.innerHTML = svg; + + // Legend + if (this.#legendEl) { + const usedTypes = new Set([...nodes.values()].map(n => n.tagName)); + this.#legendEl.innerHTML = [...usedTypes].map(t => { + const color = TYPE_COLORS[t] || "#94a3b8"; + const label = TYPE_LABELS[t] || t; + return `
${label}
`; + }).join(""); + } + } + + #escapeXml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-gov-sankey", + title: this.#title, + }; + } + + static override fromData(data: Record): FolkGovSankey { + const shape = FolkShape.fromData.call(this, data) as FolkGovSankey; + if (data.title !== undefined) shape.title = data.title; + return shape; + } + + override applyData(data: Record): void { + super.applyData(data); + if (data.title !== undefined && data.title !== this.#title) this.title = data.title; + } +} diff --git a/lib/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; }