feat: layered tab system with inter-layer flows and bidirectional feeds

Introduces the full layer/tab architecture for rSpace — each rApp becomes
a layer in a vertical stack with typed flows (economic, trust, data,
attention, governance, resource) connecting them.

New components:
- rstack-tab-bar: tab bar with flat/stack view toggle, drag reorder,
  drag-to-connect flow creation with kind/label/strength dialog
- folk-feed: canvas shape that pulls live data from other layers with
  bidirectional write-back (edit items inline, push changes to source API)
- layer-types: Layer, LayerFlow, FlowKind types and color palette

Automerge schema extended with layers, flows, activeLayerId, layerViewMode.
CommunitySync gains 11 new methods for layer/flow CRUD.

Feed definitions added to 10 modules (funds, notes, vote, choices, wallet,
data, work, network, trips, canvas) with typed feeds and acceptsFeeds.

RSpaceModule interface extended with FeedDefinition and acceptsFeeds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-25 15:29:03 -08:00
parent 0813eed5e0
commit cd440f1342
18 changed files with 2539 additions and 4 deletions

View File

@ -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<Layer>): 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<string, unknown>)[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<LayerFlow>): 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<string, unknown>)[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
*/

887
lib/folk-feed.ts Normal file
View File

@ -0,0 +1,887 @@
/**
* <folk-feed> 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<typeof setInterval> | 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 = `
<div class="feed-header">
<div class="feed-kind-dot"></div>
<div class="feed-title"></div>
<button class="feed-navigate" title="Go to source layer"></button>
<button class="feed-refresh" title="Refresh"></button>
</div>
<div class="feed-items"></div>
<div class="feed-edit-overlay"></div>
<div class="feed-status"></div>
`;
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<HTMLElement>(".feed-kind-dot");
const title = this.#inner.querySelector<HTMLElement>(".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<string, Record<string, string>> = {
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 = `<div class="feed-empty">No data</div>`;
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 `
<div class="feed-item ${editable ? "feed-item--editable" : ""}" data-index="${i}" data-item-id="${item.id || ""}">
<div class="feed-item-line" style="background:${color}"></div>
<div class="feed-item-content">
<div class="feed-item-title">${this.#escapeHtml(String(title))}</div>
${subtitle ? `<div class="feed-item-subtitle">${this.#escapeHtml(String(subtitle).slice(0, 100))}</div>` : ""}
</div>
<div class="feed-item-actions">
${editable ? `<button class="feed-item-edit" data-edit="${i}" title="Edit">✎</button>` : ""}
${badge ? `<div class="feed-item-badge" style="color:${color}">${this.#escapeHtml(String(badge))}</div>` : ""}
</div>
</div>
`;
}).join("");
// Attach item click and edit events
container.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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 = `
<div class="edit-panel">
<div class="edit-header">
<span style="color:${color}">Edit: ${this.#escapeHtml(item.title || item.name || "Item")}</span>
<button class="edit-close">&times;</button>
</div>
<div class="edit-fields">
${editableFields.map(f => `
<label class="edit-field">
<span class="edit-label">${f.label}</span>
${f.type === "textarea"
? `<textarea class="edit-input" data-field="${f.key}" rows="3">${this.#escapeHtml(String(f.value))}</textarea>`
: f.type === "select"
? `<select class="edit-input" data-field="${f.key}">
${f.options!.map(o => `<option value="${o}" ${o === f.value ? "selected" : ""}>${o}</option>`).join("")}
</select>`
: `<input class="edit-input" type="text" data-field="${f.key}" value="${this.#escapeHtml(String(f.value))}" />`
}
</label>
`).join("")}
</div>
<div class="edit-actions">
<button class="edit-cancel">Cancel</button>
<button class="edit-save" style="background:${color}20; color:${color}; border-color:${color}40">Save & Push</button>
</div>
</div>
`;
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<HTMLElement>(".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<HTMLElement>(".feed-edit-overlay");
if (!overlay) return;
// Collect edited values
const updates: Record<string, string> = {};
overlay.querySelectorAll<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(".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<HTMLElement>(".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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
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;
}
`;

View File

@ -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";

100
lib/layer-types.ts Normal file
View File

@ -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<FlowKind, string> = {
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<FlowKind, string> = {
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<string, unknown>;
}
// ── 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";
}

View File

@ -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"],
};

View File

@ -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"],
};

View File

@ -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"],
};

View File

@ -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"],
};

View File

@ -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"],
};

View File

@ -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"],
};

View File

@ -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"],
};

View File

@ -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"],
};

View File

@ -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"],
};

View File

@ -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"],
};

View File

@ -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);
});
});
}

File diff suppressed because it is too large Load Diff

View File

@ -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<void>;
/** 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 } : {}),
}));
}

View File

@ -657,6 +657,7 @@
<span class="toolbar-sep"></span>
<button id="new-arrow" title="Connect rSpaces">↗️ Connect</button>
<button id="new-feed" title="New Feed from another layer">🔄 Feed</button>
<button id="toggle-memory" title="Forgotten rSpaces">💭 Memory</button>
<span class="toolbar-sep"></span>
@ -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) {