diff --git a/lib/community-sync.ts b/lib/community-sync.ts
index 9889202..5d72303 100644
--- a/lib/community-sync.ts
+++ b/lib/community-sync.ts
@@ -1435,6 +1435,33 @@ export class CommunitySync extends EventTarget {
return Automerge.view(this.#doc, heads);
}
+ /**
+ * Revert document content to the state at a given change hash.
+ * Creates a forward change (preserving full history) and syncs to peers.
+ * Meta (space name/slug/config) is preserved — only content is reverted.
+ */
+ revertToHash(hash: string): void {
+ const snapshot = Automerge.view(this.#doc, [hash]);
+ const data = JSON.parse(JSON.stringify(snapshot)) as CommunityDoc;
+
+ const newDoc = Automerge.change(this.#doc, makeChangeMessage(`Revert to change ${hash.slice(0, 8)}`), (doc) => {
+ doc.shapes = data.shapes || {};
+ if (data.layers) doc.layers = data.layers;
+ if (data.flows) doc.flows = data.flows;
+ if (data.connections) doc.connections = data.connections;
+ if (data.groups) doc.groups = data.groups;
+ if (data.nestedSpaces) doc.nestedSpaces = data.nestedSpaces;
+ if (data.activeLayerId !== undefined) doc.activeLayerId = data.activeLayerId;
+ if (data.layerViewMode !== undefined) doc.layerViewMode = data.layerViewMode;
+ if (data.commentPins) doc.commentPins = data.commentPins;
+ // Preserve meta — don't revert space name/slug/config
+ });
+
+ this._applyDocChange(newDoc);
+ this.#undoStack.length = 0;
+ this.#redoStack.length = 0;
+ }
+
/**
* Get parsed history entries for the activity feed.
*/
diff --git a/modules/rflows/components/folk-flow-river.ts b/modules/rflows/components/folk-flow-river.ts
index e049b69..e8831d0 100644
--- a/modules/rflows/components/folk-flow-river.ts
+++ b/modules/rflows/components/folk-flow-river.ts
@@ -517,7 +517,7 @@ function renderBranch(b: BranchLayout): string {
` : "";
// Midpoint for label
- const midX = (b.x1 + exitX + b.x2) / 3;
+ const midX = (b.x1 + b.x2) / 2;
const midY = (b.y1 + b.y2) / 2 - 12;
return `${outerStroke}${innerStroke}${waterFlow}
diff --git a/scripts/test-full-loop.ts b/scripts/test-full-loop.ts
index 46e9cc3..1825df1 100644
--- a/scripts/test-full-loop.ts
+++ b/scripts/test-full-loop.ts
@@ -30,7 +30,7 @@ async function step(name: string, fn: () => Promise) {
return result;
} catch (err: any) {
console.log("ERROR", err.message);
- return { ok: false, error: err.message };
+ return { ok: false, error: err.message } as StepResult;
}
}
diff --git a/shared/components/rstack-history-panel.ts b/shared/components/rstack-history-panel.ts
index 54b0424..bf07cbe 100644
--- a/shared/components/rstack-history-panel.ts
+++ b/shared/components/rstack-history-panel.ts
@@ -413,6 +413,7 @@ export class RStackHistoryPanel extends HTMLElement {
` : ""}
+
`;
// Update the label
@@ -446,6 +447,18 @@ export class RStackHistoryPanel extends HTMLElement {
this._onScrub(parseInt(scrubber.value, 10));
});
}
+
+ // Revert to history point
+ sr.getElementById("revert-btn")?.addEventListener("click", () => {
+ const entry = this._entries[this._timeMachineIndex];
+ if (!entry?.hash) return;
+ if (!confirm(`Revert to change from ${new Date(entry.time).toLocaleString()} by ${entry.username}?\n\nThis creates a new change that restores the document to that state.`)) return;
+ this.dispatchEvent(new CustomEvent("revert-requested", {
+ bubbles: true, composed: true,
+ detail: { hash: entry.hash, index: this._timeMachineIndex },
+ }));
+ this.close();
+ });
}
// ── Helpers ──
@@ -743,6 +756,21 @@ const PANEL_CSS = `
word-break: break-word;
}
+.revert-btn {
+ width: 100%;
+ margin-top: 16px;
+ padding: 10px;
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ color: #ef4444;
+ border-radius: 8px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+.revert-btn:hover { background: rgba(239, 68, 68, 0.2); }
+
/* Load more */
.load-more {
padding: 12px 20px;
diff --git a/website/canvas.html b/website/canvas.html
index d40feb0..da0c701 100644
--- a/website/canvas.html
+++ b/website/canvas.html
@@ -3065,6 +3065,12 @@
const historyPanel = document.querySelector('rstack-history-panel');
if (historyPanel) historyPanel.setDoc(sync.doc);
+ // Wire history revert
+ document.querySelector("rstack-history-panel")?.addEventListener("revert-requested", (e) => {
+ const { hash } = (e as CustomEvent).detail;
+ if (hash) sync.revertToHash(hash);
+ });
+
// Non-blocking: open IndexedDB + load cache in background
// UI handlers below register immediately regardless of this outcome
(async () => {