Merge branch 'dev'
CI/CD / deploy (push) Successful in 2m17s Details

This commit is contained in:
Jeff Emmett 2026-04-15 13:09:01 -04:00
commit 0a5a8cb371
25 changed files with 1320 additions and 369 deletions

View File

@ -7,3 +7,18 @@
export { govApplets } from "../modules/rgov/applets";
export { flowsApplets } from "../modules/rflows/applets";
export { walletApplets } from "../modules/rwallet/applets";
export { tasksApplets } from "../modules/rtasks/applets";
export { timeApplets } from "../modules/rtime/applets";
export { calApplets } from "../modules/rcal/applets";
export { chatsApplets } from "../modules/rchats/applets";
export { dataApplets } from "../modules/rdata/applets";
export { docsApplets } from "../modules/rdocs/applets";
export { notesApplets } from "../modules/rnotes/applets";
export { photosApplets } from "../modules/rphotos/applets";
export { mapsApplets } from "../modules/rmaps/applets";
export { networkApplets } from "../modules/rnetwork/applets";
export { choicesApplets } from "../modules/rchoices/applets";
export { inboxApplets } from "../modules/rinbox/applets";
export { socialsApplets } from "../modules/rsocials/applets";
export { booksApplets } from "../modules/rbooks/applets";
export { exchangeApplets } from "../modules/rexchange/applets";

View File

@ -12,7 +12,7 @@ import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
import { dataTypeColor } from "./data-types";
import type { PortDescriptor } from "./data-types";
import type { AppletDefinition, AppletLiveData } from "../shared/applet-types";
import type { AppletDefinition, AppletLiveData, AppletContext } from "../shared/applet-types";
// ── Applet registry (populated by modules at init) ──
@ -120,45 +120,43 @@ const styles = css`
font-style: italic;
}
/* Port indicators on edges */
.port-indicator {
position: absolute;
width: 12px;
height: 12px;
border-radius: 50%;
border: 2px solid #0f172a;
cursor: crosshair;
z-index: 2;
transition: transform 0.15s;
}
.port-indicator:hover {
transform: scale(1.4);
}
.port-indicator.input {
left: -6px;
}
.port-indicator.output {
right: -6px;
}
.port-label {
/* Port chips on edges */
.port-chip {
position: absolute;
display: flex;
align-items: center;
gap: 4px;
padding: 1px 6px;
border-radius: 8px;
border: 1.5px solid;
background: var(--rs-bg-surface, #1e293b);
font-size: 9px;
color: var(--rs-text-muted, #94a3b8);
white-space: nowrap;
pointer-events: none;
cursor: crosshair;
z-index: 2;
transform: translateY(-50%);
transition: filter 0.15s;
}
.port-label.input {
left: 10px;
.port-chip:hover {
filter: brightness(1.3);
}
.port-label.output {
right: 10px;
text-align: right;
.port-chip.input {
left: -2px;
}
.port-chip.output {
right: -2px;
flex-direction: row-reverse;
}
.chip-dot {
width: 6px;
height: 6px;
border-radius: 50%;
flex-shrink: 0;
}
/* Expanded mode circuit container */
@ -250,6 +248,26 @@ export class FolkApplet extends FolkShape {
return this.#instancePorts.find(p => p.name === name);
}
/** Bridge FolkArrow piping → applet def's onInputReceived. */
override setPortValue(name: string, value: unknown): void {
super.setPortValue(name, value);
const port = this.getPort(name);
if (port?.direction !== "input") return;
const def = getAppletDef(this.#moduleId, this.#appletId);
if (!def?.onInputReceived) return;
const ctx: AppletContext = {
space: (this.closest("[space]") as any)?.getAttribute("space") || "",
shapeId: this.id,
emitOutput: (portName, val) => super.setPortValue(portName, val),
};
def.onInputReceived(name, value, ctx);
this.#renderBody();
}
/** Update live data and re-render compact body. */
updateLiveData(snapshot: Record<string, unknown>): void {
this.#liveData = {
@ -319,57 +337,35 @@ export class FolkApplet extends FolkShape {
}
#renderPorts(): void {
// Remove existing port indicators
this.#wrapper.querySelectorAll(".port-indicator, .port-label").forEach(el => el.remove());
this.#wrapper.querySelectorAll(".port-chip").forEach(el => el.remove());
const inputs = this.getInputPorts();
const outputs = this.getOutputPorts();
// Input ports on left edge
inputs.forEach((port, i) => {
const yPct = ((i + 1) / (inputs.length + 1)) * 100;
const renderChips = (ports: PortDescriptor[], dir: "input" | "output") => {
ports.forEach((port, i) => {
const yPct = ((i + 1) / (ports.length + 1)) * 100;
const color = dataTypeColor(port.type);
const dot = document.createElement("div");
dot.className = "port-indicator input";
dot.style.top = `${yPct}%`;
dot.style.backgroundColor = color;
dot.dataset.portName = port.name;
dot.dataset.portDir = "input";
dot.title = `${port.name} (${port.type})`;
const chip = document.createElement("div");
chip.className = `port-chip ${dir}`;
chip.style.top = `${yPct}%`;
chip.style.borderColor = color;
chip.dataset.portName = port.name;
chip.dataset.portDir = dir;
chip.title = `${port.name} (${port.type})`;
const label = document.createElement("span");
label.className = "port-label input";
label.style.top = `${yPct}%`;
label.style.transform = "translateY(-50%)";
label.textContent = port.name;
const dot = document.createElement("span");
dot.className = "chip-dot";
dot.style.background = color;
this.#wrapper.appendChild(dot);
this.#wrapper.appendChild(label);
const label = document.createTextNode(port.name);
chip.appendChild(dot);
chip.appendChild(label);
this.#wrapper.appendChild(chip);
});
};
// Output ports on right edge
outputs.forEach((port, i) => {
const yPct = ((i + 1) / (outputs.length + 1)) * 100;
const color = dataTypeColor(port.type);
const dot = document.createElement("div");
dot.className = "port-indicator output";
dot.style.top = `${yPct}%`;
dot.style.backgroundColor = color;
dot.dataset.portName = port.name;
dot.dataset.portDir = "output";
dot.title = `${port.name} (${port.type})`;
const label = document.createElement("span");
label.className = "port-label output";
label.style.top = `${yPct}%`;
label.style.transform = "translateY(-50%)";
label.textContent = port.name;
this.#wrapper.appendChild(dot);
this.#wrapper.appendChild(label);
});
renderChips(this.getInputPorts(), "input");
renderChips(this.getOutputPorts(), "output");
}
#renderBody(): void {

42
modules/rbooks/applets.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* rBooks applet definitions Book Card.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const bookCard: AppletDefinition = {
id: "book-card",
label: "Book Card",
icon: "📚",
accentColor: "#92400e",
ports: [
{ name: "query-in", type: "string", direction: "input" },
{ name: "book-out", type: "json", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const title = (snapshot.title as string) || "Book";
const author = (snapshot.author as string) || "";
const progress = (snapshot.progress as number) || 0;
const rating = (snapshot.rating as number) || 0;
return `
<div>
<div style="font-size:13px;font-weight:600;margin-bottom:2px">${title}</div>
${author ? `<div style="font-size:11px;color:#94a3b8;margin-bottom:6px">${author}</div>` : ""}
${rating > 0 ? `<div style="font-size:12px;margin-bottom:4px">${"★".repeat(rating)}${"☆".repeat(5 - rating)}</div>` : ""}
<div style="font-size:10px;color:#94a3b8;margin-bottom:3px">Progress: ${progress}%</div>
<div style="background:#334155;border-radius:3px;height:5px;overflow:hidden">
<div style="background:#92400e;width:${progress}%;height:100%;border-radius:3px;transition:width 0.3s"></div>
</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "query-in" && typeof value === "string") {
ctx.emitOutput("book-out", { query: value });
}
},
};
export const booksApplets: AppletDefinition[] = [bookCard];

38
modules/rcal/applets.ts Normal file
View File

