feat(collab): unify header & tab-row — shared people-panel across all rApps

Canvas now uses the same rstack-collab-overlay component as all other
rApps instead of its own custom #people-online-badge. Header restructured
to match renderShell() layout (history/settings in dropdown-wraps on left).
Bridge API (updatePeer/removePeer/setConnState/clearPeers) lets canvas
feed CommunitySync peers into the shared component.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 11:46:26 -07:00
parent e5466491c7
commit 8f1ad52557
3 changed files with 444 additions and 429 deletions

View File

@ -3,10 +3,12 @@
*
* Features:
* - "N online" badge (top-right pill with colored dots)
* - People panel dropdown (click badge to open)
* - Solo/Share mode toggle in panel
* - Remote cursors (SVG arrows with username labels, viewport-relative)
* - Focus highlighting (colored outline rings on data-collab-id elements)
* - Auto-discovery via rspace-doc-subscribe events from runtime
* - Hides on canvas page (rSpace has its own CommunitySync)
* - Bridge API for canvas (external peer management via CommunitySync)
*
* Attributes:
* module-id current module identifier
@ -45,8 +47,11 @@ export class RStackCollabOverlay extends HTMLElement {
#lastCursor = { x: 0, y: 0 };
#gcInterval: ReturnType<typeof setInterval> | null = null;
#badgeOnly = false;
#hidden = false; // true on canvas page
#soloMode = false;
#panelOpen = false;
#connState: 'connected' | 'offline' | 'reconnecting' | 'connecting' = 'connecting';
#externalPeers = false; // true for canvas — peers fed in via public API
#openActionsId: string | null = null; // which peer's actions dropdown is open
constructor() {
super();
@ -58,21 +63,13 @@ export class RStackCollabOverlay extends HTMLElement {
this.#badgeOnly = this.getAttribute('mode') === 'badge-only';
this.#soloMode = localStorage.getItem('rspace_solo_mode') === '1';
// Hide on canvas page — it has its own CommunitySync + PresenceManager
// Canvas page — peers fed externally via bridge API, skip runtime connection
if (this.#moduleId === 'rspace') {
this.#hidden = true;
// Still dispatch initial solo-mode event for canvas PresenceManager
this.#externalPeers = true;
// Dispatch initial solo-mode event for canvas PresenceManager
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
return;
}
// Explicit doc-id attribute (fallback)
const explicitDocId = this.getAttribute('doc-id');
if (explicitDocId) this.#docId = explicitDocId;
// Listen for runtime doc subscriptions (auto-discovery)
window.addEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
// Resolve local identity
this.#resolveIdentity();
@ -80,23 +77,73 @@ export class RStackCollabOverlay extends HTMLElement {
this.#render();
this.#renderBadge();
// Try connecting to runtime
this.#tryConnect();
if (!this.#externalPeers) {
// Explicit doc-id attribute (fallback)
const explicitDocId = this.getAttribute('doc-id');
if (explicitDocId) this.#docId = explicitDocId;
// GC stale peers every 5s
this.#gcInterval = setInterval(() => this.#gcPeers(), 5000);
// Listen for runtime doc subscriptions (auto-discovery)
window.addEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
// 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)
document.addEventListener('click', this.#onDocumentClick);
}
disconnectedCallback() {
if (this.#hidden) return;
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
this.#unsubAwareness?.();
this.#stopMouseTracking();
this.#stopFocusTracking();
document.removeEventListener('click', this.#onDocumentClick);
if (!this.#externalPeers) {
window.removeEventListener('rspace-doc-subscribe', this.#onDocSubscribe);
this.#unsubAwareness?.();
this.#stopMouseTracking();
this.#stopFocusTracking();
}
if (this.#gcInterval) clearInterval(this.#gcInterval);
this.#gcInterval = null;
}
// ── Public bridge API (for canvas CommunitySync) ──
updatePeer(peerId: string, username: string, color: string) {
const existing = this.#peers.get(peerId);
this.#peers.set(peerId, {
peerId,
username: username || existing?.username || 'Anonymous',
color: color || existing?.color || this.#colorForPeer(peerId),
cursor: existing?.cursor ?? null,
selection: existing?.selection ?? null,
lastSeen: Date.now(),
});
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
}
removePeer(peerId: string) {
this.#peers.delete(peerId);
if (this.#openActionsId === peerId) this.#openActionsId = null;
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
}
setConnState(state: 'connected' | 'offline' | 'reconnecting' | 'connecting') {
this.#connState = state;
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
}
clearPeers() {
this.#peers.clear();
this.#openActionsId = null;
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
}
// ── Auto-discovery ──
#onDocSubscribe = (e: Event) => {
@ -131,6 +178,7 @@ export class RStackCollabOverlay extends HTMLElement {
this.#localPeerId = runtime.peerId;
// Assign a deterministic color from peer ID
this.#localColor = this.#colorForPeer(this.#localPeerId!);
this.#connState = 'connected';
if (this.#docId) {
this.#connectToDoc();
@ -266,6 +314,17 @@ export class RStackCollabOverlay extends HTMLElement {
}
};
// ── Click-outside closes panel ──
#onDocumentClick = (e: MouseEvent) => {
if (!this.#panelOpen) return;
// Check if click was inside our shadow DOM
const path = e.composedPath();
if (path.includes(this)) return;
this.#panelOpen = false;
this.#shadow.getElementById('people-panel')?.classList.remove('open');
};
// ── GC stale peers ──
#gcPeers() {
@ -283,6 +342,7 @@ export class RStackCollabOverlay extends HTMLElement {
this.#renderCursors();
this.#renderFocusRings();
}
if (this.#panelOpen) this.#renderPanel();
}
}
@ -302,28 +362,136 @@ export class RStackCollabOverlay extends HTMLElement {
this.#shadow.innerHTML = `
<style>${OVERLAY_CSS}</style>
<div class="collab-badge visible" id="badge"></div>
<div class="people-panel" id="people-panel">
<div class="people-panel-header">
<h3>People Online</h3>
<span class="panel-count" id="panel-count">1</span>
</div>
<div class="people-list" id="people-list"></div>
</div>
<div class="collab-cursors" id="cursors"></div>
`;
this.#shadow.getElementById('badge')?.addEventListener('click', () => this.#toggleSoloMode());
this.#shadow.getElementById('badge')?.addEventListener('click', (e) => {
e.stopPropagation();
this.#togglePanel();
});
}
#toggleSoloMode() {
this.#soloMode = !this.#soloMode;
localStorage.setItem('rspace_solo_mode', this.#soloMode ? '1' : '0');
// ── Panel toggle ──
if (this.#soloMode) {
// Clear remote peers and their visual artifacts
this.#peers.clear();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
#togglePanel() {
this.#panelOpen = !this.#panelOpen;
const panel = this.#shadow.getElementById('people-panel');
if (this.#panelOpen) {
panel?.classList.add('open');
this.#renderPanel();
} else {
panel?.classList.remove('open');
}
}
// ── Panel rendering ──
#renderPanel() {
const list = this.#shadow.getElementById('people-list');
const countEl = this.#shadow.getElementById('panel-count');
if (!list) return;
const count = this.#peers.size + 1;
if (countEl) countEl.textContent = this.#connState === 'connected' ? String(count) : '\u2014';
const fragments: string[] = [];
// Connection state notice
if (this.#connState === 'offline' || this.#connState === 'reconnecting' || this.#connState === 'connecting') {
const msg = this.#connState === 'offline'
? '\u26a0 You\u2019re offline. Changes are saved locally and will resync when you reconnect.'
: 'Reconnecting to server\u2026';
fragments.push(`<div class="conn-notice">${this.#escHtml(msg)}</div>`);
}
// Self row with Solo/Share toggle
const isSolo = this.#soloMode;
fragments.push(`
<div class="people-row">
<span class="dot" style="background:${this.#localColor}"></span>
<span class="name">${this.#escHtml(this.#localUsername)} <span class="you-tag">(you)</span></span>
<span class="mode-toggle" title="Toggle cursor sharing with other users">
<button class="mode-solo ${isSolo ? 'active' : ''}" data-action="solo">Solo</button>
<button class="mode-multi ${isSolo ? '' : 'active'}" data-action="share">Share</button>
</span>
</div>
`);
// Remote peer rows
const isCanvas = this.#moduleId === 'rspace';
for (const [pid, peer] of this.#peers) {
fragments.push(`
<div class="people-row">
<span class="dot" style="background:${peer.color}"></span>
<span class="name">${this.#escHtml(peer.username)}</span>
${isCanvas ? `<button class="actions-btn" data-pid="${this.#escHtml(pid)}">&gt;</button>` : ''}
</div>
`);
// Expanded actions for canvas
if (isCanvas && this.#openActionsId === pid) {
fragments.push(`
<div class="people-actions">
<button data-action="navigate" data-pid="${this.#escHtml(pid)}">Navigate to</button>
<button data-action="ping" data-pid="${this.#escHtml(pid)}">Ping to join you</button>
</div>
`);
}
}
list.innerHTML = fragments.join('');
// Wire up event delegation on the list
list.onclick = (e) => {
const btn = (e.target as HTMLElement).closest('button');
if (!btn) return;
const action = btn.dataset.action;
const pid = btn.dataset.pid;
if (action === 'solo') {
this.#setSoloMode(true);
} else if (action === 'share') {
this.#setSoloMode(false);
} else if (btn.classList.contains('actions-btn') && pid) {
this.#openActionsId = this.#openActionsId === pid ? null : pid;
this.#renderPanel();
} else if ((action === 'navigate' || action === 'ping') && pid) {
this.dispatchEvent(new CustomEvent('collab-peer-action', {
bubbles: true,
composed: true,
detail: { action, peerId: pid },
}));
}
};
}
// ── Solo mode ──
#setSoloMode(solo: boolean) {
this.#soloMode = solo;
localStorage.setItem('rspace_solo_mode', solo ? '1' : '0');
if (solo) {
// Clear remote peers and their visual artifacts (for non-canvas modules)
if (!this.#externalPeers) {
this.#peers.clear();
if (!this.#badgeOnly) {
this.#renderCursors();
this.#renderFocusRings();
}
}
}
this.#renderBadge();
if (this.#panelOpen) this.#renderPanel();
// Notify canvas PresenceManager and any other listeners
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo: this.#soloMode } }));
document.dispatchEvent(new CustomEvent('solo-mode-change', { detail: { solo } }));
}
#renderBadge() {
@ -338,6 +506,27 @@ export class RStackCollabOverlay extends HTMLElement {
}
badge.classList.remove('solo');
if (this.#connState === 'offline') {
badge.innerHTML = `
<span class="dot" style="background:#f59e0b"></span>
<span class="count">Offline</span>
`;
badge.classList.add('visible');
badge.title = 'You\u2019re offline \u2014 changes saved locally.';
return;
}
if (this.#connState === 'reconnecting' || this.#connState === 'connecting') {
badge.innerHTML = `
<span class="dot" style="background:#3b82f6"></span>
<span class="count">Reconnecting\u2026</span>
`;
badge.classList.add('visible');
badge.title = 'Reconnecting to server\u2026';
return;
}
const count = this.#peers.size + 1; // +1 for self
const dots = Array.from(this.#peers.values())
@ -351,7 +540,7 @@ export class RStackCollabOverlay extends HTMLElement {
<span class="count">\u{1F465} ${count} online</span>
`;
badge.classList.add('visible');
badge.title = 'Online \u2014 sharing your presence. Click to go offline.';
badge.title = `${count} online \u2014 click to see people`;
}
#renderCursors() {
@ -494,6 +683,172 @@ const OVERLAY_CSS = `
font-weight: 500;
}
/* ── People panel dropdown ── */
.people-panel {
display: none;
position: absolute;
top: calc(100% + 4px);
right: 0;
width: 280px;
max-height: calc(100vh - 120px);
background: var(--rs-bg-surface, #1e1e1e);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(255,255,255,0.08);
z-index: 10001;
overflow: hidden;
pointer-events: auto;
flex-direction: column;
}
.people-panel.open {
display: flex;
}
.people-panel-header {
padding: 12px 16px;
border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.06));
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.people-panel-header h3 {
font-size: 14px;
color: var(--rs-text-primary, #fff);
margin: 0;
font-weight: 600;
}
.panel-count {
font-size: 12px;
color: var(--rs-text-secondary, #ccc);
background: var(--rs-bg-surface-raised, rgba(255,255,255,0.06));
padding: 2px 8px;
border-radius: 10px;
}
.people-list {
overflow-y: auto;
flex: 1;
padding: 8px;
}
.conn-notice {
padding: 8px 16px;
font-size: 12px;
color: var(--rs-text-muted, #888);
background: var(--rs-bg-surface-raised, rgba(255,255,255,0.04));
border-bottom: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.06));
}
.people-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s;
}
.people-row:hover {
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
}
.people-row .dot {
width: 10px;
height: 10px;
}
.people-row .name {
flex: 1;
font-size: 13px;
color: var(--rs-text-primary, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.you-tag {
font-size: 11px;
color: #94a3b8;
font-weight: normal;
}
/* ── Mode toggle (Solo/Share) ── */
.mode-toggle {
display: flex;
align-items: center;
gap: 0;
border: 1px solid var(--rs-border, rgba(255,255,255,0.12));
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.mode-toggle button {
padding: 3px 8px;
border: none;
background: none;
font-size: 11px;
color: var(--rs-text-muted, #888);
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.mode-toggle button.active {
background: var(--rs-accent, #3b82f6);
color: #fff;
}
.mode-toggle button:not(.active):hover {
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
}
/* ── Peer action buttons (canvas only) ── */
.actions-btn {
padding: 2px 8px;
border: 1px solid var(--rs-border, rgba(255,255,255,0.12));
border-radius: 6px;
background: var(--rs-bg-surface, #1e1e1e);
cursor: pointer;
font-size: 12px;
color: var(--rs-text-muted, #888);
transition: background 0.15s;
}
.actions-btn:hover {
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
}
.people-actions {
padding: 4px 8px 8px 30px;
}
.people-actions button {
display: block;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
text-align: left;
font-size: 12px;
color: var(--rs-text-secondary, #ccc);
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
}
.people-actions button:hover {
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
}
/* ── Cursors ── */
.collab-cursors {
position: fixed;
top: 0;
@ -524,4 +879,15 @@ const OVERLAY_CSS = `
line-height: 14px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* ── Mobile ── */
@media (max-width: 640px) {
.count {
display: none;
}
.people-panel {
max-width: calc(100vw - 32px);
}
}
`;

View File

@ -959,201 +959,6 @@
}
/* ── People Online badge ── */
#people-online-badge {
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
color: var(--rs-text-muted);
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
user-select: none;
transition: background 0.15s;
flex-shrink: 0;
margin: 0 8px 0 4px;
position: relative;
border-left: 1px solid var(--rs-border-subtle, rgba(255,255,255,0.08));
padding-left: 12px;
}
#people-online-badge:hover {
background: var(--rs-bg-hover, rgba(255,255,255,0.08));
}
#people-dots {
display: flex;
gap: 3px;
align-items: center;
}
#people-dots .dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
/* ── People Panel ── */
#people-panel {
position: absolute;
top: 100%;
right: 0;
width: 280px;
max-height: calc(100vh - 120px);
background: var(--rs-bg-surface);
border-radius: 12px;
box-shadow: var(--rs-shadow-lg);
z-index: 10001;
display: none;
overflow: hidden;
margin-top: 4px;
}
#people-panel.open {
display: flex;
flex-direction: column;
}
#people-panel-header {
padding: 12px 16px;
border-bottom: 1px solid var(--rs-toolbar-panel-border);
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
#people-panel-header h3 {
font-size: 14px;
color: var(--rs-text-primary);
margin: 0;
}
#people-panel-header .count {
font-size: 12px;
color: var(--rs-text-secondary);
background: var(--rs-bg-surface-raised);
padding: 2px 8px;
border-radius: 10px;
}
#people-list {
overflow-y: auto;
flex: 1;
padding: 8px;
}
.people-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 8px;
transition: background 0.15s;
}
.people-row:hover {
background: var(--rs-bg-hover);
}
.people-row .dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.people-row .name {
flex: 1;
font-size: 13px;
color: var(--rs-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.people-row .name .you-tag {
font-size: 11px;
color: #94a3b8;
font-weight: normal;
}
/* ── Mode toggle (single/multi player) ── */
.mode-toggle {
display: flex;
align-items: center;
gap: 0;
border: 1px solid var(--rs-input-border);
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
}
.mode-toggle button {
padding: 3px 8px;
border: none;
background: none;
font-size: 11px;
color: var(--rs-text-muted);
cursor: pointer;
transition: background 0.15s, color 0.15s;
white-space: nowrap;
}
.mode-toggle button.active {
background: var(--rs-accent, #3b82f6);
color: #fff;
}
.mode-toggle button:not(.active):hover {
background: var(--rs-bg-hover);
}
.people-row .actions-btn {
padding: 2px 8px;
border: 1px solid var(--rs-input-border);
border-radius: 6px;
background: var(--rs-bg-surface);
cursor: pointer;
font-size: 12px;
color: var(--rs-text-muted);
transition: background 0.15s;
}
.people-row .actions-btn:hover {
background: var(--rs-bg-hover);
}
.people-actions {
padding: 4px 8px 8px 30px;
}
.people-actions button {
display: block;
width: 100%;
padding: 6px 10px;
border: none;
background: none;
text-align: left;
font-size: 12px;
color: var(--rs-text-secondary);
cursor: pointer;
border-radius: 6px;
transition: background 0.15s;
}
.people-actions button:hover:not(:disabled) {
background: var(--rs-bg-hover);
}
.people-actions button:disabled {
color: var(--rs-text-muted);
cursor: default;
}
/* ── Multiplayer notification toast ── */
#mp-notify {
position: fixed;
@ -1241,21 +1046,6 @@
/* Dark/light mode handled by CSS custom properties in theme.css */
/* ── People panel mobile ── */
@media (max-width: 640px) {
#people-online-badge {
margin-left: 2px;
padding: 4px 6px;
}
#people-badge-text {
display: none;
}
#people-panel {
max-width: calc(100vw - 32px);
right: -8px;
}
}
#canvas-content {
position: absolute;
top: 0;
@ -1957,7 +1747,14 @@
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/favicon.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current="rspace"></rstack-app-switcher>
<rstack-space-switcher current="" name=""></rstack-space-switcher>
<button class="rstack-header__history-btn" id="history-btn" title="History"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></button>
<div class="rstack-header__dropdown-wrap">
<button class="rstack-header__history-btn" id="history-btn" title="History"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></button>
<rstack-history-panel type="canvas"></rstack-history-panel>
</div>
<div class="rstack-header__dropdown-wrap">
<button class="rstack-header__settings-btn" id="settings-btn" title="Space Settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<rstack-space-settings space="" module-id="rspace"></rstack-space-settings>
</div>
</div>
<div class="rstack-header__center">
<rstack-mi></rstack-mi>
@ -1967,25 +1764,13 @@
<rstack-comment-bell></rstack-comment-bell>
<rstack-notification-bell></rstack-notification-bell>
<rstack-share-panel></rstack-share-panel>
<button class="rstack-header__settings-btn" id="settings-btn" title="Space Settings"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
<rstack-identity></rstack-identity>
</div>
</header>
<rstack-space-settings></rstack-space-settings>
<rstack-history-panel type="canvas"></rstack-history-panel>
<div class="rstack-tab-row" data-theme="dark">
<rstack-tab-bar space="" active="" view-mode="flat"></rstack-tab-bar>
<div id="people-online-badge">
<span class="dots" id="people-dots"></span>
<span id="people-badge-text">1 online</span>
</div>
<div id="people-panel">
<div id="people-panel-header">
<h3>People Online</h3>
<span class="count" id="people-count">0</span>
</div>
<div id="people-list"></div>
</div>
<rstack-tab-bar space="" active="" view-mode="flat">
<rstack-collab-overlay module-id="rspace" space=""></rstack-collab-overlay>
</rstack-tab-bar>
</div>
<div id="community-info">
<h2 id="community-name">Loading...</h2>
@ -2738,6 +2523,8 @@
if (tabBar) {
tabBar.setAttribute("space", communitySlug);
document.querySelector("rstack-collab-overlay")?.setAttribute("space", communitySlug);
document.querySelector("rstack-space-settings")?.setAttribute("space", communitySlug);
// Helper: look up a module's display name
function getModuleLabel(id) {
@ -3081,8 +2868,7 @@
document.getElementById("canvas-loading")?.remove();
status.className = "offline";
statusText.textContent = "Offline (cached)";
connState = "offline";
renderPeopleBadge();
collabOverlay?.setConnState("offline");
}
} catch (e) {
console.warn("[Canvas] Offline cache init failed:", e);
@ -3365,16 +3151,10 @@
}
});
// ── People Online panel ──
// ── People Online (bridged to rstack-collab-overlay) ──
const onlinePeers = new Map(); // clientPeerId → { username, color, lastCursor? }
const localColor = peerIdToColor(peerId);
const peoplePanel = document.getElementById("people-panel");
const peopleBadge = document.getElementById("people-online-badge");
const peopleDots = document.getElementById("people-dots");
const peopleBadgeText = document.getElementById("people-badge-text");
const peopleCount = document.getElementById("people-count");
const peopleList = document.getElementById("people-list");
let connState = "connecting"; // "connected" | "offline" | "reconnecting" | "connecting"
const collabOverlay = document.querySelector("rstack-collab-overlay");
const pingToast = document.getElementById("ping-toast");
const pingToastText = document.getElementById("ping-toast-text");
const pingToastGo = document.getElementById("ping-toast-go");
@ -3383,7 +3163,6 @@
const mpNotifySwitch = document.getElementById("mp-notify-switch");
let pingToastTimer = null;
let mpNotifyTimer = null;
let openActionsId = null; // which peer's actions dropdown is open
// ── Single / Multiplayer mode ──
let isMultiplayer = localStorage.getItem("rspace-multiplayer") !== "false"; // default true
@ -3391,12 +3170,14 @@
function setMultiplayerMode(enabled) {
isMultiplayer = enabled;
localStorage.setItem("rspace-multiplayer", enabled ? "true" : "false");
// Show/hide remote cursors
presence.setVisible(enabled);
renderPeopleBadge();
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
}
// Listen for solo-mode-change from the collab overlay
document.addEventListener("solo-mode-change", (e) => {
setMultiplayerMode(!e.detail.solo);
});
mpNotifySwitch.addEventListener("click", () => {
setMultiplayerMode(true);
mpNotify.classList.remove("show");
@ -3404,7 +3185,7 @@
});
function showMpNotification(username) {
if (isMultiplayer) return; // already in multiplayer
if (isMultiplayer) return;
mpNotifyText.textContent = `${username} is online — switch to multiplayer to collaborate`;
mpNotify.classList.add("show");
if (mpNotifyTimer) clearTimeout(mpNotifyTimer);
@ -3417,122 +3198,20 @@
// Announce ourselves
sync.setAnnounceData({ peerId, username: storedUsername, color: localColor });
function renderPeopleBadge() {
const totalCount = onlinePeers.size + 1; // +1 for self
peopleDots.innerHTML = "";
if (connState === "offline") {
// Actually disconnected from internet
peopleBadgeText.textContent = "Offline";
peopleBadge.title = "You\u2019re offline \u2014 your changes are saved locally and will resync when you reconnect to the internet.";
const selfDot = document.createElement("span");
selfDot.className = "dot";
selfDot.style.background = "#f59e0b";
peopleDots.appendChild(selfDot);
} else if (connState === "reconnecting" || connState === "connecting") {
peopleBadgeText.textContent = "Reconnecting\u2026";
peopleBadge.title = "Reconnecting to the server \u2014 your changes are saved locally and will resync automatically.";
const selfDot = document.createElement("span");
selfDot.className = "dot";
selfDot.style.background = "#3b82f6";
peopleDots.appendChild(selfDot);
} else {
// Connected
peopleBadgeText.textContent = totalCount === 1 ? "1 online" : `${totalCount} online`;
peopleBadge.title = "";
// Self dot
const selfDot = document.createElement("span");
selfDot.className = "dot";
selfDot.style.background = localColor;
peopleDots.appendChild(selfDot);
// Remote dots (up to 4)
let dotCount = 0;
for (const [, peer] of onlinePeers) {
if (dotCount >= 4) break;
const dot = document.createElement("span");
dot.className = "dot";
dot.style.background = peer.color || "#94a3b8";
peopleDots.appendChild(dot);
dotCount++;
}
}
peopleCount.textContent = connState === "connected" ? totalCount : "\u2014";
function navigateToPeer(cursor) {
const rect = canvas.getBoundingClientRect();
panX = rect.width / 2 - cursor.x * scale;
panY = rect.height / 2 - cursor.y * scale;
updateCanvasTransform();
}
function renderPeoplePanel() {
peopleList.innerHTML = "";
// Show offline notice if disconnected
if (connState === "offline" || connState === "reconnecting" || connState === "connecting") {
const notice = document.createElement("div");
notice.style.cssText = "padding:8px 16px;font-size:12px;color:var(--rs-text-muted);background:var(--rs-bg-surface-raised,rgba(255,255,255,0.04));border-bottom:1px solid var(--rs-border-subtle,rgba(255,255,255,0.06))";
notice.textContent = connState === "offline"
? "\u26a0 You\u2019re offline. Changes are saved locally and will resync when you reconnect."
: "Reconnecting to server\u2026";
peopleList.appendChild(notice);
}
// Self row with cursor visibility toggle
const selfRow = document.createElement("div");
selfRow.className = "people-row";
selfRow.innerHTML = `<span class="dot" style="background:${escapeHtml(localColor)}"></span>
<span class="name">${escapeHtml(storedUsername)} <span class="you-tag">(you)</span></span>
<span class="mode-toggle" title="Toggle cursor sharing with other users">
<button class="mode-solo ${isMultiplayer ? '' : 'active'}">Solo</button>
<button class="mode-multi ${isMultiplayer ? 'active' : ''}">Share</button>
</span>`;
selfRow.querySelector(".mode-solo").addEventListener("click", () => setMultiplayerMode(false));
selfRow.querySelector(".mode-multi").addEventListener("click", () => setMultiplayerMode(true));
peopleList.appendChild(selfRow);
// Remote peers
for (const [pid, peer] of onlinePeers) {
const row = document.createElement("div");
row.className = "people-row";
row.innerHTML = `<span class="dot" style="background:${escapeHtml(peer.color || '#94a3b8')}"></span>
<span class="name">${escapeHtml(peer.username)}</span>
<button class="actions-btn" data-pid="${escapeHtml(pid)}">&gt;</button>`;
peopleList.appendChild(row);
// If this peer's actions are open, render them
if (openActionsId === pid) {
const actions = document.createElement("div");
actions.className = "people-actions";
const hasPos = !!peer.lastCursor;
actions.innerHTML = `
<button data-action="navigate" data-pid="${escapeHtml(pid)}" ${hasPos ? "" : "disabled"}>Navigate to${hasPos ? "" : " (no position)"}</button>
<button data-action="ping" data-pid="${escapeHtml(pid)}">Ping to join you</button>
<button disabled>Delegate to (coming soon)</button>
<button disabled>Add to space (coming soon)</button>`;
peopleList.appendChild(actions);
}
}
}
// Badge click toggles panel
peopleBadge.addEventListener("click", () => {
const isOpen = peoplePanel.classList.toggle("open");
if (isOpen) {
// Close memory panel if open (shared screen region)
const memPanel = document.getElementById("memory-panel");
if (memPanel) memPanel.classList.remove("open");
const memBtn = document.getElementById("toggle-memory");
if (memBtn) memBtn.classList.remove("active");
renderPeoplePanel();
}
});
// Delegate clicks inside people-list
peopleList.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const pid = btn.dataset.pid;
if (btn.classList.contains("actions-btn")) {
openActionsId = openActionsId === pid ? null : pid;
renderPeoplePanel();
return;
}
const action = btn.dataset.action;
if (action === "navigate" && pid) {
// Handle peer actions from collab overlay (navigate/ping)
collabOverlay?.addEventListener("collab-peer-action", (e) => {
const { action, peerId: pid } = e.detail;
if (action === "navigate") {
const peer = onlinePeers.get(pid);
if (peer?.lastCursor) navigateToPeer(peer.lastCursor);
} else if (action === "ping" && pid) {
} else if (action === "ping") {
const rect = canvas.getBoundingClientRect();
const vx = (rect.width / 2 - panX) / scale;
const vy = (rect.height / 2 - panY) / scale;
@ -3542,43 +3221,24 @@
}
});
// Click-outside closes people panel
document.addEventListener("click", (e) => {
if (peoplePanel.classList.contains("open") &&
!peoplePanel.contains(e.target) &&
!peopleBadge.contains(e.target)) {
peoplePanel.classList.remove("open");
}
});
function navigateToPeer(cursor) {
const rect = canvas.getBoundingClientRect();
panX = rect.width / 2 - cursor.x * scale;
panY = rect.height / 2 - cursor.y * scale;
updateCanvasTransform();
}
// ── People Online event handlers ──
// ── People Online event handlers (bridge to collab overlay) ──
sync.addEventListener("peer-list", (e) => {
onlinePeers.clear();
collabOverlay?.clearPeers();
const peers = e.detail.peers || [];
for (const p of peers) {
if (p.clientPeerId !== peerId) {
onlinePeers.set(p.clientPeerId, { username: p.username, color: p.color });
collabOverlay?.updatePeer(p.clientPeerId, p.username, p.color);
}
}
renderPeopleBadge();
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
});
sync.addEventListener("peer-joined", (e) => {
const d = e.detail;
if (d.peerId !== peerId) {
onlinePeers.set(d.peerId, { username: d.username, color: d.color });
renderPeopleBadge();
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
// In single-player mode, notify that someone joined
collabOverlay?.updatePeer(d.peerId, d.username, d.color);
if (!isMultiplayer) showMpNotification(d.username || "Someone");
}
});
@ -3587,9 +3247,7 @@
const leftId = e.detail.peerId;
onlinePeers.delete(leftId);
presence.removeUser(leftId);
if (openActionsId === leftId) openActionsId = null;
renderPeopleBadge();
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
collabOverlay?.removePeer(leftId);
});
sync.addEventListener("ping-user", (e) => {
@ -3597,7 +3255,6 @@
pingToastText.textContent = `${d.fromUsername || "Someone"} wants you to join them`;
pingToast.classList.add("show");
if (pingToastTimer) clearTimeout(pingToastTimer);
// Store viewport target for Go button
pingToastGo.onclick = () => {
if (d.viewport) navigateToPeer(d.viewport);
pingToast.classList.remove("show");
@ -3608,9 +3265,6 @@
}, 8000);
});
// Initial render
renderPeopleBadge();
// Track if we're processing remote changes to avoid feedback loops
let isProcessingRemote = false;
@ -3618,25 +3272,21 @@
sync.addEventListener("connected", () => {
status.className = "connected";
statusText.textContent = "Connected";
connState = "connected";
renderPeopleBadge();
collabOverlay?.setConnState("connected");
});
sync.addEventListener("disconnected", () => {
if (navigator.onLine) {
status.className = "disconnected";
statusText.textContent = "Reconnecting...";
connState = "reconnecting";
collabOverlay?.setConnState("reconnecting");
} else {
status.className = "offline";
statusText.textContent = "Offline (changes saved locally)";
connState = "offline";
collabOverlay?.setConnState("offline");
}
// Clear online peers on disconnect (they'll re-announce on reconnect)
onlinePeers.clear();
openActionsId = null;
renderPeopleBadge();
if (peoplePanel.classList.contains("open")) renderPeoplePanel();
collabOverlay?.clearPeers();
});
sync.addEventListener("synced", (e) => {
@ -6993,8 +6643,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
console.log("[Canvas] Browser went online, reconnecting...");
status.className = "syncing";
statusText.textContent = "Reconnecting...";
connState = "reconnecting";
renderPeopleBadge();
collabOverlay?.setConnState("reconnecting");
sync.connect(wsUrl);
});
@ -7002,8 +6651,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
console.log("[Canvas] Browser went offline");
status.className = "offline";
statusText.textContent = "Offline (changes saved locally)";
connState = "offline";
renderPeopleBadge();
collabOverlay?.setConnState("offline");
});
// Handle offline-loaded event

View File

@ -376,6 +376,7 @@ body.rstack-sidebar-open #toolbar {
/* ── Mobile adjustments ── */
@media (max-width: 640px) {
.rapp-info-btn { display: none; }
/* Switch header + tab row from fixed to sticky on mobile.
This avoids needing a magic padding-top on #app and lets
the header wrap naturally to two rows. */