feat(canvas): rApplet Phase 2 — port chips, data flow, toolbar palette, 15 module applets

- Replace port-indicator dots with port-chip pills (colored border + dot + name)
- Add setPortValue override bridging FolkArrow piping to onInputReceived/emitOutput
- Add Applets toolbar group with dynamic palette + template section
- 15 new module applet files: rtasks, rtime, rcal, rchats, rdata, rdocs,
  rnotes, rphotos, rmaps, rnetwork, rchoices, rinbox, rsocials, rbooks, rexchange
- 20 total applet cards across 18 modules (was 5 across 3)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-15 13:08:49 -04:00
parent 184da55813
commit fe3e3621af
18 changed files with 900 additions and 78 deletions

View File

@ -7,3 +7,18 @@
export { govApplets } from "../modules/rgov/applets"; export { govApplets } from "../modules/rgov/applets";
export { flowsApplets } from "../modules/rflows/applets"; export { flowsApplets } from "../modules/rflows/applets";
export { walletApplets } from "../modules/rwallet/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 { css, html } from "./tags";
import { dataTypeColor } from "./data-types"; import { dataTypeColor } from "./data-types";
import type { PortDescriptor } 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) ── // ── Applet registry (populated by modules at init) ──
@ -120,45 +120,43 @@ const styles = css`
font-style: italic; font-style: italic;
} }
/* Port indicators on edges */ /* Port chips on edges */
.port-indicator { .port-chip {
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 {
position: absolute; 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; font-size: 9px;
color: var(--rs-text-muted, #94a3b8); color: var(--rs-text-muted, #94a3b8);
white-space: nowrap; white-space: nowrap;
pointer-events: none; cursor: crosshair;
z-index: 2;
transform: translateY(-50%);
transition: filter 0.15s;
} }
.port-label.input { .port-chip:hover {
left: 10px; filter: brightness(1.3);
} }
.port-label.output { .port-chip.input {
right: 10px; left: -2px;
text-align: right; }
.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 */ /* Expanded mode circuit container */
@ -250,6 +248,26 @@ export class FolkApplet extends FolkShape {
return this.#instancePorts.find(p => p.name === name); 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. */ /** Update live data and re-render compact body. */
updateLiveData(snapshot: Record<string, unknown>): void { updateLiveData(snapshot: Record<string, unknown>): void {
this.#liveData = { this.#liveData = {
@ -319,57 +337,35 @@ export class FolkApplet extends FolkShape {
} }
#renderPorts(): void { #renderPorts(): void {
// Remove existing port indicators this.#wrapper.querySelectorAll(".port-chip").forEach(el => el.remove());
this.#wrapper.querySelectorAll(".port-indicator, .port-label").forEach(el => el.remove());
const inputs = this.getInputPorts(); const renderChips = (ports: PortDescriptor[], dir: "input" | "output") => {
const outputs = this.getOutputPorts(); ports.forEach((port, i) => {
const yPct = ((i + 1) / (ports.length + 1)) * 100;
const color = dataTypeColor(port.type);
// Input ports on left edge const chip = document.createElement("div");
inputs.forEach((port, i) => { chip.className = `port-chip ${dir}`;
const yPct = ((i + 1) / (inputs.length + 1)) * 100; chip.style.top = `${yPct}%`;
const color = dataTypeColor(port.type); chip.style.borderColor = color;
chip.dataset.portName = port.name;
chip.dataset.portDir = dir;
chip.title = `${port.name} (${port.type})`;
const dot = document.createElement("div"); const dot = document.createElement("span");
dot.className = "port-indicator input"; dot.className = "chip-dot";
dot.style.top = `${yPct}%`; dot.style.background = color;
dot.style.backgroundColor = color;
dot.dataset.portName = port.name;
dot.dataset.portDir = "input";
dot.title = `${port.name} (${port.type})`;
const label = document.createElement("span"); const label = document.createTextNode(port.name);
label.className = "port-label input";
label.style.top = `${yPct}%`;
label.style.transform = "translateY(-50%)";
label.textContent = port.name;
this.#wrapper.appendChild(dot); chip.appendChild(dot);
this.#wrapper.appendChild(label); chip.appendChild(label);
}); this.#wrapper.appendChild(chip);
});
};
// Output ports on right edge renderChips(this.getInputPorts(), "input");
outputs.forEach((port, i) => { renderChips(this.getOutputPorts(), "output");
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);
});
} }
#renderBody(): void { #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];

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

@ -2150,6 +2150,17 @@
<button id="new-stream" title="Stream" class="toolbar-disabled">📡 Stream</button> <button id="new-stream" title="Stream" class="toolbar-disabled">📡 Stream</button>
</div> </div>
</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> <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> </div>
@ -2518,6 +2529,7 @@
FolkHolonExplorer, FolkHolonExplorer,
FolkApplet, FolkApplet,
registerAppletDef, registerAppletDef,
listAppletDefs,
AppletTemplateManager, AppletTemplateManager,
CommunitySync, CommunitySync,
PresenceManager, PresenceManager,
@ -2543,7 +2555,13 @@
import { RStackTabBar } from "@shared/components/rstack-tab-bar"; import { RStackTabBar } from "@shared/components/rstack-tab-bar";
import { RStackMi } from "@shared/components/rstack-mi"; 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 { RStackHistoryPanel } from "@shared/components/rstack-history-panel";
import { RStackCommentBell } from "@shared/components/rstack-comment-bell"; import { RStackCommentBell } from "@shared/components/rstack-comment-bell";
import { rspaceNavUrl } from "@shared/url-helpers"; import { rspaceNavUrl } from "@shared/url-helpers";
@ -2844,6 +2862,83 @@
for (const def of govApplets) registerAppletDef("rgov", def); for (const def of govApplets) registerAppletDef("rgov", def);
for (const def of flowsApplets) registerAppletDef("rflows", def); for (const def of flowsApplets) registerAppletDef("rflows", def);
for (const def of walletApplets) registerAppletDef("rwallet", 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 // Zoom and pan state — declared early to avoid TDZ errors
// (event handlers reference these before awaits yield execution) // (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-browser": { width: 400, height: 450 },
"folk-holon-explorer": { width: 580, height: 540 }, "folk-holon-explorer": { width: 580, height: 540 },
"folk-transaction-builder": { width: 420, height: 520 }, "folk-transaction-builder": { width: 420, height: 520 },
"folk-applet": { width: 300, height: 200 },
}; };
// Get the center of the current viewport in canvas coordinates // Get the center of the current viewport in canvas coordinates