From 6b81f808f55fa07c156ba9f2ccbf4e769b0a2d13 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:34:48 -0700 Subject: [PATCH] feat(canvas): mobile toolbar flush, zoom icon toggle, SW update banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mobile toolbar collapsed position now flush with bottom-toolbar (bottom: 8px) - Toolbar defaults to collapsed on mobile (<768px) - Zoom expand/minimize uses distinct icons (magnifier +/-) instead of CSS rotation - SW update banner on all pages: "New version available — Tap to update" - Detects controllerchange + updatefound events - Purple gradient bar, dismissible, reloads on tap - Added to both shell.ts (module pages) and canvas.html (standalone) - folk-rapp: filter picker/switcher by enabled modules - server/shell: react to modules-changed event for runtime module toggling - collab-presence: minor overlay updates Co-Authored-By: Claude Opus 4.6 --- lib/folk-rapp.ts | 16 +++ server/shell.ts | 37 ++++++ shared/collab-presence.ts | 23 +++- shared/components/rstack-collab-overlay.ts | 10 +- website/canvas.html | 129 +++++++++++++++++++-- website/shell.ts | 86 ++++++++++++++ 6 files changed, 286 insertions(+), 15 deletions(-) diff --git a/lib/folk-rapp.ts b/lib/folk-rapp.ts index 077e9f7..401247a 100644 --- a/lib/folk-rapp.ts +++ b/lib/folk-rapp.ts @@ -576,6 +576,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 | 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 +817,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]) => ` ' + + ''; + 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 = ''; + const zoomMinimizeSVG = ''; + 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); diff --git a/website/shell.ts b/website/shell.ts index ee2437b..d1bb047 100644 --- a/website/shell.ts +++ b/website/shell.ts @@ -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 = ` + New version available + + + `; + + 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(); + }); +}