From 317bc46de63866d3210bb13890fd30bb151cae90 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sun, 1 Mar 2026 11:44:02 -0800 Subject: [PATCH] =?UTF-8?q?feat:=20three-state=20FUN=20=E2=80=94=20present?= =?UTF-8?q?,=20forgotten=20(faded),=20deleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shapes now have three states instead of two. "Forgetting" a shape fades it (35% opacity, greyscale) for all connected clients rather than hiding it. Other users can then choose to "forget too", "remember" (restore), or "delete" (hard-remove from DOM). A forgottenBy map tracks who forgot, enabling social signaling around shared attention. - folk-shape.ts: :state(forgotten) CSS + forgotten property - community-sync.ts: forgetShape(id,did), rememberShape, hardDeleteShape, getShapeVisualState, hasUserForgotten, getFadedShapes, getDeletedShapes - community-store.ts: forgottenBy map server-side, rememberShape clears map - canvas.html: right-click context menu, two-section memory panel (Fading/ Deleted), close button fades instead of removes, Delete key escalates Co-Authored-By: Claude Opus 4.6 --- lib/community-sync.ts | 161 ++++++++++++++++--- lib/folk-shape.ts | 18 +++ server/community-store.ts | 30 ++-- website/canvas.html | 325 ++++++++++++++++++++++++++++++++++---- 4 files changed, 468 insertions(+), 66 deletions(-) diff --git a/lib/community-sync.ts b/lib/community-sync.ts index 4643db9..89a5481 100644 --- a/lib/community-sync.ts +++ b/lib/community-sync.ts @@ -502,10 +502,11 @@ export class CommunitySync extends EventTarget { } /** - * Delete a shape from the document (hard delete — use forgetShape instead) + * Delete a shape — now aliases to forgetShape for backward compat. + * For true hard-delete, use hardDeleteShape(). */ - deleteShape(shapeId: string): void { - this.forgetShape(shapeId); + deleteShape(shapeId: string, did?: string): void { + this.forgetShape(shapeId, did || 'unknown'); } /** @@ -547,61 +548,157 @@ export class CommunitySync extends EventTarget { } /** - * Forget a shape — soft-delete. Shape stays in the doc but is hidden. + * Forget a shape — add DID to forgottenBy map. Shape fades but stays in DOM. + * Three-state: present → forgotten (faded) → deleted */ - forgetShape(shapeId: string): void { + forgetShape(shapeId: string, did: string): void { this.#doc = Automerge.change(this.#doc, `Forget shape ${shapeId}`, (doc) => { if (doc.shapes && doc.shapes[shapeId]) { - (doc.shapes[shapeId] as Record).forgotten = true; - (doc.shapes[shapeId] as Record).forgottenAt = Date.now(); + const shape = doc.shapes[shapeId] as Record; + if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') { + shape.forgottenBy = {}; + } + (shape.forgottenBy as Record)[did] = Date.now(); + // Legacy compat + shape.forgotten = true; + shape.forgottenAt = Date.now(); } }); - // Remove from visible DOM - this.#removeShapeFromDOM(shapeId); + // Don't remove from DOM — just update visual state + this.dispatchEvent(new CustomEvent("shape-state-changed", { + detail: { shapeId, state: 'forgotten', data: this.#doc.shapes?.[shapeId] } + })); this.#scheduleSave(); this.#syncToServer(); } /** - * Remember a forgotten shape — restore it to the canvas. + * Remember a forgotten shape — clear forgottenBy + deleted, restore to present. */ rememberShape(shapeId: string): void { const shapeData = this.#doc.shapes?.[shapeId]; if (!shapeData) return; + const wasDeleted = !!(shapeData as Record).deleted; + this.#doc = Automerge.change(this.#doc, `Remember shape ${shapeId}`, (doc) => { if (doc.shapes && doc.shapes[shapeId]) { - (doc.shapes[shapeId] as Record).forgotten = false; - (doc.shapes[shapeId] as Record).forgottenAt = 0; - (doc.shapes[shapeId] as Record).forgottenBy = ''; + const shape = doc.shapes[shapeId] as Record; + shape.forgottenBy = {}; + shape.deleted = false; + // Legacy compat + shape.forgotten = false; + shape.forgottenAt = 0; } }); - // Re-add to DOM - this.#applyShapeToDOM(this.#doc.shapes[shapeId]); + if (wasDeleted) { + // Re-add to DOM if was hard-deleted + this.#applyShapeToDOM(this.#doc.shapes[shapeId]); + } + + this.dispatchEvent(new CustomEvent("shape-state-changed", { + detail: { shapeId, state: 'present', data: this.#doc.shapes?.[shapeId] } + })); this.#scheduleSave(); this.#syncToServer(); } /** - * Get all forgotten shapes (for the memory layer UI). + * Hard-delete a shape — set deleted: true, remove from DOM. + * Shape stays in Automerge doc for restore from memory panel. + */ + hardDeleteShape(shapeId: string): void { + this.#doc = Automerge.change(this.#doc, `Delete shape ${shapeId}`, (doc) => { + if (doc.shapes && doc.shapes[shapeId]) { + (doc.shapes[shapeId] as Record).deleted = true; + } + }); + + this.#removeShapeFromDOM(shapeId); + this.dispatchEvent(new CustomEvent("shape-state-changed", { + detail: { shapeId, state: 'deleted', data: this.#doc.shapes?.[shapeId] } + })); + this.#scheduleSave(); + this.#syncToServer(); + } + + /** + * Get the visual state of a shape: 'present' | 'forgotten' | 'deleted' + */ + getShapeVisualState(shapeId: string): 'present' | 'forgotten' | 'deleted' { + const data = this.#doc.shapes?.[shapeId] as Record | undefined; + if (!data) return 'deleted'; + if (data.deleted === true) return 'deleted'; + const fb = data.forgottenBy; + if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) return 'forgotten'; + return 'present'; + } + + /** + * Check if a specific user has forgotten a shape + */ + hasUserForgotten(shapeId: string, did: string): boolean { + const data = this.#doc.shapes?.[shapeId] as Record | undefined; + if (!data) return false; + const fb = data.forgottenBy as Record | undefined; + return !!(fb && fb[did]); + } + + /** + * Get all forgotten (faded but not deleted) shapes + */ + getFadedShapes(): ShapeData[] { + const shapes = this.#doc.shapes || {}; + return Object.values(shapes).filter(s => { + const d = s as Record; + if (d.deleted === true) return false; + const fb = d.forgottenBy; + return fb && typeof fb === 'object' && Object.keys(fb).length > 0; + }); + } + + /** + * Get all hard-deleted shapes (for memory panel "Deleted" section) + */ + getDeletedShapes(): ShapeData[] { + const shapes = this.#doc.shapes || {}; + return Object.values(shapes).filter(s => (s as Record).deleted === true); + } + + /** + * Get all forgotten shapes — includes both faded and deleted (for backward compat). */ getForgottenShapes(): ShapeData[] { const shapes = this.#doc.shapes || {}; - return Object.values(shapes).filter(s => s.forgotten); + return Object.values(shapes).filter(s => { + const d = s as Record; + if (d.deleted === true) return true; + const fb = d.forgottenBy; + if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) return true; + return !!d.forgotten; + }); } /** * Apply full document to DOM (for initial load). - * Skips forgotten shapes — they live in the doc but are hidden from view. + * Three-state: deleted shapes are skipped, forgotten shapes are rendered faded. */ #applyDocToDOM(): void { const shapes = this.#doc.shapes || {}; for (const [id, shapeData] of Object.entries(shapes)) { - if (shapeData.forgotten) continue; // FUN: forgotten shapes stay in doc, hidden from canvas + const d = shapeData as Record; + if (d.deleted === true) continue; // Deleted: not in DOM this.#applyShapeToDOM(shapeData); + // If forgotten (faded), emit state-changed so canvas can apply visual + const fb = d.forgottenBy; + if (fb && typeof fb === 'object' && Object.keys(fb).length > 0) { + this.dispatchEvent(new CustomEvent("shape-state-changed", { + detail: { shapeId: id, state: 'forgotten', data: shapeData } + })); + } } this.dispatchEvent(new CustomEvent("synced", { detail: { shapes } })); @@ -609,8 +706,7 @@ export class CommunitySync extends EventTarget { /** * Apply Automerge patches to DOM. - * Handles forgotten state: when a shape becomes forgotten, remove it from - * the visible canvas; when remembered, re-add it. + * Three-state: forgotten shapes stay in DOM (faded), deleted shapes are removed. */ #applyPatchesToDOM(patches: Automerge.Patch[]): void { for (const patch of patches) { @@ -622,16 +718,31 @@ export class CommunitySync extends EventTarget { const shapeData = this.#doc.shapes?.[shapeId]; if (patch.action === "del" && path.length === 2) { - // Shape hard-deleted + // Shape truly removed from Automerge doc this.#removeShapeFromDOM(shapeId); } else if (shapeData) { - // FUN: if shape was just forgotten, remove from DOM - if (shapeData.forgotten) { + const d = shapeData as Record; + const state = this.getShapeVisualState(shapeId); + + if (state === 'deleted') { + // Hard-deleted: remove from DOM this.#removeShapeFromDOM(shapeId); + this.dispatchEvent(new CustomEvent("shape-state-changed", { + detail: { shapeId, state: 'deleted', data: shapeData } + })); + } else if (state === 'forgotten') { + // Forgotten: keep in DOM, emit state change for fade visual + this.#applyShapeToDOM(shapeData); + this.dispatchEvent(new CustomEvent("shape-state-changed", { + detail: { shapeId, state: 'forgotten', data: shapeData } + })); this.dispatchEvent(new CustomEvent("shape-forgotten", { detail: { shapeId, data: shapeData } })); } else { - // Shape created, updated, or remembered — render it + // Present: render normally this.#applyShapeToDOM(shapeData); + this.dispatchEvent(new CustomEvent("shape-state-changed", { + detail: { shapeId, state: 'present', data: shapeData } + })); this.#postMessageToParent("shape-updated", shapeData); } } diff --git a/lib/folk-shape.ts b/lib/folk-shape.ts index 4914298..d84f43b 100644 --- a/lib/folk-shape.ts +++ b/lib/folk-shape.ts @@ -106,6 +106,12 @@ const styles = css` outline-offset: 2px; } + :host(:state(forgotten)) { + opacity: 0.35; + filter: grayscale(0.8); + transition: opacity 0.3s ease, filter 0.3s ease; + } + :host(:state(move)), :host(:state(rotate)), :host(:state(resize-top-left)), @@ -279,6 +285,18 @@ export class FolkShape extends FolkElement { : this.#internals.states.delete("highlighted"); } + #forgotten = false; + get forgotten() { + return this.#forgotten; + } + set forgotten(forgotten) { + if (this.#forgotten === forgotten) return; + this.#forgotten = forgotten; + forgotten + ? this.#internals.states.add("forgotten") + : this.#internals.states.delete("forgotten"); + } + #editing = false; get editing() { return this.#editing; diff --git a/server/community-store.ts b/server/community-store.ts index 261d234..1003096 100644 --- a/server/community-store.ts +++ b/server/community-store.ts @@ -661,8 +661,8 @@ export function deleteShape(slug: string, shapeId: string): void { } /** - * Forget a shape — soft-delete. Shape stays in the doc but is hidden from view. - * Can be restored with rememberShape(). + * Forget a shape — add DID to forgottenBy map. Shape stays in doc but fades for all. + * Three-state: present → forgotten (faded) → deleted */ export function forgetShape(slug: string, shapeId: string, forgottenBy?: string): void { const doc = communities.get(slug); @@ -670,11 +670,17 @@ export function forgetShape(slug: string, shapeId: string, forgottenBy?: string) const newDoc = Automerge.change(doc, `Forget shape ${shapeId}`, (d) => { if (d.shapes[shapeId]) { - (d.shapes[shapeId] as Record).forgotten = true; - (d.shapes[shapeId] as Record).forgottenAt = Date.now(); - if (forgottenBy) { - (d.shapes[shapeId] as Record).forgottenBy = forgottenBy; + const shape = d.shapes[shapeId] as Record; + // Add DID to forgottenBy map (Automerge merges concurrent map writes cleanly) + if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') { + shape.forgottenBy = {}; } + if (forgottenBy) { + (shape.forgottenBy as Record)[forgottenBy] = Date.now(); + } + // Legacy compat: keep scalar forgotten flag synced + shape.forgotten = true; + shape.forgottenAt = Date.now(); } }); communities.set(slug, newDoc); @@ -682,7 +688,8 @@ export function forgetShape(slug: string, shapeId: string, forgottenBy?: string) } /** - * Remember a forgotten shape — restore it to the canvas. + * Remember a forgotten shape — clear forgottenBy map + deleted flag. + * Restores shape to present state for everyone. */ export function rememberShape(slug: string, shapeId: string): void { const doc = communities.get(slug); @@ -690,9 +697,12 @@ export function rememberShape(slug: string, shapeId: string): void { const newDoc = Automerge.change(doc, `Remember shape ${shapeId}`, (d) => { if (d.shapes[shapeId]) { - (d.shapes[shapeId] as Record).forgotten = false; - (d.shapes[shapeId] as Record).forgottenAt = 0; - (d.shapes[shapeId] as Record).forgottenBy = ''; + const shape = d.shapes[shapeId] as Record; + shape.forgottenBy = {}; + shape.deleted = false; + // Legacy compat + shape.forgotten = false; + shape.forgottenAt = 0; } }); communities.set(slug, newDoc); diff --git a/website/canvas.html b/website/canvas.html index 83e7659..1abe39f 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -422,6 +422,115 @@ background: #0d9488; } + .memory-item .delete-btn { + padding: 4px 10px; + border: none; + border-radius: 6px; + background: #ef4444; + color: white; + font-size: 12px; + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s; + } + + .memory-item .delete-btn:hover { + background: #dc2626; + } + + .memory-item .restore-btn { + padding: 4px 10px; + border: none; + border-radius: 6px; + background: #3b82f6; + color: white; + font-size: 12px; + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s; + } + + .memory-item .restore-btn:hover { + background: #2563eb; + } + + .memory-section-header { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #94a3b8; + padding: 8px 10px 4px; + } + + .memory-item .forget-count { + font-size: 11px; + color: #94a3b8; + white-space: nowrap; + } + + /* Shape context menu */ + #shape-context-menu { + position: fixed; + z-index: 10000; + background: white; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0,0,0,0.18); + padding: 4px; + min-width: 160px; + display: none; + } + + #shape-context-menu.open { + display: block; + } + + #shape-context-menu button { + display: block; + width: 100%; + padding: 8px 12px; + border: none; + background: none; + text-align: left; + font-size: 13px; + color: #1e293b; + border-radius: 6px; + cursor: pointer; + } + + #shape-context-menu button:hover { + background: #f1f5f9; + } + + #shape-context-menu button.danger { + color: #ef4444; + } + + #shape-context-menu button.danger:hover { + background: #fef2f2; + } + + body[data-theme="dark"] #shape-context-menu { + background: #1e293b; + box-shadow: 0 4px 20px rgba(0,0,0,0.4); + } + + body[data-theme="dark"] #shape-context-menu button { + color: #e2e8f0; + } + + body[data-theme="dark"] #shape-context-menu button:hover { + background: #334155; + } + + body[data-theme="dark"] #shape-context-menu button.danger { + color: #f87171; + } + + body[data-theme="dark"] #shape-context-menu button.danger:hover { + background: #3b1c1c; + } + #canvas { width: 100%; height: 100%; @@ -1048,6 +1157,8 @@
+
+
Connecting... @@ -1587,6 +1698,11 @@ setupShapeEventListeners(shape); canvasContent.appendChild(shape); sync.registerShape(shape); + // Apply forgotten visual if shape arrives in forgotten state + const visualState = sync.getShapeVisualState(data.id); + if (visualState === 'forgotten') { + shape.forgotten = true; + } } } catch (err) { console.error(`[Canvas] Failed to create remote shape ${data.id} (${data.type}):`, err); @@ -1595,7 +1711,7 @@ } }); - // FUN: Forget — handle shape removal from remote sync + // FUN: shape removed from DOM (hard-delete or true Automerge delete) sync.addEventListener("shape-removed", (e) => { const { shapeId, shape } = e.detail; if (shape && shape.parentNode) { @@ -1606,6 +1722,19 @@ if (wbEl) wbEl.remove(); }); + // Three-state: update shape visual when state changes + sync.addEventListener("shape-state-changed", (e) => { + const { shapeId, state } = e.detail; + const el = document.getElementById(shapeId); + if (!el) return; + if (state === 'forgotten') { + el.forgotten = true; + } else if (state === 'present') { + el.forgotten = false; + } + // 'deleted' is handled by shape-removed (element is removed from DOM) + }); + // Create a shape element from data function newShapeElement(data) { let shape; @@ -1898,10 +2027,11 @@ updateSelectionVisuals(); }); - // Close button + // Close button — forget (fade) instead of remove shape.addEventListener("close", () => { - sync.deleteShape(shape.id); - shape.remove(); + const did = getLocalDID(); + sync.forgetShape(shape.id, did); + shape.forgotten = true; }); } @@ -2574,12 +2704,81 @@ if (hit && hit !== wbOverlay && wbOverlay.contains(hit)) { const wbId = hit.getAttribute("data-wb-id"); if (wbId) { - sync.deleteShape(wbId); + sync.hardDeleteShape(wbId); } hit.remove(); } }); + // ── Helper: get local user DID ── + function getLocalDID() { + try { + const sess = JSON.parse(localStorage.getItem('encryptid_session') || ''); + return sess?.claims?.sub || sess?.claims?.did || 'anonymous'; + } catch { return 'anonymous'; } + } + + // ── Shape context menu (right-click on shapes) ── + const shapeContextMenu = document.getElementById("shape-context-menu"); + let contextShapeId = null; + + canvasContent.addEventListener("contextmenu", (e) => { + const shapeEl = e.target.closest("folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-embed, folk-calendar, folk-map, folk-image-gen, folk-video-gen, folk-prompt, folk-transcription, folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-social-post, folk-rapp, folk-feed, folk-piano, folk-splat, folk-blender, folk-drawfast, folk-freecad, folk-kicad"); + if (!shapeEl || !shapeEl.id) return; + + e.preventDefault(); + contextShapeId = shapeEl.id; + const state = sync.getShapeVisualState(contextShapeId); + const did = getLocalDID(); + const alreadyForgotten = sync.hasUserForgotten(contextShapeId, did); + + let html = ''; + if (state === 'present') { + html = ``; + } else if (state === 'forgotten') { + html += ``; + if (!alreadyForgotten) { + html += ``; + } + html += ``; + } + + shapeContextMenu.innerHTML = html; + shapeContextMenu.style.left = e.clientX + 'px'; + shapeContextMenu.style.top = e.clientY + 'px'; + shapeContextMenu.classList.add("open"); + }); + + shapeContextMenu.addEventListener("click", (e) => { + const btn = e.target.closest("button"); + if (!btn || !contextShapeId) return; + + const action = btn.dataset.action; + const did = getLocalDID(); + + if (action === 'forget') { + sync.forgetShape(contextShapeId, did); + const el = document.getElementById(contextShapeId); + if (el) el.forgotten = true; + } else if (action === 'forget-too') { + sync.forgetShape(contextShapeId, did); + } else if (action === 'remember') { + sync.rememberShape(contextShapeId); + } else if (action === 'delete') { + sync.hardDeleteShape(contextShapeId); + } + + shapeContextMenu.classList.remove("open"); + contextShapeId = null; + if (memoryPanel.classList.contains("open")) renderMemoryPanel(); + }); + + // Close context menu on click elsewhere + document.addEventListener("click", () => { + shapeContextMenu.classList.remove("open"); + contextShapeId = null; + }); + // Memory panel — browse and remember forgotten shapes const memoryPanel = document.getElementById("memory-panel"); const memoryList = document.getElementById("memory-list"); @@ -2607,34 +2806,84 @@ } function renderMemoryPanel() { - const forgotten = sync.getForgottenShapes(); - memoryCount.textContent = forgotten.length; + const faded = sync.getFadedShapes(); + const deleted = sync.getDeletedShapes(); + memoryCount.textContent = faded.length + deleted.length; memoryList.innerHTML = ""; - for (const shape of forgotten) { - const item = document.createElement("div"); - item.className = "memory-item"; - const ago = shape.forgottenAt - ? timeAgo(shape.forgottenAt) - : ""; + // ── Fading section ── + if (faded.length > 0) { + const header = document.createElement("div"); + header.className = "memory-section-header"; + header.textContent = "Fading"; + memoryList.appendChild(header); - item.innerHTML = ` - ${SHAPE_ICONS[shape.type] || "📦"} -
-
${escapeHtml(getShapeLabel(shape))}
-
${shape.type}${ago ? " · " + ago : ""}
-
- - `; + for (const shape of faded) { + const item = document.createElement("div"); + item.className = "memory-item"; - item.querySelector(".remember-btn").addEventListener("click", (e) => { - e.stopPropagation(); - sync.rememberShape(shape.id); - renderMemoryPanel(); - }); + const ago = shape.forgottenAt ? timeAgo(shape.forgottenAt) : ""; + const fb = shape.forgottenBy; + const forgetCount = (fb && typeof fb === 'object') ? Object.keys(fb).length : 0; - memoryList.appendChild(item); + item.innerHTML = ` + ${SHAPE_ICONS[shape.type] || "📦"} +
+
${escapeHtml(getShapeLabel(shape))}
+
${shape.type}${ago ? " · " + ago : ""}
+
+ ${forgetCount > 0 ? `${forgetCount}x` : ''} + + + `; + + item.querySelector(".remember-btn").addEventListener("click", (e) => { + e.stopPropagation(); + sync.rememberShape(shape.id); + renderMemoryPanel(); + }); + + item.querySelector(".delete-btn").addEventListener("click", (e) => { + e.stopPropagation(); + sync.hardDeleteShape(shape.id); + renderMemoryPanel(); + }); + + memoryList.appendChild(item); + } + } + + // ── Deleted section ── + if (deleted.length > 0) { + const header = document.createElement("div"); + header.className = "memory-section-header"; + header.textContent = "Deleted"; + memoryList.appendChild(header); + + for (const shape of deleted) { + const item = document.createElement("div"); + item.className = "memory-item"; + + const ago = shape.forgottenAt ? timeAgo(shape.forgottenAt) : ""; + + item.innerHTML = ` + ${SHAPE_ICONS[shape.type] || "📦"} +
+
${escapeHtml(getShapeLabel(shape))}
+
${shape.type}${ago ? " · " + ago : ""}
+
+ + `; + + item.querySelector(".restore-btn").addEventListener("click", (e) => { + e.stopPropagation(); + sync.rememberShape(shape.id); + renderMemoryPanel(); + }); + + memoryList.appendChild(item); + } } } @@ -2658,11 +2907,15 @@ if (isOpen) renderMemoryPanel(); }); - // Refresh panel when shapes are forgotten/remembered via remote sync + // Refresh panel when shapes change state via remote sync sync.addEventListener("shape-forgotten", () => { if (memoryPanel.classList.contains("open")) renderMemoryPanel(); }); + sync.addEventListener("shape-state-changed", () => { + if (memoryPanel.classList.contains("open")) renderMemoryPanel(); + }); + sync.addEventListener("synced", () => { if (memoryPanel.classList.contains("open")) renderMemoryPanel(); }); @@ -2933,14 +3186,24 @@ } }); - // ── Delete selected shapes ── + // ── Delete selected shapes (forget, not hard-delete) ── document.addEventListener("keydown", (e) => { if ((e.key === "Delete" || e.key === "Backspace") && !e.target.closest("input, textarea, [contenteditable]") && selectedShapeIds.size > 0) { + const did = getLocalDID(); for (const id of selectedShapeIds) { - sync.deleteShape(id); - document.getElementById(id)?.remove(); + const el = document.getElementById(id); + const state = sync.getShapeVisualState(id); + if (state === 'forgotten') { + // Already forgotten — hard delete + sync.hardDeleteShape(id); + if (el) el.remove(); + } else { + // Present — forget (fade) + sync.forgetShape(id, did); + if (el) el.forgotten = true; + } } deselectAll(); }