feat(canvas): add rApplet circuit components + template system

Unified applet abstraction for canvas — compact dashboard cards with typed
I/O ports, expandable circuit editors, and save-able reusable templates.

New files: shared/applet-types.ts, lib/folk-applet.ts, lib/applet-circuit-canvas.ts,
lib/applet-template-manager.ts, lib/applet-defs.ts, plus applets for rGov (Signoff Gate,
Governance Circuit), rFlows (Flow Summary), rWallet (Balance Card, Token Balance).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-15 12:01:30 -04:00
parent 1fe9b1c8bd
commit adda731df1
15 changed files with 1334 additions and 0 deletions

View File

@ -0,0 +1,277 @@
/**
* applet-circuit-canvas Reusable SVG node graph renderer.
*
* Lightweight pan/zoom SVG canvas for rendering sub-node graphs
* inside expanded folk-applet shapes. Extracted from folk-gov-circuit patterns.
*
* NOT a FolkShape just an HTMLElement used inside folk-applet's shadow DOM.
*/
import type { AppletSubNode, AppletSubEdge } from "../shared/applet-types";
const NODE_WIDTH = 200;
const NODE_HEIGHT = 80;
const PORT_RADIUS = 5;
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function bezierPath(x1: number, y1: number, x2: number, y2: number): string {
const dx = Math.abs(x2 - x1) * 0.5;
return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
}
const STYLES = `
:host {
display: block;
width: 100%;
height: 100%;
background: #0f172a;
border-radius: 0 0 8px 8px;
overflow: hidden;
}
svg {
width: 100%;
height: 100%;
}
.acc-node-body {
width: 100%;
height: 100%;
box-sizing: border-box;
background: #1e293b;
border: 1.5px solid #334155;
border-radius: 6px;
padding: 8px;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
font-family: inherit;
}
.acc-node-label {
font-size: 11px;
font-weight: 600;
color: #e2e8f0;
display: flex;
align-items: center;
gap: 4px;
}
.acc-node-meta {
font-size: 10px;
color: #94a3b8;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acc-edge-path {
fill: none;
stroke-width: 1.5;
stroke-opacity: 0.5;
pointer-events: none;
}
.acc-edge-hit {
fill: none;
stroke: transparent;
stroke-width: 10;
cursor: pointer;
}
.acc-edge-hit:hover + .acc-edge-path {
stroke-opacity: 1;
stroke-width: 2.5;
}
.acc-port-dot {
transition: r 0.1s;
}
.acc-port-hit {
cursor: crosshair;
}
.acc-port-hit:hover ~ .acc-port-dot {
r: 8;
}
.acc-grid-line {
stroke: #1e293b;
stroke-width: 0.5;
}
`;
export class AppletCircuitCanvas extends HTMLElement {
#shadow: ShadowRoot;
#nodes: AppletSubNode[] = [];
#edges: AppletSubEdge[] = [];
#panX = 0;
#panY = 0;
#zoom = 1;
#isPanning = false;
#panStart = { x: 0, y: 0 };
constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
}
get nodes() { return this.#nodes; }
set nodes(v: AppletSubNode[]) {
this.#nodes = v;
this.#render();
}
get edges() { return this.#edges; }
set edges(v: AppletSubEdge[]) {
this.#edges = v;
this.#render();
}
connectedCallback() {
this.#render();
this.#setupInteraction();
}
#render(): void {
const gridDef = `
<defs>
<pattern id="acc-grid" width="30" height="30" patternUnits="userSpaceOnUse">
<line x1="30" y1="0" x2="30" y2="30" class="acc-grid-line"/>
<line x1="0" y1="30" x2="30" y2="30" class="acc-grid-line"/>
</pattern>
</defs>
<rect width="8000" height="8000" x="-4000" y="-4000" fill="url(#acc-grid)"/>
`;
const edgesHtml = this.#edges.map(edge => {
const fromNode = this.#nodes.find(n => n.id === edge.fromNode);
const toNode = this.#nodes.find(n => n.id === edge.toNode);
if (!fromNode || !toNode) return "";
const x1 = fromNode.position.x + NODE_WIDTH;
const y1 = fromNode.position.y + NODE_HEIGHT / 2;
const x2 = toNode.position.x;
const y2 = toNode.position.y + NODE_HEIGHT / 2;
const d = bezierPath(x1, y1, x2, y2);
return `
<g data-edge-id="${esc(edge.id)}">
<path class="acc-edge-hit" d="${d}"/>
<path class="acc-edge-path" d="${d}" stroke="#6366f1" stroke-opacity="0.5"/>
</g>
`;
}).join("");
const nodesHtml = this.#nodes.map(node => {
const configSummary = Object.entries(node.config)
.slice(0, 2)
.map(([k, v]) => `${k}: ${v}`)
.join(", ");
return `
<g data-node-id="${esc(node.id)}">
<foreignObject x="${node.position.x}" y="${node.position.y}" width="${NODE_WIDTH}" height="${NODE_HEIGHT}">
<div xmlns="http://www.w3.org/1999/xhtml" class="acc-node-body">
<div class="acc-node-label">${esc(node.icon)} ${esc(node.label)}</div>
${configSummary ? `<div class="acc-node-meta">${esc(configSummary)}</div>` : ""}
</div>
</foreignObject>
</g>
`;
}).join("");
this.#shadow.innerHTML = `
<style>${STYLES}</style>
<svg xmlns="http://www.w3.org/2000/svg">
<g id="canvas-transform" transform="translate(${this.#panX},${this.#panY}) scale(${this.#zoom})">
${gridDef}
<g id="edge-layer">${edgesHtml}</g>
<g id="node-layer">${nodesHtml}</g>
</g>
</svg>
`;
this.#fitView();
}
#fitView(): void {
if (this.#nodes.length === 0) return;
const svg = this.#shadow.querySelector("svg");
if (!svg) return;
const rect = svg.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const n of this.#nodes) {
minX = Math.min(minX, n.position.x);
minY = Math.min(minY, n.position.y);
maxX = Math.max(maxX, n.position.x + NODE_WIDTH);
maxY = Math.max(maxY, n.position.y + NODE_HEIGHT);
}
const pad = 30;
const contentW = maxX - minX + pad * 2;
const contentH = maxY - minY + pad * 2;
const scaleX = rect.width / contentW;
const scaleY = rect.height / contentH;
this.#zoom = Math.min(scaleX, scaleY, 1.5);
this.#panX = (rect.width - contentW * this.#zoom) / 2 - (minX - pad) * this.#zoom;
this.#panY = (rect.height - contentH * this.#zoom) / 2 - (minY - pad) * this.#zoom;
this.#updateTransform();
}
#updateTransform(): void {
const g = this.#shadow.getElementById("canvas-transform");
if (g) g.setAttribute("transform", `translate(${this.#panX},${this.#panY}) scale(${this.#zoom})`);
}
#setupInteraction(): void {
const svg = this.#shadow.querySelector("svg");
if (!svg) return;
// Pan
svg.addEventListener("pointerdown", (e) => {
if (e.button !== 0 && e.button !== 1) return;
this.#isPanning = true;
this.#panStart = { x: e.clientX - this.#panX, y: e.clientY - this.#panY };
svg.setPointerCapture(e.pointerId);
e.preventDefault();
});
svg.addEventListener("pointermove", (e) => {
if (!this.#isPanning) return;
this.#panX = e.clientX - this.#panStart.x;
this.#panY = e.clientY - this.#panStart.y;
this.#updateTransform();
});
svg.addEventListener("pointerup", () => {
this.#isPanning = false;
});
// Zoom
svg.addEventListener("wheel", (e) => {
e.preventDefault();
const factor = e.deltaY > 0 ? 0.9 : 1.1;
const oldZoom = this.#zoom;
const newZoom = Math.max(0.2, Math.min(3, oldZoom * factor));
const rect = svg.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
this.#panX = mx - (mx - this.#panX) * (newZoom / oldZoom);
this.#panY = my - (my - this.#panY) * (newZoom / oldZoom);
this.#zoom = newZoom;
this.#updateTransform();
}, { passive: false });
}
}
customElements.define("applet-circuit-canvas", AppletCircuitCanvas);

9
lib/applet-defs.ts Normal file
View File

@ -0,0 +1,9 @@
/**
* Barrel file re-exporting all module applet definitions.
* Imported in canvas.html to register applets client-side
* (applet defs contain functions, can't be JSON-serialized).
*/
export { govApplets } from "../modules/rgov/applets";
export { flowsApplets } from "../modules/rflows/applets";
export { walletApplets } from "../modules/rwallet/applets";

View File

@ -0,0 +1,209 @@
/**
* AppletTemplateManager save/instantiate/list/delete applet templates.
*
* Templates capture a selection of shapes + their inter-connecting arrows,
* storing relative positions in CommunityDoc.templates. Instantiation
* generates new IDs, remaps arrow refs, and places at cursor position.
*/
import type { CommunitySync, CommunityDoc, ShapeData } from "./community-sync";
import type { AppletTemplateRecord, AppletTemplateShape, AppletTemplateArrow } from "../shared/applet-types";
import * as Automerge from "@automerge/automerge";
export class AppletTemplateManager {
#sync: CommunitySync;
constructor(sync: CommunitySync) {
this.#sync = sync;
}
/** Get templates map from doc. */
#getTemplates(): Record<string, AppletTemplateRecord> {
return (this.#sync.doc as any).templates || {};
}
/** Batch-mutate the Automerge doc. */
#change(msg: string, fn: (doc: any) => void): void {
const oldDoc = this.#sync.doc;
const newDoc = Automerge.change(oldDoc, msg, (d: any) => {
if (!d.templates) d.templates = {};
fn(d);
});
(this.#sync as any)._applyDocChange(newDoc);
}
// ── Save ──
/**
* Save selected shapes + their internal arrows as a template.
* Only captures arrows where both source AND target are in the selection.
*/
saveTemplate(
selectedIds: string[],
meta: { name: string; description?: string; icon?: string; color?: string; createdBy?: string },
): AppletTemplateRecord | null {
const shapes = this.#sync.doc.shapes || {};
const selectedSet = new Set(selectedIds);
// Filter to existing shapes
const validIds = selectedIds.filter(id => shapes[id]);
if (validIds.length === 0) return null;
// Compute bounding box
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const id of validIds) {
const s = shapes[id];
minX = Math.min(minX, s.x);
minY = Math.min(minY, s.y);
maxX = Math.max(maxX, s.x + s.width);
maxY = Math.max(maxY, s.y + s.height);
}
// Build relative-ID map: shapeId → relativeId
const idMap = new Map<string, string>();
let relIdx = 0;
// Separate non-arrow shapes and arrows
const templateShapes: AppletTemplateShape[] = [];
const templateArrows: AppletTemplateArrow[] = [];
for (const id of validIds) {
const s = shapes[id];
if (s.type === "folk-arrow") continue; // handle arrows separately
const relId = `rel-${relIdx++}`;
idMap.set(id, relId);
const { id: _id, x, y, width, height, rotation, type, ...rest } = s;
templateShapes.push({
relativeId: relId,
type,
relX: x - minX,
relY: y - minY,
width,
height,
rotation: rotation || 0,
props: rest as Record<string, unknown>,
});
}
// Find arrows connecting shapes within the selection
for (const [id, s] of Object.entries(shapes)) {
if (s.type !== "folk-arrow") continue;
if (!s.sourceId || !s.targetId) continue;
if (!selectedSet.has(s.sourceId) || !selectedSet.has(s.targetId)) continue;
const sourceRelId = idMap.get(s.sourceId);
const targetRelId = idMap.get(s.targetId);
if (!sourceRelId || !targetRelId) continue;
const relId = `rel-${relIdx++}`;
templateArrows.push({
relativeId: relId,
sourceRelId,
targetRelId,
sourcePort: s.sourcePort,
targetPort: s.targetPort,
});
}
const template: AppletTemplateRecord = {
id: `tpl-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
name: meta.name,
description: meta.description || "",
icon: meta.icon || "📋",
color: meta.color || "#6366f1",
createdAt: Date.now(),
createdBy: meta.createdBy || "unknown",
shapes: templateShapes,
arrows: templateArrows,
boundingWidth: maxX - minX,
boundingHeight: maxY - minY,
};
this.#change(`Save template "${meta.name}"`, (d) => {
d.templates[template.id] = template;
});
return template;
}
// ── Instantiate ──
/**
* Create new shapes + arrows from a template at the given position.
* Returns array of new shape IDs (for optional group creation).
*/
instantiateTemplate(templateId: string, x: number, y: number): string[] {
const template = this.#getTemplates()[templateId];
if (!template) return [];
// Map relativeId → new real ID
const relToNew = new Map<string, string>();
const newShapeIds: string[] = [];
// Create shapes
for (const tplShape of template.shapes) {
const newId = `shape-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
relToNew.set(tplShape.relativeId, newId);
const shapeData: ShapeData = {
type: tplShape.type,
id: newId,
x: x + tplShape.relX,
y: y + tplShape.relY,
width: tplShape.width,
height: tplShape.height,
rotation: tplShape.rotation,
...tplShape.props,
};
this.#sync.addShapeData(shapeData);
newShapeIds.push(newId);
}
// Create arrows with remapped source/target
for (const tplArrow of template.arrows) {
const sourceId = relToNew.get(tplArrow.sourceRelId);
const targetId = relToNew.get(tplArrow.targetRelId);
if (!sourceId || !targetId) continue;
const arrowId = `arrow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const arrowData: ShapeData = {
type: "folk-arrow",
id: arrowId,
x: 0,
y: 0,
width: 0,
height: 0,
rotation: 0,
sourceId,
targetId,
sourcePort: tplArrow.sourcePort,
targetPort: tplArrow.targetPort,
};
this.#sync.addShapeData(arrowData);
newShapeIds.push(arrowId);
}
return newShapeIds;
}
// ── List / Get / Delete ──
listTemplates(): AppletTemplateRecord[] {
return Object.values(this.#getTemplates())
.sort((a, b) => b.createdAt - a.createdAt);
}
getTemplate(id: string): AppletTemplateRecord | undefined {
return this.#getTemplates()[id];
}
deleteTemplate(id: string): void {
this.#change(`Delete template "${id}"`, (d) => {
delete d.templates[id];
});
}
}

View File

@ -139,6 +139,10 @@ export interface CommunityDoc {
eventLog?: EventEntry[];
/** Comment pins — Figma-style overlay markers */
commentPins?: { [pinId: string]: CommentPinData };
/** Saved applet templates (reusable wired shape groups) */
templates?: {
[templateId: string]: import("../shared/applet-types").AppletTemplateRecord;
};
}
type SyncState = Automerge.SyncState;

471
lib/folk-applet.ts Normal file
View File

@ -0,0 +1,471 @@
/**
* folk-applet Generic rApplet shape for the canvas.
*
* Compact mode (default): 300×200 card with module-provided HTML body + port indicators.
* Expanded mode: 600×400 with applet-circuit-canvas sub-graph or iframe fallback.
*
* Persisted fields: moduleId, appletId, instanceConfig, mode.
* Live data arrives via updateLiveData() no direct module imports.
*/
import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { dataTypeColor } from "./data-types";
import type { PortDescriptor } from "./data-types";
import type { AppletDefinition, AppletLiveData } from "../shared/applet-types";
// ── Applet registry (populated by modules at init) ──
const appletDefs = new Map<string, AppletDefinition>();
/** Register an applet definition. Key = "moduleId:appletId". */
export function registerAppletDef(moduleId: string, def: AppletDefinition): void {
appletDefs.set(`${moduleId}:${def.id}`, def);
}
/** Look up a registered applet definition. */
export function getAppletDef(moduleId: string, appletId: string): AppletDefinition | undefined {
return appletDefs.get(`${moduleId}:${appletId}`);
}
/** List all registered applet definitions. */
export function listAppletDefs(): Array<{ moduleId: string; def: AppletDefinition }> {
const result: Array<{ moduleId: string; def: AppletDefinition }> = [];
for (const [key, def] of appletDefs) {
const moduleId = key.split(":")[0];
result.push({ moduleId, def });
}
return result;
}
// ── Styles ──
const COMPACT_W = 300;
const COMPACT_H = 200;
const EXPANDED_W = 600;
const EXPANDED_H = 400;
const styles = css`
:host {
background: var(--rs-bg-surface, #1e293b);
border-radius: 10px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25);
overflow: visible;
}
.applet-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
position: relative;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
color: white;
font-size: 12px;
font-weight: 600;
cursor: move;
border-radius: 10px 10px 0 0;
min-height: 32px;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
gap: 2px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
line-height: 1;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.body {
flex: 1;
padding: 12px;
overflow: hidden;
font-size: 12px;
color: var(--rs-text-primary, #e2e8f0);
border-radius: 0 0 10px 10px;
}
.body-empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--rs-text-muted, #64748b);
font-style: italic;
}
/* Port indicators on edges */
.port-indicator {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #0f172a;
cursor: crosshair;
z-index: 2;
transition: transform 0.15s;
}
.port-indicator:hover {
transform: scale(1.4);
}
.port-indicator.input {
left: -6px;
}
.port-indicator.output {
right: -6px;
}
.port-label {
position: absolute;
font-size: 9px;
color: var(--rs-text-muted, #94a3b8);
white-space: nowrap;
pointer-events: none;
}
.port-label.input {
left: 10px;
}
.port-label.output {
right: 10px;
text-align: right;
}
/* Expanded mode circuit container */
.circuit-container {
flex: 1;
border-radius: 0 0 10px 10px;
overflow: hidden;
}
.circuit-container applet-circuit-canvas {
width: 100%;
height: 100%;
}
`;
declare global {
interface HTMLElementTagNameMap {
"folk-applet": FolkApplet;
}
}
export class FolkApplet extends FolkShape {
static override tagName = "folk-applet";
// Dynamic port descriptors set from the applet definition
static override portDescriptors: PortDescriptor[] = [];
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;
}
#moduleId = "";
#appletId = "";
#mode: "compact" | "expanded" = "compact";
#instanceConfig: Record<string, unknown> = {};
#liveData: AppletLiveData | null = null;
// DOM refs
#bodyEl!: HTMLElement;
#wrapper!: HTMLElement;
// Instance-level port descriptors (override static)
#instancePorts: PortDescriptor[] = [];
get moduleId() { return this.#moduleId; }
set moduleId(v: string) {
this.#moduleId = v;
this.#syncDefPorts();
}
get appletId() { return this.#appletId; }
set appletId(v: string) {
this.#appletId = v;
this.#syncDefPorts();
}
get mode() { return this.#mode; }
set mode(v: "compact" | "expanded") {
if (this.#mode === v) return;
this.#mode = v;
this.#updateMode();
}
get instanceConfig() { return this.#instanceConfig; }
set instanceConfig(v: Record<string, unknown>) { this.#instanceConfig = v; }
/** Sync port descriptors from the applet definition. */
#syncDefPorts(): void {
const def = getAppletDef(this.#moduleId, this.#appletId);
if (def) {
this.#instancePorts = def.ports;
}
}
/** Override: use instance ports instead of static. */
override getInputPorts(): PortDescriptor[] {
return this.#instancePorts.filter(p => p.direction === "input");
}
override getOutputPorts(): PortDescriptor[] {
return this.#instancePorts.filter(p => p.direction === "output");
}
override getPort(name: string): PortDescriptor | undefined {
return this.#instancePorts.find(p => p.name === name);
}
/** Update live data and re-render compact body. */
updateLiveData(snapshot: Record<string, unknown>): void {
this.#liveData = {
space: (this.closest("[space]") as any)?.getAttribute("space") || "",
moduleId: this.#moduleId,
appletId: this.#appletId,
snapshot,
outputValues: {},
};
this.#renderBody();
}
override createRenderRoot() {
const root = super.createRenderRoot();
this.#syncDefPorts();
this.initPorts();
const def = getAppletDef(this.#moduleId, this.#appletId);
const accentColor = def?.accentColor || "#475569";
const icon = def?.icon || "📦";
const label = def?.label || this.#appletId;
this.#wrapper = document.createElement("div");
this.#wrapper.className = "applet-wrapper";
this.#wrapper.innerHTML = html`
<div class="header" data-drag style="background: ${accentColor}">
<span class="header-title">${icon} ${label}</span>
<span class="header-actions">
<button class="expand-btn" title="Toggle expanded"></button>
<button class="close-btn" title="Close">&times;</button>
</span>
</div>
<div class="body body-empty">Loading...</div>
`;
const slot = root.querySelector("slot");
const container = slot?.parentElement as HTMLElement;
if (container) container.replaceWith(this.#wrapper);
this.#bodyEl = this.#wrapper.querySelector(".body") as HTMLElement;
// Wire events
this.#wrapper.querySelector(".expand-btn")!.addEventListener("click", (e) => {
e.stopPropagation();
this.mode = this.#mode === "compact" ? "expanded" : "compact";
this.dispatchEvent(new CustomEvent("content-change"));
});
this.#wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent("close"));
});
// Render port indicators
this.#renderPorts();
// Render initial body
this.#renderBody();
// Notify canvas we want live data
this.dispatchEvent(new CustomEvent("applet-subscribe", {
bubbles: true,
detail: { moduleId: this.#moduleId, appletId: this.#appletId, shapeId: this.id },
}));
return root;
}
#renderPorts(): void {
// Remove existing port indicators
this.#wrapper.querySelectorAll(".port-indicator, .port-label").forEach(el => el.remove());
const inputs = this.getInputPorts();
const outputs = this.getOutputPorts();
// Input ports on left edge
inputs.forEach((port, i) => {
const yPct = ((i + 1) / (inputs.length + 1)) * 100;
const color = dataTypeColor(port.type);
const dot = document.createElement("div");
dot.className = "port-indicator input";
dot.style.top = `${yPct}%`;
dot.style.backgroundColor = color;
dot.dataset.portName = port.name;
dot.dataset.portDir = "input";
dot.title = `${port.name} (${port.type})`;
const label = document.createElement("span");
label.className = "port-label input";
label.style.top = `${yPct}%`;
label.style.transform = "translateY(-50%)";
label.textContent = port.name;
this.#wrapper.appendChild(dot);
this.#wrapper.appendChild(label);
});
// Output ports on right edge
outputs.forEach((port, i) => {
const yPct = ((i + 1) / (outputs.length + 1)) * 100;
const color = dataTypeColor(port.type);
const dot = document.createElement("div");
dot.className = "port-indicator output";
dot.style.top = `${yPct}%`;
dot.style.backgroundColor = color;
dot.dataset.portName = port.name;
dot.dataset.portDir = "output";
dot.title = `${port.name} (${port.type})`;
const label = document.createElement("span");
label.className = "port-label output";
label.style.top = `${yPct}%`;
label.style.transform = "translateY(-50%)";
label.textContent = port.name;
this.#wrapper.appendChild(dot);
this.#wrapper.appendChild(label);
});
}
#renderBody(): void {
if (!this.#bodyEl) return;
const def = getAppletDef(this.#moduleId, this.#appletId);
if (!def) {
this.#bodyEl.className = "body body-empty";
this.#bodyEl.textContent = `Unknown applet: ${this.#moduleId}:${this.#appletId}`;
return;
}
if (this.#mode === "expanded" && def.getCircuit) {
this.#renderExpanded(def);
return;
}
// Compact mode — module-provided HTML
const data: AppletLiveData = this.#liveData || {
space: "",
moduleId: this.#moduleId,
appletId: this.#appletId,
snapshot: {},
outputValues: {},
};
try {
const bodyHtml = def.renderCompact(data);
this.#bodyEl.className = "body";
this.#bodyEl.innerHTML = bodyHtml;
} catch (err) {
this.#bodyEl.className = "body body-empty";
this.#bodyEl.textContent = `Render error: ${err}`;
}
}
#renderExpanded(def: AppletDefinition): void {
if (!def.getCircuit) return;
const space = (this.closest("[space]") as any)?.getAttribute("space") || "";
const { nodes, edges } = def.getCircuit(space);
this.#bodyEl.className = "body circuit-container";
this.#bodyEl.innerHTML = "";
const canvas = document.createElement("applet-circuit-canvas") as any;
canvas.nodes = nodes;
canvas.edges = edges;
this.#bodyEl.appendChild(canvas);
}
#updateMode(): void {
if (!this.#wrapper) return;
if (this.#mode === "expanded") {
this.width = EXPANDED_W;
this.height = EXPANDED_H;
} else {
this.width = COMPACT_W;
this.height = COMPACT_H;
}
this.#renderBody();
// Update expand button icon
const btn = this.#wrapper.querySelector(".expand-btn");
if (btn) btn.textContent = this.#mode === "expanded" ? "⊟" : "⊞";
}
// ── Serialization ──
override toJSON() {
return {
...super.toJSON(),
type: "folk-applet",
moduleId: this.#moduleId,
appletId: this.#appletId,
mode: this.#mode,
instanceConfig: this.#instanceConfig,
};
}
static override fromData(data: Record<string, any>): FolkApplet {
const shape = FolkShape.fromData.call(this, data) as FolkApplet;
if (data.moduleId) shape.moduleId = data.moduleId;
if (data.appletId) shape.appletId = data.appletId;
if (data.mode) shape.mode = data.mode;
if (data.instanceConfig) shape.instanceConfig = data.instanceConfig;
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.moduleId !== undefined && data.moduleId !== this.#moduleId) this.moduleId = data.moduleId;
if (data.appletId !== undefined && data.appletId !== this.#appletId) this.appletId = data.appletId;
if (data.mode !== undefined && data.mode !== this.#mode) this.mode = data.mode;
if (data.instanceConfig !== undefined) this.instanceConfig = data.instanceConfig;
}
}

View File

@ -128,6 +128,12 @@ export * from "./shape-registry";
// Flow Bridge (arrow ↔ LayerFlow)
export * from "./flow-bridge";
// Applets (rApplet circuit components)
export * from "./folk-applet";
export * from "./applet-circuit-canvas";
export * from "./applet-template-manager";
export * from "./applet-defs";
// Shape Groups
export * from "./group-manager";
export * from "./folk-group-frame";

52
modules/rflows/applets.ts Normal file
View File

@ -0,0 +1,52 @@
/**
* rFlows applet definition Flow Summary card.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const flowSummary: AppletDefinition = {
id: "flow-summary",
label: "Flow Summary",
icon: "💧",
accentColor: "#0891b2",
ports: [
{ name: "transfer-in", type: "json", direction: "input" },
{ name: "balance-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const inflowRate = (snapshot.inflowRate as number) || 0;
const balance = (snapshot.balance as number) || 0;
const capacity = (snapshot.capacity as number) || 1;
const fillPct = Math.min(100, Math.round((balance / capacity) * 100));
const sufficiency = fillPct >= 80 ? "Sufficient" : fillPct >= 40 ? "Moderate" : "Low";
const suffColor = fillPct >= 80 ? "#22c55e" : fillPct >= 40 ? "#f59e0b" : "#ef4444";
return `
<div>
<div style="display:flex;justify-content:space-between;align-items:baseline;margin-bottom:8px">
<span style="font-size:13px;font-weight:600">Inflow Rate</span>
<span style="font-size:15px;font-weight:700;color:#0891b2">${inflowRate.toLocaleString()}/mo</span>
</div>
<div style="margin-bottom:6px">
<div style="font-size:10px;color:#94a3b8;margin-bottom:3px">Fill: ${fillPct}%</div>
<div style="background:#334155;border-radius:3px;height:8px;overflow:hidden">
<div style="background:linear-gradient(90deg,#0891b2,#06b6d4);width:${fillPct}%;height:100%;border-radius:3px;transition:width 0.3s"></div>
</div>
</div>
<div style="text-align:center;margin-top:8px">
<span style="font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:0.5px;color:${suffColor}">${sufficiency}</span>
</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "transfer-in" && value && typeof value === "object") {
const transfer = value as Record<string, unknown>;
const amount = Number(transfer.amount) || 0;
ctx.emitOutput("balance-out", amount);
}
},
};
export const flowsApplets: AppletDefinition[] = [flowSummary];

View File

@ -11,6 +11,7 @@ import type { RSpaceModule } from "../../shared/module";
import { getModuleInfoList } from "../../shared/module";
import { verifyToken, extractToken } from "../../server/auth";
import { renderLanding } from "./landing";
import { flowsApplets } from "./applets";
import { getTransakEnv, getTransakWebhookSecret } from "../../shared/transak";
import type { SyncServer } from '../../server/local-first/sync-server';
import { flowsSchema, flowsDocId, type FlowsDoc, type SpaceFlow, type CanvasFlow } from './schemas';
@ -1214,6 +1215,7 @@ export const flowsModule: RSpaceModule = {
icon: "🌊",
description: "Budget flows, river visualization, and treasury management",
publicWrite: true,
applets: flowsApplets,
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [{ pattern: '{space}:flows:data', description: 'Space flow associations', init: flowsSchema.init }],
routes,

103
modules/rgov/applets.ts Normal file
View File

@ -0,0 +1,103 @@
/**
* rGov applet definitions Signoff Gate + Governance Circuit.
*/
import type { AppletDefinition, AppletLiveData, AppletSubNode, AppletSubEdge } from "../../shared/applet-types";
import type { PortDescriptor } from "../../lib/data-types";
const signoffGate: AppletDefinition = {
id: "signoff-gate",
label: "Signoff Gate",
icon: "⚖️",
accentColor: "#7c3aed",
ports: [
{ name: "decision-in", type: "json", direction: "input" },
{ name: "gate-out", type: "json", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const title = (snapshot.title as string) || "Signoff Required";
const satisfied = !!snapshot.satisfied;
const assignee = (snapshot.assignee as string) || "anyone";
const statusColor = satisfied ? "#22c55e" : "#f59e0b";
const statusText = satisfied ? "SATISFIED" : "WAITING";
const checkIcon = satisfied ? "✓" : "○";
return `
<div style="text-align:center">
<div style="font-size:13px;font-weight:600;margin-bottom:6px">${title}</div>
<div style="font-size:11px;color:#94a3b8;margin-bottom:8px">Assignee: ${assignee}</div>
<div style="font-size:28px;margin-bottom:4px">${checkIcon}</div>
<div style="font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:0.5px;color:${statusColor}">
${statusText}
</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "decision-in" && value && typeof value === "object") {
const decision = value as Record<string, unknown>;
ctx.emitOutput("gate-out", {
satisfied: !!decision.approved,
source: "decision-in",
timestamp: Date.now(),
});
}
},
};
const governanceCircuit: AppletDefinition = {
id: "governance-circuit",
label: "Governance Circuit",
icon: "🔀",
accentColor: "#6366f1",
ports: [
{ name: "proposal-in", type: "json", direction: "input" },
{ name: "decision-out", type: "json", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const gateCount = (snapshot.gateCount as number) || 0;
const satisfiedCount = (snapshot.satisfiedCount as number) || 0;
const name = (snapshot.circuitName as string) || "Circuit";
return `
<div style="text-align:center">
<div style="font-size:13px;font-weight:600;margin-bottom:8px">${name}</div>
<div style="font-size:24px;font-weight:700;color:#e2e8f0">${satisfiedCount}/${gateCount}</div>
<div style="font-size:10px;color:#94a3b8;margin-top:2px">gates satisfied</div>
<div style="margin-top:8px;background:#334155;border-radius:3px;height:6px;overflow:hidden">
<div style="background:#6366f1;width:${gateCount > 0 ? Math.round((satisfiedCount / gateCount) * 100) : 0}%;height:100%;border-radius:3px;transition:width 0.3s"></div>
</div>
</div>
`;
},
getCircuit(space: string): { nodes: AppletSubNode[]; edges: AppletSubEdge[] } {
// Demo circuit — in production this would read from the space's governance doc
return {
nodes: [
{
id: "gate-1", type: "signoff", label: "Community Approval",
icon: "✓", position: { x: 50, y: 40 },
config: { assignee: "Community", satisfied: false },
},
{
id: "gate-2", type: "threshold", label: "Budget Threshold",
icon: "📊", position: { x: 50, y: 160 },
config: { target: 1000, current: 650, unit: "$" },
},
{
id: "project", type: "project", label: "Project Decision",
icon: "🎯", position: { x: 350, y: 100 },
config: { gatesSatisfied: 0, gatesTotal: 2 },
},
],
edges: [
{ id: "e1", fromNode: "gate-1", fromPort: "out", toNode: "project", toPort: "in" },
{ id: "e2", fromNode: "gate-2", fromPort: "out", toNode: "project", toPort: "in" },
],
};
},
};
export const govApplets: AppletDefinition[] = [signoffGate, governanceCircuit];

View File

@ -12,6 +12,7 @@ import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing";
import { govApplets } from "./applets";
import { addShapes, getDocumentData } from "../../server/community-store";
const routes = new Hono();
@ -288,6 +289,7 @@ export const govModule: RSpaceModule = {
scoping: { defaultScope: "space", userConfigurable: false },
landingPage: renderLanding,
seedTemplate: seedTemplateGov,
applets: govApplets,
canvasShapes: [
"folk-gov-binary",
"folk-gov-threshold",

View File

@ -0,0 +1,82 @@
/**
* rWallet applet definitions Balance Card + Token Balance.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const balanceCard: AppletDefinition = {
id: "balance-card",
label: "Balance Card",
icon: "💰",
accentColor: "#059669",
ports: [
{ name: "address-in", type: "string", direction: "input" },
{ name: "balance-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const ethBalance = (snapshot.ethBalance as number) || 0;
const tokenCount = (snapshot.tokenCount as number) || 0;
const usdTotal = (snapshot.usdTotal as number) || 0;
const address = (snapshot.address as string) || "";
const shortAddr = address ? `${address.slice(0, 6)}${address.slice(-4)}` : "No address";
return `
<div>
<div style="font-size:11px;color:#94a3b8;margin-bottom:8px;font-family:monospace">${shortAddr}</div>
<div style="display:flex;justify-content:space-between;margin-bottom:6px">
<span style="font-size:11px;color:#94a3b8">ETH</span>
<span style="font-size:13px;font-weight:600">${ethBalance.toFixed(4)}</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:8px">
<span style="font-size:11px;color:#94a3b8">Tokens</span>
<span style="font-size:13px;font-weight:600">${tokenCount}</span>
</div>
<div style="border-top:1px solid #334155;padding-top:6px;text-align:right">
<span style="font-size:16px;font-weight:700;color:#22c55e">$${usdTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>
</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "address-in" && typeof value === "string") {
// Address received — in a real implementation this would trigger a balance fetch
ctx.emitOutput("balance-out", 0);
}
},
};
const tokenBalance: AppletDefinition = {
id: "token-balance",
label: "Token Balance",
icon: "🪙",
accentColor: "#7c3aed",
ports: [
{ name: "token-in", type: "string", direction: "input" },
{ name: "amount-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const tokenName = (snapshot.tokenName as string) || "Token";
const symbol = (snapshot.symbol as string) || "???";
const balance = (snapshot.balance as number) || 0;
const usdValue = (snapshot.usdValue as number) || 0;
return `
<div style="text-align:center">
<div style="font-size:28px;margin-bottom:4px">🪙</div>
<div style="font-size:13px;font-weight:600;margin-bottom:2px">${tokenName}</div>
<div style="font-size:11px;color:#94a3b8;margin-bottom:8px">${symbol}</div>
<div style="font-size:18px;font-weight:700">${balance.toLocaleString(undefined, { maximumFractionDigits: 6 })}</div>
<div style="font-size:11px;color:#94a3b8;margin-top:2px"> $${usdValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "token-in" && typeof value === "string") {
ctx.emitOutput("amount-out", 0);
}
},
};
export const walletApplets: AppletDefinition[] = [balanceCard, tokenBalance];

View File

@ -10,6 +10,7 @@ import { renderShell } from "../../server/shell";
import { getModuleInfoList } from "../../shared/module";
import type { RSpaceModule } from "../../shared/module";
import { renderLanding } from "./landing";
import { walletApplets } from "./applets";
import { verifyToken, extractToken } from "../../server/auth";
import { resolveCallerRole } from "../../server/spaces";
import type { SpaceRoleString } from "../../server/spaces";
@ -1327,6 +1328,7 @@ export const walletModule: RSpaceModule = {
name: "rWallet",
icon: "💰",
description: "Multichain Safe wallet visualization and treasury management",
applets: walletApplets,
canvasShapes: ["folk-token-mint", "folk-token-ledger", "folk-transaction-builder"],
scoping: { defaultScope: 'global', userConfigurable: false },
routes,

102
shared/applet-types.ts Normal file
View File

@ -0,0 +1,102 @@
/**
* Applet type system compact dashboard cards with typed I/O ports.
*
* Modules declare AppletDefinition[] to expose applets on the canvas.
* Each applet renders a compact card, wires via FolkArrow ports, and
* optionally expands into a circuit editor (sub-node graph).
*/
import type { PortDescriptor } from "../lib/data-types";
// ── Sub-graph types (for expanded circuit view) ──
export interface AppletSubNode {
id: string;
type: string;
label: string;
icon: string;
position: { x: number; y: number };
config: Record<string, unknown>;
}
export interface AppletSubEdge {
id: string;
fromNode: string;
fromPort: string;
toNode: string;
toPort: string;
}
// ── Applet definition (declared by modules) ──
export interface AppletDefinition {
/** Unique within module, e.g. "signoff-gate" */
id: string;
/** Display name, e.g. "Signoff Gate" */
label: string;
/** Emoji icon */
icon: string;
/** Accent color for header bar */
accentColor: string;
/** Typed I/O ports */
ports: PortDescriptor[];
/** Render compact card body HTML from live data */
renderCompact(data: AppletLiveData): string;
/** Optional: provide sub-graph for expanded circuit view */
getCircuit?(space: string): { nodes: AppletSubNode[]; edges: AppletSubEdge[] };
/** Optional: handle data arriving on an input port */
onInputReceived?(portName: string, value: unknown, ctx: AppletContext): void;
}
// ── Runtime data ──
export interface AppletLiveData {
space: string;
moduleId: string;
appletId: string;
snapshot: Record<string, unknown>;
outputValues: Record<string, unknown>;
}
export interface AppletContext {
space: string;
shapeId: string;
emitOutput(portName: string, value: unknown): void;
}
// ── Template serialization ──
export interface AppletTemplateShape {
/** Relative ID for cross-referencing within the template */
relativeId: string;
type: string;
relX: number;
relY: number;
width: number;
height: number;
rotation: number;
/** All shape-specific properties (moduleId, appletId, etc.) */
props: Record<string, unknown>;
}
export interface AppletTemplateArrow {
relativeId: string;
sourceRelId: string;
targetRelId: string;
sourcePort?: string;
targetPort?: string;
}
export interface AppletTemplateRecord {
id: string;
name: string;
description: string;
icon: string;
color: string;
createdAt: number;
createdBy: string;
shapes: AppletTemplateShape[];
arrows: AppletTemplateArrow[];
boundingWidth: number;
boundingHeight: number;
}

View File

@ -174,6 +174,8 @@ export interface RSpaceModule {
/** Per-module settings schema for space-level configuration */
settingsSchema?: ModuleSettingField[];
/** Applet definitions this module exposes for canvas rApplet cards */
applets?: import("./applet-types").AppletDefinition[];
/** Canvas shape tag names this module owns (e.g. ["folk-commitment-pool"]) */
canvasShapes?: string[];
/** Canvas AI tool IDs this module owns (e.g. ["create_commitment_pool"]) */

View File

@ -2516,6 +2516,9 @@
FolkHolon,
FolkHolonBrowser,
FolkHolonExplorer,
FolkApplet,
registerAppletDef,
AppletTemplateManager,
CommunitySync,
PresenceManager,
generatePeerId,
@ -2540,6 +2543,7 @@
import { RStackTabBar } from "@shared/components/rstack-tab-bar";
import { RStackMi } from "@shared/components/rstack-mi";
import { govApplets, flowsApplets, walletApplets } from "@lib/applet-defs";
import { RStackHistoryPanel } from "@shared/components/rstack-history-panel";
import { RStackCommentBell } from "@shared/components/rstack-comment-bell";
import { rspaceNavUrl } from "@shared/url-helpers";
@ -2758,6 +2762,7 @@
FolkHolon.define();
FolkHolonBrowser.define();
FolkHolonExplorer.define();
FolkApplet.define();
// Register all shapes with the shape registry
shapeRegistry.register("folk-shape", FolkShape);
@ -2826,6 +2831,7 @@
shapeRegistry.register("folk-holon", FolkHolon);
shapeRegistry.register("folk-holon-browser", FolkHolonBrowser);
shapeRegistry.register("folk-holon-explorer", FolkHolonExplorer);
shapeRegistry.register("folk-applet", FolkApplet);
// Wire shape→module affiliations from module declarations
for (const mod of window.__rspaceAllModules || []) {
@ -2834,6 +2840,11 @@
}
}
// Register module applet definitions (imported directly — defs contain functions)
for (const def of govApplets) registerAppletDef("rgov", def);
for (const def of flowsApplets) registerAppletDef("rflows", def);
for (const def of walletApplets) registerAppletDef("rwallet", def);
// Zoom and pan state — declared early to avoid TDZ errors
// (event handlers reference these before awaits yield execution)
let scale = 1;