346 lines
8.7 KiB
TypeScript
346 lines
8.7 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|
|
}
|