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", 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 ── // ── Layer definition ──
export interface Layer { export interface Layer {

View File

@ -301,6 +301,22 @@ export const booksModule: RSpaceModule = {
description: "Community PDF library with flipbook reader", description: "Community PDF library with flipbook reader",
routes, routes,
standaloneDomain: "rbooks.online", 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) { async onSpaceCreate(spaceSlug: string) {
// Books are global, not space-scoped (for now). No-op. // 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", description: "Temporal coordination calendar with lunar, solar, and seasonal systems",
routes, routes,
standaloneDomain: "rcal.online", 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", description: "Cosmolocal print-on-demand shop",
routes, routes,
standaloneDomain: "rcart.online", 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", description: "File sharing, share links, and memory cards",
routes, routes,
standaloneDomain: "rfiles.online", 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", description: "Deploy and manage Discourse forums",
routes, routes,
standaloneDomain: "rforum.online", 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", description: "Collaborative email with multisig approval",
routes, routes,
standaloneDomain: "rinbox.online", 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", description: "Real-time collaborative location sharing and indoor/outdoor maps",
routes, routes,
standaloneDomain: "rmaps.online", 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", description: "Drop in a document, get a pocket book",
routes, routes,
standaloneDomain: "rpubs.online", 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", description: "Design print-ready swag: stickers, posters, tees",
routes, routes,
standaloneDomain: "rswag.online", 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", description: "Community video hosting & live streaming",
routes, routes,
standaloneDomain: "rtube.online", 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"; const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
app.post("/api/mi/ask", async (c) => { 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); if (!query) return c.json({ error: "Query required" }, 400);
// Build rApp context for the system prompt // 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}`) .map((m) => `- **${m.name}** (${m.id}): ${m.icon} ${m.description}`)
.join("\n"); .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 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 ## Available rApps
${moduleList} ${moduleList}
## Current Context ## Current Context
- Space: ${space || "none selected"} ${contextSection}
- Active rApp: ${currentModule || "none"}
## Guidelines ## Guidelines
- Be concise and helpful. Keep responses short (2-4 sentences) unless the user asks for detail. - 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. - When suggesting actions, reference specific rApps by name and explain how they connect.
- You can suggest navigating to /:space/:moduleId paths. - 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. - 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 // Build conversation for Ollama
const ollamaMessages = [ const ollamaMessages = [

View File

@ -34,14 +34,14 @@ export class RStackMi extends HTMLElement {
<div class="mi-bar" id="mi-bar"> <div class="mi-bar" id="mi-bar">
<span class="mi-icon">&#10023;</span> <span class="mi-icon">&#10023;</span>
<input class="mi-input" id="mi-input" type="text" <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>
<div class="mi-panel" id="mi-panel"> <div class="mi-panel" id="mi-panel">
<div class="mi-messages" id="mi-messages"> <div class="mi-messages" id="mi-messages">
<div class="mi-welcome"> <div class="mi-welcome">
<span class="mi-welcome-icon">&#10023;</span> <span class="mi-welcome-icon">&#10023;</span>
<p>Hi, I'm <strong>mi</strong> your guide to rSpace.</p> <p>Hi, I'm <strong>mi</strong> your mycelial intelligence guide.</p>
<p class="mi-welcome-sub">Ask me about any rApp, how to find things, or what you can do here.</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> </div>
</div> </div>
@ -81,6 +81,42 @@ export class RStackMi extends HTMLElement {
bar.addEventListener("click", (e) => e.stopPropagation()); 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) { async #ask(query: string) {
const panel = this.#shadow.getElementById("mi-panel")!; const panel = this.#shadow.getElementById("mi-panel")!;
const messagesEl = this.#shadow.getElementById("mi-messages")!; const messagesEl = this.#shadow.getElementById("mi-messages")!;
@ -103,6 +139,7 @@ export class RStackMi extends HTMLElement {
try { try {
const token = getAccessToken(); const token = getAccessToken();
const context = this.#gatherContext();
const res = await fetch("/api/mi/ask", { const res = await fetch("/api/mi/ask", {
method: "POST", method: "POST",
headers: { headers: {
@ -112,8 +149,9 @@ export class RStackMi extends HTMLElement {
body: JSON.stringify({ body: JSON.stringify({
query, query,
messages: this.#messages.slice(0, -1).slice(-10), messages: this.#messages.slice(0, -1).slice(-10),
space: document.querySelector("rstack-space-switcher")?.getAttribute("current") || "", space: context.space,
module: document.querySelector("rstack-app-switcher")?.getAttribute("current") || "", module: context.module,
context,
}), }),
signal: this.#abortController.signal, signal: this.#abortController.signal,
}); });

View File

@ -20,7 +20,7 @@
* flow-select fired when a flow is clicked in stack view { detail: { flowId } } * 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"; import { FLOW_COLORS, FLOW_LABELS } from "../../lib/layer-types";
// Badge info for tab display // Badge info for tab display
@ -78,6 +78,8 @@ export interface TabBarModule {
name: string; name: string;
icon: string; icon: string;
description: string; description: string;
feeds?: FeedDefinition[];
acceptsFeeds?: FlowKind[];
} }
export class RStackTabBar extends HTMLElement { export class RStackTabBar extends HTMLElement {
@ -93,6 +95,15 @@ export class RStackTabBar extends HTMLElement {
#flowDialogOpen = false; #flowDialogOpen = false;
#flowDialogSourceId = ""; #flowDialogSourceId = "";
#flowDialogTargetId = ""; #flowDialogTargetId = "";
// 3D scene state
#sceneRotX = 55;
#sceneRotZ = -15;
#scenePerspective = 1200;
#orbitDragging = false;
#orbitLastX = 0;
#orbitLastY = 0;
#simSpeed = 1;
#simPlaying = true;
constructor() { constructor() {
super(); 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 ──
#render() { #render() {
@ -280,108 +342,115 @@ export class RStackTabBar extends HTMLElement {
const layerCount = this.#layers.length; const layerCount = this.#layers.length;
if (layerCount === 0) return ""; if (layerCount === 0) return "";
const layerHeight = 60; const layerSpacing = 80;
const layerGap = 40; const animDuration = 2 / this.#simSpeed;
const totalHeight = layerCount * layerHeight + (layerCount - 1) * layerGap + 80;
const width = 600;
// Build layer rects and flow arcs // Build layer planes
let layersSvg = ""; let layersHtml = "";
let flowsSvg = ""; const layerZMap = new Map<string, number>();
const layerPositions = new Map<string, { x: number; y: number; w: number; h: number }>();
this.#layers.forEach((layer, i) => { this.#layers.forEach((layer, i) => {
const y = 40 + i * (layerHeight + layerGap); const z = i * layerSpacing;
const x = 40; layerZMap.set(layer.id, z);
const w = width - 80;
const badge = MODULE_BADGES[layer.moduleId]; const badge = MODULE_BADGES[layer.moduleId];
const color = layer.color || badge?.color || "#94a3b8"; const color = layer.color || badge?.color || "#94a3b8";
layerPositions.set(layer.id, { x, y, w, h: layerHeight });
const isActive = layer.id === this.active; const isActive = layer.id === this.active;
layersSvg += ` const containedFeeds = this.#getContainedFeeds(layer.id);
<g class="stack-layer ${isActive ? "stack-layer--active" : ""}"
data-layer-id="${layer.id}"> // Feed port indicators — output kinds (right side) and input kinds (left side)
<rect x="${x}" y="${y}" width="${w}" height="${layerHeight}" const outputKinds = this.#getModuleOutputKinds(layer.moduleId);
rx="8" fill="${color}20" stroke="${color}" stroke-width="${isActive ? 2 : 1}" const inputKinds = this.#getModuleInputKinds(layer.moduleId);
style="cursor:pointer" />
<text x="${x + 14}" y="${y + layerHeight / 2 + 5}" const outPorts = [...outputKinds].map(k =>
fill="${color}" font-size="13" font-weight="600">${layer.label}</text> `<span class="feed-port feed-port--out" style="background:${FLOW_COLORS[k]}" title="${FLOW_LABELS[k]} out"></span>`
<text x="${x + w - 14}" y="${y + layerHeight / 2 + 5}" ).join("");
fill="${color}80" font-size="11" text-anchor="end">${badge?.badge || layer.moduleId}</text> const inPorts = [...inputKinds].map(k =>
</g> `<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) { for (const flow of this.#flows) {
const src = layerPositions.get(flow.sourceLayerId); if (!flow.active) continue;
const tgt = layerPositions.get(flow.targetLayerId); const srcZ = layerZMap.get(flow.sourceLayerId);
if (!src || !tgt) continue; 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 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 for (let p = 0; p < particleCount; p++) {
const direction = tgtY > srcY ? 1 : -1; const delay = (p / particleCount) * animDuration;
const midY = (srcY + tgtY) / 2; particlesHtml += `
<div class="flow-particle"
flowsSvg += ` data-flow-id="${flow.id}"
<g class="stack-flow" data-flow-id="${flow.id}"> style="--src-z:${srcZ}px; --tgt-z:${tgtZ}px; --color:${color};
<path d="M ${rightX} ${srcY} --duration:${animDuration}s; --delay:${delay}s;
C ${rightX + arcOut} ${srcY}, animation-play-state:${this.#simPlaying ? "running" : "paused"};"></div>
${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>
`;
} }
// Flow kind legend // Flow legend
const activeKinds = new Set(this.#flows.map(f => f.kind)); const activeKinds = new Set(this.#flows.map(f => f.kind));
let legendSvg = ""; const legendHtml = [...activeKinds].map(k => `
let legendX = 50; <span class="legend-item">
for (const kind of activeKinds) { <span class="legend-dot" style="background:${FLOW_COLORS[k]}"></span>
const color = FLOW_COLORS[kind]; ${FLOW_LABELS[k]}
legendSvg += ` </span>
<circle cx="${legendX}" cy="${totalHeight - 16}" r="4" fill="${color}" /> `).join("");
<text x="${legendX + 8}" y="${totalHeight - 12}" fill="${color}" font-size="10">${FLOW_LABELS[kind]}</text>
`;
legendX += FLOW_LABELS[kind].length * 7 + 24;
}
// Flow creation hint // Time scrubber
const hintSvg = this.#layers.length >= 2 ? ` const scrubberHtml = `
<text x="${width / 2}" y="${totalHeight - 2}" fill="#475569" font-size="9" text-anchor="middle"> <div class="time-scrubber">
Drag from one layer to another to create a flow <button class="scrubber-playpause" id="scrubber-playpause" title="${this.#simPlaying ? "Pause" : "Play"}">
</text> ${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 ` return `
<div class="stack-view"> <div class="stack-view">
<svg width="100%" height="${totalHeight}" viewBox="0 0 ${width} ${totalHeight}"> <div class="stack-view-3d" id="stack-3d"
${flowsSvg} style="perspective:${this.#scenePerspective}px;">
${layersSvg} <div class="stack-scene" id="stack-scene"
${legendSvg} style="transform: rotateX(${this.#sceneRotX}deg) rotateZ(${this.#sceneRotZ}deg);">
${hintSvg} ${layersHtml}
</svg> ${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() : ""} ${this.#flowDialogOpen ? this.#renderFlowDialog() : ""}
</div> </div>
`; `;
@ -398,24 +467,44 @@ export class RStackTabBar extends HTMLElement {
const tgtBadge = MODULE_BADGES[tgtLayer.moduleId]; const tgtBadge = MODULE_BADGES[tgtLayer.moduleId];
const kinds: FlowKind[] = ["economic", "trust", "data", "attention", "governance", "resource"]; 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 ` return `
<div class="flow-dialog" id="flow-dialog"> <div class="flow-dialog" id="flow-dialog">
<div class="flow-dialog-header">New Flow</div> <div class="flow-dialog-header">New Flow</div>
<div class="flow-dialog-route"> <div class="flow-dialog-route">
<span class="flow-dialog-badge" style="background:${srcBadge?.color || "#94a3b8"}">${srcBadge?.badge || srcLayer.moduleId}</span> <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> <span class="flow-dialog-badge" style="background:${tgtBadge?.color || "#94a3b8"}">${tgtBadge?.badge || tgtLayer.moduleId}</span>
</div> </div>
<div class="flow-dialog-field"> <div class="flow-dialog-field">
<label class="flow-dialog-label">Flow Type</label> <label class="flow-dialog-label">Flow Type</label>
<div class="flow-kind-grid" id="flow-kind-grid"> <div class="flow-kind-grid" id="flow-kind-grid">
${kinds.map(k => ` ${kinds.map(k => {
<button class="flow-kind-btn" data-kind="${k}" style="--kind-color:${FLOW_COLORS[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> <span class="flow-kind-dot" style="background:${FLOW_COLORS[k]}"></span>
${FLOW_LABELS[k]} ${FLOW_LABELS[k]}
${isCompatible && feedCount > 0 ? `<span class="flow-kind-count">${feedCount}</span>` : ""}
</button> </button>
`).join("")} `;
}).join("")}
</div> </div>
</div> </div>
<div class="flow-dialog-field"> <div class="flow-dialog-field">
@ -556,13 +645,13 @@ export class RStackTabBar extends HTMLElement {
this.#render(); this.#render();
}); });
// Stack view layer clicks + drag-to-connect // 3D Stack view: layer clicks + drag-to-connect
this.#shadow.querySelectorAll<SVGGElement>(".stack-layer").forEach(g => { this.#shadow.querySelectorAll<HTMLElement>(".layer-plane").forEach(plane => {
const layerId = g.dataset.layerId!; const layerId = plane.dataset.layerId!;
// Click to switch layer // Click to switch layer
g.addEventListener("click", () => { plane.addEventListener("click", (e) => {
if (this.#flowDragSource) return; // ignore if mid-drag if (this.#flowDragSource || this.#orbitDragging) return;
const layer = this.#layers.find(l => l.id === layerId); const layer = this.#layers.find(l => l.id === layerId);
if (layer) { if (layer) {
this.active = layerId; this.active = layerId;
@ -574,48 +663,87 @@ export class RStackTabBar extends HTMLElement {
}); });
// Drag-to-connect: mousedown starts a flow drag // Drag-to-connect: mousedown starts a flow drag
g.addEventListener("mousedown", (e) => { plane.addEventListener("mousedown", (e) => {
if ((e as MouseEvent).button !== 0) return; if ((e as MouseEvent).button !== 0) return;
e.stopPropagation(); // prevent orbit
this.#flowDragSource = layerId; 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) { if (this.#flowDragSource && this.#flowDragSource !== layerId) {
this.#flowDragTarget = 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) { if (this.#flowDragTarget === layerId) {
this.#flowDragTarget = null; this.#flowDragTarget = null;
g.classList.remove("flow-drag-target"); plane.classList.remove("flow-drag-target");
} }
}); });
}); });
// Global mouseup completes the flow drag // 3D scene: orbit controls (drag on empty space to rotate)
const svgEl = this.#shadow.querySelector<SVGElement>(".stack-view svg"); const sceneContainer = this.#shadow.getElementById("stack-3d");
if (svgEl) { if (sceneContainer) {
svgEl.addEventListener("mouseup", () => { 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) { if (this.#flowDragSource && this.#flowDragTarget) {
this.#openFlowDialog(this.#flowDragSource, this.#flowDragTarget); this.#openFlowDialog(this.#flowDragSource, this.#flowDragTarget);
} }
// Reset drag state
this.#flowDragSource = null; this.#flowDragSource = null;
this.#flowDragTarget = null; this.#flowDragTarget = null;
this.#shadow.querySelectorAll(".flow-drag-source, .flow-drag-target").forEach(el => { this.#shadow.querySelectorAll(".flow-drag-source, .flow-drag-target").forEach(el => {
el.classList.remove("flow-drag-source", "flow-drag-target"); 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 // Flow particle clicks — select flow
this.#shadow.querySelectorAll<SVGGElement>(".stack-flow").forEach(g => { this.#shadow.querySelectorAll<HTMLElement>(".flow-particle").forEach(p => {
g.addEventListener("click", (e) => { p.addEventListener("click", (e) => {
e.stopPropagation(); e.stopPropagation();
const flowId = g.dataset.flowId!; const flowId = p.dataset.flowId!;
this.dispatchEvent(new CustomEvent("flow-select", { this.dispatchEvent(new CustomEvent("flow-select", {
detail: { flowId }, detail: { flowId },
bubbles: true, bubbles: true,
@ -623,9 +751,9 @@ export class RStackTabBar extends HTMLElement {
}); });
// Right-click to delete flow // Right-click to delete flow
g.addEventListener("contextmenu", (e) => { p.addEventListener("contextmenu", (e) => {
e.preventDefault(); e.preventDefault();
const flowId = g.dataset.flowId!; const flowId = p.dataset.flowId!;
const flow = this.#flows.find(f => f.id === flowId); const flow = this.#flows.find(f => f.id === flowId);
if (flow && confirm(`Remove ${FLOW_LABELS[flow.kind]} flow${flow.label ? `: ${flow.label}` : ""}?`)) { if (flow && confirm(`Remove ${FLOW_LABELS[flow.kind]} flow${flow.label ? `: ${flow.label}` : ""}?`)) {
this.dispatchEvent(new CustomEvent("flow-remove", { 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 // Flow dialog events
if (this.#flowDialogOpen) { 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", () => { btn.addEventListener("click", () => {
this.#shadow.querySelectorAll(".flow-kind-btn").forEach(b => b.classList.remove("selected")); this.#shadow.querySelectorAll(".flow-kind-btn").forEach(b => b.classList.remove("selected"));
btn.classList.add("selected"); btn.classList.add("selected");
@ -648,9 +804,11 @@ export class RStackTabBar extends HTMLElement {
}); });
}); });
// Select "data" by default // Select default kind
const defaultBtn = this.#shadow.querySelector('.flow-kind-btn[data-kind="data"]'); 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"); defaultBtn?.classList.add("selected");
if (defaultBtn) selectedKind = (defaultBtn as HTMLElement).dataset.kind as FlowKind;
this.#shadow.getElementById("flow-cancel")?.addEventListener("click", () => { this.#shadow.getElementById("flow-cancel")?.addEventListener("click", () => {
this.#closeFlowDialog(); this.#closeFlowDialog();
@ -1131,6 +1289,27 @@ const STYLES = `
background: color-mix(in srgb, var(--kind-color) 10%, transparent); 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 { .flow-kind-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;

View File

@ -1,25 +1,6 @@
import { Hono } from "hono"; import { Hono } from "hono";
import type { FlowKind } from "../lib/layer-types"; import type { FlowKind } from "../lib/layer-types";
export type { FeedDefinition } 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;
}
/** /**
* The contract every rSpace module must implement. * The contract every rSpace module must implement.

View File

@ -23,16 +23,20 @@
#toolbar { #toolbar {
position: fixed; position: fixed;
top: 108px; /* header(56) + tab-row(36) + gap(16) */ top: 108px; /* header(56) + tab-row(36) + gap(16) */
left: 50%; left: 12px;
transform: translateX(-50%);
display: flex; display: flex;
align-items: center; flex-direction: column;
align-items: stretch;
gap: 4px; gap: 4px;
padding: 6px 10px; padding: 8px 6px;
background: white; background: white;
border-radius: 12px; border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
z-index: 1000; z-index: 1000;
max-height: calc(100vh - 130px);
overflow-y: auto;
overflow-x: visible;
scrollbar-width: thin;
} }
/* Dropdown group container */ /* Dropdown group container */
@ -41,7 +45,7 @@
} }
.toolbar-group-toggle { .toolbar-group-toggle {
padding: 7px 12px; padding: 7px 10px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
background: #f1f5f9; background: #f1f5f9;
@ -49,6 +53,7 @@
font-size: 13px; font-size: 13px;
transition: background 0.2s; transition: background 0.2s;
white-space: nowrap; white-space: nowrap;
text-align: left;
} }
.toolbar-group-toggle:hover { .toolbar-group-toggle:hover {
@ -63,9 +68,8 @@
.toolbar-dropdown { .toolbar-dropdown {
display: none; display: none;
position: absolute; position: absolute;
top: calc(100% + 6px); top: 0;
left: 50%; left: calc(100% + 6px);
transform: translateX(-50%);
min-width: 160px; min-width: 160px;
background: white; background: white;
border-radius: 10px; border-radius: 10px;
@ -96,18 +100,23 @@
background: #f1f5f9; background: #f1f5f9;
} }
.toolbar-dropdown button.active {
background: #14b8a6;
color: white;
}
/* Separator between sections */ /* Separator between sections */
.toolbar-sep { .toolbar-sep {
width: 1px; width: 100%;
height: 24px; height: 1px;
background: #e2e8f0; background: #e2e8f0;
margin: 0 2px; margin: 2px 0;
flex-shrink: 0; flex-shrink: 0;
} }
/* Direct toolbar buttons (Connect, Memory, Zoom) */ /* Direct toolbar buttons (Connect, Memory, etc.) */
#toolbar > button { #toolbar > button {
padding: 7px 12px; padding: 7px 10px;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
background: #f1f5f9; background: #f1f5f9;
@ -115,6 +124,7 @@
font-size: 13px; font-size: 13px;
transition: background 0.2s; transition: background 0.2s;
white-space: nowrap; white-space: nowrap;
text-align: left;
} }
#toolbar > button:hover { #toolbar > button:hover {
@ -149,6 +159,7 @@
#toolbar.collapsed { #toolbar.collapsed {
padding: 6px; padding: 6px;
overflow: visible;
} }
#community-info { #community-info {
@ -502,7 +513,6 @@
top: 72px; top: 72px;
left: 8px; left: 8px;
right: 8px; right: 8px;
transform: none;
flex-wrap: wrap; flex-wrap: wrap;
max-height: calc(100vh - 160px); max-height: calc(100vh - 160px);
overflow-y: auto; overflow-y: auto;
@ -516,7 +526,7 @@
display: flex; display: flex;
} }
/* On mobile, flatten groups into a grid */ /* On mobile, flatten groups */
#toolbar .toolbar-group { #toolbar .toolbar-group {
width: 100%; width: 100%;
} }
@ -530,7 +540,6 @@
#toolbar .toolbar-dropdown { #toolbar .toolbar-dropdown {
position: static; position: static;
transform: none;
box-shadow: none; box-shadow: none;
padding: 4px 0 4px 12px; padding: 4px 0 4px 12px;
min-width: 0; min-width: 0;
@ -553,10 +562,7 @@
margin: 4px 0; margin: 4px 0;
} }
/* Hide zoom/reset from toolbar (they're in #mobile-zoom) */ /* Hide collapse on mobile */
#toolbar #zoom-in,
#toolbar #zoom-out,
#toolbar #reset-view,
#toolbar #toolbar-collapse { #toolbar #toolbar-collapse {
display: none; display: none;
} }
@ -601,7 +607,19 @@
</div> </div>
<div id="toolbar"> <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"> <div class="toolbar-group">
<button class="toolbar-group-toggle">📝 Create</button> <button class="toolbar-group-toggle">📝 Create</button>
@ -706,9 +724,14 @@
<span class="toolbar-sep"></span> <span class="toolbar-sep"></span>
<button id="zoom-in" title="Zoom In">+</button> <div class="toolbar-group">
<button id="zoom-out" title="Zoom Out"></button> <button class="toolbar-group-toggle">🔍 Zoom</button>
<button id="reset-view" title="Reset View">Reset</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>
<div id="memory-panel"> <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 // Memory panel — browse and remember forgotten shapes
const memoryPanel = document.getElementById("memory-panel"); const memoryPanel = document.getElementById("memory-panel");
const memoryList = document.getElementById("memory-list"); const memoryList = document.getElementById("memory-list");
@ -1861,6 +2044,8 @@
"folk-token-mint": "🪙", "folk-token-ledger": "📒", "folk-token-mint": "🪙", "folk-token-ledger": "📒",
"folk-choice-vote": "☑", "folk-choice-rank": "📊", "folk-choice-vote": "☑", "folk-choice-rank": "📊",
"folk-choice-spider": "🕸", "folk-social-post": "📱", "folk-choice-spider": "🕸", "folk-social-post": "📱",
"folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️",
"folk-freecad": "📐", "folk-kicad": "🔌",
"folk-rapp": "📱", "folk-feed": "🔄", "folk-arrow": "↗️", "folk-rapp": "📱", "folk-feed": "🔄", "folk-arrow": "↗️",
}; };
@ -1976,8 +2161,9 @@
if (window.innerWidth > 768) return; if (window.innerWidth > 768) return;
const btn = e.target.closest("button"); const btn = e.target.closest("button");
if (!btn) return; if (!btn) return;
// Keep open for connect, memory, zoom, group toggles, 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"]; 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 (btn.classList.contains("toolbar-group-toggle")) return;
if (!keepOpen.includes(btn.id)) { if (!keepOpen.includes(btn.id)) {
toolbarEl.classList.remove("mobile-open"); toolbarEl.classList.remove("mobile-open");
@ -2016,7 +2202,7 @@
const collapseBtn = document.getElementById("toolbar-collapse"); const collapseBtn = document.getElementById("toolbar-collapse");
collapseBtn.addEventListener("click", () => { collapseBtn.addEventListener("click", () => {
const isCollapsed = toolbarEl.classList.toggle("collapsed"); const isCollapsed = toolbarEl.classList.toggle("collapsed");
collapseBtn.textContent = isCollapsed ? "▶" : "◀"; collapseBtn.textContent = isCollapsed ? "▼" : "▲";
collapseBtn.title = isCollapsed ? "Expand toolbar" : "Minimize toolbar"; collapseBtn.title = isCollapsed ? "Expand toolbar" : "Minimize toolbar";
}); });