@ -0,0 +1,38 @@
/**
* rCal applet definitions Next Event.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const nextEvent: AppletDefinition = {
id: "next-event",
label: "Next Event",
icon: "📅",
accentColor: "#2563eb",
ports: [
{ name: "events-in", type: "json", direction: "input" },
{ name: "event-out", type: "json", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const title = (snapshot.title as string) || "No upcoming events";
const time = (snapshot.time as string) || "";
const location = (snapshot.location as string) || "";
return `
<div>
<div style="font-size:13px;font-weight:600;margin-bottom:4px">${title}</div>
${time ? `<div style="font-size:11px;color:#60a5fa;margin-bottom:2px">🕐 ${time}</div>` : ""}
${location ? `<div style="font-size:11px;color:#94a3b8">📍 ${location}</div>` : ""}
${!time && !location ? `<div style="font-size:11px;color:#94a3b8;font-style:italic;margin-top:8px;text-align:center">Connect a calendar feed</div>` : ""}
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "events-in" && Array.isArray(value) && value.length > 0) {
ctx.emitOutput("event-out", value[0]);
}
},
};
export const calApplets: AppletDefinition[] = [nextEvent];

37
modules/rchats/applets.ts Normal file
View File

@ -0,0 +1,37 @@
/**
* rChats applet definitions Unread Count.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const unreadCount: AppletDefinition = {
id: "unread-count",
label: "Unread Count",
icon: "💬",
accentColor: "#0891b2",
ports: [
{ name: "channel-in", type: "string", direction: "input" },
{ name: "unread-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const channel = (snapshot.channel as string) || "general";
const unread = (snapshot.unread as number) || 0;
const badgeColor = unread > 0 ? "#ef4444" : "#334155";
return `
<div style="text-align:center">
<div style="font-size:11px;color:#94a3b8;margin-bottom:6px">#${channel}</div>
<div style="display:inline-flex;align-items:center;justify-content:center;width:48px;height:48px;border-radius:50%;background:${badgeColor};font-size:20px;font-weight:700;color:white">${unread}</div>
<div style="font-size:10px;color:#94a3b8;margin-top:6px">unread messages</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "channel-in" && typeof value === "string") {
ctx.emitOutput("unread-out", 0);
}
},
};
export const chatsApplets: AppletDefinition[] = [unreadCount];

View File

@ -0,0 +1,39 @@
/**
* rChoices applet definitions Vote Tally.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const voteTally: AppletDefinition = {
id: "vote-tally",
label: "Vote Tally",
icon: "🗳️",
accentColor: "#7c3aed",
ports: [
{ name: "session-in", type: "json", direction: "input" },
{ name: "winner-out", type: "string", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const question = (snapshot.question as string) || "Vote";
const totalVotes = (snapshot.totalVotes as number) || 0;
const winner = (snapshot.winner as string) || "—";
const winPct = (snapshot.winnerPct as number) || 0;
return `
<div style="text-align:center">
<div style="font-size:12px;font-weight:600;margin-bottom:6px">${question}</div>
<div style="font-size:20px;font-weight:700;color:#e2e8f0;margin-bottom:2px">${winner}</div>
<div style="font-size:10px;color:#94a3b8">${winPct}% of ${totalVotes} votes</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "session-in" && value && typeof value === "object") {
const session = value as Record<string, unknown>;
ctx.emitOutput("winner-out", (session.winner as string) || "");
}
},
};
export const choicesApplets: AppletDefinition[] = [voteTally];

42
modules/rdata/applets.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* rData applet definitions Analytics Card.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const analyticsCard: AppletDefinition = {
id: "analytics-card",
label: "Analytics Card",
icon: "📊",
accentColor: "#6366f1",
ports: [
{ name: "metric-in", type: "number", direction: "input" },
{ name: "delta-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const label = (snapshot.label as string) || "Metric";
const value = (snapshot.value as number) || 0;
const prev = (snapshot.previous as number) || 0;
const delta = prev > 0 ? Math.round(((value - prev) / prev) * 100) : 0;
const deltaColor = delta >= 0 ? "#22c55e" : "#ef4444";
const arrow = delta >= 0 ? "↑" : "↓";
return `
<div>
<div style="font-size:11px;color:#94a3b8;margin-bottom:6px">${label}</div>
<div style="font-size:24px;font-weight:700;color:#e2e8f0">${value.toLocaleString()}</div>
<div style="font-size:12px;font-weight:500;color:${deltaColor};margin-top:4px">
${arrow} ${Math.abs(delta)}% vs previous
</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "metric-in" && typeof value === "number") {
ctx.emitOutput("delta-out", value);
}
},
};
export const dataApplets: AppletDefinition[] = [analyticsCard];

View File

@ -9,6 +9,7 @@
import { TourEngine } from '../../../shared/tour-engine';
import type { TourStep } from '../../../shared/tour-engine';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { rspaceNavUrl } from '../../../shared/url-helpers';
interface TreeItem {
docId: string;
@ -284,10 +285,8 @@ class FolkContentTree extends HTMLElement {
}
private navigate(modId: string) {
const base = window.location.pathname.split("/").slice(0, -1).join("/");
// Navigate to the module: /{space}/r{modId} or /{space}/{modId}
const modPath = modId.startsWith("r") ? modId : `r${modId}`;
window.location.href = `${base}/${modPath}`;
window.open(rspaceNavUrl(this.space, modPath), "_blank");
}
private render() {

View File

@ -1,89 +1,99 @@
/**
* folk-data-cloud Concentric-ring SVG visualization of data objects
* across user spaces, grouped by visibility level (private/permissioned/public).
* folk-data-cloud Graph visualization of all data objects (documents)
* across the user's spaces. Nodes represent individual documents,
* grouped radially by module around a central space node.
*
* Two-level interaction: click space bubble detail panel with modules,
* click module row navigate to that module page.
* Click any node opens that module in a new tab.
* Demo mode shows dummy document nodes when unauthenticated.
*/
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { rspaceNavUrl } from '../../../shared/url-helpers';
interface SpaceInfo {
slug: string;
name: string;
// ── Types ──
interface DocNode {
docId: string;
title: string;
modId: string;
modName: string;
modIcon: string;
space: string;
spaceName: string;
visibility: string;
role?: string;
relationship?: string;
}
interface ModuleSummary {
interface GraphNode {
id: string;
name: string;
label: string;
icon: string;
docCount: number;
type: "space" | "module" | "doc";
modId?: string;
space?: string;
color: string;
x: number;
y: number;
r: number;
}
interface SpaceBubble extends SpaceInfo {
docCount: number;
modules: ModuleSummary[];
interface GraphEdge {
from: string;
to: string;
color: string;
}
type Ring = "private" | "permissioned" | "public";
// ── Colors ──
const RING_CONFIG: Record<Ring, { color: string; label: string; radius: number }> = {
private: { color: "#ef4444", label: "Private", radius: 0.28 },
permissioned: { color: "#eab308", label: "Permissioned", radius: 0.54 },
public: { color: "#22c55e", label: "Public", radius: 0.80 },
const VIS_COLORS: Record<string, string> = {
private: "#ef4444",
permissioned: "#eab308",
public: "#22c55e",
};
const RINGS: Ring[] = ["private", "permissioned", "public"];
const MOD_COLORS: Record<string, string> = {
notes: "#f97316", docs: "#f97316", vote: "#a855f7", tasks: "#3b82f6",
cal: "#06b6d4", wallet: "#eab308", flows: "#14b8a6", pubs: "#ec4899",
files: "#64748b", forum: "#8b5cf6", inbox: "#f43f5e", network: "#22d3ee",
trips: "#10b981", tube: "#f59e0b", choices: "#6366f1", cart: "#84cc16",
};
const DEMO_SPACES: SpaceBubble[] = [
{ slug: "personal", name: "Personal", visibility: "private", role: "owner", relationship: "owner", docCount: 14, modules: [
{ id: "notes", name: "rNotes", icon: "📝", docCount: 5 },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 4 },
{ id: "cal", name: "rCal", icon: "📅", docCount: 3 },
{ id: "wallet", name: "rWallet", icon: "💰", docCount: 2 },
]},
{ slug: "my-project", name: "Side Project", visibility: "private", role: "owner", relationship: "owner", docCount: 8, modules: [
{ id: "docs", name: "rDocs", icon: "📓", docCount: 3 },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 5 },
]},
{ slug: "team-alpha", name: "Team Alpha", visibility: "permissioned", role: "owner", relationship: "owner", docCount: 22, modules: [
{ id: "docs", name: "rDocs", icon: "📓", docCount: 6 },
{ id: "vote", name: "rVote", icon: "🗳", docCount: 4 },
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 3 },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 5 },
{ id: "cal", name: "rCal", icon: "📅", docCount: 4 },
]},
{ slug: "dao-gov", name: "DAO Governance", visibility: "permissioned", relationship: "member", docCount: 11, modules: [
{ id: "vote", name: "rVote", icon: "🗳", docCount: 7 },
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 4 },
]},
{ slug: "demo", name: "Demo Space", visibility: "public", relationship: "demo", docCount: 18, modules: [
{ id: "notes", name: "rNotes", icon: "📝", docCount: 3 },
{ id: "vote", name: "rVote", icon: "🗳", docCount: 2 },
{ id: "tasks", name: "rTasks", icon: "📋", docCount: 4 },
{ id: "cal", name: "rCal", icon: "📅", docCount: 3 },
{ id: "wallet", name: "rWallet", icon: "💰", docCount: 1 },
{ id: "flows", name: "rFlows", icon: "🌊", docCount: 5 },
]},
{ slug: "open-commons", name: "Open Commons", visibility: "public", relationship: "other", docCount: 9, modules: [
{ id: "docs", name: "rDocs", icon: "📓", docCount: 4 },
{ id: "pubs", name: "rPubs", icon: "📰", docCount: 5 },
]},
function modColor(modId: string): string {
return MOD_COLORS[modId] || "#94a3b8";
}
// ── Demo data ──
const DEMO_DOCS: DocNode[] = [
{ docId: "demo:notes:notebooks:nb1", title: "Product Roadmap", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:notes:notebooks:nb2", title: "Meeting Notes", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:notes:notebooks:nb3", title: "Research Log", modId: "notes", modName: "rNotes", modIcon: "📝", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:vote:proposals:p1", title: "Dark mode proposal", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:vote:proposals:p2", title: "Budget Q2", modId: "vote", modName: "rVote", modIcon: "🗳", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:tasks:boards:b1", title: "Dev Board", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:tasks:boards:b2", title: "Design Sprint", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:tasks:boards:b3", title: "Bug Tracker", modId: "tasks", modName: "rTasks", modIcon: "📋", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:cal:calendars:c1", title: "Team Calendar", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:cal:calendars:c2", title: "Personal", modId: "cal", modName: "rCal", modIcon: "📅", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:wallet:ledgers:l1", title: "cUSDC Ledger", modId: "wallet", modName: "rWallet", modIcon: "💰", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:flows:streams:s1", title: "Contributor Fund", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:flows:streams:s2", title: "Community Pool", modId: "flows", modName: "rFlows", modIcon: "🌊", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:docs:notebooks:d1", title: "Onboarding Guide", modId: "docs", modName: "rDocs", modIcon: "📓", space: "demo", spaceName: "Demo Space", visibility: "public" },
{ docId: "demo:pubs:pages:pub1", title: "Launch Announcement", modId: "pubs", modName: "rPubs", modIcon: "📰", space: "demo", spaceName: "Demo Space", visibility: "public" },
];
// ── Component ──
class FolkDataCloud extends HTMLElement {
private shadow: ShadowRoot;
private space = "demo";
private spaces: SpaceBubble[] = [];
private docs: DocNode[] = [];
private nodes: GraphNode[] = [];
private edges: GraphEdge[] = [];
private loading = true;
private isDemo = false;
private selected: string | null = null;
private hoveredSlug: string | null = null;
private width = 600;
private height = 600;
private hoveredId: string | null = null;
private width = 700;
private height = 700;
private _stopPresence: (() => void) | null = null;
private _resizeObserver: ResizeObserver | null = null;
@ -95,10 +105,10 @@ class FolkDataCloud extends HTMLElement {
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this._resizeObserver = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width || 600;
this.width = Math.min(w, 800);
this.height = this.width;
if (!this.loading) this.render();
const w = entries[0]?.contentRect.width || 700;
this.width = Math.min(w, 900);
this.height = Math.max(this.width * 0.85, 500);
if (!this.loading) { this.layout(); this.render(); }
});
this._resizeObserver.observe(this);
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rdata', context: 'Cloud' }));
@ -110,6 +120,8 @@ class FolkDataCloud extends HTMLElement {
this._resizeObserver?.disconnect();
}
// ── Data loading ──
private async loadData() {
this.loading = true;
this.render();
@ -117,9 +129,8 @@ class FolkDataCloud extends HTMLElement {
const token = localStorage.getItem("rspace_auth");
if (!token) {
this.isDemo = true;
this.spaces = DEMO_SPACES;
this.loading = false;
this.render();
this.docs = DEMO_DOCS;
this.finalize();
return;
}
@ -129,237 +140,346 @@ class FolkDataCloud extends HTMLElement {
signal: AbortSignal.timeout(8000),
});
if (!spacesResp.ok) throw new Error("spaces fetch failed");
const spacesData: { spaces: SpaceInfo[] } = await spacesResp.json();
const { spaces } = await spacesResp.json() as { spaces: Array<{ slug: string; name: string; visibility: string }> };
// Fetch content-tree for each space in parallel
const base = window.location.pathname.replace(/\/(tree|analytics|cloud)?\/?$/, "");
const bubbles: SpaceBubble[] = await Promise.all(
spacesData.spaces.map(async (sp) => {
const allDocs: DocNode[] = [];
await Promise.all(spaces.map(async (sp) => {
try {
const treeResp = await fetch(
const resp = await fetch(
`${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`,
{ signal: AbortSignal.timeout(8000) }
);
if (!treeResp.ok) return { ...sp, docCount: 0, modules: [] };
const tree = await treeResp.json();
const modules: ModuleSummary[] = (tree.modules || []).map((m: any) => ({
id: m.id,
name: m.name,
icon: m.icon,
docCount: m.collections.reduce((s: number, c: any) => s + c.items.length, 0),
}));
const docCount = modules.reduce((s, m) => s + m.docCount, 0);
return { ...sp, docCount, modules };
} catch {
return { ...sp, docCount: 0, modules: [] };
if (!resp.ok) return;
const tree = await resp.json();
for (const mod of (tree.modules || [])) {
for (const col of (mod.collections || [])) {
for (const item of (col.items || [])) {
allDocs.push({
docId: item.docId,
title: item.title || col.collection,
modId: mod.id,
modName: mod.name,
modIcon: mod.icon,
space: sp.slug,
spaceName: sp.name,
visibility: sp.visibility || "private",
});
}
})
);
}
}
} catch { /* skip space */ }
}));
this.spaces = bubbles;
this.docs = allDocs;
this.isDemo = false;
} catch {
this.isDemo = true;
this.spaces = DEMO_SPACES;
this.docs = DEMO_DOCS;
}
this.finalize();
}
private finalize() {
this.loading = false;
this.layout();
this.render();
}
private groupByRing(): Record<Ring, SpaceBubble[]> {
const groups: Record<Ring, SpaceBubble[]> = { private: [], permissioned: [], public: [] };
for (const sp of this.spaces) {
const ring = (sp.visibility as Ring) || "private";
(groups[ring] || groups.private).push(sp);
// ── Graph layout ──
// Central node per space, module nodes around it, doc nodes orbiting modules.
private layout() {
this.nodes = [];
this.edges = [];
const cx = this.width / 2;
const cy = this.height / 2;
const mobile = this.width < 500;
// Group docs by space, then by module
const spaceMap = new Map<string, { name: string; vis: string; mods: Map<string, DocNode[]> }>();
for (const doc of this.docs) {
if (!spaceMap.has(doc.space)) {
spaceMap.set(doc.space, { name: doc.spaceName, vis: doc.visibility, mods: new Map() });
}
return groups;
const sp = spaceMap.get(doc.space)!;
if (!sp.mods.has(doc.modId)) sp.mods.set(doc.modId, []);
sp.mods.get(doc.modId)!.push(doc);
}
private isMobile(): boolean {
return this.width < 500;
const spaceKeys = [...spaceMap.keys()];
const spaceCount = spaceKeys.length;
if (spaceCount === 0) return;
// Single space → center layout. Multiple → distribute around center.
const spaceR = mobile ? 18 : 24;
const modR = mobile ? 12 : 16;
const docR = mobile ? 6 : 8;
const orbitMod = mobile ? 70 : 100; // module distance from space center
const orbitDoc = mobile ? 28 : 38; // doc distance from module center
for (let si = 0; si < spaceCount; si++) {
const spaceSlug = spaceKeys[si];
const sp = spaceMap.get(spaceSlug)!;
const visColor = VIS_COLORS[sp.vis] || VIS_COLORS.private;
// Space position
let sx: number, sy: number;
if (spaceCount === 1) {
sx = cx; sy = cy;
} else {
const spaceOrbit = Math.min(this.width, this.height) * 0.3;
const spAngle = (2 * Math.PI * si / spaceCount) - Math.PI / 2;
sx = cx + spaceOrbit * Math.cos(spAngle);
sy = cy + spaceOrbit * Math.sin(spAngle);
}
const spaceNodeId = `space:${spaceSlug}`;
this.nodes.push({
id: spaceNodeId,
label: sp.name,
icon: "",
type: "space",
space: spaceSlug,
color: visColor,
x: sx, y: sy, r: spaceR,
});
// Modules around space
const modKeys = [...sp.mods.keys()];
const modCount = modKeys.length;
const actualModOrbit = Math.min(orbitMod, (spaceCount === 1 ? orbitMod * 1.5 : orbitMod));
for (let mi = 0; mi < modCount; mi++) {
const mId = modKeys[mi];
const docs = sp.mods.get(mId)!;
const firstDoc = docs[0];
const mAngle = (2 * Math.PI * mi / modCount) - Math.PI / 2;
const mx = sx + actualModOrbit * Math.cos(mAngle);
const my = sy + actualModOrbit * Math.sin(mAngle);
const modNodeId = `mod:${spaceSlug}:${mId}`;
this.nodes.push({
id: modNodeId,
label: firstDoc.modName,
icon: firstDoc.modIcon,
type: "module",
modId: mId,
space: spaceSlug,
color: modColor(mId),
x: mx, y: my, r: modR,
});
this.edges.push({ from: spaceNodeId, to: modNodeId, color: visColor });
// Doc nodes around module
for (let di = 0; di < docs.length; di++) {
const doc = docs[di];
const dAngle = (2 * Math.PI * di / docs.length) - Math.PI / 2;
// Offset by module angle to spread outward
const dx = mx + orbitDoc * Math.cos(dAngle);
const dy = my + orbitDoc * Math.sin(dAngle);
const docNodeId = `doc:${doc.docId}`;
this.nodes.push({
id: docNodeId,
label: doc.title,
icon: "",
type: "doc",
modId: mId,
space: spaceSlug,
color: modColor(mId),
x: dx, y: dy, r: docR,
});
this.edges.push({ from: modNodeId, to: docNodeId, color: modColor(mId) });
}
}
}
}
// ── Rendering ──
private render() {
const selected = this.selected ? this.spaces.find(s => s.slug === this.selected) : null;
this.shadow.innerHTML = `
<style>${this.styles()}</style>
<div class="dc">
${this.isDemo ? `<div class="dc-banner">Sign in to see your data cloud</div>` : ""}
${this.loading ? this.renderLoading() : this.renderSVG()}
${selected ? this.renderDetailPanel(selected) : ""}
${this.loading ? this.renderLoading() : this.renderGraph()}
${!this.loading ? this.renderLegend() : ""}
</div>
`;
this.attachEvents();
}
private renderLoading(): string {
const cx = this.width / 2;
const cy = this.height / 2;
return `
<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">
${RINGS.map(ring => {
const r = RING_CONFIG[ring].radius * (this.width / 2) * 0.9;
return `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
stroke="var(--rs-border)" stroke-width="1" stroke-dasharray="4 4" opacity="0.3"/>`;
}).join("")}
<text x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central"
<text x="${this.width / 2}" y="${this.height / 2}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-muted)" font-size="14">Loading your data cloud</text>
</svg>
`;
}
private renderSVG(): string {
const groups = this.groupByRing();
const cx = this.width / 2;
const cy = this.height / 2;
const scale = (this.width / 2) * 0.9;
const mobile = this.isMobile();
const bubbleR = mobile ? 20 : 28;
const maxDocCount = Math.max(1, ...this.spaces.map(s => s.docCount));
private renderGraph(): string {
if (this.nodes.length === 0) {
return `<div class="dc-empty">No data objects found</div>`;
}
const mobile = this.width < 500;
let svg = `<svg class="dc-svg" viewBox="0 0 ${this.width} ${this.height}" width="${this.width}" height="${this.height}">`;
// Render rings (outer to inner so inner draws on top)
for (const ring of [...RINGS].reverse()) {
const cfg = RING_CONFIG[ring];
const r = cfg.radius * scale;
svg += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="none"
stroke="${cfg.color}" stroke-width="1.5" stroke-dasharray="6 4" opacity="0.4"/>`;
// Ring label at top
const labelY = cy - r - 8;
svg += `<text x="${cx}" y="${labelY}" text-anchor="middle" fill="${cfg.color}"
font-size="${mobile ? 10 : 12}" font-weight="600" opacity="0.7">${cfg.label}</text>`;
// Edges first (behind nodes)
for (const edge of this.edges) {
const from = this.nodes.find(n => n.id === edge.from);
const to = this.nodes.find(n => n.id === edge.to);
if (!from || !to) continue;
svg += `<line x1="${from.x}" y1="${from.y}" x2="${to.x}" y2="${to.y}"
stroke="${edge.color}" stroke-width="1" opacity="0.2"/>`;
}
// Render bubbles per ring
for (const ring of RINGS) {
const cfg = RING_CONFIG[ring];
const ringR = cfg.radius * scale;
const ringSpaces = groups[ring];
if (ringSpaces.length === 0) continue;
// Nodes
for (const node of this.nodes) {
const isHovered = this.hoveredId === node.id;
const strokeW = isHovered ? 2.5 : (node.type === "space" ? 2 : 1.2);
const fillOpacity = node.type === "doc" ? 0.5 : (node.type === "module" ? 0.3 : 0.15);
const hoverOpacity = isHovered ? 0.8 : fillOpacity;
const angleStep = (2 * Math.PI) / ringSpaces.length;
const startAngle = -Math.PI / 2; // Start from top
svg += `<g class="dc-node" data-id="${this.escAttr(node.id)}" data-space="${this.escAttr(node.space || "")}" data-mod="${this.escAttr(node.modId || "")}" style="cursor:pointer">`;
for (let i = 0; i < ringSpaces.length; i++) {
const sp = ringSpaces[i];
const angle = startAngle + i * angleStep;
const bx = cx + ringR * Math.cos(angle);
const by = cy + ringR * Math.sin(angle);
// Circle
svg += `<circle cx="${node.x}" cy="${node.y}" r="${node.r}"
fill="${node.color}" fill-opacity="${hoverOpacity}"
stroke="${node.color}" stroke-width="${strokeW}"/>`;
// Scale bubble size by doc count (min 60%, max 100%)
const sizeScale = 0.6 + 0.4 * (sp.docCount / maxDocCount);
const r = bubbleR * sizeScale;
const isSelected = this.selected === sp.slug;
const isHovered = this.hoveredSlug === sp.slug;
const strokeW = isSelected ? 3 : (isHovered ? 2.5 : 1.5);
const fillOpacity = isSelected ? 0.25 : (isHovered ? 0.18 : 0.1);
// Bubble circle
svg += `<g class="dc-bubble" data-slug="${this.escAttr(sp.slug)}" style="cursor:pointer">`;
if (isSelected) {
svg += `<circle cx="${bx}" cy="${by}" r="${r + 5}" fill="none"
stroke="${cfg.color}" stroke-width="2" stroke-dasharray="4 3" opacity="0.6">
<animate attributeName="stroke-dashoffset" from="0" to="-14" dur="1s" repeatCount="indefinite"/>
</circle>`;
}
svg += `<circle cx="${bx}" cy="${by}" r="${r}" fill="${cfg.color}" fill-opacity="${fillOpacity}"
stroke="${cfg.color}" stroke-width="${strokeW}"/>`;
// Label
const label = mobile ? sp.name.slice(0, 6) : (sp.name.length > 12 ? sp.name.slice(0, 11) + "…" : sp.name);
svg += `<text x="${bx}" y="${by - 2}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-primary)" font-size="${mobile ? 8 : 10}" font-weight="500"
// Labels
if (node.type === "space") {
const label = mobile ? node.label.slice(0, 8) : (node.label.length > 14 ? node.label.slice(0, 13) + "…" : node.label);
svg += `<text x="${node.x}" y="${node.y + node.r + 14}" text-anchor="middle"
fill="var(--rs-text-primary)" font-size="${mobile ? 9 : 11}" font-weight="600"
pointer-events="none">${this.esc(label)}</text>`;
} else if (node.type === "module") {
svg += `<text x="${node.x}" y="${node.y + 1}" text-anchor="middle" dominant-baseline="central"
fill="var(--rs-text-primary)" font-size="${mobile ? 10 : 12}"
pointer-events="none">${node.icon}</text>`;
if (!mobile) {
svg += `<text x="${node.x}" y="${node.y + node.r + 12}" text-anchor="middle"
fill="var(--rs-text-secondary)" font-size="9" pointer-events="none">${this.esc(node.label)}</text>`;
}
} else {
// Doc — show label on hover via title
}
// Doc count badge
svg += `<text x="${bx}" y="${by + (mobile ? 9 : 11)}" text-anchor="middle"
fill="${cfg.color}" font-size="${mobile ? 7 : 9}" font-weight="600"
pointer-events="none">${sp.docCount}</text>`;
// Tooltip
const tooltipText = node.type === "space"
? `${node.label} (${this.nodes.filter(n => n.space === node.space && n.type === "doc").length} docs)`
: node.type === "module"
? `${node.label} — click to open in new tab`
: `${node.label} — click to open in new tab`;
svg += `<title>${this.esc(tooltipText)}</title>`;
// Tooltip (title element)
svg += `<title>${this.esc(sp.name)}${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} (${sp.visibility})</title>`;
svg += `</g>`;
}
}
// Center label
const totalDocs = this.spaces.reduce((s, sp) => s + sp.docCount, 0);
svg += `<text x="${cx}" y="${cy - 6}" text-anchor="middle" fill="var(--rs-text-primary)"
font-size="${mobile ? 16 : 20}" font-weight="700">${totalDocs}</text>`;
svg += `<text x="${cx}" y="${cy + 12}" text-anchor="middle" fill="var(--rs-text-muted)"
font-size="${mobile ? 9 : 11}">total documents</text>`;
svg += `</svg>`;
return svg;
}
private renderDetailPanel(sp: SpaceBubble): string {
const ring = (sp.visibility as Ring) || "private";
const cfg = RING_CONFIG[ring];
const visBadgeColor = cfg.color;
private renderLegend(): string {
// Collect unique modules present
const mods = new Map<string, { name: string; icon: string }>();
for (const doc of this.docs) {
if (!mods.has(doc.modId)) mods.set(doc.modId, { name: doc.modName, icon: doc.modIcon });
}
// Collect unique visibility levels
const visLevels = new Set<string>();
for (const doc of this.docs) visLevels.add(doc.visibility);
return `
<div class="dc-panel">
<div class="dc-panel__header">
<span class="dc-panel__name">${this.esc(sp.name)}</span>
<span class="dc-panel__vis" style="color:${visBadgeColor};border-color:${visBadgeColor}">${sp.visibility}</span>
<span class="dc-panel__count">${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""}</span>
</div>
${sp.modules.length === 0
? `<div class="dc-panel__empty">No documents in this space</div>`
: `<div class="dc-panel__modules">
${sp.modules.map(m => `
<div class="dc-panel__mod" data-nav-space="${this.escAttr(sp.slug)}" data-nav-mod="${this.escAttr(m.id)}">
<span class="dc-panel__mod-icon">${m.icon}</span>
<span class="dc-panel__mod-name">${this.esc(m.name)}</span>
<span class="dc-panel__mod-count">${m.docCount}</span>
</div>
<div class="dc-legend">
<div class="dc-legend__section">
${[...visLevels].map(v => `
<span class="dc-legend__item">
<span class="dc-legend__dot" style="background:${VIS_COLORS[v] || VIS_COLORS.private}"></span>
${this.esc(v)}
</span>
`).join("")}
</div>`
}
</div>
<div class="dc-legend__section">
${[...mods.entries()].map(([, m]) => `
<span class="dc-legend__item">${m.icon} ${this.esc(m.name)}</span>
`).join("")}
</div>
<div class="dc-legend__hint">Click any node to open in new tab</div>
</div>
`;
}
// ── Events ──
private attachEvents() {
// Bubble click — toggle selection
for (const g of this.shadow.querySelectorAll<SVGGElement>(".dc-bubble")) {
const slug = g.dataset.slug!;
for (const g of this.shadow.querySelectorAll<SVGGElement>(".dc-node")) {
const nodeId = g.dataset.id!;
const space = g.dataset.space || "";
const modId = g.dataset.mod || "";
g.addEventListener("click", () => {
this.selected = this.selected === slug ? null : slug;
this.render();
if (!space) return;
const modPath = modId
? (modId.startsWith("r") ? modId : `r${modId}`)
: "rspace";
window.open(rspaceNavUrl(space, modPath), "_blank");
});
g.addEventListener("mouseenter", () => {
this.hoveredSlug = slug;
// Update stroke without full re-render for perf
const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement;
if (circle) circle.setAttribute("stroke-width", "2.5");
this.hoveredId = nodeId;
const circle = g.querySelector("circle") as SVGCircleElement;
if (circle) {
circle.setAttribute("stroke-width", "2.5");
circle.setAttribute("fill-opacity", "0.8");
}
// Highlight connected edges
const connectedEdges = this.edges.filter(e => e.from === nodeId || e.to === nodeId);
for (const edge of connectedEdges) {
const lines = this.shadow.querySelectorAll<SVGLineElement>("line");
for (const line of lines) {
const fromNode = this.nodes.find(n => n.id === edge.from);
const toNode = this.nodes.find(n => n.id === edge.to);
if (!fromNode || !toNode) continue;
if (Math.abs(parseFloat(line.getAttribute("x1")!) - fromNode.x) < 1 &&
Math.abs(parseFloat(line.getAttribute("y1")!) - fromNode.y) < 1) {
line.setAttribute("opacity", "0.6");
line.setAttribute("stroke-width", "2");
}
}
}
});
g.addEventListener("mouseleave", () => {
this.hoveredSlug = null;
const circle = g.querySelector("circle:not([stroke-dasharray='4 3'])") as SVGCircleElement;
if (circle && this.selected !== slug) circle.setAttribute("stroke-width", "1.5");
this.hoveredId = null;
const circle = g.querySelector("circle") as SVGCircleElement;
if (circle) {
const node = this.nodes.find(n => n.id === nodeId);
if (node) {
circle.setAttribute("stroke-width", node.type === "space" ? "2" : "1.2");
const fo = node.type === "doc" ? "0.5" : (node.type === "module" ? "0.3" : "0.15");
circle.setAttribute("fill-opacity", fo);
}
}
// Reset edges
for (const line of this.shadow.querySelectorAll<SVGLineElement>("line")) {
line.setAttribute("opacity", "0.2");
line.setAttribute("stroke-width", "1");
}
});
}
}
// Module row click — navigate
for (const row of this.shadow.querySelectorAll<HTMLElement>(".dc-panel__mod")) {
row.addEventListener("click", () => {
const spaceSlug = row.dataset.navSpace!;
const modId = row.dataset.navMod!;
const modPath = modId.startsWith("r") ? modId : `r${modId}`;
window.location.href = `/${spaceSlug}/${modPath}`;
});
}
}
// ── Helpers ──
private esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
@ -381,53 +501,38 @@ class FolkDataCloud extends HTMLElement {
border-radius: 8px; color: #eab308; font-size: 0.85rem; margin-bottom: 1rem;
}
.dc-empty {
text-align: center; padding: 3rem 1rem;
color: var(--rs-text-muted); font-size: 0.9rem;
}
.dc-svg { display: block; margin: 0 auto; max-width: 100%; height: auto; }
/* Detail panel */
.dc-panel {
width: 100%; max-width: 500px; margin-top: 1rem;
background: var(--rs-bg-surface, #1e293b); border: 1px solid var(--rs-border);
border-radius: 10px; padding: 1rem; animation: dc-slideIn 0.2s ease-out;
/* Legend */
.dc-legend {
width: 100%; display: flex; flex-wrap: wrap; gap: 0.75rem;
justify-content: center; align-items: center;
padding: 0.75rem; margin-top: 0.5rem;
border-top: 1px solid var(--rs-border);
}
@keyframes dc-slideIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
.dc-legend__section {
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
}
.dc-panel__header {
display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.75rem;
padding-bottom: 0.5rem; border-bottom: 1px solid var(--rs-border);
.dc-legend__item {
display: flex; align-items: center; gap: 0.3rem;
font-size: 0.75rem; color: var(--rs-text-secondary);
}
.dc-panel__name { font-weight: 600; font-size: 1rem; flex: 1; }
.dc-panel__vis {
font-size: 0.7rem; padding: 0.15rem 0.5rem; border-radius: 10px;
border: 1px solid; text-transform: uppercase; font-weight: 600; letter-spacing: 0.03em;
.dc-legend__dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
}
.dc-panel__count { font-size: 0.8rem; color: var(--rs-text-muted); }
.dc-panel__empty {
text-align: center; padding: 1rem; color: var(--rs-text-muted); font-size: 0.85rem;
}
.dc-panel__modules { display: flex; flex-direction: column; gap: 0.25rem; }
.dc-panel__mod {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 0.6rem; border-radius: 6px; cursor: pointer;
transition: background 0.1s;
}
.dc-panel__mod:hover { background: rgba(34, 211, 238, 0.08); }
.dc-panel__mod-icon { font-size: 1rem; flex-shrink: 0; }
.dc-panel__mod-name { flex: 1; font-size: 0.85rem; }
.dc-panel__mod-count {
padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem;
background: var(--rs-bg-primary, #0f172a); border: 1px solid var(--rs-border);
color: var(--rs-text-muted);
.dc-legend__hint {
width: 100%; text-align: center;
font-size: 0.7rem; color: var(--rs-text-muted); margin-top: 0.25rem;
}
@media (max-width: 500px) {
.dc-panel { max-height: 50vh; overflow-y: auto; }
.dc-panel__name { font-size: 0.9rem; }
.dc-legend { gap: 0.4rem; padding: 0.5rem; }
.dc-legend__item { font-size: 0.65rem; }
}
`;
}

39
modules/rdocs/applets.ts Normal file
View File

@ -0,0 +1,39 @@
/**
* rDocs applet definitions Doc Summary.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const docSummary: AppletDefinition = {
id: "doc-summary",
label: "Doc Summary",
icon: "📄",
accentColor: "#d97706",
ports: [
{ name: "doc-in", type: "json", direction: "input" },
{ name: "text-out", type: "text", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const title = (snapshot.title as string) || "Untitled";
const wordCount = (snapshot.wordCount as number) || 0;
const lastEdit = (snapshot.lastEdit as string) || "";
const preview = (snapshot.preview as string) || "No content";
return `
<div>
<div style="font-size:13px;font-weight:600;margin-bottom:4px">${title}</div>
<div style="font-size:10px;color:#94a3b8;margin-bottom:6px">${wordCount} words${lastEdit ? ` · ${lastEdit}` : ""}</div>
<div style="font-size:11px;color:#cbd5e1;line-height:1.4;max-height:60px;overflow:hidden">${preview}</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "doc-in" && value && typeof value === "object") {
const doc = value as Record<string, unknown>;
ctx.emitOutput("text-out", (doc.content as string) || (doc.preview as string) || "");
}
},
};
export const docsApplets: AppletDefinition[] = [docSummary];

View File

@ -0,0 +1,75 @@
/**
* rExchange applet definitions Rate Card + Trade Status.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const rateCard: AppletDefinition = {
id: "rate-card",
label: "Rate Card",
icon: "💱",
accentColor: "#047857",
ports: [
{ name: "pair-in", type: "string", direction: "input" },
{ name: "rate-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const pair = (snapshot.pair as string) || "—/—";
const rate = (snapshot.rate as number) || 0;
const change24h = (snapshot.change24h as number) || 0;
const changeColor = change24h >= 0 ? "#22c55e" : "#ef4444";
const arrow = change24h >= 0 ? "▲" : "▼";
return `
<div style="text-align:center">
<div style="font-size:11px;color:#94a3b8;margin-bottom:4px">${pair}</div>
<div style="font-size:22px;font-weight:700;color:#e2e8f0">${rate.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 })}</div>
<div style="font-size:11px;font-weight:500;color:${changeColor};margin-top:4px">${arrow} ${Math.abs(change24h).toFixed(2)}%</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "pair-in" && typeof value === "string") {
ctx.emitOutput("rate-out", 0);
}
},
};
const tradeStatus: AppletDefinition = {
id: "trade-status",
label: "Trade Status",
icon: "🔄",
accentColor: "#047857",
ports: [
{ name: "trade-in", type: "json", direction: "input" },
{ name: "status-out", type: "string", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const type = (snapshot.type as string) || "trade";
const amount = (snapshot.amount as number) || 0;
const asset = (snapshot.asset as string) || "—";
const status = (snapshot.status as string) || "pending";
const statusColor = status === "filled" ? "#22c55e" : status === "cancelled" ? "#ef4444" : "#f59e0b";
return `
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:6px">
<span style="font-size:11px;color:#94a3b8;text-transform:uppercase">${type}</span>
<span style="font-size:10px;font-weight:500;color:${statusColor};text-transform:uppercase">${status}</span>
</div>
<div style="font-size:18px;font-weight:700;color:#e2e8f0;margin-bottom:2px">${amount.toLocaleString()}</div>
<div style="font-size:11px;color:#94a3b8">${asset}</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "trade-in" && value && typeof value === "object") {
const trade = value as Record<string, unknown>;
ctx.emitOutput("status-out", (trade.status as string) || "pending");
}
},
};
export const exchangeApplets: AppletDefinition[] = [rateCard, tradeStatus];

43
modules/rinbox/applets.ts Normal file
View File

@ -0,0 +1,43 @@
/**
* rInbox applet definitions Thread Feed.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const threadFeed: AppletDefinition = {
id: "thread-feed",
label: "Thread Feed",
icon: "📬",
accentColor: "#0e7490",
ports: [
{ name: "mailbox-in", type: "string", direction: "input" },
{ name: "count-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const total = (snapshot.total as number) || 0;
const unread = (snapshot.unread as number) || 0;
const latest = (snapshot.latestSubject as string) || "No messages";
return `
<div>
<div style="display:flex;justify-content:space-between;margin-bottom:8px">
<span style="font-size:11px;color:#94a3b8">Total</span>
<span style="font-size:13px;font-weight:600">${total}</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:8px">
<span style="font-size:11px;color:#94a3b8">Unread</span>
<span style="font-size:13px;font-weight:700;color:${unread > 0 ? "#ef4444" : "#22c55e"}">${unread}</span>
</div>
<div style="font-size:10px;color:#94a3b8;border-top:1px solid #334155;padding-top:6px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${latest}</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "mailbox-in" && typeof value === "string") {
ctx.emitOutput("count-out", 0);
}
},
};
export const inboxApplets: AppletDefinition[] = [threadFeed];

83
modules/rmaps/applets.ts Normal file
View File

@ -0,0 +1,83 @@
/**
* rMaps applet definitions Location Pin + Route Summary.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const locationPin: AppletDefinition = {
id: "location-pin",
label: "Location Pin",
icon: "📍",
accentColor: "#1d4ed8",
ports: [
{ name: "location-in", type: "json", direction: "input" },
{ name: "coords-out", type: "json", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const label = (snapshot.label as string) || "Location";
const lat = (snapshot.lat as number) || 0;
const lng = (snapshot.lng as number) || 0;
const hasCoords = lat !== 0 || lng !== 0;
return `
<div style="text-align:center">
<div style="font-size:28px;margin-bottom:4px">📍</div>
<div style="font-size:13px;font-weight:600;margin-bottom:4px">${label}</div>
${hasCoords
? `<div style="font-size:10px;color:#94a3b8;font-family:monospace">${lat.toFixed(4)}, ${lng.toFixed(4)}</div>`
: `<div style="font-size:10px;color:#94a3b8;font-style:italic">No coordinates</div>`
}
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "location-in" && value && typeof value === "object") {
const loc = value as Record<string, unknown>;
ctx.emitOutput("coords-out", { lat: loc.lat, lng: loc.lng });
}
},
};
const routeSummary: AppletDefinition = {
id: "route-summary",
label: "Route Summary",
icon: "🗺️",
accentColor: "#1d4ed8",
ports: [
{ name: "route-in", type: "json", direction: "input" },
{ name: "distance-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const from = (snapshot.from as string) || "Start";
const to = (snapshot.to as string) || "End";
const distance = (snapshot.distance as string) || "—";
const duration = (snapshot.duration as string) || "—";
return `
<div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
<span style="font-size:11px;color:#94a3b8">From</span>
<span style="font-size:12px;font-weight:600;color:#e2e8f0">${from}</span>
</div>
<div style="display:flex;align-items:center;gap:6px;margin-bottom:8px">
<span style="font-size:11px;color:#94a3b8">To</span>
<span style="font-size:12px;font-weight:600;color:#e2e8f0">${to}</span>
</div>
<div style="display:flex;justify-content:space-between;border-top:1px solid #334155;padding-top:6px">
<span style="font-size:11px;color:#60a5fa">${distance}</span>
<span style="font-size:11px;color:#94a3b8">${duration}</span>
</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "route-in" && value && typeof value === "object") {
const route = value as Record<string, unknown>;
ctx.emitOutput("distance-out", Number(route.distanceKm) || 0);
}
},
};
export const mapsApplets: AppletDefinition[] = [locationPin, routeSummary];

View File

@ -0,0 +1,40 @@
/**
* rNetwork applet definitions Contact Card.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const contactCard: AppletDefinition = {
id: "contact-card",
label: "Contact Card",
icon: "👤",
accentColor: "#4f46e5",
ports: [
{ name: "did-in", type: "string", direction: "input" },
{ name: "contact-out", type: "json", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const name = (snapshot.name as string) || "Unknown";
const did = (snapshot.did as string) || "";
const shortDid = did ? `${did.slice(0, 16)}` : "No DID";
const trustScore = (snapshot.trustScore as number) || 0;
const trustColor = trustScore >= 0.7 ? "#22c55e" : trustScore >= 0.4 ? "#f59e0b" : "#94a3b8";
return `
<div style="text-align:center">
<div style="font-size:28px;margin-bottom:4px">👤</div>
<div style="font-size:13px;font-weight:600;margin-bottom:2px">${name}</div>
<div style="font-size:9px;color:#94a3b8;font-family:monospace;margin-bottom:6px">${shortDid}</div>
<div style="font-size:11px;color:${trustColor}">Trust: ${Math.round(trustScore * 100)}%</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "did-in" && typeof value === "string") {
ctx.emitOutput("contact-out", { did: value, name: "", trustScore: 0 });
}
},
};
export const networkApplets: AppletDefinition[] = [contactCard];

40
modules/rnotes/applets.ts Normal file
View File

@ -0,0 +1,40 @@
/**
* rNotes applet definitions Vault Note.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const vaultNote: AppletDefinition = {
id: "vault-note",
label: "Vault Note",
icon: "🗒️",
accentColor: "#065f46",
ports: [
{ name: "note-in", type: "json", direction: "input" },
{ name: "content-out", type: "text", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const title = (snapshot.title as string) || "Note";
const vault = (snapshot.vault as string) || "";
const tags = (snapshot.tags as string[]) || [];
const preview = (snapshot.preview as string) || "Empty note";
return `
<div>
<div style="font-size:13px;font-weight:600;margin-bottom:2px">${title}</div>
${vault ? `<div style="font-size:10px;color:#94a3b8;margin-bottom:4px">📁 ${vault}</div>` : ""}
${tags.length > 0 ? `<div style="font-size:9px;color:#065f46;margin-bottom:4px">${tags.map(t => `#${t}`).join(" ")}</div>` : ""}
<div style="font-size:11px;color:#cbd5e1;line-height:1.4;max-height:50px;overflow:hidden">${preview}</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "note-in" && value && typeof value === "object") {
const note = value as Record<string, unknown>;
ctx.emitOutput("content-out", (note.content as string) || "");
}
},
};
export const notesApplets: AppletDefinition[] = [vaultNote];

View File

@ -0,0 +1,40 @@
/**
* rPhotos applet definitions Album Card.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const albumCard: AppletDefinition = {
id: "album-card",
label: "Album Card",
icon: "🖼️",
accentColor: "#be185d",
ports: [
{ name: "album-in", type: "string", direction: "input" },
{ name: "image-out", type: "image-url", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const name = (snapshot.name as string) || "Album";
const count = (snapshot.count as number) || 0;
const thumb = (snapshot.thumbnail as string) || "";
return `
<div style="text-align:center">
${thumb
? `<div style="width:100%;height:80px;border-radius:6px;overflow:hidden;margin-bottom:6px"><img src="${thumb}" style="width:100%;height:100%;object-fit:cover" alt=""></div>`
: `<div style="font-size:32px;margin-bottom:6px">🖼️</div>`
}
<div style="font-size:13px;font-weight:600">${name}</div>
<div style="font-size:10px;color:#94a3b8;margin-top:2px">${count} photos</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "album-in" && typeof value === "string") {
ctx.emitOutput("image-out", "");
}
},
};
export const photosApplets: AppletDefinition[] = [albumCard];

View File

@ -0,0 +1,45 @@
/**
* rSocials applet definitions Post Draft.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const postDraft: AppletDefinition = {
id: "post-draft",
label: "Post Draft",
icon: "✏️",
accentColor: "#db2777",
ports: [
{ name: "content-in", type: "text", direction: "input" },
{ name: "post-out", type: "json", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const platform = (snapshot.platform as string) || "social";
const content = (snapshot.content as string) || "";
const charCount = content.length;
const maxChars = (snapshot.maxChars as number) || 280;
const pct = Math.min(100, Math.round((charCount / maxChars) * 100));
const countColor = pct > 90 ? "#ef4444" : pct > 70 ? "#f59e0b" : "#94a3b8";
return `
<div>
<div style="font-size:11px;color:#94a3b8;margin-bottom:6px;text-transform:uppercase;letter-spacing:0.5px">${platform}</div>
<div style="font-size:11px;color:#cbd5e1;line-height:1.4;max-height:70px;overflow:hidden">${content || "<em>Empty draft</em>"}</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px">
<div style="flex:1;background:#334155;border-radius:3px;height:4px;overflow:hidden;margin-right:8px">
<div style="background:#db2777;width:${pct}%;height:100%;border-radius:3px"></div>
</div>
<span style="font-size:9px;color:${countColor}">${charCount}/${maxChars}</span>
</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "content-in" && typeof value === "string") {
ctx.emitOutput("post-out", { content: value, platform: "social", timestamp: Date.now() });
}
},
};
export const socialsApplets: AppletDefinition[] = [postDraft];

70
modules/rtasks/applets.ts Normal file
View File

@ -0,0 +1,70 @@
/**
* rTasks applet definitions Task Counter + Due Today.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const taskCounter: AppletDefinition = {
id: "task-counter",
label: "Task Counter",
icon: "📋",
accentColor: "#0f766e",
ports: [
{ name: "board-in", type: "json", direction: "input" },
{ name: "count-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const total = (snapshot.total as number) || 0;
const done = (snapshot.done as number) || 0;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
return `
<div style="text-align:center">
<div style="font-size:28px;font-weight:700;color:#e2e8f0">${done}/${total}</div>
<div style="font-size:10px;color:#94a3b8;margin:4px 0 8px">tasks complete</div>
<div style="background:#334155;border-radius:3px;height:6px;overflow:hidden">
<div style="background:#0f766e;width:${pct}%;height:100%;border-radius:3px;transition:width 0.3s"></div>
</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "board-in" && value && typeof value === "object") {
const board = value as Record<string, unknown>;
ctx.emitOutput("count-out", Number(board.total) || 0);
}
},
};
const dueToday: AppletDefinition = {
id: "due-today",
label: "Due Today",
icon: "⏰",
accentColor: "#0f766e",
ports: [
{ name: "board-in", type: "json", direction: "input" },
{ name: "tasks-out", type: "json", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const count = (snapshot.dueCount as number) || 0;
const urgent = (snapshot.urgentCount as number) || 0;
const urgColor = urgent > 0 ? "#ef4444" : "#22c55e";
return `
<div style="text-align:center">
<div style="font-size:32px;font-weight:700;color:#e2e8f0">${count}</div>
<div style="font-size:10px;color:#94a3b8;margin:4px 0">due today</div>
<div style="font-size:11px;font-weight:500;color:${urgColor}">${urgent} urgent</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "board-in" && value && typeof value === "object") {
ctx.emitOutput("tasks-out", value);
}
},
};
export const tasksApplets: AppletDefinition[] = [taskCounter, dueToday];

42
modules/rtime/applets.ts Normal file
View File

@ -0,0 +1,42 @@
/**
* rTime applet definitions Commitment Meter.
*/
import type { AppletDefinition, AppletLiveData } from "../../shared/applet-types";
const commitmentMeter: AppletDefinition = {
id: "commitment-meter",
label: "Commitment Meter",
icon: "⏳",
accentColor: "#7c3aed",
ports: [
{ name: "pool-in", type: "json", direction: "input" },
{ name: "committed-out", type: "number", direction: "output" },
],
renderCompact(data: AppletLiveData): string {
const { snapshot } = data;
const committed = (snapshot.committed as number) || 0;
const capacity = (snapshot.capacity as number) || 1;
const pct = Math.min(100, Math.round((committed / capacity) * 100));
const label = (snapshot.poolName as string) || "Pool";
return `
<div style="text-align:center">
<div style="font-size:13px;font-weight:600;margin-bottom:6px">${label}</div>
<div style="font-size:24px;font-weight:700;color:#e2e8f0">${pct}%</div>
<div style="font-size:10px;color:#94a3b8;margin:4px 0 8px">${committed}/${capacity} hrs</div>
<div style="background:#334155;border-radius:3px;height:6px;overflow:hidden">
<div style="background:#7c3aed;width:${pct}%;height:100%;border-radius:3px;transition:width 0.3s"></div>
</div>
</div>
`;
},
onInputReceived(portName, value, ctx) {
if (portName === "pool-in" && value && typeof value === "object") {
const pool = value as Record<string, unknown>;
ctx.emitOutput("committed-out", Number(pool.committed) || 0);
}
},
};
export const timeApplets: AppletDefinition[] = [commitmentMeter];

View File

@ -7,7 +7,7 @@
*/
import type { ModuleInfo } from "../shared/module";
import { escapeHtml, escapeAttr, MODULE_LANDING_CSS, RICH_LANDING_CSS, versionAssetUrls, getSpaceShellMeta } from "./shell";
import { escapeHtml, escapeAttr, brandedAppName, MODULE_LANDING_CSS, RICH_LANDING_CSS, versionAssetUrls, getSpaceShellMeta } from "./shell";
/** Category → module IDs mapping for the tabbed showcase. */
const CATEGORY_GROUPS: Record<string, { label: string; icon: string; ids: string[] }> = {
@ -40,7 +40,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
return `<a href="/${escapeAttr(m.id)}" class="lp-app-card">
<span class="lp-app-card__icon">${m.icon}</span>
<div class="lp-app-card__body">
<span class="lp-app-card__name">${escapeHtml(m.name)}</span>
<span class="lp-app-card__name">${brandedAppName(m.name)}</span>
${m.standaloneDomain ? `<span class="lp-app-card__domain">${escapeHtml(m.standaloneDomain)}</span>` : ""}
<span class="lp-app-card__desc">${escapeHtml(m.description)}</span>
</div>
@ -131,7 +131,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
<div class="lp-hero__orb lp-hero__orb--indigo" aria-hidden="true"></div>
<div class="lp-hero__grid" aria-hidden="true"></div>
<div class="lp-hero__content">
<span class="rl-tagline">Reclaim (you)<span style="color:#f97316">r</span><span style="color:#14b8a6">Space</span> on the internet</span>
<span class="rl-tagline">Reclaim (you)<span style="color:#dc8300">r</span><span style="color:#35b9b9">Space</span> on the internet</span>
<h1 class="lp-wordmark"><span class="lp-wordmark__r">r</span><span class="lp-wordmark__space">Space</span></h1>
<p class="lp-hero__tagline">
Coordinate around what you care about &mdash; without stitching together a dozen corporate apps.
@ -293,7 +293,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
<!-- 7. Final CTA -->
<section class="lp-final-cta">
<div class="rl-container" style="max-width:720px;text-align:center">
<h2 class="lp-final-cta__heading">Reclaim (you)<span style="color:#f97316;-webkit-text-fill-color:#f97316">r</span><span style="color:#14b8a6;-webkit-text-fill-color:#14b8a6">Space</span>.</h2>
<h2 class="lp-final-cta__heading">Reclaim (you)<span style="color:#dc8300;-webkit-text-fill-color:#dc8300">r</span><span style="color:#35b9b9;-webkit-text-fill-color:#35b9b9">Space</span>.</h2>
<p class="rl-subtext" style="font-size:1.15rem;line-height:1.7;text-align:center">
No algorithms deciding what you see. No ads. No data harvesting.
Just one place for your group to plan, decide, fund, and build together.
@ -382,7 +382,7 @@ export function renderSpaceDashboard(space: string, modules: ModuleInfo[]): stri
<a href="/${escapeAttr(m.id)}" class="sd-card" data-module="${escapeAttr(m.id)}">
<div class="sd-card__icon">${m.icon}</div>
<div class="sd-card__body">
<h3 class="sd-card__name">${escapeHtml(m.name)}</h3>
<h3 class="sd-card__name">${brandedAppName(m.name)}</h3>
<p class="sd-card__desc">${escapeHtml(m.description)}</p>
</div>
</a>`;
@ -609,8 +609,8 @@ body {
}
.lp-wordmark__r {
font-weight: 400;
color: #f97316;
-webkit-text-fill-color: #f97316;
color: #dc8300;
-webkit-text-fill-color: #dc8300;
}
.lp-wordmark__space {
background: var(--rs-gradient-brand);

View File

@ -2088,7 +2088,7 @@ function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: Module
const minimizeBtn = `<button class="rapp-minimize-btn" id="header-minimize-btn" title="Minimize headers"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"/></svg></button>`;
const pills = [
`<a class="rapp-nav-pill" href="${base}" data-subnav-root>${escapeHtml(mod.name)}</a>`,
`<a class="rapp-nav-pill" href="${base}" data-subnav-root>${brandedAppName(mod.name)}</a>`,
...items.map(it =>
`<a class="rapp-nav-pill" href="${base}/${escapeAttr(it.path)}">${it.icon ? escapeHtml(it.icon) + ' ' : ''}${escapeHtml(it.label)}</a>`
),
@ -2232,7 +2232,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string {
: `<div class="ml-hero">
<div class="ml-container">
<span class="ml-icon">${mod.icon}</span>
<h1 class="ml-name">${escapeHtml(mod.name)}</h1>
<h1 class="ml-name">${brandedAppName(mod.name)}</h1>
<p class="ml-desc">${escapeHtml(mod.description)}</p>
<div class="ml-ctas">
<a href="${demoUrl}" class="ml-cta-primary" id="ml-primary">Try Demo</a>
@ -2580,7 +2580,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
</div>
${featuresGrid}
<div class="rl-back">
<a href="/${escapeAttr(mod.id)}"> Back to ${escapeHtml(mod.name)}</a>
<a href="/${escapeAttr(mod.id)}"> Back to ${brandedAppName(mod.name)}</a>
</div>`;
return versionAssetUrls(`<!DOCTYPE html>
@ -2736,7 +2736,7 @@ export function renderOnboarding(opts: OnboardingOptions): string {
<div class="onboarding__card">
<div class="onboarding__glow"></div>
<span class="onboarding__icon">${moduleIcon}</span>
<h1 class="onboarding__title">${escapeHtml(moduleName)}</h1>
<h1 class="onboarding__title">${brandedAppName(moduleName)}</h1>
<p class="onboarding__desc">${escapeHtml(moduleDescription)}</p>
<p class="onboarding__hint">This app hasn't been used in <strong>${escapeHtml(spaceSlug)}</strong> yet. Load sample data to explore, or jump into the public demo.</p>
<div class="onboarding__ctas">
@ -2925,6 +2925,14 @@ export function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
/** Render rApp name with orange "r" prefix for visible HTML (not <title> tags). */
export function brandedAppName(name: string): string {
if (name.startsWith("r") && name.length > 1 && name[1] === name[1].toUpperCase()) {
return `<span style="color:#dc8300">r</span>${escapeHtml(name.slice(1))}`;
}
return escapeHtml(name);
}
export function escapeAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}

View File

@ -19,16 +19,16 @@ export async function sendWelcomeEmail(email: string, username: string): Promise
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 24px;">
<div style="background: #1e293b; border-radius: 12px; padding: 28px; color: #e2e8f0;">
<h1 style="margin: 0 0 4px; font-size: 22px; color: #f1f5f9;">
Welcome to <span style="color: #f97316;">r</span><span style="color: #14b8a6;">Space</span>, ${escapeHtml(displayName)}!
Welcome to <span style="color: #dc8300;">r</span><span style="color: #35b9b9;">Space</span>, ${escapeHtml(displayName)}!
</h1>
<p style="margin: 0 0 24px; font-size: 15px; color: #94a3b8;">
Reclaim (you)<span style="color: #f97316;">r</span><span style="color: #14b8a6;">Space</span> on the internet &mdash; one place for your group to coordinate around what you care about.
Reclaim (you)<span style="color: #dc8300;">r</span><span style="color: #35b9b9;">Space</span> on the internet &mdash; one place for your group to coordinate around what you care about.
</p>
<div style="background: #0f172a; border-radius: 8px; padding: 16px; margin-bottom: 20px;">
<p style="margin: 0 0 12px; font-size: 14px; color: #e2e8f0; line-height: 1.6;">
Instead of scattering your group across Slack, Google Docs, Trello, Zoom, Splitwise, and a dozen other apps &mdash;
<strong style="color: #14b8a6;">(you)rSpace puts it all in one shared workspace</strong> that your group actually owns.
<strong style="color: #35b9b9;">(you)rSpace puts it all in one shared workspace</strong> that your group actually owns.
</p>
<p style="margin: 0; font-size: 14px; color: #94a3b8; line-height: 1.6;">
Plan together. Decide together. Fund together. Build together. No corporate middlemen.
@ -72,7 +72,7 @@ export async function sendWelcomeEmail(email: string, username: string): Promise
<div style="text-align: center;">
<a href="${demoUrl}" style="display: inline-block; padding: 10px 22px; background: linear-gradient(135deg, #14b8a6, #0d9488); color: white; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; margin-right: 8px;">Explore the Demo Space</a>
<a href="${createUrl}" style="display: inline-block; padding: 10px 22px; background: transparent; color: #14b8a6; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; border: 1px solid #14b8a6;">Create Your Space</a>
<a href="${createUrl}" style="display: inline-block; padding: 10px 22px; background: transparent; color: #35b9b9; text-decoration: none; border-radius: 6px; font-size: 14px; font-weight: 600; border: 1px solid #14b8a6;">Create Your Space</a>
</div>
</div>
<p style="margin: 14px 0 0; font-size: 11px; color: #64748b; text-align: center;">

View File

@ -126,6 +126,14 @@ const MODULE_CATEGORIES: Record<string, string> = {
rstack: "Platform",
};
/** Color the "r" prefix orange in rApp names. */
function brandR(name: string): string {
if (name.startsWith("r") && name.length > 1 && name[1] === name[1].toUpperCase()) {
return `<span style="color:#dc8300">r</span>${name.slice(1)}`;
}
return name;
}
const CATEGORY_ORDER = [
"Create",
"Communicate",
@ -392,7 +400,7 @@ export class RStackAppSwitcher extends HTMLElement {
${badgeHtml}
<div class="item-text">
<span class="item-name-row">
<span class="item-name">${m.name}</span>
<span class="item-name">${brandR(m.name)}</span>
${scopeBadge}
</span>
<span class="item-desc">${m.description}</span>
@ -411,10 +419,10 @@ export class RStackAppSwitcher extends HTMLElement {
const badgeInfo = currentMod ? MODULE_BADGES[currentMod.id] : null;
const triggerContent = badgeInfo
? `<span class="trigger-badge" style="background:${badgeInfo.color}">${badgeInfo.badge}</span> ${currentMod!.name}`
? `<span class="trigger-badge" style="background:${badgeInfo.color}">${badgeInfo.badge}</span> ${brandR(currentMod!.name)}`
: currentMod
? `${currentMod.icon} ${currentMod.name}`
: `<span class="trigger-badge rstack-gradient">r✨</span> rSpace`;
? `${currentMod.icon} ${brandR(currentMod.name)}`
: `<span class="trigger-badge rstack-gradient">r✨</span> <span style="color:#dc8300">r</span>Space`;
this.#shadow.innerHTML = `
<style>${STYLES}</style>

View File

@ -512,8 +512,13 @@ export class RStackIdentity extends HTMLElement {
const session = getSession();
if (!session?.accessToken) return;
// Don't nag if dismissed within the last 7 days
const NUDGE_KEY = "eid_device_nudge_dismissed";
const DONE_KEY = "eid_device_nudge_done";
// Permanently suppress if multi-device already confirmed
if (localStorage.getItem(DONE_KEY) === "1") return;
// Don't nag if dismissed within the last 7 days
const dismissed = localStorage.getItem(NUDGE_KEY);
if (dismissed && Date.now() - parseInt(dismissed, 10) < 7 * 24 * 60 * 60 * 1000) return;
@ -527,7 +532,11 @@ export class RStackIdentity extends HTMLElement {
});
if (!res.ok) return;
const status = await res.json();
if (status.multiDevice) return; // already has 2+ devices
if (status.multiDevice) {
// Permanently mark as done — never nudge again
localStorage.setItem(DONE_KEY, "1");
return;
}
// Show a toast nudge with QR code
const toast = document.createElement("div");

View File

@ -2150,6 +2150,17 @@
<button id="new-stream" title="Stream" class="toolbar-disabled">📡 Stream</button>
</div>
</div>
<!-- 10. Applets -->
<div class="toolbar-group" id="applets-group">
<button class="toolbar-group-toggle" title="Applets"><span class="tg-icon"></span><span class="tg-label">Applets</span></button>
<div class="toolbar-dropdown">
<div class="toolbar-dropdown-header">Applets</div>
<div id="applet-palette-list"></div>
<div class="toolbar-dropdown-header" style="margin-top:8px">Templates</div>
<div id="applet-template-list" style="font-size:11px;color:#64748b;padding:4px 8px">No templates saved</div>
</div>
</div>
<button id="toolbar-collapse" title="Minimize toolbar"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="6" y1="18" x2="18" y2="18"/></svg></button>
</div>
@ -2518,6 +2529,7 @@
FolkHolonExplorer,
FolkApplet,
registerAppletDef,
listAppletDefs,
AppletTemplateManager,
CommunitySync,
PresenceManager,
@ -2543,7 +2555,13 @@
import { RStackTabBar } from "@shared/components/rstack-tab-bar";
import { RStackMi } from "@shared/components/rstack-mi";
import { govApplets, flowsApplets, walletApplets } from "@lib/applet-defs";
import {
govApplets, flowsApplets, walletApplets,
tasksApplets, timeApplets, calApplets, chatsApplets,
dataApplets, docsApplets, notesApplets, photosApplets,
mapsApplets, networkApplets, choicesApplets, inboxApplets,
socialsApplets, booksApplets, exchangeApplets,
} from "@lib/applet-defs";
import { RStackHistoryPanel } from "@shared/components/rstack-history-panel";
import { RStackCommentBell } from "@shared/components/rstack-comment-bell";
import { rspaceNavUrl } from "@shared/url-helpers";
@ -2844,6 +2862,83 @@
for (const def of govApplets) registerAppletDef("rgov", def);
for (const def of flowsApplets) registerAppletDef("rflows", def);
for (const def of walletApplets) registerAppletDef("rwallet", def);
for (const def of tasksApplets) registerAppletDef("rtasks", def);
for (const def of timeApplets) registerAppletDef("rtime", def);
for (const def of calApplets) registerAppletDef("rcal", def);
for (const def of chatsApplets) registerAppletDef("rchats", def);
for (const def of dataApplets) registerAppletDef("rdata", def);
for (const def of docsApplets) registerAppletDef("rdocs", def);
for (const def of notesApplets) registerAppletDef("rnotes", def);
for (const def of photosApplets) registerAppletDef("rphotos", def);
for (const def of mapsApplets) registerAppletDef("rmaps", def);
for (const def of networkApplets) registerAppletDef("rnetwork", def);
for (const def of choicesApplets) registerAppletDef("rchoices", def);
for (const def of inboxApplets) registerAppletDef("rinbox", def);
for (const def of socialsApplets) registerAppletDef("rsocials", def);
for (const def of booksApplets) registerAppletDef("rbooks", def);
for (const def of exchangeApplets) registerAppletDef("rexchange", def);
// Build applet palette in toolbar
function buildAppletPalette() {
const list = document.getElementById("applet-palette-list");
if (!list) return;
list.innerHTML = "";
const defs = listAppletDefs();
// Group by module
const byModule = new Map();
for (const { moduleId, def } of defs) {
if (!byModule.has(moduleId)) byModule.set(moduleId, []);
byModule.get(moduleId).push(def);
}
for (const [modId, modDefs] of byModule) {
for (const def of modDefs) {
const btn = document.createElement("button");
btn.title = `${def.label} (${modId})`;
btn.textContent = `${def.icon} ${def.label}`;
btn.style.borderLeft = `3px solid ${def.accentColor}`;
btn.addEventListener("click", () => {
setPendingTool("folk-applet", { moduleId: modId, appletId: def.id });
});
list.appendChild(btn);
}
}
}
buildAppletPalette();
// Build template palette (refresh on group open)
function buildTemplatePalette() {
const list = document.getElementById("applet-template-list");
if (!list) return;
if (typeof AppletTemplateManager === "undefined") return;
const templates = AppletTemplateManager.listTemplates?.();
if (!templates || templates.length === 0) {
list.innerHTML = '<span style="font-size:11px;color:#64748b;padding:4px 8px">No templates saved</span>';
return;
}
list.innerHTML = "";
for (const tmpl of templates) {
const btn = document.createElement("button");
btn.title = tmpl.description || tmpl.name;
btn.textContent = `${tmpl.icon || "📐"} ${tmpl.name}`;
btn.style.borderLeft = `3px solid ${tmpl.color || "#475569"}`;
btn.addEventListener("click", () => {
const center = getViewportCenter();
AppletTemplateManager.instantiateTemplate?.(tmpl.id, center.x, center.y);
});
list.appendChild(btn);
}
}
// Refresh template list when applets group opens
const appletsGroup = document.getElementById("applets-group");
if (appletsGroup) {
const toggle = appletsGroup.querySelector(".toolbar-group-toggle");
if (toggle) {
toggle.addEventListener("click", () => {
setTimeout(buildTemplatePalette, 0);
});
}
}
// Zoom and pan state — declared early to avoid TDZ errors
// (event handlers reference these before awaits yield execution)
@ -4093,6 +4188,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
"folk-holon-browser": { width: 400, height: 450 },
"folk-holon-explorer": { width: 580, height: 540 },
"folk-transaction-builder": { width: 420, height: 520 },
"folk-applet": { width: 300, height: 200 },
};
// Get the center of the current viewport in canvas coordinates