From 28922da39f561f5e104ac75eb9ec733eca9756ac Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 12 Mar 2026 23:34:45 -0700 Subject: [PATCH] feat: typed ports, flow bridging, governance gates & data transforms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify arrow data pipes, LayerFlows, and governance shapes into a coherent workflow substrate for collaborative rApp pipelines. - Module-specific typed ports for 10 rApp modules (rcal, rtasks, etc.) - Port picker UI when connecting shapes with port descriptors - Flow-typed arrow coloring (economic=green, governance=purple, etc.) - Arrow ↔ LayerFlow bridge: auto-create/remove Automerge flows - Governance gates: vote/choice shapes can gate arrow data flow - Safe data transforms on arrows (filter, map, pick, count, first, last) - Transform editor UI (double-click or right-click pipe arrows) - folk-feed auto-creation when dragging arrow to empty canvas Co-Authored-By: Claude Opus 4.6 --- lib/data-types.ts | 18 +++ lib/flow-bridge.ts | 66 ++++++++ lib/folk-arrow.ts | 227 +++++++++++++++++++++++++-- lib/folk-choice-rank.ts | 27 ++++ lib/folk-choice-spider.ts | 31 ++++ lib/folk-choice-vote.ts | 27 ++++ lib/folk-rapp.ts | 110 +++++++++++-- lib/index.ts | 3 + website/canvas.html | 322 ++++++++++++++++++++++++++++++++++++-- 9 files changed, 793 insertions(+), 38 deletions(-) create mode 100644 lib/flow-bridge.ts diff --git a/lib/data-types.ts b/lib/data-types.ts index 87b8269..39810c1 100644 --- a/lib/data-types.ts +++ b/lib/data-types.ts @@ -5,6 +5,8 @@ * flowing data from source outputs to target inputs with type checking. */ +import { FLOW_COLORS, type FlowKind } from "./layer-types"; + /** Supported data types for ports. */ export type DataType = | "string" @@ -37,6 +39,22 @@ export function isCompatible(source: DataType, target: DataType): boolean { return false; } +/** Infer the FlowKind from a port name for flow-typed arrow coloring. */ +export function inferFlowKind(portName: string): FlowKind { + const lower = portName.toLowerCase(); + if (/balance|deposit|transfer|allocation|flow|wallet/.test(lower)) return "economic"; + if (/vote|decision|proposal|governance|passed/.test(lower)) return "governance"; + if (/trust|reputation|endorsement/.test(lower)) return "trust"; + if (/attention|view|engagement/.test(lower)) return "attention"; + if (/file|asset|resource|photo/.test(lower)) return "resource"; + return "data"; +} + +/** Get the color for a FlowKind. */ +export function flowKindColor(kind: FlowKind): string { + return FLOW_COLORS[kind] || "#94a3b8"; +} + /** Color tint per data type for arrow visualization. */ export function dataTypeColor(type: DataType): string { switch (type) { diff --git a/lib/flow-bridge.ts b/lib/flow-bridge.ts new file mode 100644 index 0000000..51ddf38 --- /dev/null +++ b/lib/flow-bridge.ts @@ -0,0 +1,66 @@ +/** + * Flow Bridge — bridges arrow data pipes with LayerFlow entries in Automerge. + * + * When an arrow pipe connects two folk-rapp shapes, a LayerFlow is auto-created + * in the Automerge doc. Deleting the arrow removes the LayerFlow. + */ + +import { inferFlowKind } from "./data-types"; +import type { LayerFlow, Layer } from "./layer-types"; + +interface SyncLike { + getLayers(): Layer[]; + addFlow(flow: LayerFlow): void; + removeFlow(flowId: string): void; +} + +/** + * Create a LayerFlow when an arrow pipe is created between two folk-rapp shapes. + * Returns the new flowId, or null if layers couldn't be matched. + */ +export function onArrowPipeCreated( + arrowId: string, + sourcePort: string, + targetPort: string, + srcModuleId: string, + tgtModuleId: string, + sync: SyncLike, +): string | null { + const layers = sync.getLayers(); + + // Find layers matching the source and target modules + const srcLayer = layers.find(l => l.moduleId === srcModuleId); + const tgtLayer = layers.find(l => l.moduleId === tgtModuleId); + + if (!srcLayer || !tgtLayer) return null; + + const flowKind = inferFlowKind(sourcePort); + const flowId = `flow-${arrowId}`; + + const flow: LayerFlow = { + id: flowId, + kind: flowKind, + sourceLayerId: srcLayer.id, + targetLayerId: tgtLayer.id, + label: `${srcModuleId} → ${tgtModuleId}`, + strength: 1, + active: true, + meta: { + arrowId, + sourcePort, + targetPort, + }, + }; + + sync.addFlow(flow); + return flowId; +} + +/** + * Remove a LayerFlow when the corresponding arrow is deleted. + */ +export function onArrowRemoved(flowId: string, sync: SyncLike): void { + if (flowId) { + sync.removeFlow(flowId); + } +} diff --git a/lib/folk-arrow.ts b/lib/folk-arrow.ts index 9eb716a..74106b7 100644 --- a/lib/folk-arrow.ts +++ b/lib/folk-arrow.ts @@ -2,7 +2,7 @@ import { getBoxToBoxArrow } from "perfect-arrows"; import { getStroke, type StrokeOptions } from "perfect-freehand"; import { FolkElement } from "./folk-element"; import { css } from "./tags"; -import { isCompatible, dataTypeColor } from "./data-types"; +import { isCompatible, dataTypeColor, inferFlowKind, flowKindColor } from "./data-types"; // Point interface for bezier curves interface Point { @@ -134,6 +134,20 @@ declare global { export type ArrowStyle = "smooth" | "straight" | "curved" | "sketchy"; +/** A governance gate on an arrow — data only flows when the gate is open. */ +export interface ArrowGate { + shapeId: string; // governance shape ID + portName: string; // port to watch (e.g. "decision-out") + condition: "truthy" | "passed" | "threshold"; + threshold?: number; +} + +/** An inline data transform on a pipe arrow. */ +export interface ArrowTransform { + type: "filter" | "map" | "pick" | "count" | "first" | "last"; + expression: string; // safe dot-path, NO eval() +} + export class FolkArrow extends FolkElement { static override tagName = "folk-arrow"; static styles = styles; @@ -155,6 +169,34 @@ export class FolkArrow extends FolkElement { #pipeDebounce: ReturnType | null = null; #portListener: ((e: Event) => void) | null = null; + // Flow bridge + #flowId: string = ""; + + get flowId() { return this.#flowId; } + set flowId(value: string) { this.#flowId = value; } + + // Governance gate + #gate: ArrowGate | null = null; + #gateOpen: boolean = true; + #gateListener: ((e: Event) => void) | null = null; + + get gate(): ArrowGate | null { return this.#gate; } + set gate(value: ArrowGate | null) { + this.#teardownGate(); + this.#gate = value; + if (value) this.#setupGate(); + this.#updateArrow(); + } + + // Data transforms + #transform: ArrowTransform | null = null; + + get transform(): ArrowTransform | null { return this.#transform; } + set transform(value: ArrowTransform | null) { + this.#transform = value; + this.#updateArrow(); + } + get sourcePort() { return this.#sourcePort; } set sourcePort(value: string) { this.#sourcePort = value; @@ -287,6 +329,7 @@ export class FolkArrow extends FolkElement { this.#resizeObserver.disconnect(); this.#stopPositionTracking(); this.#teardownPipe(); + this.#teardownGate(); } #animationFrameId: number | null = null; @@ -329,9 +372,12 @@ export class FolkArrow extends FolkElement { return; } - // Tint arrow color by data type + // Tint arrow color: use FlowKind if both endpoints are folk-rapp, else DataType if (srcPort) { - this.#color = dataTypeColor(srcPort.type); + const bothRapp = srcEl.tagName === "FOLK-RAPP" && tgtEl.tagName === "FOLK-RAPP"; + this.#color = bothRapp + ? flowKindColor(inferFlowKind(this.#sourcePort)) + : dataTypeColor(srcPort.type); this.#updateArrow(); } @@ -343,8 +389,16 @@ export class FolkArrow extends FolkElement { // Debounce to avoid rapid-fire updates if (this.#pipeDebounce) clearTimeout(this.#pipeDebounce); this.#pipeDebounce = setTimeout(() => { + // Gate check: skip if gate is closed + if (!this.#gateOpen) return; + + // Apply transform if present + let value = this.#transform + ? this.#applyTransform(detail.value) + : detail.value; + if (tgtEl.setPortValue) { - tgtEl.setPortValue(this.#targetPort, detail.value); + tgtEl.setPortValue(this.#targetPort, value); } }, 100); }; @@ -364,6 +418,129 @@ export class FolkArrow extends FolkElement { } } + /** Set up gate listener on a governance shape. */ + #setupGate(): void { + if (!this.#gate) return; + const gateEl = document.getElementById(this.#gate.shapeId) as any; + if (!gateEl) return; + + this.#gateListener = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail.name !== this.#gate!.portName) return; + this.#evaluateGate(detail.value); + }; + + gateEl.addEventListener("port-value-changed", this.#gateListener); + + // Evaluate current value immediately + const currentVal = gateEl.getPortValue?.(this.#gate.portName); + if (currentVal !== undefined) this.#evaluateGate(currentVal); + } + + /** Remove gate listener. */ + #teardownGate(): void { + if (this.#gateListener && this.#gate) { + const gateEl = document.getElementById(this.#gate.shapeId); + if (gateEl) { + gateEl.removeEventListener("port-value-changed", this.#gateListener); + } + } + this.#gateListener = null; + this.#gateOpen = true; + } + + /** Evaluate the gate condition against the current value. */ + #evaluateGate(value: unknown): void { + const wasOpen = this.#gateOpen; + + if (!this.#gate) { + this.#gateOpen = true; + } else if (this.#gate.condition === "truthy") { + this.#gateOpen = !!value; + } else if (this.#gate.condition === "passed") { + const v = value as any; + this.#gateOpen = !!(v?.passed || v?.decided || v?.winner); + } else if (this.#gate.condition === "threshold") { + const v = value as any; + const num = typeof v === "number" ? v : (v?.margin ?? v?.score ?? 0); + this.#gateOpen = num >= (this.#gate.threshold ?? 0.5); + } + + if (wasOpen !== this.#gateOpen) this.#updateArrow(); + } + + /** Safe dot-path accessor: getPath({a: {b: 1}}, "a.b") → 1 */ + static #getPath(obj: any, path: string): any { + if (!obj || !path) return obj; + const parts = path.split("."); + let current = obj; + for (const part of parts) { + if (current == null) return undefined; + // Support bracket notation: items[0] + const match = part.match(/^(\w+)\[(\d+)\]$/); + if (match) { + current = current[match[1]]; + if (Array.isArray(current)) current = current[parseInt(match[2])]; + else return undefined; + } else { + current = current[part]; + } + } + return current; + } + + /** Evaluate a simple predicate (no eval): "status === 'DONE'" */ + static #evalPredicate(item: any, expr: string): boolean { + // Parse: field op value + const match = expr.match(/^([\w.[\]]+)\s*(===|!==|>=|<=|>|<)\s*['"]?([^'"]*?)['"]?$/); + if (!match) return !!item; + const [, field, op, val] = match; + const fieldVal = FolkArrow.#getPath(item, field); + const numVal = Number(val); + const useNum = !isNaN(numVal) && typeof fieldVal === "number"; + switch (op) { + case "===": return useNum ? fieldVal === numVal : String(fieldVal) === val; + case "!==": return useNum ? fieldVal !== numVal : String(fieldVal) !== val; + case ">": return useNum ? fieldVal > numVal : false; + case "<": return useNum ? fieldVal < numVal : false; + case ">=": return useNum ? fieldVal >= numVal : false; + case "<=": return useNum ? fieldVal <= numVal : false; + default: return false; + } + } + + /** Apply the transform to a data value. */ + #applyTransform(value: unknown): unknown { + if (!this.#transform) return value; + const { type, expression } = this.#transform; + const arr = Array.isArray(value) ? value : [value]; + + switch (type) { + case "filter": + return arr.filter(item => FolkArrow.#evalPredicate(item, expression)); + case "map": + return arr.map(item => FolkArrow.#getPath(item, expression)); + case "pick": + // Pick specific fields: "title,status,id" + const fields = expression.split(",").map(f => f.trim()); + return arr.map(item => { + const result: Record = {}; + for (const f of fields) { + result[f] = FolkArrow.#getPath(item, f); + } + return result; + }); + case "count": + return arr.length; + case "first": + return arr[0]; + case "last": + return arr[arr.length - 1]; + default: + return value; + } + } + #observeSource() { if (this.#sourceElement) { this.#resizeObserver.unobserve(this.#sourceElement); @@ -424,6 +601,10 @@ export class FolkArrow extends FolkElement { this.#targetRect.height, ); + // Gate visual: dim and use gray when gated closed + const effectiveColor = (this.#gate && !this.#gateOpen) ? "#94a3b8" : this.#color; + const gatedClosed = this.#gate && !this.#gateOpen; + if (this.#arrowStyle === "smooth") { // Original behavior: perfect-freehand tapered stroke via clipPath if (this.#svg) this.#svg.style.display = "none"; @@ -441,7 +622,8 @@ export class FolkArrow extends FolkElement { const path = getSvgPathFromStroke(stroke); this.style.clipPath = `path('${path}')`; - this.style.backgroundColor = this.#color; + this.style.backgroundColor = effectiveColor; + this.style.opacity = gatedClosed ? "0.4" : ""; } else { // SVG-based rendering for other styles this.style.clipPath = ""; @@ -497,12 +679,30 @@ export class FolkArrow extends FolkElement { arrowheadPoints = `${p1x},${p1y} ${ex},${ey} ${p2x},${p2y}`; } - this.#svg.innerHTML = ` - - + ${dashAttr}${opacityAttr} /> + `; + + // Transform badge at midpoint + if (this.#transform) { + const badges: Record = { filter: "F", map: "M", pick: "P", count: "#", first: "1", last: "L" }; + const badge = badges[this.#transform.type] || "T"; + svgContent += ` + + ${badge} + `; + } + + this.#svg.innerHTML = svgContent; } } @@ -525,6 +725,9 @@ export class FolkArrow extends FolkElement { if (data.arrowStyle) arrow.arrowStyle = data.arrowStyle; if (data.sourcePort) arrow.sourcePort = data.sourcePort; if (data.targetPort) arrow.targetPort = data.targetPort; + if (data.flowId) arrow.flowId = data.flowId; + if (data.gate) arrow.gate = data.gate; + if (data.transform) arrow.transform = data.transform; return arrow; } @@ -540,6 +743,9 @@ export class FolkArrow extends FolkElement { }; if (this.#sourcePort) json.sourcePort = this.#sourcePort; if (this.#targetPort) json.targetPort = this.#targetPort; + if (this.#flowId) json.flowId = this.#flowId; + if (this.#gate) json.gate = this.#gate; + if (this.#transform) json.transform = this.#transform; return json; } @@ -551,5 +757,8 @@ export class FolkArrow extends FolkElement { if (data.arrowStyle !== undefined && this.#arrowStyle !== data.arrowStyle) this.arrowStyle = data.arrowStyle as ArrowStyle; if (data.sourcePort !== undefined && this.#sourcePort !== data.sourcePort) this.sourcePort = data.sourcePort; if (data.targetPort !== undefined && this.#targetPort !== data.targetPort) this.targetPort = data.targetPort; + if (data.flowId !== undefined && this.#flowId !== data.flowId) this.flowId = data.flowId; + if (data.gate !== undefined) this.gate = data.gate; + if (data.transform !== undefined) this.transform = data.transform; } } diff --git a/lib/folk-choice-rank.ts b/lib/folk-choice-rank.ts index 13615bc..c1ccf1e 100644 --- a/lib/folk-choice-rank.ts +++ b/lib/folk-choice-rank.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; const USER_ID_KEY = "folk-choice-userid"; const USER_NAME_KEY = "folk-choice-username"; @@ -543,6 +544,12 @@ const MEDAL_COLORS = ["#f59e0b", "#94a3b8", "#cd7f32", "#64748b", "#64748b"]; export class FolkChoiceRank extends FolkShape { static override tagName = "folk-choice-rank"; + static override portDescriptors: PortDescriptor[] = [ + { name: "results-out", type: "json", direction: "output" }, + { name: "decision-out", type: "json", direction: "output" }, + { name: "vote-complete", type: "trigger", direction: "output" }, + ]; + static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); @@ -639,6 +646,7 @@ export class FolkChoiceRank extends FolkShape { override createRenderRoot() { const root = super.createRenderRoot(); + this.initPorts(); this.#ensureIdentity(); this.#syncMyOrdering(); @@ -774,6 +782,25 @@ export class FolkChoiceRank extends FolkShape { if (this.#activeTab === "rank") this.#renderRankList(); else this.#renderResults(); if (this.#drawerOpen) this.#renderDrawer(); + + // Emit decision data on output ports + if (this.#rankings.length > 0 && this.#options.length > 0) { + const borda = bordaCount(this.#rankings, this.#options); + const sorted = [...borda.entries()].sort((a, b) => b[1] - a[1]); + const winner = sorted[0]?.[0] ?? null; + const cw = condorcetWinner(this.#rankings, this.#options); + const uniqueVoters = new Set(this.#rankings.map(r => r.userId)).size; + + this.setPortValue("results-out", Object.fromEntries(borda)); + this.setPortValue("decision-out", { + decided: uniqueVoters > 0, + winner, + condorcetWinner: cw, + passed: !!winner, + results: Object.fromEntries(borda), + voters: uniqueVoters, + }); + } } #renderRankList() { diff --git a/lib/folk-choice-spider.ts b/lib/folk-choice-spider.ts index 5c80af5..80f0481 100644 --- a/lib/folk-choice-spider.ts +++ b/lib/folk-choice-spider.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; const USER_ID_KEY = "folk-choice-userid"; const USER_NAME_KEY = "folk-choice-username"; @@ -486,6 +487,12 @@ function userColor(userId: string): string { export class FolkChoiceSpider extends FolkShape { static override tagName = "folk-choice-spider"; + static override portDescriptors: PortDescriptor[] = [ + { name: "results-out", type: "json", direction: "output" }, + { name: "decision-out", type: "json", direction: "output" }, + { name: "vote-complete", type: "trigger", direction: "output" }, + ]; + static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); @@ -582,6 +589,7 @@ export class FolkChoiceSpider extends FolkShape { override createRenderRoot() { const root = super.createRenderRoot(); + this.initPorts(); this.#ensureIdentity(); if (this.#options.length > 0) this.#selectedOptionId = this.#options[0].id; @@ -729,6 +737,29 @@ export class FolkChoiceSpider extends FolkShape { this.#renderLegend(); this.#renderSummary(); if (this.#drawerOpen) this.#renderDrawer(); + + // Emit decision data on output ports + if (this.#options.length > 0 && this.#criteria.length > 0) { + const results = this.#options.map(opt => ({ + id: opt.id, + label: opt.label, + score: weightedMeanScore(this.#scores, this.#criteria, opt.id), + })); + results.sort((a, b) => b.score - a.score); + const winner = results[0]; + const allUsers = new Set(this.#scores.map(s => s.userId)); + + this.setPortValue("results-out", results); + this.setPortValue("decision-out", { + decided: allUsers.size > 0 && winner.score > 0, + winner: winner?.id ?? null, + winnerLabel: winner?.label ?? null, + passed: winner?.score > 0, + margin: results.length >= 2 ? winner.score - results[1].score : 0, + results, + voters: allUsers.size, + }); + } } #renderOptionTabs() { diff --git a/lib/folk-choice-vote.ts b/lib/folk-choice-vote.ts index f2d17d0..7e15511 100644 --- a/lib/folk-choice-vote.ts +++ b/lib/folk-choice-vote.ts @@ -1,5 +1,6 @@ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; +import type { PortDescriptor } from "./data-types"; const USER_ID_KEY = "folk-choice-userid"; const USER_NAME_KEY = "folk-choice-username"; @@ -403,6 +404,12 @@ const DEFAULT_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", " export class FolkChoiceVote extends FolkShape { static override tagName = "folk-choice-vote"; + static override portDescriptors: PortDescriptor[] = [ + { name: "results-out", type: "json", direction: "output" }, + { name: "decision-out", type: "json", direction: "output" }, + { name: "vote-complete", type: "trigger", direction: "output" }, + ]; + static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); @@ -487,6 +494,7 @@ export class FolkChoiceVote extends FolkShape { override createRenderRoot() { const root = super.createRenderRoot(); + this.initPorts(); this.#ensureIdentity(); const wrapper = document.createElement("div"); @@ -719,6 +727,25 @@ export class FolkChoiceVote extends FolkShape { : `${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}`; } + // Emit decision data on output ports + const sorted = [...tally.entries()].sort((a, b) => b[1] - a[1]); + const winner = sorted.length > 0 ? sorted[0] : null; + const margin = sorted.length >= 2 && totalVotes > 0 + ? (sorted[0][1] - sorted[1][1]) / totalVotes : 0; + const decided = uniqueVoters > 0 && winner && winner[1] > 0; + const passed = decided && margin > 0; + + this.setPortValue("results-out", Object.fromEntries(tally)); + this.setPortValue("decision-out", { + decided, + winner: winner ? winner[0] : null, + passed, + margin, + results: Object.fromEntries(tally), + voters: uniqueVoters, + mode: this.#mode, + }); + // Wire click events if (this.#mode !== "quadratic") { this.#optionsEl.querySelectorAll(".option-row").forEach((row) => { diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts index b070423..266943c 100644 --- a/lib/folk-rapp.ts +++ b/lib/folk-rapp.ts @@ -416,6 +416,49 @@ declare global { } } +// Module-specific port descriptors: meaningful typed ports per rApp module +const MODULE_PORTS: Record = { + rcal: [{ name: "events-out", type: "json", direction: "output" }, + { name: "upcoming-out", type: "json", direction: "output" }, + { name: "new-event", type: "json", direction: "input" }, + { name: "event-trigger", type: "trigger", direction: "input" }], + rtasks: [{ name: "tasks-out", type: "json", direction: "output" }, + { name: "task-created", type: "trigger", direction: "output" }, + { name: "create-task", type: "json", direction: "input" }, + { name: "status-filter", type: "string", direction: "input" }], + rflows: [{ name: "allocations-out", type: "json", direction: "output" }, + { name: "balance-out", type: "number", direction: "output" }, + { name: "deposit-trigger", type: "trigger", direction: "input" }, + { name: "flow-data", type: "json", direction: "output" }], + rchoices: [{ name: "results-out", type: "json", direction: "output" }, + { name: "decision-out", type: "json", direction: "output" }, + { name: "vote-complete", type: "trigger", direction: "output" }], + rvote: [{ name: "proposals-out", type: "json", direction: "output" }, + { name: "decision-out", type: "json", direction: "output" }, + { name: "vote-complete", type: "trigger", direction: "output" }, + { name: "proposal-passed", type: "boolean", direction: "output" }], + rnotes: [{ name: "notes-out", type: "json", direction: "output" }, + { name: "create-note", type: "json", direction: "input" }, + { name: "note-created", type: "trigger", direction: "output" }], + rforum: [{ name: "posts-out", type: "json", direction: "output" }, + { name: "new-post", type: "trigger", direction: "output" }, + { name: "thread-data", type: "json", direction: "output" }], + rnetwork: [{ name: "graph-out", type: "json", direction: "output" }, + { name: "nodes-out", type: "json", direction: "output" }], + rinbox: [{ name: "threads-out", type: "json", direction: "output" }, + { name: "new-message", type: "trigger", direction: "output" }], + rwallet: [{ name: "balance-out", type: "number", direction: "output" }, + { name: "transfer-trigger", type: "trigger", direction: "input" }, + { name: "transfer-data", type: "json", direction: "input" }], +}; + +const DEFAULT_PORTS: PortDescriptor[] = [ + { name: "data-in", type: "json", direction: "input" }, + { name: "data-out", type: "json", direction: "output" }, + { name: "trigger-in", type: "trigger", direction: "input" }, + { name: "trigger-out", type: "trigger", direction: "output" }, +]; + // API endpoint config per module for widget mode const WIDGET_API: Record WidgetData }> = { rinbox: { @@ -564,11 +607,14 @@ export class FolkRApp extends FolkShape { #statusEl: HTMLElement | null = null; #refreshTimer: ReturnType | null = null; #modeToggleBtn: HTMLButtonElement | null = null; + #resolvedPorts: PortDescriptor[] | null = null; get moduleId() { return this.#moduleId; } set moduleId(value: string) { if (this.#moduleId === value) return; this.#moduleId = value; + this.#resolvedPorts = null; + this.initPorts(); this.requestUpdate("moduleId"); this.dispatchEvent(new CustomEvent("content-change")); this.#renderContent(); @@ -592,6 +638,34 @@ export class FolkRApp extends FolkShape { this.#renderContent(); } + /** Resolve module-specific ports, falling back to generic defaults. */ + #getModulePorts(): PortDescriptor[] { + if (this.#resolvedPorts) return this.#resolvedPorts; + this.#resolvedPorts = MODULE_PORTS[this.#moduleId] || DEFAULT_PORTS; + return this.#resolvedPorts; + } + + override getPort(name: string): PortDescriptor | undefined { + return this.#getModulePorts().find(p => p.name === name); + } + + override getInputPorts(): PortDescriptor[] { + return this.#getModulePorts().filter(p => p.direction === "input"); + } + + override getOutputPorts(): PortDescriptor[] { + return this.#getModulePorts().filter(p => p.direction === "output"); + } + + override initPorts(): void { + const descriptors = this.#getModulePorts(); + for (const desc of descriptors) { + if (this.getPortValue(desc.name) === undefined) { + this.setPortValueSilent(desc.name, undefined); + } + } + } + override createRenderRoot() { const root = super.createRenderRoot(); @@ -692,25 +766,24 @@ export class FolkRApp extends FolkShape { // Forward input port data to iframe (AC#3) this.addEventListener("port-value-changed", ((e: CustomEvent) => { const { name, value } = e.detail; - if (name === "data-in" && this.#iframe?.contentWindow) { - const targetOrigin = this.#sandboxed && this.#ecosystemOrigin - ? this.#ecosystemOrigin : window.location.origin; - try { + const port = this.getPort(name); + if (!port || port.direction !== "input" || !this.#iframe?.contentWindow) return; + + const targetOrigin = this.#sandboxed && this.#ecosystemOrigin + ? this.#ecosystemOrigin : window.location.origin; + try { + if (port.type === "trigger") { + this.#iframe.contentWindow.postMessage({ + source: "rspace-parent", type: "trigger", + portName: name, + }, targetOrigin); + } else { this.#iframe.contentWindow.postMessage({ source: "rspace-parent", type: "port-data", portName: name, value, }, targetOrigin); - } catch { /* iframe not ready */ } - } - if (name === "trigger-in" && this.#iframe?.contentWindow) { - const targetOrigin = this.#sandboxed && this.#ecosystemOrigin - ? this.#ecosystemOrigin : window.location.origin; - try { - this.#iframe.contentWindow.postMessage({ - source: "rspace-parent", type: "trigger", - }, targetOrigin); - } catch { /* iframe not ready */ } - } + } + } catch { /* iframe not ready */ } }) as EventListener); // Load content @@ -1003,6 +1076,13 @@ export class FolkRApp extends FolkShape { const data = await res.json(); const widgetData = apiConfig.transform(data); this.#renderWidgetCard(widgetData); + + // Emit raw API data on the first json output port + const outputPorts = this.getOutputPorts(); + const jsonOut = outputPorts.find(p => p.type === "json"); + if (jsonOut) { + this.setPortValue(jsonOut.name, data); + } } catch { this.#renderWidgetFallback(); } diff --git a/lib/index.ts b/lib/index.ts index f5f6644..dd57e57 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -99,6 +99,9 @@ export * from "./folk-feed"; export * from "./data-types"; export * from "./shape-registry"; +// Flow Bridge (arrow ↔ LayerFlow) +export * from "./flow-bridge"; + // Shape Groups export * from "./group-manager"; export * from "./folk-group-frame"; diff --git a/website/canvas.html b/website/canvas.html index 4b268c0..853a286 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -2543,7 +2543,9 @@ MiTriagePanel, shapeRegistry, GroupManager, - FolkGroupFrame + FolkGroupFrame, + onArrowPipeCreated, + onArrowRemoved, } from "@lib"; import { RStackIdentity } from "@shared/components/rstack-identity"; import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher"; @@ -4464,11 +4466,156 @@ if (typeof syncBottomToolbar === "function") syncBottomToolbar(); } + // ── Port Picker UI ── + // Shows a popup to select source/target ports when connecting shapes with port descriptors + function showPortPicker(sourceEl, targetEl, onSelect) { + const srcPorts = sourceEl.getOutputPorts?.() || []; + const tgtPorts = targetEl.getInputPorts?.() || []; + + // If no ports on either side, skip picker + if (srcPorts.length === 0 && tgtPorts.length === 0) { + onSelect(null, null); + return; + } + + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;"; + + const panel = document.createElement("div"); + panel.style.cssText = "background:white;border-radius:12px;padding:16px;box-shadow:0 8px 32px rgba(0,0,0,0.2);min-width:340px;max-width:500px;font-family:system-ui;"; + + let selectedSrc = srcPorts[0]?.name || ""; + let selectedTgt = tgtPorts[0]?.name || ""; + + function render() { + const srcName = sourceEl.tagName === "FOLK-RAPP" ? (sourceEl.moduleId || "source") : "source"; + const tgtName = targetEl.tagName === "FOLK-RAPP" ? (targetEl.moduleId || "target") : "target"; + panel.innerHTML = ` +
Connect Ports
+
+
+
${srcName} (output)
+ ${srcPorts.map(p => ` + + `).join("")} + ${srcPorts.length === 0 ? '
No output ports
' : ''} +
+
+
${tgtName} (input)
+ ${tgtPorts.map(p => ` + + `).join("")} + ${tgtPorts.length === 0 ? '
No input ports
' : ''} +
+
+
+ + +
+ `; + + panel.querySelectorAll('input[name="src-port"]').forEach(r => { + r.addEventListener("change", () => { selectedSrc = r.value; render(); }); + }); + panel.querySelectorAll('input[name="tgt-port"]').forEach(r => { + r.addEventListener("change", () => { selectedTgt = r.value; render(); }); + }); + panel.querySelector(".pp-skip")?.addEventListener("click", () => { + overlay.remove(); + onSelect(null, null); + }); + panel.querySelector(".pp-connect")?.addEventListener("click", () => { + overlay.remove(); + onSelect(selectedSrc || null, selectedTgt || null); + }); + } + + render(); + overlay.appendChild(panel); + overlay.addEventListener("click", (ev) => { + if (ev.target === overlay) { overlay.remove(); onSelect(null, null); } + }); + document.body.appendChild(overlay); + } + + /** Create an arrow between two shapes, optionally with ports. */ + function createArrowBetween(sourceEl, targetEl, srcPort, tgtPort) { + const arrowId = `arrow-${Date.now()}-${++shapeCounter}`; + const colors = ["#374151", "#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6"]; + + const arrow = document.createElement("folk-arrow"); + arrow.id = arrowId; + arrow.sourceId = sourceEl.id; + arrow.targetId = targetEl.id; + arrow.color = colors[Math.floor(Math.random() * colors.length)]; + if (typeof arrowStyle !== "undefined" && arrowStyle !== "smooth") { + arrow.arrowStyle = arrowStyle; + } + if (srcPort) arrow.sourcePort = srcPort; + if (tgtPort) arrow.targetPort = tgtPort; + + canvasContent.appendChild(arrow); + sync.registerShape(arrow); + + // Flow bridge: auto-create LayerFlow if both endpoints are folk-rapp + if (sourceEl.tagName === "FOLK-RAPP" && targetEl.tagName === "FOLK-RAPP" && srcPort) { + const flowId = onArrowPipeCreated( + arrowId, srcPort, tgtPort || "", + sourceEl.moduleId, targetEl.moduleId, sync + ); + if (flowId) arrow.flowId = flowId; + } + + return arrow; + } + // Handle shape clicks for connection mode canvas.addEventListener("click", (e) => { if (!connectMode) return; const target = e.target.closest(CONNECTABLE_SELECTOR); + + // Phase 6: folk-feed auto-creation — click on empty canvas while source is folk-rapp + if (!target && connectSource && connectSource.tagName === "FOLK-RAPP") { + const feedId = `feed-${Date.now()}-${++shapeCounter}`; + const rect = connectSource.getBoundingClientRect(); + const canvasRect = canvasContent.getBoundingClientRect(); + const feedX = e.clientX - canvasRect.left; + const feedY = e.clientY - canvasRect.top; + + const feed = document.createElement("folk-feed"); + feed.id = feedId; + feed.setAttribute("x", String(feedX)); + feed.setAttribute("y", String(feedY)); + feed.setAttribute("width", "300"); + feed.setAttribute("height", "400"); + if (connectSource.moduleId) { + feed.setAttribute("source-module", connectSource.moduleId); + } + + canvasContent.appendChild(feed); + sync.registerShape(feed); + + // Create the arrow connecting rapp → feed + createArrowBetween(connectSource, feed, null, null); + + // Reset connection mode + connectSource.classList.remove("connect-source"); + connectSource = null; + connectMode = false; + canvas.classList.remove("connect-mode"); + if (typeof syncBottomToolbar === "function") syncBottomToolbar(); + return; + } + if (!target || !target.id) return; e.stopPropagation(); @@ -4478,22 +4625,18 @@ connectSource = target; target.classList.add("connect-source"); } else if (target !== connectSource) { - // Second click - create arrow - const arrowId = `arrow-${Date.now()}-${++shapeCounter}`; - const colors = ["#374151", "#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6"]; + const src = connectSource; + const hasPorts = (src.getOutputPorts?.()?.length > 0) || (target.getInputPorts?.()?.length > 0); - const arrow = document.createElement("folk-arrow"); - arrow.id = arrowId; - arrow.sourceId = connectSource.id; - arrow.targetId = target.id; - arrow.color = colors[Math.floor(Math.random() * colors.length)]; - if (typeof arrowStyle !== "undefined" && arrowStyle !== "smooth") { - arrow.arrowStyle = arrowStyle; + if (hasPorts) { + // Show port picker + showPortPicker(src, target, (srcPort, tgtPort) => { + createArrowBetween(src, target, srcPort, tgtPort); + }); + } else { + createArrowBetween(src, target, null, null); } - canvasContent.appendChild(arrow); - sync.registerShape(arrow); - // Reset connection mode connectSource.classList.remove("connect-source"); connectSource = null; @@ -5246,6 +5389,157 @@ } }); + // ── Arrow context menu (right-click on pipe arrows) ── + // Supports: governance gate, data transform, delete arrow, remove flow + let arrowContextTarget = null; + + canvasContent.addEventListener("contextmenu", (e) => { + const arrowEl = e.target.closest("folk-arrow"); + if (!arrowEl || !arrowEl.isPipe) return; + + e.preventDefault(); + e.stopPropagation(); + arrowContextTarget = arrowEl; + + const menu = document.createElement("div"); + menu.id = "arrow-context-menu"; + menu.style.cssText = `position:fixed;left:${e.clientX}px;top:${e.clientY}px;z-index:9999;background:white;border-radius:8px;box-shadow:0 4px 16px rgba(0,0,0,0.15);padding:4px;min-width:180px;font-family:system-ui;font-size:12px;`; + + let items = ''; + if (arrowEl.gate) { + items += ``; + } else { + items += ``; + } + if (arrowEl.transform) { + items += ``; + items += ``; + } else { + items += ``; + } + items += `
`; + items += ``; + + menu.innerHTML = items; + document.body.appendChild(menu); + + // Hover effect + menu.querySelectorAll("button").forEach(b => { + b.addEventListener("mouseenter", () => b.style.background = "#f1f5f9"); + b.addEventListener("mouseleave", () => b.style.background = "none"); + }); + + const closeMenu = (ev) => { + if (ev.target.closest("#arrow-context-menu")) return; + menu.remove(); + document.removeEventListener("click", closeMenu); + }; + setTimeout(() => document.addEventListener("click", closeMenu), 0); + + menu.addEventListener("click", (ev) => { + const btn = ev.target.closest("button"); + if (!btn || !arrowContextTarget) return; + const action = btn.dataset.action; + + if (action === "add-gate") { + // Show gate shape picker: find all choice/vote shapes on canvas + const gateShapes = [...canvasContent.querySelectorAll("folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-choice-conviction")]; + if (gateShapes.length === 0) { + alert("No governance shapes on canvas. Add a vote/choice shape first."); + } else { + const names = gateShapes.map((s, i) => `${i + 1}. ${s.tagName.toLowerCase()} (${s.id})`).join("\n"); + const choice = prompt(`Select gate shape:\n${names}\nEnter number:`); + const idx = parseInt(choice) - 1; + if (idx >= 0 && idx < gateShapes.length) { + arrowContextTarget.gate = { + shapeId: gateShapes[idx].id, + portName: "decision-out", + condition: "passed", + }; + sync.registerShape(arrowContextTarget); + } + } + } else if (action === "remove-gate") { + arrowContextTarget.gate = null; + sync.registerShape(arrowContextTarget); + } else if (action === "add-transform" || action === "edit-transform") { + showTransformEditor(arrowContextTarget); + } else if (action === "remove-transform") { + arrowContextTarget.transform = null; + sync.registerShape(arrowContextTarget); + } else if (action === "delete-arrow") { + if (arrowContextTarget.flowId) { + onArrowRemoved(arrowContextTarget.flowId, sync); + } + sync.hardDeleteShape(arrowContextTarget.id); + arrowContextTarget.remove(); + } + + menu.remove(); + document.removeEventListener("click", closeMenu); + arrowContextTarget = null; + }); + }); + + // ── Transform Editor (double-click arrow or from context menu) ── + function showTransformEditor(arrowEl) { + const existing = arrowEl.transform || { type: "filter", expression: "" }; + + const overlay = document.createElement("div"); + overlay.style.cssText = "position:fixed;inset:0;z-index:9999;background:rgba(0,0,0,0.3);display:flex;align-items:center;justify-content:center;"; + + const panel = document.createElement("div"); + panel.style.cssText = "background:white;border-radius:12px;padding:16px;box-shadow:0 8px 32px rgba(0,0,0,0.2);min-width:320px;font-family:system-ui;"; + panel.innerHTML = ` +
Data Transform
+
+ + +
+
+ + +
+ Filter: field === 'value' | Map: field.path | Pick: field1,field2 +
+
+
+ + +
+ `; + + overlay.appendChild(panel); + overlay.addEventListener("click", (ev) => { if (ev.target === overlay) overlay.remove(); }); + panel.querySelector("#tf-cancel").addEventListener("click", () => overlay.remove()); + panel.querySelector("#tf-apply").addEventListener("click", () => { + const type = panel.querySelector("#tf-type").value; + const expression = panel.querySelector("#tf-expr").value.trim(); + arrowEl.transform = { type, expression }; + sync.registerShape(arrowEl); + overlay.remove(); + }); + // Prevent canvas events + panel.addEventListener("click", (ev) => ev.stopPropagation()); + document.body.appendChild(overlay); + } + + // Double-click on arrow opens transform editor + canvasContent.addEventListener("dblclick", (e) => { + const arrowEl = e.target.closest("folk-arrow"); + if (arrowEl && arrowEl.isPipe) { + e.stopPropagation(); + showTransformEditor(arrowEl); + } + }); + // ── Settings + History panel toggles ── document.getElementById('settings-btn')?.addEventListener('click', () => { const panel = document.querySelector('rstack-space-settings');