diff --git a/lib/applet-defs.ts b/lib/applet-defs.ts index 242ad6ba..c5f14172 100644 --- a/lib/applet-defs.ts +++ b/lib/applet-defs.ts @@ -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"; diff --git a/lib/folk-applet.ts b/lib/folk-applet.ts index d56bbe86..8b06c143 100644 --- a/lib/folk-applet.ts +++ b/lib/folk-applet.ts @@ -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): 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(); + const renderChips = (ports: PortDescriptor[], dir: "input" | "output") => { + ports.forEach((port, i) => { + const yPct = ((i + 1) / (ports.length + 1)) * 100; + const color = dataTypeColor(port.type); - // Input ports on left edge - inputs.forEach((port, i) => { - const yPct = ((i + 1) / (inputs.length + 1)) * 100; - const color = dataTypeColor(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 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 dot = document.createElement("span"); + dot.className = "chip-dot"; + dot.style.background = color; - const label = document.createElement("span"); - label.className = "port-label input"; - label.style.top = `${yPct}%`; - label.style.transform = "translateY(-50%)"; - label.textContent = port.name; + const label = document.createTextNode(port.name); - this.#wrapper.appendChild(dot); - this.#wrapper.appendChild(label); - }); + 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 { diff --git a/modules/rbooks/applets.ts b/modules/rbooks/applets.ts new file mode 100644 index 00000000..1f269269 --- /dev/null +++ b/modules/rbooks/applets.ts @@ -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 ` +
+
${title}
+ ${author ? `
${author}
` : ""} + ${rating > 0 ? `
${"★".repeat(rating)}${"☆".repeat(5 - rating)}
` : ""} +
Progress: ${progress}%
+
+
+
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "query-in" && typeof value === "string") { + ctx.emitOutput("book-out", { query: value }); + } + }, +}; + +export const booksApplets: AppletDefinition[] = [bookCard]; diff --git a/modules/rcal/applets.ts b/modules/rcal/applets.ts new file mode 100644 index 00000000..a43e824c --- /dev/null +++ b/modules/rcal/applets.ts @@ -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 ` +
+
${title}
+ ${time ? `
🕐 ${time}
` : ""} + ${location ? `
📍 ${location}
` : ""} + ${!time && !location ? `
Connect a calendar feed
` : ""} +
+ `; + }, + 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]; diff --git a/modules/rchats/applets.ts b/modules/rchats/applets.ts new file mode 100644 index 00000000..9c046d45 --- /dev/null +++ b/modules/rchats/applets.ts @@ -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 ` +
+
#${channel}
+
${unread}
+
unread messages
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "channel-in" && typeof value === "string") { + ctx.emitOutput("unread-out", 0); + } + }, +}; + +export const chatsApplets: AppletDefinition[] = [unreadCount]; diff --git a/modules/rchoices/applets.ts b/modules/rchoices/applets.ts new file mode 100644 index 00000000..07697807 --- /dev/null +++ b/modules/rchoices/applets.ts @@ -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 ` +
+
${question}
+
${winner}
+
${winPct}% of ${totalVotes} votes
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "session-in" && value && typeof value === "object") { + const session = value as Record; + ctx.emitOutput("winner-out", (session.winner as string) || ""); + } + }, +}; + +export const choicesApplets: AppletDefinition[] = [voteTally]; diff --git a/modules/rdata/applets.ts b/modules/rdata/applets.ts new file mode 100644 index 00000000..a3cb0aba --- /dev/null +++ b/modules/rdata/applets.ts @@ -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 ` +
+
${label}
+
${value.toLocaleString()}
+
+ ${arrow} ${Math.abs(delta)}% vs previous +
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "metric-in" && typeof value === "number") { + ctx.emitOutput("delta-out", value); + } + }, +}; + +export const dataApplets: AppletDefinition[] = [analyticsCard]; diff --git a/modules/rdata/components/folk-content-tree.ts b/modules/rdata/components/folk-content-tree.ts index c2cee645..27c9c4e7 100644 --- a/modules/rdata/components/folk-content-tree.ts +++ b/modules/rdata/components/folk-content-tree.ts @@ -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() { diff --git a/modules/rdata/components/folk-data-cloud.ts b/modules/rdata/components/folk-data-cloud.ts index 39700371..fbd0cb55 100644 --- a/modules/rdata/components/folk-data-cloud.ts +++ b/modules/rdata/components/folk-data-cloud.ts @@ -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 = { - 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 = { + private: "#ef4444", + permissioned: "#eab308", + public: "#22c55e", }; -const RINGS: Ring[] = ["private", "permissioned", "public"]; +const MOD_COLORS: Record = { + 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,238 +140,347 @@ 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) => { - try { - const treeResp = 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: [] }; - } - }) - ); + const allDocs: DocNode[] = []; - this.spaces = bubbles; + await Promise.all(spaces.map(async (sp) => { + try { + const resp = await fetch( + `${base}/api/content-tree?space=${encodeURIComponent(sp.slug)}`, + { signal: AbortSignal.timeout(8000) } + ); + 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.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 { - const groups: Record = { 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 }>(); + for (const doc of this.docs) { + if (!spaceMap.has(doc.space)) { + spaceMap.set(doc.space, { name: doc.spaceName, vis: doc.visibility, mods: new Map() }); + } + const sp = spaceMap.get(doc.space)!; + if (!sp.mods.has(doc.modId)) sp.mods.set(doc.modId, []); + sp.mods.get(doc.modId)!.push(doc); + } + + 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) }); + } + } } - return groups; } - private isMobile(): boolean { - return this.width < 500; - } + // ── Rendering ── private render() { - const selected = this.selected ? this.spaces.find(s => s.slug === this.selected) : null; - this.shadow.innerHTML = `
${this.isDemo ? `
Sign in to see your data cloud
` : ""} - ${this.loading ? this.renderLoading() : this.renderSVG()} - ${selected ? this.renderDetailPanel(selected) : ""} + ${this.loading ? this.renderLoading() : this.renderGraph()} + ${!this.loading ? this.renderLegend() : ""}
`; - this.attachEvents(); } private renderLoading(): string { - const cx = this.width / 2; - const cy = this.height / 2; return ` - ${RINGS.map(ring => { - const r = RING_CONFIG[ring].radius * (this.width / 2) * 0.9; - return ``; - }).join("")} - Loading your data cloud… `; } - 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 `
No data objects found
`; + } + const mobile = this.width < 500; let svg = ``; - // 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 += ``; - - // Ring label at top - const labelY = cy - r - 8; - svg += `${cfg.label}`; + // 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 += ``; } - // 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 += ``; - 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 += ``; - // 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 += ``; - if (isSelected) { - svg += ` - - `; - } - svg += ``; - - // Label - const label = mobile ? sp.name.slice(0, 6) : (sp.name.length > 12 ? sp.name.slice(0, 11) + "…" : sp.name); - svg += ` 14 ? node.label.slice(0, 13) + "…" : node.label); + svg += `${this.esc(label)}`; - - // Doc count badge - svg += `${sp.docCount}`; - - // Tooltip (title element) - svg += `${this.esc(sp.name)} — ${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} (${sp.visibility})`; - svg += ``; + } else if (node.type === "module") { + svg += `${node.icon}`; + if (!mobile) { + svg += `${this.esc(node.label)}`; + } + } else { + // Doc — show label on hover via title } - } - // Center label - const totalDocs = this.spaces.reduce((s, sp) => s + sp.docCount, 0); - svg += `${totalDocs}`; - svg += `total documents`; + // 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 += `${this.esc(tooltipText)}`; + + 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(); + 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(); + for (const doc of this.docs) visLevels.add(doc.visibility); return ` -
-
- ${this.esc(sp.name)} - ${sp.visibility} - ${sp.docCount} doc${sp.docCount !== 1 ? "s" : ""} +
+
+ ${[...visLevels].map(v => ` + + + ${this.esc(v)} + + `).join("")}
- ${sp.modules.length === 0 - ? `
No documents in this space
` - : `
- ${sp.modules.map(m => ` -
- ${m.icon} - ${this.esc(m.name)} - ${m.docCount} -
- `).join("")} -
` - } +
+ ${[...mods.entries()].map(([, m]) => ` + ${m.icon} ${this.esc(m.name)} + `).join("")} +
+
Click any node to open in new tab
`; } + // ── Events ── + private attachEvents() { - // Bubble click — toggle selection - for (const g of this.shadow.querySelectorAll(".dc-bubble")) { - const slug = g.dataset.slug!; + for (const g of this.shadow.querySelectorAll(".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("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"); - }); - } - - // Module row click — navigate - for (const row of this.shadow.querySelectorAll(".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}`; + 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("line")) { + line.setAttribute("opacity", "0.2"); + line.setAttribute("stroke-width", "1"); + } }); } } + // ── Helpers ── + private esc(s: string): string { return s.replace(/&/g, "&").replace(//g, ">"); } @@ -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; } } `; } diff --git a/modules/rdocs/applets.ts b/modules/rdocs/applets.ts new file mode 100644 index 00000000..1de982a3 --- /dev/null +++ b/modules/rdocs/applets.ts @@ -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 ` +
+
${title}
+
${wordCount} words${lastEdit ? ` · ${lastEdit}` : ""}
+
${preview}
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "doc-in" && value && typeof value === "object") { + const doc = value as Record; + ctx.emitOutput("text-out", (doc.content as string) || (doc.preview as string) || ""); + } + }, +}; + +export const docsApplets: AppletDefinition[] = [docSummary]; diff --git a/modules/rexchange/applets.ts b/modules/rexchange/applets.ts new file mode 100644 index 00000000..9e70d8f3 --- /dev/null +++ b/modules/rexchange/applets.ts @@ -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 ` +
+
${pair}
+
${rate.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 6 })}
+
${arrow} ${Math.abs(change24h).toFixed(2)}%
+
+ `; + }, + 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 ` +
+
+ ${type} + ${status} +
+
${amount.toLocaleString()}
+
${asset}
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "trade-in" && value && typeof value === "object") { + const trade = value as Record; + ctx.emitOutput("status-out", (trade.status as string) || "pending"); + } + }, +}; + +export const exchangeApplets: AppletDefinition[] = [rateCard, tradeStatus]; diff --git a/modules/rinbox/applets.ts b/modules/rinbox/applets.ts new file mode 100644 index 00000000..fa859259 --- /dev/null +++ b/modules/rinbox/applets.ts @@ -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 ` +
+
+ Total + ${total} +
+
+ Unread + ${unread} +
+
${latest}
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "mailbox-in" && typeof value === "string") { + ctx.emitOutput("count-out", 0); + } + }, +}; + +export const inboxApplets: AppletDefinition[] = [threadFeed]; diff --git a/modules/rmaps/applets.ts b/modules/rmaps/applets.ts new file mode 100644 index 00000000..da06c016 --- /dev/null +++ b/modules/rmaps/applets.ts @@ -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 ` +
+
📍
+
${label}
+ ${hasCoords + ? `
${lat.toFixed(4)}, ${lng.toFixed(4)}
` + : `
No coordinates
` + } +
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "location-in" && value && typeof value === "object") { + const loc = value as Record; + 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 ` +
+
+ From + ${from} +
+
+ To + ${to} +
+
+ ${distance} + ${duration} +
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "route-in" && value && typeof value === "object") { + const route = value as Record; + ctx.emitOutput("distance-out", Number(route.distanceKm) || 0); + } + }, +}; + +export const mapsApplets: AppletDefinition[] = [locationPin, routeSummary]; diff --git a/modules/rnetwork/applets.ts b/modules/rnetwork/applets.ts new file mode 100644 index 00000000..9d4fa2b9 --- /dev/null +++ b/modules/rnetwork/applets.ts @@ -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 ` +
+
👤
+
${name}
+
${shortDid}
+
Trust: ${Math.round(trustScore * 100)}%
+
+ `; + }, + 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]; diff --git a/modules/rnotes/applets.ts b/modules/rnotes/applets.ts new file mode 100644 index 00000000..5caf5650 --- /dev/null +++ b/modules/rnotes/applets.ts @@ -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 ` +
+
${title}
+ ${vault ? `
📁 ${vault}
` : ""} + ${tags.length > 0 ? `
${tags.map(t => `#${t}`).join(" ")}
` : ""} +
${preview}
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "note-in" && value && typeof value === "object") { + const note = value as Record; + ctx.emitOutput("content-out", (note.content as string) || ""); + } + }, +}; + +export const notesApplets: AppletDefinition[] = [vaultNote]; diff --git a/modules/rphotos/applets.ts b/modules/rphotos/applets.ts new file mode 100644 index 00000000..01d356c9 --- /dev/null +++ b/modules/rphotos/applets.ts @@ -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 ` +
+ ${thumb + ? `
` + : `
🖼️
` + } +
${name}
+
${count} photos
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "album-in" && typeof value === "string") { + ctx.emitOutput("image-out", ""); + } + }, +}; + +export const photosApplets: AppletDefinition[] = [albumCard]; diff --git a/modules/rsocials/applets.ts b/modules/rsocials/applets.ts new file mode 100644 index 00000000..23d6adc0 --- /dev/null +++ b/modules/rsocials/applets.ts @@ -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 ` +
+
${platform}
+
${content || "Empty draft"}
+
+
+
+
+ ${charCount}/${maxChars} +
+
+ `; + }, + 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]; diff --git a/modules/rtasks/applets.ts b/modules/rtasks/applets.ts new file mode 100644 index 00000000..fd61a2c5 --- /dev/null +++ b/modules/rtasks/applets.ts @@ -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 ` +
+
${done}/${total}
+
tasks complete
+
+
+
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "board-in" && value && typeof value === "object") { + const board = value as Record; + 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 ` +
+
${count}
+
due today
+
${urgent} urgent
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "board-in" && value && typeof value === "object") { + ctx.emitOutput("tasks-out", value); + } + }, +}; + +export const tasksApplets: AppletDefinition[] = [taskCounter, dueToday]; diff --git a/modules/rtime/applets.ts b/modules/rtime/applets.ts new file mode 100644 index 00000000..c06b6535 --- /dev/null +++ b/modules/rtime/applets.ts @@ -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 ` +
+
${label}
+
${pct}%
+
${committed}/${capacity} hrs
+
+
+
+
+ `; + }, + onInputReceived(portName, value, ctx) { + if (portName === "pool-in" && value && typeof value === "object") { + const pool = value as Record; + ctx.emitOutput("committed-out", Number(pool.committed) || 0); + } + }, +}; + +export const timeApplets: AppletDefinition[] = [commitmentMeter]; diff --git a/server/landing.ts b/server/landing.ts index c38e3578..d206607f 100644 --- a/server/landing.ts +++ b/server/landing.ts @@ -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 = { @@ -40,7 +40,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string { return ` ${m.icon}
- ${escapeHtml(m.name)} + ${brandedAppName(m.name)} ${m.standaloneDomain ? `${escapeHtml(m.standaloneDomain)}` : ""} ${escapeHtml(m.description)}
@@ -131,7 +131,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {
- Reclaim (you)rSpace on the internet + Reclaim (you)rSpace on the internet

rSpace

Coordinate around what you care about — without stitching together a dozen corporate apps. @@ -293,7 +293,7 @@ export function renderMainLanding(modules: ModuleInfo[]): string {

-

Reclaim (you)rSpace.

+

Reclaim (you)rSpace.

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

-

${escapeHtml(m.name)}

+

${brandedAppName(m.name)}

${escapeHtml(m.description)}

`; @@ -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); diff --git a/server/shell.ts b/server/shell.ts index 0e951ef7..4d1dec29 100644 --- a/server/shell.ts +++ b/server/shell.ts @@ -2088,7 +2088,7 @@ function renderModuleSubNav(moduleId: string, spaceSlug: string, modules: Module const minimizeBtn = ``; const pills = [ - `${escapeHtml(mod.name)}`, + `${brandedAppName(mod.name)}`, ...items.map(it => `${it.icon ? escapeHtml(it.icon) + ' ' : ''}${escapeHtml(it.label)}` ), @@ -2232,7 +2232,7 @@ export function renderModuleLanding(opts: ModuleLandingOptions): string { : `
${mod.icon} -

${escapeHtml(mod.name)}

+

${brandedAppName(mod.name)}

${escapeHtml(mod.description)}

Try Demo @@ -2580,7 +2580,7 @@ export function renderSubPageInfo(opts: SubPageInfoOptions): string {
${featuresGrid} `; return versionAssetUrls(` @@ -2736,7 +2736,7 @@ export function renderOnboarding(opts: OnboardingOptions): string {
${moduleIcon} -

${escapeHtml(moduleName)}

+

${brandedAppName(moduleName)}

${escapeHtml(moduleDescription)}

This app hasn't been used in ${escapeHtml(spaceSlug)} yet. Load sample data to explore, or jump into the public demo.

@@ -2925,6 +2925,14 @@ export function escapeHtml(s: string): string { return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } +/** Render rApp name with orange "r" prefix for visible HTML (not 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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">"); } diff --git a/server/welcome-email.ts b/server/welcome-email.ts index 1e1c9e48..306377bb 100644 --- a/server/welcome-email.ts +++ b/server/welcome-email.ts @@ -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 — 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 — 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 — - <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;"> diff --git a/shared/components/rstack-app-switcher.ts b/shared/components/rstack-app-switcher.ts index 6934cb66..52f283d0 100644 --- a/shared/components/rstack-app-switcher.ts +++ b/shared/components/rstack-app-switcher.ts @@ -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> diff --git a/shared/components/rstack-identity.ts b/shared/components/rstack-identity.ts index bc9320db..0761e9ee 100644 --- a/shared/components/rstack-identity.ts +++ b/shared/components/rstack-identity.ts @@ -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"); diff --git a/website/canvas.html b/website/canvas.html index cde7b9ee..a3f47b29 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -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