feat(canvas): collective forgetting UX + memory graph view

Rename all "Delete" labels to "Forget permanently" to align with the
three-state memory model (present → forgotten → deleted). Add explainer
blurb in memory panel. New Collective Memory graph view — force-directed
bubble chart showing shape remembrance scores sized by how many members
still remember each shape, with click-to-navigate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 17:31:46 -07:00
parent 3a222e2ddc
commit eea3443cba
2 changed files with 233 additions and 7 deletions

View File

@ -422,7 +422,7 @@ export class FolkShape extends FolkElement {
<button part="resize-bottom-right" aria-label="Resize shape from bottom right"></button> <button part="resize-bottom-right" aria-label="Resize shape from bottom right"></button>
<button part="resize-bottom-left" aria-label="Resize shape from bottom left"></button> <button part="resize-bottom-left" aria-label="Resize shape from bottom left"></button>
<div class="slot-container"><slot></slot></div> <div class="slot-container"><slot></slot></div>
<div part="forgotten-tooltip">Forgotten right-click to remember or delete</div>`, <div part="forgotten-tooltip">Forgotten right-click to remember or forget permanently</div>`,
); );
this.#handles = Object.fromEntries( this.#handles = Object.fromEntries(

View File

@ -1148,6 +1148,55 @@
display: none !important; display: none !important;
} }
/* ── Memory graph mode ── */
#canvas.memory-mode {
overflow: hidden;
touch-action: none;
}
#canvas.memory-mode #canvas-content {
display: none !important;
}
body:has(#canvas.memory-mode) #toolbar,
body:has(#canvas.memory-mode) #bottom-toolbar {
display: none !important;
}
body:has(#canvas.memory-mode) #canvas-corner-tools #corner-zoom-toggle,
body:has(#canvas.memory-mode) #canvas-corner-tools #zoom-in,
body:has(#canvas.memory-mode) #canvas-corner-tools #zoom-out,
body:has(#canvas.memory-mode) #canvas-corner-tools #reset-view,
body:has(#canvas.memory-mode) #canvas-corner-tools .corner-sep {
display: none !important;
}
#memory-graph-overlay {
position: absolute;
inset: 0;
z-index: 10;
}
#memory-graph-overlay svg {
width: 100%;
height: 100%;
}
.memory-bubble { cursor: pointer; }
.memory-bubble:hover circle { filter: brightness(1.3); }
.memory-bubble text {
fill: var(--rs-text-primary, #e2e8f0);
font-family: system-ui, sans-serif;
pointer-events: none;
text-anchor: middle;
dominant-baseline: central;
}
#memory-graph-title {
position: absolute;
top: 80px;
left: 50%;
transform: translateX(-50%);
color: var(--rs-text-secondary, #94a3b8);
font: italic 0.875rem/1.4 system-ui, sans-serif;
text-align: center;
pointer-events: none;
z-index: 11;
}
/* ── Feed card wrappers ── */ /* ── Feed card wrappers ── */
.feed-card { .feed-card {
width: min(100%, 600px); width: min(100%, 600px);
@ -1615,6 +1664,7 @@
} }
/* When collapsed on mobile, hide feed toggle too — show only zoom icon */ /* When collapsed on mobile, hide feed toggle too — show only zoom icon */
#canvas-corner-tools.collapsed #feed-toggle, #canvas-corner-tools.collapsed #feed-toggle,
#canvas-corner-tools.collapsed #memory-graph-toggle,
#canvas-corner-tools.collapsed .corner-sep { #canvas-corner-tools.collapsed .corner-sep {
display: none !important; display: none !important;
} }
@ -2028,6 +2078,9 @@
<button id="feed-toggle" title="Feed View" class="corner-btn"> <button id="feed-toggle" title="Feed View" class="corner-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button> </button>
<button id="memory-graph-toggle" title="Collective Memory" class="corner-btn">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><circle cx="5" cy="6" r="2"/><circle cx="19" cy="6" r="2"/><circle cx="5" cy="18" r="2"/><circle cx="19" cy="18" r="2"/><line x1="9.5" y1="10.5" x2="6.5" y2="7.5"/><line x1="14.5" y1="10.5" x2="17.5" y2="7.5"/><line x1="9.5" y1="13.5" x2="6.5" y2="16.5"/><line x1="14.5" y1="13.5" x2="17.5" y2="16.5"/></svg>
</button>
</div> </div>
<!-- Hidden button for JS toggle reference (injected into identity dropdown) --> <!-- Hidden button for JS toggle reference (injected into identity dropdown) -->
@ -5466,7 +5519,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
if (!alreadyForgotten) { if (!alreadyForgotten) {
html += `<button data-action="forget-too">Forget too</button>`; html += `<button data-action="forget-too">Forget too</button>`;
} }
html += `<button data-action="delete" class="danger">Delete</button>`; html += `<button data-action="delete" class="danger">Forget permanently</button>`;
} }
shapeContextMenu.innerHTML = html; shapeContextMenu.innerHTML = html;
@ -5795,6 +5848,12 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
memoryList.innerHTML = ""; memoryList.innerHTML = "";
// ── Explainer blurb ──
const explainer = document.createElement("div");
explainer.style.cssText = "font-style:italic;color:var(--rs-text-secondary,#94a3b8);font-size:0.75rem;line-height:1.5;padding:8px 12px 12px;opacity:0.85";
explainer.textContent = "In collective knowledge spaces, forgetting is natural. When you forget a shape, it fades — but persists. The more people who forget it, the fainter it becomes. Shapes everyone still remembers stay vivid. You can always choose to remember what others have forgotten.";
memoryList.appendChild(explainer);
// ── Fading section ── // ── Fading section ──
if (faded.length > 0) { if (faded.length > 0) {
const header = document.createElement("div"); const header = document.createElement("div");
@ -5818,7 +5877,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
</div> </div>
${forgetCount > 0 ? `<span class="forget-count">${forgetCount}x</span>` : ''} ${forgetCount > 0 ? `<span class="forget-count">${forgetCount}x</span>` : ''}
<button class="remember-btn">Remember</button> <button class="remember-btn">Remember</button>
<button class="delete-btn">Delete</button> <button class="delete-btn">Forget permanently</button>
`; `;
item.querySelector(".remember-btn").addEventListener("click", (e) => { item.querySelector(".remember-btn").addEventListener("click", (e) => {
@ -5841,7 +5900,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
if (deleted.length > 0) { if (deleted.length > 0) {
const header = document.createElement("div"); const header = document.createElement("div");
header.className = "memory-section-header"; header.className = "memory-section-header";
header.textContent = "Deleted"; header.textContent = "Forgotten permanently";
memoryList.appendChild(header); memoryList.appendChild(header);
for (const shape of deleted) { for (const shape of deleted) {
@ -6379,11 +6438,11 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
dialog.style.cssText = "background:var(--rs-bg-surface,#1e1b4b);border:1px solid var(--rs-border,#334155);border-radius:12px;padding:1.5rem 2rem;max-width:380px;text-align:center;color:var(--rs-text-primary,#e2e8f0);font-family:system-ui,sans-serif;user-select:none"; dialog.style.cssText = "background:var(--rs-bg-surface,#1e1b4b);border:1px solid var(--rs-border,#334155);border-radius:12px;padding:1.5rem 2rem;max-width:380px;text-align:center;color:var(--rs-text-primary,#e2e8f0);font-family:system-ui,sans-serif;user-select:none";
const btnBase = "padding:0.5rem 1.25rem;border-radius:8px;cursor:pointer;font-size:0.875rem;touch-action:manipulation;user-select:none;-webkit-tap-highlight-color:transparent;transition:filter 0.1s"; const btnBase = "padding:0.5rem 1.25rem;border-radius:8px;cursor:pointer;font-size:0.875rem;touch-action:manipulation;user-select:none;-webkit-tap-highlight-color:transparent;transition:filter 0.1s";
dialog.innerHTML = ` dialog.innerHTML = `
<h3 style="margin:0 0 0.5rem;font-size:1.125rem">Delete ${count} elements?</h3> <h3 style="margin:0 0 0.5rem;font-size:1.125rem">Forget ${count} elements?</h3>
<p style="color:var(--rs-text-secondary,#94a3b8);font-size:0.875rem;margin:0 0 1.25rem;line-height:1.5">You are about to delete a large number of elements. This action cannot be easily undone.</p> <p style="color:var(--rs-text-secondary,#94a3b8);font-size:0.875rem;margin:0 0 1.25rem;line-height:1.5">These shapes will fade from your view. Others may still remember them. Shapes forgotten by everyone eventually dissolve.</p>
<div style="display:flex;gap:0.75rem;justify-content:center"> <div style="display:flex;gap:0.75rem;justify-content:center">
<button id="bulk-delete-cancel" style="${btnBase};border:1px solid var(--rs-border,#334155);background:transparent;color:var(--rs-text-primary,#e2e8f0)">Cancel</button> <button id="bulk-delete-cancel" style="${btnBase};border:1px solid var(--rs-border,#334155);background:transparent;color:var(--rs-text-primary,#e2e8f0)">Cancel</button>
<button id="bulk-delete-confirm" style="${btnBase};border:1px solid #dc2626;background:#dc2626;color:#fff;font-weight:600">DELETE</button> <button id="bulk-delete-confirm" style="${btnBase};border:1px solid #dc2626;background:#dc2626;color:#fff;font-weight:600">FORGET</button>
</div>`; </div>`;
overlay.appendChild(dialog); overlay.appendChild(dialog);
document.body.appendChild(overlay); document.body.appendChild(overlay);
@ -6904,6 +6963,8 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
} }
function toggleFeedMode() { function toggleFeedMode() {
// Exit memory mode first if active
if (memoryMode) toggleMemoryGraph();
feedMode = !feedMode; feedMode = !feedMode;
canvas.classList.toggle('feed-mode', feedMode); canvas.classList.toggle('feed-mode', feedMode);
feedToggleBtn.classList.toggle('active', feedMode); feedToggleBtn.classList.toggle('active', feedMode);
@ -6949,6 +7010,171 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
if (feedMode) sortFeedShapes(feedSortKey); if (feedMode) sortFeedShapes(feedSortKey);
}); });
// ── Collective Memory graph view ──
const memoryGraphBtn = document.getElementById('memory-graph-toggle');
let memoryMode = false;
function toggleMemoryGraph() {
// Exit feed mode first if active
if (feedMode) toggleFeedMode();
memoryMode = !memoryMode;
canvas.classList.toggle('memory-mode', memoryMode);
memoryGraphBtn.classList.toggle('active', memoryMode);
if (memoryMode) {
renderMemoryGraph();
} else {
const overlay = document.getElementById('memory-graph-overlay');
if (overlay) overlay.remove();
const title = document.getElementById('memory-graph-title');
if (title) title.remove();
}
}
function renderMemoryGraph() {
// Remove previous
const old = document.getElementById('memory-graph-overlay');
if (old) old.remove();
const oldTitle = document.getElementById('memory-graph-title');
if (oldTitle) oldTitle.remove();
const shapes = sync.doc.shapes || {};
const members = sync.doc.members || {};
const totalMembers = Math.max(Object.keys(members).length, 1);
// Build nodes from non-deleted shapes
const nodes = [];
for (const [id, shape] of Object.entries(shapes)) {
if (shape.deleted) continue;
const fb = shape.forgottenBy;
const forgottenCount = (fb && typeof fb === 'object') ? Object.keys(fb).length : 0;
const ratio = (totalMembers - forgottenCount) / totalMembers;
nodes.push({
id,
type: shape.type || 'shape',
label: getShapeLabel(shape),
icon: SHAPE_ICONS[shape.type] || '📦',
ratio: Math.max(ratio, 0),
forgottenCount,
totalMembers,
});
}
if (nodes.length === 0) {
memoryMode = false;
canvas.classList.remove('memory-mode');
memoryGraphBtn.classList.remove('active');
return;
}
const w = canvas.clientWidth;
const h = canvas.clientHeight;
const cx = w / 2;
const cy = h / 2;
const minR = 12, maxR = 80;
// Assign radii and initial positions
for (const n of nodes) {
n.r = minR + (maxR - minR) * n.ratio;
n.x = cx + (Math.random() - 0.5) * w * 0.6;
n.y = cy + (Math.random() - 0.5) * h * 0.6;
n.vx = 0;
n.vy = 0;
}
// Simple force simulation: center gravity + collision
for (let iter = 0; iter < 60; iter++) {
for (const n of nodes) {
// Center gravity — stronger for more-remembered shapes
const dx = cx - n.x;
const dy = cy - n.y;
const pull = 0.02 * n.ratio;
n.vx += dx * pull;
n.vy += dy * pull;
}
// Collision avoidance
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
let ddx = b.x - a.x;
let ddy = b.y - a.y;
const dist = Math.sqrt(ddx * ddx + ddy * ddy) || 1;
const minDist = a.r + b.r + 4;
if (dist < minDist) {
const push = (minDist - dist) / dist * 0.3;
const px = ddx * push;
const py = ddy * push;
a.vx -= px; a.vy -= py;
b.vx += px; b.vy += py;
}
}
}
// Apply velocity with damping
for (const n of nodes) {
n.x += n.vx;
n.y += n.vy;
n.vx *= 0.6;
n.vy *= 0.6;
// Clamp to viewport
n.x = Math.max(n.r + 8, Math.min(w - n.r - 8, n.x));
n.y = Math.max(n.r + 100, Math.min(h - n.r - 8, n.y));
}
}
// Color: green (120) → amber (40) → red (0)
function ratioToColor(ratio, opacity) {
const hue = ratio * 120; // 0=red, 60=amber, 120=green
return `hsla(${hue}, 70%, 45%, ${opacity})`;
}
// Render SVG
const overlay = document.createElement('div');
overlay.id = 'memory-graph-overlay';
let svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">`;
for (const n of nodes) {
const opacity = Math.max(0.2, n.ratio);
const fill = ratioToColor(n.ratio, opacity);
const truncLabel = n.label.length > 16 ? n.label.slice(0, 14) + '…' : n.label;
const fontSize = Math.max(9, Math.min(14, n.r * 0.35));
const tooltipText = `${n.label} (${n.type}) — forgotten by ${n.forgottenCount} of ${n.totalMembers}`;
svg += `<g class="memory-bubble" data-shape-id="${n.id}">`;
svg += `<title>${escapeHtml(tooltipText)}</title>`;
svg += `<circle cx="${n.x}" cy="${n.y}" r="${n.r}" fill="${fill}" stroke="rgba(255,255,255,0.15)" stroke-width="1"/>`;
svg += `<text x="${n.x}" y="${n.y - fontSize * 0.4}" font-size="${fontSize + 2}">${n.icon}</text>`;
svg += `<text x="${n.x}" y="${n.y + fontSize * 0.9}" font-size="${fontSize}" opacity="${opacity}">${escapeHtml(truncLabel)}</text>`;
svg += `</g>`;
}
svg += `</svg>`;
overlay.innerHTML = svg;
canvas.appendChild(overlay);
// Title
const titleEl = document.createElement('div');
titleEl.id = 'memory-graph-title';
titleEl.textContent = `Collective Memory — ${nodes.length} shapes across ${totalMembers} member${totalMembers !== 1 ? 's' : ''}`;
canvas.appendChild(titleEl);
// Click → exit memory mode and pan to shape
overlay.addEventListener('click', (e) => {
const bubble = e.target.closest('.memory-bubble');
if (!bubble) return;
const shapeId = bubble.dataset.shapeId;
toggleMemoryGraph(); // exit
// Pan canvas to center on the shape
const shapeData = (sync.doc.shapes || {})[shapeId];
if (shapeData && shapeData.x != null && shapeData.y != null) {
const rect = canvas.getBoundingClientRect();
const sw = shapeData.width || 300;
const sh = shapeData.height || 200;
panX = rect.width / 2 - (shapeData.x + sw / 2) * scale;
panY = rect.height / 2 - (shapeData.y + sh / 2) * scale;
updateCanvasTransform();
}
});
}
memoryGraphBtn.addEventListener('click', toggleMemoryGraph);
sync.connect(wsUrl); sync.connect(wsUrl);
// Debug: expose sync for console inspection // Debug: expose sync for console inspection