/** * folk-gov-project — Circuit Aggregator * * Traverses the arrow graph backward from itself to discover all upstream * governance gates. Shows "X of Y gates satisfied" with a progress bar * and requirement checklist. Auto-detects completion. */ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import type { PortDescriptor } from "./data-types"; const HEADER_COLOR = "#1d4ed8"; const GOV_TAG_NAMES = new Set([ "FOLK-GOV-BINARY", "FOLK-GOV-THRESHOLD", "FOLK-GOV-KNOB", "FOLK-GOV-AMENDMENT", ]); type ProjectStatus = "draft" | "active" | "completed" | "archived"; interface GateInfo { id: string; tagName: string; title: string; satisfied: boolean; } const styles = css` :host { background: var(--rs-bg-surface, #1e293b); border-radius: 10px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.25); min-width: 280px; min-height: 180px; overflow: hidden; } .header { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: ${HEADER_COLOR}; color: white; font-size: 12px; font-weight: 600; cursor: move; border-radius: 10px 10px 0 0; } .header-title { display: flex; align-items: center; gap: 6px; } .header-actions button { background: transparent; border: none; color: white; cursor: pointer; padding: 2px 6px; border-radius: 4px; font-size: 14px; } .header-actions button:hover { background: rgba(255, 255, 255, 0.2); } .body { display: flex; flex-direction: column; padding: 12px; gap: 8px; overflow-y: auto; max-height: calc(100% - 36px); } .title-input { background: transparent; border: none; color: var(--rs-text-primary, #e2e8f0); font-size: 14px; font-weight: 700; width: 100%; outline: none; } .title-input::placeholder { color: var(--rs-text-muted, #64748b); } .desc-input { background: transparent; border: none; color: var(--rs-text-secondary, #cbd5e1); font-size: 11px; width: 100%; outline: none; resize: none; min-height: 24px; } .desc-input::placeholder { color: var(--rs-text-muted, #475569); } .status-select { background: rgba(255, 255, 255, 0.06); border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 4px; color: var(--rs-text-primary, #e2e8f0); font-size: 11px; padding: 3px 6px; outline: none; width: fit-content; } .progress-section { margin-top: 4px; } .progress-summary { display: flex; justify-content: space-between; font-size: 12px; font-weight: 600; color: var(--rs-text-primary, #e2e8f0); margin-bottom: 4px; } .progress-wrap { position: relative; height: 16px; background: rgba(255, 255, 255, 0.08); border-radius: 8px; overflow: hidden; } .progress-bar { height: 100%; border-radius: 8px; transition: width 0.3s, background 0.3s; background: ${HEADER_COLOR}; } .progress-bar.complete { background: #22c55e; box-shadow: 0 0 8px rgba(34, 197, 94, 0.4); } .checklist { display: flex; flex-direction: column; gap: 3px; margin-top: 6px; } .check-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--rs-text-secondary, #94a3b8); padding: 3px 6px; border-radius: 4px; background: rgba(255, 255, 255, 0.03); } .check-item.satisfied { color: #22c55e; } .check-icon { width: 14px; text-align: center; font-size: 10px; } .no-gates { font-size: 11px; color: var(--rs-text-muted, #475569); font-style: italic; text-align: center; padding: 12px 0; } `; declare global { interface HTMLElementTagNameMap { "folk-gov-project": FolkGovProject; } } export class FolkGovProject extends FolkShape { static override tagName = "folk-gov-project"; static override portDescriptors: PortDescriptor[] = [ { name: "circuit-out", type: "json", direction: "output" }, ]; static { const sheet = new CSSStyleSheet(); const parentRules = Array.from(FolkShape.styles.cssRules).map(r => r.cssText).join("\n"); const childRules = Array.from(styles.cssRules).map(r => r.cssText).join("\n"); sheet.replaceSync(`${parentRules}\n${childRules}`); this.styles = sheet; } #title = "Project"; #description = ""; #status: ProjectStatus = "draft"; #pollInterval: ReturnType | null = null; // DOM refs #titleEl!: HTMLInputElement; #descEl!: HTMLTextAreaElement; #statusEl!: HTMLSelectElement; #summaryEl!: HTMLElement; #progressBar!: HTMLElement; #checklistEl!: HTMLElement; get title() { return this.#title; } set title(v: string) { this.#title = v; if (this.#titleEl) this.#titleEl.value = v; } get description() { return this.#description; } set description(v: string) { this.#description = v; if (this.#descEl) this.#descEl.value = v; } get status(): ProjectStatus { return this.#status; } set status(v: ProjectStatus) { this.#status = v; if (this.#statusEl) this.#statusEl.value = v; } override createRenderRoot() { const root = super.createRenderRoot(); this.initPorts(); const wrapper = document.createElement("div"); wrapper.style.cssText = "width:100%;height:100%;display:flex;flex-direction:column;"; wrapper.innerHTML = html`
🏗️ Project
0 of 0 gates
`; const slot = root.querySelector("slot"); const container = slot?.parentElement as HTMLElement; if (container) container.replaceWith(wrapper); // Cache refs this.#titleEl = wrapper.querySelector(".title-input") as HTMLInputElement; this.#descEl = wrapper.querySelector(".desc-input") as HTMLTextAreaElement; this.#statusEl = wrapper.querySelector(".status-select") as HTMLSelectElement; this.#summaryEl = wrapper.querySelector(".summary-text") as HTMLElement; this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; this.#checklistEl = wrapper.querySelector(".checklist") as HTMLElement; // Set initial values this.#titleEl.value = this.#title; this.#descEl.value = this.#description; this.#statusEl.value = this.#status; // Wire events const onChange = () => this.dispatchEvent(new CustomEvent("content-change")); this.#titleEl.addEventListener("input", (e) => { e.stopPropagation(); this.#title = this.#titleEl.value; onChange(); }); this.#descEl.addEventListener("input", (e) => { e.stopPropagation(); this.#description = this.#descEl.value; onChange(); }); this.#statusEl.addEventListener("change", (e) => { e.stopPropagation(); this.#status = this.#statusEl.value as ProjectStatus; onChange(); }); wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { e.stopPropagation(); this.dispatchEvent(new CustomEvent("close")); }); // Prevent drag on inputs for (const el of wrapper.querySelectorAll("input, textarea, select, button")) { el.addEventListener("pointerdown", (e) => e.stopPropagation()); } // Poll upstream gates every 2 seconds (pull-based) this.#pollInterval = setInterval(() => this.#scanUpstreamGates(), 2000); // Also scan immediately requestAnimationFrame(() => this.#scanUpstreamGates()); return root; } override disconnectedCallback() { super.disconnectedCallback(); if (this.#pollInterval) { clearInterval(this.#pollInterval); this.#pollInterval = null; } } /** * Walk the arrow graph backward from this shape to find all upstream * governance gates. Returns GateInfo[] for each discovered gate. */ #scanUpstreamGates(): void { const gates: GateInfo[] = []; const visited = new Set(); const queue: string[] = [this.id]; // Find all arrows in the document const arrows = document.querySelectorAll("folk-arrow"); while (queue.length > 0) { const currentId = queue.shift()!; if (visited.has(currentId)) continue; visited.add(currentId); // Find arrows targeting this shape for (const arrow of arrows) { const a = arrow as any; if (a.targetId === currentId) { const sourceId = a.sourceId; if (!sourceId || visited.has(sourceId)) continue; const sourceEl = document.getElementById(sourceId) as any; if (!sourceEl) continue; const tagName = sourceEl.tagName?.toUpperCase(); if (GOV_TAG_NAMES.has(tagName)) { const portVal = sourceEl.getPortValue?.("gate-out"); gates.push({ id: sourceId, tagName, title: sourceEl.title || sourceEl.getAttribute?.("title") || tagName, satisfied: portVal?.satisfied === true, }); } queue.push(sourceId); } } } this.#renderGates(gates); } #renderGates(gates: GateInfo[]) { const total = gates.length; const completed = gates.filter(g => g.satisfied).length; const pct = total > 0 ? (completed / total) * 100 : 0; const allDone = total > 0 && completed === total; // Auto-detect completion if (allDone && this.#status === "active") { this.#status = "completed"; if (this.#statusEl) this.#statusEl.value = "completed"; this.dispatchEvent(new CustomEvent("content-change")); } if (this.#summaryEl) { this.#summaryEl.textContent = `${completed} of ${total} gates`; } if (this.#progressBar) { this.#progressBar.style.width = `${pct}%`; this.#progressBar.classList.toggle("complete", allDone); } if (this.#checklistEl) { if (total === 0) { this.#checklistEl.innerHTML = `
Connect gov gates upstream to track progress
`; } else { this.#checklistEl.innerHTML = gates.map(g => { const icon = g.satisfied ? "✓" : "○"; const cls = g.satisfied ? "check-item satisfied" : "check-item"; const typeLabel = g.tagName.replace("FOLK-GOV-", "").toLowerCase(); return `
${icon}${g.title} (${typeLabel})
`; }).join(""); } } // Emit port this.setPortValue("circuit-out", { status: this.#status, completedGates: completed, totalGates: total, percentage: pct, }); } override toJSON() { return { ...super.toJSON(), type: "folk-gov-project", title: this.#title, description: this.#description, status: this.#status, }; } static override fromData(data: Record): FolkGovProject { const shape = FolkShape.fromData.call(this, data) as FolkGovProject; if (data.title !== undefined) shape.title = data.title; if (data.description !== undefined) shape.description = data.description; if (data.status !== undefined) shape.status = data.status; return shape; } override applyData(data: Record): void { super.applyData(data); if (data.title !== undefined && data.title !== this.#title) this.title = data.title; if (data.description !== undefined && data.description !== this.#description) this.description = data.description; if (data.status !== undefined && data.status !== this.#status) this.status = data.status; } }