457 lines
12 KiB
TypeScript
457 lines
12 KiB
TypeScript
/**
|
|
* 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<typeof setInterval> | 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`
|
|
<div class="header" data-drag>
|
|
<span class="header-title">🏗️ Project</span>
|
|
<span class="header-actions">
|
|
<button class="close-btn">×</button>
|
|
</span>
|
|
</div>
|
|
<div class="body">
|
|
<input class="title-input" type="text" placeholder="Project title..." />
|
|
<textarea class="desc-input" placeholder="Description..." rows="2"></textarea>
|
|
<select class="status-select">
|
|
<option value="draft">Draft</option>
|
|
<option value="active">Active</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="archived">Archived</option>
|
|
</select>
|
|
<div class="progress-section">
|
|
<div class="progress-summary">
|
|
<span class="summary-text">0 of 0 gates</span>
|
|
</div>
|
|
<div class="progress-wrap">
|
|
<div class="progress-bar" style="width: 0%"></div>
|
|
</div>
|
|
<div class="checklist"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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<string>();
|
|
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 = `<div class="no-gates">Connect gov gates upstream to track progress</div>`;
|
|
} 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 `<div class="${cls}"><span class="check-icon">${icon}</span><span>${g.title} <small>(${typeLabel})</small></span></div>`;
|
|
}).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<string, any>): 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<string, any>): 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;
|
|
}
|
|
}
|