feat: typed ports, flow bridging, governance gates & data transforms

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-12 23:34:45 -07:00
parent a1f8103237
commit 28922da39f
9 changed files with 793 additions and 38 deletions

View File

@ -5,6 +5,8 @@
* flowing data from source outputs to target inputs with type checking. * flowing data from source outputs to target inputs with type checking.
*/ */
import { FLOW_COLORS, type FlowKind } from "./layer-types";
/** Supported data types for ports. */ /** Supported data types for ports. */
export type DataType = export type DataType =
| "string" | "string"
@ -37,6 +39,22 @@ export function isCompatible(source: DataType, target: DataType): boolean {
return false; 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. */ /** Color tint per data type for arrow visualization. */
export function dataTypeColor(type: DataType): string { export function dataTypeColor(type: DataType): string {
switch (type) { switch (type) {

66
lib/flow-bridge.ts Normal file
View File

@ -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);
}
}

View File

@ -2,7 +2,7 @@ import { getBoxToBoxArrow } from "perfect-arrows";
import { getStroke, type StrokeOptions } from "perfect-freehand"; import { getStroke, type StrokeOptions } from "perfect-freehand";
import { FolkElement } from "./folk-element"; import { FolkElement } from "./folk-element";
import { css } from "./tags"; import { css } from "./tags";
import { isCompatible, dataTypeColor } from "./data-types"; import { isCompatible, dataTypeColor, inferFlowKind, flowKindColor } from "./data-types";
// Point interface for bezier curves // Point interface for bezier curves
interface Point { interface Point {
@ -134,6 +134,20 @@ declare global {
export type ArrowStyle = "smooth" | "straight" | "curved" | "sketchy"; 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 { export class FolkArrow extends FolkElement {
static override tagName = "folk-arrow"; static override tagName = "folk-arrow";
static styles = styles; static styles = styles;
@ -155,6 +169,34 @@ export class FolkArrow extends FolkElement {
#pipeDebounce: ReturnType<typeof setTimeout> | null = null; #pipeDebounce: ReturnType<typeof setTimeout> | null = null;
#portListener: ((e: Event) => void) | 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; } get sourcePort() { return this.#sourcePort; }
set sourcePort(value: string) { set sourcePort(value: string) {
this.#sourcePort = value; this.#sourcePort = value;
@ -287,6 +329,7 @@ export class FolkArrow extends FolkElement {
this.#resizeObserver.disconnect(); this.#resizeObserver.disconnect();
this.#stopPositionTracking(); this.#stopPositionTracking();
this.#teardownPipe(); this.#teardownPipe();
this.#teardownGate();
} }
#animationFrameId: number | null = null; #animationFrameId: number | null = null;
@ -329,9 +372,12 @@ export class FolkArrow extends FolkElement {
return; return;
} }
// Tint arrow color by data type // Tint arrow color: use FlowKind if both endpoints are folk-rapp, else DataType
if (srcPort) { 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(); this.#updateArrow();
} }
@ -343,8 +389,16 @@ export class FolkArrow extends FolkElement {
// Debounce to avoid rapid-fire updates // Debounce to avoid rapid-fire updates
if (this.#pipeDebounce) clearTimeout(this.#pipeDebounce); if (this.#pipeDebounce) clearTimeout(this.#pipeDebounce);
this.#pipeDebounce = setTimeout(() => { 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) { if (tgtEl.setPortValue) {
tgtEl.setPortValue(this.#targetPort, detail.value); tgtEl.setPortValue(this.#targetPort, value);
} }
}, 100); }, 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<string, any> = {};
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() { #observeSource() {
if (this.#sourceElement) { if (this.#sourceElement) {
this.#resizeObserver.unobserve(this.#sourceElement); this.#resizeObserver.unobserve(this.#sourceElement);
@ -424,6 +601,10 @@ export class FolkArrow extends FolkElement {
this.#targetRect.height, 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") { if (this.#arrowStyle === "smooth") {
// Original behavior: perfect-freehand tapered stroke via clipPath // Original behavior: perfect-freehand tapered stroke via clipPath
if (this.#svg) this.#svg.style.display = "none"; if (this.#svg) this.#svg.style.display = "none";
@ -441,7 +622,8 @@ export class FolkArrow extends FolkElement {
const path = getSvgPathFromStroke(stroke); const path = getSvgPathFromStroke(stroke);
this.style.clipPath = `path('${path}')`; this.style.clipPath = `path('${path}')`;
this.style.backgroundColor = this.#color; this.style.backgroundColor = effectiveColor;
this.style.opacity = gatedClosed ? "0.4" : "";
} else { } else {
// SVG-based rendering for other styles // SVG-based rendering for other styles
this.style.clipPath = ""; this.style.clipPath = "";
@ -497,12 +679,30 @@ export class FolkArrow extends FolkElement {
arrowheadPoints = `${p1x},${p1y} ${ex},${ey} ${p2x},${p2y}`; arrowheadPoints = `${p1x},${p1y} ${ex},${ey} ${p2x},${p2y}`;
} }
this.#svg.innerHTML = ` const dashAttr = gatedClosed
<path d="${pathD}" fill="none" stroke="${this.#color}" stroke-width="${sw}" ? 'stroke-dasharray="6 4"'
: (this.#arrowStyle === "sketchy" ? 'stroke-dasharray="0"' : "");
const opacityAttr = gatedClosed ? ' opacity="0.4"' : "";
let svgContent = `
<path d="${pathD}" fill="none" stroke="${effectiveColor}" stroke-width="${sw}"
stroke-linecap="round" stroke-linejoin="round" stroke-linecap="round" stroke-linejoin="round"
${this.#arrowStyle === "sketchy" ? 'stroke-dasharray="0"' : ""} /> ${dashAttr}${opacityAttr} />
<polygon points="${arrowheadPoints}" fill="${this.#color}" /> <polygon points="${arrowheadPoints}" fill="${effectiveColor}"${opacityAttr} />
`; `;
// Transform badge at midpoint
if (this.#transform) {
const badges: Record<string, string> = { filter: "F", map: "M", pick: "P", count: "#", first: "1", last: "L" };
const badge = badges[this.#transform.type] || "T";
svgContent += `
<circle cx="${cx}" cy="${cy}" r="10" fill="white" stroke="${effectiveColor}" stroke-width="1.5" />
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
fill="${effectiveColor}" font-size="10" font-weight="700">${badge}</text>
`;
}
this.#svg.innerHTML = svgContent;
} }
} }
@ -525,6 +725,9 @@ export class FolkArrow extends FolkElement {
if (data.arrowStyle) arrow.arrowStyle = data.arrowStyle; if (data.arrowStyle) arrow.arrowStyle = data.arrowStyle;
if (data.sourcePort) arrow.sourcePort = data.sourcePort; if (data.sourcePort) arrow.sourcePort = data.sourcePort;
if (data.targetPort) arrow.targetPort = data.targetPort; 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; return arrow;
} }
@ -540,6 +743,9 @@ export class FolkArrow extends FolkElement {
}; };
if (this.#sourcePort) json.sourcePort = this.#sourcePort; if (this.#sourcePort) json.sourcePort = this.#sourcePort;
if (this.#targetPort) json.targetPort = this.#targetPort; 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; 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.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.sourcePort !== undefined && this.#sourcePort !== data.sourcePort) this.sourcePort = data.sourcePort;
if (data.targetPort !== undefined && this.#targetPort !== data.targetPort) this.targetPort = data.targetPort; 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;
} }
} }

View File

@ -1,5 +1,6 @@
import { FolkShape } from "./folk-shape"; import { FolkShape } from "./folk-shape";
import { css, html } from "./tags"; import { css, html } from "./tags";
import type { PortDescriptor } from "./data-types";
const USER_ID_KEY = "folk-choice-userid"; const USER_ID_KEY = "folk-choice-userid";
const USER_NAME_KEY = "folk-choice-username"; const USER_NAME_KEY = "folk-choice-username";
@ -543,6 +544,12 @@ const MEDAL_COLORS = ["#f59e0b", "#94a3b8", "#cd7f32", "#64748b", "#64748b"];
export class FolkChoiceRank extends FolkShape { export class FolkChoiceRank extends FolkShape {
static override tagName = "folk-choice-rank"; 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 { static {
const sheet = new CSSStyleSheet(); const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n");
@ -639,6 +646,7 @@ export class FolkChoiceRank extends FolkShape {
override createRenderRoot() { override createRenderRoot() {
const root = super.createRenderRoot(); const root = super.createRenderRoot();
this.initPorts();
this.#ensureIdentity(); this.#ensureIdentity();
this.#syncMyOrdering(); this.#syncMyOrdering();
@ -774,6 +782,25 @@ export class FolkChoiceRank extends FolkShape {
if (this.#activeTab === "rank") this.#renderRankList(); if (this.#activeTab === "rank") this.#renderRankList();
else this.#renderResults(); else this.#renderResults();
if (this.#drawerOpen) this.#renderDrawer(); 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() { #renderRankList() {

View File

@ -1,5 +1,6 @@
import { FolkShape } from "./folk-shape"; import { FolkShape } from "./folk-shape";
import { css, html } from "./tags"; import { css, html } from "./tags";
import type { PortDescriptor } from "./data-types";
const USER_ID_KEY = "folk-choice-userid"; const USER_ID_KEY = "folk-choice-userid";
const USER_NAME_KEY = "folk-choice-username"; const USER_NAME_KEY = "folk-choice-username";
@ -486,6 +487,12 @@ function userColor(userId: string): string {
export class FolkChoiceSpider extends FolkShape { export class FolkChoiceSpider extends FolkShape {
static override tagName = "folk-choice-spider"; 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 { static {
const sheet = new CSSStyleSheet(); const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n");
@ -582,6 +589,7 @@ export class FolkChoiceSpider extends FolkShape {
override createRenderRoot() { override createRenderRoot() {
const root = super.createRenderRoot(); const root = super.createRenderRoot();
this.initPorts();
this.#ensureIdentity(); this.#ensureIdentity();
if (this.#options.length > 0) this.#selectedOptionId = this.#options[0].id; if (this.#options.length > 0) this.#selectedOptionId = this.#options[0].id;
@ -729,6 +737,29 @@ export class FolkChoiceSpider extends FolkShape {
this.#renderLegend(); this.#renderLegend();
this.#renderSummary(); this.#renderSummary();
if (this.#drawerOpen) this.#renderDrawer(); 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() { #renderOptionTabs() {

View File

@ -1,5 +1,6 @@
import { FolkShape } from "./folk-shape"; import { FolkShape } from "./folk-shape";
import { css, html } from "./tags"; import { css, html } from "./tags";
import type { PortDescriptor } from "./data-types";
const USER_ID_KEY = "folk-choice-userid"; const USER_ID_KEY = "folk-choice-userid";
const USER_NAME_KEY = "folk-choice-username"; const USER_NAME_KEY = "folk-choice-username";
@ -403,6 +404,12 @@ const DEFAULT_COLORS = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6", "
export class FolkChoiceVote extends FolkShape { export class FolkChoiceVote extends FolkShape {
static override tagName = "folk-choice-vote"; 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 { static {
const sheet = new CSSStyleSheet(); const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n");
@ -487,6 +494,7 @@ export class FolkChoiceVote extends FolkShape {
override createRenderRoot() { override createRenderRoot() {
const root = super.createRenderRoot(); const root = super.createRenderRoot();
this.initPorts();
this.#ensureIdentity(); this.#ensureIdentity();
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
@ -719,6 +727,25 @@ export class FolkChoiceVote extends FolkShape {
: `${uniqueVoters} voter${uniqueVoters !== 1 ? "s" : ""}`; : `${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 // Wire click events
if (this.#mode !== "quadratic") { if (this.#mode !== "quadratic") {
this.#optionsEl.querySelectorAll(".option-row").forEach((row) => { this.#optionsEl.querySelectorAll(".option-row").forEach((row) => {

View File

@ -416,6 +416,49 @@ declare global {
} }
} }
// Module-specific port descriptors: meaningful typed ports per rApp module
const MODULE_PORTS: Record<string, PortDescriptor[]> = {
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 // API endpoint config per module for widget mode
const WIDGET_API: Record<string, { path: string; transform: (data: any) => WidgetData }> = { const WIDGET_API: Record<string, { path: string; transform: (data: any) => WidgetData }> = {
rinbox: { rinbox: {
@ -564,11 +607,14 @@ export class FolkRApp extends FolkShape {
#statusEl: HTMLElement | null = null; #statusEl: HTMLElement | null = null;
#refreshTimer: ReturnType<typeof setInterval> | null = null; #refreshTimer: ReturnType<typeof setInterval> | null = null;
#modeToggleBtn: HTMLButtonElement | null = null; #modeToggleBtn: HTMLButtonElement | null = null;
#resolvedPorts: PortDescriptor[] | null = null;
get moduleId() { return this.#moduleId; } get moduleId() { return this.#moduleId; }
set moduleId(value: string) { set moduleId(value: string) {
if (this.#moduleId === value) return; if (this.#moduleId === value) return;
this.#moduleId = value; this.#moduleId = value;
this.#resolvedPorts = null;
this.initPorts();
this.requestUpdate("moduleId"); this.requestUpdate("moduleId");
this.dispatchEvent(new CustomEvent("content-change")); this.dispatchEvent(new CustomEvent("content-change"));
this.#renderContent(); this.#renderContent();
@ -592,6 +638,34 @@ export class FolkRApp extends FolkShape {
this.#renderContent(); 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() { override createRenderRoot() {
const root = super.createRenderRoot(); const root = super.createRenderRoot();
@ -692,25 +766,24 @@ export class FolkRApp extends FolkShape {
// Forward input port data to iframe (AC#3) // Forward input port data to iframe (AC#3)
this.addEventListener("port-value-changed", ((e: CustomEvent) => { this.addEventListener("port-value-changed", ((e: CustomEvent) => {
const { name, value } = e.detail; const { name, value } = e.detail;
if (name === "data-in" && this.#iframe?.contentWindow) { const port = this.getPort(name);
const targetOrigin = this.#sandboxed && this.#ecosystemOrigin if (!port || port.direction !== "input" || !this.#iframe?.contentWindow) return;
? this.#ecosystemOrigin : window.location.origin;
try { 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({ this.#iframe.contentWindow.postMessage({
source: "rspace-parent", type: "port-data", source: "rspace-parent", type: "port-data",
portName: name, value, portName: name, value,
}, targetOrigin); }, targetOrigin);
} catch { /* iframe not ready */ } }
} } 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 */ }
}
}) as EventListener); }) as EventListener);
// Load content // Load content
@ -1003,6 +1076,13 @@ export class FolkRApp extends FolkShape {
const data = await res.json(); const data = await res.json();
const widgetData = apiConfig.transform(data); const widgetData = apiConfig.transform(data);
this.#renderWidgetCard(widgetData); 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 { } catch {
this.#renderWidgetFallback(); this.#renderWidgetFallback();
} }

View File

@ -99,6 +99,9 @@ export * from "./folk-feed";
export * from "./data-types"; export * from "./data-types";
export * from "./shape-registry"; export * from "./shape-registry";
// Flow Bridge (arrow ↔ LayerFlow)
export * from "./flow-bridge";
// Shape Groups // Shape Groups
export * from "./group-manager"; export * from "./group-manager";
export * from "./folk-group-frame"; export * from "./folk-group-frame";

View File

@ -2543,7 +2543,9 @@
MiTriagePanel, MiTriagePanel,
shapeRegistry, shapeRegistry,
GroupManager, GroupManager,
FolkGroupFrame FolkGroupFrame,
onArrowPipeCreated,
onArrowRemoved,
} from "@lib"; } from "@lib";
import { RStackIdentity } from "@shared/components/rstack-identity"; import { RStackIdentity } from "@shared/components/rstack-identity";
import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher"; import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher";
@ -4464,11 +4466,156 @@
if (typeof syncBottomToolbar === "function") syncBottomToolbar(); 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 = `
<div style="font-size:14px;font-weight:600;margin-bottom:12px;color:#1e293b;">Connect Ports</div>
<div style="display:flex;gap:16px;">
<div style="flex:1;">
<div style="font-size:11px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;">${srcName} (output)</div>
${srcPorts.map(p => `
<label style="display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;cursor:pointer;font-size:12px;margin-bottom:2px;${p.name === selectedSrc ? 'background:#eff6ff;' : ''}">
<input type="radio" name="src-port" value="${p.name}" ${p.name === selectedSrc ? 'checked' : ''} style="accent-color:#3b82f6;" />
<span style="font-weight:500;">${p.name}</span>
<span style="color:#94a3b8;font-size:10px;">${p.type}</span>
</label>
`).join("")}
${srcPorts.length === 0 ? '<div style="font-size:11px;color:#94a3b8;">No output ports</div>' : ''}
</div>
<div style="flex:1;">
<div style="font-size:11px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;">${tgtName} (input)</div>
${tgtPorts.map(p => `
<label style="display:flex;align-items:center;gap:6px;padding:4px 8px;border-radius:6px;cursor:pointer;font-size:12px;margin-bottom:2px;${p.name === selectedTgt ? 'background:#eff6ff;' : ''}">
<input type="radio" name="tgt-port" value="${p.name}" ${p.name === selectedTgt ? 'checked' : ''} style="accent-color:#3b82f6;" />
<span style="font-weight:500;">${p.name}</span>
<span style="color:#94a3b8;font-size:10px;">${p.type}</span>
</label>
`).join("")}
${tgtPorts.length === 0 ? '<div style="font-size:11px;color:#94a3b8;">No input ports</div>' : ''}
</div>
</div>
<div style="display:flex;gap:8px;margin-top:14px;justify-content:flex-end;">
<button class="pp-skip" style="padding:6px 14px;border:1px solid #e2e8f0;border-radius:6px;background:white;cursor:pointer;font-size:12px;color:#64748b;">Skip (no pipe)</button>
<button class="pp-connect" style="padding:6px 14px;border:none;border-radius:6px;background:#3b82f6;color:white;cursor:pointer;font-size:12px;font-weight:500;" ${!selectedSrc || !selectedTgt ? 'disabled style="opacity:0.5;"' : ''}>Connect</button>
</div>
`;
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 // Handle shape clicks for connection mode
canvas.addEventListener("click", (e) => { canvas.addEventListener("click", (e) => {
if (!connectMode) return; if (!connectMode) return;
const target = e.target.closest(CONNECTABLE_SELECTOR); 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; if (!target || !target.id) return;
e.stopPropagation(); e.stopPropagation();
@ -4478,22 +4625,18 @@
connectSource = target; connectSource = target;
target.classList.add("connect-source"); target.classList.add("connect-source");
} else if (target !== connectSource) { } else if (target !== connectSource) {
// Second click - create arrow const src = connectSource;
const arrowId = `arrow-${Date.now()}-${++shapeCounter}`; const hasPorts = (src.getOutputPorts?.()?.length > 0) || (target.getInputPorts?.()?.length > 0);
const colors = ["#374151", "#ef4444", "#f59e0b", "#22c55e", "#3b82f6", "#8b5cf6"];
const arrow = document.createElement("folk-arrow"); if (hasPorts) {
arrow.id = arrowId; // Show port picker
arrow.sourceId = connectSource.id; showPortPicker(src, target, (srcPort, tgtPort) => {
arrow.targetId = target.id; createArrowBetween(src, target, srcPort, tgtPort);
arrow.color = colors[Math.floor(Math.random() * colors.length)]; });
if (typeof arrowStyle !== "undefined" && arrowStyle !== "smooth") { } else {
arrow.arrowStyle = arrowStyle; createArrowBetween(src, target, null, null);
} }
canvasContent.appendChild(arrow);
sync.registerShape(arrow);
// Reset connection mode // Reset connection mode
connectSource.classList.remove("connect-source"); connectSource.classList.remove("connect-source");
connectSource = null; 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 += `<button data-action="remove-gate" style="display:block;width:100%;text-align:left;padding:6px 10px;border:none;background:none;cursor:pointer;border-radius:4px;font-size:12px;">Remove gate</button>`;
} else {
items += `<button data-action="add-gate" style="display:block;width:100%;text-align:left;padding:6px 10px;border:none;background:none;cursor:pointer;border-radius:4px;font-size:12px;">Add governance gate...</button>`;
}
if (arrowEl.transform) {
items += `<button data-action="edit-transform" style="display:block;width:100%;text-align:left;padding:6px 10px;border:none;background:none;cursor:pointer;border-radius:4px;font-size:12px;">Edit transform</button>`;
items += `<button data-action="remove-transform" style="display:block;width:100%;text-align:left;padding:6px 10px;border:none;background:none;cursor:pointer;border-radius:4px;font-size:12px;">Remove transform</button>`;
} else {
items += `<button data-action="add-transform" style="display:block;width:100%;text-align:left;padding:6px 10px;border:none;background:none;cursor:pointer;border-radius:4px;font-size:12px;">Add transform...</button>`;
}
items += `<hr style="border:none;border-top:1px solid #e2e8f0;margin:2px 0;" />`;
items += `<button data-action="delete-arrow" style="display:block;width:100%;text-align:left;padding:6px 10px;border:none;background:none;cursor:pointer;border-radius:4px;font-size:12px;color:#ef4444;">Delete arrow</button>`;
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 = `
<div style="font-size:14px;font-weight:600;margin-bottom:12px;color:#1e293b;">Data Transform</div>
<div style="margin-bottom:8px;">
<label style="font-size:11px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;">Type</label>
<select id="tf-type" style="display:block;width:100%;margin-top:4px;padding:6px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:12px;outline:none;">
<option value="filter" ${existing.type === 'filter' ? 'selected' : ''}>Filter — keep items matching condition</option>
<option value="map" ${existing.type === 'map' ? 'selected' : ''}>Map — extract a field from each item</option>
<option value="pick" ${existing.type === 'pick' ? 'selected' : ''}>Pick — keep specific fields</option>
<option value="count" ${existing.type === 'count' ? 'selected' : ''}>Count — return item count</option>
<option value="first" ${existing.type === 'first' ? 'selected' : ''}>First — first item only</option>
<option value="last" ${existing.type === 'last' ? 'selected' : ''}>Last — last item only</option>
</select>
</div>
<div style="margin-bottom:12px;">
<label style="font-size:11px;font-weight:600;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;">Expression</label>
<input id="tf-expr" type="text" value="${existing.expression.replace(/"/g, '&quot;')}" placeholder="e.g. status === 'confirmed'" style="display:block;width:100%;box-sizing:border-box;margin-top:4px;padding:6px 10px;border:1px solid #e2e8f0;border-radius:6px;font-size:12px;outline:none;font-family:monospace;" />
<div style="font-size:10px;color:#94a3b8;margin-top:4px;">
Filter: field === 'value' | Map: field.path | Pick: field1,field2
</div>
</div>
<div style="display:flex;gap:8px;justify-content:flex-end;">
<button id="tf-cancel" style="padding:6px 14px;border:1px solid #e2e8f0;border-radius:6px;background:white;cursor:pointer;font-size:12px;color:#64748b;">Cancel</button>
<button id="tf-apply" style="padding:6px 14px;border:none;border-radius:6px;background:#3b82f6;color:white;cursor:pointer;font-size:12px;font-weight:500;">Apply</button>
</div>
`;
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 ── // ── Settings + History panel toggles ──
document.getElementById('settings-btn')?.addEventListener('click', () => { document.getElementById('settings-btn')?.addEventListener('click', () => {
const panel = document.querySelector('rstack-space-settings'); const panel = document.querySelector('rstack-space-settings');