feat: persistent tab bar, offline save improvements, and shape validation

- Tab bar state persists to localStorage per space
- Emergency synchronous localStorage fallback for beforeunload saves
- Merge Automerge full-sync instead of replacing (preserves local changes)
- Validate shape coordinates before applying (prevent NaN/Infinity)
- Save on visibilitychange for mobile browser tab backgrounding
- Add OutputPath type for module browsable content types
- Fix canvas module ID from "canvas" to "rspace" in tab-cache

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-01 11:11:14 -08:00
parent f69ee9f977
commit a61f562bbf
5 changed files with 248 additions and 151 deletions

View File

@ -305,10 +305,11 @@ export class CommunitySync extends EventTarget {
break;
case "full-sync":
// Server sending full document (for initial load)
// Server sending full document — merge to preserve local changes
if (msg.doc) {
const binary = new Uint8Array(msg.doc);
this.#doc = Automerge.load<CommunityDoc>(binary);
const serverDoc = Automerge.load<CommunityDoc>(binary);
this.#doc = Automerge.merge(this.#doc, serverDoc);
this.#syncState = Automerge.initSyncState();
this.#applyDocToDOM();
this.#scheduleSave();
@ -839,20 +840,29 @@ export class CommunitySync extends EventTarget {
/**
* Save current state immediately. Call from beforeunload handler.
* Uses synchronous localStorage fallback since IndexedDB may not complete.
*/
saveBeforeUnload(): void {
if (!this.#offlineStore) return;
if (this.#saveDebounceTimer) {
clearTimeout(this.#saveDebounceTimer);
this.#saveDebounceTimer = null;
}
try {
this.#offlineStore.saveDocImmediate(
this.#communitySlug,
Automerge.save(this.#doc)
);
const binary = Automerge.save(this.#doc);
// Synchronous localStorage fallback (guaranteed to complete)
if (this.#offlineStore) {
this.#offlineStore.saveDocEmergency(this.#communitySlug, binary);
}
// Also fire off IndexedDB save (may not complete before page dies)
if (this.#offlineStore) {
this.#offlineStore.saveDocImmediate(this.#communitySlug, binary);
}
// Push any pending Automerge changes to the server
this.#syncToServer();
} catch (e) {
console.warn("[CommunitySync] Failed to save before unload:", e);
}

View File

@ -87,13 +87,58 @@ export class OfflineStore {
}
this.#pendingSaves.delete(slug);
// Belt-and-suspenders: also write to localStorage synchronously
this.saveDocEmergency(slug, docBinary);
await this.#writeDoc(slug, docBinary);
}
/**
* Synchronous emergency save to localStorage.
* Used as a fallback when IndexedDB may not complete (e.g. beforeunload).
* The data is base64-encoded to fit in localStorage's string-only storage.
*/
saveDocEmergency(slug: string, docBinary: Uint8Array): void {
try {
const key = `rspace-save-${slug}`;
// Convert Uint8Array to base64 string
let binary = "";
for (let i = 0; i < docBinary.length; i++) {
binary += String.fromCharCode(docBinary[i]);
}
const base64 = btoa(binary);
localStorage.setItem(key, base64);
} catch (e) {
// localStorage may be full or unavailable — silently fail
console.warn("[OfflineStore] Emergency save failed:", e);
}
}
/**
* Load cached Automerge document binary.
* Checks for emergency localStorage save first, migrates it to IndexedDB if found.
*/
async loadDoc(slug: string): Promise<Uint8Array | null> {
// Check for emergency localStorage fallback first
const emergencyKey = `rspace-save-${slug}`;
try {
const base64 = localStorage.getItem(emergencyKey);
if (base64) {
console.log("[OfflineStore] Recovering from emergency localStorage save for", slug);
const binary = atob(base64);
const docBinary = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
docBinary[i] = binary.charCodeAt(i);
}
// Migrate to IndexedDB and clean up localStorage
await this.#writeDoc(slug, docBinary);
localStorage.removeItem(emergencyKey);
return docBinary;
}
} catch (e) {
console.warn("[OfflineStore] Failed to recover emergency save:", e);
}
const entry = await this.#getEntry(slug);
return entry?.docBinary ?? null;
}

View File

@ -2,6 +2,18 @@ import { Hono } from "hono";
import type { FlowKind, FeedDefinition } from "../lib/layer-types";
export type { FeedDefinition } from "../lib/layer-types";
/** A browsable content type that a module produces. */
export interface OutputPath {
/** URL segment: "notebooks" */
path: string;
/** Display name: "Notebooks" */
name: string;
/** Emoji: "📓" */
icon: string;
/** Short description: "Rich-text collaborative notebooks" */
description: string;
}
/**
* The contract every rSpace module must implement.
*
@ -33,6 +45,8 @@ export interface RSpaceModule {
onSpaceDelete?: (spaceSlug: string) => Promise<void>;
/** If true, module is hidden from app switcher (still has routes) */
hidden?: boolean;
/** Browsable content types this module produces */
outputPaths?: OutputPath[];
/** Optional: render rich landing page body HTML */
landingPage?: () => string;
/** Optional: external app to embed via iframe when ?view=app */
@ -74,6 +88,7 @@ export interface ModuleInfo {
url: string;
name: string;
};
outputPaths?: OutputPath[];
}
export function getModuleInfoList(): ModuleInfo[] {
@ -89,5 +104,6 @@ export function getModuleInfoList(): ModuleInfo[] {
...(m.acceptsFeeds ? { acceptsFeeds: m.acceptsFeeds } : {}),
...(m.landingPage ? { hasLandingPage: true } : {}),
...(m.externalApp ? { externalApp: m.externalApp } : {}),
...(m.outputPaths ? { outputPaths: m.outputPaths } : {}),
}));
}

View File

@ -255,7 +255,7 @@ export class TabCache {
private updateCanvasLayout(moduleId: string): void {
const app = document.getElementById("app");
if (!app) return;
if (moduleId === "canvas") {
if (moduleId === "rspace") {
app.classList.add("canvas-layout");
} else {
app.classList.remove("canvas-layout");

View File

@ -858,8 +858,8 @@
<body data-theme="dark">
<header class="rstack-header" data-theme="dark">
<div class="rstack-header__left">
<a href="/" style="display:flex;align-items:center;margin-right:4px"><img src="/logo.png" alt="rSpace" class="rstack-header__logo"></a>
<rstack-app-switcher current="canvas"></rstack-app-switcher>
<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>
</div>
<div class="rstack-header__center">
@ -1125,9 +1125,13 @@
sw?.reload?.();
});
// Load module list for app switcher
// Load module list for app switcher and tab bar + menu
let moduleList = [];
fetch("/api/modules").then(r => r.json()).then(data => {
document.querySelector("rstack-app-switcher")?.setModules(data.modules || []);
moduleList = data.modules || [];
document.querySelector("rstack-app-switcher")?.setModules(moduleList);
const tb = document.querySelector("rstack-tab-bar");
if (tb) tb.setModules(moduleList);
}).catch(() => {});
// ── Dark mode toggle ──
@ -1151,52 +1155,9 @@
});
}
// ── Tab bar / Layer system initialization ──
// ── Tab bar reference (full init deferred until communitySlug is known) ──
const tabBar = document.querySelector("rstack-tab-bar");
if (tabBar) {
const canvasDefaultLayer = {
id: "layer-canvas",
moduleId: "rspace",
label: "rSpace",
order: 0,
color: "",
visible: true,
createdAt: Date.now(),
};
tabBar.setLayers([canvasDefaultLayer]);
tabBar.setAttribute("active", canvasDefaultLayer.id);
// Tab switching: navigate to the selected module's page
tabBar.addEventListener("layer-switch", (e) => {
const { moduleId } = e.detail;
if (moduleId === "rspace") return; // already on canvas
window.location.href = rspaceNavUrl(
document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo",
moduleId
);
});
// Adding a new tab: navigate to that module
tabBar.addEventListener("layer-add", (e) => {
const { moduleId } = e.detail;
window.location.href = rspaceNavUrl(
document.querySelector("rstack-space-switcher")?.getAttribute("current") || "demo",
moduleId
);
});
// Closing a tab
tabBar.addEventListener("layer-close", (e) => {
tabBar.removeLayer(e.detail.layerId);
});
// View mode toggle
tabBar.addEventListener("view-toggle", (e) => {
document.dispatchEvent(new CustomEvent("layer-view-mode", { detail: { mode: e.detail.mode } }));
});
// Expose for CommunitySync wiring
window.__rspaceTabBar = tabBar;
}
@ -1277,9 +1238,148 @@
spaceSwitcher.setAttribute("name", communitySlug);
}
// Update tab bar with resolved space slug
// ── Tab bar / Layer system initialization (needs communitySlug) ──
const currentModuleId = 'rspace';
const TABS_KEY = 'rspace_tabs_' + communitySlug;
if (tabBar) {
tabBar.setAttribute("space", communitySlug);
// Helper: look up a module's display name
function getModuleLabel(id) {
const m = moduleList.find(mod => mod.id === id);
return m ? m.name : id;
}
// Helper: create a layer object
function makeLayer(id, order) {
return {
id: 'layer-' + id,
moduleId: id,
label: getModuleLabel(id),
order: order,
color: '',
visible: true,
createdAt: Date.now(),
};
}
// ── Restore tabs from localStorage ──
let layers;
try {
const saved = localStorage.getItem(TABS_KEY);
layers = saved ? JSON.parse(saved) : [];
if (!Array.isArray(layers)) layers = [];
} catch(e) { layers = []; }
// Ensure the current module is in the tab list
if (!layers.find(l => l.moduleId === currentModuleId)) {
layers.push(makeLayer(currentModuleId, layers.length));
}
// Persist immediately (includes the newly-added tab)
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
// Render all tabs with the current one active
tabBar.setLayers(layers);
tabBar.setAttribute('active', 'layer-' + currentModuleId);
// Helper: save current tab list to localStorage
function saveTabs() {
localStorage.setItem(TABS_KEY, JSON.stringify(layers));
}
// ── Tab events ──
tabBar.addEventListener('layer-switch', (e) => {
const { moduleId } = e.detail;
if (moduleId === 'rspace') return; // already on canvas
saveTabs();
window.location.href = rspaceNavUrl(communitySlug, moduleId);
});
tabBar.addEventListener('layer-add', (e) => {
const { moduleId } = e.detail;
if (!layers.find(l => l.moduleId === moduleId)) {
layers.push(makeLayer(moduleId, layers.length));
}
saveTabs();
window.location.href = rspaceNavUrl(communitySlug, moduleId);
});
tabBar.addEventListener('layer-close', (e) => {
const { layerId } = e.detail;
tabBar.removeLayer(layerId);
layers = layers.filter(l => l.id !== layerId);
saveTabs();
});
tabBar.addEventListener('view-toggle', (e) => {
document.dispatchEvent(new CustomEvent('layer-view-mode', { detail: { mode: e.detail.mode } }));
});
// ── CommunitySync: merge with Automerge once connected ──
document.addEventListener('community-sync-ready', (e) => {
const sync = e.detail?.sync;
if (!sync) return;
// Merge: Automerge layers win if they exist, otherwise seed from localStorage
const remoteLayers = sync.getLayers?.() || [];
if (remoteLayers.length > 0) {
// Ensure current module is also in the Automerge set
if (!remoteLayers.find(l => l.moduleId === currentModuleId)) {
const newLayer = makeLayer(currentModuleId, remoteLayers.length);
sync.addLayer?.(newLayer);
}
layers = sync.getLayers?.() || [];
tabBar.setLayers(layers);
const activeId = sync.doc?.activeLayerId;
if (activeId) tabBar.setAttribute('active', activeId);
if (sync.getFlows) tabBar.setFlows(sync.getFlows());
} else {
// First connection: push all localStorage tabs into Automerge
for (const l of layers) {
sync.addLayer?.(l);
}
sync.setActiveLayer?.('layer-' + currentModuleId);
}
// Keep localStorage in sync
saveTabs();
// Sync layer changes back to Automerge
tabBar.addEventListener('layer-switch', (e) => {
sync.setActiveLayer?.(e.detail.layerId);
});
tabBar.addEventListener('layer-add', (e) => {
const { moduleId } = e.detail;
const newLayer = makeLayer(moduleId, sync.getLayers?.().length || 0);
sync.addLayer?.(newLayer);
});
tabBar.addEventListener('layer-close', (e) => {
sync.removeLayer?.(e.detail.layerId);
});
tabBar.addEventListener('layer-reorder', (e) => {
const { layerId, newIndex } = e.detail;
sync.updateLayer?.(layerId, { order: newIndex });
const all = sync.getLayers?.() || [];
all.forEach((l, i) => { if (l.order !== i) sync.updateLayer?.(l.id, { order: i }); });
});
tabBar.addEventListener('flow-create', (e) => { sync.addFlow?.(e.detail.flow); });
tabBar.addEventListener('flow-remove', (e) => { sync.removeFlow?.(e.detail.flowId); });
tabBar.addEventListener('view-toggle', (e) => { sync.setLayerViewMode?.(e.detail.mode); });
// Listen for remote changes
sync.addEventListener('change', () => {
layers = sync.getLayers?.() || [];
tabBar.setLayers(layers);
if (sync.getFlows) tabBar.setFlows(sync.getFlows());
const activeId = sync.doc?.activeLayerId;
if (activeId) tabBar.setAttribute('active', activeId);
const viewMode = sync.doc?.layerViewMode;
if (viewMode) tabBar.setAttribute('view-mode', viewMode);
saveTabs(); // keep localStorage in sync
});
});
}
// ── "Try Demo" button visibility ──
@ -1370,94 +1470,6 @@
detail: { sync, communitySlug }
}));
// Wire tab bar to CommunitySync for layer persistence
if (tabBar && sync) {
const canvasDefaultLayer = {
id: "layer-canvas",
moduleId: "rspace",
label: "rSpace",
order: 0,
color: "",
visible: true,
createdAt: Date.now(),
};
// Load persisted layers from Automerge
const layers = sync.getLayers?.() || [];
if (layers.length > 0) {
tabBar.setLayers(layers);
const activeId = sync.doc?.activeLayerId;
if (activeId) tabBar.setAttribute("active", activeId);
if (sync.getFlows) tabBar.setFlows(sync.getFlows());
} else {
// First visit: persist the canvas layer
sync.addLayer?.(canvasDefaultLayer);
sync.setActiveLayer?.(canvasDefaultLayer.id);
}
// Persist layer switch
tabBar.addEventListener("layer-switch", (e) => {
sync.setActiveLayer?.(e.detail.layerId);
});
// Persist new layer
tabBar.addEventListener("layer-add", (e) => {
const { moduleId } = e.detail;
sync.addLayer?.({
id: "layer-" + moduleId,
moduleId,
label: moduleId,
order: (sync.getLayers?.() || []).length,
color: "",
visible: true,
createdAt: Date.now(),
});
});
// Persist layer close
tabBar.addEventListener("layer-close", (e) => {
sync.removeLayer?.(e.detail.layerId);
});
// Persist layer reorder
tabBar.addEventListener("layer-reorder", (e) => {
const { layerId, newIndex } = e.detail;
sync.updateLayer?.(layerId, { order: newIndex });
const allLayers = sync.getLayers?.() || [];
allLayers.forEach((l, i) => {
if (l.order !== i) sync.updateLayer?.(l.id, { order: i });
});
});
// Flow creation from stack view
tabBar.addEventListener("flow-create", (e) => {
sync.addFlow?.(e.detail.flow);
});
// Flow removal from stack view
tabBar.addEventListener("flow-remove", (e) => {
sync.removeFlow?.(e.detail.flowId);
});
// View mode persistence
tabBar.addEventListener("view-toggle", (e) => {
sync.setLayerViewMode?.(e.detail.mode);
});
// Sync remote layer/flow changes back to tab bar
sync.addEventListener("change", () => {
const updatedLayers = sync.getLayers?.() || [];
if (updatedLayers.length > 0) {
tabBar.setLayers(updatedLayers);
if (sync.getFlows) tabBar.setFlows(sync.getFlows());
const activeId = sync.doc?.activeLayerId;
if (activeId) tabBar.setAttribute("active", activeId);
const viewMode = sync.doc?.layerViewMode;
if (viewMode) tabBar.setAttribute("view-mode", viewMode);
}
});
}
// Initialize Presence for real-time cursors
const peerId = generatePeerId();
const storedUsername = localStorage.getItem("rspace-username") || `User ${peerId.slice(0, 4)}`;
@ -1837,11 +1849,18 @@
}
shape.id = data.id;
shape.x = data.x ?? 100;
shape.y = data.y ?? 100;
shape.width = data.width ?? 300;
shape.height = data.height ?? 200;
if (data.rotation) shape.rotation = data.rotation;
// Validate coordinates — use defaults only when data is missing/invalid
const x = (typeof data.x === "number" && Number.isFinite(data.x)) ? data.x : (console.warn(`[Canvas] Shape ${data.id}: invalid x=${data.x}, using default`), 100);
const y = (typeof data.y === "number" && Number.isFinite(data.y)) ? data.y : (console.warn(`[Canvas] Shape ${data.id}: invalid y=${data.y}, using default`), 100);
const w = (typeof data.width === "number" && Number.isFinite(data.width) && data.width > 0) ? data.width : (console.warn(`[Canvas] Shape ${data.id}: invalid width=${data.width}, using default`), 300);
const h = (typeof data.height === "number" && Number.isFinite(data.height) && data.height > 0) ? data.height : (console.warn(`[Canvas] Shape ${data.id}: invalid height=${data.height}, using default`), 200);
shape.x = x;
shape.y = y;
shape.width = w;
shape.height = h;
if (typeof data.rotation === "number" && Number.isFinite(data.rotation)) shape.rotation = data.rotation;
return shape;
}
@ -3166,6 +3185,13 @@
sync.saveBeforeUnload();
});
// Mobile browsers may not fire beforeunload when backgrounding tabs
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
sync.saveBeforeUnload();
}
});
sync.connect(wsUrl);
// Debug: expose sync for console inspection