diff --git a/lib/layer-types.ts b/lib/layer-types.ts index e92f5c4..508b629 100644 --- a/lib/layer-types.ts +++ b/lib/layer-types.ts @@ -40,6 +40,28 @@ export const FLOW_LABELS: Record = { custom: "Custom", }; +// ── Feed definition ── + +/** + * Feed definition — describes a data feed that a module can expose to other layers. + * Feeds are the connective tissue between layers: they let data, economic value, + * trust signals, etc. flow between rApps. + */ +export interface FeedDefinition { + /** Feed identifier (unique within the module) */ + id: string; + /** Human-readable name */ + name: string; + /** What kind of flow this feed carries */ + kind: FlowKind; + /** Description of what this feed provides */ + description: string; + /** Shape types this feed emits (e.g. "folk-note", "folk-token-mint") */ + emits?: string[]; + /** Whether this feed supports filtering */ + filterable?: boolean; +} + // ── Layer definition ── export interface Layer { diff --git a/modules/books/mod.ts b/modules/books/mod.ts index ad4f5ad..8b65407 100644 --- a/modules/books/mod.ts +++ b/modules/books/mod.ts @@ -301,6 +301,22 @@ export const booksModule: RSpaceModule = { description: "Community PDF library with flipbook reader", routes, standaloneDomain: "rbooks.online", + feeds: [ + { + id: "reading-list", + name: "Reading List", + kind: "data", + description: "Books in the community library — titles, authors, tags", + filterable: true, + }, + { + id: "annotations", + name: "Annotations", + kind: "data", + description: "Reader highlights, bookmarks, and notes on books", + }, + ], + acceptsFeeds: ["data", "resource"], async onSpaceCreate(spaceSlug: string) { // Books are global, not space-scoped (for now). No-op. diff --git a/modules/cal/mod.ts b/modules/cal/mod.ts index 54b8fd2..a5881ab 100644 --- a/modules/cal/mod.ts +++ b/modules/cal/mod.ts @@ -394,4 +394,20 @@ export const calModule: RSpaceModule = { description: "Temporal coordination calendar with lunar, solar, and seasonal systems", routes, standaloneDomain: "rcal.online", + feeds: [ + { + id: "events", + name: "Events", + kind: "data", + description: "Calendar events with times, locations, and virtual meeting links", + filterable: true, + }, + { + id: "availability", + name: "Availability", + kind: "data", + description: "Free/busy windows and scheduling availability across calendars", + }, + ], + acceptsFeeds: ["data", "governance"], }; diff --git a/modules/cart/mod.ts b/modules/cart/mod.ts index df10abb..66d3bcf 100644 --- a/modules/cart/mod.ts +++ b/modules/cart/mod.ts @@ -460,4 +460,21 @@ export const cartModule: RSpaceModule = { description: "Cosmolocal print-on-demand shop", routes, standaloneDomain: "rcart.online", + feeds: [ + { + id: "orders", + name: "Orders", + kind: "economic", + description: "Order stream with pricing, fulfillment status, and revenue splits", + filterable: true, + }, + { + id: "catalog", + name: "Catalog", + kind: "data", + description: "Active catalog listings with product details and pricing", + filterable: true, + }, + ], + acceptsFeeds: ["economic", "data"], }; diff --git a/modules/files/mod.ts b/modules/files/mod.ts index 872817e..5486f98 100644 --- a/modules/files/mod.ts +++ b/modules/files/mod.ts @@ -385,4 +385,20 @@ export const filesModule: RSpaceModule = { description: "File sharing, share links, and memory cards", routes, standaloneDomain: "rfiles.online", + feeds: [ + { + id: "file-activity", + name: "File Activity", + kind: "data", + description: "Upload, download, and share events across the file library", + filterable: true, + }, + { + id: "shared-files", + name: "Shared Files", + kind: "resource", + description: "Files available via public share links", + }, + ], + acceptsFeeds: ["data", "resource"], }; diff --git a/modules/forum/mod.ts b/modules/forum/mod.ts index 64d2eae..892e1d3 100644 --- a/modules/forum/mod.ts +++ b/modules/forum/mod.ts @@ -176,4 +176,20 @@ export const forumModule: RSpaceModule = { description: "Deploy and manage Discourse forums", routes, standaloneDomain: "rforum.online", + feeds: [ + { + id: "threads", + name: "Threads", + kind: "data", + description: "Discussion threads and topics from provisioned Discourse instances", + filterable: true, + }, + { + id: "activity", + name: "Activity", + kind: "attention", + description: "Forum engagement — new posts, replies, and participation metrics", + }, + ], + acceptsFeeds: ["data", "governance"], }; diff --git a/modules/inbox/mod.ts b/modules/inbox/mod.ts index 96c7853..84b264b 100644 --- a/modules/inbox/mod.ts +++ b/modules/inbox/mod.ts @@ -600,4 +600,20 @@ export const inboxModule: RSpaceModule = { description: "Collaborative email with multisig approval", routes, standaloneDomain: "rinbox.online", + feeds: [ + { + id: "messages", + name: "Messages", + kind: "data", + description: "Email threads and messages across shared mailboxes", + filterable: true, + }, + { + id: "notifications", + name: "Notifications", + kind: "attention", + description: "New mail alerts, approval requests, and mention notifications", + }, + ], + acceptsFeeds: ["data"], }; diff --git a/modules/maps/mod.ts b/modules/maps/mod.ts index 31b2f96..9897903 100644 --- a/modules/maps/mod.ts +++ b/modules/maps/mod.ts @@ -168,4 +168,20 @@ export const mapsModule: RSpaceModule = { description: "Real-time collaborative location sharing and indoor/outdoor maps", routes, standaloneDomain: "rmaps.online", + feeds: [ + { + id: "locations", + name: "Locations", + kind: "data", + description: "Shared location pins, points of interest, and room positions", + filterable: true, + }, + { + id: "routes", + name: "Routes", + kind: "data", + description: "Calculated routes and navigation paths between locations", + }, + ], + acceptsFeeds: ["data"], }; diff --git a/modules/pubs/mod.ts b/modules/pubs/mod.ts index 80e7afb..6456c5e 100644 --- a/modules/pubs/mod.ts +++ b/modules/pubs/mod.ts @@ -342,4 +342,20 @@ export const pubsModule: RSpaceModule = { description: "Drop in a document, get a pocket book", routes, standaloneDomain: "rpubs.online", + feeds: [ + { + id: "publications", + name: "Publications", + kind: "data", + description: "Print-ready artifacts generated from markdown documents", + filterable: true, + }, + { + id: "citations", + name: "Citations", + kind: "data", + description: "Citation and reference data extracted from documents", + }, + ], + acceptsFeeds: ["data"], }; diff --git a/modules/swag/mod.ts b/modules/swag/mod.ts index e65cd67..d4099e2 100644 --- a/modules/swag/mod.ts +++ b/modules/swag/mod.ts @@ -247,4 +247,20 @@ export const swagModule: RSpaceModule = { description: "Design print-ready swag: stickers, posters, tees", routes, standaloneDomain: "rswag.online", + feeds: [ + { + id: "designs", + name: "Designs", + kind: "resource", + description: "Print-ready design artifacts for stickers, posters, and tees", + filterable: true, + }, + { + id: "merch-orders", + name: "Merch Orders", + kind: "economic", + description: "Merchandise order events and fulfillment tracking", + }, + ], + acceptsFeeds: ["economic", "resource"], }; diff --git a/modules/tube/mod.ts b/modules/tube/mod.ts index cac5656..16621ac 100644 --- a/modules/tube/mod.ts +++ b/modules/tube/mod.ts @@ -210,4 +210,20 @@ export const tubeModule: RSpaceModule = { description: "Community video hosting & live streaming", routes, standaloneDomain: "rtube.online", + feeds: [ + { + id: "videos", + name: "Videos", + kind: "data", + description: "Video library — uploaded and live-streamed content", + filterable: true, + }, + { + id: "watch-activity", + name: "Watch Activity", + kind: "attention", + description: "View counts, watch time, and streaming engagement metrics", + }, + ], + acceptsFeeds: ["data", "resource"], }; diff --git a/server/index.ts b/server/index.ts index 88fea3b..6cb423b 100644 --- a/server/index.ts +++ b/server/index.ts @@ -134,7 +134,7 @@ const MI_MODEL = process.env.MI_MODEL || "llama3.2"; const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434"; app.post("/api/mi/ask", async (c) => { - const { query, messages = [], space, module: currentModule } = await c.req.json(); + const { query, messages = [], space, module: currentModule, context = {} } = await c.req.json(); if (!query) return c.json({ error: "Query required" }, 400); // Build rApp context for the system prompt @@ -142,22 +142,36 @@ app.post("/api/mi/ask", async (c) => { .map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`) .join("\n"); - const systemPrompt = `You are mi, the intelligent assistant for rSpace — a self-hosted, community-run platform. + // Build extended context section from client-provided context + let contextSection = `- Space: ${space || "none selected"}\n- Active rApp: ${currentModule || "none"}`; + if (context.pageTitle) contextSection += `\n- Page: ${context.pageTitle}`; + if (context.activeTab) contextSection += `\n- Active tab: ${context.activeTab}`; + if (context.openShapes?.length) { + const shapeSummary = context.openShapes + .slice(0, 10) + .map((s: any) => ` - ${s.type}${s.title ? `: ${s.title}` : ""}${s.snippet ? ` (${s.snippet})` : ""}`) + .join("\n"); + contextSection += `\n- Open shapes on canvas:\n${shapeSummary}`; + } + + const systemPrompt = `You are mi (mycelial intelligence), the intelligent assistant for rSpace — a self-hosted, community-run platform. You help users navigate, understand, and get the most out of the platform's apps (rApps). +You understand the full context of what the user has open and can guide them through setup and usage. ## Available rApps ${moduleList} ## Current Context -- Space: ${space || "none selected"} -- Active rApp: ${currentModule || "none"} +${contextSection} ## Guidelines - Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail. - When suggesting actions, reference specific rApps by name and explain how they connect. - You can suggest navigating to /:space/:moduleId paths. +- If the user has shapes open on their canvas, you can reference them and suggest connections. +- Help with setup: guide users through creating spaces, adding content, configuring rApps. - If you don't know something specific about the user's data, say so honestly. -- Use a warm, knowledgeable tone. You're a guide, not a search engine.`; +- Use a warm, knowledgeable tone. You're a mycelial guide, connecting knowledge across the platform.`; // Build conversation for Ollama const ollamaMessages = [ diff --git a/shared/components/rstack-mi.ts b/shared/components/rstack-mi.ts index f20d037..2459e4c 100644 --- a/shared/components/rstack-mi.ts +++ b/shared/components/rstack-mi.ts @@ -34,14 +34,14 @@ export class RStackMi extends HTMLElement {
+ placeholder="Ask mi anything — setup, navigation, what's possible..." autocomplete="off" />
-

Hi, I'm mi — your guide to rSpace.

-

Ask me about any rApp, how to find things, or what you can do here.

+

Hi, I'm mi — your mycelial intelligence guide.

+

I see everything you have open and can help with setup, navigation, connecting rApps, or anything else.

@@ -81,6 +81,42 @@ export class RStackMi extends HTMLElement { bar.addEventListener("click", (e) => e.stopPropagation()); } + /** Gather page context: open shapes, active module, tabs, etc. */ + #gatherContext(): Record { + const ctx: Record = {}; + + // Current space and module + ctx.space = document.querySelector("rstack-space-switcher")?.getAttribute("current") || ""; + ctx.module = document.querySelector("rstack-app-switcher")?.getAttribute("current") || ""; + + // Open shapes on canvas (if any) + const canvasContent = document.getElementById("canvas-content"); + if (canvasContent) { + const shapes = [...canvasContent.children] + .filter((el) => el.tagName?.includes("-") && el.id) + .map((el: any) => ({ + type: el.tagName.toLowerCase(), + id: el.id, + ...(el.content ? { snippet: el.content.slice(0, 60) } : {}), + ...(el.title ? { title: el.title } : {}), + })) + .slice(0, 20); + if (shapes.length) ctx.openShapes = shapes; + } + + // Active tab/layer info + const tabBar = document.querySelector("rstack-tab-bar"); + if (tabBar) { + const active = tabBar.getAttribute("active") || ""; + ctx.activeTab = active; + } + + // Page title + ctx.pageTitle = document.title; + + return ctx; + } + async #ask(query: string) { const panel = this.#shadow.getElementById("mi-panel")!; const messagesEl = this.#shadow.getElementById("mi-messages")!; @@ -103,6 +139,7 @@ export class RStackMi extends HTMLElement { try { const token = getAccessToken(); + const context = this.#gatherContext(); const res = await fetch("/api/mi/ask", { method: "POST", headers: { @@ -112,8 +149,9 @@ export class RStackMi extends HTMLElement { body: JSON.stringify({ query, messages: this.#messages.slice(0, -1).slice(-10), - space: document.querySelector("rstack-space-switcher")?.getAttribute("current") || "", - module: document.querySelector("rstack-app-switcher")?.getAttribute("current") || "", + space: context.space, + module: context.module, + context, }), signal: this.#abortController.signal, }); diff --git a/shared/components/rstack-tab-bar.ts b/shared/components/rstack-tab-bar.ts index 7d38251..e4c3480 100644 --- a/shared/components/rstack-tab-bar.ts +++ b/shared/components/rstack-tab-bar.ts @@ -20,7 +20,7 @@ * flow-select — fired when a flow is clicked in stack view { detail: { flowId } } */ -import type { Layer, LayerFlow, FlowKind } from "../../lib/layer-types"; +import type { Layer, LayerFlow, FlowKind, FeedDefinition } from "../../lib/layer-types"; import { FLOW_COLORS, FLOW_LABELS } from "../../lib/layer-types"; // Badge info for tab display @@ -78,6 +78,8 @@ export interface TabBarModule { name: string; icon: string; description: string; + feeds?: FeedDefinition[]; + acceptsFeeds?: FlowKind[]; } export class RStackTabBar extends HTMLElement { @@ -93,6 +95,15 @@ export class RStackTabBar extends HTMLElement { #flowDialogOpen = false; #flowDialogSourceId = ""; #flowDialogTargetId = ""; + // 3D scene state + #sceneRotX = 55; + #sceneRotZ = -15; + #scenePerspective = 1200; + #orbitDragging = false; + #orbitLastX = 0; + #orbitLastY = 0; + #simSpeed = 1; + #simPlaying = true; constructor() { super(); @@ -172,6 +183,57 @@ export class RStackTabBar extends HTMLElement { } } + // ── Feed compatibility helpers ── + + /** Get the set of FlowKinds a module can output (from its feeds) */ + #getModuleOutputKinds(moduleId: string): Set { + const mod = this.#modules.find(m => m.id === moduleId); + if (!mod?.feeds) return new Set(); + return new Set(mod.feeds.map(f => f.kind)); + } + + /** Get the set of FlowKinds a module can accept as input */ + #getModuleInputKinds(moduleId: string): Set { + const mod = this.#modules.find(m => m.id === moduleId); + if (!mod?.acceptsFeeds) return new Set(); + return new Set(mod.acceptsFeeds); + } + + /** Get compatible FlowKinds between two layers (intersection of source outputs and target accepts) */ + #getCompatibleKinds(srcLayerId: string, tgtLayerId: string): Set { + const srcLayer = this.#layers.find(l => l.id === srcLayerId); + const tgtLayer = this.#layers.find(l => l.id === tgtLayerId); + if (!srcLayer || !tgtLayer) return new Set(); + + const srcOutputs = this.#getModuleOutputKinds(srcLayer.moduleId); + const tgtInputs = this.#getModuleInputKinds(tgtLayer.moduleId); + + // If either module has no feed data, return empty (fallback allows all in dialog) + if (srcOutputs.size === 0 && tgtInputs.size === 0) return new Set(); + + const compatible = new Set(); + for (const kind of srcOutputs) { + if (tgtInputs.has(kind)) compatible.add(kind); + } + return compatible; + } + + /** Get feeds that have no matching outgoing flow (contained within the layer) */ + #getContainedFeeds(layerId: string): FeedDefinition[] { + const layer = this.#layers.find(l => l.id === layerId); + if (!layer) return []; + const mod = this.#modules.find(m => m.id === layer.moduleId); + if (!mod?.feeds) return []; + + const outgoingKinds = new Set( + this.#flows + .filter(f => f.sourceLayerId === layerId && f.active) + .map(f => f.kind) + ); + + return mod.feeds.filter(f => !outgoingKinds.has(f.kind)); + } + // ── Render ── #render() { @@ -280,108 +342,115 @@ export class RStackTabBar extends HTMLElement { const layerCount = this.#layers.length; if (layerCount === 0) return ""; - const layerHeight = 60; - const layerGap = 40; - const totalHeight = layerCount * layerHeight + (layerCount - 1) * layerGap + 80; - const width = 600; + const layerSpacing = 80; + const animDuration = 2 / this.#simSpeed; - // Build layer rects and flow arcs - let layersSvg = ""; - let flowsSvg = ""; - const layerPositions = new Map(); + // Build layer planes + let layersHtml = ""; + const layerZMap = new Map(); this.#layers.forEach((layer, i) => { - const y = 40 + i * (layerHeight + layerGap); - const x = 40; - const w = width - 80; + const z = i * layerSpacing; + layerZMap.set(layer.id, z); const badge = MODULE_BADGES[layer.moduleId]; const color = layer.color || badge?.color || "#94a3b8"; - - layerPositions.set(layer.id, { x, y, w, h: layerHeight }); - const isActive = layer.id === this.active; - layersSvg += ` - - - ${layer.label} - ${badge?.badge || layer.moduleId} - + const containedFeeds = this.#getContainedFeeds(layer.id); + + // Feed port indicators — output kinds (right side) and input kinds (left side) + const outputKinds = this.#getModuleOutputKinds(layer.moduleId); + const inputKinds = this.#getModuleInputKinds(layer.moduleId); + + const outPorts = [...outputKinds].map(k => + `` + ).join(""); + const inPorts = [...inputKinds].map(k => + `` + ).join(""); + + const containedHtml = containedFeeds.length > 0 ? ` +
+ ${containedFeeds.map(f => ` + + \uD83D\uDD12 + ${f.name} + + `).join("")} +
+ ` : ""; + + layersHtml += ` +
+
+
${inPorts}
+ ${badge?.badge || layer.moduleId.slice(0, 2)} + ${layer.label} +
${outPorts}
+
+ ${containedHtml} +
`; }); - // Draw flows as curved arcs on the right side + // Build flow particles + let particlesHtml = ""; for (const flow of this.#flows) { - const src = layerPositions.get(flow.sourceLayerId); - const tgt = layerPositions.get(flow.targetLayerId); - if (!src || !tgt) continue; + if (!flow.active) continue; + const srcZ = layerZMap.get(flow.sourceLayerId); + const tgtZ = layerZMap.get(flow.targetLayerId); + if (srcZ === undefined || tgtZ === undefined) continue; - const srcY = src.y + src.h / 2; - const tgtY = tgt.y + tgt.h / 2; - const rightX = src.x + src.w; - const arcOut = 30 + flow.strength * 40; const color = flow.color || FLOW_COLORS[flow.kind] || "#94a3b8"; - const strokeWidth = 1.5 + flow.strength * 3; + const particleCount = Math.max(2, Math.round(flow.strength * 6)); - // Going down or up — arc curves right - const direction = tgtY > srcY ? 1 : -1; - const midY = (srcY + tgtY) / 2; - - flowsSvg += ` - - - - - ${flow.label ? ` - ${flow.label} - ` : ""} - - - - `; + for (let p = 0; p < particleCount; p++) { + const delay = (p / particleCount) * animDuration; + particlesHtml += ` +
+ `; + } } - // Flow kind legend + // Flow legend const activeKinds = new Set(this.#flows.map(f => f.kind)); - let legendSvg = ""; - let legendX = 50; - for (const kind of activeKinds) { - const color = FLOW_COLORS[kind]; - legendSvg += ` - - ${FLOW_LABELS[kind]} - `; - legendX += FLOW_LABELS[kind].length * 7 + 24; - } + const legendHtml = [...activeKinds].map(k => ` + + + ${FLOW_LABELS[k]} + + `).join(""); - // Flow creation hint - const hintSvg = this.#layers.length >= 2 ? ` - - Drag from one layer to another to create a flow - - ` : ""; + // Time scrubber + const scrubberHtml = ` +
+ + + ${this.#simSpeed.toFixed(1)}x +
+ `; return `
- - ${flowsSvg} - ${layersSvg} - ${legendSvg} - ${hintSvg} - +
+
+ ${layersHtml} + ${particlesHtml} +
+
+
${legendHtml}
+ ${scrubberHtml} + ${this.#layers.length >= 2 ? `
Drag between layers to create a flow \u00b7 Drag empty space to orbit
` : ""} ${this.#flowDialogOpen ? this.#renderFlowDialog() : ""}
`; @@ -398,24 +467,44 @@ export class RStackTabBar extends HTMLElement { const tgtBadge = MODULE_BADGES[tgtLayer.moduleId]; const kinds: FlowKind[] = ["economic", "trust", "data", "attention", "governance", "resource"]; + const compatible = this.#getCompatibleKinds(this.#flowDialogSourceId, this.#flowDialogTargetId); + const hasModuleData = this.#modules.some(m => m.id === srcLayer.moduleId && m.feeds) || + this.#modules.some(m => m.id === tgtLayer.moduleId && m.acceptsFeeds); + + // Count source feeds per kind for badge display + const srcMod = this.#modules.find(m => m.id === srcLayer.moduleId); + const feedCountByKind = new Map(); + if (srcMod?.feeds) { + for (const f of srcMod.feeds) { + feedCountByKind.set(f.kind, (feedCountByKind.get(f.kind) || 0) + 1); + } + } return `
New Flow
${srcBadge?.badge || srcLayer.moduleId} - + \u2192 ${tgtBadge?.badge || tgtLayer.moduleId}
- ${kinds.map(k => ` - - `).join("")} + `; + }).join("")}
@@ -556,13 +645,13 @@ export class RStackTabBar extends HTMLElement { this.#render(); }); - // Stack view layer clicks + drag-to-connect - this.#shadow.querySelectorAll(".stack-layer").forEach(g => { - const layerId = g.dataset.layerId!; + // 3D Stack view: layer clicks + drag-to-connect + this.#shadow.querySelectorAll(".layer-plane").forEach(plane => { + const layerId = plane.dataset.layerId!; // Click to switch layer - g.addEventListener("click", () => { - if (this.#flowDragSource) return; // ignore if mid-drag + plane.addEventListener("click", (e) => { + if (this.#flowDragSource || this.#orbitDragging) return; const layer = this.#layers.find(l => l.id === layerId); if (layer) { this.active = layerId; @@ -574,48 +663,87 @@ export class RStackTabBar extends HTMLElement { }); // Drag-to-connect: mousedown starts a flow drag - g.addEventListener("mousedown", (e) => { + plane.addEventListener("mousedown", (e) => { if ((e as MouseEvent).button !== 0) return; + e.stopPropagation(); // prevent orbit this.#flowDragSource = layerId; - g.classList.add("flow-drag-source"); + plane.classList.add("flow-drag-source"); }); - g.addEventListener("mouseenter", () => { + plane.addEventListener("mouseenter", () => { if (this.#flowDragSource && this.#flowDragSource !== layerId) { this.#flowDragTarget = layerId; - g.classList.add("flow-drag-target"); + plane.classList.add("flow-drag-target"); } }); - g.addEventListener("mouseleave", () => { + plane.addEventListener("mouseleave", () => { if (this.#flowDragTarget === layerId) { this.#flowDragTarget = null; - g.classList.remove("flow-drag-target"); + plane.classList.remove("flow-drag-target"); } }); }); - // Global mouseup completes the flow drag - const svgEl = this.#shadow.querySelector(".stack-view svg"); - if (svgEl) { - svgEl.addEventListener("mouseup", () => { + // 3D scene: orbit controls (drag on empty space to rotate) + const sceneContainer = this.#shadow.getElementById("stack-3d"); + if (sceneContainer) { + sceneContainer.addEventListener("mousedown", (e) => { + // Only orbit on left-click on empty space (not on layer planes) + if ((e as MouseEvent).button !== 0) return; + if ((e.target as HTMLElement).closest(".layer-plane")) return; + this.#orbitDragging = true; + this.#orbitLastX = (e as MouseEvent).clientX; + this.#orbitLastY = (e as MouseEvent).clientY; + sceneContainer.style.cursor = "grabbing"; + }); + + const onMouseMove = (e: MouseEvent) => { + if (this.#orbitDragging) { + const dx = e.clientX - this.#orbitLastX; + const dy = e.clientY - this.#orbitLastY; + this.#sceneRotZ += dx * 0.3; + this.#sceneRotX = Math.max(10, Math.min(80, this.#sceneRotX - dy * 0.3)); + this.#orbitLastX = e.clientX; + this.#orbitLastY = e.clientY; + const scene = this.#shadow.getElementById("stack-scene"); + if (scene) scene.style.transform = `rotateX(${this.#sceneRotX}deg) rotateZ(${this.#sceneRotZ}deg)`; + } + }; + + const onMouseUp = () => { + if (this.#orbitDragging) { + this.#orbitDragging = false; + sceneContainer.style.cursor = ""; + } + // Complete flow drag if (this.#flowDragSource && this.#flowDragTarget) { this.#openFlowDialog(this.#flowDragSource, this.#flowDragTarget); } - // Reset drag state this.#flowDragSource = null; this.#flowDragTarget = null; this.#shadow.querySelectorAll(".flow-drag-source, .flow-drag-target").forEach(el => { el.classList.remove("flow-drag-source", "flow-drag-target"); }); - }); + }; + + // Attach to document for drag continuity + document.addEventListener("mousemove", onMouseMove); + document.addEventListener("mouseup", onMouseUp); + + // Scroll to zoom + sceneContainer.addEventListener("wheel", (e) => { + e.preventDefault(); + this.#scenePerspective = Math.max(400, Math.min(3000, this.#scenePerspective + (e as WheelEvent).deltaY * 2)); + sceneContainer.style.perspective = `${this.#scenePerspective}px`; + }, { passive: false }); } - // Stack view flow clicks — select or delete - this.#shadow.querySelectorAll(".stack-flow").forEach(g => { - g.addEventListener("click", (e) => { + // Flow particle clicks — select flow + this.#shadow.querySelectorAll(".flow-particle").forEach(p => { + p.addEventListener("click", (e) => { e.stopPropagation(); - const flowId = g.dataset.flowId!; + const flowId = p.dataset.flowId!; this.dispatchEvent(new CustomEvent("flow-select", { detail: { flowId }, bubbles: true, @@ -623,9 +751,9 @@ export class RStackTabBar extends HTMLElement { }); // Right-click to delete flow - g.addEventListener("contextmenu", (e) => { + p.addEventListener("contextmenu", (e) => { e.preventDefault(); - const flowId = g.dataset.flowId!; + const flowId = p.dataset.flowId!; const flow = this.#flows.find(f => f.id === flowId); if (flow && confirm(`Remove ${FLOW_LABELS[flow.kind]} flow${flow.label ? `: ${flow.label}` : ""}?`)) { this.dispatchEvent(new CustomEvent("flow-remove", { @@ -636,11 +764,39 @@ export class RStackTabBar extends HTMLElement { }); }); + // Time scrubber controls + const scrubberRange = this.#shadow.getElementById("scrubber-range") as HTMLInputElement | null; + const scrubberLabel = this.#shadow.getElementById("scrubber-label"); + const scrubberPlaypause = this.#shadow.getElementById("scrubber-playpause"); + + scrubberRange?.addEventListener("input", () => { + this.#simSpeed = parseFloat(scrubberRange.value); + if (scrubberLabel) scrubberLabel.textContent = `${this.#simSpeed.toFixed(1)}x`; + // Update particle durations without full re-render + const dur = 2 / this.#simSpeed; + this.#shadow.querySelectorAll(".flow-particle").forEach(p => { + p.style.setProperty("--duration", `${dur}s`); + }); + }); + + scrubberPlaypause?.addEventListener("click", () => { + this.#simPlaying = !this.#simPlaying; + scrubberPlaypause.textContent = this.#simPlaying ? "\u23F8" : "\u25B6"; + scrubberPlaypause.title = this.#simPlaying ? "Pause" : "Play"; + const state = this.#simPlaying ? "running" : "paused"; + this.#shadow.querySelectorAll(".flow-particle").forEach(p => { + p.style.animationPlayState = state; + }); + }); + // Flow dialog events if (this.#flowDialogOpen) { - let selectedKind: FlowKind = "data"; + // Default to first compatible kind, or "data" as fallback + const compatible = this.#getCompatibleKinds(this.#flowDialogSourceId, this.#flowDialogTargetId); + const defaultKind: FlowKind = compatible.size > 0 ? [...compatible][0] : "data"; + let selectedKind: FlowKind = defaultKind; - this.#shadow.querySelectorAll(".flow-kind-btn").forEach(btn => { + this.#shadow.querySelectorAll(".flow-kind-btn:not(.disabled)").forEach(btn => { btn.addEventListener("click", () => { this.#shadow.querySelectorAll(".flow-kind-btn").forEach(b => b.classList.remove("selected")); btn.classList.add("selected"); @@ -648,9 +804,11 @@ export class RStackTabBar extends HTMLElement { }); }); - // Select "data" by default - const defaultBtn = this.#shadow.querySelector('.flow-kind-btn[data-kind="data"]'); + // Select default kind + const defaultBtn = this.#shadow.querySelector(`.flow-kind-btn[data-kind="${defaultKind}"]:not(.disabled)`) || + this.#shadow.querySelector('.flow-kind-btn:not(.disabled)'); defaultBtn?.classList.add("selected"); + if (defaultBtn) selectedKind = (defaultBtn as HTMLElement).dataset.kind as FlowKind; this.#shadow.getElementById("flow-cancel")?.addEventListener("click", () => { this.#closeFlowDialog(); @@ -1131,6 +1289,27 @@ const STYLES = ` background: color-mix(in srgb, var(--kind-color) 10%, transparent); } +.flow-kind-btn.disabled { + opacity: 0.25; + pointer-events: none; + cursor: default; +} + +.flow-kind-count { + margin-left: auto; + font-size: 0.55rem; + font-weight: 700; + background: var(--kind-color); + color: #0f172a; + width: 16px; + height: 16px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + .flow-kind-dot { width: 8px; height: 8px; diff --git a/shared/module.ts b/shared/module.ts index 079afd7..d412db4 100644 --- a/shared/module.ts +++ b/shared/module.ts @@ -1,25 +1,6 @@ import { Hono } from "hono"; import type { FlowKind } from "../lib/layer-types"; - -/** - * Feed definition — describes a data feed that a module can expose to other layers. - * Feeds are the connective tissue between layers: they let data, economic value, - * trust signals, etc. flow between rApps. - */ -export interface FeedDefinition { - /** Feed identifier (unique within the module) */ - id: string; - /** Human-readable name */ - name: string; - /** What kind of flow this feed carries */ - kind: FlowKind; - /** Description of what this feed provides */ - description: string; - /** Shape types this feed emits (e.g. "folk-note", "folk-token-mint") */ - emits?: string[]; - /** Whether this feed supports filtering */ - filterable?: boolean; -} +export type { FeedDefinition } from "../lib/layer-types"; /** * The contract every rSpace module must implement. diff --git a/website/canvas.html b/website/canvas.html index 4a80a00..f271408 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -23,16 +23,20 @@ #toolbar { position: fixed; top: 108px; /* header(56) + tab-row(36) + gap(16) */ - left: 50%; - transform: translateX(-50%); + left: 12px; display: flex; - align-items: center; + flex-direction: column; + align-items: stretch; gap: 4px; - padding: 6px 10px; + padding: 8px 6px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); z-index: 1000; + max-height: calc(100vh - 130px); + overflow-y: auto; + overflow-x: visible; + scrollbar-width: thin; } /* Dropdown group container */ @@ -41,7 +45,7 @@ } .toolbar-group-toggle { - padding: 7px 12px; + padding: 7px 10px; border: none; border-radius: 8px; background: #f1f5f9; @@ -49,6 +53,7 @@ font-size: 13px; transition: background 0.2s; white-space: nowrap; + text-align: left; } .toolbar-group-toggle:hover { @@ -63,9 +68,8 @@ .toolbar-dropdown { display: none; position: absolute; - top: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); + top: 0; + left: calc(100% + 6px); min-width: 160px; background: white; border-radius: 10px; @@ -96,18 +100,23 @@ background: #f1f5f9; } + .toolbar-dropdown button.active { + background: #14b8a6; + color: white; + } + /* Separator between sections */ .toolbar-sep { - width: 1px; - height: 24px; + width: 100%; + height: 1px; background: #e2e8f0; - margin: 0 2px; + margin: 2px 0; flex-shrink: 0; } - /* Direct toolbar buttons (Connect, Memory, Zoom) */ + /* Direct toolbar buttons (Connect, Memory, etc.) */ #toolbar > button { - padding: 7px 12px; + padding: 7px 10px; border: none; border-radius: 8px; background: #f1f5f9; @@ -115,6 +124,7 @@ font-size: 13px; transition: background 0.2s; white-space: nowrap; + text-align: left; } #toolbar > button:hover { @@ -149,6 +159,7 @@ #toolbar.collapsed { padding: 6px; + overflow: visible; } #community-info { @@ -502,7 +513,6 @@ top: 72px; left: 8px; right: 8px; - transform: none; flex-wrap: wrap; max-height: calc(100vh - 160px); overflow-y: auto; @@ -516,7 +526,7 @@ display: flex; } - /* On mobile, flatten groups into a grid */ + /* On mobile, flatten groups */ #toolbar .toolbar-group { width: 100%; } @@ -530,7 +540,6 @@ #toolbar .toolbar-dropdown { position: static; - transform: none; box-shadow: none; padding: 4px 0 4px 12px; min-width: 0; @@ -553,10 +562,7 @@ margin: 4px 0; } - /* Hide zoom/reset from toolbar (they're in #mobile-zoom) */ - #toolbar #zoom-in, - #toolbar #zoom-out, - #toolbar #reset-view, + /* Hide collapse on mobile */ #toolbar #toolbar-collapse { display: none; } @@ -601,7 +607,19 @@
- + + +
+ +
+ + + + + + +
+
@@ -706,9 +724,14 @@ - - - +
+ +
+ + + +
+
@@ -1844,6 +1867,166 @@ } }); + // ── Whiteboard drawing tools ── + let wbTool = null; // "pencil" | "sticky" | "rect" | "circle" | "line" | "eraser" | null + let wbDrawing = false; + let wbStartX = 0, wbStartY = 0; + let wbCurrentPath = []; + let wbPreviewEl = null; + const wbColor = "#1e293b"; + const wbStrokeWidth = 3; + + // SVG overlay for whiteboard drawing + const wbOverlay = document.createElementNS("http://www.w3.org/2000/svg", "svg"); + wbOverlay.id = "wb-overlay"; + wbOverlay.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:5;overflow:visible;"; + canvasContent.appendChild(wbOverlay); + + function setWbTool(tool) { + const prev = wbTool; + wbTool = wbTool === tool ? null : tool; + + // Update button active states + document.querySelectorAll("[id^='wb-']").forEach(b => b.classList.remove("active")); + if (wbTool) { + document.getElementById("wb-" + wbTool)?.classList.add("active"); + canvas.style.cursor = wbTool === "eraser" ? "crosshair" : "crosshair"; + } else { + canvas.style.cursor = ""; + } + + // Disable shape interaction when whiteboard tool is active + canvasContent.style.pointerEvents = wbTool ? "none" : ""; + wbOverlay.style.pointerEvents = wbTool ? "all" : "none"; + } + + document.getElementById("wb-pencil")?.addEventListener("click", () => setWbTool("pencil")); + document.getElementById("wb-sticky")?.addEventListener("click", () => { + // Create a sticky note as a markdown shape with yellow background + setWbTool(null); + const shape = newShape("folk-markdown", { + content: "# Sticky Note\n\nClick to edit..." + }); + if (shape) { + shape.width = 200; + shape.height = 200; + shape.style.background = "#fef08a"; + shape.style.borderRadius = "4px"; + shape.style.boxShadow = "2px 2px 8px rgba(0,0,0,0.15)"; + } + }); + document.getElementById("wb-rect")?.addEventListener("click", () => setWbTool("rect")); + document.getElementById("wb-circle")?.addEventListener("click", () => setWbTool("circle")); + document.getElementById("wb-line")?.addEventListener("click", () => setWbTool("line")); + document.getElementById("wb-eraser")?.addEventListener("click", () => setWbTool("eraser")); + + // Whiteboard pointer handlers on the SVG overlay + wbOverlay.addEventListener("pointerdown", (e) => { + if (!wbTool || wbTool === "eraser") { + if (wbTool === "eraser") { + // Find and remove the nearest SVG element under cursor + const hit = document.elementFromPoint(e.clientX, e.clientY); + if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) { + hit.remove(); + } + } + return; + } + + e.preventDefault(); + e.stopPropagation(); + wbDrawing = true; + const rect = canvasContent.getBoundingClientRect(); + wbStartX = (e.clientX - rect.left) / scale; + wbStartY = (e.clientY - rect.top) / scale; + + if (wbTool === "pencil") { + wbCurrentPath = [{ x: wbStartX, y: wbStartY }]; + wbPreviewEl = document.createElementNS("http://www.w3.org/2000/svg", "path"); + wbPreviewEl.setAttribute("fill", "none"); + wbPreviewEl.setAttribute("stroke", wbColor); + wbPreviewEl.setAttribute("stroke-width", wbStrokeWidth); + wbPreviewEl.setAttribute("stroke-linecap", "round"); + wbPreviewEl.setAttribute("stroke-linejoin", "round"); + wbOverlay.appendChild(wbPreviewEl); + } else if (wbTool === "rect") { + wbPreviewEl = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + wbPreviewEl.setAttribute("fill", "none"); + wbPreviewEl.setAttribute("stroke", wbColor); + wbPreviewEl.setAttribute("stroke-width", wbStrokeWidth); + wbOverlay.appendChild(wbPreviewEl); + } else if (wbTool === "circle") { + wbPreviewEl = document.createElementNS("http://www.w3.org/2000/svg", "ellipse"); + wbPreviewEl.setAttribute("fill", "none"); + wbPreviewEl.setAttribute("stroke", wbColor); + wbPreviewEl.setAttribute("stroke-width", wbStrokeWidth); + wbOverlay.appendChild(wbPreviewEl); + } else if (wbTool === "line") { + wbPreviewEl = document.createElementNS("http://www.w3.org/2000/svg", "line"); + wbPreviewEl.setAttribute("stroke", wbColor); + wbPreviewEl.setAttribute("stroke-width", wbStrokeWidth); + wbPreviewEl.setAttribute("stroke-linecap", "round"); + wbPreviewEl.setAttribute("x1", wbStartX); + wbPreviewEl.setAttribute("y1", wbStartY); + wbPreviewEl.setAttribute("x2", wbStartX); + wbPreviewEl.setAttribute("y2", wbStartY); + wbOverlay.appendChild(wbPreviewEl); + } + + wbOverlay.setPointerCapture(e.pointerId); + }); + + wbOverlay.addEventListener("pointermove", (e) => { + if (!wbDrawing || !wbPreviewEl) return; + + const rect = canvasContent.getBoundingClientRect(); + const cx = (e.clientX - rect.left) / scale; + const cy = (e.clientY - rect.top) / scale; + + if (wbTool === "pencil") { + wbCurrentPath.push({ x: cx, y: cy }); + const d = wbCurrentPath.map((p, i) => `${i === 0 ? "M" : "L"} ${p.x} ${p.y}`).join(" "); + wbPreviewEl.setAttribute("d", d); + } else if (wbTool === "rect") { + const x = Math.min(wbStartX, cx); + const y = Math.min(wbStartY, cy); + const w = Math.abs(cx - wbStartX); + const h = Math.abs(cy - wbStartY); + wbPreviewEl.setAttribute("x", x); + wbPreviewEl.setAttribute("y", y); + wbPreviewEl.setAttribute("width", w); + wbPreviewEl.setAttribute("height", h); + } else if (wbTool === "circle") { + const cxe = (wbStartX + cx) / 2; + const cye = (wbStartY + cy) / 2; + const rx = Math.abs(cx - wbStartX) / 2; + const ry = Math.abs(cy - wbStartY) / 2; + wbPreviewEl.setAttribute("cx", cxe); + wbPreviewEl.setAttribute("cy", cye); + wbPreviewEl.setAttribute("rx", rx); + wbPreviewEl.setAttribute("ry", ry); + } else if (wbTool === "line") { + wbPreviewEl.setAttribute("x2", cx); + wbPreviewEl.setAttribute("y2", cy); + } + }); + + wbOverlay.addEventListener("pointerup", (e) => { + if (!wbDrawing) return; + wbDrawing = false; + wbPreviewEl = null; + wbCurrentPath = []; + }); + + // Eraser: click on existing SVG strokes to delete them + wbOverlay.addEventListener("click", (e) => { + if (wbTool !== "eraser") return; + const hit = e.target; + if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) { + hit.remove(); + } + }); + // Memory panel — browse and remember forgotten shapes const memoryPanel = document.getElementById("memory-panel"); const memoryList = document.getElementById("memory-list"); @@ -1861,6 +2044,8 @@ "folk-token-mint": "🪙", "folk-token-ledger": "📒", "folk-choice-vote": "☑", "folk-choice-rank": "📊", "folk-choice-spider": "🕸", "folk-social-post": "📱", + "folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️", + "folk-freecad": "📐", "folk-kicad": "🔌", "folk-rapp": "📱", "folk-feed": "🔄", "folk-arrow": "↗️", }; @@ -1976,8 +2161,9 @@ if (window.innerWidth > 768) return; const btn = e.target.closest("button"); if (!btn) return; - // Keep open for connect, memory, zoom, group toggles, collapse - const keepOpen = ["new-arrow", "toggle-memory", "zoom-in", "zoom-out", "reset-view", "toolbar-collapse"]; + // Keep open for connect, memory, group toggles, collapse, whiteboard tools + const keepOpen = ["new-arrow", "toggle-memory", "zoom-in", "zoom-out", "reset-view", "toolbar-collapse", + "wb-pencil", "wb-sticky", "wb-rect", "wb-circle", "wb-line", "wb-eraser"]; if (btn.classList.contains("toolbar-group-toggle")) return; if (!keepOpen.includes(btn.id)) { toolbarEl.classList.remove("mobile-open"); @@ -2016,7 +2202,7 @@ const collapseBtn = document.getElementById("toolbar-collapse"); collapseBtn.addEventListener("click", () => { const isCollapsed = toolbarEl.classList.toggle("collapsed"); - collapseBtn.textContent = isCollapsed ? "▶" : "◀"; + collapseBtn.textContent = isCollapsed ? "▼" : "▲"; collapseBtn.title = isCollapsed ? "Expand toolbar" : "Minimize toolbar"; });