diff --git a/lib/community-sync.ts b/lib/community-sync.ts index ab26942..db80ec0 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -514,6 +514,25 @@ export class CommunitySync extends EventTarget { wrapper.tags = data.tags; } } + + // Update token mint properties + if (data.type === "folk-token-mint") { + const mint = shape as any; + if (data.tokenName !== undefined && mint.tokenName !== data.tokenName) mint.tokenName = data.tokenName; + if (data.tokenSymbol !== undefined && mint.tokenSymbol !== data.tokenSymbol) mint.tokenSymbol = data.tokenSymbol; + if (data.description !== undefined && mint.description !== data.description) mint.description = data.description; + if (data.totalSupply !== undefined && mint.totalSupply !== data.totalSupply) mint.totalSupply = data.totalSupply; + if (data.issuedSupply !== undefined && mint.issuedSupply !== data.issuedSupply) mint.issuedSupply = data.issuedSupply; + if (data.tokenColor !== undefined && mint.tokenColor !== data.tokenColor) mint.tokenColor = data.tokenColor; + if (data.tokenIcon !== undefined && mint.tokenIcon !== data.tokenIcon) mint.tokenIcon = data.tokenIcon; + } + + // Update token ledger properties + if (data.type === "folk-token-ledger") { + const ledger = shape as any; + if (data.mintId !== undefined && ledger.mintId !== data.mintId) ledger.mintId = data.mintId; + if (data.entries !== undefined) ledger.entries = data.entries; + } } /** diff --git a/lib/folk-token-ledger.ts b/lib/folk-token-ledger.ts new file mode 100644 index 0000000..436130b --- /dev/null +++ b/lib/folk-token-ledger.ts @@ -0,0 +1,497 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 320px; + min-height: 280px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #6d28d9; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-title .symbol { + opacity: 0.8; + font-weight: 400; + font-size: 11px; + } + + .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); + } + + .ledger-body { + padding: 12px; + } + + .summary-row { + display: flex; + justify-content: space-between; + font-size: 12px; + margin-bottom: 4px; + } + + .summary-row .label { + color: #64748b; + } + + .summary-row .value { + font-weight: 600; + color: #1e293b; + } + + .holder-list { + max-height: 220px; + overflow-y: auto; + margin-top: 10px; + } + + .holder-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + font-size: 12px; + border-radius: 4px; + margin-bottom: 3px; + background: #f8fafc; + } + + .holder-item:hover { + background: #f1f5f9; + } + + .holder-info { + display: flex; + align-items: center; + gap: 6px; + } + + .holder-icon { + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + color: white; + flex-shrink: 0; + } + + .holder-name { + color: #1e293b; + font-weight: 500; + } + + .holder-memo { + font-size: 9px; + color: #94a3b8; + } + + .holder-amount { + font-weight: 600; + color: #6d28d9; + white-space: nowrap; + } + + .escrow-badge { + font-size: 9px; + background: #fef3c7; + color: #92400e; + padding: 1px 5px; + border-radius: 3px; + margin-left: 4px; + } + + .empty-state { + text-align: center; + padding: 16px; + color: #94a3b8; + font-size: 12px; + } + + .issue-form { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + } + + .issue-row { + display: flex; + gap: 4px; + } + + .issue-form input { + flex: 1; + padding: 6px 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 12px; + outline: none; + } + + .issue-form input:focus { + border-color: #6d28d9; + } + + .issue-form input.amount-input { + width: 80px; + flex: 0; + } + + .issue-btn { + padding: 6px 12px; + background: #6d28d9; + color: white; + border: none; + border-radius: 4px; + font-size: 11px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + } + + .issue-btn:hover { + background: #5b21b6; + } + + .issue-btn:disabled { + background: #cbd5e1; + cursor: not-allowed; + } +`; + +export interface LedgerEntry { + id: string; + holder: string; + holderLabel: string; + amount: number; + issuedAt: string; + issuedBy: string; + memo: string; +} + +declare global { + interface HTMLElementTagNameMap { + "folk-token-ledger": FolkTokenLedger; + } +} + +export class FolkTokenLedger extends FolkShape { + static override tagName = "folk-token-ledger"; + + 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; + } + + #mintId = ""; + #entries: LedgerEntry[] = []; + + #headerEl: HTMLElement | null = null; + #summaryEl: HTMLElement | null = null; + #listEl: HTMLElement | null = null; + #issueBtnEl: HTMLButtonElement | null = null; + + get mintId() { return this.#mintId; } + set mintId(v: string) { + this.#mintId = v; + this.#render(); + } + + get entries() { return this.#entries; } + set entries(v: LedgerEntry[]) { + this.#entries = v; + this.#render(); + this.#updateLinkedMint(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get totalIssued() { + return this.#entries.reduce((sum, e) => sum + e.amount, 0); + } + + addEntry(entry: LedgerEntry) { + this.#entries.push(entry); + this.#render(); + this.#updateLinkedMint(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + #getLinkedMint(): HTMLElement | null { + if (!this.#mintId) return null; + return document.getElementById(this.#mintId); + } + + #getMintInfo(): { name: string; symbol: string; color: string; totalSupply: number } { + const mint = this.#getLinkedMint() as any; + if (mint) { + return { + name: mint.tokenName || "Token", + symbol: mint.tokenSymbol || "TKN", + color: mint.tokenColor || "#6d28d9", + totalSupply: mint.totalSupply || 0, + }; + } + return { name: "Token", symbol: "TKN", color: "#6d28d9", totalSupply: 0 }; + } + + #updateLinkedMint() { + const mint = this.#getLinkedMint() as any; + if (mint && typeof mint.issuedSupply !== "undefined") { + mint.issuedSupply = this.totalIssued; + } + } + + #isEmail(s: string): boolean { + return s.includes("@") && !s.startsWith("did:"); + } + + #holderColor(holder: string): string { + let hash = 0; + for (let i = 0; i < holder.length; i++) { + hash = ((hash << 5) - hash + holder.charCodeAt(i)) | 0; + } + const hue = Math.abs(hash) % 360; + return `hsl(${hue}, 55%, 50%)`; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + \uD83D\uDCDC + Token Ledger + + +
+ +
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#headerEl = wrapper.querySelector(".header"); + this.#summaryEl = wrapper.querySelector(".summary"); + this.#listEl = wrapper.querySelector(".holder-list"); + + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + const holderInput = wrapper.querySelector(".holder-input") as HTMLInputElement; + const amountInput = wrapper.querySelector(".amount-input") as HTMLInputElement; + const memoInput = wrapper.querySelector(".memo-input") as HTMLInputElement; + this.#issueBtnEl = wrapper.querySelector(".issue-btn") as HTMLButtonElement; + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + const stopProp = (e: Event) => e.stopPropagation(); + holderInput.addEventListener("click", stopProp); + amountInput.addEventListener("click", stopProp); + memoInput.addEventListener("click", stopProp); + + const issueToken = () => { + const holder = holderInput.value.trim(); + const amount = Number(amountInput.value); + const memo = memoInput.value.trim(); + + if (!holder || !amount || amount <= 0) return; + + // Check supply + const mintInfo = this.#getMintInfo(); + if (mintInfo.totalSupply > 0 && this.totalIssued + amount > mintInfo.totalSupply) { + return; // Would exceed supply + } + + const label = this.#isEmail(holder) + ? holder.split("@")[0] + : holder.length > 16 ? `${holder.slice(0, 8)}...${holder.slice(-4)}` : holder; + + this.addEntry({ + id: `entry-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`, + holder, + holderLabel: label, + amount, + issuedAt: new Date().toISOString(), + issuedBy: "", + memo: memo || "Issued", + }); + + holderInput.value = ""; + amountInput.value = ""; + memoInput.value = ""; + }; + + this.#issueBtnEl.addEventListener("click", (e) => { + e.stopPropagation(); + issueToken(); + }); + + holderInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.stopPropagation(); issueToken(); } + }); + amountInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.stopPropagation(); issueToken(); } + }); + memoInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.stopPropagation(); issueToken(); } + }); + + this.#render(); + return root; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #render() { + if (!this.#headerEl || !this.#summaryEl || !this.#listEl) return; + + const mintInfo = this.#getMintInfo(); + + // Update header + this.#headerEl.style.background = mintInfo.color; + const nameEl = this.#headerEl.querySelector(".name"); + const symbolEl = this.#headerEl.querySelector(".symbol"); + if (nameEl) nameEl.textContent = `${mintInfo.name} Ledger`; + if (symbolEl) symbolEl.textContent = mintInfo.symbol; + + // Update issue button color + if (this.#issueBtnEl) { + this.#issueBtnEl.style.background = mintInfo.color; + } + + const totalIssued = this.totalIssued; + const remaining = mintInfo.totalSupply > 0 ? mintInfo.totalSupply - totalIssued : 0; + + this.#summaryEl.innerHTML = ` +
+ Total Issued + ${totalIssued.toLocaleString()} ${this.#escapeHtml(mintInfo.symbol)} +
+ ${mintInfo.totalSupply > 0 ? ` +
+ Remaining + ${remaining.toLocaleString()} ${this.#escapeHtml(mintInfo.symbol)} +
+ ` : ""} + `; + + // Aggregate balances by holder + const balances = new Map(); + for (const entry of this.#entries) { + const existing = balances.get(entry.holder); + if (existing) { + existing.total += entry.amount; + existing.lastMemo = entry.memo; + } else { + balances.set(entry.holder, { + label: entry.holderLabel, + total: entry.amount, + isEscrow: this.#isEmail(entry.holder), + lastMemo: entry.memo, + }); + } + } + + // Sort by balance descending + const sorted = [...balances.entries()].sort((a, b) => b[1].total - a[1].total); + + if (sorted.length === 0) { + this.#listEl.innerHTML = '
No tokens issued yet
'; + } else { + this.#listEl.innerHTML = sorted + .map(([holder, info]) => ` +
+
+
+ ${info.isEscrow ? '\u2709' : info.label.charAt(0).toUpperCase()} +
+
+
+ ${this.#escapeHtml(info.label)}${info.isEscrow ? 'escrow' : ''} +
+
${this.#escapeHtml(info.lastMemo)}
+
+
+ ${info.total.toLocaleString()} ${this.#escapeHtml(mintInfo.symbol)} +
+ `) + .join(""); + } + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-token-ledger", + mintId: this.#mintId, + entries: this.#entries, + }; + } +} diff --git a/lib/folk-token-mint.ts b/lib/folk-token-mint.ts new file mode 100644 index 0000000..6fdfb85 --- /dev/null +++ b/lib/folk-token-mint.ts @@ -0,0 +1,402 @@ +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; + +const styles = css` + :host { + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + min-width: 280px; + min-height: 200px; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-title .icon { + font-size: 16px; + } + + .header-title .symbol { + opacity: 0.8; + font-weight: 400; + font-size: 11px; + } + + .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); + } + + .mint-body { + padding: 12px; + } + + .desc { + font-size: 11px; + color: #64748b; + margin-bottom: 10px; + line-height: 1.4; + } + + .supply-row { + display: flex; + justify-content: space-between; + font-size: 12px; + margin-bottom: 4px; + } + + .supply-row .label { + color: #64748b; + } + + .supply-row .value { + font-weight: 600; + color: #1e293b; + } + + .progress-bar { + width: 100%; + height: 8px; + background: #e2e8f0; + border-radius: 4px; + overflow: hidden; + margin: 8px 0 12px; + } + + .progress-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; + } + + .edit-form { + padding: 8px 12px; + border-top: 1px solid #e2e8f0; + display: flex; + flex-direction: column; + gap: 4px; + } + + .edit-row { + display: flex; + gap: 4px; + } + + .edit-form input { + flex: 1; + padding: 5px 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + font-size: 11px; + outline: none; + } + + .edit-form input:focus { + border-color: #8b5cf6; + } + + .edit-form input.short { + width: 70px; + flex: 0; + } + + .edit-form input.color-input { + width: 36px; + flex: 0; + padding: 2px; + cursor: pointer; + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-token-mint": FolkTokenMint; + } +} + +export class FolkTokenMint extends FolkShape { + static override tagName = "folk-token-mint"; + + 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; + } + + #tokenName = "New Token"; + #tokenSymbol = "TKN"; + #description = ""; + #totalSupply = 1000; + #issuedSupply = 0; + #tokenColor = "#8b5cf6"; + #tokenIcon = "\uD83E\uDE99"; + #createdBy = ""; + #createdAt = new Date().toISOString(); + + #headerEl: HTMLElement | null = null; + #summaryEl: HTMLElement | null = null; + #progressEl: HTMLElement | null = null; + #descEl: HTMLElement | null = null; + + get tokenName() { return this.#tokenName; } + set tokenName(v: string) { + this.#tokenName = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get tokenSymbol() { return this.#tokenSymbol; } + set tokenSymbol(v: string) { + this.#tokenSymbol = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get description() { return this.#description; } + set description(v: string) { + this.#description = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get totalSupply() { return this.#totalSupply; } + set totalSupply(v: number) { + this.#totalSupply = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get issuedSupply() { return this.#issuedSupply; } + set issuedSupply(v: number) { + this.#issuedSupply = v; + this.#render(); + } + + get tokenColor() { return this.#tokenColor; } + set tokenColor(v: string) { + this.#tokenColor = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get tokenIcon() { return this.#tokenIcon; } + set tokenIcon(v: string) { + this.#tokenIcon = v; + this.#render(); + this.dispatchEvent(new CustomEvent("content-change")); + } + + get createdBy() { return this.#createdBy; } + set createdBy(v: string) { + this.#createdBy = v; + } + + get createdAt() { return this.#createdAt; } + set createdAt(v: string) { + this.#createdAt = v; + } + + get remaining() { + return this.#totalSupply - this.#issuedSupply; + } + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.innerHTML = html` +
+ + ${this.#tokenIcon} + ${this.#tokenName} + ${this.#tokenSymbol} + +
+ +
+
+
+
+
+
+
+
+
+ + +
+
+ + +
+
+ +
+
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) { + containerDiv.replaceWith(wrapper); + } + + this.#headerEl = wrapper.querySelector(".header"); + this.#summaryEl = wrapper.querySelector(".supply-summary"); + this.#progressEl = wrapper.querySelector(".progress-fill"); + this.#descEl = wrapper.querySelector(".desc"); + + const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement; + const nameInput = wrapper.querySelector(".name-input") as HTMLInputElement; + const symbolInput = wrapper.querySelector(".symbol-input") as HTMLInputElement; + const supplyInput = wrapper.querySelector(".supply-input") as HTMLInputElement; + const colorInput = wrapper.querySelector(".color-input") as HTMLInputElement; + const descInput = wrapper.querySelector(".desc-input") as HTMLInputElement; + + // Set initial input values + nameInput.value = this.#tokenName; + symbolInput.value = this.#tokenSymbol; + supplyInput.value = String(this.#totalSupply); + colorInput.value = this.#tokenColor; + descInput.value = this.#description; + + closeBtn.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + const stopProp = (e: Event) => e.stopPropagation(); + + nameInput.addEventListener("click", stopProp); + symbolInput.addEventListener("click", stopProp); + supplyInput.addEventListener("click", stopProp); + colorInput.addEventListener("click", stopProp); + descInput.addEventListener("click", stopProp); + + nameInput.addEventListener("change", () => { + if (nameInput.value.trim()) this.tokenName = nameInput.value.trim(); + }); + nameInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.stopPropagation(); nameInput.blur(); } + }); + + symbolInput.addEventListener("change", () => { + if (symbolInput.value.trim()) this.tokenSymbol = symbolInput.value.trim().toUpperCase(); + }); + symbolInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.stopPropagation(); symbolInput.blur(); } + }); + + supplyInput.addEventListener("change", () => { + const val = Number(supplyInput.value); + if (val > 0) this.totalSupply = val; + }); + supplyInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.stopPropagation(); supplyInput.blur(); } + }); + + colorInput.addEventListener("input", () => { + this.tokenColor = colorInput.value; + }); + + descInput.addEventListener("change", () => { + this.description = descInput.value.trim(); + }); + descInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.stopPropagation(); descInput.blur(); } + }); + + this.#render(); + return root; + } + + #escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; + } + + #render() { + if (!this.#headerEl || !this.#summaryEl || !this.#progressEl) return; + + this.#headerEl.style.background = this.#tokenColor; + const nameSpan = this.#headerEl.querySelector(".name"); + const symbolSpan = this.#headerEl.querySelector(".symbol"); + const iconSpan = this.#headerEl.querySelector(".icon"); + if (nameSpan) nameSpan.textContent = this.#tokenName; + if (symbolSpan) symbolSpan.textContent = this.#tokenSymbol; + if (iconSpan) iconSpan.textContent = this.#tokenIcon; + + if (this.#descEl) { + this.#descEl.textContent = this.#description; + this.#descEl.style.display = this.#description ? "block" : "none"; + } + + const pct = this.#totalSupply > 0 ? (this.#issuedSupply / this.#totalSupply) * 100 : 0; + + this.#summaryEl.innerHTML = ` +
+ Total Supply + ${this.#totalSupply.toLocaleString()} +
+
+ Issued + ${this.#issuedSupply.toLocaleString()} +
+
+ Remaining + ${this.remaining.toLocaleString()} +
+ `; + + this.#progressEl.style.width = `${Math.min(pct, 100)}%`; + this.#progressEl.style.background = this.#tokenColor; + } + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-token-mint", + tokenName: this.#tokenName, + tokenSymbol: this.#tokenSymbol, + description: this.#description, + totalSupply: this.#totalSupply, + issuedSupply: this.#issuedSupply, + tokenColor: this.#tokenColor, + tokenIcon: this.#tokenIcon, + createdBy: this.#createdBy, + createdAt: this.#createdAt, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index 34cd828..1e11a0c 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -51,6 +51,10 @@ export * from "./folk-budget"; export * from "./folk-packing-list"; export * from "./folk-booking"; +// Token Shapes +export * from "./folk-token-mint"; +export * from "./folk-token-ledger"; + // Sync export * from "./community-sync"; export * from "./presence"; diff --git a/server/seed-demo.ts b/server/seed-demo.ts index a05e6b7..b875999 100644 --- a/server/seed-demo.ts +++ b/server/seed-demo.ts @@ -410,6 +410,78 @@ const DEMO_SHAPES: Record[] = [ { name: "Passport + insurance", packed: true, category: "documents" }, ], }, + + // ─── rTokens: Governance Token ───────────────────────────── + { + id: "demo-mint-gov", + type: "folk-token-mint", + x: 1550, y: 50, width: 320, height: 280, rotation: 0, + tokenName: "Governance Token", + tokenSymbol: "GOV", + description: "Equal voting weight for group decisions. Each holder gets one share of governance power.", + totalSupply: 100, + issuedSupply: 100, + tokenColor: "#6d28d9", + tokenIcon: "\uD83D\uDDF3\uFE0F", + createdBy: "Maya", + createdAt: "2026-06-15T10:00:00.000Z", + }, + { + id: "demo-ledger-gov", + type: "folk-token-ledger", + x: 1940, y: 50, width: 380, height: 400, rotation: 0, + mintId: "demo-mint-gov", + entries: [ + { id: "gov-1", holder: "did:key:maya", holderLabel: "Maya", amount: 25, issuedAt: "2026-06-15T10:00:00.000Z", issuedBy: "Maya", memo: "Founding member" }, + { id: "gov-2", holder: "did:key:liam", holderLabel: "Liam", amount: 25, issuedAt: "2026-06-15T10:00:00.000Z", issuedBy: "Maya", memo: "Founding member" }, + { id: "gov-3", holder: "did:key:priya", holderLabel: "Priya", amount: 25, issuedAt: "2026-06-15T10:00:00.000Z", issuedBy: "Maya", memo: "Founding member" }, + { id: "gov-4", holder: "did:key:omar", holderLabel: "Omar", amount: 25, issuedAt: "2026-06-15T10:00:00.000Z", issuedBy: "Maya", memo: "Founding member" }, + ], + }, + { + id: "demo-arrow-gov", + type: "folk-arrow", + x: 0, y: 0, width: 0, height: 0, rotation: 0, + sourceId: "demo-mint-gov", + targetId: "demo-ledger-gov", + color: "#6d28d9", + }, + + // ─── rTokens: Contribution Credits ───────────────────────── + { + id: "demo-mint-cred", + type: "folk-token-mint", + x: 1550, y: 500, width: 320, height: 280, rotation: 0, + tokenName: "Contribution Credits", + tokenSymbol: "CRED", + description: "Earned through contributions to trip planning. Redeemable for activity priority picks.", + totalSupply: 500, + issuedSupply: 155, + tokenColor: "#059669", + tokenIcon: "\u2B50", + createdBy: "Maya", + createdAt: "2026-06-20T14:00:00.000Z", + }, + { + id: "demo-ledger-cred", + type: "folk-token-ledger", + x: 1940, y: 500, width: 380, height: 400, rotation: 0, + mintId: "demo-mint-cred", + entries: [ + { id: "cred-1", holder: "did:key:maya", holderLabel: "Maya", amount: 50, issuedAt: "2026-06-20T14:00:00.000Z", issuedBy: "Maya", memo: "Organized itinerary" }, + { id: "cred-2", holder: "did:key:liam", holderLabel: "Liam", amount: 40, issuedAt: "2026-06-22T09:00:00.000Z", issuedBy: "Maya", memo: "Gear research & drone rental" }, + { id: "cred-3", holder: "did:key:priya", holderLabel: "Priya", amount: 35, issuedAt: "2026-06-25T11:00:00.000Z", issuedBy: "Maya", memo: "Hut reservations" }, + { id: "cred-4", holder: "omar@example.com", holderLabel: "omar", amount: 30, issuedAt: "2026-06-28T16:00:00.000Z", issuedBy: "Maya", memo: "Emergency planning (escrow)" }, + ], + }, + { + id: "demo-arrow-cred", + type: "folk-arrow", + x: 0, y: 0, width: 0, height: 0, rotation: 0, + sourceId: "demo-mint-cred", + targetId: "demo-ledger-cred", + color: "#059669", + }, ]; /** diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 198730b..605f89a 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -70,6 +70,8 @@ const CONFIG = { 'https://rtrips.online', 'https://rnetwork.online', 'https://rcart.online', + 'https://rtube.online', + 'https://rstack.online', 'http://localhost:3000', 'http://localhost:5173', ], @@ -982,6 +984,8 @@ app.get('/', (c) => { rFunds rNetwork rCart + rTube + rStack diff --git a/website/canvas.html b/website/canvas.html index ef97197..4582c5a 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -167,7 +167,9 @@ folk-destination, folk-budget, folk-packing-list, - folk-booking { + folk-booking, + folk-token-mint, + folk-token-ledger { position: absolute; } @@ -176,7 +178,7 @@ folk-image-gen, folk-video-gen, folk-prompt, folk-transcription, folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, - folk-booking) { + folk-booking, folk-token-mint, folk-token-ledger) { cursor: crosshair; } @@ -185,7 +187,7 @@ folk-image-gen, folk-video-gen, folk-prompt, folk-transcription, folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, - folk-booking):hover { + folk-booking, folk-token-mint, folk-token-ledger):hover { outline: 2px dashed #3b82f6; outline-offset: 4px; } @@ -224,6 +226,7 @@ + @@ -262,6 +265,8 @@ FolkBudget, FolkPackingList, FolkBooking, + FolkTokenMint, + FolkTokenLedger, CommunitySync, PresenceManager, generatePeerId @@ -291,6 +296,8 @@ FolkBudget.define(); FolkPackingList.define(); FolkBooking.define(); + FolkTokenMint.define(); + FolkTokenLedger.define(); // Get community info from URL const hostname = window.location.hostname; @@ -314,7 +321,8 @@ "folk-map", "folk-image-gen", "folk-video-gen", "folk-prompt", "folk-transcription", "folk-video-chat", "folk-obs-note", "folk-workflow-block", "folk-itinerary", "folk-destination", - "folk-budget", "folk-packing-list", "folk-booking" + "folk-budget", "folk-packing-list", "folk-booking", + "folk-token-mint", "folk-token-ledger" ].join(", "); // Initialize CommunitySync @@ -549,6 +557,23 @@ if (data.endDate) shape.endDate = data.endDate; if (data.bookingStatus) shape.bookingStatus = data.bookingStatus; break; + case "folk-token-mint": + shape = document.createElement("folk-token-mint"); + if (data.tokenName) shape.tokenName = data.tokenName; + if (data.tokenSymbol) shape.tokenSymbol = data.tokenSymbol; + if (data.description) shape.description = data.description; + if (data.totalSupply != null) shape.totalSupply = data.totalSupply; + if (data.issuedSupply != null) shape.issuedSupply = data.issuedSupply; + if (data.tokenColor) shape.tokenColor = data.tokenColor; + if (data.tokenIcon) shape.tokenIcon = data.tokenIcon; + if (data.createdBy) shape.createdBy = data.createdBy; + if (data.createdAt) shape.createdAt = data.createdAt; + break; + case "folk-token-ledger": + shape = document.createElement("folk-token-ledger"); + if (data.mintId) shape.mintId = data.mintId; + if (data.entries) shape.entries = data.entries; + break; case "folk-markdown": default: shape = document.createElement("folk-markdown"); @@ -612,6 +637,8 @@ "folk-budget": { width: 300, height: 350 }, "folk-packing-list": { width: 280, height: 350 }, "folk-booking": { width: 300, height: 240 }, + "folk-token-mint": { width: 320, height: 280 }, + "folk-token-ledger": { width: 380, height: 400 }, }; // Get the center of the current viewport in canvas coordinates @@ -713,6 +740,38 @@ document.getElementById("add-packing-list").addEventListener("click", () => createAndAddShape("folk-packing-list")); document.getElementById("add-booking").addEventListener("click", () => createAndAddShape("folk-booking")); + // Token creation - creates a mint + ledger pair with connecting arrow + document.getElementById("add-token").addEventListener("click", () => { + const mint = createAndAddShape("folk-token-mint", { + tokenName: "New Token", + tokenSymbol: "TKN", + totalSupply: 1000, + tokenColor: "#8b5cf6", + tokenIcon: "🪙", + createdAt: new Date().toISOString(), + }); + if (mint) { + const ledger = createAndAddShape("folk-token-ledger", { + mintId: mint.id, + entries: [], + }); + if (ledger) { + // Position ledger to the right of mint + ledger.x = mint.x + mint.width + 60; + ledger.y = mint.y; + // Connect with an arrow + const arrowId = `arrow-${Date.now()}-${++shapeCounter}`; + const arrow = document.createElement("folk-arrow"); + arrow.id = arrowId; + arrow.sourceId = mint.id; + arrow.targetId = ledger.id; + arrow.color = "#8b5cf6"; + canvas.appendChild(arrow); + sync.registerShape(arrow); + } + } + }); + // Arrow connection mode let connectMode = false; let connectSource = null; diff --git a/website/index.html b/website/index.html index 9da27c8..71802c0 100644 --- a/website/index.html +++ b/website/index.html @@ -20,7 +20,15 @@ display: flex; flex-direction: column; align-items: center; + } + + .hero { + display: flex; + flex-direction: column; + align-items: center; justify-content: center; + min-height: 100vh; + width: 100%; } .container { @@ -150,57 +158,359 @@ color: #22c55e; font-size: 0.875rem; } + + /* EncryptID & Ecosystem Sections */ + + .section { + width: 100%; + max-width: 760px; + padding: 5rem 20px; + text-align: center; + } + + .section + .section { + padding-top: 0; + } + + .section-divider { + width: 60px; + height: 2px; + background: linear-gradient(90deg, #14b8a6, #7c3aed); + margin: 0 auto 3rem; + border-radius: 2px; + } + + .section h2 { + font-size: 2rem; + margin-bottom: 0.75rem; + background: linear-gradient(90deg, #00d4ff, #7c3aed); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + } + + .section-subtitle { + font-size: 1.05rem; + color: #94a3b8; + margin-bottom: 2.5rem; + line-height: 1.7; + max-width: 560px; + margin-left: auto; + margin-right: auto; + } + + .identity-card { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(124, 58, 237, 0.2); + border-radius: 16px; + padding: 2rem; + text-align: left; + margin-bottom: 1.5rem; + } + + .identity-card h3 { + font-size: 1.15rem; + margin-bottom: 0.75rem; + color: #e2e8f0; + } + + .identity-card p { + color: #94a3b8; + line-height: 1.7; + font-size: 0.95rem; + } + + .identity-card .hl { + color: #00d4ff; + font-weight: 600; + } + + .pillars { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 2rem; + } + + .pillar { + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + padding: 1.25rem 1rem; + text-align: center; + } + + .pillar-icon { + font-size: 1.75rem; + margin-bottom: 0.5rem; + } + + .pillar-title { + font-weight: 600; + font-size: 0.9rem; + margin-bottom: 0.25rem; + } + + .pillar-desc { + font-size: 0.8rem; + color: #64748b; + line-height: 1.4; + } + + .flow-diagram { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 1.75rem; + margin-bottom: 2rem; + } + + .flow-diagram pre { + font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; + font-size: 0.78rem; + color: #94a3b8; + line-height: 1.5; + text-align: left; + overflow-x: auto; + white-space: pre; + } + + .ecosystem-apps { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.75rem; + margin-top: 1.5rem; + } + + .ecosystem-app { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + color: #94a3b8; + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + transition: border-color 0.2s, color 0.2s; + } + + .ecosystem-app:hover { + border-color: rgba(0, 212, 255, 0.4); + color: #00d4ff; + } + + .encryptid-link { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 1.5rem; + background: linear-gradient(90deg, rgba(0, 212, 255, 0.1), rgba(124, 58, 237, 0.1)); + border: 1px solid rgba(124, 58, 237, 0.3); + border-radius: 8px; + color: #c4b5fd; + text-decoration: none; + font-size: 0.9rem; + font-weight: 600; + margin-top: 2rem; + transition: transform 0.2s, border-color 0.2s; + } + + .encryptid-link:hover { + transform: translateY(-2px); + border-color: rgba(124, 58, 237, 0.6); + } + + @media (max-width: 600px) { + .pillars { + grid-template-columns: 1fr; + } + .features { + grid-template-columns: 1fr; + } + .section h2 { + font-size: 1.5rem; + } + } -
-

rSpace

-

Collaborative community spaces powered by FolkJS

+
+
+

rSpace

+

Collaborative community spaces powered by FolkJS

-
-
- - -
-
- - -

Your space will be at: ___.rspace.online

-
- - -
+
+
+ + +
+
+ + +

Your space will be at: ___.rspace.online

+
+ + +
-
-
-
🎨
-
Spatial Canvas
-
Infinite collaborative workspace
-
-
-
🔄
-
Real-time Sync
-
Powered by Automerge CRDT
-
-
-
🌐
-
Your Subdomain
-
community.rspace.online
+
+
+
🎨
+
Spatial Canvas
+
Infinite collaborative workspace
+
+
+
🔄
+
Real-time Sync
+
Powered by Automerge CRDT
+
+
+
🌐
+
Your Subdomain
+
community.rspace.online
+
+ +
+
+

EncryptID

+

+ One secure, local-first identity across every tool in your community's rSpace. + No passwords. No cloud accounts. Your keys never leave your device. +

+ +
+
+
🔑
+
Passkey Login
+
Hardware-backed biometric auth. Phishing-resistant by design.
+
+
+
🏠
+
Local-First
+
Cryptographic keys are derived and stored on your device, never uploaded.
+
+
+
🔗
+
One Login, All Apps
+
Authenticate once and access every r-Ecosystem tool seamlessly.
+
+
+ +
+

Secure by default, not by opt-in

+

+ EncryptID uses WebAuthn passkeys as the root of trust — + the same standard behind Face ID and fingerprint unlock. Your identity is bound to + your device's secure hardware, so there are no passwords to leak, phish, or forget. + End-to-end encryption keys are derived locally via HKDF, + meaning the server never sees your private keys. If you lose your device, + social recovery lets trusted guardians help you regain access + without seed phrases or centralized reset flows. +

+
+ +
+

A common login for your community's toolkit

+

+ Every community rSpace comes with a full suite of interoperable tools — + voting, budgets, maps, files, notes, and more — all sharing the same + EncryptID session. Sign in once on rSpace and you're + already authenticated on rVote, rFunds, rFiles, and every other tool your + community uses. No separate accounts, no OAuth redirects, no third-party identity + providers. Your community, your identity, your data. +

+
+
+ + +
+
+

Interoperable by Design

+

+ Data flows between your community's tools because they share a common + foundation: the same identity, the same real-time sync layer, and the same + local-first architecture. +

+ +
+
+  EncryptID (identity)
+       |
+       v
+  ┌─────────── rSpace CRDT Sync Layer ───────────┐
+  |                                               |
+  |   rVote  rFunds  rFiles  rNotes  rMaps  ...   |
+  |     |       |       |       |       |         |
+  |     └───────┴───────┴───────┴───────┘         |
+  |         shared community data graph           |
+  └───────────────────────────────────────────────┘
+       |
+       v
+  Your device (keys & data stay here)
+
+
+ +
+

Your data, connected across tools

+

+ A budget created in rFunds can reference a vote + from rVote. A map pin in rMaps + can link to files in rFiles and notes in + rNotes. Because all r-Ecosystem tools share the same + Automerge CRDT sync layer, data is interoperable + without import/export steps or API integrations. Changes propagate in real-time + across every tool and every collaborator — conflict-free. +

+
+ +
+

No vendor lock-in, no data silos

+

+ Every piece of community data is stored as a local-first CRDT document + that your community owns. There's no central server gating access and no + proprietary format trapping your data. Export everything. Fork your community. + Move between hosts. The r-Ecosystem is designed + so that the community — not the platform — controls the data. +

+
+ + + + + 🔐 Learn more about EncryptID + +
+