/** * 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`