Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m41s
Details
CI/CD / deploy (push) Failing after 2m41s
Details
This commit is contained in:
commit
eff95072ea
|
|
@ -318,12 +318,13 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- rspace-internal
|
- rspace-internal
|
||||||
|
|
||||||
# ── Scribus noVNC (rDesign DTP workspace) ──
|
# ── Scribus noVNC (rDesign DTP workspace) — on-demand sidecar ──
|
||||||
scribus-novnc:
|
scribus-novnc:
|
||||||
build:
|
build:
|
||||||
context: ./docker/scribus-novnc
|
context: ./docker/scribus-novnc
|
||||||
container_name: scribus-novnc
|
container_name: scribus-novnc
|
||||||
restart: unless-stopped
|
restart: "no"
|
||||||
|
profiles: ["sidecar"]
|
||||||
mem_limit: 512m
|
mem_limit: 512m
|
||||||
cpus: 1
|
cpus: 1
|
||||||
volumes:
|
volumes:
|
||||||
|
|
@ -342,22 +343,15 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 30s
|
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:
|
networks:
|
||||||
- traefik-public
|
|
||||||
- rspace-internal
|
- rspace-internal
|
||||||
|
|
||||||
# ── Open Notebook (NotebookLM-like RAG service) ──
|
# ── Open Notebook (NotebookLM-like RAG service) — on-demand sidecar ──
|
||||||
open-notebook:
|
open-notebook:
|
||||||
image: ghcr.io/lfnovo/open-notebook:v1-latest-single
|
image: ghcr.io/lfnovo/open-notebook:v1-latest-single
|
||||||
container_name: open-notebook
|
container_name: open-notebook
|
||||||
restart: always
|
restart: "no"
|
||||||
|
profiles: ["sidecar"]
|
||||||
mem_limit: 1g
|
mem_limit: 1g
|
||||||
cpus: 1
|
cpus: 1
|
||||||
env_file: ./open-notebook.env
|
env_file: ./open-notebook.env
|
||||||
|
|
@ -365,21 +359,8 @@ services:
|
||||||
- open-notebook-data:/app/data
|
- open-notebook-data:/app/data
|
||||||
- open-notebook-db:/mydata
|
- open-notebook-db:/mydata
|
||||||
networks:
|
networks:
|
||||||
- traefik-public
|
- rspace-internal
|
||||||
- ai-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:
|
volumes:
|
||||||
rspace-data:
|
rspace-data:
|
||||||
|
|
|
||||||
|
|
@ -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 ──
|
// ── Design Agent Tool ──
|
||||||
registry.push({
|
registry.push({
|
||||||
declaration: {
|
declaration: {
|
||||||
|
|
@ -614,6 +650,96 @@ registry.push(
|
||||||
}),
|
}),
|
||||||
actionLabel: (args) => `Created amendment: ${args.title}`,
|
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];
|
export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry];
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
<div class="header">
|
||||||
|
<span class="header-title">
|
||||||
|
<span style="font-size:14px">▦</span>
|
||||||
|
<span>ASCII Art</span>
|
||||||
|
</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="close-btn" title="Close">×</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="prompt-area">
|
||||||
|
<textarea class="prompt-input" placeholder="Pattern name or describe what to generate..." rows="2"></textarea>
|
||||||
|
<div class="controls">
|
||||||
|
<select class="pattern-select">
|
||||||
|
${PATTERNS.map((p) => `<option value="${p}">${p}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
<select class="palette-select">
|
||||||
|
${PALETTES.map((p) => `<option value="${p}"${p === "classic" ? " selected" : ""}>${p}</option>`).join("")}
|
||||||
|
</select>
|
||||||
|
<input class="size-input" type="number" min="20" max="300" value="80" title="Width" placeholder="W" />
|
||||||
|
<span style="line-height:28px;color:#94a3b8;font-size:11px">×</span>
|
||||||
|
<input class="size-input" type="number" min="10" max="150" value="40" title="Height" placeholder="H" />
|
||||||
|
<button class="generate-btn">Generate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-area">
|
||||||
|
<div class="placeholder">
|
||||||
|
<span class="placeholder-icon">▦</span>
|
||||||
|
<span>Pick a pattern and click Generate</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-bar" style="display:none">
|
||||||
|
<button class="action-btn copy-text-btn">Copy Text</button>
|
||||||
|
<button class="action-btn copy-html-btn">Copy HTML</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `<div class="loading"><div class="spinner"></div><span style="font-size:12px;color:#64748b">Generating...</span></div>`;
|
||||||
|
if (this.#actionsBar) this.#actionsBar.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderError() {
|
||||||
|
if (!this.#previewArea) return;
|
||||||
|
this.#previewArea.innerHTML = `<div class="error">${this.#error}</div>`;
|
||||||
|
if (this.#actionsBar) this.#actionsBar.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderResult() {
|
||||||
|
if (!this.#previewArea) return;
|
||||||
|
if (this.#htmlOutput) {
|
||||||
|
this.#previewArea.innerHTML = `<div class="ascii-output">${this.#htmlOutput}</div>`;
|
||||||
|
} 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 = `<div class="placeholder"><span>No output received</span></div>`;
|
||||||
|
}
|
||||||
|
if (this.#actionsBar) this.#actionsBar.style.display = "flex";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,7 +48,11 @@ class Orb {
|
||||||
|
|
||||||
constructor(c: PoolCommitment, cx: number, cy: number, r: number) {
|
constructor(c: PoolCommitment, cx: number, cy: number, r: number) {
|
||||||
this.c = c;
|
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;
|
this.radius = this.baseRadius;
|
||||||
const a = Math.random() * Math.PI * 2;
|
const a = Math.random() * Math.PI * 2;
|
||||||
const d = Math.random() * (r - this.baseRadius - 10);
|
const d = Math.random() * (r - this.baseRadius - 10);
|
||||||
|
|
@ -84,7 +88,7 @@ class Orb {
|
||||||
|
|
||||||
const isH = hovered === this;
|
const isH = hovered === this;
|
||||||
this.hoverT += ((isH ? 1 : 0) - this.hoverT) * 0.12;
|
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);
|
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);
|
if (container) container.replaceWith(this.#wrapper);
|
||||||
|
|
||||||
this.#canvas = this.#wrapper.querySelector("canvas")!;
|
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.#ctx = this.#canvas.getContext("2d")!;
|
||||||
|
|
||||||
this.#canvas.addEventListener("pointermove", this.#onPointerMove);
|
this.#canvas.addEventListener("pointermove", this.#onPointerMove);
|
||||||
|
|
@ -317,9 +322,19 @@ export class FolkCommitmentPool extends FolkShape {
|
||||||
const emptyMsg = this.#wrapper.querySelector(".empty-msg") as HTMLElement;
|
const emptyMsg = this.#wrapper.querySelector(".empty-msg") as HTMLElement;
|
||||||
if (emptyMsg) emptyMsg.style.display = commitments.length === 0 ? "flex" : "none";
|
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]));
|
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 ──
|
// ── Canvas coord helpers ──
|
||||||
|
|
@ -354,8 +369,9 @@ export class FolkCommitmentPool extends FolkShape {
|
||||||
const orb = this.#findOrbAt(x, y);
|
const orb = this.#findOrbAt(x, y);
|
||||||
if (!orb) return;
|
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.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
this.#draggingOrb = orb;
|
this.#draggingOrb = orb;
|
||||||
this.#ripples.push(new Ripple(orb.x, orb.y, orb.color));
|
this.#ripples.push(new Ripple(orb.x, orb.y, orb.color));
|
||||||
|
|
|
||||||
|
|
@ -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<typeof setInterval> | 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`
|
||||||
|
<div class="header" data-drag>
|
||||||
|
<span class="header-title">⏳ Conviction</span>
|
||||||
|
<span class="header-actions">
|
||||||
|
<button class="close-btn">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<input class="title-input" type="text" placeholder="Conviction gate title..." />
|
||||||
|
<div class="config-row">
|
||||||
|
<span>Mode:</span>
|
||||||
|
<select class="mode-select">
|
||||||
|
<option value="gate">Gate</option>
|
||||||
|
<option value="tuner">Tuner</option>
|
||||||
|
</select>
|
||||||
|
<span class="threshold-row">
|
||||||
|
<span>Threshold:</span>
|
||||||
|
<input class="threshold-input" type="number" min="0" step="1" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-bar" style="width: 0%"></div>
|
||||||
|
<div class="progress-label">0 / 10</div>
|
||||||
|
</div>
|
||||||
|
<div class="score-display" style="display:none">0.00</div>
|
||||||
|
<div class="velocity-label"></div>
|
||||||
|
<div class="chart-area"></div>
|
||||||
|
<div class="stakes-list"></div>
|
||||||
|
<span class="status-label waiting">WAITING</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
|
||||||
|
|
||||||
|
// Threshold line in gate mode
|
||||||
|
if (this.#convictionMode === "gate" && this.#threshold > 0 && this.#threshold <= maxV) {
|
||||||
|
const ty = y(this.#threshold);
|
||||||
|
svg += `<line x1="${PAD.left}" y1="${ty}" x2="${W - PAD.right}" y2="${ty}" stroke="#22c55e" stroke-width="0.5" stroke-dasharray="3,2"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 += `<path d="${areaD}" fill="${HEADER_COLOR}" opacity="0.15"/>`;
|
||||||
|
|
||||||
|
// Line
|
||||||
|
const lineD = points.map((p, i) => `${i === 0 ? "M" : "L"}${x(p.t)},${y(p.v)}`).join(" ");
|
||||||
|
svg += `<path d="${lineD}" fill="none" stroke="${HEADER_COLOR}" stroke-width="1.5" stroke-linejoin="round"/>`;
|
||||||
|
|
||||||
|
// End dot
|
||||||
|
const last = points[points.length - 1];
|
||||||
|
svg += `<circle cx="${x(last.t)}" cy="${y(last.v)}" r="2.5" fill="${HEADER_COLOR}"/>`;
|
||||||
|
|
||||||
|
// Y axis
|
||||||
|
svg += `<text x="${PAD.left - 3}" y="${PAD.top + 4}" text-anchor="end" font-size="7" fill="#94a3b8" font-family="system-ui">${this.#fmtScore(maxV)}</text>`;
|
||||||
|
svg += `<text x="${PAD.left - 3}" y="${y(0)}" text-anchor="end" font-size="7" fill="#94a3b8" font-family="system-ui">0</text>`;
|
||||||
|
|
||||||
|
svg += "</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 `<div class="stake-item"><span>${s.userName} (wt:${s.weight})</span><span>${dur}</span></div>`;
|
||||||
|
}).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<string, any>): 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<string, any>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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`
|
||||||
|
<div class="header" data-drag>
|
||||||
|
<span class="header-title">🔐 Multisig</span>
|
||||||
|
<span class="header-actions">
|
||||||
|
<button class="close-btn">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<input class="title-input" type="text" placeholder="Multisig title..." />
|
||||||
|
<div class="mn-row">
|
||||||
|
<input class="mn-m-input mn-input" type="number" min="1" />
|
||||||
|
<span>of</span>
|
||||||
|
<span class="mn-n-label">0</span>
|
||||||
|
<span>required</span>
|
||||||
|
</div>
|
||||||
|
<div class="mux-svg"></div>
|
||||||
|
<div class="progress-wrap">
|
||||||
|
<div class="progress-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<div class="signers-list"></div>
|
||||||
|
<div class="add-signer-row">
|
||||||
|
<input class="add-signer-input" type="text" placeholder="Add signer..." />
|
||||||
|
<button class="add-signer-btn">+</button>
|
||||||
|
</div>
|
||||||
|
<span class="status-label waiting">WAITING</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `<svg width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">`;
|
||||||
|
|
||||||
|
// Gate body
|
||||||
|
svg += `<rect x="${gateX}" y="${gateY}" width="${gateW}" height="${gateH}" rx="4" fill="rgba(99,102,241,0.15)" stroke="${HEADER_COLOR}" stroke-width="1.5"/>`;
|
||||||
|
svg += `<text x="${W / 2}" y="${gateY + gateH / 2 + 3}" text-anchor="middle" font-size="8" fill="${HEADER_COLOR}" font-weight="600" font-family="system-ui">${this.#requiredM}/${n}</text>`;
|
||||||
|
|
||||||
|
// 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 += `<line x1="10" y1="${y}" x2="${gateX}" y2="${y}" stroke="${color}" stroke-width="1.5"/>`;
|
||||||
|
svg += `<circle cx="10" cy="${y}" r="3" fill="${color}"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output line (right side)
|
||||||
|
const outY = gateY + gateH / 2;
|
||||||
|
const outColor = this.#isSatisfied ? "#22c55e" : "rgba(255,255,255,0.2)";
|
||||||
|
svg += `<line x1="${gateX + gateW}" y1="${outY}" x2="${W - 10}" y2="${outY}" stroke="${outColor}" stroke-width="1.5"/>`;
|
||||||
|
svg += `<polygon points="${W - 10},${outY - 4} ${W - 2},${outY} ${W - 10},${outY + 4}" fill="${outColor}"/>`;
|
||||||
|
|
||||||
|
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 `<div class="${cls}">
|
||||||
|
<span class="signer-icon">${icon}</span>
|
||||||
|
<span class="signer-name">${this.#escapeHtml(s.name)}</span>
|
||||||
|
<button class="signer-toggle" data-idx="${i}">${btnLabel}</button>
|
||||||
|
</div>`;
|
||||||
|
}).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<string, any>): 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<string, any>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -16,6 +16,9 @@ const GOV_TAG_NAMES = new Set([
|
||||||
"FOLK-GOV-THRESHOLD",
|
"FOLK-GOV-THRESHOLD",
|
||||||
"FOLK-GOV-KNOB",
|
"FOLK-GOV-KNOB",
|
||||||
"FOLK-GOV-AMENDMENT",
|
"FOLK-GOV-AMENDMENT",
|
||||||
|
"FOLK-GOV-QUADRATIC",
|
||||||
|
"FOLK-GOV-CONVICTION",
|
||||||
|
"FOLK-GOV-MULTISIG",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
type ProjectStatus = "draft" | "active" | "completed" | "archived";
|
type ProjectStatus = "draft" | "active" | "completed" | "archived";
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
<div class="header" data-drag>
|
||||||
|
<span class="header-title">√ Quadratic</span>
|
||||||
|
<span class="header-actions">
|
||||||
|
<button class="close-btn">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<input class="title-input" type="text" placeholder="Transform title..." />
|
||||||
|
<div class="mode-row">
|
||||||
|
<span>Mode:</span>
|
||||||
|
<select class="mode-select">
|
||||||
|
<option value="sqrt">√ Sqrt</option>
|
||||||
|
<option value="log">log(1+x)</option>
|
||||||
|
<option value="linear">Linear</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="chart-area"></div>
|
||||||
|
<div class="entries-list"></div>
|
||||||
|
<span class="status-label">PASSTHROUGH</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
|
||||||
|
|
||||||
|
// Grid line
|
||||||
|
svg += `<line x1="${PAD.left}" y1="${PAD.top + plotH}" x2="${W - PAD.right}" y2="${PAD.top + plotH}" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/>`;
|
||||||
|
|
||||||
|
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 += `<rect x="${cx - barW - 1}" y="${PAD.top + plotH - rawH}" width="${barW}" height="${rawH}" rx="2" fill="rgba(255,255,255,0.15)"/>`;
|
||||||
|
// Effective bar (teal)
|
||||||
|
svg += `<rect x="${cx + 1}" y="${PAD.top + plotH - effH}" width="${barW}" height="${effH}" rx="2" fill="${HEADER_COLOR}"/>`;
|
||||||
|
|
||||||
|
// Label
|
||||||
|
const label = e.who.length > 5 ? e.who.slice(0, 5) : e.who;
|
||||||
|
svg += `<text x="${cx}" y="${H - 2}" text-anchor="middle" font-size="7" fill="#94a3b8" font-family="system-ui">${label}</text>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend
|
||||||
|
svg += `<rect x="${W - 60}" y="2" width="6" height="6" rx="1" fill="rgba(255,255,255,0.15)"/>`;
|
||||||
|
svg += `<text x="${W - 52}" y="7.5" font-size="6" fill="#94a3b8" font-family="system-ui">raw</text>`;
|
||||||
|
svg += `<rect x="${W - 34}" y="2" width="6" height="6" rx="1" fill="${HEADER_COLOR}"/>`;
|
||||||
|
svg += `<text x="${W - 26}" y="7.5" font-size="6" fill="#94a3b8" font-family="system-ui">eff</text>`;
|
||||||
|
|
||||||
|
svg += "</svg>";
|
||||||
|
this.#chartEl.innerHTML = svg;
|
||||||
|
}
|
||||||
|
|
||||||
|
#renderList() {
|
||||||
|
if (!this.#listEl) return;
|
||||||
|
this.#listEl.innerHTML = this.#entries.map(e =>
|
||||||
|
`<div class="entry-item"><span>${e.who}</span><span>${e.raw.toFixed(1)} → ${e.effective.toFixed(2)}</span></div>`
|
||||||
|
).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<string, any>): 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<string, any>): 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<string, string> = {
|
||||||
|
"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<string, string> = {
|
||||||
|
"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<typeof setInterval> | 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`
|
||||||
|
<div class="header" data-drag>
|
||||||
|
<span class="header-title">📊 Sankey</span>
|
||||||
|
<span class="header-actions">
|
||||||
|
<button class="close-btn">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="body">
|
||||||
|
<input class="title-input" type="text" placeholder="Flow visualizer title..." />
|
||||||
|
<div class="summary"></div>
|
||||||
|
<div class="sankey-area"></div>
|
||||||
|
<div class="legend"></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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<string, SankeyNode>();
|
||||||
|
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<string, SankeyNode>, flows: SankeyFlow[]) {
|
||||||
|
if (nodes.size === 0) return;
|
||||||
|
|
||||||
|
// Build adjacency for topological column assignment
|
||||||
|
const outEdges = new Map<string, string[]>();
|
||||||
|
const inDegree = new Map<string, number>();
|
||||||
|
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<string>();
|
||||||
|
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<number, string[]>();
|
||||||
|
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<string, SankeyNode>, flows: SankeyFlow[]) {
|
||||||
|
if (nodes.size === 0) {
|
||||||
|
if (this.#summaryEl) this.#summaryEl.textContent = "";
|
||||||
|
if (this.#sankeyEl) this.#sankeyEl.innerHTML = `<div class="no-data">Drop near gov shapes to visualize flows</div>`;
|
||||||
|
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<number, SankeyNode[]>();
|
||||||
|
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 = `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">`;
|
||||||
|
|
||||||
|
// 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 += `<path d="M${sx},${sy} C${cx1},${sy} ${cx2},${ty} ${tx},${ty}" fill="none" stroke="${color}" stroke-width="3" opacity="0.15"/>`;
|
||||||
|
// Animated dash curve
|
||||||
|
svg += `<path d="M${sx},${sy} C${cx1},${sy} ${cx2},${ty} ${tx},${ty}" fill="none" stroke="${color}" stroke-width="2" stroke-dasharray="6,4" opacity="0.6" style="animation:flow-dash 1.5s linear infinite"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 += `<rect x="${x}" y="${y}" width="${NODE_W}" height="${NODE_H}" rx="6" fill="${color}" fill-opacity="${fillOpacity}" stroke="${color}" stroke-width="1.5"/>`;
|
||||||
|
|
||||||
|
// Satisfied glow
|
||||||
|
if (n.satisfied) {
|
||||||
|
svg += `<rect x="${x}" y="${y}" width="${NODE_W}" height="${NODE_H}" rx="6" fill="none" stroke="#22c55e" stroke-width="1" opacity="0.5"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Label (truncated)
|
||||||
|
const label = n.title.length > 12 ? n.title.slice(0, 11) + "..." : n.title;
|
||||||
|
svg += `<text x="${x + NODE_W / 2}" y="${y + NODE_H / 2 + 3}" text-anchor="middle" font-size="8" fill="${color}" font-weight="600" font-family="system-ui">${this.#escapeXml(label)}</text>`;
|
||||||
|
|
||||||
|
// Tooltip title
|
||||||
|
svg += `<title>${this.#escapeXml(n.title)} (${TYPE_LABELS[n.tagName] || n.tagName}) - ${n.satisfied ? "Satisfied" : "Waiting"}</title>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg += "</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 `<div class="legend-item"><span class="legend-dot" style="background:${color}"></span>${label}</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#escapeXml(text: string): string {
|
||||||
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
override toJSON() {
|
||||||
|
return {
|
||||||
|
...super.toJSON(),
|
||||||
|
type: "folk-gov-sankey",
|
||||||
|
title: this.#title,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static override fromData(data: Record<string, any>): FolkGovSankey {
|
||||||
|
const shape = FolkShape.fromData.call(this, data) as FolkGovSankey;
|
||||||
|
if (data.title !== undefined) shape.title = data.title;
|
||||||
|
return shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
override applyData(data: Record<string, any>): void {
|
||||||
|
super.applyData(data);
|
||||||
|
if (data.title !== undefined && data.title !== this.#title) this.title = data.title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -468,33 +468,59 @@ export class FolkVideoGen extends FolkShape {
|
||||||
? { image: this.#sourceImage, prompt, duration }
|
? { image: this.#sourceImage, prompt, duration }
|
||||||
: { prompt, duration };
|
: { prompt, duration };
|
||||||
|
|
||||||
const response = await fetch(endpoint, {
|
const submitRes = await fetch(endpoint, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!submitRes.ok) {
|
||||||
throw new Error(`Generation failed: ${response.statusText}`);
|
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 = {
|
// Poll for job completion (up to 5 minutes)
|
||||||
id: crypto.randomUUID(),
|
const jobId = submitData.job_id;
|
||||||
prompt,
|
if (!jobId) throw new Error("No job ID returned");
|
||||||
url: result.url || result.video_url,
|
|
||||||
sourceImage: this.#mode === "i2v" ? this.#sourceImage || undefined : undefined,
|
|
||||||
duration,
|
|
||||||
timestamp: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.#videos.unshift(video);
|
const deadline = Date.now() + 300_000;
|
||||||
this.#renderVideos();
|
let elapsed = 0;
|
||||||
this.dispatchEvent(new CustomEvent("video-generated", { detail: { video } }));
|
|
||||||
|
|
||||||
// Clear input
|
while (Date.now() < deadline) {
|
||||||
if (this.#promptInput) this.#promptInput.value = "";
|
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) {
|
} catch (error) {
|
||||||
this.#error = error instanceof Error ? error.message : "Generation failed";
|
this.#error = error instanceof Error ? error.message : "Generation failed";
|
||||||
this.#renderError();
|
this.#renderError();
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ export * from "./folk-drawfast";
|
||||||
export * from "./folk-freecad";
|
export * from "./folk-freecad";
|
||||||
export * from "./folk-kicad";
|
export * from "./folk-kicad";
|
||||||
export * from "./folk-design-agent";
|
export * from "./folk-design-agent";
|
||||||
|
export * from "./folk-ascii-gen";
|
||||||
|
|
||||||
// Advanced Shapes
|
// Advanced Shapes
|
||||||
export * from "./folk-video-chat";
|
export * from "./folk-video-chat";
|
||||||
|
|
@ -87,6 +88,10 @@ export * from "./folk-gov-threshold";
|
||||||
export * from "./folk-gov-knob";
|
export * from "./folk-gov-knob";
|
||||||
export * from "./folk-gov-project";
|
export * from "./folk-gov-project";
|
||||||
export * from "./folk-gov-amendment";
|
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
|
// Decision/Choice Shapes
|
||||||
export * from "./folk-choice-vote";
|
export * from "./folk-choice-vote";
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ const SHAPE_ICONS: Record<string, { icon: string; label: string }> = {
|
||||||
"folk-freecad": { icon: "🔧", label: "CAD" },
|
"folk-freecad": { icon: "🔧", label: "CAD" },
|
||||||
"folk-kicad": { icon: "🔌", label: "PCB" },
|
"folk-kicad": { icon: "🔌", label: "PCB" },
|
||||||
"folk-design-agent": { icon: "🖨️", label: "Design" },
|
"folk-design-agent": { icon: "🖨️", label: "Design" },
|
||||||
|
"folk-ascii-gen": { icon: "▦", label: "ASCII Art" },
|
||||||
// Social
|
// Social
|
||||||
"folk-social-post": { icon: "📣", label: "Social Post" },
|
"folk-social-post": { icon: "📣", label: "Social Post" },
|
||||||
"folk-social-thread": { icon: "🧵", label: "Thread" },
|
"folk-social-thread": { icon: "🧵", label: "Thread" },
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,9 @@ import {
|
||||||
import { extractProductFromUrl } from './extract';
|
import { extractProductFromUrl } from './extract';
|
||||||
import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak';
|
import { createSecureWidgetUrl, extractRootDomain, getTransakApiKey, getTransakEnv } from '../../shared/transak';
|
||||||
import { createMoonPayPaymentUrl, getMoonPayApiKey, getMoonPayEnv } from '../../shared/moonpay';
|
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 QRCode from 'qrcode';
|
||||||
import { createTransport, type Transporter } from "nodemailer";
|
import { createTransport, type Transporter } from "nodemailer";
|
||||||
import {
|
import {
|
||||||
|
|
@ -46,15 +49,21 @@ let _smtpTransport: Transporter | null = null;
|
||||||
|
|
||||||
function getSmtpTransport(): Transporter | null {
|
function getSmtpTransport(): Transporter | null {
|
||||||
if (_smtpTransport) return _smtpTransport;
|
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({
|
_smtpTransport = createTransport({
|
||||||
host: process.env.SMTP_HOST || "mail.rmail.online",
|
host,
|
||||||
port: Number(process.env.SMTP_PORT) || 587,
|
port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587),
|
||||||
secure: Number(process.env.SMTP_PORT) === 465,
|
secure: !isInternal && Number(process.env.SMTP_PORT) === 465,
|
||||||
auth: {
|
...(isInternal ? {} : {
|
||||||
user: process.env.SMTP_USER || "noreply@rmail.online",
|
auth: {
|
||||||
pass: process.env.SMTP_PASS,
|
user: process.env.SMTP_USER || "noreply@rmail.online",
|
||||||
},
|
pass: process.env.SMTP_PASS!,
|
||||||
|
},
|
||||||
|
}),
|
||||||
tls: { rejectUnauthorized: false },
|
tls: { rejectUnauthorized: false },
|
||||||
});
|
});
|
||||||
return _smtpTransport;
|
return _smtpTransport;
|
||||||
|
|
@ -1777,18 +1786,24 @@ routes.post("/api/payments/:id/transak-session", async (c) => {
|
||||||
defaultCryptoAmount: effectiveAmount,
|
defaultCryptoAmount: effectiveAmount,
|
||||||
partnerOrderId: `pay-${paymentId}`,
|
partnerOrderId: `pay-${paymentId}`,
|
||||||
email,
|
email,
|
||||||
|
isAutoFillUserData: 'true',
|
||||||
|
hideExchangeScreen: 'true',
|
||||||
|
paymentMethod: 'credit_debit_card',
|
||||||
themeColor: '6366f1',
|
themeColor: '6366f1',
|
||||||
|
colorMode: 'DARK',
|
||||||
hideMenu: 'true',
|
hideMenu: 'true',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pre-fill fiat amount/currency so user sees the total immediately
|
// Derive fiat amount: use explicit fiatAmount, or infer from crypto amount for stablecoins
|
||||||
if (p.fiatAmount) {
|
const inferredFiat = p.fiatAmount || (USD_STABLECOINS.includes(p.token) ? effectiveAmount : null);
|
||||||
widgetParams.fiatAmount = p.fiatAmount;
|
if (inferredFiat) {
|
||||||
widgetParams.defaultFiatAmount = p.fiatAmount;
|
widgetParams.fiatAmount = inferredFiat;
|
||||||
|
widgetParams.defaultFiatAmount = inferredFiat;
|
||||||
}
|
}
|
||||||
if (p.fiatCurrency) {
|
const fiatCcy = p.fiatCurrency || (USD_STABLECOINS.includes(p.token) ? 'USD' : null);
|
||||||
widgetParams.fiatCurrency = p.fiatCurrency;
|
if (fiatCcy) {
|
||||||
widgetParams.defaultFiatCurrency = p.fiatCurrency;
|
widgetParams.fiatCurrency = fiatCcy;
|
||||||
|
widgetParams.defaultFiatCurrency = fiatCcy;
|
||||||
}
|
}
|
||||||
|
|
||||||
const widgetUrl = await createSecureWidgetUrl(widgetParams);
|
const widgetUrl = await createSecureWidgetUrl(widgetParams);
|
||||||
|
|
@ -1852,16 +1867,25 @@ routes.post("/api/payments/:id/card-session", async (c) => {
|
||||||
defaultCryptoAmount: effectiveAmount,
|
defaultCryptoAmount: effectiveAmount,
|
||||||
partnerOrderId: `pay-${paymentId}`,
|
partnerOrderId: `pay-${paymentId}`,
|
||||||
email,
|
email,
|
||||||
|
isAutoFillUserData: 'true',
|
||||||
|
hideExchangeScreen: 'true',
|
||||||
|
paymentMethod: 'credit_debit_card',
|
||||||
themeColor: '6366f1',
|
themeColor: '6366f1',
|
||||||
|
colorMode: 'DARK',
|
||||||
hideMenu: 'true',
|
hideMenu: 'true',
|
||||||
};
|
};
|
||||||
if (p.fiatAmount) {
|
// Derive fiat amount: use explicit fiatAmount, or infer from crypto amount for stablecoins
|
||||||
widgetParams.fiatAmount = p.fiatAmount;
|
{
|
||||||
widgetParams.defaultFiatAmount = p.fiatAmount;
|
const inferredFiat = p.fiatAmount || (USD_STABLECOINS.includes(p.token) ? effectiveAmount : null);
|
||||||
}
|
if (inferredFiat) {
|
||||||
if (p.fiatCurrency) {
|
widgetParams.fiatAmount = inferredFiat;
|
||||||
widgetParams.fiatCurrency = p.fiatCurrency;
|
widgetParams.defaultFiatAmount = inferredFiat;
|
||||||
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);
|
const widgetUrl = await createSecureWidgetUrl(widgetParams);
|
||||||
|
|
@ -2523,6 +2547,49 @@ routes.get("/request", (c) => {
|
||||||
routes.get("/pay/:id", (c) => {
|
routes.get("/pay/:id", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const paymentId = c.req.param("id");
|
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<PaymentRequestDoc>(docId);
|
||||||
|
if (doc) {
|
||||||
|
const p = doc.payment;
|
||||||
|
const terminalStates: Record<string, { title: string; msg: string; icon: string }> = {
|
||||||
|
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<number, string> = { 8453: 'Base', 84532: 'Base Sepolia', 1: 'Ethereum' };
|
||||||
|
const explorerBase: Record<number, string> = { 8453: 'https://basescan.org/tx/', 84532: 'https://sepolia.basescan.org/tx/', 1: 'https://etherscan.io/tx/' };
|
||||||
|
const txLink = p.txHash && explorerBase[p.chainId]
|
||||||
|
? `<a href="${explorerBase[p.chainId]}${p.txHash}" target="_blank" rel="noopener" style="color:#60a5fa;text-decoration:underline">${p.txHash.slice(0, 10)}...${p.txHash.slice(-8)}</a>`
|
||||||
|
: '';
|
||||||
|
return c.html(renderShell({
|
||||||
|
title: `${info.title} | rCart`,
|
||||||
|
moduleId: "rcart",
|
||||||
|
spaceSlug: space,
|
||||||
|
spaceVisibility: "public",
|
||||||
|
modules: getModuleInfoList(),
|
||||||
|
theme: "dark",
|
||||||
|
body: `
|
||||||
|
<div style="max-width:480px;margin:60px auto;padding:32px;text-align:center;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#e2e8f0">
|
||||||
|
<div style="font-size:48px;margin-bottom:16px;color:${p.status === 'paid' || p.status === 'confirmed' || p.status === 'filled' ? '#4ade80' : p.status === 'expired' ? '#fbbf24' : '#f87171'}">${info.icon}</div>
|
||||||
|
<h1 style="font-size:24px;font-weight:600;margin:0 0 12px">${info.title}</h1>
|
||||||
|
<p style="color:#94a3b8;font-size:15px;line-height:1.6;margin:0 0 24px">${info.msg}</p>
|
||||||
|
${p.amount && p.amount !== '0' ? `<div style="font-size:20px;font-weight:600;margin-bottom:8px">${p.amount} ${p.token}</div>` : ''}
|
||||||
|
${p.fiatAmount ? `<div style="color:#94a3b8;font-size:14px;margin-bottom:16px">≈ $${p.fiatAmount} ${p.fiatCurrency || 'USD'}</div>` : ''}
|
||||||
|
${chainNames[p.chainId] ? `<div style="color:#64748b;font-size:13px;margin-bottom:8px">Network: ${chainNames[p.chainId]}</div>` : ''}
|
||||||
|
${txLink ? `<div style="font-size:13px;margin-bottom:8px">Tx: ${txLink}</div>` : ''}
|
||||||
|
${p.paidAt ? `<div style="color:#64748b;font-size:13px">Paid: ${new Date(p.paidAt).toLocaleString()}</div>` : ''}
|
||||||
|
</div>`,
|
||||||
|
styles: `<link rel="stylesheet" href="/modules/rcart/cart.css">`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return c.html(renderShell({
|
return c.html(renderShell({
|
||||||
title: `Payment | rCart`,
|
title: `Payment | rCart`,
|
||||||
moduleId: "rcart",
|
moduleId: "rcart",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { renderShell } from "../../server/shell";
|
||||||
import { getModuleInfoList } from "../../shared/module";
|
import { getModuleInfoList } from "../../shared/module";
|
||||||
import type { RSpaceModule } from "../../shared/module";
|
import type { RSpaceModule } from "../../shared/module";
|
||||||
import { designAgentRoutes } from "./design-agent-route";
|
import { designAgentRoutes } from "./design-agent-route";
|
||||||
|
import { ensureSidecar } from "../../server/sidecar-manager";
|
||||||
|
|
||||||
const routes = new Hono();
|
const routes = new Hono();
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ routes.get("/api/health", (c) => {
|
||||||
|
|
||||||
// Proxy bridge API calls from rspace to the Scribus container
|
// Proxy bridge API calls from rspace to the Scribus container
|
||||||
routes.all("/api/bridge/*", async (c) => {
|
routes.all("/api/bridge/*", async (c) => {
|
||||||
|
await ensureSidecar("scribus-novnc");
|
||||||
const path = c.req.path.replace(/^.*\/api\/bridge/, "/api/scribus");
|
const path = c.req.path.replace(/^.*\/api\/bridge/, "/api/scribus");
|
||||||
const bridgeSecret = process.env.SCRIBUS_BRIDGE_SECRET || "";
|
const bridgeSecret = process.env.SCRIBUS_BRIDGE_SECRET || "";
|
||||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,84 @@ export function renderLanding(): string {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Advanced GovMods -->
|
||||||
|
<section class="rl-section">
|
||||||
|
<div class="rl-container">
|
||||||
|
<div style="text-align:center;margin-bottom:2rem">
|
||||||
|
<span class="rl-badge" style="background:#1e293b;color:#94a3b8;font-size:0.7rem;padding:0.25rem 0.75rem">ADVANCED GOVMODS</span>
|
||||||
|
<h2 class="rl-heading" style="margin-top:0.75rem;background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||||
|
Delegated Democracy & Flow Visualization
|
||||||
|
</h2>
|
||||||
|
<p style="font-size:1.05rem;color:#94a3b8;max-width:680px;margin:0.5rem auto 0">
|
||||||
|
Beyond simple gates: weight transformation for fair voting, time-weighted conviction,
|
||||||
|
multi-party approval, and real-time governance flow visualization.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rl-grid-2" style="max-width:900px;margin:0 auto">
|
||||||
|
<!-- Quadratic Transform -->
|
||||||
|
<div class="rl-card" style="border:2px solid rgba(20,184,166,0.35);background:linear-gradient(to bottom right,rgba(20,184,166,0.08),rgba(20,184,166,0.03))">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||||
|
<div style="width:2rem;height:2rem;border-radius:9999px;background:#14b8a6;display:flex;align-items:center;justify-content:center">
|
||||||
|
<span style="color:white;font-weight:700;font-size:0.9rem">√</span>
|
||||||
|
</div>
|
||||||
|
<h3 style="color:#2dd4bf;font-size:1.05rem;margin-bottom:0">Quadratic Transform</h3>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Inline weight dampening. Raw votes pass through sqrt, log, or linear transforms —
|
||||||
|
reducing whale dominance while preserving signal. Bar chart shows raw vs effective.
|
||||||
|
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Fair voting by default.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Conviction Accumulator -->
|
||||||
|
<div class="rl-card" style="border:2px solid rgba(217,119,6,0.35);background:linear-gradient(to bottom right,rgba(217,119,6,0.08),rgba(217,119,6,0.03))">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||||
|
<div style="width:2rem;height:2rem;border-radius:9999px;background:#d97706;display:flex;align-items:center;justify-content:center">
|
||||||
|
<span style="color:white;font-size:1rem">⏳</span>
|
||||||
|
</div>
|
||||||
|
<h3 style="color:#fbbf24;font-size:1.05rem;margin-bottom:0">Conviction Accumulator</h3>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Time-weighted conviction scoring. Stakes accumulate conviction over hours — longer
|
||||||
|
commitment means stronger signal. Gate mode triggers at threshold; tuner mode streams live score.
|
||||||
|
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Decisions that reward patience.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Multisig Gate -->
|
||||||
|
<div class="rl-card" style="border:2px solid rgba(99,102,241,0.35);background:linear-gradient(to bottom right,rgba(99,102,241,0.08),rgba(99,102,241,0.03))">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||||
|
<div style="width:2rem;height:2rem;border-radius:9999px;background:#6366f1;display:flex;align-items:center;justify-content:center">
|
||||||
|
<span style="color:white;font-size:1rem">🔐</span>
|
||||||
|
</div>
|
||||||
|
<h3 style="color:#818cf8;font-size:1.05rem;margin-bottom:0">Multisig Gate</h3>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Council-grade approval on the canvas.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sankey Visualizer -->
|
||||||
|
<div class="rl-card" style="border:2px solid rgba(124,58,237,0.35);background:linear-gradient(to bottom right,rgba(124,58,237,0.08),rgba(124,58,237,0.03))">
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||||
|
<div style="width:2rem;height:2rem;border-radius:9999px;background:#7c3aed;display:flex;align-items:center;justify-content:center">
|
||||||
|
<span style="color:white;font-size:1rem">📊</span>
|
||||||
|
</div>
|
||||||
|
<h3 style="color:#a78bfa;font-size:1.05rem;margin-bottom:0">Sankey Visualizer</h3>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Governance you can see flowing.</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Why Modular Governance -->
|
<!-- Why Modular Governance -->
|
||||||
<section class="rl-section rl-section--alt">
|
<section class="rl-section rl-section--alt">
|
||||||
<div class="rl-container">
|
<div class="rl-container">
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ routes.get("/", (c) => {
|
||||||
<li><strong>Knobs</strong> — Tunable parameters with temporal viscosity</li>
|
<li><strong>Knobs</strong> — Tunable parameters with temporal viscosity</li>
|
||||||
<li><strong>Projects</strong> — Circuit aggregators showing "X of Y gates satisfied"</li>
|
<li><strong>Projects</strong> — Circuit aggregators showing "X of Y gates satisfied"</li>
|
||||||
<li><strong>Amendments</strong> — Propose in-place circuit modifications</li>
|
<li><strong>Amendments</strong> — Propose in-place circuit modifications</li>
|
||||||
|
<li><strong>Quadratic Transform</strong> — Weight dampening (sqrt/log) for fair voting</li>
|
||||||
|
<li><strong>Conviction Accumulator</strong> — Time-weighted conviction scoring</li>
|
||||||
|
<li><strong>Multisig Gate</strong> — M-of-N approval multiplexor</li>
|
||||||
|
<li><strong>Sankey Visualizer</strong> — Auto-discovered governance flow diagram</li>
|
||||||
</ul>
|
</ul>
|
||||||
<a href="/rspace" style="display:inline-block;background:linear-gradient(to right,#7c3aed,#1d4ed8);color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:600;">
|
<a href="/rspace" style="display:inline-block;background:linear-gradient(to right,#7c3aed,#1d4ed8);color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:600;">
|
||||||
Open Canvas →
|
Open Canvas →
|
||||||
|
|
@ -60,6 +64,10 @@ routes.get("/api/shapes", (c) => {
|
||||||
"folk-gov-knob",
|
"folk-gov-knob",
|
||||||
"folk-gov-project",
|
"folk-gov-project",
|
||||||
"folk-gov-amendment",
|
"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 = [
|
const govTypes = [
|
||||||
"folk-gov-binary", "folk-gov-threshold", "folk-gov-knob",
|
"folk-gov-binary", "folk-gov-threshold", "folk-gov-knob",
|
||||||
"folk-gov-project", "folk-gov-amendment",
|
"folk-gov-project", "folk-gov-amendment",
|
||||||
|
"folk-gov-quadratic", "folk-gov-conviction", "folk-gov-multisig", "folk-gov-sankey",
|
||||||
];
|
];
|
||||||
if (docData?.shapes) {
|
if (docData?.shapes) {
|
||||||
const existing = Object.values(docData.shapes as Record<string, any>)
|
const existing = Object.values(docData.shapes as Record<string, any>)
|
||||||
|
|
@ -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);
|
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 ──
|
// ── Module export ──
|
||||||
|
|
@ -213,6 +299,10 @@ export const govModule: RSpaceModule = {
|
||||||
"folk-gov-knob",
|
"folk-gov-knob",
|
||||||
"folk-gov-project",
|
"folk-gov-project",
|
||||||
"folk-gov-amendment",
|
"folk-gov-amendment",
|
||||||
|
"folk-gov-quadratic",
|
||||||
|
"folk-gov-conviction",
|
||||||
|
"folk-gov-multisig",
|
||||||
|
"folk-gov-sankey",
|
||||||
],
|
],
|
||||||
canvasToolIds: [
|
canvasToolIds: [
|
||||||
"create_binary_gate",
|
"create_binary_gate",
|
||||||
|
|
@ -220,6 +310,10 @@ export const govModule: RSpaceModule = {
|
||||||
"create_gov_knob",
|
"create_gov_knob",
|
||||||
"create_gov_project",
|
"create_gov_project",
|
||||||
"create_amendment",
|
"create_amendment",
|
||||||
|
"create_quadratic_transform",
|
||||||
|
"create_conviction_gate",
|
||||||
|
"create_multisig_gate",
|
||||||
|
"create_sankey_visualizer",
|
||||||
],
|
],
|
||||||
onboardingActions: [
|
onboardingActions: [
|
||||||
{ label: "Build a Circuit", icon: "⚖️", description: "Create a governance decision circuit on the canvas", type: 'create', href: '/rgov' },
|
{ label: "Build a Circuit", icon: "⚖️", description: "Create a governance decision circuit on the canvas", type: 'create', href: '/rgov' },
|
||||||
|
|
|
||||||
|
|
@ -18,11 +18,13 @@ async function getSmtpTransport() {
|
||||||
try {
|
try {
|
||||||
const nodemailer = await import("nodemailer");
|
const nodemailer = await import("nodemailer");
|
||||||
const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport;
|
const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport;
|
||||||
|
const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix');
|
||||||
_transport = createTransport({
|
_transport = createTransport({
|
||||||
host: SMTP_HOST,
|
host: SMTP_HOST,
|
||||||
port: SMTP_PORT,
|
port: isInternal ? 25 : SMTP_PORT,
|
||||||
secure: SMTP_PORT === 465,
|
secure: !isInternal && SMTP_PORT === 465,
|
||||||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
...(isInternal ? {} : SMTP_USER ? { auth: { user: SMTP_USER, pass: SMTP_PASS } } : {}),
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
});
|
});
|
||||||
return _transport;
|
return _transport;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,15 @@ async function getSmtpTransport() {
|
||||||
try {
|
try {
|
||||||
const nodemailer = await import("nodemailer");
|
const nodemailer = await import("nodemailer");
|
||||||
const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport;
|
const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport;
|
||||||
|
const isInternal = SMTP_HOST.includes('mailcow') || SMTP_HOST.includes('postfix');
|
||||||
_smtpTransport = createTransport({
|
_smtpTransport = createTransport({
|
||||||
host: SMTP_HOST,
|
host: SMTP_HOST,
|
||||||
port: SMTP_PORT,
|
port: isInternal ? 25 : SMTP_PORT,
|
||||||
secure: SMTP_PORT === 465,
|
secure: !isInternal && SMTP_PORT === 465,
|
||||||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
...(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;
|
return _smtpTransport;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Inbox] Failed to create SMTP transport:", e);
|
console.error("[Inbox] Failed to create SMTP transport:", e);
|
||||||
|
|
|
||||||
|
|
@ -29,15 +29,19 @@ let _smtpTransport: Transporter | null = null;
|
||||||
|
|
||||||
function getSmtpTransport(): Transporter | null {
|
function getSmtpTransport(): Transporter | null {
|
||||||
if (_smtpTransport) return _smtpTransport;
|
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({
|
_smtpTransport = createTransport({
|
||||||
host: process.env.SMTP_HOST || "mail.rmail.online",
|
host,
|
||||||
port: Number(process.env.SMTP_PORT) || 587,
|
port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587),
|
||||||
secure: Number(process.env.SMTP_PORT) === 465,
|
secure: !isInternal && Number(process.env.SMTP_PORT) === 465,
|
||||||
auth: {
|
...(isInternal ? {} : {
|
||||||
user: process.env.SMTP_USER || "noreply@rmail.online",
|
auth: {
|
||||||
pass: process.env.SMTP_PASS,
|
user: process.env.SMTP_USER || "noreply@rmail.online",
|
||||||
},
|
pass: process.env.SMTP_PASS!,
|
||||||
|
},
|
||||||
|
}),
|
||||||
tls: { rejectUnauthorized: false },
|
tls: { rejectUnauthorized: false },
|
||||||
});
|
});
|
||||||
return _smtpTransport;
|
return _smtpTransport;
|
||||||
|
|
|
||||||
|
|
@ -52,15 +52,19 @@ let _smtpTransport: Transporter | null = null;
|
||||||
|
|
||||||
function getSmtpTransport(): Transporter | null {
|
function getSmtpTransport(): Transporter | null {
|
||||||
if (_smtpTransport) return _smtpTransport;
|
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({
|
_smtpTransport = createTransport({
|
||||||
host: process.env.SMTP_HOST || "mail.rmail.online",
|
host,
|
||||||
port: Number(process.env.SMTP_PORT) || 587,
|
port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587),
|
||||||
secure: Number(process.env.SMTP_PORT) === 465,
|
secure: !isInternal && Number(process.env.SMTP_PORT) === 465,
|
||||||
auth: {
|
...(isInternal ? {} : {
|
||||||
user: process.env.SMTP_USER || "noreply@rmail.online",
|
auth: {
|
||||||
pass: process.env.SMTP_PASS,
|
user: process.env.SMTP_USER || "noreply@rmail.online",
|
||||||
},
|
pass: process.env.SMTP_PASS!,
|
||||||
|
},
|
||||||
|
}),
|
||||||
tls: { rejectUnauthorized: false },
|
tls: { rejectUnauthorized: false },
|
||||||
});
|
});
|
||||||
return _smtpTransport;
|
return _smtpTransport;
|
||||||
|
|
|
||||||
250
server/index.ts
250
server/index.ts
|
|
@ -1046,6 +1046,124 @@ setInterval(() => {
|
||||||
}
|
}
|
||||||
}, 30 * 60 * 1000);
|
}, 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<string, VideoGenJob>();
|
||||||
|
|
||||||
|
// 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<typeof createTransport> | null = null;
|
let splatMailTransport: ReturnType<typeof createTransport> | null = null;
|
||||||
if (process.env.SMTP_PASS) {
|
if (process.env.SMTP_PASS) {
|
||||||
splatMailTransport = createTransport({
|
splatMailTransport = createTransport({
|
||||||
|
|
@ -1496,48 +1614,67 @@ app.post("/api/image-gen/img2img", async (c) => {
|
||||||
return c.json({ error: `Unknown provider: ${provider}` }, 400);
|
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) => {
|
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();
|
const { prompt } = await c.req.json();
|
||||||
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
if (!prompt) return c.json({ error: "prompt required" }, 400);
|
||||||
|
|
||||||
const { generateVideoViaFal } = await import("./mi-media");
|
const jobId = crypto.randomUUID();
|
||||||
const result = await generateVideoViaFal(prompt);
|
const job: VideoGenJob = {
|
||||||
if (!result.ok) return c.json({ error: result.error }, 502);
|
id: jobId, status: "pending", type: "t2v",
|
||||||
return c.json({ url: result.url, video_url: result.url });
|
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) => {
|
app.post("/api/video-gen/i2v", async (c) => {
|
||||||
if (!FAL_KEY) return c.json({ error: "FAL_KEY not configured" }, 503);
|
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);
|
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", {
|
// Stage the source image if it's a data URL
|
||||||
method: "POST",
|
let imageUrl = image;
|
||||||
headers: {
|
if (image.startsWith("data:")) {
|
||||||
Authorization: `Key ${FAL_KEY}`,
|
const url = await saveDataUrlToDisk(image, "vid-src");
|
||||||
"Content-Type": "application/json",
|
imageUrl = publicUrl(c, url);
|
||||||
},
|
} else if (image.startsWith("/")) {
|
||||||
body: JSON.stringify({
|
imageUrl = publicUrl(c, image);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const jobId = crypto.randomUUID();
|
||||||
const videoUrl = data.video?.url || data.output?.url;
|
const job: VideoGenJob = {
|
||||||
if (!videoUrl) return c.json({ error: "No video returned" }, 502);
|
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<string, any> = {
|
||||||
|
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)
|
// 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)
|
// KiCAD PCB design — MCP StreamableHTTP bridge (sidecar container)
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
||||||
|
|
|
||||||
|
|
@ -164,8 +164,8 @@ export async function generateVideoViaFal(prompt: string, source_image?: string)
|
||||||
return { ok: true, url: videoUrl };
|
return { ok: true, url: videoUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text-to-video via WAN 2.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/v2.1", {
|
const res = await fetch("https://fal.run/fal-ai/wan-t2v", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Key ${FAL_KEY}`,
|
Authorization: `Key ${FAL_KEY}`,
|
||||||
|
|
@ -173,7 +173,7 @@ export async function generateVideoViaFal(prompt: string, source_image?: string)
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
prompt,
|
prompt,
|
||||||
num_frames: 49,
|
num_frames: 81,
|
||||||
resolution: "480p",
|
resolution: "480p",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -43,15 +43,16 @@ let _smtpTransport: any = null;
|
||||||
|
|
||||||
async function getSmtpTransport() {
|
async function getSmtpTransport() {
|
||||||
if (_smtpTransport) return _smtpTransport;
|
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 {
|
try {
|
||||||
const nodemailer = await import("nodemailer");
|
const nodemailer = await import("nodemailer");
|
||||||
const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport;
|
const createTransport = (nodemailer as any).default?.createTransport || nodemailer.createTransport;
|
||||||
_smtpTransport = createTransport({
|
_smtpTransport = createTransport({
|
||||||
host: SMTP_HOST,
|
host: SMTP_HOST,
|
||||||
port: SMTP_PORT,
|
port: isInternal ? 25 : SMTP_PORT,
|
||||||
secure: SMTP_PORT === 465,
|
secure: !isInternal && SMTP_PORT === 465,
|
||||||
auth: SMTP_USER ? { user: SMTP_USER, pass: SMTP_PASS } : undefined,
|
...(isInternal ? {} : SMTP_USER ? { auth: { user: SMTP_USER, pass: SMTP_PASS } } : {}),
|
||||||
tls: { rejectUnauthorized: false },
|
tls: { rejectUnauthorized: false },
|
||||||
});
|
});
|
||||||
console.log("[email] SMTP transport configured");
|
console.log("[email] SMTP transport configured");
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,18 @@ const SIDECARS: Record<string, SidecarConfig> = {
|
||||||
port: 11434,
|
port: 11434,
|
||||||
healthTimeout: 30_000,
|
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<string, number>();
|
const lastUsed = new Map<string, number>();
|
||||||
|
|
@ -61,14 +73,17 @@ try {
|
||||||
|
|
||||||
// ── Docker Engine API over Unix socket ──
|
// ── 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
// Only set Content-Type when we actually send a JSON body
|
||||||
|
if (sendBody) headers["Content-Type"] = "application/json";
|
||||||
const req = http.request(
|
const req = http.request(
|
||||||
{
|
{
|
||||||
socketPath: DOCKER_SOCKET,
|
socketPath: DOCKER_SOCKET,
|
||||||
path: `/v1.43${path}`,
|
path: `/v1.43${path}`,
|
||||||
method,
|
method,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers,
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
let data = "";
|
let data = "";
|
||||||
|
|
@ -100,10 +115,11 @@ async function isContainerRunning(name: string): Promise<boolean> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startContainer(name: string): Promise<void> {
|
async function startContainer(name: string): Promise<void> {
|
||||||
const { status } = await dockerApi("POST", `/containers/${name}/start`);
|
const { status, body } = await dockerApi("POST", `/containers/${name}/start`);
|
||||||
// 204 = started, 304 = already running
|
// 204 = started, 304 = already running
|
||||||
if (status !== 204 && status !== 304) {
|
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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2097,17 +2097,23 @@ spaces.post("/:slug/copy-shapes", async (c) => {
|
||||||
|
|
||||||
let inviteTransport: Transporter | null = null;
|
let inviteTransport: Transporter | null = null;
|
||||||
|
|
||||||
if (process.env.SMTP_PASS) {
|
{
|
||||||
inviteTransport = createTransport({
|
const host = process.env.SMTP_HOST || "mail.rmail.online";
|
||||||
host: process.env.SMTP_HOST || "mail.rmail.online",
|
const isInternal = host.includes('mailcow') || host.includes('postfix');
|
||||||
port: Number(process.env.SMTP_PORT) || 587,
|
if (process.env.SMTP_PASS || isInternal) {
|
||||||
secure: Number(process.env.SMTP_PORT) === 465,
|
inviteTransport = createTransport({
|
||||||
auth: {
|
host,
|
||||||
user: process.env.SMTP_USER || "noreply@rmail.online",
|
port: isInternal ? 25 : (Number(process.env.SMTP_PORT) || 587),
|
||||||
pass: process.env.SMTP_PASS,
|
secure: !isInternal && Number(process.env.SMTP_PORT) === 465,
|
||||||
},
|
...(isInternal ? {} : {
|
||||||
tls: { rejectUnauthorized: false },
|
auth: {
|
||||||
});
|
user: process.env.SMTP_USER || "noreply@rmail.online",
|
||||||
|
pass: process.env.SMTP_PASS!,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
tls: { rejectUnauthorized: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Enhanced invite by email (with token + role) ──
|
// ── Enhanced invite by email (with token + role) ──
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare module 'hono' {
|
||||||
effectiveSpace: string;
|
effectiveSpace: string;
|
||||||
spaceRole: string;
|
spaceRole: string;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
|
isSubdomain: boolean;
|
||||||
x402Payment: string;
|
x402Payment: string;
|
||||||
x402Scheme: string;
|
x402Scheme: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue