diff --git a/lib/applet-circuit-canvas.ts b/lib/applet-circuit-canvas.ts new file mode 100644 index 00000000..19ec8cd5 --- /dev/null +++ b/lib/applet-circuit-canvas.ts @@ -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, "&").replace(//g, ">").replace(/"/g, """); +} + +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 = ` + + + + + + + + `; + + 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 ` + + + + + `; + }).join(""); + + const nodesHtml = this.#nodes.map(node => { + const configSummary = Object.entries(node.config) + .slice(0, 2) + .map(([k, v]) => `${k}: ${v}`) + .join(", "); + + return ` + + + + ${esc(node.icon)} ${esc(node.label)} + ${configSummary ? `${esc(configSummary)}` : ""} + + + + `; + }).join(""); + + this.#shadow.innerHTML = ` + + + + ${gridDef} + ${edgesHtml} + ${nodesHtml} + + + `; + + 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); diff --git a/lib/applet-defs.ts b/lib/applet-defs.ts new file mode 100644 index 00000000..242ad6ba --- /dev/null +++ b/lib/applet-defs.ts @@ -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"; diff --git a/lib/applet-template-manager.ts b/lib/applet-template-manager.ts new file mode 100644 index 00000000..41f828da --- /dev/null +++ b/lib/applet-template-manager.ts @@ -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 { + 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(); + 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, + }); + } + + // 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(); + 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]; + }); + } +} diff --git a/lib/community-sync.ts b/lib/community-sync.ts index a040c936..37790487 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -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; diff --git a/lib/folk-applet.ts b/lib/folk-applet.ts new file mode 100644 index 00000000..d56bbe86 --- /dev/null +++ b/lib/folk-applet.ts @@ -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(); + +/** 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 = {}; + #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) { 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): 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` + + ${icon} ${label} + + ⊞ + × + + + Loading... + `; + + 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): 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): 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; + } +} diff --git a/lib/index.ts b/lib/index.ts index f789d82d..e3b08424 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -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"; diff --git a/modules/rflows/applets.ts b/modules/rflows/applets.ts new file mode 100644 index 00000000..91d40717 --- /dev/null +++ b/modules/rflows/applets.ts @@ -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 ` + + + Inflow Rate + ${inflowRate.toLocaleString()}/mo + + + Fill: ${fillPct}% + + + + + + ${sufficiency} + + + `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "transfer-in" && value && typeof value === "object") { + const transfer = value as Record; + const amount = Number(transfer.amount) || 0; + ctx.emitOutput("balance-out", amount); + } + }, +}; + +export const flowsApplets: AppletDefinition[] = [flowSummary]; diff --git a/modules/rflows/mod.ts b/modules/rflows/mod.ts index 1701ad37..849b7a1b 100644 --- a/modules/rflows/mod.ts +++ b/modules/rflows/mod.ts @@ -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, diff --git a/modules/rgov/applets.ts b/modules/rgov/applets.ts new file mode 100644 index 00000000..92cc55cb --- /dev/null +++ b/modules/rgov/applets.ts @@ -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 ` + + ${title} + Assignee: ${assignee} + ${checkIcon} + + ${statusText} + + + `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "decision-in" && value && typeof value === "object") { + const decision = value as Record; + 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 ` + + ${name} + ${satisfiedCount}/${gateCount} + gates satisfied + + + + + `; + }, + 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]; diff --git a/modules/rgov/mod.ts b/modules/rgov/mod.ts index a5c9848a..0f7f4456 100644 --- a/modules/rgov/mod.ts +++ b/modules/rgov/mod.ts @@ -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", diff --git a/modules/rwallet/applets.ts b/modules/rwallet/applets.ts new file mode 100644 index 00000000..32b93106 --- /dev/null +++ b/modules/rwallet/applets.ts @@ -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 ` + + ${shortAddr} + + ETH + ${ethBalance.toFixed(4)} + + + Tokens + ${tokenCount} + + + $${usdTotal.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + + `; + }, + 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 ` + + 🪙 + ${tokenName} + ${symbol} + ${balance.toLocaleString(undefined, { maximumFractionDigits: 6 })} + ≈ $${usdValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "token-in" && typeof value === "string") { + ctx.emitOutput("amount-out", 0); + } + }, +}; + +export const walletApplets: AppletDefinition[] = [balanceCard, tokenBalance]; diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index 8abd2000..c462e71e 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -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, diff --git a/shared/applet-types.ts b/shared/applet-types.ts new file mode 100644 index 00000000..f4112a73 --- /dev/null +++ b/shared/applet-types.ts @@ -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; +} + +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; + outputValues: Record; +} + +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; +} + +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; +} diff --git a/shared/module.ts b/shared/module.ts index d251c77f..4ff5e9d0 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -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"]) */ diff --git a/website/canvas.html b/website/canvas.html index 227af262..cde7b9ee 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -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;