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:
parent
a1f8103237
commit
28922da39f
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<typeof setTimeout> | 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<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() {
|
||||
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 = `
|
||||
<path d="${pathD}" fill="none" stroke="${this.#color}" stroke-width="${sw}"
|
||||
const dashAttr = gatedClosed
|
||||
? '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"
|
||||
${this.#arrowStyle === "sketchy" ? 'stroke-dasharray="0"' : ""} />
|
||||
<polygon points="${arrowheadPoints}" fill="${this.#color}" />
|
||||
${dashAttr}${opacityAttr} />
|
||||
<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.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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
110
lib/folk-rapp.ts
110
lib/folk-rapp.ts
|
|
@ -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
|
||||
const WIDGET_API: Record<string, { path: string; transform: (data: any) => WidgetData }> = {
|
||||
rinbox: {
|
||||
|
|
@ -564,11 +607,14 @@ export class FolkRApp extends FolkShape {
|
|||
#statusEl: HTMLElement | null = null;
|
||||
#refreshTimer: ReturnType<typeof setInterval> | 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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 = `
|
||||
<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
|
||||
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 += `<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, '"')}" 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 ──
|
||||
document.getElementById('settings-btn')?.addEventListener('click', () => {
|
||||
const panel = document.querySelector('rstack-space-settings');
|
||||
|
|
|
|||
Loading…
Reference in New Issue