feat(dashboard): /memories is now a real-data Parallax MSDF field

Every line is a REAL memory from api.memories.list, rendered as glowing MSDF:
content snippet | id[:8] | retention%. Depth=retrievalStrength, stroke
weight=retentionStrength — a memory near the forgetting horizon renders
dimmer and thinner. Cursor-reactive (inherits the Parallax field). ASCII-
sanitized (real content has em-dashes/curly quotes the atlas can't draw).
Zero DOM, zero hardcoded text. check 0 errors.

Verified live: renders real memory ids (8bf0bb5a, 9070cece, 6d54f085) and
real retention (100%, 100%, 89% — the 89% line visibly dimmer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sam Valladares 2026-07-09 13:56:40 -07:00
parent c12b21ae45
commit b65721c2f0

View file

@ -1,247 +1,199 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import ObservatoryCanvas from '$lib/components/ObservatoryCanvas.svelte';
import { api } from '$stores/api';
import type { Memory } from '$types';
import { NODE_TYPE_COLORS } from '$types';
import MemoryAuditTrail from '$lib/components/MemoryAuditTrail.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import AnimatedNumber from '$lib/components/AnimatedNumber.svelte';
import Dropdown, { type DropdownOption } from '$lib/components/Dropdown.svelte';
import Icon from '$lib/components/Icon.svelte';
import { reveal } from '$lib/actions/reveal';
import { spotlight } from '$lib/actions/interactions';
import type { ObservatoryEngine } from '$lib/observatory/engine';
import { rgb01 } from '$lib/observatory/cognitive-palette';
import { TextLayerPass, type TextLayerItem } from '$lib/observatory/text/text-layer';
type MemoryTextItem = TextLayerItem & { memoryId?: string };
const CYAN = [...rgb01('#22C7DE'), 1] satisfies [number, number, number, number];
const SCARLET = [...rgb01('#FF3B30'), 0.92] satisfies [number, number, number, number];
const MUTED = [...rgb01('#29F2A9'), 0.62] satisfies [number, number, number, number];
const MEMORY_LIMIT = 40;
const ROW_LIMIT = 36;
let hostEl: HTMLDivElement | null = $state(null);
let engineRef: ObservatoryEngine | null = null;
let textPass: TextLayerPass | null = null;
let cursorSmoothed: { x: number; y: number } | null = null;
let memories: Memory[] = $state([]);
let searchQuery = $state('');
let selectedType = $state('');
let selectedTag = $state('');
let minRetention = $state(0);
let total = $state(0);
let loading = $state(true);
let selectedMemory: Memory | null = $state(null);
// Which inner tab of the expanded card is active. Keyed by memory id so
// switching between cards remembers each one's last view independently.
let expandedTab: Record<string, 'content' | 'audit'> = $state({});
let debounceTimer: ReturnType<typeof setTimeout>;
let error: string | null = $state(null);
let activeRun: string | null = null;
onMount(() => loadMemories());
onMount(() => {
void loadMemories();
});
onDestroy(() => {
textPass?.dispose();
textPass = null;
engineRef = null;
});
async function handleReady(engine: ObservatoryEngine) {
engineRef = engine;
const pass = new TextLayerPass(engine);
textPass = pass;
await pass.init();
pass.setText(buildTextItems());
engine.addPass(pass);
engine.demoClock.reset();
}
async function loadMemories() {
loading = true;
error = null;
textPass?.setText(buildTextItems());
try {
const params: Record<string, string> = {};
if (searchQuery) params.q = searchQuery;
if (selectedType) params.node_type = selectedType;
if (selectedTag) params.tag = selectedTag;
if (minRetention > 0) params.min_retention = String(minRetention);
const res = await api.memories.list(params);
const res = await api.memories.list({ limit: String(MEMORY_LIMIT) });
memories = res.memories;
} catch {
total = res.total;
} catch (err) {
memories = [];
total = 0;
error = err instanceof Error ? err.message : 'UNKNOWN MEMORY FETCH ERROR';
} finally {
loading = false;
textPass?.setText(buildTextItems());
engineRef?.demoClock.reset();
}
}
function onSearch() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(loadMemories, 300);
function sanitizeAscii(value: string): string {
return value
.replace(/[\u2014\u2013]/g, '-')
.replace(/[\u2018\u2019]/g, "'")
.replace(/[\u201C\u201D]/g, '"')
.replace(/\u2026/g, '...')
.replace(/[^\x20-\x7E]/g, '?');
}
function retentionColor(r: number): string {
if (r > 0.7) return '#10b981';
if (r > 0.4) return '#f59e0b';
return '#ef4444';
function memoryLine(memory: Memory): string {
const snippet = sanitizeAscii(memory.content).replace(/\s+/g, ' ').trim().slice(0, 52);
return sanitizeAscii(
`${snippet} | ${memory.id.slice(0, 8)} | ${Math.round(memory.retentionStrength * 100)}%`
);
}
// Clear, labelled dropdown options replace the dead native <select>.
const typeOptions: DropdownOption[] = [
{ value: '', label: 'All types' },
{ value: 'fact', label: 'Fact', color: NODE_TYPE_COLORS.fact },
{ value: 'concept', label: 'Concept', color: NODE_TYPE_COLORS.concept },
{ value: 'event', label: 'Event', color: NODE_TYPE_COLORS.event },
{ value: 'person', label: 'Person', color: NODE_TYPE_COLORS.person },
{ value: 'place', label: 'Place', color: NODE_TYPE_COLORS.place },
{ value: 'note', label: 'Note', color: NODE_TYPE_COLORS.note },
{ value: 'pattern', label: 'Pattern', color: NODE_TYPE_COLORS.pattern },
{ value: 'decision', label: 'Decision', color: NODE_TYPE_COLORS.decision },
];
function clamp01(value: number): number {
return Math.min(1, Math.max(0, Number.isFinite(value) ? value : 0.5));
}
// Retention filter as a clear dropdown of thresholds (was a bare range slider).
const retentionOptions: DropdownOption[] = [
{ value: '0', label: 'Any retention' },
{ value: '0.3', label: '≥ 30% — fading & up' },
{ value: '0.5', label: '≥ 50% — half-strength' },
{ value: '0.7', label: '≥ 70% — well-retained' },
{ value: '0.9', label: '≥ 90% — core memories' },
];
let retentionChoice = $state('0');
function onRetentionChange(v: string) {
minRetention = parseFloat(v);
loadMemories();
function statusItem(text: string, color = MUTED): MemoryTextItem {
return {
id: 'memories:status',
kind: 'memory-status',
text: sanitizeAscii(text),
x: -0.58,
y: 0.02,
size: 0.044,
color,
depth: 0.78,
weight: 0.62,
revealSpan: 32,
maxWidthEm: 50
};
}
function buildTextItems(): MemoryTextItem[] {
if (loading) return [statusItem('LOADING MEMORY FIELD...', CYAN)];
if (error) return [statusItem(`ERROR - ${error}`.slice(0, 72), SCARLET)];
if (memories.length === 0) return [statusItem('EMPTY MEMORY FIELD', MUTED)];
const rows = memories.slice(0, ROW_LIMIT);
const top = 0.72;
const rowStep = 1.5 / Math.max(1, ROW_LIMIT - 1);
return rows.map((memory, i) => {
const retrieval = clamp01(memory.retrievalStrength);
const retention = clamp01(memory.retentionStrength);
return {
id: `mem:${memory.id}`,
kind: 'memory',
memoryId: memory.id,
text: memoryLine(memory),
x: -0.88,
y: top - i * rowStep,
size: 0.026,
color: CYAN,
depth: retrieval,
weight: retention,
startFrame: i * 2,
revealSpan: 20,
maxWidthEm: 46
};
});
}
function pointerToNdc(e: PointerEvent | MouseEvent): { x: number; y: number } | null {
if (!hostEl) return null;
const rect = hostEl.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
return {
x: ((e.clientX - rect.left) / rect.width) * 2 - 1,
y: -(((e.clientY - rect.top) / rect.height) * 2 - 1)
};
}
function writeCursorLens(ndc: { x: number; y: number }) {
if (!hostEl || !engineRef) return;
const rect = hostEl.getBoundingClientRect();
const aspect = Math.max(0.0001, rect.width / Math.max(1, rect.height));
const raw = { x: ndc.x * Math.max(aspect, 1), y: ndc.y / Math.min(aspect, 1) };
const prev = cursorSmoothed ?? raw;
const next = { x: prev.x + (raw.x - prev.x) * 0.35, y: prev.y + (raw.y - prev.y) * 0.35 };
cursorSmoothed = next;
engineRef.setCursorPreNdc(next.x, next.y, next.x - prev.x, next.y - prev.y);
}
function handlePointerMove(e: PointerEvent) {
const ndc = pointerToNdc(e);
if (!ndc) return;
writeCursorLens(ndc);
const hit = textPass?.pickAt(ndc.x, ndc.y) ?? null;
const nextRun = hit?.kind === 'memory' ? hit.id : null;
if (nextRun !== activeRun) {
activeRun = nextRun;
textPass?.setRunDepth(nextRun, 1);
}
if (hostEl) hostEl.style.cursor = nextRun ? 'crosshair' : 'default';
}
function handlePointerLeave() {
cursorSmoothed = null;
activeRun = null;
engineRef?.setCursorPreNdc(999, 999, 0, 0);
textPass?.setRunDepth(null);
if (hostEl) hostEl.style.cursor = 'default';
}
async function handlePointerDown(e: PointerEvent) {
const ndc = pointerToNdc(e);
if (!ndc || !textPass) return;
const hit = textPass.pickAt(ndc.x, ndc.y);
if (hit?.kind !== 'memory') return;
const item = hit.payload as MemoryTextItem;
if (!item.memoryId) return;
try {
const promoted = await api.memories.promote(item.memoryId);
memories = memories.map((memory) => (memory.id === promoted.id ? promoted : memory));
textPass.setText(buildTextItems());
} catch (err) {
error = err instanceof Error ? err.message : 'UNKNOWN MEMORY PROMOTE ERROR';
textPass.setText(buildTextItems());
}
}
</script>
<div class="p-6 max-w-6xl mx-auto space-y-6">
<PageHeader
icon="memories"
title="Memories"
subtitle="Search, filter, and curate everything Vestige remembers"
accent="memory"
>
<span class="text-dim text-sm tabular-nums">
<AnimatedNumber value={memories.length} /> results
</span>
</PageHeader>
<svelte:head>
<title>Memories · Vestige</title>
</svelte:head>
<!-- Search & Filters -->
<div class="flex gap-3 flex-wrap items-end enter">
<div class="relative flex-1 min-w-64">
<span class="absolute left-3.5 top-1/2 -translate-y-1/2 text-muted pointer-events-none">
<Icon name="search" size={16} />
</span>
<input
type="text"
placeholder="Search memories… (press / to focus)"
bind:value={searchQuery}
oninput={onSearch}
class="w-full pl-10 pr-4 py-2.5 bg-white/[0.03] border border-synapse/10 rounded-xl text-text text-sm
placeholder:text-muted focus:outline-none focus:border-synapse/40 focus:ring-2 focus:ring-synapse/15 transition backdrop-blur-sm"
/>
</div>
<Dropdown
options={typeOptions}
bind:value={selectedType}
label="Type"
icon="filter"
placeholder="All types"
onChange={loadMemories}
/>
<Dropdown
options={retentionOptions}
bind:value={retentionChoice}
label="Retention"
icon="pulse"
onChange={onRetentionChange}
/>
</div>
<!-- Memory grid -->
{#if loading}
<div class="grid gap-3">
{#each Array(8) as _}
<div class="h-24 glass-subtle rounded-xl shimmer"></div>
{/each}
</div>
{:else if memories.length === 0}
<div class="enter flex flex-col items-center justify-center text-center py-20 gap-4">
<div class="text-dim opacity-60 breathe"><Icon name="memories" size={48} strokeWidth={1.2} /></div>
<p class="text-dim text-sm max-w-sm">
{searchQuery || selectedType || minRetention > 0
? 'No memories match these filters yet. Try widening your search.'
: 'No memories yet — once Vestige starts remembering, they will surface here, alive and searchable.'}
</p>
</div>
{:else}
<div class="grid gap-3">
{#each memories as memory, i (memory.id)}
<button
use:reveal={{ delay: Math.min(i * 35, 350), y: 12 }}
use:spotlight
onclick={() => selectedMemory = selectedMemory?.id === memory.id ? null : memory}
class="spotlight-surface text-left p-4 glass-subtle rounded-xl hover:bg-white/[0.04]
transition-all duration-200 group
{selectedMemory?.id === memory.id ? '!border-synapse/40 glow-synapse live-border' : ''}"
>
<div class="relative z-[1] flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<span class="w-2 h-2 rounded-full" style="background: {NODE_TYPE_COLORS[memory.nodeType] || '#8B95A5'}; box-shadow: 0 0 6px {NODE_TYPE_COLORS[memory.nodeType] || '#8B95A5'}99"></span>
<span class="text-xs text-dim capitalize">{memory.nodeType}</span>
{#each memory.tags.slice(0, 3) as tag}
<span class="text-xs px-1.5 py-0.5 bg-white/[0.04] rounded text-muted">{tag}</span>
{/each}
</div>
<p class="text-sm text-text leading-relaxed line-clamp-2">{memory.content}</p>
</div>
<div class="flex flex-col items-end gap-1 flex-shrink-0">
<div class="w-12 h-1.5 bg-deep rounded-full overflow-hidden">
<div class="h-full rounded-full" style="width: {memory.retentionStrength * 100}%; background: {retentionColor(memory.retentionStrength)}"></div>
</div>
<span class="text-xs text-muted">{(memory.retentionStrength * 100).toFixed(0)}%</span>
</div>
</div>
{#if selectedMemory?.id === memory.id}
{@const activeTab = expandedTab[memory.id] ?? 'content'}
<div class="relative z-[1] mt-4 pt-4 border-t border-synapse/10 space-y-3">
<!-- Inner tab switcher: Content (default) vs Audit Trail. -->
<div class="flex gap-1 text-[11px] uppercase tracking-wider">
<span
role="button"
tabindex="0"
onclick={(e) => { e.stopPropagation(); expandedTab[memory.id] = 'content'; }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); expandedTab[memory.id] = 'content'; } }}
class="px-3 py-1.5 rounded-lg cursor-pointer select-none transition
{activeTab === 'content' ? 'bg-synapse/20 text-synapse-glow border border-synapse/40' : 'bg-white/[0.03] text-dim hover:text-text border border-transparent'}"
>Content</span>
<span
role="button"
tabindex="0"
onclick={(e) => { e.stopPropagation(); expandedTab[memory.id] = 'audit'; }}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); expandedTab[memory.id] = 'audit'; } }}
class="px-3 py-1.5 rounded-lg cursor-pointer select-none transition
{activeTab === 'audit' ? 'bg-synapse/20 text-synapse-glow border border-synapse/40' : 'bg-white/[0.03] text-dim hover:text-text border border-transparent'}"
>Audit Trail</span>
</div>
{#if activeTab === 'content'}
<p class="text-sm text-text whitespace-pre-wrap">{memory.content}</p>
<div class="grid grid-cols-3 gap-3 text-xs text-dim">
<div>Storage: {(memory.storageStrength * 100).toFixed(1)}%</div>
<div>Retrieval: {(memory.retrievalStrength * 100).toFixed(1)}%</div>
<div>Created: {new Date(memory.createdAt).toLocaleDateString()}</div>
</div>
{:else}
<div
role="presentation"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
>
<MemoryAuditTrail memoryId={memory.id} />
</div>
{/if}
<div class="flex gap-2">
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.promote(memory.id); }}
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.promote(memory.id); } }}
class="px-3 py-1.5 bg-recall/20 text-recall text-xs rounded-lg hover:bg-recall/30 cursor-pointer select-none">Promote</span>
<span role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); api.memories.demote(memory.id); }}
onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); api.memories.demote(memory.id); } }}
class="px-3 py-1.5 bg-decay/20 text-decay text-xs rounded-lg hover:bg-decay/30 cursor-pointer select-none">Demote</span>
<!-- v2.0.7: suppress (active forgetting). Distinct from delete: the memory
persists but is inhibited from retrieval and actively decays. Each click
compounds. Graph plays the violet implosion via MemorySuppressed event. -->
<span role="button" tabindex="0"
onclick={async (e) => {
e.stopPropagation();
await api.memories.suppress(memory.id, 'dashboard trigger');
}}
onkeydown={async (e) => {
if (e.key === 'Enter') {
e.stopPropagation();
await api.memories.suppress(memory.id, 'dashboard trigger');
}
}}
title="Top-down inhibition (Anderson 2025). Compounds. Reversible for 24h."
class="px-3 py-1.5 bg-purple-500/20 text-purple-400 text-xs rounded-lg hover:bg-purple-500/30 cursor-pointer select-none">Suppress</span>
<span role="button" tabindex="0" onclick={async (e) => { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); }}
onkeydown={async (e) => { if (e.key === 'Enter') { e.stopPropagation(); await api.memories.delete(memory.id); loadMemories(); } }}
class="px-3 py-1.5 bg-decay/10 text-decay/60 text-xs rounded-lg hover:bg-decay/20 ml-auto cursor-pointer select-none">Delete</span>
</div>
</div>
{/if}
</button>
{/each}
</div>
{/if}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div bind:this={hostEl} class="fixed inset-0 bg-[#020307]" onpointerdown={handlePointerDown} onpointermove={handlePointerMove} onpointerleave={handlePointerLeave}>
<ObservatoryCanvas demo="recall-path" seed={`real-memory-field:${total}`} onready={handleReady} />
</div>