rspace-online/lib/folk-gov-project.ts

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">&times;</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;
}
}