feat(rtime): commitment pooling + weaving split-pane redesign

Resizable divider (20-65% drag, localStorage persist), pool UX upgrade
(labels, woven % badge, drag-to-weave button), multi-strand woven
connection rendering, project frames with drag/resize/auto-assign,
task dependency arrows with diamond dep-ports, mobile responsive layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-11 12:46:39 -04:00
parent 4f8cddaaf7
commit 8535ab24a2
3 changed files with 1091 additions and 100 deletions

View File

@ -36,7 +36,26 @@ const SANKEY_AUTHORITY_DISPLAY: Record<string, { label: string; color: string }>
"fin-ops": { label: "Econ", color: "#10b981" },
"dev-ops": { label: "Tech", color: "#3b82f6" },
};
const FLOW_COLOR = "#a78bfa";
// Demo DAO members
const DEMO_MEMBERS = [
{ did: "demo:alice", name: "Alice" },
{ did: "demo:bob", name: "Bob" },
{ did: "demo:carol", name: "Carol" },
{ did: "demo:dave", name: "Dave" },
{ did: "demo:eve", name: "Eve" },
{ did: "demo:frank", name: "Frank" },
{ did: "demo:grace", name: "Grace" },
{ did: "demo:heidi", name: "Heidi" },
{ did: "demo:ivan", name: "Ivan" },
{ did: "demo:judy", name: "Judy" },
];
// Per-delegator color palette for flow bands
const DELEGATOR_PALETTE = [
"#7c3aed", "#6366f1", "#8b5cf6", "#a855f7",
"#c084fc", "#818cf8", "#6d28d9", "#4f46e5",
"#7e22ce", "#5b21b6", "#4338ca", "#9333ea",
];
class FolkTrustSankey extends HTMLElement {
private shadow: ShadowRoot;
@ -48,6 +67,10 @@ class FolkTrustSankey extends HTMLElement {
private error = "";
private timeSliderValue = 100; // 0-100, percentage of history
private animationEnabled = true;
private hoveredFlowId: string | null = null;
private hoveredNodeDid: string | null = null;
private _demoTimer: ReturnType<typeof setInterval> | null = null;
private _demoIdCounter = 0;
constructor() {
super();
@ -60,11 +83,15 @@ class FolkTrustSankey extends HTMLElement {
this.space = this.getAttribute("space") || "demo";
this.authority = this.getAttribute("authority") || "gov-ops";
this.render();
this.loadData();
// Listen for cross-component sync
this._delegationsHandler = () => this.loadData();
document.addEventListener("delegations-updated", this._delegationsHandler);
if (this.space === "demo") {
this.initDemoSimulation();
} else {
this.loadData();
// Listen for cross-component sync
this._delegationsHandler = () => this.loadData();
document.addEventListener("delegations-updated", this._delegationsHandler);
}
}
disconnectedCallback() {
@ -72,6 +99,10 @@ class FolkTrustSankey extends HTMLElement {
document.removeEventListener("delegations-updated", this._delegationsHandler);
this._delegationsHandler = null;
}
if (this._demoTimer) {
clearInterval(this._demoTimer);
this._demoTimer = null;
}
}
private getAuthBase(): string {
@ -148,6 +179,151 @@ class FolkTrustSankey extends HTMLElement {
this.render();
}
// ── Demo simulation ──────────────────────────────────────────
private demoNextId(): string { return `demo-${++this._demoIdCounter}`; }
private demoRand<T>(arr: T[]): T { return arr[Math.floor(Math.random() * arr.length)]; }
/** Total outbound weight for a given did+authority (active only) */
private demoOutboundWeight(did: string, authority: string): number {
return this.flows
.filter(f => f.fromDid === did && f.authority === authority && f.state === "active")
.reduce((s, f) => s + f.weight, 0);
}
/** Generate initial demo delegations and start the mutation loop */
private initDemoSimulation() {
const now = Date.now();
this.flows = [];
this.events = [];
// Seed ~12 initial delegations spread across all 3 verticals
const authorities: string[] = ["gov-ops", "fin-ops", "dev-ops"];
for (const auth of authorities) {
// Each authority gets 3-5 initial delegations
const count = 3 + Math.floor(Math.random() * 3);
for (let i = 0; i < count; i++) {
const from = this.demoRand(DEMO_MEMBERS);
let to = this.demoRand(DEMO_MEMBERS);
// No self-delegation
while (to.did === from.did) to = this.demoRand(DEMO_MEMBERS);
// Skip if this exact pair already exists for this authority
if (this.flows.some(f => f.fromDid === from.did && f.toDid === to.did && f.authority === auth)) continue;
// Weight between 0.05 and 0.40 — capped so total outbound stays <= 1.0
const maxAvailable = 1.0 - this.demoOutboundWeight(from.did, auth);
if (maxAvailable < 0.05) continue;
const weight = Math.min(0.05 + Math.random() * 0.35, maxAvailable);
const createdAt = now - Math.floor(Math.random() * 7 * 24 * 60 * 60 * 1000); // within last week
this.flows.push({
id: this.demoNextId(), fromDid: from.did, fromName: from.name,
toDid: to.did, toName: to.name, authority: auth,
weight: Math.round(weight * 100) / 100,
state: "active", createdAt, revokedAt: null,
});
this.events.push({
id: this.demoNextId(), sourceDid: from.did, targetDid: to.did,
eventType: "delegate", authority: auth, weightDelta: weight, createdAt,
});
}
}
this.loading = false;
this.render();
// Mutate every 2.54s
this._demoTimer = setInterval(() => this.demoTick(), 2500 + Math.random() * 1500);
}
/** Single mutation step — add, adjust, revoke, or reactivate a delegation */
private demoTick() {
const now = Date.now();
const active = this.flows.filter(f => f.state === "active");
const revoked = this.flows.filter(f => f.state === "revoked");
const roll = Math.random();
if (roll < 0.35 || active.length < 4) {
// — Add a new delegation —
const auth = this.demoRand(["gov-ops", "fin-ops", "dev-ops"] as string[]);
const from = this.demoRand(DEMO_MEMBERS);
let to = this.demoRand(DEMO_MEMBERS);
let attempts = 0;
while ((to.did === from.did || this.flows.some(f => f.fromDid === from.did && f.toDid === to.did && f.authority === auth && f.state === "active")) && attempts < 20) {
to = this.demoRand(DEMO_MEMBERS);
attempts++;
}
if (to.did === from.did) return; // couldn't find a valid pair
const maxAvailable = 1.0 - this.demoOutboundWeight(from.did, auth);
if (maxAvailable < 0.05) return;
const weight = Math.min(0.05 + Math.random() * 0.30, maxAvailable);
const rounded = Math.round(weight * 100) / 100;
this.flows.push({
id: this.demoNextId(), fromDid: from.did, fromName: from.name,
toDid: to.did, toName: to.name, authority: auth,
weight: rounded, state: "active", createdAt: now, revokedAt: null,
});
this.events.push({
id: this.demoNextId(), sourceDid: from.did, targetDid: to.did,
eventType: "delegate", authority: auth, weightDelta: rounded, createdAt: now,
});
} else if (roll < 0.65 && active.length > 0) {
// — Adjust weight of an existing delegation —
const flow = this.demoRand(active);
const maxAvailable = 1.0 - this.demoOutboundWeight(flow.fromDid, flow.authority) + flow.weight;
const delta = (Math.random() - 0.4) * 0.15; // slight bias toward increase
const newWeight = Math.max(0.03, Math.min(maxAvailable, flow.weight + delta));
const rounded = Math.round(newWeight * 100) / 100;
const actualDelta = rounded - flow.weight;
flow.weight = rounded;
this.events.push({
id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid,
eventType: "adjust", authority: flow.authority, weightDelta: actualDelta, createdAt: now,
});
} else if (roll < 0.85 && active.length > 4) {
// — Revoke a delegation —
const flow = this.demoRand(active);
flow.state = "revoked";
flow.revokedAt = now;
this.events.push({
id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid,
eventType: "revoke", authority: flow.authority, weightDelta: -flow.weight, createdAt: now,
});
} else if (revoked.length > 0) {
// — Reactivate a revoked delegation —
const flow = this.demoRand(revoked);
const maxAvailable = 1.0 - this.demoOutboundWeight(flow.fromDid, flow.authority);
if (maxAvailable < flow.weight) return; // can't fit
flow.state = "active";
flow.revokedAt = null;
flow.createdAt = now; // reset so it shows as recent
this.events.push({
id: this.demoNextId(), sourceDid: flow.fromDid, targetDid: flow.toDid,
eventType: "delegate", authority: flow.authority, weightDelta: flow.weight, createdAt: now,
});
}
// Cap events list to prevent unbounded growth
if (this.events.length > 300) this.events = this.events.slice(-200);
// Clear hover state to avoid stale references, then re-render
this.hoveredFlowId = null;
this.hoveredNodeDid = null;
this.render();
}
// ── Data filtering ──────────────────────────────────────────
private getFilteredFlows(): DelegationFlow[] {
let filtered = this.flows.filter(f => f.authority === this.authority);
@ -176,97 +352,238 @@ class FolkTrustSankey extends HTMLElement {
return `<div class="empty">No delegation flows for ${this.authority}${this.timeSliderValue < 100 ? " at this time" : ""}.</div>`;
}
const W = 600, H = Math.max(300, flows.length * 40 + 60);
const leftX = 120, rightX = W - 120;
const nodeW = 16;
// --- True Sankey layout with stacked flow ports ---
const MIN_BAND = 6; // minimum band thickness in px
const WEIGHT_SCALE = 80; // scale factor: weight 1.0 = 80px band
const NODE_W = 14;
const NODE_GAP = 12; // vertical gap between nodes
const LABEL_W = 110; // space for text labels on each side
const W = 620;
const leftX = LABEL_W;
const rightX = W - LABEL_W;
// Collect unique delegators and delegates
const delegators = [...new Set(flows.map(f => f.fromDid))];
const delegates = [...new Set(flows.map(f => f.toDid))];
// Position nodes vertically
const leftH = H - 40;
const rightH = H - 40;
const leftPositions = new Map<string, number>();
const rightPositions = new Map<string, number>();
// Assign colors per delegator
const delegatorColor = new Map<string, string>();
delegators.forEach((did, i) => {
leftPositions.set(did, 20 + (leftH * (i + 0.5)) / delegators.length);
});
delegates.forEach((did, i) => {
rightPositions.set(did, 20 + (rightH * (i + 0.5)) / delegates.length);
delegatorColor.set(did, DELEGATOR_PALETTE[i % DELEGATOR_PALETTE.length]);
});
// Build SVG
const flowPaths: string[] = [];
// Compute node heights based on total weight
const leftTotals = new Map<string, number>();
const rightTotals = new Map<string, number>();
for (const did of delegators) {
leftTotals.set(did, flows.filter(f => f.fromDid === did).reduce((s, f) => s + f.weight, 0));
}
for (const did of delegates) {
rightTotals.set(did, flows.filter(f => f.toDid === did).reduce((s, f) => s + f.weight, 0));
}
const nodeHeight = (total: number) => Math.max(MIN_BAND, total * WEIGHT_SCALE);
const bandThickness = (weight: number) => Math.max(MIN_BAND * 0.5, weight * WEIGHT_SCALE);
// Compute total height needed for each side
const leftTotalH = delegators.reduce((s, did) => s + nodeHeight(leftTotals.get(did)!), 0) + (delegators.length - 1) * NODE_GAP;
const rightTotalH = delegates.reduce((s, did) => s + nodeHeight(rightTotals.get(did)!), 0) + (delegates.length - 1) * NODE_GAP;
const H = Math.max(240, Math.max(leftTotalH, rightTotalH) + 60);
// Position nodes: center the column vertically, stack nodes top-to-bottom
const leftNodeY = new Map<string, number>(); // top-edge Y of each left node
const rightNodeY = new Map<string, number>();
let curY = (H - leftTotalH) / 2;
for (const did of delegators) {
leftNodeY.set(did, curY);
curY += nodeHeight(leftTotals.get(did)!) + NODE_GAP;
}
curY = (H - rightTotalH) / 2;
for (const did of delegates) {
rightNodeY.set(did, curY);
curY += nodeHeight(rightTotals.get(did)!) + NODE_GAP;
}
// --- Stacked port allocation ---
// Track how much of each node's height has been consumed by flow connections
const leftPortOffset = new Map<string, number>(); // cumulative offset from node top
const rightPortOffset = new Map<string, number>();
for (const did of delegators) leftPortOffset.set(did, 0);
for (const did of delegates) rightPortOffset.set(did, 0);
// Sort flows by delegator order then delegate order for consistent stacking
const sortedFlows = [...flows].sort((a, b) => {
const ai = delegators.indexOf(a.fromDid);
const bi = delegators.indexOf(b.fromDid);
if (ai !== bi) return ai - bi;
return delegates.indexOf(a.toDid) - delegates.indexOf(b.toDid);
});
// Build flow band paths (filled bezier area between two curves)
const flowBands: string[] = [];
const flowTooltips: string[] = [];
const particles: string[] = [];
const midX = (leftX + NODE_W + rightX) / 2;
for (let i = 0; i < flows.length; i++) {
const f = flows[i];
const y1 = leftPositions.get(f.fromDid)!;
const y2 = rightPositions.get(f.toDid)!;
const thickness = 1.5 + Math.log10(1 + f.weight * 9) * 3;
const midX = (leftX + nodeW + rightX - nodeW) / 2;
for (const f of sortedFlows) {
const thickness = bandThickness(f.weight);
const color = delegatorColor.get(f.fromDid) || "#7c3aed";
const gradRef = `url(#grad-${delegators.indexOf(f.fromDid)})`;
// Bezier path
const path = `M ${leftX + nodeW} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${rightX - nodeW} ${y2}`;
flowPaths.push(`
<path d="${path}" fill="none" stroke="${FLOW_COLOR}" stroke-width="${thickness}" opacity="0.3"/>
<path d="${path}" fill="none" stroke="url(#flow-gradient)" stroke-width="${thickness}" opacity="0.6"/>
// Left side: source port position
const lTop = leftNodeY.get(f.fromDid)!;
const lOffset = leftPortOffset.get(f.fromDid)!;
const srcY1 = lTop + lOffset;
const srcY2 = srcY1 + thickness;
leftPortOffset.set(f.fromDid, lOffset + thickness);
// Right side: target port position
const rTop = rightNodeY.get(f.toDid)!;
const rOffset = rightPortOffset.get(f.toDid)!;
const tgtY1 = rTop + rOffset;
const tgtY2 = tgtY1 + thickness;
rightPortOffset.set(f.toDid, rOffset + thickness);
const sx = leftX + NODE_W;
const tx = rightX;
const isHovered = this.hoveredFlowId === f.id;
const opacity = this.hoveredFlowId ? (isHovered ? 0.85 : 0.15) : 0.55;
// Filled bezier band: top curve left-to-right, bottom curve right-to-left
const bandPath = [
`M ${sx} ${srcY1}`,
`C ${midX} ${srcY1}, ${midX} ${tgtY1}, ${tx} ${tgtY1}`,
`L ${tx} ${tgtY2}`,
`C ${midX} ${tgtY2}, ${midX} ${srcY2}, ${sx} ${srcY2}`,
"Z",
].join(" ");
// Center-line path for particles
const srcMid = (srcY1 + srcY2) / 2;
const tgtMid = (tgtY1 + tgtY2) / 2;
const centerPath = `M ${sx} ${srcMid} C ${midX} ${srcMid}, ${midX} ${tgtMid}, ${tx} ${tgtMid}`;
const fromName = flows.find(fl => fl.fromDid === f.fromDid)?.fromName || f.fromDid.slice(0, 12);
const toName = flows.find(fl => fl.toDid === f.toDid)?.toName || f.toDid.slice(0, 12);
flowBands.push(`
<path class="flow-band" data-flow-id="${f.id}" data-from-did="${f.fromDid}" data-to-did="${f.toDid}"
data-orig-fill="${gradRef}" d="${bandPath}"
fill="${gradRef}" opacity="${opacity}"
stroke="${color}" stroke-width="0.5" stroke-opacity="0.3"/>
`);
// Animated particles
// Invisible wider hit area for hover
flowTooltips.push(`
<path class="flow-hit" data-flow-id="${f.id}" data-from-did="${f.fromDid}" data-to-did="${f.toDid}"
d="${centerPath}" fill="none" stroke="transparent" stroke-width="${Math.max(thickness, 12)}">
<title>${this.esc(fromName)} ${this.esc(toName)}: ${Math.round(f.weight * 100)}%</title>
</path>
`);
// Weight label on band (only if thick enough)
if (thickness >= 14) {
const labelX = midX;
const labelY = ((srcY1 + srcY2) / 2 + (tgtY1 + tgtY2) / 2) / 2 + 3;
flowBands.push(`
<text x="${labelX}" y="${labelY}" fill="#fff" font-size="9" font-weight="600"
text-anchor="middle" opacity="${this.hoveredFlowId ? (isHovered ? 0.9 : 0.1) : 0.7}"
pointer-events="none">${Math.round(f.weight * 100)}%</text>
`);
}
// Animated particles along center-line
if (this.animationEnabled) {
const duration = 3 + Math.random() * 2;
const duration = 2.5 + Math.random() * 1.5;
const delay = Math.random() * duration;
const particleR = Math.max(1.5, thickness * 0.12);
particles.push(`
<circle r="${Math.max(2, thickness * 0.4)}" fill="${FLOW_COLOR}" opacity="0.8">
<animateMotion dur="${duration}s" begin="${delay}s" repeatCount="indefinite" path="${path}"/>
<circle r="${particleR}" fill="#fff" opacity="0.6">
<animateMotion dur="${duration}s" begin="${delay}s" repeatCount="indefinite" path="${centerPath}"/>
</circle>
`);
}
}
// Left nodes (delegators)
const leftNodes = delegators.map(did => {
const y = leftPositions.get(did)!;
const name = flows.find(f => f.fromDid === did)?.fromName || did.slice(0, 8);
const total = flows.filter(f => f.fromDid === did).reduce((s, f) => s + f.weight, 0);
const h = Math.max(12, total * 40);
return `
<rect x="${leftX}" y="${y - h/2}" width="${nodeW}" height="${h}" rx="3" fill="#7c3aed" opacity="0.8"/>
<text x="${leftX - 6}" y="${y + 4}" fill="var(--rs-text-primary)" font-size="11" text-anchor="end" font-weight="500">${this.esc(name)}</text>
<text x="${leftX - 6}" y="${y + 16}" fill="var(--rs-text-muted)" font-size="9" text-anchor="end">${Math.round(total * 100)}%</text>
`;
// Build per-delegator gradients
const gradients = delegators.map((did, i) => {
const color = delegatorColor.get(did)!;
return `<linearGradient id="grad-${i}" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="${color}" stop-opacity="0.8"/>
<stop offset="50%" stop-color="${color}" stop-opacity="0.5"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0.35"/>
</linearGradient>`;
}).join("");
// Right nodes (delegates)
const rightNodes = delegates.map(did => {
const y = rightPositions.get(did)!;
const name = flows.find(f => f.toDid === did)?.toName || did.slice(0, 8);
const total = flows.filter(f => f.toDid === did).reduce((s, f) => s + f.weight, 0);
const h = Math.max(12, total * 40);
// --- Weight rankings: all unique people ranked by received trust ---
const allDids = new Set<string>();
flows.forEach(f => { allDids.add(f.fromDid); allDids.add(f.toDid); });
const receivedWeight = new Map<string, number>();
allDids.forEach(did => receivedWeight.set(did, 0));
flows.forEach(f => receivedWeight.set(f.toDid, (receivedWeight.get(f.toDid) || 0) + f.weight));
const ranked = [...receivedWeight.entries()].sort((a, b) => b[1] - a[1]);
const rankOf = new Map<string, number>();
ranked.forEach(([did], i) => rankOf.set(did, i + 1));
// Sparkline: recent weight changes (last 30 days)
// Name lookup helper
const nameOf = (did: string, side: "from" | "to"): string => {
const f = side === "from"
? flows.find(fl => fl.fromDid === did)
: flows.find(fl => fl.toDid === did);
if (side === "from") return f?.fromName || did.slice(0, 8);
return f?.toName || did.slice(0, 8);
};
// Left nodes (delegators)
const leftNodes = delegators.map(did => {
const y = leftNodeY.get(did)!;
const total = leftTotals.get(did)!;
const h = nodeHeight(total);
const name = nameOf(did, "from");
const color = delegatorColor.get(did)!;
const midY = y + h / 2;
const rank = rankOf.get(did) || 0;
const recW = receivedWeight.get(did) || 0;
return `
<g class="sankey-node" data-node-did="${did}" style="cursor:pointer">
<rect x="${leftX}" y="${y}" width="${NODE_W}" height="${h}" rx="3" fill="${color}" opacity="0.9"/>
<text x="${leftX - 8}" y="${midY + 1}" fill="var(--rs-text-primary)" font-size="11" text-anchor="end" font-weight="600" dominant-baseline="middle">${this.esc(name)}</text>
<text x="${leftX - 8}" y="${midY + 13}" fill="var(--rs-text-muted)" font-size="9" text-anchor="end">${Math.round(total * 100)}% out</text>
${recW > 0 ? `<text x="${leftX - 8}" y="${midY + 23}" fill="#10b981" font-size="8" text-anchor="end">#${rank} (${Math.round(recW * 100)}% in)</text>` : ""}
</g>`;
}).join("");
// Right nodes (delegates) — sorted by rank
const rightNodes = delegates.map(did => {
const y = rightNodeY.get(did)!;
const total = rightTotals.get(did)!;
const h = nodeHeight(total);
const name = nameOf(did, "to");
const midY = y + h / 2;
const rank = rankOf.get(did) || 0;
// Sparkline
const sparkline = this.renderSparkline(did, 30);
// Rank badge
const rankColor = rank <= 3 ? "#fbbf24" : "#a78bfa";
return `
<rect x="${rightX - nodeW}" y="${y - h/2}" width="${nodeW}" height="${h}" rx="3" fill="#a78bfa" opacity="0.8"/>
<text x="${rightX + 6}" y="${y + 4}" fill="var(--rs-text-primary)" font-size="11" text-anchor="start" font-weight="500">${this.esc(name)}</text>
<text x="${rightX + 6}" y="${y + 16}" fill="var(--rs-text-muted)" font-size="9" text-anchor="start">${Math.round(total * 100)}% received</text>
${sparkline ? `<g transform="translate(${rightX + 6}, ${y + 20})">${sparkline}</g>` : ""}
`;
<g class="sankey-node" data-node-did="${did}" style="cursor:pointer">
<rect x="${rightX}" y="${y}" width="${NODE_W}" height="${h}" rx="3" fill="#a78bfa" opacity="0.9"/>
<text x="${rightX + NODE_W + 8}" y="${midY - 4}" fill="${rankColor}" font-size="9" text-anchor="start" font-weight="700">#${rank}</text>
<text x="${rightX + NODE_W + 8}" y="${midY + 8}" fill="var(--rs-text-primary)" font-size="11" text-anchor="start" font-weight="600">${this.esc(name)}</text>
<text x="${rightX + NODE_W + 8}" y="${midY + 20}" fill="var(--rs-text-muted)" font-size="9" text-anchor="start">${Math.round(total * 100)}% received</text>
${sparkline ? `<g transform="translate(${rightX + NODE_W + 8}, ${midY + 24})">${sparkline}</g>` : ""}
</g>`;
}).join("");
return `
<svg width="100%" viewBox="0 0 ${W} ${H}" class="sankey-svg">
<defs>
<linearGradient id="flow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#7c3aed" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#a78bfa" stop-opacity="0.4"/>
</linearGradient>
</defs>
<g class="flow-layer">${flowPaths.join("")}</g>
<defs>${gradients}</defs>
<g class="flow-layer">${flowBands.join("")}</g>
<g class="hit-layer">${flowTooltips.join("")}</g>
<g class="particle-layer">${particles.join("")}</g>
<g class="left-nodes">${leftNodes}</g>
<g class="right-nodes">${rightNodes}</g>
@ -343,6 +660,17 @@ class FolkTrustSankey extends HTMLElement {
.sankey-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.sankey-title { font-size: 15px; font-weight: 600; }
.demo-badge {
font-size: 9px; font-weight: 700; letter-spacing: 0.06em;
padding: 2px 8px; border-radius: 4px;
background: rgba(239, 68, 68, 0.15); color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.3);
animation: demo-pulse 2s ease-in-out infinite;
}
@keyframes demo-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.authority-filter {
display: flex; gap: 4px; flex-wrap: wrap;
@ -360,6 +688,9 @@ class FolkTrustSankey extends HTMLElement {
border-radius: 12px; padding: 16px; overflow-x: auto;
}
.sankey-svg { display: block; min-height: 200px; }
.flow-band { transition: opacity 0.2s, fill 0.2s; cursor: pointer; }
.flow-hit { cursor: pointer; }
.sankey-node { transition: opacity 0.2s; }
.time-slider {
display: flex; align-items: center; gap: 10px; margin-top: 12px;
@ -385,6 +716,7 @@ class FolkTrustSankey extends HTMLElement {
<div class="sankey-header">
<span class="sankey-title">Delegation Flows</span>
${this.space === "demo" ? `<span class="demo-badge">LIVE DEMO</span>` : ""}
<div class="authority-filter">
${SANKEY_AUTHORITIES.map(a => `<button class="authority-btn ${this.authority === a ? "active" : ""}" data-authority="${a}">${SANKEY_AUTHORITY_DISPLAY[a]?.label || a}</button>`).join("")}
</div>
@ -408,9 +740,9 @@ class FolkTrustSankey extends HTMLElement {
</div>
<div class="legend">
<div class="legend-item"><span class="legend-dot" style="background:#7c3aed"></span> Delegators</div>
<div class="legend-item"><span class="legend-dot" style="background:#a78bfa"></span> Delegates</div>
<div class="legend-item"><span class="legend-dot" style="background:linear-gradient(90deg,#7c3aed,#a78bfa)"></span> Flow (width = weight)</div>
<div class="legend-item"><span class="legend-dot" style="background:linear-gradient(90deg,#7c3aed,#a78bfa);width:24px"></span> Band width = weight</div>
<div class="legend-item"><span class="legend-dot" style="background:#10b981"></span> Trust received (hover)</div>
<div class="legend-item"><span class="legend-dot" style="background:#f59e0b"></span> Trust given (hover)</div>
</div>
`}
`;
@ -442,6 +774,118 @@ class FolkTrustSankey extends HTMLElement {
this.animationEnabled = !this.animationEnabled;
this.render();
});
// Flow band hover — highlight individual flows (only when no node is hovered)
this.shadow.querySelectorAll(".flow-band, .flow-hit").forEach(el => {
el.addEventListener("mouseenter", () => {
if (this.hoveredNodeDid) return; // node hover takes priority
const id = (el as HTMLElement).dataset.flowId;
if (id && this.hoveredFlowId !== id) {
this.hoveredFlowId = id;
this.updateHighlights();
}
});
el.addEventListener("mouseleave", () => {
if (this.hoveredNodeDid) return;
if (this.hoveredFlowId) {
this.hoveredFlowId = null;
this.updateHighlights();
}
});
});
// Node hover — highlight inbound (teal) vs outbound (amber) flows
this.shadow.querySelectorAll(".sankey-node").forEach(el => {
el.addEventListener("mouseenter", () => {
const did = (el as HTMLElement).dataset.nodeDid;
if (did && this.hoveredNodeDid !== did) {
this.hoveredNodeDid = did;
this.hoveredFlowId = null;
this.updateHighlights();
}
});
el.addEventListener("mouseleave", () => {
if (this.hoveredNodeDid) {
this.hoveredNodeDid = null;
this.updateHighlights();
}
});
});
}
// Inbound = trust flowing INTO the hovered node (teal)
// Outbound = trust flowing FROM the hovered node (amber)
private static readonly COLOR_INBOUND = "#10b981";
private static readonly COLOR_OUTBOUND = "#f59e0b";
/** Update all flow band visual states for hover (node or flow) */
private updateHighlights() {
const hNode = this.hoveredNodeDid;
const hFlow = this.hoveredFlowId;
const active = !!(hNode || hFlow);
this.shadow.querySelectorAll(".flow-band").forEach(band => {
const el = band as SVGElement;
const ds = (band as HTMLElement).dataset;
const origFill = ds.origFill || "";
if (hNode) {
// Node hover mode: color by direction
const isInbound = ds.toDid === hNode;
const isOutbound = ds.fromDid === hNode;
if (isInbound) {
el.setAttribute("fill", FolkTrustSankey.COLOR_INBOUND);
el.setAttribute("opacity", "0.8");
el.setAttribute("stroke", FolkTrustSankey.COLOR_INBOUND);
} else if (isOutbound) {
el.setAttribute("fill", FolkTrustSankey.COLOR_OUTBOUND);
el.setAttribute("opacity", "0.8");
el.setAttribute("stroke", FolkTrustSankey.COLOR_OUTBOUND);
} else {
el.setAttribute("fill", origFill);
el.setAttribute("opacity", "0.08");
el.setAttribute("stroke", "none");
}
} else if (hFlow) {
// Single flow hover mode
el.setAttribute("fill", origFill);
el.setAttribute("opacity", ds.flowId === hFlow ? "0.85" : "0.15");
} else {
// No hover — restore defaults
el.setAttribute("fill", origFill);
el.setAttribute("opacity", "0.55");
}
});
// Dim/show band weight labels
this.shadow.querySelectorAll(".flow-layer text").forEach(t => {
(t as SVGElement).setAttribute("opacity", active ? "0.1" : "0.7");
});
// Highlight/dim node groups — keep connected nodes visible
if (hNode) {
// Build set of DIDs connected to the hovered node
const connectedDids = new Set<string>([hNode]);
this.shadow.querySelectorAll(".flow-band").forEach(band => {
const ds = (band as HTMLElement).dataset;
if (ds.fromDid === hNode) connectedDids.add(ds.toDid || "");
if (ds.toDid === hNode) connectedDids.add(ds.fromDid || "");
});
this.shadow.querySelectorAll(".sankey-node").forEach(g => {
const did = (g as HTMLElement).dataset.nodeDid || "";
if (did === hNode) {
(g as SVGElement).style.opacity = "1";
} else if (connectedDids.has(did)) {
(g as SVGElement).style.opacity = "0.85";
} else {
(g as SVGElement).style.opacity = "0.3";
}
});
} else {
this.shadow.querySelectorAll(".sankey-node").forEach(g => {
(g as SVGElement).style.opacity = "1";
});
}
}
private esc(s: string): string {

View File

@ -72,6 +72,7 @@ interface Wire {
hours: number;
status: 'proposed' | 'committed';
connectionId?: string;
connectionType?: 'commitment' | 'dependency';
}
// ── Orb class ──
@ -274,6 +275,10 @@ class FolkTimebankApp extends HTMLElement {
private poolPointerId: number | null = null;
private poolPointerStart: { x: number; y: number; cx: number; cy: number } | null = null;
private poolPanelCollapsed = false;
private _panelSplitPct = 35;
private _dividerDragging = false;
private _dividerDragStartX = 0;
private _dividerDragStartPct = 0;
// Pan/zoom state
private panX = 0;
@ -313,6 +318,7 @@ class FolkTimebankApp extends HTMLElement {
// Data
private commitments: Commitment[] = [];
private tasks: TaskData[] = [];
private projectFrames: { id: string; title: string; taskIds: string[]; color?: string; x: number; y: number; w: number; h: number }[] = [];
// Collaborate state
private intents: any[] = [];
@ -353,9 +359,12 @@ class FolkTimebankApp extends HTMLElement {
else this.currentView = 'canvas';
this.dpr = window.devicePixelRatio || 1;
this._theme = (localStorage.getItem('rtime-theme') as 'dark' | 'light') || 'dark';
this._panelSplitPct = parseFloat(localStorage.getItem('rtime-split-pct') || '35');
this.render();
this.applyTheme();
this.setupPoolPanel();
this.setupDivider();
this.applyPanelSplit();
this.setupCanvas();
this.setupCollaborate();
this.fetchData();
@ -510,7 +519,8 @@ class FolkTimebankApp extends HTMLElement {
<!-- Left pool panel -->
<div class="pool-panel" id="poolPanel">
<div class="pool-panel-header">
<span>Commitments</span>
<span>Commitment Pool</span>
<span class="pool-hint">drag orb to weave \u2192</span>
<button id="poolPanelToggle" title="Collapse panel">\u27E8</button>
</div>
<canvas id="pool-canvas"></canvas>
@ -522,13 +532,17 @@ class FolkTimebankApp extends HTMLElement {
<div class="pool-detail-skill" id="detailSkill"></div>
<div class="pool-detail-hours" id="detailHours"></div>
<div class="pool-detail-desc" id="detailDesc"></div>
<div class="pool-detail-woven" id="detailWoven"></div>
<button class="pool-detail-drag-btn" id="detailDragBtn">Drag to Weave \u2192</button>
</div>
<div class="pool-panel-sidebar" id="poolSidebar">
<div class="sidebar-section">Task Templates</div>
<div id="sidebarTasks"></div>
</div>
<button class="add-btn" id="addBtn">+ Add</button>
<button class="add-btn" id="addBtn">+ Pledge Time</button>
</div>
<!-- Resizable divider -->
<div class="panel-divider" id="panelDivider"><div class="divider-pip"></div></div>
<!-- SVG infinite canvas -->
<div class="canvas-wrap" id="canvasWrap">
<svg id="weave-svg" xmlns="http://www.w3.org/2000/svg">
@ -545,6 +559,9 @@ class FolkTimebankApp extends HTMLElement {
<filter id="glowGold" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="0" stdDeviation="6" flood-color="#fbbf24" flood-opacity="0.4"/>
</filter>
<marker id="dep-arrow" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#64748b"/>
</marker>
</defs>
<rect width="100%" height="100%" fill="url(#grid)"/>
<g id="canvas-content" transform="translate(0 0) scale(1)">
@ -559,6 +576,7 @@ class FolkTimebankApp extends HTMLElement {
<button id="zoomIn" title="Zoom in">+</button>
<button id="zoomOut" title="Zoom out">\u2212</button>
<button id="zoomReset" title="Reset zoom">\u2299</button>
<button id="addFrame" title="Add project frame">\u25A1</button>
</div>
</div>
</div>
@ -681,7 +699,7 @@ class FolkTimebankApp extends HTMLElement {
</div>
<div class="modal-actions">
<button class="modal-cancel" id="modalCancel">Cancel</button>
<button class="modal-submit" id="modalSubmit">Drop into Pool</button>
<button class="modal-submit" id="modalSubmit">Pledge to Pool</button>
</div>
</div>
</div>
@ -765,10 +783,12 @@ class FolkTimebankApp extends HTMLElement {
this.poolPanelCollapsed = !this.poolPanelCollapsed;
const panel = this.shadow.getElementById('poolPanel')!;
panel.classList.toggle('collapsed', this.poolPanelCollapsed);
const divider = this.shadow.getElementById('panelDivider');
if (divider) divider.style.display = this.poolPanelCollapsed ? 'none' : '';
const btn = this.shadow.getElementById('poolPanelToggle')!;
btn.textContent = this.poolPanelCollapsed ? '\u27E9' : '\u27E8';
btn.title = this.poolPanelCollapsed ? 'Expand panel' : 'Collapse panel';
if (!this.poolPanelCollapsed) setTimeout(() => this.resizePoolCanvas(), 50);
if (!this.poolPanelCollapsed) setTimeout(() => { this.applyPanelSplit(); }, 50);
});
// Add commitment modal
@ -842,6 +862,74 @@ class FolkTimebankApp extends HTMLElement {
this.basketR = Math.min(this.poolW, this.poolH) * 0.42;
}
private setupDivider() {
const divider = this.shadow.getElementById('panelDivider');
if (!divider) return;
divider.addEventListener('pointerdown', (e: PointerEvent) => {
e.preventDefault();
this._dividerDragging = true;
this._dividerDragStartX = e.clientX;
this._dividerDragStartPct = this._panelSplitPct;
divider.setPointerCapture(e.pointerId);
divider.classList.add('active');
});
divider.addEventListener('pointermove', (e: PointerEvent) => {
if (!this._dividerDragging) return;
const view = this.shadow.getElementById('canvas-view');
if (!view) return;
const viewW = view.getBoundingClientRect().width;
if (viewW < 10) return;
const dx = e.clientX - this._dividerDragStartX;
const deltaPct = (dx / viewW) * 100;
this._panelSplitPct = Math.max(20, Math.min(65, this._dividerDragStartPct + deltaPct));
this.applyPanelSplit();
});
divider.addEventListener('pointerup', (e: PointerEvent) => {
if (!this._dividerDragging) return;
this._dividerDragging = false;
divider.releasePointerCapture(e.pointerId);
divider.classList.remove('active');
localStorage.setItem('rtime-split-pct', String(this._panelSplitPct));
});
divider.addEventListener('pointercancel', () => {
this._dividerDragging = false;
divider.classList.remove('active');
});
// Detail "Drag to Weave" button
const dragBtn = this.shadow.getElementById('detailDragBtn');
if (dragBtn) {
dragBtn.addEventListener('pointerdown', (e: PointerEvent) => {
e.preventDefault();
e.stopPropagation();
if (this.selectedOrb) {
this.hideDetail();
this.startOrbDrag(this.selectedOrb, e.clientX, e.clientY);
}
});
}
// Add Frame button
const addFrameBtn = this.shadow.getElementById('addFrame');
if (addFrameBtn) {
addFrameBtn.addEventListener('click', () => this.addProjectFrame());
}
}
private applyPanelSplit() {
const panel = this.shadow.getElementById('poolPanel');
const divider = this.shadow.getElementById('panelDivider');
if (!panel) return;
if (this.poolPanelCollapsed) return;
panel.style.width = this._panelSplitPct + '%';
if (divider) divider.style.display = '';
this.resizePoolCanvas();
}
private availableHours(commitmentId: string): number {
const commitment = this.commitments.find(c => c.id === commitmentId);
if (!commitment) return 0;
@ -933,7 +1021,10 @@ class FolkTimebankApp extends HTMLElement {
ctx.font = '600 13px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ctx.fillText('COMMITMENT BASKET', this.basketCX, this.basketCY - this.basketR - 14);
ctx.fillText('COMMITMENT POOL', this.basketCX, this.basketCY - this.basketR - 20);
ctx.font = '400 10px -apple-system, sans-serif';
ctx.fillStyle = '#a78bfa88';
ctx.fillText('pledge your time here', this.basketCX, this.basketCY - this.basketR - 6);
}
private poolFrame = () => {
@ -1023,12 +1114,30 @@ class FolkTimebankApp extends HTMLElement {
private showDetail(orb: Orb, cx: number, cy: number) {
const el = this.shadow.getElementById('poolDetail')!;
const origC = (orb as any)._originalCommitment || orb.c;
const c = orb.c;
(this.shadow.getElementById('detailDot') as HTMLElement).style.background = SKILL_COLORS[c.skill] || '#8b5cf6';
this.shadow.getElementById('detailName')!.textContent = c.memberName;
this.shadow.getElementById('detailSkill')!.textContent = SKILL_LABELS[c.skill] || c.skill;
this.shadow.getElementById('detailHours')!.textContent = c.hours + ' hour' + (c.hours !== 1 ? 's' : '') + ' pledged';
this.shadow.getElementById('detailDesc')!.textContent = c.desc;
// Woven percentage badge
const wovenEl = this.shadow.getElementById('detailWoven');
if (wovenEl) {
const totalHrs = origC.hours;
const usedHrs = this.connections
.filter(w => w.from === 'cn-' + origC.id)
.reduce((sum, w) => sum + (w.hours || 0), 0);
const pct = totalHrs > 0 ? Math.round((usedHrs / totalHrs) * 100) : 0;
if (usedHrs > 0) {
wovenEl.textContent = pct + '% woven (' + usedHrs + '/' + totalHrs + 'h)';
wovenEl.style.display = '';
} else {
wovenEl.style.display = 'none';
}
}
const panel = this.shadow.getElementById('poolPanel')!.getBoundingClientRect();
let left = cx - panel.left + 10, top = cy - panel.top - 20;
if (left + 210 > panel.width) left = cx - panel.left - 220;
@ -1262,46 +1371,128 @@ class FolkTimebankApp extends HTMLElement {
return true;
}
/** Generate a multi-strand woven/braided connection group. */
private wovenPath(x1: number, y1: number, x2: number, y2: number, skill: string, hours: number, isCommitted: boolean): SVGGElement {
const g = ns('g') as SVGGElement;
g.setAttribute('class', 'woven-connection');
const color = SKILL_COLORS[skill] || '#8b5cf6';
const strandCount = Math.min(4, Math.ceil(hours / 2));
const dx = x2 - x1, dy = y2 - y1;
const dist = Math.sqrt(dx * dx + dy * dy);
const spread = Math.min(12, dist * 0.04); // perpendicular strand spread
// Normal perpendicular vector
const nx = dist > 0 ? -dy / dist : 0;
const ny = dist > 0 ? dx / dist : 1;
// Envelope path at low opacity for "cable" feel
const envOff = spread * (strandCount - 1) * 0.5 + 3;
const cd = Math.max(40, Math.abs(dx) * 0.5);
const envPath = ns('path');
const envD = `M${x1 + nx * envOff},${y1 + ny * envOff} C${x1 + cd + nx * envOff},${y1 + ny * envOff} ${x2 - cd + nx * envOff},${y2 + ny * envOff} ${x2 + nx * envOff},${y2 + ny * envOff}` +
` L${x2 - nx * envOff},${y2 - ny * envOff}` +
` C${x2 - cd - nx * envOff},${y2 - ny * envOff} ${x1 + cd - nx * envOff},${y1 - ny * envOff} ${x1 - nx * envOff},${y1 - ny * envOff} Z`;
envPath.setAttribute('d', envD);
envPath.setAttribute('fill', color);
envPath.setAttribute('opacity', '0.05');
g.appendChild(envPath);
// Individual strands
for (let i = 0; i < strandCount; i++) {
const offset = (i - (strandCount - 1) / 2) * spread;
const phase = i * Math.PI * 0.6; // phase shift for intertwining
const amplitude = spread * 0.7;
// Build cubic bezier with phase-shifted control points
const sx = x1 + nx * offset;
const sy = y1 + ny * offset;
const ex = x2 + nx * offset;
const ey = y2 + ny * offset;
const cp1x = x1 + cd + nx * (offset + Math.sin(phase) * amplitude);
const cp1y = y1 + ny * (offset + Math.sin(phase) * amplitude);
const cp2x = x2 - cd + nx * (offset + Math.sin(phase + Math.PI) * amplitude);
const cp2y = y2 + ny * (offset + Math.sin(phase + Math.PI) * amplitude);
const strand = ns('path');
strand.setAttribute('d', `M${sx},${sy} C${cp1x},${cp1y} ${cp2x},${cp2y} ${ex},${ey}`);
strand.setAttribute('fill', 'none');
strand.setAttribute('stroke', color);
strand.setAttribute('stroke-width', '1.8');
strand.setAttribute('opacity', String(i % 2 === 0 ? 0.85 : 0.55));
if (!isCommitted) strand.setAttribute('stroke-dasharray', '6 4');
g.appendChild(strand);
}
return g;
}
private renderConnections() {
this.connectionsLayer.innerHTML = '';
this.connections.forEach(conn => {
const from = this.weaveNodes.find(n => n.id === conn.from);
const to = this.weaveNodes.find(n => n.id === conn.to);
if (!from || !to) return;
const isDep = (conn as any).connectionType === 'dependency';
let x1: number, y1: number;
if (from.type === 'commitment') {
if (isDep) {
// Dependency: from right center of source task
x1 = from.x + from.w; y1 = from.y + from.h / 2;
} else if (from.type === 'commitment') {
const cx = from.x + from.w / 2, cy = from.y + from.h / 2;
const pts = hexPoints(cx, cy, from.hexR || HEX_R);
x1 = pts[1][0]; y1 = pts[1][1];
} else {
x1 = from.x + from.w; y1 = from.y + from.h / 2;
}
let x2 = to.x, y2 = to.y + to.h / 2;
if (to.type === 'task') {
const skills = Object.keys(to.data.needs);
const idx = skills.indexOf(conn.skill);
if (idx >= 0) y2 = to.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2;
let x2: number, y2: number;
if (isDep) {
// Dependency: to left center of target task
x2 = to.x; y2 = to.y + to.h / 2;
} else {
x2 = to.x; y2 = to.y + to.h / 2;
if (to.type === 'task') {
const skills = Object.keys(to.data.needs);
const idx = skills.indexOf(conn.skill);
if (idx >= 0) y2 = to.y + TASK_H_BASE + idx * TASK_ROW + TASK_ROW / 2;
}
}
const isCommitted = conn.status === 'committed';
const p = ns('path');
p.setAttribute('d', bezier(x1, y1, x2, y2));
p.setAttribute('class', 'connection-line active');
p.setAttribute('stroke', isCommitted ? '#10b981' : '#f59e0b');
p.setAttribute('stroke-width', '2');
if (!isCommitted) p.setAttribute('stroke-dasharray', '6 4');
this.connectionsLayer.appendChild(p);
if (isDep) {
// Dependency arrow: simple gray dashed bezier with arrowhead
const p = ns('path');
p.setAttribute('d', bezier(x1, y1, x2, y2));
p.setAttribute('fill', 'none');
p.setAttribute('stroke', '#64748b');
p.setAttribute('stroke-width', '1.5');
p.setAttribute('stroke-dasharray', '6 3');
p.setAttribute('marker-end', 'url(#dep-arrow)');
p.setAttribute('class', 'dep-connection');
this.connectionsLayer.appendChild(p);
} else {
// Commitment: multi-strand woven path
const wovenG = this.wovenPath(x1, y1, x2, y2, conn.skill, conn.hours, isCommitted);
this.connectionsLayer.appendChild(wovenG);
}
// Hour label at midpoint
const mx = (x1 + x2) / 2, my = (y1 + y2) / 2;
const label = ns('text') as SVGTextElement;
label.setAttribute('x', String(mx));
label.setAttribute('y', String(my - 6));
label.setAttribute('text-anchor', 'middle');
label.setAttribute('font-size', '10');
label.setAttribute('font-weight', '600');
label.setAttribute('fill', isCommitted ? '#10b981' : '#f59e0b');
label.textContent = conn.hours + 'h ' + (isCommitted ? '\u2713' : '\u23f3');
this.connectionsLayer.appendChild(label);
if (!isDep) {
const label = ns('text') as SVGTextElement;
label.setAttribute('x', String(mx));
label.setAttribute('y', String(my - 6));
label.setAttribute('text-anchor', 'middle');
label.setAttribute('font-size', '10');
label.setAttribute('font-weight', '600');
label.setAttribute('fill', isCommitted ? '#10b981' : '#f59e0b');
label.textContent = conn.hours + 'h ' + (isCommitted ? '\u2713' : '\u23f3');
this.connectionsLayer.appendChild(label);
}
});
// ── Mycelial suggestion preview ──
@ -1587,6 +1778,37 @@ class FolkTimebankApp extends HTMLElement {
if (committed >= needed) g.appendChild(svgText('\u2713', node.w - 16, ry + TASK_ROW / 2 + 4, 13, '#10b981', '600', 'middle'));
});
// Dependency ports (diamond-shaped)
// Input dep-port: left center
const depInHit = ns('rect');
depInHit.setAttribute('x', '-12'); depInHit.setAttribute('y', String(node.h / 2 - 12));
depInHit.setAttribute('width', '24'); depInHit.setAttribute('height', '24');
depInHit.setAttribute('fill', 'transparent');
depInHit.setAttribute('class', 'dep-port');
depInHit.setAttribute('data-node', node.id); depInHit.setAttribute('data-port', 'dep-input');
g.appendChild(depInHit);
const depIn = ns('polygon');
depIn.setAttribute('points', `0,${node.h / 2 - 5} 5,${node.h / 2} 0,${node.h / 2 + 5} -5,${node.h / 2}`);
depIn.setAttribute('fill', '#475569'); depIn.setAttribute('stroke', '#64748b'); depIn.setAttribute('stroke-width', '1');
depIn.setAttribute('class', 'dep-port-diamond');
depIn.style.pointerEvents = 'none';
g.appendChild(depIn);
// Output dep-port: right center
const depOutHit = ns('rect');
depOutHit.setAttribute('x', String(node.w - 12)); depOutHit.setAttribute('y', String(node.h / 2 - 12));
depOutHit.setAttribute('width', '24'); depOutHit.setAttribute('height', '24');
depOutHit.setAttribute('fill', 'transparent');
depOutHit.setAttribute('class', 'dep-port');
depOutHit.setAttribute('data-node', node.id); depOutHit.setAttribute('data-port', 'dep-output');
g.appendChild(depOutHit);
const depOut = ns('polygon');
depOut.setAttribute('points', `${node.w},${node.h / 2 - 5} ${node.w + 5},${node.h / 2} ${node.w},${node.h / 2 + 5} ${node.w - 5},${node.h / 2}`);
depOut.setAttribute('fill', '#475569'); depOut.setAttribute('stroke', '#64748b'); depOut.setAttribute('stroke-width', '1');
depOut.setAttribute('class', 'dep-port-diamond');
depOut.style.pointerEvents = 'none';
g.appendChild(depOut);
if (ready) {
const btnY = node.baseH! + 4;
const btnR = ns('rect');
@ -1610,6 +1832,7 @@ class FolkTimebankApp extends HTMLElement {
this.nodesLayer.innerHTML = '';
this.weaveNodes.forEach(n => this.nodesLayer.appendChild(this.renderNode(n)));
this.renderConnections();
this.renderProjectFrames();
}
// SVG pointer events (with pan/zoom support)
@ -1656,6 +1879,68 @@ class FolkTimebankApp extends HTMLElement {
return;
}
// Frame resize handle
const resizeHandle = (e.target as Element).closest('.frame-resize-handle') as SVGElement;
if (resizeHandle) {
const frameId = resizeHandle.getAttribute('data-frame-id');
const frame = this.projectFrames.find(f => f.id === frameId);
if (frame) {
e.preventDefault();
const startPt = this.screenToCanvas(e.clientX, e.clientY);
const startW = frame.w, startH = frame.h;
const resizeMove = (ev: PointerEvent) => {
const curPt = this.screenToCanvas(ev.clientX, ev.clientY);
frame.w = Math.max(120, startW + (curPt.x - startPt.x));
frame.h = Math.max(80, startH + (curPt.y - startPt.y));
this.renderAll();
};
const resizeUp = () => {
document.removeEventListener('pointermove', resizeMove);
document.removeEventListener('pointerup', resizeUp);
this.autoAssignTasksToFrame(frame);
this.renderAll();
};
document.addEventListener('pointermove', resizeMove);
document.addEventListener('pointerup', resizeUp);
return;
}
}
// Frame drag (click on frame rect but not resize handle)
const frameRect = (e.target as Element).closest('.project-frame-rect') as SVGElement;
if (frameRect) {
const frameGroup = frameRect.closest('.project-frame-group') as SVGElement;
const frameId = frameGroup?.getAttribute('data-frame-id');
const frame = this.projectFrames.find(f => f.id === frameId);
if (frame) {
e.preventDefault();
const startPt = this.screenToCanvas(e.clientX, e.clientY);
const startX = frame.x, startY = frame.y;
const dragMove = (ev: PointerEvent) => {
const curPt = this.screenToCanvas(ev.clientX, ev.clientY);
frame.x = startX + (curPt.x - startPt.x);
frame.y = startY + (curPt.y - startPt.y);
this.renderAll();
};
const dragUp = () => {
document.removeEventListener('pointermove', dragMove);
document.removeEventListener('pointerup', dragUp);
};
document.addEventListener('pointermove', dragMove);
document.addEventListener('pointerup', dragUp);
return;
}
}
// Dep-port connections (task-to-task dependencies)
const depPort = (e.target as Element).closest('.dep-port') as SVGElement;
if (depPort) {
e.preventDefault();
this.connecting = { nodeId: depPort.getAttribute('data-node')!, portType: depPort.getAttribute('data-port')!, skill: '__dep__' };
this.tempConn.style.display = 'block';
return;
}
const port = (e.target as Element).closest('.port') as SVGElement;
if (port) {
e.preventDefault();
@ -1707,7 +1992,13 @@ class FolkTimebankApp extends HTMLElement {
if (this.connecting) {
const fn = this.weaveNodes.find(n => n.id === this.connecting!.nodeId);
if (!fn) return;
if (this.connecting.portType === 'input') {
if (this.connecting.portType === 'dep-input') {
const x1 = fn.x, y1 = fn.y + fn.h / 2;
this.tempConn.setAttribute('d', bezier(pt.x, pt.y, x1, y1));
} else if (this.connecting.portType === 'dep-output') {
const outX = fn.x + fn.w, outY = fn.y + fn.h / 2;
this.tempConn.setAttribute('d', bezier(outX, outY, pt.x, pt.y));
} else if (this.connecting.portType === 'input') {
const skills = fn.type === 'task' ? Object.keys(fn.data.needs) : [];
const idx = skills.indexOf(this.connecting.skill!);
const x1 = fn.x;
@ -1738,6 +2029,29 @@ class FolkTimebankApp extends HTMLElement {
}
if (this.connecting) {
// Check for dep-port target first
const depTarget = (e.target as Element).closest('.dep-port') as SVGElement;
if (depTarget && this.connecting.skill === '__dep__') {
const tId = depTarget.getAttribute('data-node')!;
const tType = depTarget.getAttribute('data-port')!;
if (tId !== this.connecting.nodeId) {
let fromId: string | undefined, toId: string | undefined;
if (this.connecting.portType === 'dep-output' && tType === 'dep-input') {
fromId = this.connecting.nodeId; toId = tId;
} else if (this.connecting.portType === 'dep-input' && tType === 'dep-output') {
fromId = tId; toId = this.connecting.nodeId;
}
if (fromId && toId && !this.connections.find(c => c.from === fromId && c.to === toId && (c as any).connectionType === 'dependency')) {
this.connections.push({ from: fromId, to: toId, skill: '__dep__', hours: 0, status: 'committed', connectionType: 'dependency' } as any);
this.renderAll();
}
}
this.connecting = null;
this.tempConn.style.display = 'none';
this.tempConn.setAttribute('d', '');
return;
}
const port = (e.target as Element).closest('.port') as SVGElement;
if (port) {
const tId = port.getAttribute('data-node')!;
@ -1778,6 +2092,19 @@ class FolkTimebankApp extends HTMLElement {
this.tempConn.setAttribute('d', '');
return;
}
if (this.dragNode && this.dragNode.type === 'task') {
// Auto-assign task to frame if dropped inside
const nodeCx = this.dragNode.x + this.dragNode.w / 2;
const nodeCy = this.dragNode.y + this.dragNode.h / 2;
for (const frame of this.projectFrames) {
if (nodeCx >= frame.x && nodeCx <= frame.x + frame.w &&
nodeCy >= frame.y && nodeCy <= frame.y + frame.h) {
if (!frame.taskIds.includes(this.dragNode.id)) frame.taskIds.push(this.dragNode.id);
} else {
frame.taskIds = frame.taskIds.filter(id => id !== this.dragNode!.id);
}
}
}
this.dragNode = null;
}
@ -2034,6 +2361,137 @@ class FolkTimebankApp extends HTMLElement {
this.renderAll();
}
// ── Project Frames ──
private renderProjectFrames() {
// Clear old frames but keep intent frames
this.intentFramesLayer.querySelectorAll('.project-frame-group').forEach(el => el.remove());
for (const frame of this.projectFrames) {
const g = ns('g') as SVGGElement;
g.setAttribute('class', 'project-frame-group');
g.setAttribute('data-frame-id', frame.id);
const fillColor = frame.color || '#8b5cf620';
const strokeColor = frame.color ? frame.color + '66' : '#8b5cf666';
const rect = ns('rect');
rect.setAttribute('x', String(frame.x));
rect.setAttribute('y', String(frame.y));
rect.setAttribute('width', String(frame.w));
rect.setAttribute('height', String(frame.h));
rect.setAttribute('rx', '12');
rect.setAttribute('fill', fillColor);
rect.setAttribute('stroke', strokeColor);
rect.setAttribute('stroke-width', '2');
rect.setAttribute('stroke-dasharray', '8 4');
rect.setAttribute('class', 'project-frame-rect');
g.appendChild(rect);
// Title bar
const titleBg = ns('rect');
titleBg.setAttribute('x', String(frame.x));
titleBg.setAttribute('y', String(frame.y));
titleBg.setAttribute('width', String(frame.w));
titleBg.setAttribute('height', '28');
titleBg.setAttribute('rx', '12');
titleBg.setAttribute('fill', strokeColor);
g.appendChild(titleBg);
// Bottom corners mask
const titleMask = ns('rect');
titleMask.setAttribute('x', String(frame.x));
titleMask.setAttribute('y', String(frame.y + 16));
titleMask.setAttribute('width', String(frame.w));
titleMask.setAttribute('height', '12');
titleMask.setAttribute('fill', strokeColor);
g.appendChild(titleMask);
const titleText = svgText(frame.title, frame.x + 12, frame.y + 18, 11, '#f1f5f9', '600');
g.appendChild(titleText);
// Resize handle (bottom-right)
const resizeHandle = ns('rect');
resizeHandle.setAttribute('x', String(frame.x + frame.w - 16));
resizeHandle.setAttribute('y', String(frame.y + frame.h - 16));
resizeHandle.setAttribute('width', '16');
resizeHandle.setAttribute('height', '16');
resizeHandle.setAttribute('fill', 'transparent');
resizeHandle.setAttribute('class', 'frame-resize-handle');
resizeHandle.setAttribute('data-frame-id', frame.id);
resizeHandle.style.cursor = 'nwse-resize';
g.appendChild(resizeHandle);
// Resize grip visual
const grip = ns('path');
grip.setAttribute('d', `M${frame.x + frame.w - 12},${frame.y + frame.h - 4} L${frame.x + frame.w - 4},${frame.y + frame.h - 12} M${frame.x + frame.w - 8},${frame.y + frame.h - 4} L${frame.x + frame.w - 4},${frame.y + frame.h - 8}`);
grip.setAttribute('stroke', '#64748b');
grip.setAttribute('stroke-width', '1.5');
grip.setAttribute('fill', 'none');
grip.style.pointerEvents = 'none';
g.appendChild(grip);
this.intentFramesLayer.appendChild(g);
}
}
private addProjectFrame() {
const wrap = this.shadow.getElementById('canvasWrap');
const wrapRect = wrap?.getBoundingClientRect();
const cx = wrapRect ? wrapRect.width / 2 : 400;
const cy = wrapRect ? wrapRect.height / 2 : 300;
const pt = this.screenToCanvas(
(wrapRect?.left || 0) + cx,
(wrapRect?.top || 0) + cy
);
const frame = {
id: 'frame-' + Date.now(),
title: 'Project Frame',
taskIds: [] as string[],
x: pt.x - 150,
y: pt.y - 100,
w: 300,
h: 200,
};
this.projectFrames.push(frame);
// Auto-assign tasks that are already inside bounds
this.autoAssignTasksToFrame(frame);
this.renderAll();
}
private autoAssignTasksToFrame(frame: { id: string; taskIds: string[]; x: number; y: number; w: number; h: number }) {
for (const node of this.weaveNodes) {
if (node.type !== 'task') continue;
const nodeCx = node.x + node.w / 2;
const nodeCy = node.y + node.h / 2;
if (nodeCx >= frame.x && nodeCx <= frame.x + frame.w &&
nodeCy >= frame.y && nodeCy <= frame.y + frame.h) {
if (!frame.taskIds.includes(node.id)) frame.taskIds.push(node.id);
}
}
}
private recomputeFrameBounds(frameId: string) {
const frame = this.projectFrames.find(f => f.id === frameId);
if (!frame || frame.taskIds.length === 0) return;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const tid of frame.taskIds) {
const node = this.weaveNodes.find(n => n.id === tid);
if (!node) continue;
minX = Math.min(minX, node.x);
minY = Math.min(minY, node.y);
maxX = Math.max(maxX, node.x + node.w);
maxY = Math.max(maxY, node.y + node.h);
}
if (minX === Infinity) return;
const pad = 20;
frame.x = minX - pad;
frame.y = minY - pad - 28; // extra for title bar
frame.w = (maxX - minX) + pad * 2;
frame.h = (maxY - minY) + pad * 2 + 28;
}
/** Highlight task nodes that have unfulfilled ports matching the given skill. */
private applySkillHighlights(skill: string) {
const groups = this.nodesLayer.querySelectorAll('.task-node');
@ -3026,7 +3484,7 @@ const CSS_TEXT = `
/* Pool panel (left side) */
.pool-panel {
width: 260px;
min-width: 180px;
flex-shrink: 0;
background: #1e293b;
border-right: 1px solid #334155;
@ -3082,6 +3540,56 @@ const CSS_TEXT = `
.pool-detail-skill { font-size: 0.78rem; color: #94a3b8; margin-bottom: 0.2rem; }
.pool-detail-hours { font-size: 0.82rem; font-weight: 600; color: #8b5cf6; }
.pool-detail-desc { font-size: 0.75rem; color: #94a3b8; margin-top: 0.25rem; line-height: 1.4; }
.pool-detail-woven {
font-size: 0.72rem; color: #8b5cf6; font-weight: 600;
margin-top: 0.35rem; padding: 0.2rem 0.5rem;
background: rgba(139,92,246,0.1); border-radius: 0.25rem;
display: inline-block;
}
.pool-detail-drag-btn {
margin-top: 0.5rem; padding: 0.35rem 0.75rem; width: 100%;
background: linear-gradient(135deg, #8b5cf6, #ec4899);
color: #fff; border: none; border-radius: 0.375rem;
font-size: 0.78rem; font-weight: 600; cursor: grab;
transition: opacity 0.15s; touch-action: none;
}
.pool-detail-drag-btn:hover { opacity: 0.85; }
.pool-detail-drag-btn:active { cursor: grabbing; }
/* Pool header hint */
.pool-hint {
font-size: 0.68rem; font-weight: 400; color: #64748b;
font-style: italic; margin-left: auto; margin-right: 0.5rem;
}
/* Resizable divider */
.panel-divider {
width: 6px;
flex-shrink: 0;
background: #1e293b;
border-left: 1px solid #334155;
border-right: 1px solid #334155;
cursor: ew-resize;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
touch-action: none;
z-index: 10;
}
.panel-divider:hover, .panel-divider.active {
background: #8b5cf6;
border-color: #8b5cf6;
}
.divider-pip {
width: 2px; height: 24px;
border-radius: 1px;
background: #475569;
pointer-events: none;
}
.panel-divider:hover .divider-pip, .panel-divider.active .divider-pip {
background: #fff;
}
.pool-panel-sidebar { flex-shrink: 0; overflow-y: auto; max-height: 200px; border-top: 1px solid #334155; }
@ -3173,6 +3681,20 @@ const CSS_TEXT = `
.task-node.ready .node-rect { stroke: #10b981; stroke-width: 2; }
.task-node.skill-match .node-rect { stroke: #fbbf24; stroke-width: 2.5; filter: url(#glowGold); }
/* Dependency ports */
.dep-port { cursor: crosshair; touch-action: none; }
.dep-port:hover + .dep-port-diamond { fill: #8b5cf6; stroke: #a78bfa; }
.dep-port-diamond { transition: fill 0.15s; }
.dep-connection { pointer-events: none; }
.woven-connection { pointer-events: none; }
/* Project frames */
.project-frame-group { cursor: grab; }
.project-frame-group:active { cursor: grabbing; }
.project-frame-rect { transition: stroke 0.15s; }
.project-frame-group:hover .project-frame-rect { stroke-width: 2.5; }
.frame-resize-handle { cursor: nwse-resize; }
/* Intent frames */
.intent-frame { pointer-events: none; }
.intent-frame-label { pointer-events: none; }
@ -3651,6 +4173,12 @@ const CSS_TEXT = `
:host([data-theme="light"]) .zoom-controls button:hover { border-color: #8b5cf6; background: #f1f5f9; }
:host([data-theme="light"]) .pool-detail { background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.12); }
:host([data-theme="light"]) .pool-detail-name { color: #1e293b; }
:host([data-theme="light"]) .panel-divider { background: #f1f5f9; border-color: #e2e8f0; }
:host([data-theme="light"]) .panel-divider:hover, :host([data-theme="light"]) .panel-divider.active { background: #8b5cf6; border-color: #8b5cf6; }
:host([data-theme="light"]) .divider-pip { background: #94a3b8; }
:host([data-theme="light"]) .dep-port-diamond { fill: #e2e8f0; stroke: #94a3b8; }
:host([data-theme="light"]) .project-frame-rect { stroke: #8b5cf644; }
:host([data-theme="light"]) .pool-detail-woven { background: rgba(139,92,246,0.08); }
/* Hex hover stroke */
.hex-hover-stroke { transition: stroke-width 0.15s; }
@ -3711,13 +4239,16 @@ const CSS_TEXT = `
.exec-step-checklist input[type="checkbox"] { accent-color: #8b5cf6; }
@media (max-width: 768px) {
.pool-panel { width: 200px; }
#canvas-view { flex-direction: column; }
.pool-panel { width: 100% !important; max-height: 220px; min-width: unset; border-right: none; border-bottom: 1px solid #334155; }
.panel-divider { display: none !important; }
.pool-hint { display: none; }
.exec-panel { width: 95vw; }
.task-edit-panel { width: 95vw; }
}
@media (max-width: 640px) {
.pool-panel { width: 180px; }
.pool-panel.collapsed { width: 36px; }
.pool-panel { max-height: 180px; }
.pool-panel.collapsed { width: 100% !important; max-height: 36px; }
}
`;

View File

@ -62,6 +62,20 @@ export interface Connection {
skill: string;
hours: number; // hours allocated in this connection
status: 'proposed' | 'committed'; // approval state
connectionType?: 'commitment' | 'dependency'; // commitment = resource flow, dependency = structural sequence
}
// ── Project Frame ──
export interface ProjectFrame {
id: string;
title: string;
taskIds: string[];
color?: string; // optional skill-color override
x: number;
y: number;
w: number;
h: number;
}
export interface ExecState {
@ -95,6 +109,7 @@ export interface TasksDoc {
tasks: Record<string, Task>;
connections: Record<string, Connection>;
execStates: Record<string, ExecState>;
projectFrames: Record<string, ProjectFrame>;
}
// ── External Time Log (backlog-md integration) ──
@ -174,6 +189,7 @@ export const tasksSchema: DocSchema<TasksDoc> = {
tasks: {},
connections: {},
execStates: {},
projectFrames: {},
}),
};