Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m36s
Details
CI/CD / deploy (push) Successful in 2m36s
Details
This commit is contained in:
commit
614a9fd1cd
|
|
@ -489,6 +489,133 @@ registry.push({
|
|||
actionLabel: (args) => `Opened design agent${args.brief ? `: ${args.brief.slice(0, 50)}` : ""}`,
|
||||
});
|
||||
|
||||
// ── rGov Governance Circuit Tools ──
|
||||
registry.push(
|
||||
{
|
||||
declaration: {
|
||||
name: "create_binary_gate",
|
||||
description: "Create a Yes/No signoff gate on the canvas. Use when a decision requires someone's explicit approval or sign-off.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Title for the signoff gate (e.g. 'Proprietor Approval')" },
|
||||
assignee: { type: "string", description: "Who must sign off (leave empty for 'anyone')" },
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-gov-binary",
|
||||
moduleId: "rgov",
|
||||
buildProps: (args) => ({
|
||||
title: args.title,
|
||||
...(args.assignee ? { assignee: args.assignee } : {}),
|
||||
}),
|
||||
actionLabel: (args) => `Created binary gate: ${args.title}`,
|
||||
},
|
||||
{
|
||||
declaration: {
|
||||
name: "create_threshold",
|
||||
description: "Create a numeric threshold gate on the canvas. Use when a decision requires accumulating a target amount (hours, dollars, signatures, etc.).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Title for the threshold (e.g. 'Capital Required')" },
|
||||
target: { type: "number", description: "Target value to reach" },
|
||||
unit: { type: "string", description: "Unit of measurement (e.g. '$', 'hours', 'signatures')" },
|
||||
},
|
||||
required: ["title", "target"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-gov-threshold",
|
||||
moduleId: "rgov",
|
||||
buildProps: (args) => ({
|
||||
title: args.title,
|
||||
target: args.target,
|
||||
...(args.unit ? { unit: args.unit } : {}),
|
||||
}),
|
||||
actionLabel: (args) => `Created threshold: ${args.title} (${args.target} ${args.unit || ""})`,
|
||||
},
|
||||
{
|
||||
declaration: {
|
||||
name: "create_gov_knob",
|
||||
description: "Create an adjustable parameter knob on the canvas. Use when a governance parameter needs to be tunable (e.g. quorum percentage, budget cap).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Parameter name (e.g. 'Quorum %')" },
|
||||
min: { type: "number", description: "Minimum value" },
|
||||
max: { type: "number", description: "Maximum value" },
|
||||
value: { type: "number", description: "Initial value" },
|
||||
unit: { type: "string", description: "Unit label (e.g. '%', '$', 'hours')" },
|
||||
cooldown: { type: "number", description: "Cooldown in seconds before value propagates (0 for instant)" },
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-gov-knob",
|
||||
moduleId: "rgov",
|
||||
buildProps: (args) => ({
|
||||
title: args.title,
|
||||
...(args.min != null ? { min: args.min } : {}),
|
||||
...(args.max != null ? { max: args.max } : {}),
|
||||
...(args.value != null ? { value: args.value } : {}),
|
||||
...(args.unit ? { unit: args.unit } : {}),
|
||||
...(args.cooldown != null ? { cooldown: args.cooldown } : {}),
|
||||
}),
|
||||
actionLabel: (args) => `Created knob: ${args.title}`,
|
||||
},
|
||||
{
|
||||
declaration: {
|
||||
name: "create_gov_project",
|
||||
description: "Create a governance project aggregator on the canvas. It automatically tracks all upstream gates wired to it and shows overall completion progress.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Project title (e.g. 'Build a Climbing Wall')" },
|
||||
description: { type: "string", description: "Project description" },
|
||||
status: { type: "string", description: "Initial status", enum: ["draft", "active", "completed", "archived"] },
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-gov-project",
|
||||
moduleId: "rgov",
|
||||
buildProps: (args) => ({
|
||||
title: args.title,
|
||||
...(args.description ? { description: args.description } : {}),
|
||||
...(args.status ? { status: args.status } : {}),
|
||||
}),
|
||||
actionLabel: (args) => `Created project: ${args.title}`,
|
||||
},
|
||||
{
|
||||
declaration: {
|
||||
name: "create_amendment",
|
||||
description: "Create a governance amendment proposal on the canvas. An amendment proposes replacing one gate with another (e.g. converting a dollar threshold into a binary checkbox).",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
title: { type: "string", description: "Amendment title" },
|
||||
targetShapeId: { type: "string", description: "ID of the shape to modify" },
|
||||
replacementType: { type: "string", description: "Type of replacement shape (e.g. 'folk-gov-binary')" },
|
||||
approvalMode: { type: "string", description: "How approval works", enum: ["single", "majority", "unanimous"] },
|
||||
description: { type: "string", description: "Description of what the amendment changes" },
|
||||
},
|
||||
required: ["title"],
|
||||
},
|
||||
},
|
||||
tagName: "folk-gov-amendment",
|
||||
moduleId: "rgov",
|
||||
buildProps: (args) => ({
|
||||
title: args.title,
|
||||
...(args.targetShapeId ? { targetShapeId: args.targetShapeId } : {}),
|
||||
...(args.replacementType ? { replacementType: args.replacementType } : {}),
|
||||
...(args.approvalMode ? { approvalMode: args.approvalMode } : {}),
|
||||
...(args.description ? { description: args.description } : {}),
|
||||
}),
|
||||
actionLabel: (args) => `Created amendment: ${args.title}`,
|
||||
},
|
||||
);
|
||||
|
||||
export const CANVAS_TOOLS: CanvasToolDefinition[] = [...registry];
|
||||
|
||||
export const CANVAS_TOOL_DECLARATIONS = CANVAS_TOOLS.map((t) => t.declaration);
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ export type ArrowStyle = "smooth" | "straight" | "curved" | "sketchy";
|
|||
export interface ArrowGate {
|
||||
shapeId: string; // governance shape ID
|
||||
portName: string; // port to watch (e.g. "decision-out")
|
||||
condition: "truthy" | "passed" | "threshold";
|
||||
condition: "truthy" | "passed" | "threshold" | "satisfied";
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
|
|
@ -464,6 +464,8 @@ export class FolkArrow extends FolkElement {
|
|||
const v = value as any;
|
||||
const num = typeof v === "number" ? v : (v?.margin ?? v?.score ?? 0);
|
||||
this.#gateOpen = num >= (this.#gate.threshold ?? 0.5);
|
||||
} else if (this.#gate.condition === "satisfied") {
|
||||
this.#gateOpen = (value as any)?.satisfied === true;
|
||||
}
|
||||
|
||||
if (wasOpen !== this.#gateOpen) this.#updateArrow();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,710 @@
|
|||
/**
|
||||
* folk-gov-amendment — Circuit Modification Proposal
|
||||
*
|
||||
* References a target shape to modify, contains proposed replacement data.
|
||||
* Built-in approval mechanism (majority/unanimous/single) with inline voter list.
|
||||
* Shows a "Before → After" diff view. On approval, dispatches gov-amendment-apply
|
||||
* event to replace the target shape while preserving its ID.
|
||||
*/
|
||||
|
||||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
import type { PortDescriptor } from "./data-types";
|
||||
|
||||
const HEADER_COLOR = "#be185d";
|
||||
|
||||
type ApprovalMode = "single" | "majority" | "unanimous";
|
||||
|
||||
interface Vote {
|
||||
voter: string;
|
||||
approve: boolean;
|
||||
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: 280px;
|
||||
min-height: 200px;
|
||||
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: 13px;
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title-input::placeholder {
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.field-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: 3px 6px;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mode-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;
|
||||
}
|
||||
|
||||
.diff-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.diff-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.diff-box {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
color: var(--rs-text-secondary, #94a3b8);
|
||||
max-height: 60px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.diff-box.before {
|
||||
border-left: 3px solid #ef4444;
|
||||
}
|
||||
|
||||
.diff-box.after {
|
||||
border-left: 3px solid #22c55e;
|
||||
}
|
||||
|
||||
.diff-arrow {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--rs-text-muted, #475569);
|
||||
}
|
||||
|
||||
.desc-textarea {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 4px;
|
||||
color: var(--rs-text-secondary, #cbd5e1);
|
||||
font-size: 11px;
|
||||
padding: 6px 8px;
|
||||
outline: none;
|
||||
resize: none;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.desc-textarea::placeholder {
|
||||
color: var(--rs-text-muted, #475569);
|
||||
}
|
||||
|
||||
.voters-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.voter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.voter-name {
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
}
|
||||
|
||||
.vote-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.vote-badge.approve {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.vote-badge.reject {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.vote-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.vote-name-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: 3px 6px;
|
||||
outline: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.vote-btn {
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vote-btn.approve {
|
||||
background: #22c55e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vote-btn.reject {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.vote-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
background: ${HEADER_COLOR};
|
||||
border: none;
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.apply-btn:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.apply-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-badge.pending {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.status-badge.approved {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-badge.rejected {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-badge.applied {
|
||||
background: rgba(29, 78, 216, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-gov-amendment": FolkGovAmendment;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkGovAmendment extends FolkShape {
|
||||
static override tagName = "folk-gov-amendment";
|
||||
|
||||
static override portDescriptors: PortDescriptor[] = [
|
||||
{ name: "approval-in", type: "json", direction: "input" },
|
||||
{ name: "amendment-out", type: "json", direction: "output" },
|
||||
{ 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 = "Amendment";
|
||||
#targetShapeId = "";
|
||||
#replacementType = "folk-gov-binary"; // what to replace with
|
||||
#replacementData: Record<string, any> = {};
|
||||
#approvalMode: ApprovalMode = "single";
|
||||
#votes: Vote[] = [];
|
||||
#amendmentStatus: "pending" | "approved" | "rejected" | "applied" = "pending";
|
||||
#description = "";
|
||||
|
||||
// DOM refs
|
||||
#titleEl!: HTMLInputElement;
|
||||
#targetEl!: HTMLInputElement;
|
||||
#replTypeEl!: HTMLInputElement;
|
||||
#modeEl!: HTMLSelectElement;
|
||||
#descEl!: HTMLTextAreaElement;
|
||||
#votersEl!: HTMLElement;
|
||||
#statusEl!: HTMLElement;
|
||||
#applyBtn!: HTMLButtonElement;
|
||||
#beforeBox!: HTMLElement;
|
||||
#voteNameEl!: HTMLInputElement;
|
||||
|
||||
get title() { return this.#title; }
|
||||
set title(v: string) {
|
||||
this.#title = v;
|
||||
if (this.#titleEl) this.#titleEl.value = v;
|
||||
}
|
||||
|
||||
get targetShapeId() { return this.#targetShapeId; }
|
||||
set targetShapeId(v: string) {
|
||||
this.#targetShapeId = v;
|
||||
if (this.#targetEl) this.#targetEl.value = v;
|
||||
this.#updateDiff();
|
||||
}
|
||||
|
||||
get replacementType() { return this.#replacementType; }
|
||||
set replacementType(v: string) {
|
||||
this.#replacementType = v;
|
||||
if (this.#replTypeEl) this.#replTypeEl.value = v;
|
||||
}
|
||||
|
||||
get replacementData() { return this.#replacementData; }
|
||||
set replacementData(v: Record<string, any>) { this.#replacementData = v; }
|
||||
|
||||
get approvalMode(): ApprovalMode { return this.#approvalMode; }
|
||||
set approvalMode(v: ApprovalMode) {
|
||||
this.#approvalMode = v;
|
||||
if (this.#modeEl) this.#modeEl.value = v;
|
||||
}
|
||||
|
||||
get votes() { return [...this.#votes]; }
|
||||
set votes(v: Vote[]) {
|
||||
this.#votes = v;
|
||||
this.#renderVoters();
|
||||
this.#checkApproval();
|
||||
}
|
||||
|
||||
get amendmentStatus() { return this.#amendmentStatus; }
|
||||
set amendmentStatus(v: "pending" | "approved" | "rejected" | "applied") {
|
||||
this.#amendmentStatus = v;
|
||||
this.#updateStatusBadge();
|
||||
}
|
||||
|
||||
get description() { return this.#description; }
|
||||
set description(v: string) {
|
||||
this.#description = v;
|
||||
if (this.#descEl) this.#descEl.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">📝 Amendment</span>
|
||||
<span class="header-actions">
|
||||
<button class="close-btn">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<input class="title-input" type="text" placeholder="Amendment title..." />
|
||||
<div class="field-row">
|
||||
<span>Target:</span>
|
||||
<input class="field-input target-id" type="text" placeholder="shape ID to replace" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span>Replace with:</span>
|
||||
<input class="field-input repl-type" type="text" placeholder="folk-gov-binary" />
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<span>Approval:</span>
|
||||
<select class="mode-select">
|
||||
<option value="single">Single</option>
|
||||
<option value="majority">Majority</option>
|
||||
<option value="unanimous">Unanimous</option>
|
||||
</select>
|
||||
</div>
|
||||
<textarea class="desc-textarea" placeholder="Describe what this amendment changes..." rows="2"></textarea>
|
||||
<div class="diff-section">
|
||||
<span class="diff-label">Before</span>
|
||||
<div class="diff-box before"></div>
|
||||
<div class="diff-arrow">↓</div>
|
||||
<span class="diff-label">After</span>
|
||||
<div class="diff-box after"></div>
|
||||
</div>
|
||||
<div class="voters-section"></div>
|
||||
<div class="vote-actions">
|
||||
<input class="vote-name-input" type="text" placeholder="Your name" />
|
||||
<button class="vote-btn approve">✓</button>
|
||||
<button class="vote-btn reject">✗</button>
|
||||
</div>
|
||||
<span class="status-badge pending">PENDING</span>
|
||||
<button class="apply-btn" disabled>Apply Amendment</button>
|
||||
</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.#targetEl = wrapper.querySelector(".target-id") as HTMLInputElement;
|
||||
this.#replTypeEl = wrapper.querySelector(".repl-type") as HTMLInputElement;
|
||||
this.#modeEl = wrapper.querySelector(".mode-select") as HTMLSelectElement;
|
||||
this.#descEl = wrapper.querySelector(".desc-textarea") as HTMLTextAreaElement;
|
||||
this.#votersEl = wrapper.querySelector(".voters-section") as HTMLElement;
|
||||
this.#statusEl = wrapper.querySelector(".status-badge") as HTMLElement;
|
||||
this.#applyBtn = wrapper.querySelector(".apply-btn") as HTMLButtonElement;
|
||||
this.#beforeBox = wrapper.querySelector(".diff-box.before") as HTMLElement;
|
||||
this.#voteNameEl = wrapper.querySelector(".vote-name-input") as HTMLInputElement;
|
||||
const afterBox = wrapper.querySelector(".diff-box.after") as HTMLElement;
|
||||
|
||||
// Set initial values
|
||||
this.#titleEl.value = this.#title;
|
||||
this.#targetEl.value = this.#targetShapeId;
|
||||
this.#replTypeEl.value = this.#replacementType;
|
||||
this.#modeEl.value = this.#approvalMode;
|
||||
this.#descEl.value = this.#description;
|
||||
afterBox.textContent = this.#replacementType;
|
||||
this.#updateDiff();
|
||||
this.#renderVoters();
|
||||
this.#updateStatusBadge();
|
||||
|
||||
// Wire events
|
||||
const onChange = () => this.dispatchEvent(new CustomEvent("content-change"));
|
||||
|
||||
this.#titleEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#title = this.#titleEl.value;
|
||||
onChange();
|
||||
});
|
||||
|
||||
this.#targetEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#targetShapeId = this.#targetEl.value;
|
||||
this.#updateDiff();
|
||||
onChange();
|
||||
});
|
||||
|
||||
this.#replTypeEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#replacementType = this.#replTypeEl.value;
|
||||
afterBox.textContent = `type: ${this.#replacementType}`;
|
||||
onChange();
|
||||
});
|
||||
|
||||
this.#modeEl.addEventListener("change", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#approvalMode = this.#modeEl.value as ApprovalMode;
|
||||
this.#checkApproval();
|
||||
onChange();
|
||||
});
|
||||
|
||||
this.#descEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#description = this.#descEl.value;
|
||||
onChange();
|
||||
});
|
||||
|
||||
// Vote buttons
|
||||
wrapper.querySelector(".vote-btn.approve")!.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#castVote(true);
|
||||
onChange();
|
||||
});
|
||||
|
||||
wrapper.querySelector(".vote-btn.reject")!.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#castVote(false);
|
||||
onChange();
|
||||
});
|
||||
|
||||
// Apply button
|
||||
this.#applyBtn.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#applyAmendment();
|
||||
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());
|
||||
}
|
||||
|
||||
// Handle approval-in port
|
||||
this.addEventListener("port-value-changed", ((e: CustomEvent) => {
|
||||
const { name, value } = e.detail;
|
||||
if (name === "approval-in" && value && typeof value === "object") {
|
||||
const v = value as any;
|
||||
if (v.voter && v.approve !== undefined) {
|
||||
this.#votes.push({
|
||||
voter: v.voter,
|
||||
approve: !!v.approve,
|
||||
timestamp: v.timestamp || Date.now(),
|
||||
});
|
||||
this.#renderVoters();
|
||||
this.#checkApproval();
|
||||
onChange();
|
||||
}
|
||||
}
|
||||
}) as EventListener);
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
#updateDiff() {
|
||||
if (!this.#beforeBox) return;
|
||||
if (!this.#targetShapeId) {
|
||||
this.#beforeBox.textContent = "(no target selected)";
|
||||
return;
|
||||
}
|
||||
const target = document.getElementById(this.#targetShapeId) as any;
|
||||
if (!target) {
|
||||
this.#beforeBox.textContent = `(shape "${this.#targetShapeId}" not found)`;
|
||||
return;
|
||||
}
|
||||
const data = target.toJSON?.() || {};
|
||||
this.#beforeBox.textContent = `type: ${data.type || target.tagName}\ntitle: ${data.title || "—"}`;
|
||||
}
|
||||
|
||||
#castVote(approve: boolean) {
|
||||
const voter = this.#voteNameEl?.value.trim() || "anonymous";
|
||||
// Prevent duplicate votes from same voter
|
||||
const existing = this.#votes.findIndex(v => v.voter === voter);
|
||||
if (existing >= 0) {
|
||||
this.#votes[existing] = { voter, approve, timestamp: Date.now() };
|
||||
} else {
|
||||
this.#votes.push({ voter, approve, timestamp: Date.now() });
|
||||
}
|
||||
if (this.#voteNameEl) this.#voteNameEl.value = "";
|
||||
this.#renderVoters();
|
||||
this.#checkApproval();
|
||||
}
|
||||
|
||||
#renderVoters() {
|
||||
if (!this.#votersEl) return;
|
||||
this.#votersEl.innerHTML = this.#votes.map(v => {
|
||||
const badge = v.approve
|
||||
? `<span class="vote-badge approve">YES</span>`
|
||||
: `<span class="vote-badge reject">NO</span>`;
|
||||
return `<div class="voter-row"><span class="voter-name">${v.voter}</span>${badge}</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
#checkApproval() {
|
||||
if (this.#amendmentStatus === "applied") return;
|
||||
|
||||
const approvals = this.#votes.filter(v => v.approve).length;
|
||||
const rejections = this.#votes.filter(v => !v.approve).length;
|
||||
const total = this.#votes.length;
|
||||
|
||||
let approved = false;
|
||||
if (this.#approvalMode === "single") {
|
||||
approved = approvals >= 1;
|
||||
} else if (this.#approvalMode === "majority") {
|
||||
approved = total > 0 && approvals > total / 2;
|
||||
} else if (this.#approvalMode === "unanimous") {
|
||||
approved = total > 0 && rejections === 0;
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
this.#amendmentStatus = "approved";
|
||||
} else if (total > 0 && this.#approvalMode === "unanimous" && rejections > 0) {
|
||||
this.#amendmentStatus = "rejected";
|
||||
} else {
|
||||
this.#amendmentStatus = "pending";
|
||||
}
|
||||
|
||||
this.#updateStatusBadge();
|
||||
this.#emitPorts();
|
||||
}
|
||||
|
||||
#updateStatusBadge() {
|
||||
if (!this.#statusEl || !this.#applyBtn) return;
|
||||
this.#statusEl.textContent = this.#amendmentStatus.toUpperCase();
|
||||
this.#statusEl.className = `status-badge ${this.#amendmentStatus}`;
|
||||
this.#applyBtn.disabled = this.#amendmentStatus !== "approved";
|
||||
}
|
||||
|
||||
#applyAmendment() {
|
||||
if (this.#amendmentStatus !== "approved") return;
|
||||
if (!this.#targetShapeId) return;
|
||||
|
||||
// Dispatch event for canvas to handle the shape replacement
|
||||
this.dispatchEvent(new CustomEvent("gov-amendment-apply", {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
targetShapeId: this.#targetShapeId,
|
||||
replacementType: this.#replacementType,
|
||||
replacementData: this.#replacementData,
|
||||
amendmentId: this.id,
|
||||
},
|
||||
}));
|
||||
|
||||
this.#amendmentStatus = "applied";
|
||||
this.#updateStatusBadge();
|
||||
this.#emitPorts();
|
||||
}
|
||||
|
||||
#emitPorts() {
|
||||
const data = {
|
||||
status: this.#amendmentStatus,
|
||||
targetShapeId: this.#targetShapeId,
|
||||
approved: this.#amendmentStatus === "approved" || this.#amendmentStatus === "applied",
|
||||
};
|
||||
this.setPortValue("amendment-out", data);
|
||||
this.setPortValue("gate-out", {
|
||||
satisfied: this.#amendmentStatus === "approved" || this.#amendmentStatus === "applied",
|
||||
});
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-gov-amendment",
|
||||
title: this.#title,
|
||||
targetShapeId: this.#targetShapeId,
|
||||
replacementType: this.#replacementType,
|
||||
replacementData: this.#replacementData,
|
||||
approvalMode: this.#approvalMode,
|
||||
votes: this.#votes,
|
||||
amendmentStatus: this.#amendmentStatus,
|
||||
description: this.#description,
|
||||
};
|
||||
}
|
||||
|
||||
static override fromData(data: Record<string, any>): FolkGovAmendment {
|
||||
const shape = FolkShape.fromData.call(this, data) as FolkGovAmendment;
|
||||
if (data.title !== undefined) shape.title = data.title;
|
||||
if (data.targetShapeId !== undefined) shape.targetShapeId = data.targetShapeId;
|
||||
if (data.replacementType !== undefined) shape.replacementType = data.replacementType;
|
||||
if (data.replacementData !== undefined) shape.replacementData = data.replacementData;
|
||||
if (data.approvalMode !== undefined) shape.approvalMode = data.approvalMode;
|
||||
if (data.votes !== undefined) shape.votes = data.votes;
|
||||
if (data.amendmentStatus !== undefined) shape.amendmentStatus = data.amendmentStatus;
|
||||
if (data.description !== undefined) shape.description = data.description;
|
||||
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.targetShapeId !== undefined && data.targetShapeId !== this.#targetShapeId) this.targetShapeId = data.targetShapeId;
|
||||
if (data.replacementType !== undefined && data.replacementType !== this.#replacementType) this.replacementType = data.replacementType;
|
||||
if (data.replacementData !== undefined) this.replacementData = data.replacementData;
|
||||
if (data.approvalMode !== undefined && data.approvalMode !== this.#approvalMode) this.approvalMode = data.approvalMode;
|
||||
if (data.votes !== undefined) this.votes = data.votes;
|
||||
if (data.amendmentStatus !== undefined && data.amendmentStatus !== this.#amendmentStatus) this.amendmentStatus = data.amendmentStatus;
|
||||
if (data.description !== undefined && data.description !== this.#description) this.description = data.description;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
/**
|
||||
* folk-gov-binary — Yes/No Signoff Gate
|
||||
*
|
||||
* A simple binary governance gate. An optional assignee can be specified
|
||||
* (who must sign off). When checked, emits { satisfied: true } on gate-out.
|
||||
*/
|
||||
|
||||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
import type { PortDescriptor } from "./data-types";
|
||||
|
||||
const HEADER_COLOR = "#7c3aed";
|
||||
|
||||
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: 200px;
|
||||
min-height: 80px;
|
||||
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;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title-input::placeholder {
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.assignee-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.assignee-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;
|
||||
width: 100px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.check-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.gate-checkbox {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gate-checkbox:checked {
|
||||
background: #22c55e;
|
||||
border-color: #22c55e;
|
||||
box-shadow: 0 0 12px rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.gate-checkbox:checked::after {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gate-checkbox:not(:checked) {
|
||||
box-shadow: 0 0 8px rgba(245, 158, 11, 0.3);
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-label.satisfied {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.status-label.waiting {
|
||||
color: #f59e0b;
|
||||
}
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-gov-binary": FolkGovBinary;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkGovBinary extends FolkShape {
|
||||
static override tagName = "folk-gov-binary";
|
||||
|
||||
static override portDescriptors: PortDescriptor[] = [
|
||||
{ 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 = "Signoff Required";
|
||||
#assignee = "";
|
||||
#satisfied = false;
|
||||
#signedBy = "";
|
||||
#timestamp = 0;
|
||||
|
||||
// DOM refs
|
||||
#titleEl!: HTMLInputElement;
|
||||
#assigneeEl!: HTMLInputElement;
|
||||
#checkboxEl!: HTMLInputElement;
|
||||
#statusEl!: HTMLElement;
|
||||
|
||||
get title() { return this.#title; }
|
||||
set title(v: string) {
|
||||
this.#title = v;
|
||||
if (this.#titleEl) this.#titleEl.value = v;
|
||||
}
|
||||
|
||||
get assignee() { return this.#assignee; }
|
||||
set assignee(v: string) {
|
||||
this.#assignee = v;
|
||||
if (this.#assigneeEl) this.#assigneeEl.value = v;
|
||||
}
|
||||
|
||||
get satisfied() { return this.#satisfied; }
|
||||
set satisfied(v: boolean) {
|
||||
this.#satisfied = v;
|
||||
if (this.#checkboxEl) this.#checkboxEl.checked = v;
|
||||
this.#updateVisuals();
|
||||
this.#emitPort();
|
||||
}
|
||||
|
||||
get signedBy() { return this.#signedBy; }
|
||||
set signedBy(v: string) { this.#signedBy = v; }
|
||||
|
||||
get timestamp() { return this.#timestamp; }
|
||||
set timestamp(v: number) { this.#timestamp = 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">⚖️ Binary Gate</span>
|
||||
<span class="header-actions">
|
||||
<button class="close-btn">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<input class="title-input" type="text" placeholder="Gate title..." />
|
||||
<div class="assignee-row">
|
||||
<span>Assignee:</span>
|
||||
<input class="assignee-input" type="text" placeholder="anyone" />
|
||||
</div>
|
||||
<div class="check-area">
|
||||
<input class="gate-checkbox" type="checkbox" />
|
||||
</div>
|
||||
<span class="status-label waiting">WAITING</span>
|
||||
</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.#assigneeEl = wrapper.querySelector(".assignee-input") as HTMLInputElement;
|
||||
this.#checkboxEl = wrapper.querySelector(".gate-checkbox") as HTMLInputElement;
|
||||
this.#statusEl = wrapper.querySelector(".status-label") as HTMLElement;
|
||||
|
||||
// Set initial values
|
||||
this.#titleEl.value = this.#title;
|
||||
this.#assigneeEl.value = this.#assignee;
|
||||
this.#checkboxEl.checked = this.#satisfied;
|
||||
this.#updateVisuals();
|
||||
|
||||
// Wire events
|
||||
this.#titleEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#title = this.#titleEl.value;
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
});
|
||||
|
||||
this.#assigneeEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#assignee = this.#assigneeEl.value;
|
||||
this.dispatchEvent(new CustomEvent("content-change"));
|
||||
});
|
||||
|
||||
this.#checkboxEl.addEventListener("change", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#satisfied = this.#checkboxEl.checked;
|
||||
this.#signedBy = this.#satisfied ? (this.#assignee || "anonymous") : "";
|
||||
this.#timestamp = this.#satisfied ? Date.now() : 0;
|
||||
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
|
||||
this.#titleEl.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
this.#assigneeEl.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
this.#checkboxEl.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
#updateVisuals() {
|
||||
if (!this.#statusEl) return;
|
||||
if (this.#satisfied) {
|
||||
this.#statusEl.textContent = "SATISFIED";
|
||||
this.#statusEl.className = "status-label satisfied";
|
||||
} else {
|
||||
this.#statusEl.textContent = "WAITING";
|
||||
this.#statusEl.className = "status-label waiting";
|
||||
}
|
||||
}
|
||||
|
||||
#emitPort() {
|
||||
this.setPortValue("gate-out", {
|
||||
satisfied: this.#satisfied,
|
||||
signedBy: this.#signedBy,
|
||||
timestamp: this.#timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-gov-binary",
|
||||
title: this.#title,
|
||||
assignee: this.#assignee,
|
||||
satisfied: this.#satisfied,
|
||||
signedBy: this.#signedBy,
|
||||
timestamp: this.#timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
static override fromData(data: Record<string, any>): FolkGovBinary {
|
||||
const shape = FolkShape.fromData.call(this, data) as FolkGovBinary;
|
||||
if (data.title !== undefined) shape.title = data.title;
|
||||
if (data.assignee !== undefined) shape.assignee = data.assignee;
|
||||
if (data.satisfied !== undefined) shape.satisfied = data.satisfied;
|
||||
if (data.signedBy !== undefined) shape.signedBy = data.signedBy;
|
||||
if (data.timestamp !== undefined) shape.timestamp = data.timestamp;
|
||||
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.assignee !== undefined && data.assignee !== this.#assignee) this.assignee = data.assignee;
|
||||
if (data.satisfied !== undefined && data.satisfied !== this.#satisfied) this.satisfied = data.satisfied;
|
||||
if (data.signedBy !== undefined && data.signedBy !== this.#signedBy) this.signedBy = data.signedBy;
|
||||
if (data.timestamp !== undefined && data.timestamp !== this.#timestamp) this.timestamp = data.timestamp;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,518 @@
|
|||
/**
|
||||
* folk-gov-knob — Adjustable Parameter
|
||||
*
|
||||
* An SVG rotary knob (225° sweep) with numeric input fallback.
|
||||
* Optional "temporal viscosity" cooldown delays value propagation.
|
||||
*/
|
||||
|
||||
import { FolkShape } from "./folk-shape";
|
||||
import { css, html } from "./tags";
|
||||
import type { PortDescriptor } from "./data-types";
|
||||
|
||||
const HEADER_COLOR = "#b45309";
|
||||
const SWEEP = 225; // degrees
|
||||
const START_ANGLE = (360 - SWEEP) / 2 + 90; // start from bottom-left
|
||||
|
||||
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: 160px;
|
||||
min-height: 120px;
|
||||
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;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--rs-text-primary, #e2e8f0);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.title-input::placeholder {
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.knob-svg {
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.knob-svg:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.value-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: 12px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
width: 60px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.unit-label {
|
||||
font-size: 11px;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.range-row {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.range-input {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
font-size: 10px;
|
||||
padding: 1px 4px;
|
||||
width: 40px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cooldown-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: var(--rs-text-muted, #64748b);
|
||||
}
|
||||
|
||||
.cooldown-input {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 3px;
|
||||
color: var(--rs-text-muted, #94a3b8);
|
||||
font-size: 10px;
|
||||
padding: 1px 4px;
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cooldown-active {
|
||||
color: #f59e0b;
|
||||
font-weight: 600;
|
||||
}
|
||||
`;
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"folk-gov-knob": FolkGovKnob;
|
||||
}
|
||||
}
|
||||
|
||||
export class FolkGovKnob extends FolkShape {
|
||||
static override tagName = "folk-gov-knob";
|
||||
|
||||
static override portDescriptors: PortDescriptor[] = [
|
||||
{ name: "value-out", type: "number", 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 = "Parameter";
|
||||
#min = 0;
|
||||
#max = 100;
|
||||
#step = 1;
|
||||
#value = 50;
|
||||
#unit = "";
|
||||
#cooldown = 0; // seconds (0 = disabled)
|
||||
#cooldownRemaining = 0;
|
||||
#cooldownTimer: ReturnType<typeof setInterval> | null = null;
|
||||
#pendingValue: number | null = null;
|
||||
|
||||
// DOM refs
|
||||
#titleEl!: HTMLInputElement;
|
||||
#valueEl!: HTMLInputElement;
|
||||
#knobSvg!: SVGSVGElement;
|
||||
#knobArc!: SVGPathElement;
|
||||
#knobDot!: SVGCircleElement;
|
||||
#cooldownRing!: SVGCircleElement;
|
||||
#cooldownLabel!: HTMLElement;
|
||||
|
||||
get title() { return this.#title; }
|
||||
set title(v: string) {
|
||||
this.#title = v;
|
||||
if (this.#titleEl) this.#titleEl.value = v;
|
||||
}
|
||||
|
||||
get min() { return this.#min; }
|
||||
set min(v: number) { this.#min = v; this.#updateKnob(); }
|
||||
|
||||
get max() { return this.#max; }
|
||||
set max(v: number) { this.#max = v; this.#updateKnob(); }
|
||||
|
||||
get step() { return this.#step; }
|
||||
set step(v: number) { this.#step = v; }
|
||||
|
||||
get value() { return this.#value; }
|
||||
set value(v: number) {
|
||||
this.#value = Math.max(this.#min, Math.min(this.#max, v));
|
||||
if (this.#valueEl) this.#valueEl.value = String(this.#value);
|
||||
this.#updateKnob();
|
||||
}
|
||||
|
||||
get unit() { return this.#unit; }
|
||||
set unit(v: string) { this.#unit = v; }
|
||||
|
||||
get cooldown() { return this.#cooldown; }
|
||||
set cooldown(v: number) { this.#cooldown = Math.max(0, 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">🎛️ Knob</span>
|
||||
<span class="header-actions">
|
||||
<button class="close-btn">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<input class="title-input" type="text" placeholder="Parameter name..." />
|
||||
<svg class="knob-svg" width="80" height="80" viewBox="0 0 80 80">
|
||||
<circle cx="40" cy="40" r="35" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="6" />
|
||||
<circle class="cooldown-ring" cx="40" cy="40" r="35" fill="none" stroke="#f59e0b"
|
||||
stroke-width="6" stroke-dasharray="220" stroke-dashoffset="220"
|
||||
transform="rotate(-90 40 40)" opacity="0.5" />
|
||||
<path class="knob-arc" fill="none" stroke="${HEADER_COLOR}" stroke-width="6" stroke-linecap="round" d="" />
|
||||
<circle class="knob-dot" cx="40" cy="40" r="5" fill="white" />
|
||||
</svg>
|
||||
<div class="value-row">
|
||||
<input class="value-input" type="number" />
|
||||
<span class="unit-label"></span>
|
||||
</div>
|
||||
<div class="range-row">
|
||||
<span>min</span>
|
||||
<input class="range-input min-input" type="number" />
|
||||
<span>max</span>
|
||||
<input class="range-input max-input" type="number" />
|
||||
<span>step</span>
|
||||
<input class="range-input step-input" type="number" min="0.01" />
|
||||
</div>
|
||||
<div class="cooldown-row">
|
||||
<span>cooldown</span>
|
||||
<input class="cooldown-input" type="number" min="0" />
|
||||
<span>s</span>
|
||||
<span class="cooldown-active" style="display:none"></span>
|
||||
</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.#valueEl = wrapper.querySelector(".value-input") as HTMLInputElement;
|
||||
this.#knobSvg = wrapper.querySelector(".knob-svg") as unknown as SVGSVGElement;
|
||||
this.#knobArc = wrapper.querySelector(".knob-arc") as unknown as SVGPathElement;
|
||||
this.#knobDot = wrapper.querySelector(".knob-dot") as unknown as SVGCircleElement;
|
||||
this.#cooldownRing = wrapper.querySelector(".cooldown-ring") as unknown as SVGCircleElement;
|
||||
this.#cooldownLabel = wrapper.querySelector(".cooldown-active") as HTMLElement;
|
||||
|
||||
const minEl = wrapper.querySelector(".min-input") as HTMLInputElement;
|
||||
const maxEl = wrapper.querySelector(".max-input") as HTMLInputElement;
|
||||
const stepEl = wrapper.querySelector(".step-input") as HTMLInputElement;
|
||||
const cooldownEl = wrapper.querySelector(".cooldown-input") as HTMLInputElement;
|
||||
const unitLabel = wrapper.querySelector(".unit-label") as HTMLElement;
|
||||
|
||||
// Set initial values
|
||||
this.#titleEl.value = this.#title;
|
||||
this.#valueEl.value = String(this.#value);
|
||||
minEl.value = String(this.#min);
|
||||
maxEl.value = String(this.#max);
|
||||
stepEl.value = String(this.#step);
|
||||
cooldownEl.value = String(this.#cooldown);
|
||||
unitLabel.textContent = this.#unit;
|
||||
this.#updateKnob();
|
||||
|
||||
// Wire events
|
||||
const onChange = () => this.dispatchEvent(new CustomEvent("content-change"));
|
||||
|
||||
this.#titleEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#title = this.#titleEl.value;
|
||||
onChange();
|
||||
});
|
||||
|
||||
this.#valueEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
const v = parseFloat(this.#valueEl.value);
|
||||
if (!isNaN(v)) this.#setValueWithCooldown(v);
|
||||
onChange();
|
||||
});
|
||||
|
||||
minEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#min = parseFloat(minEl.value) || 0;
|
||||
this.#updateKnob();
|
||||
onChange();
|
||||
});
|
||||
|
||||
maxEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#max = parseFloat(maxEl.value) || 100;
|
||||
this.#updateKnob();
|
||||
onChange();
|
||||
});
|
||||
|
||||
stepEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#step = parseFloat(stepEl.value) || 1;
|
||||
onChange();
|
||||
});
|
||||
|
||||
cooldownEl.addEventListener("input", (e) => {
|
||||
e.stopPropagation();
|
||||
this.#cooldown = parseFloat(cooldownEl.value) || 0;
|
||||
onChange();
|
||||
});
|
||||
|
||||
wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
});
|
||||
|
||||
// Knob drag interaction
|
||||
let dragging = false;
|
||||
this.#knobSvg.addEventListener("pointerdown", (e) => {
|
||||
e.stopPropagation();
|
||||
dragging = true;
|
||||
(e.target as Element).setPointerCapture(e.pointerId);
|
||||
});
|
||||
|
||||
this.#knobSvg.addEventListener("pointermove", (e) => {
|
||||
if (!dragging) return;
|
||||
e.stopPropagation();
|
||||
const rect = this.#knobSvg.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
let angle = Math.atan2(e.clientY - cy, e.clientX - cx) * (180 / Math.PI);
|
||||
angle = (angle + 360) % 360;
|
||||
// Map angle to value
|
||||
const startA = START_ANGLE;
|
||||
let relAngle = (angle - startA + 360) % 360;
|
||||
if (relAngle > SWEEP) relAngle = relAngle > SWEEP + (360 - SWEEP) / 2 ? 0 : SWEEP;
|
||||
const ratio = relAngle / SWEEP;
|
||||
let v = this.#min + ratio * (this.#max - this.#min);
|
||||
// Snap to step
|
||||
v = Math.round(v / this.#step) * this.#step;
|
||||
v = Math.max(this.#min, Math.min(this.#max, v));
|
||||
this.#setValueWithCooldown(v);
|
||||
onChange();
|
||||
});
|
||||
|
||||
this.#knobSvg.addEventListener("pointerup", (e) => {
|
||||
dragging = false;
|
||||
e.stopPropagation();
|
||||
});
|
||||
|
||||
// Prevent drag on all inputs
|
||||
for (const el of wrapper.querySelectorAll("input, button")) {
|
||||
el.addEventListener("pointerdown", (e) => e.stopPropagation());
|
||||
}
|
||||
|
||||
// Emit initial value
|
||||
this.#emitPort();
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
#setValueWithCooldown(v: number) {
|
||||
v = Math.max(this.#min, Math.min(this.#max, v));
|
||||
this.#value = v;
|
||||
if (this.#valueEl) this.#valueEl.value = String(v);
|
||||
this.#updateKnob();
|
||||
|
||||
if (this.#cooldown > 0) {
|
||||
this.#pendingValue = v;
|
||||
if (!this.#cooldownTimer) {
|
||||
this.#cooldownRemaining = this.#cooldown;
|
||||
this.#cooldownLabel.style.display = "";
|
||||
this.#cooldownLabel.textContent = `${this.#cooldownRemaining}s`;
|
||||
this.#updateCooldownRing();
|
||||
this.#cooldownTimer = setInterval(() => {
|
||||
this.#cooldownRemaining--;
|
||||
if (this.#cooldownRemaining <= 0) {
|
||||
clearInterval(this.#cooldownTimer!);
|
||||
this.#cooldownTimer = null;
|
||||
this.#cooldownLabel.style.display = "none";
|
||||
this.#cooldownRing.setAttribute("stroke-dashoffset", "220");
|
||||
if (this.#pendingValue !== null) {
|
||||
this.#emitPort();
|
||||
this.#pendingValue = null;
|
||||
}
|
||||
} else {
|
||||
this.#cooldownLabel.textContent = `${this.#cooldownRemaining}s`;
|
||||
this.#updateCooldownRing();
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
// Reset countdown
|
||||
this.#cooldownRemaining = this.#cooldown;
|
||||
this.#cooldownLabel.textContent = `${this.#cooldownRemaining}s`;
|
||||
this.#updateCooldownRing();
|
||||
}
|
||||
} else {
|
||||
this.#emitPort();
|
||||
}
|
||||
}
|
||||
|
||||
#updateCooldownRing() {
|
||||
if (!this.#cooldownRing || this.#cooldown <= 0) return;
|
||||
const circumference = 220; // 2 * π * 35
|
||||
const progress = this.#cooldownRemaining / this.#cooldown;
|
||||
this.#cooldownRing.setAttribute("stroke-dashoffset", String(circumference * (1 - progress)));
|
||||
}
|
||||
|
||||
#updateKnob() {
|
||||
if (!this.#knobArc || !this.#knobDot) return;
|
||||
const range = this.#max - this.#min;
|
||||
const ratio = range > 0 ? (this.#value - this.#min) / range : 0;
|
||||
const endAngle = START_ANGLE + ratio * SWEEP;
|
||||
|
||||
// Arc path
|
||||
const r = 35;
|
||||
const cx = 40;
|
||||
const cy = 40;
|
||||
const startRad = (START_ANGLE * Math.PI) / 180;
|
||||
const endRad = (endAngle * Math.PI) / 180;
|
||||
const x1 = cx + r * Math.cos(startRad);
|
||||
const y1 = cy + r * Math.sin(startRad);
|
||||
const x2 = cx + r * Math.cos(endRad);
|
||||
const y2 = cy + r * Math.sin(endRad);
|
||||
const largeArc = ratio * SWEEP > 180 ? 1 : 0;
|
||||
|
||||
if (ratio > 0.001) {
|
||||
this.#knobArc.setAttribute("d", `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`);
|
||||
} else {
|
||||
this.#knobArc.setAttribute("d", "");
|
||||
}
|
||||
|
||||
// Dot position
|
||||
this.#knobDot.setAttribute("cx", String(x2));
|
||||
this.#knobDot.setAttribute("cy", String(y2));
|
||||
}
|
||||
|
||||
#emitPort() {
|
||||
this.setPortValue("value-out", this.#value);
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.#cooldownTimer) {
|
||||
clearInterval(this.#cooldownTimer);
|
||||
this.#cooldownTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
override toJSON() {
|
||||
return {
|
||||
...super.toJSON(),
|
||||
type: "folk-gov-knob",
|
||||
title: this.#title,
|
||||
min: this.#min,
|
||||
max: this.#max,
|
||||
step: this.#step,
|
||||
value: this.#value,
|
||||
unit: this.#unit,
|
||||
cooldown: this.#cooldown,
|
||||
};
|
||||
}
|
||||
|
||||
static override fromData(data: Record<string, any>): FolkGovKnob {
|
||||
const shape = FolkShape.fromData.call(this, data) as FolkGovKnob;
|
||||
if (data.title !== undefined) shape.title = data.title;
|
||||
if (data.min !== undefined) shape.min = data.min;
|
||||
if (data.max !== undefined) shape.max = data.max;
|
||||
if (data.step !== undefined) shape.step = data.step;
|
||||
if (data.value !== undefined) shape.value = data.value;
|
||||
if (data.unit !== undefined) shape.unit = data.unit;
|
||||
if (data.cooldown !== undefined) shape.cooldown = data.cooldown;
|
||||
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.min !== undefined && data.min !== this.#min) this.min = data.min;
|
||||
if (data.max !== undefined && data.max !== this.#max) this.max = data.max;
|
||||
if (data.step !== undefined && data.step !== this.#step) this.step = data.step;
|
||||
if (data.value !== undefined && data.value !== this.#value) this.value = data.value;
|
||||
if (data.unit !== undefined && data.unit !== this.#unit) this.unit = data.unit;
|
||||
if (data.cooldown !== undefined && data.cooldown !== this.#cooldown) this.cooldown = data.cooldown;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,478 @@
|
|||
/**
|
||||
* 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`
|
||||
<div class="header" data-drag>
|
||||
<span class="header-title">📊 Threshold</span>
|
||||
<span class="header-actions">
|
||||
<button class="close-btn">×</button>
|
||||
</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<input class="title-input" type="text" placeholder="Threshold title..." />
|
||||
<div class="target-row">
|
||||
<span>Target:</span>
|
||||
<input class="target-input" type="number" min="0" />
|
||||
<input class="unit-input" type="text" placeholder="unit" />
|
||||
</div>
|
||||
<div class="progress-wrap">
|
||||
<div class="progress-bar" style="width: 0%"></div>
|
||||
<div class="progress-label">0 / 100</div>
|
||||
</div>
|
||||
<div class="contribute-row">
|
||||
<input class="contrib-name" type="text" placeholder="Name" />
|
||||
<input class="contrib-amount" type="number" placeholder="0" min="0" />
|
||||
<button class="contrib-btn">+</button>
|
||||
</div>
|
||||
<div class="contributions-list"></div>
|
||||
<span class="status-label waiting">WAITING</span>
|
||||
</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.#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 =>
|
||||
`<div class="contrib-item"><span>${c.who}</span><span>${c.amount} ${this.#unit}</span></div>`
|
||||
).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<string, any>): 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<string, any>): 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -81,6 +81,13 @@ export * from "./folk-social-thread";
|
|||
export * from "./folk-social-campaign";
|
||||
export * from "./folk-social-newsletter";
|
||||
|
||||
// rGov Governance Circuit Shapes
|
||||
export * from "./folk-gov-binary";
|
||||
export * from "./folk-gov-threshold";
|
||||
export * from "./folk-gov-knob";
|
||||
export * from "./folk-gov-project";
|
||||
export * from "./folk-gov-amendment";
|
||||
|
||||
// Decision/Choice Shapes
|
||||
export * from "./folk-choice-vote";
|
||||
export * from "./folk-choice-rank";
|
||||
|
|
|
|||
|
|
@ -0,0 +1,385 @@
|
|||
/**
|
||||
* rGov landing page — modular governance decision circuits.
|
||||
* GovMods: do-ocratic circuit components for multiplayer collaboration.
|
||||
*/
|
||||
export function renderLanding(): string {
|
||||
return `
|
||||
<!-- Hero -->
|
||||
<div class="rl-hero">
|
||||
<span class="rl-tagline" style="color:#818cf8;background:rgba(129,140,248,0.1);border-color:rgba(129,140,248,0.2)">
|
||||
Modular Governance for rSpace
|
||||
</span>
|
||||
<h1 class="rl-heading" style="background:linear-gradient(to right,#7c3aed,#1d4ed8);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;font-size:2.5rem">
|
||||
GovMods
|
||||
</h1>
|
||||
<p class="rl-subtitle" style="max-width:640px">
|
||||
<strong style="color:#e2e8f0">Do-ocratic circuit components</strong> for multiplayer collaboration
|
||||
around shared goals. Wire together governance primitives on a shared canvas — thresholds,
|
||||
signoffs, tunable knobs, and amendable circuits. Decisions happen by <em>doing</em>,
|
||||
not debating.
|
||||
</p>
|
||||
<div class="rl-cta-row">
|
||||
<a href="https://demo.rspace.online/rgov" class="rl-cta-primary"
|
||||
style="background:linear-gradient(to right,#7c3aed,#1d4ed8);color:white">
|
||||
<span style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
|
||||
Try the Demo
|
||||
</span>
|
||||
</a>
|
||||
<a href="/create-space" class="rl-cta-secondary">Create a Space</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- What is Modular Governance? -->
|
||||
<section class="rl-section" style="border-top:none">
|
||||
<div class="rl-container">
|
||||
<div style="text-align:center;margin-bottom:2rem">
|
||||
<span class="rl-badge" style="background:#1e293b;color:#94a3b8;font-size:0.7rem;padding:0.25rem 0.75rem">MODULAR GOVERNANCE</span>
|
||||
<h2 class="rl-heading" style="margin-top:0.75rem;background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
What are GovMods?
|
||||
</h2>
|
||||
<p style="font-size:1.05rem;color:#94a3b8;max-width:680px;margin:0.5rem auto 0">
|
||||
GovMods are <strong style="color:#818cf8">do-ocratic governance primitives</strong> —
|
||||
drag-and-drop circuit components where decisions happen through <strong style="color:#60a5fa">action</strong>,
|
||||
not deliberation. Contribute hours, pledge funds, sign off on requirements. When all gates
|
||||
in a circuit are satisfied, the decision is made. No meetings required.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-grid-3">
|
||||
<!-- Binary Gates -->
|
||||
<div class="rl-card" style="border:2px solid rgba(124,58,237,0.35);background:linear-gradient(to bottom right,rgba(124,58,237,0.08),rgba(124,58,237,0.03))">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:#7c3aed;display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:white;font-weight:700;font-size:0.9rem">✓</span>
|
||||
</div>
|
||||
<h3 style="color:#a78bfa;font-size:1.05rem;margin-bottom:0">Signoff Gate</h3>
|
||||
</div>
|
||||
<p>
|
||||
The simplest GovMod. A binary yes/no checkpoint — assign someone to approve,
|
||||
or leave it open for anyone who steps up. Green glow when satisfied.
|
||||
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Do-ocracy: whoever shows up, decides.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Thresholds -->
|
||||
<div class="rl-card" style="border:2px solid rgba(8,145,178,0.35);background:linear-gradient(to bottom right,rgba(8,145,178,0.08),rgba(8,145,178,0.03))">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:#0891b2;display:flex;align-items:center;justify-content:center">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round"><rect x="3" y="12" width="4" height="9" rx="1"/><rect x="10" y="7" width="4" height="14" rx="1"/><rect x="17" y="3" width="4" height="18" rx="1"/></svg>
|
||||
</div>
|
||||
<h3 style="color:#22d3ee;font-size:1.05rem;margin-bottom:0">Threshold Gate</h3>
|
||||
</div>
|
||||
<p>
|
||||
Accumulate contributions toward a target: hours, dollars, signatures, materials.
|
||||
Progress bar fills as people contribute. Gate opens when the community has collectively
|
||||
<em>done enough</em>.
|
||||
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Decisions backed by real resources.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Knobs -->
|
||||
<div class="rl-card" style="border:2px solid rgba(180,83,9,0.35);background:linear-gradient(to bottom right,rgba(180,83,9,0.08),rgba(180,83,9,0.03))">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.75rem">
|
||||
<div style="width:2rem;height:2rem;border-radius:9999px;background:#b45309;display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:white;font-size:1rem">🎛️</span>
|
||||
</div>
|
||||
<h3 style="color:#f59e0b;font-size:1.05rem;margin-bottom:0">Tunable Knob</h3>
|
||||
</div>
|
||||
<p>
|
||||
Adjustable parameters that wire into other GovMods. Set a budget cap, quorum
|
||||
percentage, or time limit. Optional <strong style="color:#fbbf24">temporal viscosity</strong>:
|
||||
a cooldown that prevents rapid parameter flipping.
|
||||
<strong style="display:block;margin-top:0.5rem;color:#e2e8f0">Governance that adapts, but deliberately.</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Do-ocracy in Action -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<div style="text-align:center;margin-bottom:2.5rem">
|
||||
<span class="rl-tagline" style="color:#818cf8;background:rgba(129,140,248,0.1);border-color:rgba(129,140,248,0.2)">
|
||||
Do-ocracy in Action
|
||||
</span>
|
||||
<h2 class="rl-heading" style="background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
Design → Wire → Do
|
||||
</h2>
|
||||
<p style="font-size:1.05rem;color:#94a3b8;max-width:640px;margin:0 auto">
|
||||
Three steps from blank canvas to living governance. No proposals, no quorum calls —
|
||||
just wire up the conditions and let people act.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-grid-3">
|
||||
<!-- Step 1 -->
|
||||
<div class="rl-card" style="border-color:rgba(124,58,237,0.2)">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
|
||||
<div style="width:2.5rem;height:2.5rem;border-radius:9999px;background:linear-gradient(to bottom right,#7c3aed,rgba(124,58,237,0.6));display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:white;font-weight:700;font-size:1rem">1</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rl-badge" style="background:rgba(124,58,237,0.1);color:#a78bfa;margin-bottom:0.25rem">Design</span>
|
||||
<h3 style="margin-bottom:0;font-size:1rem">Place GovMods</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Drag governance components onto the canvas: signoff gates, resource thresholds,
|
||||
tunable knobs. Or tell MI: <strong style="color:#e2e8f0">"create a governance circuit
|
||||
for building a climbing wall"</strong> and watch the GovMods appear.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2 -->
|
||||
<div class="rl-card" style="border-color:rgba(29,78,216,0.25);background:linear-gradient(to bottom right,rgba(29,78,216,0.05),rgba(124,58,237,0.03))">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
|
||||
<div style="width:2.5rem;height:2.5rem;border-radius:9999px;background:linear-gradient(to bottom right,#1d4ed8,#7c3aed);display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:white;font-weight:700;font-size:1rem">2</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rl-badge" style="background:rgba(29,78,216,0.15);color:#60a5fa;margin-bottom:0.25rem">Wire</span>
|
||||
<h3 style="margin-bottom:0;font-size:1rem">Connect the Circuit</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Draw arrows from GovMod outputs to a <strong style="color:#e2e8f0">Project aggregator</strong>.
|
||||
Wire a knob's value to a threshold's target for dynamic parameters.
|
||||
The circuit shows data flow and gate conditions in real time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 3 -->
|
||||
<div class="rl-card" style="border-color:rgba(34,197,94,0.2)">
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;margin-bottom:1rem">
|
||||
<div style="width:2.5rem;height:2.5rem;border-radius:9999px;background:linear-gradient(to bottom right,#22c55e,#16a34a);display:flex;align-items:center;justify-content:center">
|
||||
<span style="color:white;font-weight:700;font-size:1rem">3</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="rl-badge" style="background:rgba(34,197,94,0.1);color:#22c55e;margin-bottom:0.25rem">Do</span>
|
||||
<h3 style="margin-bottom:0;font-size:1rem">Contribute & Complete</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
Community members <em>do the work</em>: contribute resources, sign off, adjust parameters.
|
||||
The Project tracks <strong style="color:#22c55e">"X of Y gates satisfied"</strong>
|
||||
and auto-completes when all conditions are met through collective action.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Example: Climbing Wall -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<div style="text-align:center;margin-bottom:2.5rem">
|
||||
<h2 class="rl-heading" style="background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
Example: Build a Climbing Wall
|
||||
</h2>
|
||||
<p style="color:#94a3b8;max-width:640px;margin:0 auto">
|
||||
A community wants to build a climbing wall. Here's how GovMods make it happen
|
||||
through do-ocratic action:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Simplified circuit diagram -->
|
||||
<div style="display:flex;justify-content:center;margin-bottom:2rem">
|
||||
<svg width="560" height="200" viewBox="0 0 560 200" style="max-width:100%">
|
||||
<!-- Binary gate -->
|
||||
<rect x="10" y="20" width="120" height="60" rx="8" fill="rgba(124,58,237,0.15)" stroke="#7c3aed" stroke-width="1.5"/>
|
||||
<text x="70" y="44" text-anchor="middle" fill="#a78bfa" font-size="10" font-weight="600">Proprietor</text>
|
||||
<text x="70" y="58" text-anchor="middle" fill="#a78bfa" font-size="10">Signoff</text>
|
||||
<circle cx="70" cy="72" r="4" fill="#7c3aed"/>
|
||||
|
||||
<!-- Threshold 1: Labor -->
|
||||
<rect x="10" y="110" width="120" height="70" rx="8" fill="rgba(8,145,178,0.15)" stroke="#0891b2" stroke-width="1.5"/>
|
||||
<text x="70" y="134" text-anchor="middle" fill="#22d3ee" font-size="10" font-weight="600">Labor</text>
|
||||
<text x="70" y="148" text-anchor="middle" fill="#22d3ee" font-size="10">50 hours</text>
|
||||
<rect x="22" y="158" width="96" height="8" rx="4" fill="rgba(255,255,255,0.1)"/>
|
||||
<rect x="22" y="158" width="60" height="8" rx="4" fill="#0891b2"/>
|
||||
<text x="70" y="175" text-anchor="middle" fill="#94a3b8" font-size="8">30/50 hrs</text>
|
||||
|
||||
<!-- Threshold 2: Capital -->
|
||||
<rect x="170" y="110" width="120" height="70" rx="8" fill="rgba(8,145,178,0.15)" stroke="#0891b2" stroke-width="1.5"/>
|
||||
<text x="230" y="134" text-anchor="middle" fill="#22d3ee" font-size="10" font-weight="600">Capital</text>
|
||||
<text x="230" y="148" text-anchor="middle" fill="#22d3ee" font-size="10">$3,000</text>
|
||||
<rect x="182" y="158" width="96" height="8" rx="4" fill="rgba(255,255,255,0.1)"/>
|
||||
<rect x="182" y="158" width="86" height="8" rx="4" fill="#22c55e"/>
|
||||
<text x="230" y="175" text-anchor="middle" fill="#94a3b8" font-size="8">$2,700/$3,000</text>
|
||||
|
||||
<!-- Arrows to project -->
|
||||
<line x1="130" y1="50" x2="400" y2="80" stroke="#7c3aed" stroke-width="1.5" stroke-dasharray="4 3" opacity="0.6"/>
|
||||
<line x1="130" y1="145" x2="400" y2="100" stroke="#0891b2" stroke-width="1.5" opacity="0.8"/>
|
||||
<line x1="290" y1="145" x2="400" y2="110" stroke="#22c55e" stroke-width="1.5"/>
|
||||
|
||||
<!-- Project aggregator -->
|
||||
<rect x="400" y="50" width="150" height="100" rx="10" fill="rgba(29,78,216,0.15)" stroke="#1d4ed8" stroke-width="2"/>
|
||||
<text x="475" y="74" text-anchor="middle" fill="#60a5fa" font-size="11" font-weight="700">Build Climbing Wall</text>
|
||||
<text x="475" y="92" text-anchor="middle" fill="#94a3b8" font-size="10">1 of 3 gates satisfied</text>
|
||||
<rect x="420" y="102" width="110" height="6" rx="3" fill="rgba(255,255,255,0.1)"/>
|
||||
<rect x="420" y="102" width="37" height="6" rx="3" fill="#1d4ed8"/>
|
||||
<text x="475" y="122" text-anchor="middle" fill="#60a5fa" font-size="9">33%</text>
|
||||
|
||||
<!-- Knob wired to threshold -->
|
||||
<rect x="170" y="20" width="100" height="60" rx="8" fill="rgba(180,83,9,0.15)" stroke="#b45309" stroke-width="1.5"/>
|
||||
<text x="220" y="44" text-anchor="middle" fill="#f59e0b" font-size="10" font-weight="600">Budget Cap</text>
|
||||
<text x="220" y="58" text-anchor="middle" fill="#f59e0b" font-size="10">$3,000</text>
|
||||
<line x1="220" y1="80" x2="230" y2="110" stroke="#b45309" stroke-width="1" stroke-dasharray="3 2" opacity="0.5"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="rl-grid-2" style="max-width:900px;margin:0 auto">
|
||||
<div class="rl-card" style="border-color:rgba(124,58,237,0.15)">
|
||||
<h3 style="margin-bottom:0.35rem;color:#e2e8f0">The GovMod Circuit</h3>
|
||||
<ul style="list-style:disc;padding-left:1.25rem;margin:0;font-size:0.85rem;color:#94a3b8;line-height:1.8">
|
||||
<li><strong style="color:#a78bfa">Signoff:</strong> Proprietor approval</li>
|
||||
<li><strong style="color:#22d3ee">Threshold:</strong> 50 hours labor (people pledge hours)</li>
|
||||
<li><strong style="color:#22d3ee">Threshold:</strong> $3,000 capital (people contribute funds)</li>
|
||||
<li><strong style="color:#f59e0b">Knob:</strong> Budget Cap → wires to capital target</li>
|
||||
<li><strong style="color:#60a5fa">Project:</strong> aggregates all gates, tracks completion</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rl-card" style="border-color:rgba(190,24,93,0.15)">
|
||||
<h3 style="margin-bottom:0.35rem;color:#e2e8f0">The Amendment</h3>
|
||||
<p style="font-size:0.85rem;color:#94a3b8;line-height:1.8">
|
||||
Someone offers to donate climbing grips. They create an <strong style="color:#f472b6">amendment GovMod</strong>
|
||||
proposing to replace the $3,000 threshold with a simple signoff
|
||||
("Grips donated?"). The community votes on the amendment, and on approval the circuit
|
||||
rewires automatically — all arrows stay connected. The governance system evolved
|
||||
<em>because someone did something</em>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Why Modular Governance -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<div style="text-align:center;margin-bottom:2.5rem">
|
||||
<h2 class="rl-heading" style="background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
Why Modular Governance?
|
||||
</h2>
|
||||
<p style="color:#94a3b8;max-width:640px;margin:0 auto">
|
||||
Traditional governance is monolithic: one system fits all. GovMods let each
|
||||
community wire exactly the decision process they need.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-grid-2" style="max-width:900px;margin:0 auto">
|
||||
<!-- The Problem -->
|
||||
<div class="rl-card" style="border:2px solid rgba(239,68,68,0.2);background:rgba(239,68,68,0.04)">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
|
||||
<h3 style="color:#f87171;margin-bottom:0;font-size:1.05rem">Monolithic Governance</h3>
|
||||
</div>
|
||||
<ul style="list-style:disc;padding-left:1.25rem;margin:0">
|
||||
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">One-size-fits-all voting — everything needs a meeting</li>
|
||||
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Decisions bottleneck on the few people who attend</li>
|
||||
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Resource requirements invisible until someone asks</li>
|
||||
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Governance structure is fixed — can't adapt to the situation</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- The Solution -->
|
||||
<div class="rl-card" style="border:2px solid rgba(124,58,237,0.25);background:linear-gradient(to bottom right,rgba(124,58,237,0.05),rgba(29,78,216,0.03))">
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:1rem">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#7c3aed" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
<h3 style="color:#a78bfa;margin-bottom:0;font-size:1.05rem">GovMod Circuits</h3>
|
||||
</div>
|
||||
<ul style="list-style:disc;padding-left:1.25rem;margin:0">
|
||||
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Each decision gets exactly the governance it needs</li>
|
||||
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Do-ocratic: contribute resources, don't just vote on them</li>
|
||||
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Progress visible to everyone on a shared canvas</li>
|
||||
<li style="font-size:0.85rem;color:#94a3b8;line-height:1.8">Amendments let governance evolve mid-process</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature Grid -->
|
||||
<section class="rl-section">
|
||||
<div class="rl-container">
|
||||
<div style="text-align:center;margin-bottom:2rem">
|
||||
<h2 class="rl-heading" style="background:linear-gradient(135deg,#e2e8f0,#cbd5e1);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text">
|
||||
Built for Do-ocratic Communities
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="rl-grid-4">
|
||||
<div class="rl-card rl-card--center" style="border-color:rgba(124,58,237,0.15)">
|
||||
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#7c3aed,#6d28d9);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
|
||||
<span style="font-size:1.25rem">🔌</span>
|
||||
</div>
|
||||
<h3>Modular</h3>
|
||||
<p>Mix and match GovMods to model any decision. Compose simple primitives into complex governance.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center" style="border-color:rgba(29,78,216,0.15)">
|
||||
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#1d4ed8,#1e40af);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
|
||||
<span style="font-size:1.25rem">👥</span>
|
||||
</div>
|
||||
<h3>Multiplayer</h3>
|
||||
<p>Real-time CRDT sync. Multiple people contribute, sign off, and adjust knobs simultaneously.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center" style="border-color:rgba(180,83,9,0.15)">
|
||||
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#b45309,#92400e);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
|
||||
<span style="font-size:1.25rem">🎛️</span>
|
||||
</div>
|
||||
<h3>Temporal Viscosity</h3>
|
||||
<p>Knob cooldowns prevent rapid parameter gaming. Change happens deliberately, not reactively.</p>
|
||||
</div>
|
||||
|
||||
<div class="rl-card rl-card--center" style="border-color:rgba(190,24,93,0.15)">
|
||||
<div style="width:3rem;height:3rem;border-radius:9999px;background:linear-gradient(to bottom right,#be185d,#9d174d);display:flex;align-items:center;justify-content:center;margin:0 auto 0.75rem">
|
||||
<span style="font-size:1.25rem">📝</span>
|
||||
</div>
|
||||
<h3>Amendable</h3>
|
||||
<p>Governance that evolves. Propose circuit changes, vote inline, and the wiring adapts in place.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="rl-section rl-section--alt">
|
||||
<div class="rl-container">
|
||||
<div class="rl-card" style="border:2px solid rgba(124,58,237,0.25);background:linear-gradient(to bottom right,rgba(124,58,237,0.08),rgba(29,78,216,0.04));text-align:center;padding:3rem 2rem;position:relative;overflow:hidden">
|
||||
<span class="rl-badge" style="background:rgba(124,58,237,0.1);color:#a78bfa;font-size:0.7rem;padding:0.25rem 0.75rem">
|
||||
Join the rSpace Ecosystem
|
||||
</span>
|
||||
<h2 style="font-size:1.75rem;font-weight:700;color:#e2e8f0;margin:1rem 0">
|
||||
Ready to wire your community's governance?
|
||||
</h2>
|
||||
<p style="font-size:1.05rem;color:#94a3b8;max-width:560px;margin:0 auto 2rem;line-height:1.6">
|
||||
Create a Space and start building GovMod circuits. Drag gates onto the canvas,
|
||||
wire them together, and let your community decide through action —
|
||||
visually, collaboratively, and do-ocratically.
|
||||
</p>
|
||||
<div class="rl-cta-row" style="margin-top:0">
|
||||
<a href="/create-space" class="rl-cta-primary"
|
||||
style="background:linear-gradient(to right,#7c3aed,#1d4ed8);color:white">
|
||||
<span style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
Create a Space
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>
|
||||
</span>
|
||||
</a>
|
||||
<a href="https://demo.rspace.online/rgov" class="rl-cta-secondary">
|
||||
<span style="display:inline-flex;align-items:center;gap:0.5rem">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><polygon points="5,3 19,12 5,21"/></svg>
|
||||
Interactive Demo
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="rl-back">
|
||||
<a href="/">← Back to rSpace</a>
|
||||
</div>`;
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* rGov module — Modular governance decision circuits (GovMods).
|
||||
*
|
||||
* Do-ocratic circuit components for multiplayer collaboration around
|
||||
* shared goals. Wire together governance primitives on a shared canvas:
|
||||
* signoff gates, resource thresholds, tunable knobs, project aggregators,
|
||||
* and amendable circuits.
|
||||
*/
|
||||
|
||||
import { Hono } from "hono";
|
||||
import { renderShell } from "../../server/shell";
|
||||
import { getModuleInfoList } from "../../shared/module";
|
||||
import type { RSpaceModule } from "../../shared/module";
|
||||
import { renderLanding } from "./landing";
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
// ── Module page (within a space) ──
|
||||
|
||||
routes.get("/", (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
||||
return c.html(renderShell({
|
||||
title: `${space} — rGov | rSpace`,
|
||||
moduleId: "rgov",
|
||||
spaceSlug: space,
|
||||
modules: getModuleInfoList(),
|
||||
theme: "dark",
|
||||
body: `
|
||||
<div style="max-width:640px;margin:60px auto;padding:0 24px;color:#e2e8f0;font-family:system-ui,sans-serif;">
|
||||
<h1 style="font-size:28px;margin-bottom:8px;">⚖️ rGov — GovMods</h1>
|
||||
<p style="color:#94a3b8;margin-bottom:24px;">Do-ocratic circuit components for multiplayer collaboration</p>
|
||||
<p style="color:#cbd5e1;line-height:1.6;margin-bottom:16px;">
|
||||
Build governance decision circuits by wiring GovMods together on the canvas:
|
||||
</p>
|
||||
<ul style="color:#cbd5e1;line-height:1.8;margin-bottom:24px;padding-left:20px;">
|
||||
<li><strong>Signoff Gates</strong> — Yes/No approval checkpoints</li>
|
||||
<li><strong>Thresholds</strong> — Numeric targets (hours, dollars, signatures)</li>
|
||||
<li><strong>Knobs</strong> — Tunable parameters with temporal viscosity</li>
|
||||
<li><strong>Projects</strong> — Circuit aggregators showing "X of Y gates satisfied"</li>
|
||||
<li><strong>Amendments</strong> — Propose in-place circuit modifications</li>
|
||||
</ul>
|
||||
<a href="/${space}/rspace" style="display:inline-block;background:linear-gradient(to right,#7c3aed,#1d4ed8);color:white;padding:10px 20px;border-radius:8px;text-decoration:none;font-weight:600;">
|
||||
Open Canvas →
|
||||
</a>
|
||||
</div>
|
||||
`,
|
||||
}));
|
||||
});
|
||||
|
||||
// ── API: list gov shapes in a space ──
|
||||
|
||||
routes.get("/api/shapes", (c) => {
|
||||
return c.json({
|
||||
info: "Gov shapes are stored in the space's Automerge document. Query the canvas shapes map for types listed below.",
|
||||
types: [
|
||||
"folk-gov-binary",
|
||||
"folk-gov-threshold",
|
||||
"folk-gov-knob",
|
||||
"folk-gov-project",
|
||||
"folk-gov-amendment",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// ── Module export ──
|
||||
|
||||
export const govModule: RSpaceModule = {
|
||||
id: "rgov",
|
||||
name: "rGov",
|
||||
icon: "⚖️",
|
||||
description: "Modular governance decision circuits (GovMods)",
|
||||
routes,
|
||||
scoping: { defaultScope: "space", userConfigurable: false },
|
||||
landingPage: renderLanding,
|
||||
canvasShapes: [
|
||||
"folk-gov-binary",
|
||||
"folk-gov-threshold",
|
||||
"folk-gov-knob",
|
||||
"folk-gov-project",
|
||||
"folk-gov-amendment",
|
||||
],
|
||||
canvasToolIds: [
|
||||
"create_binary_gate",
|
||||
"create_threshold",
|
||||
"create_gov_knob",
|
||||
"create_gov_project",
|
||||
"create_amendment",
|
||||
],
|
||||
onboardingActions: [
|
||||
{ label: "Build a Circuit", icon: "⚖️", description: "Create a governance decision circuit on the canvas", type: 'create', href: '/rgov' },
|
||||
],
|
||||
};
|
||||
|
|
@ -84,6 +84,7 @@ import { bnbModule } from "../modules/rbnb/mod";
|
|||
import { vnbModule } from "../modules/rvnb/mod";
|
||||
import { crowdsurfModule } from "../modules/crowdsurf/mod";
|
||||
import { timeModule } from "../modules/rtime/mod";
|
||||
import { govModule } from "../modules/rgov/mod";
|
||||
import { sheetModule } from "../modules/rsheet/mod";
|
||||
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
|
||||
import type { SpaceRoleString } from "./spaces";
|
||||
|
|
@ -141,6 +142,7 @@ registerModule(bnbModule);
|
|||
registerModule(vnbModule);
|
||||
registerModule(crowdsurfModule);
|
||||
registerModule(timeModule);
|
||||
registerModule(govModule); // Governance decision circuits
|
||||
registerModule(designModule); // Scribus DTP + AI design agent
|
||||
// De-emphasized modules (bottom of menu)
|
||||
registerModule(forumModule);
|
||||
|
|
|
|||
|
|
@ -2460,6 +2460,11 @@
|
|||
FolkCommitmentPool,
|
||||
FolkTaskRequest,
|
||||
FolkTransactionBuilder,
|
||||
FolkGovBinary,
|
||||
FolkGovThreshold,
|
||||
FolkGovKnob,
|
||||
FolkGovProject,
|
||||
FolkGovAmendment,
|
||||
FolkChoiceVote,
|
||||
FolkChoiceRank,
|
||||
FolkChoiceSpider,
|
||||
|
|
@ -2729,6 +2734,11 @@
|
|||
FolkCommitmentPool.define();
|
||||
FolkTaskRequest.define();
|
||||
FolkTransactionBuilder.define();
|
||||
FolkGovBinary.define();
|
||||
FolkGovThreshold.define();
|
||||
FolkGovKnob.define();
|
||||
FolkGovProject.define();
|
||||
FolkGovAmendment.define();
|
||||
FolkChoiceVote.define();
|
||||
FolkChoiceRank.define();
|
||||
FolkChoiceSpider.define();
|
||||
|
|
@ -2784,6 +2794,11 @@
|
|||
shapeRegistry.register("folk-commitment-pool", FolkCommitmentPool);
|
||||
shapeRegistry.register("folk-task-request", FolkTaskRequest);
|
||||
shapeRegistry.register("folk-transaction-builder", FolkTransactionBuilder);
|
||||
shapeRegistry.register("folk-gov-binary", FolkGovBinary);
|
||||
shapeRegistry.register("folk-gov-threshold", FolkGovThreshold);
|
||||
shapeRegistry.register("folk-gov-knob", FolkGovKnob);
|
||||
shapeRegistry.register("folk-gov-project", FolkGovProject);
|
||||
shapeRegistry.register("folk-gov-amendment", FolkGovAmendment);
|
||||
shapeRegistry.register("folk-choice-vote", FolkChoiceVote);
|
||||
shapeRegistry.register("folk-choice-rank", FolkChoiceRank);
|
||||
shapeRegistry.register("folk-choice-spider", FolkChoiceSpider);
|
||||
|
|
|
|||
Loading…
Reference in New Issue