Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m0s
Details
CI/CD / deploy (push) Successful in 2m0s
Details
This commit is contained in:
commit
7c167e959d
|
|
@ -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, ">").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 = `
|
||||
<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);
|
||||
|
|
@ -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";
|
||||
|
|
@ -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];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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">×</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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"]) */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue