feat: three-state FUN — present, forgotten (faded), deleted

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-01 11:44:02 -08:00
parent a61f562bbf
commit 317bc46de6
4 changed files with 468 additions and 66 deletions

View File

@ -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<string, unknown>).forgotten = true;
(doc.shapes[shapeId] as Record<string, unknown>).forgottenAt = Date.now();
const shape = doc.shapes[shapeId] as Record<string, unknown>;
if (!shape.forgottenBy || typeof shape.forgottenBy !== 'object') {
shape.forgottenBy = {};
}
(shape.forgottenBy as Record<string, number>)[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<string, unknown>).deleted;
this.#doc = Automerge.change(this.#doc, `Remember shape ${shapeId}`, (doc) => {
if (doc.shapes && doc.shapes[shapeId]) {
(doc.shapes[shapeId] as Record<string, unknown>).forgotten = false;
(doc.shapes[shapeId] as Record<string, unknown>).forgottenAt = 0;
(doc.shapes[shapeId] as Record<string, unknown>).forgottenBy = '';
const shape = doc.shapes[shapeId] as Record<string, unknown>;
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<string, unknown>).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<string, unknown> | 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<string, unknown> | undefined;
if (!data) return false;
const fb = data.forgottenBy as Record<string, number> | 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<string, unknown>;
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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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);
}
}

View File

@ -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;

View File

@ -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<string, unknown>).forgotten = true;
(d.shapes[shapeId] as Record<string, unknown>).forgottenAt = Date.now();
if (forgottenBy) {
(d.shapes[shapeId] as Record<string, unknown>).forgottenBy = forgottenBy;
const shape = d.shapes[shapeId] as Record<string, unknown>;
// 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<string, number>)[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<string, unknown>).forgotten = false;
(d.shapes[shapeId] as Record<string, unknown>).forgottenAt = 0;
(d.shapes[shapeId] as Record<string, unknown>).forgottenBy = '';
const shape = d.shapes[shapeId] as Record<string, unknown>;
shape.forgottenBy = {};
shape.deleted = false;
// Legacy compat
shape.forgotten = false;
shape.forgottenAt = 0;
}
});
communities.set(slug, newDoc);

View File

@ -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 @@
<div id="memory-list"></div>
</div>
<div id="shape-context-menu"></div>
<div id="status" class="disconnected">
<span class="indicator"></span>
<span id="status-text">Connecting...</span>
@ -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 = `<button data-action="forget">Forget</button>`;
} else if (state === 'forgotten') {
html += `<button data-action="remember">Remember</button>`;
if (!alreadyForgotten) {
html += `<button data-action="forget-too">Forget too</button>`;
}
html += `<button data-action="delete" class="danger">Delete</button>`;
}
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 = `
<span class="icon">${SHAPE_ICONS[shape.type] || "📦"}</span>
<div class="info">
<div class="name">${escapeHtml(getShapeLabel(shape))}</div>
<div class="meta">${shape.type}${ago ? " · " + ago : ""}</div>
</div>
<button class="remember-btn">Remember</button>
`;
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 = `
<span class="icon">${SHAPE_ICONS[shape.type] || "📦"}</span>
<div class="info">
<div class="name">${escapeHtml(getShapeLabel(shape))}</div>
<div class="meta">${shape.type}${ago ? " · " + ago : ""}</div>
</div>
${forgetCount > 0 ? `<span class="forget-count">${forgetCount}x</span>` : ''}
<button class="remember-btn">Remember</button>
<button class="delete-btn">Delete</button>
`;
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 = `
<span class="icon">${SHAPE_ICONS[shape.type] || "📦"}</span>
<div class="info">
<div class="name">${escapeHtml(getShapeLabel(shape))}</div>
<div class="meta">${shape.type}${ago ? " · " + ago : ""}</div>
</div>
<button class="restore-btn">Restore</button>
`;
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();
}