feat: vertical toolbar, whiteboard tools, zoom dropdown, context-aware MI bar

- Convert canvas toolbar from horizontal (top center) to vertical (left side)
  with dropdowns opening to the right
- Add whiteboard "Draw" toolbar group: pencil, sticky note, rectangle, circle,
  line, eraser — renders SVG strokes on canvas overlay
- Nest zoom controls (in/out/reset) under a "Zoom" dropdown group
- Enhance rstack-mi to gather page context (open shapes, active tab, page title)
  and send to /api/mi/ask for context-aware responses
- Move FeedDefinition to lib/layer-types.ts, add feeds/acceptsFeeds to modules
- Extend rstack-tab-bar with feed compatibility helpers and 3D scene state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-27 14:29:24 -08:00
parent b4dc1f6f08
commit f8bd09dbac
16 changed files with 752 additions and 171 deletions

View File

@ -40,6 +40,28 @@ export const FLOW_LABELS: Record<FlowKind, string> = {
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 {

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = [

View File

@ -34,14 +34,14 @@ export class RStackMi extends HTMLElement {
<div class="mi-bar" id="mi-bar">
<span class="mi-icon">&#10023;</span>
<input class="mi-input" id="mi-input" type="text"
placeholder="Ask mi anything..." autocomplete="off" />
placeholder="Ask mi anything — setup, navigation, what's possible..." autocomplete="off" />
</div>
<div class="mi-panel" id="mi-panel">
<div class="mi-messages" id="mi-messages">
<div class="mi-welcome">
<span class="mi-welcome-icon">&#10023;</span>
<p>Hi, I'm <strong>mi</strong> your guide to rSpace.</p>
<p class="mi-welcome-sub">Ask me about any rApp, how to find things, or what you can do here.</p>
<p>Hi, I'm <strong>mi</strong> your mycelial intelligence guide.</p>
<p class="mi-welcome-sub">I see everything you have open and can help with setup, navigation, connecting rApps, or anything else.</p>
</div>
</div>
</div>
@ -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<string, any> {
const ctx: Record<string, any> = {};
// 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,
});

View File

@ -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<FlowKind> {
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<FlowKind> {
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<FlowKind> {
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<FlowKind>();
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<string, { x: number; y: number; w: number; h: number }>();
// Build layer planes
let layersHtml = "";
const layerZMap = new Map<string, number>();
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 += `
<g class="stack-layer ${isActive ? "stack-layer--active" : ""}"
data-layer-id="${layer.id}">
<rect x="${x}" y="${y}" width="${w}" height="${layerHeight}"
rx="8" fill="${color}20" stroke="${color}" stroke-width="${isActive ? 2 : 1}"
style="cursor:pointer" />
<text x="${x + 14}" y="${y + layerHeight / 2 + 5}"
fill="${color}" font-size="13" font-weight="600">${layer.label}</text>
<text x="${x + w - 14}" y="${y + layerHeight / 2 + 5}"
fill="${color}80" font-size="11" text-anchor="end">${badge?.badge || layer.moduleId}</text>
</g>
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 =>
`<span class="feed-port feed-port--out" style="background:${FLOW_COLORS[k]}" title="${FLOW_LABELS[k]} out"></span>`
).join("");
const inPorts = [...inputKinds].map(k =>
`<span class="feed-port feed-port--in" style="background:${FLOW_COLORS[k]}" title="${FLOW_LABELS[k]} in"></span>`
).join("");
const containedHtml = containedFeeds.length > 0 ? `
<div class="layer-contained">
${containedFeeds.map(f => `
<span class="contained-feed" style="--feed-color:${FLOW_COLORS[f.kind]}">
<span class="contained-lock">\uD83D\uDD12</span>
${f.name}
</span>
`).join("")}
</div>
` : "";
layersHtml += `
<div class="layer-plane ${isActive ? "layer-plane--active" : ""}"
data-layer-id="${layer.id}"
style="--layer-color:${color}; transform: translateZ(${z}px);">
<div class="layer-header">
<div class="layer-ports layer-ports--in">${inPorts}</div>
<span class="layer-badge" style="background:${color}">${badge?.badge || layer.moduleId.slice(0, 2)}</span>
<span class="layer-name">${layer.label}</span>
<div class="layer-ports layer-ports--out">${outPorts}</div>
</div>
${containedHtml}
</div>
`;
});
// 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 += `
<g class="stack-flow" data-flow-id="${flow.id}">
<path d="M ${rightX} ${srcY}
C ${rightX + arcOut} ${srcY},
${rightX + arcOut} ${tgtY},
${rightX} ${tgtY}"
fill="none" stroke="${color}" stroke-width="${strokeWidth}"
stroke-dasharray="${flow.active ? "none" : "4 4"}"
opacity="${flow.active ? 0.8 : 0.4}"
style="cursor:pointer" />
<circle cx="${rightX}" cy="${srcY}" r="3" fill="${color}" />
<circle cx="${rightX}" cy="${tgtY}" r="3" fill="${color}" />
${flow.label ? `
<text x="${rightX + arcOut + 6}" y="${midY + 4}"
fill="${color}" font-size="10" opacity="0.7">${flow.label}</text>
` : ""}
<!-- Arrow head -->
<polygon points="${rightX},${tgtY} ${rightX + 7},${tgtY - 4 * direction} ${rightX + 7},${tgtY + 4 * direction}"
fill="${color}" opacity="${flow.active ? 0.8 : 0.4}" />
</g>
`;
for (let p = 0; p < particleCount; p++) {
const delay = (p / particleCount) * animDuration;
particlesHtml += `
<div class="flow-particle"
data-flow-id="${flow.id}"
style="--src-z:${srcZ}px; --tgt-z:${tgtZ}px; --color:${color};
--duration:${animDuration}s; --delay:${delay}s;
animation-play-state:${this.#simPlaying ? "running" : "paused"};"></div>
`;
}
}
// 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 += `
<circle cx="${legendX}" cy="${totalHeight - 16}" r="4" fill="${color}" />
<text x="${legendX + 8}" y="${totalHeight - 12}" fill="${color}" font-size="10">${FLOW_LABELS[kind]}</text>
`;
legendX += FLOW_LABELS[kind].length * 7 + 24;
}
const legendHtml = [...activeKinds].map(k => `
<span class="legend-item">
<span class="legend-dot" style="background:${FLOW_COLORS[k]}"></span>
${FLOW_LABELS[k]}
</span>
`).join("");
// Flow creation hint
const hintSvg = this.#layers.length >= 2 ? `
<text x="${width / 2}" y="${totalHeight - 2}" fill="#475569" font-size="9" text-anchor="middle">
Drag from one layer to another to create a flow
</text>
` : "";
// Time scrubber
const scrubberHtml = `
<div class="time-scrubber">
<button class="scrubber-playpause" id="scrubber-playpause" title="${this.#simPlaying ? "Pause" : "Play"}">
${this.#simPlaying ? "\u23F8" : "\u25B6"}
</button>
<input type="range" class="scrubber-range" id="scrubber-range"
min="0.1" max="5" step="0.1" value="${this.#simSpeed}" />
<span class="scrubber-label" id="scrubber-label">${this.#simSpeed.toFixed(1)}x</span>
</div>
`;
return `
<div class="stack-view">
<svg width="100%" height="${totalHeight}" viewBox="0 0 ${width} ${totalHeight}">
${flowsSvg}
${layersSvg}
${legendSvg}
${hintSvg}
</svg>
<div class="stack-view-3d" id="stack-3d"
style="perspective:${this.#scenePerspective}px;">
<div class="stack-scene" id="stack-scene"
style="transform: rotateX(${this.#sceneRotX}deg) rotateZ(${this.#sceneRotZ}deg);">
${layersHtml}
${particlesHtml}
</div>
</div>
<div class="stack-legend">${legendHtml}</div>
${scrubberHtml}
${this.#layers.length >= 2 ? `<div class="stack-hint">Drag between layers to create a flow \u00b7 Drag empty space to orbit</div>` : ""}
${this.#flowDialogOpen ? this.#renderFlowDialog() : ""}
</div>
`;
@ -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<FlowKind, number>();
if (srcMod?.feeds) {
for (const f of srcMod.feeds) {
feedCountByKind.set(f.kind, (feedCountByKind.get(f.kind) || 0) + 1);
}
}
return `
<div class="flow-dialog" id="flow-dialog">
<div class="flow-dialog-header">New Flow</div>
<div class="flow-dialog-route">
<span class="flow-dialog-badge" style="background:${srcBadge?.color || "#94a3b8"}">${srcBadge?.badge || srcLayer.moduleId}</span>
<span class="flow-dialog-arrow"></span>
<span class="flow-dialog-arrow">\u2192</span>
<span class="flow-dialog-badge" style="background:${tgtBadge?.color || "#94a3b8"}">${tgtBadge?.badge || tgtLayer.moduleId}</span>
</div>
<div class="flow-dialog-field">
<label class="flow-dialog-label">Flow Type</label>
<div class="flow-kind-grid" id="flow-kind-grid">
${kinds.map(k => `
<button class="flow-kind-btn" data-kind="${k}" style="--kind-color:${FLOW_COLORS[k]}">
${kinds.map(k => {
const isCompatible = !hasModuleData || compatible.has(k);
const feedCount = feedCountByKind.get(k) || 0;
return `
<button class="flow-kind-btn ${!isCompatible ? "disabled" : ""}"
data-kind="${k}"
style="--kind-color:${FLOW_COLORS[k]}"
${!isCompatible ? 'disabled aria-disabled="true"' : ""}>
<span class="flow-kind-dot" style="background:${FLOW_COLORS[k]}"></span>
${FLOW_LABELS[k]}
${isCompatible && feedCount > 0 ? `<span class="flow-kind-count">${feedCount}</span>` : ""}
</button>
`).join("")}
`;
}).join("")}
</div>
</div>
<div class="flow-dialog-field">
@ -556,13 +645,13 @@ export class RStackTabBar extends HTMLElement {
this.#render();
});
// Stack view layer clicks + drag-to-connect
this.#shadow.querySelectorAll<SVGGElement>(".stack-layer").forEach(g => {
const layerId = g.dataset.layerId!;
// 3D Stack view: layer clicks + drag-to-connect
this.#shadow.querySelectorAll<HTMLElement>(".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<SVGElement>(".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<SVGGElement>(".stack-flow").forEach(g => {
g.addEventListener("click", (e) => {
// Flow particle clicks — select flow
this.#shadow.querySelectorAll<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".flow-kind-btn").forEach(btn => {
this.#shadow.querySelectorAll<HTMLElement>(".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;

View File

@ -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.

View File

@ -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 @@
</div>
<div id="toolbar">
<button id="toolbar-collapse" title="Minimize toolbar"></button>
<button id="toolbar-collapse" title="Minimize toolbar"></button>
<div class="toolbar-group">
<button class="toolbar-group-toggle">✏️ Draw</button>
<div class="toolbar-dropdown">
<button id="wb-pencil" title="Freehand Pencil">✏️ Pencil</button>
<button id="wb-sticky" title="Sticky Note">📌 Sticky Note</button>
<button id="wb-rect" title="Rectangle">▢ Rectangle</button>
<button id="wb-circle" title="Circle">○ Circle</button>
<button id="wb-line" title="Line"> Line</button>
<button id="wb-eraser" title="Eraser">🧹 Eraser</button>
</div>
</div>
<div class="toolbar-group">
<button class="toolbar-group-toggle">📝 Create</button>
@ -706,9 +724,14 @@
<span class="toolbar-sep"></span>
<button id="zoom-in" title="Zoom In">+</button>
<button id="zoom-out" title="Zoom Out"></button>
<button id="reset-view" title="Reset View">Reset</button>
<div class="toolbar-group">
<button class="toolbar-group-toggle">🔍 Zoom</button>
<div class="toolbar-dropdown">
<button id="zoom-in" title="Zoom In">+ Zoom In</button>
<button id="zoom-out" title="Zoom Out"> Zoom Out</button>
<button id="reset-view" title="Reset View">⟳ Reset View</button>
</div>
</div>
</div>
<div id="memory-panel">
@ -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";
});