diff --git a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts
index b3b5b6bb..4d599cfd 100644
--- a/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts
+++ b/modules/crowdsurf/components/folk-crowdsurf-dashboard.ts
@@ -10,8 +10,9 @@ import type { TourStep } from '../../../shared/tour-engine';
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { CrowdSurfLocalFirstClient } from '../local-first-client';
import type { CrowdSurfDoc, CrowdSurfPrompt, Contribution } from '../schemas';
-import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions } from '../schemas';
+import { getDecayProgress, getTimeRemaining, getRightSwipeCount, isReadyToTrigger, getUrgency, parseContributions, crowdsurfSchema, crowdsurfDocId } from '../schemas';
import { getModuleApiBase } from "../../../shared/url-helpers";
+import type { DocumentId } from "../../../shared/local-first/document";
// ── Auth helpers ──
function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null {
@@ -133,11 +134,21 @@ class FolkCrowdSurfDashboard extends HTMLElement {
if (!localStorage.getItem('crowdsurf_tour_done')) {
setTimeout(() => this._tour.start(), 800);
}
+ this.subscribeCollabOverlay();
// Check expiry every 30s
this._expiryTimer = window.setInterval(() => this.checkExpiry(), 30000);
}
+ private async subscribeCollabOverlay() {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+ try {
+ const docId = crowdsurfDocId(this.space) as DocumentId;
+ await runtime.subscribe(docId, crowdsurfSchema);
+ } catch { /* runtime unavailable */ }
+ }
+
private extractPrompts(doc: CrowdSurfDoc) {
const myDid = getMyDid();
const all = doc.prompts ? Object.values(doc.prompts) : [];
@@ -425,7 +436,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
return `
-
+
✗ Pass
✓ Join
@@ -604,7 +615,7 @@ class FolkCrowdSurfDashboard extends HTMLElement {
Elo Leaderboard
${leaderboard.map((p, i) => `
-
+
#${i + 1}
${this.esc(p.text)}
⚡ ${p.elo ?? 1500}
diff --git a/modules/rmeets/components/folk-jitsi-room.ts b/modules/rmeets/components/folk-jitsi-room.ts
index bf207d9d..c4096d6f 100644
--- a/modules/rmeets/components/folk-jitsi-room.ts
+++ b/modules/rmeets/components/folk-jitsi-room.ts
@@ -7,6 +7,8 @@
*/
import { startPresenceHeartbeat } from '../../../shared/collab-presence';
+import type { DocumentId } from "../../../shared/local-first/document";
+import { meetsSchema, meetsDocId } from "../schemas";
class FolkJitsiRoom extends HTMLElement {
private shadow: ShadowRoot;
@@ -25,6 +27,7 @@ class FolkJitsiRoom extends HTMLElement {
private directorAnimFrame: number | null = null;
private directorError = "";
private _stopPresence: (() => void) | null = null;
+ private _offlineUnsub: (() => void) | null = null;
constructor() {
super();
@@ -41,13 +44,26 @@ class FolkJitsiRoom extends HTMLElement {
this.render();
this.loadJitsiApi();
this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rmeets', context: this.room || 'Meeting' }));
+ if (this.space && this.space !== "demo") {
+ this.subscribeOffline();
+ }
}
disconnectedCallback() {
this._stopPresence?.();
+ this._offlineUnsub?.(); this._offlineUnsub = null;
this.dispose();
}
+ private async subscribeOffline() {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+ try {
+ const docId = meetsDocId(this.space) as DocumentId;
+ await runtime.subscribe(docId, meetsSchema);
+ } catch { /* runtime unavailable */ }
+ }
+
getApi() { return this.api; }
executeCommand(cmd: string, ...args: any[]) {
@@ -75,7 +91,7 @@ class FolkJitsiRoom extends HTMLElement {
.director-info { font-size: 0.75rem; color: var(--rs-text-muted, #888); padding: 0 8px; white-space: nowrap; font-family: system-ui, sans-serif; }
.director-error { font-size: 0.8rem; color: #ef4444; padding: 8px; font-family: system-ui, sans-serif; }
-
+
${this.isDirector && this.sessionId ? this.renderDirectorStrip() : ""}
diff --git a/modules/rtime/components/folk-timebank-app.ts b/modules/rtime/components/folk-timebank-app.ts
index a03dc72a..ea3205c9 100644
--- a/modules/rtime/components/folk-timebank-app.ts
+++ b/modules/rtime/components/folk-timebank-app.ts
@@ -5,6 +5,10 @@
* Two views: "pool" (canvas orbs) and "weave" (SVG node editor).
*/
+import type { DocumentId } from "../../../shared/local-first/document";
+import { startPresenceHeartbeat } from '../../../shared/collab-presence';
+import { commitmentsSchema, commitmentsDocId } from "../schemas";
+
// ── Constants ──
const SKILL_COLORS: Record
= {
@@ -320,6 +324,8 @@ class FolkTimebankApp extends HTMLElement {
private _currentExecTaskId: string | null = null;
private _cyclosMembers: { id: string; name: string; balance: number }[] = [];
private _theme: 'dark' | 'light' = 'dark';
+ private _stopPresence: (() => void) | null = null;
+ private _offlineUnsub: (() => void) | null = null;
constructor() {
super();
@@ -349,6 +355,17 @@ class FolkTimebankApp extends HTMLElement {
this.setupCanvas();
this.setupCollaborate();
this.fetchData();
+ this._stopPresence = startPresenceHeartbeat(() => ({ module: 'rtime', context: 'Timebank' }));
+ if (this.space !== 'demo') this.subscribeOffline();
+ }
+
+ private async subscribeOffline() {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (!runtime?.isInitialized) return;
+ try {
+ const docId = commitmentsDocId(this.space) as DocumentId;
+ await runtime.subscribe(docId, commitmentsSchema);
+ } catch { /* runtime unavailable */ }
}
/** Derive API base from the current pathname — works for both subdomain and path routing. */
@@ -376,6 +393,7 @@ class FolkTimebankApp extends HTMLElement {
disconnectedCallback() {
if (this.animFrame) cancelAnimationFrame(this.animFrame);
+ this._stopPresence?.(); this._offlineUnsub?.(); this._offlineUnsub = null;
}
private async fetchData() {
@@ -1318,6 +1336,7 @@ class FolkTimebankApp extends HTMLElement {
private renderNode(node: WeaveNode): SVGGElement {
const g = ns('g') as SVGGElement;
g.setAttribute('data-id', node.id);
+ g.setAttribute('data-collab-id', `${node.type}:${node.id}`);
g.setAttribute('transform', 'translate(' + node.x + ',' + node.y + ')');
if (node.type === 'commitment') {
diff --git a/modules/rtime/mod.ts b/modules/rtime/mod.ts
index 52e8a9fc..c12652af 100644
--- a/modules/rtime/mod.ts
+++ b/modules/rtime/mod.ts
@@ -23,6 +23,7 @@ import type { SyncServer } from '../../server/local-first/sync-server';
import {
commitmentsSchema, tasksSchema, externalTimeLogsSchema,
commitmentsDocId, tasksDocId, externalTimeLogsDocId,
+ SKILL_LABELS,
} from './schemas';
import type {
CommitmentsDoc, TasksDoc, ExternalTimeLogsDoc,
diff --git a/server/index.ts b/server/index.ts
index a5bd42ab..8410dd98 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -87,7 +87,7 @@ import { vnbModule } from "../modules/rvnb/mod";
import { crowdsurfModule } from "../modules/crowdsurf/mod";
import { timeModule } from "../modules/rtime/mod";
import { govModule } from "../modules/rgov/mod";
-import { sheetModule } from "../modules/rsheet/mod";
+import { sheetsModule } from "../modules/rsheets/mod";
import { exchangeModule } from "../modules/rexchange/mod";
import { spaces, createSpace, resolveCallerRole, roleAtLeast } from "./spaces";
import type { SpaceRoleString } from "./spaces";
@@ -154,7 +154,7 @@ registerModule(forumModule);
registerModule(tubeModule);
registerModule(tripsModule);
registerModule(booksModule);
-registerModule(sheetModule);
+registerModule(sheetsModule);
// registerModule(docsModule); // placeholder — not yet an rApp
// ── Config ──
@@ -943,11 +943,13 @@ app.post("/:space/api/comment-pins/notify", async (c) => {
const space = c.req.param("space");
try {
const body = await c.req.json();
- const { pinId, authorDid, authorName, text, pinIndex, isReply, mentionedDids } = body;
+ const { pinId, authorDid, authorName, text, pinIndex, isReply, mentionedDids, moduleId } = body;
if (!pinId || !authorDid) {
return c.json({ error: "Missing fields" }, 400);
}
+ const effectiveModule = moduleId || "rspace";
+ const isModuleComment = effectiveModule !== "rspace";
const members = await listSpaceMembers(space);
const excludeDids = new Set([authorDid, ...(mentionedDids || [])]);
const title = isReply
@@ -963,12 +965,12 @@ app.post("/:space/api/comment-pins/notify", async (c) => {
notify({
userDid: m.userDID,
category: "module",
- eventType: "canvas_comment",
+ eventType: isModuleComment ? "module_comment" : "canvas_comment",
title,
body: preview || `Comment pin #${pinIndex || "?"} in ${space}`,
spaceSlug: space,
- moduleId: "rspace",
- actionUrl: `/rspace#pin-${pinId}`,
+ moduleId: effectiveModule,
+ actionUrl: `/${effectiveModule}#pin-${pinId}`,
actorDid: authorDid,
actorUsername: authorName,
}),
@@ -981,7 +983,7 @@ app.post("/:space/api/comment-pins/notify", async (c) => {
sendSpaceNotification(
space,
title,
- `${preview}
View comment
`,
+ `${preview}
View comment
`,
);
})
.catch(() => {});
@@ -998,20 +1000,22 @@ app.post("/:space/api/comment-pins/notify-mention", async (c) => {
const space = c.req.param("space");
try {
const body = await c.req.json();
- const { pinId, authorDid, authorName, mentionedDids, pinIndex } = body;
+ const { pinId, authorDid, authorName, mentionedDids, pinIndex, moduleId } = body;
if (!pinId || !authorDid || !mentionedDids?.length) {
return c.json({ error: "Missing fields" }, 400);
}
+ const effectiveModule = moduleId || "rspace";
+ const isModuleComment = effectiveModule !== "rspace";
for (const did of mentionedDids) {
await notify({
userDid: did,
category: "module",
- eventType: "canvas_mention",
+ eventType: isModuleComment ? "module_mention" : "canvas_mention",
title: `${authorName} mentioned you in a comment`,
body: `Comment pin #${pinIndex || "?"} in ${space}`,
spaceSlug: space,
- moduleId: "rspace",
- actionUrl: `/rspace#pin-${pinId}`,
+ moduleId: effectiveModule,
+ actionUrl: `/${effectiveModule}#pin-${pinId}`,
actorDid: authorDid,
actorUsername: authorName,
});
@@ -3643,6 +3647,10 @@ async function serveStatic(path: string, url?: URL): Promise {
return null;
}
+// ── Module ID aliases (plural/misspelling → canonical) ──
+const MODULE_ALIASES: Record = { rsheet: "rsheets" };
+function resolveModuleAlias(id: string): string { return MODULE_ALIASES[id] ?? id; }
+
// ── Standalone domain → module lookup ──
const domainToModule = new Map();
for (const mod of getAllModules()) {
@@ -3953,7 +3961,7 @@ const server = Bun.serve({
}
// Block disabled modules before rewriting — redirect to space root
- const firstModId = pathSegments[0].toLowerCase();
+ const firstModId = resolveModuleAlias(pathSegments[0].toLowerCase());
if (firstModId !== "rspace") {
await loadCommunity(subdomain);
const spaceDoc = getDocumentData(subdomain);
@@ -3962,9 +3970,9 @@ const server = Bun.serve({
}
}
- // Normalize module ID to lowercase (rTrips → rtrips)
+ // Normalize module ID to lowercase + resolve aliases (rTrips → rtrips, rsheet → rsheets)
const normalizedPath = "/" + pathSegments.map((seg, i) =>
- i === 0 ? seg.toLowerCase() : seg
+ i === 0 ? resolveModuleAlias(seg.toLowerCase()) : seg
).join("/");
// Rewrite: /{moduleId}/... → /{space}/{moduleId}/...
@@ -3985,7 +3993,7 @@ const server = Bun.serve({
const pathSegments = url.pathname.split("/").filter(Boolean);
if (pathSegments.length >= 1) {
- const firstSegment = pathSegments[0].toLowerCase();
+ const firstSegment = resolveModuleAlias(pathSegments[0].toLowerCase());
const allModules = getAllModules();
const knownModuleIds = new Set(allModules.map((m) => m.id));
const mod = allModules.find((m) => m.id === firstSegment);
diff --git a/server/notification-service.ts b/server/notification-service.ts
index ca247120..da6fcd58 100644
--- a/server/notification-service.ts
+++ b/server/notification-service.ts
@@ -77,6 +77,7 @@ export type NotificationEventType =
// Module
| 'inbox_new_mail' | 'inbox_approval_needed' | 'choices_result'
| 'notes_shared' | 'canvas_mention' | 'canvas_comment'
+ | 'module_comment' | 'module_mention'
// System
| 'guardian_invite' | 'guardian_accepted' | 'recovery_initiated'
| 'recovery_approved' | 'device_linked' | 'security_alert'
diff --git a/server/shell.ts b/server/shell.ts
index 0b3c6c75..28a82a6d 100644
--- a/server/shell.ts
+++ b/server/shell.ts
@@ -342,6 +342,7 @@ export function renderShell(opts: ShellOptions): string {
+ ${moduleId !== "rspace" ? `
` : ''}
${renderModuleSubNav(moduleId, spaceSlug, visibleModules, opts.isSubdomain ?? IS_PRODUCTION)}
${opts.tabs ? renderTabBar(opts.tabs, opts.activeTab, opts.tabBasePath || ((opts.isSubdomain ?? IS_PRODUCTION) ? `/${escapeAttr(moduleId)}` : `/${escapeAttr(spaceSlug)}/${escapeAttr(moduleId)}`)) : ''}
diff --git a/shared/components/rstack-comment-bell.ts b/shared/components/rstack-comment-bell.ts
index 61f6170e..fc3a96f7 100644
--- a/shared/components/rstack-comment-bell.ts
+++ b/shared/components/rstack-comment-bell.ts
@@ -2,15 +2,18 @@
* — Comment button with dropdown panel.
*
* Shows a chat-bubble icon in the header bar. Badge displays the count
- * of unresolved comment pins on the current canvas. Clicking toggles a
- * dropdown panel showing all comment threads, sorted by most recent
- * message. Includes a "New Comment" button to enter pin-placement mode.
+ * of unresolved comment pins. Context-aware data source:
+ * - Canvas page: reads from `window.__communitySync?.doc?.commentPins`
+ * - Module pages: reads from the module-comments Automerge doc via runtime
*
- * Data source: `window.__communitySync?.doc?.commentPins`
- * Listens for `comment-pins-changed` on `window` (re-dispatched by canvas).
- * Polls every 5s as fallback (sync may appear after component mounts).
+ * Listens for `comment-pins-changed` + `module-comment-pins-changed` on `window`.
+ * Polls every 30s as fallback (sync may appear after component mounts).
*/
+import { moduleCommentsSchema, moduleCommentsDocId } from '../module-comment-schemas';
+import type { ModuleCommentPin, ModuleCommentsDoc } from '../module-comment-types';
+import type { DocumentId } from '../local-first/document';
+
const POLL_INTERVAL = 30_000;
interface CommentMessage {
@@ -22,19 +25,28 @@ interface CommentMessage {
interface CommentPinData {
messages: CommentMessage[];
- anchor?: { x: number; y: number };
+ anchor?: { x: number; y: number; type?: string; elementId?: string; moduleId?: string };
resolved?: boolean;
createdBy?: string;
createdByName?: string;
createdAt?: number;
}
+type PinSource = 'canvas' | 'module';
+
export class RStackCommentBell extends HTMLElement {
#shadow: ShadowRoot;
#count = 0;
#open = false;
#pollTimer: ReturnType | null = null;
#syncRef: any = null;
+ #pinSource: PinSource = 'canvas';
+
+ // Module comments state
+ #moduleDocId: DocumentId | null = null;
+ #runtime: any = null;
+ #unsubModuleChange: (() => void) | null = null;
+ #runtimePollInterval: ReturnType | null = null;
constructor() {
super();
@@ -43,19 +55,71 @@ export class RStackCommentBell extends HTMLElement {
connectedCallback() {
this.#render();
- this.#syncRef = (window as any).__communitySync || null;
+
+ // Determine context: module page or canvas
+ const moduleCommentsEl = document.querySelector('rstack-module-comments');
+ if (moduleCommentsEl) {
+ this.#pinSource = 'module';
+ this.#tryConnectRuntime();
+ } else {
+ this.#pinSource = 'canvas';
+ this.#syncRef = (window as any).__communitySync || null;
+ }
+
this.#refreshCount();
this.#pollTimer = setInterval(() => this.#refreshCount(), POLL_INTERVAL);
window.addEventListener("comment-pins-changed", this.#onPinsChanged);
+ window.addEventListener("module-comment-pins-changed", this.#onPinsChanged);
document.addEventListener("community-sync-ready", this.#onSyncReady);
}
disconnectedCallback() {
if (this.#pollTimer) clearInterval(this.#pollTimer);
+ if (this.#runtimePollInterval) clearInterval(this.#runtimePollInterval);
+ if (this.#unsubModuleChange) this.#unsubModuleChange();
window.removeEventListener("comment-pins-changed", this.#onPinsChanged);
+ window.removeEventListener("module-comment-pins-changed", this.#onPinsChanged);
document.removeEventListener("community-sync-ready", this.#onSyncReady);
}
+ // ── Runtime Connection (for module comments) ──
+
+ #tryConnectRuntime() {
+ const runtime = (window as any).__rspaceOfflineRuntime;
+ if (runtime?.isInitialized) {
+ this.#onRuntimeReady(runtime);
+ } else {
+ let polls = 0;
+ this.#runtimePollInterval = setInterval(() => {
+ const rt = (window as any).__rspaceOfflineRuntime;
+ if (rt?.isInitialized) {
+ clearInterval(this.#runtimePollInterval!);
+ this.#runtimePollInterval = null;
+ this.#onRuntimeReady(rt);
+ } else if (++polls > 20) {
+ clearInterval(this.#runtimePollInterval!);
+ this.#runtimePollInterval = null;
+ }
+ }, 500);
+ }
+ }
+
+ async #onRuntimeReady(runtime: any) {
+ this.#runtime = runtime;
+ const space = document.body?.getAttribute("data-space-slug");
+ if (!space) return;
+
+ this.#moduleDocId = moduleCommentsDocId(space);
+ await runtime.subscribe(this.#moduleDocId, moduleCommentsSchema);
+
+ this.#unsubModuleChange = runtime.onChange(this.#moduleDocId, () => {
+ this.#refreshCount();
+ if (this.#open) this.#render();
+ });
+
+ this.#refreshCount();
+ }
+
#onSyncReady = (e: Event) => {
const sync = (e as CustomEvent).detail?.sync;
if (sync) {
@@ -69,20 +133,56 @@ export class RStackCommentBell extends HTMLElement {
if (this.#open) this.#render();
};
- #refreshCount() {
- const sync = this.#syncRef || (window as any).__communitySync;
- if (sync && !this.#syncRef) this.#syncRef = sync;
- const pins = sync?.doc?.commentPins;
- if (!pins) {
- if (this.#count !== 0) {
- this.#count = 0;
- this.#updateBadge();
- }
- return;
+ // ── Data Access ──
+
+ #getCurrentModuleId(): string {
+ return document.body?.getAttribute("data-module-id") || "rspace";
+ }
+
+ #getModulePins(): [string, CommentPinData][] {
+ if (!this.#runtime || !this.#moduleDocId) return [];
+ const doc = this.#runtime.get(this.#moduleDocId) as ModuleCommentsDoc | undefined;
+ if (!doc?.pins) return [];
+
+ const moduleId = this.#getCurrentModuleId();
+ const entries: [string, CommentPinData][] = [];
+ for (const [pinId, pin] of Object.entries(doc.pins)) {
+ if (pin.anchor.moduleId !== moduleId) continue;
+ entries.push([pinId, {
+ messages: (pin.messages || []).map(m => ({
+ text: m.text,
+ createdBy: m.authorId,
+ createdByName: m.authorName,
+ createdAt: m.createdAt,
+ })),
+ anchor: { x: 0, y: 0, type: 'element', elementId: pin.anchor.elementId, moduleId: pin.anchor.moduleId },
+ resolved: pin.resolved,
+ createdBy: pin.createdBy,
+ createdByName: pin.createdByName,
+ createdAt: pin.createdAt,
+ }]);
}
- const newCount = Object.values(pins).filter(
- (p: any) => !p.resolved
- ).length;
+ return entries;
+ }
+
+ #getCanvasPins(): [string, CommentPinData][] {
+ const sync = this.#syncRef || (window as any).__communitySync;
+ const pins = sync?.doc?.commentPins;
+ if (!pins) return [];
+ return Object.entries(pins) as [string, CommentPinData][];
+ }
+
+ #refreshCount() {
+ let pins: [string, CommentPinData][];
+ if (this.#pinSource === 'module') {
+ pins = this.#getModulePins();
+ } else {
+ const sync = this.#syncRef || (window as any).__communitySync;
+ if (sync && !this.#syncRef) this.#syncRef = sync;
+ pins = this.#getCanvasPins();
+ }
+
+ const newCount = pins.filter(([, p]) => !p.resolved).length;
if (newCount !== this.#count) {
this.#count = newCount;
this.#updateBadge();
@@ -95,11 +195,10 @@ export class RStackCommentBell extends HTMLElement {
}
#getPins(): [string, CommentPinData][] {
- const sync = this.#syncRef || (window as any).__communitySync;
- const pins = sync?.doc?.commentPins;
- if (!pins) return [];
+ const entries = this.#pinSource === 'module'
+ ? this.#getModulePins()
+ : this.#getCanvasPins();
- const entries = Object.entries(pins) as [string, CommentPinData][];
// Sort by most recent message timestamp, descending
entries.sort((a, b) => {
const aTime = this.#latestMessageTime(a[1]);
@@ -165,14 +264,20 @@ export class RStackCommentBell extends HTMLElement {
? `Resolved`
: `Open`;
+ // Show element anchor for module comments
+ const anchorLabel = pin.anchor?.elementId
+ ? `${this.#esc(pin.anchor.elementId)}`
+ : "";
+
return `
-