diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 58b5621..ed3fc05 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -1,6 +1,7 @@ import * as Automerge from "@automerge/automerge"; import type { FolkShape } from "./folk-shape"; import type { OfflineStore } from "./offline-store"; +import type { Layer, LayerFlow } from "./layer-types"; // Shape data stored in Automerge document export interface ShapeData { @@ -79,6 +80,18 @@ export interface CommunityDoc { nestedSpaces?: { [refId: string]: SpaceRef; }; + /** Tab/layer system — each layer is an rApp page in this space */ + layers?: { + [id: string]: Layer; + }; + /** Inter-layer flows (economic, trust, data, etc.) */ + flows?: { + [id: string]: LayerFlow; + }; + /** Currently active layer ID */ + activeLayerId?: string; + /** Layer view mode: flat (tabs) or stack (side view) */ + layerViewMode?: "flat" | "stack"; } type SyncState = Automerge.SyncState; @@ -756,6 +769,18 @@ export class CommunitySync extends EventTarget { if (data.scores !== undefined) spider.scores = data.scores; } + // Update feed shape properties + if (data.type === "folk-feed") { + const feed = shape as any; + if (data.sourceLayer !== undefined && feed.sourceLayer !== data.sourceLayer) feed.sourceLayer = data.sourceLayer; + if (data.sourceModule !== undefined && feed.sourceModule !== data.sourceModule) feed.sourceModule = data.sourceModule; + if (data.feedId !== undefined && feed.feedId !== data.feedId) feed.feedId = data.feedId; + if (data.flowKind !== undefined && feed.flowKind !== data.flowKind) feed.flowKind = data.flowKind; + if (data.feedFilter !== undefined && feed.feedFilter !== data.feedFilter) feed.feedFilter = data.feedFilter; + if (data.maxItems !== undefined && feed.maxItems !== data.maxItems) feed.maxItems = data.maxItems; + if (data.refreshInterval !== undefined && feed.refreshInterval !== data.refreshInterval) feed.refreshInterval = data.refreshInterval; + } + // Update social-post properties if (data.type === "folk-social-post") { const post = shape as any; @@ -829,6 +854,130 @@ export class CommunitySync extends EventTarget { } } + // ── Layer & Flow API ── + + /** Add a layer to the document */ + addLayer(layer: Layer): void { + this.#doc = Automerge.change(this.#doc, `Add layer ${layer.id}`, (doc) => { + if (!doc.layers) doc.layers = {}; + doc.layers[layer.id] = layer; + }); + this.#scheduleSave(); + this.#syncToServer(); + this.dispatchEvent(new CustomEvent("layer-added", { detail: layer })); + } + + /** Remove a layer */ + removeLayer(layerId: string): void { + this.#doc = Automerge.change(this.#doc, `Remove layer ${layerId}`, (doc) => { + if (doc.layers && doc.layers[layerId]) { + delete doc.layers[layerId]; + } + // Remove flows connected to this layer + if (doc.flows) { + for (const [fid, flow] of Object.entries(doc.flows)) { + if (flow.sourceLayerId === layerId || flow.targetLayerId === layerId) { + delete doc.flows[fid]; + } + } + } + // If active layer was removed, switch to first remaining + if (doc.activeLayerId === layerId) { + const remaining = doc.layers ? Object.keys(doc.layers) : []; + doc.activeLayerId = remaining[0] || ""; + } + }); + this.#scheduleSave(); + this.#syncToServer(); + this.dispatchEvent(new CustomEvent("layer-removed", { detail: { layerId } })); + } + + /** Update a layer's properties */ + updateLayer(layerId: string, updates: Partial): void { + this.#doc = Automerge.change(this.#doc, `Update layer ${layerId}`, (doc) => { + if (doc.layers && doc.layers[layerId]) { + for (const [key, value] of Object.entries(updates)) { + (doc.layers[layerId] as unknown as Record)[key] = value; + } + } + }); + this.#scheduleSave(); + this.#syncToServer(); + } + + /** Set active layer */ + setActiveLayer(layerId: string): void { + this.#doc = Automerge.change(this.#doc, `Switch to layer ${layerId}`, (doc) => { + doc.activeLayerId = layerId; + }); + this.#scheduleSave(); + this.#syncToServer(); + this.dispatchEvent(new CustomEvent("active-layer-changed", { detail: { layerId } })); + } + + /** Set layer view mode */ + setLayerViewMode(mode: "flat" | "stack"): void { + this.#doc = Automerge.change(this.#doc, `Set view mode ${mode}`, (doc) => { + doc.layerViewMode = mode; + }); + this.#scheduleSave(); + this.#syncToServer(); + } + + /** Add a flow between layers */ + addFlow(flow: LayerFlow): void { + this.#doc = Automerge.change(this.#doc, `Add flow ${flow.id}`, (doc) => { + if (!doc.flows) doc.flows = {}; + doc.flows[flow.id] = flow; + }); + this.#scheduleSave(); + this.#syncToServer(); + this.dispatchEvent(new CustomEvent("flow-added", { detail: flow })); + } + + /** Remove a flow */ + removeFlow(flowId: string): void { + this.#doc = Automerge.change(this.#doc, `Remove flow ${flowId}`, (doc) => { + if (doc.flows && doc.flows[flowId]) { + delete doc.flows[flowId]; + } + }); + this.#scheduleSave(); + this.#syncToServer(); + } + + /** Update flow properties */ + updateFlow(flowId: string, updates: Partial): void { + this.#doc = Automerge.change(this.#doc, `Update flow ${flowId}`, (doc) => { + if (doc.flows && doc.flows[flowId]) { + for (const [key, value] of Object.entries(updates)) { + (doc.flows[flowId] as unknown as Record)[key] = value; + } + } + }); + this.#scheduleSave(); + this.#syncToServer(); + } + + /** Get all layers (sorted by order) */ + getLayers(): Layer[] { + const layers = this.#doc.layers || {}; + return Object.values(layers).sort((a, b) => a.order - b.order); + } + + /** Get all flows */ + getFlows(): LayerFlow[] { + const flows = this.#doc.flows || {}; + return Object.values(flows); + } + + /** Get flows for a specific layer (as source or target) */ + getFlowsForLayer(layerId: string): LayerFlow[] { + return this.getFlows().filter( + f => f.sourceLayerId === layerId || f.targetLayerId === layerId + ); + } + /** * Disconnect from server */ diff --git a/lib/folk-feed.ts b/lib/folk-feed.ts new file mode 100644 index 0000000..62463cf --- /dev/null +++ b/lib/folk-feed.ts @@ -0,0 +1,887 @@ +/** + * — Canvas shape that renders a live feed from another layer. + * + * Bridges layers by pulling data from a source layer's module API endpoint + * and rendering it as a live, updating feed within the current canvas. + * + * Attributes: + * source-layer — source layer ID + * source-module — source module ID (e.g. "notes", "funds", "vote") + * feed-id — which feed to pull (e.g. "recent-notes", "proposals") + * flow-kind — flow type for visual styling ("economic", "trust", "data", etc.) + * feed-filter — optional JSON filter string + * max-items — max items to display (default 10) + * refresh-interval — auto-refresh ms (default 30000, 0 = manual only) + * + * The shape auto-fetches from /{space}/{source-module}/api/{feed-endpoint} + * and renders results as a scrollable card list. + */ + +import { FolkShape } from "./folk-shape"; +import { FLOW_COLORS, FLOW_LABELS } from "./layer-types"; +import type { FlowKind } from "./layer-types"; + +export class FolkFeed extends FolkShape { + static tagName = "folk-feed"; + + #feedData: any[] = []; + #loading = false; + #error: string | null = null; + #refreshTimer: ReturnType | null = null; + #inner: HTMLElement | null = null; + #editingIndex: number | null = null; + + static get observedAttributes() { + return [ + ...FolkShape.observedAttributes, + "source-layer", "source-module", "feed-id", "flow-kind", + "feed-filter", "max-items", "refresh-interval", + ]; + } + + get sourceLayer(): string { return this.getAttribute("source-layer") || ""; } + set sourceLayer(v: string) { this.setAttribute("source-layer", v); } + + get sourceModule(): string { return this.getAttribute("source-module") || ""; } + set sourceModule(v: string) { this.setAttribute("source-module", v); } + + get feedId(): string { return this.getAttribute("feed-id") || ""; } + set feedId(v: string) { this.setAttribute("feed-id", v); } + + get flowKind(): FlowKind { return (this.getAttribute("flow-kind") as FlowKind) || "data"; } + set flowKind(v: FlowKind) { this.setAttribute("flow-kind", v); } + + get feedFilter(): string { return this.getAttribute("feed-filter") || ""; } + set feedFilter(v: string) { this.setAttribute("feed-filter", v); } + + get maxItems(): number { return parseInt(this.getAttribute("max-items") || "10", 10); } + set maxItems(v: number) { this.setAttribute("max-items", String(v)); } + + get refreshInterval(): number { return parseInt(this.getAttribute("refresh-interval") || "30000", 10); } + set refreshInterval(v: number) { this.setAttribute("refresh-interval", String(v)); } + + connectedCallback() { + super.connectedCallback(); + this.#buildUI(); + this.#fetchFeed(); + this.#startAutoRefresh(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#stopAutoRefresh(); + } + + attributeChangedCallback(name: string, oldVal: string | null, newVal: string | null) { + super.attributeChangedCallback(name, oldVal, newVal); + if (["source-module", "feed-id", "feed-filter", "max-items"].includes(name)) { + this.#fetchFeed(); + } + if (name === "refresh-interval") { + this.#stopAutoRefresh(); + this.#startAutoRefresh(); + } + if (name === "flow-kind") { + this.#updateHeader(); + } + } + + // ── Build the inner UI ── + + #buildUI() { + if (this.#inner) return; + + this.#inner = document.createElement("div"); + this.#inner.className = "folk-feed-inner"; + this.#inner.innerHTML = ` +
+
+
+ + +
+
+
+
+ `; + + const style = document.createElement("style"); + style.textContent = FEED_STYLES; + this.#inner.prepend(style); + + this.appendChild(this.#inner); + + // Refresh button + this.#inner.querySelector(".feed-refresh")?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#fetchFeed(); + }); + + // Navigate to source layer + this.#inner.querySelector(".feed-navigate")?.addEventListener("click", (e) => { + e.stopPropagation(); + if (this.sourceModule) { + const space = this.#getSpaceSlug(); + window.location.href = `/${space}/${this.sourceModule}`; + } + }); + + this.#updateHeader(); + } + + #updateHeader() { + if (!this.#inner) return; + const color = FLOW_COLORS[this.flowKind] || "#94a3b8"; + const label = FLOW_LABELS[this.flowKind] || "Feed"; + const dot = this.#inner.querySelector(".feed-kind-dot"); + const title = this.#inner.querySelector(".feed-title"); + if (dot) dot.style.background = color; + if (title) title.textContent = `${this.sourceModule} / ${this.feedId || label}`; + } + + // ── Fetch feed data ── + + async #fetchFeed() { + if (!this.sourceModule) return; + if (this.#loading) return; + + this.#loading = true; + this.#updateStatus("loading"); + + try { + // Construct feed URL based on feed ID + const space = this.#getSpaceSlug(); + const feedEndpoint = this.#getFeedEndpoint(); + const url = `/${space}/${this.sourceModule}/api/${feedEndpoint}`; + + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + + const data = await res.json(); + + // Normalize: extract the array from common response shapes + if (Array.isArray(data)) { + this.#feedData = data.slice(0, this.maxItems); + } else if (data.notes) { + this.#feedData = data.notes.slice(0, this.maxItems); + } else if (data.notebooks) { + this.#feedData = data.notebooks.slice(0, this.maxItems); + } else if (data.proposals) { + this.#feedData = data.proposals.slice(0, this.maxItems); + } else if (data.tasks) { + this.#feedData = data.tasks.slice(0, this.maxItems); + } else if (data.nodes) { + this.#feedData = data.nodes.slice(0, this.maxItems); + } else if (data.flows) { + this.#feedData = data.flows.slice(0, this.maxItems); + } else { + // Try to use the data as-is if it has array-like fields + const firstArray = Object.values(data).find(v => Array.isArray(v)); + this.#feedData = firstArray ? (firstArray as any[]).slice(0, this.maxItems) : [data]; + } + + this.#error = null; + this.#renderItems(); + this.#updateStatus("ok"); + } catch (err: any) { + this.#error = err.message || "Failed to fetch"; + this.#updateStatus("error"); + } finally { + this.#loading = false; + } + } + + #getFeedEndpoint(): string { + // Map feed IDs to actual API endpoints + const FEED_ENDPOINTS: Record> = { + notes: { + "notes-by-tag": "notes", + "recent-notes": "notes", + default: "notes", + }, + funds: { + "treasury-flows": "flows", + "transactions": "flows", + default: "flows", + }, + vote: { + proposals: "proposals", + decisions: "proposals?status=PASSED,FAILED", + default: "proposals", + }, + choices: { + "poll-results": "choices", + default: "choices", + }, + wallet: { + balances: "safe/detect", + transfers: "safe/detect", + default: "safe/detect", + }, + data: { + analytics: "stats", + "active-users": "active", + default: "stats", + }, + work: { + "task-activity": "spaces", + "board-summary": "spaces", + default: "spaces", + }, + network: { + "trust-graph": "graph", + connections: "people", + default: "graph", + }, + trips: { + "trip-expenses": "trips", + itinerary: "trips", + default: "trips", + }, + }; + + const moduleEndpoints = FEED_ENDPOINTS[this.sourceModule]; + if (!moduleEndpoints) return this.feedId || "info"; + return moduleEndpoints[this.feedId] || moduleEndpoints.default || this.feedId; + } + + #getSpaceSlug(): string { + // Try to get from URL + const parts = window.location.pathname.split("/").filter(Boolean); + return parts[0] || "demo"; + } + + // ── Render feed items ── + + #renderItems() { + const container = this.#inner?.querySelector(".feed-items"); + if (!container) return; + + const color = FLOW_COLORS[this.flowKind] || "#94a3b8"; + + if (this.#feedData.length === 0) { + container.innerHTML = `
No data
`; + return; + } + + container.innerHTML = this.#feedData.map((item, i) => { + const title = item.title || item.name || item.label || item.id || `Item ${i + 1}`; + const subtitle = item.description || item.content_plain?.slice(0, 80) || item.status || item.type || ""; + const badge = item.status || item.kind || item.type || ""; + const editable = this.#isEditable(item); + + return ` +
+
+
+
${this.#escapeHtml(String(title))}
+ ${subtitle ? `
${this.#escapeHtml(String(subtitle).slice(0, 100))}
` : ""} +
+
+ ${editable ? `` : ""} + ${badge ? `
${this.#escapeHtml(String(badge))}
` : ""} +
+
+ `; + }).join(""); + + // Attach item click and edit events + container.querySelectorAll(".feed-item").forEach(el => { + // Double-click to navigate to source + el.addEventListener("dblclick", (e) => { + e.stopPropagation(); + const idx = parseInt(el.dataset.index || "0", 10); + const item = this.#feedData[idx]; + if (item) this.#navigateToItem(item); + }); + }); + + container.querySelectorAll(".feed-item-edit").forEach(btn => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const idx = parseInt(btn.dataset.edit || "0", 10); + this.#openEditOverlay(idx); + }); + }); + } + + /** Check if an item supports write-back */ + #isEditable(item: any): boolean { + // Items with an ID from modules that support PUT/PATCH are editable + if (!item.id) return false; + const editableModules = ["notes", "work", "vote", "trips"]; + return editableModules.includes(this.sourceModule); + } + + /** Navigate to the source item in its module */ + #navigateToItem(item: any) { + const space = this.#getSpaceSlug(); + const mod = this.sourceModule; + + // Build a deep link to the item in its source module + // Emit an event so the canvas/shell can handle it + this.dispatchEvent(new CustomEvent("feed-navigate", { + bubbles: true, + detail: { + sourceModule: mod, + itemId: item.id, + item, + url: `/${space}/${mod}`, + } + })); + } + + // ── Edit overlay (bidirectional write-back) ── + + #openEditOverlay(index: number) { + const overlay = this.#inner?.querySelector(".feed-edit-overlay"); + if (!overlay) return; + + const item = this.#feedData[index]; + if (!item) return; + + this.#editingIndex = index; + const color = FLOW_COLORS[this.flowKind] || "#94a3b8"; + + // Build edit fields based on item properties + const editableFields = this.#getEditableFields(item); + + overlay.innerHTML = ` +
+
+ Edit: ${this.#escapeHtml(item.title || item.name || "Item")} + +
+
+ ${editableFields.map(f => ` + + `).join("")} +
+
+ + +
+
+ `; + + overlay.classList.add("open"); + + // Events + overlay.querySelector(".edit-close")?.addEventListener("click", () => this.#closeEditOverlay()); + overlay.querySelector(".edit-cancel")?.addEventListener("click", () => this.#closeEditOverlay()); + overlay.querySelector(".edit-save")?.addEventListener("click", () => this.#saveEdit()); + } + + #closeEditOverlay() { + const overlay = this.#inner?.querySelector(".feed-edit-overlay"); + if (overlay) { + overlay.classList.remove("open"); + overlay.innerHTML = ""; + } + this.#editingIndex = null; + } + + async #saveEdit() { + if (this.#editingIndex === null) return; + const item = this.#feedData[this.#editingIndex]; + if (!item?.id) return; + + const overlay = this.#inner?.querySelector(".feed-edit-overlay"); + if (!overlay) return; + + // Collect edited values + const updates: Record = {}; + overlay.querySelectorAll(".edit-input").forEach(el => { + const field = el.dataset.field; + if (field && el.value !== String(item[field] ?? "")) { + updates[field] = el.value; + } + }); + + if (Object.keys(updates).length === 0) { + this.#closeEditOverlay(); + return; + } + + // Write back to source module API + try { + const space = this.#getSpaceSlug(); + const endpoint = this.#getWriteBackEndpoint(item); + const url = `/${space}/${this.sourceModule}/api/${endpoint}`; + + const method = this.#getWriteBackMethod(); + const token = this.#getAuthToken(); + + const res = await fetch(url, { + method, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: JSON.stringify(updates), + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({ error: "Failed" })); + throw new Error(err.error || `HTTP ${res.status}`); + } + + // Update local data + Object.assign(item, updates); + this.#renderItems(); + this.#closeEditOverlay(); + + // Emit event for flow tracking + this.dispatchEvent(new CustomEvent("feed-writeback", { + bubbles: true, + detail: { + sourceModule: this.sourceModule, + itemId: item.id, + updates, + flowKind: this.flowKind, + } + })); + } catch (err: any) { + // Show error in overlay + const actions = overlay.querySelector(".edit-actions"); + if (actions) { + const existing = actions.querySelector(".edit-error"); + if (existing) existing.remove(); + const errorEl = document.createElement("div"); + errorEl.className = "edit-error"; + errorEl.textContent = err.message; + actions.prepend(errorEl); + } + } + } + + #getEditableFields(item: any): { key: string; label: string; value: string; type: string; options?: string[] }[] { + const fields: { key: string; label: string; value: string; type: string; options?: string[] }[] = []; + + // Module-specific editable fields + switch (this.sourceModule) { + case "notes": + if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" }); + if (item.content !== undefined) fields.push({ key: "content", label: "Content", value: item.content || "", type: "textarea" }); + break; + case "work": + if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" }); + if (item.status !== undefined) fields.push({ + key: "status", label: "Status", value: item.status, + type: "select", options: ["TODO", "IN_PROGRESS", "REVIEW", "DONE"], + }); + if (item.priority !== undefined) fields.push({ + key: "priority", label: "Priority", value: item.priority || "MEDIUM", + type: "select", options: ["LOW", "MEDIUM", "HIGH", "URGENT"], + }); + break; + case "vote": + if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" }); + if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" }); + break; + case "trips": + if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" }); + if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" }); + break; + default: + // Generic: expose title and description if present + if (item.title !== undefined) fields.push({ key: "title", label: "Title", value: item.title || "", type: "text" }); + if (item.description !== undefined) fields.push({ key: "description", label: "Description", value: item.description || "", type: "textarea" }); + } + + return fields; + } + + #getWriteBackEndpoint(item: any): string { + switch (this.sourceModule) { + case "notes": return `notes/${item.id}`; + case "work": return `tasks/${item.id}`; + case "vote": return `proposals/${item.id}`; + case "trips": return `trips/${item.id}`; + default: return `${item.id}`; + } + } + + #getWriteBackMethod(): string { + switch (this.sourceModule) { + case "work": return "PATCH"; + default: return "PUT"; + } + } + + #getAuthToken(): string | null { + // Try to get token from EncryptID (stored in localStorage by rstack-identity) + try { + return localStorage.getItem("encryptid-token") || null; + } catch { + return null; + } + } + + #updateStatus(state: "loading" | "ok" | "error") { + const el = this.#inner?.querySelector(".feed-status"); + if (!el) return; + + if (state === "loading") { + el.textContent = "Loading..."; + el.style.color = "#94a3b8"; + } else if (state === "error") { + el.textContent = this.#error || "Error"; + el.style.color = "#ef4444"; + } else { + el.textContent = `${this.#feedData.length} items`; + el.style.color = FLOW_COLORS[this.flowKind] || "#94a3b8"; + } + } + + // ── Auto-refresh ── + + #startAutoRefresh() { + if (this.refreshInterval > 0) { + this.#refreshTimer = setInterval(() => this.#fetchFeed(), this.refreshInterval); + } + } + + #stopAutoRefresh() { + if (this.#refreshTimer) { + clearInterval(this.#refreshTimer); + this.#refreshTimer = null; + } + } + + // ── Serialization ── + + toJSON() { + return { + ...super.toJSON(), + type: "folk-feed", + sourceLayer: this.sourceLayer, + sourceModule: this.sourceModule, + feedId: this.feedId, + flowKind: this.flowKind, + feedFilter: this.feedFilter, + maxItems: this.maxItems, + refreshInterval: this.refreshInterval, + }; + } + + #escapeHtml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">"); + } + + static define(tag = "folk-feed") { + if (!customElements.get(tag)) customElements.define(tag, FolkFeed); + } +} + +// ── Styles ── + +const FEED_STYLES = ` +.folk-feed-inner { + display: flex; + flex-direction: column; + height: 100%; + border-radius: 8px; + overflow: hidden; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: rgba(15, 23, 42, 0.9); + border: 1px solid rgba(255,255,255,0.08); +} + +.feed-header { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid rgba(255,255,255,0.06); + flex-shrink: 0; +} + +.feed-kind-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.feed-title { + flex: 1; + font-size: 0.75rem; + font-weight: 600; + color: #e2e8f0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.feed-refresh { + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: #64748b; + font-size: 0.85rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, background 0.15s; +} +.feed-refresh:hover { + color: #e2e8f0; + background: rgba(255,255,255,0.06); +} + +.feed-items { + flex: 1; + overflow-y: auto; + padding: 4px 0; + scrollbar-width: thin; + scrollbar-color: rgba(148,163,184,0.2) transparent; +} + +.feed-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 6px 12px; + transition: background 0.12s; + cursor: default; +} +.feed-item:hover { + background: rgba(255,255,255,0.03); +} + +.feed-item-line { + width: 3px; + min-height: 24px; + border-radius: 2px; + flex-shrink: 0; + margin-top: 2px; + opacity: 0.6; +} + +.feed-item-content { + flex: 1; + min-width: 0; +} + +.feed-item-title { + font-size: 0.75rem; + font-weight: 500; + color: #e2e8f0; + line-height: 1.3; +} + +.feed-item-subtitle { + font-size: 0.65rem; + color: #64748b; + margin-top: 2px; + line-height: 1.3; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.feed-item-badge { + font-size: 0.55rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + padding: 2px 4px; + border-radius: 3px; + background: rgba(255,255,255,0.04); + flex-shrink: 0; + margin-top: 2px; +} + +.feed-navigate { + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: #64748b; + font-size: 0.75rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.15s, background 0.15s; +} +.feed-navigate:hover { + color: #22d3ee; + background: rgba(34,211,238,0.1); +} + +.feed-item--editable { cursor: pointer; } +.feed-item--editable:hover { background: rgba(255,255,255,0.05); } + +.feed-item-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.feed-item-edit { + width: 20px; + height: 20px; + border: none; + border-radius: 3px; + background: transparent; + color: #475569; + font-size: 0.7rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; +} +.feed-item:hover .feed-item-edit { opacity: 0.7; } +.feed-item-edit:hover { opacity: 1 !important; color: #22d3ee; background: rgba(34,211,238,0.1); } + +.feed-empty { + padding: 20px; + text-align: center; + font-size: 0.75rem; + color: #475569; +} + +.feed-status { + padding: 4px 12px; + font-size: 0.6rem; + text-align: right; + border-top: 1px solid rgba(255,255,255,0.04); + flex-shrink: 0; +} + +/* ── Edit overlay ── */ + +.feed-edit-overlay { + display: none; + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.95); + border-radius: 8px; + z-index: 10; + overflow: auto; +} +.feed-edit-overlay.open { display: flex; } + +.edit-panel { + display: flex; + flex-direction: column; + width: 100%; + padding: 12px; + gap: 10px; +} + +.edit-header { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.75rem; + font-weight: 600; +} + +.edit-close { + width: 22px; + height: 22px; + border: none; + border-radius: 4px; + background: transparent; + color: #94a3b8; + font-size: 1rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.edit-close:hover { color: #ef4444; background: rgba(239,68,68,0.1); } + +.edit-fields { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +} + +.edit-field { + display: flex; + flex-direction: column; + gap: 3px; +} + +.edit-label { + font-size: 0.6rem; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.edit-input { + padding: 6px 8px; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 5px; + background: rgba(255,255,255,0.04); + color: #e2e8f0; + font-size: 0.75rem; + font-family: inherit; + outline: none; + transition: border-color 0.15s; + resize: vertical; +} +.edit-input:focus { border-color: rgba(34,211,238,0.4); } + +select.edit-input { cursor: pointer; } + +.edit-actions { + display: flex; + gap: 6px; + align-items: center; + justify-content: flex-end; +} + +.edit-cancel, .edit-save { + padding: 5px 12px; + border-radius: 5px; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.15s; +} + +.edit-cancel { + border: 1px solid rgba(255,255,255,0.1); + background: transparent; + color: #94a3b8; +} +.edit-cancel:hover { color: #e2e8f0; } + +.edit-save { + border: 1px solid; +} +.edit-save:hover { opacity: 0.8; } + +.edit-error { + font-size: 0.65rem; + color: #ef4444; + flex: 1; +} +`; diff --git a/lib/index.ts b/lib/index.ts index 412e66e..9b7d39e 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -66,6 +66,9 @@ export * from "./folk-choice-spider"; // Nested Space Shape export * from "./folk-canvas"; +// Feed Shape (inter-layer data flow) +export * from "./folk-feed"; + // Sync export * from "./community-sync"; export * from "./presence"; diff --git a/lib/layer-types.ts b/lib/layer-types.ts new file mode 100644 index 0000000..e92f5c4 --- /dev/null +++ b/lib/layer-types.ts @@ -0,0 +1,100 @@ +/** + * Layer & Flow types for the rSpace tab/layer system. + * + * Each "tab" is a Layer — a named canvas page backed by a module. + * Layers stack vertically. Flows are typed connections (economic, trust, + * data, attention, governance) that move between shapes on different layers. + * + * The "stack view" renders all layers from the side, showing flows as + * arcs/lines between strata. + */ + +// ── Flow types ── + +export type FlowKind = + | "economic" // token/currency/value flows + | "trust" // reputation, attestation, endorsement + | "data" // information, content, feeds + | "attention" // views, engagement, focus + | "governance" // votes, proposals, decisions + | "resource" // files, assets, media + | "custom"; // user-defined + +export const FLOW_COLORS: Record = { + economic: "#bef264", // lime + trust: "#c4b5fd", // violet + data: "#67e8f9", // cyan + attention: "#fcd34d", // amber + governance: "#f0abfc", // fuchsia + resource: "#6ee7b7", // emerald + custom: "#94a3b8", // slate +}; + +export const FLOW_LABELS: Record = { + economic: "Economic", + trust: "Trust", + data: "Data", + attention: "Attention", + governance: "Governance", + resource: "Resource", + custom: "Custom", +}; + +// ── Layer definition ── + +export interface Layer { + /** Unique layer ID (e.g. "layer-abc123") */ + id: string; + /** Module ID this layer is bound to (e.g. "canvas", "notes", "funds") */ + moduleId: string; + /** Display label (defaults to module name, user-customizable) */ + label: string; + /** Position in the tab bar (0-indexed, left to right) */ + order: number; + /** Layer color for the stack view strata */ + color: string; + /** Whether this layer is visible in stack view */ + visible: boolean; + /** Created timestamp */ + createdAt: number; +} + +// ── Inter-layer flow ── + +export interface LayerFlow { + /** Unique flow ID */ + id: string; + /** Flow type */ + kind: FlowKind; + /** Source layer ID */ + sourceLayerId: string; + /** Source shape ID (optional — can be layer-wide) */ + sourceShapeId?: string; + /** Target layer ID */ + targetLayerId: string; + /** Target shape ID (optional — can be layer-wide) */ + targetShapeId?: string; + /** Human-readable label */ + label?: string; + /** Flow strength/weight (0-1, affects visual thickness) */ + strength: number; + /** Whether this flow is currently active */ + active: boolean; + /** Custom color override */ + color?: string; + /** Metadata for the flow */ + meta?: Record; +} + +// ── Layer config stored in Automerge doc ── + +export interface LayerConfig { + /** Ordered list of layers */ + layers: { [id: string]: Layer }; + /** Inter-layer flows */ + flows: { [id: string]: LayerFlow }; + /** Currently active layer ID */ + activeLayerId: string; + /** View mode: 'flat' (normal tabs) or 'stack' (side/3D view) */ + viewMode: "flat" | "stack"; +} diff --git a/modules/canvas/mod.ts b/modules/canvas/mod.ts index 2ffac08..1bbcd72 100644 --- a/modules/canvas/mod.ts +++ b/modules/canvas/mod.ts @@ -55,4 +55,20 @@ export const canvasModule: RSpaceModule = { icon: "🎨", description: "Real-time collaborative canvas", routes, + feeds: [ + { + id: "shapes", + name: "Canvas Shapes", + kind: "data", + description: "All shapes on this canvas layer — notes, embeds, arrows, etc.", + filterable: true, + }, + { + id: "connections", + name: "Shape Connections", + kind: "data", + description: "Arrow connections between shapes — the canvas graph", + }, + ], + acceptsFeeds: ["economic", "trust", "data", "attention", "governance", "resource"], }; diff --git a/modules/choices/mod.ts b/modules/choices/mod.ts index 095d06b..13d7558 100644 --- a/modules/choices/mod.ts +++ b/modules/choices/mod.ts @@ -67,4 +67,14 @@ export const choicesModule: RSpaceModule = { description: "Polls, rankings, and multi-criteria scoring", routes, standaloneDomain: "rchoices.online", + feeds: [ + { + id: "poll-results", + name: "Poll Results", + kind: "governance", + description: "Live poll, ranking, and scoring outcomes", + emits: ["folk-choice-vote", "folk-choice-rank", "folk-choice-spider"], + }, + ], + acceptsFeeds: ["data", "governance"], }; diff --git a/modules/data/mod.ts b/modules/data/mod.ts index b3d540d..3c1dc4d 100644 --- a/modules/data/mod.ts +++ b/modules/data/mod.ts @@ -139,4 +139,20 @@ export const dataModule: RSpaceModule = { description: "Privacy-first analytics for the r* ecosystem", routes, standaloneDomain: "rdata.online", + feeds: [ + { + id: "analytics", + name: "Analytics Stream", + kind: "attention", + description: "Page views, active visitors, and engagement metrics across rApps", + filterable: true, + }, + { + id: "active-users", + name: "Active Users", + kind: "attention", + description: "Real-time active visitor counts", + }, + ], + acceptsFeeds: ["data", "economic"], }; diff --git a/modules/funds/mod.ts b/modules/funds/mod.ts index 97d27eb..9c11aa5 100644 --- a/modules/funds/mod.ts +++ b/modules/funds/mod.ts @@ -246,4 +246,20 @@ export const fundsModule: RSpaceModule = { description: "Budget flows, river visualization, and treasury management", routes, standaloneDomain: "rfunds.online", + feeds: [ + { + id: "treasury-flows", + name: "Treasury Flows", + kind: "economic", + description: "Budget flow states, deposits, withdrawals, and funnel allocations", + filterable: true, + }, + { + id: "transactions", + name: "Transaction Stream", + kind: "economic", + description: "Real-time deposit and withdrawal events", + }, + ], + acceptsFeeds: ["governance", "data"], }; diff --git a/modules/network/mod.ts b/modules/network/mod.ts index 2032337..0bd075d 100644 --- a/modules/network/mod.ts +++ b/modules/network/mod.ts @@ -235,4 +235,20 @@ export const networkModule: RSpaceModule = { description: "Community relationship graph visualization with CRM sync", routes, standaloneDomain: "rnetwork.online", + feeds: [ + { + id: "trust-graph", + name: "Trust Graph", + kind: "trust", + description: "People, companies, and relationship edges — the community web of trust", + filterable: true, + }, + { + id: "connections", + name: "New Connections", + kind: "trust", + description: "Recently added people and relationship links", + }, + ], + acceptsFeeds: ["data", "trust", "governance"], }; diff --git a/modules/notes/mod.ts b/modules/notes/mod.ts index 53c9f89..dd3860c 100644 --- a/modules/notes/mod.ts +++ b/modules/notes/mod.ts @@ -380,4 +380,22 @@ export const notesModule: RSpaceModule = { description: "Notebooks with rich-text notes, voice transcription, and collaboration", routes, standaloneDomain: "rnotes.online", + feeds: [ + { + id: "notes-by-tag", + name: "Notes by Tag", + kind: "data", + description: "Stream of notes filtered by tag (design, architecture, etc.)", + emits: ["folk-markdown"], + filterable: true, + }, + { + id: "recent-notes", + name: "Recent Notes", + kind: "data", + description: "Latest notes across all notebooks", + emits: ["folk-markdown"], + }, + ], + acceptsFeeds: ["data", "resource"], }; diff --git a/modules/trips/mod.ts b/modules/trips/mod.ts index e7346fa..7fcee8e 100644 --- a/modules/trips/mod.ts +++ b/modules/trips/mod.ts @@ -272,4 +272,21 @@ export const tripsModule: RSpaceModule = { description: "Collaborative trip planner with itinerary, bookings, and expense splitting", routes, standaloneDomain: "rtrips.online", + feeds: [ + { + id: "trip-expenses", + name: "Trip Expenses", + kind: "economic", + description: "Expense tracking with split amounts per traveler", + filterable: true, + }, + { + id: "itinerary", + name: "Itinerary", + kind: "data", + description: "Destinations, activities, and bookings timeline", + emits: ["folk-itinerary", "folk-destination", "folk-booking"], + }, + ], + acceptsFeeds: ["economic", "data"], }; diff --git a/modules/vote/mod.ts b/modules/vote/mod.ts index dcd4e57..bff5ce9 100644 --- a/modules/vote/mod.ts +++ b/modules/vote/mod.ts @@ -346,4 +346,20 @@ export const voteModule: RSpaceModule = { description: "Conviction voting engine for collaborative governance", routes, standaloneDomain: "rvote.online", + feeds: [ + { + id: "proposals", + name: "Proposals", + kind: "governance", + description: "Active proposals with conviction scores and vote tallies", + filterable: true, + }, + { + id: "decisions", + name: "Decision Outcomes", + kind: "governance", + description: "Passed and failed proposals — governance decisions made", + }, + ], + acceptsFeeds: ["economic", "data"], }; diff --git a/modules/wallet/mod.ts b/modules/wallet/mod.ts index d0aaaec..3aff1c9 100644 --- a/modules/wallet/mod.ts +++ b/modules/wallet/mod.ts @@ -113,4 +113,19 @@ export const walletModule: RSpaceModule = { description: "Multichain Safe wallet visualization and treasury management", routes, standaloneDomain: "rwallet.online", + feeds: [ + { + id: "balances", + name: "Token Balances", + kind: "economic", + description: "Multichain Safe token balances with USD valuations", + }, + { + id: "transfers", + name: "Transfer History", + kind: "economic", + description: "Incoming and outgoing token transfers across chains", + }, + ], + acceptsFeeds: ["economic", "governance"], }; diff --git a/modules/work/mod.ts b/modules/work/mod.ts index 2514ec6..0c055a3 100644 --- a/modules/work/mod.ts +++ b/modules/work/mod.ts @@ -236,4 +236,20 @@ export const workModule: RSpaceModule = { description: "Kanban workspace boards for collaborative task management", routes, standaloneDomain: "rwork.online", + feeds: [ + { + id: "task-activity", + name: "Task Activity", + kind: "data", + description: "Task creation, status changes, and assignment updates", + filterable: true, + }, + { + id: "board-summary", + name: "Board Summary", + kind: "data", + description: "Kanban board state — counts by status column", + }, + ], + acceptsFeeds: ["governance", "data"], }; diff --git a/server/shell.ts b/server/shell.ts index 40ba095..529b61f 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -157,12 +157,60 @@ export function renderShell(opts: ShellOptions): string { sync.setActiveLayer(e.detail.layerId); }); - // Listen for remote layer changes + // Layer add via tab bar (persist new layer) + tabBar.addEventListener('layer-add', (e) => { + const { moduleId } = e.detail; + const newLayer = { + id: 'layer-' + moduleId, + moduleId, + label: moduleId, + order: sync.getLayers().length, + color: '', + visible: true, + createdAt: Date.now(), + }; + sync.addLayer(newLayer); + }); + + // Layer close (remove from Automerge) + tabBar.addEventListener('layer-close', (e) => { + sync.removeLayer(e.detail.layerId); + }); + + // Layer reorder + tabBar.addEventListener('layer-reorder', (e) => { + const { layerId, newIndex } = e.detail; + sync.updateLayer(layerId, { order: newIndex }); + // Reindex all layers + const layers = sync.getLayers(); + layers.forEach((l, i) => { + if (l.order !== i) sync.updateLayer(l.id, { order: i }); + }); + }); + + // Flow creation from stack view drag-to-connect + tabBar.addEventListener('flow-create', (e) => { + sync.addFlow(e.detail.flow); + }); + + // Flow removal from stack view right-click + tabBar.addEventListener('flow-remove', (e) => { + sync.removeFlow(e.detail.flowId); + }); + + // View mode persistence + tabBar.addEventListener('view-toggle', (e) => { + sync.setLayerViewMode(e.detail.mode); + }); + + // Listen for remote layer/flow changes sync.addEventListener('change', () => { tabBar.setLayers(sync.getLayers()); tabBar.setFlows(sync.getFlows()); const activeId = sync.doc.activeLayerId; if (activeId) tabBar.setAttribute('active', activeId); + const viewMode = sync.doc.layerViewMode; + if (viewMode) tabBar.setAttribute('view-mode', viewMode); }); }); } diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts new file mode 100644 index 0000000..997f543 --- /dev/null +++ b/shared/components/rstack-tab-bar.ts @@ -0,0 +1,1080 @@ +/** + * — Layered tab system for rSpace. + * + * Each tab is a "layer" — an rApp page within the current space. + * Supports two view modes: + * - flat: traditional tab bar (looking down at one layer) + * - stack: side view showing all layers as stacked strata with flows + * + * Attributes: + * active — the active layer ID + * space — current space slug + * view-mode — "flat" | "stack" + * + * Events: + * layer-switch — fired when user clicks a tab { detail: { layerId, moduleId } } + * layer-add — fired when user clicks + to add a layer + * layer-close — fired when user closes a tab { detail: { layerId } } + * layer-reorder — fired on drag reorder { detail: { layerId, newIndex } } + * view-toggle — fired when switching flat/stack view { detail: { mode } } + * flow-select — fired when a flow is clicked in stack view { detail: { flowId } } + */ + +import type { Layer, LayerFlow, FlowKind } from "../../lib/layer-types"; +import { FLOW_COLORS, FLOW_LABELS } from "../../lib/layer-types"; + +// Re-export badge info so the tab bar can show module colors +const MODULE_BADGES: Record = { + canvas: { badge: "rS", color: "#5eead4" }, + notes: { badge: "rN", color: "#fcd34d" }, + pubs: { badge: "rP", color: "#fda4af" }, + swag: { badge: "rSw", color: "#fda4af" }, + splat: { badge: "r3", color: "#d8b4fe" }, + cal: { badge: "rC", color: "#7dd3fc" }, + trips: { badge: "rT", color: "#6ee7b7" }, + maps: { badge: "rM", color: "#86efac" }, + chats: { badge: "rCh", color: "#6ee7b7" }, + inbox: { badge: "rI", color: "#a5b4fc" }, + mail: { badge: "rMa", color: "#93c5fd" }, + forum: { badge: "rFo", color: "#fcd34d" }, + choices: { badge: "rCo", color: "#f0abfc" }, + vote: { badge: "rV", color: "#c4b5fd" }, + funds: { badge: "rF", color: "#bef264" }, + wallet: { badge: "rW", color: "#fde047" }, + cart: { badge: "rCt", color: "#fdba74" }, + auctions: { badge: "rA", color: "#fca5a5" }, + providers: { badge: "rPr", color: "#fdba74" }, + tube: { badge: "rTu", color: "#f9a8d4" }, + photos: { badge: "rPh", color: "#f9a8d4" }, + network: { badge: "rNe", color: "#93c5fd" }, + socials: { badge: "rSo", color: "#7dd3fc" }, + files: { badge: "rFi", color: "#67e8f9" }, + books: { badge: "rB", color: "#fda4af" }, + data: { badge: "rD", color: "#d8b4fe" }, + work: { badge: "rWo", color: "#cbd5e1" }, +}; + +export class RStackTabBar extends HTMLElement { + #shadow: ShadowRoot; + #layers: Layer[] = []; + #flows: LayerFlow[] = []; + #viewMode: "flat" | "stack" = "flat"; + #draggedTabId: string | null = null; + #addMenuOpen = false; + #flowDragSource: string | null = null; + #flowDragTarget: string | null = null; + #flowDialogOpen = false; + #flowDialogSourceId = ""; + #flowDialogTargetId = ""; + + constructor() { + super(); + this.#shadow = this.attachShadow({ mode: "open" }); + } + + static get observedAttributes() { + return ["active", "space", "view-mode"]; + } + + get active(): string { + return this.getAttribute("active") || ""; + } + + set active(val: string) { + this.setAttribute("active", val); + } + + get space(): string { + return this.getAttribute("space") || ""; + } + + get viewMode(): "flat" | "stack" { + return (this.getAttribute("view-mode") as "flat" | "stack") || "flat"; + } + + set viewMode(val: "flat" | "stack") { + this.setAttribute("view-mode", val); + } + + connectedCallback() { + this.#render(); + } + + attributeChangedCallback() { + this.#viewMode = this.viewMode; + this.#render(); + } + + /** Set the layer list (call from outside) */ + setLayers(layers: Layer[]) { + this.#layers = [...layers].sort((a, b) => a.order - b.order); + this.#render(); + } + + /** Set the inter-layer flows (for stack view) */ + setFlows(flows: LayerFlow[]) { + this.#flows = flows; + if (this.#viewMode === "stack") this.#render(); + } + + /** Add a single layer */ + addLayer(layer: Layer) { + this.#layers.push(layer); + this.#layers.sort((a, b) => a.order - b.order); + this.#render(); + } + + /** Remove a layer by ID */ + removeLayer(layerId: string) { + this.#layers = this.#layers.filter(l => l.id !== layerId); + this.#render(); + } + + /** Update a layer */ + updateLayer(layerId: string, updates: Partial) { + const layer = this.#layers.find(l => l.id === layerId); + if (layer) { + Object.assign(layer, updates); + this.#layers.sort((a, b) => a.order - b.order); + this.#render(); + } + } + + // ── Render ── + + #render() { + const active = this.active; + + this.#shadow.innerHTML = ` + +
+
+ ${this.#layers.map(l => this.#renderTab(l, active)).join("")} + + ${this.#addMenuOpen ? this.#renderAddMenu() : ""} +
+
+ +
+
+ ${this.#viewMode === "stack" ? this.#renderStackView() : ""} + `; + + this.#attachEvents(); + } + + #renderTab(layer: Layer, activeId: string): string { + const badge = MODULE_BADGES[layer.moduleId]; + const isActive = layer.id === activeId; + const badgeColor = layer.color || badge?.color || "#94a3b8"; + + return ` +
+ + ${badge?.badge || layer.moduleId.slice(0, 2)} + ${layer.label} + ${this.#layers.length > 1 ? `` : ""} +
+ `; + } + + #renderAddMenu(): string { + // Group available modules (show ones not yet added as layers) + const existingModuleIds = new Set(this.#layers.map(l => l.moduleId)); + const available = Object.entries(MODULE_BADGES) + .filter(([id]) => !existingModuleIds.has(id)) + .map(([id, info]) => ({ id, ...info })); + + if (available.length === 0) { + return `
All modules added
`; + } + + return ` +
+ ${available.map(m => ` + + `).join("")} +
+ `; + } + + #renderStackView(): string { + const layerCount = this.#layers.length; + if (layerCount === 0) return ""; + + const layerHeight = 60; + const layerGap = 40; + const totalHeight = layerCount * layerHeight + (layerCount - 1) * layerGap + 80; + const width = 600; + + // Build layer rects and flow arcs + let layersSvg = ""; + let flowsSvg = ""; + const layerPositions = new Map(); + + this.#layers.forEach((layer, i) => { + const y = 40 + i * (layerHeight + layerGap); + const x = 40; + const w = width - 80; + const badge = MODULE_BADGES[layer.moduleId]; + const color = layer.color || badge?.color || "#94a3b8"; + + layerPositions.set(layer.id, { x, y, w, h: layerHeight }); + + const isActive = layer.id === this.active; + layersSvg += ` + + + ${layer.label} + ${badge?.badge || layer.moduleId} + + `; + }); + + // Draw flows as curved arcs on the right side + for (const flow of this.#flows) { + const src = layerPositions.get(flow.sourceLayerId); + const tgt = layerPositions.get(flow.targetLayerId); + if (!src || !tgt) continue; + + const srcY = src.y + src.h / 2; + const tgtY = tgt.y + tgt.h / 2; + const rightX = src.x + src.w; + const arcOut = 30 + flow.strength * 40; + const color = flow.color || FLOW_COLORS[flow.kind] || "#94a3b8"; + const strokeWidth = 1.5 + flow.strength * 3; + + // Going down or up — arc curves right + const direction = tgtY > srcY ? 1 : -1; + const midY = (srcY + tgtY) / 2; + + flowsSvg += ` + + + + + ${flow.label ? ` + ${flow.label} + ` : ""} + + + + `; + } + + // Flow kind legend + const activeKinds = new Set(this.#flows.map(f => f.kind)); + let legendSvg = ""; + let legendX = 50; + for (const kind of activeKinds) { + const color = FLOW_COLORS[kind]; + legendSvg += ` + + ${FLOW_LABELS[kind]} + `; + legendX += FLOW_LABELS[kind].length * 7 + 24; + } + + // Flow creation hint + const hintSvg = this.#layers.length >= 2 ? ` + + Drag from one layer to another to create a flow + + ` : ""; + + return ` +
+ + ${flowsSvg} + ${layersSvg} + ${legendSvg} + ${hintSvg} + + ${this.#flowDialogOpen ? this.#renderFlowDialog() : ""} +
+ `; + } + + // ── Flow creation dialog ── + + #renderFlowDialog(): string { + const srcLayer = this.#layers.find(l => l.id === this.#flowDialogSourceId); + const tgtLayer = this.#layers.find(l => l.id === this.#flowDialogTargetId); + if (!srcLayer || !tgtLayer) return ""; + + const srcBadge = MODULE_BADGES[srcLayer.moduleId]; + const tgtBadge = MODULE_BADGES[tgtLayer.moduleId]; + + const kinds: FlowKind[] = ["economic", "trust", "data", "attention", "governance", "resource"]; + + return ` +
+
New Flow
+
+ ${srcBadge?.badge || srcLayer.moduleId} + + ${tgtBadge?.badge || tgtLayer.moduleId} +
+
+ +
+ ${kinds.map(k => ` + + `).join("")} +
+
+
+ + +
+
+ + +
+
+ + +
+
+ `; + } + + #openFlowDialog(sourceLayerId: string, targetLayerId: string) { + this.#flowDialogOpen = true; + this.#flowDialogSourceId = sourceLayerId; + this.#flowDialogTargetId = targetLayerId; + this.#render(); + } + + #closeFlowDialog() { + this.#flowDialogOpen = false; + this.#flowDialogSourceId = ""; + this.#flowDialogTargetId = ""; + this.#render(); + } + + // ── Events ── + + #attachEvents() { + // Tab clicks + this.#shadow.querySelectorAll(".tab").forEach(tab => { + tab.addEventListener("click", (e) => { + const target = e.target as HTMLElement; + if (target.classList.contains("tab-close")) return; + const layerId = tab.dataset.layerId!; + const moduleId = tab.dataset.moduleId!; + this.active = layerId; + this.dispatchEvent(new CustomEvent("layer-switch", { + detail: { layerId, moduleId }, + bubbles: true, + })); + }); + + // Drag-and-drop reorder + tab.addEventListener("dragstart", (e) => { + this.#draggedTabId = tab.dataset.layerId!; + tab.classList.add("dragging"); + (e as DragEvent).dataTransfer!.effectAllowed = "move"; + }); + + tab.addEventListener("dragend", () => { + tab.classList.remove("dragging"); + this.#draggedTabId = null; + }); + + tab.addEventListener("dragover", (e) => { + e.preventDefault(); + (e as DragEvent).dataTransfer!.dropEffect = "move"; + tab.classList.add("drag-over"); + }); + + tab.addEventListener("dragleave", () => { + tab.classList.remove("drag-over"); + }); + + tab.addEventListener("drop", (e) => { + e.preventDefault(); + tab.classList.remove("drag-over"); + if (!this.#draggedTabId || this.#draggedTabId === tab.dataset.layerId) return; + + const targetIdx = this.#layers.findIndex(l => l.id === tab.dataset.layerId); + this.dispatchEvent(new CustomEvent("layer-reorder", { + detail: { layerId: this.#draggedTabId, newIndex: targetIdx }, + bubbles: true, + })); + }); + }); + + // Close buttons + this.#shadow.querySelectorAll(".tab-close").forEach(btn => { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + const layerId = (btn as HTMLElement).dataset.close!; + this.dispatchEvent(new CustomEvent("layer-close", { + detail: { layerId }, + bubbles: true, + })); + }); + }); + + // Add button + const addBtn = this.#shadow.getElementById("add-btn"); + addBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.#addMenuOpen = !this.#addMenuOpen; + this.#render(); + }); + + // Add menu items + this.#shadow.querySelectorAll(".add-menu-item").forEach(item => { + item.addEventListener("click", (e) => { + e.stopPropagation(); + const moduleId = item.dataset.addModule!; + this.#addMenuOpen = false; + this.dispatchEvent(new CustomEvent("layer-add", { + detail: { moduleId }, + bubbles: true, + })); + }); + }); + + // Close add menu on outside click + if (this.#addMenuOpen) { + const handler = () => { + this.#addMenuOpen = false; + this.#render(); + document.removeEventListener("click", handler); + }; + setTimeout(() => document.addEventListener("click", handler), 0); + } + + // View toggle + const viewToggle = this.#shadow.getElementById("view-toggle"); + viewToggle?.addEventListener("click", () => { + const newMode = this.#viewMode === "flat" ? "stack" : "flat"; + this.#viewMode = newMode; + this.setAttribute("view-mode", newMode); + this.dispatchEvent(new CustomEvent("view-toggle", { + detail: { mode: newMode }, + bubbles: true, + })); + this.#render(); + }); + + // Stack view layer clicks + drag-to-connect + this.#shadow.querySelectorAll(".stack-layer").forEach(g => { + const layerId = g.dataset.layerId!; + + // Click to switch layer + g.addEventListener("click", () => { + if (this.#flowDragSource) return; // ignore if mid-drag + const layer = this.#layers.find(l => l.id === layerId); + if (layer) { + this.active = layerId; + this.dispatchEvent(new CustomEvent("layer-switch", { + detail: { layerId, moduleId: layer.moduleId }, + bubbles: true, + })); + } + }); + + // Drag-to-connect: mousedown starts a flow drag + g.addEventListener("mousedown", (e) => { + if ((e as MouseEvent).button !== 0) return; + this.#flowDragSource = layerId; + g.classList.add("flow-drag-source"); + }); + + g.addEventListener("mouseenter", () => { + if (this.#flowDragSource && this.#flowDragSource !== layerId) { + this.#flowDragTarget = layerId; + g.classList.add("flow-drag-target"); + } + }); + + g.addEventListener("mouseleave", () => { + if (this.#flowDragTarget === layerId) { + this.#flowDragTarget = null; + g.classList.remove("flow-drag-target"); + } + }); + }); + + // Global mouseup completes the flow drag + const svgEl = this.#shadow.querySelector(".stack-view svg"); + if (svgEl) { + svgEl.addEventListener("mouseup", () => { + if (this.#flowDragSource && this.#flowDragTarget) { + this.#openFlowDialog(this.#flowDragSource, this.#flowDragTarget); + } + // Reset drag state + this.#flowDragSource = null; + this.#flowDragTarget = null; + this.#shadow.querySelectorAll(".flow-drag-source, .flow-drag-target").forEach(el => { + el.classList.remove("flow-drag-source", "flow-drag-target"); + }); + }); + } + + // Stack view flow clicks — select or delete + this.#shadow.querySelectorAll(".stack-flow").forEach(g => { + g.addEventListener("click", (e) => { + e.stopPropagation(); + const flowId = g.dataset.flowId!; + this.dispatchEvent(new CustomEvent("flow-select", { + detail: { flowId }, + bubbles: true, + })); + }); + + // Right-click to delete flow + g.addEventListener("contextmenu", (e) => { + e.preventDefault(); + const flowId = g.dataset.flowId!; + const flow = this.#flows.find(f => f.id === flowId); + if (flow && confirm(`Remove ${FLOW_LABELS[flow.kind]} flow${flow.label ? `: ${flow.label}` : ""}?`)) { + this.dispatchEvent(new CustomEvent("flow-remove", { + detail: { flowId }, + bubbles: true, + })); + } + }); + }); + + // Flow dialog events + if (this.#flowDialogOpen) { + let selectedKind: FlowKind = "data"; + + this.#shadow.querySelectorAll(".flow-kind-btn").forEach(btn => { + btn.addEventListener("click", () => { + this.#shadow.querySelectorAll(".flow-kind-btn").forEach(b => b.classList.remove("selected")); + btn.classList.add("selected"); + selectedKind = btn.dataset.kind as FlowKind; + }); + }); + + // Select "data" by default + const defaultBtn = this.#shadow.querySelector('.flow-kind-btn[data-kind="data"]'); + defaultBtn?.classList.add("selected"); + + this.#shadow.getElementById("flow-cancel")?.addEventListener("click", () => { + this.#closeFlowDialog(); + }); + + this.#shadow.getElementById("flow-create")?.addEventListener("click", () => { + const label = (this.#shadow.getElementById("flow-label-input") as HTMLInputElement)?.value || ""; + const strength = parseFloat((this.#shadow.getElementById("flow-strength-input") as HTMLInputElement)?.value || "0.5"); + + const flowId = `flow-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + this.dispatchEvent(new CustomEvent("flow-create", { + detail: { + flow: { + id: flowId, + kind: selectedKind, + sourceLayerId: this.#flowDialogSourceId, + targetLayerId: this.#flowDialogTargetId, + label: label || undefined, + strength, + active: true, + } + }, + bubbles: true, + })); + + this.#closeFlowDialog(); + }); + } + } + + static define(tag = "rstack-tab-bar") { + if (!customElements.get(tag)) customElements.define(tag, RStackTabBar); + } +} + +// ── SVG Icons ── + +const ICON_STACK = ` + + + +`; + +const ICON_FLAT = ` + + + + + +`; + +// ── Styles ── + +const STYLES = ` +:host { display: block; } + +/* ── Tab bar (flat mode) ── */ + +.tab-bar { + display: flex; + align-items: center; + gap: 0; + height: 36px; + padding: 0 8px; + overflow: hidden; +} + +.tabs-scroll { + display: flex; + align-items: center; + gap: 2px; + flex: 1; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + position: relative; +} +.tabs-scroll::-webkit-scrollbar { display: none; } + +.tab-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: 4px; +} + +/* ── Individual tab ── */ + +.tab { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + border-radius: 6px 6px 0 0; + cursor: pointer; + white-space: nowrap; + font-size: 0.8rem; + font-weight: 500; + transition: background 0.15s, opacity 0.15s; + user-select: none; + position: relative; + flex-shrink: 0; +} + +:host-context([data-theme="dark"]) .tab { + color: #94a3b8; + background: transparent; +} +:host-context([data-theme="dark"]) .tab:hover { + background: rgba(255,255,255,0.05); + color: #e2e8f0; +} +:host-context([data-theme="dark"]) .tab.active { + background: rgba(255,255,255,0.08); + color: #f1f5f9; +} + +:host-context([data-theme="light"]) .tab { + color: #64748b; + background: transparent; +} +:host-context([data-theme="light"]) .tab:hover { + background: rgba(0,0,0,0.04); + color: #1e293b; +} +:host-context([data-theme="light"]) .tab.active { + background: rgba(0,0,0,0.06); + color: #0f172a; +} + +/* Active indicator line at bottom */ +.tab-indicator { + position: absolute; + bottom: 0; + left: 8px; + right: 8px; + height: 2px; + border-radius: 2px 2px 0 0; + opacity: 0; + transition: opacity 0.15s; +} +.tab.active .tab-indicator { opacity: 1; } + +.tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + font-size: 0.55rem; + font-weight: 900; + color: #0f172a; + line-height: 1; + flex-shrink: 0; +} + +.tab-label { + font-size: 0.8rem; +} + +.tab-close { + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border: none; + border-radius: 3px; + background: transparent; + color: inherit; + font-size: 0.85rem; + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, background 0.15s; + padding: 0; + line-height: 1; +} +.tab:hover .tab-close { opacity: 0.5; } +.tab-close:hover { opacity: 1 !important; background: rgba(239,68,68,0.2); color: #ef4444; } + +/* ── Drag states ── */ + +.tab.dragging { opacity: 0.4; } +.tab.drag-over { box-shadow: inset 2px 0 0 #22d3ee; } + +/* ── Add button ── */ + +.tab-add { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 1px dashed rgba(148,163,184,0.3); + border-radius: 5px; + background: transparent; + color: #64748b; + font-size: 0.9rem; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; + flex-shrink: 0; + margin-left: 4px; +} +.tab-add:hover { + border-color: #22d3ee; + color: #22d3ee; + background: rgba(34,211,238,0.08); +} + +/* ── Add menu ── */ + +.add-menu { + position: absolute; + top: 100%; + left: auto; + right: 0; + margin-top: 4px; + min-width: 180px; + max-height: 300px; + overflow-y: auto; + border-radius: 8px; + padding: 4px; + z-index: 100; + box-shadow: 0 8px 30px rgba(0,0,0,0.25); +} +:host-context([data-theme="dark"]) .add-menu { + background: #1e293b; + border: 1px solid rgba(255,255,255,0.1); +} +:host-context([data-theme="light"]) .add-menu { + background: white; + border: 1px solid rgba(0,0,0,0.1); +} + +.add-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border: none; + border-radius: 5px; + background: transparent; + color: inherit; + font-size: 0.8rem; + cursor: pointer; + text-align: left; + transition: background 0.12s; +} +:host-context([data-theme="dark"]) .add-menu-item:hover { background: rgba(255,255,255,0.06); } +:host-context([data-theme="light"]) .add-menu-item:hover { background: rgba(0,0,0,0.04); } + +.add-menu-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 5px; + font-size: 0.55rem; + font-weight: 900; + color: #0f172a; + flex-shrink: 0; +} + +.add-menu-empty { + padding: 12px; + text-align: center; + font-size: 0.75rem; + opacity: 0.5; +} + +/* ── View toggle ── */ + +.view-toggle { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border: none; + border-radius: 6px; + background: transparent; + color: #64748b; + cursor: pointer; + transition: background 0.15s, color 0.15s; +} +.view-toggle:hover { + background: rgba(255,255,255,0.08); + color: #e2e8f0; +} +:host-context([data-theme="light"]) .view-toggle:hover { + background: rgba(0,0,0,0.05); + color: #1e293b; +} +.view-toggle.active { + color: #22d3ee; + background: rgba(34,211,238,0.1); +} + +/* ── Stack view ── */ + +.stack-view { + padding: 12px; + overflow: auto; + max-height: 50vh; + transition: max-height 0.3s ease; +} + +:host-context([data-theme="dark"]) .stack-view { + background: rgba(15,23,42,0.5); + border-bottom: 1px solid rgba(255,255,255,0.06); +} +:host-context([data-theme="light"]) .stack-view { + background: rgba(248,250,252,0.8); + border-bottom: 1px solid rgba(0,0,0,0.06); +} + +.stack-view svg { + display: block; + margin: 0 auto; +} + +.stack-layer { cursor: pointer; } +.stack-layer:hover rect { stroke-width: 2.5; } +.stack-layer--active rect { stroke-dasharray: none; } + +/* Drag-to-connect visual states */ +.stack-layer.flow-drag-source rect { stroke-dasharray: 4 2; stroke-width: 2.5; } +.stack-layer.flow-drag-target rect { stroke-width: 3; filter: brightness(1.3); } + +.stack-flow { cursor: pointer; } +.stack-flow:hover path { stroke-width: 4 !important; opacity: 1 !important; } + +/* ── Flow creation dialog ── */ + +.flow-dialog { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 280px; + border-radius: 12px; + padding: 16px; + z-index: 50; + box-shadow: 0 12px 40px rgba(0,0,0,0.4); +} +:host-context([data-theme="dark"]) .flow-dialog { + background: #1e293b; + border: 1px solid rgba(255,255,255,0.1); + color: #e2e8f0; +} +:host-context([data-theme="light"]) .flow-dialog { + background: white; + border: 1px solid rgba(0,0,0,0.1); + color: #1e293b; +} + +.flow-dialog-header { + font-size: 0.85rem; + font-weight: 700; + margin-bottom: 10px; +} + +.flow-dialog-route { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + justify-content: center; +} + +.flow-dialog-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 3px 8px; + border-radius: 5px; + font-size: 0.6rem; + font-weight: 900; + color: #0f172a; +} + +.flow-dialog-arrow { + font-size: 1rem; + opacity: 0.4; +} + +.flow-dialog-field { + margin-bottom: 10px; +} + +.flow-dialog-label { + display: block; + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.5; + margin-bottom: 4px; +} + +.flow-kind-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; +} + +.flow-kind-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px; + border: 1px solid transparent; + border-radius: 5px; + background: transparent; + color: inherit; + font-size: 0.7rem; + cursor: pointer; + transition: background 0.12s, border-color 0.12s; +} +.flow-kind-btn:hover { background: rgba(255,255,255,0.05); } +.flow-kind-btn.selected { + border-color: var(--kind-color); + background: color-mix(in srgb, var(--kind-color) 10%, transparent); +} + +.flow-kind-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.flow-dialog-input { + width: 100%; + padding: 6px 8px; + border: 1px solid rgba(255,255,255,0.1); + border-radius: 5px; + background: rgba(255,255,255,0.04); + color: inherit; + font-size: 0.75rem; + outline: none; +} +.flow-dialog-input:focus { border-color: rgba(34,211,238,0.4); } +:host-context([data-theme="light"]) .flow-dialog-input { + border-color: rgba(0,0,0,0.1); + background: rgba(0,0,0,0.02); +} + +.flow-dialog-range { + width: 100%; + accent-color: #22d3ee; +} + +.flow-dialog-actions { + display: flex; + gap: 6px; + justify-content: flex-end; + margin-top: 4px; +} + +.flow-dialog-cancel, .flow-dialog-create { + padding: 5px 14px; + border-radius: 6px; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; +} + +.flow-dialog-cancel { + border: 1px solid rgba(255,255,255,0.1); + background: transparent; + color: inherit; + opacity: 0.6; +} +.flow-dialog-cancel:hover { opacity: 1; } + +.flow-dialog-create { + border: none; + background: #22d3ee; + color: #0f172a; +} +.flow-dialog-create:hover { opacity: 0.85; } + +/* ── Responsive ── */ + +@media (max-width: 640px) { + .tab-label { display: none; } + .tab { padding: 4px 8px; } + .stack-view { max-height: 40vh; } + .flow-dialog { width: 240px; } +} +`; diff --git a/shared/module.ts b/shared/module.ts index 7347dde..92df586 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -1,4 +1,25 @@ import { Hono } from "hono"; +import type { FlowKind } from "../lib/layer-types"; + +/** + * Feed definition — describes a data feed that a module can expose to other layers. + * Feeds are the connective tissue between layers: they let data, economic value, + * trust signals, etc. flow between rApps. + */ +export interface FeedDefinition { + /** Feed identifier (unique within the module) */ + id: string; + /** Human-readable name */ + name: string; + /** What kind of flow this feed carries */ + kind: FlowKind; + /** Description of what this feed provides */ + description: string; + /** Shape types this feed emits (e.g. "folk-note", "folk-token-mint") */ + emits?: string[]; + /** Whether this feed supports filtering */ + filterable?: boolean; +} /** * The contract every rSpace module must implement. @@ -21,6 +42,10 @@ export interface RSpaceModule { routes: Hono; /** Optional: standalone domain for this module (e.g. 'rbooks.online') */ standaloneDomain?: string; + /** Feeds this module exposes to other layers */ + feeds?: FeedDefinition[]; + /** Feed kinds this module can consume from other layers */ + acceptsFeeds?: FlowKind[]; /** Called when a new space is created (e.g. to initialize module-specific data) */ onSpaceCreate?: (spaceSlug: string) => Promise; /** Called when a space is deleted (e.g. to clean up module-specific data) */ @@ -42,13 +67,15 @@ export function getAllModules(): RSpaceModule[] { return Array.from(modules.values()); } -/** Metadata exposed to the client for the app switcher */ +/** Metadata exposed to the client for the app switcher and tab bar */ export interface ModuleInfo { id: string; name: string; icon: string; description: string; standaloneDomain?: string; + feeds?: FeedDefinition[]; + acceptsFeeds?: FlowKind[]; } export function getModuleInfoList(): ModuleInfo[] { @@ -58,5 +85,7 @@ export function getModuleInfoList(): ModuleInfo[] { icon: m.icon, description: m.description, ...(m.standaloneDomain ? { standaloneDomain: m.standaloneDomain } : {}), + ...(m.feeds ? { feeds: m.feeds } : {}), + ...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}), })); } diff --git a/website/canvas.html b/website/canvas.html index 5982210..132118f 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -657,6 +657,7 @@ + @@ -713,6 +714,7 @@ FolkChoiceSpider, FolkSocialPost, FolkCanvas, + FolkFeed, CommunitySync, PresenceManager, generatePeerId, @@ -721,11 +723,13 @@ import { RStackIdentity } from "@shared/components/rstack-identity"; import { RStackAppSwitcher } from "@shared/components/rstack-app-switcher"; import { RStackSpaceSwitcher } from "@shared/components/rstack-space-switcher"; + import { RStackTabBar } from "@shared/components/rstack-tab-bar"; // Register shell header components RStackIdentity.define(); RStackAppSwitcher.define(); RStackSpaceSwitcher.define(); + RStackTabBar.define(); // Load module list for app switcher fetch("/api/modules").then(r => r.json()).then(data => { @@ -772,6 +776,7 @@ FolkChoiceSpider.define(); FolkSocialPost.define(); FolkCanvas.define(); + FolkFeed.define(); // Get community info from URL // Supports path-based slugs: cca.rspace.online/campaign/demo → slug "campaign-demo" @@ -818,7 +823,8 @@ "folk-budget", "folk-packing-list", "folk-booking", "folk-token-mint", "folk-token-ledger", "folk-choice-vote", "folk-choice-rank", "folk-choice-spider", - "folk-social-post" + "folk-social-post", + "folk-feed" ].join(", "); // Initialize offline store and CommunitySync @@ -833,6 +839,11 @@ statusText.textContent = "Offline (cached)"; } + // Notify the shell tab bar that CommunitySync is ready + document.dispatchEvent(new CustomEvent("community-sync-ready", { + detail: { sync, communitySlug } + })); + // Initialize Presence for real-time cursors const peerId = generatePeerId(); const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`; @@ -1126,6 +1137,16 @@ if (data.collapsed != null) shape.collapsed = data.collapsed; if (data.label) shape.label = data.label; break; + case "folk-feed": + shape = document.createElement("folk-feed"); + if (data.sourceLayer) shape.sourceLayer = data.sourceLayer; + if (data.sourceModule) shape.sourceModule = data.sourceModule; + if (data.feedId) shape.feedId = data.feedId; + if (data.flowKind) shape.flowKind = data.flowKind; + if (data.feedFilter) shape.feedFilter = data.feedFilter; + if (data.maxItems) shape.maxItems = data.maxItems; + if (data.refreshInterval) shape.refreshInterval = data.refreshInterval; + break; case "folk-markdown": default: shape = document.createElement("folk-markdown"); @@ -1196,6 +1217,7 @@ "folk-choice-spider": { width: 440, height: 540 }, "folk-social-post": { width: 300, height: 380 }, "folk-canvas": { width: 600, height: 400 }, + "folk-feed": { width: 280, height: 360 }, }; // Get the center of the current viewport in canvas coordinates @@ -1446,6 +1468,67 @@ }); }); + // Feed shape — pull live data from another layer/module + document.getElementById("new-feed").addEventListener("click", () => { + // Prompt for source module (simple for now — will get a proper UI) + const modules = ["notes", "funds", "vote", "choices", "wallet", "data", "work", "network", "trips"]; + const sourceModule = prompt("Feed from which rApp?\n\n" + modules.join(", "), "notes"); + if (!sourceModule || !modules.includes(sourceModule)) return; + + // Pick flow kind based on module defaults + const moduleFlowKinds = { + funds: "economic", wallet: "economic", trips: "economic", + vote: "governance", choices: "governance", + network: "trust", + data: "attention", + notes: "data", work: "data", + }; + const flowKind = moduleFlowKinds[sourceModule] || "data"; + + const shape = newShape("folk-feed", { + sourceModule, + sourceLayer: "layer-" + sourceModule, + feedId: "", + flowKind, + maxItems: 10, + refreshInterval: 30000, + }); + + // Auto-register a LayerFlow in Automerge if layers exist + if (shape && sync.getLayers) { + const layers = sync.getLayers(); + const currentLayer = layers.find(l => l.moduleId === "canvas") || layers[0]; + const sourceLayer = layers.find(l => l.moduleId === sourceModule); + + if (currentLayer && sourceLayer) { + const flowId = `flow-auto-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; + sync.addFlow({ + id: flowId, + kind: flowKind, + sourceLayerId: sourceLayer.id, + targetLayerId: currentLayer.id, + targetShapeId: shape.id, + label: sourceModule + " feed", + strength: 0.5, + active: true, + }); + } + + // Also ensure source module has a layer (add if missing) + if (!sourceLayer) { + sync.addLayer({ + id: "layer-" + sourceModule, + moduleId: sourceModule, + label: sourceModule, + order: layers.length, + color: "", + visible: true, + createdAt: Date.now(), + }); + } + } + }); + // Arrow connection mode let connectMode = false; let connectSource = null; @@ -1515,7 +1598,7 @@ "folk-token-mint": "🪙", "folk-token-ledger": "📒", "folk-choice-vote": "☑", "folk-choice-rank": "📊", "folk-choice-spider": "🕸", "folk-social-post": "📱", - "folk-arrow": "↗️", + "folk-feed": "🔄", "folk-arrow": "↗️", }; function getShapeLabel(data) {