Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-25 16:35:18 -07:00
commit e78b49d74f
16 changed files with 737 additions and 149 deletions

View File

@ -262,6 +262,26 @@ services:
retries: 5
start_period: 10s
# ── KiCad MCP sidecar (PCB design via SSE) ──
kicad-mcp:
build: ./docker/kicad-mcp
container_name: kicad-mcp
restart: unless-stopped
volumes:
- rspace-files:/data/files
networks:
- rspace-internal
# ── FreeCAD MCP sidecar (3D CAD via SSE) ──
freecad-mcp:
build: ./docker/freecad-mcp
container_name: freecad-mcp
restart: unless-stopped
volumes:
- rspace-files:/data/files
networks:
- rspace-internal
# ── Scribus noVNC (rDesign DTP workspace) ──
scribus-novnc:
build:

View File

@ -0,0 +1,27 @@
FROM node:20-slim
# Install FreeCAD headless (freecad-cmd) and dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
freecad \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Set headless Qt/FreeCAD env
ENV QT_QPA_PLATFORM=offscreen
ENV DISPLAY=""
ENV FREECAD_USER_CONFIG=/tmp/.FreeCAD
WORKDIR /app
# Copy MCP server source
COPY freecad-mcp-server/ .
# Install Node deps + supergateway (stdio→SSE bridge)
RUN npm install && npm install -g supergateway
# Ensure generated files dir exists
RUN mkdir -p /data/files/generated
EXPOSE 8808
CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808"]

View File

@ -0,0 +1,31 @@
FROM node:20-slim
# Install KiCad, Python, and build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
kicad \
python3 \
python3-pip \
python3-venv \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Use SWIG backend (headless — no KiCad GUI needed)
ENV KICAD_BACKEND=swig
WORKDIR /app
# Copy MCP server source
COPY KiCAD-MCP-Server/ .
# Install Node deps + supergateway (stdio→SSE bridge)
RUN npm install && npm install -g supergateway
# Install Python requirements (Pillow, cairosvg, etc.)
RUN pip3 install --break-system-packages -r python/requirements.txt
# Ensure generated files dir exists
RUN mkdir -p /data/files/generated
EXPOSE 8809
CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809"]

View File

