/** * folk-gov-threshold — Numeric Progress Gate * * Tracks contributions toward a target value. Shows a progress bar, * turns green when target is met. Accepts contributions via input port * or direct UI. Target can be dynamically set via knob input port. */ import { FolkShape } from "./folk-shape"; import { css, html } from "./tags"; import type { PortDescriptor } from "./data-types"; const HEADER_COLOR = "#0891b2"; interface Contribution { who: string; amount: number; timestamp: number; } 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: 220px; min-height: 100px; 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; } .title-input { background: transparent; border: none; color: var(--rs-text-primary, #e2e8f0); font-size: 13px; font-weight: 600; width: 100%; outline: none; } .title-input::placeholder { color: var(--rs-text-muted, #64748b); } .target-row { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--rs-text-muted, #94a3b8); } .target-input, .unit-input { 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: 2px 6px; outline: none; } .target-input { width: 60px; text-align: right; } .unit-input { width: 50px; } .progress-wrap { position: relative; height: 20px; background: rgba(255, 255, 255, 0.08); border-radius: 10px; overflow: hidden; } .progress-bar { height: 100%; border-radius: 10px; 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); } .progress-label { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 600; color: white; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); } .contribute-row { display: flex; gap: 4px; } .contrib-name, .contrib-amount { 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; } .contrib-name { flex: 1; } .contrib-amount { width: 50px; text-align: right; } .contrib-btn { background: ${HEADER_COLOR}; border: none; color: white; border-radius: 4px; padding: 3px 8px; font-size: 11px; cursor: pointer; font-weight: 600; } .contrib-btn:hover { opacity: 0.85; } .contributions-list { max-height: 80px; overflow-y: auto; font-size: 10px; color: var(--rs-text-muted, #94a3b8); } .contrib-item { display: flex; justify-content: space-between; padding: 2px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } .status-label { font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.5px; text-align: center; } .status-label.satisfied { color: #22c55e; } .status-label.waiting { color: #f59e0b; } `; declare global { interface HTMLElementTagNameMap { "folk-gov-threshold": FolkGovThreshold; } } export class FolkGovThreshold extends FolkShape { static override tagName = "folk-gov-threshold"; static override portDescriptors: PortDescriptor[] = [ { name: "contribution-in", type: "json", direction: "input" }, { name: "target-in", type: "number", direction: "input" }, { name: "gate-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 = "Threshold"; #target = 100; #unit = "$"; #contributions: Contribution[] = []; // DOM refs #titleEl!: HTMLInputElement; #targetEl!: HTMLInputElement; #unitEl!: HTMLInputElement; #progressBar!: HTMLElement; #progressLabel!: HTMLElement; #contribList!: HTMLElement; #statusEl!: HTMLElement; #contribNameEl!: HTMLInputElement; #contribAmountEl!: HTMLInputElement; get title() { return this.#title; } set title(v: string) { this.#title = v; if (this.#titleEl) this.#titleEl.value = v; } get target() { return this.#target; } set target(v: number) { this.#target = v; if (this.#targetEl) this.#targetEl.value = String(v); this.#updateVisuals(); this.#emitPort(); } get unit() { return this.#unit; } set unit(v: string) { this.#unit = v; if (this.#unitEl) this.#unitEl.value = v; this.#updateVisuals(); } get contributions(): Contribution[] { return [...this.#contributions]; } set contributions(v: Contribution[]) { this.#contributions = v; this.#updateVisuals(); this.#emitPort(); } get #current(): number { return this.#contributions.reduce((sum, c) => sum + c.amount, 0); } get #percentage(): number { return this.#target > 0 ? Math.min(100, (this.#current / this.#target) * 100) : 0; } get #isSatisfied(): boolean { return this.#current >= this.#target; } 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`
📊 Threshold
Target:
0 / 100
WAITING
`; 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.#targetEl = wrapper.querySelector(".target-input") as HTMLInputElement; this.#unitEl = wrapper.querySelector(".unit-input") as HTMLInputElement; this.#progressBar = wrapper.querySelector(".progress-bar") as HTMLElement; this.#progressLabel = wrapper.querySelector(".progress-label") as HTMLElement; this.#contribList = wrapper.querySelector(".contributions-list") as HTMLElement; this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement; this.#contribNameEl = wrapper.querySelector(".contrib-name") as HTMLInputElement; this.#contribAmountEl = wrapper.querySelector(".contrib-amount") as HTMLInputElement; // Set initial values this.#titleEl.value = this.#title; this.#targetEl.value = String(this.#target); this.#unitEl.value = this.#unit; this.#updateVisuals(); // Wire events this.#titleEl.addEventListener("input", (e) => { e.stopPropagation(); this.#title = this.#titleEl.value; this.dispatchEvent(new CustomEvent("content-change")); }); this.#targetEl.addEventListener("input", (e) => { e.stopPropagation(); this.#target = parseFloat(this.#targetEl.value) || 0; this.#updateVisuals(); this.#emitPort(); this.dispatchEvent(new CustomEvent("content-change")); }); this.#unitEl.addEventListener("input", (e) => { e.stopPropagation(); this.#unit = this.#unitEl.value; this.#updateVisuals(); this.dispatchEvent(new CustomEvent("content-change")); }); wrapper.querySelector(".contrib-btn")!.addEventListener("click", (e) => { e.stopPropagation(); const who = this.#contribNameEl.value.trim() || "anonymous"; const amount = parseFloat(this.#contribAmountEl.value) || 0; if (amount <= 0) return; this.#contributions.push({ who, amount, timestamp: Date.now() }); this.#contribNameEl.value = ""; this.#contribAmountEl.value = ""; this.#updateVisuals(); this.#emitPort(); this.dispatchEvent(new CustomEvent("content-change")); }); 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, button")) { el.addEventListener("pointerdown", (e) => e.stopPropagation()); } // Handle input ports this.addEventListener("port-value-changed", ((e: CustomEvent) => { const { name, value } = e.detail; if (name === "contribution-in" && value && typeof value === "object") { const c = value as any; this.#contributions.push({ who: c.who || c.memberName || "anonymous", amount: c.amount || c.hours || 0, timestamp: c.timestamp || Date.now(), }); this.#updateVisuals(); this.#emitPort(); this.dispatchEvent(new CustomEvent("content-change")); } if (name === "target-in" && typeof value === "number") { this.#target = value; if (this.#targetEl) this.#targetEl.value = String(value); this.#updateVisuals(); this.#emitPort(); this.dispatchEvent(new CustomEvent("content-change")); } }) as EventListener); return root; } #updateVisuals() { if (!this.#progressBar) return; const pct = this.#percentage; const current = this.#current; const satisfied = this.#isSatisfied; this.#progressBar.style.width = `${pct}%`; this.#progressBar.classList.toggle("complete", satisfied); this.#progressLabel.textContent = `${current} / ${this.#target} ${this.#unit}`; if (this.#statusEl) { this.#statusEl.textContent = satisfied ? "SATISFIED" : "WAITING"; this.#statusEl.className = `status-label ${satisfied ? "satisfied" : "waiting"}`; } // Render contributions list if (this.#contribList) { this.#contribList.innerHTML = this.#contributions.map(c => `
${c.who}${c.amount} ${this.#unit}
` ).join(""); } } #emitPort() { this.setPortValue("gate-out", { satisfied: this.#isSatisfied, current: this.#current, target: this.#target, percentage: this.#percentage, }); } override toJSON() { return { ...super.toJSON(), type: "folk-gov-threshold", title: this.#title, target: this.#target, unit: this.#unit, contributions: this.#contributions, }; } static override fromData(data: Record): FolkGovThreshold { const shape = FolkShape.fromData.call(this, data) as FolkGovThreshold; if (data.title !== undefined) shape.title = data.title; if (data.target !== undefined) shape.target = data.target; if (data.unit !== undefined) shape.unit = data.unit; if (data.contributions !== undefined) shape.contributions = data.contributions; return shape; } override applyData(data: Record): void { super.applyData(data); if (data.title !== undefined && data.title !== this.#title) this.title = data.title; if (data.target !== undefined && data.target !== this.#target) this.target = data.target; if (data.unit !== undefined && data.unit !== this.#unit) this.unit = data.unit; if (data.contributions !== undefined) this.contributions = data.contributions; } }