feat(canvas): mobile toolbar flush, zoom icon toggle, SW update banner

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-25 16:34:48 -07:00
parent 76a846cc36
commit 6b81f808f5
6 changed files with 286 additions and 15 deletions

View File

@ -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<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 +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]) => `
<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 +1175,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

@ -440,6 +440,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)
@ -362,9 +363,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();
});
}