From 8f1ad52557275132baea24d3455072cc418015cf Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 11:46:26 -0700 Subject: [PATCH] =?UTF-8?q?feat(collab):=20unify=20header=20&=20tab-row=20?= =?UTF-8?q?=E2=80=94=20shared=20people-panel=20across=20all=20rApps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- shared/components/rstack-collab-overlay.ts | 434 ++++++++++++++++++-- website/canvas.html | 438 ++------------------- website/public/shell.css | 1 + 3 files changed, 444 insertions(+), 429 deletions(-) diff --git a/shared/components/rstack-collab-overlay.ts b/shared/components/rstack-collab-overlay.ts index e8e487a..04f4716 100644 --- a/shared/components/rstack-collab-overlay.ts +++ b/shared/components/rstack-collab-overlay.ts @@ -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 | 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 = `
+
+
+

People Online

+ 1 +
+
+
`; - 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(`
${this.#escHtml(msg)}
`); + } + + // Self row with Solo/Share toggle + const isSolo = this.#soloMode; + fragments.push(` +
+ + ${this.#escHtml(this.#localUsername)} (you) + + + + +
+ `); + + // Remote peer rows + const isCanvas = this.#moduleId === 'rspace'; + for (const [pid, peer] of this.#peers) { + fragments.push(` +
+ + ${this.#escHtml(peer.username)} + ${isCanvas ? `` : ''} +
+ `); + // Expanded actions for canvas + if (isCanvas && this.#openActionsId === pid) { + fragments.push(` +
+ + +
+ `); + } + } + + 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 = ` + + Offline + `; + badge.classList.add('visible'); + badge.title = 'You\u2019re offline \u2014 changes saved locally.'; + return; + } + + if (this.#connState === 'reconnecting' || this.#connState === 'connecting') { + badge.innerHTML = ` + + Reconnecting\u2026 + `; + 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 { \u{1F465} ${count} online `; 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); + } + } `; diff --git a/website/canvas.html b/website/canvas.html index 322e34b..ea82d1e 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -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 @@ - +
+ + +
+
+ + +
@@ -1967,25 +1764,13 @@ -
- -
- -
- - 1 online -
-
-
-

People Online

- 0 -
-
-
+ + +

Loading...

@@ -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 = ` - ${escapeHtml(storedUsername)} (you) - - - - `; - 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 = ` - ${escapeHtml(peer.username)} - `; - 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 = ` - - - - `; - 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 diff --git a/website/public/shell.css b/website/public/shell.css index 70a4d96..16d44cf 100644 --- a/website/public/shell.css +++ b/website/public/shell.css @@ -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. */