@ -42,6 +42,7 @@ const MODULE_META: Record<string, { badge: string; color: string; name: string;
rmeets: { badge: "rMe", color: "#6ee7b7", name: "rMeets", icon: "📹" },
rschedule: { badge: "rSc", color: "#93c5fd", name: "rSchedule", icon: "⏰" },
rsocials: { badge: "rSo", color: "#f9a8d4", name: "rSocials", icon: "📱" },
rdesign: { badge: "rDe", color: "#7c3aed", name: "rDesign", icon: "🎨" },
};
const styles = css`
@ -576,6 +577,14 @@ interface WidgetData {
export class FolkRApp extends FolkShape {
static override tagName = "folk-rapp";
/** Enabled module IDs for picker/switcher filtering (null = all enabled) */
static enabledModuleIds: Set<string> | null = null;
/** Update which modules appear in the picker and switcher dropdowns */
static setEnabledModules(ids: string[] | null) {
FolkRApp.enabledModuleIds = ids ? new Set(ids) : null;
}
/** Port descriptors for data pipe integration (AC#3) */
static override portDescriptors: PortDescriptor[] = [
{ name: "data-in", type: "json", direction: "input" },
@ -809,7 +818,11 @@ export class FolkRApp extends FolkShape {
}
#buildSwitcher(switcherEl: HTMLElement) {
const enabledSet = FolkRApp.enabledModuleIds
?? ((window as any).__rspaceEnabledModules ? new Set((window as any).__rspaceEnabledModules as string[]) : null);
const items = Object.entries(MODULE_META)
.filter(([id]) => !enabledSet || enabledSet.has(id))
.map(([id, meta]) => `
<button class="rapp-switcher-item ${id === this.#moduleId ? "active" : ""}" data-module="${id}">
<span class="rapp-switcher-badge" style="background: ${meta.color}">${meta.badge}</span>
@ -1163,7 +1176,11 @@ export class FolkRApp extends FolkShape {
#showPicker() {
if (!this.#contentEl) return;
const enabledSet = FolkRApp.enabledModuleIds
?? ((window as any).__rspaceEnabledModules ? new Set((window as any).__rspaceEnabledModules as string[]) : null);
const items = Object.entries(MODULE_META)
.filter(([id]) => !enabledSet || enabledSet.has(id))
.map(([id, meta]) => `
<button class="rapp-picker-item" data-module="${id}">
<span class="rapp-picker-badge" style="background: ${meta.color}">${meta.badge}</span>

View File

@ -119,50 +119,128 @@ class NotesCommentPanel extends HTMLElement {
return `${Math.floor(diff / 86400000)}d ago`;
};
const formatDate = (ts: number) => new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const { authorId: currentUserId } = this.getSessionInfo();
const { authorId: currentUserId, authorName: currentUserName } = this.getSessionInfo();
const initials = (name: string) => name.split(/\s+/).map(w => w[0] || '').join('').slice(0, 2).toUpperCase() || '?';
const avatarColor = (id: string) => {
let h = 0;
for (let i = 0; i < id.length; i++) h = id.charCodeAt(i) + ((h << 5) - h);
return `hsl(${Math.abs(h) % 360}, 55%, 55%)`;
};
this.shadow.innerHTML = `
<style>
:host { display: block; }
.panel { border-left: 1px solid var(--rs-border, #e5e7eb); padding: 12px; font-family: system-ui, sans-serif; font-size: 13px; max-height: 80vh; overflow-y: auto; }
.panel-title { font-weight: 600; font-size: 14px; margin-bottom: 12px; color: var(--rs-text-primary, #111); display: flex; justify-content: space-between; align-items: center; }
.thread { margin-bottom: 16px; padding: 10px; border-radius: 8px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border-subtle, #f0f0f0); cursor: pointer; transition: border-color 0.15s; }
.thread:hover { border-color: var(--rs-border, #e5e7eb); }
.thread.active { border-color: var(--rs-primary, #3b82f6); }
.thread.resolved { opacity: 0.6; }
.thread-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
.thread-author { font-weight: 600; color: var(--rs-text-primary, #111); }
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; }
.thread-actions { display: flex; gap: 4px; }
.thread-action { padding: 2px 6px; border: none; background: none; color: var(--rs-text-secondary, #666); cursor: pointer; font-size: 11px; border-radius: 4px; }
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); }
.message { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
:host { display: block; font-family: system-ui, -apple-system, sans-serif; font-size: 13px; }
.panel { padding: 8px 12px; overflow-y: auto; max-height: calc(100vh - 180px); }
.panel-title {
font-weight: 600; font-size: 13px; padding: 8px 0;
color: var(--rs-text-secondary, #666);
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1px solid var(--rs-border-subtle, #f0f0f0);
margin-bottom: 8px;
}
.thread {
margin-bottom: 8px;
padding: 12px;
border-radius: 8px;
background: var(--rs-bg-surface, #fff);
border: 1px solid var(--rs-border-subtle, #e8e8e8);
cursor: pointer;
transition: all 0.15s;
border-left: 3px solid transparent;
}
.thread:hover { box-shadow: 0 1px 4px rgba(0,0,0,0.08); }
.thread.active {
border-left-color: #fbbc04;
box-shadow: 0 1px 6px rgba(251, 188, 4, 0.2);
background: color-mix(in srgb, #fbbc04 4%, var(--rs-bg-surface, #fff));
}
.thread.resolved { opacity: 0.5; }
.thread.resolved:hover { opacity: 0.7; }
/* Author row with avatar */
.thread-header { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.avatar {
width: 26px; height: 26px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; color: #fff; flex-shrink: 0;
}
.header-info { flex: 1; min-width: 0; }
.thread-author { font-weight: 600; font-size: 13px; color: var(--rs-text-primary, #111); }
.thread-time { color: var(--rs-text-muted, #999); font-size: 11px; margin-left: 6px; }
/* Messages */
.message { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); }
.message-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; }
.message-avatar { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 9px; font-weight: 600; color: #fff; flex-shrink: 0; }
.message-author { font-weight: 500; font-size: 12px; color: var(--rs-text-secondary, #666); }
.message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.4; }
.reply-form { margin-top: 8px; display: flex; gap: 6px; }
.reply-input { flex: 1; padding: 6px 8px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 12px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
.reply-input:focus { border-color: var(--rs-primary, #3b82f6); outline: none; }
.reply-btn { padding: 6px 10px; border: none; background: var(--rs-primary, #3b82f6); color: #fff; border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500; }
.reply-btn:hover { opacity: 0.9; }
.message-time { font-size: 10px; color: var(--rs-text-muted, #aaa); }
.message-text { margin-top: 2px; color: var(--rs-text-primary, #111); line-height: 1.5; padding-left: 26px; }
.first-message-text { color: var(--rs-text-primary, #111); line-height: 1.5; }
/* Reply form — Google Docs style */
.reply-form { margin-top: 10px; border-top: 1px solid var(--rs-border-subtle, #f0f0f0); padding-top: 10px; }
.reply-input {
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
border-radius: 8px; font-size: 13px; font-family: inherit;
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
resize: none; min-height: 36px;
}
.reply-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
.reply-input::placeholder { color: var(--rs-text-muted, #999); }
.reply-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
.reply-btn {
padding: 6px 14px; border: none; background: #1a73e8; color: #fff;
border-radius: 6px; font-size: 12px; cursor: pointer; font-weight: 500;
}
.reply-btn:hover { background: #1557b0; }
.reply-cancel-btn {
padding: 6px 14px; border: none; background: transparent; color: var(--rs-text-secondary, #666);
border-radius: 6px; font-size: 12px; cursor: pointer;
}
.reply-cancel-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
/* Thread actions */
.thread-actions { display: flex; gap: 2px; margin-top: 8px; justify-content: flex-end; }
.thread-action {
padding: 4px 8px; border: none; background: none;
color: var(--rs-text-muted, #999); cursor: pointer;
font-size: 11px; border-radius: 4px;
}
.thread-action:hover { background: var(--rs-bg-hover, #f5f5f5); color: var(--rs-text-primary, #111); }
.thread-action.resolve-btn { color: #1a73e8; }
.thread-action.resolve-btn:hover { background: color-mix(in srgb, #1a73e8 8%, transparent); }
/* Reactions */
.reactions-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; align-items: center; }
.reaction-pill { display: inline-flex; align-items: center; gap: 2px; padding: 2px 6px; border-radius: 12px; border: 1px solid var(--rs-border-subtle, #e0e0e0); background: var(--rs-bg-surface, #fff); font-size: 12px; cursor: pointer; transition: all 0.15s; user-select: none; }
.reaction-pill:hover { border-color: var(--rs-primary, #3b82f6); }
.reaction-pill.active { border-color: var(--rs-primary, #3b82f6); background: color-mix(in srgb, var(--rs-primary, #3b82f6) 10%, transparent); }
.reaction-pill:hover { border-color: #1a73e8; }
.reaction-pill.active { border-color: #1a73e8; background: color-mix(in srgb, #1a73e8 10%, transparent); }
.reaction-pill .count { font-size: 11px; color: var(--rs-text-secondary, #666); }
.reaction-add { padding: 2px 6px; border-radius: 12px; border: 1px dashed var(--rs-border-subtle, #ddd); background: none; font-size: 12px; cursor: pointer; color: var(--rs-text-muted, #999); }
.reaction-add:hover { border-color: var(--rs-primary, #3b82f6); color: var(--rs-text-primary, #111); }
.reaction-add:hover { border-color: #1a73e8; color: var(--rs-text-primary, #111); }
.emoji-picker { display: none; flex-wrap: wrap; gap: 2px; padding: 4px; background: var(--rs-bg-surface, #fff); border: 1px solid var(--rs-border, #e5e7eb); border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.12); margin-top: 4px; }
.emoji-picker.open { display: flex; }
.emoji-pick { padding: 4px 6px; border: none; background: none; font-size: 16px; cursor: pointer; border-radius: 4px; }
.emoji-pick:hover { background: var(--rs-bg-hover, #f5f5f5); }
/* Reminders */
.reminder-row { margin-top: 6px; display: flex; align-items: center; gap: 6px; font-size: 12px; }
.reminder-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 12px; background: color-mix(in srgb, var(--rs-warning, #f59e0b) 15%, transparent); color: var(--rs-text-primary, #111); font-size: 11px; }
.reminder-btn { padding: 2px 8px; border: 1px solid var(--rs-border-subtle, #ddd); border-radius: 12px; background: none; font-size: 11px; cursor: pointer; color: var(--rs-text-secondary, #666); }
.reminder-btn:hover { border-color: var(--rs-primary, #3b82f6); }
.reminder-btn:hover { border-color: #1a73e8; }
.reminder-clear { padding: 1px 4px; border: none; background: none; font-size: 10px; cursor: pointer; color: var(--rs-text-muted, #999); }
.reminder-date-input { padding: 2px 6px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 11px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
/* New comment input — shown when thread has no messages */
.new-comment-form { margin-top: 4px; }
.new-comment-input {
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border, #ddd);
border-radius: 8px; font-size: 13px; font-family: inherit;
background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111);
resize: none; min-height: 60px;
}
.new-comment-input:focus { border-color: #1a73e8; outline: none; box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.15); }
.new-comment-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
</style>
<div class="panel">
<div class="panel-title">
@ -171,19 +249,43 @@ class NotesCommentPanel extends HTMLElement {
${threads.map(thread => {
const reactions = thread.reactions || {};
const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0);
const isActive = thread.id === this._activeThreadId;
const hasMessages = thread.messages.length > 0;
const firstMsg = thread.messages[0];
const authorName = firstMsg?.authorName || currentUserName;
const authorId = firstMsg?.authorId || currentUserId;
return `
<div class="thread ${thread.id === this._activeThreadId ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
<div class="thread ${isActive ? 'active' : ''} ${thread.resolved ? 'resolved' : ''}" data-thread="${thread.id}">
<div class="thread-header">
<span class="thread-author">${esc(thread.messages[0]?.authorName || 'Anonymous')}</span>
<span class="thread-time">${timeAgo(thread.createdAt)}</span>
</div>
${thread.messages.map(msg => `
<div class="message">
<div class="message-author">${esc(msg.authorName)}</div>
<div class="message-text">${esc(msg.text)}</div>
<div class="avatar" style="background: ${avatarColor(authorId)}">${initials(authorName)}</div>
<div class="header-info">
<span class="thread-author">${esc(authorName)}</span>
<span class="thread-time">${timeAgo(thread.createdAt)}</span>
</div>
`).join('')}
${thread.messages.length === 0 ? '<div class="message"><div class="message-text" style="color: var(--rs-text-muted, #999)">Click to add a comment...</div></div>' : ''}
</div>
${hasMessages ? `
<div class="first-message-text">${esc(firstMsg.text)}</div>
${thread.messages.slice(1).map(msg => `
<div class="message">
<div class="message-header">
<div class="message-avatar" style="background: ${avatarColor(msg.authorId)}">${initials(msg.authorName)}</div>
<span class="message-author">${esc(msg.authorName)}</span>
<span class="message-time">${timeAgo(msg.createdAt)}</span>
</div>
<div class="message-text">${esc(msg.text)}</div>
</div>
`).join('')}
` : `
<div class="new-comment-form">
<textarea class="new-comment-input" placeholder="Add your comment..." data-new-thread="${thread.id}" autofocus></textarea>
<div class="new-comment-actions">
<button class="reply-cancel-btn" data-cancel-new="${thread.id}">Cancel</button>
<button class="reply-btn" data-submit-new="${thread.id}">Comment</button>
</div>
</div>
`}
${hasMessages && reactionEntries.length > 0 ? `
<div class="reactions-row">
${reactionEntries.map(([emoji, users]) => `
<button class="reaction-pill ${users.includes(currentUserId) ? 'active' : ''}" data-react-thread="${thread.id}" data-react-emoji="${emoji}">${emoji} <span class="count">${users.length}</span></button>
@ -193,19 +295,28 @@ class NotesCommentPanel extends HTMLElement {
<div class="emoji-picker" data-picker="${thread.id}">
${REACTION_EMOJIS.map(e => `<button class="emoji-pick" data-pick-thread="${thread.id}" data-pick-emoji="${e}">${e}</button>`).join('')}
</div>
` : ''}
${hasMessages && thread.reminderAt ? `
<div class="reminder-row">
${thread.reminderAt
? `<span class="reminder-badge">&#9200; ${formatDate(thread.reminderAt)}</span><button class="reminder-clear" data-remind-clear="${thread.id}">&#10005;</button>`
: `<button class="reminder-btn" data-remind-set="${thread.id}">&#9200; Remind me</button>`
}
<input type="date" class="reminder-date-input" data-remind-input="${thread.id}" style="display:none">
<span class="reminder-badge">&#9200; ${formatDate(thread.reminderAt)}</span>
<button class="reminder-clear" data-remind-clear="${thread.id}">&#10005;</button>
</div>
` : ''}
${hasMessages ? `
<div class="reply-form">
<input class="reply-input" placeholder="Reply..." data-thread="${thread.id}">
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
<div class="reply-actions">
<button class="reply-btn" data-reply="${thread.id}">Reply</button>
</div>
</div>
` : ''}
<div class="thread-actions">
<button class="thread-action" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
${hasMessages ? `
<button class="reaction-add" data-react-add="${thread.id}" title="React" style="font-size:13px">+</button>
<button class="thread-action" data-remind-set="${thread.id}" title="Set reminder">&#9200;</button>
<input type="date" class="reminder-date-input" data-remind-input="${thread.id}" style="display:none">
` : ''}
<button class="thread-action resolve-btn" data-resolve="${thread.id}">${thread.resolved ? 'Re-open' : 'Resolve'}</button>
<button class="thread-action" data-delete="${thread.id}">Delete</button>
</div>
</div>`;
@ -214,12 +325,21 @@ class NotesCommentPanel extends HTMLElement {
`;
this.wireEvents();
// Auto-focus new comment textarea
requestAnimationFrame(() => {
const newInput = this.shadow.querySelector('.new-comment-input') as HTMLTextAreaElement;
if (newInput) newInput.focus();
});
}
private wireEvents() {
// Click thread to scroll editor to it
this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => {
el.addEventListener('click', (e) => {
// Don't handle clicks on inputs/buttons/textareas
const target = e.target as HTMLElement;
if (target.closest('input, textarea, button')) return;
const threadId = (el as HTMLElement).dataset.thread;
if (!threadId || !this._editor) return;
this._activeThreadId = threadId;
@ -236,6 +356,46 @@ class NotesCommentPanel extends HTMLElement {
});
});
// New comment submit (thread with no messages yet)
this.shadow.querySelectorAll('[data-submit-new]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.submitNew;
if (!threadId) return;
const textarea = this.shadow.querySelector(`textarea[data-new-thread="${threadId}"]`) as HTMLTextAreaElement;
const text = textarea?.value?.trim();
if (!text) return;
this.addReply(threadId, text);
});
});
// New comment cancel — delete the empty thread
this.shadow.querySelectorAll('[data-cancel-new]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const threadId = (btn as HTMLElement).dataset.cancelNew;
if (threadId) this.deleteThread(threadId);
});
});
// New comment textarea — Ctrl+Enter to submit, Escape to cancel
this.shadow.querySelectorAll('.new-comment-input').forEach(textarea => {
textarea.addEventListener('keydown', (e) => {
const ke = e as KeyboardEvent;
if (ke.key === 'Enter' && (ke.ctrlKey || ke.metaKey)) {
e.stopPropagation();
const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
const text = (textarea as HTMLTextAreaElement).value.trim();
if (threadId && text) this.addReply(threadId, text);
} else if (ke.key === 'Escape') {
e.stopPropagation();
const threadId = (textarea as HTMLTextAreaElement).dataset.newThread;
if (threadId) this.deleteThread(threadId);
}
});
textarea.addEventListener('click', (e) => e.stopPropagation());
});
// Reply
this.shadow.querySelectorAll('[data-reply]').forEach(btn => {
btn.addEventListener('click', (e) => {

View File

@ -1216,14 +1216,17 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
const useYjs = !isDemo && isEditable;
this.contentZone.innerHTML = `
<div class="editor-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
${isEditable ? this.renderToolbar() : ''}
<div class="collab-status-bar" id="collab-status-bar" style="display:none">
<span class="collab-status-dot"></span>
<span class="collab-status-text"></span>
<div class="editor-with-comments">
<div class="editor-wrapper">
<input class="editable-title" id="note-title-input" value="${this.esc(note.title)}" placeholder="Note title...">
${isEditable ? this.renderToolbar() : ''}
<div class="collab-status-bar" id="collab-status-bar" style="display:none">
<span class="collab-status-dot"></span>
<span class="collab-status-text"></span>
</div>
<div class="tiptap-container" id="tiptap-container"></div>
</div>
<div class="tiptap-container" id="tiptap-container"></div>
<div class="comment-sidebar" id="comment-sidebar"></div>
</div>
`;
@ -1257,6 +1260,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.wireTitleInput(note, isEditable, isDemo);
this.attachToolbarListeners();
this.wireCommentHighlightClicks();
}
/** Mount TipTap with Yjs collaboration (real-time co-editing). */
@ -1306,6 +1310,8 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
codeBlock: false,
heading: { levels: [1, 2, 3, 4] },
undoRedo: false, // Yjs has its own undo/redo
link: false,
underline: false,
}),
Link.configure({ openOnClick: false }),
Image, TaskList, TaskItem.configure({ nested: true }),
@ -1329,6 +1335,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
const s = this.getSessionInfo();
return { authorId: s.userId, authorName: s.username };
},
() => this.editor?.view ?? null,
);
this.editor.registerPlugin(suggestionPlugin);
@ -1379,7 +1386,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
element: container,
editable: isEditable,
extensions: [
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] } }),
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3, 4] }, link: false, underline: false }),
Link.configure({ openOnClick: false }),
Image, TaskList, TaskItem.configure({ nested: true }),
Placeholder.configure({ placeholder: 'Start writing, or type / for commands...' }),
@ -1656,7 +1663,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.editor = new Editor({
element: container, editable: isEditable,
extensions: [
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] } }),
StarterKit.configure({ codeBlock: false, heading: { levels: [1, 2, 3] }, link: false, underline: false }),
Link.configure({ openOnClick: false }), Image,
Placeholder.configure({ placeholder: note.type === 'CLIP' ? 'Clipped content...' : 'Add notes about this bookmark...' }),
Typography, Underline,
@ -1726,7 +1733,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.editor = new Editor({
element: container, editable: isEditable,
extensions: [
StarterKit.configure({ codeBlock: false }), Link.configure({ openOnClick: false }),
StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: 'Add a caption or notes...' }), Typography, Underline,
],
content,
@ -1795,7 +1802,7 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
this.editor = new Editor({
element: container, editable: isEditable,
extensions: [
StarterKit.configure({ codeBlock: false }), Link.configure({ openOnClick: false }),
StarterKit.configure({ codeBlock: false, link: false, underline: false }), Link.configure({ openOnClick: false }),
Placeholder.configure({ placeholder: 'Transcript will appear here...' }), Typography, Underline,
],
content,
@ -2300,10 +2307,13 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
/** Show comment panel for a specific thread. */
private showCommentPanel(threadId?: string) {
const sidebar = this.shadow.getElementById('comment-sidebar');
if (!sidebar) return;
let panel = this.shadow.querySelector('notes-comment-panel') as any;
if (!panel) {
panel = document.createElement('notes-comment-panel');
this.metaZone.appendChild(panel);
sidebar.appendChild(panel);
// Listen for demo thread mutations from comment panel
panel.addEventListener('comment-demo-mutation', (e: CustomEvent) => {
const { noteId, threads } = e.detail;
@ -2322,6 +2332,44 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
} else {
panel.demoThreads = null;
}
// Show sidebar when there are comments
sidebar.classList.add('has-comments');
}
/** Hide comment sidebar when no comments exist. */
private hideCommentPanel() {
const sidebar = this.shadow.getElementById('comment-sidebar');
if (sidebar) sidebar.classList.remove('has-comments');
}
/** Wire click handling on comment highlights in the editor to open comment panel. */
private wireCommentHighlightClicks() {
if (!this.editor) return;
// On selection change, check if cursor is inside a comment mark
this.editor.on('selectionUpdate', () => {
if (!this.editor) return;
const { $from } = this.editor.state.selection;
const commentMark = $from.marks().find(m => m.type.name === 'comment');
if (commentMark) {
const threadId = commentMark.attrs.threadId;
if (threadId) this.showCommentPanel(threadId);
}
});
// Direct click on comment highlight in the DOM
const container = this.shadow.getElementById('tiptap-container');
if (container) {
container.addEventListener('click', (e) => {
const target = e.target as HTMLElement;
const highlight = target.closest?.('.comment-highlight') as HTMLElement;
if (highlight) {
const threadId = highlight.getAttribute('data-thread-id');
if (threadId) this.showCommentPanel(threadId);
}
});
}
}
private toggleDictation(btn: HTMLElement) {
@ -2994,6 +3042,40 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
.notes-right-col #content-zone { flex: 1; overflow-y: auto; padding: 20px; }
.notes-right-col #meta-zone { padding: 0 20px 12px; }
/* ── Google Docs-like comment sidebar layout ── */
.editor-with-comments {
display: flex;
gap: 0;
min-height: 100%;
}
.editor-with-comments > .editor-wrapper {
flex: 1;
min-width: 0;
}
.comment-sidebar {
width: 0;
overflow: hidden;
transition: width 0.2s ease;
flex-shrink: 0;
}
.comment-sidebar.has-comments {
width: 280px;
border-left: 1px solid var(--rs-border, #e5e7eb);
}
@media (max-width: 768px) {
.comment-sidebar.has-comments { width: 240px; }
}
@media (max-width: 480px) {
.editor-with-comments { flex-direction: column; }
.comment-sidebar.has-comments {
width: 100%;
border-left: none;
border-top: 1px solid var(--rs-border, #e5e7eb);
max-height: 250px;
overflow-y: auto;
}
}
/* Empty state */
.editor-empty-state {
display: flex; flex-direction: column; align-items: center;
@ -3474,19 +3556,20 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
margin-left: 2px;
}
/* ── Collaboration: Comment Highlights ── */
/* ── Collaboration: Comment Highlights (Google Docs style) ── */
.tiptap-container .tiptap .comment-highlight {
background: rgba(250, 204, 21, 0.25);
border-bottom: 2px solid rgba(250, 204, 21, 0.5);
background: rgba(251, 188, 4, 0.2);
border-bottom: 2px solid rgba(251, 188, 4, 0.5);
cursor: pointer;
transition: background 0.15s;
border-radius: 1px;
}
.tiptap-container .tiptap .comment-highlight:hover {
background: rgba(250, 204, 21, 0.4);
background: rgba(251, 188, 4, 0.35);
}
.tiptap-container .tiptap .comment-highlight.resolved {
background: rgba(250, 204, 21, 0.08);
border-bottom-color: rgba(250, 204, 21, 0.15);
background: rgba(251, 188, 4, 0.06);
border-bottom-color: rgba(251, 188, 4, 0.12);
}
/* ── Collaboration: Suggestions ── */

View File

@ -10,6 +10,7 @@
*/
import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';
import type { Editor } from '@tiptap/core';
const pluginKey = new PluginKey('suggestion-plugin');
@ -24,10 +25,12 @@ interface SuggestionPluginState {
* Create the suggestion mode ProseMirror plugin.
* @param getSuggesting - callback that returns current suggesting mode state
* @param getAuthor - callback that returns { authorId, authorName }
* @param getView - callback that returns the EditorView (needed to dispatch replacement transactions)
*/
export function createSuggestionPlugin(
getSuggesting: () => boolean,
getAuthor: () => { authorId: string; authorName: string },
getView?: () => EditorView | null,
): Plugin {
return new Plugin({
key: pluginKey,
@ -125,10 +128,9 @@ export function createSuggestionPlugin(
});
if (blocked && newTr.docChanged) {
// Dispatch our modified transaction instead
// We need to use view.dispatch in the next tick
// Dispatch our modified transaction instead on the next tick
setTimeout(() => {
const view = (state as any).view;
const view = getView?.();
if (view) view.dispatch(newTr);
}, 0);
return false; // Block the original transaction

View File

@ -1615,7 +1615,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-notes-app space="${space}"></folk-notes-app>`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=6"></script>`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=7"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
}));
});

View File

@ -34,6 +34,7 @@ const NATIVE_COIN_ID: Record<string, string> = {
interface CacheEntry {
prices: Map<string, number>; // address (lowercase) → USD price
nativePrice: number;
cgAvailable: boolean; // true if CoinGecko successfully returned token data
ts: number;
}
@ -74,26 +75,43 @@ export async function getNativePrice(chainId: string): Promise<number> {
return data?.[coinId]?.usd ?? 0;
}
/** Fetch token prices for a batch of contract addresses on a chain */
/** Fetch token prices for contract addresses on a chain */
export async function getTokenPrices(
chainId: string,
addresses: string[],
): Promise<Map<string, number>> {
): Promise<{ prices: Map<string, number>; available: boolean }> {
const platform = CHAIN_PLATFORM[chainId];
if (!platform || addresses.length === 0) return new Map();
if (!platform || addresses.length === 0) return { prices: new Map(), available: false };
const lower = addresses.map((a) => a.toLowerCase());
const lower = [...new Set(addresses.map((a) => a.toLowerCase()))];
const prices = new Map<string, number>();
// CoinGecko free tier limits to 1 address per request.
// For 1 token, do a single lookup. For multiple, try batch (works with Pro/Demo keys).
if (lower.length === 1) {
const addr = lower[0];
const data = await cgFetch(
`https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${addr}&vs_currencies=usd`,
);
if (data && !data.error_code) {
if (data[addr]?.usd) prices.set(addr, data[addr].usd);
return { prices, available: true };
}
return { prices, available: false };
}
// Multiple tokens: batch request (succeeds with CoinGecko Demo/Pro API key)
const data = await cgFetch(
`https://api.coingecko.com/api/v3/simple/token_price/${platform}?contract_addresses=${lower.join(",")}&vs_currencies=usd`,
);
const result = new Map<string, number>();
if (data) {
if (data && !data.error_code) {
for (const addr of lower) {
if (data[addr]?.usd) result.set(addr, data[addr].usd);
if (data[addr]?.usd) prices.set(addr, data[addr].usd);
}
return { prices, available: true };
}
return result;
// Batch failed (free tier limit) — degrade gracefully, no spam filtering
return { prices, available: false };
}
/** Fetch and cache all prices for a chain (native + tokens) */
@ -111,13 +129,14 @@ async function fetchChainPrices(
const promise = (async (): Promise<CacheEntry> => {
try {
const [nativePrice, tokenPrices] = await Promise.all([
const [nativePrice, tokenResult] = await Promise.all([
getNativePrice(chainId),
getTokenPrices(chainId, tokenAddresses),
]);
const entry: CacheEntry = {
prices: tokenPrices,
prices: tokenResult.prices,
nativePrice,
cgAvailable: tokenResult.available,
ts: Date.now(),
};
cache.set(chainId, entry);
@ -196,7 +215,8 @@ export async function enrichWithPrices(
};
});
if (options?.filterSpam) {
// Only filter spam when CoinGecko data is available to verify against
if (options?.filterSpam && priceData.cgAvailable) {
return enriched.filter((b) => {
// Native tokens always pass
if (!b.tokenAddress || b.tokenAddress === "0x0000000000000000000000000000000000000000") return true;

View File

@ -283,7 +283,7 @@ function extractPathFromText(text: string, extensions: string[]): string | null
export const KICAD_SYSTEM_PROMPT = `You are a KiCad PCB design assistant. You have access to KiCad MCP tools to create real PCB designs.
Follow this workflow:
1. create_project Create a new KiCad project in /tmp/kicad-gen-<timestamp>/
1. create_project Create a new KiCad project in /data/files/generated/kicad-<timestamp>/
2. search_symbols Find component symbols in KiCad libraries (e.g. ESP32, BME280, capacitors, resistors)
3. add_schematic_component Place each component on the schematic
4. add_schematic_net_label Add net labels for connections
@ -296,7 +296,7 @@ Follow this workflow:
11. export_gerber, export_bom, export_pdf Generate manufacturing outputs
Important:
- Use /tmp/kicad-gen-${Date.now()}/ as the project directory
- Use /data/files/generated/kicad-${Date.now()}/ as the project directory
- Search for real symbols before placing components
- Add decoupling capacitors and pull-up resistors as needed
- Set reasonable board outline dimensions
@ -307,16 +307,16 @@ Important:
export const FREECAD_SYSTEM_PROMPT = `You are a FreeCAD parametric CAD assistant. You have access to FreeCAD MCP tools to create real 3D models.
Follow this workflow:
1. execute_python_script Create output directory: import os; os.makedirs("/tmp/freecad-gen-<timestamp>", exist_ok=True)
1. execute_python_script Create output directory: import os; os.makedirs("/data/files/generated/freecad-<timestamp>", exist_ok=True)
2. Create base geometry using create_box, create_cylinder, or create_sphere
3. Use boolean_operation (union, cut, intersection) to combine shapes
4. list_objects to verify the model state
5. save_document to save the FreeCAD file
6. execute_python_script to export STEP: Part.export([obj], "/tmp/freecad-gen-<id>/model.step")
7. execute_python_script to export STL: Mesh.export([obj], "/tmp/freecad-gen-<id>/model.stl")
6. execute_python_script to export STEP: Part.export([obj], "/data/files/generated/freecad-<id>/model.step")
7. execute_python_script to export STL: Mesh.export([obj], "/data/files/generated/freecad-<id>/model.stl")
Important:
- Use /tmp/freecad-gen-${Date.now()}/ as the working directory
- Use /data/files/generated/freecad-${Date.now()}/ as the working directory
- For hollow objects, create the outer shell then cut the inner volume
- For complex shapes, build up from primitives with boolean operations
- Wall thickness should be at least 1mm for 3D printing

View File

@ -1145,20 +1145,6 @@ async function process3DGenJob(job: Gen3DJob) {
// ── Image helpers ──
/** Copy a file from a tmp path to the served generated directory → return server-relative URL */
async function copyToServed(srcPath: string): Promise<string | null> {
try {
const srcFile = Bun.file(srcPath);
if (!(await srcFile.exists())) return null;
const basename = srcPath.split("/").pop() || `file-${Date.now()}`;
const dir = resolve(process.env.FILES_DIR || "./data/files", "generated");
await Bun.write(resolve(dir, basename), srcFile);
return `/data/files/generated/${basename}`;
} catch {
return null;
}
}
/** Read a /data/files/generated/... path from disk → base64 */
async function readFileAsBase64(serverPath: string): Promise<string> {
const filename = serverPath.split("/").pop();
@ -1653,22 +1639,18 @@ app.post("/api/blender-gen", async (c) => {
}
});
// KiCAD PCB design — MCP stdio bridge
// KiCAD PCB design — MCP SSE bridge (sidecar container)
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { runCadAgentLoop, assembleKicadResult, assembleFreecadResult, KICAD_SYSTEM_PROMPT, FREECAD_SYSTEM_PROMPT } from "./cad-orchestrator";
const KICAD_MCP_PATH = process.env.KICAD_MCP_PATH || "/home/jeffe/KiCAD-MCP-Server/dist/index.js";
const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/sse";
let kicadClient: Client | null = null;
async function getKicadClient(): Promise<Client> {
if (kicadClient) return kicadClient;
const transport = new StdioClientTransport({
command: "node",
args: [KICAD_MCP_PATH],
});
const transport = new SSEClientTransport(new URL(KICAD_MCP_URL));
const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" });
transport.onclose = () => { kicadClient = null; };
@ -1705,21 +1687,7 @@ app.post("/api/kicad/generate", async (c) => {
const orch = await runCadAgentLoop(client, KICAD_SYSTEM_PROMPT, enrichedPrompt, GEMINI_API_KEY);
const result = assembleKicadResult(orch);
// Copy generated files to served directory
const filesToCopy = [
{ path: result.schematicSvg, key: "schematicSvg" },
{ path: result.boardSvg, key: "boardSvg" },
{ path: result.gerberUrl, key: "gerberUrl" },
{ path: result.bomUrl, key: "bomUrl" },
{ path: result.pdfUrl, key: "pdfUrl" },
];
for (const { path, key } of filesToCopy) {
if (path && path.startsWith("/tmp/")) {
const served = await copyToServed(path);
if (served) (result as any)[key] = served;
}
}
// Files are already on the shared /data/files volume — no copy needed
return c.json({
schematic_svg: result.schematicSvg,
@ -1774,18 +1742,14 @@ app.post("/api/kicad/:action", async (c) => {
}
});
// FreeCAD parametric CAD — MCP stdio bridge
const FREECAD_MCP_PATH = process.env.FREECAD_MCP_PATH || "/home/jeffe/freecad-mcp-server/build/index.js";
// FreeCAD parametric CAD — MCP SSE bridge (sidecar container)
const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/sse";
let freecadClient: Client | null = null;
async function getFreecadClient(): Promise<Client> {
if (freecadClient) return freecadClient;
const transport = new StdioClientTransport({
command: "node",
args: [FREECAD_MCP_PATH],
});
const transport = new SSEClientTransport(new URL(FREECAD_MCP_URL));
const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" });
transport.onclose = () => { freecadClient = null; };
@ -1818,14 +1782,7 @@ app.post("/api/freecad/generate", async (c) => {
const orch = await runCadAgentLoop(client, FREECAD_SYSTEM_PROMPT, prompt, GEMINI_API_KEY);
const result = assembleFreecadResult(orch);
// Copy generated files to served directory
for (const key of ["stepUrl", "stlUrl"] as const) {
const path = result[key];
if (path && path.startsWith("/tmp/")) {
const served = await copyToServed(path);
if (served) (result as any)[key] = served;
}
}
// Files are already on the shared /data/files volume — no copy needed
return c.json({
preview_url: result.previewUrl,

View File

@ -47,6 +47,7 @@ const FAVICON_BADGE_MAP: Record<string, { badge: string; color: string }> = {
rschedule: { badge: "r⏱", color: "#a5b4fc" },
crowdsurf: { badge: "r🏄", color: "#fde68a" },
rids: { badge: "r🪪", color: "#6ee7b7" },
rdesign: { badge: "r🎨", color: "#7c3aed" },
rstack: { badge: "r✨", color: "#c4b5fd" },
};
@ -440,6 +441,43 @@ export function renderShell(opts: ShellOptions): string {
_switcher?.setModules(window.__rspaceModuleList);
_switcher?.setAllModules(window.__rspaceAllModules);
// Initialize folk-rapp picker/switcher filtering with enabled modules
if (window.__rspaceEnabledModules) {
customElements.whenDefined('folk-rapp').then(function() {
var FolkRApp = customElements.get('folk-rapp');
if (FolkRApp && FolkRApp.setEnabledModules) FolkRApp.setEnabledModules(window.__rspaceEnabledModules);
});
}
// React to runtime module toggling from the app switcher "Manage rApps" panel
document.addEventListener('modules-changed', function(e) {
var detail = e.detail || {};
var enabledModules = detail.enabledModules;
window.__rspaceEnabledModules = enabledModules;
// Update tab bar's module list
var allMods = window.__rspaceAllModules || [];
var enabledSet = new Set(enabledModules);
var visible = allMods.filter(function(m) { return m.id === 'rspace' || enabledSet.has(m.id); });
var tb = document.querySelector('rstack-tab-bar');
if (tb) tb.setModules(visible);
// Update folk-rapp picker/switcher
var FolkRApp = customElements.get('folk-rapp');
if (FolkRApp && FolkRApp.setEnabledModules) FolkRApp.setEnabledModules(enabledModules);
// Re-run toolbar hiding for data-requires-module buttons
document.querySelectorAll('[data-requires-module]').forEach(function(el) {
el.style.display = enabledSet.has(el.dataset.requiresModule) ? '' : 'none';
});
// Re-check empty toolbar groups
document.querySelectorAll('.toolbar-group').forEach(function(group) {
var dropdown = group.querySelector('.toolbar-dropdown');
if (!dropdown) return;
var vis = dropdown.querySelectorAll('button:not([style*="display: none"]):not(.toolbar-dropdown-header)');
group.style.display = vis.length === 0 ? 'none' : '';
});
});
// ── Welcome tour (guided feature walkthrough for first-time visitors) ──
(function() {

View File

@ -60,6 +60,19 @@ export function broadcastPresence(opts: PresenceOpts): void {
});
}
/**
* Broadcast a leave signal so peers can immediately remove us.
*/
export function broadcastLeave(): void {
const runtime = (window as any).__rspaceOfflineRuntime;
if (!runtime?.isInitialized || !runtime.isOnline) return;
const session = getSessionInfo();
runtime.sendCustom({
type: 'presence-leave',
username: session.username,
});
}
/**
* Start a 10-second heartbeat that broadcasts presence.
* Returns a cleanup function to stop the heartbeat.
@ -69,5 +82,13 @@ export function broadcastPresence(opts: PresenceOpts): void {
export function startPresenceHeartbeat(getOpts: () => PresenceOpts): () => void {
broadcastPresence(getOpts());
const timer = setInterval(() => broadcastPresence(getOpts()), 10_000);
return () => clearInterval(timer);
// Clean up immediately on page unload
const onUnload = () => broadcastLeave();
window.addEventListener('beforeunload', onUnload);
return () => {
clearInterval(timer);
window.removeEventListener('beforeunload', onUnload);
};
}

View File

@ -46,6 +46,7 @@ export class RStackCollabOverlay extends HTMLElement {
#localUsername = 'Anonymous';
#unsubAwareness: (() => void) | null = null;
#unsubPresence: (() => void) | null = null;
#unsubLeave: (() => void) | null = null;
#mouseMoveTimer: ReturnType<typeof setInterval> | null = null;
#lastCursor = { x: 0, y: 0 };
#gcInterval: ReturnType<typeof setInterval> | null = null;
@ -80,6 +81,9 @@ export class RStackCollabOverlay extends HTMLElement {
this.#render();
this.#renderBadge();
// GC stale peers every 5s (all modes — prevents lingering ghost peers)
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
if (!this.#externalPeers) {
// Explicit doc-id attribute (fallback)
const explicitDocId = this.getAttribute('doc-id');
@ -90,9 +94,6 @@ export class RStackCollabOverlay extends HTMLElement {
// Try connecting to runtime
this.#tryConnect();
// GC stale peers every 5s
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
}
// Click-outside closes panel (listen on document, check composedPath for shadow DOM)
@ -209,6 +210,21 @@ export class RStackCollabOverlay extends HTMLElement {
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
});
// Listen for explicit leave signals (immediate cleanup, no GC wait)
this.#unsubLeave = runtime.onCustomMessage('presence-leave', (msg: any) => {
const pid = msg.peerId;
if (!pid || pid === this.#localPeerId) return;
if (this.#peers.has(pid)) {
this.#peers.delete(pid);
this.#renderBadge();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
if (this.#panelOpen) this.#renderPanel();
}
});
}
#connectToDoc() {
@ -362,9 +378,10 @@ export class RStackCollabOverlay extends HTMLElement {
#gcPeers() {
const now = Date.now();
const staleThreshold = this.#externalPeers ? 30000 : 15000;
let changed = false;
for (const [id, peer] of this.#peers) {
if (now - peer.lastSeen > 15000) {
if (now - peer.lastSeen > staleThreshold) {
this.#peers.delete(id);
changed = true;
}

View File

@ -663,13 +663,10 @@
flex-shrink: 0;
}
/* Zoom toggle icon rotates when expanded */
/* Zoom toggle icon — expand/minimize swap handled via JS */
#corner-zoom-toggle svg {
transition: transform 0.2s;
}
#canvas-corner-tools:not(.collapsed) #corner-zoom-toggle svg {
transform: rotate(45deg);
}
/* ── Header history button ── */
.canvas-header-history {
@ -1700,10 +1697,11 @@
padding: 0;
}
/* Collapsed state on mobile */
/* Collapsed state on mobile — flush with bottom-toolbar */
#toolbar.collapsed {
padding: 4px;
bottom: 60px;
bottom: 8px;
right: 6px;
overflow: visible;
}
@ -2537,11 +2535,60 @@
let moduleList = [];
fetch("/api/modules").then(r => r.json()).then(data => {
moduleList = data.modules || [];
window.__rspaceAllModules = moduleList;
document.querySelector("rstack-app-switcher")?.setModules(moduleList);
const tb = document.querySelector("rstack-tab-bar");
if (tb) tb.setModules(moduleList);
// Fetch space-specific enabled modules and apply filtering
const spaceSlug = window.location.pathname.split("/").filter(Boolean)[0] || "demo";
fetch(`/api/spaces/${encodeURIComponent(spaceSlug)}/modules`)
.then(r => r.ok ? r.json() : null)
.then(spaceData => {
if (!spaceData) return;
const enabledIds = spaceData.enabledModules; // null = all
window.__rspaceEnabledModules = enabledIds;
if (enabledIds) {
const enabledSet = new Set(enabledIds);
const filtered = moduleList.filter(m => m.id === "rspace" || enabledSet.has(m.id));
document.querySelector("rstack-app-switcher")?.setModules(filtered);
const tb2 = document.querySelector("rstack-tab-bar");
if (tb2) tb2.setModules(filtered);
}
// Initialize folk-rapp filtering
customElements.whenDefined("folk-rapp").then(() => {
const FolkRApp = customElements.get("folk-rapp");
if (FolkRApp?.setEnabledModules) FolkRApp.setEnabledModules(enabledIds);
});
})
.catch(() => {});
}).catch(() => {});
// React to runtime module toggling from app switcher
document.addEventListener("modules-changed", (e) => {
const { enabledModules } = e.detail || {};
window.__rspaceEnabledModules = enabledModules;
const allMods = window.__rspaceAllModules || [];
const enabledSet = new Set(enabledModules);
const visible = allMods.filter(m => m.id === "rspace" || enabledSet.has(m.id));
const tb = document.querySelector("rstack-tab-bar");
if (tb) tb.setModules(visible);
const FolkRApp = customElements.get("folk-rapp");
if (FolkRApp?.setEnabledModules) FolkRApp.setEnabledModules(enabledModules);
document.querySelectorAll("[data-requires-module]").forEach(el => {
el.style.display = enabledSet.has(el.dataset.requiresModule) ? "" : "none";
});
document.querySelectorAll(".toolbar-group").forEach(group => {
const dropdown = group.querySelector(".toolbar-dropdown");
if (!dropdown) return;
const vis = dropdown.querySelectorAll('button:not([style*="display: none"]):not(.toolbar-dropdown-header)');
group.style.display = vis.length === 0 ? "none" : "";
});
});
// ── Dark mode (default dark, toggled from My Account dropdown) ──
{
const savedTheme = localStorage.getItem("canvas-theme") || "dark";
@ -2573,6 +2620,52 @@
}).catch((err) => {
console.warn("[Canvas] Service worker registration failed:", err);
});
// Update banner: detect new SW activation and prompt reload
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.addEventListener("controllerchange", () => {
if (!document.getElementById("sw-update-banner")) {
showSwUpdateBanner();
}
});
}
navigator.serviceWorker.getRegistration().then((reg) => {
if (!reg) return;
if (reg.waiting && navigator.serviceWorker.controller) {
showSwUpdateBanner();
return;
}
reg.addEventListener("updatefound", () => {
const nw = reg.installing;
if (!nw) return;
nw.addEventListener("statechange", () => {
if (nw.state === "installed" && navigator.serviceWorker.controller) {
showSwUpdateBanner();
}
});
});
});
}
function showSwUpdateBanner() {
if (document.getElementById("sw-update-banner")) return;
const b = document.createElement("div");
b.id = "sw-update-banner";
b.setAttribute("role", "alert");
Object.assign(b.style, {
position: "fixed", top: "0", left: "0", right: "0", zIndex: "10000",
display: "flex", alignItems: "center", justifyContent: "center", gap: "12px",
padding: "10px 16px", background: "linear-gradient(135deg, #6366f1, #8b5cf6)",
color: "white", fontSize: "14px", fontWeight: "500",
fontFamily: "system-ui, -apple-system, sans-serif",
boxShadow: "0 2px 12px rgba(0,0,0,0.3)",
animation: "sw-slide-down 0.3s ease-out",
});
b.innerHTML = '<span>New version available</span>'
+ '<button style="padding:5px 14px;border-radius:6px;border:1.5px solid rgba(255,255,255,0.5);background:rgba(255,255,255,0.15);color:white;font-size:13px;font-weight:600;cursor:pointer">Tap to update</button>'
+ '<button style="position:absolute;right:12px;top:50%;transform:translateY(-50%);background:none;border:none;color:rgba(255,255,255,0.7);font-size:20px;cursor:pointer;padding:4px 8px;line-height:1" aria-label="Dismiss">&times;</button>';
document.body.prepend(b);
b.querySelector("button")?.addEventListener("click", () => location.reload());
b.querySelector("[aria-label=Dismiss]")?.addEventListener("click", () => b.remove());
}
// Register custom elements
@ -3379,8 +3472,11 @@
sync.addEventListener("presence", (e) => {
// Always track last cursor for Navigate-to, but only show cursors in multiplayer
const pid = e.detail.peerId;
if (pid && e.detail.cursor && onlinePeers.has(pid)) {
onlinePeers.get(pid).lastCursor = e.detail.cursor;
if (pid && onlinePeers.has(pid)) {
const peerInfo = onlinePeers.get(pid);
if (e.detail.cursor) peerInfo.lastCursor = e.detail.cursor;
// Refresh lastSeen on collab overlay so GC doesn't evict active peers
collabOverlay?.updatePeer(pid, peerInfo.username, peerInfo.color);
}
if (isMultiplayer) {
presence.updatePresence(e.detail);
@ -6281,8 +6377,14 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
});
// Corner zoom toggle — expand/collapse zoom controls
document.getElementById("corner-zoom-toggle").addEventListener("click", () => {
document.getElementById("canvas-corner-tools").classList.toggle("collapsed");
const cornerTools = document.getElementById("canvas-corner-tools");
const zoomToggleBtn = document.getElementById("corner-zoom-toggle");
const zoomExpandSVG = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>';
const zoomMinimizeSVG = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>';
zoomToggleBtn.addEventListener("click", () => {
const isNowCollapsed = cornerTools.classList.toggle("collapsed");
zoomToggleBtn.innerHTML = isNowCollapsed ? zoomExpandSVG : zoomMinimizeSVG;
zoomToggleBtn.title = isNowCollapsed ? "Zoom Controls" : "Hide Zoom";
});
// Mobile toolbar toggle — collapse behavior same as desktop
@ -6397,6 +6499,13 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
collapseBtn.title = isCollapsed ? "Expand toolbar" : "Minimize toolbar";
});
// Auto-collapse toolbar on mobile
if (window.innerWidth <= 768) {
toolbarEl.classList.add("collapsed");
collapseBtn.innerHTML = wrenchSVG;
collapseBtn.title = "Expand toolbar";
}
// Mobile zoom controls (separate from toolbar)
document.getElementById("mz-in").addEventListener("click", () => {
scale = Math.min(scale * 1.1, maxScale);

View File

@ -132,3 +132,89 @@ document.addEventListener("auth-change", (e) => {
window.location.href = "/";
}
});
// ── SW Update Banner ──
// Show "new version available" when a new service worker activates.
// The SW calls skipWaiting() so it activates immediately — we detect the
// controller change and prompt the user to reload for the fresh content.
if ("serviceWorker" in navigator && location.hostname !== "localhost") {
// Only listen if there's already a controller (skip first-time install)
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.addEventListener("controllerchange", () => {
showUpdateBanner();
});
}
// Also detect waiting workers (edge case: skipWaiting didn't fire yet)
navigator.serviceWorker.getRegistration().then((reg) => {
if (!reg) return;
if (reg.waiting && navigator.serviceWorker.controller) {
showUpdateBanner();
return;
}
reg.addEventListener("updatefound", () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener("statechange", () => {
if (newWorker.state === "installed" && navigator.serviceWorker.controller) {
showUpdateBanner();
}
});
});
});
}
function showUpdateBanner() {
if (document.getElementById("sw-update-banner")) return;
const banner = document.createElement("div");
banner.id = "sw-update-banner";
banner.setAttribute("role", "alert");
banner.innerHTML = `
<span>New version available</span>
<button id="sw-update-btn">Tap to update</button>
<button id="sw-update-dismiss" aria-label="Dismiss">&times;</button>
`;
const style = document.createElement("style");
style.textContent = `
#sw-update-banner {
position: fixed; top: 0; left: 0; right: 0; z-index: 10000;
display: flex; align-items: center; justify-content: center; gap: 12px;
padding: 10px 16px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: white; font-size: 14px; font-weight: 500;
font-family: system-ui, -apple-system, sans-serif;
box-shadow: 0 2px 12px rgba(0,0,0,0.3);
animation: sw-slide-down 0.3s ease-out;
}
@keyframes sw-slide-down {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
#sw-update-btn {
padding: 5px 14px; border-radius: 6px; border: 1.5px solid rgba(255,255,255,0.5);
background: rgba(255,255,255,0.15); color: white;
font-size: 13px; font-weight: 600; cursor: pointer;
transition: background 0.15s;
}
#sw-update-btn:hover { background: rgba(255,255,255,0.3); }
#sw-update-dismiss {
position: absolute; right: 12px; top: 50%; transform: translateY(-50%);
background: none; border: none; color: rgba(255,255,255,0.7);
font-size: 20px; cursor: pointer; padding: 4px 8px; line-height: 1;
}
#sw-update-dismiss:hover { color: white; }
`;
document.head.appendChild(style);
document.body.prepend(banner);
banner.querySelector("#sw-update-btn")!.addEventListener("click", () => {
window.location.reload();
});
banner.querySelector("#sw-update-dismiss")!.addEventListener("click", () => {
banner.remove();
});
}