Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-15 16:54:52 -07:00
commit 340e1ee05b
1 changed files with 259 additions and 127 deletions

View File

@ -96,11 +96,12 @@ class FolkGraphViewer extends HTMLElement {
private authority: AuthoritySelection = "gov-ops";
private layoutMode: "force" | "rings" = "force";
private ringGuides: any[] = [];
private delegationTarget: GraphNode | null = null;
private delegationTotal = 50;
private delegationSplit = { "gov-ops": 34, "fin-ops": 33, "dev-ops": 33 };
private demoDelegations: GraphEdge[] = [];
// Multi-select delegation state
private selectedDelegates: Map<string, { node: GraphNode; weights: Record<string, number> }> = new Map();
private delegateSearchQuery = "";
// 3D graph instance
private graph: any = null;
private graphContainer: HTMLDivElement | null = null;
@ -458,36 +459,60 @@ class FolkGraphViewer extends HTMLElement {
}
.node-label-org { font-size: 11px; font-weight: 600; }
.btn-delegate {
padding: 6px 14px; border: 1px solid #a78bfa; border-radius: 8px;
background: rgba(167,139,250,0.1); color: #a78bfa; cursor: pointer;
font-size: 12px; font-weight: 600; margin-top: 8px;
}
.btn-delegate:hover { background: rgba(167,139,250,0.2); }
.deleg-popup {
/* ── Delegation panel (bottom drawer) ── */
.deleg-panel {
display: none; background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 12px; padding: 16px; margin-top: 12px; position: relative;
border-radius: 12px; padding: 12px 16px; margin-top: 8px;
}
.deleg-popup.visible { display: block; }
.deleg-popup-title { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
.deleg-popup-close {
position: absolute; top: 10px; right: 12px;
background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 16px;
.deleg-panel.visible { display: block; }
.deleg-panel-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 8px;
}
.deleg-slider-row {
display: flex; align-items: center; gap: 10px; margin: 8px 0;
.deleg-panel-title { font-size: 13px; font-weight: 600; flex: 1; }
.deleg-panel-close {
background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 14px;
}
.deleg-slider-label { font-size: 12px; font-weight: 500; min-width: 50px; }
.deleg-slider { flex: 1; accent-color: #a78bfa; }
.deleg-slider-val { font-size: 12px; font-weight: 700; min-width: 36px; text-align: right; }
.deleg-confirm {
.deleg-search-wrap { position: relative; margin-bottom: 8px; }
.deleg-search {
width: 100%; padding: 6px 10px; border: 1px solid var(--rs-input-border);
border-radius: 8px; background: var(--rs-input-bg); color: var(--rs-input-text);
font-size: 12px; outline: none;
}
.deleg-search:focus { border-color: #a78bfa; }
.deleg-results {
position: absolute; top: 100%; left: 0; right: 0; z-index: 20;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 8px; max-height: 160px; overflow-y: auto; margin-top: 2px;
}
.deleg-result-item {
padding: 6px 10px; cursor: pointer; font-size: 12px;
display: flex; align-items: center; gap: 6px;
}
.deleg-result-item:hover { background: var(--rs-bg-hover, rgba(255,255,255,0.05)); }
.deleg-result-role { font-size: 10px; color: var(--rs-text-muted); margin-left: auto; }
.deleg-row {
display: flex; align-items: center; gap: 6px; padding: 6px 0;
border-bottom: 1px solid var(--rs-border, rgba(255,255,255,0.06));
}
.deleg-row:last-child { border-bottom: none; }
.deleg-row-name { font-size: 12px; font-weight: 500; min-width: 90px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.deleg-row-sliders { display: flex; gap: 4px; flex: 1; align-items: center; }
.deleg-mini-slider { width: 60px; height: 4px; accent-color: #a78bfa; }
.deleg-mini-val { font-size: 10px; font-weight: 700; min-width: 24px; text-align: right; }
.deleg-row-remove {
background: none; border: none; color: var(--rs-text-muted); cursor: pointer;
font-size: 12px; padding: 2px 4px; border-radius: 4px;
}
.deleg-row-remove:hover { color: #ef4444; background: rgba(239,68,68,0.1); }
.deleg-confirm-all {
padding: 6px 16px; border: none; border-radius: 8px;
background: #a78bfa; color: #fff; cursor: pointer;
font-size: 13px; font-weight: 600; margin-top: 10px;
font-size: 12px; font-weight: 600; margin-top: 8px; width: 100%;
}
.deleg-confirm:hover { background: #8b5cf6; }
.deleg-step-label { font-size: 11px; color: var(--rs-text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.deleg-confirm-all:hover { background: #8b5cf6; }
.deleg-remaining { font-size: 11px; color: var(--rs-text-muted); margin-top: 6px; }
.deleg-auth-label { font-size: 9px; font-weight: 600; min-width: 30px; text-align: center; }
@media (max-width: 768px) {
.graph-canvas { min-height: 300px; }
@ -525,7 +550,7 @@ class FolkGraphViewer extends HTMLElement {
</div>
<div class="detail-panel" id="detail-panel"></div>
<div class="deleg-popup" id="deleg-popup"></div>
<div class="deleg-panel" id="deleg-panel"></div>
<div class="legend" id="legend">
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
@ -730,13 +755,26 @@ class FolkGraphViewer extends HTMLElement {
return "#c4b5fd";
})
.onNodeClick((node: GraphNode) => {
const canDelegate = node.type === "rspace_user" || node.type === "person";
// Toggle detail panel for inspection
if (this.selectedNode?.id === node.id) {
this.selectedNode = null;
} else {
this.selectedNode = node;
}
this.updateDetailPanel();
this.updateGraphData(); // refresh highlight
// Add to delegation selection if delegatable
if (canDelegate && !this.selectedDelegates.has(node.id)) {
this.selectedDelegates.set(node.id, {
node,
weights: { "gov-ops": 10, "fin-ops": 10, "dev-ops": 10 },
});
this.renderDelegationPanel();
}
this.updateGraphData();
})
.d3AlphaDecay(0.02)
.d3VelocityDecay(0.3)
@ -1161,8 +1199,6 @@ class FolkGraphViewer extends HTMLElement {
}).join("");
}
const canDelegate = n.type === "rspace_user" || n.type === "person";
panel.classList.add("visible");
panel.innerHTML = `
<div class="detail-header">
@ -1176,126 +1212,207 @@ class FolkGraphViewer extends HTMLElement {
${n.description ? `<p class="detail-desc">${this.esc(n.description)}</p>` : ""}
${trust >= 0 ? `<div class="detail-trust"><span class="trust-label">Trust Score</span><span class="trust-bar"><span class="trust-fill" style="width:${trust}%"></span></span><span class="trust-val">${trust}</span></div>` : ""}
${weightHtml}
${canDelegate ? `<button class="btn-delegate" id="btn-delegate">Delegate to ${this.esc(n.name.split(" ")[0])}</button>` : ""}
${connected.length > 0 ? `
<div class="detail-section">Connected (${connected.length})</div>
${connected.map(c => `<div class="detail-conn"><span class="conn-dot" style="background:${c.type === "company" ? "#22c55e" : "#3b82f6"}"></span>${this.esc(c.name)}<span class="conn-role">${this.esc(c.role || c.type)}</span></div>`).join("")}
` : ""}
`;
// Delegate button listener
this.shadow.getElementById("btn-delegate")?.addEventListener("click", () => {
this.delegationTarget = n;
this.delegationTotal = 50;
this.delegationSplit = { "gov-ops": 34, "fin-ops": 33, "dev-ops": 33 };
this.showDelegationPopup();
});
}
private showDelegationPopup() {
const popup = this.shadow.getElementById("deleg-popup");
if (!popup || !this.delegationTarget) return;
// ── Delegation panel (multi-select + fuzzy search) ──
const target = this.delegationTarget;
const total = this.delegationTotal;
const split = this.delegationSplit;
private renderDelegationPanel() {
const panel = this.shadow.getElementById("deleg-panel");
if (!panel) return;
popup.classList.add("visible");
popup.innerHTML = `
<button class="deleg-popup-close" id="deleg-close">\u2715</button>
<div class="deleg-popup-title">Delegate to ${this.esc(target.name)}</div>
if (this.selectedDelegates.size === 0) {
panel.classList.remove("visible");
panel.innerHTML = "";
return;
}
<div class="deleg-step-label">Step 1: Total weight</div>
<div class="deleg-slider-row">
<span class="deleg-slider-label">Total</span>
<input type="range" class="deleg-slider" id="deleg-total" min="0" max="100" value="${total}">
<span class="deleg-slider-val" id="deleg-total-val">${total}%</span>
// Compute remaining weight per authority
const spent: Record<string, number> = { "gov-ops": 0, "fin-ops": 0, "dev-ops": 0 };
this.selectedDelegates.forEach(({ weights }) => {
for (const a of DELEGATION_AUTHORITIES) spent[a] += weights[a] || 0;
});
panel.classList.add("visible");
const rows: string[] = [];
this.selectedDelegates.forEach(({ node, weights }, id) => {
rows.push(`
<div class="deleg-row" data-deleg-id="${id}">
<span class="deleg-row-name" title="${this.esc(node.name)}">${this.esc(node.name)}</span>
<div class="deleg-row-sliders">
${DELEGATION_AUTHORITIES.map(a => {
const disp = AUTHORITY_DISPLAY[a];
const w = weights[a] || 0;
return `<span class="deleg-auth-label" style="color:${disp?.color}">${disp?.label}</span>
<input type="range" class="deleg-mini-slider" data-did="${id}" data-auth="${a}" min="0" max="100" value="${w}" style="accent-color:${disp?.color}">
<span class="deleg-mini-val" data-did-val="${id}-${a}" style="color:${disp?.color}">${w}%</span>`;
}).join("")}
</div>
<button class="deleg-row-remove" data-remove="${id}" title="Remove">\u2715</button>
</div>
`);
});
panel.innerHTML = `
<div class="deleg-panel-header">
<span class="deleg-panel-title">Delegate Weight (${this.selectedDelegates.size} selected)</span>
<button class="deleg-panel-close" id="deleg-panel-close">\u2715</button>
</div>
<div class="deleg-step-label">Step 2: Domain split</div>
${DELEGATION_AUTHORITIES.map(a => {
const disp = AUTHORITY_DISPLAY[a];
const pct = split[a] || 0;
return `<div class="deleg-slider-row">
<span class="deleg-slider-label" style="color:${disp?.color || '#a78bfa'}">${disp?.label || a}</span>
<input type="range" class="deleg-slider" data-deleg-auth="${a}" min="0" max="100" value="${pct}" style="accent-color:${disp?.color || '#a78bfa'}">
<span class="deleg-slider-val" data-deleg-val="${a}">${pct}%</span>
</div>`;
}).join("")}
<div class="deleg-search-wrap">
<input class="deleg-search" type="text" placeholder="Search members to add..." id="deleg-search" value="${this.esc(this.delegateSearchQuery)}">
<div class="deleg-results" id="deleg-results" style="display:none"></div>
</div>
<button class="deleg-confirm" id="deleg-confirm">Confirm Delegation</button>
${rows.join("")}
<div class="deleg-remaining">${DELEGATION_AUTHORITIES.map(a => {
const disp = AUTHORITY_DISPLAY[a];
return `<span style="color:${disp?.color}">${disp?.label}: ${Math.max(0, 100 - spent[a])}% left</span>`;
}).join(" &middot; ")}</div>
<button class="deleg-confirm-all" id="deleg-confirm-all">Confirm All Delegations</button>
`;
this.attachDelegationListeners(panel);
}
private attachDelegationListeners(panel: HTMLElement) {
// Close
this.shadow.getElementById("deleg-close")?.addEventListener("click", () => {
this.delegationTarget = null;
popup.classList.remove("visible");
this.shadow.getElementById("deleg-panel-close")?.addEventListener("click", () => {
this.selectedDelegates.clear();
this.delegateSearchQuery = "";
this.renderDelegationPanel();
});
// Total slider
this.shadow.getElementById("deleg-total")?.addEventListener("input", (e) => {
this.delegationTotal = parseInt((e.target as HTMLInputElement).value);
const valEl = this.shadow.getElementById("deleg-total-val");
if (valEl) valEl.textContent = this.delegationTotal + "%";
// Remove buttons
panel.querySelectorAll("[data-remove]").forEach(el => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.remove!;
this.selectedDelegates.delete(id);
this.renderDelegationPanel();
});
});
// Domain sliders — adjust others proportionally
popup.querySelectorAll("[data-deleg-auth]").forEach(el => {
// Per-delegate per-authority sliders
panel.querySelectorAll("[data-did][data-auth]").forEach(el => {
el.addEventListener("input", (e) => {
const auth = (el as HTMLElement).dataset.delegAuth!;
const newVal = parseInt((e.target as HTMLInputElement).value);
const others = DELEGATION_AUTHORITIES.filter(a => a !== auth);
const oldOtherSum = others.reduce((s, a) => s + (this.delegationSplit[a] || 0), 0);
this.delegationSplit[auth] = newVal;
// Redistribute remaining to others proportionally
const remaining = 100 - newVal;
if (oldOtherSum > 0) {
for (const o of others) {
this.delegationSplit[o] = Math.round((this.delegationSplit[o] / oldOtherSum) * remaining);
}
} else {
const each = Math.round(remaining / others.length);
for (const o of others) this.delegationSplit[o] = each;
}
// Update UI
for (const a of DELEGATION_AUTHORITIES) {
const slider = popup.querySelector(`[data-deleg-auth="${a}"]`) as HTMLInputElement;
const val = popup.querySelector(`[data-deleg-val="${a}"]`);
if (slider) slider.value = String(this.delegationSplit[a]);
if (val) val.textContent = this.delegationSplit[a] + "%";
const id = (el as HTMLElement).dataset.did!;
const auth = (el as HTMLElement).dataset.auth!;
const val = parseInt((e.target as HTMLInputElement).value);
const entry = this.selectedDelegates.get(id);
if (entry) {
entry.weights[auth] = val;
const valEl = panel.querySelector(`[data-did-val="${id}-${auth}"]`);
if (valEl) valEl.textContent = val + "%";
// Update remaining display
this.updateRemainingDisplay(panel);
}
});
});
// Confirm
this.shadow.getElementById("deleg-confirm")?.addEventListener("click", () => {
this.confirmDelegation();
// Fuzzy search
const searchInput = this.shadow.getElementById("deleg-search") as HTMLInputElement | null;
const resultsDiv = this.shadow.getElementById("deleg-results");
let searchTimeout: any;
searchInput?.addEventListener("input", () => {
this.delegateSearchQuery = searchInput.value;
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
this.updateSearchResults(searchInput.value, resultsDiv);
}, 150);
});
searchInput?.addEventListener("focus", () => {
if (searchInput.value.trim()) {
this.updateSearchResults(searchInput.value, resultsDiv);
}
});
// Confirm all
this.shadow.getElementById("deleg-confirm-all")?.addEventListener("click", () => {
this.confirmAllDelegations();
});
}
private confirmDelegation() {
if (!this.delegationTarget) return;
const target = this.delegationTarget;
const totalWeight = this.delegationTotal / 100;
// Create delegation edges for each authority
for (const a of DELEGATION_AUTHORITIES) {
const pct = this.delegationSplit[a] || 0;
const weight = Math.round(totalWeight * (pct / 100) * 100) / 100;
if (weight <= 0) continue;
const edge: GraphEdge = {
source: "me-demo",
target: target.id,
type: "delegates_to",
weight,
authority: a,
};
this.edges.push(edge);
this.demoDelegations.push(edge);
private updateRemainingDisplay(panel: HTMLElement) {
const spent: Record<string, number> = { "gov-ops": 0, "fin-ops": 0, "dev-ops": 0 };
this.selectedDelegates.forEach(({ weights }) => {
for (const a of DELEGATION_AUTHORITIES) spent[a] += weights[a] || 0;
});
const remainEl = panel.querySelector(".deleg-remaining");
if (remainEl) {
remainEl.innerHTML = DELEGATION_AUTHORITIES.map(a => {
const disp = AUTHORITY_DISPLAY[a];
const left = Math.max(0, 100 - spent[a]);
return `<span style="color:${disp?.color}${left === 0 ? ';opacity:0.5' : ''}">${disp?.label}: ${left}% left</span>`;
}).join(" &middot; ");
}
}
private fuzzyMatch(query: string, name: string): boolean {
const q = query.toLowerCase();
const n = name.toLowerCase();
// Substring match
if (n.includes(q)) return true;
// Fuzzy: all query chars appear in order
let qi = 0;
for (let i = 0; i < n.length && qi < q.length; i++) {
if (n[i] === q[qi]) qi++;
}
return qi === q.length;
}
private updateSearchResults(query: string, resultsDiv: HTMLElement | null) {
if (!resultsDiv) return;
const q = query.trim();
if (!q) {
resultsDiv.style.display = "none";
return;
}
const matches = this.nodes.filter(n =>
(n.type === "rspace_user" || n.type === "person") &&
!this.selectedDelegates.has(n.id) &&
this.fuzzyMatch(q, n.name)
).slice(0, 8);
if (matches.length === 0) {
resultsDiv.style.display = "none";
return;
}
resultsDiv.style.display = "block";
resultsDiv.innerHTML = matches.map(n =>
`<div class="deleg-result-item" data-add-id="${n.id}">
<span>${this.esc(n.name)}</span>
<span class="deleg-result-role">${n.role || n.type}</span>
</div>`
).join("");
resultsDiv.querySelectorAll("[data-add-id]").forEach(el => {
el.addEventListener("click", () => {
const id = (el as HTMLElement).dataset.addId!;
const node = this.nodes.find(n => n.id === id);
if (node) {
this.selectedDelegates.set(id, {
node,
weights: { "gov-ops": 10, "fin-ops": 10, "dev-ops": 10 },
});
this.delegateSearchQuery = "";
this.renderDelegationPanel();
}
});
});
}
private confirmAllDelegations() {
if (this.selectedDelegates.size === 0) return;
// Ensure "me-demo" node exists
if (!this.nodes.find(n => n.id === "me-demo")) {
@ -1308,13 +1425,28 @@ class FolkGraphViewer extends HTMLElement {
});
}
// Recompute weight accounting
this.recomputeWeightAccounting();
// Create delegation edges for each selected delegate
this.selectedDelegates.forEach(({ node, weights }) => {
for (const a of DELEGATION_AUTHORITIES) {
const weight = Math.round((weights[a] || 0) / 100 * 100) / 100;
if (weight <= 0) continue;
const edge: GraphEdge = {
source: "me-demo",
target: node.id,
type: "delegates_to",
weight,
authority: a,
};
this.edges.push(edge);
this.demoDelegations.push(edge);
}
});
// Close popup and refresh
this.delegationTarget = null;
const popup = this.shadow.getElementById("deleg-popup");
if (popup) popup.classList.remove("visible");
// Recompute weight accounting + refresh
this.recomputeWeightAccounting();
this.selectedDelegates.clear();
this.delegateSearchQuery = "";
this.renderDelegationPanel();
this.updateGraphData();
}